Repository: locustio/locust

Issue:
Pacing and RPS trouble
### Prerequisites

- [x] I am using [the latest version of Locust](https://github.com/locustio/locust/releases/)
- [X] I am reporting a bug, not asking a question

### Description

# Pacing problem

Hello!

I faced with the problem of a sharp RPS decrease for some time after reaching the maximum number of users for the test when using a wait_time = constant_pacing(30).

<img width="1223" alt="Снимок экрана 2023-10-19 в 18 31 24" src="https://github.com/locustio/locust/assets/32510114/7eada9cc-184b-4daf-a3ed-d731c0b6d1df">
<img width="1369" alt="Снимок экрана 2023-10-19 в 18 21 58 2" src="https://github.com/locustio/locust/assets/32510114/7bc02844-69a2-4e36-84f1-91cfe3940d13">
<img width="1389" alt="Снимок экрана 2023-10-19 в 18 56 19" src="https://github.com/locustio/locust/assets/32510114/ec4ec0f6-7fbf-49bb-83af-05703c7a55a2">
<img width="1381" alt="Снимок экрана 2023-10-19 в 19 01 50" src="https://github.com/locustio/locust/assets/32510114/984a388a-b634-4a99-861a-9462cd5be488">




## How to reproduce the problem:

1) there is a target RPS for a specific request (TEST in my case) equal to 100 requests/sec (6000 requests per minute);
2) load step = 30 seconds -> intensity = 2 scenarios per minute;
3) request response time = random value from 1 to 5 seconds;
4) required number of VU = 6000 / 2 = 3000.

### Additionally: 
- Locust 2.17.0;
- test duration = 10 minutes;
- use locust-plugins to send metrics to TimescaleDB.

I created a test locust script, launched it, and after reaching all 3000 VU in 30 seconds, I get a sharp drop in RPS and then the same quick return to the target intensity. In this test case, everything may not look so critical, given the return to the desired RPS, but in real tests with a large load step this causes problems due to the longer “hole” in RPS. 

## Expectations
I expect locust not to make such RPS drops after reaching needed VU value.

#### Example from JMeter
For example, the same test in JMeter doesn't have the same problem.
<img width="1353" alt="Снимок экрана 2023-10-19 в 19 05 23" src="https://github.com/locustio/locust/assets/32510114/e1fc752d-00db-4cf9-b946-45b633acc8e9">




### Command line

-m locust --timescale --pguser=postgres --pgpassword=password --class-picker -f

### Locustfile contents

```python3
import random
import time

import locust
from locust import User, task, constant_pacing, LoadTestShape, run_single_user
import locust_plugins


class TestUser(User):
    # pacing = 30 seconds
    # target RPS = 100
    # required number of virtual users = 3000
    wait_time = constant_pacing(30)

    @task
    def task(self):
        request_start_time = time.time()
        time.sleep(random.randint(1, 5))  # to imitate respose time from 1 to 5 seconds
        processing_time = int((time.time() - request_start_time) * 1000)
        locust.events.request.fire(
            request_type="POST",
            name='TEST',
            response_time=processing_time,
            response_length=0,
            context=None,
            exception=None,
        )


class StagesShape(LoadTestShape):
    stages = [
        {"duration": 600, "users": 3000, "spawn_rate": 100, "user_classes": [TestUser]},
    ]

    def tick(self):
        run_time = self.get_run_time()

        for stage in self.stages:
            if run_time < stage["duration"]:
                try:
                    tick_data = (stage["users"], stage["spawn_rate"], stage["user_classes"])
                except:
                    tick_data = (stage["users"], stage["spawn_rate"])
                return tick_data

        return None


if __name__ == "__main__":
    run_single_user(TestUser)
```


### Python version

3.11.0

### Locust version

2.17.0

### Operating system

macOS Ventura 13.4.1 (c) (22F770820d)


Discussion:
Interesting. I can reproduce this, without --timescale, and at lower ramp up rates too. It is much less noticeable when users have time to run more than once before rampup is finished, but it is still there.

I think there is a bug in the logic for `constant_pacing` _for the first time it sleeps_. I think it implicitly assumes that the first run was instantaneous. It should definitely be fixable but needs some rethinking, because the wait_time function doesnt really get called until _after_ the first iteration.

Resolving patch:
diff --git a/locust/user/users.py b/locust/user/users.py
--- a/locust/user/users.py
+++ b/locust/user/users.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 from typing import Callable, Dict, List, Optional, final
 
+import time
 from gevent import GreenletExit, greenlet
 from gevent.pool import Group
 from urllib3 import PoolManager
@@ -119,6 +120,7 @@ def __init__(self, environment):
         self._greenlet: greenlet.Greenlet = None
         self._group: Group
         self._taskset_instance: TaskSet = None
+        self._cp_last_run = time.time()  # used by constant_pacing wait_time
 
     def on_start(self):
         """
diff --git a/locust/user/wait_time.py b/locust/user/wait_time.py
--- a/locust/user/wait_time.py
+++ b/locust/user/wait_time.py
@@ -47,15 +47,12 @@ def my_task(self):
     """
 
     def wait_time_func(self):
-        if not hasattr(self, "_cp_last_run"):
-            self._cp_last_wait_time = wait_time
-            self._cp_last_run = time()
-            return wait_time
-        else:
-            run_time = time() - self._cp_last_run - self._cp_last_wait_time
-            self._cp_last_wait_time = max(0, wait_time - run_time)
-            self._cp_last_run = time()
-            return self._cp_last_wait_time
+        if not hasattr(self, "_cp_last_wait_time"):
+            self._cp_last_wait_time = 0
+        run_time = time() - self._cp_last_run - self._cp_last_wait_time
+        self._cp_last_wait_time = max(0, wait_time - run_time)
+        self._cp_last_run = time()
+        return self._cp_last_wait_time
 
     return wait_time_func
 


Test patch: