pgbus 0.14.0 → 0.14.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/app/models/pgbus/batch_entry.rb +78 -6
  4. data/app/models/pgbus/batch_execution.rb +28 -0
  5. data/app/models/pgbus/blocked_execution.rb +2 -2
  6. data/app/models/pgbus/uniqueness_key.rb +50 -4
  7. data/app/views/pgbus/batches/_batches_table.html.erb +3 -3
  8. data/app/views/pgbus/batches/show.html.erb +7 -7
  9. data/config/locales/da.yml +2 -2
  10. data/config/locales/de.yml +2 -2
  11. data/config/locales/en.yml +2 -2
  12. data/config/locales/es.yml +2 -2
  13. data/config/locales/fi.yml +2 -2
  14. data/config/locales/fr.yml +2 -2
  15. data/config/locales/it.yml +2 -2
  16. data/config/locales/ja.yml +2 -2
  17. data/config/locales/nb.yml +2 -2
  18. data/config/locales/nl.yml +2 -2
  19. data/config/locales/pt.yml +2 -2
  20. data/config/locales/sv.yml +2 -2
  21. data/lib/generators/pgbus/add_batch_callback_jobs_generator.rb +47 -0
  22. data/lib/generators/pgbus/add_batch_executions_generator.rb +44 -0
  23. data/lib/generators/pgbus/templates/add_batch_callback_jobs.rb.erb +13 -0
  24. data/lib/generators/pgbus/templates/add_batch_executions.rb.erb +50 -0
  25. data/lib/generators/pgbus/templates/initializer.rb.erb +2 -0
  26. data/lib/generators/pgbus/templates/migration.rb.erb +23 -2
  27. data/lib/pgbus/active_job/adapter.rb +142 -28
  28. data/lib/pgbus/active_job/batch_id.rb +48 -0
  29. data/lib/pgbus/active_job/executor.rb +45 -9
  30. data/lib/pgbus/batch/sweep.rb +163 -0
  31. data/lib/pgbus/batch.rb +448 -63
  32. data/lib/pgbus/bus_record.rb +15 -1
  33. data/lib/pgbus/client.rb +191 -15
  34. data/lib/pgbus/concurrency/blocked_execution.rb +14 -1
  35. data/lib/pgbus/configuration.rb +24 -1
  36. data/lib/pgbus/engine.rb +1 -0
  37. data/lib/pgbus/generators/migration_detector.rb +30 -0
  38. data/lib/pgbus/instrumentation.rb +5 -0
  39. data/lib/pgbus/outbox/poller.rb +6 -7
  40. data/lib/pgbus/process/dispatcher.rb +41 -17
  41. data/lib/pgbus/recurring/schedule.rb +12 -1
  42. data/lib/pgbus/uniqueness.rb +35 -7
  43. data/lib/pgbus/version.rb +1 -1
  44. data/lib/pgbus/web/data_source.rb +47 -9
  45. metadata +8 -1
data/lib/pgbus/client.rb CHANGED
@@ -339,6 +339,10 @@ module Pgbus
339
339
  end
340
340
  end
341
341
 
342
+ def target_queue(queue_name, priority = nil)
343
+ @queue_strategy.target_queue(queue_name, priority)
344
+ end
345
+
342
346
  def send_message(queue_name, payload, headers: nil, delay: 0, priority: nil)
343
347
  target = @queue_strategy.target_queue(queue_name, priority)
344
348
  Instrumentation.instrument("pgbus.client.send_message", queue: target) do
@@ -381,13 +385,17 @@ module Pgbus
381
385
  msg_id
382
386
  end
383
387
 
384
- def send_batch(queue_name, payloads, headers: nil, delay: 0)
385
- full_name = config.queue_name(queue_name)
388
+ # Bulk enqueue. Routes through the queue strategy exactly like
389
+ # #send_message: under priority routing the bare `pgbus_<queue>` table is
390
+ # never created (only `_p0.._pN` are), so targeting it raised
391
+ # PG::UndefinedTable and no worker would have read it either.
392
+ def send_batch(queue_name, payloads, headers: nil, delay: 0, priority: nil)
393
+ target = @queue_strategy.target_queue(queue_name, priority)
386
394
  serialized, serialized_headers = serialize_batch(payloads, headers)
387
- Instrumentation.instrument("pgbus.client.send_batch", queue: full_name, size: payloads.size) do
395
+ Instrumentation.instrument("pgbus.client.send_batch", queue: target, size: payloads.size) do
388
396
  with_stale_connection_retry do
389
397
  ensure_queue(queue_name)
390
- synchronized { @pgmq.produce_batch(full_name, serialized, headers: serialized_headers, delay: delay) }
398
+ synchronized { @pgmq.produce_batch(target, serialized, headers: serialized_headers, delay: delay) }
391
399
  end
