ADR-023 — Login Reserve Cache (loginLocations)¶
- Status: Accepted
- Date: 2026-04-30
- Supersedes: —
- Related: ADR-015 (stale-chunk guard / count-bound pipes), ADR-016 (anvil subsystem),
REQ-RTP-S-005(no chunk loading on the main thread)
Context¶
Players who teleport via the existing rtp.onevent.firstjoin / rtp.onevent.join
permissions hit the regular keptLocations cache on join. On a busy server the
join-storm at peak hours can drain the cache faster than the per-region
Region.execute() deficit loop can refill it, causing measurable join-time
latency spikes for permission-gated auto-RTP.
A user request was raised to add a secondary kept cache, sized to the remaining empty player slots, dedicated to login-time teleports.
Decision¶
Introduce a third, optional location buffer per region — loginLocations —
maintained on its own behavioral loop, decoupled from Region.execute().
Scope¶
- Allocated only on the region attached to the default world
(
Bukkit.getWorlds().get(0));nullon every other region. - Allocated only when
PerformanceKeys.loginCacheEnabled = true. - Hard-capped at
loginCacheCap(orBukkit.getMaxPlayers()snapshotted at plugin enable whenloginCacheCap = 0).
Fill loop (mirrors, does not modify, the existing kept-promotion path)¶
A new LoginCacheTask mirrors (does not refactor) the unkept→kept promotion
block from Region.execute():
unkeptLocations.pollSilently()— same DB-key re-offer race protection.- Async chunk load via
RTPWorld.getChunkAtAsync. - Second-pass
RTPChunk.isSafeverification on the chunk's owning region thread (Folia) or inline (Spigot/Paper). Fail-closed on any verify exception. - On success: create
ChunkReservation,loginLocations.offer(...). - On rejection / unsafe verdict: close reservation, return location to
unkeptLocations(or purge the now-unsafe DB row viaoffer+pollon the unsafe path).
The fill loop is triggered by exactly two events:
- Startup burst:
RTPBukkitPlugin.onEnable→LoginCacheTask.promoteUpTo(cap - online). PlayerQuitEvent:OnPlayerQuit→LoginCacheTask.promoteUpTo(1)per region withloginLocations != null.
Region.execute() is not modified. The existing kept-promotion path is
preserved bit-for-bit, protecting the invariants covered by
ReqRtpS004NullChunkAttributionTest and the ADR-015 / ADR-016 design.
Consumption (join-time)¶
OnEventTeleports.onPlayerJoin invokes a new private primeFromLoginCache
helper before the existing teleportAction call. When the player's region has
a non-empty loginLocations, one entry is polled and stored in the player's
fastLocations future, so the existing TeleportPipelineTask consumes it
instantly without re-loading chunks.
The existing cooldown gate in onPlayerJoin is preserved; if the player is
within cooldown, primeFromLoginCache is never called and the buffer is
unaffected.
Persistence (deliberate non-decision for v1)¶
The login buffer is in-memory only. On RegionQueueManager.disableLoginCache
(reload) and on shutdown, entries are drained back to unkeptLocations (with
reservations closed). The DB row already persisted under the unkept-bucket save
callback survives the round-trip. Behavior is indistinguishable from a
persisted login bucket — only DB row labelling would differ.
A future ADR may introduce a separate rtp_login_cache table if row-level
provenance becomes operationally useful (see follow-up tracking in
POTENTIAL_BUGS.md / a future ADR).
Consequences¶
Positive¶
- Zero risk of regression on the proven
Region.execute()deficit loop. - Two truly independent loops, each easy to reason about.
- Event-driven sizing (
+1 on disconnect, −1 on join consume) naturally matches the "remaining empty slots" semantic without periodic recomputation. - S-005 compliance —
LoginCacheTaskusesgetChunkAtAsyncexclusively; Folia region-thread verification is preserved. - Default off — no behavioral change for existing deployments.
Negative¶
- Code duplication: ~80 lines of promotion logic are mirrored, not extracted. Justified by the user requirement "we should mirror it and run separately" and by the regression-risk profile of the existing block.
- The login buffer competes with
keptLocationsfor entries fromunkeptLocations. On a smallcacheCap, this can briefly starve the regular cache during the startup burst. Mitigated by the buffer's hard cap and bycacheCaptypically being sized well abovemaxPlayersin practice. - No persistence delta beyond the unkept bucket — a server restart loses ChunkReservation state on the login buffer (entries demote to unkept on shutdown and re-promote on next startup burst). Acceptable for v1.
Neutral¶
- Fabric port landed 2026-05-11 (see Fabric port below). The core fill task
was already platform-agnostic; only the startup-burst, quit-refill, and
join-consumption listeners needed Fabric mirrors. No
rtp-corechange was required. - Cross-server / proxy interaction: per
MULTI_SERVER_PLAN.mdPer-player caches stay local-only, the login reserve is not consulted by the proxy network wait queue. Cross-network joins receive a reservation token drawn fromkeptLocations/unkeptLocationsinstead, bypassing this buffer entirely. The local consumption path inOnEventTeleports/FabricOnEventTeleportsis unaffected.
Configuration¶
performance.yml:
loginCacheEnabled: false # ADR-023 toggle
loginCacheCap: 0 # 0 = auto = Bukkit.getMaxPlayers() at plugin enable
Implementation map¶
| Concern | Class / file |
|---|---|
| Config keys | PerformanceKeys.loginCacheEnabled, PerformanceKeys.loginCacheCap |
| Buffer | RegionQueueManager.loginLocations (nullable) |
| Lifecycle | RegionQueueManager.enableLoginCache(int), RegionQueueManager.disableLoginCache() |
| Fill task | io.github.dailystruggle.rtp.common.selection.region.LoginCacheTask |
| Startup burst (Bukkit) | RTPBukkitPlugin.initLoginReserveCache (called from onEnable) |
| Startup burst (Fabric) | FabricEventBridge.initLoginReserveCache (called from onServerStarted) |
| Lazy refill (Bukkit) | OnPlayerQuit → LoginCacheTask.promoteUpTo(1) |
| Lazy refill (Fabric) | FabricEventBridge.refillLoginReserveOnQuit (called from the Disconnect proxy) |
| Consumption (Bukkit) | OnEventTeleports.primeFromLoginCache |
| Consumption (Fabric) | FabricOnEventTeleports.onJoin → primeFromLoginCache |
| First-join probe (Fabric) | FabricOnEventTeleports.hasPlayedBefore (probes <worldRoot>/playerdata/<uuid>.dat) |
| Permission gate (Fabric) | FabricRTPPlayer.hasPermission → fabric-permissions-api with ops.json fallback |
| Template | rtp-plugin/src/main/resources/performance.yml |
Fabric port (2026-05-11)¶
The login reserve cache is fully wired on Fabric:
- Bootstrap.
FabricEventBridge.initLoginReserveCache(server)runs atSERVER_STARTED(after worlds are registered and the DB is initialised), resolves the overworld region by name viaMinecraftServer.overworld()(reflective; toleratesmethod_30002intermediary drift), and sizes the buffer tologinCacheCaporMinecraftServer.getMaxPlayers()when the key is0. The startup burst dispatchesLoginCacheTask.promoteUpTo(cap - online). - Refill. The reflective
ServerPlayConnectionEvents$Disconnectproxy inFabricEventBridgecallsrefillLoginReserveOnQuit(), which iteratespermRegionLookupand dispatchesLoginCacheTask.promoteUpTo(1)per region whoseloginLocationsis non-null — exact mirror of BukkitOnPlayerQuit. - Consumption.
FabricOnEventTeleports.onJoinmirrorsOnEventTeleports#onPlayerJoin: perm gate (rtp.onevent.firstjoinvs.rtp.onevent.join) → cooldown check →primeFromLoginCache→teleportAction. The join proxy inFabricEventBridgecalls adispatchJoinRtp(Object)bridge so the proxy's synthetic class does not pinServerPlayer(intermediaryclass_3222) into its bytecode constant pool — same pattern asFabricServerAccessor.registerPlayerObject. - Permissions. No Fabric-specific perm wrapper was needed:
FabricRTPPlayer.hasPermissionalready consultsfabric-permissions-api(LuckPerms-Fabric et al.) with an op-levelops.jsonfallback, soParsePermissions.hasPerm(sender, "rtp.onevent.", …)works unchanged. - First-join detection. Vanilla has no
Player.hasPlayedBefore()equivalent.FabricOnEventTeleports.hasPlayedBeforeprobes<worldRoot>/playerdata/<uuid>.datviaMinecraftServer.getWorldPath(LevelResource.ROOT)with a reflective fallback for mapping drift. Vanilla writes that file on first auto-save after a player joins, so its absence at JOIN time is a reliable "never-seen-before" signal — parity with Bukkit, which reads the same file under the hood. - Tests.
ReqFabricAdr023HasPlayedBeforeTestcovers the first-join probe (6 cases: missing file / empty playerdata dir / file present / unrelated UUID present / file-is-directory edge case / null inputs). 6/6 green.
Closes MULTI_PLATFORM_PLAN.md E3-5 for the login-reserve path.
Follow-ups (out of v1 scope)¶
- Dedicated
rtp_login_cacheDB table for row-level provenance. loginCacheMode = EVERY_LOGIN | FIRST_LOGINtoggle (currently both modes are covered implicitly by the existingrtp.onevent.firstjoin/rtp.onevent.joinpermissions).- REQ-traceable test classes for the Bukkit-side promotion + join-prime paths:
LoginCachePromotionTest(mirror of the kept promotion test against the new task) andLoginCacheJoinPrimeTest. The Fabric first-join branch is already covered byReqFabricAdr023HasPlayedBeforeTest. - Telemetry:
loginReserveExhaustion,loginFill,loginCapexporters (METRICS_PLAN.md).