Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-worker/src/directory_cache.rs
Line
Count
Source
1
// Copyright 2024 Trace Machina, Inc. All rights reserved.
2
//
3
// Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//    See LICENSE file for details
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14
15
use core::future::Future;
16
use core::pin::Pin;
17
use core::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
18
use std::collections::HashMap;
19
use std::path::{Path, PathBuf};
20
use std::sync::Arc;
21
use std::time::{SystemTime, UNIX_EPOCH};
22
23
use futures::StreamExt;
24
use futures::future::BoxFuture;
25
use futures::stream::TryStreamExt;
26
use nativelink_error::{Code, Error, ResultExt, make_err};
27
use nativelink_metric::{
28
    MetricFieldData, MetricKind, MetricPublishKnownKindData, MetricsComponent, group, publish,
29
};
30
use nativelink_proto::build::bazel::remote::execution::v2::{
31
    Directory as ProtoDirectory, DirectoryNode, FileNode, GetTreeRequest, SymlinkNode,
32
};
33
use nativelink_store::ac_utils::get_and_decode_digest;
34
use nativelink_store::cas_utils::is_zero_digest;
35
use nativelink_store::fast_slow_store::FastSlowStore;
36
use nativelink_store::filesystem_store::{FileEntry, FilesystemStore};
37
use nativelink_store::grpc_store::GrpcStore;
38
use nativelink_util::background_spawn;
39
use nativelink_util::common::DigestInfo;
40
use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc, default_digest_hasher_func};
41
use nativelink_util::fs_util::{CloneMethod, hardlink_directory_tree, set_dir_writable_recursive};
42
use nativelink_util::store_trait::{StoreKey, StoreLike};
43
use prost::Message;
44
use tokio::fs;
45
46
/// Maximum number of concurrently-polled node materializations (file
47
/// fetches, subdirectory recursions, symlink creations) per directory
48
/// level. Matches `DOWNLOAD_TO_DIRECTORY_CONCURRENCY` in
49
/// `running_actions_manager.rs`: enough parallelism to overlap slow-store
50
/// round trips, bounded so hardlink/copy syscalls do not fight the
51
/// filesystem's metadata locks (see the gate comment in
52
/// `download_to_directory`).
53
const CONSTRUCT_DIRECTORY_CONCURRENCY: usize = 64;
54
55
/// Default for `DirectoryCacheConfig::max_concurrent_fetches`, preserving
56
/// the historical cache-wide fetch bound.
57
const DEFAULT_MAX_CONCURRENT_FETCHES: usize = 64;
58
use tokio::sync::{Mutex, RwLock, Semaphore};
59
use tracing::{debug, info, trace, warn};
60
61
/// Prefix for in-progress construction trees under `cache_root`. Cache-entry
62
/// names are digest strings (hex), so dot-prefixed scratch names can never
63
/// collide with a published entry.
64
const TEMP_PREFIX: &str = ".tmp-";
65
66
/// Prefix for eviction tombstones awaiting background deletion.
67
const TOMBSTONE_PREFIX: &str = ".del-";
68
69
/// Minimum interval between summary log lines, in nanoseconds (one minute).
70
const SUMMARY_LOG_INTERVAL_NANOS: u64 = 60_000_000_000;
71
72
/// Current time as nanoseconds since the unix epoch, for lock-free LRU
73
/// timestamps. Saturates instead of failing (`u64` nanos overflow in 2554).
74
367
fn unix_nanos_now() -> u64 {
75
367
    SystemTime::now()
76
367
        .duration_since(UNIX_EPOCH)
77
367
        .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
78
367
}
79
80
/// Configuration for the directory cache
81
#[derive(Debug, Clone)]
82
pub struct DirectoryCacheConfig {
83
    /// Maximum number of cached directories
84
    pub max_entries: usize,
85
    /// Maximum total size in bytes (0 = unlimited)
86
    pub max_size_bytes: u64,
87
    /// Base directory for cache storage
88
    pub cache_root: PathBuf,
89
    /// Experimental: additionally cache every subdirectory by its own
90
    /// `Directory` digest so unchanged subtrees are shared across different
91
    /// root digests. Subtree entries participate in normal eviction and
92
    /// multiply the entry count, so `max_entries` should be raised when
93
    /// enabling this. Default: false (root-only caching, existing behavior).
94
    pub experimental_subtree_caching: bool,
95
    /// Maximum number of concurrent slow-store fetches across ALL directory
96
    /// constructions of this cache. Permits are held only across leaf I/O
97
    /// (directory-proto fetches, blob fetches/populates) and never across
98
    /// subdirectory recursion, so recursion depth cannot deadlock the
99
    /// semaphore. This is the bound that protects backing stores from RPC
100
    /// storms: per-level construction concurrency compounds multiplicatively
101
    /// across tree levels and concurrent actions (measured >1800 in-flight
102
    /// fetches for 6 concurrent ~500-file trees without this cap). Low
103
    /// values fragment coalesced/batched reads to at most this many items;
104
    /// see the config-crate doc for the `experimental_read_batching`
105
    /// interaction. Must be > 0. Default: 64.
106
    pub max_concurrent_fetches: usize,
107
    /// See `nativelink-config`'s `DirectoryCacheConfig::experimental_get_tree_prefetch`.
108
    pub experimental_get_tree_prefetch: bool,
109
}
110
111
/// Receives every CAS digest before the directory cache starts materializing
112
/// that node. Action workers use this hook to keep the digest pinned in their
113
/// local eviction-managed CAS tiers for the duration of the action.
114
pub trait DigestLease: Send + Sync {
115
    fn acquire(&self, digest: &DigestInfo);
116
}
117
118
2.00k
fn acquire_digest(lease: Option<&dyn DigestLease>, digest: &DigestInfo) {
119
2.00k
    if let Some(
lease14
) = lease {
120
14
        lease.acquire(digest);
121
1.98k
    }
122
2.00k
}
123
124
impl Default for DirectoryCacheConfig {
125
34
    fn default() -> Self {
126
34
        Self {
127
34
            max_entries: 1000,
128
34
            max_size_bytes: 10 * 1024 * 1024 * 1024, // 10 GB
129
34
            cache_root: std::env::temp_dir().join("nativelink_directory_cache"),
130
34
            experimental_subtree_caching: false,
131
34
            max_concurrent_fetches: DEFAULT_MAX_CONCURRENT_FETCHES,
132
34
            experimental_get_tree_prefetch: false,
133
34
        }
134
34
    }
135
}
136
137
/// Metadata for a cached directory
138
#[derive(Debug)]
139
struct CachedDirectoryMetadata {
140
    /// Path to the cached directory
141
    path: PathBuf,
142
    /// Recorded size in bytes. With subtree caching enabled this covers only
143
    /// bytes not owned by a descendant entry, so the sum over the map
144
    /// approximates unique materialized bytes.
145
    size: u64,
146
    /// Last access time (nanoseconds since the unix epoch) for LRU eviction.
147
    /// Atomic so cache hits can refresh it under the cache READ lock instead
148
    /// of serializing on the write lock.
149
    last_access: AtomicU64,
150
    /// Number of active users. Shared with the [`EntryPin`] RAII guards
151
    /// handed out by `acquire_entry`, which decrement it on drop — even when
152
    /// the future holding the pin is cancelled mid-await. Eviction skips
153
    /// entries with a non-zero count.
154
    ref_count: Arc<AtomicUsize>,
155
}
156
157
/// RAII pin on a cache entry: while alive, eviction will not select the
158
/// entry, so its on-disk tree cannot be deleted out from under an unlocked
159
/// materialization. Dropping the pin releases it — including implicitly when
160
/// a construction future is cancelled (e.g. `buffer_unordered` dropping
161
/// sibling futures after another node errors), so pins can never leak and
162
/// permanently block eviction.
163
#[derive(Debug)]
164
struct EntryPin {
165
    ref_count: Arc<AtomicUsize>,
166
}
167
168
impl Drop for EntryPin {
169
177
    fn drop(&mut self) {
170
177
        self.ref_count.fetch_sub(1, Ordering::SeqCst);
171
177
    }
172
}
173
174
/// A prepared tree held against eviction for the lifetime of this handle.
175
/// A cache hit can return this handle without walking or recreating the tree.
176
/// Consumers must expose the prepared tree read-only.
177
#[derive(Debug)]
178
pub struct PreparedDirectory {
179
    path: PathBuf,
180
    size: u64,
181
    pin: EntryPin,
182
}
183
184
impl PreparedDirectory {
185
128
    pub fn path(&self) -> &Path {
186
128
        &self.path
187
128
    }
188
}
189
190
/// Drop guard for an in-progress temp construction tree: if the owning
191
/// future is dropped before [`Self::disarm`] (cancellation — e.g.
192
/// `buffer_unordered` drops sibling constructions on the first error), the
193
/// partial tree is deleted in the background. Error paths instead disarm and
194
/// clean up inline so a failed construction leaves nothing behind by the
195
/// time the error propagates.
196
#[derive(Debug)]
197
struct ScratchGuard {
198
    path: Option<PathBuf>,
199
}
200
201
impl ScratchGuard {
202
123
    const fn new(path: PathBuf) -> Self {
203
123
        Self { path: Some(path) }
204
123
    }
205
206
120
    fn disarm(&mut self) {
207
120
        self.path = None;
208
120
    }
209
}
210
211
impl Drop for ScratchGuard {
212
123
    fn drop(&mut self) {
213
123
        if let Some(
path3
) = self.path.take() {
214
3
            DirectoryCache::dispatch_evictions(vec![path]);
215
120
        }
216
123
    }
217
}
218
219
/// High-performance directory cache that uses hardlinks to avoid repeated
220
/// directory reconstruction from the CAS.
221
///
222
/// When actions need input directories, instead of fetching and reconstructing
223
/// files from the CAS each time, we:
224
/// 1. Check if we've already constructed this exact directory (by digest)
225
/// 2. If yes, hardlink the entire tree to the action's workspace
226
/// 3. If no, construct it once and cache for future use
227
///
228
/// This dramatically reduces I/O and improves action startup time.
229
#[derive(Debug)]
230
pub struct DirectoryCache {
231
    /// Configuration
232
    config: DirectoryCacheConfig,
233
    /// Cache mapping digest -> metadata
234
    cache: Arc<RwLock<HashMap<DigestInfo, CachedDirectoryMetadata>>>,
235
    /// Lock for cache construction to prevent stampedes
236
    construction_locks: Arc<Mutex<HashMap<DigestInfo, Arc<Mutex<()>>>>>,
237
    /// CAS store for fetching directory protos and (fallback) file content.
238
    cas_store: Arc<FastSlowStore>,
239
    /// The `FastSlowStore`'s fast tier, if it is a `FilesystemStore`. When
240
    /// present, CAS blobs can be hardlinked directly into the cache entry
241
    /// (zero-copy) instead of fetched into RAM and rewritten. When absent
242
    /// (e.g. an unusual store layout) the cache falls back to fetch+write.
243
    filesystem_store: Option<Arc<FilesystemStore>>,
244
    /// Global slow-fetch budget shared by all constructions of this cache;
245
    /// see `DirectoryCacheConfig::max_concurrent_fetches`.
246
    fetch_permits: Semaphore,
247
    /// Count of materializations that used APFS `clonefile(2)` (macOS only;
248
    /// always zero on other platforms).
249
    clonefile_hits: AtomicU64,
250
    /// Count of materializations that used per-file `fs::hard_link`.
251
    hardlink_hits: AtomicU64,
252
    /// Count of subtree materializations served from the cache by the
253
    /// subtree's own `Directory` digest. Only ever non-zero when
254
    /// `experimental_subtree_caching` is enabled.
255
    subtree_hits: AtomicU64,
256
    /// Count of subtree constructions that could not be served from the
257
    /// cache. Only ever non-zero when `experimental_subtree_caching` is
258
    /// enabled.
259
    subtree_misses: AtomicU64,
260
    /// Count of entries removed by LRU eviction. A high rate relative to
261
    /// hits means the cache is thrashing (`max_entries`/`max_size_bytes` too
262
    /// small for the workload — especially with subtree caching enabled,
263
    /// which multiplies the entry count).
264
    evictions: AtomicU64,
265
    /// Monotonic counter for process-unique scratch names (temp construction
266
    /// trees and eviction tombstones) under `cache_root`.
267
    scratch_seq: AtomicU64,
268
    /// Mirror of the map's entry count, maintained at insert/evict/invalidate
269
    /// so the sync `MetricsComponent::publish` and the rate-limited log
270
    /// summary can read it without touching the async `RwLock`.
271
    map_entries: AtomicU64,
272
    /// Mirror of the map's total recorded size in bytes (see `map_entries`).
273
    map_size_bytes: AtomicU64,
274
    /// Timestamp (unix nanos) of the last summary log line, for rate
275
    /// limiting.
276
    last_summary_log: AtomicU64,
277
}
278
279
impl DirectoryCache {
280
    /// Creates a new `DirectoryCache`.
281
    ///
282
    /// `cas_store` is the worker's `FastSlowStore`. Its fast tier is expected
283
    /// to be a `FilesystemStore`; when it is, `construct_directory` hardlinks
284
    /// CAS blobs directly into the cache entry instead of copying them.
285
34
    pub async fn new(
286
34
        config: DirectoryCacheConfig,
287
34
        cas_store: Arc<FastSlowStore>,
288
34
    ) -> Result<Self, Error> {
289
        // A zero-permit semaphore would deadlock every construction.
290
34
        if config.max_concurrent_fetches == 0 {
291
0
            return Err(make_err!(
292
0
                Code::InvalidArgument,
293
0
                "directory_cache max_concurrent_fetches must be greater than 0"
294
0
            ));
295
34
        }
296
34
        let max_concurrent_fetches = config.max_concurrent_fetches;
297
298
        // Ensure cache root exists
299
34
        fs::create_dir_all(&config.cache_root).await.err_tip(|| 
{0
300
0
            format!(
301
                "Failed to create cache root: {}",
302
0
                config.cache_root.display()
303
            )
304
0
        })?;
305
306
        // Mirror RunningActionsManagerImpl: the fast tier is normally a
307
        // FilesystemStore. If the downcast fails the cache still works — it
308
        // just falls back to the fetch+write path for every file.
309
34
        let filesystem_store = cas_store
310
34
            .fast_store()
311
34
            .downcast_ref::<FilesystemStore>(None)
312
34
            .and_then(FilesystemStore::get_arc);
313
34
        if filesystem_store.is_none() {
314
0
            warn!(
315
                "DirectoryCache fast store is not a FilesystemStore; \
316
                 CAS blobs will be copied instead of hardlinked"
317
            );
318
34
        }
319
320
34
        let cache = Self {
321
34
            config,
322
34
            cache: Arc::new(RwLock::new(HashMap::new())),
323
34
            construction_locks: Arc::new(Mutex::new(HashMap::new())),
324
34
            cas_store,
325
34
            filesystem_store,
326
34
            fetch_permits: Semaphore::new(max_concurrent_fetches),
327
34
            clonefile_hits: AtomicU64::new(0),
328
34
            hardlink_hits: AtomicU64::new(0),
329
34
            subtree_hits: AtomicU64::new(0),
330
34
            subtree_misses: AtomicU64::new(0),
331
34
            evictions: AtomicU64::new(0),
332
34
            scratch_seq: AtomicU64::new(0),
333
34
            map_entries: AtomicU64::new(0),
334
34
            map_size_bytes: AtomicU64::new(0),
335
34
            last_summary_log: AtomicU64::new(0),
336
34
        };
337
34
        cache.sweep_cache_root().await;
338
34
        Ok(cache)
339
34
    }
340
341
    /// Sweeps pre-existing content of `cache_root`. The in-memory map starts
342
    /// empty, so anything already on disk is orphaned: entries from a
343
    /// previous process (unusable — they are not in the map, and a partial
344
    /// one would poison re-construction of its digest), plus stale temp
345
    /// trees and tombstones. Each orphan is renamed to a tombstone
346
    /// synchronously — after `new` returns, a fresh construction could
347
    /// otherwise publish to the very canonical path a detached deletion is
348
    /// about to remove — and the tombstones are deleted in the background.
349
34
    async fn sweep_cache_root(&self) {
350
34
        let mut orphans = Vec::new();
351
34
        match fs::read_dir(&self.config.cache_root).await {
352
34
            Ok(mut dir) => loop {
353
34
                match dir.next_entry().await {
354
0
                    Ok(Some(entry)) => orphans.push(entry.path()),
355
34
                    Ok(None) => break,
356
0
                    Err(e) => {
357
0
                        warn!(error = ?e, "Failed reading cache root during orphan sweep");
358
0
                        break;
359
                    }
360
                }
361
            },
362
0
            Err(e) => {
363
0
                warn!(error = ?e, "Failed to scan cache root for orphaned entries");
364
0
                return;
365
            }
366
        }
367
34
        if orphans.is_empty() {
368
34
            return;
369
0
        }
370
0
        debug!(
371
0
            count = orphans.len(),
372
            "Sweeping orphaned directory cache content"
373
        );
374
0
        let tombstones = self.tombstone_victims(orphans).await;
375
0
        Self::dispatch_evictions(tombstones);
376
34
    }
377
378
    /// Acquires a permit from the cache-wide slow-fetch budget. Held only
379
    /// across leaf I/O awaits, never across subdirectory recursion.
380
1.66k
    async fn acquire_fetch_permit(&self) -> Result<tokio::sync::SemaphorePermit<'_>, Error> {
381
1.66k
        self.fetch_permits
382
1.66k
            .acquire()
383
1.66k
            .await
384
1.66k
            .map_err(|e| 
make_err!0
(
Code::Internal0
, "Fetch semaphore closed: {e:?}"))
385
1.66k
    }
