Persistence¶
The server keeps its durable state — bans, accounts, career statistics and campaign saves — in a
SQL database it owns. Before this existed, the only things that survived a restart were a flat
banlist.txt rewritten in full on every ban, and a campaign save file.
It is on by default, and a server that cannot open its store will not start. That is deliberate: a server which runs perfectly while quietly persisting nothing is one whose operator finds out weeks later, when a ban they set does not survive a restart.
[persistence]
enabled = true
backend = "sqlite" # sqlite | postgres
sqlite_path = "cache/fl-server.db"
postgres_dsn = "" # libpq connection string; needs a -DFL_WITH_POSTGRES=ON build
busy_timeout_ms = 5000
write_queue_max = 4096
Every key is Restart class — the connections, the schema and the writer thread are all
established at startup. reload_config reports a change here rather than applying it. Full key
reference: server configuration.
Upgrading an existing server¶
Two things happen on the first start after the upgrade, both once, both logged.
The store is created. cache/fl-server.db appears relative to the server's working directory,
so that directory must be writable. A deployment whose cache/ is read-only booted before this
release and will now refuse to start, naming the path.
Existing files are imported. A banlist.txt, an allowlist.txt and a
cache/campaign_<name>.flsave are each read once into the store and then left on disk. They are
your records; deleting them buys nothing, and a downgrade to an older server should still find them.
From that point the store wins on every subsequent load. This matters more than it sounds: the files stay frozen at the moment of the upgrade while the store keeps changing, so a server that preferred the file would silently undo every ban and unban made since — and it would look like the server had simply forgotten.
Take a backup before upgrading. Migrations are forward-only (see below), so the recovery path from a bad upgrade is a backup, not a downgrade.
What is stored, and what is not yet¶
| Data | Status |
|---|---|
| Bans and the allowlist | Live. Written by ban / unban, with the issuing admin recorded. |
| Campaign saves | Live. Replaces cache/campaign_<name>.flsave. |
| Accounts | Schema and repository exist; nothing creates accounts until identity ships. |
| Career statistics | Schema and repository exist; nothing writes them until server-authoritative stats ship, which needs verified identity to key on. |
The account and statistics tables are present so that the schema does not have to change under a
live deployment when those features arrive. An empty accounts table on a busy server is expected,
not a fault.
Backends¶
SQLite — the default¶
Needs no external service. It is compiled into the server rather than taken from the system, so every platform runs one pinned version and the database behaves identically on your machine, in CI and on an operator's build.
It runs in WAL mode with synchronous = NORMAL:
- A server crash — including
kill -9— keeps everything the writer has applied, which in practice is everything but the last fraction of a second (see below for what "written" means). - A host crash or power loss can lose the last moments of applied writes, but does not corrupt the database.
If the server cannot switch the database to WAL — which happens when several servers open the same file at the same instant — it logs a warning and carries on in the journal mode it has. The store is fully functional either way, only less concurrent.
PostgreSQL — for several servers sharing one store¶
A build-time option. A stock server does not contain it, and asking for backend = "postgres"
on such a build fails at startup saying so, rather than pretending the database is unreachable.
postgres_dsn carries a password, so keep server.toml readable only by the server's user. The
value is redacted wherever the server prints it — startup log, reload_config output, admin
surfaces — but it is in the file in clear.
Durability, and what "written" means¶
Writes are applied on a dedicated thread so the simulation never blocks on the database. A command returns once its write is queued; it is applied moments later, and definitely by the time the server finishes shutting down, which flushes before closing.
So there is a brief window — sub-second on healthy storage — in which a kill -9 can lose a write
whose command has already answered. A clean shutdown has no such window. If you are about to pull
the plug on a server and the last ban matters, stop it rather than killing it.
Two more consequences worth knowing:
- A write is never silently dropped. If the queue reaches
write_queue_maxthe caller waits for it to drain rather than discarding work, and logs once naming the key to raise. - A failed write is logged at
Errorand counted. It is never swallowed.
There is no admin command or metric for store health yet. The counters exist internally — writes queued, completed, failed, and the worst queue depth seen — but nothing surfaces them. For now the log is the signal: grep for
persistence:atError. Exposing them belongs with the metrics exporter work.
Schema and migrations¶
The schema is versioned and migrations are forward-only. On start the server applies any migration newer than the database's version, each in its own transaction.
Two behaviours are deliberate:
- Migrations are safe when several servers start at once. Each runs inside an exclusive transaction and re-reads the applied version inside it, so a migration another process already applied is skipped rather than colliding. Two servers starting together against one fresh database is ordinary — a restart, a test suite, a second server in the same directory.
- An older server refuses to open a newer database, naming both versions. Carrying on would have it write rows against a shape it is guessing at.
There is no down-migration and there will not be one: it would be code that runs once, in an emergency, on data nobody has a copy of, having never been exercised.
Backups¶
Stop the server, or use a tool that understands the format — copying a live SQLite database with
cp can capture a torn state.
# SQLite, server stopped -- take the -wal and -shm files too, not just the .db
cp cache/fl-server.db cache/fl-server.db-wal cache/fl-server.db-shm backup/
# SQLite, server running -- .backup is safe against a live database; cp is not
sqlite3 cache/fl-server.db ".backup 'backup/fl-server.db'"
# PostgreSQL
pg_dump --format=custom --file=backup/fl.dump "$FL_DSN"
Managing bans¶
Bans are managed with the admin commands, not by editing a file:
| Command | Effect |
|---|---|
ban <peerId\|IP> |
Bans the address, kicks matching peers, and records the rule with the issuing admin's name |
unban <IP> |
Removes the rule |
reload_banlist |
Re-applies active ban rules from the store |
reload_allowlist |
Re-applies active allowlist rules from the store |
reload_banlist is how a running server picks up a change made elsewhere — by another server
sharing a PostgreSQL store, or by an operator working directly in the database. The file-based
security.banlist_path and security.allowlist_path are deprecated to import-only and warn at
startup when set.
A ban rule records who issued it and when. Attribution is coarse today — console for stdin, RCON
and the single-player admin token, peer N for an in-game admin — and becomes a real account
identity when identity ships.
Rules can carry an expiry, and one that has lapsed is not applied. Nothing sets a duration yet:
the running server holds its ban list in memory and rebuilds it only on load, so a temporary ban
would not actually lift until a restart. A --for option ships together with the refresh that makes
it true, rather than before it.
Running without a store¶
[persistence] enabled = false is a supported configuration for a throwaway or test server. The
server starts, logs plainly that bans, accounts and statistics will not survive a restart, and the
ban command says a ban will not outlive the process. Campaign saves fall back to
cache/campaign_<name>.flsave exactly as before — turning persistence off must not cost you a
campaign.
Troubleshooting¶
persistence: cannot open the … store: <path>: attempt to write a readonly database
The server's working directory or the database file is not writable by the server's user. Fix the
permission, point sqlite_path somewhere writable, or set enabled = false deliberately.
… it was migrated by a NEWER fl-server
The database was upgraded by a newer build. Run that build, or restore the backup taken before the
upgrade. This server will not touch it.
persistence: write queue full at N
Writes are arriving faster than the disk accepts them, and callers are being made to wait. Almost
always a sign the underlying storage is struggling; raise write_queue_max only if it is a genuine
burst.
security.banlist_path is DEPRECATED …
Expected if you still have the key set. The file was imported once and is no longer read. Remove
the key when you are satisfied the store has your bans — reload_banlist will tell you how many are
in force.
Bans do not survive a restart
Check the startup log for persistence: disabled — the store is off. Otherwise check for
persistence: lines at Error.