Coverage Report

Created: 2026-08-21 00:03

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-store/src/filesystem_store.rs
Line
Count
Source
1
// Copyright 2024 The NativeLink Authors. 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::fmt::{Debug, Formatter};
16
use core::pin::Pin;
17
use core::sync::atomic::{AtomicU64, Ordering};
18
use core::time::Duration;
19
use std::borrow::Cow;
20
#[cfg(unix)]
21
use std::collections::HashMap;
22
use std::ffi::{OsStr, OsString};
23
use std::sync::{Arc, Weak};
24
use std::time::SystemTime;
25
26
#[cfg(unix)]
27
use async_lock::Mutex;
28
use async_lock::RwLock;
29
use async_trait::async_trait;
30
use bytes::{Bytes, BytesMut};
31
use futures::stream::{StreamExt, TryStreamExt};
32
use futures::{Future, TryFutureExt};
33
use nativelink_config::stores::FilesystemSpec;
34
use nativelink_error::{Code, Error, ResultExt, make_err};
35
use nativelink_metric::MetricsComponent;
36
use nativelink_util::background_spawn;
37
use nativelink_util::buf_channel::{
38
    DropCloserReadHalf, DropCloserWriteHalf, make_buf_channel_pair,
39
};
40
use nativelink_util::common::{DigestInfo, fs};
41
use nativelink_util::evicting_map::{EvictingMap, EvictionSnapshot, LenEntry};
42
use nativelink_util::fs::FileSlot;
43
use nativelink_util::health_utils::{HealthRegistryBuilder, HealthStatus, HealthStatusIndicator};
44
#[cfg(unix)]
45
use nativelink_util::spawn_blocking;
46
#[cfg(unix)]
47
use nativelink_util::store_trait::RemoveItemCallback;
48
use nativelink_util::store_trait::{
49
    RemoveCallback, StoreDriver, StoreKey, StoreKeyBorrow, StoreOptimizations, UploadSizeInfo,
50
};
51
use tokio::io::{AsyncReadExt, AsyncWriteExt, Take};
52
use tokio::sync::Semaphore;
53
use tokio::time::timeout;
54
use tokio_stream::wrappers::ReadDirStream;
55
use tracing::{debug, error, info, trace, warn};
56
57
use crate::callback_utils::RemoveCallbackHolder;
58
use crate::cas_utils::is_zero_digest;
59
60
// Default size to allocate memory of the buffer when reading files.
61
const DEFAULT_BUFF_SIZE: usize = 32 * 1024;
62
// Default block size of all major filesystems is 4KB
63
const DEFAULT_BLOCK_SIZE: u64 = 4 * 1024;
64
65
pub const STR_FOLDER: &str = "s";
66
pub const DIGEST_FOLDER: &str = "d";
67
68
/// Suffix for the sibling directory that holds per-digest read-only
69
/// **executable** (0o555) variants of CAS blobs (see
70
/// [`FilesystemStore::get_executable_hardlink_source`]). It is a sibling of
71
/// `content_path` rather than a child so the normal content/temp scan and prune
72
/// logic never touches it. Cleared on writable startup; entries are
73
/// regenerable. If the wipe is blocked by a read-only filesystem, executable
74
/// variants are disabled so surviving files are never trusted.
75
#[cfg(unix)]
76
const EXECUTABLE_DIR_SUFFIX: &str = ".exec";
77
78
const MAX_CONCURRENT_VARIANT_LOOKUPS: usize = 32;
79
80
#[derive(Clone, Copy, Debug)]
81
pub enum FileType {
82
    Digest,
83
    String,
84
}
85
86
#[derive(Debug, MetricsComponent)]
87
pub struct SharedContext {
88
    // Used in testing to know how many active drop() spawns are running.
89
    // TODO(palfrey) It is probably a good idea to use a spin lock during
90
    // destruction of the store to ensure that all files are actually
91
    // deleted (similar to how it is done in tests).
92
    #[metric(help = "Number of active drop spawns")]
93
    pub active_drop_spawns: AtomicU64,
94
    #[metric(help = "Path to the configured temp path")]
95
    temp_path: String,
96
    #[metric(help = "Path to the configured content path")]
97
    content_path: String,
98
}
99
100
#[derive(Eq, PartialEq, Debug)]
101
enum PathType {
102
    Content,
103
    Temp,
104
    Custom(OsString),
105
}
106
107
/// [`EncodedFilePath`] stores the path to the file
108
/// including the context, path type and key to the file.
109
/// The whole [`StoreKey`] is stored as opposed to solely
110
/// the [`DigestInfo`] so that it is more usable for things
111
/// such as BEP -see Issue #1108
112
#[derive(Debug)]
113
pub struct EncodedFilePath {
114
    shared_context: Arc<SharedContext>,
115
    path_type: PathType,
116
    key: StoreKey<'static>,
117
}
118
119
impl EncodedFilePath {
120
    #[inline]
121
6.85k
    fn get_file_path(&self) -> Cow<'_, OsStr> {
122
6.85k
        get_file_path_raw(&self.path_type, self.shared_context.as_ref(), &self.key)
123
6.85k
    }
124
}
125
126
#[inline]
127
8.58k
fn get_file_path_raw<'a>(
128
8.58k
    path_type: &'a PathType,
129
8.58k
    shared_context: &SharedContext,
130
8.58k
    key: &StoreKey<'a>,
131
8.58k
) -> Cow<'a, OsStr> {
132
8.58k
    let 
folder8.55k
= match path_type {
133
3.38k
        PathType::Content => &shared_context.content_path,
134
5.17k
        PathType::Temp => &shared_context.temp_path,
135
30
        PathType::Custom(path) => return Cow::Borrowed(path),
136
    };
137
8.55k
    Cow::Owned(to_full_path_from_key(folder, key))
138
8.58k
}
139
140
impl Drop for EncodedFilePath {
141
1.93k
    fn drop(&mut self) {
142
        // `drop()` can be called during shutdown, so we use `path_type` flag to know if the
143
        // file actually needs to be deleted.
144
1.93k
        if self.path_type == PathType::Content {
145
1.91k
            return;
146
26
        }
147
148
26
        let file_path = self.get_file_path().to_os_string();
149
26
        let shared_context = self.shared_context.clone();
150
        // .fetch_add returns previous value, so we add one to get approximate current value
151
26
        let current_active_drop_spawns = shared_context
152
26
            .active_drop_spawns
153
26
            .fetch_add(1, Ordering::Relaxed)
154
26
            + 1;
155
26
        debug!(
156
            %current_active_drop_spawns,
157
            ?file_path,
158
            "Spawned a filesystem_delete_file"
159
        );
160
26
        background_spawn!("filesystem_delete_file", async move 
{20
161
20
            match fs::remove_file(&file_path).await {
162
15
                Ok(()) => debug!(?file_path, "File deleted"),
163
                // The file already being gone is the desired end state of a
164
                // delete, not a failure — e.g. an entry marked Temp after an
165
                // already-gone unref points at a path that was never created.
166
1
                Err(err) if err.code == Code::NotFound => {
167
1
                    debug!(?file_path, "File already gone, nothing to delete");
168
                }
169
0
                Err(err) => error!(?file_path, ?err, "Failed to delete file"),
170
            }
171
            // .fetch_sub returns previous value, so we subtract one to get approximate current value
172
16
            let current_active_drop_spawns = shared_context
173
16
                .active_drop_spawns
174
16
                .fetch_sub(1, Ordering::Relaxed)
175
16
                - 1;
176
16
            debug!(
177
                ?current_active_drop_spawns,
178
                ?file_path,
179
                "Dropped a filesystem_delete_file"
180
            );
181
16
        });
182
1.93k
    }
183
}
184
185
/// This creates the file path from the [`StoreKey`]. If
186
/// it is a string, the string, prefixed with [`STR_PREFIX`]
187
/// for backwards compatibility, is stored.
188
///
189
/// If it is a [`DigestInfo`], it is prefixed by [`DIGEST_PREFIX`]
190
/// followed by the string representation of a digest - the hash in hex,
191
/// a hyphen then the size in bytes
192
///
193
/// Previously, only the string representation of the [`DigestInfo`] was
194
/// used with no prefix
195
#[inline]
196
8.57k
fn to_full_path_from_key(folder: &str, key: &StoreKey<'_>) -> OsString {
197
8.57k
    match key {
198
3
        StoreKey::Str(str) => format!("{folder}/{STR_FOLDER}/{str}"),
199
8.56k
        StoreKey::Digest(digest_info) => format!("{folder}/{DIGEST_FOLDER}/{digest_info}"),
200
    }
201
8.57k
    .into()
202
8.57k
}
203
204
pub trait FileEntry: LenEntry + Send + Sync + Debug + 'static {
205
    /// Responsible for creating the underlying `FileEntry`.
206
    fn create(data_size: u64, block_size: u64, encoded_file_path: RwLock<EncodedFilePath>) -> Self;
207
208
    /// Creates a (usually) temp file, opens it and returns the path to the temp file.
209
    fn make_and_open_file(
210
        block_size: u64,
211
        encoded_file_path: EncodedFilePath,
212
    ) -> impl Future<Output = Result<(Self, FileSlot, OsString), Error>> + Send
213
    where
214
        Self: Sized;
215
216
    /// Returns the underlying size of the data in bytes
217
    fn data_size(&self) -> u64;
218
219
    /// Returns the underlying reference to the size of the data in bytes
220
    fn data_size_mut(&mut self) -> &mut u64;
221
222
    /// Returns the actual size of the underlying file on the disk after accounting for filesystem block size.
223
    fn size_on_disk(&self) -> u64;
224
225
    /// Gets the underlying `EncodedfilePath`.
226
    fn get_encoded_file_path(&self) -> &RwLock<EncodedFilePath>;
227
228
    /// Returns a reader that will read part of the underlying file.
229
    fn read_file_part(
230
        &self,
231
        offset: u64,
232
        length: u64,
233
    ) -> impl Future<Output = Result<Take<FileSlot>, Error>> + Send;
234
235
    /// This function is a safe way to extract the file name of the underlying file. To protect users from
236
    /// accidentally creating undefined behavior we encourage users to do the logic they need to do with
237
    /// the filename inside this function instead of extracting the filename and doing the logic outside.
238
    /// This is because the filename is not guaranteed to exist after this function returns, however inside
239
    /// the callback the file is always guaranteed to exist and immutable.
240
    /// DO NOT USE THIS FUNCTION TO EXTRACT THE FILENAME AND STORE IT FOR LATER USE.
241
    fn get_file_path_locked<
242
        T,
243
        Fut: Future<Output = Result<T, Error>> + Send,
244
        F: FnOnce(OsString) -> Fut + Send,
245
    >(
246
        &self,
247
        handler: F,
248
    ) -> impl Future<Output = Result<T, Error>> + Send;
