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