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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module Pgbus
4
6
  module EventBus
5
7
  class Handler
@@ -85,11 +85,14 @@ module Pgbus
85
85
  # queue is dropped: PGMQ restarts msg_ids on recreate, so a surviving
86
86
  # row would aim the dashboard's retry/discard at an unrelated message.
87
87
  # Queue names are validated word characters, so the array literal is safe.
88
+ # Matched in any case (issue #633): the dropped queue's name is lower
89
+ # case, while a row may carry the executor's configured spelling
90
+ # ("Critical") or a physical name from before #633.
88
91
  def clear_queue!(queue_names)
89
92
  connection.exec_delete(
90
- "DELETE FROM pgbus_failed_events WHERE queue_name = ANY($1::text[])",
93
+ "DELETE FROM pgbus_failed_events WHERE lower(queue_name) = ANY($1::text[])",
91
94
  "FailedEvent Clear Queue",
92
- ["{#{queue_names.join(",")}}"]
95
+ ["{#{queue_names.map(&:downcase).join(",")}}"]
93
96
  )
94
97
  rescue StandardError => e
95
98
  Pgbus.logger.error do
@@ -102,7 +102,8 @@ module Pgbus
102
102
  add_batch_executions: "pgbus:add_batch_executions",
103
103
  add_batch_callback_jobs: "pgbus:add_batch_callback_jobs",
104
104
  tune_autovacuum: "pgbus:tune_autovacuum",
105
- tune_fillfactor: "pgbus:tune_fillfactor"
105
+ tune_fillfactor: "pgbus:tune_fillfactor",
106
+ fold_queue_name_case: "pgbus:fold_queue_name_case"
106
107
  }.freeze
107
108
 
108
109
  # Human-friendly description of each migration for the generator
@@ -128,9 +129,15 @@ module Pgbus
128
129
  add_batch_executions: "batch execution-row tracking (self-healing completion, on_failure rename)",
129
130
  add_batch_callback_jobs: "batch callback-job columns (configured ActiveJob instances as batch callbacks)",
130
131
  tune_autovacuum: "autovacuum tuning for PGMQ queue and archive tables",
131
- tune_fillfactor: "fillfactor=70 on PGMQ queue tables (reduces page density during update churn)"
132
+ tune_fillfactor: "fillfactor=70 on PGMQ queue tables (reduces page density during update churn)",
133
+ fold_queue_name_case: "lower-case spelling for queues created in mixed case (PGMQ's table names are lower case)"
132
134
  }.freeze
133
135
 
136
+ # A pgbus pgmq.meta row ($1: the lower-case "<queue_prefix>_") spelled
137
+ # with an upper-case letter (issue #633).
138
+ MIXED_CASE_QUEUE_SQL = "SELECT 1 FROM pgmq.meta WHERE queue_name <> lower(queue_name) " \
139
+ "AND left(lower(queue_name), length($1)) = $1 LIMIT 1"
140
+
134
141
  def initialize(connection)
135
142
  @connection = connection
136
143
  end
@@ -143,6 +150,9 @@ module Pgbus
143
150
  return [FRESH_INSTALL] if fresh_install?
144
151
 
145
152
  [
153
+ # First: the tuning migrations below build table names from
154
+ # pgmq.meta, which a mixed-case row spells unlike its table.
155
+ *queue_name_case_migrations,
146
156
  *uniqueness_key_migrations,
147
157
  *job_stats_migrations,
148
158
  *stream_stats_migrations,
@@ -328,6 +338,23 @@ module Pgbus
328
338
  [:tune_autovacuum]
329
339
  end
330
340
 
341
+ # Issue #633: a queue an older pgbus created in mixed case shares its
342
+ # lower-case table with the spelling pgbus now uses. An unreadable
343
+ # pgmq.meta queues nothing: the doctor's "Queue name case" check says why.
344
+ def queue_name_case_migrations
345
+ return [] unless pgmq_schema_exists?
346
+ return [] unless mixed_case_queue?
347
+
348
+ [:fold_queue_name_case]
349
+ end
350
+
351
+ def mixed_case_queue?
352
+ prefix = "#{Pgbus.configuration.queue_prefix.to_s.downcase}_"
353
+ connection.select_value(MIXED_CASE_QUEUE_SQL.gsub("$1", connection.quote(prefix))).present?
354
+ rescue StandardError
355
+ false
356
+ end
357
+
331
358
  # Fillfactor tuning: check if any PGMQ queue table already has
332
359
  # fillfactor applied. If not, queue the migration.
333
360
  def fillfactor_migrations
@@ -16,7 +16,22 @@ module Pgbus
16
16
  # pgbus.job_completed — job archived successfully
17
17
  # pgbus.job_failed — job raised; carries :exception_object
18
18
  # pgbus.job_dead_lettered — job exceeded max_retries and was DLQ-routed;
19
- # :error is the last recorded error class (or nil)
19
+ # :error is the last recorded error class (or nil);
20
+ # :reason is max_retries_exceeded, or uniqueness_conflict
21
+ # for a :while_executing copy with no failed attempt whose
22
+ # key's row is not its own (skipped while another message
23
+ # held the key); also uniqueness_conflict when that row
24
+ # lookup itself fails
25
+ # pgbus.job_skipped — a :while_executing copy was read while another
26
+ # message held its key and left for a later delivery
27
+ # (on_conflict :reject or :log)
28
+ # payload: queue, job_class, job_id, provider_job_id,
29
+ # msg_id, read_ct, on_conflict, vt (seconds until it
30
+ # is delivered again)
31
+ # pgbus.job_discarded — a copy was completed without running
32
+ # payload: queue, job_class, job_id, provider_job_id,
33
+ # msg_id, reason ("uniqueness": a :while_executing copy
34
+ # under on_conflict: :discard)
20
35
  # pgbus.job_visibility_extended — heartbeat re-armed a running job's visibility timeout
21
36
  # payload: queue, job_class, msg_id, vt, extensions
22
37
  # pgbus.event_processed — event handler succeeded
@@ -60,6 +60,8 @@ module Pgbus
60
60
  subscribe("pgbus.job_completed") { |event| on_job_completed(event) },
61
61
  subscribe("pgbus.job_failed") { |event| on_job_failed(event) },
62
62
  subscribe("pgbus.job_dead_lettered") { |event| on_job_dead_lettered(event) },
63
+ subscribe("pgbus.job_skipped") { |event| on_job_skipped(event) },
64
+ subscribe("pgbus.job_discarded") { |event| on_job_discarded(event) },
63
65
  subscribe("pgbus.job_visibility_extended") { |event| on_job_visibility_extended(event) },
64
66
  subscribe("pgbus.event_processed") { |event| on_event_processed(event) },
65
67
  subscribe("pgbus.event_failed") { |event| on_event_failed(event) },
@@ -121,11 +123,35 @@ module Pgbus
121
123
  )
122
124
  end
123
125
 
126
+ # reason: max_retries_exceeded, or uniqueness_conflict for a
127
+ # :while_executing duplicate skipped until it ran out of reads (#629).
124
128
  def on_job_dead_lettered(event)
125
129
  payload = event.payload
126
130
  backend.increment(
127
131
  "#{METRIC_PREFIX}queue_job_count", 1,
128
- compact(queue: payload[:queue], job_class: payload[:job_class], status: "dead_lettered")
132
+ compact(queue: payload[:queue], job_class: payload[:job_class], status: "dead_lettered",
133
+ reason: payload[:reason])
134
+ )
135
+ end
136
+
137
+ # A :while_executing duplicate left for a later delivery because
138
+ # another message held its key (issue #629): counted per skip, so one
139
+ # copy waiting out a long holder counts several times.
140
+ def on_job_skipped(event)
141
+ payload = event.payload
142
+ backend.increment(
143
+ "#{METRIC_PREFIX}queue_job_count", 1,
144
+ compact(queue: payload[:queue], job_class: payload[:job_class], status: "skipped", reason: "uniqueness")
145
+ )
146
+ end
147
+
148
+ # A :while_executing duplicate dropped under on_conflict: :discard.
149
+ def on_job_discarded(event)
150
+ payload = event.payload
151
+ backend.increment(
152
+ "#{METRIC_PREFIX}queue_job_count", 1,
153
+ compact(queue: payload[:queue], job_class: payload[:job_class], status: "discarded",
154
+ reason: payload[:reason])
129
155
  )
130
156
  end
131
157
 
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Process
5
+ # Batched completion (issue #606, `config.completion_batching`): one per
6
+ # worker, it archives the messages of jobs that finish close together with
7
+ # one `archive_batch` per physical queue instead of one `pgmq.archive` each.
8
+ #
9
+ # There is no timer. An idle completer flushes a lone id at once; ids that
10
+ # arrive while a flush is in flight wait for the next one, so under load a
11
+ # batch is "what arrived during the previous round trip".
12
+ # `batch_size` only caps the ids per statement.
13
+ #
14
+ # The job thread waits for its own result (`complete` blocks), so the
15
+ # executor's post-archive sequence runs on it as before. Threads only: a
16
+ # blocking wait would stall an Async reactor (Configuration#validate!).
17
+ #
18
+ # completed_jobs: :delete (the config setting, issue #607) flushes with
19
+ # `delete_batch` instead; the results keep their names, so :archived
20
+ # means "completed" and :already_archived "already gone".
21
+ class Completer
22
+ # Raised on a waiting job thread whose flush can no longer happen: the
23
+ # flusher was stopped past its join budget, or died.
24
+ class Stopped < Pgbus::Error; end
25
+
26
+ # One job thread's wait for its id's result.
27
+ class Waiter
28
+ def initialize
29
+ @mutex = Mutex.new
30
+ @done = ConditionVariable.new
31
+ @state = nil
32
+ @value = nil
33
+ end
34
+
35
+ def resolve(value) = settle(:value, value)
36
+
37
+ def fail(error) = settle(:error, error)
38
+
39
+ def value
40
+ @mutex.synchronize do
41
+ @done.wait(@mutex) until @state
42
+ raise @value if @state == :error
43
+
44
+ @value
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # First settle wins: a waiter already served is not failed again by
51
+ # the flusher's last-resort cleanup.
52
+ def settle(state, value)
53
+ @mutex.synchronize do
54
+ next if @state
55
+
56
+ @state = state
57
+ @value = value
58
+ @done.broadcast
59
+ end
60
+ end
61
+ end
62
+
63
+ BATCH_OPS = { archive: :archive_batch, delete: :delete_batch }.freeze
64
+
65
+ def initialize(client:, batch_size:, completed_jobs: :archive)
66
+ @batch_op = BATCH_OPS.fetch(completed_jobs) do
67
+ raise ArgumentError, "completed_jobs must be :archive or :delete (got #{completed_jobs.inspect})"
68
+ end
69
+ @client = client
70
+ @batch_size = batch_size
71
+ @mutex = Mutex.new
72
+ @wake = ConditionVariable.new
73
+ @pending = {}
74
+ @taken = nil
75
+ @stopping = false
76
+ # Owned by this completer: one per worker, built with it. #stop asks
77
+ # it to drain and exit, then joins it (Worker#shutdown, #immediate_shutdown).
78
+ @thread = Thread.new { run } # rubocop:disable ThreadSafety/NewThread
79
+ @thread.name = "pgbus-completer"
80
+ end
81
+
82
+ # Archives msg_id from the physical (already prefixed) queue and returns
83
+ # :archived, or :already_archived when the message was gone (another
84
+ # worker archived it). Raises the flush's exception on this thread.
85
+ def complete(queue, msg_id)
86
+ waiter = @mutex.synchronize do
87
+ next if @stopping
88
+
89
+ entry = Waiter.new
90
+ (@pending[queue] ||= []) << [msg_id, entry]
91
+ @wake.signal
92
+ entry
93
+ end
94
+ return waiter.value if waiter
95
+
96
+ # Stopped: complete on the caller's thread, with the same results.
97
+ done = @client.public_send(@batch_op, queue, [msg_id], prefixed: false, job_completion: true)
98
+ done.empty? ? :already_archived : :archived
99
+ end
100
+
101
+ # Flushes whatever is pending, then joins the flusher. Past timeout the
102
+ # flusher is killed and every waiter it had not served raises Stopped.
103
+ # Later `complete` calls archive on their own thread. Idempotent.
104
+ def stop(timeout: nil)
105
+ @mutex.synchronize do
106
+ @stopping = true
107
+ @wake.signal
108
+ end
109
+ return if @thread.join(timeout)
110
+
111
+ @thread.kill
112
+ @thread.join
113
+ end
114
+
115
+ def alive? = @thread.alive?
116
+
117
+ # Ids waiting for the next flush (test and introspection seam).
118
+ def pending_count
119
+ @mutex.synchronize { @pending.sum { |_queue, entries| entries.size } }
120
+ end
121
+
122
+ # True while a flush is running (test and introspection seam).
123
+ def flushing?
124
+ @mutex.synchronize { !@taken.nil? }
125
+ end
126
+
127
+ private
128
+
129
+ def run
130
+ while (batch = take)
131
+ flush(batch)
132
+ @mutex.synchronize { @taken = nil }
133
+ end
134
+ ensure
135
+ fail_unserved
136
+ end
137
+
138
+ # Waits for pending ids and takes all of them; nil once stopping with
139
+ # nothing left.
140
+ def take
141
+ @mutex.synchronize do
142
+ @wake.wait(@mutex) while @pending.empty? && !@stopping
143
+ next if @pending.empty?
144
+
145
+ @taken = @pending
146
+ @pending = {}
147
+ @taken
148
+ end
149
+ end
150
+
151
+ def flush(batch)
152
+ batch.each do |queue, entries|
153
+ entries.each_slice(@batch_size) { |slice| flush_slice(queue, slice) }
154
+ end
155
+ end
156
+
157
+ # pgmq returns one row per archived id (as a String); an id already gone
158
+ # is absent. A duplicated id (the same message held by two threads after
159
+ # a redelivery) is archived once, so only its first waiter gets :archived.
160
+ def flush_slice(queue, slice)
161
+ archived = @client.public_send(@batch_op, queue, slice.map(&:first).uniq, prefixed: false, job_completion: true)
162
+ .to_set(&:to_i)
163
+ slice.each { |msg_id, waiter| waiter.resolve(archived.delete?(msg_id) ? :archived : :already_archived) }
164
+ rescue Exception => e # rubocop:disable Lint/RescueException -- delivered to the waiters, never swallowed
165
+ log_flush_failure(queue, slice, e) unless e.is_a?(StandardError)
166
+ slice.map(&:last).each { |waiter| waiter.fail(e) }
167
+ end
168
+
169
+ def log_flush_failure(queue, slice, error)
170
+ Pgbus.logger.error do
171
+ "[Pgbus::Completer] #{@batch_op} on #{queue} (#{slice.size} ids) raised #{error.class}: #{error.message}"
172
+ end
173
+ end
174
+
175
+ # The flusher is exiting: nothing may be left waiting on it.
176
+ def fail_unserved
177
+ leftovers = @mutex.synchronize do
178
+ entries = [@taken, @pending].compact.flat_map(&:values).flatten(1)
179
+ @taken = nil
180
+ @pending = {}
181
+ @stopping = true
182
+ entries
183
+ end
184
+ error = Stopped.new("the completer stopped before archiving this message")
185
+ leftovers.map(&:last).each { |waiter| waiter.fail(error) }
186
+ end
187
+ end
188
+ end
189
+ end
@@ -264,7 +264,7 @@ module Pgbus
264
264
  dispatch(handlers, message, queue_name)
265
265
  end
266
266
 
267
- Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
267
+ archive_handled(queue_name, message)
268
268
  # A redelivery that succeeds leaves no stale failure behind. First
269
269
  # deliveries never had one: no query on the hot path (#484's gate).
270
270
  if message.read_ct.to_i > 1
@@ -367,6 +367,20 @@ module Pgbus
367
367
  # as retrying with its error. No ErrorReporter call: Handler#process!
368
368
  # already instruments pgbus.event_failed, which feeds the reporters.
369
369
  # A handler queue has no priority sub-queues: its one table is the source.
370
+ # The handlers ran. An archive whose reply was lost after it may have
371
+ # committed, and whose re-run found the message gone (issue #607
372
+ # review), leaves nothing to retry: the message is handled.
373
+ def archive_handled(queue_name, message)
374
+ Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
375
+ rescue StandardError => e # pgmq loads lazily: completion_lost? checks the class
376
+ raise unless Client::NativeStatements.completion_lost?(e)
377
+
378
+ Pgbus.logger.warn do
379
+ "[Pgbus] Consumer archive of #{message.msg_id} on #{queue_name} lost its reply and the message is gone: " \
380
+ "counted as handled (#{e.message})"
381
+ end
382
+ end
383
+
370
384
  def record_failed_event(message, queue_name, error)
