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.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/.bun-version +1 -0
  3. data/.claude/commands/architect.md +100 -0
  4. data/.claude/commands/github-review-comments.md +237 -0
  5. data/.claude/commands/lfg.md +271 -0
  6. data/.claude/commands/review-pr.md +69 -0
  7. data/.claude/commands/security.md +122 -0
  8. data/.claude/commands/tdd.md +148 -0
  9. data/.claude/rules/agents.md +49 -0
  10. data/.claude/rules/coding-style.md +91 -0
  11. data/.claude/rules/git-workflow.md +56 -0
  12. data/.claude/rules/performance.md +73 -0
  13. data/.claude/rules/testing.md +67 -0
  14. data/CHANGELOG.md +5 -0
  15. data/CLAUDE.md +80 -0
  16. data/CODE_OF_CONDUCT.md +10 -0
  17. data/LICENSE.txt +21 -0
  18. data/README.md +417 -0
  19. data/Rakefile +14 -0
  20. data/app/controllers/pgbus/api/stats_controller.rb +11 -0
  21. data/app/controllers/pgbus/application_controller.rb +35 -0
  22. data/app/controllers/pgbus/dashboard_controller.rb +27 -0
  23. data/app/controllers/pgbus/dead_letter_controller.rb +50 -0
  24. data/app/controllers/pgbus/events_controller.rb +23 -0
  25. data/app/controllers/pgbus/jobs_controller.rb +48 -0
  26. data/app/controllers/pgbus/processes_controller.rb +10 -0
  27. data/app/controllers/pgbus/queues_controller.rb +21 -0
  28. data/app/helpers/pgbus/application_helper.rb +69 -0
  29. data/app/views/layouts/pgbus/application.html.erb +76 -0
  30. data/app/views/pgbus/dashboard/_processes_table.html.erb +30 -0
  31. data/app/views/pgbus/dashboard/_queues_table.html.erb +39 -0
  32. data/app/views/pgbus/dashboard/_recent_failures.html.erb +33 -0
  33. data/app/views/pgbus/dashboard/_stats_cards.html.erb +28 -0
  34. data/app/views/pgbus/dashboard/show.html.erb +10 -0
  35. data/app/views/pgbus/dead_letter/_messages_table.html.erb +40 -0
  36. data/app/views/pgbus/dead_letter/index.html.erb +15 -0
  37. data/app/views/pgbus/dead_letter/show.html.erb +52 -0
  38. data/app/views/pgbus/events/index.html.erb +57 -0
  39. data/app/views/pgbus/events/show.html.erb +28 -0
  40. data/app/views/pgbus/jobs/_enqueued_table.html.erb +34 -0
  41. data/app/views/pgbus/jobs/_failed_table.html.erb +45 -0
  42. data/app/views/pgbus/jobs/index.html.erb +16 -0
  43. data/app/views/pgbus/jobs/show.html.erb +57 -0
  44. data/app/views/pgbus/processes/_processes_table.html.erb +37 -0
  45. data/app/views/pgbus/processes/index.html.erb +3 -0
  46. data/app/views/pgbus/queues/_queues_list.html.erb +41 -0
  47. data/app/views/pgbus/queues/index.html.erb +3 -0
  48. data/app/views/pgbus/queues/show.html.erb +49 -0
  49. data/bun.lock +18 -0
  50. data/config/routes.rb +45 -0
  51. data/docs/README.md +28 -0
  52. data/docs/switch_from_good_job.md +279 -0
  53. data/docs/switch_from_sidekiq.md +226 -0
  54. data/docs/switch_from_solid_queue.md +247 -0
  55. data/exe/pgbus +7 -0
  56. data/lib/generators/pgbus/install_generator.rb +56 -0
  57. data/lib/generators/pgbus/templates/migration.rb.erb +114 -0
  58. data/lib/generators/pgbus/templates/pgbus.yml.erb +74 -0
  59. data/lib/generators/pgbus/templates/pgbus_binstub.erb +7 -0
  60. data/lib/pgbus/active_job/adapter.rb +109 -0
  61. data/lib/pgbus/active_job/executor.rb +107 -0
  62. data/lib/pgbus/batch.rb +153 -0
  63. data/lib/pgbus/cli.rb +84 -0
  64. data/lib/pgbus/client.rb +162 -0
  65. data/lib/pgbus/concurrency/blocked_execution.rb +74 -0
  66. data/lib/pgbus/concurrency/semaphore.rb +66 -0
  67. data/lib/pgbus/concurrency.rb +65 -0
  68. data/lib/pgbus/config_loader.rb +27 -0
  69. data/lib/pgbus/configuration.rb +99 -0
  70. data/lib/pgbus/engine.rb +31 -0
  71. data/lib/pgbus/event.rb +31 -0
  72. data/lib/pgbus/event_bus/handler.rb +76 -0
  73. data/lib/pgbus/event_bus/publisher.rb +42 -0
  74. data/lib/pgbus/event_bus/registry.rb +54 -0
  75. data/lib/pgbus/event_bus/subscriber.rb +30 -0
  76. data/lib/pgbus/process/consumer.rb +113 -0
  77. data/lib/pgbus/process/dispatcher.rb +154 -0
  78. data/lib/pgbus/process/heartbeat.rb +71 -0
  79. data/lib/pgbus/process/signal_handler.rb +49 -0
  80. data/lib/pgbus/process/supervisor.rb +198 -0
  81. data/lib/pgbus/process/worker.rb +153 -0
  82. data/lib/pgbus/serializer.rb +43 -0
  83. data/lib/pgbus/version.rb +5 -0
  84. data/lib/pgbus/web/authentication.rb +24 -0
  85. data/lib/pgbus/web/data_source.rb +406 -0
  86. data/lib/pgbus.rb +49 -0
  87. data/package.json +9 -0
  88. data/sig/pgbus.rbs +4 -0
  89. 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
+ [![Ruby](https://github.com/mhenrixon/pgbus/actions/workflows/main.yml/badge.svg)](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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Api
5
+ class StatsController < ApplicationController
6
+ def show
7
+ render json: data_source.summary_stats
8
+ end
9
+ end
10
+ end
11
+ end
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class ProcessesController < ApplicationController
5
+ def index
6
+ @processes = data_source.processes
7
+ render_frame("pgbus/processes/processes_table") if params[:frame] == "list"
8
+ end
9
+ end
10
+ 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