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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +119 -1
- data/Rakefile +10 -1
- data/app/helpers/pgbus/application_helper.rb +37 -0
- data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
- data/config/locales/da.yml +4 -0
- data/config/locales/de.yml +4 -0
- data/config/locales/en.yml +4 -0
- data/config/locales/es.yml +4 -0
- data/config/locales/fi.yml +4 -0
- data/config/locales/fr.yml +4 -0
- data/config/locales/it.yml +4 -0
- data/config/locales/ja.yml +4 -0
- data/config/locales/nb.yml +4 -0
- data/config/locales/nl.yml +4 -0
- data/config/locales/pt.yml +4 -0
- data/config/locales/sv.yml +4 -0
- data/lib/pgbus/active_job/executor.rb +25 -4
- data/lib/pgbus/cli/dlq.rb +164 -0
- data/lib/pgbus/cli.rb +18 -1
- data/lib/pgbus/client/connection_health.rb +194 -0
- data/lib/pgbus/client.rb +592 -73
- data/lib/pgbus/config_loader.rb +23 -4
- data/lib/pgbus/configuration.rb +98 -12
- data/lib/pgbus/dedup_cache.rb +8 -0
- data/lib/pgbus/doctor.rb +250 -0
- data/lib/pgbus/engine.rb +15 -0
- data/lib/pgbus/execution_pools/async_pool.rb +7 -0
- data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
- data/lib/pgbus/instrumentation.rb +1 -0
- data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
- data/lib/pgbus/metrics/backend.rb +38 -0
- data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
- data/lib/pgbus/metrics/backends/statsd.rb +64 -0
- data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
- data/lib/pgbus/metrics/subscriber.rb +190 -0
- data/lib/pgbus/metrics.rb +42 -0
- data/lib/pgbus/process/consumer.rb +215 -8
- data/lib/pgbus/process/consumer_priority.rb +34 -0
- data/lib/pgbus/process/dispatcher.rb +265 -41
- data/lib/pgbus/process/heartbeat.rb +18 -5
- data/lib/pgbus/process/memory_usage.rb +48 -0
- data/lib/pgbus/process/notify_listener.rb +26 -7
- data/lib/pgbus/process/notify_probe.rb +96 -0
- data/lib/pgbus/process/primary_validator.rb +53 -0
- data/lib/pgbus/process/signal_handler.rb +6 -0
- data/lib/pgbus/process/supervisor.rb +396 -46
- data/lib/pgbus/process/worker.rb +298 -35
- data/lib/pgbus/recurring/scheduler.rb +15 -1
- data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
- data/lib/pgbus/table_maintenance.rb +13 -2
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +20 -4
- data/lib/pgbus/web/health_app.rb +102 -0
- data/lib/pgbus/web/health_server.rb +144 -0
- data/lib/pgbus/web/streamer/instance.rb +55 -1
- data/lib/pgbus/web/streamer/listener.rb +72 -9
- data/lib/pgbus.rb +37 -0
- data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +42 -0
- data/lib/rubocop/pgbus.rb +5 -0
- data/lib/tasks/pgbus_doctor.rake +12 -0
- 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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 =
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
294
|
+
config.priority_queue_names(queue_name).each do |pq_name|
|
|
295
|
+
break if remaining <= 0
|
|
142
296
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
303
|
+
msgs.each { |m| results << [pq_name, m] }
|
|
304
|
+
remaining -= msgs.size
|
|
305
|
+
end
|
|
152
306
|
|
|
153
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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] ||
|
|
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
|
-
|
|
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
|
-
#
|
|
691
|
-
#
|
|
692
|
-
#
|
|
693
|
-
#
|
|
694
|
-
#
|
|
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
|
-
|
|
703
|
-
#
|
|
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
|
-
#
|
|
706
|
-
#
|
|
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
|
-
#
|
|
711
|
-
#
|
|
712
|
-
#
|
|
713
|
-
#
|
|
714
|
-
#
|
|
715
|
-
#
|
|
716
|
-
#
|
|
717
|
-
#
|
|
718
|
-
#
|
|
719
|
-
def
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|