1use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use std::time::Duration;
40
41use async_trait::async_trait;
42use sui_cache::StorageBackend;
43use sui_cache::CacheError;
44use sui_dockerfile_node_cache_daemon::protocol::{read_message, write_message};
45use sui_dockerfile_node_cache_daemon::{CachedArtifact, DaemonRequest, DaemonResponse};
46use tokio::net::UnixStream;
47
48const CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
54
55pub struct DaemonAwareCacheClient {
59 socket_path: Option<PathBuf>,
60 remote: Arc<dyn StorageBackend>,
61}
62
63impl DaemonAwareCacheClient {
64 #[must_use]
69 pub fn new(socket_path: Option<PathBuf>, remote: Arc<dyn StorageBackend>) -> Self {
70 Self { socket_path, remote }
71 }
72
73 async fn try_daemon_roundtrip(&self, request: &DaemonRequest) -> Option<DaemonResponse> {
77 let path = self.socket_path.as_ref()?;
78 let connect = tokio::time::timeout(CONNECT_TIMEOUT, UnixStream::connect(path)).await;
79 let Ok(Ok(mut stream)) = connect else { return None };
80 if write_message(&mut stream, request).await.is_err() {
81 return None;
82 }
83 read_message::<_, DaemonResponse>(&mut stream).await.ok()
84 }
85}
86
87#[async_trait]
88impl StorageBackend for DaemonAwareCacheClient {
89 async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, CacheError> {
90 let request = DaemonRequest::Get { content_hash: hash.to_string() };
91 match self.try_daemon_roundtrip(&request).await {
92 Some(DaemonResponse::Get { artifact }) => Ok(artifact.map(|a| a.image_ref)),
93 Some(DaemonResponse::Error { message }) => {
94 tracing::warn!(target: "sui-dockerfile-wrapper::daemon_client", hash, error = %message, "daemon reported an error servicing Get; falling through to remote");
95 self.remote.get_narinfo(hash).await
96 }
97 Some(other) => {
98 tracing::warn!(target: "sui-dockerfile-wrapper::daemon_client", hash, response = ?other, "unexpected daemon response to Get; falling through to remote");
99 self.remote.get_narinfo(hash).await
100 }
101 None => self.remote.get_narinfo(hash).await,
102 }
103 }
104
105 async fn put_narinfo_record(&self, hash: &str, content: &str) -> Result<(), CacheError> {
109 self.remote.put_narinfo_record(hash, content).await
110 }
111
112 async fn delete_narinfo_record(&self, hash: &str) -> Result<(), CacheError> {
113 self.remote.delete_narinfo_record(hash).await
114 }
115
116 async fn delete_nar_record(&self, nar_path: &str) -> Result<(), CacheError> {
117 self.remote.delete_nar_record(nar_path).await
118 }
119
120 fn nar_ref_index(&self) -> &dyn sui_cache::NarRefIndex {
121 self.remote.nar_ref_index()
122 }
123
124 async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), CacheError> {
129 self.remote.put_narinfo(hash, content).await?;
133
134 let request = DaemonRequest::Put {
139 content_hash: hash.to_string(),
140 artifact: CachedArtifact { image_ref: content.to_string() },
141 };
142 if let Some(DaemonResponse::Error { message }) = self.try_daemon_roundtrip(&request).await {
143 tracing::warn!(target: "sui-dockerfile-wrapper::daemon_client", hash, error = %message, "daemon reported an error servicing Put (remote write already succeeded)");
144 }
145 Ok(())
146 }
147
148 async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, CacheError> {
149 self.remote.get_nar(path).await
150 }
151
152 async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), CacheError> {
153 self.remote.put_nar(path, data).await
154 }
155
156 fn nar_residency(&self) -> sui_cache::NarResidency {
160 self.remote.nar_residency()
161 }
162
163 async fn get_nar_stream(
166 &self,
167 path: &str,
168 ) -> Result<Option<sui_cache::NarStream>, CacheError> {
169 self.remote.get_nar_stream(path).await
170 }
171
172 async fn put_nar_stream(
173 &self,
174 path: &str,
175 src: &dyn sui_cache::NarSource,
176 ) -> Result<(), CacheError> {
177 self.remote.put_nar_stream(path, src).await
178 }
179
180 async fn delete(&self, hash: &str) -> Result<(), CacheError> {
183 self.remote.delete(hash).await
184 }
185
186 async fn list_narinfos(&self) -> Result<Vec<String>, CacheError> {
187 self.remote.list_narinfos().await
188 }
189}
190
191#[must_use]
197pub fn socket_looks_present(path: &Path) -> bool {
198 path.exists()
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::cache::MockCacheBackend;
205 use sui_dockerfile_node_cache_daemon::{bind_unix_listener, WarmStatus};
206 use tokio::sync::watch;
207
208 fn spawn_fake_daemon(socket_path: &Path, response: DaemonResponse) -> watch::Sender<bool> {
214 let listener = bind_unix_listener(socket_path).unwrap();
215 let (tx, mut rx) = watch::channel(false);
216 tokio::spawn(async move {
217 loop {
218 tokio::select! {
219 accepted = listener.accept() => {
220 let Ok((mut stream, _)) = accepted else { continue };
221 let response = response.clone();
222 tokio::spawn(async move {
223 let _req: Result<DaemonRequest, _> = read_message(&mut stream).await;
224 let _ = write_message(&mut stream, &response).await;
225 });
226 }
227 _ = rx.changed() => {
228 if *rx.borrow() { break; }
229 }
230 }
231 }
232 });
233 tx
234 }
235
236 #[tokio::test]
237 async fn daemon_reachable_and_hit_never_calls_remote() {
238 let dir = tempfile::tempdir().unwrap();
239 let socket_path = dir.path().join("daemon.sock");
240 let _shutdown = spawn_fake_daemon(
241 &socket_path,
242 DaemonResponse::Get { artifact: Some(CachedArtifact { image_ref: "img:from-daemon".to_string() }) },
243 );
244
245 let remote = Arc::new(MockCacheBackend::new());
246 let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
247 let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
248
249 let got = client.get_narinfo("h1").await.unwrap();
250 assert_eq!(got.as_deref(), Some("img:from-daemon"));
251 assert_eq!(remote.get_narinfo("h1").await.unwrap(), None);
255 }
256
257 #[tokio::test]
258 async fn daemon_reachable_and_miss_returns_none_without_querying_remote_again() {
259 let dir = tempfile::tempdir().unwrap();
260 let socket_path = dir.path().join("daemon.sock");
261 let _shutdown = spawn_fake_daemon(&socket_path, DaemonResponse::Get { artifact: None });
262
263 let remote = Arc::new(MockCacheBackend::new().with_entry("h2", "img:should-not-be-seen"));
267 let remote_dyn: Arc<dyn StorageBackend> = remote;
268 let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
269
270 let got = client.get_narinfo("h2").await.unwrap();
271 assert_eq!(got, None, "daemon's Get-miss answer must be trusted, not overridden by a second remote check");
272 }
273
274 #[tokio::test]
275 async fn daemon_unreachable_falls_through_to_remote_unchanged() {
276 let dir = tempfile::tempdir().unwrap();
277 let socket_path = dir.path().join("no-daemon-here.sock");
279
280 let remote = Arc::new(MockCacheBackend::new().with_entry("h3", "img:direct-remote"));
281 let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
282 let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn.clone());
283
284 let via_client = client.get_narinfo("h3").await.unwrap();
285 let via_remote_directly = remote_dyn.get_narinfo("h3").await.unwrap();
286 assert_eq!(via_client, via_remote_directly);
287 assert_eq!(via_client.as_deref(), Some("img:direct-remote"));
288 }
289
290 #[tokio::test]
291 async fn no_socket_path_configured_behaves_exactly_like_direct_remote() {
292 let remote = Arc::new(MockCacheBackend::new().with_entry("h4", "img:no-daemon-configured"));
293 let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
294 let client = DaemonAwareCacheClient::new(None, remote_dyn.clone());
295
296 let via_client = client.get_narinfo("h4").await.unwrap();
297 let via_remote_directly = remote_dyn.get_narinfo("h4").await.unwrap();
298 assert_eq!(via_client, via_remote_directly);
299 }
300
301 #[tokio::test]
302 async fn put_always_writes_through_to_remote_even_when_daemon_unreachable() {
303 let dir = tempfile::tempdir().unwrap();
304 let socket_path = dir.path().join("no-daemon-here.sock");
305
306 let remote = Arc::new(MockCacheBackend::new());
307 let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
308 let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
309
310 client.put_narinfo("h5", "img:backfilled").await.unwrap();
311 assert_eq!(remote.get_narinfo("h5").await.unwrap().as_deref(), Some("img:backfilled"));
312 }
313
314 #[tokio::test]
315 async fn put_writes_through_to_remote_and_warms_the_daemon() {
316 let dir = tempfile::tempdir().unwrap();
317 let socket_path = dir.path().join("daemon.sock");
318 let _shutdown =
319 spawn_fake_daemon(&socket_path, DaemonResponse::Put { ok: true });
320
321 let remote = Arc::new(MockCacheBackend::new());
322 let remote_dyn: Arc<dyn StorageBackend> = remote.clone();
323 let client = DaemonAwareCacheClient::new(Some(socket_path), remote_dyn);
324
325 client.put_narinfo("h6", "img:dual-write").await.unwrap();
326 assert_eq!(remote.get_narinfo("h6").await.unwrap().as_deref(), Some("img:dual-write"));
327 }
328
329 #[test]
330 fn socket_presence_check_is_a_pure_filesystem_check() {
331 let dir = tempfile::tempdir().unwrap();
332 assert!(!socket_looks_present(&dir.path().join("nope.sock")));
333 }
334
335 #[test]
339 fn warm_status_type_is_reachable_from_this_crate() {
340 let _ = WarmStatus::AlreadyLocal;
341 }
342}