386
387
    /// Emits an `info!` summary of the cache counters, rate-limited to at
388
    /// most one line per [`SUMMARY_LOG_INTERVAL_NANOS`]. Called from the
389
    /// hit/miss path so deployments without a metrics pipeline can still
390
    /// verify the cache is working by grepping worker logs.
391
190
    fn maybe_log_summary(&self) {
392
190
        let now = unix_nanos_now();
393
190
        let last = self.last_summary_log.load(Ordering::Relaxed);
394
190
        if now.saturating_sub(last) < SUMMARY_LOG_INTERVAL_NANOS {
395
158
            return;
396
32
        }
397
        // Single winner per interval; losers skip the log line.
398
32
        if self
399
32
            .last_summary_log
400
32
            .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
401
32
            .is_err()
402
        {
403
0
            return;
404
32
        }
405
32
        info!(
406
32
            clonefile_hits = self.clonefile_hits.load(Ordering::Relaxed),
407
32
            hardlink_hits = self.hardlink_hits.load(Ordering::Relaxed),
408
32
            subtree_hits = self.subtree_hits.load(Ordering::Relaxed),
409
32
            subtree_misses = self.subtree_misses.load(Ordering::Relaxed),
410
32
            evictions = self.evictions.load(Ordering::Relaxed),
411
32
            entries = self.map_entries.load(Ordering::Relaxed),
412
32
            total_size_bytes = self.map_size_bytes.load(Ordering::Relaxed),
413
            "DirectoryCache summary"
414
        );
415
190
    }
416
417
    /// Records which kernel mechanism materialized a tree, for observability.
418
147
    fn record_clone_method(&self, method: CloneMethod) {
419
147
        let counter = match method {
420
0
            CloneMethod::Clonefile => &self.clonefile_hits,
421
147
            CloneMethod::Hardlink => &self.hardlink_hits,
422
        };
423
147
        counter.fetch_add(1, Ordering::Relaxed);
424
147
    }
425
426
    /// Gets or creates a directory in the cache, then hardlinks it to the destination
427
    ///
428
    /// # Arguments
429
    /// * `digest` - Digest of the root Directory proto
430
    /// * `dest_path` - Where to hardlink/create the directory
431
    ///
432
    /// # Returns
433
    /// * `Ok(true)` - Cache hit (directory was hardlinked)
434
    /// * `Ok(false)` - Cache miss (directory was constructed)
435
    /// * `Err` - Error during construction or hardlinking
436
85
    pub async fn get_or_create(&self, digest: DigestInfo, dest_path: &Path) -> Result<bool, Error> {
437
85
        self.get_or_create_with_lease(digest, dest_path, None).await
438
85
    }
439
440
    /// Gets or creates a cached directory while optionally reserving each CAS
441
    /// digest used by a construction.
442
    ///
443
    /// Existing cache hits only hardlink from the already-materialized cache
444
    /// entry, whose own pin protects the source tree. On a miss, the lease is
445
    /// called before the root, file, and child-directory digests are fetched
446
    /// or populated, closing the admission-to-eviction race for active
447
    /// actions.
448
87
    pub async fn get_or_create_with_lease(
449
87
        &self,
450
87
        digest: DigestInfo,
451
87
        dest_path: &Path,
452
87
        lease: Option<&dyn DigestLease>,
453
87
    ) -> Result<bool, Error> {
454
87
        let (
hit81
,
_size81
) = self
455
87
            .get_or_create_entry(digest, dest_path, None, true, lease)
456
87
            .await
?6
;
457
81
        Ok(hit)
458
87
    }
459
460
    /// Acquires a reusable tree without copying it to an action workspace.
461
    /// The returned handle must outlive every mount or other use of its path.
462
26
    pub async fn prepare_for_mount(
463
26
        &self,
464
26
        digest: DigestInfo,
465
26
        lease: Option<&dyn DigestLease>,
466
26
    ) -> Result<PreparedDirectory, Error> {
467
26
        self.maybe_log_summary();
468
26
        acquire_digest(lease, &digest);
469
26
        if let Some(
prepared5
) = self.acquire_entry(&digest).await {
470
5
            nativelink_util::metrics::INPUT_MOUNT_METRICS
471
5
                .cache_hits
472
5
                .add(1, &[]);
473
5
            return Ok(prepared);
474
21
        }
475
21
        let lock = {
476
21
            let mut locks = self.construction_locks.lock().await;
477
21
            locks
478
21
                .entry(digest)
479
21
                .or_insert_with(|| 
Arc::new6
(
Mutex::new6
(
()6
)))
480
21
                .clone()
481
        };
482
21
        let _guard = lock.lock().await;
483
21
        let result = if let Some(
prepared15
) = self.acquire_entry(&digest).await {
484
15
            nativelink_util::metrics::INPUT_MOUNT_METRICS
485
15
                .cache_hits
486
15
                .add(1, &[]);
487
15
            Ok(prepared)
488
        } else {
489
6
            nativelink_util::metrics::INPUT_MOUNT_METRICS
490
6
                .cache_misses
491
6
                .add(1, &[]);
492
6
            self.construct_entry(digest, None, true, lease).await
493
        };
494
21
        self.forget_construction_lock(&digest).await;
495
21
        result
496
26
    }
497
498
    /// Core get-or-create flow shared by root materializations
499
    /// ([`Self::get_or_create`]) and, with `experimental_subtree_caching`
500
    /// enabled, subtree materializations (`create_subdirectory`). Returns
501
    /// `(hit, size)` where `hit` is true when the destination was served by
502
    /// hardlinking an existing cache entry, and `size` is the entry's
503
    /// recorded size in bytes.
504
    ///
505
    /// `protos` is a map of prefetched `Directory` protos consulted during
506
    /// construction (see [`Self::prefetch_tree_protos`]); `prefetch_on_miss`
507
    /// is true only for root-level calls, which issue the tree's logical
508
    /// `GetTree` prefetch on a cold miss. Subtree flights pass the root's
509
    /// map down instead, so cache hits (root or subtree) never prefetch. A
510
    /// server may paginate that traversal across multiple RPCs.
511
164
    async fn get_or_create_entry(
512
164
        &self,
513
164
        digest: DigestInfo,
514
164
        dest_path: &Path,
515
164
        protos: Option<&HashMap<DigestInfo, ProtoDirectory>>,
516
164
        prefetch_on_miss: bool,
517
164
        lease: Option<&dyn DigestLease>,
518
164
    ) -> Result<(bool, u64), Error> {
519
164
        self.maybe_log_summary();
520
164
        acquire_digest(lease, &digest);
521
522
        // Fast path: serve from an existing entry.
523
164
        if let Some(
size11
) = self.try_materialize_from_cache(&digest, dest_path).await
?1
{
524
11
            return Ok((true, size));
525
152
        }
526
527
152
        debug!(?digest, "Directory cache MISS");
528
529
        // Single-flight: only one task constructs a given digest at a time.
530
        // Concurrent callers for the same digest block on this per-digest
531
        // mutex; when the constructor finishes and inserts the entry, the
532
        // waiters wake, find it in the cache, and materialize their own
533
        // destination from the single shared cache entry. Root and subtree
534
        // flights for the same digest share one mutex, and the lock order is
535
        // deadlock-free by construction: a flight only ever waits on locks
536
        // of its strict descendants, and the content-addressed Merkle DAG is
537
        // acyclic.
538
152
        let construction_lock = {
539
152
            let mut locks = self.construction_locks.lock().await;
540
152
            locks
541
152
                .entry(digest)
542
152
                .or_insert_with(|| 
Arc::new114
(
Mutex::new114
(
()114
)))
543
152
                .clone()
544
        };
545
152
        let _guard = construction_lock.lock().await;
546
547
        // Run the construction/materialization under the per-digest guard,
548
        // then remove its lock from the map if no other callers are waiting.
549
        // Waiters must keep sharing this flight even if construction failed.
550
152
        let 
result149
= self
551
152
            .construct_and_materialize(digest, dest_path, protos, prefetch_on_miss, lease)
552
152
            .await;
553
149
        self.forget_construction_lock(&digest).await;
554
149
        result
555
161
    }
556
557
    /// Attempts to serve `digest` from an existing cache entry by hardlinking
558
    /// it to `dest_path`, returning the entry's recorded size on success.
559
    ///
560
    /// The entry is pinned (see [`EntryPin`]) across the unlocked
561
    /// `hardlink_directory_tree` so eviction cannot delete its tree
562
    /// mid-materialization; only the brief refcount/LRU bookkeeping runs
563
    /// under the cache lock (the READ lock — hits never serialize on the
564
    /// write lock).
565
    ///
566
    /// Returns `None` when the digest is not cached, or when the entry's
567
    /// on-disk tree failed to hardlink (missing/corrupt). In the failure
568
    /// case the dead entry has been invalidated (removed + tombstoned, so it
569
    /// cannot fail every future request forever) and the possibly partially
570
    /// populated destination cleared, so the caller can construct fresh.
571
    /// If another caller still holds the entry, return the error without
572
    /// invalidating it: that caller may be using its tree through a mount.
573
316
    async fn try_materialize_from_cache(
574
316
        &self,
575
316
        digest: &DigestInfo,
576
316
        dest_path: &Path,
577
316
    ) -> Result<Option<u64>, Error> {
578
        let Some(PreparedDirectory {
579
50
            path: cache_path,
580
50
            size,
581
50
            pin,
582
316
        }) = self.acquire_entry(digest).await
583
        else {
584
266
            return Ok(None);
585
        };
586
50
        debug!(?digest, ?cache_path, "Directory cache HIT");
587
50
        match hardlink_directory_tree(&cache_path, dest_path).await {
588
46
            Ok(method) => {
589
46
                drop(pin);
590
46
                self.record_clone_method(method);
591
46
                Ok(Some(size))
592
            }
593
4
            Err(e) => {
594
4
                if !self.invalidate_entry(digest, &pin).await {
595
1
                    return Err(e).err_tip(|| "Cannot rebuild a directory while it is in use");
596
3
                }
597
3
                warn!(
598
                    ?digest,
599
                    error = ?e,
600
                    "Failed to hardlink from cache, invalidating entry and reconstructing"
601
                );
602
3
                drop(pin);
603
                // The failed walk may have partially populated the
604
                // destination; clear it so the reconstruction starts clean
605
                // (`hardlink_directory_tree` refuses an existing destination
606
                // and `create_file` fails on leftovers).
607
3
                Self::remove_tree_best_effort(dest_path).await;
608
3
                Ok(None)
609
            }
610
        }
611
316
    }
612
613
    /// The cache-miss body, run while holding the per-digest construction
614
    /// guard. Split out so `get_or_create_entry` can retire an unused
615
    /// construction lock after either success or failure.
616
    /// `protos` and `prefetch_on_miss` are documented on
617
    /// [`Self::get_or_create_entry`].