249
}
250
251
pub struct FileEntryImpl {
252
    data_size: u64,
253
    block_size: u64,
254
    // We lock around this as it gets rewritten when we move between temp and content types
255
    encoded_file_path: RwLock<EncodedFilePath>,
256
}
257
258
impl FileEntryImpl {
259
0
    pub fn get_shared_context_for_test(&mut self) -> Arc<SharedContext> {
260
0
        self.encoded_file_path.get_mut().shared_context.clone()
261
0
    }
262
}
263
264
impl FileEntry for FileEntryImpl {
265
1.93k
    fn create(data_size: u64, block_size: u64, encoded_file_path: RwLock<EncodedFilePath>) -> Self {
266
1.93k
        Self {
267
1.93k
            data_size,
268
1.93k
            block_size,
269
1.93k
            encoded_file_path,
270
1.93k
        }
271
1.93k
    }
272
273
    /// This encapsulates the logic for the edge case of if the file fails to create
274
    /// the cleanup of the file is handled without creating a `FileEntry`, which would
275
    /// try to cleanup the file as well during `drop()`.
276
1.71k
    async fn make_and_open_file(
277
1.71k
        block_size: u64,
278
1.71k
        encoded_file_path: EncodedFilePath,
279
1.71k
    ) -> Result<(Self, FileSlot, OsString), Error> {
280
1.71k
        let temp_full_path = encoded_file_path.get_file_path().to_os_string();
281
1.71k
        let temp_file_result = fs::create_file(temp_full_path.clone())
282
1.71k
            .or_else(|mut err| async 
{0
283
0
                let remove_result = fs::remove_file(&temp_full_path).await.err_tip(|| {
284
0
                    format!(
285
                        "Failed to remove file {} in filesystem store",
286
0
                        temp_full_path.display()
287
                    )
288
0
                });
289
0
                if let Err(remove_err) = remove_result {
290
0
                    err = err.merge(remove_err);
291
0
                }
292
0
                warn!(?err, ?block_size, ?temp_full_path, "Failed to create file",);
293
0
                Err(err).err_tip(|| {
294
0
                    format!(
295
                        "Failed to create {} in filesystem store",
296
0
                        temp_full_path.display()
297
                    )
298
0
                })
299
0
            })
300
1.71k
            .await
?0
;
301
302
1.71k
        Ok((
303
1.71k
            <Self as FileEntry>::create(
304
1.71k
                0, /* Unknown yet, we will fill it in later */
305
1.71k
                block_size,
306
1.71k
                RwLock::new(encoded_file_path),
307
1.71k
            ),
308
1.71k
            temp_file_result,
309
1.71k
            temp_full_path,
310
1.71k
        ))
311
1.71k
    }
312
313
25
    fn data_size(&self) -> u64 {
314
25
        self.data_size
315
25
    }
316
317
1.71k
    fn data_size_mut(&mut self) -> &mut u64 {
318
1.71k
        &mut self.data_size
319
1.71k
    }
320
321
4.44k
    fn size_on_disk(&self) -> u64 {
322
4.44k
        self.data_size.div_ceil(self.block_size) * self.block_size
323
4.44k
    }
324
325
5.10k
    fn get_encoded_file_path(&self) -> &RwLock<EncodedFilePath> {
326
5.10k
        &self.encoded_file_path
327
5.10k
    }
328
329
376
    fn read_file_part(
330
376
        &self,
331
376
        offset: u64,
332
376
        length: u64,
333
376
    ) -> impl Future<Output = Result<Take<FileSlot>, Error>> + Send {
334
376
        self.get_file_path_locked(move |full_content_path| async move {
335
376
            let 
file373
= fs::open_file(&full_content_path, offset, length)
336
376
                .await
337
376
                .err_tip(|| 
{3
338
3
                    format!(
339
                        "Failed to open file in filesystem store {}",
340
3
                        full_content_path.display()
341
                    )
342
3
                })?;
343
373
            Ok(file)
344
752
        })
345
376
    }
346
347
1.62k
    async fn get_file_path_locked<
348
1.62k
        T,
349
1.62k
        Fut: Future<Output = Result<T, Error>> + Send,
350
1.62k
        F: FnOnce(OsString) -> Fut + Send,
351
1.62k
    >(
352
1.62k
        &self,
353
1.62k
        handler: F,
354
1.62k
    ) -> Result<T, Error> {
355
1.62k
        let encoded_file_path = self.get_encoded_file_path().read().await;
356
1.62k
        handler(encoded_file_path.get_file_path().to_os_string()).await
357
1.62k
    }
358
}
359
360
impl Debug for FileEntryImpl {
361
0
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), core::fmt::Error> {
362
0
        f.debug_struct("FileEntryImpl")
363
0
            .field("data_size", &self.data_size)
364
0
            .field("encoded_file_path", &"<behind mutex>")
365
0
            .finish()
366
0
    }
367
}
368
369
1.73k
fn make_temp_digest(mut digest: DigestInfo) -> DigestInfo {
370
    static DELETE_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
371
1.73k
    let mut hash = *digest.packed_hash();
372
1.73k
    hash[24..].clone_from_slice(
373
1.73k
        &DELETE_FILE_COUNTER
374
1.73k
            .fetch_add(1, Ordering::Relaxed)
375
1.73k
            .to_le_bytes(),
376
    );
377
1.73k
    digest.set_packed_hash(*hash);
378
1.73k
    digest
379
1.73k
}
380
381
1.73k
pub fn make_temp_key(key: &StoreKey) -> StoreKey<'static> {
382
1.73k
    StoreKey::Digest(make_temp_digest(key.borrow().into_digest()))
