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