618
152
    async fn construct_and_materialize(
619
152
        &self,
620
152
        digest: DigestInfo,
621
152
        dest_path: &Path,
622
152
        protos: Option<&HashMap<DigestInfo, ProtoDirectory>>,
623
152
        prefetch_on_miss: bool,
624
152
        lease: Option<&dyn DigestLease>,
625
152
    ) -> Result<(bool, u64), Error> {
626
        // Re-check: another task may have just constructed this digest while
627
        // we waited on the construction lock. If the entry turns out to be
628
        // damaged it is invalidated and we fall through to rebuild it fresh
629
        // (exactly once — we hold the construction lock).
630
152
        if let Some(
size35
) = self.try_materialize_from_cache(&digest, dest_path).await
?0
{
631
35
            return Ok((true, size));
632
117
        }
633
634
117
        let 
prepared101
= self
635
117
            .construct_entry(digest, protos, prefetch_on_miss, lease)
636
117
            .await
?13
;
637
101
        let method = hardlink_directory_tree(prepared.path(), dest_path)
638
101
            .await
639
101
            .err_tip(|| "Failed to hardlink newly cached directory")
?0
;
640
101
        self.record_clone_method(method);
641
101
        Ok((false, prepared.size))
642
149
    }
643
644
    /// Constructs and publishes a pinned entry, without copying it elsewhere.
645
123
    async fn construct_entry(
646
123
        &self,
647
123
        digest: DigestInfo,
648
123
        protos: Option<&HashMap<DigestInfo, ProtoDirectory>>,
649
123
        prefetch_on_miss: bool,
650
123
        lease: Option<&dyn DigestLease>,
651
123
    ) -> Result<PreparedDirectory, Error> {
652
        // Construct the directory into a UNIQUE temp path first, then
653
        // atomically publish it to the canonical `cache_root/<digest>` path
654
        // via rename. Constructing at the canonical path directly would
655
        // poison the digest on failure: the partial tree left behind makes
656
        // every future construction of the digest fail on its leftovers —
657
        // and subtree digests are stable across builds, so one poisoned
658
        // popular subtree would fail every subsequent build.
659
        //
660
        // `construct_directory` returns the total tree size accumulated from
661
        // `FileNode.digest.size_bytes` as it builds — no post-hoc filesystem
662
        // walk is needed. It also sets every cache-entry directory's mode at
663
        // creation time (0o755), so no separate permission-fixup walk is
664
        // needed either.
665
        //
666
        // The cache entry's *files* are deliberately never chmod'd here:
667
        // non-executable files are hardlinks to FilesystemStore CAS blobs (see
668
        // `create_file`), and chmoding such a file mutates the inode shared
669
        // with the CAS and every other in-flight action that hardlinked the
670
        // same blob — the inode-corruption bug PR #2347 fixed.
671
123
        let cache_path = self.get_cache_path(&digest);
672
        // Root-level cold miss: prefetch the whole tree's `Directory` protos
673
        // with one logical `GetTree` traversal, following server pagination.
674
        // Subtree flights never prefetch — they inherit the root's map (or
675
        // `None`) through `protos` instead.
676
123
        let prefetched = if prefetch_on_miss {
677
52
            Box::pin(self.prefetch_tree_protos(digest)).await
678
        } else {
679
71
            None
680
        };
681
123
        let protos = prefetched.as_ref().or(protos);
682
123
        let temp_path = self.allocate_scratch_path(TEMP_PREFIX);
683
123
        let mut temp_guard = ScratchGuard::new(temp_path.clone());
684
123
        let 
size106
= match self
685
123
            .construct_directory(digest, &temp_path, protos, lease)
686
123
            .await
687
        {
688
106
            Ok(size) => size,
689
14
            Err(e) => {
690
14
                temp_guard.disarm();
691
14
                Self::remove_tree_best_effort(&temp_path).await;
692
14
                return Err(e);
693
            }
694
        };
695
696
        // Publish. A pre-existing canonical dir can only be crash leftovers
697
        // from a previous process that the startup sweep has not yet
698
        // removed: live entries are tracked in the map (this digest has no
699
        // entry — we hold its construction lock and just re-checked), and
700
        // eviction renames trees to tombstones under the cache write lock
701
        // before deleting them.
702
106
        Self::remove_tree_best_effort(&cache_path).await;
703
106
        temp_guard.disarm();
704
106
        if let Err(
e0
) = fs::rename(&temp_path, &cache_path).await {
705
0
            Self::remove_tree_best_effort(&temp_path).await;
706
0
            return Err(e)
707
0
                .err_tip(|| format!("Failed to publish cache entry: {}", cache_path.display()));
708
106
        }
709
710
        // Insert into the cache. The in-memory map mutation and the
711
        // metadata-cheap tombstone renames of evicted victims run under the
712
        // write lock (see `tombstone_victims` for why the renames must);
713
        // the expensive filesystem deletion is dispatched off the lock so
714
        // eviction I/O never serializes other callers.
715
        //
716
        // Publish already pinned: a concurrent insertion must not evict the
717
        // tree before its caller has finished copying or mounting it. The
718
        // returned handle keeps the pin alive for the entire use.
719
106
        let ref_count = Arc::new(AtomicUsize::new(1));
720
106
        let pin = EntryPin {
721
106
            ref_count: Arc::clone(&ref_count),
722
106
        };
723
106
        let tombstones = {
724
106
            let mut cache = self.cache.write().await;
725
106
            let victims = self.evict_if_needed(size, &mut cache);
726
106
            let replaced = cache.insert(
727
106
                digest,
728
106
                CachedDirectoryMetadata {
729
106
                    path: cache_path.clone(),
730
106
                    size,
731
106
                    last_access: AtomicU64::new(unix_nanos_now()),
732
106
                    ref_count,
733
106
                },
734
106
            );
735
            // Maintain the lock-free metric mirrors. A replaced entry cannot
736
            // happen (we hold the construction lock and just re-checked), but
737
            // account for it defensively so the mirrors can never drift.
738
106
            if let Some(
old0
) = replaced {
739
0
                self.map_size_bytes.fetch_sub(old.size, Ordering::Relaxed);
740
106
            } else {
741
106
                self.map_entries.fetch_add(1, Ordering::Relaxed);
742
106
            }
743
106
            self.map_size_bytes.fetch_add(size, Ordering::Relaxed);
744
106
            self.tombstone_victims(victims).await
745
        };
746
106
        Self::dispatch_evictions(tombstones);
747
748
106
        Ok(PreparedDirectory {
749
106
            path: cache_path,
750
106
            size,
751
106
            pin,
752
106
        })
753
120
    }
754
755
    /// Removes a cache entry whose on-disk tree failed to materialize
756
    /// (missing or corrupt), renames the tree to a tombstone, and deletes it
757
    /// in the background — the same mechanism eviction uses. Without this, a
758
    /// damaged entry would stay in the map and fail every future request for
759
    /// its digest until it happened to be evicted.
760
    ///
761
    /// `pin` is the guard returned by `acquire_entry` for the failed
762
    /// attempt: invalidation is skipped if the map now holds a DIFFERENT
763
    /// entry for this digest (single-flight already rebuilt it), identified
764
    /// by refcount-handle pointer identity.
765
    /// Returns `false` if another caller still pins the entry.
766
4
    async fn invalidate_entry(&self, digest: &DigestInfo, pin: &EntryPin) -> bool {
767
3
        let tombstones = {
768
4
            let mut cache = self.cache.write().await;
769
4
            let is_same_entry = cache
770
4
                .get(digest)
771
4
                .is_some_and(|m| Arc::ptr_eq(&m.ref_count, &pin.ref_count));
772
4
            if !is_same_entry {
773
0
                return true;
774
4
            }
775
            // The failed materialization owns one pin. Other pins may belong
776
            // to mounted actions, whose source must not be renamed or deleted.
777
            // Holding the write lock prevents new pins during this check.
778
4
            if pin.ref_count.load(Ordering::SeqCst) > 1 {
779
1
                return false;
780
3
            }
781
3
            let Some(metadata) = cache.remove(digest) else {
782
0
                return true;
783
            };
784
3
            self.map_entries.fetch_sub(1, Ordering::Relaxed);
785
3
            self.map_size_bytes
786
3
                .fetch_sub(metadata.size, Ordering::Relaxed);
787
3
            self.tombstone_victims(vec![metadata.path]).await
788
        };
789
3
        Self::dispatch_evictions(tombstones);
790
3
        true
791
4
    }
792
793
    /// Best-effort `remove_dir_all` that treats `NotFound` as success and
794
    /// only warns on other failures. Used to clear partially populated
795
    /// destinations before a retry, partially built temp trees after a
796
    /// failed construction, and stale crash leftovers before publishing.
797
123
    async fn remove_tree_best_effort(path: &Path) {
798
123
        if let Err(
e115
) = fs::remove_dir_all(path).await
799
115
            && e.kind() != std::io::ErrorKind::NotFound
800
        {
801
1
            warn!(?path, error = ?e, "Failed to remove partial directory tree");
802
122
        }
803
123
    }
804
805
    /// Allocates a process-unique scratch path under `cache_root` (temp
806
    /// construction trees, eviction tombstones). Unique within the process
807
    /// via a monotonic counter; leftovers from previous processes are
808
    /// removed by the startup sweep in [`Self::new`].
809
154
    fn allocate_scratch_path(&self, prefix: &str) -> PathBuf {
810
154
        let seq = self.scratch_seq.fetch_add(1, Ordering::Relaxed);
811
154
        self.config.cache_root.join(format!("{prefix}{seq}"))
812
154
    }
813
814
    /// Renames each evicted tree to a unique tombstone path and returns the
815
    /// tombstones for background deletion.
816
    ///
817
    /// MUST be called while still holding the cache write lock that removed
818
    /// the victims from the map (or before the cache is shared, as in the
819
    /// startup sweep). That ordering is what makes eviction race-free
820
    /// against re-construction of an evicted digest: a constructor only
821
    /// constructs after a cache re-check, the re-check needs the read lock,
822
    /// so it is ordered after these renames — the background deletion can
823
    /// therefore never touch a canonical path a fresh construction publishes
824
    /// to. The rename itself is metadata-only and cheap; the expensive
825
    /// `remove_dir_all` still happens off-lock in the background.
826
109
    async fn tombstone_victims(&self, victims: Vec<PathBuf>) -> Vec<PathBuf> {
827
109
        let mut tombstones = Vec::with_capacity(victims.len());
828
109
        for 
path31
in victims {
829
31
            let tombstone = self.allocate_scratch_path(TOMBSTONE_PREFIX);
830
31
            match fs::rename(&path, &tombstone).await {
831
28
                Ok(()) => tombstones.push(tombstone),
832
3
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
833
3
                    // Already gone from disk; nothing to delete.
834
3
                }
835
0
                Err(e) => {
836
0
                    warn!(
837
                        ?path,
838
                        error = ?e,
839
                        "Failed to tombstone evicted directory, deleting in place"
840
                    );
841
0
                    tombstones.push(path);
842
                }
843
            }
844
        }
845
109
        tombstones
846
109
    }
847
848
    /// If `digest` is cached, pins it against eviction and returns a
849
    /// handle holding its on-disk path, recorded size, and the RAII pin.
850
    /// Runs under the cache READ lock — the refcount and LRU timestamp are
851
    /// atomics, so hits do not serialize on the write lock. The increment is
852
    /// race-free against eviction because eviction requires the write lock,
853
    /// which excludes every read-lock holder: an entry with an outstanding
854
    /// pin is always observed with a non-zero count by `evict_lru`.
855
364
    async fn acquire_entry(&self, digest: &DigestInfo) -> Option<PreparedDirectory> {
856
364
        let cache = self.cache.read().await;
857
364
        let 
metadata71
= cache.get(digest)
?293
;
858
71
        metadata
859
71
            .last_access
860
71
            .store(unix_nanos_now(), Ordering::Relaxed);
861
71
        metadata.ref_count.fetch_add(1, Ordering::SeqCst);
862
71
        Some(PreparedDirectory {
863
71
            path: metadata.path.clone(),
864
71
            size: metadata.size,
865
71
            pin: EntryPin {
866
71
                ref_count: Arc::clone(&metadata.ref_count),
867
71
            },
868
71
        })
869
364
    }
870
871
    /// Removes an idle construction lock after the last caller finishes.
872
    /// Keep it mapped while another caller owns an `Arc`: after a failed
873
    /// construction that caller may retry before a cache entry exists, and
874
    /// giving new callers a different mutex would allow concurrent publication
875
    /// to the same path. The map lock prevents new clones during this check.
876
171
    async fn forget_construction_lock(&self, digest: &DigestInfo) {
877
171
        let mut locks = self.construction_locks.lock().await;
878
        // The map and this function's caller each own one strong reference.
879
171
        if locks
880
171
            .get(digest)
881
171
            .is_some_and(|lock| Arc::strong_count(lock) == 2)
882
120
        {
883
120
            locks.remove(digest);
884
120
        
}51
885
171
    }
886
887
    /// Constructs a directory from the CAS at the given path and returns the
888
    /// total size of the materialized tree in bytes.
889
    ///
890
    /// The size is accumulated from `FileNode.digest.size_bytes` in the
891
    /// `Directory` protos as the tree is built, rather than walking the
892
    /// filesystem afterwards with `fs::metadata` per file. Symlinks contribute
893
    /// nothing — a symlink's own inode is negligible and following it could
894
    /// double-count a file already counted via its `FileNode`.
895
    ///
896
    /// Each directory's final mode (0o755) is set at creation time, so no
897
    /// separate recursive permission pass is needed after construction.
898
    /// Prefetches every `Directory` proto of `root`'s tree with a logical
899
    /// `GetTree` traversal, following `next_page_token` when the server
900
    /// paginates it, and keys each proto by its re-computed digest (the
901
    /// responses carry protos, not digests). Returns `None` — meaning
902
    /// "use the per-level fetch path" — when the feature is disabled, the
903
    /// slow tier is not a `GrpcStore`, or the stream fails; a `Some` map
904
    /// may also be incomplete, which `construct_directory` tolerates by
905
    /// fetching any missing proto individually. One fetch permit covers
906
    /// the entire paginated traversal.
