Cross-Server /rtp Verification¶
Operator-facing manual verification of the cross-server /rtp round-trip on
a multi-proxy, multi-backend network.
Start with the single-backend precursor first:
SINGLE_BACKEND_VERIFICATION.md, and see the
Proxy mode overview for what network mode is and what it needs.
Prerequisites¶
- Two Velocity proxies sharing the same
forwarding.secret. - Two Paper backends, each with
RTPinstalled and registered against both proxies'[servers]block. - One Redis instance reachable from every proxy and backend on a single resolvable host:port.
- A shared 32-byte HMAC secret in
RTP_NET_SECRETon every JVM. rtp-proxy-velocityon both proxies,rtp-pluginon both backends, all built from the same release tag.
Configuration outline¶
On each proxy (plugins/rtp-proxy-velocity/network.yml):
network:
enabled: true
proxyId: "proxy-a" # unique per proxy
role: proxy
secretEnv: RTP_NET_SECRET
transport:
type: redis
redis: { host: redis, port: 6379 }
On each backend (plugins/RTP/network.yml):
network:
enabled: true
serverId: "backend-a" # unique per backend
role: backend
secretEnv: RTP_NET_SECRET
transport:
type: redis
redis: { host: redis, port: 6379 }
proxyId and serverId are network-wide identifiers; collisions cause one
participant to overwrite another's heartbeat row and silently degrade. Pick
short, stable strings (hostnames work).
Scenario 1: stack boot¶
Bring everything up and confirm all five processes report healthy startup.
Pass criteria:
- Both proxies log
RTP network mode enabled (proxy-X). - Both backends log
RTP network mode enabled (backend-X). - No
WARNINGlines mentionRTP_NET_SECRET,transport, orRedis.
Scenario 2: heartbeat convergence¶
Within ~5 seconds of full boot, all four participants must publish a heartbeat row to Redis.
redis-cli KEYS 'rtp:net:backend:*'
redis-cli KEYS 'rtp:net:proxy:*'
Pass criteria: 4 keys total - 2 under :backend:, 2 under :proxy:.
Common failure mode: 0 keys. Cause is almost always RTP_NET_SECRET
unset on at least one JVM, or transport.type: redis missing on one
participant (it silently runs in-memory).
Scenario 3: cross-server /rtp round-trip¶
The end-to-end happy path. Required: a live Minecraft 1.21.x client.
- Connect the client to proxy-a (default backend: backend-a).
- Run
/server backend-bonce to ensure the client's session is known to backend-b's permission map. Then/server backend-aagain. - From backend-a, run
/rtp. The backend'sNetworkRouterenrols the request in the shared wait queue (rtp:net:wq:readyLIST + per-playerrtp:net:wq:status:<pid>HASH). Some proxy worker dequeues the envelope, runs theBackendSelector(kept-count + killSwitch + region availability), claims a reservation token against the chosen backend'snetworkKeptLocationspool, and issues the transfer. - On arrival, the destination backend's
JoinTriggerSourcecallsfindReservation -> redeem, and on REDEEMED pins the pre-warmed coordinate viaRegionQueueManager.acceptRedeemedReservation(...)so the immediately-following/rtpdispatch consumes that exact coord (no second pipeline run).
Queue + reservation state transitions an operator can observe:
rtp:net:wq:status:<pid>HASH cycles throughPENDING -> ROUTING -> RESERVED -> COMPLETED(orFAILED/CANCELLEDon the failure paths); the HASH is DEL'd by the terminaltransition.luascript along with the envelope HASH and thertp:net:wq:seenSET entry.rtp:net:tok:<tokenId>HASH transitionsPENDING -> CLAIMED -> CONSUMED(orRELEASEDonPLAYER_DISCONNECTED/TTL_EXPIRED/BACKEND_REJECTED); CONSUMED/RELEASED rows are removed by the release Lua. The reaper'sReleaseSinkfiresreleaseToNetworkKepton the owning backend so the coord returns to the pool.- Heartbeat HASH
regionKeptCountsfield on the chosen backend drops by 1 at claim time and is replenished on the next pulse.
Pass criteria:
- Client lands on the destination backend at a valid coordinate.
- Destination backend log contains a
JoinTriggerSource ... redeemedline. redis-cli KEYS 'rtp:net:tok:*'shows zero entries after the redeem completes (terminaltransition.luadeleted the row).redis-cli KEYS 'rtp:net:wq:status:*'shows zero entries for the player UUID (terminal envelope cleanup).
Common failure modes:
- Client lands but no
/rtpruns: HMAC verify failed (secret mismatch), orJoinTriggerSourcenot registered (network bootstrap aborted). /rtpruns locally on the source backend:NetworkRoutergated tolocal(checkrouting.modeinnetwork.yml), token-bucket rate limit exhausted, no backend qualified (all killSwitch / zerokeptCount/ region unavailable), or the dispatcher fell through to the local pipeline. Check the backend log forRoutingDecisionFallbackReasonentries and the proxy log forDispatchOutcomewarnings.- Wait-queue envelope stuck in
PENDING: no proxy worker is drainingrtp:net:wq:ready(proxyTransportRequestTriggerSourcenot started, or all proxies are down). The reservation TTL reaper will clear stale entries, but the player observes a fallback message.
Scenario 4: kill mid-flight¶
Verify that an orphan reservation is reaped without manual cleanup.
- Seed a reservation (operator: log into a client and run
/rtp, then immediatelyCtrl+Cthe destination backend before the client'sPlayerJoinEventfires; dev: userun-acceptance.ps1 -Scenario killmidflight, which seeds via the claim Lua and kills the backend container). - Wait
reservation.ttlMs + reservation.reapIntervalMs(default 5 min total in production; 35s in the devstack). redis-cli KEYS 'rtp:net:reservation:*'must report zero matching keys.
Pass criteria: the row clears within the budget without operator intervention.
Scenario 5: kill switch¶
Verify that flipping network.killSwitch: true on a proxy halts new claims
without affecting in-flight redeems.
- Edit
proxy-a/network.yml, setnetwork.killSwitch: true, restart proxy-a. - From a client on proxy-a, run
/rtp. The proxy must reject the request withMSG_NETWORK_KILL_SWITCH(configurable viamessages.yml). - From a client on proxy-b (unaffected),
/rtpcontinues to work.
Pass criteria: proxy-a rejects with the configured message; no claim is written to Redis; proxy-b is unaffected.
Note: the devstack's run-acceptance.ps1 -Scenario killswitch performs a
wiring smoke test by issuing a synthetic claim with a sentinel value. It
does NOT exercise the real proxy gate - that requires a live client and
the manual procedure above. Treat the harness result as a regression
canary for the Lua contract, not as authoritative acceptance evidence.
Teardown¶
network.enabled: falseon every participant restores byte-identical single-server behavior.- Reservation rows older than their TTL are reaped automatically. To
flush manually:
redis-cli --scan --pattern 'rtp:net:*' | xargs redis-cli DEL.
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
Backend logs RTP_NET_SECRET is required at startup |
Env var unset on that JVM |
Heartbeat keys present but /rtp runs locally |
Proxy serverId mismatch (selector saw none) |
redeem returns WRONG_SERVER |
Player landed on a backend other than the one claimed; usually a Velocity try fallback |
redeem returns ALREADY_CONSUMED |
Duplicate PlayerJoinEvent (benign; second is a no-op) |
redeem returns EXPIRED |
Transfer took longer than reservation.ttlMs; increase TTL or shorten transfer latency |