pgbus 0.9.7 → 0.9.8

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +119 -1
  4. data/Rakefile +10 -1
  5. data/app/helpers/pgbus/application_helper.rb +37 -0
  6. data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
  7. data/config/locales/da.yml +4 -0
  8. data/config/locales/de.yml +4 -0
  9. data/config/locales/en.yml +4 -0
  10. data/config/locales/es.yml +4 -0
  11. data/config/locales/fi.yml +4 -0
  12. data/config/locales/fr.yml +4 -0
  13. data/config/locales/it.yml +4 -0
  14. data/config/locales/ja.yml +4 -0
  15. data/config/locales/nb.yml +4 -0
  16. data/config/locales/nl.yml +4 -0
  17. data/config/locales/pt.yml +4 -0
  18. data/config/locales/sv.yml +4 -0
  19. data/lib/pgbus/active_job/executor.rb +25 -4
  20. data/lib/pgbus/cli/dlq.rb +164 -0
  21. data/lib/pgbus/cli.rb +18 -1
  22. data/lib/pgbus/client/connection_health.rb +194 -0
  23. data/lib/pgbus/client.rb +592 -73
  24. data/lib/pgbus/config_loader.rb +23 -4
  25. data/lib/pgbus/configuration.rb +98 -12
  26. data/lib/pgbus/dedup_cache.rb +8 -0
  27. data/lib/pgbus/doctor.rb +250 -0
  28. data/lib/pgbus/engine.rb +15 -0
  29. data/lib/pgbus/execution_pools/async_pool.rb +7 -0
  30. data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
  31. data/lib/pgbus/instrumentation.rb +1 -0
  32. data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
  33. data/lib/pgbus/metrics/backend.rb +38 -0
  34. data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
  35. data/lib/pgbus/metrics/backends/statsd.rb +64 -0
  36. data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
  37. data/lib/pgbus/metrics/subscriber.rb +190 -0
  38. data/lib/pgbus/metrics.rb +42 -0
  39. data/lib/pgbus/process/consumer.rb +215 -8
  40. data/lib/pgbus/process/consumer_priority.rb +34 -0
  41. data/lib/pgbus/process/dispatcher.rb +265 -41
  42. data/lib/pgbus/process/heartbeat.rb +18 -5
  43. data/lib/pgbus/process/memory_usage.rb +48 -0
  44. data/lib/pgbus/process/notify_listener.rb +26 -7
  45. data/lib/pgbus/process/notify_probe.rb +96 -0
  46. data/lib/pgbus/process/primary_validator.rb +53 -0
  47. data/lib/pgbus/process/signal_handler.rb +6 -0
  48. data/lib/pgbus/process/supervisor.rb +396 -46
  49. data/lib/pgbus/process/worker.rb +298 -35
  50. data/lib/pgbus/recurring/scheduler.rb +15 -1
  51. data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
  52. data/lib/pgbus/table_maintenance.rb +13 -2
  53. data/lib/pgbus/version.rb +1 -1
  54. data/lib/pgbus/web/data_source.rb +20 -4
  55. data/lib/pgbus/web/health_app.rb +102 -0
  56. data/lib/pgbus/web/health_server.rb +144 -0
  57. data/lib/pgbus/web/streamer/instance.rb +55 -1
  58. data/lib/pgbus/web/streamer/listener.rb +72 -9
  59. data/lib/pgbus.rb +37 -0
  60. data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +42 -0
  61. data/lib/rubocop/pgbus.rb +5 -0
  62. data/lib/tasks/pgbus_doctor.rake +12 -0
  63. metadata +18 -1
data/lib/pgbus/client.rb CHANGED
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "socket"
4
5
  require "timeout"
5
6
  require_relative "client/read_after"
6
7
  require_relative "client/ensure_stream_queue"
7
8
  require_relative "client/notify_stream"
9
+ require_relative "client/connection_health"
8
10
 
9
11
  module Pgbus
10
12
  class Client
@@ -12,7 +14,7 @@ module Pgbus
12
14
  include EnsureStreamQueue
13
15
  include NotifyStream
14
16
 
15
- attr_reader :pgmq, :config
17
+ attr_reader :pgmq, :config, :connection_health
16
18
 
17
19
  PGMQ_REQUIRE_MUTEX = Mutex.new
18
20
  private_constant :PGMQ_REQUIRE_MUTEX
@@ -25,15 +27,26 @@ module Pgbus
25
27
  # re-running the trigger DDL on every queue.
26
28
  NOTIFY_THROTTLE_MS = 250
27
29
 
28
- def initialize(config = Pgbus.configuration)
29
- # Define the PGMQ module before requiring the gem so that Zeitwerk's
30
- # eager_load (called inside pgmq.rb) can resolve the constant.
31
- # Without this, Ruby 4.0 + Zeitwerk 2.7.5 raises NameError because
32
- # eager_load runs const_get(:Client) on PGMQ before the module is defined.
30
+ # Load the pgmq-ruby gem, defining the PGMQ module before requiring it so
31
+ # Zeitwerk's eager_load (called inside pgmq.rb) can resolve the constant.
32
+ # Without the pre-definition, Ruby 4.0 + Zeitwerk 2.7.5 raises NameError
33
+ # because eager_load runs const_get(:Client) on PGMQ before the module is
34
+ # defined. Extracted as a class method so unit specs that fake PGMQ::Client
35
+ # can stub *this* (a per-example class-method stub, torn down cleanly)
36
+ # instead of the global Kernel#require, which — if stubbed before pgmq is
37
+ # genuinely loaded — permanently prevents the real gem from ever loading.
38
+ def self.load_pgmq_gem!
33
39
  PGMQ_REQUIRE_MUTEX.synchronize do
34
40
  Object.const_set(:PGMQ, Module.new) unless defined?(::PGMQ)
35
41
  require "pgmq"
36
42
  end
43
+ end
44
+
45
+ # `schema_ensured:` lets a caller (in practice, tests) skip the one-time
46
+ # PGMQ schema install probe by asserting the schema already exists. Defaults
47
+ # to false so production always runs the check on first queue access.
48
+ def initialize(config = Pgbus.configuration, schema_ensured: false)
49
+ self.class.load_pgmq_gem!
37
50
  @config = config
38
51
  conn_opts = config.connection_options
