‹ Back to Blog

GoodJob 4.22 Deprecates Concurrency Keys: How to Move to Labels

Engineering Ruby Rails

GoodJob 4.22.0 shipped on October 9 with three deprecations that will turn into errors in GoodJob v5. Concurrency controls built on good_job_control_concurrency_with, or on good_job_concurrency_rule with a key: and no label:, now log a deprecation warning. The max_threads setting is renamed to threads.

Nothing breaks today. GoodJob v5 will raise on the old concurrency API and drop the good_jobs.concurrency_key column, so this is the release to migrate on while the old code still works. Scout Monitoring traces GoodJob jobs in Rails apps, and concurrency rules are one of the places where a quiet config change can show up as a pile of failed jobs. This post covers the migration, a deploy gotcha worth planning around, and what throttled jobs look like in your monitoring.

What 4.22 Deprecates

Concurrency limits in GoodJob have been scoped to a key stored in the concurrency_key column. Version 4.22 moves everything onto labels, the same tags you can already search for in the GoodJob dashboard. Three things now warn:

  • good_job_control_concurrency_with is deprecated and will be removed in v5, along with the concurrency_key column.
  • good_job_concurrency_rule with key: and no label: is deprecated and will raise in v5.
  • max_threads is renamed to threads. That covers the option, config.good_job.threads, the GOOD_JOB_THREADS environment variable, and the --threads CLI flag. The old names still work but log a warning.

The concurrency warnings fire once, when the job class is defined, through GoodJob.deprecator. If you route Rails deprecations to your logs, you’ll see them at boot.

Migrating a Key to a Label

The concurrency limits themselves don’t change. total_limit, enqueue_limit, perform_limit, enqueue_throttle, and perform_throttle all work the same way. Only the scoping moves from a key to a label. Here is the example from GoodJob’s own migration guide:

class MyJob < ApplicationJob
  include GoodJob::ActiveJobExtensions::Concurrency

  # Before
  good_job_control_concurrency_with(total_limit: 1, key: -> { "MyJob-#{arguments.first}" })

  # After
  self.good_job_labels = [-> { "MyJob-#{arguments.first}" }]
  good_job_concurrency_rule(total_limit: 1, label: -> { "MyJob-#{arguments.first}" })
end

A labelled rule only applies to jobs that carry its label, which is why the label appears twice. Version 4.22 also adds apply_label: true, which tells the rule to put its own label on every job of the class:

good_job_concurrency_rule(
  label: -> { "MyJob-#{arguments.first}" },
  apply_label: true,
  total_limit: 1
)

That keeps the two from drifting apart. The label is resolved when the job is initialized, so it’s in place for both the enqueue-time and perform-time checks, including jobs enqueued in bulk with perform_all_later.

A few cases need more thought than a find-and-replace:

Rules with no key. If you never passed key:, GoodJob used the job class name. Use the class name as your label.

Subclasses with key: nil. Some apps disabled an inherited rule by setting key: nil in a subclass. Rules are still inherited, but a labelled rule only applies to jobs that carry the label, so the equivalent is to not apply the label in that subclass. A Lambda label that returns nil is not applied, which gives you a clean opt-out.

Static keys. A fixed string key becomes a fixed label: self.good_job_labels = ["my-key"] with label: "my-key" on the rule.

The Deploy Gotcha: Jobs Already in the Queue

This is the part most likely to surprise you. Jobs that were enqueued before your change don’t have the new label, so the new rule doesn’t count them. GoodJob’s README states it directly: limits may be exceeded until those jobs finish.

For a rule like total_limit: 1 that guards against two syncs running for the same account, that means a short window after deploy where duplicates can slip through. Whether that matters depends on what the rule protects. For a rule that prevents duplicate emails, a few minutes of overlap might be fine. When it protects an external API with a strict rate limit, deploy the change when the affected queue is drained, or pause enqueuing for those jobs until the old ones clear.

What Throttled Jobs Look Like in Your APM

GoodJob enforces perform_limit with what its docs call “optimistic retry with an incremental backoff.” When a job starts and the limit is already reached, GoodJob raises GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError from a before_perform callback. A retry_on handler catches it and reschedules the job a little later. With the good_job_concurrency_claims table migrated, the longest-waiting job is rescheduled as soon as a slot frees up.

The job’s code never runs on those attempts, but your monitoring can still see them. Scout’s agent wraps the whole Active Job perform cycle, callbacks included, so the exception passes through it before retry_on handles it. We confirmed the ordering with a minimal Active Job setup that mirrors how GoodJob and Scout’s agent register their callbacks: the wrapper sees ConcurrencyExceededError, the job body doesn’t run, and the job is re-enqueued.

In Scout, each throttled attempt shows up as an errored run of that job, with ConcurrencyExceededError in your error groups. It looks alarming at first, but it’s useful information, because the number tells you whether your concurrency strategy fits your workload. GoodJob describes perform_limit as built for collisions that are atypical, like two users triggering the same job at the same moment. If Scout shows a steady stream of these errors on one job, collisions are the normal case, not the exception.

For that situation, GoodJob’s documentation recommends controlling concurrency with the number of threads dedicated to a queue, for example good_job --queues "serial:1;-serial:5", and keeping perform_limit as a backstop. Scout’s queue time and job metrics make it easy to check the effect: after the change, the error count on that job should drop, and its queue time shows whether work is now waiting longer in the queue instead.

A Checklist for the Upgrade

Before you bump to 4.22 and start on the migration:

grep -rn "good_job_control_concurrency_with" app/
grep -rn "good_job_concurrency_rule" app/ | grep "key:"
grep -rn -E "max_threads|GOOD_JOB_MAX_THREADS|--max-threads" config/ Procfile* .env* 2>/dev/null

Then convert each key to a label, decide how to handle jobs already in the queue when you deploy, and rename max_threads to threads. Check your logs after boot for any remaining GoodJob.deprecator warnings. A clean boot means you’re ready for v5.

Running GoodJob in production? Scout Monitoring traces every job with its queue time, errors, and database queries, so you can see how your concurrency rules behave under real traffic. Start for free.