cuenv_ci/executor/
backend.rs

1//! Cache Backend Abstraction
2//!
3//! Defines the `CacheBackend` trait for pluggable cache implementations.
4//! Supports both local file-based caching and remote Bazel RE v2 caching.
5
6use crate::ir::{CachePolicy, Task as IRTask};
7use async_trait::async_trait;
8use std::path::Path;
9use thiserror::Error;
10
11/// Error types for cache backend operations
12#[derive(Debug, Error)]
13pub enum BackendError {
14    /// IO error during cache operations (generic, for #[from] compatibility)
15    #[error("Cache IO error: {0}")]
16    Io(#[from] std::io::Error),
17
18    /// IO error with path context for better diagnostics
19    #[error("Failed to {operation} '{path}': {source}")]
20    IoWithContext {
21        operation: &'static str,
22        path: std::path::PathBuf,
23        source: std::io::Error,
24    },
25
26    /// Serialization error
27    #[error("Serialization error: {0}")]
28    Serialization(String),
29
30    /// Remote connection error
31    #[error("Remote cache connection error: {0}")]
32    Connection(String),
33
34    /// Remote cache unavailable (gracefully degradable)
35    ///
36    /// This error indicates the cache is temporarily unavailable but execution
37    /// should continue without caching. Callers should handle this gracefully.
38    #[error("Remote cache unavailable: {0}")]
39    Unavailable(String),
40
41    /// Digest mismatch during download
42    #[error("Digest mismatch: expected {expected}, got {actual}")]
43    DigestMismatch { expected: String, actual: String },
44
45    /// Blob not found in CAS
46    #[error("Blob not found: {digest}")]
47    BlobNotFound { digest: String },
48
49    /// Action result not found
50    #[error("Action result not found for digest: {digest}")]
51    ActionNotFound { digest: String },
52}
53
54impl BackendError {
55    /// Returns true if this error indicates the cache is unavailable but
56    /// execution should continue without caching (graceful degradation).
57    #[must_use]
58    pub fn is_gracefully_degradable(&self) -> bool {
59        matches!(
60            self,
61            BackendError::Unavailable(_)
62                | BackendError::Connection(_)
63                | BackendError::ActionNotFound { .. }
64        )
65    }
66
67    /// Create an IO error with path context
68    pub fn io_with_context(
69        operation: &'static str,
70        path: impl Into<std::path::PathBuf>,
71        source: std::io::Error,
72    ) -> Self {
73        BackendError::IoWithContext {
74            operation,
75            path: path.into(),
76            source,
77        }
78    }
79}
80
81/// Result type for cache backend operations
82pub type BackendResult<T> = std::result::Result<T, BackendError>;
83
84/// Result of a cache lookup
85#[derive(Debug, Clone)]
86pub struct CacheLookupResult {
87    /// Whether the cache entry was found
88    pub hit: bool,
89    /// The digest used for lookup
90    pub key: String,
91    /// Execution duration from cached result (if hit)
92    pub cached_duration_ms: Option<u64>,
93}
94
95impl CacheLookupResult {
96    /// Create a cache miss result
97    #[must_use]
98    pub fn miss(key: impl Into<String>) -> Self {
99        Self {
100            hit: false,
101            key: key.into(),
102            cached_duration_ms: None,
103        }
104    }
105
106    /// Create a cache hit result
107    #[must_use]
108    pub fn hit(key: impl Into<String>, duration_ms: u64) -> Self {
109        Self {
110            hit: true,
111            key: key.into(),
112            cached_duration_ms: Some(duration_ms),
113        }
114    }
115}
116
117/// Output artifact to store in cache
118#[derive(Debug, Clone)]
119pub struct CacheOutput {
120    /// Relative path within workspace
121    pub path: String,
122    /// File contents
123    pub data: Vec<u8>,
124    /// Whether this is executable
125    pub is_executable: bool,
126}
127
128/// Stored task execution result
129#[derive(Debug, Clone)]
130pub struct CacheEntry {
131    /// Standard output
132    pub stdout: Option<String>,
133    /// Standard error
134    pub stderr: Option<String>,
135    /// Exit code
136    pub exit_code: i32,
137    /// Execution duration in milliseconds
138    pub duration_ms: u64,
139    /// Output artifacts
140    pub outputs: Vec<CacheOutput>,
141}
142
143/// Cache backend trait for pluggable cache implementations
144///
145/// Implementations must be thread-safe (`Send + Sync`) for concurrent task execution.
146#[async_trait]
147pub trait CacheBackend: Send + Sync {
148    /// Check if a cached result exists for the given task and digest
149    ///
150    /// # Arguments
151    /// * `task` - The IR task definition
152    /// * `digest` - Pre-computed digest (cache key)
153    /// * `policy` - Effective cache policy (may be overridden globally)
154    ///
155    /// # Returns
156    /// `CacheLookupResult` indicating whether a cache hit was found
157    async fn check(
158        &self,
159        task: &IRTask,
160        digest: &str,
161        policy: CachePolicy,
162    ) -> BackendResult<CacheLookupResult>;
163
164    /// Store a task execution result in the cache
165    ///
166    /// # Arguments
167    /// * `task` - The IR task definition
168    /// * `digest` - Pre-computed digest (cache key)
169    /// * `entry` - The execution result to store
170    /// * `policy` - Effective cache policy
171    ///
172    /// # Errors
173    /// Returns error if storage fails (but callers should handle gracefully)
174    async fn store(
175        &self,
176        task: &IRTask,
177        digest: &str,
178        entry: &CacheEntry,
179        policy: CachePolicy,
180    ) -> BackendResult<()>;
181
182    /// Restore output artifacts from cache to the workspace
183    ///
184    /// # Arguments
185    /// * `task` - The IR task definition
186    /// * `digest` - Pre-computed digest (cache key)
187    /// * `workspace` - Directory to restore outputs to
188    ///
189    /// # Errors
190    /// Returns error if restoration fails
191    async fn restore_outputs(
192        &self,
193        task: &IRTask,
194        digest: &str,
195        workspace: &Path,
196    ) -> BackendResult<Vec<CacheOutput>>;
197
198    /// Get cached stdout/stderr logs
199    ///
200    /// # Arguments
201    /// * `task` - The IR task definition
202    /// * `digest` - Pre-computed digest (cache key)
203    ///
204    /// # Returns
205    /// Tuple of (stdout, stderr) if available
206    async fn get_logs(
207        &self,
208        task: &IRTask,
209        digest: &str,
210    ) -> BackendResult<(Option<String>, Option<String>)>;
211
212    /// Get the backend name for logging/metrics
213    fn name(&self) -> &'static str;
214
215    /// Check if the backend is available/connected
216    async fn health_check(&self) -> BackendResult<()>;
217}
218
219/// Determine if cache read is allowed for a policy
220#[must_use]
221pub fn policy_allows_read(policy: CachePolicy) -> bool {
222    matches!(policy, CachePolicy::Normal | CachePolicy::Readonly)
223}
224
225/// Determine if cache write is allowed for a policy
226#[must_use]
227pub fn policy_allows_write(policy: CachePolicy) -> bool {
228    matches!(policy, CachePolicy::Normal | CachePolicy::Writeonly)
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_cache_lookup_result() {
237        let miss = CacheLookupResult::miss("sha256:abc123");
238        assert!(!miss.hit);
239        assert_eq!(miss.key, "sha256:abc123");
240        assert!(miss.cached_duration_ms.is_none());
241
242        let hit = CacheLookupResult::hit("sha256:def456", 1234);
243        assert!(hit.hit);
244        assert_eq!(hit.key, "sha256:def456");
245        assert_eq!(hit.cached_duration_ms, Some(1234));
246    }
247
248    #[test]
249    fn test_policy_allows_read() {
250        assert!(policy_allows_read(CachePolicy::Normal));
251        assert!(policy_allows_read(CachePolicy::Readonly));
252        assert!(!policy_allows_read(CachePolicy::Writeonly));
253        assert!(!policy_allows_read(CachePolicy::Disabled));
254    }
255
256    #[test]
257    fn test_policy_allows_write() {
258        assert!(policy_allows_write(CachePolicy::Normal));
259        assert!(!policy_allows_write(CachePolicy::Readonly));
260        assert!(policy_allows_write(CachePolicy::Writeonly));
261        assert!(!policy_allows_write(CachePolicy::Disabled));
262    }
263
264    #[test]
265    fn test_is_gracefully_degradable() {
266        // Transient failures should allow graceful degradation
267        assert!(BackendError::Unavailable("test".to_string()).is_gracefully_degradable());
268        assert!(BackendError::Connection("test".to_string()).is_gracefully_degradable());
269        assert!(
270            BackendError::ActionNotFound {
271                digest: "test".to_string()
272            }
273            .is_gracefully_degradable()
274        );
275
276        // Hard failures should not allow graceful degradation
277        assert!(!BackendError::Io(std::io::Error::other("test")).is_gracefully_degradable());
278        assert!(
279            !BackendError::IoWithContext {
280                operation: "write",
281                path: std::path::PathBuf::from("/test"),
282                source: std::io::Error::other("test"),
283            }
284            .is_gracefully_degradable()
285        );
286        assert!(!BackendError::Serialization("test".to_string()).is_gracefully_degradable());
287        assert!(
288            !BackendError::DigestMismatch {
289                expected: "a".to_string(),
290                actual: "b".to_string()
291            }
292            .is_gracefully_degradable()
293        );
294        assert!(
295            !BackendError::BlobNotFound {
296                digest: "test".to_string()
297            }
298            .is_gracefully_degradable()
299        );
300    }
301
302    #[test]
303    fn test_io_with_context() {
304        let error = BackendError::io_with_context(
305            "write",
306            "/test/path",
307            std::io::Error::other("disk full"),
308        );
309        let msg = error.to_string();
310        assert!(msg.contains("write"));
311        assert!(msg.contains("/test/path"));
312        assert!(msg.contains("disk full"));
313    }
314}