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
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ class DataSource
6
+ # Re-sending a job payload as a new message — DLQ Retry, and failed-job
7
+ # Retry when the original message is gone (issues #631, #636). The
8
+ # payload names its uniqueness key and strategy, and its
9
+ # limits_concurrency key. The copy releases the uniqueness row it holds
10
+ # when it starts (:until_executing) or finishes (:until_executed), and
11
+ # signals the semaphore when it finishes. Re-sent without a row or a
12
+ # slot of its own, that release could free a newer parked copy's key,
13
+ # and that signal gave back a slot another copy held and promoted a
14
+ # parked job beside it. So the copy goes out the way Adapter#enqueue
15
+ # sends a job: the uniqueness key taken unbound before the send and
16
+ # bound to the new message after it; a slot taken before the send, or
17
+ # the copy parked behind a full limit (on_conflict: :block), or the
18
+ # retry refused (:discard, :raise) with the source left in place.
19
+ module Resend
20
+ private
21
+
22
+ # Yields to send the payload; the block returns the new msg_id.
23
+ # Returns :sent, :parked (a BlockedExecution the limit's next signal
24
+ # promotes; nothing is sent and the block does not run), or false
25
+ # when another copy holds the uniqueness key or the limit is full
26
+ # under :discard / :raise (nothing is sent, so the source — the DLQ
27
+ # message or the failed row — stays for a later retry).
28
+ # +lock_queue+ is the queue as the executor spells the lock
29
+ # (Uniqueness.lock_queue_name), which is also the logical queue a
30
+ # parked copy is promoted onto; +holder+ is the [lock queue, msg_id,
31
+ # enqueued_at] of the message the payload was read from, as the
32
+ # dashboard's discards name it (#lock_holder). +drop_source+ removes
33
+ # the source once the copy is parked (a sent copy's source is the
34
+ # block's to remove). +headers+ (a Hash) are the ones the block
35
+ # sends; a parked copy carries them to its promotion.
36
+ def resend_job(payload, lock_queue:, holder:, drop_source:, headers: nil, &)
37
+ key = resend_lock_key(payload)
38
+ stamp = key && take_resend_lock(key, lock_queue, holder)
39
+ if key && !stamp
40
+ Pgbus.logger.warn { "[Pgbus::Web] Not re-sending #{payload["job_class"]}: uniqueness key #{key} is held by another copy" }
41
+ return false
42
+ end
43
+
44
+ # Only an error raised by the produce (:produce), or by the park's
45
+ # commit (:park: the COMMIT may have landed with its reply lost),
46
+ # can hide a live copy: one from the slot upsert, the park's insert
47
+ # or the source's removal proves nothing went out, however
48
+ # database-shaped it is.
49
+ sending = [nil]
50
+ begin
51
+ outcome = resend_holding_slot(payload, lock_queue, drop_source, sending, headers, &)
52
+ rescue StandardError => e
53
+ give_back_resend_lock(key, stamp, e, sending: sending.first) if key
54
+ raise
55
+ end
56
+
57
+ case outcome
58
+ when :refused
59
+ give_back_resend_lock(key, stamp) if key
60
+ false
61
+ when :parked
62
+ # Unbound, as a parked enqueue's row is: the promoted copy's
63
+ # release takes it (UniquenessKey.release_held_by!).
64
+ :parked
65
+ else
66
+ bind_resent_lock(key, lock_queue, outcome, stamp) if key
67
+ :sent
68
+ end
69
+ ensure
70
+ UniquenessKey.clear_bind_stamp!(key) if key
71
+ end
72
+
73
+ # A failed job's message back to work: its vt reset to 0 while it is
74
+ # still queued (a second copy would run beside it; a failed attempt
75
+ # keeps its concurrency slot, so it needs no other), else its payload
76
+ # re-sent or parked. The failed row goes either way; false (row kept)
77
+ # while the re-send's key is held or its limit refuses it.
78
+ def retry_failed_job(event)
79
+ physical = failed_message_queue(event)
80
+ return resend_failed_event(event) unless physical
81
+
82
+ @client.set_visibility_timeout(physical, event["msg_id"].to_i, vt: 0, prefixed: false)
83
+ delete_failed_row(event)
84
+ true
85
+ end
86
+
87
+ # A failed job's payload as a new message on its queue, its failed row
88
+ # deleted once it is sent or parked. Its message is gone, so it can
89
+ # hold only a row still bound to it (nil send time: no unbound row is
90
+ # its own). The delete comes after the key is bound: PGMQ sends on its
91
+ # own connection, so a failed delete cannot undo the send, and must
92
+ # not give back the key of a live copy.
93
+ def resend_failed_event(event) # rubocop:disable Naming/PredicateMethod
94
+ payload = JSON.parse(event["payload"])
95
+ headers = event["headers"]
96
+ headers = JSON.parse(headers) if headers.is_a?(String)
97
+ lock_queue = Uniqueness.lock_queue_name(event["queue_name"])
98
+
99
+ outcome = resend_job(payload, lock_queue: lock_queue, holder: [lock_queue, event["msg_id"].to_i, nil],
100
+ drop_source: -> { delete_failed_row(event) }, headers: headers) do
101
+ @client.send_message(event["queue_name"], payload, headers: headers)
102
+ end
103
+ delete_failed_row(event) if outcome == :sent
104
+ outcome != false
105
+ end
106
+
107
+ def delete_failed_row(event)
108
+ connection.exec_delete(
109
+ "DELETE FROM pgbus_failed_events WHERE id = $1", "Pgbus Delete Failed Event", [event["id"].to_i]
110
+ )
111
+ end
112
+
113
+ # The key for the copy: the row the source message still holds is
114
+ # taken over (it went without releasing it — a release that failed,
115
+ # or a failed job whose message is gone), as a retry takes its
116
+ # attempt's (Adapter#hand_lock_to_retry); else a free key is
117
+ # acquired. Returns the row's created_at stamp (bind! and the
118
+ # give-back match the row by it), nil when another copy holds the
119
+ # key. A newer copy parked after the source released its row was
120
+ # acquired after the source was sent, so the takeover never matches it.
121
+ def take_resend_lock(key, lock_queue, holder)
122
+ from_queue, from_msg_id, from_enqueued_at = holder
123
+ stamp = UniquenessKey.unbind!(key, from_queue_name: from_queue, from_msg_id: from_msg_id,
124
+ from_enqueued_at: from_enqueued_at, queue_name: lock_queue)
125
+ return stamp if stamp
126
+ return unless UniquenessKey.acquire!(key, queue_name: lock_queue, msg_id: 0)
127
+
128
+ Thread.current[:pgbus_uniqueness_created_at]&.[](key)
129
+ end
130
+
131
+ # The copy did not go out (the limit refused it), or the send raised.
132
+ # Proven unsent, the row taken for it goes (matched by its stamp, so
133
+ # never a successor's). An error from the produce that may hide a
134
+ # committed send (a lost connection or reply, as
135
+ # Adapter#ambiguous_delivery? counts it), or any error from the park's
136
+ # commit, keeps the row: the copy may be live, and the reaper takes
137
+ # the row if it is not. A failed give-back is logged, so the send's
138
+ # error is the one that surfaces.
139
+ def give_back_resend_lock(key, stamp, error = nil, sending: nil)
140
+ if sending == :park || (sending == :produce && ambiguous_resend?(error))
141
+ Pgbus.logger.warn { "[Pgbus::Web] Re-send of #{key} may have been sent (#{error.class}); its key stays held" }
142
+ return
143
+ end
144
+
145
+ UniquenessKey.release_if_unbound!(key, acquired_before: stamp)
146
+ rescue StandardError => e
147
+ Pgbus.logger.warn { "[Pgbus::Web] Giving back the uniqueness key #{key} failed: #{e.message}" }
148
+ end
149
+
150
+ # ActiveRecord wraps a PG error (StatementInvalid, ConnectionFailed),
151
+ # so the cause is asked too.
152
+ def ambiguous_resend?(error)
153
+ [error, error&.cause].compact.any? do |err|
154
+ (defined?(PGMQ::Errors::ConnectionError) && err.is_a?(PGMQ::Errors::ConnectionError)) ||
155
+ (defined?(PG::Error) && err.is_a?(PG::Error))
156
+ end
157
+ end
158
+
159
+ # The key a re-sent copy has to hold: only an enqueue-locked one. An
160
+ # :until_executing payload with executions > 0 is a retry_on
161
+ # re-enqueue, which went out with no row and whose copy releases none
162
+ # (Executor#holds_uniqueness_row?): a row taken for it would never go.
163
+ def resend_lock_key(payload)
164
+ return unless payload.is_a?(Hash)
165
+
166
+ key = Uniqueness.extract_key(payload)
167
+ strategy = Uniqueness.extract_strategy(payload)
168
+ return unless key && Uniqueness::ENQUEUE_LOCK_STRATEGIES.include?(strategy)
169
+ return if strategy == :until_executing && payload["executions"].to_i.positive?
170
+
171
+ key.to_s
172
+ end
173
+
174
+ # The copy is live: a failed bind is logged, not raised. The row stays
175
+ # unbound and stamped before the send, so the copy's own release
176
+ # still takes it.
177
+ def bind_resent_lock(key, lock_queue, msg_id, stamp)
178
+ UniquenessKey.bind!(key, queue_name: lock_queue, msg_id: msg_id.to_i, acquired_at: stamp)
179
+ rescue StandardError => e
180
+ Pgbus.logger.warn { "[Pgbus::Web] Uniqueness bind of re-sent #{key} failed: #{e.message}" }
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ class DataSource
6
+ # The limits_concurrency half of a re-send (issue #636; Resend#resend_job
7
+ # is the whole). A re-sent copy signals its key's semaphore when it
8
+ # finishes, so it has to hold a slot first, taken the way
9
+ # Adapter#enqueue_with_concurrency takes one: the check and the park
10
+ # commit together under the semaphore row lock (rails/solid_queue#712),
11
+ # and the send follows the commit.
12
+ module ResendSlot
13
+ private
14
+
15
+ # Returns the block's msg_id once the copy is sent holding a slot,
16
+ # :parked when it waits behind a full limit (on_conflict: :block), and
17
+ # :refused when the limit is full under :discard or :raise. A payload
18
+ # with no concurrency key is sent as it is. +sending+ is :produce
19
+ # around the produce and :park around the park's commit, so the
20
+ # caller can tell an error that may hide a live copy from one that
21
+ # proves nothing went out.
22
+ def resend_holding_slot(payload, lock_queue, drop_source, sending, headers, &)
23
+ key = payload.is_a?(Hash) && Concurrency.extract_key(payload)
24
+ unless key
25
+ sending[0] = :produce
26
+ return yield
27
+ end
28
+
29
+ limits = resend_limits(payload)
30
+ case (taken = take_resend_slot(key, limits, lock_queue, park_payload(payload, headers), sending))
31
+ when :acquired then send_holding_resend_slot(key, sending, &)
32
+ when :full
33
+ Pgbus.logger.warn do
34
+ "[Pgbus::Web] Not re-sending #{payload["job_class"]}: concurrency limit for #{key} is full " \
35
+ "(on_conflict: :#{limits[:on_conflict]})"
36
+ end
37
+ :refused
38
+ else drop_parked_source(taken, key, drop_source)
39
+ end
40
+ end
41
+
42
+ # The limits live on the job class, not in the payload. A class that
43
+ # no longer declares them, or no longer resolves, is judged the way a
44
+ # promotion judges its parked job (Concurrency.config_for): against
45
+ # the limit the semaphore row records, parked when that is full.
46
+ def resend_limits(payload)
47
+ klass = Object.const_get(payload["job_class"].to_s)
48
+ config = klass.respond_to?(:pgbus_concurrency) && klass.pgbus_concurrency
49
+ config || { limit: nil, duration: Concurrency::DEFAULT_DURATION, on_conflict: :block }
50
+ rescue NameError
51
+ { limit: nil, duration: Concurrency::DEFAULT_DURATION, on_conflict: :block }
52
+ end
53
+
54
+ # :acquired, :full, or the id of the BlockedExecution the copy was
55
+ # parked as. The copy is sent right away, so its lease is the
56
+ # duration alone (Adapter#slot_lease with no delay). Parked on the
57
+ # logical queue at the job's own priority (the serialized "priority",
58
+ # else the default, as Adapter#handle_conflict parks it): promote_next
59
+ # sends it through Client#send_message, which prefixes the name and
60
+ # picks the _pN sub-queue by that priority. Once the park is inserted
61
+ # only its commit remains, and a COMMIT whose reply is lost may have
62
+ # landed: +sending+ says so until the transaction returns.
63
+ def take_resend_slot(key, limits, lock_queue, payload, sending)
64
+ taken = Pgbus::Semaphore.transaction(requires_new: true) do
65
+ lease = Concurrency.effective_duration(limits[:duration])
66
+ next :acquired if Concurrency::Semaphore.acquire(key, limits[:limit], lease) == :acquired
67
+ next :full unless limits[:on_conflict] == :block
68
+
69
+ Concurrency::BlockedExecution.insert(
70
+ concurrency_key: key, queue_name: lock_queue, payload: payload,
71
+ priority: payload["priority"] || Pgbus.configuration.default_priority, duration: limits[:duration]
72
+ ).rows.dig(0, 0).tap { sending[0] = :park }
73
+ end
74
+ sending[0] = nil
75
+ taken
76
+ end
77
+
78
+ # pgbus_blocked_executions has no headers column: the headers the
79
+ # copy would have been sent with (a DLQ retry's retries count) ride
80
+ # in the parked payload, and promote_next sends them as headers again
81
+ # (Concurrency::BlockedExecution::HEADERS_KEY).
82
+ def park_payload(payload, headers)
83
+ return payload if headers.nil? || headers.empty?
84
+
85
+ payload.merge(Concurrency::BlockedExecution::HEADERS_KEY => headers)
86
+ end
87
+
88
+ # Hand the slot back only when the failure proves nothing was
89
+ # produced (Adapter#send_holding_slot): on an ambiguous outcome the
90
+ # copy may be live, so the hold stands and the lease expiry plus the
91
+ # dispatcher's sweep reclaim it.
92
+ def send_holding_resend_slot(key, sending)
93
+ sending[0] = :produce
94
+ yield
95
+ rescue StandardError => e
96
+ if ambiguous_resend?(e)
97
+ Pgbus.logger.warn { "[Pgbus::Web] Re-send of #{key} may have been sent (#{e.class}); its concurrency slot stays held" }
98
+ else
99
+ begin
100
+ Concurrency::Semaphore.release(key)
101
+ rescue StandardError => release_error
102
+ Pgbus.logger.warn { "[Pgbus::Web] Giving back the concurrency slot of #{key} failed: #{release_error.message}" }
103
+ end
104
+ end
105
+ raise
106
+ end
107
+
108
+ # The park has committed; the source goes next (it lives on another
109
+ # connection, or after the commit, so the two cannot share a
110
+ # transaction). If that fails, the park is withdrawn while it still
111
+ # waits, and the error raised: nothing changed, the source stays. A
112
+ # removal whose outcome is unknown (a lost reply: its DELETE may have
113
+ # committed) keeps the park, since withdrawing it could leave neither
114
+ # copy; so does a park that cannot be withdrawn (already promoted: a
115
+ # live copy). Either way the source may stay, for the operator to
116
+ # discard: a duplicate is preferred over a lost job.
117
+ def drop_parked_source(park_id, key, drop_source)
118
+ drop_source.call
119
+ :parked
120
+ rescue StandardError => e
121
+ raise if !ambiguous_resend?(e) && withdraw_park(park_id)
122
+
123
+ Pgbus.logger.warn do
124
+ "[Pgbus::Web] Re-sent copy of #{key} is parked, but removing its source failed (#{e.class}: #{e.message}); " \
125
+ "the parked copy stands, and the source may stay to be discarded"
126
+ end
127
+ :parked
128
+ end
129
+
130
+ def withdraw_park(park_id)
131
+ Pgbus::BlockedExecution.where(id: park_id).delete_all.positive?
132
+ rescue StandardError => e
133
+ Pgbus.logger.warn { "[Pgbus::Web] Withdrawing the parked re-send #{park_id} failed: #{e.message}" }
134
+ false
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -7,6 +7,8 @@ module Pgbus
7
7
  class DataSource
8
8
  include JobList
9
9
  include DeadLetter
10
+ include Resend
11
+ include ResendSlot
10
12
  include ListCounts
11
13
  include QueueSummary
12
14
  include EventList
@@ -175,9 +177,17 @@ module Pgbus
175
177
  @client.purge_queue(name, prefixed: false)
176
178
  end
177
179
 
180
+ # The messages' uniqueness locks are released only once the table is
181
+ # gone: their holders are read just before the drop (the client runs
182
+ # the block only when it drops the table, not for a mixed-case
183
+ # duplicate from before issue #633, whose table and messages stay with
184
+ # the lower-case queue) and released after it succeeds, so a failed
185
+ # drop leaves both messages and locks.
178
186
  def drop_queue(name)
179
- release_uniqueness_keys_for_queue(name)
180
- @client.drop_queue(name, prefixed: false)
187
+ holders = nil
188
+ result = @client.drop_queue(name, prefixed: false) { holders = queue_lock_holders(name) }
189
+ release_lock_holders(holders) if holders
190
+ result
181
191
  end
182
192
 
183
193
  def pause_queue(name, reason: nil)
@@ -299,20 +309,7 @@ module Pgbus
299
309
  # duplicate (the original is still in the queue waiting for retry).
300
310
  # Falls back to enqueueing a fresh copy only if the original is gone
