mdrap 1.0.0__py3-none-any.whl
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.
- analytics.py +200 -0
- archive.py +154 -0
- async_storage.py +261 -0
- bbo.py +268 -0
- benchmark.py +169 -0
- broker.py +178 -0
- chaos.py +260 -0
- cli.py +4412 -0
- client.py +682 -0
- columnar.py +651 -0
- config.py +264 -0
- dashboard.py +125 -0
- databento_feed.py +558 -0
- depth.py +621 -0
- excel_bridge.py +795 -0
- exporter.py +995 -0
- fastpath.py +797 -0
- feed_handler.py +216 -0
- feed_workers.py +262 -0
- flow_tracker.py +474 -0
- gateway.py +102 -0
- gateway_tcp.py +117 -0
- live.py +564 -0
- mbo.py +413 -0
- mdrap-1.0.0.dist-info/METADATA +493 -0
- mdrap-1.0.0.dist-info/RECORD +62 -0
- mdrap-1.0.0.dist-info/WHEEL +5 -0
- mdrap-1.0.0.dist-info/entry_points.txt +2 -0
- mdrap-1.0.0.dist-info/licenses/LICENSE +21 -0
- mdrap-1.0.0.dist-info/top_level.txt +53 -0
- metrics.py +167 -0
- models.py +84 -0
- multicast_arbitrator.py +313 -0
- pipeline.py +323 -0
- pipeline_v2.py +370 -0
- polygon_feed.py +503 -0
- prometheus.py +275 -0
- protocol.py +256 -0
- quality.py +195 -0
- reconciliation.py +186 -0
- sbe.py +364 -0
- sdk/__init__.py +3 -0
- sdk/client.py +141 -0
- sdk/execution.py +95 -0
- sdk/strategy_vwap.py +69 -0
- sdk_dashboard.py +183 -0
- security.py +452 -0
- service.py +1171 -0
- sharded_pipeline.py +292 -0
- shm.py +511 -0
- simulator.py +171 -0
- spsc_ring.py +167 -0
- storage.py +704 -0
- strategy_sdk.py +527 -0
- stresstest.py +718 -0
- tca.py +396 -0
- term.py +199 -0
- terminal_display.py +779 -0
- watchdog.py +158 -0
- web_cockpit.py +998 -0
- workload_simulator.py +707 -0
- ws_feed.py +523 -0
analytics.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Analytical Storage & Query Engine module for MDRAP V3.
|
|
3
|
+
Implements Spec §14 — Analytical query and aggregation engine for market data.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
from typing import Dict, List, Tuple
|
|
8
|
+
|
|
9
|
+
from models import CanonicalEvent, EventType, QualityStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OHLCVAggregator:
|
|
13
|
+
def __init__(self, interval_s: float = 5.0):
|
|
14
|
+
self.interval_s = interval_s
|
|
15
|
+
# Dict key: (instrument_id, bucket_start)
|
|
16
|
+
# Value: dict holding the raw OHLCV calculations
|
|
17
|
+
self._buckets: Dict[Tuple[str, float], dict] = {}
|
|
18
|
+
|
|
19
|
+
def observe(self, event: CanonicalEvent) -> None:
|
|
20
|
+
if event.event_type != EventType.TRADE or event.price is None:
|
|
21
|
+
return
|
|
22
|
+
if event.quality_status == QualityStatus.INVALID:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
bucket_start = float(int(event.exchange_timestamp // self.interval_s) * self.interval_s)
|
|
26
|
+
key = (event.instrument_id, bucket_start)
|
|
27
|
+
|
|
28
|
+
if key not in self._buckets:
|
|
29
|
+
self._buckets[key] = {
|
|
30
|
+
"instrument_id": event.instrument_id,
|
|
31
|
+
"bucket_start": bucket_start,
|
|
32
|
+
"interval_s": self.interval_s,
|
|
33
|
+
"open": event.price,
|
|
34
|
+
"high": event.price,
|
|
35
|
+
"low": event.price,
|
|
36
|
+
"close": event.price,
|
|
37
|
+
"volume": event.quantity if event.quantity is not None else 0.0,
|
|
38
|
+
"event_count": 1,
|
|
39
|
+
"_first_ts": event.exchange_timestamp,
|
|
40
|
+
"_last_ts": event.exchange_timestamp
|
|
41
|
+
}
|
|
42
|
+
else:
|
|
43
|
+
b = self._buckets[key]
|
|
44
|
+
first_ts = b.get("_first_ts", b.get("bucket_start", 0.0))
|
|
45
|
+
last_ts = b.get("_last_ts", b.get("bucket_start", 0.0))
|
|
46
|
+
if event.exchange_timestamp < first_ts:
|
|
47
|
+
b["open"] = event.price
|
|
48
|
+
b["_first_ts"] = event.exchange_timestamp
|
|
49
|
+
if event.exchange_timestamp >= last_ts:
|
|
50
|
+
b["close"] = event.price
|
|
51
|
+
b["_last_ts"] = event.exchange_timestamp
|
|
52
|
+
|
|
53
|
+
b["high"] = max(b["high"], event.price)
|
|
54
|
+
b["low"] = min(b["low"], event.price)
|
|
55
|
+
if event.quantity is not None:
|
|
56
|
+
b["volume"] += event.quantity
|
|
57
|
+
b["event_count"] += 1
|
|
58
|
+
|
|
59
|
+
def _format_candle(self, b: dict) -> dict:
|
|
60
|
+
return {
|
|
61
|
+
"instrument_id": b["instrument_id"],
|
|
62
|
+
"bucket_start": b["bucket_start"],
|
|
63
|
+
"interval_s": b["interval_s"],
|
|
64
|
+
"open": b["open"],
|
|
65
|
+
"high": b["high"],
|
|
66
|
+
"low": b["low"],
|
|
67
|
+
"close": b["close"],
|
|
68
|
+
"volume": b["volume"],
|
|
69
|
+
"event_count": b["event_count"]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def candles(self) -> list[dict]:
|
|
73
|
+
sorted_keys = sorted(self._buckets.keys())
|
|
74
|
+
return [self._format_candle(self._buckets[k]) for k in sorted_keys]
|
|
75
|
+
|
|
76
|
+
def candles_for(self, instrument_id: str) -> list[dict]:
|
|
77
|
+
res = []
|
|
78
|
+
for k in sorted(self._buckets.keys()):
|
|
79
|
+
if k[0] == instrument_id:
|
|
80
|
+
res.append(self._format_candle(self._buckets[k]))
|
|
81
|
+
return res
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SpreadAnalyzer:
|
|
85
|
+
def __init__(self):
|
|
86
|
+
self._stats: Dict[str, dict] = {}
|
|
87
|
+
|
|
88
|
+
def observe(self, event: CanonicalEvent) -> None:
|
|
89
|
+
if event.event_type != EventType.QUOTE or event.bid_price is None or event.ask_price is None:
|
|
90
|
+
return
|
|
91
|
+
if not math.isfinite(event.bid_price) or not math.isfinite(event.ask_price):
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
spread = event.ask_price - event.bid_price
|
|
95
|
+
crossed = 1 if event.bid_price > event.ask_price else 0
|
|
96
|
+
|
|
97
|
+
if event.instrument_id not in self._stats:
|
|
98
|
+
self._stats[event.instrument_id] = {
|
|
99
|
+
"quote_count": 1,
|
|
100
|
+
"sum_spread": spread,
|
|
101
|
+
"min_spread": spread,
|
|
102
|
+
"max_spread": spread,
|
|
103
|
+
"crossed_count": crossed
|
|
104
|
+
}
|
|
105
|
+
else:
|
|
106
|
+
s = self._stats[event.instrument_id]
|
|
107
|
+
s["quote_count"] += 1
|
|
108
|
+
s["sum_spread"] += spread
|
|
109
|
+
s["min_spread"] = min(s["min_spread"], spread)
|
|
110
|
+
s["max_spread"] = max(s["max_spread"], spread)
|
|
111
|
+
s["crossed_count"] += crossed
|
|
112
|
+
|
|
113
|
+
def summary(self) -> list[dict]:
|
|
114
|
+
res = []
|
|
115
|
+
for instr, s in self._stats.items():
|
|
116
|
+
count = s["quote_count"]
|
|
117
|
+
mean_spread = s["sum_spread"] / count if count > 0 else 0.0
|
|
118
|
+
crossed_pct = (s["crossed_count"] / count * 100) if count > 0 else 0.0
|
|
119
|
+
res.append({
|
|
120
|
+
"instrument_id": instr,
|
|
121
|
+
"quote_count": count,
|
|
122
|
+
"mean_spread": mean_spread,
|
|
123
|
+
"min_spread": s["min_spread"],
|
|
124
|
+
"max_spread": s["max_spread"],
|
|
125
|
+
"crossed_count": s["crossed_count"],
|
|
126
|
+
"crossed_pct": crossed_pct
|
|
127
|
+
})
|
|
128
|
+
return res
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class VolatilityTracker:
|
|
132
|
+
def __init__(self, window: int = 100):
|
|
133
|
+
self.window = window
|
|
134
|
+
self._stats: Dict[str, dict] = {}
|
|
135
|
+
|
|
136
|
+
def observe(self, event: CanonicalEvent) -> None:
|
|
137
|
+
if event.event_type != EventType.TRADE or event.price is None:
|
|
138
|
+
return
|
|
139
|
+
if not math.isfinite(event.price) or event.price <= 0:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
p = event.price
|
|
143
|
+
|
|
144
|
+
instr = event.instrument_id
|
|
145
|
+
|
|
146
|
+
if instr not in self._stats:
|
|
147
|
+
self._stats[instr] = {
|
|
148
|
+
"count": 1,
|
|
149
|
+
"mean": p,
|
|
150
|
+
"M2": 0.0,
|
|
151
|
+
"min_price": p,
|
|
152
|
+
"max_price": p
|
|
153
|
+
}
|
|
154
|
+
else:
|
|
155
|
+
s = self._stats[instr]
|
|
156
|
+
s["count"] += 1
|
|
157
|
+
delta = p - s["mean"]
|
|
158
|
+
s["mean"] += delta / s["count"]
|
|
159
|
+
delta2 = p - s["mean"]
|
|
160
|
+
s["M2"] += delta * delta2
|
|
161
|
+
s["min_price"] = min(s["min_price"], p)
|
|
162
|
+
s["max_price"] = max(s["max_price"], p)
|
|
163
|
+
|
|
164
|
+
def summary(self) -> list[dict]:
|
|
165
|
+
res = []
|
|
166
|
+
for instr, s in self._stats.items():
|
|
167
|
+
count = s["count"]
|
|
168
|
+
mean = s["mean"]
|
|
169
|
+
std_dev = math.sqrt(s["M2"] / count) if count > 0 else 0.0
|
|
170
|
+
price_range_pct = ((s["max_price"] - s["min_price"]) / mean * 100) if mean > 0 else 0.0
|
|
171
|
+
|
|
172
|
+
res.append({
|
|
173
|
+
"instrument_id": instr,
|
|
174
|
+
"trade_count": count,
|
|
175
|
+
"mean_price": mean,
|
|
176
|
+
"std_dev": std_dev,
|
|
177
|
+
"min_price": s["min_price"],
|
|
178
|
+
"max_price": s["max_price"],
|
|
179
|
+
"price_range_pct": price_range_pct
|
|
180
|
+
})
|
|
181
|
+
return res
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class MarketAnalytics:
|
|
185
|
+
def __init__(self, ohlcv_interval_s: float = 5.0, volatility_window: int = 100):
|
|
186
|
+
self.ohlcv = OHLCVAggregator(interval_s=ohlcv_interval_s)
|
|
187
|
+
self.spreads = SpreadAnalyzer()
|
|
188
|
+
self.volatility = VolatilityTracker(window=volatility_window)
|
|
189
|
+
|
|
190
|
+
def observe(self, event: CanonicalEvent) -> None:
|
|
191
|
+
self.ohlcv.observe(event)
|
|
192
|
+
self.spreads.observe(event)
|
|
193
|
+
self.volatility.observe(event)
|
|
194
|
+
|
|
195
|
+
def full_summary(self) -> dict:
|
|
196
|
+
return {
|
|
197
|
+
"ohlcv": self.ohlcv.candles(),
|
|
198
|
+
"spreads": self.spreads.summary(),
|
|
199
|
+
"volatility": self.volatility.summary()
|
|
200
|
+
}
|
archive.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Immutable Raw Event Archive module.
|
|
3
|
+
|
|
4
|
+
Implements Spec §6.10 and §19 — immutable raw event persistence.
|
|
5
|
+
This is a write-ahead JSONL archive that captures every raw event BEFORE processing,
|
|
6
|
+
partitioned by date and source.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from typing import Iterator, Optional, Any, Dict, List
|
|
13
|
+
|
|
14
|
+
from models import RawEvent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RawArchive:
|
|
18
|
+
def __init__(self, base_dir: str = 'data/raw_archive', buffer_size: int = 500):
|
|
19
|
+
self.base_dir = base_dir
|
|
20
|
+
self.buffer_size = buffer_size
|
|
21
|
+
self._buffer: list[RawEvent] = []
|
|
22
|
+
self._file_handles: dict[str, Any] = {}
|
|
23
|
+
|
|
24
|
+
def __enter__(self):
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
28
|
+
self.close()
|
|
29
|
+
|
|
30
|
+
def write(self, raw: RawEvent) -> None:
|
|
31
|
+
self._buffer.append(raw)
|
|
32
|
+
if len(self._buffer) >= self.buffer_size:
|
|
33
|
+
self.flush()
|
|
34
|
+
|
|
35
|
+
def flush(self) -> None:
|
|
36
|
+
if not self._buffer:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
grouped: dict[str, list[str]] = {}
|
|
40
|
+
for raw in self._buffer:
|
|
41
|
+
# Format receive_timestamp to YYYY-MM-DD for partitioning
|
|
42
|
+
date_str = time.strftime('%Y-%m-%d', time.gmtime(raw.receive_timestamp))
|
|
43
|
+
dir_path = os.path.join(self.base_dir, date_str)
|
|
44
|
+
file_path = os.path.join(dir_path, f"{raw.source}.jsonl")
|
|
45
|
+
|
|
46
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
if file_path not in grouped:
|
|
49
|
+
grouped[file_path] = []
|
|
50
|
+
|
|
51
|
+
# Line is a JSON object
|
|
52
|
+
line = json.dumps({
|
|
53
|
+
"raw_id": raw.raw_id,
|
|
54
|
+
"source": raw.source,
|
|
55
|
+
"payload": raw.payload,
|
|
56
|
+
"receive_timestamp": raw.receive_timestamp
|
|
57
|
+
})
|
|
58
|
+
grouped[file_path].append(line)
|
|
59
|
+
|
|
60
|
+
for file_path, lines in grouped.items():
|
|
61
|
+
if file_path not in self._file_handles:
|
|
62
|
+
self._file_handles[file_path] = open(file_path, 'a', encoding='utf-8')
|
|
63
|
+
|
|
64
|
+
handle = self._file_handles[file_path]
|
|
65
|
+
for line in lines:
|
|
66
|
+
handle.write(line + '\n')
|
|
67
|
+
handle.flush()
|
|
68
|
+
|
|
69
|
+
self._buffer.clear()
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
self.flush()
|
|
73
|
+
for handle in self._file_handles.values():
|
|
74
|
+
handle.close()
|
|
75
|
+
self._file_handles.clear()
|
|
76
|
+
|
|
77
|
+
def stats(self) -> dict:
|
|
78
|
+
total_events = 0
|
|
79
|
+
dates = set()
|
|
80
|
+
sources = set()
|
|
81
|
+
size_bytes = 0
|
|
82
|
+
|
|
83
|
+
if not os.path.exists(self.base_dir):
|
|
84
|
+
return {
|
|
85
|
+
"total_events": 0,
|
|
86
|
+
"dates": [],
|
|
87
|
+
"sources": [],
|
|
88
|
+
"size_bytes": 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for root, _, files in os.walk(self.base_dir):
|
|
92
|
+
for file in files:
|
|
93
|
+
if file.endswith('.jsonl'):
|
|
94
|
+
file_path = os.path.join(root, file)
|
|
95
|
+
date_dir = os.path.basename(root)
|
|
96
|
+
dates.add(date_dir)
|
|
97
|
+
|
|
98
|
+
source = file[:-6] # remove .jsonl
|
|
99
|
+
sources.add(source)
|
|
100
|
+
|
|
101
|
+
size_bytes += os.path.getsize(file_path)
|
|
102
|
+
|
|
103
|
+
# count lines
|
|
104
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
105
|
+
for _ in f:
|
|
106
|
+
total_events += 1
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"total_events": total_events,
|
|
110
|
+
"dates": sorted(list(dates)),
|
|
111
|
+
"sources": sorted(list(sources)),
|
|
112
|
+
"size_bytes": size_bytes
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def replay(base_dir: str, date: Optional[str] = None, source: Optional[str] = None) -> Iterator[RawEvent]:
|
|
117
|
+
"""
|
|
118
|
+
Yields RawEvent objects from archived JSONL files.
|
|
119
|
+
"""
|
|
120
|
+
if not os.path.exists(base_dir):
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# Get all date directories sorted
|
|
124
|
+
date_dirs = sorted([d for d in os.listdir(base_dir) if os.path.isdir(os.path.join(base_dir, d))])
|
|
125
|
+
|
|
126
|
+
if date:
|
|
127
|
+
date_dirs = [d for d in date_dirs if d == date]
|
|
128
|
+
|
|
129
|
+
for date_dir in date_dirs:
|
|
130
|
+
dir_path = os.path.join(base_dir, date_dir)
|
|
131
|
+
|
|
132
|
+
# Get all jsonl files
|
|
133
|
+
files = sorted([f for f in os.listdir(dir_path) if f.endswith('.jsonl')])
|
|
134
|
+
|
|
135
|
+
if source:
|
|
136
|
+
target_file = f"{source}.jsonl"
|
|
137
|
+
files = [f for f in files if f == target_file]
|
|
138
|
+
|
|
139
|
+
for file in files:
|
|
140
|
+
file_path = os.path.join(dir_path, file)
|
|
141
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
142
|
+
for line in f:
|
|
143
|
+
if line.strip():
|
|
144
|
+
try:
|
|
145
|
+
data = json.loads(line)
|
|
146
|
+
yield RawEvent(
|
|
147
|
+
raw_id=data["raw_id"],
|
|
148
|
+
source=data["source"],
|
|
149
|
+
payload=data["payload"],
|
|
150
|
+
receive_timestamp=data["receive_timestamp"]
|
|
151
|
+
)
|
|
152
|
+
except (json.JSONDecodeError, KeyError):
|
|
153
|
+
# Skip corrupted/truncated archive lines without crashing replay
|
|
154
|
+
continue
|
async_storage.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dedicated Asynchronous Storage Worker for MDRAP.
|
|
3
|
+
|
|
4
|
+
Decouples synchronous SQLite and DuckDB disk commits (fsync) from the real-time
|
|
5
|
+
market data processing loop. Canonical events, quarantine tuples, and lineage
|
|
6
|
+
records are enqueued into a lock-free SPSC ring buffer and flushed in batches
|
|
7
|
+
by a dedicated background worker thread.
|
|
8
|
+
|
|
9
|
+
Zero disk I/O pauses on the tick broadcasting hot path.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, List, Optional, Tuple, Union
|
|
17
|
+
|
|
18
|
+
from models import CanonicalEvent
|
|
19
|
+
from spsc_ring import SPSCRingBuffer
|
|
20
|
+
from storage import Store
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Action tags for storage worker
|
|
25
|
+
OP_CANONICAL = 1
|
|
26
|
+
OP_QUARANTINE = 2
|
|
27
|
+
OP_LINEAGE = 3
|
|
28
|
+
OP_HEALTH = 4
|
|
29
|
+
OP_FLUSH_BARRIER = 5
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StorageCommand:
|
|
33
|
+
__slots__ = ("op_type", "payload")
|
|
34
|
+
|
|
35
|
+
def __init__(self, op_type: int, payload: Any):
|
|
36
|
+
self.op_type = op_type
|
|
37
|
+
self.payload = payload
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AsyncStorageWorker:
|
|
41
|
+
"""
|
|
42
|
+
Background worker thread that drains persistence commands from an SPSC ring buffer
|
|
43
|
+
and commits them to SQLite in optimized batches.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
store: Store,
|
|
49
|
+
queue_capacity: int = 65536,
|
|
50
|
+
batch_size: int = 2000,
|
|
51
|
+
flush_interval_s: float = 0.25,
|
|
52
|
+
duck_path: Optional[str] = None,
|
|
53
|
+
):
|
|
54
|
+
self.store = store
|
|
55
|
+
self.batch_size = batch_size
|
|
56
|
+
self.flush_interval_s = flush_interval_s
|
|
57
|
+
self.duck_path = duck_path
|
|
58
|
+
|
|
59
|
+
self._queue: SPSCRingBuffer[StorageCommand] = SPSCRingBuffer(capacity=queue_capacity)
|
|
60
|
+
self._running = False
|
|
61
|
+
self._thread: Optional[threading.Thread] = None
|
|
62
|
+
self._flush_lock = threading.Lock()
|
|
63
|
+
self._flush_complete_event = threading.Event()
|
|
64
|
+
|
|
65
|
+
# Telemetry counters
|
|
66
|
+
self._total_canonical = 0
|
|
67
|
+
self._total_quarantine = 0
|
|
68
|
+
self._total_lineage = 0
|
|
69
|
+
self._total_commits = 0
|
|
70
|
+
self._last_commit_ms = 0.0
|
|
71
|
+
self._max_commit_ms = 0.0
|
|
72
|
+
self._total_dropped = 0
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def queue(self) -> SPSCRingBuffer[StorageCommand]:
|
|
76
|
+
return self._queue
|
|
77
|
+
|
|
78
|
+
def start(self) -> None:
|
|
79
|
+
"""Start the background storage thread."""
|
|
80
|
+
if self._running:
|
|
81
|
+
return
|
|
82
|
+
self._running = True
|
|
83
|
+
self._thread = threading.Thread(target=self._worker_loop, name="mdrap-async-storage", daemon=True)
|
|
84
|
+
self._thread.start()
|
|
85
|
+
|
|
86
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
87
|
+
"""Gracefully stop worker and drain all pending storage writes."""
|
|
88
|
+
if not self._running:
|
|
89
|
+
return
|
|
90
|
+
self._running = False
|
|
91
|
+
if self._thread and self._thread.is_alive():
|
|
92
|
+
self._thread.join(timeout=timeout)
|
|
93
|
+
|
|
94
|
+
# Final synchronous flush of any residual queue items
|
|
95
|
+
self._drain_and_commit(force_all=True)
|
|
96
|
+
|
|
97
|
+
def write_canonical(self, event: CanonicalEvent) -> bool:
|
|
98
|
+
"""Enqueue a validated canonical event for background SQLite storage."""
|
|
99
|
+
cmd = StorageCommand(OP_CANONICAL, event)
|
|
100
|
+
ok = self._queue.offer(cmd)
|
|
101
|
+
if not ok:
|
|
102
|
+
self._total_dropped += 1
|
|
103
|
+
return ok
|
|
104
|
+
|
|
105
|
+
def write_quarantine(self, row: tuple) -> bool:
|
|
106
|
+
"""Enqueue an invalid/suspicious quarantine record."""
|
|
107
|
+
cmd = StorageCommand(OP_QUARANTINE, row)
|
|
108
|
+
ok = self._queue.offer(cmd)
|
|
109
|
+
if not ok:
|
|
110
|
+
self._total_dropped += 1
|
|
111
|
+
return ok
|
|
112
|
+
|
|
113
|
+
def write_lineage(self, row: tuple) -> bool:
|
|
114
|
+
"""Enqueue an audit lineage trace."""
|
|
115
|
+
cmd = StorageCommand(OP_LINEAGE, row)
|
|
116
|
+
ok = self._queue.offer(cmd)
|
|
117
|
+
if not ok:
|
|
118
|
+
self._total_dropped += 1
|
|
119
|
+
return ok
|
|
120
|
+
|
|
121
|
+
def write_health(self, rows: list) -> bool:
|
|
122
|
+
"""Enqueue source health telemetry."""
|
|
123
|
+
cmd = StorageCommand(OP_HEALTH, rows)
|
|
124
|
+
ok = self._queue.offer(cmd)
|
|
125
|
+
if not ok:
|
|
126
|
+
self._total_dropped += 1
|
|
127
|
+
return ok
|
|
128
|
+
|
|
129
|
+
def flush(self, timeout: float = 2.0) -> None:
|
|
130
|
+
"""Synchronously wait until all currently queued events are committed to disk."""
|
|
131
|
+
if not self._running:
|
|
132
|
+
self._drain_and_commit(force_all=True)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
with self._flush_lock:
|
|
136
|
+
self._flush_complete_event.clear()
|
|
137
|
+
barrier = StorageCommand(OP_FLUSH_BARRIER, self._flush_complete_event)
|
|
138
|
+
while not self._queue.offer(barrier):
|
|
139
|
+
time.sleep(0.0005)
|
|
140
|
+
self._flush_complete_event.wait(timeout=timeout)
|
|
141
|
+
|
|
142
|
+
def _worker_loop(self) -> None:
|
|
143
|
+
"""Main background loop draining SPSC queue and committing in batches."""
|
|
144
|
+
last_flush = time.time()
|
|
145
|
+
canonical_batch: List[CanonicalEvent] = []
|
|
146
|
+
quarantine_batch: List[tuple] = []
|
|
147
|
+
lineage_batch: List[tuple] = []
|
|
148
|
+
health_batch: List[tuple] = []
|
|
149
|
+
|
|
150
|
+
while self._running:
|
|
151
|
+
cmd = self._queue.poll()
|
|
152
|
+
if cmd is not None:
|
|
153
|
+
if cmd.op_type == OP_CANONICAL:
|
|
154
|
+
canonical_batch.append(cmd.payload)
|
|
155
|
+
elif cmd.op_type == OP_QUARANTINE:
|
|
156
|
+
quarantine_batch.append(cmd.payload)
|
|
157
|
+
elif cmd.op_type == OP_LINEAGE:
|
|
158
|
+
lineage_batch.append(cmd.payload)
|
|
159
|
+
elif cmd.op_type == OP_HEALTH:
|
|
160
|
+
health_batch.extend(cmd.payload)
|
|
161
|
+
elif cmd.op_type == OP_FLUSH_BARRIER:
|
|
162
|
+
# Flush immediately on barrier
|
|
163
|
+
self._commit_batches(canonical_batch, quarantine_batch, lineage_batch, health_batch)
|
|
164
|
+
last_flush = time.time()
|
|
165
|
+
if isinstance(cmd.payload, threading.Event):
|
|
166
|
+
cmd.payload.set()
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
now = time.time()
|
|
170
|
+
total_pending = len(canonical_batch) + len(quarantine_batch) + len(lineage_batch)
|
|
171
|
+
time_elapsed = (now - last_flush) >= self.flush_interval_s
|
|
172
|
+
|
|
173
|
+
if total_pending >= self.batch_size or (total_pending > 0 and time_elapsed):
|
|
174
|
+
self._commit_batches(canonical_batch, quarantine_batch, lineage_batch, health_batch)
|
|
175
|
+
last_flush = now
|
|
176
|
+
elif cmd is None:
|
|
177
|
+
# Yield CPU briefly if queue was empty
|
|
178
|
+
time.sleep(0.001)
|
|
179
|
+
|
|
180
|
+
# Drain any residual items on exit
|
|
181
|
+
self._commit_batches(canonical_batch, quarantine_batch, lineage_batch, health_batch)
|
|
182
|
+
|
|
183
|
+
def _commit_batches(
|
|
184
|
+
self,
|
|
185
|
+
canonical_batch: List[CanonicalEvent],
|
|
186
|
+
quarantine_batch: List[tuple],
|
|
187
|
+
lineage_batch: List[tuple],
|
|
188
|
+
health_batch: List[tuple],
|
|
189
|
+
) -> None:
|
|
190
|
+
if not canonical_batch and not quarantine_batch and not lineage_batch and not health_batch:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
t0 = time.perf_counter()
|
|
194
|
+
try:
|
|
195
|
+
if canonical_batch:
|
|
196
|
+
self.store.write_canonical_batch(canonical_batch)
|
|
197
|
+
self._total_canonical += len(canonical_batch)
|
|
198
|
+
canonical_batch.clear()
|
|
199
|
+
|
|
200
|
+
if quarantine_batch:
|
|
201
|
+
self.store.write_quarantine_batch(quarantine_batch)
|
|
202
|
+
self._total_quarantine += len(quarantine_batch)
|
|
203
|
+
quarantine_batch.clear()
|
|
204
|
+
|
|
205
|
+
if lineage_batch:
|
|
206
|
+
self.store.write_lineage_batch(lineage_batch)
|
|
207
|
+
self._total_lineage += len(lineage_batch)
|
|
208
|
+
lineage_batch.clear()
|
|
209
|
+
|
|
210
|
+
if health_batch:
|
|
211
|
+
self.store.upsert_source_health(health_batch)
|
|
212
|
+
health_batch.clear()
|
|
213
|
+
|
|
214
|
+
self.store.commit()
|
|
215
|
+
self._total_commits += 1
|
|
216
|
+
dur_ms = (time.perf_counter() - t0) * 1000.0
|
|
217
|
+
self._last_commit_ms = round(dur_ms, 2)
|
|
218
|
+
if dur_ms > self._max_commit_ms:
|
|
219
|
+
self._max_commit_ms = round(dur_ms, 2)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.error(f"Async storage batch commit failed: {e}")
|
|
222
|
+
|
|
223
|
+
def _drain_and_commit(self, force_all: bool = True) -> None:
|
|
224
|
+
"""Synchronous drain of the queue for shutdown or offline testing."""
|
|
225
|
+
canonical_batch: List[CanonicalEvent] = []
|
|
226
|
+
quarantine_batch: List[tuple] = []
|
|
227
|
+
lineage_batch: List[tuple] = []
|
|
228
|
+
health_batch: List[tuple] = []
|
|
229
|
+
|
|
230
|
+
while True:
|
|
231
|
+
cmd = self._queue.poll()
|
|
232
|
+
if cmd is None:
|
|
233
|
+
break
|
|
234
|
+
if cmd.op_type == OP_CANONICAL:
|
|
235
|
+
canonical_batch.append(cmd.payload)
|
|
236
|
+
elif cmd.op_type == OP_QUARANTINE:
|
|
237
|
+
quarantine_batch.append(cmd.payload)
|
|
238
|
+
elif cmd.op_type == OP_LINEAGE:
|
|
239
|
+
lineage_batch.append(cmd.payload)
|
|
240
|
+
elif cmd.op_type == OP_HEALTH:
|
|
241
|
+
health_batch.extend(cmd.payload)
|
|
242
|
+
elif cmd.op_type == OP_FLUSH_BARRIER:
|
|
243
|
+
if isinstance(cmd.payload, threading.Event):
|
|
244
|
+
cmd.payload.set()
|
|
245
|
+
|
|
246
|
+
self._commit_batches(canonical_batch, quarantine_batch, lineage_batch, health_batch)
|
|
247
|
+
|
|
248
|
+
def stats(self) -> dict:
|
|
249
|
+
"""Return operational telemetry."""
|
|
250
|
+
return {
|
|
251
|
+
"is_running": self._running,
|
|
252
|
+
"queue_size": self._queue.size(),
|
|
253
|
+
"queue_capacity": self._queue.capacity,
|
|
254
|
+
"total_canonical": self._total_canonical,
|
|
255
|
+
"total_quarantine": self._total_quarantine,
|
|
256
|
+
"total_lineage": self._total_lineage,
|
|
257
|
+
"total_commits": self._total_commits,
|
|
258
|
+
"last_commit_ms": self._last_commit_ms,
|
|
259
|
+
"max_commit_ms": self._max_commit_ms,
|
|
260
|
+
"total_dropped": self._total_dropped,
|
|
261
|
+
}
|