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
data/lib/pgbus/client.rb CHANGED
@@ -17,6 +17,8 @@ module Pgbus
17
17
  include NotifyLockRetry
18
18
  include EnsureStreamQueue
19
19
  include NotifyStream
20
+ include NativeStatements
21
+ include AsyncCommit
20
22
 
21
23
  attr_reader :pgmq, :config, :connection_health
22
24
 
@@ -28,6 +30,12 @@ module Pgbus
28
30
  PGMQ_META_CHECK_SQL = "SELECT 1 FROM pg_tables WHERE schemaname = 'pgmq' AND tablename = 'meta' LIMIT 1"
29
31
  private_constant :PGMQ_META_CHECK_SQL
30
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
+
31
39
  PGMQ_INSTALL_SAVEPOINT = "pgbus_pgmq_install"
32
40
  private_constant :PGMQ_INSTALL_SAVEPOINT
33
41
 
@@ -129,7 +137,11 @@ module Pgbus
129
137
  # Both raise clean PG errors — no Ruby Timeout, no Thread#raise. Only
130
138
  # safe on this dedicated-connection branch — never on the shared-AR Proc
131
139
  # path, where statement_timeout would leak into application queries.
132
- 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)
133
145
  @pgmq = PGMQ::Client.new(conn_opts, pool_size: config.resolved_pool_size, pool_timeout: config.pool_timeout)
134
146
  @pgmq_mutex = nil
135
147
  # Dedicated streams pool (issue #315): isolates the durable-stream
@@ -170,6 +182,15 @@ module Pgbus
170
182
 
171
183
  @queues_created = Concurrent::Map.new
172
184
  @stream_indexes_created = Concurrent::Map.new
185
+ # Per physical queue: its native statements, or false (issue #605).
186
+ @native_statements = Concurrent::Map.new
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
173
194
  # Guards the one-time build of the publisher autoscale trigger (issue #323).
174
195
  # NOT @pgmq_mutex — that is nil on the dedicated path (the only path the
175
196
  # trigger exists on), so it wouldn't serialize concurrent first-publishers.
@@ -299,6 +320,18 @@ module Pgbus
299
320
  end
300
321
  end
301
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
+
302
335
  # The most recently recorded installed PGMQ schema version string (e.g.
303
336
  # "1.5.0"), read from the pgbus_pgmq_schema_versions tracking table. Returns
304
337
  # nil when nothing is tracked yet or the table does not exist — the same
@@ -356,7 +389,7 @@ module Pgbus
356
389
  Instrumentation.instrument("pgbus.client.send_message", queue: target) do
357
390
  with_stale_connection_retry do
358
391
  ensure_queue(queue_name)
359
- synchronized { @pgmq.produce(target, serialize(payload), headers: headers && serialize(headers), delay: delay) }
392
+ synchronized { exec_produce(target, serialize(payload), headers: headers && serialize(headers), delay: delay) }
360
393
  end
361
394
  end
362
395
  end
@@ -403,7 +436,7 @@ module Pgbus
403
436
  Instrumentation.instrument("pgbus.client.send_batch", queue: target, size: payloads.size) do
404
437
  with_stale_connection_retry do
405
438
  ensure_queue(queue_name)
406
- synchronized { @pgmq.produce_batch(target, serialized, headers: serialized_headers, delay: delay) }
439
+ synchronized { exec_produce_batch(target, serialized, headers: serialized_headers, delay: delay) }
407
440
  end
408
441
  end
409
442
  end
@@ -424,7 +457,7 @@ module Pgbus
424
457
  guarded_read do
425
458
  Instrumentation.instrument("pgbus.client.read_batch", queue: full_name, qty: qty) do
426
459
  with_stale_connection_retry do
