Skip to content

Storage Backends

Petrus Pradella edited this page Jul 31, 2026 · 8 revisions

Storage Backends

Where PlayerData, PDSections and account data physically live is an admin decision, made in plugins/EverNifeCore/storage.yml. Plugins advise a default; the admin has the final say. The storage engine is EveryDatabase - EverNifeCore configures and drives it.

The file is generated with a full set of commented, disabled example backends on first boot, so most admins only flip enabled and fill in credentials.


The shape of storage.yml

storage-backends:
  playerdata:                  # the entry key is a FREE unique id (name it anything)
    enabled: true
    type: groupedfile
    path: plugins/EverNifeCore/StorageData/PlayerData
    format: yaml               # yaml | json  (file backends only)
  networkdata:
    enabled: true
    type: groupedfile
    path: plugins/EverNifeCore/StorageData/NetworkData
    format: yaml
  mysql:
    enabled: false
    type: sql
    url: "jdbc:mysql://localhost:3306/minecraft"
    user: root
    pass: ""
    pool:
      minIdle: 2
      maxSize: 10
      connectTimeoutSeconds: 5
      idleTimeoutSeconds: 30

default-backend: playerdata    # used when nothing more specific is configured

The entry key is a free unique id, not the type. Declaring several backends of the same type is how you point different data at different servers - e.g. a mysql_economy and a mysql_points, both type: sql, on two databases.

Two backends come enabled, split by role rather than by type:

Id Holds Requirement
playerdata evernifecore_playerdata, every pd_<plugin>_<id> what belongs to ONE player on THIS server
networkdata ec_accounts, every acs_<plugin>_<id>, ec_server_cooldowns what the whole network must agree on

The id names the role and type names the technology, which is why switching networkdata to type: sql does not turn its id into a lie. Both start as local groupedfile folders: a single server is a network of one, and that is the correct answer until a second server joins - at which point you point networkdata at a database both servers reach and nothing else changes. The disabled localfile example deliberately sits in a folder called SomeRandomFolder, so enabling it without editing the path reads as unfinished rather than as a deliberate neighbour of the other two.

Every backend's runtime dependency (JDBC drivers, the Mongo driver) is downloaded up front at boot, so switching type here never hits a missing dependency.


Supported backends

type: maps to one of these. Full capability matrix and per-backend setup live in the EveryDatabase wiki - the short version:

type: Engine Good for EveryDatabase page
groupedfile key-major files (one file per key, all its collections) the per-player model; the factory default Grouped Files
localfile one file per entity tiny deploys, human-readable Local Files
sql MySQL / MariaDB (HikariCP pool, JSON column) production, shared DB MySQL & MariaDB
postgresql PostgreSQL (HikariCP pool, JSON column) production, shared DB PostgreSQL
h2 H2 embedded / file / tcp embedded / dev H2
mongo MongoDB (native BSON documents) document workloads MongoDB
memory in-memory only, ephemeral tests / throwaway servers In-Memory

format: yaml | json applies to the file backends (groupedfile / localfile) only; yaml is the default and json is always written pretty/indented. For choosing among them, see Choosing a Backend.

⚠️ memory data is lost on shutdown - the parser warns when you enable it. Use it only for tests.


Routing the data

Once backends are declared, four blocks route the actual data. Each may name a storage-backend-id; naming a missing or disabled backend is a fail-fast error at boot with a message that points at the offending key.

playerdata

playerdata:
  storage-backend-id: playerdata          # must be an enabled backend id
  collection: evernifecore_playerdata
  load-mode: ALL                          # ALL (default) | RECENT
  recent-days: 60                         # RECENT: how far back to eager-load
  login-timeout-seconds: 15               # how long a login may wait on storage before being denied
  slow-login-report-seconds: 3            # print a per-section breakdown of a login slower than this
  default-idle-grace-seconds: 3600        # how long a section lingers after its player goes offline
  orphan-reaper:
    enabled: false                        # opt-in; sweeps section rows whose base is gone
    interval-minutes: 360

