You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Since #84, the concurrency() semaphore in the work-queue dispatch layer (now lib-data-workqueue after #86) does not bound concurrent handler invocations — it bounds concurrent live commands. A permit is acquired at message pickup and held across re-polls, including the idle pollInterval waits, until the handler returns a terminal result. For long-lived commands (e.g. sched's TaskSubmitCommand, which stays non-terminal for the whole task runtime), this makes concurrency × replicas a hard ceiling on concurrently-tracked tasks — and once reached, new intake freezes completely and silently: queued commands never execute, no log line, no metric, no back-signal.
Observed (sched-dev, 2026-07-22)
A 1200-task fan-out of long-running tasks (sched command-queue.concurrency: 100, 2 replicas):
Exactly 200 tasks launched, then intake froze: the remaining ~1000 TaskSubmitCommands never produced a single Submitting task log — they were never dispatched, not deferred.
No quota/capacity errors AWS-side; no signal sched-side.
When long tasks started completing, launches resumed strictly one-in-one-out (26 completions → 25 launches per 5-min window) — the signature of a held semaphore recycling.
slots declaration (~:163): "a permit is acquired when a lease is picked up and held for the whole lease lifetime (across re-polls), released on terminal ack / eviction / release" — the behavior is documented as intent.
Pre-#84 (lib-data-stream-redis 1.5.0): one listener thread, handler inline and serial, non-terminal messages left in the PEL and re-delivered cross-pod via XAUTOCLAIM after claim-timeout (~60s). That model had three real defects that #84 correctly fixed (head-of-line blocking → sched#600; duplicate execution on reclaim; ~60s status-detection lag → sched#303). But it also had two implicit invariants that did not survive, and neither was an explicit design decision:
Consistency check from the sched#767 prod incident: the livelock's flat error rate (~1.8 timeouts/s) equals concurrency-slots ÷ apiCallTimeout (200 ÷ 90s) — the slot pool spinning at full occupancy with every call burning its whole budget. The queue ceiling also means the 556-task prod run never had more than 200 tasks in flight; the 370s task failures were the only slot recycler.
Options
Admission
Re-poll cadence guarantee
API rate bound
A. Status quo, raise the knob (safe once sched#771's batched polling decouples API cost from slot count)
bounded (higher)
✓ pollInterval
✓ explicit
B. Permit per invocation (release before scheduleRepoll, re-acquire on re-poll)
unbounded
✗ re-polls compete with intake under backlog — the starvation the current design guards against
✗ lost again
C. Two lanes: an intake budget for new commands + a separate (or uncapped) re-poll budget
unbounded, paced
✓ bounded per lane
✓ explicit per lane
Recommendation: C — admission can never freeze, re-polls can never starve, and both rates are independently tunable; A as the pragmatic interim for sched once sched#771 merges. B just re-trades the same invariants back.
Whatever the choice, the freeze must stop being silent: at minimum a gauge for held permits + queued-but-never-started commands, and a WARN when intake is starved for more than N poll cycles.
Summary
Since #84, the
concurrency()semaphore in the work-queue dispatch layer (nowlib-data-workqueueafter #86) does not bound concurrent handler invocations — it bounds concurrent live commands. A permit is acquired at message pickup and held across re-polls, including the idlepollIntervalwaits, until the handler returns a terminal result. For long-lived commands (e.g. sched'sTaskSubmitCommand, which stays non-terminal for the whole task runtime), this makesconcurrency × replicasa hard ceiling on concurrently-tracked tasks — and once reached, new intake freezes completely and silently: queued commands never execute, no log line, no metric, no back-signal.Observed (sched-dev, 2026-07-22)
A 1200-task fan-out of long-running tasks (sched
command-queue.concurrency: 100, 2 replicas):TaskSubmitCommands never produced a singleSubmitting tasklog — they were never dispatched, not deferred.Code anchors (
lib-data-workqueue/.../AbstractWorkQueue.java)slotsdeclaration (~:163): "a permit is acquired when a lease is picked up and held for the whole lease lifetime (across re-polls), released on terminal ack / eviction / release" — the behavior is documented as intent.run()(~:483): terminal →acknowledge()→releaseLease()→slots.release(); non-terminal →scheduleRepoll()— no release.scheduleRepoll()(~:542): resubmits the invocation via the scheduler without touching the semaphore — re-polls have reserved capacity by construction.:396): re-polls of admitted commands must never be starved by new intake.Design history — what #84 traded away
Pre-#84 (
lib-data-stream-redis1.5.0): one listener thread, handler inline and serial, non-terminal messages left in the PEL and re-delivered cross-pod viaXAUTOCLAIMafterclaim-timeout(~60s). That model had three real defects that #84 correctly fixed (head-of-line blocking → sched#600; duplicate execution on reclaim; ~60s status-detection lag → sched#303). But it also had two implicit invariants that did not survive, and neither was an explicit design decision:in-flight/pollIntervalscales withconcurrency(e.g. 200 slots / 5s = 40 calls/s), which is what made the ECS control-plane storms possible: the timeline Async, non-blocking consumer processing with heartbeat lease (lib-data-stream-redis + lib-cmd-queue-redis) #84 (Jul 13) → sched#756/#758 storms (Jul ~17) → sched#767 livelock (Jul 21) is a direct consequence chain. sched#758/#769/#771 have been progressively re-building, deliberately, the rate-limiting the old architecture had by accident.Consistency check from the sched#767 prod incident: the livelock's flat error rate (~1.8 timeouts/s) equals
concurrency-slots ÷ apiCallTimeout(200 ÷ 90s) — the slot pool spinning at full occupancy with every call burning its whole budget. The queue ceiling also means the 556-task prod run never had more than 200 tasks in flight; the 370s task failures were the only slot recycler.Options
pollIntervalscheduleRepoll, re-acquire on re-poll)Recommendation: C — admission can never freeze, re-polls can never starve, and both rates are independently tunable; A as the pragmatic interim for sched once sched#771 merges. B just re-trades the same invariants back.
Whatever the choice, the freeze must stop being silent: at minimum a gauge for held permits + queued-but-never-started commands, and a WARN when intake is starved for more than N poll cycles.
Related
DescribeTasks— prerequisite for option A)