solid_queue-listen_notify 0.5.1 → 0.5.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: 02e14b868585c5b513fd8c1e16222d75af15f95fb1a8775171505c4825b971a9
4
- data.tar.gz: 16868e921aff4dd98118f4617da5b3007b6ad346e001231d34a8cb00f611d2de
3
+ metadata.gz: 8e7cb2a646ad717d865cea7a0806f9e1606bcc8a7b1c75aefdcf0211a280b4dc
4
+ data.tar.gz: c973ecd38ed1cc6aac33ebf297ba5d7c8adbf9d6379973ebc9685cb4e131e6a6
5
5
  SHA512:
6
- metadata.gz: df252638c88f888e052bde5e4ac262b28cc83830a095e445a24937d816758122a8373ca474c3ae28541b0c152864034b0eeee32b09525089b9d6da99b46ef221
7
- data.tar.gz: 468f5eed99503c2690e37f107d16afed17ef2c5df279f8f411d2d2e987d5363fe80467b81beb867e7d5304f49ae51d97de0348e7533c667a65ba3e9f37c03dfd
6
+ metadata.gz: b1970e7d67ad65596a81e9edd4af43d8d38775296e9b97b4372c8eafb27a02206cd37ea86a82d0d5753fff7d12551e55b25f32b5343db914c16d17e8c14c498d
7
+ data.tar.gz: cdca29cbc3ad90d3337e8bd942b553612cd61951c75723fb10ff9fc3024acc6aec19bbc558b1e1b972fd493b29f2ce952a2153fd1e9d651491500ca8712e0b50
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.2] - 2026-07-28
11
+
12
+ ### Fixed
13
+
14
+ - A raising `ActiveSupport::Notifications` subscriber can no longer strand a worker at the raised polling interval mid-registration, or run between a notification and the worker wake-ups it triggers.
15
+
16
+ ### Changed
17
+
18
+ - Internal: the registry is now the single owner of per-worker state (queue snapshot and replaced polling interval); the `notify` event's duration no longer includes the dispatch itself (payload unchanged).
19
+
10
20
  ## [0.5.1] - 2026-07-28
11
21
 
12
22
  ### Fixed
@@ -19,7 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
29
 
20
30
 
21
31
 
22
- [Unreleased]: https://github.com/cmer/solid_queue-listen_notify/compare/v0.5.1...HEAD
32
+ [Unreleased]: https://github.com/cmer/solid_queue-listen_notify/compare/v0.5.2...HEAD
33
+ [0.5.2]: https://github.com/cmer/solid_queue-listen_notify/compare/v0.5.1...v0.5.2
23
34
  [0.5.1]: https://github.com/cmer/solid_queue-listen_notify/compare/v0.5.0...v0.5.1
24
35
  [0.5.0]: https://github.com/cmer/solid_queue-listen_notify/compare/v0.1.0...v0.5.0
25
36
  [0.1.0]: https://github.com/cmer/solid_queue-listen_notify/releases/tag/v0.1.0
data/CLAUDE.md CHANGED
@@ -36,7 +36,7 @@ bundle exec appraisal generate # after editi
36
36
 
