Skip to content

Save games

Fallen-8 is durable by default. Every committed transaction is appended to a per-namespace write-ahead log (WAL) and fsync’d before the call returns; on demand (via PUT /save or a clean shutdown) the engine writes a save game, a full checkpoint of the graph. A single registry at <deployment>/metadata/savegames.json records every save game with its KPIs and file facts and is the sole authority for what loads on startup. Between checkpoints the WAL closes the gap, so a crash recovers every committed transaction by replaying the log on top of the last checkpoint. Volatile mode (opt-in) turns all of this off. Save games are for full point-in-time restore points; to move data as portable text instead, see bulk import/export.

flowchart LR
    TX["committed<br/>transaction"] -->|fsync each commit| WAL[("write-ahead log")]
    WAL -.->|"PUT /save or<br/>clean shutdown"| CP["checkpoint<br/>(save-game files)"]
    CP -->|records KPIs + file facts| REG[("registry<br/>savegames.json")]
    CP -->|resets WAL to this snapshot| WAL
    REG ==>|"boot: newest entry per namespace"| LOAD["load checkpoint,<br/>then replay WAL tail"]
Piece Role
WAL Append-only, one per namespace. Each committed data mutation is framed with a CRC-32 and fsync’d before PUT/WaitUntilFinished returns. A torn trailing entry (crash mid-append) is discarded cleanly on replay.
Checkpoint / save game A full snapshot: a primary file plus sidecars (element partitions, indices, services, and the subgraph, stored-query and plugin manifests). Written atomically (temp file + fsync + rename); a save resets the WAL to build on the new snapshot.
Registry One JSON document per deployment, written atomically. Records every save game (never overwritten silently; a corrupt document is a loud failure) and decides what boots.
Content Persisted On load
Vertices & edges + their properties Yes: id-partitioned element files Restored
Element embeddings Yes: element state, travels with the elements Restored (semantic traversal)
Indices Yes: one file per index Restored; a load replaces the live index set with the checkpoint’s (indices created since it are dropped), and a bound vector index is rebuilt as a derived projection (indexes, vector search)
Services Yes Restored, but not started by the startup load (no configuration key changes that); started by PUT /savegames/{id}/load, and by PUT /load unless you send startServices: false
Subgraph recipes Yes: one manifest Recompiled (subgraphs)
Stored queries Yes: source only, one manifest Recompiled (stored queries)
Registered plugins Yes, source only (one manifest) Recompiled (plugin registration)

Between checkpoints the WAL records the same data mutations plus id-space markers (Trim, TabulaRasa) and the subgraph, stored-query and plugin registrations committed since the last save, so crash recovery loses nothing committed.

The registry (never the files on disk) decides what boots. Each namespace is resolved by its immutable id (a rename keeps its history; a drop + recreate does not inherit the old one’s saves):

Registry state What loads
An entry contains the namespace Its newest such entry’s checkpoint is loaded, then the WAL tail is replayed
No entry contains it Nothing is loaded; the namespace keeps its WAL-replayed construction state, an empty graph on a fresh deployment
  • A checkpoint file merely sitting in the storage directory is not auto-loaded. (Exception: a checkpoint strictly newer than the newest registered entry is adopted: it is a durable save whose registry write did not complete before a crash.)
  • To adopt an unregistered checkpoint, load it once with PUT /load; it is then registered permanently and boots normally thereafter.
  • A registered entry whose checkpoint file is missing aborts startup loudly rather than serving an empty graph: restore the file, or delete the entry (DELETE /savegames/{id}) and restart.
  • A corrupt savegames.json is a loud failure too, never silently overwritten: fix the JSON, or move the file aside and re-adopt the checkpoints with PUT /load (you lose the history, not the data).
Key Default Effect
Fallen8:Durability:StorageDirectory app base directory Directory holding the checkpoint files and the WAL
Fallen8:Durability:CheckpointBaseName Temp.f8s Base file name of the default checkpoint path, used by the clean-shutdown save, PUT /save/all, and a bare PUT /save
Fallen8:Durability:WalPath <StorageDirectory>/fallen8.wal WAL path for the default namespace
Fallen8:Durability:Volatile false true = pure in-memory: no boot load, no shutdown save, WAL disabled; a restart loses all data by choice
Fallen8:Durability:SaveOnShutdown true true = final checkpoint on clean shutdown; false = rely on the per-commit WAL (committed work still survives; the next boot replays a longer log)
Fallen8:Metadata:Directory <app base>/metadata Directory holding savegames.json

In the compose environment these bind from environment variables (Fallen8__Durability__StorageDirectory=/data, Fallen8__Metadata__Directory=/data/metadata) so the graph data and the registry share one named volume; see running. A bare PUT /save writes to the addressed namespace’s default checkpoint path (CheckpointBaseName inside its directory); pass saveGameLocation to write somewhere else explicitly.

Base URL http://localhost:8080. PUT /save and PUT /load target the addressed namespace (bare URLs alias the default namespace; every one also answers under /ns/{ns}/…). The /savegames registry routes and PUT /save/all are Fallen-8-level (one registry per deployment), so they are not namespace-twinned. See namespaces.

Route Scope Body Responses
PUT /save namespace SaveSpecification (all optional) 200 created entry · 400 · 500 rolled back
PUT /save/all Fallen-8 (none) 200 one entry spanning all namespaces · 429 rate-limited · 500 (body names failed namespaces; successful ones are still registered)

SaveSpecification: saveGameLocation (path; defaults to CheckpointBaseName in the addressed namespace’s directory), savePartitions (defaults to the optimal count for the CPU). Both PUT /save and PUT /save/all wait for the save to finish before responding.

A save never overwrites an existing checkpoint. If the target path is already on disk, the engine writes a versioned sibling instead: <path>#<stamp>, where the stamp is a monotonically increasing UTC timestamp. So repeated saves to the default location pile up as Temp.f8s, then Temp.f8s#…, then another Temp.f8s#…. Two consequences: read the returned entry’s location as the authoritative path (it is not necessarily the path you asked for), and prune old snapshots yourself with DELETE /savegames/{id}?deleteFiles=true, which deletes exactly that entry’s own primary file and sidecars and never a sibling version’s.

GET /status carries a durability block for the addressed namespace, so a client can tell the difference between “the graph is complete” and “the graph is what survived”:

Field Means
walEnabled a write-ahead log is open for this namespace
degraded the log’s failure fence has tripped, or an anchored log is waiting for its paired load: commits are landing in memory but not durably
recoveryRan a log replay has happened in this engine’s lifetime
lastRecoveryTruncated that replay stopped before the end of the log, so the graph is a prefix of the committed history
lastRecoveryReplayedEntries how many transactions it replayed
lastCheckpointDroppedIndices how many indexes the last checkpoint could not write, so they will be absent after the next load

The block is read as a group, so its fields always describe one outcome rather than a half-finished replay. Why it exists: a client that reconciles against the graph and then deletes what nothing asserts any more would otherwise draw that conclusion from truncated history with no way to know. The integrations runtime does exactly that, and defers its deletions unless this block says writes are reaching disk, the last recovery was not truncated, and the last checkpoint dropped no indexes.

lastCheckpointDroppedIndices above zero is the other case worth acting on: a derived index you own is gone after the next load, and rebuilding it from element state is one call.

Route Effect Responses
GET /savegames List every entry, newest first 200
GET /savegames/{id} One entry 200 · 204 unknown id
PUT /savegames/{id}/load Restore the entry’s namespaces, replacing their in-memory graphs 200 (waited) · 202 (accepted) · 404 · 500 (three causes, below)
DELETE /savegames/{id} Remove the entry; ?deleteFiles=true also deletes its checkpoint files 204 · 404
PUT /load Load a checkpoint from an arbitrary path into the addressed namespace 204 · 400 · 500 rolled back

Restore query parameters: ?waitForCompletion=true awaits the load and returns 200 (otherwise it returns 202 immediately); ?namespace={name} restores just that member of the entry (404 when the entry does not contain it, the other 404 being an unknown id). Restoring recreates a dropped namespace, replaces an existing one’s content, and leaves namespaces the entry does not contain untouched. PUT /load takes a LoadSpecification: saveGameLocation and startServices (default true); a checkpoint loaded this way is registered if not already known. Unlike PUT /savegames/{id}/load it does no pre-flight file check, so a path that does not exist answers 204 with nothing loaded (the miss reaches the server log only): verify the counts afterwards.

A restore is not a rollback, because the WAL always participates. A successful save re-anchors its namespace’s WAL to the checkpoint it just wrote. Restoring the entry the live log is anchored to (normally the newest) therefore loads that checkpoint and then replays everything committed since it, landing back on the current committed state: “restore the last save game to undo my recent writes” is a no-op. Restoring an older entry re-anchors the log to it and discards the transactions committed after it, which is the honest way back to a point in time; the discard is announced as a loud server-log warning. PUT /load of an arbitrary path behaves identically.

500 from PUT /savegames/{id}/load has three causes, and the first two are all-or-nothing pre-flight failures that touch no graph at all: a member’s checkpoint file is missing (Save game files missing, the REST twin of the startup abort above) or a dropped namespace could not be recreated (Namespace restore failed). Only the third, a rolled-back load transaction, can leave a partial restore; that body carries a failedNamespaces member naming the namespaces that failed.

A registry entry (SaveGameREST) reports: id, savedAt (ISO-8601 UTC), trigger (api | shutdown | imported), location, fileCount, totalBytes, engineVersion, kpis (vertexCount, edgeCount, usedMemoryBytes for the process working set at save time, plus the index/plugin/subgraph inventory), and a namespaces[] manifest (name, id, location, file facts, kpis per member). The top level is conditional: on a multi-namespace entry (what PUT /save/all and the shutdown save produce) location is null and fileCount/totalBytes/kpis are sums across the members, so read namespaces[].location for a member’s checkpoint. A single-member entry mirrors that member at the top level, and a pre-namespace entry has no namespaces at all (it reads as a default-only save).

Save the default namespace and capture the created entry’s id:

curl -X PUT http://localhost:8080/save \
-H "Content-Type: application/json" -d '{}'
curl http://localhost:8080/savegames # list, newest first

Restore an entry (waiting for the load), then delete an old one with its files:

curl -X PUT "http://localhost:8080/savegames/sg-20260724-101500-ab12/load?waitForCompletion=true"
curl -X DELETE "http://localhost:8080/savegames/sg-20260101-000000-9f3c?deleteFiles=true"

Adopt a checkpoint that is on disk but not in the registry (registers it permanently). Point at the exact primary file: the first save to a path is the bare name, later saves are versioned, so the newest snapshot is the Temp.f8s#<stamp> with the highest stamp; files whose name continues with _ are its sidecars and never a load target.

curl -X PUT http://localhost:8080/load -H "Content-Type: application/json" \
-d '{ "saveGameLocation": "/data/Temp.f8s#5250912345678901234", "startServices": true }'

PUT /save/all checkpoints every namespace into one save-game entry: a single consistent restore point for the whole Fallen-8; the clean-shutdown save produces the same shape. Restore the whole entry, or a single member with ?namespace={name}, via PUT /savegames/{id}/load. Each namespace keeps its own checkpoint files and WAL under its own directory (default uses the storage directory itself, every other namespace a namespaces/<id>/ subdirectory of it), while the registry is shared. The namespace model itself lives in namespaces.

F8 Studio has a Save games screen that lists entries and drives save, restore, and delete; see studio.