Repository navigation
WAL commits are never flushed to disk: a power loss drops acknowledged commits and can leave documents pointing at freed pages #260
Description
CommitAsync (and every auto-committing write) is acknowledged once the WAL has been handed to the OS, not once it is on disk: the WAL is never fsynced on commit. After a power loss, every commit since the database was opened (or since the last backup) is lost unless a checkpoint copied its pages to the page file. Because the pages those commits stopped using have already been freed, durably, in the page file, the documents that come back can point at overflow pages that are on the free list or have been handed out again: the result is not an older committed state but a corrupted one.
README "Transactions & ACID" (README.md:394): "Durable: WAL ensures data safety even in power loss."
Found by the WAL crash test (tests/BLite.Tests/Oracles/WalCrashTests.cs, #263), which carries the affected rows as a known candidate until this is fixed.
Repro
The test records every write the engine makes and rebuilds the files at each crash point. In crash mode PowerLoss, only WAL bytes that were fsynced survive (Flush(flushToDisk: true)); page writes are durable when PageFile.WritePage returns (each write flushes its view, msync(MS_SYNC) on Unix). Rows: PowerLoss / Durability (108 of 249 crash states), PowerLoss / Atomicity (6), PowerLoss / Pages.Reachable (6), PowerLoss / Continue (3). Shortest sequences, crash points as the test prints them:
Durability, crash after the WAL flush of step 2:
1. Insert(id=64, count=9, code=c15, payload=2500 chars)
2. Update(id=64, count=6, code=c06, payload=0 chars)
the collection is the state after the first open: document 64, inserted and acknowledged
in step 1, is missing
Atomicity and Pages.Reachable, crash after FreePage(21) of step 3:
1. Insert(id=74, count=3, code=c08, payload=18000 chars)
2. Backup
3. Upsert(id=74, count=7, code=null, payload=20 chars)
document 74 is back in its step-1 version, but its payload differs at index 3899: expected
'llll...', got '....' (zeros); page 24, on its overflow chain, has type Free
dotnet test tests/BLite.Tests --filter "FullyQualifiedName~Oracles.WalCrashTests"With the issue number removed from the family in s_candidates, the row fails and prints the seed, the crash point and the shortest sequence. BLITE_WALCRASH_LOG=<directory> writes every failing crash state to <directory>/<mode>.tsv.
Expected: a commit that CommitAsync has acknowledged survives a power loss, and whatever survives is a committed state.
Actual: only what the WAL held at open (or at the last backup) survives; later commits are lost, and the frees they made are kept, so documents can reference freed or reused overflow pages (the Continue row: a later insert overwrites them).
Mechanism (verified on 0450b97)
- A commit is acknowledged after
await _wal.FlushAsync()(src/BLite.Core/Storage/StorageEngine.GroupCommit.cs:202; the direct pathStorageEngine.Transactions.cs:445says "Durability: ensure WAL is on disk"). WriteAheadLog.FlushAsynccalls_walStream.FlushAsync(ct)(src/BLite.Core/Transactions/WriteAheadLog.cs:491). That writes the 64 KB stream buffer to the OS and does not fsync: on .NET 6 to 10,OSFileStreamStrategy.FlushAsyncreturnsTask.CompletedTask; onlyFlush(flushToDisk: true)callsFlushToDisk. The comment right below (:492-499) already says so: "FlushAsync doesn't guarantee flushToDisk ... To be safe for WAL, we might care about fsync. For now, just FlushAsync();".- The WAL
FileStreamis opened withoutFileOptions.WriteThrough(WriteAheadLog.cs:119-126): "REMOVED FileOptions.WriteThrough for SQLite-style lazy checkpointing / Durability is ensured by explicit Flush() calls". The onlyFlush(flushToDisk: true)on the WAL is inBackupAsync(:1002).TruncateAsync(:1035-1037) usesFlushAsynctoo. - Page writes, by contrast, are durable at once:
PageFile.WritePageCoreflushes its view after every write (PageFile.cs:712,:727). - So the order on disk is wrong wherever the engine acts on a commit: the group commit frees the pages the batch stopped using right after the unsynced flush (
StorageEngine.GroupCommit.cs:248,FreePagesOfCommittedTransaction), andFreePagewrites the Free header and the free-list head straight to the page file when the page has no WAL image (StorageEngine.Memory.cs:105, as after the backup's truncation in the repro). The same holds for truncation: the pages it releases are freed durably right after the unsyncedTruncateAsync(StorageEngine.Recovery.cs:272,:285). A power loss keeps the free and drops the commit.
The test's seed (WalCrashTests.ack-before-wal-flush.patch, which drops the flush entirely) shows the same corruption under a plain process crash: what this issue does on a power loss, the seed does on any crash.
History
1e9e8cb(2026-02-07) opened the WAL withFileOptions.WriteThrough("Ensures writes go to disk immediately"), 4 KB buffer.f5802f9(2026-02-08, v1.0.0) removedWriteThroughand raised the buffer to 64 KB for SQLite-style lazy checkpointing;IMPLEMENTATION_NOTES.mdin that commit lists it as "RIMOSSOFileOptions.WriteThrough- Enorme guadagno di performance!" (the OS buffers WAL writes instead of syncing each one). The commit kept one fsync per commit:WriteAheadLog.Flush()was_walStream.Flush(flushToDisk: true), the notes count it as "WAL.Flush() [SYNC I/O Develop #1 - UNICO!]" and state "Durabilità garantita: commit record nel WAL è flushed prima del return". So dropping write-through traded per-write syncs for one sync per commit, not durability.a4e052b(2026-02-11, v1.0.0, Async Core) addedWriteAheadLog.FlushAsyncwith_walStream.FlushAsync(ct)and the comment still atWriteAheadLog.cs:492-499("To be safe for WAL, we might care about fsync. For now, just FlushAsync();"). From hereCommitAsyncdid not fsync; the synchronous commit paths still calledFlush()→Flush(flushToDisk: true).d72b73a(2026-03-12, v3.6.2) introduced group commit: "issues a single FlushAsync per group, amortising fsync cost", with the flush estimated at "~5–30 µs", which is the cost of handing the buffer to the OS, not of an fsync.63465aa(2026-03-24, v4.0.0, async-first) removed the synchronousFlush()/Truncate()with theirFlush(flushToDisk: true). Since then no commit path fsyncs the WAL;BackupAsync(f3bdf4f, Complete multi-file backups with manifest and backup API metadata #95) is the onlyFlush(flushToDisk: true)left.
So the fsync was meant to stay (group commit was designed to amortise it), and it went missing through the async FlushAsync path. Whether to restore it on every commit is a durability/latency decision, see below.
Affected versions: CommitAsync since v1.0.0, every commit path since v4.0.0. The frees that turn a lost commit into a corrupted document are those of the current code, right after the commit (#233 / #236, 4d3e6b9) or at the next truncation (#168 / #169, e19035c), both merged after v5.2.3; before them pages were freed earlier still. Not reproduced on hardware; the test models a power loss as "only fsynced WAL bytes and msynced pages survive".
Fix considerations
This is a durability/latency trade-off for the maintainers to decide; the code today is in between (the README and the group-commit design promise sync-on-commit, the flush does not do it).
- Sync on commit (what the README describes, SQLite's
synchronous=FULL): fsync the WAL once per group commit before acknowledging the batch:_walStream.Flush(flushToDisk: true)inWriteAheadLog.FlushAsync, or keepFlushAsyncand follow it with a flush to disk of the handle (fsync/FlushFileBuffers). One fsync per batch, which on most storage dominates the latency of a small commit; group commit amortises it over every transaction in the batch, asd72b73aintended. The same fsync belongs inTruncateAsyncbefore the pages a truncation releases are freed. On macOS,fsyncdoes not flush the drive cache; SQLite usesF_FULLFSYNCthere. - A configurable synchronous mode if the lazy path should stay available: sync-on-commit as the default, and a sync-on-checkpoint mode (SQLite's
synchronous=NORMALin WAL mode) documented as possibly losing the last commits on a power loss. Even in that mode the WAL has to be fsynced before freeing pages a commit released, otherwise a power loss corrupts those documents instead of just making them older. - The README line should then say which mode gives which guarantee.
Related: #95 / f3bdf4f (the only Flush(flushToDisk: true), in BackupAsync), #119 / #120 (checkpoint on Dispose, a clean close, not a power loss), #168 / #169 and #233 / #236 (the frees that make a lost commit a corrupted document), #261 and #262 (the other two findings of the same test). No closed issue or PR discusses commit fsync or power loss.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsIn Progress
Activity
CaffeinatedCoder commentedon Oct 17, 2026
Fix in #280. The WAL is now synced (
Flush(flushToDisk: true)) once per group-commit batch before acknowledging, and on truncation. The sync costs about 0.14 → 4.5 ms per sequential commit on macOS/APFS, so the PR addsPageFileConfig.WalSync:Fullis the default;Normalsyncs only before checkpoints, before freeing released pages and on truncation. With the#260entry removed, the WAL crash test passes all rows.test(oracles): link the WAL crash candidate families to their issues
test(oracle): run the store model and the WAL crash oracle on an encr…
mrdevrobot commentedon Oct 18, 2026
Confirmed by code on 0450b97: no commit path fsyncs the WAL, while page frees reach the page file at once. A power loss can drop acknowledged commits; a process crash is not affected. Not reproducible end to end here: high, not critical. The per-commit fsync in #280 costs ~35x per commit on macOS and needs a maintainer decision.