Coverage Report

Created: 2026-07-09 17:41

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
use nativelink_util::store_trait::{
47
    RemoveItemCallback, StoreDriver, StoreKey, StoreKeyBorrow, StoreOptimizations, UploadSizeInfo,
48
};
49
use tokio::io::{AsyncReadExt, AsyncWriteExt, Take};
50
use tokio::sync::Semaphore;
51
use tokio::time::timeout;
52
use tokio_stream::wrappers::ReadDirStream;
53
use tracing::{debug, error, info, trace, warn};
54
55
use crate::callback_utils::RemoveItemCallbackHolder;
56
use crate::cas_utils::is_zero_digest;
57
58
// Default size to allocate memory of the buffer when reading files.
59
const DEFAULT_BUFF_SIZE: usize = 32 * 1024;
60
// Default block size of all major filesystems is 4KB
61
const DEFAULT_BLOCK_SIZE: u64 = 4 * 1024;
62
63
pub const STR_FOLDER: &str = "s";
64
pub const DIGEST_FOLDER: &str = "d";
65
66
/// Suffix for the sibling directory that holds per-digest read-only
67
/// **executable** (0o555) variants of CAS blobs (see
68
/// [`FilesystemStore::get_executable_hardlink_source`]). It is a sibling of
69
/// `content_path` rather than a child so the normal content/temp scan and prune
70
/// logic never touches it. Cleared on startup; entries are regenerable.
71
#[cfg(unix)]
72
const EXECUTABLE_DIR_SUFFIX: &str = ".exec";
73
74
#[derive(Clone, Copy, Debug)]
75
pub enum FileType {
76
    Digest,
77
    String,
78
}
79
80
#[derive(Debug, MetricsComponent)]
81
pub struct SharedContext {
82
    // Used in testing to know how many active drop() spawns are running.
83
    // TODO(palfrey) It is probably a good idea to use a spin lock during
84
    // destruction of the store to ensure that all files are actually
85
    // deleted (similar to how it is done in tests).
86
    #[metric(help = "Number of active drop spawns")]
87
    pub active_drop_spawns: AtomicU64,
88
    #[metric(help = "Path to the configured temp path")]
89
    temp_path: String,
90
    #[metric(help = "Path to the configured content path")]
91
    content_path: String,
92
}
93
94
#[derive(Eq, PartialEq, Debug)]
95
enum PathType {
96
    Content,
97
    Temp,
98
    Custom(OsString),
99
}
100
101
/// [`EncodedFilePath`] stores the path to the file
102
/// including the context, path type and key to the file.
103
/// The whole [`StoreKey`] is stored as opposed to solely
104
/// the [`DigestInfo`] so that it is more usable for things
105
/// such as BEP -see Issue #1108
106
#[derive(Debug)]
107
pub struct EncodedFilePath {
108
    shared_context: Arc<SharedContext>,
109
    path_type: PathType,
110
    key: StoreKey<'static>,
111
}
112
113
impl EncodedFilePath {
114
    #[inline]
115
4.93k
    fn get_file_path(&self) -> Cow<'_, OsStr> {
116
4.93k
        get_file_path_raw(&self.path_type, self.shared_context.as_ref(), &self.key)
117
4.93k
    }
118
}
119
120
#[inline]
121
6.17k
fn get_file_path_raw<'a>(
122
6.17k
    path_type: &'a PathType,
123
6.17k
    shared_context: &SharedContext,
124
6.17k
    key: &StoreKey<'a>,
125
6.17k
) -> Cow<'a, OsStr> {
126
6.17k
    let 
folder6.15k
= match path_type {
127
2.45k
        PathType::Content => &shared_context.content_path,
128
3.69k
        PathType::Temp => &shared_context.temp_path,
129
24
        PathType::Custom(path) => return Cow::Borrowed(path),
130
    };
131
6.15k
    Cow::Owned(to_full_path_from_key(folder, key))
132
6.17k
}
133
134
impl Drop for EncodedFilePath {
135
1.24k
    fn drop(&mut self) {
136
        // `drop()` can be called during shutdown, so we use `path_type` flag to know if the
137
        // file actually needs to be deleted.
138
1.24k
        if self.path_type == PathType::Content {
139
1.22k
            return;
140
18
        }
141
142
18
        let file_path = self.get_file_path().to_os_string();
143
18
        let shared_context = self.shared_context.clone();
144
        // .fetch_add returns previous value, so we add one to get approximate current value
145
18
        let current_active_drop_spawns = shared_context
146
18
            .active_drop_spawns
147
18
            .fetch_add(1, Ordering::Relaxed)
148
18
            + 1;
149
18
        debug!(
150
            %current_active_drop_spawns,
151
            ?file_path,
152
            "Spawned a filesystem_delete_file"
153
        );
154
18
        background_spawn!("filesystem_delete_file", async move 
{12
155
12
            match fs::remove_file(&file_path).await {
156
6
                Ok(()) => debug!(?file_path, "File deleted"),
157
                // The file already being gone is the desired end state of a
158
                // delete, not a failure — e.g. an entry marked Temp after an
159
                // already-gone unref points at a path that was never created.
160
1
                Err(err) if err.code == Code::NotFound => {
161
1
                    debug!(?file_path, "File already gone, nothing to delete");
162
                }
163
0
                Err(err) => error!(?file_path, ?err, "Failed to delete file"),
164
            }
165
            // .fetch_sub returns previous value, so we subtract one to get approximate current value
166
7
            let current_active_drop_spawns = shared_context
167
7
                .active_drop_spawns
168
7
                .fetch_sub(1, Ordering::Relaxed)
169
7
                - 1;
170
7
            debug!(
171
                ?current_active_drop_spawns,
172
                ?file_path,
173
                "Dropped a filesystem_delete_file"
174
            );
175
7
        });
176
1.24k
    }
177
}
178
179
/// This creates the file path from the [`StoreKey`]. If
180
/// it is a string, the string, prefixed with [`STR_PREFIX`]
181
/// for backwards compatibility, is stored.
182
///
183
/// If it is a [`DigestInfo`], it is prefixed by [`DIGEST_PREFIX`]
184
/// followed by the string representation of a digest - the hash in hex,
185
/// a hyphen then the size in bytes
186
///
187
/// Previously, only the string representation of the [`DigestInfo`] was
188
/// used with no prefix
189
#[inline]
190
6.16k
fn to_full_path_from_key(folder: &str, key: &StoreKey<'_>) -> OsString {
191
6.16k
    match key {
192
3
        StoreKey::Str(str) => format!("{folder}/{STR_FOLDER}/{str}"),
193
6.15k
        StoreKey::Digest(digest_info) => format!("{folder}/{DIGEST_FOLDER}/{digest_info}"),
194
    }
195
6.16k
    .into()
196
6.16k
}
197
198
pub trait FileEntry: LenEntry + Send + Sync + Debug + 'static {
199
    /// Responsible for creating the underlying `FileEntry`.
200
    fn create(data_size: u64, block_size: u64, encoded_file_path: RwLock<EncodedFilePath>) -> Self;
201
202
    /// Creates a (usually) temp file, opens it and returns the path to the temp file.
203
    fn make_and_open_file(
204
        block_size: u64,
205
        encoded_file_path: EncodedFilePath,
206
    ) -> impl Future<Output = Result<(Self, FileSlot, OsString), Error>> + Send
207
    where
208
        Self: Sized;
209
210
    /// Returns the underlying size of the data in bytes
211
    fn data_size(&self) -> u64;
212
213
    /// Returns the underlying reference to the size of the data in bytes
214
    fn data_size_mut(&mut self) -> &mut u64;
215
216
    /// Returns the actual size of the underlying file on the disk after accounting for filesystem block size.
217
    fn size_on_disk(&self) -> u64;
218
219
    /// Gets the underlying `EncodedfilePath`.
220
    fn get_encoded_file_path(&self) -> &RwLock<EncodedFilePath>;
221
222
    /// Returns a reader that will read part of the underlying file.
223
    fn read_file_part(
224
        &self,
225
        offset: u64,
226
        length: u64,
227
    ) -> impl Future<Output = Result<Take<FileSlot>, Error>> + Send;
228
229
    /// This function is a safe way to extract the file name of the underlying file. To protect users from
230
    /// accidentally creating undefined behavior we encourage users to do the logic they need to do with
231
    /// the filename inside this function instead of extracting the filename and doing the logic outside.
232
    /// This is because the filename is not guaranteed to exist after this function returns, however inside
233
    /// the callback the file is always guaranteed to exist and immutable.
234
    /// DO NOT USE THIS FUNCTION TO EXTRACT THE FILENAME AND STORE IT FOR LATER USE.
235
    fn get_file_path_locked<
236
        T,
237
        Fut: Future<Output = Result<T, Error>> + Send,
238
        F: FnOnce(OsString) -> Fut + Send,
239
    >(
240
        &self,
241
        handler: F,
242
    ) -> impl Future<Output = Result<T, Error>> + Send;
