Async execution and disaggregated state¶
A coroutine substrate that lets keyed operators issue non-blocking state reads and enrichment lookups, backed by a remote state tier with a bounded in-memory hot cache.
Overview¶
A keyed operator normally reads its state synchronously: a get returns the value or blocks the runner thread until it does. That is fine when state lives in process RAM, but a disaggregated backend keeps the bulk of its state in a remote tier (an object store), so a cold read can take a network round-trip. The async substrate exists so a single slow read does not stall the runner. A record whose read misses the hot tier suspends its own coroutine, the load runs off-thread, and the runner keeps making progress on other keys; the suspended record resumes on the runner thread once its value arrives.
The same coroutine primitive (clink::async::Task<T>) also drives enrichment lookups against external services through the io_uring HTTP client, with retry, circuit-breaking and a PID controller available as composable utilities.
The disaggregated state tier is RemoteReadBackend: a hot in-memory tier in front of a durable remote tier (RemotePool), with a blocking load on a cold miss, working-set eviction once a byte budget is exceeded, and snapshot/restore that commits only the delta and restores lazily.
Where it lives¶
| Area | File | Type / responsibility |
|---|---|---|
| Coroutine primitive | include/clink/async/task.hpp |
Task<T> / Task<void>: lazy-start, move-only, awaitable coroutine |
| Completion source | include/clink/async/completion_executor.hpp |
CompletionExecutor interface; ThreadPoolCompletionExecutor (portable default) |
| io_uring reactor | include/clink/async/io_uring_reactor.hpp, src/async/io_uring_reactor.cpp |
IoUringReactor: SQE/CQE ring, sleep/read/write/accept/connect awaitables (Linux only) |
| io_uring executor | include/clink/async/io_uring_completion_executor.hpp |
IoUringCompletionExecutor: fd reads via io_uring, opaque work via a thread pool |
| HTTP client | include/clink/async/http_pool.hpp, src/async/http_pool.cpp |
HttpPool: coroutine HTTP/1.1 with keep-alive pooling (Linux only) |
| Resilience utilities | include/clink/async/circuit_breaker.hpp, retry_policy.hpp, pid_controller.hpp |
CircuitBreaker, retry() / RetryPolicy, PidController |
| Per-subtask controller | include/clink/runtime/async_execution_controller.hpp |
AsyncExecutionController: per-key gate, epoch manager, cross-thread ready-queue |
| Disaggregated backend | include/clink/state/remote_read_backend.hpp |
RemoteReadBackend: hot tier + remote tier, get_async, eviction, async persist |
| Read coalescer | include/clink/state/coalescing_backend.hpp |
CoalescingBackend: decorator collapsing per-record reads into one get_many_async |
| Lookup operator | include/clink/operators/async_lookup_operator.hpp |
AsyncLookupOperator<In,Out>: drives a Task<Out>-returning lookup per record |
| Runner wiring | include/clink/runtime/dag.hpp |
Wires the controller, resume scheduler, flush hook and resume order |
How it works¶
There are two related but distinct uses of the substrate: async state reads inside keyed operators, and async enrichment lookups against external services. Both are built on Task<T>.
The Task coroutine¶
Task<T> (include/clink/async/task.hpp) is a minimal C++20 coroutine return type. It is lazy-start (initial_suspend returns suspend_always, so the body does not run until something resumes it), move-only (RAII over the std::coroutine_handle, destroyed on drop), and exception-safe (an uncaught throw inside the body is captured on the promise and rethrown from get()). The public surface is resume(), done(), get(), has_exception() and valid().
A Task<T> is itself awaitable, so a coroutine can co_await another_task() to chain async work. The awaiter records the awaiting coroutine as a continuation on the awaited task's promise, and the FinalAwaiter (in detail::PromiseBase) hands control back to that continuation at final_suspend via symmetric transfer. A scheduler (the operator, or the AsyncExecutionController) is responsible for driving the outermost task forward.
The async state read path¶
The controlling component is AsyncExecutionController (include/clink/runtime/async_execution_controller.hpp), one per keyed-operator subtask. Its design rests on a single rule: coroutines are only ever resumed on the runner thread. That keeps the per-key gate, the in-flight table, the parked-waiter FIFOs and the epoch bookkeeping runner-thread-private and lock-free. The only cross-thread surface is schedule_resume(handle), which a foreign IO thread calls to hand a suspended handle back through a mutex-guarded ready-queue and wake the runner.
flowchart TB
subgraph runner["Runner thread"]
S["submit(key, coro)"] --> G{"per-key gate:<br/>key free?"}
G -->|"free: kick coroutine"| AW["co_await get_async(key)"]
AW -->|"cold miss: suspend"| BLK["executor.submit_blocking"]
POLL["poll(): resume handle on runner thread,<br/>write-through fills hot tier"] --> EMIT["emit / post-state mutation"]
end
subgraph io["IO / completion thread"]
LOAD["run blocking load"] --> POST["post handle to ready-queue"]
end
BLK --> LOAD
POST -->|"schedule_resume(h)<br/>(ready-queue + cv)"| POLL
Three mechanisms make this correct:
- Per-key gate. At most one in-flight computation per key. A second record for a busy key parks FIFO and is promoted when the in-flight one completes (
finish_record_), so same-key reads always observe prior same-key writes regardless of completion timing. Distinct keys overlap freely. The default in-flight cap iskDefaultMaxInFlight = 6000;submit()returns false at the cap so the caller applies backpressure. - Epoch manager. Records are tagged with the epoch they arrive in.
on_watermark(on_release)closes the current epoch, opens a new one, and queues the release action.try_release_()runs that action (forward the watermark downstream and fire due event-time timers) only once the epoch has finished, that is every record that arrived before the watermark has completed its post-state stage, and every earlier epoch has already released. Releases run in epoch order on the runner thread, so an async operator never forwards a watermark or fires a timer while an earlier record is still pending. See ./time-and-windowing.md. - Barrier drain.
drain_for_barrier()drains all in-flight and parked records to quiescence before the caller runscapture()/snapshot(), so the captured cut is consistent (no torn, double-counted or lost state). See ./checkpointing.md.
A faulted record body is caught in wrap_(), recorded as first_error_, and the record is still accounted so its key releases and its epoch advances; poll() / drain() then rethrow on the runner thread so it follows the normal operator-failure path.
The operator opt-in is declared on Operator / CoOperator in include/clink/operators/operator_base.hpp: supports_async(), plus process_async(), which the runner calls instead of process() when supports_async() is true and the bound state backend reports supports_async_get(). The single-input and co-operator runners in include/clink/runtime/dag.hpp construct the controller, wire the backend's resume scheduler to it (via a weak_ptr so a late completion after teardown is a safe no-op), route watermarks and barriers through the controller, and route data through process_async. The default leaves every operator on the synchronous process() path, byte-for-byte unchanged.
In the SQL frontend (src/sql/install.cpp) the production adopters are AggregateRowOp (GROUP BY), EquiJoinRowOp (stream-stream equi-join), IntervalJoinRowOp (interval join, INNER and OUTER - eviction and the OUTER unmatched null-padding run through per-key event-time timers), DistinctRowOp (SELECT DISTINCT), SetOpRowOp (INTERSECT / EXCEPT), SemiAntiJoinRowOp (the plain IN / EXISTS / NOT EXISTS path; null-aware NOT IN keeps the in-memory path because a NULL right poisons probes across keys), and the watermark-driven WindowRowOp (TUMBLE/HOP/CUMULATE), SessionWindowRowOp (SESSION) and OverAggregateRowOp (OVER aggregates). They compute an effective_async_ flag in open() from whether the bound backend defers reads, then return it from supports_async(), and their process_async issues one co_await kv.get_async(key) per record. See ./sql-frontend.md. DistinctRowOp holds a presence marker per distinct row in KeyedState, so its seen-set is checkpointed (the former in-memory set was not, so a restore replayed already-emitted rows); its state key is built from the same columns the upstream row_compute_key hashes, so a row's per-key state lands in the same key-group its record routes to.
The watermark-driven operators (window, session, OVER) do not scan all keys at the watermark - that would not fit per-key async reads. Instead the async path keys per-group/per-partition state in KeyedState and registers an event-time timer keyed by the group/partition for each buffered window or pending row. A watermark then fires only the due keys' timers (fires_async_event_time_timers() -> on_event_time_timers_async), so a fire point-reads just that key's state - and distinct due keys' reads coalesce into one get_many. SessionWindowRowOp registers a timer at each fold's session end + gap + lateness; session merges make those timers dynamic (an extended session leaves a stale timer, a merge leaves duplicates), so the fire re-checks every session's live end against the watermark and fires the due ones - stale and duplicate timers are idempotent no-ops. This sidesteps any "scan the whole state" primitive. OverAggregateRowOp covers the whole OVER surface on the async path - running aggregates, FIRST_VALUE / LAST_VALUE, LAG, and bounded ROWS / RANGE frames - because its per-partition PartState (running aggregates, the LAG ring, the bounded-frame buffer, the pending buffer) is serialised whole by its codec and emit_one_ maintains the ring and buffer identically on the sync and async paths. IntervalJoinRowOp uses the same timer mechanism for EVICTION rather than firing: each buffered row registers a timer at its eviction point (left ts + lower + 1, right ts + upper + 1), and the fire drops every row past its window from the due key's two buffers, null-padding an unmatched kept-side (OUTER) row exactly as the sync watermark scan does. The fire takes the largest due timer as the watermark for the cutoff; because a row is due iff its eviction timer is at or below it, that selects precisely the sync eviction set.
A watermark-driven operator that drops late records at ingest needs the watermark before the async runner defers its effect to epoch drain. Operator::on_watermark_observed(wm) is called synchronously by the single-input async runner the instant it pulls a watermark (before the deferred fire/forward), so e.g. OverAggregateRowOp advances its late-drop bound in step with the synchronous path; it is a no-op by default. Such an operator also persists its non-keyed event-time scalars (the late-drop bound) as operator-state in a snapshot_timers / restore_timers override, and keeps any pending-buffer tiebreaker seq in the per-key KeyedState value (not an operator-global counter), so the disaggregated state stays checkpoint-consistent across restore.
Read coalescing¶
coalesce_reads() is a second opt-in. When true (and async is active), the runner wraps the per-subtask backend in a CoalescingBackend (include/clink/state/coalescing_backend.hpp) and registers its flush() as the controller's flush hook. Each record's co_await kv.get_async(key) then lands in the decorator, which does not issue a read immediately: it registers (op, key) into a pending batch and suspends the record. When the controller is otherwise stuck (every in-flight record suspended on its read, nothing ready), drain() calls the flush hook first, which issues one inner get_many_async for the whole pending batch and scatters each result back to its waiting record. So N records reading N distinct keys collapse into one batched (and, on a content-addressed pool, hash-coalesced) round-trip instead of N. This is safe because the per-key gate guarantees every pending read in a batch is for a distinct key, and the flush runs on the runner thread. AggregateRowOp, EquiJoinRowOp, IntervalJoinRowOp, DistinctRowOp, SetOpRowOp and SemiAntiJoinRowOp all set coalesce_reads() to true.
Deadline-aware ordering¶
deadline_aware() is a third, independent opt-in. When true, the runner flips the controller to ResumeOrder::Priority and wires the backend's deadline-aware hand-back. The operator tags each read via get_async(key, order_key) (lower order_key resumes sooner, for example a deadline in milliseconds). poll() then stable_sorts a batch of already-ready, distinct-key completions by order_key before resuming them. Reordering is always safe: the per-key gate makes every ready completion a distinct key, and epoch/watermark releases are count-based and happen after the resume loop, so resume order cannot change which records belong to an epoch or when a watermark releases. coalesce_reads() and deadline_aware() are mutually defeating, the coalescer resumes batches inline rather than through the priority queue, so declaring both logs a warning (dag.hpp) and the deadline ordering is inert.
The disaggregated state backend¶
RemoteReadBackend (include/clink/state/remote_read_backend.hpp) is the backend whose reads can genuinely block on a remote tier. State is two tiers: a hot in-memory tier (recent writes plus filled-on-read keys, held in an InMemoryStateBackend) and a cold remote tier reached through a RemoteLoader. It has two constructions:
- Loader-only. Cold reads go through an arbitrary
RemoteLoadercallable; snapshot/restore capture the hot tier only; there is no durable remote write-back. Used for read-through caching over an external loader, and for tests. - Pool-backed (production). State is durable in a
RemotePool. Cold reads fetch(op, key)from the pool as of the last committed checkpoint; snapshot commits only the delta since the last checkpoint; restore is lazy (cold reads serve the restored checkpoint, nothing is loaded eagerly), which is the fast-recovery and fast-rescale win.
get(op, key) (synchronous) returns a hot hit immediately, otherwise does a blocking remote load with the lock released and fills the result through into the hot tier. get_async(op, key[, order_key]) is the non-blocking twin: a hot hit co_returns with no suspension; on a cold miss with a wired resume scheduler it suspends on a RemoteLoad awaiter whose await_suspend posts the blocking load to the completion executor and, on completion, calls post_resume_ to hand the handle back to the runner; await_resume (on the runner) does the write-through under the lock. If no resume scheduler is wired, get_async degrades to a safe inline blocking load. get_many_async serves hot hits immediately then fetches all cold misses in one batched call with a single suspension (RemoteLoadMany).
The completion source behind the awaiter is the CompletionExecutor (include/clink/async/completion_executor.hpp). The default is a per-backend ThreadPoolCompletionExecutor sized by io_threads (default kDefaultIoThreads = 8); a shared or io_uring-backed executor can be injected at construction. submit_blocking runs an opaque callable (the production case, an S3 GET through the AWS SDK) on a worker thread; submit_read does a file-descriptor read. The executor never resumes a coroutine and never touches the hot tier; it only runs the load off-thread and posts the handle.
Working-set eviction¶
A non-zero hot_max_bytes makes the working set genuinely exceed RAM. The hot tier carries LRU bookkeeping (lru_ front is most-recently-used, index_ maps the composed (op,key) to its node). Once hot_bytes_ exceeds the budget, maybe_evict_() evicts least-recently-used clean keys back to the pool (clean meaning already durable at the last committed checkpoint), re-fetched on next read. A key that is dirty (written since the last checkpoint) or deleted is never evicted, so eviction never loses state; if every over-budget key is dirty the tier transiently exceeds budget and the next snapshot reclaims it. The futile skip-scan past pinned entries is bounded by kMaxPinnedSkips = 256 so a write burst stays O(n) on the runner thread rather than O(n^2); productive evictions are uncapped.
Async persist¶
Pool-backed snapshot splits into capture(id) (the operator-thread phase: detach a point-in-time copy of the delta into a pending commit, pin its keys so a read during the off-thread commit window still sees this checkpoint's effect) and persist(handle) (the worker-thread phase: durably commit the delta to the pool, advance last_ckpt_, release the pins, then re-run eviction). supports_async_persist() is true only when pool-backed. This moves the slow S3 commit off the operator and checkpoint-barrier threads onto the snapshot worker. The reasoning and pin lifecycle is in ./checkpointing.md; restore, rescale and lazy materialisation are in ./fault-tolerance-and-rescale.md and ./state-and-backends.md.
The io_uring reactor and HTTP lookups¶
On Linux with liburing, IoUringReactor (include/clink/async/io_uring_reactor.hpp) owns one submission/completion ring and runs a single dispatch loop. Awaitables (sleep_for, read_async, write_async, accept_async, connect_async) submit SQEs from any thread under a submit mutex; run() reaps CQEs on the loop thread, copies the kernel result into the per-op OperationState, and resumes the awaiting coroutine on the loop thread. stop() posts a NOP SQE to wake a blocked io_uring_wait_cqe. If the submission queue is full, the awaiter resumes immediately with -ENOMEM rather than deadlocking. The header compiles to an empty namespace off Linux or without CLINK_HAS_URING.
HttpPool (include/clink/async/http_pool.hpp, src/async/http_pool.cpp) composes the socket awaitables into a coroutine HTTP/1.1 client with keep-alive connection pooling (capped by max_connections, default 16). It supports GET and POST with Content-Length bodies; it does not do chunked transfer-encoding, TLS, HTTP/2, or DNS (the host must be a dotted-quad IPv4 string), and a request over the connection cap fast-fails with status = 0.
AsyncLookupOperator<In,Out> (include/clink/operators/async_lookup_operator.hpp) drives a std::function<async::Task<Out>(const In&)> per record: it kicks each lookup's Task<Out>, keeps in-flight tasks in an ordered queue, and emits completed results in input order (or any order when ordered = false). The user expresses concurrency through co_await inside the lookup body, so a single thread can hold many in-flight lookups. Its watermark/barrier path currently spin-waits with a short sleep plus resume() to let suspended awaitables advance; this operator is not wired to the AsyncExecutionController.
Resilience utilities¶
These are standalone, header-only, and composed by callers rather than wired automatically:
CircuitBreaker(circuit_breaker.hpp): three-state breaker (Closed, Open, HalfOpen).allow_call()gates traffic; afterfailure_thresholdconsecutive failures (default 5) it trips Open; aftercooldown(default 30s) it admits a single HalfOpen probe; success closes it, failure re-opens it. Lock-protected so concurrent lookups can share one breaker; emits transition metrics.retry()withRetryPolicy(retry_policy.hpp): wraps a callable in retry-with-backoff.max_attempts(default 1, no retry),initial_backoff(50 ms),backoff_multiplier(2.0),max_backoff(30 s), and ashould_retrypredicate (default: retry on anystd::exception). The current implementation sleeps on the calling thread; an async variant is noted as future work in the source.PidController(pid_controller.hpp): a textbook PID controller with anti-windup and a clamped output, intended to drive adaptive rescaling decisions from a measured signal (for example a queue-fill ratio) towards a setpoint. It is pure math and is independent of operators and IO.
Key types and APIs¶
clink::async::Task<T>/Task<void>: lazy-start awaitable coroutine;resume(),done(),get(),has_exception().clink::async::CompletionExecutor:submit_blocking(job)andsubmit_read(fd, buf, len, done).ThreadPoolCompletionExecutoris the portable default;IoUringCompletionExecutoradds io_uring fd reads (Linux).clink::async::IoUringReactor:run(),stop(), and the sleep/read/write/accept/connect awaitables (Linux +CLINK_HAS_URING).clink::async::HttpPool:request(),get(),post()returningTask<HttpResponse>(Linux +CLINK_HAS_URING).clink::AsyncExecutionController:submit(key, make_coro),on_watermark(on_release),schedule_resume(handle[, order_key]),poll(),drain(),drain_for_barrier(),set_flush_hook(),set_resume_order().clink::RemoteReadBackend: synchronousStateBackendsurface plusget_async,get_many_async,supports_async_get(),supports_async_persist(),capture()/persist(), andset_async_resume_scheduler/set_deadline_resume_scheduler.clink::CoalescingBackend:StateBackenddecorator withflush()andflush_pending_reads().- Operator opt-ins (
Operator/CoOperator):supports_async(),process_async(),coalesce_reads(),deadline_aware().
Configuration and knobs¶
| Knob | Where | Default | Effect |
|---|---|---|---|
CLINK_HAS_URING |
CMake (CMakeLists.txt), Linux only when liburing is found |
unset off Linux | Compiles the io_uring reactor and HTTP pool; otherwise their headers are empty namespaces |
kDefaultIoThreads |
completion_executor.hpp |
8 | Default thread-pool size for the completion executor |
kDefaultMaxInFlight |
async_execution_controller.hpp |
6000 | In-flight record cap before submit() applies backpressure |
io_threads |
disagg-local:// query, remote-read:// query |
1 (disagg-local), 8 (remote-read) |
Sizes the per-backend completion executor |
hot_max_bytes |
disagg-local:// / remote-read:// query |
a quarter of physical RAM, floor 64 MiB (default_remote_hot_max_bytes); =0 forces unbounded |
Hot-tier byte budget; non-zero enables LRU eviction back to the pool |
CircuitBreakerConfig |
constructed by the caller | failure_threshold = 5, cooldown = 30s |
Trip threshold and cooldown |
RetryPolicy |
constructed by the caller | max_attempts = 1, initial_backoff = 50ms, backoff_multiplier = 2.0, max_backoff = 30s |
Retry count and backoff schedule |
The disagg-local:// scheme is registered in src/state/state_backend_factory.cpp; it builds a RemoteReadBackend over an in-process InMemoryRemotePool. The S3-backed remote-read://<bucket>/<prefix> scheme is registered by the S3 connector (impls/s3/src/register_state_backend.cpp) over an S3RemotePool. See ./state-and-backends.md.
Guarantees and caveats¶
- Threading contract. Coroutines resume only on the runner thread; the controller's per-key state is lock-free by that invariant. A completion landing after subtask teardown is a safe no-op (the resume scheduler holds a
weak_ptr). - Config-gated activation. The async path activates only when an operator returns
supports_async()true and the bound backend returnssupports_async_get()true. Otherwise the operator runs the unchanged synchronousprocess()path.coalesce_reads()anddeadline_aware()are further independent opt-ins; declaring both makes the deadline ordering inert (logged at wire-up). - Production adopters. Among shipped operators the auto-on production adopters of async state are the SQL
GROUP BY(AggregateRowOp), stream-stream equi-join (EquiJoinRowOp), and the watermark-drivenWindowRowOp(TUMBLE/HOP/CUMULATE) andOverAggregateRowOp(running-aggregate OVER) via per-key event-time timers.AsyncLookupOperatorandHttpPoolare the enrichment path and are not wired to theAsyncExecutionController. - io_uring is optional and Linux-only. Without liburing the reactor, the io_uring completion executor and the HTTP pool are not built; the
Task, controller, thread-pool executor and resilience utilities still work. TheIoUringCompletionExecutoris built and tested but is deliberately not selected by the state-backend factory: no production loader is fd-level (the disaggregated tier is S3 over HTTP, which ridessubmit_blocking). - Eviction never loses state. Only clean (already-durable) keys are evicted; dirty and deleted keys stay hot until the next checkpoint commits them. The pinned-skip scan is bounded, so the hot tier may transiently exceed its budget when its LRU tail is all-pinned.
- Per-backend budgeting.
default_remote_hot_max_bytes()is per keyed-operator subtask. A node running many keyed operators with the default can oversubscribe RAM; per-operator or global budgeting is a deferred follow-on, and a caller can pass an explicithot_max_bytesto bound it. - HTTP client limits.
HttpPooldoes not support chunked transfer-encoding, TLS, HTTP/2, or DNS resolution, and fast-fails past the connection cap. disagg-local://is not durable. Its pool is process RAM, so there is no cross-process failover or rescale and no remote-IO latency to overlap; it exists to make the async/disaggregated path reachable for correctness coverage without an object store. The throughput win needs the real S3-backedremote-read://tier.
Related¶
- ./state-and-backends.md (keyed state, the
RemotePool, and other backends) - ./checkpointing.md (barriers, the capture/persist split, and the async-persist worker)
- ./fault-tolerance-and-rescale.md (lazy restore, rescale and schema evolution)
- ./operator-model.md (the operator opt-in surface and the runner)
- ./task-lifecycle.md (the local runtime and the runner loop)
- ./time-and-windowing.md (watermarks, epochs and event-time timers)
- ./sql-frontend.md (
AggregateRowOpandEquiJoinRowOp, the production async adopters) - ../connectors/README.md (the S3 connector that registers the
remote-read://backend)