Skip to content

Feature: variable worker concurrency - #1557

Open
thigger wants to merge 4 commits into
procrastinate-org:mainfrom
thigger:flex-concurrency-mod
Open

Feature: variable worker concurrency#1557
thigger wants to merge 4 commits into
procrastinate-org:mainfrom
thigger:flex-concurrency-mod

Conversation

@thigger

@thigger thigger commented Jun 3, 2026

Copy link
Copy Markdown

For initial discussion; happy to sort documentation etc if you're happy with this route. I'm developing on Windows so a lot of tests fail on main at the moment; I'll have a look at spinning up WSL to run tests if this approach is OK. It seems to be working fine for me.

Works by allocating a larger semaphore (=concurrency plus buffer); you can then vary concurrency from zero to initial concurrency+buffer.
On buffer=0 should behave the same as the original worker.
Added app.run_worker_async_background() which returns the worker so its concurrency can be adjusted.

Related: ticket #1552

Successful PR Checklist:

  • Tests
    • (not applicable?)
  • Documentation
    • (not applicable?)

PR label(s):

Summary by CodeRabbit

  • New Features
    • Added optional buffer concurrency to reserve part of worker capacity for pre-fetch buffering.
    • Workers can be started as embedded background asyncio tasks and returned immediately for later control.
    • Runtime adjustment of active concurrency without restart.
    • New readouts for running job count and spare processing capacity.

@thigger
thigger requested a review from a team as a code owner June 3, 2026 17:26
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0305616f-e0c7-4b50-a4fb-e4401ecb27fe

📥 Commits

Reviewing files that changed from the base of the PR and between 0d84aec and 46ad554.

📒 Files selected for processing (2)
  • procrastinate/app.py
  • procrastinate/worker.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • procrastinate/app.py
  • procrastinate/worker.py

📝 Walkthrough

Walkthrough

Adds optional buffer_concurrency to worker configuration and runtime behavior (semaphore pre-acquire, held buffer slots, fetch-time buffering, and runtime scaling). Adds App.run_worker_async_background() to start a Worker as an asyncio background task and return it immediately.

Changes

Buffer Concurrency and Background Worker Support

Layer / File(s) Summary
Buffer Concurrency Configuration
procrastinate/app.py, procrastinate/worker.py
WorkerOptions TypedDict adds optional buffer_concurrency; Worker.__init__ accepts and validates buffer_concurrency, computes total_capacity = concurrency + buffer_concurrency, and initializes _held_buffer_slots.
Semaphore Lifecycle and Buffer Initialization
procrastinate/worker.py
_job_semaphore is initialized with total_capacity. At _run_loop startup, buffer_concurrency permits are pre-acquired and recorded in _held_buffer_slots; on shutdown, held permits are released.
Runtime Concurrency Controls
procrastinate/worker.py
set_concurrency() adjusts active concurrency within total_capacity bounds, releasing held buffer permits when increasing. New running_jobs_count and spare_concurrency properties expose current state.
Job Fetch with Buffer Slot Management
procrastinate/worker.py
_fetch_and_process_jobs may hold acquired semaphore slots as buffer capacity, skips fetching until buffer-held slots reach buffer_concurrency, and alters semaphore release logic for buffering and shutdown cases.
Background Worker Entry Point
procrastinate/app.py
App.run_worker_async_background() creates a worker via _worker(), defaults install_signal_handlers=False when not provided, launches worker.run() as a background asyncio task named from worker.worker_name, stores it on worker.run_task, and returns the worker immediately.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I nibble slots and count each one,
A buffer stashed before the run;
Background hops, a silent start,
Concurrency split like carrot art. 🐇🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feature: variable worker concurrency' directly corresponds to the main changes: introducing buffer_concurrency and set_concurrency() for runtime adjustment of worker concurrency.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
procrastinate/worker.py (1)

39-58: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject invalid capacity combinations in Worker.__init__
concurrency/buffer_concurrency are not validated, so total_capacity = concurrency + buffer_concurrency can be 0 (or negative). In that case _run_loop() creates asyncio.Semaphore(self.total_capacity), and _fetch_and_process_jobs() blocks forever on the first acquire() when total_capacity == 0; set_concurrency() can’t recover because it’s capped by the same total_capacity. Validate concurrency >= 0, buffer_concurrency >= 0, and concurrency + buffer_concurrency > 0.

Possible fix
         self.worker_name = name
+        if concurrency < 0 or buffer_concurrency < 0:
+            raise ValueError(
+                "concurrency and buffer_concurrency must be non-negative"
+            )
+        total_capacity = concurrency + buffer_concurrency
+        if total_capacity <= 0:
+            raise ValueError(
+                "concurrency + buffer_concurrency must be greater than 0"
+            )
         self.concurrency = concurrency
         self.buffer_concurrency = buffer_concurrency
-        self.total_capacity = concurrency + buffer_concurrency
+        self.total_capacity = total_capacity
         self._held_buffer_slots = 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@procrastinate/worker.py` around lines 39 - 58, Validate the concurrency
inputs in Worker.__init__: ensure concurrency and buffer_concurrency are
integers >= 0 and that total_capacity = concurrency + buffer_concurrency is > 0;
if any check fails, raise a ValueError with a clear message. Update the
constructor (Worker.__init__) to perform these checks before computing
total_capacity so that downstream methods like _run_loop (which creates an
asyncio.Semaphore(self.total_capacity)), _fetch_and_process_jobs (which acquires
that semaphore), and set_concurrency won’t deadlock on a zero or negative
capacity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@procrastinate/worker.py`:
- Around line 39-58: Validate the concurrency inputs in Worker.__init__: ensure
concurrency and buffer_concurrency are integers >= 0 and that total_capacity =
concurrency + buffer_concurrency is > 0; if any check fails, raise a ValueError
with a clear message. Update the constructor (Worker.__init__) to perform these
checks before computing total_capacity so that downstream methods like _run_loop
(which creates an asyncio.Semaphore(self.total_capacity)),
_fetch_and_process_jobs (which acquires that semaphore), and set_concurrency
won’t deadlock on a zero or negative capacity.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 722d5793-51b6-4fbd-92e7-f7c3f74b2e00

📥 Commits

Reviewing files that changed from the base of the PR and between 518d5a8 and 0d84aec.

📒 Files selected for processing (2)
  • procrastinate/app.py
  • procrastinate/worker.py

@thigger
thigger force-pushed the flex-concurrency-mod branch from 0d84aec to 01f9296 Compare June 3, 2026 18:01
@thigger

thigger commented Jun 3, 2026

Copy link
Copy Markdown
Author

procrastinate/worker.py (1)> 39-58: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject invalid capacity combinations in Worker.__init__

Done (01f9296)

@TheNeedForSleep

Copy link
Copy Markdown

Hi @thigger ,

What is your motivation behind this change?
Juggling available ram?

@thigger

thigger commented Jun 10, 2026

Copy link
Copy Markdown
Author

What is your motivation behind this change? Juggling available ram?

My tasks are fairly long-running (LLM-based) and if too many pile up in the LLM server queue then the kv cache ends up being evicted so things slow down a fair bit; I'm running dynamic concurrency with a watcher that keeps an eye on the LLM server so that the requests don't end up queueing.
Additionally, there's a health check that keeps an eye on the source database as the guys running it seem to have decided to reboot it regularly, and drops concurrency to zero until it's back up - which stops the thundering herd of 12,000 tasks all going for one try before they enter exponential backoff/retry.
Seems to be working pretty well for me now with the code above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants