fixtureqa 0.1.8__tar.gz → 0.2.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 (78) hide show
  1. {fixtureqa-0.1.8/fixtureqa.egg-info → fixtureqa-0.2.0}/PKG-INFO +1 -1
  2. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/app.py +16 -1
  3. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/connection_manager.py +17 -4
  4. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/ws.py +28 -10
  5. fixtureqa-0.2.0/fixture/core/atomic_io.py +38 -0
  6. fixtureqa-0.2.0/fixture/core/auth.py +105 -0
  7. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/config_store.py +23 -6
  8. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/custom_tag_store.py +3 -6
  9. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/fix_application.py +13 -2
  10. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/housekeeping.py +11 -2
  11. fixtureqa-0.2.0/fixture/core/inbound.py +104 -0
  12. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/message_store.py +121 -40
  13. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/scenario_runner.py +13 -2
  14. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/scenario_store.py +3 -7
  15. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/session.py +34 -12
  16. fixtureqa-0.2.0/fixture/core/session_manager.py +253 -0
  17. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/spec_overlay_store.py +3 -6
  18. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/template_store.py +3 -7
  19. fixtureqa-0.2.0/fixture/static/assets/index-jaBTfclx.js +102 -0
  20. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/static/index.html +1 -1
  21. {fixtureqa-0.1.8 → fixtureqa-0.2.0/fixtureqa.egg-info}/PKG-INFO +1 -1
  22. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixtureqa.egg-info/SOURCES.txt +11 -2
  23. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/pyproject.toml +1 -1
  24. fixtureqa-0.2.0/tests/test_atomic_io.py +70 -0
  25. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/tests/test_auth.py +28 -0
  26. fixtureqa-0.2.0/tests/test_inbound.py +105 -0
  27. fixtureqa-0.2.0/tests/test_message_store.py +126 -0
  28. fixtureqa-0.2.0/tests/test_scenarios.py +75 -0
  29. fixtureqa-0.2.0/tests/test_session_lifecycle.py +82 -0
  30. fixtureqa-0.2.0/tests/test_session_manager_concurrency.py +90 -0
  31. fixtureqa-0.2.0/tests/test_ws.py +54 -0
  32. fixtureqa-0.1.8/fixture/core/auth.py +0 -68
  33. fixtureqa-0.1.8/fixture/core/session_manager.py +0 -173
  34. fixtureqa-0.1.8/fixture/static/assets/index-DnrYtXs9.js +0 -102
  35. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/LICENSE +0 -0
  36. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/README.md +0 -0
  37. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/__init__.py +0 -0
  38. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/__main__.py +0 -0
  39. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/__init__.py +0 -0
  40. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/deps.py +0 -0
  41. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/__init__.py +0 -0
  42. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/admin.py +0 -0
  43. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/auth.py +0 -0
  44. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/branding.py +0 -0
  45. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/custom_tags.py +0 -0
  46. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/fix_spec.py +0 -0
  47. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/messages.py +0 -0
  48. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/scenarios.py +0 -0
  49. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/sessions.py +0 -0
  50. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/setup.py +0 -0
  51. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/spec_overlay.py +0 -0
  52. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/templates.py +0 -0
  53. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/schemas.py +0 -0
  54. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/config/__init__.py +0 -0
  55. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/__init__.py +0 -0
  56. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/events.py +0 -0
  57. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/fix_parser.py +0 -0
  58. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/fix_spec_parser.py +0 -0
  59. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/fix_tags.py +0 -0
  60. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/message_log.py +0 -0
  61. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/models.py +0 -0
  62. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/user_store.py +0 -0
  63. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/venue_responses.py +0 -0
  64. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/fix_specs/FIX42.xml +0 -0
  65. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/fix_specs/FIX44.xml +0 -0
  66. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/server.py +0 -0
  67. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  68. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/static/assets/index-Dv0_KeqF.css +0 -0
  69. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  70. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/static/favicon.svg +0 -0
  71. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/ui/__init__.py +0 -0
  72. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
  73. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixtureqa.egg-info/entry_points.txt +0 -0
  74. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixtureqa.egg-info/requires.txt +0 -0
  75. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixtureqa.egg-info/top_level.txt +0 -0
  76. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/setup.cfg +0 -0
  77. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/tests/test_sessions.py +0 -0
  78. {fixtureqa-0.1.8 → fixtureqa-0.2.0}/tests/test_templates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import os