383
1.73k
}
384
385
/// Group-commit flush coalescer (macOS only).
386
///
387
/// On macOS, `File::sync_all` issues `fcntl(F_FULLFSYNC)` — a full
388
/// device-cache flush — once per call. The flush is serialized at the
389
/// device, costs multiple milliseconds, and dominates uploads of many
390
/// small blobs (a 4,400-tiny-file action input tree spends ~12s of its
391
/// ~14.5s materialization in these flushes). Linux does not have this
392
/// problem because concurrent `fsync` calls coalesce inside the
393
/// filesystem journal's group commit.
394
///
395
/// This applies the same group-commit idea in userspace, exploiting an
396
/// asymmetry in `F_FULLFSYNC`: writing a file's pages to the storage
397
/// device is per-file work (plain `fsync(2)`, cheap), while the expensive
398
/// device-cache drain is device-wide by nature. Each writer first pushes
399
/// its own data to the device with `fsync(2)`, then joins the current
400
/// commit round; a single long-lived flusher task per DEVICE issues one
401
/// `F_FULLFSYNC` (on a dedicated sentinel file) per round, covering every
402
/// previously-`fsync`ed blob at once. Rounds self-batch exactly like a
403
/// journal: while one flush is running, later writers accumulate into
404
/// the next round.
405
///
406
/// The coalescer (and its flusher task) is per store instance. Sharing
407
/// one coalescer across all stores on a device would amortize further —
408
/// the flush is device-wide — but a process-global flusher task cannot
409
/// safely outlive the tokio runtime that spawned it (multiple runtimes
410
/// coexist in one process, e.g. one per test); doing this correctly
411
/// needs a runtime-agnostic flusher (a dedicated OS thread) and is left
412
/// as a follow-up.
413
///
414
/// Durability is identical to per-file `F_FULLFSYNC`: a writer only
415
/// proceeds (and only renames its blob into the content directory) after
416
/// a device-cache flush that started after its own `fsync(2)` completed.
417
#[cfg(target_os = "macos")]
418
#[derive(Debug)]
419
struct FlushCoalescer {
420
    /// The round currently accepting waiters. Swapped out by the flusher
421
    /// right before it flushes, so writers whose `fsync(2)` finished
422
    /// after the flush began land in the next round.
423
    current_round: parking_lot::Mutex<Arc<FlushRound>>,
424
    /// Wakes the flusher task. Writers notify after subscribing to their
425
    /// round, so a wakeup can never be observed before its waiter.
426
    wake: Arc<tokio::sync::Notify>,
427
    /// Dedicated file the `F_FULLFSYNC` is issued on. A SIBLING of the
428
    /// store's content path (like the `.exec` variant directory), so
429
    /// neither the startup scan nor `move_old_cache`'s legacy root sweep
430
    /// ever sees it.
431
    sentinel: Arc<std::fs::File>,
432
    /// Number of device-wide barriers issued. Kept separately from the
433
    /// per-file `fsync(2)` count so tests can pin the coalescing invariant.
434
    full_flush_count: Arc<AtomicU64>,
435
}
436
437
#[cfg(target_os = "macos")]
438
#[derive(Debug)]
439
struct FlushRound {
440
    /// Broadcasts the flush outcome to every writer in the round.
441
    result_tx: tokio::sync::watch::Sender<Option<Result<(), Error>>>,
442
}
443
444
#[cfg(target_os = "macos")]
445
impl FlushRound {
446
    fn new() -> Arc<Self> {
447
        let (result_tx, _) = tokio::sync::watch::channel(None);
448
        Arc::new(Self { result_tx })
449
    }
450
}
451
452
/// Guarantees a [`FlushRound`]'s waiters always receive a result: if the
453
/// flusher task is cancelled at an await point (runtime shutdown), waiters
454
/// must get an error rather than pend forever — their own `Arc<FlushRound>`
455
/// keeps the channel alive, so a dropped-sender wakeup can never happen.
456
#[cfg(target_os = "macos")]
457
struct SendOnDrop(Option<Arc<FlushRound>>);
458
459
#[cfg(target_os = "macos")]
460
impl SendOnDrop {
461
    fn finish(mut self, result: Result<(), Error>) {
462
        if let Some(round) = self.0.take() {
463
            // Ignore send errors: every waiter may have been cancelled.
464
            drop(round.result_tx.send(Some(result)));
465
        }
466
    }
467
}
468
469
#[cfg(target_os = "macos")]
470
impl Drop for SendOnDrop {
471
    fn drop(&mut self) {
472
        if let Some(round) = self.0.take() {
473
            drop(round.result_tx.send(Some(Err(make_err!(
474
                Code::Internal,
475
                "Flush coalescer round task cancelled before completing"
476
            )))));
477
        }
478
    }
479
}
480
481
#[cfg(target_os = "macos")]
482
impl Drop for FlushCoalescer {
483
    fn drop(&mut self) {
484
        // Wake the flusher so it observes the dead Weak and exits instead
485
        // of parking forever.
486
        self.wake.notify_one();
487
    }
488
}
489
490
#[cfg(target_os = "macos")]
491
impl FlushCoalescer {
492
    /// Creates the coalescer for a store and spawns its flusher task on
493
    /// the current runtime (the same runtime the store's uploads run on).
494
    async fn for_content_path(content_path: &str) -> Result<Arc<Self>, Error> {
495
        // Sibling of `content_path` (never inside it): the startup scan
496
        // and `move_old_cache` sweep everything under the content root.
497
        let sentinel_path = format!("{content_path}.flush_sentinel");
498
        let sentinel = spawn_blocking!("filesystem_store_flush_sentinel_open", move || {
499
            std::fs::OpenOptions::new()
500
                .write(true)
501
                .create(true)
502
                .truncate(false)
503
                .open(&sentinel_path)
504
                .map_err(|e| {
505
                    make_err!(
506
                        Code::Internal,
507
                        "Failed to create flush sentinel {sentinel_path}: {e:?}"
508
                    )
509
                })
510
        })
511
        .await
512
        .err_tip(|| "Failed to join flush sentinel open task")??;
513
514
        let coalescer = Arc::new(Self {
515
            current_round: parking_lot::Mutex::new(FlushRound::new()),
516
            wake: Arc::new(tokio::sync::Notify::new()),
517
            sentinel: Arc::new(sentinel),
518
            full_flush_count: Arc::new(AtomicU64::new(0)),
519
        });
520
521
        let weak = Arc::downgrade(&coalescer);
522
        let wake = coalescer.wake.clone();
523
        background_spawn!("filesystem_store_flush_coalescer", async move {
524
            loop {
525
                wake.notified().await;
526
                let Some(coalescer) = weak.upgrade() else {
527
                    return;
528
                };
529
                coalescer.flush_one_round().await;
530
                // Drop the strong ref before parking so the store can be
531
                // torn down while the flusher is idle.
532
            }
533
        });
534
        Ok(coalescer)
535
    }
536
537
    /// Waits until a device-cache flush that started after this call has
538
    /// completed. Callers must have already `fsync(2)`ed their own file.
539
    async fn commit(&self) -> Result<(), Error> {
540
        let round = self.current_round.lock().clone();
541
        let mut result_rx = round.result_tx.subscribe();
542
        // Notify strictly after subscribing: the flusher skips rounds
543
        // with no receivers, so this ordering makes lost wakeups
544
        // impossible (a permit is stored even while the flusher is busy).
545
        self.wake.notify_one();
546
        let result_ref = result_rx
547
            .wait_for(Option::is_some)
548
            .await
549
            .map_err(|_| make_err!(Code::Internal, "Flush coalescer round dropped"))?;
550
        result_ref
551
            .clone()
552
            .unwrap_or_else(|| Err(make_err!(Code::Internal, "Flush round result missing")))
553
    }
554
555
    async fn flush_one_round(&self) {
556
        let round = self.current_round.lock().clone();
557
        if round.result_tx.receiver_count() == 0 {
558
            // Spurious wakeup (e.g. a permit left over from a round that
559
            // a previous iteration already served).
560
            return;
561
        }
562
        let send_guard = SendOnDrop(Some(round.clone()));
563
564
        // Brief accumulation window, only when this round actually has
565
        // multiple waiters: lets a burst pack more writers into the round
566
        // before it closes, trading ~3ms of publish latency (about the
567
        // cost of one device flush) for fewer device flushes. A solo
568
        // writer on an idle store skips it and pays only its own flush.
569
        if round.result_tx.receiver_count() > 1 {
570
            tokio::time::sleep(Duration::from_millis(3)).await;
571
        }
572
        // Close the round: writers arriving from here on cannot assume
573
        // this flush covers them, so they must get a fresh round.
574
        {
575
            let mut current = self.current_round.lock();
576
            if Arc::ptr_eq(&*current, &round) {
577
                *current = FlushRound::new();
578
            }
579
        }
580
        let sentinel = self.sentinel.clone();
581
        let full_flush_count = self.full_flush_count.clone();
582
        let flush_result = spawn_blocking!("filesystem_store_full_flush", move || {
583
            use std::os::fd::AsRawFd;
584
            use std::os::unix::fs::FileExt;
585
            // Keep the sentinel dirty so the flush is never a no-op.
586
            // Best-effort: a failed write (e.g. ENOSPC on a full CoW
587
            // volume) must not fail the whole round — the fcntl below is
588
            // what provides the device-cache drain.
589
            if let Err(err) = sentinel.write_at(b"f", 0) {
590
                warn!(?err, "Flush sentinel write failed; issuing flush anyway");
591
            }
592
            // Count the actual blocking operation, rather than merely counting
593
            // rounds scheduled by the async task.
594
            full_flush_count.fetch_add(1, Ordering::Relaxed);
595
            loop {
596
                if unsafe { libc::fcntl(sentinel.as_raw_fd(), libc::F_FULLFSYNC) } != -1 {
597
                    return Ok(());
598
                }
599
                let err = std::io::Error::last_os_error();
600
                // std's sync_all retries EINTR (cvt_r); match it.
601
                if err.kind() != std::io::ErrorKind::Interrupted {
602
                    return Err(Error::from(err).append("F_FULLFSYNC failed in flush coalescer"));
603
                }
604
            }
605
        })
606
        .await
607
        .unwrap_or_else(|e| {
608
            Err(Error::from_std_err(Code::Internal, &e).append("Flush coalescer task failed"))
609
        });
610
        send_guard.finish(flush_result);
611
    }
612
}
613
614
#[cfg(unix)]
615
109
async fn prepare_executable_dir(content_path: &str) -> Result<bool, Error> {
616
1
    fn is_non_writable_error(err: &std::io::Error) -> bool {
617
0
        matches!(
618
1
            err.kind(),
619
            std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem
620
        )
621
1
    }
622
623
109
    let executable_dir = format!("{content_path}{EXECUTABLE_DIR_SUFFIX}");
624
109
    let executable_digest_dir = format!("{executable_dir}/{DIGEST_FOLDER}");
625
109
    spawn_blocking!("filesystem_store_prepare_executable_dir", move || {
626
109
        match std::fs::remove_dir_all(&executable_dir) {
627
2
            Ok(()) => {}
628
107
            Err(
err106
) if err.kind() == std::io::ErrorKind::NotFoun
d106
=>
{}106
629
            // A read-only cache must remain available for ordinary reads. Do
630
            // not reuse the directory, though: a surviving executable variant
631
            // may be stale or torn after an unclean shutdown.
632
1
            Err(err) if is_non_writable_error(&err) => return Ok(false),
633
0
            Err(err) => {
634
0
                return Err(Error::from(err)
635
0
                    .append(format!("Failed to clear executable dir {executable_dir}")));
636
            }
637
        }
638
639
108
        match std::fs::create_dir_all(&executable_digest_dir) {
640
108
            Ok(()) => Ok(true),
641
0
            Err(err) if is_non_writable_error(&err) => Ok(false),
642
0
            Err(err) => Err(Error::from(err).append(format!(
643
0
                "Failed to create executable dir {executable_digest_dir}"
644
0
            ))),
645
        }
646
109
    })
647
109
    .await
648
109
    .err_tip(|| "Failed to join executable-dir preparation task")
?0
649
109
}
650
651
impl LenEntry for FileEntryImpl {
652
    #[inline]
653
4.44k
    fn len(&self) -> u64 {
654
4.44k
        self.size_on_disk()
655
4.44k
    }
656
657
0
    fn is_empty(&self) -> bool {
658
0
        self.data_size == 0
659
0
    }
660
661
    // unref() only triggers when an item is removed from the eviction_map. It is possible
662
    // that another place in code has a reference to `FileEntryImpl` and may later read the
663
    // file. To support this edge case, we first move the file to a temp file and point
664
    // target file location to the new temp file. `unref()` should only ever be called once.
665
    #[inline]
666
23
    async fn unref(&self) {
667
23
        let mut encoded_file_path = self.encoded_file_path.write().await;
668
23
        if encoded_file_path.path_type == PathType::Temp {
669
            // We are already a temp file that is now marked for deletion on drop.
670
            // This is very rare, but most likely the rename into the content path failed.
671
5
            warn!(
672
5
                key = ?encoded_file_path.key,
673
                "File is already a temp file",
674
            );
675
5
            return;
676
18
        }
677
18
        let from_path = encoded_file_path.get_file_path();
678
18
        let new_key = make_temp_key(&encoded_file_path.key);
679
680
18
        let to_path = to_full_path_from_key(&encoded_file_path.shared_context.temp_path, &new_key);
681
682
18
        if let Err(
err5
) = fs::rename(&from_path, &to_path).await {
683
            // ENOENT from rename is ambiguous: the source may be gone, or
684
            // a directory component of the destination (the temp dir) may
685
            // be missing. Confirm the source is genuinely gone before
686
            // treating it as benign — otherwise a removed temp dir would
687
            // flip an intact content file to Temp and orphan it on disk.
688
5
            let source_gone = err.code == Code::NotFound
689
4
                && matches!(
690
5
                    fs::metadata(&from_path).await,
691
4
                    Err(meta_err) if meta_err.code == Code::NotFound
692
                );
693
5
            if source_gone {
694
                // The file is already gone — typically another thread's
695
                // eviction beat us, or the entry never got its file on
696
                // disk. Benign here, and it dominates log volume under
697
                // heavy write+evict concurrency, so keep it at `debug`.
698
                // Mark the entry Temp (as a successful rename would) so a
699
                // repeat unref is a no-op and drop stops claiming the
700
                // content path.
701
4
                debug!(
702
4
                    key = ?encoded_file_path.key,
703
                    "Failed to rename file (already gone, treating as benign)",
704
                );
705
4
                encoded_file_path.path_type = PathType::Temp;
706
4
                encoded_file_path.key = new_key;
707
            } else {
708
                // Either a non-ENOENT failure (EACCES, EXDEV, EBUSY, …) or
709
                // ENOENT with the source still present (missing temp dir).
710
                // The content file is intact; leave the entry as Content.
711
1
                warn!(
712
1
                    key = ?encoded_file_path.key,
713
                    ?from_path,
714
                    ?to_path,
715
                    ?err,
716
                    "Failed to rename file",
717
                );
718
            }
719
        } else {
720
13
            debug!(
721
13
                key = ?encoded_file_path.key,
722
                ?from_path,
723
                ?to_path,
724
                "Renamed file (unref)",
725
            );
726
13
            encoded_file_path.path_type = PathType::Temp;
727
13
            encoded_file_path.key = new_key;
728
        }
729
23
    }
730
}
731
732
#[inline]
733
203
fn digest_from_filename(file_name: &str) -> Result<DigestInfo, Error> {
734
203
    let (hash, size) = file_name.split_once('-').err_tip(|| "")
?0
;
735
203
    let size = size.parse::<i64>()
?0
;
736
203
    DigestInfo::try_new(hash, size)
737
203
}
738
739
204
pub fn key_from_file(file_name: &str, file_type: FileType) -> Result<StoreKey<'_>, Error> {
740
204
    match file_type {
741
1
        FileType::String => Ok(StoreKey::new_str(file_name)),
742
203
        FileType::Digest => digest_from_filename(file_name).map(StoreKey::Digest),
743
    }
744
204
}
745
746
/// The number of files to read the metadata for at the same time when running
747
/// `add_files_to_cache`.
748
const SIMULTANEOUS_METADATA_READS: usize = 200;
749
750
type FsEvictingMap<'a, Fe> =
751
    EvictingMap<StoreKeyBorrow, StoreKey<'a>, Arc<Fe>, SystemTime, RemoveCallbackHolder>;
752
753
109
async fn add_files_to_cache<Fe: FileEntry>(
754
109
    evicting_map: &FsEvictingMap<'_, Fe>,
755
109
    anchor_time: &SystemTime,
756
109
    shared_context: &Arc<SharedContext>,
757
109
    block_size: u64,
758
109
    rename_fn: fn(&OsStr, &OsStr) -> Result<(), std::io::Error>,
759
109
) -> Result<(), Error> {
760
    #[expect(clippy::too_many_arguments)]
761
203
    async fn process_entry<Fe: FileEntry>(
762
203
        evicting_map: &FsEvictingMap<'_, Fe>,
763
203
        file_name: &str,
764
203
        file_type: FileType,
765
203
        atime: SystemTime,
766
203
        data_size: u64,
767
203
        block_size: u64,
768
203
        anchor_time: &SystemTime,
769
203
        shared_context: &Arc<SharedContext>,
770
203
    ) -> Result<(), Error> {
771
203
        let key = key_from_file(file_name, file_type)
?0
;
772
773
203
        let file_entry = Fe::create(
774
203
            data_size,
775
203
            block_size,
776
203
            RwLock::new(EncodedFilePath {
777
203
                shared_context: shared_context.clone(),
778
203
                path_type: PathType::Content,
779
203
                key: key.borrow().into_owned(),
780
203
            }),
781
        );
782
203
        let time_since_anchor = if let Ok(
d202
) = anchor_time.duration_since(atime) {
783
202
            d
784
        } else {
785
1
            warn!(
786
                %file_name,
787
1
                atime = %humantime::format_rfc3339(atime),
788
1
                anchor_time = %humantime::format_rfc3339(*anchor_time),
789
                "File access time newer than FilesystemStore start time",
790
            );
791
1
            Duration::ZERO
792
        };
793
203
        evicting_map
794
203
            .insert_with_time(
795
203
                key.into_owned().into(),
796
203
                Arc::new(file_entry),
797
203
                i32::try_from(time_since_anchor.as_secs()).unwrap_or(i32::MAX),
798
203
            )
799
203
            .await;
800
203
        Ok(())
801
203
    }
802
803
327
    async fn read_files(
804
327
        folder: Option<&str>,
805
327
        shared_context: &SharedContext,
806
327
    ) -> Result<Vec<(String, SystemTime, u64, bool)>, Error> {
807
        // Note: In Dec 2024 this is for backwards compatibility with the old
808
        // way files were stored on disk. Previously all files were in a single
809
        // folder regardless of the StoreKey type. This allows old versions of
810
        // nativelink file layout to be upgraded at startup time.
811
        // This logic can be removed once more time has passed.
812
327
        let read_dir = folder.map_or_else(
813
109
            || format!("{}/", shared_context.content_path),
814
218
            |folder| format!("{}/{folder}/", shared_context.content_path),
815
        );
816
817
327
        let (_permit, dir_handle) = fs::read_dir(read_dir)
818
327
            .await
819
327
            .err_tip(|| "Failed opening content directory for iterating in filesystem store")
?0
820
327
            .into_inner();
821
822
327
        let read_dir_stream = ReadDirStream::new(dir_handle);
823
327
        read_dir_stream
824
421
            .
map327
(|dir_entry| async move {
825
421
                let dir_entry = dir_entry.unwrap();
826
421
                let file_name = dir_entry.file_name().into_string().unwrap();
827
421
                let metadata = dir_entry
828
421
                    .metadata()
829
421
                    .await
830
421
                    .err_tip(|| "Failed to get metadata in filesystem store")
?0
;
831
                // We need to filter out folders - we do not want to try to cache the s and d folders.
832
421
                let is_file =
833
421
                    metadata.is_file() || !(
file_name == STR_FOLDER218
||
file_name == DIGEST_FOLDER109
);
834
                // Using access time is not perfect, but better than random. We do not update the
835
                // atime when a file is actually "touched", we rely on whatever the filesystem does
836
                // when we read the file (usually update on read).
837
421
                let atime = metadata
838
421
                    .accessed()
839
421
                    .or_else(|_| 
metadata0
.
modified0
())
840
421
                    .unwrap_or(SystemTime::UNIX_EPOCH);
841
421
                Result::<(String, SystemTime, u64, bool), Error>::Ok((
842
421
                    file_name,
843
421
                    atime,
844
421
                    metadata.len(),
845
421
                    is_file,
846
421
                ))
847
842
            })
848
327
            .buffer_unordered(SIMULTANEOUS_METADATA_READS)
849
327
            .try_collect()
850
327
            .await
851
327
    }
852
853
    /// Note: In Dec 2024 this is for backwards compatibility with the old
854
    /// way files were stored on disk. Previously all files were in a single
855
    /// folder regardless of the [`StoreKey`] type. This moves files from the old cache
856
    /// location to the new cache location, under [`DIGEST_FOLDER`].
857
109
    async fn move_old_cache(
858
109
        shared_context: &Arc<SharedContext>,
859
109
        rename_fn: fn(&OsStr, &OsStr) -> Result<(), std::io::Error>,
860
109
    ) -> Result<(), Error> {
861
109
        let file_infos = read_files(None, shared_context).await
?0
;
862
863
109
        let from_path = &shared_context.content_path;
864
865
109
        let to_path = format!("{}/{DIGEST_FOLDER}", shared_context.content_path);
866
867
109
        for (
file_name0
, _, _, _) in file_infos.into_iter().filter(|x| x.3) {
868
0
            let from_file: OsString = format!("{from_path}/{file_name}").into();
869
0
            let to_file: OsString = format!("{to_path}/{file_name}").into();
870
871
0
            if let Err(err) = rename_fn(&from_file, &to_file) {
872
0
                warn!(?from_file, ?to_file, ?err, "Failed to rename file",);
873
            } else {
874
0
                debug!(?from_file, ?to_file, "Renamed file (old cache)",);
875
            }
876
        }
877
109
        Ok(())
878
109
    }
879
880
218
    async fn add_files_to_cache<Fe: FileEntry>(
881
218
        evicting_map: &FsEvictingMap<'_, Fe>,
882
218
        anchor_time: &SystemTime,
883
218
        shared_context: &Arc<SharedContext>,
884
218
        block_size: u64,
885
218
        folder: &str,
886
218
    ) -> Result<(), Error> {
887
218
        let file_infos = read_files(Some(folder), shared_context).await
?0
;
888
218
        let file_type = match folder {
889
218
            STR_FOLDER => 
FileType::String109
,
890
109
            DIGEST_FOLDER => FileType::Digest,
891
0
            _ => panic!("Invalid folder type"),
892
        };
893
894
218
        let path_root = format!("{}/{folder}", shared_context.content_path);
895
896
218
        for (
file_name203
,
atime203
,
data_size203
, _) in file_infos.into_iter().filter(|x| x.3) {
897
203
            let result = process_entry(
898
203
                evicting_map,
899
203
                &file_name,
900
203
                file_type,
901
203
                atime,
902
203
                data_size,
903
203
                block_size,
904
203
                anchor_time,
905
203
                shared_context,
906
203
            )
907
203
            .await;
908
203
            if let Err(
err0
) = result {
909
0
                warn!(?file_name, ?err, "Failed to add file to eviction cache",);
910
                // Ignore result.
911
0
                drop(fs::remove_file(format!("{path_root}/{file_name}")).await);
912
203
            }
913
        }
914
218
        Ok(())
915
218
    }
916
917
109
    move_old_cache(shared_context, rename_fn).await
?0
;
918
919
109
    add_files_to_cache(
920
109
        evicting_map,
921
109
        anchor_time,
922
109
        shared_context,
923
109
        block_size,
924
109
        DIGEST_FOLDER,
925
109
    )
926
109
    .await
?0
;
927
928
109
    add_files_to_cache(
929
109
        evicting_map,
930
109
        anchor_time,
931
109
        shared_context,
932
109
        block_size,
933
109
        STR_FOLDER,
934
109
    )
935
109
    .await
?0
;
936
109
    Ok(())
937
109
}
938
939
109
async fn prune_temp_path(temp_path: &str) -> Result<(), Error> {
940
218
    async fn prune_temp_inner(temp_path: &str, subpath: &str) -> Result<(), Error> {
941
218
        let (_permit, dir_handle) = fs::read_dir(format!("{temp_path}/{subpath}"))
942
218
            .await
943
218
            .err_tip(
944
                || "Failed opening temp directory to prune partial downloads in filesystem store",
945
0
            )?
946
218
            .into_inner();
947
948
218
        let mut read_dir_stream = ReadDirStream::new(dir_handle);
949
218
        while let Some(
dir_entry0
) = read_dir_stream.next().await {
950
0
            let path = dir_entry?.path();
951
0
            if let Err(err) = fs::remove_file(&path).await {
952
0
                warn!(?path, ?err, "Failed to delete file",);
953
0
            }
954
        }
955
218
        Ok(())
956
218
    }
957
958
109
    prune_temp_inner(temp_path, STR_FOLDER).await
?0
;
959
109
    prune_temp_inner(temp_path, DIGEST_FOLDER).await
?0
;
960
109
    Ok(())
961
109
}
962
963
// Sometimes we get files to emplace that are identical to the existing files
964
// Due to the evict/remove/replace cycle taking some amount of time, we actually
965
// want to drop these
966
// Return value is "is duplicate"
967
1.73k
pub async fn check_duplicate_files<Fe>(
968
1.73k
    evicting_map: &Arc<FsEvictingMap<'_, Fe>>,
969
1.73k
    key: &StoreKey<'static>,
970
1.73k
    entry: &Arc<Fe>,
971
1.73k
) -> Result<bool, Error>
972
1.73k
where
973
1.73k
    Fe: FileEntry,
974
1.73k
{
975
1.73k
    let temp_file_encoded_file_path = entry.get_encoded_file_path().write().await;
976
1.73k
    let maybe_existing_item = evicting_map.get(&key.borrow().into_owned()).await;
977
1.73k
    if let Some(
existing_item9
) = maybe_existing_item {
978
9
        if Arc::ptr_eq(entry, &existing_item) {
979
1
            warn!("Tried to check duplicate of an entry we already have!");
980
1
            return Ok(true);
981
8
        }
982
8
        let existing_item_encoded_file_path = existing_item.get_encoded_file_path().write().await;
983
8
        if entry.data_size() == existing_item.data_size() {
984
            const CHUNK_SIZE: usize = 16 * 1024; // 16kb chunks, kinda picked out of the air
985
7
            let file_length = entry.data_size();
986
7
            let existing_path = existing_item_encoded_file_path.get_file_path();
987
7
            let temp_path = temp_file_encoded_file_path.get_file_path();
988
7
            trace!(?existing_path, ?temp_path, "Checking duplicate files");
989
7
            let mut temp_file = fs::open_file(&temp_path, 0, file_length).await
?0
;
990
7
            let mut existing_file = fs::open_file(&existing_path, 0, file_length).await
?0
;
991
992
7
            let mut temp_buffer: [u8; CHUNK_SIZE] = [0; CHUNK_SIZE];
993
7
            let mut existing_buffer: [u8; CHUNK_SIZE] = [0; CHUNK_SIZE];
994
            // in a file_length file, there are 0 to file_length-1 entries
995
            // not file_length. It's counting all the bytes, starting from 0
996
7
            for offset in (0..file_length - 1).step_by(CHUNK_SIZE) {
997
7
                let buffer_size = if offset + (CHUNK_SIZE as u64) <= file_length {
998
0
                    CHUNK_SIZE
999
7
                } else if file_length < CHUNK_SIZE as u64 {
1000
7
                    usize::try_from(file_length)
1001
7
                        .expect("Always succeeds because file_length < 16384")
1002
                } else {
1003
0
                    usize::try_from(file_length - offset).expect("Always succeeds because offset < file_length, and offset-file_length must be < 16384")
1004
                };
1005
7
                if let Err(
err0
) = temp_file.read_exact(&mut temp_buffer[0..buffer_size]).await {
1006
0
                    warn!(
1007
                        ?err,
1008
                        ?temp_path,
1009
                        file_length,
1010
                        offset,
1011
                        buffer_size,
1012
                        "Failed to read temp file, skipping duplicate check"
1013
                    );
1014
0
                    return Ok(false);
1015
7
                }
1016
7
                if let Err(
err0
) = existing_file
1017
7
                    .read_exact(&mut existing_buffer[0..buffer_size])
1018
7
                    .await
1019
                {
1020
0
                    warn!(
1021
                        ?err,
1022
                        ?existing_path,
1023
                        file_length,
1024
                        offset,
1025
                        buffer_size,
1026
                        "Failed to read existing, skipping duplicate check"
1027
                    );
1028
0
                    return Ok(false);
1029
7
                }
1030
7
                if temp_buffer.ne(&existing_buffer) {
1031
5
                    trace!(
1032
                        ?existing_path,
1033
                        ?temp_path,
1034
                        "Files are different, so non-duplicate"
1035
                    );
1036
5
                    return Ok(false);
1037
2
                }
1038
            }
1039
2
            trace!(
1040
                ?existing_path,
1041
                ?temp_path,
1042
                "Identical files, so don't need to edit, skipping emplace"
1043
            );
1044
2
            return Ok(true);
1045
1
        }
1046
1
        trace!(
1047
1
            entry_data_size = entry.data_size(),
1048
1
            existing_data_size = existing_item.data_size(),
1049
1
            existing_path = ?existing_item_encoded_file_path.get_file_path(),
1050
            "Different data sizes, so non-duplicate"
1051
        );
1052
    } else {
1053
1.72k
        trace!(
1054
1.72k
            temp_file = ?temp_file_encoded_file_path.get_file_path(),
1055
            "No existing entry, so not duplicate"
1056
        );
1057
    }
1058
1.72k
    Ok(false)
1059
1.73k
}
1060
1061
/// Deletes a digest's `.exec` variant (see
1062
/// [`FilesystemStore::get_executable_hardlink_source`]) when that digest is
1063
/// evicted or replaced in the primary CAS `evicting_map`. Without this, the
1064
/// `.exec` directory is invisible to `max_bytes` and is only ever cleared by
1065
/// the startup `remove_dir_all`, so it grows without bound at runtime (#2474).
1066
/// Tying its lifetime to the primary entry instead bounds total disk use to
1067
/// roughly `2 * max_bytes` in the worst case (every blob also executable).
1068
#[cfg(unix)]
1069
#[derive(Debug)]
1070
struct ExecutableVariantRemover {
1071
    content_path: String,
1072
}
1073
1074
#[cfg(unix)]
1075
impl RemoveItemCallback for ExecutableVariantRemover {
1076
20
    fn callback<'a>(
1077
20
        &'a self,
1078
20
        store_key: StoreKey<'a>,
1079
20
    ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
1080
20
        Box::pin(async move {
1081
20
            let StoreKey::Digest(
digest19
) = store_key else {
1082
1
                return;
1083
            };
1084
19
            let variant_path = format!(
1085
                "{}{EXECUTABLE_DIR_SUFFIX}/{DIGEST_FOLDER}/{digest}",
1086
                self.content_path
1087
            );
1088
19
            match fs::remove_file(&variant_path).await {
1089
1
                Ok(()) => debug!(
1090
                    ?variant_path,
1091
                    "Deleted executable variant for evicted digest"
1092
                ),
1093
                // Common case: no variant was ever materialized for this digest.
1094
18
                Err(err) if err.code == Code::NotFound => {}
1095
0
                Err(err) => warn!(
1096
                    ?variant_path,
1097
                    ?err,
1098
                    "Failed to delete executable variant for evicted digest"
1099
                ),
1100
            }
1101
20
        })
1102
20
    }
1103
}
1104
1105
#[derive(Debug, MetricsComponent)]
1106
pub struct FilesystemStore<Fe: FileEntry = FileEntryImpl> {
1107
    #[metric]
1108
    shared_context: Arc<SharedContext>,
1109
    #[metric(group = "evicting_map")]
1110
    evicting_map: Arc<FsEvictingMap<'static, Fe>>,
1111
    #[metric(help = "Block size of the configured filesystem")]
1112
    block_size: u64,
1113
    #[metric(help = "Size of the configured read buffer size")]
1114
    read_buffer_size: usize,
1115
    /// See [`FilesystemSpec::evict_page_cache`]. When false (the default) the
1116
    /// per-blob `posix_fadvise(DONTNEED)` calls are skipped.
1117
    evict_page_cache: bool,
1118
    weak_self: Weak<Self>,
1119
    rename_fn: fn(&OsStr, &OsStr) -> Result<(), std::io::Error>,
1120
    /// Limits concurrent write operations to prevent disk I/O saturation.
1121
    write_semaphore: Option<Semaphore>,
1122
    /// See [`FlushCoalescer`]: amortizes the per-blob `F_FULLFSYNC` cost
1123
    /// across concurrent uploads without weakening durability. `None` when
1124
    /// the sentinel could not be created (e.g. read-only content volume);
1125
    /// uploads then fall back to per-file `sync_all`, matching the old
1126
    /// behavior (and such stores fail at write time anyway).
1127
    #[cfg(target_os = "macos")]
1128
    flush_coalescer: Option<Arc<FlushCoalescer>>,
1129
    /// Per-digest single-flight locks guarding creation of the executable
1130
    /// variant in `{content_path}.exec`, so each variant's writable fd is
1131
    /// opened exactly once. The outer lock is sync and only ever held to
1132
    /// get/insert/remove the per-digest async lock — never across I/O.
1133
    #[cfg(unix)]
1134
    executable_locks: std::sync::Mutex<HashMap<DigestInfo, Arc<Mutex<()>>>>,
1135
    /// False when the startup wipe could not safely remove old executable
1136
    /// variants from a read-only filesystem. Ordinary CAS reads remain
1137
    /// available, but executable hardlink sources must not reuse stale files.
1138
    #[cfg(unix)]
1139
    executable_variants_enabled: bool,
1140
}
1141
1142
impl<Fe: FileEntry> FilesystemStore<Fe> {
1143
97
    pub async fn new(spec: &FilesystemSpec) -> Result<Arc<Self>, Error> {
1144
1.72k
        
Self::new_with_timeout_and_rename_fn97
(
spec97
, |from, to| std::fs::rename(from, to)).
await97
1145
97
    }
1146
1147
109
    pub async fn new_with_timeout_and_rename_fn(
1148
109
        spec: &FilesystemSpec,
1149
109
        rename_fn: fn(&OsStr, &OsStr) -> Result<(), std::io::Error>,
1150
109
    ) -> Result<Arc<Self>, Error> {
1151
218
        async fn create_subdirs(path: &str) -> Result<(), Error> {
1152
218
            fs::create_dir_all(format!("{path}/{STR_FOLDER}"))
1153
218
                .await
1154
218
                .err_tip(|| 
format!0
("Failed to create directory {path}/{STR_FOLDER}"))
?0
;
1155
218
            fs::create_dir_all(format!("{path}/{DIGEST_FOLDER}"))
1156
218
                .await
1157
218
                .err_tip(|| 
format!0
("Failed to create directory {path}/{DIGEST_FOLDER}"))
1158
218
        }
1159
1160
109
        let now = SystemTime::now();
1161
1162
109
        let empty_policy = nativelink_config::stores::EvictionPolicy::default();
1163
109
        let eviction_policy = spec.eviction_policy.as_ref().unwrap_or(&empty_policy);
1164
109
        let evicting_map = Arc::new(EvictingMap::new(eviction_policy, now));
1165
1166
        // Create temp and content directories and the s and d subdirectories.
1167
1168
109
        create_subdirs(&spec.temp_path).await
?0
;
1169
109
        create_subdirs(&spec.content_path).await
?0
;
1170
1171
        // Executable-variant directory: a sibling of `content_path` holding
1172
        // per-digest 0o555 copies used as hardlink sources for executable
1173
        // inputs (see `get_executable_hardlink_source`). Cleared on writable
1174
        // startup — the variants are regenerable and we never want a stale one
1175
        // to leak across runs. If a read-only filesystem prevents the wipe,
1176
        // ordinary CAS reads remain enabled but executable variants do not.
1177
        // Unix-only: the executable bit (and the ETXTBSY race it guards
1178
        // against) does not apply on Windows.
1179
        #[cfg(unix)]
1180
109
        let executable_variants_enabled = {
1181
109
            let enabled = prepare_executable_dir(&spec.content_path).await
?0
;
1182
109
            if !enabled {
1183
1
                warn!(
1184
1
                    executable_dir = %format!("{}{EXECUTABLE_DIR_SUFFIX}", spec.content_path),
1185
                    "Executable directory is not writable; serving CAS reads with executable variants disabled"
1186
                );
1187
108
            }
1188
109
            if enabled {
1189
108
                // Only register cleanup when executable variants are enabled.
1190
108
                // Otherwise surviving variants are deliberately quarantined,
1191
108
                // and repeated deletion warnings would obscure the fallback.
1192
108
                evicting_map.add_remove_callback(RemoveCallbackHolder::new(Arc::new(
1193
108
                    ExecutableVariantRemover {
1194
108
                        content_path: spec.content_path.clone(),
1195
108
                    },
1196
108
                )));
1197
108
            
}1
1198
109
            enabled
1199
        };
1200
1201
109
        let shared_context = Arc::new(SharedContext {
1202
109
            active_drop_spawns: AtomicU64::new(0),
1203
109
            temp_path: spec.temp_path.clone(),
1204
109
            content_path: spec.content_path.clone(),
1205
109
        });
1206
1207
109
        let block_size = if spec.block_size == 0 {
1208
102
            DEFAULT_BLOCK_SIZE
1209
        } else {
1210
7
            spec.block_size
1211
        };
1212
109
        add_files_to_cache(
1213
109
            evicting_map.as_ref(),
1214
109
            &now,
1215
109
            &shared_context,
1216
109
            block_size,
1217
109
            rename_fn,
1218
109
        )
1219
109
        .await
?0
;
1220
109
        prune_temp_path(&shared_context.temp_path).await
?0
;
1221
1222
109
        let read_buffer_size = if spec.read_buffer_size == 0 {
1223
94
            DEFAULT_BUFF_SIZE
1224
        } else {
1225
15
            spec.read_buffer_size as usize
1226
        };
1227
109
        let write_semaphore = if spec.max_concurrent_writes > 0 {
1228
0
            Some(Semaphore::new(spec.max_concurrent_writes))
1229
        } else {
1230
109
            None
1231
        };
1232
        // Never make construction fail over the durability optimization:
1233
        // a read-only content volume must still serve reads, exactly as
1234
        // it did before the coalescer existed.
1235
        #[cfg(target_os = "macos")]
1236
        let flush_coalescer = FlushCoalescer::for_content_path(&shared_context.content_path)
1237
            .await
1238
            .inspect_err(|err| {
1239
                warn!(
1240
                    ?err,
1241
                    "Failed to create flush coalescer; falling back to per-file sync_all"
1242
                );
1243
            })
1244
            .ok();
1245
1246
109
        Ok(Arc::new_cyclic(|weak_self| Self {
1247
109
            shared_context,
1248
109
            evicting_map,
1249
109
            block_size,
1250
109
            read_buffer_size,
1251
109
            evict_page_cache: spec.evict_page_cache,
1252
109
            weak_self: weak_self.clone(),
1253
109
            rename_fn,
1254
109
            write_semaphore,
1255
            #[cfg(target_os = "macos")]
1256
            flush_coalescer,
1257
            #[cfg(unix)]
1258
109
            executable_locks: std::sync::Mutex::new(HashMap::new()),
1259
            #[cfg(unix)]
1260
109
            executable_variants_enabled,
1261
109
        }))
1262
109
    }
1263
1264
68
    pub fn get_arc(&self) -> Option<Arc<Self>> {
1265
68
        self.weak_self.upgrade()
1266
68
    }
1267
1268
    /// Reserve a digest before it is populated so active action inputs cannot
1269
    /// be evicted while they are being materialized.
1270
3
    pub fn lease_digest(&self, digest: &DigestInfo) {
1271
3
        let key: StoreKey<'static> = (*digest).into();
1272
3
        self.evicting_map.lease_key(StoreKeyBorrow::from(key));
1273
3
    }
1274
1275
    /// Release a batch of action-input leases and trim retained entries once.
1276
1
    pub async fn release_digests(&self, digests: &[DigestInfo]) {
1277
1
        self.evicting_map
1278
3
            .
release_keys1
(
digests1
.
iter1
().
map1
(|digest| StoreKey::Digest(*digest)))
1279
1
            .await;
1280
1
    }
1281
1282
    /// Path of the read-only executable (0o555) variant for `digest`.
1283
    #[cfg(unix)]
1284
10
    fn executable_variant_path(&self, digest: &DigestInfo) -> OsString {
1285
10
        format!(
1286
            "{}{EXECUTABLE_DIR_SUFFIX}/{DIGEST_FOLDER}/{digest}",
1287
10
            self.shared_context.content_path
1288
        )
1289
10
        .into()
1290
10
    }
1291
1292
    /// Resolves the executable variant for many digests at once, batching the
1293
    /// warm path's existence checks into a single dispatch.
1294
    ///
1295
    /// Every input file carrying the executable bit resolves through here, and
1296
    /// in a toolchain-heavy input tree that is nearly all of them. The `stat(2)`
1297
    /// this needs costs single-digit microseconds on tmpfs, while the permit
1298
    /// acquisition and thread hop to dispatch one cost tens: asking per path
1299
    /// spent ~4.6s per action on a 15k-file tree, and asking once for the tree
1300
    /// spends ~37ms.
1301
28
    pub async fn get_executable_hardlink_sources(
1302
28
        &self,
1303
28
        digests: &[DigestInfo],
1304
28
    ) -> Vec<Result<OsString, Error>> {
1305
28
        let mut tasks = Vec::with_capacity(digests.len());
1306
28
        for (
digest3
,
hit3
) in digests
1307
28
            .iter()
1308
28
            .copied()
1309
28
            .zip(self.materialized_variants(digests).await)
1310
        {
1311
3
            tasks.push(async move {
1312
3
                match hit {
1313
1
                    Some(path) => Ok(path),
1314
2
                    None => self.get_executable_hardlink_source(&digest).await,
1315
                }
1316
3
            });
1317
        }
1318
28
        futures::stream::iter(tasks)
1319
28
            .buffered(MAX_CONCURRENT_VARIANT_LOOKUPS)
1320
28
            .collect()
1321
28
            .await
1322
28
    }
1323
1324
    /// Reports which digests already have an executable variant on disk, in one
1325
    /// batched existence check.
1326
    #[cfg(unix)]
1327
28
    async fn materialized_variants(&self, digests: &[DigestInfo]) -> Vec<Option<OsString>> {
1328
28
        let paths: Vec<OsString> = digests
1329
28
            .iter()
1330
28
            .map(|digest| 
self3
.
executable_variant_path3
(
digest3
))
1331
28
            .collect();
1332
1333
        // Variants disabled and a failed batch are the same situation: nothing
1334
        // is known to exist, so every digest takes the per-digest path, which
1335
        // reports real errors per file.
1336
28
        let exists = if self.executable_variants_enabled {
1337
28
            fs::exists_many(paths.iter().map(Into::into).collect())
1338
28
                .await
1339
28
                .ok()
1340
        } else {
1341
0
            None
1342
        }
1343
28
        .unwrap_or_else(|| 
vec!0
[false;
paths0
.
len0
()]);
1344
1345
28
        paths
1346
28
            .into_iter()
1347
28
            .zip(exists)
1348
28
            .map(|(path, hit)| 
hit3
.
then_some3
(
path3
))
1349
28
            .collect()
1350
28
    }
1351
1352
    /// Non-unix never builds executable variants, so there is never one on disk
1353
    /// to find and nothing to batch.
1354
    #[cfg(not(unix))]
1355
    async fn materialized_variants(&self, digests: &[DigestInfo]) -> Vec<Option<OsString>> {
1356
        vec![None; digests.len()]
1357
    }
1358
1359
    /// Returns the path to a private, read-only **executable** (0o555) copy of
1360
    /// the blob for `digest`, creating it at most once. Callers **hardlink**
1361
    /// the returned path into action input trees instead of copying the
1362
    /// executable per action.
1363
    ///
1364
    /// Why this exists: a CAS blob is stored read-only **0o444** and shared
1365
    /// across actions by hardlink, so it cannot carry the executable bit and
1366
    /// must never be `chmod`'d (that mutates the shared inode — the #2347
1367
    /// corruption class). Materializing an executable input therefore needs a
1368
    /// separate 0o555 inode. Doing that copy *per action* opens a writable fd
1369
    /// in the worker's hot path; under fork-heavy concurrency a child can
1370
    /// inherit that fd and a concurrent `execve` of the executable then fails
1371
    /// with `ETXTBSY` ("Text file busy", os error 26). Creating the 0o555 inode
1372
    /// **once** — writer fd fsync'd and closed, then atomically renamed into
1373
    /// place before the inode is ever hardlinked or executed — and hardlinking
1374
    /// it thereafter keeps the per-action path hardlink-only.
1375
    #[cfg(unix)]
1376
8
    pub async fn get_executable_hardlink_source(
1377
8
        &self,
1378
8
        digest: &DigestInfo,
1379
8
    ) -> Result<OsString, Error> {
1380
8
        if !self.executable_variants_enabled {
1381
1
            return Err(make_err!(
1382
1
                Code::FailedPrecondition,
1383
1
                "Executable hardlink sources are disabled because the startup wipe could not safely clear the read-only executable directory"
1384
1
            ));
1385
7
        }
1386
7
        let variant_path = self.executable_variant_path(digest);
1387
1388
        // Fast path: the variant already exists, so the caller can hardlink it
1389
        // with no writable fd anywhere in sight.
1390
7
        if fs::metadata(&variant_path).await.is_ok() {
1391
1
            return Ok(variant_path);
1392
6
        }
1393
1394
        // Single-flight: exactly one task ever opens a writable fd for this
1395
        // variant. Without this, a cold-cache burst of concurrent actions
1396
        // would each open a writer for the same executable — the very window
1397
        // ETXTBSY exploits.
1398
6
        let lock = {
1399
6
            let mut locks = self
1400
6
                .executable_locks
1401
6
                .lock()
1402
6
                .expect("executable_locks poisoned");
1403
6
            locks
1404
6
                .entry(*digest)
1405
6
                .or_insert_with(|| Arc::new(Mutex::new(())))
1406
6
                .clone()
1407
        };
1408
6
        let _guard = lock.lock().await;
1409
1410
        // Re-check: another task may have constructed it while we waited.
1411
6
        if fs::metadata(&variant_path).await.is_ok() {
1412
0
            self.forget_executable_lock(digest);
1413
0
            return Ok(variant_path);
1414
6
        }
1415
1416
6
        let result = self.create_executable_variant(digest, &variant_path).await;
1417
1418
        // The digest may have been evicted mid-copy: its eviction callback ran
1419
        // before the rename published the variant, so nothing owns the file
1420
        // anymore. This orphans the variant from eviction accounting, but the
1421
        // race is rare enough (needs an eviction to land in the narrow window
1422
        // between rename and this check, on a digest's first-ever variant
1423
        // materialization) that it's a self-limiting leak, not a systemic one
1424
        // — cheaper to log and let this action succeed with the still-valid
1425
        // file than to fail an otherwise-successful action over it.
1426
6
        if result.is_ok() && 
self.evicting_map5
.
get5
(&digest.into()).
await5
.
is_none5
() {
1427
0
            warn!(
1428
                %digest,
1429
                ?variant_path,
1430
                "Digest evicted while materializing its executable variant; \
1431
                 variant is now untracked by eviction accounting"
1432
            );
1433
6
        }
1434
1435
        // Drop the per-digest lock entry regardless of outcome so the map
1436
        // cannot grow unbounded; a concurrent waiter already cloned the Arc.
1437
6
        self.forget_executable_lock(digest);
1438
6
        result.map(|()| variant_path)
1439
8
    }
1440
1441
    /// Non-unix has no executable bit and no `ETXTBSY`, so just hardlink the
1442
    /// CAS blob directly.
1443
    #[cfg(not(unix))]
1444
    pub async fn get_executable_hardlink_source(
1445
        &self,
1446
        digest: &DigestInfo,
1447
    ) -> Result<OsString, Error> {
1448
        let file_entry = self.get_file_entry_for_digest(digest).await?;
1449
        file_entry
1450
            .get_file_path_locked(|p| async move { Ok(p) })
1451
            .await
1452
    }
1453
1454
    #[cfg(unix)]
1455
6
    fn forget_executable_lock(&self, digest: &DigestInfo) {
1456
6
        self.executable_locks
1457
6
            .lock()
1458
6
            .expect("executable_locks poisoned")
1459
6
            .remove(digest);
1460
6
    }
1461
1462
    /// Materializes the 0o555 executable variant for `digest`. Must be called
1463
    /// under the per-digest single-flight guard.
1464
    #[cfg(unix)]
1465
6
    async fn create_executable_variant(
1466
6
        &self,
1467
6
        digest: &DigestInfo,
1468
6
        variant_path: &OsStr,
1469
6
    ) -> Result<(), Error> {
1470
        // Resolve the on-disk CAS blob (0o444) to copy from. Must be present in
1471
        // this tier; callers populate the fast store first.
1472
6
        let 
file_entry5
= self
1473
6
            .get_file_entry_for_digest(digest)
1474
6
            .await
1475
6
            .err_tip(|| "Resolving CAS blob for executable variant")
?1
;
1476
5
        let src_path = file_entry
1477
10
            .
get_file_path_locked5
(|p| async move
{5
Ok(p)5
})
1478
5
            .await
?0
;
1479
1480
5
        let variant_owned = variant_path.to_os_string();
1481
5
        let mut temp_owned = variant_path.to_os_string();
1482
5
        temp_owned.push(".tmp");
1483
5
        let rename_fn = self.rename_fn;
1484
1485
        // All of this is blocking std::fs; run it off the async runtime. The
1486
        // writable fd opened by `copy` is fully closed before the `rename`
1487
        // publishes the inode, so no reachable hardlink of the variant ever has
1488
        // an open writer.
1489
5
        spawn_blocking!(
1490
            "filesystem_store_executable_variant",
1491
5
            move || -> Result<(), Error> {
1492
                use std::os::unix::fs::PermissionsExt;
1493
5
                std::fs::copy(&src_path, &temp_owned).map_err(|e| 
{0
1494
0
                    make_err!(Code::Internal, "executable-variant copy failed: {e:?}")
1495
0
                })?;
1496
5
                std::fs::set_permissions(&temp_owned, std::fs::Permissions::from_mode(0o555))
1497
5
                    .map_err(|e| 
{0
1498
0
                        make_err!(
1499
0
                            Code::Internal,
1500
                            "executable-variant chmod 0o555 failed: {e:?}"
1501
                        )
1502
0
                    })?;
1503
                // Belt-and-suspenders flush before publish. The `.exec`
1504
                // directory is cleared before executable variants are enabled,
1505
                // so durability is not needed across process restarts. The
1506
                // flush still ensures a published variant is complete for the
1507
                // current process. Non-macOS keeps the prior `sync_all`
1508
                // behavior; macOS uses plain `fsync(2)` (data handed to the
1509
                // device, no multi-ms `F_FULLFSYNC` device-cache drain), which
1510
                // covers kernel panics — the next writable startup wipe covers
1511
                // power loss.
1512
5
                let f = std::fs::File::open(&temp_owned)
1513
5
                    .map_err(|e| 
make_err!0
(
Code::Internal0
, "executable-variant reopen: {e:?}"))
?0
;
1514
                #[cfg(target_os = "macos")]
1515
                let flush_result = {
1516
                    use std::os::fd::AsRawFd;
1517
                    loop {
1518
                        if unsafe { libc::fsync(f.as_raw_fd()) } == 0 {
1519
                            break Ok(());
1520
                        }
1521
                        let err = std::io::Error::last_os_error();
1522
                        if err.kind() != std::io::ErrorKind::Interrupted {
1523
                            break Err(err);
1524
                        }
1525
                    }
1526
                };
1527
                #[cfg(not(target_os = "macos"))]
1528
5
                let flush_result = f.sync_all();
1529
5
                flush_result
1530
5
                    .map_err(|e| 
make_err!0
(
Code::Internal0
, "executable-variant fsync: {e:?}"))
?0
;
1531
5
                drop(f);
1532
5
                rename_fn(temp_owned.as_os_str(), variant_owned.as_os_str()).map_err(|e| 
{0
1533
0
                    make_err!(Code::Internal, "executable-variant rename failed: {e:?}")
1534
0
                })?;
1535
5
                Ok(())
1536
5
            }
1537
        )
1538
5
        .await
1539
5
        .err_tip(|| "executable-variant spawn_blocking join failed")
?0
1540
6
    }
1541
1542
1.26k
    pub async fn get_file_entry_for_digest(&self, digest: &DigestInfo) -> Result<Arc<Fe>, Error> {
1543
        // Zero-digest blobs have no backing file on disk (FilesystemStore
1544
        // never persists zero-byte content). The previous implementation
1545
        // returned a synthetic FileEntry whose content_path did not exist,
1546
        // which downstream callers would then try to hard_link from,
1547
        // silently producing missing or empty output files in worker
1548
        // execution directories. Return NotFound so callers are forced to
1549
        // take the explicit zero-digest path (e.g. fs::create_file).
1550
1.26k
        if is_zero_digest(digest) {
1551
1
            return Err(make_err!(
1552
1
                Code::NotFound,
1553
1
                "{digest} is a zero-digest; FilesystemStore does not persist zero-byte files. \
1554
1
                 Callers must materialise empty files directly rather than going through get_file_entry_for_digest."
1555
1
            ));
1556
1.26k
        }
1557
1.26k
        self.evicting_map
1558
1.26k
            .get(&digest.into())
1559
1.26k
            .await
1560
1.26k
            .ok_or_else(|| 
make_err!8
(
Code::NotFound8
, "{digest} not found in filesystem store. This may indicate the file was evicted due to cache pressure. Consider increasing 'max_bytes' in your filesystem store's eviction_policy configuration."))
1561
1.26k
    }
1562
1563
1.46k
    async fn update_file(
1564
1.46k
        self: Pin<&Self>,
1565
1.46k
        mut entry: Fe,
1566
1.46k
        mut temp_file: FileSlot,
1567
1.46k
        final_key: StoreKey<'static>,
1568
1.46k
        mut reader: DropCloserReadHalf,
1569
1.46k
    ) -> Result<u64, Error> {
1570
1.46k
        let mut data_size = 0;
1571
        loop {
1572
2.93k
            let mut data = reader
1573
2.93k
                .recv()
1574
2.93k
                .await
1575
2.93k
                .err_tip(|| "Failed to receive data in filesystem store")
?0
;
1576
2.93k
            let data_len = data.len();
1577
2.93k
            if data_len == 0 {
1578
1.46k
                break; // EOF.
1579
1.46k
            }
1580
1.46k
            temp_file
1581
1.46k
                .write_all_buf(&mut data)
1582
1.46k
                .await
1583
1.46k
                .err_tip(|| "Failed to write data into filesystem store")
?0
;
1584
1.46k
            data_size += data_len as u64;
1585
        }
1586
1587
1.46k
        let permit = if let Some(
sem0
) = &self.write_semaphore {
1588
0
            Some(sem.acquire().await.map_err(|err| {
1589
0
                Error::from_std_err(Code::Internal, &err).append("Write semaphore closed")
1590
0
            })?)
1591
        } else {
1592
1.46k
            None
1593
        };
1594
1595
        // tokio defers write errors to the next write or flush call, so without an explicit flush
1596
        // the final write's failure is silently swallowed by `sync_all`, and a truncated file would
1597
        // be renamed into the content path.
1598
1.46k
        temp_file
1599
1.46k
            .flush()
1600
1.46k
            .await
1601
1.46k
            .err_tip(|| "Failed to flush in filesystem store")
?0
;
1602
1.46k
        self.flush_durably(&temp_file)
1603
1.46k
            .await
1604
1.46k
            .err_tip(|| "Failed to sync in filesystem store")
?0
;
1605
1606
1.46k
        drop(permit);
1607
1608
1.46k
        if self.evict_page_cache {
1609
0
            temp_file.advise_dontneed();
1610
1.46k
        }
1611
1.46k
        trace!(?temp_file, "Dropping file to update_file");
1612
1.46k
        drop(temp_file);
1613
1614
1.46k
        *entry.data_size_mut() = data_size;
1615
1.46k
        self.emplace_file(final_key, Arc::new(entry)).await
?1
;
1616
1.46k
        Ok(data_size)
1617
1.46k
    }
1618
1619
1.73k
    async fn emplace_file(&self, key: StoreKey<'static>, entry: Arc<Fe>) -> Result<(), Error> {
1620
        // This sequence of events is quite tricky to understand due to the amount of triggers that
1621
        // happen, async'ness of it and the locking. So here is a breakdown of what happens:
1622
        // 1. Here will hold a write lock on any file operations of this FileEntry.
1623
        // 2. Then insert the entry into the evicting map. This may trigger an eviction of other
1624
        //    entries.
1625
        // 3. Eviction triggers `unref()`, which grabs a write lock on the evicted FileEntry
1626
        //    during the rename.
1627
        // 4. It should be impossible for items to be added while eviction is happening, so there
1628
        //    should not be a deadlock possibility. However, it is possible for the new FileEntry
1629
        //    to be evicted before the file is moved into place. Eviction of the newly inserted
1630
        //    item is not possible within the `insert()` call because the write lock inside the
1631
        //    eviction map. If an eviction of new item happens after `insert()` but before
1632
        //    `rename()` then we get to finish our operation because the `unref()` of the new item
1633
        //    will be blocked on us because we currently have the lock.
1634
        // 5. Move the file into place. Since we hold a write lock still anyone that gets our new
1635
        //    FileEntry (which has not yet been placed on disk) will not be able to read the file's
1636
        //    contents until we release the lock.
1637
1.73k
        let evicting_map = self.evicting_map.clone();
1638
1.73k
        let rename_fn = self.rename_fn;
1639
1640
        // We need to guarantee that this will get to the end even if the parent future is dropped.
1641
        // See: https://github.com/TraceMachina/nativelink/issues/495
1642
1.73k
        background_spawn!("filesystem_store_emplace_file", async move {
1643
            // Sometimes we get files to emplace that are identical to the existing files
1644
            // Due to the evict/remove/replace cycle taking some amount of time, we actually
1645
            // want to drop these
1646
1.73k
            if check_duplicate_files(&evicting_map, &key, &entry).await
?0
{
1647
1
                return Ok(());
1648
1.72k
            }
1649
1650
1.72k
            evicting_map
1651
1.72k
                .insert(key.borrow().into_owned().into(), entry.clone())
1652
1.72k
                .await;
1653
1654
            // The insert might have resulted in an eviction/unref so we need to check
1655
            // it still exists in there. But first, get the lock...
1656
1.72k
            let mut encoded_file_path = entry.get_encoded_file_path().write().await;
1657
            // Then check it's still in there...
1658
1.72k
            if evicting_map.get(&key).await.is_none() {
1659
3
                info!(%key, "Got eviction while emplacing, dropping");
1660
3
                return Ok(());
1661
1.72k
            }
1662
1663
1.72k
            let final_path = get_file_path_raw(
1664
1.72k
                &PathType::Content,
1665
1.72k
                encoded_file_path.shared_context.as_ref(),
1666
1.72k
                &key,
1667
            );
1668
1669
1.72k
            let from_path = encoded_file_path.get_file_path();
1670
1671
            // Lock the blob down as read-only *before* it lands at its final
1672
            // content path, so every hardlink of it (the worker directory cache
1673
            // and `download_to_directory`) inherits an immutable, read-only
1674
            // inode. This is what preserves the input-tree hermeticity contract
1675
            // (actions cannot mutate their inputs) without a per-materialization
1676
            // chmod walk, and it means callers must never `chmod` a hardlinked
1677
            // blob — anything needing a different mode (e.g. an executable's +x
1678
            // bit) must take a private copy. Content-addressed blobs are
1679
            // write-once and replaced by `rename` rather than in-place writes,
1680
            // and unlink only needs the *parent directory* to be writable, so a
1681
            // read-only file mode is safe for both overwrite-by-rename and
1682
            // eviction. Best-effort: a failure here (e.g. the temp file was
1683
            // already evicted out from under us) must not abort the emplace.
1684
            #[cfg(unix)]
1685
            {
1686
                use std::os::unix::fs::PermissionsExt;
1687
0
                if let Err(err) =
1688
1.72k
                    fs::set_permissions(&from_path, std::fs::Permissions::from_mode(0o444)).await
1689
                {
1690
0
                    warn!(?err, ?from_path, "Failed to set CAS blob read-only");
1691
1.72k
                }
1692
            }
1693
            // Internally tokio spawns fs commands onto a blocking thread anyways.
1694
            // Since we are already on a blocking thread, we just need the `fs` wrapper to manage
1695
            // an open-file permit (ensure we don't open too many files at once).
1696
1.72k
            let result = (rename_fn)(&from_path, &final_path).err_tip(|| 
{1
1697
1
                format!(
1698
                    "Failed to rename temp file to final path {}",
1699
1
                    final_path.display()
1700
                )
1701
1
            });
1702
1703
            // In the event our move from temp file to final file fails we need to ensure we remove
1704
            // the entry from our map.
1705
            // Remember: At this point it is possible for another thread to have a reference to
1706
            // `entry`, so we can't delete the file, only drop() should ever delete files.
1707
1.72k
            if let Err(
err1
) = result {
1708
1
                error!(?err, ?from_path, ?final_path, "Failed to rename file",);
1709
                // Warning: To prevent deadlock we need to release our lock or during `remove_if()`
1710
                // it will call `unref()`, which triggers a write-lock on `encoded_file_path`.
1711
1
                drop(encoded_file_path);
1712
                // It is possible that the item in our map is no longer the item we inserted,
1713
                // So, we need to conditionally remove it only if the pointers are the same.
1714
1715
1
                evicting_map
1716
1
                    .remove_if(&key, |map_entry| Arc::<Fe>::ptr_eq(map_entry, &entry))
1717
1
                    .await;
1718
1
                return Err(err);
1719
1.72k
            }
1720
1.72k
            trace!(?key, "Finished emplace file");
1721
1.72k
            encoded_file_path.path_type = PathType::Content;
1722
1.72k
            encoded_file_path.key = key;
1723
1.72k
            Ok(())
1724
1.73k
        })
1725
1.73k
        .await
1726
1.72k
        .err_tip(|| "Failed to create spawn in filesystem store update_file")
?0
1727
1.72k
    }
1728
1729
    /// Makes `file`'s contents durable with `sync_all` semantics.
1730
    ///
1731
    /// On macOS the naive equivalent (`fcntl(F_FULLFSYNC)` per file) is a
1732
    /// full device-cache flush that serializes at the device and dominates
1733
    /// many-small-blob uploads, so the flush is split into the cheap
1734
    /// per-file part (`fsync(2)`: push this file's pages to the device)
1735
    /// and a device-wide cache drain shared with every concurrent upload
1736
    /// via [`FlushCoalescer`]. Other platforms get their journal's group
1737
    /// commit for free and keep plain `sync_all`.
1738
1.71k
    async fn flush_durably(&self, file: &FileSlot) -> Result<(), Error> {
1739
        #[cfg(target_os = "macos")]
1740
        {
1741
            use std::os::fd::AsRawFd;
1742
            let Some(flush_coalescer) = &self.flush_coalescer else {
1743
                return file.as_ref().sync_all().await.map_err(Into::into);
1744
            };
1745
            // Dup the handle so the blocking task owns its own fd: if
1746
            // this future is cancelled mid-await, the temp file's fd can
1747
            // close and be reused, and an already-started blocking task
1748
            // must never fsync an unrelated descriptor.
1749
            let file_dup = file
1750
                .as_ref()
1751
                .try_clone()
1752
                .await
1753
                .err_tip(|| "Failed to dup fd for fsync in filesystem store")?
1754
                .into_std()
1755
                .await;
1756
            // Deliberately NOT via `fs::call_with_permit`: the caller's
1757
            // `FileSlot` already holds an open-file permit, so requiring a
1758
            // second one here deadlocks when the semaphore is drained (the
1759
            // exact scenario the #2051 regression test pins at one
1760
            // permit). Concurrency is still bounded: every in-flight fsync
1761
            // belongs to an upload holding a `FileSlot` permit.
1762
            spawn_blocking!("filesystem_store_fsync", move || {
1763
                loop {
1764
                    if unsafe { libc::fsync(file_dup.as_raw_fd()) } == 0 {
1765
                        return Ok(());
1766
                    }
1767
                    let err = std::io::Error::last_os_error();
1768
                    // std's sync_all retries EINTR (cvt_r); match it.
1769
                    if err.kind() != std::io::ErrorKind::Interrupted {
1770
                        return Err(Error::from(err).append("fsync failed in filesystem store"));
1771
                    }
1772
                }
1773
            })
1774
            .await
1775
            .err_tip(|| "Failed to join fsync task in filesystem store")??;
1776
            flush_coalescer.commit().await
1777
        }
1778
        #[cfg(not(target_os = "macos"))]
1779
        {
1780
1.71k
            file.as_ref().sync_all().await.map_err(Into::into)
1781
        }
1782
1.71k
    }
1783
1784
0
    pub fn get_eviction_snapshot(&self) -> EvictionSnapshot {
1785
0
        self.evicting_map.get_snapshot()
1786
0
    }
1787
1788
    // Only for tests, so we can run check_duplicate_files
1789
4
    pub fn get_evicting_map(&self) -> Arc<FsEvictingMap<'static, Fe>> {
1790
4
        self.evicting_map.clone()
1791
4
    }
