pgbus 0.0.1 → 0.1.2

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +37 -3
  3. data/Rakefile +98 -1
  4. data/app/controllers/pgbus/application_controller.rb +8 -0
  5. data/app/controllers/pgbus/recurring_tasks_controller.rb +36 -0
  6. data/app/helpers/pgbus/application_helper.rb +41 -0
  7. data/app/models/pgbus/application_record.rb +7 -0
  8. data/app/models/pgbus/batch_entry.rb +31 -0
  9. data/app/models/pgbus/blocked_execution.rb +40 -0
  10. data/app/models/pgbus/process_entry.rb +9 -0
  11. data/app/models/pgbus/processed_event.rb +9 -0
  12. data/app/models/pgbus/recurring_execution.rb +33 -0
  13. data/app/models/pgbus/recurring_task.rb +42 -0
  14. data/app/models/pgbus/semaphore.rb +29 -0
  15. data/app/views/layouts/pgbus/application.html.erb +1 -0
  16. data/app/views/pgbus/dashboard/_stats_cards.html.erb +9 -1
  17. data/app/views/pgbus/dead_letter/_messages_table.html.erb +55 -18
  18. data/app/views/pgbus/jobs/_enqueued_table.html.erb +46 -8
  19. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +79 -0
  20. data/app/views/pgbus/recurring_tasks/index.html.erb +6 -0
  21. data/app/views/pgbus/recurring_tasks/show.html.erb +122 -0
  22. data/config/routes.rb +7 -0
  23. data/lib/active_job/queue_adapters/pgbus_adapter.rb +29 -0
  24. data/lib/generators/pgbus/add_recurring_generator.rb +56 -0
  25. data/lib/generators/pgbus/install_generator.rb +76 -2
  26. data/lib/generators/pgbus/templates/add_recurring_tables.rb.erb +31 -0
  27. data/lib/generators/pgbus/templates/migration.rb.erb +72 -4
  28. data/lib/generators/pgbus/templates/recurring.yml.erb +40 -0
  29. data/lib/generators/pgbus/templates/upgrade_pgmq.rb.erb +30 -0
  30. data/lib/generators/pgbus/upgrade_pgmq_generator.rb +60 -0
  31. data/lib/pgbus/active_job/adapter.rb +3 -6
  32. data/lib/pgbus/active_job/executor.rb +26 -12
  33. data/lib/pgbus/batch.rb +65 -72
  34. data/lib/pgbus/cli.rb +11 -16
  35. data/lib/pgbus/client.rb +32 -15
  36. data/lib/pgbus/concurrency/blocked_execution.rb +32 -37
  37. data/lib/pgbus/concurrency/semaphore.rb +11 -39
  38. data/lib/pgbus/concurrency.rb +10 -2
  39. data/lib/pgbus/configuration.rb +48 -0
  40. data/lib/pgbus/engine.rb +19 -1
  41. data/lib/pgbus/event_bus/handler.rb +10 -23
  42. data/lib/pgbus/instrumentation.rb +29 -0
  43. data/lib/pgbus/pgmq_schema/pgmq_v1.11.0.sql +2123 -0
  44. data/lib/pgbus/pgmq_schema.rb +159 -0
  45. data/lib/pgbus/process/consumer.rb +17 -9
  46. data/lib/pgbus/process/dispatcher.rb +33 -41
  47. data/lib/pgbus/process/heartbeat.rb +15 -23
  48. data/lib/pgbus/process/signal_handler.rb +23 -1
  49. data/lib/pgbus/process/supervisor.rb +79 -2
  50. data/lib/pgbus/process/worker.rb +42 -13
  51. data/lib/pgbus/recurring/already_recorded.rb +7 -0
  52. data/lib/pgbus/recurring/command_job.rb +28 -0
  53. data/lib/pgbus/recurring/config_loader.rb +35 -0
  54. data/lib/pgbus/recurring/schedule.rb +102 -0
  55. data/lib/pgbus/recurring/scheduler.rb +102 -0
  56. data/lib/pgbus/recurring/task.rb +111 -0
  57. data/lib/pgbus/serializer.rb +16 -6
  58. data/lib/pgbus/version.rb +1 -1
  59. data/lib/pgbus/web/data_source.rb +217 -36
  60. data/lib/pgbus.rb +8 -0
  61. data/lib/tasks/pgbus_pgmq.rake +62 -0
  62. metadata +51 -24
  63. data/.bun-version +0 -1
  64. data/.claude/commands/architect.md +0 -100
  65. data/.claude/commands/github-review-comments.md +0 -237
  66. data/.claude/commands/lfg.md +0 -271
  67. data/.claude/commands/review-pr.md +0 -69
  68. data/.claude/commands/security.md +0 -122
  69. data/.claude/commands/tdd.md +0 -148
  70. data/.claude/rules/agents.md +0 -49
  71. data/.claude/rules/coding-style.md +0 -91
  72. data/.claude/rules/git-workflow.md +0 -56
  73. data/.claude/rules/performance.md +0 -73
  74. data/.claude/rules/testing.md +0 -67
  75. data/CLAUDE.md +0 -80
  76. data/CODE_OF_CONDUCT.md +0 -10
  77. data/bun.lock +0 -18
  78. data/docs/README.md +0 -28
  79. data/docs/switch_from_good_job.md +0 -279
  80. data/docs/switch_from_sidekiq.md +0 -226
  81. data/docs/switch_from_solid_queue.md +0 -247
  82. data/package.json +0 -9
  83. data/sig/pgbus.rbs +0 -4
@@ -0,0 +1,30 @@
1
+ class UpgradePgmqToV<%= target_version_slug.camelize %> < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ # Drop existing PGMQ functions and types so they can be re-created
4
+ # at the new version. Tables (pgmq.meta, queue tables) are preserved.
5
+ execute Pgbus::PgmqSchema.drop_pgmq_functions_sql
6
+
7
+ # Re-create all PGMQ functions and types at the new version.
8
+ # This uses the vendored SQL which doesn't require the pgmq extension.
9
+ execute Pgbus::PgmqSchema.install_sql("<%= target_version %>")
10
+
11
+ # Record the upgrade
12
+ execute <<~SQL
13
+ CREATE TABLE IF NOT EXISTS pgbus_pgmq_schema_versions (
14
+ id SERIAL PRIMARY KEY,
15
+ version VARCHAR NOT NULL,
16
+ installed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
17
+ install_method VARCHAR NOT NULL DEFAULT 'embedded'
18
+ );
19
+
20
+ INSERT INTO pgbus_pgmq_schema_versions (version, install_method)
21
+ VALUES ('<%= target_version %>', 'upgrade');
22
+ SQL
23
+ end
24
+
25
+ def down
26
+ raise ActiveRecord::IrreversibleMigration,
27
+ "PGMQ schema downgrade is not supported. " \
28
+ "Restore from a database backup if needed."
29
+ end
30
+ end
@@ -0,0 +1,60 @@
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 UpgradePgmqGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Upgrade PGMQ schema to the latest vendored version"
14
+
15
+ class_option :database,
16
+ type: :string,
17
+ default: nil,
18
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
19
+
20
+ def create_migration
21
+ if separate_database?
22
+ migration_template "upgrade_pgmq.rb.erb",
23
+ "db/pgbus_migrate/upgrade_pgmq_to_v#{target_version_slug}.rb"
24
+ else
25
+ migration_template "upgrade_pgmq.rb.erb",
26
+ "db/migrate/upgrade_pgmq_to_v#{target_version_slug}.rb"
27
+ end
28
+ end
29
+
30
+ def display_post_upgrade
31
+ say ""
32
+ say "PGMQ upgrade migration created!", :green
33
+ say " Target version: #{target_version}", :yellow
34
+ say ""
35
+ say "Next steps:"
36
+ say " 1. Review the migration in db/#{separate_database? ? "pgbus_migrate" : "migrate"}/"
37
+ say " 2. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
38
+ say ""
39
+ end
40
+
41
+ private
42
+
43
+ def migration_version
44
+ "[#{ActiveRecord::Migration.current_version}]"
45
+ end
46
+
47
+ def target_version
48
+ Pgbus::PgmqSchema.latest_version
49
+ end
50
+
51
+ def target_version_slug
52
+ target_version.tr(".", "_")
53
+ end
54
+
55
+ def separate_database?
56
+ options[:database].present?
57
+ end
58
+ end
59
+ end
60
+ end
@@ -7,7 +7,7 @@ module Pgbus
7
7
  class Adapter
8
8
  def enqueue(active_job)
9
9
  queue = active_job.queue_name || Pgbus.configuration.default_queue
10
- payload_hash = JSON.parse(Serializer.serialize_job(active_job))
10
+ payload_hash = Serializer.serialize_job_hash(active_job)
11
11
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
12
12
  payload_hash = inject_batch_metadata(payload_hash)
13
13
 
@@ -16,7 +16,7 @@ module Pgbus
16
16
 
17
17
  def enqueue_at(active_job, timestamp)
18
18
  queue = active_job.queue_name || Pgbus.configuration.default_queue
19
- payload_hash = JSON.parse(Serializer.serialize_job(active_job))
19
+ payload_hash = Serializer.serialize_job_hash(active_job)
20
20
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
21
21
  payload_hash = inject_batch_metadata(payload_hash)
22
22
  delay = [(timestamp - Time.now.to_f).ceil, 0].max
@@ -88,7 +88,7 @@ module Pgbus
88
88
  def enqueue_immediate(queue, jobs)
89
89
  return if jobs.empty?
90
90
 
91
- payloads = jobs.map { |j| JSON.parse(Serializer.serialize_job(j)) }
91
+ payloads = jobs.map { |j| Serializer.serialize_job_hash(j) }
92
92
  msg_ids = Pgbus.client.send_batch(queue, payloads)
93
93
 
94
94
  unless msg_ids.is_a?(Array) && msg_ids.size == jobs.size
@@ -104,6 +104,3 @@ module Pgbus
104
104
  end
105
105
  end
106
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)
@@ -21,12 +21,18 @@ module Pgbus
21
21
  return :dead_lettered
22
22
  end
23
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"])
24
+ job_class = payload["job_class"]
25
+
26
+ job_succeeded = false
27
+
28
+ Instrumentation.instrument("pgbus.executor.execute", queue: queue_name, job_class: job_class) do
29
+ job = ::ActiveJob::Base.deserialize(payload)
30
+ execute_job(job)
31
+ client.archive_message(queue_name, message.msg_id.to_i)
32
+ job_succeeded = true
33
+ end
34
+
35
+ instrument("pgbus.job_completed", queue: queue_name, job_class: job_class)
30
36
  :success
31
37
  rescue StandardError => e
32
38
  handle_failure(message, queue_name, e)
@@ -34,6 +40,14 @@ module Pgbus
34
40
  # Don't signal concurrency on transient failure — the job will be retried.
35
41
  # Semaphore is released only on success or dead-lettering.
36
42
  :failed
43
+ ensure
44
+ # Signal concurrency and batch only when the job was archived successfully.
45
+ # job_succeeded is set AFTER archive_message, so if archive fails the
46
+ # semaphore slot stays held until VT expires and the job is retried.
47
+ if job_succeeded
48
+ signal_concurrency(payload)
49
+ signal_batch_completed(payload)
50
+ end
37
51
  end
38
52
 
39
53
  private
@@ -67,12 +81,12 @@ module Pgbus
67
81
  key = Concurrency.extract_key(payload)
68
82
  return unless key
69
83
 
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])
84
+ # Atomic permit handoff: try to promote a blocked job first.
85
+ # promote_next wraps delete + enqueue in a transaction so neither is lost.
86
+ # If promoted, the slot stays occupied (no release needed).
87
+ # Only release the semaphore if there's nothing to promote.
88
+ promoted = Concurrency::BlockedExecution.promote_next(key, client: client)
89
+ Concurrency::Semaphore.release(key) unless promoted
76
90
  rescue StandardError => e
77
91
  Pgbus.logger.warn { "[Pgbus] Concurrency signal failed: #{e.message}" }
78
92
  end
data/lib/pgbus/batch.rb CHANGED
@@ -29,124 +29,117 @@ module Pgbus
29
29
  self
30
30
  end
31
31
 
32
- # Record a completed job. Returns the batch status after update.
32
+ # Record a completed job. Returns the batch row after update.
33
33
  def self.job_completed(batch_id)
34
34
  update_counter(batch_id, "completed_jobs")
35
35
  end
36
36
 
37
- # Record a discarded (dead-lettered) job. Returns the batch status after update.
37
+ # Record a discarded (dead-lettered) job. Returns the batch row after update.
38
38
  def self.job_discarded(batch_id)
39
39
  update_counter(batch_id, "discarded_jobs")
40
40
  end
41
41
 
42
42
  # Find a batch record by ID. Returns a hash or nil.
43
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
44
+ BatchEntry.find_by(batch_id: batch_id)&.attributes
52
45
  end
53
46
 
54
47
  # Delete finished batches older than the given threshold.
55
48
  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
49
+ BatchEntry.stale(before: older_than).delete_all
64
50
  end
65
51
 
66
52
  private
67
53
 
68
54
  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)]
55
+ BatchEntry.create!(
56
+ batch_id: batch_id,
57
+ description: description,
58
+ on_finish_class: on_finish&.name,
59
+ on_success_class: on_success&.name,
60
+ on_discard_class: on_discard&.name,
61
+ properties: JSON.generate(properties),
62
+ status: "pending"
79
63
  )
80
64
  end
81
65
 
82
66
  def count_jobs(&)
67
+ previous_batch_id = Thread.current[:pgbus_batch_id]
68
+ previous_count = Thread.current[:pgbus_batch_job_count]
69
+
83
70
  Thread.current[:pgbus_batch_id] = batch_id
84
- @job_count = 0
71
+ Thread.current[:pgbus_batch_job_count] = 0
85
72
 
86
73
  yield
87
74
 
88
75
  @job_count = Thread.current[:pgbus_batch_job_count] || 0
89
76
  ensure
90
- Thread.current[:pgbus_batch_id] = nil
91
- Thread.current[:pgbus_batch_job_count] = nil
77
+ Thread.current[:pgbus_batch_id] = previous_batch_id
78
+ Thread.current[:pgbus_batch_job_count] = previous_count
92
79
  end
93
80
 
94
81
  def update_total
95
- return unless defined?(ActiveRecord::Base)
82
+ if @job_count.zero?
83
+ # Finish empty batches immediately — no jobs to signal completion
84
+ BatchEntry.where(batch_id: batch_id).update_all(
85
+ total_jobs: 0,
86
+ status: "finished",
87
+ finished_at: Time.current
88
+ )
89
+ fire_empty_batch_callbacks
90
+ else
91
+ BatchEntry.where(batch_id: batch_id).update_all(total_jobs: @job_count, status: "processing")
92
+ end
93
+ end
96
94
 
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
- )
95
+ def fire_empty_batch_callbacks
96
+ record = BatchEntry.find_by(batch_id: batch_id)
97
+ return unless record
98
+
99
+ properties = parse_properties(record.properties)
100
+ self.class.send(:enqueue_callback, record.on_finish_class, properties) if record.on_finish_class
101
+ self.class.send(:enqueue_callback, record.on_success_class, properties) if record.on_success_class
102
+ end
103
+
104
+ def parse_properties(props)
105
+ JSON.parse(props.presence || "{}")
106
+ rescue JSON::ParserError => e
107
+ Pgbus.logger.error { "[Pgbus] Invalid batch properties JSON: #{e.message}" }
108
+ {}
102
109
  end
103
110
 
104
111
  class << self
105
112
  private
106
113
 
107
114
  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
- )
115
+ result = BatchEntry.increment_counter!(batch_id, column)
116
+ return nil unless result
128
117
 
129
- row = result.first
130
- return nil unless row
131
-
132
- fire_callbacks(row) if row["status"] == "finished"
133
- row
118
+ fire_callbacks(result[:record]) if result[:just_finished]
119
+ result
134
120
  end
135
121
 
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
122
+ def fire_callbacks(record)
123
+ properties = begin
124
+ JSON.parse(record.properties.presence || "{}")
125
+ rescue JSON::ParserError => e
126
+ Pgbus.logger.error { "[Pgbus] Invalid batch properties JSON: #{e.message}" }
127
+ {}
128
+ end
129
+ all_succeeded = record.discarded_jobs.zero?
130
+
131
+ enqueue_callback(record.on_finish_class, properties) if record.on_finish_class
132
+ enqueue_callback(record.on_success_class, properties) if record.on_success_class && all_succeeded
133
+ enqueue_callback(record.on_discard_class, properties) if record.on_discard_class && !all_succeeded
143
134
  end
144
135
 
145
136
  def enqueue_callback(class_name, properties)
146
- job_class = class_name.constantize
137
+ job_class = class_name.safe_constantize
138
+ unless job_class && job_class < ::ActiveJob::Base
139
+ Pgbus.logger.error { "[Pgbus] Batch callback class invalid or not an ActiveJob: #{class_name}" }
140
+ return
141
+ end
147
142
  job_class.perform_later(properties)
148
- rescue NameError => e
149
- Pgbus.logger.error { "[Pgbus] Batch callback class not found: #{class_name}: #{e.message}" }
150
143
  end
151
144
  end
152
145
  end
data/lib/pgbus/cli.rb CHANGED
@@ -33,24 +33,19 @@ module Pgbus
33
33
  end
34
34
 
35
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
- )
36
+ processes = ProcessEntry.order(:kind, :created_at)
37
+ .select(:kind, :hostname, :pid, :metadata, :last_heartbeat_at)
40
38
 
41
- if processes.none?
42
- puts "No Pgbus processes running."
43
- return
44
- end
39
+ if processes.none?
40
+ puts "No Pgbus processes running."
41
+ return
42
+ end
45
43
 
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."
44
+ puts "KIND HOST PID HEARTBEAT METADATA"
45
+ puts "-" * 100
46
+ processes.each do |p|
47
+ puts format("%-12s %-20s %-8s %-30s %s",
48
+ p.kind, p.hostname, p.pid, p.last_heartbeat_at, p.metadata)
54
49
  end
55
50
  end
56
51
 
data/lib/pgbus/client.rb CHANGED
@@ -6,61 +6,78 @@ module Pgbus
6
6
  class Client
7
7
  attr_reader :pgmq, :config
8
8
 
9
+ PGMQ_REQUIRE_MUTEX = Mutex.new
10
+ private_constant :PGMQ_REQUIRE_MUTEX
11
+
9
12
  def initialize(config = Pgbus.configuration)
10
- require "pgmq-ruby"
13
+ # Define the PGMQ module before requiring the gem so that Zeitwerk's
14
+ # eager_load (called inside pgmq.rb) can resolve the constant.
15
+ # Without this, Ruby 4.0 + Zeitwerk 2.7.5 raises NameError because
16
+ # eager_load runs const_get(:Client) on PGMQ before the module is defined.
17
+ PGMQ_REQUIRE_MUTEX.synchronize do
18
+ Object.const_set(:PGMQ, Module.new) unless defined?(::PGMQ)
19
+ require "pgmq"
20
+ end
11
21
  @config = config
