fixtureqa 0.4.22__tar.gz → 0.4.24__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 (102) hide show
  1. {fixtureqa-0.4.22/fixtureqa.egg-info → fixtureqa-0.4.24}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/messages.py +39 -14
  3. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/perf.py +64 -14
  4. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/message_store.py +57 -0
  5. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/models.py +5 -0
  6. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/perf_engine.py +4 -0
  7. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/session.py +5 -1
  8. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/session_manager.py +17 -0
  9. fixtureqa-0.4.24/fixture/static/assets/index-B3ccKxdv.js +102 -0
  10. fixtureqa-0.4.22/fixture/static/assets/index-V2H2CzlQ.css → fixtureqa-0.4.24/fixture/static/assets/index-meqdijDf.css +1 -1
  11. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/static/index.html +2 -2
  12. {fixtureqa-0.4.22 → fixtureqa-0.4.24/fixtureqa.egg-info}/PKG-INFO +1 -1
  13. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixtureqa.egg-info/SOURCES.txt +3 -2
  14. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/pyproject.toml +1 -1
  15. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_message_store.py +56 -0
  16. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_perf_api.py +46 -5
  17. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_perf_engine.py +21 -0
  18. fixtureqa-0.4.24/tests/test_session_auto_response.py +42 -0
  19. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_sessions.py +50 -0
  20. fixtureqa-0.4.22/fixture/static/assets/index-BQKTSxpO.js +0 -102
  21. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/LICENSE +0 -0
  22. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/README.md +0 -0
  23. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/__init__.py +0 -0
  24. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/__main__.py +0 -0
  25. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/__init__.py +0 -0
  26. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/app.py +0 -0
  27. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/connection_manager.py +0 -0
  28. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/deps.py +0 -0
  29. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/__init__.py +0 -0
  30. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/admin.py +0 -0
  31. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/auth.py +0 -0
  32. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/branding.py +0 -0
  33. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/custom_tags.py +0 -0
  34. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/fix_spec.py +0 -0
  35. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/scenarios.py +0 -0
  36. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/sessions.py +0 -0
  37. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/setup.py +0 -0
  38. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/spec_overlay.py +0 -0
  39. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/templates.py +0 -0
  40. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/routers/ws.py +0 -0
  41. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/api/schemas.py +0 -0
  42. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/config/__init__.py +0 -0
  43. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/__init__.py +0 -0
  44. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/atomic_io.py +0 -0
  45. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/auth.py +0 -0
  46. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/config_store.py +0 -0
  47. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/custom_tag_store.py +0 -0
  48. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/db_migrations.py +0 -0
  49. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/events.py +0 -0
  50. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/fix_application.py +0 -0
  51. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/fix_builder.py +0 -0
  52. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/fix_parser.py +0 -0
  53. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/fix_spec_parser.py +0 -0
  54. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/fix_tags.py +0 -0
  55. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/fix_time.py +0 -0
  56. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/housekeeping.py +0 -0
  57. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/inbound.py +0 -0
  58. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/json_store.py +0 -0
  59. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/latency_csv_writer.py +0 -0
  60. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/message_log.py +0 -0
  61. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/perf_charts.py +0 -0
  62. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/perf_models.py +0 -0
  63. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/perf_payload.py +0 -0
  64. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/perf_stats.py +0 -0
  65. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/perf_store.py +0 -0
  66. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/perf_writer.py +0 -0
  67. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/scenario_runner.py +0 -0
  68. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/scenario_store.py +0 -0
  69. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/spec_overlay_store.py +0 -0
  70. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/template_store.py +0 -0
  71. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/user_store.py +0 -0
  72. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/core/venue_responses.py +0 -0
  73. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/fix_specs/FIX42.xml +0 -0
  74. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/fix_specs/FIX44.xml +0 -0
  75. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/server.py +0 -0
  76. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  77. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  78. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  79. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/static/favicon.svg +0 -0
  80. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixture/ui/__init__.py +0 -0
  81. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixtureqa.egg-info/dependency_links.txt +0 -0
  82. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixtureqa.egg-info/entry_points.txt +0 -0
  83. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixtureqa.egg-info/requires.txt +0 -0
  84. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/fixtureqa.egg-info/top_level.txt +0 -0
  85. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/setup.cfg +0 -0
  86. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_atomic_io.py +0 -0
  87. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_auth.py +0 -0
  88. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_config_store.py +0 -0
  89. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_connection_manager.py +0 -0
  90. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_db_migrations.py +0 -0
  91. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_fix_builder.py +0 -0
  92. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_health.py +0 -0
  93. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_inbound.py +0 -0
  94. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_inbound_validation.py +0 -0
  95. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_perf_models.py +0 -0
  96. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_perf_payload.py +0 -0
  97. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_perf_rehydrate.py +0 -0
  98. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_scenarios.py +0 -0
  99. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_session_lifecycle.py +0 -0
  100. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_session_manager_concurrency.py +0 -0
  101. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_templates.py +0 -0
  102. {fixtureqa-0.4.22 → fixtureqa-0.4.24}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.22
