1use std::fmt::Display;
19
20use ahash::AHashMap;
21use nautilus_core::UnixNanos;
22
23use crate::{
24 error::EventStoreError,
25 markers::{DataClass, MarkerReader, StreamCursor, StreamDictEntry, StreamSlot},
26 replay::{
27 CatalogReplayRecord, CatalogSliceCoverage, CatalogSlicePlan, CatalogSliceQuery,
28 ReplayCatalog,
29 },
30};
31
32#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct JoinedStream {
35 pub entry: StreamDictEntry,
37 pub cursor: StreamCursor,
39 pub records: Vec<CatalogReplayRecord>,
41 pub candidate: bool,
43}
44
45pub fn join_at_entry<C>(
57 reader: &MarkerReader,
58 catalog: &mut C,
59 event_seq_before: u64,
60) -> Result<Vec<JoinedStream>, EventStoreError>
61where
62 C: ReplayCatalog + ?Sized,
63{
64 let mut folded: Vec<_> = reader.fold_to(event_seq_before)?.into_values().collect();
65 folded.sort_by_key(|cursor| cursor.slot);
66 let dict = reader.stream_dictionary()?;
67
68 folded
69 .into_iter()
70 .map(|cursor| join_stream(&dict, catalog, cursor))
71 .collect()
72}
73
74fn join_stream<C>(
75 dict: &AHashMap<StreamSlot, StreamDictEntry>,
76 catalog: &mut C,
77 cursor: StreamCursor,
78) -> Result<JoinedStream, EventStoreError>
79where
80 C: ReplayCatalog + ?Sized,
81{
82 let entry = dict.get(&cursor.slot).cloned().ok_or_else(|| {
83 EventStoreError::Backend(format!(
84 "marker stream slot {} missing from dictionary",
85 cursor.slot
86 ))
87 })?;
88
89 let Some(data_cls) = replayable_data_class(entry.data_cls) else {
90 return Ok(JoinedStream {
91 entry,
92 cursor,
93 records: Vec::new(),
94 candidate: false,
95 });
96 };
97
98 let expected_count = usize::try_from(cursor.count).map_err(|_| {
99 EventStoreError::Backend(format!(
100 "marker cursor count {} does not fit in memory",
101 cursor.count
102 ))
103 })?;
104 let mut records = load_replayable_records(catalog, &entry, &cursor, data_cls)?;
105 let candidate = records.len() != expected_count;
106
107 if records.len() > expected_count {
113 records.drain(..records.len() - expected_count);
114 }
115
116 Ok(JoinedStream {
117 entry,
118 cursor,
119 records,
120 candidate,
121 })
122}
123
124fn load_replayable_records<C>(
125 catalog: &mut C,
126 entry: &StreamDictEntry,
127 cursor: &StreamCursor,
128 data_cls: &str,
129) -> Result<Vec<CatalogReplayRecord>, EventStoreError>
130where
131 C: ReplayCatalog + ?Sized,
132{
133 let query = CatalogSliceQuery {
134 data_cls: data_cls.to_string(),
135 identifiers: vec![entry.identifier.clone()],
136 start: UnixNanos::from(0),
137 end: cursor.ts_init_hi,
138 required: false,
139 };
140 let coverage = plan_slice(catalog, &query)?;
141 let plan = CatalogSlicePlan { query, coverage };
142
143 if plan.is_missing() {
144 Ok(Vec::new())
145 } else {
146 load_slice(catalog, &plan)
147 }
148}
149
150fn replayable_data_class(data_cls: DataClass) -> Option<&'static str> {
151 match data_cls {
152 DataClass::Quote => Some("quotes"),
153 DataClass::Trade => Some("trades"),
154 DataClass::Bar => Some("bars"),
155 DataClass::BookDeltas | DataClass::BookDepth10 => None,
156 }
157}
158
159fn plan_slice<C>(
160 catalog: &mut C,
161 query: &CatalogSliceQuery,
162) -> Result<CatalogSliceCoverage, EventStoreError>
163where
164 C: ReplayCatalog + ?Sized,
165{
166 catalog
167 .plan_slice(query)
168 .map_err(|e| catalog_error(&query.data_cls, "plan", e))
169}
170
171fn load_slice<C>(
172 catalog: &mut C,
173 plan: &CatalogSlicePlan,
174) -> Result<Vec<CatalogReplayRecord>, EventStoreError>
175where
176 C: ReplayCatalog + ?Sized,
177{
178 catalog
179 .load_slice(plan)
180 .map_err(|e| catalog_error(&plan.query.data_cls, "load", e))
181}
182
183fn catalog_error(data_cls: &str, action: &str, error: impl Display) -> EventStoreError {
184 EventStoreError::Backend(format!(
185 "catalog marker join {action} failed for {data_cls}: {error}"
186 ))
187}
188
189#[cfg(test)]
190mod tests {
191 use nautilus_core::UnixNanos;
192 use nautilus_model::{
193 data::{Bar, BarType, QuoteTick, TradeTick},
194 enums::AggressorSide,
195 identifiers::{InstrumentId, TradeId},
196 types::{Price, Quantity},
197 };
198 use rstest::rstest;
199
200 use super::*;
201 use crate::{
202 manifest::RunStatus,
203 markers::{
204 DataClass, DataCursorSnapshot, HiFiMarker, MarkerBackend, MarkerGap, MarkerManifest,
205 MarkerReader, MemoryMarkerBackend, StreamCursor, StreamDictEntry, compute_dict_hash,
206 compute_marker_hash,
207 },
208 replay::{
209 CatalogReplayData, CatalogReplayRecord, CatalogSliceCoverage, CatalogSlicePlan,
210 CatalogSliceQuery, ReplayCatalog,
211 },
212 };
213
214 #[rstest]
215 fn join_resolves_replayable_catalog_slices_for_entry() {
216 let quote = dict(0, DataClass::Quote, "AUD/USD.SIM");
217 let trade = dict(1, DataClass::Trade, "AUD/USD.SIM");
218 let bar_type = "AUDUSD.SIM-1-MINUTE-LAST-EXTERNAL";
219 let bar = dict(2, DataClass::Bar, bar_type);
220 let reader = reader_with(
221 vec![quote.clone(), trade.clone(), bar.clone()],
222 vec![
223 snapshot(
224 1,
225 5,
226 vec![
227 cursor(0, 2_000, 2),
228 cursor(1, 3_000, 1),
229 cursor(2, 4_000, 2),
230 ],
231 ),
232 snapshot(2, 11, vec![cursor(0, 3_000, 3)]),
233 ],
234 );
235 let mut catalog = StubReplayCatalog::new(vec![
236 quote_record(1_000),
237 quote_record(2_000),
238 quote_record(3_000),
239 trade_record(3_000),
240 bar_record(bar_type, 1_000),
241 bar_record(bar_type, 4_000),
242 bar_record(bar_type, 5_000),
243 ]);
244
245 let joined = join_at_entry(&reader, &mut catalog, 5).expect("join");
246
247 assert_eq!(joined.len(), 3);
248 assert_join(
249 &joined[0],
250 "e,
251 &cursor(0, 2_000, 2),
252 &["quotes", "quotes"],
253 false,
254 );
255 assert_join(&joined[1], &trade, &cursor(1, 3_000, 1), &["trades"], false);
256 assert_join(
257 &joined[2],
258 &bar,
259 &cursor(2, 4_000, 2),
260 &["bars", "bars"],
261 false,
262 );
263 assert_eq!(
264 catalog.plan_queries,
265 vec![
266 query("quotes", "AUD/USD.SIM", 2_000),
267 query("trades", "AUD/USD.SIM", 3_000),
268 query("bars", bar_type, 4_000),
269 ],
270 );
271 }
272
273 #[rstest]
274 fn count_mismatch_flags_candidate() {
275 let quote = dict(0, DataClass::Quote, "AUD/USD.SIM");
276 let reader = reader_with(
277 vec![quote.clone()],
278 vec![snapshot(1, 5, vec![cursor(0, 2_000, 3)])],
279 );
280 let mut catalog = StubReplayCatalog::new(vec![quote_record(1_000), quote_record(2_000)]);
281
282 let joined = join_at_entry(&reader, &mut catalog, 5).expect("join");
283
284 assert_eq!(joined.len(), 1);
285 assert_join(
286 &joined[0],
287 "e,
288 &cursor(0, 2_000, 3),
289 &["quotes", "quotes"],
290 true,
291 );
292 }
293
294 #[rstest]
295 fn over_count_mismatch_keeps_trailing_records_to_marker_count() {
296 let quote = dict(0, DataClass::Quote, "AUD/USD.SIM");
301 let reader = reader_with(
302 vec![quote.clone()],
303 vec![snapshot(1, 5, vec![cursor(0, 2_000, 2)])],
304 );
305 let mut catalog = StubReplayCatalog::new(vec![
306 quote_record(1_000),
307 quote_record(1_500),
308 quote_record(2_000),
309 ]);
310
311 let joined = join_at_entry(&reader, &mut catalog, 5).expect("join");
312
313 assert_eq!(joined.len(), 1);
314 assert_join(
315 &joined[0],
316 "e,
317 &cursor(0, 2_000, 2),
318 &["quotes", "quotes"],
319 true,
320 );
321 assert_eq!(
322 joined[0]
323 .records
324 .iter()
325 .map(|record| record.ts_init)
326 .collect::<Vec<_>>(),
327 vec![UnixNanos::from(1_500), UnixNanos::from(2_000)],
328 );
329 }
330
331 #[rstest]
332 #[case::deltas(DataClass::BookDeltas)]
333 #[case::depth10(DataClass::BookDepth10)]
334 fn order_book_streams_resolve_order_only(#[case] data_cls: DataClass) {
335 let order_book = dict(0, data_cls, "AUD/USD.SIM");
336 let reader = reader_with(
337 vec![order_book.clone()],
338 vec![snapshot(1, 5, vec![cursor(0, 2_000, 3)])],
339 );
340 let mut catalog = StubReplayCatalog::new(vec![quote_record(1_000)]);
341
342 let joined = join_at_entry(&reader, &mut catalog, 5).expect("join");
343
344 assert_eq!(joined.len(), 1);
345 assert_join(&joined[0], &order_book, &cursor(0, 2_000, 3), &[], false);
346 assert!(catalog.plan_queries.is_empty());
347 assert!(catalog.load_plans.is_empty());
348 }
349
350 #[rstest]
351 fn missing_optional_catalog_slice_flags_candidate_without_loading() {
352 let quote = dict(0, DataClass::Quote, "AUD/USD.SIM");
353 let reader = reader_with(
354 vec![quote.clone()],
355 vec![snapshot(1, 5, vec![cursor(0, 2_000, 2)])],
356 );
357 let mut catalog = StubReplayCatalog::new(Vec::new()).with_coverage_files(Vec::new());
358
359 let joined = join_at_entry(&reader, &mut catalog, 5).expect("join");
360
361 assert_eq!(joined.len(), 1);
362 assert_join(&joined[0], "e, &cursor(0, 2_000, 2), &[], true);
363 assert_eq!(
364 catalog.plan_queries,
365 vec![query("quotes", "AUD/USD.SIM", 2_000)],
366 );
367 assert!(catalog.load_plans.is_empty());
368 }
369
370 #[rstest]
371 fn missing_stream_dict_entry_returns_error() {
372 let reader = reader_with(Vec::new(), vec![snapshot(1, 5, vec![cursor(0, 2_000, 1)])]);
373 let mut catalog = StubReplayCatalog::new(Vec::new());
374
375 let err = join_at_entry(&reader, &mut catalog, 5).expect_err("missing dict must fail");
376
377 match err {
378 EventStoreError::Backend(message) => {
379 assert!(
380 message.contains("marker stream slot 0 missing from dictionary"),
381 "message was: {message}",
382 );
383 }
384 other => panic!("expected Backend, was {other:?}"),
385 }
386 }
387
388 #[rstest]
389 fn dictionary_scan_error_is_preserved() {
390 let reader = MarkerReader::new(Box::new(FailingDictBackend));
391 let mut catalog = StubReplayCatalog::new(Vec::new());
392
393 let err = join_at_entry(&reader, &mut catalog, 5).expect_err("dict scan must fail");
394
395 match err {
396 EventStoreError::Backend(message) => {
397 assert_eq!(message, "scan dict failed");
398 }
399 other => panic!("expected Backend, was {other:?}"),
400 }
401 }
402
403 #[rstest]
404 fn catalog_plan_error_is_wrapped() {
405 let quote = dict(0, DataClass::Quote, "AUD/USD.SIM");
406 let reader = reader_with(vec![quote], vec![snapshot(1, 5, vec![cursor(0, 2_000, 1)])]);
407 let mut catalog = StubReplayCatalog::new(Vec::new()).with_plan_error("offline");
408
409 let err = join_at_entry(&reader, &mut catalog, 5).expect_err("plan error must fail");
410
411 match err {
412 EventStoreError::Backend(message) => {
413 assert_eq!(
414 message,
415 "catalog marker join plan failed for quotes: offline",
416 );
417 }
418 other => panic!("expected Backend, was {other:?}"),
419 }
420 }
421
422 #[rstest]
423 fn catalog_load_error_is_wrapped() {
424 let quote = dict(0, DataClass::Quote, "AUD/USD.SIM");
425 let reader = reader_with(vec![quote], vec![snapshot(1, 5, vec![cursor(0, 2_000, 1)])]);
426 let mut catalog = StubReplayCatalog::new(Vec::new()).with_load_error("decode failed");
427
428 let err = join_at_entry(&reader, &mut catalog, 5).expect_err("load error must fail");
429
430 match err {
431 EventStoreError::Backend(message) => {
432 assert_eq!(
433 message,
434 "catalog marker join load failed for quotes: decode failed",
435 );
436 }
437 other => panic!("expected Backend, was {other:?}"),
438 }
439 }
440
441 struct StubReplayCatalog {
442 records: Vec<CatalogReplayRecord>,
443 plan_queries: Vec<CatalogSliceQuery>,
444 load_plans: Vec<CatalogSlicePlan>,
445 coverage_files: Option<Vec<String>>,
446 plan_error: Option<String>,
447 load_error: Option<String>,
448 }
449
450 impl StubReplayCatalog {
451 fn new(records: Vec<CatalogReplayRecord>) -> Self {
452 Self {
453 records,
454 plan_queries: Vec::new(),
455 load_plans: Vec::new(),
456 coverage_files: None,
457 plan_error: None,
458 load_error: None,
459 }
460 }
461
462 fn with_coverage_files(mut self, files: Vec<String>) -> Self {
463 self.coverage_files = Some(files);
464 self
465 }
466
467 fn with_plan_error(mut self, message: &str) -> Self {
468 self.plan_error = Some(message.to_string());
469 self
470 }
471
472 fn with_load_error(mut self, message: &str) -> Self {
473 self.load_error = Some(message.to_string());
474 self
475 }
476 }
477
478 impl ReplayCatalog for StubReplayCatalog {
479 type Error = String;
480
481 fn plan_slice(
482 &mut self,
483 query: &CatalogSliceQuery,
484 ) -> Result<CatalogSliceCoverage, Self::Error> {
485 if let Some(error) = &self.plan_error {
486 return Err(error.clone());
487 }
488
489 self.plan_queries.push(query.clone());
490 Ok(CatalogSliceCoverage::from_files(
491 self.coverage_files.clone().unwrap_or_else(|| {
492 vec![format!("{}/{}", query.data_cls, query.identifiers[0])]
493 }),
494 ))
495 }
496
497 fn load_slice(
498 &mut self,
499 plan: &CatalogSlicePlan,
500 ) -> Result<Vec<CatalogReplayRecord>, Self::Error> {
501 if let Some(error) = &self.load_error {
502 return Err(error.clone());
503 }
504
505 self.load_plans.push(plan.clone());
506 Ok(self
507 .records
508 .iter()
509 .filter(|record| record_matches_query(record, &plan.query))
510 .cloned()
511 .collect())
512 }
513 }
514
515 fn reader_with(
516 dict_entries: Vec<StreamDictEntry>,
517 snapshots: Vec<DataCursorSnapshot>,
518 ) -> MarkerReader {
519 let mut backend = MemoryMarkerBackend::new();
520 backend.open_run(manifest()).expect("open run");
521 for entry in dict_entries {
522 backend
523 .put_dict(&entry, compute_dict_hash(&entry))
524 .expect("put dict");
525 }
526
527 for snapshot in snapshots {
528 backend
529 .append_snapshot(&snapshot, compute_marker_hash(&snapshot))
530 .expect("append snapshot");
531 }
532 MarkerReader::new(Box::new(backend))
533 }
534
535 fn manifest() -> MarkerManifest {
536 MarkerManifest {
537 run_id: "1700000000-join".to_string(),
538 enabled_classes: vec![DataClass::Quote, DataClass::Trade, DataClass::Bar],
539 high_fidelity: false,
540 snapshot_count: 0,
541 hifi_count: 0,
542 gap_count: 0,
543 dict_count: 0,
544 status: RunStatus::Running,
545 }
546 }
547
548 fn snapshot(
549 marker_seq: u64,
550 event_seq_before: u64,
551 advanced: Vec<StreamCursor>,
552 ) -> DataCursorSnapshot {
553 DataCursorSnapshot {
554 marker_seq,
555 event_seq_before,
556 ts_init: UnixNanos::from(1_700_000_000_000_000_000 + marker_seq),
557 advanced,
558 }
559 }
560
561 fn cursor(slot: u32, ts_init_hi: u64, count: u64) -> StreamCursor {
562 StreamCursor {
563 slot,
564 ts_init_hi: UnixNanos::from(ts_init_hi),
565 count,
566 }
567 }
568
569 fn dict(slot: u32, data_cls: DataClass, identifier: &str) -> StreamDictEntry {
570 StreamDictEntry {
571 slot,
572 data_cls,
573 identifier: identifier.to_string(),
574 }
575 }
576
577 fn quote_record(ts_init: u64) -> CatalogReplayRecord {
578 let instrument_id = InstrumentId::from("AUD/USD.SIM");
579 CatalogReplayRecord::from_data(CatalogReplayData::Quote(QuoteTick::new(
580 instrument_id,
581 Price::from("1.0001"),
582 Price::from("1.0002"),
583 Quantity::from("100"),
584 Quantity::from("100"),
585 UnixNanos::from(ts_init),
586 UnixNanos::from(ts_init),
587 )))
588 }
589
590 fn trade_record(ts_init: u64) -> CatalogReplayRecord {
591 let instrument_id = InstrumentId::from("AUD/USD.SIM");
592 CatalogReplayRecord::from_data(CatalogReplayData::Trade(TradeTick::new(
593 instrument_id,
594 Price::from("1.0001"),
595 Quantity::from("100"),
596 AggressorSide::Buyer,
597 TradeId::from("T-1"),
598 UnixNanos::from(ts_init),
599 UnixNanos::from(ts_init),
600 )))
601 }
602
603 fn bar_record(bar_type: &str, ts_init: u64) -> CatalogReplayRecord {
604 CatalogReplayRecord::from_data(CatalogReplayData::Bar(Bar::new(
605 BarType::from(bar_type),
606 Price::from("1.0000"),
607 Price::from("1.0002"),
608 Price::from("1.0000"),
609 Price::from("1.0001"),
610 Quantity::from("100"),
611 UnixNanos::from(ts_init),
612 UnixNanos::from(ts_init),
613 )))
614 }
615
616 fn query(data_cls: &str, identifier: &str, end: u64) -> CatalogSliceQuery {
617 CatalogSliceQuery {
618 data_cls: data_cls.to_string(),
619 identifiers: vec![identifier.to_string()],
620 start: UnixNanos::from(0),
621 end: UnixNanos::from(end),
622 required: false,
623 }
624 }
625
626 fn record_matches_query(record: &CatalogReplayRecord, query: &CatalogSliceQuery) -> bool {
627 record.data_cls == query.data_cls
628 && record
629 .identifier
630 .as_ref()
631 .is_some_and(|identifier| query.identifiers.contains(identifier))
632 && record.ts_init >= query.start
633 && record.ts_init <= query.end
634 }
635
636 fn assert_join(
637 joined: &JoinedStream,
638 entry: &StreamDictEntry,
639 cursor: &StreamCursor,
640 data_classes: &[&str],
641 candidate: bool,
642 ) {
643 assert_eq!(&joined.entry, entry);
644 assert_eq!(&joined.cursor, cursor);
645 assert_eq!(
646 joined
647 .records
648 .iter()
649 .map(|record| record.data_cls.as_str())
650 .collect::<Vec<_>>(),
651 data_classes,
652 );
653 assert_eq!(joined.candidate, candidate);
654 }
655
656 #[derive(Debug)]
657 struct FailingDictBackend;
658
659 impl MarkerBackend for FailingDictBackend {
660 fn open_run(&mut self, _: MarkerManifest) -> Result<(), EventStoreError> {
661 unreachable!("test backend is read-only")
662 }
663
664 fn append_snapshot(
665 &mut self,
666 _: &DataCursorSnapshot,
667 _: [u8; 32],
668 ) -> Result<(), EventStoreError> {
669 unreachable!("test backend is read-only")
670 }
671
672 fn append_hifi(&mut self, _: &HiFiMarker, _: [u8; 32]) -> Result<(), EventStoreError> {
673 unreachable!("test backend is read-only")
674 }
675
676 fn append_gap(&mut self, _: &MarkerGap, _: [u8; 32]) -> Result<(), EventStoreError> {
677 unreachable!("test backend is read-only")
678 }
679
680 fn put_dict(&mut self, _: &StreamDictEntry, _: [u8; 32]) -> Result<(), EventStoreError> {
681 unreachable!("test backend is read-only")
682 }
683
684 fn scan_snapshots(&self) -> Result<Vec<DataCursorSnapshot>, EventStoreError> {
685 Ok(vec![snapshot(1, 5, vec![cursor(0, 2_000, 1)])])
686 }
687
688 fn scan_hifi(&self) -> Result<Vec<HiFiMarker>, EventStoreError> {
689 unreachable!("test does not scan high-fidelity markers")
690 }
691
692 fn scan_gaps(&self) -> Result<Vec<MarkerGap>, EventStoreError> {
693 unreachable!("test does not scan gaps")
694 }
695
696 fn scan_dict(&self) -> Result<Vec<StreamDictEntry>, EventStoreError> {
697 Err(EventStoreError::Backend("scan dict failed".to_string()))
698 }
699
700 fn seal(&mut self, _: RunStatus) -> Result<(), EventStoreError> {
701 unreachable!("test backend is read-only")
702 }
703
704 fn manifest(&self) -> Result<MarkerManifest, EventStoreError> {
705 unreachable!("test does not read manifest")
706 }
707 }
708}