Repository navigation
Collection-per-file mode: creating a collection can route another collection's page to the main file (lock-free read of a Dictionary) #312
Description
In collection-per-file mode (PageFileConfig.Server), creating a collection while other collections read or write their pages can send one of those pages to the main file. The operation then fails with:
InvalidDataException: Page 3254779953 is outside the page file (file holds 65 pages): the file is corrupt.
3254779953 is 0xC2000031: the collection-page marker, slot 2, local page 49. Nothing on disk is corrupt. Slot 2's file exists, but the slot lookup missed.
Mechanism (0450b97)
_collectionSlotToNameis a plainDictionary<int, string>(src/BLite.Core/Storage/StorageEngine.cs:79).- It is written under
_collectionSlotLockwhen a collection gets its slot (GetOrAssignCollectionSlot,StorageEngine.Memory.cs:663-683). - It is read without that lock by
GetCollectionNameBySlot(StorageEngine.Memory.cs:333-335).GetPageFilecalls that for every read, write and free of a collection page. Dictionarydoes not support a read that runs concurrently with a write. When the 4th and the 8th collection get their slots, the dictionary resizes (capacity 3 → 7 → 17).Resizeassigns a new, still empty_bucketsarray before it re-links the entries, so a concurrentTryGetValuefor an existing slot can returnfalse.- On a miss,
GetPageFilefalls through to the main file and passes the whole encoded id as the physical page number (StorageEngine.Memory.cs:327-331).
A related ordering detail: GetOrAssignCollectionSlot publishes name → slot before slot → name (lines 679-680). A thread that finds the slot through _collectionNameToSlot can route a page before its name is in _collectionSlotToName.
Reads fail with the exception above. That is the only outcome observed so far. Writes use the same routing: WritePage on the main file would take its grow path (PageFile.EnsureCapacityCore) and try to extend the main file to the page's offset. With 16 KB pages that is tens of TB. The collection file would never get that page. I have not observed this.
Reproduction
The error appeared once while running ConcurrentConsistencyTests 60 times under load (DOTNET_PROCESSOR_COUNT=2, two full-suite copies running alongside). It happened in the workload of HighContention_ManySmallTransactions_ConsistentFinalState: 8 threads each create their own collection at the same moment and insert into it. It hit writer 2 in its second transaction, while the other collections were still being created. The race window is the few instructions of each resize, which happen only at the 4th and the 8th collection of an engine. So it is rare, but it hits exactly the workload that test runs on a loaded CI runner.
Fix considerations
- Make
_collectionSlotToNamesafe for lock-free readers: aConcurrentDictionary<int, string>, or astring?[MaxCollections]array (64 entries) written withVolatile.Write. The array fits because slots are dense and bounded. - Publish
slot → namebeforename → slot. LoadCollectionSlotswrites the same maps (at open, before any reader). It should use the same types.
Found while investigating #311 (the other failure of that test). Related: #288.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
CaffeinatedCoder commentedon Oct 17, 2026
Reproduced reliably: the test in #314 races slot lookups and page reads against all 63 slot assignments of an engine. On
mainit fails in 10 of 10 runs within about a second (also withDOTNET_PROCESSOR_COUNT=2), and a variant that reads throughReadPagehit the exact error from this issue:Page 3221225536 is outside the page file (file holds 65 pages). Fix in #314.mrdevrobot commentedon Oct 18, 2026
Reproduced on 0450b97 (3 of 3) with the test from #314. Collection-per-file mode only: medium.
fix(storage): make the collection slot map safe for lock-free readers (…