Skip to main content

nautilus_persistence/common/
coverage.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//! Closed nanosecond interval coverage for catalog data, empty, and deleted segments.
17
18use 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/// What a coverage segment records in the catalog log.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum CoverageKind {
42    Data,
43    Empty,
44    Deleted,
45}
46
47/// A data, empty, or deleted segment recorded for a catalog key.
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub struct CoverageSegment {
50    pub interval: ClosedInterval,
51    pub kind: CoverageKind,
52}
53
54impl CoverageSegment {
55    /// Creates a segment if `start <= end`.
56    #[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/// Effective data and known-empty coverage intervals for one request key.
66#[derive(Clone, Debug, Default, Eq, PartialEq)]
67pub struct CoverageIntervals {
68    /// Intervals backed by stored data.
69    pub data: Vec<ClosedInterval>,
70    /// Intervals explicitly known to contain no data.
71    pub empty: Vec<ClosedInterval>,
72}
73
74/// Durable coverage row shared by table-backed catalog implementations.
75#[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
90/// Converts a durable row to a closed segment.
91///
92/// Returns `None` for an invalid inverted timestamp range instead of normalizing it.
93impl 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
99/// Converts valid durable coverage rows to closed segments, skipping inverted ranges.
100pub 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/// Returns the latest timestamp still covered by data after applying deletions.
108///
109/// Empty coverage rows do not represent stored data and are skipped.
110#[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
153/// # Errors
154///
155/// Returns an error if `ts_init` is missing, has an unsupported type, contains invalid values,
156/// or the selected rows cannot be materialized.
157pub 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/// Orders coverage rows causally. `data_version` is a monotonic ordering token derived
199/// from the writer's base snapshot, not a snapshot handle; rows written before versioning
200/// existed carry `None` and order as version 0, which is correct because every versioned
201/// row postdates them. Ties (including compacted rows sharing one version) fall back to
202/// the creation timestamp, keeping the order total and transitive on mixed catalogs.
203#[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
259/// # Errors
260///
261/// Returns an error if the coverage rows cannot be encoded with the canonical Arrow schema.
262pub 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
315/// # Errors
316///
317/// Returns an error if a batch does not match the canonical coverage schema or contains an
318/// unknown coverage status.
319pub 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/// Merges closed intervals, treating adjacent intervals as contiguous coverage.
440#[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/// Returns the effective union of all data and empty segments after applying delete tombstones.
471#[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/// Returns effective data and known-empty intervals after applying delete tombstones.
490#[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/// Returns the portions of `[request_start, request_end]` not covered by data or empty segments.
516#[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/// Subtracts `deleted` from coverage intervals, merging overlapping and adjacent inputs first.
527#[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/// Returns the portions of `[request_start, request_end]` not covered by closed intervals.
566#[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}