Skip to content

fix(storage): file compare-and-swap no longer deadlocks recover_wal; StorageCas closures are now 'static - #2003

Merged
zonotope merged 11 commits into
mainfrom
fix/cas-deadlock
Oct 5, 2026
Merged

zonotope merged 11 commits into
mainfrom
fix/cas-deadlock

Conversation

@zonotope

Copy link
Copy Markdown
Contributor

Fixes #2001

Summary

This PR fixes two intermittent CI failures in fluree-db-api.

  • it_indexing_workflow::file_based_indexing_then_new_connection_loads_and_queries hangs until nextest kills it at 360 s. The cause is a deadlock in FileStorage between recover_wal and an in-flight compare-and-swap. The fix is in the storage layer.
  • it_cached_handle_cow_cancel::cancelled_commit_never_exposes_the_empty_cache_slot fails at its 60 s deadline. The cause is a race in the test itself. The fix is in the test.

The deadlock

FlureeBuilder::file(..).build() calls FileStorage::recover_wal. recover_wal takes the root gate's write lock with futures::executor::block_on, which blocks the calling thread. The root gate is one tokio::sync::RwLock per storage root, shared by every FileStorage handle in the process.

FileStorage::compare_and_swap holds a read guard on the root gate from its read to its write. It ran the read and the write as two spawn_blocking tasks and ran the closure on the async task between them. The read guard therefore passed through the async task, first in the read's finished JoinHandle output and then in the task's own state. Only a runtime thread can move it on from there.

If recover_wal blocks the only runtime thread while a compare-and-swap is between its read and its write, neither can make progress. On a current-thread runtime this is permanent. The test hits it because it builds a second instance on a root where two indexers and a GC task are publishing through the nameservice, which uses compare-and-swap.

The same deadlock can occur outside tests. Any sync build() of a file-backed instance on a root that another handle in the process is writing to can hit it. On a multi-thread runtime it needs every worker thread blocked at once, so it is much less likely there.

The fix

FileStorage::compare_and_swap now runs the read, the closure, and the write in one spawn_blocking task. The read guard is taken and released on that blocking thread. No async task holds the root gate at any point, so recover_wal only waits for blocking-pool work that can finish on its own. Every other begin_operation call site already ran on a blocking thread.

The blocking task enters the caller's tracing span, so the closure's events and the cas phases event stay under the caller's span.

locked_read_blocking and locked_write_blocking replace the async blocking_locked_read and blocking_locked_write. They are synchronous and must be called off the async runtime. The read takes its key lock through wal::key_lock, which blocking_insert already uses. The comments on this path use the code's names for the root gate, key stripe, and key lock, and the OperationHold and LockedFile fields are renamed to match.

Breaking change

StorageCas::compare_and_swap now requires a 'static closure and result type:

F: Fn(Option<&[u8]>) -> Result<CasAction<T>, StorageExtError> + Send + Sync + 'static,
T: Send + 'static,

This lets an implementation run the closure off the calling task. Callers whose closures borrow local values must move owned values in instead. In most cases this means adding move and cloning any value that is still used after the call. Generic wrappers that forward a closure need the same bounds. EncryptedStorage now moves an Arc of its key provider into its closure.

All implementations and call sites in the workspace are updated. Nothing in the crates excluded from the workspace uses StorageCas.

The cow-cancel test race

The test detected the commit window by polling handle.is_locked() every millisecond. The commit takes the optimistic path, which holds the write lock only for the commit itself. On a busy runner the commit could take and release the lock between two polls. The poll then ran until its 60 s deadline.

The test now wraps MemoryStorage in a CommitGate that parks the next commit-blob write once armed. The commit blob is written inside the commit window, under the write lock, so a parked write means a commit is in flight. The test asserts is_locked() at that point, cancels the caller, and then releases the write.

Testing

  • recover_wal_on_a_runtime_thread_does_not_deadlock_an_in_flight_cas (new, fluree-db-core) blocks a current-thread runtime in recover_wal while a compare-and-swap is between its read and its write. It passes with this change. With the read's guard handed back to the async task, it deadlocks and fails at its 10 s timeout.
  • The rewritten cow-cancel test fails on its intended t = 0 assertion when the commit is awaited inline in place of the shielded task.
  • fluree-db-api: 4536 passed on the final commit, excluding the testcontainers binaries.
  • fluree-db-core storage tests: 133 passed on the final commit.
  • Stress runs, on this branch before the final comment and wal::key_lock cleanup: file_based_indexing_then_new_connection_loads_and_queries passed 150 of 150, and the cow-cancel test passed 200 of 200. Run alone, the indexing test takes about 0.06 s.
  • fluree-db-core, fluree-db-nameservice, and fluree-db-crypto: 1173 passed, before the same cleanup.
  • Clippy with -D warnings on the touched crates, and the wasm32 clippy job, pass.

