Table of Contents

02 — Storage

Code: src/Typhon.Engine/Storage/

Storage is the physical layer: a paged data file managed by the engine's own page cache, segments built on top of pages, and accessors that hold short-lived, JIT-inlined views into chunk memory. Everything above this layer — MVCC revision chains, B+Tree indexes, the ECS component tables, even the page CRC + seqlock snapshots the checkpoint relies on — bottoms out here. The shape of this layer (8 KB pages, two-pass clock-sweep eviction, generic <TStore> segments) is what makes the higher layers cheap.

Because the cache is a fixed pool with on-demand load + clock-sweep eviction, the database file can be arbitrarily larger than the cache — resident memory is bounded by DatabaseCacheSize, not by database size. Persistent component data, B+Tree indexes, the per-archetype EntityMap, and the free-space bitmap all live in this paged store, so data volume and entity count scale with disk, not memory. The lone exception is Transient components (TransientComponentSegment), which are in-memory only by design. This larger-than-RAM property is routine for SQL and embedded engines (SQLite, LMDB) but sets Typhon apart from in-memory ECS frameworks, where the entire world must fit in process memory.

You read this doc when you're: tuning page-cache pressure, understanding why a benchmark stalls, designing a new on-disk structure, debugging dirty-counter inflation, or reading the engine's most allocation-sensitive code (ChunkAccessor, ChunkBasedSegment). The narrative is bottom-up — file → pages → segments → accessors → cross-cutting concerns (dirty tracking, backpressure, page CRC + seqlock).

Storage layer overview
The layered storage model: two IPageStore backends (PersistentStore over the page cache, heap-backed TransientStore), the PagedMMF / ManagedPagedMMF cache core, the generic <TStore> segment hierarchy (ChunkBasedSegment extends LogicalSegment; VSBS / StringTable compose a chunk segment), the hot-path accessors, and the cross-cutting dirty-tracking / backpressure / page-CRC machinery.

1. Overview — the layered model

Three concentric layers, each with one obvious type:

Layer Type What it owns
Page cache PagedMMF A pinned slab of N × 8 KB memory backing the data file. Allocates / evicts pages by clock-sweep.
Database manager ManagedPagedMMF Page 0 root header + bootstrap dictionary + occupancy bitmap. Allocates / frees file pages.
Segments + accessors LogicalSegment<TStore>, ChunkBasedSegment<TStore>, ChunkAccessor<TStore> Logical pages grouped into segments; fixed-size chunks allocated within segments; SOA-cached accessors for hot-path chunk reads/writes.

Two backends share the segment / accessor code via the IPageStore struct-generic interface:

  • PersistentStore — wraps ManagedPagedMMF. The JIT inlines every delegation; assembly is identical to non-generic code.
  • TransientStore — heap-backed, no cache, no dirty tracking. The dirty-tracking methods are no-ops; typeof(TStore) branches in ChunkAccessor dead-code-eliminate the persistence machinery for transient components.

Cross-cutting concerns sit alongside this layered core:

  • Dirty tracking — ChangeSet — coordinates DirtyCounter / ActiveChunkWriters lifecycle for a UoW.
  • Backpressure — IPageCacheBackpressureStrategy / WaitForIOStrategy — what happens when the clock-sweep finds nothing evictable.
  • Page CRC & seqlock — CRC32C torn-write detection + consistent checkpoint snapshots (no FPI; recovery rebuilds — see §7).
  • Storage introspection — StorageMapTypes — the read-only surface Workbench's Database File Map uses.

2. PagedMMF — the page cache

PagedMMF is the foundation. It owns a single backing file (the database) and a fixed-size pool of in-memory pages. Pages are loaded on demand and evicted when the pool is full.

Page layout — 64 + 128 + 8000 = 8192

┌──────────────────┬──────────────────┬──────────────────────────────────────────┐
│ PageBaseHeader   │ Metadata         │ Raw Data                                 │
│ 64 bytes         │ 128 bytes        │ 8000 bytes                               │
└──────────────────┴──────────────────┴──────────────────────────────────────────┘
0                  64                 192                                       8192

Constants defined on PagedMMF:

Constant Value Meaning
PageSize 8192 One page
PageBaseHeaderSize 64 Bytes reserved for PageBaseHeader
PageMetadataSize 128 Per-page metadata (e.g. chunk occupancy bitmap)
PageHeaderSize 192 PageBaseHeaderSize + PageMetadataSize
PageRawDataSize 8000 What user code actually writes to
PageSizePow2 13 2^13 = 8192 (used for shift-instead-of-divide)

The base header is a small struct (PageBaseHeader) with Flags, Type, FormatRevision, ChangeRevision (incremented every disk write), PageChecksum (CRC32C over the page, skipping the checksum field itself), and ModificationCounter (the seqlock counter used for torn-page detection — see §7). The header struct itself only occupies the first 24 bytes; the rest of the 64-byte zone is reserved for forward compatibility.

PageInfo and PageState

Each in-memory page has a sidecar PageInfo record: 64 bytes, one cache line, in a native table rather than one GC object per slot, so a very large cache adds nothing for the garbage collector to trace. A zeroed record is a free slot. It tracks:

  • FilePageIndex — which file page the slot holds (the slot's own index is its position in the table)
  • PageState — current state machine value (see below)
  • ClockSweepCounter — eviction heuristic (range 0..5, see §2)
  • DirtyCounter (DC) — mutator marks a unit of work still holds on the page; > 0 prevents eviction
  • WritebackGen / CapturedGen — the page owes a write to the data file while they differ; prevents eviction until a checkpoint has made the bytes durable (see §5). A bitmap with one bit per slot lets the checkpoint find the owed pages without visiting every slot
  • ActiveChunkWriters (ACW) — > 0 means writers are mid-flight; prevents checkpoint snapshot and eviction
  • SlotRefCount — number of ChunkAccessor slots holding raw pointers into this page
  • AccessEpoch — epoch tag (see 01-foundation §4)
  • CrcVerified — CRC checked since this load? (reset on allocate)
  • SlotReady — false while the slot's owner is still preparing it (loading or zeroing the page); other requesters wait
  • ReadPending — a disk read for the slot may be in flight; its task is kept in a small side table, not in the record
  • StateSyncRoot — AccessControlSmall protecting state transitions
  • PageExclusiveLatch — AccessControlSmall for exclusive writer ownership
  • ExclusiveLatchDepth — re-entrance counter (multiple chunks on the same page)

PageState is a 4-value enum with a deliberate gap (no value 3) — bits are reserved for future state extension:

internal enum PageState : ushort
{
    Free       = 0,   // Unallocated slot
    Allocating = 1,   // Mid-load, single-owner
    Idle       = 2,   // Loaded, evictable when DC == 0 and AccessEpoch < MinActiveEpoch
    Exclusive  = 4,   // Held by one thread under PageExclusiveLatch
}

State transitions are protected by StateSyncRoot. The Idle → Exclusive transition is what TryLatchPageExclusive does; it also bumps the seqlock counter from even → odd.

Default cache size — 256 MiB

DatabaseCacheSize on PagedMMFOptions defaults to 256 MiB (DefaultDatabaseCacheSize) — a production-sane size for the one primary engine a process normally runs. The hard minimum is 8 MiB (MinimumCacheSize = MinimumMemPageCount × 8 KiB); a configured size below the 64 MiB recommended floor (RecommendedMinimumCacheSize) logs a startup warning. Public byte constants — PagedMMFOptions.DefaultCacheSizeBytes / MinimumCacheSizeBytes / PageSizeBytes — expose these in-code.

The 8 MiB minimum is deliberately small — but it's the hard floor; production-sane sizing starts far higher. The internal TestMode flag suppresses the small-cache warning and the min-size floor so a fixture can run below 8 MiB; fixtures that deliberately exercise eviction opt in via [Property("CacheSize", ...)] to a sub-floor cache under TestMode, while the general test default is a representative 8 MiB (right at the floor). That puts the eviction, backpressure, and dirty-counter paths under real pressure exactly where a test wants it, while the rest of the suite runs representatively. Production leaves TestMode off and gets the 256 MiB default; size DatabaseCacheSize — or the fluent TyphonOptions.PageCacheSize(...) — for your workload's largest single-transaction working set (real servers go much higher).

The validator enforces: the size must be a multiple of the page size, at least 8 MiB (unless TestMode), and at most 2³¹ − 1 pages (about 16 TiB: a slot index is an int). There is no other ceiling (#945): the cache is one contiguous 64-bit allocation (IMemoryAllocator.AllocateLargePinned, 4 KiB-aligned, never zeroed), and the OS is the limit. A size the host cannot grant is refused at startup with a StorageException (TyphonErrorCode.PageCacheAllocationFailed) naming DatabaseCacheSize, the size and the limit that applied: the commit limit on Windows, the overcommit policy on Linux. The two async I/O paths, which need a Memory<byte> and so cannot address past 2 GiB from one base, go through fixed 1 GiB windows (PageCacheWindow); the page-access hot path is unchanged pointer arithmetic, and a page's address maps back to its slot exactly (rule PS-13).

Two-pass clock-sweep eviction

When a new page is requested and no Free slot exists, AllocateMemoryPageCore (in PagedMMF.cs) runs two passes over the page array:

Pass 1 (counter-respecting):  scan up to 2 × N slots
   if slot.ClockSweepCounter == 0 and TryAcquire succeeds → evict it
   else slot.DecrementClockSweepCounter and continue
Pass 2 (counter-ignoring):    scan up to N slots
   take the first slot TryAcquire succeeds on, regardless of counter

The counter is incremented on every access via PageInfo.IncrementClockSweepCounter, capped at ClockSweepMaxValue = 5. Hot pages climb to 5 and survive several full sweeps; cold pages decrement to 0 and get reclaimed. The second pass exists for the case where every page is dirty or epoch-protected at the moment we sweep — we still need a slot, so we make one more circle ignoring the heuristic but respecting the real eviction blockers (DC, writeback debt, ACW, SlotRefCount, AccessEpoch).

When both passes fail, the backpressure path kicks in (see §6). The clock hand _clockSweepCurrentIndex is a CacheLinePaddedInt to avoid false sharing with adjacent state.

Two micro-optimizations worth mentioning

AllocateMemoryPageCore has a fast prefix path: if filePageIndex - 1 is already cached in memPageIndex N, the allocator tries N + 1 first. This lets sequential page reads coalesce into a single disk write later when both pages flush.

CRC verification is lazy. EnsurePageVerified (line ~1436) runs only the first time a page is touched after load. If PageChecksumVerification.RecoveryOnly is set (the default until recovery completes), it's skipped entirely. After recovery, DatabaseEngine flips the mode to OnLoad — every fresh load verifies, then CrcVerified is cached until the slot is reused. On mismatch during recovery the page is recorded suspect — rebuilt if it backs a derived structure, or a loud failure if it still backs a live primary chunk (§7, and 11-durability §6); during normal operation a PageCorruptionException propagates.


3. ManagedPagedMMF — the database file manager

ManagedPagedMMF extends PagedMMF with what makes the file actually a database: a known root layout, an occupancy bitmap, and a bootstrap key/value store.

The first eight pages of an empty file (format v4)

Page 0: Meta slot A          — magic + format revision + bootstrap dictionary (CK-05 A/B meta pair)
Page 1: Meta slot B          — the alternate meta slot
Page 2: Occupancy segment root — directory-only LogicalSegment (lists [2, 4]); CK-05 directory page → twinned
Page 3: Occupancy root TWIN   — the root's second physical slot (CK-05)
Page 4: Occupancy first data page — the BitmapL3 L0 words live here (the directory-only root carries no data)
Page 5: Reserved for occupancy growth (next data page)
Page 6: Reserved for the occupancy bitmap's next map-extension directory page
Page 7: Reserved for that map-extension page's TWIN (it is itself a directory page → needs a twin)

InitialReservedPageCount = 8. The reserves are pre-allocated so the first occupancy grow doesn't need to chain through the allocator that's itself trying to grow. The directory-only root (v4) is why the occupancy bitmap needs a separate first data page (page 4): the root page holds only the segment's page directory, no bitmap words.

RootFileHeader — about 108 bytes

On disk at page 0, offset PageBaseHeaderSize (64), there's a small identity header:

[StructLayout(LayoutKind.Sequential)]
unsafe internal struct RootFileHeader
{
    public fixed byte HeaderSignature[32];    // "TyphonDatabase"
    public int        DatabaseFormatRevision; // currently 4 (CK-05 twins + directory-only root)
    public ulong      DatabaseFilesChunkSize;
    public fixed byte DatabaseName[64];       // verified on load
}

Total ~108 bytes. Everything dynamic — SPIs for segments, the checkpoint LSN, occupancy reserved page indices — moved out of the struct and into the bootstrap dictionary that follows immediately after, at BootstrapStreamOffset = PageBaseHeaderSize + sizeof(RootFileHeader).

BootstrapDictionary — typed key/value on page 0

BootstrapDictionary is a compact tagged-value stream. On disk:

[StreamLength:2B] [TypeTag:1B][Key:UTF8+NUL][Value:N bytes] ... [0xFF]

Supported value types: Bool (1 B), Int1..Int6 (4..24 B), Long (8 B), DateTime (8 B, ticks), String (UTF-8 NUL-terminated). The End sentinel is 0xFF. In-memory it's a Dictionary<string, Value> for O(1) lookup; the stream is rewritten in full on shutdown (and on bootstrap mutations).

Keys are conventional engine prefixes — OccupancyMapSPI, OccupancyReserved, CheckpointLSN are the storage-layer ones. The Ecs / Transactions / Durability / Schema layers each contribute their own (e.g. BK_NextFreeTSN, the schema SPIs).

Occupancy bitmap — BitmapL3 on a LogicalSegment

The free-page tracking lives in BitmapL3 — a three-level summarized bitmap stored in the file itself as the raw data of a LogicalSegment<PersistentStore>. Each L0 long covers 64 pages of the file; one data page worth of L0 longs (PageRawDataSize / 8 = 1000) covers 64 000 file pages, which is roughly 500 MiB of file data per occupancy data page. With the directory-only root (v4) the occupancy segment's root holds only the page directory — no bitmap words — so the L0 words start on the segment's first data page (genesis page 4). Bit mutations keep their data page dirty-until-checkpoint (even on allocation paths that pass no ChangeSet), so a torn/evicted occupancy page can never silently drop a freshly-set bit.

When occupancy gets full, GrowOccupancySegment consumes the pre-allocated _occupancyNextReservedPageIndex, allocates a fresh reserve, and the cycle continues. The L1/L2 summary levels live in heap memory (Memory<long> arrays) — they're rebuilt on load from the L0 bitmap (source of truth).

AllocatePages / AllocatePage enter under _occupancyMapAccess exclusive (see 01-foundation §1 — AccessControl) and walk the bitmap level-down to find a free run.


4. Segments & accessors

A segment is a typed view of a sequence of file pages. The base class LogicalSegment<TStore> handles the page-list bookkeeping; ChunkBasedSegment<TStore> adds fixed-size chunk allocation on top.

LogicalSegment<TStore> — page-list segment

Directory-only root (v4). The root page is a pure directory page: its entire 8000-byte raw-data area lists the first 2000 pages of the segment — it carries no segment data. If the segment grows beyond 2000 pages, additional map-extension pages are chained, each holding another 2000 (= PageRawDataSize / sizeof(int)) page indices. Because the root holds no data, the CK-05 twin that shadows every directory page (§7) protects only the immutable directory — never live data — and directory addressing is uniform (root and every extension page hold the same number of entries). One consequence: a segment always spans at least 2 pages (the directory root + at least one data page); the allocators clamp to this minimum.

Two relevant constants:

Constant Value Meaning
RootHeaderIndexSectionCount 2000 Page indices on the directory-only root page (= NextHeadersIndexSectionCount)
NextHeadersIndexSectionCount 2000 Page indices stored on each map-extension page

Forward traversal goes through the linked list in LogicalSegmentHeader (lives at offset PageBaseHeader.Size = 64 inside each segment page, in the metadata zone):

  • LogicalSegmentNextMapPBID — next map-extension directory page (0 = end)
  • LogicalSegmentNextRawDataPBID — next data page (0 = end)

The root page holds no usable data (the directory fills the whole PageRawDataSize); every data page (segment page 1+) has the full 8000 bytes.

Grow(newLength, ...) is lock-protected and volatile-publishes the new _pages array — concurrent reads always see a consistent index view. It is also all-or-nothing (PS-11): it initializes the new pages, then pins and latches the directory pages and the old tail before it writes any of them, so a grow that fails — typically on a page-cache back-pressure timeout — leaves the segment exactly as it was and gives back the pages it allocated. GetPage(i, epoch, ...) resolves the i-th segment page index through _store.RequestPageEpoch, returning a PageAccessor (a thin wrapper over the page address with typed Metadata<T> / RawData<T> / StructAt<T> slicing).

ChunkBasedSegment<TStore> — fixed-stride allocator

Stores fixed-size chunks (minimum stride 8 B). Each page reserves part of its 128-byte metadata zone for a per-page occupancy bitmap; chunks are allocated by setting bits in that bitmap via Interlocked.Or. Three precomputed values keep GetChunkLocation branch-free:

  • ChunkCountRootPage — chunks per root page. With the directory-only root (v4) the directory fills the whole root, so this is always 0 — chunk 0 lives on segment page 1, not the root.
  • ChunkCountPerPage — chunks per data (non-root) page
  • _pageDivider (ChunkPageDivider) — exact 64-bit magic multiplier for chunkIdx / ChunkCountPerPage (one Math.BigMul, ~3 cycles, vs 20–80 for division); replaces the 32-bit multiplier that overflowed at large chunk ids (#1204)

Alignment padding ensures chunks start at stride-aligned absolute page offsets — for stride = 128, each data page wastes 64 B (because PageHeaderSize = 192 isn't a multiple of 128). For stride = 64, padding is zero. (The root carries no chunks, so its alignment is moot.)

Room-bits bitmap

Free-page tracking is a room-bits bitmap (RoomBlock[] _roomBlocks): one bit per page, set when the page may have a free chunk (a conservative superset of the truth — a bit set on a full page is a false positive, corrected on the next allocation attempt). Three additional fields manage the invariant concurrently:

  • _roomGeneration — bumped each time a room or summary bit goes from clear to set; an allocation that found no room grows the segment only when this counter is unchanged since its search, preventing a grow past a chunk freed behind the scan.
  • _transientClears — counts allocations that have cleared a room or summary bit but not yet re-read the page or word; a grow waits for zero before concluding that no chunk is available.
  • _allocationCursor — next-fit hint; avoids rescanning from page 0 on every allocation.

AllocateChunk finds the first page with its room bit set (NextPageWithRoom), scans its per-chunk bitmap (Interlocked.Or with a bit mask), and on success registers the page with the caller's ChangeSet (AddByMemPageIndex, which records the bitmap write even when the set already tracks the page; MarkPageModified without one) + Interlocked.Increment(_allocatedCount). When the page is found full, the allocation clears its room bit and re-reads the page — setting the bit back if a chunk was freed in the window. FreeChunk clears the chunk's bitmap bit, then sets the page's room bit. The minimum chunk size is 8 bytes.

Growth — uses your ChangeSet

GrowChunkCapacity(minNewPageCount, changeSet) doubles the segment up to 1 024 pages and grows it 1 024 pages at a time beyond that (or to the minimum requested). It sets the new pages' room bits, then calls base.Grow, which clears each new page in full — chunk bitmap included — and records the modification before unlatching it, so no page is written after it is published and each is owed a write from that moment: see §5 below.

EnsureCapacity(minChunkCount, changeSet) is the pre-sizing entry point used by schema migration; GrowIfNeeded is the lazy variant used inside AllocateChunk when _allocatedCount == _capacity.

ChunkAccessor<TStore> — the hot-path accessor

ChunkAccessor<TStore> is the type the ECS / Indexing / Revision layers actually hold across operations. It's a struct (no heap allocation), tagged [NoCopy], with pure Structure-of-Arrays layout for SIMD search:

private fixed int  _pageIndices[16];    // 64 bytes — SIMD-searchable
private fixed long _baseAddresses[16];  // 128 bytes — direct pointer per slot

private ushort _dirtyFlags;             // 16-bit dirty bitmask (one bit per slot)
private byte   _clockHand;              // O(1) eviction cursor
private byte   _mruSlot;                // MRU shortcut
private byte   _usedSlots;              // high water mark

private int    _stride;
private int    _rootHeaderOffset;
private int    _otherHeaderOffset;

Three-tier hot path inside GetChunkAddress:

  1. MRU check — single-page workloads (B+Tree probes inside a node, ECS field reads on the same chunk) hit this without touching SIMD.
  2. SIMD Vector256 search — two 256-bit loads + equality compare across 16 cached page indices, scanned with ExtractMostSignificantBits + TrailingZeroCount.
  3. Clock-hand eviction — when no slot matches, the clock hand advances and the slot at the cursor is evicted. Eviction cannot fail: the cache has no pinned slots, the underlying page is protected by the active EpochGuard regardless of slot eviction.

memPageIndex for a slot is computed on demand from _baseAddresses[slot] minus _memPagesBaseAddr, shifted by PageSizePow2. This saves 64 B of state per accessor (no _memPageIndices[16]) at the cost of ~3 cycles in slow paths only.

Dirty marking: MarkSlotDirty(slot) sets the dirty bit, calls _store.IncrementActiveChunkWriters(memPageIdx), and registers the page with the ChangeSet. See §5 — this is the choreography that makes B+Tree splits torn-page-safe.


5. ChangeSet & dirty tracking

The page cache tracks three independent things per page:

Field Set by Cleared by Blocks
DirtyCounter (DC) — mutator marks IncrementDirty, through a ChangeSet (AddByMemPageIndex, one mark per page per set) DecrementDirtyByDelta, only by the ChangeSet that took the mark (via ReleaseDirtyMarks()) Eviction while > 0
WritebackGen / CapturedGen — writeback debt WritebackGen: every modification (IncrementDirty, MarkPageModified) CapturedGen: MarkCaptured, after the checkpoint's fsync Eviction while they differ
ActiveChunkWriters (ACW) IncrementActiveChunkWriters (in MarkSlotDirty) DecrementActiveChunkWriters (in CommitChanges, eviction queue) Checkpoint snapshot and eviction while > 0
  • DC > 0 means a mutation is in progress under someone's ChangeSet. The page stays resident until the owner releases its marks, exactly as many as it took (PS-05). The counter says nothing about the disk.
  • Writeback debt (WritebackGen != CapturedGen) means the page holds bytes that are not on the data file yet. Only a durable write clears it (PS-10): the checkpoint samples WritebackGen before its seqlock copy and publishes that sample with MarkCaptured after the fsync. Any number of modifications between two captures owe exactly one write, and a modification that lands after the sample leaves the page owed to the next cycle (CP-04).
  • ACW > 0 means a writer is mid-flight. Snapshotting now would capture partially-written data (e.g. a B+Tree node with odd OLC version, or a struct mid-update). Checkpoint skips this page on the current cycle; it'll be picked up next time.

ChangeSet — the per-UoW dirty page set

ChangeSet wraps a HashSet<int> of memory-page indices touched during a UoW. Public surface:

Method What it does
AddByMemPageIndex(int) Register a dirty page. The first call per page takes the set's one mark on it (IncrementDirty, which also records the modification); a later call — a re-dirty — takes no mark but still records the modification (MarkPageModified). Returns true if this was the first registration.
SaveChangesAsync() / SaveChanges() Structural writes only (bootstrap, schema evolution, segment growth, recovery replay): release this set's marks, then write its pages; their fsync clears the debt. The user-data path never calls it — the checkpoint writes those pages.
ReleaseDirtyMarks() Release every mark this set took — one per page it registered — then clear the set. The pages stay protected by their writeback debt until a checkpoint writes them.
Reset() Rollback — the same exact release. It does not clear the debt: the bytes were changed in place, so the page is still owed a write.
DeferEviction(entry) / FlushDeferredEvictions() Per-eviction SlotRefCount / ACW decrements that the persistent-store ChunkAccessor queues when a slot gets evicted mid-UoW, drained at commit.

ChangeSet is pooled via PagedMMF.RentChangeSet + ReturnChangeSet — a ConcurrentBag<ChangeSet> keeps reusable instances. Pool rental is the normal pattern; the new ChangeSet(this) path is only the cold start.

ReleaseDirtyMarks and the 128-op refresh

Called by the ECS path every 128 entity operations (EpochRefreshInterval = 128 — see 06-ecs §9) inside FlushAndRefreshEpoch, and again when the accessor is disposed. Marks last only as long as the work that took them: without the refresh, a 10 000-op transaction would hold every page it touched resident until it ended. Releasing all of them is safe because the writeback debt, not the counter, keeps unwritten bytes in the cache.

A new page is owed a write before it is published

LogicalSegment.InitDataPages clears and initializes each new page under its exclusive latch and records the modification before unlatching it: with a ChangeSet, AddByMemPageIndex takes the set's mark on it; without one, MarkPageModified records the debt alone. Directory map pages written by a grow are handled the same way. Either way WritebackGen has moved past CapturedGen by the time the page is reachable, so it cannot be evicted before a checkpoint has written it.

A checkpoint that captures the new page before its first writer (AllocateChunk → GetChunkAddress) arrives is harmless: the writer's modification moves WritebackGen past the captured sample, and the page stays owed to the next cycle (CP-04). GrowChunkCapacity called without a ChangeSet makes a local one and releases its marks before returning, on a throw too (PS-05), so a grow never strands a mark.

MarkSlotDirty re-registration — the re-dirty guard

MarkSlotDirty (in ChunkAccessor) has subtle behaviour for re-dirty. When _changeSet.AddByMemPageIndex(memIdx) returns false (page already tracked by this ChangeSet, dirtied again in a later accessor rental), AddByMemPageIndex takes no second mark but still records the modification. That record is what matters: a checkpoint can have captured the page between the two rentals (with ACW = 0 in between), and the bump moves WritebackGen past that capture, so the page stays owed to the next cycle, which captures the new bytes (CP-04). The page keeps the set's first mark until ReleaseDirtyMarks, so a second one would protect nothing.


6. Backpressure

When the clock-sweep returns nothing — every page is dirty, epoch-protected, or slot-pinned — the allocator enters the backpressure path. The strategy is pluggable:

internal interface IPageCacheBackpressureStrategy : IDisposable
{
    bool OnPressure(ref BackpressureContext ctx, int dirtyPageCount, int epochProtectedCount);
    void SignalPageAvailable();
}

WaitForIOStrategy is the default — a ManualResetEventSlim waited up to 50 ms per iteration, signalled when a page is left with no marks and no writeback debt — by PagedMMF.MarkCaptured after a checkpoint write, or by DecrementDirtyByDelta (ChangeSet.ReleaseDirtyMarks) releasing a page's last mark. Each retry re-checks BackpressureContext.ShouldGiveUp (see 01-foundation §2) against TimeoutOptions.Current.PageCacheBackpressureTimeout; if it expires, a PageCacheBackpressureTimeoutException propagates.

The factory hook on PagedMMFOptions:

internal Func<IPageCacheBackpressureStrategy> BackpressureStrategyFactory { get; set; }
    = () => new WaitForIOStrategy();

It's internal — meant to be set by the engine's test harness, not by application code.

OnBackpressure → ForceCheckpoint

Independent of the strategy, the moment the allocator decides backpressure is needed, it invokes OnBackpressure?.Invoke() — set by DatabaseEngine to CheckpointManager.ForceCheckpoint. This wakes the checkpoint thread immediately instead of waiting for the timer (default CheckpointIntervalMs = 30 000). The pipeline writes the owed pages in batches and, after each batch's fsync, publishes their captures (MarkCaptured), which calls SignalPageAvailable for every page left with no marks and no debt, which wakes the strategy's ManualResetEventSlim. End-to-end the worst case is one write batch — up to 8 192 pages or 100 ms (CK-15) — + the strategy's wait granularity.

PageCacheGaugeSnapshot — what the profiler sees

PageCacheGaugeSnapshot is the per-tick sampled view exposed to the profiler. Mutually-exclusive buckets (FreePages / CleanUsedPages / DirtyUsedPages / ExclusivePages) sum to TotalPages; overlay counters (EpochProtectedPages, PendingIoReads) are independent and may include pages from any bucket. Workbench renders the buckets as a stacked area chart — the mutually-exclusive invariant is what keeps that visualization honest.


7. Page CRC & seqlock writes

Two mechanisms let the checkpoint snapshot a live page consistently and verifiably without blocking writers: a seqlock counter on every page detects in-flight writes, and a CRC32C on every page detects a torn write after a crash. There is no FPI — full-page images are never written; torn pages are healed by re-derivation or fail the open loudly (see 11-durability §6).

The seqlock — ModificationCounter

Every page header has a ModificationCounter : int. Convention: even = quiescent, odd = mid-modification. TryLatchPageExclusive (≈ PagedMMF.cs:1262) bumps it from even → odd before any writes touch the payload; UnlatchPageExclusive (≈ :1303) bumps it odd → even after. Readers can detect a torn read by sampling the counter before and after their copy and rejecting the result on mismatch.

CopyPageWithSeqlock (≈ :1697) is the consumer used by checkpoint: it spins while the counter is odd, copies the page into staging, and re-checks the counter — if it changed, retry. There are two skip conditions: an odd counter on a page whose PageState is not Exclusive is a stale counter — no writer to wait for — and is skipped immediately (logged via LogStaleSeqlockCounterSkip); an odd counter held by a real exclusive writer for longer than the 100 ms threshold is also skipped (the writer is hung or in backpressure). A skipped page holds the checkpoint's coverage gate back (CK-03) but keeps its dirty bit / DC so the next cycle re-captures it.

Critically — InitHeader in LogicalSegment.cs preserves ModificationCounter and ChangeRevision across the clear (PS-14). Zeroing ModificationCounter while a page is latched would leave the seqlock counter odd after unlatch — a quiescent page falsely advertising a write. Zeroing ChangeRevision would let a reused page number's per-sector stamp go backwards, since PageSectorFooter embeds its low 16 bits in every sector. The stale-counter guard above skips such a page immediately rather than spinning, but the correct invariant is the counter (and the slot-reuse reset in TryAcquire): a quiescent page must read even.

The CRC — PageChecksum

Every page header also carries a PageChecksum : uint. It is a CRC32C (Crc32CUtil.ComputeSkipping in Foundation/, hardware-accelerated, ~0.4 µs/8 KB) over the whole page except the 4-byte checksum field. The checkpoint stamps it on the staging-buffer copy at write time (so it reflects exactly the bytes that hit disk), and EnsurePageVerified checks it on load (OnLoad mode) and during recovery. The CRC helper lives in Foundation/, not Storage/ — by design, the storage layer holds zero Wal / Lsn / Fpi identifiers (grep-enforced).

Torn-page safety — detect, then rebuild or loud-fail (no FPI)

When EnsurePageVerified finds a stored CRC that doesn't match the recomputed one, the response depends on the page's role (full table in 11-durability §6):

  • Derived pages (Index, Spatial, Occupancy) — discarded and rebuilt from primary data during recovery (RB-01 / CK-09). Always healable.
  • Primary pages (component/revision content, EntityMap, cluster, collections, string table, system) — recorded suspect; after rebuild, a suspect page that no longer backs a live chunk is healed, but one that still backs a live primary chunk fails the open loudly (RB-04). A torn primary is never silently served as intact.
  • Structural meta / segment-directory pages — protected by checkpoint v2's A/B slot-pairing: the current-valid slot is never overwritten, so a torn write can't destroy the only good copy.
Checkpoint v2 pipeline

8. Storage introspection

The Workbench's Database File Map (Module 15) reads the engine's storage state without touching data pages — everything comes from in-memory engine structures. The surface lives in StorageMapTypes and on ManagedPagedMMF itself:

Type Purpose
StoragePageType enum Semantic classification of one file page (Unknown / Free / Root / Occupancy / Component / Revision / Index / Cluster / Vsbs / StringTable)
StorageSegmentKind enum Runtime role of a logical segment (Component / Revision / Index / Cluster / Vsbs / StringTable / Occupancy / Other)
StorageSegmentDescriptor Read-only snapshot of one segment: root page, kind, owned pages, chunk-layout constants (stride, root/per-page chunk counts, data offsets)

ManagedPagedMMF (ManagedPagedMMF.StorageMap.cs) exposes:

  • StorageFilePageCount — total file pages
  • StoragePageSize — 8192
  • ReadOccupancyBits(Span<long>) — bulk-read the occupancy L0 bitmap into a caller-owned span

DatabaseEngine.EnumerateStorageSegments returns the list of StorageSegmentDescriptors for Workbench rendering. Everything here is read-only, no data-page I/O, and no allocation beyond what the caller's Span<long> provides.


9. Configuration

PagedMMFOptions is the only configuration surface in this layer:

Property Default Meaning
DatabaseName "TyphonDB" Logical name. Validated against ^[A-Za-z0-9_-]+$ and ≤ 63 UTF-8 bytes.
DatabaseDirectory Environment.CurrentDirectory Filesystem directory. Must exist. DatabaseAbsoluteDirectory returns the absolutized form.
DatabaseFileName DatabaseName (if unset) Validated with the same rules, but no longer names any file: a database is the bundle directory {DatabaseDirectory}/{DatabaseName}.typhon/, and the paged data file inside it is always named data (no extension).
DatabaseCacheSize 256 MiB (DefaultDatabaseCacheSize) Total page cache bytes. Must be a multiple of PageSize, at least MinimumCacheSize (8 MiB), at most 2³¹ − 1 pages; otherwise only what the host grants (#945).
PagesDebugPattern false Fill a page-cache slot with a debug pattern when it is assigned. No longer observable: a page not read from disk is cleared right after (PS-14), a page read from disk is overwritten.
BackpressureStrategyFactory (internal) () => new WaitForIOStrategy() Test hook to substitute the backpressure strategy.

Also exposed: EnsureFileDeleted() (best-effort delete of the backing file + lock file; for tests), IsValid / Validate(silent, out validation) (returns the multi-line error string or throws).

ManagedPagedMMFOptions is currently a marker subclass — same shape, separate type for DI binding.

The advisory lock file (db.lock inside the {DatabaseName}.typhon/ bundle directory) is created on open and carries { pid, machineName, startedAt, yieldable, profilerEndpoint } JSON — the last two optional, absent meaning false / none. A stale lock (process gone) is silently removed; a live lock (or a foreign-machine lock that can't be verified) raises DatabaseLockedException.


See also

  • 01-foundation — EpochManager / EpochGuard (page protection), AccessControlSmall (page state locks), CacheLinePaddedInt (clock hand), memory allocators (page cache pinning)
  • 05-revision — MVCC revision storage uses ChunkBasedSegment + ChunkAccessor and threads its ChangeSet through writes
  • 06-ecs — ComponentTable is the ECS-side consumer of segments; EpochRefreshInterval = 128 drives ReleaseDirtyMarks
  • 11-durability — the checkpoint v2 cycle; page CRC + seqlock snapshots; torn-page rebuild/loud-fail
  • 13-resources — PagedMMF registers itself as a ResourceNode (memory + I/O metrics on the Storage subtree)