Skip to content

Commit 5d1fcc5

Browse files
authored
new: Warm local cache when a hit from remote cache. (#2592)
* Add impl. * Update changelog. * Handle zero blob.
1 parent 3896b50 commit 5d1fcc5

5 files changed

Lines changed: 315 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
performance and reduce disk space usage.
1414
- When the local/remote cache is missing a blob, we'll attempt to retrieve it from the other
1515
cache, which can improve cache hit rates in some scenarios.
16+
- When a remote cache hit, we'll now warm the local cache with the hydrated manifest and its
17+
blobs, so the next run resolves locally instead of round-tripping to the remote.
1618
- **Processes**
1719
- Improved our "stream and capture output" child process handling to operate on bytes instead of
1820
lines, which should resolve some edge cases with output not being written to the console, or

crates/cache-storage/src/manifest.rs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -254,17 +254,18 @@ impl Manifest {
254254

255255
for file in &self.files {
256256
if let Some(digest) = &file.digest {
257-
if let Some(bytes) = &file.bytes {
258-
sources.push(BlobInput {
259-
content: BlobContent::Inline(bytes.clone()),
260-
digest: digest.to_owned(),
261-
});
257+
let content = if digest.size == 0 {
258+
BlobContent::Inline(Bytes::new())
259+
} else if let Some(bytes) = &file.bytes {
260+
BlobContent::Inline(bytes.clone())
262261
} else {
263-
sources.push(BlobInput {
264-
content: BlobContent::File(file.path.to_logical_path(workspace_root)),
265-
digest: digest.to_owned(),
266-
});
267-
}
262+
BlobContent::File(file.path.to_logical_path(workspace_root))
263+
};
264+
265+
sources.push(BlobInput {
266+
content,
267+
digest: digest.to_owned(),
268+
});
268269
}
269270
}
270271

crates/cache-storage/src/storage.rs

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ pub struct CacheContext {
2929
pub workspace_root: PathBuf,
3030
}
3131

32-
#[derive(Debug)]
32+
#[derive(Clone, Debug)]
3333
pub struct StorageOptions {
3434
pub only_backends: Vec<Id>,
3535
pub include_local: bool,
@@ -115,25 +115,43 @@ impl Storage {
115115
}
116116

117117
pub fn get_backends(&self) -> Vec<&BoxedStorageBackend> {
118-
let mut backends = vec![];
118+
self.get_backends_with_options(&self.options)
119+
}
119120

120-
if self.options.only_backends.is_empty() {
121-
if self.options.include_local {
122-
backends.extend(self.local_backends.iter());
123-
}
121+
pub fn get_backends_with_options(&self, options: &StorageOptions) -> Vec<&BoxedStorageBackend> {
122+
let mut backends = vec![];
124123

125-
if self.options.include_remote {
126-
backends.extend(self.remote_backends.iter());
127-
}
128-
} else {
124+
if options.include_local {
129125
backends.extend(self.local_backends.iter());
126+
}
127+
128+
if options.include_remote {
130129
backends.extend(self.remote_backends.iter());
131-
backends.retain(|backend| self.options.only_backends.contains(backend.get_id()));
130+
}
131+
132+
if !options.only_backends.is_empty() {
133+
backends.retain(|backend| options.only_backends.contains(backend.get_id()));
132134
}
133135

134136
backends
135137
}
136138

139+
pub fn get_local_backends(&self) -> Vec<&BoxedStorageBackend> {
140+
self.get_backends_with_options(&StorageOptions {
141+
// Respect previously configured options
142+
include_remote: false,
143+
..self.options.clone()
144+
})
145+
}
146+
147+
pub fn get_remote_backends(&self) -> Vec<&BoxedStorageBackend> {
148+
self.get_backends_with_options(&StorageOptions {
149+
// Respect previously configured options
150+
include_local: false,
151+
..self.options.clone()
152+
})
153+
}
154+
137155
pub fn is_local_enabled(&self) -> bool {
138156
!self.local_backends.is_empty()
139157
}
@@ -227,7 +245,7 @@ impl Storage {
227245
continue;
228246
}
229247

230-
background_tasks.push(tokio::spawn(Box::pin(archive_manifest_in_backend(
248+
background_tasks.push(tokio::spawn(Box::pin(persist_manifest_in_backend(
231249
Arc::clone(backend),
232250
digest.to_owned(),
233251
manifest.clone(),
@@ -254,7 +272,7 @@ impl Storage {
254272
let ManifestSource {
255273
mut manifest,
256274
backend: original_backend,
257-
..
275+
remote,
258276
} = manifest_source;
259277
let mut backends = VecDeque::from_iter(self.get_backends());
260278
let mut count = 1;
@@ -294,6 +312,13 @@ impl Storage {
294312
"Hydrated cache manifest from {count} storage backends"
295313
);
296314

315+
// A remote hit leaves the local tier cold. Warm it from the
316+
// now-in-memory blobs so the next run resolves locally instead of
317+
// round-tripping to the remote again
318+
if remote {
319+
self.warm_local_backends(digest, &manifest).await;
320+
}
321+
297322
return Ok(Some(manifest));
298323
}
299324

@@ -305,6 +330,33 @@ impl Storage {
305330
Ok(None)
306331
}
307332

333+
/// Warm the local tier after a remote cache hit by persisting the fully
334+
/// hydrated manifest and its blobs into every active, writable local
335+
/// backend, so the next run resolves locally instead of round-tripping to
336+
/// the remote.
337+
async fn warm_local_backends(&self, digest: &Digest, manifest: &Manifest) {
338+
let mut background_tasks = self.background_tasks.lock().unwrap();
339+
340+
for backend in self.get_local_backends() {
341+
if !backend.is_writable() {
342+
continue;
343+
}
344+
345+
trace!(
346+
storage = backend.get_id().as_str(),
347+
hash = digest.hash.as_str(),
348+
"Warming local storage backend from remote cache hit"
349+
);
350+
351+
background_tasks.push(tokio::spawn(Box::pin(persist_manifest_in_backend(
352+
Arc::clone(backend),
353+
digest.to_owned(),
354+
manifest.clone(),
355+
self.context.workspace_root.clone(),
356+
))));
357+
}
358+
}
359+
308360
pub async fn wait_for_background_tasks(&self) -> miette::Result<()> {
309361
let background_tasks = {
310362
self.background_tasks
@@ -360,7 +412,7 @@ impl Storage {
360412
}
361413
}
362414

363-
async fn archive_manifest_in_backend(
415+
async fn persist_manifest_in_backend(
364416
backend: BoxedStorageBackend,
365417
digest: Digest,
366418
mut manifest: Manifest,

crates/cache-storage/tests/manifest_test.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,34 @@ mod collect_blob_sources {
160160

161161
assert_eq!(sources.len(), 2);
162162
}
163+
164+
#[test]
165+
fn empty_file_uses_inline_empty_blob_not_a_path() {
166+
// A size-0 output must become a shared inline empty blob, never a
167+
// File(path): the path isn't materialized when warming runs, and empty
168+
// files dedupe to the one empty digest in CAS.
169+
let manifest = Manifest {
170+
files: vec![
171+
file(None, Some(digest('a', 0))),
172+
file(Some(Bytes::from_static(b"hi")), Some(digest('c', 2))),
173+
],
174+
..Default::default()
175+
};
176+
177+
let sources = manifest.collect_blob_inputs(Path::new("/workspace"));
178+
179+
assert_eq!(sources.len(), 2);
180+
181+
let empty = sources
182+
.iter()
183+
.find(|source| source.digest == digest('a', 0))
184+
.expect("empty file should still produce a blob input");
185+
186+
match &empty.content {
187+
BlobContent::Inline(bytes) => assert!(bytes.is_empty()),
188+
_ => panic!("size-0 output must be an inline empty blob, not a file path"),
189+
}
190+
}
163191
}
164192

165193
mod hydration {

0 commit comments

Comments
 (0)