Skip to content

fix(storage): keep prepared transactions' WAL records until they commit or roll back (#188) - #246

Closed
CaffeinatedCoder wants to merge 1 commit into
mainfrom
fix/188-prepared-wal-truncation
Closed

CaffeinatedCoder wants to merge 1 commit into
mainfrom
fix/188-prepared-wal-truncation

Conversation

@CaffeinatedCoder

@CaffeinatedCoder CaffeinatedCoder commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #188 (single-process part; the multi-process part was closed by #201, which rejects these paths with AllowMultiProcessAccess).

MarkTransactionCommittedAsync appends only a commit record and relies on the Begin/Write records PrepareTransactionAsync wrote. Prepare held no _commitLock, and a prepared transaction's pages sit in _walCache, so a checkpoint (or RecoverAsync) could truncate the WAL between prepare and mark. The commit then had no Write records: after a crash before the next checkpoint, recovery replayed nothing and the committed pages were lost. (The in-library PrepareAsync → CommitAsync flow rewrites everything through group commit and was not affected.)

Changes:

  • PrepareTransactionAsync (both overloads) appends and flushes under _commitLock, like a commit, so its records no longer interleave with a commit batch, and it registers the transaction under that lock. If the lock is not free within WriteTimeoutMs it returns false, as for any other failure. Under heavy commit load a prepare can therefore fail where it used to write unsynchronised.
  • Until a prepared transaction commits or rolls back (CompleteTransaction), checkpoints still write pages but skip the WAL truncation; RecoverAsync skips it too and records the written pages in _checkpointedWalPages, as a non-truncating checkpoint does.
  • Docs: an unresolved prepare holds back truncation (the WAL grows until it is committed or rolled back; Transaction.Dispose and engine Dispose roll back what they own), and recovery applies only committed transactions, so a transaction still prepared at close or crash is rolled back on reopen.

Not changed:

  • No API resolves in-doubt transactions after a restart (_walCache is in memory, transaction ids restart at 1), so a prepare that survives a participant crash would be a new feature.
  • CommitTransactionAsync(ulong) was already self-contained under _commitLock.
  • A prepared transaction whose CommitTransactionAsync(Transaction) fails is removed from _activeTransactions, so engine Dispose no longer rolls it back; it then holds back truncation until the engine closes. Documented, no counter/warning added.
  • MarkTransactionCommittedAsync still takes _commitLock with a blocking Wait() without timeout (pre-existing).

Tests

PreparedTransactionWalTests (single-process; engine disposed without checkpoint, then reopened):

  • prepare, checkpoint, MarkTransactionCommittedAsync, reopen: page recovered (lost on main)
  • prepare, RecoverAsync, mark, reopen: page recovered (lost on main)
  • prepare while the commit lock is held: returns false and writes nothing
  • tx.PrepareAsync() keeps the WAL across a checkpoint; after CommitAsync / RollbackAsync the next checkpoint truncates again, and the rolled-back page is not applied

All 5 fail on main. Full BLite.Tests suite passes.

Conflicts with other open PRs

Conflicts with #310 (write-write conflict detection, #304) in src/BLite.Core/Storage/StorageEngine.Transactions.cs. Resolution: CompleteTransaction removes the transaction from both _preparedTransactions (here) and _baseVersions (#310). PrepareCoreAsync calls #310's ThrowIfConflicting under the commit lock, before the Begin record, so a conflicting prepare returns false and writes nothing. MarkTransactionCommittedAsync keeps #310's check, and its prepared records then never get a commit record. Two tests in PreparedTransactionWalTests cover this. Either PR can land first; I will push the resolution to the second.

🤖 Generated with Claude Code

…it or roll back (#188)

- PrepareTransactionAsync appends its Begin/Write records and flushes under
  _commitLock, like a commit, so they no longer interleave with a group-commit
  batch and no truncation runs while they are written. Returns false if the
  lock cannot be taken within WriteTimeoutMs.
- A prepared transaction is tracked until it commits or rolls back
  (CompleteTransaction). Meanwhile checkpoints still write pages but skip the
  WAL truncation, and RecoverAsync skips it too, so
  MarkTransactionCommittedAsync, which appends only the commit record, finds
  the prepared Write records in the WAL and the commit survives a crash.
- Document the behaviour, including that an unresolved prepare holds back
  truncation and that recovery rolls back a transaction still prepared.
- Drop comments that said prepare writes without _commitLock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mrdevrobot

Copy link
Copy Markdown
Contributor

Not merging. The loss in #188 needs a caller to drive MarkTransactionCommittedAsync directly, which BLite never does (#188 is low/P3). The PR adds new behaviour to checkpoint and recovery: prepare can now fail on commit-lock contention, and an unresolved or failed prepared transaction blocks WAL truncation until the engine closes. Too much risk for that path; a change confined to MarkTransactionCommittedAsync, or documenting/obsoleting the direct 2PC entry points, would be proportionate.

@mrdevrobot mrdevrobot closed this Oct 18, 2026

This branch was successfully deployed

1 active deployment
Production — f0eb67d8 Deployed Oct 18, 2026 by CaffeinatedCoder via build_and_publish #745
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/low Cosmetic, ordering or documentation-level difference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-process WAL: PrepareTransactionAsync and the direct commit paths bypass the commit and cross-process writer locks

2 participants