timeseries_table_format/coverage/
layout.rs1use snafu::Snafu;
12use uuid::Uuid;
13
14use crate::metadata::table_metadata::{IndexKind, IndexSpec, TimeBucket};
15
16pub const COVERAGE_ROOT_DIR: &str = "_coverage";
18pub const SEGMENT_COVERAGE_DIR: &str = "_coverage/segments";
20pub const TABLE_SNAPSHOT_DIR: &str = "_coverage/table";
22pub const COVERAGE_EXT: &str = "roar";
24
25#[derive(Debug, Snafu)]
27pub enum CoverageLayoutError {
28 #[snafu(display("Invalid coverage id: {coverage_id}"))]
30 InvalidCoverageId {
31 coverage_id: String,
33 },
34}
35
36pub 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 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 if coverage_id.starts_with('.') {
58 return Err(CoverageLayoutError::InvalidCoverageId {
59 coverage_id: coverage_id.to_string(),
60 });
61 }
62
63 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 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
84pub 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
92pub 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 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
226pub fn segment_coverage_id_v2(index: &IndexSpec, coverage_bytes: &[u8]) -> String {
228 coverage_id_v2(b"segcov-v2", "segcov", index, coverage_bytes)
229}
230
231pub fn table_coverage_id_v2(index: &IndexSpec, coverage_bytes: &[u8]) -> String {
233 coverage_id_v2(b"tblcov-v2", "tblcov", index, coverage_bytes)
234}
235
236pub(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
241pub(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
246pub(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 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(×tamp_index("ts", TimeBucket::Hours(5)), bytes);
337 let different_column = segment_coverage_id_v2(
338 ×tamp_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(×tamp_index("ts", TimeBucket::Days(1)), bytes);
403 let different_column = table_coverage_id_v2(
404 ×tamp_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}