pgbus 0.22.0 → 0.23.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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +41 -14
  4. data/Rakefile +7 -1
  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 +142 -20
  31. data/lib/pgbus/active_job/quiet_logs.rb +57 -0
  32. data/lib/pgbus/autovacuum_tuning.rb +2 -2
  33. data/lib/pgbus/cli/dlq.rb +2 -1
  34. data/lib/pgbus/client/async_commit.rb +149 -0
  35. data/lib/pgbus/client/native_statements.rb +28 -16
  36. data/lib/pgbus/client.rb +181 -17
  37. data/lib/pgbus/concurrency/blocked_execution.rb +9 -1
  38. data/lib/pgbus/configuration.rb +71 -6
  39. data/lib/pgbus/dead_letter_header.rb +3 -0
  40. data/lib/pgbus/doctor.rb +55 -2
  41. data/lib/pgbus/failed_event_recorder.rb +5 -2
  42. data/lib/pgbus/generators/migration_detector.rb +29 -2
  43. data/lib/pgbus/instrumentation.rb +16 -1
  44. data/lib/pgbus/metrics/subscriber.rb +27 -1
  45. data/lib/pgbus/process/completer.rb +4 -2
  46. data/lib/pgbus/process/worker.rb +28 -3
  47. data/lib/pgbus/queue_name_fold.rb +145 -0
  48. data/lib/pgbus/queue_name_validator.rb +20 -2
  49. data/lib/pgbus/recurring/schedule.rb +22 -13
  50. data/lib/pgbus/stat_buffer.rb +67 -26
  51. data/lib/pgbus/streams/cursor.rb +17 -6
  52. data/lib/pgbus/streams/envelope.rb +16 -0
  53. data/lib/pgbus/streams/key.rb +1 -1
  54. data/lib/pgbus/table_maintenance.rb +1 -1
  55. data/lib/pgbus/uniqueness.rb +43 -16
  56. data/lib/pgbus/version.rb +1 -1
  57. data/lib/pgbus/web/data_source/dead_letter.rb +14 -5
  58. data/lib/pgbus/web/data_source/resend.rb +185 -0
  59. data/lib/pgbus/web/data_source/resend_slot.rb +139 -0
  60. data/lib/pgbus/web/data_source.rb +55 -37
  61. data/lib/pgbus/web/dead_letter_reason.rb +6 -0
  62. data/lib/pgbus/web/streamer/connection.rb +36 -29
  63. data/lib/pgbus/web/streamer/falcon_connection.rb +29 -25
  64. data/lib/pgbus/web/streamer/heartbeat.rb +14 -3
  65. data/lib/pgbus/web/streamer/instance.rb +8 -5
  66. data/lib/pgbus/web/streamer/listener.rb +239 -62
  67. data/lib/pgbus/web/streamer/outbound_pump.rb +6 -4
  68. data/lib/pgbus/web/streamer/registry.rb +55 -29
  69. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +73 -12
  70. data/lib/tasks/pgbus_queues.rake +27 -0
  71. data/sig/pgbus/active_job/adapter.rbs +1 -1
  72. data/sig/pgbus/active_job/quiet_logs.rbs +11 -0
  73. data/sig/pgbus/client.rbs +6 -5
  74. data/sig/pgbus/configuration.rbs +4 -0
  75. metadata +11 -3
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Client
5
+ # Asynchronous completion commit (issue #619,
6
+ # `config.completion_synchronous_commit = false`). A completion statement
7
+ # (archive, delete and their batches) runs in its own transaction with
8
+ # synchronous_commit off, so the job is acknowledged without waiting for
9
+ # its WAL flush. The four queries go out in one libpq pipeline with one
10
+ # sync, so it is still one round trip (pg writes each query to the socket
11
+ # as it is sent, but waits for a reply only once, after the sync):
12
+ #
13
+ # BEGIN
14
+ # SET LOCAL synchronous_commit TO off
15
+ # <the prepared completion statement> <- the result the caller gets
16
+ # COMMIT
17
+ #
18
+ # SET LOCAL ends with the transaction, so the pooled connection never
19
+ # keeps the setting, and the statement stays prepared (no planning cost
20
+ # back, issue #605). Only a job's completion asks for it (the executor and
21
+ # the completer pass job_completion: true; the event consumer and the
22
+ # dashboard keep the durable commit), and NativeStatements#exec_native
23
+ # calls it only outside a caller's transaction: a completion inside one is
24
+ # the caller's to commit.
25
+ #
26
+ # A failed query aborts the rest of the pipeline and leaves the explicit
27
+ # BEGIN open in a failed state: the connection leaves pipeline mode, the
28
+ # transaction is rolled back, and the error is raised for run_native's
29
+ # re-run and short-row checks. A statement not yet prepared on this session
30
+ # runs through prepare_and_exec outside the pipeline, committed
31
+ # synchronously, once; the next call pipelines again.
32
+ module AsyncCommit
33
+ ASYNC_COMMIT_BEGIN = "BEGIN"
34
+ ASYNC_COMMIT_OFF = "SET LOCAL synchronous_commit TO off"
35
+ ASYNC_COMMIT_COMMIT = "COMMIT"
36
+ NO_PARAMS = [].freeze
37
+
38
+ private
39
+
40
+ def exec_async_commit(conn, statement, params, prepared)
41
+ results = pipeline_async_commit(conn, statement, params, prepared)
42
+ failed = results.find { |result| result.result_status == PG::PGRES_FATAL_ERROR }
43
+ unless failed
44
+ NativeStatements.note_executed if prepared
45
+ return results[2]
46
+ end
47
+
48
+ rollback_async_commit(conn)
49
+ begin
50
+ failed.check
51
+ rescue PG::InvalidSqlStatementName
52
+ raise unless prepared
53
+
54
+ prepare_and_exec(conn, statement, params)
55
+ end
56
+ end
57
+
58
+ # Sends the four queries and one sync, and reads every result up to the
59
+ # sync: each query answers its result and then nil.
60
+ def pipeline_async_commit(conn, statement, params, prepared)
61
+ conn.enter_pipeline_mode
62
+ begin
63
+ conn.send_query_params(ASYNC_COMMIT_BEGIN, NO_PARAMS)
64
+ conn.send_query_params(ASYNC_COMMIT_OFF, NO_PARAMS)
65
+ if prepared
66
+ conn.send_query_prepared(statement.name, params)
67
+ else
68
+ conn.send_query_params(statement.sql, params)
69
+ end
70
+ conn.send_query_params(ASYNC_COMMIT_COMMIT, NO_PARAMS)
71
+ conn.pipeline_sync
72
+ read_pipeline(conn)
73
+ ensure
74
+ leave_pipeline(conn)
75
+ end
76
+ end
77
+
78
+ def read_pipeline(conn)
79
+ results = []
80
+ ended = false
81
+ loop do
82
+ result = conn.get_result
83
+ if result.nil?
84
+ # One nil closes each query's results; two in a row means libpq
85
+ # has nothing more, so the sync will not come. That session's
86
+ # state cannot be trusted: pgmq-ruby re-runs on a fresh one.
87
+ if ended
88
+ discard_session(conn, "A completion pipeline ended before its sync")
89
+ raise PG::ConnectionBad, "pipeline ended before its sync"
90
+ end
91
+
92
+ ended = true
93
+ next
94
+ end
95
+ ended = false
96
+ break if result.result_status == PG::PGRES_PIPELINE_SYNC
97
+
98
+ results << result
99
+ end
100
+ results
101
+ end
102
+
103
+ # A connection back in the pool in pipeline mode would break the next
104
+ # caller. A lost one is left as it is: pgmq-ruby resets a bad connection
105
+ # at its next checkout, and a reset leaves pipeline mode. One that cannot
106
+ # leave (results left unread by an interrupted read) is reset here.
107
+ def leave_pipeline(conn)
108
+ return if conn.pipeline_status == PG::PQ_PIPELINE_OFF || conn.status == PG::CONNECTION_BAD
109
+
110
+ conn.exit_pipeline_mode
111
+ rescue PG::Error => e
112
+ discard_session(conn, "Could not leave pipeline mode after a completion (#{e.message})")
113
+ end
114
+
115
+ # Resets a session whose pipeline state cannot be trusted. A reset that
116
+ # fails (the server is gone) is logged and left there, so the error the
117
+ # caller is already raising is the one that surfaces; pgmq-ruby resets a
118
+ # bad connection at its next checkout.
119
+ # A reset reconnects with the same conninfo (the read bounds ride in it)
120
+ # but does not re-run the connect lambda, so the :session-mode SETs are
121
+ # applied again here.
122
+ def discard_session(conn, reason)
123
+ Pgbus.logger.warn { "[Pgbus::Client] #{reason}, resetting the connection" }
124
+ begin
125
+ conn.reset
126
+ rescue PG::Error => e
127
+ Pgbus.logger.warn { "[Pgbus::Client] Resetting the connection failed: #{e.message}" }
128
+ return
129
+ end
130
+ reapply_session_gucs(conn)
131
+ end
132
+
133
+ def reapply_session_gucs(conn)
134
+ apply_session_gucs(conn, @session_gucs) if @session_gucs
135
+ rescue PG::Error => e
136
+ Pgbus.logger.warn do
137
+ "[Pgbus::Client] Re-applying the session's database.yml variables after the reset failed: #{e.message}"
138
+ end
139
+ end
140
+
141
+ # The failed BEGIN block (INERROR) is still open after the sync. A failed
142
+ # COMMIT has already ended it (IDLE); a lost connection reports UNKNOWN
143
+ # and has nothing to roll back.
144
+ def rollback_async_commit(conn)
145
+ conn.exec("ROLLBACK") if [PG::PQTRANS_INERROR, PG::PQTRANS_INTRANS].include?(conn.transaction_status)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -299,8 +299,11 @@ module Pgbus
299
299
  # call otherwise. Return values match pgmq-ruby's: Messages with String
