Skip to content

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)); null on every other region.
  • Allocated only when PerformanceKeys.loginCacheEnabled = true.
  • Hard-capped at loginCacheCap (or Bukkit.getMaxPlayers() snapshotted at plugin enable when loginCacheCap = 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():

  1. unkeptLocations.pollSilently() — same DB-key re-offer race protection.
  2. Async chunk load via RTPWorld.getChunkAtAsync.
  3. Second-pass RTPChunk.isSafe verification on the chunk's owning region thread (Folia) or inline (Spigot/Paper). Fail-closed on any verify exception.
  4. On success: create ChunkReservation, loginLocations.offer(...).
  5. On rejection / unsafe verdict: close reservation, return location to unkeptLocations (or purge the now-unsafe DB row via offer+poll on the unsafe path).

The fill loop is triggered by exactly two events:

  • Startup burst: RTPBukkitPlugin.onEnableLoginCacheTask.promoteUpTo(cap - online).
  • PlayerQuitEvent: OnPlayerQuitLoginCacheTask.promoteUpTo(1) per region with loginLocations != 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 — LoginCacheTask uses getChunkAtAsync exclusively; 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 keptLocations for entries from unkeptLocations. On a small cacheCap, this can briefly starve the regular cache during the startup burst. Mitigated by the buffer's hard cap and by cacheCap typically being sized well above maxPlayers in 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-core change was required.
  • Cross-server / proxy interaction: per MULTI_SERVER_PLAN.md Per-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 from keptLocations / unkeptLocations instead, bypassing this buffer entirely. The local consumption path in OnEventTeleports / FabricOnEventTeleports is 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) OnPlayerQuitLoginCacheTask.promoteUpTo(1)
Lazy refill (Fabric) FabricEventBridge.refillLoginReserveOnQuit (called from the Disconnect proxy)
Consumption (Bukkit) OnEventTeleports.primeFromLoginCache
Consumption (Fabric) FabricOnEventTeleports.onJoinprimeFromLoginCache
First-join probe (Fabric) FabricOnEventTeleports.hasPlayedBefore (probes <worldRoot>/playerdata/<uuid>.dat)
Permission gate (Fabric) FabricRTPPlayer.hasPermissionfabric-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 at SERVER_STARTED (after worlds are registered and the DB is initialised), resolves the overworld region by name via MinecraftServer.overworld() (reflective; tolerates method_30002 intermediary drift), and sizes the buffer to loginCacheCap or MinecraftServer.getMaxPlayers() when the key is 0. The startup burst dispatches LoginCacheTask.promoteUpTo(cap - online).
  • Refill. The reflective ServerPlayConnectionEvents$Disconnect proxy in FabricEventBridge calls refillLoginReserveOnQuit(), which iterates permRegionLookup and dispatches LoginCacheTask.promoteUpTo(1) per region whose loginLocations is non-null — exact mirror of Bukkit OnPlayerQuit.
  • Consumption. FabricOnEventTeleports.onJoin mirrors OnEventTeleports#onPlayerJoin: perm gate (rtp.onevent.firstjoin vs. rtp.onevent.join) → cooldown check → primeFromLoginCacheteleportAction. The join proxy in FabricEventBridge calls a dispatchJoinRtp(Object) bridge so the proxy's synthetic class does not pin ServerPlayer (intermediary class_3222) into its bytecode constant pool — same pattern as FabricServerAccessor.registerPlayerObject.
  • Permissions. No Fabric-specific perm wrapper was needed: FabricRTPPlayer.hasPermission already consults fabric-permissions-api (LuckPerms-Fabric et al.) with an op-level ops.json fallback, so ParsePermissions.hasPerm(sender, "rtp.onevent.", …) works unchanged.
  • First-join detection. Vanilla has no Player.hasPlayedBefore() equivalent. FabricOnEventTeleports.hasPlayedBefore probes <worldRoot>/playerdata/<uuid>.dat via MinecraftServer.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. ReqFabricAdr023HasPlayedBeforeTest covers 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_cache DB table for row-level provenance.
  • loginCacheMode = EVERY_LOGIN | FIRST_LOGIN toggle (currently both modes are covered implicitly by the existing rtp.onevent.firstjoin / rtp.onevent.join permissions).
  • REQ-traceable test classes for the Bukkit-side promotion + join-prime paths: LoginCachePromotionTest (mirror of the kept promotion test against the new task) and LoginCacheJoinPrimeTest. The Fabric first-join branch is already covered by ReqFabricAdr023HasPlayedBeforeTest.
  • Telemetry: loginReserveExhaustion, loginFill, loginCap exporters (METRICS_PLAN.md).