pgbus 0.16.1 → 0.16.2
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 +2 -0
- data/lib/pgbus/client/ensure_stream_queue.rb +23 -12
- data/lib/pgbus/client/notify_lock_retry.rb +114 -0
- data/lib/pgbus/client.rb +2 -0
- data/lib/pgbus/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bee03fec1c58fc9cda375da9c6aa4009f13091b5a3f042ae7de6e33d0a53fddb
|
|
4
|
+
data.tar.gz: 36da6db6f6786a67c9d0d8894501b31d783ca88011695683ecdf7da17aeaf749
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 133f075c1832d10fcfeb49eb937fe274f8009f03ce940970aef123a11c24129429122316212a02e42e4b40186d4c181b179bb9fa1237073cc5709da27edace4d
|
|
7
|
+
data.tar.gz: a52c6d64d27c696cb5287cdcccd03876a082eeb0106fd2c1978b8e95accf0f90f5474e99e9117ccf2039ffdf3ec5bdc9ee48279fc8bac59c98207ab39c3073c3
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
### Fixed
|
|
4
4
|
|
|
5
|
+
- **Concurrent first-broadcast notify-insert setup no longer pages on a Postgres deadlock / lock-wait (the residual of #403).** Two processes racing the first durable Turbo/Pgbus stream broadcast can both see "notify trigger not current" and both run `pgmq.enable_notify_insert` → `DROP TRIGGER` under `AccessExclusiveLock`. Postgres deadlocks, or hits `lock_timeout`, or — on pooled connections where `statement_timeout` < `lock_timeout` — `canceling statement due to statement timeout` **while locking**. PGMQ wraps those as `PGMQ::Errors::ConnectionError`. 0.16.1 already skips DROP when the trigger is current and retries stale sockets via `with_stale_connection_retry`, but it did not retry these lock races, so the residual still paged (getzazu/app#3817; AppSignal Zazu ma-prod #546 / za-prod #530). `ensure_stream_queue` now wraps the notify-setup path in a dedicated `with_notify_lock_retry` (up to 3 attempts, short backoff **outside** `@pgmq_mutex`): deadlock, lock-not-available, lock-timeout, and statement-timeout **with** lock-wait context retry; a bare statement timeout and permission errors still fail fast, while a missing-queue `ConnectionError` uses the existing one-time queue-recreation path without lock retries. `with_stale_connection_retry` is unchanged — that helper stays idle-socket / no-SQL-sent only. Consumers that prepended an app-level retry around `ensure_stream_queue` (getzazu/app#3827) can drop that prepend after upgrading.
|
|
6
|
+
|
|
5
7
|
- **A client that connects to the health port and never sends a request line no longer wedges every later probe (issue #455).** `Pgbus::Web::HealthServer` serves every probe from one accept-loop thread and read the request line with an unbounded `client.gets("\r\n", MAX_REQUEST_LINE)`, so a silent connection parked that read forever: docker's `--health-cmd` (`bin/pgbus-health`) then timed out on every attempt and the container stayed unhealthy for the rest of its life while the supervisor and its workers were fine. Each accepted connection now carries a 1-second `IO#timeout` — deliberately under `HealthProbe::DEFAULT_TIMEOUT` (2s), so one wedged client can't push the next probe past its own deadline — and the resulting `IO::TimeoutError` (an `IOError`) falls into the existing per-connection `rescue StandardError`, which logs it, drops the connection and frees the loop. `#stop` no longer leaks the thread either: a loop parked on an already-accepted client never sees the listening socket close, so `stop` kills the thread when it outlives the join and joins again afterwards (`Thread#kill` is asynchronous), instead of returning "stopped" while it is still running. Only reachable from inside the container on the `health_bind` 127.0.0.1 default — a wedged health gate, not a security issue. Refs #455.
|
|
6
8
|
- **The unbound-lock reaper no longer counts a dead-lettered copy as a live message, so a `:until_executed` job that once dead-lettered can be enqueued again.** `Client#uniqueness_keys_present` scanned every queue in `pgmq.meta` for the payload's `pgbus_uniqueness_key` — including `*_dlq` queues. A dead-lettered copy keeps the original payload (key included) but is not in flight: the executor already released the lock when it moved the message. For a legacy `pending`/`msg_id=0` row (pre-#418 enqueue, or a bind that failed) the DLQ copy made the key look "still here" forever, the reaper kept the row, and an `on_conflict: :discard` job — a heartbeat, typically — was discarded on every enqueue until someone purged the DLQ by hand. Seen in production as three heartbeats silently dead for nine days behind a 468-message DLQ. Dead-letter queues are now skipped in the scan; bound-lock probes (`message_exists?`) were never affected because they resolve the logical queue's physical tables only.
|
|
7
9
|
- **`Pgbus::Testing.disabled!` can no longer turn a Capybara teardown race into a hung test process (issue #443).** The `streams_test_mode` stub closed immediately, so a page on a stream-bearing layout had its `EventSource` reconnect every ~3s for the whole example; a reconnect landing after `disabled!` had switched test mode off — a config-level `after` hook runs *before* `Capybara.reset_sessions!` — took the real path and started a live `Streamer` (listener/dispatcher/heartbeat threads + a LISTEN connection) inside the RSpec process. Its orphaned threads then shared the test's pinned AR connection and CI died at the job timeout with only `message type 0x5a arrived from server while idle`. Three changes: the stub now emits `retry: 86400000` so the browser does not reconnect at all; `Streamer::Instance#shutdown!` snapshots every component's threads (`#threads` on Listener/Dispatcher/Heartbeat/OutboundPump/HubClient/FailoverListener), logs one error naming those still alive after their bounded joins, and returns that list (`Streamer.reset!` forwards it, `nil` when nothing was live); and `Testing.disabled!` raises `Pgbus::Testing::StreamerLeakError` — pointing at `config.append_after` — when threads leaked, while a live streamer that shut down cleanly is only logged. README and docs now recommend `config.append_after` for the teardown hook.
|
|
@@ -78,22 +78,33 @@ module Pgbus
|
|
|
78
78
|
# peer process's memo. The memoized path then skips `pgmq.create` and
|
|
79
79
|
# `enable_notify_insert` raises "Queue does not exist".
|
|
80
80
|
def ensure_stream_queue_tables(full_name, stream_name)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
synchronized { enable_notify_if_needed(full_name, 0) }
|
|
81
|
+
# Lock-retry wraps one setup attempt (create-path 250ms notify
|
|
82
|
+
# plus the stream 0ms override). Missing-queue recovery stays
|
|
83
|
+
# *outside* that budget so a later lock error cannot re-run
|
|
84
|
+
# forget+recreate up to ATTEMPTS times. The recovery attempt
|
|
85
|
+
# gets its own lock-retry — the DROP TRIGGER race can still
|
|
86
|
+
# happen after we recreate. Sleeps run after synchronized
|
|
87
|
+
# releases @pgmq_mutex. Do not fold these errors into
|
|
88
|
+
# with_stale_connection_retry (idle-socket only).
|
|
89
|
+
setup_stream_queue_tables(full_name, stream_name)
|
|
91
90
|
rescue PGMQ::Errors::ConnectionError => e
|
|
92
91
|
raise unless missing_pgmq_queue_error?(e)
|
|
93
92
|
|
|
94
93
|
forget_stream_queue_memo!(full_name, stream_name)
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
setup_stream_queue_tables(full_name, stream_name)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def setup_stream_queue_tables(full_name, stream_name)
|
|
98
|
+
with_notify_lock_retry(stream_name) do
|
|
99
|
+
ensure_single_queue(full_name)
|
|
100
|
+
|
|
101
|
+
# PGMQ's default NOTIFY throttle is 250ms — meant to coalesce
|
|
102
|
+
# high-frequency worker queue inserts. Streams are latency-
|
|
103
|
+
# sensitive and need every broadcast to fire a NOTIFY, even
|
|
104
|
+
# when several are batched within a single millisecond.
|
|
105
|
+
# Override the throttle to 0 specifically for stream queues.
|
|
106
|
+
synchronized { enable_notify_if_needed(full_name, 0) }
|
|
107
|
+
end
|
|
97
108
|
end
|
|
98
109
|
|
|
99
110
|
def forget_stream_queue_memo!(full_name, stream_name)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Client
|
|
5
|
+
# Retries Postgres deadlocks / lock timeouts around stream-queue
|
|
6
|
+
# notify-insert setup.
|
|
7
|
+
#
|
|
8
|
+
# 0.16.1 already skips `pgmq.enable_notify_insert` when the notify
|
|
9
|
+
# trigger is current (#403 treats a concurrent CREATE as success), but
|
|
10
|
+
# two processes can still both see "not current" and deadlock — or hit
|
|
11
|
+
# lock_timeout / statement_timeout-while-locking — on `DROP TRIGGER`
|
|
12
|
+
# under AccessExclusiveLock. PGMQ wraps those as
|
|
13
|
+
# `PGMQ::Errors::ConnectionError`, which `with_stale_connection_retry`
|
|
14
|
+
# must not treat as stale (that helper is idle-socket / no-SQL-sent
|
|
15
|
+
# only).
|
|
16
|
+
#
|
|
17
|
+
# Sleep here — in the rescue, *outside* the yielded block — so the
|
|
18
|
+
# backoff never runs while `synchronized` holds `@pgmq_mutex`. Callers
|
|
19
|
+
# wrap the full stream-queue tables attempt (create-path 250ms notify
|
|
20
|
+
# plus the stream 0ms override), not hold the mutex across this method.
|
|
21
|
+
# Missing-queue errors stay outside this policy (one-time recreation
|
|
22
|
+
# in `ensure_stream_queue_tables`); permission errors fail fast.
|
|
23
|
+
# On the shared-AR path a lock error inside the caller's transaction
|
|
24
|
+
# aborts that transaction — retrying on the same connection would
|
|
25
|
+
# raise "current transaction is aborted" and hide the lock error, so
|
|
26
|
+
# we re-raise the original instead.
|
|
27
|
+
module NotifyLockRetry
|
|
28
|
+
ATTEMPTS = 3
|
|
29
|
+
DELAYS = [0.05, 0.15, 0.35].freeze
|
|
30
|
+
|
|
31
|
+
# Also match statement-timeout lock waits: pooled connections often
|
|
32
|
+
# keep lock_timeout above statement_timeout, so a DROP TRIGGER waiter
|
|
33
|
+
# dies as PG::QueryCanceled ("canceling statement due to statement
|
|
34
|
+
# timeout / while locking"), not LockWaitTimeout. Bare statement
|
|
35
|
+
# timeout (no lock context) is deterministic — do not retry.
|
|
36
|
+
LOCK_FAILURE_PATTERN = /
|
|
37
|
+
deadlock\ detected
|
|
38
|
+
|lock\ not\ available
|
|
39
|
+
|canceling\ statement\ due\ to\ lock\ timeout
|
|
40
|
+
/ix
|
|
41
|
+
STATEMENT_TIMEOUT = /canceling statement due to statement timeout/i
|
|
42
|
+
LOCK_WAIT_CONTEXT = /while locking/i
|
|
43
|
+
|
|
44
|
+
def self.retryable?(error)
|
|
45
|
+
current = error
|
|
46
|
+
depth = 0
|
|
47
|
+
while current && depth < 8
|
|
48
|
+
return true if lock_class?(current) || lock_message?(current)
|
|
49
|
+
|
|
50
|
+
current = current.cause
|
|
51
|
+
depth += 1
|
|
52
|
+
end
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.lock_class?(error)
|
|
57
|
+
return false if error.nil?
|
|
58
|
+
|
|
59
|
+
(defined?(::PG::TRDeadlockDetected) && error.is_a?(::PG::TRDeadlockDetected)) ||
|
|
60
|
+
(defined?(::PG::LockNotAvailable) && error.is_a?(::PG::LockNotAvailable)) ||
|
|
61
|
+
(defined?(::ActiveRecord::Deadlocked) && error.is_a?(::ActiveRecord::Deadlocked)) ||
|
|
62
|
+
(defined?(::ActiveRecord::LockWaitTimeout) && error.is_a?(::ActiveRecord::LockWaitTimeout))
|
|
63
|
+
end
|
|
64
|
+
private_class_method :lock_class?
|
|
65
|
+
|
|
66
|
+
def self.lock_message?(error)
|
|
67
|
+
return false if error.nil?
|
|
68
|
+
|
|
69
|
+
message = error.message.to_s
|
|
70
|
+
return true if LOCK_FAILURE_PATTERN.match?(message)
|
|
71
|
+
|
|
72
|
+
STATEMENT_TIMEOUT.match?(message) && LOCK_WAIT_CONTEXT.match?(message)
|
|
73
|
+
end
|
|
74
|
+
private_class_method :lock_message?
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def with_notify_lock_retry(stream_name)
|
|
79
|
+
attempts = 0
|
|
80
|
+
begin
|
|
81
|
+
yield
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
attempts += 1
|
|
84
|
+
raise unless attempts < ATTEMPTS && NotifyLockRetry.retryable?(e)
|
|
85
|
+
# Shared-AR connection already inside (or aborted by) the caller's
|
|
86
|
+
# transaction: a retry on the same socket cannot recover and would
|
|
87
|
+
# mask this lock error with "current transaction is aborted".
|
|
88
|
+
# Dedicated-pool and idle after_commit paths still retry.
|
|
89
|
+
raise if notify_lock_retry_blocked_by_caller_transaction?
|
|
90
|
+
|
|
91
|
+
# Sleep here — in the rescue, *outside* the yielded block — so the
|
|
92
|
+
# backoff never runs while @pgmq_mutex is held: on the shared-
|
|
93
|
+
# connection path the mutex lives inside `synchronized` within the
|
|
94
|
+
# yielded block, and the raise unwinds out of it (releasing the
|
|
95
|
+
# mutex) before we get here. See DELAYS. Clamp the index to the
|
|
96
|
+
# last delay so a future ATTEMPTS > DELAYS.size never sleeps nil.
|
|
97
|
+
sleep DELAYS[[attempts - 1, DELAYS.size - 1].min]
|
|
98
|
+
|
|
99
|
+
Pgbus.logger.warn do
|
|
100
|
+
"[Pgbus::Client] Retrying stream-queue notify setup after a Postgres lock failure " \
|
|
101
|
+
"(attempt #{attempts}/#{ATTEMPTS} stream=#{stream_name}): #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
retry
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def notify_lock_retry_blocked_by_caller_transaction?
|
|
108
|
+
queue_ddl_rides_caller_transaction?
|
|
109
|
+
rescue StandardError
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/pgbus/client.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "socket"
|
|
5
5
|
require "timeout"
|
|
6
6
|
require_relative "client/read_after"
|
|
7
|
+
require_relative "client/notify_lock_retry"
|
|
7
8
|
require_relative "client/ensure_stream_queue"
|
|
8
9
|
require_relative "client/notify_stream"
|
|
9
10
|
require_relative "client/connection_health"
|
|
@@ -13,6 +14,7 @@ module Pgbus
|
|
|
13
14
|
class Client
|
|
14
15
|
include ReadAfter
|
|
15
16
|
include FairRead
|
|
17
|
+
include NotifyLockRetry
|
|
16
18
|
include EnsureStreamQueue
|
|
17
19
|
include NotifyStream
|
|
18
20
|
|
data/lib/pgbus/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pgbus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.16.
|
|
4
|
+
version: 0.16.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -271,6 +271,7 @@ files:
|
|
|
271
271
|
- lib/pgbus/client/connection_health.rb
|
|
272
272
|
- lib/pgbus/client/ensure_stream_queue.rb
|
|
273
273
|
- lib/pgbus/client/fair_read.rb
|
|
274
|
+
- lib/pgbus/client/notify_lock_retry.rb
|
|
274
275
|
- lib/pgbus/client/notify_stream.rb
|
|
275
276
|
- lib/pgbus/client/read_after.rb
|
|
276
277
|
- lib/pgbus/client/resizable_pool.rb
|