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 nautilus_model::data::QuoteTick;
384    use rstest::rstest;
385
386    use super::*;
387    use crate::catalog::session::TypedDataBatchSession;
388
389    #[derive(Debug)]
390    struct ReaderOnlyCatalog;
391
392    impl CatalogReader for ReaderOnlyCatalog {
393        fn reset_session(&mut self) {}
394
395        fn instruments(
396            &mut self,
397            _query: &CatalogInstrumentQuery,
398        ) -> anyhow::Result<Vec<InstrumentAny>> {
399            Ok(Vec::new())
400        }
401
402        fn query_batch(&mut self, _query: &CatalogQuery) -> anyhow::Result<DataBatch> {
403            Ok(DataBatch::Quote(Vec::new().into()))
404        }
405
406        fn query_batch_session(
407            &mut self,
408            _query: &CatalogQuery,
409            chunk_size: Option<usize>,
410        ) -> anyhow::Result<DataBatchQueryResult> {
411            Ok(Box::new(TypedDataBatchSession::<QuoteTick>::from_vec(
412                Vec::new(),
413                chunk_size,
414            )))
415        }
416    }
417
418    #[rstest]
419    fn reader_only_catalog_uses_optional_capability_defaults() {
420        let mut catalog = ReaderOnlyCatalog;
421
422        let error = catalog
423            .query_metadata(&CatalogQuery::new(NautilusDataType::QuoteTick))
424            .unwrap_err();
425
426        match error.downcast_ref::<PersistenceError>() {
427            Some(PersistenceError::Unsupported(operation)) => {
428                assert_eq!(operation, "query_metadata");
429            }
430            other => panic!("Expected an unsupported capability error, received {other:?}"),
431        }
432    }
433
434    #[rstest]
435    fn reader_only_catalog_reports_every_unimplemented_capability_as_unsupported() {
436        let mut catalog = ReaderOnlyCatalog;
437
438        let errors = [
439            catalog
440                .query_identifiers(&CatalogQuery::new(NautilusDataType::QuoteTick))
441                .unwrap_err(),
442            catalog
443                .get_missing_intervals_for_request(
444                    UnixNanos::default(),
445                    UnixNanos::default(),
446                    NautilusDataType::QuoteTick,
447                    None,
448                )
449                .unwrap_err(),
450            catalog
451                .query_last_timestamp(NautilusDataType::QuoteTick, None)
452                .unwrap_err(),
453            catalog
454                .query_display_record_batches(&CatalogQuery::new(NautilusDataType::QuoteTick))
455                .unwrap_err(),
456            catalog
457                .query_record_batches(&CatalogRecordQuery::new(NautilusRecordType::AccountState))
458                .unwrap_err(),
459        ];
460
461        let operations = errors
462            .iter()
463            .map(|e| match e.downcast_ref::<PersistenceError>() {
464                Some(PersistenceError::Unsupported(operation)) => operation.clone(),
465                other => panic!("Expected an unsupported capability error, received {other:?}"),
466            })
467            .collect::<Vec<_>>();
468
469        assert_eq!(
470            operations,
471            vec![
472                "query_identifiers",
473                "get_missing_intervals_for_request",
474                "query_last_timestamp",
475                "query_display_record_batches",
476                "query_record_batches",
477            ],
478        );
479    }
480}