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};
33
34pub const COVERAGE_SCHEMA_VERSION: u32 = 1;
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum CoverageKind {
39 Data,
40 Empty,
41 Deleted,
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct CoverageSegment {
47 pub interval: ClosedInterval,
48 pub kind: CoverageKind,
49}
50
51#[derive(Clone, Debug, Default, Eq, PartialEq)]
53pub struct CoverageIntervals {
54 pub data: Vec<ClosedInterval>,
56 pub empty: Vec<ClosedInterval>,
58}
59
60#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct CatalogCoverageRow {
63 pub table_path: String,
64 pub data_type: String,
65 pub identifier: Option<String>,
66 pub start_ts: u64,
67 pub end_ts: u64,
68 pub kind: CoverageKind,
69 pub row_count: u64,
70 pub data_version: Option<i64>,
71 pub source: String,
72 pub created_ts: u64,
73 pub schema_version: u32,
74}
75
76impl From<&CatalogCoverageRow> for Option<CoverageSegment> {
80 fn from(row: &CatalogCoverageRow) -> Self {
81 CoverageSegment::new(row.start_ts, row.end_ts, row.kind)
82 }
83}
84
85pub fn rows_to_segments<'a, I>(rows: I) -> Vec<CoverageSegment>
87where
88 I: IntoIterator<Item = &'a CatalogCoverageRow>,
89{
90 rows.into_iter().filter_map(Into::into).collect()
91}
92
93#[must_use]
97pub fn last_data_timestamp(rows: &[CatalogCoverageRow]) -> Option<u64> {
98 let mut intervals = Vec::new();
99 for row in rows {
100 let Some(interval) = ClosedInterval::new(row.start_ts, row.end_ts) else {
101 continue;
102 };
103
104 match row.kind {
105 CoverageKind::Data => intervals.push(interval),
106 CoverageKind::Deleted => {
107 intervals = subtract_interval_from_intervals(&intervals, interval);
108 }
109 CoverageKind::Empty => {}
110 }
111 }
112 merge_closed_intervals(&intervals)
113 .into_iter()
114 .map(|interval| interval.end)
115 .max()
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 row.data_type.clone(),
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| row.data_type.clone())
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: data_type[row]
348 .clone()
349 .ok_or_else(|| anyhow::anyhow!("{DATA_TYPE_COLUMN} is null"))?,
350 identifier: identifier[row].clone(),
351 start_ts: start_ts[row],
352 end_ts: end_ts[row],
353 kind: coverage_kind_from_str(
354 status[row]
355 .as_deref()
356 .ok_or_else(|| anyhow::anyhow!("{STATUS_COLUMN} is null"))?,
357 )?,
358 row_count: row_count[row],
359 data_version: (!data_version.is_null(row)).then(|| data_version.value(row)),
360 source: source[row]
361 .clone()
362 .ok_or_else(|| anyhow::anyhow!("{SOURCE_COLUMN} is null"))?,
363 created_ts: created_ts[row],
364 schema_version: schema_version[row],
365 });
366 }
367 }
368
369 Ok(rows)
370}
371
372fn u64_values(batch: &RecordBatch, name: &str) -> anyhow::Result<Vec<u64>> {
373 let column = batch
374 .column_by_name(name)
375 .ok_or_else(|| anyhow::anyhow!("{name} column not found"))?;
376 let values = U64ColumnRef::try_from_array(column.as_ref())
377 .ok_or_else(|| anyhow::anyhow!("{name} column is not UInt64 or Int64"))?;
378
379 (0..values.len())
380 .map(|row| {
381 anyhow::ensure!(!values.is_null(row), "{name} column contains null values");
382 values.value(row).ok_or_else(|| {
383 anyhow::anyhow!("Catalog coverage integer column contains a negative value")
384 })
385 })
386 .collect()
387}
388
389fn u32_values(batch: &RecordBatch, name: &str) -> anyhow::Result<Vec<u32>> {
390 let column = batch
391 .column_by_name(name)
392 .ok_or_else(|| anyhow::anyhow!("{name} column not found"))?;
393 let values = U32ColumnRef::try_from_array(column.as_ref())
394 .ok_or_else(|| anyhow::anyhow!("{name} column is not UInt32, Int32, or Int64"))?;
395
396 match values {
397 U32ColumnRef::UInt32(values) => {
398 anyhow::ensure!(
399 values.null_count() == 0,
400 "{name} column contains null values"
401 );
402 Ok(values.values().to_vec())
403 }
404 U32ColumnRef::Int32(values) => {
405 anyhow::ensure!(
406 values.null_count() == 0,
407 "{name} column contains null values"
408 );
409 values
410 .values()
411 .iter()
412 .map(|value| {
413 u32::try_from(*value).map_err(|_| {
414 anyhow::anyhow!("Catalog coverage integer column contains a negative value")
415 })
416 })
417 .collect()
418 }
419 U32ColumnRef::Int64(values) => {
420 anyhow::ensure!(
421 values.null_count() == 0,
422 "{name} column contains null values"
423 );
424 values
425 .values()
426 .iter()
427 .map(|value| {
428 u32::try_from(*value).map_err(|_| {
429 anyhow::anyhow!(
430 "Catalog coverage integer column contains a value outside the u32 range"
431 )
432 })
433 })
434 .collect()
435 }
436 }
437}
438
439fn string_values(batch: &RecordBatch, name: &str) -> anyhow::Result<Vec<Option<String>>> {
440 let column = batch
441 .column_by_name(name)
442 .ok_or_else(|| anyhow::anyhow!("{name} column not found"))?;
443 let values = StringColumnRef::try_from_array(column.as_ref())
444 .ok_or_else(|| anyhow::anyhow!("{name} column is not Utf8 or Utf8View"))?;
445 Ok((0..batch.num_rows())
446 .map(|row| values.value_opt(row).map(str::to_string))
447 .collect())
448}
449
450impl CoverageSegment {
451 #[must_use]
453 pub const fn new(start: u64, end: u64, kind: CoverageKind) -> Option<Self> {
454 match ClosedInterval::new(start, end) {
455 Some(interval) => Some(Self { interval, kind }),
456 None => None,
457 }
458 }
459}
460
461#[must_use]
463pub fn merge_closed_intervals(intervals: &[ClosedInterval]) -> Vec<ClosedInterval> {
464 let mut intervals: Vec<ClosedInterval> = intervals
465 .iter()
466 .copied()
467 .filter(|interval| interval.start <= interval.end)
468 .collect();
469
470 if intervals.is_empty() {
471 return Vec::new();
472 }
473
474 intervals.sort_by_key(|interval| interval.start);
475
476 let mut merged = Vec::with_capacity(intervals.len());
477 let mut current = intervals[0];
478
479 for interval in intervals.into_iter().skip(1) {
480 if interval.start <= current.end.saturating_add(1) {
481 current.end = current.end.max(interval.end);
482 } else {
483 merged.push(current);
484 current = interval;
485 }
486 }
487
488 merged.push(current);
489 merged
490}
491
492#[must_use]
494pub fn covered_intervals(segments: &[CoverageSegment]) -> Vec<ClosedInterval> {
495 let mut intervals = Vec::new();
496
497 for segment in segments {
498 match segment.kind {
499 CoverageKind::Data | CoverageKind::Empty => {
500 intervals.push(segment.interval);
501 }
502 CoverageKind::Deleted => {
503 intervals = merge_closed_intervals(&intervals);
504 intervals = subtract_interval_from_merged_intervals(&intervals, segment.interval);
505 }
506 }
507 }
508
509 merge_closed_intervals(&intervals)
510}
511
512#[must_use]
514pub fn coverage_intervals_by_kind(segments: &[CoverageSegment]) -> CoverageIntervals {
515 let mut data = Vec::new();
516 let mut empty = Vec::new();
517
518 for segment in segments {
519 match segment.kind {
520 CoverageKind::Data => data.push(segment.interval),
521 CoverageKind::Empty => empty.push(segment.interval),
522 CoverageKind::Deleted => {
523 data = subtract_interval_from_merged_intervals(
524 &merge_closed_intervals(&data),
525 segment.interval,
526 );
527 empty = subtract_interval_from_merged_intervals(
528 &merge_closed_intervals(&empty),
529 segment.interval,
530 );
531 }
532 }
533 }
534
535 let data = merge_closed_intervals(&data);
536 let mut empty = merge_closed_intervals(&empty);
537 for interval in &data {
538 empty = subtract_interval_from_merged_intervals(&empty, *interval);
539 }
540
541 CoverageIntervals { data, empty }
542}
543
544#[must_use]
546pub fn missing_segments_for_request(
547 request_start: u64,
548 request_end: u64,
549 segments: &[CoverageSegment],
550) -> Vec<ClosedInterval> {
551 let covered = covered_intervals(segments);
552 missing_intervals(request_start, request_end, &covered)
553}
554
555#[must_use]
557pub fn subtract_interval_from_intervals(
558 intervals: &[ClosedInterval],
559 deleted: ClosedInterval,
560) -> Vec<ClosedInterval> {
561 subtract_interval_from_merged_intervals(&merge_closed_intervals(intervals), deleted)
562}
563
564fn subtract_interval_from_merged_intervals(
565 intervals: &[ClosedInterval],
566 deleted: ClosedInterval,
567) -> Vec<ClosedInterval> {
568 let mut remaining = Vec::new();
569
570 for interval in intervals.iter().copied() {
571 if deleted.end < interval.start || deleted.start > interval.end {
572 remaining.push(interval);
573 continue;
574 }
575
576 if deleted.start > interval.start {
577 remaining.push(ClosedInterval {
578 start: interval.start,
579 end: deleted.start.saturating_sub(1),
580 });
581 }
582
583 if deleted.end < interval.end {
584 remaining.push(ClosedInterval {
585 start: deleted.end.saturating_add(1),
586 end: interval.end,
587 });
588 }
589 }
590
591 remaining
592}
593
594#[must_use]
596pub fn missing_intervals(
597 request_start: u64,
598 request_end: u64,
599 intervals: &[ClosedInterval],
600) -> Vec<ClosedInterval> {
601 if request_start > request_end {
602 return Vec::new();
603 }
604
605 let intervals = merge_closed_intervals(intervals);
606 let mut missing = Vec::new();
607 let mut cursor = request_start;
608
609 for interval in intervals {
610 if interval.end < cursor {
611 continue;
612 }
613
614 if interval.start > request_end {
615 break;
616 }
617
618 if cursor < interval.start {
619 missing.push(ClosedInterval {
620 start: cursor,
621 end: interval.start - 1,
622 });
623 }
624
625 if interval.end >= request_end {
626 return missing;
627 }
628
629 cursor = interval.end.saturating_add(1);
630 }
631
632 if cursor <= request_end {
633 missing.push(ClosedInterval {
634 start: cursor,
635 end: request_end,
636 });
637 }
638
639 missing
640}
641
642#[cfg(test)]
643mod tests {
644 use rstest::rstest;
645
646 use super::*;
647
648 #[rstest]
649 fn merge_closed_intervals_treats_adjacent_nanoseconds_as_contiguous() {
650 let intervals = [
651 ClosedInterval::new(20, 30).unwrap(),
652 ClosedInterval::new(10, 19).unwrap(),
653 ClosedInterval::new(50, 55).unwrap(),
654 ];
655
656 assert_eq!(
657 merge_closed_intervals(&intervals),
658 vec![
659 ClosedInterval::new(10, 30).unwrap(),
660 ClosedInterval::new(50, 55).unwrap(),
661 ],
662 );
663 }
664
665 #[rstest]
666 fn missing_segments_for_request_treats_empty_segments_as_covered() {
667 let segments = [
668 CoverageSegment::new(1, 4, CoverageKind::Empty).unwrap(),
669 CoverageSegment::new(5, 7, CoverageKind::Data).unwrap(),
670 ];
671
672 assert_eq!(
673 missing_segments_for_request(1, 10, &segments),
674 vec![ClosedInterval::new(8, 10).unwrap()],
675 );
676 }
677
678 #[rstest]
679 fn coverage_intervals_by_kind_keeps_data_out_of_empty_ranges() {
680 let segments = [
681 CoverageSegment::new(1, 10, CoverageKind::Empty).unwrap(),
682 CoverageSegment::new(4, 6, CoverageKind::Data).unwrap(),
683 ];
684
685 assert_eq!(
686 coverage_intervals_by_kind(&segments),
687 CoverageIntervals {
688 data: vec![ClosedInterval::new(4, 6).unwrap()],
689 empty: vec![
690 ClosedInterval::new(1, 3).unwrap(),
691 ClosedInterval::new(7, 10).unwrap(),
692 ],
693 },
694 );
695 }
696
697 #[rstest]
698 fn missing_segments_for_request_applies_delete_tombstones_in_order() {
699 let segments = [
700 CoverageSegment::new(10, 30, CoverageKind::Data).unwrap(),
701 CoverageSegment::new(15, 25, CoverageKind::Deleted).unwrap(),
702 CoverageSegment::new(20, 22, CoverageKind::Data).unwrap(),
703 ];
704
705 assert_eq!(
706 missing_segments_for_request(10, 30, &segments),
707 vec![
708 ClosedInterval::new(15, 19).unwrap(),
709 ClosedInterval::new(23, 25).unwrap(),
710 ],
711 );
712 }
713
714 #[rstest]
715 fn missing_intervals_handles_overlaps_and_invalid_requests() {
716 let intervals = [
717 ClosedInterval::new(10, 30).unwrap(),
718 ClosedInterval::new(20, 40).unwrap(),
719 ClosedInterval::new(60, 80).unwrap(),
720 ];
721
722 assert_eq!(
723 missing_intervals(1, 100, &intervals),
724 vec![
725 ClosedInterval::new(1, 9).unwrap(),
726 ClosedInterval::new(41, 59).unwrap(),
727 ClosedInterval::new(81, 100).unwrap(),
728 ],
729 );
730 assert_eq!(missing_intervals(100, 1, &intervals), Vec::new());
731 }
732}