Serialise data-page writes across collections sharing one file - #136
Merged
Conversation
DocumentCollection serialises its own writes with _collectionLock, but page placement depends on state shared by every collection in the file: the FreeSpaceIndex (FreeSpaceIndexProvider hands out a single instance when UsesSeparateCollectionFiles is false) and the page allocator. FindPageWithSpace and InsertIntoPage are two separate calls, so two collections writing concurrently can be handed the same page. The loser either fails the space check with "Not enough space: need N, have M | PageId=..." or, when the check passes, writes over slots another collection's primary index still points at. Because a page is read, modified and written back as a whole buffer, the overlap silently drops the other collection's documents; the damage only surfaces later as a BSON parse failure on an unrelated read. StorageEngine now owns the write lock and hands the same instance to every collection in single-file mode. Collection-per-file databases share nothing, so each collection keeps its own lock and write concurrency between collections is preserved there. Fixes #135
Contributor
There was a problem hiding this comment.
Pull request overview
Adds storage-engine-managed write serialization for collections sharing a file, plus a concurrent write regression test.
Changes:
- Adds shared/per-file write-lock creation in
StorageEngine. - Uses the engine-provided lock in
DocumentCollection. - Adds cross-collection concurrent-write coverage.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Review findings |
|---|---|
tests/BLite.Tests/CrossCollectionWriteRaceTests.cs |
Nit (3 votes): Validate exact IDs and payloads for all three writers, not only leading characters for two collections. |
src/BLite.Core/Storage/StorageEngine.cs |
Critical (1 vote): DynamicCollection still bypasses the shared lock. Critical (3 votes): Per-file locks must coordinate multiple wrappers for the same physical collection file. |
src/BLite.Core/Collections/DocumentCollection.cs |
Critical (1 vote): ForcePruneAsync can write and free pages without acquiring the file-wide lock. |
Suppressed comments (1)
src/BLite.Core/Storage/StorageEngine.cs:220
- The engine now allocates this semaphore, but
StorageEngine.Disposedoes not dispose_dataWriteLock; the collection explicitly leaves it alive because it may be shared. Dispose this engine-owned semaphore after writers have stopped, alongside the other engine-owned synchronization objects, so repeated engine open/close cycles do not leave an undisposed wait handle.
_dataWriteLock = _collectionFiles == null ? new SemaphoreSlim(1, 1) : null;
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+70
to
+74
| // Concurrency control for write operations (B-Tree and Page modifications). | ||
| // Obtained from the storage engine: single-file databases share one instance across all | ||
| // collections, because page placement depends on file-wide state. Never disposed here — the | ||
| // instance may be shared with other collections. | ||
| private readonly SemaphoreSlim _collectionLock; |
| /// (see <see cref="_dataWriteLock"/>). In collection-per-file mode each collection gets its | ||
| /// own, preserving write concurrency between collections. | ||
| /// </summary> | ||
| internal SemaphoreSlim CreateCollectionWriteLock() => _dataWriteLock ?? new SemaphoreSlim(1, 1); |
| /// (see <see cref="_dataWriteLock"/>). In collection-per-file mode each collection gets its | ||
| /// own, preserving write concurrency between collections. | ||
| /// </summary> | ||
| internal SemaphoreSlim CreateCollectionWriteLock() => _dataWriteLock ?? new SemaphoreSlim(1, 1); |
Comment on lines
+97
to
+100
| await foreach (var e in db.StringEntities.FindAllAsync()) | ||
| { | ||
| Assert.StartsWith("s", e.Value); | ||
| seen.Add(e.Id); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #135.
Problem
DocumentCollectionserialises its own writes with_collectionLock(
DocumentCollection.cs:71), but page placement depends on state shared by every collection inthe file:
FreeSpaceIndex—FreeSpaceIndexProviderhands out a single instance wheneverUsesSeparateCollectionFilesis false (FreeSpaceIndexProvider.cs:18-29)StorageEngineFindPageWithSpaceandInsertIntoPageare two separate calls with no lock spanning both, so itis a check-then-act race between collections:
The thrown
InvalidOperationExceptionis the lucky outcome. When the losing writer still fits,the write goes through and lands over slots another collection's primary index still points at —
and since
InsertIntoPagereads, modifies and writes back the page as a whole buffer, the othercollection's documents are dropped with nothing reported at write time. The damage surfaces much
later, on an unrelated read, as a BSON parse failure on a document that was never written that
way. Nothing is persisted corrupt, which is what makes it look like an encoding bug.
UpdateAsyncreaches the same path throughUpdateDataCorewhen a document outgrows its slot andis relocated, so updates are affected too.
Fix
StorageEnginenow owns the write lock and hands it to collections:SemaphoreSlim, so thewhole find-page → write-page sequence is serialised file-wide.
CollectionDataDirectoryset) — nothing is shared, so eachcollection gets its own lock and write concurrency between collections is preserved.
DocumentCollection._collectionLockchanges from a field initialiser to_storage.CreateCollectionWriteLock(). Every existing acquisition site is unchanged, so the lockscope (which already covers
InsertCore/UpdateCore/DeleteCoreand the auto-commit) is thesame — only its granularity changes. The collection never disposes it, since the instance may be
shared.
Two files, ~20 lines. No public API change.
Cost
Writes to different collections of a single-file database no longer overlap. That is the price of
correctness here: the state they contend on is file-wide, so a per-collection lock cannot make
those writes safe. Databases that want write concurrency across collections can use the
collection-per-file layout, which this PR leaves untouched.
Alternatives considered and rejected:
index, but not the page read-modify-write: two collections holding disjoint reservations on the
same page would still clobber each other on write-back.
FreeSpaceIndexper collection in single-file mode — removes the sharing but costs pagereuse across collections and leaves the shared page allocator unprotected.
Test
tests/BLite.Tests/CrossCollectionWriteRaceTests.cs— three writers on three collections of oneTestDbContext, plainInsertAsync, single process, 5 seconds. Asserts no writer exception, thenthat every inserted id is enumerable and every document intact.
Bisected on this machine (macOS arm64, .NET 10.0.6):
Sample failure without the fix:
Note
FSI=281against the page header's realAvailableFreeSpaceof 179: the shared free-spaceindex is describing a page another collection has already consumed.
Full suite
dotnet test tests/BLite.Tests→ 2323 passed, 3 failed, 4 skipped.The 3 failures are
MultiProcessWalSharedMemoryTests(
WriterLock_IsMutualExclusion_AcrossInstances,BeginTransaction_Phase7_ReplaysCrossProcessCommits,Phase7_ReplayDoesNotDuplicate_LocalCommits). Verified pre-existing: the same three fail on thesame machine with this branch's
src/changes stashed. They are the macOSflock/FileStreamissue tracked in #134 and do not show up on the
ubuntu-latestrelease pipeline.One thing I could not explain
While iterating on the test I saw a single run where one
StringEntityout of ~34,500 wasinserted but not returned by
FindAllAsync. It did not reproduce in the following 9 runs (6 ofthem with per-id tracking rather than counting), and a single-writer single-collection probe of
35,000 inserts lost nothing across 3 runs. I could not pin it down and it is a different failure
mode from the one this PR fixes — flagging it rather than leaving it unsaid.
Performance
Measured on this branch vs the same tree with the two
src/files reverted toda9795a~1.Release build, macOS arm64, .NET 10.0.6, single-file database, default
PageFileConfig.First rep of each scenario discarded as warm-up; run-to-run spread inside each set is roughly
±10%, so only the concurrent-insert delta is above noise.
FindAllAsyncover 100k docsA is unchanged by construction: the lock scope and acquisition sites are identical, only the
instance differs.
C is unchanged by construction too: reads never take the write lock.
B is the real cost, and it is the point of the change — writes to different collections of one
file no longer overlap. Note the baseline column is flattered by its own bug: every run lost 4–9
inserts to
Not enough space, and a failed insert aborts atInsertIntoPagewithout doing therest of the work, so the baseline is doing measurably less work per "operation".
Databases that need write concurrency across collections can use the collection-per-file layout
(
CollectionDataDirectory), where each collection keeps its own lock — that path is unchanged.