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
294 .lock()
295 .expect("shared marker")
296 .open_run(manifest())
297 .expect("open marker run");
298
299 let writer = MarkerWriter::spawn(
300 Box::new(wrapper),
301 get_atomic_clock_static(),
302 MarkerWriterConfig {
303 channel_capacity: 100,
304 max_batch: 1,
305 max_latency: Duration::from_millis(1),
306 },
307 )
308 .expect("spawn writer");
309
310 (
311 DataMarkerCapture::new(registry(), writer, submit_counter, config),
312 shared,
313 )
314 }
315
316 fn snapshots(shared: &SharedMemoryMarkerState) -> Vec<DataCursorSnapshot> {
317 shared
318 .lock()
319 .expect("shared marker")
320 .scan_snapshots()
321 .expect("scan snapshots")
322 }
323
324 fn hifi(shared: &SharedMemoryMarkerState) -> Vec<HiFiMarker> {
325 shared
326 .lock()
327 .expect("shared marker")
328 .scan_hifi()
329 .expect("scan hifi")
330 }
331
332 fn dict(shared: &SharedMemoryMarkerState) -> Vec<StreamDictEntry> {
333 shared
334 .lock()
335 .expect("shared marker")
336 .scan_dict()
337 .expect("scan dict")
338 }
339
340 #[rstest]
341 fn event_seq_before_tracks_submit_counter() {
342 let submit_counter = Arc::new(AtomicU64::new(5));
343 let cfg = config(vec!["ETHUSDT.BINANCE".to_string()], Duration::from_secs(1));
344 let (mut capture, shared) = open_capture(&cfg, Arc::clone(&submit_counter));
345 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
346
347 capture.observe_publish(
348 topic,
349 "e("ETHUSDT.BINANCE", 10, 20),
350 UnixNanos::from(20),
351 );
352 submit_counter.store(6, Ordering::Release);
353 capture.on_entry_submitted(UnixNanos::from(30));
354 capture.close();
355
356 let snapshots = snapshots(&shared);
357 let hifi = hifi(&shared);
358
359 assert_eq!(hifi.len(), 1);
360 assert_eq!(hifi[0].event_seq_before, 5);
361 assert_eq!(snapshots.len(), 1);
362 assert_eq!(snapshots[0].event_seq_before, 6);
363 }
364
365 #[rstest]
366 fn snapshot_written_on_entry_boundary() {
367 let submit_counter = Arc::new(AtomicU64::new(1));
368 let cfg = config(Vec::new(), Duration::from_secs(1));
369 let (mut capture, shared) = open_capture(&cfg, submit_counter);
370 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
371
372 capture.observe_publish(
373 topic,
374 "e("ETHUSDT.BINANCE", 10, 20),
375 UnixNanos::from(20),
376 );
377 capture.on_entry_submitted(UnixNanos::from(30));
378 capture.close();
379
380 let snapshots = snapshots(&shared);
381 let dict = dict(&shared);
382
383 assert_eq!(snapshots.len(), 1);
384 assert_eq!(snapshots[0].marker_seq, 1);
385 assert_eq!(snapshots[0].event_seq_before, 1);
386 assert_eq!(snapshots[0].advanced[0].count, 1);
387 assert_eq!(
388 dict,
389 vec![StreamDictEntry {
390 slot: 0,
391 data_cls: DataClass::Quote,
392 identifier: "ETHUSDT.BINANCE".to_string(),
393 }]
394 );
395 }
396
397 #[rstest]
398 fn safety_flush_emits_without_entry() {
399 let submit_counter = Arc::new(AtomicU64::new(0));
400 let cfg = config(Vec::new(), Duration::from_nanos(10));
401 let (mut capture, shared) = open_capture(&cfg, submit_counter);
402 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
403
404 capture.observe_publish(
405 topic,
406 "e("ETHUSDT.BINANCE", 10, 20),
407 UnixNanos::from(20),
408 );
409 capture.maybe_safety_flush(UnixNanos::from(30));
410 capture.close();
411
412 let snapshots = snapshots(&shared);
413
414 assert_eq!(snapshots.len(), 1);
415 assert_eq!(snapshots[0].event_seq_before, 0);
416 assert_eq!(snapshots[0].ts_init, UnixNanos::from(30));
417 }
418
419 #[rstest]
420 fn safety_flush_waits_for_interval_after_entry_boundary() {
421 let submit_counter = Arc::new(AtomicU64::new(1));
422 let cfg = config(Vec::new(), Duration::from_nanos(10));
423 let (mut capture, shared) = open_capture(&cfg, submit_counter);
424 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
425
426 capture.observe_publish(
427 topic,
428 "e("ETHUSDT.BINANCE", 10, 20),
429 UnixNanos::from(20),
430 );
431 capture.on_entry_submitted(UnixNanos::from(100));
432 capture.observe_publish(
433 topic,
434 "e("ETHUSDT.BINANCE", 30, 40),
435 UnixNanos::from(40),
436 );
437 capture.maybe_safety_flush(UnixNanos::from(109));
438 capture.maybe_safety_flush(UnixNanos::from(110));
439 capture.close();
440
441 let snapshots = snapshots(&shared);
442
443 assert_eq!(snapshots.len(), 2);
444 assert_eq!(snapshots[0].ts_init, UnixNanos::from(100));
445 assert_eq!(snapshots[0].advanced[0].count, 1);
446 assert_eq!(snapshots[1].ts_init, UnixNanos::from(110));
447 assert_eq!(snapshots[1].advanced[0].count, 2);
448 }
449
450 #[rstest]
451 fn hifi_marker_emitted_for_configured_instrument() {
452 let submit_counter = Arc::new(AtomicU64::new(2));
453 let cfg = config(vec!["ETHUSDT.BINANCE".to_string()], Duration::from_secs(1));
454 let (mut capture, shared) = open_capture(&cfg, submit_counter);
455 let eth_topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
456 let btc_topic: Topic = "data.quotes.BINANCE.BTCUSDT".into();
457
458 capture.observe_publish(
459 eth_topic,
460 "e("ETHUSDT.BINANCE", 10, 20),
461 UnixNanos::from(20),
462 );
463 capture.observe_publish(
464 btc_topic,
465 "e("BTCUSDT.BINANCE", 30, 40),
466 UnixNanos::from(40),
467 );
468 capture.close();
469
470 let hifi = hifi(&shared);
471
472 assert_eq!(hifi.len(), 1);
473 assert_eq!(hifi[0].marker_seq, 1);
474 assert_eq!(hifi[0].event_seq_before, 2);
475 assert_eq!(hifi[0].slot, 0);
476 assert_eq!(hifi[0].ts_event, UnixNanos::from(10));
477 assert_eq!(hifi[0].ts_init, UnixNanos::from(20));
478 assert_eq!(hifi[0].same_ts_ordinal, 0);
479 assert_eq!(hifi[0].record_fingerprint, [7; 32]);
480 }
481
482 #[rstest]
483 fn hifi_same_ts_ordinal_increments() {
484 let submit_counter = Arc::new(AtomicU64::new(0));
485 let cfg = config(vec!["ETHUSDT.BINANCE".to_string()], Duration::from_secs(1));
486 let (mut capture, shared) = open_capture(&cfg, submit_counter);
487 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
488
489 capture.observe_publish(
490 topic,
491 "e("ETHUSDT.BINANCE", 10, 20),
492 UnixNanos::from(20),
493 );
494 capture.observe_publish(
495 topic,
496 "e("ETHUSDT.BINANCE", 11, 20),
497 UnixNanos::from(20),
498 );
499 capture.close();
500
501 let hifi = hifi(&shared);
502
503 assert_eq!(hifi.len(), 2);
504 assert_eq!(
505 hifi.iter()
506 .map(|marker| marker.same_ts_ordinal)
507 .collect::<Vec<_>>(),
508 vec![0, 1]
509 );
510 assert_eq!(
511 hifi.iter()
512 .map(|marker| marker.marker_seq)
513 .collect::<Vec<_>>(),
514 vec![1, 2]
515 );
516 }
517
518 #[rstest]
519 fn unregistered_type_is_ignored() {
520 let submit_counter = Arc::new(AtomicU64::new(0));
521 let cfg = config(Vec::new(), Duration::from_nanos(1));
522 let (mut capture, shared) = open_capture(&cfg, submit_counter);
523 let topic: Topic = "data.quotes.BINANCE.ETHUSDT".into();
524
525 capture.observe_publish(topic, &IgnoredMessage, UnixNanos::from(20));
526 capture.maybe_safety_flush(UnixNanos::from(30));
527 capture.close();
528
529 assert!(snapshots(&shared).is_empty());
530 assert!(hifi(&shared).is_empty());
531 assert!(dict(&shared).is_empty());
532 }
533}