39
52
  @shared_connection = conn_opts.is_a?(Proc)
@@ -52,6 +65,14 @@ module Pgbus
52
65
  # Use the resolved pool size (auto-tuned from worker thread counts
53
66
  # unless explicitly set) and let pgmq-ruby's connection_pool handle
54
67
  # concurrency internally (no mutex needed).
68
+ #
69
+ # Bound reads with libpq-native mechanisms baked into the connection
70
+ # options (issue #198): a server-side statement_timeout for a slow query,
71
+ # plus client-side tcp_user_timeout + keepalives for a dead/hung socket.
72
+ # Both raise clean PG errors — no Ruby Timeout, no Thread#raise. Only
73
+ # safe on this dedicated-connection branch — never on the shared-AR Proc
74
+ # path, where statement_timeout would leak into application queries.
75
+ conn_opts = apply_connection_bounds(conn_opts)
55
76
  @pgmq = PGMQ::Client.new(conn_opts, pool_size: config.resolved_pool_size, pool_timeout: config.pool_timeout)
56
77
  @pgmq_mutex = nil
57
78
  end
@@ -59,7 +80,130 @@ module Pgbus
59
80
  @queues_created = Concurrent::Map.new
60
81
  @stream_indexes_created = Concurrent::Map.new
61
82
  @queue_strategy = QueueFactory.for(config)
62
- @schema_ensured = false
83
+ @schema_ensured = schema_ensured
84
+ @connection_health = ConnectionHealth.new(
85
+ on_open: method(:log_circuit_open),
86
+ on_close: method(:log_circuit_close)
87
+ )
88
+ # Snapshot whether libpq's baked-in read bounds fully cover a hung socket
89
+ # on this host/connection, so the read path can skip the Ruby Timeout
90
+ # last resort. Computed once: @shared_connection, config.read_timeout
91
+ # (which apply_connection_bounds also snapshots), the platform, and the
92
+ # linked libpq version are all fixed for a Client's lifetime.
93
+ @libpq_read_bounds_effective = libpq_read_bounds_effective?
94
+ warn_shared_connection_read_bounds
95
+ end
96
+
97
+ # True when this client shares ActiveRecord's connection (the Proc
98
+ # connection_options path): pool_size is forced to 1 and every operation is
99
+ # serialized through @pgmq_mutex. False on the dedicated-connection path,
100
+ # where pgmq-ruby owns its own pool and no mutex is needed.
101
+ def shared_connection?
102
+ @shared_connection
103
+ end
104
+
105
+ # Whether the shared-connection serialization mutex is currently held. False
106
+ # on the dedicated-connection path (no mutex). Lets callers assert that a
107
+ # code path (e.g. a retry backoff sleep) runs OUTSIDE the mutex without
108
+ # reaching into the mutex object itself.
109
+ def synchronizing?
110
+ @pgmq_mutex ? @pgmq_mutex.locked? : false
111
+ end
112
+
113
+ # Actively open a database connection and run `SELECT 1` so a bad
114
+ # database_url / connection_params surfaces at boot instead of on the
115
+ # first operation. PGMQ::Client's pool is lazy — nothing touches the
116
+ # database at init — so without this the supervisor forks children that
117
+ # crash-loop against an unreachable DB. Called from Supervisor#run before
118
+ # any queue bootstrap or forking.
119
+ #
120
+ # Raises Pgbus::ConfigurationError (not a transient PGMQ error) because a
121
+ # failure here means the operator's connection config is wrong: the message
122
+ # carries the underlying error plus which config source was in use.
123
+ def verify_connection!
124
+ synchronized do
125
+ @pgmq.with_connection { |conn| conn.exec("SELECT 1") }
126
+ end
127
+ true
128
+ rescue PGMQ::Errors::ConnectionError, PG::Error => e
129
+ raise ConfigurationError, "Database connection failed via #{connection_source}: #{e.message}"
130
+ end
131
+
132
+ # Lightweight liveness probe used by the doctor: open a raw connection and
133
+ # run `SELECT 1`. Unlike verify_connection! (which wraps failures as
134
+ # ConfigurationError for the supervisor boot path), ping lets the raw
135
+ # PG/PGMQ error propagate so the caller can render the underlying reason.
136
+ # Returns true on success; a bad connection raises rather than returning
137
+ # false — the caller renders the underlying reason — so this is a probe,
138
+ # not a boolean predicate, hence no `?` suffix.
139
+ def ping # rubocop:disable Naming/PredicateMethod
140
+ with_raw_connection { |conn| conn.exec("SELECT 1") }
141
+ true
142
+ end
143
+
144
+ # The logical queue names pgbus expects to exist based on the configuration
145
+ # (default queue + worker capsules + recurring tasks). Public wrapper around
146
+ # collect_configured_queues so the doctor can diff configured-vs-existing
147
+ # queues without reaching into PGMQ or config internals directly.
148
+ def configured_queues
149
+ collect_configured_queues
150
+ end
151
+
152
+ # Whether the given logical queue currently has a live PGMQ insert-NOTIFY
153
+ # trigger with pgbus's throttle interval on every physical table it maps to.
154
+ # Uses the same physical-name resolution as bootstrap (@queue_strategy), so
155
+ # a priority queue's _p0.._pN sub-tables — where the trigger actually lives —
156
+ # are all checked, not the bare prefixed name that priority mode never
157
+ # creates. Returns false when any physical table lacks the trigger or the
158
+ # check can't run.
159
+ def notify_enabled?(queue_name)
160
+ names = @queue_strategy.physical_queue_names(queue_name)
161
+ names.all? { |physical| notify_trigger_current?(physical, NOTIFY_THROTTLE_MS) }
162
+ end
163
+
164
+ # The physical PGMQ queue table names a logical queue maps to — one for a
165
+ # standard queue, or the _p0.._pN sub-queues when priority is enabled. This
166
+ # is the SAME resolution the bootstrap path uses (@queue_strategy), so a
167
+ # caller diffing configured-vs-existing queues compares the exact names PGMQ
168
+ # actually holds rather than the bare prefixed name.
169
+ def physical_queue_names(logical_name)
170
+ @queue_strategy.physical_queue_names(logical_name)
171
+ end
172
+
173
+ # Whether the PGMQ schema itself is present (the pgmq.meta table exists),
174
+ # independent of pgbus's own version-tracking table. Lets a caller tell
175
+ # "PGMQ installed via the extension / before version tracking" (schema
176
+ # present, no tracking row) apart from "PGMQ not installed at all".
177
+ def pgmq_installed?
178
+ with_raw_connection do |conn|
179
+ result = conn.exec(
180
+ "SELECT 1 FROM pg_tables WHERE schemaname = 'pgmq' AND tablename = 'meta' LIMIT 1"
181
+ )
182
+ result.ntuples.positive?
183
+ end
184
+ end
185
+
186
+ # The most recently recorded installed PGMQ schema version string (e.g.
187
+ # "1.5.0"), read from the pgbus_pgmq_schema_versions tracking table. Returns
188
+ # nil when nothing is tracked yet or the table does not exist — the same
189
+ # logic the `pgbus:pgmq:status` rake task uses, kept here so the doctor and
190
+ # the rake task share one raw-SQL path (never SQL outside the Client).
191
+ def pgmq_schema_version
192
+ with_raw_connection do |conn|
193
+ result = conn.exec(
194
+ "SELECT version FROM pgbus_pgmq_schema_versions ORDER BY installed_at DESC LIMIT 1"
195
+ )
196
+ row = result.first
197
+ row && row["version"]
198
+ end
199
+ rescue ActiveRecord::StatementInvalid => e
200
+ raise unless undefined_table_error?(e)
201
+
202
+ nil
203
+ rescue StandardError => e
204
+ raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
205
+
206
+ nil
63
207
  end
64
208
 
65
209
  def ensure_queue(name)
@@ -109,18 +253,22 @@ module Pgbus
109
253
 
110
254
  def read_message(queue_name, vt: nil)
111
255
  full_name = config.queue_name(queue_name)
112
- Instrumentation.instrument("pgbus.client.read_message", queue: full_name) do
113
- with_stale_connection_retry do
114
- synchronized { with_read_timeout { @pgmq.read(full_name, vt: vt || config.visibility_timeout) } }
256
+ guarded_read do
257
+ Instrumentation.instrument("pgbus.client.read_message", queue: full_name) do
258
+ with_stale_connection_retry do
259
+ synchronized { with_read_timeout { @pgmq.read(full_name, vt: vt || config.visibility_timeout) } }
260
+ end
115
261
  end
116
262
  end
117
263
  end
118
264
 
119
265
  def read_batch(queue_name, qty:, vt: nil)
120
266
  full_name = config.queue_name(queue_name)
121
- Instrumentation.instrument("pgbus.client.read_batch", queue: full_name, qty: qty) do
122
- with_stale_connection_retry do
123
- synchronized { with_read_timeout { @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
267
+ guarded_read do
268
+ Instrumentation.instrument("pgbus.client.read_batch", queue: full_name, qty: qty) do
269
+ with_stale_connection_retry do
270
+ synchronized { with_read_timeout { @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
271
+ end
124
272
  end
125
273
  end
126
274
  end
@@ -128,42 +276,51 @@ module Pgbus
128
276
  # Read from priority sub-queues, highest priority (p0) first.
129
277
  # Returns [priority_queue_name, messages] pairs.
130
278
  def read_batch_prioritized(queue_name, qty:, vt: nil)
279
+ # Non-priority fast path delegates to read_batch, which is already gated
280
+ # by the connection-health breaker — no extra guard needed here.
131
281
  unless @queue_strategy.priority?
132
282
  return (read_batch(queue_name, qty: qty, vt: vt) || []).map do |m|
133
283
  [config.queue_name(queue_name), m]
134
284
  end
135
285
  end
136
286
 
137
- remaining = qty
138
- results = []
287
+ # The priority loop issues its own reads, so gate the whole loop: an open
288
+ # breaker fails fast before any sub-queue is touched, and the loop as a
289
+ # unit records one success/failure with the latch.
290
+ guarded_read do
291
+ remaining = qty
292
+ results = []
139
293
 
140
- config.priority_queue_names(queue_name).each do |pq_name|
141
- break if remaining <= 0
294
+ config.priority_queue_names(queue_name).each do |pq_name|
295
+ break if remaining <= 0
142
296
 
143
- msgs = Instrumentation.instrument("pgbus.client.read_batch", queue: pq_name, qty: remaining) do
144
- with_stale_connection_retry do
145
- synchronized { with_read_timeout { @pgmq.read_batch(pq_name, vt: vt || config.visibility_timeout, qty: remaining) } }
146
- end
147
- end || []
297
+ msgs = Instrumentation.instrument("pgbus.client.read_batch", queue: pq_name, qty: remaining) do
298
+ with_stale_connection_retry do
299
+ synchronized { with_read_timeout { @pgmq.read_batch(pq_name, vt: vt || config.visibility_timeout, qty: remaining) } }
300
+ end
301
+ end || []
148
302
 
149
- msgs.each { |m| results << [pq_name, m] }
150
- remaining -= msgs.size
151
- end
303
+ msgs.each { |m| results << [pq_name, m] }
304
+ remaining -= msgs.size
305
+ end
152
306
 
153
- results
307
+ results
308
+ end
154
309
  end
155
310
 
156
311
  def read_with_poll(queue_name, qty:, vt: nil, max_poll_seconds: 5, poll_interval_ms: 100)
157
312
  full_name = config.queue_name(queue_name)
158
- with_stale_connection_retry do
159
- synchronized do
160
- @pgmq.read_with_poll(
161
- full_name,
162
- vt: vt || config.visibility_timeout,
163
- qty: qty,
164
- max_poll_seconds: max_poll_seconds,
165
- poll_interval_ms: poll_interval_ms
166
- )
313
+ guarded_read do
314
+ with_stale_connection_retry do
315
+ synchronized do
316
+ @pgmq.read_with_poll(
317
+ full_name,
318
+ vt: vt || config.visibility_timeout,
319
+ qty: qty,
320
+ max_poll_seconds: max_poll_seconds,
321
+ poll_interval_ms: poll_interval_ms
322
+ )
323
+ end
167
324
  end
168
325
  end
169
326
  end
@@ -178,11 +335,13 @@ module Pgbus
178
335
  # otherwise the pool can overflow on multi-queue reads (issue #123).
179
336
  def read_multi(queue_names, qty:, vt: nil, limit: nil)
180
337
  full_names = queue_names.map { |q| config.queue_name(q) }
181
- Instrumentation.instrument("pgbus.client.read_multi", queues: full_names, qty: qty, limit: limit) do
182
- with_stale_connection_retry do
183
- synchronized do
184
- with_read_timeout do
185
- @pgmq.read_multi(full_names, vt: vt || config.visibility_timeout, qty: qty, limit: limit)
338
+ guarded_read do
339
+ Instrumentation.instrument("pgbus.client.read_multi", queues: full_names, qty: qty, limit: limit) do
340
+ with_stale_connection_retry do
341
+ synchronized do
342
+ with_read_timeout do
343
+ @pgmq.read_multi(full_names, vt: vt || config.visibility_timeout, qty: qty, limit: limit)
344
+ end
186
345
  end
187
346
  end
188
347
  end
@@ -268,6 +427,25 @@ module Pgbus
268
427
  end
269
428
  end
270
429
 
430
+ # Snapshot of the PGMQ connection pool: {size:, available:, pool_timeout:}.
431
+ #
432
+ # Reads pgmq-ruby's own pool counters (@pgmq.stats -> {size:, available:})
433
+ # and adds the configured pool_timeout so alerting has the full picture:
434
+ # how many connections exist, how many are free right now, and how long a
435
+ # checkout waits before raising a pool-timeout error. Works on both the
436
+ # dedicated-pool path and the shared-Proc path (where size is 1).
437
+ #
438
+ # Purely observational — wrapped in a rescue that returns {} so a probe or
439
+ # heartbeat reading the pool can never break job processing. Not routed
440
+ # through with_stale_connection_retry: reading in-memory counters touches no
441
+ # socket, and a failing read must degrade to {} rather than retry.
442
+ def pool_stats
443
+ @pgmq.stats.merge(pool_timeout: config.pool_timeout)
444
+ rescue StandardError => e
445
+ Pgbus.logger.debug { "[Pgbus::Client] pool_stats unavailable: #{e.class}: #{e.message}" }
446
+ {}
447
+ end
448
+
271
449
  def list_queues
272
450
  with_stale_connection_retry do
273
451
  synchronized { @pgmq.list_queues }
@@ -358,26 +536,32 @@ module Pgbus
358
536
 
359
537
  def read_grouped(queue_name, qty:, vt: nil)
360
538
  full_name = config.queue_name(queue_name)
361
- Instrumentation.instrument("pgbus.client.read_grouped", queue: full_name, qty: qty) do
362
- with_stale_connection_retry do
363
- synchronized { with_read_timeout { @pgmq.read_grouped(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
539
+ guarded_read do
540
+ Instrumentation.instrument("pgbus.client.read_grouped", queue: full_name, qty: qty) do
541
+ with_stale_connection_retry do
542
+ synchronized { with_read_timeout { @pgmq.read_grouped(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
543
+ end
364
544
  end
365
545
  end
366
546
  end
367
547
 
368
548
  def read_grouped_rr(queue_name, qty:, vt: nil)
369
549
  full_name = config.queue_name(queue_name)
370
- Instrumentation.instrument("pgbus.client.read_grouped_rr", queue: full_name, qty: qty) do
371
- with_stale_connection_retry do
372
- synchronized { with_read_timeout { @pgmq.read_grouped_rr(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
550
+ guarded_read do
551
+ Instrumentation.instrument("pgbus.client.read_grouped_rr", queue: full_name, qty: qty) do
552
+ with_stale_connection_retry do
553
+ synchronized { with_read_timeout { @pgmq.read_grouped_rr(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
554
+ end
373
555
  end
374
556
  end
375
557
  end
376
558
 
377
559
  def read_grouped_head(queue_name, qty:, vt: nil)
378
560
  full_name = config.queue_name(queue_name)
379
- with_stale_connection_retry do
380
- synchronized { with_read_timeout { @pgmq.read_grouped_head(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
561
+ guarded_read do
562
+ with_stale_connection_retry do
563
+ synchronized { with_read_timeout { @pgmq.read_grouped_head(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
564
+ end
381
565
  end
382
566
  end
383
567
 
@@ -463,6 +647,19 @@ module Pgbus
463
647
 
464
648
  private
465
649
 
650
+ # Human-readable label for which config knob supplied the connection
651
+ # options, mirroring Configuration#connection_options' precedence. Used in
652
+ # verify_connection!'s error so the operator knows which setting to fix.
653
+ def connection_source
654
+ if config.database_url
655
+ "database_url"
656
+ elsif config.connection_params
657
+ "connection_params"
658
+ else
659
+ "ActiveRecord-derived connection"
660
+ end
661
+ end
662
+
466
663
  # Accept either a logical name ("default") or an already-prefixed
467
664
  # physical name ("pgbus_default") and return the physical name.
468
665
  # Coerces symbols to strings so callers can pass either form.
@@ -505,7 +702,7 @@ module Pgbus
505
702
 
506
703
  # Queues from worker configs
507
704
  (config.workers || []).each do |w|
508
- worker_queues = w[:queues] || w["queues"] || [config.default_queue]
705
+ worker_queues = w[:queues] || [config.default_queue]
509
706
  worker_queues.each { |q| queues << q unless q == "*" }
510
707
  end
511
708
 
@@ -610,8 +807,17 @@ module Pgbus
610
807
  # expected throttle interval. When it does, we can skip the destructive
611
808
  # DROP TRIGGER + CREATE TRIGGER cycle that causes deadlocks when multiple
612
809
  # forked processes race during bootstrap.
810
+ #
811
+ # Routes through the pooled @pgmq.with_connection (health-checked, reused)
812
+ # rather than opening a fresh PG.connect per queue: on the String/Hash path
813
+ # with_raw_connection did a full TCP/TLS/auth setup for every queue at every
814
+ # supervisor boot — and again in each forked child — churning short-lived
815
+ # connections through the pooler. The checkout here is a sequential sibling
816
+ # of the @pgmq.create call above it (create's own checkout has already been
817
+ # returned), so there is no nested checkout: safe even on the shared-Proc
818
+ # pool_size=1 path.
613
819
  def notify_trigger_current?(full_name, throttle_ms)
614
- with_raw_connection do |conn|
820
+ @pgmq.with_connection do |conn|
615
821
  result = conn.exec_params(<<~SQL, [full_name, throttle_ms])
616
822
  SELECT 1
617
823
  FROM pg_trigger t
@@ -687,11 +893,29 @@ module Pgbus
687
893
  ].freeze
688
894
  private_constant :STALE_CONNECTION_PATTERNS
689
895
 
690
- # Rescue PGMQ::Errors::ConnectionError once if its message matches a
691
- # known stale-socket pattern. pgmq-ruby's auto_reconnect + verify_connection!
692
- # already recovers on the *next* checkout, so a single retry is sufficient.
693
- # Other connection errors (pool timeout, misconfiguration, truly unreachable
694
- # DB) propagate.
896
+ # How many times a matched stale-connection error is retried before it
897
+ # propagates. Two attempts (not one) so a transient window — a PgBouncer
898
+ # restart or a brief failover that outlasts the first immediate retry
899
+ # still gets a second, backed-off chance rather than failing an enqueue
900
+ # the caller may never retry.
901
+ STALE_RETRY_ATTEMPTS = 2
902
+ private_constant :STALE_RETRY_ATTEMPTS
903
+
904
+ # Backoff before each retry, indexed by (attempt - 1): ~0.1s before the
905
+ # first retry, ~0.5s before the second. Short enough to stay invisible on
906
+ # a healthy path (error-path only — never slept on success) and to not
907
+ # stall a worker loop, long enough to let a pooler/failover window clear.
908
+ STALE_RETRY_DELAYS = [0.1, 0.5].freeze
909
+ private_constant :STALE_RETRY_DELAYS
910
+
911
+ # Rescue PGMQ::Errors::ConnectionError if its message matches a known
912
+ # stale-socket pattern, retrying up to STALE_RETRY_ATTEMPTS times with a
913
+ # short backoff (STALE_RETRY_DELAYS) between attempts. pgmq-ruby's
914
+ # auto_reconnect + verify_connection! recovers a single dead pooled socket
915
+ # on the *next* checkout, but a transient window — a PgBouncer restart or a
916
+ # brief failover — can outlast an immediate retry; the backed-off second
917
+ # attempt gives that window time to clear. Other connection errors (pool
918
+ # timeout, misconfiguration, truly unreachable DB) propagate immediately.
695
919
  #
696
920
  # Wraps every @pgmq.* call site. Pattern matching is intentionally narrow
697
921
  # (pre-flight / idle-socket signals only), so retry is safe even for
@@ -699,28 +923,281 @@ module Pgbus
699
923
  # connection was dead *before* pgmq-ruby tried to use it, so no SQL was
700
924
  # ever sent. Mid-flight errors like "server closed the connection" are
701
925
  # excluded from the pattern list for this reason.
702
- # Bound a read at config.read_timeout so a dead socket raises
703
- # Pgbus::ReadTimeoutError instead of blocking the worker loop forever.
926
+
927
+ # Seconds by which the outer bounds (client-side tcp_user_timeout and the
928
+ # Ruby Timeout last resort) exceed the server-side statement_timeout. Sizing
929
+ # the outer bounds a little higher lets a live-but-slow server's clean
930
+ # statement_timeout cancel win the race, so the outer bounds fire only when
931
+ # the peer is genuinely gone. See apply_connection_bounds and with_read_timeout.
932
+ READ_TIMEOUT_SLACK = 5
933
+ private_constant :READ_TIMEOUT_SLACK
934
+
935
+ # Bound a read and surface a timeout as Pgbus::ReadTimeoutError. Prefer
936
+ # libpq-native bounds baked into the connection; the Ruby Timeout is a
937
+ # narrow, last-resort fallback used only where libpq cannot bound a hung
938
+ # socket. In order, cleanest to last-resort:
939
+ #
940
+ # 1. statement_timeout (server GUC, baked into the connection) — a slow
941
+ # query is cancelled by Postgres → PG::QueryCanceled, which pgmq-ruby
942
+ # wraps as PGMQ::Errors::ConnectionError ("canceling statement due to
943
+ # statement timeout"); mapping_statement_timeout re-raises it as
944
+ # Pgbus::ReadTimeoutError. The clean path for a live-but-slow server.
945
+ # 2. tcp_user_timeout / keepalives (client-side libpq, baked into the
946
+ # connection) — a dead/hung socket makes libpq raise PG::ConnectionBad
947
+ # synchronously, which pgmq-ruby recognises and reconnects. NO
948
+ # Thread#raise, no buffer corruption. Linux + libpq >= 12 only.
949
+ #
950
+ # When @libpq_read_bounds_effective (the common production case: Linux,
951
+ # dedicated connection, read_timeout set, libpq >= 12) BOTH bounds are in
952
+ # force and Ruby Timeout is never wired in — pure libpq.
953
+ #
954
+ # 3. Ruby Timeout.timeout — the LAST resort, reached ONLY on a *dedicated*
955
+ # connection where libpq's socket bound is a no-op: non-Linux hosts
956
+ # (macOS/BSD/Windows) or a libpq < 12. It interrupts via Thread#raise —
957
+ # the mechanism issue #198 flags as unsafe — so it is slack-delayed and
958
+ # used only when there is no libpq alternative on that host.
704
959
  #
705
- # MUST wrap only the bare `@pgmq.read*` call, sitting *inside* both
706
- # `synchronized` and `with_stale_connection_retry`:
960
+ # The shared-AR Proc path deliberately gets NEITHER a baked-in bound NOR
961
+ # this Ruby Timeout: we don't own that socket, and Thread#raise on a
962
+ # connection ActiveRecord also queries is the most dangerous place to use
963
+ # it. Instead the operator configures libpq timeouts in database.yml
964
+ # (statement_timeout via `variables:`, plus tcp_user_timeout/keepalives),
965
+ # which AR passes straight through to the connection. #initialize logs a
966
+ # one-time hint when read_timeout is set on a Proc connection.
967
+ #
968
+ # KNOWN LIMITATION: when (3) fires on a genuinely hung socket, libpq may
969
+ # leave the pooled PG::Connection reporting CONNECTION_OK while it will
970
+ # in fact re-hang on reuse, and pgmq-ruby's health check won't discard
971
+ # it (it isn't CONNECTION_BAD). The proper fix is a public pool-reload on
972
+ # pgmq-ruby (follow-up, cf. mensfeld/pgmq-ruby#94); until then it's
973
+ # documented and confined to the non-Linux dedicated path.
974
+ #
975
+ # MUST wrap only the bare `@pgmq.read*` call, inside both `synchronized` and
976
+ # `with_stale_connection_retry`, so the Timeout clock starts only after the
977
+ # mutex is acquired (a thread queued behind another read is not charged for
978
+ # the wait) and each stale-retry attempt gets its own full budget:
707
979
  #
708
980
  # with_stale_connection_retry { synchronized { with_read_timeout { @pgmq.read* } } }
981
+ def with_read_timeout(&block)
982
+ # libpq covers everything (Linux, dedicated conn, read_timeout, libpq>=12),
983
+ # OR this is the Proc path where we defer to AR/database.yml — either way,
984
+ # no Ruby Timeout. Only the dedicated-but-libpq-can't-bound-the-socket case
985
+ # (non-Linux / libpq<12) falls through to the Timeout fallback below.
986
+ return mapping_statement_timeout(&block) if @libpq_read_bounds_effective || @shared_connection
987
+
988
+ timeout = config.read_timeout
989
+ return mapping_statement_timeout(&block) unless timeout&.positive?
990
+
991
+ # rubocop:disable Pgbus/NoRubyTimeout -- deliberate last-resort bound; see above
992
+ Timeout.timeout(timeout + READ_TIMEOUT_SLACK, Pgbus::ReadTimeoutError) do
993
+ mapping_statement_timeout(&block)
994
+ end
995
+ # rubocop:enable Pgbus/NoRubyTimeout
996
+ end
997
+
998
+ # True when libpq's connection-baked read bounds (statement_timeout +
999
+ # tcp_user_timeout + keepalives) fully cover both a slow query AND a
1000
+ # dead/hung socket, so with_read_timeout can skip the Ruby Timeout entirely.
1001
+ # Requires ALL of:
1002
+ # * a dedicated connection — the shared-AR Proc path has no baked-in bounds
1003
+ # (we don't own that socket; statement_timeout would leak into app queries)
1004
+ # * read_timeout set — apply_connection_bounds no-ops on nil, so skipping
1005
+ # Timeout with no bound installed would leave a read unbounded forever
1006
+ # * TCP_USER_TIMEOUT available — macOS/BSD/Windows no-op it, and keepalives
1007
+ # alone can't bound a stall mid-reply (data sent, never ACKed)
1008
+ # * libpq >= 12 — older libpq rejects the tcp_user_timeout conninfo keyword
1009
+ # outright (it fails the whole connection), so we must not have baked it in
1010
+ def libpq_read_bounds_effective?
1011
+ return false if @shared_connection
1012
+ return false unless config.read_timeout&.positive?
1013
+ return false unless Socket.const_defined?(:TCP_USER_TIMEOUT)
1014
+
1015
+ PG.library_version >= 120_000
1016
+ end
1017
+
1018
+ # On the shared-AR (Proc) path pgbus doesn't own the connection, so it bakes
1019
+ # in no read bounds and deliberately does NOT wrap reads in Ruby Timeout
1020
+ # (Thread#raise on a socket ActiveRecord also uses is the most dangerous
1021
+ # place for it). When read_timeout is set the operator likely expects reads
1022
+ # to be bounded, so point them at the libpq timeouts AR passes through from
1023
+ # database.yml — the same bounds pgbus's dedicated path installs itself.
1024
+ def warn_shared_connection_read_bounds
1025
+ return unless @shared_connection && config.read_timeout&.positive?
1026
+
1027
+ Pgbus.logger.warn do
1028
+ "[Pgbus::Client] read_timeout is set but pgbus is sharing ActiveRecord's " \
1029
+ "connection, so it can't bound reads itself. Configure libpq timeouts on " \
1030
+ "the pgbus connection in database.yml instead: `variables: { statement_timeout: <ms> }` " \
1031
+ "plus `tcp_user_timeout: <ms>` and `keepalives: 1` (Linux). Use a dedicated " \
1032
+ "database_url/connection_params for pgbus to have it apply these automatically."
1033
+ end
1034
+ end
1035
+
1036
+ # Substring pgmq-ruby surfaces (wrapped as PGMQ::Errors::ConnectionError)
1037
+ # when Postgres cancels a query that overran statement_timeout. Detected in
1038
+ # the read paths and re-raised as Pgbus::ReadTimeoutError so the server-side
1039
+ # bound preserves the same public contract the Ruby Timeout gave callers.
1040
+ STATEMENT_TIMEOUT_PATTERN = "canceling statement due to statement timeout"
1041
+ private_constant :STATEMENT_TIMEOUT_PATTERN
1042
+
1043
+ def statement_timeout_error?(error)
1044
+ error.message.to_s.downcase.include?(STATEMENT_TIMEOUT_PATTERN)
1045
+ end
1046
+
1047
+ # Run a read block, re-raising a server-side statement_timeout cancellation
1048
+ # as Pgbus::ReadTimeoutError. Wraps the read call sites so the public
1049
+ # contract (ReadTimeoutError on a timed-out read) holds whether the bound
1050
+ # fired server-side (the normal case) or via the Ruby Timeout fallback.
1051
+ def mapping_statement_timeout
1052
+ yield
1053
+ rescue PGMQ::Errors::ConnectionError => e
1054
+ raise Pgbus::ReadTimeoutError, e.message if statement_timeout_error?(e)
1055
+
1056
+ raise
1057
+ end
1058
+
1059
+ # Idle seconds before libpq starts probing a quiet connection with TCP
1060
+ # keepalives, and the interval/count of those probes. Sized to detect a
1061
+ # dead peer (or a NAT/LB that silently dropped an idle flow) well inside a
1062
+ # typical cloud idle-drop window (~350–600s). Pool and LISTEN connections
1063
+ # sit idle between reads, so keepalives are what catch a peer that vanished
1064
+ # while nothing was in flight. Client-side libpq keywords — never sent in
1065
+ # the startup packet, so a pooler (PgBouncer) can't reject them.
1066
+ KEEPALIVE_IDLE_SECONDS = 30
1067
+ KEEPALIVE_INTERVAL_SECONDS = 10
1068
+ KEEPALIVE_COUNT = 3
1069
+ private_constant :KEEPALIVE_IDLE_SECONDS, :KEEPALIVE_INTERVAL_SECONDS, :KEEPALIVE_COUNT
1070
+
1071
+ # Bake libpq-native read/connection bounds into the connection options of a
1072
+ # dedicated pgmq-ruby connection (issue #198). Two independent libpq
1073
+ # mechanisms, deliberately NOT Ruby's Timeout — Timeout interrupts via
1074
+ # Thread#raise, which can fire mid-libpq call and corrupt the pooled
1075
+ # PG::Connection's result buffer:
1076
+ #
1077
+ # 1. statement_timeout (server GUC, via `options=-c`) — bounds a query the
1078
+ # server is actively running. Postgres cancels it and sends back
1079
+ # PG::QueryCanceled, which the read paths map to Pgbus::ReadTimeoutError.
1080
+ # This is the bound for a live-but-slow server.
1081
+ # 2. tcp_user_timeout + keepalives (client-side libpq conninfo keywords) —
1082
+ # bound a dead/hung socket the server never answers on, where
1083
+ # statement_timeout structurally cannot fire (no live server to cancel).
1084
+ # libpq forces the socket closed and raises PG::ConnectionBad /
1085
+ # PG::UnableToSend synchronously on the calling thread — a clean error
1086
+ # through the normal pgmq path, no Thread#raise, no buffer corruption.
1087
+ # tcp_user_timeout catches death mid-read (data sent, never ACKed);
1088
+ # keepalives catch death on an idle connection.
1089
+ #
1090
+ # tcp_user_timeout is sized at read_timeout + a small slack so statement_timeout
1091
+ # (the clean server-side cancel) wins whenever the server is still answering;
1092
+ # the socket bound only fires when the peer is genuinely gone.
709
1093
  #
710
- # Two reasons for this nesting:
711
- # 1. On the shared-connection path @pgmq_mutex serializes all reads.
712
- # The timeout clock must start only after the mutex is acquired,
713
- # otherwise a thread queued behind another read is charged for the
714
- # wait and raises a false ReadTimeoutError for a socket it never
715
- # touched.
716
- # 2. with_stale_connection_retry can retry once; with the timeout
717
- # inside, each socket attempt gets its own full timeout budget
718
- # rather than sharing one across both attempts.
719
- def with_read_timeout(&)
1094
+ # Called only on the dedicated-connection branch (String URL / Hash params).
1095
+ # Never on the shared-AR Proc path statement_timeout is connection-wide and
1096
+ # would leak into application queries, and the socket there is AR's to own.
1097
+ #
1098
+ # NOTE: statement_timeout is connection-wide, so writes on these connections
1099
+ # gain the same bound. Acceptable — an enqueue that can't complete within
1100
+ # read_timeout is already failing and keeps a single server-side mechanism.
1101
+ #
1102
+ # Returns conn_opts unchanged when read_timeout is nil (bounding disabled).
1103
+ def apply_connection_bounds(conn_opts)
720
1104
  timeout = config.read_timeout
721
- return yield unless timeout&.positive?
1105
+ return conn_opts unless timeout&.positive?
1106
+
1107
+ statement_ms = (timeout * 1000).to_i
1108
+ # Socket-death bound sits just above the server-side query bound so a live
1109
+ # server's clean statement_timeout cancel always wins the race.
1110
+ socket_ms = ((timeout + READ_TIMEOUT_SLACK) * 1000).to_i
1111
+ # tcp_user_timeout is a libpq 12+ conninfo keyword; libpq < 12 rejects it
1112
+ # outright and fails the whole connection. So only bake in the socket-level
1113
+ # keywords when the linked libpq understands them — statement_timeout (a
1114
+ # server GUC via `options`) is always safe. Older libpq keeps just the
1115
+ # query bound; the Ruby Timeout fallback covers the socket there.
1116
+ with_socket = libpq_supports_socket_bounds?
1117
+
1118
+ case conn_opts
1119
+ when Hash
1120
+ merge_connection_bounds(conn_opts, statement_ms, socket_ms, with_socket: with_socket)
1121
+ when String
1122
+ append_connection_bounds(conn_opts, statement_ms, socket_ms, with_socket: with_socket)
1123
+ else
1124
+ conn_opts
1125
+ end
1126
+ end
1127
+
1128
+ # Whether the linked libpq accepts the tcp_user_timeout / keepalives conninfo
1129
+ # keywords. Added in libpq 12; an older libpq raises "invalid connection
1130
+ # option" and fails the connection, so we must not emit them there.
1131
+ def libpq_supports_socket_bounds?
1132
+ defined?(PG) && PG.respond_to?(:library_version) && PG.library_version >= 120_000
1133
+ end
722
1134
 
723
- Timeout.timeout(timeout, Pgbus::ReadTimeoutError, &)
1135
+ # Hash form maps 1:1 to libpq keywords, so no escaping/encoding is needed.
1136
+ # The GUC stays nested in `options`; the socket keywords are top-level.
1137
+ # Preserve any caller-supplied `:options` (e.g. `-c search_path=…`) by
1138
+ # appending our `-c statement_timeout=…` rather than overwriting it.
1139
+ def merge_connection_bounds(conn_opts, statement_ms, socket_ms, with_socket:)
1140
+ options = [conn_opts[:options], "-c statement_timeout=#{statement_ms}"].compact.join(" ")
1141
+ merged = conn_opts.merge(options: options)
1142
+ return merged unless with_socket
1143
+
1144
+ merged.merge(
1145
+ keepalives: 1,
1146
+ keepalives_idle: KEEPALIVE_IDLE_SECONDS,
1147
+ keepalives_interval: KEEPALIVE_INTERVAL_SECONDS,
1148
+ keepalives_count: KEEPALIVE_COUNT,
1149
+ tcp_user_timeout: socket_ms
1150
+ )
1151
+ end
1152
+
1153
+ # libpq accepts two connection-string forms. URI form (postgres:// or
1154
+ # postgresql://) carries keywords as URL-encoded query params — the GUC in
1155
+ # `options` must percent-encode its space (%20) and `=` (%3D). key=value
1156
+ # conninfo form carries them space-separated, with the GUC single-quoted so
1157
+ # the outer parser keeps `-c statement_timeout=…` as one value.
1158
+ def append_connection_bounds(conn_opts, statement_ms, socket_ms, with_socket:)
1159
+ if conn_opts.start_with?("postgres://", "postgresql://")
1160
+ separator = conn_opts.include?("?") ? "&" : "?"
1161
+ socket = if with_socket
1162
+ "keepalives=1&keepalives_idle=#{KEEPALIVE_IDLE_SECONDS}" \
1163
+ "&keepalives_interval=#{KEEPALIVE_INTERVAL_SECONDS}" \
1164
+ "&keepalives_count=#{KEEPALIVE_COUNT}&tcp_user_timeout=#{socket_ms}&"
1165
+ else
1166
+ ""
1167
+ end
1168
+ "#{conn_opts}#{separator}#{socket}options=-c%20statement_timeout%3D#{statement_ms}"
1169
+ else
1170
+ socket = if with_socket
1171
+ "keepalives=1 keepalives_idle=#{KEEPALIVE_IDLE_SECONDS} " \
1172
+ "keepalives_interval=#{KEEPALIVE_INTERVAL_SECONDS} " \
1173
+ "keepalives_count=#{KEEPALIVE_COUNT} tcp_user_timeout=#{socket_ms} "
1174
+ else
1175
+ ""
1176
+ end
1177
+ "#{conn_opts} #{socket}options='-c statement_timeout=#{statement_ms}'"
1178
+ end
1179
+ end
1180
+
1181
+ # Gate a read through the in-memory connection-health circuit breaker.
1182
+ # When the breaker is open the block never runs — Pgbus::ConnectionCircuitOpenError
1183
+ # is raised before any pool checkout, sparing a dead database from the whole
1184
+ # fleet re-polling and the error tracker from per-poll noise. A completed
1185
+ # read records success (closing/resetting the breaker); a
1186
+ # PGMQ::Errors::ConnectionError records a failure (and still propagates).
1187
+ # Writes are intentionally NOT gated — callers must see enqueue failures.
1188
+ def guarded_read(&)
1189
+ @connection_health.run_guarded(&)
1190
+ end
1191
+
1192
+ def log_circuit_open(backoff)
1193
+ Pgbus.logger.warn do
1194
+ "[Pgbus::Client] Connection circuit opened after #{ConnectionHealth::OPEN_THRESHOLD}+ " \
1195
+ "consecutive connection failures — reads fail fast for ~#{backoff}s"
1196
+ end
1197
+ end
1198
+
1199
+ def log_circuit_close
1200
+ Pgbus.logger.info { "[Pgbus::Client] Connection circuit closed — database reachable again" }
724
1201
  end
725
1202
 
726
1203
  def with_stale_connection_retry
@@ -729,10 +1206,19 @@ module Pgbus
729
1206
  yield
730
1207
  rescue PGMQ::Errors::ConnectionError => e
731
1208
  attempts += 1
732
- raise unless attempts == 1 && stale_connection_error?(e)
1209
+ raise enrich_pool_timeout_error(e) unless attempts <= STALE_RETRY_ATTEMPTS && stale_connection_error?(e)
1210
+
1211
+ # Sleep here — in the rescue, *outside* the yielded block — so the
1212
+ # backoff never runs while @pgmq_mutex is held: on the shared-connection
1213
+ # path the mutex lives inside `synchronized` within the yielded block,
1214
+ # and the raise unwinds out of it (releasing the mutex) before we get
1215
+ # here. See STALE_RETRY_DELAYS. Clamp the index to the last delay so a
1216
+ # future STALE_RETRY_ATTEMPTS > STALE_RETRY_DELAYS.size never sleeps nil.
1217
+ sleep STALE_RETRY_DELAYS[[attempts - 1, STALE_RETRY_DELAYS.size - 1].min]
733
1218
 
734
1219
  Pgbus.logger.warn do
735
- "[Pgbus::Client] Retrying after stale pgmq connection: #{e.message}"
1220
+ "[Pgbus::Client] Retrying after stale pgmq connection " \
1221
+ "(attempt #{attempts}/#{STALE_RETRY_ATTEMPTS}): #{e.message}"
736
1222
  end
737
1223
  retry
738
1224
  end
@@ -743,6 +1229,39 @@ module Pgbus
743
1229
  STALE_CONNECTION_PATTERNS.any? { |pattern| message.include?(pattern) }
744
1230
  end
745
1231
 
1232
+ # Substring pgmq-ruby uses when a pool checkout times out — a
1233
+ # ConnectionPool::TimeoutError re-raised as PGMQ::Errors::ConnectionError
1234
+ # "Connection pool timeout: ..." (see PGMQ::Connection#with_connection).
1235
+ # Deliberately NOT in STALE_CONNECTION_PATTERNS: a saturated pool must not
1236
+ # be retried (that just piles more waiters onto an already-exhausted pool).
1237
+ POOL_TIMEOUT_MARKER = "connection pool timeout"
1238
+ private_constant :POOL_TIMEOUT_MARKER
1239
+
1240
+ def pool_timeout_error?(error)
1241
+ error.message.to_s.downcase.include?(POOL_TIMEOUT_MARKER)
1242
+ end
1243
+
1244
+ # A bare "Connection pool timeout" tells an operator nothing actionable.
1245
+ # For that (and only that) error, return a same-class replacement whose
1246
+ # message carries the live pool state and a concrete next step, so the
1247
+ # first signal of saturation is diagnosable. The class is preserved so
1248
+ # callers rescuing PGMQ::Errors::ConnectionError behave identically. Any
1249
+ # other ConnectionError is returned untouched. Enrichment never raises:
1250
+ # pool_stats already rescues to {}, and a formatting failure falls back to
1251
+ # the original error.
1252
+ def enrich_pool_timeout_error(error)
1253
+ return error unless pool_timeout_error?(error)
1254
+
1255
+ stats = pool_stats
1256
+ detail = stats.empty? ? "" : " (pool #{stats})"
1257
+ error.class.new(
1258
+ "#{error.message}#{detail} — " \
1259
+ "raise Pgbus.configuration.pool_size or reduce worker threads"
1260
+ )
1261
+ rescue StandardError
1262
+ error
1263
+ end
1264
+
746
1265
  def serialize(data)
747
1266
  case data
748
1267
  when String