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
@@ -0,0 +1,74 @@
1
+ # Pgbus configuration
2
+ # https://github.com/mhenrixon/pgbus
3
+
4
+ default: &default
5
+ # Queue prefix for all PGMQ queues
6
+ queue_prefix: pgbus
7
+
8
+ # Default queue name (without prefix)
9
+ default_queue: default
10
+
11
+ # Connection pool for PGMQ client
12
+ pool_size: 5
13
+ pool_timeout: 5
14
+
15
+ # Use PostgreSQL LISTEN/NOTIFY for instant job wake-up
16
+ listen_notify: true
17
+ notify_throttle_ms: 250
18
+
19
+ # Visibility timeout in seconds (how long a message is invisible after being read)
20
+ visibility_timeout: 30
21
+
22
+ # Dead letter queue
23
+ max_retries: 5
24
+
25
+ # Idempotency deduplication TTL (seconds)
26
+ idempotency_ttl: 604800 # 7 days
27
+
28
+ # Worker definitions
29
+ workers:
30
+ - queues:
31
+ - critical
32
+ - default
33
+ threads: 5
34
+ - queues:
35
+ - low
36
+ threads: 2
37
+
38
+ # Worker recycling (prevents memory bloat)
39
+ max_jobs_per_worker: 10000
40
+ max_memory_mb: 512
41
+ # max_worker_lifetime: 3600 # seconds
42
+
43
+ # Dispatcher settings (maintenance tasks)
44
+ dispatch_interval: 1.0
45
+
46
+ # Event consumers (uncomment to enable event bus)
47
+ # event_consumers:
48
+ # - topics:
49
+ # - "orders.#"
50
+ # threads: 3
51
+ # - topics:
52
+ # - "notifications.#"
53
+ # threads: 1
54
+
55
+ development:
56
+ <<: *default
57
+ workers:
58
+ - queues:
59
+ - default
60
+ threads: 2
61
+ max_jobs_per_worker: ~
62
+ max_memory_mb: ~
63
+
64
+ test:
65
+ <<: *default
66
+ workers:
67
+ - queues:
68
+ - default
69
+ threads: 1
70
+ polling_interval: 0.01
71
+ visibility_timeout: 5
72
+
73
+ production:
74
+ <<: *default
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../config/environment"
5
+ require "pgbus/cli"
6
+
7
+ Pgbus::CLI.start(ARGV)
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Pgbus
6
+ module ActiveJob
7
+ class Adapter
8
+ def enqueue(active_job)
9
+ queue = active_job.queue_name || Pgbus.configuration.default_queue
10
+ payload_hash = JSON.parse(Serializer.serialize_job(active_job))
11
+ payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
12
+ payload_hash = inject_batch_metadata(payload_hash)
13
+
14
+ enqueue_with_concurrency(active_job, queue, payload_hash)
15
+ end
16
+
17
+ def enqueue_at(active_job, timestamp)
18
+ queue = active_job.queue_name || Pgbus.configuration.default_queue
19
+ payload_hash = JSON.parse(Serializer.serialize_job(active_job))
20
+ payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
21
+ payload_hash = inject_batch_metadata(payload_hash)
22
+ delay = [(timestamp - Time.now.to_f).ceil, 0].max
23
+
24
+ enqueue_with_concurrency(active_job, queue, payload_hash, delay: delay)
25
+ end
26
+
27
+ def enqueue_all(active_jobs)
28
+ active_jobs.group_by { |j| j.queue_name || Pgbus.configuration.default_queue }.each do |queue, jobs|
29
+ enqueue_immediate(queue, jobs.reject { |j| j.scheduled_at && j.scheduled_at > Time.now })
30
+ jobs.select { |j| j.scheduled_at && j.scheduled_at > Time.now }.each { |j| enqueue_at(j, j.scheduled_at.to_f) }
31
+ end
32
+
33
+ active_jobs.count
34
+ end
35
+
36
+ private
37
+
38
+ def enqueue_with_concurrency(active_job, queue, payload_hash, delay: 0)
39
+ key = Concurrency.extract_key(payload_hash)
40
+ concurrency = concurrency_config(active_job)
41
+
42
+ if key && concurrency
43
+ result = Concurrency::Semaphore.acquire(key, concurrency[:limit], concurrency[:duration])
44
+
45
+ if result == :acquired
46
+ msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay)
47
+ active_job.provider_job_id = msg_id
48
+ else
49
+ handle_conflict(concurrency, active_job, key, queue, payload_hash)
50
+ end
51
+ else
52
+ msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay)
53
+ active_job.provider_job_id = msg_id
54
+ end
55
+
56
+ active_job
57
+ end
58
+
59
+ def concurrency_config(active_job)
60
+ active_job.class.respond_to?(:pgbus_concurrency) && active_job.class.pgbus_concurrency
61
+ end
62
+
63
+ def handle_conflict(concurrency, active_job, key, queue, payload_hash)
64
+ case concurrency[:on_conflict]
65
+ when :block
66
+ Concurrency::BlockedExecution.insert(
67
+ concurrency_key: key,
68
+ queue_name: queue,
69
+ payload: payload_hash,
70
+ priority: active_job.try(:priority) || 0,
71
+ duration: concurrency[:duration]
72
+ )
73
+ when :discard
74
+ Pgbus.logger.info { "[Pgbus] Discarding job #{active_job.class.name}: concurrency limit for #{key}" }
75
+ when :raise
76
+ raise ConcurrencyLimitExceeded, "Concurrency limit reached for key: #{key}"
77
+ end
78
+ end
79
+
80
+ def inject_batch_metadata(payload_hash)
81
+ batch_id = Thread.current[:pgbus_batch_id]
82
+ return payload_hash unless batch_id
83
+
84
+ Thread.current[:pgbus_batch_job_count] = (Thread.current[:pgbus_batch_job_count] || 0) + 1
85
+ payload_hash.merge(Batch::METADATA_KEY => batch_id)
86
+ end
87
+
88
+ def enqueue_immediate(queue, jobs)
89
+ return if jobs.empty?
90
+
91
+ payloads = jobs.map { |j| JSON.parse(Serializer.serialize_job(j)) }
92
+ msg_ids = Pgbus.client.send_batch(queue, payloads)
93
+
94
+ unless msg_ids.is_a?(Array) && msg_ids.size == jobs.size
95
+ raise "Pgbus batch enqueue failed: expected #{jobs.size} ids, got #{msg_ids&.size || 0}"
96
+ end
97
+
98
+ jobs.zip(msg_ids).each { |job, id| job.provider_job_id = id }
99
+ end
100
+
101
+ def enqueue_after_transaction_commit?
102
+ true
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ # Register the adapter with ActiveJob (register method added in Rails 7.2+)
109
+ ActiveJob::QueueAdapters.register(:pgbus, Pgbus::ActiveJob::Adapter) if ActiveJob::QueueAdapters.respond_to?(:register)
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module ActiveJob
5
+ class Executor
6
+ attr_reader :client, :config
7
+
8
+ def initialize(client: Pgbus.client, config: Pgbus.configuration)
9
+ @client = client
10
+ @config = config
11
+ end
12
+
13
+ def execute(message, queue_name)
14
+ payload = JSON.parse(message.message)
15
+ read_count = message.read_ct.to_i
16
+
17
+ if read_count > config.max_retries
18
+ handle_dead_letter(message, queue_name, payload)
19
+ signal_concurrency(payload)
20
+ signal_batch_discarded(payload)
21
+ return :dead_lettered
22
+ end
23
+
24
+ job = ::ActiveJob::Base.deserialize(payload)
25
+ execute_job(job)
26
+ client.archive_message(queue_name, message.msg_id.to_i)
27
+ signal_concurrency(payload)
28
+ signal_batch_completed(payload)
29
+ instrument("pgbus.job_completed", queue: queue_name, job_class: payload["job_class"])
30
+ :success
31
+ rescue StandardError => e
32
+ handle_failure(message, queue_name, e)
33
+ instrument("pgbus.job_failed", queue: queue_name, job_class: payload&.dig("job_class"), error: e.class.name)
34
+ # Don't signal concurrency on transient failure — the job will be retried.
35
+ # Semaphore is released only on success or dead-lettering.
36
+ :failed
37
+ end
38
+
39
+ private
40
+
41
+ def execute_job(job)
42
+ if defined?(Rails) && Rails.application
43
+ Rails.application.executor.wrap { job.perform_now }
44
+ else
45
+ job.perform_now
46
+ end
47
+ end
48
+
49
+ def handle_failure(_message, _queue_name, error)
50
+ Pgbus.logger.error { "[Pgbus] Job failed: #{error.class}: #{error.message}" }
51
+ Pgbus.logger.debug { error.backtrace&.join("\n") }
52
+
53
+ # Message visibility timeout will expire and it becomes available again.
54
+ # read_ct tracks delivery attempts — when it exceeds max_retries,
55
+ # the next read will route to DLQ.
56
+ end
57
+
58
+ def instrument(event_name, payload = {})
59
+ return unless defined?(ActiveSupport::Notifications)
60
+
61
+ ActiveSupport::Notifications.instrument(event_name, payload)
62
+ rescue StandardError => e
63
+ Pgbus.logger.debug { "[Pgbus] Notification failure #{event_name}: #{e.class}: #{e.message}" }
64
+ end
65
+
66
+ def signal_concurrency(payload)
67
+ key = Concurrency.extract_key(payload)
68
+ return unless key
69
+
70
+ Concurrency::Semaphore.release(key)
71
+
72
+ released = Concurrency::BlockedExecution.release_next(key)
73
+ return unless released
74
+
75
+ client.send_message(released[:queue_name], released[:payload])
76
+ rescue StandardError => e
77
+ Pgbus.logger.warn { "[Pgbus] Concurrency signal failed: #{e.message}" }
78
+ end
79
+
80
+ def signal_batch_completed(payload)
81
+ batch_id = payload[Batch::METADATA_KEY]
82
+ return unless batch_id
83
+
84
+ Batch.job_completed(batch_id)
85
+ rescue StandardError => e
86
+ Pgbus.logger.warn { "[Pgbus] Batch completion signal failed: #{e.message}" }
87
+ end
88
+
89
+ def signal_batch_discarded(payload)
90
+ batch_id = payload[Batch::METADATA_KEY]
91
+ return unless batch_id
92
+
93
+ Batch.job_discarded(batch_id)
94
+ rescue StandardError => e
95
+ Pgbus.logger.warn { "[Pgbus] Batch discard signal failed: #{e.message}" }
96
+ end
97
+
98
+ def handle_dead_letter(message, queue_name, payload)
99
+ Pgbus.logger.warn do
100
+ job_class = payload["job_class"] || "unknown"
101
+ "[Pgbus] Moving job #{job_class} to dead letter queue after #{message.read_ct} attempts"
102
+ end
103
+ client.move_to_dead_letter(queue_name, message)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Pgbus
7
+ class Batch
8
+ METADATA_KEY = "pgbus_batch_id"
9
+
10
+ attr_reader :batch_id, :properties, :description,
11
+ :on_finish, :on_success, :on_discard
12
+
13
+ def initialize(on_finish: nil, on_success: nil, on_discard: nil, description: nil, properties: {})
14
+ @batch_id = SecureRandom.uuid
15
+ @on_finish = on_finish
16
+ @on_success = on_success
17
+ @on_discard = on_discard
18
+ @description = description
19
+ @properties = properties
20
+ @job_count = 0
21
+ end
22
+
23
+ # Enqueue a group of jobs as a batch.
24
+ # Jobs enqueued inside the block are tracked as part of this batch.
25
+ def enqueue(&)
26
+ create_record
27
+ count_jobs(&)
28
+ update_total
29
+ self
30
+ end
31
+
32
+ # Record a completed job. Returns the batch status after update.
33
+ def self.job_completed(batch_id)
34
+ update_counter(batch_id, "completed_jobs")
35
+ end
36
+
37
+ # Record a discarded (dead-lettered) job. Returns the batch status after update.
38
+ def self.job_discarded(batch_id)
39
+ update_counter(batch_id, "discarded_jobs")
40
+ end
41
+
42
+ # Find a batch record by ID. Returns a hash or nil.
43
+ def self.find(batch_id)
44
+ return nil unless defined?(ActiveRecord::Base)
45
+
46
+ result = ActiveRecord::Base.connection.exec_query(
47
+ "SELECT * FROM pgbus_batches WHERE batch_id = $1",
48
+ "Pgbus Batch Find",
49
+ [batch_id]
50
+ )
51
+ result.first
52
+ end
53
+
54
+ # Delete finished batches older than the given threshold.
55
+ def self.cleanup(older_than:)
56
+ return 0 unless defined?(ActiveRecord::Base)
57
+
58
+ result = ActiveRecord::Base.connection.exec_query(
59
+ "DELETE FROM pgbus_batches WHERE status = 'finished' AND finished_at < $1 RETURNING id",
60
+ "Pgbus Batch Cleanup",
61
+ [older_than]
62
+ )
63
+ result.to_a.size
64
+ end
65
+
66
+ private
67
+
68
+ def create_record
69
+ return unless defined?(ActiveRecord::Base)
70
+
71
+ ActiveRecord::Base.connection.exec_query(
72
+ <<~SQL,
73
+ INSERT INTO pgbus_batches
74
+ (batch_id, description, on_finish_class, on_success_class, on_discard_class, properties, status)
75
+ VALUES ($1, $2, $3, $4, $5, $6, 'pending')
76
+ SQL
77
+ "Pgbus Batch Create",
78
+ [batch_id, description, on_finish&.name, on_success&.name, on_discard&.name, JSON.generate(properties)]
79
+ )
80
+ end
81
+
82
+ def count_jobs(&)
83
+ Thread.current[:pgbus_batch_id] = batch_id
84
+ @job_count = 0
85
+
86
+ yield
87
+
88
+ @job_count = Thread.current[:pgbus_batch_job_count] || 0
89
+ ensure
90
+ Thread.current[:pgbus_batch_id] = nil
91
+ Thread.current[:pgbus_batch_job_count] = nil
92
+ end
93
+
94
+ def update_total
95
+ return unless defined?(ActiveRecord::Base)
96
+
97
+ ActiveRecord::Base.connection.exec_query(
98
+ "UPDATE pgbus_batches SET total_jobs = $1, status = 'processing' WHERE batch_id = $2",
99
+ "Pgbus Batch Update Total",
100
+ [@job_count, batch_id]
101
+ )
102
+ end
103
+
104
+ class << self
105
+ private
106
+
107
+ def update_counter(batch_id, column)
108
+ return nil unless defined?(ActiveRecord::Base)
109
+
110
+ result = ActiveRecord::Base.connection.exec_query(
111
+ <<~SQL,
112
+ UPDATE pgbus_batches
113
+ SET #{column} = #{column} + 1,
114
+ status = CASE
115
+ WHEN completed_jobs + discarded_jobs + 1 = total_jobs THEN 'finished'
116
+ ELSE status
117
+ END,
118
+ finished_at = CASE
119
+ WHEN completed_jobs + discarded_jobs + 1 = total_jobs THEN NOW()
120
+ ELSE finished_at
121
+ END
122
+ WHERE batch_id = $1
123
+ RETURNING status, total_jobs, completed_jobs, discarded_jobs, on_finish_class, on_success_class, on_discard_class, properties
124
+ SQL
125
+ "Pgbus Batch Counter",
126
+ [batch_id]
127
+ )
128
+
129
+ row = result.first
130
+ return nil unless row
131
+
132
+ fire_callbacks(row) if row["status"] == "finished"
133
+ row
134
+ end
135
+
136
+ def fire_callbacks(row)
137
+ properties = JSON.parse(row["properties"] || "{}")
138
+ all_succeeded = row["discarded_jobs"].to_i.zero?
139
+
140
+ enqueue_callback(row["on_finish_class"], properties) if row["on_finish_class"]
141
+ enqueue_callback(row["on_success_class"], properties) if row["on_success_class"] && all_succeeded
142
+ enqueue_callback(row["on_discard_class"], properties) if row["on_discard_class"] && !all_succeeded
143
+ end
144
+
145
+ def enqueue_callback(class_name, properties)
146
+ job_class = class_name.constantize
147
+ job_class.perform_later(properties)
148
+ rescue NameError => e
149
+ Pgbus.logger.error { "[Pgbus] Batch callback class not found: #{class_name}: #{e.message}" }
150
+ end
151
+ end
152
+ end
153
+ end
data/lib/pgbus/cli.rb ADDED
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module CLI
5
+ module_function
6
+
7
+ def start(args)
8
+ command = args.first || "help"
9
+
10
+ case command
11
+ when "start"
12
+ start_supervisor
13
+ when "status"
14
+ show_status
15
+ when "queues"
16
+ list_queues
17
+ when "version"
18
+ puts "pgbus #{Pgbus::VERSION}"
19
+ when "help", "--help", "-h"
20
+ print_help
21
+ else
22
+ puts "Unknown command: #{command}"
23
+ print_help
24
+ exit 1
25
+ end
26
+ end
27
+
28
+ def start_supervisor
29
+ Pgbus.logger.info { "[Pgbus] Starting Pgbus #{Pgbus::VERSION}..." }
30
+
31
+ supervisor = Process::Supervisor.new
32
+ supervisor.run
33
+ end
34
+
35
+ def show_status
36
+ if defined?(ActiveRecord::Base)
37
+ processes = ActiveRecord::Base.connection.execute(
38
+ "SELECT kind, hostname, pid, metadata, last_heartbeat_at FROM pgbus_processes ORDER BY kind, created_at"
39
+ )
40
+
41
+ if processes.none?
42
+ puts "No Pgbus processes running."
43
+ return
44
+ end
45
+
46
+ puts "KIND HOST PID HEARTBEAT METADATA"
47
+ puts "-" * 100
48
+ processes.each do |p|
49
+ puts format("%-12s %-20s %-8s %-30s %s",
50
+ p["kind"], p["hostname"], p["pid"], p["last_heartbeat_at"], p["metadata"])
51
+ end
52
+ else
53
+ puts "ActiveRecord not available. Run from a Rails context."
54
+ end
55
+ end
56
+
57
+ def list_queues
58
+ Pgbus.client.list_queues
59
+ metrics = Pgbus.client.metrics
60
+
61
+ puts "QUEUE DEPTH VISIBLE OLDEST (s) TOTAL "
62
+ puts "-" * 95
63
+
64
+ Array(metrics).each do |m|
65
+ puts format("%-40s %-10s %-10s %-15s %-15s",
66
+ m.queue_name, m.queue_length, m.queue_visible_length,
67
+ m.oldest_msg_age_sec || "-", m.total_messages)
68
+ end
69
+ end
70
+
71
+ def print_help
72
+ puts <<~HELP
73
+ Usage: pgbus <command>
74
+
75
+ Commands:
76
+ start Start the Pgbus supervisor (workers + dispatcher)
77
+ status Show running Pgbus processes
78
+ queues List queues with metrics
79
+ version Show version
80
+ help Show this help
81
+ HELP
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pgbus
6
+ class Client
7
+ attr_reader :pgmq, :config
8
+
9
+ def initialize(config = Pgbus.configuration)
10
+ require "pgmq-ruby"
11
+ @config = config
12
+ @pgmq = PGMQ::Client.new(
13
+ config.connection_options,
14
+ pool_size: config.pool_size,
15
+ pool_timeout: config.pool_timeout
16
+ )
17
+ @queues_created = {}
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def ensure_queue(name)
22
+ full_name = config.queue_name(name)
23
+ @mutex.synchronize do
24
+ return if @queues_created[full_name]
25
+
26
+ @pgmq.create(full_name)
27
+ @pgmq.enable_notify_insert(full_name, throttle_interval_ms: config.notify_throttle_ms) if config.listen_notify
28
+ @queues_created[full_name] = true
29
+ end
30
+ end
31
+
32
+ def ensure_dead_letter_queue(name)
33
+ dlq_name = config.dead_letter_queue_name(name)
34
+ @mutex.synchronize do
35
+ return if @queues_created[dlq_name]
36
+
37
+ @pgmq.create(dlq_name)
38
+ @queues_created[dlq_name] = true
39
+ end
40
+ end
41
+
42
+ def send_message(queue_name, payload, headers: nil, delay: 0)
43
+ full_name = config.queue_name(queue_name)
44
+ ensure_queue(queue_name)
45
+ @pgmq.produce(full_name, serialize(payload), headers: headers && serialize(headers), delay: delay)
46
+ end
47
+
48
+ def send_batch(queue_name, payloads, headers: nil, delay: 0)
49
+ full_name = config.queue_name(queue_name)
50
+ ensure_queue(queue_name)
51
+ serialized = payloads.map { |p| serialize(p) }
52
+ serialized_headers = headers&.map { |h| serialize(h) }
53
+ @pgmq.produce_batch(full_name, serialized, headers: serialized_headers, delay: delay)
54
+ end
55
+
56
+ def read_message(queue_name, vt: nil)
57
+ full_name = config.queue_name(queue_name)
58
+ @pgmq.read(full_name, vt: vt || config.visibility_timeout)
59
+ end
60
+
61
+ def read_batch(queue_name, qty:, vt: nil)
62
+ full_name = config.queue_name(queue_name)
63
+ @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty)
64
+ end
65
+
66
+ def read_with_poll(queue_name, qty:, vt: nil, max_poll_seconds: 5, poll_interval_ms: 100)
67
+ full_name = config.queue_name(queue_name)
68
+ @pgmq.read_with_poll(
69
+ full_name,
70
+ vt: vt || config.visibility_timeout,
71
+ qty: qty,
72
+ max_poll_seconds: max_poll_seconds,
73
+ poll_interval_ms: poll_interval_ms
74
+ )
75
+ end
76
+
77
+ def delete_message(queue_name, msg_id)
78
+ full_name = config.queue_name(queue_name)
79
+ @pgmq.delete(full_name, msg_id)
80
+ end
81
+
82
+ def archive_message(queue_name, msg_id)
83
+ full_name = config.queue_name(queue_name)
84
+ @pgmq.archive(full_name, msg_id)
85
+ end
86
+
87
+ def extend_visibility(queue_name, msg_id, vt:)
88
+ full_name = config.queue_name(queue_name)
89
+ @pgmq.set_vt(full_name, msg_id, vt: vt)
90
+ end
91
+
92
+ def set_visibility_timeout(queue_name, msg_id, vt:)
93
+ @pgmq.set_vt(queue_name, msg_id, vt: vt)
94
+ end
95
+
96
+ def delete_from_queue(queue_name, msg_id)
97
+ @pgmq.delete(queue_name, msg_id)
98
+ end
99
+
100
+ def transaction(&)
101
+ @pgmq.transaction(&)
102
+ end
103
+
104
+ def move_to_dead_letter(queue_name, message)
105
+ ensure_dead_letter_queue(queue_name)
106
+ dlq_name = config.dead_letter_queue_name(queue_name)
107
+ full_queue = config.queue_name(queue_name)
108
+
109
+ @pgmq.transaction do |txn|
110
+ txn.produce(dlq_name, message.message, headers: message.headers)
111
+ txn.delete(full_queue, message.msg_id.to_i)
112
+ end
113
+ end
114
+
115
+ def metrics(queue_name = nil)
116
+ if queue_name
117
+ @pgmq.metrics(config.queue_name(queue_name))
118
+ else
119
+ @pgmq.metrics_all
120
+ end
121
+ end
122
+
123
+ def list_queues
124
+ @pgmq.list_queues
125
+ end
126
+
127
+ def purge_queue(queue_name)
128
+ @pgmq.purge_queue(config.queue_name(queue_name))
129
+ end
130
+
131
+ # Topic routing
132
+ def bind_topic(pattern, queue_name)
133
+ full_name = config.queue_name(queue_name)
134
+ ensure_queue(queue_name)
135
+ @pgmq.bind_topic(pattern, full_name)
136
+ end
137
+
138
+ def publish_to_topic(routing_key, payload, headers: nil, delay: 0)
139
+ @pgmq.produce_topic(
140
+ routing_key,
141
+ serialize(payload),
142
+ headers: headers && serialize(headers),
143
+ delay: delay
144
+ )
145
+ end
146
+
147
+ def close
148
+ @pgmq.close
149
+ end
150
+
151
+ private
152
+
153
+ def serialize(data)
154
+ case data
155
+ when String
156
+ data
157
+ else
158
+ JSON.generate(data)
159
+ end
160
+ end
161
+ end
162
+ end