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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -7
- data/README.md +34 -7
- data/Rakefile +22 -1
- data/app/models/pgbus/stream_queue.rb +87 -0
- data/lib/generators/pgbus/add_stream_queues_generator.rb +50 -0
- data/lib/generators/pgbus/install_generator.rb +3 -3
- data/lib/generators/pgbus/templates/add_stream_queues.rb.erb +11 -0
- data/lib/generators/pgbus/templates/initializer.rb.erb +62 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +14 -0
- data/lib/generators/pgbus/update_generator.rb +6 -66
- data/lib/pgbus/client/ensure_stream_queue.rb +15 -1
- data/lib/pgbus/client/read_after.rb +7 -6
- data/lib/pgbus/client/resizable_pool.rb +160 -0
- data/lib/pgbus/client.rb +261 -4
- data/lib/pgbus/configuration.rb +244 -11
- data/lib/pgbus/doctor.rb +73 -2
- data/lib/pgbus/engine.rb +30 -3
- data/lib/pgbus/generators/migration_detector.rb +7 -0
- data/lib/pgbus/process/dispatcher.rb +89 -19
- data/lib/pgbus/process/notify_listener.rb +18 -2
- data/lib/pgbus/process/worker.rb +19 -3
- data/lib/pgbus/streams/pool_autoscaler.rb +202 -0
- data/lib/pgbus/streams/pool_trigger.rb +124 -0
- data/lib/pgbus/streams/turbo_broadcastable.rb +23 -0
- data/lib/pgbus/streams.rb +2 -2
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/streamer/connection.rb +22 -6
- data/lib/pgbus/web/streamer/falcon_connection.rb +17 -5
- data/lib/pgbus/web/streamer/instance.rb +75 -5
- data/lib/pgbus/web/streamer/io_writer.rb +11 -5
- data/lib/pgbus/web/streamer/listener.rb +61 -1
- data/lib/pgbus/web/streamer/outbound_pump.rb +260 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +89 -7
- metadata +9 -4
- data/lib/generators/pgbus/templates/pgbus.yml.erb +0 -76
- data/lib/pgbus/config_loader.rb +0 -73
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
return if
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -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.
|
|
600
|
-
#
|
|
601
|
-
|
|
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
|