Skip to main content

nautilus_persistence/backend/
catalog_operations.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//! Catalog operations for data consolidation and reset functionality.
17//!
18//! This module contains the consolidation and reset operations for the `ParquetDataCatalog`.
19//! These operations are separated into their own module for better organization and maintainability.
20
21use ahash::{AHashMap, AHashSet};
22use futures::StreamExt;
23use indexmap::IndexSet;
24use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_DAY};
25use nautilus_model::data::{
26    Bar, CustomData, Data, HasTsInit, IndexPriceUpdate, MarkPriceUpdate, OrderBookDelta,
27    OrderBookDepth10, QuoteTick, TradeTick, close::InstrumentClose,
28};
29use nautilus_serialization::arrow::{DecodeDataFromRecordBatch, EncodeToRecordBatch};
30use object_store::{ObjectStoreExt, path::Path as ObjectPath};
31
32use crate::{
33    backend::catalog::{
34        CatalogPathPrefix, ParquetDataCatalog, are_intervals_contiguous, are_intervals_disjoint,
35        extract_path_components, make_object_store_path, parse_filename_timestamps,
36        timestamps_to_filename,
37    },
38    parquet::{
39        combine_parquet_files_from_object_store, min_max_from_parquet_metadata_object_store,
40    },
41};
42
43/// Information about a consolidation query to be executed.
44///
45/// This struct encapsulates all the information needed to execute a single consolidation
46/// operation, including the data range to query and file naming strategy.
47///
48/// # Fields
49///
50/// - `query_start`: Start timestamp for the data query range (inclusive, in nanoseconds).
51/// - `query_end`: End timestamp for the data query range (inclusive, in nanoseconds).
52/// - `use_period_boundaries`: If true, uses period boundaries for file naming; if false, uses actual data timestamps.
53///
54/// # Usage
55///
56/// This struct is used internally by the consolidation system to plan and execute
57/// data consolidation operations. It allows the system to:
58/// - Separate query planning from execution.
59/// - Handle complex scenarios like data splitting.
60/// - Optimize file naming strategies.
61/// - Batch multiple operations efficiently.
62/// - Maintain file contiguity across periods.
63///
64/// # Examples
65///
66/// ```rust,no_run
67/// use nautilus_persistence::backend::catalog_operations::ConsolidationQuery;
68///
69/// // Regular consolidation query
70/// let query = ConsolidationQuery {
71///     query_start: 1609459200000000000,
72///     query_end: 1609545600000000000,
73///     use_period_boundaries: true,
74/// };
75///
76/// // Split operation to preserve data
77/// let split_query = ConsolidationQuery {
78///     query_start: 1609459200000000000,
79///     query_end: 1609462800000000000,
80///     use_period_boundaries: false,
81/// };
82/// ```
83#[derive(Debug, Clone)]
84pub struct ConsolidationQuery {
85    /// Start timestamp for the query range (inclusive, in nanoseconds)
86    pub query_start: u64,
87    /// End timestamp for the query range (inclusive, in nanoseconds)
88    pub query_end: u64,
89    /// Whether to use period boundaries for file naming (true) or actual data timestamps (false)
90    pub use_period_boundaries: bool,
91}
92
93/// Information about a deletion operation to be executed.
94///
95/// This struct encapsulates all the information needed to execute a single deletion
96/// operation, including the type of operation and file handling details.
97#[derive(Debug, Clone)]
98pub struct DeleteOperation {
99    /// Type of deletion operation ("remove", "`split_before`", "`split_after`").
100    pub operation_type: String,
101    /// List of files involved in this operation.
102    pub files: Vec<String>,
103    /// Start timestamp for data query (used for split operations).
104    pub query_start: u64,
105    /// End timestamp for data query (used for split operations).
106    pub query_end: u64,
107    /// Start timestamp for new file naming (used for split operations).
108    pub file_start_ns: u64,
109    /// End timestamp for new file naming (used for split operations).
110    pub file_end_ns: u64,
111}
112
113impl ParquetDataCatalog {
114    /// Consolidates all data files in the catalog.
115    ///
116    /// This method identifies all leaf directories in the catalog that contain parquet files
117    /// and consolidates them. A leaf directory is one that contains files but no subdirectories.
118    /// This is a convenience method that effectively calls `consolidate_data` for all data types
119    /// and instrument IDs in the catalog.
120    ///
121    /// # Parameters
122    ///
123    /// - `start`: Optional start timestamp for the consolidation range. Only files with timestamps
124    ///   greater than or equal to this value will be consolidated. If None, all files
125    ///   from the beginning of time will be considered.
126    /// - `end`: Optional end timestamp for the consolidation range. Only files with timestamps
127    ///   less than or equal to this value will be consolidated. If None, all files
128    ///   up to the end of time will be considered.
129    /// - `ensure_contiguous_files`: Whether to validate that consolidated intervals are contiguous (default: true).
130    ///
131    /// # Returns
132    ///
133    /// Returns `Ok(())` on success, or an error if consolidation fails for any directory.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if:
138    /// - Directory listing fails.
139    /// - File consolidation operations fail.
140    /// - Interval validation fails (when `ensure_contiguous_files` is true).
141    ///
142    /// # Examples
143    ///
144    /// ```rust,no_run
145    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
146    /// use nautilus_core::UnixNanos;
147    ///
148    /// let catalog = ParquetDataCatalog::new(/* ... */);
149    ///
150    /// // Consolidate all files in the catalog
151    /// catalog.consolidate_catalog(None, None, None, None)?;
152    ///
153    /// // Consolidate only files within a specific time range
154    /// catalog.consolidate_catalog(
155    ///     Some(UnixNanos::from(1609459200000000000)),
156    ///     Some(UnixNanos::from(1609545600000000000)),
157    ///     Some(true),
158    ///     None
159    /// )?;
160    /// # Ok::<(), anyhow::Error>(())
161    /// ```
162    pub fn consolidate_catalog(
163        &self,
164        start: Option<UnixNanos>,
165        end: Option<UnixNanos>,
166        ensure_contiguous_files: Option<bool>,
167        deduplicate: Option<bool>,
168    ) -> anyhow::Result<()> {
169        let leaf_directories = self.find_leaf_data_directories()?;
170
171        for directory in leaf_directories {
172            self.consolidate_directory(
173                &directory,
174                start,
175                end,
176                ensure_contiguous_files,
177                deduplicate,
178            )?;
179        }
180
181        Ok(())
182    }
183
184    /// Consolidates data files for a specific data type and identifier.
185    ///
186    /// This method consolidates Parquet files within a specific directory (defined by data type
187    /// and optional identifier) by merging multiple files into a single file. This improves
188    /// query performance and can reduce storage overhead.
189    ///
190    /// # Parameters
191    ///
192    /// - `type_name`: The data type directory name (e.g., "quotes", "trades", "bars").
193    /// - `identifier`: Optional identifier to target a specific instrument's data. Can be an `instrument_id` (e.g., "EUR/USD.SIM") or a `bar_type` (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
194    /// - `start`: Optional start timestamp to limit consolidation to files within this range.
195    /// - `end`: Optional end timestamp to limit consolidation to files within this range.
196    /// - `ensure_contiguous_files`: Whether to validate that consolidated intervals are contiguous (default: true).
197    ///
198    /// # Returns
199    ///
200    /// Returns `Ok(())` on success, or an error if consolidation fails.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if:
205    /// - The directory path cannot be constructed.
206    /// - File consolidation operations fail.
207    /// - Interval validation fails (when `ensure_contiguous_files` is true).
208    ///
209    /// # Examples
210    ///
211    /// ```rust,no_run
212    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
213    /// use nautilus_core::UnixNanos;
214    ///
215    /// let catalog = ParquetDataCatalog::new(/* ... */);
216    ///
217    /// // Consolidate all quote files for a specific instrument
218    /// catalog.consolidate_data(
219    ///     "quotes",
220    ///     Some("BTCUSD".to_string()),
221    ///     None,
222    ///     None,
223    ///     None,
224    ///     None
225    /// )?;
226    ///
227    /// // Consolidate trade files within a time range
228    /// catalog.consolidate_data(
229    ///     "trades",
230    ///     None,
231    ///     Some(UnixNanos::from(1609459200000000000)),
232    ///     Some(UnixNanos::from(1609545600000000000)),
233    ///     Some(true),
234    ///     None
235    /// )?;
236    /// # Ok::<(), anyhow::Error>(())
237    /// ```
238    pub fn consolidate_data(
239        &self,
240        type_name: &str,
241        identifier: Option<&str>,
242        start: Option<UnixNanos>,
243        end: Option<UnixNanos>,
244        ensure_contiguous_files: Option<bool>,
245        deduplicate: Option<bool>,
246    ) -> anyhow::Result<()> {
247        let directory = self.make_path(type_name, identifier)?;
248        self.consolidate_directory(&directory, start, end, ensure_contiguous_files, deduplicate)
249    }
250
251    /// Consolidates Parquet files within a specific directory by merging them into a single file.
252    ///
253    /// This internal method performs the actual consolidation work for a single directory.
254    /// It identifies files within the specified time range, validates their intervals,
255    /// and combines them into a single Parquet file with optimized storage.
256    ///
257    /// # Parameters
258    ///
259    /// - `directory`: The directory path containing Parquet files to consolidate.
260    /// - `start`: Optional start timestamp to limit consolidation to files within this range.
261    /// - `end`: Optional end timestamp to limit consolidation to files within this range.
262    /// - `ensure_contiguous_files`: Whether to validate that consolidated intervals are contiguous.
263    ///
264    /// # Returns
265    ///
266    /// Returns `Ok(())` on success, or an error if consolidation fails.
267    ///
268    /// # Behavior
269    ///
270    /// - Skips consolidation if directory contains 1 or fewer files.
271    /// - Filters files by timestamp range if start/end are specified.
272    /// - Sorts intervals by start timestamp before consolidation.
273    /// - Creates a new file spanning the entire time range of input files.
274    /// - Validates interval disjointness after consolidation (if enabled).
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if:
279    /// - Directory listing fails.
280    /// - File combination operations fail.
281    /// - Interval validation fails (when `ensure_contiguous_files` is true).
282    /// - Object store operations fail.
283    fn consolidate_directory(
284        &self,
285        directory: &str,
286        start: Option<UnixNanos>,
287        end: Option<UnixNanos>,
288        ensure_contiguous_files: Option<bool>,
289        deduplicate: Option<bool>,
290    ) -> anyhow::Result<()> {
291        let parquet_files = self.list_parquet_files(directory)?;
292
293        if parquet_files.len() <= 1 {
294            return Ok(());
295        }
296
297        let mut files_to_consolidate = Vec::new();
298        let mut intervals = Vec::new();
299        let start = start.map(|t| t.as_u64());
300        let end = end.map(|t| t.as_u64());
301
302        for file in parquet_files {
303            if let Some(interval) = parse_filename_timestamps(&file) {
304                let (interval_start, interval_end) = interval;
305                let include_file = match (start, end) {
306                    (Some(s), Some(e)) => interval_start >= s && interval_end <= e,
307                    (Some(s), None) => interval_start >= s,
308                    (None, Some(e)) => interval_end <= e,
309                    (None, None) => true,
310                };
311
312                if include_file {
313                    files_to_consolidate.push(file);
314                    intervals.push(interval);
315                }
316            }
317        }
318
319        intervals.sort_by_key(|&(start, _)| start);
320
321        if let (Some(first_interval), Some(last_interval)) = (intervals.first(), intervals.last()) {
322            let file_name = timestamps_to_filename(
323                UnixNanos::from(first_interval.0),
324                UnixNanos::from(last_interval.1),
325            );
326            let path = make_object_store_path(directory, &[&file_name]);
327
328            // Convert string paths to ObjectPath for the function call
329            let object_paths: Vec<ObjectPath> = files_to_consolidate
330                .iter()
331                .map(|path| ObjectPath::from(path.as_str()))
332                .collect();
333
334            self.execute_async(async {
335                combine_parquet_files_from_object_store(
336                    self.object_store.clone(),
337                    object_paths,
338                    &ObjectPath::from(path),
339                    Some(self.compression),
340                    Some(self.max_row_group_size),
341                    deduplicate,
342                )
343                .await
344            })?;
345        }
346
347        if ensure_contiguous_files.unwrap_or(true) && !are_intervals_disjoint(&intervals) {
348            anyhow::bail!("Intervals are not disjoint after consolidating a directory");
349        }
350
351        Ok(())
352    }
353
354    /// Consolidates all data files in the catalog by splitting them into fixed time periods.
355    ///
356    /// This method identifies all leaf directories in the catalog that contain parquet files
357    /// and consolidates them by period. A leaf directory is one that contains files but no subdirectories.
358    /// This is a convenience method that effectively calls `consolidate_data_by_period` for all data types
359    /// and instrument IDs in the catalog.
360    ///
361    /// # Parameters
362    ///
363    /// - `period_nanos`: The period duration for consolidation in nanoseconds. Default is 1 day (86400000000000).
364    ///   Examples: 3600000000000 (1 hour), 604800000000000 (7 days), 1800000000000 (30 minutes)
365    /// - `start`: Optional start timestamp for the consolidation range. Only files with timestamps
366    ///   greater than or equal to this value will be consolidated. If None, all files
367    ///   from the beginning of time will be considered.
368    /// - `end`: Optional end timestamp for the consolidation range. Only files with timestamps
369    ///   less than or equal to this value will be consolidated. If None, all files
370    ///   up to the end of time will be considered.
371    /// - `ensure_contiguous_files`: If true, uses period boundaries for file naming.
372    ///   If false, uses actual data timestamps for file naming.
373    ///
374    /// # Returns
375    ///
376    /// Returns `Ok(())` on success, or an error if consolidation fails for any directory.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error if:
381    /// - Directory listing fails.
382    /// - Data type extraction from path fails.
383    /// - Period-based consolidation operations fail.
384    ///
385    /// # Notes
386    ///
387    /// - This operation can be resource-intensive for large catalogs with many data types.
388    ///   and instruments.
389    /// - The consolidation process splits data into fixed time periods rather than combining.
390    ///   all files into a single file per directory.
391    /// - Uses the same period-based consolidation logic as `consolidate_data_by_period`.
392    /// - Original files are removed and replaced with period-based consolidated files.
393    /// - This method is useful for periodic maintenance of the catalog to standardize.
394    ///   file organization by time periods.
395    ///
396    /// # Examples
397    ///
398    /// ```rust,no_run
399    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
400    /// use nautilus_core::UnixNanos;
401    ///
402    /// let catalog = ParquetDataCatalog::new(/* ... */);
403    ///
404    /// // Consolidate all files in the catalog by 1-day periods
405    /// catalog.consolidate_catalog_by_period(
406    ///     Some(86400000000000), // 1 day in nanoseconds
407    ///     None,
408    ///     None,
409    ///     Some(true)
410    /// )?;
411    ///
412    /// // Consolidate only files within a specific time range by 1-hour periods
413    /// catalog.consolidate_catalog_by_period(
414    ///     Some(3600000000000), // 1 hour in nanoseconds
415    ///     Some(UnixNanos::from(1609459200000000000)),
416    ///     Some(UnixNanos::from(1609545600000000000)),
417    ///     Some(false)
418    /// )?;
419    /// # Ok::<(), anyhow::Error>(())
420    /// ```
421    pub fn consolidate_catalog_by_period(
422        &mut self,
423        period_nanos: Option<u64>,
424        start: Option<UnixNanos>,
425        end: Option<UnixNanos>,
426        ensure_contiguous_files: Option<bool>,
427    ) -> anyhow::Result<()> {
428        let leaf_directories = self.find_leaf_data_directories()?;
429
430        for directory in leaf_directories {
431            let (data_cls, identifier) =
432                self.extract_data_cls_and_identifier_from_path(&directory)?;
433
434            if let Some(data_cls_name) = data_cls {
435                let identifier_ref = identifier.as_deref();
436                // Use match statement to call the generic consolidate_data_by_period for various types
437                match data_cls_name.as_str() {
438                    "quotes" => {
439                        self.consolidate_data_by_period_generic::<QuoteTick>(
440                            identifier_ref,
441                            period_nanos,
442                            start,
443                            end,
444                            ensure_contiguous_files,
445                        )?;
446                    }
447                    "trades" => {
448                        self.consolidate_data_by_period_generic::<TradeTick>(
449                            identifier_ref,
450                            period_nanos,
451                            start,
452                            end,
453                            ensure_contiguous_files,
454                        )?;
455                    }
456                    "order_book_deltas" => {
457                        self.consolidate_data_by_period_generic::<OrderBookDelta>(
458                            identifier_ref,
459                            period_nanos,
460                            start,
461                            end,
462                            ensure_contiguous_files,
463                        )?;
464                    }
465                    "order_book_depths" => {
466                        self.consolidate_data_by_period_generic::<OrderBookDepth10>(
467                            identifier_ref,
468                            period_nanos,
469                            start,
470                            end,
471                            ensure_contiguous_files,
472                        )?;
473                    }
474                    "bars" => {
475                        self.consolidate_data_by_period_generic::<Bar>(
476                            identifier_ref,
477                            period_nanos,
478                            start,
479                            end,
480                            ensure_contiguous_files,
481                        )?;
482                    }
483                    "index_prices" => {
484                        self.consolidate_data_by_period_generic::<IndexPriceUpdate>(
485                            identifier_ref,
486                            period_nanos,
487                            start,
488                            end,
489                            ensure_contiguous_files,
490                        )?;
491                    }
492                    "mark_prices" => {
493                        self.consolidate_data_by_period_generic::<MarkPriceUpdate>(
494                            identifier_ref,
495                            period_nanos,
496                            start,
497                            end,
498                            ensure_contiguous_files,
499                        )?;
500                    }
501                    "instrument_closes" => {
502                        self.consolidate_data_by_period_generic::<InstrumentClose>(
503                            identifier_ref,
504                            period_nanos,
505                            start,
506                            end,
507                            ensure_contiguous_files,
508                        )?;
509                    }
510                    _ => {
511                        if let Some(custom_type_name) = data_cls_name.strip_prefix("custom/") {
512                            self.consolidate_custom_data_by_period(
513                                custom_type_name,
514                                identifier_ref,
515                                period_nanos,
516                                start,
517                                end,
518                                ensure_contiguous_files,
519                            )?;
520                        } else {
521                            // Skip unknown data types
522                            log::warn!("Unknown data type for consolidation: {data_cls_name}");
523                        }
524                    }
525                }
526            }
527        }
528
529        Ok(())
530    }
531
532    /// Extracts data class and identifier from a directory path.
533    ///
534    /// This method parses a directory path to extract the data type and optional
535    /// instrument identifier. It's used to determine what type of data consolidation
536    /// to perform for each directory.
537    ///
538    /// # Parameters
539    ///
540    /// - `path`: The directory path to parse.
541    ///
542    /// # Returns
543    ///
544    /// Returns a tuple of (`data_class`, identifier) where both are optional strings.
545    ///
546    /// # Errors
547    ///
548    /// Currently this function does not return an error; it keeps the catalog
549    /// path-parsing API shape for compatibility with callers.
550    pub fn extract_data_cls_and_identifier_from_path(
551        &self,
552        path: &str,
553    ) -> anyhow::Result<(Option<String>, Option<String>)> {
554        // Use cross-platform path parsing
555        let path_components = extract_path_components(path);
556
557        // Find the "data" directory in the path
558        if let Some(data_index) = path_components.iter().position(|part| part == "data")
559            && data_index + 1 < path_components.len()
560        {
561            let second = &path_components[data_index + 1];
562
563            // Custom data: data/custom/TypeName[/identifier segments...]
564            if *second == "custom" && data_index + 2 < path_components.len() {
565                let type_name = path_components[data_index + 2].clone();
566                let data_cls = format!("custom/{type_name}");
567                let identifier = if data_index + 3 < path_components.len() {
568                    Some(path_components[data_index + 3..].join("/"))
569                } else {
570                    None
571                };
572                return Ok((Some(data_cls), identifier));
573            }
574
575            let data_cls = second.clone();
576            let identifier = if data_index + 2 < path_components.len() {
577                Some(path_components[data_index + 2].clone())
578            } else {
579                None
580            };
581
582            return Ok((Some(data_cls), identifier));
583        }
584
585        // If we can't parse the path, return None for both
586        Ok((None, None))
587    }
588
589    /// Consolidates data files by splitting them into fixed time periods.
590    ///
591    /// This method queries data by period and writes consolidated files immediately,
592    /// using efficient period-based consolidation logic. When start/end boundaries intersect existing files,
593    /// the function automatically splits those files to preserve all data.
594    ///
595    /// # Parameters
596    ///
597    /// - `type_name`: The data type directory name (e.g., "quotes", "trades", "bars").
598    /// - `identifier`: Optional instrument ID to consolidate. If None, consolidates all instruments.
599    /// - `period_nanos`: The period duration for consolidation in nanoseconds. Default is 1 day (86400000000000).
600    ///   Examples: 3600000000000 (1 hour), 604800000000000 (7 days), 1800000000000 (30 minutes)
601    /// - `start`: Optional start timestamp for consolidation range. If None, uses earliest available data.
602    ///   If specified and intersects existing files, those files will be split to preserve
603    ///   data outside the consolidation range.
604    /// - `end`: Optional end timestamp for consolidation range. If None, uses latest available data.
605    ///   If specified and intersects existing files, those files will be split to preserve
606    ///   data outside the consolidation range.
607    /// - `ensure_contiguous_files`: If true, uses period boundaries for file naming.
608    ///   If false, uses actual data timestamps for file naming.
609    ///
610    /// # Returns
611    ///
612    /// Returns `Ok(())` on success, or an error if consolidation fails.
613    ///
614    /// # Errors
615    ///
616    /// Returns an error if:
617    /// - The directory path cannot be constructed.
618    /// - File operations fail.
619    /// - Data querying or writing fails.
620    ///
621    /// # Notes
622    ///
623    /// - Uses two-phase approach: first determines all queries, then executes them.
624    /// - Groups intervals into contiguous groups to preserve holes between groups.
625    /// - Allows consolidation across multiple files within each contiguous group.
626    /// - Skips queries if target files already exist for efficiency.
627    /// - Original files are removed immediately after querying each period.
628    /// - When `ensure_contiguous_files=false`, file timestamps match actual data range.
629    /// - When `ensure_contiguous_files=true`, file timestamps use period boundaries.
630    /// - Uses modulo arithmetic for efficient period boundary calculation.
631    /// - Preserves holes in data by preventing queries from spanning across gaps.
632    /// - Automatically splits files at start/end boundaries to preserve all data.
633    /// - Split operations are executed before consolidation to ensure data preservation.
634    ///
635    /// # Examples
636    ///
637    /// ```rust,no_run
638    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
639    /// use nautilus_core::UnixNanos;
640    ///
641    /// let catalog = ParquetDataCatalog::new(/* ... */);
642    ///
643    /// // Consolidate all quote files by 1-day periods
644    /// catalog.consolidate_data_by_period(
645    ///     "quotes",
646    ///     None,
647    ///     Some(86400000000000), // 1 day in nanoseconds
648    ///     None,
649    ///     None,
650    ///     Some(true)
651    /// )?;
652    ///
653    /// // Consolidate specific instrument by 1-hour periods
654    /// catalog.consolidate_data_by_period(
655    ///     "trades",
656    ///     Some("BTCUSD".to_string()),
657    ///     Some(3600000000000), // 1 hour in nanoseconds
658    ///     Some(UnixNanos::from(1609459200000000000)),
659    ///     Some(UnixNanos::from(1609545600000000000)),
660    ///     Some(false)
661    /// )?;
662    /// # Ok::<(), anyhow::Error>(())
663    /// ```
664    pub fn consolidate_data_by_period(
665        &mut self,
666        type_name: &str,
667        identifier: Option<&str>,
668        period_nanos: Option<u64>,
669        start: Option<UnixNanos>,
670        end: Option<UnixNanos>,
671        ensure_contiguous_files: Option<bool>,
672    ) -> anyhow::Result<()> {
673        // Use match statement to call the generic consolidate_data_by_period for various types
674        match type_name {
675            "quotes" => {
676                self.consolidate_data_by_period_generic::<QuoteTick>(
677                    identifier,
678                    period_nanos,
679                    start,
680                    end,
681                    ensure_contiguous_files,
682                )?;
683            }
684            "trades" => {
685                self.consolidate_data_by_period_generic::<TradeTick>(
686                    identifier,
687                    period_nanos,
688                    start,
689                    end,
690                    ensure_contiguous_files,
691                )?;
692            }
693            "order_book_deltas" => {
694                self.consolidate_data_by_period_generic::<OrderBookDelta>(
695                    identifier,
696                    period_nanos,
697                    start,
698                    end,
699                    ensure_contiguous_files,
700                )?;
701            }
702            "order_book_depths" => {
703                self.consolidate_data_by_period_generic::<OrderBookDepth10>(
704                    identifier,
705                    period_nanos,
706                    start,
707                    end,
708                    ensure_contiguous_files,
709                )?;
710            }
711            "bars" => {
712                self.consolidate_data_by_period_generic::<Bar>(
713                    identifier,
714                    period_nanos,
715                    start,
716                    end,
717                    ensure_contiguous_files,
718                )?;
719            }
720            "index_prices" => {
721                self.consolidate_data_by_period_generic::<IndexPriceUpdate>(
722                    identifier,
723                    period_nanos,
724                    start,
725                    end,
726                    ensure_contiguous_files,
727                )?;
728            }
729            "mark_prices" => {
730                self.consolidate_data_by_period_generic::<MarkPriceUpdate>(
731                    identifier,
732                    period_nanos,
733                    start,
734                    end,
735                    ensure_contiguous_files,
736                )?;
737            }
738            "instrument_closes" => {
739                self.consolidate_data_by_period_generic::<InstrumentClose>(
740                    identifier,
741                    period_nanos,
742                    start,
743                    end,
744                    ensure_contiguous_files,
745                )?;
746            }
747            _ => {
748                if let Some(custom_type_name) = type_name.strip_prefix("custom/") {
749                    self.consolidate_custom_data_by_period(
750                        custom_type_name,
751                        identifier,
752                        period_nanos,
753                        start,
754                        end,
755                        ensure_contiguous_files,
756                    )?;
757                } else {
758                    anyhow::bail!("Unknown data type for consolidation: {type_name}");
759                }
760            }
761        }
762
763        Ok(())
764    }
765
766    /// Generic consolidate data files by splitting them into fixed time periods.
767    ///
768    /// This is a type-safe version of `consolidate_data_by_period` that uses generic types
769    /// to ensure compile-time correctness and enable reuse across different data types.
770    ///
771    /// # Type Parameters
772    ///
773    /// - `T`: The data type to consolidate, must implement required traits for serialization.
774    ///
775    /// # Parameters
776    ///
777    /// - `identifier`: Optional instrument ID to target a specific instrument's data.
778    /// - `period_nanos`: Optional period size in nanoseconds (default: 1 day).
779    /// - `start`: Optional start timestamp for consolidation range.
780    /// - `end`: Optional end timestamp for consolidation range.
781    /// - `ensure_contiguous_files`: Optional flag to control file naming strategy.
782    ///
783    /// # Returns
784    ///
785    /// Returns `Ok(())` on success, or an error if consolidation fails.
786    ///
787    /// # Errors
788    ///
789    /// Returns an error if interval lookup, query preparation, file removal, or
790    /// rewritten data writes fail.
791    pub fn consolidate_data_by_period_generic<T>(
792        &mut self,
793        identifier: Option<&str>,
794        period_nanos: Option<u64>,
795        start: Option<UnixNanos>,
796        end: Option<UnixNanos>,
797        ensure_contiguous_files: Option<bool>,
798    ) -> anyhow::Result<()>
799    where
800        T: DecodeDataFromRecordBatch
801            + CatalogPathPrefix
802            + EncodeToRecordBatch
803            + HasTsInit
804            + TryFrom<Data>
805            + Clone,
806    {
807        let period_nanos = period_nanos.unwrap_or(NANOSECONDS_IN_DAY);
808        let ensure_contiguous_files = ensure_contiguous_files.unwrap_or(true);
809
810        // Use get_intervals for cleaner implementation
811        let intervals = self.get_intervals(T::path_prefix(), identifier)?;
812
813        if intervals.is_empty() {
814            return Ok(()); // No files to consolidate
815        }
816
817        // Use auxiliary function to prepare all queries for execution
818        let queries_to_execute = self.prepare_consolidation_queries(
819            T::path_prefix(),
820            identifier,
821            &intervals,
822            period_nanos,
823            start,
824            end,
825            ensure_contiguous_files,
826        )?;
827
828        if queries_to_execute.is_empty() {
829            return Ok(()); // No queries to execute
830        }
831
832        // Get directory for file operations
833        let directory = self.make_path(T::path_prefix(), identifier)?;
834        let mut existing_files = self.list_parquet_files(&directory)?;
835        existing_files.sort();
836
837        // Capture the overall window's left bound before the loop consumes queries_to_execute,
838        // a source file is only deleted when its interval is fully consumed by the consolidation.
839        let overall_query_start = queries_to_execute[0].query_start;
840
841        // Phase 2: Execute queries, write, and delete
842        let mut file_start_ns: Option<u64> = None; // Track contiguity across periods
843
844        for query_info in queries_to_execute {
845            // Query data for this period using query_typed_data
846            let instrument_ids = identifier.map(|id| vec![id.to_string()]);
847
848            // Use optimize_file_loading=false to match Python behavior:
849            // During consolidation, we want to read only the specific files being consolidated,
850            // not the entire directory. This ensures precise file control during consolidation.
851            let period_data = self.query_typed_data::<T>(
852                instrument_ids,
853                Some(UnixNanos::from(query_info.query_start)),
854                Some(UnixNanos::from(query_info.query_end)),
855                None,
856                Some(existing_files.clone()),
857                false, // optimize_file_loading=false for precise file control during consolidation
858            )?;
859
860            if period_data.is_empty() {
861                // Skip if no data found, but maintain contiguity by using query start
862                if file_start_ns.is_none() {
863                    file_start_ns = Some(query_info.query_start);
864                }
865                continue;
866            }
867
868            // Determine final file timestamps
869            let (final_start_ns, final_end_ns) = if query_info.use_period_boundaries {
870                // Use period boundaries for file naming, maintaining contiguity
871                if file_start_ns.is_none() {
872                    file_start_ns = Some(query_info.query_start);
873                }
874                let start = *file_start_ns.get_or_insert(query_info.query_start);
875                (start, query_info.query_end)
876            } else {
877                // Use actual data timestamps for file naming
878                let Some(first_data) = period_data.first() else {
879                    continue;
880                };
881                let Some(last_data) = period_data.last() else {
882                    continue;
883                };
884                let first_ts = first_data.ts_init().as_u64();
885                let last_ts = last_data.ts_init().as_u64();
886                (first_ts, last_ts)
887            };
888
889            // Check again if target file exists (in case it was created during this process)
890            let target_filename = format!(
891                "{}/{}",
892                directory,
893                timestamps_to_filename(
894                    UnixNanos::from(final_start_ns),
895                    UnixNanos::from(final_end_ns)
896                )
897            );
898
899            if self.file_exists(&target_filename)? {
900                // This period is already consolidated; do not let a later cleanup delete it.
901                let target_object_path = self.to_object_path(&target_filename)?.to_string();
902                existing_files.retain(|f| f != &target_object_path);
903                continue;
904            }
905
906            // Write consolidated data for this period using write_to_parquet
907            // Use skip_disjoint_check since we're managing file removal carefully
908            let start_ts = UnixNanos::from(final_start_ns);
909            let end_ts = UnixNanos::from(final_end_ns);
910            self.write_to_parquet(&period_data, Some(start_ts), Some(end_ts), Some(true))?;
911
912            // Delete files fully consumed by this period; keep straddlers so no data is lost
913            for file in existing_files.clone() {
914                if let Some(interval) = parse_filename_timestamps(&file)
915                    && interval.1 <= query_info.query_end
916                    && interval.0 >= overall_query_start
917                {
918                    existing_files.retain(|f| f != &file);
919                    self.delete_file(&file)?;
920                }
921            }
922
923            // Reset so next period starts a new contiguous segment
924            file_start_ns = None;
925        }
926
927        Ok(())
928    }
929
930    /// Consolidates custom data files by splitting them into fixed time periods.
931    ///
932    /// This method provides consolidation for custom data types that don't have compile-time
933    /// type information. It uses dynamic querying and writing methods.
934    ///
935    /// # Parameters
936    ///
937    /// - `type_name`: The custom data type name (without "custom/" prefix).
938    /// - `identifier`: Optional instrument ID to consolidate.
939    /// - `period_nanos`: Optional period size in nanoseconds (default: 1 day).
940    /// - `start`: Optional start timestamp for consolidation range.
941    /// - `end`: Optional end timestamp for consolidation range.
942    /// - `ensure_contiguous_files`: Optional flag to control file naming strategy.
943    ///
944    /// # Returns
945    ///
946    /// Returns `Ok(())` on success, or an error if consolidation fails.
947    fn consolidate_custom_data_by_period(
948        &mut self,
949        type_name: &str,
950        identifier: Option<&str>,
951        period_nanos: Option<u64>,
952        start: Option<UnixNanos>,
953        end: Option<UnixNanos>,
954        ensure_contiguous_files: Option<bool>,
955    ) -> anyhow::Result<()> {
956        let period_nanos = period_nanos.unwrap_or(NANOSECONDS_IN_DAY);
957        let ensure_contiguous_files = ensure_contiguous_files.unwrap_or(true);
958
959        // Get intervals for the custom data type
960        let path_prefix = format!("custom/{type_name}");
961        let intervals = self.get_intervals(&path_prefix, identifier)?;
962
963        if intervals.is_empty() {
964            return Ok(()); // No files to consolidate
965        }
966
967        // Use auxiliary function to prepare all queries for execution
968        let queries_to_execute = self.prepare_consolidation_queries(
969            &path_prefix,
970            identifier,
971            &intervals,
972            period_nanos,
973            start,
974            end,
975            ensure_contiguous_files,
976        )?;
977
978        if queries_to_execute.is_empty() {
979            return Ok(()); // No queries to execute
980        }
981
982        // Get directory for file operations
983        let directory = self.make_path(&path_prefix, identifier)?;
984        let mut existing_files = self.list_parquet_files(&directory)?;
985        existing_files.sort();
986
987        // Capture the overall window's left bound before the loop consumes queries_to_execute,
988        // a source file is only deleted when its interval is fully consumed by the consolidation.
989        let overall_query_start = queries_to_execute[0].query_start;
990
991        // Phase 2: Execute queries, write, and delete
992        let mut file_start_ns: Option<u64> = None; // Track contiguity across periods
993
994        for query_info in queries_to_execute {
995            // Query custom data for this period using query_custom_data_dynamic
996            let instrument_ids = identifier.map(|id| vec![id.to_string()]);
997
998            let period_data = self.query_custom_data_dynamic(
999                type_name,
1000                instrument_ids.as_deref(),
1001                Some(UnixNanos::from(query_info.query_start)),
1002                Some(UnixNanos::from(query_info.query_end)),
1003                None,
1004                Some(existing_files.clone()),
1005                false, // optimize_file_loading=false for precise file control during consolidation
1006            )?;
1007
1008            if period_data.is_empty() {
1009                // Skip if no data found, but maintain contiguity by using query start
1010                if file_start_ns.is_none() {
1011                    file_start_ns = Some(query_info.query_start);
1012                }
1013                continue;
1014            }
1015
1016            // Determine final file timestamps
1017            let (final_start_ns, final_end_ns) = if query_info.use_period_boundaries {
1018                // Use period boundaries for file naming, maintaining contiguity
1019                if file_start_ns.is_none() {
1020                    file_start_ns = Some(query_info.query_start);
1021                }
1022                let start = *file_start_ns.get_or_insert(query_info.query_start);
1023                (start, query_info.query_end)
1024            } else {
1025                // Use actual data timestamps for file naming
1026                let Some(first_data) = period_data.first() else {
1027                    continue;
1028                };
1029                let Some(last_data) = period_data.last() else {
1030                    continue;
1031                };
1032                let first_ts = first_data.ts_init().as_u64();
1033                let last_ts = last_data.ts_init().as_u64();
1034                (first_ts, last_ts)
1035            };
1036
1037            // Check again if target file exists (in case it was created during this process)
1038            let target_filename = format!(
1039                "{}/{}",
1040                directory,
1041                timestamps_to_filename(
1042                    UnixNanos::from(final_start_ns),
1043                    UnixNanos::from(final_end_ns)
1044                )
1045            );
1046
1047            if self.file_exists(&target_filename)? {
1048                // This period is already consolidated; do not let a later cleanup delete it.
1049                let target_object_path = self.to_object_path(&target_filename)?.to_string();
1050                existing_files.retain(|f| f != &target_object_path);
1051                continue;
1052            }
1053
1054            // Group custom data by type for writing
1055            let mut custom_data_by_type: AHashMap<String, Vec<CustomData>> = AHashMap::new();
1056
1057            for data in period_data {
1058                if let Data::Custom(c) = data {
1059                    let type_name_str = c.data.type_name().to_string();
1060                    custom_data_by_type
1061                        .entry(type_name_str)
1062                        .or_default()
1063                        .push(c);
1064                }
1065            }
1066
1067            // Write consolidated data for each type
1068            for (_, items) in custom_data_by_type {
1069                let start_ts = UnixNanos::from(final_start_ns);
1070                let end_ts = UnixNanos::from(final_end_ns);
1071                self.write_custom_data_batch(items, Some(start_ts), Some(end_ts), Some(true))?;
1072            }
1073
1074            // Delete files fully consumed by this period; keep straddlers so no data is lost
1075            for file in existing_files.clone() {
1076                if let Some(interval) = parse_filename_timestamps(&file)
1077                    && interval.1 <= query_info.query_end
1078                    && interval.0 >= overall_query_start
1079                {
1080                    existing_files.retain(|f| f != &file);
1081                    self.delete_file(&file)?;
1082                }
1083            }
1084
1085            // Reset so next period starts a new contiguous segment
1086            file_start_ns = None;
1087        }
1088
1089        Ok(())
1090    }
1091
1092    /// Deletes custom data within a specified time range.
1093    ///
1094    /// This method provides deletion for custom data types that don't have compile-time
1095    /// type information. It uses dynamic querying and writing methods.
1096    ///
1097    /// # Parameters
1098    ///
1099    /// - `type_name`: The custom data type name (without "custom/" prefix).
1100    /// - `identifier`: Optional instrument ID to delete data for.
1101    /// - `start`: Optional start timestamp for the deletion range.
1102    /// - `end`: Optional end timestamp for the deletion range.
1103    ///
1104    /// # Returns
1105    ///
1106    /// Returns `Ok(())` on success, or an error if deletion fails.
1107    fn delete_custom_data_range(
1108        &mut self,
1109        type_name: &str,
1110        identifier: Option<&str>,
1111        start: Option<UnixNanos>,
1112        end: Option<UnixNanos>,
1113    ) -> anyhow::Result<()> {
1114        let path_prefix = format!("custom/{type_name}");
1115
1116        // Get intervals for the custom data type
1117        let intervals = self.get_intervals(&path_prefix, identifier)?;
1118
1119        if intervals.is_empty() {
1120            return Ok(()); // No files to process
1121        }
1122
1123        // Prepare all operations for execution
1124        let operations_to_execute =
1125            self.prepare_delete_operations(&path_prefix, identifier, &intervals, start, end)?;
1126
1127        if operations_to_execute.is_empty() {
1128            return Ok(()); // No operations to execute
1129        }
1130
1131        // Execute all operations
1132        let mut files_to_remove = AHashSet::<String>::new();
1133
1134        for operation in operations_to_execute {
1135            // Reset the session before each operation
1136            self.reset_session();
1137
1138            match operation.operation_type.as_str() {
1139                "split_before" => {
1140                    // Query custom data before the deletion range and write it
1141                    let instrument_ids = identifier.map(|id| vec![id.to_string()]);
1142                    let before_data = self.query_custom_data_dynamic(
1143                        type_name,
1144                        instrument_ids.as_deref(),
1145                        Some(UnixNanos::from(operation.query_start)),
1146                        Some(UnixNanos::from(operation.query_end)),
1147                        None,
1148                        Some(operation.files.clone()),
1149                        false,
1150                    )?;
1151
1152                    if !before_data.is_empty() {
1153                        // Group custom data by type for writing
1154                        use ahash::AHashMap;
1155                        let mut custom_data_by_type: AHashMap<String, Vec<CustomData>> =
1156                            AHashMap::new();
1157
1158                        for data in before_data {
1159                            if let Data::Custom(c) = data {
1160                                let type_name_str = c.data.type_name().to_string();
1161                                custom_data_by_type
1162                                    .entry(type_name_str)
1163                                    .or_default()
1164                                    .push(c);
1165                            }
1166                        }
1167
1168                        // Write data for each type
1169                        for (_, items) in custom_data_by_type {
1170                            let start_ts = UnixNanos::from(operation.file_start_ns);
1171                            let end_ts = UnixNanos::from(operation.file_end_ns);
1172                            self.write_custom_data_batch(
1173                                items,
1174                                Some(start_ts),
1175                                Some(end_ts),
1176                                Some(true),
1177                            )?;
1178                        }
1179                    }
1180                }
1181                "split_after" => {
1182                    // Query custom data after the deletion range and write it
1183                    let instrument_ids = identifier.map(|id| vec![id.to_string()]);
1184                    let after_data = self.query_custom_data_dynamic(
1185                        type_name,
1186                        instrument_ids.as_deref(),
1187                        Some(UnixNanos::from(operation.query_start)),
1188                        Some(UnixNanos::from(operation.query_end)),
1189                        None,
1190                        Some(operation.files.clone()),
1191                        false,
1192                    )?;
1193
1194                    if !after_data.is_empty() {
1195                        // Group custom data by type for writing
1196                        use ahash::AHashMap;
1197                        let mut custom_data_by_type: AHashMap<String, Vec<CustomData>> =
1198                            AHashMap::new();
1199
1200                        for data in after_data {
1201                            if let Data::Custom(c) = data {
1202                                let type_name_str = c.data.type_name().to_string();
1203                                custom_data_by_type
1204                                    .entry(type_name_str)
1205                                    .or_default()
1206                                    .push(c);
1207                            }
1208                        }
1209
1210                        // Write data for each type
1211                        for (_, items) in custom_data_by_type {
1212                            let start_ts = UnixNanos::from(operation.file_start_ns);
1213                            let end_ts = UnixNanos::from(operation.file_end_ns);
1214                            self.write_custom_data_batch(
1215                                items,
1216                                Some(start_ts),
1217                                Some(end_ts),
1218                                Some(true),
1219                            )?;
1220                        }
1221                    }
1222                }
1223                _ => {
1224                    // For "remove" operations, just mark files for removal
1225                }
1226            }
1227
1228            // Mark files for removal (applies to all operation types)
1229            for file in operation.files {
1230                files_to_remove.insert(file);
1231            }
1232        }
1233
1234        // Remove all files that were processed
1235        for file in files_to_remove {
1236            if let Err(e) = self.delete_file(&file) {
1237                log::warn!("Failed to delete file {file}: {e}");
1238            }
1239        }
1240
1241        Ok(())
1242    }
1243
1244    /// Prepares all queries for consolidation by filtering, grouping, and handling splits.
1245    ///
1246    /// This auxiliary function handles all the preparation logic for consolidation:
1247    /// 1. Filters intervals by time range.
1248    /// 2. Groups intervals into contiguous groups.
1249    /// 3. Identifies and creates split operations for data preservation.
1250    /// 4. Generates period-based consolidation queries.
1251    /// 5. Checks for existing target files.
1252    ///
1253    /// # Errors
1254    ///
1255    /// Returns an error if split planning, target path construction, or object store
1256    /// existence checks fail.
1257    #[expect(clippy::too_many_arguments)]
1258    pub fn prepare_consolidation_queries(
1259        &self,
1260        type_name: &str,
1261        identifier: Option<&str>,
1262        intervals: &[(u64, u64)],
1263        period_nanos: u64,
1264        start: Option<UnixNanos>,
1265        end: Option<UnixNanos>,
1266        ensure_contiguous_files: bool,
1267    ) -> anyhow::Result<Vec<ConsolidationQuery>> {
1268        // Filter intervals by time range if specified
1269        let used_start = start.map(|s| s.as_u64());
1270        let used_end = end.map(|e| e.as_u64());
1271
1272        let mut filtered_intervals = Vec::new();
1273
1274        for &(interval_start, interval_end) in intervals {
1275            // Check if interval overlaps with the specified range
1276            if used_start.is_none_or(|used_start| used_start <= interval_end)
1277                && used_end.is_none_or(|used_end| interval_start <= used_end)
1278            {
1279                filtered_intervals.push((interval_start, interval_end));
1280            }
1281        }
1282
1283        if filtered_intervals.is_empty() {
1284            return Ok(Vec::new()); // No intervals in the specified range
1285        }
1286
1287        // Check contiguity of filtered intervals if required
1288        if ensure_contiguous_files && !are_intervals_contiguous(&filtered_intervals) {
1289            anyhow::bail!(
1290                "Intervals are not contiguous. When ensure_contiguous_files=true, \
1291                 all files in the consolidation range must have contiguous timestamps."
1292            );
1293        }
1294
1295        // Group intervals by the target period: split only when the gap between files
1296        // exceeds one period, since sub-period gaps land in the same consolidated file.
1297        let contiguous_groups = self.group_contiguous_intervals(&filtered_intervals, period_nanos);
1298
1299        let mut queries_to_execute = Vec::new();
1300
1301        // Handle interval splitting by creating split operations for data preservation
1302        if !filtered_intervals.is_empty() {
1303            if let Some(start_ts) = used_start {
1304                let first_interval = filtered_intervals[0];
1305                if first_interval.0 < start_ts && start_ts <= first_interval.1 {
1306                    // Split before start: preserve data from interval_start to start-1
1307                    queries_to_execute.push(ConsolidationQuery {
1308                        query_start: first_interval.0,
1309                        query_end: start_ts - 1,
1310                        use_period_boundaries: false,
1311                    });
1312                }
1313            }
1314
1315            if let Some(end_ts) = used_end {
1316                let last_interval = filtered_intervals[filtered_intervals.len() - 1];
1317                if last_interval.0 <= end_ts && end_ts < last_interval.1 {
1318                    // Split after end: preserve data from end+1 to interval_end
1319                    queries_to_execute.push(ConsolidationQuery {
1320                        query_start: end_ts + 1,
1321                        query_end: last_interval.1,
1322                        use_period_boundaries: false,
1323                    });
1324                }
1325            }
1326        }
1327
1328        // Generate period-based consolidation queries for each contiguous group
1329        for group in contiguous_groups {
1330            let group_start = group[0].0;
1331            let group_end = group[group.len() - 1].1;
1332
1333            // Apply start/end filtering to the group
1334            let effective_start = used_start.map_or(group_start, |s| s.max(group_start));
1335            let effective_end = used_end.map_or(group_end, |e| e.min(group_end));
1336
1337            if effective_start > effective_end {
1338                continue; // Skip if no overlap
1339            }
1340
1341            // Generate period-based queries within this contiguous group
1342            let mut current_start_ns = (effective_start / period_nanos) * period_nanos;
1343
1344            // Add safety check to prevent infinite loops (match Python logic)
1345            let max_iterations = 10000;
1346            let mut iteration_count = 0;
1347
1348            while current_start_ns <= effective_end {
1349                iteration_count += 1;
1350                if iteration_count > max_iterations {
1351                    // Safety break to prevent infinite loops
1352                    break;
1353                }
1354                let current_end_ns = (current_start_ns + period_nanos - 1).min(effective_end);
1355
1356                // Check if target file already exists (only when ensure_contiguous_files is true)
1357                if ensure_contiguous_files {
1358                    let directory = self.make_path(type_name, identifier)?;
1359                    let target_filename = format!(
1360                        "{}/{}",
1361                        directory,
1362                        timestamps_to_filename(
1363                            UnixNanos::from(current_start_ns),
1364                            UnixNanos::from(current_end_ns)
1365                        )
1366                    );
1367
1368                    if self.file_exists(&target_filename)? {
1369                        // Skip if target file already exists
1370                        current_start_ns += period_nanos;
1371                        continue;
1372                    }
1373                }
1374
1375                // Add query to execution list
1376                queries_to_execute.push(ConsolidationQuery {
1377                    query_start: current_start_ns,
1378                    query_end: current_end_ns,
1379                    use_period_boundaries: ensure_contiguous_files,
1380                });
1381
1382                // Move to next period
1383                current_start_ns += period_nanos;
1384
1385                if current_start_ns > effective_end {
1386                    break;
1387                }
1388            }
1389        }
1390
1391        // Sort queries by start date to enable efficient file removal
1392        // Files can be removed when interval[1] <= query_info["query_end"]
1393        // and processing in chronological order ensures optimal cleanup
1394        queries_to_execute.sort_by_key(|q| q.query_start);
1395
1396        Ok(queries_to_execute)
1397    }
1398
1399    /// Groups intervals for period-based consolidation.
1400    ///
1401    /// Groups adjacent intervals into the same bucket unless the gap between them exceeds
1402    /// `period_nanos`. Sub-period gaps land in the same consolidated file anyway, so they
1403    /// do not warrant a split. Gaps larger than one period represent genuine data holes.
1404    ///
1405    /// # Parameters
1406    ///
1407    /// - `intervals`: A slice of timestamp intervals as (start, end) tuples, sorted by start.
1408    /// - `period_nanos`: The target consolidation period; gaps larger than this split groups.
1409    ///
1410    /// # Returns
1411    ///
1412    /// Returns a vector of groups. Returns an empty vector if the input is empty.
1413    ///
1414    /// # Examples
1415    ///
1416    /// ```text
1417    /// Legacy chunked files with period=86_400_000_000_000 (1 day):
1418    ///   [(1,5), (6,10), (11,15)] -> [[(1,5), (6,10), (11,15)]]
1419    ///
1420    /// Small period=1 with mixed gaps:
1421    ///   [(1,5), (8,10), (12,15)] -> [[(1,5)], [(8,10)], [(12,15)]]
1422    /// ```
1423    #[must_use]
1424    pub fn group_contiguous_intervals(
1425        &self,
1426        intervals: &[(u64, u64)],
1427        period_nanos: u64,
1428    ) -> Vec<Vec<(u64, u64)>> {
1429        if intervals.is_empty() {
1430            return Vec::new();
1431        }
1432
1433        // Split groups only when the gap between files exceeds one period,
1434        // since sub-period gaps land in the same consolidated file anyway.
1435        // This works for both legacy chunked files (gap ~1ns) and fragment-per-flush
1436        // catalogs (gap ~bar interval) without inferring spacing from the data.
1437        let mut contiguous_groups = Vec::new();
1438        let mut current_group = vec![intervals[0]];
1439
1440        for i in 1..intervals.len() {
1441            let prev_end = intervals[i - 1].1;
1442            let curr_start = intervals[i].0;
1443
1444            if curr_start.saturating_sub(prev_end) > period_nanos {
1445                contiguous_groups.push(current_group);
1446                current_group = vec![intervals[i]];
1447            } else {
1448                current_group.push(intervals[i]);
1449            }
1450        }
1451
1452        contiguous_groups.push(current_group);
1453
1454        contiguous_groups
1455    }
1456
1457    /// Checks if a file exists in the object store.
1458    ///
1459    /// This method performs a HEAD operation on the object store to determine if a file
1460    /// exists without downloading its content. It works with both local and remote object stores.
1461    ///
1462    /// # Parameters
1463    ///
1464    /// - `path`: The file path to check, relative to the catalog structure.
1465    ///
1466    /// # Returns
1467    ///
1468    /// Returns `true` if the file exists, `false` if it doesn't exist.
1469    ///
1470    /// # Errors
1471    ///
1472    /// Returns an error if the object store operation fails due to network issues,
1473    /// authentication problems, or other I/O errors.
1474    fn file_exists(&self, path: &str) -> anyhow::Result<bool> {
1475        let object_path = self.to_object_path(path)?;
1476        let exists = self.execute_async(async {
1477            let result: bool = self.object_store.head(&object_path).await.is_ok();
1478            Ok(result)
1479        })?;
1480        Ok(exists)
1481    }
1482
1483    /// Deletes a file from the object store.
1484    ///
1485    /// This method removes a file from the object store. The operation is permanent
1486    /// and cannot be undone. It works with both local filesystems and remote object stores.
1487    ///
1488    /// # Parameters
1489    ///
1490    /// - `path`: The file path to delete, relative to the catalog structure.
1491    ///
1492    /// # Returns
1493    ///
1494    /// Returns `Ok(())` on successful deletion.
1495    ///
1496    /// # Errors
1497    ///
1498    /// Returns an error if:
1499    /// - The file doesn't exist.
1500    /// - Permission is denied.
1501    /// - Network issues occur (for remote stores).
1502    /// - The object store operation fails.
1503    ///
1504    /// # Safety
1505    ///
1506    /// This operation is irreversible. Ensure the file is no longer needed before deletion.
1507    fn delete_file(&self, path: &str) -> anyhow::Result<()> {
1508        let object_path = self.to_object_path(path)?;
1509        self.execute_async(async {
1510            self.object_store
1511                .delete(&object_path)
1512                .await
1513                .map_err(anyhow::Error::from)
1514        })?;
1515        Ok(())
1516    }
1517
1518    /// Resets the filenames of all Parquet files in the catalog to match their actual content timestamps.
1519    ///
1520    /// This method scans all leaf data directories in the catalog and renames files based on
1521    /// the actual timestamp range of their content. This is useful when files have been
1522    /// modified or when filename conventions have changed.
1523    ///
1524    /// # Returns
1525    ///
1526    /// Returns `Ok(())` on success, or an error if the operation fails.
1527    ///
1528    /// # Errors
1529    ///
1530    /// Returns an error if:
1531    /// - Directory listing fails.
1532    /// - File metadata reading fails.
1533    /// - File rename operations fail.
1534    /// - Interval validation fails after renaming.
1535    ///
1536    /// # Examples
1537    ///
1538    /// ```rust,no_run
1539    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
1540    ///
1541    /// let catalog = ParquetDataCatalog::new(/* ... */);
1542    ///
1543    /// // Reset all filenames in the catalog
1544    /// catalog.reset_all_file_names()?;
1545    /// # Ok::<(), anyhow::Error>(())
1546    /// ```
1547    pub fn reset_all_file_names(&self) -> anyhow::Result<()> {
1548        let leaf_directories = self.find_leaf_data_directories()?;
1549
1550        for directory in leaf_directories {
1551            self.reset_file_names(&directory)?;
1552        }
1553
1554        Ok(())
1555    }
1556
1557    /// Resets the filenames of Parquet files for a specific data type and identifier.
1558    ///
1559    /// This method renames files in a specific directory based on the actual timestamp
1560    /// range of their content. This is useful for correcting filenames after data
1561    /// modifications or when filename conventions have changed.
1562    ///
1563    /// # Parameters
1564    ///
1565    /// - `data_cls`: The data type directory name (e.g., "quotes", "trades").
1566    /// - `identifier`: Optional identifier to target a specific instrument's data. Can be an `instrument_id` (e.g., "EUR/USD.SIM") or a `bar_type` (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1567    ///
1568    /// # Returns
1569    ///
1570    /// Returns `Ok(())` on success, or an error if the operation fails.
1571    ///
1572    /// # Errors
1573    ///
1574    /// Returns an error if:
1575    /// - The directory path cannot be constructed.
1576    /// - File metadata reading fails.
1577    /// - File rename operations fail.
1578    /// - Interval validation fails after renaming.
1579    ///
1580    /// # Examples
1581    ///
1582    /// ```rust,no_run
1583    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
1584    ///
1585    /// let catalog = ParquetDataCatalog::new(/* ... */);
1586    ///
1587    /// // Reset filenames for all quote files
1588    /// catalog.reset_data_file_names("quotes", None)?;
1589    ///
1590    /// // Reset filenames for a specific instrument's trade files
1591    /// catalog.reset_data_file_names("trades", Some("BTCUSD".to_string()))?;
1592    /// # Ok::<(), anyhow::Error>(())
1593    /// ```
1594    pub fn reset_data_file_names(
1595        &self,
1596        data_cls: &str,
1597        identifier: Option<&str>,
1598    ) -> anyhow::Result<()> {
1599        let directory = self.make_path(data_cls, identifier)?;
1600        self.reset_file_names(&directory)
1601    }
1602
1603    /// Resets the filenames of Parquet files in a directory to match their actual content timestamps.
1604    ///
1605    /// This internal method scans all Parquet files in a directory, reads their metadata to
1606    /// determine the actual timestamp range of their content, and renames the files accordingly.
1607    /// This ensures that filenames accurately reflect the data they contain.
1608    ///
1609    /// # Parameters
1610    ///
1611    /// - `directory`: The directory path containing Parquet files to rename.
1612    ///
1613    /// # Returns
1614    ///
1615    /// Returns `Ok(())` on success, or an error if the operation fails.
1616    ///
1617    /// # Process
1618    ///
1619    /// 1. Lists all Parquet files in the directory
1620    /// 2. For each file, reads metadata to extract min/max timestamps
1621    /// 3. Generates a new filename based on actual timestamp range
1622    /// 4. Moves the file to the new name using object store operations
1623    /// 5. Validates that intervals remain disjoint after renaming
1624    ///
1625    /// # Errors
1626    ///
1627    /// Returns an error if:
1628    /// - Directory listing fails.
1629    /// - Metadata reading fails for any file.
1630    /// - File move operations fail.
1631    /// - Interval validation fails after renaming.
1632    /// - Object store operations fail.
1633    ///
1634    /// # Notes
1635    ///
1636    /// - This operation can be time-consuming for directories with many files.
1637    /// - Files are processed sequentially to avoid conflicts.
1638    /// - The operation is atomic per file but not across the entire directory.
1639    fn reset_file_names(&self, directory: &str) -> anyhow::Result<()> {
1640        let parquet_files = self.list_parquet_files(directory)?;
1641
1642        for file in parquet_files {
1643            let object_path = ObjectPath::from(file.as_str());
1644            let (first_ts, last_ts) = self.execute_async(async {
1645                min_max_from_parquet_metadata_object_store(
1646                    self.object_store.clone(),
1647                    &object_path,
1648                    "ts_init",
1649                )
1650                .await
1651            })?;
1652
1653            let new_filename =
1654                timestamps_to_filename(UnixNanos::from(first_ts), UnixNanos::from(last_ts));
1655            let new_file_path = make_object_store_path(directory, &[&new_filename]);
1656            let new_object_path = ObjectPath::from(new_file_path);
1657
1658            self.move_file(&object_path, &new_object_path)?;
1659        }
1660
1661        let intervals = self.get_directory_intervals(directory)?;
1662
1663        if !are_intervals_disjoint(&intervals) {
1664            anyhow::bail!("Intervals are not disjoint after resetting file names");
1665        }
1666
1667        Ok(())
1668    }
1669
1670    /// Finds all leaf data directories in the catalog.
1671    ///
1672    /// A leaf directory is one that contains data files but no subdirectories.
1673    /// This method is used to identify directories that can be processed for
1674    /// consolidation or other operations.
1675    ///
1676    /// # Returns
1677    ///
1678    /// Returns a vector of directory path strings representing leaf directories,
1679    /// or an error if directory traversal fails.
1680    ///
1681    /// # Errors
1682    ///
1683    /// Returns an error if:
1684    /// - Object store listing operations fail.
1685    /// - Directory structure cannot be analyzed.
1686    ///
1687    /// # Examples
1688    ///
1689    /// ```rust,no_run
1690    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
1691    ///
1692    /// let catalog = ParquetDataCatalog::new(/* ... */);
1693    ///
1694    /// let leaf_dirs = catalog.find_leaf_data_directories()?;
1695    /// for dir in leaf_dirs {
1696    ///     println!("Found leaf directory: {}", dir);
1697    /// }
1698    /// # Ok::<(), anyhow::Error>(())
1699    /// ```
1700    pub fn find_leaf_data_directories(&self) -> anyhow::Result<Vec<String>> {
1701        let data_dir = make_object_store_path(&self.base_path, &["data"]);
1702
1703        let leaf_dirs = self.execute_async(async {
1704            let mut all_paths = AHashSet::new();
1705            let mut directories = IndexSet::new();
1706            let mut files_in_dirs = AHashMap::new();
1707
1708            // List all objects under the data directory
1709            let prefix = ObjectPath::from(format!("{data_dir}/"));
1710            let mut stream = self.object_store.list(Some(&prefix));
1711
1712            while let Some(object) = stream.next().await {
1713                let object = object?;
1714                let path_str = object.location.to_string();
1715                all_paths.insert(path_str.clone());
1716
1717                // Extract directory path
1718                if let Some(parent) = std::path::Path::new(&path_str).parent() {
1719                    let parent_str = parent.to_string_lossy().to_string();
1720                    directories.insert(parent_str.clone());
1721
1722                    // Track files in each directory
1723                    files_in_dirs
1724                        .entry(parent_str)
1725                        .or_insert_with(Vec::new)
1726                        .push(path_str);
1727                }
1728            }
1729
1730            // Find leaf directories (directories with files but no subdirectories)
1731            let mut leaf_dirs = Vec::new();
1732
1733            for dir in &directories {
1734                let has_files = files_in_dirs
1735                    .get(dir)
1736                    .is_some_and(|files| !files.is_empty());
1737                let has_subdirs = directories
1738                    .iter()
1739                    .any(|d| d.starts_with(&make_object_store_path(dir, &[""])) && d != dir);
1740
1741                if has_files && !has_subdirs {
1742                    leaf_dirs.push(dir.clone());
1743                }
1744            }
1745
1746            leaf_dirs.sort();
1747            Ok::<Vec<String>, anyhow::Error>(leaf_dirs)
1748        })?;
1749
1750        Ok(leaf_dirs)
1751    }
1752
1753    /// Deletes data within a specified time range for a specific data type and identifier.
1754    ///
1755    /// This method identifies all parquet files that intersect with the specified time range
1756    /// and handles them appropriately:
1757    /// - Files completely within the range are deleted
1758    /// - Files partially overlapping the range are split to preserve data outside the range
1759    /// - The original intersecting files are removed after processing
1760    ///
1761    /// # Parameters
1762    ///
1763    /// - `type_name`: The data type directory name (e.g., "quotes", "trades", "bars").
1764    /// - `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.
1765    /// - `start`: Optional start timestamp for the deletion range. If None, deletes from the beginning.
1766    /// - `end`: Optional end timestamp for the deletion range. If None, deletes to the end.
1767    ///
1768    /// # Returns
1769    ///
1770    /// Returns `Ok(())` on success, or an error if deletion fails.
1771    ///
1772    /// # Errors
1773    ///
1774    /// Returns an error if:
1775    /// - The directory path cannot be constructed.
1776    /// - File operations fail.
1777    /// - Data querying or writing fails.
1778    ///
1779    /// # Notes
1780    ///
1781    /// - This operation permanently removes data and cannot be undone.
1782    /// - Files that partially overlap the deletion range are split to preserve data outside the range.
1783    /// - The method ensures data integrity by using atomic operations where possible.
1784    /// - Empty directories are not automatically removed after deletion.
1785    ///
1786    /// # Examples
1787    ///
1788    /// ```rust,no_run
1789    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
1790    /// use nautilus_core::UnixNanos;
1791    ///
1792    /// let catalog = ParquetDataCatalog::new(/* ... */);
1793    ///
1794    /// // Delete all quote data for a specific instrument
1795    /// catalog.delete_data_range(
1796    ///     "quotes",
1797    ///     Some("BTCUSD".to_string()),
1798    ///     None,
1799    ///     None
1800    /// )?;
1801    ///
1802    /// // Delete trade data within a specific time range
1803    /// catalog.delete_data_range(
1804    ///     "trades",
1805    ///     None,
1806    ///     Some(UnixNanos::from(1609459200000000000)),
1807    ///     Some(UnixNanos::from(1609545600000000000))
1808    /// )?;
1809    /// # Ok::<(), anyhow::Error>(())
1810    /// ```
1811    pub fn delete_data_range(
1812        &mut self,
1813        type_name: &str,
1814        identifier: Option<&str>,
1815        start: Option<UnixNanos>,
1816        end: Option<UnixNanos>,
1817    ) -> anyhow::Result<()> {
1818        // Use match statement to call the generic delete_data_range for various types
1819        match type_name {
1820            "quotes" => self.delete_data_range_generic::<QuoteTick>(identifier, start, end),
1821            "trades" => self.delete_data_range_generic::<TradeTick>(identifier, start, end),
1822            "bars" => self.delete_data_range_generic::<Bar>(identifier, start, end),
1823            "order_book_deltas" => {
1824                self.delete_data_range_generic::<OrderBookDelta>(identifier, start, end)
1825            }
1826            "order_book_depth10" => {
1827                self.delete_data_range_generic::<OrderBookDepth10>(identifier, start, end)
1828            }
1829            _ => {
1830                if let Some(custom_type_name) = type_name.strip_prefix("custom/") {
1831                    self.delete_custom_data_range(custom_type_name, identifier, start, end)
1832                } else {
1833                    anyhow::bail!("Unsupported data type: {type_name}");
1834                }
1835            }
1836        }
1837    }
1838
1839    /// Deletes data within a specified time range across the entire catalog.
1840    ///
1841    /// This method identifies all leaf directories in the catalog that contain parquet files
1842    /// and deletes data within the specified time range from each directory. A leaf directory
1843    /// is one that contains files but no subdirectories. This is a convenience method that
1844    /// effectively calls `delete_data_range` for all data types and instrument IDs in the catalog.
1845    ///
1846    /// # Parameters
1847    ///
1848    /// - `start`: Optional start timestamp for the deletion range. If None, deletes from the beginning.
1849    /// - `end`: Optional end timestamp for the deletion range. If None, deletes to the end.
1850    ///
1851    /// # Returns
1852    ///
1853    /// Returns `Ok(())` on success, or an error if deletion fails.
1854    ///
1855    /// # Errors
1856    ///
1857    /// Returns an error if:
1858    /// - Directory traversal fails.
1859    /// - Data class extraction from paths fails.
1860    /// - Individual delete operations fail.
1861    ///
1862    /// # Notes
1863    ///
1864    /// - This operation permanently removes data and cannot be undone.
1865    /// - The deletion process handles file intersections intelligently by splitting files
1866    ///   when they partially overlap with the deletion range.
1867    /// - Files completely within the deletion range are removed entirely.
1868    /// - Files partially overlapping the deletion range are split to preserve data outside the range.
1869    /// - This method is useful for bulk data cleanup operations across the entire catalog.
1870    /// - Empty directories are not automatically removed after deletion.
1871    ///
1872    /// # Examples
1873    ///
1874    /// ```rust,no_run
1875    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
1876    /// use nautilus_core::UnixNanos;
1877    ///
1878    /// let mut catalog = ParquetDataCatalog::new(/* ... */);
1879    ///
1880    /// // Delete all data before a specific date across entire catalog
1881    /// catalog.delete_catalog_range(
1882    ///     None,
1883    ///     Some(UnixNanos::from(1609459200000000000))
1884    /// )?;
1885    ///
1886    /// // Delete all data within a specific range across entire catalog
1887    /// catalog.delete_catalog_range(
1888    ///     Some(UnixNanos::from(1609459200000000000)),
1889    ///     Some(UnixNanos::from(1609545600000000000))
1890    /// )?;
1891    ///
1892    /// // Delete all data after a specific date across entire catalog
1893    /// catalog.delete_catalog_range(
1894    ///     Some(UnixNanos::from(1609459200000000000)),
1895    ///     None
1896    /// )?;
1897    /// # Ok::<(), anyhow::Error>(())
1898    /// ```
1899    pub fn delete_catalog_range(
1900        &mut self,
1901        start: Option<UnixNanos>,
1902        end: Option<UnixNanos>,
1903    ) -> anyhow::Result<()> {
1904        let leaf_directories = self.find_leaf_data_directories()?;
1905
1906        for directory in leaf_directories {
1907            if let Ok((Some(data_type), identifier)) =
1908                self.extract_data_cls_and_identifier_from_path(&directory)
1909            {
1910                // Call the existing delete_data_range method
1911                if let Err(e) =
1912                    self.delete_data_range(&data_type, identifier.as_deref(), start, end)
1913                {
1914                    log::warn!("Failed to delete data in directory {directory}: {e}");
1915                    // Continue with other directories instead of failing completely
1916                }
1917            }
1918        }
1919
1920        Ok(())
1921    }
1922
1923    /// Generic implementation for deleting data within a specified time range.
1924    ///
1925    /// This method provides the core deletion logic that works with any data type
1926    /// that implements the required traits. It handles file intersection analysis,
1927    /// data splitting for partial overlaps, and file cleanup.
1928    ///
1929    /// # Type Parameters
1930    ///
1931    /// - `T`: The data type that implements required traits for catalog operations.
1932    ///
1933    /// # Parameters
1934    ///
1935    /// - `identifier`: Optional instrument ID to delete data for.
1936    /// - `start`: Optional start timestamp for the deletion range.
1937    /// - `end`: Optional end timestamp for the deletion range.
1938    ///
1939    /// # Returns
1940    ///
1941    /// Returns `Ok(())` on success, or an error if deletion fails.
1942    ///
1943    /// # Errors
1944    ///
1945    /// Returns an error if interval lookup, delete planning, file removal, or
1946    /// rewritten data writes fail.
1947    pub fn delete_data_range_generic<T>(
1948        &mut self,
1949        identifier: Option<&str>,
1950        start: Option<UnixNanos>,
1951        end: Option<UnixNanos>,
1952    ) -> anyhow::Result<()>
1953    where
1954        T: DecodeDataFromRecordBatch
1955            + CatalogPathPrefix
1956            + EncodeToRecordBatch
1957            + HasTsInit
1958            + TryFrom<Data>
1959            + Clone,
1960    {
1961        // Get intervals for cleaner implementation
1962        let intervals = self.get_intervals(T::path_prefix(), identifier)?;
1963
1964        if intervals.is_empty() {
1965            return Ok(()); // No files to process
1966        }
1967
1968        // Prepare all operations for execution
1969        let operations_to_execute =
1970            self.prepare_delete_operations(T::path_prefix(), identifier, &intervals, start, end)?;
1971
1972        if operations_to_execute.is_empty() {
1973            return Ok(()); // No operations to execute
1974        }
1975
1976        // Execute all operations
1977        let mut files_to_remove = AHashSet::<String>::new();
1978
1979        for operation in operations_to_execute {
1980            // Reset the session before each operation to ensure fresh data is loaded
1981            // This clears any cached table registrations that might interfere with file operations
1982            self.reset_session();
1983
1984            match operation.operation_type.as_str() {
1985                "split_before" => {
1986                    // Query data before the deletion range and write it
1987                    // Use optimize_file_loading=false for precise file control during split operations
1988                    let instrument_ids = identifier.map(|id| vec![id.to_string()]);
1989                    let before_data = self.query_typed_data::<T>(
1990                        instrument_ids,
1991                        Some(UnixNanos::from(operation.query_start)),
1992                        Some(UnixNanos::from(operation.query_end)),
1993                        None,
1994                        Some(operation.files.clone()),
1995                        false, // optimize_file_loading=false for precise file control
1996                    )?;
1997
1998                    if !before_data.is_empty() {
1999                        let start_ts = UnixNanos::from(operation.file_start_ns);
2000                        let end_ts = UnixNanos::from(operation.file_end_ns);
2001                        self.write_to_parquet(
2002                            &before_data,
2003                            Some(start_ts),
2004                            Some(end_ts),
2005                            Some(true),
2006                        )?;
2007                    }
2008                }
2009                "split_after" => {
2010                    // Query data after the deletion range and write it
2011                    // Use optimize_file_loading=false for precise file control during split operations
2012                    let instrument_ids = identifier.map(|id| vec![id.to_string()]);
2013                    let after_data = self.query_typed_data::<T>(
2014                        instrument_ids,
2015                        Some(UnixNanos::from(operation.query_start)),
2016                        Some(UnixNanos::from(operation.query_end)),
2017                        None,
2018                        Some(operation.files.clone()),
2019                        false, // optimize_file_loading=false for precise file control
2020                    )?;
2021
2022                    if !after_data.is_empty() {
2023                        let start_ts = UnixNanos::from(operation.file_start_ns);
2024                        let end_ts = UnixNanos::from(operation.file_end_ns);
2025                        self.write_to_parquet(
2026                            &after_data,
2027                            Some(start_ts),
2028                            Some(end_ts),
2029                            Some(true),
2030                        )?;
2031                    }
2032                }
2033                _ => {
2034                    // For "remove" operations, just mark files for removal
2035                }
2036            }
2037
2038            // Mark files for removal (applies to all operation types)
2039            for file in operation.files {
2040                files_to_remove.insert(file);
2041            }
2042        }
2043
2044        // Remove all files that were processed
2045        for file in files_to_remove {
2046            if let Err(e) = self.delete_file(&file) {
2047                log::warn!("Failed to delete file {file}: {e}");
2048            }
2049        }
2050
2051        Ok(())
2052    }
2053
2054    /// Prepares all operations for data deletion by identifying files that need to be
2055    /// split or removed.
2056    ///
2057    /// This auxiliary function handles all the preparation logic for deletion:
2058    /// 1. Filters intervals by time range
2059    /// 2. Identifies files that intersect with the deletion range
2060    /// 3. Creates split operations for files that partially overlap
2061    /// 4. Generates removal operations for files completely within the range
2062    ///
2063    /// # Parameters
2064    ///
2065    /// - `type_name`: The data type directory name for path generation.
2066    /// - `identifier`: Optional instrument identifier for path generation.
2067    /// - `intervals`: List of (`start_ts`, `end_ts`) tuples representing existing file intervals.
2068    /// - `start`: Optional start timestamp for deletion range.
2069    /// - `end`: Optional end timestamp for deletion range.
2070    ///
2071    /// # Returns
2072    ///
2073    /// Returns a vector of `DeleteOperation` structs ready for execution.
2074    ///
2075    /// # Errors
2076    ///
2077    /// Returns an error if target path construction fails.
2078    pub fn prepare_delete_operations(
2079        &self,
2080        type_name: &str,
2081        identifier: Option<&str>,
2082        intervals: &[(u64, u64)],
2083        start: Option<UnixNanos>,
2084        end: Option<UnixNanos>,
2085    ) -> anyhow::Result<Vec<DeleteOperation>> {
2086        // Convert start/end to nanoseconds
2087        let delete_start_ns = start.map(|s| s.as_u64());
2088        let delete_end_ns = end.map(|e| e.as_u64());
2089
2090        let mut operations = Vec::new();
2091
2092        // Get directory for file path construction
2093        let directory = self.make_path(type_name, identifier)?;
2094
2095        // Process each interval (which represents an actual file)
2096        for &(file_start_ns, file_end_ns) in intervals {
2097            // Check if file intersects with deletion range
2098            let intersects = delete_start_ns
2099                .is_none_or(|delete_start_ns| delete_start_ns <= file_end_ns)
2100                && delete_end_ns.is_none_or(|delete_end_ns| file_start_ns <= delete_end_ns);
2101
2102            if !intersects {
2103                continue; // File doesn't intersect with deletion range
2104            }
2105
2106            // Construct file path from interval timestamps
2107            let filename = timestamps_to_filename(
2108                UnixNanos::from(file_start_ns),
2109                UnixNanos::from(file_end_ns),
2110            );
2111            let file_path = make_object_store_path(&directory, &[&filename]);
2112
2113            // Determine what type of operation is needed
2114            let file_completely_within_range = delete_start_ns
2115                .is_none_or(|delete_start_ns| delete_start_ns <= file_start_ns)
2116                && delete_end_ns.is_none_or(|delete_end_ns| file_end_ns <= delete_end_ns);
2117
2118            if file_completely_within_range {
2119                // File is completely within deletion range - just mark for removal
2120                operations.push(DeleteOperation {
2121                    operation_type: "remove".to_string(),
2122                    files: vec![file_path],
2123                    query_start: 0,
2124                    query_end: 0,
2125                    file_start_ns: 0,
2126                    file_end_ns: 0,
2127                });
2128            } else {
2129                // File partially overlaps - need to split
2130                if let Some(delete_start) = delete_start_ns
2131                    && file_start_ns < delete_start
2132                {
2133                    // Keep data before deletion range
2134                    operations.push(DeleteOperation {
2135                        operation_type: "split_before".to_string(),
2136                        files: vec![file_path.clone()],
2137                        query_start: file_start_ns,
2138                        query_end: delete_start.saturating_sub(1), // Exclusive end
2139                        file_start_ns,
2140                        file_end_ns: delete_start.saturating_sub(1),
2141                    });
2142                }
2143
2144                if let Some(delete_end) = delete_end_ns
2145                    && delete_end < file_end_ns
2146                {
2147                    // Keep data after deletion range
2148                    operations.push(DeleteOperation {
2149                        operation_type: "split_after".to_string(),
2150                        files: vec![file_path.clone()],
2151                        query_start: delete_end.saturating_add(1), // Exclusive start
2152                        query_end: file_end_ns,
2153                        file_start_ns: delete_end.saturating_add(1),
2154                        file_end_ns,
2155                    });
2156                }
2157            }
2158        }
2159
2160        Ok(operations)
2161    }
2162}