Concurrency limiting allows precise control over how many jobs can be worked at once, with configurable partitioning options. Limits can be configured globally, across all processes, and locally, per process, and can also be partitioned based on job attributes.
Concurrency limits are a feature of River Pro ✨. If you haven’t yet, install River Pro and run the pro migration line.
Basic usage
Added in River Pro for Go v0.12.0.
Concurrency limits require an up-to-date database schema with the pro migration line:
riverpro migrate-up --database-url "$DATABASE_URL" --line proConcurrency limits are configured per queue by using the ProQueues field on your riverpro.Config:
&riverpro.Config{ Config: river.Config{ Queues: map[string]river.QueueConfig{ "default": {MaxWorkers: 100}, }, Workers: riverdemo.Workers(dbPool), }, ProQueues: map[string]riverpro.QueueConfig{ "external_api_provider": { Concurrency: riverpro.ConcurrencyConfig{ GlobalLimit: 20, LocalLimit: 10, }, MaxWorkers: 20, }, },}A single queue may only be configured in either ProQueues or Queues, but not both. By default, no concurrency limits are applied unless specified in Concurrency using a riverpro.ConcurrencyConfig.
Run the pro migration line after the main River migrations:
bundle exec river migrate-up --database-url "$DATABASE_URL" --line proConfigure limits on entries in River::Pro::Config’s pro_queues:
River::Pro::Config.new( core: River::Config.new(queues: {default: 100}, workers: workers), pro_queues: { "external_api_provider" => River::Pro::QueueConfig.new( concurrency: River::Pro::ConcurrencyConfig.new( global_limit: 20, local_limit: 10 ), max_workers: 20 ) })Pass this config to River::Pro::Client.new(driver, config: ...). A queue can appear in either core.queues or pro_queues, but cannot appear in both. A zero limit means unlimited; both limits default to zero.
For Postgres queues shared across clients, use matching advisory_lock_prefix and advisory_lock_schema values on River::Pro::Config. These select the lock namespace; configure the connection’s search path separately. An empty lock schema and "public" are different lock namespaces.
Concurrency limits build on River’s normal job prioritization, meaning that jobs will still be fetched in order of priority and scheduled_at. However, any job that would be fetched based on normal prioritization will be skipped over if working it would exceed a concurrency limit.
Global limits
Global limits ensure that no more than the specified number of jobs are worked at once globally across all clients working jobs in that queue (and database / schema). Set the global limit in the queue’s concurrency configuration.
If no partitioning is enabled, the global limit applies to all jobs in the queue. With partitioning, the global limit is enforced independently per partition.
Local limits
Local limits ensure that no more than the specified number of jobs are worked at once locally within a single client. Set the local limit in the queue’s concurrency configuration.
If no partitioning is enabled, the local limit applies to all jobs in the queue. With partitioning, the local limit is enforced independently per partition.
Partitioning
By default, concurrency limits are enforced across all jobs in a queue. With partitioning, limits are enforced separately for different subsets of jobs in the queue.
Partitioning can be based on job arguments, job kind, or both.
Partitioning by arguments
Partitioning by job arguments allows limits to be applied independently based on the argument values. A common use case is to partition by customer or tenant, allowing each to have an independent concurrency limit:
&riverpro.Config{ ProQueues: map[string]riverpro.QueueConfig{ "expensive_actions": { Concurrency: riverpro.ConcurrencyConfig{ GlobalLimit: 2, Partition: riverpro.PartitionConfig{ ByArgs: []string{"customer_id"}, }, }, MaxWorkers: 10, }, },}River::Pro::Config.new( core: River::Config.new(workers: workers), pro_queues: { "expensive_actions" => River::Pro::QueueConfig.new( concurrency: River::Pro::ConcurrencyConfig.new( global_limit: 2, partition: River::Pro::PartitionConfig.new(by_args: ["customer_id"]) ), max_workers: 10 ) })Here, a maximum of 2 concurrent jobs per customer run globally, while each client can handle jobs for multiple customers concurrently, up to the queue’s configured worker count.
The ByArgs option must use JSON keys, not Go struct field names. For example:
type MyJobArgs struct { CustomerID int `json:"customer_id"`}To partition by customer, you must specify "customer_id" in ByArgs, not "CustomerID". If a specified field is missing in the job arguments, River will use an empty value; in the example above, all jobs without a customer_id would be partitioned together.
If ByArgs is specified as an empty slice []string{}, all arguments will be used for partitioning.
by_args selects encoded JSON keys. For this argument class, use by_args: ["customer_id"]:
MyJobArgs = Struct.new(:customer_id, keyword_init: true) do def kind = "my_job" def to_json = JSON.generate(to_h)endMissing fields contribute an empty value, so jobs without customer_id share a partition. Nested keys use dotted paths such as "customer.id"; escape a literal dot with a backslash, as in 'region\.code'.
by_args: [] uses all arguments for partitioning.
Partitioning by all arguments
Including all arguments can put every job in its own partition when arguments contain unique values such as timestamps, making limits ineffective. Prefer one or more stable fields.
Partitioning by kind
Partitioning can also be based on job kind. For example, rather than a single global limit of 10 across all job kinds, the following configuration allows up to 10 jobs concurrently for each job kind:
&riverpro.Config{ ProQueues: map[string]riverpro.QueueConfig{ "expensive_actions": { Concurrency: riverpro.ConcurrencyConfig{ GlobalLimit: 10, Partition: riverpro.PartitionConfig{ByKind: true}, }, MaxWorkers: 100, }, },}River::Pro::Config.new( core: River::Config.new(workers: workers), pro_queues: { "expensive_actions" => River::Pro::QueueConfig.new( concurrency: River::Pro::ConcurrencyConfig.new( global_limit: 10, partition: River::Pro::PartitionConfig.new(by_kind: true) ), max_workers: 100 ) })In this example, no more than 10 jobs of any single kind will be worked concurrently across all clients. However, a single client may work up to 100 jobs at once from a mix of kinds.
Adjusting in the UI
The concurrency limit can be overridden in the UI on the queue page. Limit overrides are persisted until removed, or until the queue has had no active clients for a long time.
An example of this can be viewed in the demo app.
FAQ
How do limits interact?
The local limit takes precedence over the global limit. For example, with a global limit of 20 and a local limit of 5, if 10 jobs are already running globally, the client will not fetch more than 5 additional jobs (per partition, if partitioning is enabled).
Each client also respects the queue’s configured worker count. Without partitioning, the smallest of the worker count, the local limit, and remaining global capacity determines how many jobs will be worked. With partitioning, a client may fill its worker slots across partitions, but never exceeds the local or global limit for a single partition.
Why might limits appear exceeded?
Global concurrency limits are strictly enforced during job fetching. Jobs may appear to exceed limits in the database due to asynchronous completion updates, at least when viewed from the jobs table (or job UI in River UI).
River ensures limits are respected at execution time, despite temporary visibility differences. The queue page in River UI reflects the actual running job counts.
Compatibility with other features
Concurrency limits are compatible with all other River and River Pro features like workflows and sequences.