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}