Skip to content

Multithreaded, Postgres-based, ActiveJob backend for Ruby on Rails.

License

Notifications You must be signed in to change notification settings

sensedata/good_job

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

GoodJob

Gem Version Test Status Ruby Toolbox

GoodJob is a multithreaded, Postgres-based, ActiveJob backend for Ruby on Rails.

Inspired by Delayed::Job and Que, GoodJob is designed for maximum compatibility with Ruby on Rails, ActiveJob, and Postgres to be simple and performant for most workloads.

  • Designed for ActiveJob. Complete support for async, queues, delays, priorities, timeouts, and retries with near-zero configuration.
  • Built for Rails. Fully adopts Ruby on Rails threading and code execution guidelines with Concurrent::Ruby.
  • Backed by Postgres. Relies upon Postgres integrity, session-level Advisory Locks to provide run-once safety and stay within the limits of schema.rb, and LISTEN/NOTIFY to reduce queuing latency.
  • For most workloads. Targets full-stack teams, economy-minded solo developers, and applications that enqueue 1-million jobs/day and more.

For more of the story of GoodJob, read the introductory blog post.

📊 Comparison of GoodJob with other job queue backends (click to expand)
Queues, priority, retries Database Concurrency Reliability/Integrity Latency
GoodJob ✅ Yes ✅ Postgres ✅ Multithreaded ✅ ACID, Advisory Locks ✅ Postgres LISTEN/NOTIFY
Que ✅ Yes 🔶️ Postgres, requires structure.sql ✅ Multithreaded ✅ ACID, Advisory Locks ✅ Postgres LISTEN/NOTIFY
Delayed Job ✅ Yes ✅ Postgres 🔴 Single-threaded ✅ ACID, record-based 🔶 Polling
Sidekiq ✅ Yes 🔴 Redis ✅ Multithreaded 🔴 Crashes lose jobs ✅ Redis BRPOP
Sidekiq Pro ✅ Yes 🔴 Redis ✅ Multithreaded ✅ Redis RPOPLPUSH ✅ Redis RPOPLPUSH

Table of contents

Set up

  1. Add good_job to your application's Gemfile and install the gem:

    bundle add good_job
  2. Run the GoodJob install generator. This will generate a database migration to create a table for GoodJob's job records:

    bin/rails g good_job:install

    Run the migration:

    bin/rails db:migrate

    Optional: If using Rails' multiple databases with the migrations_paths configuration option, use the --database option:

    bin/rails g good_job:install --database animals
    bin/rails db:migrate:animals
  3. Configure the ActiveJob adapter:

    # config/application.rb or config/environments/{RAILS_ENV}.rb
    config.active_job.queue_adapter = :good_job
  4. Inside of your application, queue your job 🎉:

    YourJob.perform_later

    GoodJob supports all ActiveJob features:

    YourJob.set(queue: :some_queue, wait: 5.minutes, priority: 10).perform_later
  5. In development, GoodJob executes jobs immediately in a separate thread ("async" mode). In production, GoodJob provides different options:

    • By default, GoodJob separates job enqueuing from job execution so that jobs can be scaled independently of the web server. Use the GoodJob command-line tool to execute jobs:

      bundle exec good_job start

      Ideally the command-line tool should be run on a separate machine or container from the web process. For example, on Heroku:

      web: rails server
      worker: bundle exec good_job start

      The command-line tool supports a variety of options, see the reference below for command-line configuration.

    • GoodJob can also be configured to execute jobs within the web server process to save on resources. This is useful for low-workloads when economy is paramount.

      GOOD_JOB_EXECUTION_MODE=async rails server

      Additional configuration is likely necessary, see the reference below for configuration.

Compatibility

  • Ruby on Rails: 6.0+
  • Ruby: Ruby 2.5+. JRuby 9.2.13+
  • Postgres: 10.0+

Configuration

Command-line options

There are several top-level commands available through the good_job command-line tool.

Configuration options are available with help.

good_job start

good_job start executes queued jobs.

$ bundle exec good_job help start

Usage:
  good_job start

Options:
  [--queues=QUEUE_LIST]        # Queues or pools to work from. (env var: GOOD_JOB_QUEUES, default: *)
  [--max-threads=COUNT]        # Default number of threads per pool to use for working jobs. (env var: GOOD_JOB_MAX_THREADS, default: 5)
  [--poll-interval=SECONDS]    # Interval between polls for available jobs in seconds (env var: GOOD_JOB_POLL_INTERVAL, default: 1)
  [--max-cache=COUNT]          # Maximum number of scheduled jobs to cache in memory (env var: GOOD_JOB_MAX_CACHE, default: 10000)
  [--shutdown-timeout=SECONDS] # Number of seconds to wait for jobs to finish when shutting down before stopping the thread. (env var: GOOD_JOB_SHUTDOWN_TIMEOUT, default: -1 (forever))
  [--enable-cron]              # Whether to run cron process (default: false)
  [--daemonize]                # Run as a background daemon (default: false)
  [--pidfile=PIDFILE]          # Path to write daemonized Process ID (env var: GOOD_JOB_PIDFILE, default: tmp/pids/good_job.pid)
  [--probe-port=PORT]          # Port for http health check (env var: GOOD_JOB_PROBE_PORT, default: nil)
  [--queue-select-limit=COUNT] # The number of queued jobs to select when polling for a job to run. (env var: GOOD_JOB_QUEUE_SELECT_LIMIT, default: nil)"

Executes queued jobs.

All options can be configured with environment variables.
See option descriptions for the matching environment variable name.

== Configuring queues

Separate multiple queues with commas; exclude queues with a leading minus;
separate isolated execution pools with semicolons and threads with colons.

good_job cleanup_preserved_jobs

good_job cleanup_preserved_jobs destroys preserved job records. See GoodJob.preserve_job_records for when this command is useful.

$ bundle exec good_job help cleanup_preserved_jobs

Usage:
  good_job cleanup_preserved_jobs

Options:
  [--before-seconds-ago=SECONDS] # Destroy records finished more than this many seconds ago (env var:  GOOD_JOB_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO, default: 1209600 (14 days))

Destroys preserved job records.

By default, GoodJob destroys job records when the job is performed and this
command is not necessary.

However, when `GoodJob.preserve_job_records = true`, the jobs will be
preserved in the database. This is useful when wanting to analyze or
inspect job performance.

If you are preserving job records this way, use this command regularly
to destroy old records and preserve space in your database.

Configuration options

ActiveJob configuration depends on where the code is placed:

  • config.active_job.queue_adapter = :good_job within config/application.rb or config/environments/*.rb.
  • ActiveJob::Base.queue_adapter = :good_job within an initializer (e.g. config/initializers/active_job.rb).

GoodJob configuration can be placed within Rails config directory for all environments (config/application.rb), within a particular environment (e.g. config/environments/development.rb), or within an initializer (e.g. config/initializers/good_job.rb).

Configuration examples:

# config/initializers/good_job.rb OR config/application.rb OR config/environments/{RAILS_ENV}.rb

Rails.application.configure do
  # Configure options individually...
  config.good_job.preserve_job_records = true
  config.good_job.retry_on_unhandled_error = false
  config.good_job.on_thread_error = -> (exception) { Sentry.capture_exception(exception) }
  config.good_job.execution_mode = :async
  config.good_job.queues = '*'
  config.good_job.max_threads = 5
  config.good_job.poll_interval = 30 # seconds
  config.good_job.shutdown_timeout = 25 # seconds
  config.good_job.enable_cron = true
  config.good_job.cron = { example: { cron: '0 * * * *', class: 'ExampleJob'  } }

  # ...or all at once.
  config.good_job = {
    preserve_job_records: true,
    retry_on_unhandled_error: false,
    on_thread_error: -> (exception) { Sentry.capture_exception(exception) },
    execution_mode: :async,
    queues: '*',
    max_threads: 5,
    poll_interval: 30,
    shutdown_timeout: 25,
    enable_cron: true,
    cron: {
      example: {
        cron: '0 * * * *',
        class: 'ExampleJob'
      },
    },
  }
end

Available configuration options are:

  • execution_mode (symbol) specifies how and where jobs should be executed. You can also set this with the environment variable GOOD_JOB_EXECUTION_MODE. It can be any one of:

    • :inline executes jobs immediately in whatever process queued them (usually the web server process). This should only be used in test and development environments.
    • :external causes the adapter to enqueue jobs, but not execute them. When using this option (the default for production environments), you’ll need to use the command-line tool to actually execute your jobs.
    • :async (or :async_server) executes jobs in separate threads within the Rails web server process (bundle exec rails server). It can be more economical for small workloads because you don’t need a separate machine or environment for running your jobs, but if your web server is under heavy load or your jobs require a lot of resources, you should choose :external instead. When not in the Rails web server, jobs will execute in :external mode to ensure jobs are not executed within rails console, rails db:migrate, rails assets:prepare, etc.
    • :async_all executes jobs in separate threads in any Rails process.
  • queues (string) sets queues or pools to execute jobs. You can also set this with the environment variable GOOD_JOB_QUEUES.

  • max_threads (integer) sets the default number of threads per pool to use for working jobs. You can also set this with the environment variable GOOD_JOB_MAX_THREADS.

  • poll_interval (integer) sets the number of seconds between polls for jobs when execution_mode is set to :async. You can also set this with the environment variable GOOD_JOB_POLL_INTERVAL. A poll interval of -1 disables polling completely.

  • max_cache (integer) sets the maximum number of scheduled jobs that will be stored in memory to reduce execution latency when also polling for scheduled jobs. Caching 10,000 scheduled jobs uses approximately 20MB of memory. You can also set this with the environment variable GOOD_JOB_MAX_CACHE.

  • shutdown_timeout (integer) number of seconds to wait for jobs to finish when shutting down before stopping the thread. Defaults to forever: -1. You can also set this with the environment variable GOOD_JOB_SHUTDOWN_TIMEOUT.

  • enable_cron (boolean) whether to run cron process. Defaults to false. You can also set this with the environment variable GOOD_JOB_ENABLE_CRON.

  • cron (hash) cron configuration. Defaults to {}. You can also set this as a JSON string with the environment variable GOOD_JOB_CRON

  • cleanup_discarded_jobs (boolean) whether to destroy discarded jobs when cleaning up preserved jobs using the $ good_job cleanup_preserved_jobs CLI command or calling GoodJob.cleanup_preserved_jobs. Defaults to true. Can also be set with the environment variable GOOD_JOB_CLEANUP_DISCARDED_JOBS. This configuration is only used when {GoodJob.preserve_job_records} is true.

  • cleanup_preserved_jobs_before_seconds_ago (integer) number of seconds to preserve jobs when using the $ good_job cleanup_preserved_jobs CLI command or calling GoodJob.cleanup_preserved_jobs. Defaults to 1209600 (14 days). Can also be set with the environment variable GOOD_JOB_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO. This configuration is only used when {GoodJob.preserve_job_records} is true.

  • cleanup_interval_jobs (integer) Number of jobs a Scheduler will execute before cleaning up preserved jobs. Defaults to 1000. Can also be set with the environment variable GOOD_JOB_CLEANUP_INTERVAL_JOBS.

  • cleanup_interval_seconds (integer) Number of seconds a Scheduler will wait before cleaning up preserved jobs. Defaults to 600 (10 minutes). Can also be set with the environment variable GOOD_JOB_CLEANUP_INTERVAL_SECONDS.

  • inline_execution_respects_schedule (boolean) Opt-in to future behavior of inline execution respecting scheduled jobs. Defaults to false.

  • logger (Rails Logger) lets you set a custom logger for GoodJob. It should be an instance of a Rails Logger (Default: Rails.logger).

  • preserve_job_records (boolean) keeps job records in your database even after jobs are completed. (Default: true)

  • retry_on_unhandled_error (boolean) causes jobs to be re-queued and retried if they raise an instance of StandardError. Be advised this may lead to jobs being repeated infinitely (see below for more on retries). Instances of Exception, like SIGINT, will always be retried, regardless of this attribute’s value. (Default: false)

  • on_thread_error (proc, lambda, or callable) will be called when there is an Exception. It can be useful for logging errors to bug tracking services, like Sentry or Airbrake. Example:

    config.good_job.on_thread_error = -> (exception) { Sentry.capture_exception(exception) }

By default, GoodJob configures the following execution modes per environment:

# config/environments/development.rb
config.active_job.queue_adapter = :good_job
config.good_job.execution_mode = :async

# config/environments/test.rb
config.active_job.queue_adapter = :good_job
config.good_job.execution_mode = :inline

# config/environments/production.rb
config.active_job.queue_adapter = :good_job
config.good_job.execution_mode = :external

Global options

Good Job’s general behavior can also be configured via attributes directly on the GoodJob module:

  • GoodJob.active_record_parent_class (string) The ActiveRecord parent class inherited by GoodJob's ActiveRecord model GoodJob::Job (defaults to "ActiveRecord::Base"). Configure this when using multiple databases with ActiveRecord or when other custom configuration is necessary for the ActiveRecord model to connect to the Postgres database. The value must be a String to avoid premature initialization of ActiveRecord.

You’ll generally want to configure these in config/initializers/good_job.rb, like so:

# config/initializers/good_job.rb
GoodJob.active_record_parent_class = "ApplicationRecord"

The following options are also configurable via accessors, but you are encouraged to use the configuration attributes instead because these may be deprecated and removed in the future:

  • GoodJob.logger (Rails Logger) lets you set a custom logger for GoodJob. It should be an instance of a Rails Logger.
  • GoodJob.preserve_job_records (boolean) keeps job records in your database even after jobs are completed. (Default: true)
  • GoodJob.retry_on_unhandled_error (boolean) causes jobs to be re-queued and retried if they raise an instance of StandardError. Be advised this may lead to jobs being repeated infinitely (see below for more on retries). Instances of Exception, like SIGINT, will always be retried, regardless of this attribute’s value. (Default: false)
  • GoodJob.on_thread_error (proc, lambda, or callable) will be called when there is an Exception. It can be useful for logging errors to bug tracking services, like Sentry or Airbrake.

Dashboard

Dashboard UI

🚧 GoodJob's dashboard is a work in progress. Please contribute ideas and code on Github.

GoodJob includes a Dashboard as a mountable Rails::Engine.

  1. Mount the engine in your config/routes.rb file. The following will mount it at http://example.com/good_job.

    # config/routes.rb
    # ...
    mount GoodJob::Engine => 'good_job'
  2. Configure authentication. Because jobs can potentially contain sensitive information, you should authorize access. For example, using Devise's authenticate helper, that might look like:

    # config/routes.rb
    # ...
    authenticate :user, ->(user) { user.admin? } do
      mount GoodJob::Engine => 'good_job'
    end

    Another option is using basic auth like this:

    # config/initializers/good_job.rb
    GoodJob::Engine.middleware.use(Rack::Auth::Basic) do |username, password|
      ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.good_job_username, username) &&
        ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.good_job_password, password)
    end

To view finished jobs (succeeded and discarded) on the Dashboard, GoodJob must be configured to preserve job records. Preservation is enabled by default.

Troubleshooting the Dashboard: Some applications are unable to autoload the Goodjob Engine. To work around this, explicitly require the Engine at the top of your config/application.rb file, immediately after Rails is required and before Bundler requires the Rails' groups.

# config/application.rb
require_relative 'boot'
require 'rails/all'
require 'good_job/engine' # <= Add this line
# ...

API-only Rails applications

API-only Rails applications may not have all of the required Rack middleware for the GoodJob Dashboard to function. To re-add the middleware:

# config/application.rb
module MyApp
  class Application < Rails::Application
    #...
    config.middleware.use Rack::MethodOverride
    config.middleware.use ActionDispatch::Flash
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use ActionDispatch::Session::CookieStore
  end
end

Live Polling

The Dashboard can be set to automatically refresh by checking "Live Poll" in the Dashboard header, or by setting ?poll=10 with the interval in seconds (default 30 seconds).

ActiveJob concurrency

GoodJob can extend ActiveJob to provide limits on concurrently running jobs, either at time of enqueue or at perform. Limiting concurrency can help prevent duplicate, double or unnecessary jobs from being enqueued, or race conditions when performing, for example when interacting with 3rd-party APIs.

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

  good_job_control_concurrency_with(
    # Maximum number of unfinished jobs to allow with the concurrency key
    # Can be an Integer or Lambda/Proc that is invoked in the context of the job
    total_limit: 1,

    # Or, if more control is needed:
    # Maximum number of jobs with the concurrency key to be
    # concurrently enqueued (excludes performing jobs)
    # Can be an Integer or Lambda/Proc that is invoked in the context of the job
    enqueue_limit: 2,

    # Maximum number of jobs with the concurrency key to be
    # concurrently performed (excludes enqueued jobs)
    # Can be an Integer or Lambda/Proc that is invoked in the context of the job
    perform_limit: 1,

    # Note: Under heavy load, the total number of jobs may exceed the
    # sum of `enqueue_limit` and `perform_limit` because of race conditions
    # caused by imperfectly disjunctive states. If you need to constrain
    # the total number of jobs, use `total_limit` instead. See #378.

    # A unique key to be globally locked against.
    # Can be String or Lambda/Proc that is invoked in the context of the job.
    # Note: Arguments passed to #perform_later can be accessed through ActiveJob's `arguments` method
    # which is an array containing positional arguments and, optionally, a kwarg hash.
    key: -> { "Unique-#{arguments.first}-#{arguments.last[:version]}" } #  MyJob.perform_later("Alice", version: 'v2') => "Unique-Alice-v2"
  )

  def perform(first_name)
    # do work
  end
end

When testing, the resulting concurrency key value can be inspected:

job = MyJob.perform_later("Alice")
job.good_job_concurrency_key #=> "Unique-Alice"

How concurrency controls work

GoodJob's concurrency control strategy for perform_limit is "optimistic retry with an incremental backoff". The code is readable.

  • "Optimistic" meaning that the implementation's performance trade-off assumes that collisions are atypical (e.g. two users enqueue the same job at the same time) rather than regular (e.g. the system enqueues thousands of colliding jobs at the same time). Depending on your concurrency requirements, you may also want to manage concurrency through the number of GoodJob threads and processes that are performing a given queue.
  • "Retry with an incremental backoff" means that when perform_limit is exceeded, the job will raise a GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError which is caught by a retry_on handler which re-schedules the job to execute in the near future with an incremental backoff.
  • First-in-first-out job execution order is not preserved when a job is retried with incremental back-off.
  • For pessimistic usecases that collisions are expected, use number of threads/processes (e.g., good_job --queue "serial:1;-serial:5") to control concurrency. It is also a good idea to use perform_limit as backstop.

Cron-style repeating/recurring jobs

GoodJob can enqueue jobs on a recurring basis that can be used as a replacement for cron.

Cron-style jobs are run on every GoodJob process (e.g. CLI or async execution mode) when config.good_job.enable_cron = true, but GoodJob's cron uses unique indexes to ensure that only a single job is enqueued at the given time interval.

Cron-format is parsed by the fugit gem, which has support for seconds-level resolution (e.g. * * * * * *) and natural language parsing (e.g. every second).

# config/environments/application.rb or a specific environment e.g. production.rb

# Enable cron in this process; e.g. only run on the first Heroku worker process
config.good_job.enable_cron = ENV['DYNO'] == 'worker.1' # or `true` or via $GOOD_JOB_ENABLE_CRON

# Configure cron with a hash that has a unique key for each recurring job
config.good_job.cron = {
  # Every 15 minutes, enqueue `ExampleJob.set(priority: -10).perform_later(42, name: "Alice")`
  frequent_task: { # each recurring job must have a unique key
    cron: "*/15 * * * *", # cron-style scheduling format by fugit gem
    class: "ExampleJob", # reference the Job class with a string
    args: [42, "life"], # positional arguments to pass; can also be a proc e.g. `-> { [Time.now] }`
    kwargs: { name: "Alice" }, # keyword arguments to pass; can also be a proc e.g. `-> { { name: NAMES.sample } }`
    set: { priority: -10 }, # additional ActiveJob properties; can also be a lambda/proc e.g. `-> { { priority: [1,2].sample } }`
    description: "Something helpful", # optional description that appears in Dashboard
  },
  another_task: {
    cron: "0 0,12 * * *",
    class: "AnotherJob",
  },
  # etc.
}