392
400
  end
393
401
  end
@@ -682,21 +690,55 @@ module Pgbus
682
690
  # false — the message definitely does not exist
683
691
  # nil — could not determine (e.g. queue table missing or unknown error).
684
692
  # Callers MUST treat nil as "exists" for safety.
685
- def message_exists?(queue_name, msg_id: nil, uniqueness_key: nil)
686
- has_msg_id = !msg_id.nil?
687
- has_uniqueness_key = !uniqueness_key.nil?
688
- raise ArgumentError, "pass exactly one of msg_id or uniqueness_key" unless has_msg_id ^ has_uniqueness_key
693
+ def message_in_queue?(queue_name, msg_id:)
694
+ message_exists?(queue_name, msg_id: msg_id)
695
+ end
696
+
697
+ # Look up a queue row by ActiveJob job_id in the JSON payload. Used by the
698
+ # batch sweep: move_to_dead_letter produces a new DLQ msg_id, so the
699
+ # source msg_id is not a DLQ identity.
700
+ #
701
+ # Same tri-state as message_exists?: true / false / nil (unknown). A
702
+ # logical name expands to every physical table the strategy owns (the
703
+ # `_pN` sub-queues under priority routing); an already-prefixed physical
704
+ # name probes itself only.
705
+ def message_with_job_id?(queue_name, job_id:)
706
+ tables = lookup_physical_queue_names(queue_name).map { |name| QueueNameValidator.sanitize!(name) }
707
+ determined = false
708
+
709
+ synchronized do
710
+ with_raw_connection do |conn|
711
+ tables.each do |sanitized|
712
+ present = probe_job_id_presence(conn, sanitized, job_id)
713
+ return true if present == true
714
+
715
+ determined = true unless present == :missing
716
+ end
717
+ end
718
+ end
689
719
 
720
+ determined ? false : nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition -- tri-state: nil means unknown
721
+ end
722
+
723
+ # DLQ companion of a logical or already-physical queue name. Does not
724
+ # re-prefix a name that resolve_full_queue_name already expanded.
725
+ def dead_letter_physical_name(queue_name)
726
+ full = resolve_full_queue_name(queue_name)
727
+ # Priority sub-queues share the logical queue's DLQ (`pgbus_default_dlq`),
728
+ # not a per-priority `…_pN_dlq`. Only strip when the strategy actually
729
+ # creates `_pN` tables — a logical queue named `orders_p0` must keep it.
730
+ base = @queue_strategy.priority? ? full.sub(/_p\d+\z/, "") : full
731
+ "#{base}#{Pgbus::DEAD_LETTER_SUFFIX}"
732
+ end
733
+
734
+ # Same tri-state as message_exists?: true / false / nil (unknown).
735
+ def message_archived?(queue_name, msg_id:)
690
736
  full_name = resolve_full_queue_name(queue_name)
691
737
  sanitized = QueueNameValidator.sanitize!(full_name)
692
738
 
693
739
  synchronized do
694
740
  with_raw_connection do |conn|
695
- if has_msg_id
696
- msg_id_present?(conn, sanitized, msg_id.to_i)
697
- else
698
- uniqueness_key_present?(conn, sanitized, uniqueness_key)
699
- end
741
+ msg_id_archived?(conn, sanitized, msg_id.to_i)
700
742
  end
701
743
  end
702
744
  rescue ActiveRecord::StatementInvalid => e
@@ -709,6 +751,64 @@ module Pgbus
709
751
  nil
710
752
  end
711
753
 
754
+ def message_exists?(queue_name, msg_id: nil, uniqueness_key: nil)
755
+ has_msg_id = !msg_id.nil?
756
+ has_uniqueness_key = !uniqueness_key.nil?
757
+ raise ArgumentError, "pass msg_id, uniqueness_key, or both" unless has_msg_id || has_uniqueness_key
758
+
759
+ tables = lookup_physical_queue_names(queue_name).map { |name| QueueNameValidator.sanitize!(name) }
760
+ determined = false
761
+
762
+ synchronized do
763
+ with_raw_connection do |conn|
764
+ tables.each do |sanitized|
765
+ present = probe_queue_presence(
766
+ conn, sanitized,
767
+ msg_id: has_msg_id ? msg_id.to_i : nil,
768
+ uniqueness_key: has_uniqueness_key ? uniqueness_key : nil
769
+ )
770
+ return true if present == true
771
+
772
+ determined = true unless present == :missing
773
+ end
774
+ end
775
+ end
776
+
777
+ determined ? false : nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition -- tri-state: nil means unknown
778
+ end
779
+
780
+ # Which uniqueness keys currently appear in any live PGMQ queue payload.
781
+ # Used by the dispatcher reaper for unbound locks (pending / msg_id=0)
782
+ # so it never probes the synthetic `pending` queue (issue #418).
783
+ #
784
+ # Per-queue UndefinedTable is skipped (table dropped between listing and
785
+ # select). Failure to list pgmq.meta — or any non-undefined error — raises;
786
+ # callers must treat that as "unknown" and keep the lock.
787
+ def uniqueness_keys_present(lock_keys)
788
+ keys = Array(lock_keys).compact.map(&:to_s).uniq
789
+ return Set.new if keys.empty?
790
+
791
+ found = Set.new
792
+ synchronized do
793
+ with_raw_connection do |conn|
794
+ names = conn.exec("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
795
+ .map { |row| row["queue_name"] }
796
+ names.each do |name|
797
+ break if found.size == keys.size
798
+
799
+ sanitized = begin
800
+ QueueNameValidator.sanitize!(name)
801
+ rescue ArgumentError
802
+ next
803
+ end
804
+
805
+ scan_uniqueness_keys(conn, sanitized, keys, found)
806
+ end
807
+ end
808
+ end
809
+ found
810
+ end
811
+
712
812
  def purge_archive(queue_name, older_than:, batch_size: 1000)
713
813
  full_name = config.queue_name(queue_name)
714
814
  sanitized = QueueNameValidator.sanitize!(full_name)
@@ -933,9 +1033,76 @@ module Pgbus
933
1033
  name.start_with?(prefix) ? name : config.queue_name(name)
934
1034
  end
935
1035
 
936
- def msg_id_present?(conn, sanitized, msg_id)
1036
+ # Logical names expand through QueueFactory so a priority queue's _pN
1037
+ # tables are included; already-prefixed physical names stay as-is.
1038
+ def lookup_physical_queue_names(queue_name)
1039
+ name = queue_name.to_s
1040
+ prefix = "#{config.queue_prefix}_"
1041
+ name.start_with?(prefix) ? [name] : @queue_strategy.physical_queue_names(name)
1042
+ end
1043
+
1044
+ def probe_queue_presence(conn, sanitized, msg_id:, uniqueness_key:)
1045
+ if msg_id
1046
+ msg_id_present?(conn, sanitized, msg_id, uniqueness_key: uniqueness_key)
1047
+ else
1048
+ uniqueness_key_present?(conn, sanitized, uniqueness_key)
1049
+ end
1050
+ rescue ActiveRecord::StatementInvalid => e
1051
+ raise unless undefined_table_error?(e)
1052
+
1053
+ :missing
1054
+ rescue StandardError => e
1055
+ raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
1056
+
1057
+ :missing
1058
+ end
1059
+
1060
+ def probe_job_id_presence(conn, sanitized, job_id)
1061
+ job_id_present?(conn, sanitized, job_id)
1062
+ rescue ActiveRecord::StatementInvalid => e
1063
+ raise unless undefined_table_error?(e)
1064
+
1065
+ :missing
1066
+ rescue StandardError => e
1067
+ raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
1068
+
1069
+ :missing
1070
+ end
1071
+
1072
+ def scan_uniqueness_keys(conn, sanitized, keys, found)
1073
+ placeholders = keys.each_index.map { |i| "$#{i + 1}" }.join(", ")
937
1074
  result = conn.exec_params(
938
- "SELECT 1 FROM pgmq.q_#{sanitized} WHERE msg_id = $1 LIMIT 1",
1075
+ "SELECT DISTINCT message::jsonb ->> 'pgbus_uniqueness_key' AS k " \
1076
+ "FROM pgmq.q_#{sanitized} " \
1077
+ "WHERE message::jsonb ->> 'pgbus_uniqueness_key' IN (#{placeholders})",
1078
+ keys
1079
+ )
1080
+ result.each { |row| found.add(row["k"]) if row["k"] }
1081
+ rescue ActiveRecord::StatementInvalid => e
1082
+ raise unless undefined_table_error?(e)
1083
+ rescue StandardError => e
1084
+ raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
1085
+ end
1086
+
1087
+ def msg_id_present?(conn, sanitized, msg_id, uniqueness_key: nil)
1088
+ result = if uniqueness_key
1089
+ conn.exec_params(
1090
+ "SELECT 1 FROM pgmq.q_#{sanitized} WHERE msg_id = $1 " \
1091
+ "AND message::jsonb ->> 'pgbus_uniqueness_key' = $2 LIMIT 1",
1092
+ [msg_id, uniqueness_key]
1093
+ )
1094
+ else
1095
+ conn.exec_params(
1096
+ "SELECT 1 FROM pgmq.q_#{sanitized} WHERE msg_id = $1 LIMIT 1",
1097
+ [msg_id]
1098
+ )
1099
+ end
1100
+ result.ntuples.positive?
1101
+ end
1102
+
1103
+ def msg_id_archived?(conn, sanitized, msg_id)
1104
+ result = conn.exec_params(
1105
+ "SELECT 1 FROM pgmq.a_#{sanitized} WHERE msg_id = $1 LIMIT 1",
939
1106
  [msg_id]
940
1107
  )
941
1108
  result.ntuples.positive?
@@ -950,6 +1117,15 @@ module Pgbus
950
1117
  result.ntuples.positive?
951
1118
  end
952
1119
 
1120
+ def job_id_present?(conn, sanitized, job_id)
1121
+ result = conn.exec_params(
1122
+ "SELECT 1 FROM pgmq.q_#{sanitized} " \
1123
+ "WHERE message::jsonb ->> 'job_id' = $1 LIMIT 1",
1124
+ [job_id]
1125
+ )
1126
+ result.ntuples.positive?
1127
+ end
1128
+
953
1129
  # Detect "relation does not exist" via the underlying PG error type.
954
1130
  # Falls back to message matching only if PG::UndefinedTable is undefined
955
1131
  # (very old pg gem) — never relies on locale-sensitive text.
@@ -28,12 +28,25 @@ module Pgbus
28
28
  # otherwise. This avoids losing a blocked row if enqueue fails.
29
29
  def promote_next(concurrency_key, client:, delay: 0)
30
30
  released = nil
31
+ msg_id = nil
31
32
  Pgbus::BlockedExecution.transaction do
32
33
  released = release_next(concurrency_key)
33
34
  raise ActiveRecord::Rollback unless released
34
35
 
35
36
  actual_delay = resolve_delay(released[:payload], delay)
36
- client.send_message(released[:queue_name], released[:payload], delay: actual_delay)
37
+ # Carry the enqueuer's priority through: under priority routing it
38
+ # picks the _pN sub-queue, not just the release order (issue #423).
39
+ msg_id = client.send_message(released[:queue_name], released[:payload],
40
+ delay: actual_delay, priority: released[:priority])
41
+ end
42
+
43
+ if released && msg_id
44
+ begin
45
+ Batch.backfill_execution(released[:payload], msg_id,
46
+ client.target_queue(released[:queue_name], released[:priority]))
47
+ rescue StandardError => e
48
+ Pgbus.logger.warn { "[Pgbus] Batch execution backfill failed after promote: #{e.message}" }
49
+ end
37
50
  end
38
51
 
39
52
  !!released
@@ -75,6 +75,12 @@ module Pgbus
75
75
  # Pgbus::Process::Dispatcher.
76
76
  attr_reader :archive_retention
77
77
 
78
+ # Batch execution-row sweep + finished-batch cleanup. Retention nil disables
79
+ # cleanup (same sentinel as archive_retention). Sweep interval is required.
80
+ # Stall threshold: how long a pending batch may go without a new execution
81
+ # row (or an orphan row without a msg_id) before the sweep repairs it.
82
+ attr_reader :batch_retention, :batch_sweep_interval, :batch_stall_threshold
83
+
78
84
  # Transactional outbox
79
85
  attr_accessor :outbox_enabled, :outbox_poll_interval, :outbox_batch_size
80
86
  attr_reader :outbox_retention # rubocop:disable Style/AccessorGrouping
@@ -264,6 +270,9 @@ module Pgbus
264
270
  @group_mode = nil
265
271
 
266
272
  @archive_retention = 7 * 24 * 3600 # 7 days
273
+ @batch_retention = 7 * 24 * 3600 # 7 days
274
+ @batch_sweep_interval = 300 # 5 minutes
275
+ @batch_stall_threshold = 300 # 5 minutes, solid_queue's stalled_for default
267
276
 
268
277
  @outbox_enabled = false
269
278
  @outbox_poll_interval = 1.0
@@ -818,7 +827,9 @@ module Pgbus
818
827
 
819
828
  # Interval knobs: positive Numeric, never nil (mirror polling_interval).
820
829
  { dispatch_interval: dispatch_interval, outbox_poll_interval: outbox_poll_interval,
821
- recurring_schedule_interval: recurring_schedule_interval }.each do |name, value|
830
+ recurring_schedule_interval: recurring_schedule_interval,
831
+ batch_sweep_interval: batch_sweep_interval,
832
+ batch_stall_threshold: batch_stall_threshold }.each do |name, value|
822
833
  raise Pgbus::ConfigurationError, "#{name} must be > 0" unless value.is_a?(Numeric) && value.positive?
823
834
  end
824
835
 
@@ -1170,6 +1181,18 @@ module Pgbus
1170
1181
  @archive_retention = coerce_duration!(value, :archive_retention)
1171
1182
  end
1172
1183
 
1184
+ def batch_retention=(value)
1185
+ @batch_retention = coerce_duration!(value, :batch_retention)
1186
+ end
1187
+
1188
+ def batch_sweep_interval=(value)
1189
+ @batch_sweep_interval = coerce_duration!(value, :batch_sweep_interval)
1190
+ end
1191
+
1192
+ def batch_stall_threshold=(value)
1193
+ @batch_stall_threshold = coerce_duration!(value, :batch_stall_threshold)
1194
+ end
1195
+
1173
1196
  def outbox_retention=(value)
1174
1197
  @outbox_retention = coerce_duration!(value, :outbox_retention)
1175
1198
  end
data/lib/pgbus/engine.rb CHANGED
@@ -74,6 +74,7 @@ module Pgbus
74
74
  ActiveSupport.on_load(:active_job) do
75
75
  include Pgbus::Concurrency
76
76
  include Pgbus::Uniqueness
77
+ include Pgbus::ActiveJob::BatchId
77
78
  end
78
79
  end
79
80
 
@@ -78,6 +78,8 @@ module Pgbus
78
78
  add_recurring: "pgbus:add_recurring",
