Performance [P1]: Bound async worker task creation - #200
Conversation
`_AsyncWorkerManager._consume_queue()` created an `asyncio.Task` for every dequeued work item and only acquired the concurrency semaphore later, inside `_process_work_item()`. The semaphore therefore throttled execution but not allocation, so during a burst with slow handlers the number of pending task objects grew with the queue depth rather than with the configured concurrency. The loop also rescanned the entire `running_tasks` set on every iteration. The consumer now acquires a semaphore permit *before* dequeuing an item and hands that permit off to the task it creates, which releases it once the work item is done. The queue stays the bounded backlog and in-flight work item tasks never exceed the configured `ConcurrencyOptions` limits. Completed tasks are removed from `running_tasks` via `task.add_done_callback(set.discard)` instead of an O(n) sweep. Semantics are preserved: there is still no await between `queue.get()` returning and `asyncio.create_task()`, so an item can never be dropped by cancellation; the permit is released if the dequeue times out or raises; and `queue.task_done()` is still called exactly once per item, before the permit is released, on both the success and failure paths. Fixes #193 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 010b6832-802b-425d-957c-cf29b7143392
There was a problem hiding this comment.
Pull request overview
This PR fixes a performance issue in the core durabletask async worker manager where asyncio.Task objects were being allocated per queued work item before concurrency throttling applied, causing task allocation (and overhead) to grow with queue depth during bursts. It changes the internal scheduling so task allocation is bounded by ConcurrencyOptions, and improves task-set cleanup efficiency.
Changes:
- Acquire the concurrency semaphore before dequeueing and task creation, and release the permit in the work-item task when finished.
- Replace per-iteration O(n) cleanup of
running_taskswithadd_done_callback(set.discard)for completed-task removal. - Add regression tests validating bounded task allocation and exactly-once
queue.task_done()behavior on failure, plus a changelog entry under## Unreleased.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
durabletask/worker.py |
Bounds allocated in-flight work item tasks to concurrency limits and removes completed tasks via done callbacks. |
tests/durabletask/test_worker_concurrency_loop_async.py |
Adds tests for bounded task allocation and exactly-once task_done() on failure paths. |
CHANGELOG.md |
Documents the user-visible fix under ## Unreleased → FIXED. |
…c-worker-task-creation
The second element of the tuple returned by _start_manager_and_wait_for_queues is an already-created coroutine object, not a callable. Naming the local wait_for_queues made �wait wait_for_queues read like a missing call -- it misled an automated reviewer and would mislead a human too. Rename the local to queues_ready and say so in the docstring. No behavior change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 010b6832-802b-425d-957c-cf29b7143392
| async def run_test(): | ||
| manager.prepare_for_run() | ||
| worker_task, queues_ready = _start_manager_and_wait_for_queues(manager) | ||
| await queues_ready |
…c-worker-task-creation
|
Declining all four instances of the "Statement has no effect" alert on this file (comments What the name is actually bound toThe helper returns its readiness awaitable already called: durabletask-python/tests/durabletask/test_worker_concurrency_loop_async.py Lines 100 to 115 in f75b20e Note the trailing
Both proposed fixes fail
The first was confirmed twice — once with a standalone reproduction, and once by actually applying the suggestion to this file and running it, which failed at precisely the two flagged lines: The second suggestion presumes The underlying claim is falseThe statement demonstrably does have an effect — awaiting the coroutine runs its body, and the side effects are observable: Without that Note The alert re-fired unchanged after the variable was renamed from What was taken from the feedbackThe readability concern underneath the alert was fair, so it was addressed: the misleading local variables were renamed No functional change is warranted here. |
…c-worker-task-creation
Summary
_AsyncWorkerManager._consume_queue()created anasyncio.Taskfor every dequeued work item and only acquired the concurrency semaphore later, inside_process_work_item(). The semaphore therefore throttled execution but not allocation: during a burst with slow handlers, the number of pending task objects grew with the queue depth rather than with the configured concurrency. The loop also rescanned the entirerunning_tasksset on every iteration.Change
ConcurrencyOptionslimits.running_tasksviatask.add_done_callback(set.discard)instead of the O(n) sweep on each loop iteration.Semantics are preserved:
queue.get()returning andasyncio.create_task(), so an item can never be dropped by cancellation in that window (same atomicity as before).queue.task_done()is still called exactly once per item, before the permit is released, on both the success and failure paths (nestedfinallyso atask_done()error still releases the permit, matching the oldasync with semaphore:unwinding order).Verification
test_async_worker_manager_bounds_task_allocation— submits 25 items with a concurrency limit of 2 and asserts peak allocated work item tasks stays<= 2while peak running stays at the full limit of 2 (i.e. concurrency is still fully utilized) and all items complete. Confirmed this test fails onmain(Expected at most 2 concurrently allocated work item tasks, got 25) and passes with the fix.test_async_worker_manager_calls_task_done_exactly_once_on_failure— wrapsqueue.task_donewith a counter and asserts exactly one call per item on the failure path, with the cancellation callback invoked for every item.pytest tests/durabletask -q --ignore-glob="*_e2e.py"→ 683 passed, 7 skipped. (Baseline on this branch before the change: 680 passed, 1 failed —test_client.py::test_sync_client_recreate_cooldown_prevents_immediate_repeated_recreation, a pre-existing timing-sensitive flake unrelated to this change; it passes in the post-change run.)pytest tests/durabletask-azuremanaged/test_sandboxes_extension.py tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py -q→ 42 passed.flake8 durabletask/worker.py tests/durabletask/test_worker_concurrency_loop_async.py→ clean.pyright(strict, repo config) ondurabletask/worker.py→ 0 errors.Fixes #193