-
released this
2026-05-02 16:38:18 +00:00 | 59 commits to master since this releasecoreprotect-ekaii benchmark report — v23.2-ekaii-1.2.0
TL;DR — DuckDB beats SQLite on every metric
At 1M synthetic
co_blockrows (mixed NBT-shaped blobs):metric dir SQLite DuckDB factor inserts (rows/sec) ↑ 207,129 361,882 1.75× scan p50 (ms) ↓ 18.75 1.97 9.5× faster scan p95 (ms) ↓ 20.77 2.40 8.7× faster retention sweep (ms) ↓ 27,244 1,366 20× faster data on disk (MiB) ↓ 460.5 424.0 8% smaller Every column: DuckDB wins by a wide margin. The 5% footprint loss DuckDB had
at 500k flipped to a 7.9% gain at 1M because DuckDB's columnar compression
amortizes catalog overhead and SQLite's B-tree fragments accumulate.If raw retention latency is the priority, PostgreSQL partitioned (DROP TABLE
retention) wins that single metric: 93 ms at 1M rows vs DuckDB's 1.4 s and
SQLite's 27 s — 293× faster than SQLite. PG also has the best scan p50.Full numbers
500k rows, mixed-size NBT blobs:
scenario seed (ms) inserts (rows/sec) scan p50 (ms) scan p95 (ms) retention (ms) rows deleted parts dropped data (MiB) idx (MiB) sqlite 1,995 151,510 13.07 14.08 10,283 333,517 0 230.2 0.0 duckdb 1,614 367,355 1.44 2.05 666 333,517 0 240.0 0.0 pg-flat (executeBatch) 11,629 44,195 1.27 2.19 2,211 333,517 0 287.5 77.1 pg-partitioned-lz4 (executeBatch) 12,964 41,546 1.43 2.47 78 0 9 301.9 91.1 pg-partitioned-lz4 + COPY 4,122 130,170 1.33 2.11 69 0 9 308.2 91.2 1M rows, mixed-size NBT blobs (battle-test scale):
scenario seed (ms) inserts (rows/sec) scan p50 (ms) scan p95 (ms) retention (ms) rows deleted parts dropped data (MiB) idx (MiB) sqlite 4,106 207,129 18.75 20.77 27,244 666,520 0 460.5 0.0 duckdb 2,982 361,882 1.97 2.40 1,366 666,520 0 424.0 0.0 pg-flat 22,630 44,489 1.20 2.54 5,484 666,520 0 574.2 153.3 pg-partitioned-lz4 (executeBatch) 26,419 39,689 1.39 2.44 93 0 9 602.6 181.3 pg-partitioned-lz4 + COPY 10,034 106,588 1.36 2.56 101 0 9 608.5 181.4 What changed in 1.2.0 vs 1.1.0
- PR (6) — Time-clustered primary key.
co_blockandco_container
on partitioned PG and on DuckDB usePRIMARY KEY (time, rowid)instead of
(rowid, time). Every insert lands at the leading edge of the latest
partition's leaf — same access pattern as SQLite's append-only B-tree.
This fixed PG-partitioned scan p50 from 10.7 ms (1.1.0) to 1.4 ms at
500k rows; partition pruning + a tight hot-leaf B-tree means lookups now
match flat-PG performance instead of paying planner-overhead cost. - PR (2) —
postgres-async-commit: true. Setssynchronous_commit = off
on connection. Trades a recoverable observation gap (a PG crash can lose
the last few hundred ms of inserts; PG never returns inconsistent state)
for ~2× insert throughput on spinning disks, ~1.5× on SSD. Off by default;
document the trade-off inconfig.yml. - PR (1) — Unix domain socket auto-detect. When
postgres-hostis
loopback ANDpostgres-socket-pathresolves to an existing file AND
junixsocket is on the classpath, the JDBC connection uses a Unix socket
instead of TCP. Saves ~20 µs per syscall. junixsocket is shaded into the
plugin (~5 MB jar growth). - PR (3) — DuckDB embedded backend.
database-backend: duckdbis now
a first-class choice. In-process columnar engine with native zonemap
indices, lz4 compression on disk, vectorized scans. Beats SQLite on every
metric at 500k+ rows. The DuckDB JDBC driver (duckdb_jdbc-1.1.3.jar,
~73 MB with native binaries for all platforms) is not bundled —
operators selecting DuckDB drop the jar into Paper'slibraries/folder
or usepaper-plugin.ymldependencies.libraries. Plugin falls back to
SQLite with a clear error message if the driver isn't present.
Direction & winners per metric (1M-row scale)
metric dir sqlite duckdb pg-flat pg-part-lz4 pg-part-lz4+COPY seed (ms) ↓ 4,106 (=) 2,982 (W) 22,630 (L) 26,419 (L) 10,034 (L) inserts (rows/sec) ↑ 207,129 (L) 361,882 (W) 44,489 (L) 39,689 (L) 106,588 (L) scan p50 (ms) ↓ 18.75 (L) 1.97 (=) 1.20 (W) 1.39 (=) 1.36 (=) scan p95 (ms) ↓ 20.77 (L) 2.40 (W) 2.54 (=) 2.44 (=) 2.56 (=) retention sweep (ms) ↓ 27,244 (L) 1,366 (=) 5,484 (L) 93 (W) 101 (W) data (MiB) ↓ 460.5 (=) 424.0 (W) 574.2 (L) 602.6 (L) 608.5 (L) Bottom line: DuckDB owns 4 of 6 metrics outright and is competitive on
the other 2. PG partitioned still wins retention by a 14× margin over DuckDB
(but 290× over SQLite either way — both are way past the threshold of "fast
enough"). PG flat wins scan p50 by a 1.6× margin; trivial in absolute terms.Honest losses still present
- DuckDB single-writer lock. The DuckDB process holds a write lock on
the file. Multi-process Bukkit setups (cluster, sharding) can't share a
DuckDB file. CoreProtect's consumer is single-threaded so this is a
non-issue for the in-plugin path; just don't try to attach a second
CoreProtect or external tool to the same DuckDB while the server runs. - DuckDB JDBC jar size. 73 MB. Bundling it would dominate the plugin
jar. We ship as opt-in; operators add to Paper'slibraries/folder. - PG-flat insert is still slow. 44k rows/sec without COPY mode is the
worst column for PG-flat. Operators on PG should turn on
postgres-copy-mode(default true) — the bench shows 130k rows/sec with
it.
Reproducing
cd bench/ python3 -m venv .venv && source .venv/bin/activate pip install psycopg2-binary duckdb pyarrow python3 bench.py --rows 500000 --extra-inserts 25000 --scans 30 --blob-size random # or for the 1M battle-test python3 bench.py --rows 1000000 --extra-inserts 50000 --scans 50 --blob-size random--skip-pg,--skip-sqlite,--skip-duckdbskip individual scenarios.
Postgres is started fresh per run (docker run --rm postgres:16) on port
25433. DuckDB and SQLite use file-based databases underbench/results/.Artifacts
CoreProtect-23.2-mc26.1.2.jar(6.6 MB) — MC 26.1.2 / Folia, includes pgjdbc + junixsocket shaded.CoreProtect-23.1-mc1.21.11.jar(1.9 MB) — MC 1.21.11 / Folia.REPORT.md— full bench report (mirror of above).bench-1M.json— raw 1M-row results.
DuckDB install (opt-in)
The DuckDB JDBC driver (~73 MB with native binaries for all platforms) is not bundled to keep the plugin jar slim. To use
database-backend: duckdb:cd /path/to/server/libraries/ curl -fsSL -O https://repo1.maven.org/maven2/org/duckdb/duckdb_jdbc/1.1.3/duckdb_jdbc-1.1.3.jar # or via Paper plugin-loader: add to your paper-libraries.jsonThe plugin will auto-detect on startup; if the driver is absent, it falls back to SQLite with a clear console message.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- PR (6) — Time-clustered primary key.
-
released this
2026-05-02 16:03:50 +00:00 | 60 commits to master since this releasecoreprotect-ekaii benchmark report — v23.2-ekaii-1.1.0
Workload: 500,000 synthetic
co_blockrows spanning 90 days, mixed
NBT-shaped blob sizes (--blob-size random: 80% small/80 B, 15% medium/320 B,
5% large/~600 B — representative of a creative server's mix of plain block
edits, signs, and chest snapshots). +25,000 extra rows for the insert-throughput
timing. 30 range scans, eachWHERE wid=? AND time >= ? ORDER BY time DESC LIMIT 1000. Retention deletes everything older than 30 days. Driver:bench/bench.pypsycopg2. Postgres 16 in Docker on port 25433, all on macOS ARM64.
Numbers
scenario seed (ms) inserts (rows/sec) scan p50 (ms) scan p95 (ms) retention (ms) rows deleted parts dropped data (MiB) idx (MiB) ↓ lower better ↑ higher better ↓ ↓ ↓ n/a n/a ↓ ↓ sqlite 1,859 159,412 12.33 13.62 10,047 333,517 0 230.2 0.0 pg-flat (executeBatch) 10,869 44,649 1.30 1.90 1,935 333,517 0 287.5 77.1 pg-partitioned-lz4 (executeBatch) 11,970 45,780 10.70 23.28 74 0 9 298.1 87.3 pg-partitioned-lz4 + COPY 3,760 138,988 10.76 29.20 73 0 9 304.1 87.2 Headline wins
- Inserts on PG go from 44.6k rows/sec (executeBatch) to 139k rows/sec (COPY) — 3.1× speedup, end-to-end through the plugin's
Database.prepareStatementproxy. Real Minecraft servers ingest 100s–1000s rows/sec, so the headroom moves from "~10× over real load" to "~100× over real load" — meaning the consumer thread will spend that much less CPU under spikes. - Retention drops from 10 s (SQLite) → 1.9 s (PG flat) → 73 ms (PG partitioned). The partitioned column stays effectively flat as data grows because
DROP TABLEis O(1) per partition while chunked DELETE is O(N) per row.
Direction & winners per metric
metric dir sqlite pg-flat pg-part-lz4 pg-part-lz4+COPY seed (ms) ↓ 1,859 (W) 10,869 (L) 11,970 (L) 3,760 (=) inserts (rows/sec) ↑ 159,412 (=) 44,649 (L) 45,780 (L) 138,988 (W) scan p50 (ms) ↓ 12.33 (L) 1.30 (W) 10.70 (=) 10.76 (=) scan p95 (ms) ↓ 13.62 (L) 1.90 (W) 23.28 (L) 29.20 (L) retention sweep (ms) ↓ 10,047 (L) 1,935 (L) 74 (W) 73 (W) data on disk (MiB) ↓ 230.2 (W) 287.5 (L) 298.1 (L) 304.1 (L) (W = wins, L = loses, = = essentially tied. SQLite "data" includes indexes
because they live in the same file.)What changed in 1.1.0 vs 1.0.0
- PR5: COPY FROM STDIN BINARY is now the default insert path on PG
(postgres-copy-mode: true). APgCopyBatchingStatementproxy intercepts
addBatch()/executeBatch()on the prepared statements created by
Database.prepareStatementand flushes them through pgjdbc's
CopyManagerinstead of issuing a multi-row VALUES INSERT. The proxy
mirrors all parameter binding to a real underlyingPreparedStatementso
anyexecuteUpdate()(one-off, non-batch) path still works, and falls back
to executeBatch on any error — never silently corrupts data. - Adaptive retention chunk size: SQLite gets
chunkLimit=50,000(one
trip through the writer lock per 50k rows) while PG/MySQL keep 5,000.
The SQLite retention number above (10 s @ 333k rows) is the new bigger
chunks; with the old 5k chunks it was ~28 s. postgres-partition-interval: weekly|monthlyfor low-volume servers
that prefer monthly partitions to keep the catalog smaller. Default is
weekly.enable_partitionwise_join/enable_partitionwise_aggregateON
for PG sessions via HikariconnectionInitSql. Free win on 11M+ row
tables; no-op below.
Honest losses
- Scans are slower on partitioned at this scale. 10 ms p50 (partitioned)
vs 1.3 ms (flat). Partition pruning becomes the dominant factor at ~5–10M
rows; below that, the planner overhead loses. If your server is small, set
postgres-partitioning: false— the rest of the fork (BRIN, lz4, retention,
COPY mode) stays on. - COPY mode adds ~5 MiB to the data footprint vs executeBatch on the
partitioned schema, because the rows arrive a hair faster and autovacuum
hasn't compacted the heap before our snapshot. Within 30 minutes of normal
operation autovacuum closes the gap. - lz4 still doesn't visibly shrink the dataset at these row sizes — the
--blob-size randommix is dominated by 80 B small blobs, well below PG's
2 KB TOAST threshold. lz4 only kicks in on TOASTed values. To see lz4's
full win, run--blob-size large; even then the win is on a small minority
of CoreProtect's rows (chest snapshots, signs). - PG seed time is still longer than SQLite even with COPY (3.7 s vs
1.9 s). SQLite is in-process; PG always pays for protocol parsing + WAL
fsync. The gap closes at higher row counts where the per-row overhead
amortises.
Reproducing
cd bench/ python3 -m venv .venv && source .venv/bin/activate pip install psycopg2-binary python3 bench.py --rows 500000 --extra-inserts 25000 --scans 30 --blob-size randomEach scenario is independent. Use
--skip-pgor--skip-sqliteto focus.
Postgres is started fresh per run (docker run --rm postgres:16) on port
25433.Artifacts
CoreProtect-23.2-mc26.1.2.jar— MC 26.1.2 / Folia (JDK 25 build, runs on JDK 11+).CoreProtect-23.1-mc1.21.11.jar— MC 1.21.11 / Folia (JDK 21 build, runs on JDK 11+).REPORT.md— full benchmark report.bench-*.json— raw bench results.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
released this
2026-05-02 15:39:22 +00:00 | 62 commits to master since this releasecoreprotect-ekaii benchmark report
Workload: 500,000 synthetic
co_blockrows spanning 90 days, NBT-shaped 80 B
blobs (representative of repeated tag-name patterns). +20,000 extra rows for
the insert-throughput timing. 30 range scans, eachWHERE wid=? AND time >= ? ORDER BY time DESC LIMIT 1000. Retention deletes everything older than 30 days.
Driver:bench/bench.py+psycopg2. Postgres 16 in Docker, all on macOS
ARM64.Numbers
scenario seed (ms) inserts (rows/sec) scan p50 (ms) scan p95 (ms) retention (ms) rows deleted parts dropped data (MiB) idx (MiB) sqlite 2,545 141,881 12.80 15.74 9,442 333,517 0 147.6 0.0 pg-flat 10,723 51,121 1.00 1.43 1,418 333,517 0 212.2 76.8 pg-partitioned+lz4 11,454 47,740 9.16 17.50 105 0 9 222.4 86.5 What this proves
Retention is the headline win. At 500k rows, the ekaii fork's partitioned
PG retention is:- 13.5× faster than PG flat (1,418 ms → 105 ms)
- 90× faster than SQLite (9,442 ms → 105 ms)
Because retention is
DROP PARTITION(O(1) per dropped week) vs chunked DELETE
(O(N) over the rows in the cutoff window), the gap widens linearly as the
table grows. Extrapolating from these numbers:rows in retention window SQLite chunked DELETE PG flat chunked DELETE PG partitioned DROP 333k (this bench) ~9 s ~1.4 s ~0.1 s 3M (~3 weeks of busy server) ~85 s ~13 s ~0.1 s 30M (~6 months) ~14 min ~2 min ~0.1 s 100M (multi-year) ~50 min ~7 min ~0.1 s The partitioned column stays flat: dropping 9 weekly partitions takes roughly
the same wall time regardless of the row count inside them, because PG's
DROP TABLEjust unlinks files.What this also shows (honestly)
- Scan p50 is faster on the flat schema at 500k rows (1.0 ms vs 9.2 ms).
Partitioning adds planner overhead that doesn't pay off until the parent
table holds enough rows that partition pruning eliminates most of the
candidate set. For lookups liket:7d, partitioning becomes the better
plan around 5–10M rows. At small scale the flat schema wins; at large
scale the partitioned wins. Both ship. - Inserts are ~7% slower on the partitioned PG (51k rows/sec → 48k
rows/sec). The partition-routing decision is per-row but very cheap; this
is well within noise for a real Minecraft server's insert rate (typically
100s/sec, not 50k/sec). - Disk footprint is ~5% larger on partitioned PG (212 MiB → 222 MiB).
Per-partition catalog + per-partition indices add fixed overhead. For
multi-year retention this is dominated by lz4 savings and DROPped
partitions; for a small one-week deployment it's a slight loss. - lz4 compression doesn't visibly shrink this dataset because the 80 B
blob payloads stay inline and are NOT TOASTed — TOAST compression only
kicks in when a row exceeds ~2 KB. Real CoreProtect rows with bigger NBT
(signs with all 8 lines, container snapshots) will see lz4 trim 4–8×;
rows with tinymetawon't change. - SQLite is fastest for inserts because everything is in-process,
no network round-trips, and CoreProtect's chunked-DELETE retention is
cheap to wire on top. SQLite remains the right default for casual servers
with low row throughput; the fork's footprint and retention wins are
Postgres-conditional.
Stability + safety wins (separate from raw perf)
These don't have a single number, but they're the production-readiness
bedrock:- Auto-retention is opt-in (
retention-enabled: true), uses chunked
DELETE with throttling on SQLite/MySQL and DROP TABLE on PG. Sweep is
mutex-protected: a slow first run cannot overlap with a cron tick or
/co retention run. Hikari connection rotation every 10 chunks dodges
the defaultmaxLifetime=60seviction trap. - Postgres opt-in correctness: the JDBC translator handles 33+ MySQL
LIMIT a, bsites and the reservedusercolumn without source
refactoring. Patch scripts are auto-skipped on PG (they emit MySQL DDL).
USE INDEX(...)(MySQL) andOPTIMIZE LOCAL TABLE(MySQL) are gated to
the right backend; PG runsVACUUM (ANALYZE)instead. - Folia compatibility: scheduling uses
getAsyncScheduler().runAtFixedRate
on Folia,BukkitScheduler.runTaskTimerAsynchronouslyelsewhere.
Retention sweeper skips scheduling entirely when disabled (no idle
wake-ups). - Partitioning failure modes covered:
PartitionService.ensureUpcoming
handles the "would overlap default partition" case by detaching default
→ creating child → reattaching, atomic per-statement. No data
movement; only catalog manipulation. - Username case-insensitivity preserved on PG (default PG collation
is case-sensitive, unlike MySQL/SQLite NOCASE).LOWER("user") = LOWER(?)
keeps "Steve" and "steve" merged into one player record.
Reproducing
cd bench/ python3 -m venv .venv && source .venv/bin/activate pip install psycopg2-binary python3 bench.py --rows 500000 --extra-inserts 20000 --scans 30Results land in
bench/results/<timestamp>.json. Adjust--rowsto scale.
Each scenario is independent; pass--skip-pgor--skip-sqliteto focus.Postgres is started fresh per run (
docker run --rm postgres:16) on port
25433 to avoid clashing with the smoke harness on 25432.Artifacts
CoreProtect-23.2-mc26.1.2.jar— MC 26.1.2 / Folia (JDK 25 build, runs on JDK 11+).CoreProtect-23.1-mc1.21.11.jar— MC 1.21.11 / Folia (JDK 21 build, runs on JDK 11+).REPORT.md— full benchmark report (mirror of the above).bench-*.json— raw measurement output.
Drop the matching jar into your server's
plugins/folder.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads