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
DirtyCountergo negative. - In-flight lock-free B+Tree writes are checkpoint-safe โ
ActiveChunkWritersblocks 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 โ
SlotRefCountprotects a page for as long as anyChunkAccessorslot โ 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
DatabaseCacheSizeabove 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 โ
DirtyCounterconservation across add/release/reset/checkpoint-ack, never over- or under-releases - CheckpointManagerTests โ
ActiveChunkWriterscoverage gate blocks checkpoint capture on a pinned page and unblocks once released
๐ Related
- Related feature: Epoch-Based Resource Protection (the underlying scope mechanism), Page Allocation & Occupancy Tracking (the layer below, file-page bookkeeping)