fix: pre-allocate on Darwin to prevent APFS sparse-hole corruption#326
fix: pre-allocate on Darwin to prevent APFS sparse-hole corruption#326nsakaimbo wants to merge 1 commit into
Conversation
130fe81 to
e09f7d7
Compare
|
Thank you. Good catch. Though can you check if the 2nd change is actually necessary. That's quite the perf impact. if !s.canReflink {
if isBlank {
return 0, 0, nil
}This suggests that truncating an empty file does not end up with an empty file. You can try it with the new test, it still passes without that change. |
|
Interesting, the new test doesn't fail with the old code, at least on Linux. Would be nice to figure out what the actual issue is first. |
69126e3 to
6f4dae9
Compare
Replace os.Truncate with preallocateFile in AssembleFile. On Darwin/APFS, uses fcntl(F_PREALLOCATE) to physically allocate blocks before truncating, preventing sparse-hole corruption during concurrent assembly. On other platforms, delegates to os.Truncate (no behavior change). Remove the 100-chunk limit in nullChunkSeed.LongestMatchWith — unnecessary since WriteInto returns immediately when isBlank=true.
6f4dae9 to
a46c5b4
Compare
|
@nsakaimbo I opened nsakaimbo#1 against your PR with what I think the fixes should look like. Please take a look and merge into yours if everything looks ok. |
|
I spent some time trying to get to the root cause of this before merging a fix, since the corruption mechanism was never really established. Short version: I was unable to reproduce it, despite trying under conditions considerably more hostile than the original report, and a code audit found no desync-side mechanism that could explain the symptom. I'm going to merge #361 anyway — the preallocation is cheap (your own numbers showed no assembly-time cost), and it removes the dependency on sparse-hole behavior entirely, so it works around the issue regardless of where it lives. What was tried, on an Apple Silicon machine running macOS 26.5.2 / Darwin 25.5.0:
So if this is real — and the non-deterministic, silent, zeros-for-data symptom does look like lost writes — it's most likely specific to your environment: macOS build, M1-generation hardware, the target volume, or genuine ≥350 GB scale, none of which I could replicate (the test machine caps out around 110 GiB usable). If you're still able to observe this, a few things would help narrow it down:
Merging #361 now. Thanks again for the report and the detailed measurements. |
…es (#361) AssembleFile truncated the output file to its target size, which on APFS creates a sparse file. Concurrent WriteAt calls on adjacent regions of large sparse files have shown non-deterministic corruption there, with null-chunk regions occasionally reading back wrong (#326). Replace the plain truncate with preallocateFile: on Darwin it uses fcntl(F_PREALLOCATE) to physically allocate blocks before truncating. Since F_PREALLOCATE with F_PEOFPOSMODE allocates relative to the current end of file, only the missing difference is requested, keeping in-place reassembly of existing files from over-allocating. Filesystems without F_PREALLOCATE support (SMB, FUSE) fall back to the previous plain-truncate behavior. All other platforms delegate to Truncate as before.
Summary
Fix assembly correctness on macOS/APFS when files contain large zero-filled regions (e.g., VM disk images). Two changes, no public API modifications:
os.TruncatewithpreallocateFileinAssembleFile— on Darwin, usesfcntl(F_PREALLOCATE)to physically allocate blocks; on other platforms, delegates toos.Truncate(no behavior change)nullChunkSeed.LongestMatchWith(performance optimization, unnecessary sinceWriteIntoreturns immediately whenisBlank=true)Problem
On macOS/APFS,
os.Truncatecreates sparse files — zero regions are not physically allocated. WhenAssembleFile's concurrent workers callWriteAton adjacent regions of a sparse file, APFS's copy-on-write handling causes non-deterministic data corruption. The assembled file has the correct size but different content each run.This only affects macOS/APFS. On Linux (ext4),
Truncateproduces real zeros and the existing code works correctly.Root Cause
Discovered while testing CDC delta downloads for ~350 GB VM disk images on Mac Mini M1 hosts (APFS):
cmpagainst the source found the first divergence at a chunk boundary where the assembled file had zeros instead of dataisBlank=false(read-back verification on every chunk) produced correct output, confirming the issue is in theisBlank=truepath interacting with APFS sparse holesfcntl(F_PREALLOCATE)before truncation physically allocates blocks, eliminating sparse holes —isBlank=truethen works correctly at full speedThe bug only manifests with very large files (~350 GB with ~44 GB of zero regions). Small test files don't reproduce it.
Fix
preallocateFile(preallocate_darwin.go): Callsfcntl(F_PREALLOCATE)+ftruncateto allocate real blocks. TheisBlankskip in the null seed'sWriteIntoremains correct because the pre-allocated blocks already contain zeros.preallocateFile(preallocate_other.go): Delegates toos.Truncate— no behavior change on Linux or other platforms.Performance
Tested with a 325 GB VM disk image (46,638 chunks, 2,853 null chunks, max run of 1,942 consecutive) on Mac Mini M1:
isBlank=falseNo performance impact on Linux.