Skip to main content

nautilus_persistence/backend/parquet/
delete.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//! Range-based deletion and split operations for the parquet catalog.
17
18#![expect(
19    clippy::missing_errors_doc,
20    reason = "delete operations forward catalog/storage errors and operate on validated batches"
21)]
22
23use ahash::AHashSet;
24use nautilus_core::UnixNanos;
25use nautilus_model::data::{
26    Bar, CustomData, Data, FundingRateUpdate, HasTsInit, IndexPriceUpdate, InstrumentStatus,
27    MarkPriceUpdate, NautilusDataType, OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick,
28    TradeTick, close::InstrumentClose,
29};
30use nautilus_serialization::arrow::{DecodeTypedFromRecordBatch, EncodeToRecordBatch};
31
32use crate::{
33    backend::parquet::{
34        catalog::ParquetDataCatalog,
35        paths::{make_object_store_path, timestamps_to_filename},
36    },
37    catalog::types::{
38        CatalogDataType, HasCatalogDataType, data_type_from_data_path_prefix,
39        parquet_data_path_prefix,
40    },
41    common::custom::group_custom_data_by_type,
42};
43
44/// Kind of deletion operation to execute.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum DeleteOperationKind {
47    /// Remove the files entirely.
48    Remove,
49    /// Rewrite the data preserved before the deleted range, then remove the files.
50    SplitBefore,
51    /// Rewrite the data preserved after the deleted range, then remove the files.
52    SplitAfter,
53}
54
55/// Information about a deletion operation to be executed.
56#[derive(Debug, Clone)]
57pub struct DeleteOperation {
58    /// Kind of deletion operation.
59    pub kind: DeleteOperationKind,
60    /// List of files involved in this operation.
61    pub files: Vec<String>,
62    /// Start timestamp for data query (used for split operations).
63    pub query_start: u64,
64    /// End timestamp for data query (used for split operations).
65    pub query_end: u64,
66    /// Start timestamp for new file naming (used for split operations).
67    pub file_start_ns: u64,
68    /// End timestamp for new file naming (used for split operations).
69    pub file_end_ns: u64,
70}
71
72impl ParquetDataCatalog {
73    /// Deletes custom data within a specified time range.
74    ///
75    /// This method provides deletion for custom data types that don't have compile-time
76    /// type information. It uses dynamic querying and writing methods.
77    ///
78    /// # Parameters
79    ///
80    /// - `type_name`: The custom data type name (without "custom/" prefix).
81    /// - `identifier`: Optional instrument ID to delete data for.
82    /// - `start`: Optional start timestamp for the deletion range.
83    /// - `end`: Optional end timestamp for the deletion range.
84    ///
85    /// # Returns
86    ///
87    /// Returns `Ok(())` on success, or an error if deletion fails.
88    fn delete_custom_data_range(
89        &mut self,
90        type_name: &str,
91        identifier: Option<&str>,
92        start: Option<UnixNanos>,
93        end: Option<UnixNanos>,
94    ) -> anyhow::Result<()> {
95        let data_type = NautilusDataType::Custom {
96            type_name: type_name.to_string(),
97        };
98        let path_prefix = parquet_data_path_prefix(&data_type);
99
100        // Get intervals for the custom data type
101        let intervals = self.get_intervals(&CatalogDataType::Data(data_type), identifier)?;
102
103        if intervals.is_empty() {
104            return Ok(()); // No files to process
105        }
106
107        // Prepare all operations for execution
108        let operations_to_execute = self.prepare_delete_operations(
109            path_prefix.as_ref(),
110            identifier,
111            &intervals,
112            start,
113            end,
114        )?;
115
116        if operations_to_execute.is_empty() {
117            return Ok(()); // No operations to execute
118        }
119
120        // Execute all operations
121        let mut files_to_remove = AHashSet::<String>::new();
122
123        for operation in operations_to_execute {
124            // Reset the session before each operation
125            self.clear_session_tables();
126
127            match operation.kind {
128                DeleteOperationKind::SplitBefore | DeleteOperationKind::SplitAfter => {
129                    // Query the custom data preserved by the split and write it
130                    let instrument_ids = identifier.map(|id| vec![id.to_string()]);
131                    let preserved_data = self.query_custom_data_dynamic(
132                        type_name,
133                        instrument_ids.as_deref(),
134                        Some(UnixNanos::from(operation.query_start)),
135                        Some(UnixNanos::from(operation.query_end)),
136                        None,
137                        Some(operation.files.clone()),
138                        false,
139                    )?;
140
141                    if !preserved_data.is_empty() {
142                        let custom_items: Vec<CustomData> = preserved_data
143                            .into_iter()
144                            .filter_map(|data| match data {
145                                Data::Custom(c) => Some(c),
146                                _ => None,
147                            })
148                            .collect();
149
150                        let start_ts = UnixNanos::from(operation.file_start_ns);
151                        let end_ts = UnixNanos::from(operation.file_end_ns);
152
153                        for items in group_custom_data_by_type(custom_items.iter()) {
154                            self.write_custom_data_refs_batch(
155                                &items,
156                                Some(start_ts),
157                                Some(end_ts),
158                                Some(true),
159                            )?;
160                        }
161                    }
162                }
163                DeleteOperationKind::Remove => {}
164            }
165
166            // Mark files for removal (applies to all operation types)
167            for file in operation.files {
168                files_to_remove.insert(file);
169            }
170        }
171
172        // Remove all files that were processed
173        for file in files_to_remove {
174            if let Err(e) = self.delete_file(&file) {
175                log::warn!("Failed to delete file {file}: {e}");
176            }
177        }
178
179        Ok(())
180    }
181
182    /// Deletes data within a specified time range for a specific data type and identifier.
183    ///
184    /// This method identifies all parquet files that intersect with the specified time range
185    /// and handles them appropriately:
186    /// - Files completely within the range are deleted
187    /// - Files partially overlapping the range are split to preserve data outside the range
188    /// - The original intersecting files are removed after processing
189    ///
190    /// # Parameters
191    ///
192    /// - `data_type`: The data type to delete from.
193    /// - `identifier`: Optional identifier to delete data for. Can be an `instrument_id` (e.g., "EUR/USD.SIM") or a `bar_type` (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL"). If None, deletes data across all identifiers.
194    /// - `start`: Optional start timestamp for the deletion range. If None, deletes from the beginning.
195    /// - `end`: Optional end timestamp for the deletion range. If None, deletes to the end.
196    ///
197    /// # Returns
198    ///
199    /// Returns `Ok(())` on success, or an error if deletion fails.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if:
204    /// - The directory path cannot be constructed.
205    /// - File operations fail.
206    /// - Data querying or writing fails.
207    ///
208    /// # Notes
209    ///
210    /// - This operation permanently removes data and cannot be undone.
211    /// - Files that partially overlap the deletion range are split to preserve data outside the range.
212    /// - The method ensures data integrity by using atomic operations where possible.
213    /// - Empty directories are not automatically removed after deletion.
214    ///
215    /// # Examples
216    ///
217    /// ```rust,no_run
218    /// use nautilus_core::UnixNanos;
219    /// use nautilus_model::data::NautilusDataType;
220    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
221    ///
222    /// let mut catalog = ParquetDataCatalog::new(
223    ///     std::path::Path::new("/tmp/nautilus_data"),
224    ///     None,
225    ///     None,
226    ///     None,
227    ///     None,
228    /// );
229    ///
230    /// // Delete all quote data for a specific instrument
231    /// catalog.delete_data_range(&NautilusDataType::QuoteTick, Some("BTCUSD"), None, None)?;
232    ///
233    /// // Delete trade data within a specific time range
234    /// catalog.delete_data_range(
235    ///     &NautilusDataType::TradeTick,
236    ///     None,
237    ///     Some(UnixNanos::from(1609459200000000000)),
238    ///     Some(UnixNanos::from(1609545600000000000)),
239    /// )?;
240    /// # Ok::<(), anyhow::Error>(())
241    /// ```
242    pub fn delete_data_range(
243        &mut self,
244        data_type: &NautilusDataType,
245        identifier: Option<&str>,
246        start: Option<UnixNanos>,
247        end: Option<UnixNanos>,
248    ) -> anyhow::Result<()> {
249        match data_type {
250            NautilusDataType::QuoteTick => {
251                self.delete_data_range_generic::<QuoteTick>(identifier, start, end)
252            }
253            NautilusDataType::TradeTick => {
254                self.delete_data_range_generic::<TradeTick>(identifier, start, end)
255            }
256            NautilusDataType::Bar => self.delete_data_range_generic::<Bar>(identifier, start, end),
257            NautilusDataType::OrderBookDelta => {
258                self.delete_data_range_generic::<OrderBookDelta>(identifier, start, end)
259            }
260            NautilusDataType::OrderBookDepth => {
261                self.delete_data_range_generic::<OrderBookDepth>(identifier, start, end)
262            }
263            NautilusDataType::MarkPriceUpdate => {
264                self.delete_data_range_generic::<MarkPriceUpdate>(identifier, start, end)
265            }
266            NautilusDataType::IndexPriceUpdate => {
267                self.delete_data_range_generic::<IndexPriceUpdate>(identifier, start, end)
268            }
269            NautilusDataType::InstrumentClose => {
270                self.delete_data_range_generic::<InstrumentClose>(identifier, start, end)
271            }
272            NautilusDataType::FundingRateUpdate => {
273                self.delete_data_range_generic::<FundingRateUpdate>(identifier, start, end)
274            }
275            NautilusDataType::OptionGreeks => {
276                self.delete_data_range_generic::<OptionGreeks>(identifier, start, end)
277            }
278            NautilusDataType::InstrumentStatus => {
279                self.delete_data_range_generic::<InstrumentStatus>(identifier, start, end)
280            }
281            NautilusDataType::Custom { type_name } => {
282                self.delete_custom_data_range(type_name, identifier, start, end)
283            }
284            other @ NautilusDataType::Instrument => {
285                anyhow::bail!("Unsupported data type: {other}")
286            }
287            #[cfg(feature = "defi")]
288            other @ NautilusDataType::Defi => {
289                anyhow::bail!("Unsupported data type: {other}")
290            }
291        }
292    }
293
294    /// Deletes data within a specified time range across the entire catalog.
295    ///
296    /// This method identifies all leaf directories in the catalog that contain parquet files
297    /// and deletes data within the specified time range from each directory. A leaf directory
298    /// is one that contains files but no subdirectories. This is a convenience method that
299    /// effectively calls `delete_data_range` for all data types and instrument IDs in the catalog.
300    ///
301    /// # Parameters
302    ///
303    /// - `start`: Optional start timestamp for the deletion range. If None, deletes from the beginning.
304    /// - `end`: Optional end timestamp for the deletion range. If None, deletes to the end.
305    ///
306    /// # Returns
307    ///
308    /// Returns `Ok(())` on success, or an error if deletion fails.
309    ///
310    /// # Errors
311    ///
312    /// Returns an error if:
313    /// - Directory traversal fails.
314    /// - Data class extraction from paths fails.
315    /// - Individual delete operations fail.
316    ///
317    /// # Notes
318    ///
319    /// - This operation permanently removes data and cannot be undone.
320    /// - The deletion process handles file intersections intelligently by splitting files
321    ///   when they partially overlap with the deletion range.
322    /// - Files completely within the deletion range are removed entirely.
323    /// - Files partially overlapping the deletion range are split to preserve data outside the range.
324    /// - This method is useful for bulk data cleanup operations across the entire catalog.
325    /// - Empty directories are not automatically removed after deletion.
326    ///
327    /// # Examples
328    ///
329    /// ```rust,no_run
330    /// use nautilus_core::UnixNanos;
331    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
332    ///
333    /// let mut catalog = ParquetDataCatalog::new(
334    ///     std::path::Path::new("/tmp/nautilus_data"),
335    ///     None,
336    ///     None,
337    ///     None,
338    ///     None,
339    /// );
340    ///
341    /// // Delete all data before a specific date across entire catalog
342    /// catalog.delete_catalog_range(None, Some(UnixNanos::from(1609459200000000000)))?;
343    ///
344    /// // Delete all data within a specific range across entire catalog
345    /// catalog.delete_catalog_range(
346    ///     Some(UnixNanos::from(1609459200000000000)),
347    ///     Some(UnixNanos::from(1609545600000000000)),
348    /// )?;
349    ///
350    /// // Delete all data after a specific date across entire catalog
351    /// catalog.delete_catalog_range(Some(UnixNanos::from(1609459200000000000)), None)?;
352    /// # Ok::<(), anyhow::Error>(())
353    /// ```
354    pub fn delete_catalog_range(
355        &mut self,
356        start: Option<UnixNanos>,
357        end: Option<UnixNanos>,
358    ) -> anyhow::Result<()> {
359        let leaf_directories = self.find_leaf_data_directories()?;
360
361        for directory in leaf_directories {
362            if let Ok((Some(data_cls), identifier)) =
363                self.extract_data_cls_and_identifier_from_path(&directory)
364            {
365                let Ok(data_type) = data_type_from_data_path_prefix(&data_cls) else {
366                    log::warn!("Skipping directory {directory}: unknown data class {data_cls}");
367                    continue;
368                };
369
370                if let Err(e) =
371                    self.delete_data_range(&data_type, identifier.as_deref(), start, end)
372                {
373                    log::warn!("Failed to delete data in directory {directory}: {e}");
374                    // Continue with other directories instead of failing completely
375                }
376            }
377        }
378
379        Ok(())
380    }
381
382    /// Generic implementation for deleting data within a specified time range.
383    ///
384    /// This method provides the core deletion logic that works with any data type
385    /// that implements the required traits. It handles file intersection analysis,
386    /// data splitting for partial overlaps, and file cleanup.
387    ///
388    /// # Type Parameters
389    ///
390    /// - `T`: The data type that implements required traits for catalog operations.
391    ///
392    /// # Parameters
393    ///
394    /// - `identifier`: Optional instrument ID to delete data for.
395    /// - `start`: Optional start timestamp for the deletion range.
396    /// - `end`: Optional end timestamp for the deletion range.
397    ///
398    /// # Returns
399    ///
400    /// Returns `Ok(())` on success, or an error if deletion fails.
401    pub fn delete_data_range_generic<T>(
402        &mut self,
403        identifier: Option<&str>,
404        start: Option<UnixNanos>,
405        end: Option<UnixNanos>,
406    ) -> anyhow::Result<()>
407    where
408        T: DecodeTypedFromRecordBatch
409            + HasCatalogDataType
410            + EncodeToRecordBatch
411            + HasTsInit
412            + TryFrom<Data>
413            + Clone,
414    {
415        // Get intervals for cleaner implementation
416        let data_type = T::catalog_data_type();
417        let path_prefix = parquet_data_path_prefix(&data_type);
418        let intervals =
419            self.get_intervals(&CatalogDataType::Data(data_type.clone()), identifier)?;
420
421        if intervals.is_empty() {
422            return Ok(()); // No files to process
423        }
424
425        // Prepare all operations for execution
426        let operations_to_execute = self.prepare_delete_operations(
427            path_prefix.as_ref(),
428            identifier,
429            &intervals,
430            start,
431            end,
432        )?;
433
434        if operations_to_execute.is_empty() {
435            return Ok(()); // No operations to execute
436        }
437
438        // Execute all operations
439        let mut files_to_remove = AHashSet::<String>::new();
440
441        for operation in operations_to_execute {
442            // Reset the session before each operation to ensure fresh data is loaded
443            // This clears any cached table registrations that might interfere with file operations
444            self.clear_session_tables();
445
446            match operation.kind {
447                DeleteOperationKind::SplitBefore | DeleteOperationKind::SplitAfter => {
448                    // Query the data preserved by the split and write it
449                    // Use optimize_file_loading=false for precise file control during split operations
450                    let instrument_ids = identifier.map(|id| vec![id.to_string()]);
451                    let preserved_data = self.query_typed_data::<T>(
452                        instrument_ids,
453                        Some(UnixNanos::from(operation.query_start)),
454                        Some(UnixNanos::from(operation.query_end)),
455                        None,
456                        Some(operation.files.clone()),
457                        false, // optimize_file_loading=false for precise file control
458                    )?;
459
460                    if !preserved_data.is_empty() {
461                        let start_ts = UnixNanos::from(operation.file_start_ns);
462                        let end_ts = UnixNanos::from(operation.file_end_ns);
463                        self.write_to_parquet(
464                            &preserved_data,
465                            Some(start_ts),
466                            Some(end_ts),
467                            Some(true),
468                        )?;
469                    }
470                }
471                DeleteOperationKind::Remove => {}
472            }
473
474            // Mark files for removal (applies to all operation types)
475            for file in operation.files {
476                files_to_remove.insert(file);
477            }
478        }
479
480        // Remove all files that were processed
481        for file in files_to_remove {
482            if let Err(e) = self.delete_file(&file) {
483                log::warn!("Failed to delete file {file}: {e}");
484            }
485        }
486
487        Ok(())
488    }
489
490    /// Prepares all operations for data deletion by identifying files that need to be
491    /// split or removed.
492    ///
493    /// This auxiliary function handles all the preparation logic for deletion:
494    /// 1. Filters intervals by time range
495    /// 2. Identifies files that intersect with the deletion range
496    /// 3. Creates split operations for files that partially overlap
497    /// 4. Generates removal operations for files completely within the range
498    ///
499    /// # Parameters
500    ///
501    /// - `type_name`: The data type directory name for path generation.
502    /// - `identifier`: Optional instrument identifier for path generation.
503    /// - `intervals`: List of (`start_ts`, `end_ts`) tuples representing existing file intervals.
504    /// - `start`: Optional start timestamp for deletion range.
505    /// - `end`: Optional end timestamp for deletion range.
506    ///
507    /// # Returns
508    ///
509    /// Returns a vector of `DeleteOperation` structs ready for execution.
510    pub fn prepare_delete_operations(
511        &self,
512        type_name: &str,
513        identifier: Option<&str>,
514        intervals: &[(u64, u64)],
515        start: Option<UnixNanos>,
516        end: Option<UnixNanos>,
517    ) -> anyhow::Result<Vec<DeleteOperation>> {
518        // Convert start/end to nanoseconds
519        let delete_start_ns = start.map(|s| s.as_u64());
520        let delete_end_ns = end.map(|e| e.as_u64());
521
522        let mut operations = Vec::new();
523
524        // Get directory for file path construction
525        let directory = self.make_path(type_name, identifier)?;
526
527        // Process each interval (which represents an actual file)
528        for &(file_start_ns, file_end_ns) in intervals {
529            // Check if file intersects with deletion range
530            let intersects = delete_start_ns.is_none_or(|start| start <= file_end_ns)
531                && delete_end_ns.is_none_or(|end| file_start_ns <= end);
532
533            if !intersects {
534                continue; // File doesn't intersect with deletion range
535            }
536
537            // Construct file path from interval timestamps
538            let filename = timestamps_to_filename(
539                UnixNanos::from(file_start_ns),
540                UnixNanos::from(file_end_ns),
541            );
542            let file_path = make_object_store_path(&directory, [&filename]);
543
544            // Determine what type of operation is needed
545            let file_completely_within_range = delete_start_ns
546                .is_none_or(|start| start <= file_start_ns)
547                && delete_end_ns.is_none_or(|end| file_end_ns <= end);
548
549            if file_completely_within_range {
550                // File is completely within deletion range - just mark for removal
551                operations.push(DeleteOperation {
552                    kind: DeleteOperationKind::Remove,
553                    files: vec![file_path],
554                    query_start: 0,
555                    query_end: 0,
556                    file_start_ns: 0,
557                    file_end_ns: 0,
558                });
559            } else {
560                // File partially overlaps - need to split
561                if let Some(delete_start) = delete_start_ns
562                    && file_start_ns < delete_start
563                {
564                    // Keep data before deletion range
565                    operations.push(DeleteOperation {
566                        kind: DeleteOperationKind::SplitBefore,
567                        files: vec![file_path.clone()],
568                        query_start: file_start_ns,
569                        query_end: delete_start.saturating_sub(1), // Exclusive end
570                        file_start_ns,
571                        file_end_ns: delete_start.saturating_sub(1),
572                    });
573                }
574
575                if let Some(delete_end) = delete_end_ns
576                    && delete_end < file_end_ns
577                {
578                    // Keep data after deletion range
579                    operations.push(DeleteOperation {
580                        kind: DeleteOperationKind::SplitAfter,
581                        files: vec![file_path.clone()],
582                        query_start: delete_end.saturating_add(1), // Exclusive start
583                        query_end: file_end_ns,
584                        file_start_ns: delete_end.saturating_add(1),
585                        file_end_ns,
586                    });
587                }
588            }
589        }
590
591        Ok(operations)
592    }
593}