SkillAgentSearch skills...

Quiescent

A Clojure library for composable async tasks with automatic parallelization, structured concurrency, and parent-child and chain cancellation

Install / Use

/learn @multiplyco/Quiescent
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

Quiescent

Clojars Project cljdoc

A Clojure/ClojureScript library for composable async tasks with automatic parallelization, structured concurrency, and parent-child and chain cancellation.

In particular, Quiescent has been designed with these constraints in mind:

  • A task cannot exceed the lifetime of its parent. Though there's an escape hatch for things that need to run to completion.
  • A task cannot cross the "border" of another task. Where a task would return another task, a plain value is returned instead.
  • If one task fails within a scope, all siblings should be torn down as quickly as possible while teardown of statefully allocated resources should still complete without interruption.

Tasks go through 7 phases during their lifecycle: pending, running, grounding, transforming, writing, settling and quiescent. This library is named after the last phase in the lifecycle, which is when all subtasks have settled and the task tree has come to rest.

Table of contents

Requirements

Clojure

Quiescent builds heavily on virtual threads and other features present only in recent versions of the JDK.

  • JDK 21+ (Virtual threads)
  • JDK 25 recommended (Better virtual threads; ScopedValue support)
  • Clojure 1.12+

ClojureScript

  • ClojureScript 1.11+
  • Any modern JavaScript runtime (browser or Node.js)

Installation

;; deps.edn
co.multiply/quiescent {:mvn/version "0.2.5"}

Quiescent depends on three other libraries: Machine Latch, Scoped, and Pathling, which will be transitively available when using this library. Machine Latch in turn depends on Scoped. There are no further dependencies.

Core concepts

Tasks

A task represents an async computation. Create one that executes on a virtual thread with task:

(require '[co.multiply.quiescent :as q])

;; Quiescent strongly discourages parking on platform threads.
;; Let's turn it off for this REPL session, or we won't be able to dereference
;; the tasks that we create.
(q/throw-on-platform-park! false)

(def my-task
  (q/task
    (Thread/sleep 100)
    "done"))

@my-task
;; => "done"
;; (blocks until complete)

task spawns a task that runs on a virtual thread, while cpu-task runs directly on a platform thread. q runs the body synchronously on the calling thread. Other than that, they are all semantically equivalent.

Promises

Promises are tasks you resolve externally, which is useful for bridging callback APIs:

(def p (q/promise))

;; Later, from a callback:
(deliver p "result")
;; or
(q/fail p (ex-info "oops" {}))

@p
;; => "result" (or throws ExceptionInfo if the `fail` was used)

Promises cannot be compelled, but otherwise implement the full task API. You can chain with then, finally, and so on, cancel them, race them against other tasks, or do anything else that tasks generally permit.

Structured Concurrency

Tasks form a tree. When you create a task inside another task, the inner task becomes a child of the outer:

(def parent
  (q/task
    (let [child (q/task (Thread/sleep 100) :done)]
      @child)))

A child task cannot outlive its parent. When a parent settles (completes, fails, or is cancelled), all children that haven't yet settled are cancelled automatically.

(def parent
  (q/task
    (q/task (Thread/sleep 5000) (println "I'll never print"))
    :done)) ;; Parent returns immediately

@parent
;; => :done
;; The inner task is cancelled—it never prints.

The inner task here is an orphan: it was started but not awaited or returned. When the parent settles with :done, the orphan is torn down.

This applies recursively. If a grandparent settles, all descendants—children, grandchildren, and so on—are cancelled.

Structured concurrency prevents resource leaks and runaway tasks. You don't need to manually track and cancel background work. Settling the parent cleans up the entire subtree.

It also means exceptions propagate predictably. If a child fails and the parent doesn't handle it, the parent fails too, which cancels all siblings.

The compel escape hatch

Sometimes a task genuinely needs to outlive its parent (cleanup work, flushing buffers, releasing connections). Use compel to protect it:

(q/task
  (q/compel (flush-to-disk))
  :done)

The compeled task won't be cancelled when the parent settles. It runs to completion (or failure) independently. Cascade cancellation from ancestors stops at the moat created by compel.

Grounding

When a task returns a data structure containing nested tasks, those tasks aren't orphans but part of the result. Their values are resolved in parallel and inlined into the final structure in a process called "grounding."

(def result
  (q/task
    {:user   (fetch-user 123)
     :orders (fetch-orders 123)})) ;; <- `fetch-…` returns tasks.

@result
;; => {:user   {...}
;;     :orders [...]}

This is transitive. If a returned task itself returns nested tasks, or they chain off neighbouring tasks, they are all recursively grounded until a plain value is all that remains.

If any task fails during grounding, all siblings are cancelled immediately.

Grounding is not a blocking operation. When grounding begins, the parent thread returns to the pool. The nested tasks coordinate among themselves to assemble the final value. No control thread waits for them to complete.

This means platform threads are safe to use:

(def result
  (q/cpu-task ;; <- Platform thread
    {:user   (q/task (fetch-user 123))
     :orders (q/task (fetch-orders 123))}))

The platform thread constructs the map and immediately returns to the pool. The nested virtual-thread tasks complete on their own and finalize the result. The platform thread never parks.

Handling outcomes and chaining transformations

Quiescent provides several handlers for reacting to task outcomes:

| Handler | Primary purpose | Runs on success | Runs on error | Runs on cancellation | |-----------|-----------------|-----------------|---------------|----------------------| | then | Transformation | ✓ | | | | catch | Transformation | | ✓ | | | handle | Transformation | ✓ | ✓ | | | ok | Side-effect | ✓ | | | | err | Side-effect | | ✓ | | | done | Side-effect | ✓ | ✓ | | | finally | Teardown | ✓ | ✓ | ✓ |

  • Transformation: Transform the outcome. The handler's return value becomes the new result.
  • Side-effect: Observe the outcome. The original value/error passes through unchanged.
  • Teardown: Release stateful resources, or other effects that should happen regardless of how the task terminates.

Cancellation is a control signal, not an error. When a task is cancelled, only finally runs, the rest are torn down without executing their workload or, if they were in the process of executing their workload, their backing threads will be interrupted.

Transformations and error handling

Chain transformations with then:

(-> (q/task (fetch-user 123))
  (q/then :name))

then accepts multiple arguments for combining tasks:

(q/then task-a task-b task-c
  (fn [a b c]
    (+ a b c)))

You're expected to provide a function that accepts as many args as there are tasks. Of course, in this case + could be provided directly.

;; Arguably a better version of the above
(q/then task-a task-b task-c +)

qmerge merges maps whose values may be (or contain) tasks:

(q/qmerge
  {:user (fetch-user id)}
  {:orders (fetch-orders id)})
;; => {:user {...} :orders [...]}

This is slightly more ergonomic and efficient than the semantically equivalent:

(q/then {:user (fetch-user id)} {:orders (fetch-orders id)} merge)

Use catch to recover from errors:

(-> (q/task (fetch-user 123))
  (q/then :name)
  (q/catch (fn [e] "Unknown")))

catch supports multiple exception types with exclusive-or semantics, like try/catch:

(-> (q/task (risky-operation))
  (q/catch
    IllegalArgumentException (fn [e] :bad-arg)
    IOException (fn [e] :io-error)
    Throwable (f
View on GitHub
GitHub Stars32
CategoryDevelopment
Updated2d ago
Forks0

Languages

Clojure

Security Score

90/100

Audited on Mar 29, 2026

No findings