Updating

GoodJob follows semantic versioning, though updates may be encouraged through deprecation warnings in minor versions.

Upgrading minor versions

Upgrading between minor versions (e.g. v1.4 to v1.5) should not introduce breaking changes, but can introduce new deprecation warnings and database migration notices.

To perform upgrades to the GoodJob database tables:

  1. Generate new database migration files:

    bin/rails g good_job:update

    Optional: If using Rails' multiple databases with the migrations_paths configuration option, use the --database option:

    bin/rails g good_job:update --database animals
  2. Run the database migration locally

    bin/rails db:migrate
  3. Commit the migration files and resulting db/schema.rb changes.

  4. Deploy the code, run the migrations against the production database, and restart server/worker processes.

Upgrading v2 to v3

GoodJob v3 is operationally identical to v2; upgrading to GoodJob v3 should be simple. If you are already using >= v2.9+ no other changes are necessary.

  1. Upgrade to v2.99.x, following the minor version upgrade process, running any remaining database migrations (rails g good_job:update) and addressing deprecation warnings.
  2. Upgrade from v2.99.x to v3.x

Notable changes:

  • Defaults to preserve job records, and automatically delete them after 14 days.
  • Defaults to discarding failed jobs, instead of immediately retrying them.
  • :inline execution mode respects job schedules. Tests can invoke GoodJob.perform_inline to execute jobs.
  • GoodJob::Adapter can no longer can be initialized with custom execution options (queues:, max_threads:, poll_interval:).
  • Renames GoodJob::ActiveJobJob to GoodJob::Job.
  • Removes support for Rails 5.2.