Not run locally: it_iceberg_direct, it_storage_s3_testcontainers, and it_vended_credentials_testcontainers need Docker. S3Storage only changed its compare_and_swap signature. CI is the first run of those tests against this change.

Related

#1851 reports a hang with the same shape. merge_time_travel_survives_reload_from_commits is a current-thread test that rebuilds a file-backed instance on a root the first instance was writing to. No stack was captured for that hang, so this PR leaves the issue open.

@zonotope
zonotope requested review from aaj3f and bplatz September 30, 2026 18:15
@zonotope zonotope self-assigned this Sep 30, 2026
@zonotope zonotope added bug Something isn't working as expected breaking-change Backwards-incompatible change; drives the Breaking Changes release-notes section labels Sep 30, 2026

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a clean & clever fix @zonotope. No real headline notes from me other than that I still noticed some of the surprisingly slow wall-clock time conditions described in #2001 even from HEAD of this PR branch. More details in the review below. I don't think that's a hold on merging this, but if you're also able to reproduce what I describe in the following review, it might be cause to either open a narrower follow-up issue or to reclarify this as not entirely fixing/closing #2001. I'll leave it up to you.


Running the read, the closure and the write in one blocking task removes the guard hand-off outright instead of making recover_wal cleverer, and moving the comments onto the code's own names (root gate, key stripe, key lock) made the locking much easier to follow. I reproduced the mechanism: with compare_and_swap put back into the two-hop shape (read in one spawn_blocking, guard handed back to the async task, closure, write in a second spawn_blocking), recover_wal_on_a_runtime_thread_does_not_deadlock_an_in_flight_cas fails at its 10 s timeout, and at the head it passes. I also checked the "no async task holds the root gate" claim against every begin_operation caller — delete_key_blocking (called only inside spawn_blocking at file.rs:1584 and :1602), the write path at :1718, blocking_insert at :1789, and locked_read_blocking at :1842 — and all of them now run on blocking threads. The other StorageCas impls don't need the same treatment: MemoryStorage does read, closure and write under one sync lock with no await, S3Storage has no root gate (conditional PUT plus retry), and EncryptedStorage only delegates. That's also why fluree-db-crypto is in the diff, since a 'static closure can't borrow self; I ran a quick encrypted compare-and-swap round trip over MemoryStorage (the closure sees plaintext, storage holds ciphertext) and it passes at the head.

A few small notes inline, all optional. Two more that have no line to sit on:

  • This is more of a question than a suggestion. The PR title feeds the release notes, and with the breaking-change label "Fix CAS Deadlock" will be the entry under Breaking Changes. Something that names the API change would help whoever upgrades, e.g. "fix(storage): file compare-and-swap no longer deadlocks recover_wal; StorageCas closures are now 'static".
  • One thing I noticed while reproducing: the 28–37 s passing runs in #2001 may be a different cost. With the two-hop shape restored, file_based_indexing_then_new_connection_loads_and_queries still passes alone in 0.23–0.30 s (5 of 5) with a fresh TMPDIR. On my machine at this head it once took 188 s, and sample put all of that in DiskArtifactCache::ensure_capacity → scan_cache_entries, walking a $TMPDIR/fluree_binary_cache that every test binary shares (about 400k entries and 4.1 GB here), not in recover_wal. I can't tell what your environment looked like, so this is a hunch rather than a finding — but since this PR closes #2001, it may be worth a line on the issue so the slow-run half doesn't lose its tracking.

Adherence to repo commitments:

  • Patterns/abstractions: ✔ reuses wal::key_lock / wal::read_opt and the one-blocking-hop idiom every other begin_operation site already uses; no new construct.
  • Performance (speed first, memory second): ✔ the commit-path compare-and-swap now pays one blocking hop instead of two and clones FileStorage once instead of twice; no new lock; the closure's serde work moves off the async worker; and d8521bddf drops a create_dir_all from every insert. No performance-degradation risk.
  • Deployment targets: ✔ FileStorage and wal are cfg(all(feature = "native", not(target_arch = "wasm32"))), so wasm32 only sees the trait signature — I ran the CI wasm32 clippy command locally and it's clean, as are CI's wasm32 and wasm-smoke. Solo's standalone fluree-ai builds FlureeBuilder::file, and its integration tests build a writer and a reader on one path in one process, which is exactly this shape, so the fix reaches it at the next pin bump; solo neither calls nor implements StorageCas, so the breaking change needs no solo code change. The Lambda hosts use S3, which only sees the signature. A blocking task now finishes its compare-and-swap even if the caller is cancelled, which is fine on the long-lived hosts that use FileStorage.
  • Testing: ✔ the new unit test is wired into fluree-db-core's lib tests and goes red on the old shape; key_lock_recreates_a_removed_parent_directory goes red without the NotFound retry; CI ran all four new or changed tests by name at a3b74997a.
  • Conventions: ⚠️ the five commits are subject-only, though the PR body carries the full rationale; fmt and clippy are clean; docs/design/storage-traits.md lags the new bounds (inline).

