1use std::{
19 any::Any,
20 sync::{
21 Arc,
22 atomic::{AtomicU64, Ordering},
23 },
24 time::Duration,
25};
26
27use ahash::AHashSet;
28use nautilus_core::UnixNanos;
29use nautilus_system::event_store::DataMarkerConfig;
30
31use crate::{
32 Topic,
33 markers::{
34 CursorState, DataMarkerExtractorRegistry, HiFiMarker, MarkerMsg, MarkerWriter,
35 StreamDictEntry,
36 },
37};
38
39#[derive(Debug)]
45pub struct DataMarkerCapture {
46 cursor: CursorState,
47 registry: DataMarkerExtractorRegistry,
48 writer: MarkerWriter,
49 submit_counter: Arc<AtomicU64>,
50 marker_seq: u64,
51 hifi: AHashSet<String>,
52 last_flush: UnixNanos,
53 safety_flush_interval: Duration,
54 lane_failed: bool,
55}
56
57impl DataMarkerCapture {
58 #[must_use]
60 pub fn new(
61 registry: DataMarkerExtractorRegistry,
62 writer: MarkerWriter,
63 submit_counter: Arc<AtomicU64>,
64 config: &DataMarkerConfig,
65 ) -> Self {
66 Self {
67 cursor: CursorState::new(),
68 registry,
69 writer,
70 submit_counter,
71 marker_seq: 0,
72 hifi: config.high_fidelity.iter().cloned().collect(),
73 last_flush: UnixNanos::default(),
74 safety_flush_interval: config.safety_flush_interval,
75 lane_failed: false,
76 }
77 }
78
79 pub fn observe_publish(&mut self, topic: Topic, message: &dyn Any, _now: UnixNanos) {
81 let Some(extractor) = self.registry.lookup(message) else {
82 return;
83 };
84 let event_seq_before = self.submit_counter.load(Ordering::Acquire);
85 let Some(identifier) = extractor.identifier(message) else {
86 return;
87 };
88 let Some((ts_event, ts_init)) = extractor.timestamps(message) else {
89 return;
90 };
91 let data_class = extractor.data_class();
92 let record_fingerprint = if self.hifi.contains(identifier.as_str()) {
93 let Some(record_fingerprint) = extractor.fingerprint(message) else {
94 return;
95 };
96 Some(record_fingerprint)
97 } else {
98 None
99 };
100
101 let (slot, same_ts_ordinal) = self.cursor.advance(topic, data_class, &identifier, ts_init);
102 self.drain_dict_entries();
103
104 if let Some(record_fingerprint) = record_fingerprint {
105 let marker_seq = self.marker_seq + 1;
106 let marker = HiFiMarker {
107 marker_seq,
108 event_seq_before,
109 slot,
110 ts_event,
111 ts_init,
112 same_ts_ordinal,
113 record_fingerprint,
114 };
115 self.submit_marker(MarkerMsg::HiFi(marker), marker_seq);
116 }
117 }
118
119 pub fn on_entry_submitted(&mut self, now: UnixNanos) {
121 self.flush_snapshot(now);
122 }
123
124 pub fn maybe_safety_flush(&mut self, now: UnixNanos) {
126 if now
127 .duration_since(&self.last_flush)
128 .is_some_and(|elapsed| elapsed >= duration_nanos_saturating(self.safety_flush_interval))
129 {
130 self.flush_snapshot(now);
131 }
132 }
133
134 pub fn close(self) {
136 self.writer.close();
137 }
138
139 fn drain_dict_entries(&mut self) {
140 for entry in self.cursor.take_new_dict_entries() {
141 self.submit_dict(entry);
142 }
143 }
144
145 fn submit_dict(&mut self, entry: StreamDictEntry) {
146 if self.writer.put_dict(entry).is_err() {
147 self.note_lane_failure();
148 }
149 }
150
151 fn flush_snapshot(&mut self, now: UnixNanos) {
152 let marker_seq = self.marker_seq + 1;
153 let event_seq_before = self.submit_counter.load(Ordering::Acquire);
154
155 if let Some(snapshot) = self
156 .cursor
157 .build_snapshot(marker_seq, event_seq_before, now)
158 {
159 self.submit_marker(MarkerMsg::Snapshot(snapshot), marker_seq);
160 self.last_flush = now;
161 }
162 }
163
164 fn submit_marker(&mut self, msg: MarkerMsg, marker_seq: u64) {
165 if self.writer.submit(msg, marker_seq).is_err() {
166 self.note_lane_failure();
167 }
168 self.marker_seq = marker_seq;
169 }
170
171 fn note_lane_failure(&mut self) {
174 if !self.lane_failed {
175 self.lane_failed = true;
176 log::error!(
177 "Marker writer lane failed; data marker capture is disabled for the rest of the run"
178 );
179 }
180 }
181}
182
183fn duration_nanos_saturating(duration: Duration) -> u64 {
184 u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)
185}
186
187#[cfg(test)]
188mod tests {
189 use std::{
190 any::Any,
191 sync::{
192 Arc,
193 atomic::{AtomicU64, Ordering},
194 },
195 time::Duration,
196 };
197
198 use nautilus_core::{UnixNanos, time::get_atomic_clock_static};
199 use nautilus_system::event_store::DataMarkerClass;
200 use rstest::rstest;
201
202 use super::{
203 super::test_support::{SharedMemoryMarker, SharedMemoryMarkerState},
204 *,
205 };
206 use crate::{
207 Topic,
208 manifest::RunStatus,
209 markers::{
210 DataClass, DataCursorSnapshot, DataMarkerExtractor, DataMarkerExtractorRegistry,
211 HiFiMarker, MarkerBackend, MarkerManifest, MarkerWriter, MarkerWriterConfig,
212 StreamDictEntry,
213 },
214 };
215
216 #[derive(Debug)]
217 struct TestQuote {
218 identifier: &'static str,
219 ts_event: UnixNanos,
220 ts_init: UnixNanos,
221 fingerprint: [u8; 32],
222 }
223
224 #[derive(Debug)]
225 struct IgnoredMessage;
226
227 #[derive(Debug)]
228 struct TestQuoteExtractor;
229
230 impl DataMarkerExtractor for TestQuoteExtractor {
231 fn data_class(&self) -> DataClass {
232 DataClass::Quote
233 }
234
235 fn identifier(&self, msg: &dyn Any) -> Option<String> {
236 msg.downcast_ref::<TestQuote>()
237 .map(|quote| quote.identifier.to_string())
238 }
239
240 fn timestamps(&self, msg: &dyn Any) -> Option<(UnixNanos, UnixNanos)> {
241 msg.downcast_ref::<TestQuote>()
242 .map(|quote| (quote.ts_event, quote.ts_init))
243 }
244
245 fn fingerprint(&self, msg: &dyn Any) -> Option<[u8; 32]> {
246 msg.downcast_ref::<TestQuote>()
247 .map(|quote| quote.fingerprint)
248 }
249 }
250
251 fn manifest() -> MarkerManifest {
252 MarkerManifest {
253 run_id: "1700000000-phase7".to_string(),
254 enabled_classes: vec![DataClass::Quote],
255 high_fidelity: true,
256 snapshot_count: 0,
257 hifi_count: 0,
258 gap_count: 0,
259 dict_count: 0,
260 status: RunStatus::Running,
261 }
262 }
263
264 fn registry() -> DataMarkerExtractorRegistry {
265 let mut registry = DataMarkerExtractorRegistry::new();
266 registry.register::<TestQuote>(Box::new(TestQuoteExtractor));
267 registry
268 }
269
270 fn config(high_fidelity: Vec<String>, safety_flush_interval: Duration) -> DataMarkerConfig {
271 DataMarkerConfig {
272 classes: vec![DataMarkerClass::Quote],
273 high_fidelity,
274 safety_flush_interval,
275 channel_capacity: 100,
276 }
277 }
278
279 fn quote(identifier: &'static str, ts_event: u64, ts_init: u64) -> TestQuote {
280 TestQuote {
281 identifier,
282 ts_event: UnixNanos::from(ts_event),
283 ts_init: UnixNanos::from(ts_init),
284 fingerprint: [7; 32],
285 }
286 }
287
288 fn open_capture(
289 config: &DataMarkerConfig,
290 submit_counter: Arc<AtomicU64>,
291 ) -> (DataMarkerCapture, SharedMemoryMarkerState) {
292 let (wrapper, shared) = SharedMemoryMarker::new();
293 shared.lock().open_run(manifest()).expect("open marker run");
294
295 let writer = MarkerWriter::spawn(
296 Box::new(wrapper),
297 get_atomic_clock_static(),
298 MarkerWriterConfig {
299 channel_capacity: 100,
300 max_batch: 1,
301 max_latency: Duration::from_millis(1),
302 },
303 )
304 .expect("spawn writer");
305
306 (
307 DataMarkerCapture::new(registry(), writer, submit_counter, config),
308 shared,
309 )
310 }
311
312 fn snapshots(shared: &SharedMemoryMarkerState) -> Vec<DataCursorSnapshot> {
313 shared.lock().scan_snapshots().expect("scan snapshots")
314 }
315
316 fn hifi(shared: &SharedMemoryMarkerState) -> Vec<HiFiMarker> {
317 shared.lock().scan_hifi().expect("scan hifi")
318 }
319
320 fn dict(shared: &SharedMemoryMarkerState) -> Vec<StreamDictEntry> {
321 shared.lock().scan_dict().expect("scan dict")
322 }
323
324 #[rstest]
325 fn event_seq_before_tracks_submit_counter() {
326 let submit_counter = Arc::new(AtomicU64::new(5));
327 let cfg = config(vec!["ETHUSDT.BINANCE".to_string()], Duration::from_secs(1));
328 let (mut capture, shared) = open_capture(&cfg, Arc::clone(&submit_counter));
329 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
330
331 capture.observe_publish(
332 topic,
333 "e("ETHUSDT.BINANCE", 10, 20),
334 UnixNanos::from(20),
335 );
336 submit_counter.store(6, Ordering::Release);
337 capture.on_entry_submitted(UnixNanos::from(30));
338 capture.close();
339
340 let snapshots = snapshots(&shared);
341 let hifi = hifi(&shared);
342
343 assert_eq!(hifi.len(), 1);
344 assert_eq!(hifi[0].event_seq_before, 5);
345 assert_eq!(snapshots.len(), 1);
346 assert_eq!(snapshots[0].event_seq_before, 6);
347 }
348
349 #[rstest]
350 fn snapshot_written_on_entry_boundary() {
351 let submit_counter = Arc::new(AtomicU64::new(1));
352 let cfg = config(Vec::new(), Duration::from_secs(1));
353 let (mut capture, shared) = open_capture(&cfg, submit_counter);
354 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
355
356 capture.observe_publish(
357 topic,
358 "e("ETHUSDT.BINANCE", 10, 20),
359 UnixNanos::from(20),
360 );
361 capture.on_entry_submitted(UnixNanos::from(30));
362 capture.close();
363
364 let snapshots = snapshots(&shared);
365 let dict = dict(&shared);
366
367 assert_eq!(snapshots.len(), 1);
368 assert_eq!(snapshots[0].marker_seq, 1);
369 assert_eq!(snapshots[0].event_seq_before, 1);
370 assert_eq!(snapshots[0].advanced[0].count, 1);
371 assert_eq!(
372 dict,
373 vec![StreamDictEntry {
374 slot: 0,
375 data_cls: DataClass::Quote,
376 identifier: "ETHUSDT.BINANCE".to_string(),
377 }]
378 );
379 }
380
381 #[rstest]
382 fn safety_flush_emits_without_entry() {
383 let submit_counter = Arc::new(AtomicU64::new(0));
384 let cfg = config(Vec::new(), Duration::from_nanos(10));
385 let (mut capture, shared) = open_capture(&cfg, submit_counter);
386 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
387
388 capture.observe_publish(
389 topic,
390 "e("ETHUSDT.BINANCE", 10, 20),
391 UnixNanos::from(20),
392 );
393 capture.maybe_safety_flush(UnixNanos::from(30));
394 capture.close();
395
396 let snapshots = snapshots(&shared);
397
398 assert_eq!(snapshots.len(), 1);
399 assert_eq!(snapshots[0].event_seq_before, 0);
400 assert_eq!(snapshots[0].ts_init, UnixNanos::from(30));
401 }
402
403 #[rstest]
404 fn safety_flush_waits_for_interval_after_entry_boundary() {
405 let submit_counter = Arc::new(AtomicU64::new(1));
406 let cfg = config(Vec::new(), Duration::from_nanos(10));
407 let (mut capture, shared) = open_capture(&cfg, submit_counter);
408 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
409
410 capture.observe_publish(
411 topic,
412 "e("ETHUSDT.BINANCE", 10, 20),
413 UnixNanos::from(20),
414 );
415 capture.on_entry_submitted(UnixNanos::from(100));
416 capture.observe_publish(
417 topic,
418 "e("ETHUSDT.BINANCE", 30, 40),
419 UnixNanos::from(40),
420 );
421 capture.maybe_safety_flush(UnixNanos::from(109));
422 capture.maybe_safety_flush(UnixNanos::from(110));
423 capture.close();
424
425 let snapshots = snapshots(&shared);
426
427 assert_eq!(snapshots.len(), 2);
428 assert_eq!(snapshots[0].ts_init, UnixNanos::from(100));
429 assert_eq!(snapshots[0].advanced[0].count, 1);
430 assert_eq!(snapshots[1].ts_init, UnixNanos::from(110));
431 assert_eq!(snapshots[1].advanced[0].count, 2);
432 }
433
434 #[rstest]
435 fn hifi_marker_emitted_for_configured_instrument() {
436 let submit_counter = Arc::new(AtomicU64::new(2));
437 let cfg = config(vec!["ETHUSDT.BINANCE".to_string()], Duration::from_secs(1));
438 let (mut capture, shared) = open_capture(&cfg, submit_counter);
439 let eth_topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
440 let btc_topic: Topic = "data.quotes.BINANCE.BTCUSDT".into();
441
442 capture.observe_publish(
443 eth_topic,
444 "e("ETHUSDT.BINANCE", 10, 20),
445 UnixNanos::from(20),
446 );
447 capture.observe_publish(
448 btc_topic,
449 "e("BTCUSDT.BINANCE", 30, 40),
450 UnixNanos::from(40),
451 );
452 capture.close();
453
454 let hifi = hifi(&shared);
455
456 assert_eq!(hifi.len(), 1);
457 assert_eq!(hifi[0].marker_seq, 1);
458 assert_eq!(hifi[0].event_seq_before, 2);
459 assert_eq!(hifi[0].slot, 0);
460 assert_eq!(hifi[0].ts_event, UnixNanos::from(10));
461 assert_eq!(hifi[0].ts_init, UnixNanos::from(20));
462 assert_eq!(hifi[0].same_ts_ordinal, 0);
463 assert_eq!(hifi[0].record_fingerprint, [7; 32]);
464 }
465
466 #[rstest]
467 fn hifi_same_ts_ordinal_increments() {
468 let submit_counter = Arc::new(AtomicU64::new(0));
469 let cfg = config(vec!["ETHUSDT.BINANCE".to_string()], Duration::from_secs(1));
470 let (mut capture, shared) = open_capture(&cfg, submit_counter);
471 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
472
473 capture.observe_publish(
474 topic,
475 "e("ETHUSDT.BINANCE", 10, 20),
476 UnixNanos::from(20),
477 );
478 capture.observe_publish(
479 topic,
480 "e("ETHUSDT.BINANCE", 11, 20),
481 UnixNanos::from(20),
482 );
483 capture.close();
484
485 let hifi = hifi(&shared);
486
487 assert_eq!(hifi.len(), 2);
488 assert_eq!(
489 hifi.iter()
490 .map(|marker| marker.same_ts_ordinal)
491 .collect::<Vec<_>>(),
492 vec![0, 1]
493 );
494 assert_eq!(
495 hifi.iter()
496 .map(|marker| marker.marker_seq)
497 .collect::<Vec<_>>(),
498 vec![1, 2]
499 );
500 }
501
502 #[rstest]
503 fn unregistered_type_is_ignored() {
504 let submit_counter = Arc::new(AtomicU64::new(0));
505 let cfg = config(Vec::new(), Duration::from_nanos(1));
506 let (mut capture, shared) = open_capture(&cfg, submit_counter);
507 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
508
509 capture.observe_publish(topic, &IgnoredMessage, UnixNanos::from(20));
510 capture.maybe_safety_flush(UnixNanos::from(30));
511 capture.close();
512
513 assert!(snapshots(&shared).is_empty());
514 assert!(hifi(&shared).is_empty());
515 assert!(dict(&shared).is_empty());
516 }
517}