load-mode: ALL loads every player at startup; RECENT eager-loads only players seen in the last recent-days and lazy-loads the rest on demand.

default-idle-grace-seconds (default 3600) is how long a player's section stays in memory after that player goes offline - the server-wide value for every section whose developer did not ask for a specific one. Lower it to reclaim memory sooner (0 releases as soon as the player leaves), raise it so a reconnect does not have to read from the database again.

login-timeout-seconds (default 15) bounds the whole login resolution - the player row plus every section that loads at login - not one read. When it is exceeded the login is denied rather than served with data missing.

slow-login-report-seconds (default 3, 0 disables) is when the framework stops being silent about a slow login and prints what it was waiting on: per section, the time, the plugin that declared it, that plugin's author and the backend it lives on. Reach for it before blaming EverNifeCore for a slow join - the breakdown usually names a specific plugin or a specific database. It prints on a timeout too, with the sections still loading marked as pending.

pdsections

pdsections:
  myplugin:                   # your plugin's name, lowercase
    jobs:                     # the section id declared at registration
      storage-backend-id: mysql
      # collection: pd_myplugin_jobs
      # lifecycle: LAZY                           # LAZY | ONLINE | RESIDENT | PRELOADED
      # idle-grace-seconds: 600                   # overrides the developer AND the global default
      # cache: { policy: TTL, ttlSeconds: 300 }   # ALWAYS | TTL

Generated automatically the first time each PDSection registers, keyed by the section's id (not its class name), so renaming the class never orphans the entry. The admin then edits storage-backend-id, collection, lifecycle, idle-grace-seconds and cache freely - keys are matched case-insensitively.

lifecycle overrides the plugin's declaration. It exists for one situation: the slow-login report named a section you would rather not pay for at every join, and LAZY takes it off the login path - the first read then happens whenever something actually asks for it. An unknown value fails the boot with a message listing the valid ones, rather than silently falling back.

An admin cache: sets freshness only; when a cell enters and leaves memory is the developer's lifecycle. NOCACHE is refused for a PDSection - the cached cell is the instance the flush pipeline persists, so bypassing the cache would lose every write.

An entry no registered section claims is reported with a WARNING shortly after boot: either the plugin that owned it is gone, or its section id changed and the rows of the old collection are no longer reachable. Nothing is moved or deleted for you.

accountsections

accountsections:
  myplugin:
    achievements:
      collection: acs_myplugin_achievements
      # cache: { policy: TTL, ttlSeconds: 300 }

The same idea for account-wide sections: generated on first registration, keyed by the section id, matched case-insensitively, and an unclaimed entry gets the same orphan WARNING.

Two knobs only, and the difference from pdsections is deliberate. There is no per-section backend: the whole account family lives on the backend set under network, which is what lets a link absorb an account's rows without coordinating writes across backends. There is no per-section lifecycle or idle-grace-seconds either - the family has one cycle, driven by whether any member of the account is online.

cache.policy: TTL is the one worth knowing about. An account row is written by every instance of the network, so on a backend with no change feed the login refresh would otherwise be the only moment another server's write becomes visible here. A TTL bounds that staleness without needing Redis. NOCACHE is refused, for the same reason as a PDSection.

network

network:
  storage-backend-id: networkdata   # REQUIRED - an enabled backend id, named explicitly
  # idle-grace-seconds: 3600        # absent = follow playerdata.default-idle-grace-seconds
  server-cooldowns:
    collection: ec_server_cooldowns
    # cache: { policy: TTL, ttlSeconds: 300 }

The one backend every server of your network must agree on. It holds the account registry (ec_accounts), every account-wide section, the network-wide server cooldowns, and whatever a plugin puts there through ECNetworkStorage.

Two different questions meet here, and only the first one is configured:

  • "do all my servers see the same row?" - answered by the backend below. Point every server at one shared database and they share data with no linking involved, because Minecraft servers already agree on a player's UUID.
  • "are these two identities the same person?" - answered by /ecaccount link, per person, by an admin. It is needed when the UUID itself differs (a Hytale server, a Discord identity). Nothing in storage.yml enables or disables it; a link exists only once someone runs the command. See Accounts.

