Add dynamic concurrency limits - #794
Draft
p-larson wants to merge 10 commits into
Draft
Conversation
Store the last evaluated cap and a generation so a key can resize without remaining-slot math going stale.
Memoize proc results in process memory by key and generation. Integer to: stays remaining-slot math; to: 0 refuses admit. Concurrency.refresh resizes slots and unblocks or reblocks to match.
Cover integer to: unchanged, proc caps, to: 0, in-process memo, and refresh up, down, and pause.
Describe the enqueue key vs admit cap split, in-process memo, and the additive semaphore columns.
Existing installs load queue_schema once; they need install:migrations rather than hand-edited add_column.
Enqueue is still per-job; the new tax is proc eval (memoized in process) and row-by-row reblock on refresh decrease.
key: is identity at enqueue; to: is evaluated on admit and memoized per process for concurrency_limit_cache_ttl.
Keep key: as stable identity; putting the cap in the key makes resize a remap. Width is min(limit, waiting jobs) per key.
Threads already share it; forked workers each get their own. Sharing across forks would need Rails.cache.
Five workers times 20 threads is five Tenant.finds for a key in the window, not 100.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
key:can vary per job.to:is a class integer. That is enough when every job in a class shares one cap.It is not enough when many tenants share one worker fleet and each tenant has its own cap (SLA, downstream capacity, pause). Then:
queue_as/set(queue:)only works if that queue already exists and has workers. A new bucket is topology (a deploy), not an admin write.Queue + worker count remains the right throttle for a kind of work. This is for a cap per key on a shared fleet.
Concurrency controls have real overhead (blocked rows, no bulk enqueue, semaphore updates). This path accepts that so isolation is the semaphore, not a queue per tenant.
API
key:who shares the pool. Computed at enqueue. Stable identity only —"tenant/123", not"tenant/123/8". Stamping the cap into the key makes a resize a new key (two semaphores, jobs on the old string, a remap). That was a workaround for concurrency not storing the cap. The cap lives onsolid_queue_semaphores.limit.to:the cap for that key. Integer, or a proc of the job arguments, evaluated on admit. Integerto:is a constant (same remaining-slot path as 1.3.2). A proc is the extra cost: deserialize arguments and run it (hereTenant.find).ActiveSupport::Cache::MemoryStoreper Ruby process (class instance, Monitor-synchronized). All threads in that process share it — it is not thread-local. Keyed by concurrency key +generation, forSolidQueue.concurrency_limit_cache_ttl(default30.seconds). Later jobs for the same key in that process reuse the memo and do not run the proc. Set tofalseto eval every admit. Forked workers each have their own store. NotRails.cache.refresh(key, to:)applies a new cap to work already blocked or ready, bumpsgenerationso other processes drop their memo on the next wait, unblocks on increase, reblocks excess ready on decrease. Claimed jobs finish. Passto: 0to pause that key.Scenarios
to:to: 0, which now refuses admit).to: 2, four jobslimit. Proc runs once per process per TTL.refreshgeneration.refreshrefresh(..., to: 0)refreshwaitafter TTL (or a miss) sees the new proc value. Already-queued jobs stay put untilrefresh.Fleet size
You do not need the cap in the key to size the fleet. Join waiting jobs to
solid_queue_semaphoreson the uniquekeyand readlimit. No Tenant query, no string parse.Two numbers:
min(stored limit, ready + blocked). A tenant with cap 8 and 50k blocked jobs still only asks for 8 slots. Extra tasks let more tenants run at their own cap; one tenant stays at 8.COALESCE(s.limit, 1)matches 1.3.2 until the next wait backfillslimit.Performance
The cost this PR adds is
to:when it is a proc.On admit we deserialize arguments and run
to:(theTenant.findin the snippet). That is the query you would otherwise pay on every enqueue.SolidQueue.concurrency_limit_cache_ttl(default30.seconds) is the lever:generation, still within TTL → reuse the integer, skip the proc.refreshbumpedgeneration, or TTL isfalse→ run the proc again.The memo is one
MemoryStoreper Ruby process. Threads in that process share it. Forked workers do not. One entry per key this process has admitted in the TTL window.The bound is per key, per TTL, per process — not per thread. Five worker processes with 20 threads each: at most 5 proc/DB hits for
tenant/123in that window, not 100. A different key is a different bound. Enqueue-side processes (Puma, etc.) each have their own store, so add one miss per those processes too.Integer
to:does not use the cache and stays on the 1.3.2 remaining-slot path (exceptto: 0). The two extra columns are cheap.refreshdecrease is an operational spike (reblock ready jobs one row at a time), not the enqueue path.Concurrency controls themselves (blocked rows, no bulk, semaphore
FOR UPDATE) are unchanged and still the dominant baseline. Use a procto:when the cap is per key on a shared fleet. Prefer queues and worker counts when the cap is for a kind of work.Changes
solid_queue_semaphores.limit(nullable) andgeneration(default 0). Existing rows keep remaining-slot math until the nextwaitbackfillslimit.AddLimitAndGenerationToSolidQueueSemaphoresfor existing installs (bin/rails solid_queue:install:migrations, thendb:migrate). New installs get the columns fromdb/queue_schema.rb(schema version2026_08_26_120000so a later migrate does not add them twice).to:on wait, in-process memo keyed by concurrency key + generation (SolidQueue.concurrency_limit_cache_ttl).SolidQueue::Concurrency.refresh(key, to:).Related: #228 (callable
to:, closed as not planned). This addsrefreshso a cap change applies to work already in the queue, which a proc-on-next-wait alone does not do.