debit must never overdraw,
and a committed debit must never be lost. Below is what that actually requires, from
first principles, and how a stateful actor hands you the same thing.
How You’d Build This, from First Principles
Strip it to the metal. One process serves every account —a, b, c, x, y,
… — holding them all in an in-memory map and handling a request for whichever one comes
in. Two things make each debit correct: a per-account lock, so two debits on the
same account can’t interleave (while debits on different accounts still run in
parallel), and a write-ahead log + fsync, so a committed debit is on disk before
you reply.
fsync — you don’t acknowledge the debit until the
bytes are on disk:
fsync, unlock:
fsync
before you reply is durability. Everything else follows:
- one process owns it all — the map, the per-account locks, and the log are shared by
every account
a…y; - durability is the log: a debit is committed once its record is
fsync’d, so a crash loses only what was never acked; - recovery is not replaying an ever-growing log from zero — that gets slower forever. You periodically write a snapshot of all balances and keep only the log records since it; on restart you load the snapshot and replay that short tail. (Postgres’ checkpoint + WAL, SQLite’s WAL mode, and Redis’ RDB + AOF all do exactly this.) The on-disk snapshot + log is the truth; memory is a cache;
- to grow past one process you shard accounts across processes and machines,
routing each account to the one that owns it — and this is where most people stop
hand-rolling and reach for a database,
which is exactly this machine (sharded, replicated, snapshot + write-ahead log) run by
someone else. Postgres’
COMMITis whatpersist_datadoes, its checkpoint is your snapshot, andSELECT … FOR UPDATEis the lock.
The Same Thing as a Stateful Actor
debit(acct_id, …) did the lookup
itself (lookup_or_create) and the logic. With an actor that lookup moves to the
caller: a normal function parses the request, names the account with idFromName,
and calls the method — which now takes only amount and never has to find its own state.
idFromName(acct) is the actor-world lookup_or_create — instead of finding a struct in
this process’s map, it routes to the one instance that owns that account, anywhere (the
“Reaching one account” row below).
Same check-and-decrement, same durability requirement — but no pthread_mutex, no
fsync, no log, no snapshots, no shard map. The two primitives are still there; the
runtime provides them. Here’s the mapping, mechanism by mechanism:
| Mechanism (first principles) | Hand-rolled in C | Stateful actor |
|---|---|---|
| Serialization point | a pthread_mutex per account, held across the change | one thread per name — the lock you can’t forget, because there is none |
| Durable before you reply | persist_data — append + fsync — then ack | method returns ⇒ writes flushed (flush-before-ack) |
| Truth vs cache | the snapshot + log on disk; the in-memory map is a cache | ctx.storage; instance fields are a cache, gone on restart |
| Reaching one account | a map lookup in-process; a shard map + router once you outgrow one process | idFromName(name) routes to the one owner |
| Parallelism across accounts | per-account locks in one multi-threaded process | a separate instance per name — each its own thread and storage |
| Restart recovery | load the snapshot, replay the log tail | runtime restarts the instance; storage is already durable |
| Scheduling | a timerfd + a timer wheel | ctx.storage.setAlarm + an alarm() handler |
a, b, c, x, y and splits
it: each account id becomes its own instance. idFromName("a") and idFromName("b")
are two different instances, on two different threads, each with its own storage —
debits on a serialize, debits on a and b run in parallel, and there is no shared
map, per-account lock, or single log to own. A stateful actor is that machine — a
single-threaded server with durable, flush-before-ack storage, one per account — that
the platform shards, routes, and restarts for you. You write the body of debit; the
runtime is the lock, the durability, the recovery, and the router.
The four guarantees the runtime makes from this mapping are stated precisely in
Execution model.
Next Steps
- Execution model — the four guarantees, stated precisely
- Lifecycle & placement — where an instance runs, and what survives a restart
- Addressing — naming and routing instances
- Project structure — the two-export shape