Skip to content

Coalesce FilesystemStore durability flushes on macOS#2539

Open
erneestoc wants to merge 1 commit into
TraceMachina:mainfrom
erneestoc:ec/fs-flush-coalescing
Open

Coalesce FilesystemStore durability flushes on macOS#2539
erneestoc wants to merge 1 commit into
TraceMachina:mainfrom
erneestoc:ec/fs-flush-coalescing

Conversation

@erneestoc

@erneestoc erneestoc commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Coalesce FilesystemStore durability flushes on macOS (userspace group commit)

Problem

Every FilesystemStore upload makes its blob durable with File::sync_all
before publishing it into the content directory. On macOS, sync_all issues
fcntl(F_FULLFSYNC) — a full device-cache flush — once per blob. The flush
serializes at the device and costs multiple milliseconds, so workloads that
upload thousands of small blobs (a worker's fast tier materializing a
many-tiny-file action input tree) spend nearly all of their time in flushes:
a 4,400-file / 2 MB input tree takes 14.5 s cold (21.0 s under contention),
of which ~12 s is F_FULLFSYNC, while tar -x of the same tree takes ~1.4 s.

Linux does not suffer from this because concurrent fsync calls coalesce
inside the filesystem journal's group commit. macOS has no journal-level
coalescing and no syncfs(2), and F_BARRIERFSYNC measures just as slow
per-file here (~15.6 s).

Change: group commit in userspace (macOS only, no config)

F_FULLFSYNC has an exploitable asymmetry: writing one file's pages to the
storage device is per-file work (plain fsync(2) — measured effectively
free), but the expensive device-cache drain is device-wide by nature. So a
FlushCoalescer inside FilesystemStore applies the ext4 trick one layer
up:

  1. Each writer pushes its own blob to the device with fsync(2) (cheap).
  2. It then joins the current commit round. One round leader issues a
    single F_FULLFSYNC (on a dedicated sentinel file in the content root)
    that makes every previously-fsynced blob durable at once.
  3. Rounds self-batch exactly like a journal: while one flush runs, later
    writers accumulate into the next round. A ~3 ms accumulation window
    (about the cost of one flush) fattens rounds further.
  4. Only after the covering flush completes does a writer rename its blob
    into the content directory.

Durability is unchanged: a blob is only published after a device-cache
flush that started after its own data reached the device — the same
guarantee as per-file F_FULLFSYNC, at ~1/10th the flush count. A solo
(unbatched) write pays one flush plus the 3 ms window, roughly what it pays
today. Linux and Windows keep plain sync_all (their journals already
group-commit; behavior byte-identical).

Measurements

Real-stack benchmark (DirectoryCache → FastSlowStore(FilesystemStore fast) →
GrpcStore → in-process ByteStream/CAS servers over TCP), 4,400-file / ~450 B
tree, Apple Silicon / APFS, output trees byte-verified every run:

configuration alone contended (5×800-file neighbors)
per-file F_FULLFSYNC (today) 14.5 s 21.0 s
coalesced (this PR) ~3.0 s ~5.6 s
no flush at all (floor) 2.2 s 4.1 s
tar -x reference ~1.3–1.6 s

4.8× / 3.7× with full durability, within ~35% of the physically possible
no-flush floor (contended runs ranged 4.9–6.3 s across iterations).

Relationship to a possible sync_policy knob

A companion branch (not yet a PR) adds FilesystemSpec.sync_policy
("full" | "data" | "fsync" | "none", default "full") for deployments that
want to trade durability for the last ~25% on re-fetchable cache tiers.
The two compose — this coalescer would become the implementation of full
on macOS — and neither depends on the other; happy to open it as a
follow-up if there's interest.

Tests

