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..6573a33b 100644 --- a/src/BLite.Core/BLiteSession.cs +++ b/src/BLite.Core/BLiteSession.cs @@ -102,18 +102,35 @@ 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() + /// + /// 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(); - if (CurrentTransaction != null) - { - try { CurrentTransaction.RollbackAsync(); } - finally { CurrentTransaction = null; } - } + ct.ThrowIfCancellationRequested(); + 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..1cbe9548 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(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 e637e541..106310ac 100644 --- a/tests/BLite.Tests/BLiteSessionTests.cs +++ b/tests/BLite.Tests/BLiteSessionTests.cs @@ -120,6 +120,122 @@ 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); + } + + [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 // ─────────────────────────────────────────────────────────────────────────