This guide explains how spindel's pieces fit together. Read this to build a mental model before diving into specific APIs.
A spin is a cached reactive computation. It runs a function body, caches the result, and automatically re-executes when dependencies change.
(def doubled
(spin
(let [{:keys [new]} (track counter)]
(* 2 new))))
Key properties:
A signal is a mutable reactive value. It's like an atom that notifies dependent spins when it changes.
(def counter (signal 0))
(swap! counter inc) ;; All spins tracking `counter` are marked dirty
Key properties:
@, swap!, reset!Effects are the mechanism through which spins interact with signals and other spins. There are three built-in effects:
| Effect | Purpose | Inside spin |
|---|---|---|
await | Depend on another spin's result | (await child-spin) |
track | Observe a signal reactively | (track my-signal) |
yield | Emit a value in an async sequence | (yield value) |
Effects are CPS breakpoints — the spin macro transforms them into continuation-passing style so execution can suspend and resume.
The execution context is the runtime environment that manages all state, dependency tracking, and scheduling. Every spindel operation requires a bound context:
(def ctx (create-execution-context))
(binding [ec/*execution-context* ctx]
;; All spindel operations here
)
The context is bound dynamically (not captured) because:
The execution context contains:
Spindel automatically builds a dependency graph as spins execute:
(def a (signal 1))
(def b (signal 2))
(def sum (spin (+ (:new (track a)) (:new (track b)))))
(def prod (spin (* (await sum) 10)))
This creates:
Signal a ──┐
├──→ Spin sum ──→ Spin prod
Signal b ──┘
*spin-id*track called — registers signal as dependency of current spinawait called — registers child spin as dependency of current spinDependencies are re-tracked on every execution. If a spin conditionally tracks different signals, the graph updates accordingly.
When a signal changes, spindel ensures consistent updates using topological ordering and batching.
Without protection, diamond dependencies cause glitches:
Signal x ──→ Spin A ──┐
│ ├──→ Spin C (sees inconsistent A and B)
└────→ Spin B ──┘
If C observes A's new value but B's old value, it computes with inconsistent inputs.
:spin-completion events they produce flow through the single :engine/pending FIFO and are drained naturally. There is no separate completion queue or per-batch barrier — the unified subscription model collapsed those into one drain. The drain machinery and CAS lock are documented at src/org/replikativ/spindel/engine/impl/simple.cljc (search for drain-events!).Signal x changes
│
▼
Topo order + descendant filter + ancestor escalation → ordered observer set
│
▼
Resume each observer's track-cont (parallel on JVM for >1)
│
▼
Completions enqueue on :engine/pending, drained FIFO (no glitches)
Use batch to group signal changes into a single propagation:
(batch
(swap! signal-a inc)
(swap! signal-b inc))
;; One propagation pass, not two
The spin macro transforms its body using partial CPS (continuation-passing style). This is what enables non-blocking suspension at await and track calls.
;; You write:
(spin
(let [x (await child)]
(* x 2)))
;; The macro produces (conceptually):
(make-spin
(fn [resolve reject]
(await-handler child spin-id loc
(fn [x] ;; resolve continuation
(resolve (* x 2)))
reject)))
The CPS transformation:
await, track, yield)try/catch, loop/recur, let, if, do, binding across breakpointsIf await blocked the thread (like @), you'd need one thread per suspended spin. With CPS:
Every spin result is cached by address. The address is deterministic — generated from a hash chain based on source location.
(spin ...) ;; Address: hash(parent-address, source-location)
The deterministic addressing means:
A spin's cache is invalidated (marked dirty) when:
On next deref, the spin re-executes and caches the new result.
Spins don't re-execute eagerly when marked dirty. They wait until their result is needed:
(swap! counter inc) ;; `doubled` marked dirty, but NOT re-executed
@doubled ;; NOW it re-executes
This avoids unnecessary work when intermediate computations are dirty but never read.
await, track, and yield work 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 |