Repository navigation
Persistence design
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.
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.
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.
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.
| 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 |
- Nothing in the design.
| 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 |
| 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 |
- None of its own. The questions that span systems are on Server core design.
Guides
Explanation
Reference
Design
- Server core design
- Roadmap
- Codec design
- Message audit design
- Host design
- Gateway design
- Owners design
- Persistence design
- Command layer design
- Harness design
About this wiki