pgbus 0.9.7 → 0.9.9
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 +367 -0
- data/README.md +454 -25
- 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/generators/pgbus/{add_job_locks_generator.rb → add_uniqueness_keys_generator.rb} +5 -5
- data/lib/pgbus/active_job/adapter.rb +1 -1
- 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 +50 -4
- data/lib/pgbus/configuration.rb +294 -79
- data/lib/pgbus/dedup_cache.rb +8 -0
- data/lib/pgbus/doctor.rb +275 -0
- data/lib/pgbus/engine.rb +15 -0
- data/lib/pgbus/execution_pools/async_pool.rb +9 -2
- data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
- data/lib/pgbus/generators/config_converter.rb +7 -5
- data/lib/pgbus/generators/migration_detector.rb +20 -16
- data/lib/pgbus/instrumentation.rb +3 -1
- data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
- data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
- 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 +214 -0
- data/lib/pgbus/metrics.rb +43 -0
- data/lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql +2126 -0
- data/lib/pgbus/pgmq_schema.rb +7 -2
- data/lib/pgbus/process/consumer.rb +354 -18
- 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 +423 -50
- data/lib/pgbus/process/worker.rb +288 -35
- data/lib/pgbus/recurring/schedule.rb +1 -2
- data/lib/pgbus/recurring/scheduler.rb +15 -1
- data/lib/pgbus/serializer.rb +4 -4
- data/lib/pgbus/streams/broadcastable_override.rb +0 -8
- data/lib/pgbus/streams/signed_name.rb +2 -2
- data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
- data/lib/pgbus/table_maintenance.rb +13 -2
- data/lib/pgbus/uniqueness.rb +11 -12
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +36 -4
- data/lib/pgbus/web/health_app.rb +102 -0
- data/lib/pgbus/web/health_server.rb +144 -0
- data/lib/pgbus/web/payload_filter.rb +3 -3
- data/lib/pgbus/web/streamer/instance.rb +58 -2
- data/lib/pgbus/web/streamer/listener.rb +69 -21
- data/lib/pgbus.rb +77 -0
- data/lib/tasks/pgbus_doctor.rake +12 -0
- metadata +19 -4
- data/app/models/pgbus/job_lock.rb +0 -98
- data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -7,11 +7,30 @@ module Pgbus
|
|
|
7
7
|
class Worker
|
|
8
8
|
include SignalHandler
|
|
9
9
|
|
|
10
|
-
attr_reader :queues, :threads, :config, :execution_mode
|
|
11
|
-
|
|
10
|
+
attr_reader :queues, :threads, :config, :execution_mode,
|
|
11
|
+
:rate_counter, :wake_signal, :restore_streak, :lifecycle
|
|
12
|
+
# stat_buffer is writable so a test can swap in a buffer double after
|
|
13
|
+
# construction to assert graceful_shutdown / check_recycle flush it. The
|
|
14
|
+
# executor captured the original buffer at construction, but these paths
|
|
15
|
+
# flush @stat_buffer directly, so the swap is observable.
|
|
16
|
+
attr_accessor :stat_buffer
|
|
17
|
+
# notify_listener / notify_retry_at / notify_retry_backoff are writable so
|
|
18
|
+
# tests can seed the self-healing listener state, re-arm the backoff window
|
|
19
|
+
# between calls, and simulate start_notify_listener assigning the listener
|
|
20
|
+
# from inside a stub (production mutates all three in the run loop).
|
|
21
|
+
attr_accessor :notify_listener, :notify_retry_at, :notify_retry_backoff
|
|
22
|
+
|
|
23
|
+
# The collaborators below (rate_counter, wake_signal, stat_buffer) and the
|
|
24
|
+
# recycle clock (started_at_monotonic) accept injected seeds so tests can
|
|
25
|
+
# observe or stub them without poking private ivars. All default to the
|
|
26
|
+
# exact values production constructs, so behavior is unchanged.
|
|
12
27
|
def initialize(queues:, threads: 5, config: Pgbus.configuration,
|
|
13
28
|
single_active_consumer: false, consumer_priority: 0,
|
|
14
|
-
execution_mode: :threads, group_mode: nil
|
|
29
|
+
execution_mode: :threads, group_mode: nil, liveness_pipe: nil,
|
|
30
|
+
rate_counter: nil, wake_signal: nil, stat_buffer: :default,
|
|
31
|
+
notify_listener: nil, notify_retry_at: 0.0,
|
|
32
|
+
notify_retry_backoff: NOTIFY_RETRY_BASE_SECONDS,
|
|
33
|
+
started_at_monotonic: nil)
|
|
15
34
|
@queues = Array(queues)
|
|
16
35
|
@initial_queues = @queues.dup.freeze
|
|
17
36
|
@wildcard = @queues.include?("*")
|
|
@@ -38,20 +57,47 @@ module Pgbus
|
|
|
38
57
|
@jobs_failed = Concurrent::AtomicFixnum.new(0)
|
|
39
58
|
@in_flight = Concurrent::AtomicFixnum.new(0)
|
|
40
59
|
@loop_tick_at = Concurrent::AtomicReference.new(nil)
|
|
41
|
-
@rate_counter = RateCounter.new(:processed, :failed, :dequeued)
|
|
60
|
+
@rate_counter = rate_counter || RateCounter.new(:processed, :failed, :dequeued)
|
|
42
61
|
@started_at = Time.current
|
|
43
|
-
@started_at_monotonic = monotonic_now
|
|
44
|
-
|
|
62
|
+
@started_at_monotonic = started_at_monotonic || monotonic_now
|
|
63
|
+
# stat_buffer: :default means "build one iff config.stats_enabled";
|
|
64
|
+
# passing an explicit value (including nil) overrides that for tests.
|
|
65
|
+
@stat_buffer =
|
|
66
|
+
if stat_buffer == :default
|
|
67
|
+
if config.stats_enabled
|
|
68
|
+
Pgbus::StatBuffer.new(
|
|
69
|
+
flush_size: config.stats_flush_size,
|
|
70
|
+
flush_interval: config.stats_flush_interval
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
stat_buffer
|
|
75
|
+
end
|
|
45
76
|
@executor = Pgbus::ActiveJob::Executor.new(stat_buffer: @stat_buffer)
|
|
46
|
-
@wake_signal = WakeSignal.new
|
|
77
|
+
@wake_signal = wake_signal || WakeSignal.new
|
|
47
78
|
@pool = ExecutionPools.build(
|
|
48
79
|
mode: @execution_mode,
|
|
49
80
|
capacity: threads,
|
|
50
81
|
on_state_change: -> { @wake_signal.notify! }
|
|
51
82
|
)
|
|
52
83
|
@circuit_breaker = Pgbus::CircuitBreaker.new(config: config)
|
|
84
|
+
@drain_started_at = nil
|
|
85
|
+
# Evict/restore cooldown state (issue #209). When a permanently-deleted
|
|
86
|
+
# queue is evicted down to an empty list, restoring the initial queues
|
|
87
|
+
# immediately re-triggers the same undefined-table error every loop tick.
|
|
88
|
+
# These back off the restore attempt on an exponential schedule instead.
|
|
89
|
+
@restore_streak = 0
|
|
90
|
+
@last_evicted_at = nil
|
|
91
|
+
@deferral_warned = false
|
|
53
92
|
@queue_lock = QueueLock.new if @single_active_consumer
|
|
54
|
-
@notify_listener =
|
|
93
|
+
@notify_listener = notify_listener
|
|
94
|
+
@notify_retry_at = notify_retry_at
|
|
95
|
+
@notify_retry_backoff = notify_retry_backoff
|
|
96
|
+
# OS-level liveness channel to the supervisor watchdog. Optional: nil
|
|
97
|
+
# unless the supervisor forked us with one. Written from stamp_loop_tick
|
|
98
|
+
# so the watchdog can detect a wedged worker even when the database (and
|
|
99
|
+
# thus the Heartbeat's loop_tick_at) is unavailable.
|
|
100
|
+
@liveness_pipe = liveness_pipe
|
|
55
101
|
end
|
|
56
102
|
|
|
57
103
|
def stats
|
|
@@ -69,8 +115,37 @@ module Pgbus
|
|
|
69
115
|
}.merge(@pool.metadata)
|
|
70
116
|
end
|
|
71
117
|
|
|
118
|
+
# Test seams for the atomic counters recycle logic and prefetch
|
|
119
|
+
# flow-control consult. Production only increments these during message
|
|
120
|
+
# handling; seeding them lets a test cross a threshold without running
|
|
121
|
+
# thousands of real jobs.
|
|
122
|
+
def jobs_processed=(count)
|
|
123
|
+
@jobs_processed.value = count
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def in_flight=(count)
|
|
127
|
+
@in_flight.value = count
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The last wall-clock loop-tick stamp (Time.now.to_f) fed to the
|
|
131
|
+
# heartbeat's loop_tick_supplier. Wall-clock — NOT monotonic — so it stays
|
|
132
|
+
# comparable across the process boundary the supervisor watchdog reads it
|
|
133
|
+
# over. nil until the first stamp_loop_tick.
|
|
134
|
+
def last_loop_tick
|
|
135
|
+
@loop_tick_at.get
|
|
136
|
+
end
|
|
137
|
+
|
|
72
138
|
NOTIFY_FALLBACK_POLL_SECONDS = 15
|
|
73
139
|
|
|
140
|
+
# NotifyListener startup can fail on a transient boot-time condition (DB
|
|
141
|
+
# restarting, failover, DNS blip) or its thread can die mid-run. Rather
|
|
142
|
+
# than downgrade to blind polling until process restart, the loop retries
|
|
143
|
+
# from ensure_notify_listener on an exponential backoff: NOTIFY_RETRY_BASE
|
|
144
|
+
# doubling up to NOTIFY_RETRY_MAX. Constant-tuned, matching the
|
|
145
|
+
# CircuitBreaker/Dispatcher precedent.
|
|
146
|
+
NOTIFY_RETRY_BASE_SECONDS = 5
|
|
147
|
+
NOTIFY_RETRY_MAX_SECONDS = 300
|
|
148
|
+
|
|
74
149
|
def run
|
|
75
150
|
setup_signals
|
|
76
151
|
start_heartbeat
|
|
@@ -87,9 +162,14 @@ module Pgbus
|
|
|
87
162
|
process_signals
|
|
88
163
|
check_recycle
|
|
89
164
|
refresh_wildcard_queues
|
|
165
|
+
ensure_notify_listener
|
|
90
166
|
|
|
91
167
|
break if @lifecycle.stopped?
|
|
92
|
-
|
|
168
|
+
# quiesced? (all slots free), not idle? (any slot free) — exiting
|
|
169
|
+
# with work still in flight abandons those jobs to the 30s
|
|
170
|
+
# wait_for_termination timeout in shutdown. Bounded by
|
|
171
|
+
# config.drain_timeout so a stuck job can't wedge the loop forever.
|
|
172
|
+
break if @lifecycle.draining? && (@pool.quiesced? || drain_deadline_exceeded?)
|
|
93
173
|
|
|
94
174
|
claim_and_execute if @lifecycle.can_process?
|
|
95
175
|
@stat_buffer&.flush_if_due
|
|
@@ -103,6 +183,13 @@ module Pgbus
|
|
|
103
183
|
Pgbus.logger.info { "[Pgbus] Worker shutting down gracefully..." }
|
|
104
184
|
Pgbus.stopping = true
|
|
105
185
|
@lifecycle.transition_to(:draining)
|
|
186
|
+
# Flush buffered stats at drain entry so the ≤ stats_flush_interval /
|
|
187
|
+
# stats_flush_size window isn't lost if the supervisor watchdog SIGKILLs
|
|
188
|
+
# a stalled worker before the drain-loop shutdown flush runs. Runs on the
|
|
189
|
+
# main loop thread (signals are dispatched via process_signals, not in
|
|
190
|
+
# trap context), so the DB write is safe. flush is thread-safe and
|
|
191
|
+
# no-ops when the buffer is empty.
|
|
192
|
+
@stat_buffer&.flush
|
|
106
193
|
@wake_signal.notify!
|
|
107
194
|
end
|
|
108
195
|
|
|
@@ -121,6 +208,15 @@ module Pgbus
|
|
|
121
208
|
# regex on every queue-missing error in hot fetch/read paths.
|
|
122
209
|
MISSING_QUEUE_REGEX = /pgmq\.q_(\w+)/
|
|
123
210
|
|
|
211
|
+
# Exponential backoff bounds for restoring evicted queues (issue #209).
|
|
212
|
+
# After the worker's queues are fully evicted (queue table permanently
|
|
213
|
+
# deleted), the first restore waits RESTORE_COOLDOWN_BASE seconds; each
|
|
214
|
+
# consecutive failed restore doubles the wait up to RESTORE_COOLDOWN_MAX.
|
|
215
|
+
# A successful fetch resets the streak so a recreated queue restores
|
|
216
|
+
# promptly. Constant-tuned, matching the NOTIFY_RETRY precedent.
|
|
217
|
+
RESTORE_COOLDOWN_BASE = 30
|
|
218
|
+
RESTORE_COOLDOWN_MAX = 300
|
|
219
|
+
|
|
124
220
|
private
|
|
125
221
|
|
|
126
222
|
def claim_and_execute
|
|
@@ -168,17 +264,33 @@ module Pgbus
|
|
|
168
264
|
return []
|
|
169
265
|
end
|
|
170
266
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
267
|
+
results =
|
|
268
|
+
if priority_enabled?
|
|
269
|
+
fetch_prioritized(active_queues, qty)
|
|
270
|
+
elsif @group_mode
|
|
271
|
+
fetch_grouped(active_queues, qty)
|
|
272
|
+
elsif active_queues.size == 1
|
|
273
|
+
queue = active_queues.first
|
|
274
|
+
messages = Pgbus.client.read_batch(queue, qty: qty) || []
|
|
275
|
+
messages.map { |m| [queue, m] }
|
|
276
|
+
else
|
|
277
|
+
fetch_multi(active_queues, qty)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# A read that reached here without an undefined-queue error means the
|
|
281
|
+
# queue tables exist again; drop the restore backoff so a recreated
|
|
282
|
+
# queue is reinstated promptly after the next eviction (issue #209).
|
|
283
|
+
@restore_streak = 0
|
|
284
|
+
results
|
|
285
|
+
rescue Pgbus::ConnectionCircuitOpenError
|
|
286
|
+
# The client-level connection breaker is open: the database has failed
|
|
287
|
+
# enough consecutive connection attempts that reads now fail fast. Idle
|
|
288
|
+
# this poll without an ErrorReporter call — the whole point of the
|
|
289
|
+
# breaker is to stop every worker flooding the error tracker for the
|
|
290
|
+
# duration of a database outage. The open/close transitions are logged
|
|
291
|
+
# once by the client (Client#log_circuit_open / #log_circuit_close),
|
|
292
|
+
# not per poll here.
|
|
293
|
+
[]
|
|
182
294
|
rescue StandardError => e
|
|
183
295
|
if undefined_queue_table_error?(e)
|
|
184
296
|
evict_missing_queues(e)
|
|
@@ -339,17 +451,56 @@ module Pgbus
|
|
|
339
451
|
end
|
|
340
452
|
end
|
|
341
453
|
Pgbus.logger.error { "[Pgbus] Queue table missing: #{error.message}" }
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
454
|
+
return unless @queues.empty? && !@wildcard
|
|
455
|
+
|
|
456
|
+
# Open (or re-arm) the restore cooldown window: stamp the eviction time so
|
|
457
|
+
# restore_evicted_queues can measure the backoff, and clear the
|
|
458
|
+
# per-window deferral-warned flag so the next deferral logs exactly once.
|
|
459
|
+
@last_evicted_at = monotonic_now
|
|
460
|
+
@deferral_warned = false
|
|
461
|
+
restore_evicted_queues
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Restore the worker's initial queues after a full eviction — but only once
|
|
465
|
+
# the exponential cooldown has elapsed (issue #209). While the cooldown is
|
|
466
|
+
# pending, leaves @queues empty (the caller idles via the empty-active_queues
|
|
467
|
+
# path) and logs a single deferral warn per window rather than one error pair
|
|
468
|
+
# per loop tick. Each actual restore escalates the streak so a permanently
|
|
469
|
+
# deleted queue backs off toward RESTORE_COOLDOWN_MAX instead of spinning.
|
|
345
470
|
def restore_evicted_queues
|
|
471
|
+
return unless restore_cooldown_elapsed?
|
|
472
|
+
|
|
346
473
|
@queues = @initial_queues.dup
|
|
474
|
+
@restore_streak += 1
|
|
347
475
|
Pgbus.logger.warn do
|
|
348
476
|
"[Pgbus] Worker queue list was empty after eviction — " \
|
|
349
477
|
"restoring initial queues: #{@queues.join(", ")}"
|
|
350
478
|
end
|
|
351
479
|
end
|
|
352
480
|
|
|
481
|
+
# True once RESTORE_COOLDOWN_BASE * 2**streak seconds (capped at
|
|
482
|
+
# RESTORE_COOLDOWN_MAX) have passed since the window opened. When still
|
|
483
|
+
# pending, emits at most one deferral warn per window naming the wait.
|
|
484
|
+
def restore_cooldown_elapsed?
|
|
485
|
+
return true if @last_evicted_at.nil?
|
|
486
|
+
|
|
487
|
+
wait = restore_cooldown_seconds
|
|
488
|
+
return true if monotonic_now - @last_evicted_at >= wait
|
|
489
|
+
|
|
490
|
+
unless @deferral_warned
|
|
491
|
+
@deferral_warned = true
|
|
492
|
+
Pgbus.logger.warn do
|
|
493
|
+
"[Pgbus] All queues evicted; deferring restore — next restore attempt in #{wait}s " \
|
|
494
|
+
"(streak=#{@restore_streak})"
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
false
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def restore_cooldown_seconds
|
|
501
|
+
[RESTORE_COOLDOWN_BASE * (2**@restore_streak), RESTORE_COOLDOWN_MAX].min
|
|
502
|
+
end
|
|
503
|
+
|
|
353
504
|
def detect_zombie(queue_name, message)
|
|
354
505
|
return unless config.zombie_detection
|
|
355
506
|
return unless message.read_ct.to_i > 1
|
|
@@ -365,6 +516,20 @@ module Pgbus
|
|
|
365
516
|
Pgbus.logger.debug { "[Pgbus] Zombie detection failed: #{e.class}: #{e.message}" }
|
|
366
517
|
end
|
|
367
518
|
|
|
519
|
+
# Lazily stamps the drain start on first call — the predicate is only
|
|
520
|
+
# reached while draining, so this covers every path into the drain
|
|
521
|
+
# state (graceful_shutdown, recycling) without hooking each one.
|
|
522
|
+
def drain_deadline_exceeded?
|
|
523
|
+
@drain_started_at ||= monotonic_now
|
|
524
|
+
return false unless monotonic_now - @drain_started_at > config.drain_timeout
|
|
525
|
+
|
|
526
|
+
Pgbus.logger.warn do
|
|
527
|
+
"[Pgbus] Worker drain deadline (#{config.drain_timeout}s) reached with #{@in_flight.value} job(s) " \
|
|
528
|
+
"still in flight — proceeding to shutdown"
|
|
529
|
+
end
|
|
530
|
+
true
|
|
531
|
+
end
|
|
532
|
+
|
|
368
533
|
def check_recycle
|
|
369
534
|
return unless @lifecycle.running?
|
|
370
535
|
|
|
@@ -373,6 +538,9 @@ module Pgbus
|
|
|
373
538
|
|
|
374
539
|
Pgbus.stopping = true
|
|
375
540
|
@lifecycle.transition_to(:draining)
|
|
541
|
+
# Flush buffered stats on recycle-triggered drain for the same reason as
|
|
542
|
+
# graceful_shutdown: shrink the SIGKILL loss window. Same-thread, safe.
|
|
543
|
+
@stat_buffer&.flush
|
|
376
544
|
Pgbus::Instrumentation.instrument(
|
|
377
545
|
"pgbus.worker.recycle",
|
|
378
546
|
reason: reason,
|
|
@@ -416,16 +584,9 @@ module Pgbus
|
|
|
416
584
|
true
|
|
417
585
|
end
|
|
418
586
|
|
|
587
|
+
# Instrumentation payload may report a value up to MEMORY_CHECK_TTL seconds old.
|
|
419
588
|
def current_memory_mb
|
|
420
|
-
|
|
421
|
-
`ps -o rss= -p #{::Process.pid}`.to_i / 1024
|
|
422
|
-
else
|
|
423
|
-
begin
|
|
424
|
-
File.read("/proc/#{::Process.pid}/statm").split[1].to_i * 4096 / (1024 * 1024)
|
|
425
|
-
rescue Errno::ENOENT
|
|
426
|
-
0
|
|
427
|
-
end
|
|
428
|
-
end
|
|
589
|
+
MemoryUsage.current_mb
|
|
429
590
|
end
|
|
430
591
|
|
|
431
592
|
def notify_wakeup?
|
|
@@ -433,7 +594,11 @@ module Pgbus
|
|
|
433
594
|
end
|
|
434
595
|
|
|
435
596
|
def wake_timeout
|
|
436
|
-
|
|
597
|
+
# A dead listener (running? false) will never wake the loop, so treat
|
|
598
|
+
# it as absent and keep polling at the short interval until
|
|
599
|
+
# ensure_notify_listener restarts it. Only a live listener earns the
|
|
600
|
+
# long NOTIFY-mode ceiling.
|
|
601
|
+
return effective_polling_interval unless notify_wakeup? && @notify_listener&.running?
|
|
437
602
|
|
|
438
603
|
[effective_polling_interval, config.polling_interval, NOTIFY_FALLBACK_POLL_SECONDS].max
|
|
439
604
|
end
|
|
@@ -467,6 +632,42 @@ module Pgbus
|
|
|
467
632
|
end
|
|
468
633
|
end
|
|
469
634
|
|
|
635
|
+
# Self-heal a NotifyListener that never started or whose thread died.
|
|
636
|
+
# Called each loop iteration but gated by a monotonic backoff timestamp
|
|
637
|
+
# so a persistent outage retries on 5s→…→300s intervals, not every tick
|
|
638
|
+
# (mirrors refresh_wildcard_queues' throttle). A restarted listener has
|
|
639
|
+
# its queue subscription reconciled (wildcard workers) and the backoff
|
|
640
|
+
# reset; a still-failing restart doubles the backoff up to the cap.
|
|
641
|
+
def ensure_notify_listener
|
|
642
|
+
return unless notify_wakeup?
|
|
643
|
+
return if @notify_listener&.running?
|
|
644
|
+
return if monotonic_now < @notify_retry_at
|
|
645
|
+
|
|
646
|
+
stop_dead_notify_listener
|
|
647
|
+
start_notify_listener
|
|
648
|
+
|
|
649
|
+
if @notify_listener&.running?
|
|
650
|
+
sync_notify_listener_queues
|
|
651
|
+
@notify_retry_backoff = NOTIFY_RETRY_BASE_SECONDS
|
|
652
|
+
else
|
|
653
|
+
@notify_retry_backoff = [@notify_retry_backoff * 2, NOTIFY_RETRY_MAX_SECONDS].min
|
|
654
|
+
end
|
|
655
|
+
@notify_retry_at = monotonic_now + @notify_retry_backoff
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Stop a listener whose thread died so its dedicated PG connection is
|
|
659
|
+
# released before start_notify_listener allocates a fresh one. A nil or
|
|
660
|
+
# still-running listener is left alone (the caller already gated on it).
|
|
661
|
+
def stop_dead_notify_listener
|
|
662
|
+
return if @notify_listener.nil?
|
|
663
|
+
|
|
664
|
+
@notify_listener.stop
|
|
665
|
+
rescue StandardError => e
|
|
666
|
+
Pgbus.logger.warn { "[Pgbus] Failed to stop dead NotifyListener: #{e.class}: #{e.message}" }
|
|
667
|
+
ensure
|
|
668
|
+
@notify_listener = nil
|
|
669
|
+
end
|
|
670
|
+
|
|
470
671
|
def sync_notify_listener_queues
|
|
471
672
|
return unless @notify_listener
|
|
472
673
|
|
|
@@ -494,12 +695,49 @@ module Pgbus
|
|
|
494
695
|
queues: queues, threads: threads, pid: ::Process.pid,
|
|
495
696
|
execution_mode: @execution_mode, consumer_priority: @consumer_priority
|
|
496
697
|
},
|
|
497
|
-
on_beat: -> {
|
|
498
|
-
loop_tick_supplier: -> { @loop_tick_at.get }
|
|
698
|
+
on_beat: -> { on_heartbeat },
|
|
699
|
+
loop_tick_supplier: -> { @loop_tick_at.get },
|
|
700
|
+
metadata_supplier: -> { throughput_metadata }
|
|
499
701
|
)
|
|
500
702
|
@heartbeat.start
|
|
501
703
|
end
|
|
502
704
|
|
|
705
|
+
# Runs once per heartbeat interval (not per job), so it's the right place
|
|
706
|
+
# to snapshot the per-beat rate counters and emit connection-pool
|
|
707
|
+
# observability without touching any per-job hot path. Pool utilization
|
|
708
|
+
# goes out as a `pgbus.client.pool` event carrying {size:, available:,
|
|
709
|
+
# pool_timeout:}. Reading the pool must never crash the beat — pool_stats
|
|
710
|
+
# already rescues to {}, and this whole method is guarded so a listener or
|
|
711
|
+
# unexpected error can't take down the heartbeat thread.
|
|
712
|
+
def on_heartbeat
|
|
713
|
+
@rate_counter.snapshot!
|
|
714
|
+
emit_pool_stats
|
|
715
|
+
rescue StandardError => e
|
|
716
|
+
Pgbus.logger.debug { "[Pgbus] Worker heartbeat hook error: #{e.class}: #{e.message}" }
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def emit_pool_stats
|
|
720
|
+
stats = Pgbus.client.pool_stats
|
|
721
|
+
return if stats.empty?
|
|
722
|
+
|
|
723
|
+
Pgbus::Instrumentation.instrument("pgbus.client.pool", stats)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Per-worker throughput persisted into pgbus_processes.metadata on every
|
|
727
|
+
# heartbeat so the dashboard can show cluster-wide live rates. Called by
|
|
728
|
+
# the Heartbeat's metadata_supplier after on_beat (snapshot!) has
|
|
729
|
+
# refreshed the rate counter, so these rates are current. Keys are
|
|
730
|
+
# stringified because the value round-trips through JSON in the metadata
|
|
731
|
+
# column.
|
|
732
|
+
def throughput_metadata
|
|
733
|
+
{
|
|
734
|
+
"rates" => @rate_counter.rates.transform_keys(&:to_s),
|
|
735
|
+
"jobs_processed" => @jobs_processed.value,
|
|
736
|
+
"jobs_failed" => @jobs_failed.value,
|
|
737
|
+
"in_flight" => @in_flight.value
|
|
738
|
+
}
|
|
739
|
+
end
|
|
740
|
+
|
|
503
741
|
def shutdown
|
|
504
742
|
Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
|
|
505
743
|
@notify_listener&.stop
|
|
@@ -520,8 +758,23 @@ module Pgbus
|
|
|
520
758
|
# Wall clock is required because the supervisor watchdog reads
|
|
521
759
|
# this value from a different process (cross-fork) and the
|
|
522
760
|
# dashboard reads it from a different host.
|
|
761
|
+
#
|
|
762
|
+
# Also pokes the OS-level liveness pipe (when the supervisor forked us
|
|
763
|
+
# with one) so the watchdog has a database-independent signal. The write
|
|
764
|
+
# is non-blocking and never raises in the hot path: exception: false
|
|
765
|
+
# returns :wait_writable on a full pipe (which itself proves recent,
|
|
766
|
+
# undrained ticks — liveness — so a dropped write is fine), and the
|
|
767
|
+
# rescue covers the reader-gone / fd-closed cases so a dead pipe can
|
|
768
|
+
# never crash the worker loop. The payload is a content-free byte: the
|
|
769
|
+
# supervisor treats "any bytes readable" as liveness and stamps arrival
|
|
770
|
+
# time on its own monotonic clock, so no timestamp crosses the fork.
|
|
523
771
|
def stamp_loop_tick
|
|
524
772
|
@loop_tick_at.set(Time.now.to_f)
|
|
773
|
+
return unless @liveness_pipe
|
|
774
|
+
|
|
775
|
+
@liveness_pipe.write_nonblock("\0", exception: false)
|
|
776
|
+
rescue Errno::EPIPE, IOError, Errno::EBADF
|
|
777
|
+
nil
|
|
525
778
|
end
|
|
526
779
|
end
|
|
527
780
|
end
|
|
@@ -202,8 +202,7 @@ module Pgbus
|
|
|
202
202
|
|
|
203
203
|
payload.merge(
|
|
204
204
|
Pgbus::Uniqueness::METADATA_KEY => key,
|
|
205
|
-
Pgbus::Uniqueness::STRATEGY_KEY => config[:strategy].to_s
|
|
206
|
-
Pgbus::Uniqueness::TTL_KEY => config[:lock_ttl]
|
|
205
|
+
Pgbus::Uniqueness::STRATEGY_KEY => config[:strategy].to_s
|
|
207
206
|
)
|
|
208
207
|
end
|
|
209
208
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
3
5
|
module Pgbus
|
|
4
6
|
module Recurring
|
|
5
7
|
class Scheduler
|
|
@@ -12,6 +14,7 @@ module Pgbus
|
|
|
12
14
|
@schedule = Schedule.new(config: config)
|
|
13
15
|
@shutting_down = false
|
|
14
16
|
@last_runs = {}
|
|
17
|
+
@loop_tick_at = Concurrent::AtomicReference.new(nil)
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
def run
|
|
@@ -25,6 +28,7 @@ module Pgbus
|
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
loop do
|
|
31
|
+
stamp_loop_tick
|
|
28
32
|
break if @shutting_down
|
|
29
33
|
|
|
30
34
|
process_signals
|
|
@@ -100,10 +104,20 @@ module Pgbus
|
|
|
100
104
|
Pgbus.logger.error { "[Pgbus] Failed to sync recurring tasks: #{e.class}: #{e.message}" }
|
|
101
105
|
end
|
|
102
106
|
|
|
107
|
+
# Wall clock (not monotonic) so the dashboard can compare the beacon
|
|
108
|
+
# against Time.now across processes; the heartbeat timer thread reads it
|
|
109
|
+
# via loop_tick_supplier. A scheduler wedged inside tick stops advancing
|
|
110
|
+
# this while heartbeats keep firing, so the beacon ages and the processes
|
|
111
|
+
# page can surface the stall.
|
|
112
|
+
def stamp_loop_tick
|
|
113
|
+
@loop_tick_at.set(Time.now.to_f)
|
|
114
|
+
end
|
|
115
|
+
|
|
103
116
|
def start_heartbeat
|
|
104
117
|
@heartbeat = Process::Heartbeat.new(
|
|
105
118
|
kind: "scheduler",
|
|
106
|
-
metadata: { pid: ::Process.pid, tasks: schedule.tasks.size }
|
|
119
|
+
metadata: { pid: ::Process.pid, tasks: schedule.tasks.size },
|
|
120
|
+
loop_tick_supplier: -> { @loop_tick_at.get }
|
|
107
121
|
)
|
|
108
122
|
@heartbeat.start
|
|
109
123
|
end
|
data/lib/pgbus/serializer.rb
CHANGED
|
@@ -55,21 +55,21 @@ module Pgbus
|
|
|
55
55
|
# can be resolved — prevents loading arbitrary objects from crafted payloads.
|
|
56
56
|
def locate_global_id(gid_string)
|
|
57
57
|
gid = GlobalID.parse(gid_string)
|
|
58
|
-
raise
|
|
58
|
+
raise Pgbus::SerializationError, "Invalid GlobalID: #{gid_string.inspect}" unless gid
|
|
59
59
|
|
|
60
60
|
allowed = Pgbus.configuration.allowed_global_id_models
|
|
61
61
|
if allowed && allowed.empty?
|
|
62
|
-
raise
|
|
62
|
+
raise Pgbus::SerializationError,
|
|
63
63
|
"GlobalID deserialization is disabled (allowed_global_id_models is empty). " \
|
|
64
64
|
"Set to nil to allow all models, or add permitted classes."
|
|
65
65
|
end
|
|
66
66
|
if allowed&.any? { |entry| !entry.is_a?(Class) && !entry.is_a?(Module) }
|
|
67
|
-
raise
|
|
67
|
+
raise Pgbus::SerializationError,
|
|
68
68
|
"allowed_global_id_models must contain Class/Module objects, " \
|
|
69
69
|
"got: #{allowed.map(&:class).uniq.join(", ")}"
|
|
70
70
|
end
|
|
71
71
|
if allowed&.none? { |klass| gid.model_class <= klass }
|
|
72
|
-
raise
|
|
72
|
+
raise Pgbus::SerializationError,
|
|
73
73
|
"GlobalID model #{gid.model_class} is not in allowed_global_id_models. " \
|
|
74
74
|
"Add it to Pgbus.configuration.allowed_global_id_models to permit deserialization."
|
|
75
75
|
end
|
|
@@ -155,14 +155,6 @@ module Pgbus
|
|
|
155
155
|
ensure
|
|
156
156
|
previous.each { |tl_key, value| Thread.current[tl_key] = value }
|
|
157
157
|
end
|
|
158
|
-
|
|
159
|
-
# Kept for backward compatibility — the class-level durable callbacks
|
|
160
|
-
# (broadcasts_to with durable:) and any external callers may still use it.
|
|
161
|
-
def with_pgbus_durable(value, &)
|
|
162
|
-
return yield if value.nil?
|
|
163
|
-
|
|
164
|
-
with_pgbus_broadcast_opts(durable: value, &)
|
|
165
|
-
end
|
|
166
158
|
end
|
|
167
159
|
end
|
|
168
160
|
end
|
|
@@ -14,10 +14,10 @@ module Pgbus
|
|
|
14
14
|
# `"gid://app/Order/42:messages"`). Verification returns that string;
|
|
15
15
|
# tampered or unsigned input raises `InvalidSignedName`.
|
|
16
16
|
module SignedName
|
|
17
|
-
class InvalidSignedName <
|
|
17
|
+
class InvalidSignedName < Pgbus::Error
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
class MissingSecret <
|
|
20
|
+
class MissingSecret < Pgbus::Error
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def self.verify!(token)
|
|
@@ -37,11 +37,13 @@ module Pgbus
|
|
|
37
37
|
def broadcast_stream_to(*streamables, content:)
|
|
38
38
|
name = stream_name_from(streamables)
|
|
39
39
|
override = Thread.current[:pgbus_broadcast_durable]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
# When no explicit thread-local override is present, let the config
|
|
41
|
+
# resolver decide: it checks `streams_durable_patterns` first (exact
|
|
42
|
+
# string or regex match), then falls back to
|
|
43
|
+
# `streams_default_broadcast_mode`. Passing this through keeps
|
|
44
|
+
# pattern-based routing alive for the whole Turbo::Broadcastable and
|
|
45
|
+
# phlex-reactive broadcast path (see issue #267).
|
|
46
|
+
durable = override.nil? ? Pgbus.configuration.stream_durable?(name) : override
|
|
45
47
|
Pgbus.stream(name, durable: durable).broadcast(
|
|
46
48
|
content,
|
|
47
49
|
exclude: Thread.current[:pgbus_broadcast_exclude],
|
|
@@ -80,12 +80,23 @@ module Pgbus
|
|
|
80
80
|
"REINDEX TABLE CONCURRENTLY \"#{schema}\".\"#{relname}\""
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
# stop_check is polled before each candidate; when it returns true the
|
|
84
|
+
# loop stops before touching the next table so a mid-pass shutdown does
|
|
85
|
+
# not block on VACUUM/REINDEX of the remaining tables. The default never
|
|
86
|
+
# stops, so callers that don't pass it are unaffected.
|
|
87
|
+
def run_maintenance(conn, threshold: BLOAT_THRESHOLD, reindex: true, stop_check: -> { false })
|
|
84
88
|
candidates = vacuum_candidates(conn, threshold: threshold)
|
|
85
89
|
return 0 if candidates.empty?
|
|
86
90
|
|
|
87
91
|
maintained = 0
|
|
88
|
-
candidates.
|
|
92
|
+
candidates.each_with_index do |candidate, index|
|
|
93
|
+
if stop_check.call
|
|
94
|
+
Pgbus.logger.info do
|
|
95
|
+
"[Pgbus::TableMaintenance] Maintenance interrupted by shutdown after #{index} of #{candidates.size}"
|
|
96
|
+
end
|
|
97
|
+
break
|
|
98
|
+
end
|
|
99
|
+
|
|
89
100
|
table = candidate[:table]
|
|
90
101
|
Pgbus.logger.info do
|
|
91
102
|
"[Pgbus::TableMaintenance] Vacuuming #{table} " \
|
data/lib/pgbus/uniqueness.rb
CHANGED
|
@@ -43,28 +43,28 @@ module Pgbus
|
|
|
43
43
|
|
|
44
44
|
METADATA_KEY = "pgbus_uniqueness_key"
|
|
45
45
|
STRATEGY_KEY = "pgbus_uniqueness_strategy"
|
|
46
|
-
TTL_KEY = "pgbus_uniqueness_lock_ttl"
|
|
47
|
-
|
|
48
|
-
# TTL is kept for metadata compatibility but no longer drives lock expiry.
|
|
49
|
-
# The lock exists until the job completes or is dead-lettered.
|
|
50
|
-
DEFAULT_LOCK_TTL = 24 * 60 * 60
|
|
51
46
|
|
|
52
47
|
VALID_STRATEGIES = %i[until_executed while_executing].freeze
|
|
53
48
|
VALID_CONFLICTS = %i[reject discard log].freeze
|
|
54
49
|
|
|
55
50
|
class_methods do
|
|
56
|
-
def ensures_uniqueness(strategy: :until_executed, key: nil,
|
|
51
|
+
def ensures_uniqueness(strategy: :until_executed, key: nil, on_conflict: :reject, **opts)
|
|
52
|
+
# lock_ttl was validated and stored in message metadata but never read
|
|
53
|
+
# by the lock lifecycle (the lock lives until the job completes or is
|
|
54
|
+
# dead-lettered, not until a TTL expires). Removed in 1.0.0.
|
|
55
|
+
if opts.key?(:lock_ttl)
|
|
56
|
+
raise ArgumentError,
|
|
57
|
+
"lock_ttl: was removed in pgbus 1.0.0 — it was validated but never read by anything. " \
|
|
58
|
+
"Remove it from ensures_uniqueness. See https://pgbus.dev/docs/upgrading-pgbus"
|
|
59
|
+
end
|
|
60
|
+
raise ArgumentError, "unknown keyword: #{opts.keys.first.inspect}" if opts.any?
|
|
57
61
|
raise ArgumentError, "strategy must be one of: #{VALID_STRATEGIES.join(", ")}" unless VALID_STRATEGIES.include?(strategy)
|
|
58
62
|
raise ArgumentError, "on_conflict must be one of: #{VALID_CONFLICTS.join(", ")}" unless VALID_CONFLICTS.include?(on_conflict)
|
|
59
|
-
|
|
60
|
-
valid_ttl = lock_ttl.is_a?(Numeric) || (defined?(ActiveSupport::Duration) && lock_ttl.is_a?(ActiveSupport::Duration))
|
|
61
|
-
raise ArgumentError, "lock_ttl must be a positive number or Duration" unless valid_ttl && lock_ttl.positive?
|
|
62
63
|
raise ArgumentError, "key must be callable (Proc or lambda)" if key && !key.respond_to?(:call)
|
|
63
64
|
|
|
64
65
|
@pgbus_uniqueness = {
|
|
65
66
|
strategy: strategy,
|
|
66
67
|
key: key || ->(*) { name },
|
|
67
|
-
lock_ttl: lock_ttl,
|
|
68
68
|
on_conflict: on_conflict
|
|
69
69
|
}.freeze
|
|
70
70
|
end
|
|
@@ -102,8 +102,7 @@ module Pgbus
|
|
|
102
102
|
|
|
103
103
|
payload_hash.merge(
|
|
104
104
|
METADATA_KEY => key,
|
|
105
|
-
STRATEGY_KEY => config[:strategy].to_s
|
|
106
|
-
TTL_KEY => config[:lock_ttl]
|
|
105
|
+
STRATEGY_KEY => config[:strategy].to_s
|
|
107
106
|
)
|
|
108
107
|
end
|
|
109
108
|
|
data/lib/pgbus/version.rb
CHANGED