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/pgmq_schema.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Pgbus
|
|
|
11
11
|
# Vendored SQL files live in lib/pgbus/pgmq_schema/pgmq_v{VERSION}.sql
|
|
12
12
|
# and are exact copies of the upstream pgmq-extension/sql/pgmq.sql at each release.
|
|
13
13
|
module PgmqSchema
|
|
14
|
-
class VersionNotFoundError <
|
|
14
|
+
class VersionNotFoundError < Pgbus::Error; end
|
|
15
15
|
|
|
16
16
|
SCHEMA_DIR = File.expand_path("pgmq_schema", __dir__).freeze
|
|
17
17
|
|
|
@@ -112,7 +112,11 @@ module Pgbus
|
|
|
112
112
|
EXECUTE 'DROP FUNCTION IF EXISTS ' || r.func_sig || ' CASCADE';
|
|
113
113
|
END LOOP;
|
|
114
114
|
|
|
115
|
-
-- Drop custom types in pgmq schema
|
|
115
|
+
-- Drop custom composite types in pgmq schema (e.g. message_record,
|
|
116
|
+
-- queue_record, metrics_result). Standalone `CREATE TYPE ... AS (...)`
|
|
117
|
+
-- types get a pg_class shell with relkind = 'c'; only skip row-types
|
|
118
|
+
-- backed by an actual relation (table/view/etc.), which must be
|
|
119
|
+
-- dropped via DROP TABLE rather than DROP TYPE.
|
|
116
120
|
FOR r IN
|
|
117
121
|
SELECT n.nspname || '.' || t.typname AS type_name
|
|
118
122
|
FROM pg_catalog.pg_type t
|
|
@@ -122,6 +126,7 @@ module Pgbus
|
|
|
122
126
|
AND NOT EXISTS (
|
|
123
127
|
SELECT 1 FROM pg_catalog.pg_class c
|
|
124
128
|
WHERE c.reltype = t.oid
|
|
129
|
+
AND c.relkind <> 'c'
|
|
125
130
|
)
|
|
126
131
|
LOOP
|
|
127
132
|
EXECUTE 'DROP TYPE IF EXISTS ' || r.type_name || ' CASCADE';
|
|
@@ -7,29 +7,125 @@ 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, :circuit_breaker
|
|
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
|
|
17
|
+
# stat_buffer is writable so a test can swap in a buffer double after
|
|
18
|
+
# construction and assert graceful_shutdown / check_recycle / shutdown flush
|
|
19
|
+
# it (mirrors Worker#stat_buffer).
|
|
20
|
+
attr_accessor :stat_buffer
|
|
11
21
|
|
|
12
|
-
def
|
|
22
|
+
def shutting_down?
|
|
23
|
+
@shutting_down
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def jobs_processed
|
|
27
|
+
@jobs_processed.value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Seed the processed-job counter. Used by tests to drive the recycle
|
|
31
|
+
# thresholds without running thousands of real jobs; production only ever
|
|
32
|
+
# increments it via the AtomicFixnum during message handling.
|
|
33
|
+
def jobs_processed=(count)
|
|
34
|
+
@jobs_processed.value = count
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Mirrors Worker::NOTIFY_FALLBACK_POLL_SECONDS. When a live NotifyListener
|
|
38
|
+
# drives wake-up latency, the empty-read wait is a safety net rather than
|
|
39
|
+
# the steady-state cadence, so it rises to this ceiling: one missed NOTIFY
|
|
40
|
+
# costs bounded latency, never a stuck queue.
|
|
41
|
+
NOTIFY_FALLBACK_POLL_SECONDS = 15
|
|
42
|
+
|
|
43
|
+
# NotifyListener startup can fail on a transient boot-time condition (DB
|
|
44
|
+
# restarting, failover, DNS blip) or its thread can die mid-run. The loop
|
|
45
|
+
# retries from ensure_notify_listener on an exponential backoff:
|
|
46
|
+
# NOTIFY_RETRY_BASE doubling up to NOTIFY_RETRY_MAX. Matches Worker.
|
|
47
|
+
NOTIFY_RETRY_BASE_SECONDS = 5
|
|
48
|
+
NOTIFY_RETRY_MAX_SECONDS = 300
|
|
49
|
+
|
|
50
|
+
# The notify-listener lifecycle state (`notify_listener`, `notify_retry_at`,
|
|
51
|
+
# `notify_retry_backoff`), the recycle clock (`started_at_monotonic`), and
|
|
52
|
+
# the resolved subscription set (`queue_names`) accept injected seeds so
|
|
53
|
+
# tests can drive the run loop from a known state without poking private
|
|
54
|
+
# ivars. All default to the values production initializes to; `queue_names`
|
|
55
|
+
# defaults to nil, meaning "derive from the registry in setup_subscriptions".
|
|
56
|
+
def initialize(topics:, threads: 3, config: Pgbus.configuration, execution_mode: :threads,
|
|
57
|
+
queue_names: nil, liveness_pipe: nil, stat_buffer: :default,
|
|
58
|
+
notify_listener: nil, notify_retry_at: 0.0,
|
|
59
|
+
notify_retry_backoff: NOTIFY_RETRY_BASE_SECONDS,
|
|
60
|
+
started_at_monotonic: nil)
|
|
13
61
|
@topics = Array(topics)
|
|
14
62
|
@threads = threads
|
|
15
63
|
@config = config
|
|
16
64
|
@execution_mode = ExecutionPools.normalize_mode(execution_mode)
|
|
17
65
|
@shutting_down = false
|
|
18
|
-
@
|
|
66
|
+
@recycling = false
|
|
67
|
+
@jobs_processed = Concurrent::AtomicFixnum.new(0)
|
|
68
|
+
@loop_tick_at = Concurrent::AtomicReference.new(nil)
|
|
69
|
+
@started_at_monotonic = started_at_monotonic || monotonic_now
|
|
70
|
+
@wake_signal = WakeSignal.new
|
|
71
|
+
@pool = ExecutionPools.build(
|
|
72
|
+
mode: @execution_mode,
|
|
73
|
+
capacity: threads,
|
|
74
|
+
on_state_change: -> { @wake_signal.notify! }
|
|
75
|
+
)
|
|
19
76
|
@registry = EventBus::Registry.instance
|
|
77
|
+
@circuit_breaker = Pgbus::CircuitBreaker.new(config: config)
|
|
78
|
+
# stat_buffer: :default means "build one iff config.stats_enabled";
|
|
79
|
+
# passing an explicit value (including nil) overrides that for tests.
|
|
80
|
+
@stat_buffer =
|
|
81
|
+
if stat_buffer == :default
|
|
82
|
+
if config.stats_enabled
|
|
83
|
+
Pgbus::StatBuffer.new(
|
|
84
|
+
flush_size: config.stats_flush_size,
|
|
85
|
+
flush_interval: config.stats_flush_interval
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
stat_buffer
|
|
90
|
+
end
|
|
91
|
+
@queue_names = queue_names
|
|
92
|
+
@notify_listener = notify_listener
|
|
93
|
+
@notify_retry_at = notify_retry_at
|
|
94
|
+
@notify_retry_backoff = notify_retry_backoff
|
|
95
|
+
# OS-level liveness channel to the supervisor watchdog. nil unless the
|
|
96
|
+
# supervisor forked us with one. Written from stamp_loop_tick so the
|
|
97
|
+
# watchdog can detect a wedged consumer even when the database is down.
|
|
98
|
+
@liveness_pipe = liveness_pipe
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The last wall-clock loop-tick stamp (Time.now.to_f) fed to the
|
|
102
|
+
# heartbeat's loop_tick_supplier. Wall-clock so it stays comparable across
|
|
103
|
+
# the process boundary the supervisor watchdog reads it over. nil until the
|
|
104
|
+
# first stamp_loop_tick.
|
|
105
|
+
def last_loop_tick
|
|
106
|
+
@loop_tick_at.get
|
|
20
107
|
end
|
|
21
108
|
|
|
22
109
|
def run
|
|
23
110
|
setup_signals
|
|
24
111
|
start_heartbeat
|
|
25
112
|
setup_subscriptions
|
|
26
|
-
|
|
113
|
+
start_notify_listener
|
|
114
|
+
Pgbus.logger.info do
|
|
115
|
+
"[Pgbus] Consumer started: topics=#{topics.join(",")} threads=#{threads} " \
|
|
116
|
+
"notify_wakeup=#{notify_wakeup?} pid=#{::Process.pid}"
|
|
117
|
+
end
|
|
27
118
|
|
|
28
119
|
loop do
|
|
120
|
+
stamp_loop_tick
|
|
121
|
+
process_signals
|
|
122
|
+
check_recycle
|
|
123
|
+
ensure_notify_listener
|
|
124
|
+
|
|
29
125
|
break if @shutting_down
|
|
30
126
|
|
|
31
|
-
process_signals
|
|
32
127
|
consume
|
|
128
|
+
@stat_buffer&.flush_if_due
|
|
33
129
|
end
|
|
34
130
|
|
|
35
131
|
shutdown
|
|
@@ -37,10 +133,17 @@ module Pgbus
|
|
|
37
133
|
|
|
38
134
|
def graceful_shutdown
|
|
39
135
|
@shutting_down = true
|
|
136
|
+
# Flush buffered stats at drain entry so the window since the last flush
|
|
137
|
+
# isn't lost if the supervisor watchdog SIGKILLs a stalled consumer
|
|
138
|
+
# before shutdown runs. Same-thread (signals dispatched via
|
|
139
|
+
# process_signals, not trap context), so the DB write is safe.
|
|
140
|
+
@stat_buffer&.flush
|
|
141
|
+
@wake_signal.notify!
|
|
40
142
|
end
|
|
41
143
|
|
|
42
144
|
def immediate_shutdown
|
|
43
145
|
@shutting_down = true
|
|
146
|
+
@wake_signal.notify!
|
|
44
147
|
@pool.kill
|
|
45
148
|
end
|
|
46
149
|
|
|
@@ -55,17 +158,12 @@ module Pgbus
|
|
|
55
158
|
|
|
56
159
|
def consume
|
|
57
160
|
idle = @pool.available_capacity
|
|
58
|
-
return
|
|
161
|
+
return @wake_signal.wait(timeout: wake_timeout) if idle <= 0
|
|
59
162
|
|
|
60
|
-
tagged_messages =
|
|
61
|
-
queue = @queue_names.first
|
|
62
|
-
(Pgbus.client.read_batch(queue, qty: idle) || []).map { |m| [queue, m] }
|
|
63
|
-
else
|
|
64
|
-
fetch_multi_consumer(idle)
|
|
65
|
-
end
|
|
163
|
+
tagged_messages = fetch_messages(idle)
|
|
66
164
|
|
|
67
165
|
if tagged_messages.empty?
|
|
68
|
-
|
|
166
|
+
@wake_signal.wait(timeout: wake_timeout)
|
|
69
167
|
return
|
|
70
168
|
end
|
|
71
169
|
|
|
@@ -74,10 +172,38 @@ module Pgbus
|
|
|
74
172
|
end
|
|
75
173
|
end
|
|
76
174
|
|
|
175
|
+
# Returns an array of [queue_name, message] pairs. Queues whose circuit
|
|
176
|
+
# breaker has tripped are skipped so a poison queue is left to cool down
|
|
177
|
+
# instead of being hammered every tick (mirrors Worker#fetch_messages).
|
|
178
|
+
def fetch_messages(qty)
|
|
179
|
+
active_queues = @queue_names.reject { |q| @circuit_breaker.paused?(q) }
|
|
180
|
+
return [] if active_queues.empty?
|
|
181
|
+
|
|
182
|
+
if active_queues.size == 1
|
|
183
|
+
queue = active_queues.first
|
|
184
|
+
(Pgbus.client.read_batch(queue, qty: qty) || []).map { |m| [queue, m] }
|
|
185
|
+
else
|
|
186
|
+
fetch_multi_consumer(active_queues, qty)
|
|
187
|
+
end
|
|
188
|
+
rescue Pgbus::ConnectionCircuitOpenError
|
|
189
|
+
# The client-level connection breaker is open: the database has failed
|
|
190
|
+
# enough consecutive connection attempts that reads fail fast. Idle this
|
|
191
|
+
# poll without an ErrorReporter call so the whole consumer pool doesn't
|
|
192
|
+
# flood the error tracker for the duration of a database outage. The
|
|
193
|
+
# open/close transitions are logged once by the client, not per poll.
|
|
194
|
+
[]
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
ErrorReporter.report(e, { action: "fetch_messages", queues: active_queues })
|
|
197
|
+
[]
|
|
198
|
+
end
|
|
199
|
+
|
|
77
200
|
def handle_message(message, queue_name)
|
|
201
|
+
execution_start = monotonic_now
|
|
202
|
+
|
|
78
203
|
if message.read_ct.to_i > config.max_retries
|
|
79
204
|
Pgbus.logger.warn { "[Pgbus] Consumer moving message #{message.msg_id} to DLQ after #{message.read_ct} reads" }
|
|
80
205
|
Pgbus.client.move_to_dead_letter(queue_name, message)
|
|
206
|
+
record_stat(message, queue_name, "dead_lettered", execution_start)
|
|
81
207
|
return
|
|
82
208
|
end
|
|
83
209
|
|
|
@@ -91,23 +217,58 @@ module Pgbus
|
|
|
91
217
|
end
|
|
92
218
|
|
|
93
219
|
Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
|
|
220
|
+
@circuit_breaker.record_success(queue_name)
|
|
221
|
+
record_stat(message, queue_name, "success", execution_start)
|
|
94
222
|
rescue StandardError => e
|
|
95
223
|
Pgbus.logger.error { "[Pgbus] Consumer error: #{e.class}: #{e.message}" }
|
|
96
224
|
# Message stays in queue; VT will expire and it becomes available again.
|
|
97
225
|
# read_ct tracks delivery attempts — when it exceeds max_retries,
|
|
98
226
|
# the next read will route to DLQ above.
|
|
227
|
+
@circuit_breaker.record_failure(queue_name)
|
|
228
|
+
record_stat(message, queue_name, "failed", execution_start)
|
|
229
|
+
ensure
|
|
230
|
+
# Count every message the consumer handles — success, DLQ-routed, AND
|
|
231
|
+
# rescued failure — mirroring Worker#process_message, which increments
|
|
232
|
+
# unconditionally. Counting only successes would let max_jobs recycling
|
|
233
|
+
# never trip on a poison/all-failing queue: the exact unbounded-memory
|
|
234
|
+
# scenario recycling exists to bound.
|
|
235
|
+
@jobs_processed.increment
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Record a job stat for the handled message, mirroring the shape the
|
|
239
|
+
# executor pushes (Executor#record_stat) so consumer and worker throughput
|
|
240
|
+
# land in the same pgbus_job_stats table. No-op unless stats are enabled.
|
|
241
|
+
def record_stat(message, queue_name, status, start_time)
|
|
242
|
+
return unless config.stats_enabled
|
|
243
|
+
|
|
244
|
+
attrs = {
|
|
245
|
+
job_class: "EventConsumer",
|
|
246
|
+
queue_name: queue_name,
|
|
247
|
+
status: status,
|
|
248
|
+
duration_ms: ((monotonic_now - start_time) * 1000).round,
|
|
249
|
+
enqueue_latency_ms: nil,
|
|
250
|
+
retry_count: [message.read_ct.to_i - 1, 0].max
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if @stat_buffer
|
|
254
|
+
@stat_buffer.push(attrs)
|
|
255
|
+
else
|
|
256
|
+
JobStat.record!(**attrs)
|
|
257
|
+
end
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
Pgbus.logger.debug { "[Pgbus] Consumer stat recording failed: #{e.message}" }
|
|
99
260
|
end
|
|
100
261
|
|
|
101
262
|
# `qty` is the total pool capacity. pgmq-ruby treats `qty:` as per-queue,
|
|
102
263
|
# so we also pass `limit: qty` to cap the total across all queues —
|
|
103
264
|
# otherwise we get `queue_count * qty` messages and overflow the
|
|
104
265
|
# execution pool, crashing the consumer fork (issue #123).
|
|
105
|
-
def fetch_multi_consumer(qty)
|
|
106
|
-
messages = Pgbus.client.read_multi(
|
|
266
|
+
def fetch_multi_consumer(active_queues, qty)
|
|
267
|
+
messages = Pgbus.client.read_multi(active_queues, qty: qty, limit: qty) || []
|
|
107
268
|
prefix = "#{config.queue_prefix}_"
|
|
108
269
|
|
|
109
270
|
messages.map do |m|
|
|
110
|
-
logical = m.queue_name&.delete_prefix(prefix) ||
|
|
271
|
+
logical = m.queue_name&.delete_prefix(prefix) || active_queues.first
|
|
111
272
|
[logical, m]
|
|
112
273
|
end
|
|
113
274
|
end
|
|
@@ -119,20 +280,195 @@ module Pgbus
|
|
|
119
280
|
subscription_pattern.start_with?(topic_filter.delete_suffix(".#"))
|
|
120
281
|
end
|
|
121
282
|
|
|
283
|
+
# Signal the loop to exit cleanly once a recycle limit is hit. The clean
|
|
284
|
+
# exit gets an immediate supervisor restart (supervisor.rb:305-307), so a
|
|
285
|
+
# fresh fork replaces this one before its memory grows unbounded — the
|
|
286
|
+
# same fix Worker#check_recycle provides. Guarded by @recycling so the
|
|
287
|
+
# per-iteration call instruments and logs exactly once.
|
|
288
|
+
def check_recycle
|
|
289
|
+
return if @recycling
|
|
290
|
+
|
|
291
|
+
reason = recycle_reason
|
|
292
|
+
return unless reason
|
|
293
|
+
|
|
294
|
+
@recycling = true
|
|
295
|
+
@shutting_down = true
|
|
296
|
+
# Flush buffered stats on recycle-triggered drain for the same reason as
|
|
297
|
+
# graceful_shutdown: shrink the SIGKILL loss window. Same-thread, safe.
|
|
298
|
+
@stat_buffer&.flush
|
|
299
|
+
Pgbus::Instrumentation.instrument(
|
|
300
|
+
"pgbus.consumer.recycle",
|
|
301
|
+
reason: reason,
|
|
302
|
+
jobs_processed: @jobs_processed.value,
|
|
303
|
+
memory_mb: current_memory_mb,
|
|
304
|
+
lifetime_seconds: monotonic_now - @started_at_monotonic
|
|
305
|
+
)
|
|
306
|
+
@wake_signal.notify!
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def recycle_reason
|
|
310
|
+
return :max_jobs if exceeded_max_jobs?
|
|
311
|
+
return :max_memory if exceeded_max_memory?
|
|
312
|
+
return :max_lifetime if exceeded_max_lifetime?
|
|
313
|
+
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def recycle_needed?
|
|
318
|
+
!recycle_reason.nil?
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def exceeded_max_jobs?
|
|
322
|
+
return false unless config.max_jobs_per_worker && @jobs_processed.value >= config.max_jobs_per_worker
|
|
323
|
+
|
|
324
|
+
Pgbus.logger.info { "[Pgbus] Consumer recycling: max_jobs reached (#{@jobs_processed.value})" }
|
|
325
|
+
true
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def exceeded_max_memory?
|
|
329
|
+
return false unless config.max_memory_mb && current_memory_mb > config.max_memory_mb
|
|
330
|
+
|
|
331
|
+
Pgbus.logger.info do
|
|
332
|
+
"[Pgbus] Consumer recycling: memory limit (#{current_memory_mb}MB > #{config.max_memory_mb}MB)"
|
|
333
|
+
end
|
|
334
|
+
true
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def exceeded_max_lifetime?
|
|
338
|
+
return false unless config.max_worker_lifetime && (monotonic_now - @started_at_monotonic) > config.max_worker_lifetime
|
|
339
|
+
|
|
340
|
+
Pgbus.logger.info { "[Pgbus] Consumer recycling: lifetime exceeded" }
|
|
341
|
+
true
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Instrumentation payload may report a value up to MEMORY_CHECK_TTL seconds old.
|
|
345
|
+
def current_memory_mb
|
|
346
|
+
MemoryUsage.current_mb
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def notify_wakeup?
|
|
350
|
+
config.respond_to?(:worker_notify_wakeup?) && config.worker_notify_wakeup?
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def wake_timeout
|
|
354
|
+
# A dead listener (running? false) will never wake the loop, so treat it
|
|
355
|
+
# as absent and keep polling at the short interval until
|
|
356
|
+
# ensure_notify_listener restarts it. Only a live listener earns the
|
|
357
|
+
# long NOTIFY-mode ceiling.
|
|
358
|
+
return config.polling_interval unless notify_wakeup? && @notify_listener&.running?
|
|
359
|
+
|
|
360
|
+
[config.polling_interval, NOTIFY_FALLBACK_POLL_SECONDS].max
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def start_notify_listener
|
|
364
|
+
return unless notify_wakeup?
|
|
365
|
+
|
|
366
|
+
@notify_listener = NotifyListener.new(
|
|
367
|
+
physical_queues: physical_queue_names,
|
|
368
|
+
on_wake: -> { @wake_signal.notify! },
|
|
369
|
+
connection_options: config.worker_notify_connection_options,
|
|
370
|
+
health_check_ms: (config.polling_interval * 1000).to_i.clamp(250, 5_000),
|
|
371
|
+
logger: Pgbus.logger
|
|
372
|
+
).start
|
|
373
|
+
rescue StandardError => e
|
|
374
|
+
@notify_listener = nil
|
|
375
|
+
Pgbus.logger.error do
|
|
376
|
+
"[Pgbus] Consumer NotifyListener failed to start, falling back to polling: #{e.class}: #{e.message}"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Self-heal a NotifyListener that never started or whose thread died.
|
|
381
|
+
# Called each loop iteration but gated by a monotonic backoff timestamp so
|
|
382
|
+
# a persistent outage retries on 5s→…→300s intervals, not every tick
|
|
383
|
+
# (mirrors Worker#ensure_notify_listener).
|
|
384
|
+
def ensure_notify_listener
|
|
385
|
+
return unless notify_wakeup?
|
|
386
|
+
return if @notify_listener&.running?
|
|
387
|
+
return if monotonic_now < @notify_retry_at
|
|
388
|
+
|
|
389
|
+
stop_dead_notify_listener
|
|
390
|
+
start_notify_listener
|
|
391
|
+
|
|
392
|
+
@notify_retry_backoff = if @notify_listener&.running?
|
|
393
|
+
NOTIFY_RETRY_BASE_SECONDS
|
|
394
|
+
else
|
|
395
|
+
[@notify_retry_backoff * 2, NOTIFY_RETRY_MAX_SECONDS].min
|
|
396
|
+
end
|
|
397
|
+
@notify_retry_at = monotonic_now + @notify_retry_backoff
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Stop a listener whose thread died so its dedicated PG connection is
|
|
401
|
+
# released before start_notify_listener allocates a fresh one.
|
|
402
|
+
def stop_dead_notify_listener
|
|
403
|
+
return if @notify_listener.nil?
|
|
404
|
+
|
|
405
|
+
@notify_listener.stop
|
|
406
|
+
rescue StandardError => e
|
|
407
|
+
Pgbus.logger.warn { "[Pgbus] Consumer failed to stop dead NotifyListener: #{e.class}: #{e.message}" }
|
|
408
|
+
ensure
|
|
409
|
+
@notify_listener = nil
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def physical_queue_names
|
|
413
|
+
prefix = "#{config.queue_prefix}_"
|
|
414
|
+
@queue_names.map { |q| "#{prefix}#{q}" }
|
|
415
|
+
end
|
|
416
|
+
|
|
122
417
|
def start_heartbeat
|
|
123
418
|
@heartbeat = Heartbeat.new(
|
|
124
419
|
kind: "consumer",
|
|
125
|
-
metadata: { topics: topics, threads: threads, pid: ::Process.pid }
|
|
420
|
+
metadata: { topics: topics, threads: threads, pid: ::Process.pid },
|
|
421
|
+
on_beat: -> { on_heartbeat },
|
|
422
|
+
loop_tick_supplier: -> { @loop_tick_at.get }
|
|
126
423
|
)
|
|
127
424
|
@heartbeat.start
|
|
128
425
|
end
|
|
129
426
|
|
|
427
|
+
# Runs once per heartbeat interval (not per message), so it's the right
|
|
428
|
+
# place to emit connection-pool observability without touching any per-job
|
|
429
|
+
# hot path. Reading the pool must never crash the beat — pool_stats already
|
|
430
|
+
# rescues to {}, and this whole method is guarded so an unexpected error
|
|
431
|
+
# can't take down the heartbeat thread (mirrors Worker#on_heartbeat).
|
|
432
|
+
def on_heartbeat
|
|
433
|
+
emit_pool_stats
|
|
434
|
+
rescue StandardError => e
|
|
435
|
+
Pgbus.logger.debug { "[Pgbus] Consumer heartbeat hook error: #{e.class}: #{e.message}" }
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def emit_pool_stats
|
|
439
|
+
stats = Pgbus.client.pool_stats
|
|
440
|
+
return if stats.empty?
|
|
441
|
+
|
|
442
|
+
Pgbus::Instrumentation.instrument("pgbus.client.pool", stats)
|
|
443
|
+
end
|
|
444
|
+
|
|
130
445
|
def shutdown
|
|
446
|
+
@notify_listener&.stop
|
|
131
447
|
@pool.shutdown
|
|
132
448
|
@pool.wait_for_termination(30)
|
|
449
|
+
@stat_buffer&.stop
|
|
133
450
|
@heartbeat&.stop
|
|
134
451
|
restore_signals
|
|
135
|
-
Pgbus.logger.info { "[Pgbus] Consumer stopped" }
|
|
452
|
+
Pgbus.logger.info { "[Pgbus] Consumer stopped. Processed: #{@jobs_processed.value}" }
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def monotonic_now
|
|
456
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Stamp the loop-progress beacon with a wall-clock timestamp (required
|
|
460
|
+
# because the supervisor watchdog reads it cross-fork and the dashboard
|
|
461
|
+
# reads it cross-host). Also pokes the OS-level liveness pipe when the
|
|
462
|
+
# supervisor forked us with one, giving the watchdog a database-independent
|
|
463
|
+
# signal. The write is non-blocking and never raises in the hot path
|
|
464
|
+
# (mirrors Worker#stamp_loop_tick).
|
|
465
|
+
def stamp_loop_tick
|
|
466
|
+
@loop_tick_at.set(Time.now.to_f)
|
|
467
|
+
return unless @liveness_pipe
|
|
468
|
+
|
|
469
|
+
@liveness_pipe.write_nonblock("\0", exception: false)
|
|
470
|
+
rescue Errno::EPIPE, IOError, Errno::EBADF
|
|
471
|
+
nil
|
|
136
472
|
end
|
|
137
473
|
end
|
|
138
474
|
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.
|