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
@@ -44,6 +44,16 @@ module Pgbus
44
44
  @pending_restarts = pending_restarts
45
45
  @crash_counts = Hash.new(0)
46
46
  @notify_hub = notify_hub
47
+ @intended_children = 0
48
+ @readiness = Concurrent::AtomicReference.new(
49
+ ReadinessSnapshot.new(booted: false, shutting_down: shutting_down, expected: 0, live: forks.size)
50
+ )
51
+ end
52
+
53
+ # The current container-local readiness state. Safe to call from any
54
+ # thread (the health server's accept thread reads it per probe).
55
+ def readiness_snapshot
56
+ @readiness.get
47
57
  end
48
58
 
49
59
  def shutting_down?
@@ -97,6 +107,7 @@ module Pgbus
97
107
  start_notify_hub
98
108
 
99
109
  boot_processes
110
+ mark_booted
100
111
  monitor_loop
101
112
  ensure
102
113
  shutdown
@@ -105,17 +116,51 @@ module Pgbus
105
116
  def graceful_shutdown
106
117
  Pgbus.logger.info { "[Pgbus] Supervisor: graceful shutdown requested" }
107
118
  @shutting_down = true
119
+ refresh_readiness
108
120
  signal_children("TERM")
109
121
  end
110
122
 
111
123
  def immediate_shutdown
112
124
  Pgbus.logger.warn { "[Pgbus] Supervisor: immediate shutdown requested" }
113
125
  @shutting_down = true
126
+ refresh_readiness
114
127
  signal_children("QUIT")
115
128
  end
116
129
 
117
130
  private
118
131
 
132
+ # Boot is complete: connection verified, queues bootstrapped, every
133
+ # configured child fork ATTEMPTED. The baseline is the larger of the
134
+ # intended-attempt count and the fork-table size: a boot-time fork
135
+ # failure (EAGAIN/ENOMEM, logged-and-swallowed in fork_*) leaves
136
+ # intended > live, so the readiness gate reports DEGRADED instead of
137
+ # blessing a container that is missing workers. Roles that legitimately
138
+ # declined to boot (scheduler with no recurring tasks) never reach a
139
+ # fork_* method and are counted by neither side.
140
+ def mark_booted
141
+ @booted = true
142
+ @expected_children = [@intended_children, @forks.size].max
143
+ refresh_readiness
144
+ end
145
+
146
+ # Count a child the configuration intends this boot to run. Called at
147
+ # the top of every fork_* method — before the fork can fail — and only
148
+ # pre-boot, so restart_child's re-forks never inflate the baseline.
149
+ def note_intended_child
150
+ @intended_children += 1 unless @booted
151
+ end
152
+
153
+ # Publish a fresh snapshot; the swapped-in Data is immutable, so the
154
+ # health server's accept thread always reads a consistent state.
155
+ def refresh_readiness
156
+ @readiness.set(
157
+ ReadinessSnapshot.new(
158
+ booted: !!@booted, shutting_down: @shutting_down,
159
+ expected: @expected_children || 0, live: @forks.size
160
+ )
161
+ )
162
+ end
163
+
119
164
  # Log a single boot diagnostics banner: the settings that actually
120
165
  # determine whether this deployment works. One consecutive block of
121
166
  # "[Pgbus] boot:"-prefixed info lines so it reads cleanly under both the
@@ -253,6 +298,7 @@ module Pgbus
253
298
  end
254
299
 
255
300
  def fork_worker(worker_config, slot: nil)
301
+ note_intended_child
256
302
  queues = worker_config[:queues] || [config.default_queue]
257
303
  threads = worker_config[:threads] || 5
258
304
  single_active = worker_config[:single_active_consumer] || false
@@ -337,6 +383,7 @@ module Pgbus
337
383
  end
338
384
 
339
385
  def fork_dispatcher
386
+ note_intended_child
340
387
  pid = fork do
341
388
  restore_signals
342
389
  setup_child_process
@@ -364,6 +411,7 @@ module Pgbus
364
411
  end
365
412
 
366
413
  def fork_scheduler
414
+ note_intended_child
367
415
  pid = fork do
368
416
  restore_signals
369
417
  setup_child_process
@@ -427,6 +475,7 @@ module Pgbus
427
475
  end
428
476
 
429
477
  def fork_consumer(consumer_config, slot: nil)
478
+ note_intended_child
430
479
  # Array() so a consumer entry without :topics can't NoMethodError the
431
480
  # supervisor on the topics.join log lines below.
432
481
  topics = Array(consumer_config[:topics])
@@ -504,6 +553,7 @@ module Pgbus
504
553
  end
505
554
 
506
555
  def fork_outbox_poller
556
+ note_intended_child
507
557
  pid = fork do
508
558
  restore_signals
509
559
  setup_child_process