3
+ Version: 0.4.24
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -4,6 +4,7 @@ from typing import Annotated, Optional
4
4
  from fastapi import APIRouter, Depends, HTTPException, Query
5
5
  from fastapi.responses import StreamingResponse
6
6
 
7
+ from ...core.message_store import MessageStore
7
8
  from ...core.models import SessionConfig
8
9
  from ...core.session_manager import SessionManager
9
10
  from ...core.user_store import User
@@ -16,6 +17,20 @@ SM = Annotated[SessionManager, Depends(get_session_manager)]
16
17
  OwnedSession = Annotated[SessionConfig, Depends(get_owned_session)]
17
18
 
18
19
 
20
+ def _to_response(e) -> MessageEntryResponse:
21
+ return MessageEntryResponse(
22
+ id=e.id,
23
+ direction=e.direction,
24
+ admin=e.admin,
25
+ seq_num=e.seq_num,
26
+ msg_type=e.msg_type,
27
+ msg_type_name=e.msg_type_name,
28
+ ts=e.timestamp.isoformat(),
29
+ fields={str(k): v for k, v in e.fields.items()},
30
+ raw=e.raw,
31
+ )
32
+
33
+
19
34
  @router.get("/{session_id}/messages", response_model=list[MessageEntryResponse])
20
35
  def get_messages(
21
36
  session_id: str,
@@ -44,20 +59,30 @@ def get_messages(
44
59
  before_id=before_id,
45
60
  )
46
61
 
47
- return [
48
- MessageEntryResponse(
49
- id=e.id,
50
- direction=e.direction,
51
- admin=e.admin,
52
- seq_num=e.seq_num,
53
- msg_type=e.msg_type,
54
- msg_type_name=e.msg_type_name,
55
- ts=e.timestamp.isoformat(),
56
- fields={str(k): v for k, v in e.fields.items()},
57
- raw=e.raw,
58
- )
59
- for e in entries
60
- ]
62
+ return [_to_response(e) for e in entries]
63
+
64
+
65
+ @router.get("/{session_id}/messages/search", response_model=list[MessageEntryResponse])
66
+ def search_messages(
67
+ session_id: str,
68
+ _cfg: OwnedSession,
69
+ sm: SM,
70
+ q: str = Query(..., min_length=1, description="substring or regex query over raw FIX"),
71
+ regex: bool = Query(default=False, description="treat q as a regular expression"),
72
+ limit: int = Query(default=1000, ge=1, le=5000),
73
+ ):
74
+ """Search the session's full messages.db history (not just the loaded page).
75
+ Matches against the raw FIX with SOH normalised to '|'. Returns the most
76
+ recent `limit` matches, oldest-first."""
77
+ writer = sm.message_writer
78
+ if writer is None:
79
+ return [] # message persistence disabled globally — nothing in messages.db
80
+ store = MessageStore(session_id, writer)
81
+ try:
82
+ entries = store.search_entries(q, regex=regex, limit=limit)
83
+ except ValueError as e:
84
+ raise HTTPException(status_code=400, detail=str(e))
85
+ return [_to_response(e) for e in entries]
61
86
 
62
87
 
63
88
  @router.get("/{session_id}/messages/export")
@@ -44,6 +44,32 @@ Admin = Annotated[User, Depends(require_platform_admin)]
44
44
 
45
45
  _TERMINAL = ("completed", "stopped", "error")
46
46
 
47
+ # Per-message persistence — durable_seqnums (fsync per message) and log_messages
48
+ # (a messages.db write per message) — happens inline on the event loop's
49
+ # send/receive path. Measured sustainable rate is ~36 msg/s on a local disk and
50
+ # materially worse on a Docker/WSL2 named volume; past that the loop stalls on
51
+ # fsync and the whole server appears frozen. We hold a conservative ceiling so a
52
+ # logged run at a low rate still works, while a high-rate logged run is refused
53
+ # up front (before the run starts) rather than wedging the server.
54
+ DURABLE_SAFE_MSG_RATE = 30 # messages/second a logged/durable session can sustain
55
+
56
+
57
+ def _est_msgs_per_order(body: RunConfig) -> int:
58
+ """Rough peak FIX message count generated per order, for the durability rate
59
+ guard: 35=D out + 35=8 ack + fills, plus a request+reply pair for each of the
60
+ amend / cancel flavours when enabled. Deliberately an upper-ish bound."""
61
+ n = 2 + body.payload.fill.fills_per_order # order + ack + fills
62
+ if body.amend.enabled:
63
+ n += 2 # 35=G + 35=8 replace
64
+ if body.cancel.enabled:
65
+ n += 2 # 35=F + 35=8/9 reply
66
+ return max(1, n)
67
+
68
+
69
+ def _order_rate_per_s(body: RunConfig) -> float:
70
+ r = body.rate
71
+ return r.orders_per_window * 1000.0 / r.order_window_ms
72
+
47
73
 
48
74
  def _require_started(sm: SessionManager, sid: str, label: str):
49
75
  try:
@@ -68,10 +94,45 @@ async def create_run(body: RunConfig, sm: SM, reg: REG, ts: TS, admin: Admin):
68
94
  # (a sync def route runs in a threadpool with no running loop).
69
95
  need_client = body.mode in ("loopback", "manager", "client")
70
96
  need_venue = body.mode in ("loopback", "manager", "venue")
97
+ participating: list[tuple[str, object]] = [] # (label, config) for fixture legs
71
98
  if need_client:
72
- _require_started(sm, body.client_session_id, "client")
99
+ participating.append(("client", _require_started(sm, body.client_session_id, "client").config))
73
100
  if need_venue:
74
- _require_started(sm, body.venue_session_id, "venue")
101
+ participating.append(("venue", _require_started(sm, body.venue_session_id, "venue").config))
102
+
103
+ # Durability rate guard — BEFORE reg.create() (which starts the run). A
104
+ # session with durable_seqnums or log_messages persists every message inline
105
+ # on the event loop; above a low rate this stalls the loop and freezes the
106
+ # whole server. Allow a logged run at a safe low rate (useful to inspect the
107
+ # messages); refuse above it with the safe order rate spelled out.
108
+ logged = [(label, cfg) for label, cfg in participating
109
+ if cfg.durable_seqnums or cfg.log_messages]
110
+ durable_warnings: list[str] = []
111
+ if logged:
112
+ mpo = _est_msgs_per_order(body)
113
+ est_msg_rate = _order_rate_per_s(body) * mpo
114
+ safe_order_rate = DURABLE_SAFE_MSG_RATE / mpo
115
+
116
+ def _flags(cfg) -> str:
117
+ f = [n for n, on in (("durable_seqnums", cfg.durable_seqnums),
118
+ ("log_messages", cfg.log_messages)) if on]
119
+ return "+".join(f)
120
+ who = ", ".join(f"{label} ({_flags(cfg)})" for label, cfg in logged)
121
+
122
+ if est_msg_rate > DURABLE_SAFE_MSG_RATE:
123
+ raise HTTPException(status_code=409, detail=(
124
+ f"This run would generate ~{est_msg_rate:.0f} msg/s "
125
+ f"(~{mpo} msgs/order × {_order_rate_per_s(body):.0f} orders/s), but "
126
+ f"{who} persist every message inline on the event loop "
127
+ f"(fsync / messages.db write). Above ~{DURABLE_SAFE_MSG_RATE} msg/s "
128
+ f"this stalls the server. Either lower the order rate to "
129
+ f"≤ {safe_order_rate:.0f}/s to keep message logging, or convert the "
130
+ f"session(s) to a Performance session (durable_seqnums + log_messages "
131
+ f"off) for high-rate runs."))
132
+ durable_warnings.append(
133
+ f"message persistence is on for {who}; safe only up to "
134
+ f"~{safe_order_rate:.0f} orders/s (~{DURABLE_SAFE_MSG_RATE} msg/s) — at "
135
+ f"higher rates the event loop stalls on per-message fsync/DB writes")
75
136
 
76
137
  # CompIDs from the driving (client, else venue) session — for template tokens.
77
138
  drive_sid = body.client_session_id if need_client else body.venue_session_id
@@ -82,18 +143,7 @@ async def create_run(body: RunConfig, sm: SM, reg: REG, ts: TS, admin: Admin):
82
143
  except PerfConfigError as e:
83
144
  raise HTTPException(status_code=422, detail=str(e))
84
145
 
85
- # Soft perf warnings — don't block, but flag footguns.
86
- checked = []
87
- if need_client:
88
- checked.append((body.client_session_id, "client"))
89
- if need_venue:
90
- checked.append((body.venue_session_id, "venue"))
91
- for sid, label in checked:
92
- cfg = sm.get_state(sid).config
93
- if cfg.durable_seqnums:
94
- run.warnings.append(f"{label} session has durable_seqnums=True — fsync per message caps throughput")
95
- if cfg.log_messages:
96
- run.warnings.append(f"{label} session has log_messages=True — persistence churn adds loop latency")
146
+ run.warnings.extend(durable_warnings)
97
147
  return {"run_id": run.run_id}
98
148
 
99
149
 
@@ -12,6 +12,7 @@ inserts so the I/O thread is never blocked by SQLite.
12
12
  import json
13
13
  import logging
14
14
  import queue
15
+ import re
15
16
  import sqlite3
16
17
  import threading
17
18
  from concurrent.futures import Future
@@ -78,6 +79,12 @@ def _entry_to_row(session_id: str, entry: LogEntry) -> tuple:
78
79
  )