Verified locally at a3b74997a: fluree-db-core (942), fluree-db-nameservice (169) and fluree-db-crypto --features nameservice (28) lib tests green; grp_transact cow-cancel and grp_index file-indexing tests green; two-hop and no-retry mutations each turned their test red and were restored; wasm32 clippy (-D warnings) clean.

Approving so you can merge when ready, but maybe worth considering the inline notes first — they're minor, but if you agree they're right, I'd rather see them folded in now than lost in the backlog.

Comment thread fluree-db-core/src/storage/file.rs Outdated
// Yield so the compare-and-swap task starts its blocking task.
// Then block the runtime thread until the read has finished.
tokio::task::yield_now().await;
std::thread::sleep(Duration::from_millis(200));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Optional — this sleep makes the regression check timing-dependent.

The test relies on the blocking read finishing inside these 200 ms. When it doesn't (a busy runner), recover_wal takes the root gate before the compare-and-swap does, and the old two-hop shape passes too — so the test can go green on exactly the regression it guards against. And when the read does finish in time, the whole compare-and-swap has usually finished as well, so on the fixed path recover_wal rarely ends up waiting on an in-flight CAS at all.

A deterministic variant: signal from inside the closure, where the root gate is held, and block the runtime thread on that signal instead of sleeping. On the fixed shape the closure runs on the blocking thread, so the signal arrives and recover_wal waits on a CAS that really is in flight; on the old shape the closure needs the blocked runtime thread, so the recv_timeout fails every time. I ran this variant next to yours: green 3 of 3 at the head, red on the two-hop shape (the CAS closure needed the blocked runtime thread: Timeout). Illustrative, replacing the tokio::spawn through the sleep:

let (in_f_tx, in_f_rx) = std::sync::mpsc::channel::<()>();
let cas = tokio::spawn({
    let storage = storage.clone();
    async move {
        storage
            .compare_and_swap(HEAD, move |_| {
                // The root gate is held here.
                let _ = in_f_tx.send(());
                std::thread::sleep(Duration::from_millis(100));
                Ok(CasAction::<()>::Write(b"x".to_vec()))
            })
            .await
    }
});
tokio::task::yield_now().await;
// Block the runtime thread until the closure runs, so `recover_wal` meets a CAS in flight.
in_f_rx
    .recv_timeout(Duration::from_secs(5))
    .expect("the CAS closure needed the blocked runtime thread");
FileStorage::new(dir.path()).recover_wal().unwrap();
cas.await.unwrap().unwrap();

This is minor and non-blocking — but if you agree it's right, I'd rather see it folded in now than lost in the backlog.

F: Fn(Option<&[u8]>) -> std::result::Result<CasAction<T>, StorageExtError>
+ Send
+ Sync
+ 'static,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Optional — docs/design/storage-traits.md:322-333 still shows the old bounds.

That mdBook page (in docs/SUMMARY.md:113) restates StorageCas with F: Fn(..) + Send + Sync and T: Send. Since this PR carries the breaking-change label and that page is where someone implementing the trait looks first, it seems worth having the snippet carry + 'static along with the one-sentence reason from the doc comment here. It's a two-line change, so I'd rather see it folded in here than tracked separately.

Commenting here because docs/design/storage-traits.md is not in this diff.

// Lock released when `locked` is dropped (on Abort path, dropped here)
})
.await
.unwrap_or_else(|e| match e.try_into_panic() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Optional — the panic-resume arm from d629fe182 has no test.

If this ever regresses into the Err arm, a panic in a nameservice closure comes back as StorageExtError::io("spawn_blocking join: …"), which a caller can't tell apart from an I/O failure. I wrote a quick #[should_panic(expected = "…")] test that panics inside the closure, and it passes at the head; a second one confirmed the key lock is released afterwards (the next compare-and-swap on the same key writes inside 5 s). Either would pin this in a few lines. Minor and non-blocking — but if you agree, I'd rather it land with this PR than end up in the backlog.

@zonotope zonotope changed the title Fix CAS Deadlock fix(storage): file compare-and-swap no longer deadlocks recover_wal; StorageCas closures are now 'static Oct 5, 2026
@zonotope
zonotope merged commit ac26b46 into main Oct 5, 2026
16 checks passed
@zonotope
zonotope deleted the fix/cas-deadlock branch October 5, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change Backwards-incompatible change; drives the Breaking Changes release-notes section bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Second file-backed Fluree on a live root can block forever in recover_wal (grp_index test hang)

2 participants