Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,10 @@ var config = new PageFileConfig { AllowMultiProcessAccess = true };
using var db = new AppDb("shared.db", config); // open from multiple processes
```

Each transaction starts by replaying what other processes have committed since. If a commit in the same process is running, it waits up to `LockTimeout.ReadTimeoutMs` for it; after that it starts anyway and may not see those other commits until the next transaction. A checkpoint writes its pages under the cross-process writer lock, so commits in every process wait for those writes (not for the flush that follows).

Every process that opens the database must run a BLite version later than 5.2.3. Older versions don't track the WAL generation in the `.wal-shm`, so in a mixed deployment a process can still read, and checkpoint back over newer data, pages from a WAL another process has already checkpointed and truncated ([#197](https://github.com/EntglDb/BLite/issues/197)). Commit through `Transaction.CommitAsync` (group commit): the direct commit path `CommitTransactionAsync(ulong)` and two-phase commit (`PrepareTransactionAsync`, `MarkTransactionCommittedAsync`) throw `NotSupportedException` in multi-process mode ([#188](https://github.com/EntglDb/BLite/issues/188)).

### �📡 Change Data Capture (CDC)
Real-time event streaming for database changes with transactional consistency.

Expand Down
10 changes: 9 additions & 1 deletion roadmap/v5/MULTI_PROCESS_WAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ Offset Size Field
24 8 WalEndOffset [last byte written to WAL, Interlocked]
32 8 CheckpointedOffset [last byte safely checkpointed, Interlocked]
40 4 WriterOwnerPid [0 = unlocked]
44 20 Reserved [pad to 64 bytes]
44 4 Reserved
48 8 WalGeneration [incremented on every WAL truncation, Interlocked]
56 8 Reserved [pad to 64 bytes]
─────────────────────────────────────────────────────────
64 MaxReaders × 16 Reader Slot Array
Per slot: [long ProcessId][long MaxReadOffset]
Expand All @@ -146,6 +148,12 @@ Offset Size Field

The WAL index is a double-buffered open-addressing hash table mapping `uint pageId → long walByteOffset`. The double-buffer allows the writer to rebuild one half while readers continue using the other, then atomically flip a pointer in the SHM header.

**Version compatibility.** `WalGeneration` was added in a reserved field without changing `Version`, so older processes can open the same `.wal-shm`, but they neither increment it when they truncate the WAL nor discard what they replayed when another process does. Every process that opens the database must therefore run a BLite version later than 5.2.3; in a mixed deployment the older processes keep the bug described in [#197](https://github.com/EntglDb/BLite/issues/197) (stale pages read and checkpointed back after another process truncated the WAL).

**Configuration mismatch.** A well-formed `.wal-shm` whose page size or reader-slot count differs from the opening process's configuration is re-initialised only if nothing shows it in use (a WAL end offset of 0, no live writer or reader PID); otherwise `Open` throws `InvalidOperationException`. On re-initialisation `WalGeneration` is incremented and `NextTransactionId` kept, so neither ever goes back.

**Commit paths.** Only group commit appends under the cross-process writer lock, after catching up with the shared WAL, and publishes the new end offset. The direct commit path `CommitTransactionAsync(ulong)` and two-phase commit (`PrepareTransactionAsync`, `MarkTransactionCommittedAsync`) do none of that and throw `NotSupportedException` in multi-process mode ([#188](https://github.com/EntglDb/BLite/issues/188)).

---

## Platform Matrix
Expand Down
7 changes: 7 additions & 0 deletions src/BLite.Core/Metrics/MetricsDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ internal sealed class MetricsDispatcher : IDisposable
// ── Checkpoint counters ─────────────────────────────────────────────────
private long _checkpoints;
private long _checkpointLatencySum; // microseconds
private long _checkpointsFailed;

// ── Global collection counters ──────────────────────────────────────────
private long _inserts;
Expand Down Expand Up @@ -185,6 +186,7 @@ public MetricsSnapshot GetSnapshot()
GroupCommitAvgBatchSize = gcBatches > 0 ? (double)gcSizeSum / gcBatches : 0,
CheckpointsTotal = cpCount,
AvgCheckpointLatencyUs = cpCount > 0 ? (double)cpLatSum / cpCount : 0,
CheckpointsFailedTotal = Interlocked.Read(ref _checkpointsFailed),
InsertsTotal = inserts,
UpdatesTotal = updates,
DeletesTotal = deletes,
Expand Down Expand Up @@ -247,6 +249,11 @@ private void Aggregate(MetricEvent evt)
break;

case MetricEventType.Checkpoint:
if (!evt.Success)
{
Interlocked.Increment(ref _checkpointsFailed);
break;
}
Interlocked.Increment(ref _checkpoints);
Interlocked.Add(ref _checkpointLatencySum, micros);
break;
Expand Down
7 changes: 7 additions & 0 deletions src/BLite.Core/Metrics/MetricsSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ public sealed class MetricsSnapshot
/// <summary>Average checkpoint duration in microseconds. 0 if no checkpoints yet.</summary>
public double AvgCheckpointLatencyUs { get; init; }

/// <summary>
/// Total number of checkpoints that wrote nothing because they failed: a lock timeout, an
/// error, or (multi-process mode) a shared WAL that could not be replayed first. Not included
/// in <see cref="CheckpointsTotal"/> or <see cref="AvgCheckpointLatencyUs"/>.
/// </summary>
public long CheckpointsFailedTotal { get; init; }

// ── Collection operations ───────────────────────────────────────────────

/// <summary>Total document inserts across all collections.</summary>
Expand Down
58 changes: 13 additions & 45 deletions src/BLite.Core/Storage/StorageEngine.Pages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,11 @@ public void ReadPage(uint pageId, ulong? transactionId, Span<byte> destination)
// When another process committed a page that our local _walIndex doesn't have,
// consult the SHM hash table to find the WAL byte offset, then read the page
// data directly from the WAL file.
if (_shm != null && _wal is Transactions.WriteAheadLog walFile)
if (ReadFromSharedWal(pageId) is { } pageData)
{
long walOffset = _shm.LookupPageOffset(pageId);
if (walOffset > 0)
{
var pageData = walFile.ReadPageAt(walOffset, pageId);
if (pageData != null)
{
// Populate local _walIndex and _walOffsets to amortise future reads
// and ensure CheckpointAsync respects the reader boundary (Phase 6).
_walIndex[pageId] = pageData;
_walOffsets?[pageId] = walOffset;
var length = Math.Min(pageData.Length, destination.Length);
pageData.AsSpan(0, length).CopyTo(destination);
return;
}
}
var length = Math.Min(pageData.Length, destination.Length);
pageData.AsSpan(0, length).CopyTo(destination);
return;
}

// 3. Read committed baseline from PageFile
Expand Down Expand Up @@ -95,21 +83,11 @@ public void ReadPageHeader(uint pageId, ulong? transactionId, Span<byte> destina
}

// Cross-process WAL lookup (Phase 4): check SHM index on miss.
if (_shm != null && _wal is Transactions.WriteAheadLog walFileH)
if (ReadFromSharedWal(pageId) is { } pageData)
{
long walOffset = _shm.LookupPageOffset(pageId);
if (walOffset > 0)
{
var pageData = walFileH.ReadPageAt(walOffset, pageId);
if (pageData != null)
{
_walIndex[pageId] = pageData;
_walOffsets?[pageId] = walOffset;
var length = Math.Min(pageData.Length, destination.Length);
pageData.AsSpan(0, length).CopyTo(destination);
return;
}
}
var length = Math.Min(pageData.Length, destination.Length);
pageData.AsSpan(0, length).CopyTo(destination);
return;
}

GetPageFile(pageId, out var physId).ReadPageHeader(physId, destination);
Expand Down Expand Up @@ -200,25 +178,15 @@ public ValueTask ReadPageAsync(uint pageId, ulong? transactionId, Memory<byte> d
}

// 2b. Cross-process WAL index lookup (Phase 4) — synchronous read via RandomAccess.
if (_shm != null && _wal is Transactions.WriteAheadLog walFileAsync)
if (ReadFromSharedWal(pageId) is { } pageData)
{
long walOffset = _shm.LookupPageOffset(pageId);
if (walOffset > 0)
{
var pageData = walFileAsync.ReadPageAt(walOffset, pageId);
if (pageData != null)
{
_walIndex[pageId] = pageData;
_walOffsets?[pageId] = walOffset;
var length = Math.Min(pageData.Length, destination.Length);
pageData.AsMemory(0, length).CopyTo(destination);
var length = Math.Min(pageData.Length, destination.Length);
pageData.AsMemory(0, length).CopyTo(destination);
#if NET5_0_OR_GREATER
return ValueTask.CompletedTask;
return ValueTask.CompletedTask;
#else
return default;
return default;
#endif
}
}
}

// 3. PageFile — true async OS read
Expand Down
Loading
Loading