Astral Realms Documentation Help

Architecture

The auction house is a small read-mostly database (items) protected by a 3-layer cache and a single distributed lock. This page documents the moving parts and the trade-offs that drove the shape.

Caches

RabbitMQ "auctionhouse.items" │ ▼ ItemPacketListener (every server with the plugin enabled) │ ▼ ┌─────────────────┴─────────────────┐ ▼ ▼ ItemRepository.refresh(uuid) ItemRepository.invalidate(uuid) │ │ ▼ ▼ Caffeine AsyncLoadingCache Multimap<ownerId, itemId> (max 10 000, expireAfterAccess 14d) │ ▼ Sorted snapshot list (used by AhItemProvider)

Caffeine

Each ItemRepository instance keeps an AsyncLoadingCache<UUID, AuctionItem> capped at 10 000 entries with a 14-day expiry. The cache loader hits the database; misses fold into the loader without blocking the menu render thread.

Multimap

A parallel Multimap<UUID, UUID> from owner → item id lets /ah resolve a seller's listings without scanning the cache. Every save updates it; every invalidation cleans it.

Sorted snapshot

sortedCache() returns a sorted, unmodifiable List<AuctionItem> updated whenever the cache mutates. The browse menu's AhItemProvider reads from this list, so paginated rendering is O(1) per slot. The sort key is the listing's primary order in the repository (insertion-based, deterministic).

Cross-server sync

Two packets, one exchange:

Opcode

Packet

Producer

Consumer

0x00

ItemUpdatedPacket(uuid)

ItemRepository.save

Every ItemPacketListener calls repository.refresh(uuid).

0x01

ItemDeletedPacket(uuid)

ItemRepository.delete

Every ItemPacketListener calls repository.invalidate(uuid).

The producer is always the owner of the mutation — the server that ran the sell / buy / claim. The exchange is auctionhouse.items (constant AHConstants.ITEMS_EXCHANGE).

Notes:

  • The packet does not carry the new state. It is a "go check the database" nudge. Consumers re-read. This keeps the packet payload trivial and avoids races where a slow consumer would persist a stale snapshot.

  • The producing server also updates its own cache directly — it doesn't rely on the packet bouncing back. The broadcast is for the other servers.

Distributed lock

Every mutation that can race against itself takes the same Redis lock:

key = "auctionhouse:locks:" + itemId SET key "1" NX EX 30

Operation

Why locked

buy

Prevent two buyers from both succeeding on the same item.

claim

Prevent buy and claim from racing.

expire (scheduled task)

Prevent the cleanup task from clobbering an in-flight buy/claim.

The lock is taken after the initial state validation, then the state is re-validated under the lock. Acquisition failure maps to BUY_FAILED_UNAVAILABLE — when another path is mid-mutation, the user sees the same "unavailable" message they'd see if the item had already been sold.

Lazy ItemStack hydration

Both AuctionItem and AuctionTransaction store the item payload as LONGBLOB (Bukkit's NBT-aware serialisation). The Java side keeps an AtomicReference<ItemStack> itemStack and uses double-checked locking to deserialise on first use:

public ItemStack item() { ItemStack cached = itemStack.get(); if (cached != null) return cached; synchronized (this) { cached = itemStack.get(); if (cached != null) return cached; ItemStack fresh = Bukkit.getUnsafe().deserializeItem(itemData); itemStack.set(fresh); return fresh; } }

That keeps idle items light (raw bytes only) and the deserialisation happens at most once per JVM per item.

Scheduled background tasks

Task

Cadence

Owner

What it does

Expiry sweep

every 15 s (after a 200-tick warm-up)

ItemService

Marks LISTED items past their TTL as EXPIRED.

Average refetch

every 30 minutes

AverageService

Re-runs the SQL aggregate and pushes results into Redis.

Transaction cleanup

every ~36 minutes

TransactionService

DELETE FROM transactions WHERE created_at < NOW() - INTERVAL 60 DAY.

The tasks are independent — they each take their own locks (Redis for expiry, none for the others).

Data retention

  • items rows are removed when sold (buy/claim deletes the row).

  • items rows in EXPIRED status stay until the seller claims them. There is no automatic deletion for unclaimed expired listings.

  • transactions rows live for 60 days then drop. The 90-day average window is intentionally longer than the retention so the trim doesn't eat into the data the averaging service depends on — but read it as a known invariant: keep retention ≥ averaging window if you tune either.

Failure modes

  • Redis down: locks cannot be acquired; mutations short-circuit to BUY_FAILED_UNAVAILABLE. The average cache falls through to "no data".

  • RabbitMQ down: packets cannot be sent. The producing server's local cache stays correct; peer caches drift. They self-heal on their own scheduled refresh, but for the cold case operators should run /ah refresh.

  • Postgres down: the cache continues to serve until eviction. Saves fail; the user sees UNEXPECTED_ERROR.

  • Mailbox delivery failure on buy: the buyer's money was already moved. The plugin attempts a refund to the seller and logs the failure — the item state is intentionally left in the database for manual reconciliation rather than risk duplication.

The bias throughout is "fail closed and tell the player" rather than "retry silently". Telemetry + human follow-up is preferred to subtle bugs that re-emerge under load.

Last modified: 25 July 2026