Spindel supports O(1) copy-on-write forking of execution contexts. Forks share state with their parent via structural sharing and store mutations locally, enabling isolated execution branches, speculative computation, and checkpointing.
(require '[org.replikativ.spindel.engine.context :as ctx]
'[org.replikativ.spindel.engine.core :as ec]
'[org.replikativ.spindel.signal :as sig])
(def ctx-main (ctx/create-execution-context))
;; Create signals in the main context
(binding [ec/*execution-context* ctx-main]
(def counter (sig/signal 0))
(swap! counter inc)) ;; counter = 1
;; Fork — O(1), creates an overlay
(def ctx-fork (ctx/fork-context ctx-main))
(ctx/fork-context parent-ctx
:state-updates {...} ;; initial overlay state
:bindings {:key "val"} ;; fork-local configuration (merged with parent)
:metadata {:label "my-fork"}
:process-id 42) ;; override auto-assigned process ID
A fork is fully isolated from parent for its own writes (fork→parent never leaks) and parent-following on shared paths (parent's writes are visible in the fork until the fork shadows that path). Overlay forks are deliberately not symmetric snapshots — that asymmetry is what makes them efficient for speculative-with-rebase / Elle-style branching.
;; Mutate in fork
(binding [ec/*execution-context* ctx-fork]
(swap! counter inc) ;; fork: counter = 2
(swap! counter inc) ;; fork: counter = 3
@counter) ;; => 3
;; Parent unchanged by fork's writes
(binding [ec/*execution-context* ctx-main]
@counter) ;; => 1
| Operation | Parent sees? | Fork sees? |
|---|---|---|
| Fork reads parent state | N/A | Yes (via overlay fall-through) |
| Fork mutates state | No — always isolated to fork's overlay | Yes (in overlay) |
| Parent mutates state on a shared path | Yes | Yes, until fork shadows that path — overlay-fork is parent-following by design |
| Parent mutates state on a fork-local path | Yes | No (fork has its own value or nil) |
| Fork creates new state | No | Yes (in overlay) |
Fork-local paths (full isolation, no fall-through from parent): :continuations, :engine/pending, :engine/draining?, :engine/delayed-spins, :engine/timer-handles, plus any path added via the OverlayBackend's local-paths set.
Shared paths (overlay fall-through, parent-following on reads): everything else, including :nodes, :subscriptions, :spin-tracking, :atoms, and :engine/cancelled-tokens.
If you need fully-isolated semantics on a shared path — a fork that does not track parent's later writes — use snapshot-context instead of fork-context. A snapshot returns a new root with ImmutableBackend, no parent, no fall-through. The cancellation interaction with :engine/cancelled-tokens (a shared path) is discussed in the source comments at src/org/replikativ/spindel/effects/await.cljc (search for cancellable-external-pair).
Forked contexts use an overlay backend that:
The fork itself is O(1) — only the overlay structure is created. State is copied lazily on first write to each key.
A fork is fully isolated for state, but a few resources are shared with the parent context for performance:
If an external resource has Yggdrasil fork/merge semantics, register it with
org.replikativ.spindel.yggdrasil/register! and fork through that namespace's
fork!. The returned ForkHandle wraps the execution-context fork and is the
single settlement authority for all selected registered systems.
(require '[org.replikativ.spindel.yggdrasil :as ygg])
(def world
(ygg/fork! {:systems #{:kb :repo}
:purpose :proposal
:owner :run-42}))
;; Work in the isolated reactive context and its Yggdrasil branches.
(binding [ec/*execution-context* (:child-ctx world)]
(perform-work!))
;; Settlement is mutually exclusive and exactly once.
(ygg/merge-fork! world) ; or discard-fork! / transfer-fork!
The portable value from fork-descriptor contains world identity, ancestry,
owner, policy, and per-system branch/basis data. It never contains the live
contexts or affine token.
After the world is physically quiescent, trusted host code may split one open whole-world settlement capability into disjoint per-system capabilities:
(def parts
(ygg/partition-fork!
world
[{:systems #{:repo} :owner :code-review}
{:systems #{:kb} :owner :knowledge-review}]))
Partitioning consumes world. Every descriptor-named system must occur exactly
once; overlaps and omissions are rejected. All returned handles retain the same
:fork/id because they govern one execution world, while each has a distinct
:fork/settlement-id. fork-diff, fork-conflicts, merge-fork!,
discard-fork!, and merge-fork-from-parent! operate only on that handle's
scope.
This divides settlement authority, not arbitrary runtime access: the handles
share the child execution context and are host capabilities, not sandbox values.
The system registry must also still match the creation descriptor; worlds with
uncheckpointed child-only systems are refused until those systems receive a
portable basis entry. A successful partition freezes registration in that child
world (including recursive partitions), closing the race between exhaustive
scope validation and later register!/unregister! calls. Settlement also fails
closed if a scoped system is removed or replaced in the parent; it is never
silently reclassified as child-only.
merge-fork-from-parent! temporarily leases the same affine authority as
:advancing. Transfer, partition, merge, and discard are rejected until the
advance completes. A read-only preflight failure reopens the handle; failure
after durable mutation begins leaves it :incomplete for explicit recovery.
Spin results live on each SpinNode in the unified :nodes map. A fork:
:result for spin X, the fork sees the same
result on first read — no re-execution.The parent's cache is never observed to be stale by the fork: either the fork reads the parent's value (because the dependency is unchanged in the fork too), or the fork has its own copy (because the dependency moved in the fork).
There is one subtlety when you fork while a signal change is still
pending (swapped but not yet drained). fork-context resets
:engine/pending to [], so the fork drops that eager recompute trigger.
This is safe for the reactive programming model: a spin in the fork
that observes a stale-clean derived spin via await/track recomputes it
against the new value — the generation guard refuses the awaited spin's
stale fast-path. Spin recompute is driven by the per-spin :dirty flag
plus that generation guard, not by the pending queue, so quiescence
before forking is an optimization, not a correctness requirement.
The exception is the discouraged path: a raw @deref of a stale-clean
spin returns the cached value without recomputing (the
don't-@deref-spins-outside-the-REPL rule) — and
across a fork that staleness is permanent (the parent eventually drains
and re-dirties; the fork dropped its copy of the pending event and won't).
Observe forked spins reactively (await/track), never with @.
The same O(1) copy-on-write idea is lifted across peers over yggdrasil
branches by fork-remote! / merge-fork-remote!: branch off a followed
remote checkout into an isolated single-writer branch, write, then merge
back on demand via the :fork-of merge-base. See
Distributed → Workspace Reflection & Cross-System Forking.
Snapshots create an immutable copy of a context's state. Unlike forks, snapshots are completely independent (no parent reference).
;; Create immutable snapshot
(def snapshot (ctx/snapshot-context ctx-main))
(ctx/snapshot-context ctx-main
:clean-in-flight? true ;; mark in-flight spins as dirty (default: true)
:include-pending? true) ;; include pending events (default: true)
Convert a snapshot back to a live context:
(def ctx-restored (ctx/restore-snapshot snapshot))
(binding [ec/*execution-context* ctx-restored]
@counter) ;; => 1 (same as when snapshot was taken)
(ctx/restore-snapshot snapshot
:drain-events? true) ;; process pending events after restore (default: true)
Contexts can be serialized to EDN for checkpointing, distribution, or persistence:
;; Serialize to EDN string
(def edn-str (ctx/serialize-context ctx-main))
;; Deserialize back to a live context
(def ctx-deserialized
(-> (ctx/deserialize-context edn-str (ctx/get-executor ctx-main))
ctx/restore-snapshot))
serialize-context creates a snapshot internally if the context isn't already a snapshot.
After deserializing a context, continuations are lost (they're not serializable). The rebuild mechanism re-executes the model function to restore continuations:
;; Macro version: prepare, execute, finalize
(def rebuilt-ctx
(ctx/with-rebuild-context snapshot {}
@(my-model-fn))) ;; re-executes to rebuild continuations
;; Manual version for more control
(let [prep-ctx (ctx/prepare-rebuild-context snapshot
:initial-chain-head some-hash)
_ (binding [ec/*execution-context* prep-ctx]
@(my-model-fn))
final (ctx/finalize-rebuild-context prep-ctx)]
final)
In rebuild mode, spin bodies execute but return cached values. This rebuilds the dependency graph and continuations without changing computed results.
Try different approaches, keep the best:
(defn try-strategies [ctx strategies]
(let [forks (mapv (fn [s]
{:strategy s
:ctx (ctx/fork-context ctx)})
strategies)]
;; Run each strategy in its fork
(doseq [{:keys [strategy ctx]} forks]
(binding [ec/*execution-context* ctx]
(apply-strategy strategy)))
;; Pick the best result
(let [best (select-best forks)]
(:ctx best))))
Run multiple agents sharing common state:
(defn create-agents [base-ctx n]
(mapv (fn [i]
(ctx/fork-context base-ctx
:bindings {:agent-id i}
:metadata {:label (str "agent-" i)}))
(range n)))
;; Each agent operates independently
;; All share parent's base state (signals, cached spins)
;; Mutations isolated to each agent's overlay
Save state and restore on failure:
;; Save checkpoint
(def checkpoint (ctx/snapshot-context ctx-main))
;; Do risky work...
(try
(binding [ec/*execution-context* ctx-main]
(risky-operation!))
(catch Exception e
;; Rollback by restoring checkpoint
(def ctx-main (ctx/restore-snapshot checkpoint))))
Use simulation contexts for reproducible tests:
(def test-ctx (ctx/create-simulation-context))
;; Virtual time mode — time advances explicitly
(binding [ec/*execution-context* test-ctx]
;; ... set up spins that use sleep/timeout ...
;; Advance time to trigger scheduled events
(ctx/advance-time! test-ctx 1000) ;; advance 1 second
;; Check results deterministically
(assert (= 42 @my-spin)))
Track fork lineage for distributed systems testing:
(ctx/get-process-id ctx-fork) ;; => 1
(ctx/get-parent-process-id ctx-fork) ;; => 0
(ctx/get-fork-lineage ctx-fork) ;; => [0 1]
(ctx/root-context? ctx-main) ;; => true
(ctx/root-context? ctx-fork) ;; => false
(ctx/fork-depth ctx-fork) ;; => 1
;; Create root context
(def ctx (ctx/create-execution-context))
;; Fork (shares drain thread with parent)
(def fork (ctx/fork-context ctx))
;; Stop root context (stops background drain thread)
(ctx/stop-context! ctx)
;; Safe no-op on forks — they share the parent's drain thread
;; Full shutdown (stops drain + closes executor)
(ctx/close-context! ctx)
;; Only use when certain no async work remains
Can 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 |