overload-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overload/__init__.py +3 -0
- overload/__main__.py +5 -0
- overload/cli.py +393 -0
- overload/collection/__init__.py +1 -0
- overload/collection/environment.py +23 -0
- overload/collection/models.py +88 -0
- overload/collection/parser.py +220 -0
- overload/collection/variables.py +84 -0
- overload/config_file.py +73 -0
- overload/engine/__init__.py +1 -0
- overload/engine/assertions.py +151 -0
- overload/engine/auth.py +87 -0
- overload/engine/events.py +50 -0
- overload/engine/http_client.py +274 -0
- overload/engine/load_patterns.py +730 -0
- overload/engine/models.py +254 -0
- overload/engine/rate_limiter.py +124 -0
- overload/engine/runner.py +86 -0
- overload/report/__init__.py +1 -0
- overload/report/exporters.py +77 -0
- overload/report/generator.py +71 -0
- overload/report/templates/report.html +369 -0
- overload/utils/__init__.py +1 -0
- overload/utils/naming.py +26 -0
- overload/web/__init__.py +1 -0
- overload/web/app.py +38 -0
- overload/web/routes/__init__.py +1 -0
- overload/web/routes/api.py +461 -0
- overload/web/routes/ws.py +77 -0
- overload/web/static/css/app.css +242 -0
- overload/web/static/js/app.js +241 -0
- overload/web/static/js/charts.js +385 -0
- overload/web/static/js/collection.js +344 -0
- overload/web/static/js/runner.js +625 -0
- overload/web/templates/index.html +23 -0
- overload_cli-0.1.0.dist-info/METADATA +267 -0
- overload_cli-0.1.0.dist-info/RECORD +40 -0
- overload_cli-0.1.0.dist-info/WHEEL +4 -0
- overload_cli-0.1.0.dist-info/entry_points.txt +2 -0
- overload_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Coroutine
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
from overload.collection.models import ParsedRequest
|
|
11
|
+
from overload.collection.variables import VariableContext
|
|
12
|
+
from overload.engine.http_client import HttpClient
|
|
13
|
+
from overload.engine.models import (
|
|
14
|
+
PatternConfig,
|
|
15
|
+
RequestDistribution,
|
|
16
|
+
RequestResult,
|
|
17
|
+
RunProgress,
|
|
18
|
+
Stats,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
ProgressCallback = Callable[[RunProgress], Coroutine[Any, Any, None]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LoadPattern(Protocol):
|
|
27
|
+
async def execute(
|
|
28
|
+
self,
|
|
29
|
+
client: HttpClient,
|
|
30
|
+
requests: list[ParsedRequest],
|
|
31
|
+
variables: VariableContext,
|
|
32
|
+
config: PatternConfig,
|
|
33
|
+
run_id: str,
|
|
34
|
+
cancel_event: asyncio.Event,
|
|
35
|
+
on_progress: ProgressCallback | None = None,
|
|
36
|
+
) -> list[RequestResult]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _pick_request(
|
|
40
|
+
requests: list[ParsedRequest],
|
|
41
|
+
index: int,
|
|
42
|
+
distribution: RequestDistribution,
|
|
43
|
+
) -> ParsedRequest:
|
|
44
|
+
if distribution == RequestDistribution.RANDOM:
|
|
45
|
+
return random.choice(requests)
|
|
46
|
+
return requests[index % len(requests)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _fire_one(
|
|
50
|
+
client: HttpClient,
|
|
51
|
+
request: ParsedRequest,
|
|
52
|
+
variables: VariableContext,
|
|
53
|
+
semaphore: asyncio.Semaphore,
|
|
54
|
+
) -> RequestResult:
|
|
55
|
+
async with semaphore:
|
|
56
|
+
return await client.execute(request, variables)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_last_emit_state: dict[str, tuple[int, float]] = {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _emit_progress(
|
|
63
|
+
callback: ProgressCallback | None,
|
|
64
|
+
run_id: str,
|
|
65
|
+
results: list[RequestResult],
|
|
66
|
+
total: int,
|
|
67
|
+
phase: str,
|
|
68
|
+
start_time: float,
|
|
69
|
+
) -> None:
|
|
70
|
+
if callback is None:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
elapsed = time.monotonic() - start_time
|
|
74
|
+
completed = len(results)
|
|
75
|
+
|
|
76
|
+
prev_count, prev_time = _last_emit_state.get(run_id, (0, start_time))
|
|
77
|
+
dt = time.monotonic() - prev_time
|
|
78
|
+
dr = completed - prev_count
|
|
79
|
+
instant_rps = round(dr / max(dt, 0.1), 1) if dt > 0.05 else 0.0
|
|
80
|
+
_last_emit_state[run_id] = (completed, time.monotonic())
|
|
81
|
+
|
|
82
|
+
status_codes: dict[int, int] = {}
|
|
83
|
+
total_latency = 0.0
|
|
84
|
+
error_count = 0
|
|
85
|
+
for r in results:
|
|
86
|
+
status_codes[r.status_code] = status_codes.get(r.status_code, 0) + 1
|
|
87
|
+
total_latency += r.latency_ms
|
|
88
|
+
if r.status_code < 200 or r.status_code >= 400:
|
|
89
|
+
error_count += 1
|
|
90
|
+
|
|
91
|
+
recent_slice = results[-20:] if results else []
|
|
92
|
+
base_idx = max(0, completed - len(recent_slice))
|
|
93
|
+
recent = [
|
|
94
|
+
{
|
|
95
|
+
"idx": base_idx + i,
|
|
96
|
+
"name": r.request_name,
|
|
97
|
+
"method": r.method,
|
|
98
|
+
"status": r.status_code,
|
|
99
|
+
"latency": round(r.latency_ms, 1),
|
|
100
|
+
"url": r.url[:100],
|
|
101
|
+
"error": r.error,
|
|
102
|
+
}
|
|
103
|
+
for i, r in enumerate(recent_slice)
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
await callback(RunProgress(
|
|
108
|
+
run_id=run_id,
|
|
109
|
+
total_requests=total,
|
|
110
|
+
completed_requests=completed,
|
|
111
|
+
current_rps=instant_rps,
|
|
112
|
+
phase=phase,
|
|
113
|
+
elapsed_seconds=round(elapsed, 1),
|
|
114
|
+
error_count=error_count,
|
|
115
|
+
status_codes=status_codes,
|
|
116
|
+
avg_latency_ms=round(total_latency / max(completed, 1), 1),
|
|
117
|
+
recent_results=recent,
|
|
118
|
+
))
|
|
119
|
+
except Exception:
|
|
120
|
+
logger.exception("Error in progress callback")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _cancel_tasks(tasks: list[asyncio.Task]) -> None:
|
|
124
|
+
for t in tasks:
|
|
125
|
+
if not t.done():
|
|
126
|
+
t.cancel()
|
|
127
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class BurstPattern:
|
|
131
|
+
async def execute(
|
|
132
|
+
self,
|
|
133
|
+
client: HttpClient,
|
|
134
|
+
requests: list[ParsedRequest],
|
|
135
|
+
variables: VariableContext,
|
|
136
|
+
config: PatternConfig,
|
|
137
|
+
run_id: str,
|
|
138
|
+
cancel_event: asyncio.Event,
|
|
139
|
+
on_progress: ProgressCallback | None = None,
|
|
140
|
+
) -> list[RequestResult]:
|
|
141
|
+
n = config.total_requests
|
|
142
|
+
sem = asyncio.Semaphore(config.concurrency)
|
|
143
|
+
results: list[RequestResult] = []
|
|
144
|
+
start_time = time.monotonic()
|
|
145
|
+
|
|
146
|
+
logger.info("Burst: %d requests, concurrency=%d", n, config.concurrency)
|
|
147
|
+
|
|
148
|
+
await _emit_progress(on_progress, run_id, results, n, "Preparing burst...", start_time)
|
|
149
|
+
|
|
150
|
+
tasks = [
|
|
151
|
+
asyncio.create_task(
|
|
152
|
+
_fire_one(
|
|
153
|
+
client,
|
|
154
|
+
_pick_request(requests, i, config.distribution),
|
|
155
|
+
variables,
|
|
156
|
+
sem,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
for i in range(n)
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
await _emit_progress(on_progress, run_id, results, n, f"Firing {n} requests...", start_time)
|
|
163
|
+
|
|
164
|
+
progress_interval = max(n // 20, 1)
|
|
165
|
+
for i, coro in enumerate(asyncio.as_completed(tasks)):
|
|
166
|
+
if cancel_event.is_set():
|
|
167
|
+
await _cancel_tasks(tasks)
|
|
168
|
+
break
|
|
169
|
+
result = await coro
|
|
170
|
+
results.append(result)
|
|
171
|
+
if (i + 1) % progress_interval == 0 or i == n - 1:
|
|
172
|
+
await _emit_progress(on_progress, run_id, results, n, "running", start_time)
|
|
173
|
+
|
|
174
|
+
return results
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class RampPattern:
|
|
178
|
+
async def execute(
|
|
179
|
+
self,
|
|
180
|
+
client: HttpClient,
|
|
181
|
+
requests: list[ParsedRequest],
|
|
182
|
+
variables: VariableContext,
|
|
183
|
+
config: PatternConfig,
|
|
184
|
+
run_id: str,
|
|
185
|
+
cancel_event: asyncio.Event,
|
|
186
|
+
on_progress: ProgressCallback | None = None,
|
|
187
|
+
) -> list[RequestResult]:
|
|
188
|
+
start_rps = config.ramp_start_rps
|
|
189
|
+
end_rps = config.ramp_end_rps
|
|
190
|
+
step = config.step_rps
|
|
191
|
+
step_dur = config.step_duration_seconds
|
|
192
|
+
sem = asyncio.Semaphore(config.concurrency)
|
|
193
|
+
all_results: list[RequestResult] = []
|
|
194
|
+
start_time = time.monotonic()
|
|
195
|
+
request_idx = 0
|
|
196
|
+
|
|
197
|
+
total_estimate = sum(
|
|
198
|
+
rps * step_dur
|
|
199
|
+
for rps in range(start_rps, end_rps + 1, step)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
logger.info("Ramp: %d -> %d req/s, step=%d, step_duration=%ds", start_rps, end_rps, step, step_dur)
|
|
203
|
+
|
|
204
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, "Preparing ramp test...", start_time)
|
|
205
|
+
|
|
206
|
+
for rps in range(start_rps, end_rps + 1, step):
|
|
207
|
+
if cancel_event.is_set():
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
phase = f"Ramping: {rps} req/s"
|
|
211
|
+
interval = 1.0 / rps
|
|
212
|
+
batch_tasks: list[asyncio.Task] = []
|
|
213
|
+
batch_start = time.monotonic()
|
|
214
|
+
|
|
215
|
+
for i in range(rps * step_dur):
|
|
216
|
+
if cancel_event.is_set():
|
|
217
|
+
break
|
|
218
|
+
delay = batch_start + i * interval - time.monotonic()
|
|
219
|
+
if delay > 0:
|
|
220
|
+
await asyncio.sleep(delay)
|
|
221
|
+
req = _pick_request(requests, request_idx, config.distribution)
|
|
222
|
+
request_idx += 1
|
|
223
|
+
batch_tasks.append(
|
|
224
|
+
asyncio.create_task(_fire_one(client, req, variables, sem))
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if cancel_event.is_set():
|
|
228
|
+
await _cancel_tasks(batch_tasks)
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
|
|
232
|
+
for r in batch_results:
|
|
233
|
+
if isinstance(r, RequestResult):
|
|
234
|
+
all_results.append(r)
|
|
235
|
+
|
|
236
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
|
|
237
|
+
|
|
238
|
+
return all_results
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class LoadTestPattern:
|
|
242
|
+
async def execute(
|
|
243
|
+
self,
|
|
244
|
+
client: HttpClient,
|
|
245
|
+
requests: list[ParsedRequest],
|
|
246
|
+
variables: VariableContext,
|
|
247
|
+
config: PatternConfig,
|
|
248
|
+
run_id: str,
|
|
249
|
+
cancel_event: asyncio.Event,
|
|
250
|
+
on_progress: ProgressCallback | None = None,
|
|
251
|
+
) -> list[RequestResult]:
|
|
252
|
+
target_rps = config.target_rps
|
|
253
|
+
ramp_up = config.ramp_up_seconds
|
|
254
|
+
hold = config.hold_duration_seconds
|
|
255
|
+
ramp_down = config.ramp_down_seconds
|
|
256
|
+
sem = asyncio.Semaphore(config.concurrency)
|
|
257
|
+
all_results: list[RequestResult] = []
|
|
258
|
+
in_flight: list[asyncio.Task] = []
|
|
259
|
+
start_time = time.monotonic()
|
|
260
|
+
request_idx = 0
|
|
261
|
+
|
|
262
|
+
total_duration = ramp_up + hold + ramp_down
|
|
263
|
+
total_estimate = int(target_rps * (ramp_up / 2 + hold + ramp_down / 2))
|
|
264
|
+
|
|
265
|
+
logger.info(
|
|
266
|
+
"Load: target=%d req/s, ramp_up=%ds, hold=%ds, ramp_down=%ds",
|
|
267
|
+
target_rps, ramp_up, hold, ramp_down,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, "Preparing load test...", start_time)
|
|
271
|
+
|
|
272
|
+
async def _run_at_rps(rps: int, duration: float, phase: str) -> None:
|
|
273
|
+
nonlocal request_idx
|
|
274
|
+
if rps <= 0 or duration <= 0:
|
|
275
|
+
return
|
|
276
|
+
interval = 1.0 / rps
|
|
277
|
+
phase_start = time.monotonic()
|
|
278
|
+
i = 0
|
|
279
|
+
while time.monotonic() - phase_start < duration:
|
|
280
|
+
if cancel_event.is_set():
|
|
281
|
+
return
|
|
282
|
+
delay = phase_start + i * interval - time.monotonic()
|
|
283
|
+
if delay > 0:
|
|
284
|
+
await asyncio.sleep(delay)
|
|
285
|
+
req = _pick_request(requests, request_idx, config.distribution)
|
|
286
|
+
request_idx += 1
|
|
287
|
+
task = asyncio.create_task(_fire_one(client, req, variables, sem))
|
|
288
|
+
task.add_done_callback(lambda t: all_results.append(t.result()) if not t.cancelled() and t.exception() is None else None)
|
|
289
|
+
in_flight.append(task)
|
|
290
|
+
i += 1
|
|
291
|
+
if i % max(rps // 2, 1) == 0:
|
|
292
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
|
|
293
|
+
|
|
294
|
+
# Ramp up
|
|
295
|
+
if ramp_up > 0:
|
|
296
|
+
steps = max(ramp_up, 1)
|
|
297
|
+
for s in range(steps):
|
|
298
|
+
if cancel_event.is_set():
|
|
299
|
+
break
|
|
300
|
+
current_rps = max(1, int(target_rps * (s + 1) / steps))
|
|
301
|
+
await _run_at_rps(current_rps, 1.0, f"Ramping up: {current_rps} req/s")
|
|
302
|
+
|
|
303
|
+
# Hold
|
|
304
|
+
if not cancel_event.is_set():
|
|
305
|
+
await _run_at_rps(target_rps, hold, f"Holding at {target_rps} req/s")
|
|
306
|
+
|
|
307
|
+
# Ramp down
|
|
308
|
+
if ramp_down > 0 and not cancel_event.is_set():
|
|
309
|
+
steps = max(ramp_down, 1)
|
|
310
|
+
for s in range(steps):
|
|
311
|
+
if cancel_event.is_set():
|
|
312
|
+
break
|
|
313
|
+
current_rps = max(1, int(target_rps * (steps - s) / steps))
|
|
314
|
+
await _run_at_rps(current_rps, 1.0, f"Ramping down: {current_rps} req/s")
|
|
315
|
+
|
|
316
|
+
if cancel_event.is_set():
|
|
317
|
+
await _cancel_tasks(in_flight)
|
|
318
|
+
else:
|
|
319
|
+
pending = [t for t in in_flight if not t.done()]
|
|
320
|
+
if pending:
|
|
321
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
322
|
+
|
|
323
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, "complete", start_time)
|
|
324
|
+
return all_results
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class StressPattern:
|
|
328
|
+
async def execute(
|
|
329
|
+
self,
|
|
330
|
+
client: HttpClient,
|
|
331
|
+
requests: list[ParsedRequest],
|
|
332
|
+
variables: VariableContext,
|
|
333
|
+
config: PatternConfig,
|
|
334
|
+
run_id: str,
|
|
335
|
+
cancel_event: asyncio.Event,
|
|
336
|
+
on_progress: ProgressCallback | None = None,
|
|
337
|
+
) -> list[RequestResult]:
|
|
338
|
+
start_rps = config.start_rps
|
|
339
|
+
step = config.step_rps
|
|
340
|
+
step_dur = config.step_duration_seconds
|
|
341
|
+
max_rps = config.max_rps
|
|
342
|
+
failure_threshold = config.failure_threshold_pct / 100.0
|
|
343
|
+
sem = asyncio.Semaphore(config.concurrency)
|
|
344
|
+
all_results: list[RequestResult] = []
|
|
345
|
+
start_time = time.monotonic()
|
|
346
|
+
request_idx = 0
|
|
347
|
+
|
|
348
|
+
logger.info(
|
|
349
|
+
"Stress: start=%d, step=%d, max=%d, failure_threshold=%.0f%%",
|
|
350
|
+
start_rps, step, max_rps, config.failure_threshold_pct,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
await _emit_progress(on_progress, run_id, all_results, 0, "Preparing stress test...", start_time)
|
|
354
|
+
|
|
355
|
+
rps = start_rps
|
|
356
|
+
breaking_point = 0
|
|
357
|
+
|
|
358
|
+
while rps <= max_rps:
|
|
359
|
+
if cancel_event.is_set():
|
|
360
|
+
break
|
|
361
|
+
|
|
362
|
+
phase = f"Stress: {rps} req/s"
|
|
363
|
+
interval = 1.0 / rps
|
|
364
|
+
batch_tasks: list[asyncio.Task] = []
|
|
365
|
+
batch_start = time.monotonic()
|
|
366
|
+
|
|
367
|
+
for i in range(rps * step_dur):
|
|
368
|
+
if cancel_event.is_set():
|
|
369
|
+
break
|
|
370
|
+
delay = batch_start + i * interval - time.monotonic()
|
|
371
|
+
if delay > 0:
|
|
372
|
+
await asyncio.sleep(delay)
|
|
373
|
+
req = _pick_request(requests, request_idx, config.distribution)
|
|
374
|
+
request_idx += 1
|
|
375
|
+
batch_tasks.append(
|
|
376
|
+
asyncio.create_task(_fire_one(client, req, variables, sem))
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if cancel_event.is_set():
|
|
380
|
+
await _cancel_tasks(batch_tasks)
|
|
381
|
+
break
|
|
382
|
+
|
|
383
|
+
batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
|
|
384
|
+
step_results = [r for r in batch_results if isinstance(r, RequestResult)]
|
|
385
|
+
all_results.extend(step_results)
|
|
386
|
+
|
|
387
|
+
if step_results:
|
|
388
|
+
errors = sum(1 for r in step_results if r.status_code < 200 or r.status_code >= 400)
|
|
389
|
+
error_rate = errors / len(step_results)
|
|
390
|
+
logger.info("Stress step %d req/s: error_rate=%.1f%%", rps, error_rate * 100)
|
|
391
|
+
|
|
392
|
+
if error_rate >= failure_threshold:
|
|
393
|
+
breaking_point = rps
|
|
394
|
+
logger.info("Breaking point found at %d req/s (%.1f%% errors)", rps, error_rate * 100)
|
|
395
|
+
break
|
|
396
|
+
|
|
397
|
+
await _emit_progress(on_progress, run_id, all_results, 0, phase, start_time)
|
|
398
|
+
rps += step
|
|
399
|
+
|
|
400
|
+
if breaking_point == 0 and not cancel_event.is_set():
|
|
401
|
+
breaking_point = rps - step
|
|
402
|
+
|
|
403
|
+
await _emit_progress(
|
|
404
|
+
on_progress, run_id, all_results, 0,
|
|
405
|
+
f"complete (breaking point: {breaking_point} req/s)", start_time,
|
|
406
|
+
)
|
|
407
|
+
return all_results
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class SpikePattern:
|
|
411
|
+
async def execute(
|
|
412
|
+
self,
|
|
413
|
+
client: HttpClient,
|
|
414
|
+
requests: list[ParsedRequest],
|
|
415
|
+
variables: VariableContext,
|
|
416
|
+
config: PatternConfig,
|
|
417
|
+
run_id: str,
|
|
418
|
+
cancel_event: asyncio.Event,
|
|
419
|
+
on_progress: ProgressCallback | None = None,
|
|
420
|
+
) -> list[RequestResult]:
|
|
421
|
+
baseline_rps = config.baseline_rps
|
|
422
|
+
spike_rps = config.spike_rps
|
|
423
|
+
baseline_dur = config.baseline_duration_seconds
|
|
424
|
+
spike_dur = config.spike_duration_seconds
|
|
425
|
+
recovery_dur = config.recovery_duration_seconds
|
|
426
|
+
sem = asyncio.Semaphore(config.concurrency)
|
|
427
|
+
all_results: list[RequestResult] = []
|
|
428
|
+
start_time = time.monotonic()
|
|
429
|
+
request_idx = 0
|
|
430
|
+
|
|
431
|
+
total_estimate = baseline_rps * baseline_dur + spike_rps * spike_dur + baseline_rps * recovery_dur
|
|
432
|
+
|
|
433
|
+
logger.info(
|
|
434
|
+
"Spike: baseline=%d, spike=%d, baseline_dur=%ds, spike_dur=%ds, recovery=%ds",
|
|
435
|
+
baseline_rps, spike_rps, baseline_dur, spike_dur, recovery_dur,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, "Preparing spike test...", start_time)
|
|
439
|
+
|
|
440
|
+
async def _run_phase(rps: int, duration: float, phase: str) -> None:
|
|
441
|
+
nonlocal request_idx
|
|
442
|
+
if rps <= 0 or duration <= 0:
|
|
443
|
+
return
|
|
444
|
+
interval = 1.0 / rps
|
|
445
|
+
phase_start = time.monotonic()
|
|
446
|
+
tasks: list[asyncio.Task] = []
|
|
447
|
+
i = 0
|
|
448
|
+
while time.monotonic() - phase_start < duration:
|
|
449
|
+
if cancel_event.is_set():
|
|
450
|
+
await _cancel_tasks(tasks)
|
|
451
|
+
return
|
|
452
|
+
delay = phase_start + i * interval - time.monotonic()
|
|
453
|
+
if delay > 0:
|
|
454
|
+
await asyncio.sleep(delay)
|
|
455
|
+
req = _pick_request(requests, request_idx, config.distribution)
|
|
456
|
+
request_idx += 1
|
|
457
|
+
tasks.append(asyncio.create_task(_fire_one(client, req, variables, sem)))
|
|
458
|
+
i += 1
|
|
459
|
+
if i % max(rps, 1) == 0:
|
|
460
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
|
|
461
|
+
|
|
462
|
+
if cancel_event.is_set():
|
|
463
|
+
await _cancel_tasks(tasks)
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
467
|
+
for r in results:
|
|
468
|
+
if isinstance(r, RequestResult):
|
|
469
|
+
all_results.append(r)
|
|
470
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
|
|
471
|
+
|
|
472
|
+
await _run_phase(baseline_rps, baseline_dur, f"Baseline: {baseline_rps} req/s")
|
|
473
|
+
if not cancel_event.is_set():
|
|
474
|
+
await _run_phase(spike_rps, spike_dur, f"SPIKE: {spike_rps} req/s")
|
|
475
|
+
if not cancel_event.is_set():
|
|
476
|
+
await _run_phase(baseline_rps, recovery_dur, f"Recovery: {baseline_rps} req/s")
|
|
477
|
+
|
|
478
|
+
return all_results
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class SoakPattern:
|
|
482
|
+
async def execute(
|
|
483
|
+
self,
|
|
484
|
+
client: HttpClient,
|
|
485
|
+
requests: list[ParsedRequest],
|
|
486
|
+
variables: VariableContext,
|
|
487
|
+
config: PatternConfig,
|
|
488
|
+
run_id: str,
|
|
489
|
+
cancel_event: asyncio.Event,
|
|
490
|
+
on_progress: ProgressCallback | None = None,
|
|
491
|
+
) -> list[RequestResult]:
|
|
492
|
+
rps = config.soak_rps
|
|
493
|
+
duration = config.soak_duration_seconds
|
|
494
|
+
sem = asyncio.Semaphore(config.concurrency)
|
|
495
|
+
all_results: list[RequestResult] = []
|
|
496
|
+
start_time = time.monotonic()
|
|
497
|
+
request_idx = 0
|
|
498
|
+
total_estimate = rps * duration
|
|
499
|
+
|
|
500
|
+
logger.info("Soak: %d req/s for %ds", rps, duration)
|
|
501
|
+
|
|
502
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, "Preparing soak test...", start_time)
|
|
503
|
+
|
|
504
|
+
interval = 1.0 / rps
|
|
505
|
+
phase_start = time.monotonic()
|
|
506
|
+
tasks: list[asyncio.Task] = []
|
|
507
|
+
i = 0
|
|
508
|
+
|
|
509
|
+
while time.monotonic() - phase_start < duration:
|
|
510
|
+
if cancel_event.is_set():
|
|
511
|
+
break
|
|
512
|
+
delay = phase_start + i * interval - time.monotonic()
|
|
513
|
+
if delay > 0:
|
|
514
|
+
await asyncio.sleep(delay)
|
|
515
|
+
req = _pick_request(requests, request_idx, config.distribution)
|
|
516
|
+
request_idx += 1
|
|
517
|
+
task = asyncio.create_task(_fire_one(client, req, variables, sem))
|
|
518
|
+
task.add_done_callback(
|
|
519
|
+
lambda t: all_results.append(t.result()) if not t.cancelled() and t.exception() is None else None
|
|
520
|
+
)
|
|
521
|
+
tasks.append(task)
|
|
522
|
+
i += 1
|
|
523
|
+
|
|
524
|
+
if i % (rps * 5) == 0:
|
|
525
|
+
elapsed_min = (time.monotonic() - phase_start) / 60
|
|
526
|
+
phase = f"Soaking: {rps} req/s ({elapsed_min:.1f}m elapsed)"
|
|
527
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
|
|
528
|
+
|
|
529
|
+
if cancel_event.is_set():
|
|
530
|
+
await _cancel_tasks(tasks)
|
|
531
|
+
else:
|
|
532
|
+
pending = [t for t in tasks if not t.done()]
|
|
533
|
+
if pending:
|
|
534
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
535
|
+
|
|
536
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, "complete", start_time)
|
|
537
|
+
return all_results
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class BreakpointPattern:
|
|
541
|
+
async def execute(
|
|
542
|
+
self,
|
|
543
|
+
client: HttpClient,
|
|
544
|
+
requests: list[ParsedRequest],
|
|
545
|
+
variables: VariableContext,
|
|
546
|
+
config: PatternConfig,
|
|
547
|
+
run_id: str,
|
|
548
|
+
cancel_event: asyncio.Event,
|
|
549
|
+
on_progress: ProgressCallback | None = None,
|
|
550
|
+
) -> list[RequestResult]:
|
|
551
|
+
start_rps = config.start_rps
|
|
552
|
+
precision = config.precision_rps
|
|
553
|
+
latency_threshold = config.latency_threshold_ms
|
|
554
|
+
error_threshold = config.error_threshold_pct / 100.0
|
|
555
|
+
max_rps = config.max_rps
|
|
556
|
+
sem = asyncio.Semaphore(config.concurrency)
|
|
557
|
+
all_results: list[RequestResult] = []
|
|
558
|
+
start_time = time.monotonic()
|
|
559
|
+
request_idx = 0
|
|
560
|
+
|
|
561
|
+
logger.info(
|
|
562
|
+
"Breakpoint: start=%d, precision=%d, latency_threshold=%.0fms, error_threshold=%.0f%%",
|
|
563
|
+
start_rps, precision, latency_threshold, config.error_threshold_pct,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
await _emit_progress(on_progress, run_id, all_results, 0, "Preparing breakpoint test...", start_time)
|
|
567
|
+
|
|
568
|
+
low = start_rps
|
|
569
|
+
high = max_rps
|
|
570
|
+
last_good = start_rps
|
|
571
|
+
breakpoint_rps = 0
|
|
572
|
+
|
|
573
|
+
async def _probe(rps: int) -> tuple[float, float]:
|
|
574
|
+
nonlocal request_idx
|
|
575
|
+
interval = 1.0 / rps
|
|
576
|
+
probe_tasks = []
|
|
577
|
+
probe_start = time.monotonic()
|
|
578
|
+
probe_count = rps * 5
|
|
579
|
+
|
|
580
|
+
for idx in range(probe_count):
|
|
581
|
+
if cancel_event.is_set():
|
|
582
|
+
break
|
|
583
|
+
delay = probe_start + idx * interval - time.monotonic()
|
|
584
|
+
if delay > 0:
|
|
585
|
+
await asyncio.sleep(delay)
|
|
586
|
+
req = _pick_request(requests, request_idx, config.distribution)
|
|
587
|
+
request_idx += 1
|
|
588
|
+
probe_tasks.append(asyncio.create_task(_fire_one(client, req, variables, sem)))
|
|
589
|
+
|
|
590
|
+
probe_results = await asyncio.gather(*probe_tasks, return_exceptions=True)
|
|
591
|
+
valid = [r for r in probe_results if isinstance(r, RequestResult)]
|
|
592
|
+
all_results.extend(valid)
|
|
593
|
+
|
|
594
|
+
if not valid:
|
|
595
|
+
return 0.0, 1.0
|
|
596
|
+
|
|
597
|
+
latencies = sorted(r.latency_ms for r in valid)
|
|
598
|
+
p95_idx = int(len(latencies) * 0.95)
|
|
599
|
+
p95 = latencies[min(p95_idx, len(latencies) - 1)]
|
|
600
|
+
|
|
601
|
+
errors = sum(1 for r in valid if r.status_code < 200 or r.status_code >= 400)
|
|
602
|
+
error_rate = errors / len(valid)
|
|
603
|
+
|
|
604
|
+
return p95, error_rate
|
|
605
|
+
|
|
606
|
+
# Binary search for breakpoint
|
|
607
|
+
while high - low > precision:
|
|
608
|
+
if cancel_event.is_set():
|
|
609
|
+
break
|
|
610
|
+
|
|
611
|
+
mid = (low + high) // 2
|
|
612
|
+
phase = f"Probing: {mid} req/s"
|
|
613
|
+
await _emit_progress(on_progress, run_id, all_results, 0, phase, start_time)
|
|
614
|
+
|
|
615
|
+
p95, error_rate = await _probe(mid)
|
|
616
|
+
logger.info("Probe %d req/s: p95=%.1fms, error_rate=%.1f%%", mid, p95, error_rate * 100)
|
|
617
|
+
|
|
618
|
+
if p95 > latency_threshold or error_rate > error_threshold:
|
|
619
|
+
high = mid
|
|
620
|
+
breakpoint_rps = mid
|
|
621
|
+
else:
|
|
622
|
+
low = mid
|
|
623
|
+
last_good = mid
|
|
624
|
+
|
|
625
|
+
if breakpoint_rps == 0:
|
|
626
|
+
breakpoint_rps = high
|
|
627
|
+
|
|
628
|
+
await _emit_progress(
|
|
629
|
+
on_progress, run_id, all_results, 0,
|
|
630
|
+
f"complete (breakpoint: {breakpoint_rps} req/s, last good: {last_good} req/s)",
|
|
631
|
+
start_time,
|
|
632
|
+
)
|
|
633
|
+
return all_results
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class CustomPattern:
|
|
637
|
+
async def execute(
|
|
638
|
+
self,
|
|
639
|
+
client: HttpClient,
|
|
640
|
+
requests: list[ParsedRequest],
|
|
641
|
+
variables: VariableContext,
|
|
642
|
+
config: PatternConfig,
|
|
643
|
+
run_id: str,
|
|
644
|
+
cancel_event: asyncio.Event,
|
|
645
|
+
on_progress: ProgressCallback | None = None,
|
|
646
|
+
) -> list[RequestResult]:
|
|
647
|
+
stages = config.stages
|
|
648
|
+
if not stages:
|
|
649
|
+
logger.warning("Custom pattern: no stages defined")
|
|
650
|
+
return []
|
|
651
|
+
|
|
652
|
+
sem = asyncio.Semaphore(config.concurrency)
|
|
653
|
+
all_results: list[RequestResult] = []
|
|
654
|
+
start_time = time.monotonic()
|
|
655
|
+
request_idx = 0
|
|
656
|
+
|
|
657
|
+
total_estimate = sum(
|
|
658
|
+
s.get("rps", 0) * s.get("duration", 0) for s in stages
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
logger.info("Custom: %d stages, estimated %d requests", len(stages), total_estimate)
|
|
662
|
+
|
|
663
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, "Preparing custom test...", start_time)
|
|
664
|
+
|
|
665
|
+
for stage_num, stage in enumerate(stages, 1):
|
|
666
|
+
if cancel_event.is_set():
|
|
667
|
+
break
|
|
668
|
+
|
|
669
|
+
rps = stage.get("rps", 10)
|
|
670
|
+
duration = stage.get("duration", 30)
|
|
671
|
+
phase = f"Stage {stage_num}/{len(stages)}: {rps} req/s for {duration}s"
|
|
672
|
+
|
|
673
|
+
if rps <= 0 or duration <= 0:
|
|
674
|
+
continue
|
|
675
|
+
|
|
676
|
+
interval = 1.0 / rps
|
|
677
|
+
stage_start = time.monotonic()
|
|
678
|
+
tasks: list[asyncio.Task] = []
|
|
679
|
+
i = 0
|
|
680
|
+
|
|
681
|
+
while time.monotonic() - stage_start < duration:
|
|
682
|
+
if cancel_event.is_set():
|
|
683
|
+
break
|
|
684
|
+
delay = stage_start + i * interval - time.monotonic()
|
|
685
|
+
if delay > 0:
|
|
686
|
+
await asyncio.sleep(delay)
|
|
687
|
+
req = _pick_request(requests, request_idx, config.distribution)
|
|
688
|
+
request_idx += 1
|
|
689
|
+
task = asyncio.create_task(_fire_one(client, req, variables, sem))
|
|
690
|
+
task.add_done_callback(
|
|
691
|
+
lambda t: all_results.append(t.result()) if not t.cancelled() and t.exception() is None else None
|
|
692
|
+
)
|
|
693
|
+
tasks.append(task)
|
|
694
|
+
i += 1
|
|
695
|
+
|
|
696
|
+
if i % max(rps, 1) == 0:
|
|
697
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
|
|
698
|
+
|
|
699
|
+
if cancel_event.is_set():
|
|
700
|
+
await _cancel_tasks(tasks)
|
|
701
|
+
break
|
|
702
|
+
|
|
703
|
+
pending = [t for t in tasks if not t.done()]
|
|
704
|
+
if pending:
|
|
705
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
706
|
+
|
|
707
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
|
|
708
|
+
|
|
709
|
+
await _emit_progress(on_progress, run_id, all_results, total_estimate, "complete", start_time)
|
|
710
|
+
return all_results
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
PATTERNS: dict[str, LoadPattern] = {
|
|
714
|
+
"burst": BurstPattern(),
|
|
715
|
+
"ramp": RampPattern(),
|
|
716
|
+
"load": LoadTestPattern(),
|
|
717
|
+
"stress": StressPattern(),
|
|
718
|
+
"spike": SpikePattern(),
|
|
719
|
+
"soak": SoakPattern(),
|
|
720
|
+
"breakpoint": BreakpointPattern(),
|
|
721
|
+
"custom": CustomPattern(),
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def get_pattern(name: str) -> LoadPattern:
|
|
726
|
+
pattern = PATTERNS.get(name.lower())
|
|
727
|
+
if pattern is None:
|
|
728
|
+
available = ", ".join(PATTERNS.keys())
|
|
729
|
+
raise ValueError(f"Unknown pattern: {name}. Available: {available}")
|
|
730
|
+
return pattern
|