301
311
  # (e.g., already moved to DLQ).
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
- else
306
- payload = JSON.parse(event["payload"])
307
- headers = event["headers"]
308
- headers = JSON.parse(headers) if headers.is_a?(String)
309
- @client.send_message(event["queue_name"], payload, headers: headers)
310
- end
311
-
312
- connection.exec_delete(
313
- "DELETE FROM pgbus_failed_events WHERE id = $1", "Pgbus Delete Failed Event", [id.to_i]
314
- )
315
- true
312
+ retry_failed_job(event)
316
313
  rescue StandardError => e
317
314
  Pgbus.logger.debug { "[Pgbus::Web] Error retrying failed event #{id}: #{e.message}" }
318
315
  false
@@ -336,27 +333,24 @@ module Pgbus
336
333
 
337
334
  # Failed jobs only, like discard_all_failed: an event's message is still
338
335
  # in its handler queue, so a re-sent copy would run the handler twice.
336
+ # Each job goes back the way Retry sends it (#retry_failed_job): a
337
+ # message still queued gets its vt reset, one that is gone is re-sent.
338
+ # A row it cannot retry (the re-send's key is held, its concurrency
339
+ # limit refuses it, or a call fails) stays, and the walk goes on past it
340
+ # by id.
339
341
  def retry_all_failed
340
342
  count = 0
343
+ last_id = 0
341
344
  loop do
342
345
  batch = connection.select_all(
343
- "SELECT * FROM pgbus_failed_events WHERE #{NOT_HANDLER_QUEUE} ORDER BY id LIMIT 100", "Pgbus Retry Batch",
344
- [handler_queue_names_literal]
346
+ "SELECT * FROM pgbus_failed_events WHERE #{NOT_HANDLER_QUEUE} AND id > $2 ORDER BY id LIMIT 100",
347
+ "Pgbus Retry Batch", [handler_queue_names_literal, last_id]
345
348
  ).to_a
346
349
  break if batch.empty?
347
350
 
348
351
  batch.each do |event|
349
- payload = JSON.parse(event["payload"])
350
- headers = event["headers"]
351
- headers = JSON.parse(headers) if headers.is_a?(String)
352
-
353
- connection.transaction do
354
- @client.send_message(event["queue_name"], payload, headers: headers)
355
- connection.exec_delete(
356
- "DELETE FROM pgbus_failed_events WHERE id = $1", "Pgbus Delete Failed Event", [event["id"].to_i]
357
- )
358
- end
359
- count += 1
352
+ last_id = event["id"].to_i
353
+ count += 1 if retry_failed_job(event)
360
354
  rescue StandardError => e