300
300
  # fields, String ids, true/false for one archive/delete.
301
301
 
302
+ # The send statements bind the delay as $3::integer. is_a?(Integer) also
303
+ # takes a whole-second ActiveSupport::Duration, which binds as its seconds;
304
+ # a fractional delay or a Time goes through pgmq-ruby.
302
305
  def exec_produce(name, message, headers:, delay:)
303
- statements = delay.is_a?(Numeric) && native_statements_for(name)
306
+ statements = delay.is_a?(Integer) && native_statements_for(name)
304
307
  return @pgmq.produce(name, message, headers: headers, delay: delay) unless statements
305
308
 
306
309
  run_native(statements[:send], [message, headers, delay])[0]["msg_id"]
@@ -308,7 +311,7 @@ module Pgbus
308
311
 
309
312
  def exec_produce_batch(name, messages, headers:, delay:)
310
313
  statements = messages.any? && (headers.nil? || headers.length == messages.length) &&
311
- delay.is_a?(Numeric) && native_statements_for(name)
314
+ delay.is_a?(Integer) && native_statements_for(name)
312
315
  return @pgmq.produce_batch(name, messages, headers: headers, delay: delay) unless statements
313
316
 
314
317
  encoder = PG::TextEncoder::Array.new
@@ -325,32 +328,37 @@ module Pgbus
325
328
  result.map { |row| PGMQ::Message.new(row) }