Upgrading v1 to v2

GoodJob v2 introduces a new Advisory Lock key format that is operationally different than the v1 advisory lock key format; it's therefore necessary to perform a simple, but staged production upgrade. If you are already using >= v1.12+ no other changes are necessary.

  1. Upgrade your production environment to v1.99.x following the minor version upgrade process, including database migrations. v1.99 is a transitional release that is safely compatible with both v1.x and v2.0.0 because it uses both v1- and v2-formatted advisory locks.
  2. Address any deprecation warnings generated by v1.99.
  3. Upgrade your production environment from v1.99.x to v2.0.x again following the minor upgrade process.

Notable changes:

  • Renames :async_server execution mode to :async; renames prior :async execution mode to :async_all.
  • Sets default Development environment's execution mode to :async with disabled polling.
  • Excludes performing jobs from enqueue_limit's count in GoodJob::ActiveJobExtensions::Concurrency.
  • Triggers GoodJob.on_thread_error for unhandled ActiveJob exceptions.
  • Renames GoodJob.reperform_jobs_on_standard_error accessor to GoodJob.retry_on_unhandled_error.
  • Renames GoodJob::Adapter.shutdown(wait:) argument to GoodJob::Adapter.shutdown(timeout:).
  • Changes Advisory Lock key format from good_jobs[ROW_ID] to good_jobs-[ACTIVE_JOB_ID].
  • Expects presence of columns good_jobs.active_job_id, good_jobs.concurrency_key, good_jobs.concurrency_key, and good_jobs.retried_good_job_id.

