moxzi
Docs / Server / Persistence and durability

Persistence and durabilityalpha

What survives a restart, a crash, and a kill -9?

Durability in moxzid is a snapshot plus a write-ahead log, per actor, under the directory you pass to -s. The constraint that comes first: without -s the server is entirely in-memory and everything dies with the process. That default is deliberate and explicit either way — a scratch run wants it, and anything real must say otherwise.

What is on disk#

state/
  principal.seed                       32 random bytes: this host's actor namespace
  <hex-actor-id>/
    snapshot.bin                       heap + stable memory + cycle balance, at a seq
    wal.log                            messages accepted since that snapshot
    outcalls.log                       HTTPS outcall responses, keyed by message seq
    history.jsonl                      the recent-message ring, for post-mortems
    module.wasm  actor.json            for actors created at runtime, not by the manifest

The seed is created once and kept, which is why an actor's derived address is the same tomorrow. Two servers must never share a state directory.

The ordering that makes it work#

A message is written to the WAL and flushed and fsynced before it executes, never after. Logging afterwards would look equivalent and is not: a crash between executing and logging silently loses a message the caller was told had been accepted, which is the one failure a durable system may not have. The price is that a message which trapped gets replayed and traps again — which is correct, because the trap is deterministic and replay reproduces exactly the state the crash interrupted.

Replay must be exact, so the non-deterministic inputs are recorded rather than re-sampled:

InputHow replay reproduces it
ic0.timethe timestamp is written into the WAL entry and restored before the message re-runs
HTTPS outcall responsesrecorded in outcalls.log against the message's sequence number and replayed from there
raw_randderived from a counter that is part of the actor's persisted record, not drawn fresh

Snapshots are written to snapshot.tmp, fsynced, and renamed into place, so a crash mid-write leaves the previous snapshot intact; only then is the WAL truncated. A crash between the rename and the truncation costs a replay, never a loss.

The three failure modes#

ModeWhat moxzid doesWhat comes back
Clean shutdown (SIGTERM / SIGINT)finishes the message in flight, starts nothing new, checkpoints every actor, exits 0the snapshot. scripts/moxzid_shutdown.sh asserts the restart replays nothing
Crash while a message is runningnothing — the process is gonethe snapshot, then every WAL entry after it, including the message that was mid-flight
kill -9 or power lossnothing, by definitionthe same. scripts/moxzid_crash.sh proves it twice: once by snapshot, and once with --snapshot-below 0 so recovery must replay the log

The two recovery paths are different code, and only one of them runs by default, which is why the gate exercises both. --snapshot-below <BYTES> (default 50 MB) is the switch: actors at or below that heap size are snapshotted after every message, so their log is usually empty and recovery is a file read; larger actors ride the WAL. Setting it to 0 snapshots never and is how the replay path gets tested rather than assumed.

fsync is on. Without it "durable" is a wish rather than a property — a page-cache write survives the process dying and not the machine.

What does not come back#

LostWhy
A message accepted by the socket but not yet starteddurability begins at the WAL append, which happens when the actor thread picks the job up. A queued message dies with the process and its client sees a dropped connection
A half-written WAL recordrecords are length-prefixed, so a torn tail is detected. Replay stops at the first record it cannot read whole. That message was never acknowledged to anyone, so dropping it is correct
The last line of history.jsonl after a power cuthistory is a deliberately weaker diagnostic tier: no fsync, plain greppable JSONL. Losing a line costs a post-mortem aid, not state
Query resultsa query is never journalled, cannot write, and is rolled back whatever happens
An actor whose stored module no longer loadsreported at startup as LOST <id> <why> and skipped. It is named rather than swallowed — a server that starts having silently lost a canister is worse than one that says so

An open question, not a claim: the armed global-timer deadline is host-side state and is not part of a snapshot, which carries memory, stable memory and the cycle balance only. No gate here covers whether a timer armed before a restart still fires afterwards. If it matters to your actor, re-arm from its initialisation path rather than assuming.

Actors created at runtime#

An actor installed over POST /install/<name>, or created by another actor, is durable from the moment it exists: its module and record are written beside the WAL, and the store that makes its messages journalled at all is opened then rather than at the next restart. On recovery these are adopted first, before the manifest's own actors are replayed, and re-installed with their original init argument — a log replayed against a default-initialised heap would rebuild a different actor without saying so.

Inter-actor calls need care that a single-actor design does not. A message that touched other actors snapshots them too, because their commits are real even when the ingress itself failed, and their own stores hold no journal of inter-actor deliveries — a snapshot is the only durable record their state has.

An upgrade forces a checkpoint. This is correctness, not housekeeping: the log holds messages that ran against the old code, so replaying them after a crash would run them against the new code and could land the actor somewhere it never was.

Recovery, and the alpha caveat#

Recovery runs on one thread, one actor at a time, and completes before the server reports ready. It is single-flight: there is no parallel replay, no incremental serve-while-you- recover, and no way to prioritise one actor. Everything that is not a health probe answers 503 starting: <note> until it finishes. That is why the listener binds before recovery — a long replay stays visible instead of looking like a hang — and why clients must wait on GET /health/ready rather than /health.

Practically: keep --snapshot-below above your actors' heap sizes if startup time matters, and stop with SIGTERM so the restart reads a snapshot instead of replaying a journal.

Backup and restore#

kill -TERM $MOXZID_PID && wait        # checkpoints every actor, exits 0
tar -czf backup.tar.gz -C /var/lib moxzi
# ... disaster ...
tar -xzf backup.tar.gz -C /var/lib
systemctl start moxzid                # state intact, still serving

scripts/moxzid_backup.sh executes exactly these steps, including wiping the directory in between. Copying the state directory while the server is running is not a supported backup: stop first, or accept that you may catch a torn write.

Next#

On this pageWhat is on diskThe ordering that makes it workThe three failure modesWhat does not come backActors created at runtimeRecovery, and the alpha caveatBackup and restoreNext