pgbus 0.9.7 → 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 +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/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 +98 -12
- 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/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 +18 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
3
5
|
module Pgbus
|
|
4
6
|
module Process
|
|
5
7
|
class Dispatcher
|
|
@@ -24,23 +26,106 @@ module Pgbus
|
|
|
24
26
|
# too-small value just delays cleanup, never breaks anything.
|
|
25
27
|
ARCHIVE_COMPACTION_BATCH_SIZE = 1000
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
# Maintenance backoff during systemic outages. When every attempted
|
|
30
|
+
# maintenance task fails for two consecutive cycles (e.g. the database
|
|
31
|
+
# is down), skip maintenance entirely for an exponentially growing
|
|
32
|
+
# window instead of retrying ~12 tasks every dispatch interval and
|
|
33
|
+
# flooding logs and error trackers. The window doubles per consecutive
|
|
34
|
+
# failed cycle and is capped. Constants (not config) per the precedent
|
|
35
|
+
# above: the values rarely need tuning and only affect noise, never
|
|
36
|
+
# correctness. Backoff exits the moment any task succeeds again.
|
|
37
|
+
MAINTENANCE_BACKOFF_BASE = 30 # seconds; first backoff window
|
|
38
|
+
MAINTENANCE_BACKOFF_MAX = 600 # seconds; window ceiling (10 minutes)
|
|
39
|
+
|
|
40
|
+
# The per-task monotonic timestamps run_maintenance consults via run_if_due.
|
|
41
|
+
# Enumerated so set_maintenance_timestamp can validate its argument.
|
|
42
|
+
MAINTENANCE_TIMESTAMPS = %i[
|
|
43
|
+
@last_cleanup_at @last_reap_at @last_concurrency_at @last_batch_cleanup_at
|
|
44
|
+
@last_recurring_cleanup_at @last_archive_compaction_at @last_stream_archive_compaction_at
|
|
45
|
+
@last_outbox_cleanup_at @last_job_lock_cleanup_at @last_stats_cleanup_at
|
|
46
|
+
@last_orphan_stream_sweep_at @last_table_maintenance_at
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# Outcome of a single maintenance task in a cycle. Lets run_maintenance
|
|
50
|
+
# distinguish success/failed/skipped and, on failure, carry the error
|
|
51
|
+
# and task name for reporting or summarizing.
|
|
52
|
+
MaintenanceResult = ::Struct.new(:status, :task, :error) do
|
|
53
|
+
def success? = status == :success
|
|
54
|
+
def skipped? = status == :skipped
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Accumulates the per-item outcomes of a task that loops over queues.
|
|
58
|
+
# A task should only be considered failed when it attempted work and
|
|
59
|
+
# *every* attempt failed — a partial failure (some items succeed) must
|
|
60
|
+
# not trip maintenance backoff. #result returns the first error in the
|
|
61
|
+
# total-failure case and nil otherwise, matching the "return e on
|
|
62
|
+
# failure" contract run_if_due expects.
|
|
63
|
+
class LoopOutcome
|
|
64
|
+
def initialize
|
|
65
|
+
@successes = 0
|
|
66
|
+
@failures = 0
|
|
67
|
+
@first_error = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def record_success
|
|
71
|
+
@successes += 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def record_failure(error)
|
|
75
|
+
@failures += 1
|
|
76
|
+
@first_error = error if @first_error.nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def result
|
|
80
|
+
@first_error if @failures.positive? && @successes.zero?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
attr_reader :config, :maintenance_failure_streak, :maintenance_backoff_until
|
|
85
|
+
|
|
86
|
+
def shutting_down?
|
|
87
|
+
@shutting_down
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The last wall-clock timestamp (Time.now.to_f) stamped by the run loop
|
|
91
|
+
# via the heartbeat's loop_tick_supplier. Wall-clock — NOT monotonic — so
|
|
92
|
+
# it stays comparable across the parent/child process boundary the
|
|
93
|
+
# supervisor watchdog reads it over. nil until the first stamp_loop_tick.
|
|
94
|
+
def last_loop_tick
|
|
95
|
+
@loop_tick_at.get
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Test seam: set one of the @last_*_at maintenance timestamps so a task
|
|
99
|
+
# becomes (or stops being) due on the next run_maintenance. The timestamps
|
|
100
|
+
# are per-task monotonic clocks with no constructor injection point (all 12
|
|
101
|
+
# default to monotonic_now at construction), so a post-construction setter
|
|
102
|
+
# is the minimal way to drive the due/not-due logic deterministically.
|
|
103
|
+
# ivar must be one of the recognized @last_*_at names.
|
|
104
|
+
def set_maintenance_timestamp(ivar, monotonic_value)
|
|
105
|
+
raise ArgumentError, "unknown maintenance timestamp #{ivar}" unless MAINTENANCE_TIMESTAMPS.include?(ivar.to_sym)
|
|
106
|
+
|
|
107
|
+
instance_variable_set(ivar, monotonic_value)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Read one of the @last_*_at maintenance timestamps (companion to
|
|
111
|
+
# set_maintenance_timestamp) so a test can assert a timestamp did or did
|
|
112
|
+
# not advance without reaching into the ivar directly.
|
|
113
|
+
def maintenance_timestamp(ivar)
|
|
114
|
+
raise ArgumentError, "unknown maintenance timestamp #{ivar}" unless MAINTENANCE_TIMESTAMPS.include?(ivar.to_sym)
|
|
115
|
+
|
|
116
|
+
instance_variable_get(ivar)
|
|
117
|
+
end
|
|
28
118
|
|
|
29
119
|
def initialize(config: Pgbus.configuration)
|
|
30
120
|
@config = config
|
|
31
121
|
@shutting_down = false
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@
|
|
37
|
-
@
|
|
38
|
-
@
|
|
39
|
-
@last_outbox_cleanup_at = monotonic_now
|
|
40
|
-
@last_job_lock_cleanup_at = monotonic_now
|
|
41
|
-
@last_stats_cleanup_at = monotonic_now
|
|
42
|
-
@last_orphan_stream_sweep_at = monotonic_now
|
|
43
|
-
@last_table_maintenance_at = monotonic_now
|
|
122
|
+
# Seed every per-task timestamp from the single source of truth so the
|
|
123
|
+
# list can never drift from set_maintenance_timestamp's allow-list.
|
|
124
|
+
now = monotonic_now
|
|
125
|
+
MAINTENANCE_TIMESTAMPS.each { |ivar| instance_variable_set(ivar, now) }
|
|
126
|
+
@maintenance_failure_streak = 0
|
|
127
|
+
@maintenance_backoff_until = nil
|
|
128
|
+
@loop_tick_at = Concurrent::AtomicReference.new(nil)
|
|
44
129
|
end
|
|
45
130
|
|
|
46
131
|
def run
|
|
@@ -51,6 +136,7 @@ module Pgbus
|
|
|
51
136
|
end
|
|
52
137
|
|
|
53
138
|
loop do
|
|
139
|
+
stamp_loop_tick
|
|
54
140
|
break if @shutting_down
|
|
55
141
|
|
|
56
142
|
process_signals
|
|
@@ -75,33 +161,120 @@ module Pgbus
|
|
|
75
161
|
|
|
76
162
|
private
|
|
77
163
|
|
|
78
|
-
def run_maintenance
|
|
79
|
-
|
|
164
|
+
def run_maintenance(now = monotonic_now)
|
|
165
|
+
return if in_backoff?(now)
|
|
166
|
+
|
|
167
|
+
results = run_maintenance_tasks(now)
|
|
168
|
+
update_maintenance_backoff(now, results)
|
|
169
|
+
end
|
|
80
170
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
run_if_due(now, :@
|
|
88
|
-
run_if_due(now, :@
|
|
89
|
-
run_if_due(now, :@
|
|
90
|
-
run_if_due(now, :@
|
|
171
|
+
# Runs every due maintenance task and returns their results. Each entry
|
|
172
|
+
# is a MaintenanceResult carrying the outcome (:success/:failed/:skipped)
|
|
173
|
+
# and, on failure, the exception and task name so run_maintenance can
|
|
174
|
+
# decide whether to report per-task or summarize.
|
|
175
|
+
def run_maintenance_tasks(now)
|
|
176
|
+
results = []
|
|
177
|
+
results << run_if_due(now, :@last_cleanup_at, CLEANUP_INTERVAL) { cleanup_processed_events }
|
|
178
|
+
results << run_if_due(now, :@last_reap_at, REAP_INTERVAL) { reap_stale_processes }
|
|
179
|
+
results << run_if_due(now, :@last_concurrency_at, CONCURRENCY_INTERVAL) { cleanup_concurrency }
|
|
180
|
+
results << run_if_due(now, :@last_batch_cleanup_at, BATCH_CLEANUP_INTERVAL) { cleanup_batches }
|
|
181
|
+
results << run_if_due(now, :@last_recurring_cleanup_at, RECURRING_CLEANUP_INTERVAL) { cleanup_recurring_executions }
|
|
182
|
+
results << run_if_due(now, :@last_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { compact_archives }
|
|
183
|
+
results << run_if_due(now, :@last_stream_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { prune_stream_archives }
|
|
184
|
+
results << run_if_due(now, :@last_outbox_cleanup_at, OUTBOX_CLEANUP_INTERVAL) { cleanup_outbox }
|
|
185
|
+
results << run_if_due(now, :@last_job_lock_cleanup_at, JOB_LOCK_CLEANUP_INTERVAL) { cleanup_job_locks }
|
|
186
|
+
results << run_if_due(now, :@last_stats_cleanup_at, STATS_CLEANUP_INTERVAL) { cleanup_stats }
|
|
91
187
|
sweep_interval = config.streams_orphan_sweep_interval
|
|
92
|
-
run_if_due(now, :@last_orphan_stream_sweep_at, sweep_interval) { sweep_orphan_streams } if sweep_interval
|
|
93
|
-
run_if_due(now, :@last_table_maintenance_at, TABLE_MAINTENANCE_INTERVAL) { run_table_maintenance }
|
|
188
|
+
results << run_if_due(now, :@last_orphan_stream_sweep_at, sweep_interval) { sweep_orphan_streams } if sweep_interval
|
|
189
|
+
results << run_if_due(now, :@last_table_maintenance_at, TABLE_MAINTENANCE_INTERVAL) { run_table_maintenance }
|
|
190
|
+
results
|
|
94
191
|
end
|
|
95
192
|
|
|
96
|
-
#
|
|
97
|
-
#
|
|
193
|
+
# Runs the block when the interval has elapsed and reports the outcome.
|
|
194
|
+
# Only updates the timestamp when the block succeeds — on failure the
|
|
195
|
+
# next tick retries instead of waiting the full interval. Errors are NOT
|
|
196
|
+
# reported here; run_maintenance decides whether to report per-task or
|
|
197
|
+
# summarize into a single backoff warning.
|
|
198
|
+
#
|
|
199
|
+
# A task fails one of two ways: it raises (a few tasks let errors
|
|
200
|
+
# propagate), or its own rescue returns the exception (most tasks catch
|
|
201
|
+
# StandardError to log a descriptive warning, then hand the error back
|
|
202
|
+
# here via `return e` so the cycle can still see the failure). Both are
|
|
203
|
+
# normalized to a :failed result carrying the exception.
|
|
98
204
|
def run_if_due(now, ivar, interval)
|
|
99
|
-
return unless now - instance_variable_get(ivar) >= interval
|
|
205
|
+
return MaintenanceResult.new(:skipped, task_name(ivar), nil) unless now - instance_variable_get(ivar) >= interval
|
|
206
|
+
|
|
207
|
+
outcome = yield
|
|
208
|
+
return MaintenanceResult.new(:failed, task_name(ivar), outcome) if outcome.is_a?(StandardError)
|
|
100
209
|
|
|
101
|
-
yield
|
|
102
210
|
instance_variable_set(ivar, now)
|
|
211
|
+
MaintenanceResult.new(:success, task_name(ivar), nil)
|
|
103
212
|
rescue StandardError => e
|
|
104
|
-
|
|
213
|
+
MaintenanceResult.new(:failed, task_name(ivar), e)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def task_name(ivar)
|
|
217
|
+
ivar.to_s.delete_prefix("@last_").delete_suffix("_at")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def in_backoff?(now)
|
|
221
|
+
@maintenance_backoff_until && now < @maintenance_backoff_until
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Given this cycle's results, advance or reset the failure streak and
|
|
225
|
+
# the backoff window. Any success restores normal cadence. An all-failed
|
|
226
|
+
# cycle (at least one task attempted, none succeeded) advances the
|
|
227
|
+
# streak; the first such cycle reports every error individually, and the
|
|
228
|
+
# cycle that reaches the streak threshold enters backoff with a single
|
|
229
|
+
# summary warning instead.
|
|
230
|
+
def update_maintenance_backoff(now, results)
|
|
231
|
+
attempted = results.reject(&:skipped?)
|
|
232
|
+
return if attempted.empty?
|
|
233
|
+
|
|
234
|
+
if attempted.any?(&:success?)
|
|
235
|
+
reset_maintenance_backoff
|
|
236
|
+
return
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
enter_or_advance_backoff(now, attempted)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def reset_maintenance_backoff
|
|
243
|
+
@maintenance_failure_streak = 0
|
|
244
|
+
@maintenance_backoff_until = nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def enter_or_advance_backoff(now, failures)
|
|
248
|
+
first_failing_cycle = @maintenance_failure_streak.zero?
|
|
249
|
+
@maintenance_failure_streak += 1
|
|
250
|
+
|
|
251
|
+
if first_failing_cycle
|
|
252
|
+
report_maintenance_failures(failures)
|
|
253
|
+
elsif @maintenance_failure_streak >= 2
|
|
254
|
+
begin_backoff(now, failures)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def report_maintenance_failures(failures)
|
|
259
|
+
failures.each do |result|
|
|
260
|
+
ErrorReporter.report(result.error, { action: "dispatcher_maintenance", task: result.task })
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def begin_backoff(now, failures)
|
|
265
|
+
delay = backoff_delay(@maintenance_failure_streak)
|
|
266
|
+
@maintenance_backoff_until = now + delay
|
|
267
|
+
first = failures.first
|
|
268
|
+
Pgbus.logger.warn do
|
|
269
|
+
"[Pgbus] Dispatcher maintenance backoff: #{failures.size} task(s) failing " \
|
|
270
|
+
"(#{first.error.class}: #{first.error.message}); skipping maintenance for #{delay}s"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Exponential window: BASE * 2**(streak - 2), capped at MAX. Streak 2 is
|
|
275
|
+
# the first backoff (2**0 == 1 == BASE), streak 3 doubles it, and so on.
|
|
276
|
+
def backoff_delay(streak)
|
|
277
|
+
[MAINTENANCE_BACKOFF_BASE * (2**(streak - 2)), MAINTENANCE_BACKOFF_MAX].min
|
|
105
278
|
end
|
|
106
279
|
|
|
107
280
|
def cleanup_processed_events
|
|
@@ -112,6 +285,7 @@ module Pgbus
|
|
|
112
285
|
Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} expired processed events" } if deleted.positive?
|
|
113
286
|
rescue StandardError => e
|
|
114
287
|
Pgbus.logger.warn { "[Pgbus] Idempotency cleanup failed: #{e.message}" }
|
|
288
|
+
e
|
|
115
289
|
end
|
|
116
290
|
|
|
117
291
|
def reap_stale_processes
|
|
@@ -120,6 +294,7 @@ module Pgbus
|
|
|
120
294
|
Pgbus.logger.info { "[Pgbus] Reaped #{deleted} stale processes" } if deleted.positive?
|
|
121
295
|
rescue StandardError => e
|
|
122
296
|
Pgbus.logger.warn { "[Pgbus] Stale process reaping failed: #{e.message}" }
|
|
297
|
+
e
|
|
123
298
|
end
|
|
124
299
|
|
|
125
300
|
def cleanup_concurrency
|
|
@@ -132,6 +307,7 @@ module Pgbus
|
|
|
132
307
|
Pgbus.logger.debug { "[Pgbus] Expired #{orphaned} orphaned blocked executions" } if orphaned.positive?
|
|
133
308
|
rescue StandardError => e
|
|
134
309
|
Pgbus.logger.warn { "[Pgbus] Concurrency cleanup failed: #{e.message}" }
|
|
310
|
+
e
|
|
135
311
|
end
|
|
136
312
|
|
|
137
313
|
def release_blocked_for_key(key)
|
|
@@ -146,6 +322,7 @@ module Pgbus
|
|
|
146
322
|
Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} finished batches" } if deleted.positive?
|
|
147
323
|
rescue StandardError => e
|
|
148
324
|
Pgbus.logger.warn { "[Pgbus] Batch cleanup failed: #{e.message}" }
|
|
325
|
+
e
|
|
149
326
|
end
|
|
150
327
|
|
|
151
328
|
def cleanup_stats
|
|
@@ -171,11 +348,25 @@ module Pgbus
|
|
|
171
348
|
maintained = TableMaintenance.run_maintenance(
|
|
172
349
|
raw_conn,
|
|
173
350
|
threshold: TableMaintenance::BLOAT_THRESHOLD,
|
|
174
|
-
reindex: true
|
|
351
|
+
reindex: true,
|
|
352
|
+
stop_check: -> { @shutting_down }
|
|
175
353
|
)
|
|
176
354
|
Pgbus.logger.info { "[Pgbus] Table maintenance completed: #{maintained} table(s) vacuumed" } if maintained.positive?
|
|
177
355
|
rescue StandardError => e
|
|
178
356
|
Pgbus.logger.warn { "[Pgbus] Table maintenance failed: #{e.message}" }
|
|
357
|
+
e
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Polled at the top of each per-queue maintenance loop. Returns true when
|
|
361
|
+
# a shutdown has begun, logging one summary line so the interruption is
|
|
362
|
+
# visible. `index` is how many queues were already processed, so the
|
|
363
|
+
# message reads "after N of M". An interrupted (early-returned) task
|
|
364
|
+
# counts as success in run_if_due and simply runs again next interval.
|
|
365
|
+
def interrupted?(label, index, total)
|
|
366
|
+
return false unless @shutting_down
|
|
367
|
+
|
|
368
|
+
Pgbus.logger.info { "[Pgbus] #{label} interrupted by shutdown after #{index} of #{total}" }
|
|
369
|
+
true
|
|
179
370
|
end
|
|
180
371
|
|
|
181
372
|
def cleanup_job_locks
|
|
@@ -186,6 +377,9 @@ module Pgbus
|
|
|
186
377
|
# that fail and retry can hold locks for hours.
|
|
187
378
|
reaped = reap_orphaned_uniqueness_keys
|
|
188
379
|
Pgbus.logger.info { "[Pgbus] Reaped #{reaped} orphaned uniqueness keys" } if reaped.positive?
|
|
380
|
+
rescue StandardError => e
|
|
381
|
+
Pgbus.logger.warn { "[Pgbus] Job lock cleanup failed: #{e.message}" }
|
|
382
|
+
e
|
|
189
383
|
end
|
|
190
384
|
|
|
191
385
|
def reap_orphaned_uniqueness_keys
|
|
@@ -208,9 +402,6 @@ module Pgbus
|
|
|
208
402
|
return 0 if orphaned.empty?
|
|
209
403
|
|
|
210
404
|
UniquenessKey.where(lock_key: orphaned.map(&:lock_key)).delete_all
|
|
211
|
-
rescue StandardError => e
|
|
212
|
-
Pgbus.logger.warn { "[Pgbus] Uniqueness key cleanup failed: #{e.message}" }
|
|
213
|
-
0
|
|
214
405
|
end
|
|
215
406
|
|
|
216
407
|
# Returns true if the message referenced by this lock is definitely gone
|
|
@@ -244,6 +435,7 @@ module Pgbus
|
|
|
244
435
|
Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} published outbox entries" } if deleted.positive?
|
|
245
436
|
rescue StandardError => e
|
|
246
437
|
Pgbus.logger.warn { "[Pgbus] Outbox cleanup failed: #{e.message}" }
|
|
438
|
+
e
|
|
247
439
|
end
|
|
248
440
|
|
|
249
441
|
def compact_archives
|
|
@@ -257,17 +449,23 @@ module Pgbus
|
|
|
257
449
|
conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
|
|
258
450
|
queue_names = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
|
|
259
451
|
|
|
260
|
-
|
|
452
|
+
outcome = LoopOutcome.new
|
|
453
|
+
queue_names.each_with_index do |full_name, index|
|
|
454
|
+
break if interrupted?("Archive compaction", index, queue_names.size)
|
|
261
455
|
next unless full_name.start_with?("#{prefix}_")
|
|
262
456
|
|
|
263
457
|
stripped = full_name.delete_prefix("#{prefix}_")
|
|
264
458
|
deleted = Pgbus.client.purge_archive(stripped, older_than: cutoff, batch_size: batch_size)
|
|
265
459
|
Pgbus.logger.debug { "[Pgbus] Compacted #{deleted} archive entries from #{full_name}" } if deleted.positive?
|
|
460
|
+
outcome.record_success
|
|
266
461
|
rescue StandardError => e
|
|
267
462
|
Pgbus.logger.warn { "[Pgbus] Archive compaction failed for #{full_name}: #{e.message}" }
|
|
463
|
+
outcome.record_failure(e)
|
|
268
464
|
end
|
|
465
|
+
outcome.result
|
|
269
466
|
rescue StandardError => e
|
|
270
467
|
Pgbus.logger.warn { "[Pgbus] Archive compaction failed: #{e.message}" }
|
|
468
|
+
e
|
|
271
469
|
end
|
|
272
470
|
|
|
273
471
|
# Prunes per-stream archive tables. Unlike compact_archives (which
|
|
@@ -280,11 +478,13 @@ module Pgbus
|
|
|
280
478
|
prefix = config.streams_queue_prefix
|
|
281
479
|
return if prefix.nil? || prefix.empty?
|
|
282
480
|
|
|
283
|
-
batch_size =
|
|
481
|
+
batch_size = ARCHIVE_COMPACTION_BATCH_SIZE
|
|
284
482
|
conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
|
|
285
483
|
queue_names = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
|
|
286
484
|
|
|
287
|
-
|
|
485
|
+
outcome = LoopOutcome.new
|
|
486
|
+
queue_names.each_with_index do |full_name, index|
|
|
487
|
+
break if interrupted?("Stream archive compaction", index, queue_names.size)
|
|
288
488
|
next unless full_name.start_with?("#{prefix}_")
|
|
289
489
|
|
|
290
490
|
retention = retention_for_stream_queue(full_name)
|
|
@@ -298,11 +498,15 @@ module Pgbus
|
|
|
298
498
|
"[Pgbus] Compacted #{deleted} stream archive entries from #{full_name}"
|
|
299
499
|
end
|
|
300
500
|
end
|
|
501
|
+
outcome.record_success
|
|
301
502
|
rescue StandardError => e
|
|
302
503
|
Pgbus.logger.warn { "[Pgbus] Stream archive compaction failed for #{full_name}: #{e.message}" }
|
|
504
|
+
outcome.record_failure(e)
|
|
303
505
|
end
|
|
506
|
+
outcome.result
|
|
304
507
|
rescue StandardError => e
|
|
305
508
|
Pgbus.logger.warn { "[Pgbus] Stream archive compaction failed: #{e.message}" }
|
|
509
|
+
e
|
|
306
510
|
end
|
|
307
511
|
|
|
308
512
|
def retention_for_stream_queue(full_name)
|
|
@@ -330,7 +534,9 @@ module Pgbus
|
|
|
330
534
|
queue_names = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
|
|
331
535
|
|
|
332
536
|
dropped = 0
|
|
333
|
-
|
|
537
|
+
outcome = LoopOutcome.new
|
|
538
|
+
queue_names.each_with_index do |full_name, index|
|
|
539
|
+
break if interrupted?("Orphan stream sweep", index, queue_names.size)
|
|
334
540
|
next unless full_name.start_with?("#{prefix}_")
|
|
335
541
|
|
|
336
542
|
row = conn.select_one(<<~SQL, "Pgbus Orphan Check")
|
|
@@ -344,13 +550,17 @@ module Pgbus
|
|
|
344
550
|
Pgbus.client.drop_queue(full_name, prefixed: false)
|
|
345
551
|
dropped += 1
|
|
346
552
|
Pgbus.logger.info { "[Pgbus] Dropped orphan stream queue: #{full_name}" }
|
|
553
|
+
outcome.record_success
|
|
347
554
|
rescue StandardError => e
|
|
348
555
|
Pgbus.logger.warn { "[Pgbus] Orphan stream sweep failed for #{full_name}: #{e.message}" }
|
|
556
|
+
outcome.record_failure(e)
|
|
349
557
|
end
|
|
350
558
|
|
|
351
559
|
Pgbus.logger.debug { "[Pgbus] Orphan stream sweep complete: dropped #{dropped} queue(s)" } if dropped.positive?
|
|
560
|
+
outcome.result
|
|
352
561
|
rescue StandardError => e
|
|
353
562
|
Pgbus.logger.warn { "[Pgbus] Orphan stream sweep failed: #{e.message}" }
|
|
563
|
+
e
|
|
354
564
|
end
|
|
355
565
|
|
|
356
566
|
def cleanup_recurring_executions
|
|
@@ -361,14 +571,28 @@ module Pgbus
|
|
|
361
571
|
Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} old recurring executions" } if deleted.positive?
|
|
362
572
|
rescue StandardError => e
|
|
363
573
|
Pgbus.logger.warn { "[Pgbus] Recurring execution cleanup failed: #{e.message}" }
|
|
574
|
+
e
|
|
364
575
|
end
|
|
365
576
|
|
|
366
577
|
def monotonic_now
|
|
367
578
|
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
368
579
|
end
|
|
369
580
|
|
|
581
|
+
# Wall clock (not monotonic) so the dashboard can compare the beacon
|
|
582
|
+
# against Time.now across processes; the heartbeat timer thread reads it
|
|
583
|
+
# via loop_tick_supplier. A dispatcher wedged inside run_maintenance stops
|
|
584
|
+
# advancing this while heartbeats keep firing, so the beacon ages and the
|
|
585
|
+
# processes page can surface the stall.
|
|
586
|
+
def stamp_loop_tick
|
|
587
|
+
@loop_tick_at.set(Time.now.to_f)
|
|
588
|
+
end
|
|
589
|
+
|
|
370
590
|
def start_heartbeat
|
|
371
|
-
@heartbeat = Heartbeat.new(
|
|
591
|
+
@heartbeat = Heartbeat.new(
|
|
592
|
+
kind: "dispatcher",
|
|
593
|
+
metadata: { pid: ::Process.pid },
|
|
594
|
+
loop_tick_supplier: -> { @loop_tick_at.get }
|
|
595
|
+
)
|
|
372
596
|
@heartbeat.start
|
|
373
597
|
end
|
|
374
598
|
|
|
@@ -11,11 +11,12 @@ module Pgbus
|
|
|
11
11
|
|
|
12
12
|
attr_reader :process_entry
|
|
13
13
|
|
|
14
|
-
def initialize(kind:, metadata: {}, on_beat: nil, loop_tick_supplier: nil)
|
|
14
|
+
def initialize(kind:, metadata: {}, on_beat: nil, loop_tick_supplier: nil, metadata_supplier: nil)
|
|
15
15
|
@kind = kind
|
|
16
16
|
@metadata = metadata
|
|
17
17
|
@on_beat = on_beat
|
|
18
18
|
@loop_tick_supplier = loop_tick_supplier
|
|
19
|
+
@metadata_supplier = metadata_supplier
|
|
19
20
|
@timer = nil
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -35,10 +36,8 @@ module Pgbus
|
|
|
35
36
|
|
|
36
37
|
@on_beat&.call
|
|
37
38
|
updates = { last_heartbeat_at: Time.current }
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
updates[:metadata] = @metadata.merge("loop_tick_at" => tick&.to_f)
|
|
41
|
-
end
|
|
39
|
+
metadata = beat_metadata
|
|
40
|
+
updates[:metadata] = metadata unless metadata.nil?
|
|
42
41
|
ProcessEntry.where(id: @process_id).update_all(updates)
|
|
43
42
|
rescue StandardError => e
|
|
44
43
|
Pgbus.logger.warn { "[Pgbus] Heartbeat failed: #{e.message}" }
|
|
@@ -46,6 +45,20 @@ module Pgbus
|
|
|
46
45
|
|
|
47
46
|
private
|
|
48
47
|
|
|
48
|
+
# Build the metadata hash to persist on this beat. Returns nil when no
|
|
49
|
+
# supplier is configured so heartbeats without dynamic metadata
|
|
50
|
+
# (supervisor/dispatcher/scheduler) leave the column untouched, exactly
|
|
51
|
+
# as before. The loop_tick and metadata suppliers are called here — after
|
|
52
|
+
# @on_beat has run — so any snapshot!/refresh in on_beat is reflected.
|
|
53
|
+
def beat_metadata
|
|
54
|
+
return nil unless @loop_tick_supplier || @metadata_supplier
|
|
55
|
+
|
|
56
|
+
metadata = @metadata.dup
|
|
57
|
+
metadata["loop_tick_at"] = @loop_tick_supplier.call&.to_f if @loop_tick_supplier
|
|
58
|
+
metadata.merge!(@metadata_supplier.call) if @metadata_supplier
|
|
59
|
+
metadata
|
|
60
|
+
end
|
|
61
|
+
|
|
49
62
|
def register_process
|
|
50
63
|
record = ProcessEntry.create!(
|
|
51
64
|
kind: @kind,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Process
|
|
5
|
+
module MemoryUsage
|
|
6
|
+
MEMORY_CHECK_TTL = 5
|
|
7
|
+
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
@cached_value = nil
|
|
10
|
+
@cached_at = nil
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def current_mb
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
16
|
+
|
|
17
|
+
return @cached_value if @cached_value && @cached_at && (now - @cached_at) < MEMORY_CHECK_TTL
|
|
18
|
+
|
|
19
|
+
@cached_value = read_memory
|
|
20
|
+
@cached_at = now
|
|
21
|
+
@cached_value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reset!
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
@cached_value = nil
|
|
28
|
+
@cached_at = nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def read_memory
|
|
35
|
+
if RUBY_PLATFORM.include?("darwin")
|
|
36
|
+
`ps -o rss= -p #{::Process.pid}`.to_i / 1024
|
|
37
|
+
else
|
|
38
|
+
begin
|
|
39
|
+
File.read("/proc/#{::Process.pid}/statm").split[1].to_i * 4096 / (1024 * 1024)
|
|
40
|
+
rescue Errno::ENOENT
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -95,14 +95,29 @@ module Pgbus
|
|
|
95
95
|
@commands << [:unlisten, physical_queue]
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
# Public so the owning worker can detect a listener whose thread died
|
|
99
|
+
# (run_loop hit a fatal error and cleared @running in its ensure) and
|
|
100
|
+
# restart it. Guarded by @state_mutex like every other @running access.
|
|
100
101
|
def running?
|
|
101
102
|
@state_mutex.synchronize { @running }
|
|
102
103
|
end
|
|
103
104
|
|
|
105
|
+
private
|
|
106
|
+
|
|
104
107
|
def run_loop
|
|
105
108
|
conn = build_connection
|
|
109
|
+
# Reject a connection that landed on a read-only replica before doing
|
|
110
|
+
# anything else. After a failover, stale DNS can point this fresh
|
|
111
|
+
# connection at the demoted master; NOTIFY fires only on the primary,
|
|
112
|
+
# so we'd sit deaf forever. A replica here raises ReplicaConnectionError
|
|
113
|
+
# and the rescue below runs the fatal/ensure path (worker-level startup
|
|
114
|
+
# retry is a separate concern); a reconnect converges on the primary.
|
|
115
|
+
PrimaryValidator.validate_primary!(conn)
|
|
116
|
+
# One-shot delivery self-probe on the initial connection only. A pooler
|
|
117
|
+
# or replica that silently breaks LISTEN/NOTIFY is surfaced here with an
|
|
118
|
+
# actionable error; the listener still runs and degrades to polling.
|
|
119
|
+
# Reconnects skip the probe to stay cheap.
|
|
120
|
+
NotifyProbe.probe_notify_delivery!(conn, logger: @logger)
|
|
106
121
|
@state_mutex.synchronize { @conn = conn }
|
|
107
122
|
drain_commands
|
|
108
123
|
|
|
@@ -200,12 +215,16 @@ module Pgbus
|
|
|
200
215
|
new_conn = nil
|
|
201
216
|
begin
|
|
202
217
|
new_conn = build_connection
|
|
218
|
+
# Reject a replica before re-LISTENing. A fresh PG.connect re-resolves
|
|
219
|
+
# DNS, so backing off and retrying converges on the promoted master
|
|
220
|
+
# once DNS catches up after a failover.
|
|
221
|
+
PrimaryValidator.validate_primary!(new_conn)
|
|
203
222
|
channels.each { |channel| new_conn.exec(%(LISTEN "#{channel}")) }
|
|
204
|
-
rescue PG::Error => e
|
|
205
|
-
# build_connection may have succeeded before a later LISTEN raised
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
# repeated failures.
|
|
223
|
+
rescue PG::Error, ReplicaConnectionError => e
|
|
224
|
+
# build_connection may have succeeded before a later LISTEN raised,
|
|
225
|
+
# or validate_primary! rejected a replica. Without this close, the
|
|
226
|
+
# partially-built conn is orphaned and the next retry just allocates
|
|
227
|
+
# another one — leaking PG connections on repeated failures.
|
|
209
228
|
close_quietly(new_conn)
|
|
210
229
|
@logger.error { "[Pgbus::NotifyListener] reconnect failed: #{e.class}: #{e.message}" }
|
|
211
230
|
sleep RECONNECT_BACKOFF_SECONDS
|