Skip to main content

nautilus_persistence/catalog/
traits.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//! Backend-neutral catalog trait declarations for object-safe runtime catalog APIs.
17
18use std::{borrow::Cow, fmt::Debug};
19
20use ahash::AHashMap;
21pub use arrow::record_batch::RecordBatch;
22use nautilus_core::{ClosedInterval, Params, UnixNanos};
23use nautilus_model::{
24    data::{Data, DataBatch, NautilusDataType, NautilusRecordType},
25    instruments::InstrumentAny,
26};
27
28pub use super::types::{
29    CatalogAsOf, CatalogCommit, CatalogInstrumentQuery, CatalogQuery, CatalogRecordQuery,
30};
31pub(crate) use super::types::{
32    filter_instrument_query_result, filter_instruments_for_request_range,
33};
34use crate::{
35    catalog::session::DataBatchQueryResult,
36    common::coverage::{CoverageIntervals, missing_intervals},
37    errors::PersistenceError,
38};
39
40/// Boxed runtime catalog backend.
41pub type DataCatalog = Box<dyn Catalog>;
42
43/// Builds the error a backend returns for a catalog capability it does not implement.
44///
45/// Callers distinguish these from genuine failures by downcasting to
46/// [`PersistenceError::Unsupported`].
47fn unsupported(operation: &str) -> anyhow::Error {
48    anyhow::Error::from(PersistenceError::unsupported(operation))
49}
50
51/// Arrow schema metadata and the first queried timestamp where it is used.
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct CatalogMetadata {
54    pub first_ts_init: UnixNanos,
55    pub metadata: Params,
56}
57
58/// Persistence-specific paths for [`NautilusDataType`].
59pub trait NautilusDataTypePrefix {
60    /// Returns the catalog path prefix for this data type.
61    ///
62    /// For built-in variants the prefix is borrowed from the type's `CatalogPathPrefix`
63    /// implementation. For [`NautilusDataType::Custom`] the prefix is owned because it
64    /// embeds the user-supplied `type_name`.
65    #[must_use]
66    fn path_prefix(&self) -> Cow<'static, str>;
67}
68
69/// Persistence-specific paths for [`NautilusRecordType`].
70pub trait NautilusRecordTypePrefix {
71    /// Returns catalog path prefix for record type.
72    #[must_use]
73    fn path_prefix(&self) -> Cow<'static, str>;
74}
75
76/// Runtime catalog read API used by data loading code.
77///
78/// The methods are object-safe so callers in other crates can accept a catalog backend without
79/// depending on a concrete table format.
80/// Implementations must preserve the typed logical schema of built-in data, records, and
81/// instruments. Only user-defined [`Data::Custom`] values may use an opaque, self-describing
82/// payload.
83pub trait CatalogReader: Debug + Send {
84    /// Creates an independent catalog for a lazy query while sharing backend resources.
85    ///
86    /// Backends with mutable per-query state should return a catalog with isolated session state.
87    /// The default keeps external catalogs on the existing one-instance-per-query path.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the backend cannot create the query catalog.
92    fn fork_query_catalog(&self) -> anyhow::Result<Option<DataCatalog>> {
93        Ok(None)
94    }
95
96    /// Resets any per-query session state.
97    fn reset_session(&mut self);
98
99    /// Queries instruments known by the catalog.
100    ///
101    /// Applies `where_clause` as an additional SQL predicate when supported by the backend.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if the backend cannot query instruments.
106    fn instruments(&mut self, query: &CatalogInstrumentQuery)
107    -> anyhow::Result<Vec<InstrumentAny>>;
108
109    /// Queries catalog data as a typed batch.
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if the backend query fails.
114    fn query_batch(&mut self, query: &CatalogQuery) -> anyhow::Result<DataBatch>;
115
116    /// Queries catalog data as a typed batch session.
117    ///
118    /// `chunk_size` configures this query session only. `None` uses the default streaming chunk size;
119    /// `Some(n)` yields timestamp-aligned typed batches no smaller than `n` when same-ts rows cross
120    /// the boundary.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if the backend query fails.
125    fn query_batch_session(
126        &mut self,
127        query: &CatalogQuery,
128        chunk_size: Option<usize>,
129    ) -> anyhow::Result<DataBatchQueryResult>;
130
131    /// Queries the concrete catalog row identifiers matched by a data query.
132    ///
133    /// Implementations should use backend-native projection (for example, DataFusion
134    /// `SELECT DISTINCT identifier`) so callers can discover identifiers without
135    /// materializing full market data rows.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if the backend identifier query fails, or if the backend
140    /// does not override this default implementation.
141    fn query_identifiers(&mut self, _query: &CatalogQuery) -> anyhow::Result<Vec<String>> {
142        Err(unsupported("query_identifiers"))
143    }
144
145    /// Queries Arrow schema metadata and the first queried timestamp where each metadata is used.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the backend query or metadata lookup fails.
150    fn query_metadata(&mut self, _query: &CatalogQuery) -> anyhow::Result<Vec<CatalogMetadata>> {
151        Err(unsupported("query_metadata"))
152    }
153
154    /// Returns request intervals not covered by catalog data or known-empty coverage.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if interval discovery fails.
159    fn get_missing_intervals_for_request(
160        &mut self,
161        _start: UnixNanos,
162        _end: UnixNanos,
163        _data_type: NautilusDataType,
164        _identifier: Option<&str>,
165    ) -> anyhow::Result<Vec<(u64, u64)>> {
166        Err(unsupported("get_missing_intervals_for_request"))
167    }
168
169    /// Returns missing request intervals for each identifier.
170    ///
171    /// Backends should override this method when all identifiers can share one coverage scan.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if interval discovery fails.
176    fn get_missing_intervals_for_identifiers(
177        &mut self,
178        start: UnixNanos,
179        end: UnixNanos,
180        data_type: NautilusDataType,
181        identifiers: &[String],
182    ) -> anyhow::Result<AHashMap<String, Vec<(u64, u64)>>> {
183        identifiers
184            .iter()
185            .map(|identifier| {
186                self.get_missing_intervals_for_request(
187                    start,
188                    end,
189                    data_type.clone(),
190                    Some(identifier),
191                )
192                .map(|missing| (identifier.clone(), missing))
193            })
194            .collect()
195    }
196
197    /// Returns effective data and known-empty coverage for each identifier.
198    ///
199    /// Backends should override this method when all identifiers can share one coverage scan.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if interval discovery fails.
204    fn get_coverage_intervals_for_identifiers(
205        &mut self,
206        start: UnixNanos,
207        end: UnixNanos,
208        data_type: NautilusDataType,
209        identifiers: &[String],
210    ) -> anyhow::Result<AHashMap<String, CoverageIntervals>> {
211        self.get_missing_intervals_for_identifiers(start, end, data_type, identifiers)?
212            .into_iter()
213            .map(|(identifier, missing)| {
214                let missing = missing
215                    .into_iter()
216                    .filter_map(|(start, end)| ClosedInterval::new(start, end))
217                    .collect::<Vec<_>>();
218                let data = missing_intervals(start.as_u64(), end.as_u64(), &missing);
219                Ok((
220                    identifier,
221                    CoverageIntervals {
222                        data,
223                        empty: Vec::new(),
224                    },
225                ))
226            })
227            .collect()
228    }
229
230    /// Returns the last timestamp covered by the catalog for a data type and optional identifier.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if backend coverage discovery fails.
235    fn query_last_timestamp(
236        &mut self,
237        _data_type: NautilusDataType,
238        _identifier: Option<&str>,
239    ) -> anyhow::Result<Option<u64>> {
240        Err(unsupported("query_last_timestamp"))
241    }
242
243    /// Queries catalog data as display-friendly Arrow record batches.
244    ///
245    /// Display conversion normalizes fixed-point prices and quantities to floating-point columns
246    /// and preserves catalog query semantics for the concrete backend. Implementations should
247    /// accept multiple identifiers in one call; shared-table backends can use a single
248    /// multi-identifier predicate, while file-oriented backends can concatenate matching results.
249    ///
250    /// # Errors
251    ///
252    /// Returns an error if the backend query or display conversion fails.
253    fn query_display_record_batches(
254        &mut self,
255        _query: &CatalogQuery,
256    ) -> anyhow::Result<Vec<RecordBatch>> {
257        Err(unsupported("query_display_record_batches"))
258    }
259
260    /// Queries catalog records as raw Arrow record batches.
261    ///
262    /// This supports record types outside the [`Data`] enum, such as account state,
263    /// order/position events, snapshots, reports, and instruments.
264    /// Backends must preserve the fixed schema selected by [`NautilusRecordType`] rather than
265    /// returning an opaque serialized payload.
266    ///
267    /// # Errors
268    ///
269    /// Returns an error if backend query execution fails.
270    fn query_record_batches(
271        &mut self,
272        _query: &CatalogRecordQuery,
273    ) -> anyhow::Result<Vec<RecordBatch>> {
274        Err(unsupported("query_record_batches"))
275    }
276
277    /// Queries catalog records as display-friendly Arrow record batches.
278    ///
279    /// Implementations may return raw batches for record types with no specialized
280    /// display conversion.
281    ///
282    /// # Errors
283    ///
284    /// Returns an error if backend query or display conversion fails.
285    fn query_record_display_batches(
286        &mut self,
287        query: &CatalogRecordQuery,
288    ) -> anyhow::Result<Vec<RecordBatch>> {
289        self.query_record_batches(query)
290    }
291}
292
293/// Runtime catalog mutation API.
294///
295/// Implementations must preserve the typed logical schema of built-in data, records, and
296/// instruments. Only user-defined [`Data::Custom`] values may use an opaque, self-describing
297/// payload.
298pub trait CatalogWriter: Debug + Send {
299    /// Writes instrument definitions into the catalog.
300    ///
301    /// Backends must preserve typed instrument fields rather than storing an opaque serialized
302    /// instrument payload.
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if the backend write fails.
307    fn write_instruments(&mut self, instruments: &[InstrumentAny]) -> anyhow::Result<()>;
308
309    /// Writes mixed built-in data values into the catalog.
310    ///
311    /// Pass a non-empty `data` vec. To record coverage for a known-empty interval,
312    /// use [`Self::record_empty_coverage`] instead.
313    /// Backends must preserve the fixed schema of built-in variants. An opaque, self-describing
314    /// payload is permitted only for user-defined [`Data::Custom`] values.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if the backend write fails.
319    fn write_data(
320        &mut self,
321        data: &[Data],
322        start: Option<UnixNanos>,
323        end: Option<UnixNanos>,
324        params: Option<Params>,
325    ) -> anyhow::Result<()>;
326
327    /// Writes a typed data batch into the catalog.
328    ///
329    /// Implementations can override this to avoid materializing compatibility [`Data`] rows.
330    ///
331    /// # Errors
332    ///
333    /// Returns an error if the backend write fails.
334    fn write_data_batch(
335        &mut self,
336        batch: &DataBatch,
337        start: Option<UnixNanos>,
338        end: Option<UnixNanos>,
339        params: Option<Params>,
340    ) -> anyhow::Result<()> {
341        let data = batch.to_data_vec_for_compat();
342        self.write_data(&data, start, end, params)
343    }
344
345    /// Writes Arrow record batches for a record family into the catalog.
346    ///
347    /// Backends must preserve the fixed schema selected by [`NautilusRecordType`] rather than
348    /// storing an opaque serialized payload.
349    ///
350    /// # Errors
351    ///
352    /// Returns an error if the backend cannot persist the record batches.
353    fn write_records(
354        &mut self,
355        record_type: NautilusRecordType,
356        batches: &[RecordBatch],
357        params: Option<Params>,
358    ) -> anyhow::Result<()>;
359
360    /// Records request coverage for a known-empty interval.
361    ///
362    /// Backends may do nothing when the interval is not adjacent to an existing file.
363    ///
364    /// # Errors
365    ///
366    /// Returns an error if the backend cannot record empty coverage.
367    fn record_empty_coverage(
368        &mut self,
369        data_type: NautilusDataType,
370        identifier: Option<&str>,
371        start: UnixNanos,
372        end: UnixNanos,
373    ) -> anyhow::Result<()>;
374}
375
376/// Full read-write catalog capability used by factories and catalog workers.
377pub trait Catalog: CatalogReader + CatalogWriter {}
378
379impl<T> Catalog for T where T: CatalogReader + CatalogWriter + ?Sized {}
380
381#[cfg(test)]
382mod tests {
383    use std::sync::Arc;
384
385    use arrow::{
386        array::StringArray,
387        datatypes::{DataType, Field, Schema},
388    };
389    use nautilus_model::data::QuoteTick;
390    use rstest::rstest;
391
392    use super::*;
393    use crate::catalog::session::TypedDataBatchSession;
394
395    #[derive(Debug)]
396    struct ReaderOnlyCatalog;
397
398    impl CatalogReader for ReaderOnlyCatalog {
399        fn reset_session(&mut self) {}
400
401        fn instruments(
402            &mut self,
403            _query: &CatalogInstrumentQuery,
404        ) -> anyhow::Result<Vec<InstrumentAny>> {
405            Ok(Vec::new())
406        }
407
408        fn query_batch(&mut self, _query: &CatalogQuery) -> anyhow::Result<DataBatch> {
409            Ok(DataBatch::Quote(Vec::new().into()))
410        }
411
412        fn query_batch_session(
413            &mut self,
414            _query: &CatalogQuery,
415            chunk_size: Option<usize>,
416        ) -> anyhow::Result<DataBatchQueryResult> {
417            Ok(Box::new(TypedDataBatchSession::<QuoteTick>::from_vec(
418                Vec::new(),
419                chunk_size,
420            )))
421        }
422    }
423
424    #[rstest]
425    fn reader_only_catalog_reports_every_unimplemented_capability_as_unsupported() {
426        let mut catalog = ReaderOnlyCatalog;
427
428        let errors = [
429            catalog
430                .query_identifiers(&CatalogQuery::new(NautilusDataType::QuoteTick))
431                .unwrap_err(),
432            catalog
433                .query_metadata(&CatalogQuery::new(NautilusDataType::QuoteTick))
434                .unwrap_err(),
435            catalog
436                .get_missing_intervals_for_request(
437                    UnixNanos::default(),
438                    UnixNanos::default(),
439                    NautilusDataType::QuoteTick,
440                    None,
441                )
442                .unwrap_err(),
443            catalog
444                .query_last_timestamp(NautilusDataType::QuoteTick, None)
445                .unwrap_err(),
446            catalog
447                .query_display_record_batches(&CatalogQuery::new(NautilusDataType::QuoteTick))
448                .unwrap_err(),
449            catalog
450                .query_record_batches(&CatalogRecordQuery::new(NautilusRecordType::AccountState))
451                .unwrap_err(),
452        ];
453
454        let operations = errors
455            .iter()
456            .map(|e| match e.downcast_ref::<PersistenceError>() {
457                Some(PersistenceError::Unsupported(operation)) => operation.clone(),
458                other => panic!("Expected an unsupported capability error, received {other:?}"),
459            })
460            .collect::<Vec<_>>();
461
462        assert_eq!(
463            operations,
464            vec![
465                "query_identifiers",
466                "query_metadata",
467                "get_missing_intervals_for_request",
468                "query_last_timestamp",
469                "query_display_record_batches",
470                "query_record_batches",
471            ],
472        );
473    }
474
475    #[derive(Debug)]
476    struct IntervalCatalog;
477
478    impl CatalogReader for IntervalCatalog {
479        fn reset_session(&mut self) {}
480
481        fn instruments(
482            &mut self,
483            _query: &CatalogInstrumentQuery,
484        ) -> anyhow::Result<Vec<InstrumentAny>> {
485            Ok(Vec::new())
486        }
487
488        fn query_batch(&mut self, _query: &CatalogQuery) -> anyhow::Result<DataBatch> {
489            Ok(DataBatch::Quote(Vec::new().into()))
490        }
491
492        fn query_batch_session(
493            &mut self,
494            _query: &CatalogQuery,
495            chunk_size: Option<usize>,
496        ) -> anyhow::Result<DataBatchQueryResult> {
497            Ok(Box::new(TypedDataBatchSession::<QuoteTick>::from_vec(
498                Vec::new(),
499                chunk_size,
500            )))
501        }
502
503        fn get_missing_intervals_for_request(
504            &mut self,
505            start: UnixNanos,
506            end: UnixNanos,
507            _data_type: NautilusDataType,
508            identifier: Option<&str>,
509        ) -> anyhow::Result<Vec<(u64, u64)>> {
510            match identifier {
511                Some("partial") => Ok(vec![(5, 6)]),
512                Some("missing") => Ok(vec![(start.as_u64(), end.as_u64())]),
513                _ => Ok(Vec::new()),
514            }
515        }
516
517        fn query_record_batches(
518            &mut self,
519            query: &CatalogRecordQuery,
520        ) -> anyhow::Result<Vec<RecordBatch>> {
521            let schema = Arc::new(Schema::new(vec![Field::new(
522                "identifier",
523                DataType::Utf8,
524                true,
525            )]));
526            let identifier = StringArray::from(vec![query.identifier.clone()]);
527            Ok(vec![RecordBatch::try_new(
528                schema,
529                vec![Arc::new(identifier)],
530            )?])
531        }
532    }
533
534    fn identifiers() -> Vec<String> {
535        vec![
536            "partial".to_string(),
537            "missing".to_string(),
538            "covered".to_string(),
539        ]
540    }
541
542    #[rstest]
543    fn missing_intervals_for_identifiers_default_queries_each_identifier() {
544        let mut catalog = IntervalCatalog;
545
546        let missing = catalog
547            .get_missing_intervals_for_identifiers(
548                UnixNanos::from(1),
549                UnixNanos::from(10),
550                NautilusDataType::QuoteTick,
551                &identifiers(),
552            )
553            .unwrap();
554
555        assert_eq!(
556            missing,
557            AHashMap::from_iter([
558                ("partial".to_string(), vec![(5, 6)]),
559                ("missing".to_string(), vec![(1, 10)]),
560                ("covered".to_string(), Vec::new()),
561            ]),
562        );
563    }
564
565    #[rstest]
566    fn coverage_intervals_for_identifiers_default_reports_request_complement_as_data() {
567        let mut catalog = IntervalCatalog;
568
569        let coverage = catalog
570            .get_coverage_intervals_for_identifiers(
571                UnixNanos::from(1),
572                UnixNanos::from(10),
573                NautilusDataType::QuoteTick,
574                &identifiers(),
575            )
576            .unwrap();
577
578        let data = |pairs: &[(u64, u64)]| CoverageIntervals {
579            data: pairs
580                .iter()
581                .map(|&(start, end)| ClosedInterval::new(start, end).unwrap())
582                .collect(),
583            empty: Vec::new(),
584        };
585
586        assert_eq!(
587            coverage,
588            AHashMap::from_iter([
589                ("partial".to_string(), data(&[(1, 4), (7, 10)])),
590                ("missing".to_string(), data(&[])),
591                ("covered".to_string(), data(&[(1, 10)])),
592            ]),
593        );
594    }
595
596    #[rstest]
597    fn record_display_batches_default_returns_raw_record_batches() {
598        let mut catalog = IntervalCatalog;
599        let query = CatalogRecordQuery::new(NautilusRecordType::FillReport)
600            .with_identifier(Some("O-1".to_string()));
601
602        let display = catalog.query_record_display_batches(&query).unwrap();
603
604        assert_eq!(display, catalog.query_record_batches(&query).unwrap());
605    }
606}