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