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
// ─────────────────────────────────────────────────────────────────────────