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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +78 -3
  4. data/app/models/pgbus/processed_event.rb +45 -0
  5. data/app/views/pgbus/dashboard/_queues_table.html.erb +5 -3
  6. data/app/views/pgbus/queues/_queues_list.html.erb +5 -3
  7. data/app/views/pgbus/queues/show.html.erb +3 -0
  8. data/config/locales/da.yml +7 -2
  9. data/config/locales/de.yml +7 -2
  10. data/config/locales/en.yml +7 -2
  11. data/config/locales/es.yml +7 -2
  12. data/config/locales/fi.yml +7 -2
  13. data/config/locales/fr.yml +7 -2
  14. data/config/locales/it.yml +7 -2
  15. data/config/locales/ja.yml +7 -2
  16. data/config/locales/nb.yml +7 -2
  17. data/config/locales/nl.yml +7 -2
  18. data/config/locales/pt.yml +7 -2
  19. data/config/locales/sv.yml +7 -2
  20. data/exe/pgbus-health +9 -0
  21. data/lib/generators/pgbus/add_processed_event_completion_generator.rb +45 -0
  22. data/lib/generators/pgbus/templates/add_processed_event_completion.rb.erb +19 -0
  23. data/lib/generators/pgbus/templates/migration.rb.erb +3 -0
  24. data/lib/pgbus/cli.rb +6 -4
  25. data/lib/pgbus/client.rb +44 -0
  26. data/lib/pgbus/configuration.rb +43 -0
  27. data/lib/pgbus/event_bus/handler.rb +49 -7
  28. data/lib/pgbus/generators/migration_detector.rb +13 -0
  29. data/lib/pgbus/health_probe.rb +132 -0
  30. data/lib/pgbus/integrations/appsignal/probe.rb +8 -4
  31. data/lib/pgbus/mcp/tools/queues_tool.rb +6 -0
  32. data/lib/pgbus/process/consumer.rb +4 -1
  33. data/lib/pgbus/process/readiness_snapshot.rb +30 -0
  34. data/lib/pgbus/process/supervisor.rb +64 -3
  35. data/lib/pgbus/process/worker.rb +14 -4
  36. data/lib/pgbus/version.rb +1 -1
  37. data/lib/pgbus/web/data_source.rb +8 -1
  38. data/lib/pgbus/web/health_app.rb +23 -1
  39. data/lib/pgbus/web/metrics_serializer.rb +9 -0
  40. metadata +7 -1
@@ -65,7 +65,8 @@ sv:
65
65
  empty: Inga köer hittades
66
66
  headers:
67
67
  depth: Djup
68
- oldest: Äldst (s)
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
- oldest: Äldsta (s)
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 "-" * 95
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.total_messages)
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
@@ -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
- # Atomically claim idempotency: INSERT ... ON CONFLICT DO NOTHING.
104
- # Returns true if this handler claimed the event (row was inserted),
105
- # false if another handler already processed it (conflict, no insert).
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
- # Uses an in-memory dedup cache to skip the DB for recently-seen events.
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 = "#{event_id}:#{self.class.name}"
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
- claimed = result.rows.any?
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
- claimed
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
- gauge "queue_oldest_message_age_seconds", age, tags
90
- gauge "queue_latency", age * 1_000, tags
91
- end
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
- @pool.wait_for_termination(30)
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