2
3
  from contextlib import asynccontextmanager
3
4
 
@@ -19,6 +20,13 @@ from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templ
19
20
 
20
21
  @asynccontextmanager
21
22
  async def lifespan(app: FastAPI):
23
+ # Capture the running event loop so worker threads (scenarios, and later
24
+ # the perf injector) can submit coroutines back to it via
25
+ # run_coroutine_threadsafe. get_event_loop() does not work from a worker
26
+ # thread on Python 3.12.
27
+ loop = asyncio.get_running_loop()
28
+ app.state.loop = loop
29
+ app.state.scenario_runner.set_loop(loop)
22
30
  # Register event bridge with SessionManager
23
31
  sm: SessionManager = app.state.session_manager
24
32
  cm: ConnectionManager = app.state.connection_manager
@@ -50,12 +58,19 @@ def create_app(
50
58
  lifespan=lifespan,
51
59
  )
52
60
 
61
+ # Resolve a stable JWT signing key (persists under data_dir if no env var)
62
+ from ..core.auth import init_secret_key
63
+ init_secret_key(data_dir)
64
+
53
65
  # Store singletons on app state (injected via Depends)
54
66
  app.state.session_manager = session_manager
55
67
  app.state.connection_manager = ConnectionManager()
56
68
  user_store = UserStore(db_path)
57
69
  app.state.user_store = user_store
58
- app.state.housekeeping = HousekeepingService(msg_db_path, log_dir, user_store)
70
+ app.state.housekeeping = HousekeepingService(
71
+ msg_db_path, log_dir, user_store,
72
+ message_writer=session_manager.message_writer,
73
+ )
59
74
  app.state.template_store = TemplateStore(data_dir)
60
75
  app.state.scenario_store = ScenarioStore(data_dir)
61
76
  app.state.custom_tag_store = CustomTagStore(data_dir)
@@ -73,14 +73,27 @@ class ConnectionManager:
73
73
 
74
74
  def __init__(self):
75
75
  self._clients: dict[str, dict[WebSocket, _ConnInfo]] = {}
76
+ # Hold strong references to in-flight broadcast tasks. asyncio only keeps
77
+ # a weak reference to tasks, so a fire-and-forget create_task() can be
78
+ # garbage-collected mid-execution, silently dropping a broadcast.
79
+ self._tasks: set[asyncio.Task] = set()
80
+
81
+ def _spawn(self, coro) -> None:
82
+ """Schedule a broadcast coroutine, retaining a reference until it finishes."""
83
+ task = asyncio.create_task(coro)
84
+ self._tasks.add(task)
85
+ task.add_done_callback(self._tasks.discard)
76
86
 
77
87
  # ------------------------------------------------------------------
78
88
  # Called from async context (WS handler)
79
89
  # ------------------------------------------------------------------
80
90
 
81
91
  async def connect(self, websocket: WebSocket, session_id: str = "*",
82
- uid: str = "", is_admin: bool = False) -> None:
83
- await websocket.accept()
92
+ uid: str = "", is_admin: bool = False,
93
+ subprotocol: str | None = None) -> None:
94
+ # Echo the negotiated subprotocol ("bearer") when the token came in via
95
+ # the Sec-WebSocket-Protocol header, so the handshake completes cleanly.
96
+ await websocket.accept(subprotocol=subprotocol)
84
97
  self._clients.setdefault(session_id, {})[websocket] = _ConnInfo(uid, is_admin)
85
98
 
86
99
  def disconnect(self, websocket: WebSocket, session_id: str = "*") -> None:
@@ -94,11 +107,11 @@ class ConnectionManager:
94
107
  def sync_event_handler(self, event: SessionEvent) -> None:
95
108
  """Registered with SessionManager.subscribe(). Called on the event loop."""
96
109
  payload = _serialize_event(event)
97
- asyncio.create_task(self._broadcast(event.session_id, payload, event.owner_uid))
110
+ self._spawn(self._broadcast(event.session_id, payload, event.owner_uid))
98
111
 
