From b0aed3b1c61ed3868d7b9259f4cab0d6ab98cf94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sun, 18 Oct 2026 12:53:39 +0200 Subject: [PATCH 1/2] fix(session): await the rollback in BLiteSession; add RollbackAsync (#307) BLiteSession.Rollback() started CurrentTransaction.RollbackAsync() and returned without awaiting it, so the WAL abort record, the Aborted state and the rollback handlers (free-space-index restore) ran later, and a failure of that work was never observed. Under concurrent commits 236-244 of 300 transactions were still Active when Rollback() returned. BLiteSession.RollbackAsync() awaits the rollback and forgets the transaction before it starts, so a second call cannot roll the same transaction back twice. Rollback() now blocks on it, the pattern Transaction.Dispose already uses. The generated typed session wrapper gets RollbackAsync() too, and the README session sample awaits it. Tests: BLiteSessionTests, five tests; the two load tests fail on upstream/main (244 and 300 of 300 still active) and pass with the fix. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- src/BLite.Core/BLiteSession.cs | 26 +++-- src/BLite.SourceGenerators/MapperGenerator.cs | 4 +- tests/BLite.Tests/BLiteSessionTests.cs | 100 ++++++++++++++++++ 4 files changed, 123 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1a868fde..1782f682 100644 --- a/README.md +++ b/README.md @@ -928,7 +928,7 @@ try } catch { - session.Rollback(); // or disposed automatically + await session.RollbackAsync(); // or disposed automatically throw; } diff --git a/src/BLite.Core/BLiteSession.cs b/src/BLite.Core/BLiteSession.cs index 68e7ef24..5ea15b08 100644 --- a/src/BLite.Core/BLiteSession.cs +++ b/src/BLite.Core/BLiteSession.cs @@ -102,18 +102,30 @@ public async ValueTask CommitAsync(CancellationToken ct = default) } /// - /// Rolls back the active transaction, discarding all uncommitted changes. + /// Rolls back the active transaction, discarding all uncommitted changes. When the returned + /// task completes, the rollback is complete: the abort record is in the WAL, the transaction is + /// and its rollback handlers have run. A failure of that + /// work is thrown to the caller. Does nothing when no transaction is active. /// - public void Rollback() + public ValueTask RollbackAsync() { ThrowIfDisposed(); - if (CurrentTransaction != null) - { - try { CurrentTransaction.RollbackAsync(); } - finally { CurrentTransaction = null; } - } + var transaction = CurrentTransaction; + if (transaction == null) + return default; + + // Forget the transaction before the rollback runs, so a second Rollback or a concurrent + // CommitAsync on this session cannot act on a transaction that is already rolling back. + CurrentTransaction = null; + return transaction.RollbackAsync(); } + /// + /// Rolls back the active transaction, discarding all uncommitted changes, and blocks until the + /// rollback is complete. Prefer in asynchronous code. + /// + public void Rollback() => RollbackAsync().GetAwaiter().GetResult(); + // ───────────────────────────────────────────────────────────────────────── // ITransactionHolder // ───────────────────────────────────────────────────────────────────────── diff --git a/src/BLite.SourceGenerators/MapperGenerator.cs b/src/BLite.SourceGenerators/MapperGenerator.cs index 7e7cfa7f..30553590 100644 --- a/src/BLite.SourceGenerators/MapperGenerator.cs +++ b/src/BLite.SourceGenerators/MapperGenerator.cs @@ -289,8 +289,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) sb.AppendLine($" public global::System.Threading.Tasks.ValueTask SaveChangesAsync(global::System.Threading.CancellationToken ct = default) => _session.CommitAsync(ct);"); sb.AppendLine($" /// Begins a new transaction for this session or returns the active one."); sb.AppendLine($" public global::BLite.Core.Transactions.ITransaction BeginTransaction() => _session.BeginTransaction();"); - sb.AppendLine($" /// Rolls back the active transaction, discarding uncommitted changes."); + sb.AppendLine($" /// Rolls back the active transaction, discarding uncommitted changes, and blocks until the rollback is complete."); sb.AppendLine($" public void Rollback() => _session.Rollback();"); + sb.AppendLine($" /// Asynchronously rolls back the active transaction, discarding uncommitted changes."); + sb.AppendLine($" public global::System.Threading.Tasks.ValueTask RollbackAsync() => _session.RollbackAsync();"); sb.AppendLine($" /// Disposes this session, rolling back any uncommitted transaction."); sb.AppendLine($" public void Dispose() => _session.Dispose();"); sb.AppendLine($" }}"); diff --git a/tests/BLite.Tests/BLiteSessionTests.cs b/tests/BLite.Tests/BLiteSessionTests.cs index e637e541..e7fecfbf 100644 --- a/tests/BLite.Tests/BLiteSessionTests.cs +++ b/tests/BLite.Tests/BLiteSessionTests.cs @@ -120,6 +120,106 @@ public async Task RollingBackOneSession_DoesNotAffectOtherSessions() Assert.Equal(t2.TransactionId, s2.CurrentTransaction!.TransactionId); } + // ───────────────────────────────────────────────────────────────────────── + // 2b. Rollback completes before it returns (#307) + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Runs on 300 transactions while another task keeps committing + /// to a second collection, and counts the transactions that are still + /// when the rollback call has + /// returned. Without the fix Rollback() dropped the ValueTask, and the WAL abort record, + /// the state change and the free-space-index restore ran later: 59–85 of 300 under load. + /// + private async Task CountTransactionsStillActiveAfterRollback(Func rollback) + { + var doc = _engine.CreateDocument(["name"], b => b.AddString("name", "rolled back")); + using var stop = new System.Threading.CancellationTokenSource(); + var load = Task.Run(async () => + { + var other = _engine.GetOrCreateCollection("load"); + while (!stop.IsCancellationRequested) + { + var txn = _engine.BeginTransaction(); + await other.InsertAsync(_engine.CreateDocument(["n"], b => b.AddInt32("n", 1)), txn); + await txn.CommitAsync(); + } + }); + + int stillActive = 0; + try + { + for (int i = 0; i < 300; i++) + { + using var session = _engine.OpenSession(); + var txn = session.BeginTransaction(); + await session.InsertAsync("orders", doc); + await rollback(session); + if (txn.State != BLite.Core.Transactions.TransactionState.Aborted) stillActive++; + } + } + finally + { + stop.Cancel(); + await load; + } + return stillActive; + } + + [Fact] + public async Task RollbackAsync_TransactionIsAborted_WhenItReturns() + { + var stillActive = await CountTransactionsStillActiveAfterRollback(s => s.RollbackAsync().AsTask()); + Assert.Equal(0, stillActive); + } + + [Fact] + public async Task Rollback_TransactionIsAborted_WhenItReturns() + { + var stillActive = await CountTransactionsStillActiveAfterRollback(s => { s.Rollback(); return Task.CompletedTask; }); + Assert.Equal(0, stillActive); + } + + [Fact] + public async Task RollbackAsync_DiscardsTheWrites_AndClearsCurrentTransaction() + { + using var session = _engine.OpenSession(); + session.BeginTransaction(); + var doc = _engine.CreateDocument(["name"], b => b.AddString("name", "gone")); + var id = await session.InsertAsync("orders", doc); + + await session.RollbackAsync(); + + Assert.Null(session.CurrentTransaction); + Assert.Null(await session.FindByIdAsync("orders", id)); + // The session is usable again: a new transaction can be started and committed. + session.BeginTransaction(); + var id2 = await session.InsertAsync("orders", doc); + await session.CommitAsync(); + Assert.NotNull(await session.FindByIdAsync("orders", id2)); + } + + [Fact] + public async Task RollbackAsync_WithoutTransaction_IsANoOp() + { + using var session = _engine.OpenSession(); + await session.RollbackAsync(); + session.Rollback(); + Assert.Null(session.CurrentTransaction); + } + + [Fact] + public async Task RollbackAsync_CalledTwice_RollsBackOnce() + { + using var session = _engine.OpenSession(); + var txn = session.BeginTransaction(); + var first = session.RollbackAsync(); + var second = session.RollbackAsync(); // nothing left to roll back + await first; + await second; + Assert.Equal(BLite.Core.Transactions.TransactionState.Aborted, txn.State); + } + // ───────────────────────────────────────────────────────────────────────── // 3. CRUD via sessions — Insert / FindById / UpdateAsync / Delete // ───────────────────────────────────────────────────────────────────────── From 22d66a1c52d10767aea0a036d9c94ed8c5a00df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 19 Oct 2026 06:20:53 +0200 Subject: [PATCH 2/2] fix(session): RollbackAsync takes a CancellationToken (#307) BLiteSession.RollbackAsync and the generated session wrapper's RollbackAsync take a CancellationToken, like CommitAsync. The token is checked before the rollback starts; a cancelled token throws and leaves the transaction active. Co-Authored-By: Claude Opus 5.5 --- src/BLite.Core/BLiteSession.cs | 7 ++++++- src/BLite.SourceGenerators/MapperGenerator.cs | 2 +- tests/BLite.Tests/BLiteSessionTests.cs | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/BLite.Core/BLiteSession.cs b/src/BLite.Core/BLiteSession.cs index 5ea15b08..6573a33b 100644 --- a/src/BLite.Core/BLiteSession.cs +++ b/src/BLite.Core/BLiteSession.cs @@ -107,9 +107,14 @@ public async ValueTask CommitAsync(CancellationToken ct = default) /// and its rollback handlers have run. A failure of that /// work is thrown to the caller. Does nothing when no transaction is active. /// - public ValueTask RollbackAsync() + /// + /// Checked before the rollback starts: a cancelled token throws + /// and leaves the transaction active. A rollback that has started is not cancelled. + /// + public ValueTask RollbackAsync(CancellationToken ct = default) { ThrowIfDisposed(); + ct.ThrowIfCancellationRequested(); var transaction = CurrentTransaction; if (transaction == null) return default; diff --git a/src/BLite.SourceGenerators/MapperGenerator.cs b/src/BLite.SourceGenerators/MapperGenerator.cs index 30553590..1cbe9548 100644 --- a/src/BLite.SourceGenerators/MapperGenerator.cs +++ b/src/BLite.SourceGenerators/MapperGenerator.cs @@ -292,7 +292,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) sb.AppendLine($" /// Rolls back the active transaction, discarding uncommitted changes, and blocks until the rollback is complete."); sb.AppendLine($" public void Rollback() => _session.Rollback();"); sb.AppendLine($" /// Asynchronously rolls back the active transaction, discarding uncommitted changes."); - sb.AppendLine($" public global::System.Threading.Tasks.ValueTask RollbackAsync() => _session.RollbackAsync();"); + sb.AppendLine($" public global::System.Threading.Tasks.ValueTask RollbackAsync(global::System.Threading.CancellationToken ct = default) => _session.RollbackAsync(ct);"); sb.AppendLine($" /// Disposes this session, rolling back any uncommitted transaction."); sb.AppendLine($" public void Dispose() => _session.Dispose();"); sb.AppendLine($" }}"); diff --git a/tests/BLite.Tests/BLiteSessionTests.cs b/tests/BLite.Tests/BLiteSessionTests.cs index e7fecfbf..106310ac 100644 --- a/tests/BLite.Tests/BLiteSessionTests.cs +++ b/tests/BLite.Tests/BLiteSessionTests.cs @@ -220,6 +220,22 @@ public async Task RollbackAsync_CalledTwice_RollsBackOnce() Assert.Equal(BLite.Core.Transactions.TransactionState.Aborted, txn.State); } + [Fact] + public async Task RollbackAsync_CancelledToken_Throws_AndLeavesTheTransactionActive() + { + using var session = _engine.OpenSession(); + var txn = session.BeginTransaction(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => session.RollbackAsync(cts.Token).AsTask()); + + Assert.Same(txn, session.CurrentTransaction); + Assert.Equal(BLite.Core.Transactions.TransactionState.Active, txn.State); + await session.RollbackAsync(); + Assert.Equal(BLite.Core.Transactions.TransactionState.Aborted, txn.State); + } + // ───────────────────────────────────────────────────────────────────────── // 3. CRUD via sessions — Insert / FindById / UpdateAsync / Delete // ─────────────────────────────────────────────────────────────────────────