361
355
  Pgbus.logger.error { "[Pgbus::Web] Failed to retry event #{event["id"]}: #{e.message}" }
362
356
  end
@@ -634,9 +628,10 @@ module Pgbus
634
628
  # Drop every job parked behind a key. The jobs never run, so the
635
629
  # bookkeeping they would have resolved has to be resolved here: a parked
636
630
  # batch child is marked failed (otherwise its batch waits forever and
637
- # on_failure never fires — issue #413) and an :until_executed uniqueness
638
- # lock is released (otherwise it is orphaned, since no executor will ever
639
- # release it — issue #423). Returns the number of jobs discarded.
631
+ # on_failure never fires — issue #413) and an :until_executed or
632
+ # :until_executing uniqueness lock is released (otherwise it is
633
+ # orphaned, since no executor will ever release it — issue #423).
634
+ # Returns the number of jobs discarded.
640
635
  def discard_parked_jobs(key)
641
636
  return 0 if key.to_s.strip.empty?
642
637
 
@@ -1162,11 +1157,17 @@ module Pgbus
1162
1157
  # one acquired after this row was parked.
1163
1158
  def release_parked_uniqueness_lock(payload, parked_at)
1164
1159
  key = payload[Uniqueness::METADATA_KEY]
1165
- return unless key && payload[Uniqueness::STRATEGY_KEY].to_s == "until_executed"
1160
+ return unless key && enqueue_locked_strategy?(payload[Uniqueness::STRATEGY_KEY])
1166
1161
 
