This is the mental model of spindel: what spins, signals, and effects are, how they compose, and how the engine carries a change through the graph. Read this before the API guides — every later doc assumes the vocabulary introduced here.
The whole engine rests on one idea: a spin is a body of checkpoints; a change jumps execution back to a checkpoint and runs forward from there. Everything below is that idea, unfolded.
Signal — a value in a box. You read it, you change it. When it changes, it tells its watchers. Nothing more.
(def counter (signal 0))
(swap! counter inc) ;; every spin tracking `counter` is marked dirty
A signal is atom-compatible (@, swap!, reset!), fork-safe
(its state lives in the execution context, isolated on fork), and
delta-tracking (collections are wrapped so the engine knows what
in them changed — see Incremental).
Spin — a piece of computation the engine can pause and resume.
You write a spin as ordinary code; the spin wrapper rewrites it behind
the scenes so the engine can freeze it part-way through and pick it up
later.
(def doubled
(spin
(let [{:keys [new]} (track counter)]
(* 2 new))))
A spin is cached (its result is stored and returned on later deref without re-running), reactive (it re-runs when a tracked dependency changes), lazy (re-execution waits until the result is needed), and stateless (it holds no internal state — all state lives in the execution context).
track — used inside a spin: (track some-signal). Two things in one
move:
When a spin runs, every track (and every await — §3) drops a
checkpoint: a saved bookmark — the rest of the body, packaged so the
engine can continue it later.
(The engine's source and the other docs call a checkpoint a continuation — the standard term for "the saved rest of a computation." This doc says checkpoint because it is the friendlier picture. Same thing.)
(spin
(let [a (track s-a)
b (track s-b)]
[a b]))
Running it the first time:
start ──▶ (track s-a) ──▶ (track s-b) ──▶ [a b] ──▶ done
• •
checkpoint 1 checkpoint 2
watches s-a watches s-b
Result: [0 0]. Two checkpoints now sit in the engine, each watching its
signal.
A signal changes → the engine finds the checkpoint(s) watching it and resumes the body from there:
s-b changes → resume from checkpoint 2 → re-run (track s-b) and
[a b] with the new b → [0 1]. (track s-a) is behind the
checkpoint — untouched.s-a changes → resume from checkpoint 1 → run forward → [10 1].A checkpoint left alone by an unrelated change stays valid — it keeps watching its signal.
track watches a signal. await watches another spin.
(await child) means: pause here until child produces a value, then
continue with that value. It drops a checkpoint just like track —
this one watches a child spin instead of a signal:
(spin
(let [x (await child-spin)]
(* x 2)))
parent: start ──▶ (await child-spin) ──▶ (* x 2) ──▶ done
•
checkpoint
watches child-spin
┊
child-spin: runs on its own ┄┄┄▶ produces 5
┊
▼
child done → checkpoint fires → parent continues, x = 5 → 10
The parent runs until (await child-spin), then pauses. The child
runs separately. When it produces a value, the await checkpoint fires
and the parent continues — x is the child's value.
track | await | |
|---|---|---|
| watches | a signal | a child spin |
| fires when | the signal changes | the child completes |
| you get | the value (+ what changed) | the child's value |
Does an await fire more than once? It depends on the child. A child
that completes once → the await fires once. But a child can be
reactive — it can re-complete later, because its own signals changed.
When it does, the await checkpoint fires again and the parent continues
again with the child's new value. So an await fires once per
completion of its child — usually once, more if the child is reactive.
That re-firing is what makes parallel reactive (§4).
Until now a spin's checkpoints sat in a line: track, then track, then await — one after another, the body paused at one of them.
parallel is the first thing with many live checkpoints at the same
time. (parallel [c1 c2 c3]) runs three spins, waits for all of
them, and gives back [r1 r2 r3]. It drops one await checkpoint per
child, all at once:
parallel: • watches c1 ┄┄▶ c1 runs ┄▶ done ┐
• watches c2 ┄┄▶ c2 runs ┄▶ done ┤
• watches c3 ┄┄▶ c3 runs ┄▶ done ┘
│
all fired ────────────────────▶ parallel completes [r1 r2 r3]
Three checkpoints, live simultaneously. As each child finishes, its
checkpoint fires; when the last fires, parallel has every result and
completes.
And because await checkpoints re-fire when a reactive child
re-completes (§3): if c2 later re-completes with a new value,
parallel's c2 checkpoint fires again and parallel re-completes with
the updated vector. parallel is reactive.
This is why a spin's await checkpoints are a set, not a stack —
parallel holds three at once, none "on top of" another.
race is parallel's sibling: same many-checkpoints shape, but it
completes as soon as the first child finishes, and cancels the rest.
"A signal changes → checkpoints resume" — the how is deliberately not immediate. The engine has one event queue, worked one event at a time.
change s-b
│
▼
┌──────────── event queue ─────────────┐
│ [s-b changed] │
└───────────────────────────────────────┘
│ drain takes one ◀──────────────┐
▼ │ finishing a spin
handle it: resume checkpoints, │ drops a new event
re-run spins │
└──▶ spin completed ──▶ [X done] ───┘
... repeat until the queue is empty ...
Why a queue, instead of just calling things directly?
Use batch to group several signal changes into a single propagation:
(batch
(swap! signal-a inc)
(swap! signal-b inc))
;; one propagation pass, not two
You have now seen three things that drop a checkpoint: track, await,
and (next section) yield. They are not three special cases. They are
three effects, and "effect" is the real general concept:
An effect is a call the
spinwrapper recognizes and turns into a checkpoint.
When the spin macro rewrites a body, it scans for effect calls. At each
one it splits the body — everything after the call becomes the
checkpoint — and routes the call to that effect's handler, the code
that decides what to watch and when to resume:
(spin …code… (track s) …rest…)
│
macro splits the body here
│
┌────────────┴─────────────┐
the track call …rest… becomes
→ track's handler the checkpoint
(watch s, resume
on change)
So the engine does not hard-code "track" and "await." It keeps a small
registry of effects, and the macro builds a body's checkpoints from
whatever is registered. That is why you can add your own effect
(custom-effects.md): register a symbol and a handler, and
(your-effect …) becomes a checkpoint in spin bodies just like the
built-ins.
Three words name three real stages of one effect — keep them straight:
track), at the source level;track and await both consume — they wait for something. yield
produces.
(yield v) drops a checkpoint that hands out the value v, then pauses
— until someone asks for the next one. You use it inside gen-aseq,
which builds a lazy async sequence:
(gen-aseq
(yield 1)
(yield 2)
(yield 3))
A consumer pulls the sequence one step at a time with anext: each
anext runs the body to the next yield, hands back
[value rest-of-sequence], and pauses again. Nothing runs until pulled.
That exposes a second axis the model needs — push vs pull:
PUSH — a signal PULL — an async sequence
change it → engine notifies anext → consumer asks for next
track consumes it, body re-runs yield produces into it
the engine drives the consumer drives
A signal is a stream the engine pushes at you; an async sequence is a stream you pull. Same "stream of values" idea — opposite direction of control.
A signal change re-runs spins. If every re-run redid all the work from scratch, a deep reactive graph would be hopeless. It is not, because of caching.
Every spin's engine record holds a cached result and a clean / dirty flag:
A signal change marks only the spins downstream of it dirty; everything
else stays clean. When a spin awaits a child that is clean, it gets
the cached result immediately — the child does not re-run. Only
dirty spins actually re-run.
There is a second, finer check — the capture gate. When a spin is defined inside another spin's body and the parent re-runs, that inner spin is re-encountered. The engine compares the values the inner spin captured from its surroundings: if they are unchanged, the inner spin is left clean and serves its cache; only if a captured value actually moved does it re-run. So re-running a parent does not blindly re-run all its children — each re-runs only if its own inputs moved.
Together: a change re-runs the minimum — the dirty sub-graph — and everything else answers from cache.
Re-runs are also lazy. A spin marked dirty does not re-run eagerly:
(swap! counter inc) ;; `doubled` marked dirty, but NOT re-executed
@doubled ;; NOW it re-executes
This avoids work when an intermediate computation is dirty but never read.
Not every spin is a cacheable calculation. There are two kinds, and the engine labels each one:
A computation spin — the normal kind, written with the spin
macro. It is a pure-ish calculation: the same inputs give the same
result. It has a stable identity, it is cacheable, and it can be
replayed — re-run from scratch and land in the same place. Replay is
what makes fork / restore possible.
A resource spin — sleep, parallel, race, a deferred, a
mailbox. Its body is an effect on the outside world: it arms a timer,
starts coordination, allocates a one-shot slot. It is not a pure
calculation and not replayable — replaying it would arm the timer
twice. Each one is a fresh, single-use thing.
The engine treats them differently exactly where it must: a computation spin's cache is reused and the spin replayed; a resource spin is always run fresh and never replayed. Labelling the two kinds explicitly is what lets the engine apply the right rule everywhere instead of guessing.
A spin finishes in one of three ways: with a value, with an error, or cancelled.
Errors travel like values, in reverse. A spin's result is tagged
:ok or :error. When a parent awaits a child that finished
:error, the error flows into the parent and short-circuits it — the
rest of the parent's body is skipped and the parent finishes :error
too. An error propagates up the await chain just as a value would, only
down the failure track.
Cancellation is cooperative. cancel-spin! marks a spin (and
everything depending on it) — it does not kill anything mid-flight. The
consuming effects — track and await — check "am I cancelled?" the
instant they run, so a cancelled spin stops the next time it reaches one.
A spin in a tight loop with no track or await in it will not notice
until the loop ends — that "cooperative, not preemptive" contract falls
straight out of checkpoints are the control points. race and
parallel use this internally — race cancels the losers, parallel
cancels the siblings when one fails.
Cancellation is really a flavour of error — a cancelled spin finishes with a cancellation-typed error — so the same up-the-chain propagation applies.
Everything above — signal state, the spin cache, the dependency graph, the event queue, suspended checkpoints — lives inside one value: the execution context. Every spindel operation runs against a bound context:
(def ctx (create-execution-context))
(binding [ec/*execution-context* ctx]
;; all spindel operations here
)
The context is bound dynamically, not captured, for three reasons:
Because the whole runtime is one value, you can fork-context it in
O(1) (copy-on-write — see Forking), snapshot-context it,
serialize it, and restore it.
Each spin's cache is keyed by an address that is deterministic —
derived from the spin's source location via a hash chain, not from object
identity. The same (spin …) form in the same file produces the same
address every time it expands: across re-runs of its parent, across
forks, across serialize/deserialize round-trips. That is what lets a
forked context replay the same address sequence and lets cached results
land on the same nodes. (Sequential spins at the same source location
get distinct addresses — the hash chain advances per call.)
track (watch a signal),
await (watch a child spin), yield (emit into a pulled sequence), or
any effect you register yourself.Two axes organize all of it:
track is comonadic (a value carried with its history);
await is monadic (one step sequenced after another);When a piece of the engine confuses you, come back to the one sentence: a spin is a body of checkpoints; a change resumes a checkpoint and runs forward.
await, track, yield in detailCan you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |