pgbus 0.1.4 → 0.1.6

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +326 -11
  3. data/app/controllers/pgbus/api/insights_controller.rb +16 -0
  4. data/app/controllers/pgbus/insights_controller.rb +10 -0
  5. data/app/controllers/pgbus/locks_controller.rb +9 -0
  6. data/app/controllers/pgbus/outbox_controller.rb +10 -0
  7. data/app/controllers/pgbus/queues_controller.rb +10 -0
  8. data/app/helpers/pgbus/application_helper.rb +34 -0
  9. data/app/models/pgbus/job_lock.rb +82 -0
  10. data/app/models/pgbus/job_stat.rb +94 -0
  11. data/app/models/pgbus/outbox_entry.rb +10 -0
  12. data/app/models/pgbus/queue_state.rb +33 -0
  13. data/app/views/layouts/pgbus/application.html.erb +33 -8
  14. data/app/views/pgbus/dashboard/_stats_cards.html.erb +24 -18
  15. data/app/views/pgbus/insights/show.html.erb +161 -0
  16. data/app/views/pgbus/locks/index.html.erb +53 -0
  17. data/app/views/pgbus/outbox/index.html.erb +55 -0
  18. data/app/views/pgbus/queues/_queues_list.html.erb +15 -1
  19. data/config/routes.rb +7 -0
  20. data/lib/generators/pgbus/add_job_locks_generator.rb +52 -0
  21. data/lib/generators/pgbus/add_job_stats_generator.rb +52 -0
  22. data/lib/generators/pgbus/add_outbox_generator.rb +52 -0
  23. data/lib/generators/pgbus/add_queue_states_generator.rb +51 -0
  24. data/lib/generators/pgbus/add_recurring_generator.rb +1 -1
  25. data/lib/generators/pgbus/install_generator.rb +1 -1
  26. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +21 -0
  27. data/lib/generators/pgbus/templates/add_job_stats.rb.erb +18 -0
  28. data/lib/generators/pgbus/templates/add_outbox.rb.erb +25 -0
  29. data/lib/generators/pgbus/templates/add_queue_states.rb.erb +16 -0
  30. data/lib/generators/pgbus/upgrade_pgmq_generator.rb +1 -1
  31. data/lib/pgbus/active_job/adapter.rb +64 -9
  32. data/lib/pgbus/active_job/executor.rb +67 -5
  33. data/lib/pgbus/circuit_breaker.rb +112 -0
  34. data/lib/pgbus/client.rb +127 -50
  35. data/lib/pgbus/configuration.rb +55 -1
  36. data/lib/pgbus/dedup_cache.rb +76 -0
  37. data/lib/pgbus/engine.rb +1 -0
  38. data/lib/pgbus/event_bus/handler.rb +13 -2
  39. data/lib/pgbus/outbox/poller.rb +117 -0
  40. data/lib/pgbus/outbox.rb +30 -0
  41. data/lib/pgbus/process/consumer_priority.rb +64 -0
  42. data/lib/pgbus/process/dispatcher.rb +75 -0
  43. data/lib/pgbus/process/heartbeat.rb +3 -1
  44. data/lib/pgbus/process/lifecycle.rb +111 -0
  45. data/lib/pgbus/process/queue_lock.rb +87 -0
  46. data/lib/pgbus/process/supervisor.rb +46 -6
  47. data/lib/pgbus/process/wake_signal.rb +53 -0
  48. data/lib/pgbus/process/worker.rb +117 -21
  49. data/lib/pgbus/queue_factory.rb +62 -0
  50. data/lib/pgbus/rate_counter.rb +81 -0
  51. data/lib/pgbus/recurring/schedule.rb +1 -1
  52. data/lib/pgbus/uniqueness.rb +169 -0
  53. data/lib/pgbus/version.rb +1 -1
  54. data/lib/pgbus/web/data_source.rb +136 -2
  55. data/lib/pgbus.rb +9 -0
  56. metadata +31 -1
@@ -0,0 +1,52 @@
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 AddJobLocksGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Add job locks table for uniqueness guarantees"
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_file
21
+ if separate_database?
22
+ migration_template "add_job_locks.rb.erb",
23
+ "db/pgbus_migrate/add_pgbus_job_locks.rb"
24
+ else
25
+ migration_template "add_job_locks.rb.erb",
26
+ "db/migrate/add_pgbus_job_locks.rb"
27
+ end
28
+ end
29
+
30
+ def display_post_install
31
+ say ""
32
+ say "Pgbus job locks table installed!", :green
33
+ say ""
34
+ say "Next steps:"
35
+ say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
36
+ say " 2. Add `ensures_uniqueness` to your job classes (DSL is auto-included)"
37
+ say " 3. Restart pgbus: bin/pgbus start"
38
+ say ""
39
+ end
40
+
41
+ private
42
+
43
+ def migration_version
44
+ "[#{ActiveRecord::Migration.current_version}]"
45
+ end
46
+
47
+ def separate_database?
48
+ options[:database].present?
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,52 @@
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 AddJobStatsGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Add job stats table for dashboard insights and performance tracking"
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_file
21
+ if separate_database?
22
+ migration_template "add_job_stats.rb.erb",
23
+ "db/pgbus_migrate/add_pgbus_job_stats.rb"
24
+ else
25
+ migration_template "add_job_stats.rb.erb",
26
+ "db/migrate/add_pgbus_job_stats.rb"
27
+ end
28
+ end
29
+
30
+ def display_post_install
31
+ say ""
32
+ say "Pgbus job stats table installed!", :green
33
+ say ""
34
+ say "Next steps:"
35
+ say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
36
+ say " 2. Stats collection is enabled by default"
37
+ say " 3. View insights at /pgbus/insights"
38
+ say ""
39
+ end
40
+
41
+ private
42
+
43
+ def migration_version
44
+ "[#{ActiveRecord::Migration.current_version}]"
45
+ end
46
+
47
+ def separate_database?
48
+ options[:database].present?
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,52 @@
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 AddOutboxGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Add outbox table for transactional event publishing"
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_file
21
+ if separate_database?
22
+ migration_template "add_outbox.rb.erb",
23
+ "db/pgbus_migrate/add_pgbus_outbox.rb"
24
+ else
25
+ migration_template "add_outbox.rb.erb",
26
+ "db/migrate/add_pgbus_outbox.rb"
27
+ end
28
+ end
29
+
30
+ def display_post_install
31
+ say ""
32
+ say "Pgbus outbox installed!", :green
33
+ say ""
34
+ say "Next steps:"
35
+ say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
36
+ say " 2. Enable in config: config.outbox_enabled = true"
37
+ say " 3. Restart pgbus: bin/pgbus start"
38
+ say ""
39
+ end
40
+
41
+ private
42
+
43
+ def migration_version
44
+ "[#{ActiveRecord::Migration.current_version}]"
45
+ end
46
+
47
+ def separate_database?
48
+ options[:database].present?
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,51 @@
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 AddQueueStatesGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Add queue states table for pause/resume and circuit breaker support"
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_file
21
+ if separate_database?
22
+ migration_template "add_queue_states.rb.erb",
23
+ "db/pgbus_migrate/add_pgbus_queue_states.rb"
24
+ else
25
+ migration_template "add_queue_states.rb.erb",
26
+ "db/migrate/add_pgbus_queue_states.rb"
27
+ end
28
+ end
29
+
30
+ def display_post_install
31
+ say ""
32
+ say "Pgbus queue states table installed!", :green
33
+ say ""
34
+ say "Next steps:"
35
+ say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
36
+ say " 2. Restart pgbus: bin/pgbus start"
37
+ say ""
38
+ end
39
+
40
+ private
41
+
42
+ def migration_version
43
+ "[#{ActiveRecord::Migration.current_version}]"
44
+ end
45
+
46
+ def separate_database?
47
+ options[:database].present?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -17,7 +17,7 @@ module Pgbus
17
17
  default: nil,
18
18
  desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
19
19
 
20
- def create_migration
20
+ def create_migration_file
21
21
  if separate_database?
22
22
  migration_template "add_recurring_tables.rb.erb",
23
23
  "db/pgbus_migrate/add_pgbus_recurring_tables.rb"
@@ -24,7 +24,7 @@ module Pgbus
24
24
  desc: "Use a separate database for pgbus tables (e.g. --database=pgbus). " \
25
25
  "Migrations go to db/pgbus_migrate/ and schema to db/pgbus_schema.rb"
26
26
 
27
- def create_migration
27
+ def create_migration_file
28
28
  if separate_database?
29
29
  migration_template "migration.rb.erb",
30
30
  "db/pgbus_migrate/create_pgbus_tables.rb"
@@ -0,0 +1,21 @@
1
+ class AddPgbusJobLocks < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :pgbus_job_locks do |t|
4
+ t.string :lock_key, null: false
5
+ t.string :job_class, null: false
6
+ t.string :job_id
7
+ t.string :state, null: false, default: "queued"
8
+ t.integer :owner_pid
9
+ t.string :owner_hostname
10
+ t.datetime :locked_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
11
+ t.datetime :expires_at, null: false
12
+ end
13
+
14
+ add_index :pgbus_job_locks, :lock_key,
15
+ unique: true, name: "idx_pgbus_job_locks_key"
16
+ add_index :pgbus_job_locks, :expires_at,
17
+ name: "idx_pgbus_job_locks_expires"
18
+ add_index :pgbus_job_locks, [:state, :owner_pid],
19
+ name: "idx_pgbus_job_locks_reaper"
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ class AddPgbusJobStats < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :pgbus_job_stats do |t|
4
+ t.string :job_class, null: false
5
+ t.string :queue_name, null: false
6
+ t.string :status, null: false
7
+ t.integer :duration_ms, null: false, default: 0
8
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
9
+ end
10
+
11
+ add_index :pgbus_job_stats, :created_at,
12
+ name: "idx_pgbus_job_stats_time"
13
+ add_index :pgbus_job_stats, [:job_class, :created_at],
14
+ name: "idx_pgbus_job_stats_class_time"
15
+ add_index :pgbus_job_stats, [:status, :created_at],
16
+ name: "idx_pgbus_job_stats_status_time"
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ class AddPgbusOutbox < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :pgbus_outbox_entries do |t|
4
+ t.string :queue_name
5
+ t.string :routing_key
6
+ t.jsonb :payload, null: false
7
+ t.jsonb :headers
8
+ t.integer :priority, default: 0
9
+ t.integer :delay, default: 0
10
+ t.datetime :published_at
11
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
12
+ end
13
+
14
+ add_check_constraint :pgbus_outbox_entries,
15
+ "(queue_name IS NOT NULL) <> (routing_key IS NOT NULL)",
16
+ name: "chk_pgbus_outbox_destination"
17
+
18
+ add_index :pgbus_outbox_entries, :published_at,
19
+ where: "published_at IS NULL",
20
+ name: "idx_pgbus_outbox_unpublished"
21
+ add_index :pgbus_outbox_entries, :published_at,
22
+ where: "published_at IS NOT NULL",
23
+ name: "idx_pgbus_outbox_cleanup"
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ class AddPgbusQueueStates < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :pgbus_queue_states do |t|
4
+ t.string :queue_name, null: false
5
+ t.boolean :paused, null: false, default: false
6
+ t.string :paused_reason
7
+ t.datetime :paused_at
8
+ t.integer :circuit_breaker_trip_count, default: 0
9
+ t.datetime :circuit_breaker_resume_at
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :pgbus_queue_states, :queue_name,
14
+ unique: true, name: "idx_pgbus_queue_states_queue_name"
15
+ end
16
+ end
@@ -17,7 +17,7 @@ module Pgbus
17
17
  default: nil,
18
18
  desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
19
19
 
20
- def create_migration
20
+ def create_migration_file
21
21
  if separate_database?
22
22
  migration_template "upgrade_pgmq.rb.erb",
23
23
  "db/pgbus_migrate/upgrade_pgmq_to_v#{target_version_slug}.rb"
@@ -9,8 +9,11 @@ module Pgbus
9
9
  queue = active_job.queue_name || Pgbus.configuration.default_queue
10
10
  payload_hash = Serializer.serialize_job_hash(active_job)
11
11
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
12
+ payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
12
13
  payload_hash = inject_batch_metadata(payload_hash)
13
14
 
15
+ return active_job if uniqueness_rejected?(active_job, payload_hash)
16
+
14
17
  enqueue_with_concurrency(active_job, queue, payload_hash)
15
18
  end
16
19
 
@@ -18,14 +21,27 @@ module Pgbus
18
21
  queue = active_job.queue_name || Pgbus.configuration.default_queue
19
22
  payload_hash = Serializer.serialize_job_hash(active_job)
20
23
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
24
+ payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
21
25
  payload_hash = inject_batch_metadata(payload_hash)
22
26
  delay = [(timestamp - Time.now.to_f).ceil, 0].max
23
27
 
28
+ return active_job if uniqueness_rejected?(active_job, payload_hash)
29
+
24
30
  enqueue_with_concurrency(active_job, queue, payload_hash, delay: delay)
25
31
  end
26
32
 
27
33
  def enqueue_all(active_jobs)
28
- active_jobs.group_by { |j| j.queue_name || Pgbus.configuration.default_queue }.each do |queue, jobs|
34
+ # Jobs with uniqueness must go through individual enqueue to acquire locks
35
+ unique, bulk = active_jobs.partition { |j| Uniqueness.uniqueness_config(j) }
36
+ unique.each do |j|
37
+ if j.scheduled_at && j.scheduled_at > Time.now
38
+ enqueue_at(j, j.scheduled_at.to_f)
39
+ else
40
+ enqueue(j)
41
+ end
42
+ end
43
+
44
+ bulk.group_by { |j| j.queue_name || Pgbus.configuration.default_queue }.each do |queue, jobs|
29
45
  enqueue_immediate(queue, jobs.reject { |j| j.scheduled_at && j.scheduled_at > Time.now })
30
46
  jobs.select { |j| j.scheduled_at && j.scheduled_at > Time.now }.each { |j| enqueue_at(j, j.scheduled_at.to_f) }
31
47
  end
@@ -38,39 +54,50 @@ module Pgbus
38
54
  def enqueue_with_concurrency(active_job, queue, payload_hash, delay: 0)
39
55
  key = Concurrency.extract_key(payload_hash)
40
56
  concurrency = concurrency_config(active_job)
57
+ priority = active_job.try(:priority)
41
58
 
42
59
  if key && concurrency
43
60
  result = Concurrency::Semaphore.acquire(key, concurrency[:limit], concurrency[:duration])
44
61
 
45
62
  if result == :acquired
46
- msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay)
63
+ msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
47
64
  active_job.provider_job_id = msg_id
48
65
  else
49
- handle_conflict(concurrency, active_job, key, queue, payload_hash)
66
+ handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: priority)
50
67
  end
51
68
  else
52
- msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay)
69
+ msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
53
70
  active_job.provider_job_id = msg_id
54
71
  end
55
72
 
73
+ Thread.current[:pgbus_acquired_uniqueness_key] = nil
56
74
  active_job
57
- rescue Pgbus::SchemaNotReady => e
58
- Pgbus.logger.error { "[Pgbus] #{e.message}" }
59
- raise
75
+ rescue StandardError => e
76
+ # Roll back the uniqueness lock if enqueue failed
77
+ rollback_key = Thread.current[:pgbus_acquired_uniqueness_key]
78
+ if rollback_key
79
+ begin
80
+ Uniqueness.release_lock(rollback_key)
81
+ rescue StandardError => rollback_error
82
+ Pgbus.logger.warn { "[Pgbus] Lock rollback failed: #{rollback_error.message}" }
83
+ end
84
+ Thread.current[:pgbus_acquired_uniqueness_key] = nil
85
+ end
86
+ raise e
60
87
  end
61
88
 
62
89
  def concurrency_config(active_job)
63
90
  active_job.class.respond_to?(:pgbus_concurrency) && active_job.class.pgbus_concurrency
64
91
  end
65
92
 
66
- def handle_conflict(concurrency, active_job, key, queue, payload_hash)
93
+ def handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: nil)
67
94
  case concurrency[:on_conflict]
68
95
  when :block
69
96
  Concurrency::BlockedExecution.insert(
70
97
  concurrency_key: key,
71
98
  queue_name: queue,
72
99
  payload: payload_hash,
73
- priority: active_job.try(:priority) || 0,
100
+ priority: priority || Pgbus.configuration.default_priority,
74
101
  duration: concurrency[:duration]
75
102
  )
76
103
  when :discard
@@ -80,6 +107,34 @@ module Pgbus
80
107
  end
81
108
  end
82
109
 
110
+ def uniqueness_rejected?(active_job, payload_hash)
111
+ uniqueness_key = Uniqueness.extract_key(payload_hash)
112
+ return false unless uniqueness_key
113
+
114
+ result = Uniqueness.acquire_enqueue_lock(uniqueness_key, active_job)
115
+
116
+ # :no_lock means no enqueue-time lock needed (e.g. :while_executing strategy)
117
+ return false if result == :no_lock
118
+
119
+ # Store the acquired key so we can release it if enqueue fails
120
+ Thread.current[:pgbus_acquired_uniqueness_key] = uniqueness_key if result == :acquired
121
+ return false if result == :acquired
122
+
123
+ config = Uniqueness.uniqueness_config(active_job)
124
+ case config[:on_conflict]
125
+ when :reject
126
+ raise JobNotUnique, "Job #{active_job.class.name} is already locked"
127
+ when :discard
128
+ Pgbus.logger.info { "[Pgbus] Discarding duplicate job #{active_job.class.name}" }
129
+ true
130
+ when :log
131
+ Pgbus.logger.warn { "[Pgbus] Duplicate job #{active_job.class.name} detected" }
132
+ true
133
+ else
134
+ true
135
+ end
136
+ end
137
+
83
138
  def inject_batch_metadata(payload_hash)
84
139
  batch_id = Thread.current[:pgbus_batch_id]
85
140
  return payload_hash unless batch_id
@@ -10,33 +10,58 @@ module Pgbus
10
10
  @config = config
11
11
  end
12
12
 
13
- def execute(message, queue_name)
13
+ def execute(message, queue_name, source_queue: nil)
14
+ execution_start = monotonic_now
14
15
  payload = JSON.parse(message.message)
15
16
  read_count = message.read_ct.to_i
16
17
 
17
18
  if read_count > config.max_retries
18
- handle_dead_letter(message, queue_name, payload)
19
+ handle_dead_letter(message, queue_name, payload, source_queue: source_queue)
19
20
  signal_concurrency(payload)
20
21
  signal_batch_discarded(payload)
22
+ Uniqueness.release_lock(Uniqueness.extract_key(payload))
23
+ record_stat(payload, queue_name, "dead_lettered", execution_start)
21
24
  return :dead_lettered
22
25
  end
23
26
 
24
27
  job_class = payload["job_class"]
28
+ uniqueness_key = Uniqueness.extract_key(payload)
29
+ uniqueness_strategy = Uniqueness.extract_strategy(payload)
30
+ uniqueness_ttl = payload[Uniqueness::TTL_KEY] || Uniqueness::DEFAULT_LOCK_TTL
31
+
32
+ if uniqueness_key
33
+ case uniqueness_strategy
34
+ when :until_executed
35
+ # Transition the queued lock to executing state with our PID.
36
+ # The lock was acquired at enqueue time — now we claim ownership
37
+ # so the reaper can correlate it with our heartbeat.
38
+ Uniqueness.claim_for_execution!(uniqueness_key, ttl: uniqueness_ttl)
39
+ when :while_executing
40
+ # Acquire the lock now. If another worker is already executing
41
+ # this job, skip it — VT will expire and it'll be retried.
42
+ unless Uniqueness.acquire_execution_lock(uniqueness_key, payload)
43
+ Pgbus.logger.info { "[Pgbus] Skipping duplicate execution for #{job_class}" }
44
+ return :skipped
45
+ end
46
+ end
47
+ end
25
48
 
26
49
  job_succeeded = false
27
50
 
28
51
  Instrumentation.instrument("pgbus.executor.execute", queue: queue_name, job_class: job_class) do
29
52
  job = ::ActiveJob::Base.deserialize(payload)
30
53
  execute_job(job)
31
- client.archive_message(queue_name, message.msg_id.to_i)
54
+ archive_from(queue_name, message.msg_id.to_i, source_queue: source_queue)
32
55
  job_succeeded = true
33
56
  end
34
57
 
35
58
  instrument("pgbus.job_completed", queue: queue_name, job_class: job_class)
59
+ record_stat(payload, queue_name, "success", execution_start)
36
60
  :success
37
61
  rescue StandardError => e
38
62
  handle_failure(message, queue_name, e)
39
63
  instrument("pgbus.job_failed", queue: queue_name, job_class: payload&.dig("job_class"), error: e.class.name)
64
+ record_stat(payload, queue_name, "failed", execution_start)
40
65
  # Don't signal concurrency on transient failure — the job will be retried.
41
66
  # Semaphore is released only on success or dead-lettering.
42
67
  :failed
@@ -47,6 +72,8 @@ module Pgbus
47
72
  if job_succeeded
48
73
  signal_concurrency(payload)
49
74
  signal_batch_completed(payload)
75
+ # Release uniqueness lock on successful completion (both strategies)
76
+ Uniqueness.release_lock(uniqueness_key) if uniqueness_key
50
77
  end
51
78
  end
52
79
 
@@ -60,6 +87,24 @@ module Pgbus
60
87
  end
61
88
  end
62
89
 
90
+ def monotonic_now
91
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
92
+ end
93
+
94
+ def record_stat(payload, queue_name, status, start_time)
95
+ return unless config.stats_enabled
96
+
97
+ duration_ms = ((monotonic_now - start_time) * 1000).round
98
+ JobStat.record!(
99
+ job_class: payload&.dig("job_class") || "unknown",
100
+ queue_name: queue_name,
101
+ status: status,
102
+ duration_ms: duration_ms
103
+ )
104
+ rescue StandardError => e
105
+ Pgbus.logger.debug { "[Pgbus] Stat recording failed: #{e.message}" }
106
+ end
107
+
63
108
  def handle_failure(_message, _queue_name, error)
64
109
  Pgbus.logger.error { "[Pgbus] Job failed: #{error.class}: #{error.message}" }
65
110
  Pgbus.logger.debug { error.backtrace&.join("\n") }
@@ -109,12 +154,29 @@ module Pgbus
109
154
  Pgbus.logger.warn { "[Pgbus] Batch discard signal failed: #{e.message}" }
110
155
  end
111
156
 
112
- def handle_dead_letter(message, queue_name, payload)
157
+ def archive_from(queue_name, msg_id, source_queue: nil)
158
+ if source_queue
159
+ client.archive_from_queue(source_queue, msg_id)
160
+ else
161
+ client.archive_message(queue_name, msg_id)
162
+ end
163
+ end
164
+
165
+ def handle_dead_letter(message, queue_name, payload, source_queue: nil)
113
166
  Pgbus.logger.warn do
114
167
  job_class = payload["job_class"] || "unknown"
115
168
  "[Pgbus] Moving job #{job_class} to dead letter queue after #{message.read_ct} attempts"
116
169
  end
117
- client.move_to_dead_letter(queue_name, message)
170
+ if source_queue
171
+ client.ensure_dead_letter_queue(queue_name)
172
+ dlq_name = config.dead_letter_queue_name(queue_name)
173
+ client.transaction do |txn|
174
+ txn.produce(dlq_name, message.message, headers: message.headers)
175
+ txn.delete(source_queue, message.msg_id.to_i)
176
+ end
177
+ else
178
+ client.move_to_dead_letter(queue_name, message)
179
+ end
118
180
  end
119
181
  end
120
182
  end