1167
1162
  UniquenessKey.release_if_unbound!(key, acquired_before: parked_at)
1168
1163
  end
1169
1164
 
1165
+ # The strategies whose row is taken at enqueue and so is held by a parked
1166
+ # job: :until_executed, and :until_executing (issue #628).
1167
+ def enqueue_locked_strategy?(strategy)
1168
+ Uniqueness::ENQUEUE_LOCK_STRATEGIES.any? { |s| s.to_s == strategy.to_s }
1169
+ end
1170
+
1170
1171
  # A row parked before the double-encoding fix stores a jsonb *string*
1171
1172
  # holding the document — the same two-pass unwrap BlockedExecution
1172
1173
  # .release_next! does.
@@ -1613,10 +1614,19 @@ module Pgbus
1613
1614
  # Release the locks a collection of formatted messages hold, in a single
1614
1615
  # query.
1615
1616
  def release_locks_for_messages(messages)
1616
- holders = messages.filter_map do |m|
1617
+ release_lock_holders(lock_holders_for(messages))
1618
+ rescue StandardError => e
1619
+ Pgbus.logger.debug { "[Pgbus::Web] Error releasing locks for messages: #{e.message}" }
1620
+ end
1621
+
1622
+ def lock_holders_for(messages)
1623
+ messages.filter_map do |m|
1617
1624
  key = extract_uniqueness_key_from_payload_str(m[:message])
1618
1625
  [key, *lock_holder(m[:queue_name], m[:msg_id], m[:headers], m[:enqueued_at])] if key
1619
1626
  end
1627
+ end
1628
+
1629
+ def release_lock_holders(holders)
1620
1630
  UniquenessKey.release_all_held_by!(holders) if holders.any?
1621
1631
  rescue StandardError => e
1622
1632
  Pgbus.logger.debug { "[Pgbus::Web] Error releasing locks for messages: #{e.message}" }
@@ -1802,10 +1812,18 @@ module Pgbus
1802
1812
  # Release all uniqueness keys associated with a queue before purge/drop.
1803
1813
  # Scans queue messages for uniqueness metadata and deletes matching rows.
1804
1814
  def release_uniqueness_keys_for_queue(queue_name)
1805
- messages = query_queue_messages_raw(queue_name, 10_000, 0)
1806
- release_locks_for_messages(messages)
1815
+ holders = queue_lock_holders(queue_name)
1816
+ release_lock_holders(holders) if holders
1817
+ end
1818
+
1819
+ # The uniqueness-lock holders of a queue's messages, or nil when they
1820
+ # cannot be read (the purge or drop goes ahead; the reaper frees what
1821
+ # is left).
1822
+ def queue_lock_holders(queue_name)
1823
+ lock_holders_for(query_queue_messages_raw(queue_name, 10_000, 0))
1807
1824
  rescue StandardError => e
1808
- Pgbus.logger.debug { "[Pgbus::Web] Error releasing uniqueness keys for queue #{queue_name}: #{e.message}" }
1825
+ Pgbus.logger.debug { "[Pgbus::Web] Error reading uniqueness keys for queue #{queue_name}: #{e.message}" }
1826
+ nil
1809
1827
  end
1810
1828
  end
1811
1829
  end
@@ -45,6 +45,12 @@ module Pgbus
45
45
 
46
46
  def reason_for(block, attempts, max)
47
47
  error_class = block["error_class"]
48
+ # A :while_executing copy skipped on every read (issue #629): no
49
+ # attempt ran, so "crashed before it could report" would mislead.
50
+ if block["reason"] == DeadLetterHeader::REASON_UNIQUENESS_CONFLICT && !error_class
51
+ return ["uniqueness_conflict", { attempts: attempts, max: max }]
52
+ end
53
+
48
54
  unless error_class
49
55
  key = block["source"] == "consumer" ? "event_no_error" : "no_error_recorded"
50
56
  return [key, { attempts: attempts, max: max }]
@@ -62,21 +62,24 @@ module Pgbus
62
62
  # existing caller is unchanged. The Dispatcher overrides it with the
