diff --git a/CHANGELOG.md b/CHANGELOG.md index 4660424..f612b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Multi-database support — a `connects_to` config option wraps all engine requests in `ActiveRecord::Base.connected_to(...)` when set; accepts any keyword arguments supported by Rails (`database:`, `role:`, `shard:`); defaults to `nil` so existing single-database setups are unaffected +- Read replica support — when `connects_to` is set to `{ reading: , writing: }`, the engine automatically routes GET requests to the reading role and mutating requests (POST/DELETE/PATCH) to the writing role via `ActiveRecord::Base.connected_to(role:)`; passing any other hash (e.g. `{ role: :writing }`, `{ shard: :name }`) falls through to `connected_to` directly; defaults to `nil` so single-database setups are unaffected - Webhook alert config — `alert_webhook_url` and `alert_failure_threshold` settings POST a JSON payload (`event`, `failure_count`, `threshold`, `fired_at`) to any URL when the failed job count meets or exceeds the threshold; fires asynchronously in a background thread so dashboard requests are never blocked; a configurable `alert_webhook_cooldown` (default 3600 s) prevents repeated alerts while the count stays elevated; HTTP errors are logged and swallowed - Bulk retry with delay — "+5s", "+10s", "+30s", and "+1m" stagger buttons on the Failed Jobs page retry all matched jobs with a configurable interval between each; the first job runs immediately, subsequent jobs are scheduled at incremental offsets; uses per-execution `retry` so `scheduled_at` is respected by SolidQueue's dispatcher; buttons only appear when more than one job is present - Scheduled job management — "Run Now" promotes a scheduled job to run immediately by back-dating its `scheduled_at`; "+1h", "+24h", and "+7d" buttons push `scheduled_at` forward by the chosen offset; both actions update the execution and the underlying job record; Turbo Stream responses remove the row on "Run Now" and update the `scheduled_at` cell in place on postpone diff --git a/README.md b/README.md index 3529c0c..15b6fda 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ SolidQueueWeb.configure do |config| config.alert_webhook_url = "https://hooks.example.com/solid-queue" # POST target (default: nil = disabled) config.alert_failure_threshold = 10 # fire when failed count >= this (default: nil = disabled) config.alert_webhook_cooldown = 1800 # seconds between repeated alerts (default: 3600) - config.connects_to = { database: { writing: :queue, reading: :queue } } # multi-db (default: nil) + config.connects_to = { reading: :reading, writing: :writing } # read replica (default: nil) end SolidQueueWeb.authenticate do @@ -140,20 +140,23 @@ The request body is JSON: The webhook fires asynchronously in a background thread so dashboard page loads are never delayed. HTTP errors are logged to `Rails.logger` and swallowed. The cooldown window prevents repeated alerts while the count stays elevated — the clock resets on each app restart. -## Multi-database setup +## Read replica support -If Solid Queue runs on a separate database, set `connects_to` to match your app's database configuration. The engine wraps every request in `ActiveRecord::Base.connected_to(...)` with the options you provide. +Set `connects_to` with both `reading:` and `writing:` keys to enable automatic role switching. GET requests are routed to the reading role; POST/DELETE/PATCH requests use the writing role. ```ruby SolidQueueWeb.configure do |config| - # Solid Queue on a named database: - config.connects_to = { database: { writing: :queue, reading: :queue } } - - # Or just pin to the writing role to bypass automatic read/write splitting: - config.connects_to = { role: :writing } + # Route dashboard reads to the replica, writes to primary: + config.connects_to = { reading: :reading, writing: :writing } end ``` +The role names must match what Solid Queue's models are configured with (set via `SolidQueue.connects_to` in your app). To pin all requests to a single role instead (e.g. to bypass automatic read/write splitting middleware), pass a plain `role:` or `shard:` hash: + +```ruby +config.connects_to = { role: :writing } +``` + When `connects_to` is `nil` (the default), no connection switching occurs and single-database apps are unaffected. ## Roadmap @@ -163,9 +166,6 @@ Planned features, roughly ordered by priority: **Operations** - Admin audit log — record who retried or discarded which jobs and when (requires host-app user identity) -**Infrastructure** -- Read replica support — route dashboard queries to a replica to avoid impacting the primary - Pull requests for any of these are welcome. See [Contributing](#contributing) below. ## Contributing diff --git a/app/controllers/solid_queue_web/application_controller.rb b/app/controllers/solid_queue_web/application_controller.rb index 157e6aa..8352160 100644 --- a/app/controllers/solid_queue_web/application_controller.rb +++ b/app/controllers/solid_queue_web/application_controller.rb @@ -14,13 +14,20 @@ class ApplicationController < ActionController::Base def with_database_connection config = SolidQueueWeb.connects_to - if config - ActiveRecord::Base.connected_to(**config) { yield } + return yield unless config + + if replica_configured?(config) + role = request.get? ? config[:reading] : config[:writing] + ActiveRecord::Base.connected_to(role: role) { yield } else - yield + ActiveRecord::Base.connected_to(**config) { yield } end end + def replica_configured?(config) + config.key?(:reading) && config.key?(:writing) + end + def authenticate! return unless (auth = SolidQueueWeb.authenticate) diff --git a/spec/controllers/solid_queue_web/application_controller_spec.rb b/spec/controllers/solid_queue_web/application_controller_spec.rb new file mode 100644 index 0000000..0e88e61 --- /dev/null +++ b/spec/controllers/solid_queue_web/application_controller_spec.rb @@ -0,0 +1,62 @@ +require "rails_helper" + +RSpec.describe SolidQueueWeb::ApplicationController, type: :controller do + controller(SolidQueueWeb::ApplicationController) do + skip_before_action :authenticate! + + def index + render plain: "ok" + end + end + + after { SolidQueueWeb.connects_to = nil } + + describe "#with_database_connection" do + context "when connects_to is not configured" do + it "does not call connected_to" do + expect(ActiveRecord::Base).not_to receive(:connected_to) + get :index + expect(response).to have_http_status(:ok) + end + end + + context "when connects_to has a single role" do + before { SolidQueueWeb.connects_to = { role: :writing } } + + it "calls connected_to with the config" do + expect(ActiveRecord::Base).to receive(:connected_to).with(role: :writing).and_yield + get :index + end + end + + context "when connects_to has both reading and writing roles" do + before { SolidQueueWeb.connects_to = { reading: :reading, writing: :writing } } + + it "uses the reading role for GET requests" do + expect(ActiveRecord::Base).to receive(:connected_to).with(role: :reading).and_yield + get :index + expect(response).to have_http_status(:ok) + end + + it "uses the writing role for non-GET requests" do + expect(ActiveRecord::Base).to receive(:connected_to).with(role: :writing).and_yield + post :index + expect(response).to have_http_status(:ok) + end + end + end + + describe "#replica_configured?" do + it "returns false when only role is configured" do + expect(controller.send(:replica_configured?, { role: :writing })).to be false + end + + it "returns false when only writing is configured" do + expect(controller.send(:replica_configured?, { writing: :writing })).to be false + end + + it "returns true when both reading and writing roles are configured" do + expect(controller.send(:replica_configured?, { reading: :reading, writing: :writing })).to be true + end + end +end diff --git a/spec/requests/solid_queue_web/dashboard_spec.rb b/spec/requests/solid_queue_web/dashboard_spec.rb index a284319..a0cc814 100644 --- a/spec/requests/solid_queue_web/dashboard_spec.rb +++ b/spec/requests/solid_queue_web/dashboard_spec.rb @@ -92,6 +92,15 @@ get "/jobs" expect(response).to have_http_status(:ok) end + + context "when reading and writing roles are both configured" do + before { SolidQueueWeb.connects_to = { reading: :writing, writing: :writing } } + + it "request succeeds with replica role switching active" do + get "/jobs" + expect(response).to have_http_status(:ok) + end + end end describe "alert webhook" do