243
}
244
245
pub struct FileEntryImpl {
246
    data_size: u64,
247
    block_size: u64,
248
    // We lock around this as it gets rewritten when we move between temp and content types
249
    encoded_file_path: RwLock<EncodedFilePath>,
250
}
251
252
impl FileEntryImpl {
253
0
    pub fn get_shared_context_for_test(&mut self) -> Arc<SharedContext> {
254
0
        self.encoded_file_path.get_mut().shared_context.clone()
255
0
    }
256
}
257
258
impl FileEntry for FileEntryImpl {
259
1.24k
    fn create(data_size: u64, block_size: u64, encoded_file_path: RwLock<EncodedFilePath>) -> Self {
260
1.24k
        Self {
261
1.24k
            data_size,
262
1.24k
            block_size,
263
1.24k
            encoded_file_path,
264
1.24k
        }
265
1.24k
    }
266
267
    /// This encapsulates the logic for the edge case of if the file fails to create
268
    /// the cleanup of the file is handled without creating a `FileEntry`, which would
269
    /// try to cleanup the file as well during `drop()`.
270
1.22k
    async fn make_and_open_file(
271
1.22k
        block_size: u64,
272
1.22k
        encoded_file_path: EncodedFilePath,
273
1.22k
    ) -> Result<(Self, FileSlot, OsString), Error> {
274
1.22k
        let temp_full_path = encoded_file_path.get_file_path().to_os_string();
275
1.22k
        let temp_file_result = fs::create_file(temp_full_path.clone())
276
1.22k
            .or_else(|mut err| async 
{0
277
0
                let remove_result = fs::remove_file(&temp_full_path).await.err_tip(|| {
278
0
                    format!(
279
                        "Failed to remove file {} in filesystem store",
280
0
                        temp_full_path.display()
281
                    )
282
0
                });
283
0
                if let Err(remove_err) = remove_result {
284
0
                    err = err.merge(remove_err);
285
0
                }
286
0
                warn!(?err, ?block_size, ?temp_full_path, "Failed to create file",);
287
0
                Err(err).err_tip(|| {
288
0
                    format!(
289
                        "Failed to create {} in filesystem store",
290
0
                        temp_full_path.display()
291
                    )
292
0
                })
293
0
            })
294
1.22k
            .await
?0
;
295
296
1.22k
        Ok((
297
1.22k
            <Self as FileEntry>::create(
298
1.22k
                0, /* Unknown yet, we will fill it in later */
299
1.22k
                block_size,
300
1.22k
                RwLock::new(encoded_file_path),
301
1.22k
            ),
302
1.22k
            temp_file_result,
303
1.22k
            temp_full_path,
304
1.22k
        ))
305
1.22k
    }
306
307
25
    fn data_size(&self) -> u64 {
308
25
        self.data_size
309
25
    }
310
311
1.22k
    fn data_size_mut(&mut self) -> &mut u64 {
312
1.22k
        &mut self.data_size
313
1.22k
    }
314
315
1.52k
    fn size_on_disk(&self) -> u64 {
316
1.52k
        self.data_size.div_ceil(self.block_size) * self.block_size
317
1.52k
    }
318
319
3.68k
    fn get_encoded_file_path(&self) -> &RwLock<EncodedFilePath> {
320
3.68k
        &self.encoded_file_path
321
3.68k
    }
322
323
117
    fn read_file_part(
324
117
        &self,
325
117
        offset: u64,
326
117
        length: u64,
327
117
    ) -> impl Future<Output = Result<Take<FileSlot>, Error>> + Send {
328
117
        self.get_file_path_locked(move |full_content_path| async move {
329
117
            let 
file114
= fs::open_file(&full_content_path, offset, length)
330
117
                .await
331
117
                .err_tip(|| 
{3
332
3
                    format!(
333
                        "Failed to open file in filesystem store {}",
334
3
                        full_content_path.display()
335
                    )
336
3
                })?;
337
114
            Ok(file)
338
234
        })
339
117
    }
340
341
1.20k
    async fn get_file_path_locked<
342
1.20k
        T,
343
1.20k
        Fut: Future<Output = Result<T, Error>> + Send,
344
1.20k
        F: FnOnce(OsString) -> Fut + Send,
345
1.20k
    >(
346
1.20k
        &self,
347
1.20k
        handler: F,
348
1.20k
    ) -> Result<T, Error> {
349
1.20k
        let encoded_file_path = self.get_encoded_file_path().read().await;
350
1.20k
        handler(encoded_file_path.get_file_path().to_os_string()).await
351
1.20k
    }
352
}
353
354
impl Debug for FileEntryImpl {
355
0
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), core::fmt::Error> {
356
0
        f.debug_struct("FileEntryImpl")
357
0
            .field("data_size", &self.data_size)
358
0
            .field("encoded_file_path", &"<behind mutex>")
359
0
            .finish()
360
0
    }
361
}
362
363
1.24k
fn make_temp_digest(mut digest: DigestInfo) -> DigestInfo {
364
    static DELETE_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
365
1.24k
    let mut hash = *digest.packed_hash();
366
1.24k
    hash[24..].clone_from_slice(
367
1.24k
        &DELETE_FILE_COUNTER
368
1.24k
            .fetch_add(1, Ordering::Relaxed)
369
1.24k
            .to_le_bytes(),
370
    );
371
1.24k
    digest.set_packed_hash(*hash);
372
1.24k
    digest
373
1.24k
}
374
375
1.24k
pub fn make_temp_key(key: &StoreKey) -> StoreKey<'static> {
376
1.24k
    StoreKey::Digest(make_temp_digest(key.borrow().into_digest()))
377
1.24k
}
378
379
impl LenEntry for FileEntryImpl {
380
    #[inline]
381
1.51k
    fn len(&self) -> u64 {
382
1.51k
        self.size_on_disk()
383
1.51k
    }
384
385
0
    fn is_empty(&self) -> bool {
386
0
        self.data_size == 0
387
0
    }
388
389
    // unref() only triggers when an item is removed from the eviction_map. It is possible
390
    // that another place in code has a reference to `FileEntryImpl` and may later read the
391
    // file. To support this edge case, we first move the file to a temp file and point
392
    // target file location to the new temp file. `unref()` should only ever be called once.
393
    #[inline]
394
15
    async fn unref(&self) {
395
15
        let mut encoded_file_path = self.encoded_file_path.write().await;
396
15
        if encoded_file_path.path_type == PathType::Temp {
397
            // We are already a temp file that is now marked for deletion on drop.
398
            // This is very rare, but most likely the rename into the content path failed.
399
3
            warn!(
400
3
                key = ?encoded_file_path.key,
401
                "File is already a temp file",
402
            );
403
3
            return;
404
12
        }
405
12
        let from_path = encoded_file_path.get_file_path();
406
12
        let new_key = make_temp_key(&encoded_file_path.key);
407
408
12
        let to_path = to_full_path_from_key(&encoded_file_path.shared_context.temp_path, &new_key);
409
410
12
        if let Err(
err5
) = fs::rename(&from_path, &to_path).await {
411
            // ENOENT from rename is ambiguous: the source may be gone, or
412
            // a directory component of the destination (the temp dir) may
413
            // be missing. Confirm the source is genuinely gone before
414
            // treating it as benign — otherwise a removed temp dir would
415
            // flip an intact content file to Temp and orphan it on disk.
416
5
            let source_gone = err.code == Code::NotFound
417
4
                && matches!(
418
5
                    fs::metadata(&from_path).await,
419
4
                    Err(meta_err) if meta_err.code == Code::NotFound
420
                );
421
5
            if source_gone {
422
                // The file is already gone — typically another thread's
423
                // eviction beat us, or the entry never got its file on
424
                // disk. Benign here, and it dominates log volume under
425
                // heavy write+evict concurrency, so keep it at `debug`.
426
                // Mark the entry Temp (as a successful rename would) so a
427
                // repeat unref is a no-op and drop stops claiming the
428
                // content path.
429
4
                debug!(
430
4
                    key = ?encoded_file_path.key,
431
                    "Failed to rename file (already gone, treating as benign)",
432
                );
433
4
                encoded_file_path.path_type = PathType::Temp;
434
4
                encoded_file_path.key = new_key;
435
            } else {
436
                // Either a non-ENOENT failure (EACCES, EXDEV, EBUSY, …) or
437
                // ENOENT with the source still present (missing temp dir).
438
                // The content file is intact; leave the entry as Content.
439
1
                warn!(
440
1
                    key = ?encoded_file_path.key,
441
                    ?from_path,
442
                    ?to_path,
443
                    ?err,
444
                    "Failed to rename file",
445
                );
446
            }
447
        } else {
448
7
            debug!(
449
7
                key = ?encoded_file_path.key,
450
                ?from_path,
451
                ?to_path,
452
                "Renamed file (unref)",
453
            );
454
7
            encoded_file_path.path_type = PathType::Temp;
455
7
            encoded_file_path.key = new_key;
456
        }
457
15
    }
458
}
459
460
#[inline]
461
2
fn digest_from_filename(file_name: &str) -> Result<DigestInfo, Error> {
462
2
    let (hash, size) = file_name.split_once('-').err_tip(|| "")
?0
;
463
2
    let size = size.parse::<i64>()
?0
;
464
2
    DigestInfo::try_new(hash, size)
465
2
}
466
467
3
pub fn key_from_file(file_name: &str, file_type: FileType) -> Result<StoreKey<'_>, Error> {
468
3
    match file_type {
469
1
        FileType::String => Ok(StoreKey::new_str(file_name)),
470
2
        FileType::Digest => digest_from_filename(file_name).map(StoreKey::Digest),
471
    }