326
329
  end
327
330
 
328
- def exec_archive(name, msg_id)
331
+ # async: commit with synchronous_commit off (AsyncCommit, issue #619);
332
+ # true only for a job's completion under completion_synchronous_commit
333
+ # = false.
334
+ def exec_archive(name, msg_id, async: false)
329
335
  statements = native_statements_for(name)
330
336
  return @pgmq.archive(name, msg_id) unless statements
331
337
 
332
- run_native(statements[:archive], [msg_id], rows: 1).ntuples.positive?
338
+ run_native(statements[:archive], [msg_id], rows: 1, async: async).ntuples.positive?
333
339
  end
334
340
 
335
- def exec_archive_batch(name, msg_ids)
341
+ def exec_archive_batch(name, msg_ids, async: false)
336
342
  statements = msg_ids.any? && native_statements_for(name)
337
343
  return @pgmq.archive_batch(name, msg_ids) unless statements
338
344
 
339
- run_native(statements[:archive_batch], [PG::TextEncoder::Array.new.encode(msg_ids)], rows: msg_ids).map { |row| row["msg_id"] }
345
+ params = [PG::TextEncoder::Array.new.encode(msg_ids)]
346
+ run_native(statements[:archive_batch], params, rows: msg_ids, async: async).map { |row| row["msg_id"] }
340
347
  end
341
348
 
342
- def exec_delete(name, msg_id)
349
+ def exec_delete(name, msg_id, async: false)
343
350
  statements = native_statements_for(name)
344
351
  return @pgmq.delete(name, msg_id) unless statements
345
352
 
346
- run_native(statements[:delete], [msg_id], rows: 1).ntuples.positive?
353
+ run_native(statements[:delete], [msg_id], rows: 1, async: async).ntuples.positive?
347
354
  end
348
355
 
349
- def exec_delete_batch(name, msg_ids)
356
+ def exec_delete_batch(name, msg_ids, async: false)
350
357
  statements = msg_ids.any? && native_statements_for(name)
351
358
  return @pgmq.delete_batch(name, msg_ids) unless statements
