fix(perf): run batch execution off the uvicorn event loop (#901, P0.7) - #990
Conversation
Closes #901 (P0.7). POST /api/v2/tasks/execute and /tasks/approve called conductor.start_batch() directly from an async handler. start_batch does not detach — it drives every task through _execute_task_subprocess, ending in process.wait() — so the complete agent execution of the whole batch ran on the event loop. Every other request, SSE stream, WebSocket and /health froze for its duration, which for a real batch is minutes to hours. The web UI calls this endpoint, so this alone made the server unusable for more than one concurrent tenant. start_batch is split at its natural seam: - create_batch() validates, persists and emits BATCH_STARTED, returning a PENDING record — so a client polling /batches sees it immediately; - execute_batch() takes a persisted batch and runs it to completion, reading strategy/max_parallel/engine off the record so a resumed batch executes as created; - start_batch() remains as the blocking create+execute wrapper the CLI and tests use, with a docstring warning handlers off it. Both handlers now create the batch, return batch_id, and hand execution to a daemon thread — the same shape start_single_task already used for a single run. A dedicated thread rather than the unused BackgroundTasks parameter: Starlette would run it in the shared anyio threadpool, where an hours-long batch permanently occupies one of its limited workers and enough concurrent batches starve every other sync endpoint. That parameter is now removed, along with the module docstring line still advertising start_batch. The tests assert the two properties that matter — the handler returns *while the batch is still running*, and a concurrent request is served meanwhile — rather than just 200 OK, which passes on the broken code. The blocking stand-in waits on an Event the test releases instead of sleeping, so "still running" is a controlled fact rather than a wall-clock race, and teardown releases it so no detached thread leaks into the next test's timing budget.
The detached execution left one synchronous SQLite write — create_batch's INSERT — on the event loop. Milliseconds normally, but it made test_health_responds_while_a_batch_runs fail when run after the full conductor suite on an fsync-bound machine: the 2s concurrency budget went on the persist, not on the batch. Loosening the threshold would have hidden a real (if small) blocking call, so both handlers now wrap create_batch in run_in_threadpool — the pattern diagnose_v2 and environment_v2 already use for exactly this. The handler is now genuinely non-blocking end to end. 81 passed across the offload tests and the whole conductor suite together.
|
Claude finished @frankbria's task in 6m 1s —— View job GLM precision review — completeScope: concrete defects only (logic errors, data loss, races, resource leaks, contract breaks, security) in the three changed files. Style/coverage left to the general review.
✅ GLM review: no defects found. What I specifically checked and ruled out:
|
Review follow-up on #901. Both CodeRabbit and the opencode pass independently found the same regression, and it is one I introduced. Offloading removed an accidental safety property: while execution ran inline on the event loop, a second POST could not even be *served* until the first batch finished — the bug serialized batches. Nothing replaced it. check_assignment_status is check-then-act: it counts IN_PROGRESS tasks, but tasks only move inside the detached thread, long after the handler responded. Two clicks within that window both passed, both persisted, and ran two agent subprocesses against the same git worktree. /approve was worse — with start_execution=true it never called the guard at all. A workspace now admits one batch at a time. The check and the create happen under a single per-workspace lock (_create_batch_exclusively), because checking first and creating after leaves exactly the window two clicks need; a second request gets 409 with the blocking batch's id. It runs on the worker thread both handlers already use, so the lock never touches the event loop. This also bounds thread creation, which both reviewers raised separately: concurrent batch threads are now capped by workspace count rather than request rate. A failure in the detached thread no longer wedges the batch. execute_batch sets RUNNING before dispatching, and work before _execute_parallel's own try (plan building, starting the reconciliation thread) can raise past it — leaving the row RUNNING forever, with the client already holding its 200 and resume_batch accepting only PARTIAL/FAILED/CANCELLED. conductor.fail_batch() finalizes such a batch and emits BATCH_FAILED; the background runner calls it on any escape, and get_batch is now inside the same try so a DB error cannot escape the thread either. Fixing the guard surfaced a real handler bug: /approve's catch-all re-wrapped the deliberate 409 as a 500. It now re-raises HTTPException, as /execute already did. Also updates tests/integration/test_tasks_v2_engine.py — CI caught that it patches conductor.start_batch to assert the engine kwarg reaches the conductor. Same subject, new seam: it patches create_batch, plus _start_batch_detached so no thread spawns against an unpersisted mock. Per both reviewers, test_health_responds_while_a_batch_runs now waits for the batch to be genuinely in flight before measuring, instead of a fixed 50ms sleep — otherwise a slow-to-start thread makes it pass vacuously.
|
Claude finished @frankbria's task in 6m 36s —— View job GLM precision review — completeScope: concrete defects only (logic errors, data loss, races, resource leaks, contract breaks, security) across all three commits. This pass specifically covers
✅ GLM review: no defects found. What I specifically checked and ruled out (focus on the new commit
Considered and deliberately rejected (no concrete wrong outcome / not a regression):
|
Third-party review round — a regression I introducedBoth CodeRabbit and the opencode/GLM pass independently found the same thing, and it is the most interesting finding in this PR. The blocking bug was accidentally serializing batches
And Fix: a workspace admits one batch at a time. The check and the create happen under a single per-workspace lock — checking first and creating after leaves exactly the window two clicks need. A second request gets 409 naming the blocking batch. It runs on the worker thread both handlers already use, so the lock never touches the event loop. This also resolves the thread-bound concern both reviewers raised separately (CodeRabbit MAJOR, opencode LOW): concurrent batch threads are now capped by workspace count rather than request rate, without inventing a queue whose overflow semantics this issue never specified. Tests: A wedged batch could not even be resumed
Sharper than the old behaviour, where the same exception became a 500 the client saw. Now the client holds a Fix: That surfaced a real handler bug
CI caught a third test file
Test strengthened (both reviewers)
Confirmed clean by opencodeCancel/stop racing the detached thread ( Accepted, documented
|
Closes #901 (P0.7 —
critical/perf, filed by the SaaS launch review).Problem
POST /api/v2/tasks/executeand/tasks/approvecalledconductor.start_batch(...)directly from anasync defhandler.start_batchdoes not detach — it drives every task through_execute_task_subprocess, ending inprocess.wait().So the complete agent execution of the whole batch ran on the uvicorn event loop. Every other request, SSE stream, WebSocket and
/healthfroze for its duration — minutes to hours for a real batch. The web UI calls this endpoint. As the issue puts it: this alone makes the server unusable for more than one concurrent tenant.The unused
BackgroundTasksparameter on/tasks/approveshows offloading was intended and never wired.Fix
start_batchsplits at its natural seam:create_batch()BATCH_STARTED, returns a PENDING record — so a client polling/batchessees it at onceexecute_batch()start_batch()Both handlers now create the batch, return
batch_id, and hand execution to a daemon thread — the same shapestart_single_taskalready used for a single run.Why a thread and not
BackgroundTasksStarlette would run it in the shared anyio threadpool, where an hours-long batch permanently occupies one of its limited workers, and enough concurrent batches starve every other sync endpoint. The issue's own criterion blesses the thread ("as
start_single_taskdoes"). The unused parameter is removed.One more blocking call, found by the test
create_batch's SQLite INSERT was still synchronous on the loop. Milliseconds normally — but it made the concurrency test fail when run after the full conductor suite on an fsync-bound machine: the whole 2s budget went on the persist, not the batch. Loosening the threshold would have hidden a real blocking call, so both handlers wrapcreate_batchinrun_in_threadpool(the patterndiagnose_v2/environment_v2already use). The handlers are now non-blocking end to end.Acceptance criteria
start_batchsplit into create-record and execute phasesconductor.create_batch/conductor.execute_batchPOST /tasks/executereturnsbatch_idimmediatelytest_execute_returns_while_the_batch_is_still_runningtest_health_responds_while_a_batch_runsBackgroundTasksparameter is used or removedOn the tests
They assert the properties that actually distinguish fixed from broken — the handler returns while the batch is still running, and a concurrent request is served meanwhile. A plain
assert status_code == 200passes on the broken code.Two details worth noting:
/healthrequest is issued with the execute POST still in flight (asyncio.create_task). Awaiting the POST first would let a blocking handler finish before/healthis even sent — the timing would look fine on exactly the code this is meant to catch. My first draft made that mistake and the mutation check caught it.Eventthe test releases rather than sleeping, so "still running" is a controlled fact instead of a wall-clock race, and teardown releases it so no detached thread leaks into the next test's timing budget.Mutation-verified: replacing the
threading.Thread(...)dispatch with a direct inline call fails 4 of the 5 tests. The fifth pins persist-before-response, which is true either way by design.Verification
test_conductor.pysuite, the combination that surfaced the persist-on-loop blocking.ruffclean, strictmypyclean on all 192 files.Known limitations
cf work batch resumealready covers recovery, andexecute_batchreading its config off the record is what makes resume behave identically.