1792
1793
    /// Returns the number of device-wide durability barriers issued by this
1794
    /// store's macOS flush coalescer. Exposed so the integration test can pin
1795
    /// the batching behavior rather than only checking data correctness.
1796
    #[cfg(target_os = "macos")]
1797
    pub fn full_flush_count_for_test(&self) -> Option<u64> {
1798
        self.flush_coalescer
1799
            .as_ref()
1800
            .map(|coalescer| coalescer.full_flush_count.load(Ordering::Relaxed))
1801
    }
1802
1803
    // Separated out so tests can use this
1804
1.71k
    pub async fn make_temp_file(
1805
1.71k
        &self,
1806
1.71k
        temp_key: StoreKey<'static>,
1807
1.71k
    ) -> Result<(Fe, FileSlot, OsString), Error> {
1808
1.71k
        Fe::make_and_open_file(
1809
1.71k
            self.block_size,
1810
1.71k
            EncodedFilePath {
1811
1.71k
                shared_context: self.shared_context.clone(),
1812
1.71k
                path_type: PathType::Temp,
1813
1.71k
                key: temp_key,
1814
1.71k
            },
1815
1.71k
        )
1816
1.71k
        .await
1817
1.71k
    }
1818
}
1819
1820
#[async_trait]
1821
impl<Fe: FileEntry> StoreDriver for FilesystemStore<Fe> {
1822
0
    async fn post_init(self: Arc<Self>) -> Result<(), Error> {
1823
        Ok(())
1824
0
    }
1825
1826
    async fn has_with_results(
1827
        self: Pin<&Self>,
1828
        keys: &[StoreKey<'_>],
1829
        results: &mut [Option<u64>],
1830
2.00k
    ) -> Result<(), Error> {
1831
        let own_keys = keys
1832
            .iter()
1833
2.00k
            .map(|sk| sk.borrow().into_owned())
1834
            .collect::<Vec<_>>();
1835
        self.evicting_map
1836
            .sizes_for_keys(own_keys.iter(), results, false /* peek */)
1837
            .await;
1838
        // We need to do a special pass to ensure our zero files exist.
1839
        // If our results failed and the result was a zero file, we need to
1840
        // create the file by spec.
1841
        for (key, result) in keys.iter().zip(results.iter_mut()) {
1842
            if result.is_some() || !is_zero_digest(key.borrow()) {
1843
                continue;
1844
            }
1845
            let (mut tx, rx) = make_buf_channel_pair();
1846
            let send_eof_result = tx.send_eof();
1847
            self.update(key.borrow(), rx, UploadSizeInfo::ExactSize(0))
1848
                .await
1849
0
                .err_tip(|| format!("Failed to create zero file for key {}", key.as_str()))
1850
                .merge(
1851
                    send_eof_result
1852
                        .err_tip(|| "Failed to send zero file EOF in filesystem store has"),
1853
                )?;
1854
1855
            *result = Some(0);
1856
        }
1857
        Ok(())
1858
2.00k
    }
1859
1860
    async fn update(
1861
        self: Pin<&Self>,
1862
        key: StoreKey<'_>,
1863
        mut reader: DropCloserReadHalf,
1864
        _upload_size: UploadSizeInfo,
1865
1.56k
    ) -> Result<u64, Error> {
1866
        if is_zero_digest(key.borrow()) {
1867
            // don't need to add, because zero length files are just assumed to exist.
1868
            return Ok(0);
1869
        }
1870
1871
        let temp_key = make_temp_key(&key);
1872
1873
        // There's a possibility of deadlock here where we take all of the
1874
        // file semaphores with make_and_open_file and the semaphores for
1875
        // whatever is populating reader is exhasted on the threads that
1876
        // have the FileSlots and not on those which can't.  To work around
1877
        // this we don't take the FileSlot until there's something on the
1878
        // reader available to know that the populator is active.
1879
        reader.peek().await?;
1880
1881
        let (entry, temp_file, temp_full_path) = self.make_temp_file(temp_key).await?;
1882
1883
        self.update_file(entry, temp_file, key.into_owned(), reader)
1884
            .await
1885
1
            .err_tip(|| {
1886
1
                format!(
1887
                    "While processing with temp file {}",
1888
1
                    temp_full_path.display()
1889
                )
1890
1
            })
1891
1.56k
    }
1892
1893
1.55k
    fn optimized_for(&self, optimization: StoreOptimizations) -> bool {
1894
1.53k
        matches!(
1895
1.55k
            optimization,
1896
            StoreOptimizations::FileUpdates | StoreOptimizations::SubscribesToUpdateOneshot
1897
        )
1898
1.55k
    }
1899
1900
248
    async fn update_oneshot(self: Pin<&Self>, key: StoreKey<'_>, data: Bytes) -> Result<(), Error> {
1901
        if is_zero_digest(key.borrow()) {
1902
            return Ok(());
1903
        }
1904
1905
        let temp_key = make_temp_key(&key);
1906
        let (mut entry, mut temp_file, temp_full_path) = self
1907
            .make_temp_file(temp_key)
1908
            .await
1909
            .err_tip(|| "Failed to create temp file in filesystem store update_oneshot")?;
1910
1911
        // Write directly without channel overhead
1912
        if !data.is_empty() {
1913
            temp_file
1914
                .write_all(&data)
1915
                .await
1916
0
                .err_tip(|| format!("Failed to write data to {}", temp_full_path.display()))?;
1917
        }
1918
1919
        let _permit = if let Some(sem) = &self.write_semaphore {
1920
0
            Some(sem.acquire().await.map_err(|err| {
1921
0
                Error::from_std_err(Code::Internal, &err).append("Write semaphore closed")
1922
0
            })?)
1923
        } else {
1924
            None
1925
        };
1926
1927
        // See comment in `update_file` above.
1928
        temp_file
1929
            .flush()
1930
            .await
1931
            .err_tip(|| "Failed to flush in filesystem store update_oneshot")?;
1932
        self.flush_durably(&temp_file)
1933
            .await
1934
            .err_tip(|| "Failed to sync in filesystem store update_oneshot")?;
1935
1936
        drop(_permit);
1937
1938
        if self.evict_page_cache {
1939
            temp_file.advise_dontneed();
1940
        }
1941
        drop(temp_file);
1942
1943
        *entry.data_size_mut() = data.len() as u64;
1944
        self.emplace_file(key.into_owned(), Arc::new(entry)).await
1945
248
    }
1946
1947
    async fn update_with_whole_file(
1948
        self: Pin<&Self>,
1949
        key: StoreKey<'_>,
1950
        path: OsString,
1951
        file: FileSlot,
1952
        upload_size: UploadSizeInfo,
1953
16
    ) -> Result<(u64, Option<FileSlot>), Error> {
1954
        let file_size = match upload_size {
1955
            UploadSizeInfo::ExactSize(size) => size,
1956
            UploadSizeInfo::MaxSize(_) => file
1957
                .as_ref()
1958
                .metadata()
1959
                .await
1960
0
                .err_tip(|| format!("While reading metadata for {}", path.display()))?
1961
                .len(),
1962
        };
1963
        if file_size == 0 {
1964
            // don't need to add, because zero length files are just assumed to exist
1965
            return Ok((0, None));
1966
        }
1967
        let entry = Fe::create(
1968
            file_size,
1969
            self.block_size,
1970
            RwLock::new(EncodedFilePath {
1971
                shared_context: self.shared_context.clone(),
1972
                path_type: PathType::Custom(path),
1973
                key: key.borrow().into_owned(),
1974
            }),
1975
        );
1976
        // We are done with the file, if we hold a reference to the file here, it could
1977
        // result in a deadlock if `emplace_file()` also needs file descriptors.
1978
        trace!(?file, "Dropping file to to update_with_whole_file");
1979
        if self.evict_page_cache {
1980
            file.advise_dontneed();
1981
        }
1982
        drop(file);
1983
        self.emplace_file(key.into_owned(), Arc::new(entry))
1984
            .await
1985
            .err_tip(|| "Could not move file into store in upload_file_to_store, maybe dest is on different volume?")?;
1986
        return Ok((file_size, None));
1987
16
    }
1988
1989
    async fn get_part(
1990
        self: Pin<&Self>,
1991
        key: StoreKey<'_>,
1992
        writer: &mut DropCloserWriteHalf,
1993
        offset: u64,
1994
        length: Option<u64>,
1995
399
    ) -> Result<(), Error> {
1996
        if is_zero_digest(key.borrow()) {
1997
            self.has(key.borrow())
1998
                .await
1999
                .err_tip(|| "Failed to check if zero digest exists in filesystem store")?;
2000
            writer
2001
                .send_eof()
2002
                .err_tip(|| "Failed to send zero EOF in filesystem store get_part")?;
2003
            return Ok(());
2004
        }
2005
        let owned_key = key.into_owned();
2006
2
        let entry = self.evicting_map.get(&owned_key).await.ok_or_else(|| {
2007
2
            make_err!(
2008
2
                Code::NotFound,
2009
                "{} not found in filesystem store here",
2010
2
                owned_key.as_str()
2011
            )
2012
2
        })?;
2013
        let read_limit = length.unwrap_or(u64::MAX);
2014
3
        let mut temp_file = entry.read_file_part(offset, read_limit).or_else(|err| async move {
2015
            // If the file is not found, we need to remove it from the eviction map.
2016
3
            if err.code == Code::NotFound {
2017
                // Map said the file was present but `open()` hit ENOENT.
2018
                // Self-heals: we remove the stale entry below and a
2019
                // fast/slow caller re-populates from the slow store, so
2020
                // this is a recoverable warn, not a fatal error.
2021
3
                warn!(
2022
                    ?err,
2023
                    key = ?owned_key,
2024
                    "Filesystem store map/disk divergence: removing entry; reader will fall through to slow store",
2025
                );
2026
3
                self.evicting_map.remove(&owned_key).await;
2027
0
            }
2028
3
            Err(err)
2029
6
        }).await?;
2030
2031
        loop {
2032
            let mut buf = BytesMut::with_capacity(self.read_buffer_size);
2033
            temp_file
2034
                .read_buf(&mut buf)
2035
                .await
2036
                .err_tip(|| "Failed to read data in filesystem store")?;
2037
            if buf.is_empty() {
2038
                break; // EOF.
2039
            }
2040
            writer
2041
                .send(buf.freeze())
2042
                .await
2043
                .err_tip(|| "Failed to send chunk in filesystem store get_part")?;
2044
        }
2045
        if self.evict_page_cache {
2046
            temp_file.get_ref().advise_dontneed();
2047
        }
2048
        writer
2049
            .send_eof()
2050
            .err_tip(|| "Filed to send EOF in filesystem store get_part")?;
2051
2052
        Ok(())
2053
399
    }
2054
2055
1.60k
    fn inner_store(&self, _digest: Option<StoreKey>) -> &dyn StoreDriver {
2056
1.60k
        self
2057
1.60k
    }
2058
2059
68
    fn as_any<'a>(&'a self) -> &'a (dyn core::any::Any + Sync + Send + 'static) {
2060
68
        self
2061
68
    }
