Add fiber worker execution mode#728
Conversation
|
I've pushed my initial implementation for this to #729 for comparison. Feel free to take anything you deem useful. :) |
|
Hi @rosa, I've published a benchmark harness that answers three questions:
https://github.com/crmne/solid_queue_bench Solid Queue: async vs thread
The cleanest result is DB pool ceiling (stress suite)The headline suite caps total concurrency to keep comparisons fair. The stress suite removes that cap. Async::Job vs Solid QueueAsync::Job + Redis is faster across all shared tests (+7% to +213%), but that's a different backend entirely -- a throughput ceiling reference, not a same-backend comparison. Bottom lineThe main |
|
I'm running this code in production at Chat with Work right now. Switched from |
|
Also running this in production and can confirm it works very well. |
|
I wanted to highlight something to make sure it's more visible to others... it looks like this setup would allow for separately configured worker pools, so one could imagine putting "mostly waiting on LLMs or slow APIs" jobs into an async pool, and the "regular" work could be left on a thread pool. For example, in the PR changes here, you can see a config example like so: - queues: "llm*"
execution_mode: async
- queues: [ real_time, background ]
execution_mode: thread |
|
Just a heads-up: I'm planning to write a blog post about this PR! :) |
|
Hey @crmne, sorry for the delay here. I'm swamped with other work and haven't had the time to look into this at all. It's a big diff so I need time. I also need to figure out the naming around this, because we already have an async execution mode unrelated to this, so the naming needs to be adjusted. I'm really thankful for the contribution, but I'm afraid I won't have time to review this in the near term. Of course, you're welcome to write whatever blog post you want 😊 |
|
Hey @rosa, didn't mean to add any pressure - I totally get being swamped with other committments. Does |
|
Oh, no, no worries! I didn't think you were trying to add pressure, it's just that I felt really bad about seemingly ignoring this 😅 Yes! |
|
How about: |
I like that, but mixing fibers and threads wouldn't make sense. |
Right, sorry, that was a copy/paste from my early implementation. If this one doesn't expose threads, just ignore that line. :) |
|
Maybe it could just be |
I like it. Will implement that during the weekend. |
|
I updated it accordingly, avoiding the need of a separate I also renamed the worker implementation/docs away from "async", using "fiber" and "worker concurrency model" to avoid confusion. |
|
https://paolino.me/solid-queue-doesnt-need-a-thread-per-job/ in case people are interested |
| ActiveSupport::IsolatedExecutionState.isolation_level == :fiber | ||
| end | ||
|
|
||
| def ensure_io_timeout_compatibility!(io_class = IO) |
There was a problem hiding this comment.
Not sure about monkey patching here. Maybe just raise the required ruby version?
|
|
||
| # Older versions of the async gem don't support waking the reactor from another | ||
| # thread reliably, so we cooperatively poll for newly posted work. | ||
| sleep(IDLE_WAIT_INTERVAL) if pending_executions.empty? |
There was a problem hiding this comment.
This creates a busy-poll loop at 100hz per worker?
Would it be better to raise the min required async gem version to async >= 2.29 (thread safety improvements) so you can replace the poll loop with Async::Notification or Async::Queue?
gobijan
left a comment
There was a problem hiding this comment.
Raise async gem version to avoid busy-poll loop?
|
Similarly would be cool to see this work be introduced into Rails core so we could set |
rsamoilov
left a comment
There was a problem hiding this comment.
In my opinion, if the configuration is called fibers, it needs to be backend-agnostic. Which means checking Fiber.scheduler instead of defined?(Async), using Fiber.schedule {} instead of Async {}, and SizedQueue instead of Async::Semaphore - thus completely relying on the standard library and being implementation-agnostic.
Good design is the one that survives change, but this executor can't survive change by definition, as it locks the implementation to a specific backend. Async is the de facto standard, but there are other implementations, and hopefully there will be many more. IMO using Async belongs to the application layer, and tying the executor to the specific implementation of the scheduler would lock users out from using other (perfectly functional) implementations in the future. To me, this coupling doesn't make sense in the framework code, especially when the standard library exposes clean hooks that allow the executor to remain implementation-agnostic.
Essentially, I'd suggest either using a backend-agnostic implementation or updating the fibers configuration name to specifically point out that it's powered by Async.
|
That's a good point, @rsamoilov 🤔 |
|
Thanks @rsamoilov. I agree that a backend-agnostic fiber executor could be worth exploring, but I don't think that should block this PR. To me, This PR implements the concrete version we have today: a fiber worker backed by Async. That implementation is done, tested, documented, benchmarked, and already running in production. It also handles the Solid Queue-specific parts that matter here: bounded in-flight jobs, lifecycle, shutdown, error propagation, worker wakeups, and the DB-pool guidance around fiber execution. I agree that making the Async-specific pieces backend-agnostic may be worth exploring. But I don't think that's a mechanical naming fix; it's a separate design exercise. It should be proven against at least one real alternative scheduler, with the same lifecycle/shutdown/error behavior and benchmarks showing what users gain from the extra abstraction. So my preference is to land this PR as the initial fiber worker implementation, keeping the I'm happy to make the docs explicit that the current fiber worker is powered by Async, but I don't think we should hold up the concrete implementation people can use today for an abstraction that hasn't been demonstrated yet. |
It also introduces a level of indirection, as the current implementation doesn't really use fibers. It uses Async, which uses fibers under the hood. I work with fibers a lot, so I thought I'd share my perspective - for something that can end up in a Rails default job backend, that's a meaningful architectural choice. |
That's not accurate. Async is built on Ruby fibers; this implementation provides fiber-based concurrency via Async. I agree we should be explicit about that, so I'm happy to document the current worker as Async-backed. But I don't think that turns this PR into a generic scheduler abstraction. That's a separate design with real tradeoffs: lifecycle, shutdown, error propagation, DB pool behavior, and benchmarks against a concrete alternative. So I think the decision here is simple: land the Async-backed fiber worker now, or require a broader adapter layer before any fiber worker can ship. My preference is strongly the former. If someone wants scheduler pluggability, that should be a follow-up PR with code, tradeoffs, and numbers, not a blocker for this one. |
|
Were there any updates on the ETA for this PR? It would be really helpful to have this functionality! |
|
Hey @will-s-stone, no ETA. I'm still swamped with other work and not sure when I'll have time to work on this. |
|
This seems like exactly what we need. Thanks @crmne for the work! |
|
Hey! I'm finally catching up with PRs and issues this week, so I'll get to this one in the coming days. Thanks @crmne and everyone else for your patience 🙏🏻 |
|
|
||
| def ensure_io_timeout_compatibility!(io_class = IO) | ||
| unless io_class.method_defined?(:timeout) && io_class.method_defined?(:timeout=) | ||
| # Async 2.24, which Ruby 3.1 resolves to, expects Ruby's newer IO |
There was a problem hiding this comment.
I think I'm going to stop supporting Ruby 3.1. It's time, it's been EOL since 2025-03-26. In that way, we can simplify this I think.
|
@rsamoilov, @crmne, I've thought about the explicit Async vs backend-agnostic question, and I agree with @crmne here. Async is already tested and benchmarked for this PR, so I think we should go with it. The only exposed configuration, Thanks a lot for your perspective, @rsamoilov, really appreciate it! I don't work with fibers, so your insight has been very useful for me. |
Workers can now be configured with `fibers: N` as an alternative to `threads: N` (specifying both is invalid). In fiber mode, claimed jobs execute as fibers on a single Async reactor thread, bounded by the configured fiber count. This suits mostly I/O-bound jobs, where cooperative scheduling allows much higher in-flight capacity with fewer database connections than a thread pool. The existing pool was extracted into ExecutionPools::ThreadPool (SolidQueue::Pool remains as an alias), with the new ExecutionPools::FiberPool sharing the same interface. Pools are built on worker boot, after forking, since a reactor thread wouldn't survive a fork. Fiber mode requires the async gem (>= 2.24), which is not a runtime dependency: workers configured with `fibers` raise a clear error if it isn't available. It also requires fiber-scoped isolated execution state (ActiveSupport::IsolatedExecutionState.isolation_level = :fiber), validated at boot and configuration-check time. The database pool size guidance is fiber-aware and Rails-version aware: on Rails 7.2+, fibers release connections between queries, so a fiber worker needs far fewer connections than its fiber count.
Now that the minimum supported Ruby version is 3.2, IO#timeout, IO#timeout= and IO::TimeoutError, which async expects, are always available natively, and we no longer need to patch them in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fiber pool's reactor looped with a 10ms sleep to check for newly posted work, and again while waiting for in-flight executions to finish during shutdown, on the assumption that the async gem can't reliably wake the reactor from another thread. It can: Thread::Queue#pop is fiber-scheduler-aware, so a fiber blocking on it suspends properly while other fibers run, and a push from the poller thread wakes the reactor through the scheduler (via Scheduler#unblock and the selector's cross-thread wakeup, reliable since io-event 1.3, which all supported async versions require). This removes the idle CPU cost of the busy-poll and the up-to-10ms latency between claiming a job and executing it. Shutdown now closes the queue, which wakes the blocked pop, drains any already-queued executions and then returns nil, ending the loop. The separate wait for in-flight executions is unnecessary: they run in fibers that are children of the reactor's top-level task, which already waits for all its children before exiting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover the full lifecycle of a supervised, forked fiber worker: jobs executing in fibers, graceful termination draining in-flight jobs, and abrupt termination releasing claimed jobs. The unit tests exercise the fiber pool in-process only, which wouldn't catch a pool built before forking, whose reactor thread wouldn't survive the fork. Also restore the generic first worker in the configuration example, adding the fiber worker as an extra entry instead of replacing it, and note that the fiber isolation level is an application-wide setting, not scoped to Solid Queue's processes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2f845aa to
3559d3e
Compare
|
|
||
| def validate_execution_options!(options) | ||
| if options.key?(:threads) && options.key?(:fibers) | ||
| raise ArgumentError, "Workers can specify either `threads` or `fibers`, but not both." |
There was a problem hiding this comment.
I think this belongs in the configuration validation.
| def mark_metadata_dirty | ||
| metadata_state_mutex.synchronize { @metadata_dirty = true } | ||
| end | ||
|
|
||
| def metadata_state_mutex | ||
| @metadata_state_mutex | ||
| end | ||
|
|
||
| def reload_metadata_if_needed(executions_claimed) | ||
| needs_reload = metadata_state_mutex.synchronize do | ||
| claimed_or_dirty = executions_claimed || @metadata_dirty | ||
| @metadata_dirty = false | ||
| claimed_or_dirty | ||
| end | ||
|
|
||
| reload_metadata if needs_reload |
There was a problem hiding this comment.
I think we can simplify all this, and just rely on ClaimedExecution records, instead of having to keep this denormalized metadata up to date. I don't think it's worth it. I'll push some changes to this.
|
@crmne, I prepared a few changes on top of your work. It was getting too big to just push it here as I did with a few smaller changes before, so I've opened crmne#1 for you to check. Core functionality doesn't change except for some defensive code and nicer errors for private APIs, which I think we can do without. The largest change is removing the dynamic metadata from workers and the need to keep the inflight capacity in sync. |
Summary
Hi @rosa, I finally had some time to work on this after our earlier conversation about async worker execution mode.
This PR is a first implementation of async worker execution mode for Solid Queue. I'm also running benchmarks in parallel and can follow up with numbers once I have stable results.
Workers can now be configured with
execution_mode: :async(or:fiber), which runs claimed jobs as fibers on a single async reactor thread and bounds concurrency withcapacity/fibersinstead of a thread pool.This is separate from supervisor
asyncmode. Supervisor mode still controls whether managed processes run in forks or threads; this change adds an async execution backend for workers themselves.What Changed
SolidQueue::ExecutionPools::AsyncPoolSolidQueue::ExecutionPools::ThreadPoolexecution_mode: :asyncand:fiberas a configuration aliascapacity/fibersas the clearer async-worker concurrency optionsConfiguration / Validation
This PR also tightens async worker validation:
asyncgem is not availablethreads:and requirecapacityorfibersinsteadDatabase pool guidance is now Rails-version-aware:
The README now documents the version-specific DB-pool guidance and calls out sticky Active Record APIs that can still pin connections.
Why
The goal is to support cooperative, mostly I/O-bound job execution with lower thread overhead and a clearer concurrency model, while keeping the feature explicit and safe to configure.
Tests
Added and updated coverage for: