fixtureqa 0.1.8__tar.gz → 0.3.0__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.
Files changed (98) hide show
  1. {fixtureqa-0.1.8/fixtureqa.egg-info → fixtureqa-0.3.0}/PKG-INFO +2 -2
  2. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/__main__.py +8 -1
  3. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/app.py +43 -2
  4. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/connection_manager.py +95 -6
  5. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/deps.py +12 -0
  6. fixtureqa-0.3.0/fixture/api/routers/perf.py +245 -0
  7. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/sessions.py +17 -23
  8. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/ws.py +28 -10
  9. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/schemas.py +9 -0
  10. fixtureqa-0.3.0/fixture/core/atomic_io.py +38 -0
  11. fixtureqa-0.3.0/fixture/core/auth.py +105 -0
  12. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/config_store.py +25 -6
  13. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/custom_tag_store.py +3 -6
  14. fixtureqa-0.3.0/fixture/core/db_migrations.py +60 -0
  15. fixtureqa-0.3.0/fixture/core/fix_application.py +119 -0
  16. fixtureqa-0.3.0/fixture/core/fix_builder.py +132 -0
  17. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/housekeeping.py +11 -2
  18. fixtureqa-0.3.0/fixture/core/inbound.py +104 -0
  19. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/message_log.py +4 -1
  20. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/message_store.py +130 -41
  21. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/models.py +23 -0
  22. fixtureqa-0.3.0/fixture/core/perf_engine.py +741 -0
  23. fixtureqa-0.3.0/fixture/core/perf_models.py +205 -0
  24. fixtureqa-0.3.0/fixture/core/perf_payload.py +387 -0
  25. fixtureqa-0.3.0/fixture/core/perf_stats.py +153 -0
  26. fixtureqa-0.3.0/fixture/core/perf_store.py +46 -0
  27. fixtureqa-0.3.0/fixture/core/perf_writer.py +285 -0
  28. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/scenario_runner.py +37 -30
  29. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/scenario_store.py +3 -7
  30. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/session.py +99 -26
  31. fixtureqa-0.3.0/fixture/core/session_manager.py +258 -0
  32. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/spec_overlay_store.py +3 -6
  33. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/template_store.py +3 -7
  34. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/user_store.py +6 -0
  35. fixtureqa-0.3.0/fixture/static/assets/index-CLIVc6MX.js +102 -0
  36. fixtureqa-0.1.8/fixture/static/assets/index-Dv0_KeqF.css → fixtureqa-0.3.0/fixture/static/assets/index-WkbDd3YE.css +1 -1
  37. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/static/index.html +2 -2
  38. {fixtureqa-0.1.8 → fixtureqa-0.3.0/fixtureqa.egg-info}/PKG-INFO +2 -2
  39. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixtureqa.egg-info/SOURCES.txt +31 -3
  40. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixtureqa.egg-info/requires.txt +1 -1
  41. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/pyproject.toml +2 -2
  42. fixtureqa-0.3.0/tests/test_atomic_io.py +70 -0
  43. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/tests/test_auth.py +28 -0
  44. fixtureqa-0.3.0/tests/test_connection_manager.py +87 -0
  45. fixtureqa-0.3.0/tests/test_db_migrations.py +65 -0
  46. fixtureqa-0.3.0/tests/test_fix_builder.py +80 -0
  47. fixtureqa-0.3.0/tests/test_health.py +8 -0
  48. fixtureqa-0.3.0/tests/test_inbound.py +105 -0
  49. fixtureqa-0.3.0/tests/test_inbound_validation.py +96 -0
  50. fixtureqa-0.3.0/tests/test_message_store.py +126 -0
  51. fixtureqa-0.3.0/tests/test_perf_api.py +135 -0
  52. fixtureqa-0.3.0/tests/test_perf_engine.py +366 -0
  53. fixtureqa-0.3.0/tests/test_perf_models.py +65 -0
  54. fixtureqa-0.3.0/tests/test_perf_payload.py +213 -0
  55. fixtureqa-0.3.0/tests/test_perf_rehydrate.py +56 -0
  56. fixtureqa-0.3.0/tests/test_scenarios.py +75 -0
  57. fixtureqa-0.3.0/tests/test_session_lifecycle.py +82 -0
  58. fixtureqa-0.3.0/tests/test_session_manager_concurrency.py +90 -0
  59. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/tests/test_sessions.py +44 -0
  60. fixtureqa-0.3.0/tests/test_ws.py +54 -0
  61. fixtureqa-0.1.8/fixture/core/auth.py +0 -68
  62. fixtureqa-0.1.8/fixture/core/fix_application.py +0 -67
  63. fixtureqa-0.1.8/fixture/core/session_manager.py +0 -173
  64. fixtureqa-0.1.8/fixture/static/assets/index-DnrYtXs9.js +0 -102
  65. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/LICENSE +0 -0
  66. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/README.md +0 -0
  67. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/__init__.py +0 -0
  68. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/__init__.py +0 -0
  69. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/__init__.py +0 -0
  70. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/admin.py +0 -0
  71. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/auth.py +0 -0
  72. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/branding.py +0 -0
  73. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/custom_tags.py +0 -0
  74. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/fix_spec.py +0 -0
  75. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/messages.py +0 -0
  76. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/scenarios.py +0 -0
  77. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/setup.py +0 -0
  78. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/spec_overlay.py +0 -0
  79. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/templates.py +0 -0
  80. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/config/__init__.py +0 -0
  81. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/__init__.py +0 -0
  82. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/events.py +0 -0
  83. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/fix_parser.py +0 -0
  84. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/fix_spec_parser.py +0 -0
  85. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/fix_tags.py +0 -0
  86. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/venue_responses.py +0 -0
  87. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/fix_specs/FIX42.xml +0 -0
  88. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/fix_specs/FIX44.xml +0 -0
  89. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/server.py +0 -0
  90. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  91. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  92. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/static/favicon.svg +0 -0
  93. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/ui/__init__.py +0 -0
  94. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
  95. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixtureqa.egg-info/entry_points.txt +0 -0
  96. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixtureqa.egg-info/top_level.txt +0 -0
  97. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/setup.cfg +0 -0
  98. {fixtureqa-0.1.8 → fixtureqa-0.3.0}/tests/test_templates.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.1.8
3
+ Version: 0.3.0
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
7
- Requires-Dist: fixcore-engine
7
+ Requires-Dist: fixcore-engine==0.5.0
8
8
  Requires-Dist: fastapi>=0.111.0
9
9
  Requires-Dist: uvicorn[standard]>=0.29.0
10
10
  Requires-Dist: websockets>=12.0
@@ -31,13 +31,20 @@ def _configure_logging(level_name: str, log_file: str | None = None) -> None:
31
31
  root = logging.getLogger()
32
32
  root.setLevel(level)
33
33
 
34
- # Stdout handler
34
+ # Stdout handler. When a log file is also configured, keep the console at
35
+ # WARNING+ so high-rate perf runs (and chatty per-message engine logging)
36
+ # don't flood the terminal — the file still captures everything at `level`.
35
37
  sh = logging.StreamHandler()
36
38
  sh.setFormatter(formatter)
39
+ if log_file:
40
+ sh.setLevel(logging.WARNING)
37
41
  root.addHandler(sh)
38
42
 
39
43
  # Optional rotating file handler
40
44
  if log_file:
45
+ log_dir = os.path.dirname(log_file)
46
+ if log_dir:
47
+ os.makedirs(log_dir, exist_ok=True)
41
48
  fh = logging.handlers.RotatingFileHandler(
42
49
  log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
43
50
  )
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import os
2
3
  from contextlib import asynccontextmanager
3
4
 
@@ -14,19 +15,37 @@ from ..core.spec_overlay_store import SpecOverlayStore
14
15
  from ..core.template_store import TemplateStore
15
16
  from ..core.user_store import UserStore
16
17
  from .connection_manager import ConnectionManager
17
- from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding, custom_tags, spec_overlay
18
+ from ..core.perf_engine import RunRegistry
19
+ from ..core.perf_writer import PerfWriter
20
+ from ..core.perf_store import PerfStore
21
+ from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding, custom_tags, spec_overlay, perf
18
22
 
19
23
 
20
24
  @asynccontextmanager
21
25
  async def lifespan(app: FastAPI):
26
+ # Capture the running event loop so worker threads (scenarios, and later
27
+ # the perf injector) can submit coroutines back to it via
28
+ # run_coroutine_threadsafe. get_event_loop() does not work from a worker
29
+ # thread on Python 3.12.
30
+ loop = asyncio.get_running_loop()
31
+ app.state.loop = loop
32
+ app.state.scenario_runner.set_loop(loop)
22
33
  # Register event bridge with SessionManager
23
34
  sm: SessionManager = app.state.session_manager
24
35
  cm: ConnectionManager = app.state.connection_manager
25
36
  sm.subscribe(cm.sync_event_handler)
37
+ # Periodic WS message-rate aggregator (emits message_agg under high load)
38
+ agg_task = asyncio.create_task(cm.run_aggregator())
26
39
  # Start background housekeeping thread
27
40
  app.state.housekeeping.start()
28
41
  yield
42
+ agg_task.cancel()
29
43
  sm.unsubscribe(cm.sync_event_handler)
44
+ # Stop active perf runs before tearing down sessions, then flush perf writer
45
+ try:
46
+ await app.state.perf_registry.shutdown()
47
+ except Exception:
48
+ pass
30
49
  # Force-stop all running sessions so their asyncio tasks can be cancelled cleanly
31
50
  for sid in list(sm._sessions.keys()):
32
51
  try:
@@ -34,6 +53,7 @@ async def lifespan(app: FastAPI):
34
53
  except Exception:
35
54
  pass
36
55
  sm.close() # flush SQLite writer
56
+ app.state.perf_writer.close() # flush perf.db writer
37
57
 
38
58
 
39
59
  def create_app(
@@ -50,16 +70,30 @@ def create_app(
50
70
  lifespan=lifespan,
51
71
  )
52
72
 
73
+ # Resolve a stable JWT signing key (persists under data_dir if no env var)
74
+ from ..core.auth import init_secret_key
75
+ init_secret_key(data_dir)
76
+
53
77
  # Store singletons on app state (injected via Depends)
54
78
  app.state.session_manager = session_manager
55
79
  app.state.connection_manager = ConnectionManager()
56
80
  user_store = UserStore(db_path)
57
81
  app.state.user_store = user_store
58
- app.state.housekeeping = HousekeepingService(msg_db_path, log_dir, user_store)
82
+ app.state.housekeeping = HousekeepingService(
83
+ msg_db_path, log_dir, user_store,
84
+ message_writer=session_manager.message_writer,
85
+ )
59
86
  app.state.template_store = TemplateStore(data_dir)
60
87
  app.state.scenario_store = ScenarioStore(data_dir)
61
88
  app.state.custom_tag_store = CustomTagStore(data_dir)
62
89
  app.state.spec_overlay_store = SpecOverlayStore(data_dir)
90
+ perf_writer = PerfWriter(os.path.join(data_dir, "perf.db"))
91
+ app.state.perf_writer = perf_writer
92
+ app.state.perf_registry = RunRegistry(perf_writer)
93
+ # Runs left non-terminal in perf.db were interrupted by a prior restart —
94
+ # mark them errored so History doesn't show zombie 'running' rows.
95
+ app.state.perf_registry.reconcile_interrupted()
96
+ app.state.perf_store = PerfStore(data_dir)
63
97
  conn_manager: ConnectionManager = app.state.connection_manager
64
98
  app.state.scenario_runner = ScenarioRunner(
65
99
  session_manager, app.state.template_store, conn_manager.broadcast_scenario_event
@@ -77,8 +111,15 @@ def create_app(
77
111
  app.include_router(branding.router, prefix="/api")
78
112
  app.include_router(custom_tags.router, prefix="/api/custom-tags")
79
113
  app.include_router(spec_overlay.router, prefix="/api/spec-overlay")
114
+ app.include_router(perf.router, prefix="/api/perf")
80
115
  app.include_router(ws.router)
81
116
 
117
+ # Unauthenticated liveness probe (used by the Docker HEALTHCHECK). Registered
118
+ # before the SPA catch-all below so it isn't shadowed by index.html.
119
+ @app.get("/api/health")
120
+ def health():
121
+ return {"status": "ok"}
122
+
82
123
  # Serve built React app from fixture/static/
83
124
  static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
84
125
  static_dir = os.path.abspath(static_dir)
@@ -71,16 +71,41 @@ class ConnectionManager:
71
71
  Each connection carries a uid and is_admin flag for broadcast filtering.
72
72
  """
73
73
 
74
+ # Aggregation: stream per-message frames only up to PER_MSG_RATE_CAP msg/s
75
+ # per session; above that, suppress per-message and emit one periodic
76
+ # `message_agg` summary instead (the UI can't render thousands/sec anyway).
77
+ AGG_INTERVAL_S = 0.5
78
+ PER_MSG_RATE_CAP = 40 # msgs/sec/session below which per-message frames stream
79
+
74
80
  def __init__(self):
75
81
  self._clients: dict[str, dict[WebSocket, _ConnInfo]] = {}
82
+ # Hold strong references to in-flight broadcast tasks. asyncio only keeps
83
+ # a weak reference to tasks, so a fire-and-forget create_task() can be
84
+ # garbage-collected mid-execution, silently dropping a broadcast.
85
+ self._tasks: set[asyncio.Task] = set()
86
+ # Per-session message counters for the current aggregation window.
87
+ self._counters: dict[str, dict] = {}
88
+ self._per_window_cap = max(1, int(self.PER_MSG_RATE_CAP * self.AGG_INTERVAL_S))
89
+
90
+ def _has_clients(self) -> bool:
91
+ return any(self._clients.values())
92
+
93
+ def _spawn(self, coro) -> None:
94
+ """Schedule a broadcast coroutine, retaining a reference until it finishes."""
95
+ task = asyncio.create_task(coro)
96
+ self._tasks.add(task)
97
+ task.add_done_callback(self._tasks.discard)
76
98
 
77
99
  # ------------------------------------------------------------------
78
100
  # Called from async context (WS handler)
79
101
  # ------------------------------------------------------------------
80
102
 
81
103
  async def connect(self, websocket: WebSocket, session_id: str = "*",
82
- uid: str = "", is_admin: bool = False) -> None:
83
- await websocket.accept()
104
+ uid: str = "", is_admin: bool = False,
105
+ subprotocol: str | None = None) -> None:
106
+ # Echo the negotiated subprotocol ("bearer") when the token came in via
107
+ # the Sec-WebSocket-Protocol header, so the handshake completes cleanly.
108
+ await websocket.accept(subprotocol=subprotocol)
84
109
  self._clients.setdefault(session_id, {})[websocket] = _ConnInfo(uid, is_admin)
85
110
 
86
111
  def disconnect(self, websocket: WebSocket, session_id: str = "*") -> None:
@@ -92,13 +117,77 @@ class ConnectionManager:
92
117
  # ------------------------------------------------------------------
93
118
 
94
119
  def sync_event_handler(self, event: SessionEvent) -> None:
95
- """Registered with SessionManager.subscribe(). Called on the event loop."""
96
- payload = _serialize_event(event)
97
- asyncio.create_task(self._broadcast(event.session_id, payload, event.owner_uid))
120
+ """Registered with SessionManager.subscribe(). Called on the event loop.
121
+
122
+ Does nothing when no clients are connected (avoids per-message work at
123
+ high rate with nobody watching). Message events are rate-gated: streamed
124
+ per-message up to the cap, then suppressed in favour of periodic
125
+ `message_agg` summaries from run_aggregator().
126
+ """
127
+ if not self._has_clients():
128
+ return
129
+
130
+ et = event.event_type
131
+ if et in (EventType.MESSAGE_RECEIVED, EventType.MESSAGE_SENT):
132
+ sid = event.session_id
133
+ c = self._counters.get(sid)
134
+ if c is None:
135
+ c = {"count": 0, "in": 0, "out": 0, "last_seq": 0, "owner": event.owner_uid}
136
+ self._counters[sid] = c
137
+ c["count"] += 1
138
+ c["owner"] = event.owner_uid
139
+ if et == EventType.MESSAGE_RECEIVED:
140
+ c["in"] += 1
141
+ else:
142
+ c["out"] += 1
143
+ seq = event.payload.get("seq_num", 0)
144
+ if seq:
145
+ c["last_seq"] = seq
146
+ # Stream per-message only while under the per-window cap; beyond it
147
+ # the aggregator emits a summary instead (bounded task creation).
148
+ if c["count"] <= self._per_window_cap:
149
+ self._spawn(self._broadcast(sid, _serialize_event(event), event.owner_uid))
150
+ else:
151
+ # status / engine_error — always delivered immediately
152
+ self._spawn(self._broadcast(event.session_id, _serialize_event(event), event.owner_uid))
153
+
154
+ async def run_aggregator(self) -> None:
155
+ """Periodic task: emit a `message_agg` summary for any session whose
156
+ per-message frames were suppressed this window. Start in the app
157
+ lifespan; cancel on shutdown."""
158
+ try:
159
+ while True:
160
+ await asyncio.sleep(self.AGG_INTERVAL_S)
161
+ await self._emit_aggregates()
162
+ except asyncio.CancelledError:
163
+ pass
164
+
165
+ async def _emit_aggregates(self) -> None:
166
+ """Emit one message_agg per session whose per-message frames were
167
+ suppressed (high rate) this window. Resets the window counters."""
168
+ if not self._counters:
169
+ return
170
+ snapshot, self._counters = self._counters, {}
171
+ if not self._has_clients():
172
+ return
173
+ for sid, c in snapshot.items():
174
+ if c["count"] <= self._per_window_cap:
175
+ continue # low rate — per-message frames already covered it
176
+ payload = {
177
+ "type": "message_agg",
178
+ "session_id": sid,
179
+ "rate": round(c["count"] / self.AGG_INTERVAL_S, 1),
180
+ "in": c["in"],
181
+ "out": c["out"],
182
+ "last_seq": c["last_seq"],
183
+ "window_ms": int(self.AGG_INTERVAL_S * 1000),
184
+ "ts": _now_iso(),
185
+ }
186
+ await self._broadcast(sid, payload, c["owner"])
98
187
 
99
188
  def broadcast_scenario_event(self, uid: str, data: dict) -> None:
100
189
  """Schedule delivery to all WS connections for uid."""
101
- asyncio.create_task(self._broadcast_to_user(uid, data))
190
+ self._spawn(self._broadcast_to_user(uid, data))
102
191
 
103
192
  # ------------------------------------------------------------------
104
193
  # Internal async broadcast
@@ -81,3 +81,15 @@ def get_custom_tag_store(request: HTTPConnection) -> CustomTagStore:
81
81
 
82
82
  def get_spec_overlay_store(request: HTTPConnection) -> SpecOverlayStore:
83
83
  return request.app.state.spec_overlay_store
84
+
85
+
86
+ def get_perf_registry(request: HTTPConnection):
87
+ return request.app.state.perf_registry
88
+
89
+
90
+ def get_perf_writer(request: HTTPConnection):
91
+ return request.app.state.perf_writer
92
+
93
+
94
+ def get_perf_store(request: HTTPConnection):
95
+ return request.app.state.perf_store
@@ -0,0 +1,245 @@
1
+ """
2
+ Performance-testing REST + WebSocket API (admin-only).
3
+
4
+ Stage 1a: run lifecycle (start/list/get/stop) + live snapshot WS. The WS uses a
5
+ short-TTL single-use ticket (JWT never in the URL); the rest of the contract
6
+ (messages/scenarios/export/configs) lands in later stages.
7
+ """
8
+ import csv
9
+ import io
10
+ import logging
11
+ import zipfile
12
+ from typing import Annotated, Optional
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
15
+ from fastapi.responses import Response, StreamingResponse
16
+
17
+ from ...core.models import SessionStatus
18
+ from ...core.perf_models import RunConfig, RunStatus
19
+ from ...core.perf_engine import RunRegistry
20
+ from ...core.perf_payload import PerfConfigError
21
+ from ...core.perf_store import PerfStore
22
+ from ...core.perf_writer import PerfWriter
23
+ from ...core.session_manager import SessionManager
24
+ from ...core.template_store import TemplateStore
25
+ from ...core.user_store import User
26
+ from ..deps import (
27
+ get_session_manager, get_perf_registry, get_perf_writer, get_perf_store,
28
+ get_template_store, require_platform_admin,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ router = APIRouter(tags=["perf"])
34
+
35
+ SM = Annotated[SessionManager, Depends(get_session_manager)]
36
+ REG = Annotated[RunRegistry, Depends(get_perf_registry)]
37
+ PW = Annotated[PerfWriter, Depends(get_perf_writer)]
38
+ PS = Annotated[PerfStore, Depends(get_perf_store)]
39
+ TS = Annotated[TemplateStore, Depends(get_template_store)]
40
+ Admin = Annotated[User, Depends(require_platform_admin)]
41
+
42
+ _TERMINAL = ("completed", "stopped", "error")
43
+
44
+
45
+ def _require_started(sm: SessionManager, sid: str, label: str):
46
+ try:
47
+ state = sm.get_state(sid)
48
+ except KeyError:
49
+ raise HTTPException(status_code=409, detail=f"{label} session {sid!r} not found")
50
+ # The run drives load through the session but never starts/connects it — it
51
+ # must already be LOGGED_ON, or every send_message() silently returns False
52
+ # (orders all counted as rejected) and the run looks like it never started.
53
+ if state.status != SessionStatus.LOGGED_ON:
54
+ raise HTTPException(
55
+ status_code=409,
56
+ detail=(f"{label} session {sid!r} is not logged on "
57
+ f"(status {state.status.name}); start it and wait for the "
58
+ f"connection to establish before running a perf test"))
59
+ return state
60
+
61
+
62
+ @router.post("/runs", status_code=201)
63
+ async def create_run(body: RunConfig, sm: SM, reg: REG, ts: TS, admin: Admin):
64
+ # async so PerfRun.start()'s asyncio.create_task runs on the event loop
65
+ # (a sync def route runs in a threadpool with no running loop).
66
+ need_client = body.mode in ("loopback", "manager", "client")
67
+ need_venue = body.mode in ("loopback", "manager", "venue")
68
+ if need_client:
69
+ _require_started(sm, body.client_session_id, "client")
70
+ if need_venue:
71
+ _require_started(sm, body.venue_session_id, "venue")
72
+
73
+ # CompIDs from the driving (client, else venue) session — for template tokens.
74
+ drive_sid = body.client_session_id if need_client else body.venue_session_id
75
+ dcfg = sm.get_state(drive_sid).config
76
+ try:
77
+ run = reg.create(body, sm, template_store=ts, owner_uid=admin.uid,
78
+ sender_comp_id=dcfg.sender_comp_id, target_comp_id=dcfg.target_comp_id)
79
+ except PerfConfigError as e:
80
+ raise HTTPException(status_code=422, detail=str(e))
81
+
82
+ # Soft perf warnings — don't block, but flag footguns.
83
+ checked = []
84
+ if need_client:
85
+ checked.append((body.client_session_id, "client"))
86
+ if need_venue:
87
+ checked.append((body.venue_session_id, "venue"))
88
+ for sid, label in checked:
89
+ cfg = sm.get_state(sid).config
90
+ if cfg.durable_seqnums:
91
+ run.warnings.append(f"{label} session has durable_seqnums=True — fsync per message caps throughput")
92
+ if cfg.log_messages:
93
+ run.warnings.append(f"{label} session has log_messages=True — persistence churn adds loop latency")
94
+ return {"run_id": run.run_id}
95
+
96
+
97
+ @router.get("/runs", response_model=list[RunStatus])
98
+ def list_runs(reg: REG, _admin: Admin):
99
+ # Live runs + historical runs from perf.db (survives a backend restart).
100
+ return reg.list_status()
101
+
102
+
103
+ @router.get("/runs/{run_id}", response_model=RunStatus)
104
+ def get_run(run_id: str, reg: REG, _admin: Admin):
105
+ status = reg.get_status(run_id)
106
+ if status is None:
107
+ raise HTTPException(status_code=404, detail="Run not found")
108
+ return status
109
+
110
+
111
+ @router.delete("/runs/{run_id}", response_model=RunStatus)
112
+ async def stop_run(run_id: str, reg: REG, _admin: Admin):
113
+ run = await reg.stop(run_id)
114
+ if run is not None:
115
+ return run.status_response()
116
+ # Not live — a historical/terminal run can't be stopped, but report its status.
117
+ status = reg.get_status(run_id)
118
+ if status is None:
119
+ raise HTTPException(status_code=404, detail="Run not found")
120
+ return status
121
+
122
+
123
+ @router.get("/runs/{run_id}/messages")
124
+ def run_messages(run_id: str, reg: REG, pw: PW, _admin: Admin,
125
+ limit: int = Query(200, ge=1, le=5000), offset: int = Query(0, ge=0)):
126
+ if not reg.exists(run_id):
127
+ raise HTTPException(status_code=404, detail="Run not found")
128
+ return pw.read_messages(run_id, limit, offset)
129
+
130
+
131
+ @router.get("/runs/{run_id}/scenarios")
132
+ def run_scenarios(run_id: str, reg: REG, pw: PW, _admin: Admin,
133
+ limit: int = Query(200, ge=1, le=5000), offset: int = Query(0, ge=0)):
134
+ if not reg.exists(run_id):
135
+ raise HTTPException(status_code=404, detail="Run not found")
136
+ return pw.read_scenarios(run_id, limit, offset)
137
+
138
+
139
+ def _csv_stream(pw: PerfWriter, table: str, cols: tuple, run_id: str):
140
+ buf = io.StringIO()
141
+ w = csv.writer(buf)
142
+ w.writerow(cols)
143
+ yield buf.getvalue()
144
+ buf.seek(0); buf.truncate(0)
145
+ for row in pw.iter_rows(table, cols, run_id):
146
+ w.writerow(row)
147
+ yield buf.getvalue()
148
+ buf.seek(0); buf.truncate(0)
149
+
150
+
151
+ def _csv_text(pw: PerfWriter, table: str, cols: tuple, run_id: str) -> str:
152
+ out = io.StringIO()
153
+ w = csv.writer(out)
154
+ w.writerow(cols)
155
+ for row in pw.iter_rows(table, cols, run_id):
156
+ w.writerow(row)
157
+ return out.getvalue()
158
+
159
+
160
+ @router.get("/runs/{run_id}/export")
161
+ def run_export(run_id: str, reg: REG, pw: PW, _admin: Admin,
162
+ kind: str = Query("messages")):
163
+ if not reg.exists(run_id):
164
+ raise HTTPException(status_code=404, detail="Run not found")
165
+ if kind == "messages":
166
+ return StreamingResponse(
167
+ _csv_stream(pw, "perf_messages", pw.message_columns(), run_id),
168
+ media_type="text/csv",
169
+ headers={"Content-Disposition": f'attachment; filename="{run_id}_messages.csv"'})
170
+ if kind == "scenarios":
171
+ return StreamingResponse(
172
+ _csv_stream(pw, "perf_scenarios", pw.scenario_columns(), run_id),
173
+ media_type="text/csv",
174
+ headers={"Content-Disposition": f'attachment; filename="{run_id}_scenarios.csv"'})
175
+ if kind == "both":
176
+ buf = io.BytesIO()
177
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
178
+ z.writestr(f"{run_id}_messages.csv",
179
+ _csv_text(pw, "perf_messages", pw.message_columns(), run_id))
180
+ z.writestr(f"{run_id}_scenarios.csv",
181
+ _csv_text(pw, "perf_scenarios", pw.scenario_columns(), run_id))
182
+ return Response(buf.getvalue(), media_type="application/zip",
183
+ headers={"Content-Disposition": f'attachment; filename="{run_id}.zip"'})
184
+ raise HTTPException(status_code=422, detail="kind must be messages|scenarios|both")
185
+
186
+
187
+ # --- saved configs --------------------------------------------------------
188
+
189
+ @router.get("/configs")
190
+ def list_configs(store: PS, _admin: Admin):
191
+ return store.list()
192
+
193
+
194
+ @router.post("/configs", status_code=201)
195
+ def save_config(body: RunConfig, store: PS, _admin: Admin):
196
+ return {"config_id": store.save(body.model_dump())}
197
+
198
+
199
+ @router.get("/configs/{config_id}", response_model=RunConfig)
200
+ def get_config(config_id: str, store: PS, _admin: Admin):
201
+ cfg = store.get(config_id)
202
+ if cfg is None:
203
+ raise HTTPException(status_code=404, detail="Config not found")
204
+ return cfg
205
+
206
+
207
+ @router.delete("/configs/{config_id}", status_code=204)
208
+ def delete_config(config_id: str, store: PS, _admin: Admin):
209
+ if not store.delete(config_id):
210
+ raise HTTPException(status_code=404, detail="Config not found")
211
+
212
+
213
+ @router.post("/runs/{run_id}/ticket", status_code=201)
214
+ def ws_ticket(run_id: str, reg: REG, _admin: Admin):
215
+ if reg.get(run_id) is None:
216
+ raise HTTPException(status_code=404, detail="Run not found")
217
+ ticket, ttl = reg.issue_ticket(run_id)
218
+ return {"ticket": ticket, "expires_in": ttl}
219
+
220
+
221
+ @router.websocket("/runs/{run_id}/live")
222
+ async def live(websocket: WebSocket, run_id: str, ticket: Optional[str] = Query(default=None)):
223
+ reg: RunRegistry = websocket.app.state.perf_registry
224
+ if not ticket or reg.redeem_ticket(ticket) != run_id:
225
+ await websocket.close(code=4003, reason="Invalid or expired ticket")
226
+ return
227
+ run = reg.get(run_id)
228
+ if run is None:
229
+ await websocket.close(code=4004, reason="Run not found")
230
+ return
231
+
232
+ await websocket.accept()
233
+ q = run.add_watcher()
234
+ try:
235
+ while True:
236
+ snap = await q.get()
237
+ await websocket.send_json(snap)
238
+ if snap.get("status") in _TERMINAL:
239
+ break
240
+ except WebSocketDisconnect:
241
+ pass
242
+ except Exception:
243
+ pass
244
+ finally:
245
+ run.remove_watcher(q)
@@ -41,6 +41,9 @@ def _session_response(sm: SessionManager, session_id: str, us: Optional[UserStor
41
41
  end_time=cfg.end_time,
42
42
  reset_on_logon=cfg.reset_on_logon,
43
43
  reset_on_logout=cfg.reset_on_logout,
44
+ durable_seqnums=cfg.durable_seqnums,
45
+ log_messages=cfg.log_messages,
46
+ validate_inbound=cfg.validate_inbound,
44
47
  session_role=cfg.session_role.value,
45
48
  auto_ack=cfg.auto_ack,
46
49
  auto_ack_delay_ms=cfg.auto_ack_delay_ms,
@@ -102,6 +105,9 @@ async def create_session(body: SessionConfigRequest, sm: SM, user: CurrentUser):
102
105
  end_time=body.end_time,
103
106
  reset_on_logon=body.reset_on_logon,
104
107
  reset_on_logout=body.reset_on_logout,
108
+ durable_seqnums=body.durable_seqnums,
109
+ log_messages=body.log_messages,
110
+ validate_inbound=body.validate_inbound,
105
111
  session_role=role,
106
112
  auto_ack=body.auto_ack,
107
113
  auto_ack_delay_ms=body.auto_ack_delay_ms,
@@ -152,6 +158,9 @@ def import_sessions(body: list[SessionConfigRequest], sm: SM, user: CurrentUser)
152
158
  end_time=item.end_time,
153
159
  reset_on_logon=item.reset_on_logon,
154
160
  reset_on_logout=item.reset_on_logout,
161
+ durable_seqnums=item.durable_seqnums,
162
+ log_messages=item.log_messages,
163
+ validate_inbound=item.validate_inbound,
155
164
  session_role=role,
156
165
  auto_ack=item.auto_ack,
157
166
  auto_ack_delay_ms=item.auto_ack_delay_ms,
@@ -218,31 +227,16 @@ async def stop_session(
218
227
  @router.post("/{session_id}/send")
219
228
  async def send_message(session_id: str, body: SendMessageRequest, sm: SM, user: CurrentUser):
220
229
  _check_ownership(sm, session_id, user)
221
- from ...core.fix_parser import parse_raw
222
- from fixcore.message.message import Message
230
+ from ...core.fix_builder import build_message_from_raw
223
231
  from datetime import datetime, timezone
224
232
 
225
- raw = body.raw
226
- if "\x01" not in raw:
227
- raw = raw.replace("|", "\x01")
228
-
229
- fields = parse_raw(raw) # {tag_int: value_str}
230
- msg_type = fields.get(35)
231
- if not msg_type:
232
- raise HTTPException(status_code=422, detail="Missing MsgType (tag 35)")
233
-
234
- if not body.no_auto_ts and 60 not in fields:
235
- fields[60] = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S")
236
-
237
- SKIP = {8, 9, 10, 34, 35, 49, 52, 56}
238
- msg = Message()
239
- msg.header.set(35, msg_type)
240
- for tag, value in fields.items():
241
- if tag not in SKIP:
242
- try:
243
- msg.set_field(int(tag), str(value))
244
- except Exception:
245
- pass
233
+ # Build group-aware using the session's DataDictionary so repeating groups
234
+ # (e.g. NoAllocs rows) survive instead of collapsing to their last instance.
235
+ ts = None if body.no_auto_ts else datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S")
236
+ try:
237
+ msg = build_message_from_raw(body.raw, sm.get_data_dictionary(session_id), transact_time=ts)
238
+ except ValueError as e:
239
+ raise HTTPException(status_code=422, detail=str(e))
246
240
 
247
241
  ok = await sm.send_message(session_id, msg)
248
242
  if not ok:
@@ -40,19 +40,35 @@ async def _send_snapshot(websocket: WebSocket, sm: SessionManager, uid: str, is_
40
40
  })
41
41
 
42
42
 
43
- def _authenticate_ws(websocket: WebSocket, token: Optional[str]) -> tuple[str, bool]:
44
- """Validate JWT token from query param. Returns (uid, is_admin) or closes with 403."""
43
+ def _token_from_ws(websocket: WebSocket, query_token: Optional[str]) -> tuple[Optional[str], Optional[str]]:
44
+ """Resolve the JWT, preferring the Sec-WebSocket-Protocol header so the
45
+ token never appears in the URL (and thus not in access/proxy logs or
46
+ browser history). The browser sends ["bearer", "<jwt>"]; we echo "bearer"
47
+ as the negotiated subprotocol. Falls back to the legacy query param.
48
+
49
+ Returns (token, subprotocol_to_echo).
50
+ """
51
+ raw = websocket.headers.get("sec-websocket-protocol", "")
52
+ parts = [p.strip() for p in raw.split(",") if p.strip()]
53
+ if len(parts) >= 2 and parts[0] == "bearer":
54
+ return parts[1], "bearer"
55
+ return query_token, None
56
+
57
+
58
+ def _authenticate_ws(websocket: WebSocket, token: Optional[str]) -> tuple[Optional[str], bool, Optional[str]]:
59
+ """Validate the JWT. Returns (uid, is_admin, subprotocol) or (None, False, None)."""
60
+ token, subprotocol = _token_from_ws(websocket, token)
45
61
  if not token:
46
- return None, False
62
+ return None, False, None
47
63
  try:
48
64
  payload = decode_token(token)
49
65
  uid = payload.get("sub", "")
50
66
  role = payload.get("role", "")
51
67
  if not uid:
52
- return None, False
53
- return uid, role == "platform_admin"
68
+ return None, False, None
69
+ return uid, role == "platform_admin", subprotocol
54
70
  except JWTError:
55
- return None, False
71
+ return None, False, None
56
72
 
57
73
 
58
74
  @router.websocket("/ws")
@@ -63,7 +79,7 @@ async def ws_all_sessions(
63
79
  token: Optional[str] = Query(default=None),
64
80
  ):
65
81
  """Subscribe to events from all sessions."""
66
- uid, is_admin = _authenticate_ws(websocket, token)
82
+ uid, is_admin, subprotocol = _authenticate_ws(websocket, token)
67
83
  if not uid:
68
84
  logger.warning("WS /ws: auth failed (no token or invalid JWT)")
69
85
  await websocket.close(code=4003, reason="Unauthorized")
@@ -78,7 +94,8 @@ async def ws_all_sessions(
78
94
  return
79
95
 
80
96
  logger.info("WS /ws: client connected uid=%s is_admin=%s", uid, is_admin)
81
- await conn_mgr.connect(websocket, session_id="*", uid=uid, is_admin=is_admin)
97
+ await conn_mgr.connect(websocket, session_id="*", uid=uid, is_admin=is_admin,
98
+ subprotocol=subprotocol)
82
99
  try:
83
100
  await _send_snapshot(websocket, sm, uid, is_admin)
84
101
  while True:
@@ -103,7 +120,7 @@ async def ws_single_session(
103
120
  token: Optional[str] = Query(default=None),
104
121
  ):
105
122
  """Subscribe to events from a single session."""
106
- uid, is_admin = _authenticate_ws(websocket, token)
123
+ uid, is_admin, subprotocol = _authenticate_ws(websocket, token)
107
124
  if not uid:
108
125
  logger.warning("WS /ws/%s: auth failed (no token or invalid JWT)", session_id)
109
126
  await websocket.close(code=4003, reason="Unauthorized")
@@ -125,7 +142,8 @@ async def ws_single_session(
125
142
  return
126
143
 
127
144
  logger.info("WS /ws/%s: client connected uid=%s is_admin=%s", session_id, uid, is_admin)
128
- await conn_mgr.connect(websocket, session_id=session_id, uid=uid, is_admin=is_admin)
145
+ await conn_mgr.connect(websocket, session_id=session_id, uid=uid, is_admin=is_admin,
146
+ subprotocol=subprotocol)
129
147
  try:
130
148
  await _send_snapshot(websocket, sm, uid, is_admin, session_id=session_id)
131
149
  while True: