pgbus 0.21.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c3c6921f17cfc002f7177240a3d1b2c202a78c7127e8f0da75a75ebcaf365d1
4
- data.tar.gz: b73396771876a230b1b62b6b30dceb87329be7259810c5346fd4a3fc78f59991
3
+ metadata.gz: 59ff2a591041f47789d41f241c5496534dbba5314ebb37a0c2fe41286f611587
4
+ data.tar.gz: 9f8b3c5a4f88330b96cd6ecab5d4dfe18580d48765b3aa50873e3f54150f63b1
5
5
  SHA512:
6
- metadata.gz: 5667ec2a6ebd1ef9e09f5f89818e69784267b3aa0c13a0b5cef2782b346af2361c106454fbb58d67a688b48f451483327fa4f3edc0a1d9a82664aa599af52454
7
- data.tar.gz: 83d7a44001fe09db593caacc788307198361279c023b7ed2ed8397fdec17fb353fa44087d9a973857951835d6cb1206314ade872a99d85586988561be2477a85
6
+ metadata.gz: 76c28daff8340ec68e0cf5b0b7f9a69ad48b1d024d65ff9af37fbc734167a8b710afca0b1fce9163783b43e022f0d8e6d4d465219aa91282b52eb69d770e969b
7
+ data.tar.gz: 8f2b9d1f6c4f899c64a9dd0fa1918b64e158799e6172bb0e42cf7beac6ca191675e5df9c31e90eaa62c4f1714c6c55eb317612e8d7b6047ab3e069d5bbab6046
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Fixed
4
+
5
+ - **The dashboard's Retry, Discard and Discard all reach a failed job's message under priority routing (issue #577).** A failed-job row records the logical queue only, and with `priority_levels > 1` the message sits in a `_pN` sub-queue, never in the base table. Retry reset the visibility timeout on `pgmq.q_pgbus_<queue>`, which priority routing never creates, and answered "Could not retry job."; Discard and Discard all archived from that same table, so they reported the job discarded while its message stayed queued and ran again. Each sub-queue also numbers its messages from its own sequence, so the msg_id alone could name another job's message in a sibling sub-queue. The dashboard now finds the sub-queue whose message has the row's msg_id and carries the job's `job_id` (`Pgbus::Client#locate_message`, which returns that physical table or nil, and `#locate_messages` for many at once, one query per table), resets or archives the message there, and leaves a same-numbered message elsewhere alone. Discard all locates a queue's messages together and archives them in one batch per table. When no sub-queue holds it, Retry enqueues a fresh copy as before. Without priority routing there is one table and nothing changes, except that a message reusing the msg_id under another `job_id` is no longer reset or archived in the job's place. Refs #577.
6
+
3
7
  ## [0.21.0] - 2026-10-19
4
8
 
5
9
  ### Added
@@ -11,11 +15,15 @@
11
15
  ### Changed
12
16
 
13
17
  - **`actor:` and `reason:` are reserved keywords of `Pgbus::Workflow.start!` (issue #571).** Like `idempotency_key:`, they are taken for the audit trail and no longer reach the run's `arguments`. A workflow that receives an argument literally named `actor` or `reason` through `start!` renames it, e.g. `start!(subject, requested_by: "alice")` read as `arguments[:requested_by]`. Runs already started keep their stored arguments, and `rerun!` copies them as stored. Refs #571.
18
+ - **`Pgbus::Client#message_exists?`, `#message_in_queue?`, `#message_archived?`, `#message_with_job_id?` and `#dead_letter_physical_name` take a logical queue name unless you pass `prefixed: false` (issue #569).** They used to guess that a name starting with `"#{queue_prefix}_"` was already physical. **If you call one of them with a physical table name, add `prefixed: false`;** without it the name is prefixed again. Under priority routing two of them also answer differently for a logical name: `message_archived?` probes every `_pN` archive (it probed a base table that priority routing never creates, and answered nil), and `dead_letter_physical_name` keeps a logical name's own `_pN` suffix (`orders_p2` → `…_orders_p2_dlq`, the queue `move_to_dead_letter` writes), stripping it only from a physical sub-queue name passed with `prefixed: false`.
19
+ - **The batch completion callback is `on_complete`.** `Pgbus::Batch.new(on_complete:)`, the builder's `b.on_complete` and the `#on_complete` reader replace `on_finish`, which keeps working as a deprecated alias until 1.0 and logs one warning per process. The database columns stay `on_finish_class` / `on_finish_job` until the 1.0 schema cleanup, so batches created before upgrading still fire, and the `pgbus.batch_finished` instrumentation event keeps its name. The dashboard's batch page labels the callback "On Complete". Refs #538.
20
+
14
21
 
15
22
  ### Fixed
16
23
 
17
24
  - **The workflows one-ongoing index predicate dumps the same `schema.rb` line from a migrated and from a schema-loaded database (issue #573).** `pgbus:add_workflows` and the fresh-install migration declared `idx_pgbus_workflows_one_ongoing` with `where: "status IN ('ready','performing','waiting','paused')"`. `status` is a `varchar`, so Postgres stored the list as `varchar` literals cast to `text[]`; `db:schema:load` parses that dumped string into a different expression, and each later dump flipped the line depending on which kind of database it came from. Both templates now write `status::text IN (…)`, whose stored form replays to itself. The index means the same either way, so existing installs need nothing. An app that wants a stable dump can recreate the index in a migration of its own: `remove_index :pgbus_workflows, name: "idx_pgbus_workflows_one_ongoing"`, then `add_index :pgbus_workflows, [:workflow_class, :subject_type, :subject_id], unique: true, where: "status::text IN ('ready','performing','waiting','paused')", name: "idx_pgbus_workflows_one_ongoing"` (both run in the migration's transaction, so the constraint never lapses; `remove_index` takes an ACCESS EXCLUSIVE lock, so `pgbus_workflows` is locked against reads and writes (workers, workflow starts and the dashboard wait) until the migration commits), and dump the schema again. Refs #573.
18
25
  - **A guard that ends a `wait_for:` step after a reattempt consumes the signal the step holds (issue #572).** A woken attempt that called `reattempt!` (or failed into a pause and was resumed) kept its signal `claimed` for the next attempt, whose row carries no `signal_id` until its gate runs. When `cancel_if` or `skip_if` ended that next attempt (or a guard raised and its `on_exception` policy skipped or cancelled, or a deploy had declared the step a `removed_step` meanwhile), the signal stayed `claimed` forever. The attempt now records the held signal before its outcome, so it is consumed when the step ends and kept when it reattempts or pauses, as for any attempt. Refs #572.
26
+ - **A queue whose logical name starts with the prefix is probed in its own table (issue #569).** `Pgbus::Client#message_exists?`, `#message_in_queue?`, `#message_archived?`, `#message_with_job_id?` and `#dead_letter_physical_name` decided whether a name was already physical by `name.start_with?("#{queue_prefix}_")`, so a logical `pgbus_mailers` (table `pgbus_pgbus_mailers`) was probed as `pgmq.q_pgbus_mailers`. The answer was "unknown", or another queue's answer when that table existed. The dispatcher kept an orphaned uniqueness lock under such a queue forever. The dashboard's failed-event Retry enqueued a copy next to the original message. The batch sweep kept an orphan execution row forever, or un-counted a dead-lettered job. The name's spelling no longer decides anything: these methods take a logical name, expanded through `config.queue_name` and the queue strategy, unless the caller passes `prefixed: false` for a physical table name. This is the `prefixed:` convention `delete_message`, `archive_message`, `set_visibility_timeout`, `purge_queue` and `drop_queue` already follow. `message_archived?` now also expands a logical name to every `_pN` archive under priority routing, as `message_exists?` already did; before, it probed a base table that does not exist there. The batch sweep now passes `prefixed: false` for a backfilled execution row (it holds the table its message went to) and the logical name for an orphan row. The caller-facing change is under Changed.
19
27
 
20
28
  ## [0.20.1] - 2026-10-18
21
29
 
@@ -38,14 +38,17 @@ module Pgbus
38
38
  swept
39
39
  end
40
40
 
41
+ # A row with a msg_id was backfilled with the physical table its message
42
+ # was sent to (the `_pN` sub-queue under priority routing), so every
43
+ # probe takes that name as is.
41
44
  def classify_stale(row, client)
42
- in_queue = client.message_in_queue?(row.queue_name, msg_id: row.msg_id)
45
+ in_queue = client.message_in_queue?(row.queue_name, msg_id: row.msg_id, prefixed: false)
43
46
  return :still_present if in_queue != false
44
47
 
45
- dlq = client.dead_letter_physical_name(row.queue_name)
46
- return :failed if client.message_with_job_id?(dlq, job_id: row.job_id) == true
48
+ dlq = client.dead_letter_physical_name(row.queue_name, prefixed: false)
49
+ return :failed if client.message_with_job_id?(dlq, job_id: row.job_id, prefixed: false) == true
47
50
 
48
- return :completed if client.message_archived?(row.queue_name, msg_id: row.msg_id) == true
51
+ return :completed if client.message_archived?(row.queue_name, msg_id: row.msg_id, prefixed: false) == true
49
52
 
50
53
  Pgbus.logger.warn do
51
54
  "[Pgbus] Batch execution #{row.job_id} (batch #{row.batch_id}) has no queue, " \
@@ -84,6 +87,8 @@ module Pgbus
84
87
  swept
85
88
  end
86
89
 
90
+ # A row without a msg_id still holds the logical queue name it was
91
+ # inserted with (the job's queue_name); the backfill was never applied.
87
92
  def classify_orphan(row, client)
88
93
  return :live if row.queue_name.nil?
89
94
 
@@ -91,7 +96,7 @@ module Pgbus
91
96
  return :live if in_queue != false
92
97
 
93
98
  dlq = client.dead_letter_physical_name(row.queue_name)
94
- return :failed if client.message_with_job_id?(dlq, job_id: row.job_id) == true
99
+ return :failed if client.message_with_job_id?(dlq, job_id: row.job_id, prefixed: false) == true
95
100
 
96
101
  :orphan
97
102
  end
data/lib/pgbus/client.rb CHANGED
@@ -720,16 +720,24 @@ module Pgbus
720
720
  # reaper to determine if a uniqueness lock with msg_id=0 (placeholder)
721
721
  # still has a corresponding queue message.
722
722
  #
723
- # +queue_name+ may be either a logical name (e.g. "default") or an already
724
- # prefixed physical name (e.g. "pgbus_default"). The client normalizes both.
723
+ # +queue_name+ is a logical name (e.g. "default"), expanded through the
724
+ # queue strategy, unless the caller passes +prefixed: false+ for a physical
725
+ # table name (e.g. "pgbus_default_p1"), used as is. The name's spelling
726
+ # decides nothing: a logical "pgbus_mailers" lives in pgbus_pgbus_mailers
727
+ # (issue #569).
725
728
  #
726
729
  # Returns:
727
730
  # true — the message definitely exists in the queue
728
731
  # false — the message definitely does not exist
729
732
  # nil — could not determine (e.g. queue table missing or unknown error).
730
733
  # Callers MUST treat nil as "exists" for safety.
731
- def message_in_queue?(queue_name, msg_id:)
732
- message_exists?(queue_name, msg_id: msg_id)
734
+ #
735
+ # Under priority routing a logical name probes every `_pN` table, and each
736
+ # table has its own msg_id sequence: a bare +msg_id+ may match a different
737
+ # message in a sibling sub-queue. A caller that knows the sub-queue passes
738
+ # its physical name with +prefixed: false+.
739
+ def message_in_queue?(queue_name, msg_id:, prefixed: true)
740
+ message_exists?(queue_name, msg_id: msg_id, prefixed: prefixed)
733
741
  end
734
742
 
735
743
  # Look up a queue row by ActiveJob job_id in the JSON payload. Used by the
@@ -738,10 +746,10 @@ module Pgbus
738
746
  #
739
747
  # Same tri-state as message_exists?: true / false / nil (unknown). A
740
748
  # logical name expands to every physical table the strategy owns (the
741
- # `_pN` sub-queues under priority routing); an already-prefixed physical
742
- # name probes itself only.
743
- def message_with_job_id?(queue_name, job_id:)
744
- tables = lookup_physical_queue_names(queue_name).map { |name| QueueNameValidator.sanitize!(name) }
749
+ # `_pN` sub-queues under priority routing); a physical name passed with
750
+ # +prefixed: false+ probes itself only.
751
+ def message_with_job_id?(queue_name, job_id:, prefixed: true)
752
+ tables = lookup_physical_queue_names(queue_name, prefixed: prefixed).map { |name| QueueNameValidator.sanitize!(name) }
745
753
  determined = false
746
754
 
747
755
  synchronized do
@@ -758,43 +766,85 @@ module Pgbus
758
766
  determined ? false : nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition -- tri-state: nil means unknown
759
767
  end
760
768
 
761
- # DLQ companion of a logical or already-physical queue name. Does not
762
- # re-prefix a name that resolve_full_queue_name already expanded.
763
- def dead_letter_physical_name(queue_name)
764
- full = resolve_full_queue_name(queue_name)
769
+ # The physical table that holds message +msg_id+, or nil when none does.
770
+ # See locate_messages.
771
+ def locate_message(queue_name, msg_id:, job_id: nil, prefixed: true)
772
+ locate_messages(queue_name, [[msg_id, job_id]], prefixed: prefixed)[msg_id.to_i]
773
+ end
774
+
775
+ # { msg_id => physical table } for each [msg_id, job_id] pair whose
776
+ # message is queued, one query per table on one connection. A logical
777
+ # name expands to every table the strategy owns, and under priority
778
+ # routing each `_pN` sub-queue numbers its messages from its own
779
+ # sequence: only a message carrying the pair's job_id matches, or any
780
+ # message with that msg_id when job_id is nil, the first table winning
781
+ # (issue #577). A physical name with +prefixed: false+ is probed alone.
782
+ # A table that does not exist holds nothing; other errors raise.
783
+ def locate_messages(queue_name, messages, prefixed: true)
784
+ wanted = messages.to_h { |msg_id, job_id| [msg_id.to_i, job_id&.to_s] }
785
+ tables = lookup_physical_queue_names(queue_name, prefixed: prefixed)
786
+ located = {}
787
+
788
+ synchronized do
789
+ with_raw_connection do |conn|
790
+ tables.each do |name|
791
+ pending = wanted.keys - located.keys
792
+ break if pending.empty?
793
+
794
+ queued_job_ids(conn, QueueNameValidator.sanitize!(name), pending).each do |row|
795
+ msg_id = row["msg_id"].to_i
796
+ job_id = wanted[msg_id]
797
+ located[msg_id] ||= name if job_id.nil? || row["job_id"] == job_id
798
+ end
799
+ end
800
+ end
801
+ end
802
+
803
+ located
804
+ end
805
+
806
+ # DLQ companion of a logical queue name, or of a physical table name passed
807
+ # with +prefixed: false+.
808
+ def dead_letter_physical_name(queue_name, prefixed: true)
809
+ return config.dead_letter_queue_name(queue_name) if prefixed
810
+
765
811
  # Priority sub-queues share the logical queue's DLQ (`pgbus_default_dlq`),
766
- # not a per-priority `…_pN_dlq`. Only strip when the strategy actually
767
- # creates `_pN` tables — a logical queue named `orders_p0` must keep it.
768
- base = @queue_strategy.priority? ? full.sub(/_p\d+\z/, "") : full
812
+ # not a per-priority `…_pN_dlq`. Only a physical name can be a sub-queue,
813
+ # and only when the strategy actually creates `_pN` tables: a logical
814
+ # queue named `orders_p0` keeps its suffix.
815
+ name = queue_name.to_s
816
+ base = @queue_strategy.priority? ? name.sub(/_p\d+\z/, "") : name
769
817
  "#{base}#{Pgbus::DEAD_LETTER_SUFFIX}"
770
818
  end
771
819
 
772
820
  # Same tri-state as message_exists?: true / false / nil (unknown).
773
- def message_archived?(queue_name, msg_id:)
774
- full_name = resolve_full_queue_name(queue_name)
775
- sanitized = QueueNameValidator.sanitize!(full_name)
821
+ # A logical name probes the archive of every physical table the strategy
822
+ # owns, like message_exists?; a physical name (prefixed: false) its own.
823
+ # A bare msg_id is unique per table only (see message_in_queue?).
824
+ def message_archived?(queue_name, msg_id:, prefixed: true)
825
+ tables = lookup_physical_queue_names(queue_name, prefixed: prefixed).map { |name| QueueNameValidator.sanitize!(name) }
826
+ determined = false
776
827
 
777
828
  synchronized do
778
829
  with_raw_connection do |conn|
779
- msg_id_archived?(conn, sanitized, msg_id.to_i)
830
+ tables.each do |sanitized|
831
+ archived = probe_archive_presence(conn, sanitized, msg_id.to_i)
832
+ return true if archived == true
833
+
834
+ determined = true unless archived == :missing
835
+ end
780
836
  end
781
837
  end
782
- rescue ActiveRecord::StatementInvalid => e
783
- raise unless undefined_table_error?(e)
784
-
785
- nil
786
- rescue StandardError => e
787
- raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
788
838
 
789
- nil
839
+ determined ? false : nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition -- tri-state: nil means unknown
790
840
  end
791
841
 
792
- def message_exists?(queue_name, msg_id: nil, uniqueness_key: nil)
842
+ def message_exists?(queue_name, msg_id: nil, uniqueness_key: nil, prefixed: true)
793
843
  has_msg_id = !msg_id.nil?
794
844
  has_uniqueness_key = !uniqueness_key.nil?
795
845
  raise ArgumentError, "pass msg_id, uniqueness_key, or both" unless has_msg_id || has_uniqueness_key
796
846
 
797
- tables = lookup_physical_queue_names(queue_name).map { |name| QueueNameValidator.sanitize!(name) }
847
+ tables = lookup_physical_queue_names(queue_name, prefixed: prefixed).map { |name| QueueNameValidator.sanitize!(name) }
798
848
  determined = false
799
849
 
800
850
  synchronized do
@@ -1092,21 +1142,10 @@ module Pgbus
1092
1142
  end
1093
1143
  end
1094
1144
 
1095
- # Accept either a logical name ("default") or an already-prefixed
1096
- # physical name ("pgbus_default") and return the physical name.
1097
- # Coerces symbols to strings so callers can pass either form.
1098
- def resolve_full_queue_name(queue_name)
1099
- name = queue_name.to_s
1100
- prefix = "#{config.queue_prefix}_"
1101
- name.start_with?(prefix) ? name : config.queue_name(name)
1102
- end
1103
-
1104
1145
  # Logical names expand through QueueFactory so a priority queue's _pN
1105
- # tables are included; already-prefixed physical names stay as-is.
1106
- def lookup_physical_queue_names(queue_name)
1107
- name = queue_name.to_s
1108
- prefix = "#{config.queue_prefix}_"
1109
- name.start_with?(prefix) ? [name] : @queue_strategy.physical_queue_names(name)
1146
+ # tables are included; a physical name (prefixed: false) stays as is.
1147
+ def lookup_physical_queue_names(queue_name, prefixed:)
1148
+ prefixed ? @queue_strategy.physical_queue_names(queue_name.to_s) : [queue_name.to_s]
1110
1149
  end
1111
1150
 
1112
1151
  def probe_queue_presence(conn, sanitized, msg_id:, uniqueness_key:)
@@ -1125,6 +1164,18 @@ module Pgbus
1125
1164
  :missing
1126
1165
  end
1127
1166
 
1167
+ def probe_archive_presence(conn, sanitized, msg_id)
1168
+ msg_id_archived?(conn, sanitized, msg_id)
1169
+ rescue ActiveRecord::StatementInvalid => e
1170
+ raise unless undefined_table_error?(e)
1171
+
1172
+ :missing
1173
+ rescue StandardError => e
1174
+ raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
1175
+
1176
+ :missing
1177
+ end
1178
+
1128
1179
  def probe_job_id_presence(conn, sanitized, job_id)
1129
1180
  job_id_present?(conn, sanitized, job_id)
1130
1181
  rescue ActiveRecord::StatementInvalid => e
@@ -1137,6 +1188,22 @@ module Pgbus
1137
1188
  :missing
1138
1189
  end
1139
1190
 
1191
+ # msg_ids are integers, so the array literal is safe.
1192
+ def queued_job_ids(conn, sanitized, msg_ids)
1193
+ conn.exec_params(
1194
+ "SELECT msg_id, message::jsonb ->> 'job_id' AS job_id FROM pgmq.q_#{sanitized} WHERE msg_id = ANY($1::bigint[])",
1195
+ ["{#{msg_ids.join(",")}}"]
1196
+ )
1197
+ rescue ActiveRecord::StatementInvalid => e
1198
+ raise unless undefined_table_error?(e)
1199
+
1200
+ []
1201
+ rescue StandardError => e
1202
+ raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
1203
+
1204
+ []
1205
+ end
1206
+
1140
1207
  def scan_uniqueness_keys(conn, sanitized, keys, found)
1141
1208
  placeholders = keys.each_index.map { |i| "$#{i + 1}" }.join(", ")
1142
1209
  result = conn.exec_params(
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.21.0"
4
+ VERSION = "0.21.1"
5
5
  end
@@ -299,9 +299,9 @@ module Pgbus
299
299
  # duplicate (the original is still in the queue waiting for retry).
300
300
  # Falls back to enqueueing a fresh copy only if the original is gone
301
301
  # (e.g., already moved to DLQ).
302
- msg_id = event["msg_id"]
303
- if msg_id && @client.message_exists?(event["queue_name"], msg_id: msg_id.to_i)
304
- @client.set_visibility_timeout(event["queue_name"], msg_id.to_i, vt: 0)
302
+ physical = failed_message_queue(event)
303
+ if physical
304
+ @client.set_visibility_timeout(physical, event["msg_id"].to_i, vt: 0, prefixed: false)
305
305
  else
306
306
  payload = JSON.parse(event["payload"])
307
307
  headers = event["headers"]
@@ -1721,12 +1721,29 @@ module Pgbus
1721
1721
  nil
1722
1722
  end
1723
1723
 
1724
+ # The physical queue holding a failed job's message, or nil when it is
1725
+ # gone. The row records the logical queue only, and under priority
1726
+ # routing a msg_id repeats across the _pN sub-queues, so the job's
1727
+ # job_id must match too (issue #577). Two sub-queues' failures with the
1728
+ # same msg_id share one row, which keeps the first one's payload: that
1729
+ # is the message found (issue #583).
1730
+ def failed_message_queue(event)
1731
+ return unless event["queue_name"] && event["msg_id"]
1732
+
1733
+ @client.locate_message(event["queue_name"], msg_id: event["msg_id"].to_i, job_id: failed_job_id(event))
1734
+ end
1735
+
1736
+ def failed_job_id(event)
1737
+ payload = event["payload"]
1738
+ payload = JSON.parse(payload) if payload.is_a?(String)
1739
+ payload["job_id"] if payload.is_a?(Hash)
1740
+ end
1741
+
1724
1742
  # Archive the queue message a failed_event row points to. Idempotent —
1725
1743
  # silently no-ops if the message no longer exists in the queue.
1726
1744
  def archive_failed_message(event)
1727
- return unless event["queue_name"] && event["msg_id"]
1728
-
1729
- @client.archive_message(event["queue_name"], event["msg_id"].to_i)
1745
+ physical = failed_message_queue(event)
1746
+ @client.archive_message(physical, event["msg_id"].to_i, prefixed: false) if physical
1730
1747
  rescue StandardError => e
1731
1748
  Pgbus.logger.debug do
1732
1749
  "[Pgbus::Web] Error archiving message for failed event #{event["id"]}: #{e.message}"
@@ -1734,30 +1751,30 @@ module Pgbus
1734
1751
  end
1735
1752
 
1736
1753
  # Archive every queue message referenced by a failed_event row.
1737
- # Groups by queue and uses archive_batch so an N-event discard is
1738
- # one SQL statement per queue instead of N per-row roundtrips.
1739
- # Falls back to per-row archive inside a rescue if a batch fails,
1740
- # so one bad queue can't block progress on the others.
1754
+ # Groups by queue, then locates and archive_batches per physical table,
1755
+ # so an N-event discard is two SQL statements per table instead of N
1756
+ # per-row roundtrips. Falls back to per-row archive inside a rescue if
1757
+ # a batch fails, so one bad queue can't block progress on the others.
1741
1758
  def archive_all_failed_messages
1742
1759
  rows = connection.select_all(
1743
- "SELECT id, queue_name, msg_id FROM pgbus_failed_events WHERE msg_id IS NOT NULL AND #{NOT_HANDLER_QUEUE}",
1760
+ "SELECT id, queue_name, msg_id, payload FROM pgbus_failed_events WHERE msg_id IS NOT NULL AND #{NOT_HANDLER_QUEUE}",
1744
1761
  "Pgbus Collect Failed Messages", [handler_queue_names_literal]
1745
1762
  )
1746
1763
 
1747
1764
  grouped = rows.to_a.group_by { |row| row["queue_name"] }
1748
1765
  grouped.each do |queue_name, events|
1749
- msg_ids = events.filter_map { |row| row["msg_id"]&.to_i }
1750
- next if msg_ids.empty?
1751
-
1752
- begin
1753
- @client.archive_batch(queue_name, msg_ids)
1754
- rescue StandardError => e
1755
- Pgbus.logger.debug do
1756
- "[Pgbus::Web] archive_batch failed for #{queue_name} (#{e.message}); " \
1757
- "falling back to per-row archive"
1758
- end
1759
- events.each { |row| archive_failed_message(row) }
1766
+ # A priority queue's msg_ids repeat across its _pN sub-queues, so
1767
+ # each message is located first and archived from its own table.
1768
+ located = @client.locate_messages(queue_name, events.map { |row| [row["msg_id"].to_i, failed_job_id(row)] })
1769
+ located.group_by { |_msg_id, physical| physical }.each do |physical, pairs|
1770
+ @client.archive_batch(physical, pairs.map(&:first), prefixed: false)
1771
+ end
1772
+ rescue StandardError => e
1773
+ Pgbus.logger.debug do
1774
+ "[Pgbus::Web] archive_batch failed for #{queue_name} (#{e.message}); " \
1775
+ "falling back to per-row archive"
1760
1776
  end
1777
+ events.each { |row| archive_failed_message(row) }
1761
1778
  end
1762
1779
  rescue StandardError => e
1763
1780
  Pgbus.logger.debug { "[Pgbus::Web] Error archiving failed messages: #{e.message}" }
data/sig/pgbus/client.rbs CHANGED
@@ -13,7 +13,7 @@ module Pgbus
13
13
  def convert_archive_partitioned: (untyped queue_name, ?partition_interval: untyped, ?retention_interval: untyped, ?leading_partition: untyped) -> untyped
14
14
  def create_fifo_index: (untyped queue_name) -> untyped
15
15
  def create_fifo_indexes_all: () -> untyped
16
- def dead_letter_physical_name: (untyped queue_name) -> untyped
16
+ def dead_letter_physical_name: (untyped queue_name, ?prefixed: untyped) -> untyped
17
17
  def delete_batch: (untyped queue_name, untyped msg_ids, ?prefixed: untyped) -> untyped
18
18
  def delete_message: (untyped queue_name, untyped msg_id, ?prefixed: untyped) -> untyped
19
19
  def drop_queue: (untyped queue_name, ?prefixed: untyped) -> untyped
@@ -25,10 +25,12 @@ module Pgbus
25
25
  def in_recovery?: () -> bool
26
26
  def list_notify_insert_throttles: () -> untyped
27
27
  def list_queues: () -> untyped
28
- def message_archived?: (untyped queue_name, msg_id: untyped) -> bool
29
- def message_exists?: (untyped queue_name, ?msg_id: untyped, ?uniqueness_key: untyped) -> bool
30
- def message_in_queue?: (untyped queue_name, msg_id: untyped) -> bool
31
- def message_with_job_id?: (untyped queue_name, job_id: untyped) -> bool
28
+ def locate_message: (untyped queue_name, msg_id: untyped, ?job_id: untyped, ?prefixed: untyped) -> String?
29
+ def locate_messages: (untyped queue_name, Array[[untyped, untyped]] messages, ?prefixed: untyped) -> Hash[Integer, String]
30
+ def message_archived?: (untyped queue_name, msg_id: untyped, ?prefixed: untyped) -> bool?
31
+ def message_exists?: (untyped queue_name, ?msg_id: untyped, ?uniqueness_key: untyped, ?prefixed: untyped) -> bool?
32
+ def message_in_queue?: (untyped queue_name, msg_id: untyped, ?prefixed: untyped) -> bool?
33
+ def message_with_job_id?: (untyped queue_name, job_id: untyped, ?prefixed: untyped) -> bool?
32
34
  def metrics: (?untyped queue_name) -> untyped
33
35
  def move_to_dead_letter: (untyped queue_name, untyped message, ?headers: untyped) -> untyped
34
36
  def notify_enabled?: (untyped queue_name) -> bool
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.21.0
4
+ version: 0.21.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson