From 1badf573c030a200d75b69761d9004a753078157 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:41:22 +0000 Subject: [PATCH 1/3] Initial plan From 7f3273b7225b769f8797e1b2d31a2135fc9caa82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:59:42 +0000 Subject: [PATCH 2/3] Add IPageStorage/IWriteAheadLog abstractions, in-memory backends, CreateInMemory factory, and WASM roadmap Agent-Logs-Url: https://github.com/EntglDb/BLite/sessions/e4b3c63e-3286-4d25-9134-1f34c9e7100d Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com> --- WASM_SUPPORT.md | 252 ++++++++++++++ src/BLite.Core/BLiteEngine.cs | 38 +++ src/BLite.Core/DocumentDbContext.cs | 28 ++ src/BLite.Core/Storage/IPageStorage.cs | 65 ++++ src/BLite.Core/Storage/MemoryPageStorage.cs | 203 +++++++++++ src/BLite.Core/Storage/PageFile.cs | 3 +- .../Storage/StorageEngine.Memory.cs | 10 +- src/BLite.Core/Storage/StorageEngine.cs | 59 +++- src/BLite.Core/Transactions/IWriteAheadLog.cs | 45 +++ .../Transactions/MemoryWriteAheadLog.cs | 182 ++++++++++ src/BLite.Core/Transactions/WriteAheadLog.cs | 3 +- tests/BLite.Tests/InMemoryStorageTests.cs | 315 ++++++++++++++++++ 12 files changed, 1189 insertions(+), 14 deletions(-) create mode 100644 WASM_SUPPORT.md create mode 100644 src/BLite.Core/Storage/IPageStorage.cs create mode 100644 src/BLite.Core/Storage/MemoryPageStorage.cs create mode 100644 src/BLite.Core/Transactions/IWriteAheadLog.cs create mode 100644 src/BLite.Core/Transactions/MemoryWriteAheadLog.cs create mode 100644 tests/BLite.Tests/InMemoryStorageTests.cs diff --git a/WASM_SUPPORT.md b/WASM_SUPPORT.md new file mode 100644 index 0000000..5607b54 --- /dev/null +++ b/WASM_SUPPORT.md @@ -0,0 +1,252 @@ +# WASM Support — Design & Implementation Roadmap + +This document captures the analysis of the [WASM support request](https://github.com/EntglDb/BLite/issues) and breaks it into separate, actionable sub-issues. Each issue is self-contained and can be implemented and reviewed independently. + +--- + +## Background + +BLite's current storage stack (v4.x) relies on: + +| Component | Implementation | WASM blocker? | +|---|---|---| +| Page storage | `PageFile` — memory-mapped file (`MemoryMappedFile`) | ✅ Yes — `MemoryMappedFile` is not available in browsers | +| Write-ahead log | `WriteAheadLog` — sequential `FileStream` | ✅ Yes — `FileStream` is not available in browsers | +| Directory helpers | `Directory.CreateDirectory`, `File.Exists`, ... | ✅ Yes — filesystem APIs are not available in browsers | + +The maintainer noted (in the original thread) that the engine must be decoupled from its wrappers before WASM storage backends can be plugged in. + +--- + +## What Has Been Implemented (v4.3 — this PR) + +The foundational abstraction layer has been added: + +### `IPageStorage` (new interface — `src/BLite.Core/Storage/IPageStorage.cs`) + +``` +IPageStorage +├── int PageSize +├── uint NextPageId +├── void Open() +├── void ReadPage(uint pageId, Span destination) +├── void ReadPageHeader(uint pageId, Span destination) +├── ValueTask ReadPageAsync(uint pageId, Memory destination, CancellationToken ct) +├── void WritePage(uint pageId, ReadOnlySpan source) +├── uint AllocatePage() +├── void FreePage(uint pageId) +├── void Flush() +├── Task FlushAsync(CancellationToken ct) +└── Task BackupAsync(string destinationPath, CancellationToken ct) +``` + +`PageFile` now implements `IPageStorage`. No existing behaviour has changed. + +### `MemoryPageStorage` (new class — `src/BLite.Core/Storage/MemoryPageStorage.cs`) + +A `ConcurrentDictionary`-backed, fully in-memory implementation of `IPageStorage`: +- Zero file-system dependencies (WASM compatible today) +- Suitable for unit tests, ephemeral caches, and in-browser WASM apps + +### `IWriteAheadLog` (new interface — `src/BLite.Core/Transactions/IWriteAheadLog.cs`) + +``` +IWriteAheadLog +├── ValueTask WriteBeginRecordAsync(ulong transactionId, CancellationToken ct) +├── ValueTask WriteCommitRecordAsync(ulong transactionId, CancellationToken ct) +├── ValueTask WriteAbortRecordAsync(ulong transactionId, CancellationToken ct) +├── ValueTask WriteDataRecordAsync(ulong transactionId, uint pageId, ReadOnlyMemory afterImage, CancellationToken ct) +├── Task FlushAsync(CancellationToken ct) +├── long GetCurrentSize() +├── Task TruncateAsync(CancellationToken ct) +└── List ReadAll() +``` + +`WriteAheadLog` now implements `IWriteAheadLog`. No existing behaviour has changed. + +### `MemoryWriteAheadLog` (new class — `src/BLite.Core/Transactions/MemoryWriteAheadLog.cs`) + +An in-memory, `List`-backed WAL implementation: +- All records stored in process memory — no file I/O +- `FlushAsync` is a no-op (records survive until `TruncateAsync` or disposal) +- Full `ReadAll()` support for recovery path compatibility + +### `StorageEngine` pluggable constructor (updated — `src/BLite.Core/Storage/StorageEngine.cs`) + +```csharp +// New constructor — accepts any IPageStorage + IWriteAheadLog: +public StorageEngine(IPageStorage pageStorage, IWriteAheadLog wal) +``` + +The existing `StorageEngine(string databasePath, PageFileConfig config)` is completely unchanged. + +### `BLiteEngine.CreateInMemory()` (new factory — `src/BLite.Core/BLiteEngine.cs`) + +```csharp +// Creates a fully in-memory BLiteEngine — no file system required: +var engine = BLiteEngine.CreateInMemory(); +// Optional page size and KV options: +var engine = BLiteEngine.CreateInMemory(pageSize: 8192); +``` + +### `DocumentDbContext` pluggable constructor (updated — `src/BLite.Core/DocumentDbContext.cs`) + +```csharp +// Subclasses can now use in-memory storage: +protected DocumentDbContext(StorageEngine storage, BLiteKvOptions? kvOptions = null) +``` + +--- + +## Remaining Sub-Issues + +The following issues should be tracked separately and implemented in order. + +--- + +### Issue 1 — OPFS Storage Backend for WASM (`BLite.Wasm.Opfs`) + +**Scope:** Implement `OpfsPageStorage : IPageStorage` that stores pages in the browser's +[Origin Private File System (OPFS)](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system). + +**Motivation:** +OPFS has the highest throughput of all browser persistence APIs (comparable to native file I/O +in benchmarks). It is supported in Chrome 102+, Firefox 111+, and Safari 15.2+ in dedicated +worker contexts. + +**Implementation sketch:** +```csharp +// src/BLite.Wasm/Storage/OpfsPageStorage.cs +public sealed class OpfsPageStorage : IPageStorage +{ + // Uses JavaScript interop via [JSImport] / [DynamicDependency] to call + // the OPFS SyncAccessHandle (synchronous, high-perf) in a Worker thread. + // Pages are stored as sequential regions in a single OPFS file. + // ReadPage / WritePage map directly to ReadSync / WriteSync on the handle. +} +``` + +**Project:** New `src/BLite.Wasm/BLite.Wasm.csproj` +- Target: `net8.0-browser` (or `net9.0-browser`) +- References `BLite.Core` +- Depends on `Microsoft.AspNetCore.Components.WebAssembly` + +**References:** +- [wa-sqlite OPFS benchmark](https://github.com/rhashimoto/wa-sqlite/tree/master/src/examples#vfs-comparison) +- [OPFS SyncAccessHandle spec](https://fs.spec.whatwg.org/#api-filesystemsyncaccesshandle) + +--- + +### Issue 2 — IndexedDB Storage Backend for WASM (`BLite.Wasm.IndexedDb`) + +**Scope:** Implement `IndexedDbPageStorage : IPageStorage` backed by the browser's +[IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API). + +**Motivation:** +IndexedDB is universally supported (all modern browsers, including Safari 7+) and persists +across sessions. Throughput is lower than OPFS but it is the safest choice for maximum +compatibility, especially in main-thread Blazor WASM contexts where OPFS Workers are not +readily available. + +**Implementation sketch:** +```csharp +// Pages stored as Uint8Array blobs keyed by (databaseName, pageId) +// in an IndexedDB object store. +// ReadPageAsync / WritePageAsync use [JSImport] to call the browser IDB API. +public sealed class IndexedDbPageStorage : IPageStorage +{ + // Read/write are async; the synchronous ReadPage/WritePage overloads + // block using a TaskCompletionSource pattern (acceptable in WASM + // where the main thread uses cooperative scheduling). +} +``` + +**References:** +- [MDN IndexedDB Guide](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB) + +--- + +### Issue 3 — WASM-targeted WAL: `OpfsWriteAheadLog` / `IndexedDbWriteAheadLog` + +**Scope:** WAL implementations that persist records to OPFS or IndexedDB, enabling crash +recovery in browser contexts. + +**Motivation:** +`MemoryWriteAheadLog` (added in this PR) has no persistence — if the browser tab or Worker +crashes, un-checkpointed data is lost. A browser-persistent WAL closes that gap. + +**Implementation sketch:** +- `OpfsWriteAheadLog : IWriteAheadLog` — appends records to an OPFS file using + `FileSystemSyncAccessHandle.write()`. +- `IndexedDbWriteAheadLog : IWriteAheadLog` — stores WAL records as IndexedDB + key/value entries; `TruncateAsync` deletes all entries in a single IDB transaction. + +--- + +### Issue 4 — `BLite.Wasm` NuGet Package + +**Scope:** Ship a purpose-built `BLite.Wasm` NuGet package targeting `net8.0-browser` +(or `net9.0-browser`) that bundles: +- `OpfsPageStorage` (primary recommendation) +- `IndexedDbPageStorage` (compatibility fallback) +- `OpfsWriteAheadLog` / `IndexedDbWriteAheadLog` +- Convenience factory methods: + ```csharp + // Auto-selects OPFS when available, falls back to IndexedDB + var engine = await BLiteWasm.CreateAsync("mydb"); + ``` +- A Blazor service extension: + ```csharp + // In Program.cs of a Blazor WASM app: + builder.Services.AddBLiteWasm("mydb"); + ``` + +--- + +### Issue 5 — WASM Demo & Documentation + +**Scope:** Provide an end-to-end example of BLite running in a Blazor WASM app: +- `samples/BLite.BlazorWasm/` — minimal Blazor WASM app storing and querying BSON documents + entirely in the browser using the OPFS or IndexedDB backend. +- Update `README.md` with a WASM section. +- Update `BENCHMARKS.md` with WASM throughput numbers (OPFS vs IndexedDB vs in-memory). + +--- + +## Recommended Sequencing + +``` +[Done] Issue 0 Storage abstraction (IPageStorage, IWriteAheadLog, MemoryPageStorage, MemoryWriteAheadLog) +[ ] Issue 3 Browser WAL implementations (OPFS / IndexedDB) +[ ] Issue 1 OPFS page storage backend +[ ] Issue 2 IndexedDB page storage backend (compatibility fallback) +[ ] Issue 4 BLite.Wasm NuGet package + factory API +[ ] Issue 5 Blazor WASM sample + docs +``` + +--- + +## Testing Strategy + +Each backend should be verified by: +1. Running the existing `InMemoryStorageTests` suite against the new backend (swap `MemoryPageStorage` + for the new implementation). +2. A Playwright / browser automation test that exercises `BLiteEngine.CreateInMemory()` inside a + `dotnet-wasm` test harness. +3. Throughput benchmarks comparing OPFS, IndexedDB, and in-memory. + +--- + +## MessagePack / MemoryPack Serialisation (Separate Track) + +The original issue also raised the question of why BLite uses C-BSON instead of MessagePack +or MemoryPack. This is a separate concern from WASM storage and should be tracked as an +independent issue: + +- **MessagePack engine** — Replace or augment the BSON serialisation layer with MessagePack-CSharp. + Smaller on-disk document size for mixed-type data; excellent AOT compatibility. +- **MemoryPack engine** — Zero-copy, struct-layout serialisation for pure C# workloads. + Potentially the fastest option for query-heavy, schema-stable data. + +Both would implement a new `IDocumentSerializer` interface (to be designed) so that the +storage layer and serialisation layer remain independently swappable. diff --git a/src/BLite.Core/BLiteEngine.cs b/src/BLite.Core/BLiteEngine.cs index 8addc96..4ceb48a 100644 --- a/src/BLite.Core/BLiteEngine.cs +++ b/src/BLite.Core/BLiteEngine.cs @@ -72,6 +72,44 @@ public BLiteEngine(string databasePath, PageFileConfig config, BLiteKvOptions? k _kvStore = new BLiteKvStore(_storage, kvOptions); } + /// + /// Internal constructor used by and other factory methods + /// that supply a pre-built . + /// + internal BLiteEngine(StorageEngine storage, BLiteKvOptions? kvOptions = null) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _kvStore = new BLiteKvStore(_storage, kvOptions); + } + + /// + /// Creates a fully in-memory with no file-system dependencies. + /// All data is stored in process memory and is lost when the engine is disposed or the + /// process exits. + /// + /// This mode is ideal for: + /// + /// Unit and integration tests that should not touch the file system. + /// Ephemeral caches or temporary working sets. + /// Browser-hosted .NET WASM applications, as a foundation before a full + /// IndexedDB/OPFS backend is available. + /// + /// + /// + /// + /// Page size in bytes. Defaults to (16 KB). + /// Use (8 KB) for workloads with many small documents. + /// + /// Optional Key-Value store configuration. + public static BLiteEngine CreateInMemory(int pageSize = 16384, BLiteKvOptions? kvOptions = null) + { + var pageStorage = new MemoryPageStorage(pageSize); + pageStorage.Open(); + var wal = new MemoryWriteAheadLog(); + var storageEngine = new StorageEngine(pageStorage, wal); + return new BLiteEngine(storageEngine, kvOptions); + } + #endregion #region Session Management diff --git a/src/BLite.Core/DocumentDbContext.cs b/src/BLite.Core/DocumentDbContext.cs index 68f6999..d90b27b 100644 --- a/src/BLite.Core/DocumentDbContext.cs +++ b/src/BLite.Core/DocumentDbContext.cs @@ -83,6 +83,34 @@ protected DocumentDbContext(string databasePath, PageFileConfig config, BLiteKvO InitializeCollections(); } + /// + /// Creates a database context backed by a pre-built . + /// Use this constructor to supply a custom backend, such as + /// for in-memory or WASM scenarios. + /// + /// Example — in-memory context: + /// + /// var pageStorage = new MemoryPageStorage(16384); + /// pageStorage.Open(); + /// var wal = new MemoryWriteAheadLog(); + /// var engine = new StorageEngine(pageStorage, wal); + /// var ctx = new MyDbContext(engine); + /// + /// + /// + protected DocumentDbContext(StorageEngine storage, BLiteKvOptions? kvOptions = null) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _cdc = new CDC.ChangeStreamDispatcher(); + _storage.RegisterCdc(_cdc); + _kvStore = new BLiteKvStore(_storage, kvOptions); + + var modelBuilder = new ModelBuilder(); + OnModelCreating(modelBuilder); + _model = modelBuilder.GetEntityBuilders(); + InitializeCollections(); + } + /// /// Provides access to the embedded Key-Value store that shares the same database file. /// diff --git a/src/BLite.Core/Storage/IPageStorage.cs b/src/BLite.Core/Storage/IPageStorage.cs new file mode 100644 index 0000000..8224873 --- /dev/null +++ b/src/BLite.Core/Storage/IPageStorage.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace BLite.Core.Storage; + +/// +/// Abstraction over page-based storage backends. +/// The default file-system implementation is . +/// Alternative implementations (e.g. for in-memory or +/// browser storage) can be provided to enable scenarios such as WASM, unit testing, +/// and ephemeral databases. +/// +public interface IPageStorage : IDisposable +{ + /// Page size in bytes. All pages are the same fixed size. + int PageSize { get; } + + /// Total number of pages ever allocated (not all may currently be in use). + uint NextPageId { get; } + + /// Opens (or initialises) the storage. Must be called once before any I/O. + void Open(); + + /// + /// Reads a full page by its ID into . + /// must be at least bytes. + /// + void ReadPage(uint pageId, Span destination); + + /// + /// Reads up to .Length bytes from the start of a page. + /// Use this to read only the page header without copying the entire page payload. + /// must not exceed bytes. + /// + void ReadPageHeader(uint pageId, Span destination); + + /// Reads a full page asynchronously into . + ValueTask ReadPageAsync(uint pageId, Memory destination, CancellationToken cancellationToken = default); + + /// + /// Writes a page at the given ID from . + /// must be at least bytes. + /// + void WritePage(uint pageId, ReadOnlySpan source); + + /// Allocates a new page (reusing a free page if one is available) and returns its ID. + uint AllocatePage(); + + /// Returns a page to the free list so it can be reused by future allocations. + void FreePage(uint pageId); + + /// Flushes all pending writes to their durable destination (synchronous). + void Flush(); + + /// Flushes all pending writes to their durable destination (asynchronous). + Task FlushAsync(CancellationToken cancellationToken = default); + + /// + /// Creates a consistent backup of this storage to . + /// Throws for backends that do not support + /// file-based backup (e.g. ). + /// + Task BackupAsync(string destinationPath, CancellationToken cancellationToken = default); +} diff --git a/src/BLite.Core/Storage/MemoryPageStorage.cs b/src/BLite.Core/Storage/MemoryPageStorage.cs new file mode 100644 index 0000000..c2021cf --- /dev/null +++ b/src/BLite.Core/Storage/MemoryPageStorage.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace BLite.Core.Storage; + +/// +/// In-memory page storage backend. Stores all pages in a +/// with no file-system dependencies, making it suitable for: +/// +/// Ephemeral (non-persistent) embedded databases +/// Unit and integration tests that must not touch the file system +/// Browser-hosted .NET WASM applications (as a foundation before a full IndexedDB/OPFS backend) +/// +/// +/// Create an in-memory via . +/// +/// +public sealed class MemoryPageStorage : IPageStorage +{ + private readonly int _pageSize; + private readonly ConcurrentDictionary _pages = new(); + private readonly Stack _freeList = new(); + private readonly object _allocationLock = new(); + private uint _nextPageId; + private bool _disposed; + + /// + /// Initialises a new in-memory page storage with the given page size. + /// Call to initialise the header pages before use. + /// + /// Fixed size in bytes of every page. + public MemoryPageStorage(int pageSize) + { + if (pageSize < 512) + throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be at least 512 bytes."); + _pageSize = pageSize; + } + + /// + public int PageSize => _pageSize; + + /// + public uint NextPageId => _nextPageId; + + /// + /// Initialises page 0 (file header) and page 1 (collection metadata) if they have not + /// already been written. Subsequent calls are no-ops. + /// + public void Open() + { + if (_nextPageId > 0) + return; // Already open + + // Page 0: File Header + var headerPage = new byte[_pageSize]; + var header = new PageHeader + { + PageId = 0, + PageType = PageType.Header, + FreeBytes = (ushort)(_pageSize - 32), + NextPageId = 0, + TransactionId = 0, + Checksum = 0, + FormatVersion = PageHeader.CurrentFormatVersion, + DictionaryRootPageId = 0, + KvRootPageId = 0 + }; + header.WriteTo(headerPage); + _pages[0] = headerPage; + + // Page 1: Collection Metadata (slotted page) + var metaPage = new byte[_pageSize]; + var metaHeader = new SlottedPageHeader + { + PageId = 1, + PageType = PageType.Collection, + SlotCount = 0, + FreeSpaceStart = SlottedPageHeader.Size, + FreeSpaceEnd = (ushort)_pageSize, + NextOverflowPage = 0, + TransactionId = 0 + }; + metaHeader.WriteTo(metaPage); + _pages[1] = metaPage; + + _nextPageId = 2; + } + + /// + public void ReadPage(uint pageId, Span destination) + { + ThrowIfDisposed(); + if (destination.Length < _pageSize) + throw new ArgumentException($"Destination must be at least {_pageSize} bytes."); + + if (_pages.TryGetValue(pageId, out var page)) + page.AsSpan(0, _pageSize).CopyTo(destination); + else + destination.Slice(0, _pageSize).Clear(); // Return zeroes for uninitialized pages + } + + /// + public void ReadPageHeader(uint pageId, Span destination) + { + ThrowIfDisposed(); + if (destination.Length > _pageSize) + throw new ArgumentException($"Destination must not exceed {_pageSize} bytes."); + + if (_pages.TryGetValue(pageId, out var page)) + page.AsSpan(0, destination.Length).CopyTo(destination); + else + destination.Clear(); + } + + /// + public ValueTask ReadPageAsync(uint pageId, Memory destination, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ReadPage(pageId, destination.Span.Slice(0, _pageSize)); +#if NET5_0_OR_GREATER + return ValueTask.CompletedTask; +#else + return default; +#endif + } + + /// + public void WritePage(uint pageId, ReadOnlySpan source) + { + ThrowIfDisposed(); + if (source.Length < _pageSize) + throw new ArgumentException($"Source must be at least {_pageSize} bytes."); + + // Reuse the existing buffer if one already exists for this page. + if (_pages.TryGetValue(pageId, out var existing)) + { + source.Slice(0, _pageSize).CopyTo(existing); + } + else + { + var copy = new byte[_pageSize]; + source.Slice(0, _pageSize).CopyTo(copy); + _pages[pageId] = copy; + } + } + + /// + public uint AllocatePage() + { + ThrowIfDisposed(); + lock (_allocationLock) + { + if (_freeList.Count > 0) + return _freeList.Pop(); + + return _nextPageId++; + } + } + + /// + public void FreePage(uint pageId) + { + ThrowIfDisposed(); + if (pageId == 0) + throw new InvalidOperationException("Cannot free the header page (page 0)."); + + lock (_allocationLock) + { + _freeList.Push(pageId); + } + } + + /// No-op: in-memory storage has no durable destination to flush to. + public void Flush() { } + + /// No-op: in-memory storage has no durable destination to flush to. + public Task FlushAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + /// + /// Always thrown — in-memory storage cannot be backed up to a file path. + /// Serialise the data layer and reload it if persistence is required. + /// + public Task BackupAsync(string destinationPath, CancellationToken cancellationToken = default) + => throw new NotSupportedException( + "In-memory storage does not support file-based backup. " + + "Use a file-based PageFile backend if backup is required."); + + /// + public void Dispose() + { + _disposed = true; + _pages.Clear(); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(MemoryPageStorage)); + } +} diff --git a/src/BLite.Core/Storage/PageFile.cs b/src/BLite.Core/Storage/PageFile.cs index 7361e4e..bac602a 100644 --- a/src/BLite.Core/Storage/PageFile.cs +++ b/src/BLite.Core/Storage/PageFile.cs @@ -187,8 +187,9 @@ public static PageFileConfig Server(string databasePath, PageFileConfig? baseCon /// /// Page-based file storage with memory-mapped I/O. /// Manages fixed-size pages for efficient storage and retrieval. +/// Implements — the pluggable storage backend abstraction. /// -public sealed class PageFile : IDisposable +public sealed class PageFile : IPageStorage { private readonly string _filePath; private readonly PageFileConfig _config; diff --git a/src/BLite.Core/Storage/StorageEngine.Memory.cs b/src/BLite.Core/Storage/StorageEngine.Memory.cs index 1457a92..1d7187a 100644 --- a/src/BLite.Core/Storage/StorageEngine.Memory.cs +++ b/src/BLite.Core/Storage/StorageEngine.Memory.cs @@ -75,12 +75,12 @@ public void FreePage(uint pageId) // ----------------------------------------------------------------------- /// - /// Returns the that owns the given (possibly encoded) page ID - /// and outputs the physical (file-local) page number to pass to that file. + /// Returns the that owns the given (possibly encoded) page ID + /// and outputs the physical (file-local) page number to pass to that storage backend. /// Routing is determined entirely from the high bits of : /// no in-memory dictionaries are required and routing survives engine restarts. /// - private PageFile GetPageFile(uint pageId, out uint physicalPageId) + private IPageStorage GetPageFile(uint pageId, out uint physicalPageId) { var fileTag = pageId & SubFileTypeMask; @@ -108,13 +108,13 @@ private PageFile GetPageFile(uint pageId, out uint physicalPageId) => (_collectionSlotToName != null && _collectionSlotToName.TryGetValue(slot, out var name)) ? name : null; - private PageFile GetOrCreateCollectionFile(string collectionName) + private IPageStorage GetOrCreateCollectionFile(string collectionName) { if (_collectionFiles == null) return _pageFile; return _collectionFiles.GetOrAdd(collectionName, name => - new Lazy(() => + new Lazy(() => { var filePath = CollectionFilePath(name); var pf = new PageFile(filePath, AsStandaloneConfig(_config)); diff --git a/src/BLite.Core/Storage/StorageEngine.cs b/src/BLite.Core/Storage/StorageEngine.cs index 4ff651e..63cb8a9 100644 --- a/src/BLite.Core/Storage/StorageEngine.cs +++ b/src/BLite.Core/Storage/StorageEngine.cs @@ -16,9 +16,9 @@ namespace BLite.Core.Storage; /// public sealed partial class StorageEngine : IDisposable { - private readonly PageFile _pageFile; // data: Data, Overflow, Collection, KV, Dictionary, TimeSeries, Metadata - private readonly PageFile? _indexFile; // indices: Index, Vector, Spatial (null = uses _pageFile) - private readonly WriteAheadLog _wal; + private readonly IPageStorage _pageFile; // data: Data, Overflow, Collection, KV, Dictionary, TimeSeries, Metadata + private readonly IPageStorage? _indexFile; // indices: Index, Vector, Spatial (null = uses _pageFile) + private readonly IWriteAheadLog _wal; private CDC.ChangeStreamDispatcher? _cdc; private volatile Metrics.MetricsDispatcher? _metrics; @@ -30,11 +30,11 @@ public sealed partial class StorageEngine : IDisposable // Lazily populated on first read after commit private readonly ConcurrentDictionary _walIndex; - // Collection-per-file: collectionName → PageFile dedicated + // Collection-per-file: collectionName → IPageStorage dedicated // Null if CollectionDataDirectory not configured (embedded mode, single file) - // Lazy ensures the file-open factory runs exactly once per collection, + // Lazy ensures the file-open factory runs exactly once per collection, // even when ConcurrentDictionary.GetOrAdd is called concurrently for the same key. - private readonly ConcurrentDictionary>? _collectionFiles; + private readonly ConcurrentDictionary>? _collectionFiles; // Collection slot registry — only populated in multi-file mode // Maps collection name → slot index (0-63) and slot → name @@ -115,7 +115,7 @@ public StorageEngine(string databasePath, PageFileConfig config) if (config.CollectionDataDirectory != null) { Directory.CreateDirectory(config.CollectionDataDirectory); - _collectionFiles = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + _collectionFiles = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); _collectionNameToSlot = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); _collectionSlotToName = new Dictionary(); _slotsFilePath = Path.Combine(config.CollectionDataDirectory, ".slots"); @@ -157,6 +157,51 @@ public StorageEngine(string databasePath, PageFileConfig config) // _checkpointManager.StartAutoCheckpoint(); } + /// + /// Creates a storage engine backed by pre-built and + /// instances. Use this constructor when you need a + /// non-file-system backend, such as for in-memory + /// or WASM use cases. + /// + /// The supplied must already be opened + /// (i.e. Open() called) before being passed here. + /// + /// + /// Multi-file routing (separate index file, per-collection files) is not available + /// in this mode — all pages share the single instance. + /// + /// + /// Page storage backend (already opened). + /// Write-ahead log implementation. + public StorageEngine(IPageStorage pageStorage, IWriteAheadLog wal) + { + _config = PageFileConfig.Default; + _pageFile = pageStorage ?? throw new ArgumentNullException(nameof(pageStorage)); + _wal = wal ?? throw new ArgumentNullException(nameof(wal)); + + _walCache = new ConcurrentDictionary>(); + _walIndex = new ConcurrentDictionary(); + _activeTransactions = new ConcurrentDictionary(); + _nextTransactionId = 0; + + _writerGate = null; // No admission control needed for single-backend mode. + + _commitChannel = Channel.CreateBounded(new BoundedChannelOptions(4096) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false + }); + _writerTask = Task.Run(() => GroupCommitWriterAsync(_writerCts.Token)); + + // No WAL recovery: the caller provides a fresh backend. + // For MemoryWriteAheadLog this is always correct; for a custom persistent WAL + // the caller is responsible for replaying WAL before constructing the engine. + + InitializeDictionary(); + InitializeKv(); + } + /// /// Page size for this storage engine /// diff --git a/src/BLite.Core/Transactions/IWriteAheadLog.cs b/src/BLite.Core/Transactions/IWriteAheadLog.cs new file mode 100644 index 0000000..9547a04 --- /dev/null +++ b/src/BLite.Core/Transactions/IWriteAheadLog.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace BLite.Core.Transactions; + +/// +/// Abstraction over Write-Ahead Log implementations. +/// The default file-system implementation is . +/// Alternative implementations (e.g. for in-memory or +/// browser storage) can be provided to enable scenarios such as WASM and unit testing. +/// +public interface IWriteAheadLog : IDisposable +{ + /// Appends a BEGIN record for the given transaction. + ValueTask WriteBeginRecordAsync(ulong transactionId, CancellationToken ct = default); + + /// Appends a COMMIT record for the given transaction. + ValueTask WriteCommitRecordAsync(ulong transactionId, CancellationToken ct = default); + + /// Appends an ABORT record for the given transaction. + ValueTask WriteAbortRecordAsync(ulong transactionId, CancellationToken ct = default); + + /// Appends a WRITE (data) record for the given transaction and page. + ValueTask WriteDataRecordAsync(ulong transactionId, uint pageId, ReadOnlyMemory afterImage, CancellationToken ct = default); + + /// Flushes all pending WAL records to their durable destination. + Task FlushAsync(CancellationToken ct = default); + + /// Returns the current byte size of the WAL (used to trigger checkpoint decisions). + long GetCurrentSize(); + + /// + /// Truncates the WAL, discarding all records. + /// Should only be called after a successful checkpoint has applied all committed writes. + /// + Task TruncateAsync(CancellationToken ct = default); + + /// + /// Reads and returns all WAL records (used during crash recovery). + /// Returns an empty list for implementations that do not persist records. + /// + List ReadAll(); +} diff --git a/src/BLite.Core/Transactions/MemoryWriteAheadLog.cs b/src/BLite.Core/Transactions/MemoryWriteAheadLog.cs new file mode 100644 index 0000000..f6570fb --- /dev/null +++ b/src/BLite.Core/Transactions/MemoryWriteAheadLog.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace BLite.Core.Transactions; + +/// +/// In-memory Write-Ahead Log implementation. Records are stored in a +/// in process memory with no file-system dependencies, making it suitable for: +/// +/// Ephemeral (non-persistent) embedded databases +/// Unit and integration tests that must not touch the file system +/// Browser-hosted .NET WASM applications (as a foundation before a full IndexedDB/OPFS backend) +/// +/// +/// Durability guarantees are relaxed: records survive the current process lifetime only. +/// If the process exits, any uncommitted or un-checkpointed data is lost. +/// +/// +public sealed class MemoryWriteAheadLog : IWriteAheadLog +{ + private readonly List _records = new(); + private long _sizeBytes; + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly int _writeTimeoutMs; + private bool _disposed; + + /// + /// Initialises a new in-memory WAL. + /// + /// Timeout in milliseconds for acquiring the internal lock. + public MemoryWriteAheadLog(int writeTimeoutMs = 5_000) + { + _writeTimeoutMs = writeTimeoutMs; + } + + /// + public async ValueTask WriteBeginRecordAsync(ulong transactionId, CancellationToken ct = default) + { + if (!await _lock.WaitAsync(_writeTimeoutMs, ct).ConfigureAwait(false)) + throw new TimeoutException("Timed out acquiring MemoryWriteAheadLog lock."); + try + { + _records.Add(new WalRecord { Type = WalRecordType.Begin, TransactionId = transactionId, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + _sizeBytes += 17; // same fixed size as the file-based implementation + } + finally + { + _lock.Release(); + } + } + + /// + public async ValueTask WriteCommitRecordAsync(ulong transactionId, CancellationToken ct = default) + { + if (!await _lock.WaitAsync(_writeTimeoutMs, ct).ConfigureAwait(false)) + throw new TimeoutException("Timed out acquiring MemoryWriteAheadLog lock."); + try + { + _records.Add(new WalRecord { Type = WalRecordType.Commit, TransactionId = transactionId, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + _sizeBytes += 17; + } + finally + { + _lock.Release(); + } + } + + /// + public async ValueTask WriteAbortRecordAsync(ulong transactionId, CancellationToken ct = default) + { + if (!await _lock.WaitAsync(_writeTimeoutMs, ct).ConfigureAwait(false)) + throw new TimeoutException("Timed out acquiring MemoryWriteAheadLog lock."); + try + { + _records.Add(new WalRecord { Type = WalRecordType.Abort, TransactionId = transactionId, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + _sizeBytes += 17; + } + finally + { + _lock.Release(); + } + } + + /// + public async ValueTask WriteDataRecordAsync(ulong transactionId, uint pageId, ReadOnlyMemory afterImage, CancellationToken ct = default) + { + if (!await _lock.WaitAsync(_writeTimeoutMs, ct).ConfigureAwait(false)) + throw new TimeoutException("Timed out acquiring MemoryWriteAheadLog lock."); + try + { + _records.Add(new WalRecord + { + Type = WalRecordType.Write, + TransactionId = transactionId, + PageId = pageId, + AfterImage = afterImage.ToArray() + }); + _sizeBytes += 17 + afterImage.Length; + } + finally + { + _lock.Release(); + } + } + + /// No-op: in-memory WAL has no durable destination to flush to. + public Task FlushAsync(CancellationToken ct = default) => Task.CompletedTask; + + /// + public long GetCurrentSize() + { + if (!_lock.Wait(_writeTimeoutMs)) + throw new TimeoutException("Timed out acquiring MemoryWriteAheadLog lock."); + try + { + return _sizeBytes; + } + finally + { + _lock.Release(); + } + } + + /// + public async Task TruncateAsync(CancellationToken ct = default) + { + if (!await _lock.WaitAsync(_writeTimeoutMs, ct).ConfigureAwait(false)) + throw new TimeoutException("Timed out acquiring MemoryWriteAheadLog lock."); + try + { + _records.Clear(); + _sizeBytes = 0; + } + finally + { + _lock.Release(); + } + } + + /// + public List ReadAll() + { + if (!_lock.Wait(_writeTimeoutMs)) + throw new TimeoutException("Timed out acquiring MemoryWriteAheadLog lock."); + try + { + return new List(_records); + } + finally + { + _lock.Release(); + } + } + + /// + public void Dispose() + { + if (_disposed) return; + if (_lock.Wait(5_000)) + { + try + { + _records.Clear(); + _disposed = true; + } + finally + { + _lock.Release(); + _lock.Dispose(); + } + } + else + { + _records.Clear(); + _disposed = true; + _lock.Dispose(); + } + GC.SuppressFinalize(this); + } +} diff --git a/src/BLite.Core/Transactions/WriteAheadLog.cs b/src/BLite.Core/Transactions/WriteAheadLog.cs index a4f1483..ac7a97a 100644 --- a/src/BLite.Core/Transactions/WriteAheadLog.cs +++ b/src/BLite.Core/Transactions/WriteAheadLog.cs @@ -15,8 +15,9 @@ public enum WalRecordType : byte /// /// Write-Ahead Log (WAL) for durability and recovery. /// All changes are logged before being applied. +/// Implements — the pluggable WAL abstraction. /// -public sealed class WriteAheadLog : IDisposable +public sealed class WriteAheadLog : IWriteAheadLog { private readonly string _walPath; private FileStream? _walStream; diff --git a/tests/BLite.Tests/InMemoryStorageTests.cs b/tests/BLite.Tests/InMemoryStorageTests.cs new file mode 100644 index 0000000..b448467 --- /dev/null +++ b/tests/BLite.Tests/InMemoryStorageTests.cs @@ -0,0 +1,315 @@ +using BLite.Bson; +using BLite.Core; +using BLite.Core.Storage; +using BLite.Core.Transactions; + +namespace BLite.Tests; + +/// +/// Tests for the in-memory storage backend (, +/// ) and the factory. +/// These tests exercise the full database stack without touching the file system, +/// which is the foundation for WASM support. +/// +public class InMemoryStorageTests +{ + // ─── MemoryPageStorage unit tests ──────────────────────────────────────── + + [Fact] + public void MemoryPageStorage_Open_InitializesPages() + { + using var storage = new MemoryPageStorage(16384); + storage.Open(); + + Assert.Equal(16384, storage.PageSize); + Assert.Equal(2u, storage.NextPageId); // pages 0 and 1 are pre-allocated + + // Page 0 must contain a valid PageHeader + var buf = new byte[16384]; + storage.ReadPage(0, buf); + var header = PageHeader.ReadFrom(buf); + Assert.Equal(PageType.Header, header.PageType); + Assert.Equal(PageHeader.CurrentFormatVersion, header.FormatVersion); + } + + [Fact] + public void MemoryPageStorage_AllocatePage_ReturnsSequentialIds() + { + using var storage = new MemoryPageStorage(16384); + storage.Open(); // _nextPageId = 2 + + var id1 = storage.AllocatePage(); + var id2 = storage.AllocatePage(); + var id3 = storage.AllocatePage(); + + Assert.Equal(2u, id1); + Assert.Equal(3u, id2); + Assert.Equal(4u, id3); + } + + [Fact] + public void MemoryPageStorage_FreePage_ReusesFreePages() + { + using var storage = new MemoryPageStorage(16384); + storage.Open(); + + var id1 = storage.AllocatePage(); // 2 + var id2 = storage.AllocatePage(); // 3 + storage.FreePage(id1); + + var reused = storage.AllocatePage(); + Assert.Equal(id1, reused); + + // After reuse, next allocation should be fresh + var next = storage.AllocatePage(); + Assert.Equal(id2 + 1, next); // id2=3, so next=4 + } + + [Fact] + public void MemoryPageStorage_WriteThenRead_RoundTrips() + { + using var storage = new MemoryPageStorage(16384); + storage.Open(); + + var pageId = storage.AllocatePage(); + var written = new byte[16384]; + written[0] = 0xAB; + written[100] = 0xCD; + written[16383] = 0xEF; + + storage.WritePage(pageId, written); + + var read = new byte[16384]; + storage.ReadPage(pageId, read); + + Assert.Equal(written[0], read[0]); + Assert.Equal(written[100], read[100]); + Assert.Equal(written[16383], read[16383]); + } + + [Fact] + public void MemoryPageStorage_ReadUnallocatedPage_ReturnsZeroes() + { + using var storage = new MemoryPageStorage(16384); + storage.Open(); + + var buf = new byte[16384]; + storage.ReadPage(999, buf); // Page 999 was never written + Assert.All(buf, b => Assert.Equal(0, b)); + } + + [Fact] + public void MemoryPageStorage_ReadPageHeader_ReturnsPrefix() + { + using var storage = new MemoryPageStorage(16384); + storage.Open(); + + // Page 0 header starts at offset 0 — read just the 32-byte PageHeader + var headerBuf = new byte[32]; + storage.ReadPageHeader(0, headerBuf); + var header = PageHeader.ReadFrom(headerBuf); + Assert.Equal(PageType.Header, header.PageType); + } + + [Fact] + public async Task MemoryPageStorage_ReadPageAsync_RoundTrips() + { + using var storage = new MemoryPageStorage(16384); + storage.Open(); + + var pageId = storage.AllocatePage(); + var written = new byte[16384]; + written[42] = 99; + storage.WritePage(pageId, written); + + var read = new byte[16384]; + await storage.ReadPageAsync(pageId, read.AsMemory()); + Assert.Equal(99, read[42]); + } + + [Fact] + public void MemoryPageStorage_FlushAndFlushAsync_AreNoOps() + { + using var storage = new MemoryPageStorage(16384); + storage.Open(); + storage.Flush(); // must not throw + storage.FlushAsync().GetAwaiter().GetResult(); // must not throw + } + + [Fact] + public void MemoryPageStorage_BackupAsync_Throws() + { + using var storage = new MemoryPageStorage(16384); + storage.Open(); + Assert.Throws(() => + storage.BackupAsync("/tmp/backup.db").GetAwaiter().GetResult()); + } + + [Fact] + public void MemoryPageStorage_FreePage0_Throws() + { + using var storage = new MemoryPageStorage(16384); + storage.Open(); + Assert.Throws(() => storage.FreePage(0)); + } + + [Fact] + public void MemoryPageStorage_AfterDispose_Throws() + { + var storage = new MemoryPageStorage(16384); + storage.Open(); + storage.Dispose(); + + var buf = new byte[16384]; + Assert.Throws(() => storage.ReadPage(0, buf)); + } + + // ─── MemoryWriteAheadLog unit tests ────────────────────────────────────── + + [Fact] + public async Task MemoryWriteAheadLog_WriteAndReadAll_RoundTrips() + { + using var wal = new MemoryWriteAheadLog(); + + await wal.WriteBeginRecordAsync(1); + await wal.WriteDataRecordAsync(1, 5, new byte[] { 1, 2, 3 }.AsMemory()); + await wal.WriteCommitRecordAsync(1); + + var records = wal.ReadAll(); + Assert.Equal(3, records.Count); + Assert.Equal(WalRecordType.Begin, records[0].Type); + Assert.Equal(WalRecordType.Write, records[1].Type); + Assert.Equal(5u, records[1].PageId); + Assert.Equal(WalRecordType.Commit, records[2].Type); + } + + [Fact] + public async Task MemoryWriteAheadLog_TruncateAsync_ClearsRecords() + { + using var wal = new MemoryWriteAheadLog(); + await wal.WriteBeginRecordAsync(1); + await wal.WriteCommitRecordAsync(1); + + Assert.True(wal.GetCurrentSize() > 0); + + await wal.TruncateAsync(); + + Assert.Equal(0, wal.GetCurrentSize()); + Assert.Empty(wal.ReadAll()); + } + + [Fact] + public async Task MemoryWriteAheadLog_GetCurrentSize_TracksWrites() + { + using var wal = new MemoryWriteAheadLog(); + + Assert.Equal(0, wal.GetCurrentSize()); + await wal.WriteBeginRecordAsync(1); + Assert.True(wal.GetCurrentSize() > 0); + } + + // ─── BLiteEngine.CreateInMemory integration tests ──────────────────────── + + [Fact] + public async Task CreateInMemory_InsertAndFind_Works() + { + using var engine = BLiteEngine.CreateInMemory(); + var col = engine.GetOrCreateCollection("users"); + + var doc = col.CreateDocument(["_id", "name", "age"], b => b + .AddString("name", "Alice") + .AddInt32("age", 30)); + var id = await col.InsertAsync(doc); + await engine.CommitAsync(); + + var found = await col.FindByIdAsync(id); + Assert.NotNull(found); + Assert.True(found.TryGetInt32("age", out var age)); + Assert.Equal(30, age); + } + + [Fact] + public async Task CreateInMemory_MultipleCollections_Work() + { + using var engine = BLiteEngine.CreateInMemory(); + var users = engine.GetOrCreateCollection("users"); + var orders = engine.GetOrCreateCollection("orders"); + + await users.InsertAsync(users.CreateDocument(["_id", "name"], b => b.AddString("name", "Bob"))); + await orders.InsertAsync(orders.CreateDocument(["_id", "item"], b => b.AddString("item", "Widget"))); + await engine.CommitAsync(); + + Assert.Equal(1, await users.CountAsync()); + Assert.Equal(1, await orders.CountAsync()); + } + + [Fact] + public async Task CreateInMemory_ExplicitTransaction_CommitMakesDataVisible() + { + using var engine = BLiteEngine.CreateInMemory(); + var col = engine.GetOrCreateCollection("items"); + + var txn = await engine.BeginTransactionAsync(); + var doc = col.CreateDocument(["_id", "key"], b => b.AddString("key", "value")); + await col.InsertAsync(doc, txn); + await txn.CommitAsync(); + + Assert.Equal(1, await col.CountAsync()); + } + + [Fact] + public async Task CreateInMemory_ExplicitTransaction_RollbackDiscardsData() + { + using var engine = BLiteEngine.CreateInMemory(); + var col = engine.GetOrCreateCollection("items"); + + var txn = await engine.BeginTransactionAsync(); + var doc = col.CreateDocument(["_id", "key"], b => b.AddString("key", "value")); + await col.InsertAsync(doc, txn); + await txn.RollbackAsync(); + + Assert.Equal(0, await col.CountAsync()); + } + + [Fact] + public async Task CreateInMemory_CustomPageSize_Works() + { + using var engine = BLiteEngine.CreateInMemory(pageSize: 8192); + var col = engine.GetOrCreateCollection("data"); + + var doc = col.CreateDocument(["_id", "x"], b => b.AddInt32("x", 42)); + await col.InsertAsync(doc); + await engine.CommitAsync(); + + Assert.Equal(1, await col.CountAsync()); + } + + [Fact] + public async Task CreateInMemory_DataNotPersisted_AfterDispose() + { + // Create, insert, dispose, then create again — data must be gone. + var engine1 = BLiteEngine.CreateInMemory(); + var col1 = engine1.GetOrCreateCollection("data"); + var doc = col1.CreateDocument(["_id", "id"], b => b.AddInt32("id", 1)); + await col1.InsertAsync(doc); + await engine1.CommitAsync(); + Assert.Equal(1, await col1.CountAsync()); + engine1.Dispose(); + + // New engine — fresh memory — data is gone. + using var engine2 = BLiteEngine.CreateInMemory(); + var col2 = engine2.GetOrCreateCollection("data"); + Assert.Equal(0, await col2.CountAsync()); + } + + [Fact] + public void CreateInMemory_NoFileCreated() + { + // Verify that using an in-memory engine doesn't create any .db files. + var tempDir = Path.GetTempPath(); + var before = Directory.GetFiles(tempDir, "*.db").Length; + using (var _ = BLiteEngine.CreateInMemory()) { } + var after = Directory.GetFiles(tempDir, "*.db").Length; + Assert.Equal(before, after); + } +} From 9b6547c80f87821e08824ee441838662efba4cd9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:04:03 +0000 Subject: [PATCH 3/3] Address code review: extract CurrentTimestampMs helper, use _writeTimeoutMs in Dispose, improve test variable name Agent-Logs-Url: https://github.com/EntglDb/BLite/sessions/e4b3c63e-3286-4d25-9134-1f34c9e7100d Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com> --- src/BLite.Core/Transactions/MemoryWriteAheadLog.cs | 10 ++++++---- tests/BLite.Tests/InMemoryStorageTests.cs | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/BLite.Core/Transactions/MemoryWriteAheadLog.cs b/src/BLite.Core/Transactions/MemoryWriteAheadLog.cs index f6570fb..c9db573 100644 --- a/src/BLite.Core/Transactions/MemoryWriteAheadLog.cs +++ b/src/BLite.Core/Transactions/MemoryWriteAheadLog.cs @@ -42,7 +42,7 @@ public async ValueTask WriteBeginRecordAsync(ulong transactionId, CancellationTo throw new TimeoutException("Timed out acquiring MemoryWriteAheadLog lock."); try { - _records.Add(new WalRecord { Type = WalRecordType.Begin, TransactionId = transactionId, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + _records.Add(new WalRecord { Type = WalRecordType.Begin, TransactionId = transactionId, Timestamp = CurrentTimestampMs() }); _sizeBytes += 17; // same fixed size as the file-based implementation } finally @@ -58,7 +58,7 @@ public async ValueTask WriteCommitRecordAsync(ulong transactionId, CancellationT throw new TimeoutException("Timed out acquiring MemoryWriteAheadLog lock."); try { - _records.Add(new WalRecord { Type = WalRecordType.Commit, TransactionId = transactionId, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + _records.Add(new WalRecord { Type = WalRecordType.Commit, TransactionId = transactionId, Timestamp = CurrentTimestampMs() }); _sizeBytes += 17; } finally @@ -74,7 +74,7 @@ public async ValueTask WriteAbortRecordAsync(ulong transactionId, CancellationTo throw new TimeoutException("Timed out acquiring MemoryWriteAheadLog lock."); try { - _records.Add(new WalRecord { Type = WalRecordType.Abort, TransactionId = transactionId, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + _records.Add(new WalRecord { Type = WalRecordType.Abort, TransactionId = transactionId, Timestamp = CurrentTimestampMs() }); _sizeBytes += 17; } finally @@ -158,7 +158,7 @@ public List ReadAll() public void Dispose() { if (_disposed) return; - if (_lock.Wait(5_000)) + if (_lock.Wait(_writeTimeoutMs)) { try { @@ -179,4 +179,6 @@ public void Dispose() } GC.SuppressFinalize(this); } + + private static long CurrentTimestampMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); } diff --git a/tests/BLite.Tests/InMemoryStorageTests.cs b/tests/BLite.Tests/InMemoryStorageTests.cs index b448467..cf2fef6 100644 --- a/tests/BLite.Tests/InMemoryStorageTests.cs +++ b/tests/BLite.Tests/InMemoryStorageTests.cs @@ -308,7 +308,7 @@ public void CreateInMemory_NoFileCreated() // Verify that using an in-memory engine doesn't create any .db files. var tempDir = Path.GetTempPath(); var before = Directory.GetFiles(tempDir, "*.db").Length; - using (var _ = BLiteEngine.CreateInMemory()) { } + using (var engine = BLiteEngine.CreateInMemory()) { } var after = Directory.GetFiles(tempDir, "*.db").Length; Assert.Equal(before, after); }