Repository: locustio/locust

Issue:
Fail to spawn fixed users when run with DoubleWave load shape
<!-- 
If you have a general question about how to use Locust, please check Stack Overflow first https://stackoverflow.com/questions/tagged/locust

You can also ask new questions on SO, https://stackoverflow.com/questions/ask just remember to tag your question with "locust". Do not immediately post your issue here after posting to SO, wait for an answer there instead.

Use this form only for reporting actual bugs in Locust. Remember, the developers of Locust are unpaid volunteers, so make sure you have tried everything you can think of before filing a bug :) 

Always make sure you are running an up to date Locust version (pip3 install -U locust)
-->

### Describe the bug
<!-- A clear and concise description of what the bug is -->
When running DoubleWave load shape with both fix count user and spawn users, during spawning new users, locust will first check the fixed user count using blow codes([dispatch.py](https://github.com/locustio/locust/blob/master/locust/dispatch.py))
```
              while len(spawned_classes) != len(fixed_users):
                    user_name: Optional[str] = next(cycle_fixed_gen)
                    if not user_name:
                        break

                    if current_fixed_users_count[user_name] < fixed_users[user_name].fixed_count:
                        current_fixed_users_count[user_name] += 1
                        if current_fixed_users_count[user_name] == fixed_users[user_name].fixed_count:
                            spawned_classes.add(user_name)
                        yield user_name

                        # 'self._try_dispatch_fixed' was changed outhere,  we have to recalculate current count
                        if self._try_dispatch_fixed:
                            current_fixed_users_count = {u: self._get_user_current_count(u) for u in fixed_users}
                            spawned_classes.clear()
                            self._try_dispatch_fixed = False
```
If `current_fixed_users_count[user_name] >= fixed_users[user_name].fixed_count:`, it will start loop with below warning:
CPU usage above 90%! This may constrain your throughput and may even give inconsistent response time measurements!

### Expected behavior
<!-- Tell us what you think should happen -->
Locust should spawn DoubleWave load shape with fixed count users.
![屏幕截图 2023-05-16 140343](https://github.com/locustio/locust/assets/92465777/4ae28783-eef7-4b9a-9777-3a6b7f24ca49)

### Actual behavior
<!-- Tell us what happens instead. Include screenshots if this an issue with the GUI. -->

Locust will get stuck when spawning new users if the current fixed user count is equal expected fixed user count.
![屏幕截图 2023-05-16 135920](https://github.com/locustio/locust/assets/92465777/5d375eec-2369-4193-a1bf-4bc15ed43b37)

### Steps to reproduce
<!-- Please provide a minimal reproducible code example (https://stackoverflow.com/help/minimal-reproducible-example) --> 

locust -f locustfile.py --host https://www.bing.com

```
import time
from locust import HttpUser, task
from locust import constant_throughput
from locust import LoadTestShape
import math


class AdminUser(HttpUser):
    wait_time = constant_throughput(1)
    fixed_count = 2

    @task
    def my_task(self):
        self.client.get("", name="admin user")
        time.sleep(1)


class WebUser(HttpUser):
    weight = 3

    @task
    def my_task(self):
        self.client.get("", name="web user")
        time.sleep(1)


class MobileUser(HttpUser):
    weight = 1

    @task
    def my_task(self):
        self.client.get("", name="mobile user")
        time.sleep(1)


class DoubleWave(LoadTestShape):
    min_users = 2
    peak_one_users = 4
    peak_two_users = 6
    time_limit = 150

    def tick(self):
        run_time = round(self.get_run_time())

        if run_time < self.time_limit:
            user_count = (
                (self.peak_one_users - self.min_users)
                * math.e ** -(((run_time / (self.time_limit / 10 * 2 / 3)) - 5) ** 2)
                + (self.peak_two_users - self.min_users)
                * math.e ** -(((run_time / (self.time_limit / 10 * 2 / 3)) - 10) ** 2)
                + self.min_users
            )
            return (round(user_count), round(user_count))
        else:
            return None

```

### Environment

- OS:  WSL2
- Python version: Python 3.10.11
- Locust version: Locust 2.15.1
- Locust command line that you ran: locust -f locustfile.py --host https://www.bing.com
- Locust file contents 



Discussion:
@EzR1d3r I think this is ”your” code? Could you have a look?
> @EzR1d3r I think this is ”your” code? Could you have a look?

Ok, I will when have enought time, may be at weekend.
I have repeated the bug. I guess I know how to fix, but I have to check.
@TC-zpei could you check pls the branch https://github.com/locustio/locust/pull/2350, it should work.
@EzR1d3r I have checked out the fixed code from #2350, and it is working as expected. Thank you for the quick fix.
> @EzR1d3r I have checked out the fixed code from #2350, and it is working as expected. Thank you for the quick fix.

Good. I also want to write unit test for this case and then it will be ready to merge.
@cyberw I am trying to run tests locally, but I have the error.
I remember I was fighting against this last time.. but it should be simple? What I am doing wrong?
```
d:\Projects\locust\$ pytest ./locust/test/test_main.py
============ test session starts =============
platform win32 -- Python 3.10.8, pytest-7.3.1, pluggy-1.0.0
rootdir: D:\Projects\locust
collected 0 items / 1 error

=================== ERRORS ======================= 
_____________________ ERROR collecting locust/test/test_main.py ______________________ 
ImportError while importing test module 'D:\Projects\locust\locust\test\test_main.py'. 
Hint: make sure your test modules/packages have valid Python names.
Traceback:
C:\Python310\lib\importlib\__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
locust\__init__.py:13: in <module>
    from ._version import version as __version__
E   ModuleNotFoundError: No module named 'locust._version'
============================== short test summary info =============================== 
ERROR locust/test/test_main.py
!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!! 
================================== 1 error in 0.31s ==================================
```
I dont remember that issue. That file should be generated when doing pip install (e.g. `pip install -e .`). If it isnt then maybe just create the file manually :)
This is what mine looks like after installing:

```
# file generated by setuptools_scm
# don't change, don't track in version control
__version__ = version = '2.15.2.dev13'
__version_tuple__ = version_tuple = (2, 15, 2, 'dev13')
```
> pip install -e .

Thx, it works for test_dispatch.py. It is enought for me, but with test_main.py it raises an import error with termios module (on win). I am not sure, but it will work only on linux, or I am wrong?
On windows it is supposed to import win32console instead of termios. But never mind. Is the change ready for merge?

```
if os.name == "nt":
    from win32api import STD_INPUT_HANDLE
    from win32console import (
        GetStdHandle,
        KEY_EVENT,
        ENABLE_ECHO_INPUT,
        ENABLE_LINE_INPUT,
        ENABLE_PROCESSED_INPUT,
    )
else:
    import select
    import termios
    import tty
```
> Is the change ready for merge?

No, I still didn't add the test for this case. I will mention when it be ready.

Resolving patch:
diff --git a/locust/dispatch.py b/locust/dispatch.py
--- a/locust/dispatch.py
+++ b/locust/dispatch.py
@@ -409,8 +409,6 @@ def infinite_cycle_gen(users: List[Tuple[Type[User], int]]) -> itertools.cycle:
 
                     if current_fixed_users_count[user_name] < fixed_users[user_name].fixed_count:
                         current_fixed_users_count[user_name] += 1
-                        if current_fixed_users_count[user_name] == fixed_users[user_name].fixed_count:
-                            spawned_classes.add(user_name)
                         yield user_name
 
                         # 'self._try_dispatch_fixed' was changed outhere,  we have to recalculate current count
@@ -418,6 +416,8 @@ def infinite_cycle_gen(users: List[Tuple[Type[User], int]]) -> itertools.cycle:
                             current_fixed_users_count = {u: self._get_user_current_count(u) for u in fixed_users}
                             spawned_classes.clear()
                             self._try_dispatch_fixed = False
+                    else:
+                        spawned_classes.add(user_name)
 
             yield next(cycle_weighted_gen)
 


Test patch:
diff --git a/locust/test/test_dispatch.py b/locust/test/test_dispatch.py
--- a/locust/test/test_dispatch.py
+++ b/locust/test/test_dispatch.py
@@ -3546,7 +3546,37 @@ class User2(User):
         iterations = list(users_dispatcher)
         self.assertDictEqual(iterations[-1]["1"], {"User1": 50, "User2": 50})
 
-    def test_ramp_up_ramp_down_and_rump_up_again(self):
+    def test_ramp_up_ramp_down_and_ramp_up_again_single_fixed_class(self):
+        class User1(User):
+            fixed_count = 2
+
+        class User2(User):
+            weight = 1
+
+        class User3(User):
+            weight = 3
+
+        user_classes = [User1, User3, User2]
+        workers = [WorkerNode("1")]
+
+        users_dispatcher = UsersDispatcher(worker_nodes=workers, user_classes=user_classes)
+
+        users_dispatcher.new_dispatch(target_user_count=5, spawn_rate=1)
+        users_dispatcher._wait_between_dispatch = 0
+        iterations = list(users_dispatcher)
+        self.assertDictEqual(iterations[-1]["1"], {"User1": 2, "User2": 1, "User3": 2})
+
+        users_dispatcher.new_dispatch(target_user_count=2, spawn_rate=1)
+        users_dispatcher._wait_between_dispatch = 0
+        iterations = list(users_dispatcher)
+        self.assertDictEqual(iterations[-1]["1"], {"User1": 2, "User2": 0, "User3": 0})
+
+        users_dispatcher.new_dispatch(target_user_count=7, spawn_rate=1)
+        users_dispatcher._wait_between_dispatch = 0
+        iterations = list(users_dispatcher)
+        self.assertDictEqual(iterations[-1]["1"], {"User1": 2, "User2": 1, "User3": 4})
+
+    def test_ramp_up_ramp_down_and_ramp_up_again(self):
         for weights, fixed_counts in [
             [(1, 1, 1, 1, 1), (100, 100, 50, 50, 200)],
             [(1, 1, 1, 1, 1), (100, 150, 50, 50, 0)],
@@ -3600,6 +3630,7 @@ class User5(User):
                                 self.assertEqual(
                                     users_dispatcher._get_user_current_count(user_class.__name__),
                                     user_class.fixed_count,
+                                    msg=f"{user_class.__name__}, {target_user_count}",
                                 )
 
                         # Ramp-down to go to `down_to_count`