@@ -537,6 +587,9 @@ module Pgbus
537
587
  # refresh, and fork status broadcast (issue #381).
538
588
  @notify_hub&.tick
539
589
  end
590
+ # After reap + restarts so a clean recycle (reaped and re-forked in
591
+ # the same pass) never dips the published live count (issue #386).
592
+ refresh_readiness
540
593
  interruptible_sleep(FORK_WAIT)
541
594
  end
542
595
  end
@@ -821,7 +874,12 @@ module Pgbus
821
874
  def start_health_server
822
875
  return unless config.health_port
823
876
 
824
- @health_server = Pgbus::Web::HealthServer.new(port: config.health_port, bind: config.health_bind)
877
+ # The standalone server answers /readyz from THIS supervisor's
878
+ # container-local snapshot — a rolling deploy's health gate must
879
+ # measure the new container, not the fleet-wide verdict a sibling
880
+ # container's workers can satisfy (issue #386).
881
+ app = Pgbus::Web::HealthApp.new(local_readiness: -> { readiness_snapshot })
882
+ @health_server = Pgbus::Web::HealthServer.new(port: config.health_port, bind: config.health_bind, app: app)
825
883
  @health_server.start
826
884
  end
827
885
 
@@ -876,8 +934,11 @@ module Pgbus
876
934
  end
877
935
 
878
936
  def shutdown
879
- # Wait for all children with timeout
880
- deadline = Time.now + 30
937
+ # Wait for children to drain and exit, bounded by config.shutdown_timeout
938
+ # (default drain_timeout + 5) so raising the drain window can never
939
+ # mean SIGKILLing workers mid-drain. An orchestrator's stop grace
940
+ # period should exceed this value (issue #386).
941
+ deadline = Time.now + config.shutdown_timeout
881
942
 
882
943
  until @forks.empty? || Time.now > deadline
883
944
  reap_children
@@ -155,6 +155,12 @@ module Pgbus
155
155
  NOTIFY_RETRY_BASE_SECONDS = 5
156
156
  NOTIFY_RETRY_MAX_SECONDS = 300
157
157
 
158
+ # Residual pool wait in #shutdown, AFTER the drain loop already spent up
159
+ # to config.drain_timeout on in-flight jobs. Short by design: a job still
160
+ # running has proven it won't finish, and this wait competes with the
161
+ # supervisor's shutdown_timeout deadline (issue #386).
162
+ POOL_TERMINATION_WAIT = 5
163
+
158
164
  def run
159
165
  setup_signals
160
166
  start_heartbeat
@@ -175,9 +181,9 @@ module Pgbus
175
181
 
176
182
  break if @lifecycle.stopped?
177
183
  # quiesced? (all slots free), not idle? (any slot free) — exiting
178
- # with work still in flight abandons those jobs to the 30s
179
- # wait_for_termination timeout in shutdown. Bounded by
180
- # config.drain_timeout so a stuck job can't wedge the loop forever.
184
+ # with work still in flight abandons those jobs to shutdown's short
185
+ # POOL_TERMINATION_WAIT residual. Bounded by config.drain_timeout so
186
+ # a stuck job can't wedge the loop forever.
181
187
  break if @lifecycle.draining? && (@pool.quiesced? || drain_deadline_exceeded?)
182
188
 
183
189
  claim_and_execute if @lifecycle.can_process?
@@ -792,7 +798,11 @@ module Pgbus
792
798
  Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
793
799
  stop_wake_source
794
800
  @pool.shutdown
795
- @pool.wait_for_termination(30)
801
+ # Residual wait only: the drain loop already waited up to
802
+ # config.drain_timeout for in-flight jobs. A job still running here has
803
+ # proven it won't finish; waiting another full window would push the
804
+ # worker past the supervisor's shutdown_timeout deadline (issue #386).
805
+ @pool.wait_for_termination(POOL_TERMINATION_WAIT)
796
806
  @stat_buffer&.stop
797
807
  @queue_lock&.unlock_all
798
808
  @heartbeat&.stop
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.13.1"
4
+ VERSION = "0.13.3"
5
5
  end
