fixtureqa 0.4.3__tar.gz → 0.4.5__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.3/fixtureqa.egg-info → fixtureqa-0.4.5}/PKG-INFO +1 -1
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/app.py +2 -1
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/perf.py +25 -2
- fixtureqa-0.4.5/fixture/core/exec_csv_writer.py +81 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_engine.py +45 -3
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_models.py +4 -0
- fixtureqa-0.4.3/fixture/static/assets/index-Dd6aSjfO.js → fixtureqa-0.4.5/fixture/static/assets/index-BKIC30ah.js +5 -5
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/index.html +1 -1
- {fixtureqa-0.4.3 → fixtureqa-0.4.5/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixtureqa.egg-info/SOURCES.txt +2 -1
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/pyproject.toml +1 -1
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_perf_api.py +43 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_perf_engine.py +51 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/LICENSE +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/README.md +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/server.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/assets/index-BwQf-cei.css +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/setup.cfg +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_health.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_ws.py +0 -0
|
@@ -90,7 +90,8 @@ def create_app(
|
|
|
90
90
|
app.state.spec_overlay_store = SpecOverlayStore(data_dir)
|
|
91
91
|
perf_writer = PerfWriter(os.path.join(data_dir, "perf.db"))
|
|
92
92
|
app.state.perf_writer = perf_writer
|
|
93
|
-
app.state.perf_registry = RunRegistry(
|
|
93
|
+
app.state.perf_registry = RunRegistry(
|
|
94
|
+
perf_writer, execs_dir=os.path.join(data_dir, "perf_execs"))
|
|
94
95
|
# Runs left non-terminal in perf.db were interrupted by a prior restart —
|
|
95
96
|
# mark them errored so History doesn't show zombie 'running' rows.
|
|
96
97
|
app.state.perf_registry.reconcile_interrupted()
|
|
@@ -12,7 +12,7 @@ import zipfile
|
|
|
12
12
|
from typing import Annotated, Optional
|
|
13
13
|
|
|
14
14
|
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
|
15
|
-
from fastapi.responses import Response, StreamingResponse
|
|
15
|
+
from fastapi.responses import FileResponse, Response, StreamingResponse
|
|
16
16
|
|
|
17
17
|
from ...core.models import SessionStatus
|
|
18
18
|
from ...core.perf_models import RunConfig, RunStatus
|
|
@@ -136,6 +136,11 @@ def run_scenarios(run_id: str, reg: REG, pw: PW, _admin: Admin,
|
|
|
136
136
|
return pw.read_scenarios(run_id, limit, offset)
|
|
137
137
|
|
|
138
138
|
|
|
139
|
+
def _run_is_live(reg: RunRegistry, run_id: str) -> bool:
|
|
140
|
+
run = reg.get(run_id)
|
|
141
|
+
return run is not None and run.status not in _TERMINAL
|
|
142
|
+
|
|
143
|
+
|
|
139
144
|
def _csv_stream(pw: PerfWriter, table: str, cols: tuple, run_id: str):
|
|
140
145
|
buf = io.StringIO()
|
|
141
146
|
w = csv.writer(buf)
|
|
@@ -172,6 +177,19 @@ def run_export(run_id: str, reg: REG, pw: PW, _admin: Admin,
|
|
|
172
177
|
_csv_stream(pw, "perf_scenarios", pw.scenario_columns(), run_id),
|
|
173
178
|
media_type="text/csv",
|
|
174
179
|
headers={"Content-Disposition": f'attachment; filename="{run_id}_scenarios.csv"'})
|
|
180
|
+
if kind == "execs":
|
|
181
|
+
# CSV-direct: the run streams per-exec rows straight into this csv.gz; the
|
|
182
|
+
# gzip footer is only written when the run finishes, so a live file would
|
|
183
|
+
# download truncated.
|
|
184
|
+
if _run_is_live(reg, run_id):
|
|
185
|
+
raise HTTPException(status_code=409,
|
|
186
|
+
detail="Run still active — the exec CSV is finalized when the run finishes")
|
|
187
|
+
path = reg.exec_csv_path(run_id)
|
|
188
|
+
if path is None:
|
|
189
|
+
raise HTTPException(status_code=404,
|
|
190
|
+
detail="No exec recording for this run (enable record_execs before starting)")
|
|
191
|
+
return FileResponse(path, media_type="application/gzip",
|
|
192
|
+
filename=f"{run_id}_execs.csv.gz")
|
|
175
193
|
if kind == "both":
|
|
176
194
|
buf = io.BytesIO()
|
|
177
195
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
@@ -179,9 +197,14 @@ def run_export(run_id: str, reg: REG, pw: PW, _admin: Admin,
|
|
|
179
197
|
_csv_text(pw, "perf_messages", pw.message_columns(), run_id))
|
|
180
198
|
z.writestr(f"{run_id}_scenarios.csv",
|
|
181
199
|
_csv_text(pw, "perf_scenarios", pw.scenario_columns(), run_id))
|
|
200
|
+
execs_path = reg.exec_csv_path(run_id)
|
|
201
|
+
if execs_path is not None and not _run_is_live(reg, run_id):
|
|
202
|
+
# Already gzipped — store as-is rather than re-deflating.
|
|
203
|
+
z.write(execs_path, arcname=f"{run_id}_execs.csv.gz",
|
|
204
|
+
compress_type=zipfile.ZIP_STORED)
|
|
182
205
|
return Response(buf.getvalue(), media_type="application/zip",
|
|
183
206
|
headers={"Content-Disposition": f'attachment; filename="{run_id}.zip"'})
|
|
184
|
-
raise HTTPException(status_code=422, detail="kind must be messages|scenarios|both")
|
|
207
|
+
raise HTTPException(status_code=422, detail="kind must be messages|scenarios|execs|both")
|
|
185
208
|
|
|
186
209
|
|
|
187
210
|
# --- saved configs --------------------------------------------------------
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-run gzipped CSV recorder for individual ExecutionReports (opt-in via
|
|
3
|
+
test.record_execs).
|
|
4
|
+
|
|
5
|
+
CSV-direct: there is no database staging — rows stream straight to
|
|
6
|
+
data/perf_execs/<run_id>.csv.gz and export serves the finished file as-is.
|
|
7
|
+
Same non-blocking producer contract as PerfWriter: the event loop only does a
|
|
8
|
+
put_nowait (drop+count when the bounded queue is full); all formatting and
|
|
9
|
+
gzip/disk work happens on one daemon writer thread, so recording cannot add
|
|
10
|
+
latency to the measured inbound path.
|
|
11
|
+
|
|
12
|
+
One instance per recording run; it owns its file exclusively for its lifetime.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import csv
|
|
17
|
+
import gzip
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import queue
|
|
21
|
+
import threading
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
DEFAULT_MAX_QUEUE = 200_000
|
|
26
|
+
|
|
27
|
+
EXEC_COLS = (
|
|
28
|
+
"run_id", "corr_id", "clordid", "exec_id",
|
|
29
|
+
"exec_type", "ord_status", # tags 150 / 39
|
|
30
|
+
"last_qty", "last_px", "cum_qty", "leaves_qty", # tags 32 / 31 / 14 / 151
|
|
31
|
+
"exec_seq", # 1-based report index within the order
|
|
32
|
+
"sent_at", # wall clock of the order send (same for all the order's rows)
|
|
33
|
+
"recv_at", # wall clock of this report's receive
|
|
34
|
+
"latency_us", # client receive − order send (one monotonic clock — authoritative;
|
|
35
|
+
# recv_at − sent_at is wall clock, for cross-referencing only)
|
|
36
|
+
"gap_us", # since the previous report for this order; empty on the first
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExecCsvWriter:
|
|
41
|
+
def __init__(self, path: str, max_queue: int = DEFAULT_MAX_QUEUE):
|
|
42
|
+
self.path = path
|
|
43
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
44
|
+
self._queue: queue.Queue = queue.Queue(maxsize=max_queue)
|
|
45
|
+
self.dropped = 0
|
|
46
|
+
self._thread = threading.Thread(target=self._run, name="perf-exec-csv", daemon=True)
|
|
47
|
+
self._thread.start()
|
|
48
|
+
|
|
49
|
+
# -- producer side (event loop; never blocks) -----------------------
|
|
50
|
+
|
|
51
|
+
def enqueue(self, row: tuple) -> None:
|
|
52
|
+
try:
|
|
53
|
+
self._queue.put_nowait(row)
|
|
54
|
+
except queue.Full:
|
|
55
|
+
self.dropped += 1
|
|
56
|
+
if self.dropped % 1000 == 1:
|
|
57
|
+
logger.warning("exec csv queue full — dropped %d row(s) so far", self.dropped)
|
|
58
|
+
|
|
59
|
+
def close(self) -> None:
|
|
60
|
+
"""Drain, finalize the gzip stream and stop. Blocks on the thread join —
|
|
61
|
+
call off-loop (asyncio.to_thread)."""
|
|
62
|
+
try:
|
|
63
|
+
self._queue.put(None, timeout=5)
|
|
64
|
+
except queue.Full:
|
|
65
|
+
logger.error("exec csv queue full on close — file %s may be incomplete", self.path)
|
|
66
|
+
self._thread.join(timeout=30.0)
|
|
67
|
+
|
|
68
|
+
# -- writer thread ---------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _run(self) -> None:
|
|
71
|
+
try:
|
|
72
|
+
with gzip.open(self.path, "wt", newline="") as fh:
|
|
73
|
+
w = csv.writer(fh)
|
|
74
|
+
w.writerow(EXEC_COLS)
|
|
75
|
+
while True:
|
|
76
|
+
item = self._queue.get()
|
|
77
|
+
if item is None:
|
|
78
|
+
return # with-block flushes + writes the gzip footer
|
|
79
|
+
w.writerow(item)
|
|
80
|
+
except Exception:
|
|
81
|
+
logger.exception("exec csv writer failed (%s)", self.path)
|
|
@@ -21,6 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
import asyncio
|
|
22
22
|
import json
|
|
23
23
|
import logging
|
|
24
|
+
import os
|
|
24
25
|
import random
|
|
25
26
|
import time
|
|
26
27
|
import uuid
|
|
@@ -28,6 +29,7 @@ from typing import Optional
|
|
|
28
29
|
|
|
29
30
|
from fixcore.message.message import Message
|
|
30
31
|
|
|
32
|
+
from .exec_csv_writer import ExecCsvWriter
|
|
31
33
|
from .fix_time import utc_timestamp
|
|
32
34
|
from .perf_models import (
|
|
33
35
|
RunConfig, RunStatus, LiveSnapshot, ClientLeg, VenueLeg, SnapshotErrors, LatencyStats,
|
|
@@ -80,11 +82,14 @@ def _to_float(v) -> Optional[float]:
|
|
|
80
82
|
class PerfRun:
|
|
81
83
|
def __init__(self, run_id: str, config: RunConfig, sm: SessionManager, writer: PerfWriter,
|
|
82
84
|
template_store=None, owner_uid: str = "",
|
|
83
|
-
sender_comp_id: str = "", target_comp_id: str = ""
|
|
85
|
+
sender_comp_id: str = "", target_comp_id: str = "",
|
|
86
|
+
execs_dir: Optional[str] = None):
|
|
84
87
|
self.run_id = run_id
|
|
85
88
|
self.config = config
|
|
86
89
|
self._sm = sm
|
|
87
90
|
self._writer = writer
|
|
91
|
+
self._execs_dir = execs_dir
|
|
92
|
+
self._exec_csv: Optional[ExecCsvWriter] = None
|
|
88
93
|
|
|
89
94
|
self.status: str = "pending"
|
|
90
95
|
self.started_at: Optional[float] = None
|
|
@@ -131,6 +136,16 @@ class PerfRun:
|
|
|
131
136
|
self.status = "running"
|
|
132
137
|
self.started_at = time.time()
|
|
133
138
|
self._start_ns = time.perf_counter_ns()
|
|
139
|
+
if self.config.test.record_execs:
|
|
140
|
+
# Per-exec capture happens in the client-leg correlator; venue-only
|
|
141
|
+
# runs have nothing to record.
|
|
142
|
+
if not self._has_client:
|
|
143
|
+
self.warnings.append("record_execs has no effect in venue mode (no client leg)")
|
|
144
|
+
elif not self._execs_dir:
|
|
145
|
+
self.warnings.append("record_execs unavailable (no exec CSV directory configured)")
|
|
146
|
+
else:
|
|
147
|
+
self._exec_csv = ExecCsvWriter(
|
|
148
|
+
os.path.join(self._execs_dir, f"{self.run_id}.csv.gz"))
|
|
134
149
|
if self._has_client:
|
|
135
150
|
self._client_sub = self._sm.subscribe_session(self.config.client_session_id)
|
|
136
151
|
if self._has_venue:
|
|
@@ -192,6 +207,10 @@ class PerfRun:
|
|
|
192
207
|
self._client_sub.close()
|
|
193
208
|
if self._venue_sub:
|
|
194
209
|
self._venue_sub.close()
|
|
210
|
+
if self._exec_csv is not None:
|
|
211
|
+
# Correlator is cancelled (no more enqueues); drain + gzip-finalize
|
|
212
|
+
# off-loop — close() joins the writer thread.
|
|
213
|
+
await asyncio.to_thread(self._exec_csv.close)
|
|
195
214
|
self.status = "stopped" if self._stopped_by_user else "completed"
|
|
196
215
|
self._build_snapshot()
|
|
197
216
|
self._publish()
|
|
@@ -482,6 +501,20 @@ class PerfRun:
|
|
|
482
501
|
return
|
|
483
502
|
entry["seen_exec"].add(exec_id)
|
|
484
503
|
|
|
504
|
+
if self._exec_csv is not None:
|
|
505
|
+
seq = entry["exec_seq"] = entry.get("exec_seq", 0) + 1
|
|
506
|
+
prev_ns = entry.get("last_recv_ns")
|
|
507
|
+
entry["last_recv_ns"] = now_ns
|
|
508
|
+
self._exec_csv.enqueue((
|
|
509
|
+
self.run_id, corr, entry.get("clordid"), exec_id,
|
|
510
|
+
f.get(T_EXECTYPE), f.get(T_ORDSTATUS),
|
|
511
|
+
_to_float(f.get(T_LASTQTY)), _to_float(f.get(T_LASTPX)),
|
|
512
|
+
_to_float(f.get(T_CUMQTY)), _to_float(f.get(T_LEAVESQTY)),
|
|
513
|
+
seq, entry.get("sent_at"), time.time(),
|
|
514
|
+
(now_ns - entry["sent_ns"]) / 1000.0,
|
|
515
|
+
((now_ns - prev_ns) / 1000.0) if prev_ns is not None else None,
|
|
516
|
+
))
|
|
517
|
+
|
|
485
518
|
if not entry["responded"]:
|
|
486
519
|
entry["responded"] = True
|
|
487
520
|
entry["first_resp_at"] = time.time()
|
|
@@ -640,8 +673,9 @@ class RunRegistry:
|
|
|
640
673
|
TICKET_TTL_S = 30.0
|
|
641
674
|
_TERMINAL_STATES = ("completed", "stopped", "error")
|
|
642
675
|
|
|
643
|
-
def __init__(self, writer: PerfWriter):
|
|
676
|
+
def __init__(self, writer: PerfWriter, execs_dir: Optional[str] = None):
|
|
644
677
|
self._writer = writer
|
|
678
|
+
self._execs_dir = execs_dir
|
|
645
679
|
self._runs: dict[str, PerfRun] = {}
|
|
646
680
|
self._tickets: dict[str, tuple[str, float]] = {}
|
|
647
681
|
|
|
@@ -649,11 +683,19 @@ class RunRegistry:
|
|
|
649
683
|
owner_uid: str = "", sender_comp_id: str = "", target_comp_id: str = "") -> PerfRun:
|
|
650
684
|
run_id = str(uuid.uuid4())
|
|
651
685
|
run = PerfRun(run_id, config, sm, self._writer, template_store, owner_uid,
|
|
652
|
-
sender_comp_id, target_comp_id)
|
|
686
|
+
sender_comp_id, target_comp_id, execs_dir=self._execs_dir)
|
|
653
687
|
self._runs[run_id] = run
|
|
654
688
|
run.start()
|
|
655
689
|
return run
|
|
656
690
|
|
|
691
|
+
def exec_csv_path(self, run_id: str) -> Optional[str]:
|
|
692
|
+
"""Path to the run's per-exec csv.gz, or None if it never recorded.
|
|
693
|
+
The file is complete (gzip footer written) only once the run is terminal."""
|
|
694
|
+
if not self._execs_dir:
|
|
695
|
+
return None
|
|
696
|
+
path = os.path.join(self._execs_dir, f"{run_id}.csv.gz")
|
|
697
|
+
return path if os.path.exists(path) else None
|
|
698
|
+
|
|
657
699
|
def get(self, run_id: str) -> Optional[PerfRun]:
|
|
658
700
|
"""The live PerfRun, or None if it's not in this process (e.g. historical).
|
|
659
701
|
Used for actions that need the live object: stop, WS watcher, ticket."""
|
|
@@ -43,6 +43,10 @@ class TestConfig(BaseModel):
|
|
|
43
43
|
max_pending: int = Field(default=100_000, gt=0)
|
|
44
44
|
on_saturation: SaturationPolicy = "pause"
|
|
45
45
|
output: str = "results"
|
|
46
|
+
# Record every inbound ExecutionReport (ack + each fill) to a per-run
|
|
47
|
+
# csv.gz for detailed latency analysis. Off by default: ~(1+fills_per_order)×
|
|
48
|
+
# the per-order row volume on disk.
|
|
49
|
+
record_execs: bool = False
|
|
46
50
|
|
|
47
51
|
|
|
48
52
|
class OrderPayloadConfig(BaseModel):
|