472
3
}
473
474
/// The number of files to read the metadata for at the same time when running
475
/// `add_files_to_cache`.
476
const SIMULTANEOUS_METADATA_READS: usize = 200;
477
478
type FsEvictingMap<'a, Fe> =
479
    EvictingMap<StoreKeyBorrow, StoreKey<'a>, Arc<Fe>, SystemTime, RemoveItemCallbackHolder>;
480
481
89
async fn add_files_to_cache<Fe: FileEntry>(
482
89
    evicting_map: &FsEvictingMap<'_, Fe>,
483
89
    anchor_time: &SystemTime,
484
89
    shared_context: &Arc<SharedContext>,
485
89
    block_size: u64,
486
89
    rename_fn: fn(&OsStr, &OsStr) -> Result<(), std::io::Error>,
487
89
) -> Result<(), Error> {
488
    #[expect(clippy::too_many_arguments)]
489
2
    async fn process_entry<Fe: FileEntry>(
490
2
        evicting_map: &FsEvictingMap<'_, Fe>,
491
2
        file_name: &str,
492
2
        file_type: FileType,
493
2
        atime: SystemTime,
494
2
        data_size: u64,
495
2
        block_size: u64,
496
2
        anchor_time: &SystemTime,
497
2
        shared_context: &Arc<SharedContext>,
498
2
    ) -> Result<(), Error> {
499
2
        let key = key_from_file(file_name, file_type)
?0
;
500
501
2
        let file_entry = Fe::create(
502
2
            data_size,
503
2
            block_size,
504
2
            RwLock::new(EncodedFilePath {
505
2
                shared_context: shared_context.clone(),
506
2
                path_type: PathType::Content,
507
2
                key: key.borrow().into_owned(),
508
2
            }),
509
        );
510
2
        let time_since_anchor = if let Ok(
d1
) = anchor_time.duration_since(atime) {
511
1
            d
512
        } else {
513
1
            warn!(
514
                %file_name,
515
1
                atime = %humantime::format_rfc3339(atime),
516
1
                anchor_time = %humantime::format_rfc3339(*anchor_time),
517
                "File access time newer than FilesystemStore start time",
518
            );
519
1
            Duration::ZERO
520
        };
521
2
        evicting_map
522
2
            .insert_with_time(
523
2
                key.into_owned().into(),
524
2
                Arc::new(file_entry),
525
2
                i32::try_from(time_since_anchor.as_secs()).unwrap_or(i32::MAX),
526
2
            )
527
2
            .await;
528
2
        Ok(())
529
2
    }
530
531
267
    async fn read_files(
532
267
        folder: Option<&str>,
533
267
        shared_context: &SharedContext,
534
267
    ) -> Result<Vec<(String, SystemTime, u64, bool)>, Error> {
535
        // Note: In Dec 2024 this is for backwards compatibility with the old
536
        // way files were stored on disk. Previously all files were in a single
537
        // folder regardless of the StoreKey type. This allows old versions of
538
        // nativelink file layout to be upgraded at startup time.
539
        // This logic can be removed once more time has passed.
540
267
        let read_dir = folder.map_or_else(
541
89
            || format!("{}/", shared_context.content_path),
542
178
            |folder| format!("{}/{folder}/", shared_context.content_path),
543
        );
544
545
267
        let (_permit, dir_handle) = fs::read_dir(read_dir)
546
267
            .await
547
267
            .err_tip(|| "Failed opening content directory for iterating in filesystem store")
?0
548
267
            .into_inner();
549
550
267
        let read_dir_stream = ReadDirStream::new(dir_handle);
551
267
        read_dir_stream
552
267
            .map(|dir_entry| async move 
{180
553
180
                let dir_entry = dir_entry.unwrap();
554
180
                let file_name = dir_entry.file_name().into_string().unwrap();
555
180
                let metadata = dir_entry
556
180
                    .metadata()
557
180
                    .await
558
180
                    .err_tip(|| "Failed to get metadata in filesystem store")
?0
;
559
                // We need to filter out folders - we do not want to try to cache the s and d folders.
560
180
                let is_file =
561
180
                    metadata.is_file() || !(
file_name == STR_FOLDER178
||
file_name == DIGEST_FOLDER89
);
562
                // Using access time is not perfect, but better than random. We do not update the
563
                // atime when a file is actually "touched", we rely on whatever the filesystem does
564
                // when we read the file (usually update on read).
565
180
                let atime = metadata
566
180
                    .accessed()
567
180
                    .or_else(|_| 
metadata0
.
modified0
())
568
180
                    .unwrap_or(SystemTime::UNIX_EPOCH);
569
180
                Result::<(String, SystemTime, u64, bool), Error>::Ok((
570
180
                    file_name,
571
180
                    atime,
572
180
                    metadata.len(),
573
180
                    is_file,
574
180
                ))
575
360
            })
576
267
            .buffer_unordered(SIMULTANEOUS_METADATA_READS)
577
267
            .try_collect()
578
267
            .await
579
267
    }
580
581
    /// Note: In Dec 2024 this is for backwards compatibility with the old
582
    /// way files were stored on disk. Previously all files were in a single
583
    /// folder regardless of the [`StoreKey`] type. This moves files from the old cache
584
    /// location to the new cache location, under [`DIGEST_FOLDER`].
585
89
    async fn move_old_cache(
586
89
        shared_context: &Arc<SharedContext>,
587
89
        rename_fn: fn(&OsStr, &OsStr) -> Result<(), std::io::Error>,
588
89
    ) -> Result<(), Error> {
589
89
        let file_infos = read_files(None, shared_context).await
?0
;
590
591
89
        let from_path = &shared_context.content_path;
592
593
89
        let to_path = format!("{}/{DIGEST_FOLDER}", shared_context.content_path);
594
595
89
        for (
file_name0
, _, _, _) in file_infos.into_iter().filter(|x| x.3) {
596
0
            let from_file: OsString = format!("{from_path}/{file_name}").into();
597
0
            let to_file: OsString = format!("{to_path}/{file_name}").into();
598
599
0
            if let Err(err) = rename_fn(&from_file, &to_file) {
600
0
                warn!(?from_file, ?to_file, ?err, "Failed to rename file",);
601
            } else {
602
0
                debug!(?from_file, ?to_file, "Renamed file (old cache)",);
603
            }
604
        }
605
89
        Ok(())
606
89
    }
607
608
178
    async fn add_files_to_cache<Fe: FileEntry>(
609
178
        evicting_map: &FsEvictingMap<'_, Fe>,
610
178
        anchor_time: &SystemTime,
611
178
        shared_context: &Arc<SharedContext>,
612
178
        block_size: u64,
613
178
        folder: &str,
614
178
    ) -> Result<(), Error> {
615
178
        let file_infos = read_files(Some(folder), shared_context).await
?0
;
616
178
        let file_type = match folder {
617
178
            STR_FOLDER => 
FileType::String89
,
618
89
            DIGEST_FOLDER => FileType::Digest,
619
0
            _ => panic!("Invalid folder type"),
620
        };
621
622
178
        let path_root = format!("{}/{folder}", shared_context.content_path);
623
624
178
        for (
file_name2
,
atime2
,
data_size2
, _) in file_infos.into_iter().filter(|x| x.3) {
625
2
            let result = process_entry(
626
2
                evicting_map,
627
2
                &file_name,
628
2
                file_type,
629
2
                atime,
630
2
                data_size,
631
2
                block_size,
632
2
                anchor_time,
633
2
                shared_context,
634
2
            )
635
2
            .await;
636
2
            if let Err(
err0
) = result {
637
0
                warn!(?file_name, ?err, "Failed to add file to eviction cache",);
638
                // Ignore result.
639
0
                drop(fs::remove_file(format!("{path_root}/{file_name}")).await);
640
2
            }
641
        }
642
178
        Ok(())
643
178
    }
644
645
89
    move_old_cache(shared_context, rename_fn).await
?0
;
646
647
89
    add_files_to_cache(
648
89
        evicting_map,
649
89
        anchor_time,
650
89
        shared_context,
651
89
        block_size,
652
89
        DIGEST_FOLDER,
653
89
    )
654
89
    .await
?0
;
655
656
89
    add_files_to_cache(
657
89
        evicting_map,
658
89
        anchor_time,
659
89
        shared_context,
660
89
        block_size,
661
89
        STR_FOLDER,
662
89
    )
663
89
    .await
?0
;
664
89
    Ok(())
665
89
}
666
667
89
async fn prune_temp_path(temp_path: &str) -> Result<(), Error> {
668
178
    async fn prune_temp_inner(temp_path: &str, subpath: &str) -> Result<(), Error> {
669
178
        let (_permit, dir_handle) = fs::read_dir(format!("{temp_path}/{subpath}"))
670
178
            .await
671
178
            .err_tip(
672
                || "Failed opening temp directory to prune partial downloads in filesystem store",
673
0
            )?
674
178
            .into_inner();
675
676
178
        let mut read_dir_stream = ReadDirStream::new(dir_handle);
677
178
        while let Some(
dir_entry0
) = read_dir_stream.next().await {
678
0
            let path = dir_entry?.path();
679
0
            if let Err(err) = fs::remove_file(&path).await {
680
0
                warn!(?path, ?err, "Failed to delete file",);
681
0
            }
682
        }
683
178
        Ok(())
684
178
    }
685
686
89
    prune_temp_inner(temp_path, STR_FOLDER).await
?0
;
687
89
    prune_temp_inner(temp_path, DIGEST_FOLDER).await
?0
;
688
89
    Ok(())
689
89
}
690
691
// Sometimes we get files to emplace that are identical to the existing files
692
// Due to the evict/remove/replace cycle taking some amount of time, we actually
693
// want to drop these
694
// Return value is "is duplicate"
695
1.24k
pub async fn check_duplicate_files<Fe>(
696
1.24k
    evicting_map: &Arc<FsEvictingMap<'_, Fe>>,
697
1.24k
    key: &StoreKey<'static>,
698
1.24k
    entry: &Arc<Fe>,
699
1.24k
) -> Result<bool, Error>
700
1.24k
where
701
1.24k
    Fe: FileEntry,
702
1.24k
{
703
1.24k
    let temp_file_encoded_file_path = entry.get_encoded_file_path().write().await;
704
1.24k
    let maybe_existing_item = evicting_map.get(&key.borrow().into_owned()).await;
705
1.24k
    if let Some(
existing_item9
) = maybe_existing_item {
706
9
        if Arc::ptr_eq(entry, &existing_item) {
707
1
            warn!("Tried to check duplicate of an entry we already have!");
708
1
            return Ok(true);
709
8
        }
710
8
        let existing_item_encoded_file_path = existing_item.get_encoded_file_path().write().await;
711
8
        if entry.data_size() == existing_item.data_size() {
712
            const CHUNK_SIZE: usize = 16 * 1024; // 16kb chunks, kinda picked out of the air
713
7
            let file_length = entry.data_size();
714
7
            let existing_path = existing_item_encoded_file_path.get_file_path();
715
7
            let temp_path = temp_file_encoded_file_path.get_file_path();
716
7
            trace!(?existing_path, ?temp_path, "Checking duplicate files");
717
7
            let mut temp_file = fs::open_file(&temp_path, 0, file_length).await
?0
;
718
7
            let mut existing_file = fs::open_file(&existing_path, 0, file_length).await
?0
;
719
720
7
            let mut temp_buffer: [u8; CHUNK_SIZE] = [0; CHUNK_SIZE];
721
7
            let mut existing_buffer: [u8; CHUNK_SIZE] = [0; CHUNK_SIZE];
722
            // in a file_length file, there are 0 to file_length-1 entries
723
            // not file_length. It's counting all the bytes, starting from 0
724
7
            for offset in (0..file_length - 1).step_by(CHUNK_SIZE) {
725
7
                let buffer_size = if offset + (CHUNK_SIZE as u64) <= file_length {
726
0
                    CHUNK_SIZE
727
7
                } else if file_length < CHUNK_SIZE as u64 {
728
7
                    usize::try_from(file_length)
729
7
                        .expect("Always succeeds because file_length < 16384")
730
                } else {
731
0
                    usize::try_from(file_length - offset).expect("Always succeeds because offset < file_length, and offset-file_length must be < 16384")
732
                };
733
7
                if let Err(
err0
) = temp_file.read_exact(&mut temp_buffer[0..buffer_size]).await {
734
0
                    warn!(
735
                        ?err,
736
                        ?temp_path,
737
                        file_length,
738
                        offset,
739
                        buffer_size,
740
                        "Failed to read temp file, skipping duplicate check"
741
                    );
742
0
                    return Ok(false);
743
7
                }
744
7
                if let Err(
err0
) = existing_file
745
7
                    .read_exact(&mut existing_buffer[0..buffer_size])
746
7
                    .await
747
                {
748
0
                    warn!(
749
                        ?err,
750
                        ?existing_path,
751
                        file_length,
752
                        offset,
753
                        buffer_size,
754
                        "Failed to read existing, skipping duplicate check"
755
                    );
756
0
                    return Ok(false);
757
7
                }
758
7
                if temp_buffer.ne(&existing_buffer) {
759
5
                    trace!(
760
                        ?existing_path,
761
                        ?temp_path,
762
                        "Files are different, so non-duplicate"
763
                    );
764
5
                    return Ok(false);
765
2
                }
766
            }
767
2
            trace!(
768
                ?existing_path,
769
                ?temp_path,
770
                "Identical files, so don't need to edit, skipping emplace"
771
            );
772
2
            return Ok(true);
773
1
        }
774
1
        trace!(
775
1
            entry_data_size = entry.data_size(),
776
1
            existing_data_size = existing_item.data_size(),
777
1
            existing_path = ?existing_item_encoded_file_path.get_file_path(),
778
            "Different data sizes, so non-duplicate"
779
        );
780
    } else {
781
1.23k
        trace!(
782
1.23k
            temp_file = ?temp_file_encoded_file_path.get_file_path(),
783
            "No existing entry, so not duplicate"
784
        );
785
    }
786
1.23k
    Ok(false)
787
1.24k
}
788
789
/// Deletes a digest's `.exec` variant (see
790
/// [`FilesystemStore::get_executable_hardlink_source`]) when that digest is
791
/// evicted or replaced in the primary CAS `evicting_map`. Without this, the
792
/// `.exec` directory is invisible to `max_bytes` and is only ever cleared by
793
/// the startup `remove_dir_all`, so it grows without bound at runtime (#2474).
794
/// Tying its lifetime to the primary entry instead bounds total disk use to
795
/// roughly `2 * max_bytes` in the worst case (every blob also executable).
796
#[cfg(unix)]
797
#[derive(Debug)]
798
struct ExecutableVariantRemover {
799
    content_path: String,
800
}
801
802
#[cfg(unix)]
803
impl RemoveItemCallback for ExecutableVariantRemover {
804
12
    fn callback<'a>(
805
12
        &'a self,
806
12
        store_key: StoreKey<'a>,
807
12
    ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
808
12
        Box::pin(async move {
809
12
            let StoreKey::Digest(
digest11
) = store_key else {
810
1
                return;
811
            };
812
11
            let variant_path = format!(
813
                "{}{EXECUTABLE_DIR_SUFFIX}/{DIGEST_FOLDER}/{digest}",
814
                self.content_path
815
            );
816
11
            match fs::remove_file(&variant_path).await {
817
1
                Ok(()) => debug!(
818
                    ?variant_path,
819
                    "Deleted executable variant for evicted digest"
820
                ),
821
                // Common case: no variant was ever materialized for this digest.
822
10
                Err(err) if err.code == Code::NotFound => {}
823
0
                Err(err) => warn!(
824
                    ?variant_path,
825
                    ?err,
826
                    "Failed to delete executable variant for evicted digest"
827
                ),
828
            }
829
12
        })
830
12
    }
