1use crate::{
2 BlobSource, BlobUpload, CacheDigest, CacheDirectory, LocalActionCache, LocalCas,
3 ManifestPutOutcome, RemoteActionResult, RemoteCacheClient, RemoteCacheMode, RustcMetadata,
4 canonical_json,
5};
6use eyre::{Result, bail};
7use futures_util::{FutureExt, StreamExt, future::BoxFuture, stream};
8use log::warn;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex, Weak};
15use std::time::{Duration, Instant};
16use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
17
18const MAX_EXECUTABLE_IDENTITIES: usize = 64;
19const MAX_EXECUTABLE_IDENTITY_SIZE: usize = 64 * 1024;
20const MAX_EXECUTABLE_IDENTITY_BYTES: usize = 256 * 1024;
21const TASK_ACTION_MANIFEST_VERSION: u8 = 1;
22const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
23const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
24const MAX_REMOTE_TRANSFERS: usize = 64;
25const MAX_PREFETCH_TRANSFERS: usize = 48;
26const MAX_PREFETCH_ACTION_BATCH: usize = 256;
27const PREFETCH_ACTION_BATCH_DELAY: Duration = Duration::from_millis(5);
28const MAX_PREFETCH_DIRECTORY_OBJECTS: usize = 100_000;
29const MAX_PREFETCH_OBJECTS_PER_WAVE: usize = 100_000;
30
31pub struct AgentRemoteCache {
33 pub client: RemoteCacheClient,
34 pub mode: RemoteCacheMode,
35 pub staging_dir: PathBuf,
36}
37
38pub const AGENT_PROTOCOL_VERSION: u8 = 1;
40const MAX_REQUEST_BYTES: usize = 16 * 1024 * 1024;
45
46#[derive(Debug, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum AgentRequest {
50 Hello {
51 protocol: u8,
52 client_version: String,
53 },
54 FindBlob {
56 digest: CacheDigest,
57 },
58 FindBlobs {
60 digests: Vec<CacheDigest>,
61 },
62 StoreBlob {
63 digest: CacheDigest,
64 source: PathBuf,
65 },
66 FindActionResult {
67 action: CacheDigest,
68 },
69 RecordActionHit {
70 action: CacheDigest,
71 restore: RestoreStats,
72 },
73 RecordBypass {
75 kind: String,
76 },
77 RecordActionVerification {
78 matched: bool,
79 restore: RestoreStats,
80 },
81 StoreActionResult {
82 result: RemoteActionResult,
83 },
84 FindActionPrediction {
85 task: String,
86 invocation: CacheDigest,
87 },
88 RecordActionPrediction {
89 task: String,
90 prediction: ActionPrediction,
91 },
92 FindExecutableIdentity {
93 executable: PathBuf,
94 environment: BTreeMap<String, Option<String>>,
95 },
96 StoreExecutableIdentity {
97 executable: PathBuf,
98 environment: BTreeMap<String, Option<String>>,
99 stdout: Vec<u8>,
100 },
101}
102
103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct RestoreStats {
107 pub duration_ns: u64,
109 pub output_files: u64,
111 pub output_bytes: u64,
113}
114
115#[derive(Debug, Serialize, Deserialize)]
117#[serde(tag = "type", rename_all = "snake_case")]
118pub enum AgentResponse {
119 Hello {
120 protocol: u8,
121 agent_version: String,
122 },
123 Blob {
125 path: Option<PathBuf>,
126 },
127 Blobs {
129 paths: Vec<Option<PathBuf>>,
130 },
131 Stored {
132 path: PathBuf,
133 },
134 ActionResult {
135 result: Option<RemoteActionResult>,
136 },
137 ActionHitRecorded,
138 ActionVerificationRecorded,
139 BypassRecorded,
140 ActionStored {
141 path: PathBuf,
142 },
143 ActionPrediction {
144 prediction: Option<ActionPrediction>,
145 },
146 ActionPredictionRecorded,
147 ExecutableIdentity {
148 stdout: Option<Vec<u8>>,
149 },
150 Error {
151 message: String,
152 },
153}
154
155#[derive(Debug, Clone, Default, PartialEq, Eq)]
157pub struct AgentStats {
158 pub session_duration_ns: u64,
160 pub lookups: u64,
162 pub hits: u64,
164 pub stores: u64,
166 pub stored_bytes: u64,
168 pub verifications: u64,
170 pub divergences: u64,
172 pub downloaded_bytes: u64,
174 pub uploaded_bytes: u64,
176 pub prefetched_actions: u64,
178 pub bypasses: BTreeMap<String, u64>,
180 pub remote_manifest_lookups: u64,
182 pub remote_manifest_lookup_duration_ns: u64,
184 pub remote_action_lookups: u64,
186 pub remote_action_lookup_duration_ns: u64,
188 pub remote_blob_requests: u64,
190 pub remote_blob_pack_requests: u64,
192 pub remote_blob_pack_blobs: u64,
194 pub remote_blob_transfer_duration_ns: u64,
196 pub local_cas_write_duration_ns: u64,
198 pub prefetch_runs: u64,
200 pub prefetch_duration_ns: u64,
202 pub materialization_duration_ns: u64,
204 pub restored_output_files: u64,
206 pub restored_output_bytes: u64,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct ActionPrediction {
215 pub invocation: CacheDigest,
216 pub action: CacheDigest,
217 pub adapter: String,
218 pub payload: String,
219}
220
221#[derive(Default)]
222struct AtomicAgentStats {
223 lookups: AtomicU64,
224 hits: AtomicU64,
225 stores: AtomicU64,
226 stored_bytes: AtomicU64,
227 verifications: AtomicU64,
228 divergences: AtomicU64,
229 downloaded_bytes: AtomicU64,
230 uploaded_bytes: AtomicU64,
231 prefetched_actions: AtomicU64,
232 remote_manifest_lookups: AtomicU64,
233 remote_manifest_lookup_duration_ns: AtomicU64,
234 remote_action_lookups: AtomicU64,
235 remote_action_lookup_duration_ns: AtomicU64,
236 remote_blob_requests: AtomicU64,
237 remote_blob_pack_requests: AtomicU64,
238 remote_blob_pack_blobs: AtomicU64,
239 remote_blob_transfer_duration_ns: AtomicU64,
240 local_cas_write_duration_ns: AtomicU64,
241 prefetch_runs: AtomicU64,
242 prefetch_duration_ns: AtomicU64,
243 materialization_duration_ns: AtomicU64,
244 bypasses: Mutex<BTreeMap<String, u64>>,
245 restored_output_files: AtomicU64,
246 restored_output_bytes: AtomicU64,
247}
248
249struct AtomicDurationTimer<'a> {
250 started: Instant,
251 target: &'a AtomicU64,
252}
253
254impl<'a> AtomicDurationTimer<'a> {
255 fn start(target: &'a AtomicU64) -> Self {
256 Self {
257 started: Instant::now(),
258 target,
259 }
260 }
261}
262
263impl Drop for AtomicDurationTimer<'_> {
264 fn drop(&mut self) {
265 atomic_saturating_add(self.target, duration_ns(self.started));
266 }
267}
268
269fn duration_ns(started: Instant) -> u64 {
270 started.elapsed().as_nanos().try_into().unwrap_or(u64::MAX)
271}
272
273fn atomic_saturating_add(target: &AtomicU64, value: u64) {
274 let _ = target.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
275 Some(current.saturating_add(value))
276 });
277}
278
279fn queue_prefetch_digest(
280 verified: &BTreeMap<CacheDigest, PathBuf>,
281 pending: &mut BTreeMap<CacheDigest, ()>,
282 digest: CacheDigest,
283) {
284 if verified.contains_key(&digest) || pending.contains_key(&digest) {
285 return;
286 }
287 pending.insert(digest, ());
288}
289
290fn queue_prefetch_directory(
291 seen: &BTreeMap<CacheDigest, ()>,
292 pending: &mut BTreeMap<CacheDigest, ()>,
293 digest: CacheDigest,
294 limit: usize,
295) -> bool {
296 if seen.contains_key(&digest) || pending.contains_key(&digest) {
297 return true;
298 }
299 if seen.len().saturating_add(pending.len()) >= limit {
300 return false;
301 }
302 pending.insert(digest, ());
303 true
304}
305
306#[derive(Clone)]
312pub struct CacheAgent {
313 cas: LocalCas,
314 actions: LocalActionCache,
315 verified_blobs: Arc<Mutex<BTreeMap<CacheDigest, PathBuf>>>,
316 version: Arc<str>,
317 write_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
318 action_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
319 stats: Arc<AtomicAgentStats>,
320 executable_identities: Arc<Mutex<BTreeMap<ExecutableIdentityKey, Vec<u8>>>>,
321 manifest_dir: Arc<PathBuf>,
322 task_actions: Arc<Mutex<BTreeMap<String, TaskActionState>>>,
323 next_task_run: Arc<AtomicU64>,
324 manifest_write_lock: Arc<Mutex<()>>,
325 remote: Option<Arc<RemoteCacheClient>>,
326 remote_mode: RemoteCacheMode,
327 remote_staging_dir: Arc<PathBuf>,
328 pending_remote_actions: Arc<Mutex<BTreeMap<CacheDigest, RemoteActionResult>>>,
329 remote_transfers: Arc<tokio::sync::Semaphore>,
330 prefetch_transfers: Arc<tokio::sync::Semaphore>,
331 prefetch_tasks: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336struct TaskActionManifest {
337 version: u8,
338 task: String,
339 predictions: Vec<ActionPrediction>,
340}
341
342#[derive(Serialize)]
343struct TaskActionManifestSelector<'a> {
344 version: u8,
345 kind: &'static str,
346 task: &'a str,
347}
348
349#[derive(Debug, Clone, Default)]
350struct TaskActionState {
351 manifest: String,
352 baseline_loaded: bool,
353 predictions: BTreeMap<CacheDigest, ActionPrediction>,
354 remote_etag: Option<String>,
355}
356
357struct PrefetchedAction {
358 adapter: String,
359 result: RemoteActionResult,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
363struct ExecutableIdentityKey {
364 executable: PathBuf,
365 environment: BTreeMap<String, Option<String>>,
366}
367
368impl CacheAgent {
369 pub fn new(cache_dir: impl Into<PathBuf>, version: impl Into<Arc<str>>) -> Self {
371 Self::build(cache_dir.into(), version.into(), None)
372 }
373
374 pub fn new_remote(
376 cache_dir: impl Into<PathBuf>,
377 version: impl Into<Arc<str>>,
378 remote: AgentRemoteCache,
379 ) -> Self {
380 Self::build(cache_dir.into(), version.into(), Some(remote))
381 }
382
383 fn build(cache_dir: PathBuf, version: Arc<str>, remote: Option<AgentRemoteCache>) -> Self {
384 let remote_mode = remote
385 .as_ref()
386 .map_or(RemoteCacheMode::ReadOnly, |remote| remote.mode);
387 let remote_staging_dir = remote.as_ref().map_or_else(
388 || cache_dir.join("remote"),
389 |remote| remote.staging_dir.clone(),
390 );
391 let remote = remote.map(|remote| Arc::new(remote.client));
392 Self {
393 cas: LocalCas::new(cache_dir.clone()),
394 actions: LocalActionCache::new(cache_dir.clone()),
395 verified_blobs: Arc::new(Mutex::new(BTreeMap::new())),
396 version,
397 write_locks: Arc::new(Mutex::new(BTreeMap::new())),
398 action_locks: Arc::new(Mutex::new(BTreeMap::new())),
399 stats: Arc::new(AtomicAgentStats::default()),
400 executable_identities: Arc::new(Mutex::new(BTreeMap::new())),
401 manifest_dir: Arc::new(cache_dir.join("task-manifests").join("v1")),
402 task_actions: Arc::new(Mutex::new(BTreeMap::new())),
403 next_task_run: Arc::new(AtomicU64::new(0)),
404 manifest_write_lock: Arc::new(Mutex::new(())),
405 remote,
406 remote_mode,
407 remote_staging_dir: Arc::new(remote_staging_dir),
408 pending_remote_actions: Arc::new(Mutex::new(BTreeMap::new())),
409 remote_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS)),
410 prefetch_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_PREFETCH_TRANSFERS)),
411 prefetch_tasks: Arc::new(Mutex::new(Vec::new())),
412 }
413 }
414
415 pub async fn begin_task(&self, task: &str) -> Result<String> {
417 validate_task_identity(task)?;
418 let (remote_manifest, mut remote_etag) = if self.remote_mode.reads() {
419 match self.get_remote_task_manifest(task).await {
420 Ok(Some((manifest, etag))) => (Some(manifest), Some(etag)),
421 Ok(None) => (None, None),
422 Err(error) => {
423 warn!("remote task action manifest lookup failed for {task}: {error}");
424 (None, None)
425 }
426 }
427 } else {
428 (None, None)
429 };
430 let manifest = {
431 let _write_guard = self.manifest_write_lock.lock().unwrap();
432 let _file_guard = self.lock_task_manifest(task)?;
433 let local_manifest = self.load_task_manifest(task)?;
434 let manifest = match (remote_manifest, local_manifest) {
435 (Some(remote), Some(local)) => {
436 let (manifest, merged) = merge_remote_task_manifest(task, remote, local);
437 if !merged {
438 remote_etag = None;
439 }
440 Some(manifest)
441 }
442 (Some(remote), None) => Some(remote),
443 (None, local) => local,
444 };
445 if let Some(manifest) = &manifest {
446 self.persist_task_manifest(manifest)?;
447 }
448 manifest
449 };
450 let state = if let Some(manifest) = manifest {
451 TaskActionState {
452 manifest: task.to_string(),
453 baseline_loaded: true,
454 predictions: manifest
455 .predictions
456 .into_iter()
457 .map(|prediction| (prediction.invocation.clone(), prediction))
458 .collect(),
459 remote_etag,
460 }
461 } else {
462 TaskActionState {
463 manifest: task.to_string(),
464 baseline_loaded: true,
465 remote_etag,
466 ..TaskActionState::default()
467 }
468 };
469 let sequence = self.next_task_run.fetch_add(1, Ordering::Relaxed);
470 let run =
471 CacheDigest::blake3(format!("{task}\0{}\0{sequence}", std::process::id()).as_bytes())
472 .hash;
473 let predictions = state.predictions.values().cloned().collect();
474 self.task_actions.lock().unwrap().insert(run.clone(), state);
475 self.spawn_prefetch_predictions(predictions);
476 Ok(run)
477 }
478
479 pub async fn cancel_prefetches(&self) {
481 let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
482 for task in &tasks {
483 task.abort();
484 }
485 for task in tasks {
486 if let Err(error) = task.await
487 && !error.is_cancelled()
488 {
489 warn!("remote action prefetch task failed: {error}");
490 }
491 }
492 }
493
494 #[cfg(test)]
495 async fn wait_for_prefetches(&self) {
496 let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
497 for task in tasks {
498 if let Err(error) = task.await {
499 warn!("remote action prefetch task failed: {error}");
500 }
501 }
502 }
503
504 pub async fn commit_task(&self, run: &str) -> Result<()> {
506 validate_task_identity(run)?;
507 let state = self
508 .task_actions
509 .lock()
510 .unwrap()
511 .get(run)
512 .cloned()
513 .ok_or_else(|| eyre::eyre!("task action manifest baseline was not loaded"))?;
514 if !state.baseline_loaded {
515 bail!("task action manifest baseline was not loaded");
516 }
517 let task = state.manifest;
518 validate_task_identity(&task)?;
519 let manifest = {
520 let _write_guard = self.manifest_write_lock.lock().unwrap();
521 let _file_guard = self.lock_task_manifest(&task)?;
522 let mut predictions = self
523 .load_task_manifest(&task)?
524 .map(|manifest| {
525 manifest
526 .predictions
527 .into_iter()
528 .map(|prediction| (prediction.invocation.clone(), prediction))
529 .collect::<BTreeMap<_, _>>()
530 })
531 .unwrap_or_default();
532 predictions.extend(state.predictions);
533 let manifest = TaskActionManifest {
534 version: TASK_ACTION_MANIFEST_VERSION,
535 task: task.clone(),
536 predictions: predictions.into_values().collect(),
537 };
538 validate_task_manifest(&manifest, &task)?;
539 self.persist_task_manifest(&manifest)?;
540 manifest
541 };
542 self.task_actions.lock().unwrap().remove(run);
543 if self.remote_mode.writes() {
544 match self
545 .put_remote_task_manifest(&task, manifest, state.remote_etag)
546 .await
547 {
548 Ok(remote_manifest) => {
549 let _write_guard = self.manifest_write_lock.lock().unwrap();
550 let reconciliation = (|| {
551 let _file_guard = self.lock_task_manifest(&task)?;
552 let manifest = match self.load_task_manifest(&task)? {
553 Some(local) => {
554 merge_remote_task_manifest(&task, remote_manifest, local).0
555 }
556 None => remote_manifest,
557 };
558 self.persist_task_manifest(&manifest)
559 })();
560 if let Err(error) = reconciliation {
561 warn!(
562 "remote task action manifest reconciliation failed for {task}: {error}"
563 );
564 }
565 }
566 Err(error) => {
567 warn!("remote task action manifest upload failed for {task}: {error}");
568 }
569 }
570 }
571 Ok(())
572 }
573
574 fn task_manifest_path(&self, task: &str) -> PathBuf {
575 self.manifest_dir.join(format!("{task}.json"))
576 }
577
578 fn task_manifest_lock_path(&self, task: &str) -> PathBuf {
579 self.manifest_dir.join("locks").join(format!("{task}.lock"))
580 }
581
582 fn lock_task_manifest(&self, task: &str) -> Result<fslock::LockFile> {
583 let path = self.task_manifest_lock_path(task);
584 fs::create_dir_all(path.parent().expect("task manifest lock has a parent"))?;
585 let mut lock = fslock::LockFile::open(&path)?;
586 lock.lock()?;
587 Ok(lock)
588 }
589
590 fn load_task_manifest(&self, task: &str) -> Result<Option<TaskActionManifest>> {
591 match fs::read(self.task_manifest_path(task)) {
592 Ok(contents) => Ok(Some(self.parse_task_manifest(task, &contents, false)?)),
593 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
594 Err(error) => Err(error.into()),
595 }
596 }
597
598 fn parse_task_manifest(
599 &self,
600 task: &str,
601 contents: &[u8],
602 require_canonical: bool,
603 ) -> Result<TaskActionManifest> {
604 let manifest: TaskActionManifest = serde_json::from_slice(contents)?;
605 validate_task_manifest(&manifest, task)?;
606 if require_canonical && canonical_json(&manifest)? != contents {
607 bail!("task action manifest is not canonical JSON");
608 }
609 Ok(manifest)
610 }
611
612 fn task_manifest_selector(task: &str) -> Result<(Vec<u8>, CacheDigest)> {
613 let bytes = canonical_json(&TaskActionManifestSelector {
614 version: 1,
615 kind: "task_action_manifest",
616 task,
617 })?;
618 let digest = CacheDigest::blake3(&bytes);
619 Ok((bytes, digest))
620 }
621
622 fn persist_task_manifest(&self, manifest: &TaskActionManifest) -> Result<()> {
623 let bytes = canonical_json(manifest)?;
624 fs::create_dir_all(self.manifest_dir.as_path())?;
625 let mut temporary = tempfile::NamedTempFile::new_in(self.manifest_dir.as_path())?;
626 std::io::Write::write_all(temporary.as_file_mut(), &bytes)?;
627 temporary.as_file_mut().sync_all()?;
628 temporary
629 .persist(self.task_manifest_path(&manifest.task))
630 .map_err(|error| error.error)?;
631 Ok(())
632 }
633
634 async fn get_remote_task_manifest(
635 &self,
636 task: &str,
637 ) -> Result<Option<(TaskActionManifest, String)>> {
638 let Some(remote) = &self.remote else {
639 return Ok(None);
640 };
641 let (_, selector) = Self::task_manifest_selector(task)?;
642 let _permit = self.remote_transfers.acquire().await?;
643 self.stats
644 .remote_manifest_lookups
645 .fetch_add(1, Ordering::Relaxed);
646 let _timer = AtomicDurationTimer::start(&self.stats.remote_manifest_lookup_duration_ns);
647 let Some(remote_manifest) = remote.get_action_manifest(&selector).await? else {
648 return Ok(None);
649 };
650 let manifest = self.parse_task_manifest(task, &remote_manifest.bytes, true)?;
651 Ok(Some((manifest, remote_manifest.etag)))
652 }
653
654 async fn put_remote_task_manifest(
655 &self,
656 task: &str,
657 mut manifest: TaskActionManifest,
658 mut expected_etag: Option<String>,
659 ) -> Result<TaskActionManifest> {
660 let Some(remote) = &self.remote else {
661 return Ok(manifest);
662 };
663 let (_, selector) = Self::task_manifest_selector(task)?;
664 for _ in 0..4 {
665 let bytes = canonical_json(&manifest)?;
666 let outcome = {
667 let _permit = self.remote_transfers.acquire().await?;
668 remote
669 .put_action_manifest(&selector, &bytes, expected_etag.as_deref())
670 .await?
671 };
672 match outcome {
673 ManifestPutOutcome::Stored => return Ok(manifest),
674 ManifestPutOutcome::PreconditionFailed => {
675 let Some((remote_manifest, etag)) = self.get_remote_task_manifest(task).await?
676 else {
677 expected_etag = None;
678 continue;
679 };
680 manifest = merge_task_manifests(task, Some(remote_manifest), manifest)?;
681 expected_etag = Some(etag);
682 }
683 }
684 }
685 bail!("remote task action manifest changed too frequently")
686 }
687
688 pub fn stats(&self) -> AgentStats {
690 AgentStats {
691 session_duration_ns: 0,
692 lookups: self.stats.lookups.load(Ordering::Relaxed),
693 hits: self.stats.hits.load(Ordering::Relaxed),
694 stores: self.stats.stores.load(Ordering::Relaxed),
695 stored_bytes: self.stats.stored_bytes.load(Ordering::Relaxed),
696 verifications: self.stats.verifications.load(Ordering::Relaxed),
697 divergences: self.stats.divergences.load(Ordering::Relaxed),
698 downloaded_bytes: self.stats.downloaded_bytes.load(Ordering::Relaxed),
699 uploaded_bytes: self.stats.uploaded_bytes.load(Ordering::Relaxed),
700 prefetched_actions: self.stats.prefetched_actions.load(Ordering::Relaxed),
701 bypasses: self.stats.bypasses.lock().unwrap().clone(),
702 remote_manifest_lookups: self.stats.remote_manifest_lookups.load(Ordering::Relaxed),
703 remote_manifest_lookup_duration_ns: self
704 .stats
705 .remote_manifest_lookup_duration_ns
706 .load(Ordering::Relaxed),
707 remote_action_lookups: self.stats.remote_action_lookups.load(Ordering::Relaxed),
708 remote_action_lookup_duration_ns: self
709 .stats
710 .remote_action_lookup_duration_ns
711 .load(Ordering::Relaxed),
712 remote_blob_requests: self.stats.remote_blob_requests.load(Ordering::Relaxed),
713 remote_blob_pack_requests: self.stats.remote_blob_pack_requests.load(Ordering::Relaxed),
714 remote_blob_pack_blobs: self.stats.remote_blob_pack_blobs.load(Ordering::Relaxed),
715 remote_blob_transfer_duration_ns: self
716 .stats
717 .remote_blob_transfer_duration_ns
718 .load(Ordering::Relaxed),
719 local_cas_write_duration_ns: self
720 .stats
721 .local_cas_write_duration_ns
722 .load(Ordering::Relaxed),
723 prefetch_runs: self.stats.prefetch_runs.load(Ordering::Relaxed),
724 prefetch_duration_ns: self.stats.prefetch_duration_ns.load(Ordering::Relaxed),
725 materialization_duration_ns: self
726 .stats
727 .materialization_duration_ns
728 .load(Ordering::Relaxed),
729 restored_output_files: self.stats.restored_output_files.load(Ordering::Relaxed),
730 restored_output_bytes: self.stats.restored_output_bytes.load(Ordering::Relaxed),
731 }
732 }
733
734 fn write_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
735 Self::digest_lock(&self.write_locks, digest)
736 }
737
738 fn action_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
739 Self::digest_lock(&self.action_locks, digest)
740 }
741
742 fn digest_lock(
743 locks: &Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>,
744 digest: &CacheDigest,
745 ) -> Arc<tokio::sync::Mutex<()>> {
746 let mut locks = locks.lock().unwrap();
747 locks.retain(|_, lock| lock.strong_count() > 0);
748 if let Some(lock) = locks.get(digest).and_then(Weak::upgrade) {
749 return lock;
750 }
751 let lock = Arc::new(tokio::sync::Mutex::new(()));
752 locks.insert(digest.clone(), Arc::downgrade(&lock));
753 lock
754 }
755
756 fn spawn_prefetch_predictions(&self, predictions: Vec<ActionPrediction>) {
757 if predictions.is_empty() || !self.remote_mode.reads() || self.remote.is_none() {
758 return;
759 }
760 let agent = self.clone();
761 let task = tokio::spawn(async move {
762 agent.prefetch_predictions(predictions.iter()).await;
763 });
764 self.prefetch_tasks.lock().unwrap().push(task);
765 }
766
767 async fn prefetch_predictions<'a>(
768 &self,
769 predictions: impl Iterator<Item = &'a ActionPrediction>,
770 ) {
771 if !self.remote_mode.reads() || self.remote.is_none() {
772 return;
773 }
774 self.stats.prefetch_runs.fetch_add(1, Ordering::Relaxed);
775 let _timer = AtomicDurationTimer::start(&self.stats.prefetch_duration_ns);
776 let mut actions = BTreeMap::new();
777 for prediction in predictions {
778 actions
779 .entry(prediction.action.clone())
780 .or_insert_with(|| prediction.adapter.clone());
781 }
782 let mut actions = actions.into_iter();
783 let mut tasks = tokio::task::JoinSet::new();
784 for _ in 0..MAX_PREFETCH_TRANSFERS {
785 let Some((action, adapter)) = actions.next() else {
786 break;
787 };
788 let agent = self.clone();
789 tasks.spawn(async move { agent.resolve_prefetch_action(action, adapter).await });
790 }
791 let mut resolved = Vec::new();
792 while !tasks.is_empty() {
793 let result = if resolved.is_empty() {
794 tasks.join_next().await
795 } else {
796 match tokio::time::timeout(PREFETCH_ACTION_BATCH_DELAY, tasks.join_next()).await {
797 Ok(result) => result,
798 Err(_) => {
799 self.prefetch_resolved_actions(std::mem::take(&mut resolved))
800 .await;
801 continue;
802 }
803 }
804 };
805 let Some(result) = result else {
806 break;
807 };
808 match result {
809 Ok(Ok(Some(action))) => resolved.push(action),
810 Ok(Ok(None)) => {}
811 Ok(Err(error)) => warn!("remote action prefetch failed: {error}"),
812 Err(error) => warn!("remote action prefetch task failed: {error}"),
813 }
814 if let Some((action, adapter)) = actions.next() {
815 let agent = self.clone();
816 tasks.spawn(async move { agent.resolve_prefetch_action(action, adapter).await });
817 }
818 if resolved.len() == MAX_PREFETCH_ACTION_BATCH {
819 self.prefetch_resolved_actions(std::mem::take(&mut resolved))
820 .await;
821 }
822 }
823 if !resolved.is_empty() {
824 self.prefetch_resolved_actions(resolved).await;
825 }
826 }
827
828 #[cfg(test)]
829 async fn prefetch_action(&self, action: CacheDigest, adapter: String) -> Result<()> {
830 if let Some(action) = self.resolve_prefetch_action(action, adapter).await? {
831 self.prefetch_resolved_actions(vec![action]).await;
832 }
833 Ok(())
834 }
835
836 async fn resolve_prefetch_action(
837 &self,
838 action: CacheDigest,
839 adapter: String,
840 ) -> Result<Option<PrefetchedAction>> {
841 let remote = self
842 .remote
843 .as_ref()
844 .ok_or_else(|| eyre::eyre!("remote cache is not configured"))?;
845 let result = {
846 let lock = self.action_lock(&action);
847 let _guard = lock.lock().await;
848 if self.actions.find(&action)?.is_some() {
849 return Ok(None);
850 }
851 if let Some(result) = self
852 .pending_remote_actions
853 .lock()
854 .unwrap()
855 .get(&action)
856 .cloned()
857 {
858 result
859 } else {
860 let _prefetch_permit = self.prefetch_transfers.acquire().await?;
861 let result = {
862 let _permit = self.remote_transfers.acquire().await?;
863 self.get_remote_action_result(remote, &action).await?
864 };
865 let Some(result) = result else {
866 return Ok(None);
867 };
868 self.pending_remote_actions
869 .lock()
870 .unwrap()
871 .insert(action.clone(), result.clone());
872 result
873 }
874 };
875 Ok(Some(PrefetchedAction { adapter, result }))
876 }
877
878 fn prefetch_resolved_actions(&self, actions: Vec<PrefetchedAction>) -> BoxFuture<'_, ()> {
879 self.prefetch_resolved_actions_inner(actions).boxed()
880 }
881
882 async fn prefetch_resolved_actions_inner(&self, actions: Vec<PrefetchedAction>) {
883 let Some(remote) = self.remote.as_deref() else {
884 return;
885 };
886 if actions.is_empty() {
887 return;
888 }
889
890 let mut top_level = BTreeMap::new();
891 for action in &actions {
892 for digest in [
893 Some(&action.result.action),
894 action.result.metadata.as_ref(),
895 action.result.output_root.as_ref(),
896 ]
897 .into_iter()
898 .flatten()
899 {
900 top_level.insert(digest.clone(), ());
901 }
902 }
903 let mut verified = self
904 .fetch_remote_blobs(
905 remote,
906 top_level.into_keys().collect(),
907 Some(&self.prefetch_transfers),
908 )
909 .await;
910
911 let mut next = BTreeMap::new();
912 let mut pending_directories = BTreeMap::new();
913 let mut parsed_directories = BTreeMap::new();
914 let mut rustc_metadata = BTreeMap::new();
915 for action in &actions {
916 if action.adapter == "rustc"
917 && let Some(metadata_digest) = &action.result.metadata
918 {
919 match verified
920 .get(metadata_digest)
921 .ok_or_else(|| eyre::eyre!("remote rustc action metadata is missing"))
922 .and_then(|path| Self::parse_rustc_metadata(path))
923 {
924 Ok(metadata) => {
925 queue_prefetch_digest(&verified, &mut next, metadata.stdout.clone());
926 queue_prefetch_digest(&verified, &mut next, metadata.stderr.clone());
927 rustc_metadata.insert(metadata_digest.clone(), metadata);
928 }
929 Err(error) => warn!(
930 "remote rustc action metadata prefetch failed for {}: {error}",
931 action.result.action.hash
932 ),
933 }
934 }
935 if let Some(output_root) = &action.result.output_root {
936 pending_directories.insert(output_root.clone(), ());
937 }
938 }
939
940 let mut seen_directories = BTreeMap::new();
941 loop {
942 let mut following = BTreeMap::new();
943 let mut directory_limit_exceeded = false;
944 for digest in pending_directories.into_keys() {
945 following.remove(&digest);
946 if seen_directories.insert(digest.clone(), ()).is_some() {
947 continue;
948 }
949 if seen_directories.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
950 warn!("remote action output tree is too large to prefetch");
951 following.clear();
952 break;
953 }
954 match verified
955 .get(&digest)
956 .ok_or_else(|| eyre::eyre!("remote action output directory is missing"))
957 .and_then(|path| Self::parse_cache_directory(path))
958 {
959 Ok(directory) => {
960 for file in &directory.files {
961 queue_prefetch_digest(&verified, &mut next, file.digest.clone());
962 if next.len() >= MAX_PREFETCH_OBJECTS_PER_WAVE {
963 self.flush_prefetch_digest_batch(remote, &mut verified, &mut next)
964 .await;
965 }
966 }
967 for child in &directory.directories {
968 if !queue_prefetch_directory(
969 &seen_directories,
970 &mut following,
971 child.digest.clone(),
972 MAX_PREFETCH_DIRECTORY_OBJECTS,
973 ) {
974 warn!("remote action output tree is too large to prefetch");
975 directory_limit_exceeded = true;
976 break;
977 }
978 queue_prefetch_digest(&verified, &mut next, child.digest.clone());
979 if next.len() >= MAX_PREFETCH_OBJECTS_PER_WAVE {
980 self.flush_prefetch_digest_batch(remote, &mut verified, &mut next)
981 .await;
982 }
983 }
984 parsed_directories.insert(digest, directory);
985 }
986 Err(error) => warn!(
987 "remote action output directory prefetch failed for {}: {error}",
988 digest.hash
989 ),
990 }
991 if directory_limit_exceeded {
992 following.clear();
993 break;
994 }
995 }
996 self.flush_prefetch_digest_batch(remote, &mut verified, &mut next)
997 .await;
998 if following.is_empty() {
999 break;
1000 }
1001 pending_directories = following;
1002 }
1003
1004 for action in actions {
1005 match Self::validate_prefetched_action(
1006 &action,
1007 &verified,
1008 &rustc_metadata,
1009 &parsed_directories,
1010 ) {
1011 Ok(()) => {
1012 if let Err(error) = self.actions.store(&action.result) {
1013 warn!(
1014 "remote action prefetch could not publish {}: {error}",
1015 action.result.action.hash
1016 );
1017 continue;
1018 }
1019 self.pending_remote_actions
1020 .lock()
1021 .unwrap()
1022 .remove(&action.result.action);
1023 self.stats
1024 .prefetched_actions
1025 .fetch_add(1, Ordering::Relaxed);
1026 }
1027 Err(error) => warn!(
1028 "remote action prefetch was incomplete for {}: {error}",
1029 action.result.action.hash
1030 ),
1031 }
1032 }
1033 }
1034
1035 async fn flush_prefetch_digest_batch(
1036 &self,
1037 remote: &RemoteCacheClient,
1038 verified: &mut BTreeMap<CacheDigest, PathBuf>,
1039 pending: &mut BTreeMap<CacheDigest, ()>,
1040 ) {
1041 if pending.is_empty() {
1042 return;
1043 }
1044 let digests = std::mem::take(pending).into_keys().collect();
1045 verified.extend(
1046 self.fetch_remote_blobs(remote, digests, Some(&self.prefetch_transfers))
1047 .await,
1048 );
1049 }
1050
1051 async fn fetch_remote_blobs(
1052 &self,
1053 remote: &RemoteCacheClient,
1054 digests: Vec<CacheDigest>,
1055 prefetch_limit: Option<&tokio::sync::Semaphore>,
1056 ) -> BTreeMap<CacheDigest, PathBuf> {
1057 let mut verified = BTreeMap::new();
1058 let mut missing = BTreeMap::new();
1059 for digest in digests {
1060 match self.find_verified_blob(&digest) {
1061 Ok(Some(path)) => {
1062 verified.insert(digest, path);
1063 }
1064 Ok(None) => {
1065 missing.insert(digest, ());
1066 }
1067 Err(error) => warn!(
1068 "local cache blob lookup failed for {}: {error}",
1069 digest.hash
1070 ),
1071 }
1072 }
1073 if missing.is_empty() {
1074 return verified;
1075 }
1076
1077 let mut pack_candidates = missing.clone();
1078 while !pack_candidates.is_empty() {
1079 let requested = pack_candidates.keys().cloned().collect::<Vec<_>>();
1080 let (pack, transfer_duration_ns) = {
1081 let _prefetch_permit = match prefetch_limit {
1082 Some(limit) => match limit.acquire().await {
1083 Ok(permit) => Some(permit),
1084 Err(error) => {
1085 warn!(
1086 "remote cache blob pack could not acquire prefetch limit: {error}"
1087 );
1088 break;
1089 }
1090 },
1091 None => None,
1092 };
1093 let _transfer_permit = match self.remote_transfers.acquire().await {
1094 Ok(permit) => permit,
1095 Err(error) => {
1096 warn!("remote cache blob pack could not acquire transfer limit: {error}");
1097 break;
1098 }
1099 };
1100 let transfer_started = Instant::now();
1101 let pack = remote
1102 .get_blob_pack(&requested, self.remote_staging_dir.as_path())
1103 .await;
1104 (pack, duration_ns(transfer_started))
1105 };
1106 let pack = match pack {
1107 Ok(Some(pack)) => pack,
1108 Ok(None) => break,
1109 Err(error) => {
1110 atomic_saturating_add(
1111 &self.stats.remote_blob_transfer_duration_ns,
1112 transfer_duration_ns,
1113 );
1114 warn!(
1115 "remote cache blob pack failed; falling back to individual blobs: {error}"
1116 );
1117 break;
1118 }
1119 };
1120 atomic_saturating_add(
1121 &self.stats.remote_blob_transfer_duration_ns,
1122 transfer_duration_ns,
1123 );
1124 atomic_saturating_add(&self.stats.remote_blob_pack_requests, pack.requests);
1125 atomic_saturating_add(&self.stats.remote_blob_pack_blobs, pack.blob_count);
1126 atomic_saturating_add(&self.stats.downloaded_bytes, pack.payload_bytes);
1127 if pack.requested.is_empty() {
1128 break;
1129 }
1130 for digest in &pack.requested {
1131 pack_candidates.remove(digest);
1132 }
1133 let mut ingests = stream::iter(pack.blobs.into_iter().map(|(digest, source)| {
1134 let digest_for_result = digest.clone();
1135 async move {
1136 (
1137 digest_for_result,
1138 self.ingest_packed_blob(digest, source).await,
1139 )
1140 }
1141 }))
1142 .buffer_unordered(MAX_PREFETCH_TRANSFERS);
1143 while let Some((digest, result)) = ingests.next().await {
1144 match result {
1145 Ok(path) => {
1146 missing.remove(&digest);
1147 verified.insert(digest, path);
1148 }
1149 Err(error) => warn!(
1150 "remote cache packed blob ingest failed for {}: {error}",
1151 digest.hash
1152 ),
1153 }
1154 }
1155 }
1156
1157 let mut transfers = stream::iter(missing.into_keys().map(|digest| {
1158 let digest_for_result = digest.clone();
1159 async move {
1160 (
1161 digest_for_result,
1162 self.fetch_remote_blob_with_limit(remote, &digest, prefetch_limit)
1163 .await,
1164 )
1165 }
1166 }))
1167 .buffer_unordered(MAX_PREFETCH_TRANSFERS);
1168 while let Some((digest, result)) = transfers.next().await {
1169 match result {
1170 Ok(path) => {
1171 verified.insert(digest, path);
1172 }
1173 Err(error) => warn!(
1174 "remote cache blob prefetch failed for {}: {error}",
1175 digest.hash
1176 ),
1177 }
1178 }
1179 verified
1180 }
1181
1182 async fn ingest_packed_blob(&self, digest: CacheDigest, source: PathBuf) -> Result<PathBuf> {
1183 let digest_size = digest.size;
1184 let lock = self.write_lock(&digest);
1185 let _guard = lock.lock().await;
1186 let agent = self.clone();
1187 let (path, stored, cas_duration_ns) = tokio::task::spawn_blocking(move || {
1188 if let Some(path) = agent.find_verified_blob(&digest)? {
1189 return Ok::<_, eyre::Report>((path, false, 0));
1190 }
1191 let cas_started = Instant::now();
1192 let path = agent.cas.store_verified_file(&digest, &source)?;
1193 let cas_duration_ns = duration_ns(cas_started);
1194 agent.remember_verified_blob(&digest, &path);
1195 Ok((path, true, cas_duration_ns))
1196 })
1197 .await??;
1198 atomic_saturating_add(&self.stats.local_cas_write_duration_ns, cas_duration_ns);
1199 if stored {
1200 self.stats.stores.fetch_add(1, Ordering::Relaxed);
1201 atomic_saturating_add(&self.stats.stored_bytes, digest_size);
1202 }
1203 Ok(path)
1204 }
1205
1206 fn parse_rustc_metadata(path: &Path) -> Result<RustcMetadata> {
1207 let bytes = fs::read(path)?;
1208 let metadata: RustcMetadata = serde_json::from_slice(&bytes)?;
1209 if metadata.version != 1 || metadata.kind != "rustc" || canonical_json(&metadata)? != bytes
1210 {
1211 bail!("remote rustc action metadata is invalid");
1212 }
1213 Ok(metadata)
1214 }
1215
1216 fn parse_cache_directory(path: &Path) -> Result<CacheDirectory> {
1217 let bytes = fs::read(path)?;
1218 let directory: CacheDirectory = serde_json::from_slice(&bytes)?;
1219 if directory.version != 1 || canonical_json(&directory)? != bytes {
1220 bail!("remote action output directory is invalid");
1221 }
1222 Ok(directory)
1223 }
1224
1225 #[cfg(test)]
1226 fn load_cache_directory(&self, digest: &CacheDigest) -> Result<CacheDirectory> {
1227 let path = self
1228 .find_verified_blob(digest)?
1229 .ok_or_else(|| eyre::eyre!("remote action output directory is missing"))?;
1230 Self::parse_cache_directory(&path)
1231 }
1232
1233 fn validate_prefetched_action(
1234 action: &PrefetchedAction,
1235 verified: &BTreeMap<CacheDigest, PathBuf>,
1236 rustc_metadata: &BTreeMap<CacheDigest, RustcMetadata>,
1237 directories: &BTreeMap<CacheDigest, CacheDirectory>,
1238 ) -> Result<()> {
1239 if !verified.contains_key(&action.result.action) {
1240 bail!("remote action descriptor is missing");
1241 }
1242 if let Some(metadata) = &action.result.metadata {
1243 if action.adapter == "rustc" {
1244 let metadata = rustc_metadata
1245 .get(metadata)
1246 .ok_or_else(|| eyre::eyre!("remote rustc action metadata is missing"))?;
1247 for digest in [&metadata.stdout, &metadata.stderr] {
1248 if !verified.contains_key(digest) {
1249 bail!("remote rustc action diagnostic blob is missing");
1250 }
1251 }
1252 } else if !verified.contains_key(metadata) {
1253 bail!("remote action metadata is missing");
1254 }
1255 }
1256 let mut pending = action
1257 .result
1258 .output_root
1259 .iter()
1260 .cloned()
1261 .collect::<Vec<_>>();
1262 let mut seen = BTreeMap::new();
1263 while let Some(digest) = pending.pop() {
1264 if seen.insert(digest.clone(), ()).is_some() {
1265 continue;
1266 }
1267 if seen.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
1268 bail!("remote action output tree is too large");
1269 }
1270 let directory = directories
1271 .get(&digest)
1272 .ok_or_else(|| eyre::eyre!("remote action output directory is missing"))?;
1273 for file in &directory.files {
1274 if !verified.contains_key(&file.digest) {
1275 bail!("remote action output file is missing");
1276 }
1277 }
1278 pending.extend(
1279 directory
1280 .directories
1281 .iter()
1282 .map(|directory| directory.digest.clone()),
1283 );
1284 }
1285 Ok(())
1286 }
1287
1288 #[cfg(test)]
1289 async fn prefetch_output_tree(
1290 &self,
1291 remote: &RemoteCacheClient,
1292 output_root: &CacheDigest,
1293 ) -> Result<()> {
1294 let mut pending = vec![output_root.clone()];
1295 let mut seen = BTreeMap::new();
1296 while let Some(digest) = pending.pop() {
1297 if seen.insert(digest.clone(), ()).is_some() {
1298 continue;
1299 }
1300 if seen.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
1301 bail!("remote action output tree is too large");
1302 }
1303 self.fetch_remote_blob_with_limit(remote, &digest, Some(&self.prefetch_transfers))
1304 .await?;
1305 let directory = self.load_cache_directory(&digest)?;
1306 let mut transfers = stream::iter(directory.files.into_iter().map(|file| async move {
1307 self.fetch_remote_blob_with_limit(
1308 remote,
1309 &file.digest,
1310 Some(&self.prefetch_transfers),
1311 )
1312 .await
1313 .map(|_| ())
1314 }))
1315 .buffer_unordered(MAX_PREFETCH_TRANSFERS);
1316 while let Some(result) = transfers.next().await {
1317 result?;
1318 }
1319 pending.extend(
1320 directory
1321 .directories
1322 .into_iter()
1323 .map(|directory| directory.digest),
1324 );
1325 }
1326 Ok(())
1327 }
1328
1329 async fn fetch_remote_blob(
1330 &self,
1331 remote: &RemoteCacheClient,
1332 digest: &CacheDigest,
1333 ) -> Result<PathBuf> {
1334 self.fetch_remote_blob_with_limit(remote, digest, None)
1335 .await
1336 }
1337
1338 async fn fetch_remote_blob_with_limit(
1339 &self,
1340 remote: &RemoteCacheClient,
1341 digest: &CacheDigest,
1342 prefetch_limit: Option<&tokio::sync::Semaphore>,
1343 ) -> Result<PathBuf> {
1344 let lock = self.write_lock(digest);
1345 let _guard = lock.lock().await;
1346 if let Some(path) = self.find_verified_blob(digest)? {
1347 return Ok(path);
1348 }
1349 let _prefetch_permit = match prefetch_limit {
1350 Some(limit) => Some(limit.acquire().await?),
1351 None => None,
1352 };
1353 let _permit = self.remote_transfers.acquire().await?;
1354 self.stats
1355 .remote_blob_requests
1356 .fetch_add(1, Ordering::Relaxed);
1357 let transfer_timer =
1358 AtomicDurationTimer::start(&self.stats.remote_blob_transfer_duration_ns);
1359 let temporary = remote
1360 .get_blob_file(digest, self.remote_staging_dir.as_path())
1361 .await?;
1362 drop(transfer_timer);
1363 let _cas_timer = AtomicDurationTimer::start(&self.stats.local_cas_write_duration_ns);
1364 let path = self.cas.store_verified_file(digest, temporary.path())?;
1365 self.remember_verified_blob(digest, &path);
1366 self.stats.stores.fetch_add(1, Ordering::Relaxed);
1367 self.stats
1368 .stored_bytes
1369 .fetch_add(digest.size, Ordering::Relaxed);
1370 self.stats
1371 .downloaded_bytes
1372 .fetch_add(digest.size, Ordering::Relaxed);
1373 Ok(path)
1374 }
1375
1376 async fn respond(&self, request: AgentRequest) -> AgentResponse {
1377 let result = match request {
1378 AgentRequest::FindBlob { digest } => self.find_blob(&digest).await,
1379 AgentRequest::FindBlobs { digests } => self.find_blobs(digests).await,
1380 AgentRequest::StoreBlob { digest, source } => self.store_blob(&digest, &source).await,
1381 AgentRequest::FindActionResult { action } => {
1382 self.stats.lookups.fetch_add(1, Ordering::Relaxed);
1383 self.find_action_result(&action).await
1384 }
1385 AgentRequest::RecordActionHit { action, restore } => {
1386 self.record_action_hit(&action, restore)
1387 }
1388 AgentRequest::RecordBypass { kind } => {
1389 *self.stats.bypasses.lock().unwrap().entry(kind).or_insert(0) += 1;
1390 Ok(AgentResponse::BypassRecorded)
1391 }
1392 AgentRequest::RecordActionVerification { matched, restore } => {
1393 self.record_materialization(restore);
1394 self.stats.verifications.fetch_add(1, Ordering::Relaxed);
1395 if !matched {
1396 self.stats.divergences.fetch_add(1, Ordering::Relaxed);
1397 }
1398 Ok(AgentResponse::ActionVerificationRecorded)
1399 }
1400 AgentRequest::StoreActionResult { result } => self.store_action_result(&result).await,
1401 AgentRequest::FindActionPrediction { task, invocation } => {
1402 self.find_action_prediction(&task, &invocation)
1403 }
1404 AgentRequest::RecordActionPrediction { task, prediction } => {
1405 self.record_action_prediction(&task, prediction)
1406 }
1407 AgentRequest::FindExecutableIdentity {
1408 executable,
1409 environment,
1410 } => self.find_executable_identity(executable, environment),
1411 AgentRequest::StoreExecutableIdentity {
1412 executable,
1413 environment,
1414 stdout,
1415 } => self.store_executable_identity(executable, environment, stdout),
1416 AgentRequest::Hello { .. } => {
1417 Err(eyre::eyre!("hello is only valid as the first request"))
1418 }
1419 };
1420 result.unwrap_or_else(|error| AgentResponse::Error {
1421 message: error.to_string(),
1422 })
1423 }
1424
1425 async fn find_blob(&self, digest: &CacheDigest) -> Result<AgentResponse> {
1426 if let Some(path) = self.find_verified_blob(digest)? {
1427 return Ok(AgentResponse::Blob { path: Some(path) });
1428 }
1429 if !self.remote_mode.reads() {
1430 return Ok(AgentResponse::Blob { path: None });
1431 }
1432 let Some(remote) = &self.remote else {
1433 return Ok(AgentResponse::Blob { path: None });
1434 };
1435 match self.fetch_remote_blob(remote, digest).await {
1436 Ok(path) => Ok(AgentResponse::Blob { path: Some(path) }),
1437 Err(error) => {
1438 warn!(
1439 "remote cache blob lookup failed for {}: {error}",
1440 digest.hash
1441 );
1442 Ok(AgentResponse::Blob { path: None })
1443 }
1444 }
1445 }
1446
1447 async fn find_blobs(&self, digests: Vec<CacheDigest>) -> Result<AgentResponse> {
1448 let mut paths = BTreeMap::new();
1449 let mut missing = Vec::new();
1450 for digest in &digests {
1451 match self.find_verified_blob(digest)? {
1452 Some(path) => {
1453 paths.insert(digest.clone(), path);
1454 }
1455 None => {
1456 missing.push(digest.clone());
1457 }
1458 }
1459 }
1460
1461 if !missing.is_empty()
1462 && self.remote_mode.reads()
1463 && let Some(remote) = &self.remote
1464 {
1465 paths.extend(self.fetch_remote_blobs(remote, missing, None).await);
1466 }
1467
1468 Ok(AgentResponse::Blobs {
1469 paths: digests
1470 .into_iter()
1471 .map(|digest| paths.get(&digest).cloned())
1472 .collect(),
1473 })
1474 }
1475
1476 async fn store_blob(&self, digest: &CacheDigest, source: &Path) -> Result<AgentResponse> {
1477 let remote = if self.remote_mode.writes() {
1478 self.remote.as_deref()
1479 } else {
1480 None
1481 };
1482 let path = {
1483 let lock = self.write_lock(digest);
1484 let _guard = lock.lock().await;
1485 if let Some(path) = self.find_verified_blob(digest)? {
1486 path
1487 } else {
1488 let path = self.cas.store_file(digest, source)?;
1489 self.remember_verified_blob(digest, &path);
1490 self.stats.stores.fetch_add(1, Ordering::Relaxed);
1491 self.stats
1492 .stored_bytes
1493 .fetch_add(digest.size, Ordering::Relaxed);
1494 path
1495 }
1496 };
1497 if let Some(remote) = remote {
1498 let _permit = self.remote_transfers.acquire().await?;
1499 if let Err(error) = remote
1500 .put_blob(&BlobUpload {
1501 digest: digest.clone(),
1502 source: BlobSource::Path(path.clone()),
1503 })
1504 .await
1505 {
1506 warn!(
1507 "remote cache blob upload failed for {}: {error}",
1508 digest.hash
1509 );
1510 } else {
1511 self.stats
1512 .uploaded_bytes
1513 .fetch_add(digest.size, Ordering::Relaxed);
1514 }
1515 }
1516 Ok(AgentResponse::Stored { path })
1517 }
1518
1519 fn find_verified_blob(&self, digest: &CacheDigest) -> Result<Option<PathBuf>> {
1520 let remembered = self.verified_blobs.lock().unwrap().get(digest).cloned();
1521 if let Some(path) = remembered {
1522 if digest.matches_file(&path).unwrap_or(false) {
1523 return Ok(Some(path));
1524 }
1525 self.verified_blobs.lock().unwrap().remove(digest);
1526 }
1527 let path = self.cas.find(digest)?;
1528 if let Some(path) = &path {
1529 self.remember_verified_blob(digest, path);
1530 }
1531 Ok(path)
1532 }
1533
1534 fn remember_verified_blob(&self, digest: &CacheDigest, path: &Path) {
1535 self.verified_blobs
1536 .lock()
1537 .unwrap()
1538 .insert(digest.clone(), path.to_path_buf());
1539 }
1540
1541 async fn find_action_result(&self, action: &CacheDigest) -> Result<AgentResponse> {
1542 if let Some(result) = self.actions.find(action)? {
1543 return Ok(AgentResponse::ActionResult {
1544 result: Some(result),
1545 });
1546 }
1547 if !self.remote_mode.reads() {
1548 return Ok(AgentResponse::ActionResult { result: None });
1549 }
1550 let Some(remote) = &self.remote else {
1551 return Ok(AgentResponse::ActionResult { result: None });
1552 };
1553 let lock = self.action_lock(action);
1554 let _guard = lock.lock().await;
1555 if let Some(result) = self.actions.find(action)? {
1556 return Ok(AgentResponse::ActionResult {
1557 result: Some(result),
1558 });
1559 }
1560 if let Some(result) = self
1561 .pending_remote_actions
1562 .lock()
1563 .unwrap()
1564 .get(action)
1565 .cloned()
1566 {
1567 return Ok(AgentResponse::ActionResult {
1568 result: Some(result),
1569 });
1570 }
1571 let _permit = self.remote_transfers.acquire().await?;
1572 match self.get_remote_action_result(remote, action).await {
1573 Ok(Some(result)) => {
1574 self.pending_remote_actions
1575 .lock()
1576 .unwrap()
1577 .insert(action.clone(), result.clone());
1578 Ok(AgentResponse::ActionResult {
1579 result: Some(result),
1580 })
1581 }
1582 Ok(None) => Ok(AgentResponse::ActionResult { result: None }),
1583 Err(error) => {
1584 warn!(
1585 "remote cache action lookup failed for {}: {error}",
1586 action.hash
1587 );
1588 Ok(AgentResponse::ActionResult { result: None })
1589 }
1590 }
1591 }
1592
1593 async fn store_action_result(&self, result: &RemoteActionResult) -> Result<AgentResponse> {
1594 let path = self.actions.store(result)?;
1595 if self.remote_mode.writes()
1596 && let Some(remote) = &self.remote
1597 {
1598 let _permit = self.remote_transfers.acquire().await?;
1599 if let Err(error) = remote.put_action_result(result).await {
1600 warn!(
1601 "remote cache action upload failed for {}: {error}",
1602 result.action.hash
1603 );
1604 }
1605 }
1606 Ok(AgentResponse::ActionStored { path })
1607 }
1608
1609 async fn get_remote_action_result(
1610 &self,
1611 remote: &RemoteCacheClient,
1612 action: &CacheDigest,
1613 ) -> Result<Option<RemoteActionResult>> {
1614 self.stats
1615 .remote_action_lookups
1616 .fetch_add(1, Ordering::Relaxed);
1617 let _timer = AtomicDurationTimer::start(&self.stats.remote_action_lookup_duration_ns);
1618 remote.get_action_result(action).await
1619 }
1620
1621 fn record_action_hit(
1622 &self,
1623 action: &CacheDigest,
1624 restore: RestoreStats,
1625 ) -> Result<AgentResponse> {
1626 if self.actions.find(action)?.is_none() {
1627 let pending = self.pending_remote_actions.lock().unwrap().remove(action);
1628 if let Some(result) = pending {
1629 self.actions.store(&result)?;
1630 } else {
1631 bail!("cannot record a hit for a missing action result");
1632 }
1633 }
1634 self.record_restore(restore);
1635 self.stats.hits.fetch_add(1, Ordering::Relaxed);
1636 Ok(AgentResponse::ActionHitRecorded)
1637 }
1638
1639 fn record_restore(&self, restore: RestoreStats) {
1640 self.record_materialization(restore);
1641 atomic_saturating_add(&self.stats.restored_output_files, restore.output_files);
1642 atomic_saturating_add(&self.stats.restored_output_bytes, restore.output_bytes);
1643 }
1644
1645 fn record_materialization(&self, restore: RestoreStats) {
1646 atomic_saturating_add(&self.stats.materialization_duration_ns, restore.duration_ns);
1647 }
1648
1649 fn find_action_prediction(
1650 &self,
1651 task: &str,
1652 invocation: &CacheDigest,
1653 ) -> Result<AgentResponse> {
1654 validate_task_identity(task)?;
1655 invocation.validate()?;
1656 let prediction = self
1657 .task_actions
1658 .lock()
1659 .unwrap()
1660 .get(task)
1661 .and_then(|state| state.predictions.get(invocation))
1662 .cloned();
1663 Ok(AgentResponse::ActionPrediction { prediction })
1664 }
1665
1666 fn record_action_prediction(
1667 &self,
1668 task: &str,
1669 prediction: ActionPrediction,
1670 ) -> Result<AgentResponse> {
1671 validate_task_identity(task)?;
1672 validate_action_prediction(&prediction)?;
1673 let mut tasks = self.task_actions.lock().unwrap();
1674 let state = tasks.entry(task.to_string()).or_default();
1675 if !state.predictions.contains_key(&prediction.invocation)
1676 && state.predictions.len() >= MAX_TASK_ACTION_PREDICTIONS
1677 {
1678 bail!("task action manifest contains too many predictions");
1679 }
1680 state
1681 .predictions
1682 .insert(prediction.invocation.clone(), prediction);
1683 Ok(AgentResponse::ActionPredictionRecorded)
1684 }
1685
1686 fn executable_identity_key(
1687 &self,
1688 executable: PathBuf,
1689 environment: BTreeMap<String, Option<String>>,
1690 ) -> Result<ExecutableIdentityKey> {
1691 if !environment
1692 .keys()
1693 .all(|name| matches!(name.as_str(), "RUSTUP_HOME" | "RUSTUP_TOOLCHAIN"))
1694 {
1695 bail!("executable identity contains an unsupported environment variable");
1696 }
1697 Ok(ExecutableIdentityKey {
1698 executable,
1699 environment,
1700 })
1701 }
1702
1703 fn find_executable_identity(
1704 &self,
1705 executable: PathBuf,
1706 environment: BTreeMap<String, Option<String>>,
1707 ) -> Result<AgentResponse> {
1708 let key = self.executable_identity_key(executable, environment)?;
1709 let stdout = self
1710 .executable_identities
1711 .lock()
1712 .unwrap()
1713 .get(&key)
1714 .cloned();
1715 Ok(AgentResponse::ExecutableIdentity { stdout })
1716 }
1717
1718 fn store_executable_identity(
1719 &self,
1720 executable: PathBuf,
1721 environment: BTreeMap<String, Option<String>>,
1722 stdout: Vec<u8>,
1723 ) -> Result<AgentResponse> {
1724 if stdout.len() > MAX_EXECUTABLE_IDENTITY_SIZE {
1725 bail!("executable identity exceeds {MAX_EXECUTABLE_IDENTITY_SIZE} bytes");
1726 }
1727 let key = self.executable_identity_key(executable, environment)?;
1728 let mut identities = self.executable_identities.lock().unwrap();
1729 let is_new = !identities.contains_key(&key);
1730 let previous_size = identities.get(&key).map_or(0, Vec::len);
1731 if is_new && identities.len() >= MAX_EXECUTABLE_IDENTITIES {
1732 bail!("executable identity cache contains too many entries");
1733 }
1734 let retained_bytes = identities.values().map(Vec::len).sum::<usize>();
1735 if retained_bytes - previous_size + stdout.len() > MAX_EXECUTABLE_IDENTITY_BYTES {
1736 bail!("executable identity cache contains too many bytes");
1737 }
1738 identities.insert(key, stdout.clone());
1739 Ok(AgentResponse::ExecutableIdentity {
1740 stdout: Some(stdout),
1741 })
1742 }
1743
1744 pub async fn handle_connection<S>(&self, stream: S) -> Result<()>
1746 where
1747 S: AsyncRead + AsyncWrite + Unpin,
1748 {
1749 let (reader, mut writer) = tokio::io::split(stream);
1750 let mut reader = BufReader::new(reader);
1751 let hello = read_request(&mut reader)
1752 .await?
1753 .ok_or_else(|| eyre::eyre!("connection closed before the agent handshake"))?;
1754 let request: AgentRequest = serde_json::from_str(&hello)?;
1755 match request {
1756 AgentRequest::Hello {
1757 protocol,
1758 client_version,
1759 } if protocol == AGENT_PROTOCOL_VERSION && client_version == self.version.as_ref() => {}
1760 AgentRequest::Hello { protocol, .. } if protocol != AGENT_PROTOCOL_VERSION => {
1761 send_response(
1762 &mut writer,
1763 &AgentResponse::Error {
1764 message: format!(
1765 "unsupported agent protocol {protocol}; expected {AGENT_PROTOCOL_VERSION}"
1766 ),
1767 },
1768 )
1769 .await?;
1770 return Ok(());
1771 }
1772 AgentRequest::Hello { client_version, .. } => {
1773 send_response(
1774 &mut writer,
1775 &AgentResponse::Error {
1776 message: format!(
1777 "cache client {client_version} does not match agent {}",
1778 self.version
1779 ),
1780 },
1781 )
1782 .await?;
1783 return Ok(());
1784 }
1785 _ => bail!("the first agent request must be hello"),
1786 }
1787 send_response(
1788 &mut writer,
1789 &AgentResponse::Hello {
1790 protocol: AGENT_PROTOCOL_VERSION,
1791 agent_version: self.version.to_string(),
1792 },
1793 )
1794 .await?;
1795
1796 while let Some(line) = read_request(&mut reader).await? {
1797 let response = match serde_json::from_str(&line) {
1798 Ok(request) => self.respond(request).await,
1799 Err(error) => AgentResponse::Error {
1800 message: format!("invalid agent request: {error}"),
1801 },
1802 };
1803 send_response(&mut writer, &response).await?;
1804 }
1805 Ok(())
1806 }
1807}
1808
1809async fn read_request<R>(reader: &mut R) -> Result<Option<String>>
1815where
1816 R: AsyncBufRead + Unpin,
1817{
1818 let mut line = Vec::new();
1819 loop {
1820 let available = reader.fill_buf().await?;
1821 if available.is_empty() {
1822 break;
1823 }
1824 let (consumed, complete) = match available.iter().position(|byte| *byte == b'\n') {
1825 Some(index) => (index, true),
1826 None => (available.len(), false),
1827 };
1828 if line.len() + consumed > MAX_REQUEST_BYTES {
1829 bail!("agent request exceeded {MAX_REQUEST_BYTES} bytes");
1830 }
1831 line.extend_from_slice(&available[..consumed]);
1832 reader.consume(consumed + usize::from(complete));
1834 if complete {
1835 return Ok(Some(String::from_utf8(line)?));
1836 }
1837 }
1838 if line.is_empty() {
1839 Ok(None)
1840 } else {
1841 Ok(Some(String::from_utf8(line)?))
1842 }
1843}
1844
1845fn validate_task_identity(task: &str) -> Result<()> {
1846 if task.len() != 64
1847 || !task
1848 .bytes()
1849 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1850 {
1851 bail!("invalid task action identity");
1852 }
1853 Ok(())
1854}
1855
1856fn validate_action_prediction(prediction: &ActionPrediction) -> Result<()> {
1857 prediction.invocation.validate()?;
1858 prediction.action.validate()?;
1859 if prediction.adapter.is_empty()
1860 || !prediction
1861 .adapter
1862 .bytes()
1863 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1864 {
1865 bail!("invalid action prediction adapter");
1866 }
1867 if prediction.payload.len() > MAX_ACTION_PREDICTION_PAYLOAD {
1868 bail!("action prediction payload is too large");
1869 }
1870 serde_json::from_str::<serde_json::Value>(&prediction.payload)?;
1871 Ok(())
1872}
1873
1874fn validate_task_manifest(manifest: &TaskActionManifest, task: &str) -> Result<()> {
1875 if manifest.version != TASK_ACTION_MANIFEST_VERSION || manifest.task != task {
1876 bail!("task action manifest has an invalid identity");
1877 }
1878 if manifest.predictions.len() > MAX_TASK_ACTION_PREDICTIONS {
1879 bail!("task action manifest contains too many predictions");
1880 }
1881 let mut invocations = BTreeMap::new();
1882 for prediction in &manifest.predictions {
1883 validate_action_prediction(prediction)?;
1884 if invocations.insert(&prediction.invocation, ()).is_some() {
1885 bail!("task action manifest contains duplicate predictions");
1886 }
1887 }
1888 Ok(())
1889}
1890
1891fn merge_task_manifests(
1892 task: &str,
1893 base: Option<TaskActionManifest>,
1894 update: TaskActionManifest,
1895) -> Result<TaskActionManifest> {
1896 validate_task_manifest(&update, task)?;
1897 let mut predictions = BTreeMap::new();
1898 if let Some(base) = base {
1899 validate_task_manifest(&base, task)?;
1900 predictions.extend(
1901 base.predictions
1902 .into_iter()
1903 .map(|prediction| (prediction.invocation.clone(), prediction)),
1904 );
1905 }
1906 predictions.extend(
1907 update
1908 .predictions
1909 .into_iter()
1910 .map(|prediction| (prediction.invocation.clone(), prediction)),
1911 );
1912 let manifest = TaskActionManifest {
1913 version: TASK_ACTION_MANIFEST_VERSION,
1914 task: task.to_owned(),
1915 predictions: predictions.into_values().collect(),
1916 };
1917 validate_task_manifest(&manifest, task)?;
1918 Ok(manifest)
1919}
1920
1921fn merge_remote_task_manifest(
1922 task: &str,
1923 remote: TaskActionManifest,
1924 local: TaskActionManifest,
1925) -> (TaskActionManifest, bool) {
1926 match merge_task_manifests(task, Some(remote), local.clone()) {
1927 Ok(manifest) => (manifest, true),
1928 Err(error) => {
1929 warn!("remote task action manifest merge failed for {task}: {error}");
1930 (local, false)
1931 }
1932 }
1933}
1934
1935async fn send_response(
1936 writer: &mut (impl AsyncWrite + Unpin),
1937 response: &AgentResponse,
1938) -> Result<()> {
1939 let mut encoded = serde_json::to_vec(response)?;
1940 encoded.push(b'\n');
1941 writer.write_all(&encoded).await?;
1942 writer.flush().await?;
1943 Ok(())
1944}
1945
1946#[cfg(test)]
1947mod tests {
1948 use super::*;
1949 use crate::ACTION_RESULT_MEDIA_TYPE;
1950 use std::time::Duration;
1951
1952 #[test]
1953 fn directory_queue_counts_only_unique_unseen_nodes() {
1954 let shared = CacheDigest::blake3(b"shared");
1955 let first = CacheDigest::blake3(b"first");
1956 let second = CacheDigest::blake3(b"second");
1957 let overflow = CacheDigest::blake3(b"overflow");
1958 let seen = BTreeMap::from([(shared.clone(), ())]);
1959 let mut pending = BTreeMap::new();
1960
1961 assert!(queue_prefetch_directory(&seen, &mut pending, shared, 3));
1962 assert!(pending.is_empty());
1963 assert!(queue_prefetch_directory(
1964 &seen,
1965 &mut pending,
1966 first.clone(),
1967 3
1968 ));
1969 assert!(queue_prefetch_directory(&seen, &mut pending, first, 3));
1970 assert!(queue_prefetch_directory(&seen, &mut pending, second, 3));
1971 assert!(!queue_prefetch_directory(&seen, &mut pending, overflow, 3));
1972 assert_eq!(pending.len(), 2);
1973 }
1974
1975 async fn handshake(stream: &mut (impl AsyncRead + AsyncWrite + Unpin), version: &str) {
1976 let request = AgentRequest::Hello {
1977 protocol: AGENT_PROTOCOL_VERSION,
1978 client_version: version.to_string(),
1979 };
1980 let mut encoded = serde_json::to_vec(&request).unwrap();
1981 encoded.push(b'\n');
1982 stream.write_all(&encoded).await.unwrap();
1983 stream.flush().await.unwrap();
1984 let mut response = String::new();
1985 BufReader::new(stream)
1986 .read_line(&mut response)
1987 .await
1988 .unwrap();
1989 assert!(matches!(
1990 serde_json::from_str(&response).unwrap(),
1991 AgentResponse::Hello { .. }
1992 ));
1993 }
1994
1995 #[tokio::test]
1996 async fn rejects_a_request_that_never_ends_its_line() {
1997 let directory = tempfile::tempdir().unwrap();
1998 let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
1999 let (mut client, server) = tokio::io::duplex(64 * 1024);
2000 let task = tokio::spawn(async move { agent.handle_connection(server).await });
2001
2002 handshake(&mut client, "test-version").await;
2003 let filler = vec![b'x'; 64 * 1024];
2006 let mut written = 0usize;
2007 while written <= MAX_REQUEST_BYTES {
2008 if client.write_all(&filler).await.is_err() {
2009 break;
2010 }
2011 written += filler.len();
2012 }
2013
2014 let error = task.await.unwrap().err().unwrap();
2015 assert!(
2016 error.to_string().contains("exceeded"),
2017 "unexpected error: {error}"
2018 );
2019 }
2020
2021 #[tokio::test]
2022 async fn counts_bypasses_by_reason() {
2023 let directory = tempfile::tempdir().unwrap();
2024 let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2025
2026 for kind in [
2027 "unsupported-crate-type",
2028 "unsupported-crate-type",
2029 "incremental",
2030 ] {
2031 agent
2032 .respond(AgentRequest::RecordBypass { kind: kind.into() })
2033 .await;
2034 }
2035
2036 let stats = agent.stats();
2037 assert_eq!(stats.bypasses.get("unsupported-crate-type"), Some(&2));
2038 assert_eq!(stats.bypasses.get("incremental"), Some(&1));
2039 }
2040
2041 #[tokio::test]
2042 async fn handshake_and_blob_round_trip() {
2043 let directory = tempfile::tempdir().unwrap();
2044 let source = directory.path().join("source");
2045 std::fs::write(&source, b"cached object").unwrap();
2046 let digest = CacheDigest::blake3(b"cached object");
2047 let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2048 let (mut client, server) = tokio::io::duplex(16 * 1024);
2049 let server_agent = agent.clone();
2050 let task = tokio::spawn(async move { server_agent.handle_connection(server).await });
2051
2052 handshake(&mut client, "test-version").await;
2053 let request = AgentRequest::StoreBlob {
2054 digest: digest.clone(),
2055 source,
2056 };
2057 let mut encoded = serde_json::to_vec(&request).unwrap();
2058 encoded.push(b'\n');
2059 client.write_all(&encoded).await.unwrap();
2060 let mut response = String::new();
2061 BufReader::new(&mut client)
2062 .read_line(&mut response)
2063 .await
2064 .unwrap();
2065 assert!(matches!(
2066 serde_json::from_str(&response).unwrap(),
2067 AgentResponse::Stored { .. }
2068 ));
2069 drop(client);
2070 task.await.unwrap().unwrap();
2071 assert_eq!(
2072 agent.stats(),
2073 AgentStats {
2074 stores: 1,
2075 stored_bytes: digest.size,
2076 ..AgentStats::default()
2077 }
2078 );
2079 }
2080
2081 #[test]
2082 fn remembered_blobs_reject_same_size_corruption() {
2083 let directory = tempfile::tempdir().unwrap();
2084 let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2085 let digest = CacheDigest::blake3(b"cached object");
2086 let path = agent.cas.store_bytes(&digest, b"cached object").unwrap();
2087 assert_eq!(
2088 agent.find_verified_blob(&digest).unwrap(),
2089 Some(path.clone())
2090 );
2091
2092 std::fs::write(&path, b"broken object").unwrap();
2093
2094 assert!(agent.find_verified_blob(&digest).is_err());
2095 assert!(!agent.verified_blobs.lock().unwrap().contains_key(&digest));
2096 }
2097
2098 #[tokio::test]
2099 async fn publishes_a_complete_action_result() {
2100 let directory = tempfile::tempdir().unwrap();
2101 let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2102 let action = CacheDigest::blake3(b"action");
2103 let metadata = CacheDigest::blake3(b"metadata");
2104 let output_root = CacheDigest::blake3(b"directory");
2105 for (digest, contents) in [
2106 (&action, b"action".as_slice()),
2107 (&metadata, b"metadata".as_slice()),
2108 (&output_root, b"directory".as_slice()),
2109 ] {
2110 agent.cas.store_bytes(digest, contents).unwrap();
2111 }
2112 let response = agent
2113 .respond(AgentRequest::StoreActionResult {
2114 result: RemoteActionResult {
2115 action: action.clone(),
2116 metadata: Some(metadata),
2117 output_root: Some(output_root),
2118 version: 1,
2119 },
2120 })
2121 .await;
2122 assert!(matches!(response, AgentResponse::ActionStored { .. }));
2123 let response = agent
2124 .respond(AgentRequest::FindActionResult {
2125 action: action.clone(),
2126 })
2127 .await;
2128 assert!(matches!(
2129 response,
2130 AgentResponse::ActionResult {
2131 result: Some(result)
2132 } if result.action == action
2133 ));
2134 assert!(matches!(
2135 agent
2136 .respond(AgentRequest::RecordActionHit {
2137 action: action.clone(),
2138 restore: RestoreStats {
2139 duration_ns: 7,
2140 output_files: 2,
2141 output_bytes: 11,
2142 },
2143 })
2144 .await,
2145 AgentResponse::ActionHitRecorded
2146 ));
2147 assert_eq!(
2148 agent.stats(),
2149 AgentStats {
2150 lookups: 1,
2151 hits: 1,
2152 materialization_duration_ns: 7,
2153 restored_output_files: 2,
2154 restored_output_bytes: 11,
2155 ..AgentStats::default()
2156 }
2157 );
2158 }
2159
2160 #[tokio::test]
2161 async fn missing_action_result_is_a_cache_miss() {
2162 let directory = tempfile::tempdir().unwrap();
2163 let agent = CacheAgent::new(directory.path(), "test-version");
2164 let action = CacheDigest::blake3(b"missing action");
2165 let response = agent
2166 .respond(AgentRequest::FindActionResult {
2167 action: action.clone(),
2168 })
2169 .await;
2170
2171 assert!(matches!(
2172 response,
2173 AgentResponse::ActionResult { result: None }
2174 ));
2175 assert!(matches!(
2176 agent
2177 .respond(AgentRequest::RecordActionHit {
2178 action,
2179 restore: RestoreStats::default(),
2180 })
2181 .await,
2182 AgentResponse::Error { .. }
2183 ));
2184 assert_eq!(
2185 agent.stats(),
2186 AgentStats {
2187 lookups: 1,
2188 ..AgentStats::default()
2189 }
2190 );
2191
2192 assert!(matches!(
2193 agent
2194 .respond(AgentRequest::RecordActionVerification {
2195 matched: false,
2196 restore: RestoreStats {
2197 duration_ns: 7,
2198 output_files: 2,
2199 output_bytes: 11,
2200 },
2201 })
2202 .await,
2203 AgentResponse::ActionVerificationRecorded
2204 ));
2205 assert_eq!(agent.stats().verifications, 1);
2206 assert_eq!(agent.stats().divergences, 1);
2207 assert_eq!(agent.stats().materialization_duration_ns, 7);
2208 assert_eq!(agent.stats().restored_output_files, 0);
2209 assert_eq!(agent.stats().restored_output_bytes, 0);
2210 }
2211
2212 #[tokio::test]
2213 async fn coalesces_repeated_remote_action_lookups() {
2214 let directory = tempfile::tempdir().unwrap();
2215 let mut server = mockito::Server::new_async().await;
2216 let action = CacheDigest::blake3(b"remote action");
2217 let result = RemoteActionResult {
2218 action: action.clone(),
2219 metadata: None,
2220 output_root: None,
2221 version: 1,
2222 };
2223 let remote = server
2224 .mock("GET", action_path(&action).as_str())
2225 .with_status(200)
2226 .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2227 .with_body(serde_json::to_vec(&result).unwrap())
2228 .expect(1)
2229 .create_async()
2230 .await;
2231 let agent = remote_agent(
2232 &server,
2233 directory.path().join("reader"),
2234 RemoteCacheMode::ReadOnly,
2235 );
2236
2237 for _ in 0..2 {
2238 assert!(matches!(
2239 agent
2240 .respond(AgentRequest::FindActionResult {
2241 action: action.clone(),
2242 })
2243 .await,
2244 AgentResponse::ActionResult {
2245 result: Some(found)
2246 } if found == result
2247 ));
2248 }
2249 remote.assert_async().await;
2250 }
2251
2252 #[tokio::test]
2253 async fn publishes_only_successfully_committed_task_action_manifests() {
2254 let directory = tempfile::tempdir().unwrap();
2255 let cache = directory.path().join("cache");
2256 let task = "a".repeat(64);
2257 let first_invocation = CacheDigest::blake3(b"first invocation");
2258 let first = ActionPrediction {
2259 invocation: first_invocation.clone(),
2260 action: CacheDigest::blake3(b"first action"),
2261 adapter: "rustc".into(),
2262 payload: "{}".into(),
2263 };
2264
2265 let agent = CacheAgent::new(&cache, "test-version");
2266 let first_run = agent.begin_task(&task).await.unwrap();
2267 assert!(matches!(
2268 agent
2269 .respond(AgentRequest::RecordActionPrediction {
2270 task: first_run.clone(),
2271 prediction: first.clone(),
2272 })
2273 .await,
2274 AgentResponse::ActionPredictionRecorded
2275 ));
2276 agent.commit_task(&first_run).await.unwrap();
2277
2278 let uncommitted = CacheAgent::new(&cache, "test-version");
2279 let uncommitted_run = uncommitted.begin_task(&task).await.unwrap();
2280 let second_invocation = CacheDigest::blake3(b"second invocation");
2281 assert!(matches!(
2282 uncommitted
2283 .respond(AgentRequest::RecordActionPrediction {
2284 task: uncommitted_run,
2285 prediction: ActionPrediction {
2286 invocation: second_invocation.clone(),
2287 action: CacheDigest::blake3(b"second action"),
2288 adapter: "rustc".into(),
2289 payload: "{}".into(),
2290 },
2291 })
2292 .await,
2293 AgentResponse::ActionPredictionRecorded
2294 ));
2295
2296 let next_session = CacheAgent::new(&cache, "test-version");
2297 let next_run = next_session.begin_task(&task).await.unwrap();
2298 assert!(matches!(
2299 next_session
2300 .respond(AgentRequest::FindActionPrediction {
2301 task: next_run.clone(),
2302 invocation: first_invocation,
2303 })
2304 .await,
2305 AgentResponse::ActionPrediction {
2306 prediction: Some(prediction)
2307 } if prediction == first
2308 ));
2309 assert!(matches!(
2310 next_session
2311 .respond(AgentRequest::FindActionPrediction {
2312 task: next_run,
2313 invocation: second_invocation,
2314 })
2315 .await,
2316 AgentResponse::ActionPrediction { prediction: None }
2317 ));
2318
2319 let corrupt_task = "b".repeat(64);
2320 fs::create_dir_all(next_session.manifest_dir.as_path()).unwrap();
2321 fs::write(next_session.task_manifest_path(&corrupt_task), b"not json").unwrap();
2322 assert!(next_session.begin_task(&corrupt_task).await.is_err());
2323 let corrupt_run = "c".repeat(64);
2324 next_session.task_actions.lock().unwrap().insert(
2325 corrupt_run.clone(),
2326 TaskActionState {
2327 manifest: corrupt_task.clone(),
2328 ..TaskActionState::default()
2329 },
2330 );
2331 assert!(matches!(
2332 next_session
2333 .respond(AgentRequest::RecordActionPrediction {
2334 task: corrupt_run.clone(),
2335 prediction: first,
2336 })
2337 .await,
2338 AgentResponse::ActionPredictionRecorded
2339 ));
2340 assert!(next_session.commit_task(&corrupt_run).await.is_err());
2341 assert_eq!(
2342 fs::read(next_session.task_manifest_path(&corrupt_task)).unwrap(),
2343 b"not json"
2344 );
2345 }
2346
2347 #[tokio::test]
2348 async fn round_trips_task_actions_between_fresh_local_caches() {
2349 let directory = tempfile::tempdir().unwrap();
2350 let mut server = mockito::Server::new_async().await;
2351 let task = "e".repeat(64);
2352 let invocation = CacheDigest::blake3(b"remote invocation");
2353 let action_bytes = canonical_json(&serde_json::json!({"kind":"rustc"})).unwrap();
2354 let stdout_bytes = b"cached stdout".to_vec();
2355 let stderr_bytes = b"cached stderr".to_vec();
2356 let artifact_bytes = b"cached artifact".to_vec();
2357 let stdout = CacheDigest::blake3(&stdout_bytes);
2358 let stderr = CacheDigest::blake3(&stderr_bytes);
2359 let artifact = CacheDigest::blake3(&artifact_bytes);
2360 let metadata_bytes = canonical_json(&RustcMetadata {
2361 version: 1,
2362 kind: "rustc".into(),
2363 stdout: stdout.clone(),
2364 stderr: stderr.clone(),
2365 })
2366 .unwrap();
2367 let directory_bytes = canonical_json(&serde_json::json!({
2368 "directories":[],
2369 "files":[{"digest":artifact,"executable":false,"mode":420,"name":"artifact"}],
2370 "symlinks":[],
2371 "version":1
2372 }))
2373 .unwrap();
2374 let action = CacheDigest::blake3(&action_bytes);
2375 let metadata = CacheDigest::blake3(&metadata_bytes);
2376 let output_root = CacheDigest::blake3(&directory_bytes);
2377 let result = RemoteActionResult {
2378 action: action.clone(),
2379 metadata: Some(metadata.clone()),
2380 output_root: Some(output_root.clone()),
2381 version: 1,
2382 };
2383 let prediction = ActionPrediction {
2384 invocation: invocation.clone(),
2385 action: action.clone(),
2386 adapter: "rustc".into(),
2387 payload: "{}".into(),
2388 };
2389 let manifest_bytes = canonical_json(&TaskActionManifest {
2390 version: TASK_ACTION_MANIFEST_VERSION,
2391 task: task.clone(),
2392 predictions: vec![prediction.clone()],
2393 })
2394 .unwrap();
2395 let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
2396 let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
2397
2398 let mut mocks = Vec::new();
2399 for (digest, bytes) in [
2400 (&action, action_bytes.as_slice()),
2401 (&metadata, metadata_bytes.as_slice()),
2402 (&output_root, directory_bytes.as_slice()),
2403 (&stdout, stdout_bytes.as_slice()),
2404 (&stderr, stderr_bytes.as_slice()),
2405 (&artifact, artifact_bytes.as_slice()),
2406 ] {
2407 mocks.push(
2408 server
2409 .mock("PUT", blob_path(digest).as_str())
2410 .match_header("mbx-cache-namespace", "test")
2411 .match_body(bytes.to_vec())
2412 .with_status(200)
2413 .expect(1)
2414 .create_async()
2415 .await,
2416 );
2417 }
2418 mocks.push(
2419 server
2420 .mock("PUT", action_path(&result.action).as_str())
2421 .match_header("mbx-cache-namespace", "test")
2422 .with_status(200)
2423 .expect(1)
2424 .create_async()
2425 .await,
2426 );
2427 mocks.push(
2428 server
2429 .mock("PUT", action_manifest_path(&selector).as_str())
2430 .match_header("mbx-cache-namespace", "test")
2431 .match_header("if-none-match", "*")
2432 .match_body(manifest_bytes.clone())
2433 .with_status(201)
2434 .expect(1)
2435 .create_async()
2436 .await,
2437 );
2438 mocks.push(
2439 server
2440 .mock("GET", action_manifest_path(&selector).as_str())
2441 .with_status(200)
2442 .with_header("etag", &format!("\"{manifest_etag}\""))
2443 .with_body(manifest_bytes.clone())
2444 .expect(1)
2445 .create_async()
2446 .await,
2447 );
2448 mocks.push(
2449 server
2450 .mock("GET", action_path(&action).as_str())
2451 .with_status(200)
2452 .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2453 .with_body(serde_json::to_vec(&result).unwrap())
2454 .expect(1)
2455 .create_async()
2456 .await,
2457 );
2458 for (digest, bytes) in [
2459 (&action, action_bytes.as_slice()),
2460 (&metadata, metadata_bytes.as_slice()),
2461 (&output_root, directory_bytes.as_slice()),
2462 (&stdout, stdout_bytes.as_slice()),
2463 (&stderr, stderr_bytes.as_slice()),
2464 (&artifact, artifact_bytes.as_slice()),
2465 ] {
2466 mocks.push(
2467 server
2468 .mock("GET", blob_path(digest).as_str())
2469 .with_status(200)
2470 .with_body(bytes)
2471 .expect(1)
2472 .create_async()
2473 .await,
2474 );
2475 }
2476
2477 let writer = remote_agent(
2478 &server,
2479 directory.path().join("writer"),
2480 RemoteCacheMode::WriteOnly,
2481 );
2482 for (index, (digest, bytes)) in [
2483 (&action, action_bytes.as_slice()),
2484 (&metadata, metadata_bytes.as_slice()),
2485 (&output_root, directory_bytes.as_slice()),
2486 (&stdout, stdout_bytes.as_slice()),
2487 (&stderr, stderr_bytes.as_slice()),
2488 (&artifact, artifact_bytes.as_slice()),
2489 ]
2490 .into_iter()
2491 .enumerate()
2492 {
2493 let source = directory.path().join(format!("source-{index}"));
2494 fs::write(&source, bytes).unwrap();
2495 assert!(matches!(
2496 writer
2497 .respond(AgentRequest::StoreBlob {
2498 digest: digest.clone(),
2499 source,
2500 })
2501 .await,
2502 AgentResponse::Stored { .. }
2503 ));
2504 }
2505 assert!(matches!(
2506 writer
2507 .respond(AgentRequest::StoreActionResult {
2508 result: result.clone(),
2509 })
2510 .await,
2511 AgentResponse::ActionStored { .. }
2512 ));
2513 let run = writer.begin_task(&task).await.unwrap();
2514 assert!(matches!(
2515 writer
2516 .respond(AgentRequest::RecordActionPrediction {
2517 task: run.clone(),
2518 prediction: prediction.clone(),
2519 })
2520 .await,
2521 AgentResponse::ActionPredictionRecorded
2522 ));
2523 writer.commit_task(&run).await.unwrap();
2524
2525 let reader = remote_agent(
2526 &server,
2527 directory.path().join("reader"),
2528 RemoteCacheMode::ReadOnly,
2529 );
2530 let run = reader.begin_task(&task).await.unwrap();
2531 reader.wait_for_prefetches().await;
2532 assert!(matches!(
2533 reader
2534 .respond(AgentRequest::FindActionPrediction {
2535 task: run,
2536 invocation,
2537 })
2538 .await,
2539 AgentResponse::ActionPrediction {
2540 prediction: Some(found)
2541 } if found == prediction
2542 ));
2543 assert!(matches!(
2544 reader
2545 .respond(AgentRequest::FindActionResult {
2546 action: action.clone(),
2547 })
2548 .await,
2549 AgentResponse::ActionResult {
2550 result: Some(found)
2551 } if found == result
2552 ));
2553 for digest in [&action, &metadata, &output_root] {
2554 assert!(matches!(
2555 reader
2556 .respond(AgentRequest::FindBlob {
2557 digest: digest.clone(),
2558 })
2559 .await,
2560 AgentResponse::Blob { path: Some(_) }
2561 ));
2562 }
2563 assert!(matches!(
2564 reader
2565 .respond(AgentRequest::RecordActionHit {
2566 action,
2567 restore: RestoreStats::default(),
2568 })
2569 .await,
2570 AgentResponse::ActionHitRecorded
2571 ));
2572 for mock in mocks {
2573 mock.assert_async().await;
2574 }
2575 let stats = reader.stats();
2576 assert_eq!(stats.prefetch_runs, 1);
2577 assert_eq!(stats.prefetched_actions, 1);
2578 assert!(stats.remote_manifest_lookups > 0);
2579 assert!(stats.remote_action_lookups > 0);
2580 assert!(stats.remote_blob_requests > 0);
2581 assert!(stats.remote_manifest_lookup_duration_ns > 0);
2582 assert!(stats.remote_action_lookup_duration_ns > 0);
2583 assert!(stats.remote_blob_transfer_duration_ns > 0);
2584 assert!(stats.local_cas_write_duration_ns > 0);
2585 assert!(stats.prefetch_duration_ns > 0);
2586 }
2587
2588 #[tokio::test]
2589 async fn keeps_newer_local_predictions_when_remote_manifest_is_stale() {
2590 let directory = tempfile::tempdir().unwrap();
2591 let mut server = mockito::Server::new_async().await;
2592 let task = "f".repeat(64);
2593 let invocation = CacheDigest::blake3(b"shared invocation");
2594 let local_prediction = ActionPrediction {
2595 invocation: invocation.clone(),
2596 action: CacheDigest::blake3(b"new local action"),
2597 adapter: "rustc".into(),
2598 payload: "{}".into(),
2599 };
2600 let remote_prediction = ActionPrediction {
2601 invocation: invocation.clone(),
2602 action: CacheDigest::blake3(b"stale remote action"),
2603 adapter: "rustc".into(),
2604 payload: "{}".into(),
2605 };
2606 let remote_manifest = TaskActionManifest {
2607 version: TASK_ACTION_MANIFEST_VERSION,
2608 task: task.clone(),
2609 predictions: vec![remote_prediction],
2610 };
2611 let remote_bytes = canonical_json(&remote_manifest).unwrap();
2612 let remote_etag = blake3::hash(&remote_bytes).to_hex().to_string();
2613 let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
2614 let remote = server
2615 .mock("GET", action_manifest_path(&selector).as_str())
2616 .with_status(200)
2617 .with_header("etag", &format!("\"{remote_etag}\""))
2618 .with_body(remote_bytes)
2619 .expect(1)
2620 .create_async()
2621 .await;
2622
2623 let agent = remote_agent(
2624 &server,
2625 directory.path().join("reader"),
2626 RemoteCacheMode::ReadOnly,
2627 );
2628 agent
2629 .persist_task_manifest(&TaskActionManifest {
2630 version: TASK_ACTION_MANIFEST_VERSION,
2631 task: task.clone(),
2632 predictions: vec![local_prediction.clone()],
2633 })
2634 .unwrap();
2635
2636 let run = agent.begin_task(&task).await.unwrap();
2637 assert!(matches!(
2638 agent
2639 .respond(AgentRequest::FindActionPrediction {
2640 task: run,
2641 invocation,
2642 })
2643 .await,
2644 AgentResponse::ActionPrediction {
2645 prediction: Some(found)
2646 } if found == local_prediction
2647 ));
2648 let persisted = agent.load_task_manifest(&task).unwrap().unwrap();
2649 assert_eq!(persisted.predictions, vec![local_prediction]);
2650 remote.assert_async().await;
2651 }
2652
2653 #[tokio::test]
2654 async fn prefetch_does_not_block_task_initialization() {
2655 let directory = tempfile::tempdir().unwrap();
2656 let mut server = mockito::Server::new_async().await;
2657 let task = "9".repeat(64);
2658 let invocation = CacheDigest::blake3(b"prefetched invocation");
2659 let action_bytes = b"prefetched action";
2660 let action = CacheDigest::blake3(action_bytes);
2661 let result = RemoteActionResult {
2662 action: action.clone(),
2663 metadata: None,
2664 output_root: None,
2665 version: 1,
2666 };
2667 let manifest_bytes = canonical_json(&TaskActionManifest {
2668 version: TASK_ACTION_MANIFEST_VERSION,
2669 task: task.clone(),
2670 predictions: vec![ActionPrediction {
2671 invocation,
2672 action: action.clone(),
2673 adapter: "rustc".into(),
2674 payload: "{}".into(),
2675 }],
2676 })
2677 .unwrap();
2678 let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
2679 let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
2680 let manifest = server
2681 .mock("GET", action_manifest_path(&selector).as_str())
2682 .with_status(200)
2683 .with_header("etag", &format!("\"{manifest_etag}\""))
2684 .with_body(manifest_bytes)
2685 .expect(1)
2686 .create_async()
2687 .await;
2688 let release = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
2689 let response_release = release.clone();
2690 let result_bytes = serde_json::to_vec(&result).unwrap();
2691 let action_result = server
2692 .mock("GET", action_path(&action).as_str())
2693 .with_status(200)
2694 .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2695 .with_chunked_body(move |writer| {
2696 let (released, condition) = &*response_release;
2697 let mut released = released.lock().unwrap();
2698 while !*released {
2699 released = condition.wait(released).unwrap();
2700 }
2701 std::io::Write::write_all(writer, &result_bytes)
2702 })
2703 .expect(1)
2704 .create_async()
2705 .await;
2706 let action_blob = server
2707 .mock("GET", blob_path(&action).as_str())
2708 .with_status(200)
2709 .with_body(action_bytes)
2710 .expect(1)
2711 .create_async()
2712 .await;
2713 let agent = remote_agent(
2714 &server,
2715 directory.path().join("reader"),
2716 RemoteCacheMode::ReadOnly,
2717 );
2718
2719 let begin = tokio::time::timeout(Duration::from_secs(2), agent.begin_task(&task)).await;
2720 let (released, condition) = &*release;
2721 *released.lock().unwrap() = true;
2722 condition.notify_all();
2723 let run = begin
2724 .expect("task initialization waited for prefetch")
2725 .unwrap();
2726 assert_eq!(
2727 agent
2728 .task_actions
2729 .lock()
2730 .unwrap()
2731 .get(&run)
2732 .unwrap()
2733 .predictions
2734 .len(),
2735 1
2736 );
2737 agent.wait_for_prefetches().await;
2738 manifest.assert_async().await;
2739 action_result.assert_async().await;
2740 action_blob.assert_async().await;
2741 assert!(agent.actions.find(&action).unwrap().is_some());
2742 }
2743
2744 #[tokio::test]
2745 async fn prefetches_complete_actions_in_directory_wave_blob_packs() {
2746 let directory = tempfile::tempdir().unwrap();
2747 let mut server = mockito::Server::new_async().await;
2748 let action_bytes = b"packed action descriptor";
2749 let stdout_bytes = b"packed stdout";
2750 let stderr_bytes = b"packed stderr";
2751 let artifact_bytes = b"packed artifact";
2752 let action = CacheDigest::blake3(action_bytes);
2753 let stdout = CacheDigest::blake3(stdout_bytes);
2754 let stderr = CacheDigest::blake3(stderr_bytes);
2755 let artifact = CacheDigest::blake3(artifact_bytes);
2756 let metadata_bytes = canonical_json(&RustcMetadata {
2757 version: 1,
2758 kind: "rustc".into(),
2759 stdout: stdout.clone(),
2760 stderr: stderr.clone(),
2761 })
2762 .unwrap();
2763 let metadata = CacheDigest::blake3(&metadata_bytes);
2764 let directory_bytes = canonical_json(&serde_json::json!({
2765 "directories": [],
2766 "files": [{
2767 "digest": artifact,
2768 "executable": false,
2769 "mode": 420,
2770 "name": "artifact",
2771 }],
2772 "symlinks": [],
2773 "version": 1,
2774 }))
2775 .unwrap();
2776 let output_root = CacheDigest::blake3(&directory_bytes);
2777 let result = RemoteActionResult {
2778 action: action.clone(),
2779 metadata: Some(metadata.clone()),
2780 output_root: Some(output_root.clone()),
2781 version: 1,
2782 };
2783 let action_result = server
2784 .mock("GET", action_path(&action).as_str())
2785 .with_status(200)
2786 .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2787 .with_body(serde_json::to_vec(&result).unwrap())
2788 .expect(1)
2789 .create_async()
2790 .await;
2791 let capabilities = server
2792 .mock("GET", "/v1/capabilities")
2793 .with_status(200)
2794 .with_header("content-type", "application/json")
2795 .with_body(
2796 serde_json::json!({
2797 "protocol":{"major":1},
2798 "features":{"blob_packs":true},
2799 "limits":{"max_batch_items":100,"max_pack_bytes":1048576}
2800 })
2801 .to_string(),
2802 )
2803 .expect(1)
2804 .create_async()
2805 .await;
2806 let mut top = vec![
2807 (action.clone(), action_bytes.as_slice()),
2808 (metadata.clone(), metadata_bytes.as_slice()),
2809 (output_root.clone(), directory_bytes.as_slice()),
2810 ];
2811 top.sort_by(|left, right| left.0.cmp(&right.0));
2812 let first_pack = server
2813 .mock("POST", "/v1/blobs:pack")
2814 .match_body(mockito::Matcher::Json(serde_json::json!({
2815 "digests": top.iter().map(|(digest, _)| digest).collect::<Vec<_>>()
2816 })))
2817 .with_status(200)
2818 .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
2819 .with_body(blob_pack_body(&top))
2820 .expect(1)
2821 .create_async()
2822 .await;
2823 let mut leaves = vec![
2824 (stdout.clone(), stdout_bytes.as_slice()),
2825 (stderr.clone(), stderr_bytes.as_slice()),
2826 (artifact.clone(), artifact_bytes.as_slice()),
2827 ];
2828 leaves.sort_by(|left, right| left.0.cmp(&right.0));
2829 let second_pack = server
2830 .mock("POST", "/v1/blobs:pack")
2831 .match_body(mockito::Matcher::Json(serde_json::json!({
2832 "digests": leaves.iter().map(|(digest, _)| digest).collect::<Vec<_>>()
2833 })))
2834 .with_status(200)
2835 .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
2836 .with_body(blob_pack_body(&leaves))
2837 .expect(1)
2838 .create_async()
2839 .await;
2840 let agent = remote_agent(
2841 &server,
2842 directory.path().join("reader"),
2843 RemoteCacheMode::ReadOnly,
2844 );
2845
2846 agent
2847 .prefetch_action(action.clone(), "rustc".into())
2848 .await
2849 .unwrap();
2850
2851 assert_eq!(agent.actions.find(&action).unwrap(), Some(result));
2852 let stats = agent.stats();
2853 assert_eq!(stats.prefetched_actions, 1);
2854 assert_eq!(stats.remote_blob_requests, 0);
2855 assert_eq!(stats.remote_blob_pack_requests, 2);
2856 assert_eq!(stats.remote_blob_pack_blobs, 6);
2857 action_result.assert_async().await;
2858 capabilities.assert_async().await;
2859 first_pack.assert_async().await;
2860 second_pack.assert_async().await;
2861 }
2862
2863 #[tokio::test]
2864 async fn foreground_blob_batches_use_blob_packs() {
2865 let directory = tempfile::tempdir().unwrap();
2866 let mut server = mockito::Server::new_async().await;
2867 let mut entries = [
2868 (CacheDigest::blake3(b"first"), b"first".as_slice()),
2869 (CacheDigest::blake3(b"second"), b"second".as_slice()),
2870 ];
2871 entries.sort_by(|left, right| left.0.cmp(&right.0));
2872 let requested = entries
2873 .iter()
2874 .map(|(digest, _)| digest.clone())
2875 .collect::<Vec<_>>();
2876 let response_requested = vec![
2877 entries[0].0.clone(),
2878 entries[1].0.clone(),
2879 entries[0].0.clone(),
2880 ];
2881 let capabilities = server
2882 .mock("GET", "/v1/capabilities")
2883 .with_status(200)
2884 .with_header("content-type", "application/json")
2885 .with_body(
2886 serde_json::json!({
2887 "protocol":{"major":1},
2888 "features":{"blob_packs":true},
2889 "limits":{"max_batch_items":100,"max_pack_bytes":1048576}
2890 })
2891 .to_string(),
2892 )
2893 .expect(1)
2894 .create_async()
2895 .await;
2896 let pack = server
2897 .mock("POST", "/v1/blobs:pack")
2898 .match_body(mockito::Matcher::Json(serde_json::json!({
2899 "digests": requested.clone()
2900 })))
2901 .with_status(200)
2902 .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
2903 .with_body(blob_pack_body(&entries))
2904 .expect(1)
2905 .create_async()
2906 .await;
2907 let agent = remote_agent(
2908 &server,
2909 directory.path().join("reader"),
2910 RemoteCacheMode::ReadOnly,
2911 );
2912 let response = agent
2913 .respond(AgentRequest::FindBlobs {
2914 digests: response_requested,
2915 })
2916 .await;
2917
2918 let AgentResponse::Blobs { paths } = response else {
2919 panic!("unexpected blob lookup response");
2920 };
2921 assert_eq!(paths.len(), 3);
2922 for (expected, path) in [entries[0].1, entries[1].1, entries[0].1]
2923 .into_iter()
2924 .zip(paths)
2925 {
2926 assert_eq!(fs::read(path.unwrap()).unwrap(), expected);
2927 }
2928 let stats = agent.stats();
2929 assert_eq!(stats.remote_blob_requests, 0);
2930 assert_eq!(stats.remote_blob_pack_requests, 1);
2931 assert_eq!(stats.remote_blob_pack_blobs, 2);
2932 capabilities.assert_async().await;
2933 pack.assert_async().await;
2934 }
2935
2936 #[tokio::test]
2937 async fn preserves_successful_pack_metrics_when_a_later_chunk_falls_back() {
2938 let directory = tempfile::tempdir().unwrap();
2939 let mut server = mockito::Server::new_async().await;
2940 let mut entries = [
2941 (CacheDigest::blake3(b"first"), b"first".as_slice()),
2942 (CacheDigest::blake3(b"second"), b"second".as_slice()),
2943 ];
2944 entries.sort_by(|left, right| left.0.cmp(&right.0));
2945 let (first_digest, first_bytes) = entries[0].clone();
2946 let (second_digest, second_bytes) = entries[1].clone();
2947 let capabilities = server
2948 .mock("GET", "/v1/capabilities")
2949 .with_status(200)
2950 .with_header("content-type", "application/json")
2951 .with_body(
2952 serde_json::json!({
2953 "protocol":{"major":1},
2954 "features":{"blob_packs":true},
2955 "limits":{"max_batch_items":1,"max_pack_bytes":1048576}
2956 })
2957 .to_string(),
2958 )
2959 .expect(1)
2960 .create_async()
2961 .await;
2962 let first_pack = server
2963 .mock("POST", "/v1/blobs:pack")
2964 .match_body(mockito::Matcher::Json(serde_json::json!({
2965 "digests": [&first_digest]
2966 })))
2967 .with_status(200)
2968 .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
2969 .with_body(blob_pack_body(&[(first_digest.clone(), first_bytes)]))
2970 .expect(1)
2971 .create_async()
2972 .await;
2973 let failed_pack = server
2974 .mock("POST", "/v1/blobs:pack")
2975 .match_body(mockito::Matcher::Json(serde_json::json!({
2976 "digests": [&second_digest]
2977 })))
2978 .with_status(500)
2979 .expect(1)
2980 .create_async()
2981 .await;
2982 let fallback = server
2983 .mock("GET", blob_path(&second_digest).as_str())
2984 .with_status(200)
2985 .with_body(second_bytes)
2986 .expect(1)
2987 .create_async()
2988 .await;
2989 let agent = remote_agent(
2990 &server,
2991 directory.path().join("reader"),
2992 RemoteCacheMode::ReadOnly,
2993 );
2994 let remote = agent.remote.as_deref().unwrap();
2995
2996 let verified = agent
2997 .fetch_remote_blobs(
2998 remote,
2999 vec![first_digest.clone(), second_digest.clone()],
3000 Some(&agent.prefetch_transfers),
3001 )
3002 .await;
3003
3004 assert_eq!(verified.len(), 2);
3005 assert_eq!(fs::read(&verified[&first_digest]).unwrap(), first_bytes);
3006 assert_eq!(fs::read(&verified[&second_digest]).unwrap(), second_bytes);
3007 let stats = agent.stats();
3008 assert_eq!(stats.remote_blob_pack_requests, 1);
3009 assert_eq!(stats.remote_blob_pack_blobs, 1);
3010 assert_eq!(stats.remote_blob_requests, 1);
3011 capabilities.assert_async().await;
3012 first_pack.assert_async().await;
3013 failed_pack.assert_async().await;
3014 fallback.assert_async().await;
3015 }
3016
3017 #[tokio::test]
3018 async fn malformed_blob_pack_metadata_falls_back_to_individual_blobs() {
3019 let directory = tempfile::tempdir().unwrap();
3020 let mut server = mockito::Server::new_async().await;
3021 let bytes = b"fallback blob";
3022 let digest = CacheDigest::blake3(bytes);
3023 server
3024 .mock("GET", "/v1/capabilities")
3025 .with_status(200)
3026 .with_header("content-type", "application/json")
3027 .with_body(
3028 serde_json::json!({
3029 "protocol":{"major":1},
3030 "features":{"blob_packs":true},
3031 "limits":{"max_batch_items":100,"max_pack_bytes":1048576}
3032 })
3033 .to_string(),
3034 )
3035 .expect(1)
3036 .create_async()
3037 .await;
3038 let pack = server
3039 .mock("POST", "/v1/blobs:pack")
3040 .with_status(200)
3041 .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
3042 .with_header(crate::BLOB_PACK_BYTES_HEADER, "not-a-number")
3043 .with_body(blob_pack_body(&[(digest.clone(), bytes.as_slice())]))
3044 .expect(1)
3045 .create_async()
3046 .await;
3047 let fallback = server
3048 .mock("GET", blob_path(&digest).as_str())
3049 .with_status(200)
3050 .with_body(bytes)
3051 .expect(1)
3052 .create_async()
3053 .await;
3054 let agent = remote_agent(
3055 &server,
3056 directory.path().join("reader"),
3057 RemoteCacheMode::ReadOnly,
3058 );
3059 let remote = agent.remote.as_deref().unwrap();
3060
3061 let verified = agent
3062 .fetch_remote_blobs(remote, vec![digest.clone()], None)
3063 .await;
3064
3065 assert_eq!(fs::read(&verified[&digest]).unwrap(), bytes);
3066 let stats = agent.stats();
3067 assert_eq!(stats.remote_blob_pack_requests, 0);
3068 assert_eq!(stats.remote_blob_pack_blobs, 0);
3069 assert_eq!(stats.remote_blob_requests, 1);
3070 pack.assert_async().await;
3071 fallback.assert_async().await;
3072 }
3073
3074 #[tokio::test]
3075 async fn foreground_action_lookup_does_not_wait_for_prefetch_output() {
3076 let directory = tempfile::tempdir().unwrap();
3077 let mut server = mockito::Server::new_async().await;
3078 let action_bytes = b"prefetched action";
3079 let artifact_bytes = b"prefetched artifact";
3080 let action = CacheDigest::blake3(action_bytes);
3081 let artifact = CacheDigest::blake3(artifact_bytes);
3082 let directory_bytes = canonical_json(&serde_json::json!({
3083 "directories": [],
3084 "files": [{
3085 "digest": artifact,
3086 "executable": false,
3087 "mode": 420,
3088 "name": "artifact",
3089 }],
3090 "symlinks": [],
3091 "version": 1,
3092 }))
3093 .unwrap();
3094 let output_root = CacheDigest::blake3(&directory_bytes);
3095 let result = RemoteActionResult {
3096 action: action.clone(),
3097 metadata: None,
3098 output_root: Some(output_root.clone()),
3099 version: 1,
3100 };
3101 let action_result = server
3102 .mock("GET", action_path(&action).as_str())
3103 .with_status(200)
3104 .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
3105 .with_body(serde_json::to_vec(&result).unwrap())
3106 .expect(1)
3107 .create_async()
3108 .await;
3109 let action_blob = server
3110 .mock("GET", blob_path(&action).as_str())
3111 .with_status(200)
3112 .with_body(action_bytes)
3113 .expect(1)
3114 .create_async()
3115 .await;
3116 let output_directory = server
3117 .mock("GET", blob_path(&output_root).as_str())
3118 .with_status(200)
3119 .with_body(directory_bytes)
3120 .expect(1)
3121 .create_async()
3122 .await;
3123 let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
3124 let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
3125 let response_started = started.clone();
3126 let response_release = release.clone();
3127 let artifact_blob = server
3128 .mock("GET", blob_path(&artifact).as_str())
3129 .with_status(200)
3130 .with_chunked_body(move |writer| {
3131 response_started.store(true, Ordering::Release);
3132 while !response_release.load(Ordering::Acquire) {
3133 std::thread::sleep(Duration::from_millis(10));
3134 }
3135 std::io::Write::write_all(writer, artifact_bytes)
3136 })
3137 .expect(1)
3138 .create_async()
3139 .await;
3140 let agent = remote_agent(
3141 &server,
3142 directory.path().join("reader"),
3143 RemoteCacheMode::ReadOnly,
3144 );
3145 let prefetch_agent = agent.clone();
3146 let prefetch_action = action.clone();
3147 let prefetch = tokio::spawn(async move {
3148 prefetch_agent
3149 .prefetch_action(prefetch_action, "rustc".into())
3150 .await
3151 });
3152 tokio::time::timeout(Duration::from_secs(1), async {
3153 while !started.load(Ordering::Acquire) {
3154 tokio::time::sleep(Duration::from_millis(10)).await;
3155 }
3156 })
3157 .await
3158 .expect("prefetch did not request the output blob");
3159
3160 let foreground = tokio::time::timeout(
3161 Duration::from_millis(250),
3162 agent.find_action_result(&action),
3163 )
3164 .await;
3165 release.store(true, Ordering::Release);
3166 prefetch.await.unwrap().unwrap();
3167 let foreground = foreground.expect("foreground action lookup waited for output prefetch");
3168
3169 assert!(matches!(
3170 foreground.unwrap(),
3171 AgentResponse::ActionResult {
3172 result: Some(found)
3173 } if found == result
3174 ));
3175 action_result.assert_async().await;
3176 action_blob.assert_async().await;
3177 output_directory.assert_async().await;
3178 artifact_blob.assert_async().await;
3179 }
3180
3181 #[tokio::test]
3182 async fn session_completion_cancels_outstanding_prefetches() {
3183 let directory = tempfile::tempdir().unwrap();
3184 let agent = CacheAgent::new(directory.path(), "test-version");
3185 let task = tokio::spawn(std::future::pending::<()>());
3186 agent.prefetch_tasks.lock().unwrap().push(task);
3187
3188 tokio::time::timeout(Duration::from_secs(1), agent.cancel_prefetches())
3189 .await
3190 .expect("prefetch cancellation blocked session completion");
3191 assert!(agent.prefetch_tasks.lock().unwrap().is_empty());
3192 }
3193
3194 #[tokio::test]
3195 async fn prefetch_reserves_capacity_for_foreground_transfers() {
3196 let transfers = tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS);
3197 let _prefetch = transfers
3198 .acquire_many(MAX_PREFETCH_TRANSFERS as u32)
3199 .await
3200 .unwrap();
3201 assert!(transfers.available_permits() > 0);
3202 }
3203
3204 #[tokio::test]
3205 async fn prefetches_output_files_concurrently() {
3206 let directory = tempfile::tempdir().unwrap();
3207 let (responses, output_root) = output_tree_responses(8);
3208 let (base_url, maximum_in_flight, server) =
3209 delayed_blob_server(responses, Duration::from_millis(50)).await;
3210 let agent = remote_agent_url(
3211 base_url,
3212 directory.path().join("reader"),
3213 RemoteCacheMode::ReadOnly,
3214 );
3215
3216 agent
3217 .prefetch_output_tree(agent.remote.as_deref().unwrap(), &output_root)
3218 .await
3219 .unwrap();
3220 server.await.unwrap();
3221
3222 assert!(maximum_in_flight.load(Ordering::Relaxed) > 1);
3223 }
3224
3225 #[tokio::test]
3226 #[ignore = "local remote-cache throughput benchmark"]
3227 async fn benchmark_prefetch_output_tree_latency() {
3228 let files = std::env::var("MBX_BENCH_FILES")
3229 .ok()
3230 .and_then(|value| value.parse().ok())
3231 .unwrap_or(96);
3232 let latency = Duration::from_millis(
3233 std::env::var("MBX_BENCH_LATENCY_MS")
3234 .ok()
3235 .and_then(|value| value.parse().ok())
3236 .unwrap_or(100),
3237 );
3238
3239 let directory = tempfile::tempdir().unwrap();
3240 let (responses, output_root) = output_tree_responses(files);
3241 let (base_url, maximum_in_flight, server) = delayed_blob_server(responses, latency).await;
3242 let agent = remote_agent_url(
3243 base_url,
3244 directory.path().join("reader"),
3245 RemoteCacheMode::ReadOnly,
3246 );
3247 let remote = agent.remote.as_deref().unwrap();
3248
3249 let started = std::time::Instant::now();
3250 agent
3251 .prefetch_output_tree(remote, &output_root)
3252 .await
3253 .unwrap();
3254 let elapsed = started.elapsed();
3255
3256 eprintln!(
3257 "prefetched {files} blobs with {} ms latency in {elapsed:?}",
3258 latency.as_millis()
3259 );
3260 server.await.unwrap();
3261 eprintln!(
3262 "maximum concurrent requests: {}",
3263 maximum_in_flight.load(Ordering::Relaxed)
3264 );
3265 }
3266
3267 fn output_tree_responses(files: usize) -> (BTreeMap<String, Vec<u8>>, CacheDigest) {
3268 let mut entries = Vec::with_capacity(files);
3269 let mut responses = BTreeMap::new();
3270 for index in 0..files {
3271 let body = format!("cached artifact {index}").into_bytes();
3272 let digest = CacheDigest::blake3(&body);
3273 entries.push(serde_json::json!({
3274 "digest": digest,
3275 "executable": false,
3276 "mode": 420,
3277 "name": format!("artifact-{index}"),
3278 }));
3279 responses.insert(blob_path(&digest), body);
3280 }
3281 let directory = canonical_json(&serde_json::json!({
3282 "directories": [],
3283 "files": entries,
3284 "symlinks": [],
3285 "version": 1,
3286 }))
3287 .unwrap();
3288 let output_root = CacheDigest::blake3(&directory);
3289 responses.insert(blob_path(&output_root), directory);
3290 (responses, output_root)
3291 }
3292
3293 fn blob_pack_body(entries: &[(CacheDigest, &[u8])]) -> Vec<u8> {
3294 let mut pack = crate::BLOB_PACK_MAGIC.to_vec();
3295 for (digest, bytes) in entries {
3296 pack.push(match digest.algorithm.as_str() {
3297 "blake3" => 1,
3298 "sha256" => 2,
3299 algorithm => panic!("unexpected test digest algorithm {algorithm}"),
3300 });
3301 pack.extend(hex::decode(&digest.hash).unwrap());
3302 pack.extend(digest.size.to_be_bytes());
3303 pack.extend_from_slice(bytes);
3304 }
3305 pack
3306 }
3307
3308 async fn delayed_blob_server(
3309 responses: BTreeMap<String, Vec<u8>>,
3310 latency: Duration,
3311 ) -> (
3312 url::Url,
3313 Arc<std::sync::atomic::AtomicUsize>,
3314 tokio::task::JoinHandle<()>,
3315 ) {
3316 use std::sync::atomic::AtomicUsize;
3317 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3318
3319 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3320 let address = listener.local_addr().unwrap();
3321 let responses = Arc::new(responses);
3322 let request_count = responses.len();
3323 let in_flight = Arc::new(AtomicUsize::new(0));
3324 let maximum_in_flight = Arc::new(AtomicUsize::new(0));
3325 let observed_maximum = maximum_in_flight.clone();
3326 let server = tokio::spawn(async move {
3327 let mut requests = tokio::task::JoinSet::new();
3328 for _ in 0..request_count {
3329 let (mut socket, _) = listener.accept().await.unwrap();
3330 let responses = responses.clone();
3331 let in_flight = in_flight.clone();
3332 let maximum_in_flight = maximum_in_flight.clone();
3333 requests.spawn(async move {
3334 let mut request = Vec::new();
3335 loop {
3336 let mut chunk = [0; 1024];
3337 let size = socket.read(&mut chunk).await.unwrap();
3338 assert!(size > 0, "client closed before sending request headers");
3339 request.extend_from_slice(&chunk[..size]);
3340 if request.windows(4).any(|window| window == b"\r\n\r\n") {
3341 break;
3342 }
3343 }
3344 let request = String::from_utf8_lossy(&request);
3345 let path = request
3346 .lines()
3347 .next()
3348 .and_then(|line| line.split_whitespace().nth(1))
3349 .unwrap();
3350 let body = responses.get(path).unwrap();
3351 let active = in_flight.fetch_add(1, Ordering::Relaxed) + 1;
3352 maximum_in_flight.fetch_max(active, Ordering::Relaxed);
3353 tokio::time::sleep(latency).await;
3354 socket
3355 .write_all(
3356 format!(
3357 "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
3358 body.len()
3359 )
3360 .as_bytes(),
3361 )
3362 .await
3363 .unwrap();
3364 socket.write_all(body).await.unwrap();
3365 in_flight.fetch_sub(1, Ordering::Relaxed);
3366 });
3367 }
3368 while requests.join_next().await.is_some() {}
3369 });
3370 (
3371 format!("http://{address}").parse().unwrap(),
3372 observed_maximum,
3373 server,
3374 )
3375 }
3376
3377 fn remote_agent(
3378 server: &mockito::ServerGuard,
3379 cache_dir: PathBuf,
3380 mode: RemoteCacheMode,
3381 ) -> CacheAgent {
3382 remote_agent_url(server.url().parse().unwrap(), cache_dir, mode)
3383 }
3384
3385 fn remote_agent_url(
3386 base_url: url::Url,
3387 cache_dir: PathBuf,
3388 mode: RemoteCacheMode,
3389 ) -> CacheAgent {
3390 let client = RemoteCacheClient::new(crate::RemoteCacheConfig {
3391 base_url,
3392 namespace: "test".into(),
3393 token: None,
3394 token_file: None,
3395 oidc_audience: None,
3396 connect_timeout: Duration::from_secs(1),
3397 read_timeout: Duration::from_secs(1),
3398 download_timeout: Duration::from_secs(1),
3399 retries: 0,
3400 })
3401 .unwrap();
3402 CacheAgent::new_remote(
3403 &cache_dir,
3404 "test-version",
3405 AgentRemoteCache {
3406 client,
3407 mode,
3408 staging_dir: cache_dir.join("remote"),
3409 },
3410 )
3411 }
3412
3413 fn blob_path(digest: &CacheDigest) -> String {
3414 format!(
3415 "/v1/blobs/{}/{}/{}",
3416 digest.algorithm, digest.hash, digest.size
3417 )
3418 }
3419
3420 fn action_path(digest: &CacheDigest) -> String {
3421 format!(
3422 "/v1/action-results/{}/{}/{}",
3423 digest.algorithm, digest.hash, digest.size
3424 )
3425 }
3426
3427 fn action_manifest_path(digest: &CacheDigest) -> String {
3428 format!(
3429 "/v1/action-manifests/{}/{}/{}",
3430 digest.algorithm, digest.hash, digest.size
3431 )
3432 }
3433
3434 #[tokio::test]
3435 async fn merges_overlapping_runs_into_one_task_manifest() {
3436 let directory = tempfile::tempdir().unwrap();
3437 let cache = directory.path().join("cache");
3438 let task = "d".repeat(64);
3439 let agent = CacheAgent::new(&cache, "test-version");
3440 let first_run = agent.begin_task(&task).await.unwrap();
3441 let second_run = agent.begin_task(&task).await.unwrap();
3442 assert_ne!(first_run, second_run);
3443 let first_invocation = CacheDigest::blake3(b"overlap one");
3444 let second_invocation = CacheDigest::blake3(b"overlap two");
3445 for (run, invocation) in [
3446 (&first_run, &first_invocation),
3447 (&second_run, &second_invocation),
3448 ] {
3449 assert!(matches!(
3450 agent
3451 .respond(AgentRequest::RecordActionPrediction {
3452 task: run.clone(),
3453 prediction: ActionPrediction {
3454 invocation: invocation.clone(),
3455 action: CacheDigest::blake3(invocation.hash.as_bytes()),
3456 adapter: "rustc".into(),
3457 payload: "{}".into(),
3458 },
3459 })
3460 .await,
3461 AgentResponse::ActionPredictionRecorded
3462 ));
3463 }
3464 agent.commit_task(&first_run).await.unwrap();
3465 agent.commit_task(&second_run).await.unwrap();
3466
3467 let next = CacheAgent::new(cache, "test-version");
3468 let run = next.begin_task(&task).await.unwrap();
3469 for invocation in [first_invocation, second_invocation] {
3470 assert!(matches!(
3471 next.respond(AgentRequest::FindActionPrediction {
3472 task: run.clone(),
3473 invocation,
3474 })
3475 .await,
3476 AgentResponse::ActionPrediction {
3477 prediction: Some(_)
3478 }
3479 ));
3480 }
3481 }
3482
3483 #[test]
3484 fn keeps_local_manifest_when_remote_merge_exceeds_prediction_limit() {
3485 let task = "7".repeat(64);
3486 let prediction = |index: usize| {
3487 let digest = CacheDigest::blake3(&index.to_le_bytes());
3488 ActionPrediction {
3489 invocation: digest.clone(),
3490 action: digest,
3491 adapter: "rustc".into(),
3492 payload: "{}".into(),
3493 }
3494 };
3495 let local = TaskActionManifest {
3496 version: TASK_ACTION_MANIFEST_VERSION,
3497 task: task.clone(),
3498 predictions: (0..MAX_TASK_ACTION_PREDICTIONS).map(prediction).collect(),
3499 };
3500 let expected_first = local.predictions[0].clone();
3501 let remote = TaskActionManifest {
3502 version: TASK_ACTION_MANIFEST_VERSION,
3503 task: task.clone(),
3504 predictions: vec![prediction(MAX_TASK_ACTION_PREDICTIONS)],
3505 };
3506
3507 let (manifest, merged) = merge_remote_task_manifest(&task, remote, local);
3508 assert!(!merged);
3509 assert_eq!(manifest.predictions.len(), MAX_TASK_ACTION_PREDICTIONS);
3510 assert_eq!(manifest.predictions[0], expected_first);
3511 }
3512
3513 #[test]
3514 fn task_manifest_lock_is_shared_across_agents() {
3515 let directory = tempfile::tempdir().unwrap();
3516 let cache = directory.path().join("cache");
3517 let first = CacheAgent::new(&cache, "test-version");
3518 let second = CacheAgent::new(&cache, "test-version");
3519 let task = "8".repeat(64);
3520
3521 let first_lock = first.lock_task_manifest(&task).unwrap();
3522 let mut contender = fslock::LockFile::open(&second.task_manifest_lock_path(&task)).unwrap();
3523 assert!(!contender.try_lock().unwrap());
3524 drop(first_lock);
3525 assert!(contender.try_lock().unwrap());
3526 }
3527
3528 #[tokio::test]
3529 async fn memoizes_client_observed_executable_identities() {
3530 let directory = tempfile::tempdir().unwrap();
3531 let agent = CacheAgent::new(directory.path(), "test-version");
3532 let executable = directory.path().join("rustc");
3533 let environment = BTreeMap::from([("RUSTUP_TOOLCHAIN".into(), Some("stable".into()))]);
3534
3535 let response = agent
3536 .respond(AgentRequest::FindExecutableIdentity {
3537 executable: executable.clone(),
3538 environment: environment.clone(),
3539 })
3540 .await;
3541 assert!(matches!(
3542 response,
3543 AgentResponse::ExecutableIdentity { stdout: None }
3544 ));
3545
3546 let response = agent
3547 .respond(AgentRequest::StoreExecutableIdentity {
3548 executable: executable.clone(),
3549 environment: environment.clone(),
3550 stdout: b"rustc identity".to_vec(),
3551 })
3552 .await;
3553 assert!(matches!(
3554 response,
3555 AgentResponse::ExecutableIdentity {
3556 stdout: Some(stdout)
3557 } if stdout == b"rustc identity"
3558 ));
3559
3560 let response = agent
3561 .respond(AgentRequest::FindExecutableIdentity {
3562 executable,
3563 environment,
3564 })
3565 .await;
3566 assert!(matches!(
3567 response,
3568 AgentResponse::ExecutableIdentity {
3569 stdout: Some(stdout)
3570 } if stdout == b"rustc identity"
3571 ));
3572 }
3573
3574 #[test]
3575 fn bounds_executable_identity_entry_count() {
3576 let directory = tempfile::tempdir().unwrap();
3577 let agent = CacheAgent::new(directory.path(), "test-version");
3578 for index in 0..MAX_EXECUTABLE_IDENTITIES {
3579 agent
3580 .store_executable_identity(
3581 directory.path().join(format!("rustc-{index}")),
3582 BTreeMap::new(),
3583 vec![b'x'],
3584 )
3585 .unwrap();
3586 }
3587
3588 assert!(
3589 agent
3590 .store_executable_identity(
3591 directory.path().join("one-too-many"),
3592 BTreeMap::new(),
3593 vec![b'x'],
3594 )
3595 .is_err()
3596 );
3597 }
3598
3599 #[test]
3600 fn bounds_executable_identity_retained_bytes() {
3601 let directory = tempfile::tempdir().unwrap();
3602 let agent = CacheAgent::new(directory.path(), "test-version");
3603 for index in 0..MAX_EXECUTABLE_IDENTITY_BYTES / MAX_EXECUTABLE_IDENTITY_SIZE {
3604 agent
3605 .store_executable_identity(
3606 directory.path().join(format!("rustc-{index}")),
3607 BTreeMap::new(),
3608 vec![b'x'; MAX_EXECUTABLE_IDENTITY_SIZE],
3609 )
3610 .unwrap();
3611 }
3612
3613 assert!(
3614 agent
3615 .store_executable_identity(
3616 directory.path().join("one-byte-too-many"),
3617 BTreeMap::new(),
3618 vec![b'x'],
3619 )
3620 .is_err()
3621 );
3622 }
3623
3624 #[tokio::test]
3625 async fn version_skew_is_a_handshake_miss() {
3626 let directory = tempfile::tempdir().unwrap();
3627 let agent = CacheAgent::new(directory.path(), "agent-version");
3628 let (mut client, server) = tokio::io::duplex(1024);
3629 let task = tokio::spawn(async move { agent.handle_connection(server).await });
3630 let request = AgentRequest::Hello {
3631 protocol: AGENT_PROTOCOL_VERSION,
3632 client_version: "other-version".into(),
3633 };
3634 let mut encoded = serde_json::to_vec(&request).unwrap();
3635 encoded.push(b'\n');
3636 client.write_all(&encoded).await.unwrap();
3637 let mut response = String::new();
3638 BufReader::new(&mut client)
3639 .read_line(&mut response)
3640 .await
3641 .unwrap();
3642
3643 assert!(matches!(
3644 serde_json::from_str(&response).unwrap(),
3645 AgentResponse::Error { .. }
3646 ));
3647 task.await.unwrap().unwrap();
3648 }
3649}