Read models
Readiness, rebuilds, and what a read answers while a projector is catching up.
Each projector runs one sequential task, is the only writer of its own SQLite database, and commits its rows and its checkpoint in a single transaction. State and position cannot disagree, whatever happens to the process.
The definition hash
A projector records the hash of its definition inside its read model: heklang’s digest of what the declaration does, so its subscription, its entity shapes and its handler bodies are all in it. At startup the recorded hash is compared with the current one, and that decides what the projector does.
| Change | Definition change |
|---|---|
| adding, removing or retyping a column | yes |
| changing a key or an index | yes |
| handling a new event type, or dropping one | yes |
| editing what a handler does | yes |
| reformatting or re-commenting | no |
The line is drawn at meaning rather than at source text. A reformat or a new comment moves no hash and rebuilds nothing, while a corrected handler rebuilds and re-folds the log rather than leaving rows the old logic built.
Readiness
| Readiness | Reads answer | Resolves by |
|---|---|---|
ready |
rows | |
rebuilding |
503 rebuilding, with Retry-After |
itself |
stale |
503 stale, naming the replay route |
an operator replay |
rebuild_failed |
503 rebuild_failed, pointing at last_error |
fixing the cause, then a replay |
quarantined |
503 quarantined |
an operator, after looking |
rebuilding carries a Retry-After and stale does not, and that difference is the
signal: one clears on its own and the other is waiting for a person.
/status reports readiness beside position, lag, running, failed,
replays_completed, replays_failed and last_error.
running separates a projector idling for an operator from one whose thread is gone. A
replay posted to the latter is refused with 503 not_running rather than accepted and
dropped.
Rebuilds
With [projectors] auto_rebuild = true, the default, a definition change rebuilds
automatically at startup. Set it false and the projector goes stale instead, which is what
you want when a rebuild is expensive enough to schedule.
Either way it is rebuild-and-swap: build a fresh database from position 0, seal it, rename it in. A reader that opens the file mid-swap never sees a torn one.
curl -X POST localhost:8080/projectors/CustomerOrders/replay
{ "status": "replay_scheduled", "projector": "CustomerOrders" }
hekla plan tells you which projectors would rebuild and how many positions each would redo,
before the deploy. See Change safety.
Consistency
A read model is eventually consistent by default, because a projector is not in the request path. Nothing about the command’s response waits for it.
When a caller needs to see its own write, it passes the position back:
curl 'localhost:8080/read/CustomerOrders/Order?customer_id=7&after=41'
The read blocks until the projector reaches position 41, up to timeout_ms (default 5000,
maximum 30000), then answers 503 not_caught_up with a Retry-After if it did not get
there. timeout_ms=0 checks once without waiting.
That is per request, which is the right granularity: a write-then-read flow pays for consistency and a dashboard does not.
Erased columns
A column whose subject has been erased reads back absent, exactly as an unset optional does. A rebuild writes it NULL, because no read path ever mints a key.
That is why a sealed column must be optional, and it is the whole observable difference an erasure makes to a read model.
On disk
data/projectors/{Name}.db
One SQLite database per projector. Opening these directly is not a supported surface and the table layout is private; it changes without notice, and the checkpoint invariant is only guaranteed through the runtime.
Projector databases are rebuildable from the log, so they are the part of the data directory you do not need to back up. See Deploying.