907
52
    async fn prefetch_tree_protos(
908
52
        &self,
909
52
        root: DigestInfo,
910
52
    ) -> Option<HashMap<DigestInfo, ProtoDirectory>> {
911
52
        if !self.config.experimental_get_tree_prefetch {
912
50
            return None;
913
2
        }
914
2
        let 
grpc_store1
= self
915
2
            .cas_store
916
2
            .slow_store()
917
2
            .downcast_ref::<GrpcStore>(None)
?1
;
918
1
        let digest_hasher = opentelemetry::Context::current()
919
1
            .get::<DigestHasherFunc>()
920
1
            .map_or_else(default_digest_hasher_func, |v| *v);
921
1
        let result: Result<HashMap<DigestInfo, ProtoDirectory>, Error> = async {
922
1
            let _permit = self.acquire_fetch_permit().await
?0
;
923
1
            let mut protos = HashMap::new();
924
1
            let mut page_token = String::new();
925
            loop {
926
2
                let mut stream = grpc_store
927
2
                    .get_tree(tonic::Request::new(GetTreeRequest {
928
2
                        instance_name: String::new(),
929
2
                        root_digest: Some(root.into()),
930
2
                        page_size: 0,
931
2
                        page_token,
932
2
                        digest_function: digest_hasher.proto_digest_func().into(),
933
2
                    }))
934
2
                    .await
935
2
                    .err_tip(|| "in prefetch_tree_protos")
?0
936
2
                    .into_inner();
937
2
                let mut next_page_token = String::new();
938
4
                while let Some(
response2
) = stream
939
4
                    .message()
940
4
                    .await
941
4
                    .map_err(Error::from)
942
4
                    .err_tip(|| "reading GetTree stream in prefetch_tree_protos")
?0
943
                {
944
2
                    next_page_token = response.next_page_token;
945
2
                    for directory in response.directories {
946
2
                        // GetTree yields protos without digests; recompute with
947
2
                        // the caller's digest function so lookups match the
948
2
                        // digests embedded in parent directories.
949
2
                        let encoded = directory.encode_to_vec();
950
2
                        let mut hasher = digest_hasher.hasher();
951
2
                        hasher.update(&encoded);
952
2
                        protos.insert(hasher.finalize_digest(), directory);
953
2
                    }
954
                }
955
2
                if next_page_token.is_empty() {
956
1
                    break;
957
1
                }
958
1
                page_token = next_page_token;
959
            }
960
1
            Ok(protos)
961
1
        }
962
1
        .await;
963
1
        match result {
964
1
            Ok(protos) => {
965
1
                trace!(?root, protos = protos.len(), "GetTree prefetch complete");
966
1
                Some(protos)
967
            }
968
0
            Err(err) => {
969
0
                debug!(
970
                    ?err,
971
                    ?root,
972
                    "GetTree prefetch failed; using per-level fetches"
973
                );
974
0
                None
975
            }
976
        }
977
52
    }
978
979
246
    fn construct_directory<'a>(
980
246
        &'a self,
981
246
        digest: DigestInfo,
982
246
        dest_path: &'a Path,
983
246
        protos: Option<&'a HashMap<DigestInfo, ProtoDirectory>>,
984
246
        lease: Option<&'a dyn DigestLease>,
985
246
    ) -> Pin<Box<dyn Future<Output = Result<u64, Error>> + Send + 'a>> {
986
246
        Box::pin(async move {
987
246
            debug!(?digest, ?dest_path, "Constructing directory");
988
            // The root is reserved by `get_or_create_entry`; child digests are
989
            // reserved before their recursive futures are queued below.
990
991
            // Use the prefetched proto when available; otherwise fetch it
992
            // (permit held only for the fetch). A prefetch-map miss (e.g.
993
            // an incomplete GetTree response) degrades to the fetch path.
994
246
            let prefetched = protos.and_then(|map| 
map2
.
get2
(
&digest2
));
995
246
            let 
directory239
:
ProtoDirectory239
= if let Some(
directory2
) = prefetched {
996
2
                directory.clone()
997
            } else {
998
244
                let _permit = self.acquire_fetch_permit().await
?0
;
999
244
                get_and_decode_digest(self.cas_store.as_ref(), digest.into())
1000
244
                    .await
1001
242
                    .err_tip(|| 
format!5
("Failed to fetch directory digest: {digest:?}"))
?5
1002
            };
1003
1004
            // Create the destination directory. It must be writable while it
1005
            // is being populated; 0o755 is its final mode too, so set it now
1006
            // (umask-independent) — no post-construction permission walk.
1007
239
            self.create_dir_writable(dest_path).await
?1
;
1008
1009
237
            let mut total_size: u64 = 0;
1010
1.61k
            for file in 
&directory.files237
{
1011
1.61k
                if let Some(file_digest) = &file.digest {
1012
                    // size_bytes is non-negative; clamp defensively.
1013
1.61k
                    total_size += u64::try_from(file_digest.size_bytes).unwrap_or(0);
1014
1.61k
                    if let Ok(file_digest) = DigestInfo::try_from(file_digest) {
1015
1.61k
                        acquire_digest(lease, &file_digest);
1016
1.61k
                    
}0
1017
0
                }
1018
            }
1019
1020
            // Materialize all nodes of this directory level concurrently,
1021
            // sharing one concurrency budget — the same shape as
1022
            // `download_to_directory`. Every file that is not in the fast
1023
            // tier pays a full slow-store round trip, so awaiting nodes
1024
            // sequentially serializes N round trips and dominates cold-cache
1025
            // construction time for large trees. Futures yield the subtree
1026
            // size they materialized (0 for files, whose sizes are already
1027
            // summed from their digests above, and for symlinks).
1028
237
            let mut node_futures: Vec<BoxFuture<'_, Result<u64, Error>>> = Vec::with_capacity(
1029
237
                directory.files.len() + directory.directories.len() + directory.symlinks.len(),
1030
            );
1031
1.61k
            for file in 
&directory.files237
{
1032
1.61k
                node_futures.push(Box::pin(async move {
1033
1.61k
                    self.create_file(dest_path, file).await.map(|()| 0)
1034
1.61k
                }));
1035
            }
1036
237
            for 
dir_node200
in &directory.directories {
1037
200
                if let Some(dir_digest) = &dir_node.digest
1038
200
                    && let Ok(dir_digest) = DigestInfo::try_from(dir_digest)
1039
200
                {
1040
200
                    acquire_digest(lease, &dir_digest);
1041
200
                
}0
1042
200
                node_futures.push(Box::pin(
1043
200
                    self.create_subdirectory(dest_path, dir_node, protos, lease),
1044
200
                ));
1045
            }
1046
237
            for 
symlink129
in &directory.symlinks {
1047
129
                node_futures.push(Box::pin(async move {
1048
129
                    self.create_symlink(dest_path, symlink).await.map(|()| 0)
1049
129
                }));
1050
            }
1051
237
            total_size += futures::stream::iter(node_futures)
1052
237
                .buffer_unordered(CONSTRUCT_DIRECTORY_CONCURRENCY)
1053
3.86k
                .
try_fold237
(0u64, |acc, size| async move
{1.93k
Ok(acc + size)1.93k
})
1054
237
                .await
?8
;
1055
1056
229
            Ok(total_size)
1057
243
        })
1058
246
    }
1059
1060
    /// Creates `dir` (and any missing parents) and sets its mode to 0o755 so
1061
    /// that it is writable while the cache entry is being populated and stays
1062
    /// at a stable, umask-independent final mode afterwards.
1063
239
    async fn create_dir_writable(&self, dir: &Path) -> Result<(), Error> {
1064
239
        fs::create_dir_all(dir)
1065
239
            .await
1066
238
            .err_tip(|| 
format!1
("Failed to create directory: {}",
dir1
.
display1
()))
?1
;
1067
        #[cfg(unix)]
1068
        {
1069
            use std::os::unix::fs::PermissionsExt;
1070
237
            fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755))
1071
237
                .await
1072
237
                .err_tip(|| 
format!0
("Failed to set directory mode: {}",
dir0
.
display0
()))
?0
;
1073
        }
1074
237
        Ok(())
1075
238
    }
1076
1077
    /// Creates a file from a `FileNode` inside a cache entry.
1078
    ///
1079
    /// The fast path hardlinks the `FilesystemStore` CAS blob directly into the
1080
    /// cache entry — zero-copy, metadata-only — exactly like
1081
    /// `download_to_directory`. A hardlinked file shares its inode with the CAS
1082
    /// store (and every other action that hardlinked the same blob), so it MUST
1083
    /// NOT be chmod'd: doing so is the inode-corruption bug PR #2347 fixed.
1084
    ///
1085
    /// This imposes two correctness rules, both handled here:
1086
    ///  * Executable files (`FileNode.is_executable`) need the `+x` bit, which
1087
    ///    cannot be applied to a shared CAS inode. They are given their own
1088
    ///    private inode via fetch+write and then chmod'd — never hardlinked.
1089
    ///  * If the blob is not locally hardlinkable (the fast tier is not a
1090
    ///    `FilesystemStore`, or the blob is not present in it / was evicted),
1091
    ///    fall back to fetch+write for that file rather than failing.
1092
1.61k
    async fn create_file(&self, parent: &Path, file_node: &FileNode) -> Result<(), Error> {
1093
1.61k
        let file_path = parent.join(&file_node.name);
1094
1.61k
        let digest = DigestInfo::try_from(
1095
1.61k
            file_node
1096
1.61k
                .digest
1097
1.61k
                .clone()
1098
1.61k
                .ok_or_else(|| 
make_err!0
(
Code::InvalidArgument0
, "File node missing digest"))
?0
,
1099
        )
1100
1.61k
        .err_tip(|| "Invalid file digest")
?0
;
1101
1102
1.61k
        trace!(?file_path, ?digest, "Creating file");
1103
1104
        // Zero-byte files (digest af1349b9...-0) are not stored in
1105
        // FilesystemStore / many CAS backends, so fetching here returns
1106
        // NotFound. In Bazel-style trees these show up frequently as empty
1107
        // marker / config files (.linksearchpaths, empty .env, .toml, etc.),
1108
        // and a single failure aborts the whole DirectoryCache construction.
1109
        // Short-circuit and write the empty file directly.
1110
1.61k
        if is_zero_digest(digest) {
1111
188
            fs::write(&file_path, b"")
1112
188
                .await
1113
188
                .err_tip(|| 
format!0
("Failed to write empty file: {}",
file_path.display()0
))
?0
;
1114
188
            return Ok(());
1115
1.42k
        }
1116
1117
        // Executable files need their own inode to carry the +x bit without
1118
        // mutating the shared CAS blob — copy, never hardlink.
1119
1.42k
        if file_node.is_executable {
1120
190
            return self.copy_file_to(&digest, &file_path, true).await;
1121
1.23k
        }
1122
1123
        // Non-executable file: try to hardlink the CAS blob directly.
1124
1.23k
        if let Some(filesystem_store) = &self.filesystem_store {
1125
1.23k
            match self
1126
1.23k
                .hardlink_cas_blob(filesystem_store, &digest, &file_path)
1127
1.23k
                .await
1128
            {
1129
1.23k
                Ok(()) => return Ok(()),
1130
0
                Err(e) if e.code == Code::NotFound => {
1131
                    // The blob is not in the filesystem tier (e.g. it lives
1132
                    // only in the slow store, or was evicted). Fall through
1133
                    // to fetch+write rather than failing the whole build.
1134
0
                    trace!(
1135
                        ?digest,
1136
                        ?file_path,
1137
                        "CAS blob not locally hardlinkable, copying instead"
1138
                    );
1139
                }
1140
0
                Err(e) => return Err(e),
1141
            }
1142
0
        }
1143
1144
        // Fallback: fetch the blob and write a private copy. Non-executable,
1145
        // but still made read-only (0o444) by copy_file_to so the materialized
1146
        // input is immutable like the hardlink path.
1147
0
        self.copy_file_to(&digest, &file_path, false).await
1148
1.61k
    }
1149
1150
    /// Hardlinks the `FilesystemStore` CAS blob for `digest` into `file_path`.
1151
    /// Mirrors `download_to_directory`: populate the fast store, resolve the
1152
    /// blob's on-disk path under the entry lock, then `fs::hard_link`.
1153
    ///
1154
    /// Returns a `NotFound` error if the blob is not present in the filesystem
1155
    /// tier; callers fall back to fetch+write in that case.
1156
1.23k
    async fn hardlink_cas_blob(
1157
1.23k
        &self,
1158
1.23k
        filesystem_store: &FilesystemStore,
1159
1.23k
        digest: &DigestInfo,
1160
1.23k
        file_path: &Path,
1161
1.23k
    ) -> Result<(), Error> {
1162
        // Ensure the blob is in the fast (filesystem) tier so it has an
1163
        // on-disk file we can hardlink. This may fetch from the slow store,
1164
        // so it holds a fetch permit.
1165
        {
1166
1.23k
            let _permit = self.acquire_fetch_permit().await
?0
;
1167
1.23k
            self.cas_store
1168
1.23k
                .populate_fast_store(StoreKey::Digest(*digest))
1169
1.23k
                .await
1170
1.23k
                .err_tip(|| 
format!0
("Failed to populate fast store for {digest}"))
?0
;
1171
        }
1172
1173
1.23k
        let file_entry = filesystem_store
1174
1.23k
            .get_file_entry_for_digest(digest)
1175
1.23k
            .await
1176
1.23k
            .err_tip(|| "Resolving CAS file entry for hardlink")
?0
;
1177
1178
1.23k
        let file_path = file_path.to_path_buf();
1179
1.23k
        file_entry
1180
1.23k
            .get_file_path_locked(move |src| async move {
1181
1.23k
                fs::hard_link(&src, &file_path).await.err_tip(|| 
{0
1182
0
                    format!(
1183
                        "Failed to hardlink CAS blob into cache entry: {}",
1184
0
                        file_path.display()
1185
                    )
1186
0
                })
1187
2.46k
            })
1188
1.23k
            .await
1189
1.23k
    }
1190
1191
    /// Fetches the blob for `digest` from the CAS and writes a private copy at
1192
    /// `file_path`, then chmods it read-only (`0o555` when `executable`, else
1193
    /// `0o444`). This is safe because the copy has its own inode, unshared with
1194
    /// the CAS, so the chmod cannot mutate a shared blob.
