feat: Delayed::Limit (a concurrency limiter)#116
Open
smudge wants to merge 5 commits into
Open
Conversation
smudge
force-pushed
the
smudge/gcra-limiter
branch
from
July 17, 2026 20:10
4f68e8e to
12f1bae
Compare
smudge
force-pushed
the
smudge/gcra-limiter
branch
from
July 17, 2026 20:19
12f1bae to
b82d3d4
Compare
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.
The
Delayed::Limitclass provides a database-backed concurrency limiter/optimizer for jobs (via a Generic Cell Rate Algorithm implemented in SQL+Ruby). Use it to (e.g.) stay under a third-party API's published rate limit, or to keep from overwhelming a downstream datastore.The recommended interface is
with_limit, provided via theDelayed::Limitablemodule (which is included in allActiveJobclasses by default):The limiter will then attempt to maximize throughput without exceeding the limit. If the limit would be exceeded within a configurable timeout, the job will immediately end and enqueue a retry attempt with polynomial backoff. Because its state lives in the database, the limit applies across every worker and process at once.
Plain (non-ActiveJob) classes may
include Delayed::Limitableto usewith_limit, but they must define their own rescheduling/lifecycle behavior forDelayed::Limit::LimitExceededErrorerrors.Setup
To use this feature, make sure you have a
delayed_limitstable, or runrake delayed:install:migrationsto add it (see "Database Setup" in thedelayedREADME).As of now, only PostgreSQL and SQLite (3.35+) are supported. The primary SQL query relies on an upserting
RETURNINGclause and database-native timestamp arithmetic. You can check the current connection at runtime withDelayed::Limit.supported?.Traffic Shaping vs Traffic Enforcement
By default, the limiter will
sleepup to 5 seconds (or a specifiedwait_timeout) before yielding to the limited work. (This behavior is subject to the usual GIL and OS scheduling, so treat the configured rate as a best-effort target rather than a hard guarantee.)Use a longer
wait_timeoutfor even better throughput smoothing (at the cost of blocking threads):Or set it to
0to fail fast, so that the job never blocks a worker thread:Customizing
with_limitThe purpose defaults to the job's underscored class name, so it may be omitted entirely if the limit is not shared with any other class:
Use
on:to wrap one or more other instance methods instead ofperform(e.g. if only a portion of the job's work is subject to the limit):For ActiveJob classes only, use
retry_attempts:,retry_wait:, andretry_jitter:to customize the retry behavior. (By default, jobs retry indefinitely with a polynomial backoff, with thewait_timeoutacting as a floor on the computed wait.) Ifwith_limitis declared multiple times on the same class (e.g. to apply different limits to different methods), only the first declaration defines the job's retry behavior.Manually Limiting a Block of Code
To rate limit code that doesn't belong to a job class, call
Delayed::Limit.within_limitdirectly. It accepts the samepurpose,max:,per:, andwait_timeout:arguments aswith_limit, and wraps the limited work in a block:The key difference is that there is no built-in retry behavior: if the limit would be exceeded within the
wait_timeout, the call raisesDelayed::Limit::LimitExceededErrorimmediately, and it is up to the caller to rescue and/or retry.Shared Limits
To avoid repeating the same purpose's
maxandperacross multiple call sites, register a limit in advance (e.g. in an initializer):Then, reference it by just its name at each declaration:
Registered limits are cached indefinitely in memory and are not thread-safe on write, so avoid registering them dynamically or at runtime!
Handling "Burst" Throughput
As of now, there is no "burst" capacity. The limiter allows one call per "drain interval" (
per / max), so a limit of 60-per-minute behaves identically to 1-per-second (and will not allow more than 1 call in the first second).This is generally acceptable for background job processing (and for traffic shaping in general), but may be revisited in the future in order to support use cases like API traffic enforcement.
Monitoring Limit Usage
Each call emits an
ActiveSupport::Notification(delayed.limit.within_limitordelayed.limit.exceeded), so you can monitor limiting activity the same way you would any other event (see "Monitoring Jobs & Workers" in thedelayedREADME).