Astral Realms Documentation Help

Architecture

AstralLeaderboards splits authority and presentation across two deployables that share the common module (models + wire packets). This page documents the write/read paths, the caches on each side, and the periodic safety nets that keep them converged.

Modules

Module

What it is

Owns

paper

A Paper plugin, one instance per game server.

Command, menu, placeholders, built-in Bukkit trackers, a read-through Caffeine cache per player and per (leaderboard, period).

master

A standalone MageHic-runtime service, one instance for the whole network.

The MariaDB entries/leaderboards tables (source of truth), ranking, and the authoritative Redis sorted-set cache.

common

Shared library, no onEnable of its own.

LeaderboardEntry, LeaderboardPeriod, SortDirection, and the 7 wire packets registered in LeaderboardPacketRegistry.

Both paper and master connect independently to the same messaging broker (MessagingService, exchange leaderboards — constant LeaderboardConstants.MESSAGING_CHANNEL) and the same Redis (CacheService). Paper never talks to MariaDB directly; every write and every cold read goes through the master over messaging.

Period windows

Every leaderboard is tracked in three buckets simultaneously — the period is a read-time choice, not a per-leaderboard config value:

Period

Bucket key

Resets

ALL_TIME

constant "all"

Never.

WEEKLY

ISO week-based year+week, e.g. 2026-W29

When the ISO week rolls over (UTC).

MONTHLY

Calendar year+month, e.g. 2026-07

When the calendar month rolls over (UTC).

LeaderboardPeriod.currentKeys(now) returns all three keys for "now"; every write fans out to all three buckets in one statement (ON DUPLICATE KEY UPDATE) so a single score lands in the all-time, weekly and monthly board at once. All keys are computed in UTC so master and every paper server agree without clock skew concerns beyond system-clock drift.

Write path

paper: /leaderboard set ──► UpdatePlayerEntryPacket(leaderboardId, playerId, value, sort) paper: built-in tracker / LeaderboardService.update ──► IncrementPlayerEntryPacket(leaderboardId, playerId, delta, sort) │ ▼ (messaging exchange "leaderboards") master: LeaderboardPacketListener.handle │ EntryService.save / .increment ├─ ingest(leaderboardId, sort) — updates + persists the sort-direction registry │ (leaderboards table) only when it changed ├─ EntryRepository upsert into MariaDB `entries`, │ one row per current period bucket (all/weekly/monthly) └─ propagate(...) ├─ LeaderboardCacheRepository.addOrUpdate — Redis ZADD + rank lookup, │ one call per affected bucket ├─ EntryCacheRepository.save — per-player Redis hash └─ broadcast EntryUpdatedPacket(playerId, leaderboardId) broadcast LeaderboardUpdatedPacket(leaderboardId) │ ▼ (every paper server, including the writer) LeaderboardPacketListener.handle ├─ EntryUpdatedPacket → EntriesService.refresh(playerId) (invalidates + reloads that player's Caffeine entry) └─ LeaderboardUpdatedPacket → LeaderboardServiceImpl.refresh(leaderboardId) (invalidates + reloads all 3 period buckets)

sort travels with every write packet — it is not looked up on the master. EntryService.ingest records whatever direction paper last sent and persists it to the leaderboards table only when it changes, so the master's own periodic rebuilds (which have no config file) can reproduce the same ranking after a restart.

Read path (per-player standing)

EntriesService.get(playerId) │ Caffeine AsyncLoadingCache<UUID, List<LeaderboardEntry>> (refreshAfterWrite 5m) ▼ miss EntryCacheRepository.find(playerId) — per-player Redis hash, 1-day TTL │ miss RequestPlayerEntryPacket(playerId) ──► master: EntryService.handleRequest │ EntryRepository.findByPlayer(playerId) │ filters to only *current* period buckets EntryCacheRepository.save(...) (repopulates Redis for next time) │ ◄── PlayerEntryResponsePacket(success, entries) (RPC reply)

Read path (leaderboard top-N)

LeaderboardServiceImpl.get(leaderboardId, period) │ Caffeine AsyncLoadingCache<CacheKey(leaderboardId, period), List<LeaderboardEntry>> (refreshAfterWrite 5m) ▼ miss LeaderboardCacheRepository.get(leaderboardId, periodKey, TOP_N=40, sort) — reads the Redis ZSET directly (paper never asks master for a full top-N over messaging)

Every leaderboard × period combination is warmed on LeaderboardServiceImpl construction so the first render after startup doesn't block on a cache miss.

Redis layout

Key

Written by

Shape

leaderboards:<leaderboardId>:<periodKey>

master (LeaderboardCacheRepository)

ZSET, member = player UUID, score = value with a 1e-16 tie-break nudge toward the earlier updatedAt.

leaderboards:scores:<leaderboardId>:<periodKey>

master

HASH, member → exact original score (the ZSET score is nudged, so exact reads come from here).

leaderboards:entry:<playerId>

master (EntryCacheRepository)

HASH, field <leaderboardId>|<periodKey>"<position>:<score>", 1-day TTL.

Periodic rebuilds (master safety nets)

Two runTaskTimerAsync loops, every 30 minutes (PERIODIC_REBUILD_MINUTES), independent of the live update path:

Task

What it does

updateIndexes

Recomputes the stored position column for every row via a windowed ROW_NUMBER() query per (leaderboard, period), honouring each leaderboard's ranking direction. Broadcasts IndexesUpdatedPacket per leaderboard touched.

updateLeaderboards

Re-derives the Redis top-N for every known leaderboard × every current period bucket from MariaDB (including leaderboards with zero current entries, so a freshly rolled-over weekly/monthly board is cleared instead of left stale), then deletes rows whose period_key is no longer current. Broadcasts LeaderboardUpdatedPacket per leaderboard.

These exist to repair drift from missed/duplicate packets and to prune storage — deleteStaleBuckets means weekly/monthly rows older than the current window are never served again and are safe to delete.

Wire packets

Registered in LeaderboardPacketRegistry on both modules (opcodes must match on every node):

Opcode

Packet

Direction

Purpose

0x00

EntryUpdatedPacket(uuid, leaderboardId)

master → paper (broadcast)

"Refresh this player's cached standing."

0x01

IndexesUpdatedPacket(leaderboardId)

master → paper (broadcast)

Emitted after the periodic index rebuild; paper reacts the same as LeaderboardUpdatedPacket.

0x02

LeaderboardUpdatedPacket(id)

master → paper (broadcast)

"Refresh this leaderboard's cached top-N (all periods)."

0x03

RequestPlayerEntryPacket(playerId)

paper → master (RPC)

Cold-cache pull of a player's standings.

0x04

PlayerEntryResponsePacket(success, entries)

master → paper (RPC reply)

Reply to 0x03.

0x05

UpdatePlayerEntryPacket(leaderboardId, playerId, value, sort)

paper → master

Overwrite (/leaderboard set, LeaderboardService.update).

0x06

IncrementPlayerEntryPacket(leaderboardId, playerId, value, sort)

paper → master

Add a delta (built-in trackers, EntriesService.increment).

Failure modes

  • Messaging broker down: writes and cold-cache pulls fail silently server-side (logged via getSLF4JLogger); no in-game feedback is shown for /leaderboard set. Peer caches drift until the next successful broadcast or the master's 30-minute periodic rebuild.

  • Master down: paper's warm Caffeine/Redis-hash caches keep serving stale data until they expire (5-minute refresh, 1-day TTL on the per-player hash); writes queue up as failed futures, not retried.

  • MariaDB down: the master's writes fail (logged); Redis continues serving whatever it last held.

Last modified: 25 July 2026