1use std::{cmp::Ordering, collections::BTreeMap, sync::Arc};
19
20use arrow::{
21 array::{Array, Int64Array, StringArray, UInt32Array, UInt64Array},
22 compute::take_record_batch,
23 datatypes::{DataType, Field, Schema},
24 record_batch::RecordBatch,
25};
26use nautilus_core::ClosedInterval;
27use nautilus_serialization::arrow::{StringColumnRef, U32ColumnRef, U64ColumnRef};
28
29use super::{
30 CREATED_TS_COLUMN, DATA_TYPE_COLUMN, DATA_VERSION_COLUMN, END_TS_COLUMN, ROW_COUNT_COLUMN,
31 SCHEMA_VERSION_COLUMN, SOURCE_COLUMN, START_TS_COLUMN, STATUS_COLUMN, TABLE_PATH_COLUMN,
32};
33use crate::catalog::types::{
34 CatalogDataType, catalog_data_type_from_path, catalog_data_type_path_prefix,
35};
36
37pub const COVERAGE_SCHEMA_VERSION: u32 = 1;
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum CoverageKind {
42 Data,
43 Empty,
44 Deleted,
45}
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub struct CoverageSegment {
50 pub interval: ClosedInterval,
51 pub kind: CoverageKind,
52}
53
54impl CoverageSegment {
55 #[must_use]
57 pub const fn new(start: u64, end: u64, kind: CoverageKind) -> Option<Self> {
58 match ClosedInterval::new(start, end) {
59 Some(interval) => Some(Self { interval, kind }),
60 None => None,
61 }
62 }
63}
64
65#[derive(Clone, Debug, Default, Eq, PartialEq)]
67pub struct CoverageIntervals {
68 pub data: Vec<ClosedInterval>,
70 pub empty: Vec<ClosedInterval>,
72}
73
74#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct CatalogCoverageRow {
77 pub table_path: String,
78 pub data_type: CatalogDataType,
79 pub identifier: Option<String>,
80 pub start_ts: u64,
81 pub end_ts: u64,
82 pub kind: CoverageKind,
83 pub row_count: u64,
84 pub data_version: Option<i64>,
85 pub source: String,
86 pub created_ts: u64,
87 pub schema_version: u32,
88}
89
90impl From<&CatalogCoverageRow> for Option<CoverageSegment> {
94 fn from(row: &CatalogCoverageRow) -> Self {
95 CoverageSegment::new(row.start_ts, row.end_ts, row.kind)
96 }
97}
98
99pub fn rows_to_segments<'a, I>(rows: I) -> Vec<CoverageSegment>
101where
102 I: IntoIterator<Item = &'a CatalogCoverageRow>,
103{
104 rows.into_iter().filter_map(Into::into).collect()
105}
106
107#[must_use]
111pub fn last_data_timestamp(rows: &[CatalogCoverageRow]) -> Option<u64> {
112 coverage_intervals_by_kind(&rows_to_segments(rows))
113 .data
114 .last()
115 .map(|interval| interval.end)
116}
117
118pub fn deduplicate_and_sort_coverage_rows(
119 rows: Vec<CatalogCoverageRow>,
120) -> Vec<CatalogCoverageRow> {
121 let mut rows = deduplicate_coverage_rows(rows);
122 rows.sort_by(compare_coverage_rows_causal);
123 rows
124}
125
126#[must_use]
127pub fn deduplicate_coverage_rows(rows: Vec<CatalogCoverageRow>) -> Vec<CatalogCoverageRow> {
128 let mut deduplicated = BTreeMap::new();
129
130 for row in rows {
131 let key = (
132 row.table_path.clone(),
133 catalog_data_type_path_prefix(&row.data_type).into_owned(),
134 row.identifier.clone(),
135 row.start_ts,
136 row.end_ts,
137 coverage_kind_rank(row.kind),
138 row.row_count,
139 );
140
141 if let Some(existing) = deduplicated.get_mut(&key) {
142 if compare_coverage_rows_causal(&row, existing).is_gt() {
143 *existing = row;
144 }
145 } else {
146 deduplicated.insert(key, row);
147 }
148 }
149
150 deduplicated.into_values().collect()
151}
152
153pub fn filter_record_batch_by_ts_init_intervals(
158 batch: &RecordBatch,
159 intervals: &[ClosedInterval],
160) -> anyhow::Result<Option<(RecordBatch, Vec<u64>)>> {
161 let ts_init = batch
162 .column_by_name("ts_init")
163 .ok_or_else(|| anyhow::anyhow!("ts_init column not found"))?;
164 let values = U64ColumnRef::try_from_array(ts_init.as_ref())
165 .ok_or_else(|| anyhow::anyhow!("ts_init column must be UInt64 or Int64"))?;
166 let mut indices = Vec::with_capacity(batch.num_rows());
167 let mut timestamps = Vec::with_capacity(batch.num_rows());
168
169 for row in 0..batch.num_rows() {
170 anyhow::ensure!(!ts_init.is_null(row), "ts_init column contains null values");
171 let timestamp = values
172 .value(row)
173 .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
174
175 if intervals
176 .iter()
177 .any(|interval| interval.start <= timestamp && timestamp <= interval.end)
178 {
179 indices.push(u32::try_from(row)?);
180 timestamps.push(timestamp);
181 }
182 }
183
184 if indices.is_empty() {
185 return Ok(None);
186 }
187
188 if indices.len() == batch.num_rows() {
189 return Ok(Some((batch.clone(), timestamps)));
190 }
191
192 let indices = UInt32Array::from(indices);
193 let batch = take_record_batch(batch, &indices)
194 .map_err(|e| anyhow::anyhow!("Failed to filter batch by coverage: {e}"))?;
195 Ok(Some((batch, timestamps)))
196}
197
198#[must_use]
204pub fn compare_coverage_rows_causal(
205 left: &CatalogCoverageRow,
206 right: &CatalogCoverageRow,
207) -> Ordering {
208 left.data_version
209 .unwrap_or(0)
210 .cmp(&right.data_version.unwrap_or(0))
211 .then(left.created_ts.cmp(&right.created_ts))
212 .then(left.start_ts.cmp(&right.start_ts))
213 .then(left.end_ts.cmp(&right.end_ts))
214}
215
216const fn coverage_kind_rank(kind: CoverageKind) -> u8 {
217 match kind {
218 CoverageKind::Data => 0,
219 CoverageKind::Empty => 1,
220 CoverageKind::Deleted => 2,
221 }
222}
223
224#[must_use]
225pub fn coverage_kind_to_str(kind: CoverageKind) -> &'static str {
226 match kind {
227 CoverageKind::Data => "data",
228 CoverageKind::Empty => "empty",
229 CoverageKind::Deleted => "deleted",
230 }
231}
232
233fn coverage_kind_from_str(value: &str) -> anyhow::Result<CoverageKind> {
234 match value {
235 "data" => Ok(CoverageKind::Data),
236 "empty" => Ok(CoverageKind::Empty),
237 "deleted" => Ok(CoverageKind::Deleted),
238 other => anyhow::bail!("Unknown catalog coverage status: {other}"),
239 }
240}
241
242#[must_use]
243pub fn coverage_batch_schema() -> Schema {
244 Schema::new(vec![
245 Field::new(TABLE_PATH_COLUMN, DataType::Utf8, false),
246 Field::new(DATA_TYPE_COLUMN, DataType::Utf8, false),
247 Field::new("identifier", DataType::Utf8, true),
248 Field::new(START_TS_COLUMN, DataType::UInt64, false),
249 Field::new(END_TS_COLUMN, DataType::UInt64, false),
250 Field::new(STATUS_COLUMN, DataType::Utf8, false),
251 Field::new(ROW_COUNT_COLUMN, DataType::UInt64, false),
252 Field::new(DATA_VERSION_COLUMN, DataType::Int64, true),
253 Field::new(SOURCE_COLUMN, DataType::Utf8, false),
254 Field::new(CREATED_TS_COLUMN, DataType::UInt64, false),
255 Field::new(SCHEMA_VERSION_COLUMN, DataType::UInt32, false),
256 ])
257}
258
259pub fn coverage_rows_to_batch(rows: &[CatalogCoverageRow]) -> anyhow::Result<RecordBatch> {
263 Ok(RecordBatch::try_new(
264 Arc::new(coverage_batch_schema()),
265 vec![
266 Arc::new(StringArray::from(
267 rows.iter()
268 .map(|row| row.table_path.clone())
269 .collect::<Vec<_>>(),
270 )),
271 Arc::new(StringArray::from(
272 rows.iter()
273 .map(|row| catalog_data_type_path_prefix(&row.data_type).into_owned())
274 .collect::<Vec<_>>(),
275 )),
276 Arc::new(StringArray::from(
277 rows.iter()
278 .map(|row| row.identifier.clone())
279 .collect::<Vec<_>>(),
280 )),
281 Arc::new(UInt64Array::from(
282 rows.iter().map(|row| row.start_ts).collect::<Vec<_>>(),
283 )),
284 Arc::new(UInt64Array::from(
285 rows.iter().map(|row| row.end_ts).collect::<Vec<_>>(),
286 )),
287 Arc::new(StringArray::from(
288 rows.iter()
289 .map(|row| coverage_kind_to_str(row.kind))
290 .collect::<Vec<_>>(),
291 )),
292 Arc::new(UInt64Array::from(
293 rows.iter().map(|row| row.row_count).collect::<Vec<_>>(),
294 )),
295 Arc::new(Int64Array::from(
296 rows.iter().map(|row| row.data_version).collect::<Vec<_>>(),
297 )),
298 Arc::new(StringArray::from(
299 rows.iter()
300 .map(|row| row.source.clone())
301 .collect::<Vec<_>>(),
302 )),
303 Arc::new(UInt64Array::from(
304 rows.iter().map(|row| row.created_ts).collect::<Vec<_>>(),
305 )),
306 Arc::new(UInt32Array::from(
307 rows.iter()
308 .map(|row| row.schema_version)
309 .collect::<Vec<_>>(),
310 )),
311 ],
312 )?)
313}
314
315pub fn decode_coverage_batches(
320 batches: Vec<RecordBatch>,
321) -> anyhow::Result<Vec<CatalogCoverageRow>> {
322 let mut rows = Vec::new();
323
324 for batch in batches {
325 let table_path = string_values(&batch, TABLE_PATH_COLUMN)?;
326 let data_type = string_values(&batch, DATA_TYPE_COLUMN)?;
327 let identifier = string_values(&batch, "identifier")?;
328 let status = string_values(&batch, STATUS_COLUMN)?;
329 let source = string_values(&batch, SOURCE_COLUMN)?;
330 let start_ts = u64_values(&batch, START_TS_COLUMN)?;
331 let end_ts = u64_values(&batch, END_TS_COLUMN)?;
332 let row_count = u64_values(&batch, ROW_COUNT_COLUMN)?;
333 let created_ts = u64_values(&batch, CREATED_TS_COLUMN)?;
334 let data_version = batch
335 .column_by_name(DATA_VERSION_COLUMN)
336 .ok_or_else(|| anyhow::anyhow!("{DATA_VERSION_COLUMN} column not found"))?
337 .as_any()
338 .downcast_ref::<Int64Array>()
339 .ok_or_else(|| anyhow::anyhow!("{DATA_VERSION_COLUMN} column is not Int64"))?;
340 let schema_version = u32_values(&batch, SCHEMA_VERSION_COLUMN)?;
341
342 for row in 0..batch.num_rows() {
343 rows.push(CatalogCoverageRow {
344 table_path: table_path[row]
345 .clone()
346 .ok_or_else(|| anyhow::anyhow!("{TABLE_PATH_COLUMN} is null"))?,
347 data_type: catalog_data_type_from_path(
348 data_type[row]
349 .as_deref()
350 .ok_or_else(|| anyhow::anyhow!("{DATA_TYPE_COLUMN} is null"))?,
351 )?,
352 identifier: identifier[row].clone(),
353 start_ts: start_ts[row],
354 end_ts: end_ts[row],
355 kind: coverage_kind_from_str(
356 status[row]
357 .as_deref()
358 .ok_or_else(|| anyhow::anyhow!("{STATUS_COLUMN} is null"))?,
359 )?,
360 row_count: row_count[row],
361 data_version: (!data_version.is_null(row)).then(|| data_version.value(row)),
362 source: source[row]
363 .clone()
364 .ok_or_else(|| anyhow::anyhow!("{SOURCE_COLUMN} is null"))?,
365 created_ts: created_ts[row],
366 schema_version: schema_version[row],
367 });
368 }
369 }
370
371 Ok(rows)
372}
373
374fn u64_values(batch: &RecordBatch, name: &str) -> anyhow::Result<Vec<u64>> {
375 let column = batch
376 .column_by_name(name)
377 .ok_or_else(|| anyhow::anyhow!("{name} column not found"))?;
378 let values = U64ColumnRef::try_from_array(column.as_ref())
379 .ok_or_else(|| anyhow::anyhow!("{name} column is not UInt64 or Int64"))?;
380
381 (0..values.len())
382 .map(|row| {
383 anyhow::ensure!(!values.is_null(row), "{name} column contains null values");
384 values.value(row).ok_or_else(|| {
385 anyhow::anyhow!("Catalog coverage integer column contains a negative value")
386 })
387 })
388 .collect()
389}
390
391fn u32_values(batch: &RecordBatch, name: &str) -> anyhow::Result<Vec<u32>> {
392 let column = batch
393 .column_by_name(name)
394 .ok_or_else(|| anyhow::anyhow!("{name} column not found"))?;
395 let values = U32ColumnRef::try_from_array(column.as_ref())
396 .ok_or_else(|| anyhow::anyhow!("{name} column is not UInt32, Int32, or Int64"))?;
397
398 anyhow::ensure!(
399 column.null_count() == 0,
400 "{name} column contains null values"
401 );
402
403 match values {
404 U32ColumnRef::UInt32(values) => Ok(values.values().to_vec()),
405 U32ColumnRef::Int32(values) => values
406 .values()
407 .iter()
408 .map(|value| {
409 u32::try_from(*value).map_err(|_| {
410 anyhow::anyhow!("Catalog coverage integer column contains a negative value")
411 })
412 })
413 .collect(),
414 U32ColumnRef::Int64(values) => values
415 .values()
416 .iter()
417 .map(|value| {
418 u32::try_from(*value).map_err(|_| {
419 anyhow::anyhow!(
420 "Catalog coverage integer column contains a value outside the u32 range"
421 )
422 })
423 })
424 .collect(),
425 }
426}
427
428fn string_values(batch: &RecordBatch, name: &str) -> anyhow::Result<Vec<Option<String>>> {
429 let column = batch
430 .column_by_name(name)
431 .ok_or_else(|| anyhow::anyhow!("{name} column not found"))?;
432 let values = StringColumnRef::try_from_array(column.as_ref())
433 .ok_or_else(|| anyhow::anyhow!("{name} column is not Utf8 or Utf8View"))?;
434 Ok((0..batch.num_rows())
435 .map(|row| values.value_opt(row).map(str::to_string))
436 .collect())
437}
438
439#[must_use]
441pub fn merge_closed_intervals(intervals: &[ClosedInterval]) -> Vec<ClosedInterval> {
442 let mut intervals: Vec<ClosedInterval> = intervals
443 .iter()
444 .copied()
445 .filter(|interval| interval.start <= interval.end)
446 .collect();
447
448 if intervals.is_empty() {
449 return Vec::new();
450 }
451
452 intervals.sort_by_key(|interval| interval.start);
453
454 let mut merged = Vec::with_capacity(intervals.len());
455 let mut current = intervals[0];
456
457 for interval in intervals.into_iter().skip(1) {
458 if interval.start <= current.end.saturating_add(1) {
459 current.end = current.end.max(interval.end);
460 } else {
461 merged.push(current);
462 current = interval;
463 }
464 }
465
466 merged.push(current);
467 merged
468}
469
470#[must_use]
472pub fn covered_intervals(segments: &[CoverageSegment]) -> Vec<ClosedInterval> {
473 let mut intervals = Vec::new();
474
475 for segment in segments {
476 match segment.kind {
477 CoverageKind::Data | CoverageKind::Empty => {
478 intervals.push(segment.interval);
479 }
480 CoverageKind::Deleted => {
481 intervals = subtract_interval_from_intervals(&intervals, segment.interval);
482 }
483 }
484 }
485
486 merge_closed_intervals(&intervals)
487}
488
489#[must_use]
491pub fn coverage_intervals_by_kind(segments: &[CoverageSegment]) -> CoverageIntervals {
492 let mut data = Vec::new();
493 let mut empty = Vec::new();
494
495 for segment in segments {
496 match segment.kind {
497 CoverageKind::Data => data.push(segment.interval),
498 CoverageKind::Empty => empty.push(segment.interval),
499 CoverageKind::Deleted => {
500 data = subtract_interval_from_intervals(&data, segment.interval);
501 empty = subtract_interval_from_intervals(&empty, segment.interval);
502 }
503 }
504 }
505
506 let data = merge_closed_intervals(&data);
507 let mut empty = merge_closed_intervals(&empty);
508 for interval in &data {
509 empty = subtract_interval_from_merged_intervals(&empty, *interval);
510 }
511
512 CoverageIntervals { data, empty }
513}
514
515#[must_use]
517pub fn missing_segments_for_request(
518 request_start: u64,
519 request_end: u64,
520 segments: &[CoverageSegment],
521) -> Vec<ClosedInterval> {
522 let covered = covered_intervals(segments);
523 missing_intervals(request_start, request_end, &covered)
524}
525
526#[must_use]
528pub fn subtract_interval_from_intervals(
529 intervals: &[ClosedInterval],
530 deleted: ClosedInterval,
531) -> Vec<ClosedInterval> {
532 subtract_interval_from_merged_intervals(&merge_closed_intervals(intervals), deleted)
533}
534
535fn subtract_interval_from_merged_intervals(
536 intervals: &[ClosedInterval],
537 deleted: ClosedInterval,
538) -> Vec<ClosedInterval> {
539 let mut remaining = Vec::new();
540
541 for interval in intervals.iter().copied() {
542 if deleted.end < interval.start || deleted.start > interval.end {
543 remaining.push(interval);
544 continue;
545 }
546
547 if deleted.start > interval.start {
548 remaining.push(ClosedInterval {
549 start: interval.start,
550 end: deleted.start.saturating_sub(1),
551 });
552 }
553
554 if deleted.end < interval.end {
555 remaining.push(ClosedInterval {
556 start: deleted.end.saturating_add(1),
557 end: interval.end,
558 });
559 }
560 }
561
562 remaining
563}
564
565#[must_use]
567pub fn missing_intervals(
568 request_start: u64,
569 request_end: u64,
570 intervals: &[ClosedInterval],
571) -> Vec<ClosedInterval> {
572 if request_start > request_end {
573 return Vec::new();
574 }
575
576 let intervals = merge_closed_intervals(intervals);
577 let mut missing = Vec::new();
578 let mut cursor = request_start;
579
580 for interval in intervals {
581 if interval.end < cursor {
582 continue;
583 }
584
585 if interval.start > request_end {
586 break;
587 }
588
589 if cursor < interval.start {
590 missing.push(ClosedInterval {
591 start: cursor,
592 end: interval.start - 1,
593 });
594 }
595
596 if interval.end >= request_end {
597 return missing;
598 }
599
600 cursor = interval.end.saturating_add(1);
601 }
602
603 if cursor <= request_end {
604 missing.push(ClosedInterval {
605 start: cursor,
606 end: request_end,
607 });
608 }
609
610 missing
611}
612
613#[cfg(test)]
614mod tests {
615 use arrow::array::{ArrayRef, Int32Array};
616 use nautilus_model::data::{NautilusDataType, NautilusRecordType};
617 use rstest::rstest;
618
619 use super::*;
620
621 #[rstest]
622 fn merge_closed_intervals_treats_adjacent_nanoseconds_as_contiguous() {
623 let intervals = [
624 ClosedInterval::new(20, 30).unwrap(),
625 ClosedInterval::new(10, 19).unwrap(),
626 ClosedInterval::new(50, 55).unwrap(),
627 ];
628
629 assert_eq!(
630 merge_closed_intervals(&intervals),
631 vec![
632 ClosedInterval::new(10, 30).unwrap(),
633 ClosedInterval::new(50, 55).unwrap(),
634 ],
635 );
636 }
637
638 #[rstest]
639 fn missing_segments_for_request_treats_empty_segments_as_covered() {
640 let segments = [
641 CoverageSegment::new(1, 4, CoverageKind::Empty).unwrap(),
642 CoverageSegment::new(5, 7, CoverageKind::Data).unwrap(),
643 ];
644
645 assert_eq!(
646 missing_segments_for_request(1, 10, &segments),
647 vec![ClosedInterval::new(8, 10).unwrap()],
648 );
649 }
650
651 #[rstest]
652 fn coverage_intervals_by_kind_keeps_data_out_of_empty_ranges() {
653 let segments = [
654 CoverageSegment::new(1, 10, CoverageKind::Empty).unwrap(),
655 CoverageSegment::new(4, 6, CoverageKind::Data).unwrap(),
656 ];
657
658 assert_eq!(
659 coverage_intervals_by_kind(&segments),
660 CoverageIntervals {
661 data: vec![ClosedInterval::new(4, 6).unwrap()],
662 empty: vec![
663 ClosedInterval::new(1, 3).unwrap(),
664 ClosedInterval::new(7, 10).unwrap(),
665 ],
666 },
667 );
668 }
669
670 #[rstest]
671 fn missing_segments_for_request_applies_delete_tombstones_in_order() {
672 let segments = [
673 CoverageSegment::new(10, 30, CoverageKind::Data).unwrap(),
674 CoverageSegment::new(15, 25, CoverageKind::Deleted).unwrap(),
675 CoverageSegment::new(20, 22, CoverageKind::Data).unwrap(),
676 ];
677
678 assert_eq!(
679 missing_segments_for_request(10, 30, &segments),
680 vec![
681 ClosedInterval::new(15, 19).unwrap(),
682 ClosedInterval::new(23, 25).unwrap(),
683 ],
684 );
685 }
686
687 #[rstest]
688 fn missing_intervals_handles_overlaps_and_invalid_requests() {
689 let intervals = [
690 ClosedInterval::new(10, 30).unwrap(),
691 ClosedInterval::new(20, 40).unwrap(),
692 ClosedInterval::new(60, 80).unwrap(),
693 ];
694
695 assert_eq!(
696 missing_intervals(1, 100, &intervals),
697 vec![
698 ClosedInterval::new(1, 9).unwrap(),
699 ClosedInterval::new(41, 59).unwrap(),
700 ClosedInterval::new(81, 100).unwrap(),
701 ],
702 );
703 assert_eq!(missing_intervals(100, 1, &intervals), Vec::new());
704 }
705
706 fn closed_intervals(pairs: &[(u64, u64)]) -> Vec<ClosedInterval> {
707 pairs
708 .iter()
709 .map(|&(start, end)| ClosedInterval::new(start, end).unwrap())
710 .collect()
711 }
712
713 fn coverage_row(
714 start_ts: u64,
715 end_ts: u64,
716 kind: CoverageKind,
717 data_version: Option<i64>,
718 created_ts: u64,
719 ) -> CatalogCoverageRow {
720 CatalogCoverageRow {
721 table_path: "quotes".to_string(),
722 data_type: NautilusDataType::QuoteTick.into(),
723 identifier: Some("AUD/USD.SIM".to_string()),
724 start_ts,
725 end_ts,
726 kind,
727 row_count: 5,
728 data_version,
729 source: "writer".to_string(),
730 created_ts,
731 schema_version: COVERAGE_SCHEMA_VERSION,
732 }
733 }
734
735 #[rstest]
736 #[case::single_nanosecond_request(5, 5, &[], &[(5, 5)])]
737 #[case::interval_ends_at_request_start(5, 10, &[(1, 5)], &[(6, 10)])]
738 #[case::interval_starts_at_request_end(1, 10, &[(10, 15)], &[(1, 9)])]
739 #[case::interval_after_request(1, 5, &[(10, 15)], &[(1, 5)])]
740 #[case::interval_before_request(10, 20, &[(1, 5), (8, 12)], &[(13, 20)])]
741 fn missing_intervals_handles_request_boundaries(
742 #[case] request_start: u64,
743 #[case] request_end: u64,
744 #[case] intervals: &[(u64, u64)],
745 #[case] expected: &[(u64, u64)],
746 ) {
747 let missing = missing_intervals(request_start, request_end, &closed_intervals(intervals));
748
749 assert_eq!(missing, closed_intervals(expected));
750 }
751
752 #[rstest]
753 #[case::deletes_first_nanosecond(&[(10, 20)], (5, 10), &[(11, 20)])]
754 #[case::deletes_last_nanosecond(&[(10, 20)], (20, 25), &[(10, 19)])]
755 #[case::keeps_interval_after_deletion(&[(10, 20)], (1, 5), &[(10, 20)])]
756 #[case::keeps_interval_before_deletion(&[(10, 20)], (25, 30), &[(10, 20)])]
757 #[case::deletes_shared_start(&[(10, 20)], (10, 15), &[(16, 20)])]
758 #[case::deletes_shared_end(&[(10, 20)], (15, 20), &[(10, 14)])]
759 #[case::merges_before_deleting(&[(16, 20), (10, 15)], (12, 18), &[(10, 11), (19, 20)])]
760 fn subtract_interval_from_intervals_handles_boundaries(
761 #[case] intervals: &[(u64, u64)],
762 #[case] deleted: (u64, u64),
763 #[case] expected: &[(u64, u64)],
764 ) {
765 let deleted = ClosedInterval::new(deleted.0, deleted.1).unwrap();
766
767 let remaining = subtract_interval_from_intervals(&closed_intervals(intervals), deleted);
768
769 assert_eq!(remaining, closed_intervals(expected));
770 }
771
772 #[rstest]
773 fn coverage_intervals_by_kind_applies_deletes_to_data_and_empty() {
774 let segments = [
775 CoverageSegment::new(1, 10, CoverageKind::Data).unwrap(),
776 CoverageSegment::new(11, 20, CoverageKind::Empty).unwrap(),
777 CoverageSegment::new(5, 15, CoverageKind::Deleted).unwrap(),
778 ];
779
780 assert_eq!(
781 coverage_intervals_by_kind(&segments),
782 CoverageIntervals {
783 data: closed_intervals(&[(1, 4)]),
784 empty: closed_intervals(&[(16, 20)]),
785 },
786 );
787 }
788
789 #[rstest]
790 #[case::deleted_tail(
791 &[(10, 20, CoverageKind::Data), (21, 40, CoverageKind::Empty), (15, 20, CoverageKind::Deleted)],
792 Some(14),
793 )]
794 #[case::data_after_delete(
795 &[(10, 20, CoverageKind::Data), (5, 30, CoverageKind::Deleted), (12, 13, CoverageKind::Data)],
796 Some(13),
797 )]
798 #[case::inverted_row_skipped(
799 &[(10, 20, CoverageKind::Data), (30, 25, CoverageKind::Data)],
800 Some(20),
801 )]
802 #[case::empty_only(&[(1, 5, CoverageKind::Empty)], None)]
803 fn last_data_timestamp_applies_rows_in_order(
804 #[case] rows: &[(u64, u64, CoverageKind)],
805 #[case] expected: Option<u64>,
806 ) {
807 let rows = rows
808 .iter()
809 .map(|&(start_ts, end_ts, kind)| coverage_row(start_ts, end_ts, kind, None, 1))
810 .collect::<Vec<_>>();
811
812 assert_eq!(last_data_timestamp(&rows), expected);
813 }
814
815 #[rstest]
816 fn deduplicate_and_sort_coverage_rows_keeps_causal_latest_per_kind() {
817 let older_data = coverage_row(10, 20, CoverageKind::Data, Some(1), 100);
818 let newer_data = coverage_row(10, 20, CoverageKind::Data, Some(2), 50);
819 let deleted = coverage_row(10, 20, CoverageKind::Deleted, Some(1), 200);
820 let empty = coverage_row(30, 40, CoverageKind::Empty, Some(1), 150);
821 let unversioned = coverage_row(1, 5, CoverageKind::Data, None, 300);
822
823 let rows = deduplicate_and_sort_coverage_rows(vec![
824 older_data,
825 deleted.clone(),
826 newer_data.clone(),
827 empty.clone(),
828 unversioned.clone(),
829 ]);
830
831 assert_eq!(rows, vec![unversioned, empty, deleted, newer_data]);
832 }
833
834 #[rstest]
835 fn coverage_rows_round_trip_through_record_batch() {
836 let rows = vec![
837 CatalogCoverageRow {
838 table_path: "quotes".to_string(),
839 data_type: NautilusDataType::QuoteTick.into(),
840 identifier: None,
841 start_ts: 1,
842 end_ts: 5,
843 kind: CoverageKind::Data,
844 row_count: 3,
845 data_version: None,
846 source: "writer".to_string(),
847 created_ts: 100,
848 schema_version: COVERAGE_SCHEMA_VERSION,
849 },
850 CatalogCoverageRow {
851 table_path: "bars".to_string(),
852 data_type: NautilusDataType::Bar.into(),
853 identifier: Some("ES.GLBX".to_string()),
854 start_ts: 6,
855 end_ts: 9,
856 kind: CoverageKind::Deleted,
857 row_count: 0,
858 data_version: Some(7),
859 source: "delete".to_string(),
860 created_ts: 200,
861 schema_version: 2,
862 },
863 CatalogCoverageRow {
864 table_path: "trades".to_string(),
865 data_type: NautilusDataType::TradeTick.into(),
866 identifier: Some("ETHUSDT.BINANCE".to_string()),
867 start_ts: 10,
868 end_ts: 12,
869 kind: CoverageKind::Empty,
870 row_count: 0,
871 data_version: Some(0),
872 source: "request".to_string(),
873 created_ts: 300,
874 schema_version: 3,
875 },
876 ];
877
878 let batch = coverage_rows_to_batch(&rows).unwrap();
879
880 assert_eq!(decode_coverage_batches(vec![batch]).unwrap(), rows);
881 }
882
883 #[rstest]
884 #[case::path_prefix("quotes", Ok(NautilusDataType::QuoteTick.into()))]
885 #[case::type_name("QuoteTick", Ok(NautilusDataType::QuoteTick.into()))]
886 #[case::shared_record(
887 "record/account_state",
888 Ok(CatalogDataType::Record(NautilusRecordType::AccountState))
889 )]
890 #[case::unknown("not_a_type", Err("Unknown catalog type path 'not_a_type'"))]
891 fn decode_coverage_batches_parses_data_type_column(
892 #[case] stored: &str,
893 #[case] expected: Result<CatalogDataType, &str>,
894 ) {
895 let batch =
896 coverage_rows_to_batch(&[coverage_row(1, 5, CoverageKind::Data, None, 1)]).unwrap();
897 let data_type_index = batch.schema().index_of(DATA_TYPE_COLUMN).unwrap();
898 let mut columns = batch.columns().to_vec();
899 columns[data_type_index] = Arc::new(StringArray::from(vec![stored]));
900 let batch = RecordBatch::try_new(batch.schema(), columns).unwrap();
901
902 let decoded = decode_coverage_batches(vec![batch])
903 .map(|rows| rows[0].data_type.clone())
904 .map_err(|e| e.to_string());
905
906 assert_eq!(decoded, expected.map_err(str::to_string));
907 }
908
909 #[rstest]
910 fn decode_coverage_batches_rejects_unknown_status() {
911 let batch =
912 coverage_rows_to_batch(&[coverage_row(1, 5, CoverageKind::Data, None, 1)]).unwrap();
913 let status_index = batch.schema().index_of(STATUS_COLUMN).unwrap();
914 let mut columns = batch.columns().to_vec();
915 columns[status_index] = Arc::new(StringArray::from(vec!["partial"]));
916 let batch = RecordBatch::try_new(batch.schema(), columns).unwrap();
917
918 let error = decode_coverage_batches(vec![batch]).unwrap_err();
919
920 assert_eq!(
921 error.to_string(),
922 "Unknown catalog coverage status: partial"
923 );
924 }
925
926 #[rstest]
927 #[case::uint32_null(
928 Arc::new(UInt32Array::from(vec![None::<u32>])),
929 Err("_nautilus_schema_version column contains null values")
930 )]
931 #[case::int32_null(
932 Arc::new(Int32Array::from(vec![None::<i32>])),
933 Err("_nautilus_schema_version column contains null values")
934 )]
935 #[case::int64_null(
936 Arc::new(Int64Array::from(vec![None::<i64>])),
937 Err("_nautilus_schema_version column contains null values")
938 )]
939 #[case::int32_negative(
940 Arc::new(Int32Array::from(vec![-1])),
941 Err("Catalog coverage integer column contains a negative value")
942 )]
943 #[case::int64_above_u32(
944 Arc::new(Int64Array::from(vec![i64::from(u32::MAX) + 1])),
945 Err("Catalog coverage integer column contains a value outside the u32 range")
946 )]
947 #[case::int32_valid(Arc::new(Int32Array::from(vec![3])), Ok(3))]
948 #[case::int64_valid(Arc::new(Int64Array::from(vec![4])), Ok(4))]
949 fn decode_coverage_batches_reads_schema_version_integer_types(
950 #[case] schema_version: ArrayRef,
951 #[case] expected: Result<u32, &str>,
952 ) {
953 let batch =
954 coverage_rows_to_batch(&[coverage_row(1, 5, CoverageKind::Data, None, 1)]).unwrap();
955 let index = batch.schema().index_of(SCHEMA_VERSION_COLUMN).unwrap();
956 let data_type = schema_version.data_type().clone();
957 let mut fields = batch.schema().fields().to_vec();
958 fields[index] = Arc::new(Field::new(SCHEMA_VERSION_COLUMN, data_type, true));
959 let mut columns = batch.columns().to_vec();
960 columns[index] = schema_version;
961 let schema = Arc::new(Schema::new(fields));
962 let batch = RecordBatch::try_new(schema, columns).unwrap();
963
964 let decoded = decode_coverage_batches(vec![batch])
965 .map(|rows| rows[0].schema_version)
966 .map_err(|e| e.to_string());
967
968 assert_eq!(decoded, expected.map_err(str::to_string));
969 }
970
971 #[rstest]
972 fn rows_to_segments_skips_inverted_rows() {
973 let rows = [
974 coverage_row(1, 5, CoverageKind::Data, None, 1),
975 coverage_row(9, 3, CoverageKind::Deleted, None, 2),
976 ];
977
978 assert_eq!(
979 rows_to_segments(&rows),
980 vec![CoverageSegment::new(1, 5, CoverageKind::Data).unwrap()],
981 );
982 }
983
984 fn ts_init_batch() -> RecordBatch {
985 RecordBatch::try_new(
986 Arc::new(Schema::new(vec![
987 Field::new("ts_init", DataType::UInt64, false),
988 Field::new("value", DataType::UInt32, false),
989 ])),
990 vec![
991 Arc::new(UInt64Array::from(vec![1_u64, 5, 10, 15])),
992 Arc::new(UInt32Array::from(vec![1_u32, 2, 3, 4])),
993 ],
994 )
995 .unwrap()
996 }
997
998 #[rstest]
999 #[case::selects_rows_inside_bounds(&[(5, 10)], Some((vec![2, 3], vec![5, 10])))]
1000 #[case::selects_every_row(&[(0, 20)], Some((vec![1, 2, 3, 4], vec![1, 5, 10, 15])))]
1001 #[case::selects_across_intervals(&[(0, 1), (15, 15)], Some((vec![1, 4], vec![1, 15])))]
1002 #[case::selects_no_row(&[(20, 30)], None)]
1003 fn filter_record_batch_by_ts_init_intervals_selects_rows(
1004 #[case] intervals: &[(u64, u64)],
1005 #[case] expected: Option<(Vec<u32>, Vec<u64>)>,
1006 ) {
1007 let filtered = filter_record_batch_by_ts_init_intervals(
1008 &ts_init_batch(),
1009 &closed_intervals(intervals),
1010 )
1011 .unwrap()
1012 .map(|(batch, timestamps)| {
1013 let values = batch
1014 .column_by_name("value")
1015 .unwrap()
1016 .as_any()
1017 .downcast_ref::<UInt32Array>()
1018 .unwrap()
1019 .values()
1020 .to_vec();
1021 (values, timestamps)
1022 });
1023
1024 assert_eq!(filtered, expected);
1025 }
1026
1027 #[rstest]
1028 fn filter_record_batch_by_ts_init_intervals_requires_ts_init() {
1029 let batch = RecordBatch::try_new(
1030 Arc::new(Schema::new(vec![Field::new(
1031 "value",
1032 DataType::UInt32,
1033 false,
1034 )])),
1035 vec![Arc::new(UInt32Array::from(vec![1_u32]))],
1036 )
1037 .unwrap();
1038
1039 let error = filter_record_batch_by_ts_init_intervals(&batch, &closed_intervals(&[(0, 1)]))
1040 .unwrap_err();
1041
1042 assert_eq!(error.to_string(), "ts_init column not found");
1043 }
1044}