From 04f73a4e5b379295f201f813d15b36c08cca9718 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 23:19:55 -0500 Subject: [PATCH] docs(cdclient): fdb copies and hot reload Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ docs/CDClientFdb.md | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 docs/CDClientFdb.md diff --git a/README.md b/README.md index e630e9139..9a9b59d3b 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ locally. See [docs/UgcServer.md](docs/UgcServer.md). * **CDClient:** the hot lookup tables (ComponentsRegistry, ItemComponent, Objects) are read straight from the client's `cdclient.fdb`, memory-mapped once and shared by every server process (Windows, Linux, macOS), so loading a character with thousands of different items no longer stalls a world. The CDClient data is never modified. + Servers read a copy of the client's `cdclient.fdb`, so it can be replaced while they run, and master reloads it on + every server when it changes or on `/reloadcdclient` ([docs/CDClientFdb.md](docs/CDClientFdb.md)). * The chat server's old web API is removed; the dashboard's API covers online players, teams and announcements. ## License diff --git a/docs/CDClientFdb.md b/docs/CDClientFdb.md new file mode 100644 index 000000000..f796f0f77 --- /dev/null +++ b/docs/CDClientFdb.md @@ -0,0 +1,70 @@ +# CDClient: the client's cdclient.fdb + +## Reading rows from the fdb + +ComponentsRegistry, ItemComponent and Objects read their rows from a memory-mapped copy of the client's `cdclient.fdb` +(`FdbReader`, `CDFdb`), shared between all server processes through the OS page cache. `CDServer.sqlite` stays the +source of truth: at load each table compares its rows in both files and reads the ids whose rows the cdserver +migrations changed from SQLite (`CDFdb::FindChangedKeys`). Other tables read `CDServer.sqlite` as before. + +## Copies, not the client's file + +No server opens the client's `/cdclient.fdb`. Master copies it into `resServer`, named by the 64-bit FNV-1a hash of +its bytes, and makes the matching SQLite file: + +| File | What | +| --- | --- | +| `resServer/cdclient-.fdb` | copy of the client's fdb; the hash is of the copy's bytes | +| `resServer/CDServer-.sqlite` | made from that copy with `FdbToSqlite` and every cdserver migration | +| `resServer/cdclient-current` | two lines: the fdb copy and the SQLite file every server opens | +| `resServer/CDServer.sqlite` | the file used before copies existed; still used when the pointer names it | + +A new version of the fdb gets new names, so nothing is replaced or truncated while a process has it mapped or open. +Every file is written under a temporary name and renamed into place. Windows can't rename over or delete an open +file, and the content-addressed names avoid both. The client's file is only read and never locked, since it is copied +and not mapped. + +On the first start with copies, master copies the fdb and points at the existing `CDServer.sqlite`. If the client's fdb +changed while master was down, master makes the new `CDServer-.sqlite` at startup. Master then runs the cdserver +migrations on whichever SQLite file is current, as before. Worlds read `cdclient-current` at startup +(`FdbSnapshot::Resolve`). If it is missing, or names a file that isn't there, they use `CDServer.sqlite` without an fdb. +A client with only packed files (no loose `cdclient.fdb`) keeps using `CDServer.sqlite`, and nothing is watched. + +## Hot reload + +Master checks the client's fdb every `cdclient_watch_seconds` (default 5, 0 = off). It compares the size and mtime and +starts a reload once they differ from the current version and have stayed the same for one poll, so a file still being +copied in isn't read halfway. A reload can also be started by: + +* `/reloadcdclient` (GM 9, paired with the `cdclient_reload` dashboard permission); +* a `CDCLIENT_RELOAD` message with no names sent to master (what `/reloadcdclient` sends; master also accepts it from + the dashboard). + +A reload runs these steps: + +1. **Worker thread (master):** copy and hash the file. If the hash is the current one, stop. Otherwise make + `CDServer-.sqlite` on its own SQLite connection (convert, then every cdserver migration, recorded in its + `migration_history`), and compare the old and new copies table by table (row count and a content hash). The worker + never logs or touches RakNet, the game database, the shared CDClient connection or config. +2. **Main thread (master):** switch master's own CDClient connection and tables, write `cdclient-current`, log the + changed tables (for example `Objects: 16012 -> 16015 rows`), and send `CDCLIENT_RELOAD` with both names to every + ready world. +3. **Each world, between frames:** `CDClientDatabase::Reconnect` opens the new SQLite file before closing the old one, + and `CDClientManager::Reload` empties every table, maps the new copy and loads again (the changed-row lists are + rebuilt). The old table entries and the old fdb view are kept alive, so objects already spawned keep what they + loaded, and objects made afterwards read the new data. The world logs how long the switch took. + +Master keeps the current and previous copies and removes older ones after each reload and at startup. On POSIX, a +removed file that is still mapped stays readable until it is closed. On Windows the removal fails while a process maps +it, and master tries again next time. + +## Limits + +* The world reloads its cached tables on the main thread, so a reload costs about as long as the CDClient part of + startup, once per reload. +* Data copied out of the tables into other caches (for example behaviors already built, or zone data read at world + start) keeps the old values until those objects are made again. +* The UGC and dashboard servers don't switch on a reload yet; they read the current files when they start. +* The dashboard has no reload button yet; use `/reloadcdclient` or let master see the file change. +* A world that starts while master is making a new copy can read the old pointer and miss the broadcast; it catches up + at the next reload or restart.