12
22
  @pgmq = PGMQ::Client.new(
13
23
  config.connection_options,
14
24
  pool_size: config.pool_size,
15
25
  pool_timeout: config.pool_timeout
16
26
  )
17
- @queues_created = {}
18
- @mutex = Mutex.new
27
+ @queues_created = Concurrent::Map.new
19
28
  end
20
29
 
21
30
  def ensure_queue(name)
22
31
  full_name = config.queue_name(name)
23
- @mutex.synchronize do
24
- return if @queues_created[full_name]
32
+ return if @queues_created[full_name]
25
33
 
34
+ @queues_created.compute_if_absent(full_name) do
26
35
  @pgmq.create(full_name)
27
36
  @pgmq.enable_notify_insert(full_name, throttle_interval_ms: config.notify_throttle_ms) if config.listen_notify
28
- @queues_created[full_name] = true
37
+ true
29
38
  end
30
39
  end
31
40
 
32
41
  def ensure_dead_letter_queue(name)
33
42
  dlq_name = config.dead_letter_queue_name(name)
34
- @mutex.synchronize do
35
- return if @queues_created[dlq_name]
43
+ return if @queues_created[dlq_name]
36
44
 
45
+ @queues_created.compute_if_absent(dlq_name) do
37
46
  @pgmq.create(dlq_name)
38
- @queues_created[dlq_name] = true
47
+ true
39
48
  end
40
49
  end
41
50
 
42
51
  def send_message(queue_name, payload, headers: nil, delay: 0)
43
52
  full_name = config.queue_name(queue_name)
44
53
  ensure_queue(queue_name)
45
- @pgmq.produce(full_name, serialize(payload), headers: headers && serialize(headers), delay: delay)
54
+ Instrumentation.instrument("pgbus.client.send_message", queue: full_name) do
55
+ @pgmq.produce(full_name, serialize(payload), headers: headers && serialize(headers), delay: delay)
56
+ end
46
57
  end
47
58
 
48
59
  def send_batch(queue_name, payloads, headers: nil, delay: 0)
49
60
  full_name = config.queue_name(queue_name)
50
61
  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)
62
+ Instrumentation.instrument("pgbus.client.send_batch", queue: full_name, size: payloads.size) do
63
+ serialized = payloads.map { |p| serialize(p) }
64
+ serialized_headers = headers&.map { |h| serialize(h) }
65
+ @pgmq.produce_batch(full_name, serialized, headers: serialized_headers, delay: delay)
66
+ end
54
67
  end
55
68
 
56
69
  def read_message(queue_name, vt: nil)
57
70
  full_name = config.queue_name(queue_name)
58
- @pgmq.read(full_name, vt: vt || config.visibility_timeout)
71
+ Instrumentation.instrument("pgbus.client.read_message", queue: full_name) do
72
+ @pgmq.read(full_name, vt: vt || config.visibility_timeout)
73
+ end
59
74
  end
60
75
 
61
76
  def read_batch(queue_name, qty:, vt: nil)
62
77
  full_name = config.queue_name(queue_name)
63
- @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty)
78
+ Instrumentation.instrument("pgbus.client.read_batch", queue: full_name, qty: qty) do
79
+ @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty)
80
+ end
64
81
  end
65
82
 
66
83
  def read_with_poll(queue_name, qty:, vt: nil, max_poll_seconds: 5, poll_interval_ms: 100)
@@ -8,65 +8,60 @@ module Pgbus
8
8
  class << self
9
9
  # Insert a blocked execution for a job that hit the concurrency limit.
10
10
  def insert(concurrency_key:, queue_name:, payload:, duration:, priority: 0)
11
- expires_at = Time.now.utc + duration
12
-
13
- execute(<<~SQL, "Pgbus Blocked Insert", [concurrency_key, queue_name, JSON.generate(payload), priority, expires_at])
14
- INSERT INTO pgbus_blocked_executions
15
- (concurrency_key, queue_name, payload, priority, expires_at)
16
- VALUES ($1, $2, $3, $4, $5)
17
- SQL
11
+ Pgbus::BlockedExecution.create!(
12
+ concurrency_key: concurrency_key,
13
+ queue_name: queue_name,
14
+ payload: JSON.generate(payload),
15
+ priority: priority,
16
+ expires_at: Time.now.utc + duration
17
+ )
18
18
  end
19
19
 
20
20
  # Release the next blocked execution for a given concurrency key.
21
21
  # Returns the released row (queue_name, payload) or nil if none.
22
22
  def release_next(concurrency_key)
23
- return nil unless defined?(ActiveRecord::Base)
23
+ Pgbus::BlockedExecution.release_next!(concurrency_key)
24
+ end
24
25
 
25
- result = execute(<<~SQL, "Pgbus Blocked Release", [concurrency_key])
26
- DELETE FROM pgbus_blocked_executions
27
- WHERE id = (
28
- SELECT id FROM pgbus_blocked_executions
29
- WHERE concurrency_key = $1
30
- ORDER BY priority ASC, created_at ASC
31
- LIMIT 1
32
- FOR UPDATE SKIP LOCKED
33
- )
34
- RETURNING queue_name, payload
35
- SQL
26
+ # Atomically promote the next blocked execution: delete the row and enqueue
27
+ # the job in a single transaction. Returns true if a job was promoted, false
28
+ # otherwise. This avoids losing a blocked row if enqueue fails.
29
+ def promote_next(concurrency_key, client:, delay: 0)
30
+ released = nil
31
+ Pgbus::BlockedExecution.transaction do
32
+ released = release_next(concurrency_key)
33
+ raise ActiveRecord::Rollback unless released
36
34
 
37
- row = result.first
38
- return nil unless row
35
+ actual_delay = resolve_delay(released[:payload], delay)
36
+ client.send_message(released[:queue_name], released[:payload], delay: actual_delay)
37
+ end
39
38
 
40
- { queue_name: row["queue_name"], payload: JSON.parse(row["payload"]) }
39
+ !!released
40
+ rescue StandardError => e
41
+ Pgbus.logger.warn { "[Pgbus] Promote blocked execution failed for #{concurrency_key}: #{e.message}" }
42
+ false
41
43
  end
42
44
 
43
45
  # Delete blocked executions that have expired.
44
46
  # Returns the count of deleted rows.
45
47
  def expire_stale
46
- result = execute(<<~SQL, "Pgbus Blocked Expire", [Time.now.utc])
47
- DELETE FROM pgbus_blocked_executions
48
- WHERE expires_at < $1
49
- RETURNING id
50
- SQL
51
-
52
- result.to_a.size
48
+ Pgbus::BlockedExecution.expired(Time.now.utc).delete_all
53
49
  end
54
50
 
55
51
  # Count blocked executions for a given key. Useful for testing/monitoring.
56
52
  def count_for(concurrency_key)
57
- result = execute(<<~SQL, "Pgbus Blocked Count", [concurrency_key])
58
- SELECT COUNT(*) AS cnt FROM pgbus_blocked_executions WHERE concurrency_key = $1
59
- SQL
60
-
61
- result.first&.fetch("cnt", 0).to_i
53
+ Pgbus::BlockedExecution.where(concurrency_key: concurrency_key).count
62
54
  end
63
55
 
64
56
  private
65
57
 
66
- def execute(sql, name, binds)
67
- return [] unless defined?(ActiveRecord::Base)
58
+ def resolve_delay(payload, default_delay)
59
+ scheduled_at = payload["scheduled_at"]
60
+ return default_delay unless scheduled_at
68
61
 
69
- ActiveRecord::Base.connection.exec_query(sql, name, binds)
62
+ [Time.parse(scheduled_at).to_f - Time.now.to_f, 0].max.ceil
63
+ rescue StandardError
64
+ default_delay
70
65
  end
71
66
  end
72
67
  end