831
}
832
833
#[derive(Debug, MetricsComponent)]
834
pub struct FilesystemStore<Fe: FileEntry = FileEntryImpl> {
835
    #[metric]
836
    shared_context: Arc<SharedContext>,
837
    #[metric(group = "evicting_map")]
838
    evicting_map: Arc<FsEvictingMap<'static, Fe>>,
839
    #[metric(help = "Block size of the configured filesystem")]
840
    block_size: u64,
841
    #[metric(help = "Size of the configured read buffer size")]
842
    read_buffer_size: usize,
843
    weak_self: Weak<Self>,
844
    rename_fn: fn(&OsStr, &OsStr) -> Result<(), std::io::Error>,
845
    /// Limits concurrent write operations to prevent disk I/O saturation.
846
    write_semaphore: Option<Semaphore>,
847
    /// Per-digest single-flight locks guarding creation of the executable
848
    /// variant in `{content_path}.exec`, so each variant's writable fd is
849
    /// opened exactly once. The outer lock is sync and only ever held to
850
    /// get/insert/remove the per-digest async lock — never across I/O.
851
    #[cfg(unix)]
852
    executable_locks: std::sync::Mutex<HashMap<DigestInfo, Arc<Mutex<()>>>>,
853
}
854
855
impl<Fe: FileEntry> FilesystemStore<Fe> {
856
77
    pub async fn new(spec: &FilesystemSpec) -> Result<Arc<Self>, Error> {
857
1.22k
        
Self::new_with_timeout_and_rename_fn77
(
spec77
, |from, to| std::fs::rename(from, to)).
await77
858
77
    }
859
860
89
    pub async fn new_with_timeout_and_rename_fn(
861
89
        spec: &FilesystemSpec,
862
89
        rename_fn: fn(&OsStr, &OsStr) -> Result<(), std::io::Error>,
863
89
    ) -> Result<Arc<Self>, Error> {
864
178
        async fn create_subdirs(path: &str) -> Result<(), Error> {
865
178
            fs::create_dir_all(format!("{path}/{STR_FOLDER}"))
866
178
                .await
867
178
                .err_tip(|| 
format!0
("Failed to create directory {path}/{STR_FOLDER}"))
?0
;
868
178
            fs::create_dir_all(format!("{path}/{DIGEST_FOLDER}"))
869
178
                .await
870
178
                .err_tip(|| 
format!0
("Failed to create directory {path}/{DIGEST_FOLDER}"))
871
178
        }
872
873
89
        let now = SystemTime::now();
874
875
89
        let empty_policy = nativelink_config::stores::EvictionPolicy::default();
876
89
        let eviction_policy = spec.eviction_policy.as_ref().unwrap_or(&empty_policy);
877
89
        let evicting_map = Arc::new(EvictingMap::new(eviction_policy, now));
878
879
        // Create temp and content directories and the s and d subdirectories.
880
881
89
        create_subdirs(&spec.temp_path).await
?0
;
882
89
        create_subdirs(&spec.content_path).await
?0
;
883
884
        // Executable-variant directory: a sibling of `content_path` holding
885
        // per-digest 0o555 copies used as hardlink sources for executable
886
        // inputs (see `get_executable_hardlink_source`). Cleared on startup —
887
        // the variants are regenerable and we never want a stale one to leak
888
        // across runs. Unix-only: the executable bit (and the ETXTBSY race it
889
        // guards against) does not apply on Windows.
890
        #[cfg(unix)]
891
        {
892
89
            let executable_dir = format!("{}{EXECUTABLE_DIR_SUFFIX}", spec.content_path);
893
89
            drop(fs::remove_dir_all(&executable_dir).await);
894
89
            fs::create_dir_all(format!("{executable_dir}/{DIGEST_FOLDER}"))
895
89
                .await
896
89
                .err_tip(|| 
format!0
("Failed to create executable dir {executable_dir}"))
?0
;
897
89
            evicting_map.add_remove_callback(RemoveItemCallbackHolder::new(Arc::new(
898
89
                ExecutableVariantRemover {
899
89
                    content_path: spec.content_path.clone(),
900
89
                },
901
89
            )));
902
        }
903
904
89
        let shared_context = Arc::new(SharedContext {
905
89
            active_drop_spawns: AtomicU64::new(0),
906
89
            temp_path: spec.temp_path.clone(),
907
89
            content_path: spec.content_path.clone(),
908
89
        });
909
910
89
        let block_size = if spec.block_size == 0 {
911
85
            DEFAULT_BLOCK_SIZE
912
        } else {
913
4
            spec.block_size
914
        };
915
89
        add_files_to_cache(
916
89
            evicting_map.as_ref(),
917
89
            &now,
918
89
            &shared_context,
919
89
            block_size,
920
89
            rename_fn,
921
89
        )
922
89
        .await
?0
;
923
89
        prune_temp_path(&shared_context.temp_path).await
?0
;
924
925
89
        let read_buffer_size = if spec.read_buffer_size == 0 {
926
74
            DEFAULT_BUFF_SIZE
927
        } else {
928
15
            spec.read_buffer_size as usize
929
        };
930
89
        let write_semaphore = if spec.max_concurrent_writes > 0 {
931
0
            Some(Semaphore::new(spec.max_concurrent_writes))
932
        } else {
933
89
            None
934
        };
935
89
        Ok(Arc::new_cyclic(|weak_self| Self {
936
89
            shared_context,
937
89
            evicting_map,
938
89
            block_size,
939
89
            read_buffer_size,
940
89
            weak_self: weak_self.clone(),
941
89
            rename_fn,
942
89
            write_semaphore,
943
            #[cfg(unix)]
944
89
            executable_locks: std::sync::Mutex::new(HashMap::new()),
945
89
        }))
946
89
    }
947
948
51
    pub fn get_arc(&self) -> Option<Arc<Self>> {
949
51
        self.weak_self.upgrade()
950
51
    }
951
952
    /// Path of the read-only executable (0o555) variant for `digest`.
953
    #[cfg(unix)]
954
5
    fn executable_variant_path(&self, digest: &DigestInfo) -> OsString {
955
5
        format!(
956
            "{}{EXECUTABLE_DIR_SUFFIX}/{DIGEST_FOLDER}/{digest}",
957
5
            self.shared_context.content_path
958
        )
959
5
        .into()
960
5
    }
961
962
    /// Returns the path to a private, read-only **executable** (0o555) copy of
963
    /// the blob for `digest`, creating it at most once. Callers **hardlink**
964
    /// the returned path into action input trees instead of copying the
965
    /// executable per action.
966
    ///
967
    /// Why this exists: a CAS blob is stored read-only **0o444** and shared
968
    /// across actions by hardlink, so it cannot carry the executable bit and
969
    /// must never be `chmod`'d (that mutates the shared inode — the #2347
970
    /// corruption class). Materializing an executable input therefore needs a
971
    /// separate 0o555 inode. Doing that copy *per action* opens a writable fd
972
    /// in the worker's hot path; under fork-heavy concurrency a child can
973
    /// inherit that fd and a concurrent `execve` of the executable then fails
974
    /// with `ETXTBSY` ("Text file busy", os error 26). Creating the 0o555 inode
975
    /// **once** — writer fd fsync'd and closed, then atomically renamed into
976
    /// place before the inode is ever hardlinked or executed — and hardlinking
977
    /// it thereafter keeps the per-action path hardlink-only.
978
    #[cfg(unix)]
979
5
    pub async fn get_executable_hardlink_source(
980
5
        &self,
981
5
        digest: &DigestInfo,
982
5
    ) -> Result<OsString, Error> {
983
5
        let variant_path = self.executable_variant_path(digest);
984
985
        // Fast path: the variant already exists, so the caller can hardlink it
986
        // with no writable fd anywhere in sight.
987
5
        if fs::metadata(&variant_path).await.is_ok() {
988
2
            return Ok(variant_path);
989
3
        }
990
991
        // Single-flight: exactly one task ever opens a writable fd for this
992
        // variant. Without this, a cold-cache burst of concurrent actions
993
        // would each open a writer for the same executable — the very window
994
        // ETXTBSY exploits.
995
3
        let lock = {
996
3
            let mut locks = self
997
3
                .executable_locks
998
3
                .lock()
999
3
                .expect("executable_locks poisoned");
1000
3
            locks
1001
3
                .entry(*digest)
1002
3
                .or_insert_with(|| Arc::new(Mutex::new(())))
1003
3
                .clone()
1004
        };
1005
3
        let _guard = lock.lock().await;
1006
1007
        // Re-check: another task may have constructed it while we waited.
1008
3
        if fs::metadata(&variant_path).await.is_ok() {
1009
0
            self.forget_executable_lock(digest);
1010
0
            return Ok(variant_path);
1011
3
        }
1012
1013
3
        let result = self.create_executable_variant(digest, &variant_path).await;
1014
1015
        // The digest may have been evicted mid-copy: its eviction callback ran
1016
        // before the rename published the variant, so nothing owns the file
1017
        // anymore. This orphans the variant from eviction accounting, but the
1018
        // race is rare enough (needs an eviction to land in the narrow window
1019
        // between rename and this check, on a digest's first-ever variant
1020
        // materialization) that it's a self-limiting leak, not a systemic one
1021
        // — cheaper to log and let this action succeed with the still-valid
1022
        // file than to fail an otherwise-successful action over it.
1023
3
        if result.is_ok() && self.evicting_map.get(&digest.into()).await.is_none() {
1024
0
            warn!(
1025
                %digest,
1026
                ?variant_path,
1027
                "Digest evicted while materializing its executable variant; \
1028
                 variant is now untracked by eviction accounting"
1029
            );
1030
3
        }
1031
1032
        // Drop the per-digest lock entry regardless of outcome so the map
1033
        // cannot grow unbounded; a concurrent waiter already cloned the Arc.
1034
3
        self.forget_executable_lock(digest);
1035
3
        result.map(|()| variant_path)
1036
5
    }
1037
1038
    /// Non-unix has no executable bit and no `ETXTBSY`, so just hardlink the
1039
    /// CAS blob directly.
1040
    #[cfg(not(unix))]
1041
    pub async fn get_executable_hardlink_source(
1042
        &self,
1043
        digest: &DigestInfo,
1044
    ) -> Result<OsString, Error> {
1045
        let file_entry = self.get_file_entry_for_digest(digest).await?;
1046
        file_entry
1047
            .get_file_path_locked(|p| async move { Ok(p) })
1048
            .await
1049
    }
1050
1051
    #[cfg(unix)]
1052
3
    fn forget_executable_lock(&self, digest: &DigestInfo) {
1053
3
        self.executable_locks
1054
3
            .lock()
1055
3
            .expect("executable_locks poisoned")
1056
3
            .remove(digest);
1057
3
    }
1058
1059
    /// Materializes the 0o555 executable variant for `digest`. Must be called
1060
    /// under the per-digest single-flight guard.
1061
    #[cfg(unix)]
1062
3
    async fn create_executable_variant(
1063
3
        &self,
1064
3
        digest: &DigestInfo,
1065
3
        variant_path: &OsStr,
1066
3
    ) -> Result<(), Error> {
1067
        // Resolve the on-disk CAS blob (0o444) to copy from. Must be present in
1068
        // this tier; callers populate the fast store first.
1069
3
        let file_entry = self
1070
3
            .get_file_entry_for_digest(digest)
1071
3
            .await
1072
3
            .err_tip(|| "Resolving CAS blob for executable variant")
?0
;
1073
3
        let src_path = file_entry
1074
6
            .
get_file_path_locked3
(|p| async move
{3
Ok(p)3
})
1075
3
            .await
?0
;
1076
1077
3
        let variant_owned = variant_path.to_os_string();
1078
3
        let mut temp_owned = variant_path.to_os_string();
1079
3
        temp_owned.push(".tmp");
1080
3
        let rename_fn = self.rename_fn;
1081
1082
        // All of this is blocking std::fs; run it off the async runtime. The
1083
        // writable fd opened by `copy` is fully closed before the `rename`
1084
        // publishes the inode, so no reachable hardlink of the variant ever has
1085
        // an open writer.
1086
3
        spawn_blocking!(
1087
            "filesystem_store_executable_variant",
1088
3
            move || -> Result<(), Error> {
1089
                use std::os::unix::fs::PermissionsExt;
1090
3
                std::fs::copy(&src_path, &temp_owned).map_err(|e| 
{0
1091
0
                    make_err!(Code::Internal, "executable-variant copy failed: {e:?}")
1092
0
                })?;
1093
3
                std::fs::set_permissions(&temp_owned, std::fs::Permissions::from_mode(0o555))
1094
3
                    .map_err(|e| 
{0
1095
0
                        make_err!(
1096
0
                            Code::Internal,
1097
                            "executable-variant chmod 0o555 failed: {e:?}"
1098
                        )
1099
0
                    })?;
1100
                // Reopen read-only purely to fsync the bytes durable before publish.
1101
3
                let f = std::fs::File::open(&temp_owned)
1102
3
                    .map_err(|e| 
make_err!0
(
Code::Internal0
, "executable-variant reopen: {e:?}"))
?0
;
1103
3
                f.sync_all()
1104
3
                    .map_err(|e| 
make_err!0
(
Code::Internal0
, "executable-variant fsync: {e:?}"))
?0
;
1105
3
                drop(f);
1106
3
                rename_fn(temp_owned.as_os_str(), variant_owned.as_os_str()).map_err(|e| 
{0
1107
0
                    make_err!(Code::Internal, "executable-variant rename failed: {e:?}")
1108
0
                })?;
1109
3
                Ok(())
1110
3
            }
1111
        )
1112
3
        .await
1113
3
        .err_tip(|| "executable-variant spawn_blocking join failed")
?0
1114
3
    }
1115
1116
1.09k
    pub async fn get_file_entry_for_digest(&self, digest: &DigestInfo) -> Result<Arc<Fe>, Error> {
1117
        // Zero-digest blobs have no backing file on disk (FilesystemStore
1118
        // never persists zero-byte content). The previous implementation
1119
        // returned a synthetic FileEntry whose content_path did not exist,
1120
        // which downstream callers would then try to hard_link from,
1121
        // silently producing missing or empty output files in worker
1122
        // execution directories. Return NotFound so callers are forced to
1123
        // take the explicit zero-digest path (e.g. fs::create_file).
1124
1.09k
        if is_zero_digest(digest) {
1125
1
            return Err(make_err!(
1126
1
                Code::NotFound,
1127
1
                "{digest} is a zero-digest; FilesystemStore does not persist zero-byte files. \
1128
1
                 Callers must materialise empty files directly rather than going through get_file_entry_for_digest."
1129
1
            ));
1130
1.08k
        }
1131
1.08k
        self.evicting_map
1132
1.08k
            .get(&digest.into())
1133
1.08k
            .await
1134
1.08k
            .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."))
1135
1.09k
    }
1136
1137
1.19k
    async fn update_file(
1138
1.19k
        self: Pin<&Self>,
1139
1.19k
        mut entry: Fe,
1140
1.19k
        mut temp_file: FileSlot,
1141
1.19k
        final_key: StoreKey<'static>,
1142
1.19k
        mut reader: DropCloserReadHalf,
1143
1.19k
    ) -> Result<u64, Error> {
1144
1.19k
        let mut data_size = 0;
1145
        loop {
1146
2.38k
            let mut data = reader
1147
2.38k
                .recv()
1148
2.38k
                .await
1149
2.38k
                .err_tip(|| "Failed to receive data in filesystem store")
?0
;
1150
2.38k
            let data_len = data.len();
1151
2.38k
            if data_len == 0 {
1152
1.19k
                break; // EOF.
1153
1.19k
            }
1154
1.19k
            temp_file
1155
1.19k
                .write_all_buf(&mut data)
1156
1.19k
                .await
1157
1.19k
                .err_tip(|| "Failed to write data into filesystem store")
?0
;
1158
1.19k
            data_size += data_len as u64;
1159
        }
1160
1161
1.19k
        let permit = if let Some(
sem0
) = &self.write_semaphore {
1162
0
            Some(sem.acquire().await.map_err(|err| {
1163
0
                Error::from_std_err(Code::Internal, &err).append("Write semaphore closed")
1164
0
            })?)
1165
        } else {
1166
1.19k
            None
1167
        };
1168
1169
        // tokio defers write errors to the next write or flush call, so without an explicit flush
1170
        // the final write's failure is silently swallowed by `sync_all`, and a truncated file would
1171
        // be renamed into the content path.
1172
1.19k
        temp_file
1173
1.19k
            .flush()
1174
1.19k
            .await
1175
1.19k
            .err_tip(|| "Failed to flush in filesystem store")
?0
;
1176
1.19k
        temp_file
1177
1.19k
            .as_ref()
1178
1.19k
            .sync_all()
1179
1.19k
            .await
1180
1.19k
            .err_tip(|| "Failed to sync_data in filesystem store")
?0
;
1181
1182
1.19k
        drop(permit);
1183
1184
1.19k
        temp_file.advise_dontneed();
1185
1.19k
        trace!(?temp_file, "Dropping file to update_file");
1186
1.19k
        drop(temp_file);
1187
1188
1.19k
        *entry.data_size_mut() = data_size;
1189
1.19k
        self.emplace_file(final_key, Arc::new(entry)).await
?1
;
1190
1.19k
        Ok(data_size)
1191
1.19k
    }
1192
1193
1.23k
    async fn emplace_file(&self, key: StoreKey<'static>, entry: Arc<Fe>) -> Result<(), Error> {
1194
        // This sequence of events is quite tricky to understand due to the amount of triggers that
1195
        // happen, async'ness of it and the locking. So here is a breakdown of what happens:
1196
        // 1. Here will hold a write lock on any file operations of this FileEntry.
1197
        // 2. Then insert the entry into the evicting map. This may trigger an eviction of other
1198
        //    entries.
1199
        // 3. Eviction triggers `unref()`, which grabs a write lock on the evicted FileEntry
1200
        //    during the rename.
1201
        // 4. It should be impossible for items to be added while eviction is happening, so there
1202
        //    should not be a deadlock possibility. However, it is possible for the new FileEntry
1203
        //    to be evicted before the file is moved into place. Eviction of the newly inserted
1204
        //    item is not possible within the `insert()` call because the write lock inside the
1205
        //    eviction map. If an eviction of new item happens after `insert()` but before
1206
        //    `rename()` then we get to finish our operation because the `unref()` of the new item
1207
        //    will be blocked on us because we currently have the lock.
1208
        // 5. Move the file into place. Since we hold a write lock still anyone that gets our new
1209
        //    FileEntry (which has not yet been placed on disk) will not be able to read the file's
1210
        //    contents until we release the lock.
1211
1.23k
        let evicting_map = self.evicting_map.clone();
1212
1.23k
        let rename_fn = self.rename_fn;
1213
1214
        // We need to guarantee that this will get to the end even if the parent future is dropped.
1215
        // See: https://github.com/TraceMachina/nativelink/issues/495
1216
1.23k
        background_spawn!("filesystem_store_emplace_file", async move {
1217
            // Sometimes we get files to emplace that are identical to the existing files
1218
            // Due to the evict/remove/replace cycle taking some amount of time, we actually
1219
            // want to drop these
1220
1.23k
            if check_duplicate_files(&evicting_map, &key, &entry).await
?0
{
1221
1
                return Ok(());
1222
1.23k
            }
1223
1224
1.23k
            evicting_map
1225
1.23k
                .insert(key.borrow().into_owned().into(), entry.clone())
1226
1.23k
                .await;
1227
1228
            // The insert might have resulted in an eviction/unref so we need to check
1229
            // it still exists in there. But first, get the lock...
1230
1.23k
            let mut encoded_file_path = entry.get_encoded_file_path().write().await;
1231
            // Then check it's still in there...
1232
1.23k
            if evicting_map.get(&key).await.is_none() {
1233
1
                info!(%key, "Got eviction while emplacing, dropping");
1234
1
                return Ok(());
1235
1.23k
            }
1236
1237
1.23k
            let final_path = get_file_path_raw(
1238
1.23k
                &PathType::Content,
1239
1.23k
                encoded_file_path.shared_context.as_ref(),
1240
1.23k
                &key,
1241
            );
1242
1243
1.23k
            let from_path = encoded_file_path.get_file_path();
1244
1245
            // Lock the blob down as read-only *before* it lands at its final
1246
            // content path, so every hardlink of it (the worker directory cache
1247
            // and `download_to_directory`) inherits an immutable, read-only
1248
            // inode. This is what preserves the input-tree hermeticity contract
1249
            // (actions cannot mutate their inputs) without a per-materialization
1250
            // chmod walk, and it means callers must never `chmod` a hardlinked
1251
            // blob — anything needing a different mode (e.g. an executable's +x
1252
            // bit) must take a private copy. Content-addressed blobs are
1253
            // write-once and replaced by `rename` rather than in-place writes,
1254
            // and unlink only needs the *parent directory* to be writable, so a
1255
            // read-only file mode is safe for both overwrite-by-rename and
1256
            // eviction. Best-effort: a failure here (e.g. the temp file was
1257
            // already evicted out from under us) must not abort the emplace.
1258
            #[cfg(unix)]
1259
            {
1260
                use std::os::unix::fs::PermissionsExt;
1261
0
                if let Err(err) =
1262
1.23k
                    fs::set_permissions(&from_path, std::fs::Permissions::from_mode(0o444)).await
1263
                {
1264
0
                    warn!(?err, ?from_path, "Failed to set CAS blob read-only");
1265
1.23k
                }
1266
            }
1267
            // Internally tokio spawns fs commands onto a blocking thread anyways.
1268
            // Since we are already on a blocking thread, we just need the `fs` wrapper to manage
1269
            // an open-file permit (ensure we don't open too many files at once).
1270
1.23k
            let result = (rename_fn)(&from_path, &final_path).err_tip(|| 
{1
1271
1
                format!(
1272
                    "Failed to rename temp file to final path {}",
1273
1
                    final_path.display()
1274
                )
1275
1
            });
1276
1277
            // In the event our move from temp file to final file fails we need to ensure we remove
1278
            // the entry from our map.
1279
            // Remember: At this point it is possible for another thread to have a reference to
1280
            // `entry`, so we can't delete the file, only drop() should ever delete files.
1281
1.23k
            if let Err(
err1
) = result {
1282
1
                error!(?err, ?from_path, ?final_path, "Failed to rename file",);
1283
                // Warning: To prevent deadlock we need to release our lock or during `remove_if()`
1284
                // it will call `unref()`, which triggers a write-lock on `encoded_file_path`.
1285
1
                drop(encoded_file_path);
1286
                // It is possible that the item in our map is no longer the item we inserted,
1287
                // So, we need to conditionally remove it only if the pointers are the same.
1288
1289
1
                evicting_map
1290
1
                    .remove_if(&key, |map_entry| Arc::<Fe>::ptr_eq(map_entry, &entry))
1291
1
                    .await;
1292
1
                return Err(err);
1293
1.23k
            }
1294
1.23k
            trace!(?key, "Finished emplace file");
1295
1.23k
            encoded_file_path.path_type = PathType::Content;
1296
1.23k
            encoded_file_path.key = key;
1297
1.23k
            Ok(())
1298
1.23k
        })
1299
1.23k
        .await
1300
1.23k
        .err_tip(|| "Failed to create spawn in filesystem store update_file")
?0
1301
1.23k
    }
1302
1303
0
    pub fn get_eviction_snapshot(&self) -> EvictionSnapshot {
1304
0
        self.evicting_map.get_snapshot()
1305
0
    }
1306
1307
    // Only for tests, so we can run check_duplicate_files
1308
4
    pub fn get_evicting_map(&self) -> Arc<FsEvictingMap<'static, Fe>> {
1309
4
        self.evicting_map.clone()
1310
4
    }