@@ -1177,7 +1177,8 @@ module Pgbus
1177
1177
  (SELECT EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int FROM pgmq.#{qtable}) AS oldest_msg_age_sec,
1178
1178
  (SELECT CASE WHEN is_called THEN last_value ELSE 0 END FROM pgmq.#{seq_name}) AS total_messages,
1179
1179
  (SELECT max(read_ct) FROM pgmq.#{qtable}) AS max_read_ct,
1180
- (SELECT count(*) FROM pgmq.#{qtable} WHERE vt <= NOW() AND read_ct = 0) AS visible_unread_length
1180
+ (SELECT count(*) FROM pgmq.#{qtable} WHERE vt <= NOW() AND read_ct = 0) AS visible_unread_length,
1181
+ (SELECT EXTRACT(epoch FROM (NOW() - min(vt)))::int FROM pgmq.#{qtable} WHERE vt <= NOW()) AS oldest_claimable_age_sec
1181
1182
  SQL
1182
1183
  rescue StandardError => e
1183
1184
  Pgbus.logger.debug { "[Pgbus::Web] Skipping queue metrics for #{name}: #{e.message}" }
@@ -1193,7 +1194,9 @@ module Pgbus
1193
1194
  name: row["queue_name"],
1194
1195
  queue_length: row["queue_length"].to_i,
1195
1196
  queue_visible_length: row["queue_visible_length"].to_i,
1197
+ parked_length: row["queue_length"].to_i - row["queue_visible_length"].to_i,
1196
1198
  oldest_msg_age_sec: row["oldest_msg_age_sec"]&.to_i,
1199
+ oldest_claimable_age_sec: row["oldest_claimable_age_sec"]&.to_i,
1197
1200
  newest_msg_age_sec: row["newest_msg_age_sec"]&.to_i,
1198
1201
  total_messages: row["total_messages"].to_i,
1199
1202
  max_read_ct: row["max_read_ct"]&.to_i,
@@ -1216,6 +1219,7 @@ module Pgbus
1216
1219
  count(CASE WHEN vt <= NOW() THEN 1 END) AS queue_visible_length,
1217
1220
  EXTRACT(epoch FROM (NOW() - max(enqueued_at)))::int AS newest_msg_age_sec,
1218
1221
  EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int AS oldest_msg_age_sec,
1222
+ EXTRACT(epoch FROM (NOW() - min(vt) FILTER (WHERE vt <= NOW())))::int AS oldest_claimable_age_sec,
1219
1223
  max(read_ct) AS max_read_ct,
1220
1224
  count(CASE WHEN vt <= NOW() AND read_ct = 0 THEN 1 END) AS visible_unread_length
1221
1225
  FROM pgmq.#{qtable}
@@ -1229,6 +1233,7 @@ module Pgbus
1229
1233
  q_summary.queue_visible_length,
1230
1234
  q_summary.newest_msg_age_sec,
1231
1235
  q_summary.oldest_msg_age_sec,
1236
+ q_summary.oldest_claimable_age_sec,
1232
1237
  q_summary.max_read_ct,
1233
1238
  q_summary.visible_unread_length,
1234
1239
  all_metrics.total_messages
@@ -1241,7 +1246,9 @@ module Pgbus
1241
1246
  name: queue_name,
1242
1247
  queue_length: row["queue_length"].to_i,
1243
1248
  queue_visible_length: row["queue_visible_length"].to_i,
1249
+ parked_length: row["queue_length"].to_i - row["queue_visible_length"].to_i,
1244
1250
  oldest_msg_age_sec: row["oldest_msg_age_sec"]&.to_i,
1251
+ oldest_claimable_age_sec: row["oldest_claimable_age_sec"]&.to_i,
1245
1252
  newest_msg_age_sec: row["newest_msg_age_sec"]&.to_i,
1246
1253
  total_messages: row["total_messages"].to_i,
1247
1254
  max_read_ct: row["max_read_ct"]&.to_i,
@@ -49,8 +49,15 @@ module Pgbus
49
49
  # @param data_source [Pgbus::Web::DataSource, nil] read layer for /readyz.
50
50
  # nil (the default) builds a fresh DataSource per readiness check, which
51
51
  # avoids serving stale metrics from a long-lived app's memoized instance.
52
- def initialize(data_source: nil)
52
+ # @param local_readiness [#call, nil] when set, /readyz answers from this
53
+ # callable's {Process::ReadinessSnapshot} instead of the cluster-wide
54
+ # analyzer — the supervisor's standalone HealthServer passes its own
55
+ # snapshot so a rolling deploy's health gate measures THIS container,
56
+ # not the fleet (issue #386). The Rails-mounted app leaves it nil and
57
+ # keeps the cluster verdict.
58
+ def initialize(data_source: nil, local_readiness: nil)
53
59
  @data_source = data_source
60
+ @local_readiness = local_readiness
54
61
  end
55
62
 
56
63
  def call(env)
@@ -69,6 +76,8 @@ module Pgbus
69
76
  end
70
77
 
71
78
  def readyz
79
+ return local_readyz if @local_readiness
80
+
72
81
  # HealthAnalyzer lives in the MCP namespace, which is excluded from
73
82
  # Zeitwerk (its *tools* subclass the optional `mcp` gem). The analyzer
74
83
  # itself has no gem dependency, so require just that one file — the
@@ -83,6 +92,19 @@ module Pgbus
83
92
  [503, JSON_HEADERS.dup, [{ status: "ERROR", error: e.message }.to_json]]
84
93
  end
85
94
 
95
+ # Container-local readiness: no database, no analyzer — just the
96
+ # supervisor's published snapshot. The error path mirrors the cluster
97
+ # readyz: 503 ERROR, logged, never swallowed.
98
+ def local_readyz
99
+ snapshot = @local_readiness.call
100
+ status = snapshot.ready? ? 200 : 503
101
+ body = { status: snapshot.status, expected: snapshot.expected, live: snapshot.live }
102
+ [status, JSON_HEADERS.dup, [body.to_json]]
103
+ rescue StandardError => e
104
+ Pgbus.logger.error { "[Pgbus::Web::HealthApp] local readiness check failed: #{e.class}: #{e.message}" }
105
+ [503, JSON_HEADERS.dup, [{ status: "ERROR", error: e.message }.to_json]]
106
+ end
107
+
86
108
  # Reuse an injected DataSource (tests, an app that wants one shared
87
109
  # instance); otherwise build a fresh one each check so per-instance
88
110
  # memoization can never serve stale queue/process metrics.
@@ -54,6 +54,15 @@ module Pgbus
54
54
  end
55
55
  end
56
56
 
57
+ gauge(lines, "pgbus_queue_oldest_claimable_age_seconds",
58
+ "Age of the oldest message eligible for pickup (visibility timeout elapsed)") do
59
+ queues.filter_map do |q|
60
+ next unless q[:oldest_claimable_age_sec]
61
+
62
+ [q[:oldest_claimable_age_sec], { queue: q[:name] }]
63
+ end
64
+ end
65
+
57
66
  gauge(lines, "pgbus_queue_paused", "Whether the queue is paused (1) or active (0)") do
58
67
  queues.map { |q| [q[:paused] ? 1 : 0, { queue: q[:name] }] }
59
68
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.13.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -114,6 +114,7 @@ email:
114
114
  - mikael@mhenrixon.com
115
115
  executables:
116
116
  - pgbus
117
+ - pgbus-health
117
118
  extensions: []
118
119
  extra_rdoc_files: []
119
120
  files:
@@ -207,6 +208,7 @@ files:
207
208
  - config/locales/sv.yml
208
209
  - config/routes.rb
209
210
  - exe/pgbus
211
+ - exe/pgbus-health
210
212
  - lib/active_job/queue_adapters/pgbus_adapter.rb
211
213
  - lib/generators/pgbus/add_failed_events_index_generator.rb
212
214
  - lib/generators/pgbus/add_job_stats_generator.rb
@@ -214,6 +216,7 @@ files:
214
216
  - lib/generators/pgbus/add_job_stats_queue_index_generator.rb
215
217
  - lib/generators/pgbus/add_outbox_generator.rb
216
218
  - lib/generators/pgbus/add_presence_generator.rb
219
+ - lib/generators/pgbus/add_processed_event_completion_generator.rb
217
220
  - lib/generators/pgbus/add_queue_states_generator.rb
218
221
  - lib/generators/pgbus/add_recurring_generator.rb
219
222
  - lib/generators/pgbus/add_stream_queues_generator.rb
@@ -228,6 +231,7 @@ files:
228
231
  - lib/generators/pgbus/templates/add_job_stats_queue_index.rb.erb
229
232
  - lib/generators/pgbus/templates/add_outbox.rb.erb
230
233
  - lib/generators/pgbus/templates/add_presence.rb.erb
234
+ - lib/generators/pgbus/templates/add_processed_event_completion.rb.erb
231
235
  - lib/generators/pgbus/templates/add_queue_states.rb.erb
232
236
  - lib/generators/pgbus/templates/add_recurring_tables.rb.erb
233
237
  - lib/generators/pgbus/templates/add_stream_queues.rb.erb
@@ -282,6 +286,7 @@ files:
282
286
  - lib/pgbus/failed_event_recorder.rb
283
287
  - lib/pgbus/generators/database_target_detector.rb
284
288
  - lib/pgbus/generators/migration_detector.rb
289
+ - lib/pgbus/health_probe.rb
285
290
  - lib/pgbus/instrumentation.rb
286
291
  - lib/pgbus/integrations/appsignal.rb
287
292
  - lib/pgbus/integrations/appsignal/dashboard.json
@@ -333,6 +338,7 @@ files:
333
338
  - lib/pgbus/process/notify_probe.rb
334
339
  - lib/pgbus/process/primary_validator.rb
335
340
  - lib/pgbus/process/queue_lock.rb
341
+ - lib/pgbus/process/readiness_snapshot.rb
336
342
  - lib/pgbus/process/signal_handler.rb
337
343
  - lib/pgbus/process/supervisor.rb
338
344
  - lib/pgbus/process/wake_pipe.rb