Go deeper

Exceptions, retries, and reliability

GoodJob guarantees that a completely-performed job will run once and only once. GoodJob fully supports ActiveJob's built-in functionality for error handling, retries and timeouts.

Exceptions

ActiveJob provides tools for rescuing and retrying exceptions, including retry_on, discard_on, rescue_from that will rescue exceptions before they get to GoodJob.

If errors do reach GoodJob, you can assign a callable to GoodJob.on_thread_error to be notified. For example, to log errors to an exception monitoring service like Sentry (or Bugsnag, Airbrake, Honeybadger, etc.):

# config/initializers/good_job.rb
GoodJob.on_thread_error = -> (exception) { Sentry.capture_exception(exception) }

Retries

By default, GoodJob relies on ActiveJob's retry functionality.

ActiveJob can be configured to retry an infinite number of times, with an exponential backoff. Using ActiveJob's retry_on prevents exceptions from reaching GoodJob:

class ApplicationJob < ActiveJob::Base
  retry_on StandardError, wait: :exponentially_longer, attempts: Float::INFINITY
  # ...
end

When using retry_on with a limited number of retries, the final exception will not be rescued and will raise to GoodJob's error handler. To avoid this, pass a block to retry_on to handle the final exception instead of raising it to GoodJob:

class ApplicationJob < ActiveJob::Base
  retry_on StandardError, attempts: 5 do |_job, _exception|
    # Log error, do nothing, etc.
  end
  # ...
end

When using retry_on with an infinite number of retries, exceptions will never be raised to GoodJob, which means GoodJob.on_thread_error will never be called. To report log or report exceptions to an exception monitoring service (e.g. Sentry, Bugsnag, Airbrake, Honeybadger, etc), create an explicit exception wrapper. For example:

class ApplicationJob < ActiveJob::Base
  retry_on StandardError, wait: :exponentially_longer, attempts: Float::INFINITY

  retry_on SpecialError, attempts: 5 do |_job, exception|
    Sentry.capture_exception(exception)
  end

  around_perform do |_job, block|
    block.call
  rescue StandardError => e
    Sentry.capture_exception(e)
    raise
  end
  # ...
end

By default, jobs will not be retried unless retry_on is configured. This can be overridden by setting GoodJob.retry_on_unhandled_error to true; GoodJob will then retry the failing job immediately and infinitely, potentially causing high load.

ActionMailer retries

Any configuration in ApplicationJob will have to be duplicated on ActionMailer::MailDeliveryJob because ActionMailer uses that custom class which inherits from ActiveJob::Base, rather than your application's ApplicationJob.

You can use an initializer to configure ActionMailer::MailDeliveryJob, for example:

# config/initializers/good_job.rb
ActionMailer::MailDeliveryJob.retry_on StandardError, wait: :exponentially_longer, attempts: Float::INFINITY

# With Sentry (or Bugsnag, Airbrake, Honeybadger, etc.)
ActionMailer::MailDeliveryJob.around_perform do |_job, block|
  block.call
rescue StandardError => e
  Sentry.capture_exception(e)
  raise
end

Note, that ActionMailer::MailDeliveryJob is a default since Rails 6.0. Be sure that your app is using that class, as it might also be configured to use (deprecated now) ActionMailer::DeliveryJob.

Timeouts

Job timeouts can be configured with an around_perform:

class ApplicationJob < ActiveJob::Base
  JobTimeoutError = Class.new(StandardError)

  around_perform do |_job, block|
    # Timeout jobs after 10 minutes
    Timeout.timeout(10.minutes, JobTimeoutError) do
      block.call
    end
  end
end

Optimize queues, threads, and processes