79
79
  add_failed_events_index: "pgbus:add_failed_events_index",
80
80
  add_processed_event_completion: "pgbus:add_processed_event_completion",
81
+ add_batch_executions: "pgbus:add_batch_executions",
82
+ add_batch_callback_jobs: "pgbus:add_batch_callback_jobs",
81
83
  tune_autovacuum: "pgbus:tune_autovacuum",
82
84
  tune_fillfactor: "pgbus:tune_fillfactor"
83
85
  }.freeze
@@ -97,6 +99,8 @@ module Pgbus
97
99
  add_recurring: "recurring tasks + executions tables",
98
100
  add_failed_events_index: "unique index on pgbus_failed_events (queue_name, msg_id)",
99
101
  add_processed_event_completion: "completed_at on pgbus_processed_events (two-phase idempotency claim)",
102
+ add_batch_executions: "batch execution-row tracking (self-healing completion, on_failure rename)",
103
+ add_batch_callback_jobs: "batch callback-job columns (configured ActiveJob instances as batch callbacks)",
100
104
  tune_autovacuum: "autovacuum tuning for PGMQ queue and archive tables",
101
105
  tune_fillfactor: "fillfactor=70 on PGMQ queue tables (reduces page density during update churn)"
102
106
  }.freeze
@@ -123,6 +127,8 @@ module Pgbus
123
127
  *recurring_migrations,
124
128
  *failed_events_index_migrations,
125
129
  *processed_event_completion_migrations,
130
+ *batch_executions_migrations,
131
+ *batch_callback_jobs_migrations,
126
132
  *autovacuum_migrations,
127
133
  *fillfactor_migrations
128
134
  ]
@@ -223,6 +229,30 @@ module Pgbus
223
229
  [:add_processed_event_completion]
224
230
  end
225
231
 
232
+ # Execution-row tracking plus the on_discard → on_failure column rename
233
+ # ship as one upgrade. Either a missing executions table or leftover
234
+ # pre-rename columns means this generator still needs to run.
235
+ def batch_executions_migrations
236
+ return [] unless table_exists?("pgbus_batches")
237
+
238
+ batches_columns = column_names("pgbus_batches")
239
+ migrated = table_exists?("pgbus_batch_executions") &&
240
+ batches_columns.include?("on_failure_class") &&
241
+ !batches_columns.include?("discarded_jobs")
242
+ return [] if migrated
243
+
244
+ [:add_batch_executions]
245
+ end
246
+
247
+ # Configured callback instances (on_finish: MyJob.new.set(...)) need
248
+ # somewhere to store the serialized job — issue #415.
249
+ def batch_callback_jobs_migrations
250
+ return [] unless table_exists?("pgbus_batches")
251
+ return [] if column_names("pgbus_batches").include?("on_finish_job")
252
+
253
+ [:add_batch_callback_jobs]
254
+ end
255
+
226
256
  # Autovacuum tuning: check if any PGMQ queue table already has
227
257
  # custom autovacuum settings applied. If not, queue the migration.
228
258
  def autovacuum_migrations
@@ -25,6 +25,11 @@ module Pgbus
25
25
  # pgbus.consumer.recycle — consumer hit a recycle threshold (same payload shape as pgbus.worker.recycle)
26
26
  # pgbus.serializer.serialize — job/event serialization
27
27
  # pgbus.serializer.deserialize — job/event deserialization
28
+ # pgbus.batch_finished — batch flipped to finished
29
+ # payload: batch_id, total_jobs, completed_jobs, failed_jobs
30
+ # pgbus.batch_sweep — dispatcher stalled-batch sweep
31
+ # payload: stalled_for, stale_executions, orphan_rows,
32
+ # started_batches, finished_batches
28
33
  #
29
34
  module Instrumentation
30
35
  module_function
@@ -102,12 +102,12 @@ module Pgbus
102
102
  return 0 if entries.empty?
103
103
 
104
104
  succeeded = 0
105
- entries.group_by { |e| [e.queue_name, e.priority, e.delay || 0] }.each do |(queue, _priority, delay), group|
105
+ entries.group_by { |e| [e.queue_name, e.priority, e.delay || 0] }.each do |(queue, priority, delay), group|
106
106
  payloads = group.map(&:payload)
107
107
  headers = group.map(&:headers)
108
108
  headers = nil if headers.all?(&:blank?)
109
109
 
110
- Pgbus.client.send_batch(queue, payloads, headers: headers, delay: delay)
110
+ Pgbus.client.send_batch(queue, payloads, headers: headers, delay: delay, priority: priority)
111
111
  now = Time.current
112
112
  group.each { |e| e.update!(published_at: now) }
113
113
  succeeded += group.size
@@ -119,16 +119,15 @@ module Pgbus
119
119
  succeeded
120
120
  end
121
121
 
122
- # Fallback for individual publishing when a batch fails.
123
- # Intentionally omits priority to match send_batch routing behavior
124
- # (base queue only), ensuring consistent queue placement regardless
125
- # of whether the batch or fallback path is used.
122
+ # Fallback for individual publishing when a batch fails. Passes the same
123
+ # priority as the batch path so queue placement is identical either way.
126
124
  def publish_single_queue(entry)
127
125
  Pgbus.client.send_message(
128
126
  entry.queue_name,
129
127
  entry.payload,
130
128
  headers: entry.headers,
131
- delay: entry.delay || 0
129
+ delay: entry.delay || 0,
130
+ priority: entry.priority
132
131
  )
133
132
  entry.update!(published_at: Time.current)
134
133
  true
@@ -51,6 +51,7 @@ module Pgbus
51
51
  # Enumerated so set_maintenance_timestamp can validate its argument.
