feat(orchestration): admission queue with deferred pickup (#441)#544
feat(orchestration): admission queue with deferred pickup (#441)#544nizar-lahlali wants to merge 3 commits into
Conversation
When a task hits the per-user concurrency cap, park it in a new QUEUED state instead of failing it (the #331 mass-fail mode). A scheduled AdmissionQueuePickup Lambda drains the queue FIFO (by created_at) as slots free up, flipping QUEUED -> SUBMITTED and re-invoking the orchestrator, whose atomic admissionControl stays the single writer of the concurrency counter — a lost pickup race harmlessly re-queues without losing FIFO position. - task-status: new QUEUED state (pre-active — holds no slot), with SUBMITTED <-> QUEUED transitions plus QUEUED -> CANCELLED/FAILED - orchestrator: queueTask() replaces failTask on cap; queued_at is stamped once, admission_attempts increments per pass; Linear/Jira channel feedback now says 'queued' and fires only on first entry - reconcile-admission-queue: FIFO drain per user with read-only capacity pre-check, conditional flips (cancel-safe), 24h max-age backstop, and orchestrator re-invoke carrying a pickup nonce - reconcile-stranded-tasks: age by time-in-current-status so a task that waited in the queue longer than the stranded timeout is not killed right after pickup - API/CLI: GET /tasks/{id} computes read-time queue_position + estimated_wait_s (fail-open); bgagent status/detail render a 'Queue: position N (est. wait ~Xm)' line - cancel: QUEUED is cancellable everywhere (REST already generic; Slack cancel list extended); no concurrency release needed - idempotent replay returns the existing QUEUED task unchanged - tests: state-machine invariants, queueTask, pickup handler (incl. #331 fan-out-burst regression: burst above cap survives and drains FIFO with zero failures), get-task queue position, CLI rendering Closes #441
…eue-deferred-pickup # Conflicts: # cdk/src/handlers/orchestrate-task.ts
scottschreckengaust
left a comment
There was a problem hiding this comment.
Review — feat(orchestration): admission queue with deferred pickup (#441)
Verdict: COMMENT (non-gating). This is a well-architected, well-bounded, thoroughly documented feature that faithfully implements the approved acceptance criteria of #441. No merge blockers. I am not approving because two real quality issues are worth addressing — a genuine (minor, non-safety) correctness bug in queue-position reporting under the very fan-out-burst scenario this feature targets, and a coverage gap on the load-bearing re-queue integration path — but neither gates merge. Take them as strong suggestions.
Governance (ADR-003) — PASS
- #441 exists, is OPEN, carries
approved+P0+orchestration. Implementation matches its acceptance criteria (durable QUEUED state, FIFO drain bycreated_at, cancel/idempotency preserved,queue_position/ETA surfaced, #331 regression test). Branchfeat/441-admission-queue-deferred-pickupfollows the pattern.mergeable=MERGEABLE(the earlier UNKNOWN resolved; main already merged in — no rebase needed). CI green exceptinteg-smoke(pending admin approval).
Vision alignment (VISION.md / ORCHESTRATOR.md) — PASS
- Fire-and-forget default preserved. Deferred pickup keeps unattended async execution: the scheduled drain re-invokes the orchestrator; no user action needed while queued (the Linear/Jira comment even says "No action needed").
- Bounded blast radius & cost — the key tenet, and it holds. The queue is DynamoDB-backed (not an unbounded SQS pile), drained by a 1-minute scheduled Lambda whose empty cycle is a single cheap GSI query. A 24h max-age backstop (
QUEUE_MAX_AGE_SECONDS) fails over-age tasks so the queue can never accumulate unbounded zombies. QUEUED holds no concurrency slot and no compute — correctly excluded fromACTIVE_STATUSES(the comment intask-status.tscalls out that counting it would deadlock the queue). No undocumented tenet trade. - Reviewable outcomes preserved: queued tasks still flow to the same PR pipeline once admitted.
Blocking — NONE
No unbounded queue, no missing bootstrap coverage, no task double-run, no admission race that loses correctness, no undocumented tenet trade.
Non-gating findings (please address)
-
queue_positionmis-reports on same-millisecond ties —cdk/src/handlers/get-task.ts:79.created_atisnew Date().toISOString()(ms granularity), not the ULIDtask_id.computeQueueInforanks with strictitem.created_at < record.created_at, so two tasks created in the same millisecond each see the other as not ahead → both report the same position. This is precisely the #331 fan-out burst (children minted in a tight loop) this feature exists to serve, sobgagent statuscould show two queued tasks both claiming "position 3". Risk: user-visible UX-only inconsistency (FIFO drain is unaffected — the pickup Lambda uses the GSI's own sort order). Fix: break ties deterministically on the ULIDtask_id, which is monotonic with creation (inline suggestion below). Add a get-task test with two identicalcreated_atrows pinning the behavior — the current position test (get-task.test.ts:164-167) uses four distinct timestamps and never a tie. -
Re-queue integration path is untested end-to-end —
cdk/src/handlers/orchestrate-task.ts:91-126. The load-bearing FIFO-preservation logic (on a failedadmissionControl, pass the re-readcurrent— not the stale step-1task— toqueueTask, and gate channel feedback oncurrent.queued_at === undefinedso a pickup-cycle re-queue doesn't resetqueued_at/admission_attemptsor spam the issue) has no handler-level test.queueTask's re-queue behavior is covered in isolation (orchestrate-task.test.ts:1020), but a regression toqueueTask(task)(stale snapshot) would silently reset FIFO bookkeeping and every existing test would still pass. Add a handler test asserting the re-read record is passed and notify-on-cap fires only on first entry. -
pickUpTaskinvoke-failure is miscounted in the summary —cdk/src/handlers/reconcile-admission-queue.ts:224returnsfalseon a failed orchestrator re-invoke (task now SUBMITTED-with-no-pipeline), which the caller counts asskipped_race(line 367), noterrors. A systemic invoke outage (bad ARN / throttling / perms) that strands every picked-up task would log at INFO witherrors: 0— indistinguishable from benign cancel races, and no alarm-worthyerror_idlike the siblingreconcile-stranded-tasks.ts:400-412pattern. Recovery itself is sound (the task IS reachable by the stranded reconciler — verified:created_atis never rewritten so it stays under the GSI cutoff, and the new age-by-status_created_atlogic protects it until it's genuinely stranded). This is purely an observability gap. Fix: incrementsummary.errors(or a dedicatedinvoke_failedcounter) on that branch and fold it into the escalation at line 381.
Nits
MAX_CONCURRENTdefault drift (cosmetic).orchestrator.ts:41defaults?? '3'; the new files (get-task.ts:34,reconcile-admission-queue.ts:69,task-api.ts:549,admission-queue-pickup.ts:119) and the orchestrator construct (task-orchestrator.ts:200) default to10. In the deployed stackMAX_CONCURRENT_TASKS_PER_USERis always set (construct default 10), so all planes agree at 10 and the?? '3'never fires — but the lingering3reads as a real disagreement.confirm-uploads.ts:48also still uses?? '3'. Worth aligning the fallbacks to one value in a follow-up. (Not in this PR's diff, so no inline.)expireQueuedTask's cancel-race branch (reconcile-admission-queue.ts:266) and the> QUEUE_MAX_AGE_SECONDSboundary lack a direct test (the pickup path has the equivalent ConditionalCheckFailed test). Cheap to add.
Documentation — EXCELLENT
docs/design/ORCHESTRATOR.md is comprehensively updated: new QUEUED state row, state diagram edges, transition table (incl. the 24h backstop), cancel-semantics row, and a rewritten admission-control step 2 explaining the single-writer/re-queue design. The Starlight mirror docs/src/content/docs/architecture/Orchestrator.md is regenerated in the same PR (no stale-mirror CI risk). CLI types.ts is synced (QUEUED + the three new TaskDetail fields) and format.ts renders the queue line. No ADR needed — this is an additive lifecycle state within the existing durable-execution model, not a new architectural decision.
Tests & CI
- Bootstrap synth-coverage (ADR-002): N/A — correctly not updated. The only new CFN resources are
AWS::Events::Rule(events:PutRule) andAWS::Lambda::Function(lambda:CreateFunction) — both already incdk/src/bootstrap/resource-action-map.ts(same Rule+Lambda pattern as ConcurrencyReconciler / StrandedTaskReconciler / PendingUploadCleanup). No new resource type ⇒ no bootstrap-policy /BOOTSTRAP_VERSIONbump required. Verified by grepping the diff for new construct instantiations. - #366 test-performance: PASS.
admission-queue-pickup.test.tscachesTemplate.fromStack()once (module-level cache, barenew App()picks up the global bundling-disable), no per-test synth, no bundling re-enable.task-status.test.tsis a pure constant unit test (no synth). - Strong behavior-focused coverage otherwise: the named #331 fan-out-burst regression, FIFO ordering, per-user capacity math, pagination,
queued_at-set-once,admission_attemptsincrement, conditional-flip-loses-to-cancel (pickup path), state-machineQUEUED ∉ ACTIVE_STATUSESinvariant, CLI rendering + fail-open null. Gaps are findings #1/#2/#3 above.
Note on a claim I investigated and discharged
An automated pass flagged a "concurrency counter double-increment on durable replay" in the admission step as HIGH. I verified against the diff and durable-execution semantics: admissionControl was already inside context.step('admission-control') before this PR (the diff only swaps failTask→queueTask inside the pre-existing step). At-least-once step re-execution is a general durable-execution property — documented in ORCHESTRATOR.md — and is explicitly backstopped by the existing ConcurrencyReconciler (reconciles active_count drift against actual active tasks). It is neither introduced nor worsened by #544, so it is out of scope here.
Review agents run
pr-review-toolkit:silent-failure-hunter(invoked) — surfaced the invoke-failure miscount (#3) and the durable-replay concern (investigated + discharged as pre-existing).pr-review-toolkit:pr-test-analyzer(invoked) — surfaced the tie gap (#1) and the re-queue integration gap (#2).code-reviewer/type-design-analyzer— hand-reviewed. Types:TaskStatus/VALID_TRANSITIONS/PRE_ACTIVE_STATUSESare clean;TaskDetailadditions are well-modeled (queue_position/estimated_wait_snullable, never persisted). Bootstrap (ADR-002) verified by hand.
Human heuristics
- Proportionality: appropriate. A durable DynamoDB state + a cheap scheduled drain is the right weight for #441 — not over-built (no SQS/Step Functions), reuses the existing single-writer counter and reconciler backstop.
- Coherence: matches the established reconciler/durable-step patterns already in the codebase.
- Clarity (AI004): the
get-task.ts:104fail-opennosemgrepis correctly justified (queue position is best-effort UX; must not 500 the whole GET). No unjustified silent masking found. - Appropriateness (AI005): tests assert does-do (condition expressions, flipped IDs in FIFO order, counters, decoded payloads), not should-do.
🤖 Generated with Claude Code
| for (const item of resp.Items ?? []) { | ||
| if (item.task_id === record.task_id) { | ||
| found = true; | ||
| } else if (typeof item.created_at === 'string' && item.created_at < record.created_at) { |
There was a problem hiding this comment.
Same-millisecond tie mis-reports position. created_at is new Date().toISOString() (ms granularity), so two tasks created in the same millisecond produce identical strings; with strict <, neither counts the other as ahead and both report the same queue_position — exactly the fan-out-burst case (#331) this feature targets. The ULID task_id is monotonic with creation, so use it as a deterministic tiebreak:
| } else if (typeof item.created_at === 'string' && item.created_at < record.created_at) { | |
| } else if ( | |
| typeof item.created_at === 'string' | |
| && (item.created_at < record.created_at | |
| || (item.created_at === record.created_at | |
| && typeof item.task_id === 'string' | |
| && item.task_id < record.task_id)) | |
| ) { |
Also add a get-task test with two rows sharing record.created_at to pin this — the current position test uses four distinct timestamps and never a tie.
Closes #441. Successor to #331 (fan-out mass-fail against the concurrency cap).
What
When admission hits the per-user concurrency cap, the task is now queued, not failed: it transitions
SUBMITTED → QUEUED(a new pre-active state that holds no concurrency slot) and a scheduled AdmissionQueuePickup Lambda drains the queue in FIFO order (bycreated_at) as slots free up — flippingQUEUED → SUBMITTEDand re-invoking the orchestrator.Design highlights
admissionControlconditional-increment remains the only writer of the concurrency counter. A pickup that loses the admission race harmlessly re-queues (created_atnever changes, so FIFO position is preserved). This avoids introducing a second writer — the class of drift bug behind Fan-out orchestration mass-fails against the per-user concurrency cap (no queue/retry) #331.QUEUEDis cancellable via REST (already status-generic) and Slack (list extended). No compute or slot to release.Idempotency-Keyreturns the existingQUEUEDtask (200) — dedupe path is status-agnostic and untouched.QUEUE_MAX_AGE_SECONDS(default 24 h) is failed with a clear message. The stranded-task reconciler now ages tasks by time-in-current-status so a long queue wait isn't mistaken for a strandedSUBMITTEDtask right after pickup.API / CLI surface
TaskRecord:queued_at,admission_attempts(internal bookkeeping).GET /tasks/{id}:queued_at,queue_position(1-based FIFO rank among the caller's queued tasks, computed at read time, fail-open),estimated_wait_s(coarse heuristic).bgagent status/ detail view renderQueue: position N (est. wait ~Xm)whileQUEUED.check-types-syncpasses.#331 regression test
reconcile-admission-queue.test.tsincludes a fan-out-burst regression: 5 children queued above a cap of 3 all survive asQUEUED, drain FIFO (3 picked up, 2 remain), and zero tasks fail.Testing
mise run buildpasses end-to-end (agent quality, CDK compile + synth + 2265 tests across 125 suites, CLI 576 tests, docs build + sync, type/constants drift checks).queueTask(first-entry vs re-queue, race, no counter writes), pickup handler (FIFO, capacity bounds, per-user isolation, races, invoke-failure, max-age backstop, pagination), get-task queue position (rank, fail-open, left-queue race), CLI rendering.Notes for reviewers
ceil(position / cap) × avg-task-duration, default 600 s, env-tunable) — not a promise.AdmissionQueuePickupruns every minute; an empty-queue cycle is a single GSI query.FAILED+admission_rejectedevent as terminal will now seeQUEUED+admission_queuedinstead.