2062
2063
0
    fn as_any_arc(self: Arc<Self>) -> Arc<dyn core::any::Any + Sync + Send + 'static> {
2064
0
        self
2065
0
    }
2066
2067
0
    fn register_health(self: Arc<Self>, registry: &mut HealthRegistryBuilder) {
2068
0
        registry.register_indicator(self);
2069
0
    }
2070
2071
0
    fn register_remove_callback(self: Arc<Self>, callback: RemoveCallback) -> Result<(), Error> {
2072
0
        self.evicting_map
2073
0
            .add_remove_callback(RemoveCallbackHolder::new(callback));
2074
0
        Ok(())
2075
0
    }
2076
}
2077
2078
#[async_trait]
2079
impl<Fe: FileEntry> HealthStatusIndicator for FilesystemStore<Fe> {
2080
0
    fn get_name(&self) -> &'static str {
2081
0
        "FilesystemStore"
2082
0
    }
2083
2084
    /// Lightweight probe: `stat()` the `content_path` directory. No
2085
    /// write-semaphore / eviction-map contention with production
2086
    /// traffic, and bounded so a hung NFS / EBS mount can't wedge the
2087
    /// indicator.
2088
2
    async fn check_health(&self, _namespace: Cow<'static, str>) -> HealthStatus {
2089
        const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
2090
2091
        let content_path = &self.shared_context.content_path;
2092
        let stat = tokio::fs::metadata(&content_path);
2093
        match timeout(HEALTH_PROBE_TIMEOUT, stat).await {
2094
            Ok(Ok(meta)) if meta.is_dir() => {
2095
                HealthStatus::new_ok(self, "FilesystemStore::check_health: ok".into())
2096
            }
2097
            Ok(Ok(_)) => HealthStatus::new_failed(
2098
                self,
2099
                format!(
2100
                    "FilesystemStore::check_health: content_path {content_path} is not a directory"
2101
                )
2102
                .into(),
2103
            ),
2104
            Ok(Err(e)) => {
2105
                warn!(
2106
                    ?e,
2107
                    %content_path,
2108
                    "FilesystemStore::check_health: stat errored",
2109
                );
2110
                HealthStatus::new_failed(
2111
                    self,
2112
                    format!("FilesystemStore::check_health: stat errored: {e}").into(),
2113
                )
2114
            }
2115
            Err(_) => {
2116
                warn!(
2117
                    %content_path,
2118
                    timeout_secs = HEALTH_PROBE_TIMEOUT.as_secs(),
2119
                    "FilesystemStore::check_health: stat timed out",
2120
                );
2121
                HealthStatus::Timeout {
2122
                    struct_name: self.struct_name(),
2123
                }
2124
            }
2125
        }
2126
2
    }
2127
}