pgbus 0.9.10 → 0.10.0

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -7
  3. data/README.md +34 -7
  4. data/Rakefile +22 -1
  5. data/app/models/pgbus/stream_queue.rb +87 -0
  6. data/lib/generators/pgbus/add_stream_queues_generator.rb +50 -0
  7. data/lib/generators/pgbus/install_generator.rb +3 -3
  8. data/lib/generators/pgbus/templates/add_stream_queues.rb.erb +11 -0
  9. data/lib/generators/pgbus/templates/initializer.rb.erb +62 -0
  10. data/lib/generators/pgbus/templates/migration.rb.erb +14 -0
  11. data/lib/generators/pgbus/update_generator.rb +6 -66
  12. data/lib/pgbus/client/ensure_stream_queue.rb +15 -1
  13. data/lib/pgbus/client/read_after.rb +7 -6
  14. data/lib/pgbus/client/resizable_pool.rb +160 -0
  15. data/lib/pgbus/client.rb +261 -4
  16. data/lib/pgbus/configuration.rb +244 -11
  17. data/lib/pgbus/doctor.rb +73 -2
  18. data/lib/pgbus/engine.rb +30 -3
  19. data/lib/pgbus/generators/migration_detector.rb +7 -0
  20. data/lib/pgbus/process/dispatcher.rb +89 -19
  21. data/lib/pgbus/process/notify_listener.rb +18 -2
  22. data/lib/pgbus/process/worker.rb +19 -3
  23. data/lib/pgbus/streams/pool_autoscaler.rb +202 -0
  24. data/lib/pgbus/streams/pool_trigger.rb +124 -0
  25. data/lib/pgbus/streams/turbo_broadcastable.rb +23 -0
  26. data/lib/pgbus/streams.rb +2 -2
  27. data/lib/pgbus/version.rb +1 -1
  28. data/lib/pgbus/web/streamer/connection.rb +22 -6
  29. data/lib/pgbus/web/streamer/falcon_connection.rb +17 -5
  30. data/lib/pgbus/web/streamer/instance.rb +75 -5
  31. data/lib/pgbus/web/streamer/io_writer.rb +11 -5
  32. data/lib/pgbus/web/streamer/listener.rb +61 -1
  33. data/lib/pgbus/web/streamer/outbound_pump.rb +260 -0
  34. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +89 -7
  35. metadata +9 -4
  36. data/lib/generators/pgbus/templates/pgbus.yml.erb +0 -76
  37. data/lib/pgbus/config_loader.rb +0 -73
  38. data/lib/pgbus/generators/config_converter.rb +0 -345
@@ -37,6 +37,16 @@ module Pgbus
37
37
  MAINTENANCE_BACKOFF_BASE = 30 # seconds; first backoff window
38
38
  MAINTENANCE_BACKOFF_MAX = 600 # seconds; window ceiling (10 minutes)
39
39
 
40
+ # A pooled backend the server terminated while the dispatcher sat idle
41
+ # between cycles surfaces with this exact PostgreSQL text on the next
42
+ # query. It is expected and self-healing — release_maintenance_connections
43
+ # returns the dead socket to the pool and the next cycle reconnects — so a
44
+ # task that hits it logs a calm INFO rather than an alarming WARN. Matched
45
+ # case-insensitively as a substring of the exception message. Common in
46
+ # local development (Postgres restart, `rails db:migrate` terminating idle
47
+ # backends, foreman/overmind restarting the DB), rare in production.
48
+ TERMINATED_CONNECTION_SIGNAL = "terminating connection due to administrator command"
49
+
40
50
  # The per-task monotonic timestamps run_maintenance consults via run_if_due.
41
51
  # Enumerated so set_maintenance_timestamp can validate its argument.
42
52
  MAINTENANCE_TIMESTAMPS = %i[
@@ -166,6 +176,51 @@ module Pgbus
166
176
 
167
177
  results = run_maintenance_tasks(now)
168
178
  update_maintenance_backoff(now, results)
179
+ ensure
180
+ release_maintenance_connections
181
+ end
182
+
183
+ # Return every AR connection this cycle leased back to the pool. The
184
+ # dispatcher is a long-lived loop that queries the pool on coarse
185
+ # intervals (concurrency cleanup every 300s, most tasks hourly). If it
186
+ # kept its pooled connection leased across the idle sleep, a backend the
187
+ # server terminates while it sits idle — routine in local development
188
+ # (a Postgres restart, `rails db:migrate` calling pg_terminate_backend,
189
+ # foreman/overmind restarting the DB) — would be reused dead on the next
190
+ # cycle, and every task would raise PG::ConnectionBad
191
+ # ("terminating connection due to administrator command"). Releasing here
192
+ # means the next cycle checks out afresh, and AR's verify!-on-checkout
193
+ # transparently discards a dead socket and reconnects. Best-effort: a
194
+ # failure to release must never crash the loop (mirrors the Streamer's
195
+ # release_ar_connections).
196
+ def release_maintenance_connections
197
+ return unless defined?(::ActiveRecord::Base)
198
+
199
+ Pgbus::BusRecord.connection_handler.clear_active_connections!
200
+ rescue StandardError => e
201
+ Pgbus.logger.debug { "[Pgbus] Maintenance connection release failed: #{e.class}: #{e.message}" }
202
+ end
203
+
204
+ # True when the error is a pooled backend the server terminated while the
205
+ # dispatcher was idle between cycles. Self-healing (see
206
+ # TERMINATED_CONNECTION_SIGNAL / release_maintenance_connections), so
207
+ # callers downgrade the log from WARN to INFO.
208
+ def terminated_connection_error?(error)
209
+ error.message.to_s.downcase.include?(TERMINATED_CONNECTION_SIGNAL)
210
+ end
211
+
212
+ # Log a maintenance task failure at the right volume: a self-healing
213
+ # terminated-idle-connection is a calm INFO ("will reconnect next cycle"),
214
+ # anything else stays a WARN operators should look at.
215
+ def log_maintenance_failure(label, error)
216
+ if terminated_connection_error?(error)
217
+ Pgbus.logger.info do
218
+ "[Pgbus] #{label} skipped: database connection was terminated while idle; " \
219
+ "reconnecting on the next cycle (#{error.message.lines.first&.strip})"
220
+ end
221
+ else
222
+ Pgbus.logger.warn { "[Pgbus] #{label} failed: #{error.message}" }
223
+ end
169
224
  end
170
225
 
171
226
  # Runs every due maintenance task and returns their results. Each entry
@@ -284,7 +339,7 @@ module Pgbus
284
339
  deleted = ProcessedEvent.expired(Time.current - ttl).delete_all
285
340
  Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} expired processed events" } if deleted.positive?
286
341
  rescue StandardError => e
287
- Pgbus.logger.warn { "[Pgbus] Idempotency cleanup failed: #{e.message}" }
342
+ log_maintenance_failure("Idempotency cleanup", e)
288
343
  e
289
344
  end
290
345
 
@@ -293,7 +348,7 @@ module Pgbus
293
348
  deleted = ProcessEntry.stale(Time.current - threshold).delete_all
294
349
  Pgbus.logger.info { "[Pgbus] Reaped #{deleted} stale processes" } if deleted.positive?
295
350
  rescue StandardError => e
296
- Pgbus.logger.warn { "[Pgbus] Stale process reaping failed: #{e.message}" }
351
+ log_maintenance_failure("Stale process reaping", e)
297
352
  e
298
353
  end
299
354
 
@@ -306,7 +361,7 @@ module Pgbus
306
361
  orphaned = Concurrency::BlockedExecution.expire_stale
307
362
  Pgbus.logger.debug { "[Pgbus] Expired #{orphaned} orphaned blocked executions" } if orphaned.positive?
308
363
  rescue StandardError => e
309
- Pgbus.logger.warn { "[Pgbus] Concurrency cleanup failed: #{e.message}" }
364
+ log_maintenance_failure("Concurrency cleanup", e)
310
365
  e
311
366
  end
312
367
 
@@ -314,14 +369,14 @@ module Pgbus
314
369
  promoted = Concurrency::BlockedExecution.promote_next(key, client: Pgbus.client)
315
370
  Pgbus.logger.debug { "[Pgbus] Released blocked execution for key: #{key}" } if promoted
316
371
  rescue StandardError => e
317
- Pgbus.logger.warn { "[Pgbus] Failed to release blocked execution for #{key}: #{e.message}" }
372
+ log_maintenance_failure("Releasing blocked execution for #{key}", e)
318
373
  end
319
374
 
320
375
  def cleanup_batches
321
376
  deleted = Batch.cleanup(older_than: Time.current - (7 * 24 * 3600)) # 7 days
322
377
  Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} finished batches" } if deleted.positive?
323
378
  rescue StandardError => e
324
- Pgbus.logger.warn { "[Pgbus] Batch cleanup failed: #{e.message}" }
379
+ log_maintenance_failure("Batch cleanup", e)
325
380
  e