1311
1312
    // Separated out so tests can use this
1313
1.22k
    pub async fn make_temp_file(
1314
1.22k
        &self,
1315
1.22k
        temp_key: StoreKey<'static>,
1316
1.22k
    ) -> Result<(Fe, FileSlot, OsString), Error> {
1317
1.22k
        Fe::make_and_open_file(
1318
1.22k
            self.block_size,
1319
1.22k
            EncodedFilePath {
1320
1.22k
                shared_context: self.shared_context.clone(),
1321
1.22k
                path_type: PathType::Temp,
1322
1.22k
                key: temp_key,
1323
1.22k
            },
1324
1.22k
        )
1325
1.22k
        .await
1326
1.22k
    }
1327
}
1328
1329
#[async_trait]
1330
impl<Fe: FileEntry> StoreDriver for FilesystemStore<Fe> {
1331
0
    async fn post_init(self: Arc<Self>) -> Result<(), Error> {
1332
        Ok(())
1333
0
    }
1334
1335
    async fn has_with_results(
1336
        self: Pin<&Self>,
1337
        keys: &[StoreKey<'_>],
1338
        results: &mut [Option<u64>],
1339
1.42k
    ) -> Result<(), Error> {
1340
        let own_keys = keys
1341
            .iter()
1342
1.42k
            .map(|sk| sk.borrow().into_owned())
1343
            .collect::<Vec<_>>();
1344
        self.evicting_map
1345
            .sizes_for_keys(own_keys.iter(), results, false /* peek */)
1346
            .await;
1347
        // We need to do a special pass to ensure our zero files exist.
1348
        // If our results failed and the result was a zero file, we need to
1349
        // create the file by spec.
1350
        for (key, result) in keys.iter().zip(results.iter_mut()) {
1351
            if result.is_some() || !is_zero_digest(key.borrow()) {
1352
                continue;
1353
            }
1354
            let (mut tx, rx) = make_buf_channel_pair();
1355
            let send_eof_result = tx.send_eof();
1356
            self.update(key.borrow(), rx, UploadSizeInfo::ExactSize(0))
1357
                .await
1358
0
                .err_tip(|| format!("Failed to create zero file for key {}", key.as_str()))
1359
                .merge(
1360
                    send_eof_result
1361
                        .err_tip(|| "Failed to send zero file EOF in filesystem store has"),
1362
                )?;
1363
1364
            *result = Some(0);
1365
        }
1366
        Ok(())
1367
1.42k
    }
1368
1369
    async fn update(
1370
        self: Pin<&Self>,
1371
        key: StoreKey<'_>,
1372
        mut reader: DropCloserReadHalf,
1373
        _upload_size: UploadSizeInfo,
1374
1.27k
    ) -> Result<u64, Error> {
1375
        if is_zero_digest(key.borrow()) {
1376
            // don't need to add, because zero length files are just assumed to exist.
1377
            return Ok(0);
1378
        }
1379
1380
        let temp_key = make_temp_key(&key);
1381
1382
        // There's a possibility of deadlock here where we take all of the
1383
        // file semaphores with make_and_open_file and the semaphores for
1384
        // whatever is populating reader is exhasted on the threads that
1385
        // have the FileSlots and not on those which can't.  To work around
1386
        // this we don't take the FileSlot until there's something on the
1387
        // reader available to know that the populator is active.
1388
        reader.peek().await?;
1389
1390
        let (entry, temp_file, temp_full_path) = self.make_temp_file(temp_key).await?;
1391
1392
        self.update_file(entry, temp_file, key.into_owned(), reader)
1393
            .await
1394
1
            .err_tip(|| {
1395
1
                format!(
1396
                    "While processing with temp file {}",
1397
1
                    temp_full_path.display()
1398
                )
1399
1
            })
1400
1.27k
    }
1401
1402
1.25k
    fn optimized_for(&self, optimization: StoreOptimizations) -> bool {
1403
1.24k
        matches!(
1404
1.25k
            optimization,
1405
            StoreOptimizations::FileUpdates | StoreOptimizations::SubscribesToUpdateOneshot
1406
        )
1407
1.25k
    }
1408
1409
34
    async fn update_oneshot(self: Pin<&Self>, key: StoreKey<'_>, data: Bytes) -> Result<(), Error> {
1410
        if is_zero_digest(key.borrow()) {
1411
            return Ok(());
1412
        }
1413
1414
        let temp_key = make_temp_key(&key);
1415
        let (mut entry, mut temp_file, temp_full_path) = self
1416
            .make_temp_file(temp_key)
1417
            .await
1418
            .err_tip(|| "Failed to create temp file in filesystem store update_oneshot")?;
1419
1420
        // Write directly without channel overhead
1421
        if !data.is_empty() {
1422
            temp_file
1423
                .write_all(&data)
1424
                .await
1425
0
                .err_tip(|| format!("Failed to write data to {}", temp_full_path.display()))?;
1426
        }
1427
1428
        let _permit = if let Some(sem) = &self.write_semaphore {
1429
0
            Some(sem.acquire().await.map_err(|err| {
1430
0
                Error::from_std_err(Code::Internal, &err).append("Write semaphore closed")
1431
0
            })?)
1432
        } else {
1433
            None
1434
        };
1435
1436
        // See comment in `update_file` above.
1437
        temp_file
1438
            .flush()
1439
            .await
1440
            .err_tip(|| "Failed to flush in filesystem store update_oneshot")?;
1441
        temp_file
1442
            .as_ref()
1443
            .sync_all()
1444
            .await
1445
            .err_tip(|| "Failed to sync_data in filesystem store update_oneshot")?;
1446
1447
        drop(_permit);
1448
1449
        temp_file.advise_dontneed();
1450
        drop(temp_file);
1451
1452
        *entry.data_size_mut() = data.len() as u64;
1453
        self.emplace_file(key.into_owned(), Arc::new(entry)).await
1454
34
    }
1455
1456
    async fn update_with_whole_file(
1457
        self: Pin<&Self>,
1458
        key: StoreKey<'_>,
1459
        path: OsString,
1460
        file: FileSlot,
1461
        upload_size: UploadSizeInfo,
1462
13
    ) -> Result<(u64, Option<FileSlot>), Error> {
1463
        let file_size = match upload_size {
1464
            UploadSizeInfo::ExactSize(size) => size,
1465
            UploadSizeInfo::MaxSize(_) => file
1466
                .as_ref()
1467
                .metadata()
1468
                .await
1469
0
                .err_tip(|| format!("While reading metadata for {}", path.display()))?
1470
                .len(),
1471
        };
1472
        if file_size == 0 {
1473
            // don't need to add, because zero length files are just assumed to exist
1474
            return Ok((0, None));
1475
        }
1476
        let entry = Fe::create(
1477
            file_size,
1478
            self.block_size,
1479
            RwLock::new(EncodedFilePath {
1480
                shared_context: self.shared_context.clone(),
1481
                path_type: PathType::Custom(path),
1482
                key: key.borrow().into_owned(),
1483
            }),
1484
        );
1485
        // We are done with the file, if we hold a reference to the file here, it could
1486
        // result in a deadlock if `emplace_file()` also needs file descriptors.
1487
        trace!(?file, "Dropping file to to update_with_whole_file");
1488
        file.advise_dontneed();
1489
        drop(file);
1490
        self.emplace_file(key.into_owned(), Arc::new(entry))
1491
            .await
1492
            .err_tip(|| "Could not move file into store in upload_file_to_store, maybe dest is on different volume?")?;
1493
        return Ok((file_size, None));
1494
13
    }
1495
1496
    async fn get_part(
1497
        self: Pin<&Self>,
1498
        key: StoreKey<'_>,
1499
        writer: &mut DropCloserWriteHalf,
1500
        offset: u64,
1501
        length: Option<u64>,
1502
136
    ) -> Result<(), Error> {
1503
        if is_zero_digest(key.borrow()) {
1504
            self.has(key.borrow())
1505
                .await
1506
                .err_tip(|| "Failed to check if zero digest exists in filesystem store")?;
1507
            writer
1508
                .send_eof()
1509
                .err_tip(|| "Failed to send zero EOF in filesystem store get_part")?;
1510
            return Ok(());
1511
        }
1512
        let owned_key = key.into_owned();
1513
2
        let entry = self.evicting_map.get(&owned_key).await.ok_or_else(|| {
1514
2
            make_err!(
1515
2
                Code::NotFound,
1516
                "{} not found in filesystem store here",
1517
2
                owned_key.as_str()
1518
            )
1519
2
        })?;
1520
        let read_limit = length.unwrap_or(u64::MAX);
1521
3
        let mut temp_file = entry.read_file_part(offset, read_limit).or_else(|err| async move {
1522
            // If the file is not found, we need to remove it from the eviction map.
1523
3
            if err.code == Code::NotFound {
1524
                // Map said the file was present but `open()` hit ENOENT.
1525
                // Self-heals: we remove the stale entry below and a
1526
                // fast/slow caller re-populates from the slow store, so
1527
                // this is a recoverable warn, not a fatal error.
1528
3
                warn!(
1529
                    ?err,
1530
                    key = ?owned_key,
1531
                    "Filesystem store map/disk divergence: removing entry; reader will fall through to slow store",
1532
                );
1533
3
                self.evicting_map.remove(&owned_key).await;
1534
0
            }
1535
3
            Err(err)
1536
6
        }).await?;
1537
1538
        loop {
1539
            let mut buf = BytesMut::with_capacity(self.read_buffer_size);
1540
            temp_file
1541
                .read_buf(&mut buf)
1542
                .await
1543
                .err_tip(|| "Failed to read data in filesystem store")?;
1544
            if buf.is_empty() {
1545
                break; // EOF.
1546
            }
1547
            writer
1548
                .send(buf.freeze())
1549
                .await
1550
                .err_tip(|| "Failed to send chunk in filesystem store get_part")?;
1551
        }
1552
        temp_file.get_ref().advise_dontneed();
1553
        writer
1554
            .send_eof()
1555
            .err_tip(|| "Filed to send EOF in filesystem store get_part")?;
1556
1557
        Ok(())
1558
136
    }
1559
1560
1.29k
    fn inner_store(&self, _digest: Option<StoreKey>) -> &dyn StoreDriver {
1561
1.29k
        self
1562
1.29k
    }
1563
1564
51
    fn as_any<'a>(&'a self) -> &'a (dyn core::any::Any + Sync + Send + 'static) {
1565
51
        self
1566
51
    }
