1#![expect(
67 clippy::missing_fields_in_debug,
68 reason = "catalog Debug redacts internal caches"
69)]
70
71use std::{
72 collections::{BTreeMap, HashMap, HashSet},
73 fmt::Debug,
74 path::{Path, PathBuf},
75 sync::Arc,
76};
77
78use ahash::AHashMap;
79use arrow::record_batch::RecordBatch;
80use futures::StreamExt;
81use nautilus_core::{Params, UnixNanos, string::conversions::to_snake_case};
82use nautilus_model::{
83 data::{
84 Bar, CustomData, Data, DataBatch, FundingRateUpdate, HasTsInit, IndexPriceUpdate,
85 InstrumentStatus, MarkPriceUpdate, NautilusDataType, NautilusRecordType, OptionGreeks,
86 OrderBookDelta, OrderBookDepth, QuoteTick, TradeTick, close::InstrumentClose,
87 is_monotonically_increasing_by_init,
88 },
89 instruments::{Instrument, InstrumentAny},
90};
91use nautilus_serialization::arrow::{
92 ArrowSchemaProvider, DecodeDataFromRecordBatch, DecodeTypedFromRecordBatch,
93 EncodeToRecordBatch, catalog_display::catalog_record_batch_to_display,
94 custom::CustomDataDecoder, display::instrument::encode_instruments,
95 record_batch_without_identifier_column,
96};
97use object_store::{ObjectStore, ObjectStoreExt, path::Path as ObjectPath};
98use serde::Serialize;
99
100use crate::{
101 backend::parquet::{
102 intervals::query_interval_diff,
103 io::{
104 append_path_to_file_uri, decode_object_store_segment, is_remote_uri_scheme,
105 read_parquet_from_object_store, read_parquet_schema_from_object_store, remote_full_uri,
106 remote_store_root_url, write_batches_to_object_store,
107 },
108 paths::{extract_bar_type_instrument_id, query_intersects_filename},
109 },
110 catalog::{
111 session::{DEFAULT_DATA_BATCH_CHUNK_SIZE, DataBatchQueryResult, TypedDataBatchSession},
112 traits::{
113 CatalogInstrumentQuery, CatalogMetadata, CatalogQuery, CatalogReader,
114 CatalogRecordQuery, CatalogWriter, DataCatalog, filter_instrument_query_result,
115 filter_instruments_for_request_range,
116 },
117 types::{
118 CatalogAsOf, CatalogDataType, HasCatalogDataType, INSTRUMENT_PATH_PREFIXES,
119 instrument_any_type, instrument_path_prefix, parquet_data_path_prefix,
120 record_path_prefix,
121 },
122 },
123 common::{
124 custom::prepare_custom_data_batch,
125 datafusion::{self as datafusion, DataBackendSession, build_query},
126 },
127 config::validate_catalog_counts,
128};
129
130mod coverage;
131mod query;
132mod session;
133mod store;
134mod write;
135
136pub use crate::backend::parquet::{
139 intervals::{are_intervals_contiguous, are_intervals_disjoint},
140 paths::{
141 CatalogPathPrefix, extract_identifier_from_path, extract_path_components,
142 extract_sql_safe_filename, local_to_object_store_path, make_local_path,
143 make_object_store_path, make_sql_safe_identifier, parse_filename_timestamps,
144 safe_directory_identifier, timestamps_to_filename, urisafe_instrument_id,
145 },
146};
147
148pub const QUERY_OPTIMIZE_FILE_LOADING: &str = "optimize_file_loading";
150
151pub const WRITE_SKIP_DISJOINT_CHECK: &str = "skip_disjoint_check";
153
154macro_rules! define_builtin_data_dispatch {
155 (
156 (Instrument, InstrumentAny, Instrument, Instrument, $instrument_prefix:literal),
157 $(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?
158 ) => {
159
160
161 fn query_builtin_batch(
162 catalog: &mut ParquetDataCatalog,
163 data_type: &NautilusDataType,
164 identifiers: Option<Vec<String>>,
165 start: Option<UnixNanos>,
166 end: Option<UnixNanos>,
167 where_clause: Option<&str>,
168 optimize_file_loading: bool,
169 ) -> Option<anyhow::Result<DataBatch>> {
170 match data_type {
171 $(
172 NautilusDataType::$variant => Some(
173 catalog
174 .query::<$type>(
175 identifiers,
176 start,
177 end,
178 where_clause,
179 None,
180 optimize_file_loading,
181 )
182 .map(|data| DataBatch::$batch(data.into())),
183 ),
184 )+
185 _ => None,
186 }
187 }
188
189 fn write_catalog_batch(
190 catalog: &ParquetDataCatalog,
191 batch: &DataBatch,
192 start: Option<UnixNanos>,
193 end: Option<UnixNanos>,
194 skip_disjoint_check: Option<bool>,
195 ) -> anyhow::Result<()> {
196 #[allow(unreachable_patterns, reason = "reject unsupported variants introduced by feature unification")]
197 match batch {
198 DataBatch::BookDeltas(data) => {
199 let deltas = data.iter().flat_map(|batch| batch.deltas.iter().copied()).collect::<Vec<_>>();
200 catalog.write_grouped_to_parquet(&deltas, start, end, skip_disjoint_check)
201 }
202 DataBatch::Custom(data) => catalog.write_custom_data_batch(data.as_ref(), start, end, skip_disjoint_check).map(|_| ()),
203 DataBatch::Instrument(data) => catalog.write_instruments(data.as_ref().to_vec()).map(|_| ()),
204 $(DataBatch::$batch(data) => catalog.write_grouped_to_parquet(data.as_ref(), start, end, skip_disjoint_check),)+
205 _ => anyhow::bail!("Unsupported catalog data batch: {}", batch.data_type_name()),
206 }
207 }
208 };
209}
210
211nautilus_model::for_each_data_type!(define_builtin_data_dispatch);
212
213pub struct ParquetDataCatalog {
242 pub base_path: String,
244 pub original_uri: String,
246 pub object_store: Arc<dyn ObjectStore>,
248 pub session: DataBackendSession,
250 pub batch_size: usize,
252 pub compression: parquet::basic::Compression,
254 pub max_row_group_size: usize,
256}
257
258impl Debug for ParquetDataCatalog {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 f.debug_struct(stringify!(ParquetDataCatalog))
261 .field("base_path", &self.base_path)
262 .finish()
263 }
264}
265
266impl ParquetDataCatalog {
267 #[must_use]
301 pub fn new(
302 base_path: &Path,
303 storage_options: Option<AHashMap<String, String>>,
304 batch_size: Option<usize>,
305 compression: Option<parquet::basic::Compression>,
306 max_row_group_size: Option<usize>,
307 ) -> Self {
308 let path_str = base_path.to_string_lossy().to_string();
309 Self::from_uri(
310 &path_str,
311 storage_options,
312 batch_size,
313 compression,
314 max_row_group_size,
315 )
316 .expect("Failed to create catalog from path")
317 }
318
319 pub fn from_uri(
392 uri: &str,
393 storage_options: Option<AHashMap<String, String>>,
394 batch_size: Option<usize>,
395 compression: Option<parquet::basic::Compression>,
396 max_row_group_size: Option<usize>,
397 ) -> anyhow::Result<Self> {
398 validate_catalog_counts(batch_size, max_row_group_size)?;
399
400 let batch_size = batch_size.unwrap_or(DEFAULT_DATA_BATCH_CHUNK_SIZE);
401 let compression = compression.unwrap_or(parquet::basic::Compression::ZSTD(
402 parquet::basic::ZstdLevel::default(),
403 ));
404 let max_row_group_size =
405 max_row_group_size.unwrap_or(crate::backend::parquet::DEFAULT_ROW_GROUP_SIZE);
406
407 let location = crate::backend::parquet::io::create_object_store_location_from_path(
408 uri,
409 storage_options,
410 )?;
411
412 Ok(Self {
413 base_path: location.base_path,
414 original_uri: location.original_uri,
415 object_store: location.object_store,
416 session: DataBackendSession::new(batch_size),
417 batch_size,
418 compression,
419 max_row_group_size,
420 })
421 }
422
423 #[must_use]
425 pub fn get_base_path(&self) -> String {
426 self.base_path.clone()
427 }
428
429 pub(crate) fn clear_session_tables(&mut self) {
433 self.session.clear_registered_tables();
434 }
435}
436
437impl CatalogReader for ParquetDataCatalog {
438 fn fork_query_catalog(&self) -> anyhow::Result<Option<DataCatalog>> {
439 Ok(Some(Box::new(Self {
440 base_path: self.base_path.clone(),
441 original_uri: self.original_uri.clone(),
442 object_store: self.object_store.clone(),
443 session: DataBackendSession::new(self.batch_size),
444 batch_size: self.batch_size,
445 compression: self.compression,
446 max_row_group_size: self.max_row_group_size,
447 })))
448 }
449
450 fn query_batch_session(
451 &mut self,
452 query: &CatalogQuery,
453 chunk_size: Option<usize>,
454 ) -> anyhow::Result<DataBatchQueryResult> {
455 ensure_latest_query(query.as_of)?;
456 macro_rules! batch_session {
457 ((Instrument, InstrumentAny, Instrument, Instrument, $instrument_prefix:literal), $(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?) => {
458 match &query.data_type {
459 $(NautilusDataType::$variant => {
460 let pages = self.query_typed_pages::<$type>(
461 query.identifiers.clone(), query.start, query.end, query.where_clause.as_deref(), None,
462 optimize_file_loading(query.params.as_ref()),
463 )?;
464 Ok(Box::new(TypedDataBatchSession::new(pages, chunk_size)) as DataBatchQueryResult)
465 },)+
466 _ => match self.query_batch(query)? {
467 DataBatch::Instrument(data) => Ok(Box::new(TypedDataBatchSession::from_vec(data.as_ref().to_vec(), chunk_size))),
468 DataBatch::Custom(data) => Ok(Box::new(TypedDataBatchSession::from_vec(data.as_ref().to_vec(), chunk_size))),
469 _ => anyhow::bail!("Unsupported Parquet query family"),
470 },
471 }
472 };
473 }
474 nautilus_model::for_each_data_type!(batch_session)
475 }
476
477 fn reset_session(&mut self) {
478 self.clear_session_tables();
479 }
480
481 fn instruments(
482 &mut self,
483 query: &CatalogInstrumentQuery,
484 ) -> anyhow::Result<Vec<InstrumentAny>> {
485 let CatalogInstrumentQuery {
486 instrument_ids,
487 start,
488 end,
489 where_clause,
490 instrument_type,
491 } = query.clone();
492 let instrument_ids = instrument_ids.as_deref();
493 self.query_instruments_filtered_with_where_and_type(
494 instrument_ids,
495 start,
496 end,
497 where_clause.as_deref(),
498 instrument_type.as_ref(),
499 )
500 }
501
502 fn query_batch(&mut self, query: &CatalogQuery) -> anyhow::Result<DataBatch> {
503 ensure_latest_query(query.as_of)?;
504 let CatalogQuery {
505 data_type,
506 identifiers,
507 start,
508 end,
509 where_clause,
510 params,
511 instrument_type,
512 ..
513 } = query.clone();
514 let where_clause = where_clause.as_deref();
515 let optimize_file_loading = optimize_file_loading(params.as_ref());
516
517 match data_type {
518 NautilusDataType::Instrument => {
519 let data = self.query_instruments_filtered_with_where_and_type(
520 identifiers.as_deref(),
521 start,
522 end,
523 where_clause,
524 instrument_type.as_ref(),
525 )?;
526 Ok(DataBatch::Instrument(
527 filter_instrument_query_result(data, start, params.as_ref()).into(),
528 ))
529 }
530 NautilusDataType::Custom { type_name } => {
531 let data = self.query_custom_data_dynamic(
532 &type_name,
533 identifiers.as_deref(),
534 start,
535 end,
536 where_clause,
537 None,
538 optimize_file_loading,
539 )?;
540 Ok(DataBatch::Custom(
541 data.into_iter()
542 .filter_map(|item| match item {
543 Data::Custom(custom) => Some(custom),
544 _ => None,
545 })
546 .collect::<Vec<_>>()
547 .into(),
548 ))
549 }
550 #[cfg(feature = "defi")]
551 NautilusDataType::Defi => Err(anyhow::Error::from(
552 crate::errors::PersistenceError::unsupported("Parquet catalog DeFi data"),
553 )),
554 data_type => query_builtin_batch(
555 self,
556 &data_type,
557 identifiers,
558 start,
559 end,
560 where_clause,
561 optimize_file_loading,
562 )
563 .unwrap_or_else(|| {
564 Err(anyhow::Error::from(
565 crate::errors::PersistenceError::unsupported(format!(
566 "Parquet catalog {data_type} data"
567 )),
568 ))
569 }),
570 }
571 }
572
573 fn query_identifiers(&mut self, query: &CatalogQuery) -> anyhow::Result<Vec<String>> {
574 ensure_latest_query(query.as_of)?;
575 let CatalogQuery {
576 data_type,
577 identifiers,
578 start,
579 end,
580 where_clause,
581 params,
582 instrument_type,
583 ..
584 } = query.clone();
585
586 if data_type == NautilusDataType::Instrument {
587 let mut identifiers = self
588 .query_instruments_filtered_with_where_and_type(
589 identifiers.as_deref(),
590 start,
591 end,
592 where_clause.as_deref(),
593 instrument_type.as_ref(),
594 )?
595 .into_iter()
596 .map(|instrument| instrument.id().to_string())
597 .collect::<Vec<_>>();
598 identifiers.sort();
599 identifiers.dedup();
600 return Ok(identifiers);
601 }
602
603 Self::query_identifiers(
604 self,
605 &CatalogDataType::Data(data_type),
606 identifiers,
607 start,
608 end,
609 where_clause.as_deref(),
610 optimize_file_loading(params.as_ref()),
611 )
612 }
613
614 fn query_display_record_batches(
615 &mut self,
616 query: &CatalogQuery,
617 ) -> anyhow::Result<Vec<RecordBatch>> {
618 ensure_latest_query(query.as_of)?;
619 let CatalogQuery {
620 data_type,
621 identifiers,
622 start,
623 end,
624 where_clause,
625 params,
626 instrument_type,
627 ..
628 } = query.clone();
629
630 if data_type == NautilusDataType::Instrument {
631 let instruments = self.query_instruments_filtered_with_where_and_type(
632 identifiers.as_deref(),
633 start,
634 end,
635 where_clause.as_deref(),
636 instrument_type.as_ref(),
637 )?;
638 return if instruments.is_empty() {
639 Ok(Vec::new())
640 } else {
641 Ok(vec![encode_instruments(&instruments)?])
642 };
643 }
644
645 Self::query_display_record_batches(
646 self,
647 &data_type,
648 identifiers,
649 start,
650 end,
651 where_clause.as_deref(),
652 optimize_file_loading(params.as_ref()),
653 )
654 }
655
656 fn query_record_batches(
657 &mut self,
658 query: &CatalogRecordQuery,
659 ) -> anyhow::Result<Vec<RecordBatch>> {
660 ensure_latest_query(query.as_of)?;
661 let CatalogRecordQuery {
662 record_type,
663 identifier,
664 start,
665 end,
666 where_clause,
667 params,
668 ..
669 } = query.clone();
670 Self::query_record_batches(
671 self,
672 &CatalogDataType::Record(record_type),
673 identifier,
674 start,
675 end,
676 where_clause.as_deref(),
677 optimize_file_loading(params.as_ref()),
678 )
679 }
680
681 fn query_record_display_batches(
682 &mut self,
683 query: &CatalogRecordQuery,
684 ) -> anyhow::Result<Vec<RecordBatch>> {
685 CatalogReader::query_record_batches(self, query)
686 }
687
688 fn query_metadata(&mut self, query: &CatalogQuery) -> anyhow::Result<Vec<CatalogMetadata>> {
689 ensure_latest_query(query.as_of)?;
690 let CatalogQuery {
691 data_type,
692 identifiers,
693 start,
694 end,
695 where_clause,
696 instrument_type,
697 ..
698 } = query.clone();
699
700 let data_type = match (data_type, instrument_type) {
701 (NautilusDataType::Instrument, Some(instrument_type)) => {
702 CatalogDataType::Instrument(instrument_type)
703 }
704 (data_type, _) => CatalogDataType::Data(data_type),
705 };
706
707 Self::query_metadata(
708 self,
709 &data_type,
710 identifiers,
711 start,
712 end,
713 where_clause.as_deref(),
714 )
715 }
716
717 fn get_missing_intervals_for_request(
718 &mut self,
719 start: UnixNanos,
720 end: UnixNanos,
721 data_type: NautilusDataType,
722 identifier: Option<&str>,
723 ) -> anyhow::Result<Vec<(u64, u64)>> {
724 match data_type {
725 NautilusDataType::Instrument => {
726 let identifiers = identifier.map(|value| vec![value.to_string()]);
727 let data = self.query_instruments_filtered(
728 identifiers.as_deref(),
729 Some(start),
730 Some(end),
731 )?;
732 Ok(if data.is_empty() {
733 vec![(start.as_u64(), end.as_u64())]
734 } else {
735 Vec::new()
736 })
737 }
738 NautilusDataType::Custom { type_name } => {
739 if let Some(identifier) = identifier {
740 let directory = self.make_path_custom_data(&type_name, Some(identifier))?;
741 let intervals = self.get_directory_intervals(&directory)?;
742
743 Ok(query_interval_diff(
744 start.as_u64(),
745 end.as_u64(),
746 &intervals,
747 ))
748 } else {
749 Self::get_missing_intervals_for_request(
750 self,
751 start.as_u64(),
752 end.as_u64(),
753 &CatalogDataType::Data(NautilusDataType::Custom { type_name }),
754 None,
755 )
756 }
757 }
758 _ => Self::get_missing_intervals_for_request(
759 self,
760 start.as_u64(),
761 end.as_u64(),
762 &CatalogDataType::Data(data_type),
763 identifier,
764 ),
765 }
766 }
767
768 fn query_last_timestamp(
769 &mut self,
770 data_type: NautilusDataType,
771 identifier: Option<&str>,
772 ) -> anyhow::Result<Option<u64>> {
773 match data_type {
774 NautilusDataType::Instrument => {
775 let identifiers = identifier.map(|value| vec![value.to_string()]);
776 Ok(self
777 .query_instruments(identifiers.as_deref())?
778 .into_iter()
779 .map(|instrument| HasTsInit::ts_init(&instrument).as_u64())
780 .max())
781 }
782 NautilusDataType::Custom { type_name } => {
783 if let Some(identifier) = identifier {
784 let directory = self.make_path_custom_data(&type_name, Some(identifier))?;
785 let intervals = self.get_directory_intervals(&directory)?;
786
787 Ok(intervals.into_iter().map(|(_, end)| end).max())
788 } else {
789 Self::query_last_timestamp(
790 self,
791 &CatalogDataType::Data(NautilusDataType::Custom { type_name }),
792 None,
793 )
794 }
795 }
796 _ => Self::query_last_timestamp(self, &CatalogDataType::Data(data_type), identifier),
797 }
798 }
799}
800
801impl CatalogWriter for ParquetDataCatalog {
802 fn write_instruments(&mut self, instruments: &[InstrumentAny]) -> anyhow::Result<()> {
803 Self::write_instruments(self, instruments.to_vec()).map(|_| ())
804 }
805
806 fn write_data(
807 &mut self,
808 data: &[Data],
809 start: Option<UnixNanos>,
810 end: Option<UnixNanos>,
811 params: Option<Params>,
812 ) -> anyhow::Result<()> {
813 if data.is_empty() {
814 return Ok(());
815 }
816
817 let skip_disjoint_check = params
818 .as_ref()
819 .and_then(|params| params.get_bool(WRITE_SKIP_DISJOINT_CHECK));
820 self.write_data_enum(data, start, end, skip_disjoint_check)
821 }
822
823 fn write_data_batch(
824 &mut self,
825 batch: &DataBatch,
826 start: Option<UnixNanos>,
827 end: Option<UnixNanos>,
828 params: Option<Params>,
829 ) -> anyhow::Result<()> {
830 let skip_disjoint_check = params
831 .as_ref()
832 .and_then(|params| params.get_bool(WRITE_SKIP_DISJOINT_CHECK));
833
834 write_catalog_batch(self, batch, start, end, skip_disjoint_check)
835 }
836
837 fn write_records(
838 &mut self,
839 record_type: NautilusRecordType,
840 batches: &[RecordBatch],
841 params: Option<Params>,
842 ) -> anyhow::Result<()> {
843 let params = params.unwrap_or_default();
844 self.write_record_batches(&record_type, params.get_str("identifier"), batches, ¶ms)
845 }
846
847 fn record_empty_coverage(
848 &mut self,
849 data_type: NautilusDataType,
850 identifier: Option<&str>,
851 start: UnixNanos,
852 end: UnixNanos,
853 ) -> anyhow::Result<()> {
854 match &data_type {
855 NautilusDataType::Instrument => {
856 anyhow::bail!(
857 "Cannot record empty instrument coverage without a concrete instrument type"
858 )
859 }
860 NautilusDataType::Custom { type_name } => {
861 let directory = self.make_path_custom_data(type_name, identifier)?;
862 self.extend_file_name_in_directory(&directory, start, end)
863 }
864 _ => self.extend_file_name(&CatalogDataType::Data(data_type), identifier, start, end),
865 }
866 }
867}
868
869fn ensure_latest_query(as_of: CatalogAsOf) -> anyhow::Result<()> {
870 anyhow::ensure!(
871 as_of == CatalogAsOf::Latest,
872 "Parquet catalog does not support historical queries"
873 );
874 Ok(())
875}
876
877fn optimize_file_loading(params: Option<&Params>) -> bool {
878 params
879 .and_then(|params| params.get_bool(QUERY_OPTIMIZE_FILE_LOADING))
880 .unwrap_or(true)
881}