Post-review hardening (multi-angle review applied)

  • Sentinel moved to a SIBLING of the content path ({content_path}.flush_sentinel,
    the .exec-directory precedent): the startup scan's legacy root sweep
    would otherwise migrate and delete a sentinel inside the content root on
    every restart.
  • Sentinel creation is non-fatal and off the async thread: a read-only
    content volume degrades to per-file sync_all instead of failing store
    construction (macOS-only regression otherwise).
  • The blocking fsync task owns a dup'd fd (try_clone), so caller
    cancellation can never race a reused descriptor. It deliberately does
    NOT take an open-file permit: the caller's FileSlot already holds one,
    and requiring a second deadlocks under a drained semaphore (caught by
    the Deadlock in NativeLink Worker due to File Permit Exhaustion #2051 regression test, which pins the semaphore at one permit).
    Concurrency is still bounded by the callers' own permits.
  • EINTR retried exactly as std's sync_all (cvt_r) does, for both
    fsync(2) and F_FULLFSYNC.
  • A send-on-drop guard makes a cancelled flusher round error its waiters
    instead of stranding them; the sentinel dirty-write is best-effort so an
    ENOSPC on the 8-byte write cannot fail a whole round.
  • The 3 ms accumulation window only applies to rounds with >1 waiter — a
    solo write on an idle store pays roughly today's latency.
  • Leader-election was replaced by a single long-lived flusher task per
    store (simpler audit surface; serialization is inherent). Sharing one
    flusher per device (st_dev) would amortize across stores on one
    volume but requires a runtime-agnostic flusher (dedicated OS thread) —
    multiple tokio runtimes per process (tests) break a task-based global —
    left as a documented follow-up.
  • The executable-variant copy path drops its flush entirely: the .exec
    directory is cleared on every startup, so durability there bought
    nothing while costing one F_FULLFSYNC per executable digest.

Notes for reviewers

  • The sentinel file lives at {content_path}.flush_sentinel (a sibling of
    the content root), and is re-dirtied (best-effort) each round so the
    flush is never elided.
  • The flusher is one detached long-lived task per store, woken by a
    Notify; writers notify strictly after subscribing to their round, so
    lost wakeups are impossible, and FlushCoalescer::drop wakes the task
    so it exits instead of parking forever.

This change is Reviewable

Every FilesystemStore upload makes its blob durable with File::sync_all
before publishing it, which on macOS issues fcntl(F_FULLFSYNC) — a full
device-cache flush — once per blob. The flush serializes at the device
and costs multiple milliseconds, so many-small-blob workloads spend
nearly all their time flushing: a 4,400-file / 2 MB action input tree
takes 14.5s cold (21.0s under contention), ~12s of which is F_FULLFSYNC,
while tar -x of the same tree takes ~1.4s. Linux does not suffer from
this because concurrent fsyncs coalesce in the filesystem journal's
group commit; macOS has no journal-level coalescing and no syncfs(2),
and F_BARRIERFSYNC measures just as slow per file (~15.6s).

Apply the same group-commit idea in userspace, macOS-only, no config:
F_FULLFSYNC's per-file part (pushing the file's pages to the device) is
cheap plain fsync(2); the expensive device-cache drain is device-wide by
nature. Each writer fsync(2)s its own blob (bounded by the crate's
open-file permit machinery), then joins the current commit round; a
single long-lived flusher task issues one F_FULLFSYNC per round on a
dedicated sentinel file (a sibling of the content path, invisible to the
startup scan) covering every previously-fsynced blob at once. Rounds
self-batch like a journal — while one flush runs, later writers
accumulate into the next round — plus a ~3ms accumulation window applied
only when a round has multiple waiters, so a solo write pays about what
it pays today.

Durability is unchanged: a blob is only renamed into the content
directory after a device-cache flush that started after its own data
reached the device — the same guarantee as per-file F_FULLFSYNC. EINTR
is retried exactly as std's sync_all does; the blocking fsync task owns
a dup'd fd so caller cancellation can never touch a reused descriptor;
a cancelled flusher round reports an error to its waiters rather than
stranding them; and a store whose sentinel cannot be created (read-only
volume) degrades to per-file sync_all instead of failing construction.
The executable-variant copy path drops its flush entirely: the .exec
directory is cleared on every startup, so those files are never trusted
across a crash. Linux and Windows keep plain sync_all, byte-identical
behavior.

Measured on the benchmark above (output trees byte-verified every run):
14.5s -> ~3.0s alone and 21.0s -> ~5.6s contended (4.8x / 3.7x), within
~35% of the no-flush floor (2.2s / 4.1s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXVtatcR9YMecBiu9RjwpC
@vercel

vercel Bot commented Jul 9, 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, Comment Jul 9, 2026 4:58pm
nativelink-aidm Ready Ready Preview, Comment Jul 9, 2026 4:58pm

Request Review

// startup (see `new_with_timeout_and_rename_fn`), so a
// variant is never trusted across a crash — durability
// would buy nothing, and on macOS `sync_all` is a
// multi-ms full device-cache flush per executable.

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.

Shouldn't this still get done on non-Mac unix systems?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think it buys anything on any platform: the .exec variant directory is unconditionally wiped on every startup (drop(fs::remove_dir_all(&executable_dir).await) in new_with_timeout_and_rename_fn, filesystem_store.rs:893), so a variant can never survive a crash to be trusted afterwards — the flush was guarding a file that's deleted before it could ever be read again. Process crashes don't need it either (page cache survives the process), and the ETXTBSY protocol only requires the writer fd to be closed before the rename, not flushed.

The fsync was only load-bearing if the variants were meant to persist across restarts — and they deliberately aren't (they're regenerable, and the startup clear exists so a stale variant can't leak across runs).

That said, it's cheap on Linux — if you'd prefer belt-and-suspenders on non-macOS I'm happy to restore it behind #[cfg(not(target_os = "macos"))]. Just flagging that it would be dead code by the above reasoning rather than a platform behavior difference.

@erneestoc erneestoc requested a review from palfrey July 10, 2026 17:32
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.

2 participants