1#![expect(
19 clippy::missing_errors_doc,
20 clippy::missing_panics_doc,
21 reason = "catalog write functions validate catalog-controlled batches and forward storage errors"
22)]
23
24use nautilus_serialization::arrow::catalog_identifier_from_metadata;
25
26use super::{
27 BTreeMap, CustomData, Data, DataBatch, EncodeToRecordBatch, HasCatalogDataType, HasTsInit,
28 InstrumentAny, NautilusRecordType, ObjectPath, ObjectStoreExt, Params, ParquetDataCatalog,
29 PathBuf, RecordBatch, Serialize, UnixNanos, WRITE_SKIP_DISJOINT_CHECK, are_intervals_disjoint,
30 instrument_any_type, instrument_path_prefix, parquet_data_path_prefix,
31 prepare_custom_data_batch, record_batch_without_identifier_column, record_path_prefix,
32 timestamps_to_filename, to_snake_case, write_batches_to_object_store, write_catalog_batch,
33};
34use crate::{
35 backend::parquet::io::write_batches_to_object_store_create,
36 common::metadata::record_batch_ts_init_range,
37};
38
39impl ParquetDataCatalog {
40 pub fn write_data_enum(
77 &self,
78 data: &[Data],
79 start: Option<UnixNanos>,
80 end: Option<UnixNanos>,
81 skip_disjoint_check: Option<bool>,
82 ) -> anyhow::Result<()> {
83 for batch in DataBatch::from_data_vec_grouped(data)? {
84 write_catalog_batch(self, &batch, start, end, skip_disjoint_check)?;
85 }
86 Ok(())
87 }
88
89 pub(super) fn write_grouped_to_parquet<T>(
90 &self,
91 data: &[T],
92 start: Option<UnixNanos>,
93 end: Option<UnixNanos>,
94 skip_disjoint_check: Option<bool>,
95 ) -> anyhow::Result<()>
96 where
97 T: Clone + HasTsInit + EncodeToRecordBatch + HasCatalogDataType,
98 {
99 let mut groups: BTreeMap<Option<String>, Vec<T>> = BTreeMap::new();
100
101 for item in data {
102 let identifier = catalog_identifier_from_metadata(&item.metadata());
103 groups.entry(identifier).or_default().push(item.clone());
104 }
105
106 for items in groups.into_values() {
107 self.write_to_parquet(&items, start, end, skip_disjoint_check)?;
108 }
109 Ok(())
110 }
111
112 pub fn write_record_batches(
118 &mut self,
119 record_type: &NautilusRecordType,
120 identifier: Option<&str>,
121 batches: &[RecordBatch],
122 params: &Params,
123 ) -> anyhow::Result<()> {
124 if batches.is_empty() || batches.iter().all(|batch| batch.num_rows() == 0) {
125 return Ok(());
126 }
127
128 let (start_ts, end_ts) = record_batch_ts_init_range(batches)?;
129 let record_prefix = record_path_prefix(record_type);
130 let directory = self.make_path(record_prefix.as_ref(), identifier)?;
131 let filename = timestamps_to_filename(UnixNanos::from(start_ts), UnixNanos::from(end_ts));
132 let path = PathBuf::from(directory.clone()).join(&filename);
133 let object_path = self.to_object_path(&path.to_string_lossy())?;
134 let skip_disjoint_check = params.get_bool(WRITE_SKIP_DISJOINT_CHECK).unwrap_or(false);
135
136 if !skip_disjoint_check {
137 let current_intervals = self.get_directory_intervals(&directory)?;
138 let mut intervals = current_intervals.clone();
139 intervals.push((start_ts, end_ts));
140 anyhow::ensure!(
141 are_intervals_disjoint(&intervals),
142 "Writing file {filename} interval ({start_ts}, {end_ts}) would create non-disjoint intervals. Existing intervals: {current_intervals:?}",
143 );
144 }
145
146 self.execute_async(|| async {
147 write_batches_to_object_store(
148 batches,
149 self.object_store.clone(),
150 &object_path,
151 Some(self.compression),
152 Some(self.max_row_group_size),
153 None,
154 )
155 .await
156 })
157 }
158
159 pub fn write_to_parquet<T>(
215 &self,
216 data: &[T],
217 start: Option<UnixNanos>,
218 end: Option<UnixNanos>,
219 skip_disjoint_check: Option<bool>,
220 ) -> anyhow::Result<PathBuf>
221 where
222 T: HasTsInit + EncodeToRecordBatch + HasCatalogDataType,
223 {
224 if data.is_empty() {
225 return Ok(PathBuf::new());
226 }
227
228 let type_name = to_snake_case(std::any::type_name::<T>());
229 Self::check_ascending_timestamps(data, &type_name)?;
230
231 let chunk_metadata = T::chunk_metadata(data);
232 if let Some(position) = data
233 .iter()
234 .position(|item| !item.matches_chunk_metadata(&chunk_metadata))
235 {
236 anyhow::bail!(
237 "Cannot write {type_name} data with mixed identities: element {position} has \
238 metadata {:?} but the chunk has {chunk_metadata:?}; write each \
239 instrument or bar type separately",
240 data[position].metadata(),
241 );
242 }
243
244 let start_ts = start.unwrap_or(data.first().unwrap().ts_init());
245 let end_ts = end.unwrap_or(data.last().unwrap().ts_init());
246
247 let batches = self.data_to_record_batches(data)?;
248 let schema = batches.first().expect("Batches are empty.").schema();
249
250 let data_type = T::catalog_data_type();
251 let path_prefix = parquet_data_path_prefix(&data_type);
252 let identifier = if matches!(data_type, super::NautilusDataType::Bar) {
253 schema.metadata.get("bar_type").cloned()
254 } else {
255 schema.metadata.get("instrument_id").cloned()
256 };
257
258 let directory = self.make_path(path_prefix.as_ref(), identifier.as_deref())?;
259 self.write_parquet_file_checked(
260 &directory,
261 start_ts,
262 end_ts,
263 &batches,
264 skip_disjoint_check.unwrap_or(false),
265 "File",
266 Some(&format!("{type_name} data")),
267 None,
268 )
269 }
270
271 pub fn write_custom_data_batch<D>(
296 &self,
297 data: D,
298 start: Option<UnixNanos>,
299 end: Option<UnixNanos>,
300 skip_disjoint_check: Option<bool>,
301 ) -> anyhow::Result<PathBuf>
302 where
303 D: AsRef<[CustomData]>,
304 {
305 let data = data.as_ref();
306 let data = data.iter().collect::<Vec<_>>();
307 self.write_custom_data_refs_batch(&data, start, end, skip_disjoint_check)
308 }
309
310 pub(crate) fn write_custom_data_refs_batch(
311 &self,
312 data: &[&CustomData],
313 start: Option<UnixNanos>,
314 end: Option<UnixNanos>,
315 skip_disjoint_check: Option<bool>,
316 ) -> anyhow::Result<PathBuf> {
317 if data.is_empty() {
318 return Ok(PathBuf::new());
319 }
320
321 let (batch, type_name, identifier, start_ts, end_ts) = prepare_custom_data_batch(data)?;
322 let start_ts = start.unwrap_or(start_ts);
323 let end_ts = end.unwrap_or(end_ts);
324 let batches = vec![record_batch_without_identifier_column(batch)?];
325
326 let directory = self.make_path_custom_data(&type_name, identifier.as_deref())?;
327 self.write_parquet_file_checked(
328 &directory,
329 start_ts,
330 end_ts,
331 &batches,
332 skip_disjoint_check.unwrap_or(false),
333 "File",
334 None,
335 None,
336 )
337 }
338
339 pub fn write_instruments(
380 &self,
381 instruments: Vec<InstrumentAny>,
382 ) -> anyhow::Result<Vec<PathBuf>> {
383 use nautilus_model::instruments::Instrument;
384
385 if instruments.is_empty() {
386 return Ok(Vec::new());
387 }
388
389 let mut by_type_and_id: BTreeMap<(String, String), Vec<InstrumentAny>> = BTreeMap::new();
392
393 for instrument in instruments {
394 let instrument_type = instrument_any_type(&instrument);
395 let instrument_prefix = instrument_path_prefix(&instrument_type).to_string();
396 let instrument_id = Instrument::id(&instrument).to_string();
397 by_type_and_id
398 .entry((instrument_prefix, instrument_id))
399 .or_default()
400 .push(instrument);
401 }
402
403 let mut paths = Vec::new();
404
405 for ((instrument_prefix, instrument_id), instrument_group) in by_type_and_id {
406 Self::check_ascending_timestamps(&instrument_group, "instrument")?;
407
408 let start_ts = HasTsInit::ts_init(instrument_group.first().unwrap());
409 let end_ts = HasTsInit::ts_init(instrument_group.last().unwrap());
410 let batches = self.data_to_record_batches(&instrument_group)?;
411 if batches.is_empty() {
412 continue;
413 }
414
415 let directory = self.make_path(&instrument_prefix, Some(instrument_id.as_str()))?;
416
417 let path = self.write_parquet_file_checked(
420 &directory,
421 start_ts,
422 end_ts,
423 &batches,
424 false,
425 "Instrument file",
426 Some(&format!("instrument data for {instrument_id}")),
427 None,
428 )?;
429
430 paths.push(path);
431 }
432
433 Ok(paths)
434 }
435
436 #[expect(clippy::too_many_arguments)]
442 pub(crate) fn write_parquet_file_checked(
443 &self,
444 directory: &str,
445 start_ts: UnixNanos,
446 end_ts: UnixNanos,
447 batches: &[RecordBatch],
448 skip_disjoint_check: bool,
449 file_label: &str,
450 data_description: Option<&str>,
451 replay_identity: Option<&str>,
452 ) -> anyhow::Result<PathBuf> {
453 let filename = timestamps_to_filename(start_ts, end_ts);
454 let filename = replay_identity.map_or(filename.clone(), |identity| {
455 let stem = filename.strip_suffix(".parquet").unwrap_or(&filename);
456 let digest = blake3::hash(identity.as_bytes()).to_hex();
457 format!("{stem}_{}.parquet", &digest[..16])
458 });
459 let path = PathBuf::from(directory).join(&filename);
460 let object_path = self.to_object_path(&path.to_string_lossy())?;
461
462 let file_exists = self.execute_async(|| async {
463 let exists: bool = self.object_store.head(&object_path).await.is_ok();
464 Ok(exists)
465 })?;
466
467 if file_exists {
468 log::info!(
469 "{file_label} {} already exists, skipping write",
470 path.display()
471 );
472 return Ok(path);
473 }
474
475 if !skip_disjoint_check {
476 let current_intervals = self.get_directory_intervals(directory)?;
477 let new_interval = (start_ts.as_u64(), end_ts.as_u64());
478 let mut new_intervals = current_intervals.clone();
479 new_intervals.push(new_interval);
480
481 if !are_intervals_disjoint(&new_intervals) {
482 anyhow::bail!(
483 "Writing file {filename} with interval ({start_ts}, {end_ts}) would create \
484 non-disjoint intervals. Existing intervals: {current_intervals:?}"
485 );
486 }
487 }
488
489 if let Some(data_description) = data_description {
490 log::info!(
491 "Writing {} batches of {data_description} to {}",
492 batches.len(),
493 path.display(),
494 );
495 }
496
497 self.execute_async(|| async {
498 let result = if replay_identity.is_some() {
499 write_batches_to_object_store_create(
500 batches,
501 self.object_store.clone(),
502 &object_path,
503 Some(self.compression),
504 Some(self.max_row_group_size),
505 None,
506 )
507 .await
508 } else {
509 write_batches_to_object_store(
510 batches,
511 self.object_store.clone(),
512 &object_path,
513 Some(self.compression),
514 Some(self.max_row_group_size),
515 None,
516 )
517 .await
518 };
519
520 if let Err(e) = result {
521 if replay_identity.is_some()
522 && matches!(
523 e.downcast_ref::<object_store::Error>(),
524 Some(object_store::Error::AlreadyExists { .. })
525 )
526 {
527 return Ok(());
528 }
529 return Err(e);
530 }
531 Ok(())
532 })?;
533
534 Ok(path)
535 }
536
537 pub fn write_to_json<T>(
591 &self,
592 data: Vec<T>,
593 path: Option<PathBuf>,
594 write_metadata: bool,
595 ) -> anyhow::Result<PathBuf>
596 where
597 T: HasTsInit + Serialize + HasCatalogDataType + EncodeToRecordBatch,
598 {
599 if data.is_empty() {
600 return Ok(PathBuf::new());
601 }
602
603 let type_name = to_snake_case(std::any::type_name::<T>());
604 Self::check_ascending_timestamps(&data, &type_name)?;
605
606 let start_ts = data.first().unwrap().ts_init();
607 let end_ts = data.last().unwrap().ts_init();
608
609 let data_type = T::catalog_data_type();
610 let path_prefix = parquet_data_path_prefix(&data_type);
611 let directory = path
612 .unwrap_or_else(|| PathBuf::from(self.make_path(path_prefix.as_ref(), None).unwrap()));
613 let filename = timestamps_to_filename(start_ts, end_ts).replace(".parquet", ".json");
614 let json_path = directory.join(&filename);
615
616 log::info!(
617 "Writing {} records of {type_name} data to {}",
618 data.len(),
619 json_path.display(),
620 );
621
622 if write_metadata {
623 let metadata = T::chunk_metadata(&data);
624 let metadata_path = json_path.with_extension("metadata.json");
625 log::info!("Writing metadata to {}", metadata_path.display());
626
627 let metadata_object_path = ObjectPath::from(metadata_path.to_string_lossy().as_ref());
629 let metadata_json = serde_json::to_vec_pretty(&metadata)?;
630 self.execute_async(|| async {
631 let _: object_store::PutResult = self
632 .object_store
633 .put(&metadata_object_path, metadata_json.into())
634 .await?;
635 Ok(())
636 })?;
637 }
638
639 let json_object_path = ObjectPath::from(json_path.to_string_lossy().as_ref());
641 let json_data = serde_json::to_vec_pretty(&serde_json::to_value(data)?)?;
642 self.execute_async(|| async {
643 let _: object_store::PutResult = self
644 .object_store
645 .put(&json_object_path, json_data.into())
646 .await?;
647 Ok(())
648 })?;
649
650 Ok(json_path)
651 }
652
653 pub fn check_ascending_timestamps<T: HasTsInit>(
660 data: &[T],
661 type_name: &str,
662 ) -> anyhow::Result<()> {
663 if !data
664 .array_windows()
665 .all(|[a, b]| a.ts_init() <= b.ts_init())
666 {
667 anyhow::bail!("{type_name} timestamps must be in ascending order");
668 }
669
670 Ok(())
671 }
672
673 pub fn data_to_record_batches<T>(&self, data: &[T]) -> anyhow::Result<Vec<RecordBatch>>
694 where
695 T: HasTsInit + EncodeToRecordBatch,
696 {
697 if data.is_empty() {
698 return Ok(Vec::new());
699 }
700
701 let mut batches = Vec::new();
702 let metadata = EncodeToRecordBatch::chunk_metadata(data);
703
704 for chunk in data.chunks(self.batch_size) {
705 let record_batch = T::encode_batch(&metadata, chunk)?;
706 let record_batch = record_batch_without_identifier_column(record_batch)?;
707 batches.push(record_batch);
708 }
709
710 Ok(batches)
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use std::{
717 fmt::Display,
718 fs::File,
719 sync::{
720 Arc,
721 atomic::{AtomicUsize, Ordering},
722 },
723 };
724
725 use arrow::{
726 array::Int64Array,
727 datatypes::{DataType, Field, Schema},
728 record_batch::RecordBatch,
729 };
730 use futures::stream::BoxStream;
731 use nautilus_core::UnixNanos;
732 use nautilus_model::data::{
733 OrderBookDelta, OrderBookDepth,
734 stubs::{stub_delta, stub_depth10},
735 };
736 use nautilus_serialization::arrow::{
737 DecodeFromRecordBatch, KEY_PRICE_PRECISION, KEY_SIZE_PRECISION,
738 };
739 use object_store::{
740 CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore,
741 ObjectStoreExt, PutMode, PutMultipartOptions, PutOptions, PutPayload, PutResult,
742 Result as ObjectStoreResult, memory::InMemory, path::Path as ObjectPath,
743 };
744 use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
745 use rstest::rstest;
746 use tempfile::TempDir;
747
748 use super::ParquetDataCatalog;
749 use crate::common::datafusion::DataBackendSession;
750
751 #[rstest]
752 fn depth_write_shares_file_metadata_across_chunks(stub_depth10: OrderBookDepth) {
753 let mut empty = stub_depth10.clone();
754 empty.bids.clear();
755 empty.asks.clear();
756 empty.bid_counts.clear();
757 empty.ask_counts.clear();
758 let directory = TempDir::new().unwrap();
759 let catalog = ParquetDataCatalog::from_uri(
760 directory.path().to_str().unwrap(),
761 None,
762 Some(2),
763 None,
764 Some(2),
765 )
766 .unwrap();
767 let data = vec![empty.clone(), empty, stub_depth10];
768
769 let path = catalog.write_to_parquet(&data, None, None, None).unwrap();
770 let builder = ParquetRecordBatchReaderBuilder::try_new(
771 File::open(directory.path().join(path)).unwrap(),
772 )
773 .unwrap();
774 let metadata = builder.schema().metadata().clone();
775 let batches = builder
776 .build()
777 .unwrap()
778 .collect::<Result<Vec<_>, _>>()
779 .unwrap();
780 let decoded = batches
781 .iter()
782 .cloned()
783 .flat_map(|batch| OrderBookDepth::decode_batch(&metadata, batch).unwrap())
784 .collect::<Vec<_>>();
785
786 assert_eq!(metadata[KEY_PRICE_PRECISION], "2");
787 assert_eq!(metadata[KEY_SIZE_PRECISION], "0");
788 assert_eq!(decoded.len(), 3);
789 assert_eq!(decoded, data);
790 assert_eq!(decoded[2].bids[0].price.precision, 2);
791 }
792
793 #[rstest]
794 fn leading_clear_delta_writes_with_following_precision(stub_delta: OrderBookDelta) {
795 let directory = TempDir::new().unwrap();
796 let catalog = ParquetDataCatalog::from_uri(
797 directory.path().to_str().unwrap(),
798 None,
799 Some(2),
800 None,
801 None,
802 )
803 .unwrap();
804 let clear = OrderBookDelta::clear(
805 stub_delta.instrument_id,
806 0,
807 UnixNanos::from(1),
808 UnixNanos::from(1),
809 );
810 let second_clear = OrderBookDelta::clear(
811 stub_delta.instrument_id,
812 0,
813 UnixNanos::from(2),
814 UnixNanos::from(2),
815 );
816
817 let path = catalog
818 .write_to_parquet(&[clear, second_clear, stub_delta], None, None, None)
819 .unwrap();
820 let builder = ParquetRecordBatchReaderBuilder::try_new(
821 File::open(directory.path().join(&path)).unwrap(),
822 )
823 .unwrap();
824 let metadata = builder.schema().metadata().clone();
825 let decoded = builder
826 .build()
827 .unwrap()
828 .map(|batch| OrderBookDelta::decode_batch(&metadata, batch.unwrap()).unwrap())
829 .collect::<Vec<_>>()
830 .concat();
831
832 assert!(directory.path().join(path).exists());
833 assert_eq!(metadata[KEY_PRICE_PRECISION], "2");
834 assert_eq!(decoded[2].order.price.precision, 2);
835 }
836
837 #[derive(Debug)]
838 struct CreateRaceStore {
839 inner: InMemory,
840 create_calls: AtomicUsize,
841 }
842
843 impl Display for CreateRaceStore {
844 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
845 f.write_str("create-race")
846 }
847 }
848
849 #[async_trait::async_trait]
850 impl ObjectStore for CreateRaceStore {
851 async fn put_opts(
852 &self,
853 location: &ObjectPath,
854 payload: PutPayload,
855 opts: PutOptions,
856 ) -> ObjectStoreResult<PutResult> {
857 if opts.mode == PutMode::Create {
858 self.create_calls.fetch_add(1, Ordering::Relaxed);
859 return Err(object_store::Error::AlreadyExists {
860 path: location.to_string(),
861 source: Box::new(std::io::Error::new(
862 std::io::ErrorKind::AlreadyExists,
863 "injected create race",
864 )),
865 });
866 }
867 self.inner.put_opts(location, payload, opts).await
868 }
869
870 async fn put_multipart_opts(
871 &self,
872 location: &ObjectPath,
873 opts: PutMultipartOptions,
874 ) -> ObjectStoreResult<Box<dyn MultipartUpload>> {
875 self.inner.put_multipart_opts(location, opts).await
876 }
877
878 async fn get_opts(
879 &self,
880 location: &ObjectPath,
881 options: GetOptions,
882 ) -> ObjectStoreResult<GetResult> {
883 self.inner.get_opts(location, options).await
884 }
885
886 fn list(
887 &self,
888 prefix: Option<&ObjectPath>,
889 ) -> BoxStream<'static, ObjectStoreResult<ObjectMeta>> {
890 self.inner.list(prefix)
891 }
892
893 async fn list_with_delimiter(
894 &self,
895 prefix: Option<&ObjectPath>,
896 ) -> ObjectStoreResult<ListResult> {
897 self.inner.list_with_delimiter(prefix).await
898 }
899
900 fn delete_stream(
901 &self,
902 locations: BoxStream<'static, ObjectStoreResult<ObjectPath>>,
903 ) -> BoxStream<'static, ObjectStoreResult<ObjectPath>> {
904 self.inner.delete_stream(locations)
905 }
906
907 async fn copy_opts(
908 &self,
909 from: &ObjectPath,
910 to: &ObjectPath,
911 opts: CopyOptions,
912 ) -> ObjectStoreResult<()> {
913 self.inner.copy_opts(from, to, opts).await
914 }
915 }
916
917 #[rstest]
918 fn promotion_writes_to_memory_store_without_copy_support() {
919 let catalog = ParquetDataCatalog {
920 base_path: "catalog".to_string(),
921 original_uri: "memory://".to_string(),
922 object_store: Arc::new(InMemory::new()),
923 session: DataBackendSession::new(5_000),
924 batch_size: 5_000,
925 compression: parquet::basic::Compression::SNAPPY,
926 max_row_group_size: 5_000,
927 };
928 let batch = RecordBatch::try_new(
929 Arc::new(Schema::new(vec![Field::new(
930 "ts_init",
931 DataType::Int64,
932 false,
933 )])),
934 vec![Arc::new(Int64Array::from(vec![1]))],
935 )
936 .unwrap();
937
938 let path = catalog
939 .write_parquet_file_checked(
940 "quotes/TEST",
941 UnixNanos::from(1),
942 UnixNanos::from(1),
943 &[batch],
944 false,
945 "Promoted file",
946 None,
947 Some("memory-replay"),
948 )
949 .unwrap();
950 let object_path = catalog.to_object_path(&path.to_string_lossy()).unwrap();
951 catalog
952 .execute_async(|| async {
953 catalog.object_store.head(&object_path).await?;
954 Ok(())
955 })
956 .unwrap();
957 }
958
959 #[rstest]
960 fn promotion_accepts_already_exists_after_head_miss() {
961 let object_store = Arc::new(CreateRaceStore {
962 inner: InMemory::new(),
963 create_calls: AtomicUsize::new(0),
964 });
965 let catalog = ParquetDataCatalog {
966 base_path: "catalog".to_string(),
967 original_uri: "memory://".to_string(),
968 object_store: object_store.clone(),
969 session: DataBackendSession::new(5_000),
970 batch_size: 5_000,
971 compression: parquet::basic::Compression::SNAPPY,
972 max_row_group_size: 5_000,
973 };
974 let batch = RecordBatch::try_new(
975 Arc::new(Schema::new(vec![Field::new(
976 "ts_init",
977 DataType::Int64,
978 false,
979 )])),
980 vec![Arc::new(Int64Array::from(vec![1]))],
981 )
982 .unwrap();
983
984 catalog
985 .write_parquet_file_checked(
986 "quotes/TEST",
987 UnixNanos::from(1),
988 UnixNanos::from(1),
989 &[batch],
990 true,
991 "Promoted file",
992 None,
993 Some("racing-replay"),
994 )
995 .unwrap();
996
997 assert_eq!(object_store.create_calls.load(Ordering::Relaxed), 1);
998 }
999}