52
52
  MAINTENANCE_TIMESTAMPS = %i[
53
53
  @last_cleanup_at @last_reap_at @last_concurrency_at @last_batch_cleanup_at
54
+ @last_batch_sweep_at
54
55
  @last_recurring_cleanup_at @last_archive_compaction_at @last_stream_archive_compaction_at
55
56
  @last_outbox_cleanup_at @last_job_lock_cleanup_at @last_stats_cleanup_at
56
57
  @last_orphan_stream_sweep_at @last_table_maintenance_at
@@ -107,7 +108,7 @@ module Pgbus
107
108
 
108
109
  # Test seam: set one of the @last_*_at maintenance timestamps so a task
109
110
  # becomes (or stops being) due on the next run_maintenance. The timestamps
110
- # are per-task monotonic clocks with no constructor injection point (all 12
111
+ # are per-task monotonic clocks with no constructor injection point (all
111
112
  # default to monotonic_now at construction), so a post-construction setter
112
113
  # is the minimal way to drive the due/not-due logic deterministically.
113
114
  # ivar must be one of the recognized @last_*_at names.
@@ -233,6 +234,7 @@ module Pgbus
233
234
  results << run_if_due(now, :@last_reap_at, REAP_INTERVAL) { reap_stale_processes }
234
235
  results << run_if_due(now, :@last_concurrency_at, CONCURRENCY_INTERVAL) { cleanup_concurrency }
235
236
  results << run_if_due(now, :@last_batch_cleanup_at, BATCH_CLEANUP_INTERVAL) { cleanup_batches }
237
+ results << run_if_due(now, :@last_batch_sweep_at, config.batch_sweep_interval) { sweep_stalled_batches }
236
238
  results << run_if_due(now, :@last_recurring_cleanup_at, RECURRING_CLEANUP_INTERVAL) { cleanup_recurring_executions }
237
239
  results << run_if_due(now, :@last_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { compact_archives }
238
240
  results << run_if_due(now, :@last_stream_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { prune_stream_archives }
@@ -373,13 +375,23 @@ module Pgbus
373
375
  end
374
376
 
375
377
  def cleanup_batches
376
- deleted = Batch.cleanup(older_than: Time.current - (7 * 24 * 3600)) # 7 days
378
+ retention = config.batch_retention
379
+ return unless retention&.positive?
380
+
381
+ deleted = Batch.cleanup(older_than: Time.current - retention)
377
382
  Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} finished batches" } if deleted.positive?
378
383
  rescue StandardError => e
379
384
  log_maintenance_failure("Batch cleanup", e)
380
385
  e
381
386
  end
382
387
 
388
+ def sweep_stalled_batches
389
+ Batch.sweep_stalled
390
+ rescue StandardError => e
391
+ log_maintenance_failure("Batch sweep", e)
392
+ e
393
+ end
394
+
383
395
  def cleanup_stats
384
396
  retention = config.stats_retention
385
397
  return unless retention&.positive?
@@ -446,34 +458,46 @@ module Pgbus
446
458
  # younger could be a freshly-acquired lock whose send_message hasn't
447
459
  # committed yet.
448
460
  threshold = Time.current - (config.visibility_timeout * 2)
461
+ candidates = keys.select { |key| key.created_at && key.created_at < threshold && key.queue_name }
462
+ return 0 if candidates.empty?
449
463
 
450
- orphaned = keys.select do |key|
451
- next false unless key.created_at && key.created_at < threshold
452
- next false unless key.queue_name
453
-
454
- message_gone?(key)
455
- end
464
+ bound, unbound = candidates.partition { |key| bound_lock?(key) }
465
+ orphaned = bound.select { |key| message_gone?(key) }
466
+ orphaned.concat(gone_unbound_locks(unbound))
456
467
 
457
468
  return 0 if orphaned.empty?
458
469
 
459
470
  UniquenessKey.where(lock_key: orphaned.map(&:lock_key)).delete_all
460
471
  end
461
472
 
462
- # Returns true if the message referenced by this lock is definitely gone
463
- # from the queue. Returns false otherwise (message present, or unknown).
473
+ def bound_lock?(key)
474
+ !Uniqueness.placeholder?(queue_name: key.queue_name, msg_id: key.msg_id)
475
+ end
476
+
477
+ # Unbound rows (synthetic "pending" queue and/or msg_id=0) must not probe
478
+ # pgmq.q_<prefix>_pending — that table does not exist, message_exists?
479
+ # returns nil, and the reaper would keep the lock forever (issue #418).
480
+ # Scan every live queue for the payload key instead.
481
+ def gone_unbound_locks(unbound)
482
+ return [] if unbound.empty?
483
+
484
+ present = Pgbus.client.uniqueness_keys_present(unbound.map(&:lock_key))
485
+ unbound.reject { |key| present.include?(key.lock_key) }
486
+ end
487
+
488
+ # Returns true if the message referenced by this bound lock is definitely
489
+ # gone from the queue. Returns false otherwise (message present, or unknown).
464
490
  #
465
491
  # Routes through Pgbus::Client#message_exists? so all PGMQ access stays
466
492
  # behind the client interface. The client returns nil when it can't
467
493
  # determine the answer (queue table missing, etc.); we treat that as
468
494
  # "still here" — the reaper must NEVER delete a lock when in doubt.
469
495
  def message_gone?(key)
470
- msg_id = key.msg_id.to_i
471
- result = if msg_id.positive?
472
- Pgbus.client.message_exists?(key.queue_name, msg_id: msg_id)
473
- else
474
- Pgbus.client.message_exists?(key.queue_name, uniqueness_key: key.lock_key)
475
- end
476
-
496
+ result = Pgbus.client.message_exists?(
497
+ key.queue_name,
498
+ msg_id: key.msg_id.to_i,
499
+ uniqueness_key: key.lock_key
500
+ )
477
501
  result == false
478
502
  rescue StandardError => e
479
503
  Pgbus.logger.warn { "[Pgbus] Reap check failed for #{key.lock_key}: #{e.message}" }
@@ -29,7 +29,8 @@ module Pgbus
29
29
  payload = inject_uniqueness_metadata(task, payload)
30
30
 
31
31
  Pgbus.client.ensure_queue(queue)
32
- Pgbus.client.send_message(queue, payload, headers: headers)
32
+ msg_id = Pgbus.client.send_message(queue, payload, headers: headers)
33
+ bind_uniqueness_lock(acquired_key, queue, msg_id)
33
34
 
34
35
  Pgbus.logger.info do
35
36
  "[Pgbus] Enqueued recurring task #{task.key} (#{task.class_name || task.command}) " \
@@ -159,6 +160,16 @@ module Pgbus
159
160
  :already_locked # Fail closed — skip enqueue when lock check errors
160
161
  end
161
162
 
163
+ # Point the pre-produce lock at the real PGMQ msg_id. Fail-soft: a bind
164
+ # error must not roll back an already-produced message (issue #418).
165
+ def bind_uniqueness_lock(key, queue, msg_id)
166
+ return unless key.is_a?(String)
167
+
168
+ Uniqueness.bind_lock(key, queue_name: queue, msg_id: msg_id)
169
+ rescue StandardError => e
170
+ Pgbus.logger.warn { "[Pgbus] Uniqueness bind failed: #{e.message}" }
171
+ end
172
+
162
173
  # Release a uniqueness lock. Safe to call with nil or :already_locked.
163
174
  def release_uniqueness_lock(key)
164
175
  return if key.nil? || key == :already_locked
@@ -10,9 +10,10 @@ module Pgbus
10
10
  # at any time — from enqueue through completion.
11
11
  #
12
12
  # Lock lifecycle (advisory lock + thin lookup table):
13
- # 1. Enqueue: pg_advisory_xact_lock serializes concurrent attempts,
14
- # then INSERT INTO pgbus_uniqueness_keys ON CONFLICT DO NOTHING.
15
- # The lock row lives as long as the job is in the queue or executing.
13
+ # 1. Enqueue: INSERT INTO pgbus_uniqueness_keys ON CONFLICT DO NOTHING
14
+ # (logical queue, msg_id=0). After send_message, bind! writes the
15
+ # real msg_id. The lock row lives as long as the job is in the queue
16
+ # or executing.
16
17
  # 2. Execution: PGMQ's visibility timeout is the execution lock —
17
18
  # no separate claim_for_execution step needed.
18
19
  # 3. Completion/DLQ: DELETE FROM pgbus_uniqueness_keys WHERE lock_key = ?.
@@ -43,6 +44,10 @@ module Pgbus
43
44
 
44
45
  METADATA_KEY = "pgbus_uniqueness_key"
45
46
  STRATEGY_KEY = "pgbus_uniqueness_strategy"
47
+ # Synthetic queue stored before produce when the caller does not know the
48
+ # real queue yet. Never a live PGMQ queue — the reaper must not probe
49
+ # pgmq.q_<prefix>_pending for these rows (issue #418).
50
+ PLACEHOLDER_QUEUE = "pending"
46
51
 
47
52
  VALID_STRATEGIES = %i[until_executed while_executing].freeze
48
53
  VALID_CONFLICTS = %i[reject discard log].freeze
@@ -174,19 +179,25 @@ module Pgbus
174
179
  UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: msg_id)
175
180
  else
176
181
  # Pre-produce check: use advisory lock + ON CONFLICT
177
- UniquenessKey.acquire!(key, queue_name: queue_name || "pending", msg_id: msg_id || 0)
182
+ UniquenessKey.acquire!(key, queue_name: queue_name || PLACEHOLDER_QUEUE, msg_id: msg_id || 0)
178
183
  end
179
184
  acquired ? :acquired : :locked
180
185
  end
181
186
 
182
187
  # Acquire the uniqueness lock at execution time (:while_executing only).
183
188
  # Returns true if acquired, false if another instance is running.
184
- def acquire_execution_lock(key, payload)
189
+ #
190
+ # Bound to the message being executed so a row left behind by a crashed
191
+ # attempt of the SAME message is re-acquired on retry instead of locking
192
+ # that message out until it dead-letters (issue #423). Callers without a
193
+ # message (legacy signature) fall back to the unbound placeholder.
194
+ def acquire_execution_lock(key, payload, msg_id: 0, queue_name: nil)
185
195
  strategy = extract_strategy(payload)
186
196
  return true unless strategy == :while_executing
187
197
 
188
- queue_name = payload["queue_name"] || "unknown"
189
- UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: 0)
198
+ queue_name ||= payload["queue_name"] || "unknown"
199
+ UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: msg_id.to_i,
200
+ reacquire_same_message: msg_id.to_i.positive?)
190
201
  end
191
202
 
192
203
  # Release the uniqueness lock after execution completes.
@@ -195,6 +206,23 @@ module Pgbus
195
206
 
196
207
  UniquenessKey.release!(key)
197
208
  end
209
+
210
+ # Point a pre-produce lock at the real queue + PGMQ msg_id after send.
211
+ # No-op when key is nil or msg_id is not a positive integer (the reaper
212
+ # treats those rows as unbound and scans live queues instead).
213
+ def bind_lock(key, queue_name:, msg_id:)
214
+ return unless key
215
+ return unless msg_id.to_i.positive?
216
+
217
+ UniquenessKey.bind!(key, queue_name: queue_name, msg_id: msg_id)
218
+ end
219
+
220
+ # True when the uniqueness row is not yet bound to a real PGMQ message.
221
+ # Covers the synthetic pending queue and any msg_id=0 placeholder
222
+ # (recurring scheduler, bind-not-yet-run, bind failure).
223
+ def placeholder?(queue_name:, msg_id:)
224
+ msg_id.to_i <= 0 || queue_name.to_s == PLACEHOLDER_QUEUE
225
+ end
198
226
  end
199
227
  end
200
228
  end