Shared MySQL and Redis for Paper plugins. Other plugins depend on this one instead of each managing their own connection pools, retry loops, and cache layers. Everything runs off the main thread.
- A HikariCP connection pool pointed at MySQL, verified on startup with configurable retries.
- Async
query,queryList, andupdatecalls that returnCompletableFutures and never touch the server thread. - A write batcher that queues cheap writes and flushes them as JDBC batches on a timer or when the queue hits a size cap.
- A Redis cache backed by a Jedis pool, with
get,set, single-key and pattern-basedinvalidate. writeThrough— does the SQL update, waits for it to land, then busts the cache keys. Callers don't have to remember both halves.- Transactions on a single connection with commit/rollback.
- A migration system that tracks schema versions in the database and uses MySQL advisory locks so two servers starting at once don't collide.
- Retries with linear backoff on all queries and updates. Batched writes that fail are requeued up to the retry limit, then logged as dropped.
- If the final batch flush on shutdown fails, the lost writes are dumped
to
plugins/AsyncData/unsaved-writes.logso you can see what didn't make it.
- Drop the jar into
plugins/. - Start the server once so it writes
plugins/AsyncData/config.yml. - Fill in your MySQL and Redis credentials and restart.
Other plugins access AsyncData through the Bukkit service manager or by
grabbing the plugin instance and calling getDatabase() / getCache().
var asyncData = (AsyncData) Bukkit.getPluginManager().getPlugin("AsyncData");
var db = asyncData.getDatabase();
var cache = asyncData.getCache();
// run a query
db.query("SELECT coins FROM players WHERE uuid = ?",
List.of(player.getUniqueId().toString()),
rs -> rs.getInt("coins"))
.thenAccept(coins -> { /* back on an IO thread, not the main thread */ });
// write-through: update the row, then bust the cache key
db.writeThrough("UPDATE players SET coins = ? WHERE uuid = ?",
List.of(500, uuid.toString()),
"player:" + uuid);
// fire-and-forget batch write (flushed every second or at 200 queued)
db.batchWrite("INSERT INTO logs (uuid, action, ts) VALUES (?, ?, ?)",
List.of(uuid.toString(), "login", System.currentTimeMillis()));
// transaction
db.transaction(conn -> {
try (var ps = conn.prepareStatement("UPDATE players SET coins = coins - ? WHERE uuid = ?")) {
ps.setInt(1, 100);
ps.setString(2, buyer.toString());
ps.executeUpdate();
}
try (var ps = conn.prepareStatement("UPDATE players SET coins = coins + ? WHERE uuid = ?")) {
ps.setInt(1, 100);
ps.setString(2, seller.toString());
ps.executeUpdate();
}
return null;
});
// schema migration on your plugin's enable
db.migrate(List.of(
new Migration(1, "create_players", conn -> {
try (var st = conn.createStatement()) {
st.executeUpdate("CREATE TABLE IF NOT EXISTS players ("
+ "uuid VARCHAR(36) PRIMARY KEY, coins INT DEFAULT 0)");
}
}),
new Migration(2, "add_last_seen", conn -> {
try (var st = conn.createStatement()) {
st.executeUpdate("ALTER TABLE players ADD COLUMN last_seen BIGINT DEFAULT 0");
}
})
));update() runs immediately on an IO thread and hands you a future with the
affected row count. Use it when you need to know the write landed.
batchWrite() queues the statement and returns nothing. The batcher groups
queued writes by SQL text and flushes them as JDBC batches every second, or
sooner if the queue reaches 200 entries. Use it for high-volume writes where
individual confirmation doesn't matter — stat increments, logs, analytics.
If a batch fails, the writes are requeued and retried on the next flush. After exhausting the retry limit they are logged at SEVERE and dropped.
writeThrough is the intended path for keeping MySQL and Redis consistent.
It waits for the SQL update to succeed before invalidating the cache keys,
so a reader between the write and the invalidation always sees the new data
in MySQL rather than a stale cache entry.
There is no batchWriteThrough. Batched writes are deferred by design, so
there is no point at which the cache can be reliably invalidated — the write
hasn't happened yet when the call returns. If you batch-write data that is
also cached, either accept TTL-based staleness or invalidate manually after
calling flushBatch().
invalidatePattern("player:") walks the keyspace with SCAN (not KEYS)
and deletes everything under asyncdata:player:*. Safe to run on large
databases without blocking Redis.
- Queries and updates retry up to
retry.attemptstimes with linear backoff before giving up. The default is 3 attempts, 2 seconds apart. - Batched writes that fail mid-flush are requeued individually and retried on the next cycle. Writes that exceed the retry limit are logged and dropped.
- If the server shuts down and the final batch flush fails entirely, every
lost write is appended to
unsaved-writes.logwith a timestamp, the SQL, and the parameters. This is evidence, not a replay mechanism — you can inspect and rerun them manually. - Startup connection failures are retried with backoff. If the database is still unreachable after all attempts, the plugin disables itself rather than running without storage.
- Config values are clamped to sane minimums. Setting
io-threads: 0gives you 1, not a crash.
plugins/AsyncData/
config.yml MySQL and Redis credentials, pool sizing, retry
and batch tuning
unsaved-writes.log only created if a shutdown flush fails — contains
the SQL and params of every write that was lost