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};
33
34pub const COVERAGE_SCHEMA_VERSION: u32 = 1;
35
36/// What a coverage segment records in the catalog log.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum CoverageKind {
39    Data,
40    Empty,
41    Deleted,
42}
43
44/// A data, empty, or deleted segment recorded for a catalog key.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct CoverageSegment {
47    pub interval: ClosedInterval,
48    pub kind: CoverageKind,
49}
50
51/// Effective data and known-empty coverage intervals for one request key.
52#[derive(Clone, Debug, Default, Eq, PartialEq)]
53pub struct CoverageIntervals {
54    /// Intervals backed by stored data.
55    pub data: Vec<ClosedInterval>,
56    /// Intervals explicitly known to contain no data.
57    pub empty: Vec<ClosedInterval>,
58}
59
60/// Durable coverage row shared by table-backed catalog implementations.
61#[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
76/// Converts a durable row to a closed segment.
77///
78/// Returns `None` for an invalid inverted timestamp range instead of normalizing it.
79impl 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
85/// Converts valid durable coverage rows to closed segments, skipping inverted ranges.
86pub 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/// Returns the latest timestamp still covered by data after applying deletions.
94///
95/// Empty coverage rows do not represent stored data and are skipped.
96#[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
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| 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
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: 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    /// Creates a segment if `start <= end`.
452    #[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/// Merges closed intervals, treating adjacent intervals as contiguous coverage.
462#[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/// Returns the effective union of all data and empty segments after applying delete tombstones.
493#[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/// Returns effective data and known-empty intervals after applying delete tombstones.
513#[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/// Returns the portions of `[request_start, request_end]` not covered by data or empty segments.
545#[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/// Subtracts `deleted` from already merged coverage intervals.
556#[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/// Returns the portions of `[request_start, request_end]` not covered by closed intervals.
595#[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}