Coverage Report

Created: 2026-07-24 00:44

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