pgbus 0.22.0 → 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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +36 -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 +145 -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
@@ -2031,20 +2149,30 @@ module Pgbus
2031
2149
  # unchanged, as does :options mode (where forward_connection_variables
2032
2150
  # already baked the GUCs into `options`). See issue #332.
2033
2151
  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?
2152
+ variables = session_gucs(conn_opts)
2153
+ return conn_opts unless variables
2039
2154
 
2040
2155
  pg_opts = conn_opts.except(:variables)
2041
2156
  lambda do
2042
2157
  conn = PG.connect(pg_opts)
2043
- variables.each { |name, value| conn.exec("SET #{name} = '#{value}'") }
2158
+ apply_session_gucs(conn, variables)
2044
2159
  conn
2045
2160
  end
2046
2161
  end
2047
2162
 
2163
+ # The database.yml variables :session GUC mode applies with SET after
2164
+ # connect, or nil when nothing is applied that way.
2165
+ def session_gucs(conn_opts)
2166
+ return unless config.connection_guc_mode == :session && conn_opts.is_a?(Hash)
2167
+
2168
+ variables = conn_opts[:variables]
2169
+ variables unless variables.nil? || variables.empty?
2170
+ end
2171
+
2172
+ def apply_session_gucs(conn, variables)
2173
+ variables.each { |name, value| conn.exec("SET #{name} = '#{value}'") }
2174
+ end
2175
+
2048
2176
  def apply_connection_bounds(conn_opts)
2049
2177
  timeout = config.read_timeout
2050
2178
  return conn_opts unless timeout&.positive?
@@ -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
@@ -52,6 +52,37 @@ module Pgbus
52
52
  # Batch::Sweep then cannot tell a deleted batch job from one whose message
53
53
  # vanished another way (a purged queue): both count as completed, silently.
54
54
  attr_reader :completed_jobs
55
+ # completion_synchronous_commit (issue #619, opt-in): false commits a job's
56
+ # completion (the executor's archive or delete, the completer's
57
+ # archive_batch or delete_batch) with `SET LOCAL synchronous_commit TO off`,
58
+ # in one libpq pipeline: BEGIN, the SET LOCAL, the prepared statement and
59
+ # COMMIT go out with one sync, so the job stops waiting for its WAL flush.
60
+ # A completion is then acknowledged before its WAL record reaches disk: a
61
+ # Postgres server crash (not a client crash, not a planned switchover), or a
62
+ # failover to a standby that has not yet received that WAL (it also stops
63
+ # waiting for a synchronous standby), within about three times
64
+ # wal_writer_delay can lose completions committed in that window, and each
65
+ # lost completion redelivers its job after visibility_timeout. Sends, retries, dead-letter moves, the EventBus
66
+ # consumer's archive and dashboard actions keep full durability, and a
67
+ # completion inside a caller's transaction is left to that transaction.
68
+ # Native statements on pgbus's own connections only: with
69
+ # native_statements = false, or on ActiveRecord's shared connection (a
70
+ # connection_params Proc), it has no effect (pgbus doctor warns). Needs
71
+ # libpq 14+ and pg 1.5+ (pipeline mode), checked by validate!. true
72
+ # (default) commits as before.
73
+ attr_reader :completion_synchronous_commit
74
+ # quiet_active_job_logs (issue #618): true makes each worker process
75
+ # unsubscribe ActiveJob's LogSubscriber from perform_start.active_job and
76
+ # perform.active_job (Pgbus::ActiveJob::QuietLogs), so the per-job
77
+ # "Performing …" / "Performed …" info lines stop, and with them ActiveJob's
78
+ # "Error performing …" line (the same perform listener writes it): an
79
+ # exception that escapes ActiveJob's handlers is still logged by
80
+ # ErrorReporter and recorded in pgbus_failed_events, one retry_on catches
81
+ # still logs ActiveJob's "Retrying …" line, and a job a before_perform
82
+ # callback halted logs nothing.
83
+ # Enqueued, Retrying, Stopped retrying and Discarded still log, and the web
84
+ # process is untouched. Without ActiveJob it does nothing.
85
+ attr_reader :quiet_active_job_logs
55
86
  # visibility_heartbeat / visibility_heartbeat_interval: while a job runs,
56
87
  # its message's visibility timeout is re-armed every interval seconds
57
88
  # (default: a third of visibility_timeout), so a job that outlives the
@@ -367,6 +398,8 @@ module Pgbus
367
398
  @completion_batching = false
368
399
  @completion_batch_size = 100
369
400
  @completed_jobs = :archive
401
+ @completion_synchronous_commit = true
402
+ @quiet_active_job_logs = false
370
403
  @execution_mode = :threads
371
404
 
372
405
  @max_jobs_per_worker = nil
@@ -564,12 +597,13 @@ module Pgbus
564
597
  @streams_idle_timeout = 3_600 # 1 hour
565
598
  # 250ms — this value plays two roles: (1) the TCP keepalive
566
599
  # interval for the streamer's PG LISTEN connection, and (2) the
567
- # upper bound on how long Dispatcher#handle_connect waits for
568
- # the Listener to acknowledge a synchronous ensure_listening
569
- # call. 5s was unbounded enough to drop messages on a
570
- # realistic subscribe burst; 250ms keeps the connect-path race
571
- # window tight while still leaving headroom over a typical
572
- # PG keepalive interval.
600
+ # budget (plus 1s) a stream's first connect waits for a stuck local
601
+ # Listener to acknowledge its ensure_listening (with
602
+ # streams_listen_scope :master, HubClient waits this cycle plus 2s
603
+ # before failing over). A live listener
604
+ # is woken through a pipe and acks within a thread hop, and a
605
+ # connect on a stream that already has a subscriber does not
606
+ # wait at all (issue #630), so connects no longer pay this cycle.
573
607
  @streams_listen_health_check_ms = 250
574
608
  @streams_write_deadline_ms = 5_000
575
609
  # Deadline (ms) for a socket write made INSIDE the dispatcher's
@@ -856,6 +890,22 @@ module Pgbus
856
890
  @completed_jobs = mode
857
891
  end
858
892
 
893
+ def completion_synchronous_commit=(value)
894
+ unless [true, false].include?(value)
895
+ raise Pgbus::ConfigurationError, "completion_synchronous_commit must be true or false (got #{value.inspect})"
896
+ end
897
+
898
+ @completion_synchronous_commit = value
899
+ end
900
+
901
+ def quiet_active_job_logs=(value)
902
+ unless [true, false].include?(value)
903
+ raise Pgbus::ConfigurationError, "quiet_active_job_logs must be true or false (got #{value.inspect})"
904
+ end
905
+
906
+ @quiet_active_job_logs = value
907
+ end
908
+
859
909
  def native_statements=(value)
860
910
  raise Pgbus::ConfigurationError, "native_statements must be true or false" unless [true, false].include?(value)
861
911
 
@@ -1052,6 +1102,7 @@ module Pgbus
1052
1102
 
1053
1103
  validate_read_ahead!
1054
1104
  validate_completion_batching!
1105
+ validate_completion_synchronous_commit!
1055
1106
  validate_capsule_processes!
1056
1107
 
1057
1108
  if priority_levels && !(priority_levels.is_a?(Integer) && priority_levels >= 1 && priority_levels <= 10)
@@ -1843,6 +1894,20 @@ module Pgbus
1843
1894
  "execution_mode :async"
1844
1895
  end
1845
1896
 
1897
+ # The asynchronous completion commit runs in libpq pipeline mode (issue
1898
+ # #619): libpq 14+ and a pg gem that wraps it (1.5+). Checked here, once,
1899
+ # rather than on every completion.
1900
+ def validate_completion_synchronous_commit!
1901
+ return if completion_synchronous_commit
1902
+
1903
+ require "pg" unless defined?(::PG::Connection)
1904
+ return if ::PG::Connection.method_defined?(:enter_pipeline_mode) && ::PG.library_version >= 140_000
1905
+
1906
+ raise Pgbus::ConfigurationError,
1907
+ "completion_synchronous_commit = false needs libpq 14+ and pg 1.5+ (pipeline mode); " \
1908
+ "this process has libpq #{::PG.library_version} and pg #{::PG::VERSION}"
1909
+ end
1910
+
1846
1911
  # Shared resolver for a dedicated connection (streamer LISTEN / worker
1847
1912
  # NotifyListener) that can override the base `connection_options` to bypass a
1848
1913
  # pooler. Precedence: a full `url` wins outright; otherwise `host`/`port`
@@ -20,6 +20,9 @@ module Pgbus
20
20
  ORIGINAL_KEY = "pgbus_original_headers"
21
21
  VERSION = 1
22
22
  REASON_MAX_RETRIES = "max_retries_exceeded"
23
+ # A :while_executing duplicate skipped on every read while another
24
+ # message held its key, until read_ct passed max_retries (issue #629).
25
+ REASON_UNIQUENESS_CONFLICT = "uniqueness_conflict"
23
26
  # The release that started writing the block: rows without it predate it.
24
27
  SINCE = "0.18.0"
25
28
  MESSAGE_LIMIT = 1_000