Skip to main content

ci_engine/result_cache/
store.rs

1// SPDX-License-Identifier: Apache-2.0
2//! In-memory and filesystem stores for portable result-cache entries.
3
4use std::{
5    collections::BTreeMap,
6    fs::File,
7    io::{Read, Write},
8    path::{Path, PathBuf},
9    sync::Mutex,
10};
11
12use super::{
13    entry::{ResultCacheEntry, ResultCacheError},
14    key::{entry_id, CacheKey},
15};
16
17/// Lookup and seed a content-addressed result cache.
18pub trait ResultCache {
19    /// Load the entry for `key` + `check_name`, if a valid one is stored.
20    fn get(
21        &self,
22        key: &CacheKey,
23        check_name: &str,
24    ) -> Result<Option<ResultCacheEntry>, ResultCacheError>;
25
26    /// Persist `entry` under its recorded triple and check name.
27    fn put(&self, entry: &ResultCacheEntry) -> Result<(), ResultCacheError>;
28}
29
30/// Process-local cache used by tests and as a seed/hit fixture.
31#[derive(Debug, Default)]
32pub struct MemoryResultCache {
33    entries: Mutex<BTreeMap<String, ResultCacheEntry>>,
34}
35
36impl MemoryResultCache {
37    /// Empty cache.
38    #[must_use]
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Snapshot of every stored entry, for inspection and tampering tests.
44    #[must_use]
45    pub fn entries(&self) -> Vec<ResultCacheEntry> {
46        self.entries
47            .lock()
48            .unwrap_or_else(|poisoned| poisoned.into_inner())
49            .values()
50            .cloned()
51            .collect()
52    }
53}
54
55impl ResultCache for MemoryResultCache {
56    fn get(
57        &self,
58        key: &CacheKey,
59        check_name: &str,
60    ) -> Result<Option<ResultCacheEntry>, ResultCacheError> {
61        let entries = self
62            .entries
63            .lock()
64            .unwrap_or_else(|poisoned| poisoned.into_inner());
65        Ok(entries
66            .get(&entry_id(key, check_name))
67            .filter(|entry| entry.is_valid_for(key, check_name))
68            .cloned())
69    }
70
71    fn put(&self, entry: &ResultCacheEntry) -> Result<(), ResultCacheError> {
72        let key = entry.cache_key();
73        self.entries
74            .lock()
75            .unwrap_or_else(|poisoned| poisoned.into_inner())
76            .insert(entry_id(&key, &entry.check_name), entry.clone());
77        Ok(())
78    }
79}
80
81/// Directory-backed cache. Entries are JSON files named by the entry digest.
82///
83/// The layout is portable: copy the directory (or a single entry file) to
84/// another machine and the same triple hits.
85#[derive(Debug, Clone)]
86pub struct FsResultCache {
87    root: PathBuf,
88}
89
90impl FsResultCache {
91    /// Use `root` as the cache directory. Created on first write.
92    #[must_use]
93    pub fn new(root: impl Into<PathBuf>) -> Self {
94        Self { root: root.into() }
95    }
96
97    /// Filesystem root this cache reads and writes.
98    #[must_use]
99    pub fn root(&self) -> &Path {
100        &self.root
101    }
102
103    fn path_for(&self, key: &CacheKey, check_name: &str) -> PathBuf {
104        let id = entry_id(key, check_name);
105        self.root.join(&id[..2]).join(format!("{id}.json"))
106    }
107}
108
109impl ResultCache for FsResultCache {
110    fn get(
111        &self,
112        key: &CacheKey,
113        check_name: &str,
114    ) -> Result<Option<ResultCacheEntry>, ResultCacheError> {
115        let path = self.path_for(key, check_name);
116        let mut file = match File::open(&path) {
117            Ok(file) => file,
118            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
119            Err(error) => return Err(error.into()),
120        };
121        let mut bytes = Vec::new();
122        file.read_to_end(&mut bytes)?;
123        let Ok(entry) = serde_json::from_slice::<ResultCacheEntry>(&bytes) else {
124            return Ok(None);
125        };
126        if entry.is_valid_for(key, check_name) {
127            Ok(Some(entry))
128        } else {
129            Ok(None)
130        }
131    }
132
133    fn put(&self, entry: &ResultCacheEntry) -> Result<(), ResultCacheError> {
134        let key = entry.cache_key();
135        let path = self.path_for(&key, &entry.check_name);
136        let bytes = serde_json::to_vec(entry).map_err(|error| {
137            std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string())
138        })?;
139        write_atomic(&path, &bytes)
140    }
141}
142
143fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ResultCacheError> {
144    let parent = path.parent().ok_or_else(|| {
145        std::io::Error::new(
146            std::io::ErrorKind::InvalidInput,
147            "result cache entry path has no parent",
148        )
149    })?;
150    std::fs::create_dir_all(parent)?;
151    let unique = format!(
152        ".{}.{}-{}.tmp",
153        path.file_name()
154            .and_then(|name| name.to_str())
155            .unwrap_or("entry"),
156        std::process::id(),
157        std::time::SystemTime::now()
158            .duration_since(std::time::UNIX_EPOCH)
159            .map(|duration| duration.as_nanos())
160            .unwrap_or(0)
161    );
162    let tmp = parent.join(unique);
163    let written = write_tmp_then_rename(&tmp, path, bytes);
164    if written.is_err() {
165        let _cleanup = std::fs::remove_file(&tmp);
166    }
167    written
168}
169
170fn write_tmp_then_rename(tmp: &Path, dest: &Path, bytes: &[u8]) -> Result<(), ResultCacheError> {
171    let mut file = File::create(tmp)?;
172    file.write_all(bytes)?;
173    file.sync_all()?;
174    std::fs::rename(tmp, dest)?;
175    Ok(())
176}