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
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 |
|---|---|---|---|
|
|
| Every |
|
|
| Every |
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:
Operation | Why locked |
|---|---|
| Prevent two buyers from both succeeding on the same item. |
| Prevent buy and claim from racing. |
| 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:
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) |
| Marks LISTED items past their TTL as EXPIRED. |
Average refetch | every 30 minutes |
| Re-runs the SQL aggregate and pushes results into Redis. |
Transaction cleanup | every ~36 minutes |
|
|
The tasks are independent — they each take their own locks (Redis for expiry, none for the others).
Data retention
itemsrows are removed when sold (buy/claim deletes the row).itemsrows inEXPIREDstatus stay until the seller claims them. There is no automatic deletion for unclaimed expired listings.transactionsrows 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.