pgbus 0.13.1 → 0.13.3
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 +12 -0
- data/README.md +78 -3
- data/app/models/pgbus/processed_event.rb +45 -0
- data/app/views/pgbus/dashboard/_queues_table.html.erb +5 -3
- data/app/views/pgbus/queues/_queues_list.html.erb +5 -3
- data/app/views/pgbus/queues/show.html.erb +3 -0
- data/config/locales/da.yml +7 -2
- data/config/locales/de.yml +7 -2
- data/config/locales/en.yml +7 -2
- data/config/locales/es.yml +7 -2
- data/config/locales/fi.yml +7 -2
- data/config/locales/fr.yml +7 -2
- data/config/locales/it.yml +7 -2
- data/config/locales/ja.yml +7 -2
- data/config/locales/nb.yml +7 -2
- data/config/locales/nl.yml +7 -2
- data/config/locales/pt.yml +7 -2
- data/config/locales/sv.yml +7 -2
- data/exe/pgbus-health +9 -0
- data/lib/generators/pgbus/add_processed_event_completion_generator.rb +45 -0
- data/lib/generators/pgbus/templates/add_processed_event_completion.rb.erb +19 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +3 -0
- data/lib/pgbus/cli.rb +6 -4
- data/lib/pgbus/client.rb +44 -0
- data/lib/pgbus/configuration.rb +43 -0
- data/lib/pgbus/event_bus/handler.rb +49 -7
- data/lib/pgbus/generators/migration_detector.rb +13 -0
- data/lib/pgbus/health_probe.rb +132 -0
- data/lib/pgbus/integrations/appsignal/probe.rb +8 -4
- data/lib/pgbus/mcp/tools/queues_tool.rb +6 -0
- data/lib/pgbus/process/consumer.rb +4 -1
- data/lib/pgbus/process/readiness_snapshot.rb +30 -0
- data/lib/pgbus/process/supervisor.rb +64 -3
- data/lib/pgbus/process/worker.rb +14 -4
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +8 -1
- data/lib/pgbus/web/health_app.rb +23 -1
- data/lib/pgbus/web/metrics_serializer.rb +9 -0
- metadata +7 -1
data/config/locales/sv.yml
CHANGED
|
@@ -65,7 +65,8 @@ sv:
|
|
|
65
65
|
empty: Inga köer hittades
|
|
66
66
|
headers:
|
|
67
67
|
depth: Djup
|
|
68
|
-
|
|
68
|
+
oldest_claimable: Äldsta tillgänglig (s)
|
|
69
|
+
parked: Parkerade
|
|
69
70
|
queue: Kö
|
|
70
71
|
total: Totalt
|
|
71
72
|
visible: Synliga
|
|
@@ -475,7 +476,8 @@ sv:
|
|
|
475
476
|
actions: Åtgärder
|
|
476
477
|
depth: Djup
|
|
477
478
|
newest: Nyaste (s)
|
|
478
|
-
|
|
479
|
+
oldest_claimable: Äldsta tillgänglig (s)
|
|
480
|
+
parked: Parkerade
|
|
479
481
|
queue: Kö
|
|
480
482
|
total_ever: Totalt någonsin
|
|
481
483
|
visible: Synliga
|
|
@@ -516,6 +518,9 @@ sv:
|
|
|
516
518
|
scheduled: 'Schemalagt:'
|
|
517
519
|
timezone: 'Tidszon:'
|
|
518
520
|
visible_at: 'Synlig vid:'
|
|
521
|
+
oldest: 'Äldsta:'
|
|
522
|
+
oldest_claimable: 'Äldsta tillgänglig:'
|
|
523
|
+
parked: 'Parkerade:'
|
|
519
524
|
pause: Pausa
|
|
520
525
|
pause_confirm: Pausa bearbetning?
|
|
521
526
|
purge_confirm: Rensa alla meddelanden?
|
data/exe/pgbus-health
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Container HEALTHCHECK probe (issue #386). Deliberately loads ONLY the
|
|
5
|
+
# probe file — never the pgbus gem, Bundler, or Rails — because a docker
|
|
6
|
+
# HEALTHCHECK runs this every few seconds.
|
|
7
|
+
require_relative "../lib/pgbus/health_probe"
|
|
8
|
+
|
|
9
|
+
exit Pgbus::HealthProbe.run(ARGV)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require_relative "migration_path"
|
|
6
|
+
|
|
7
|
+
module Pgbus
|
|
8
|
+
module Generators
|
|
9
|
+
class AddProcessedEventCompletionGenerator < Rails::Generators::Base
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
|
+
include MigrationPath
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Add completed_at to pgbus_processed_events for two-phase idempotency claims " \
|
|
16
|
+
"(crash mid-handler re-runs instead of silently skipping)"
|
|
17
|
+
|
|
18
|
+
class_option :database,
|
|
19
|
+
type: :string,
|
|
20
|
+
default: nil,
|
|
21
|
+
desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
|
|
22
|
+
|
|
23
|
+
def create_migration_file
|
|
24
|
+
migration_template "add_processed_event_completion.rb.erb",
|
|
25
|
+
File.join(pgbus_migrate_path, "add_pgbus_processed_event_completion.rb")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def display_post_install
|
|
29
|
+
say ""
|
|
30
|
+
say "Pgbus two-phase idempotency claim migration installed!", :green
|
|
31
|
+
say ""
|
|
32
|
+
say "Next steps:"
|
|
33
|
+
say " 1. Run: rails db:migrate#{migrate_command_suffix}"
|
|
34
|
+
say " 2. Restart pgbus: bin/pgbus start"
|
|
35
|
+
say ""
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def migration_version
|
|
41
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class AddPgbusProcessedEventCompletion < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
add_column :pgbus_processed_events, :completed_at, :datetime
|
|
4
|
+
|
|
5
|
+
# Backfill legacy rows as completed: a row written by the single-phase
|
|
6
|
+
# code means handle() was at least started — treating it as completed
|
|
7
|
+
# preserves the old skip behavior instead of retroactively re-running
|
|
8
|
+
# history on the next delivery of an old event.
|
|
9
|
+
execute <<~SQL
|
|
10
|
+
UPDATE pgbus_processed_events
|
|
11
|
+
SET completed_at = processed_at
|
|
12
|
+
WHERE completed_at IS NULL
|
|
13
|
+
SQL
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def down
|
|
17
|
+
remove_column :pgbus_processed_events, :completed_at
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -30,6 +30,9 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
30
30
|
t.string :event_id, null: false
|
|
31
31
|
t.string :handler_class, null: false
|
|
32
32
|
t.datetime :processed_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
33
|
+
# Two-phase idempotency claim: NULL = claimed but not finished (a crash
|
|
34
|
+
# mid-handler re-runs on redelivery); set = completed, dedup applies.
|
|
35
|
+
t.datetime :completed_at
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
add_index :pgbus_processed_events, [:event_id, :handler_class],
|
data/lib/pgbus/cli.rb
CHANGED
|
@@ -177,14 +177,16 @@ module Pgbus
|
|
|
177
177
|
def list_queues
|
|
178
178
|
Pgbus.client.list_queues
|
|
179
179
|
metrics = Pgbus.client.metrics
|
|
180
|
+
claimable_ages = Pgbus.client.oldest_claimable_ages
|
|
180
181
|
|
|
181
|
-
puts "QUEUE DEPTH VISIBLE OLDEST (s) TOTAL "
|
|
182
|
-
puts "-" *
|
|
182
|
+
puts "QUEUE DEPTH VISIBLE OLDEST (s) CLAIMABLE (s) TOTAL "
|
|
183
|
+
puts "-" * 111
|
|
183
184
|
|
|
184
185
|
Array(metrics).each do |m|
|
|
185
|
-
puts format("%-40s %-10s %-10s %-15s %-15s",
|
|
186
|
+
puts format("%-40s %-10s %-10s %-15s %-15s %-15s",
|
|
186
187
|
m.queue_name, m.queue_length, m.queue_visible_length,
|
|
187
|
-
m.oldest_msg_age_sec || "-", m.
|
|
188
|
+
m.oldest_msg_age_sec || "-", claimable_ages[m.queue_name] || "-",
|
|
189
|
+
m.total_messages)
|
|
188
190
|
end
|
|
189
191
|
end
|
|
190
192
|
|
data/lib/pgbus/client.rb
CHANGED
|
@@ -543,6 +543,38 @@ module Pgbus
|
|
|
543
543
|
end
|
|
544
544
|
end
|
|
545
545
|
|
|
546
|
+
# Age (seconds) of the oldest message actually eligible for pickup, i.e.
|
|
547
|
+
# whose visibility timeout has elapsed. Unlike pgmq's oldest_msg_age_sec
|
|
548
|
+
# (computed from enqueued_at), a scheduled or backoff-parked message —
|
|
549
|
+
# future vt — contributes nothing until it comes due, so a queue holding
|
|
550
|
+
# only parked messages reads nil ("no claimable backlog") instead of an
|
|
551
|
+
# age growing at wall-clock rate (issue #389). pgmq's metrics_result type
|
|
552
|
+
# is frozen upstream, so this lives here rather than in the SQL function.
|
|
553
|
+
#
|
|
554
|
+
# With a queue name: the age for that (prefixed) queue, or nil.
|
|
555
|
+
# Without: a hash of every physical queue in pgmq.meta to its age.
|
|
556
|
+
#
|
|
557
|
+
# Routes through the pooled @pgmq.with_connection (health-checked, bounded
|
|
558
|
+
# by the statement/socket timeouts applied at Client#initialize) rather
|
|
559
|
+
# than a fresh unbounded PG.connect per call — same rationale as
|
|
560
|
+
# notify_trigger_current?. synchronized: on the shared-Proc path @pgmq
|
|
561
|
+
# rides the AR raw connection, so the query must serialize against
|
|
562
|
+
# concurrent PGMQ operations. One checkout spans all per-queue queries;
|
|
563
|
+
# nothing nests inside it, so the shared pool_size=1 path is safe.
|
|
564
|
+
def oldest_claimable_ages(queue_name = nil)
|
|
565
|
+
synchronized do
|
|
566
|
+
@pgmq.with_connection do |conn|
|
|
567
|
+
if queue_name
|
|
568
|
+
claimable_age_for(conn, config.queue_name(queue_name))
|
|
569
|
+
else
|
|
570
|
+
names = conn.exec("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
|
|
571
|
+
.map { |row| row["queue_name"] }
|
|
572
|
+
names.to_h { |name| [name, claimable_age_for(conn, name)] }
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
546
578
|
# Snapshot of the PGMQ connection pool: {size:, available:, pool_timeout:}.
|
|
547
579
|
#
|
|
548
580
|
# Reads pgmq-ruby's own pool counters (@pgmq.stats -> {size:, available:})
|
|
@@ -948,6 +980,18 @@ module Pgbus
|
|
|
948
980
|
end
|
|
949
981
|
end
|
|
950
982
|
|
|
983
|
+
# queue_name is a physical (already prefixed) queue name; sanitized to a
|
|
984
|
+
# bare identifier before interpolation, same as the dashboard's DataSource.
|
|
985
|
+
def claimable_age_for(conn, queue_name)
|
|
986
|
+
qtable = "q_#{QueueNameValidator.sanitize!(queue_name)}"
|
|
987
|
+
row = conn.exec(<<~SQL).first
|
|
988
|
+
SELECT EXTRACT(epoch FROM (NOW() - min(vt)))::int AS age_sec
|
|
989
|
+
FROM pgmq.#{qtable}
|
|
990
|
+
WHERE vt <= NOW()
|
|
991
|
+
SQL
|
|
992
|
+
row && row["age_sec"]&.to_i
|
|
993
|
+
end
|
|
994
|
+
|
|
951
995
|
def with_raw_connection
|
|
952
996
|
opts = config.connection_options
|
|
953
997
|
owned = false
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -35,6 +35,16 @@ module Pgbus
|
|
|
35
35
|
# wait, so recycling/deploy never wedges on a permanently-stuck job.
|
|
36
36
|
attr_accessor :stall_threshold, :read_timeout, :drain_timeout
|
|
37
37
|
|
|
38
|
+
# shutdown_timeout bounds how long the supervisor waits for its children
|
|
39
|
+
# after forwarding TERM before escalating to SIGKILL. nil (default) derives
|
|
40
|
+
# drain_timeout + SHUTDOWN_TIMEOUT_MARGIN, so raising drain_timeout keeps
|
|
41
|
+
# the supervisor's deadline above the workers' drain window. An orchestrator
|
|
42
|
+
# stop grace period (Kamal stop_timeout, Kubernetes terminationGracePeriod)
|
|
43
|
+
# should exceed this value, or docker SIGKILLs the whole tree first.
|
|
44
|
+
attr_writer :shutdown_timeout
|
|
45
|
+
|
|
46
|
+
SHUTDOWN_TIMEOUT_MARGIN = 5
|
|
47
|
+
|
|
38
48
|
# Dispatcher settings
|
|
39
49
|
attr_accessor :dispatch_interval
|
|
40
50
|
|
|
@@ -238,6 +248,7 @@ module Pgbus
|
|
|
238
248
|
@stall_threshold = 90
|
|
239
249
|
@read_timeout = 30
|
|
240
250
|
@drain_timeout = 30
|
|
251
|
+
@shutdown_timeout = nil
|
|
241
252
|
|
|
242
253
|
@dispatch_interval = 1.0
|
|
243
254
|
|
|
@@ -712,6 +723,8 @@ module Pgbus
|
|
|
712
723
|
end
|
|
713
724
|
raise Pgbus::ConfigurationError, "drain_timeout must be > 0" unless drain_timeout.is_a?(Numeric) && drain_timeout.positive?
|
|
714
725
|
|
|
726
|
+
validate_shutdown_timeout!
|
|
727
|
+
|
|
715
728
|
unless stats_flush_size.is_a?(Integer) && stats_flush_size.positive?
|
|
716
729
|
raise Pgbus::ConfigurationError, "stats_flush_size must be a positive integer"
|
|
717
730
|
end
|
|
@@ -765,6 +778,30 @@ module Pgbus
|
|
|
765
778
|
self
|
|
766
779
|
end
|
|
767
780
|
|
|
781
|
+
# An explicit shutdown_timeout must be a positive number; nil keeps the
|
|
782
|
+
# derived drain_timeout + margin default. A value below drain_timeout is
|
|
783
|
+
# legal but self-defeating (the supervisor SIGKILLs workers mid-drain), so
|
|
784
|
+
# it warns instead of raising.
|
|
785
|
+
def validate_shutdown_timeout!
|
|
786
|
+
explicit = @shutdown_timeout
|
|
787
|
+
# Finite real only: Float::INFINITY would blow up Supervisor#shutdown's
|
|
788
|
+
# `Time.now + shutdown_timeout` before any child cleanup ran, and a
|
|
789
|
+
# Complex would crash `positive?` — reject both here, at boot.
|
|
790
|
+
valid = explicit.is_a?(Numeric) && explicit.real? && explicit.finite? && explicit.positive?
|
|
791
|
+
unless explicit.nil? || valid
|
|
792
|
+
raise Pgbus::ConfigurationError,
|
|
793
|
+
"shutdown_timeout must be a positive finite number or nil " \
|
|
794
|
+
"(defaults to drain_timeout + #{SHUTDOWN_TIMEOUT_MARGIN})"
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
return unless explicit && explicit < drain_timeout
|
|
798
|
+
|
|
799
|
+
Pgbus.logger.warn do
|
|
800
|
+
"[Pgbus] shutdown_timeout (#{explicit}s) is below drain_timeout (#{drain_timeout}s) — " \
|
|
801
|
+
"the supervisor will SIGKILL workers before their drain window ends"
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
768
805
|
# Pre-1.0 surface-freeze: reject malformed values for core job-path keys at
|
|
769
806
|
# boot rather than failing deep in a worker/dispatcher/poller/scheduler
|
|
770
807
|
# thread, per-enqueue, or by silently corrupting queue names / leaving the
|
|
@@ -1237,6 +1274,12 @@ module Pgbus
|
|
|
1237
1274
|
# because only one runs at a time per reactor thread.
|
|
1238
1275
|
ASYNC_POOL_CONNECTIONS = 3
|
|
1239
1276
|
|
|
1277
|
+
# Resolved supervisor SIGKILL deadline: the explicit value when set,
|
|
1278
|
+
# otherwise drain_timeout + SHUTDOWN_TIMEOUT_MARGIN (see attr_writer docs).
|
|
1279
|
+
def shutdown_timeout
|
|
1280
|
+
@shutdown_timeout || (drain_timeout + SHUTDOWN_TIMEOUT_MARGIN)
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1240
1283
|
def resolved_pool_size
|
|
1241
1284
|
return pool_size if pool_size
|
|
1242
1285
|
|
|
@@ -45,6 +45,7 @@ module Pgbus
|
|
|
45
45
|
Instrumentation.instrument("pgbus.event_processed", instrument_payload) do
|
|
46
46
|
handle(event)
|
|
47
47
|
end
|
|
48
|
+
complete_claim!(event.event_id) if self.class.idempotent?
|
|
48
49
|
:handled
|
|
49
50
|
rescue StandardError => e
|
|
50
51
|
instrument(
|
|
@@ -100,13 +101,25 @@ module Pgbus
|
|
|
100
101
|
ActiveSupport::Notifications.instrument(event_name, payload)
|
|
101
102
|
end
|
|
102
103
|
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
104
|
+
# Two-phase idempotency claim (issue #385). Phase 1: atomically claim
|
|
105
|
+
# via INSERT ... ON CONFLICT DO NOTHING with completed_at NULL — a
|
|
106
|
+
# *pending* claim. Returns true when this delivery should run handle:
|
|
106
107
|
#
|
|
107
|
-
#
|
|
108
|
+
# - insert won → fresh claim
|
|
109
|
+
# - insert lost, completed_at NULL → a prior attempt claimed but was
|
|
110
|
+
# killed before finishing (SIGKILL mid-handler); re-run so the crash
|
|
111
|
+
# doesn't silently drop the execution. Safe: PGMQ's VT means the
|
|
112
|
+
# prior holder is dead or wedged past its timeout — the same
|
|
113
|
+
# at-least-once window every non-idempotent handler has.
|
|
114
|
+
#
|
|
115
|
+
# Returns false (skip) only for a *completed* execution. Phase 2 is
|
|
116
|
+
# complete_claim! after handle returns; only completed executions enter
|
|
117
|
+
# the in-memory dedup cache.
|
|
118
|
+
#
|
|
119
|
+
# Legacy fallback: without the completed_at column (upgraded gem,
|
|
120
|
+
# not-yet-migrated table) this degrades to the old single-phase claim.
|
|
108
121
|
def claim_idempotency?(event_id)
|
|
109
|
-
cache_key =
|
|
122
|
+
cache_key = dedup_key(event_id)
|
|
110
123
|
return false if self.class.dedup_cache.seen?(cache_key)
|
|
111
124
|
|
|
112
125
|
result = ProcessedEvent.insert(
|
|
@@ -114,9 +127,38 @@ module Pgbus
|
|
|
114
127
|
unique_by: %i[event_id handler_class]
|
|
115
128
|
)
|
|
116
129
|
|
|
117
|
-
|
|
130
|
+
unless ProcessedEvent.completion_column?
|
|
131
|
+
self.class.dedup_cache.mark!(cache_key)
|
|
132
|
+
return result.rows.any?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
return true if result.rows.any?
|
|
136
|
+
|
|
137
|
+
completed_at = ProcessedEvent
|
|
138
|
+
.where(event_id: event_id, handler_class: self.class.name)
|
|
139
|
+
.pick(:completed_at)
|
|
140
|
+
return true if completed_at.nil? # pending claim (or purged row) → re-run
|
|
141
|
+
|
|
118
142
|
self.class.dedup_cache.mark!(cache_key)
|
|
119
|
-
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Phase 2: stamp the claim completed and only then admit it to the
|
|
147
|
+
# dedup cache. Skipped on legacy schemas (single-phase claims are
|
|
148
|
+
# already cached at claim time). If this write fails, process!'s rescue
|
|
149
|
+
# re-raises, the consumer leaves the message for VT redelivery, and the
|
|
150
|
+
# still-pending claim re-runs — at-least-once, never a silent drop.
|
|
151
|
+
def complete_claim!(event_id)
|
|
152
|
+
return unless ProcessedEvent.completion_column?
|
|
153
|
+
|
|
154
|
+
ProcessedEvent
|
|
155
|
+
.where(event_id: event_id, handler_class: self.class.name)
|
|
156
|
+
.update_all(completed_at: Time.now.utc)
|
|
157
|
+
self.class.dedup_cache.mark!(dedup_key(event_id))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def dedup_key(event_id)
|
|
161
|
+
"#{event_id}:#{self.class.name}"
|
|
120
162
|
end
|
|
121
163
|
end
|
|
122
164
|
end
|
|
@@ -77,6 +77,7 @@ module Pgbus
|
|
|
77
77
|
add_outbox: "pgbus:add_outbox",
|
|
78
78
|
add_recurring: "pgbus:add_recurring",
|
|
79
79
|
add_failed_events_index: "pgbus:add_failed_events_index",
|
|
80
|
+
add_processed_event_completion: "pgbus:add_processed_event_completion",
|
|
80
81
|
tune_autovacuum: "pgbus:tune_autovacuum",
|
|
81
82
|
tune_fillfactor: "pgbus:tune_fillfactor"
|
|
82
83
|
}.freeze
|
|
@@ -95,6 +96,7 @@ module Pgbus
|
|
|
95
96
|
add_outbox: "outbox entries table (transactional outbox)",
|
|
96
97
|
add_recurring: "recurring tasks + executions tables",
|
|
97
98
|
add_failed_events_index: "unique index on pgbus_failed_events (queue_name, msg_id)",
|
|
99
|
+
add_processed_event_completion: "completed_at on pgbus_processed_events (two-phase idempotency claim)",
|
|
98
100
|
tune_autovacuum: "autovacuum tuning for PGMQ queue and archive tables",
|
|
99
101
|
tune_fillfactor: "fillfactor=70 on PGMQ queue tables (reduces page density during update churn)"
|
|
100
102
|
}.freeze
|
|
@@ -120,6 +122,7 @@ module Pgbus
|
|
|
120
122
|
*outbox_migrations,
|
|
121
123
|
*recurring_migrations,
|
|
122
124
|
*failed_events_index_migrations,
|
|
125
|
+
*processed_event_completion_migrations,
|
|
123
126
|
*autovacuum_migrations,
|
|
124
127
|
*fillfactor_migrations
|
|
125
128
|
]
|
|
@@ -210,6 +213,16 @@ module Pgbus
|
|
|
210
213
|
[:add_failed_events_index]
|
|
211
214
|
end
|
|
212
215
|
|
|
216
|
+
# completed_at backs the two-phase idempotency claim (issue #385).
|
|
217
|
+
# Without it, idempotent handlers fall back to single-phase claims and
|
|
218
|
+
# a crash mid-handler silently drops the execution on redelivery.
|
|
219
|
+
def processed_event_completion_migrations
|
|
220
|
+
return [] unless table_exists?("pgbus_processed_events")
|
|
221
|
+
return [] if column_names("pgbus_processed_events").include?("completed_at")
|
|
222
|
+
|
|
223
|
+
[:add_processed_event_completion]
|
|
224
|
+
end
|
|
225
|
+
|
|
213
226
|
# Autovacuum tuning: check if any PGMQ queue table already has
|
|
214
227
|
# custom autovacuum settings applied. If not, queue the migration.
|
|
215
228
|
def autovacuum_migrations
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
# Dependency-free readiness probe for container HEALTHCHECKs (issue #386).
|
|
7
|
+
#
|
|
8
|
+
# exe/pgbus-health loads this file via require_relative and nothing else:
|
|
9
|
+
# a docker HEALTHCHECK runs the probe every few seconds, so it must never
|
|
10
|
+
# drag in Bundler, Zeitwerk, Rails, or the rest of the gem. Only Ruby's
|
|
11
|
+
# bundled socket library is allowed here.
|
|
12
|
+
#
|
|
13
|
+
# healthcheck:
|
|
14
|
+
# cmd: bin/pgbus-health # port from PGBUS_HEALTH_PORT
|
|
15
|
+
# cmd: bin/pgbus-health --port 9394 --path /livez
|
|
16
|
+
#
|
|
17
|
+
# Exit codes: 0 healthy (HTTP 2xx), 1 unhealthy (non-2xx, refused, timeout),
|
|
18
|
+
# 2 usage error (no/invalid port).
|
|
19
|
+
class HealthProbe
|
|
20
|
+
EXIT_OK = 0
|
|
21
|
+
EXIT_UNHEALTHY = 1
|
|
22
|
+
EXIT_USAGE = 2
|
|
23
|
+
|
|
24
|
+
DEFAULT_PATH = "/readyz"
|
|
25
|
+
DEFAULT_TIMEOUT = 2.0
|
|
26
|
+
HOST = "127.0.0.1"
|
|
27
|
+
|
|
28
|
+
USAGE = "usage: pgbus-health [--port PORT] [--path PATH] [--timeout SECONDS]\n " \
|
|
29
|
+
"port falls back to the PGBUS_HEALTH_PORT environment variable\n"
|
|
30
|
+
|
|
31
|
+
def self.run(argv, env: ENV, out: $stdout, err: $stderr)
|
|
32
|
+
new(argv, env: env, out: out, err: err).run
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(argv, env: ENV, out: $stdout, err: $stderr)
|
|
36
|
+
@out = out
|
|
37
|
+
@err = err
|
|
38
|
+
@path = DEFAULT_PATH
|
|
39
|
+
@timeout = DEFAULT_TIMEOUT
|
|
40
|
+
@port = env["PGBUS_HEALTH_PORT"]
|
|
41
|
+
@usage_error = false
|
|
42
|
+
parse(argv)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run
|
|
46
|
+
return usage_failure if @usage_error
|
|
47
|
+
|
|
48
|
+
port = Integer(@port, exception: false)
|
|
49
|
+
# Out-of-range ports would reach Socket.tcp and raise SocketError — a
|
|
50
|
+
# backtrace where a HEALTHCHECK needs a deterministic exit code.
|
|
51
|
+
return usage_failure unless port&.between?(1, 65_535)
|
|
52
|
+
|
|
53
|
+
probe(port)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Hand-rolled flag parsing: three flags do not justify optparse in a
|
|
59
|
+
# script whose reason to exist is loading nothing.
|
|
60
|
+
def parse(argv)
|
|
61
|
+
args = argv.dup
|
|
62
|
+
until args.empty?
|
|
63
|
+
flag = args.shift
|
|
64
|
+
value = args.shift
|
|
65
|
+
return @usage_error = true if value.nil?
|
|
66
|
+
|
|
67
|
+
case flag
|
|
68
|
+
when "--port" then @port = value
|
|
69
|
+
when "--path" then @path = value
|
|
70
|
+
when "--timeout"
|
|
71
|
+
# A typo'd timeout must be a usage error, not `to_f`'s silent 0.0 —
|
|
72
|
+
# a zero deadline reports the container unhealthy on every probe.
|
|
73
|
+
timeout = Float(value, exception: false)
|
|
74
|
+
return @usage_error = true unless timeout&.positive?
|
|
75
|
+
|
|
76
|
+
@timeout = timeout
|
|
77
|
+
else
|
|
78
|
+
return @usage_error = true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def usage_failure
|
|
84
|
+
@err.write(USAGE)
|
|
85
|
+
EXIT_USAGE
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def probe(port)
|
|
89
|
+
status = http_status(port)
|
|
90
|
+
healthy = status&.between?(200, 299)
|
|
91
|
+
@out.write("pgbus-health: #{@path} -> #{status || "no response"}\n")
|
|
92
|
+
healthy ? EXIT_OK : EXIT_UNHEALTHY
|
|
93
|
+
rescue SystemCallError, IOError, SocketError => e
|
|
94
|
+
@err.write("pgbus-health: #{@path} -> #{e.class}: #{e.message}\n")
|
|
95
|
+
EXIT_UNHEALTHY
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Minimal HTTP/1.0 exchange: send the request, read just the status line.
|
|
99
|
+
# The deadline covers connect and read together.
|
|
100
|
+
def http_status(port)
|
|
101
|
+
deadline = monotonic_now + @timeout
|
|
102
|
+
Socket.tcp(HOST, port, connect_timeout: @timeout) do |sock|
|
|
103
|
+
sock.write("GET #{@path} HTTP/1.0\r\nHost: #{HOST}\r\nConnection: close\r\n\r\n")
|
|
104
|
+
line = read_status_line(sock, deadline)
|
|
105
|
+
code = line&.split(" ", 3)&.fetch(1, nil)
|
|
106
|
+
Integer(code, exception: false)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def read_status_line(sock, deadline)
|
|
111
|
+
buffer = +""
|
|
112
|
+
until buffer.include?("\n")
|
|
113
|
+
remaining = deadline - monotonic_now
|
|
114
|
+
return nil if remaining <= 0 || !sock.wait_readable(remaining)
|
|
115
|
+
|
|
116
|
+
chunk = sock.read_nonblock(1024, exception: false)
|
|
117
|
+
return nil if chunk.nil? # EOF before a full status line
|
|
118
|
+
next if chunk == :wait_readable # spurious wakeup — re-wait on the deadline
|
|
119
|
+
|
|
120
|
+
buffer << chunk
|
|
121
|
+
end
|
|
122
|
+
buffer[/\A[^\r\n]*/]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ::Process, not Process — inside the Pgbus namespace the bare constant
|
|
126
|
+
# resolves to Pgbus::Process (the process model), which is also why this
|
|
127
|
+
# file must never be renamed into that namespace.
|
|
128
|
+
def monotonic_now
|
|
129
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -85,10 +85,14 @@ module Pgbus
|
|
|
85
85
|
gauge "queue_visible_depth", q[:queue_visible_length], tags
|
|
86
86
|
gauge "queue_paused", q[:paused] ? 1 : 0, tags
|
|
87
87
|
age = q[:oldest_msg_age_sec]
|
|
88
|
-
if age
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
gauge "queue_oldest_message_age_seconds", age, tags if age
|
|
89
|
+
claimable_age = q[:oldest_claimable_age_sec]
|
|
90
|
+
gauge "queue_oldest_claimable_age_seconds", claimable_age, tags if claimable_age
|
|
91
|
+
# Latency = time the oldest *claimable* message has waited for
|
|
92
|
+
# pickup; a queue holding only vt-parked (scheduled/backoff)
|
|
93
|
+
# messages is healthy, so 0 — not the raw enqueued_at age, which
|
|
94
|
+
# grows at wall-clock rate on a parked message (issue #389).
|
|
95
|
+
gauge "queue_latency", (claimable_age || 0) * 1_000, tags
|
|
92
96
|
end
|
|
93
97
|
rescue StandardError => e
|
|
94
98
|
log_failure("queue metrics", e)
|
|
@@ -14,6 +14,12 @@ module Pgbus
|
|
|
14
14
|
(messages whose visibility timeout has expired and are ready to be
|
|
15
15
|
claimed), oldest/newest message age in seconds, lifetime total, and
|
|
16
16
|
paused state. Use this to answer "are any queues backed up?".
|
|
17
|
+
oldest_claimable_age_sec is the age of the oldest message actually
|
|
18
|
+
eligible for pickup — nil means no message is currently claimable:
|
|
19
|
+
every remaining message is scheduled, backoff-parked, or in flight
|
|
20
|
+
with a future visibility timeout. A queue whose oldest_msg_age_sec
|
|
21
|
+
keeps growing while oldest_claimable_age_sec stays nil has no
|
|
22
|
+
starving backlog — nothing is waiting for a worker.
|
|
17
23
|
DESC
|
|
18
24
|
|
|
19
25
|
input_schema(properties: {}, required: [])
|
|
@@ -480,7 +480,10 @@ module Pgbus
|
|
|
480
480
|
def shutdown
|
|
481
481
|
stop_wake_source
|
|
482
482
|
@pool.shutdown
|
|
483
|
-
|
|
483
|
+
# The consumer has no quiesce-gated drain loop like Worker's, so this
|
|
484
|
+
# wait IS its drain window — bound it by the same knob workers use
|
|
485
|
+
# instead of a hardcoded 30s (issue #386).
|
|
486
|
+
@pool.wait_for_termination(config.drain_timeout)
|
|
484
487
|
@stat_buffer&.stop
|
|
485
488
|
@heartbeat&.stop
|
|
486
489
|
restore_signals
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Process
|
|
5
|
+
# Immutable container-local readiness state, published by the supervisor
|
|
6
|
+
# (one atomic swap per monitor pass) and read by the standalone health
|
|
7
|
+
# server's accept thread — the immutability is what makes the cross-thread
|
|
8
|
+
# handoff safe without a lock (issue #386).
|
|
9
|
+
#
|
|
10
|
+
# `expected` is the child count forked by boot_processes; `live` is the
|
|
11
|
+
# current fork-table size. A child sitting in crash-restart backoff keeps
|
|
12
|
+
# `live < expected`, which is exactly the signal a rolling deploy's health
|
|
13
|
+
# gate needs to fail on: the replacement container never goes ready, and
|
|
14
|
+
# the orchestrator keeps the old container running.
|
|
15
|
+
ReadinessSnapshot = Data.define(:booted, :shutting_down, :expected, :live) do
|
|
16
|
+
def ready?
|
|
17
|
+
booted && !shutting_down && live >= expected
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# DRAINING wins over BOOTING: a supervisor told to stop mid-boot is
|
|
21
|
+
# leaving, not arriving, and must never look like it will become ready.
|
|
22
|
+
def status
|
|
23
|
+
return "DRAINING" if shutting_down
|
|
24
|
+
return "BOOTING" unless booted
|
|
25
|
+
|
|
26
|
+
ready? ? "OK" : "DEGRADED"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|