Skip to main content

nautilus_event_store/markers/
join.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Catalog joins for folded data marker cursors.
17
18use 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/// Catalog rows joined to one folded marker stream cursor.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct JoinedStream {
35    /// Stream dictionary entry for the folded cursor.
36    pub entry: StreamDictEntry,
37    /// Folded cursor for the requested event-store boundary.
38    pub cursor: StreamCursor,
39    /// Catalog rows selected for replayable data classes.
40    pub records: Vec<CatalogReplayRecord>,
41    /// Whether the catalog row count disagrees with the folded marker count.
42    pub candidate: bool,
43}
44
45/// Joins folded marker cursors at `event_seq_before` to replayable catalog rows.
46///
47/// Quote, trade, and bar streams plan and load catalog slices from the beginning of the
48/// catalog through the folded cursor's `ts_init_hi`, then return the first `count` rows.
49/// Order-book streams remain order-only and return no catalog rows.
50///
51/// # Errors
52///
53/// Returns [`EventStoreError`] when marker cursor folding fails, when a folded slot has no
54/// dictionary entry, when `count` does not fit in memory on this platform, or when the catalog
55/// fails while planning or loading a replayable slice.
56pub 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    // The run observed the trailing `count` records ending at ts_init_hi, while the
108    // slice query spans the whole catalog, so a catalog that predates the run
109    // over-returns at the front: keep the trailing window. Rows sharing ts_init_hi
110    // where only a prefix was observed remain ambiguous; `candidate` stays true and
111    // an exact boundary needs the hifi same_ts_ordinal/fingerprint join.
112    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            &quote,
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            &quote,
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        // The cursor counted the records the run observed ending at ts_init_hi=2_000,
297        // so a record carrying that ts_init was observed by construction. A catalog
298        // that predates the run over-returns at the front of the slice; the join must
299        // keep the trailing window, never head rows the run may not have seen.
300        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            &quote,
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], &quote, &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}