79
80
 
80
81
 
82
+ def _like_escape(s: str) -> str:
83
+ """Escape LIKE wildcards so a substring query matches literally (paired
84
+ with ESCAPE '\\' in the SQL)."""
85
+ return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
86
+
87
+
81
88
  def _row_to_entry(row) -> LogEntry:
82
89
  row_id, _, ts, direction, admin, seq_num, mt, mt_name, raw, fields_json = row
83
90
  return LogEntry(
@@ -364,6 +371,56 @@ class MessageStore:
364
371
 
365
372
  return [_row_to_entry(r) for r in rows]
366
373
 
374
+ def search_entries(
375
+ self,
376
+ query: str,
377
+ *,
378
+ regex: bool = False,
379
+ limit: int = 1000,
380
+ ) -> list[LogEntry]:
381
+ """Search this session's persisted messages by raw content — the whole
382
+ messages.db history for the session, not just a loaded page.
383
+
384
+ The haystack is the raw FIX with SOH normalised to '|' (so a typed
385
+ 'tag=value' matches and the pattern lines up with the on-screen view).
386
+ text mode: case-insensitive substring via LIKE. regex mode: Python re
387
+ (case-insensitive) via a per-connection REGEXP function. Returns up to
388
+ `limit` most-recent matches, oldest-first. Raises ValueError on an
389
+ invalid regex (the route maps it to a 400)."""
390
+ query = query.strip()
391
+ if not query:
392
+ return []
393
+
394
+ hay = "replace(raw, char(1), '|')"
395
+ select = (
396
+ "SELECT id, session_id, ts, direction, admin, seq_num, "
397
+ "msg_type, msg_type_name, raw, fields_json FROM messages "
398
+ "WHERE session_id = ?"
399
+ )
400
+ params: list = [self._session_id]
401
+
402
+ with closing(_connect(self._db_path)) as conn:
403
+ if regex:
404
+ try:
405
+ rx = re.compile(query, re.IGNORECASE)
406
+ except re.error as e:
407
+ raise ValueError(f"invalid regex: {e}") from e
408
+ conn.create_function(
409
+ "re_match", 1,
410
+ lambda v: 1 if v is not None and rx.search(v) else 0,
411
+ deterministic=True,
412
+ )
413
+ cond = f"re_match({hay})"
414
+ else:
415
+ cond = f"{hay} LIKE ? ESCAPE '\\'"
416
+ params.append(f"%{_like_escape(query)}%")
417
+ sql = (f"SELECT * FROM ({select} AND {cond} ORDER BY id DESC LIMIT ?) "
418
+ "ORDER BY id ASC")
419
+ params.append(limit)
420
+ rows = conn.execute(sql, params).fetchall()
421
+
422
+ return [_row_to_entry(r) for r in rows]
423
+
367
424
  def __len__(self) -> int:
368
425
  with closing(_connect(self._db_path)) as conn:
369
426
  row = conn.execute(
@@ -108,3 +108,8 @@ class SessionState:
108
108
  message_log: Optional[MessageLog] = field(default=None) # set by Session on init
109
109
  status_changed_at: Optional[float] = None # time.time() of last status transition
110
110
  last_heartbeat_at: Optional[float] = None # time.time() of last inbound Heartbeat
111
+ # >0 while one or more perf runs drive this session: the run generates its
112
+ # own acks/fills, so venue auto-responses must stay silent to avoid
113
+ # duplicate/conflicting ExecReports corrupting the measurement. Counter (not
114
+ # bool) so overlapping runs nest. Runtime-only — never persisted.
115
+ perf_active: int = 0
@@ -232,8 +232,10 @@ class PerfRun:
232
232
  "inbound 35=F from the external client is still answered")
233
233
  if self._has_client:
234
234
  self._client_sub = self._sm.subscribe_session(self.config.client_session_id)
235
+ self._sm.mark_perf_active(self.config.client_session_id)
235
236
  if self._has_venue:
236
237
  self._venue_sub = self._sm.subscribe_session(self.config.venue_session_id)
238
+ self._sm.mark_perf_active(self.config.venue_session_id)
237
239
  self._writer.write_run(self._run_record())
238
240
  self._main = asyncio.create_task(self._run())
239
241
 
@@ -296,8 +298,10 @@ class PerfRun:
296
298
  self._venue_sends.clear()
297
299
  if self._client_sub:
298
300
  self._client_sub.close()
301
+ self._sm.unmark_perf_active(self.config.client_session_id)
299
302
  if self._venue_sub:
300
303
  self._venue_sub.close()
304
+ self._sm.unmark_perf_active(self.config.venue_session_id)
301
305
  if self._latency_csv is not None:
302
306
  # Correlator is cancelled (no more enqueues); drain + gzip-finalize
303
307
  # off-loop — close() joins the writer thread.
@@ -286,8 +286,12 @@ class Session:
286
286
  self.state.last_recv_seq_num = entry.seq_num
287
287
  if entry.msg_type == "0": # Heartbeat
288
288
  self.state.last_heartbeat_at = time.time()
289
+ # Stay silent while a perf run drives this session — the run
290
+ # generates its own acks/fills, so an auto-response here would
291
+ # double up and corrupt the measurement.
289
292
  if (entry.msg_type == "D" and not entry.admin
290
- and self.config.session_role.value == "venue"):
293
+ and self.config.session_role.value == "venue"
294
+ and self.state.perf_active == 0):
291
295
  self._schedule_venue_responses(entry.fields)
292
296
 
293
297
  self._event_handler(event)
@@ -223,6 +223,23 @@ class SessionManager:
223
223
  if not subs:
224
224
  self._session_subs.pop(sub.session_id, None)
225
225
 
226
+ def mark_perf_active(self, session_id: str) -> None:
227
+ """A perf run started driving this session — suppress its venue
228
+ auto-responses (the run owns the acks/fills) until unmarked. Counter,
229
+ so overlapping runs nest. Mirror with unmark_perf_active()."""
230
+ with self._lock:
231
+ session = self._sessions.get(session_id)
232
+ if session is not None:
233
+ session.state.perf_active += 1
234
+
235
+ def unmark_perf_active(self, session_id: str) -> None:
236
+ """A perf run stopped driving this session. Tolerates a session removed
237
+ mid-run (lookup may miss) and never underflows below zero."""
238
+ with self._lock:
239
+ session = self._sessions.get(session_id)
240
+ if session is not None:
241
+ session.state.perf_active = max(0, session.state.perf_active - 1)
242
+
226
243
  # ------------------------------------------------------------------
227
244
  # Internal
228
245
  # ------------------------------------------------------------------