Conversation
Newly written files get a local staging file (tmpfs/ext4) the kernel reads/ writes directly via FUSE_PASSTHROUGH, bypassing the daemon per-op; reconciled into JuiceFS slices on release. Opt-in via JUICEFS_PASSTHROUGH=1 + JUICEFS_PASSTHROUGH_DIR (must be a non-stacked fs). Results (4K randwrite, local file backend): buffered ~780 -> ~1.88M IOPS, durable ~270 -> ~152k IOPS. Reconcile reads the staging file back into slices (per-chunk buffer copy; reopen-by-path), then invalidates the kernel attr/data cache (InodeNotify) so same-session reads see the committed file. Verified correct read-your-writes for 1/17/64/200/333 MB. The cache fix adds no throughput cost (only 2 notify calls at close). Requires the passthrough-enabled go-fuse fork (local replace). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Haytham Abuelfutuh <haytham@union.ai>
EngHabu
force-pushed
the
haytham/fuse-passthrough
branch
from
June 24, 2026 17:46
155bf7e to
5e0a1bd
Compare
EngHabu
marked this pull request as ready for review
June 24, 2026 19:56
Author
|
Depends on juicedata/go-fuse#53, which adds the FUSE passthrough primitives ( |
Author
|
@jiefenghuang please take a look. |
Contributor
Thank you very much for your contribution. We’ll be handling the 1.4 release this week, I’ll review this a little later. |
Open() consulted pt.tryOpen() before the IsSpecialNode() branch, so a write-open of a JuiceFS internal inode (.control, .stats, .config, ...) could be granted FOPEN_PASSTHROUGH and served by a kernel backing file. The .control protocol then broke silently: the checkpoint verb's write/read on .control went to a plain temp file instead of the daemon's in-process handler, so the op never dispatched and 'juicefs checkpoint' hung indefinitely (with passthrough enabled). Guard tryOpen to refuse special nodes. Verified before/after with passthrough genuinely engaged: no-fix checkpoint hangs (.control opened special=true and passthrough'd); with the guard .control is skipped, data files still use passthrough, and checkpoint completes with a valid snapshot. Regression test in passthrough_special_test.go. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxvU7KdSpd1oRRmAgWTA6Z
Registering a backing fd costs an ioctl — or, on unprivileged broker mounts, an RPC round trip to the node broker — plus a staging-file create, once per write-open. Small-file workloads open thousands of times and pay it every time (measured 2x slower small-file creates over the broker on EKS, ENG26-869). Keep reconciled staging files registered: truncate to zero and park them (LIFO, capped at 64 per mount) for the next write-open. After warm-up a small-file loop performs no registrations, no unregistrations and no file creates. A backing is never attached to two live opens: checkout is exclusive and a file is only parked after its reconcile copied the data out and truncated it. Failed reconciles retire the backing instead of recycling it; stale staging files from a crashed process are swept at mount. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWLe95UTvfBpTXptpBXLrB
The backing-registration ioctl needs CAP_SYS_ADMIN in the init user namespace. A non-root mount whose container merely *adds* the capability (bounding set, no ambient) fails EPERM on every attempt — measured 2000+ doomed ioctl+create+warn cycles in one small-file run on EKS (ENG26-869). EPERM is permanent for the process, so latch passthrough off for the mount on first sight; other errnos stay retryable. The warning now says what to do about it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWLe95UTvfBpTXptpBXLrB
close(2) returns before the RELEASE-time reconcile copies the staging file into JuiceFS slices, so a checkpoint/commit issued right after a close could FlushAll+snapshot mid-copy and publish a short file (reproduced: 256MB write + immediate checkpoint -> 44MB in snapshot; also seen on EKS as a finalize missing exactly the last written file). Add an external-flush counter to VFS that reconcile brackets; the checkpoint verb (stacked branch) waits for it to reach zero before flushing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWLe95UTvfBpTXptpBXLrB
Two more legs of the reconcile fence (the checkpoint verb was fenced in 9a50674, but finalize goes through unmount, not the verb): - fuse.Serve waits out in-flight reconciles after the kernel session ends, so a daemon exit (umount or SIGTERM->lazy umount) can't strand a closed file's staging data. Reproduced on EKS: finalize published volumes short by exactly the last written 256MB file, both privileged and brokered. - Export the in-flight count as gauge 'passthrough_staging_blocks'. The name contains 'staging_blocks' on purpose: durability watchers that sum staging gauges from .stats before sealing a snapshot (the flyteplugins drain-before-commit path) then wait for reconciles with no client change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWLe95UTvfBpTXptpBXLrB
Four independent-review CRITICALs on the write-passthrough path: 1. Existing-file corruption: tryOpen gated only on write-intent, so an O_RDWR/O_WRONLY/O_APPEND open of a NON-empty file got a backing that starts empty — reads served as zeros (read-modify-write corruption) and reconcile (linear copy from offset 0) overwrote the real prefix with holes. Gate on emptyAtOpen: Create, O_TRUNC, or zero length. 2. Cross-mount corruption: staging lived in a host-shared dir with a startup glob-sweep and per-process pool-N names — a second mount's sweep unlinked the first's live staging, and pool-N collided across processes. Isolate each mount in a private os.MkdirTemp subdir; drop the shared-root sweep; O_EXCL so a stale name can't be truncated. 3. Read-error-as-EOF: a mid-copy read error broke the loop like io.EOF and committed a truncated file as complete. Distinguish io.EOF; bail loudly otherwise. 4. Reconcile failure destroyed the only copy: the retire path os.Remove'd the staging file holding data whose close(2) already returned. Rename it to an .orphan sibling and log at error instead. Also scope the consistency-point fence past the pf==nil check so plain (non-passthrough) releases don't add spurious checkpoint contention. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWLe95UTvfBpTXptpBXLrB
…race) An immediate reopen-for-write of a file just written via passthrough sees metadata size 0 (its reconcile is still in flight), so emptyAtOpen looks true again and a second empty backing would clobber the first's data on reconcile. Reserve the inode across open+reconcile; overlapping write opens fall to the daemon path. Also covers two concurrent write-opens of one empty inode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWLe95UTvfBpTXptpBXLrB
EngHabu
force-pushed
the
haytham/fuse-passthrough
branch
from
July 8, 2026 02:16
c6b5208 to
d378c47
Compare
A reopen of a file just written via passthrough could race the async reconcile: until it lands, the size reads 0, so a new write (daemon or passthrough) interleaves with the reconcile's linear copy and is lost. Block Open (bounded 30s) until the inode's reconcile finishes, so the reopen sees authoritative size/content. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWLe95UTvfBpTXptpBXLrB
EngHabu
force-pushed
the
haytham/fuse-passthrough
branch
from
July 8, 2026 02:19
6800364 to
b5f97b9
Compare
Four fixes for the deferred HIGH/MEDIUM passthrough hardening items: - SIGTERM force-exit: the 30s force-exit path now extends its deadline (10 min hard cap) while passthrough reconciles are in flight, so FlushAll can no longer commit a mid-copy prefix as a durable short file when umount is stuck. - fsync/fdatasync honesty: FSYNC on a passthrough fh used to return success while every byte sat only in the local staging file. It now copies the staging content into JuiceFS slices (open stays live, backing stays registered; release reconcile remains the authority) and flushes the writer, matching non-passthrough fsync durability. Copies are serialized per-open (ptFile.mu) and fenced with Begin/EndExternalFlush so checkpoints wait them out. - SIGHUP smooth upgrade: a handover while passthrough opens are live would strand their staging data (the successor has no record of them). The SIGHUP handler now drains — blocking new passthrough opens, bounded 10s — and refuses the restart if opens remain, re-enabling passthrough. - truncate divergence: SETATTR size changes never reach the backing file (the kernel only diverts read/write/mmap); mirror successful size changes onto the live backing so reads and the release-time reconcile don't undo the truncate. Also drops the obsolete google.golang.org/grpc/stats/opentelemetry carve-out requirement (package merged into grpc; ambiguous import). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M2cGhQ5cyh5t9u7DrMv2y
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M2cGhQ5cyh5t9u7DrMv2y
Cross-inode reuse (register-once, checkin/checkout pool) was measurably cheaper for small-file workloads, but is unsafe: once mmap(2) is called on a passthrough fd, fuse_passthrough_mmap() repoints the VMA directly at the backing file (vma_set_file), decoupling the mapping from the fd. close(2) can still trigger reconcile+recycle while a dirty mapping keeps writing to what is now, after recycling, a different inode's staging file — silent cross-tenant data corruption, with no FUSE opcode able to observe mmap activity on a passthrough-diverted file to guard against it. Remove the pool entirely: checkout() always registers a fresh backing, and reconcile() always retires it (unregister + close + remove) instead of truncating and parking it for reuse. This also removes the truncate/checkin race window between a completing reconcile and a new open landing on the same backing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0127fkXsGz1PMmfkFKERDmtM
…budget copyStagingLocked's 4MB read buffer was a raw make(), invisible to utils.AllocMemory() — the counter vfs/writer.go and vfs/compact.go use as backpressure (utils.AllocMemory()-store.UsedMemory() vs BufferSize). Concurrent reconciles could inflate RSS well past the configured budget without the writer/compactor ever seeing it and throttling. Use the pooled utils.Alloc/Free instead, same as chunk/page.go already does for its buffers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0127fkXsGz1PMmfkFKERDmtM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Write-path FUSE passthrough (kernel ≥ 6.9) for JuiceFS. When a file is opened for write, JuiceFS hands the kernel a local staging file as a passthrough backing file (
FOPEN_PASSTHROUGH+OpenOut.BackingID). The kernel then serves that file'sread()/write()directly against the staging file, with no per-op upcall to the JuiceFS daemon. On release, the staging file is reconciled into normal JuiceFS slices via the existing writer path, and the kernel's attr/data cache is invalidated so readers see the committed file.This removes the dominant cost of small random writes over FUSE — the kernel↔daemon round trip per operation.
How it works
‹staging-dir›/‹ino›-‹fh›.tmp, register it withServer.RegisterBackingFd, returnFOPEN_PASSTHROUGH+BackingID. Reads/writes now bypass the daemon.vfs.Write→vfs.Flush(normal slice upload + metadata commit),UnregisterBackingFd, delete the staging file, thenInodeNotifyto refresh the kernel cache.Opt-in (prototype):
JUICEFS_PASSTHROUGH=1andJUICEFS_PASSTHROUGH_DIR=‹dir›. The staging dir must be on a non-stacked filesystem (tmpfs/ext4/xfs) — an overlayfs/fuse backing returnsELOOPfrom the kernel'smax_stack_depthcheck. On a real node the JuiceFS cache dir (node disk) satisfies this.Dependency
Requires FUSE passthrough support in the go-fuse binding, which
release-2.5doesn't have yet — see juicedata/go-fuse#53. This PR currently wires it via areplaceto that branch; it would point at the merged go-fuse commit once #53 lands.Benchmarks (4 KiB random write, local
file://backend, kernel 6.12)fdatasync)The per-op FUSE daemon tax is gone — writes hit the local staging file at native speed. (Benchmark staged on tmpfs, so the durable figure is RAM-fsync-bound; on node NVMe expect lower but still ~10–50k, i.e. well above the ~270 vanilla floor.) Reads and metadata ops are unchanged — this is purely a write-path optimization.
Correctness
InodeNotifycache-invalidation fix.What's changed
pkg/fuse/passthrough.go(new) — staging-file lifecycle + reconcile.pkg/fuse/fuse.go— Open/Create inject passthrough; Release reconciles; Serve negotiates the capability.pkg/vfs/vfs.go— mirror the two newMountOptionsfields intoFuseOptions.go.mod—replacego-fuse with the passthrough-enabled fork.Remaining WIP (before this is merge-ready)
getattrreturns a stale size while a passthrough file is open and being written (daemon never sees the writes); report the backing file's size.stat'd microseconds afterclose()(async FUSE notify); closeable by disabling attr-cache for passthrough files.--passthroughmount flag; add a regression test perAGENTS.md.