326
381
  end
327
382
 
@@ -353,7 +408,7 @@ module Pgbus
353
408
  )
354
409
  Pgbus.logger.info { "[Pgbus] Table maintenance completed: #{maintained} table(s) vacuumed" } if maintained.positive?
355
410
  rescue StandardError => e
356
- Pgbus.logger.warn { "[Pgbus] Table maintenance failed: #{e.message}" }
411
+ log_maintenance_failure("Table maintenance", e)
357
412
  e
358
413
  end
359
414
 
@@ -378,7 +433,7 @@ module Pgbus
378
433
  reaped = reap_orphaned_uniqueness_keys
379
434
  Pgbus.logger.info { "[Pgbus] Reaped #{reaped} orphaned uniqueness keys" } if reaped.positive?
380
435
  rescue StandardError => e
381
- Pgbus.logger.warn { "[Pgbus] Job lock cleanup failed: #{e.message}" }
436
+ log_maintenance_failure("Job lock cleanup", e)
382
437
  e
383
438
  end
384
439
 
@@ -434,7 +489,7 @@ module Pgbus
434
489
  deleted = OutboxEntry.published_before(Time.current - retention).delete_all
435
490
  Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} published outbox entries" } if deleted.positive?
436
491
  rescue StandardError => e
437
- Pgbus.logger.warn { "[Pgbus] Outbox cleanup failed: #{e.message}" }
492
+ log_maintenance_failure("Outbox cleanup", e)
438
493
  e
439
494
  end
440
495
 
@@ -445,6 +500,10 @@ module Pgbus
445
500
  cutoff = Time.current - retention
446
501
  batch_size = ARCHIVE_COMPACTION_BATCH_SIZE
447
502
  prefix = config.queue_prefix
503
+ # Stream queues share the job prefix but are pruned separately by
504
+ # prune_stream_archives (per-stream retention). Skip them here so a
505
+ # stream archive is compacted by exactly one path, not both.
506
+ stream_names = current_stream_queue_names
448
507
 
449
508
  conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
450
509
  queue_names = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
@@ -453,6 +512,7 @@ module Pgbus
453
512
  queue_names.each_with_index do |full_name, index|
454
513
  break if interrupted?("Archive compaction", index, queue_names.size)
455
514
  next unless full_name.start_with?("#{prefix}_")
515
+ next if stream_names.include?(full_name)
456
516
 
457
517
  stripped = full_name.delete_prefix("#{prefix}_")
458
518
  deleted = Pgbus.client.purge_archive(stripped, older_than: cutoff, batch_size: batch_size)
@@ -464,7 +524,7 @@ module Pgbus
464
524
  end
465
525
  outcome.result
466
526
  rescue StandardError => e
467
- Pgbus.logger.warn { "[Pgbus] Archive compaction failed: #{e.message}" }
527
+ log_maintenance_failure("Archive compaction", e)
468
528
  e
469
529
  end
470
530
 
@@ -475,8 +535,8 @@ module Pgbus
475
535
  # Lookup order for a given queue: exact string match, then regex
476
536
  # match, then streams_default_retention (5 minutes by default).
477
537
  def prune_stream_archives
478
- prefix = config.streams_queue_prefix
479
- return if prefix.nil? || prefix.empty?
538
+ stream_names = current_stream_queue_names
539
+ return if stream_names.empty?
480
540
 
481
541
  batch_size = ARCHIVE_COMPACTION_BATCH_SIZE
482
542
  conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
@@ -485,7 +545,7 @@ module Pgbus
485
545
  outcome = LoopOutcome.new
486
546
  queue_names.each_with_index do |full_name, index|
487
547
  break if interrupted?("Stream archive compaction", index, queue_names.size)
488
- next unless full_name.start_with?("#{prefix}_")
548
+ next unless stream_names.include?(full_name)
489
549
 
490
550
  retention = retention_for_stream_queue(full_name)
491
551
  next unless retention.positive?
@@ -505,7 +565,7 @@ module Pgbus
505
565
  end
506
566
  outcome.result
507
567
  rescue StandardError => e
508
- Pgbus.logger.warn { "[Pgbus] Stream archive compaction failed: #{e.message}" }
568
+ log_maintenance_failure("Stream archive compaction", e)
509
569
  e
510
570
  end
511
571
 
@@ -524,12 +584,12 @@ module Pgbus
524
584
  end
525
585
 
526
586
  def sweep_orphan_streams
527
- prefix = config.streams_queue_prefix
528
- return if prefix.nil? || prefix.empty?
529
-
530
587
  threshold = config.streams_orphan_threshold
531
588
  return unless threshold
532
589
 
590
+ stream_names = current_stream_queue_names
591
+ return if stream_names.empty?
592
+
533
593
  conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
534
594
  queue_names = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
535
595
 
@@ -537,7 +597,7 @@ module Pgbus
537
597
  outcome = LoopOutcome.new
538
598
  queue_names.each_with_index do |full_name, index|
539
599
  break if interrupted?("Orphan stream sweep", index, queue_names.size)
540
- next unless full_name.start_with?("#{prefix}_")
600
+ next unless stream_names.include?(full_name)
541
601
 
542
602
  safe_name = QueueNameValidator.sanitize!(full_name)
543
603
  row = conn.select_one(<<~SQL, "Pgbus Orphan Check")
@@ -571,10 +631,20 @@ module Pgbus
571
631
  Pgbus.logger.debug { "[Pgbus] Orphan stream sweep complete: dropped #{dropped} queue(s)" } if dropped.positive?
572
632
  outcome.result
573
633
  rescue StandardError => e
574
- Pgbus.logger.warn { "[Pgbus] Orphan stream sweep failed: #{e.message}" }
634
+ log_maintenance_failure("Orphan stream sweep", e)
575
635
  e
576
636
  end
577
637
 
638
+ # Fresh set of physical stream-queue names for this maintenance pass.
639
+ # Reset first so a stream created since the last hourly pass is picked
640
+ # up without a process restart. Empty on unmigrated installs (registry
641
+ # table absent) — callers then treat every queue as a job queue, which
642
+ # is the pre-registry behavior.
643
+ def current_stream_queue_names
644
+ Pgbus::StreamQueue.reset_cache!
645
+ Pgbus::StreamQueue.all_names
646
+ end
647
+
578
648
  def cleanup_recurring_executions
579
649
  retention = config.recurring_execution_retention
580
650
  return unless retention&.positive?
@@ -582,7 +652,7 @@ module Pgbus
582
652
  deleted = RecurringExecution.older_than(Time.current - retention).delete_all
583
653
  Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} old recurring executions" } if deleted.positive?
584
654
  rescue StandardError => e
585
- Pgbus.logger.warn { "[Pgbus] Recurring execution cleanup failed: #{e.message}" }
655
+ log_maintenance_failure("Recurring execution cleanup", e)
586
656
  e
587
657
  end
588
658
 
@@ -49,12 +49,25 @@ module Pgbus
49
49
  @running = false
50
50
  @thread = nil
51
51
  @conn = nil
52
+ # Optimistic until the start-time self-probe runs: assume NOTIFY delivery
53
+ # works so a not-yet-probed listener isn't mistaken for a pooler-deaf one.
54
+ @delivering = true
52
55
  end
53
56
 
54
57
  def listening_to
55
58
  @state_mutex.synchronize { @listening_to.dup }
56
59
  end
57
60
 
61
+ # Whether the start-time self-probe confirmed this connection can actually
62
+ # receive a NOTIFY. False when a transaction-mode pooler or replica
63
+ # silently drops LISTEN: the thread is still alive (running? == true) but
64
+ # will never wake the loop. The Worker/Consumer consult this so a
65
+ # live-but-deaf listener is treated as absent for wake-timeout purposes —
66
+ # fast polling, not the 15s NOTIFY ceiling (issue #332).
67
+ def delivering?
68
+ @state_mutex.synchronize { @delivering }
69
+ end
70
+
58
71
  def start
59
72
  @state_mutex.synchronize do
60
73
  return self if @running
@@ -116,8 +129,11 @@ module Pgbus
116
129
  # One-shot delivery self-probe on the initial connection only. A pooler
117
130
  # or replica that silently breaks LISTEN/NOTIFY is surfaced here with an
118
131
  # 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)
132
+ # Reconnects skip the probe to stay cheap. Record the result so the
133
+ # owning worker can drop back to fast polling instead of the 15s NOTIFY
134
+ # ceiling when this connection can't actually deliver (issue #332).
135
+ delivering = NotifyProbe.probe_notify_delivery!(conn, logger: @logger)
136
+ @state_mutex.synchronize { @delivering = delivering }
121
137
  @state_mutex.synchronize { @conn = conn }
122
138
  drain_commands
123
139
 
@@ -407,10 +407,19 @@ module Pgbus
407
407
  dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
408
408
  prefix = "#{config.queue_prefix}_"
409
409
 
410
+ # Stream queues share the job namespace (pgbus_<name>) but must never
411
+ # be adopted by a wildcard worker: a worker would claim durable
412
+ # broadcasts, fail to deserialize them, and DLQ-move them out of the
413
+ # stream's replay history. The registry is what tells them apart.
414
+ # Reset first so a stream created since the last resolve is excluded.
415
+ Pgbus::StreamQueue.reset_cache!
416
+ stream_names = Pgbus::StreamQueue.all_names
417
+
410
418
  conn = Pgbus.configuration.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
411
419
  all_queues = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
412
420
  resolved = all_queues
413
421
  .reject { |q| q.end_with?(dlq_suffix) }
422
+ .reject { |q| stream_names.include?(q) }
414
423
  .map { |q| q.delete_prefix(prefix) }
415
424
 
416
425
  if resolved.empty?
@@ -596,13 +605,20 @@ module Pgbus
596
605
  def wake_timeout
597
606
  # A dead listener (running? false) will never wake the loop, so treat
598
607
  # it as absent and keep polling at the short interval until
599
- # ensure_notify_listener restarts it. Only a live listener earns the
600
- # long NOTIFY-mode ceiling.
601
- return effective_polling_interval unless notify_wakeup? && @notify_listener&.running?
608
+ # ensure_notify_listener restarts it. A live-but-deaf listener
609
+ # (delivering? false — a transaction-mode pooler or replica that drops
610
+ # LISTEN) is the same story: it will never wake us, so don't raise the
611
+ # poll floor to the 15s ceiling behind it (issue #332). Only a live,
612
+ # delivering listener earns the long NOTIFY-mode ceiling.
613
+ return effective_polling_interval unless notify_wakeup? && listener_delivering?
602
614
 
603
615
  [effective_polling_interval, config.polling_interval, NOTIFY_FALLBACK_POLL_SECONDS].max
604
616
  end
605
617
 
618
+ def listener_delivering?
619
+ @notify_listener&.running? && @notify_listener.delivering?
620
+ end
621
+
606
622
  def effective_polling_interval
607
623
  return config.polling_interval if @consumer_priority.zero?
608
624
 
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Streams
5
+ # Self-tuning decision logic for the dedicated streams DB pool (issue #323).
6
+ #
7
+ # A PURE DECISION OBJECT — it owns no thread and no connection. Two callers
8
+ # drive it, both feeding it a live-headroom reading:
9
+ # * the streamer's Listener, as a periodic maintenance check on its idle
10
+ # LISTEN connection (see Maintenance below); and
11
+ # * a publisher (Client#send_stream_message), throttled, on the job pool.
12
+ # #evaluate reads the streams pool's busy ratio, decides, and resizes via the
13
+ # #325 hot-swap primitive.
14
+ #
15
+ # Cadence is slow (streams_pool_autoscale_interval, default 5 min), so the
16
+ # interval itself is the debounce: it acts on each check with no multi-sample
17
+ # hysteresis and no cooldown. A sustained burst converges over a few checks
18
+ # (one grow step each); a swap's transient (a freshly-swapped connection_pool
19
+ # is lazy → reads busy ≈ 0) simply means "no shrink this check" and is gone by
20
+ # the next check minutes later.
21
+ #
22
+ # Decision priority per check:
23
+ # 1. EMERGENCY SHRINK — DB free connections critically low → resize to
24
+ # baseline now (protect the DB; overrides the busy signal).
25
+ # 2. GROW — pool saturated AND a fair share of real headroom exists.
26
+ # 3. SHRINK — pool idle → step toward baseline.
27
+ # 4. HOLD.
28
+ #
29
+ # SAFETY (proven in the #323 design; independent of cadence):
30
+ # * No multi-process exhaustion: four stacked grow guards (GROW_RESERVE
31
+ # gate, SAFETY peer inflation, STEP_MAX, per-process floor(free/2)).
32
+ # * No grow↔emergency limit cycle: GROW_RESERVE (0.20·maxc) ≥ 4×
33
+ # EMERGENCY_MARGIN (0.05·maxc) → a ~15% dead-zone.
34
+ class PoolAutoscaler
35
+ GROW_THRESHOLD = 0.85 # busy_ratio to grow
36
+ SHRINK_THRESHOLD = 0.30 # busy_ratio to shrink
37
+ FAIR_FRACTION = 0.25 # claim only ¼ of the computed fair share per grow
38
+ SAFETY = 1.5 # inflate peer count → deflate fair share (undercount guard)
39
+ STEP_MAX = 4 # hard cap on connections added per single grow
40
+
41
+ # SQL a caller runs on a connection to gather headroom. Public so the
42
+ # Listener/publisher can issue it; $1 is the application_name LIKE pattern.
43
+ HEADROOM_SQL = <<~SQL
44
+ SELECT current_setting('max_connections')::int AS maxc,
45
+ count(*) AS used,
46
+ count(DISTINCT application_name)
47
+ FILTER (WHERE application_name LIKE $1) AS peers
48
+ FROM pg_stat_activity
49
+ SQL
50
+
51
+ def initialize(client:, config:, logger: Pgbus.logger)
52
+ @client = client
53
+ @config = config
54
+ @logger = logger
55
+ @baseline = config.streams_pool_size
56
+ end
57
+
58
+ # One maintenance decision. `headroom` is {maxc:, used:, peers:} from a
59
+ # caller's query, or nil if it failed (→ HOLD). Returns the action taken
60
+ # (:emergency_shrink / :grow / :shrink / :hold) — handy for tests + logging.
61
+ #
62
+ # `allow_shrink:` gates the NORMAL idle shrink (priority 3). The streamer
63
+ # passes true (it sees the sustained idle picture across replay reads). A
64
+ # publisher passes false: it only ever reacts to its OWN publish pressure
65
+ # (grow), and leaves idle-shrink to the streamer/consumer. Emergency shrink
66
+ # is NEVER gated — a DB running out of connections must always be relieved,
67
+ # whoever notices first.
68
+ def evaluate(headroom, allow_shrink: true)
69
+ return :hold if headroom.nil?
70
+
71
+ free = headroom[:maxc] - headroom[:used]
72
+
73
+ # PRIORITY 1 — EMERGENCY SHRINK (keys off DB `free`, an external fact).
74
+ return emergency_shrink(free, headroom[:maxc]) if free < emergency_margin(headroom[:maxc])
75
+
76
+ size, busy_ratio = pool_busy
77
+ return :hold if size.nil?
78
+
79
+ if busy_ratio >= GROW_THRESHOLD
80
+ maybe_grow(size, free, headroom)
81
+ elsif allow_shrink && busy_ratio < SHRINK_THRESHOLD
82
+ maybe_shrink(size)
83
+ else
84
+ :hold
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ # PRIORITY 2 — GROW into a bounded fair share of live headroom.
91
+ def maybe_grow(size, free, headroom)
92
+ return :hold if free <= grow_reserve(headroom[:maxc]) # not enough headroom
93
+ return :hold if size <= 1 # a telemetry checkout would starve publishing
94
+
95
+ delta = grow_delta(free, headroom[:peers])
96
+ return :hold if delta < 1
97
+
98
+ target = size + delta
99
+ cap = @config.streams_pool_max
100
+ target = [target, cap].min if cap
101
+ return :hold if target <= size
102
+
103
+ act(size, target, :grow)
104
+ end
105
+
106
+ # PRIORITY 3 — SHRINK one step toward the baseline.
107
+ def maybe_shrink(size)
108
+ return :hold if size <= @baseline
109
+
110
+ act(size, [@baseline, size - STEP_MAX].max, :shrink)
111
+ end
112
+
113
+ def emergency_shrink(free, maxc)
114
+ return :hold if pool_current_size <= @baseline
115
+
116
+ if swapped?(@client.resize_streams_pool(@baseline))
117
+ @logger.warn do
118
+ "[Pgbus::Streams::PoolAutoscaler] EMERGENCY streams-pool shrink to " \
119
+ "#{@baseline} (free=#{free}/#{maxc})"
120
+ end
121
+ :emergency_shrink
122
+ else
123
+ # The DB is critically low on connections but the shrink was a no-op
124
+ # (unchanged / shared-AR). This is the one case where a failed resize
125
+ # matters most — leave a trace instead of silently reporting success.
126
+ @logger.warn do
127
+ "[Pgbus::Streams::PoolAutoscaler] EMERGENCY streams-pool shrink to " \
128
+ "#{@baseline} did NOT apply (free=#{free}/#{maxc}) — resize was a no-op"
129
+ end
130
+ :hold
131
+ end
132
+ end
133
+
134
+ # fair_share = free / (peers · SAFETY); claim FAIR_FRACTION, never more than
135
+ # STEP_MAX or half the remaining headroom (leave ≥half for unknown peers →
136
+ # geometric contraction → N processes can't collectively exhaust the DB).
137
+ def grow_delta(free, peers)
138
+ peer_count = [peers.to_i, 1].max
139
+ fair_share = free.to_f / (peer_count * SAFETY)
140
+ [(FAIR_FRACTION * fair_share).floor, STEP_MAX, (free / 2)].min
141
+ end
142
+
143
+ def act(from_size, target_size, kind)
144
+ if swapped?(@client.resize_streams_pool(target_size))
145
+ @logger.info { "[Pgbus::Streams::PoolAutoscaler] streams pool #{from_size}->#{target_size} (#{kind})" }
146
+ kind
147
+ else
148
+ :hold # unchanged / shared-AR no-op
149
+ end
150
+ end
151
+
152
+ # Margins self-derive from live max_connections; the 4× gap forbids a
153
+ # grow↔emergency limit cycle.
154
+ def emergency_margin(maxc) = [5, (0.05 * maxc).ceil].max
155
+ def grow_reserve(maxc) = [20, (0.20 * maxc).ceil].max
156
+
157
+ def pool_busy
158
+ stats = @client.streams_pool_stats
159
+ return [nil, nil] if stats.nil? || stats.empty?
160
+
161
+ size = stats[:size]
162
+ available = stats[:available]
163
+ return [nil, nil] if size.nil? || available.nil? || size <= 0
164
+
165
+ [size, (size - available).to_f / size]
166
+ end
167
+
168
+ def pool_current_size
169
+ stats = @client.streams_pool_stats
170
+ stats.is_a?(Hash) ? stats[:size].to_i : 0
171
+ end
172
+
173
+ def swapped?(result) = result.is_a?(Pgbus::Client::ResizablePool::SwapStats)
174
+
175
+ # Wraps an autoscaler as a Listener maintenance task: on each throttled idle
176
+ # window the Listener calls #run(conn); this queries live headroom on THAT
177
+ # connection (the streamer's existing LISTEN connection — no extra
178
+ # connection) and hands it to the autoscaler. `interval` is the throttle
179
+ # (seconds) the Listener honors between runs.
180
+ class Maintenance
181
+ attr_reader :interval
182
+
183
+ def initialize(autoscaler:, interval:, application_name_prefix:)
184
+ @autoscaler = autoscaler
185
+ @interval = interval
186
+ @like = "#{application_name_prefix}_%"
187
+ end
188
+
189
+ def run(conn)
190
+ row = conn.exec_params(HEADROOM_SQL, [@like]).first
191
+ # Explicit positional hash (braces) — evaluate now takes an optional
192
+ # allow_shrink: keyword, so a bare hash would be parsed as keywords.
193
+ # The streamer allows shrink (it sees the sustained idle picture).
194
+ @autoscaler.evaluate(
195
+ { maxc: row["maxc"].to_i, used: row["used"].to_i, peers: row["peers"].to_i },
196
+ allow_shrink: true
197
+ )
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Pgbus
6
+ module Streams
7
+ # Publisher-side throttled autoscale trigger (issue #323 follow-up).
8
+ #
9
+ # The streamer autoscales via a periodic check on its idle LISTEN connection,
10
+ # but a pure-*publisher* process (a worker that fans out broadcasts via
11
+ # Client#send_stream_message but serves no SSE) has no streamer — so its
12
+ # streams pool would never autoscale. This closes that gap: each publish
13
+ # opportunistically triggers a headroom check, throttled to at most once per
14
+ # `interval` seconds across all publisher threads.
15
+ #
16
+ # OFF THE HOT PATH: the publishing thread only does a lock-free
17
+ # compare-and-set to claim the throttle window and, if it wins, posts the
18
+ # actual work to a background single-thread executor and returns immediately.
19
+ # It never runs the query or the resize inline. A quiet (non-publishing)
20
+ # process does zero work — this object is only ever built by the first publish
21
+ # (Client#streams_pool_trigger), and only when autoscale is on and the pool is
22
+ # dedicated, so a process that never publishes a stream never spawns the
23
+ # executor thread. There is no always-on timer.
24
+ #
25
+ # AT MOST ONE CHECK AT A TIME is enforced primarily by the single-thread
26
+ # executor (it serializes run_check calls end-to-end) plus the throttle CAS
27
+ # (one post per interval). The @running flag is a belt-and-suspenders
28
+ # invariant that becomes load-bearing only if a MULTI-threaded executor is
29
+ # injected (the executor: seam) — then it is the sole guard against two
30
+ # overlapping check bodies. It is cheap (one AtomicBoolean per check), so it
31
+ # stays.
32
+ #
33
+ # The query runs through the JOB pool, NOT the streams pool: pg_stat_activity
34
+ # is global, so any connection to the same database reads the same headroom,
35
+ # and using the job pool means the check can never starve on a saturated
36
+ # streams pool (the very pool it's trying to grow). The decision reuses
37
+ # PoolAutoscaler#evaluate with allow_shrink: false — a publisher only ever
38
+ # reacts to its own publish pressure (grow) and to DB exhaustion (emergency
39
+ # shrink); the streamer/consumer handles normal idle shrink.
40
+ class PoolTrigger
41
+ def initialize(autoscaler:, job_pool:, interval:, application_name_prefix:, clock: nil,
42
+ executor: nil, logger: Pgbus.logger)
43
+ @autoscaler = autoscaler
44
+ @job_pool = job_pool
45
+ @interval = interval
46
+ @like = "#{application_name_prefix}_%"
47
+ @clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
48
+ @logger = logger
49
+ # A single background worker with a GENUINELY bounded 1-slot queue that
50
+ # DISCARDS overflow. NOTE: Concurrent::SingleThreadExecutor silently drops
51
+ # max_queue: (it hardcodes an unbounded queue), so build the base
52
+ # ThreadPoolExecutor directly to actually honor the bound. Built eagerly
53
+ # (assigned once, safely published with the object) — the quiet-process
54
+ # "no thread" guarantee is owned by Client#streams_pool_trigger (this
55
+ # object only exists after a real publish with autoscale on), so there's
56
+ # no reason to lazy-build inside. Injectable for tests.
57
+ @executor = executor || Concurrent::ThreadPoolExecutor.new(
58
+ min_threads: 1, max_threads: 1, max_queue: 1, fallback_policy: :discard
59
+ )
60
+ # nil = never checked. AtomicReference gives a lock-free compare-and-set
61
+ # so exactly one concurrent publisher claims each throttle window.
62
+ @last_check = Concurrent::AtomicReference.new(nil)
63
+ # At-most-one-check guard — load-bearing only under an injected
64
+ # multi-threaded executor (see class comment).
65
+ @running = Concurrent::AtomicBoolean.new(false)
66
+ end
67
+
68
+ # Called from the publish path. Claims the throttle window with a single CAS
69
+ # and, if it wins, defers the actual check to the background executor. Does
70
+ # NOT run the query or resize on the calling thread. Never raises.
71
+ def maybe_check
72
+ return unless claim_window
73
+
74
+ @executor.post { run_check }
75
+ rescue StandardError => e
76
+ # A post after shutdown raises RejectedExecutionError; swallow it so a
77
+ # publish never fails. The check body itself is guarded inside run_check.
78
+ @logger.debug { "[Pgbus::Streams::PoolTrigger] dispatch failed: #{e.class}: #{e.message}" }
79
+ end
80
+
81
+ # Stop the background executor (idempotent). For a clean Client#close.
82
+ def shutdown
83
+ @executor.shutdown
84
+ @executor.wait_for_termination(5)
85
+ end
86
+
87
+ private
88
+
89
+ # True for exactly one caller per `interval` window; false for the rest.
90
+ # A losing CAS (another thread advanced @last_check first) also returns
91
+ # false, so only one thread posts the check per window.
92
+ def claim_window
93
+ now = @clock.call
94
+ last = @last_check.get
95
+ return false if last && (now - last) < @interval
96
+
97
+ @last_check.compare_and_set(last, now)
98
+ end
99
+
100
+ # Runs on the executor thread. Skips if a prior check is still in flight so
101
+ # a slow query can't queue up work. Fully guarded — a failure here never
102
+ # escapes the executor.
103
+ def run_check
104
+ return unless @running.make_true # false if already running
105
+
106
+ begin
107
+ headroom = read_headroom
108
+ @autoscaler.evaluate(headroom, allow_shrink: false) if headroom
109
+ ensure
110
+ @running.make_false
111
+ end
112
+ rescue StandardError => e
113
+ @logger.debug { "[Pgbus::Streams::PoolTrigger] check failed: #{e.class}: #{e.message}" }
114
+ end
115
+
116
+ def read_headroom
117
+ @job_pool.with_connection do |conn|
118
+ row = conn.exec_params(PoolAutoscaler::HEADROOM_SQL, [@like]).first
119
+ { maxc: row["maxc"].to_i, used: row["used"].to_i, peers: row["peers"].to_i }
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end