ADR-110 - Purpose-Gated Live Loads¶
Status: Proposed Date: 2026-10-08 (amended 2026-10-13: unpinned fallback for fresh worlds) Extends: ADR-016 (Anvil read-only data source), ADR-015 (chunk tickets and stale guard) Related: ADR-109 (probe-first governor)
Context¶
Every full candidate check resolves its chunk through RTPWorld.getOrLoadChunk: RTP cache, then the region-file view (ADR-016), then a native (server) load. Queue fill (RegionCacheTask -> PregenTask) used the same chain as an immediate teleport, so whenever the region file could not answer (ungenerated chunk, corrupt entry, non-Anvil format, anvilPrefilterEnabled: false) fill loaded, and generated if needed, the center chunk, the safety neighbour grid, and the (2r+1)^2 success ring (r = max(safetyRadius, viewDistanceSelect)).
None of that was pinned. PregenTask returned results without a reservation; the shared-queue path ignored the ring and stored the location unpinned in unkeptLocations; the per-player path waited for the ring, then stored it unpinned too. Native loads without a ticket are typically unloaded before the location is used.
A native load costs the server far more than RTP's own timing shows: memory for the full chunk, light and entities; scheduling work it owns (generation workers, on Folia the owning region); and churn in its open region-file cache. ADR-109 measures only RTP-side wall time, so it cannot price this. A load whose result is discarded is pure cost, except when it generated the chunk: the saved chunk is then readable from its region file.
Decision¶
- Purpose travels with generation.
LoadPurpose { IMMEDIATE, SPECULATIVE }is set onPregenState.RegionCacheTaskfills withSPECULATIVE(shared and per-player). Every other caller (empty-queue fallback,/rtp biome:..., immediate teleport) keepsIMMEDIATEand today's behaviour. Third-partyILocationGeneratorimplementations keep their own contract. - Non-loading read SPI.
RTPWorld.getChunkIfReadable(cx, cz)resolves from a resident chunk or the region file and completes withnullwhere only a native load could answer;getOrReadChunkcomposes it with the cache. Bukkit/Paper, Folia, NeoForge and Fabric common / common-unobf override it with their region-file path. The default serves resident chunks only (used by the v26 Fabric adapters, which have no region-file path). - Speculative fill reads first and prefers a pin for native loads. Center chunk and safety neighbours resolve through the read SPI. On a miss the attempt either:
- pins: claims a slot from
LiveLoadGate(per region,keptLocations.size() + pinsInFlight < activeChunkCap), then proceeds with native loads and returns its result with aChunkReservation, whichRegionCacheTaskhands to the kept (pinned) queue; if the kept queue refuses, the reservation is closed and the location is stored unpinned; - loads unpinned: no slot free, so center and safety neighbours load natively without a pin and are validated on load, and the result goes to
unkeptLocationswithout a success ring (item 4). Public fills in flight are bounded by the cache deficit; the cache pipe's pacer, charged each fill's wall time to completion, sets how many start per pulse; or - defers: no slot free and the heap is under pressure (
HeapPressureMonitor,maxHeapPercent), so the candidate is skipped (deferred/...outcome) without touching the bad-chunk tables and the fill run ends without the exhaustion dump; a backlog candidate returns to the backlog asUNKNOWN(ADR-028); the next fill pulse retries. A slot is released on every attempt exit (reschedule, success, exhaustion, deferral). - No unpinned ring. A speculative result that never pinned completes without the success ring; the teleport-time preload (
viewDistanceTeleport, ADR-072) loads it when a player is waiting. The per-player fill path likewise enqueues without building a ring. - Resident chunks are free. A chunk the server already holds is always used, whatever the purpose.
- ADR-109 prices file reads only. On the speculative path, native-load time is excluded from the probe governor's samples.
- Observable.
LiveLoadGateRowper region inRTPMetricsExtension.liveLoadGates: read-resolved, deferred (center / neighbour), pinned loads, pinned kept / overflow, unpinned loads, rings skipped, pins in flight, and resident-at-use counts split by pinned / unpinned (QueueTaskchecksisChunkLoadedwhen a queued location is consumed). A 60 s per-region[RTP][load-gate]INFO summary is temporary verification output and may be lowered to FINE once accepted.
Consequences¶
- Fill never preloads an unpinned success ring; retained memory from fill is bounded by
activeChunkCappinned results. Unpinned loads hold their center ticket (ChunkReservation) only for the attempt and release it on every exit (S-002). - On fresh / mostly ungenerated worlds or non-Anvil formats, once the kept queue is full the cold queue fills through unpinned loads of center and safety neighbours. On a fresh world that load is also the generation: the chunk is saved and later reads (cold-to-hot promotion, teleport preload) come from the region file. A pin-only rule left
cacheCappermanently empty on fresh worlds and re-ran a fully deferred 32-attempt fill train every pulse, each ending in the "Adjust your configuration" exhaustion log. ChunkReservationtickets only the set's center chunk; ring chunks of a pinned result are held by the server's ticket propagation around it, not individually.unkeptLocationsentries from fill are verified from file data or on load; the existing teleport-timeisSafere-check (ADR-016 section 4) remains authoritative (S-001).- Status moves to Accepted once a stress run shows lower MSPT / memory from fill without a teleport-latency regression, and the resident-at-use counts confirm pinned results survive until use.
Alternatives Considered¶
| Alternative | Why Rejected |
|---|---|
| Status quo (fill loads natively) | Loads and generates chunks that are typically unloaded before use; costs the server memory and scheduling RTP never measures. |
| Defer strictly (never load natively in fill) | Fill produces nothing on fresh worlds or non-Anvil formats. |
| Pin-only native loads (defer once the kept queue is full) | Shipped first. On a fresh world the cold queue never filled, and fill re-ran a fully deferred train every pulse. Its premise that an unpinned load is pure cost ignores that the load also generates and saves the chunk. |
| Cap speculative loads by measured unloaded-before-use rate | Wasted loads happen before the measurement reacts; pinning bounds the memory by construction. |
| Let ADR-109 price native loads | Server-side memory and scheduling costs are invisible to RTP-side timings. |
| Copy unpinned loads into detached chunk snapshots | A second chunk-data path beside the region-file view the anvil subsystem already provides, with adapter code on every platform. Validating on load under the attempt's own short-lived ticket needs neither. |