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}