Coverage Report

Created: 2026-09-18 20:40

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