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