Skip to main content

nautilus_persistence/backend/parquet/
consolidation.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//! Period-based and bulk consolidation of parquet files in a catalog directory.
17
18#![expect(
19    clippy::missing_errors_doc,
20    reason = "consolidation operations forward catalog/storage errors and operate on validated batches"
21)]
22
23use ahash::AHashSet;
24use arrow::record_batch::RecordBatch;
25use nautilus_core::{ClosedInterval, UnixNanos, string::conversions::to_snake_case};
26use nautilus_model::data::{
27    Bar, CustomData, Data, HasTsInit, IndexPriceUpdate, MarkPriceUpdate, NautilusDataType,
28    OrderBookDelta, OrderBookDepth, QuoteTick, TradeTick, close::InstrumentClose,
29};
30use nautilus_serialization::arrow::{
31    DecodeTypedFromRecordBatch, EncodeToRecordBatch, catalog_identifier_from_metadata,
32};
33use object_store::path::Path as ObjectPath;
34
35use crate::{
36    backend::parquet::{
37        catalog::ParquetDataCatalog,
38        intervals::{are_intervals_contiguous, are_intervals_disjoint},
39        io::combine_parquet_files_from_object_store,
40        paths::{
41            extract_path_components, make_object_store_path, normalize_path_separators,
42            parse_filename_timestamps, timestamps_to_filename,
43        },
44    },
45    catalog::types::{
46        CatalogDataType, HasCatalogDataType, parquet_catalog_data_type_path_prefixes,
47        parquet_data_path_prefix,
48    },
49    common::coverage::merge_closed_intervals,
50};
51
52/// Information about a consolidation query to be executed.
53#[derive(Debug, Clone)]
54pub struct ConsolidationQuery {
55    /// Start timestamp for the query range (inclusive, in nanoseconds).
56    pub query_start: u64,
57    /// End timestamp for the query range (inclusive, in nanoseconds).
58    pub query_end: u64,
59    /// Whether to use period boundaries for file naming (true) or actual data timestamps (false).
60    pub use_period_boundaries: bool,
61}
62
63impl ParquetDataCatalog {
64    /// Consolidates all data files in the catalog.
65    ///
66    /// This method identifies all leaf directories in the catalog that contain parquet files
67    /// and consolidates them. A leaf directory is one that contains files but no subdirectories.
68    /// This is a convenience method that effectively calls `consolidate_data` for all data types
69    /// and instrument IDs in the catalog.
70    ///
71    /// # Parameters
72    ///
73    /// - `start`: Optional start timestamp for the consolidation range. Only files with timestamps
74    ///   greater than or equal to this value will be consolidated. If None, all files
75    ///   from the beginning of time will be considered.
76    /// - `end`: Optional end timestamp for the consolidation range. Only files with timestamps
77    ///   less than or equal to this value will be consolidated. If None, all files
78    ///   up to the end of time will be considered.
79    /// - `ensure_contiguous_files`: Whether to validate that consolidated intervals are contiguous (default: true).
80    ///
81    /// # Returns
82    ///
83    /// Returns `Ok(())` on success, or an error if consolidation fails for any directory.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if:
88    /// - Directory listing fails.
89    /// - File consolidation operations fail.
90    /// - Interval validation fails (when `ensure_contiguous_files` is true).
91    ///
92    /// # Examples
93    ///
94    /// ```rust,no_run
95    /// use nautilus_core::UnixNanos;
96    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
97    ///
98    /// let mut catalog = ParquetDataCatalog::new(
99    ///     std::path::Path::new("/tmp/nautilus_data"),
100    ///     None,
101    ///     None,
102    ///     None,
103    ///     None,
104    /// );
105    ///
106    /// // Consolidate all files in the catalog
107    /// catalog.consolidate_catalog(None, None, None, None)?;
108    ///
109    /// // Consolidate only files within a specific time range
110    /// catalog.consolidate_catalog(
111    ///     Some(UnixNanos::from(1609459200000000000)),
112    ///     Some(UnixNanos::from(1609545600000000000)),
113    ///     Some(true),
114    ///     None,
115    /// )?;
116    /// # Ok::<(), anyhow::Error>(())
117    /// ```
118    pub fn consolidate_catalog(
119        &self,
120        start: Option<UnixNanos>,
121        end: Option<UnixNanos>,
122        ensure_contiguous_files: Option<bool>,
123        deduplicate: Option<bool>,
124    ) -> anyhow::Result<()> {
125        let leaf_directories = self.find_leaf_data_directories()?;
126
127        for directory in leaf_directories {
128            self.consolidate_directory(
129                &directory,
130                start,
131                end,
132                ensure_contiguous_files,
133                deduplicate,
134            )?;
135        }
136
137        Ok(())
138    }
139
140    /// Consolidates data files for a specific data type and identifier.
141    ///
142    /// This method consolidates Parquet files within a specific directory (defined by data type
143    /// and optional identifier) by merging multiple files into a single file. This improves
144    /// query performance and can reduce storage overhead.
145    ///
146    /// # Parameters
147    ///
148    /// - `data_type`: The stored family to consolidate.
149    /// - `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").
150    /// - `start`: Optional start timestamp to limit consolidation to files within this range.
151    /// - `end`: Optional end timestamp to limit consolidation to files within this range.
152    /// - `ensure_contiguous_files`: Whether to validate that consolidated intervals are contiguous (default: true).
153    ///
154    /// # Returns
155    ///
156    /// Returns `Ok(())` on success, or an error if consolidation fails.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if:
161    /// - The directory path cannot be constructed.
162    /// - File consolidation operations fail.
163    /// - Interval validation fails (when `ensure_contiguous_files` is true).
164    ///
165    /// # Examples
166    ///
167    /// ```rust,no_run
168    /// use nautilus_core::UnixNanos;
169    /// use nautilus_model::data::NautilusDataType;
170    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
171    ///
172    /// let mut catalog = ParquetDataCatalog::new(
173    ///     std::path::Path::new("/tmp/nautilus_data"),
174    ///     None,
175    ///     None,
176    ///     None,
177    ///     None,
178    /// );
179    ///
180    /// // Consolidate all quote files for a specific instrument
181    /// catalog.consolidate_data(
182    ///     &NautilusDataType::QuoteTick.into(),
183    ///     Some("BTCUSD"),
184    ///     None,
185    ///     None,
186    ///     None,
187    ///     None,
188    /// )?;
189    ///
190    /// // Consolidate trade files within a time range
191    /// catalog.consolidate_data(
192    ///     &NautilusDataType::TradeTick.into(),
193    ///     None,
194    ///     Some(UnixNanos::from(1609459200000000000)),
195    ///     Some(UnixNanos::from(1609545600000000000)),
196    ///     Some(true),
197    ///     None,
198    /// )?;
199    /// # Ok::<(), anyhow::Error>(())
200    /// ```
201    pub fn consolidate_data(
202        &mut self,
203        data_type: &CatalogDataType,
204        identifier: Option<&str>,
205        start: Option<UnixNanos>,
206        end: Option<UnixNanos>,
207        ensure_contiguous_files: Option<bool>,
208        deduplicate: Option<bool>,
209    ) -> anyhow::Result<()> {
210        for type_name in parquet_catalog_data_type_path_prefixes(data_type) {
211            self.consolidate_prefix_data(
212                type_name.as_ref(),
213                identifier,
214                start,
215                end,
216                ensure_contiguous_files,
217                deduplicate,
218            )?;
219        }
220
221        Ok(())
222    }
223
224    fn consolidate_prefix_data(
225        &mut self,
226        type_name: &str,
227        identifier: Option<&str>,
228        start: Option<UnixNanos>,
229        end: Option<UnixNanos>,
230        ensure_contiguous_files: Option<bool>,
231        deduplicate: Option<bool>,
232    ) -> anyhow::Result<()> {
233        let directory = self.make_path(type_name, identifier)?;
234        let raw_result = self.consolidate_directory(
235            &directory,
236            start,
237            end,
238            ensure_contiguous_files,
239            deduplicate,
240        );
241
242        match raw_result {
243            Ok(()) => Ok(()),
244            Err(raw_error)
245                if can_rewrite_consolidation_by_period(type_name)
246                    && is_schema_incompatibility(&raw_error) =>
247            {
248                if deduplicate.unwrap_or(false) {
249                    anyhow::bail!(
250                        "Raw parquet consolidation failed due to incompatible file schemas, \
251                         but typed period consolidation cannot preserve deduplicate=true. \
252                         Raw consolidation error: {raw_error}"
253                    );
254                }
255
256                log::warn!(
257                    "Raw parquet consolidation failed due to incompatible file schemas for \
258                     {type_name}; retrying with typed period consolidation. Raw error: {raw_error}"
259                );
260
261                self.consolidate_prefix_data_by_period(
262                    type_name,
263                    identifier,
264                    None,
265                    start,
266                    end,
267                    ensure_contiguous_files,
268                )
269                .map_err(|typed_error| {
270                    anyhow::anyhow!(
271                        "Raw parquet consolidation failed due to incompatible file schemas, \
272                         and typed period consolidation also failed. Raw error: {raw_error}; \
273                         typed period error: {typed_error}"
274                    )
275                })
276            }
277            Err(e) => Err(e),
278        }
279    }
280
281    /// Consolidates Parquet files within a specific directory by merging them into a single file.
282    ///
283    /// This internal method performs the actual consolidation work for a single directory.
284    /// It identifies files within the specified time range, validates their intervals,
285    /// and combines them into a single Parquet file with optimized storage.
286    ///
287    /// # Parameters
288    ///
289    /// - `directory`: The directory path containing Parquet files to consolidate.
290    /// - `start`: Optional start timestamp to limit consolidation to files within this range.
291    /// - `end`: Optional end timestamp to limit consolidation to files within this range.
292    /// - `ensure_contiguous_files`: Whether to validate that consolidated intervals are contiguous.
293    ///
294    /// # Returns
295    ///
296    /// Returns `Ok(())` on success, or an error if consolidation fails.
297    ///
298    /// # Behavior
299    ///
300    /// - Skips consolidation if directory contains 1 or fewer files.
301    /// - Filters files by timestamp range if start/end are specified.
302    /// - Sorts intervals by start timestamp before consolidation.
303    /// - Creates a new file spanning the entire time range of input files.
304    /// - Validates interval disjointness after consolidation (if enabled).
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if:
309    /// - Directory listing fails.
310    /// - File combination operations fail.
311    /// - Interval validation fails (when `ensure_contiguous_files` is true).
312    /// - Object store operations fail.
313    fn consolidate_directory(
314        &self,
315        directory: &str,
316        start: Option<UnixNanos>,
317        end: Option<UnixNanos>,
318        ensure_contiguous_files: Option<bool>,
319        deduplicate: Option<bool>,
320    ) -> anyhow::Result<()> {
321        let parquet_files = self.list_parquet_files(directory)?;
322
323        if parquet_files.len() <= 1 {
324            return Ok(());
325        }
326
327        let mut files_to_consolidate = Vec::new();
328        let start = start.map(|t| t.as_u64());
329        let end = end.map(|t| t.as_u64());
330
331        for file in parquet_files {
332            let Some(interval) = parse_filename_timestamps(&file) else {
333                continue;
334            };
335
336            if start.is_none_or(|s| interval.0 >= s) && end.is_none_or(|e| interval.1 <= e) {
337                files_to_consolidate.push((file, interval));
338            }
339        }
340
341        files_to_consolidate.sort_by_key(|&(_, (interval_start, _))| interval_start);
342        let intervals: Vec<(u64, u64)> = files_to_consolidate
343            .iter()
344            .map(|&(_, interval)| interval)
345            .collect();
346
347        // Validate disjointness before merging so source files are left untouched on failure
348        if ensure_contiguous_files.unwrap_or(true) && !are_intervals_disjoint(&intervals) {
349            anyhow::bail!("Intervals are not disjoint before consolidating a directory");
350        }
351
352        let Some(last_end) = intervals.iter().map(|interval| interval.1).max() else {
353            return Ok(());
354        };
355
356        let file_name =
357            timestamps_to_filename(UnixNanos::from(intervals[0].0), UnixNanos::from(last_end));
358        let path = make_object_store_path(directory, [&file_name]);
359
360        let object_paths = files_to_consolidate
361            .iter()
362            .map(|(path, _)| self.to_object_path_parsed(path))
363            .collect::<anyhow::Result<Vec<_>>>()?;
364
365        self.execute_async(|| async {
366            combine_parquet_files_from_object_store(
367                self.object_store.clone(),
368                object_paths,
369                &ObjectPath::from(path),
370                Some(self.compression),
371                Some(self.max_row_group_size),
372                deduplicate,
373            )
374            .await
375        })
376    }
377
378    /// Consolidates all data files in the catalog by splitting them into fixed time periods.
379    ///
380    /// This method identifies all leaf directories in the catalog that contain parquet files
381    /// and consolidates them by period. A leaf directory is one that contains files but no subdirectories.
382    /// This is a convenience method that effectively calls `consolidate_data_by_period` for all data types
383    /// and instrument IDs in the catalog.
384    ///
385    /// # Parameters
386    ///
387    /// - `period_nanos`: The period duration for consolidation in nanoseconds. Default is 1 day (86400000000000).
388    ///   Examples: 3600000000000 (1 hour), 604800000000000 (7 days), 1800000000000 (30 minutes)
389    /// - `start`: Optional start timestamp for the consolidation range. Only files with timestamps
390    ///   greater than or equal to this value will be consolidated. If None, all files
391    ///   from the beginning of time will be considered.
392    /// - `end`: Optional end timestamp for the consolidation range. Only files with timestamps
393    ///   less than or equal to this value will be consolidated. If None, all files
394    ///   up to the end of time will be considered.
395    /// - `ensure_contiguous_files`: If true, uses period boundaries clipped to `start` and `end` for file naming.
396    ///   If false, uses actual data timestamps for file naming.
397    ///
398    /// # Returns
399    ///
400    /// Returns `Ok(())` on success, or an error if consolidation fails for any directory.
401    ///
402    /// # Errors
403    ///
404    /// Returns an error if:
405    /// - `period_nanos` is zero and a directory holds data to consolidate.
406    /// - Directory listing fails.
407    /// - Data type extraction from path fails.
408    /// - Period-based consolidation operations fail.
409    ///
410    /// # Notes
411    ///
412    /// - This operation can be resource-intensive for large catalogs with many data types.
413    ///   and instruments.
414    /// - The consolidation process splits data into fixed time periods rather than combining.
415    ///   all files into a single file per directory.
416    /// - Uses the same period-based consolidation logic as `consolidate_data_by_period`.
417    /// - Original files are removed and replaced with period-based consolidated files.
418    /// - This method is useful for periodic maintenance of the catalog to standardize.
419    ///   file organization by time periods.
420    ///
421    /// # Examples
422    ///
423    /// ```rust,no_run
424    /// use nautilus_core::UnixNanos;
425    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
426    ///
427    /// let mut catalog = ParquetDataCatalog::new(
428    ///     std::path::Path::new("/tmp/nautilus_data"),
429    ///     None,
430    ///     None,
431    ///     None,
432    ///     None,
433    /// );
434    ///
435    /// // Consolidate all files in the catalog by 1-day periods
436    /// catalog.consolidate_catalog_by_period(
437    ///     Some(86400000000000), // 1 day in nanoseconds
438    ///     None,
439    ///     None,
440    ///     Some(true),
441    /// )?;
442    ///
443    /// // Consolidate only files within a specific time range by 1-hour periods
444    /// catalog.consolidate_catalog_by_period(
445    ///     Some(3600000000000), // 1 hour in nanoseconds
446    ///     Some(UnixNanos::from(1609459200000000000)),
447    ///     Some(UnixNanos::from(1609545600000000000)),
448    ///     Some(false),
449    /// )?;
450    /// # Ok::<(), anyhow::Error>(())
451    /// ```
452    pub fn consolidate_catalog_by_period(
453        &mut self,
454        period_nanos: Option<u64>,
455        start: Option<UnixNanos>,
456        end: Option<UnixNanos>,
457        ensure_contiguous_files: Option<bool>,
458    ) -> anyhow::Result<()> {
459        let leaf_directories = self.find_leaf_data_directories()?;
460
461        for directory in leaf_directories {
462            let (Some(data_cls_name), identifier) =
463                self.extract_data_cls_and_identifier_from_path(&directory)?
464            else {
465                continue;
466            };
467
468            if !self.dispatch_consolidate_data_by_period(
469                &data_cls_name,
470                identifier.as_deref(),
471                period_nanos,
472                start,
473                end,
474                ensure_contiguous_files,
475            )? {
476                // Skip unknown data types
477                log::warn!("Unknown data type for consolidation: {data_cls_name}");
478            }
479        }
480
481        Ok(())
482    }
483
484    /// Extracts data class and identifier from a directory path.
485    ///
486    /// This method parses a directory path to extract the data type and optional
487    /// instrument identifier. It's used to determine what type of data consolidation
488    /// to perform for each directory.
489    ///
490    /// # Parameters
491    ///
492    /// - `path`: The directory path to parse.
493    ///
494    /// # Returns
495    ///
496    /// Returns a tuple of (`data_class`, identifier) where both are optional strings.
497    pub fn extract_data_cls_and_identifier_from_path(
498        &self,
499        path: &str,
500    ) -> anyhow::Result<(Option<String>, Option<String>)> {
501        // Parse from the catalog root so a base path containing a `data` segment is skipped
502        let path = self.path_without_local_base(&normalize_path_separators(path));
503        let path_components = extract_path_components(&path);
504
505        // Find the "data" directory in the path
506        if let Some(data_index) = path_components.iter().position(|part| part == "data")
507            && data_index + 1 < path_components.len()
508        {
509            let second = &path_components[data_index + 1];
510
511            if second == "custom" {
512                let Some(type_name) = path_components.get(data_index + 2) else {
513                    return Ok((None, None));
514                };
515
516                let identifier = (path_components.len() > data_index + 3)
517                    .then(|| path_components[data_index + 3..].join("/"));
518                return Ok((Some(format!("custom/{type_name}")), identifier));
519            }
520
521            let data_cls = second.clone();
522            let identifier = path_components.get(data_index + 2).cloned();
523
524            return Ok((Some(data_cls), identifier));
525        }
526
527        // If we can't parse the path, return None for both
528        Ok((None, None))
529    }
530
531    /// Consolidates data files by splitting them into fixed time periods.
532    ///
533    /// This method queries data by period and writes consolidated files immediately,
534    /// using efficient period-based consolidation logic. When start/end boundaries intersect existing files,
535    /// the function automatically splits those files to preserve all data.
536    ///
537    /// # Parameters
538    ///
539    /// - `data_type`: The stored family to consolidate.
540    /// - `identifier`: Optional instrument ID to consolidate. If None, consolidates all instruments.
541    /// - `period_nanos`: The period duration for consolidation in nanoseconds. Default is 1 day (86400000000000).
542    ///   Examples: 3600000000000 (1 hour), 604800000000000 (7 days), 1800000000000 (30 minutes)
543    /// - `start`: Optional start timestamp for consolidation range. If None, uses earliest available data.
544    ///   If specified and intersects existing files, those files will be split to preserve
545    ///   data outside the consolidation range.
546    /// - `end`: Optional end timestamp for consolidation range. If None, uses latest available data.
547    ///   If specified and intersects existing files, those files will be split to preserve
548    ///   data outside the consolidation range.
549    /// - `ensure_contiguous_files`: If true, uses period boundaries clipped to `start` and `end` for file naming.
550    ///   If false, uses actual data timestamps for file naming.
551    ///
552    /// # Returns
553    ///
554    /// Returns `Ok(())` on success, or an error if consolidation fails.
555    ///
556    /// # Errors
557    ///
558    /// Returns an error if:
559    /// - `period_nanos` is zero and the directory holds data to consolidate.
560    /// - `start` exceeds `end` and the directory holds data to consolidate.
561    /// - `data_type` is a record family or an instrument selector, which have no
562    ///   period-typed rewrite; use [`Self::consolidate_data`] for those.
563    /// - The directory path cannot be constructed.
564    /// - File operations fail.
565    /// - Data querying or writing fails.
566    ///
567    /// # Notes
568    ///
569    /// - Uses two-phase approach: first determines all queries, then executes them.
570    /// - Groups intervals into contiguous groups to preserve holes between groups.
571    /// - Allows consolidation across multiple files within each contiguous group.
572    /// - Validates existing targets before keeping them and removing their source fragments.
573    /// - Removes source files only after processing their entire time range.
574    /// - When `ensure_contiguous_files=false`, file timestamps match actual data range.
575    /// - When `ensure_contiguous_files=true`, file timestamps use period boundaries clipped to `start` and `end`.
576    /// - Existing-target checks compare row values without counting identical copies.
577    /// - Uses modulo arithmetic for efficient period boundary calculation.
578    /// - Preserves holes in data by preventing queries from spanning across gaps.
579    /// - Automatically splits files at start/end boundaries to preserve all data.
580    /// - Split operations are executed before consolidation to ensure data preservation.
581    ///
582    /// # Examples
583    ///
584    /// ```rust,no_run
585    /// use nautilus_core::UnixNanos;
586    /// use nautilus_model::data::NautilusDataType;
587    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
588    ///
589    /// let mut catalog = ParquetDataCatalog::new(
590    ///     std::path::Path::new("/tmp/nautilus_data"),
591    ///     None,
592    ///     None,
593    ///     None,
594    ///     None,
595    /// );
596    ///
597    /// // Consolidate all quote files by 1-day periods
598    /// catalog.consolidate_data_by_period(
599    ///     &NautilusDataType::QuoteTick.into(),
600    ///     None,
601    ///     Some(86400000000000), // 1 day in nanoseconds
602    ///     None,
603    ///     None,
604    ///     Some(true),
605    /// )?;
606    ///
607    /// // Consolidate specific instrument by 1-hour periods
608    /// catalog.consolidate_data_by_period(
609    ///     &NautilusDataType::TradeTick.into(),
610    ///     Some("BTCUSD"),
611    ///     Some(3600000000000), // 1 hour in nanoseconds
612    ///     Some(UnixNanos::from(1609459200000000000)),
613    ///     Some(UnixNanos::from(1609545600000000000)),
614    ///     Some(false),
615    /// )?;
616    /// # Ok::<(), anyhow::Error>(())
617    /// ```
618    pub fn consolidate_data_by_period(
619        &mut self,
620        data_type: &CatalogDataType,
621        identifier: Option<&str>,
622        period_nanos: Option<u64>,
623        start: Option<UnixNanos>,
624        end: Option<UnixNanos>,
625        ensure_contiguous_files: Option<bool>,
626    ) -> anyhow::Result<()> {
627        anyhow::ensure!(
628            matches!(
629                data_type,
630                CatalogDataType::Data(data_type) if *data_type != NautilusDataType::Instrument
631            ),
632            "Period consolidation applies to data families only, not {data_type}; \
633             use consolidate_data",
634        );
635
636        for type_name in parquet_catalog_data_type_path_prefixes(data_type) {
637            self.consolidate_prefix_data_by_period(
638                type_name.as_ref(),
639                identifier,
640                period_nanos,
641                start,
642                end,
643                ensure_contiguous_files,
644            )?;
645        }
646
647        Ok(())
648    }
649
650    fn consolidate_prefix_data_by_period(
651        &mut self,
652        type_name: &str,
653        identifier: Option<&str>,
654        period_nanos: Option<u64>,
655        start: Option<UnixNanos>,
656        end: Option<UnixNanos>,
657        ensure_contiguous_files: Option<bool>,
658    ) -> anyhow::Result<()> {
659        if !self.dispatch_consolidate_data_by_period(
660            type_name,
661            identifier,
662            period_nanos,
663            start,
664            end,
665            ensure_contiguous_files,
666        )? {
667            anyhow::bail!("Unknown data type for consolidation: {type_name}");
668        }
669
670        Ok(())
671    }
672
673    /// Dispatches period-based consolidation for `type_name`, returning `Ok(false)` for
674    /// unknown data types so callers choose whether to warn or fail.
675    fn dispatch_consolidate_data_by_period(
676        &mut self,
677        type_name: &str,
678        identifier: Option<&str>,
679        period_nanos: Option<u64>,
680        start: Option<UnixNanos>,
681        end: Option<UnixNanos>,
682        ensure_contiguous_files: Option<bool>,
683    ) -> anyhow::Result<bool> {
684        match type_name {
685            "quotes" => {
686                self.consolidate_data_by_period_generic::<QuoteTick>(
687                    identifier,
688                    period_nanos,
689                    start,
690                    end,
691                    ensure_contiguous_files,
692                )?;
693            }
694            "trades" => {
695                self.consolidate_data_by_period_generic::<TradeTick>(
696                    identifier,
697                    period_nanos,
698                    start,
699                    end,
700                    ensure_contiguous_files,
701                )?;
702            }
703            "order_book_deltas" => {
704                self.consolidate_data_by_period_generic::<OrderBookDelta>(
705                    identifier,
706                    period_nanos,
707                    start,
708                    end,
709                    ensure_contiguous_files,
710                )?;
711            }
712            "order_book_depths" => {
713                self.consolidate_data_by_period_generic::<OrderBookDepth>(
714                    identifier,
715                    period_nanos,
716                    start,
717                    end,
718                    ensure_contiguous_files,
719                )?;
720            }
721            "bars" => {
722                self.consolidate_data_by_period_generic::<Bar>(
723                    identifier,
724                    period_nanos,
725                    start,
726                    end,
727                    ensure_contiguous_files,
728                )?;
729            }
730            "index_prices" => {
731                self.consolidate_data_by_period_generic::<IndexPriceUpdate>(
732                    identifier,
733                    period_nanos,
734                    start,
735                    end,
736                    ensure_contiguous_files,
737                )?;
738            }
739            "mark_prices" => {
740                self.consolidate_data_by_period_generic::<MarkPriceUpdate>(
741                    identifier,
742                    period_nanos,
743                    start,
744                    end,
745                    ensure_contiguous_files,
746                )?;
747            }
748            "instrument_closes" => {
749                self.consolidate_data_by_period_generic::<InstrumentClose>(
750                    identifier,
751                    period_nanos,
752                    start,
753                    end,
754                    ensure_contiguous_files,
755                )?;
756            }
757            _ => {
758                if let Some(custom_type_name) = type_name.strip_prefix("custom/") {
759                    self.consolidate_custom_data_by_period(
760                        custom_type_name,
761                        identifier,
762                        period_nanos,
763                        start,
764                        end,
765                        ensure_contiguous_files,
766                    )?;
767                } else {
768                    return Ok(false);
769                }
770            }
771        }
772
773        Ok(true)
774    }
775
776    /// Generic consolidate data files by splitting them into fixed time periods.
777    ///
778    /// This is a type-safe version of `consolidate_data_by_period` that uses generic types
779    /// to ensure compile-time correctness and enable reuse across different data types.
780    ///
781    /// # Type Parameters
782    ///
783    /// - `T`: The data type to consolidate, must implement required traits for serialization.
784    ///
785    /// # Parameters
786    ///
787    /// - `identifier`: Optional instrument ID to target a specific instrument's data.
788    /// - `period_nanos`: Optional period size in nanoseconds (default: 1 day).
789    /// - `start`: Optional start timestamp for consolidation range.
790    /// - `end`: Optional end timestamp for consolidation range.
791    /// - `ensure_contiguous_files`: Optional flag to control file naming strategy.
792    ///
793    /// # Returns
794    ///
795    /// Returns `Ok(())` on success, or an error if consolidation fails.
796    pub fn consolidate_data_by_period_generic<T>(
797        &mut self,
798        identifier: Option<&str>,
799        period_nanos: Option<u64>,
800        start: Option<UnixNanos>,
801        end: Option<UnixNanos>,
802        ensure_contiguous_files: Option<bool>,
803    ) -> anyhow::Result<()>
804    where
805        T: DecodeTypedFromRecordBatch
806            + HasCatalogDataType
807            + EncodeToRecordBatch
808            + HasTsInit
809            + TryFrom<Data>
810            + Clone,
811    {
812        let period_nanos = period_nanos.unwrap_or(86_400_000_000_000); // Default: 1 day
813        let ensure_contiguous_files = ensure_contiguous_files.unwrap_or(true);
814
815        // Use get_intervals for cleaner implementation
816        let data_type = T::catalog_data_type();
817        let path_prefix = parquet_data_path_prefix(&data_type);
818        let intervals =
819            self.get_intervals(&CatalogDataType::Data(data_type.clone()), identifier)?;
820
821        if intervals.is_empty() {
822            return Ok(()); // No files to consolidate
823        }
824
825        // Use auxiliary function to prepare all queries for execution
826        let queries_to_execute = self.prepare_consolidation_queries(
827            path_prefix.as_ref(),
828            identifier,
829            &intervals,
830            period_nanos,
831            start,
832            end,
833            ensure_contiguous_files,
834        )?;
835
836        if queries_to_execute.is_empty() {
837            return Ok(()); // No queries to execute
838        }
839
840        // Get directory for file operations
841        let directory = self.make_path(path_prefix.as_ref(), identifier)?;
842        let mut existing_files = self.list_parquet_files(&directory)?;
843        existing_files.sort();
844
845        // Simulate source removals and target creation before mutating storage, so validation
846        // observes the same files as execution while holding only one period's rows.
847        let type_name = to_snake_case(std::any::type_name::<T>());
848
849        for write_files in [false, true] {
850            let mut files = ConsolidationFiles::new(&existing_files, start, end);
851            let mut file_start_ns: Option<u64> = None;
852
853            for query_info in &queries_to_execute {
854                let period_data = self.query_period::<T>(identifier, query_info, &files.sources)?;
855
856                let (Some(first), Some(last)) = (period_data.first(), period_data.last()) else {
857                    if query_info.use_period_boundaries {
858                        file_start_ns.get_or_insert(query_info.query_start);
859                    }
860
861                    files.complete(self, query_info, write_files)?;
862                    continue;
863                };
864
865                let (start_ts, end_ts) =
866                    query_info.file_range(&mut file_start_ns, first.ts_init(), last.ts_init());
867
868                Self::check_ascending_timestamps(&period_data, &type_name)?;
869                Self::check_chunk_identity(&period_data, &type_name)?;
870                let batches = self.data_to_record_batches(&period_data)?;
871                let metadata = T::chunk_metadata(&period_data);
872                let target_identifier = catalog_identifier_from_metadata(&metadata);
873                let target_directory =
874                    self.make_path(path_prefix.as_ref(), target_identifier.as_deref())?;
875                let target_filename = make_object_store_path(
876                    &target_directory,
877                    [timestamps_to_filename(start_ts, end_ts)],
878                );
879                let target = self.to_object_path(&target_filename)?.to_string();
880
881                if files.prepare_target(self, &target, &batches)? && write_files {
882                    self.write_parquet_file_checked(
883                        &target_directory,
884                        start_ts,
885                        end_ts,
886                        &batches,
887                        true,
888                        "File",
889                        Some(&format!("{type_name} data")),
890                        None,
891                    )?;
892                }
893
894                files.complete(self, query_info, write_files)?;
895
896                // Reset so next period starts a new contiguous segment
897                file_start_ns = None;
898            }
899        }
900
901        Ok(())
902    }
903
904    /// Consolidates custom data files by splitting them into fixed time periods.
905    ///
906    /// This method provides consolidation for custom data types that don't have compile-time
907    /// type information. It uses dynamic querying and writing methods.
908    ///
909    /// # Parameters
910    ///
911    /// - `type_name`: The custom data type name (without "custom/" prefix).
912    /// - `identifier`: Optional instrument ID to consolidate.
913    /// - `period_nanos`: Optional period size in nanoseconds (default: 1 day).
914    /// - `start`: Optional start timestamp for consolidation range.
915    /// - `end`: Optional end timestamp for consolidation range.
916    /// - `ensure_contiguous_files`: Optional flag to control file naming strategy.
917    ///
918    /// # Returns
919    ///
920    /// Returns `Ok(())` on success, or an error if consolidation fails.
921    fn consolidate_custom_data_by_period(
922        &mut self,
923        type_name: &str,
924        identifier: Option<&str>,
925        period_nanos: Option<u64>,
926        start: Option<UnixNanos>,
927        end: Option<UnixNanos>,
928        ensure_contiguous_files: Option<bool>,
929    ) -> anyhow::Result<()> {
930        let period_nanos = period_nanos.unwrap_or(86_400_000_000_000); // Default: 1 day
931        let ensure_contiguous_files = ensure_contiguous_files.unwrap_or(true);
932
933        // Get intervals for the custom data type
934        let data_type = NautilusDataType::Custom {
935            type_name: type_name.to_string(),
936        };
937
938        let path_prefix = parquet_data_path_prefix(&data_type);
939        let intervals = self.get_intervals(&CatalogDataType::Data(data_type), identifier)?;
940
941        if intervals.is_empty() {
942            return Ok(()); // No files to consolidate
943        }
944
945        // Use auxiliary function to prepare all queries for execution
946        let queries_to_execute = self.prepare_consolidation_queries(
947            path_prefix.as_ref(),
948            identifier,
949            &intervals,
950            period_nanos,
951            start,
952            end,
953            ensure_contiguous_files,
954        )?;
955
956        if queries_to_execute.is_empty() {
957            return Ok(()); // No queries to execute
958        }
959
960        // Get directory for file operations
961        let directory = self.make_path(path_prefix.as_ref(), identifier)?;
962        let mut existing_files = self.list_parquet_files(&directory)?;
963        existing_files.sort();
964
965        // Simulate source removals and target creation before mutating storage, so validation
966        // observes the same files as execution while holding only one period's rows.
967        for write_files in [false, true] {
968            let mut files = ConsolidationFiles::new(&existing_files, start, end);
969            let mut file_start_ns: Option<u64> = None;
970
971            for query_info in &queries_to_execute {
972                let custom_items =
973                    self.query_custom_period(type_name, identifier, query_info, &files.sources)?;
974
975                let (Some(first), Some(last)) = (custom_items.first(), custom_items.last()) else {
976                    if query_info.use_period_boundaries {
977                        file_start_ns.get_or_insert(query_info.query_start);
978                    }
979
980                    files.complete(self, query_info, write_files)?;
981                    continue;
982                };
983
984                let (start_ts, end_ts) = query_info.file_range(
985                    &mut file_start_ns,
986                    first.data.ts_init(),
987                    last.data.ts_init(),
988                );
989
990                self.consolidate_custom_period(
991                    &mut files,
992                    &custom_items,
993                    start_ts,
994                    end_ts,
995                    write_files,
996                )?;
997                files.complete(self, query_info, write_files)?;
998
999                // Reset so next period starts a new contiguous segment
1000                file_start_ns = None;
1001            }
1002        }
1003
1004        Ok(())
1005    }
1006
1007    fn consolidate_custom_period(
1008        &self,
1009        files: &mut ConsolidationFiles,
1010        rows: &[CustomData],
1011        start: UnixNanos,
1012        end: UnixNanos,
1013        write_files: bool,
1014    ) -> anyhow::Result<()> {
1015        let writes = self.prepare_custom_data_groups(rows, Some(start), Some(end), Some(true))?;
1016
1017        for write in &writes {
1018            let target = self
1019                .to_object_path(&write.path().to_string_lossy())?
1020                .to_string();
1021
1022            if files.prepare_target(self, &target, &write.batches)? && write_files {
1023                self.write_custom_data_prepared(write, Some(true))?;
1024            }
1025        }
1026
1027        Ok(())
1028    }
1029
1030    // Queries the data of one consolidation period from `files` only.
1031    fn query_period<T>(
1032        &mut self,
1033        identifier: Option<&str>,
1034        query_info: &ConsolidationQuery,
1035        files: &[String],
1036    ) -> anyhow::Result<Vec<T>>
1037    where
1038        T: DecodeTypedFromRecordBatch + HasCatalogDataType + HasTsInit,
1039    {
1040        let instrument_ids = identifier.map(|id| vec![id.to_string()]);
1041
1042        // Use optimize_file_loading=false to match Python behavior:
1043        // During consolidation, we want to read only the specific files being consolidated,
1044        // not the entire directory. This ensures precise file control during consolidation.
1045        self.query::<T>(
1046            instrument_ids,
1047            Some(UnixNanos::from(query_info.query_start)),
1048            Some(UnixNanos::from(query_info.query_end)),
1049            None,
1050            Some(files.to_vec()),
1051            false, // optimize_file_loading=false for precise file control during consolidation
1052        )
1053    }
1054
1055    // Queries the custom data of one consolidation period from `files` only.
1056    fn query_custom_period(
1057        &mut self,
1058        type_name: &str,
1059        identifier: Option<&str>,
1060        query_info: &ConsolidationQuery,
1061        files: &[String],
1062    ) -> anyhow::Result<Vec<CustomData>> {
1063        let instrument_ids = identifier.map(|id| vec![id.to_string()]);
1064        let period_data = self.query_custom_data_dynamic(
1065            type_name,
1066            instrument_ids.as_deref(),
1067            Some(UnixNanos::from(query_info.query_start)),
1068            Some(UnixNanos::from(query_info.query_end)),
1069            None,
1070            Some(files.to_vec()),
1071            false, // optimize_file_loading=false for precise file control during consolidation
1072        )?;
1073
1074        Ok(period_data
1075            .into_iter()
1076            .filter_map(|data| match data {
1077                Data::Custom(c) => Some(c),
1078                _ => None,
1079            })
1080            .collect())
1081    }
1082
1083    /// Prepares all queries for consolidation by filtering, grouping, and handling splits.
1084    ///
1085    /// Includes every period and the segments outside the requested bounds. Existing targets
1086    /// remain in the plan so execution can verify their rows before removing source files.
1087    ///
1088    /// # Errors
1089    ///
1090    /// Returns an error if:
1091    /// - `period_nanos` is zero.
1092    /// - `start` exceeds `end`.
1093    /// - The intervals are not contiguous when `ensure_contiguous_files` is true.
1094    #[expect(
1095        clippy::too_many_arguments,
1096        reason = "Consolidation keeps its window and policy arguments explicit"
1097    )]
1098    pub fn prepare_consolidation_queries(
1099        &self,
1100        _type_name: &str,
1101        _identifier: Option<&str>,
1102        intervals: &[(u64, u64)],
1103        period_nanos: u64,
1104        start: Option<UnixNanos>,
1105        end: Option<UnixNanos>,
1106        ensure_contiguous_files: bool,
1107    ) -> anyhow::Result<Vec<ConsolidationQuery>> {
1108        anyhow::ensure!(period_nanos > 0, "period_nanos must be positive");
1109
1110        if let (Some(start), Some(end)) = (start, end) {
1111            anyhow::ensure!(start <= end, "consolidation start must not exceed end");
1112        }
1113
1114        // Filter intervals by time range if specified
1115        let used_start = start.map(|s| s.as_u64());
1116        let used_end = end.map(|e| e.as_u64());
1117
1118        let mut filtered_intervals = Vec::new();
1119
1120        for &(interval_start, interval_end) in intervals {
1121            // Check if interval overlaps with the specified range
1122            if interval_intersects_range((interval_start, interval_end), start, end) {
1123                filtered_intervals.push((interval_start, interval_end));
1124            }
1125        }
1126
1127        if filtered_intervals.is_empty() {
1128            return Ok(Vec::new()); // No intervals in the specified range
1129        }
1130
1131        // Check contiguity of filtered intervals if required
1132        if ensure_contiguous_files && !are_intervals_contiguous(&filtered_intervals) {
1133            anyhow::bail!(
1134                "Intervals are not contiguous. When ensure_contiguous_files=true, \
1135                 all files in the consolidation range must have contiguous timestamps."
1136            );
1137        }
1138
1139        // Group intervals by the target period: split only when the gap between files
1140        // exceeds one period, since sub-period gaps land in the same consolidated file.
1141        let contiguous_groups = self.group_contiguous_intervals(&filtered_intervals, period_nanos);
1142
1143        let mut queries_to_execute = Vec::new();
1144
1145        // Handle interval splitting by creating split operations for data preservation
1146        if !filtered_intervals.is_empty() {
1147            if let Some(start_ts) = used_start {
1148                let first_interval = filtered_intervals[0];
1149                if first_interval.0 < start_ts && start_ts <= first_interval.1 {
1150                    // Split before start: preserve data from interval_start to start-1
1151                    queries_to_execute.push(ConsolidationQuery {
1152                        query_start: first_interval.0,
1153                        query_end: start_ts - 1,
1154                        use_period_boundaries: false,
1155                    });
1156                }
1157            }
1158
1159            if let Some(end_ts) = used_end {
1160                let last_end = filtered_intervals
1161                    .iter()
1162                    .map(|interval| interval.1)
1163                    .max()
1164                    .unwrap_or(end_ts);
1165                if end_ts < last_end {
1166                    // Split after end: preserve data from end+1 to interval_end
1167                    queries_to_execute.push(ConsolidationQuery {
1168                        query_start: end_ts + 1,
1169                        query_end: last_end,
1170                        use_period_boundaries: false,
1171                    });
1172                }
1173            }
1174        }
1175
1176        // Generate period-based consolidation queries for each contiguous group
1177        for group in contiguous_groups {
1178            let group_start = group[0].0;
1179            let group_end = group
1180                .iter()
1181                .fold(group[0].1, |end, interval| end.max(interval.1));
1182
1183            // Apply start/end filtering to the group
1184            let effective_start = used_start.map_or(group_start, |s| s.max(group_start));
1185            let effective_end = used_end.map_or(group_end, |e| e.min(group_end));
1186
1187            if effective_start > effective_end {
1188                continue; // Skip if no overlap
1189            }
1190
1191            // Generate period-based queries within this contiguous group
1192            let mut current_start_ns = (effective_start / period_nanos) * period_nanos;
1193
1194            loop {
1195                let current_end_ns = current_start_ns
1196                    .saturating_add(period_nanos - 1)
1197                    .min(effective_end);
1198
1199                queries_to_execute.push(ConsolidationQuery {
1200                    query_start: current_start_ns.max(used_start.unwrap_or(current_start_ns)),
1201                    query_end: current_end_ns,
1202                    use_period_boundaries: ensure_contiguous_files,
1203                });
1204
1205                match current_start_ns.checked_add(period_nanos) {
1206                    Some(next_start_ns) if next_start_ns <= effective_end => {
1207                        current_start_ns = next_start_ns;
1208                    }
1209                    _ => break,
1210                }
1211            }
1212        }
1213
1214        queries_to_execute.sort_by_key(|q| q.query_start);
1215
1216        Ok(queries_to_execute)
1217    }
1218
1219    /// Groups intervals for period-based consolidation.
1220    ///
1221    /// Groups adjacent intervals into the same bucket unless the gap between them exceeds
1222    /// `period_nanos`. Sub-period gaps land in the same consolidated file anyway, so they
1223    /// do not warrant a split. Gaps larger than one period represent genuine data holes.
1224    ///
1225    /// # Parameters
1226    ///
1227    /// - `intervals`: A slice of timestamp intervals as (start, end) tuples, sorted by start.
1228    /// - `period_nanos`: The target consolidation period; gaps larger than this split groups.
1229    ///
1230    /// # Returns
1231    ///
1232    /// Returns a vector of groups. Returns an empty vector if the input is empty.
1233    ///
1234    /// # Examples
1235    ///
1236    /// ```text
1237    /// Legacy chunked files with period=86_400_000_000_000 (1 day):
1238    ///   [(1,5), (6,10), (11,15)] -> [[(1,5), (6,10), (11,15)]]
1239    ///
1240    /// Small period=1 with mixed gaps:
1241    ///   [(1,5), (8,10), (12,15)] -> [[(1,5)], [(8,10)], [(12,15)]]
1242    /// ```
1243    #[must_use]
1244    pub fn group_contiguous_intervals(
1245        &self,
1246        intervals: &[(u64, u64)],
1247        period_nanos: u64,
1248    ) -> Vec<Vec<(u64, u64)>> {
1249        if intervals.is_empty() {
1250            return Vec::new();
1251        }
1252
1253        // Split groups only when the gap between files exceeds one period,
1254        // since sub-period gaps land in the same consolidated file anyway.
1255        // This works for both legacy chunked files (gap ~1ns) and fragment-per-flush
1256        // catalogs (gap ~bar interval) without inferring spacing from the data.
1257        let mut contiguous_groups = Vec::new();
1258        let mut current_group = vec![intervals[0]];
1259        let mut current_end = intervals[0].1;
1260        for &(curr_start, curr_end) in intervals.iter().skip(1) {
1261            if curr_start.saturating_sub(current_end) > period_nanos {
1262                contiguous_groups.push(current_group);
1263                current_group = vec![(curr_start, curr_end)];
1264                current_end = curr_end;
1265            } else {
1266                current_group.push((curr_start, curr_end));
1267                current_end = current_end.max(curr_end);
1268            }
1269        }
1270
1271        contiguous_groups.push(current_group);
1272
1273        contiguous_groups
1274    }
1275}
1276
1277impl ConsolidationQuery {
1278    fn file_range(
1279        &self,
1280        start_ns: &mut Option<u64>,
1281        first: UnixNanos,
1282        last: UnixNanos,
1283    ) -> (UnixNanos, UnixNanos) {
1284        if self.use_period_boundaries {
1285            let start = *start_ns.get_or_insert(self.query_start);
1286            (UnixNanos::from(start), UnixNanos::from(self.query_end))
1287        } else {
1288            (first, last)
1289        }
1290    }
1291}
1292
1293struct ConsolidationFiles {
1294    sources: Vec<String>,
1295    paths: AHashSet<String>,
1296    completed: Vec<ClosedInterval>,
1297}
1298
1299impl ConsolidationFiles {
1300    fn new(files: &[String], start: Option<UnixNanos>, end: Option<UnixNanos>) -> Self {
1301        Self {
1302            sources: files
1303                .iter()
1304                .filter(|file| {
1305                    parse_filename_timestamps(file)
1306                        .is_some_and(|interval| interval_intersects_range(interval, start, end))
1307                })
1308                .cloned()
1309                .collect(),
1310            paths: files.iter().cloned().collect(),
1311            completed: Vec::new(),
1312        }
1313    }
1314
1315    fn prepare_target(
1316        &mut self,
1317        catalog: &ParquetDataCatalog,
1318        target: &str,
1319        batches: &[RecordBatch],
1320    ) -> anyhow::Result<bool> {
1321        if !self.paths.contains(target) {
1322            self.paths.insert(target.to_string());
1323            return Ok(true);
1324        }
1325
1326        anyhow::ensure!(
1327            catalog.target_holds_rows(target, batches)?,
1328            "Cannot consolidate into existing target {target}: it does not hold every source row",
1329        );
1330        self.sources.retain(|file| file != target);
1331        Ok(false)
1332    }
1333
1334    fn complete(
1335        &mut self,
1336        catalog: &ParquetDataCatalog,
1337        query: &ConsolidationQuery,
1338        write_files: bool,
1339    ) -> anyhow::Result<()> {
1340        self.completed.push(ClosedInterval {
1341            start: query.query_start,
1342            end: query.query_end,
1343        });
1344
1345        self.completed = merge_closed_intervals(&self.completed);
1346
1347        for file in self.sources.clone() {
1348            if let Some((start, end)) = parse_filename_timestamps(&file)
1349                && self
1350                    .completed
1351                    .iter()
1352                    .any(|covered| covered.start <= start && end <= covered.end)
1353            {
1354                self.sources.retain(|source| source != &file);
1355                self.paths.remove(&file);
1356                if write_files {
1357                    catalog.delete_file(&file)?;
1358                }
1359            }
1360        }
1361
1362        Ok(())
1363    }
1364}
1365
1366fn interval_intersects_range(
1367    (interval_start, interval_end): (u64, u64),
1368    start: Option<UnixNanos>,
1369    end: Option<UnixNanos>,
1370) -> bool {
1371    start.is_none_or(|start| start.as_u64() <= interval_end)
1372        && end.is_none_or(|end| interval_start <= end.as_u64())
1373}
1374
1375fn can_rewrite_consolidation_by_period(type_name: &str) -> bool {
1376    matches!(
1377        type_name,
1378        "quotes"
1379            | "trades"
1380            | "order_book_deltas"
1381            | "order_book_depths"
1382            | "bars"
1383            | "index_prices"
1384            | "mark_prices"
1385    )
1386}
1387
1388fn is_schema_incompatibility(error: &anyhow::Error) -> bool {
1389    let message = error.to_string().to_lowercase();
1390    [
1391        "schema",
1392        "field",
1393        "column",
1394        "data type",
1395        "datatype",
1396        "not compatible",
1397        "mismatch",
1398    ]
1399    .iter()
1400    .any(|needle| message.contains(needle))
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405    use rstest::rstest;
1406
1407    use super::*;
1408
1409    #[rstest]
1410    #[case("quotes", true)]
1411    #[case("bars", true)]
1412    #[case("instrument_closes", false)]
1413    #[case("custom/signal", false)]
1414    fn can_rewrite_consolidation_by_period_only_for_supported_types(
1415        #[case] type_name: &str,
1416        #[case] expected: bool,
1417    ) {
1418        assert_eq!(can_rewrite_consolidation_by_period(type_name), expected);
1419    }
1420
1421    #[rstest]
1422    #[case("schema mismatch while writing record batch", true)]
1423    #[case("object store request timed out", false)]
1424    fn schema_incompatibility_detection_matches_schema_errors(
1425        #[case] message: &str,
1426        #[case] expected: bool,
1427    ) {
1428        assert_eq!(
1429            is_schema_incompatibility(&anyhow::anyhow!(message.to_string())),
1430            expected
1431        );
1432    }
1433}