pgbus 0.9.6 → 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 +45 -6
- data/app/views/pgbus/jobs/show.html.erb +1 -1
- 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 +102 -13
- 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/payload_filter.rb +68 -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 +19 -1
|
@@ -7,28 +7,90 @@ module Pgbus
|
|
|
7
7
|
class Consumer
|
|
8
8
|
include SignalHandler
|
|
9
9
|
|
|
10
|
-
attr_reader :topics, :threads, :config, :execution_mode
|
|
10
|
+
attr_reader :topics, :threads, :config, :execution_mode,
|
|
11
|
+
:queue_names, :wake_signal, :notify_retry_backoff
|
|
12
|
+
# notify_listener is writable so tests can simulate a start_notify_listener
|
|
13
|
+
# success from inside a stub (production sets it in start_notify_listener).
|
|
14
|
+
# notify_retry_at is writable so a test can re-arm the backoff window
|
|
15
|
+
# between successive ensure_notify_listener calls.
|
|
16
|
+
attr_accessor :notify_listener, :notify_retry_at
|
|
11
17
|
|
|
12
|
-
def
|
|
18
|
+
def shutting_down?
|
|
19
|
+
@shutting_down
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def jobs_processed
|
|
23
|
+
@jobs_processed.value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Seed the processed-job counter. Used by tests to drive the recycle
|
|
27
|
+
# thresholds without running thousands of real jobs; production only ever
|
|
28
|
+
# increments it via the AtomicFixnum during message handling.
|
|
29
|
+
def jobs_processed=(count)
|
|
30
|
+
@jobs_processed.value = count
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Mirrors Worker::NOTIFY_FALLBACK_POLL_SECONDS. When a live NotifyListener
|
|
34
|
+
# drives wake-up latency, the empty-read wait is a safety net rather than
|
|
35
|
+
# the steady-state cadence, so it rises to this ceiling: one missed NOTIFY
|
|
36
|
+
# costs bounded latency, never a stuck queue.
|
|
37
|
+
NOTIFY_FALLBACK_POLL_SECONDS = 15
|
|
38
|
+
|
|
39
|
+
# NotifyListener startup can fail on a transient boot-time condition (DB
|
|
40
|
+
# restarting, failover, DNS blip) or its thread can die mid-run. The loop
|
|
41
|
+
# retries from ensure_notify_listener on an exponential backoff:
|
|
42
|
+
# NOTIFY_RETRY_BASE doubling up to NOTIFY_RETRY_MAX. Matches Worker.
|
|
43
|
+
NOTIFY_RETRY_BASE_SECONDS = 5
|
|
44
|
+
NOTIFY_RETRY_MAX_SECONDS = 300
|
|
45
|
+
|
|
46
|
+
# The notify-listener lifecycle state (`notify_listener`, `notify_retry_at`,
|
|
47
|
+
# `notify_retry_backoff`), the recycle clock (`started_at_monotonic`), and
|
|
48
|
+
# the resolved subscription set (`queue_names`) accept injected seeds so
|
|
49
|
+
# tests can drive the run loop from a known state without poking private
|
|
50
|
+
# ivars. All default to the values production initializes to; `queue_names`
|
|
51
|
+
# defaults to nil, meaning "derive from the registry in setup_subscriptions".
|
|
52
|
+
def initialize(topics:, threads: 3, config: Pgbus.configuration, execution_mode: :threads,
|
|
53
|
+
queue_names: nil, notify_listener: nil, notify_retry_at: 0.0,
|
|
54
|
+
notify_retry_backoff: NOTIFY_RETRY_BASE_SECONDS,
|
|
55
|
+
started_at_monotonic: nil)
|
|
13
56
|
@topics = Array(topics)
|
|
14
57
|
@threads = threads
|
|
15
58
|
@config = config
|
|
16
59
|
@execution_mode = ExecutionPools.normalize_mode(execution_mode)
|
|
17
60
|
@shutting_down = false
|
|
18
|
-
@
|
|
61
|
+
@recycling = false
|
|
62
|
+
@jobs_processed = Concurrent::AtomicFixnum.new(0)
|
|
63
|
+
@started_at_monotonic = started_at_monotonic || monotonic_now
|
|
64
|
+
@wake_signal = WakeSignal.new
|
|
65
|
+
@pool = ExecutionPools.build(
|
|
66
|
+
mode: @execution_mode,
|
|
67
|
+
capacity: threads,
|
|
68
|
+
on_state_change: -> { @wake_signal.notify! }
|
|
69
|
+
)
|
|
19
70
|
@registry = EventBus::Registry.instance
|
|
71
|
+
@queue_names = queue_names
|
|
72
|
+
@notify_listener = notify_listener
|
|
73
|
+
@notify_retry_at = notify_retry_at
|
|
74
|
+
@notify_retry_backoff = notify_retry_backoff
|
|
20
75
|
end
|
|
21
76
|
|
|
22
77
|
def run
|
|
23
78
|
setup_signals
|
|
24
79
|
start_heartbeat
|
|
25
80
|
setup_subscriptions
|
|
26
|
-
|
|
81
|
+
start_notify_listener
|
|
82
|
+
Pgbus.logger.info do
|
|
83
|
+
"[Pgbus] Consumer started: topics=#{topics.join(",")} threads=#{threads} " \
|
|
84
|
+
"notify_wakeup=#{notify_wakeup?} pid=#{::Process.pid}"
|
|
85
|
+
end
|
|
27
86
|
|
|
28
87
|
loop do
|
|
88
|
+
process_signals
|
|
89
|
+
check_recycle
|
|
90
|
+
ensure_notify_listener
|
|
91
|
+
|
|
29
92
|
break if @shutting_down
|
|
30
93
|
|
|
31
|
-
process_signals
|
|
32
94
|
consume
|
|
33
95
|
end
|
|
34
96
|
|
|
@@ -37,10 +99,12 @@ module Pgbus
|
|
|
37
99
|
|
|
38
100
|
def graceful_shutdown
|
|
39
101
|
@shutting_down = true
|
|
102
|
+
@wake_signal.notify!
|
|
40
103
|
end
|
|
41
104
|
|
|
42
105
|
def immediate_shutdown
|
|
43
106
|
@shutting_down = true
|
|
107
|
+
@wake_signal.notify!
|
|
44
108
|
@pool.kill
|
|
45
109
|
end
|
|
46
110
|
|
|
@@ -55,7 +119,7 @@ module Pgbus
|
|
|
55
119
|
|
|
56
120
|
def consume
|
|
57
121
|
idle = @pool.available_capacity
|
|
58
|
-
return
|
|
122
|
+
return @wake_signal.wait(timeout: wake_timeout) if idle <= 0
|
|
59
123
|
|
|
60
124
|
tagged_messages = if @queue_names.size == 1
|
|
61
125
|
queue = @queue_names.first
|
|
@@ -65,7 +129,7 @@ module Pgbus
|
|
|
65
129
|
end
|
|
66
130
|
|
|
67
131
|
if tagged_messages.empty?
|
|
68
|
-
|
|
132
|
+
@wake_signal.wait(timeout: wake_timeout)
|
|
69
133
|
return
|
|
70
134
|
end
|
|
71
135
|
|
|
@@ -96,6 +160,13 @@ module Pgbus
|
|
|
96
160
|
# Message stays in queue; VT will expire and it becomes available again.
|
|
97
161
|
# read_ct tracks delivery attempts — when it exceeds max_retries,
|
|
98
162
|
# the next read will route to DLQ above.
|
|
163
|
+
ensure
|
|
164
|
+
# Count every message the consumer handles — success, DLQ-routed, AND
|
|
165
|
+
# rescued failure — mirroring Worker#process_message, which increments
|
|
166
|
+
# unconditionally. Counting only successes would let max_jobs recycling
|
|
167
|
+
# never trip on a poison/all-failing queue: the exact unbounded-memory
|
|
168
|
+
# scenario recycling exists to bound.
|
|
169
|
+
@jobs_processed.increment
|
|
99
170
|
end
|
|
100
171
|
|
|
101
172
|
# `qty` is the total pool capacity. pgmq-ruby treats `qty:` as per-queue,
|
|
@@ -119,6 +190,137 @@ module Pgbus
|
|
|
119
190
|
subscription_pattern.start_with?(topic_filter.delete_suffix(".#"))
|
|
120
191
|
end
|
|
121
192
|
|
|
193
|
+
# Signal the loop to exit cleanly once a recycle limit is hit. The clean
|
|
194
|
+
# exit gets an immediate supervisor restart (supervisor.rb:305-307), so a
|
|
195
|
+
# fresh fork replaces this one before its memory grows unbounded — the
|
|
196
|
+
# same fix Worker#check_recycle provides. Guarded by @recycling so the
|
|
197
|
+
# per-iteration call instruments and logs exactly once.
|
|
198
|
+
def check_recycle
|
|
199
|
+
return if @recycling
|
|
200
|
+
|
|
201
|
+
reason = recycle_reason
|
|
202
|
+
return unless reason
|
|
203
|
+
|
|
204
|
+
@recycling = true
|
|
205
|
+
@shutting_down = true
|
|
206
|
+
Pgbus::Instrumentation.instrument(
|
|
207
|
+
"pgbus.consumer.recycle",
|
|
208
|
+
reason: reason,
|
|
209
|
+
jobs_processed: @jobs_processed.value,
|
|
210
|
+
memory_mb: current_memory_mb,
|
|
211
|
+
lifetime_seconds: monotonic_now - @started_at_monotonic
|
|
212
|
+
)
|
|
213
|
+
@wake_signal.notify!
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def recycle_reason
|
|
217
|
+
return :max_jobs if exceeded_max_jobs?
|
|
218
|
+
return :max_memory if exceeded_max_memory?
|
|
219
|
+
return :max_lifetime if exceeded_max_lifetime?
|
|
220
|
+
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def recycle_needed?
|
|
225
|
+
!recycle_reason.nil?
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def exceeded_max_jobs?
|
|
229
|
+
return false unless config.max_jobs_per_worker && @jobs_processed.value >= config.max_jobs_per_worker
|
|
230
|
+
|
|
231
|
+
Pgbus.logger.info { "[Pgbus] Consumer recycling: max_jobs reached (#{@jobs_processed.value})" }
|
|
232
|
+
true
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def exceeded_max_memory?
|
|
236
|
+
return false unless config.max_memory_mb && current_memory_mb > config.max_memory_mb
|
|
237
|
+
|
|
238
|
+
Pgbus.logger.info do
|
|
239
|
+
"[Pgbus] Consumer recycling: memory limit (#{current_memory_mb}MB > #{config.max_memory_mb}MB)"
|
|
240
|
+
end
|
|
241
|
+
true
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def exceeded_max_lifetime?
|
|
245
|
+
return false unless config.max_worker_lifetime && (monotonic_now - @started_at_monotonic) > config.max_worker_lifetime
|
|
246
|
+
|
|
247
|
+
Pgbus.logger.info { "[Pgbus] Consumer recycling: lifetime exceeded" }
|
|
248
|
+
true
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Instrumentation payload may report a value up to MEMORY_CHECK_TTL seconds old.
|
|
252
|
+
def current_memory_mb
|
|
253
|
+
MemoryUsage.current_mb
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def notify_wakeup?
|
|
257
|
+
config.respond_to?(:worker_notify_wakeup?) && config.worker_notify_wakeup?
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def wake_timeout
|
|
261
|
+
# A dead listener (running? false) will never wake the loop, so treat it
|
|
262
|
+
# as absent and keep polling at the short interval until
|
|
263
|
+
# ensure_notify_listener restarts it. Only a live listener earns the
|
|
264
|
+
# long NOTIFY-mode ceiling.
|
|
265
|
+
return config.polling_interval unless notify_wakeup? && @notify_listener&.running?
|
|
266
|
+
|
|
267
|
+
[config.polling_interval, NOTIFY_FALLBACK_POLL_SECONDS].max
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def start_notify_listener
|
|
271
|
+
return unless notify_wakeup?
|
|
272
|
+
|
|
273
|
+
@notify_listener = NotifyListener.new(
|
|
274
|
+
physical_queues: physical_queue_names,
|
|
275
|
+
on_wake: -> { @wake_signal.notify! },
|
|
276
|
+
connection_options: config.worker_notify_connection_options,
|
|
277
|
+
health_check_ms: (config.polling_interval * 1000).to_i.clamp(250, 5_000),
|
|
278
|
+
logger: Pgbus.logger
|
|
279
|
+
).start
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
@notify_listener = nil
|
|
282
|
+
Pgbus.logger.error do
|
|
283
|
+
"[Pgbus] Consumer NotifyListener failed to start, falling back to polling: #{e.class}: #{e.message}"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Self-heal a NotifyListener that never started or whose thread died.
|
|
288
|
+
# Called each loop iteration but gated by a monotonic backoff timestamp so
|
|
289
|
+
# a persistent outage retries on 5s→…→300s intervals, not every tick
|
|
290
|
+
# (mirrors Worker#ensure_notify_listener).
|
|
291
|
+
def ensure_notify_listener
|
|
292
|
+
return unless notify_wakeup?
|
|
293
|
+
return if @notify_listener&.running?
|
|
294
|
+
return if monotonic_now < @notify_retry_at
|
|
295
|
+
|
|
296
|
+
stop_dead_notify_listener
|
|
297
|
+
start_notify_listener
|
|
298
|
+
|
|
299
|
+
@notify_retry_backoff = if @notify_listener&.running?
|
|
300
|
+
NOTIFY_RETRY_BASE_SECONDS
|
|
301
|
+
else
|
|
302
|
+
[@notify_retry_backoff * 2, NOTIFY_RETRY_MAX_SECONDS].min
|
|
303
|
+
end
|
|
304
|
+
@notify_retry_at = monotonic_now + @notify_retry_backoff
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Stop a listener whose thread died so its dedicated PG connection is
|
|
308
|
+
# released before start_notify_listener allocates a fresh one.
|
|
309
|
+
def stop_dead_notify_listener
|
|
310
|
+
return if @notify_listener.nil?
|
|
311
|
+
|
|
312
|
+
@notify_listener.stop
|
|
313
|
+
rescue StandardError => e
|
|
314
|
+
Pgbus.logger.warn { "[Pgbus] Consumer failed to stop dead NotifyListener: #{e.class}: #{e.message}" }
|
|
315
|
+
ensure
|
|
316
|
+
@notify_listener = nil
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def physical_queue_names
|
|
320
|
+
prefix = "#{config.queue_prefix}_"
|
|
321
|
+
@queue_names.map { |q| "#{prefix}#{q}" }
|
|
322
|
+
end
|
|
323
|
+
|
|
122
324
|
def start_heartbeat
|
|
123
325
|
@heartbeat = Heartbeat.new(
|
|
124
326
|
kind: "consumer",
|
|
@@ -128,11 +330,16 @@ module Pgbus
|
|
|
128
330
|
end
|
|
129
331
|
|
|
130
332
|
def shutdown
|
|
333
|
+
@notify_listener&.stop
|
|
131
334
|
@pool.shutdown
|
|
132
335
|
@pool.wait_for_termination(30)
|
|
133
336
|
@heartbeat&.stop
|
|
134
337
|
restore_signals
|
|
135
|
-
Pgbus.logger.info { "[Pgbus] Consumer stopped" }
|
|
338
|
+
Pgbus.logger.info { "[Pgbus] Consumer stopped. Processed: #{@jobs_processed.value}" }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def monotonic_now
|
|
342
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
136
343
|
end
|
|
137
344
|
end
|
|
138
345
|
end
|
|
@@ -11,6 +11,15 @@ module Pgbus
|
|
|
11
11
|
# consumers are served first and lower-priority consumers wait
|
|
12
12
|
# until all higher-priority consumers are at their prefetch limit.
|
|
13
13
|
module ConsumerPriority
|
|
14
|
+
# Time-to-live (seconds) for cached max_active_priority lookups.
|
|
15
|
+
# The underlying data only changes on heartbeat cadence, so a short
|
|
16
|
+
# TTL avoids ~10 queries/second per prioritized worker at the default
|
|
17
|
+
# 0.1s polling interval.
|
|
18
|
+
CACHE_TTL = 5
|
|
19
|
+
|
|
20
|
+
@cache = {}
|
|
21
|
+
@cache_mutex = Mutex.new
|
|
22
|
+
|
|
14
23
|
# Check if this worker should yield to a higher-priority worker.
|
|
15
24
|
# Returns true if a higher-priority healthy worker exists for
|
|
16
25
|
# any of the given queues.
|
|
@@ -26,7 +35,31 @@ module Pgbus
|
|
|
26
35
|
# Returns the highest consumer_priority among healthy workers
|
|
27
36
|
# that share at least one queue with the given queue list,
|
|
28
37
|
# excluding the current worker (by PID).
|
|
38
|
+
#
|
|
39
|
+
# Results are cached per (sorted queues, pid) for CACHE_TTL seconds.
|
|
40
|
+
# On a miss the query runs outside the lock (a duplicate query on a
|
|
41
|
+
# race is harmless), then the value is stored under the mutex.
|
|
29
42
|
def self.max_active_priority(queues, my_pid)
|
|
43
|
+
key = [queues.sort, my_pid]
|
|
44
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
45
|
+
|
|
46
|
+
cached = @cache_mutex.synchronize { @cache[key] }
|
|
47
|
+
return cached[:value] if cached && (now - cached[:at]) < CACHE_TTL
|
|
48
|
+
|
|
49
|
+
value = compute_max_active_priority(queues, my_pid)
|
|
50
|
+
@cache_mutex.synchronize { @cache[key] = { value: value, at: now } }
|
|
51
|
+
value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Clears all cached max_active_priority entries. Intended for spec
|
|
55
|
+
# isolation; also safe to call at runtime.
|
|
56
|
+
def self.reset_cache!
|
|
57
|
+
@cache_mutex.synchronize { @cache.clear }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Runs the actual query and reduces worker rows to the highest
|
|
61
|
+
# consumer_priority sharing a queue with the given list.
|
|
62
|
+
def self.compute_max_active_priority(queues, my_pid)
|
|
30
63
|
conn = Pgbus.configuration.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
|
|
31
64
|
rows = conn.select_all(
|
|
32
65
|
"SELECT metadata FROM pgbus_processes WHERE kind = 'worker' AND pid != $1 AND last_heartbeat_at > $2",
|
|
@@ -49,6 +82,7 @@ module Pgbus
|
|
|
49
82
|
|
|
50
83
|
max_priority
|
|
51
84
|
end
|
|
85
|
+
private_class_method :compute_max_active_priority
|
|
52
86
|
|
|
53
87
|
# Calculate the effective polling interval for this worker.
|
|
54
88
|
# Higher-priority workers use the base interval.
|