fixtureqa 0.4.17__tar.gz → 0.4.19__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.
- {fixtureqa-0.4.17/fixtureqa.egg-info → fixtureqa-0.4.19}/PKG-INFO +2 -1
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/perf.py +41 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/exec_csv_writer.py +18 -10
- fixtureqa-0.4.19/fixture/core/perf_charts.py +170 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_engine.py +222 -35
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_stats.py +1 -0
- fixtureqa-0.4.19/fixture/static/assets/index-BSuHxk6b.js +102 -0
- fixtureqa-0.4.17/fixture/static/assets/index-BwQf-cei.css → fixtureqa-0.4.19/fixture/static/assets/index-o_l1KBYd.css +1 -1
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/static/index.html +2 -2
- {fixtureqa-0.4.17 → fixtureqa-0.4.19/fixtureqa.egg-info}/PKG-INFO +2 -1
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixtureqa.egg-info/SOURCES.txt +3 -2
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixtureqa.egg-info/requires.txt +1 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/pyproject.toml +2 -1
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_perf_api.py +37 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_perf_engine.py +160 -12
- fixtureqa-0.4.17/fixture/static/assets/index-BM8-U5CW.js +0 -102
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/LICENSE +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/README.md +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/app.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_models.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/server.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/setup.cfg +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_health.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_ws.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fixtureqa
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.19
|
|
4
4
|
Summary: FIXture — FIX Protocol Testing Tool
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
License-File: LICENSE
|
|
@@ -10,6 +10,7 @@ Requires-Dist: uvicorn[standard]>=0.29.0
|
|
|
10
10
|
Requires-Dist: websockets>=12.0
|
|
11
11
|
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
12
12
|
Requires-Dist: passlib[bcrypt]>=1.7.4
|
|
13
|
+
Requires-Dist: matplotlib>=3.7
|
|
13
14
|
Provides-Extra: dev
|
|
14
15
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
16
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
@@ -5,9 +5,11 @@ Stage 1a: run lifecycle (start/list/get/stop) + live snapshot WS. The WS uses a
|
|
|
5
5
|
short-TTL single-use ticket (JWT never in the URL); the rest of the contract
|
|
6
6
|
(messages/scenarios/export/configs) lands in later stages.
|
|
7
7
|
"""
|
|
8
|
+
import asyncio
|
|
8
9
|
import csv
|
|
9
10
|
import io
|
|
10
11
|
import logging
|
|
12
|
+
import os
|
|
11
13
|
import zipfile
|
|
12
14
|
from typing import Annotated, Optional
|
|
13
15
|
|
|
@@ -16,6 +18,7 @@ from fastapi.responses import FileResponse, Response, StreamingResponse
|
|
|
16
18
|
|
|
17
19
|
from ...core.models import SessionStatus
|
|
18
20
|
from ...core.perf_models import RunConfig, RunStatus
|
|
21
|
+
from ...core.perf_charts import render_latency_chart
|
|
19
22
|
from ...core.perf_engine import RunRegistry
|
|
20
23
|
from ...core.perf_payload import PerfConfigError
|
|
21
24
|
from ...core.perf_store import PerfStore
|
|
@@ -207,6 +210,44 @@ def run_export(run_id: str, reg: REG, pw: PW, _admin: Admin,
|
|
|
207
210
|
raise HTTPException(status_code=422, detail="kind must be messages|scenarios|execs|both")
|
|
208
211
|
|
|
209
212
|
|
|
213
|
+
@router.get("/runs/{run_id}/chart")
|
|
214
|
+
async def run_chart(run_id: str, reg: REG, _admin: Admin,
|
|
215
|
+
metric: str = Query("one_way"), agg: str = Query("both")):
|
|
216
|
+
"""Per-message latency chart (PNG) from the run's recorded csv.gz. Rendered
|
|
217
|
+
off-loop on first request and cached per (metric, agg) — the CSV is immutable
|
|
218
|
+
once the run finishes. Needs record_execs; 404 otherwise, 409 while live."""
|
|
219
|
+
if metric not in ("one_way", "round_trip", "both") or agg not in ("scatter", "percentile", "both"):
|
|
220
|
+
raise HTTPException(status_code=422,
|
|
221
|
+
detail="metric must be one_way|round_trip|both; agg must be scatter|percentile|both")
|
|
222
|
+
if not reg.exists(run_id):
|
|
223
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
224
|
+
if _run_is_live(reg, run_id):
|
|
225
|
+
raise HTTPException(status_code=409,
|
|
226
|
+
detail="Run still active — the latency chart is rendered when the run finishes")
|
|
227
|
+
csv_path = reg.exec_csv_path(run_id)
|
|
228
|
+
if csv_path is None or not os.path.exists(csv_path):
|
|
229
|
+
raise HTTPException(status_code=404,
|
|
230
|
+
detail="No per-message recording for this run (enable record_execs before starting)")
|
|
231
|
+
# Single-leg runs (client/venue) derive one-way from the wire send time;
|
|
232
|
+
# label it so the chart can't be misread as the single-clock measurement.
|
|
233
|
+
st = reg.get_status(run_id)
|
|
234
|
+
one_way_label = None
|
|
235
|
+
if st is not None and st.mode not in ("loopback", "manager"):
|
|
236
|
+
one_way_label = "one-way (tag-60 wire send → recv; needs synced clocks)"
|
|
237
|
+
out_path = f"{csv_path[:-len('.csv.gz')]}_{metric}_{agg}.png"
|
|
238
|
+
if not os.path.exists(out_path):
|
|
239
|
+
try:
|
|
240
|
+
await asyncio.to_thread(
|
|
241
|
+
render_latency_chart, csv_path, out_path,
|
|
242
|
+
metric=metric, agg=agg, title=f"Per-message latency — {run_id}",
|
|
243
|
+
one_way_label=one_way_label)
|
|
244
|
+
except ValueError as e:
|
|
245
|
+
# No chartable points (e.g. recording produced only un-latencied rows).
|
|
246
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
247
|
+
return FileResponse(out_path, media_type="image/png",
|
|
248
|
+
filename=f"{run_id}_latency.png")
|
|
249
|
+
|
|
250
|
+
|
|
210
251
|
# --- saved configs --------------------------------------------------------
|
|
211
252
|
|
|
212
253
|
@router.get("/configs")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Per-run gzipped CSV recorder for individual
|
|
3
|
-
|
|
2
|
+
Per-run gzipped CSV recorder for individual inbound correlated messages —
|
|
3
|
+
every ExecutionReport (ack, fill, amend/cancel reply) plus OrderCancelReject
|
|
4
|
+
(35=9), opt-in via test.record_execs.
|
|
4
5
|
|
|
5
6
|
CSV-direct: there is no database staging — rows stream straight to
|
|
6
7
|
data/perf_execs/<run_id>.csv.gz and export serves the finished file as-is.
|
|
@@ -10,6 +11,7 @@ gzip/disk work happens on one daemon writer thread, so recording cannot add
|
|
|
10
11
|
latency to the measured inbound path.
|
|
11
12
|
|
|
12
13
|
One instance per recording run; it owns its file exclusively for its lifetime.
|
|
14
|
+
Drives scripts/perf_latency_chart.py (per-`kind` latency plots).
|
|
13
15
|
"""
|
|
14
16
|
from __future__ import annotations
|
|
15
17
|
|
|
@@ -26,14 +28,20 @@ DEFAULT_MAX_QUEUE = 200_000
|
|
|
26
28
|
|
|
27
29
|
EXEC_COLS = (
|
|
28
30
|
"run_id", "corr_id", "clordid", "exec_id",
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
"
|
|
31
|
+
"kind", # message kind / chart series key (engine-assigned):
|
|
32
|
+
# client→venue: order / amend / cancel
|
|
33
|
+
# venue→client: ack / fill / pending-replace / replace /
|
|
34
|
+
# pending-cancel / canceled / cancel-reject / amend-reject
|
|
35
|
+
"direction", # c2v (client→hub→venue) | v2c (venue→hub→client)
|
|
36
|
+
"send_at", # wall clock of THIS message's send on the near leg (one-way x-axis)
|
|
37
|
+
"recv_at", # wall clock of THIS message's receive on the far leg
|
|
38
|
+
"req_send_at", # wall clock of the originating request's send (order/amend/cancel
|
|
39
|
+
# send; == send_at for c2v rows) — the round-trip x-axis
|
|
40
|
+
"one_way_us", # this message's one-way transit. manager/loopback: recv − send on
|
|
41
|
+
# one monotonic clock. client/venue-only: recv − the wire send time
|
|
42
|
+
# (TransactTime 60 / SendingTime 52), needs a synced clock.
|
|
43
|
+
"round_trip_us", # v2c only: recv − the originating request's send (our clock); empty for c2v
|
|
44
|
+
"exec_type", "ord_status", "cum_qty", "leaves_qty", # tags 150 / 39 / 14 / 151 (forensics)
|
|
37
45
|
)
|
|
38
46
|
|
|
39
47
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Post-run per-message latency charts from a recorded run's csv.gz
|
|
3
|
+
(test.record_execs). One plot line/series per message `kind` (ack / fill /
|
|
4
|
+
replace / canceled / cancel-reject / …): x = order send time (run progression),
|
|
5
|
+
y = per-message latency.
|
|
6
|
+
|
|
7
|
+
Single implementation, two callers: scripts/perf_latency_chart.py (offline CLI)
|
|
8
|
+
and the FastAPI /perf/runs/{id}/chart endpoint (rendered off-loop and cached).
|
|
9
|
+
|
|
10
|
+
matplotlib is imported lazily inside render_latency_chart so importing this
|
|
11
|
+
module (and starting the server) never pays the matplotlib import cost; the
|
|
12
|
+
render uses the Agg backend via the object-oriented Figure API, which is
|
|
13
|
+
safe to run on a worker thread (no pyplot global state, no GUI backend).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import csv
|
|
18
|
+
import gzip
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
# Stable colour per kind so charts are comparable across runs (mirrors the live
|
|
23
|
+
# dashboard hues). Unknown kinds fall back to the mpl cycle.
|
|
24
|
+
_KIND_COLOR = {
|
|
25
|
+
"order": "#38bdf8", # client→venue
|
|
26
|
+
"ack": "#00b4c8",
|
|
27
|
+
"fill": "#a78bfa",
|
|
28
|
+
"amend": "#f59e0b", # client→venue
|
|
29
|
+
"pending-replace": "#c084fc",
|
|
30
|
+
"replace": "#7c3aed",
|
|
31
|
+
"cancel": "#fb7185", # client→venue
|
|
32
|
+
"pending-cancel": "#fb923c",
|
|
33
|
+
"canceled": "#2dd4bf",
|
|
34
|
+
"cancel-reject": "#f43f5e",
|
|
35
|
+
"amend-reject": "#ef4444",
|
|
36
|
+
}
|
|
37
|
+
# Drawing order: order lifecycle, then amend, then cancel (request before reply).
|
|
38
|
+
_KIND_ORDER = ["order", "ack", "fill", "amend", "pending-replace", "replace",
|
|
39
|
+
"cancel", "pending-cancel", "canceled", "cancel-reject", "amend-reject"]
|
|
40
|
+
|
|
41
|
+
# Per metric: (latency column, x-axis column, axis label).
|
|
42
|
+
_METRIC = {
|
|
43
|
+
"one_way": ("one_way_us", "send_at", "one-way hub transit"),
|
|
44
|
+
"round_trip": ("round_trip_us", "req_send_at", "round-trip"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _pctl(sorted_vals: list[float], q: float) -> float:
|
|
49
|
+
"""Nearest-rank percentile of an already-sorted list (q in 0..1)."""
|
|
50
|
+
if not sorted_vals:
|
|
51
|
+
return 0.0
|
|
52
|
+
i = min(len(sorted_vals) - 1, max(0, int(round(q * (len(sorted_vals) - 1)))))
|
|
53
|
+
return sorted_vals[i]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_points(csv_gz_path: str, metric: str):
|
|
57
|
+
"""Group (x_seconds_since_start, y_ms) points by kind from the gz CSV for one
|
|
58
|
+
metric ('one_way' or 'round_trip'): y = that latency column, x = its send
|
|
59
|
+
column rebased to seconds since the first send."""
|
|
60
|
+
y_col, x_col, _ = _METRIC[metric]
|
|
61
|
+
by_kind: dict[str, list[tuple[float, float]]] = defaultdict(list)
|
|
62
|
+
t0: Optional[float] = None
|
|
63
|
+
with gzip.open(csv_gz_path, "rt", newline="") as fh:
|
|
64
|
+
for r in csv.DictReader(fh):
|
|
65
|
+
yraw = r.get(y_col) or ""
|
|
66
|
+
xraw = r.get(x_col) or ""
|
|
67
|
+
if yraw == "" or xraw == "":
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
x = float(xraw)
|
|
71
|
+
y_ms = float(yraw) / 1000.0
|
|
72
|
+
except ValueError:
|
|
73
|
+
continue
|
|
74
|
+
t0 = x if t0 is None else min(t0, x)
|
|
75
|
+
by_kind[r.get("kind") or "unknown"].append((x, y_ms))
|
|
76
|
+
if t0 is not None:
|
|
77
|
+
for pts in by_kind.values():
|
|
78
|
+
pts[:] = [(x - t0, y) for (x, y) in pts]
|
|
79
|
+
return by_kind
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ordered_kinds(by_kind) -> list[str]:
|
|
83
|
+
known = [k for k in _KIND_ORDER if k in by_kind]
|
|
84
|
+
extra = sorted(k for k in by_kind if k not in _KIND_COLOR)
|
|
85
|
+
return known + extra
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _draw(ax, by_kind, agg: str, bucket_s: float, logy: bool, ylabel: str, title: str):
|
|
89
|
+
for kind in _ordered_kinds(by_kind):
|
|
90
|
+
pts = sorted(by_kind[kind])
|
|
91
|
+
if not pts:
|
|
92
|
+
continue
|
|
93
|
+
color = _KIND_COLOR.get(kind)
|
|
94
|
+
xs = [p[0] for p in pts]
|
|
95
|
+
ys = [p[1] for p in pts]
|
|
96
|
+
if agg in ("scatter", "both"):
|
|
97
|
+
ax.scatter(xs, ys, s=6, alpha=0.25 if agg == "both" else 0.5,
|
|
98
|
+
color=color, label=None if agg == "both" else kind,
|
|
99
|
+
edgecolors="none")
|
|
100
|
+
if agg in ("percentile", "both"):
|
|
101
|
+
bx, p50, p99 = _bucketed(pts, bucket_s)
|
|
102
|
+
if bx:
|
|
103
|
+
ax.plot(bx, p50, color=color, lw=1.6, label=f"{kind} p50")
|
|
104
|
+
ax.plot(bx, p99, color=color, lw=1.0, ls="--", alpha=0.7,
|
|
105
|
+
label=f"{kind} p99")
|
|
106
|
+
ax.set_xlabel("run time (s, from first send)")
|
|
107
|
+
ax.set_ylabel(ylabel + (" [log]" if logy else ""))
|
|
108
|
+
if logy:
|
|
109
|
+
ax.set_yscale("log")
|
|
110
|
+
ax.grid(True, which="both", ls=":", alpha=0.4)
|
|
111
|
+
ax.legend(loc="upper left", fontsize=8, ncol=2, framealpha=0.85)
|
|
112
|
+
ax.set_title(title)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def render_latency_chart(csv_gz_path: str, out_path: str, *, metric: str = "one_way",
|
|
116
|
+
agg: str = "both", bucket_s: float = 2.0,
|
|
117
|
+
logy: bool = True, kinds: Optional[list[str]] = None,
|
|
118
|
+
title: Optional[str] = None, one_way_label: Optional[str] = None) -> str:
|
|
119
|
+
"""Render a per-kind latency chart from a recorded run CSV to out_path (PNG).
|
|
120
|
+
|
|
121
|
+
metric: 'one_way' (recv − send, this message's hub transit; x = its own send
|
|
122
|
+
time) | 'round_trip' (recv − the originating request's send; x = request send)
|
|
123
|
+
| 'both' (two stacked panels, one-way above round-trip).
|
|
124
|
+
agg: 'scatter' = one point per message; 'percentile' = per-kind p50 & p99 over
|
|
125
|
+
`bucket_s`-second buckets (exposes backlog); 'both' overlays them.
|
|
126
|
+
|
|
127
|
+
Blocking + CPU-bound (matplotlib) — call off the event loop. Returns out_path.
|
|
128
|
+
Raises ValueError if the CSV has no usable points for the metric.
|
|
129
|
+
"""
|
|
130
|
+
import matplotlib
|
|
131
|
+
matplotlib.use("Agg")
|
|
132
|
+
from matplotlib.figure import Figure
|
|
133
|
+
|
|
134
|
+
metrics = ["one_way", "round_trip"] if metric == "both" else [metric]
|
|
135
|
+
panels = []
|
|
136
|
+
for m in metrics:
|
|
137
|
+
by_kind = _read_points(csv_gz_path, m)
|
|
138
|
+
if kinds:
|
|
139
|
+
by_kind = {k: v for k, v in by_kind.items() if k in set(kinds)}
|
|
140
|
+
if any(by_kind.values()):
|
|
141
|
+
panels.append((m, by_kind))
|
|
142
|
+
if not panels:
|
|
143
|
+
raise ValueError(f"no chartable {metric!r} latency points in {csv_gz_path}")
|
|
144
|
+
|
|
145
|
+
fig = Figure(figsize=(12, 6.5 * len(panels)), dpi=120)
|
|
146
|
+
axes = fig.subplots(len(panels), 1, squeeze=False)[:, 0]
|
|
147
|
+
base = title or "Per-message latency"
|
|
148
|
+
for ax, (m, by_kind) in zip(axes, panels):
|
|
149
|
+
label = one_way_label if (m == "one_way" and one_way_label) else _METRIC[m][2]
|
|
150
|
+
_draw(ax, by_kind, agg, bucket_s, logy, f"latency (ms) — {label}",
|
|
151
|
+
base if len(panels) == 1 else f"{base} — {label}")
|
|
152
|
+
fig.tight_layout()
|
|
153
|
+
fig.savefig(out_path)
|
|
154
|
+
return out_path
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _bucketed(sorted_pts: list[tuple[float, float]], bucket_s: float):
|
|
158
|
+
"""Per-bucket (center_x, p50, p99) over fixed-width time buckets."""
|
|
159
|
+
if bucket_s <= 0:
|
|
160
|
+
bucket_s = 1.0
|
|
161
|
+
buckets: dict[int, list[float]] = defaultdict(list)
|
|
162
|
+
for x, y in sorted_pts:
|
|
163
|
+
buckets[int(x // bucket_s)].append(y)
|
|
164
|
+
bx, p50, p99 = [], [], []
|
|
165
|
+
for b in sorted(buckets):
|
|
166
|
+
ys = sorted(buckets[b])
|
|
167
|
+
bx.append((b + 0.5) * bucket_s)
|
|
168
|
+
p50.append(_pctl(ys, 0.50))
|
|
169
|
+
p99.append(_pctl(ys, 0.99))
|
|
170
|
+
return bx, p50, p99
|