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