Upstream: tidwall/wal#43, following up on #21 and PR #22
Repo: tidwall/wal (tested on v1.2.1, default options, NoSync: false)
Status upstream: the first half is known: issue #21, open since June 2023, and PR #22, unmerged. The second and third parts below are not reported.
1. A crash during one append bricks the log (known: #21)
With NoSync: false, every acknowledged write is fsynced, so a crash can only tear the entry that was being written and hadn't been acknowledged. On restart, load() parses the tail segment with loadSegmentEntries (wal.go:293). loadNextBinaryEntry (wal.go:599-606) returns ErrCorrupt for the partial entry, and Open fails. Every later Open fails the same way until someone edits the file by hand.
seedfall finds this on seed 1 and shrinks it to one fault:
cd examples/wals
seedfall replay -c tidwall.json -seed 1
366ms crash server during disk op #56
server#2| open wal: log corrupt
FAIL server#2 crashed
2. PR #22 recovers in memory but never truncates the file (new)
PR #22 adds RecoverCorruptedTail. When it's set, loadSegmentEntries stops at the first corrupt entry and trims s.ebuf to the valid prefix. load() then seeks the write offset back to len(ebuf), but it doesn't truncate the file.
If the next append is shorter than the torn bytes, the end of the old partial entry stays in the file after the new entry. As long as the segment is the tail, recovery skips those bytes again. Once the segment rotates, it is loaded with loadSegmentEntries(s, false), and Read of any index in that segment returns ErrCorrupt. One recoverable crash becomes permanent corruption in the middle of the log.
This was reproduced with PR #22 applied and RecoverCorruptedTail: true, on seed 117 (4 faults after shrinking). A diagnostic listing of segment 00000000000000000104 showed the following:
server#4 start: size=583 entries=6 valid=509 tail="me131-1111…" (74-byte torn entry)
server#5 start: size=583 entries=7 valid=552 tail="1111111…" (new 43-byte entry written at 509, 31 stale bytes left)
server#5| read wal: log corrupt
Fix: truncate the file to len(lseg.ebuf) and fsync it before seeking.
3. cycle() never fsyncs the directory (new; strict POSIX only)
cycle() (wal.go:355-376) creates the next segment with O_CREATE|O_TRUNC. It fsyncs the previous segment and, on later writes, the new one, but it never fsyncs the log directory. POSIX only makes the new file's name durable once the directory is fsynced.
Under seedfall's strict_dirs model, a crash after a rotation can lose the whole new segment, even though every entry in it was fsynced and acknowledged. Seed 2 shows this: after a single crash, segment …041 is missing, and acknowledged entries 41 onward are gone. The server had already fsynced every ancestor directory at startup, so the only gap left is inside the library.
On ext4, XFS and btrfs, fsyncing the file usually persists its directory entry too, so typical Linux deployments are mostly not exposed. This is the same caveat as the hashicorp/raft snapshot finding.
Verified fix
These are applied to a local copy in .prof/tidwall-wal; the full diff is in .prof/tidwall-full-fix.diff:
- PR #22, with
RecoverCorruptedTail: truein the example server - in
load():l.sfile.Truncate(int64(len(lseg.ebuf)))andl.sfile.Sync()before theSeek - in
cycle(): fsyncl.pathafter creating the new segment
| Variant | tidwall.json |
tidwall.strict.json |
|---|---|---|
| v1.2.1 | FAIL seed 1 (1 fault) | FAIL |
| + PR #22 | FAIL seed 117 (stale tail, ErrCorrupt mid-log) |
FAIL seed 2 (segment lost) |
| + PR #22 + truncate | ok, 1500 seeds | not run separately |
| + PR #22 + truncate + dir fsync | ok, 1500 seeds | ok, 1500 seeds |
The client also checks that every entry the server returns is a payload it actually sent, so a fix that skipped torn bytes but exposed them as phantom entries would fail too.
Suggested upstream action
Comment on PR #22 with the truncate fix and the seed 117 reproduction. Then file a separate issue for the directory fsync, with the strict-POSIX caveat.