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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 869b9dc59dc3fdab2396f6dcfa8e1fa04372abd23cdd527e0a07078d2d93a98d
4
- data.tar.gz: 9fa6807b78c09b3ef397b98013165e866233ca8bceb6d660a3f12fe5a5b696d8
3
+ metadata.gz: bee03fec1c58fc9cda375da9c6aa4009f13091b5a3f042ae7de6e33d0a53fddb
4
+ data.tar.gz: 36da6db6f6786a67c9d0d8894501b31d783ca88011695683ecdf7da17aeaf749
5
5
  SHA512:
6
- metadata.gz: 519e747379272929b6add403ca50e3b7b9a64df5b326a73b8372c24dd87d5edea58076fc9d93e95bd78de1df5e46482f095da283a67bf093aceae659ca895218
7
- data.tar.gz: 5f32250830c195bb1e35422fa4ff504ff3e95cd0ba8afdd8cb9b239f18e1f3fc860339bf7fa5ca2284438413e2b8dfbae6e530b922418df1f5464641cd9f0b4a
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
- ensure_single_queue(full_name)
82
-
83
- # PGMQ's default NOTIFY throttle is 250ms meant to coalesce
84
- # high-frequency worker queue inserts. Streams are latency-
85
- # sensitive and need every broadcast to fire a NOTIFY, even
86
- # when several are batched within a single millisecond.
87
- # Override the throttle to 0 specifically for stream queues.
88
- # Use the idempotent path to avoid deadlocks when multiple
89
- # processes race to set up the same stream queue.
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
- ensure_single_queue(full_name)
96
- synchronized { enable_notify_if_needed(full_name, 0) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.16.1"
4
+ VERSION = "0.16.2"
5
5
  end
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.1
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