VonixGuardian Vonix Mods v2.3.2 Search GitHub
VonixGuardian/Operations/Database & storage

Database & storage#

VonixGuardian stores its audit log in a relational database. SQLite is the default and needs no setup; MySQL, MariaDB, and PostgreSQL are supported for larger servers.

Choosing a backend#

Backend Best for Trade-off
SQLite Default, small to mid servers, single-node setups One writer at a time; WAL helps but concurrent writes still serialise.
MySQL / MariaDB Busy servers already running a database Network round-trips on every batch; needs credentials in config.
PostgreSQL Busy servers wanting a serious database Same as MySQL, plus PostgreSQL's own maintenance tooling.

Rough guidance: if /vg status shows a rising queue depth with a healthy backend, storage is your bottleneck and a remote database will help. If the queue is flat and drops are zero, SQLite is already sufficient regardless of player count.

The bundled SQLite driver ships nested inside the jar on every loader, so a SQLite install needs nothing beyond the jar. MySQL and PostgreSQL drivers are also nested — no separate library install is required.

Where the data lives#

Backend Location
SQLite vonixguardian.db in the server root (configurable via database.file).
MySQL / PostgreSQL The database named in database.jdbcUrl.

Also on disk, beside the database:

<server root>/
├── vonixguardian.db                       audit data (SQLite)
├── config/vonixguardian/config.json       configuration
├── config/vonixguardian/worlds/           per-world overrides
├── config/vonixguardian/queue-quarantine.bin  durable quarantine outbox
├── logs/vonixguardian/audit-YYYY-MM-DD.log.jsonl  rolling log file
└── blacklist.txt                          optional suppression rules

Schema#

Current schema version: 9. Migrations are additive and idempotent — they run at boot, detect the existing version, and apply only what is missing.

Tables#

Table Purpose
vg_users Actor registry: UUID, name, first seen, last seen.
vg_worlds World key registry.
vg_actions The audit log itself. One row per event.
vg_rollback_batches One row per rollback or restore operation.
vg_rollback_batch_actions Which actions each batch touched.
vg_repair_required Rows a mutation could neither apply nor compensate.
vg_sink_outbox Durable quarantine for admissions that could not be written.
vg_schema_version Applied migration versions.

vg_actions#

The core table. Each row is one event.

Column Type Meaning
id integer PK Row identity.
ts bigint Epoch milliseconds.
type smallint Action type id — see Action types.
user_id integer FK to vg_users.
world_id integer FK to vg_worlds.
x, y, z integer Block coordinate.
target varchar(4096) Block, item, or entity id.
meta text Free-form payload (chat text, command, sign content).
amount integer Quantity, default 1.
rolled_back tinyint 1 when this row has been undone.
source_tag varchar(64) Attribution sentinel or source hint.
sign_side varchar(8) front / back on 1.20+.
sign_dye_color varchar(16) Sign dye colour on 1.20+.
sign_waxed boolean Sign waxed state on 1.20+.
old_block_state text Previous block state, for rollback fidelity.
new_block_state text New block state.
block_entity_nbt blob Block entity NBT, when storage.persistNbt is on.
item_nbt blob Item NBT.
entity_nbt blob Entity NBT.
inventory_slot integer Slot index for inventory rows.
pair_id bigint Links the two halves of a paired event (container in/out).

Indexing is on (world, x, z, y, time) for spatial queries plus a composite (ts, id) index added in v9 for keyset pagination.

Why the (ts, id) index exists

Paging past the first page used to run a deep OFFSET. The (ts, id) index lets page 2 and beyond continue from the last row's keyset instead, so a lookup on page 40 costs about the same as page 2.

Backup and restore#

SQLite#

Stop the server, then copy the file. SQLite in WAL mode leaves -wal and -shm sidecars, so copying a live database without the sidecars captures an inconsistent snapshot.

# Server stopped
cp vonixguardian.db vonixguardian.db.backup-$(date +%F)

For a live backup without stopping, use SQLite's own tooling:

sqlite3 vonixguardian.db ".backup '/backup/vg-$(date +%F).db'"

Restore by putting the file back with the server stopped and the sidecars removed:

rm -f vonixguardian.db-wal vonixguardian.db-shm
cp vonixguardian.db.backup-2026-11-01 vonixguardian.db

MySQL / MariaDB#

mysqldump --single-transaction --quick vonixguardian > vg-$(date +%F).sql
mysql vonixguardian < vg-2026-11-01.sql

--single-transaction gives a consistent snapshot without locking the tables.

PostgreSQL#

pg_dump -Fc vonixguardian > vg-$(date +%F).dump
pg_restore -d vonixguardian --clean vg-2026-11-01.dump

Back up before a large purge

purge is irreversible. There is no preview and no undo. If you are about to delete a range you might later want, take a backup first.

Direct SQL#

Read-only queries against the audit log are useful for reporting and for verifying that a rollback did what it claimed.

-- Row counts by action type, last 7 days
SELECT a.type, COUNT(*) AS n
FROM vg_actions a
WHERE a.ts > (EXTRACT(EPOCH FROM now()) * 1000 - 604800000)
GROUP BY a.type
ORDER BY n DESC;
-- The 20 most recent block breaks in the overworld
SELECT u.name, a.x, a.y, a.z, a.target, a.ts
FROM vg_actions a
JOIN vg_users u  ON u.id = a.user_id
JOIN vg_worlds w ON w.id = a.world_id
WHERE w.world_key = 'minecraft:overworld'
  AND a.type = 2
ORDER BY a.ts DESC
LIMIT 20;
-- Database size by table (PostgreSQL)
SELECT relname, pg_size_pretty(pg_total_relation_size(oid))
FROM pg_class
WHERE relname LIKE 'vg_%'
ORDER BY pg_total_relation_size(oid) DESC;

Action type ids are stable: 1 place, 2 break, 3 container deposit, 4 container withdraw, and so on. The full list is on Action types.

Never write to vg_actions directly

Inserting or updating rows by hand bypasses the batch bookkeeping in vg_rollback_batches and vg_rollback_batch_actions. A rollback can then reference rows that do not exist, or miss rows that do. Read directly only.

Quarantine and drops#

When the writer cannot keep up, events are not silently lost. The admission path tries, in order:

  1. Enqueue normally.
  2. Journal to the durable quarantine outbox (vg_sink_outbox plus queue-quarantine.bin) if the queue is full and the quarantine has capacity.
  3. Count and log the drop at ERROR with the action type, id, world, and coordinates if the quarantine is also full.

/vg status exposes the counters:

Field Meaning
dropped Events that never reached the database.
quarantined Events currently held in the quarantine outbox.
recovered Quarantined events since reclaimed.
qOverflow Events lost because the quarantine was full.
qWriteFail Failures writing to the quarantine itself.

A non-zero dropped with zero quarantined means the durable path is disabled or full — check qOverflow and qWriteFail. Rows recovered from quarantine are replayed; rows in qOverflow are loss records, not replayable.

For a rejected explosion handoff, the journal records entries=<n> at the blast centre only. The per-block list is not retained, so that row is a loss record and not a rollback source.

Repair-required rows#

When a mutation cannot be applied and cannot be compensated, the row is recorded in vg_repair_required with the batch id and a reason. This is the engine refusing to guess.

/vg status lists them:

§ Rollback recovery
  open batches 1 — inspect with /vg lookup before re-running rollback or restore
  batch 17 mode=ROLLBACK affected=318
  repair required 2
  repair action 4412 batch=17 reason=target block entity type mismatch

Inspect the referenced rows with /vg lookup and #verbose, then decide whether to re-run the rollback for that region. There is no automatic repair — a wrong assumption is worse than an outstanding record.

Cache and maintenance#

Each backend benefits from its own maintenance, and /vg purge ... #optimize runs the right one:

Backend Statement
SQLite VACUUM
MySQL / MariaDB OPTIMIZE TABLE
PostgreSQL VACUUM ANALYZE

The optimize step is capped at five minutes and its failure never fails the purge. Run it after a large deletion, not routinely — VACUUM on a multi-gigabyte SQLite file rewrites the whole database.

Migrating to another backend#

/vg migrate-db copies the catalog and is described in full on Backend migration. The short version: configure database.migrationTarget, run /vg migrate-db <type> CONFIRM from the console, then change database.type and restart.