63
63
  # short streams_fanout_write_deadline_ms for hot-loop fanout writes,
64
64
  # bounding head-of-line blocking on a slow client (issue #315 item 3).
65
- def enqueue(envelopes, deadline_ms: @write_deadline_ms)
65
+ #
66
+ # frames: msg_id → the frozen SSE frame, built once per wake by the
67
+ # Dispatcher for every connection on the stream (issue #621). An
68
+ # envelope the Hash does not hold (or no Hash: the connect replay, an
69
+ # in-flight buffer flush) is framed here, so the bytes are the same
70
+ # either way.
71
+ def enqueue(envelopes, deadline_ms: @write_deadline_ms, frames: nil)
66
72
  written = []
67
73
  envelopes.each do |envelope|
68
- ephemeral = envelope.msg_id.negative?
69
- next if !ephemeral && envelope.msg_id <= @last_msg_id_sent.get
74
+ msg_id = envelope.msg_id
75
+ ephemeral = msg_id.negative?
76
+ next if !ephemeral && msg_id <= @last_msg_id_sent.get
70
77
 
71
- bytes = Pgbus::Streams::Envelope.message(
72
- id: envelope.msg_id,
73
- event: sse_event_for(envelope),
74
- data: envelope.payload
75
- )
78
+ bytes = frames&.[](msg_id) || frame_for(envelope)
76
79
 
77
80
  result = @writer.write(self, bytes, deadline_ms: deadline_ms)
78
81
  if result == :ok
79
- @last_msg_id_sent.set(envelope.msg_id) unless ephemeral
82
+ @last_msg_id_sent.set(msg_id) unless ephemeral
80
83
  @last_write_at = monotonic
81
84
  written << envelope
82
85
  else
@@ -88,11 +91,26 @@ module Pgbus
88
91
  end
89
92
 
90
93
  def write_comment(text)
91
- write_frame(Pgbus::Streams::Envelope.comment(text))
94
+ write_raw(Pgbus::Streams::Envelope.comment(text))
95
+ end
96
+
97
+ # The one raw writer: bytes that are already a whole SSE frame (the
98
+ # heartbeat's shared comment, the shutdown sentinel). A successful
99
+ # write counts as activity for the idle reaper; a failed one marks the
100
+ # connection dead. The Heartbeat passes the short fan-out deadline
101
+ # (issue #621); everything else keeps the connection's own.
102
+ def write_raw(bytes, deadline_ms: @write_deadline_ms)
103
+ result = @writer.write(self, bytes, deadline_ms: deadline_ms)
104
+ if result == :ok
105
+ @last_write_at = monotonic
106
+ else
107
+ mark_dead!
108
+ end
109
+ result
92
110
  end
93
111
 
94
112
  def write_sentinel(bytes)
95
- @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
113
+ write_raw(bytes)
96
114
  end
97
115
 
98
116
  # The pgbus:connected frame carrying this connection's id. Written by
@@ -100,7 +118,7 @@ module Pgbus
100
118
  # seat joined (issue #521), so a client that acts on the id right
101
119
  # after the event finds its row.
102
120
  def write_connected
103
- write_frame(Pgbus::Streams::Envelope.connected(id: @id))
121
+ write_raw(Pgbus::Streams::Envelope.connected(id: @id))
104
122
  end
105
123
 
106
124
  def idle_for
@@ -143,23 +161,12 @@ module Pgbus
143
161
 
144
162
  private
145
163
 
146
- def write_frame(bytes)
147
- result = @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
148
- if result == :ok
149
- @last_write_at = monotonic
150
- else
151
- mark_dead!
152
- end
153
- result
154
- end
155
-
156
- # The SSE event name for a frame: the envelope's typed event when
157
- # present, else the default turbo-stream. Plain ReadAfter::Envelopes
158
- # (no event field) and StreamEnvelopes with a nil event both fall
159
- # back to the default. (issue #170)
160
- def sse_event_for(envelope)
161
- event = envelope.respond_to?(:event) ? envelope.event : nil
162
- event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
164
+ def frame_for(envelope)
165
+ Pgbus::Streams::Envelope.message(
166
+ id: envelope.msg_id,
167
+ event: Pgbus::Streams::Envelope.sse_event_for(envelope),
168
+ data: envelope.payload
169
+ )
163
170
  end
164
171
 
165
172
  def monotonic