By default, GoodJob creates a single thread execution pool that will execute jobs from any queue. Depending on your application's workload, job types, and service level objectives, you may wish to optimize execution resources. For example, providing dedicated execution resources for transactional emails so they are not delayed by long-running batch jobs. Some options:

  • Multiple isolated execution pools within a single process:

    For moderate workloads, multiple isolated thread execution pools offers a good balance between congestion management and economy.

    A pool is configured with the following syntax <participating_queues>:<thread_count>:

    • <participating_queues>: either queue1,queue2 (only those queues), +queue1,queue2 (only those queues, and processed in order), * (all) or -queue1,queue2 (all except those queues).
    • <thread_count>: a count overriding for this specific pool the global max-threads.

    Pool configurations are separated with a semicolon (;) in the queues configuration

    $ bundle exec good_job \
        --queues="transactional_messages:2;batch_processing:1;-transactional_messages,batch_processing:2;*" \
        --max-threads=5

    This configuration will result in a single process with 4 isolated thread execution pools.

    • transactional_messages:2: execute jobs enqueued on transactional_messages, with up to 2 threads.
    • batch_processing:1 execute jobs enqueued on batch_processing, with a single thread.
    • -transactional_messages,batch_processing: execute jobs enqueued on any queue excluding transactional_messages or batch_processing, with up to 2 threads.
    • *: execute jobs on any queue, with up to 5 threads (as configured by --max-threads=5).

    When a pool is performing jobs from multiple queues, jobs will be performed from specified queues, ordered by priority and creation time. To perform jobs from queues in the queues' given order, use the + modifier. In this example, jobs in batch_processing will be performed only when there are no jobs in transactional_messages:

    bundle exec good_job --queues="+transactional_messages,batch_processing"

    Configuration can be injected by environment variables too:

    $ GOOD_JOB_QUEUES="transactional_messages:2;batch_processing:1;-transactional_messages,batch_processing:2;*" \
      GOOD_JOB_MAX_THREADS=5 \
      bundle exec good_job
  • Multiple processes:

    While multiple isolated thread execution pools offer a way to provide dedicated execution resources, those resources are bound to a single machine. To scale them independently, define several processes.

    For example, this configuration on Heroku allows to customize the dyno count (instances), or type (CPU/RAM), per process type:

    # Procfile
    
    # Separate process types
    worker: bundle exec good_job --max-threads=5
    transactional_worker: bundle exec good_job --queues="transactional_messages" --max-threads=2
    batch_worker: bundle exec good_job --queues="batch_processing" --max-threads=1

    To optimize for CPU performance at the expense of greater memory and system resource usage, while keeping a single process type (and thus a single dyno), combine several processes and wait for them:

    # Procfile
    
    # Combined multi-process
    combined_worker: bundle exec good_job --max-threads=5 & bundle exec good_job --queues="transactional_messages" --max-threads=2 & bundle exec good_job --queues="batch_processing" --max-threads=1 & wait -n

Keep in mind, queue operations and management is an advanced discipline. This stuff is complex, especially for heavy workloads and unique processing requirements. Good job 👍

Database connections

Each GoodJob execution thread requires its own database connection that is automatically checked out from Rails’ connection pool. For example:

# config/database.yml
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5).to_i + 3 + ENV.fetch("GOOD_JOB_MAX_THREADS", 5).to_i %>

To calculate the total number of the database connections you'll need:

  • 1 connection dedicated to the scheduler aka LISTEN/NOTIFY
  • 1 connection per query pool thread e.g. --queues=mice:2;elephants:1 is 3 threads. Pool thread size defaults to --max-threads
  • (optional) 2 connections for Cron scheduler if you're running it
  • (optional) 1 connection per subthread, if your application makes multithreaded database queries within a job
  • When running :async, you must also add the number of threads by the webserver

The queue process will not crash if the connections pool is exhausted, instead it will report an exception (eg. ActiveRecord::ConnectionTimeoutError).

Production setup

When running GoodJob in a production environment, you should be mindful of:

The recommended way to monitor the queue in production is:

  • have an exception notifier callback (see on_thread_error)
  • if possible, run the queue as a dedicated instance and use available HTTP health check probes instead of PID-based monitoring
  • keep an eye on the number of jobs in the queue (abnormal high number of unscheduled jobs means the queue could be underperforming)
  • consider performance monitoring services which support the built-in Rails instrumentation (eg. Sentry, Skylight, etc.)

Queue performance with Queue Select Limit

GoodJob’s advisory locking strategy uses a materialized CTE (Common Table Expression). This strategy can be non-performant when querying a very large queue of executable jobs (100,000+) because the database query must materialize all executable jobs before acquiring an advisory lock.

GoodJob offers an optional optimization to limit the number of jobs that are queried: Queue Select Limit.

# CLI option
--queue-select-limit=1000

# Rails configuration
config.good_job.queue_select_limit = 1000

# Environment Variable
GOOD_JOB_QUEUE_SELECT_LIMIT=1000

The Queue Select Limit value should be set to a rough upper-bound that exceeds all GoodJob execution threads / database connections. 1000 is a number that likely exceeds the available database connections on most PaaS offerings, but still offers a performance boost for GoodJob when executing very large queues.

To explain where this value is used, here is the pseudo-query that GoodJob uses to find executable jobs:

  SELECT *
  FROM good_jobs
  WHERE id IN (
    WITH rows AS MATERIALIZED (
      SELECT id, active_job_id
      FROM good_jobs
      WHERE (scheduled_at <= NOW() OR scheduled_at IS NULL) AND finished_at IS NULL
      ORDER BY priority DESC NULLS LAST, created_at ASC
      [LIMIT 1000] -- <= introduced when queue_select_limit is set
    )
    SELECT id
    FROM rows
    WHERE pg_try_advisory_lock(('x' || substr(md5('good_jobs' || '-' || active_job_id::text), 1, 16))::bit(64)::bigint)
    LIMIT 1
  )