352
359
 
353
- run_native(statements[:delete_batch], [PG::TextEncoder::Array.new.encode(msg_ids)], rows: msg_ids).map { |row| row["msg_id"] }
360
+ params = [PG::TextEncoder::Array.new.encode(msg_ids)]
361
+ run_native(statements[:delete_batch], params, rows: msg_ids, async: async).map { |row| row["msg_id"] }
354
362
  end
355
363
 
356
364
  # rows: the rows an archive or delete returns when every id was still
@@ -366,12 +374,12 @@ module Pgbus
366
374
  # id another worker had already taken also makes a re-run come back
367
375
  # short; every waiter of that batch then claims :ambiguous, the side the
368
376
  # executor already errs on.
369
- def run_native(statement, params, rows: nil)
377
+ def run_native(statement, params, rows: nil, async: false)
370
378
  attempt = 0
371
379
  lost = nil
372
380
  @pgmq.with_connection do |conn|
373
381
  attempt += 1
374
- result = exec_native(conn, statement, params)
382
+ result = exec_native(conn, statement, params, async)
375
383
  if rows && attempt > 1 && result.ntuples < (rows.is_a?(Array) ? rows.uniq.size : rows)
376
384
  raise PGMQ::Errors::ConnectionError, "#{COMPLETION_NOT_RESENT}: #{lost}"
377
385
  end
@@ -383,10 +391,14 @@ module Pgbus
383
391
  end
384
392
  end
385
393
 
386
- def exec_native(conn, statement, params)
387
- if @native_unprepared || NativeStatements.unprepared? || inside_caller_transaction?(conn)
388
- return conn.exec_params(statement.sql, params)
389
- end
394
+ # async (a job's completion under completion_synchronous_commit = false)
395
+ # goes through AsyncCommit, unless it runs inside a caller's transaction.
396
+ def exec_native(conn, statement, params, async)
397
+ return conn.exec_params(statement.sql, params) if inside_caller_transaction?(conn)
398
+
399
+ unprepared = @native_unprepared || NativeStatements.unprepared?
400
+ return exec_async_commit(conn, statement, params, !unprepared) if async
401
+ return conn.exec_params(statement.sql, params) if unprepared
390
402
 
391
403
  begin
392
404
  result = conn.exec_prepared(statement.name, params)
data/lib/pgbus/client.rb CHANGED
@@ -18,6 +18,7 @@ module Pgbus
18
18
  include EnsureStreamQueue
19
19
  include NotifyStream
20
20
  include NativeStatements
21
+ include AsyncCommit
21
22
 
22
23
  attr_reader :pgmq, :config, :connection_health
23
24
 
@@ -29,6 +30,12 @@ module Pgbus
29
30
  PGMQ_META_CHECK_SQL = "SELECT 1 FROM pg_tables WHERE schemaname = 'pgmq' AND tablename = 'meta' LIMIT 1"
30
31
  private_constant :PGMQ_META_CHECK_SQL
31
32
 
33
+ # pgbus's own queues only ($1: the lower-case "<queue_prefix>_"): another
34
+ # PGMQ user of the same schema keeps its spelling.
35
+ MIXED_CASE_QUEUES_SQL = "SELECT queue_name FROM pgmq.meta WHERE queue_name <> lower(queue_name) " \
36
+ "AND left(lower(queue_name), length($1)) = $1 ORDER BY queue_name"
37
+ private_constant :MIXED_CASE_QUEUES_SQL
38
+
32
39
  PGMQ_INSTALL_SAVEPOINT = "pgbus_pgmq_install"
33
40
  private_constant :PGMQ_INSTALL_SAVEPOINT
34
41
 
@@ -130,7 +137,11 @@ module Pgbus
130
137
  # Both raise clean PG errors — no Ruby Timeout, no Thread#raise. Only
131
138
  # safe on this dedicated-connection branch — never on the shared-AR Proc
132
139
  # path, where statement_timeout would leak into application queries.
133
- conn_opts = wrap_session_gucs(apply_connection_bounds(conn_opts))
140
+ conn_opts = apply_connection_bounds(conn_opts)
141
+ # What the connect lambda SETs per session (:session GUC mode), kept so
142
+ # a session reset after a failed completion pipeline gets it again.
143
+ @session_gucs = session_gucs(conn_opts)
144
+ conn_opts = wrap_session_gucs(conn_opts)
134
145
  @pgmq = PGMQ::Client.new(conn_opts, pool_size: config.resolved_pool_size, pool_timeout: config.pool_timeout)
