Skip to content

Fix the raciness of the filesystem store (in progress) - #2715

Open
Jirixek wants to merge 2 commits into
TraceMachina:mainfrom
Jirixek:jsz/fix_fs_store_race
Open

Fix the raciness of the filesystem store (in progress)#2715
Jirixek wants to merge 2 commits into
TraceMachina:mainfrom
Jirixek:jsz/fix_fs_store_race

Conversation

@Jirixek

@Jirixek Jirixek commented Aug 24, 2026

Copy link
Copy Markdown

What and why

First of all, thank you so much for your work on Nativelink. We’ve been using it in prod for a couple of years now.

We sometimes see that when the filesystem store is under pressure (i.e., near its capacity), errors start to occur which are resolved only by restarting Nativelink. I finally got around to debugging why these errors happen.

The bug

In the logs, I see the following events.

  2026-08-23T12:07:55.390205Z DEBUG nativelink_util::evicting_map: Evicting, key: StoreKeyBorrow(Digest(DigestInfo("fcf3890ac0f5c5724629878618f8cb086771863c96e2194eefaa526d46ae9bff-90")))
    at nativelink-util/src/evicting_map.rs:537
    in nativelink_store::filesystem_store::filesystem_store_emplace_file
    in nativelink_worker::local_worker::worker_start_action
    in nativelink_worker::local_worker::worker_start_action_ctx with operation_id: "e242aff6-db9e-49c1-8d33-27991baea804", digest_function: SHA256
    in nativelink_worker::local_worker::run with shutdown_rx: broadcast::Receiver
    in nativelink::worker with name: "worker_0"

  2026-08-23T12:07:55.650092Z DEBUG nativelink_service::cas_server: return: Response { metadata: MetadataMap { headers: {} }, message: FindMissingBlobsResponse { missing_blob_digests: [..., Digest { hash: "fcf3890ac0f5c5724629878618f8cb086771863c96e2194eefaa526d46ae9bff", size_bytes: 90 }, ...] }, extensions: {} }
    at nativelink-service/src/cas_server.rs:1120
    in nativelink_service::cas_server::find_missing_blobs with request.instance_name: "main", request.digest_function: 0
    in nativelink_util::task::http_executor
    in nativelink::services::http_connection with remote_addr: 127.0.0.1:36026, socket_addr: 127.0.0.1:50051

  2026-08-23T12:07:55.823632Z DEBUG nativelink_service::bytestream_server: return: Ok(Response { metadata: MetadataMap { headers: {} }, message: WriteResponse { committed_size: 90 }, extensions: {} })
    at nativelink-service/src/bytestream_server.rs:858
    in nativelink_service::bytestream_server::inner_write with digest: DigestInfo("fcf3890ac0f5c5724629878618f8cb086771863c96e2194eefaa526d46ae9bff-90"), digest_function: Sha256, stream: WriteRequestStreamWrapper { resource_info: ResourceInfo { instance_name: "main", uuid: Some("212739e7-f262-4f0c-af60-138a6b6b7fa5"), compressor: None, digest_function: None, hash: "fcf3890ac0f5c5724629878618f8cb086771863c96e2194eefaa526d46ae9bff", size: "90", expected_size: 90, optional_metadata: None }, bytes_received: 0, first_msg: Some(WriteRequest { resource_name: "main/uploads/212739e7-f262-4f0c-af60-138a6b6b7fa5/blobs/fcf3890ac0f5c5724629878618f8cb086771863c96e2194eefaa526d46ae9bff/90", write_offset: 0, finish_write: true, .. }), write_finished: false }
    in nativelink_service::bytestream_server::bytestream_write
    in nativelink_service::bytestream_server::write with request: Streaming
    in nativelink_util::task::http_executor
    in nativelink::services::http_connection with remote_addr: 127.0.0.1:36026, socket_addr: 127.0.0.1:50051

  2026-08-23T12:07:55.933864Z DEBUG nativelink_store::filesystem_store: Renamed file (unref), key: Digest(DigestInfo("fcf3890ac0f5c5724629878618f8cb086771863c96e2194eefaa526d46ae9bff-90")), from_path: "/nativelink-server/workdir/data/content_path-cas/d/fcf3890ac0f5c5724629878618f8cb086771863c96e2194eefaa526d46ae9bff-90", to_path: "/nativelink-server/workdir/data/tmp_path-cas/d/fcf3890ac0f5c5724629878618f8cb086771863c96e2194ea805000000000000-90"
    at nativelink-store/src/filesystem_store.rs:720
    in nativelink_store::filesystem_store::filesystem_store_emplace_file
    in nativelink_worker::local_worker::worker_start_action
    in nativelink_worker::local_worker::worker_start_action_ctx with operation_id: "e242aff6-db9e-49c1-8d33-27991baea804", digest_function: SHA256
    in nativelink_worker::local_worker::run with shutdown_rx: broadcast::Receiver
    in nativelink::worker with name: "worker_0"

  2026-08-23T12:07:55.974929Z  WARN nativelink_store::filesystem_store: Filesystem store map/disk divergence: removing entry; reader will fall through to slow store, err: Error { code: NotFound, messages: ["No such file or directory (os error 2)", "Could not open /nativelink-server/workdir/data/content_path-cas/d/fcf3890ac0f5c5724629878618f8cb086771863c96e2194eefaa526d46ae9bff-90", "Failed to open file in filesystem store /nativelink-server/workdir/data/content_path-cas/d/fcf3890ac0f5c5724629878618f8cb086771863c96e2194eefaa526d46ae9bff-90"] }, key: Digest(DigestInfo("fcf3890ac0f5c5724629878618f8cb086771863c96e2194eefaa526d46ae9bff-90"))
    at nativelink-store/src/filesystem_store.rs:2021
    in nativelink_worker::local_worker::worker_start_action
    in nativelink_worker::local_worker::worker_start_action_ctx with operation_id: "0a62aaaa-3f9f-4994-9fa7-3c840072b8b0", digest_function: SHA256
    in nativelink_worker::local_worker::run with shutdown_rx: broadcast::Receiver
    in nativelink::worker with name: "worker_0"

The following happens in code. The eviction map and the file renaming (CAS → tmp dir) are two separate tokio tasks that are not synchronised. I suppose this is to make FS CAS performant. However, because file renaming is a separate task, it can run at any time in the future, i.e., there may be a significant time window between removing an entry from the eviction map and actually renaming a file.

If during this time window a new file is created, i.e., an entry is added and the file is moved from tmp to the CAS dir, the original file (now pending for deletion) in CAS is overwritten by a new file. After this, the cleanup task runs, moving the file out of the CAS dir → tmp dir. This leaves the eviction map with an entry that no longer has an underlying file in the CAS dir.

The proposed solution

I suggest stamping every file with a generation number on upload. A generation can be a current timestamp, but any monotonic u64 would suffice. The generation would be a part of the filename and stored in EncodedFilePath.

This would solve the problem because adding/removing entries to the eviction map and spawning tasks to move files from and to CAS would be completely independent actions. The lifecycle of a file (CREATED → UPLOADED_TO_EVICTION_MAP → REMOVED_FROM_EVICTION_MAP) could be encapsulated in FileEntryImpl. Moreover, I believe we could clean up the code that tries but fails to mitigate this problem, e.g., check_duplicate_files (although I’m not entirely sure whether there are other reasons why check_duplicate_files exists).

I’m willing to make a patch, since it affects our production. Before I make a patch, I'd like to hear your thoughts and confirm I'm not missing anything. I created a test to demonstrate the bug (sorry for the AI comments; I’ll remove them in a final patch).


This change is Reviewable

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nativelink Ready Ready Preview Aug 25, 2026 5:08pm
nativelink-aidm Ready Ready Preview Aug 25, 2026 5:08pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Some of the pull request description still needs filling in:

  • How was this verified? is missing. Please keep the template's headings.
  • Risk is missing. Please keep the template's headings.

Edit the description and this check re-runs on its own. The sections exist because they are the parts a reviewer cannot get from the diff: why the change is needed, how you know it works, and what breaks if it is wrong.

@modernmedici

Copy link
Copy Markdown
Collaborator

Thanks @jiri-szkandera-cdn77 we will review. Also great to hear you are using it in production! We would love to do a case study. Can you email me on sumon at tracemachina dot com to say hi


Ok(())
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a question of how to handle the migration. I've implement it for digests, but I'm not sure what the STR folder is used for exactly, and whether it's okay to consider '-' as a special character. Do you have any preference?

@Jirixek

Jirixek commented Aug 25, 2026

Copy link
Copy Markdown
Author

I've implemented the POC fix, and I'm currently testing it with our builds. So far, it looks like it has solved our problems.

Comment on lines 1730 to 1757
// Sometimes we get files to emplace that are identical to the existing files
// Due to the evict/remove/replace cycle taking some amount of time, we actually
// want to drop these
if check_duplicate_files(&evicting_map, &key, &entry).await? {
return Ok(());
}

evicting_map
.insert(key.borrow().into_owned().into(), entry.clone())
.await;

// The insert might have resulted in an eviction/unref so we need to check
// it still exists in there. But first, get the lock...
let mut encoded_file_path = entry.get_encoded_file_path().write().await;
// Then check it's still in there...
if evicting_map.get(&key).await.is_none() {
info!(%key, "Got eviction while emplacing, dropping");
return Ok(());
}

let final_path = get_file_path_raw(
&PathType::Content,
encoded_file_path.shared_context.as_ref(),
&key,
encoded_file_path.generation,
);

let from_path = encoded_file_path.get_file_path();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part can now be simplified.

@palfrey

palfrey commented Aug 25, 2026

Copy link
Copy Markdown
Member

CLA assistant check Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.You have signed the CLA already but the status is still pending? Let us recheck it.

@Jirixek Thank you for your contribution. This will need signing before we can accept this code.

@Jirixek

Jirixek commented Aug 25, 2026

Copy link
Copy Markdown
Author

CLA assistant check Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.You have signed the CLA already but the status is still pending? Let us recheck it.

@Jirixek Thank you for your contribution. This will need signing before we can accept this code.

@palfrey I've signed the CLA and clicked the recheck buttom about 2-3 times, but the licence_cla job is still pending. Do you know where the problem might be?

@MarcusSorealheis MarcusSorealheis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say this PR fixes a real race, but at the cost of startup time. The directory traversal and metadata work for d/ is approximately doubled on every startup—not only during the first migration. For a cache containing millions of files, that could materially increase restart time and metadata I/O.

The feedback addresses some of the correctness concerns I have about the change. Overall, great contribution and let me know how I can help you get it over the line.

Ok(())
}

async fn move_old_cache_2(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration only handles d/, but previous releases stored string entries as s/ without a generation. Startup subsequently parses every s/ filename with key_and_generation_from_file. Keys without - fail and are deleted, while a legacy key such as foo-1 is silently loaded as key foo, generation 1. String keys are arbitrary and are used by BEP and scheduler storage, so - cannot safely distinguish old and new layouts. Please introduce an unambiguous format/version marker and add upgrade tests covering string keys with no hyphen, hyphens, and numeric suffixes.

let path_root = format!("{}/{folder}", shared_context.content_path);

let mut max_generation = 0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The race being fixed intentionally allows old and new generation files for one key to coexist until stale unref runs. If the process exits during that window, both remain. Here's the flow roughly:

  1. On restart, read_files uses buffer_unordered
  2. Then, this loop inserts them in arbitrary completion order
  3. The second insertion replaces and unrefs the first.

For mutable string keys this can resurrect stale data and delete the newest generation.

Please consider grouping entries by logical key and deterministically retain the authoritative generation, with a restart regression test containing two generations for one string key. If “newest” means publish order, generation also needs to be assigned at publication rather than when the temp file is created.

&PathType::Content,
encoded_file_path.shared_context.as_ref(),
&key,
encoded_file_path.generation,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only checks that some entry exists for the key. A concurrent same-key upload can replace entry before this check, leaving a different Arc in the map. The stale task then publishes its generation-specific content file and marks it Content. If the replacement’s unref observed it while it was still Temp, nobody removes the resulting content file and it escapes eviction accounting indefinitely. Please require Arc::ptr_eq with the current map entry before renaming, leaving stale entries as Temp so drop cleans them up.

let from_file: OsString = format!("{folder_path}/{file_name}").into();
let to_file: OsString = format!("{folder_path}/{file_name}-0").into();

if let Err(err) = rename_fn(&from_file, &to_file) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is an edge case but customers are asking about read-only more and more.

When this rename fails on a read-only content volume, startup only logs a warning. The unchanged legacy filename is then rejected by the generation parser and never added to the map, so an upgraded read-only cache stops serving all legacy blobs. This conflicts with the store’s existing read-only startup behavior. Please support reading legacy paths without mutating them, OR maybe you could provide another migration mechanism and cover a read-only upgrade in tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants