Wire Solid Queue and add jwt/webmock gems

Solid Queue was declared (Gemfile, production queue: database) but never installed: no bin/jobs, no queue.yml, no queue schema, adapter still commented out. Run the installer and point production at the queue database, ahead of the first real ActiveJob (GitHub PR reviews). jwt mints the RS256 App JWTs for GitHub App installation tokens; webmock stubs GitHub REST calls in specs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WhPzinYJUYgBed63XMvJX1

Claude committed Jul 2, 2026 at 12:21 UTC 78c7a21dfc3c5a333234533bcf317e5757cdb004
7 files changed +198 -1
Gemfile
+6
index 411f0ac..9792d4d 100644 --- a/Gemfile +++ b/Gemfile @@ -31,6 +31,9 @@ gem "redcarpet", "~> 3.6" # Syntax highlighting for code files gem "rouge", "~> 4.5" +# RS256 JWT minting for GitHub App authentication (installation access tokens) +gem "jwt", "~> 3.1" + # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] # gem "bcrypt", "~> 3.1.7" @@ -77,6 +80,9 @@ group :development, :test do # RSpec for request/model specs [https://github.com/rspec/rspec-rails] gem "rspec-rails", "~> 8.0" + + # Stub outbound HTTP (GitHub REST) in specs + gem "webmock", require: false end group :development do
Gemfile.lock
+20
index fae165b..9b69aee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -94,6 +94,9 @@ GEM logger (~> 1.5) concurrent-ruby (1.3.7) connection_pool (3.0.2) + crack (1.0.1) + bigdecimal + rexml crass (1.0.6) date (3.5.1) debug (1.11.1) @@ -111,11 +114,13 @@ GEM et-orbi (1.4.0) tzinfo ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86_64-linux-gnu) fugit (1.12.2) et-orbi (~> 1.4) raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) + hashdiff (1.2.1) i18n (1.15.1) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -135,6 +140,8 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) json (2.19.9) + jwt (3.2.0) + base64 kamal (2.12.0) activesupport (>= 7.0) base64 (~> 0.2) @@ -189,12 +196,15 @@ GEM nio4r (2.7.5) nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) ostruct (0.6.3) parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) racc pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-linux) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -262,6 +272,7 @@ GEM regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) + rexml (3.4.4) rouge (4.7.0) rspec-core (3.13.6) rspec-support (~> 3.13.0) @@ -347,8 +358,10 @@ GEM railties (>= 7.0.0) tailwindcss-ruby (~> 3.0) tailwindcss-ruby (3.4.19-arm64-darwin) + tailwindcss-ruby (3.4.19-x86_64-linux) thor (1.5.0) thruster (0.1.21-arm64-darwin) + thruster (0.1.21-x86_64-linux) timeout (0.6.1) tsort (0.2.0) turbo-rails (2.0.23) @@ -365,6 +378,10 @@ GEM actionview (>= 8.0.0) bindex (>= 0.4.0) railties (>= 8.0.0) + webmock (3.26.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket-driver (0.8.1) base64 websocket-extensions (>= 0.1.0) @@ -373,6 +390,7 @@ GEM PLATFORMS arm64-darwin + x86_64-linux DEPENDENCIES bootsnap @@ -383,6 +401,7 @@ DEPENDENCIES image_processing (~> 1.2) importmap-rails jbuilder + jwt (~> 3.1) kamal letter_opener pg (~> 1.1) @@ -404,6 +423,7 @@ DEPENDENCIES turbo-rails tzinfo-data web-console + webmock BUNDLED WITH 2.7.2
bin/jobs
+6
new file mode 100755 index 0000000..dcf59f3 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV)
config/environments/production.rb
+2 -1
index cb0241d..04aade1 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -50,7 +50,8 @@ Rails.application.configure do # config.cache_store = :mem_cache_store # Replace the default in-process and non-durable queuing backend for Active Job. - # config.active_job.queue_adapter = :resque + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors.
config/queue.yml
+20
new file mode 100644 index 0000000..336f5dd --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,20 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + # Each review job can pin a thread on an Onde Cloud request for up to + # ~90s (OndeCloudService::READ_TIMEOUT) — keep concurrency low. + threads: 2 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default
config/recurring.yml
+15
new file mode 100644 index 0000000..b4207f9 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,15 @@ +# examples: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_cleanup_with_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day + +production: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12
db/queue_schema.rb
+129
new file mode 100644 index 0000000..85194b6 --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,129 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end