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:
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.
Quiescent builds heavily on virtual threads and other features present only in recent versions of the JDK.
ScopedValue support);; deps.edn
co.multiply/quiescent {:mvn/version "0.2.4"}
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.
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 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.
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.
compel escape hatchSometimes 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.
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.
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 | ✓ | ✓ | ✓ |
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.
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 (fn [e] :other)))
Only the first matching handler runs. If that handler throws, the exception propagates. It's not caught by later handlers in the same expression.
For nesting semantics (where one handler's exception can be caught by another), chain separate catch calls.
Use handle when you need to handle both success and failure:
(-> (q/task (fetch-data))
(q/handle
(fn [value error]
(if error
{:status :error :message (ex-message error)}
{:status :ok :data value}))))
It's idiomatic to check for the presence or absence of error, where nil reliably indicates the absence of an error.
Checking nil on value is unreliable, since that can be a valid result.
ok, err, done, and finally produce side effects and can't alter the outcome:
(-> (q/task (fetch-user 123))
(q/ok (fn [user] (log "Fetched user" {:id (:id user)})))
(q/err (fn [e] (log "Failed to fetch user" {:error e})))
(q/done (fn [_v _e] (log "`fetch-user` completed.")))
(q/finally (fn [_v _e _c] (log "`fetch-user` terminated."))))
Use done to observe success or error (but not cancellation):
(-> (q/task (fetch-user 123))
(q/done
(fn [_v e]
(if e
(metrics/record-failure)
(metrics/record-success)))))
Use finally to release resources that must be cleaned up regardless of outcome. It receives a third
argument indicating whether the task was cancelled:
(let [resource (acquire-resource)]
(-> (q/task (use-resource resource))
(q/finally
(fn [_v e c]
(release-resource resource)
(cond
c (println "Cancelled!")
e (println "Error!")
:else (println "Success!"))))))
Use monitor to trigger a side effect when a task doesn't finish within the specified timeframe:
(-> (q/task (Thread/sleep 5000) :ok)
(q/monitor 500 #(println "This is taking a long time.")))
Use time to measure how long a task takes:
(-> (fetch-user id)
(q/time (fn [value error cancelled ms]
(log/info "fetch-user took" ms "ms"))))
The side-effect function receives four arguments: value (or nil), exception (or nil), cancelled flag, and elapsed
milliseconds. By default, timing starts when time is called. To include task construction time, capture the start
time beforehand and pass it as the second argument.
qdo awaits all tasks but returns only the last result. Useful for awaiting side effects:
(q/qdo
(log-event)
(perform-work))
If perform-work were fast enough, and log-event slow enough, log-event might be cancelled without completing.
Where compel would allow log-event to complete independently (and resist cancellation), qdo delays the return of
perform-work until both are done (and won't resist cancellation).
Tasks can be cancelled explicitly with cancel:
(def my-task (q/task (slow-operation)))
(q/cancel my-task)
;; Cancels my-task and all its descendants.
cancel returns a task that resolves to a boolean: true if cancellation succeeded, false if the task had already
settled. You can ignore the result for fire-and-forget cancellation, or await it to confirm:
(-> my-task
(q/cancel)
(q/ok #(if % (log "Task cancelled.") (log "Task already finished."))))
The returned task settles once the cancelled subtree is "quiescent": all tasks, subtasks, and finally handlers have
completed. Note that quiescence doesn't guarantee that all threads have been released; threads are responsible for
responding to interruption and may delay doing so.
A cancelled task contains a CancellationException and will throw if dereferenced.
compel and direct cancellationAs stated earlier, compel ignores cascade cancellation and allows a task to outlive the scope of its parent.
(def parent
(q/task
(let [subtask (q/sleep 100 "I'm alive!")]
(q/race (q/compel subtask) (q/sleep 50))
@subtask)))
Here race cannot tear down subtask because it sees the compel moat. But cancelling parent directly would still
cancel subtask. The moat that protects subtask only exists within the race, not in the outer let.
If you hold a reference to a compeled task, you can still cancel it explicitly.
Use await to wait until a task reaches quiescence:
@(q/await task) ; Block until quiescent, returns true
@(q/await task 1000) ; With timeout, returns false if expired
await returns a task containing a boolean: true when settled, false if the timeout expired. Unlike deref, it
never throws on failure or cancellation. This is useful when you need to know a task has finished while being unaffected
by the outcome.
Dereferencing cannot be done in ClojureScript. You can chain off the task returned by await on both platforms.
Use cancelled? to check if a task was cancelled:
(q/cancelled? some-task) ; => true or false
Inside a finally handler, check the third argument:
(q/finally task
(fn [_v _e c]
(release-resource)
(when c
(log/info "Task was cancelled"))))
To limit how many tasks run concurrently, use a gate:
(let [g (q/gate 20)]
@(qfor [n (range 1000)]
(q/gate-task g
(q/sleep (+ 50 (rand-int 100))
(fn simulate-delay []
(println "Task" n "done.")
n)))))
;; Prints a lot of "Task <n> done."
;; => [0 1 2 … 999]
qfor is nothing special; it expands to (q (mapv …)).
Gates participate in structured concurrency: cancelling a gate cancels all tasks created through it. Any task created via a gate that hasn't already been cancelled, or run to completion, is immediately cancelled in turn.
Gates are also reentrant: nested gate-task calls on the same gate don't consume additional permits:
(let [g (q/gate 1)]
;; This works despite only 1 permit, because the inner gate-task
;; detects that it's already inside the same gate, and so runs immediately.
@(q/gate-task g
(q/gate-task g :inner-result)))
;; => :inner-result
To run the gated code on a platform thread, just return a cpu-task directly or inside a data structure.
(let [g (q/gate 20)]
@(qfor [n (range 1000)]
(q/gate-task g
{:cpu-bound (q/cpu-task …)
:io-bound (q/task …)})))
Gates are tasks, and can be treated as such:
(let [g (q/gate 20)]
@(qfor [n (range 1000)]
(-> (q/gate-task g
{:cpu-bound (q/cpu-task …)
:io-bound (q/task …)})
(q/then (fn [value] …)))))
Any handler chained onto the gate-task runs after its permit has been returned to the gate. It does not extend the
lifetime of the gate-task itself.
Gates can be used mid-chain:
(let [g (q/gate 20)]
@(qfor [n (range 1000)]
(-> (q/task (* n 2)) ;; Runs with parallelism greater than 20
(q/then
(fn stringify
[n]
(q/gate-task g ;; Runs with parallelism of 20
(q/sleep (+ 10 (rand-int 100))
#(str "n-" n)))))
(q/then ;; Runs with parallelism greater than 20
(fn keywordify
[s]
(println "Done:" s)
(keyword s))))))
Gates work with both Clojure and ClojureScript.
On Clojure, you can also use semaphore with with-semaphore for more traditional permit-based control:
(let [sem (q/semaphore 4)]
@(qfor [work-unit (get-work-units)]
(q/task
(q/with-semaphore sem
@(q/cpu-task (cpu-bound-calculation work-unit))))))
Note that you can't acquire a semaphore permit inside a platform thread by default — this would block the thread. Acquire the permit within a virtual thread, then start the platform thread.
retry provides configurable retry logic with exponential backoff:
(q/retry
(fn [retrying?]
(fetch-flaky-service))
{:retries 5
:backoff-ms 1000
:backoff-factor 2
:retry-callback (fn [e retries backoff]
(log/warn "Retry in" backoff "ms," retries "left"))})
The function receives a boolean indicating whether this is a retry attempt (false for the first call, true for
subsequent retries). Options:
:retries - Maximum retry attempts (default: 3):backoff-ms - Initial backoff duration in ms (default: 2000):backoff-factor - Multiplier for backoff after each retry (default: 2):retry-callback - Called before each retry with [exception retries-left backoff-ms]:validate - Function [value exception] to determine success; throw to trigger retryThe :validate option is useful when a "successful" response should still trigger a retry:
(q/retry
(fn [_] (http/get url))
{:validate (fn [response _]
(when (= 503 (:status response))
(throw (ex-info "Service unavailable" {})))
response)})
Initially, Quiescent automatically propagated thread bindings to all tasks, but this turned out to be too expensive. A simple no-op task had ~20µs in pure overhead, and most of it (95% or more) was the time it took to set thread bindings.
As of JDK 25, there's an alternative to ThreadLocal: ScopedValue. It fits quite well together with Clojure's
immutable data structures.
Tasks support automatic scoped value propagation using the Scoped
library, and also uses it for some internal bookkeeping. The concession is that you have to use the ask verb to
extract the currently bound value.
(require '[co.multiply.scoped :refer [ask scoping]])
(def ^:dynamic *name*)
(scoping [*name* "Alice"]
(q/task
(println "Hello," (ask *name*))
(scoping [*name* "Bob"]
(q/task
(println "Hello," (ask *name*))))))
;; Prints:
;; Hello, Alice
;; Hello, Bob
The floor for a task that does nothing and runs synchronously on the current thread (i.e. (q nil)) is 91ns as
measured on an M1 using Criterium. This sounds impressive until you realize that this is 91ns longer than it takes to
run nil. Still, this establishes where we're starting from.
If you add in some coordination, for example:
(qfor [n (range 1000)]
(q n))
This comes out to ~266µs, or about ~0.27µs per task. If we disregard that the mapv to which qfor expands
will cost some portion of this, most of the overhead is due to there being a parent task, with subtasks in its scope.
So the tasks engage in coordination:
However, there is very little contention. q is synchronous, so the tasks are started, run, and end synchronously.
Starting a virtual thread itself takes somewhere between 0.1µs to 1µs, so we can remeasure with this:
(qfor [n (range 1000)]
(q/task n)) ;; <- Run 1000 virtual threads
This now takes 505µs, or about 0.5µs where the additional cost can partly be attributed to the submission of the body to a virtual executor, and partly to contention during coordination when the tasks start, run, and stop asynchronously.
If we start 100 000 tasks instead:
(qfor [n (range 100000)]
(q/task n))
This takes 69ms, or about 0.69µs, which means that it goes slightly superlinear at larger volumes of simultaneous tasks.
However, if we group them into 100 batches of 1000 each instead:
(qfor [_ (range 100)]
(q/task
(qfor [n (range 1000)]
(q/task n))))
We're looking at a total running time of 30ms, or 0.3µs per task. As contention is decreased and constant overhead is amortize across many tasks, the average cost of a task decreases. Therefore, if your work is bursty in nature, it could be worth chunking it. However, if those same tasks are spread out over time, this will not matter, as contention will be low during any given moment of time.
You might not be using an M1, so the exact numbers here are not important. But understanding the relative ratio between the cost of a task in isolation, and the cost of tasks under coordination and at scale can be useful to help you reason about your own workloads.
By default, dereferencing a task on a platform thread throws an exception. This prevents accidentally blocking carrier threads in virtual thread pools.
; On a platform thread:
@my-task ; => throws "Refusing to park platform thread"
; Explicitly allow it:
(q/deref-cpu my-task) ; => blocks and returns result
Disable this check globally with (q/throw-on-platform-park! false).
CompletableFutures are automatically converted when returned from tasks or passed to chaining functions.
; Wrap a CompletableFuture as a task
(q (s3/async-get …))
;; Or use directly in chaining functions:
(qlet [some-cf (s3/async-get …)
some-other (q/task …)]
(str some-cf some-other))
(-> (s3/async-get …)
(q/then …))
(-> (s3/async-get …)
(q/timeout 100 (Exception. "Too slow!")))
; Convert task to CompletableFuture
(q/as-cf my-task)
JavaScript Promises integrate directly with tasks.
; Wrap a Promise as a task
(q (js/fetch url))
;; Or use directly in task context:
(q/task
(let [response (q (js/fetch url))
data (q (.json response))]
(process data)))
; Convert task to Promise
(q/as-jsp my-task)
Note: JS Promises cannot represent cancellation distinctly from rejection. A cancelled task converted via as-jsp
will reject with the cancellation error.
core.async support is available as an optional adapter. Add core.async to your own dependencies, then require the adapter:
(require '[co.multiply.quiescent.adapter.core-async :as q-async])
; Wrap a channel as a task (takes first value)
(q/as-task some-channel)
; Convert task to channel
(q-async/as-chan my-task)
Unlike CompletableFuture and js/Promise, channels require explicit conversion with as-task. Channels are
intentionally not auto-converted during grounding, since a channel in a return value is often meant to remain
a channel.
Quiescent aims to provide the same semantics on both platforms, but some differences arise from the underlying runtime environments.
JavaScript is single-threaded, so tasks cannot be dereferenced with @. Use callbacks, convert to JS Promise with
as-jsp, or use the with-task test helper.
;; Won't work in CLJS:
;; @(q/task :result)
;; Instead, chain handlers:
(-> (q/task :result)
(q/then process-result)
(q/catch handle-error))
;; Or convert to Promise:
(-> (q/as-jsp my-task)
(.then process-result)
(.catch handle-error))
Since JavaScript has no threads, the following CLJ-specific features are not available in CLJS:
cpu-task - Use task instead (runs on the event loop)-cpu handler variants (then-cpu, catch-cpu, handle-cpu, ok-cpu, err-cpu, done-cpu, finally-cpu) -
Use the non--cpu versions insteadsemaphore, with-semaphore, acquire, release - Use gate / gate-task insteadinterrupted?, comply-interrupt - Use aborted? / comply-abort with abort-signal insteadderef-cpu - Not applicablethrow-on-platform-park! - Not applicableCLJS provides abort-signal for integrating with the Fetch API and other AbortController-aware APIs:
(q/task
(let [response (q (js/fetch url #js {:signal (q/abort-signal)}))]
(process response)))
When the task is cancelled, the AbortSignal is triggered, which cancels the fetch request. Each call to
abort-signal returns a fresh signal tied to the current task's lifecycle.
Related utilities:
(aborted? signal) - Check if an AbortSignal has been triggered(comply-abort signal) - Throw if the signal has been triggeredCancellation exceptions differ between platforms:
| Platform | Cancellation exception |
|---|---|
| CLJ | java.util.concurrent.CancellationException |
| CLJS | ex-info with {:cancelled true} in ex-data |
The cancelled? predicate works uniformly on both platforms.
catch clause matching uses different mechanisms:
;; CLJ - exception classes:
(q/catch task
IllegalArgumentException (fn [e] :bad-arg)
IOException (fn [e] :io-error))
;; CLJS - predicates:
(q/catch task
#(= :bad-arg (:type (ex-data %))) (fn [e] :bad-arg)
#(= :io-error (:type (ex-data %))) (fn [e] :io-error))
The single-arity form (q/catch task handler-fn) works the same on both platforms.
On JDK 25+, Quiescent uses ScopedValue for efficient scope propagation. In CLJS (and earlier JDKs), an alternative
mechanism is used. The API (scoping, ask) remains the same.
When processing work that acquires external resources, cleanup must run even if the parent task is cancelled. Use
compel to protect critical cleanup from cancellation.
Resources should protect their own cleanup. By placing compel inside close-conn, safety is guaranteed wherever
it's used and callers don't need to remember to protect it ad-hoc.
(require '[co.multiply.quiescent :as q])
(defn open-conn
[id]
(println "Opening connection" id))
(defn close-conn
[id]
;; compel ensures cleanup completes even if parent is cancelled
(q/compel
(-> (q/task
(Thread/sleep 1000)
(println "Connection" id "released"))
(q/finally
(fn [_v _e c]
(when c
(println "Connection" id "leaked!")))))))
(defn process-with-resource
[id]
(open-conn id)
(-> (q/task
(println "Working on" id)
(Thread/sleep 5000)
(println "Finished" id))
(q/finally
(fn [_v e _c]
(when e (println "Work" id "interrupted"))
(close-conn id)))))
(q/throw-on-platform-park! false)
(def parent
(q/task
[(process-with-resource :a)
(process-with-resource :b)]))
;; Cancel after 1 second - work stops, but connections still release:
(Thread/sleep 1000)
@(q/cancel parent)
;; Prints:
;; Opening connection :a
;; Opening connection :b
;; Working on :a
;; Working on :b
;; Work :a interrupted
;; Work :b interrupted
;; Connection :a released
;; Connection :b released
Try removing compel from close-conn. You'll see Connection :a leaked! instead of Connection :a released as the
cleanup gets cancelled along with everything else, and connections leak.
You can race not just individual tasks, but entire data structures. Grounding resolves all tasks within a structure, so racing structures means "first group to fully complete wins."
(require '[co.multiply.quiescent :as q])
(q/throw-on-platform-park! false)
(defn random-waiter
[id]
(let [ms (long (+ 10 (rand-int 50)))]
(println (format "`%s` will wait %sms" id ms))
(q/task (Thread/sleep ms)
{id ms})))
(let [t1 (random-waiter :a)
t2 (random-waiter :b)
t3 (random-waiter :c)]
(println "Winner combo:"
@(q/race #{t1 t2} #{t2 t3} #{t1 t3})))
;; `:a` will wait 37ms
;; `:b` will wait 23ms
;; `:c` will wait 10ms
;; Winner combo: #{{:c 10} {:b 23}}
The pair containing the two fastest tasks wins. They execute in parallel, so the race is effectively decided by the slowest task in the fastest group. Losing pairs are cancelled mid-flight.
Each task appears in multiple pairs. This is safe: cancelling an already-completed task is a no-op, and its
resolved value won't be overwritten with a cancellation exception. In this case, only :a was actually cancelled.
qlet analyzes symbol dependencies and automatically parallelizes independent bindings. You don't specify what runs
in parallel. qlet instead infers it from the data flow.
(require '[co.multiply.quiescent :as q])
(require '[co.multiply.scoped :refer [ask scoping]])
(def ^:dynamic *start-ms*)
(defn log [msg]
(println (format "[%3dms] %s" (- (System/currentTimeMillis) (ask *start-ms*)) msg)))
(defn fetch-user [id]
(q/task
(log "Fetching user...")
(Thread/sleep 100)
(log "User fetched")
{:id id :name "Alice"}))
(defn fetch-orders [user-id]
(q/task
(log (str "Fetching orders for user " user-id "..."))
(Thread/sleep 150)
(log "Orders fetched")
[{:id 1} {:id 2}]))
(defn fetch-recommendations [user-id]
(q/task
(log (str "Fetching recs for user " user-id "..."))
(Thread/sleep 120)
(log "Recs fetched")
[:product-a :product-b]))
(defn fetch-promotions []
(q/task
(log "Fetching promotions...")
(Thread/sleep 80)
(log "Promotions fetched")
[:promo-1 :promo-2]))
(q/throw-on-platform-park! false)
(time
(scoping [*start-ms* (System/currentTimeMillis)]
@(qlet [user (fetch-user 123)
orders (fetch-orders (:id user)) ; depends on user
recs (fetch-recommendations (:id user)) ; depends on user
promos (fetch-promotions)] ; independent
{:user (:name user) :orders orders :recs recs :promos promos})))
;; [ 2ms] Fetching user...
;; [ 2ms] Fetching promotions...
;; [ 86ms] Promotions fetched
;; [103ms] User fetched
;; [103ms] Fetching recs for user 123...
;; [103ms] Fetching orders for user 123...
;; [229ms] Recs fetched
;; [257ms] Orders fetched
;; "Elapsed time: 257ms"
;;
;; => {:user "Alice"
;; :orders [{:id 1} {:id 2}]
;; :recs [:product-a :product-b]
;; :promos [:promo-1 :promo-2]}
Sequential execution would take 450ms (100+150+120+80). qlet takes 257ms because:
user and promos run in parallel (both independent)orders and recs run in parallel (both depend only on user)The qlet above expands to:
(q/task
(let [form-0 (fetch-user 123)
form-1 (q/then form-0
(fn [param-0]
(let [user param-0]
(fetch-orders (:id user)))))
form-2 (q/then form-0
(fn [param-0]
(let [user param-0]
(fetch-recommendations (:id user)))))
form-3 (fetch-promotions)]
(q/then form-0 form-1 form-2 form-3
(fn [param-0 param-1 param-2 param-3]
(let [user param-0
orders param-1
recs param-2
promos param-3]
{:user (:name user) :orders orders :recs recs :promos promos})))))
Note that forms with no dependencies remain unchanged and are not automatically wrapped with task conversion. This is because:
then accepts non-tasks as arguments anyway.Supply your own task/cpu-task for forms where you truly want to construct a task inline.
if-qlet and when-qlet provide async versions of if-let and when-let:
;; if-qlet: await, bind if truthy, else branch
(q/if-qlet [user (fetch-user id)]
(process-user user)
(handle-not-found))
;; when-qlet: await, bind and execute body if truthy
(q/when-qlet [user (fetch-user id)]
(log/info "Processing" user)
(process-user user))
Both return tasks. The test expression is awaited, and if truthy, its value is bound for the body.
Happy Eyeballs (RFC 8305) is a connection algorithm that improves responsiveness by racing multiple connection attempts with staggered starts. Instead of trying addresses sequentially (and waiting for each to timeout), it starts with the first address, then after a short delay begins the next attempt while keeping the first running. Whichever connects first wins; the others are cancelled.
As inspired by Missionary.
(require '[co.multiply.quiescent :as q])
(require '[co.multiply.scoped :refer [ask scoping]])
(import '[java.net InetAddress Socket])
(def ^:dynamic *start-ms*)
(defn log [msg]
(println (format "[%3dms] %s" (- (System/currentTimeMillis) (ask *start-ms*)) msg)))
(defn connector
[{:keys [addr port]}]
(let [log-addr (str addr ":" port)]
(log (str "Connecting: " log-addr))
(-> (q/task (Socket. ^InetAddress addr (int port)))
(q/finally
(fn [_v e c]
(cond
c (log (str "Cancelled: " log-addr))
e (log (str "Error: " log-addr))
:else (log (str "Success: " log-addr))))))))
(defn happy-eyeballs
[ms configs]
(letfn [(attempt [[config & configs]]
(if config
(let [trigger (q/promise)]
;; `race-stateful` executes the given side effect on losers which
;; nevertheless had their value realized before they could be
;; cancelled.
(q/race-stateful Socket/.close
;; Attempt a connection with the given config.
(-> (connector config)
;; If it fails immediately, deliver the remaining configs
;; to the `trigger`. `err` is the side-effecting version
;; of `catch`.
(q/err (fn [_e] (deliver trigger configs)))
;; If we've waited `ms` milliseconds it's time to try
;; another option. `monitor` is the side-effecting version
;; of `timeout`.
(q/monitor ms #(deliver trigger configs)))
;; When the promise `trigger` contains configs, follow up
;; with a recursive call, the return value of which will
;; be a participant in the race.
(q/then trigger attempt)))
(throw (IllegalStateException. "No configs left to try."))))]
;; Wrap the initial attempt in a task so that the exception is swallowed
;; by the task if an empty `configs` vector is given to `happy-eyeballs`.
(q/task (attempt configs))))
(def configs
(into [{:addr (InetAddress/getByName "192.0.2.1") :port 80}
{:addr "0" :port -1}]
(mapv (fn [addr] {:addr addr :port 80})
(InetAddress/getAllByName "clojure.org"))))
(q/throw-on-platform-park! false)
(time
(scoping [*start-ms* (System/currentTimeMillis)]
(let [socket @(happy-eyeballs 5 configs)]
(log (str "Winner: " (.getInetAddress socket)))
(Socket/.close socket)
:success)))
;; Prints
;; [ 0ms] Connecting: /192.0.2.1:80
;; [ 6ms] Connecting: 0:-1
;; [ 6ms] Error: 0:-1
;; [ 7ms] Connecting: clojure.org/3.164.240.110:80
;; [ 13ms] Connecting: clojure.org/3.164.240.16:80
;; [ 15ms] Success: clojure.org/3.164.240.110:80
;; [ 15ms] Cancelled: clojure.org/3.164.240.16:80
;; [ 15ms] Cancelled: /192.0.2.1:80
;; [ 15ms] Winner: clojure.org/3.164.240.110
;; "Elapsed time: 15.34175 msecs"
If you're looking for the React library by the same name, it's available here. This library was named before I was aware that there was a previous library of the same name. There's no relation between them.
Eclipse Public License 2.0. Copyright (c) 2025-2026 Multiply. See LICENSE.
Authored by @eneroth
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 |