135
146
  @pgmq_mutex = nil
136
147
  # Dedicated streams pool (issue #315): isolates the durable-stream
@@ -174,6 +185,12 @@ module Pgbus
174
185
  # Per physical queue: its native statements, or false (issue #605).
175
186
  @native_statements = Concurrent::Map.new
176
187
  @native_unprepared = config.native_statements && ar_disables_prepared_statements?
188
+ # Job completions commit with synchronous_commit off (issue #619).
189
+ # Native statements only: the pgmq functions are never wrapped. Never on
190
+ # ActiveRecord's own connection (the Proc path): pgbus does not pipeline
191
+ # on, or reset, a connection it does not own.
192
+ @async_completion_commit = config.native_statements && !config.completion_synchronous_commit &&
193
+ !@shared_connection
177
194
  # Guards the one-time build of the publisher autoscale trigger (issue #323).
178
195
  # NOT @pgmq_mutex — that is nil on the dedicated path (the only path the
179
196
  # trigger exists on), so it wouldn't serialize concurrent first-publishers.
@@ -303,6 +320,18 @@ module Pgbus
303
320
  end
304
321
  end
305
322
 
323
+ # pgmq.meta rows spelled with an upper-case letter (issue #633). PGMQ
324
+ # lower-cases table names, so each shares its table with the lower-case
325
+ # spelling pgbus now uses; `rails generate pgbus:fold_queue_name_case`
326
+ # renames them. Pgbus::Doctor reports them.
327
+ def mixed_case_queue_names
328
+ synchronized do
329
+ @pgmq.with_connection do |conn|
330
+ conn.exec_params(MIXED_CASE_QUEUES_SQL, ["#{config.queue_prefix.to_s.downcase}_"]).column_values(0)
331
+ end
332
+ end
333
+ end
334
+
306
335
  # The most recently recorded installed PGMQ schema version string (e.g.
307
336
  # "1.5.0"), read from the pgbus_pgmq_schema_versions tracking table. Returns
308
337
  # nil when nothing is tracked yet or the table does not exist — the same
@@ -551,35 +580,45 @@ module Pgbus
551
580
 
552
581
  # Delete a message. Pass prefixed: false when queue_name is already
553
582
  # the full PGMQ queue name (e.g. from priority sub-queues or dashboard).
554
- def delete_message(queue_name, msg_id, prefixed: true)
583
+ #
584
+ # job_completion: true marks the call as a job's completion (the executor
585
+ # and the completer pass it). Under config.completion_synchronous_commit =
586
+ # false only those commit with synchronous_commit off (issue #619); every
587
+ # other caller (the event consumer, whose replay reads the archive, and the
588
+ # dashboard) keeps the durable commit. The same holds for the three below.
589
+ def delete_message(queue_name, msg_id, prefixed: true, job_completion: false)
555
590
  name = prefixed ? config.queue_name(queue_name) : queue_name
591
+ async = job_completion && @async_completion_commit
556
592
  with_stale_connection_retry do
557
- synchronized { exec_delete(name, msg_id) }
593
+ synchronized { exec_delete(name, msg_id, async: async) }
558
594
  end
559
595
  end
560
596
 
561
597
  # Archive a message. Pass prefixed: false when queue_name is already
562
598
  # the full PGMQ queue name.
563
- def archive_message(queue_name, msg_id, prefixed: true)
599
+ def archive_message(queue_name, msg_id, prefixed: true, job_completion: false)
564
600
  name = prefixed ? config.queue_name(queue_name) : queue_name
601
+ async = job_completion && @async_completion_commit
565
602
  with_stale_connection_retry do
566
- synchronized { exec_archive(name, msg_id) }
603
+ synchronized { exec_archive(name, msg_id, async: async) }
567
604
  end
568
605
  end
569
606
 
570
607
  # Batch archive — moves multiple messages to the archive table in one call.
571
- def archive_batch(queue_name, msg_ids, prefixed: true)
608
+ def archive_batch(queue_name, msg_ids, prefixed: true, job_completion: false)
572
609
  name = prefixed ? config.queue_name(queue_name) : queue_name
610
+ async = job_completion && @async_completion_commit
573
611
  with_stale_connection_retry do
574
- synchronized { exec_archive_batch(name, msg_ids) }
612
+ synchronized { exec_archive_batch(name, msg_ids, async: async) }
575
613
  end
576
614
  end
577
615
 
578
616
  # Batch delete — permanently removes multiple messages in one call.
