JetDatabaseWriter

Concurrency and lock ordering

Status: active reference Date: 2026-06-16 Last updated: 2026-10-19

This note is the single, canonical description of the synchronization model used by AccessReader, AccessWriter, their services, and the DatabaseFile each facade owns, with its page file: a read-only PageFile for the reader, the writer’s Pager. It exists to answer one question that the code alone makes hard to verify: when more than one synchronization primitive is involved, which one is outer and which is inner?

Read this before adding a new lock, taking an existing lock from a new call site, or moving an await inside a critical section. See Why these are not consolidated for the rationale behind the separate primitives.

The primitives

The reader operation gate drains active operations before disposal. The page store serializes physical stream access, while the writer’s pager separately coordinates its transaction journal and plaintext frame cache.

Primitive Owner Responsibility
operationGate ReaderServices.Operations Reentrant reader-operation leases and disposal drain
mutationGate TransactionLifecycle Serializes writer mutations, begin, commit, rollback, shrink and disposal; rejects callback re-entry
IoGate PageFile, used by Pager.JournalGate Journal reads, attach and detach; never held across a store operation
frameGate Pager Serializes cache fills, writes, append allocation, truncation and journal transitions
frameSync Pager Frame dictionary, CLOCK bookkeeping, counters and invalidation generation; memory only
ioGate StreamPageStore Seek-based reads, writes, length changes and flushes; positional reads bypass it
Per-page and commit locks JetByteRangeLock Advisory cross-process page writes and transaction replay
ownedMapSetsLock CatalogOwnedMapPolicy Writable and refused TDEF sets; memory only
ownedDataPagesCacheLock OwnedDataPages Per-table owned-page cache and writer observer state; memory only
cacheLock TDefImageCache Parsed TDEF images, physical chain dependencies and publication epoch; memory only
TableCatalog.schemas monitor TableCatalog Schema publication and catalog generation; memory only
gate and sync OwnedPageIndex Serializes whole-file discovery; publishes ownership maps and invalidation epochs under a memory-only leaf lock
operationsDrained atomic publication Reader operation gate Publishes the one-time disposal drain notification
Lock-file slot LockFileCoordinator .ldb / .laccdb slot held until disposal finishes

The insert-page hint belongs to the writer’s mutationGate flow, including transaction snapshots and restoration. Initial database construction uses an unpublished writer. The hint needs no additional lock.

RowMapper<T> retains the bounded materializer cache’s LRU lock and per-entry locks for first compilation. Published read/write delegates and decoder initialization flags use acquire/release accesses, so initialized entries skip the per-entry lock. A published flag also caches a null decoder refusal; factory failures leave initialization retryable. The immutable property map uses Lazy<T> with PublicationOnly for safe publication without caching factory exceptions.

CompactDiscoveryAccounting admits each independent work category and its working-storage reservations with bounded Interlocked.CompareExchange loops. Reads use Volatile.Read, and reservation disposal releases bytes exactly once with atomic exchanges and addition. These counters expose no cross-category snapshot. Attachment content admission still uses a lock because its content and decoding-work allowances must succeed or fail together.

Constraint identity during schema changes

Constraint identity follows the writer’s serialized schema lifecycle. TDEF page numbers and physical column descriptors are reusable storage locations, not object generation identifiers. Drop unregisters the table’s column constraints and table rule; create registers a new list even when the allocator returns the same page and writes an identical TDEF. Dropping and readding a column builds its new constraints from the new declaration. A schema rewrite carries only surviving columns’ CLR defaults and delegates, registers its replacement, and explicitly rebinds it after a physical transplant. Rename transfers the existing registry entry. Statement and caller-transaction rollback restore the previous registry snapshot, including bindings, table rules and AutoNumber counters.

ConstraintReplacementTests verifies TDEF root and physical column descriptor reuse across Jet3, Jet4 and ACE, plus identical complete TDEF images for Jet4/ACE. Jet3 retains shared map storage and allocates different usage-map rows. The tests cover replacement defaults and rules, counter reseeding, column descriptor reuse (including an ACE complex-table transplant at the same TDEF root), reopen, unchanged bytes on refusal and usable caller transactions. ConstraintReplacementIdentityTests separately covers registry replacement and rollback with identical physical descriptors, including removal of all constraints. These are library lifecycle regressions; they do not infer a native schema-generation field.

The physical comparison in ConstraintRegistry is a defensive check, not an external-change detector. Byte-identical replacement outside the writer cannot be distinguished by comparing those bytes. Exclusive ownership is required for the entire writer lifetime, including caller-supplied streams and intervals between calls. Dispose before an external edit and reopen afterwards so the new writer reads the current properties and counters. Neither cached reads nor constraint validation promise to detect an edit that violates that ownership contract.

Acquisition order

The writer’s mutationGate is outermost and spans the entire public mutation or transaction-lifecycle call. The AsyncLocal mutation marker rejects a callback that re-enters the same writer with ReentrantWriterCall before it waits for the gate. Internal auto-commit and disposal paths call the lifecycle cores under the gate they already hold.

A writer cache fill or write holds frameGate. It may briefly take IoGate to consult or modify the journal, then releases IoGate before calling the store. A physical write encodes its scratch buffer first, takes its per-page byte-range lock, then takes the store’s ioGate for seek/write. The page lock is released after the store gate. AES transforms never run under the store gate. A physical read releases the store gate before decoding.

Diagnostic trace delivery occurs after page acquisition releases its gates and outside ownership/cache publication locks. DiagnosticTrace suppresses recursive delivery and listener failures. Reader entry/disposal and writer mutation/disposal check its async-flow marker before acquiring their gates, refusing callback reentry with ReentrantDiagnosticCall; a listener cannot wait on disposal of the operation that is delivering it. Optional EventSource counters use atomic totals; recording a failure never initializes an EventSource or invokes its listeners. Provider registration is an explicit caller action before database operations.

frameSync and the other memory locks are leaves: no awaits or store calls while holding them. Invalidation takes only frameSync, increments a generation and drops every frame. A load that began under an older generation cannot retain its bytes after invalidation.

A Pager.JournalGate lease holds frameGate and then IoGate. It waits for physical writes to finish before capturing transaction state and must never span a page read, write, append, cache fill or store call. Transaction attach and detach invalidate frames while under this lease; disposal releases IoGate and then frameGate. The commit path disposes its lease before replaying pages through normal page writes.

The reader operation lease and owned-page index initializer can span reads, but neither can be acquired while holding a store or pager gate. Positional reader I/O bypasses the store gate and leaves the shared stream position alone. The cross-process commit sentinel spans the transaction replay; the lock-file slot spans the open database lifetime.

Annotated call paths

Writer statement commit

InsertRowsAsync → RunAutoCommitAsync (AccessWriter.cs) → TransactionLifecycle.RunAutoCommitAsync (TransactionLifecycle.cs) → mutationGate → BeginTransactionCoreAsync → work → CommitTransactionCoreAsync.

BeginTransactionAsync
  └─ JournalGate lease (frameGate, then IoGate) ──▶ capture writer state (ownedMapSetsLock briefly)
              ──▶ gate.Attach(journal); set ActiveTransaction ──▶ dispose the lease