99
112
  def broadcast_scenario_event(self, uid: str, data: dict) -> None:
100
113
  """Schedule delivery to all WS connections for uid."""
101
- asyncio.create_task(self._broadcast_to_user(uid, data))
114
+ self._spawn(self._broadcast_to_user(uid, data))
102
115
 
103
116
  # ------------------------------------------------------------------
104
117
  # Internal async broadcast
@@ -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:
@@ -0,0 +1,38 @@
1
+ """
2
+ Atomic JSON file writes shared by the JSON-backed stores.
3
+
4
+ Each store previously wrote to a fixed "<path>.tmp" then os.replace()'d it.
5
+ Two concurrent writers to the same path clobbered that single temp file,
6
+ so the bytes promoted by os.replace could be a corrupt interleaving. This
7
+ helper writes to a uniquely-named temp file in the same directory, fsyncs it,
8
+ then renames over the target — atomic and collision-free.
9
+ """
10
+ import json
11
+ import os
12
+ import tempfile
13
+
14
+
15
+ def atomic_write_json(path: str, data, *, indent: int = 2) -> None:
16
+ """Atomically serialize `data` as JSON to `path`.
17
+
18
+ The temp file is created in the same directory as `path` (so os.replace is
19
+ a same-filesystem atomic rename) with a unique name (so concurrent writers
20
+ don't share a temp file). On any failure the temp file is removed.
21
+ """
22
+ directory = os.path.dirname(path) or "."
23
+ os.makedirs(directory, exist_ok=True)
24
+ fd, tmp = tempfile.mkstemp(
25
+ prefix=os.path.basename(path) + ".", suffix=".tmp", dir=directory
26
+ )
27
+ try:
28
+ with os.fdopen(fd, "w") as f:
29
+ json.dump(data, f, indent=indent)
30
+ f.flush()
31
+ os.fsync(f.fileno())
32
+ os.replace(tmp, path)
33
+ except BaseException:
34
+ try:
35
+ os.unlink(tmp)
36
+ except OSError:
37
+ pass
38
+ raise
@@ -0,0 +1,105 @@
1
+ """
2
+ Password hashing and JWT helpers.
3
+
4
+ Secret key is read from the FIXTURE_SECRET_KEY environment variable.
5
+ If absent a random key is generated for the current process (tokens
6
+ won't survive a restart — acceptable for dev, not for production).
7
+ """
8
+ import logging
9
+ import os
10
+ import secrets
11
+
12
+ import bcrypt as _bcrypt
13
+
14
+ from jose import JWTError, jwt
15
+
16
+ from .user_store import User
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _ALGORITHM = "HS256"
21
+ _EXPIRY_SECONDS = 8 * 60 * 60 # 8 hours
22
+
23
+ # Set from FIXTURE_SECRET_KEY at import; otherwise resolved by init_secret_key()
24
+ # (persistent file) or, as a last resort, lazily generated by _get_secret().
25
+ _SECRET_KEY: str = os.environ.get("FIXTURE_SECRET_KEY", "")
26
+
27
+
28
+ def init_secret_key(data_dir: str) -> None:
29
+ """Resolve a stable signing key when FIXTURE_SECRET_KEY is not set.
30
+
31
+ Loads (or creates) a persistent key file under data_dir so tokens survive
32
+ restarts in dev. Production should still set FIXTURE_SECRET_KEY explicitly
33
+ — it is required for multi-process deployments, where each worker would
34
+ otherwise generate its own key and reject each other's tokens.
35
+ """
36
+ global _SECRET_KEY
37
+ if _SECRET_KEY:
38
+ return # explicit env var wins
39
+
40
+ key_path = os.path.join(data_dir, ".secret_key")
41
+ try:
42
+ if os.path.isfile(key_path):
43
+ with open(key_path) as f:
44
+ _SECRET_KEY = f.read().strip()
45
+ if not _SECRET_KEY:
46
+ _SECRET_KEY = secrets.token_hex(32)
47
+ os.makedirs(data_dir, exist_ok=True)
48
+ # 0600 — the key must not be world-readable.
49
+ fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
50
+ with os.fdopen(fd, "w") as f:
51
+ f.write(_SECRET_KEY)
52
+ logger.warning(
53
+ "FIXTURE_SECRET_KEY not set — generated a persistent key at %s. "
54
+ "Set FIXTURE_SECRET_KEY in production (required for multi-process).",
55
+ key_path,
56
+ )
57
+ except OSError as e:
58
+ logger.warning("Could not persist secret key (%s); falling back to ephemeral.", e)
59
+
60
+
61
+ def _get_secret() -> str:
62
+ global _SECRET_KEY
63
+ if not _SECRET_KEY:
64
+ # Last resort: no env var and init_secret_key never ran / failed.
65
+ _SECRET_KEY = secrets.token_hex(32)
66
+ logger.warning(
67
+ "FIXTURE_SECRET_KEY not set — using a random ephemeral key. "
68
+ "Tokens will be invalidated on restart."
69
+ )
70
+ return _SECRET_KEY
71
+
72
+
73
+ def _encode(plain: str) -> bytes:
74
+ # bcrypt hard-limits at 72 bytes — truncate to avoid ValueError in bcrypt 5.x
75
+ return plain.encode("utf-8")[:72]
76
+
77
+
78
+ def hash_password(plain: str) -> str:
79
+ return _bcrypt.hashpw(_encode(plain), _bcrypt.gensalt()).decode("utf-8")
80
+
81
+
82
+ def verify_password(plain: str, hashed: str) -> bool:
83
+ try:
84
+ return _bcrypt.checkpw(_encode(plain), hashed.encode("utf-8"))
85
+ except Exception:
86
+ return False
87
+
88
+
89
+ def create_token(user: User) -> str:
90
+ import time
91
+ payload = {
92
+ "sub": user.uid,
93
+ "username": user.username,
94
+ "role": user.role,
95
+ "exp": int(time.time()) + _EXPIRY_SECONDS,
96
+ }
97
+ return jwt.encode(payload, _get_secret(), algorithm=_ALGORITHM)
98
+
99
+
100
+ def decode_token(token: str) -> dict:
101
+ """
102
+ Decode and validate a JWT. Raises jose.JWTError on invalid/expired token.
103
+ Returns the raw payload dict.
104
+ """
105
+ return jwt.decode(token, _get_secret(), algorithms=[_ALGORITHM])
@@ -1,7 +1,8 @@
1
1
  import json