Execute jobs async / in-process

GoodJob can execute jobs "async" in the same process as the web server (e.g. bin/rails s). GoodJob's async execution mode offers benefits of economy by not requiring a separate job worker process, but with the tradeoff of increased complexity. Async mode can be configured in two ways:

  • Via Rails configuration:

    # config/environments/production.rb
    config.active_job.queue_adapter = :good_job
    
    # To change the execution mode
    config.good_job.execution_mode = :async
    
    # Or with more configuration
    config.good_job = {
      execution_mode: :async,
      max_threads: 4,
      poll_interval: 30
    }
  • Or, with environment variables:

    GOOD_JOB_EXECUTION_MODE=async GOOD_JOB_MAX_THREADS=4 GOOD_JOB_POLL_INTERVAL=30 bin/rails server

Depending on your application configuration, you may need to take additional steps:

  • Ensure that you have enough database connections for both web and job execution threads:

    # config/database.yml
    pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5).to_i + ENV.fetch("GOOD_JOB_MAX_THREADS", 4).to_i %>
  • When running Puma with workers (WEB_CONCURRENCY > 0) or another process-forking web server, GoodJob's threadpool schedulers should be stopped before forking, restarted after fork, and cleanly shut down on exit. Stopping GoodJob's scheduler pre-fork is recommended to ensure that GoodJob does not continue executing jobs in the parent/controller process. For example, with Puma:

    # config/puma.rb
    
    before_fork do
      GoodJob.shutdown
    end
    
    on_worker_boot do
      GoodJob.restart
    end
    
    on_worker_shutdown do
      GoodJob.shutdown
    end
    
    MAIN_PID = Process.pid
    at_exit do
      GoodJob.shutdown if Process.pid == MAIN_PID
    end

    GoodJob is compatible with Puma's preload_app! method.

    For Passenger:

    if defined? PhusionPassenger
      PhusionPassenger.on_event :starting_worker_process do |forked|
        # If `forked` is true, we're in smart spawning mode.
        # https://www.phusionpassenger.com/docs/advanced_guides/in_depth/ruby/spawn_methods.html#smart-spawning-hooks
        if forked
          GoodJob.logger.info { 'Starting Passenger worker process.' }
          GoodJob.restart
        end
      end
    
      PhusionPassenger.on_event :stopping_worker_process do
        GoodJob.logger.info { 'Stopping Passenger worker process.' }
        GoodJob.shutdown
      end
    end
    
    # GoodJob also starts in the Passenger preloader process. This one does not
    # trigger the above events, thus we catch it with `Kernel#at_exit`.
    PRELOADER_PID = Process.pid
    at_exit do
      if Process.pid == PRELOADER_PID
        GoodJob.logger.info { 'Passenger AppPreloader shutting down.' }
        GoodJob.shutdown
      end
    end

    If you are using cron-style jobs, you might also want to look at your Passenger configuration, especially at passenger_pool_idle_time and passenger_min_instances to make sure there's always at least once process running that can execute cron-style scheduled jobs. See also Passenger's optimization guide for more information.

Migrate to GoodJob from a different ActiveJob backend

If your application is already using an ActiveJob backend, you will need to install GoodJob to enqueue and perform newly created jobs and finish performing pre-existing jobs on the previous backend.

  1. Enqueue newly created jobs on GoodJob either entirely by setting ActiveJob::Base.queue_adapter = :good_job or progressively via individual job classes:

    # jobs/specific_job.rb
    class SpecificJob < ApplicationJob
      self.queue_adapter = :good_job
      # ...
    end
  2. Continue running executors for both backends. For example, on Heroku it's possible to run two processes within the same dyno:

     # Procfile
     # ...
     worker: bundle exec que ./config/environment.rb & bundle exec good_job & wait -n
  3. Once you are confident that no unperformed jobs remain in the previous ActiveJob backend, code and configuration for that backend can be completely removed.

Monitor and preserve worked jobs

GoodJob is fully instrumented with ActiveSupport::Notifications.

By default, GoodJob will preserve job records for 14 days after they are run, regardless of whether they succeed or raised an exception.

To instead delete job records immediately after they are finished:

# config/initializers/good_job.rb
config.good_job.preserve_job_records = false # defaults to true; can also be `false` or `:on_unhandled_error`

GoodJob will automatically delete preserved job records after 14 days. The retention period, as well as the frequency GoodJob checks for deletable records can be configured:

config.good_job.cleanup_preserved_jobs_before_seconds_ago = 14.days.to_i
config.good_job.cleanup_interval_jobs = 1_000 # Number of executed jobs between deletion sweeps.
config.good_job.cleanup_interval_seconds = 10.minutes.to_i # Number of seconds between deletion sweeps.

It is also possible to manually trigger a cleanup of preserved job records:

  • For example, in a Rake task:

    GoodJob.cleanup_preserved_jobs # Will use default retention period
    GoodJob.cleanup_preserved_jobs(older_than: 7.days) # custom retention period
  • For example, using the good_job command-line utility:

    bundle exec good_job cleanup_preserved_jobs --before-seconds-ago=86400

Write tests

