Skip to content

Commit 4577c3a

Browse files
Merge pull request #44 from eclectic-coding/feat/read-replica-support
feat: read replica support via connects_to role switching
2 parents 6875fd9 + 869e0aa commit 4577c3a

5 files changed

Lines changed: 93 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- 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
12+
- Read replica support — when `connects_to` is set to `{ reading: <role>, writing: <role> }`, 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
1313
- 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
1414
- 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
1515
- 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

README.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ SolidQueueWeb.configure do |config|
103103
config.alert_webhook_url = "https://hooks.example.com/solid-queue" # POST target (default: nil = disabled)
104104
config.alert_failure_threshold = 10 # fire when failed count >= this (default: nil = disabled)
105105
config.alert_webhook_cooldown = 1800 # seconds between repeated alerts (default: 3600)
106-
config.connects_to = { database: { writing: :queue, reading: :queue } } # multi-db (default: nil)
106+
config.connects_to = { reading: :reading, writing: :writing } # read replica (default: nil)
107107
end
108108

109109
SolidQueueWeb.authenticate do
@@ -140,20 +140,23 @@ The request body is JSON:
140140

141141
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.
142142

143-
## Multi-database setup
143+
## Read replica support
144144

145-
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.
145+
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.
146146

147147
```ruby
148148
SolidQueueWeb.configure do |config|
149-
# Solid Queue on a named database:
150-
config.connects_to = { database: { writing: :queue, reading: :queue } }
151-
152-
# Or just pin to the writing role to bypass automatic read/write splitting:
153-
config.connects_to = { role: :writing }
149+
# Route dashboard reads to the replica, writes to primary:
150+
config.connects_to = { reading: :reading, writing: :writing }
154151
end
155152
```
156153

154+
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:
155+
156+
```ruby
157+
config.connects_to = { role: :writing }
158+
```
159+
157160
When `connects_to` is `nil` (the default), no connection switching occurs and single-database apps are unaffected.
158161

159162
## Roadmap
@@ -163,9 +166,6 @@ Planned features, roughly ordered by priority:
163166
**Operations**
164167
- Admin audit log — record who retried or discarded which jobs and when (requires host-app user identity)
165168

166-
**Infrastructure**
167-
- Read replica support — route dashboard queries to a replica to avoid impacting the primary
168-
169169
Pull requests for any of these are welcome. See [Contributing](#contributing) below.
170170

171171
## Contributing

app/controllers/solid_queue_web/application_controller.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,20 @@ class ApplicationController < ActionController::Base
1414

1515
def with_database_connection
1616
config = SolidQueueWeb.connects_to
17-
if config
18-
ActiveRecord::Base.connected_to(**config) { yield }
17+
return yield unless config
18+
19+
if replica_configured?(config)
20+
role = request.get? ? config[:reading] : config[:writing]
21+
ActiveRecord::Base.connected_to(role: role) { yield }
1922
else
20-
yield
23+
ActiveRecord::Base.connected_to(**config) { yield }
2124
end
2225
end
2326

27+
def replica_configured?(config)
28+
config.key?(:reading) && config.key?(:writing)
29+
end
30+
2431
def authenticate!
2532
return unless (auth = SolidQueueWeb.authenticate)
2633

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
require "rails_helper"
2+
3+
RSpec.describe SolidQueueWeb::ApplicationController, type: :controller do
4+
controller(SolidQueueWeb::ApplicationController) do
5+
skip_before_action :authenticate!
6+
7+
def index
8+
render plain: "ok"
9+
end
10+
end
11+
12+
after { SolidQueueWeb.connects_to = nil }
13+
14+
describe "#with_database_connection" do
15+
context "when connects_to is not configured" do
16+
it "does not call connected_to" do
17+
expect(ActiveRecord::Base).not_to receive(:connected_to)
18+
get :index
19+
expect(response).to have_http_status(:ok)
20+
end
21+
end
22+
23+
context "when connects_to has a single role" do
24+
before { SolidQueueWeb.connects_to = { role: :writing } }
25+
26+
it "calls connected_to with the config" do
27+
expect(ActiveRecord::Base).to receive(:connected_to).with(role: :writing).and_yield
28+
get :index
29+
end
30+
end
31+
32+
context "when connects_to has both reading and writing roles" do
33+
before { SolidQueueWeb.connects_to = { reading: :reading, writing: :writing } }
34+
35+
it "uses the reading role for GET requests" do
36+
expect(ActiveRecord::Base).to receive(:connected_to).with(role: :reading).and_yield
37+
get :index
38+
expect(response).to have_http_status(:ok)
39+
end
40+
41+
it "uses the writing role for non-GET requests" do
42+
expect(ActiveRecord::Base).to receive(:connected_to).with(role: :writing).and_yield
43+
post :index
44+
expect(response).to have_http_status(:ok)
45+
end
46+
end
47+
end
48+
49+
describe "#replica_configured?" do
50+
it "returns false when only role is configured" do
51+
expect(controller.send(:replica_configured?, { role: :writing })).to be false
52+
end
53+
54+
it "returns false when only writing is configured" do
55+
expect(controller.send(:replica_configured?, { writing: :writing })).to be false
56+
end
57+
58+
it "returns true when both reading and writing roles are configured" do
59+
expect(controller.send(:replica_configured?, { reading: :reading, writing: :writing })).to be true
60+
end
61+
end
62+
end

spec/requests/solid_queue_web/dashboard_spec.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@
9292
get "/jobs"
9393
expect(response).to have_http_status(:ok)
9494
end
95+
96+
context "when reading and writing roles are both configured" do
97+
before { SolidQueueWeb.connects_to = { reading: :writing, writing: :writing } }
98+
99+
it "request succeeds with replica role switching active" do
100+
get "/jobs"
101+
expect(response).to have_http_status(:ok)
102+
end
103+
end
95104
end
96105

97106
describe "alert webhook" do

0 commit comments

Comments
 (0)