pgbus 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.bun-version +1 -0
- data/.claude/commands/architect.md +100 -0
- data/.claude/commands/github-review-comments.md +237 -0
- data/.claude/commands/lfg.md +271 -0
- data/.claude/commands/review-pr.md +69 -0
- data/.claude/commands/security.md +122 -0
- data/.claude/commands/tdd.md +148 -0
- data/.claude/rules/agents.md +49 -0
- data/.claude/rules/coding-style.md +91 -0
- data/.claude/rules/git-workflow.md +56 -0
- data/.claude/rules/performance.md +73 -0
- data/.claude/rules/testing.md +67 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +80 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +417 -0
- data/Rakefile +14 -0
- data/app/controllers/pgbus/api/stats_controller.rb +11 -0
- data/app/controllers/pgbus/application_controller.rb +35 -0
- data/app/controllers/pgbus/dashboard_controller.rb +27 -0
- data/app/controllers/pgbus/dead_letter_controller.rb +50 -0
- data/app/controllers/pgbus/events_controller.rb +23 -0
- data/app/controllers/pgbus/jobs_controller.rb +48 -0
- data/app/controllers/pgbus/processes_controller.rb +10 -0
- data/app/controllers/pgbus/queues_controller.rb +21 -0
- data/app/helpers/pgbus/application_helper.rb +69 -0
- data/app/views/layouts/pgbus/application.html.erb +76 -0
- data/app/views/pgbus/dashboard/_processes_table.html.erb +30 -0
- data/app/views/pgbus/dashboard/_queues_table.html.erb +39 -0
- data/app/views/pgbus/dashboard/_recent_failures.html.erb +33 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +28 -0
- data/app/views/pgbus/dashboard/show.html.erb +10 -0
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +40 -0
- data/app/views/pgbus/dead_letter/index.html.erb +15 -0
- data/app/views/pgbus/dead_letter/show.html.erb +52 -0
- data/app/views/pgbus/events/index.html.erb +57 -0
- data/app/views/pgbus/events/show.html.erb +28 -0
- data/app/views/pgbus/jobs/_enqueued_table.html.erb +34 -0
- data/app/views/pgbus/jobs/_failed_table.html.erb +45 -0
- data/app/views/pgbus/jobs/index.html.erb +16 -0
- data/app/views/pgbus/jobs/show.html.erb +57 -0
- data/app/views/pgbus/processes/_processes_table.html.erb +37 -0
- data/app/views/pgbus/processes/index.html.erb +3 -0
- data/app/views/pgbus/queues/_queues_list.html.erb +41 -0
- data/app/views/pgbus/queues/index.html.erb +3 -0
- data/app/views/pgbus/queues/show.html.erb +49 -0
- data/bun.lock +18 -0
- data/config/routes.rb +45 -0
- data/docs/README.md +28 -0
- data/docs/switch_from_good_job.md +279 -0
- data/docs/switch_from_sidekiq.md +226 -0
- data/docs/switch_from_solid_queue.md +247 -0
- data/exe/pgbus +7 -0
- data/lib/generators/pgbus/install_generator.rb +56 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +114 -0
- data/lib/generators/pgbus/templates/pgbus.yml.erb +74 -0
- data/lib/generators/pgbus/templates/pgbus_binstub.erb +7 -0
- data/lib/pgbus/active_job/adapter.rb +109 -0
- data/lib/pgbus/active_job/executor.rb +107 -0
- data/lib/pgbus/batch.rb +153 -0
- data/lib/pgbus/cli.rb +84 -0
- data/lib/pgbus/client.rb +162 -0
- data/lib/pgbus/concurrency/blocked_execution.rb +74 -0
- data/lib/pgbus/concurrency/semaphore.rb +66 -0
- data/lib/pgbus/concurrency.rb +65 -0
- data/lib/pgbus/config_loader.rb +27 -0
- data/lib/pgbus/configuration.rb +99 -0
- data/lib/pgbus/engine.rb +31 -0
- data/lib/pgbus/event.rb +31 -0
- data/lib/pgbus/event_bus/handler.rb +76 -0
- data/lib/pgbus/event_bus/publisher.rb +42 -0
- data/lib/pgbus/event_bus/registry.rb +54 -0
- data/lib/pgbus/event_bus/subscriber.rb +30 -0
- data/lib/pgbus/process/consumer.rb +113 -0
- data/lib/pgbus/process/dispatcher.rb +154 -0
- data/lib/pgbus/process/heartbeat.rb +71 -0
- data/lib/pgbus/process/signal_handler.rb +49 -0
- data/lib/pgbus/process/supervisor.rb +198 -0
- data/lib/pgbus/process/worker.rb +153 -0
- data/lib/pgbus/serializer.rb +43 -0
- data/lib/pgbus/version.rb +5 -0
- data/lib/pgbus/web/authentication.rb +24 -0
- data/lib/pgbus/web/data_source.rb +406 -0
- data/lib/pgbus.rb +49 -0
- data/package.json +9 -0
- data/sig/pgbus.rbs +4 -0
- metadata +198 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Switch from Sidekiq to Pgbus
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Sidekiq uses Redis as its message broker. Pgbus uses PostgreSQL via PGMQ. Switching eliminates Redis from your infrastructure and gives you dead letter queues, worker recycling, and an event bus -- all backed by your existing database.
|
|
6
|
+
|
|
7
|
+
**Effort estimate:** Low if you use ActiveJob exclusively. Medium-high if you use native Sidekiq workers or Pro/Enterprise features.
|
|
8
|
+
|
|
9
|
+
## Step 1: Update dependencies
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Gemfile
|
|
13
|
+
|
|
14
|
+
# Remove
|
|
15
|
+
gem "sidekiq"
|
|
16
|
+
gem "sidekiq-cron" # if used
|
|
17
|
+
gem "sidekiq-unique-jobs" # if used
|
|
18
|
+
|
|
19
|
+
# Add
|
|
20
|
+
gem "pgbus"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bundle install
|
|
25
|
+
rails generate pgbus:install
|
|
26
|
+
rails db:migrate
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Step 2: Switch the adapter
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# config/application.rb (or config/environments/production.rb)
|
|
33
|
+
|
|
34
|
+
# Before
|
|
35
|
+
config.active_job.queue_adapter = :sidekiq
|
|
36
|
+
|
|
37
|
+
# After
|
|
38
|
+
config.active_job.queue_adapter = :pgbus
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Step 3: Convert native Sidekiq workers
|
|
42
|
+
|
|
43
|
+
If all your jobs inherit from `ApplicationJob` (ActiveJob), skip this step -- they work unchanged.
|
|
44
|
+
|
|
45
|
+
If you have native Sidekiq workers using `include Sidekiq::Job`, convert them:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Before: Native Sidekiq worker
|
|
49
|
+
class HardWorker
|
|
50
|
+
include Sidekiq::Job
|
|
51
|
+
sidekiq_options queue: :critical, retry: 5
|
|
52
|
+
|
|
53
|
+
def perform(user_id, action)
|
|
54
|
+
user = User.find(user_id)
|
|
55
|
+
# ...
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Enqueue
|
|
60
|
+
HardWorker.perform_async(user.id, "activate")
|
|
61
|
+
HardWorker.perform_at(5.minutes.from_now, user.id, "activate")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# After: ActiveJob
|
|
66
|
+
class HardWorker < ApplicationJob
|
|
67
|
+
queue_as :critical
|
|
68
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 5
|
|
69
|
+
|
|
70
|
+
def perform(user_id, action)
|
|
71
|
+
user = User.find(user_id)
|
|
72
|
+
# ...
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Enqueue
|
|
77
|
+
HardWorker.perform_later(user.id, "activate")
|
|
78
|
+
HardWorker.set(wait: 5.minutes).perform_later(user.id, "activate")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### API mapping
|
|
82
|
+
|
|
83
|
+
| Sidekiq | ActiveJob / Pgbus |
|
|
84
|
+
|---------|-------------------|
|
|
85
|
+
| `perform_async(args)` | `perform_later(args)` |
|
|
86
|
+
| `perform_at(time, args)` | `.set(wait_until: time).perform_later(args)` |
|
|
87
|
+
| `perform_in(duration, args)` | `.set(wait: duration).perform_later(args)` |
|
|
88
|
+
| `sidekiq_options queue: :name` | `queue_as :name` |
|
|
89
|
+
| `sidekiq_options retry: N` | `retry_on StandardError, attempts: N` |
|
|
90
|
+
| `sidekiq_retries_exhausted` | `discard_on` + `after_discard` callback |
|
|
91
|
+
|
|
92
|
+
## Step 4: Replace middleware with ActiveJob callbacks
|
|
93
|
+
|
|
94
|
+
Sidekiq middleware wraps job push (client) and execution (server) in a Rack-style chain. ActiveJob provides equivalent hooks.
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# Before: Sidekiq server middleware
|
|
98
|
+
class LoggingMiddleware
|
|
99
|
+
include Sidekiq::ServerMiddleware
|
|
100
|
+
def call(job_instance, job_payload, queue)
|
|
101
|
+
Rails.logger.info("Starting #{job_payload['class']}")
|
|
102
|
+
yield
|
|
103
|
+
Rails.logger.info("Finished #{job_payload['class']}")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# After: ActiveJob callback (add to ApplicationJob or per-job)
|
|
110
|
+
class ApplicationJob < ActiveJob::Base
|
|
111
|
+
around_perform do |job, block|
|
|
112
|
+
Rails.logger.info("Starting #{job.class.name}")
|
|
113
|
+
block.call
|
|
114
|
+
Rails.logger.info("Finished #{job.class.name}")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Callback mapping
|
|
120
|
+
|
|
121
|
+
| Sidekiq middleware | ActiveJob callback |
|
|
122
|
+
|--------------------|-------------------|
|
|
123
|
+
| Server middleware (before yield) | `before_perform` |
|
|
124
|
+
| Server middleware (around yield) | `around_perform` |
|
|
125
|
+
| Server middleware (after yield) | `after_perform` |
|
|
126
|
+
| Client middleware (before yield) | `before_enqueue` |
|
|
127
|
+
| Client middleware (around yield) | `around_enqueue` |
|
|
128
|
+
| Client middleware (after yield) | `after_enqueue` |
|
|
129
|
+
|
|
130
|
+
## Step 5: Configure workers
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
# Before: config/sidekiq.yml
|
|
134
|
+
:concurrency: 10
|
|
135
|
+
:queues:
|
|
136
|
+
- [critical, 3]
|
|
137
|
+
- [default, 2]
|
|
138
|
+
- [low, 1]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```yaml
|
|
142
|
+
# After: config/pgbus.yml
|
|
143
|
+
production:
|
|
144
|
+
workers:
|
|
145
|
+
- queues: [critical]
|
|
146
|
+
threads: 5
|
|
147
|
+
- queues: [default, low]
|
|
148
|
+
threads: 10
|
|
149
|
+
max_jobs_per_worker: 10000
|
|
150
|
+
max_memory_mb: 512
|
|
151
|
+
max_worker_lifetime: 3600
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Step 6: Replace the dashboard
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# Before: config/routes.rb
|
|
158
|
+
require "sidekiq/web"
|
|
159
|
+
mount Sidekiq::Web => "/sidekiq"
|
|
160
|
+
|
|
161
|
+
# After:
|
|
162
|
+
mount Pgbus::Engine => "/pgbus"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
If you used `Sidekiq::Web`'s authentication:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
Pgbus.configure do |config|
|
|
169
|
+
config.web_auth = ->(request) {
|
|
170
|
+
request.env["warden"].user&.admin?
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Step 7: Update process management
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
# Before
|
|
179
|
+
bundle exec sidekiq
|
|
180
|
+
|
|
181
|
+
# After
|
|
182
|
+
bundle exec pgbus start
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Update your `Procfile`, systemd units, or container entrypoints accordingly.
|
|
186
|
+
|
|
187
|
+
## Step 8: Remove Redis
|
|
188
|
+
|
|
189
|
+
Once all Sidekiq jobs have drained and you've verified Pgbus is processing correctly, remove Redis from your infrastructure:
|
|
190
|
+
|
|
191
|
+
- Remove `REDIS_URL` / Sidekiq Redis config from environment
|
|
192
|
+
- Remove `Sidekiq.configure_server` / `Sidekiq.configure_client` blocks
|
|
193
|
+
- Remove Redis from `docker-compose.yml`, Terraform, etc.
|
|
194
|
+
|
|
195
|
+
## What you gain
|
|
196
|
+
|
|
197
|
+
- **No Redis dependency** -- one fewer service to operate
|
|
198
|
+
- **Dead letter queues** -- failed jobs route to `_dlq` queues after `max_retries`, visible in the dashboard
|
|
199
|
+
- **Worker recycling** -- memory, job count, and lifetime limits prevent the memory bloat that plagues long-running Sidekiq processes
|
|
200
|
+
- **Event bus** -- AMQP-style topic routing for pub/sub, built into the same infrastructure
|
|
201
|
+
- **LISTEN/NOTIFY** -- instant job wake-up without polling overhead
|
|
202
|
+
|
|
203
|
+
## What you lose (for now)
|
|
204
|
+
|
|
205
|
+
| Sidekiq feature | Status in Pgbus |
|
|
206
|
+
|-----------------|-----------------|
|
|
207
|
+
| Batches (Pro) | `Pgbus::Batch` with on_finish/on_success/on_discard callbacks |
|
|
208
|
+
| Rate limiting (Enterprise) | Planned |
|
|
209
|
+
| Concurrency controls (Enterprise) | `Pgbus::Concurrency` with `limits_concurrency` DSL |
|
|
210
|
+
| Unique jobs (Enterprise / `sidekiq-unique-jobs`) | Partial -- event bus has idempotency; job-level dedup planned |
|
|
211
|
+
| Cron / recurring jobs (`sidekiq-cron`) | Planned |
|
|
212
|
+
| Real-time metrics (Sidekiq Web) | Pgbus dashboard covers queue depth, failures, processes |
|
|
213
|
+
|
|
214
|
+
## Gotchas
|
|
215
|
+
|
|
216
|
+
1. **Argument serialization**: Sidekiq passes raw JSON; ActiveJob uses GlobalID for ActiveRecord objects. If you pass raw IDs (`user.id`), both work the same. If you pass AR objects (`user`), ActiveJob serializes via GlobalID automatically.
|
|
217
|
+
|
|
218
|
+
2. **`Sidekiq::Testing.fake!`**: Replace with `ActiveJob::TestHelper`:
|
|
219
|
+
```ruby
|
|
220
|
+
include ActiveJob::TestHelper
|
|
221
|
+
assert_enqueued_jobs 1 { MyJob.perform_later(args) }
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
3. **`Sidekiq.redis { |conn| ... }`**: If you use Sidekiq's Redis connection for custom caching or distributed locks, you'll need a separate solution (e.g., PostgreSQL advisory locks, `with_advisory_lock` gem).
|
|
225
|
+
|
|
226
|
+
4. **Job priorities**: Sidekiq uses queue weight ordering. Pgbus processes queues in the order listed in the worker config. Put higher-priority queues first.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Switch from SolidQueue to Pgbus
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
SolidQueue and Pgbus are both PostgreSQL-backed job processors with similar architectures: supervisor/worker process model, `FOR UPDATE SKIP LOCKED` for contention-free polling, and forked processes. The migration is straightforward since both are ActiveJob adapters.
|
|
6
|
+
|
|
7
|
+
**Key differences:** Pgbus adds LISTEN/NOTIFY for instant wake-up (SolidQueue only polls), dead letter queues, worker recycling, and an event bus. Pgbus uses PGMQ under the hood instead of custom tables.
|
|
8
|
+
|
|
9
|
+
**Effort estimate:** Low. Both are pure ActiveJob adapters, so your jobs work unchanged.
|
|
10
|
+
|
|
11
|
+
## Step 1: Update dependencies
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
|
|
16
|
+
# Remove
|
|
17
|
+
gem "solid_queue"
|
|
18
|
+
gem "mission_control-jobs" # if used
|
|
19
|
+
|
|
20
|
+
# Add
|
|
21
|
+
gem "pgbus"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
rails generate pgbus:install
|
|
27
|
+
rails db:migrate
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Step 2: Switch the adapter
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# config/application.rb
|
|
34
|
+
|
|
35
|
+
# Before
|
|
36
|
+
config.active_job.queue_adapter = :solid_queue
|
|
37
|
+
|
|
38
|
+
# After
|
|
39
|
+
config.active_job.queue_adapter = :pgbus
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If you set the adapter per-environment or use `config.solid_queue.connects_to` for a separate queue database, update those too.
|
|
43
|
+
|
|
44
|
+
## Step 3: Convert worker configuration
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
# Before: config/queue.yml (SolidQueue)
|
|
48
|
+
production:
|
|
49
|
+
dispatchers:
|
|
50
|
+
- polling_interval: 1
|
|
51
|
+
batch_size: 500
|
|
52
|
+
workers:
|
|
53
|
+
- queues: "critical"
|
|
54
|
+
threads: 5
|
|
55
|
+
processes: 2
|
|
56
|
+
polling_interval: 0.1
|
|
57
|
+
- queues: "default,low"
|
|
58
|
+
threads: 3
|
|
59
|
+
processes: 3
|
|
60
|
+
polling_interval: 1
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
# After: config/pgbus.yml
|
|
65
|
+
production:
|
|
66
|
+
workers:
|
|
67
|
+
- queues: [critical]
|
|
68
|
+
threads: 5
|
|
69
|
+
- queues: [critical]
|
|
70
|
+
threads: 5
|
|
71
|
+
- queues: [default, low]
|
|
72
|
+
threads: 3
|
|
73
|
+
- queues: [default, low]
|
|
74
|
+
threads: 3
|
|
75
|
+
- queues: [default, low]
|
|
76
|
+
threads: 3
|
|
77
|
+
max_retries: 5
|
|
78
|
+
max_jobs_per_worker: 10000
|
|
79
|
+
max_memory_mb: 512
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
> **Note:** SolidQueue's `processes: N` forks N identical workers. In Pgbus, list the same worker config N times, or let the supervisor handle it via configuration (one entry per process).
|
|
83
|
+
|
|
84
|
+
### Configuration mapping
|
|
85
|
+
|
|
86
|
+
| SolidQueue | Pgbus | Notes |
|
|
87
|
+
|------------|-------|-------|
|
|
88
|
+
| `polling_interval` | `polling_interval` | Pgbus defaults to 0.1s; LISTEN/NOTIFY makes this a fallback only |
|
|
89
|
+
| `threads` | `threads` | Same concept |
|
|
90
|
+
| `processes` | Repeat worker entry | One entry per forked process |
|
|
91
|
+
| `dispatchers[].batch_size` | N/A | Pgbus dispatcher does maintenance, not dispatch |
|
|
92
|
+
| `queues: "a,b"` (string) | `queues: [a, b]` (array) | Different format |
|
|
93
|
+
| `queues: "*"` (wildcard) | List queues explicitly | PGMQ queues are explicit |
|
|
94
|
+
|
|
95
|
+
## Step 4: Remove concurrency controls
|
|
96
|
+
|
|
97
|
+
SolidQueue's `limits_concurrency` is a SolidQueue-specific mixin. Remove it from your jobs:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# Before: SolidQueue concurrency control
|
|
101
|
+
class ProcessOrderJob < ApplicationJob
|
|
102
|
+
limits_concurrency to: 1,
|
|
103
|
+
key: ->(order) { order.account_id },
|
|
104
|
+
duration: 15.minutes,
|
|
105
|
+
on_conflict: :block
|
|
106
|
+
|
|
107
|
+
def perform(order)
|
|
108
|
+
# ...
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
# After: Remove the SolidQueue mixin
|
|
115
|
+
class ProcessOrderJob < ApplicationJob
|
|
116
|
+
def perform(order)
|
|
117
|
+
# ...
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
> Pgbus supports concurrency controls via `Pgbus::Concurrency`:
|
|
123
|
+
> ```ruby
|
|
124
|
+
> class ProcessOrderJob < ApplicationJob
|
|
125
|
+
> include Pgbus::Concurrency
|
|
126
|
+
> limits_concurrency to: 1,
|
|
127
|
+
> key: ->(order) { order.account_id },
|
|
128
|
+
> duration: 15.minutes,
|
|
129
|
+
> on_conflict: :block
|
|
130
|
+
> def perform(order)
|
|
131
|
+
> # ...
|
|
132
|
+
> end
|
|
133
|
+
> end
|
|
134
|
+
> ```
|
|
135
|
+
|
|
136
|
+
## Step 5: Migrate recurring tasks
|
|
137
|
+
|
|
138
|
+
If you use SolidQueue's `config/recurring.yml`:
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
# Before: config/recurring.yml (SolidQueue)
|
|
142
|
+
production:
|
|
143
|
+
daily_cleanup:
|
|
144
|
+
class: CleanupJob
|
|
145
|
+
schedule: "every day at 2am"
|
|
146
|
+
queue: maintenance
|
|
147
|
+
hourly_sync:
|
|
148
|
+
class: SyncJob
|
|
149
|
+
schedule: "0 * * * *"
|
|
150
|
+
args: [42, "sync"]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Pgbus does not yet have built-in recurring task support. Options:
|
|
154
|
+
|
|
155
|
+
1. **Use the `whenever` gem** for cron-based scheduling:
|
|
156
|
+
```ruby
|
|
157
|
+
# config/schedule.rb (whenever gem)
|
|
158
|
+
every 1.day, at: "2:00 am" do
|
|
159
|
+
runner "CleanupJob.perform_later"
|
|
160
|
+
end
|
|
161
|
+
every :hour do
|
|
162
|
+
runner "SyncJob.perform_later(42, 'sync')"
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
2. **Use system cron** directly:
|
|
167
|
+
```cron
|
|
168
|
+
0 2 * * * cd /app && bin/rails runner "CleanupJob.perform_later"
|
|
169
|
+
0 * * * * cd /app && bin/rails runner "SyncJob.perform_later(42, 'sync')"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
3. Wait for Pgbus recurring task support (planned).
|
|
173
|
+
|
|
174
|
+
## Step 6: Replace the dashboard
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# Before: config/routes.rb
|
|
178
|
+
mount MissionControl::Jobs::Engine, at: "/jobs"
|
|
179
|
+
|
|
180
|
+
# After:
|
|
181
|
+
mount Pgbus::Engine => "/pgbus"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Step 7: Update process management
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
# Before
|
|
188
|
+
bundle exec rake solid_queue:start
|
|
189
|
+
|
|
190
|
+
# After
|
|
191
|
+
bundle exec pgbus start
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Step 8: Clean up SolidQueue tables
|
|
195
|
+
|
|
196
|
+
After verifying Pgbus processes jobs correctly and SolidQueue's tables are drained:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class RemoveSolidQueue < ActiveRecord::Migration[7.1]
|
|
200
|
+
def up
|
|
201
|
+
drop_table :solid_queue_blocked_executions, if_exists: true
|
|
202
|
+
drop_table :solid_queue_claimed_executions, if_exists: true
|
|
203
|
+
drop_table :solid_queue_failed_executions, if_exists: true
|
|
204
|
+
drop_table :solid_queue_pauses, if_exists: true
|
|
205
|
+
drop_table :solid_queue_processes, if_exists: true
|
|
206
|
+
drop_table :solid_queue_ready_executions, if_exists: true
|
|
207
|
+
drop_table :solid_queue_recurring_executions, if_exists: true
|
|
208
|
+
drop_table :solid_queue_recurring_tasks, if_exists: true
|
|
209
|
+
drop_table :solid_queue_scheduled_executions, if_exists: true
|
|
210
|
+
drop_table :solid_queue_semaphores, if_exists: true
|
|
211
|
+
drop_table :solid_queue_jobs, if_exists: true
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## What you gain
|
|
217
|
+
|
|
218
|
+
- **LISTEN/NOTIFY** -- SolidQueue only polls (100-150ms latency baseline). Pgbus wakes workers instantly via PostgreSQL LISTEN/NOTIFY, with polling as fallback only.
|
|
219
|
+
- **Dead letter queues** -- SolidQueue marks jobs as failed but keeps them in the same table. Pgbus routes failures to dedicated `_dlq` queues after `max_retries` for clear separation.
|
|
220
|
+
- **Worker recycling** -- SolidQueue workers run indefinitely. Pgbus recycles workers by job count, memory, or lifetime to prevent memory bloat.
|
|
221
|
+
- **Event bus** -- AMQP-style pub/sub with topic routing, not available in SolidQueue.
|
|
222
|
+
- **PGMQ under the hood** -- battle-tested message queue extension with visibility timeouts and atomic operations.
|
|
223
|
+
|
|
224
|
+
## What you lose (for now)
|
|
225
|
+
|
|
226
|
+
| SolidQueue feature | Status in Pgbus |
|
|
227
|
+
|--------------------|-----------------|
|
|
228
|
+
| `limits_concurrency` | `Pgbus::Concurrency` with `limits_concurrency` DSL |
|
|
229
|
+
| `config/recurring.yml` | Planned |
|
|
230
|
+
| Queue pausing (`SolidQueue::Queue.pause`) | Planned |
|
|
231
|
+
| Separate queue database | Not planned (PGMQ lives in your primary DB) |
|
|
232
|
+
| Numeric job priorities | PGMQ reads in FIFO order per queue; use separate queues for priority |
|
|
233
|
+
|
|
234
|
+
## Gotchas
|
|
235
|
+
|
|
236
|
+
1. **PgBouncer**: If you run PgBouncer in transaction mode, LISTEN/NOTIFY won't work. Use session mode or direct connections for Pgbus worker processes. This also applies to PGMQ's `FOR UPDATE SKIP LOCKED`.
|
|
237
|
+
|
|
238
|
+
2. **Separate queue database**: SolidQueue supports `connects_to` for a dedicated queue DB. Pgbus requires PGMQ in the same database it connects to. If you need isolation, use a separate PostgreSQL database with PGMQ installed and configure `Pgbus.configuration.database_url`.
|
|
239
|
+
|
|
240
|
+
3. **Queue naming**: SolidQueue uses bare queue names (`default`). Pgbus prefixes all queues (`pgbus_default`). Your `queue_as :default` declarations work unchanged -- the prefix is applied automatically.
|
|
241
|
+
|
|
242
|
+
4. **`ActionMailer::MailDeliveryJob`**: This can bypass the application-level adapter setting in some Rails versions. If mailer jobs don't appear in Pgbus, add to `ApplicationMailer`:
|
|
243
|
+
```ruby
|
|
244
|
+
class ApplicationMailer < ActionMailer::Base
|
|
245
|
+
self.deliver_later_queue_name = :mailers
|
|
246
|
+
end
|
|
247
|
+
```
|
data/exe/pgbus
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Install Pgbus: create migration, config file, and binstub"
|
|
14
|
+
|
|
15
|
+
def create_migration
|
|
16
|
+
migration_template "migration.rb.erb", "db/migrate/install_pgbus.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_config_file
|
|
20
|
+
template "pgbus.yml.erb", "config/pgbus.yml"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_binstub
|
|
24
|
+
template "pgbus_binstub.erb", "bin/pgbus"
|
|
25
|
+
chmod "bin/pgbus", 0o755
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def configure_active_job
|
|
29
|
+
application_config = "config.active_job.queue_adapter = :pgbus"
|
|
30
|
+
|
|
31
|
+
return unless File.exist?(File.join(destination_root, "config", "application.rb"))
|
|
32
|
+
|
|
33
|
+
inject_into_file "config/application.rb",
|
|
34
|
+
"\n #{application_config}\n",
|
|
35
|
+
after: "class Application < Rails::Application\n"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def display_post_install
|
|
39
|
+
say ""
|
|
40
|
+
say "Pgbus installed successfully!", :green
|
|
41
|
+
say ""
|
|
42
|
+
say "Next steps:"
|
|
43
|
+
say " 1. Run: rails db:migrate"
|
|
44
|
+
say " 2. Edit config/pgbus.yml to configure workers"
|
|
45
|
+
say " 3. Start processing: bin/pgbus start"
|
|
46
|
+
say ""
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def migration_version
|
|
52
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
class InstallPgbus < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
# Install PGMQ extension
|
|
4
|
+
enable_extension "pgmq"
|
|
5
|
+
|
|
6
|
+
# Idempotent event processing deduplication
|
|
7
|
+
create_table :pgbus_processed_events do |t|
|
|
8
|
+
t.string :event_id, null: false
|
|
9
|
+
t.string :handler_class, null: false
|
|
10
|
+
t.datetime :processed_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :pgbus_processed_events, [:event_id, :handler_class],
|
|
14
|
+
unique: true, name: "idx_pgbus_processed_events_unique"
|
|
15
|
+
add_index :pgbus_processed_events, :processed_at,
|
|
16
|
+
name: "idx_pgbus_processed_events_cleanup"
|
|
17
|
+
|
|
18
|
+
# Process registry (heartbeats, monitoring)
|
|
19
|
+
create_table :pgbus_processes do |t|
|
|
20
|
+
t.string :kind, null: false
|
|
21
|
+
t.string :hostname
|
|
22
|
+
t.integer :pid
|
|
23
|
+
t.jsonb :metadata, default: {}
|
|
24
|
+
t.datetime :last_heartbeat_at
|
|
25
|
+
t.timestamps
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
add_index :pgbus_processes, :last_heartbeat_at,
|
|
29
|
+
name: "idx_pgbus_processes_heartbeat"
|
|
30
|
+
|
|
31
|
+
# Failed events log (for dashboard visibility)
|
|
32
|
+
create_table :pgbus_failed_events do |t|
|
|
33
|
+
t.string :queue_name, null: false
|
|
34
|
+
t.bigint :msg_id
|
|
35
|
+
t.jsonb :payload
|
|
36
|
+
t.jsonb :headers
|
|
37
|
+
t.string :error_class
|
|
38
|
+
t.text :error_message
|
|
39
|
+
t.text :backtrace
|
|
40
|
+
t.integer :retry_count, default: 0
|
|
41
|
+
t.datetime :failed_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
add_index :pgbus_failed_events, :queue_name, name: "idx_pgbus_failed_events_queue"
|
|
45
|
+
add_index :pgbus_failed_events, :failed_at, name: "idx_pgbus_failed_events_time"
|
|
46
|
+
|
|
47
|
+
# Concurrency semaphores (counting locks for job concurrency limits)
|
|
48
|
+
create_table :pgbus_semaphores do |t|
|
|
49
|
+
t.string :key, null: false
|
|
50
|
+
t.integer :value, null: false, default: 0
|
|
51
|
+
t.integer :max_value, null: false, default: 1
|
|
52
|
+
t.datetime :expires_at, null: false
|
|
53
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
add_index :pgbus_semaphores, :key,
|
|
57
|
+
unique: true, name: "idx_pgbus_semaphores_key"
|
|
58
|
+
add_index :pgbus_semaphores, :expires_at,
|
|
59
|
+
name: "idx_pgbus_semaphores_expired"
|
|
60
|
+
|
|
61
|
+
# Blocked executions (jobs waiting for a concurrency semaphore)
|
|
62
|
+
create_table :pgbus_blocked_executions do |t|
|
|
63
|
+
t.string :concurrency_key, null: false
|
|
64
|
+
t.string :queue_name, null: false
|
|
65
|
+
t.jsonb :payload, null: false
|
|
66
|
+
t.integer :priority, null: false, default: 0
|
|
67
|
+
t.datetime :expires_at, null: false
|
|
68
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
add_index :pgbus_blocked_executions, [:concurrency_key, :priority, :created_at],
|
|
72
|
+
name: "idx_pgbus_blocked_release_order"
|
|
73
|
+
|
|
74
|
+
# Batch tracking (coordinated job groups with callbacks)
|
|
75
|
+
create_table :pgbus_batches do |t|
|
|
76
|
+
t.string :batch_id, null: false
|
|
77
|
+
t.string :description
|
|
78
|
+
t.string :on_finish_class
|
|
79
|
+
t.string :on_success_class
|
|
80
|
+
t.string :on_discard_class
|
|
81
|
+
t.jsonb :properties, default: {}
|
|
82
|
+
t.integer :total_jobs, null: false, default: 0
|
|
83
|
+
t.integer :completed_jobs, null: false, default: 0
|
|
84
|
+
t.integer :failed_jobs, null: false, default: 0
|
|
85
|
+
t.integer :discarded_jobs, null: false, default: 0
|
|
86
|
+
t.string :status, null: false, default: "pending"
|
|
87
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
88
|
+
t.datetime :finished_at
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
add_index :pgbus_batches, :batch_id,
|
|
92
|
+
unique: true, name: "idx_pgbus_batches_batch_id"
|
|
93
|
+
add_index :pgbus_batches, :status,
|
|
94
|
+
name: "idx_pgbus_batches_status"
|
|
95
|
+
|
|
96
|
+
# Create default queues via PGMQ
|
|
97
|
+
execute "SELECT pgmq.create('pgbus_default')"
|
|
98
|
+
execute "SELECT pgmq.create('pgbus_default_dlq')"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def down
|
|
102
|
+
execute "SELECT pgmq.drop_queue('pgbus_default_dlq')"
|
|
103
|
+
execute "SELECT pgmq.drop_queue('pgbus_default')"
|
|
104
|
+
|
|
105
|
+
drop_table :pgbus_batches
|
|
106
|
+
drop_table :pgbus_blocked_executions
|
|
107
|
+
drop_table :pgbus_semaphores
|
|
108
|
+
drop_table :pgbus_failed_events
|
|
109
|
+
drop_table :pgbus_processes
|
|
110
|
+
drop_table :pgbus_processed_events
|
|
111
|
+
|
|
112
|
+
disable_extension "pgmq"
|
|
113
|
+
end
|
|
114
|
+
end
|