storage-backend-id is required and explicit. Absent, empty, naming a backend that is not declared, or naming a disabled one - each is a critical error that cancels the boot. There is deliberately no "empty inherits default-backend": that implicit fallback is what turns an unrelated edit into a silent migration of the whole network family.

⚠️ A storage.yml still carrying the old multi-platform-accounts block is refused, with a message mapping each key onto its replacement. Ignoring it would let an admin read their own file back and believe those keys still do something. enabled has no replacement: linking identities was never a switch, and without a link every identity is its own account exactly as before.

idle-grace-seconds is how long an account row stays in memory once no member of that account is online here - after the last one quits, and equally for a row that was read for an account nobody was playing on (an offline lookup, an aggregate). One value covers the whole family; what a single section can override lives in accountsections.

server-cooldowns is the entry for the collection behind Cooldown.network(id) (see Cooldowns). It is generated when absent and takes two keys: collection, which is how you get out of a name collision with another plugin, and cache, where policy: TTL bounds how stale another server's write may look here on a backend with no change feed. NOCACHE is refused - the flush iterates the cached values, so a route without a cache would lose every write. There is no storage-backend-id here: these rows belong to the network family, and the family moves as one.


Two file backends may not share a directory

Two enabled file backends (groupedfile / localfile) that resolve to the same directory - or one whose directory sits inside the other's - cancel the boot, at the same severity as a database that does not answer. The check runs on the config alone, before anything connects, and compares absolute normalized paths (never toRealPath, which would throw on a first boot where the folder does not exist yet).

It is fatal rather than a warning because the damage is invisible:

  • a groupedfile's per-key lock lives in a store per Storage instance. Two backends over one directory are two independent lock maps over the same files: mutual exclusion stops existing and two concurrent writes to one key overwrite each other with no error;
  • listing reads every file in the directory carrying the resolved extension, so each backend starts trying to decode the other's files as its own;
  • the schema/migration bookkeeping of one becomes a stray file in the other's directory.

A warning in a boot log nobody reads does not protect against that, and by the time anyone notices the data is already corrupt. The fix costs one edited path.

Two things this rule deliberately does not cover:

  • a disabled backend, which opens no file at all;
  • an h2 database file sitting inside such a directory. Its file does not match the extension a file backend lists, so it is neither read nor overwritten - visual clutter, not corruption.

Multi-server cache-sync

When several instances share one database, each instance caches rows independently - a write on instance A must invalidate the same entry on instance B. That is what multi-server-cache-sync does.

multi-server-cache-sync:
  enabled: true               # default; HARMLESS no-op on a single server
  transport: auto             # auto | redis | native
  redis:
    enabled: false
    host: localhost
    port: 6379

Transport resolution:

  • auto (default) - Redis if the redis block is enabled, else the backends' native change feed when every manager's backend has one and none of them is a file backend, else a silent no-op.
  • redis - force the Redis pub/sub transport.
  • native - use only the backends' native feed; this is the only way to reach a file backend's.

Which backends have a native feed: MongoDB, PostgreSQL and the file backends (groupedfile / localfile). sql (MySQL/MariaDB) has none - there, cross-instance coherence exists only through Redis.

📌 Why auto skips a file backend's feed. That feed watches this machine's filesystem, so it cannot carry a write made by another server, and an event has no origin - every local write comes back and re-invalidates the cell it just updated. On a stock install that would cost a watcher thread per storage and a wasted reload per write to learn what this server already knew. Asking for it by name (transport: native) is where it earns its keep: an admin editing the data files by hand gets the server to notice.

📌 Current state of the Redis transport. The Redis transport lives in the optional everydatabase-manager-jedis module, which is not bundled with EverNifeCore on this version. If you configure a redis block without that runtime on the classpath, EverNifeCore logs a warning at boot and cache-sync via Redis is a no-op. The native-feed path (Mongo/PostgreSQL) is wired and works today. On a single server the whole block is a harmless no-op regardless.

