Skip to content

fix(storage): serialise multi-process create/open and remap grown files (#216) - #221

Open
CaffeinatedCoder wants to merge 2 commits into
EntglDb:mainfrom
CaffeinatedCoder:fix/216-concurrent-create
Open

CaffeinatedCoder wants to merge 2 commits into
EntglDb:mainfrom
CaffeinatedCoder:fix/216-concurrent-create

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Contributor

Fixes #216

With AllowMultiProcessAccess, two processes opening the same new database at once crashed one of them. File creation, the header write, dictionary init and collection creation all ran outside any cross-process lock. Each process also kept a memory map sized at open time while another process grew the file.

Changes

  • WalShmFcntl: lock slots. Slot 0 is the WAL writer lock; slot 1 is a new open lock built from the same primitives. Windows and Linux lock the next byte of the SHM file; macOS uses flock on <shm>.open.lock.
  • WalSharedMemory: AcquireOpenLock/ReleaseOpenLock. The SHM header is now validated and initialised under the open lock.
  • StorageEngine: the SHM is opened first, and the constructor holds the open lock through page-file create/open, recovery and dictionary/KV init. A second opener waits, then opens the initialised files.
    • EnterOpenLock() (reentrant per engine, a no-op in single-process mode) also covers collection creation (DocumentCollection/DynamicCollection constructors, CreateCollection, CreateSessionCollection, DropOrphanCollections), adding dictionary keys (the dictionary is reloaded from disk first) and opening collection files.
    • When the OS lock is taken, page files skip past blocks other processes added and re-read the free-list head.
  • PageFile: accesses are checked against the mapped length, not the file length.
    • A page past the map but inside the file is remapped first.
    • Growing never resizes a file that another process has already grown.
    • A zero-filled file of the initial size (the creator died before writing the header) is initialised again, instead of throwing "Page size mismatch".

Single-process mode takes no lock. Open time is unchanged (4.1–4.8 ms before and after), and ReadPage got faster (≈510 → ≈240 ns) because it no longer calls fstat per read.

Tests (MultiProcessConcurrentCreateTests, separate engines on the same files, as in MultiProcessWalSharedMemoryTests):

  • 2 and 4 concurrent creators, 10 rounds each;
  • a half-initialised file;
  • pages another engine added past the map;
  • dictionary ids across engines.

All of them fail on main. The first three are in a separate commit (c7ceb06) that shows the failure. The full suite passes (2528 / 0 / 4 skipped), and the multi-process filter passed 5 runs with no hangs. BLite.NetStandard21.Tests wasn't run locally (no .NET 6 runtime).

Repro harness (posted on #216). Rounds with a crashed process, before → after, macOS 27 arm64 / .NET 10:

  • 2 processes, open only: 24/30 → 0/30
  • 4 processes, open only: 10/10 → 0/10
  • 1 writer + 1 reader: 30/30 → 0/30, nothing lost
  • 1 writer + 3 readers: 10/10 → 0/10, nothing lost

A Linux container (Ubuntu 24.04 arm64) gives the same results.

Not covered

  • Two processes writing at the same time still lose commits, because the free-space index, B-tree roots and page allocation are per process. MULTI_PROCESS_WAL.md lists simultaneous writers as out of scope, but nothing enforces it, and main shows the same loss when the database already exists. I suggest a follow-up that makes a second writer process fail fast.
  • Still unlocked: runtime EnsureIndex, DropCollection, KV root creation and TruncateToMinimumAsync. On open, RecoverAsync still truncates a non-empty shared WAL without the writer lock (Multi-process: RecoverAsync on open truncates the shared WAL without the SHM lock and leaves the SHM end offset stale #200).

Merge note: this conflicts with #201 in StorageEngine.cs. I'll resolve it in whichever lands second.

🤖 Generated with Claude Code

CaffeinatedCoder and others added 2 commits October 13, 2026 18:56
…glDb#216)

Adds MultiProcessConcurrentCreateTests, which fail on main:
- two and four engines open the same, not yet existing database at the
  same moment with AllowMultiProcessAccess (10 rounds each); one of them
  fails with UnauthorizedAccessException (page write past its stale
  memory map) or "Page size mismatch" (header read before it was written)
- a zero-filled file left by a creator that died between sizing the file
  and writing its header must be initialised on open

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es (EntglDb#216)

Two processes opening the same, not yet existing database with
AllowMultiProcessAccess crashed one of them: the file creation, header
write, dictionary init and collection creation ran outside any
cross-process lock, and each process kept a memory map sized at open
while the other one grew the file.

- WalShmFcntl: lock slots. Slot 0 is the existing WAL writer lock, slot 1
  a new open lock (Windows/Linux: next byte of the SHM file; macOS: its
  own <shm>.open.lock file), with the same primitives.
- WalSharedMemory: AcquireOpenLock/ReleaseOpenLock; the SHM header is now
  validated / initialised under it.
- StorageEngine: the SHM is opened first and the constructor holds the
  open lock from page-file creation through recovery, dictionary and KV
  init, so a second opener waits and then opens the initialised files.
  EnterOpenLock (reentrant per engine) is also taken around collection
  creation (DocumentCollection / DynamicCollection constructors,
  CreateCollection, CreateSessionCollection, DropOrphanCollections),
  around adding a dictionary key (which first reloads the dictionary from
  disk in multi-process mode), and around opening a collection file.
- PageFile: page accesses are bounds-checked against the mapped length
  instead of the file length; a read or write past the map but inside the
  file remaps first; growing no longer resizes a file another process has
  already grown. Under the open lock, RefreshAllocationState moves
  allocation past blocks other processes added and re-reads the free-list
  head. A zero-filled file of the initial size (creator died before
  writing the header) is initialised instead of failing with a page-size
  mismatch.

Single-process mode takes no lock; its only new per-access cost is a
comparison against the mapped length.

Tests: remap of pages another engine added, dictionary ids across
engines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch had an error being deployed

1 failed deployment
Production — d51a42e2 Deployed Oct 13, 2026 by CaffeinatedCoder via build_and_publish #695
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-process: two processes creating a new database at the same time crash one of them on open

1 participant