work phase (row encode, index maintenance, page allocation)
  └─ no durable locks held: every WritePageAsync/AppendPageAsync sees the
     attached journal and buffers into it while holding frameGate and briefly IoGate for the
     buffer swap. Private statements spill bounded batches outside IoGate;
     the first spill acquires the commit sentinel, retained through completion or undo.
  └─ ownedMapSetsLock may be taken briefly (leaf, memory only;
     the owned-map policy's MSysObjects scan runs outside its lock). The writer caches structural TDEF images and table schemas. Those images, its owner index and validated table-page results follow logical page writes;
     observer updates take only a leaf lock and never read through the pager.

CommitTransactionAsync
  ├─ JournalGate lease (frameGate, then IoGate) ──▶ gate.Detach(); ActiveTransaction = null ──▶ dispose the lease
  ├─ ByteRangeLock commit-lock sentinel  ◀── reuse the sentinel held by an earlier spill, or acquire it
  │     last cancellation check (earlier spills remain undoable)
  │     Pager.CommitAsync takes frameGate for capture, replay and recovery:
  │         capture raw before-images not already retained by an earlier spill
  │         final cancellation check, then CancellationToken.None:
  │           foreach buffered page (ascending page order):
  │             encode one page; ByteRangeLock per-page ──▶ store ioGate ──▶ seek/write
  │           durable store flush for file transactions, then durable journal decision
  │         on a storage failure: restore raw bytes, original length, and flush
  └─ release commit-lock (finally)

RollbackTransactionAsync  (auto-commit calls it when the work throws)
  └─ JournalGate lease (frameGate, then IoGate) ──▶ gate.Detach() ──▶ dispose the lease
       ──▶ restore any early spills under frameGate, outside IoGate
       ──▶ restore writer state (catalog invalidated,
                insert hint, ownedMapSetsLock briefly, constraint registry)

The commit-lock sentinel is “outer” only in the sense that it spans the replay window; it is acquired after IoGate has been released, so it never nests outside an already-held IoGate. Capturing and restoring the writer state is memory-only. The outer mutationGate protects the insert hint and constraint registry; the owned-map policy briefly takes its leaf ownedMapSetsLock. Frame invalidation briefly takes frameSync there. A commit that fails before replay restores any early spills. If a stream write or flush fails during replay, the pager restores the raw before-images and original file length while holding frameGate and the commit sentinel. After successful recovery, the lifecycle restores the same writer state as a normal rollback and marks the transaction rolled back. If recovery also fails, the transaction remains neither committed nor rolled back and the writer rejects mutations with WriterFaulted. Disposal skips pending writes and container rewrap. Non-file stores retain raw before-images in memory; physical shrinking can spill its custom-stream tail undo to a DeleteOnClose temporary file. Only raw stored bytes enter the log, so encrypted pages remain encrypted. File transactions retain a persistent page-hash manifest, selective original raw page images and flushed write intents in an adjacent .jdw-journal. The store records each encoded write before it reaches the database. In-process undo bypasses new intents because original bytes are already captured and bound to the manifest. Recovery validates all journal and database content before replay; an uncertain commit-decision failure preserves both files and faults the writer. See transaction crash recovery. The private temporary undo log alone remains insufficient for custom-stream or power-loss recovery.

Failed calls inside an explicit transaction

Under mutationGate, RunInSavepointAsync captures the insert hint, owned-map policy and constraint registry, then begins a journal frame under JournalGate. Each first page mutation in that frame keeps the prior journal bytes and zero-reservation state; child frames merge their first-touch priors into their parent. Failure restores the frame and append boundary under JournalGate with CancellationToken.None, invalidates pager observers and the catalog, and restores the captured writer state. The allocator reloads its free-page view after the pager invalidation generation changes. Earlier successful calls remain buffered and the explicit transaction stays active.

Default writer statements (UseTransactionalWrites = false)

Default row, table-schema, relationship and complex-item calls use the same private journal and commit-lock sequence above. At max(64, PageCacheSize / 2) buffered pages, the pager captures each page’s first raw before-image and writes the batch, then releases its replay images. Work-phase failures and cancellation restore earlier spills and writer state; replay failures restore the original bytes and length. Each started physical batch and restoration ignores cancellation, while work and preparation between batches remain cancellable. File-backed statements always request a device flush before the journal commit decision. Other stores request it when UseTransactionalWrites is true. Private statements are exempt from the explicit transaction page budget; provisional zero reservations keep metadata without allocating page buffers.

Initial database creation and physical tail shrinking use reference-counted write scopes with a separate lifecycle. Path creation publishes only a completely initialized, flushed staging file. File-backed shrinking prepares its own page-hash manifest, saved original tail pages and truncation intent under the pager frame gate, and records the committed result before cleanup. Encryption replacement also sits outside the statement path. A deterministic generation-bound intent retains immutable old/new copies and an append-only process commit decision. Exclusive source and recovery leases span publication and cleanup; path writer open validates pending evidence before opening the database, including a missing destination. Readers, detection and creation refuse pending evidence. See the transaction recovery contract for bootstrap and power-loss limits.

Reader operation

File readers retain read-only sharing for their lifetime; path writers request exclusive sharing. A caller-supplied file reader also holds a separate read lease. Readers refuse any pending .jdw-journal before parsing the header. Caller-provided streams and non-cooperating Unix processes still need external coordination; cached readers are not refreshable views of concurrent mutations.

Every public reader operation opens with using AsyncReentrantOperationGate.Lease operation = operations.Enter(); in the reader service that implements it (for example TableReader.Rows in TableReader.cs). The LINQ provider and the index-query handles call those services directly, so they enter the same gate.

operationGate lease  (reentrant: nested reader calls on the same async flow join the root)
  └─ per page read: store ioGate ──▶ seek/read ──▶ release store ioGate ──▶ page-codec decode
       (a path-opened reader's uncached FileStream reads instead use a
        positionless RandomAccess read that bypasses store ioGate)
  └─ table-scan read-ahead: the next data page's read runs alongside the
     caller's decode of the current page, so two page reads can be in flight
  └─ owned-page cache build: ownedDataPagesCacheLock (leaf, memory only)
  └─ TDEF image cache: cacheLock (leaf, memory only; the chain is read
     before the lock is taken)
  └─ owned-page index build (the first table whose usage map fails validation):
       OwnedPageIndex.gate ──▶ ReadUncachedPageAsync for every page from 3 to the end of file
       (each takes store ioGate on the seek-and-read path) ──▶ release the gate
       (other operations that need the index await the gate for the whole pass)

A path-opened reader’s file handle is synchronous (FileOptions.None; see “Page I/O handle” in read-performance-bottlenecks.md), so each page read runs as a blocking read on a thread-pool thread. On Windows the I/O manager serializes I/O on a synchronous file object: the read-ahead pair and concurrent scans on one reader queue their reads in the kernel, below every lock listed here, and still overlap them with decode. That queue adds no lock to this hierarchy and cannot close a deadlock cycle, because it never waits on a library lock: a queued read waits only for the reads ahead of it, and each of those completes without taking one. Library gates are held while the OS reads: the operation lease across every read, OwnedPageIndex.gate across the whole-file pass, and store ioGate across each read on the seek-and-read path. The library only ever awaits them (WaitAsync, or the disposal drain) and never blocks a thread on one, so a read queued in the kernel can delay the operations waiting on those gates but cannot deadlock them. The writer opens an overlapped handle.

When the read starts on a thread-pool thread with no synchronization context and the default task scheduler, a path-opened reader runs it on that thread instead of another pool thread (PageFile.ReadsInlineOnThreadPool). On the seek-and-read path that thread holds store ioGate across the blocking read, just as an offloaded read held it across the awaited one, so the hierarchy is unchanged. An inline read cannot be cancelled once started; the token is checked before it. Threads with a synchronization context, such as UI threads, never block on a page read.

Disposal

Reader — DisposeAsync (AccessReader.cs):

operationGate.TryBeginDispose(out waitForOperations)
  └─ lockFile.DisposeAfterAsync(waitForOperations, DisposeReaderResourcesAsync)
        ├─ await waitForOperations   (in-flight reader operations drain)
        ├─ DisposeReaderResourcesAsync → services.Dispose (page and catalog caches)
        │                                 → DatabaseFile.DisposeAsync (PageFile: store and its gate, journal gate,
        │                                   page cipher; then the TableDefReader and
        │                                   OwnedDataPages caches)
        └─ release .ldb / .laccdb slot  (always last)
  └─ operationGate.CompleteDispose()

The reader gate publishes its drain completion source once with Interlocked.Exchange, then checks the active-operation count. The final operation decrements that count with Interlocked.Decrement, then reads the completion source with Volatile.Read. The full fences ensure that either disposal observes zero operations or the final operation observes the published notification; both may safely try to complete it.

Writer — DisposeAsync (AccessWriter.cs) waits for mutationGate and checks disposal again under the gate before teardown:

lockFileCoordinator.DisposeAfterAsync(
    TransactionLifecycle.DisposeActiveTransactionAsync,  (implicit rollback of any open tx; then
                                                          Pager.ForceDetachJournal without the gate)
    RewrapAndCloseOuterEncryptedStreamAsync,             (Agile re-encrypt on close)
    DatabaseFile.DisposeAsync)
  └─ release .ldb / .laccdb slot  (always last)

Reentrancy

Primitive Reentrant? Mechanism / consequence
operationGate Yes AsyncLocal<int> operationDepth; nested calls on one async flow join the active root operation
mutationGate No AsyncLocal rejects same-flow writer callback re-entry before the semaphore wait
OwnedPageIndex.gate No Binary SemaphoreSlim(1,1) — the index build only reads pages; it must never ask for the owned-page index itself
frameGate / store ioGate No Binary semaphores; never re-enter on the same flow
IoGate No Binary SemaphoreSlim(1,1) — re-entering on the same flow self-deadlocks; never hold it, or a Pager.JournalGate lease, across a *PageAsync call
ByteRangeLock per-page No OS advisory byte-range lock; re-locking the same range blocks
ownedMapSetsLock Yes Synchronous lock; keep as a leaf
ownedDataPagesCacheLock Yes Synchronous lock; keep as a leaf
TDefImageCache.cacheLock Yes Synchronous lock; keep as a leaf

Writer-owned caches and the exclusive-writer assumption

The writer keeps some of what it reads from the file in memory for the rest of the session, on the assumption that nothing else changes the file while it is open:

Cache Owner Dropped when
Plaintext page frames, bounded by PageCacheSize Pager CLOCK eviction; explicit-transaction attachment, rollback, failed writes and truncation invalidate all frames; private statement attachment and successful replay retain coherent frames. Each write refreshes its retained frame.
Validated owned data pages and usage-map dependencies OwnedDataPages Data-page ownership changes update the result; TDEF owned-map pointer, owned-map row or REFERENCE bitmap changes discard it; writes to sibling map rows keep it. Rollback, failed replay, truncation and shrink discard all results through the pager observer.
User-table list and table schemas with lazily loaded properties TableCatalog Invalidate: every catalog write (create, drop or rewrite a table, add a catalog object), a rollback, a failed statement and a failed commit. Each call also moves TableCatalog.Generation on.
Parsed structural TDEF images TDefImageCache A structural write to any chain page drops that image. Row, AutoNumber and complex-reference counter changes retain it; the writer reads live counters from the root. Rollback, failed replay and truncation discard all images. Chain reads publish only if no write changed the epoch.
Insert-page hint DataPageInserter Restored to its state at the transaction or statement savepoint on a rollback; forgotten if commit recovery also fails.
The TDEF pages whose owned-page maps the writer may extend, and those it found it may not CatalogOwnedMapPolicy Restored to their state at the transaction or statement savepoint on a rollback. If commit recovery also fails, the writable set is restored and the refusals are forgotten. A table the writer creates drops a refusal for its TDEF page.
Constraint lists, AutoNumber NextAutoValue and complex-reference counters ConstraintRegistry Re-registered by schema changes; restored on a rollback. Successful commit recovery restores them too. If recovery fails, counters stay above values that may be on disk and the writer refuses further mutations.
Enforced relationships, which every insert, update and delete checks RelationshipCatalogStore Every MSysRelationships write through the store (create, drop or rename a relationship, rename a key column), and whenever TableCatalog.Generation has moved on since the set was loaded, so a rollback or failed commit reloads it.

All of these are memory-only. Under mutationGate, restoring or dropping the insert hint and constraint registry needs no additional lock. The other caches take their respective leaf locks, including ownedMapSetsLock, ownedDataPagesCacheLock, TDefImageCache.cacheLock, TableCatalog.schemas and frameSync; the generation counters use Interlocked.

A writer opened by path holds the file with FileShare.Read, so no other process can open it for writing while the writer is open. A writer opened on a caller-supplied stream leaves that to the caller: if another process writes the file underneath it, the writer keeps using the tables, counters and relationships it already read and does not see the change. Every MSysRelationships write in this library must go through RelationshipCatalogStore, which drops the relationship cache; a new path that writes those rows some other way must call RelationshipCatalogStore.Invalidate.

Reader-owned caches and the unchanging-file assumption

The reader keeps what it reads from the file in memory until it is disposed, on the assumption that the file does not change while it is open:

Cache Owner Dropped when
Pages and their row directories, up to PageCacheSize pages ReaderPageCache The least recently used page first, when the cache is full; every page at dispose.
User-table list TableCatalog Dispose.
Linked-table list LinkedTableReader Dispose.
Each table’s parsed TDEF image and lazily loaded properties TableDefReader and TableCatalog Dispose.
Each table’s owned data pages, and the whole-file owner index OwnedDataPages Dispose.

Nothing writes through the reader, so its own work never makes its caches stale. AccessReaderOptions.FileShare defaults to Read, and path-based readers exclude write/delete sharing. A FileStream passed directly to OpenAsync needs a compatible read-only lease. Linked-source resolver streams instead require caller-managed isolation and a committed snapshot: the library does not reopen their filenames or access sidecars. Windows enforces sharing; other platforms require cooperating access, and custom streams require caller-managed isolation. Keep the source unchanged for the reader lifetime: its caches do not refresh. If external access bypasses that coordination, cached and newly read pages can disagree.

Rules for new code

  1. Acquire primitives in the documented order. If you need two, the one higher in the hierarchy is taken first and released last.
  2. Never hold IoGate, or a Pager.JournalGate lease, when calling ReadPageAsync / WritePageAsync / AppendPageAsync — they take the gate themselves on their gated paths.
  3. Keep ownedMapSetsLock, ownedDataPagesCacheLock, TDefImageCache.cacheLock, frameSync and OwnedPageIndex.sync as leaf locks: pure in-memory work, no await and no other lock acquired while held.
  4. Physical writes take the per-page byte-range lock before the store gate; never hold the journal IoGate across store I/O.
  5. The cross-process locks (LockFileCoordinator slot, ByteRangeLock commit-lock) are lifetime- or transaction-scoped, not per-page; do not fold them into the per-operation nesting.
  6. Access the insert-page hint only within the writer’s serialized mutation flow or unpublished initialization.

Why these are not consolidated

The store gate, reader operation gate and memory-cache locks remain separate because they protect different lifetimes and invariants:

A single “do everything” primitive would conflate I/O serialization, a read-concurrency drain, and a memory-cache guard — increasing contention and coupling. Writer-only state such as the insert-page hint instead uses the existing mutation flow, and independent scalar state uses atomic operations where no compound invariant requires a lock.