pgbus 0.21.4 → 0.23.0

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/README.md +37 -15
  4. data/Rakefile +15 -2
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +17 -3
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +4 -4
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -1
  8. data/app/controllers/pgbus/dead_letter_controller.rb +3 -1
  9. data/app/controllers/pgbus/jobs_controller.rb +2 -1
  10. data/app/models/pgbus/blocked_execution.rb +3 -2
  11. data/app/models/pgbus/queue_state.rb +6 -0
  12. data/app/models/pgbus/uniqueness_key.rb +11 -0
  13. data/config/locales/da.yml +1 -0
  14. data/config/locales/de.yml +1 -0
  15. data/config/locales/en.yml +1 -0
  16. data/config/locales/es.yml +1 -0
  17. data/config/locales/fi.yml +1 -0
  18. data/config/locales/fr.yml +1 -0
  19. data/config/locales/it.yml +1 -0
  20. data/config/locales/ja.yml +1 -0
  21. data/config/locales/nb.yml +1 -0
  22. data/config/locales/nl.yml +1 -0
  23. data/config/locales/pt.yml +1 -0
  24. data/config/locales/sv.yml +1 -0
  25. data/lib/generators/pgbus/fold_queue_name_case_generator.rb +57 -0
  26. data/lib/generators/pgbus/templates/fold_queue_name_case.rb.erb +12 -0
  27. data/lib/generators/pgbus/templates/tune_autovacuum.rb.erb +2 -2
  28. data/lib/generators/pgbus/templates/tune_fillfactor.rb.erb +1 -1
  29. data/lib/pgbus/active_job/adapter.rb +3 -2
  30. data/lib/pgbus/active_job/executor.rb +197 -46
  31. data/lib/pgbus/active_job/quiet_logs.rb +57 -0
  32. data/lib/pgbus/autovacuum_tuning.rb +2 -2
  33. data/lib/pgbus/batch/sweep.rb +2 -0
  34. data/lib/pgbus/batch.rb +4 -1
  35. data/lib/pgbus/cli/dlq.rb +2 -1
  36. data/lib/pgbus/client/async_commit.rb +149 -0
  37. data/lib/pgbus/client/native_statements.rb +432 -0
  38. data/lib/pgbus/client.rb +156 -20
  39. data/lib/pgbus/concurrency/blocked_execution.rb +10 -1
  40. data/lib/pgbus/configuration.rb +158 -6
  41. data/lib/pgbus/dead_letter_header.rb +3 -0
  42. data/lib/pgbus/doctor.rb +55 -2
  43. data/lib/pgbus/event_bus/handler.rb +2 -0
  44. data/lib/pgbus/failed_event_recorder.rb +5 -2
  45. data/lib/pgbus/generators/migration_detector.rb +29 -2
  46. data/lib/pgbus/instrumentation.rb +16 -1
  47. data/lib/pgbus/metrics/subscriber.rb +27 -1
  48. data/lib/pgbus/process/completer.rb +189 -0
  49. data/lib/pgbus/process/consumer.rb +15 -1
  50. data/lib/pgbus/process/worker.rb +57 -4
  51. data/lib/pgbus/queue_name_fold.rb +145 -0
  52. data/lib/pgbus/queue_name_validator.rb +20 -2
  53. data/lib/pgbus/recurring/schedule.rb +22 -13
  54. data/lib/pgbus/serializer.rb +1 -0
  55. data/lib/pgbus/stat_buffer.rb +67 -26
  56. data/lib/pgbus/streams/cursor.rb +17 -6
  57. data/lib/pgbus/streams/envelope.rb +16 -0
  58. data/lib/pgbus/streams/key.rb +1 -1
  59. data/lib/pgbus/table_maintenance.rb +1 -1
  60. data/lib/pgbus/uniqueness.rb +43 -16
  61. data/lib/pgbus/version.rb +1 -1
  62. data/lib/pgbus/visibility_heartbeat.rb +16 -12
  63. data/lib/pgbus/web/data_source/dead_letter.rb +14 -5
  64. data/lib/pgbus/web/data_source/resend.rb +185 -0
  65. data/lib/pgbus/web/data_source/resend_slot.rb +139 -0
  66. data/lib/pgbus/web/data_source.rb +55 -37
  67. data/lib/pgbus/web/dead_letter_reason.rb +6 -0
  68. data/lib/pgbus/web/streamer/connection.rb +36 -29
  69. data/lib/pgbus/web/streamer/falcon_connection.rb +29 -25
  70. data/lib/pgbus/web/streamer/heartbeat.rb +14 -3
  71. data/lib/pgbus/web/streamer/instance.rb +8 -5
  72. data/lib/pgbus/web/streamer/listener.rb +239 -62
  73. data/lib/pgbus/web/streamer/outbound_pump.rb +6 -4
  74. data/lib/pgbus/web/streamer/registry.rb +55 -29
  75. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +73 -12
  76. data/lib/tasks/pgbus_queues.rake +27 -0
  77. data/sig/pgbus/active_job/adapter.rbs +1 -1
  78. data/sig/pgbus/active_job/quiet_logs.rbs +11 -0
  79. data/sig/pgbus/client.rbs +7 -5
  80. data/sig/pgbus/configuration.rbs +12 -0
  81. data/sig/pgbus/process/completer.rbs +26 -0
  82. metadata +14 -3
@@ -4,6 +4,12 @@ module Pgbus
4
4
  class QueueState < BusRecord
5
5
  self.table_name = "pgbus_queue_states"
6
6
 
7
+ # One row per queue whatever the spelling (issue #633): the dashboard
8
+ # names a queue from its pgmq.meta row, which PGMQ's lower-case table
9
+ # names make lower case, while a worker names it as configured
10
+ # ("Critical"). Applies to writes and to find_by/where.
11
+ normalizes :queue_name, with: ->(name) { name.to_s.downcase }
12
+
7
13
  scope :paused, -> { where(paused: true) }
8
14
 
9
15
  def self.paused?(queue_name)
@@ -191,6 +191,17 @@ module Pgbus
191
191
  )
192
192
  end
193
193
 
194
+ # True when the key's row is bound to this message (queue + msg_id): its
195
+ # own attempt holds it, as a crashed attempt leaves it. The executor asks
196
+ # this when it dead-letters a :while_executing message that never failed
197
+ # (issue #629): a crash loop, not a uniqueness conflict.
198
+ def self.bound_to?(lock_key, queue_name:, msg_id:)
199
+ !connection.select_value(
200
+ "SELECT 1 FROM #{table_name} WHERE lock_key = $1 AND queue_name = $2 AND msg_id = $3 LIMIT 1",
201
+ "UniquenessKey Bound To", [lock_key, queue_name.to_s, msg_id.to_i]
202
+ ).nil?
203
+ end
204
+
194
205
  # Check if a key is currently locked.
195
206
  def self.locked?(lock_key)
196
207
  result = connection.select_value(
@@ -224,6 +224,7 @@ da:
224
224
  retried_before:
225
225
  one: Genforsøgt fra DLQ én gang før.
226
226
  other: Genforsøgt fra DLQ %{count} gange før.
227
+ uniqueness_conflict_html: Sprunget over ved alle %{attempts} læsninger (maks. %{max}), mens en anden kopi af jobbet holdt dets unikhedsnøgle — intet forsøg kørte
227
228
  show:
228
229
  back: Tilbage til DLQ
229
230
  context: Kontekst
@@ -224,6 +224,7 @@ de:
224
224
  retried_before:
225
225
  one: Schon einmal aus der DLQ wiederholt.
226
226
  other: Schon %{count}-mal aus der DLQ wiederholt.
227
+ uniqueness_conflict_html: Bei allen %{attempts} Lesevorgängen übersprungen (max. %{max}), weil eine andere Kopie des Jobs seinen Eindeutigkeitsschlüssel hielt — kein Versuch lief
227
228
  show:
228
229
  back: Zurück zur DLQ
229
230
  context: Kontext
@@ -224,6 +224,7 @@ en:
224
224
  retried_before:
225
225
  one: Retried from the DLQ once before.
226
226
  other: Retried from the DLQ %{count} times before.
227
+ uniqueness_conflict_html: Skipped on all %{attempts} reads (max %{max}) while another copy of the job held its uniqueness key — no attempt ran
227
228
  show:
228
229
  back: Back to DLQ
229
230
  context: Context
@@ -224,6 +224,7 @@ es:
224
224
  retried_before:
225
225
  one: Reintentado desde la DLQ una vez antes.
226
226
  other: Reintentado desde la DLQ %{count} veces antes.
227
+ uniqueness_conflict_html: Omitido en las %{attempts} lecturas (máx. %{max}) mientras otra copia del job tenía su clave de unicidad — no se ejecutó ningún intento
227
228
  show:
228
229
  back: Volver a DLQ
229
230
  context: Contexto
@@ -224,6 +224,7 @@ fi:
224
224
  retried_before:
225
225
  one: Yritetty uudelleen DLQ:sta kerran aiemmin.
226
226
  other: Yritetty uudelleen DLQ:sta %{count} kertaa aiemmin.
227
+ uniqueness_conflict_html: Ohitettu kaikilla %{attempts} lukukerralla (enint. %{max}), koska työn toinen kopio piti sen yksilöllisyysavainta — yhtään yritystä ei ajettu
227
228
  show:
228
229
  back: Takaisin DLQ:hon
229
230
  context: Konteksti
@@ -224,6 +224,7 @@ fr:
224
224
  retried_before:
225
225
  one: Déjà relancé depuis la DLQ une fois.
226
226
  other: Déjà relancé depuis la DLQ %{count} fois.
227
+ uniqueness_conflict_html: Ignoré à chacune des %{attempts} lectures (max %{max}) car une autre copie du job détenait sa clé d’unicité — aucune tentative n’a été exécutée
227
228
  show:
228
229
  back: Retour à la DLQ
229
230
  context: Contexte
@@ -224,6 +224,7 @@ it:
224
224
  retried_before:
225
225
  one: Già riprovato dalla DLQ una volta.
226
226
  other: Già riprovato dalla DLQ %{count} volte.
227
+ uniqueness_conflict_html: Saltato in tutte le %{attempts} letture (max %{max}) mentre un’altra copia del job deteneva la sua chiave di unicità — nessun tentativo eseguito
227
228
  show:
228
229
  back: Torna a DLQ
229
230
  context: Contesto
@@ -224,6 +224,7 @@ ja:
224
224
  retried_before:
225
225
  one: 以前に DLQ から 1 回リトライされました。
226
226
  other: 以前に DLQ から %{count} 回リトライされました。
227
+ uniqueness_conflict_html: 別のコピーが一意性キーを保持していたため、%{attempts} 回の読み取りすべてでスキップされました(上限 %{max} 回) — 試行は一度も実行されていません
227
228
  show:
228
229
  back: DLQに戻る
229
230
  context: コンテキスト
@@ -224,6 +224,7 @@ nb:
224
224
  retried_before:
225
225
  one: Forsøkt på nytt fra DLQ én gang før.
226
226
  other: Forsøkt på nytt fra DLQ %{count} ganger før.
227
+ uniqueness_conflict_html: Hoppet over ved alle %{attempts} lesinger (maks. %{max}) mens en annen kopi av jobben holdt unikhetsnøkkelen — ingen forsøk kjørte
227
228
  show:
228
229
  back: Tilbake til DLQ
229
230
  context: Kontekst
@@ -224,6 +224,7 @@ nl:
224
224
  retried_before:
225
225
  one: Al eens eerder vanuit de DLQ opnieuw geprobeerd.
226
226
  other: Al %{count} keer eerder vanuit de DLQ opnieuw geprobeerd.
227
+ uniqueness_conflict_html: Bij alle %{attempts} keer lezen overgeslagen (max %{max}) omdat een andere kopie van de job de uniciteitssleutel had — geen poging uitgevoerd
227
228
  show:
228
229
  back: Terug naar DLQ
229
230
  context: Context
@@ -224,6 +224,7 @@ pt:
224
224
  retried_before:
225
225
  one: Já reenviado da DLQ uma vez.
226
226
  other: Já reenviado da DLQ %{count} vezes.
227
+ uniqueness_conflict_html: Ignorado em todas as %{attempts} leituras (máx. %{max}) enquanto outra cópia do job detinha sua chave de unicidade — nenhuma tentativa foi executada
227
228
  show:
228
229
  back: Voltar para DLQ
229
230
  context: Contexto
@@ -224,6 +224,7 @@ sv:
224
224
  retried_before:
225
225
  one: Omförsökt från DLQ en gång tidigare.
226
226
  other: Omförsökt från DLQ %{count} gånger tidigare.
227
+ uniqueness_conflict_html: Hoppades över vid alla %{attempts} läsningar (max %{max}) medan en annan kopia av jobbet höll dess unikhetsnyckel — inget försök kördes
227
228
  show:
228
229
  back: Tillbaka till DLQ
229
230
  context: Kontext
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+ require_relative "migration_path"
6
+
7
+ module Pgbus
8
+ module Generators
9
+ # Issue #633: PGMQ lower-cases its table names, so a queue pgbus created
10
+ # as "pgbus_Orders" before 0.23.0 lives in pgmq.q_pgbus_orders while
11
+ # pgmq.meta records the mixed-case spelling. pgbus now spells every queue
12
+ # in lower case; this migration brings an install's existing rows to that
13
+ # spelling. No table is dropped, renamed or copied.
14
+ class FoldQueueNameCaseGenerator < Rails::Generators::Base
15
+ include ActiveRecord::Generators::Migration
16
+ include MigrationPath
17
+
18
+ source_root File.expand_path("templates", __dir__)
19
+
20
+ desc "Spell existing PGMQ queues (pgmq.meta, NOTIFY triggers, pgbus's queue-name columns) in lower case, " \
21
+ "the way their tables are"
22
+
23
+ class_option :database,
24
+ type: :string,
25
+ default: nil,
26
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
27
+
28
+ def create_migration_file
29
+ migration_template "fold_queue_name_case.rb.erb",
30
+ File.join(pgbus_migrate_path, "fold_pgbus_queue_name_case.rb")
31
+ end
32
+
33
+ def display_post_install
34
+ say ""
35
+ say "Pgbus queue-name fold migration created!", :green
36
+ say ""
37
+ say "PGMQ spells every queue table in lower case. This migration renames the"
38
+ say "pgmq.meta rows (and NOTIFY triggers) of queues created with upper-case"
39
+ say "letters to the lower-case spelling their tables already use. Messages stay"
40
+ say "where they are."
41
+ say ""
42
+ say "Next steps:"
43
+ say " 1. Deploy pgbus 0.23.0+ everywhere first: an older process recreates its mixed-case row"
44
+ say " 2. Run: rails db:migrate#{migrate_command_suffix}"
45
+ say " 3. Check: rake pgbus:doctor (\"Queue name case\" is ok); if it still names a queue,"
46
+ say " run: rake pgbus:queues:fold_case"
47
+ say ""
48
+ end
49
+
50
+ private
51
+
52
+ def migration_version
53
+ "[#{ActiveRecord::Migration.current_version}]"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,12 @@
1
+ class FoldPgbusQueueNameCase < ActiveRecord::Migration<%= migration_version %>
2
+ # Issue #633. PGMQ lower-cases its table names, so a queue created as
3
+ # "pgbus_Orders" lives in pgmq.q_pgbus_orders while pgmq.meta records
4
+ # "pgbus_Orders". Pgbus::QueueNameFold renames the old rows to the lower-case
5
+ # spelling pgbus now uses; it changes rows only, never a table.
6
+ def up
7
+ Pgbus::QueueNameFold.run!(connection)
8
+ end
9
+
10
+ # The original spellings are not kept: PGMQ never used them for a table.
11
+ def down; end
12
+ end
@@ -25,8 +25,8 @@ class TunePgbusAutovacuum < ActiveRecord::Migration<%= migration_version %>
25
25
  q RECORD;
26
26
  BEGIN
27
27
  FOR q IN SELECT queue_name FROM pgmq.meta LOOP
28
- EXECUTE format('ALTER TABLE pgmq.q_%I RESET (autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay, autovacuum_analyze_scale_factor)', q.queue_name);
29
- EXECUTE format('ALTER TABLE pgmq.a_%I RESET (autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay, autovacuum_analyze_scale_factor)', q.queue_name);
28
+ EXECUTE format('ALTER TABLE pgmq.%I RESET (autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay, autovacuum_analyze_scale_factor)', 'q_' || lower(q.queue_name));
29
+ EXECUTE format('ALTER TABLE pgmq.%I RESET (autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay, autovacuum_analyze_scale_factor)', 'a_' || lower(q.queue_name));
30
30
  END LOOP;
31
31
  END $$;
32
32
  SQL
@@ -22,7 +22,7 @@ class TunePgbusFillfactor < ActiveRecord::Migration<%= migration_version %>
22
22
  q RECORD;
23
23
  BEGIN
24
24
  FOR q IN SELECT queue_name FROM pgmq.meta LOOP
25
- EXECUTE format('ALTER TABLE pgmq.q_%I RESET (fillfactor)', q.queue_name);
25
+ EXECUTE format('ALTER TABLE pgmq.%I RESET (fillfactor)', 'q_' || lower(q.queue_name));
26
26
  END LOOP;
27
27
  END $$;
28
28
  SQL
@@ -215,7 +215,7 @@ module Pgbus
215
215
  return true
216
216
  when :discard
217
217
  Pgbus.logger.info { "[Pgbus] Discarding job #{active_job.class.name}: concurrency limit for #{key}" }
218
- # The job will never run: roll back an :until_executed uniqueness lock
218
+ # The job will never run: roll back an enqueue-time uniqueness lock
219
219
  # acquired earlier in this enqueue (no executor will release it), and
220
220
  # uncount it from its batch so completion is not waited on forever.
221
221
  rollback_acquired_uniqueness_lock
@@ -226,7 +226,8 @@ module Pgbus
226
226
  false
227
227
  end
228
228
 
229
- # Releases an :until_executed lock this enqueue acquired, if any.
229
+ # Releases an enqueue-time lock (:until_executed, :until_executing) this
230
+ # enqueue acquired, if any.
230
231
  # Used when the job is dropped before a message is sent (concurrency
231
232
  # :discard, or send_message raising) — otherwise the lock is orphaned
232
233
  # because no executor will ever release it.
@@ -1,16 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "time"
4
-
5
3
  module Pgbus
6
4
  module ActiveJob
7
5
  class Executor
8
6
  attr_reader :client, :config
9
7
 
10
- def initialize(client: Pgbus.client, config: Pgbus.configuration, stat_buffer: nil)
8
+ # completer: the worker's Process::Completer when config.completion_batching
9
+ # is on (issue #606): complete_from goes through it instead of one
10
+ # archive_message per job. config.completed_jobs (issue #607) is read
11
+ # once here: :delete finishes a successful job with delete_message.
12
+ def initialize(client: Pgbus.client, config: Pgbus.configuration, stat_buffer: nil, completer: nil)
11
13
  @client = client
12
14
  @config = config
13
15
  @stat_buffer = stat_buffer
16
+ @completer = completer
17
+ @delete_completed = config.completed_jobs == :delete
18
+ @completed_word = @delete_completed ? "deleted" : "archived"
14
19
  end
15
20
 
16
21
  # Exceptions we never want to swallow — let the process die/signal propagate.
@@ -30,12 +35,16 @@ module Pgbus
30
35
  # it: the DLQ copy carries it in its headers (issue #495).
31
36
  last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i,
32
37
  source_queue: physical_queue(queue_name, source_queue))
33
- handle_dead_letter(message, queue_name, payload, source_queue: source_queue, last_error: last_error)
38
+ # Before the release below: the lookup asks whether the key's row is
39
+ # still this message's own (a crashed attempt's), see dead_letter_reason.
40
+ reason = dead_letter_reason(message, queue_name, payload, last_error)
41
+ handle_dead_letter(message, queue_name, payload, source_queue: source_queue, last_error: last_error,
42
+ reason: reason)
34
43
  FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i,
35
44
  source_queue: physical_queue(queue_name, source_queue))
36
45
  signal_concurrency(payload)
37
46
  signal_batch_discarded(payload)
38
- release_uniqueness_lock(Uniqueness.extract_key(payload), queue_name, message)
47
+ release_uniqueness_lock(Uniqueness.extract_key(payload), queue_name, message) if holds_uniqueness_row?(payload)
39
48
  record_stat(payload, queue_name, "dead_lettered", execution_start, message: message)
40
49
  instrument(
41
50
  "pgbus.job_dead_lettered",
@@ -45,7 +54,8 @@ module Pgbus
45
54
  provider_job_id: payload["provider_job_id"],
46
55
  read_ct: read_count,
47
56
  msg_id: message.msg_id.to_i,
48
- error: last_error&.dig(:error_class)
57
+ error: last_error&.dig(:error_class),
58
+ reason: reason
49
59
  )
50
60
  Pgbus.logger.debug { "[Pgbus::Executor] dead_lettered #{log_tag(message, queue_name)} job_class=#{job_class}" }
51
61
  return :dead_lettered
@@ -61,17 +71,23 @@ module Pgbus
61
71
  # released on completion or DLQ.
62
72
  nil
63
73
  when :while_executing
64
- # Acquire the lock now, bound to this message. If another worker is
65
- # already executing this job, skip it — VT will expire and it'll be
66
- # retried. A row left by a crashed attempt of THIS message is
74
+ # Acquire the lock now, bound to this message. If another message
75
+ # holds it, the job's on_conflict decides (issue #629): :discard
76
+ # drops this copy, :reject and :log leave it for a backed-off
77
+ # redelivery. A row left by a crashed attempt of THIS message is
67
78
  # re-acquired, not treated as a duplicate.
68
79
  acquired = Uniqueness.acquire_execution_lock(
69
80
  uniqueness_key, payload, msg_id: message.msg_id.to_i, queue_name: queue_name
70
81
  )
71
- unless acquired
72
- Pgbus.logger.info { "[Pgbus] Skipping duplicate execution for #{job_class}" }
73
- return :skipped
74
- end
82
+ return handle_uniqueness_conflict(message, queue_name, payload, uniqueness_key, source_queue) unless acquired
83
+ when :until_executing
84
+ # A debounce (issue #628): the row guarded the queued copy, and
85
+ # goes before perform so an enqueue that arrives while this runs
86
+ # is accepted. release_held_by! takes only the row this message
87
+ # holds, never a newer copy's (a dashboard re-send holds its own,
88
+ # #631). A redelivery (row already gone) matches nothing and runs; a failed release is logged and the
89
+ # job runs anyway — the reaper or the DLQ release takes the row.
90
+ release_uniqueness_lock(uniqueness_key, queue_name, message) if holds_uniqueness_row?(payload)
75
91
  end
76
92
  end
77
93
 
@@ -117,16 +133,19 @@ module Pgbus
117
133
  # heartbeat lapsed and it was redelivered): that worker owns the
118
134
  # completion signals, and signalling again here would release the
119
135
  # concurrency slot a second time (rails/solid_queue#761).
120
- if archive_from(queue_name, msg_id, source_queue: source_queue) == :already_archived
136
+ if complete_from(queue_name, msg_id, source_queue: source_queue) == :already_archived
121
137
  Pgbus.logger.warn do
122
- "[Pgbus::Executor] already archived elsewhere, skipping signals #{log_tag(message, queue_name)} job_class=#{job_class}"
138
+ "[Pgbus::Executor] already #{@completed_word} elsewhere, skipping signals " \
139
+ "#{log_tag(message, queue_name)} job_class=#{job_class}"
123
140
  end
124
141
  release_duplicate_execution_lock(uniqueness_key, uniqueness_strategy, queue_name, msg_id)
125
142
  return :duplicate
126
143
  end
127
- Pgbus.logger.debug { "[Pgbus::Executor] archived #{log_tag(message, queue_name)} job_class=#{job_class}" }
144
+ Pgbus.logger.debug { "[Pgbus::Executor] #{@completed_word} #{log_tag(message, queue_name)} job_class=#{job_class}" }
128
145
  job_succeeded = true
129
- release_uniqueness_lock(uniqueness_key, queue_name, message)
146
+ # :until_executing released its row before perform; a newer copy's
147
+ # row is not this message's to take.
148
+ release_uniqueness_lock(uniqueness_key, queue_name, message) unless uniqueness_strategy == :until_executing
130
149
  # Only FailedEventRecorder.record! writes this table, and only after a
131
150
  # failed attempt, so a first delivery has no row: skip the per-job
132
151
  # DELETE round trip (issue #484). A stale row from a dropped-and-
@@ -147,7 +166,7 @@ module Pgbus
147
166
  rescue Exception => e # rubocop:disable Lint/RescueException
148
167
  # Widened from StandardError to catch Async::Stop / Async::Cancel
149
168
  # (both inherit from Exception, not StandardError) under execution_mode: :async.
150
- # Before this, a fiber interruption between perform_now and archive_from
169
+ # Before this, a fiber interruption between perform_now and complete_from
151
170
  # silently lost control flow — no failed event row, no job_failed
152
171
  # notification, uniqueness lock held until VT expired. See issue #126.
153
172
  handle_failure(message, queue_name, e, payload: payload, source_queue: source_queue)
@@ -196,7 +215,8 @@ module Pgbus
196
215
  # The table the message was read from: the _pN sub-queue under priority
197
216
  # routing, else the logical queue's own. Each table numbers its messages
198
217
  # from its own sequence, so a failed-event row is keyed on it (issue
199
- # #583). Built only on the failure and redelivery paths.
218
+ # #583). Built only on the failure and redelivery paths, and for the
219
+ # completer (issue #606), which groups by physical queue.
200
220
  def physical_queue(queue_name, source_queue)
201
221
  source_queue || config.queue_name(queue_name)
202
222
  end
@@ -221,6 +241,15 @@ module Pgbus
221
241
  Pgbus.logger.warn { "[Pgbus] Uniqueness release failed: #{e.message}" }
222
242
  end
223
243
 
244
+ # False for an :until_executing retry_on re-enqueue (executions > 0): it
245
+ # went out through the free key without acquiring a row (the attempt's
246
+ # was released at its start), so the unbound clause of release_held_by!
247
+ # could only match a newer copy's row — one parked behind its
248
+ # concurrency limit before this retry was sent (issue #628).
249
+ def holds_uniqueness_row?(payload)
250
+ Uniqueness.extract_strategy(payload) != :until_executing || payload["executions"].to_i.zero?
251
+ end
252
+
224
253
  # Keep the message invisible while perform runs (see VisibilityHeartbeat).
225
254
  # Wraps only the perform: the heartbeat must be gone before archive or
226
255
  # the retry backoff touches the same message's VT.
@@ -282,33 +311,43 @@ module Pgbus
282
311
  Pgbus.logger.debug { "[Pgbus] Stat recording failed: #{e.message}" }
283
312
  end
284
313
 
314
+ # pgmq-ruby sets no result type map, so enqueued_at is the TIMESTAMPTZ
315
+ # text PostgreSQL sends ("2026-10-29 10:11:12.123456+00", any offset).
316
+ # Time.new reads it in C at ~0.3 µs; Time.parse took ~7 µs a job and
317
+ # pg's TimestampWithTimeZone decoder ~3 µs (issue #604). Unreadable text
318
+ # raises ArgumentError: the stat is recorded without latency.
285
319
  def compute_enqueue_latency(message)
286
320
  return unless message
287
321
 
288
322
  enqueued_at = message.enqueued_at
289
323
  return unless enqueued_at
290
324
 
291
- # Fast path: numeric epoch (float seconds) avoids Time.parse entirely.
292
- # PGMQ returns enqueued_at as a Time or string depending on the driver.
293
- case enqueued_at
294
- when Numeric
295
- [((Time.now.to_f - enqueued_at) * 1000).round, 0].max
296
- when Time
297
- [((Time.now.utc - enqueued_at.utc) * 1000).round, 0].max
298
- else
299
- parse_enqueue_latency_from_string(enqueued_at.to_s)
300
- end
325
+ epoch = case enqueued_at
326
+ when Numeric then enqueued_at
327
+ when Time then enqueued_at.to_f
328
+ else enqueued_at_epoch(enqueued_at.to_s)
329
+ end
330
+ return unless epoch
331
+
332
+ [((::Process.clock_gettime(::Process::CLOCK_REALTIME) - epoch) * 1000).round, 0].max
301
333
  rescue ArgumentError, TypeError
302
334
  nil
303
335
  end
304
336
 
305
- def parse_enqueue_latency_from_string(str)
306
- # PGMQ enqueued_at is TIMESTAMPTZ (always UTC internally).
307
- # If the string lacks an explicit offset, assume UTC to avoid
308
- # misinterpretation when the system timezone is non-UTC.
309
- str = "#{str} UTC" unless str.match?(/[+-]\d{2}:?\d{2}\s*$|Z\s*$/i)
310
- enqueued_at = Time.parse(str)
311
- [((Time.now.utc - enqueued_at) * 1000).round, 0].max
337
+ # A trailing "+00", "-03", "+05:30", "+0530" or "Z".
338
+ UTC_OFFSET_SUFFIX = /(?:[+-]\d\d(?::?\d\d)?|Z)\z/
339
+ private_constant :UTC_OFFSET_SUFFIX
340
+
341
+ # Time.new reads a string without an offset in the host's zone, but
342
+ # TIMESTAMPTZ is UTC inside, so such a string gets a "Z". Checked here
343
+ # rather than with `in: "UTC"`: that keyword costs a Hash per job.
344
+ # Time.new also takes a bare year ("2026") as a time; a string without a
345
+ # time of day is not a timestamp, so it has no latency.
346
+ def enqueued_at_epoch(str)
347
+ return unless str.include?(":")
348
+
349
+ str = "#{str}Z" unless str.match?(UTC_OFFSET_SUFFIX)
350
+ Time.new(str).to_f
312
351
  end
313
352
 
314
353
  def handle_failure(message, queue_name, error, payload: nil, source_queue: nil)
@@ -351,13 +390,88 @@ module Pgbus
351
390
  RetryBackoff.compute_delay(attempt: attempt)
352
391
  end
353
392
 
393
+ set_message_visibility(message, queue_name, delay, source_queue)
394
+ rescue StandardError => e
395
+ Pgbus.logger.debug { "[Pgbus] Retry backoff VT update failed: #{e.message}" }
396
+ end
397
+
398
+ def set_message_visibility(message, queue_name, delay, source_queue)
354
399
  if source_queue
355
400
  client.set_visibility_timeout(source_queue, message.msg_id.to_i, vt: delay, prefixed: false)
356
401
  else
357
402
  client.set_visibility_timeout(queue_name, message.msg_id.to_i, vt: delay)
358
403
  end
359
- rescue StandardError => e
360
- Pgbus.logger.debug { "[Pgbus] Retry backoff VT update failed: #{e.message}" }
404
+ end
405
+
406
+ # A :while_executing message whose key another message holds (issue
407
+ # #629). The duplicate holds no uniqueness row, so nothing is released.
408
+ def handle_uniqueness_conflict(message, queue_name, payload, key, source_queue)
409
+ policy = Uniqueness.extract_on_conflict(payload)
410
+ if policy == :discard
411
+ discard_duplicate(message, queue_name, payload, key, source_queue)
412
+ else
413
+ skip_duplicate(message, queue_name, payload, key, source_queue, policy)
414
+ end
415
+ end
416
+
417
+ # :discard — the duplicate will never run: complete it, then the
418
+ # bookkeeping the dead-letter path does for such a message. Lost to
419
+ # another worker that completed it first: that worker owns the signals.
420
+ def discard_duplicate(message, queue_name, payload, key, source_queue)
421
+ msg_id = message.msg_id.to_i
422
+ if complete_from(queue_name, msg_id, source_queue: source_queue) == :already_archived
423
+ Pgbus.logger.warn do
424
+ "[Pgbus::Executor] already #{@completed_word} elsewhere, skipping signals " \
425
+ "#{log_tag(message, queue_name)} job_class=#{payload["job_class"]}"
426
+ end
427
+ return :duplicate
428
+ end
429
+ if message.read_ct.to_i > 1
430
+ FailedEventRecorder.clear!(queue_name: queue_name, msg_id: msg_id,
431
+ source_queue: physical_queue(queue_name, source_queue))
432
+ end
433
+ signal_concurrency(payload)
434
+ signal_batch_discarded(payload)
435
+ instrument("pgbus.job_discarded", queue: queue_name, job_class: payload["job_class"], job_id: payload["job_id"],
436
+ provider_job_id: payload["provider_job_id"], msg_id: msg_id, reason: "uniqueness")
437
+ Pgbus.logger.info do
438
+ "[Pgbus] Discarded duplicate #{payload["job_class"]}: uniqueness key #{key} is held by another message " \
439
+ "(#{log_tag(message, queue_name)})"
440
+ end
441
+ :discarded
442
+ end
443
+
444
+ # :reject / :log — the copy still runs once the holder is done, but each
445
+ # skip backs its redelivery off so the skips stretch over the holder's
446
+ # run. read_ct cannot be decremented, so a skip still counts toward
447
+ # max_retries; the dead-letter reason says when that is why it died.
448
+ def skip_duplicate(message, queue_name, payload, key, source_queue, policy)
449
+ delay = skip_delay(message, payload)
450
+ begin
451
+ set_message_visibility(message, queue_name, delay, source_queue)
452
+ rescue StandardError => e
453
+ Pgbus.logger.warn { "[Pgbus] Skip backoff VT update failed: #{e.message}" }
454
+ end
455
+ text = "[Pgbus] Skipping duplicate execution for #{payload["job_class"]}: uniqueness key #{key} is held by " \
456
+ "another message (#{log_tag(message, queue_name)}); next delivery in #{delay}s"
457
+ policy == :log ? Pgbus.logger.warn { text } : Pgbus.logger.info { text }
458
+ instrument("pgbus.job_skipped", queue: queue_name, job_class: payload["job_class"], job_id: payload["job_id"],
459
+ provider_job_id: payload["provider_job_id"], msg_id: message.msg_id.to_i,
460
+ read_ct: message.read_ct.to_i, on_conflict: policy, vt: delay)
461
+ :skipped
462
+ end
463
+
464
+ # visibility_timeout * 2^(read_ct - 1), jittered and capped at the job's
465
+ # (or the global) retry_backoff_max, and never below visibility_timeout:
466
+ # a skip never comes back sooner than the read's own timeout would.
467
+ def skip_delay(message, payload)
468
+ vt = config.visibility_timeout
469
+ job_class = resolve_job_class(payload)
470
+ overrides = (job_class.respond_to?(:pgbus_retry_backoff_config) && job_class.pgbus_retry_backoff_config) || {}
471
+ max = [overrides[:max] || config.retry_backoff_max, vt].max
472
+ delay = RetryBackoff.compute_delay(attempt: message.read_ct.to_i, base: vt, max: max,
473
+ jitter: overrides[:jitter] || config.retry_backoff_jitter)
474
+ [delay, vt].max
361
475
  end
362
476
 
363
477
  def resolve_job_class(payload)
@@ -412,7 +526,13 @@ module Pgbus
412
526
  # that succeeded but failed to archive redelivers after VT expiry and
413
527
  # runs twice. If the retry also fails, fall through to the normal
414
528
  # failure path (recorded failure + VT-based redelivery).
415
- # Returns :archived, :already_archived, or :ambiguous.
529
+ # Returns :archived, :already_archived, or :ambiguous. Under
530
+ # config.completed_jobs = :delete it deletes the message instead
531
+ # (delete_message, or the completer's delete_batch): pgmq.delete is as
532
+ # idempotent and returns false the same way, so the symbols keep their
533
+ # names and "archived" means "completed". job_completion: true lets
534
+ # config.completion_synchronous_commit = false commit it with
535
+ # synchronous_commit off (issue #619).
416
536
  #
417
537
  # A `false` reported after our OWN retry is ambiguous, not a duplicate:
418
538
  # the first archive may well have committed with only its reply lost to
@@ -421,13 +541,21 @@ module Pgbus
421
541
  # batch and uniqueness state on every such blip, whereas a genuine
422
542
  # duplicate would additionally require another worker to have taken and
423
543
  # finished the same message inside the retry window.
424
- def archive_from(queue_name, msg_id, source_queue: nil)
544
+ def complete_from(queue_name, msg_id, source_queue: nil)
425
545
  attempts = 0
426
546
  begin
427
- archived = if source_queue
428
- client.archive_message(source_queue, msg_id, prefixed: false)
547
+ archived = if @completer
548
+ @completer.complete(physical_queue(queue_name, source_queue), msg_id) == :archived
549
+ # Spelled out, no `**` forward: on Ruby 3.3 that
550
+ # allocates per job even without keywords (#484 budget).
551
+ elsif @delete_completed && source_queue
552
+ client.delete_message(source_queue, msg_id, prefixed: false, job_completion: true)
553
+ elsif @delete_completed
554
+ client.delete_message(queue_name, msg_id, job_completion: true)
555
+ elsif source_queue
556
+ client.archive_message(source_queue, msg_id, prefixed: false, job_completion: true)
429
557
  else
430
- client.archive_message(queue_name, msg_id)
558
+ client.archive_message(queue_name, msg_id, job_completion: true)
431
559
  end
432
560
  return :archived unless archived == false
433
561
 
@@ -437,7 +565,7 @@ module Pgbus
437
565
  raise unless attempts == 1 && connection_error?(e)
438
566
 
439
567
  Pgbus.logger.warn do
440
- "[Pgbus::Executor] Archive failed on connection error, retrying once: #{e.message}"
568
+ "[Pgbus::Executor] #{@delete_completed ? "Delete" : "Archive"} failed on connection error, retrying once: #{e.message}"
441
569
  end
442
570
  retry
443
571
  end
@@ -463,14 +591,37 @@ module Pgbus
463
591
  defined?(PGMQ::Errors::ConnectionError) && error.is_a?(PGMQ::Errors::ConnectionError)
464
592
  end
465
593
 
466
- def handle_dead_letter(message, queue_name, payload, source_queue: nil, last_error: nil)
594
+ # uniqueness_conflict for a :while_executing message no attempt of
595
+ # which failed: it was skipped on every read until read_ct passed
596
+ # max_retries (issue #629). Not whether the key is held at this read:
597
+ # the backed-off skips span minutes and the holder may have finished
598
+ # inside the last one. The one other way to die without a failed-event
599
+ # row is a crash loop, and a crashed attempt leaves the key's row bound
600
+ # to this message: that keeps max_retries_exceeded. A failed lookup
601
+ # keeps the stronger signal, the missing failed-event row.
602
+ def dead_letter_reason(message, queue_name, payload, last_error)
603
+ return DeadLetterHeader::REASON_MAX_RETRIES if last_error
604
+
605
+ key = Uniqueness.extract_key(payload)
606
+ return DeadLetterHeader::REASON_MAX_RETRIES unless key && Uniqueness.extract_strategy(payload) == :while_executing
607
+
608
+ own = UniquenessKey.bound_to?(key, queue_name: Uniqueness.lock_queue_name(queue_name, config: config),
609
+ msg_id: message.msg_id.to_i)
610
+ own ? DeadLetterHeader::REASON_MAX_RETRIES : DeadLetterHeader::REASON_UNIQUENESS_CONFLICT
611
+ rescue StandardError => e
612
+ Pgbus.logger.warn { "[Pgbus] Uniqueness row lookup failed, dead-lettering as uniqueness_conflict: #{e.message}" }
613
+ DeadLetterHeader::REASON_UNIQUENESS_CONFLICT
614
+ end
615
+
616
+ def handle_dead_letter(message, queue_name, payload, source_queue: nil, last_error: nil,
617
+ reason: DeadLetterHeader::REASON_MAX_RETRIES)
467
618
  Pgbus.logger.warn do
468
619
  job_class = payload["job_class"] || "unknown"
469
620
  error = last_error ? " (last error: #{last_error[:error_class]})" : ""
470
621
  "[Pgbus] Moving job #{job_class} to dead letter queue after #{message.read_ct} attempts#{error}"
471
622
  end
472
623
  headers = DeadLetterHeader.build(
473
- existing: message.headers, reason: DeadLetterHeader::REASON_MAX_RETRIES, source: "worker",
624
+ existing: message.headers, reason: reason, source: "worker",
474
625
  source_queue: source_queue || config.queue_name(queue_name), source_msg_id: message.msg_id.to_i,
475
626
  source_enqueued_at: message.enqueued_at, attempts: message.read_ct.to_i,
476
627
  max_retries: config.max_retries, error: last_error