Skip to content
hekla

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.