🧩Architecture
This page explains why NetworkEconomy is safe against balance duplication and lost writes. If you only want to install it, go to installation.md.
The problem it solves
The naive way to write an economy plugin is:
On join, read the player's balance from the database into a local field.
Mutate that field while they play.
On quit, write the field back.
On a single server this mostly works. On a network it is broken in two directions:
Duplication. A player with 1,000 coins spends them on the lobby, then quickly rejoins on survival — which still holds the stale cached 1,000 — and quits, writing 1,000 back over the correct value.
Lost writes. Two servers both hold a cached balance and both save it. The last save wins and the other server's earnings vanish.
The four rules
1. The database is the only authority
There is exactly one source of truth: the balances table in MySQL / MariaDB. Local caches are read-through only — they are never written back as if they were authoritative. A cache miss is just a database read; a cache being wrong can only ever cause a briefly stale display, never a wrong transaction, because every mutation is evaluated by the database itself.
2. All money movements are atomic relative operations
No operation ever reads a balance into Java, changes it, and writes it back. Instead:
Deposit
UPDATE balances SET balance = balance + ? WHERE uuid = ? AND currency = ?
Withdraw
UPDATE balances SET balance = balance - ? WHERE uuid = ? AND currency = ? AND balance >= ?
Transfer
Both sides inside a single transaction, rows locked with SELECT ... FOR UPDATE in a deterministic order
Set / Reset
UPDATE balances SET balance = ? inside a transaction, then read back
The withdrawal is the important one. The sufficiency check (balance >= ?) and the debit are a single statement, so they cannot be interleaved. If twenty servers try to withdraw 10 from a balance of 100 at the same instant, exactly ten succeed; the rest affect zero rows and return INSUFFICIENT_FUNDS. There is no window in which two servers can both "see enough money" and both spend it.
Transfers lock both rows in a deterministic order, which is what prevents two opposite transfers (A → B and B → A) from deadlocking each other.
3. A monotonic version column makes sync idempotent
Every balance row carries a version counter that is incremented on every change. Redis broadcasts carry (balance, version), and the cache applies an incoming update only if its version is newer than what it already holds.
This is what makes the sync layer robust rather than merely fast:
Messages that arrive out of order are harmless — the older one is dropped.
Messages that arrive twice are harmless — applying them is idempotent.
A message that is lost entirely costs nothing but a brief staleness, resolved by the next read or the cache expiry.
4. Redis pub/sub keeps caches coherent in real time
When a balance changes, the originating node publishes a compact message on the configured channel. Every other node applies it to its cache (subject to the version guard) and fires BalanceUpdateEvent with isRemote() == true. The publishing node ignores its own messages by matching server-id, which is why that value must be unique per server.
If Redis is disabled or fails to connect, the plugin logs it and falls back to re-reading online players' balances from the database every cache.fallback-refresh-seconds. Correctness is unaffected — only the propagation delay changes.
Request flow
The Vault trade-off
Vault's Economy API is synchronous — it must return a number immediately. NetworkEconomy's core is asynchronous. The bridge therefore blocks briefly on the underlying async operation, bounded by a 5-second timeout, so that it can return an accurate response rather than a guess.
Reads are cheaper than they look: the bridge serves getBalance from the local cache when the player is cached (the normal case for online players) and only falls back to a blocking database read for offline players.
Latency-sensitive plugins — anything calling the economy in a tight loop or on every tick — should use the native async API instead of the Vault bridge. See for-developers.md.
Startup order
The plugin wires itself in a fixed order, because parts of it depend on each other:
config → storage (connect + apply schema) → cache → service → Redis → Vault → commands & listeners
The service is created before Redis so the Redis message handler can call back into it, and Redis is then injected into the service. If the database cannot be reached, or the currency configuration is invalid, the plugin disables itself rather than running in a state where balances could be wrong.
Connection handling & shading
Connection handling is built on DatabaseProvider: pooled MariaDB via HikariCP, Redis via Lettuce with a dedicated pub/sub connection, and SQL schema loading with {prefix} substitution.
All bundled libraries — DatabaseProvider, HikariCP, the MariaDB driver, Lettuce, Netty, Reactor — are relocated under com.ohalee.networkeconomy.lib. Relocating Netty in particular is required, not cosmetic: Paper ships its own Netty and Lettuce needs a newer one, so an unrelocated build would clash with the server at runtime.
Project layout
Last updated