Skip to content

fix(wal): sync the WAL to disk before a commit is acknowledged - #280

Open
CaffeinatedCoder wants to merge 1 commit into
mainfrom
fix/wal-fsync-on-commit
Open

CaffeinatedCoder wants to merge 1 commit into
mainfrom
fix/wal-fsync-on-commit

Conversation

@CaffeinatedCoder

@CaffeinatedCoder CaffeinatedCoder commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

What was wrong

CommitAsync and every auto-committing write were acknowledged once the WAL had been handed to the OS, not once it was on disk (#260). WriteAheadLog.FlushAsync called FileStream.FlushAsync, which does not fsync on .NET 6 to 10, and the WAL stream has no WriteThrough. Since 63465aa (v4.0.0) removed the synchronous Flush(flushToDisk: true), no commit path synced the WAL.

The pages a commit stopped using are freed right after that flush, straight to the page file. A power loss therefore kept the frees and dropped the commits, so documents could come back pointing at freed or reused overflow pages. TruncateAsync had the same gap: the pages a truncation releases are freed right after it returns.

Fix

  • WriteAheadLog.FlushAsync hands the buffer to the OS (FlushAsync) and then calls Flush(flushToDisk: true) (fsync, FlushFileBuffers on Windows). Group commit still flushes once per batch, so the sync is amortised over the batch, as d72b73a intended. The lazy checkpoint is unchanged.
  • TruncateAsync syncs the truncation before returning.

Cost, and the switch

I measured with a small console app against BLite.Core (Release) on macOS 27 / Apple Silicon / APFS. Each run does 1000 sequential InsertAsync + CommitAsync calls on a BLiteEngine after 50 warm-up commits, then 8 concurrent writers × 250 transactions. Two runs each:

sequential commit, mean / p50 / p99 8 writers
main (no sync) 0.14 / 0.11 / 0.8 ms 18,600–24,700 commits/s
this PR, WalSyncMode.Full (default) 4.5–4.8 / 4.2–4.8 / 7.1–7.3 ms 690–770 commits/s
this PR, WalSyncMode.Normal 1.2–1.3 / 0.16–0.17 / 5.7–5.8 ms 7,400–9,100 commits/s

That cost is large, mostly the cost of a real flush on this storage. So, as the issue's second option suggests, there is a switch:

  • PageFileConfig.WalSync with WalSyncMode.Full (the default, 0, so default(PageFileConfig) and every preset stay durable) and WalSyncMode.Normal.
  • Normal acknowledges a commit after the hand-off alone. It syncs the WAL before a checkpoint copies committed pages to the data file, before the pages a commit stopped using are freed (_freeOnCommit holds an entry for a transaction in the batch), and on truncation. A power loss can lose the most recent commits but leaves a committed state, never one pointing at freed pages. This matches SQLite's synchronous=NORMAL in WAL mode.
  • In Normal mode the p99 and the mean include those syncs, from checkpoints when the WAL passes MaxWalSize.
  • WriteAheadLog takes the mode as syncOnFlush (constructor, default true) and gains SyncAsync(). IWriteAheadLog is unchanged, so the memory and WASM WALs are untouched.
  • README's "Durable" line now says which mode guarantees what.

If you would rather not have the switch, drop WalSyncMode, PageFileConfig.WalSync and SyncDeferredWalAsync. The fix itself is the two Flush(flushToDisk: true) lines in WriteAheadLog.

Tests

WalCommitSyncTests replaces the WAL's FileStream with a recording stream over the same handle (the seam the WAL crash test uses) and records each write, truncation, flush and sync in order:

Test Without the fix
AutoCommit_Insert_Returns_Only_After_The_Wal_Is_Synced fails: write, write, write, write, flush, flush
Transaction_CommitAsync_Returns_Only_After_The_Wal_Is_Synced fails
Checkpoint_Syncs_The_Wal_Truncation_Before_Returning fails: set-length, flush, flush
Normal_Mode_Acknowledges_A_Commit_Without_Syncing new API, does not compile on main
Normal_Mode_Syncs_Before_Freeing_The_Pages_A_Commit_Released new API; also fails with the free-path sync removed
Normal_Mode_Syncs_Before_A_Checkpoint_Copies_Pages new API; also fails with the checkpoint sync removed

I checked the first three by reverting src/ to upstream/main. I checked the last two by removing each Normal-mode sync point in turn.

WAL crash oracle (#263): I cherry-picked this onto test/oracle-wal and deleted the #260 entry from s_candidates. Oracles.WalCrashTests then passes all 25 rows, including PowerLoss / Durability, Atomicity, Pages.Reachable and Continue (default mode, Full). That test is the real guard for what survives each crash point; the unit tests above only pin the order of calls.

dotnet test tests/BLite.Tests gave 3089 passed and 4 skipped. Nine tests failed under load from parallel runs (ConcurrentConsistency, BTreeSplit, BTreeRollbackFree, IndexPageRelease, StringIndexPrefixMatch, UnchangedIndexKeyUpdate, two DocumentCollection*); all 115 tests of those classes pass when rerun on their own. The suite takes about a minute longer because commits now sync. BLite.Core builds for net10.0 and netstandard2.1 with 0 warnings, and tests/BLite.NetStandard21.Tests builds.

Compatibility

  • No file format change and no index rebuild.
  • Commit latency rises by one fsync per batch by default, as measured above.
  • WriteAheadLog's new constructor parameter is optional and appended at the end.
  • AOT: no reflection added.

Fixes #260

The WAL crash test PR (#263) lists this as a known candidate. Its #260 row in s_candidates can go once this lands.

Conflicts with other open PRs

Conflicts with #310 (write-write conflict detection, #304) in src/BLite.Core/Storage/StorageEngine.GroupCommit.cs and README.md. Resolution: the sync-before-free loop skips commits that #310 rejected as conflicts, and a batch whose commits were all rejected skips its FlushAsync, so a rejected commit is never written or synced. The README keeps this PR's Durable line and #310's Isolated line. Test: WalCommitSyncTests.A_Commit_Rejected_As_A_Conflict_Writes_And_Syncs_Nothing. Either PR can land first; I will push the resolution to the second.

This PR should land together with #321, the fix for #303. With a sync per batch, a commit waits for the previous batch's fsync while holding the locks, and the 500 ms LockTimeout.Default (tuned for an unsynced WAL) fires more often.

🤖 Generated with Claude Code

Every commit path awaits WriteAheadLog.FlushAsync before it acknowledges
a commit and frees the pages the commit stopped using. FlushAsync called
FileStream.FlushAsync, which hands the 64 KB buffer to the OS and does
not fsync (.NET 6 to 10), and the stream has no WriteThrough. Since the
async-first rewrite removed the synchronous Flush(flushToDisk: true), no
commit synced the WAL. A power loss therefore dropped acknowledged
commits while the frees those commits made, written straight to the page
file, survived, so documents could come back pointing at freed or reused
overflow pages. TruncateAsync had the same gap: the pages a truncation
releases are freed right after it returns.

FlushAsync now follows the buffer hand-off with Flush(flushToDisk: true),
once per group-commit batch, and TruncateAsync syncs the truncation. The
lazy checkpoint is unchanged.

The sync costs a lot here: on macOS/APFS a sequential single-document
commit goes from about 0.13 ms to about 4.5 ms. So there is a switch,
PageFileConfig.WalSync, defaulting to WalSyncMode.Full (the behaviour
above). WalSyncMode.Normal acknowledges a commit after the hand-off
alone and syncs the WAL before a checkpoint copies pages to the data
file, before the pages a commit stopped using are freed, and on
truncation. A power loss can then lose the most recent commits but
leaves a committed state, as SQLite's synchronous=NORMAL does in WAL
mode. README's durability line now says which mode guarantees what.

Found-By: OT
Defect-Class: WAL
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mrdevrobot mrdevrobot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. The durability gap in #260 is real, but a sync on every commit by default is not acceptable: on macOS it took commits from about 1.5 ms to about 53 ms (Flush(true) is F_FULLFSYNC there), and with the 500 ms default lock timeout 313–329 of 2,000 concurrent inserts timed out.

Keep WalSyncMode, with a default that leaves commit latency unchanged; full per-commit sync is opt-in. Independently of the mode, sync the WAL before the writes whose loss corrupts rather than drops: before a page freed by a committed transaction reaches the page file and before the WAL is truncated. Document what each mode survives (process crash vs. power loss). LockTimeout.Default stays at 500 ms (#321 is closed).

This branch had an error being deployed

1 failed deployment
Production — 9f742a3e Deployed Oct 17, 2026 by CaffeinatedCoder via build_and_publish #753
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working package/core Affects BLite.Core severity/high Wrong results, crash or failed writes in a realistic scenario

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WAL commits are never flushed to disk: a power loss drops acknowledged commits and can leave documents pointing at freed pages

2 participants