Summary
Two related dispatch/throughput defects, both measured on a running server while dogfooding a real fan-out ingestion pipeline:
-
(severe) Global head-of-line block. A single long-running in-flight activity stalls all outbox dispatch on the node — across every workflow run, not just its own. While one ~12-minute activity was in-flight on one worker, 32 instant activities belonging to a different run sat undispatched on idle workers. This is an availability problem: one big activity freezes the whole node.
-
(scaling) Plain activities execute serially; a worker pool does not help. Plain (non-agent) activities run inline on a single current-thread serve loop in the worker SDK, and the server does not fan a fork across idle workers. Standing up a 12-connection worker pool produced zero parallelism — the giant activities still ran one at a time. Wall-time ≈ sum of activity durations regardless of how many workers are connected.
The two compound: a serial worker plus a head-of-line-blocking dispatcher means one large activity freezes the pipeline, and adding workers cannot recover it.
Context
Dogfooding a document-ingestion workflow authored in AWL: fan out over 32 council-meeting PDFs (fork … join), drive each through fetch → extract → embed → store, fold a manifest back. Several inputs are large scanned "attachment books" (up to ~593 pages); their extract activity is a genuine ~12-minute CPU job. Server aion 0.9.1 (haematite store 0.5.0, liminal 0.3.0), single-node, outbox liminal transport, default namespace.
Evidence (measured, not inferred)
Finding 1 — head-of-line block. With one worker busy on a ~12-min extract and 11 additional idle workers registered on the same (namespace, task_queue, node), a freshly-started run could not dispatch any of its 32 fetch steps (each is a sub-millisecond path+sha256):
09:54:59 fetch=0/32 cpu=98.9% active=1 deadletters=0
09:55:59 fetch=0/32 cpu=99.2% active=1 deadletters=0
09:56:39 fetch=0/32 cpu=99.5% active=1 deadletters=0 <- orig worker (the one giant) KILLED here
09:56:59 fetch=20/32 cpu=0.2% active=0 deadletters=0
09:57:20 fetch=32/32 cpu=1.7% active=0 deadletters=0
deadletters=0 throughout — the dispatcher was not retrying/dead-lettering, it was genuinely blocked on acceptance by the one busy worker. The instant that worker was killed, all 32 fetches (on the idle pool) completed in seconds. One long in-flight activity had been starving unrelated work on idle workers.
Finding 2 — serial plain-activity execution. With the 12-worker pool healthy and dispatch unblocked, the giant extract activities still ran strictly one-at-a-time — one core busy, eleven idle:
09:57:40 extract=3/32 cpu=7.3% active=0
09:58:20 extract=6/32 cpu=15.1% active=0
09:59:00 extract=7/32 cpu=23.3% active=1
10:01:01 extract=7/32 cpu=43.6% active=1 <- one giant grinding on one worker; 11 idle
The fork over 32 extractions never fanned out. (active = worker processes above 20% CPU.)
Root cause
Worker SDK (certain, code-grounded). crates/aion-worker/src/runtime/liminal.rs:606-615: an agent dispatch is spawned ("MUST NOT block the serve loop"), but a plain activity runs inline — self.execute(&request).await directly in handle_pushed_frame, which the serve loop (serve_until_drop) awaits before receiving the next frame. That loop is block_on a current-thread runtime (runtime/liminal_serve.rs:104 new_current_thread, :142 block_on). Net: one plain activity per connection at a time. WorkerConfig::max_concurrency is set by the worker but is not consulted anywhere on the liminal plain-activity path — no spawn, no semaphore — so raising it changes nothing for plain activities (it is honoured for agent activities, which spawn).
Server dispatch (observed). The outbox claim/dispatch loop does not make progress on other pending rows / idle workers while a dispatch is awaiting acceptance by a busy worker — hence Finding 1. The cluster max_in_flight_activities ceiling is not the limiter here (≥32 activities flushed at once the moment the block cleared). I did not line-trace the server dispatch loop end-to-end, so treat the server half as a characterised symptom + hypothesis rather than a pinned line.
Proposed
- Worker SDK: run plain activities the way agent activities already run —
spawn each under a max_concurrency semaphore instead of inline-awaiting on the serve loop — so a connection can execute up to its advertised concurrency and the serve loop stays free. (Alone, this fixes both findings for a single worker.)
- Server dispatcher: ensure one un-accepted / long-running dispatch cannot head-of-line-block dispatch of other rows to other (idle) workers — dispatch acceptance should not serialise the claim loop, and a
fork should be free to fan across idle workers in the pool.
Severity
Finding 1 is severe for multi-tenant / mixed-workload use: any single long activity freezes dispatch for every run on the node. Finding 2 caps throughput at one activity's worth of work at a time regardless of worker count. Neither loses data — the run still completes durably and correctly, just serially — so not a correctness bug; an availability + scaling bug. Surfaced as a dogfooding finding; happy to share the full worker/server logs or the AWL pipeline if useful.
Summary
Two related dispatch/throughput defects, both measured on a running server while dogfooding a real fan-out ingestion pipeline:
(severe) Global head-of-line block. A single long-running in-flight activity stalls all outbox dispatch on the node — across every workflow run, not just its own. While one ~12-minute activity was in-flight on one worker, 32 instant activities belonging to a different run sat undispatched on idle workers. This is an availability problem: one big activity freezes the whole node.
(scaling) Plain activities execute serially; a worker pool does not help. Plain (non-agent) activities run inline on a single current-thread serve loop in the worker SDK, and the server does not fan a
forkacross idle workers. Standing up a 12-connection worker pool produced zero parallelism — the giant activities still ran one at a time. Wall-time ≈ sum of activity durations regardless of how many workers are connected.The two compound: a serial worker plus a head-of-line-blocking dispatcher means one large activity freezes the pipeline, and adding workers cannot recover it.
Context
Dogfooding a document-ingestion workflow authored in AWL: fan out over 32 council-meeting PDFs (
fork … join), drive each throughfetch → extract → embed → store, fold a manifest back. Several inputs are large scanned "attachment books" (up to ~593 pages); theirextractactivity is a genuine ~12-minute CPU job. Serveraion 0.9.1(haematite store 0.5.0, liminal 0.3.0), single-node, outbox liminal transport, default namespace.Evidence (measured, not inferred)
Finding 1 — head-of-line block. With one worker busy on a ~12-min
extractand 11 additional idle workers registered on the same(namespace, task_queue, node), a freshly-started run could not dispatch any of its 32fetchsteps (each is a sub-millisecond path+sha256):deadletters=0throughout — the dispatcher was not retrying/dead-lettering, it was genuinely blocked on acceptance by the one busy worker. The instant that worker was killed, all 32 fetches (on the idle pool) completed in seconds. One long in-flight activity had been starving unrelated work on idle workers.Finding 2 — serial plain-activity execution. With the 12-worker pool healthy and dispatch unblocked, the giant
extractactivities still ran strictly one-at-a-time — one core busy, eleven idle:The
forkover 32 extractions never fanned out. (active= worker processes above 20% CPU.)Root cause
Worker SDK (certain, code-grounded).
crates/aion-worker/src/runtime/liminal.rs:606-615: an agent dispatch isspawned ("MUST NOT block the serve loop"), but a plain activity runs inline —self.execute(&request).awaitdirectly inhandle_pushed_frame, which the serve loop (serve_until_drop) awaits before receiving the next frame. That loop isblock_ona current-thread runtime (runtime/liminal_serve.rs:104new_current_thread,:142block_on). Net: one plain activity per connection at a time.WorkerConfig::max_concurrencyis set by the worker but is not consulted anywhere on the liminal plain-activity path — nospawn, no semaphore — so raising it changes nothing for plain activities (it is honoured for agent activities, which spawn).Server dispatch (observed). The outbox claim/dispatch loop does not make progress on other pending rows / idle workers while a dispatch is awaiting acceptance by a busy worker — hence Finding 1. The cluster
max_in_flight_activitiesceiling is not the limiter here (≥32 activities flushed at once the moment the block cleared). I did not line-trace the server dispatch loop end-to-end, so treat the server half as a characterised symptom + hypothesis rather than a pinned line.Proposed
spawneach under amax_concurrencysemaphore instead of inline-awaiting on the serve loop — so a connection can execute up to its advertised concurrency and the serve loop stays free. (Alone, this fixes both findings for a single worker.)forkshould be free to fan across idle workers in the pool.Severity
Finding 1 is severe for multi-tenant / mixed-workload use: any single long activity freezes dispatch for every run on the node. Finding 2 caps throughput at one activity's worth of work at a time regardless of worker count. Neither loses data — the run still completes durably and correctly, just serially — so not a correctness bug; an availability + scaling bug. Surfaced as a dogfooding finding; happy to share the full worker/server logs or the AWL pipeline if useful.