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::{StreamExt, 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 tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
16
17const MAX_EXECUTABLE_IDENTITIES: usize = 64;
18const MAX_EXECUTABLE_IDENTITY_SIZE: usize = 64 * 1024;
19const MAX_EXECUTABLE_IDENTITY_BYTES: usize = 256 * 1024;
20const TASK_ACTION_MANIFEST_VERSION: u8 = 1;
21const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
22const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
23const MAX_REMOTE_TRANSFERS: usize = 64;
24const MAX_PREFETCH_TRANSFERS: usize = 48;
25const MAX_PREFETCH_DIRECTORY_OBJECTS: usize = 100_000;
26
27pub struct AgentRemoteCache {
29 pub client: RemoteCacheClient,
30 pub mode: RemoteCacheMode,
31 pub staging_dir: PathBuf,
32}
33
34pub const AGENT_PROTOCOL_VERSION: u8 = 1;
36
37#[derive(Debug, Serialize, Deserialize)]
39#[serde(tag = "type", rename_all = "snake_case")]
40pub enum AgentRequest {
41 Hello {
42 protocol: u8,
43 client_version: String,
44 },
45 FindBlob {
47 digest: CacheDigest,
48 },
49 StoreBlob {
50 digest: CacheDigest,
51 source: PathBuf,
52 },
53 FindActionResult {
54 action: CacheDigest,
55 },
56 RecordActionHit {
57 action: CacheDigest,
58 },
59 RecordActionVerification {
60 matched: bool,
61 },
62 StoreActionResult {
63 result: RemoteActionResult,
64 },
65 FindActionPrediction {
66 task: String,
67 invocation: CacheDigest,
68 },
69 RecordActionPrediction {
70 task: String,
71 prediction: ActionPrediction,
72 },
73 FindExecutableIdentity {
74 executable: PathBuf,
75 environment: BTreeMap<String, Option<String>>,
76 },
77 StoreExecutableIdentity {
78 executable: PathBuf,
79 environment: BTreeMap<String, Option<String>>,
80 stdout: Vec<u8>,
81 },
82}
83
84#[derive(Debug, Serialize, Deserialize)]
86#[serde(tag = "type", rename_all = "snake_case")]
87pub enum AgentResponse {
88 Hello {
89 protocol: u8,
90 agent_version: String,
91 },
92 Blob {
94 path: Option<PathBuf>,
95 },
96 Stored {
97 path: PathBuf,
98 },
99 ActionResult {
100 result: Option<RemoteActionResult>,
101 },
102 ActionHitRecorded,
103 ActionVerificationRecorded,
104 ActionStored {
105 path: PathBuf,
106 },
107 ActionPrediction {
108 prediction: Option<ActionPrediction>,
109 },
110 ActionPredictionRecorded,
111 ExecutableIdentity {
112 stdout: Option<Vec<u8>>,
113 },
114 Error {
115 message: String,
116 },
117}
118
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
121pub struct AgentStats {
122 pub lookups: u64,
124 pub hits: u64,
126 pub stores: u64,
128 pub stored_bytes: u64,
130 pub verifications: u64,
132 pub divergences: u64,
134 pub downloaded_bytes: u64,
136 pub uploaded_bytes: u64,
138 pub prefetched_actions: u64,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct ActionPrediction {
147 pub invocation: CacheDigest,
148 pub action: CacheDigest,
149 pub adapter: String,
150 pub payload: String,
151}
152
153#[derive(Default)]
154struct AtomicAgentStats {
155 lookups: AtomicU64,
156 hits: AtomicU64,
157 stores: AtomicU64,
158 stored_bytes: AtomicU64,
159 verifications: AtomicU64,
160 divergences: AtomicU64,
161 downloaded_bytes: AtomicU64,
162 uploaded_bytes: AtomicU64,
163 prefetched_actions: AtomicU64,
164}
165
166#[derive(Clone)]
171pub struct CacheAgent {
172 cas: LocalCas,
173 actions: LocalActionCache,
174 verified_blobs: Arc<Mutex<BTreeMap<CacheDigest, PathBuf>>>,
175 version: Arc<str>,
176 write_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
177 action_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
178 stats: Arc<AtomicAgentStats>,
179 executable_identities: Arc<Mutex<BTreeMap<ExecutableIdentityKey, Vec<u8>>>>,
180 manifest_dir: Arc<PathBuf>,
181 task_actions: Arc<Mutex<BTreeMap<String, TaskActionState>>>,
182 next_task_run: Arc<AtomicU64>,
183 manifest_write_lock: Arc<Mutex<()>>,
184 remote: Option<Arc<RemoteCacheClient>>,
185 remote_mode: RemoteCacheMode,
186 remote_staging_dir: Arc<PathBuf>,
187 pending_remote_actions: Arc<Mutex<BTreeMap<CacheDigest, RemoteActionResult>>>,
188 remote_transfers: Arc<tokio::sync::Semaphore>,
189 prefetch_transfers: Arc<tokio::sync::Semaphore>,
190 prefetch_tasks: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195struct TaskActionManifest {
196 version: u8,
197 task: String,
198 predictions: Vec<ActionPrediction>,
199}
200
201#[derive(Serialize)]
202struct TaskActionManifestSelector<'a> {
203 version: u8,
204 kind: &'static str,
205 task: &'a str,
206}
207
208#[derive(Debug, Clone, Default)]
209struct TaskActionState {
210 manifest: String,
211 baseline_loaded: bool,
212 predictions: BTreeMap<CacheDigest, ActionPrediction>,
213 remote_etag: Option<String>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
217struct ExecutableIdentityKey {
218 executable: PathBuf,
219 environment: BTreeMap<String, Option<String>>,
220}
221
222impl CacheAgent {
223 pub fn new(cache_dir: impl Into<PathBuf>, version: impl Into<Arc<str>>) -> Self {
225 Self::build(cache_dir.into(), version.into(), None)
226 }
227
228 pub fn new_remote(
230 cache_dir: impl Into<PathBuf>,
231 version: impl Into<Arc<str>>,
232 remote: AgentRemoteCache,
233 ) -> Self {
234 Self::build(cache_dir.into(), version.into(), Some(remote))
235 }
236
237 fn build(cache_dir: PathBuf, version: Arc<str>, remote: Option<AgentRemoteCache>) -> Self {
238 let remote_mode = remote
239 .as_ref()
240 .map_or(RemoteCacheMode::ReadOnly, |remote| remote.mode);
241 let remote_staging_dir = remote.as_ref().map_or_else(
242 || cache_dir.join("remote"),
243 |remote| remote.staging_dir.clone(),
244 );
245 let remote = remote.map(|remote| Arc::new(remote.client));
246 Self {
247 cas: LocalCas::new(cache_dir.clone()),
248 actions: LocalActionCache::new(cache_dir.clone()),
249 verified_blobs: Arc::new(Mutex::new(BTreeMap::new())),
250 version,
251 write_locks: Arc::new(Mutex::new(BTreeMap::new())),
252 action_locks: Arc::new(Mutex::new(BTreeMap::new())),
253 stats: Arc::new(AtomicAgentStats::default()),
254 executable_identities: Arc::new(Mutex::new(BTreeMap::new())),
255 manifest_dir: Arc::new(cache_dir.join("task-manifests").join("v1")),
256 task_actions: Arc::new(Mutex::new(BTreeMap::new())),
257 next_task_run: Arc::new(AtomicU64::new(0)),
258 manifest_write_lock: Arc::new(Mutex::new(())),
259 remote,
260 remote_mode,
261 remote_staging_dir: Arc::new(remote_staging_dir),
262 pending_remote_actions: Arc::new(Mutex::new(BTreeMap::new())),
263 remote_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS)),
264 prefetch_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_PREFETCH_TRANSFERS)),
265 prefetch_tasks: Arc::new(Mutex::new(Vec::new())),
266 }
267 }
268
269 pub async fn begin_task(&self, task: &str) -> Result<String> {
271 validate_task_identity(task)?;
272 let (remote_manifest, mut remote_etag) = if self.remote_mode.reads() {
273 match self.get_remote_task_manifest(task).await {
274 Ok(Some((manifest, etag))) => (Some(manifest), Some(etag)),
275 Ok(None) => (None, None),
276 Err(error) => {
277 warn!("remote task action manifest lookup failed for {task}: {error}");
278 (None, None)
279 }
280 }
281 } else {
282 (None, None)
283 };
284 let manifest = {
285 let _write_guard = self.manifest_write_lock.lock().unwrap();
286 let _file_guard = self.lock_task_manifest(task)?;
287 let local_manifest = self.load_task_manifest(task)?;
288 let manifest = match (remote_manifest, local_manifest) {
289 (Some(remote), Some(local)) => {
290 let (manifest, merged) = merge_remote_task_manifest(task, remote, local);
291 if !merged {
292 remote_etag = None;
293 }
294 Some(manifest)
295 }
296 (Some(remote), None) => Some(remote),
297 (None, local) => local,
298 };
299 if let Some(manifest) = &manifest {
300 self.persist_task_manifest(manifest)?;
301 }
302 manifest
303 };
304 let state = if let Some(manifest) = manifest {
305 TaskActionState {
306 manifest: task.to_string(),
307 baseline_loaded: true,
308 predictions: manifest
309 .predictions
310 .into_iter()
311 .map(|prediction| (prediction.invocation.clone(), prediction))
312 .collect(),
313 remote_etag,
314 }
315 } else {
316 TaskActionState {
317 manifest: task.to_string(),
318 baseline_loaded: true,
319 remote_etag,
320 ..TaskActionState::default()
321 }
322 };
323 let sequence = self.next_task_run.fetch_add(1, Ordering::Relaxed);
324 let run =
325 CacheDigest::blake3(format!("{task}\0{}\0{sequence}", std::process::id()).as_bytes())
326 .hash;
327 let predictions = state.predictions.values().cloned().collect();
328 self.task_actions.lock().unwrap().insert(run.clone(), state);
329 self.spawn_prefetch_predictions(predictions);
330 Ok(run)
331 }
332
333 pub async fn cancel_prefetches(&self) {
335 let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
336 for task in &tasks {
337 task.abort();
338 }
339 for task in tasks {
340 if let Err(error) = task.await
341 && !error.is_cancelled()
342 {
343 warn!("remote action prefetch task failed: {error}");
344 }
345 }
346 }
347
348 #[cfg(test)]
349 async fn wait_for_prefetches(&self) {
350 let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
351 for task in tasks {
352 if let Err(error) = task.await {
353 warn!("remote action prefetch task failed: {error}");
354 }
355 }
356 }
357
358 pub async fn commit_task(&self, run: &str) -> Result<()> {
360 validate_task_identity(run)?;
361 let state = self
362 .task_actions
363 .lock()
364 .unwrap()
365 .get(run)
366 .cloned()
367 .ok_or_else(|| eyre::eyre!("task action manifest baseline was not loaded"))?;
368 if !state.baseline_loaded {
369 bail!("task action manifest baseline was not loaded");
370 }
371 let task = state.manifest;
372 validate_task_identity(&task)?;
373 let manifest = {
374 let _write_guard = self.manifest_write_lock.lock().unwrap();
375 let _file_guard = self.lock_task_manifest(&task)?;
376 let mut predictions = self
377 .load_task_manifest(&task)?
378 .map(|manifest| {
379 manifest
380 .predictions
381 .into_iter()
382 .map(|prediction| (prediction.invocation.clone(), prediction))
383 .collect::<BTreeMap<_, _>>()
384 })
385 .unwrap_or_default();
386 predictions.extend(state.predictions);
387 let manifest = TaskActionManifest {
388 version: TASK_ACTION_MANIFEST_VERSION,
389 task: task.clone(),
390 predictions: predictions.into_values().collect(),
391 };
392 validate_task_manifest(&manifest, &task)?;
393 self.persist_task_manifest(&manifest)?;
394 manifest
395 };
396 self.task_actions.lock().unwrap().remove(run);
397 if self.remote_mode.writes() {
398 match self
399 .put_remote_task_manifest(&task, manifest, state.remote_etag)
400 .await
401 {
402 Ok(remote_manifest) => {
403 let _write_guard = self.manifest_write_lock.lock().unwrap();
404 let reconciliation = (|| {
405 let _file_guard = self.lock_task_manifest(&task)?;
406 let manifest = match self.load_task_manifest(&task)? {
407 Some(local) => {
408 merge_remote_task_manifest(&task, remote_manifest, local).0
409 }
410 None => remote_manifest,
411 };
412 self.persist_task_manifest(&manifest)
413 })();
414 if let Err(error) = reconciliation {
415 warn!(
416 "remote task action manifest reconciliation failed for {task}: {error}"
417 );
418 }
419 }
420 Err(error) => {
421 warn!("remote task action manifest upload failed for {task}: {error}");
422 }
423 }
424 }
425 Ok(())
426 }
427
428 fn task_manifest_path(&self, task: &str) -> PathBuf {
429 self.manifest_dir.join(format!("{task}.json"))
430 }
431
432 fn task_manifest_lock_path(&self, task: &str) -> PathBuf {
433 self.manifest_dir.join("locks").join(format!("{task}.lock"))
434 }
435
436 fn lock_task_manifest(&self, task: &str) -> Result<fslock::LockFile> {
437 let path = self.task_manifest_lock_path(task);
438 fs::create_dir_all(path.parent().expect("task manifest lock has a parent"))?;
439 let mut lock = fslock::LockFile::open(&path)?;
440 lock.lock()?;
441 Ok(lock)
442 }
443
444 fn load_task_manifest(&self, task: &str) -> Result<Option<TaskActionManifest>> {
445 match fs::read(self.task_manifest_path(task)) {
446 Ok(contents) => Ok(Some(self.parse_task_manifest(task, &contents, false)?)),
447 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
448 Err(error) => Err(error.into()),
449 }
450 }
451
452 fn parse_task_manifest(
453 &self,
454 task: &str,
455 contents: &[u8],
456 require_canonical: bool,
457 ) -> Result<TaskActionManifest> {
458 let manifest: TaskActionManifest = serde_json::from_slice(contents)?;
459 validate_task_manifest(&manifest, task)?;
460 if require_canonical && canonical_json(&manifest)? != contents {
461 bail!("task action manifest is not canonical JSON");
462 }
463 Ok(manifest)
464 }
465
466 fn task_manifest_selector(task: &str) -> Result<(Vec<u8>, CacheDigest)> {
467 let bytes = canonical_json(&TaskActionManifestSelector {
468 version: 1,
469 kind: "task_action_manifest",
470 task,
471 })?;
472 let digest = CacheDigest::blake3(&bytes);
473 Ok((bytes, digest))
474 }
475
476 fn persist_task_manifest(&self, manifest: &TaskActionManifest) -> Result<()> {
477 let bytes = canonical_json(manifest)?;
478 fs::create_dir_all(self.manifest_dir.as_path())?;
479 let mut temporary = tempfile::NamedTempFile::new_in(self.manifest_dir.as_path())?;
480 std::io::Write::write_all(temporary.as_file_mut(), &bytes)?;
481 temporary.as_file_mut().sync_all()?;
482 temporary
483 .persist(self.task_manifest_path(&manifest.task))
484 .map_err(|error| error.error)?;
485 Ok(())
486 }
487
488 async fn get_remote_task_manifest(
489 &self,
490 task: &str,
491 ) -> Result<Option<(TaskActionManifest, String)>> {
492 let Some(remote) = &self.remote else {
493 return Ok(None);
494 };
495 let (_, selector) = Self::task_manifest_selector(task)?;
496 let _permit = self.remote_transfers.acquire().await?;
497 let Some(remote_manifest) = remote.get_action_manifest(&selector).await? else {
498 return Ok(None);
499 };
500 let manifest = self.parse_task_manifest(task, &remote_manifest.bytes, true)?;
501 Ok(Some((manifest, remote_manifest.etag)))
502 }
503
504 async fn put_remote_task_manifest(
505 &self,
506 task: &str,
507 mut manifest: TaskActionManifest,
508 mut expected_etag: Option<String>,
509 ) -> Result<TaskActionManifest> {
510 let Some(remote) = &self.remote else {
511 return Ok(manifest);
512 };
513 let (_, selector) = Self::task_manifest_selector(task)?;
514 for _ in 0..4 {
515 let bytes = canonical_json(&manifest)?;
516 let outcome = {
517 let _permit = self.remote_transfers.acquire().await?;
518 remote
519 .put_action_manifest(&selector, &bytes, expected_etag.as_deref())
520 .await?
521 };
522 match outcome {
523 ManifestPutOutcome::Stored => return Ok(manifest),
524 ManifestPutOutcome::PreconditionFailed => {
525 let Some((remote_manifest, etag)) = self.get_remote_task_manifest(task).await?
526 else {
527 expected_etag = None;
528 continue;
529 };
530 manifest = merge_task_manifests(task, Some(remote_manifest), manifest)?;
531 expected_etag = Some(etag);
532 }
533 }
534 }
535 bail!("remote task action manifest changed too frequently")
536 }
537
538 pub fn stats(&self) -> AgentStats {
540 AgentStats {
541 lookups: self.stats.lookups.load(Ordering::Relaxed),
542 hits: self.stats.hits.load(Ordering::Relaxed),
543 stores: self.stats.stores.load(Ordering::Relaxed),
544 stored_bytes: self.stats.stored_bytes.load(Ordering::Relaxed),
545 verifications: self.stats.verifications.load(Ordering::Relaxed),
546 divergences: self.stats.divergences.load(Ordering::Relaxed),
547 downloaded_bytes: self.stats.downloaded_bytes.load(Ordering::Relaxed),
548 uploaded_bytes: self.stats.uploaded_bytes.load(Ordering::Relaxed),
549 prefetched_actions: self.stats.prefetched_actions.load(Ordering::Relaxed),
550 }
551 }
552
553 fn write_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
554 Self::digest_lock(&self.write_locks, digest)
555 }
556
557 fn action_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
558 Self::digest_lock(&self.action_locks, digest)
559 }
560
561 fn digest_lock(
562 locks: &Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>,
563 digest: &CacheDigest,
564 ) -> Arc<tokio::sync::Mutex<()>> {
565 let mut locks = locks.lock().unwrap();
566 locks.retain(|_, lock| lock.strong_count() > 0);
567 if let Some(lock) = locks.get(digest).and_then(Weak::upgrade) {
568 return lock;
569 }
570 let lock = Arc::new(tokio::sync::Mutex::new(()));
571 locks.insert(digest.clone(), Arc::downgrade(&lock));
572 lock
573 }
574
575 fn spawn_prefetch_predictions(&self, predictions: Vec<ActionPrediction>) {
576 if predictions.is_empty() || !self.remote_mode.reads() || self.remote.is_none() {
577 return;
578 }
579 let agent = self.clone();
580 let task = tokio::spawn(async move {
581 agent.prefetch_predictions(predictions.iter()).await;
582 });
583 self.prefetch_tasks.lock().unwrap().push(task);
584 }
585
586 async fn prefetch_predictions<'a>(
587 &self,
588 predictions: impl Iterator<Item = &'a ActionPrediction>,
589 ) {
590 if !self.remote_mode.reads() || self.remote.is_none() {
591 return;
592 }
593 let mut actions = BTreeMap::new();
594 for prediction in predictions {
595 actions
596 .entry(prediction.action.clone())
597 .or_insert_with(|| prediction.adapter.clone());
598 }
599 let mut actions = actions.into_iter();
600 let mut tasks = tokio::task::JoinSet::new();
601 for _ in 0..MAX_PREFETCH_TRANSFERS {
602 let Some((action, adapter)) = actions.next() else {
603 break;
604 };
605 let agent = self.clone();
606 tasks.spawn(async move { agent.prefetch_action(action, adapter).await });
607 }
608 while let Some(result) = tasks.join_next().await {
609 match result {
610 Ok(Ok(())) => {}
611 Ok(Err(error)) => warn!("remote action prefetch failed: {error}"),
612 Err(error) => warn!("remote action prefetch task failed: {error}"),
613 }
614 if let Some((action, adapter)) = actions.next() {
615 let agent = self.clone();
616 tasks.spawn(async move { agent.prefetch_action(action, adapter).await });
617 }
618 }
619 }
620
621 async fn prefetch_action(&self, action: CacheDigest, adapter: String) -> Result<()> {
622 let remote = self
623 .remote
624 .as_ref()
625 .ok_or_else(|| eyre::eyre!("remote cache is not configured"))?;
626 let result = {
627 let lock = self.action_lock(&action);
628 let _guard = lock.lock().await;
629 if self.actions.find(&action)?.is_some() {
630 return Ok(());
631 }
632 if let Some(result) = self
633 .pending_remote_actions
634 .lock()
635 .unwrap()
636 .get(&action)
637 .cloned()
638 {
639 result
640 } else {
641 let _prefetch_permit = self.prefetch_transfers.acquire().await?;
642 let result = {
643 let _permit = self.remote_transfers.acquire().await?;
644 remote.get_action_result(&action).await?
645 };
646 let Some(result) = result else { return Ok(()) };
647 self.pending_remote_actions
648 .lock()
649 .unwrap()
650 .insert(action.clone(), result.clone());
651 result
652 }
653 };
654 self.fetch_remote_blob_prefetch(remote, &result.action)
655 .await?;
656 if let Some(metadata) = &result.metadata {
657 let path = self.fetch_remote_blob_prefetch(remote, metadata).await?;
658 if adapter == "rustc" {
659 let bytes = fs::read(path)?;
660 let metadata: RustcMetadata = serde_json::from_slice(&bytes)?;
661 if metadata.version != 1
662 || metadata.kind != "rustc"
663 || canonical_json(&metadata)? != bytes
664 {
665 bail!("remote rustc action metadata is invalid");
666 }
667 self.fetch_remote_blob_prefetch(remote, &metadata.stdout)
668 .await?;
669 self.fetch_remote_blob_prefetch(remote, &metadata.stderr)
670 .await?;
671 }
672 }
673 if let Some(output_root) = &result.output_root {
674 self.prefetch_output_tree(remote, output_root).await?;
675 }
676 self.actions.store(&result)?;
677 self.pending_remote_actions.lock().unwrap().remove(&action);
678 self.stats
679 .prefetched_actions
680 .fetch_add(1, Ordering::Relaxed);
681 Ok(())
682 }
683
684 async fn prefetch_output_tree(
685 &self,
686 remote: &RemoteCacheClient,
687 output_root: &CacheDigest,
688 ) -> Result<()> {
689 let mut pending = vec![output_root.clone()];
690 let mut seen = BTreeMap::new();
691 while let Some(digest) = pending.pop() {
692 if seen.insert(digest.clone(), ()).is_some() {
693 continue;
694 }
695 if seen.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
696 bail!("remote action output tree is too large");
697 }
698 let path = self.fetch_remote_blob_prefetch(remote, &digest).await?;
699 let bytes = fs::read(path)?;
700 let directory: CacheDirectory = serde_json::from_slice(&bytes)?;
701 if directory.version != 1 || canonical_json(&directory)? != bytes {
702 bail!("remote action output directory is invalid");
703 }
704 let mut transfers = stream::iter(directory.files.into_iter().map(|file| async move {
705 self.fetch_remote_blob_prefetch(remote, &file.digest)
706 .await
707 .map(|_| ())
708 }))
709 .buffer_unordered(MAX_PREFETCH_TRANSFERS);
710 while let Some(result) = transfers.next().await {
711 result?;
712 }
713 pending.extend(
714 directory
715 .directories
716 .into_iter()
717 .map(|directory| directory.digest),
718 );
719 }
720 Ok(())
721 }
722
723 async fn fetch_remote_blob_prefetch(
724 &self,
725 remote: &RemoteCacheClient,
726 digest: &CacheDigest,
727 ) -> Result<PathBuf> {
728 self.fetch_remote_blob_with_limit(remote, digest, Some(&self.prefetch_transfers))
729 .await
730 }
731
732 async fn fetch_remote_blob(
733 &self,
734 remote: &RemoteCacheClient,
735 digest: &CacheDigest,
736 ) -> Result<PathBuf> {
737 self.fetch_remote_blob_with_limit(remote, digest, None)
738 .await
739 }
740
741 async fn fetch_remote_blob_with_limit(
742 &self,
743 remote: &RemoteCacheClient,
744 digest: &CacheDigest,
745 prefetch_limit: Option<&tokio::sync::Semaphore>,
746 ) -> Result<PathBuf> {
747 let lock = self.write_lock(digest);
748 let _guard = lock.lock().await;
749 if let Some(path) = self.find_verified_blob(digest)? {
750 return Ok(path);
751 }
752 let _prefetch_permit = match prefetch_limit {
753 Some(limit) => Some(limit.acquire().await?),
754 None => None,
755 };
756 let _permit = self.remote_transfers.acquire().await?;
757 let temporary = remote
758 .get_blob_file(digest, self.remote_staging_dir.as_path())
759 .await?;
760 let path = self.cas.store_verified_file(digest, temporary.path())?;
761 self.remember_verified_blob(digest, &path);
762 self.stats.stores.fetch_add(1, Ordering::Relaxed);
763 self.stats
764 .stored_bytes
765 .fetch_add(digest.size, Ordering::Relaxed);
766 self.stats
767 .downloaded_bytes
768 .fetch_add(digest.size, Ordering::Relaxed);
769 Ok(path)
770 }
771
772 async fn respond(&self, request: AgentRequest) -> AgentResponse {
773 let result = match request {
774 AgentRequest::FindBlob { digest } => self.find_blob(&digest).await,
775 AgentRequest::StoreBlob { digest, source } => self.store_blob(&digest, &source).await,
776 AgentRequest::FindActionResult { action } => {
777 self.stats.lookups.fetch_add(1, Ordering::Relaxed);
778 self.find_action_result(&action).await
779 }
780 AgentRequest::RecordActionHit { action } => self.record_action_hit(&action),
781 AgentRequest::RecordActionVerification { matched } => {
782 self.stats.verifications.fetch_add(1, Ordering::Relaxed);
783 if !matched {
784 self.stats.divergences.fetch_add(1, Ordering::Relaxed);
785 }
786 Ok(AgentResponse::ActionVerificationRecorded)
787 }
788 AgentRequest::StoreActionResult { result } => self.store_action_result(&result).await,
789 AgentRequest::FindActionPrediction { task, invocation } => {
790 self.find_action_prediction(&task, &invocation)
791 }
792 AgentRequest::RecordActionPrediction { task, prediction } => {
793 self.record_action_prediction(&task, prediction)
794 }
795 AgentRequest::FindExecutableIdentity {
796 executable,
797 environment,
798 } => self.find_executable_identity(executable, environment),
799 AgentRequest::StoreExecutableIdentity {
800 executable,
801 environment,
802 stdout,
803 } => self.store_executable_identity(executable, environment, stdout),
804 AgentRequest::Hello { .. } => {
805 Err(eyre::eyre!("hello is only valid as the first request"))
806 }
807 };
808 result.unwrap_or_else(|error| AgentResponse::Error {
809 message: error.to_string(),
810 })
811 }
812
813 async fn find_blob(&self, digest: &CacheDigest) -> Result<AgentResponse> {
814 if let Some(path) = self.find_verified_blob(digest)? {
815 return Ok(AgentResponse::Blob { path: Some(path) });
816 }
817 if !self.remote_mode.reads() {
818 return Ok(AgentResponse::Blob { path: None });
819 }
820 let Some(remote) = &self.remote else {
821 return Ok(AgentResponse::Blob { path: None });
822 };
823 match self.fetch_remote_blob(remote, digest).await {
824 Ok(path) => Ok(AgentResponse::Blob { path: Some(path) }),
825 Err(error) => {
826 warn!(
827 "remote cache blob lookup failed for {}: {error}",
828 digest.hash
829 );
830 Ok(AgentResponse::Blob { path: None })
831 }
832 }
833 }
834
835 async fn store_blob(&self, digest: &CacheDigest, source: &Path) -> Result<AgentResponse> {
836 let remote = if self.remote_mode.writes() {
837 self.remote.as_deref()
838 } else {
839 None
840 };
841 let path = {
842 let lock = self.write_lock(digest);
843 let _guard = lock.lock().await;
844 if let Some(path) = self.find_verified_blob(digest)? {
845 path
846 } else {
847 let path = self.cas.store_file(digest, source)?;
848 self.remember_verified_blob(digest, &path);
849 self.stats.stores.fetch_add(1, Ordering::Relaxed);
850 self.stats
851 .stored_bytes
852 .fetch_add(digest.size, Ordering::Relaxed);
853 path
854 }
855 };
856 if let Some(remote) = remote {
857 let _permit = self.remote_transfers.acquire().await?;
858 if let Err(error) = remote
859 .put_blob(&BlobUpload {
860 digest: digest.clone(),
861 source: BlobSource::Path(path.clone()),
862 })
863 .await
864 {
865 warn!(
866 "remote cache blob upload failed for {}: {error}",
867 digest.hash
868 );
869 } else {
870 self.stats
871 .uploaded_bytes
872 .fetch_add(digest.size, Ordering::Relaxed);
873 }
874 }
875 Ok(AgentResponse::Stored { path })
876 }
877
878 fn find_verified_blob(&self, digest: &CacheDigest) -> Result<Option<PathBuf>> {
879 let remembered = self.verified_blobs.lock().unwrap().get(digest).cloned();
880 if let Some(path) = remembered {
881 if digest.matches_file(&path).unwrap_or(false) {
882 return Ok(Some(path));
883 }
884 self.verified_blobs.lock().unwrap().remove(digest);
885 }
886 let path = self.cas.find(digest)?;
887 if let Some(path) = &path {
888 self.remember_verified_blob(digest, path);
889 }
890 Ok(path)
891 }
892
893 fn remember_verified_blob(&self, digest: &CacheDigest, path: &Path) {
894 self.verified_blobs
895 .lock()
896 .unwrap()
897 .insert(digest.clone(), path.to_path_buf());
898 }
899
900 async fn find_action_result(&self, action: &CacheDigest) -> Result<AgentResponse> {
901 if let Some(result) = self.actions.find(action)? {
902 return Ok(AgentResponse::ActionResult {
903 result: Some(result),
904 });
905 }
906 if !self.remote_mode.reads() {
907 return Ok(AgentResponse::ActionResult { result: None });
908 }
909 let Some(remote) = &self.remote else {
910 return Ok(AgentResponse::ActionResult { result: None });
911 };
912 let lock = self.action_lock(action);
913 let _guard = lock.lock().await;
914 if let Some(result) = self.actions.find(action)? {
915 return Ok(AgentResponse::ActionResult {
916 result: Some(result),
917 });
918 }
919 if let Some(result) = self
920 .pending_remote_actions
921 .lock()
922 .unwrap()
923 .get(action)
924 .cloned()
925 {
926 return Ok(AgentResponse::ActionResult {
927 result: Some(result),
928 });
929 }
930 let _permit = self.remote_transfers.acquire().await?;
931 match remote.get_action_result(action).await {
932 Ok(Some(result)) => {
933 self.pending_remote_actions
934 .lock()
935 .unwrap()
936 .insert(action.clone(), result.clone());
937 Ok(AgentResponse::ActionResult {
938 result: Some(result),
939 })
940 }
941 Ok(None) => Ok(AgentResponse::ActionResult { result: None }),
942 Err(error) => {
943 warn!(
944 "remote cache action lookup failed for {}: {error}",
945 action.hash
946 );
947 Ok(AgentResponse::ActionResult { result: None })
948 }
949 }
950 }
951
952 async fn store_action_result(&self, result: &RemoteActionResult) -> Result<AgentResponse> {
953 let path = self.actions.store(result)?;
954 if self.remote_mode.writes()
955 && let Some(remote) = &self.remote
956 {
957 let _permit = self.remote_transfers.acquire().await?;
958 if let Err(error) = remote.put_action_result(result).await {
959 warn!(
960 "remote cache action upload failed for {}: {error}",
961 result.action.hash
962 );
963 }
964 }
965 Ok(AgentResponse::ActionStored { path })
966 }
967
968 fn record_action_hit(&self, action: &CacheDigest) -> Result<AgentResponse> {
969 if self.actions.find(action)?.is_none() {
970 let pending = self.pending_remote_actions.lock().unwrap().remove(action);
971 if let Some(result) = pending {
972 self.actions.store(&result)?;
973 } else {
974 bail!("cannot record a hit for a missing action result");
975 }
976 }
977 self.stats.hits.fetch_add(1, Ordering::Relaxed);
978 Ok(AgentResponse::ActionHitRecorded)
979 }
980
981 fn find_action_prediction(
982 &self,
983 task: &str,
984 invocation: &CacheDigest,
985 ) -> Result<AgentResponse> {
986 validate_task_identity(task)?;
987 invocation.validate()?;
988 let prediction = self
989 .task_actions
990 .lock()
991 .unwrap()
992 .get(task)
993 .and_then(|state| state.predictions.get(invocation))
994 .cloned();
995 Ok(AgentResponse::ActionPrediction { prediction })
996 }
997
998 fn record_action_prediction(
999 &self,
1000 task: &str,
1001 prediction: ActionPrediction,
1002 ) -> Result<AgentResponse> {
1003 validate_task_identity(task)?;
1004 validate_action_prediction(&prediction)?;
1005 let mut tasks = self.task_actions.lock().unwrap();
1006 let state = tasks.entry(task.to_string()).or_default();
1007 if !state.predictions.contains_key(&prediction.invocation)
1008 && state.predictions.len() >= MAX_TASK_ACTION_PREDICTIONS
1009 {
1010 bail!("task action manifest contains too many predictions");
1011 }
1012 state
1013 .predictions
1014 .insert(prediction.invocation.clone(), prediction);
1015 Ok(AgentResponse::ActionPredictionRecorded)
1016 }
1017
1018 fn executable_identity_key(
1019 &self,
1020 executable: PathBuf,
1021 environment: BTreeMap<String, Option<String>>,
1022 ) -> Result<ExecutableIdentityKey> {
1023 if !environment
1024 .keys()
1025 .all(|name| matches!(name.as_str(), "RUSTUP_HOME" | "RUSTUP_TOOLCHAIN"))
1026 {
1027 bail!("executable identity contains an unsupported environment variable");
1028 }
1029 Ok(ExecutableIdentityKey {
1030 executable,
1031 environment,
1032 })
1033 }
1034
1035 fn find_executable_identity(
1036 &self,
1037 executable: PathBuf,
1038 environment: BTreeMap<String, Option<String>>,
1039 ) -> Result<AgentResponse> {
1040 let key = self.executable_identity_key(executable, environment)?;
1041 let stdout = self
1042 .executable_identities
1043 .lock()
1044 .unwrap()
1045 .get(&key)
1046 .cloned();
1047 Ok(AgentResponse::ExecutableIdentity { stdout })
1048 }
1049
1050 fn store_executable_identity(
1051 &self,
1052 executable: PathBuf,
1053 environment: BTreeMap<String, Option<String>>,
1054 stdout: Vec<u8>,
1055 ) -> Result<AgentResponse> {
1056 if stdout.len() > MAX_EXECUTABLE_IDENTITY_SIZE {
1057 bail!("executable identity exceeds {MAX_EXECUTABLE_IDENTITY_SIZE} bytes");
1058 }
1059 let key = self.executable_identity_key(executable, environment)?;
1060 let mut identities = self.executable_identities.lock().unwrap();
1061 let is_new = !identities.contains_key(&key);
1062 let previous_size = identities.get(&key).map_or(0, Vec::len);
1063 if is_new && identities.len() >= MAX_EXECUTABLE_IDENTITIES {
1064 bail!("executable identity cache contains too many entries");
1065 }
1066 let retained_bytes = identities.values().map(Vec::len).sum::<usize>();
1067 if retained_bytes - previous_size + stdout.len() > MAX_EXECUTABLE_IDENTITY_BYTES {
1068 bail!("executable identity cache contains too many bytes");
1069 }
1070 identities.insert(key, stdout.clone());
1071 Ok(AgentResponse::ExecutableIdentity {
1072 stdout: Some(stdout),
1073 })
1074 }
1075
1076 pub async fn handle_connection<S>(&self, stream: S) -> Result<()>
1078 where
1079 S: AsyncRead + AsyncWrite + Unpin,
1080 {
1081 let (reader, mut writer) = tokio::io::split(stream);
1082 let mut lines = BufReader::new(reader).lines();
1083 let hello = lines
1084 .next_line()
1085 .await?
1086 .ok_or_else(|| eyre::eyre!("connection closed before the agent handshake"))?;
1087 let request: AgentRequest = serde_json::from_str(&hello)?;
1088 match request {
1089 AgentRequest::Hello {
1090 protocol,
1091 client_version,
1092 } if protocol == AGENT_PROTOCOL_VERSION && client_version == self.version.as_ref() => {}
1093 AgentRequest::Hello { protocol, .. } if protocol != AGENT_PROTOCOL_VERSION => {
1094 send_response(
1095 &mut writer,
1096 &AgentResponse::Error {
1097 message: format!(
1098 "unsupported agent protocol {protocol}; expected {AGENT_PROTOCOL_VERSION}"
1099 ),
1100 },
1101 )
1102 .await?;
1103 return Ok(());
1104 }
1105 AgentRequest::Hello { client_version, .. } => {
1106 send_response(
1107 &mut writer,
1108 &AgentResponse::Error {
1109 message: format!(
1110 "cache client {client_version} does not match agent {}",
1111 self.version
1112 ),
1113 },
1114 )
1115 .await?;
1116 return Ok(());
1117 }
1118 _ => bail!("the first agent request must be hello"),
1119 }
1120 send_response(
1121 &mut writer,
1122 &AgentResponse::Hello {
1123 protocol: AGENT_PROTOCOL_VERSION,
1124 agent_version: self.version.to_string(),
1125 },
1126 )
1127 .await?;
1128
1129 while let Some(line) = lines.next_line().await? {
1130 let response = match serde_json::from_str(&line) {
1131 Ok(request) => self.respond(request).await,
1132 Err(error) => AgentResponse::Error {
1133 message: format!("invalid agent request: {error}"),
1134 },
1135 };
1136 send_response(&mut writer, &response).await?;
1137 }
1138 Ok(())
1139 }
1140}
1141
1142fn validate_task_identity(task: &str) -> Result<()> {
1143 if task.len() != 64
1144 || !task
1145 .bytes()
1146 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1147 {
1148 bail!("invalid task action identity");
1149 }
1150 Ok(())
1151}
1152
1153fn validate_action_prediction(prediction: &ActionPrediction) -> Result<()> {
1154 prediction.invocation.validate()?;
1155 prediction.action.validate()?;
1156 if prediction.adapter.is_empty()
1157 || !prediction
1158 .adapter
1159 .bytes()
1160 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1161 {
1162 bail!("invalid action prediction adapter");
1163 }
1164 if prediction.payload.len() > MAX_ACTION_PREDICTION_PAYLOAD {
1165 bail!("action prediction payload is too large");
1166 }
1167 serde_json::from_str::<serde_json::Value>(&prediction.payload)?;
1168 Ok(())
1169}
1170
1171fn validate_task_manifest(manifest: &TaskActionManifest, task: &str) -> Result<()> {
1172 if manifest.version != TASK_ACTION_MANIFEST_VERSION || manifest.task != task {
1173 bail!("task action manifest has an invalid identity");
1174 }
1175 if manifest.predictions.len() > MAX_TASK_ACTION_PREDICTIONS {
1176 bail!("task action manifest contains too many predictions");
1177 }
1178 let mut invocations = BTreeMap::new();
1179 for prediction in &manifest.predictions {
1180 validate_action_prediction(prediction)?;
1181 if invocations.insert(&prediction.invocation, ()).is_some() {
1182 bail!("task action manifest contains duplicate predictions");
1183 }
1184 }
1185 Ok(())
1186}
1187
1188fn merge_task_manifests(
1189 task: &str,
1190 base: Option<TaskActionManifest>,
1191 update: TaskActionManifest,
1192) -> Result<TaskActionManifest> {
1193 validate_task_manifest(&update, task)?;
1194 let mut predictions = BTreeMap::new();
1195 if let Some(base) = base {
1196 validate_task_manifest(&base, task)?;
1197 predictions.extend(
1198 base.predictions
1199 .into_iter()
1200 .map(|prediction| (prediction.invocation.clone(), prediction)),
1201 );
1202 }
1203 predictions.extend(
1204 update
1205 .predictions
1206 .into_iter()
1207 .map(|prediction| (prediction.invocation.clone(), prediction)),
1208 );
1209 let manifest = TaskActionManifest {
1210 version: TASK_ACTION_MANIFEST_VERSION,
1211 task: task.to_owned(),
1212 predictions: predictions.into_values().collect(),
1213 };
1214 validate_task_manifest(&manifest, task)?;
1215 Ok(manifest)
1216}
1217
1218fn merge_remote_task_manifest(
1219 task: &str,
1220 remote: TaskActionManifest,
1221 local: TaskActionManifest,
1222) -> (TaskActionManifest, bool) {
1223 match merge_task_manifests(task, Some(remote), local.clone()) {
1224 Ok(manifest) => (manifest, true),
1225 Err(error) => {
1226 warn!("remote task action manifest merge failed for {task}: {error}");
1227 (local, false)
1228 }
1229 }
1230}
1231
1232async fn send_response(
1233 writer: &mut (impl AsyncWrite + Unpin),
1234 response: &AgentResponse,
1235) -> Result<()> {
1236 let mut encoded = serde_json::to_vec(response)?;
1237 encoded.push(b'\n');
1238 writer.write_all(&encoded).await?;
1239 writer.flush().await?;
1240 Ok(())
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245 use super::*;
1246 use crate::ACTION_RESULT_MEDIA_TYPE;
1247 use std::time::Duration;
1248
1249 async fn handshake(stream: &mut (impl AsyncRead + AsyncWrite + Unpin), version: &str) {
1250 let request = AgentRequest::Hello {
1251 protocol: AGENT_PROTOCOL_VERSION,
1252 client_version: version.to_string(),
1253 };
1254 let mut encoded = serde_json::to_vec(&request).unwrap();
1255 encoded.push(b'\n');
1256 stream.write_all(&encoded).await.unwrap();
1257 stream.flush().await.unwrap();
1258 let mut response = String::new();
1259 BufReader::new(stream)
1260 .read_line(&mut response)
1261 .await
1262 .unwrap();
1263 assert!(matches!(
1264 serde_json::from_str(&response).unwrap(),
1265 AgentResponse::Hello { .. }
1266 ));
1267 }
1268
1269 #[tokio::test]
1270 async fn handshake_and_blob_round_trip() {
1271 let directory = tempfile::tempdir().unwrap();
1272 let source = directory.path().join("source");
1273 std::fs::write(&source, b"cached object").unwrap();
1274 let digest = CacheDigest::blake3(b"cached object");
1275 let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
1276 let (mut client, server) = tokio::io::duplex(16 * 1024);
1277 let server_agent = agent.clone();
1278 let task = tokio::spawn(async move { server_agent.handle_connection(server).await });
1279
1280 handshake(&mut client, "test-version").await;
1281 let request = AgentRequest::StoreBlob {
1282 digest: digest.clone(),
1283 source,
1284 };
1285 let mut encoded = serde_json::to_vec(&request).unwrap();
1286 encoded.push(b'\n');
1287 client.write_all(&encoded).await.unwrap();
1288 let mut response = String::new();
1289 BufReader::new(&mut client)
1290 .read_line(&mut response)
1291 .await
1292 .unwrap();
1293 assert!(matches!(
1294 serde_json::from_str(&response).unwrap(),
1295 AgentResponse::Stored { .. }
1296 ));
1297 drop(client);
1298 task.await.unwrap().unwrap();
1299 assert_eq!(
1300 agent.stats(),
1301 AgentStats {
1302 stores: 1,
1303 stored_bytes: digest.size,
1304 ..AgentStats::default()
1305 }
1306 );
1307 }
1308
1309 #[test]
1310 fn remembered_blobs_reject_same_size_corruption() {
1311 let directory = tempfile::tempdir().unwrap();
1312 let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
1313 let digest = CacheDigest::blake3(b"cached object");
1314 let path = agent.cas.store_bytes(&digest, b"cached object").unwrap();
1315 assert_eq!(
1316 agent.find_verified_blob(&digest).unwrap(),
1317 Some(path.clone())
1318 );
1319
1320 std::fs::write(&path, b"broken object").unwrap();
1321
1322 assert!(agent.find_verified_blob(&digest).is_err());
1323 assert!(!agent.verified_blobs.lock().unwrap().contains_key(&digest));
1324 }
1325
1326 #[tokio::test]
1327 async fn publishes_a_complete_action_result() {
1328 let directory = tempfile::tempdir().unwrap();
1329 let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
1330 let action = CacheDigest::blake3(b"action");
1331 let metadata = CacheDigest::blake3(b"metadata");
1332 let output_root = CacheDigest::blake3(b"directory");
1333 for (digest, contents) in [
1334 (&action, b"action".as_slice()),
1335 (&metadata, b"metadata".as_slice()),
1336 (&output_root, b"directory".as_slice()),
1337 ] {
1338 agent.cas.store_bytes(digest, contents).unwrap();
1339 }
1340 let response = agent
1341 .respond(AgentRequest::StoreActionResult {
1342 result: RemoteActionResult {
1343 action: action.clone(),
1344 metadata: Some(metadata),
1345 output_root: Some(output_root),
1346 version: 1,
1347 },
1348 })
1349 .await;
1350 assert!(matches!(response, AgentResponse::ActionStored { .. }));
1351 let response = agent
1352 .respond(AgentRequest::FindActionResult {
1353 action: action.clone(),
1354 })
1355 .await;
1356 assert!(matches!(
1357 response,
1358 AgentResponse::ActionResult {
1359 result: Some(result)
1360 } if result.action == action
1361 ));
1362 assert!(matches!(
1363 agent
1364 .respond(AgentRequest::RecordActionHit {
1365 action: action.clone()
1366 })
1367 .await,
1368 AgentResponse::ActionHitRecorded
1369 ));
1370 assert_eq!(
1371 agent.stats(),
1372 AgentStats {
1373 lookups: 1,
1374 hits: 1,
1375 ..AgentStats::default()
1376 }
1377 );
1378 }
1379
1380 #[tokio::test]
1381 async fn missing_action_result_is_a_cache_miss() {
1382 let directory = tempfile::tempdir().unwrap();
1383 let agent = CacheAgent::new(directory.path(), "test-version");
1384 let action = CacheDigest::blake3(b"missing action");
1385 let response = agent
1386 .respond(AgentRequest::FindActionResult {
1387 action: action.clone(),
1388 })
1389 .await;
1390
1391 assert!(matches!(
1392 response,
1393 AgentResponse::ActionResult { result: None }
1394 ));
1395 assert!(matches!(
1396 agent
1397 .respond(AgentRequest::RecordActionHit { action })
1398 .await,
1399 AgentResponse::Error { .. }
1400 ));
1401 assert_eq!(
1402 agent.stats(),
1403 AgentStats {
1404 lookups: 1,
1405 ..AgentStats::default()
1406 }
1407 );
1408
1409 assert!(matches!(
1410 agent
1411 .respond(AgentRequest::RecordActionVerification { matched: false })
1412 .await,
1413 AgentResponse::ActionVerificationRecorded
1414 ));
1415 assert_eq!(agent.stats().verifications, 1);
1416 assert_eq!(agent.stats().divergences, 1);
1417 }
1418
1419 #[tokio::test]
1420 async fn coalesces_repeated_remote_action_lookups() {
1421 let directory = tempfile::tempdir().unwrap();
1422 let mut server = mockito::Server::new_async().await;
1423 let action = CacheDigest::blake3(b"remote action");
1424 let result = RemoteActionResult {
1425 action: action.clone(),
1426 metadata: None,
1427 output_root: None,
1428 version: 1,
1429 };
1430 let remote = server
1431 .mock("GET", action_path(&action).as_str())
1432 .with_status(200)
1433 .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1434 .with_body(serde_json::to_vec(&result).unwrap())
1435 .expect(1)
1436 .create_async()
1437 .await;
1438 let agent = remote_agent(
1439 &server,
1440 directory.path().join("reader"),
1441 RemoteCacheMode::ReadOnly,
1442 );
1443
1444 for _ in 0..2 {
1445 assert!(matches!(
1446 agent
1447 .respond(AgentRequest::FindActionResult {
1448 action: action.clone(),
1449 })
1450 .await,
1451 AgentResponse::ActionResult {
1452 result: Some(found)
1453 } if found == result
1454 ));
1455 }
1456 remote.assert_async().await;
1457 }
1458
1459 #[tokio::test]
1460 async fn publishes_only_successfully_committed_task_action_manifests() {
1461 let directory = tempfile::tempdir().unwrap();
1462 let cache = directory.path().join("cache");
1463 let task = "a".repeat(64);
1464 let first_invocation = CacheDigest::blake3(b"first invocation");
1465 let first = ActionPrediction {
1466 invocation: first_invocation.clone(),
1467 action: CacheDigest::blake3(b"first action"),
1468 adapter: "rustc".into(),
1469 payload: "{}".into(),
1470 };
1471
1472 let agent = CacheAgent::new(&cache, "test-version");
1473 let first_run = agent.begin_task(&task).await.unwrap();
1474 assert!(matches!(
1475 agent
1476 .respond(AgentRequest::RecordActionPrediction {
1477 task: first_run.clone(),
1478 prediction: first.clone(),
1479 })
1480 .await,
1481 AgentResponse::ActionPredictionRecorded
1482 ));
1483 agent.commit_task(&first_run).await.unwrap();
1484
1485 let uncommitted = CacheAgent::new(&cache, "test-version");
1486 let uncommitted_run = uncommitted.begin_task(&task).await.unwrap();
1487 let second_invocation = CacheDigest::blake3(b"second invocation");
1488 assert!(matches!(
1489 uncommitted
1490 .respond(AgentRequest::RecordActionPrediction {
1491 task: uncommitted_run,
1492 prediction: ActionPrediction {
1493 invocation: second_invocation.clone(),
1494 action: CacheDigest::blake3(b"second action"),
1495 adapter: "rustc".into(),
1496 payload: "{}".into(),
1497 },
1498 })
1499 .await,
1500 AgentResponse::ActionPredictionRecorded
1501 ));
1502
1503 let next_session = CacheAgent::new(&cache, "test-version");
1504 let next_run = next_session.begin_task(&task).await.unwrap();
1505 assert!(matches!(
1506 next_session
1507 .respond(AgentRequest::FindActionPrediction {
1508 task: next_run.clone(),
1509 invocation: first_invocation,
1510 })
1511 .await,
1512 AgentResponse::ActionPrediction {
1513 prediction: Some(prediction)
1514 } if prediction == first
1515 ));
1516 assert!(matches!(
1517 next_session
1518 .respond(AgentRequest::FindActionPrediction {
1519 task: next_run,
1520 invocation: second_invocation,
1521 })
1522 .await,
1523 AgentResponse::ActionPrediction { prediction: None }
1524 ));
1525
1526 let corrupt_task = "b".repeat(64);
1527 fs::create_dir_all(next_session.manifest_dir.as_path()).unwrap();
1528 fs::write(next_session.task_manifest_path(&corrupt_task), b"not json").unwrap();
1529 assert!(next_session.begin_task(&corrupt_task).await.is_err());
1530 let corrupt_run = "c".repeat(64);
1531 next_session.task_actions.lock().unwrap().insert(
1532 corrupt_run.clone(),
1533 TaskActionState {
1534 manifest: corrupt_task.clone(),
1535 ..TaskActionState::default()
1536 },
1537 );
1538 assert!(matches!(
1539 next_session
1540 .respond(AgentRequest::RecordActionPrediction {
1541 task: corrupt_run.clone(),
1542 prediction: first,
1543 })
1544 .await,
1545 AgentResponse::ActionPredictionRecorded
1546 ));
1547 assert!(next_session.commit_task(&corrupt_run).await.is_err());
1548 assert_eq!(
1549 fs::read(next_session.task_manifest_path(&corrupt_task)).unwrap(),
1550 b"not json"
1551 );
1552 }
1553
1554 #[tokio::test]
1555 async fn round_trips_task_actions_between_fresh_local_caches() {
1556 let directory = tempfile::tempdir().unwrap();
1557 let mut server = mockito::Server::new_async().await;
1558 let task = "e".repeat(64);
1559 let invocation = CacheDigest::blake3(b"remote invocation");
1560 let action_bytes = canonical_json(&serde_json::json!({"kind":"rustc"})).unwrap();
1561 let stdout_bytes = b"cached stdout".to_vec();
1562 let stderr_bytes = b"cached stderr".to_vec();
1563 let artifact_bytes = b"cached artifact".to_vec();
1564 let stdout = CacheDigest::blake3(&stdout_bytes);
1565 let stderr = CacheDigest::blake3(&stderr_bytes);
1566 let artifact = CacheDigest::blake3(&artifact_bytes);
1567 let metadata_bytes = canonical_json(&RustcMetadata {
1568 version: 1,
1569 kind: "rustc".into(),
1570 stdout: stdout.clone(),
1571 stderr: stderr.clone(),
1572 })
1573 .unwrap();
1574 let directory_bytes = canonical_json(&serde_json::json!({
1575 "directories":[],
1576 "files":[{"digest":artifact,"executable":false,"mode":420,"name":"artifact"}],
1577 "symlinks":[],
1578 "version":1
1579 }))
1580 .unwrap();
1581 let action = CacheDigest::blake3(&action_bytes);
1582 let metadata = CacheDigest::blake3(&metadata_bytes);
1583 let output_root = CacheDigest::blake3(&directory_bytes);
1584 let result = RemoteActionResult {
1585 action: action.clone(),
1586 metadata: Some(metadata.clone()),
1587 output_root: Some(output_root.clone()),
1588 version: 1,
1589 };
1590 let prediction = ActionPrediction {
1591 invocation: invocation.clone(),
1592 action: action.clone(),
1593 adapter: "rustc".into(),
1594 payload: "{}".into(),
1595 };
1596 let manifest_bytes = canonical_json(&TaskActionManifest {
1597 version: TASK_ACTION_MANIFEST_VERSION,
1598 task: task.clone(),
1599 predictions: vec![prediction.clone()],
1600 })
1601 .unwrap();
1602 let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
1603 let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
1604
1605 let mut mocks = Vec::new();
1606 for (digest, bytes) in [
1607 (&action, action_bytes.as_slice()),
1608 (&metadata, metadata_bytes.as_slice()),
1609 (&output_root, directory_bytes.as_slice()),
1610 (&stdout, stdout_bytes.as_slice()),
1611 (&stderr, stderr_bytes.as_slice()),
1612 (&artifact, artifact_bytes.as_slice()),
1613 ] {
1614 mocks.push(
1615 server
1616 .mock("PUT", blob_path(digest).as_str())
1617 .match_header("mise-cache-namespace", "test")
1618 .match_body(bytes.to_vec())
1619 .with_status(200)
1620 .expect(1)
1621 .create_async()
1622 .await,
1623 );
1624 }
1625 mocks.push(
1626 server
1627 .mock("PUT", action_path(&result.action).as_str())
1628 .match_header("mise-cache-namespace", "test")
1629 .with_status(200)
1630 .expect(1)
1631 .create_async()
1632 .await,
1633 );
1634 mocks.push(
1635 server
1636 .mock("PUT", action_manifest_path(&selector).as_str())
1637 .match_header("mise-cache-namespace", "test")
1638 .match_header("if-none-match", "*")
1639 .match_body(manifest_bytes.clone())
1640 .with_status(201)
1641 .expect(1)
1642 .create_async()
1643 .await,
1644 );
1645 mocks.push(
1646 server
1647 .mock("GET", action_manifest_path(&selector).as_str())
1648 .with_status(200)
1649 .with_header("etag", &format!("\"{manifest_etag}\""))
1650 .with_body(manifest_bytes.clone())
1651 .expect(1)
1652 .create_async()
1653 .await,
1654 );
1655 mocks.push(
1656 server
1657 .mock("GET", action_path(&action).as_str())
1658 .with_status(200)
1659 .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1660 .with_body(serde_json::to_vec(&result).unwrap())
1661 .expect(1)
1662 .create_async()
1663 .await,
1664 );
1665 for (digest, bytes) in [
1666 (&action, action_bytes.as_slice()),
1667 (&metadata, metadata_bytes.as_slice()),
1668 (&output_root, directory_bytes.as_slice()),
1669 (&stdout, stdout_bytes.as_slice()),
1670 (&stderr, stderr_bytes.as_slice()),
1671 (&artifact, artifact_bytes.as_slice()),
1672 ] {
1673 mocks.push(
1674 server
1675 .mock("GET", blob_path(digest).as_str())
1676 .with_status(200)
1677 .with_body(bytes)
1678 .expect(1)
1679 .create_async()
1680 .await,
1681 );
1682 }
1683
1684 let writer = remote_agent(
1685 &server,
1686 directory.path().join("writer"),
1687 RemoteCacheMode::WriteOnly,
1688 );
1689 for (index, (digest, bytes)) in [
1690 (&action, action_bytes.as_slice()),
1691 (&metadata, metadata_bytes.as_slice()),
1692 (&output_root, directory_bytes.as_slice()),
1693 (&stdout, stdout_bytes.as_slice()),
1694 (&stderr, stderr_bytes.as_slice()),
1695 (&artifact, artifact_bytes.as_slice()),
1696 ]
1697 .into_iter()
1698 .enumerate()
1699 {
1700 let source = directory.path().join(format!("source-{index}"));
1701 fs::write(&source, bytes).unwrap();
1702 assert!(matches!(
1703 writer
1704 .respond(AgentRequest::StoreBlob {
1705 digest: digest.clone(),
1706 source,
1707 })
1708 .await,
1709 AgentResponse::Stored { .. }
1710 ));
1711 }
1712 assert!(matches!(
1713 writer
1714 .respond(AgentRequest::StoreActionResult {
1715 result: result.clone(),
1716 })
1717 .await,
1718 AgentResponse::ActionStored { .. }
1719 ));
1720 let run = writer.begin_task(&task).await.unwrap();
1721 assert!(matches!(
1722 writer
1723 .respond(AgentRequest::RecordActionPrediction {
1724 task: run.clone(),
1725 prediction: prediction.clone(),
1726 })
1727 .await,
1728 AgentResponse::ActionPredictionRecorded
1729 ));
1730 writer.commit_task(&run).await.unwrap();
1731
1732 let reader = remote_agent(
1733 &server,
1734 directory.path().join("reader"),
1735 RemoteCacheMode::ReadOnly,
1736 );
1737 let run = reader.begin_task(&task).await.unwrap();
1738 reader.wait_for_prefetches().await;
1739 assert!(matches!(
1740 reader
1741 .respond(AgentRequest::FindActionPrediction {
1742 task: run,
1743 invocation,
1744 })
1745 .await,
1746 AgentResponse::ActionPrediction {
1747 prediction: Some(found)
1748 } if found == prediction
1749 ));
1750 assert!(matches!(
1751 reader
1752 .respond(AgentRequest::FindActionResult {
1753 action: action.clone(),
1754 })
1755 .await,
1756 AgentResponse::ActionResult {
1757 result: Some(found)
1758 } if found == result
1759 ));
1760 for digest in [&action, &metadata, &output_root] {
1761 assert!(matches!(
1762 reader
1763 .respond(AgentRequest::FindBlob {
1764 digest: digest.clone(),
1765 })
1766 .await,
1767 AgentResponse::Blob { path: Some(_) }
1768 ));
1769 }
1770 assert!(matches!(
1771 reader
1772 .respond(AgentRequest::RecordActionHit { action })
1773 .await,
1774 AgentResponse::ActionHitRecorded
1775 ));
1776 for mock in mocks {
1777 mock.assert_async().await;
1778 }
1779 }
1780
1781 #[tokio::test]
1782 async fn keeps_newer_local_predictions_when_remote_manifest_is_stale() {
1783 let directory = tempfile::tempdir().unwrap();
1784 let mut server = mockito::Server::new_async().await;
1785 let task = "f".repeat(64);
1786 let invocation = CacheDigest::blake3(b"shared invocation");
1787 let local_prediction = ActionPrediction {
1788 invocation: invocation.clone(),
1789 action: CacheDigest::blake3(b"new local action"),
1790 adapter: "rustc".into(),
1791 payload: "{}".into(),
1792 };
1793 let remote_prediction = ActionPrediction {
1794 invocation: invocation.clone(),
1795 action: CacheDigest::blake3(b"stale remote action"),
1796 adapter: "rustc".into(),
1797 payload: "{}".into(),
1798 };
1799 let remote_manifest = TaskActionManifest {
1800 version: TASK_ACTION_MANIFEST_VERSION,
1801 task: task.clone(),
1802 predictions: vec![remote_prediction],
1803 };
1804 let remote_bytes = canonical_json(&remote_manifest).unwrap();
1805 let remote_etag = blake3::hash(&remote_bytes).to_hex().to_string();
1806 let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
1807 let remote = server
1808 .mock("GET", action_manifest_path(&selector).as_str())
1809 .with_status(200)
1810 .with_header("etag", &format!("\"{remote_etag}\""))
1811 .with_body(remote_bytes)
1812 .expect(1)
1813 .create_async()
1814 .await;
1815
1816 let agent = remote_agent(
1817 &server,
1818 directory.path().join("reader"),
1819 RemoteCacheMode::ReadOnly,
1820 );
1821 agent
1822 .persist_task_manifest(&TaskActionManifest {
1823 version: TASK_ACTION_MANIFEST_VERSION,
1824 task: task.clone(),
1825 predictions: vec![local_prediction.clone()],
1826 })
1827 .unwrap();
1828
1829 let run = agent.begin_task(&task).await.unwrap();
1830 assert!(matches!(
1831 agent
1832 .respond(AgentRequest::FindActionPrediction {
1833 task: run,
1834 invocation,
1835 })
1836 .await,
1837 AgentResponse::ActionPrediction {
1838 prediction: Some(found)
1839 } if found == local_prediction
1840 ));
1841 let persisted = agent.load_task_manifest(&task).unwrap().unwrap();
1842 assert_eq!(persisted.predictions, vec![local_prediction]);
1843 remote.assert_async().await;
1844 }
1845
1846 #[tokio::test]
1847 async fn prefetch_does_not_block_task_initialization() {
1848 let directory = tempfile::tempdir().unwrap();
1849 let mut server = mockito::Server::new_async().await;
1850 let task = "9".repeat(64);
1851 let invocation = CacheDigest::blake3(b"prefetched invocation");
1852 let action_bytes = b"prefetched action";
1853 let action = CacheDigest::blake3(action_bytes);
1854 let result = RemoteActionResult {
1855 action: action.clone(),
1856 metadata: None,
1857 output_root: None,
1858 version: 1,
1859 };
1860 let manifest_bytes = canonical_json(&TaskActionManifest {
1861 version: TASK_ACTION_MANIFEST_VERSION,
1862 task: task.clone(),
1863 predictions: vec![ActionPrediction {
1864 invocation,
1865 action: action.clone(),
1866 adapter: "rustc".into(),
1867 payload: "{}".into(),
1868 }],
1869 })
1870 .unwrap();
1871 let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
1872 let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
1873 let manifest = server
1874 .mock("GET", action_manifest_path(&selector).as_str())
1875 .with_status(200)
1876 .with_header("etag", &format!("\"{manifest_etag}\""))
1877 .with_body(manifest_bytes)
1878 .expect(1)
1879 .create_async()
1880 .await;
1881 let release = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
1882 let response_release = release.clone();
1883 let result_bytes = serde_json::to_vec(&result).unwrap();
1884 let action_result = server
1885 .mock("GET", action_path(&action).as_str())
1886 .with_status(200)
1887 .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1888 .with_chunked_body(move |writer| {
1889 let (released, condition) = &*response_release;
1890 let mut released = released.lock().unwrap();
1891 while !*released {
1892 released = condition.wait(released).unwrap();
1893 }
1894 std::io::Write::write_all(writer, &result_bytes)
1895 })
1896 .expect(1)
1897 .create_async()
1898 .await;
1899 let action_blob = server
1900 .mock("GET", blob_path(&action).as_str())
1901 .with_status(200)
1902 .with_body(action_bytes)
1903 .expect(1)
1904 .create_async()
1905 .await;
1906 let agent = remote_agent(
1907 &server,
1908 directory.path().join("reader"),
1909 RemoteCacheMode::ReadOnly,
1910 );
1911
1912 let begin = tokio::time::timeout(Duration::from_secs(2), agent.begin_task(&task)).await;
1913 let (released, condition) = &*release;
1914 *released.lock().unwrap() = true;
1915 condition.notify_all();
1916 let run = begin
1917 .expect("task initialization waited for prefetch")
1918 .unwrap();
1919 assert_eq!(
1920 agent
1921 .task_actions
1922 .lock()
1923 .unwrap()
1924 .get(&run)
1925 .unwrap()
1926 .predictions
1927 .len(),
1928 1
1929 );
1930 agent.wait_for_prefetches().await;
1931 manifest.assert_async().await;
1932 action_result.assert_async().await;
1933 action_blob.assert_async().await;
1934 assert!(agent.actions.find(&action).unwrap().is_some());
1935 }
1936
1937 #[tokio::test]
1938 async fn foreground_action_lookup_does_not_wait_for_prefetch_output() {
1939 let directory = tempfile::tempdir().unwrap();
1940 let mut server = mockito::Server::new_async().await;
1941 let action_bytes = b"prefetched action";
1942 let artifact_bytes = b"prefetched artifact";
1943 let action = CacheDigest::blake3(action_bytes);
1944 let artifact = CacheDigest::blake3(artifact_bytes);
1945 let directory_bytes = canonical_json(&serde_json::json!({
1946 "directories": [],
1947 "files": [{
1948 "digest": artifact,
1949 "executable": false,
1950 "mode": 420,
1951 "name": "artifact",
1952 }],
1953 "symlinks": [],
1954 "version": 1,
1955 }))
1956 .unwrap();
1957 let output_root = CacheDigest::blake3(&directory_bytes);
1958 let result = RemoteActionResult {
1959 action: action.clone(),
1960 metadata: None,
1961 output_root: Some(output_root.clone()),
1962 version: 1,
1963 };
1964 let action_result = server
1965 .mock("GET", action_path(&action).as_str())
1966 .with_status(200)
1967 .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1968 .with_body(serde_json::to_vec(&result).unwrap())
1969 .expect(1)
1970 .create_async()
1971 .await;
1972 let action_blob = server
1973 .mock("GET", blob_path(&action).as_str())
1974 .with_status(200)
1975 .with_body(action_bytes)
1976 .expect(1)
1977 .create_async()
1978 .await;
1979 let output_directory = server
1980 .mock("GET", blob_path(&output_root).as_str())
1981 .with_status(200)
1982 .with_body(directory_bytes)
1983 .expect(1)
1984 .create_async()
1985 .await;
1986 let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
1987 let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
1988 let response_started = started.clone();
1989 let response_release = release.clone();
1990 let artifact_blob = server
1991 .mock("GET", blob_path(&artifact).as_str())
1992 .with_status(200)
1993 .with_chunked_body(move |writer| {
1994 response_started.store(true, Ordering::Release);
1995 while !response_release.load(Ordering::Acquire) {
1996 std::thread::sleep(Duration::from_millis(10));
1997 }
1998 std::io::Write::write_all(writer, artifact_bytes)
1999 })
2000 .expect(1)
2001 .create_async()
2002 .await;
2003 let agent = remote_agent(
2004 &server,
2005 directory.path().join("reader"),
2006 RemoteCacheMode::ReadOnly,
2007 );
2008 let prefetch_agent = agent.clone();
2009 let prefetch_action = action.clone();
2010 let prefetch = tokio::spawn(async move {
2011 prefetch_agent
2012 .prefetch_action(prefetch_action, "rustc".into())
2013 .await
2014 });
2015 tokio::time::timeout(Duration::from_secs(1), async {
2016 while !started.load(Ordering::Acquire) {
2017 tokio::time::sleep(Duration::from_millis(10)).await;
2018 }
2019 })
2020 .await
2021 .expect("prefetch did not request the output blob");
2022
2023 let foreground = tokio::time::timeout(
2024 Duration::from_millis(250),
2025 agent.find_action_result(&action),
2026 )
2027 .await;
2028 release.store(true, Ordering::Release);
2029 prefetch.await.unwrap().unwrap();
2030 let foreground = foreground.expect("foreground action lookup waited for output prefetch");
2031
2032 assert!(matches!(
2033 foreground.unwrap(),
2034 AgentResponse::ActionResult {
2035 result: Some(found)
2036 } if found == result
2037 ));
2038 action_result.assert_async().await;
2039 action_blob.assert_async().await;
2040 output_directory.assert_async().await;
2041 artifact_blob.assert_async().await;
2042 }
2043
2044 #[tokio::test]
2045 async fn session_completion_cancels_outstanding_prefetches() {
2046 let directory = tempfile::tempdir().unwrap();
2047 let agent = CacheAgent::new(directory.path(), "test-version");
2048 let task = tokio::spawn(std::future::pending::<()>());
2049 agent.prefetch_tasks.lock().unwrap().push(task);
2050
2051 tokio::time::timeout(Duration::from_secs(1), agent.cancel_prefetches())
2052 .await
2053 .expect("prefetch cancellation blocked session completion");
2054 assert!(agent.prefetch_tasks.lock().unwrap().is_empty());
2055 }
2056
2057 #[tokio::test]
2058 async fn prefetch_reserves_capacity_for_foreground_transfers() {
2059 let transfers = tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS);
2060 let _prefetch = transfers
2061 .acquire_many(MAX_PREFETCH_TRANSFERS as u32)
2062 .await
2063 .unwrap();
2064 assert!(transfers.available_permits() > 0);
2065 }
2066
2067 #[tokio::test]
2068 async fn prefetches_output_files_concurrently() {
2069 let directory = tempfile::tempdir().unwrap();
2070 let (responses, output_root) = output_tree_responses(8);
2071 let (base_url, maximum_in_flight, server) =
2072 delayed_blob_server(responses, Duration::from_millis(50)).await;
2073 let agent = remote_agent_url(
2074 base_url,
2075 directory.path().join("reader"),
2076 RemoteCacheMode::ReadOnly,
2077 );
2078
2079 agent
2080 .prefetch_output_tree(agent.remote.as_deref().unwrap(), &output_root)
2081 .await
2082 .unwrap();
2083 server.await.unwrap();
2084
2085 assert!(maximum_in_flight.load(Ordering::Relaxed) > 1);
2086 }
2087
2088 #[tokio::test]
2089 #[ignore = "local remote-cache throughput benchmark"]
2090 async fn benchmark_prefetch_output_tree_latency() {
2091 let files = std::env::var("MISE_CACHE_BENCH_FILES")
2092 .ok()
2093 .and_then(|value| value.parse().ok())
2094 .unwrap_or(96);
2095 let latency = Duration::from_millis(
2096 std::env::var("MISE_CACHE_BENCH_LATENCY_MS")
2097 .ok()
2098 .and_then(|value| value.parse().ok())
2099 .unwrap_or(100),
2100 );
2101
2102 let directory = tempfile::tempdir().unwrap();
2103 let (responses, output_root) = output_tree_responses(files);
2104 let (base_url, maximum_in_flight, server) = delayed_blob_server(responses, latency).await;
2105 let agent = remote_agent_url(
2106 base_url,
2107 directory.path().join("reader"),
2108 RemoteCacheMode::ReadOnly,
2109 );
2110 let remote = agent.remote.as_deref().unwrap();
2111
2112 let started = std::time::Instant::now();
2113 agent
2114 .prefetch_output_tree(remote, &output_root)
2115 .await
2116 .unwrap();
2117 let elapsed = started.elapsed();
2118
2119 eprintln!(
2120 "prefetched {files} blobs with {} ms latency in {elapsed:?}",
2121 latency.as_millis()
2122 );
2123 server.await.unwrap();
2124 eprintln!(
2125 "maximum concurrent requests: {}",
2126 maximum_in_flight.load(Ordering::Relaxed)
2127 );
2128 }
2129
2130 fn output_tree_responses(files: usize) -> (BTreeMap<String, Vec<u8>>, CacheDigest) {
2131 let mut entries = Vec::with_capacity(files);
2132 let mut responses = BTreeMap::new();
2133 for index in 0..files {
2134 let body = format!("cached artifact {index}").into_bytes();
2135 let digest = CacheDigest::blake3(&body);
2136 entries.push(serde_json::json!({
2137 "digest": digest,
2138 "executable": false,
2139 "mode": 420,
2140 "name": format!("artifact-{index}"),
2141 }));
2142 responses.insert(blob_path(&digest), body);
2143 }
2144 let directory = canonical_json(&serde_json::json!({
2145 "directories": [],
2146 "files": entries,
2147 "symlinks": [],
2148 "version": 1,
2149 }))
2150 .unwrap();
2151 let output_root = CacheDigest::blake3(&directory);
2152 responses.insert(blob_path(&output_root), directory);
2153 (responses, output_root)
2154 }
2155
2156 async fn delayed_blob_server(
2157 responses: BTreeMap<String, Vec<u8>>,
2158 latency: Duration,
2159 ) -> (
2160 url::Url,
2161 Arc<std::sync::atomic::AtomicUsize>,
2162 tokio::task::JoinHandle<()>,
2163 ) {
2164 use std::sync::atomic::AtomicUsize;
2165 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2166
2167 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2168 let address = listener.local_addr().unwrap();
2169 let responses = Arc::new(responses);
2170 let request_count = responses.len();
2171 let in_flight = Arc::new(AtomicUsize::new(0));
2172 let maximum_in_flight = Arc::new(AtomicUsize::new(0));
2173 let observed_maximum = maximum_in_flight.clone();
2174 let server = tokio::spawn(async move {
2175 let mut requests = tokio::task::JoinSet::new();
2176 for _ in 0..request_count {
2177 let (mut socket, _) = listener.accept().await.unwrap();
2178 let responses = responses.clone();
2179 let in_flight = in_flight.clone();
2180 let maximum_in_flight = maximum_in_flight.clone();
2181 requests.spawn(async move {
2182 let mut request = Vec::new();
2183 loop {
2184 let mut chunk = [0; 1024];
2185 let size = socket.read(&mut chunk).await.unwrap();
2186 assert!(size > 0, "client closed before sending request headers");
2187 request.extend_from_slice(&chunk[..size]);
2188 if request.windows(4).any(|window| window == b"\r\n\r\n") {
2189 break;
2190 }
2191 }
2192 let request = String::from_utf8_lossy(&request);
2193 let path = request
2194 .lines()
2195 .next()
2196 .and_then(|line| line.split_whitespace().nth(1))
2197 .unwrap();
2198 let body = responses.get(path).unwrap();
2199 let active = in_flight.fetch_add(1, Ordering::Relaxed) + 1;
2200 maximum_in_flight.fetch_max(active, Ordering::Relaxed);
2201 tokio::time::sleep(latency).await;
2202 socket
2203 .write_all(
2204 format!(
2205 "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
2206 body.len()
2207 )
2208 .as_bytes(),
2209 )
2210 .await
2211 .unwrap();
2212 socket.write_all(body).await.unwrap();
2213 in_flight.fetch_sub(1, Ordering::Relaxed);
2214 });
2215 }
2216 while requests.join_next().await.is_some() {}
2217 });
2218 (
2219 format!("http://{address}").parse().unwrap(),
2220 observed_maximum,
2221 server,
2222 )
2223 }
2224
2225 fn remote_agent(
2226 server: &mockito::ServerGuard,
2227 cache_dir: PathBuf,
2228 mode: RemoteCacheMode,
2229 ) -> CacheAgent {
2230 remote_agent_url(server.url().parse().unwrap(), cache_dir, mode)
2231 }
2232
2233 fn remote_agent_url(
2234 base_url: url::Url,
2235 cache_dir: PathBuf,
2236 mode: RemoteCacheMode,
2237 ) -> CacheAgent {
2238 let client = RemoteCacheClient::new(crate::RemoteCacheConfig {
2239 base_url,
2240 namespace: "test".into(),
2241 token: None,
2242 token_file: None,
2243 oidc_audience: None,
2244 connect_timeout: Duration::from_secs(1),
2245 read_timeout: Duration::from_secs(1),
2246 download_timeout: Duration::from_secs(1),
2247 retries: 0,
2248 })
2249 .unwrap();
2250 CacheAgent::new_remote(
2251 &cache_dir,
2252 "test-version",
2253 AgentRemoteCache {
2254 client,
2255 mode,
2256 staging_dir: cache_dir.join("remote"),
2257 },
2258 )
2259 }
2260
2261 fn blob_path(digest: &CacheDigest) -> String {
2262 format!(
2263 "/v1/blobs/{}/{}/{}",
2264 digest.algorithm, digest.hash, digest.size
2265 )
2266 }
2267
2268 fn action_path(digest: &CacheDigest) -> String {
2269 format!(
2270 "/v1/action-results/{}/{}/{}",
2271 digest.algorithm, digest.hash, digest.size
2272 )
2273 }
2274
2275 fn action_manifest_path(digest: &CacheDigest) -> String {
2276 format!(
2277 "/v1/action-manifests/{}/{}/{}",
2278 digest.algorithm, digest.hash, digest.size
2279 )
2280 }
2281
2282 #[tokio::test]
2283 async fn merges_overlapping_runs_into_one_task_manifest() {
2284 let directory = tempfile::tempdir().unwrap();
2285 let cache = directory.path().join("cache");
2286 let task = "d".repeat(64);
2287 let agent = CacheAgent::new(&cache, "test-version");
2288 let first_run = agent.begin_task(&task).await.unwrap();
2289 let second_run = agent.begin_task(&task).await.unwrap();
2290 assert_ne!(first_run, second_run);
2291 let first_invocation = CacheDigest::blake3(b"overlap one");
2292 let second_invocation = CacheDigest::blake3(b"overlap two");
2293 for (run, invocation) in [
2294 (&first_run, &first_invocation),
2295 (&second_run, &second_invocation),
2296 ] {
2297 assert!(matches!(
2298 agent
2299 .respond(AgentRequest::RecordActionPrediction {
2300 task: run.clone(),
2301 prediction: ActionPrediction {
2302 invocation: invocation.clone(),
2303 action: CacheDigest::blake3(invocation.hash.as_bytes()),
2304 adapter: "rustc".into(),
2305 payload: "{}".into(),
2306 },
2307 })
2308 .await,
2309 AgentResponse::ActionPredictionRecorded
2310 ));
2311 }
2312 agent.commit_task(&first_run).await.unwrap();
2313 agent.commit_task(&second_run).await.unwrap();
2314
2315 let next = CacheAgent::new(cache, "test-version");
2316 let run = next.begin_task(&task).await.unwrap();
2317 for invocation in [first_invocation, second_invocation] {
2318 assert!(matches!(
2319 next.respond(AgentRequest::FindActionPrediction {
2320 task: run.clone(),
2321 invocation,
2322 })
2323 .await,
2324 AgentResponse::ActionPrediction {
2325 prediction: Some(_)
2326 }
2327 ));
2328 }
2329 }
2330
2331 #[test]
2332 fn keeps_local_manifest_when_remote_merge_exceeds_prediction_limit() {
2333 let task = "7".repeat(64);
2334 let prediction = |index: usize| {
2335 let digest = CacheDigest::blake3(&index.to_le_bytes());
2336 ActionPrediction {
2337 invocation: digest.clone(),
2338 action: digest,
2339 adapter: "rustc".into(),
2340 payload: "{}".into(),
2341 }
2342 };
2343 let local = TaskActionManifest {
2344 version: TASK_ACTION_MANIFEST_VERSION,
2345 task: task.clone(),
2346 predictions: (0..MAX_TASK_ACTION_PREDICTIONS).map(prediction).collect(),
2347 };
2348 let expected_first = local.predictions[0].clone();
2349 let remote = TaskActionManifest {
2350 version: TASK_ACTION_MANIFEST_VERSION,
2351 task: task.clone(),
2352 predictions: vec![prediction(MAX_TASK_ACTION_PREDICTIONS)],
2353 };
2354
2355 let (manifest, merged) = merge_remote_task_manifest(&task, remote, local);
2356 assert!(!merged);
2357 assert_eq!(manifest.predictions.len(), MAX_TASK_ACTION_PREDICTIONS);
2358 assert_eq!(manifest.predictions[0], expected_first);
2359 }
2360
2361 #[test]
2362 fn task_manifest_lock_is_shared_across_agents() {
2363 let directory = tempfile::tempdir().unwrap();
2364 let cache = directory.path().join("cache");
2365 let first = CacheAgent::new(&cache, "test-version");
2366 let second = CacheAgent::new(&cache, "test-version");
2367 let task = "8".repeat(64);
2368
2369 let first_lock = first.lock_task_manifest(&task).unwrap();
2370 let mut contender = fslock::LockFile::open(&second.task_manifest_lock_path(&task)).unwrap();
2371 assert!(!contender.try_lock().unwrap());
2372 drop(first_lock);
2373 assert!(contender.try_lock().unwrap());
2374 }
2375
2376 #[tokio::test]
2377 async fn memoizes_client_observed_executable_identities() {
2378 let directory = tempfile::tempdir().unwrap();
2379 let agent = CacheAgent::new(directory.path(), "test-version");
2380 let executable = directory.path().join("rustc");
2381 let environment = BTreeMap::from([("RUSTUP_TOOLCHAIN".into(), Some("stable".into()))]);
2382
2383 let response = agent
2384 .respond(AgentRequest::FindExecutableIdentity {
2385 executable: executable.clone(),
2386 environment: environment.clone(),
2387 })
2388 .await;
2389 assert!(matches!(
2390 response,
2391 AgentResponse::ExecutableIdentity { stdout: None }
2392 ));
2393
2394 let response = agent
2395 .respond(AgentRequest::StoreExecutableIdentity {
2396 executable: executable.clone(),
2397 environment: environment.clone(),
2398 stdout: b"rustc identity".to_vec(),
2399 })
2400 .await;
2401 assert!(matches!(
2402 response,
2403 AgentResponse::ExecutableIdentity {
2404 stdout: Some(stdout)
2405 } if stdout == b"rustc identity"
2406 ));
2407
2408 let response = agent
2409 .respond(AgentRequest::FindExecutableIdentity {
2410 executable,
2411 environment,
2412 })
2413 .await;
2414 assert!(matches!(
2415 response,
2416 AgentResponse::ExecutableIdentity {
2417 stdout: Some(stdout)
2418 } if stdout == b"rustc identity"
2419 ));
2420 }
2421
2422 #[test]
2423 fn bounds_executable_identity_entry_count() {
2424 let directory = tempfile::tempdir().unwrap();
2425 let agent = CacheAgent::new(directory.path(), "test-version");
2426 for index in 0..MAX_EXECUTABLE_IDENTITIES {
2427 agent
2428 .store_executable_identity(
2429 directory.path().join(format!("rustc-{index}")),
2430 BTreeMap::new(),
2431 vec![b'x'],
2432 )
2433 .unwrap();
2434 }
2435
2436 assert!(
2437 agent
2438 .store_executable_identity(
2439 directory.path().join("one-too-many"),
2440 BTreeMap::new(),
2441 vec![b'x'],
2442 )
2443 .is_err()
2444 );
2445 }
2446
2447 #[test]
2448 fn bounds_executable_identity_retained_bytes() {
2449 let directory = tempfile::tempdir().unwrap();
2450 let agent = CacheAgent::new(directory.path(), "test-version");
2451 for index in 0..MAX_EXECUTABLE_IDENTITY_BYTES / MAX_EXECUTABLE_IDENTITY_SIZE {
2452 agent
2453 .store_executable_identity(
2454 directory.path().join(format!("rustc-{index}")),
2455 BTreeMap::new(),
2456 vec![b'x'; MAX_EXECUTABLE_IDENTITY_SIZE],
2457 )
2458 .unwrap();
2459 }
2460
2461 assert!(
2462 agent
2463 .store_executable_identity(
2464 directory.path().join("one-byte-too-many"),
2465 BTreeMap::new(),
2466 vec![b'x'],
2467 )
2468 .is_err()
2469 );
2470 }
2471
2472 #[tokio::test]
2473 async fn version_skew_is_a_handshake_miss() {
2474 let directory = tempfile::tempdir().unwrap();
2475 let agent = CacheAgent::new(directory.path(), "agent-version");
2476 let (mut client, server) = tokio::io::duplex(1024);
2477 let task = tokio::spawn(async move { agent.handle_connection(server).await });
2478 let request = AgentRequest::Hello {
2479 protocol: AGENT_PROTOCOL_VERSION,
2480 client_version: "other-version".into(),
2481 };
2482 let mut encoded = serde_json::to_vec(&request).unwrap();
2483 encoded.push(b'\n');
2484 client.write_all(&encoded).await.unwrap();
2485 let mut response = String::new();
2486 BufReader::new(&mut client)
2487 .read_line(&mut response)
2488 .await
2489 .unwrap();
2490
2491 assert!(matches!(
2492 serde_json::from_str(&response).unwrap(),
2493 AgentResponse::Error { .. }
2494 ));
2495 task.await.unwrap().unwrap();
2496 }
2497}