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}