Table of Contents

Epoch-Based Page Protection & Dirty-Page Tracking

Per-page eviction safety built from an epoch tag plus four counters, replacing per-page reference counting entirely.

Status: โœ… Implemented ยท Visibility: Internal ยท Category: Storage

๐ŸŽฏ What it solves

The page cache must never reclaim a memory page that a live reader still has a raw pointer into, that has modifications not yet durable on disk, or that a B+Tree write is mid-flight on โ€” but the general "don't evict what's in use" problem is already solved by Epoch-Based Resource Protection. What's specific to the page cache is everything epoch scoping alone can't express: a page can still be "in use" after the transaction that wrote it has exited its scope (it's dirty and not yet checkpointed), or while a B+Tree's lock-free write is in flight on it (no exclusive latch is held), or while a ChunkAccessor slot holds a cached raw pointer into it across an eviction. Each of these needs its own protection signal layered on top of the epoch tag.

โš™๏ธ How it works (in brief)

Every cached memory page carries an AccessEpoch tag (stamped to the current epoch on each access, via compare-and-swap so it only ever increases) plus four independent counters: DirtyCounter (mutator marks a unit of work still holds on the page), ActiveChunkWriters (in-flight lock-free B+Tree writes, which checkpoint's snapshot must not race), SlotRefCount (live ChunkAccessor slot references to the page's raw address), and a writeback-debt flag (HasWritebackDebt โ€” WritebackGen != CapturedGen) that tracks whether the checkpoint has captured this page's most recent modification. A page is only eligible for clock-sweep eviction when all five say it's safe: epoch stale, no dirty marks, no active writers, no slot references, and no writeback debt. On the transactional (unit-of-work) write path, dirty marks are produced by a ChangeSet attached to a unit of work โ€” every mutated page is registered exactly once, the mark count is conservation-tracked (so concurrent unit-of-work scopes touching the same page never under- or over-release it), and disposal releases every mark the UoW took. Every mark also records the modification, so the page keeps its writeback debt โ€” and stays unevictable โ€” until a checkpoint has written it durably. In-place cluster writes โ€” ClusterRef.GetSpan + MarkDirty, both WriteSpatial overloads, and the fence's EmitArchetypeFenceRange โ€” bypass the ChangeSet entirely and record pages as writeback debt directly via MarkPageModified, without incrementing DirtyCounter.

๐Ÿ’ป Usage

This is transparent engine plumbing โ€” every component write, index update, and B+Tree mutation registers its dirty pages and exits its epoch scope automatically. There is no API to mark a page dirty or to pin it directly:

using var uow = db.CreateUnitOfWork();            // owns the ChangeSet for this scope
using var tx = uow.CreateTransaction();           // epoch scope entered here

EntityRefMut e = tx.OpenMut(entityId);
Position p = e.Read<Position>();         // touched pages: epoch-tagged
p.X += 1f;
e.Set<Position>(p);                      // the written page: DirtyCounter++, writeback debt raised
tx.Commit();                                      // epoch scope exited; dirty marks released
                                                   // (the debt stays until a checkpoint writes the page)

The one place application code interacts with this layer is sizing the cache and handling the failure mode when it's undersized โ€” a single transaction's working set must fit within DatabaseCacheSize, since every page it touches is epoch-protected for the scope's whole lifetime:

services.AddScopedManagedPagedMemoryMappedFile(options =>
{
    options.DatabaseCacheSize = 64UL * 1024 * 1024;   // 8192 pages @ 8KB; size above the largest expected UoW working set
});

try
{
    BulkInsertThousandsOfEntities(db);
}
catch (PageCacheBackpressureTimeoutException ex)
{
    // No evictable page after waiting TimeoutOptions.Current.PageCacheBackpressureTimeout (default 5s).
    // ex.DirtyPageCount / ex.EpochProtectedCount say which protection is holding pages back.
    // Fix: enlarge DatabaseCacheSize, or split the transaction into smaller batches.
}
Option Default Effect
PagedMMFOptions.DatabaseCacheSize (or TyphonOptions.PageCacheSize) 256 MiB Total page-cache budget (bytes); must exceed the largest single transaction's unique-page working set. 8 MiB minimum; below a 64 MiB recommended floor logs a warning
TimeoutOptions.Current.PageCacheBackpressureTimeout 5s How long allocation waits for a page to become evictable before throwing

โš ๏ธ Guarantees & limits

  • A clean, epoch-stale, unreferenced page is always reclaimable โ€” eviction never blocks on anything but these five signals; there's no separate "this page is special" escape hatch to reason about.
  • Dirty data is never evicted before it's durable โ€” writeback debt clears only after the checkpoint's fsync has made the page's captured bytes durable, and a modification that lands after the capture leaves the page owed to the next cycle; mark releases on commit, rollback and crash-recovery paths are exact and cannot make DirtyCounter go negative.
  • In-flight lock-free B+Tree writes are checkpoint-safe โ€” ActiveChunkWriters blocks the checkpoint's page snapshot (and eviction) while a write is in flight; it exists because optimistic B+Tree writes don't take the page's exclusive latch the way ordinary writes do.
  • Raw pointers stay valid across deferred slot eviction โ€” SlotRefCount protects a page for as long as any ChunkAccessor slot โ€” even one logically evicted from its warm cache โ€” might still be dereferenced through a pointer the engine cached.
  • Known limitation โ€” a refresh window's working set must fit the cache: protection lasts until the holder's next epoch refresh. A transaction refreshes every 128 entity opens, spawns or enumerated entities (#1189), so it holds only what it touched since; a parallel worker is refreshed once per system. Whatever touches more unique pages than the cache holds within one window cannot proceed โ€” every page it touches stays unevictable by its own epoch tag. Size DatabaseCacheSize above that footprint.
  • No per-page synchronized increment/decrement on the read path โ€” only writes register dirty marks; the epoch tag (shared with the underlying scope mechanism) is already paid for once per transaction.

๐Ÿงช Tests

  • EpochPageCacheTests โ€” epoch-tagged page stays protected while a scope is active, becomes evictable only after scope exit
  • ChangeSetConservationTests โ€” DirtyCounter conservation across add/release/reset/checkpoint-ack, never over- or under-releases
  • CheckpointManagerTests โ€” ActiveChunkWriters coverage gate blocks checkpoint capture on a pinned page and unblocks once released