2
2
  import os
3
- from typing import List
3
+ from typing import Dict, List
4
4
 
5
+ from .atomic_io import atomic_write_json
5
6
  from .models import SessionConfig, ConnectionType, SessionRole
6
7
 
7
8
 
@@ -28,12 +29,28 @@ class ConfigStore:
28
29
  def save_for_user(self, uid: str, configs: List[SessionConfig]) -> None:
29
30
  """Atomically save one user's sessions to data_dir/<uid>/sessions.json."""
30
31
  user_dir = os.path.join(self._data_dir, uid)
31
- os.makedirs(user_dir, exist_ok=True)
32
32
  path = os.path.join(user_dir, "sessions.json")
33
- tmp = path + ".tmp"
34
- with open(tmp, "w") as f:
35
- json.dump([_config_to_dict(c) for c in configs], f, indent=2)
36
- os.replace(tmp, path)
33
+ atomic_write_json(path, [_config_to_dict(c) for c in configs])
34
+
35
+ def save_all(self, configs_by_user: Dict[str, List[SessionConfig]]) -> None:
36
+ """Persist every user's sessions and clear files for users with none left.
37
+
38
+ Reconciles against on-disk state: a user whose last session was removed
39
+ no longer appears in configs_by_user, so their sessions.json is emptied.
40
+ Without this, a deleted session reappears on the next load_all().
41
+ """
42
+ for uid, configs in configs_by_user.items():
43
+ self.save_for_user(uid, configs)
44
+
45
+ if not os.path.isdir(self._data_dir):
46
+ return
47
+ for uid in os.listdir(self._data_dir):
48
+ if uid in configs_by_user:
49
+ continue
50
+ # Only rewrite users that already have a persisted file; skip
51
+ # non-user entries in data_dir (messages.db, logs, etc.).
52
+ if os.path.isfile(os.path.join(self._data_dir, uid, "sessions.json")):
53
+ self.save_for_user(uid, [])
37
54
 