371
385
  FailedEventRecorder.record!(
372
386
  queue_name: queue_name, msg_id: message.msg_id.to_i, source_queue: config.queue_name(queue_name),
@@ -62,6 +62,12 @@ module Pgbus
62
62
  "fair_share and group_mode are mutually exclusive — this worker has group_mode " \
63
63
  "#{(@group_mode || config.group_mode).inspect} while config.fair_share is set"
64
64
  end
65
+ # The completer blocks a job thread on its flush, which would stall an
66
+ # Async reactor (issue #606); the mode can arrive here without validate!.
67
+ if config.completion_batching && @execution_mode == :async
68
+ raise Pgbus::ConfigurationError,
69
+ "completion_batching supports execution_mode :threads only; this worker runs :async"
70
+ end
65
71
  @single_active_consumer = single_active_consumer
66
72
  @consumer_priority = consumer_priority
67
73
  @lifecycle = Lifecycle.new
@@ -94,7 +100,16 @@ module Pgbus
94
100
  else
95
101
  stat_buffer
96
102
  end
97
- @executor = Pgbus::ActiveJob::Executor.new(stat_buffer: @stat_buffer)
103
+ # One per worker when config.completion_batching (issue #606): archives
104
+ # the jobs that finish close together in one archive_batch, or deletes
105
+ # them with delete_batch under config.completed_jobs = :delete (#607).
106
+ @completer = if config.completion_batching
107
+ Completer.new(client: Pgbus.client, batch_size: config.completion_batch_size,
108
+ completed_jobs: config.completed_jobs)
109
+ end
110
+ # The worker's config, so the executor and the completer read one
111
+ # completed_jobs (issue #607).
112
+ @executor = Pgbus::ActiveJob::Executor.new(config: config, stat_buffer: @stat_buffer, completer: @completer)
98
113
  @wake_signal = wake_signal || WakeSignal.new
99
114
  @pool = ExecutionPools.build(
100
115
  mode: @execution_mode,
@@ -179,6 +194,7 @@ module Pgbus
179
194
  POOL_TERMINATION_WAIT = 5
180
195
 
181
196
  def run
197
+ quiet_active_job_logs
182
198
  setup_signals
183
199
  start_heartbeat
184
200
  resolve_wildcard_queues
@@ -239,6 +255,13 @@ module Pgbus
239
255
  Pgbus.stopping = true
240
256
  @lifecycle.transition_to!(:stopped)
241
257
  @wake_signal.notify!
258
+ # Before the pool: a job thread killed while it waits on a flush would
259
+ # skip its signals while the flush still commits (archived, never
260
+ # signalled). Stopping first resolves every waiter; a job that finishes
261
+ # inside the grace then archives on its own thread. Bounded by the same
262
+ # grace: a flush wedged on a hung Postgres is abandoned, and its jobs
263
+ # fail with Completer::Stopped and re-run after visibility_timeout.
264
+ @completer&.stop(timeout: config.immediate_shutdown_grace)
242
265
  ExecutionPools.terminate(@pool, grace: config.immediate_shutdown_grace)
243
266
  end
244
267
 
@@ -260,6 +283,18 @@ module Pgbus
260
283
 
261
284
  private
262
285
 
286
+ # config.quiet_active_job_logs (issue #618): only worker processes perform
287
+ # jobs, so only they drop the per-job "Performing …" / "Performed …"
288
+ # lines. ActiveJob::LogSubscriber attaches when active_job/base loads,
289
+ # which an app with no job class to eager-load has not done yet, so load
290
+ # it first (defined? does not trigger ActiveJob's autoload of Base).
291
+ def quiet_active_job_logs
292
+ return unless config.quiet_active_job_logs && defined?(::ActiveJob::Base)
293
+
294
+ require "active_job/base"
295
+ Pgbus::ActiveJob::QuietLogs.apply!
296
+ end
297
+
263
298
  # One loop step (issue #486): feed buffered claims to free slots, read
264
299
  # the deficit (free slots + read_ahead - buffered, capped by
265
300
  # prefetch_limit), feed again. With read_ahead 0 the buffer never
@@ -488,11 +523,18 @@ module Pgbus
488
523
  def process_message(message, queue_name, source_queue: nil)
489
524
  result = @executor.execute(message, queue_name, source_queue: source_queue)
490
525
  @jobs_processed.increment
491
- @rate_counter.increment(:processed)
492
- if result == :failed
526
+ # A skipped uniqueness duplicate comes back later: counting it now
527
+ # would count one job twice (issue #629).
528
+ @rate_counter.increment(:processed) unless result == :skipped
529
+ case result
530
+ when :failed
493
531
  @jobs_failed.increment
494
532
  @rate_counter.increment(:failed)
495
533
  @circuit_breaker.record_failure(queue_name)
534
+ when :skipped, :discarded
535
+ # No job ran: neither a failure nor a success that would wipe the
536
+ # failure streak of a queue whose real jobs keep failing.
537
+ nil
496
538
  else
497
539
  @circuit_breaker.record_success(queue_name)
498
540
  end
@@ -547,7 +589,12 @@ module Pgbus
547
589
  prefix = "#{config.queue_prefix}_"
548
590
  if (match = MISSING_QUEUE_REGEX.match(error.message))
549
591
  physical_name = match[1]
550
- logical_name = physical_name.delete_prefix(prefix)
592
+ # Postgres names the table as PGMQ spells it (lower case, issue #633;
593
+ # separators normalized), so find the queue as configured.
594
+ # A priority sub-queue (_pN) belongs to its base queue.
595
+ logical_name = @queues.find { |q| config.priority_queue_names(q).include?(physical_name) } ||
596
+ @queues.find { |q| config.queue_name(q) == physical_name } ||
597
+ physical_name.delete_prefix(prefix)
551
598
  if @queues.delete(logical_name)
552
599
  Pgbus.logger.warn { "[Pgbus] Evicted deleted queue '#{logical_name}' (#{physical_name}) from worker" }
553
600
  end
@@ -927,7 +974,13 @@ module Pgbus
927
974
  # config.drain_timeout for in-flight jobs. A job still running here has
928
975
  # proven it won't finish; waiting another full window would push the
929
976
  # worker past the supervisor's shutdown_timeout deadline (issue #386).
977
+ residual_deadline = monotonic_now + POOL_TERMINATION_WAIT
930
978
  @pool.wait_for_termination(POOL_TERMINATION_WAIT)
979
+ # After the pool: every waiter is a job thread, so the drain already
980
+ # flushed theirs; this flushes any left and joins the flusher. It shares
981
+ # the pool's residual budget rather than adding its own, so shutdown
982
+ # stays inside the supervisor's drain_timeout + 5 default deadline.
983
+ @completer&.stop(timeout: [residual_deadline - monotonic_now, 0].max)
931
984
  VisibilityHeartbeat.stop
932
985
  @stat_buffer&.stop
933
986
  @queue_lock&.unlock_all
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Issue #633 upgrade path. PGMQ lower-cases its table names
5
+ # (pgmq.format_table_name), so a queue an older pgbus created as
6
+ # "pgbus_Orders" lives in pgmq.q_pgbus_orders while pgmq.meta records
7
+ # "pgbus_Orders". pgbus now spells every queue in lower case; this renames
8
+ # the old rows to match their tables.
9
+ #
10
+ # Only rows change. The queue tables are shared by both spellings, so nothing
11
+ # here drops, renames or copies a table: dropping either spelling's queue
12
+ # through PGMQ would remove the shared table.
13
+ #
14
+ # Run by the pgbus:fold_queue_name_case migration and by
15
+ # `rake pgbus:queues:fold_case` (for a row an older process recreated after
16
+ # the migration ran). Idempotent. Takes an ActiveRecord connection and
17
+ # folds pgbus's queues only (those named "<prefix>_…" in any case), so
18
+ # another PGMQ user of the same schema keeps its spelling and its NOTIFY
19
+ # channel.
20
+ module QueueNameFold
21
+ module_function
22
+
23
+ def run!(connection, prefix: Pgbus.configuration.queue_prefix)
24
+ fold_pgmq_meta(connection, "#{prefix.to_s.downcase}_")
25
+ fold_unique_column(connection, :pgbus_stream_queues, prefer: "")
26
+ fold_unique_column(connection, :pgbus_queue_states, prefer: "paused DESC,")
27
+ fold_column(connection, :pgbus_uniqueness_keys, :queue_name)
28
+ fold_failed_event_source_queues(connection)
29
+ end
30
+
31
+ # One mixed-case pgmq.meta row at a time: add the lower-case row, move its
32
+ # topic bindings, delete the old row (and its throttle and binding rows),
33
+ # then re-arm the insert-NOTIFY trigger under the new name. PGMQ 1.13.1's
34
+ # trigger NOTIFYs on the channel of the name it was armed with, which
35
+ # listeners now spell in lower case. Where both spellings exist, the
36
+ # lower-case row's throttle wins.
37
+ def fold_pgmq_meta(connection, own_prefix)
38
+ prefix = connection.quote(own_prefix)
39
+ connection.execute(<<~SQL)
40
+ DO $$
41
+ DECLARE
42
+ q RECORD;
43
+ folded TEXT;
44
+ throttle INTEGER;
45
+ has_throttle BOOLEAN := to_regclass('pgmq.notify_insert_throttle') IS NOT NULL;
46
+ has_bindings BOOLEAN := to_regclass('pgmq.topic_bindings') IS NOT NULL;
47
+ BEGIN
48
+ FOR q IN
49
+ SELECT queue_name, is_partitioned, is_unlogged, created_at
50
+ FROM pgmq.meta
51
+ WHERE queue_name <> lower(queue_name)
52
+ AND left(lower(queue_name), length(#{prefix})) = #{prefix}
53
+ ORDER BY queue_name
54
+ LOOP
55
+ folded := lower(q.queue_name);
56
+
57
+ INSERT INTO pgmq.meta (queue_name, is_partitioned, is_unlogged, created_at)
58
+ VALUES (folded, q.is_partitioned, q.is_unlogged, q.created_at)
59
+ ON CONFLICT DO NOTHING;
60
+
61
+ throttle := NULL;
62
+ IF has_throttle THEN
63
+ SELECT COALESCE(
64
+ (SELECT throttle_interval_ms FROM pgmq.notify_insert_throttle WHERE queue_name = folded),
65
+ (SELECT throttle_interval_ms FROM pgmq.notify_insert_throttle WHERE queue_name = q.queue_name)
66
+ ) INTO throttle;
67
+ DELETE FROM pgmq.notify_insert_throttle WHERE queue_name = q.queue_name;
68
+ END IF;
69
+
70
+ IF has_bindings THEN
71
+ INSERT INTO pgmq.topic_bindings (pattern, queue_name, bound_at)
72
+ SELECT pattern, folded, bound_at FROM pgmq.topic_bindings WHERE queue_name = q.queue_name
73
+ ON CONFLICT DO NOTHING;
74
+ DELETE FROM pgmq.topic_bindings WHERE queue_name = q.queue_name;
75
+ END IF;
76
+
77
+ DELETE FROM pgmq.meta WHERE queue_name = q.queue_name;
78
+
79
+ IF throttle IS NOT NULL THEN
80
+ PERFORM pgmq.enable_notify_insert(folded, throttle);
81
+ END IF;
82
+ END LOOP;
83
+ END $$;
84
+ SQL
85
+ end
86
+
87
+ # A column with a unique index: keep one row per lower-case name (the
88
+ # `prefer` order first, then the row already in lower case, then the
89
+ # oldest), delete the rest, then lower-case what is left.
90
+ def fold_unique_column(connection, table, prefer:)
91
+ return unless connection.table_exists?(table)
92
+
93
+ connection.execute(<<~SQL)
94
+ DELETE FROM #{table} WHERE id IN (
95
+ SELECT id FROM (
96
+ SELECT id, row_number() OVER (
97
+ PARTITION BY lower(queue_name)
98
+ ORDER BY #{prefer} (queue_name = lower(queue_name)) DESC, id
99
+ ) AS rank
100
+ FROM #{table}
101
+ WHERE lower(queue_name) IN (SELECT lower(queue_name) FROM #{table} WHERE queue_name <> lower(queue_name))
102
+ ) ranked
103
+ WHERE rank > 1
104
+ )
105
+ SQL
106
+ fold_column(connection, table, :queue_name)
107
+ end
108
+
109
+ def fold_column(connection, table, column)
110
+ return unless connection.table_exists?(table) && connection.column_exists?(table, column)
111
+
112
+ connection.execute("UPDATE #{table} SET #{column} = lower(#{column}) WHERE #{column} <> lower(#{column})")
113
+ end
114
+
115
+ # source_queue is the physical queue a failure was read from, now spelled in
116
+ # lower case. queue_name is the executor's logical name, as configured, and
117
+ # stays. A message that failed under both spellings (before and after the
118
+ # deploy) has two rows: one per (queue_name, msg_id, folded source) stays,
119
+ # the lower-case one if there is one (written after the deploy), else the
120
+ # newest, so the (queue_name, msg_id, source_queue) unique index holds.
121
+ def fold_failed_event_source_queues(connection)
122
+ return unless connection.table_exists?(:pgbus_failed_events) && connection.column_exists?(:pgbus_failed_events, :source_queue)
123
+
124
+ connection.execute(<<~SQL)
125
+ DELETE FROM pgbus_failed_events WHERE id IN (
126
+ SELECT id FROM (
127
+ SELECT id, row_number() OVER (
128
+ PARTITION BY queue_name, msg_id, lower(source_queue)
129
+ ORDER BY (source_queue = lower(source_queue)) DESC, id DESC
130
+ ) AS rank
131
+ FROM pgbus_failed_events
132
+ WHERE (queue_name, msg_id, lower(source_queue)) IN (
133
+ SELECT queue_name, msg_id, lower(source_queue) FROM pgbus_failed_events
134
+ WHERE source_queue <> lower(source_queue)
135
+ )
136
+ ) ranked
137
+ WHERE rank > 1
138
+ )
139
+ SQL
140
+ fold_column(connection, :pgbus_failed_events, :source_queue)
141
+ end
142
+
143
+ private_class_method :fold_pgmq_meta, :fold_unique_column, :fold_column, :fold_failed_event_source_queues
144
+ end
145
+ end