Maintenance Scheduling
include/tess/maintenance.h and the narrow headers under
include/tess/maintenance/ define the stable source contract for derived-state
maintenance. They do not alter world construction, authoritative storage,
exact event handling, or simulation command execution. Include the maintenance
aggregate explicitly: the compatibility umbrella tess/tess.h deliberately
does not import it. This keeps experimental implementation spellings and their
platform-specific scheduler machinery out of the dependency-free core
umbrella. Because the facade is an alias-only source move, its headers
transitively make those experimental spellings reachable; reachability does not
make the virtual scheduler or deferred backends stable.
The stable spellings share the exact pre-graduation implementation types. This
mechanical source move keeps the controlled M3 and Steam Deck campaign
representative; stability attaches to the documented tess::maintenance
names and semantics, not to undocumented implementation namespaces, object
layout, or a virtual ABI.
Stable Surface
MaintenanceTaskis a long-lived operation over derived state.MaintenanceBudgetis a shared unit budget for a drain.ImmediateSchedulerexecutes each request synchronously. Self-schedules and A-to-B-to-A requests use an allocation-free iterative trampoline, preserving one execution per request without recursive task entry. A self-schedule that consumes no budget returnsfalseto report that the synchronous backend cannot make progress. A recursive scheduler lock serializes concurrent callers while permitting a running task to schedule itself or another task; each externalschedule()returns only after its own request executes. If a callback throws, its active trampoline frame is discarded, including reentrant follow-ups accepted into that call-local frame.MaintenanceMetricsreports schedules, collapsed schedules, executions, and capacity failures.
include/tess/maintenance/scheduler.h supplies the fixed-registration
contract. It deliberately uses a structural customization boundary:
RegisteredScheduler<Backend>preallocates opaque task slots. Tasks register during setup and the registry becomes immutable atseal(); post-seal release retires a slot permanently rather than reusing its identity.MaintenanceBackendis a compile-time structural boundary, not a virtual ABI. A custom backend supplies construction from capacity, an explicitScheduleResult, aBackendDrainResultfor run/flush, metrics, and a no-throw pending query. The facade serializes drain calls, but schedules may arrive concurrently with each other and with a drain. A custom backend must linearize scheduling against pending observations and drains, avoid concurrent invocation of one task, and allow a callback to schedule through the same facade. On normal return,Acceptedlinearizes when the offer executes synchronously or is retained/coalesced without loss.CapacityExhaustedretains and executes none of that offer, so the caller may retry.has_pending()linearizes against those operations and reports work still retained at its observation; a concurrent drain may consume accepted work before itsschedule()call returns. Metrics are thread-safe and monotonic but their no-throw diagnostic snapshot need not be transactional across fields while operations are in flight. If a callback throws, that invocation is consumed rather than restored and its execution is counted. Offers coalesced into the same synchronous call-local invocation are consumed with it; every independently retained accepted offer remains reachable. This is observable with the immediate backend, whose reentrant follow-ups belong to its active call frame. The exception itself propagates verbatim; authoritative dirty-mask and content-version state remains responsible for deciding whether to retry.FixedRegistrationBackendoptionally addsregister_task()andseal()as a required pair for implementations such as dirty-bit. At facade sealing, each live slot is registered once in slot order before one backend seal. Both hooks are no-throw; a rejected registration is an unrecoverable capacity mismatch, so partially published setup cannot escape through an exception. The facade owns fixed registration when those hooks are absent.MaintenanceHandlecontains private owner-epoch, slot, and generation state. Handles are copyable value tokens, but registered tasks and schedulers are non-copyable and non-movable. A task must outlive its registration. Destroying the scheduler releases every task; destroying a still-registered task fails fast in every build.try_schedule()returns no value for expected stale or foreign-handle uncertainty without scheduling work or changing scheduler metrics.schedule()fails fast for the same misuse. Registration after sealing, cross-scheduler task ownership, repeated unchecked release, and lifecycle mutation from a callback also fail fast independently of assertions. A callback may schedule another task through the same registered scheduler; nested identity, scheduling, drain, or lifecycle operations on a different registered scheduler fail fast across all backend types, avoiding an implicit cross-owner lock order. Calling a drain reentrantly from a callback also fails fast. The no-throw, read-onlymetrics()snapshot is deliberately callable from any callback owner.ScheduleResultdistinguishes accepted work, retryable queue-capacity exhaustion, and an immediate zero-progress stall.DrainResultdistinguishes a positive idle observation, work drained to quiescence, budget exhaustion with reachable work, and a zero-progress stall. Callback exceptions continue to propagate verbatim; the contract does not replace their type and message with a status. The throwing invocation and synchronous call-local follow-ups are consumed, independently retained accepted work remains reachable, and authoritative dirty-mask and content-version state remains responsible for the caller's retry decision.try_release()returns aReleaseResultthat distinguishes release, invalid identity, and missing idle evidence. Release never cancels work. After sealing, a separate drain must returnIdleafter the last accepted schedule before release is allowed; aDrainedresult requires one more observation. An in-flight schedule prevents that drain from returningIdle. Operations use a lifecycle barrier so release cannot race scheduling or draining through the wrapper.
Idle means only that the scheduler observed no reachable execution at entry
or exit without an intervening accepted schedule. It does not prove external
dirty state is clean, exclude a producer that has not scheduled its dirty
signal, or make world/residency mutation safe. An adapter must separately own
the producer and residency transition around its quiescent boundary: close and
join producers before the drain that establishes Idle, then keep them closed
through the residency transition. Likewise, canonical archive equality is
useful authoritative-state evidence but cannot validate dirty masks, content
or topology versions, residency generations, or derived products, which
archives intentionally omit.
Stable External Chunk Adapter
include/tess/maintenance/chunk_adapter.h is the stable world-backed consumer
of the registered contract. ChunkMaintenanceAdapter remains external to
storage: it borrows an immovable World, owns its scheduler, tasks, handles,
and derived product slots, and never changes world construction or ChunkMeta
layout. Its default backend is stable synchronous ImmediateScheduler. The
caller gives one nonzero dirty mask to one clearing owner and a typed rebuild
callback. Zero or foreign marks are rejected before mutation.
The operation results are deliberately explicit. ChunkMarkResult reports
accepted and rejected marks; ChunkProductView combines a product pointer,
ChunkProductState, and its token; ChunkResidencyResult carries a
ChunkResidencyStatus, which also reports reconciliation; ChunkEvictionResult
reports an eviction attempt; and ChunkAdapterReleaseResult distinguishes
released, not-idle, and already-released adapters.
Concurrent producers may offer scheduler work, subject to the world's
external-synchronization rules. Residency changes and task release instead
require exclusive adapter access: close and join producers before the drain
that establishes Idle, and keep them closed through the operation.
Dense task slots map directly to chunk keys. Sparse task slots map to the
world's fixed resident slots and carry {key, residency_generation} bindings.
Sparse binding changes are permitted only after producers are closed and
joined and an explicit adapter drain has returned a fresh positive Idle.
The caller keeps producers closed through an adapter-owned residency batch.
Direct sparse residency mutation after binding is unsupported; a coordinated
archive load is reconciled explicitly at the same quiescent boundary. Tasks
recheck resident_ref() before unchecked sparse access, but those checks do
not make unsynchronized world mutation safe.
Each completed product carries ChunkProductToken{key, content_version,
residency_generation} and is classified as unavailable, stale, or current.
The callback writes derived state only; authoritative fields and archive bytes
do not depend on backend selection. After a successful callback the adapter
publishes the token and uses clear_dirty_observed() to clear exactly its
observed bits. An intervening mark or residency change leaves the token stale
and work retryable. Because the product token records the shared chunk content
version, a disjoint dirty owner can also stale it without setting this
adapter's bits. An explicit retry() rebuilds that version drift even when the
owned observation is empty and never clears the other owner's mask bits. A
callback exception propagates verbatim, may leave a partial but stale product,
leaves the owned dirty signal or shared content-version drift intact, and
requires an explicit caller retry.
mark_dirty() records authoritative dirty-mask/content-version state before
scheduling,
so queue capacity failure cannot erase the retry signal. Scheduling remains
coalescing. If a generation-safe clear fails and its follow-up cannot enter a
bounded comparison queue, a preallocated per-slot bit retains that logical
retry. Unbounded flush() reoffers this debt before and after backend work; a
post-drain reoffer or still-retained debt reports BudgetExhausted, never
Idle, and therefore cannot open residency or release. Budgeted drains remain
explicit: run_some() only reports retained debt as BudgetExhausted because
a structural backend may execute an accepted offer synchronously outside the
supplied budget. Explicit retry() or unbounded flush() performs
re-admission. Warmed adapter scheduling and draining allocate only if the user
rebuild callback does. The self-checking
examples/chunk_maintenance.cc shows the stable immediate default while
inspecting the dirty mask, content version, product token, and backend metrics.
An installed-package consumer compiles and runs the same workflow, and a
second installed-package consumer proves registration, opaque handles, sparse
residency, budgeted draining through a consumer-defined structural backend,
explicit flush, and checked shutdown against only stable spellings.
Queued backends allocate their pointer ring only during construction. A task
must outlive its scheduler or a completed flush(). Destroying a scheduler
with pending tasks drops the non-owning pointers without executing them.
Capacity exhaustion returns false; the authoritative dirty signal must remain
set so the caller can retry. Tasks inspect content versions or dirty masks,
clear only the state they actually rebuilt, and may schedule follow-up work
when budgeted work remains. A queued task that successfully schedules any
follow-up on its executing thread without consuming budget stops that drain
with false instead of allowing direct or cross-task rescheduling to spin
forever. The follow-up remains queued for caller intervention. A schedule call
from another thread is an independent producer and is not attributed to the
running task. Concurrent drain calls and immediate schedule calls are
serialized, so a task never executes against itself. A task may call only
schedule() on a scheduler while it runs; calling run_some() or flush()
reentrantly is outside the contract because queued drains hold their
non-recursive serialization lock.
DirtyBitScheduler has a distinct setup phase. Registration is idempotent up
to the configured capacity, and registration racing with seal() either
completes before publication or is rejected. Scheduling and draining are
rejected before sealing, and post-seal registration is rejected. The registry
stores non-owning pointers, so registered tasks must outlive the scheduler or
a completed flush(). Destroying the scheduler drops pending bits without
executing tasks. Allocation-sensitive callers must warm every producer and
drain thread with a successful schedule or task execution before entering the
measured or allocation-prohibited region; seal() warms no thread. A thrown
task consumes only its own claimed bit; other claimed or concurrently scheduled
tasks remain pending for a later drain.
A queued backend removes an entry before invoking its task. If the task throws, the exception propagates and that queue entry is not restored. The task's authoritative dirty-mask and content-version state must remain set; after inspecting partial effects, the caller decides whether explicitly scheduling a retry is safe.
Coalescing is not exact-event delivery. Authoritative gameplay events remain on exact queues and simulation phases. Explicit flush points define when a consumer may depend on completed derived state.
Experimental Backends and Promotion Decision
include/tess/experimental/maintenance.h retains the virtual
MaintenanceScheduler interface plus the FifoScheduler,
CoalescingScheduler, and registered DirtyBitScheduler backends. The
stable facade does not re-export them. An application
may explicitly supply one of those types as a backend, but doing so does not
make that backend or the virtual interface stable.
The registered dirty-bit implementation passed correctness, determinism, generation-safe clear, exception, shutdown, allocation, ASan/UBSan, and TSan gates. In the controlled portable campaign, its M3 result was flat with no material regression. The Steam Deck result was a material regression against the immediate guardrail in budgeted, flush, and the 256- and 1,024-task scaling workloads; the 4,096-task scaling result was inconclusive. The cross-hardware rule therefore keeps dirty-bit experimental. FIFO and queued coalescing remain experimental comparison machinery for the same reason.
The stable adapter defaults to the measured immediate implementation. No measured implementation body, adapter body, MNT-3 campaign configuration, compiler or benchmark flag, benchmark, or fixture changed during graduation, so the campaign remains representative. The generic paired-sentinel source map now identifies the stable alias directory as requiring the dedicated campaign; that CI metadata was not an MNT-3 measurement input. No scheduler is embedded in world storage, world construction is unchanged, and exact events and authoritative simulation paths remain outside maintenance. See the optimization log and design-decision history for the retained measurements and authority decision.