By default, GoodJob uses its inline adapter in the test environment; the inline adapter is designed for the test environment. When enqueuing a job with GoodJob's inline adapter, the job will be executed immediately on the current thread; unhandled exceptions will be raised.

In GoodJob 2.0, the inline adapter will execute future scheduled jobs immediately. In the next major release, GoodJob 3.0, the inline adapter will not execute future scheduled jobs and instead enqueue them in the database.

To opt into this behavior immediately set: config.good_job.inline_execution_respects_schedule = true

To perform jobs inline at any time, use GoodJob.perform_inline. For example, using time helpers within an integration test:

MyJob.set(wait: 10.minutes).perform_later
travel_to(15.minutes.from_now) { GoodJob.perform_inline }

Note: Rails travel/travel_to time helpers do not have millisecond precision, so you must leave at least 1 second between the schedule and time traveling for the job to be executed. This behavior may change in Rails 7.1.

PgBouncer compatibility

GoodJob is not compatible with PgBouncer in transaction mode, but is compatible with PgBouncer's connection mode. GoodJob uses connection-based advisory locks and LISTEN/NOTIFY, both of which require full database connections.

A workaround to this limitation is to make a direct database connection available to GoodJob. With Rails 6.0's support for multiple databases, a direct connection to the database can be configured:

  1. Define a direct connection to your database that is not proxied through PgBouncer, for example:

    # config/database.yml
    
    production:
      primary:
        url: postgres://pgbouncer_host/my_database
      primary_direct:
        url: postgres://database_host/my_database
  2. Create a new ActiveRecord base class that uses the direct database connection

    # app/models/application_direct_record.rb
    
    class ApplicationDirectRecord < ActiveRecord::Base
      self.abstract_class = true
      connects_to database: :primary_direct
    end
  3. Configure GoodJob to use the newly created ActiveRecord base class:

    # config/initializers/good_job.rb
    
    GoodJob.active_record_parent_class = "ApplicationDirectRecord"

CLI HTTP health check probes

GoodJob's CLI offers an http health check probe to better manage process lifecycle in containerized environments like Kubernetes:

# Run the CLI with a health check on port 7001
good_job start --probe-port=7001

# or via an environment variable
GOOD_JOB_PROBE_PORT=7001 good_job start

# Probe the status
curl localhost:7001/status
curl localhost:7001/status/started
curl localhost:7001/status/connected

Multiple health checks are available at different paths:

  • / or /status: the CLI process is running
  • /status/started: the multithreaded job executor is running
  • /status/connected: the database connection is established

This can be configured, for example with Kubernetes:

spec:
  containers:
    - name: good_job
      image: my_app:latest
      env:
        - name: RAILS_ENV
          value: production
        - name: GOOD_JOB_PROBE_PORT
          value: 7001
      command:
          - good_job
          - start
      ports:
        - name: probe-port
          containerPort: 7001
      startupProbe:
        httpGet:
          path: "/status/started"
          port: probe-port
        failureThreshold: 30
        periodSeconds: 10
      livenessProbe:
        httpGet:
          path: "/status/connected"
          port: probe-port
        failureThreshold: 1
        periodSeconds: 10

Contribute

All contributions, from feedback to code and beyond, are welcomed and appreciated 🙏

For gem development and debugging information, please review the README's Gem Development section.

Gem development

Development setup

# Clone the repository locally
git clone [email protected]:bensheldon/good_job.git

# Set up the gem development environment
bin/setup

Rails development harness

A Rails application exists within spec/test_app that is used for development, test, and GoodJob Demo environments.

# Run a local development webserver
bin/rails s

# Disable job execution and cron for cleaner console output
GOOD_JOB_ENABLE_CRON=0 GOOD_JOB_EXECUTION_MODE=external bin/rails s

# Open the Rails console
bin/rails c

For developing locally within another Ruby on Rails project:

# Within Ruby on Rails project directory
# Ensure that the Gemfile is set to git with a branch e.g.
# gem "good_job", git: "https://github.com/bensheldon/good_job.git", branch: "main"
# Then, override the Bundle config to point to the local filesystem's good_job repository
bundle config local.good_job /path/to/local/good_job/repository

# Confirm that the local copy is used
bundle install

# => Using good_job 0.1.0 from https://github.com/bensheldon/good_job.git (at /Users/You/Projects/good_job@dc57fb0)

Running tests

Tests can be run against the primary development environment:

# Set up the gem development environment
bin/setup

# Run the tests
bin/rspec

Environment variables that may help with debugging:

  • LOUD=1: display all stdout/stderr output from all sources. This is helpful because GoodJob wraps some tests with quiet { } for cleaner test output, but it can hinder debugging.
  • SHOW_BROWSER=1: Run system tests headfully with Chrome/Chromedriver. Use binding.irb in the system tests to pause.

Appraisal can be used to run a test matrix of multiple versions of Rails:

# Install Appraisal matrix of gemfiles
bin/appraisal

# Run tests against matrix
bin/appraisal bin/rspec

Release

Package maintainers can release this gem by running:

# Sign into rubygems
$ gem signin

# Add a .env file with the following:
# CHANGELOG_GITHUB_TOKEN= # Github Personal Access Token

# Update version number, changelog, and create git commit:
$ bundle exec rake release_good_job[minor] # major,minor,patch

# ..and follow subsequent directions.

License

The gem is available as open source under the terms of the MIT License.

About

Multithreaded, Postgres-based, ActiveJob backend for Ruby on Rails.

Resources

License

Code of conduct

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Ruby 86.8%
  • HTML 10.9%
  • JavaScript 1.6%
  • Other 0.7%