strobengine 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {strobengine-0.2.0 → strobengine-0.2.1}/.github/workflows/publish-test.yml +18 -3
- {strobengine-0.2.0 → strobengine-0.2.1}/.github/workflows/publish.yml +17 -3
- {strobengine-0.2.0 → strobengine-0.2.1}/CHANGELOG.md +28 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/Cargo.lock +1 -1
- {strobengine-0.2.0 → strobengine-0.2.1}/Cargo.toml +1 -1
- {strobengine-0.2.0 → strobengine-0.2.1}/PKG-INFO +1 -1
- {strobengine-0.2.0 → strobengine-0.2.1}/docs/roadmap.md +3 -3
- {strobengine-0.2.0 → strobengine-0.2.1}/pyproject.toml +1 -1
- {strobengine-0.2.0 → strobengine-0.2.1}/src/lib.rs +95 -27
- {strobengine-0.2.0 → strobengine-0.2.1}/src/metrics.rs +19 -12
- {strobengine-0.2.0 → strobengine-0.2.1}/src/progress.rs +15 -5
- {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/_strobengine.pyi +3 -3
- {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/cli.py +5 -2
- strobengine-0.2.1/src/strobengine/constants.py +14 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/engine.py +28 -14
- {strobengine-0.2.0 → strobengine-0.2.1}/uv.lock +1 -1
- {strobengine-0.2.0 → strobengine-0.2.1}/.github/workflows/ci.yml +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/.gitignore +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/.python-version +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/LICENSE +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/README.md +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/cliff.toml +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/docs/benchmarks.md +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/docs/http_methods.md +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/docs/infrastructure.md +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/examples/http_methods.py +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/examples/load_test.py +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/examples/spike_test.py +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/examples/stress_test.py +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/src/chaos.rs +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/src/config.rs +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/src/logging.rs +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/__init__.py +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/py.typed +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/reporter.py +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/src/worker.rs +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/tests/__init__.py +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/tests/conftest.py +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/tests/test_cli.py +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/tests/test_engine.py +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/tests/test_logging.py +0 -0
- {strobengine-0.2.0 → strobengine-0.2.1}/tests/test_reporter.py +0 -0
|
@@ -6,11 +6,24 @@ on:
|
|
|
6
6
|
|
|
7
7
|
jobs:
|
|
8
8
|
build-wheels:
|
|
9
|
-
name: Build wheels on ${{ matrix.os }}
|
|
9
|
+
name: Build wheels on ${{ matrix.os }} (${{ matrix.target || 'native' }})
|
|
10
10
|
runs-on: ${{ matrix.os }}
|
|
11
11
|
strategy:
|
|
12
|
+
fail-fast: false
|
|
12
13
|
matrix:
|
|
13
|
-
|
|
14
|
+
include:
|
|
15
|
+
# Linux
|
|
16
|
+
- os: ubuntu-latest
|
|
17
|
+
target: x86_64
|
|
18
|
+
# Windows
|
|
19
|
+
- os: windows-latest
|
|
20
|
+
target: x64
|
|
21
|
+
# macOS Apple Silicon (ARM64)
|
|
22
|
+
- os: macos-latest
|
|
23
|
+
target: aarch64
|
|
24
|
+
# macOS Intel (x86_64) cross-compiled on macos-latest
|
|
25
|
+
- os: macos-latest
|
|
26
|
+
target: x86_64
|
|
14
27
|
steps:
|
|
15
28
|
- uses: actions/checkout@v7
|
|
16
29
|
|
|
@@ -22,6 +35,7 @@ jobs:
|
|
|
22
35
|
- name: Build wheels via Maturin
|
|
23
36
|
uses: PyO3/maturin-action@v1
|
|
24
37
|
with:
|
|
38
|
+
target: ${{ matrix.target }}
|
|
25
39
|
args: --release --out dist --find-interpreter
|
|
26
40
|
sccache: 'true'
|
|
27
41
|
sdist: false
|
|
@@ -29,7 +43,7 @@ jobs:
|
|
|
29
43
|
- name: Upload wheel artifacts
|
|
30
44
|
uses: actions/upload-artifact@v7
|
|
31
45
|
with:
|
|
32
|
-
name: wheels-${{ matrix.os }}
|
|
46
|
+
name: wheels-${{ matrix.os }}-${{ matrix.target }}
|
|
33
47
|
path: dist/*.whl
|
|
34
48
|
|
|
35
49
|
build-sdist:
|
|
@@ -71,3 +85,4 @@ jobs:
|
|
|
71
85
|
with:
|
|
72
86
|
repository-url: https://test.pypi.org/legacy/
|
|
73
87
|
packages-dir: dist/
|
|
88
|
+
skip-existing: true # Skips 400 errors for already uploaded files
|
|
@@ -9,11 +9,24 @@ on:
|
|
|
9
9
|
|
|
10
10
|
jobs:
|
|
11
11
|
build-wheels:
|
|
12
|
-
name: Build wheels on ${{ matrix.os }}
|
|
12
|
+
name: Build wheels on ${{ matrix.os }} (${{ matrix.target || 'native' }})
|
|
13
13
|
runs-on: ${{ matrix.os }}
|
|
14
14
|
strategy:
|
|
15
|
+
fail-fast: false
|
|
15
16
|
matrix:
|
|
16
|
-
|
|
17
|
+
include:
|
|
18
|
+
# Linux
|
|
19
|
+
- os: ubuntu-latest
|
|
20
|
+
target: x86_64
|
|
21
|
+
# Windows
|
|
22
|
+
- os: windows-latest
|
|
23
|
+
target: x64
|
|
24
|
+
# macOS Apple Silicon (ARM64)
|
|
25
|
+
- os: macos-latest
|
|
26
|
+
target: aarch64
|
|
27
|
+
# macOS Intel (x86_64) cross-compiled on macos-latest
|
|
28
|
+
- os: macos-latest
|
|
29
|
+
target: x86_64
|
|
17
30
|
steps:
|
|
18
31
|
- uses: actions/checkout@v7
|
|
19
32
|
|
|
@@ -25,6 +38,7 @@ jobs:
|
|
|
25
38
|
- name: Build wheels via Maturin
|
|
26
39
|
uses: PyO3/maturin-action@v1
|
|
27
40
|
with:
|
|
41
|
+
target: ${{ matrix.target }}
|
|
28
42
|
args: --release --out dist --find-interpreter
|
|
29
43
|
sccache: 'true'
|
|
30
44
|
sdist: false
|
|
@@ -32,7 +46,7 @@ jobs:
|
|
|
32
46
|
- name: Upload wheel artifacts
|
|
33
47
|
uses: actions/upload-artifact@v7
|
|
34
48
|
with:
|
|
35
|
-
name: wheels-${{ matrix.os }}
|
|
49
|
+
name: wheels-${{ matrix.os }}-${{ matrix.target }}
|
|
36
50
|
path: dist/*.whl
|
|
37
51
|
|
|
38
52
|
build-sdist:
|
|
@@ -4,6 +4,31 @@ All notable changes to `strobengine` will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.2.1] - 2026-08-03
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- *(types)* Correct headers type in _strobengine.pyi from dict to list of tuples (#41)
|
|
12
|
+
- Replace build_client panic with proper error propagation (#42)
|
|
13
|
+
- Replace std::process::exit with safe PyKeyboardInterrupt on SIGINT (#43)
|
|
14
|
+
- *(progress)* Guard against zero total_duration in progress bar calculation (#44)
|
|
15
|
+
- Improve worker shutdown and panic handling (#45)
|
|
16
|
+
- *(metrics)* Normalize counter types to AtomicU64 for 32-bit safety (#46)
|
|
17
|
+
- *(cli)* Add type annotation to _output_results summary parameter (#48)
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
- Add comment explaining SystemExit re-raise pattern (#50)
|
|
22
|
+
|
|
23
|
+
### Miscellaneous Tasks
|
|
24
|
+
|
|
25
|
+
- Remove unnecessary clippy allow on run_load_test (#47)
|
|
26
|
+
- Add multi-architecture matrix for macOS wheel builds (#52)
|
|
27
|
+
|
|
28
|
+
### Refactoring
|
|
29
|
+
|
|
30
|
+
- Extract magic number 8192 to named constant METRIC_CHANNEL_BUFFER (#49)
|
|
31
|
+
- Extract magic numbers to named constants across Rust and Python (#51)
|
|
7
32
|
## [0.2.0] - 2026-08-02
|
|
8
33
|
|
|
9
34
|
### Bug Fixes
|
|
@@ -20,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
20
45
|
- Update roadmap to reflect changes (#34)
|
|
21
46
|
- Add HTTP method, body, and header documentation
|
|
22
47
|
- Add code examples (#37)
|
|
48
|
+
- *(changelog)* Update CHANGELOG.md for v0.2.0 release
|
|
23
49
|
|
|
24
50
|
### Features
|
|
25
51
|
|
|
@@ -43,6 +69,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
43
69
|
|
|
44
70
|
- *(release)* Prepare v0.1.0 changelog and tag
|
|
45
71
|
- Add PyPI publish workflow with tag-based triggering (#26)
|
|
72
|
+
- *(pyproject)* Add license metadata and project URLs, bump version
|
|
73
|
+
- *(release)* Merge v0.2.0 release prep into main (#40)
|
|
46
74
|
|
|
47
75
|
### Performance
|
|
48
76
|
|
|
@@ -15,12 +15,12 @@ This document outlines the planned trajectory and upcoming feature epics for **s
|
|
|
15
15
|
|
|
16
16
|
### Epic: Fault Injection & Resilience Testing (Chaos)
|
|
17
17
|
|
|
18
|
-
- [x] Basic chaos testing support. `[
|
|
18
|
+
- [x] Basic chaos testing support. `[v0.2.0]`
|
|
19
19
|
|
|
20
20
|
### Epic: Real-Time Telemetry & Progress Bars
|
|
21
21
|
*Target Focus: UX & Observability*
|
|
22
22
|
|
|
23
|
-
- [x] Interactive CLI progress indicators (live RPS, active virtual users, latency feed). `[
|
|
23
|
+
- [x] Interactive CLI progress indicators (live RPS, active virtual users, latency feed). `[v0.2.0]`
|
|
24
24
|
- [ ] ~~Real-time telemetry dashboard / TUI integration.~~ *(Postponed: Python scripting is primary runner; CLI is for quick drafts only)*
|
|
25
25
|
- [ ] ~~Improved streaming metrics collection to minimize memory overhead during long runs.~~ *(Deferred until $O(1)$ memory metrics are required for multi-hour runs)*
|
|
26
26
|
|
|
@@ -31,7 +31,7 @@ This document outlines the planned trajectory and upcoming feature epics for **s
|
|
|
31
31
|
|
|
32
32
|
- [ ] Custom HTTP Headers support (authentication, user-agents, metadata).
|
|
33
33
|
- [ ] Dynamic & static Request Payloads (`POST`/`PUT`/`PATCH` body support with JSON/Form Data).
|
|
34
|
-
- [
|
|
34
|
+
- [x] Support for all standard HTTP methods (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`). `[v0.2.0]`
|
|
35
35
|
|
|
36
36
|
---
|
|
37
37
|
|
|
@@ -24,6 +24,20 @@ use crate::chaos::ChaosEngine;
|
|
|
24
24
|
use crate::config::{LoadProfile, TestConfig};
|
|
25
25
|
use crate::metrics::{LiveCounters, RequestMetric};
|
|
26
26
|
|
|
27
|
+
/// Buffer capacity for the async MPSC channel streaming metrics from workers to the aggregator.
|
|
28
|
+
///
|
|
29
|
+
/// 8,192 (~8k) provides enough head room to prevent worker task backpressure during high RPS bursts
|
|
30
|
+
/// without allocating unnecessary heap memory (8,192 * std::mem::size_of::<RequestMetric>()).
|
|
31
|
+
const METRIC_CHANNEL_BUFFER: usize = 8192;
|
|
32
|
+
|
|
33
|
+
/// Client network configuration defaults.
|
|
34
|
+
const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 5;
|
|
35
|
+
const DEFAULT_H2_KEEPALIVE_INTERVAL_SECS: u64 = 10;
|
|
36
|
+
const DEFAULT_H2_KEEPALIVE_TIMEOUT_SECS: u64 = 5;
|
|
37
|
+
|
|
38
|
+
/// Supervisor loop poll rate in milliseconds.
|
|
39
|
+
const SUPERVISOR_TICK_MS: u64 = 200;
|
|
40
|
+
|
|
27
41
|
fn parse_method(method_str: &str) -> PyResult<Method> {
|
|
28
42
|
method_str.to_uppercase().parse().map_err(|_| {
|
|
29
43
|
pyo3::exceptions::PyValueError::new_err(format!("Invalid HTTP method: {method_str}"))
|
|
@@ -55,17 +69,20 @@ fn parse_headers(headers: Option<Vec<(String, String)>>) -> PyResult<HeaderMap>
|
|
|
55
69
|
Ok(header_map)
|
|
56
70
|
}
|
|
57
71
|
|
|
58
|
-
fn build_client(
|
|
72
|
+
fn build_client(
|
|
73
|
+
concurrency: usize,
|
|
74
|
+
timeout_secs: u64,
|
|
75
|
+
header_map: HeaderMap,
|
|
76
|
+
) -> Result<reqwest::Client, reqwest::Error> {
|
|
59
77
|
reqwest::Client::builder()
|
|
60
78
|
.pool_max_idle_per_host(concurrency)
|
|
61
79
|
.timeout(Duration::from_secs(timeout_secs))
|
|
62
|
-
.connect_timeout(Duration::from_secs(
|
|
80
|
+
.connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
|
|
63
81
|
.tcp_nodelay(true)
|
|
64
|
-
.http2_keep_alive_interval(Duration::from_secs(
|
|
65
|
-
.http2_keep_alive_timeout(Duration::from_secs(
|
|
82
|
+
.http2_keep_alive_interval(Duration::from_secs(DEFAULT_H2_KEEPALIVE_INTERVAL_SECS))
|
|
83
|
+
.http2_keep_alive_timeout(Duration::from_secs(DEFAULT_H2_KEEPALIVE_TIMEOUT_SECS))
|
|
66
84
|
.default_headers(header_map)
|
|
67
85
|
.build()
|
|
68
|
-
.expect("failed to build HTTP client")
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
#[pyfunction]
|
|
@@ -74,7 +91,6 @@ fn init_logging(level: String, log_file: Option<String>) {
|
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
#[pyfunction]
|
|
77
|
-
#[allow(clippy::too_many_arguments)]
|
|
78
94
|
fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSummary> {
|
|
79
95
|
py.detach(move || {
|
|
80
96
|
let url = config.url;
|
|
@@ -109,9 +125,15 @@ fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSu
|
|
|
109
125
|
.build()
|
|
110
126
|
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
|
|
111
127
|
|
|
112
|
-
|
|
113
|
-
|
|
128
|
+
// Build the client OUTSIDE the async closure
|
|
129
|
+
let client = build_client(concurrency, timeout_secs, header_map)
|
|
130
|
+
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
|
|
114
131
|
|
|
132
|
+
// Track whether cancellation was triggered by user SIGINT
|
|
133
|
+
let interrupted = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
134
|
+
let interrupted_check = Arc::clone(&interrupted);
|
|
135
|
+
|
|
136
|
+
let result = rt.block_on(async move {
|
|
115
137
|
tracing::debug!("http client created");
|
|
116
138
|
|
|
117
139
|
let counters = Arc::new(LiveCounters::new());
|
|
@@ -123,20 +145,20 @@ fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSu
|
|
|
123
145
|
|
|
124
146
|
// Spawn SIGINT listener with double Ctrl+C safety hatch
|
|
125
147
|
let token_clone = cancel_token.clone();
|
|
148
|
+
let interrupted_clone = interrupted.clone();
|
|
126
149
|
tokio::spawn(async move {
|
|
127
150
|
if tokio::signal::ctrl_c().await.is_ok() {
|
|
128
151
|
tracing::info!("received SIGINT, initiating graceful shutdown");
|
|
152
|
+
interrupted_clone.store(true, Ordering::SeqCst);
|
|
129
153
|
token_clone.cancel();
|
|
130
154
|
|
|
131
|
-
// Second Ctrl+C forces immediate termination
|
|
132
155
|
if tokio::signal::ctrl_c().await.is_ok() {
|
|
133
|
-
tracing::warn!("received second SIGINT,
|
|
134
|
-
std::process::exit(130);
|
|
156
|
+
tracing::warn!("received second SIGINT, shutdown already in progress");
|
|
135
157
|
}
|
|
136
158
|
}
|
|
137
159
|
});
|
|
138
160
|
|
|
139
|
-
let (tx, rx) = tokio::sync::mpsc::channel::<RequestMetric>(
|
|
161
|
+
let (tx, rx) = tokio::sync::mpsc::channel::<RequestMetric>(METRIC_CHANNEL_BUFFER);
|
|
140
162
|
|
|
141
163
|
let aggregator = tokio::spawn(async move {
|
|
142
164
|
let mut latencies = Vec::new();
|
|
@@ -184,12 +206,17 @@ fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSu
|
|
|
184
206
|
drop(tx);
|
|
185
207
|
|
|
186
208
|
for handle in handles {
|
|
187
|
-
let
|
|
209
|
+
if let Err(e) = handle.await {
|
|
210
|
+
tracing::warn!(error = %e, "worker task panicked");
|
|
211
|
+
}
|
|
188
212
|
}
|
|
189
213
|
|
|
190
214
|
// Wait for render task to finish naturally
|
|
215
|
+
#[allow(clippy::collapsible_if)]
|
|
191
216
|
if let Some(handle) = render_handle {
|
|
192
|
-
let
|
|
217
|
+
if let Err(e) = handle.await {
|
|
218
|
+
tracing::debug!(error = %e, "render task panicked");
|
|
219
|
+
}
|
|
193
220
|
}
|
|
194
221
|
|
|
195
222
|
let latencies = aggregator
|
|
@@ -201,8 +228,19 @@ fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSu
|
|
|
201
228
|
|
|
202
229
|
tracing::info!(total, errors, "constant load test completed");
|
|
203
230
|
|
|
204
|
-
Ok(metrics::calculate_summary(
|
|
205
|
-
|
|
231
|
+
Ok::<metrics::TestSummary, pyo3::PyErr>(metrics::calculate_summary(
|
|
232
|
+
total, errors, latencies,
|
|
233
|
+
))
|
|
234
|
+
})?;
|
|
235
|
+
|
|
236
|
+
// If SIGINT was received, raise KeyboardInterrupt to Python
|
|
237
|
+
if interrupted_check.load(Ordering::SeqCst) {
|
|
238
|
+
return Err(pyo3::exceptions::PyKeyboardInterrupt::new_err(
|
|
239
|
+
"load test interrupted by user",
|
|
240
|
+
));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
Ok(result)
|
|
206
244
|
})
|
|
207
245
|
}
|
|
208
246
|
|
|
@@ -261,9 +299,14 @@ fn run_load_profiles(
|
|
|
261
299
|
.build()
|
|
262
300
|
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
|
|
263
301
|
|
|
264
|
-
|
|
265
|
-
|
|
302
|
+
let client = build_client(max_concurrency, timeout_secs, header_map)
|
|
303
|
+
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
|
|
304
|
+
|
|
305
|
+
// Track whether cancellation was triggered by user SIGINT
|
|
306
|
+
let interrupted = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
307
|
+
let interrupted_check = Arc::clone(&interrupted);
|
|
266
308
|
|
|
309
|
+
let result = rt.block_on(async move {
|
|
267
310
|
tracing::debug!("http client created");
|
|
268
311
|
|
|
269
312
|
let counters = Arc::new(LiveCounters::new());
|
|
@@ -275,19 +318,20 @@ fn run_load_profiles(
|
|
|
275
318
|
|
|
276
319
|
// Spawn SIGINT listener with double Ctrl+C safety hatch
|
|
277
320
|
let token_clone = cancel_token.clone();
|
|
321
|
+
let interrupted_clone = interrupted.clone();
|
|
278
322
|
tokio::spawn(async move {
|
|
279
323
|
if tokio::signal::ctrl_c().await.is_ok() {
|
|
280
324
|
tracing::info!("received SIGINT, initiating graceful shutdown");
|
|
325
|
+
interrupted_clone.store(true, Ordering::SeqCst);
|
|
281
326
|
token_clone.cancel();
|
|
282
327
|
|
|
283
328
|
if tokio::signal::ctrl_c().await.is_ok() {
|
|
284
|
-
tracing::warn!("received second SIGINT,
|
|
285
|
-
std::process::exit(130);
|
|
329
|
+
tracing::warn!("received second SIGINT, shutdown already in progress");
|
|
286
330
|
}
|
|
287
331
|
}
|
|
288
332
|
});
|
|
289
333
|
|
|
290
|
-
let (tx, rx) = tokio::sync::mpsc::channel::<RequestMetric>(
|
|
334
|
+
let (tx, rx) = tokio::sync::mpsc::channel::<RequestMetric>(METRIC_CHANNEL_BUFFER);
|
|
291
335
|
|
|
292
336
|
let aggregator = tokio::spawn(async move {
|
|
293
337
|
let mut latencies = Vec::new();
|
|
@@ -323,6 +367,7 @@ fn run_load_profiles(
|
|
|
323
367
|
let supervisor = tokio::spawn(async move {
|
|
324
368
|
let mut child_tokens: Vec<CancellationToken> = Vec::new();
|
|
325
369
|
let mut handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
|
|
370
|
+
let mut reaped_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
|
|
326
371
|
let mut current_concurrency = 0usize;
|
|
327
372
|
let start = Instant::now();
|
|
328
373
|
|
|
@@ -357,22 +402,31 @@ fn run_load_profiles(
|
|
|
357
402
|
if let Some(token) = child_tokens.pop() {
|
|
358
403
|
token.cancel();
|
|
359
404
|
if let Some(handle) = handles.pop() {
|
|
360
|
-
|
|
405
|
+
reaped_handles.push(handle);
|
|
361
406
|
}
|
|
362
407
|
current_concurrency -= 1;
|
|
363
408
|
}
|
|
364
409
|
}
|
|
365
410
|
|
|
411
|
+
// Join reaped workers concurrently
|
|
412
|
+
for handle in reaped_handles.drain(..) {
|
|
413
|
+
if let Err(e) = handle.await {
|
|
414
|
+
tracing::debug!(error = %e, "cancelled worker panicked during shutdown");
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
366
418
|
tracing::debug!(current_concurrency, target, "supervisor tick");
|
|
367
419
|
|
|
368
|
-
tokio::time::sleep(Duration::from_millis(
|
|
420
|
+
tokio::time::sleep(Duration::from_millis(SUPERVISOR_TICK_MS)).await;
|
|
369
421
|
}
|
|
370
422
|
|
|
371
423
|
for token in child_tokens {
|
|
372
424
|
token.cancel();
|
|
373
425
|
}
|
|
374
426
|
for handle in handles {
|
|
375
|
-
let
|
|
427
|
+
if let Err(e) = handle.await {
|
|
428
|
+
tracing::warn!(error = %e, "worker task panicked");
|
|
429
|
+
}
|
|
376
430
|
}
|
|
377
431
|
|
|
378
432
|
drop(tx);
|
|
@@ -383,8 +437,11 @@ fn run_load_profiles(
|
|
|
383
437
|
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
|
|
384
438
|
|
|
385
439
|
// Wait for render task to finish naturally
|
|
440
|
+
#[allow(clippy::collapsible_if)]
|
|
386
441
|
if let Some(handle) = render_handle {
|
|
387
|
-
let
|
|
442
|
+
if let Err(e) = handle.await {
|
|
443
|
+
tracing::debug!(error = %e, "render task panicked");
|
|
444
|
+
}
|
|
388
445
|
}
|
|
389
446
|
|
|
390
447
|
let latencies = aggregator
|
|
@@ -396,8 +453,19 @@ fn run_load_profiles(
|
|
|
396
453
|
|
|
397
454
|
tracing::info!(total, errors, "profile load test completed");
|
|
398
455
|
|
|
399
|
-
Ok(metrics::calculate_summary(
|
|
400
|
-
|
|
456
|
+
Ok::<metrics::TestSummary, pyo3::PyErr>(metrics::calculate_summary(
|
|
457
|
+
total, errors, latencies,
|
|
458
|
+
))
|
|
459
|
+
})?;
|
|
460
|
+
|
|
461
|
+
// If SIGINT was received, raise KeyboardInterrupt to Python
|
|
462
|
+
if interrupted_check.load(Ordering::SeqCst) {
|
|
463
|
+
return Err(pyo3::exceptions::PyKeyboardInterrupt::new_err(
|
|
464
|
+
"load test interrupted by user",
|
|
465
|
+
));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
Ok(result)
|
|
401
469
|
})
|
|
402
470
|
}
|
|
403
471
|
|
|
@@ -2,6 +2,9 @@ use std::sync::atomic::{AtomicU64, AtomicUsize};
|
|
|
2
2
|
|
|
3
3
|
use pyo3::prelude::*;
|
|
4
4
|
|
|
5
|
+
/// Conversion factor from microseconds to milliseconds (1 ms = 1,000 us).
|
|
6
|
+
pub const MICROS_PER_MILLI: f64 = 1_000.0;
|
|
7
|
+
|
|
5
8
|
#[allow(dead_code)]
|
|
6
9
|
pub struct RequestMetric {
|
|
7
10
|
pub status_code: u16,
|
|
@@ -10,8 +13,8 @@ pub struct RequestMetric {
|
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
pub struct LiveCounters {
|
|
13
|
-
pub total_requests:
|
|
14
|
-
pub errors:
|
|
16
|
+
pub total_requests: AtomicU64,
|
|
17
|
+
pub errors: AtomicU64,
|
|
15
18
|
pub active_workers: AtomicUsize,
|
|
16
19
|
pub completed_requests: AtomicU64,
|
|
17
20
|
pub latency_sum_micros: AtomicU64,
|
|
@@ -21,8 +24,8 @@ pub struct LiveCounters {
|
|
|
21
24
|
impl LiveCounters {
|
|
22
25
|
pub fn new() -> Self {
|
|
23
26
|
Self {
|
|
24
|
-
total_requests:
|
|
25
|
-
errors:
|
|
27
|
+
total_requests: AtomicU64::new(0),
|
|
28
|
+
errors: AtomicU64::new(0),
|
|
26
29
|
active_workers: AtomicUsize::new(0),
|
|
27
30
|
completed_requests: AtomicU64::new(0),
|
|
28
31
|
latency_sum_micros: AtomicU64::new(0),
|
|
@@ -46,11 +49,15 @@ pub struct TestSummary {
|
|
|
46
49
|
pub p99_latency_ms: f64,
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
pub fn calculate_summary(
|
|
52
|
+
pub fn calculate_summary(
|
|
53
|
+
total_requests: u64,
|
|
54
|
+
total_errors: u64,
|
|
55
|
+
mut latencies: Vec<u128>,
|
|
56
|
+
) -> TestSummary {
|
|
50
57
|
if latencies.is_empty() {
|
|
51
58
|
return TestSummary {
|
|
52
|
-
total_requests:
|
|
53
|
-
total_errors:
|
|
59
|
+
total_requests: total_requests as usize,
|
|
60
|
+
total_errors: total_errors as usize,
|
|
54
61
|
average_latency_ms: 0.0,
|
|
55
62
|
p95_latency_ms: 0.0,
|
|
56
63
|
p99_latency_ms: 0.0,
|
|
@@ -61,17 +68,17 @@ pub fn calculate_summary(total: usize, errors: usize, mut latencies: Vec<u128>)
|
|
|
61
68
|
|
|
62
69
|
let len = latencies.len();
|
|
63
70
|
let sum: u128 = latencies.iter().sum();
|
|
64
|
-
let average_latency_ms = sum as f64 / len as f64 /
|
|
71
|
+
let average_latency_ms = sum as f64 / len as f64 / MICROS_PER_MILLI;
|
|
65
72
|
|
|
66
73
|
let p95_idx = (len * 95 / 100).min(len - 1);
|
|
67
74
|
let p99_idx = (len * 99 / 100).min(len - 1);
|
|
68
75
|
|
|
69
76
|
TestSummary {
|
|
70
|
-
total_requests:
|
|
71
|
-
total_errors:
|
|
77
|
+
total_requests: total_requests as usize,
|
|
78
|
+
total_errors: total_errors as usize,
|
|
72
79
|
average_latency_ms,
|
|
73
|
-
p95_latency_ms: latencies[p95_idx] as f64 /
|
|
74
|
-
p99_latency_ms: latencies[p99_idx] as f64 /
|
|
80
|
+
p95_latency_ms: latencies[p95_idx] as f64 / MICROS_PER_MILLI,
|
|
81
|
+
p99_latency_ms: latencies[p99_idx] as f64 / MICROS_PER_MILLI,
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
@@ -5,10 +5,16 @@ use std::time::{Duration, Instant};
|
|
|
5
5
|
use indicatif::{ProgressBar, ProgressStyle};
|
|
6
6
|
use tokio_util::sync::CancellationToken;
|
|
7
7
|
|
|
8
|
-
use crate::metrics::LiveCounters;
|
|
8
|
+
use crate::metrics::{LiveCounters, MICROS_PER_MILLI};
|
|
9
|
+
|
|
10
|
+
/// Target update rate for the terminal UI tick loop.
|
|
11
|
+
const RENDER_LOOP_SLEEP_MS: u64 = 200;
|
|
12
|
+
|
|
13
|
+
/// Indicatif progress bar total resolution ticks.
|
|
14
|
+
const PROGRESS_BAR_MAX_TICKS: u64 = 100;
|
|
9
15
|
|
|
10
16
|
pub fn create_progress_bar(_total_duration: Duration) -> ProgressBar {
|
|
11
|
-
let pb = ProgressBar::new(
|
|
17
|
+
let pb = ProgressBar::new(PROGRESS_BAR_MAX_TICKS);
|
|
12
18
|
pb.set_style(
|
|
13
19
|
ProgressStyle::with_template(
|
|
14
20
|
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/dim}] {pos}% | {msg}",
|
|
@@ -33,7 +39,7 @@ pub async fn render_loop(
|
|
|
33
39
|
|
|
34
40
|
loop {
|
|
35
41
|
tokio::select! {
|
|
36
|
-
_ = tokio::time::sleep(Duration::from_millis(
|
|
42
|
+
_ = tokio::time::sleep(Duration::from_millis(RENDER_LOOP_SLEEP_MS)) => {
|
|
37
43
|
let now = Instant::now();
|
|
38
44
|
let delta_sec = (now - last_tick).as_secs_f64();
|
|
39
45
|
last_tick = now;
|
|
@@ -48,7 +54,11 @@ pub async fn render_loop(
|
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
// Progress percentage based on elapsed time
|
|
51
|
-
let pct =
|
|
57
|
+
let pct = if total_duration.is_zero() {
|
|
58
|
+
0
|
|
59
|
+
} else {
|
|
60
|
+
(elapsed.as_secs_f64() / total_duration.as_secs_f64() * 100.0) as u64
|
|
61
|
+
};
|
|
52
62
|
pb.set_position(pct.min(99));
|
|
53
63
|
|
|
54
64
|
// Sample metrics
|
|
@@ -66,7 +76,7 @@ pub async fn render_loop(
|
|
|
66
76
|
|
|
67
77
|
// Average latency across all completed requests
|
|
68
78
|
let avg_latency_ms = if latency_count > 0 {
|
|
69
|
-
latency_sum as f64 / latency_count as f64 /
|
|
79
|
+
latency_sum as f64 / latency_count as f64 / MICROS_PER_MILLI
|
|
70
80
|
} else {
|
|
71
81
|
0.0
|
|
72
82
|
};
|
|
@@ -30,7 +30,7 @@ class TestConfig:
|
|
|
30
30
|
no_progress: bool
|
|
31
31
|
method: str
|
|
32
32
|
body: str | None
|
|
33
|
-
headers:
|
|
33
|
+
headers: list[tuple[str, str]] | None
|
|
34
34
|
def __init__(
|
|
35
35
|
self,
|
|
36
36
|
url: str,
|
|
@@ -42,7 +42,7 @@ class TestConfig:
|
|
|
42
42
|
no_progress: bool = False,
|
|
43
43
|
method: str = "GET",
|
|
44
44
|
body: str | None = None,
|
|
45
|
-
headers:
|
|
45
|
+
headers: list[tuple[str, str]] | None = None,
|
|
46
46
|
) -> None: ...
|
|
47
47
|
|
|
48
48
|
class TestSummary:
|
|
@@ -68,5 +68,5 @@ def run_load_profiles(
|
|
|
68
68
|
no_progress: bool = False,
|
|
69
69
|
method: str = "GET",
|
|
70
70
|
body: str | None = None,
|
|
71
|
-
headers:
|
|
71
|
+
headers: list[tuple[str, str]] | None = None,
|
|
72
72
|
) -> TestSummary: ...
|
|
@@ -6,7 +6,7 @@ from typing import Annotated
|
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
|
-
from strobengine._strobengine import init_logging
|
|
9
|
+
from strobengine._strobengine import TestSummary, init_logging
|
|
10
10
|
from strobengine.engine import RequestOptions, StrobEngine
|
|
11
11
|
from strobengine.reporter import print_summary
|
|
12
12
|
|
|
@@ -114,7 +114,9 @@ def _global_options(
|
|
|
114
114
|
pass
|
|
115
115
|
|
|
116
116
|
|
|
117
|
-
def _output_results(
|
|
117
|
+
def _output_results(
|
|
118
|
+
summary: TestSummary, url: str, duration_secs: int, json_output: bool
|
|
119
|
+
) -> None:
|
|
118
120
|
if json_output:
|
|
119
121
|
print(
|
|
120
122
|
json.dumps(
|
|
@@ -394,6 +396,7 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
394
396
|
try:
|
|
395
397
|
app(args=argv)
|
|
396
398
|
except SystemExit as e:
|
|
399
|
+
# Re-raise to prevent KeyboardInterrupt handler from catching typer exits
|
|
397
400
|
raise e
|
|
398
401
|
except KeyboardInterrupt:
|
|
399
402
|
typer.echo("\nInterrupted.", err=True)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Default configuration values for strobengine execution."""
|
|
2
|
+
|
|
3
|
+
DEFAULT_CONCURRENCY: int = 10
|
|
4
|
+
DEFAULT_DURATION_SECS: int = 10
|
|
5
|
+
DEFAULT_TIMEOUT_SECS: int = 10
|
|
6
|
+
DEFAULT_START_CONCURRENCY: int = 10
|
|
7
|
+
DEFAULT_MAX_CONCURRENCY: int = 200
|
|
8
|
+
DEFAULT_RAMP_SECS: int = 60
|
|
9
|
+
DEFAULT_HOLD_SECS: int = 30
|
|
10
|
+
DEFAULT_BASELINE: int = 5
|
|
11
|
+
DEFAULT_PEAK_CONCURRENCY: int = 500
|
|
12
|
+
DEFAULT_PRE_SPIKE_SECS: int = 5
|
|
13
|
+
DEFAULT_SPIKE_SECS: int = 10
|
|
14
|
+
DEFAULT_POST_SPIKE_SECS: int = 5
|
|
@@ -8,13 +8,27 @@ from strobengine._strobengine import (
|
|
|
8
8
|
run_load_profiles,
|
|
9
9
|
run_load_test,
|
|
10
10
|
)
|
|
11
|
+
from strobengine.constants import (
|
|
12
|
+
DEFAULT_BASELINE,
|
|
13
|
+
DEFAULT_CONCURRENCY,
|
|
14
|
+
DEFAULT_DURATION_SECS,
|
|
15
|
+
DEFAULT_HOLD_SECS,
|
|
16
|
+
DEFAULT_MAX_CONCURRENCY,
|
|
17
|
+
DEFAULT_PEAK_CONCURRENCY,
|
|
18
|
+
DEFAULT_POST_SPIKE_SECS,
|
|
19
|
+
DEFAULT_PRE_SPIKE_SECS,
|
|
20
|
+
DEFAULT_RAMP_SECS,
|
|
21
|
+
DEFAULT_SPIKE_SECS,
|
|
22
|
+
DEFAULT_START_CONCURRENCY,
|
|
23
|
+
DEFAULT_TIMEOUT_SECS,
|
|
24
|
+
)
|
|
11
25
|
|
|
12
26
|
|
|
13
27
|
@dataclass
|
|
14
28
|
class RequestOptions:
|
|
15
29
|
"""Encapsulates common HTTP and execution parameters with validation."""
|
|
16
30
|
|
|
17
|
-
timeout: int =
|
|
31
|
+
timeout: int = DEFAULT_TIMEOUT_SECS
|
|
18
32
|
chaos: bool = False
|
|
19
33
|
no_progress: bool = False
|
|
20
34
|
method: str = "GET"
|
|
@@ -30,8 +44,8 @@ class StrobEngine:
|
|
|
30
44
|
def __init__(
|
|
31
45
|
self,
|
|
32
46
|
url: str,
|
|
33
|
-
concurrency: int =
|
|
34
|
-
duration: int =
|
|
47
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
48
|
+
duration: int = DEFAULT_DURATION_SECS,
|
|
35
49
|
options: RequestOptions | None = None,
|
|
36
50
|
profile: LoadProfile | None = None,
|
|
37
51
|
) -> None:
|
|
@@ -64,8 +78,8 @@ class StrobEngine:
|
|
|
64
78
|
def load_test(
|
|
65
79
|
cls,
|
|
66
80
|
url: str,
|
|
67
|
-
concurrency: int =
|
|
68
|
-
duration: int =
|
|
81
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
82
|
+
duration: int = DEFAULT_DURATION_SECS,
|
|
69
83
|
options: RequestOptions | None = None,
|
|
70
84
|
) -> "StrobEngine":
|
|
71
85
|
return cls(
|
|
@@ -79,10 +93,10 @@ class StrobEngine:
|
|
|
79
93
|
def stress_test(
|
|
80
94
|
cls,
|
|
81
95
|
url: str,
|
|
82
|
-
start_concurrency: int =
|
|
83
|
-
max_concurrency: int =
|
|
84
|
-
ramp_duration: int =
|
|
85
|
-
hold_duration: int =
|
|
96
|
+
start_concurrency: int = DEFAULT_START_CONCURRENCY,
|
|
97
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
|
|
98
|
+
ramp_duration: int = DEFAULT_RAMP_SECS,
|
|
99
|
+
hold_duration: int = DEFAULT_HOLD_SECS,
|
|
86
100
|
options: RequestOptions | None = None,
|
|
87
101
|
) -> "StrobEngine":
|
|
88
102
|
if start_concurrency <= 0:
|
|
@@ -112,11 +126,11 @@ class StrobEngine:
|
|
|
112
126
|
def spike_test(
|
|
113
127
|
cls,
|
|
114
128
|
url: str,
|
|
115
|
-
baseline: int =
|
|
116
|
-
peak_concurrency: int =
|
|
117
|
-
pre_spike_duration: int =
|
|
118
|
-
spike_duration: int =
|
|
119
|
-
post_spike_duration: int =
|
|
129
|
+
baseline: int = DEFAULT_BASELINE,
|
|
130
|
+
peak_concurrency: int = DEFAULT_PEAK_CONCURRENCY,
|
|
131
|
+
pre_spike_duration: int = DEFAULT_PRE_SPIKE_SECS,
|
|
132
|
+
spike_duration: int = DEFAULT_SPIKE_SECS,
|
|
133
|
+
post_spike_duration: int = DEFAULT_POST_SPIKE_SECS,
|
|
120
134
|
options: RequestOptions | None = None,
|
|
121
135
|
) -> "StrobEngine":
|
|
122
136
|
if baseline <= 0:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|