cuenv_ci/executor/
backend.rs1use crate::ir::{CachePolicy, Task as IRTask};
7use async_trait::async_trait;
8use std::path::Path;
9use thiserror::Error;
10
11#[derive(Debug, Error)]
13pub enum BackendError {
14 #[error("Cache IO error: {0}")]
16 Io(#[from] std::io::Error),
17
18 #[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 #[error("Serialization error: {0}")]
28 Serialization(String),
29
30 #[error("Remote cache connection error: {0}")]
32 Connection(String),
33
34 #[error("Remote cache unavailable: {0}")]
39 Unavailable(String),
40
41 #[error("Digest mismatch: expected {expected}, got {actual}")]
43 DigestMismatch { expected: String, actual: String },
44
45 #[error("Blob not found: {digest}")]
47 BlobNotFound { digest: String },
48
49 #[error("Action result not found for digest: {digest}")]
51 ActionNotFound { digest: String },
52}
53
54impl BackendError {
55 #[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 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
81pub type BackendResult<T> = std::result::Result<T, BackendError>;
83
84#[derive(Debug, Clone)]
86pub struct CacheLookupResult {
87 pub hit: bool,
89 pub key: String,
91 pub cached_duration_ms: Option<u64>,
93}
94
95impl CacheLookupResult {
96 #[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 #[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#[derive(Debug, Clone)]
119pub struct CacheOutput {
120 pub path: String,
122 pub data: Vec<u8>,
124 pub is_executable: bool,
126}
127
128#[derive(Debug, Clone)]
130pub struct CacheEntry {
131 pub stdout: Option<String>,
133 pub stderr: Option<String>,
135 pub exit_code: i32,
137 pub duration_ms: u64,
139 pub outputs: Vec<CacheOutput>,
141}
142
143#[async_trait]
147pub trait CacheBackend: Send + Sync {
148 async fn check(
158 &self,
159 task: &IRTask,
160 digest: &str,
161 policy: CachePolicy,
162 ) -> BackendResult<CacheLookupResult>;
163
164 async fn store(
175 &self,
176 task: &IRTask,
177 digest: &str,
178 entry: &CacheEntry,
179 policy: CachePolicy,
180 ) -> BackendResult<()>;
181
182 async fn restore_outputs(
192 &self,
193 task: &IRTask,
194 digest: &str,
195 workspace: &Path,
196 ) -> BackendResult<Vec<CacheOutput>>;
197
198 async fn get_logs(
207 &self,
208 task: &IRTask,
209 digest: &str,
210 ) -> BackendResult<(Option<String>, Option<String>)>;
211
212 fn name(&self) -> &'static str;
214
215 async fn health_check(&self) -> BackendResult<()>;
217}
218
219#[must_use]
221pub fn policy_allows_read(policy: CachePolicy) -> bool {
222 matches!(policy, CachePolicy::Normal | CachePolicy::Readonly)
223}
224
225#[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 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 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}