pgbus 0.9.10 → 0.9.11
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 +22 -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 +200 -1
- data/lib/pgbus/configuration.rb +170 -9
- data/lib/pgbus/doctor.rb +49 -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/worker.rb +9 -0
- 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
|
@@ -1,50 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
-
require "pgbus/generators/config_converter"
|
|
5
4
|
require "pgbus/generators/migration_detector"
|
|
6
5
|
require "pgbus/generators/database_target_detector"
|
|
7
6
|
|
|
8
7
|
module Pgbus
|
|
9
8
|
module Generators
|
|
10
|
-
# Upgrade command
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# absent — safe to re-run.
|
|
16
|
-
#
|
|
17
|
-
# 2. Migration detection: inspect the live database and add any
|
|
18
|
-
# missing pgbus migrations to db/migrate (or db/pgbus_migrate
|
|
19
|
-
# if a separate database is configured). Invokes each matching
|
|
20
|
-
# sub-generator in-process via Thor's invoke, so this mirrors
|
|
21
|
-
# what the user would get running each generator by hand.
|
|
9
|
+
# Upgrade command: inspect the live database and add any missing pgbus
|
|
10
|
+
# migrations to db/migrate (or db/pgbus_migrate if a separate database is
|
|
11
|
+
# configured). Invokes each matching sub-generator in-process via Thor's
|
|
12
|
+
# invoke, so this mirrors what the user would get running each generator
|
|
13
|
+
# by hand.
|
|
22
14
|
#
|
|
23
15
|
# Usage:
|
|
24
16
|
#
|
|
25
17
|
# bin/rails generate pgbus:update
|
|
26
18
|
# bin/rails generate pgbus:update --dry-run
|
|
27
|
-
# bin/rails generate pgbus:update --skip-config
|
|
28
19
|
# bin/rails generate pgbus:update --skip-migrations
|
|
29
20
|
# bin/rails generate pgbus:update --database=pgbus
|
|
30
21
|
# bin/rails generate pgbus:update --quiet
|
|
31
22
|
class UpdateGenerator < Rails::Generators::Base
|
|
32
|
-
desc "Upgrade pgbus:
|
|
33
|
-
|
|
34
|
-
class_option :source,
|
|
35
|
-
type: :string,
|
|
36
|
-
default: "config/pgbus.yml",
|
|
37
|
-
desc: "Path to an existing YAML config to convert (default: config/pgbus.yml)"
|
|
38
|
-
|
|
39
|
-
class_option :destination,
|
|
40
|
-
type: :string,
|
|
41
|
-
default: "config/initializers/pgbus.rb",
|
|
42
|
-
desc: "Path to the generated initializer (default: config/initializers/pgbus.rb)"
|
|
43
|
-
|
|
44
|
-
class_option :skip_config,
|
|
45
|
-
type: :boolean,
|
|
46
|
-
default: false,
|
|
47
|
-
desc: "Skip the YAML → Ruby initializer conversion step"
|
|
23
|
+
desc "Upgrade pgbus: add any missing migrations"
|
|
48
24
|
|
|
49
25
|
class_option :skip_migrations,
|
|
50
26
|
type: :boolean,
|
|
@@ -67,30 +43,6 @@ module Pgbus
|
|
|
67
43
|
default: false,
|
|
68
44
|
desc: "Suppress verbose per-step output"
|
|
69
45
|
|
|
70
|
-
def convert_yaml_if_present
|
|
71
|
-
return if options[:skip_config]
|
|
72
|
-
|
|
73
|
-
source_path = File.expand_path(options[:source], destination_root)
|
|
74
|
-
destination_path = File.expand_path(options[:destination], destination_root)
|
|
75
|
-
|
|
76
|
-
unless File.exist?(source_path)
|
|
77
|
-
log "YAML config not found at #{options[:source]}; skipping config conversion."
|
|
78
|
-
return
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
if File.exist?(destination_path)
|
|
82
|
-
log "Initializer already exists at #{options[:destination]}; skipping config conversion."
|
|
83
|
-
return
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
ruby_source = load_and_convert(source_path)
|
|
87
|
-
if options[:dry_run]
|
|
88
|
-
log_change "[dry-run] would create #{options[:destination]}"
|
|
89
|
-
else
|
|
90
|
-
create_file destination_path, ruby_source
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
46
|
def detect_and_install_missing_migrations
|
|
95
47
|
return if options[:skip_migrations]
|
|
96
48
|
|
|
@@ -172,18 +124,6 @@ module Pgbus
|
|
|
172
124
|
|
|
173
125
|
private
|
|
174
126
|
|
|
175
|
-
# Wrap converter and YAML errors as Thor::Error so the generator
|
|
176
|
-
# surfaces them with the standard "in red, no backtrace, exit 1"
|
|
177
|
-
# behavior. Catches:
|
|
178
|
-
# - ConfigConverter::Error (validation, missing file race)
|
|
179
|
-
# - Psych::Exception (malformed YAML, disallowed types)
|
|
180
|
-
# - Errno::ENOENT / Errno::EACCES (file disappeared / not readable)
|
|
181
|
-
def load_and_convert(source_path)
|
|
182
|
-
ConfigConverter.from_yaml(source_path)
|
|
183
|
-
rescue ConfigConverter::Error, Psych::Exception, Errno::ENOENT, Errno::EACCES => e
|
|
184
|
-
raise Thor::Error, "Failed to convert #{options[:source]}: #{e.message}"
|
|
185
|
-
end
|
|
186
|
-
|
|
187
127
|
def active_record_available?
|
|
188
128
|
defined?(::ActiveRecord::Base) && ::ActiveRecord::Base.respond_to?(:connection)
|
|
189
129
|
end
|
|
@@ -22,7 +22,14 @@ module Pgbus
|
|
|
22
22
|
full_name = config.queue_name(stream_name)
|
|
23
23
|
|
|
24
24
|
with_stale_connection_retry do
|
|
25
|
-
ensure_queue
|
|
25
|
+
# Create the BARE queue directly. ensure_queue would fan out through
|
|
26
|
+
# the priority strategy to _p0.._pN under priority_levels>1, leaving
|
|
27
|
+
# the bare queue — the one the streamer NOTIFYs on and read_after
|
|
28
|
+
# peeks — uncreated, and the enable_notify_if_needed below would then
|
|
29
|
+
# raise on the missing bare table. Streams never use priority
|
|
30
|
+
# sub-queues (issue #310).
|
|
31
|
+
ensure_pgmq_schema
|
|
32
|
+
ensure_single_queue(full_name)
|
|
26
33
|
|
|
27
34
|
# PGMQ's default NOTIFY throttle is 250ms — meant to coalesce
|
|
28
35
|
# high-frequency worker queue inserts. Streams are latency-
|
|
@@ -38,6 +45,8 @@ module Pgbus
|
|
|
38
45
|
# requires a roundtrip and a brief ACCESS SHARE lock on the archive
|
|
39
46
|
# table. Broadcast-per-after_commit loops can hit this 1000x/sec on
|
|
40
47
|
# the same stream, so memoize per-process after the first success.
|
|
48
|
+
# The StreamQueue registration shares this memo — both are one-time
|
|
49
|
+
# per stream per process and must both survive a first-broadcast.
|
|
41
50
|
return if @stream_indexes_created[stream_name]
|
|
42
51
|
|
|
43
52
|
sanitized = QueueNameValidator.sanitize!(full_name)
|
|
@@ -52,6 +61,11 @@ module Pgbus
|
|
|
52
61
|
end
|
|
53
62
|
end
|
|
54
63
|
|
|
64
|
+
# Record the physical queue name so maintenance (stream-archive prune,
|
|
65
|
+
# orphan sweep, compact_archives) and wildcard workers can tell this
|
|
66
|
+
# queue apart from a job queue. No-ops on unmigrated installs.
|
|
67
|
+
Pgbus::StreamQueue.record!(full_name)
|
|
68
|
+
|
|
55
69
|
@stream_indexes_created[stream_name] = true
|
|
56
70
|
end
|
|
57
71
|
end
|
|
@@ -18,7 +18,7 @@ module Pgbus
|
|
|
18
18
|
sql = build_read_after_sql(sanitized)
|
|
19
19
|
|
|
20
20
|
rows = synchronized do
|
|
21
|
-
|
|
21
|
+
with_streams_connection do |conn|
|
|
22
22
|
conn.exec_params(sql, [after_id.to_i, limit.to_i]).to_a
|
|
23
23
|
end
|
|
24
24
|
end
|
|
@@ -34,7 +34,7 @@ module Pgbus
|
|
|
34
34
|
sanitized = sanitized_queue(stream_name)
|
|
35
35
|
sql = "SELECT COALESCE(MAX(msg_id), 0) AS max FROM pgmq.q_#{sanitized}"
|
|
36
36
|
synchronized do
|
|
37
|
-
|
|
37
|
+
with_streams_connection do |conn|
|
|
38
38
|
conn.exec(sql).first.fetch("max").to_i
|
|
39
39
|
end
|
|
40
40
|
end
|
|
@@ -53,7 +53,7 @@ module Pgbus
|
|
|
53
53
|
) AS least
|
|
54
54
|
SQL
|
|
55
55
|
synchronized do
|
|
56
|
-
|
|
56
|
+
with_streams_connection do |conn|
|
|
57
57
|
value = conn.exec(sql).first.fetch("least")
|
|
58
58
|
value&.to_i
|
|
59
59
|
end
|
|
@@ -84,9 +84,10 @@ module Pgbus
|
|
|
84
84
|
# See issues #101 and #104. The comparison is case-insensitive because
|
|
85
85
|
# Postgres downcases unquoted identifiers in its error output, while
|
|
86
86
|
# `sanitized` can contain uppercase characters for GlobalID-keyed streams
|
|
87
|
-
# (e.g. `
|
|
88
|
-
# `pgbus_stream_from Current.user`
|
|
89
|
-
#
|
|
87
|
+
# (e.g. `pgbus_Z2lkOi8vY29zbW9zL1VzZXIvMQ` from
|
|
88
|
+
# `pgbus_stream_from Current.user` — stream queues share the job prefix,
|
|
89
|
+
# see Configuration#queue_name). A case-sensitive substring match would
|
|
90
|
+
# miss the downcased relation name and let the exception escape.
|
|
90
91
|
#
|
|
91
92
|
# The regex uses `\b` word boundaries so `pgmq.q_<needle>` doesn't
|
|
92
93
|
# accidentally match longer related identifiers like
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
class Client
|
|
7
|
+
# Owns the current streams PGMQ::Client behind a Concurrent::AtomicReference
|
|
8
|
+
# and resizes it by BUILDING A NEW pool, atomically swapping the reference,
|
|
9
|
+
# then bounded-draining + shutting down the old one (issue #323 spike).
|
|
10
|
+
#
|
|
11
|
+
# connection_pool 2.x cannot resize in place (@size is frozen at
|
|
12
|
+
# construction; only shutdown/reload/available exist), so growing REQUIRES a
|
|
13
|
+
# new PGMQ::Client + a reference swap.
|
|
14
|
+
#
|
|
15
|
+
# DRAIN CORRECTNESS — the load-bearing detail. We do NOT trust
|
|
16
|
+
# connection_pool's `available == size` as "drained": pgmq-ruby's
|
|
17
|
+
# #with_connection retries a lost connection (checkout → checkin → checkout
|
|
18
|
+
# again), so between the two checkouts the connection sits idle in the queue
|
|
19
|
+
# and `available` momentarily equals `size` while a produce/read is still
|
|
20
|
+
# mid-flight. Closing the pool in that window makes the retry's checkout raise
|
|
21
|
+
# PoolShuttingDownError → a genuinely LOST durable broadcast (the
|
|
22
|
+
# PgBouncer/failover mid-INSERT case). Instead each reader op is bracketed by
|
|
23
|
+
# an in-flight AtomicFixnum on the CAPTURED pool, and drain waits until THAT
|
|
24
|
+
# hits zero. This is immune to the internal retry and independent of the
|
|
25
|
+
# connection_pool version.
|
|
26
|
+
#
|
|
27
|
+
# DURABLE BROADCASTS are loss-safe by construction: #produce captures the
|
|
28
|
+
# Pool once, runs its whole retrying checkout→INSERT→COMMIT→checkin on that
|
|
29
|
+
# pool, then decrements. Both old and new pools connect to the same Postgres,
|
|
30
|
+
# so any INSERT commits; and close waits for the captured pool's inflight to
|
|
31
|
+
# reach zero, so a pool is never shut while a produce is in flight on it.
|
|
32
|
+
class ResizablePool
|
|
33
|
+
# The current pool: a PGMQ::Client plus its own in-flight checkout counter.
|
|
34
|
+
Pool = Data.define(:pgmq, :inflight)
|
|
35
|
+
|
|
36
|
+
SwapStats = Data.define(:swap_count, :last_drain_seconds, :last_conns_closed,
|
|
37
|
+
:last_from_size, :last_to_size, :last_drained)
|
|
38
|
+
|
|
39
|
+
DRAIN_POLL = 0.01 # 10ms
|
|
40
|
+
|
|
41
|
+
# `shared:` is accepted for symmetry with the Client's two connection paths
|
|
42
|
+
# but is not stored — the shared-AR alias guard lives in #close_current via
|
|
43
|
+
# the `job_pool:` identity check, and #swap is simply never called on the
|
|
44
|
+
# shared path (Client#resize_streams_pool short-circuits there).
|
|
45
|
+
def initialize(pgmq, shared:, drain_timeout:, logger:, clock: ::Process) # rubocop:disable Lint/UnusedMethodArgument
|
|
46
|
+
@ref = Concurrent::AtomicReference.new(new_pool(pgmq))
|
|
47
|
+
@drain_timeout = drain_timeout
|
|
48
|
+
@logger = logger
|
|
49
|
+
@clock = clock
|
|
50
|
+
@swap_mutex = Mutex.new # serializes swap AND close_current; reads never take it
|
|
51
|
+
@swap_count = 0
|
|
52
|
+
@last = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Hot path: one volatile load, no lock.
|
|
56
|
+
def current = @ref.get
|
|
57
|
+
|
|
58
|
+
# Bracketed reader ops — inflight decrements only after the WHOLE retrying
|
|
59
|
+
# call returns (so drain can't close under an in-flight produce/read).
|
|
60
|
+
def produce(*, **)
|
|
61
|
+
pool = @ref.get
|
|
62
|
+
pool.inflight.increment
|
|
63
|
+
begin
|
|
64
|
+
pool.pgmq.produce(*, **)
|
|
65
|
+
ensure
|
|
66
|
+
pool.inflight.decrement
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def with_connection(&)
|
|
71
|
+
pool = @ref.get
|
|
72
|
+
pool.inflight.increment
|
|
73
|
+
begin
|
|
74
|
+
pool.pgmq.with_connection(&)
|
|
75
|
+
ensure
|
|
76
|
+
pool.inflight.decrement
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stats = @ref.get.pgmq.stats
|
|
81
|
+
|
|
82
|
+
# Swap in a freshly-built PGMQ::Client, then drain + close the old Pool.
|
|
83
|
+
def swap(new_pgmq, from_size:, to_size:)
|
|
84
|
+
@swap_mutex.synchronize do
|
|
85
|
+
old = @ref.get
|
|
86
|
+
@ref.set(new_pool(new_pgmq)) # new checkouts now resolve to the new pool
|
|
87
|
+
drained, secs, closed = drain_and_close(old)
|
|
88
|
+
@swap_count += 1
|
|
89
|
+
@last = SwapStats.new(swap_count: @swap_count, last_drain_seconds: secs,
|
|
90
|
+
last_conns_closed: closed, last_from_size: from_size,
|
|
91
|
+
last_to_size: to_size, last_drained: drained)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Teardown of the current pool (from Client#close), race-free vs swap.
|
|
96
|
+
# Skips the close when the current pool aliases the job pool (shared-AR path).
|
|
97
|
+
def close_current(job_pool:)
|
|
98
|
+
@swap_mutex.synchronize do
|
|
99
|
+
pool = @ref.get
|
|
100
|
+
pool.pgmq.close unless pool.pgmq.equal?(job_pool)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def stats_snapshot
|
|
105
|
+
@last || SwapStats.new(swap_count: 0, last_drain_seconds: 0.0, last_conns_closed: 0,
|
|
106
|
+
last_from_size: nil, last_to_size: nil, last_drained: nil)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def new_pool(pgmq)
|
|
112
|
+
Pool.new(pgmq: pgmq, inflight: Concurrent::AtomicFixnum.new(0))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Bounded drain on the OLD pool's inflight counter, then shutdown. Mirrors
|
|
116
|
+
# OutboundPump#stop: fixed budget, log-and-abandon, NEVER Thread#kill /
|
|
117
|
+
# Timeout.timeout. Returns [drained?, wall_secs, conns_closed].
|
|
118
|
+
def drain_and_close(old)
|
|
119
|
+
t0 = mono
|
|
120
|
+
drained = wait_until_drained(old.inflight)
|
|
121
|
+
# Count connections about to be torn down BEFORE close (after close it's ~0).
|
|
122
|
+
conns = conns_open(old.pgmq)
|
|
123
|
+
unless drained
|
|
124
|
+
@logger.warn do
|
|
125
|
+
"[Pgbus::ResizablePool] streams pool did not fully drain in " \
|
|
126
|
+
"#{@drain_timeout}s (inflight=#{old.inflight.value}); shutting down anyway — " \
|
|
127
|
+
"in-flight connections self-close on checkin"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
old.pgmq.close
|
|
131
|
+
[drained, mono - t0, conns]
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
@logger.error { "[Pgbus::ResizablePool] old pool teardown failed: #{e.class}: #{e.message}" }
|
|
134
|
+
[false, mono - t0, 0]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def wait_until_drained(inflight)
|
|
138
|
+
deadline = mono + @drain_timeout
|
|
139
|
+
loop do
|
|
140
|
+
return true if inflight.value <= 0
|
|
141
|
+
return false if mono >= deadline
|
|
142
|
+
|
|
143
|
+
sleep(DRAIN_POLL) # NOT Timeout.timeout — Pgbus/NoRubyTimeout cop
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# size - available = connections currently open (created & not idle-in-queue).
|
|
148
|
+
def conns_open(pgmq)
|
|
149
|
+
stats = pgmq.stats
|
|
150
|
+
[stats[:size] - stats[:available], 0].max
|
|
151
|
+
rescue StandardError
|
|
152
|
+
0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Qualify ::Process — Pgbus::Process (the worker/supervisor namespace)
|
|
156
|
+
# shadows the top-level constant here.
|
|
157
|
+
def mono = @clock.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/pgbus/client.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "client/read_after"
|
|
|
7
7
|
require_relative "client/ensure_stream_queue"
|
|
8
8
|
require_relative "client/notify_stream"
|
|
9
9
|
require_relative "client/connection_health"
|
|
10
|
+
require_relative "client/resizable_pool"
|
|
10
11
|
|
|
11
12
|
module Pgbus
|
|
12
13
|
class Client
|
|
@@ -59,6 +60,12 @@ module Pgbus
|
|
|
59
60
|
# operations through a mutex.
|
|
60
61
|
@pgmq = PGMQ::Client.new(conn_opts, pool_size: 1, pool_timeout: config.pool_timeout)
|
|
61
62
|
@pgmq_mutex = Mutex.new
|
|
63
|
+
# No dedicated streams pool on this path: a second PGMQ::Client would
|
|
64
|
+
# still funnel through the same non-thread-safe AR raw_connection.
|
|
65
|
+
# Stream publish + replay share the single serialized connection —
|
|
66
|
+
# @streams_pgmq aliases @pgmq so the code paths are uniform, and
|
|
67
|
+
# #with_streams_connection falls back to with_raw_connection.
|
|
68
|
+
@streams_pgmq = @pgmq
|
|
62
69
|
else
|
|
63
70
|
# With a String URL or Hash params, pgmq-ruby creates its own dedicated
|
|
64
71
|
# PG::Connection per pool slot — no shared state with ActiveRecord.
|
|
@@ -75,10 +82,44 @@ module Pgbus
|
|
|
75
82
|
conn_opts = apply_connection_bounds(conn_opts)
|
|
76
83
|
@pgmq = PGMQ::Client.new(conn_opts, pool_size: config.resolved_pool_size, pool_timeout: config.pool_timeout)
|
|
77
84
|
@pgmq_mutex = nil
|
|
85
|
+
# Dedicated streams pool (issue #315): isolates the durable-stream
|
|
86
|
+
# publish INSERT (#send_stream_message) and the dispatcher's per-wake
|
|
87
|
+
# replay reads (#read_after) from the job pool, so a saturated worker
|
|
88
|
+
# pool can't delay a broadcast on pool checkout, and each wake reuses a
|
|
89
|
+
# persistent connection instead of a fresh PG.connect per call. Its own
|
|
90
|
+
# PGMQ::Client → its own connection_pool, sized independently of worker
|
|
91
|
+
# thread counts.
|
|
92
|
+
# Build the streams pool from streams_connection_options (which defaults
|
|
93
|
+
# to connection_options but honors streams_database_url/host/port for a
|
|
94
|
+
# separate/direct streams DB — issue #315), bounds-applied, and tagged
|
|
95
|
+
# with a per-process application_name so the autoscaler can count peer
|
|
96
|
+
# processes from pg_stat_activity (issue #323 P1/P2). Snapshot it so a
|
|
97
|
+
# hot-swap rebuilds a byte-identical pool at a new size.
|
|
98
|
+
@streams_conn_opts = tag_application_name(
|
|
99
|
+
apply_connection_bounds(config.streams_connection_options)
|
|
100
|
+
)
|
|
101
|
+
@streams_pgmq = PGMQ::Client.new(@streams_conn_opts, pool_size: config.streams_pool_size,
|
|
102
|
+
pool_timeout: config.streams_pool_timeout)
|
|
78
103
|
end
|
|
79
104
|
|
|
105
|
+
# Wrap the streams pool so its live reference can be atomically hot-swapped
|
|
106
|
+
# to a new size under load without losing broadcasts or leaking connections
|
|
107
|
+
# (issue #323 spike; #resize_streams_pool). All streams-pool access goes
|
|
108
|
+
# through this — see #streams_pool. Default behavior with no swap is
|
|
109
|
+
# byte-identical (one AtomicReference read + a counter bump per op).
|
|
110
|
+
@streams_pool = ResizablePool.new(
|
|
111
|
+
@streams_pgmq,
|
|
112
|
+
shared: @shared_connection,
|
|
113
|
+
drain_timeout: config.streams_pool_timeout + 1.0,
|
|
114
|
+
logger: Pgbus.logger
|
|
115
|
+
)
|
|
116
|
+
|
|
80
117
|
@queues_created = Concurrent::Map.new
|
|
81
118
|
@stream_indexes_created = Concurrent::Map.new
|
|
119
|
+
# Guards the one-time build of the publisher autoscale trigger (issue #323).
|
|
120
|
+
# NOT @pgmq_mutex — that is nil on the dedicated path (the only path the
|
|
121
|
+
# trigger exists on), so it wouldn't serialize concurrent first-publishers.
|
|
122
|
+
@streams_trigger_mutex = Mutex.new
|
|
82
123
|
@queue_strategy = QueueFactory.for(config)
|
|
83
124
|
@schema_ensured = schema_ensured
|
|
84
125
|
@connection_health = ConnectionHealth.new(
|
|
@@ -240,6 +281,38 @@ module Pgbus
|
|
|
240
281
|
end
|
|
241
282
|
end
|
|
242
283
|
|
|
284
|
+
# Durable stream broadcast. Unlike #send_message, this ALWAYS targets the
|
|
285
|
+
# bare queue (config.queue_name) and never the priority strategy's
|
|
286
|
+
# _p0.._pN sub-queues: streams are delivered by a non-consuming peek
|
|
287
|
+
# (read_after) on the bare queue, and the streamer LISTENs on the bare
|
|
288
|
+
# channel, so a broadcast routed to _p1 would never reach the browser
|
|
289
|
+
# (issue #310). ensure_stream_queue creates the bare queue + NOTIFY
|
|
290
|
+
# trigger + archive index, mirroring this bare-name write path.
|
|
291
|
+
def send_stream_message(stream_name, payload, headers: nil, delay: 0)
|
|
292
|
+
target = config.queue_name(stream_name)
|
|
293
|
+
# Capture the produced msg_id — it is this method's return value (callers
|
|
294
|
+
# like Stream#broadcast rely on it), so the autoscale trigger below must NOT
|
|
295
|
+
# become the last expression.
|
|
296
|
+
msg_id = Instrumentation.instrument("pgbus.client.send_message", queue: target) do
|
|
297
|
+
with_stale_connection_retry do
|
|
298
|
+
ensure_stream_queue(stream_name)
|
|
299
|
+
# Publish through the dedicated streams pool (issue #315) so a
|
|
300
|
+
# saturated job pool can't block a broadcast on pool checkout. On the
|
|
301
|
+
# shared-AR path @streams_pgmq aliases @pgmq and synchronized still
|
|
302
|
+
# serializes on the mutex.
|
|
303
|
+
synchronized do
|
|
304
|
+
streams_pool.produce(target, serialize(payload), headers: headers && serialize(headers), delay: delay)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
# Opportunistically autoscale the streams pool from the publish path so a
|
|
309
|
+
# pure-publisher process (no streamer) still grows under a broadcast storm
|
|
310
|
+
# (issue #323 follow-up). Throttled + fail-soft — never delays or breaks the
|
|
311
|
+
# broadcast; nil (a no-op) unless autoscale is on and the pool is dedicated.
|
|
312
|
+
streams_pool_trigger&.maybe_check
|
|
313
|
+
msg_id
|
|
314
|
+
end
|
|
315
|
+
|
|
243
316
|
def send_batch(queue_name, payloads, headers: nil, delay: 0)
|
|
244
317
|
full_name = config.queue_name(queue_name)
|
|
245
318
|
serialized, serialized_headers = serialize_batch(payloads, headers)
|
|
@@ -446,6 +519,16 @@ module Pgbus
|
|
|
446
519
|
{}
|
|
447
520
|
end
|
|
448
521
|
|
|
522
|
+
# Same shape as #pool_stats but for the dedicated streams pool (issue #315).
|
|
523
|
+
# On the shared-AR path @streams_pgmq aliases @pgmq, so this reports the job
|
|
524
|
+
# pool's counters — accurate, since streams share that connection there.
|
|
525
|
+
def streams_pool_stats
|
|
526
|
+
streams_pool.stats.merge(pool_timeout: config.streams_pool_timeout)
|
|
527
|
+
rescue StandardError => e
|
|
528
|
+
Pgbus.logger.debug { "[Pgbus::Client] streams_pool_stats unavailable: #{e.class}: #{e.message}" }
|
|
529
|
+
{}
|
|
530
|
+
end
|
|
531
|
+
|
|
449
532
|
def list_queues
|
|
450
533
|
with_stale_connection_retry do
|
|
451
534
|
synchronized { @pgmq.list_queues }
|
|
@@ -642,7 +725,47 @@ module Pgbus
|
|
|
642
725
|
end
|
|
643
726
|
|
|
644
727
|
def close
|
|
645
|
-
|
|
728
|
+
# Stop the publisher autoscale executor (if one was ever built) so its
|
|
729
|
+
# background thread doesn't leak (issue #323). Outside `synchronized` — it
|
|
730
|
+
# takes no pool lock and shutdown waits on a possibly-running check.
|
|
731
|
+
@streams_pool_trigger.shutdown if defined?(@streams_pool_trigger) && @streams_pool_trigger
|
|
732
|
+
synchronized do
|
|
733
|
+
@pgmq.close
|
|
734
|
+
# Close the CURRENT streams pool too (issue #315) so its connections
|
|
735
|
+
# don't leak. close_current reads the live (possibly hot-swapped, #323)
|
|
736
|
+
# pool once under the swap mutex and skips it when it aliases @pgmq (the
|
|
737
|
+
# shared-AR path) so we don't double-close the same pool.
|
|
738
|
+
@streams_pool.close_current(job_pool: @pgmq)
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# Opt-in hot-swap of the dedicated streams pool to a new size (issue #323
|
|
743
|
+
# spike). Builds a fresh PGMQ::Client at new_size with the SAME bounds-applied
|
|
744
|
+
# connection options, atomically swaps the live reference, then drains +
|
|
745
|
+
# closes the old pool (bounded, never Thread#kill). NOT called automatically —
|
|
746
|
+
# there is no control loop here; a caller triggers it explicitly.
|
|
747
|
+
#
|
|
748
|
+
# No-op on the shared-AR (Proc) path (the streams pool aliases the job pool,
|
|
749
|
+
# which is non-thread-safe and forced to pool_size 1 — swapping it would
|
|
750
|
+
# corrupt the job pool), and no-op when the size is unchanged.
|
|
751
|
+
#
|
|
752
|
+
# @return [ResizablePool::SwapStats] on a swap, or {swapped: false, reason:}
|
|
753
|
+
def resize_streams_pool(new_size)
|
|
754
|
+
raise ArgumentError, "new_size must be a positive integer" unless new_size.is_a?(Integer) && new_size.positive?
|
|
755
|
+
return { swapped: false, reason: :shared_connection } if @shared_connection
|
|
756
|
+
return { swapped: false, reason: :unchanged } if streams_pool.stats[:size] == new_size
|
|
757
|
+
|
|
758
|
+
from_size = streams_pool.stats[:size]
|
|
759
|
+
new_pgmq = PGMQ::Client.new(
|
|
760
|
+
@streams_conn_opts, pool_size: new_size, pool_timeout: config.streams_pool_timeout
|
|
761
|
+
)
|
|
762
|
+
@streams_pool.swap(new_pgmq, from_size: from_size, to_size: new_size)
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# Accumulated streams-pool swap telemetry (issue #323) — for the bench and a
|
|
766
|
+
# future control loop. Zero-valued before any swap.
|
|
767
|
+
def streams_swap_stats
|
|
768
|
+
@streams_pool.stats_snapshot
|
|
646
769
|
end
|
|
647
770
|
|
|
648
771
|
private
|
|
@@ -776,6 +899,22 @@ module Pgbus
|
|
|
776
899
|
conn&.close if owned
|
|
777
900
|
end
|
|
778
901
|
|
|
902
|
+
# Yields a PG connection from the dedicated streams pool for the streamer's
|
|
903
|
+
# replay reads (read_after / stream_current_msg_id / stream_oldest_msg_id).
|
|
904
|
+
# On the dedicated (String/Hash) path this checks out a persistent pooled
|
|
905
|
+
# connection — no fresh PG.connect per call (issue #315). On the shared-AR
|
|
906
|
+
# (Proc) path there is no separate pool (@streams_pgmq aliases @pgmq and
|
|
907
|
+
# points at the non-thread-safe AR raw_connection), so we fall back to
|
|
908
|
+
# with_raw_connection, which reuses that same shared connection. Callers
|
|
909
|
+
# already wrap this in `synchronized`, so the shared path stays serialized.
|
|
910
|
+
def with_streams_connection(&)
|
|
911
|
+
if @shared_connection
|
|
912
|
+
with_raw_connection(&)
|
|
913
|
+
else
|
|
914
|
+
streams_pool.with_connection(&)
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
|
|
779
918
|
def ensure_single_queue(full_name)
|
|
780
919
|
return if @queues_created[full_name]
|
|
781
920
|
|
|
@@ -871,6 +1010,40 @@ module Pgbus
|
|
|
871
1010
|
end
|
|
872
1011
|
end
|
|
873
1012
|
|
|
1013
|
+
# The ResizablePool wrapping the streams pool. All streams-pool reads
|
|
1014
|
+
# (produce / with_connection / stats) go through it so the underlying
|
|
1015
|
+
# PGMQ::Client can be atomically hot-swapped to a new size (issue #323).
|
|
1016
|
+
attr_reader :streams_pool
|
|
1017
|
+
|
|
1018
|
+
# Lazily-built publisher-side autoscale trigger (issue #323). nil (a no-op)
|
|
1019
|
+
# unless streams_pool_autoscale is on AND this is the dedicated-connection
|
|
1020
|
+
# path (resize is a no-op on the shared-AR path). Built once — on the first
|
|
1021
|
+
# publish, then reused. Runs its headroom query through the job pool (@pgmq),
|
|
1022
|
+
# so it never competes with the streams pool it resizes.
|
|
1023
|
+
#
|
|
1024
|
+
# Double-checked under @streams_trigger_mutex so concurrent first-publishers
|
|
1025
|
+
# (the integration spec fires 8) can't each build their own trigger — that
|
|
1026
|
+
# would give each thread a trigger with its own throttle, defeating the
|
|
1027
|
+
# once-per-interval throttle on the first window. Steady-state publishes hit
|
|
1028
|
+
# the fast `defined?` path with no lock.
|
|
1029
|
+
def streams_pool_trigger
|
|
1030
|
+
return @streams_pool_trigger if defined?(@streams_pool_trigger)
|
|
1031
|
+
|
|
1032
|
+
@streams_trigger_mutex.synchronize do
|
|
1033
|
+
return @streams_pool_trigger if defined?(@streams_pool_trigger)
|
|
1034
|
+
|
|
1035
|
+
@streams_pool_trigger =
|
|
1036
|
+
if config.streams_pool_autoscale && !@shared_connection
|
|
1037
|
+
autoscaler = Streams::PoolAutoscaler.new(client: self, config: config)
|
|
1038
|
+
Streams::PoolTrigger.new(
|
|
1039
|
+
autoscaler: autoscaler, job_pool: @pgmq,
|
|
1040
|
+
interval: config.streams_pool_autoscale_interval,
|
|
1041
|
+
application_name_prefix: config.streams_application_name
|
|
1042
|
+
)
|
|
1043
|
+
end
|
|
1044
|
+
end
|
|
1045
|
+
end
|
|
1046
|
+
|
|
874
1047
|
# Substrings that indicate the pooled PG::Connection was already dead
|
|
875
1048
|
# *before* pgmq-ruby tried to use it — typically killed by a connection
|
|
876
1049
|
# pooler (PgBouncer server_idle_timeout / client_idle_timeout), an admin
|
|
@@ -1100,6 +1273,32 @@ module Pgbus
|
|
|
1100
1273
|
# read_timeout is already failing — and keeps a single server-side mechanism.
|
|
1101
1274
|
#
|
|
1102
1275
|
# Returns conn_opts unchanged when read_timeout is nil (bounding disabled).
|
|
1276
|
+
# Stamp a per-process application_name on the streams-pool connection options
|
|
1277
|
+
# so the autoscaler can count peer processes via
|
|
1278
|
+
# `pg_stat_activity.application_name LIKE '<prefix>_%'` (issue #323 P1). The
|
|
1279
|
+
# suffix is the pid so DISTINCT application_name is an exact process count.
|
|
1280
|
+
# application_name is a cosmetic session GUC — appending it can't break the
|
|
1281
|
+
# connection. The Proc (shared-AR) path never reaches here.
|
|
1282
|
+
def tag_application_name(conn_opts)
|
|
1283
|
+
name = "#{config.streams_application_name}_#{::Process.pid}"
|
|
1284
|
+
case conn_opts
|
|
1285
|
+
when Hash
|
|
1286
|
+
conn_opts.merge(application_name: name)
|
|
1287
|
+
when String
|
|
1288
|
+
# Two libpq string forms (mirrors #append_connection_bounds): URI form
|
|
1289
|
+
# carries params as `?key=value&…` query pairs; key=value conninfo form
|
|
1290
|
+
# is space-separated. Appending wins over any earlier application_name.
|
|
1291
|
+
if conn_opts.start_with?("postgres://", "postgresql://")
|
|
1292
|
+
separator = conn_opts.include?("?") ? "&" : "?"
|
|
1293
|
+
"#{conn_opts}#{separator}application_name=#{name}"
|
|
1294
|
+
else
|
|
1295
|
+
"#{conn_opts} application_name=#{name}"
|
|
1296
|
+
end
|
|
1297
|
+
else
|
|
1298
|
+
conn_opts
|
|
1299
|
+
end
|
|
1300
|
+
end
|
|
1301
|
+
|
|
1103
1302
|
def apply_connection_bounds(conn_opts)
|
|
1104
1303
|
timeout = config.read_timeout
|
|
1105
1304
|
return conn_opts unless timeout&.positive?
|