427
- synchronized { with_read_timeout { @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
460
+ synchronized { with_read_timeout { exec_read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
428
461
  end
429
462
  end
430
463
  end
@@ -547,35 +580,45 @@ module Pgbus
547
580
 
548
581
  # Delete a message. Pass prefixed: false when queue_name is already
549
582
  # the full PGMQ queue name (e.g. from priority sub-queues or dashboard).
550
- 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)
551
590
  name = prefixed ? config.queue_name(queue_name) : queue_name
591
+ async = job_completion && @async_completion_commit
552
592
  with_stale_connection_retry do
553
- synchronized { @pgmq.delete(name, msg_id) }
593
+ synchronized { exec_delete(name, msg_id, async: async) }
554
594
  end
555
595
  end
556
596
 
557
597
  # Archive a message. Pass prefixed: false when queue_name is already
558
598
  # the full PGMQ queue name.
559
- def archive_message(queue_name, msg_id, prefixed: true)
599
+ def archive_message(queue_name, msg_id, prefixed: true, job_completion: false)
560
600
  name = prefixed ? config.queue_name(queue_name) : queue_name
601
+ async = job_completion && @async_completion_commit
561
602
  with_stale_connection_retry do
562
- synchronized { @pgmq.archive(name, msg_id) }
603
+ synchronized { exec_archive(name, msg_id, async: async) }
563
604
  end
564
605
  end
565
606
 
566
607
  # Batch archive — moves multiple messages to the archive table in one call.
567
- def archive_batch(queue_name, msg_ids, prefixed: true)
608
+ def archive_batch(queue_name, msg_ids, prefixed: true, job_completion: false)
568
609
  name = prefixed ? config.queue_name(queue_name) : queue_name
610
+ async = job_completion && @async_completion_commit
569
611
  with_stale_connection_retry do
570
- synchronized { @pgmq.archive_batch(name, msg_ids) }
612
+ synchronized { exec_archive_batch(name, msg_ids, async: async) }
571
613
  end
572
614
  end
573
615
 
574
616
  # Batch delete — permanently removes multiple messages in one call.
575
- def delete_batch(queue_name, msg_ids, prefixed: true)
617
+ def delete_batch(queue_name, msg_ids, prefixed: true, job_completion: false)
576
618
  name = prefixed ? config.queue_name(queue_name) : queue_name
619
+ async = job_completion && @async_completion_commit
577
620
  with_stale_connection_retry do
578
- synchronized { @pgmq.delete_batch(name, msg_ids) }
621
+ synchronized { exec_delete_batch(name, msg_ids, async: async) }
579
622
  end
580
623
  end
581
624
 
@@ -700,12 +743,23 @@ module Pgbus
700
743
  end
701
744
  end
702
745
 
703
- 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)
704
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
+
705
758
  result = with_stale_connection_retry do
706
759
  synchronized { @pgmq.drop_queue(name) }
707
760
  end
708
761
  @queues_created.delete(name)
762
+ forget_other_spellings(name)
709
763
  # Failed-event rows are keyed by the name the executor saw (physical or
710
764
  # logical); a recreated queue reuses msg_ids, so neither may survive.
711
765
  FailedEventRecorder.clear_queue!([name, name.delete_prefix("#{config.queue_prefix}_")].uniq)
@@ -1602,6 +1656,67 @@ module Pgbus
1602
1656
  duplicate_install_error?(error) || duplicate_install_error?(error.cause)
1603
1657
  end
1604
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
+
1605
1720
  # Whether pgmq.meta records the queue — the authoritative "create
1606
1721
  # committed" signal (pgmq.create writes it atomically with the
1607
1722
  # tables). The pooled checkout is a sequential sibling of the failed
@@ -1618,7 +1733,13 @@ module Pgbus
1618
1733
  end
1619
1734
 
1620
1735
  # Check whether the NOTIFY trigger already exists on this queue with the
1621
- # 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
1622
1743
  # DROP TRIGGER + CREATE TRIGGER cycle that causes deadlocks when multiple
1623
1744
  # forked processes race during bootstrap.
1624
1745
  #
@@ -1640,6 +1761,7 @@ module Pgbus
1640
1761
  WHERE n.nspname = 'pgmq'
1641
1762
  AND c.relname = pgmq.format_table_name($1, 'q')
1642
1763
  AND t.tgname = '#{NOTIFY_TRIGGER_NAME}'
1764
+ AND (t.tgnargs = 0 OR t.tgargs = convert_to($1, 'UTF8') || '\\000'::bytea)
1643
1765
  AND EXISTS (
1644
1766
  SELECT 1 FROM pgmq.notify_insert_throttle
1645
1767
  WHERE queue_name = $1
@@ -2027,20 +2149,30 @@ module Pgbus
2027
2149
  # unchanged, as does :options mode (where forward_connection_variables
2028
2150
  # already baked the GUCs into `options`). See issue #332.
2029
2151
  def wrap_session_gucs(conn_opts)
2030
- return conn_opts unless config.connection_guc_mode == :session
2031
- return conn_opts unless conn_opts.is_a?(Hash)
2032
-
2033
- variables = conn_opts[:variables]
2034
- return conn_opts if variables.nil? || variables.empty?
2152
+ variables = session_gucs(conn_opts)
2153
+ return conn_opts unless variables
2035
2154
 
2036
2155
  pg_opts = conn_opts.except(:variables)
2037
2156
  lambda do
2038
2157
  conn = PG.connect(pg_opts)
2039
- variables.each { |name, value| conn.exec("SET #{name} = '#{value}'") }
2158
+ apply_session_gucs(conn, variables)
2040
2159
  conn
2041
2160
  end
2042
2161
  end
2043
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
+
2044
2176
  def apply_connection_bounds(conn_opts)
2045
2177
  timeout = config.read_timeout
2046
2178
  return conn_opts unless timeout&.positive?
@@ -2182,6 +2314,10 @@ module Pgbus
2182
2314
  end
2183
2315
 
2184
2316
  def stale_connection_error?(error)
2317
+ # A completion whose re-run came back short may have committed: never
2318
+ # re-run it again (NativeStatements#run_native).
2319
+ return false if NativeStatements.completion_lost?(error)
2320
+
2185
2321
  # Substring match against any stale-connection pattern. Deliberately a
2186
2322
  # single Regexp rather than `STALE_CONNECTION_PATTERNS.any? { |p| msg.include?(p) }`:
2187
2323
  # Style/ArrayIntersect misfires on that shape and "corrects" it to
@@ -1,10 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
 
5
6
  module Pgbus
6
7
  module Concurrency
7
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
+
8
15
  class << self
9
16
  # Insert a blocked execution for a job that hit the concurrency limit.
10
17
  #
@@ -63,8 +70,10 @@ module Pgbus
63
70
 
64
71
  # Carry the enqueuer's priority through: under priority routing it
65
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
66
74
  msg_id = client.send_message(released[:queue_name], released[:payload],
67
- delay: actual_delay, priority: released[:priority])
75
+ delay: actual_delay, priority: released[:priority],
76
+ **(headers ? { headers: headers } : {}))
68
77
  end
69
78
 
70
79
  return false unless released && msg_id
@@ -24,6 +24,65 @@ module Pgbus
24
24
  # one. 0 (default) is off. Overridable per capsule / event_consumers entry
25
25
  # (`read_ahead:`); prefetch_limit still caps everything claimed.
26
26
  attr_reader :read_ahead
27
+ # completion_batching (issue #606, opt-in, default false): a worker archives
28
+ # the jobs that finish close together with one archive_batch per flush
29
+ # instead of one pgmq.archive per job (Process::Completer). No timer: a lone
30
+ # job is archived at once, and whatever finishes during a flush goes in the
31
+ # next. completion_batch_size (default 100) caps the ids per statement.
32
+ # Threads only: validate! rejects it next to execution_mode :async, global
33
+ # or per capsule. The crash window widens from one job to one flush: on a
34
+ # hard crash (SIGKILL, OOM) the jobs waiting in the current flush re-run
35
+ # after visibility_timeout, and a flush that committed just before the
36
+ # crash leaves its jobs completed but unsignalled (uniqueness lock, batch,
37
+ # concurrency slot) until the reapers and sweeps heal them. Worker shutdown
38
+ # waits for a flush in flight at most immediate_shutdown_grace (QUIT) or
39
+ # the rest of the 5 s pool residual (TERM); a flush wedged past that is
40
+ # abandoned, and its jobs fail (Completer::Stopped) and re-run after
41
+ # visibility_timeout, or stay unsignalled if it had committed.
42
+ attr_accessor :completion_batching, :completion_batch_size
43
+ # completed_jobs (issue #607): how a successful job's message leaves its
44
+ # queue. :archive (default) is pgmq.archive, DELETE + INSERT into the
45
+ # queue's a_ table, which archive_retention compacts. :delete is
46
+ # pgmq.delete, the DELETE alone: no a_ rows, no a_ index upkeep, nothing for
47
+ # the compaction to trim, and no completed-job history in the archive.
48
+ # Equally reliable: either way the message is gone once the statement
49
+ # commits. Through the completer (completion_batching) it is delete_batch.
50
+ # ActiveJob only: EventBus handler queues keep archiving (event replay
51
+ # reads their archive), and failures and dead letters are unchanged.
52
+ # Batch::Sweep then cannot tell a deleted batch job from one whose message
53
+ # vanished another way (a purged queue): both count as completed, silently.
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
27
86
  # visibility_heartbeat / visibility_heartbeat_interval: while a job runs,
28
87
  # its message's visibility timeout is re-armed every interval seconds
29
88
  # (default: a third of visibility_timeout), so a job that outlives the
@@ -214,6 +273,22 @@ module Pgbus
214
273
  # See issue #332.
215
274
  attr_reader :connection_guc_mode
216
275
 
276
+ # Run Client's read_batch, archive, delete, send and their batch forms as
277
+ # pgbus-owned static SQL in named prepared statements on the pooled
278
+ # connection, instead of pgmq's plpgsql functions, whose dynamic EXECUTE
279
+ # Postgres plans again on every call (issue #605). Default true. A queue
280
+ # whose table lacks a column the statements use (a pgmq older than the
281
+ # `headers` column) stays on the pgmq functions by itself.
282
+ #
283
+ # Behind a transaction-mode PgBouncer, prepared statements need PgBouncer
284
+ # >= 1.21 with `max_prepared_statements` set. Without it, a process that
285
+ # sees its statements disappear switches to sending the same SQL
286
+ # unprepared and logs a warning once, and connections built from a
287
+ # database.yml with `prepared_statements: false` send it unprepared from
288
+ # the start. false uses the pgmq functions everywhere: the full escape
289
+ # hatch.
290
+ attr_reader :native_statements
291
+
217
292
  # Zombie message detection — logs a warning when a message is redelivered
218
293
  # (read_ct > 1) without any prior failure recorded in pgbus_failed_events.
219
294
  attr_accessor :zombie_detection
@@ -320,6 +395,11 @@ module Pgbus
320
395
 
321
396
  @prefetch_limit = nil
322
397
  @read_ahead = 0
398
+ @completion_batching = false
399
+ @completion_batch_size = 100
400
+ @completed_jobs = :archive
401
+ @completion_synchronous_commit = true
402
+ @quiet_active_job_logs = false
323
403
  @execution_mode = :threads
324
404
 
325
405
  @max_jobs_per_worker = nil
@@ -404,6 +484,7 @@ module Pgbus
404
484
  @connects_to = nil
405
485
  @require_primary = false
406
486
  @connection_guc_mode = :options
487
+ @native_statements = true
407
488
 
408
489
  @mcp_write_tools = false
409
490
 
@@ -516,12 +597,13 @@ module Pgbus
516
597
  @streams_idle_timeout = 3_600 # 1 hour
517
598
  # 250ms — this value plays two roles: (1) the TCP keepalive
518
599
  # interval for the streamer's PG LISTEN connection, and (2) the
519
- # upper bound on how long Dispatcher#handle_connect waits for
520
- # the Listener to acknowledge a synchronous ensure_listening
521
- # call. 5s was unbounded enough to drop messages on a
522
- # realistic subscribe burst; 250ms keeps the connect-path race
523
- # window tight while still leaving headroom over a typical
524
- # 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.
525
607
  @streams_listen_health_check_ms = 250
526
608
  @streams_write_deadline_ms = 5_000
527
609
  # Deadline (ms) for a socket write made INSIDE the dispatcher's
@@ -797,6 +879,39 @@ module Pgbus
797
879
  @connection_guc_mode = mode
798
880
  end
799
881
 
882
+ VALID_COMPLETED_JOBS = %i[archive delete].freeze
883
+
884
+ def completed_jobs=(value)
885
+ mode = value.to_sym if value.is_a?(String) || value.is_a?(Symbol)
886
+ unless VALID_COMPLETED_JOBS.include?(mode)
887
+ raise Pgbus::ConfigurationError, "completed_jobs must be :archive or :delete (got #{value.inspect})"
888
+ end
889
+
890
+ @completed_jobs = mode
891
+ end
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
+
909
+ def native_statements=(value)
910
+ raise Pgbus::ConfigurationError, "native_statements must be true or false" unless [true, false].include?(value)
911
+
912
+ @native_statements = value
913
+ end
914
+
800
915
  VALID_PGMQ_SCHEMA_MODES = %i[auto extension embedded].freeze
801
916
 
802
917
  def pgmq_schema_mode=(mode)
@@ -986,6 +1101,8 @@ module Pgbus
986
1101
  end
987
1102
 
988
1103
  validate_read_ahead!
1104
+ validate_completion_batching!
1105
+ validate_completion_synchronous_commit!
989
1106
  validate_capsule_processes!
990
1107
 
991
1108
  if priority_levels && !(priority_levels.is_a?(Integer) && priority_levels >= 1 && priority_levels <= 10)
@@ -1756,6 +1873,41 @@ module Pgbus
1756
1873
 
1757
1874
  private
1758
1875
 
1876
+ # The completer's job threads block on their flush, which would stall an
1877
+ # Async reactor, so batching is threads-only (issue #606). Every capsule
1878
+ # resolves its mode through execution_mode_for, as the supervisor does.
1879
+ def validate_completion_batching!
1880
+ unless [true, false].include?(completion_batching)
1881
+ raise Pgbus::ConfigurationError, "completion_batching must be true or false (got #{completion_batching.inspect})"
1882
+ end
1883
+ unless completion_batch_size.is_a?(Integer) && completion_batch_size.positive?
1884
+ raise Pgbus::ConfigurationError,
1885
+ "completion_batch_size must be a positive Integer (got #{completion_batch_size.inspect})"
1886
+ end
1887
+ return unless completion_batching
1888
+
1889
+ modes = [ExecutionPools.normalize_mode(execution_mode)] + Array(workers).map { |w| execution_mode_for(w) }
1890
+ return unless modes.include?(:async)
1891
+
1892
+ raise Pgbus::ConfigurationError,
1893
+ "completion_batching supports execution_mode :threads only; turn it off for a global or capsule " \
1894
+ "execution_mode :async"
1895
+ end
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
+
1759
1911
  # Shared resolver for a dedicated connection (streamer LISTEN / worker
1760
1912
  # NotifyListener) that can override the base `connection_options` to bypass a
1761
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
data/lib/pgbus/doctor.rb CHANGED
@@ -5,7 +5,7 @@ require "pgbus/mcp/health_analyzer"
5
5
 
6
6
  module Pgbus
7
7
  # Preflight diagnostics for a pgbus deployment — the single command that
8
- # answers "is this environment healthy enough to run?". Runs twelve checks and
8
+ # answers "is this environment healthy enough to run?". Runs fourteen checks and
9
9
  # returns a machine-readable result plus a human report, so `pgbus doctor`
10
10
  # and `rake pgbus:doctor` can gate a deploy or CI run (exit 0 on success,
11
11
  # 1 on any failure).
@@ -36,13 +36,15 @@ module Pgbus
36
36
  "Database" => :check_database,
37
37
  "PGMQ schema" => :check_pgmq_schema,
38
38
  "Queues" => :check_queues,
39
+ "Queue name case" => :check_queue_name_case,
39
40
  "LISTEN/NOTIFY" => :check_notify,
40
41
  "Process liveness" => :check_processes,
41
42
  "GlobalID allowlist" => :check_allowed_global_id_models,
42
43
  "Broadcast queue" => :check_broadcast_queue,
43
44
  "Primary affinity" => :check_primary,
44
45
  "Dedicated connections" => :check_dedicated_connections,
45
- "Connection budget" => :check_connection_budget
46
+ "Connection budget" => :check_connection_budget,
47
+ "Completion commit" => :check_completion_commit
46
48
  }.freeze
47
49
 
48
50
  # Process liveness reads the pgbus_processes table (via HealthAnalyzer), so
@@ -128,6 +130,7 @@ module Pgbus
128
130
  resolved_pool_size: safe { @config.resolved_pool_size },
129
131
  listen_notify: @config.listen_notify,
130
132
  roles: @config.roles || "all",
133
+ completion_commit: @config.completion_synchronous_commit ? "sync" : "async",
131
134
  capsules: capsule_summary,
132
135
  database_url: redacted_database_url,
133
136
  connection_params: redacted_connection_params
@@ -252,6 +255,30 @@ module Pgbus
252
255
  Check.new(name: "Queues", status: :fail, detail: "#{e.class}: #{e.message}")
253
256
  end
254
257
 
258
+ # How many mixed-case queue names the queue-name case check lists.
259
+ CASE_NAMES_SHOWN = 5
260
+ private_constant :CASE_NAMES_SHOWN
261
+
262
+ # 4b. Queue-name case (issue #633). PGMQ spells every queue table in lower
263
+ # case, and pgbus now spells every queue name that way; a pgmq.meta row an
264
+ # older pgbus created as "pgbus_Orders" shares pgmq.q_pgbus_orders with the
265
+ # current spelling. Its messages are reachable, so this warns rather than
266
+ # fails: the dashboard lists the queue twice until the fold migration runs.
267
+ def check_queue_name_case
268
+ names = @client.mixed_case_queue_names
269
+ return Check.new(name: "Queue name case", status: :ok, detail: "every queue is spelled in lower case") if names.empty?
270
+
271
+ shown = names.first(CASE_NAMES_SHOWN).join(", ")
272
+ shown += ", …" if names.size > CASE_NAMES_SHOWN
273
+ Check.new(name: "Queue name case", status: :warn,
274
+ detail: "#{names.size} queue(s) in pgmq.meta spelled in mixed case (#{shown}); their tables are " \
275
+ "lower case and pgbus now uses that spelling — once every process runs this pgbus, run " \
276
+ "`rails generate pgbus:fold_queue_name_case` and migrate " \
277
+ "(`rake pgbus:queues:fold_case` once that migration has run)")
278
+ rescue StandardError => e
279
+ Check.new(name: "Queue name case", status: :warn, detail: "could not read pgmq.meta (#{e.class}: #{e.message})")
280
+ end
281
+
255
282
  # 5. LISTEN/NOTIFY — when configured on, every configured queue should carry
256
283
  # the insert-NOTIFY trigger. A missing trigger is a warning, not a failure:
257
284
  # pgbus still works (it falls back to polling), it just loses instant wakeup.
@@ -395,6 +422,32 @@ module Pgbus
395
422
  Check.new(name: "Dedicated connections", status: :fail, detail: "#{e.class}: #{e.message}")
396
423
  end
397
424
 
425
+ # config.completion_synchronous_commit (issue #619): informational, except
426
+ # that false does nothing with native_statements off or on ActiveRecord's
427
+ # shared connection, which warns.
428
+ def check_completion_commit
429
+ return Check.new(name: "Completion commit", status: :ok, detail: "synchronous (default)") if
430
+ @config.completion_synchronous_commit
431
+
432
+ unless @config.native_statements
433
+ return Check.new(name: "Completion commit", status: :warn,
434
+ detail: "completion_synchronous_commit = false has no effect: completions run through " \
435
+ "pgmq's functions (native_statements = false)")
436
+ end
437
+
438
+ if @client.respond_to?(:shared_connection?) && @client.shared_connection?
439
+ return Check.new(name: "Completion commit", status: :warn,
440
+ detail: "completion_synchronous_commit = false has no effect: pgbus shares ActiveRecord's " \
441
+ "connection (connection_params Proc); give pgbus its own connections to use it")
442
+ end
443
+
444
+ Check.new(name: "Completion commit", status: :ok,
445
+ detail: "asynchronous: completions commit with synchronous_commit off; a Postgres server crash can " \
446
+ "lose the last completions, whose jobs run again after visibility_timeout")
447
+ rescue StandardError => e
448
+ Check.new(name: "Completion commit", status: :warn, detail: "#{e.class}: #{e.message}")
449
+ end
450
+
398
451
  # How many direct LISTEN connections this config pins at steady state
399
452
  # (issue #381) — informational (always :ok) so operators can do capacity
400
453
  # math on the pooler's direct-connection budget from the doctor output.