1195
190
    async fn copy_file_to(
1196
190
        &self,
1197
190
        digest: &DigestInfo,
1198
190
        file_path: &Path,
1199
190
        executable: bool,
1200
190
    ) -> Result<(), Error> {
1201
190
        let data = {
1202
190
            let _permit = self.acquire_fetch_permit().await
?0
;
1203
190
            self.cas_store
1204
190
                .get_part_unchunked(StoreKey::Digest(*digest), 0, None)
1205
190
                .await
1206
190
                .err_tip(|| 
format!0
("Failed to fetch file: {}",
file_path0
.
display0
()))
?0
1207
        };
1208
1209
190
        fs::write(file_path, data.as_ref())
1210
190
            .await
1211
190
            .err_tip(|| 
format!0
("Failed to write file: {}",
file_path0
.
display0
()))
?0
;
1212
1213
        #[cfg(unix)]
1214
        {
1215
            use std::os::unix::fs::PermissionsExt;
1216
            // Read-only, matching the hermeticity contract for materialized
1217
            // inputs: 0o555 (r-xr-xr-x) for executables so the +x bit survives,
1218
            // 0o444 (r--r--r--) for data files. This file has its own private
1219
            // inode (just written above), so chmoding it cannot affect any CAS
1220
            // blob or another action's hardlink — unlike the hardlink path,
1221
            // where the shared read-only blob must never be chmod'd.
1222
190
            let mode = if executable { 0o555 } else { 
0o4440
};
1223
190
            fs::set_permissions(file_path, std::fs::Permissions::from_mode(mode))
1224
190
                .await
1225
190
                .err_tip(|| 
format!0
("Failed to set permissions: {}",
file_path0
.
display0
()))
?0
;
1226
        }
1227
        #[cfg(not(unix))]
1228
        let _ = executable;
1229
1230
190
        Ok(())
1231
190
    }
1232
1233
    /// Creates a subdirectory from a `DirectoryNode`, returning the total size
1234
    /// of the subtree it materializes.
1235
    ///
1236
    /// With `experimental_subtree_caching` enabled, the subtree is looked up
1237
    /// in (and inserted into) the cache by its own `Directory` digest, so a
1238
    /// subtree already constructed under any previous root is one
1239
    /// `hardlink_directory_tree` call instead of a full recursive rebuild.
1240
    /// When disabled (the default), the subtree is always constructed
1241
    /// directly under the parent with no cache interaction — today's
1242
    /// behavior, unchanged.
1243
200
    async fn create_subdirectory(
1244
200
        &self,
1245
200
        parent: &Path,
1246
200
        dir_node: &DirectoryNode,
1247
200
        protos: Option<&HashMap<DigestInfo, ProtoDirectory>>,
1248
200
        lease: Option<&dyn DigestLease>,
1249
200
    ) -> Result<u64, Error> {
1250
200
        let dir_path = parent.join(&dir_node.name);
1251
200
        let digest =
1252
200
            DigestInfo::try_from(dir_node.digest.clone().ok_or_else(|| 
{0
1253
0
                make_err!(Code::InvalidArgument, "Directory node missing digest")
1254
0
            })?)
1255
200
            .err_tip(|| "Invalid directory digest")
?0
;
1256
1257
200
        trace!(?dir_path, ?digest, "Creating subdirectory");
1258
1259
200
        if self.config.experimental_subtree_caching {
1260
            // Subtree caching: look the subtree up in (and insert it into)
1261
            // the cache by its own digest through the same core flow root
1262
            // materializations use. REAPI `Directory` nodes are
1263
            // content-addressed, so a subtree digest seen under one root is
1264
            // byte-identical wherever else it appears; construction recurses
1265
            // through this method, so nested subtrees get their own entries
1266
            // too (leaf-level reuse).
1267
77
            let (
hit66
,
_size66
) = self
1268
77
                .get_or_create_entry(digest, &dir_path, protos, false, lease)
1269
77
                .await
?8
;
1270
66
            let counter = if hit {
1271
6
                &self.subtree_hits
1272
            } else {
1273
60
                &self.subtree_misses
1274
            };
1275
66
            counter.fetch_add(1, Ordering::Relaxed);
1276
            // The child's cache entry owns its bytes: contribute 0 to the
1277
            // parent's recorded size, so an entry's size covers only bytes
1278
            // not owned by a descendant entry and the sum over the map
1279
            // approximates unique materialized bytes. Counting the real
1280
            // subtree size here instead would tally every file once per
1281
            // ancestor level (~depth-fold inflation of `max_size_bytes`
1282
            // pressure) and make the cache self-defeat via phantom
1283
            // evictions.
1284
66
            return Ok(0);
1285
123
        }
1286
1287
        // Recursively construct subdirectory
1288
123
        self.construct_directory(digest, &dir_path, protos, lease)
1289
123
            .await
1290
197
    }
1291
1292
    /// Creates a symlink from a `SymlinkNode`
1293
129
    async fn create_symlink(&self, parent: &Path, symlink: &SymlinkNode) -> Result<(), Error> {
1294
129
        let link_path = parent.join(&symlink.name);
1295
129
        let target = Path::new(&symlink.target);
1296
1297
129
        trace!(?link_path, ?target, "Creating symlink");
1298
1299
        #[cfg(unix)]
1300
129
        fs::symlink(&target, &link_path)
1301
129
            .await
1302
129
            .err_tip(|| 
format!0
("Failed to create symlink: {}",
link_path.display()0
))
?0
;
1303
1304
        #[cfg(windows)]
1305
        {
1306
            // On Windows, we need to know if target is a directory
1307
            // For now, assume files (can be improved later)
1308
            fs::symlink_file(&target, &link_path)
1309
                .await
1310
                .err_tip(|| format!("Failed to create symlink: {}", link_path.display()))?;
1311
        }
1312
1313
129
        Ok(())
1314
129
    }
1315
1316
    /// Selects and removes victim entries from the in-memory `cache` map until
1317
    /// it is within the entry-count and size budgets, and returns the on-disk
1318
    /// paths of the removed entries.
1319
    ///
1320
    /// This is a pure in-memory operation — it does NO filesystem I/O and is
1321
    /// not `async`. The caller runs it under the cache write lock and then,
1322
    /// after releasing the lock, dispatches the returned paths for deletion
1323
    /// via [`Self::dispatch_evictions`]. Keeping eviction's `remove_dir_all`
1324
    /// off the write lock prevents one caller's eviction I/O from serializing
1325
    /// every other concurrent `get_or_create`.
1326
106
    fn evict_if_needed(
1327
106
        &self,
1328
106
        incoming_size: u64,
1329
106
        cache: &mut HashMap<DigestInfo, CachedDirectoryMetadata>,
1330
106
    ) -> Vec<PathBuf> {
1331
106
        let mut evicted_paths = Vec::new();
1332
1333
        // Check entry count
1334
134
        while cache.len() >= self.config.max_entries {
1335
56
            let Some((
e_size28
,
path28
)) = Self::evict_lru(cache) else {
1336
                // nothing evictable (all entries pinned) — have to exit
1337
28
                warn!(
1338
28
                    current_items = cache.len(),
1339
                    max_entries = self.config.max_entries,
1340
                    "Unable to evict anything from directory_cache, will exceed max entries"
1341
                );
1342
28
                break;
1343
            };
1344
28
            self.evictions.fetch_add(1, Ordering::Relaxed);
1345
28
            self.map_entries.fetch_sub(1, Ordering::Relaxed);
1346
28
            self.map_size_bytes.fetch_sub(e_size, Ordering::Relaxed);
1347
28
            evicted_paths.push(path);
1348
        }
1349
1350
        // Check total size
1351
106
        if self.config.max_size_bytes > 0 {
1352
106
            let current_size: u64 = cache.values().map(|m| m.size).sum();
1353
106
            let mut size_after = current_size + incoming_size;
1354
1355
106
            while size_after > self.config.max_size_bytes {
1356
0
                let Some((e_size, path)) = Self::evict_lru(cache) else {
1357
                    // nothing evictable (all entries pinned) — have to exit
1358
0
                    warn!(
1359
                        size_after,
1360
                        max_size_bytes = self.config.max_size_bytes,
1361
                        "Unable to evict anything from directory_cache, will exceed max size"
1362
                    );
1363
0
                    break;
1364
                };
1365
0
                self.evictions.fetch_add(1, Ordering::Relaxed);
1366
0
                self.map_entries.fetch_sub(1, Ordering::Relaxed);
1367
0
                self.map_size_bytes.fetch_sub(e_size, Ordering::Relaxed);
1368
0
                size_after -= e_size;
1369
0
                evicted_paths.push(path);
1370
            }
1371
0
        }
1372
1373
106
        evicted_paths
1374
106
    }
1375
1376
    /// Removes the least-recently-used unpinned entry from the in-memory map
1377
    /// and returns its `(size, path)`. Entries with `ref_count > 0` are
1378
    /// in-flight materializations and are never selected — their on-disk tree
1379
    /// must not be deleted while a caller is hardlinking from it.
1380
    ///
1381
    /// Pure in-memory; the actual filesystem deletion is the caller's job.
1382
56
    fn evict_lru(
1383
56
        cache: &mut HashMap<DigestInfo, CachedDirectoryMetadata>,
1384
56
    ) -> Option<(u64, PathBuf)> {
1385
56
        let 
to_evict28
= cache
1386
56
            .iter()
1387
390
            .
filter56
(|(_, m)| m.ref_count.load(Ordering::SeqCst) == 0)
1388
61
            .
min_by_key56
(|(_, m)| m.last_access.load(Ordering::Relaxed))
1389
56
            .map(|(digest, _)| *digest)
?28
;
1390
28
        let metadata = cache.remove(&to_evict)
?0
;
1391
28
        debug!(
1392
            digest = ?to_evict,
1393
            size = metadata.size,
1394
            "Evicting cached directory"
1395
        );
1396
28
        Some((metadata.size, metadata.path))
1397
56
    }
1398
1399
    /// Dispatches filesystem deletion of evicted cache-entry trees onto a
1400
    /// background task, so eviction I/O never runs under the cache write lock.
1401
    ///
1402
    /// Each tree's directories are chmod'd writable first
1403
    /// (`set_dir_writable_recursive`) — never its files: a cache-entry file
1404
    /// shares an inode with the `FilesystemStore` CAS blob and every action
1405
    /// that hardlinked it, so chmoding it would corrupt that shared inode (the
1406
    /// PR #2347 bug). Directory write permission alone is sufficient to unlink
1407
    /// files on unix.
1408
112
    fn dispatch_evictions(paths: Vec<PathBuf>) {
1409
112
        if paths.is_empty() {
1410
98
            return;
1411
14
        }
1412
14
        background_spawn!("directory_cache_evict", async move 
{13
1413
30
            for path in 
paths13
{
1414
30
                match fs::metadata(&path).await {
1415
                    Err(_) => {
1416
                        // Already gone (e.g. a prior cleanup, or the cache
1417
                        // root was torn down). Nothing to do, not an error.
1418
3
                        continue;
1419
                    }
1420
27
                    Ok(
metadata0
) if !metadata.is_dir(
)0
=> {
1421
                        // Stray file (only plausible from an orphan sweep of
1422
                        // a polluted cache root).
1423
0
                        if let Err(e) = fs::remove_file(&path).await {
1424
0
                            warn!(?path, error = ?e, "Failed to remove evicted file from disk");
1425
0
                        }
1426
0
                        continue;
1427
                    }
1428
27
                    Ok(_) => {}
1429
                }
1430
27
                if let Err(
e0
) = set_dir_writable_recursive(&path).await {
1431
0
                    warn!(
1432
                        ?path,
1433
                        error = ?e,
1434
                        "Unable to mark evicted directory writable, removal may fail"
1435
                    );
1436
26
                }
1437
26
                if let Err(
e0
) = fs::remove_dir_all(&path).await {
1438
0
                    warn!(
1439
                        ?path,
1440
                        error = ?e,
1441
                        "Failed to remove evicted directory from disk"
1442
                    );
1443
26
                }
1444
            }
1445
12
        });
1446
112
    }
1447
1448
    /// Gets the cache path for a digest
1449
126
    fn get_cache_path(&self, digest: &DigestInfo) -> PathBuf {
1450
126
        self.config.cache_root.join(format!("{digest}"))
1451
126
    }
1452
1453
    /// Returns cache statistics
1454
25
    
pub async fn stats(&self) -> CacheStats3
{
1455
25
        let cache = self.cache.read().await;
1456
25
        let total_size: u64 = cache.values().map(|m| m.size).sum();
1457
25
        let in_use = cache
1458
25
            .values()
1459
83
            .
filter25
(|m| m.ref_count.load(Ordering::SeqCst) > 0)
1460
25
            .count();
1461
1462
25
        CacheStats {
1463
25
            entries: cache.len(),
1464
25
            total_size_bytes: total_size,
1465
25
            in_use_entries: in_use,
1466
25
            clonefile_hits: self.clonefile_hits.load(Ordering::Relaxed),
1467
25
            hardlink_hits: self.hardlink_hits.load(Ordering::Relaxed),
1468
25
            subtree_hits: self.subtree_hits.load(Ordering::Relaxed),
1469
25
            subtree_misses: self.subtree_misses.load(Ordering::Relaxed),
1470
25
            evictions: self.evictions.load(Ordering::Relaxed),
1471
25
        }
1472
25
    }
1473
}
1474
1475
impl MetricsComponent for DirectoryCache {
1476
0
    fn publish(
1477
0
        &self,
1478
0
        _kind: MetricKind,
1479
0
        field_metadata: MetricFieldData,
1480
0
    ) -> Result<MetricPublishKnownKindData, nativelink_metric::Error> {
1481
0
        let _enter = group!(field_metadata.name).entered();
1482
0
        publish!(
1483
0
            "clonefile_hits",
1484
0
            &self.clonefile_hits,
1485
0
            MetricKind::Counter,
1486
0
            "Materializations that used APFS clonefile(2) (macOS only)"
1487
        );
1488
0
        publish!(
1489
0
            "hardlink_hits",
1490
0
            &self.hardlink_hits,
1491
0
            MetricKind::Counter,
1492
0
            "Materializations that used per-file hardlinks"
1493
        );
1494
0
        publish!(
1495
0
            "subtree_hits",
1496
0
            &self.subtree_hits,
1497
0
            MetricKind::Counter,
1498
0
            "Subtree materializations served from the cache (experimental_subtree_caching)"
1499
        );
1500
0
        publish!(
1501
0
            "subtree_misses",
1502
0
            &self.subtree_misses,
1503
0
            MetricKind::Counter,
1504
0
            "Subtree materializations that had to be constructed (experimental_subtree_caching)"
1505
        );
1506
0
        publish!(
1507
0
            "evictions",
1508
0
            &self.evictions,
1509
0
            MetricKind::Counter,
1510
0
            "Cache entries removed by LRU eviction (high rate = cache thrash)"
1511
        );
1512
0
        publish!(
1513
0
            "entries",
1514
0
            &self.map_entries,
1515
0
            MetricKind::Default,
1516
0
            "Current number of cached directory entries"
1517
        );
1518
0
        publish!(
1519
0
            "total_size_bytes",
1520
0
            &self.map_size_bytes,
1521
0
            MetricKind::Default,
1522
0
            "Current recorded size of all cached entries in bytes"
1523
        );
1524
0
        Ok(MetricPublishKnownKindData::Component)
1525
0
    }
1526
}
1527
1528
/// Statistics about the directory cache
1529
#[derive(Debug, Clone, Copy)]
1530
pub struct CacheStats {
1531
    pub entries: usize,
1532
    pub total_size_bytes: u64,
1533
    pub in_use_entries: usize,
1534
    /// Materializations that used APFS `clonefile(2)` (macOS).
1535
    pub clonefile_hits: u64,
1536
    /// Materializations that used per-file `fs::hard_link`.
1537
    pub hardlink_hits: u64,
1538
    /// Subtree materializations served from the cache by digest. Always zero
1539
    /// unless `experimental_subtree_caching` is enabled.
1540
    pub subtree_hits: u64,
1541
    /// Subtree constructions not servable from the cache. Always zero unless
1542
    /// `experimental_subtree_caching` is enabled.
1543
    pub subtree_misses: u64,
1544
    /// Entries removed by LRU eviction. A high rate relative to hits means
1545
    /// the cache is thrashing and `max_entries`/`max_size_bytes` should be
1546
    /// raised.
1547
    pub evictions: u64,
1548
}
1549
1550
#[cfg(test)]
1551
mod tests {
1552
    use nativelink_config::stores::{
1553
        FastSlowSpec, FilesystemSpec, MemorySpec, StoreDirection, StoreSpec,
1554
    };
1555
    use nativelink_macro::nativelink_test;
1556
    use nativelink_store::memory_store::MemoryStore;
1557
    use nativelink_util::store_trait::Store;
1558
    use prost::Message;
1559
    use tempfile::TempDir;
1560
1561
    use super::*;
1562
1563
    /// Builds a `FastSlowStore` whose fast tier is a real `FilesystemStore`
1564
    /// and whose slow tier is a `MemoryStore` — the same shape the worker
1565
    /// wires up. Returns the `FastSlowStore` plus the slow `Store` handle so
1566
    /// tests can seed blobs/protos into the slow tier.
1567
12
    async fn make_fast_slow_store(temp_dir: &TempDir) -> (Arc<FastSlowStore>, Store) {
1568
12
        let fast_spec = FilesystemSpec {
1569
12
            content_path: temp_dir
1570
12
                .path()
1571
12
                .join("cas_content")
1572
12
                .to_string_lossy()
1573
12
                .into_owned(),
1574
12
            temp_path: temp_dir
1575
12
                .path()
1576
12
                .join("cas_temp")
1577
12
                .to_string_lossy()
1578
12
                .into_owned(),
1579
12
            eviction_policy: None,
1580
12
            ..Default::default()
1581
12
        };
1582
12
        let slow_spec = MemorySpec::default();
1583
12
        let fast_store: Arc<FilesystemStore> = FilesystemStore::new(&fast_spec).await.unwrap();
1584
12
        let slow_store = MemoryStore::new(&slow_spec);
1585
12
        let cas_store = FastSlowStore::new(
1586
12
            &FastSlowSpec {
1587
12
                fast: StoreSpec::Filesystem(fast_spec),
1588
12
                slow: StoreSpec::Memory(slow_spec),
1589
12
                fast_direction: StoreDirection::default(),
1590
12
                slow_direction: StoreDirection::default(),
1591
12
                bypass_dedup_threshold_bytes: 0,
1592
12
                has_slow_write_wait_timeout_s: 0,
1593
12
                leader_inactivity_timeout_s: 0,
1594
12
            },
1595
12
            Store::new(fast_store),
1596
12
            Store::new(slow_store.clone()),
1597
        );
1598
12
        (cas_store, Store::new(slow_store))
1599
12
    }
1600
1601
    /// Uploads `content` to `store` under a digest derived from `tag`, returns
1602
    /// the digest. `FastSlowStore`/`MemoryStore`/`FilesystemStore` do not
1603
    /// verify content hashes, so a synthetic-but-unique digest is sufficient.
1604
28
    async fn upload_blob(store: &Store, tag: u8, content: &[u8]) -> DigestInfo {
1605
28
        let digest = DigestInfo::new([tag; 32], content.len() as u64);
1606
28
        store
1607
28
            .as_store_driver_pin()
1608
28
            .update_oneshot(digest.into(), content.to_vec().into())
1609
28
            .await
1610
28
            .unwrap();
1611
28
        digest
1612
28
    }
1613
1614
    /// Seeds a one-file directory ("test.txt" = "Hello, World!") into the slow
1615
    /// store and returns the `FastSlowStore` + the root directory digest.
1616
7
    async fn setup_test_store(temp_dir: &TempDir) -> (Arc<FastSlowStore>, DigestInfo) {
1617
7
        let (cas_store, slow_store) = make_fast_slow_store(temp_dir).await;
1618
1619
7
        let file_digest = upload_blob(&slow_store, 1, b"Hello, World!").await;
1620
1621
7
        let directory = ProtoDirectory {
1622
7
            files: vec![FileNode {
1623
7
                name: "test.txt".to_string(),
1624
7
                digest: Some(file_digest.into()),
1625
7
                is_executable: false,
1626
7
                ..Default::default()
1627
7
            }],
1628
7
            directories: vec![],
1629
7
            symlinks: vec![],
1630
7
            ..Default::default()
1631
7
        };
1632
7
        let mut dir_data = Vec::new();
1633
7
        directory.encode(&mut dir_data).unwrap();
1634
7
        let dir_digest = upload_blob(&slow_store, 2, &dir_data).await;
1635
1636
7
        (cas_store, dir_digest)
1637
7
    }
1638
1639
    #[nativelink_test]
1640
    async fn test_directory_cache_basic() -> Result<(), Error> {
1641
        let temp_dir = TempDir::new().unwrap();
1642
        let cache_root = temp_dir.path().join("cache");
1643
        let (store, dir_digest) = setup_test_store(&temp_dir).await;
1644
1645
        let config = DirectoryCacheConfig {
1646
            max_entries: 10,
1647
            max_size_bytes: 1024 * 1024,
1648
            cache_root,
1649
            ..Default::default()
1650
        };
1651
1652
        let cache = DirectoryCache::new(config, store).await?;
1653
1654
        // First access - cache miss
1655
        let dest1 = temp_dir.path().join("dest1");
1656
        let hit = cache.get_or_create(dir_digest, &dest1).await?;
1657
        assert!(!hit, "First access should be cache miss");
1658
        assert!(dest1.join("test.txt").exists());
1659
        assert_eq!(
1660
            fs::read(dest1.join("test.txt")).await.unwrap(),
1661
            b"Hello, World!",
1662
            "materialized file content must be byte-identical to the CAS blob"
1663
        );
1664
1665
        // Second access - cache hit
1666
        let dest2 = temp_dir.path().join("dest2");
1667
        let hit = cache.get_or_create(dir_digest, &dest2).await?;
1668
        assert!(hit, "Second access should be cache hit");
1669
        assert!(dest2.join("test.txt").exists());
1670
        assert_eq!(
1671
            fs::read(dest2.join("test.txt")).await.unwrap(),
1672
            b"Hello, World!",
1673
            "cache-hit materialized content must be byte-identical"
1674
        );
1675
1676
        // Verify stats
1677
        let stats = cache.stats().await;
1678
        assert_eq!(stats.entries, 1);
1679
1680
        // Two get_or_create calls succeeded → two materializations were
1681
        // recorded. On macOS both should be clonefile; on Linux both hardlink.
1682
        #[cfg(target_os = "macos")]
1683
        {
1684
            assert_eq!(stats.clonefile_hits, 2, "macOS should record 2 clones");
1685
            assert_eq!(stats.hardlink_hits, 0);
1686
        }
1687
        #[cfg(not(target_os = "macos"))]
1688
        {
1689
            assert_eq!(stats.clonefile_hits, 0);
1690
            assert_eq!(
1691
                stats.hardlink_hits, 2,
1692
                "non-macOS should record 2 hardlinks"
1693
            );
1694
        }
1695
1696
        Ok(())
1697
    }
1698
1699
    /// A Directory containing a zero-byte file must be constructible even when
1700
    /// the CAS has no entry for the zero-byte digest. In production CAS
1701
    /// backends (`FilesystemStore` in particular) refuse to store zero-byte
1702
    /// blobs, so without the short-circuit this is a `NotFound` error and 30%+
1703
    /// of cache constructions fail (per PR #2243).
1704
    #[nativelink_test]
1705
    async fn test_directory_cache_zero_byte_file() -> Result<(), Error> {
1706
        let temp_dir = TempDir::new().unwrap();
1707
        let cache_root = temp_dir.path().join("cache");
1708
        let (store, slow_store) = make_fast_slow_store(&temp_dir).await;
1709
1710
        // RFC 6234 / Bazel zero-byte SHA-256 digest, hash for b"".
1711
        let zero_digest = DigestInfo::try_new(
1712
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1713
            0,
1714
        )
1715
        .unwrap();
1716
        // Deliberately do NOT upload the zero-byte blob — that's the whole
1717
        // point: real CAS backends won't have it.
1718
1719
        let directory = ProtoDirectory {
1720
            files: vec![FileNode {
1721
                name: "empty.txt".to_string(),
1722
                digest: Some(zero_digest.into()),
1723
                is_executable: false,
1724
                ..Default::default()
1725
            }],
1726
            directories: vec![],
1727
            symlinks: vec![],
1728
            ..Default::default()
1729
        };
1730
        let mut dir_data = Vec::new();
1731
        directory.encode(&mut dir_data).unwrap();
1732
        let dir_digest = upload_blob(&slow_store, 3, &dir_data).await;
1733
1734
        let config = DirectoryCacheConfig {
1735
            max_entries: 10,
1736
            max_size_bytes: 1024 * 1024,
1737
            cache_root,
1738
            ..Default::default()
1739
        };
1740
        let cache = DirectoryCache::new(config, store).await?;
1741
1742
        let dest = temp_dir.path().join("dest_empty");
1743
        let hit = cache.get_or_create(dir_digest, &dest).await?;
1744
        assert!(!hit, "First construction should be a cache miss");
1745
1746
        let empty_path = dest.join("empty.txt");
1747
        assert!(empty_path.exists(), "zero-byte file should be created");
1748
        let metadata = fs::metadata(&empty_path).await.unwrap();
1749
        assert_eq!(metadata.len(), 0, "zero-byte file must be 0 bytes");
1750
1751
        Ok(())
1752
    }
1753
1754
    /// Regression test for CAS inode corruption during directory cache
1755
    /// eviction.
1756
    ///
1757
    /// Background: a cached directory's files share an inode (via hardlink)
1758
    /// with both the `FilesystemStore` CAS entry and every action workspace
1759
    /// that has consumed this cached directory. The cleanup path that runs
1760
    /// before `fs::remove_dir_all` used to call `set_readwrite_recursive`,
1761
    /// which chmods every file in the tree to 0o644 — silently mutating the
1762
    /// shared inode's mode for every in-flight action holding a hardlink to
1763
    /// the same blob. In production this surfaced as EACCES on exec for
1764
    /// `cc_wrapper.sh` (whose CAS mode is 0o555, but eviction turned it into
1765
    /// 0o644, dropping the +x bit).
1766
    ///
1767
    /// This test models the real scenario: a "cached" directory tree whose
1768
    /// files are hardlinked to a still-active "action workspace" file. The
1769
    /// eviction cleanup runs on the cached tree, and we assert the active
1770
    /// workspace file's mode is untouched. Before the fix this test fails
1771
    /// because `set_readwrite_recursive` chmods the cached-side file, and
1772
    /// the same inode underlies the workspace file.
1773
    #[cfg(unix)]
1774
    #[nativelink_test]
1775
    async fn test_eviction_cleanup_preserves_hardlinked_file_mode() -> Result<(), Error> {
1776
        use std::os::unix::fs::{MetadataExt, PermissionsExt};
1777
1778
        use nativelink_util::fs_util::{set_dir_writable_recursive, set_readonly_recursive};
1779
1780
        let temp_dir = TempDir::new().unwrap();
1781
1782
        // Build a "cached" directory tree mimicking what DirectoryCache
1783
        // produces: a top-level dir with a nested subdir and an executable
1784
        // file inside. Mode 0o555 matches the CAS mode of an executable like
1785
        // `cc_wrapper.sh`.
1786
        let cache_entry_dir = temp_dir.path().join("cache_entry");
1787
        let nested_dir = cache_entry_dir.join("nested");
1788
        fs::create_dir_all(&nested_dir).await.unwrap();
1789
        let cached_file = nested_dir.join("cc_wrapper.sh");
1790
        fs::write(&cached_file, b"#!/bin/sh\necho hi\n")
1791
            .await
1792
            .unwrap();
1793
        fs::set_permissions(&cached_file, std::fs::Permissions::from_mode(0o555))
1794
            .await
1795
            .unwrap();
1796
1797
        // Lock the cached tree down the way DirectoryCache does after
1798
        // construction (set_readonly_recursive). This makes every file
1799
        // read-only (0o555) and leaves every directory writable (0o755).
1800
        set_readonly_recursive(&cache_entry_dir).await?;
1801
1802
        // Simulate an in-flight action workspace that has hardlinked the
1803
        // cached file. This is what `hardlink_directory_tree` does in
1804
        // `get_or_create` after the cached tree is built and locked down.
1805
        let action_workspace = temp_dir.path().join("action_workspace");
1806
        fs::create_dir_all(&action_workspace).await.unwrap();
1807
        let workspace_file = action_workspace.join("cc_wrapper.sh");
1808
        fs::hard_link(&cached_file, &workspace_file).await.unwrap();
1809
1810
        // Sanity check: cached file and workspace file share the same inode.
1811
        let cached_ino = fs::metadata(&cached_file).await.unwrap().ino();
1812
        let workspace_ino = fs::metadata(&workspace_file).await.unwrap().ino();
1813
        assert_eq!(
1814
            cached_ino, workspace_ino,
1815
            "workspace file must share inode with cached file (hardlinked)",
1816
        );
1817
        let workspace_mode_before = fs::metadata(&workspace_file)
1818
            .await
1819
            .unwrap()
1820
            .permissions()
1821
            .mode()
1822
            & 0o777;
1823
        assert_eq!(workspace_mode_before, 0o555);
1824
1825
        // Run the cleanup that `evict_lru` runs before removing the tree.
1826
        // After the fix this only chmods directories; before the fix this
1827
        // chmoded every file to 0o644, mutating the shared inode.
1828
        set_dir_writable_recursive(&cache_entry_dir).await?;
1829
1830
        // Critical assertion: the action workspace file's mode (i.e. the
1831
        // shared inode's mode) MUST be unchanged. If this fails, the cleanup
1832
        // path corrupted the inode for an in-flight action — that is the
1833
        // bug we are guarding against.
1834
        let workspace_mode_after = fs::metadata(&workspace_file)
1835
            .await
1836
            .unwrap()
1837
            .permissions()
1838
            .mode()
1839
            & 0o777;
1840
        assert_eq!(
1841
            workspace_mode_after, workspace_mode_before,
1842
            "eviction cleanup mutated the inode mode of an active workspace \
1843
             file (was 0o{workspace_mode_before:o}, now 0o{workspace_mode_after:o}); \
1844
             this is the CAS inode corruption bug",
1845
        );
1846
1847
        // We should still be able to remove the cached tree. This proves
1848
        // directory writability alone is sufficient to unlink files on unix.
1849
        fs::remove_dir_all(&cache_entry_dir).await.unwrap();
1850
        assert!(!cache_entry_dir.exists());
1851
1852
        // The workspace's file is still intact and the mode survives even
1853
        // after the cached tree is gone.
1854
        assert!(workspace_file.exists());
1855
        let workspace_mode_after_remove = fs::metadata(&workspace_file)
1856
            .await
1857
            .unwrap()
1858
            .permissions()
1859
            .mode()
1860
            & 0o777;
1861
        assert_eq!(workspace_mode_after_remove, workspace_mode_before);
1862
1863
        Ok(())
1864
    }
1865
1866
    /// Builds a nested directory tree in the CAS: a root directory containing
1867
    /// one file plus a subdirectory, and the subdirectory in turn containing a
1868
    /// file. Returns the `FastSlowStore` and the root directory's digest. Uses
1869
    /// the same `make_fast_slow_store` + `upload_blob` shape as the other tests
1870
    /// so the store matches `DirectoryCache::new`'s `Arc<FastSlowStore>` arg.
1871
    ///
1872
    /// Only used by `test_materialized_tree_dirs_writable_files_readonly`,
1873
    /// which is `#[cfg(unix)]`; gated to match so non-unix builds (Windows)
1874
    /// do not flag this helper as dead code.
1875
    #[cfg(unix)]
1876
1
    async fn setup_nested_test_store(temp_dir: &TempDir) -> (Arc<FastSlowStore>, DigestInfo) {
1877
1
        let (cas_store, slow_store) = make_fast_slow_store(temp_dir).await;
1878
1879
        // A file shared by both the root and the nested subdirectory.
1880
1
        let file_digest = upload_blob(&slow_store, 10, b"Hello, World!").await;
1881
1882
        // The nested subdirectory: contains a single file.
1883
1
        let subdir = ProtoDirectory {
1884
1
            files: vec![FileNode {
1885
1
                name: "nested.txt".to_string(),
1886
1
                digest: Some(file_digest.into()),
1887
1
                is_executable: false,
1888
1
                ..Default::default()
1889
1
            }],
1890
1
            directories: vec![],
1891
1
            symlinks: vec![],
1892
1
            ..Default::default()
1893
1
        };
1894
1
        let mut subdir_data = Vec::new();
1895
1
        subdir.encode(&mut subdir_data).unwrap();
1896
1
        let subdir_digest = upload_blob(&slow_store, 11, &subdir_data).await;
1897
1898
        // The root directory: one file plus the subdirectory above.
1899
1
        let root = ProtoDirectory {
1900
1
            files: vec![FileNode {
1901
1
                name: "root.txt".to_string(),
1902
1
                digest: Some(file_digest.into()),
1903
1
                is_executable: false,
1904
1
                ..Default::default()
1905
1
            }],
1906
1
            directories: vec![DirectoryNode {
1907
1
                name: "subdir".to_string(),
1908
1
                digest: Some(subdir_digest.into()),
1909
1
            }],
1910
1
            symlinks: vec![],
1911
1
            ..Default::default()
1912
1
        };
1913
1
        let mut root_data = Vec::new();
1914
1
        root.encode(&mut root_data).unwrap();
1915
1
        let root_digest = upload_blob(&slow_store, 12, &root_data).await;
1916
1917
1
        (cas_store, root_digest)
1918
1
    }
1919
1920
    /// Asserts every directory in `root` (the root itself and every nested
1921
    /// subdirectory) is writable and every file is read-only.
1922
    #[cfg(unix)]
1923
8
    fn assert_dirs_writable_files_readonly(
1924
8
        root: &Path,
1925
8
    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
1926
8
        Box::pin(async move {
1927
            use std::os::unix::fs::PermissionsExt;
1928
1929
8
            let metadata = fs::symlink_metadata(root)
1930
8
                .await
1931
8
                .err_tip(|| 
format!0
("metadata for {}",
root0
.
display0
()))
?0
;
1932
8
            let mode = metadata.permissions().mode() & 0o777;
1933
1934
8
            if metadata.is_dir() {
1935
4
                assert_eq!(
1936
4
                    mode & 0o200,
1937
                    0o200,
1938
                    "directory {} must be writable (mode 0o{mode:o})",
1939
0
                    root.display(),
1940
                );
1941
4
                let mut entries = fs::read_dir(root).await
?0
;
1942
10
                while let Some(
entry6
) = entries.next_entry().await
?0
{
1943
6
                    assert_dirs_writable_files_readonly(&entry.path()).await
?0
;
1944
                }
1945
4
            } else if metadata.is_file() {
1946
4
                assert_eq!(
1947
4
                    mode & 0o222,
1948
                    0,
1949
                    "file {} must be read-only (mode 0o{mode:o})",
1950
0
                    root.display(),
1951
                );
1952
0
            }
1953
            // Symlinks: mode is not meaningful, skip.
1954
1955
8
            Ok(())
1956
8
        })
1957
8
    }
1958
1959
    /// After `get_or_create` materializes a tree — on both the fresh
1960
    /// cache-miss path and the cache-hit path — every directory in the
1961
    /// destination must be writable (so Bazel actions can create outputs at
1962
    /// nested declared paths) and every file must be read-only (the file
1963
    /// inodes are CAS-hardlinked; chmoding them would corrupt the shared
1964
    /// inode). `prepare_action_inputs` relies on this so it no longer needs a
1965
    /// separate `set_dir_writable_recursive` post-walk.
1966
    #[cfg(unix)]
1967
    #[nativelink_test]
1968
    async fn test_materialized_tree_dirs_writable_files_readonly() -> Result<(), Error> {
1969
        let temp_dir = TempDir::new().unwrap();
1970
        let cache_root = temp_dir.path().join("cache");
1971
        let (store, root_digest) = setup_nested_test_store(&temp_dir).await;
1972
1973
        let config = DirectoryCacheConfig {
1974
            max_entries: 10,
1975
            max_size_bytes: 1024 * 1024,
1976
            cache_root,
1977
            ..Default::default()
1978
        };
1979
        let cache = DirectoryCache::new(config, store).await?;
1980
1981
        // Fresh-materialize path (cache miss).
1982
        let miss_dest = temp_dir.path().join("dest_miss");
1983
        let hit = cache.get_or_create(root_digest, &miss_dest).await?;
1984
        assert!(!hit, "first access must be a cache miss");
1985
        assert!(miss_dest.join("subdir").join("nested.txt").exists());
1986
        assert_dirs_writable_files_readonly(&miss_dest).await?;
1987
1988
        // A nested output can be created with no separate chmod walk.
1989
        let nested_output = miss_dest.join("subdir").join("output.o");
1990
        fs::write(&nested_output, b"action output").await.err_tip(
1991
            || "creating a nested output must succeed without set_dir_writable_recursive",
1992
        )?;
1993
1994
        // Cache-hit path: a second materialization of the same digest.
1995
        let hit_dest = temp_dir.path().join("dest_hit");
1996
        let hit = cache.get_or_create(root_digest, &hit_dest).await?;
1997
        assert!(hit, "second access must be a cache hit");
1998
        assert!(hit_dest.join("subdir").join("nested.txt").exists());
1999
        assert_dirs_writable_files_readonly(&hit_dest).await?;
2000
2001
        // The cache-hit destination also accepts a nested output directly.
2002
        fs::write(hit_dest.join("subdir").join("output.o"), b"action output")
2003
            .await
2004
            .err_tip(|| "cache-hit destination must accept a nested output directly")?;
2005
2006
        Ok(())
2007
    }
2008
2009
    /// OPT #1: a non-executable file in a cache entry must be a hardlink to
2010
    /// the `FilesystemStore` CAS blob — sharing the same inode — rather than a
2011
    /// fresh copy. This is the zero-copy materialization the optimization
2012
    /// delivers.
2013
    #[cfg(unix)]
2014
    #[nativelink_test]
2015
    async fn test_construct_hardlinks_cas_blob() -> Result<(), Error> {
2016
        use std::os::unix::fs::MetadataExt;
2017
2018
        let temp_dir = TempDir::new().unwrap();
2019
        let cache_root = temp_dir.path().join("cache");
2020
        let (store, dir_digest) = setup_test_store(&temp_dir).await;
2021
2022
        // Resolve the filesystem-tier CAS blob path for the file before
2023
        // construction so we can compare inodes afterwards.
2024
        let filesystem_store = store
2025
            .fast_store()
2026
            .downcast_ref::<FilesystemStore>(None)
2027
            .unwrap()
2028
            .get_arc()
2029
            .unwrap();
2030
        // Pull the blob into the fast tier (construction does this too).
2031
        store
2032
            .populate_fast_store(StoreKey::Digest(DigestInfo::new([1u8; 32], 13)))
2033
            .await?;
2034
        let cas_ino = filesystem_store
2035
            .get_file_entry_for_digest(&DigestInfo::new([1u8; 32], 13))
2036
            .await?
2037
2
            .get_file_path_locked(|p| async move 
{1
Ok(
fs::metadata1
(&p).await
?0
.
ino1
()) })
2038
            .await?;
2039
2040
        let config = DirectoryCacheConfig {
2041
            max_entries: 10,
2042
            max_size_bytes: 1024 * 1024,
2043
            cache_root,
2044
            ..Default::default()
2045
        };
2046
        let cache = DirectoryCache::new(config, store).await?;
2047
2048
        let dest = temp_dir.path().join("dest");
2049
        let hit = cache.get_or_create(dir_digest, &dest).await?;
2050
        assert!(!hit, "first access is a miss");
2051
2052
        // The cache entry's file (not yet the dest, which is a clone on macOS)
2053
        // must share the CAS inode. The cache entry path is cache_root/<digest>.
2054
        let cache_entry_file = cache.get_cache_path(&dir_digest).join("test.txt");
2055
        let entry_ino = fs::metadata(&cache_entry_file).await?.ino();
2056
        assert_eq!(
2057
            entry_ino, cas_ino,
2058
            "cache-entry file must be hardlinked to the CAS blob inode (zero-copy)"
2059
        );
2060
2061
        // Content must still be byte-identical.
2062
        assert_eq!(
2063
            fs::read(&cache_entry_file).await?,
2064
            b"Hello, World!",
2065
            "hardlinked file content must match the CAS blob"
2066
        );
2067
2068
        Ok(())
2069
    }
2070
2071
    /// OPT #1 correctness: an executable file must NOT be hardlinked to the
2072
    /// shared CAS blob (chmoding it would corrupt the inode shared with the
2073
    /// CAS and every other action — the PR #2347 bug). It must instead get
2074
    /// its own private inode AND carry the +x bit.
2075
    #[cfg(unix)]
2076
    #[nativelink_test]
2077
    async fn test_construct_executable_gets_private_inode() -> Result<(), Error> {
2078
        use std::os::unix::fs::{MetadataExt, PermissionsExt};
2079
2080
        let temp_dir = TempDir::new().unwrap();
2081
        let cache_root = temp_dir.path().join("cache");
2082
        let (cas_store, slow_store) = make_fast_slow_store(&temp_dir).await;
2083
2084
        let script = b"#!/bin/sh\necho ran\n";
2085
        let file_digest = upload_blob(&slow_store, 7, script).await;
2086
2087
        let directory = ProtoDirectory {
2088
            files: vec![FileNode {
2089
                name: "run.sh".to_string(),
2090
                digest: Some(file_digest.into()),
2091
                is_executable: true,
2092
                ..Default::default()
2093
            }],
2094
            ..Default::default()
2095
        };
2096
        let mut dir_data = Vec::new();
2097
        directory.encode(&mut dir_data).unwrap();
2098
        let dir_digest = upload_blob(&slow_store, 8, &dir_data).await;
2099
2100
        // Resolve the CAS blob inode for the executable.
2101
        cas_store
2102
            .populate_fast_store(StoreKey::Digest(file_digest))
2103
            .await?;
2104
        let filesystem_store = cas_store
2105
            .fast_store()
2106
            .downcast_ref::<FilesystemStore>(None)
2107
            .unwrap()
2108
            .get_arc()
2109
            .unwrap();
2110
        let (cas_ino, cas_mode) = filesystem_store
2111
            .get_file_entry_for_digest(&file_digest)
2112
            .await?
2113
1
            .get_file_path_locked(|p| async move {
2114
1
                let m = fs::metadata(&p).await
?0
;
2115
1
                Ok((m.ino(), m.permissions().mode() & 0o777))
2116
2
            })
2117
            .await?;
2118
2119
        let config = DirectoryCacheConfig {
2120
            max_entries: 10,
2121
            max_size_bytes: 1024 * 1024,
2122
            cache_root,
2123
            ..Default::default()
2124
        };
2125
        let cache = DirectoryCache::new(config, cas_store).await?;
2126
2127
        let dest = temp_dir.path().join("dest");
2128
        cache.get_or_create(dir_digest, &dest).await?;
2129
2130
        let cache_entry_file = cache.get_cache_path(&dir_digest).join("run.sh");
2131
        let entry_meta = fs::metadata(&cache_entry_file).await?;
2132
        let entry_mode = entry_meta.permissions().mode() & 0o777;
2133
2134
        // Private inode: distinct from the shared CAS blob.
2135
        assert_ne!(
2136
            entry_meta.ino(),
2137
            cas_ino,
2138
            "executable must have its own inode, not the shared CAS blob inode"
2139
        );
2140
        // The +x bit is set on the cache entry.
2141
        assert_ne!(entry_mode & 0o111, 0, "executable bit must be set");
2142
        // Content byte-identical.
2143
        assert_eq!(fs::read(&cache_entry_file).await?, script);
2144
        // The CAS blob's mode was NOT mutated by the chmod of the private copy.
2145
        let cas_mode_after = filesystem_store
2146
            .get_file_entry_for_digest(&file_digest)
2147
            .await?
2148
1
            .get_file_path_locked(|p| async move {
2149
1
                Ok(fs::metadata(&p).await
?0
.permissions().mode() & 0o777)
2150
2
            })
2151
            .await?;
2152
        assert_eq!(
2153
            cas_mode_after, cas_mode,
2154
            "CAS blob mode must be untouched by the executable's private chmod"
2155
        );
2156
2157
        Ok(())
2158
    }
2159
2160
    /// OPT #1 fallback: when the CAS blob lives only in the slow tier and is
2161
    /// not locally hardlinkable, construction must still succeed by copying.
2162
    /// `populate_fast_store` resolves this in practice, but the fetch+write
2163
    /// fallback must remain correct and produce identical content.
2164
    #[nativelink_test]
2165
    async fn test_construct_file_content_roundtrip() -> Result<(), Error> {
2166
        let temp_dir = TempDir::new().unwrap();
2167
        let cache_root = temp_dir.path().join("cache");
2168
        let (store, dir_digest) = setup_test_store(&temp_dir).await;
2169
2170
        let config = DirectoryCacheConfig {
2171
            max_entries: 10,
2172
            max_size_bytes: 1024 * 1024,
2173
            cache_root,
2174
            ..Default::default()
2175
        };
2176
        let cache = DirectoryCache::new(config, store).await?;
2177
2178
        let dest = temp_dir.path().join("dest");
2179
        cache.get_or_create(dir_digest, &dest).await?;
2180
        assert_eq!(
2181
            fs::read(dest.join("test.txt")).await?,
2182
            b"Hello, World!",
2183
            "materialized content must round-trip the CAS blob exactly"
2184
        );
2185
2186
        Ok(())
2187
    }
2188
2189
    /// OPT #2: the cache entry's recorded size must equal the sum of
2190
    /// `FileNode.digest.size_bytes` across the whole (nested) tree —
2191
    /// accumulated during construction, with no post-hoc filesystem walk.
2192
    #[nativelink_test]
2193
    async fn test_size_accounting_from_digest_sizes() -> Result<(), Error> {
2194
        let temp_dir = TempDir::new().unwrap();
2195
        let cache_root = temp_dir.path().join("cache");
2196
        let (cas_store, slow_store) = make_fast_slow_store(&temp_dir).await;
2197
2198
        // Two files at the root, one file in a nested subdir.
2199
        let f1 = upload_blob(&slow_store, 10, b"aaaaaaaa").await; // 8 bytes
2200
        let f2 = upload_blob(&slow_store, 11, b"bbb").await; // 3 bytes
2201
        let f3 = upload_blob(&slow_store, 12, b"ccccc").await; // 5 bytes
2202
2203
        let sub = ProtoDirectory {
2204
            files: vec![FileNode {
2205
                name: "nested.bin".to_string(),
2206
                digest: Some(f3.into()),
2207
                is_executable: false,
2208
                ..Default::default()
2209
            }],
2210
            ..Default::default()
2211
        };
2212
        let mut sub_data = Vec::new();
2213
        sub.encode(&mut sub_data).unwrap();
2214
        let sub_digest = upload_blob(&slow_store, 13, &sub_data).await;
2215
2216
        let root = ProtoDirectory {
2217
            files: vec![
2218
                FileNode {
2219
                    name: "a.bin".to_string(),
2220
                    digest: Some(f1.into()),
2221
                    is_executable: false,
2222
                    ..Default::default()
2223
                },
2224
                FileNode {
2225
                    name: "b.bin".to_string(),
2226
                    digest: Some(f2.into()),
2227
                    is_executable: false,
2228
                    ..Default::default()
2229
                },
2230
            ],
2231
            directories: vec![DirectoryNode {
2232
                name: "sub".to_string(),
2233
                digest: Some(sub_digest.into()),
2234
            }],
2235
            ..Default::default()
2236
        };
2237
        let mut root_data = Vec::new();
2238
        root.encode(&mut root_data).unwrap();
2239
        let root_digest = upload_blob(&slow_store, 14, &root_data).await;
2240
2241
        let config = DirectoryCacheConfig {
2242
            max_entries: 10,
2243
            max_size_bytes: 1024 * 1024,
2244
            cache_root,
2245
            ..Default::default()
2246
        };
2247
        let cache = DirectoryCache::new(config, cas_store).await?;
2248
2249
        let dest = temp_dir.path().join("dest");
2250
        cache.get_or_create(root_digest, &dest).await?;
2251
2252
        let stats = cache.stats().await;
2253
        assert_eq!(
2254
            stats.total_size_bytes,
2255
            8 + 3 + 5,
2256
            "cache size must be the sum of all FileNode digest sizes (incl. nested)"
2257
        );
2258
2259
        Ok(())
2260
    }
2261
2262
    /// OPT #2: every directory in a cache entry — root and nested — must be
2263
    /// left at mode 0o755, set at creation time without a separate walk.
2264
    #[cfg(unix)]
2265
    #[nativelink_test]
2266
    async fn test_cache_entry_dirs_are_writable() -> Result<(), Error> {
2267
        use std::os::unix::fs::PermissionsExt;
2268
2269
        let temp_dir = TempDir::new().unwrap();
2270
        let cache_root = temp_dir.path().join("cache");
2271
        let (cas_store, slow_store) = make_fast_slow_store(&temp_dir).await;
2272
2273
        let f = upload_blob(&slow_store, 20, b"data").await;
2274
        let sub = ProtoDirectory {
2275
            files: vec![FileNode {
2276
                name: "leaf.txt".to_string(),
2277
                digest: Some(f.into()),
2278
                is_executable: false,
2279
                ..Default::default()
2280
            }],
2281
            ..Default::default()
2282
        };
2283
        let mut sub_data = Vec::new();
2284
        sub.encode(&mut sub_data).unwrap();
2285
        let sub_digest = upload_blob(&slow_store, 21, &sub_data).await;
2286
2287
        let root = ProtoDirectory {
2288
            directories: vec![DirectoryNode {
2289
                name: "sub".to_string(),
2290
                digest: Some(sub_digest.into()),
2291
            }],
2292
            ..Default::default()
2293
        };
2294
        let mut root_data = Vec::new();
2295
        root.encode(&mut root_data).unwrap();
2296
        let root_digest = upload_blob(&slow_store, 22, &root_data).await;
2297
2298
        let config = DirectoryCacheConfig {
2299
            max_entries: 10,
2300
            max_size_bytes: 1024 * 1024,
2301
            cache_root,
2302
            ..Default::default()
2303
        };
2304
        let cache = DirectoryCache::new(config, cas_store).await?;
2305
        let dest = temp_dir.path().join("dest");
2306
        cache.get_or_create(root_digest, &dest).await?;
2307
2308
        let entry_root = cache.get_cache_path(&root_digest);
2309
        for dir in [entry_root.clone(), entry_root.join("sub")] {
2310
            let mode = fs::metadata(&dir).await?.permissions().mode() & 0o777;
2311
            assert_eq!(
2312
                mode,
2313
                0o755,
2314
                "cache-entry directory {} must be 0o755",
2315
                dir.display()
2316
            );
2317
        }
2318
2319
        Ok(())
2320
    }
2321
2322
    /// OPT #5: many concurrent `get_or_create` calls for the *same* digest
2323
    /// must be single-flighted — the directory is constructed exactly once
2324
    /// and every caller materializes its own destination from that single
2325
    /// cache entry. Single-flight is observable through the hit/miss return:
2326
    /// exactly one call sees a miss (`false` — it did the construct), all
2327
    /// others see a hit (`true`). Every destination must also be correct.
2328
    ///
2329
    /// Runs on a multi-threaded runtime so the calls truly race.
2330
    #[nativelink_test(flavor = "multi_thread", worker_threads = 4)]
2331
    async fn test_concurrent_get_or_create_single_flight() -> Result<(), Error> {
2332
        use futures::future::join_all;
2333
2334
        let temp_dir = TempDir::new().unwrap();
2335
        let cache_root = temp_dir.path().join("cache");
2336
        let (store, dir_digest) = setup_test_store(&temp_dir).await;
2337
2338
        let config = DirectoryCacheConfig {
2339
            max_entries: 10,
2340
            max_size_bytes: 1024 * 1024,
2341
            cache_root,
2342
            ..Default::default()
2343
        };
2344
        let cache = Arc::new(DirectoryCache::new(config, store).await?);
2345
2346
        // Fire 16 concurrent requests for the same digest, each to its own
2347
        // destination.
2348
        #[allow(clippy::items_after_statements)]
2349
        const N: usize = 16;
2350
        let dests: Vec<PathBuf> = (0..N)
2351
16
            .map(|i| temp_dir.path().join(format!("dest_{i}")))
2352
            .collect();
2353
16
        let futures = dests.iter().map(|dest| {
2354
16
            let cache = Arc::clone(&cache);
2355
16
            let dest = dest.clone();
2356
16
            async move { cache.get_or_create(dir_digest, &dest).await }
2357
16
        });
2358
        let results: Vec<bool> = join_all(futures)
2359
            .await
2360
            .into_iter()
2361
            .collect::<Result<_, _>>()?;
2362
2363
        // Exactly one construction (one miss); the rest are cache hits.
2364
16
        let misses = results.iter().filter(|hit| !**hit).count();
2365
        assert_eq!(
2366
            misses, 1,
2367
            "exactly one caller should construct the directory (single-flight)"
2368
        );
2369
        assert_eq!(results.iter().filter(|hit| **hit).count(), N - 1);
2370
2371
        // The cache holds exactly one entry, and every destination has the
2372
        // correct, byte-identical content.
2373
        let stats = cache.stats().await;
2374
        assert_eq!(stats.entries, 1, "digest must be cached exactly once");
2375
        for dest in &dests {
2376
            assert_eq!(
2377
                fs::read(dest.join("test.txt")).await?,
2378
                b"Hello, World!",
2379
                "every concurrently-materialized destination must be correct"
2380
            );
2381
        }
2382
2383
        Ok(())
2384
    }
2385
2386
    #[nativelink_test]
2387
    async fn get_tree_prefetch_falls_back_without_grpc_store() -> Result<(), Error> {
2388
        // With the flag on but a non-grpc slow tier, prefetch must return
2389
        // None and construction must fall back to per-level fetches with
2390
        // identical results.
2391
        let temp_dir = TempDir::new().unwrap();
2392
        let cache_root = temp_dir.path().join("cache");
2393
        let (store, dir_digest) = setup_test_store(&temp_dir).await;
2394
        let config = DirectoryCacheConfig {
2395
            max_entries: 10,
2396
            max_size_bytes: 1024 * 1024,
2397
            cache_root,
2398
            experimental_get_tree_prefetch: true,
2399
            ..Default::default()
2400
        };
2401
        let cache = DirectoryCache::new(config, store).await?;
2402
        let dest = temp_dir.path().join("dest");
2403
        assert!(!cache.get_or_create(dir_digest, &dest).await?);
2404
        assert!(dest.join("test.txt").exists());
2405
        Ok(())
2406
    }
2407
2408
    #[nativelink_test]
2409
    async fn queued_construction_retry_excludes_new_requests() -> Result<(), Error> {
2410
        let temp_dir = TempDir::new().unwrap();
2411
        let (store, digest) = setup_test_store(&temp_dir).await;
2412
        let cache = DirectoryCache::new(
2413
            DirectoryCacheConfig {
2414
                cache_root: temp_dir.path().join("cache"),
2415
                ..Default::default()
2416
            },
2417
            store,
2418
        )
2419
        .await?;
2420
        // Reproduce a failed constructor handing its lock to a waiting retry.
2421
        // No entry has been published yet, so new requests must join
2422
        // that same flight rather than race to replace the same cache path.
2423
        let constructor = Arc::new(Mutex::new(()));
2424
        cache
2425
            .construction_locks
2426
            .lock()
2427
            .await
2428
            .insert(digest, constructor.clone());
2429
        let retry = constructor.clone();
2430
        let retry_guard = retry.lock().await;
2431
        cache.forget_construction_lock(&digest).await;
2432
        drop(constructor);
2433
2434
        let dest = temp_dir.path().join("dest");
2435
        let request = cache.get_or_create(digest, &dest);
2436
        tokio::pin!(request);
2437
        assert!(
2438
            tokio::time::timeout(std::time::Duration::from_millis(200), request.as_mut())
2439
                .await
2440
                .is_err(),
2441
            "a new request bypassed an existing construction retry"
2442
        );
2443
        drop(retry_guard);
2444
        drop(retry);
2445
        assert!(!request.await?);
2446
        assert!(dest.join("test.txt").exists());
2447
        assert!(cache.construction_locks.lock().await.is_empty());
2448
        Ok(())
2449
    }
2450
2451
    #[nativelink_test]
2452
    async fn failed_materialization_preserves_mounted_entry() -> Result<(), Error> {
2453
        let temp_dir = TempDir::new().unwrap();
2454
        let (store, digest) = setup_test_store(&temp_dir).await;
2455
        let cache = DirectoryCache::new(
2456
            DirectoryCacheConfig {
2457
                cache_root: temp_dir.path().join("cache"),
2458
                ..Default::default()
2459
            },
2460
            store,
2461
        )
2462
        .await?;
2463
        let prepared = cache.prepare_for_mount(digest, None).await?;
2464
        // An invalid destination must not invalidate another action's mount.
2465
        let dest = temp_dir.path().join("dest");
2466
        fs::write(&dest, b"not a directory").await?;
2467
        assert!(cache.get_or_create(digest, &dest).await.is_err());
2468
        let cached = cache.acquire_entry(&digest).await.unwrap();
2469
        assert!(
2470
            Arc::ptr_eq(&prepared.pin.ref_count, &cached.pin.ref_count),
2471
            "failed materialization replaced a pinned mount source"
2472
        );
2473
        assert!(prepared.path().join("test.txt").exists());
2474
        drop(cached);
2475
        drop(prepared);
2476
        fs::remove_file(&dest).await?;
2477
        assert!(cache.get_or_create(digest, &dest).await?);
2478
        Ok(())
2479
    }
2480
}