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
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mhenrixon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
# Pgbus
|
|
2
|
+
|
|
3
|
+
PostgreSQL-native job processing and event bus for Rails, built on [PGMQ](https://github.com/tembo-io/pgmq).
|
|
4
|
+
|
|
5
|
+
**Why Pgbus?** If you already run PostgreSQL, you don't need Redis for background jobs. Pgbus gives you ActiveJob integration, AMQP-style topic routing, dead letter queues, worker memory management, and a live dashboard -- all backed by your existing database.
|
|
6
|
+
|
|
7
|
+
[](https://github.com/mhenrixon/pgbus/actions/workflows/main.yml)
|
|
8
|
+
|
|
9
|
+
## Table of contents
|
|
10
|
+
|
|
11
|
+
- [Features](#features)
|
|
12
|
+
- [Requirements](#requirements)
|
|
13
|
+
- [Installation](#installation)
|
|
14
|
+
- [Quick start](#quick-start)
|
|
15
|
+
- [Concurrency controls](#concurrency-controls)
|
|
16
|
+
- [Batches](#batches)
|
|
17
|
+
- [Configuration reference](#configuration-reference)
|
|
18
|
+
- [Architecture](#architecture)
|
|
19
|
+
- [CLI](#cli)
|
|
20
|
+
- [Dashboard](#dashboard)
|
|
21
|
+
- [Database tables](#database-tables)
|
|
22
|
+
- [Switching from another backend](#switching-from-another-backend)
|
|
23
|
+
- [Development](#development)
|
|
24
|
+
- [License](#license)
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- **ActiveJob adapter** -- drop-in replacement, zero config migration from other backends
|
|
29
|
+
- **Event bus** -- publish/subscribe with AMQP-style topic routing (`orders.#`, `payments.*`)
|
|
30
|
+
- **Dead letter queues** -- automatic DLQ routing after configurable retries
|
|
31
|
+
- **Worker recycling** -- memory, job count, and lifetime limits prevent runaway processes
|
|
32
|
+
- **LISTEN/NOTIFY** -- instant wake-up, polling as fallback only
|
|
33
|
+
- **Idempotent events** -- deduplication via `(event_id, handler_class)` unique index
|
|
34
|
+
- **Live dashboard** -- Turbo Frames auto-refresh, no ActionCable required
|
|
35
|
+
- **Supervisor/worker model** -- forked processes with heartbeat monitoring
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
- Ruby >= 3.3
|
|
40
|
+
- Rails >= 7.1
|
|
41
|
+
- PostgreSQL with the [PGMQ extension](https://github.com/tembo-io/pgmq)
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
Add to your Gemfile:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
gem "pgbus"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then install the PGMQ extension in your database:
|
|
52
|
+
|
|
53
|
+
```sql
|
|
54
|
+
CREATE EXTENSION IF NOT EXISTS pgmq;
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
### 1. Configure (optional)
|
|
60
|
+
|
|
61
|
+
Pgbus works with zero config in Rails -- it uses your existing `ActiveRecord` connection. For custom setups, create `config/pgbus.yml`:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
production:
|
|
65
|
+
queue_prefix: myapp
|
|
66
|
+
default_queue: default
|
|
67
|
+
pool_size: 10
|
|
68
|
+
max_retries: 5
|
|
69
|
+
workers:
|
|
70
|
+
- queues: [default, mailers]
|
|
71
|
+
threads: 10
|
|
72
|
+
- queues: [critical]
|
|
73
|
+
threads: 5
|
|
74
|
+
event_consumers:
|
|
75
|
+
- queues: [orders, payments]
|
|
76
|
+
threads: 5
|
|
77
|
+
max_jobs_per_worker: 10000
|
|
78
|
+
max_memory_mb: 512
|
|
79
|
+
max_worker_lifetime: 3600
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or configure in an initializer:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# config/initializers/pgbus.rb
|
|
86
|
+
Pgbus.configure do |config|
|
|
87
|
+
config.queue_prefix = "myapp"
|
|
88
|
+
config.max_retries = 5
|
|
89
|
+
config.max_jobs_per_worker = 10_000
|
|
90
|
+
config.max_memory_mb = 512
|
|
91
|
+
config.max_worker_lifetime = 3600
|
|
92
|
+
|
|
93
|
+
config.workers = [
|
|
94
|
+
{ queues: %w[default mailers], threads: 10 },
|
|
95
|
+
{ queues: %w[critical], threads: 5 }
|
|
96
|
+
]
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 2. Use as ActiveJob backend
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# config/application.rb
|
|
104
|
+
config.active_job.queue_adapter = :pgbus
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
That's it. Your existing jobs work unchanged:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
class OrderConfirmationJob < ApplicationJob
|
|
111
|
+
queue_as :mailers
|
|
112
|
+
|
|
113
|
+
def perform(order)
|
|
114
|
+
OrderMailer.confirmation(order).deliver_now
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Enqueue
|
|
119
|
+
OrderConfirmationJob.perform_later(order)
|
|
120
|
+
|
|
121
|
+
# Schedule
|
|
122
|
+
OrderConfirmationJob.set(wait: 5.minutes).perform_later(order)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 3. Event bus (optional)
|
|
126
|
+
|
|
127
|
+
Publish events with AMQP-style topic routing:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
# Publish an event
|
|
131
|
+
Pgbus::EventBus::Publisher.publish(
|
|
132
|
+
"orders.created",
|
|
133
|
+
{ order_id: order.id, total: order.total }
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Publish with delay
|
|
137
|
+
Pgbus::EventBus::Publisher.publish_later(
|
|
138
|
+
"invoices.due",
|
|
139
|
+
{ invoice_id: invoice.id },
|
|
140
|
+
delay: 30.days
|
|
141
|
+
)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Subscribe with handlers:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
# app/handlers/order_created_handler.rb
|
|
148
|
+
class OrderCreatedHandler < Pgbus::EventBus::Handler
|
|
149
|
+
idempotent! # Deduplicate by (event_id, handler_class)
|
|
150
|
+
|
|
151
|
+
def handle(event)
|
|
152
|
+
order_id = event.payload["order_id"]
|
|
153
|
+
Analytics.track_order(order_id)
|
|
154
|
+
InventoryService.reserve(order_id)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Register in an initializer
|
|
159
|
+
Pgbus::EventBus::Registry.instance.subscribe(
|
|
160
|
+
"orders.created",
|
|
161
|
+
OrderCreatedHandler
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Wildcard patterns
|
|
165
|
+
Pgbus::EventBus::Registry.instance.subscribe(
|
|
166
|
+
"orders.#", # matches orders.created, orders.updated, orders.shipped.confirmed
|
|
167
|
+
OrderAuditHandler
|
|
168
|
+
)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### 4. Start workers
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
bundle exec pgbus start
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
This boots a supervisor that manages:
|
|
178
|
+
- **Workers** -- process ActiveJob queues
|
|
179
|
+
- **Dispatcher** -- runs maintenance tasks (idempotency cleanup, stale process reaping)
|
|
180
|
+
- **Consumers** -- process event bus messages
|
|
181
|
+
|
|
182
|
+
### 5. Mount the dashboard
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# config/routes.rb
|
|
186
|
+
mount Pgbus::Engine => "/pgbus"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The dashboard shows queues, jobs, processes, failures, dead letter messages, and event subscribers. It auto-refreshes via Turbo Frames with no WebSocket dependency.
|
|
190
|
+
|
|
191
|
+
Protect it in production:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
Pgbus.configure do |config|
|
|
195
|
+
config.web_auth = ->(request) {
|
|
196
|
+
request.env["warden"].user&.admin?
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Concurrency controls
|
|
202
|
+
|
|
203
|
+
Limit how many jobs with the same key can run concurrently:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
class ProcessOrderJob < ApplicationJob
|
|
207
|
+
include Pgbus::Concurrency
|
|
208
|
+
|
|
209
|
+
limits_concurrency to: 1,
|
|
210
|
+
key: ->(order_id) { "ProcessOrder-#{order_id}" },
|
|
211
|
+
duration: 15.minutes,
|
|
212
|
+
on_conflict: :block
|
|
213
|
+
|
|
214
|
+
def perform(order_id)
|
|
215
|
+
# Only one job per order_id runs at a time
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Options
|
|
221
|
+
|
|
222
|
+
| Option | Default | Description |
|
|
223
|
+
|--------|---------|-------------|
|
|
224
|
+
| `to:` | (required) | Maximum concurrent jobs for the same key |
|
|
225
|
+
| `key:` | Job class name | Proc receiving job arguments, returns a string key |
|
|
226
|
+
| `duration:` | `15.minutes` | Safety expiry for the semaphore (crashed worker recovery) |
|
|
227
|
+
| `on_conflict:` | `:block` | What to do when the limit is reached |
|
|
228
|
+
|
|
229
|
+
### Conflict strategies
|
|
230
|
+
|
|
231
|
+
| Strategy | Behavior |
|
|
232
|
+
|----------|----------|
|
|
233
|
+
| `:block` | Hold the job in a blocked queue. It is automatically released when a slot opens or the semaphore expires. |
|
|
234
|
+
| `:discard` | Silently drop the job. |
|
|
235
|
+
| `:raise` | Raise `Pgbus::ConcurrencyLimitExceeded` so the caller can handle it. |
|
|
236
|
+
|
|
237
|
+
### How it works
|
|
238
|
+
|
|
239
|
+
1. **Enqueue**: The adapter checks a semaphore table for the concurrency key. If under the limit, it increments the counter and sends the job to PGMQ. If at the limit, it applies the `on_conflict` strategy.
|
|
240
|
+
2. **Complete**: After a job succeeds or is dead-lettered, the executor decrements the semaphore and releases the next blocked job (if any).
|
|
241
|
+
3. **Safety net**: The dispatcher periodically cleans up expired semaphores and orphaned blocked executions to recover from crashed workers.
|
|
242
|
+
|
|
243
|
+
## Batches
|
|
244
|
+
|
|
245
|
+
Coordinate groups of jobs with callbacks when all complete:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
batch = Pgbus::Batch.new(
|
|
249
|
+
on_finish: BatchFinishedJob,
|
|
250
|
+
on_success: BatchSucceededJob,
|
|
251
|
+
on_discard: BatchFailedJob,
|
|
252
|
+
description: "Import users",
|
|
253
|
+
properties: { initiated_by: current_user.id }
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
batch.enqueue do
|
|
257
|
+
users.each { |user| ImportUserJob.perform_later(user.id) }
|
|
258
|
+
end
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Callbacks
|
|
262
|
+
|
|
263
|
+
| Callback | Fired when |
|
|
264
|
+
|----------|------------|
|
|
265
|
+
| `on_finish` | All jobs completed (success or discard) |
|
|
266
|
+
| `on_success` | All jobs completed successfully (zero discarded) |
|
|
267
|
+
| `on_discard` | At least one job was dead-lettered |
|
|
268
|
+
|
|
269
|
+
Callback jobs receive the batch `properties` hash as their argument:
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
class BatchFinishedJob < ApplicationJob
|
|
273
|
+
def perform(properties)
|
|
274
|
+
user = User.find(properties["initiated_by"])
|
|
275
|
+
ImportMailer.complete(user).deliver_later
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### How it works
|
|
281
|
+
|
|
282
|
+
1. `Batch.new(...)` creates a tracking row in `pgbus_batches` with `status: "pending"`
|
|
283
|
+
2. `batch.enqueue { ... }` tags each enqueued job with the `pgbus_batch_id` in its payload
|
|
284
|
+
3. After each job completes or is dead-lettered, the executor atomically updates the batch counters
|
|
285
|
+
4. When `completed_jobs + discarded_jobs == total_jobs`, the batch status flips to `"finished"` and callback jobs are enqueued
|
|
286
|
+
5. The dispatcher cleans up finished batches older than 7 days
|
|
287
|
+
|
|
288
|
+
## Configuration reference
|
|
289
|
+
|
|
290
|
+
| Option | Default | Description |
|
|
291
|
+
|--------|---------|-------------|
|
|
292
|
+
| `database_url` | `nil` | PostgreSQL connection URL (auto-detected in Rails) |
|
|
293
|
+
| `queue_prefix` | `"pgbus"` | Prefix for all PGMQ queue names |
|
|
294
|
+
| `default_queue` | `"default"` | Default queue for jobs without explicit queue |
|
|
295
|
+
| `pool_size` | `5` | Connection pool size |
|
|
296
|
+
| `workers` | `[{queues: ["default"], threads: 5}]` | Worker process definitions |
|
|
297
|
+
| `event_consumers` | `nil` | Event consumer process definitions (same format as workers) |
|
|
298
|
+
| `polling_interval` | `0.1` | Seconds between polls (LISTEN/NOTIFY is primary) |
|
|
299
|
+
| `visibility_timeout` | `30` | Seconds before unacked message becomes visible again |
|
|
300
|
+
| `max_retries` | `5` | Failed reads before routing to dead letter queue |
|
|
301
|
+
| `max_jobs_per_worker` | `nil` | Recycle worker after N jobs (nil = unlimited) |
|
|
302
|
+
| `max_memory_mb` | `nil` | Recycle worker when memory exceeds N MB |
|
|
303
|
+
| `max_worker_lifetime` | `nil` | Recycle worker after N seconds |
|
|
304
|
+
| `listen_notify` | `true` | Use PGMQ's LISTEN/NOTIFY for instant wake-up |
|
|
305
|
+
| `dispatch_interval` | `1.0` | Seconds between dispatcher maintenance ticks |
|
|
306
|
+
| `idempotency_ttl` | `604800` | Seconds to keep processed event records (7 days, cleaned hourly) |
|
|
307
|
+
| `web_auth` | `nil` | Lambda for dashboard authentication |
|
|
308
|
+
| `web_refresh_interval` | `5000` | Dashboard auto-refresh interval in milliseconds |
|
|
309
|
+
| `web_live_updates` | `true` | Enable Turbo Frames auto-refresh on dashboard |
|
|
310
|
+
|
|
311
|
+
## Architecture
|
|
312
|
+
|
|
313
|
+
```text
|
|
314
|
+
Supervisor (fork manager)
|
|
315
|
+
├── Worker 1 (queues: [default, mailers], threads: 10)
|
|
316
|
+
├── Worker 2 (queues: [critical], threads: 5)
|
|
317
|
+
├── Dispatcher (maintenance: idempotency cleanup, stale process reaping)
|
|
318
|
+
└── Consumer (event bus topics)
|
|
319
|
+
|
|
320
|
+
PostgreSQL + PGMQ
|
|
321
|
+
├── pgbus_default (job queue)
|
|
322
|
+
├── pgbus_default_dlq (dead letter queue)
|
|
323
|
+
├── pgbus_critical (job queue)
|
|
324
|
+
├── pgbus_critical_dlq (dead letter queue)
|
|
325
|
+
└── pgbus_mailers (job queue)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### How it works
|
|
329
|
+
|
|
330
|
+
1. **Enqueue**: ActiveJob serializes the job to JSON, Pgbus sends it to the appropriate PGMQ queue
|
|
331
|
+
2. **Read**: Workers poll queues (or wake instantly via LISTEN/NOTIFY) and claim messages with a visibility timeout
|
|
332
|
+
3. **Execute**: The job is deserialized and executed within the Rails executor
|
|
333
|
+
4. **Archive/Retry**: On success, the message is archived. On failure, the visibility timeout expires and the message becomes available again. PGMQ's `read_ct` tracks delivery attempts
|
|
334
|
+
5. **Dead letter**: When `read_ct` exceeds `max_retries`, the message is moved to the `_dlq` queue for manual inspection
|
|
335
|
+
|
|
336
|
+
### Worker recycling
|
|
337
|
+
|
|
338
|
+
Unlike solid_queue, Pgbus workers recycle themselves to prevent memory bloat:
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
Pgbus.configure do |config|
|
|
342
|
+
config.max_jobs_per_worker = 10_000 # Restart after 10k jobs
|
|
343
|
+
config.max_memory_mb = 512 # Restart if memory exceeds 512MB
|
|
344
|
+
config.max_worker_lifetime = 3600 # Restart after 1 hour
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
When a limit is hit, the worker drains its thread pool, exits, and the supervisor forks a fresh process.
|
|
349
|
+
|
|
350
|
+
## CLI
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
pgbus start # Start supervisor with workers + dispatcher
|
|
354
|
+
pgbus status # Show running processes
|
|
355
|
+
pgbus queues # List queues with depth/metrics
|
|
356
|
+
pgbus version # Print version
|
|
357
|
+
pgbus help # Show help
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## Dashboard
|
|
361
|
+
|
|
362
|
+
The dashboard is a mountable Rails engine at `/pgbus` with:
|
|
363
|
+
|
|
364
|
+
- **Overview** -- queue depths, enqueued count, active processes, failure count
|
|
365
|
+
- **Queues** -- per-queue metrics, purge actions
|
|
366
|
+
- **Jobs** -- enqueued and failed jobs, retry/discard actions
|
|
367
|
+
- **Dead letter** -- DLQ messages with retry/discard, bulk actions
|
|
368
|
+
- **Processes** -- active workers/dispatcher/consumers with heartbeat status
|
|
369
|
+
- **Events** -- registered subscribers and processed events
|
|
370
|
+
|
|
371
|
+
All tables use Turbo Frames for periodic auto-refresh without page reloads.
|
|
372
|
+
|
|
373
|
+
## Database tables
|
|
374
|
+
|
|
375
|
+
Pgbus uses these tables (created via PGMQ and migrations):
|
|
376
|
+
|
|
377
|
+
| Table | Purpose |
|
|
378
|
+
|-------|---------|
|
|
379
|
+
| `q_pgbus_*` | PGMQ job queues (managed by PGMQ) |
|
|
380
|
+
| `a_pgbus_*` | PGMQ archive tables (managed by PGMQ) |
|
|
381
|
+
| `pgbus_processes` | Heartbeat tracking for workers/dispatcher/consumers |
|
|
382
|
+
| `pgbus_failed_events` | Failed event dispatch records |
|
|
383
|
+
| `pgbus_processed_events` | Idempotency deduplication (event_id, handler_class) |
|
|
384
|
+
| `pgbus_semaphores` | Concurrency control counting semaphores |
|
|
385
|
+
| `pgbus_blocked_executions` | Jobs waiting for a concurrency semaphore slot |
|
|
386
|
+
| `pgbus_batches` | Batch tracking with job counters and callback config |
|
|
387
|
+
|
|
388
|
+
## Switching from another backend
|
|
389
|
+
|
|
390
|
+
Already using a different job processor? These guides walk you through the migration:
|
|
391
|
+
|
|
392
|
+
- **[Switch from Sidekiq](docs/switch_from_sidekiq.md)** -- remove Redis, convert native workers, replace middleware with callbacks
|
|
393
|
+
- **[Switch from SolidQueue](docs/switch_from_solid_queue.md)** -- similar architecture, swap config format, gain LISTEN/NOTIFY + worker recycling
|
|
394
|
+
- **[Switch from GoodJob](docs/switch_from_good_job.md)** -- both PostgreSQL-native, swap advisory locks for PGMQ visibility timeouts
|
|
395
|
+
|
|
396
|
+
See [docs/README.md](docs/README.md) for a full feature comparison table.
|
|
397
|
+
|
|
398
|
+
## Development
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
bundle install
|
|
402
|
+
bundle exec rake # Run tests + rubocop
|
|
403
|
+
bundle exec rspec # Run tests only
|
|
404
|
+
bundle exec rubocop # Run linter only
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
System tests use Playwright via Capybara:
|
|
408
|
+
|
|
409
|
+
```bash
|
|
410
|
+
bun install
|
|
411
|
+
bunx --bun playwright install chromium
|
|
412
|
+
bundle exec rspec spec/system/
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
## License
|
|
416
|
+
|
|
417
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec) do |t|
|
|
7
|
+
t.pattern = "spec/pgbus/**/*_spec.rb"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
require "rubocop/rake_task"
|
|
11
|
+
|
|
12
|
+
RuboCop::RakeTask.new
|
|
13
|
+
|
|
14
|
+
task default: %i[spec rubocop]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
include Web::Authentication
|
|
6
|
+
|
|
7
|
+
protect_from_forgery with: :exception
|
|
8
|
+
|
|
9
|
+
layout "pgbus/application"
|
|
10
|
+
|
|
11
|
+
helper Pgbus::ApplicationHelper
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def data_source
|
|
16
|
+
@data_source ||= Pgbus.configuration.web_data_source || Web::DataSource.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def page_param
|
|
20
|
+
[params[:page].to_i, 1].max
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def per_page
|
|
24
|
+
Pgbus.configuration.web_per_page
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def turbo_frame_request?
|
|
28
|
+
request.headers["Turbo-Frame"].present? || params[:frame].present?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_frame(partial)
|
|
32
|
+
render partial: partial, layout: false
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class DashboardController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
case params[:frame]
|
|
7
|
+
when "stats"
|
|
8
|
+
@stats = data_source.summary_stats
|
|
9
|
+
render_frame("pgbus/dashboard/stats_cards")
|
|
10
|
+
when "queues"
|
|
11
|
+
@queues = data_source.queues_with_metrics
|
|
12
|
+
render_frame("pgbus/dashboard/queues_table")
|
|
13
|
+
when "processes"
|
|
14
|
+
@processes = data_source.processes
|
|
15
|
+
render_frame("pgbus/dashboard/processes_table")
|
|
16
|
+
when "failures"
|
|
17
|
+
@recent_failures = data_source.failed_events(page: 1, per_page: 5)
|
|
18
|
+
render_frame("pgbus/dashboard/recent_failures")
|
|
19
|
+
else
|
|
20
|
+
@stats = data_source.summary_stats
|
|
21
|
+
@queues = data_source.queues_with_metrics
|
|
22
|
+
@processes = data_source.processes
|
|
23
|
+
@recent_failures = data_source.failed_events(page: 1, per_page: 5)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class DeadLetterController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@messages = data_source.dlq_messages(page: page_param, per_page: per_page)
|
|
7
|
+
render_frame("pgbus/dead_letter/messages_table") if params[:frame] == "list"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
@message = data_source.dlq_message_detail(params[:id].to_i)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def retry
|
|
15
|
+
queue_name = params[:queue_name].to_s
|
|
16
|
+
unless queue_name.end_with?(Pgbus.configuration.dead_letter_queue_suffix)
|
|
17
|
+
return redirect_to dead_letter_index_path, alert: "Invalid DLQ queue."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if data_source.retry_dlq_message(queue_name, params[:id])
|
|
21
|
+
redirect_to dead_letter_index_path, notice: "Message re-enqueued to original queue."
|
|
22
|
+
else
|
|
23
|
+
redirect_to dead_letter_index_path, alert: "Could not retry message."
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def discard
|
|
28
|
+
queue_name = params[:queue_name].to_s
|
|
29
|
+
unless queue_name.end_with?(Pgbus.configuration.dead_letter_queue_suffix)
|
|
30
|
+
return redirect_to dead_letter_index_path, alert: "Invalid DLQ queue."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if data_source.discard_dlq_message(queue_name, params[:id])
|
|
34
|
+
redirect_to dead_letter_index_path, notice: "Message discarded."
|
|
35
|
+
else
|
|
36
|
+
redirect_to dead_letter_index_path, alert: "Could not discard message."
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def retry_all
|
|
41
|
+
count = data_source.retry_all_dlq
|
|
42
|
+
redirect_to dead_letter_index_path, notice: "Re-enqueued #{count} DLQ messages."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def discard_all
|
|
46
|
+
count = data_source.discard_all_dlq
|
|
47
|
+
redirect_to dead_letter_index_path, notice: "Discarded #{count} DLQ messages."
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class EventsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@events = data_source.processed_events(page: page_param, per_page: per_page)
|
|
7
|
+
@subscribers = data_source.registered_subscribers
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
@event = data_source.processed_event(params[:id])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def replay
|
|
15
|
+
event = data_source.processed_event(params[:id])
|
|
16
|
+
if event && data_source.replay_event(event)
|
|
17
|
+
redirect_to events_path, notice: "Event replayed."
|
|
18
|
+
else
|
|
19
|
+
redirect_to events_path, alert: "Event not found or could not be replayed."
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class JobsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
if params[:frame] == "failed"
|
|
7
|
+
@failed = data_source.failed_events(page: page_param, per_page: per_page)
|
|
8
|
+
render_frame("pgbus/jobs/failed_table")
|
|
9
|
+
elsif params[:frame] == "enqueued"
|
|
10
|
+
@jobs = params[:status] == "failed" ? [] : data_source.jobs(queue_name: params[:queue], page: page_param, per_page: per_page)
|
|
11
|
+
render_frame("pgbus/jobs/enqueued_table")
|
|
12
|
+
else
|
|
13
|
+
@jobs = params[:status] == "failed" ? [] : data_source.jobs(queue_name: params[:queue], page: page_param, per_page: per_page)
|
|
14
|
+
@failed = data_source.failed_events(page: page_param, per_page: per_page)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
@job = data_source.failed_event(params[:id])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def retry
|
|
23
|
+
if data_source.retry_failed_event(params[:id])
|
|
24
|
+
redirect_to jobs_path, notice: "Job re-enqueued."
|
|
25
|
+
else
|
|
26
|
+
redirect_to jobs_path, alert: "Could not retry job."
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def discard
|
|
31
|
+
if data_source.discard_failed_event(params[:id])
|
|
32
|
+
redirect_to jobs_path, notice: "Job discarded."
|
|
33
|
+
else
|
|
34
|
+
redirect_to jobs_path, alert: "Could not discard job."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def retry_all
|
|
39
|
+
count = data_source.retry_all_failed
|
|
40
|
+
redirect_to jobs_path, notice: "Re-enqueued #{count} jobs."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def discard_all
|
|
44
|
+
count = data_source.discard_all_failed
|
|
45
|
+
redirect_to jobs_path, notice: "Discarded #{count} jobs."
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class QueuesController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@queues = data_source.queues_with_metrics
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
@queue = data_source.queue_detail(params[:name])
|
|
11
|
+
redirect_to queues_path, alert: "Queue not found." and return unless @queue
|
|
12
|
+
|
|
13
|
+
@messages = data_source.jobs(queue_name: params[:name], page: page_param, per_page: per_page)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def purge
|
|
17
|
+
data_source.purge_queue(params[:name])
|
|
18
|
+
redirect_to queue_path(name: params[:name]), notice: "Queue purged."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|