1567
1568
0
    fn as_any_arc(self: Arc<Self>) -> Arc<dyn core::any::Any + Sync + Send + 'static> {
1569
0
        self
1570
0
    }
1571
1572
0
    fn register_health(self: Arc<Self>, registry: &mut HealthRegistryBuilder) {
1573
0
        registry.register_indicator(self);
1574
0
    }
1575
1576
0
    fn register_remove_callback(
1577
0
        self: Arc<Self>,
1578
0
        callback: Arc<dyn RemoveItemCallback>,
1579
0
    ) -> Result<(), Error> {
1580
0
        self.evicting_map
1581
0
            .add_remove_callback(RemoveItemCallbackHolder::new(callback));
1582
0
        Ok(())
1583
0
    }
1584
}
1585
1586
#[async_trait]
1587
impl<Fe: FileEntry> HealthStatusIndicator for FilesystemStore<Fe> {
1588
0
    fn get_name(&self) -> &'static str {
1589
0
        "FilesystemStore"
1590
0
    }
1591
1592
    /// Lightweight probe: `stat()` the `content_path` directory. No
1593
    /// write-semaphore / eviction-map contention with production
1594
    /// traffic, and bounded so a hung NFS / EBS mount can't wedge the
1595
    /// indicator.
1596
2
    async fn check_health(&self, _namespace: Cow<'static, str>) -> HealthStatus {
1597
        const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
1598
1599
        let content_path = &self.shared_context.content_path;
1600
        let stat = tokio::fs::metadata(&content_path);
1601
        match timeout(HEALTH_PROBE_TIMEOUT, stat).await {
1602
            Ok(Ok(meta)) if meta.is_dir() => {
1603
                HealthStatus::new_ok(self, "FilesystemStore::check_health: ok".into())
1604
            }
1605
            Ok(Ok(_)) => HealthStatus::new_failed(
1606
                self,
1607
                format!(
1608
                    "FilesystemStore::check_health: content_path {content_path} is not a directory"
1609
                )
1610
                .into(),
1611
            ),
1612
            Ok(Err(e)) => {
1613
                warn!(
1614
                    ?e,
1615
                    %content_path,
1616
                    "FilesystemStore::check_health: stat errored",
1617
                );
1618
                HealthStatus::new_failed(
1619
                    self,
1620
                    format!("FilesystemStore::check_health: stat errored: {e}").into(),
1621
                )
1622
            }
1623
            Err(_) => {
1624
                warn!(
1625
                    %content_path,
1626
                    timeout_secs = HEALTH_PROBE_TIMEOUT.as_secs(),
1627
                    "FilesystemStore::check_health: stat timed out",
1628
                );
1629
                HealthStatus::Timeout {
1630
                    struct_name: self.struct_name(),
1631
                }
1632
            }
1633
        }
1634
2
    }
1635
}