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