Skip to content
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Most embedded databases for .NET are either wrappers around C libraries (SQLite,
- **Zero Allocation**: I/O and interaction paths use `Span<byte>` and `stackalloc`. No heap allocations for reads/writes.
- **Type-Safe**: No reflection. All serialization code is generated at compile-time.
- **Developer Experience**: Full LINQ provider (`IQueryable`) that feels like Entity Framework but runs on bare metal.
- **Reliable**: Full ACID transactions with Write-Ahead Logging (WAL) and Snapshot Isolation.
- **Reliable**: ACID transactions with Write-Ahead Logging (WAL). Concurrent transactions never overwrite each other's commits: the first to commit wins.

---

Expand Down Expand Up @@ -391,7 +391,7 @@ using var dynSub = engine.GetOrCreateCollection("orders").Watch()
### 🛡️ Transactions & ACID
- **Atomic**: Multi-document transactions.
- **Durable**: WAL ensures data safety even in power loss.
- **Isolated**: Snapshot isolation allowing concurrent readers and writers.
- **Isolated**: Read committed: a transaction sees its own writes and what other transactions have committed. Two transactions that change the same page cannot both commit: the first one wins, the second one's `CommitAsync` throws `BLiteTransactionConflictException` and rolls it back, so run it again in a new transaction. A write made without a transaction (`InsertAsync(doc)`, `UpdateAsync`, `DeleteAsync`, …) commits on its own and, when a transaction committed one of its pages first, runs again by itself; it throws `BLiteTransactionConflictException` only after 10 rejected attempts. Index creation, `VacuumAsync` and retention commit without retrying and can throw it too.
- **Thread-Safe**: Protected with `SemaphoreSlim` to prevent race conditions in concurrent scenarios.
- **Async-Only**: All CRUD operations on `DocumentCollection<TId, T>` and `DynamicCollection` are exclusively async/await — no blocking synchronous methods. Eliminates accidental blocking calls on thread-pool threads.
- **Implicit Transactions**: Use `SaveChangesAsync()` for automatic transaction management.
Expand Down Expand Up @@ -916,7 +916,7 @@ if (doc is not null)

## 🔌 BLiteSession — Per-Connection Isolation *(v3.8.0)*

When a single `BLiteEngine` is shared across multiple concurrent clients (e.g. inside a custom server layer), `BLiteSession` provides **per-connection isolated transaction contexts**. Each session carries its own transaction state so independent callers cannot interfere with each other.
When a single `BLiteEngine` is shared across multiple concurrent clients (e.g. inside a custom server layer), `BLiteSession` provides **per-connection isolated transaction contexts**. Each session carries its own transaction state. Sessions are not serialized against each other: when two sessions' open transactions change the same page, the first to commit wins and the other's `CommitAsync` throws `BLiteTransactionConflictException` (see Transactions & ACID above).

Open a session with `engine.OpenSession()`. Disposing the session automatically rolls back any uncommitted transaction.

Expand Down Expand Up @@ -1172,7 +1172,7 @@ We are actively building the core. Here is where we stand:
- ✅ **CDC Watch on `DynamicCollection` (v3.6.0)**: `DynamicCollection.Watch()` adds real-time change streams to the schema-less API — previously only available on typed `DocumentCollection<TId, T>`.
- ✅ **HNSW Vector Search Correctness (v3.6.2)**: full correctness pass — fixes `AllocateNode` overflow, neighbor link integrity, `SelectNeighbors` heuristic, random level distribution (`mL = 1/ln(M)`), and index persistence across close/reopen. 12 dedicated edge-case tests added.
- ✅ **OLAP GroupBy Push-down (v3.7.0)**: aggregate terminal operators (`Count`, `Sum`, `Min`, `Max`, `Average`) are pushed down to the storage layer via `BTreeQueryProvider.TryBsonAggregate<TResult>`, eliminating unnecessary document materialization for large scans.
- ✅ **BLiteSession — Per-Connection Isolation (v3.8.0)**: `BLiteEngine.OpenSession()` returns a `BLiteSession` with its own isolated transaction context. Multiple sessions on the same engine run independent concurrent transactions. Disposing a session rolls back any uncommitted transaction automatically.
- ✅ **BLiteSession — Per-Connection Isolation (v3.8.0)**: `BLiteEngine.OpenSession()` returns a `BLiteSession` with its own isolated transaction context. Multiple sessions on the same engine run independent concurrent transactions; when two change the same page, the first to commit wins and the other fails with `BLiteTransactionConflictException`. Disposing a session rolls back any uncommitted transaction automatically.
- ✅ **Multi-File Storage Layout (v3.8.0)**: `PageFileConfig.Server(dbPath)` configures separate files for WAL, index data, and per-collection data. `BLiteMigration.ToMultiFileAsync()` / `ToSingleFileAsync()` migrate existing databases between layouts, preserving all documents, KV entries (including TTL), and index definitions.
- ✅ **Non-Blocking Checkpoints (v3.8.0)**: checkpoint and metadata writes are deferred to avoid blocking the hot path. `PageFile` uses `Lazy<T>` for collection file initialization and `ReaderWriterLockSlim` to fix concurrent read/write races.
- ✅ **Async-Only CRUD API (v4.0.0 — Breaking Change)**: Synchronous data methods (`Insert`, `Update`, `Delete`, `FindById`, `FindAll`, `Find`, and all bulk variants) have been **removed** from `DocumentCollection<TId, T>` and `DynamicCollection`. All data operations are now exclusively async — this eliminates accidental blocking calls on thread-pool threads, simplifies the internal code paths, and enforces correct async usage throughout the entire stack.
Expand Down
70 changes: 70 additions & 0 deletions src/BLite.Core/BLiteTransactionConflictException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System;

namespace BLite.Core;

/// <summary>
/// Thrown by a commit when another transaction has committed a change to a page this transaction
/// also changed, after this transaction read that page. The first transaction to commit wins; this
/// one is rolled back (<see cref="Transactions.ITransaction.State"/> becomes
/// <see cref="Transactions.TransactionState.Aborted"/>) so that neither commit silently overwrites
/// the other (#304).
/// </summary>
/// <remarks>
/// <para>
/// Nothing of the rejected transaction is written. Run its work again in a new transaction to retry:
/// it then reads the other transaction's committed changes first.
/// </para>
/// <para>
/// Conflicts are detected per page, not per document: two transactions that change different
/// documents stored on the same data page, or whose index entries share a B-Tree leaf, conflict too.
/// A write method called without a transaction (<c>InsertAsync(document)</c>, <c>UpdateAsync</c>,
/// <c>DeleteAsync</c>, their bulk and upsert variants, <c>TruncateAsync</c> of a typed collection, and
/// the <c>BLiteSession</c> methods) commits on its own. When a transaction that committed first changed
/// one of its pages, it runs again in a new transaction while it still holds the collection lock, and
/// throws this exception only if all of its attempts (10) were rejected. Other operations that commit a
/// transaction of their own (index creation, <c>VacuumAsync</c>, retention and time-series pruning,
/// <c>DynamicCollection.TruncateAsync</c>) do not retry and can throw it; run them again.
/// </para>
/// <para>
/// In multi-process mode (<c>PageFileConfig.AllowMultiProcessAccess</c>) a commit that writes pages is
/// also rejected with this exception when the commits of other processes in the shared WAL could not
/// be read, so that it cannot be checked against them.
/// </para>
/// <para>
/// Derives from <see cref="InvalidOperationException"/>, so code that already catches that type
/// for commit failures keeps working.
/// </para>
/// </remarks>
public class BLiteTransactionConflictException : InvalidOperationException
{
/// <summary>Creates the exception for <paramref name="transactionId"/> and the first conflicting page.</summary>
internal BLiteTransactionConflictException(ulong transactionId, uint pageId)
: this(transactionId, pageId,
$"Transaction {transactionId} conflicts with a transaction that committed first: page {pageId} " +
"was changed by both. The transaction was rolled back; run it again to retry.")
{
}

private BLiteTransactionConflictException(ulong transactionId, uint pageId, string message)
: base(message)
{
TransactionId = transactionId;
PageId = pageId;
}

/// <summary>
/// Creates the exception for a commit that writes <paramref name="pageId"/> (its first page) while
/// this engine could not replay what other processes committed to the shared WAL.
/// </summary>
internal static BLiteTransactionConflictException SharedWalNotReplayed(ulong transactionId, uint pageId)
=> new(transactionId, pageId,
$"Transaction {transactionId} was not committed: the commits of other processes in the shared WAL " +
$"could not be read, so its pages (first: page {pageId}) cannot be checked against them. " +
"The transaction was rolled back; run it again to retry.");

/// <summary>The transaction that was rolled back.</summary>
public ulong TransactionId { get; }

/// <summary>The first page found that both transactions changed, or the first page of a commit that could not be checked.</summary>
public uint PageId { get; }
}
Loading
Loading