37
37
  `lib/solid_queue/listen_notify.rb` is the facade: config (`mattr_accessor`, mirroring solid_queue's style; copied from `config.solid_queue_listen_notify` by the Railtie), `register`/`deregister` (called from the lifecycle hooks; fully rescued — this gem must never make a worker look broken), the per-pid memoized `operational?`, and `after_fork`.
38
38
 
39
- Flow at worker boot: `register(worker)` → `operational?` runs **Preflight** once per process → if operational, raise the worker's `polling_interval` to `fallback_polling_interval` (only ever raise, record the original) → `Registry.instance.register(worker)` starts the **Listener** on first registration.
39
+ Flow at worker boot: `register(worker)` → `operational?` runs **Preflight** once per process → if operational, raise the worker's `polling_interval` to `fallback_polling_interval` (only ever raise) → `Registry.instance.register(worker, restore_interval:)` records the replaced interval with the worker and starts the **Listener** on first registration. The registry is the single owner of per-worker state (queue snapshot + replaced interval); on a fatal listener death it hands the `[worker, original_interval]` pairs back to the facade, which restores and wakes them.
40
40
 
41
41
  - **`Preflight`** (`preflight.rb`) — decides operational-or-not; never raises; each failure prints an actionable WARN/ERROR banner (loud failure is a product requirement, not a nicety). Checks in order: enabled → channel 1–63 bytes → adapter is PG → trigger installed (auto-installed by default when missing) → trigger function body contains the exact `pg_notify('<channel>', ...)` call → **cross-connection self-test**: LISTEN on the listener's connection path, NOTIFY from a pooled queue-DB connection. Cross-connection is what detects both PgBouncer transaction pooling and a `listen_database` pointing at the wrong database.
42
42
  - **`Registry`** (`registry.rb`) — per-process singleton multiplexing one listener to N workers (async mode runs N workers as threads in one process). Locking discipline is documented in its header comment and must be preserved: the mutex is never held across `wake_up`, `listener.stop`, or anything blocking; listeners are detached under the lock and stopped outside it. Cleanup is crash-tolerant, never hook-dependent (`on_worker_stop` is skipped on SIGQUIT `exit!`): dead workers are reaped on register and on the keepalive tick.
@@ -17,6 +17,27 @@ module SolidQueue
17
17
  # Everything ActiveRecord-shaped in here is resolved inside method bodies:
18
18
  # requiring this file must not require ActiveRecord.
19
19
  class ConnectionProvider
20
+ # Best-effort teardown for a connection this provider handed out: nobody
21
+ # else can close it (it was removed from its pool), and it may already be
22
+ # broken, so both statements are attempted and both failures swallowed.
23
+ def self.release(connection, channel:)
24
+ return if connection.nil?
25
+
26
+ begin
27
+ connection.execute("UNLISTEN #{connection.quote_column_name(channel)}")
28
+ rescue StandardError
29
+ nil
30
+ end
31
+
32
+ begin
33
+ connection.disconnect!
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ nil
39
+ end
40
+
20
41
  def initialize
21
42
  @mutex = Mutex.new
22
43
  @pools = {}
@@ -30,10 +51,6 @@ module SolidQueue
30
51
  end
31
52
  end
32
53
 
33
- def to_proc
34
- method(:call).to_proc
35
- end
36
-
37
54
  # Drops the memoized isolated pools. The connections handed out so far are
38
55
  # not affected: they were removed from those pools and belong to whoever
39
56
  # asked for them.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "connection_provider"
4
+
3
5
  module SolidQueue
4
6
  module ListenNotify
5
7
  # One background thread owning one dedicated Postgres connection: LISTENs on
@@ -220,24 +222,18 @@ module SolidQueue
220
222
  end
221
223
  end
222
224
 
225
+ # Dispatches first, instruments after: the ordering is what guarantees
226
+ # that no notifications subscriber — however it subscribed — can stand
227
+ # between a notification and the wake-ups it owes.
223
228
  def handle_notification(payload)
224
229
  @consecutive_failures = 0
225
230
  queue_name = payload.to_s
226
- dispatched = false
227
231
 
228
- instrument(:notify, queue_name: queue_name) do |event|
229
- dispatched = true
230
- counts = registry.dispatch(queue_name)
231
- event.merge!(counts) if counts.is_a?(Hash)
232
- end
232
+ counts = registry.dispatch(queue_name)
233
233
 
234
- # ActiveSupport runs a plain `subscribe` block AFTER the instrumented
235
- # block, so a subscriber that raises has not stopped the fan-out above
236
- # from happening. The rarer `subscribe(name, object_responding_to_start)`
237
- # form does run before it, though, and a notification dropped because
238
- # somebody's metrics object raised would be a job left waiting for the
239
- # next poll.
240
- registry.dispatch(queue_name) unless dispatched
234
+ event_payload = { queue_name: queue_name }
235
+ event_payload.merge!(counts) if counts.is_a?(Hash)
236
+ instrument(:notify, **event_payload)
241
237
  end
242
238
 
243
239
  def tick_keepalive
@@ -319,24 +315,10 @@ module SolidQueue
319
315
  nil
320
316
  end
321
317
 
322
- # Best effort on both statements: this runs on connections we already
323
- # know may be broken.
324
318
  def safe_disconnect
325
319
  connection = @connection
326
320
  @connection = nil
327
- return if connection.nil?
328
-
329
- begin
330
- connection.execute("UNLISTEN #{connection.quote_column_name(channel)}")
331
- rescue StandardError
332
- nil
333
- end
334
-
335
- begin
336
- connection.disconnect!
337
- rescue StandardError
338
- nil
339
- end
321
+ ConnectionProvider.release(connection, channel: channel)
340
322
  end
341
323
 
342
324
  # Sleeps in wait_timeout-sized slices so that #stop is never delayed by
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "trigger_installer"
4
+ require_relative "connection_provider"
4
5
 
5
6
  module SolidQueue
6
7
  module ListenNotify
@@ -176,7 +177,7 @@ module SolidQueue
176
177
  end
177
178
  ensure
178
179
  # Nobody else can: this connection was removed from its pool.
179
- disconnect(connection)
180
+ ConnectionProvider.release(connection, channel: channel)
180
181
  end
181
182
  end
182
183
 
@@ -190,20 +191,6 @@ module SolidQueue
190
191
  end
191
192
  end
192
193
 
193
- def disconnect(connection)
194
- begin
195
- connection.execute("UNLISTEN #{connection.quote_column_name(channel)}")
196
- rescue StandardError
197
- nil
198
- end
199
-
200
- begin
201
- connection.disconnect!
202
- rescue StandardError
203
- nil
204
- end
205
- end
206
-
207
194
  # Two statements, one connection: a transaction-mode pooler will very
208
195
  # likely serve them from two different backends. Only ever used to
209
196
  # enrich the self-test failure, never to decide anything.
@@ -20,7 +20,15 @@ module SolidQueue
20
20
  # is what guarantees "at most one listener per non-empty transition" when
21
21
  # several worker threads register concurrently.
22
22
  # * #dispatch runs on the listener thread and performs zero database work.
23
+ #
24
+ # The registry is the single owner of per-worker state: the queue snapshot
25
+ # used to route notifications, and the polling interval the module replaced
26
+ # at registration time — kept so that a listener which dies for good can
27
+ # hand every worker back to be put where it was found. One map, one lock,
28
+ # one fork-reset path.
23
29
  class Registry
30
+ # Everything this process knows about a registered worker.
31
+ Entry = Struct.new(:queues, :restore_interval)
24
32
  class << self
25
33
  # A class-level ivar rather than a constant, because the fork hook has to
26
34
  # be able to REPLACE it: fork() copies only the calling thread, and this
@@ -70,17 +78,23 @@ module SolidQueue
70
78
  def initialize(listener_factory: nil)
71
79
  @listener_factory = listener_factory || method(:build_default_listener)
72
80
  @mutex = Mutex.new
73
- @workers = {}
81
+ @workers = empty_workers
74
82
  @listener = nil
75
83
  @pid = ::Process.pid
76
84
  end
77
85
 
78
- def register(worker)
86
+ # `restore_interval` is the polling interval the module replaced when it
87
+ # raised this worker's — nil when nothing was changed. It is recorded here,
88
+ # with the worker, so that the crash path has one place to look.
89
+ def register(worker, restore_interval: nil)
79
90
  guard_fork!
80
91
 
81
92
  @mutex.synchronize do
82
93
  sweep_dead_locked
83
- @workers[worker] ||= snapshot_queues(worker)
94
+ entry = (@workers[worker] ||= Entry.new(snapshot_queues(worker), nil))
95
+ # ||=, not =: a re-registration must never overwrite the interval the
96
+ # worker originally had with one this gem already raised.
97
+ entry.restore_interval ||= restore_interval
84
98
  start_listener_locked
85
99
  end
86
100
 
@@ -118,7 +132,13 @@ module SolidQueue
118
132
  next false unless @listener.equal?(listener)
119
133
 
120
134
  @listener = nil
121
- stranded = @workers.keys
135
+ # Hand each interval over exactly once: a second crash after a revival
136
+ # must not "restore" workers that are already back on their own.
137
+ stranded = @workers.map do |worker, entry|
138
+ pair = [ worker, entry.restore_interval ]
139
+ entry.restore_interval = nil
140
+ pair
141
+ end
122
142
  true
123
143
  end
124
144
 
@@ -141,8 +161,8 @@ module SolidQueue
141
161
  skipped_saturated = 0
142
162
  unreachable = nil
143
163
 
144
- pairs.each do |worker, queues|
145
- next unless QueueMatcher.matches?(queues, queue_name)
164
+ pairs.each do |worker, entry|
165
+ next unless QueueMatcher.matches?(entry.queues, queue_name)
146
166
 
147
167
  if !wake_saturated && pool_saturated?(worker)
148
168
  skipped_saturated += 1
@@ -202,7 +222,7 @@ module SolidQueue
202
222
  # single-threaded here: replace it instead of trying to take it.
203
223
  @mutex = Mutex.new
204
224
  @listener = nil
205
- @workers = {}
225
+ @workers = empty_workers
206
226
  @pid = ::Process.pid
207
227
 
208
228
  ListenNotify.instrument(:fork_detected, parent_pid: parent_pid, pid: @pid)
@@ -217,7 +237,7 @@ module SolidQueue
217
237
  listener = @mutex.synchronize do
218
238
  current = @listener
219
239
  @listener = nil
220
- @workers = {}
240
+ @workers = empty_workers
221
241
  current
222
242
  end
223
243
 
@@ -250,7 +270,14 @@ module SolidQueue
250
270
 
251
271
  # Caller holds @mutex.
252
272
  def sweep_dead_locked
253
- @workers.delete_if { |worker, _queues| !worker_alive?(worker) }
273
+ @workers.delete_if { |worker, _entry| !worker_alive?(worker) }
274
+ end
275
+
276
+ # Keyed by identity: a Solid Queue worker defines no #hash or #eql?, and
277
+ # relying on the default object identity implicitly would be relying on
278
+ # something nobody wrote down.
279
+ def empty_workers
280
+ {}.compare_by_identity
254
281
  end
255
282
 
256
283
  def reap(workers)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module SolidQueue
4
4
  module ListenNotify
5
- VERSION = "0.5.1"
5
+ VERSION = "0.5.2"
6
6
  end
7
7
  end
@@ -60,12 +60,6 @@ module SolidQueue
60
60
  @@preflight_result = nil
61
61
  @@fallback_logger = nil
62
62
 
63
- # worker => the polling interval it had before we raised it. Kept so that a
64
- # listener which dies for good can put every worker back where it found it,
65
- # rather than leaving it polling every 10 seconds with nothing to wake it.
66
- @@overrides_lock = Mutex.new
67
- @@overrides = {}.compare_by_identity
68
-
69
63
  # `SOLID_QUEUE_LISTEN_NOTIFY_ENABLED=false` is documented as a kill switch
70
64
  # that needs no deploy, and an application that sets `enabled` explicitly —
71
65
  # to either value — would otherwise overwrite the mattr default and take
@@ -107,8 +101,15 @@ module SolidQueue
107
101
  return nil unless operational?
108
102
 
109
103
  restore_to = raise_polling_interval(worker)
110
- Registry.instance.register(worker)
111
- remember_override(worker, restore_to)
104
+ Registry.instance.register(worker, restore_interval: restore_to)
105
+
106
+ # Instrumented only once the registry has recorded the override, so that
107
+ # a subscriber raising here finds the bookkeeping already consistent — it
108
+ # can never strand a raised interval that nobody remembers.
109
+ if restore_to
110
+ instrument :override_polling_interval,
111
+ worker_name: worker_name(worker), from: restore_to, to: fallback_polling_interval.to_f
112
+ end
112
113
  nil
113
114
  rescue StandardError, ScriptError => e
114
115
  # A worker left polling every 10 seconds with nothing to wake it is the one
@@ -121,12 +122,11 @@ module SolidQueue
121
122
  # on_worker_stop is not guaranteed to run at all (a supervisor that exceeds
122
123
  # its shutdown timeout exits without callbacks), so this is a convenience,
123
124
  # never the thing cleanup depends on.
125
+ #
126
+ # Never instantiate the registry just to deregister: this hook also runs in
127
+ # processes where nothing ever registered, and building a registry there
128
+ # would be building one that only exists to be empty.
124
129
  def deregister(worker)
125
- forget_override(worker)
126
-
127
- # Never instantiate the registry just to deregister: this hook also runs in
128
- # processes where nothing ever registered, and building a registry there
129
- # would be building one that only exists to be empty.
130
130
  return nil unless Registry.instantiated?
131
131
 
132
132
  Registry.instance.deregister(worker)
@@ -136,7 +136,9 @@ module SolidQueue
136
136
  end
137
137
 
138
138
  # Called by the registry when the listener thread died for good — a fatal,
139
- # non-connection error, which no reconnect will fix.
139
+ # non-connection error, which no reconnect will fix. `stranded` is the
140
+ # registry's list of [worker, original_interval] pairs; the interval is nil
141
+ # for workers whose interval was never raised.
140
142
  #
141
143
  # The gem raised these workers' polling intervals on the strength of a
142
144
  # promise it can no longer keep, and a worker left polling every 10 seconds
@@ -144,8 +146,8 @@ module SolidQueue
144
146
  # gem. So the promise is withdrawn: every interval we raised goes back, every
145
147
  # worker is woken so it re-reads it rather than finishing its current sleep,
146
148
  # and the whole thing is said out loud.
147
- def listener_crashed(error, workers)
148
- restored = Array(workers).count { |worker| restore_override(worker) }
149
+ def listener_crashed(error, stranded)
150
+ restored = Array(stranded).count { |worker, interval| restore_override(worker, interval) }
149
151
  announce_listener_death(error, restored)
150
152
  nil
151
153
  rescue StandardError, ScriptError => e
@@ -184,18 +186,16 @@ module SolidQueue
184
186
  # belongs to the preflight lock: `operational?` holds it across connecting to
185
187
  # Postgres and a self-test that waits up to two seconds, and a child that
186
188
  # inherited it locked would hang the first worker to register — permanently,
187
- # inside a lifecycle hook, with no error and no output. Replacing three
188
- # mutexes in a process that is still single-threaded costs nothing, and it
189
- # means the gem is not relying on an interpreter implementation detail to
190
- # avoid its worst failure mode.
189
+ # inside a lifecycle hook, with no error and no output. Replacing a mutex in
190
+ # a process that is still single-threaded costs nothing, and it means the
191
+ # gem is not relying on an interpreter implementation detail to avoid its
192
+ # worst failure mode.
191
193
  #
192
- # Order matters: locks first, then the guards that use them. The preflight
194
+ # Order matters: the lock first, then the guards that use it. The preflight
193
195
  # VERDICT needs no reset (it is stamped with the pid that reached it, so a
194
196
  # child recomputes its own).
195
197
  def after_fork
196
198
  @@preflight_lock = Mutex.new
197
- @@overrides_lock = Mutex.new
198
- @@overrides = {}.compare_by_identity
199
199
 
200
200
  connection_provider.forget_pools! if connection_provider.respond_to?(:forget_pools!)
201
201
  Registry.after_fork!
@@ -228,7 +228,6 @@ module SolidQueue
228
228
  # preflight to run again after reconfiguring.
229
229
  def reset!
230
230
  @@preflight_lock.synchronize { @@preflight_result = nil }
231
- @@overrides_lock.synchronize { @@overrides = {}.compare_by_identity }
232
231
  connection_provider.reset! if connection_provider.respond_to?(:reset!)
233
232
  nil
234
233
  end
@@ -292,6 +291,8 @@ module SolidQueue
292
291
  # all only because operational? proved notifications arrive.
293
292
  #
294
293
  # Returns the interval that was replaced, or nil when nothing changed.
294
+ # Deliberately does NOT instrument: the caller does, once the registry
295
+ # has recorded what happened here.
295
296
  def raise_polling_interval(worker)
296
297
  target = fallback_polling_interval&.to_f
297
298
  return nil if target.nil?
@@ -301,7 +302,6 @@ module SolidQueue
301
302
  return nil if current.nil? || target <= current.to_f
302
303
 
303
304
  worker.polling_interval = target
304
- instrument :override_polling_interval, worker_name: worker_name(worker), from: current, to: target
305
305
  current
306
306
  end
307
307
 
@@ -313,27 +313,10 @@ module SolidQueue
313
313
  nil
314
314
  end
315
315
 
316
- # Overridden intervals ---------------------------------------------------
317
- #
318
- # Keyed by identity: a Solid Queue worker defines no #hash or #eql?, but
319
- # relying on the default object identity implicitly would be relying on
320
- # something nobody wrote down.
321
-
322
- def remember_override(worker, previous)
323
- return if previous.nil?
324
-
325
- @@overrides_lock.synchronize { @@overrides[worker] = previous }
326
- end
327
-
328
- def forget_override(worker)
329
- @@overrides_lock.synchronize { @@overrides.delete(worker) }
330
- end
331
-
332
- # Puts one worker back on its own interval and wakes it, so that it
333
- # re-reads the interval now rather than at the end of the sleep it is
334
- # already in. Returns whether anything was actually restored.
335
- def restore_override(worker)
336
- previous = @@overrides_lock.synchronize { @@overrides.delete(worker) }
316
+ # Puts one worker back on the interval the registry remembered for it and
317
+ # wakes it, so that it re-reads the interval now rather than at the end of
318
+ # the sleep it is already in. Returns whether anything was restored.
319
+ def restore_override(worker, previous)
337
320
  return false if previous.nil?
338
321
 
339
322
  current = current_polling_interval(worker)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue-listen_notify
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Mercier