How it works
What happens between a request arriving and a read model answering, and which part of the runtime owns each step.
hekla is one process holding four things: an event log, a set of read models, a set of durable effects, and an HTTP surface over all three. This page follows one request through all of them, because most of what is surprising about the runtime is a consequence of the order these steps happen in.
One request, end to end
A POST /commands/PlaceOrder arrives. The route is the declared name of the command, not
the name of the file it lives in.
The command is loaded and its arguments are checked against the declared parameter
list. A missing field or a wrong type is a 400 before any of your code runs.
Its folds are resolved. A fold is a read declaration: it names a slice of the log,
and the runtime replays that slice to produce a value. A run of adjacent fold and guard
declarations resolves in one pass over the log, so five folds do not mean five reads.
The body runs and reaches one of three outcomes. It emits events and falls off the end,
it returns invalid(...), or it returns reject SomeRefusal.
The append is conditioned on the slices the folds named. This is the part worth slowing down for. The command did not read the log and then separately declare what it depends on; the slices it folded are the condition. If another writer landed in any of them between the read and the append, the append is refused, and the runtime re-runs the command against the new log.
tephra commits the events, assigns each a position, and the response goes back with the positions and the tags that were written.
Projectors and effects see the new positions independently. Neither is in the request path. The command has already answered by the time either of them runs.
What each part is allowed to do
The restrictions are not defensive checks. They are the reason a rebuild and a replay reproduce what they did the first time.
| reach the network | read a clock | decrypt | append | fail | |
|---|---|---|---|---|---|
| command | no | yes | no | yes | invalid / reject |
| guard | no | no | no | no | invalid / reject |
| projector | no | no | no | no | no |
| effect arm | yes | yes | yes | no | fail |
| fold arm | no | no | no | no | no |
A projector has no failure channel at all, which is what makes rebuilding it total: there is no state in which a rebuild half-succeeded. An effect never appends directly; when it needs to record something, it calls a command, which goes through the same append condition as any other.
The moving parts
Behind the HTTP surface there are four kinds of worker.
One writer. Every append goes through it, which is what makes a position a total order rather than a per-stream one, and it is why a hekla deployment is one process against one data directory.
One sequential task per projector, each the only writer of its own SQLite database. Rows and the checkpoint commit in a single transaction, so a projector’s state and its position can never disagree, whatever happens to the process.
One reader thread per effect, owning that effect’s subscription to the log, feeding a
worker pool shared across every effect. Work is partitioned into lanes by the arm’s @key.
One lane processes in log order; different lanes never wait on each other.
The HTTP server, which owns no state of its own. It reads the same runtime the console
and /metrics read.
Where durability lives
| Written to | Holds | Rebuildable |
|---|---|---|
data/events/ |
the event log, via tephra | no, this is the source of truth |
data/projectors/{Name}.db |
one read model and its checkpoint | yes, from position 0 |
data/hekla.db |
effect journals, subject keys, deployed declarations | no |
data/hekla.lock.db |
the exclusive one-process lock | n/a |
Two of those are irreplaceable. A read model is a cache with a checkpoint attached, and hekla will rebuild one from the log whenever its definition changes.
Why effects need a journal
An effect reaches the world, so it cannot simply be re-run from the start after a crash. Every impure call it makes looks itself up in a journal first: if the call is recorded, the recorded result comes back instead of the call being made again.
The key is the call itself, hashed, plus an ordinal, rather than a position in a sequence. That is what lets you edit an arm, reorder its statements, and still have a half-finished invocation resume correctly rather than replaying someone’s payment.
Arguments are hashed and never stored. A journal that kept them would outlive the erasure it belonged to.
Where to go next
- Getting started builds a project and calls it.
- Commands covers folds, stages and the append condition properly.
- Why DCB is the argument for the boundary model, if the append condition above is the part you want convincing on.
- Effects in production is what to read before you run one.