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}