Skip to content

Persistence design

rebaserHEAD edited this page Oct 17, 2026 · 1 revision

design, published verified 2026-10-16

The design for persistence: the database, its schemas and migrations, how ids are handed out, and the write-behind queue that carries every owner's reads and writes. Step 3's first stage builds the account, realm and ops schemas and the queue; the content schema arrives with the content pipeline. Roadmap says how far the build has got.

The idea

In short

One PostgreSQL 18 database with a schema per owner. No owner ever waits on it: an owner hands its work to the write-behind queue and hears back by message, and the queue keeps every key's work in order. A change counts as saved only once its commit is confirmed.

Why

An owner that waits on the database stalls everything it holds for as long as the database takes. Owners ask, workers do, and the order of saves and loads per key is kept in one place, so a load can't overtake the save it depends on and a transfer is never half done.

Decisions

Locked

One PostgreSQL 18 database, a schema per owner: account, realm, content (rebuilt from files, as Server core design describes) and ops (the console's audit log and settings). Each schema has its own DbContext and its own migration history, and a CI guard applies every migration to an empty database.

PostgreSQL 18 is the minimum because names and emails use a case-insensitive ICU collation (und-u-ks-level2), and LIKE works on such collations from 18. Each schema's first migration creates the collation if it doesn't exist, so the order the schemas migrate in doesn't matter. uint and ulong map to bigint through one convention. jsonb holds only blobs the client owns; unlock bitsets are bytea.

Ids come from sequences in blocks of IdBlockSize, handed out in memory and refilled off the tick, so an id exists before the save does. A character id sequence steps by the block size, so one fetch is one block.

No owner waits on the database once the server runs. Owners ask, workers read, and the results come back as messages; only loading at start-up, before an owner ticks, is exempt. The character list and the full load at character select run on load workers. Each load carries a ticket, so a result nobody still wants is dropped, and the result arrives as a message rather than an await on the realm's thread, so the realm can't skip re-checking what changed while it waited. The account owner's entitlements and a housing map's plot load the same way, and a map receives its character in the handoff.

The sign-on exchange reads credentials directly. The gateway reads an account's salt and verifier through an account directory with an async read, which it can do because it owns no game state. Logins are refused while the database is unreachable.

A dirty mark clears only when its commit is confirmed. An owner snapshots its dirty state on its own thread into an immutable change set and hands it to the write-behind queue, where a save worker applies it with a pooled DbContext, one SaveChanges and Npgsql's retry strategy.

The write-behind queue keeps saves and loads in order. Every change set names the persistent keys it touches: character, account, guild, plot. A sequencer keeps one queue per key, all in a single arrival order, and hands a set to a free worker once the set heads every one of its keys' queues, so a transfer that touches several keys stays ordered with no deadlock and no starvation. The sequencer only keeps the books; it never touches the database.

  • One database operation per key is in flight. A load queues behind any save for the same character, and a newer snapshot merges into a waiting save for the same key, carrying both dirty sets. A transfer that touches several keys never merges.
  • A failed save is retried, never dropped. The change set is retried with backoff and holds only its own keys, while owners keep their dirty state. The console shows the backlog, and new logins are refused while the database is unreachable.
  • A refusal is an answer, not a failure. A write that breaks a unique rule, such as a name already taken, completes as refused, and the owner tells the client so.
  • Transfers are all or nothing. A trade is one change set holding both characters, committed before either client hears about it. Escrow and account spend commit both owners' rows in one transaction.

The connection pool is sized to the workers plus the console. What survives a crash: everything committed. What's lost: anything since the last commit, bounded by the save interval. Transfers are never half done.

Knobs

Knob Default Sets
SaveWorkers, LoadWorkers not set yet The write-behind queue's two worker pools
IdBlockSize 1000 How many ids a sequence hands out per refill
Persistence:ConnectionString none, required The database the server uses

Open

  • Nothing in the design.

Rejected

Idea What killed it
Clearing dirty state before the commit confirms A failed write then loses the change silently
Saving a transfer as two saves The one save-ordering item duplication on record in another game server came from exactly that
citext for names and emails An ICU collation does the same job, and PostgreSQL 18 lets LIKE work on it

Verification status

Claim Basis
Case-insensitive names and emails Designed; held by the schema's own tests against PostgreSQL 18 as it's built
Order per key, retries and refusals Designed; held by the queue's own tests as it's built

Open questions

Clone this wiki locally