diff --git a/Gemfile b/Gemfile index 3039202f0..9d941b842 100644 --- a/Gemfile +++ b/Gemfile @@ -25,6 +25,8 @@ gem "govuk_design_system_formbuilder" gem "hotwire-rails" # Soft delete ActiveRecords objects gem "discard" +# Multi-threaded, Postgres-based, ActiveJob backend for Ruby on Rails +gem "good_job" group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console diff --git a/Gemfile.lock b/Gemfile.lock index 176bb4202..6778e2e8a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rspec/rspec-core.git - revision: 053fcfeb6b0b6627edf7261737553a6f7df8cc14 + revision: d57c371ee92b16211b80ac7b0b025968438f5297 branch: main specs: rspec-core (3.11.0.pre) @@ -137,14 +137,27 @@ GEM dotenv (= 2.7.6) railties (>= 3.2) erubi (1.10.0) + et-orbi (1.2.5) + tzinfo factory_bot (6.2.0) activesupport (>= 5.0.0) factory_bot_rails (6.2.0) factory_bot (~> 6.2.0) railties (>= 5.0.0) ffi (1.15.4) + fugit (1.5.2) + et-orbi (~> 1.1, >= 1.1.8) + raabro (~> 1.4) globalid (0.5.2) activesupport (>= 5.0) + good_job (2.4.1) + activejob (>= 5.2.0) + activerecord (>= 5.2.0) + concurrent-ruby (>= 1.0.2) + fugit (>= 1.1) + railties (>= 5.2.0) + thor (>= 0.14.1) + zeitwerk (>= 2.0) govuk-components (2.1.3) activemodel (>= 6.0) railties (>= 6.0) @@ -198,6 +211,7 @@ GEM public_suffix (4.0.6) puma (5.5.2) nio4r (~> 2.0) + raabro (1.4.0) racc (1.5.2) rack (2.2.3) rack-mini-profiler (2.3.3) @@ -300,7 +314,7 @@ GEM stimulus-rails (0.7.1) rails (>= 6.0.0) thor (1.1.0) - turbo-rails (7.1.0) + turbo-rails (7.1.1) rails (>= 6.0.0) tzinfo (2.0.4) concurrent-ruby (~> 1.0) @@ -337,6 +351,7 @@ DEPENDENCIES discard dotenv-rails factory_bot_rails + good_job govuk-components govuk_design_system_formbuilder hotwire-rails diff --git a/config/application.rb b/config/application.rb index 45edf3531..a2278f044 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,5 +34,7 @@ module DataCollector # Don't generate system test files. config.generators.system_tests = nil + + config.active_job.queue_adapter = :good_job end end diff --git a/db/migrate/20211018125238_create_good_jobs.rb b/db/migrate/20211018125238_create_good_jobs.rb new file mode 100644 index 000000000..fdd772ccc --- /dev/null +++ b/db/migrate/20211018125238_create_good_jobs.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +class CreateGoodJobs < ActiveRecord::Migration[5.2] + def change + enable_extension 'pgcrypto' + + create_table :good_jobs, id: :uuid do |t| + t.text :queue_name + t.integer :priority + t.jsonb :serialized_params + t.timestamp :scheduled_at + t.timestamp :performed_at + t.timestamp :finished_at + t.text :error + + t.timestamps + + t.uuid :active_job_id + t.text :concurrency_key + t.text :cron_key + t.uuid :retried_good_job_id + end + + add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: "index_good_jobs_on_scheduled_at" + add_index :good_jobs, [:queue_name, :scheduled_at], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at + add_index :good_jobs, [:active_job_id, :created_at], name: :index_good_jobs_on_active_job_id_and_created_at + add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished + add_index :good_jobs, [:cron_key, :created_at], name: :index_good_jobs_on_cron_key_and_created_at + end +end diff --git a/db/schema.rb b/db/schema.rb index 03a612ecd..eb3e90515 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_10_15_090040) do +ActiveRecord::Schema.define(version: 2021_10_18_125238) do # These are extensions that must be enabled in order to support this database + enable_extension "pgcrypto" enable_extension "plpgsql" create_table "case_logs", force: :cascade do |t| @@ -135,4 +136,25 @@ ActiveRecord::Schema.define(version: 2021_10_15_090040) do t.index ["discarded_at"], name: "index_case_logs_on_discarded_at" end + create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "queue_name" + t.integer "priority" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "performed_at" + t.datetime "finished_at" + t.text "error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id" + t.text "concurrency_key" + t.text "cron_key" + t.uuid "retried_good_job_id" + t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" + t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" + t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at" + t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" + t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" + end + end diff --git a/doc/adr/adr-008-queue-job-processing.md b/doc/adr/adr-008-queue-job-processing.md new file mode 100644 index 000000000..0045befa2 --- /dev/null +++ b/doc/adr/adr-008-queue-job-processing.md @@ -0,0 +1,21 @@ +### ADR - 008: Background Job Queueing and Processing + +#### Why background jobs? + +While we can probably target making validations run fast enough to be able to complete all single case log API actions synchronously in process, with arbitrarily large bulk actions that becomes impossible so we probably need to introduce background job queueing and processing. This will enable us to return an API response immediately and process the Case Logs sent asynchronously. + +We will use ActiveJob backed by Good Job (https://github.com/bensheldon/good_job) for this. + +#### Why Good Job? + +There are multiple options we could use for this, with the main differences being Thread based vs Process based, and Queueing database (Postgres vs Redis): + +- Sidekiq (Thread-based, Redis) +- Faktory (Thread-based, Redis) +- Resque (Process-based, Redis) +- Delayed Job (Process-based, Postgres) +- Good Job (Thread-based, Postgres) + +Sidekiq is probably the most widely used multi-threaded job backend, and is also widely used in Gov.UK services (https://docs.publishing.service.gov.uk/manual/sidekiq.html) but requires additional infrastructure (Redis). By sticking with a Postgres based backend initially we can keep our architecture simpler, and only add Redis if we need to later. + +By using ActiveJob as our interface we can ensure that changing backend later requires minimal if any rewriting of job code. Of the two Postgres based backends Good Job is the more recent, inspired by Delayed Job but specifically written for Rails and ActiveJob making it a good fit here. It also expects to be performant for up to 1-million jobs per day which is more than we're expecting by orders of magnitude.