ADR-054 — RTPRunnable Self-Scheduling Thread Routing¶
Status: Accepted Date: 2026-05-29
Context¶
RTPRunnable (rtp-api, package io.github.dailystruggle.rtp.common.tasks) is the platform-agnostic base task for the RTP execution pipeline (countdown delays, post-setup hooks, post-teleport actions, pre-generation work). Historically a caller had to know both the task and the correct scheduler tier, then call the matching RTPScheduler method explicitly:
- async pool —
runTaskAsynchronously/runTaskTimerAsynchronously - main / region thread —
runTask(RTPLocation, Runnable),runTask(RTPWorld, cx, cz, ...) - entity (player) thread —
runTaskForPlayer(RTPPlayer, RTPRunnable, delayTicks)
On Folia this coupling is a recurring footgun: a task that touches a player must land on that player's entity scheduler, and a task that touches world state must land on the owning region thread, or it throws ThreadAccessException. The routing decision lived at every call site rather than with the task that carries the spatial/entity context.
The long-standing backlog item (todo.md Phase 1, "RTPRunnable spatial-context routing") proposed letting the task self-schedule onto the correct thread. An earlier sketch suggested reusing the RtpTarget API value type, but RtpTarget is a destination selector (Kind DEFAULT/REGION/WORLD + a name); it carries no coordinates or player and therefore cannot identify a tick thread. The thread-routing context is an RTPLocation (region thread) or an RTPPlayer (entity thread) — both already first-class rtp-api types, and RTPRunnable already exposed a (null-returning) getLocation().
Decision¶
RTPRunnable gains the thread-routing context and a self-scheduling entry point:
- Routing fields (additive).
RTPPlayer target— optional entity-thread routing target, withgetTarget()/setTarget(...).-
RTPLocation location— optional region-thread routing context, withsetLocation(...);getLocation()now returns this field instead ofnull(subclasses may still override to derive it dynamically). Setters are fluent (returnthis). -
Static scheduler hook. A
public static RTPScheduler schedulerfield onRTPRunnable, installed by core in theRTPconstructor (RTPRunnable.scheduler = scheduler;) alongside the existingnull-scheduler guard. This mirrors the established static-hook injection pattern already used fortrackHook/updateHook/untrackHook, keepingrtp-apifree of anyrtp-corereference.RTPSchedulerlives inrtp-api, so no new module dependency is introduced. -
schedule()/schedule(long delayTicks). Self-dispatch by routing context, in precedence order: - a
targetplayer is set →runTaskForPlayer(target, this, delay)(entity thread; natively supports a tick delay and acceptsRTPRunnabledirectly); - else a
locationis set →runTask(location, this::runWithTracking)when no delay, orrunTaskLater(location.world(), cx, cz, this::runWithTracking, delay)when delayed (chunk coords viablockX >> 4,blockZ >> 4); - else (no spatial context) →
runTaskAsynchronously(this::runWithTracking)when no delay, orrunTaskLater(this::runWithTracking, delay)when delayed. -
schedule()uses the task's owngetDelay(). -
Require-by-contract.
schedule(...)throwsIllegalStateExceptionwhenschedulerisnull(core not yet loaded), consistent with thertp-apipolicy that entry points must not silently no-op.
The Runnable-typed scheduler paths submit this::runWithTracking so MSPT accounting and lifecycle cleanup continue to run; the entity path submits this because runTaskForPlayer takes RTPRunnable and the adapter performs tracking.
Alternatives Considered¶
| Alternative | Why Rejected |
|---|---|
Store an RtpTarget on the runnable for routing |
RtpTarget is a destination selector (region/world name), not a coordinate or player; a name cannot resolve to a tick thread. It cannot drive entity/region routing. |
| Keep routing at every call site | The coupling between "what the task touches" and "which thread it must run on" belongs with the task, not duplicated at each caller; the status quo is the Folia ThreadAccessException footgun this ADR removes. |
| Inject the scheduler via constructor parameter | Would touch every RTPRunnable construction site across core and adapters; the static-hook pattern already exists for the memory-tracker hooks and is the project idiom for one-time core→api wiring. |
Have rtp-api reach into RTP.scheduler (core) |
Forbidden: rtp-api must not depend on rtp-core. RTPScheduler is an rtp-api interface, so a static field of that type stays within module boundaries. |
| Add an async one-shot-with-delay scheduler method | Out of scope; the no-context delayed case maps cleanly onto the existing runTaskLater(Runnable, delay) (main thread), and adding a scheduler method would touch every RTPScheduler implementation. |
Consequences¶
- Positive:
- Callers that hold an
RTPRunnablewith a player or location canschedule()without knowing the scheduler tier; routing is Folia-correct by construction (entity → region → async). - No new module dependency, no new
RTPSchedulermethod, no change to existing call sites; the change is purely additive. - The
null-scheduler guard makes premature scheduling a loud failure rather than a silent no-op. getLocation()now has a usable backing field, closing the long-opentodo.mdspatial-context item without a parallelSpatialContexttype.- Negative / Trade-offs:
- A mutable static (
RTPRunnable.scheduler) is process-global; acceptable because the scheduler is a process-wide singleton already mirrored byRTP.scheduler, and it is reset only in tests. schedule(...)is opt-in; existing tasks submitted viaRTP.schedulerdirectly are unchanged and unaffected.
References¶
rtp-api/src/main/java/io/github/dailystruggle/rtp/common/tasks/RTPRunnable.java— routing fields, staticschedulerhook,schedule()/schedule(long).rtp-api/src/main/java/io/github/dailystruggle/rtp/api/scheduling/RTPScheduler.java— the tier methods routed to.rtp-core/src/main/java/io/github/dailystruggle/rtp/common/RTP.java— installsRTPRunnable.schedulerin the constructor.rtp-core/src/test/java/io/github/dailystruggle/rtp/common/tasks/RTPRunnableScheduleRoutingTest.java— routing + require-by-contract coverage.- ADR-023, Folia threading rules in
.junie/AGENTS.md(entity/region scheduler hard rules).