• v23.2-ekaii-1.2.0 cd210137c3

    v23.2-ekaii-1.2.0 — DuckDB embedded backend, beats SQLite on every metric
    Some checks failed
    Build CoreProtect (ekaii fork) / Build (mc26.1.2-jdk25) (push) Successful in 42s
    Build CoreProtect (ekaii fork) / Release on tag (push) Failing after 30m41s
    Build CoreProtect (ekaii fork) / Build (mc1.21.11-jdk21) (push) Successful in 43s
    Stable

    admin_ekaii released this 2026-05-02 16:38:18 +00:00 | 59 commits to master since this release

    coreprotect-ekaii benchmark report — v23.2-ekaii-1.2.0

    TL;DR — DuckDB beats SQLite on every metric

    At 1M synthetic co_block rows (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

    1. PR (6) — Time-clustered primary key. co_block and co_container
      on partitioned PG and on DuckDB use PRIMARY 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.
    2. PR (2) — postgres-async-commit: true. Sets synchronous_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 in config.yml.
    3. PR (1) — Unix domain socket auto-detect. When postgres-host is
      loopback AND postgres-socket-path resolves 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).
    4. PR (3) — DuckDB embedded backend. database-backend: duckdb is 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's libraries/ folder
      or use paper-plugin.yml dependencies.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's libraries/ 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-duckdb skip individual scenarios.
    Postgres is started fresh per run (docker run --rm postgres:16) on port
    25433. DuckDB and SQLite use file-based databases under bench/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.json
    

    The plugin will auto-detect on startup; if the driver is absent, it falls back to SQLite with a clear console message.

    Downloads
  • v23.2-ekaii-1.1.0 88e9ac2a11

    v23.2-ekaii-1.1.0 — COPY-based inserts (3.1× faster) + cheap fixes
    Some checks failed
    Build CoreProtect (ekaii fork) / Release on tag (push) Failing after 41s
    Build CoreProtect (ekaii fork) / Build (mc1.21.11-jdk21) (push) Successful in 45s
    Build CoreProtect (ekaii fork) / Build (mc26.1.2-jdk25) (push) Successful in 45s
    Stable

    admin_ekaii released this 2026-05-02 16:03:50 +00:00 | 60 commits to master since this release

    coreprotect-ekaii benchmark report — v23.2-ekaii-1.1.0

    Workload: 500,000 synthetic co_block rows 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, each WHERE 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 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.prepareStatement proxy. 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 TABLE is 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

    1. PR5: COPY FROM STDIN BINARY is now the default insert path on PG
      (postgres-copy-mode: true). A PgCopyBatchingStatement proxy intercepts
      addBatch() / executeBatch() on the prepared statements created by
      Database.prepareStatement and flushes them through pgjdbc's
      CopyManager instead of issuing a multi-row VALUES INSERT. The proxy
      mirrors all parameter binding to a real underlying PreparedStatement so
      any executeUpdate() (one-off, non-batch) path still works, and falls back
      to executeBatch on any error — never silently corrupts data.
    2. 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.
    3. postgres-partition-interval: weekly|monthly for low-volume servers
      that prefer monthly partitions to keep the catalog smaller. Default is
      weekly.
    4. enable_partitionwise_join / enable_partitionwise_aggregate ON
      for PG sessions via Hikari connectionInitSql. 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 random mix 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 random
    

    Each scenario is independent. Use --skip-pg or --skip-sqlite to 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
  • v23.2-ekaii-1.0.0 434723b472

    v23.2-ekaii-1.0.0 — Postgres + partitioning + auto-retention
    Some checks failed
    Build CoreProtect (ekaii fork) / Release on tag (push) Has been skipped
    Build CoreProtect (ekaii fork) / Build (mc1.21.11-jdk21) (push) Successful in 44s
    Build CoreProtect (ekaii fork) / Build (mc26.1.2-jdk25) (push) Failing after 11m24s
    Stable

    admin_ekaii released this 2026-05-02 15:39:22 +00:00 | 62 commits to master since this release

    coreprotect-ekaii benchmark report

    Workload: 500,000 synthetic co_block rows 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, each WHERE 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 TABLE just 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 like t: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 tiny meta won'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:

    1. 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 default maxLifetime=60s eviction trap.
    2. Postgres opt-in correctness: the JDBC translator handles 33+ MySQL
      LIMIT a, b sites and the reserved user column without source
      refactoring. Patch scripts are auto-skipped on PG (they emit MySQL DDL).
      USE INDEX(...) (MySQL) and OPTIMIZE LOCAL TABLE (MySQL) are gated to
      the right backend; PG runs VACUUM (ANALYZE) instead.
    3. Folia compatibility: scheduling uses getAsyncScheduler().runAtFixedRate
      on Folia, BukkitScheduler.runTaskTimerAsynchronously elsewhere.
      Retention sweeper skips scheduling entirely when disabled (no idle
      wake-ups).
    4. 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.
    5. 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 30
    

    Results land in bench/results/<timestamp>.json. Adjust --rows to scale.
    Each scenario is independent; pass --skip-pg or --skip-sqlite to 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