38
55
 
39
56
  def _load_file(path: str) -> List[SessionConfig]:
@@ -4,6 +4,8 @@ import json
4
4
  import os
5
5
  from threading import Lock
6
6
 
7
+ from .atomic_io import atomic_write_json
8
+
7
9
 
8
10
  class CustomTagStore:
9
11
  def __init__(self, data_dir: str) -> None:
@@ -21,12 +23,7 @@ class CustomTagStore:
21
23
  return {}
22
24
 
23
25
  def _save(self, uid: str, tags: dict[int, dict]) -> None:
24
- path = self._path(uid)
25
- os.makedirs(os.path.dirname(path), exist_ok=True)
26
- tmp = path + ".tmp"
27
- with open(tmp, "w") as f:
28
- json.dump({str(k): v for k, v in tags.items()}, f, indent=2)
29
- os.replace(tmp, path)
26
+ atomic_write_json(self._path(uid), {str(k): v for k, v in tags.items()})
30
27
 
31
28
  def list_tags(self, uid: str) -> list[dict]:
32
29
  with self._lock:
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import time
2
3
 
3
4
  from fixcore.application import Application
4
5
  from fixcore.message.message import Message
@@ -44,11 +45,21 @@ class FixApplication(Application):
44
45
  pass
45
46
 
46
47
  def from_admin(self, message: Message, session_id: SessionID) -> None:
47
- self._emit(EventType.MESSAGE_RECEIVED, {"raw": message.encode().decode("ascii", errors="replace"), "admin": True})
48
+ recv_perf_ns = time.perf_counter_ns()
49
+ self._emit(EventType.MESSAGE_RECEIVED, {
50
+ "raw": message.encode().decode("ascii", errors="replace"),
51
+ "admin": True,
52
+ "recv_perf_ns": recv_perf_ns,
53
+ })
48
54
 
49
55
  def from_app(self, message: Message, session_id: SessionID) -> None:
56
+ recv_perf_ns = time.perf_counter_ns()
50
57
  try:
51
- self._emit(EventType.MESSAGE_RECEIVED, {"raw": message.encode().decode("ascii", errors="replace"), "admin": False})
58
+ self._emit(EventType.MESSAGE_RECEIVED, {
59
+ "raw": message.encode().decode("ascii", errors="replace"),
60
+ "admin": False,
61
+ "recv_perf_ns": recv_perf_ns,
62
+ })
52
63
  except Exception:
53
64
  pass
54
65
 
@@ -28,10 +28,15 @@ class HousekeepingService:
28
28
  admin UI take effect on the next scheduled run without a restart.