For the deeper model, see Cross-Process Cache Sync.


Logging

logging:
  level: warn                 # warn (default) | info | debug | trace

Controls the EveryDatabase storage log verbosity (bind reports, conflict/flush lines).


When a backend cannot be reached

A backend declared enabled: true that does not answer at boot stops the server. This is on by default, and it is deliberate: booting without the database that holds player data would let every player join with empty data, and the first save would overwrite the real rows with those empty ones.

Every unreachable backend is reported at once, not one per restart. The report names each one with its type, the target it was pointed at (with the password redacted), which storage.yml keys route data to it, and the root cause of the failure; the full stack traces are printed above the banner.

# plugins/EverNifeCore/config.yml
Settings:
  Storage:
    STOP_SERVER_IF_STORAGE_IS_UNREACHABLE: true   # the default

Two things worth knowing before you flip it:

  • It only ever stops a BOOT. A failed reload never stops anything - the previously loaded storage is still live and serving, so the report says so and nothing is lost. Fix the config or the database and reload again.
  • false does not make the server work without a database. EverNifeCore stays disabled either way, every plugin that depends on it fails, and whatever data does get written diverges from what the database holds. The only real ways out of a boot failure are starting the database, fixing the url/user/pass, or setting enabled: false on a backend you no longer use.

Admin commands: /ecstorage

Subcommand Permission node What it does
status evernifecore.command.storage.status Prints the routing (which backend/collection each entity persists on), the claimed collections per backend with their owner, plus health counters - quit-flush retry backlog, adopted conflicts, last failed write.
transfer section <plugin:section> <backend> evernifecore.command.storage.transfer Migrates one PDSection's collection to another backend at runtime (see below).
transfer network <backend> evernifecore.command.storage.transfer Moves the whole network family to another backend (see below).

transfer is a branch, not a command: typing it alone lists the two migrations. The permission sits on the branch, so granting evernifecore.command.storage.transfer grants both.

The claim listing in status is how a collection a plugin put on the network backend becomes visible at all: it gets no storage.yml entry, so the claim is its only registration.


Moving data between backends

You are not locked in. A single PDSection's collection can be migrated to another backend at runtime without editing files by hand - see Legacy Data Migration for /ecstorage transfer section. The underlying builder is EveryDatabase's Moving Data Between Backends.

Moving the whole network: /ecstorage transfer network <backend>

The day a second server joins, the network family has to leave that local folder. transfer network does it in one command - the day-one local groupedfile is a decision you can undo.

The unit is the whole network family, indivisibly. A link absorbs an account's rows in one place, so a family split across backends would need a write coordinated across two databases - the very thing giving it one backend refused. It is also the command an admin actually wants: they think "I want my network on MySQL", not "I want collection X moved".

What travels is what is claimed on the source backend - ec_accounts, every acs_*, ec_server_cooldowns and every collection a plugin claimed through ECNetworkStorage. Nobody maintains a list, so a plugin's collection goes along with the framework's.

The order of events:

  1. The preview prints first, naming every collection that will move and its owner. A claim recorded without a descriptor cannot be copied by anything, so it is named as left behind rather than skipped in silence - reading "transferred everything" over a collection that stayed put is the failure worth preventing here.
  2. Everything is flushed, because the copy reads the backend: anything still dirty in memory would be left behind.
  3. Each collection is claimed on the target and copied there.
  4. Cutover writes network.storage-backend-id: <target> into storage.yml and re-runs the core's storage reload, so every family lands on exactly what was copied and plugin storage-reload callbacks fire on the way through.

If any step fails, the claims this transfer created on the target are released, the old binding stays exactly as it was, and the reason is reported. The source collections are never deleted (same discipline as the PDSection transfer), so undoing is a matter of pointing the config back. Only one network transfer runs at a time, process-wide. A maintenance window is recommended.


See also

Clone this wiki locally