Skip to main content

timeseries_table_format/coverage/
io.rs

1//! Coverage sidecar file management.
2//!
3//! This module provides helpers for reading and writing coverage data to
4//! sidecar files in the table storage directory. It bridges the coverage
5//! module (serialization/deserialization) with the storage layer (disk I/O).
6//!
7//! # Overview
8//!
9//! Coverage sidecars are stored alongside table data and segments to track which
10//! index intervals have been observed. This module abstracts the I/O details:
11//!
12//! - Serializes [`Coverage`] instances to bytes using the RoaringTreemap format.
13//! - Writes bytes to the table storage with atomic or new-only semantics.
14//! - Handles errors from layout validation, serialization, and storage layers.
15//!
16//! # Atomic vs. New-Only Writes
17//!
18//! - **Atomic** writes safely overwrite existing sidecars.
19//! - **New-only** writes fail if the file already exists.
20
21use std::path::Path;
22
23use snafu::{Backtrace, Snafu};
24
25use crate::{
26    coverage::layout::CoverageLayoutError,
27    coverage::{
28        Coverage, EntityCoverage,
29        serde::{CoverageCodecError, coverage_from_bytes, entity_coverage_from_bytes},
30    },
31    metadata::schema_compat::SchemaCompatibilityError,
32    storage::{self, StorageError, TableLocation},
33};
34
35#[cfg(test)]
36use crate::coverage::serde::coverage_to_bytes;
37
38/// Errors that can occur during coverage sidecar operations.
39///
40/// These errors propagate from lower layers: layout validation, serialization,
41/// storage, and file I/O. Callers should inspect the variant to determine
42/// the nature of the failure and how to recover.
43#[derive(Debug, Snafu)]
44#[non_exhaustive]
45pub enum CoverageSidecarError {
46    /// Layout validation error (e.g., invalid coverage ID or path).
47    #[snafu(context(false), display("Coverage sidecar layout error: {source}"))]
48    Layout {
49        /// The underlying layout error.
50        source: CoverageLayoutError,
51        /// Backtrace captured at the sidecar boundary.
52        backtrace: Backtrace,
53    },
54
55    /// Coverage serialization or deserialization failed.
56    #[snafu(context(false), display("Coverage sidecar codec error: {source}"))]
57    Codec {
58        /// The underlying coverage codec error.
59        #[snafu(source, backtrace)]
60        source: CoverageCodecError,
61    },
62
63    /// An entity identity does not match the table's configured schema.
64    #[snafu(
65        context(false),
66        display("Entity coverage identity does not match the table schema: {source}")
67    )]
68    EntityIdentitySchema {
69        /// Identity arity or scalar-type mismatch.
70        #[snafu(source(from(SchemaCompatibilityError, Box::new)), backtrace)]
71        source: Box<SchemaCompatibilityError>,
72    },
73
74    /// Storage I/O error (read, write, or metadata operations).
75    #[snafu(
76        context(false),
77        display("Storage error while accessing coverage sidecar: {source}")
78    )]
79    Storage {
80        /// The underlying storage error.
81        #[snafu(source, backtrace)]
82        source: StorageError,
83    },
84}
85
86impl CoverageSidecarError {
87    pub(crate) fn storage_cleanup_failed(&self) -> bool {
88        matches!(
89            self,
90            Self::Storage {
91                source: StorageError::CleanupFailed { .. }
92            }
93        )
94    }
95}
96
97/// Write a coverage bitmap to a sidecar file using atomic semantics.
98///
99/// Atomically writes the given [`Coverage`] to a file at `rel_path` within the
100/// table storage. If the file already exists, it will be overwritten. This is
101/// suitable for updating table-level coverage snapshots or refreshing segment
102/// coverage metadata.
103///
104/// # Arguments
105///
106/// * `location` - The table storage location.
107/// * `rel_path` - The relative path within the table root where the sidecar should be written.
108/// * `cov` - The coverage bitmap to serialize and write.
109///
110/// # Returns
111///
112/// Returns `Ok(())` if the sidecar was written successfully, or an error if
113/// serialization or storage fails.
114///
115/// # Errors
116///
117/// Returns [`CoverageSidecarError`] if:
118/// - Serialization of the coverage fails ([`CoverageSidecarError::Codec`]).
119/// - Storage I/O fails ([`CoverageSidecarError::Storage`]).
120#[cfg(test)]
121pub(crate) async fn write_coverage_sidecar_atomic(
122    location: &TableLocation,
123    rel_path: &Path,
124    cov: &Coverage,
125) -> Result<(), CoverageSidecarError> {
126    let bytes = coverage_to_bytes(cov)?;
127    storage::write_atomic(location.as_ref(), rel_path, &bytes).await?;
128    Ok(())
129}
130
131/// Write a coverage bitmap to a sidecar file with exclusive creation.
132///
133/// Writes the given [`Coverage`] to a file at `rel_path` within the table storage,
134/// but only if the file does not already exist. This is suitable for creating
135/// per-segment coverage files for the first time, ensuring that accidental
136/// overwrites do not occur.
137///
138/// # Arguments
139///
140/// * `location` - The table storage location.
141/// * `rel_path` - The relative path within the table root where the sidecar should be written.
142/// * `cov` - The coverage bitmap to serialize and write.
143///
144/// # Returns
145///
146/// Returns `Ok(())` if the sidecar was created successfully, or an error if
147/// the file already exists or if serialization/storage fails.
148///
149/// # Errors
150///
151/// Returns [`CoverageSidecarError`] if:
152/// - Serialization of the coverage fails ([`CoverageSidecarError::Codec`]).
153/// - The file already exists (storage layer dependent).
154/// - Storage I/O fails for other reasons ([`CoverageSidecarError::Storage`]).
155#[cfg(test)]
156async fn write_coverage_sidecar_new(
157    location: &TableLocation,
158    rel_path: &Path,
159    cov: &Coverage,
160) -> Result<(), CoverageSidecarError> {
161    let bytes = coverage_to_bytes(cov)?;
162    storage::write_new(location.as_ref(), rel_path, &bytes).await?;
163    Ok(())
164}
165
166/// Write a coverage bitmap as bytes to a sidecar file with exclusive creation.
167///
168/// # Errors
169///
170/// Returns [`CoverageSidecarError::Storage`] when the storage layer rejects the write,
171/// including when the file already exists. Callers must not assume that an
172/// existing file belongs to the current write attempt.
173pub async fn write_coverage_sidecar_new_bytes(
174    location: &TableLocation,
175    rel_path: &Path,
176    bytes: &[u8],
177) -> Result<(), CoverageSidecarError> {
178    storage::write_new(location.as_ref(), rel_path, bytes).await?;
179    Ok(())
180}
181
182/// Read a coverage bitmap from a sidecar file.
183///
184/// Reads and deserializes a [`Coverage`] instance from a sidecar file at `rel_path`
185/// within the table storage. Missing files remain [`StorageError::NotFound`]
186/// sources inside [`CoverageSidecarError::Storage`].
187///
188/// # Arguments
189///
190/// * `location` - The table storage location.
191/// * `rel_path` - The relative path within the table root where the sidecar is located.
192///
193/// # Returns
194///
195/// Returns `Ok(coverage)` if the sidecar was read and deserialized successfully,
196/// or an error if the file is not found or deserialization fails.
197///
198/// # Errors
199///
200/// Returns [`CoverageSidecarError`] if:
201/// - The file does not exist ([`CoverageSidecarError::Storage`]).
202/// - Deserialization of the coverage fails ([`CoverageSidecarError::Codec`]).
203/// - Storage I/O fails ([`CoverageSidecarError::Storage`]).
204pub async fn read_coverage_sidecar(
205    location: &TableLocation,
206    rel_path: &Path,
207) -> Result<Coverage, CoverageSidecarError> {
208    let bytes = storage::read_all_bytes(location.as_ref(), rel_path).await?;
209    Ok(coverage_from_bytes(&bytes)?)
210}
211
212/// Read entity-scoped coverage from a sidecar file.
213///
214/// # Errors
215///
216/// Returns [`CoverageSidecarError`] when storage or entity coverage decoding fails.
217pub async fn read_entity_coverage_sidecar(
218    location: &TableLocation,
219    rel_path: &Path,
220) -> Result<EntityCoverage, CoverageSidecarError> {
221    let bytes = storage::read_all_bytes(location.as_ref(), rel_path).await?;
222    Ok(entity_coverage_from_bytes(&bytes)?)
223}
224
225#[cfg(test)]
226mod tests {
227    use std::{error::Error as _, io};
228
229    use snafu::ErrorCompat;
230
231    use super::*;
232    use crate::{
233        coverage::{
234            EntityIdentity,
235            serde::{coverage_from_bytes, entity_coverage_to_bytes},
236        },
237        storage::{StorageBackendError, StorageLocation},
238    };
239    use tempfile::TempDir;
240
241    fn temp_location() -> (TempDir, TableLocation) {
242        let tmp = TempDir::new().expect("tempdir");
243        let loc = TableLocation::local(tmp.path());
244        (tmp, loc)
245    }
246
247    fn storage_source(error: &CoverageSidecarError) -> &StorageError {
248        error
249            .source()
250            .and_then(|source| source.downcast_ref::<StorageError>())
251            .expect("storage source")
252    }
253
254    fn filesystem_source(error: &StorageError) -> &io::Error {
255        error
256            .source()
257            .and_then(|source| source.downcast_ref::<StorageBackendError>())
258            .and_then(|source| source.source())
259            .and_then(|source| source.downcast_ref::<io::Error>())
260            .expect("filesystem source")
261    }
262
263    #[tokio::test]
264    async fn write_atomic_overwrites_existing() {
265        let (_tmp, loc) = temp_location();
266        let rel = Path::new("_coverage/table/1.roar");
267
268        let cov1 = Coverage::from_iter(vec![1u64, 2, 3]);
269        write_coverage_sidecar_atomic(&loc, rel, &cov1)
270            .await
271            .expect("first write");
272
273        // Overwrite with different coverage
274        let cov2 = Coverage::from_iter(vec![10u64, 11]);
275        write_coverage_sidecar_atomic(&loc, rel, &cov2)
276            .await
277            .expect("overwrite");
278
279        // Read back and verify it matches the second write
280        let abs = match &loc.as_ref() {
281            StorageLocation::Local(root) => root.join(rel),
282        };
283        let bytes = std::fs::read(abs).expect("read file");
284        let restored = coverage_from_bytes(&bytes).expect("deserialize");
285        assert_eq!(cov2.present(), restored.present());
286    }
287
288    #[tokio::test]
289    async fn write_new_fails_if_exists() {
290        let (_tmp, loc) = temp_location();
291        let rel = Path::new("_coverage/segments/seg-1.roar");
292
293        let cov = Coverage::from_iter(vec![5u64]);
294        write_coverage_sidecar_new(&loc, rel, &cov)
295            .await
296            .expect("first write");
297
298        let err = write_coverage_sidecar_new(&loc, rel, &cov)
299            .await
300            .expect_err("second write should fail");
301
302        match err {
303            CoverageSidecarError::Storage {
304                source: StorageError::AlreadyExists { .. },
305                ..
306            } => {}
307            _ => panic!("expected AlreadyExists storage error"),
308        }
309    }
310
311    #[tokio::test]
312    async fn read_sidecar_round_trip() {
313        let (_tmp, loc) = temp_location();
314        let rel = Path::new("_coverage/table/2.roar");
315
316        let cov = Coverage::from_iter(vec![1u64, 3, 5, 7]);
317        write_coverage_sidecar_atomic(&loc, rel, &cov)
318            .await
319            .expect("write sidecar");
320
321        let restored = read_coverage_sidecar(&loc, rel)
322            .await
323            .expect("read sidecar");
324        assert_eq!(cov.present(), restored.present());
325    }
326
327    #[tokio::test]
328    async fn read_entity_sidecar_round_trip() {
329        let (_tmp, loc) = temp_location();
330        let rel = Path::new("_coverage/table/entity.roar");
331        let mut coverage = EntityCoverage::empty();
332        coverage.union_coverage(
333            EntityIdentity::try_new(vec!["A".into()]).unwrap(),
334            Coverage::from_iter([1, 2]),
335        );
336        let bytes = entity_coverage_to_bytes(&coverage).unwrap();
337        write_coverage_sidecar_new_bytes(&loc, rel, &bytes)
338            .await
339            .expect("write entity sidecar");
340
341        assert_eq!(
342            read_entity_coverage_sidecar(&loc, rel)
343                .await
344                .expect("read entity sidecar"),
345            coverage
346        );
347    }
348
349    #[tokio::test]
350    async fn read_sidecar_missing_preserves_storage_not_found() {
351        let (_tmp, loc) = temp_location();
352        let rel = Path::new("_coverage/table/missing.roar");
353
354        let err = read_coverage_sidecar(&loc, rel)
355            .await
356            .expect_err("should be missing");
357
358        let sidecar_backtrace = ErrorCompat::backtrace(&err).expect("sidecar backtrace");
359        let storage = storage_source(&err);
360        let storage_backtrace = ErrorCompat::backtrace(storage).expect("storage backtrace");
361
362        assert!(matches!(
363            storage,
364            StorageError::NotFound { path, .. } if path.contains("missing.roar")
365        ));
366        assert_eq!(filesystem_source(storage).kind(), io::ErrorKind::NotFound);
367        assert!(std::ptr::eq(sidecar_backtrace, storage_backtrace));
368    }
369
370    #[tokio::test]
371    async fn read_sidecar_corrupt_bytes_returns_codec_error() {
372        let (tmp, loc) = temp_location();
373        let rel = Path::new("_coverage/table/corrupt.roar");
374
375        // Write garbage bytes to the expected path
376        let abs = match &loc.as_ref() {
377            StorageLocation::Local(root) => root.join(rel),
378        };
379        std::fs::create_dir_all(abs.parent().unwrap()).expect("create dirs");
380        std::fs::write(&abs, b"not a bitmap").expect("write corrupt");
381
382        let err = read_coverage_sidecar(&loc, rel)
383            .await
384            .expect_err("should fail to deserialize");
385
386        let sidecar_backtrace = ErrorCompat::backtrace(&err).expect("sidecar backtrace");
387        let codec = err
388            .source()
389            .and_then(|source| source.downcast_ref::<CoverageCodecError>())
390            .expect("codec source");
391        let codec_backtrace = ErrorCompat::backtrace(codec).expect("codec backtrace");
392
393        assert!(matches!(
394            codec,
395            CoverageCodecError::BitmapDeserialization { .. }
396        ));
397        assert!(codec.source().is_some());
398        assert!(std::ptr::eq(sidecar_backtrace, codec_backtrace));
399
400        drop(tmp); // ensure tempdir not optimized away
401    }
402
403    #[cfg(unix)]
404    #[tokio::test]
405    async fn read_sidecar_permission_denied_remains_a_storage_error() {
406        use std::os::unix::fs::PermissionsExt;
407
408        let (_tmp, loc) = temp_location();
409        let rel = Path::new("_coverage/table/denied.roar");
410        let absolute = match loc.as_ref() {
411            StorageLocation::Local(root) => root.join(rel),
412        };
413        std::fs::create_dir_all(absolute.parent().unwrap()).expect("create dirs");
414        std::fs::write(&absolute, coverage_to_bytes(&Coverage::empty()).unwrap())
415            .expect("write sidecar");
416        let original_permissions = std::fs::metadata(&absolute).unwrap().permissions();
417        let mut denied_permissions = original_permissions.clone();
418        denied_permissions.set_mode(0o0);
419        std::fs::set_permissions(&absolute, denied_permissions).expect("deny reads");
420
421        let error = read_coverage_sidecar(&loc, rel)
422            .await
423            .expect_err("read must be denied");
424        std::fs::set_permissions(&absolute, original_permissions).expect("restore permissions");
425
426        let storage = storage_source(&error);
427        assert!(matches!(storage, StorageError::OtherIo { .. }));
428        assert_eq!(
429            filesystem_source(storage).kind(),
430            io::ErrorKind::PermissionDenied
431        );
432    }
433}