579
- def delete_batch(queue_name, msg_ids, prefixed: true)
617
+ def delete_batch(queue_name, msg_ids, prefixed: true, job_completion: false)
580
618
  name = prefixed ? config.queue_name(queue_name) : queue_name
619
+ async = job_completion && @async_completion_commit
581
620
  with_stale_connection_retry do
582
- synchronized { exec_delete_batch(name, msg_ids) }
621
+ synchronized { exec_delete_batch(name, msg_ids, async: async) }
583
622
  end
584
623
  end
585
624
 
@@ -704,12 +743,23 @@ module Pgbus
704
743
  end
705
744
  end
706
745
 
707
- def drop_queue(queue_name, prefixed: true)
746
+ # +before_drop+ runs only when the table itself is dropped (not for a
747
+ # case duplicate, whose table stays), decided once: the dashboard
748
+ # releases the dropped messages' uniqueness locks there.
749
+ def drop_queue(queue_name, prefixed: true, &before_drop)
708
750
  name = prefixed ? config.queue_name(queue_name) : queue_name
751
+ if case_duplicate?(name)
752
+ forget_case_duplicate(name)
753
+ return true
754
+ end
755
+
756
+ before_drop&.call
757
+
709
758
  result = with_stale_connection_retry do
710
759
  synchronized { @pgmq.drop_queue(name) }
711
760
  end
712
761
  @queues_created.delete(name)
762
+ forget_other_spellings(name)
713
763
  # Failed-event rows are keyed by the name the executor saw (physical or
714
764
  # logical); a recreated queue reuses msg_ids, so neither may survive.
715
765
  FailedEventRecorder.clear_queue!([name, name.delete_prefix("#{config.queue_prefix}_")].uniq)
@@ -1606,6 +1656,67 @@ module Pgbus
1606
1656
  duplicate_install_error?(error) || duplicate_install_error?(error.cause)
1607
1657
  end
1608
1658
 
1659
+ # A mixed-case pgmq.meta row from before issue #633 whose lower-case
1660
+ # spelling is registered too: both name one table, the lower-case row is
1661
+ # the queue every current process uses. Dropping the duplicate (the
1662
+ # dashboard lists both until pgbus:fold_queue_name_case runs, and the
1663
+ # orphan stream sweep sees both) must not drop that table.
1664
+ def case_duplicate?(name)
1665
+ return false if name == name.downcase
1666
+
1667
+ synchronized { queue_registered?(name) && queue_registered?(name.downcase) }
1668
+ end
1669
+
1670
+ # Removes only the duplicate's pgmq.meta row (its throttle and topic
1671
+ # bindings cascade); the table and the lower-case queue stay. The shared
1672
+ # table's insert trigger may carry the duplicate's name (an older process
1673
+ # armed it last), so it is re-armed under the lower-case name with the
1674
+ # throttle the lower-case row had (else the duplicate's): with the
1675
+ # duplicate's throttle row gone, that trigger would NOTIFY no more.
1676
+ def forget_case_duplicate(name)
1677
+ canonical = name.downcase
1678
+ with_stale_connection_retry do
1679
+ synchronized do
1680
+ throttle = notify_throttle_for(canonical, name)
1681
+ @pgmq.with_connection do |conn|
1682
+ conn.exec_params("DELETE FROM pgmq.meta WHERE queue_name = $1", [name])
1683
+ end
1684
+ @pgmq.enable_notify_insert(canonical, throttle_interval_ms: throttle) if throttle && !notify_trigger_current?(canonical, throttle)
1685
+ end
1686
+ end
1687
+ @queues_created.delete(name)
1688
+ end
1689
+
1690
+ # The NOTIFY throttle of the first of +names+ that has one, or nil (no
1691
+ # throttle row, or a PGMQ without notify_insert_throttle).
1692
+ def notify_throttle_for(*names)
1693
+ @pgmq.with_connection do |conn|
1694
+ next nil unless conn.exec("SELECT to_regclass('pgmq.notify_insert_throttle')").getvalue(0, 0)
1695
+
1696
+ rows = conn.exec_params(
1697
+ "SELECT queue_name, throttle_interval_ms FROM pgmq.notify_insert_throttle WHERE queue_name = ANY($1::text[])",
1698
+ ["{#{names.join(",")}}"]
1699
+ ).to_a
1700
+ row = names.lazy.filter_map { |n| rows.find { |r| r["queue_name"] == n } }.first
1701
+ row && Integer(row["throttle_interval_ms"])
1702
+ end
1703
+ end
1704
+
1705
+ # pgmq.drop_queue removes the table and only its own pgmq.meta row; a
1706
+ # mixed-case spelling from before issue #633 would be left naming a table
1707
+ # that is gone. Idempotent, so a stale pooled socket retries it.
1708
+ def forget_other_spellings(name)
1709
+ with_stale_connection_retry do
1710
+ synchronized do
1711
+ @pgmq.with_connection do |conn|
1712
+ conn.exec_params(
1713
+ "DELETE FROM pgmq.meta WHERE lower(queue_name) = lower($1) AND queue_name <> $1", [name]
1714
+ )
1715
+ end
1716
+ end
1717
+ end
1718
+ end
1719
+
1609
1720
  # Whether pgmq.meta records the queue — the authoritative "create
1610
1721
  # committed" signal (pgmq.create writes it atomically with the
1611
1722
  # tables). The pooled checkout is a sequential sibling of the failed
@@ -1622,7 +1733,13 @@ module Pgbus
1622
1733
  end
1623
1734
 
1624
1735
  # Check whether the NOTIFY trigger already exists on this queue with the
1625
- # expected throttle interval. When it does, we can skip the destructive
1736
+ # expected throttle interval, armed under this spelling of its name. PGMQ
1737
+ # 1.13.1's trigger NOTIFYs on the channel of the name it was armed with
1738
+ # (its argument), and every spelling of a name shares the queue's table
1739
+ # (issue #633): a process from before #633 that re-arms it under a
1740
+ # mixed-case name moves its NOTIFYs to a channel no listener uses. A
1741
+ # trigger without an argument (older PGMQ) NOTIFYs on the table's name,
1742
+ # which is the lower-case spelling. When it does, we can skip the destructive
1626
1743
  # DROP TRIGGER + CREATE TRIGGER cycle that causes deadlocks when multiple
1627
1744
  # forked processes race during bootstrap.
1628
1745
  #
@@ -1644,6 +1761,7 @@ module Pgbus
1644
1761
  WHERE n.nspname = 'pgmq'
1645
1762
  AND c.relname = pgmq.format_table_name($1, 'q')
1646
1763
  AND t.tgname = '#{NOTIFY_TRIGGER_NAME}'
1764
+ AND (t.tgnargs = 0 OR t.tgargs = convert_to($1, 'UTF8') || '\\000'::bytea)
1647
1765
  AND EXISTS (
1648
1766
  SELECT 1 FROM pgmq.notify_insert_throttle
1649
1767
  WHERE queue_name = $1
@@ -1758,6 +1876,16 @@ module Pgbus
1758
1876
  # restart or a brief failover — that outlasts the first immediate retry
1759
1877
  # still gets a second, backed-off chance rather than failing an enqueue
1760
1878
  # the caller may never retry.
1879
+ #
1880
+ # The last retry runs on freshly reloaded pools (issue #645). A reset that
1881
+ # Thread#raise interrupted mid-poll leaves the pooled PG::Connection in
1882
+ # CONNECTION_STARTED: neither finished? nor CONNECTION_BAD, so pgmq-ruby's
1883
+ # checkout health check keeps handing it out and libpq refuses every send
1884
+ # ("no connection to the server"). The first retry still goes through that
1885
+ # health check alone, which replaces a socket the server closed without
1886
+ # touching the rest of the pool; only when it fails too are both pools'
1887
+ # idle connections dropped (reload_pools_before_last_stale_retry), as the
1888
+ # #354 wedged-read path does.
1761
1889
  STALE_RETRY_ATTEMPTS = 2
1762
1890
  private_constant :STALE_RETRY_ATTEMPTS
1763
1891
 
@@ -1889,6 +2017,31 @@ module Pgbus
1889
2017
  end
1890
2018
  end
1891
2019
 
2020
+ # Drop both pools' idle connections before the last stale retry (issue
2021
+ # #645; see STALE_RETRY_ATTEMPTS), so it checks out a fresh connection
2022
+ # instead of the one pgmq-ruby's health check kept handing back. Skipped
2023
+ # silently on the shared-AR Proc path: those slots wrap ActiveRecord's own
2024
+ # socket (#reload refuses there with a warning that would be noise on every
2025
+ # retry). Each pool reloads on its own, so one failing does not keep the
2026
+ # other's connections. A reload failure is logged, never raised: the stale
2027
+ # ConnectionError the retry may raise next is the actionable error.
2028
+ def reload_pools_before_last_stale_retry(attempt, error)
2029
+ return if @shared_connection
2030
+
2031
+ Pgbus.logger.warn do
2032
+ "[Pgbus::Client] Dropping pooled connections before the last stale-connection retry " \
2033
+ "(attempt #{attempt}/#{STALE_RETRY_ATTEMPTS}): #{error.message}"
2034
+ end
2035
+ { "job" => @pgmq, "streams" => @streams_pool }.each do |name, pool|
2036
+ pool.reload
2037
+ rescue StandardError => e
2038
+ Pgbus.logger.warn do
2039
+ "[Pgbus::Client] #{name} pool reload before the last stale-connection retry failed: " \
2040
+ "#{e.class}: #{e.message}"
2041
+ end
2042
+ end
2043
+ end
2044
+
1892
2045
  # True when libpq's connection-baked read bounds (statement_timeout +
1893
2046
  # tcp_user_timeout + keepalives) fully cover both a slow query AND a
1894
2047
  # dead/hung socket, so with_read_timeout can skip the Ruby Timeout entirely.
@@ -2031,20 +2184,30 @@ module Pgbus
2031
2184
  # unchanged, as does :options mode (where forward_connection_variables
2032
2185
  # already baked the GUCs into `options`). See issue #332.
2033
2186
  def wrap_session_gucs(conn_opts)
2034
- return conn_opts unless config.connection_guc_mode == :session
2035
- return conn_opts unless conn_opts.is_a?(Hash)
2036
-
2037
- variables = conn_opts[:variables]
2038
- return conn_opts if variables.nil? || variables.empty?
2187
+ variables = session_gucs(conn_opts)
2188
+ return conn_opts unless variables
2039
2189
 
2040
2190
  pg_opts = conn_opts.except(:variables)
2041
2191
  lambda do
2042
2192
  conn = PG.connect(pg_opts)
2043
- variables.each { |name, value| conn.exec("SET #{name} = '#{value}'") }
2193
+ apply_session_gucs(conn, variables)
2044
2194
  conn
2045
2195
  end
2046
2196
  end
2047
2197
 
2198
+ # The database.yml variables :session GUC mode applies with SET after
2199
+ # connect, or nil when nothing is applied that way.
2200
+ def session_gucs(conn_opts)
2201
+ return unless config.connection_guc_mode == :session && conn_opts.is_a?(Hash)
2202
+
2203
+ variables = conn_opts[:variables]
2204
+ variables unless variables.nil? || variables.empty?
2205
+ end
2206
+
2207
+ def apply_session_gucs(conn, variables)
2208
+ variables.each { |name, value| conn.exec("SET #{name} = '#{value}'") }
2209
+ end
2210
+
2048
2211
  def apply_connection_bounds(conn_opts)
2049
2212
  timeout = config.read_timeout
2050
2213
  return conn_opts unless timeout&.positive?
@@ -2165,6 +2328,7 @@ module Pgbus
2165
2328
  "[Pgbus::Client] Retrying after stale pgmq connection " \
2166
2329
  "(attempt #{attempts}/#{STALE_RETRY_ATTEMPTS}): #{e.message}"
2167
2330
  end
2331
+ reload_pools_before_last_stale_retry(attempts, e) if attempts == STALE_RETRY_ATTEMPTS
2168
2332
  retry
2169
2333
  end
2170
2334
  end
@@ -6,6 +6,12 @@ require "time"
6
6
  module Pgbus
7
7
  module Concurrency
8
8
  module BlockedExecution
9
+ # Headers a parked job is to be sent with. pgbus_blocked_executions has
10
+ # no headers column, so a dashboard retry parked behind its limit
11
+ # (Web::DataSource::ResendSlot) carries them in its payload under this
12
+ # key; promote_next takes them out and sends them as headers.
13
+ HEADERS_KEY = "pgbus_park_headers"
14
+
9
15
  class << self
10
16
  # Insert a blocked execution for a job that hit the concurrency limit.
11
17
  #
@@ -64,8 +70,10 @@ module Pgbus
64
70
 
65
71
  # Carry the enqueuer's priority through: under priority routing it
66
72
  # picks the _pN sub-queue, not just the release order (issue #423).
73
+ headers = released[:payload].is_a?(Hash) ? released[:payload].delete(HEADERS_KEY) : nil
67
74
  msg_id = client.send_message(released[:queue_name], released[:payload],
68
- delay: actual_delay, priority: released[:priority])
75
+ delay: actual_delay, priority: released[:priority],
76
+ **(headers ? { headers: headers } : {}))
69
77
  end
70
78
 
71
79
  return false unless released && msg_id