29
29
  """
30
30
 
31
- def __init__(self, msg_db_path: str, log_dir: str, user_store: "UserStore"):
31
+ def __init__(self, msg_db_path: str, log_dir: str, user_store: "UserStore",
32
+ message_writer=None):
32
33
  self._msg_db_path = msg_db_path
33
34
  self._log_dir = log_dir
34
35
  self._user_store = user_store
36
+ # When present, purges are funneled through the single message writer so
37
+ # messages.db only ever has one writer. Falls back to its own connection
38
+ # when message logging is disabled (no writer).
39
+ self._message_writer = message_writer
35
40
 
36
41
  def start(self) -> None:
37
42
  """Start the background daemon thread."""
@@ -80,9 +85,13 @@ class HousekeepingService:
80
85
  def _purge_messages(self, retention_days: int) -> int:
81
86
  if retention_days == 0:
82
87
  return 0
88
+ cutoff = (datetime.now(tz=timezone.utc) - timedelta(days=retention_days)).isoformat()
89
+ if self._message_writer is not None:
90
+ # Single-writer path: the writer thread owns the only write connection.
91
+ return self._message_writer.purge(cutoff)
92
+ # Fallback: message logging disabled — no central writer, so no contention.
83
93
  if not os.path.isfile(self._msg_db_path):
84
94
  return 0
85
- cutoff = (datetime.now(tz=timezone.utc) - timedelta(days=retention_days)).isoformat()
86
95
  with sqlite3.connect(self._msg_db_path, timeout=30) as conn:
87
96
  cur = conn.execute("DELETE FROM messages WHERE ts < ?", (cutoff,))
88
97
  return cur.rowcount
@@ -0,0 +1,104 @@
1
+ """
2
+ Per-session inbound message subscriptions.
3
+
4
+ This is the stable interface a high-rate consumer (e.g. the perf correlator)
5
+ builds against. A subscriber gets a *bounded* asyncio.Queue fed from the event
6
+ loop. The producer side (offer) is O(1) and never blocks the loop; when the
7
+ consumer falls behind and the buffer fills, the oldest message is evicted and a
8
+ `dropped` counter is incremented so loss is visible and measurable.
9
+
10
+ Threading: offer() and get() are both expected to run on the FastAPI/uvicorn
11
+ event loop (fixcore callbacks dispatch there; perf consumers are loop tasks).
12
+ asyncio.Queue is not thread-safe — do not call these from a worker thread.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from .session_manager import SessionManager
23
+
24
+ DEFAULT_MAXSIZE = 10_000
25
+
26
+
27
+ @dataclass
28
+ class InboundMessage:
29
+ """A single inbound (received) FIX message handed to a subscriber."""
30
+ session_id: str
31
+ admin: bool # True = session-level (Logon, Heartbeat, ...)
32
+ raw: str # raw FIX string (SOH-delimited)
33
+ msg_type: str # MsgType (tag 35)
34
+ seq_num: int # MsgSeqNum (tag 34); 0 if absent
35
+ fields: dict # {tag_int: value_str}
36
+ recv_perf_ns: int # time.perf_counter_ns() at fixcore delivery
37
+
38
+
39
+ class SessionSubscription:
40
+ """
41
+ Bounded, drop-oldest feed of inbound messages for one session.
42
+
43
+ Producer (event loop) calls offer(); consumer (a perf correlator task)
44
+ awaits get(). Close via close() or use as a context manager to deregister.
45
+ """
46
+
47
+ def __init__(self, manager: "SessionManager", session_id: str,
48
+ maxsize: int = DEFAULT_MAXSIZE):
49
+ self._manager = manager
50
+ self.session_id = session_id
51
+ self._queue: "asyncio.Queue[InboundMessage]" = asyncio.Queue(maxsize=maxsize)
52
+ self.dropped = 0
53
+ self._closed = False
54
+
55
+ # ------------------------------------------------------------------
56
+ # Producer side (event loop)
57
+ # ------------------------------------------------------------------
58
+
59
+ def offer(self, msg: InboundMessage) -> None:
60
+ """Enqueue a message. O(1), never blocks. Evicts oldest when full."""
61
+ try:
62
+ self._queue.put_nowait(msg)
63
+ except asyncio.QueueFull:
64
+ try:
65
+ self._queue.get_nowait() # evict oldest
66
+ except asyncio.QueueEmpty:
67
+ pass
68
+ self.dropped += 1
69
+ try:
70
+ self._queue.put_nowait(msg)
71
+ except asyncio.QueueFull:
72
+ # Should not happen on a single-threaded loop, but stay safe.
73
+ self.dropped += 1
74
+
75
+ # ------------------------------------------------------------------
76
+ # Consumer side
77
+ # ------------------------------------------------------------------
78
+
79
+ async def get(self) -> InboundMessage:
80
+ """Await the next inbound message."""
81
+ return await self._queue.get()
82
+
83
+ def get_nowait(self) -> InboundMessage:
84
+ """Return the next message or raise asyncio.QueueEmpty."""
85
+ return self._queue.get_nowait()
86
+
87
+ def qsize(self) -> int:
88
+ return self._queue.qsize()
89
+
90
+ # ------------------------------------------------------------------
91
+ # Lifecycle
92
+ # ------------------------------------------------------------------
93
+
94
+ def close(self) -> None:
95
+ """Deregister from the manager. Idempotent."""
96
+ if not self._closed:
97
+ self._closed = True
98
+ self._manager._remove_subscription(self)
99
+
100
+ def __enter__(self) -> "SessionSubscription":
101
+ return self
102
+
103
+ def __exit__(self, *exc) -> None:
104
+ self.close()