1use std::{collections::BTreeMap, sync::Arc};
19
20use ahash::AHashSet;
21use arrow::{
22 array::{ArrayRef, StringArray},
23 compute::cast,
24 datatypes::DataType as ArrowDataType,
25 record_batch::RecordBatch,
26 row::{RowConverter, SortField},
27};
28use nautilus_core::{UnixNanos, string::conversions::to_snake_case};
29use nautilus_model::data::{
30 Bar, CustomData, Data, DataType, FundingRateUpdate, HasTsInit, IndexPriceUpdate,
31 InstrumentStatus, MarkPriceUpdate, NautilusDataType, OptionGreeks, OrderBookDelta,
32 OrderBookDepth, QuoteTick, TradeTick, close::InstrumentClose,
33};
34use nautilus_serialization::arrow::{
35 DecodeTypedFromRecordBatch, EncodeToRecordBatch, KEY_BAR_TYPE, KEY_INSTRUMENT_ID,
36 KEY_PRICE_PRECISION, KEY_SIZE_PRECISION, KEY_TYPE_NAME, is_json_string_field,
37};
38
39use crate::{
40 backend::parquet::{
41 catalog::ParquetDataCatalog,
42 io::read_parquet_from_object_store,
43 paths::{make_object_store_path, parse_filename_timestamps, timestamps_to_filename},
44 },
45 catalog::types::{
46 HasCatalogDataType, data_type_from_data_path_prefix, parquet_data_path_prefix,
47 },
48};
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum DeleteOperationKind {
53 Remove,
55 SplitBefore,
57 SplitAfter,
59}
60
61#[derive(Debug, Clone)]
63pub struct DeleteOperation {
64 pub kind: DeleteOperationKind,
66 pub files: Vec<String>,
68 pub query_start: u64,
70 pub query_end: u64,
72 pub file_start_ns: u64,
74 pub file_end_ns: u64,
76}
77
78impl ParquetDataCatalog {
79 pub fn delete_catalog_range(
140 &mut self,
141 start: Option<UnixNanos>,
142 end: Option<UnixNanos>,
143 ) -> anyhow::Result<()> {
144 let leaf_directories = self.find_leaf_data_directories()?;
145
146 for directory in leaf_directories {
147 let Ok((Some(data_cls), identifier)) =
148 self.extract_data_cls_and_identifier_from_path(&directory)
149 else {
150 continue;
151 };
152
153 let Ok(data_type) = data_type_from_data_path_prefix(&data_cls) else {
154 log::warn!("Skipping directory {directory}: unknown data class {data_cls}");
155 continue;
156 };
157
158 self.delete_data_range(&data_type, identifier.as_deref(), start, end)?;
159 }
160
161 Ok(())
162 }
163
164 pub fn delete_data_range(
226 &mut self,
227 data_type: &NautilusDataType,
228 identifier: Option<&str>,
229 start: Option<UnixNanos>,
230 end: Option<UnixNanos>,
231 ) -> anyhow::Result<()> {
232 match data_type {
233 NautilusDataType::QuoteTick => {
234 self.delete_data_range_generic::<QuoteTick>(identifier, start, end)
235 }
236 NautilusDataType::TradeTick => {
237 self.delete_data_range_generic::<TradeTick>(identifier, start, end)
238 }
239 NautilusDataType::Bar => self.delete_data_range_generic::<Bar>(identifier, start, end),
240 NautilusDataType::OrderBookDelta => {
241 self.delete_data_range_generic::<OrderBookDelta>(identifier, start, end)
242 }
243 NautilusDataType::OrderBookDepth => {
244 self.delete_data_range_generic::<OrderBookDepth>(identifier, start, end)
245 }
246 NautilusDataType::MarkPriceUpdate => {
247 self.delete_data_range_generic::<MarkPriceUpdate>(identifier, start, end)
248 }
249 NautilusDataType::IndexPriceUpdate => {
250 self.delete_data_range_generic::<IndexPriceUpdate>(identifier, start, end)
251 }
252 NautilusDataType::InstrumentClose => {
253 self.delete_data_range_generic::<InstrumentClose>(identifier, start, end)
254 }
255 NautilusDataType::FundingRateUpdate => {
256 self.delete_data_range_generic::<FundingRateUpdate>(identifier, start, end)
257 }
258 NautilusDataType::OptionGreeks => {
259 self.delete_data_range_generic::<OptionGreeks>(identifier, start, end)
260 }
261 NautilusDataType::InstrumentStatus => {
262 self.delete_data_range_generic::<InstrumentStatus>(identifier, start, end)
263 }
264 NautilusDataType::Custom { type_name } => {
265 self.delete_custom_data_range(type_name, identifier, start, end)
266 }
267 other @ NautilusDataType::Instrument => {
268 anyhow::bail!("Unsupported data type: {other}")
269 }
270 #[cfg(feature = "defi")]
271 other @ NautilusDataType::Defi => {
272 anyhow::bail!("Unsupported data type: {other}")
273 }
274 #[cfg(not(feature = "defi"))]
275 #[allow(
276 unreachable_patterns,
277 reason = "DeFi variants can exist without this crate's defi feature"
278 )]
279 other => anyhow::bail!("Unsupported data type: {other}"),
280 }
281 }
282
283 pub fn delete_data_range_generic<T>(
309 &mut self,
310 identifier: Option<&str>,
311 start: Option<UnixNanos>,
312 end: Option<UnixNanos>,
313 ) -> anyhow::Result<()>
314 where
315 T: DecodeTypedFromRecordBatch
316 + HasCatalogDataType
317 + EncodeToRecordBatch
318 + HasTsInit
319 + TryFrom<Data>
320 + Clone,
321 {
322 let data_type = T::catalog_data_type();
324 let files = self.files_for_delete(&data_type, identifier)?;
325
326 if files.is_empty() {
327 return Ok(());
328 }
329
330 let operations_to_execute = operations_for_files(&files, start, end)?;
331
332 if operations_to_execute.is_empty() {
333 return Ok(()); }
335
336 let file_paths: AHashSet<&str> = files.iter().map(|(path, ..)| path.as_str()).collect();
337 let (targets, files_to_remove) = group_operations_by_target(operations_to_execute);
338 let type_name = to_snake_case(std::any::type_name::<T>());
339 let mut writes = Vec::new();
340
341 for (target, sources) in &targets {
346 let rows = self.query_split_rows::<T>(identifier, target, sources)?;
347
348 if rows.is_empty() {
349 continue;
350 }
351
352 Self::check_chunk_identity(&rows, &type_name)?;
353 if file_paths.contains(target.0.as_str()) {
354 let batches = self.data_to_record_batches(&rows)?;
355 self.ensure_split_target_holds_rows(&target.0, &batches)?;
356 continue;
357 }
358
359 writes.push((target, sources));
360 }
361
362 for (target, sources) in writes {
363 let rows = self.query_split_rows::<T>(identifier, target, sources)?;
364 self.write_to_parquet(
365 &rows,
366 Some(UnixNanos::from(target.1)),
367 Some(UnixNanos::from(target.2)),
368 Some(true),
369 )?;
370 }
371
372 for file in files_to_remove {
373 self.delete_file(&file)?;
374 }
375
376 Ok(())
377 }
378
379 fn delete_custom_data_range(
395 &mut self,
396 type_name: &str,
397 identifier: Option<&str>,
398 start: Option<UnixNanos>,
399 end: Option<UnixNanos>,
400 ) -> anyhow::Result<()> {
401 let data_type = NautilusDataType::Custom {
402 type_name: type_name.to_string(),
403 };
404
405 let files = self.files_for_delete(&data_type, identifier)?;
406
407 if files.is_empty() {
408 return Ok(());
409 }
410
411 let operations_to_execute = operations_for_files(&files, start, end)?;
412
413 if operations_to_execute.is_empty() {
414 return Ok(()); }
416
417 let file_paths: AHashSet<&str> = files.iter().map(|(path, ..)| path.as_str()).collect();
418 let (targets, files_to_remove) = group_operations_by_target(operations_to_execute);
419 let mut writes = Vec::new();
420
421 for (target, sources) in &targets {
427 let rows = self.query_custom_split_rows(type_name, identifier, target, sources)?;
428
429 if rows.is_empty() {
430 continue;
431 }
432
433 let target_writes = self.prepare_custom_data_groups(
434 &rows,
435 Some(UnixNanos::from(target.1)),
436 Some(UnixNanos::from(target.2)),
437 Some(true),
438 )?;
439
440 if file_paths.contains(target.0.as_str()) {
441 let batches: Vec<RecordBatch> = target_writes
442 .into_iter()
443 .flat_map(|write| write.batches)
444 .collect();
445 self.ensure_split_target_holds_rows(&target.0, &batches)?;
446 continue;
447 }
448
449 writes.push((target, sources));
450 }
451
452 for (target, sources) in writes {
453 let rows = self.query_custom_split_rows(type_name, identifier, target, sources)?;
454 self.write_custom_data_groups(
455 &rows,
456 Some(UnixNanos::from(target.1)),
457 Some(UnixNanos::from(target.2)),
458 Some(true),
459 )?;
460 }
461
462 for file in files_to_remove {
463 self.delete_file(&file)?;
464 }
465
466 Ok(())
467 }
468
469 fn query_split_rows<T>(
471 &mut self,
472 identifier: Option<&str>,
473 target: &SplitTarget,
474 sources: &[String],
475 ) -> anyhow::Result<Vec<T>>
476 where
477 T: DecodeTypedFromRecordBatch + HasCatalogDataType + HasTsInit,
478 {
479 self.clear_session_tables();
481 let instrument_ids = identifier.map(|id| vec![id.to_string()]);
483 let mut rows = self.query::<T>(
484 instrument_ids,
485 Some(UnixNanos::from(target.1)),
486 Some(UnixNanos::from(target.2)),
487 None,
488 Some(sources.to_vec()),
489 false,
490 )?;
491 rows.sort_by_key(HasTsInit::ts_init);
492 Ok(rows)
493 }
494
495 fn query_custom_split_rows(
498 &mut self,
499 type_name: &str,
500 identifier: Option<&str>,
501 target: &SplitTarget,
502 sources: &[String],
503 ) -> anyhow::Result<Vec<CustomData>> {
504 self.clear_session_tables();
505 let instrument_ids = identifier.map(|id| vec![id.to_string()]);
506 let mut rows: Vec<CustomData> = self
507 .query_custom_data_dynamic(
508 type_name,
509 instrument_ids.as_deref(),
510 Some(UnixNanos::from(target.1)),
511 Some(UnixNanos::from(target.2)),
512 None,
513 Some(sources.to_vec()),
514 false,
515 )?
516 .into_iter()
517 .filter_map(|data| match data {
518 Data::Custom(c) => Some(c),
519 _ => None,
520 })
521 .collect();
522 rows.sort_by_key(|custom| custom.data.ts_init());
523 Ok(rows)
524 }
525
526 fn ensure_split_target_holds_rows(
531 &self,
532 target: &str,
533 rows: &[RecordBatch],
534 ) -> anyhow::Result<()> {
535 anyhow::ensure!(
536 self.target_holds_rows(target, rows)?,
537 "Cannot preserve the rows outside the deleted range: their target file {target} \
538 already exists without them"
539 );
540 Ok(())
541 }
542
543 pub(crate) fn target_holds_rows(
544 &self,
545 target: &str,
546 rows: &[RecordBatch],
547 ) -> anyhow::Result<bool> {
548 let object_path = self.to_object_path_parsed(target)?;
549
550 let (existing, schema) = self.execute_async(|| async {
551 read_parquet_from_object_store(self.object_store.clone(), &object_path).await
552 })?;
553
554 for batch in rows {
555 for key in [
556 KEY_INSTRUMENT_ID,
557 KEY_BAR_TYPE,
558 KEY_PRICE_PRECISION,
559 KEY_SIZE_PRECISION,
560 KEY_TYPE_NAME,
561 ] {
562 if schema.metadata().get(key) != batch.schema().metadata().get(key) {
563 return Ok(false);
564 }
565 }
566 }
567
568 batches_hold_rows(&existing, rows)
569 }
570
571 fn files_for_delete(
572 &self,
573 data_type: &NautilusDataType,
574 identifier: Option<&str>,
575 ) -> anyhow::Result<Vec<(String, u64, u64)>> {
576 let directory = if let NautilusDataType::Custom { type_name } = data_type {
577 self.make_path_custom_data(type_name, identifier)?
578 } else {
579 let prefix = parquet_data_path_prefix(data_type);
580 self.make_path(prefix.as_ref(), identifier)?
581 };
582
583 let files = self.list_parquet_files(&directory)?;
584
585 Ok(files
586 .into_iter()
587 .filter_map(|path| {
588 parse_filename_timestamps(&path).map(|(start, end)| (path, start, end))
589 })
590 .collect())
591 }
592
593 pub fn prepare_delete_operations(
621 &self,
622 type_name: &str,
623 identifier: Option<&str>,
624 intervals: &[(u64, u64)],
625 start: Option<UnixNanos>,
626 end: Option<UnixNanos>,
627 ) -> anyhow::Result<Vec<DeleteOperation>> {
628 let directory = self.make_path(type_name, identifier)?;
629
630 let files = intervals
631 .iter()
632 .map(|&(file_start_ns, file_end_ns)| {
633 let filename = timestamps_to_filename(
634 UnixNanos::from(file_start_ns),
635 UnixNanos::from(file_end_ns),
636 );
637 let file_path = make_object_store_path(&directory, [&filename]);
638 (file_path, file_start_ns, file_end_ns)
639 })
640 .collect::<Vec<_>>();
641
642 operations_for_files(&files, start, end)
643 }
644}
645
646fn operations_for_files(
647 files: &[(String, u64, u64)],
648 start: Option<UnixNanos>,
649 end: Option<UnixNanos>,
650) -> anyhow::Result<Vec<DeleteOperation>> {
651 if let (Some(start), Some(end)) = (start, end) {
652 anyhow::ensure!(start <= end, "deletion start must not exceed end");
653 }
654
655 let delete_start_ns = start.map(|s| s.as_u64());
656 let delete_end_ns = end.map(|e| e.as_u64());
657 let mut operations = Vec::new();
658
659 for (file_path, file_start_ns, file_end_ns) in files {
660 let intersects = delete_start_ns.is_none_or(|start| start <= *file_end_ns)
661 && delete_end_ns.is_none_or(|end| *file_start_ns <= end);
662
663 if !intersects {
664 continue;
665 }
666
667 let file_completely_within_range = delete_start_ns
668 .is_none_or(|start| start <= *file_start_ns)
669 && delete_end_ns.is_none_or(|end| *file_end_ns <= end);
670
671 if file_completely_within_range {
672 operations.push(DeleteOperation {
673 kind: DeleteOperationKind::Remove,
674 files: vec![file_path.clone()],
675 query_start: 0,
676 query_end: 0,
677 file_start_ns: 0,
678 file_end_ns: 0,
679 });
680
681 continue;
682 }
683
684 if let Some(delete_start) = delete_start_ns
685 && *file_start_ns < delete_start
686 {
687 operations.push(DeleteOperation {
688 kind: DeleteOperationKind::SplitBefore,
689 files: vec![file_path.clone()],
690 query_start: *file_start_ns,
691 query_end: delete_start.saturating_sub(1),
692 file_start_ns: *file_start_ns,
693 file_end_ns: delete_start.saturating_sub(1),
694 });
695 }
696
697 if let Some(delete_end) = delete_end_ns
698 && delete_end < *file_end_ns
699 {
700 operations.push(DeleteOperation {
701 kind: DeleteOperationKind::SplitAfter,
702 files: vec![file_path.clone()],
703 query_start: delete_end.saturating_add(1),
704 query_end: *file_end_ns,
705 file_start_ns: delete_end.saturating_add(1),
706 file_end_ns: *file_end_ns,
707 });
708 }
709 }
710
711 Ok(operations)
712}
713
714type SplitTarget = (String, u64, u64);
716
717fn group_operations_by_target(
720 operations: Vec<DeleteOperation>,
721) -> (BTreeMap<SplitTarget, Vec<String>>, AHashSet<String>) {
722 let mut targets = BTreeMap::<SplitTarget, Vec<String>>::new();
723 let mut files_to_remove = AHashSet::new();
724
725 for operation in operations {
726 if operation.kind != DeleteOperationKind::Remove {
727 targets
728 .entry(split_target(&operation))
729 .or_default()
730 .extend(operation.files.iter().cloned());
731 }
732 files_to_remove.extend(operation.files);
733 }
734
735 (targets, files_to_remove)
736}
737
738fn split_target(operation: &DeleteOperation) -> SplitTarget {
739 let source = &operation.files[0];
740 let filename = timestamps_to_filename(
741 UnixNanos::from(operation.file_start_ns),
742 UnixNanos::from(operation.file_end_ns),
743 );
744 let target = match source.rsplit_once('/') {
745 Some((directory, _)) => make_object_store_path(directory, [&filename]),
746 None => filename,
747 };
748
749 (target, operation.file_start_ns, operation.file_end_ns)
750}
751
752fn batches_hold_rows(existing: &[RecordBatch], rows: &[RecordBatch]) -> anyhow::Result<bool> {
757 let Some(first) = rows.first() else {
758 return Ok(true);
759 };
760 let Some(existing_first) = existing.first() else {
761 return Ok(rows.iter().all(|batch| batch.num_rows() == 0));
762 };
763
764 let existing_schema = existing_first.schema();
765 let names: Vec<String> = first
766 .schema()
767 .fields()
768 .iter()
769 .map(|field| field.name().clone())
770 .filter(|name| existing_schema.field_with_name(name).is_ok())
771 .collect();
772 let fields = names
773 .iter()
774 .map(|name| {
775 let data_type = first.schema().field_with_name(name)?.data_type().clone();
776 Ok(SortField::new(data_type))
777 })
778 .collect::<anyhow::Result<Vec<_>>>()?;
779 let converter = RowConverter::new(fields)?;
780 let json_names: AHashSet<&str> = names
781 .iter()
782 .filter(|name| {
783 *name == "data_type"
784 || [first.schema(), existing_schema.clone()]
785 .iter()
786 .any(|schema| schema.field_with_name(name).is_ok_and(is_json_string_field))
787 })
788 .map(String::as_str)
789 .collect();
790 let columns = |batch: &RecordBatch| {
791 names
792 .iter()
793 .map(|name| {
794 let column = batch
795 .column_by_name(name)
796 .ok_or_else(|| anyhow::anyhow!("Missing column {name} in Parquet batch"))?;
797
798 if json_names.contains(name.as_str()) {
799 canonical_json_column(column, name == "data_type")
800 } else {
801 Ok(column.clone())
802 }
803 })
804 .collect::<anyhow::Result<Vec<_>>>()
805 };
806
807 let mut existing_rows = AHashSet::new();
808
809 for batch in existing {
810 for row in &converter.convert_columns(&columns(batch)?)? {
811 existing_rows.insert(row.as_ref().to_vec());
812 }
813 }
814
815 for batch in rows {
816 for row in &converter.convert_columns(&columns(batch)?)? {
817 if !existing_rows.contains(row.as_ref()) {
818 return Ok(false);
819 }
820 }
821 }
822
823 Ok(true)
824}
825
826fn canonical_json_column(column: &ArrayRef, is_data_type: bool) -> anyhow::Result<ArrayRef> {
828 let column = cast(column, &ArrowDataType::Utf8)?;
829 let values = column
830 .as_any()
831 .downcast_ref::<StringArray>()
832 .ok_or_else(|| anyhow::anyhow!("JSON column is not a string array"))?;
833 let canonical = values
834 .iter()
835 .map(|value| {
836 value
837 .map(|value| {
838 let value: serde_json::Value = if is_data_type {
839 let data_type = DataType::from_persistence_json(value)?;
840 let json = data_type.to_persistence_json()?;
841 serde_json::from_str(&json)?
842 } else {
843 serde_json::from_str(value)?
844 };
845
846 Ok::<_, anyhow::Error>(canonical_json_value(value).to_string())
847 })
848 .transpose()
849 })
850 .collect::<Result<StringArray, _>>()?;
851 Ok(Arc::new(canonical))
852}
853
854fn canonical_json_value(value: serde_json::Value) -> serde_json::Value {
855 match value {
856 serde_json::Value::Object(map) => {
857 let mut entries: Vec<_> = map.into_iter().collect();
858 entries.sort_by(|a, b| a.0.cmp(&b.0));
859 serde_json::Value::Object(
860 entries
861 .into_iter()
862 .map(|(key, value)| (key, canonical_json_value(value)))
863 .collect(),
864 )
865 }
866 serde_json::Value::Array(values) => {
867 serde_json::Value::Array(values.into_iter().map(canonical_json_value).collect())
868 }
869 value => value,
870 }
871}