Skip to main content

timeseries_table_format/coverage/
layout.rs

1//! Coverage on-disk layout helpers.
2//!
3//! These helpers define:
4//! - how coverage ids are validated
5//! - how coverage sidecar keys are constructed (relative to the table root)
6//! - deterministic id derivation helpers for per-segment and table snapshots
7//!
8//! Note: these functions return canonical slash-separated object keys. The
9//! storage backend is responsible for resolving them under its table root.
10
11use snafu::Snafu;
12use uuid::Uuid;
13
14use crate::metadata::table_metadata::{IndexKind, IndexSpec, TimeBucket};
15
16/// Root directory for coverage data.
17pub const COVERAGE_ROOT_DIR: &str = "_coverage";
18/// Directory for segment coverage data.
19pub const SEGMENT_COVERAGE_DIR: &str = "_coverage/segments";
20/// Directory for table snapshot coverage data.
21pub const TABLE_SNAPSHOT_DIR: &str = "_coverage/table";
22/// File extension for coverage files.
23pub const COVERAGE_EXT: &str = "roar";
24
25/// Errors that can occur during coverage layout operations.
26#[derive(Debug, Snafu)]
27pub enum CoverageLayoutError {
28    /// Returned when an invalid coverage ID is provided.
29    #[snafu(display("Invalid coverage id: {coverage_id}"))]
30    InvalidCoverageId {
31        /// The invalid coverage ID.
32        coverage_id: String,
33    },
34}
35
36/// Validates that a coverage ID meets security and format requirements.
37///
38/// A valid coverage ID must:
39/// - Not be empty and not exceed 128 characters
40/// - Not contain path separators (`/`, `\\`) or `..` sequences
41/// - Only contain ASCII alphanumeric characters, dots, underscores, and hyphens
42pub fn validate_coverage_id(coverage_id: &str) -> Result<(), CoverageLayoutError> {
43    if coverage_id.is_empty() || coverage_id.len() > 128 {
44        return Err(CoverageLayoutError::InvalidCoverageId {
45            coverage_id: coverage_id.to_string(),
46        });
47    }
48
49    // Require at least one alphanumeric
50    if !coverage_id.chars().any(|c| c.is_ascii_alphanumeric()) {
51        return Err(CoverageLayoutError::InvalidCoverageId {
52            coverage_id: coverage_id.to_string(),
53        });
54    }
55
56    // Reject leading dot
57    if coverage_id.starts_with('.') {
58        return Err(CoverageLayoutError::InvalidCoverageId {
59            coverage_id: coverage_id.to_string(),
60        });
61    }
62
63    // Reject any path separator and any ".." component-ish content.
64    if coverage_id.contains('/') || coverage_id.contains('\\') || coverage_id.contains("..") {
65        return Err(CoverageLayoutError::InvalidCoverageId {
66            coverage_id: coverage_id.to_string(),
67        });
68    }
69
70    // Restrict to a conservative ASCII allowlist.
71    let ok = coverage_id
72        .chars()
73        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
74
75    if !ok {
76        return Err(CoverageLayoutError::InvalidCoverageId {
77            coverage_id: coverage_id.to_string(),
78        });
79    }
80
81    Ok(())
82}
83
84/// Relative object key: `_coverage/segments/<coverage_id>.roar`
85pub fn segment_coverage_key(coverage_id: &str) -> Result<String, CoverageLayoutError> {
86    validate_coverage_id(coverage_id)?;
87    Ok(format!(
88        "{SEGMENT_COVERAGE_DIR}/{coverage_id}.{COVERAGE_EXT}"
89    ))
90}
91
92/// Relative object key: `_coverage/table/<version>-<snapshot_id>.roar`
93pub fn table_snapshot_key(version: u64, snapshot_id: &str) -> Result<String, CoverageLayoutError> {
94    validate_coverage_id(snapshot_id)?;
95    Ok(format!(
96        "{TABLE_SNAPSHOT_DIR}/{version}-{snapshot_id}.{COVERAGE_EXT}"
97    ))
98}
99
100fn coverage_id_v2(
101    domain_prefix: &[u8],
102    output_prefix: &str,
103    index: &IndexSpec,
104    coverage_bytes: &[u8],
105) -> String {
106    let mut h = blake3::Hasher::new();
107
108    // domain separation
109    h.update(domain_prefix);
110    h.update(b"\0");
111
112    h.update(index.column.as_bytes());
113    h.update(b"\0");
114
115    match &index.kind {
116        IndexKind::Timestamp { bucket, timezone } => {
117            h.update(b"T");
118            hash_time_bucket(&mut h, bucket);
119            h.update(b"\0");
120            match timezone {
121                Some(timezone) => {
122                    h.update(b"S");
123                    h.update(timezone.as_bytes());
124                }
125                None => {
126                    h.update(b"N");
127                }
128            }
129        }
130        IndexKind::Int64 { bucket_width } => {
131            h.update(b"I");
132            h.update(&bucket_width.get().to_le_bytes());
133        }
134        IndexKind::UInt64 { bucket_width } => {
135            h.update(b"U");
136            h.update(&bucket_width.get().to_le_bytes());
137        }
138    }
139
140    h.update(b"\0");
141    h.update(coverage_bytes);
142
143    let hex = h.finalize().to_hex();
144    format!("{output_prefix}-{}", &hex[..32])
145}
146
147fn entity_coverage_id_v1(
148    domain_prefix: &[u8],
149    output_prefix: &str,
150    index: &IndexSpec,
151    coverage_bytes: &[u8],
152) -> String {
153    let mut h = blake3::Hasher::new();
154    h.update(domain_prefix);
155    h.update(b"\0");
156
157    h.update(b"C");
158    hash_len_prefixed(&mut h, index.column.as_bytes());
159    h.update(b"E");
160    hash_usize(&mut h, index.entity_columns.len());
161    for column in &index.entity_columns {
162        hash_len_prefixed(&mut h, column.as_bytes());
163    }
164    h.update(b"K");
165    match &index.kind {
166        IndexKind::Timestamp { bucket, timezone } => {
167            h.update(b"T");
168            hash_time_bucket(&mut h, bucket);
169            match timezone {
170                Some(timezone) => {
171                    h.update(b"S");
172                    hash_len_prefixed(&mut h, timezone.as_bytes());
173                }
174                None => {
175                    h.update(b"N");
176                }
177            }
178        }
179        IndexKind::Int64 { bucket_width } => {
180            h.update(b"I");
181            h.update(&bucket_width.get().to_le_bytes());
182        }
183        IndexKind::UInt64 { bucket_width } => {
184            h.update(b"U");
185            h.update(&bucket_width.get().to_le_bytes());
186        }
187    }
188    h.update(b"\0");
189    h.update(coverage_bytes);
190
191    let hex = h.finalize().to_hex();
192    format!("{output_prefix}-{}", &hex[..32])
193}
194
195fn hash_len_prefixed(hasher: &mut blake3::Hasher, bytes: &[u8]) {
196    hash_usize(hasher, bytes.len());
197    hasher.update(bytes);
198}
199
200fn hash_usize(hasher: &mut blake3::Hasher, value: usize) {
201    hasher.update(value.to_string().as_bytes());
202    hasher.update(b":");
203}
204
205fn hash_time_bucket(hasher: &mut blake3::Hasher, bucket: &TimeBucket) {
206    match bucket {
207        TimeBucket::Seconds(n) => {
208            hasher.update(b"S");
209            hasher.update(&n.to_le_bytes());
210        }
211        TimeBucket::Minutes(n) => {
212            hasher.update(b"M");
213            hasher.update(&n.to_le_bytes());
214        }
215        TimeBucket::Hours(n) => {
216            hasher.update(b"H");
217            hasher.update(&n.to_le_bytes());
218        }
219        TimeBucket::Days(n) => {
220            hasher.update(b"D");
221            hasher.update(&n.to_le_bytes());
222        }
223    }
224}
225
226/// Deterministically derive a safe content id for segment coverage.
227pub fn segment_coverage_id_v2(index: &IndexSpec, coverage_bytes: &[u8]) -> String {
228    coverage_id_v2(b"segcov-v2", "segcov", index, coverage_bytes)
229}
230
231/// Deterministically derive a safe content id for table snapshot coverage.
232pub fn table_coverage_id_v2(index: &IndexSpec, coverage_bytes: &[u8]) -> String {
233    coverage_id_v2(b"tblcov-v2", "tblcov", index, coverage_bytes)
234}
235
236/// Derive a content id for an entity-scoped segment coverage sidecar.
237pub(crate) fn segment_entity_coverage_id_v1(index: &IndexSpec, coverage_bytes: &[u8]) -> String {
238    entity_coverage_id_v1(b"entity-segcov-v1", "segcov", index, coverage_bytes)
239}
240
241/// Derive a content id for an entity-scoped table coverage snapshot.
242pub(crate) fn table_entity_coverage_id_v1(index: &IndexSpec, coverage_bytes: &[u8]) -> String {
243    entity_coverage_id_v1(b"entity-tblcov-v1", "tblcov", index, coverage_bytes)
244}
245
246/// Add a writer-owned suffix to a deterministic coverage content id.
247pub(crate) fn coverage_file_id_for_attempt(content_id: &str, attempt_id: &Uuid) -> String {
248    format!("{content_id}-{attempt_id}")
249}
250
251#[cfg(test)]
252mod tests {
253    use std::num::NonZeroU64;
254
255    use super::*;
256
257    fn timestamp_index(column: &str, bucket: TimeBucket) -> IndexSpec {
258        IndexSpec {
259            column: column.to_string(),
260            entity_columns: Vec::new(),
261            kind: IndexKind::Timestamp {
262                bucket,
263                timezone: None,
264            },
265        }
266    }
267
268    #[test]
269    fn validate_coverage_id_accepts_valid_ids() {
270        let long = "a".repeat(128);
271        let valid_ids = ["abc", "A_B-1.2", long.as_str()];
272
273        for id in valid_ids {
274            validate_coverage_id(id).expect("valid id should pass");
275        }
276    }
277
278    #[test]
279    fn validate_coverage_id_rejects_empty_or_too_long() {
280        let too_long = "x".repeat(129);
281        assert!(validate_coverage_id("").is_err());
282        assert!(validate_coverage_id(&too_long).is_err());
283    }
284
285    #[test]
286    fn validate_coverage_id_rejects_path_components() {
287        for id in ["a/b", "a\\b", "a..b", "..", "../etc"] {
288            assert!(validate_coverage_id(id).is_err(), "id `{id}` should fail");
289        }
290    }
291
292    #[test]
293    fn validate_coverage_id_rejects_disallowed_chars() {
294        for id in ["space id", "id*", "id@", "id$", "id:"] {
295            assert!(validate_coverage_id(id).is_err(), "id `{id}` should fail");
296        }
297    }
298
299    #[test]
300    fn segment_coverage_key_formats_and_validates() {
301        let id = "seg-001";
302        let key = segment_coverage_key(id).expect("valid id");
303        assert_eq!(key, "_coverage/segments/seg-001.roar");
304
305        // Ensure validation runs
306        assert!(segment_coverage_key("bad/id").is_err());
307    }
308
309    #[test]
310    fn table_snapshot_key_formats() {
311        let key = table_snapshot_key(42, "snap-001").expect("valid snapshot id");
312        assert_eq!(key, "_coverage/table/42-snap-001.roar");
313    }
314
315    #[test]
316    fn segment_coverage_id_is_deterministic_and_valid() {
317        let index = timestamp_index("ts", TimeBucket::Minutes(1));
318        let bytes = b"bitmap-bytes";
319
320        let id1 = segment_coverage_id_v2(&index, bytes);
321        let id2 = segment_coverage_id_v2(&index, bytes);
322
323        assert_eq!(id1, id2, "same inputs must produce stable id");
324        assert!(id1.starts_with("segcov-"));
325        assert_eq!(id1.len(), "segcov-".len() + 32, "prefix + 32 hex chars");
326        validate_coverage_id(&id1).expect("derived id should be valid");
327    }
328
329    #[test]
330    fn segment_coverage_id_changes_with_inputs() {
331        let bytes = b"bytes";
332
333        let base_index = timestamp_index("ts", TimeBucket::Seconds(5));
334        let base = segment_coverage_id_v2(&base_index, bytes);
335        let different_bucket =
336            segment_coverage_id_v2(&timestamp_index("ts", TimeBucket::Hours(5)), bytes);
337        let different_column = segment_coverage_id_v2(
338            &timestamp_index("event_time", TimeBucket::Seconds(5)),
339            bytes,
340        );
341        let different_kind = segment_coverage_id_v2(
342            &IndexSpec {
343                column: "ts".to_string(),
344                entity_columns: Vec::new(),
345                kind: IndexKind::UInt64 {
346                    bucket_width: NonZeroU64::new(5).unwrap(),
347                },
348            },
349            bytes,
350        );
351        let different_integer_domain = segment_coverage_id_v2(
352            &IndexSpec {
353                column: "ts".to_string(),
354                entity_columns: Vec::new(),
355                kind: IndexKind::Int64 {
356                    bucket_width: NonZeroU64::new(5).unwrap(),
357                },
358            },
359            bytes,
360        );
361        let different_width = segment_coverage_id_v2(
362            &IndexSpec {
363                column: "ts".to_string(),
364                entity_columns: Vec::new(),
365                kind: IndexKind::UInt64 {
366                    bucket_width: NonZeroU64::new(6).unwrap(),
367                },
368            },
369            bytes,
370        );
371        let different_bytes = segment_coverage_id_v2(&base_index, b"other");
372
373        assert_ne!(base, different_bucket, "bucket spec should affect id");
374        assert_ne!(base, different_column, "index column should affect id");
375        assert_ne!(base, different_kind, "index kind should affect id");
376        assert_ne!(different_kind, different_integer_domain);
377        assert_ne!(different_kind, different_width);
378        assert_ne!(base, different_bytes, "coverage bytes should affect id");
379    }
380
381    #[test]
382    fn table_coverage_id_is_deterministic_and_valid() {
383        let index = timestamp_index("ts", TimeBucket::Hours(1));
384        let bytes = b"table-bitmap";
385
386        let id1 = table_coverage_id_v2(&index, bytes);
387        let id2 = table_coverage_id_v2(&index, bytes);
388
389        assert_eq!(id1, id2, "same inputs must produce stable id");
390        assert!(id1.starts_with("tblcov-"));
391        assert_eq!(id1.len(), "tblcov-".len() + 32, "prefix + 32 hex chars");
392        validate_coverage_id(&id1).expect("derived id should be valid");
393    }
394
395    #[test]
396    fn table_coverage_id_changes_with_inputs() {
397        let bytes = b"bytes";
398
399        let base_index = timestamp_index("ts", TimeBucket::Minutes(15));
400        let base = table_coverage_id_v2(&base_index, bytes);
401        let different_bucket =
402            table_coverage_id_v2(&timestamp_index("ts", TimeBucket::Days(1)), bytes);
403        let different_column = table_coverage_id_v2(
404            &timestamp_index("event_time", TimeBucket::Minutes(15)),
405            bytes,
406        );
407        let different_bytes = table_coverage_id_v2(&base_index, b"other");
408
409        assert_ne!(base, different_bucket, "bucket spec should affect id");
410        assert_ne!(base, different_column, "index column should affect id");
411        assert_ne!(base, different_bytes, "coverage bytes should affect id");
412    }
413
414    #[test]
415    fn entity_coverage_ids_include_ordered_entity_columns() {
416        let index = IndexSpec {
417            column: "ts".to_string(),
418            entity_columns: vec!["symbol".to_string(), "venue".to_string()],
419            kind: IndexKind::Timestamp {
420                bucket: TimeBucket::Minutes(1),
421                timezone: None,
422            },
423        };
424        let mut renamed = index.clone();
425        renamed.entity_columns[0] = "device".to_string();
426        let mut reordered = index.clone();
427        reordered.entity_columns.reverse();
428        let bytes = b"entity-coverage-bytes";
429
430        let segment = segment_entity_coverage_id_v1(&index, bytes);
431        assert_ne!(segment, segment_entity_coverage_id_v1(&renamed, bytes));
432        assert_ne!(segment, segment_entity_coverage_id_v1(&reordered, bytes));
433
434        let table = table_entity_coverage_id_v1(&index, bytes);
435        assert_ne!(table, table_entity_coverage_id_v1(&renamed, bytes));
436        assert_ne!(table, table_entity_coverage_id_v1(&reordered, bytes));
437    }
438
439    #[test]
440    fn coverage_file_ids_are_owned_by_the_append_attempt() {
441        let content_id = "segcov-0123456789abcdef0123456789abcdef";
442        let first = coverage_file_id_for_attempt(content_id, &Uuid::from_u128(1));
443        let second = coverage_file_id_for_attempt(content_id, &Uuid::from_u128(2));
444
445        assert_ne!(first, second);
446        validate_coverage_id(&first).expect("first id should be valid");
447        validate_coverage_id(&second).expect("second id should be valid");
448    }
449}