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
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?
|
|
@@ -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
|
|
@@ -62,5 +62,28 @@ module Pgbus
|
|
|
62
62
|
|
|
63
63
|
::Turbo::StreamsChannel.singleton_class.prepend(TurboBroadcastable)
|
|
64
64
|
end
|
|
65
|
+
|
|
66
|
+
# turbo-rails' async broadcast helpers (`broadcast_*_later_to`, the
|
|
67
|
+
# default for `broadcasts_to`/`broadcasts_refreshes`) enqueue these three
|
|
68
|
+
# ActiveJobs, which ship with no `queue_as` and so land on the default
|
|
69
|
+
# queue — where a render+broadcast can wait behind long-running jobs
|
|
70
|
+
# before the browser sees the update (#311). When the operator sets
|
|
71
|
+
# `config.streams_broadcast_queue`, route them to that dedicated queue so
|
|
72
|
+
# a `realtime:`-style worker capsule can isolate broadcast latency from job
|
|
73
|
+
# throughput. Called from the engine's turbo_broadcastable initializer.
|
|
74
|
+
# No-op when the queue is nil or turbo-rails is not loaded.
|
|
75
|
+
def self.install_broadcast_queue!(queue_name)
|
|
76
|
+
return if queue_name.nil?
|
|
77
|
+
|
|
78
|
+
%w[
|
|
79
|
+
Turbo::Streams::ActionBroadcastJob
|
|
80
|
+
Turbo::Streams::BroadcastJob
|
|
81
|
+
Turbo::Streams::BroadcastStreamJob
|
|
82
|
+
].each do |const_name|
|
|
83
|
+
next unless Object.const_defined?(const_name)
|
|
84
|
+
|
|
85
|
+
Object.const_get(const_name).queue_as(queue_name)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
65
88
|
end
|
|
66
89
|
end
|
data/lib/pgbus/streams.rb
CHANGED
|
@@ -157,10 +157,10 @@ module Pgbus
|
|
|
157
157
|
}
|
|
158
158
|
Instrumentation.instrument("pgbus.stream.broadcast", instrument_payload) do
|
|
159
159
|
if transaction
|
|
160
|
-
transaction.after_commit { @client.
|
|
160
|
+
transaction.after_commit { @client.send_stream_message(@name, wrapped) }
|
|
161
161
|
nil
|
|
162
162
|
else
|
|
163
|
-
@client.
|
|
163
|
+
@client.send_stream_message(@name, wrapped)
|
|
164
164
|
end
|
|
165
165
|
end
|
|
166
166
|
end
|
data/lib/pgbus/version.rb
CHANGED
|
@@ -13,17 +13,29 @@ module Pgbus
|
|
|
13
13
|
# cursor only for envelopes that actually wrote successfully. This is
|
|
14
14
|
# the client-side leg of the replay-race fix (§6.5 of the design doc).
|
|
15
15
|
class Connection
|
|
16
|
-
attr_reader :id, :stream_name, :io, :mutex, :
|
|
16
|
+
attr_reader :id, :stream_name, :io, :mutex, :context
|
|
17
17
|
# The presence member id this connection auto-joined as, or nil for
|
|
18
18
|
# non-presence streams / anonymous connections. Set by the
|
|
19
19
|
# Dispatcher on connect; read on disconnect and heartbeat touch.
|
|
20
20
|
attr_accessor :presence_member
|
|
21
21
|
|
|
22
|
+
# last_msg_id_sent is the ONE field whose writer can cross threads: with
|
|
23
|
+
# streams_writer_threads > 0 (issue #321) the OutboundPump worker thread
|
|
24
|
+
# advances it inside #enqueue, while the dispatcher thread reads it via
|
|
25
|
+
# cursor_for / handle_connect. A Concurrent::AtomicReference gives a real
|
|
26
|
+
# happens-before on every Ruby engine (not just MRI's GVL) at ~ns cost —
|
|
27
|
+
# one worker only ever writes a given connection's field (stable
|
|
28
|
+
# partition), so there's no writer-writer contention. Inline mode
|
|
29
|
+
# (default) reads/writes it single-threaded, semantically identical.
|
|
30
|
+
def last_msg_id_sent
|
|
31
|
+
@last_msg_id_sent.get
|
|
32
|
+
end
|
|
33
|
+
|
|
22
34
|
def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil)
|
|
23
35
|
@id = id
|
|
24
36
|
@stream_name = stream_name
|
|
25
37
|
@io = io
|
|
26
|
-
@last_msg_id_sent = since_id.to_i
|
|
38
|
+
@last_msg_id_sent = Concurrent::AtomicReference.new(since_id.to_i)
|
|
27
39
|
@writer = writer
|
|
28
40
|
@write_deadline_ms = write_deadline_ms
|
|
29
41
|
@mutex = Mutex.new
|
|
@@ -40,11 +52,15 @@ module Pgbus
|
|
|
40
52
|
@context = context
|
|
41
53
|
end
|
|
42
54
|
|
|
43
|
-
|
|
55
|
+
# deadline_ms defaults to the connection's own write deadline so every
|
|
56
|
+
# existing caller is unchanged. The Dispatcher overrides it with the
|
|
57
|
+
# short streams_fanout_write_deadline_ms for hot-loop fanout writes,
|
|
58
|
+
# bounding head-of-line blocking on a slow client (issue #315 item 3).
|
|
59
|
+
def enqueue(envelopes, deadline_ms: @write_deadline_ms)
|
|
44
60
|
written = []
|
|
45
61
|
envelopes.each do |envelope|
|
|
46
62
|
ephemeral = envelope.msg_id.negative?
|
|
47
|
-
next if !ephemeral && envelope.msg_id <= @last_msg_id_sent
|
|
63
|
+
next if !ephemeral && envelope.msg_id <= @last_msg_id_sent.get
|
|
48
64
|
|
|
49
65
|
bytes = Pgbus::Streams::Envelope.message(
|
|
50
66
|
id: envelope.msg_id,
|
|
@@ -52,9 +68,9 @@ module Pgbus
|
|
|
52
68
|
data: envelope.payload
|
|
53
69
|
)
|
|
54
70
|
|
|
55
|
-
result = @writer.write(self, bytes, deadline_ms:
|
|
71
|
+
result = @writer.write(self, bytes, deadline_ms: deadline_ms)
|
|
56
72
|
if result == :ok
|
|
57
|
-
@last_msg_id_sent
|
|
73
|
+
@last_msg_id_sent.set(envelope.msg_id) unless ephemeral
|
|
58
74
|
@last_write_at = monotonic
|
|
59
75
|
written << envelope
|
|
60
76
|
else
|
|
@@ -12,15 +12,23 @@ module Pgbus
|
|
|
12
12
|
# directly to body.write which is backed by Thread::Queue and
|
|
13
13
|
# is fiber-safe under Falcon's scheduler.
|
|
14
14
|
class FalconConnection
|
|
15
|
-
attr_reader :id, :stream_name, :io, :mutex, :
|
|
15
|
+
attr_reader :id, :stream_name, :io, :mutex, :context
|
|
16
16
|
attr_accessor :presence_member
|
|
17
17
|
|
|
18
|
+
# last_msg_id_sent mirrors Connection's cross-thread cursor (issue #321):
|
|
19
|
+
# a Concurrent::AtomicReference so the OutboundPump worker's advance and
|
|
20
|
+
# the dispatcher's read have a happens-before on every Ruby engine. See
|
|
21
|
+
# the note on Connection#last_msg_id_sent.
|
|
22
|
+
def last_msg_id_sent
|
|
23
|
+
@last_msg_id_sent.get
|
|
24
|
+
end
|
|
25
|
+
|
|
18
26
|
def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil)
|
|
19
27
|
@id = id
|
|
20
28
|
@stream_name = stream_name
|
|
21
29
|
@body = body
|
|
22
30
|
@io = body
|
|
23
|
-
@last_msg_id_sent = since_id.to_i
|
|
31
|
+
@last_msg_id_sent = Concurrent::AtomicReference.new(since_id.to_i)
|
|
24
32
|
@write_deadline_ms = write_deadline_ms
|
|
25
33
|
@mutex = Mutex.new
|
|
26
34
|
@dead = false
|
|
@@ -31,10 +39,14 @@ module Pgbus
|
|
|
31
39
|
@context = context
|
|
32
40
|
end
|
|
33
41
|
|
|
34
|
-
|
|
42
|
+
# deadline_ms is accepted for duck-type parity with Connection#enqueue
|
|
43
|
+
# (so the Dispatcher's safe_enqueue can call either class) but ignored:
|
|
44
|
+
# write_to_body is non-blocking under Falcon's fiber reactor, so there
|
|
45
|
+
# is no head-of-line blocking to bound here (issue #315 item 3).
|
|
46
|
+
def enqueue(envelopes, deadline_ms: @write_deadline_ms) # rubocop:disable Lint/UnusedMethodArgument
|
|
35
47
|
written = []
|
|
36
48
|
envelopes.each do |envelope|
|
|
37
|
-
next if envelope.msg_id <= @last_msg_id_sent
|
|
49
|
+
next if envelope.msg_id <= @last_msg_id_sent.get
|
|
38
50
|
|
|
39
51
|
bytes = Pgbus::Streams::Envelope.message(
|
|
40
52
|
id: envelope.msg_id,
|
|
@@ -44,7 +56,7 @@ module Pgbus
|
|
|
44
56
|
|
|
45
57
|
result = write_to_body(bytes)
|
|
46
58
|
if result == :ok
|
|
47
|
-
@last_msg_id_sent
|
|
59
|
+
@last_msg_id_sent.set(envelope.msg_id)
|
|
48
60
|
@last_write_at = monotonic
|
|
49
61
|
written << envelope
|
|
50
62
|
else
|
|
@@ -20,7 +20,8 @@ module Pgbus
|
|
|
20
20
|
# production the module-level Streamer.current(...) builds all of the
|
|
21
21
|
# defaults from the configuration.
|
|
22
22
|
class Instance
|
|
23
|
-
attr_reader :registry, :listener, :dispatcher, :heartbeat, :dispatch_queue, :stream_counter
|
|
23
|
+
attr_reader :registry, :listener, :dispatcher, :heartbeat, :dispatch_queue, :stream_counter, :pump,
|
|
24
|
+
:autoscaler
|
|
24
25
|
|
|
25
26
|
def initialize(
|
|
26
27
|
client: Pgbus.client,
|
|
@@ -39,10 +40,26 @@ module Pgbus
|
|
|
39
40
|
|
|
40
41
|
@stream_counter = StreamCounter.new
|
|
41
42
|
@pg_connection = pg_connection || build_pg_connection
|
|
43
|
+
# Self-tuning streams-pool autoscaler (issue #323). Opt-in; nil unless
|
|
44
|
+
# enabled AND on the dedicated connection path (the shared-AR streams
|
|
45
|
+
# pool aliases the non-thread-safe job pool and resize is a no-op there).
|
|
46
|
+
# It's a pure decision object — no thread, no connection. It runs as a
|
|
47
|
+
# throttled maintenance task on the Listener's idle LISTEN connection
|
|
48
|
+
# (zero extra connections), querying live headroom there.
|
|
49
|
+
@autoscaler =
|
|
50
|
+
if @config.streams_pool_autoscale && !@client.shared_connection?
|
|
51
|
+
Pgbus::Streams::PoolAutoscaler.new(client: @client, config: @config, logger: @logger)
|
|
52
|
+
end
|
|
42
53
|
@listener = Listener.new(
|
|
43
54
|
pg_connection: @pg_connection,
|
|
44
55
|
dispatch_queue: @dispatch_queue,
|
|
45
56
|
health_check_ms: @config.streams_listen_health_check_ms,
|
|
57
|
+
# Opt-in dispatch-queue backpressure (issue #315 item 3). 0 =
|
|
58
|
+
# unbounded (default). The queue itself stays an unbounded
|
|
59
|
+
# Queue.new so the request-thread Connect push and the dispatcher's
|
|
60
|
+
# own prune_dead self-post never block.
|
|
61
|
+
dispatch_queue_limit: @config.streams_dispatch_queue_limit,
|
|
62
|
+
maintenance: build_autoscale_maintenance,
|
|
46
63
|
logger: @logger,
|
|
47
64
|
# On reconnect the Listener rebuilds its OWN connection via this
|
|
48
65
|
# factory (fresh connect re-resolves DNS, converges on the promoted
|
|
@@ -52,6 +69,14 @@ module Pgbus
|
|
|
52
69
|
# can inject its own factory to avoid touching real configuration.
|
|
53
70
|
connection_factory: connection_factory || -> { build_raw_pg_connection }
|
|
54
71
|
)
|
|
72
|
+
# Off-thread durable fanout writer (issue #321). Built only when
|
|
73
|
+
# streams_writer_threads > 0; nil means fanout writes stay inline on
|
|
74
|
+
# the dispatcher thread (the default, pre-#321 behavior). The pump
|
|
75
|
+
# reports write acks on @ack_queue (drained by the dispatcher) and
|
|
76
|
+
# posts a DisconnectMessage via on_dead when a write fails, so the
|
|
77
|
+
# dispatcher owns all cursor + registry cleanup on its own thread.
|
|
78
|
+
@ack_queue = Queue.new
|
|
79
|
+
@pump = build_pump
|
|
55
80
|
@dispatcher = StreamEventDispatcher.new(
|
|
56
81
|
client: @client,
|
|
57
82
|
registry: @registry,
|
|
@@ -59,7 +84,9 @@ module Pgbus
|
|
|
59
84
|
dispatch_queue: @dispatch_queue,
|
|
60
85
|
logger: @logger,
|
|
61
86
|
config: @config,
|
|
62
|
-
stream_counter: @stream_counter
|
|
87
|
+
stream_counter: @stream_counter,
|
|
88
|
+
pump: @pump,
|
|
89
|
+
ack_queue: @ack_queue
|
|
63
90
|
)
|
|
64
91
|
@heartbeat = Heartbeat.new(
|
|
65
92
|
registry: @registry,
|
|
@@ -76,6 +103,9 @@ module Pgbus
|
|
|
76
103
|
return if @started
|
|
77
104
|
|
|
78
105
|
@started = true
|
|
106
|
+
# Pump first: it must be ready to accept writes before the dispatcher
|
|
107
|
+
# can post any (issue #321). No-op when offload is off (@pump nil).
|
|
108
|
+
@pump&.start
|
|
79
109
|
@listener.start
|
|
80
110
|
@dispatcher.start
|
|
81
111
|
@heartbeat.start
|
|
@@ -112,9 +142,15 @@ module Pgbus
|
|
|
112
142
|
# 2. Listener next (stop accepting new NOTIFYs)
|
|
113
143
|
# 3. Dispatcher next (drain the queue; it's now finite because
|
|
114
144
|
# nothing else writes into it)
|
|
115
|
-
# 4.
|
|
116
|
-
#
|
|
117
|
-
#
|
|
145
|
+
# 4. Writer pump next (issue #321): once the dispatcher — the pump's
|
|
146
|
+
# only producer — is stopped, each partition is finite, so the
|
|
147
|
+
# pump drains every accepted-but-unflushed durable frame and joins
|
|
148
|
+
# its workers before we touch the sockets. No-op when offload is
|
|
149
|
+
# off. Must come BEFORE close_all_connections so buffered frames
|
|
150
|
+
# flush before their sockets close.
|
|
151
|
+
# 5. Send pgbus:shutdown sentinel to every connection and close
|
|
152
|
+
# their sockets. We do this AFTER stopping the dispatcher AND the
|
|
153
|
+
# pump so nothing else is writing to these IOs concurrently.
|
|
118
154
|
#
|
|
119
155
|
# Bounded by the configured write deadline per connection; a dead
|
|
120
156
|
# client drops instantly, a slow one stalls for at most write_deadline_ms.
|
|
@@ -126,6 +162,7 @@ module Pgbus
|
|
|
126
162
|
safely { @heartbeat.stop }
|
|
127
163
|
safely { @listener.stop }
|
|
128
164
|
safely { @dispatcher.stop }
|
|
165
|
+
safely { @pump&.stop }
|
|
129
166
|
close_all_connections
|
|
130
167
|
end
|
|
131
168
|
end
|
|
@@ -138,6 +175,39 @@ module Pgbus
|
|
|
138
175
|
@logger.warn { "[Pgbus::Streamer::Instance] component stop raised: #{e.class}: #{e.message}" }
|
|
139
176
|
end
|
|
140
177
|
|
|
178
|
+
# Off-thread durable fanout writer (issue #321). nil (the default) keeps
|
|
179
|
+
# fanout writes inline on the dispatcher thread. When on_dead fires
|
|
180
|
+
# (a write failed), the pump posts a DisconnectMessage onto the shared
|
|
181
|
+
# dispatch queue — the SAME explicit protocol prune_dead and the
|
|
182
|
+
# heartbeat use — so the dispatcher thread owns the lockless cursor +
|
|
183
|
+
# registry cleanup and the pump never touches dispatcher state.
|
|
184
|
+
# Wrap the autoscaler as a throttled Listener maintenance task (issue
|
|
185
|
+
# #323), so headroom is read on the Listener's existing idle LISTEN
|
|
186
|
+
# connection every streams_pool_autoscale_interval seconds. nil (no
|
|
187
|
+
# autoscaler) means the Listener runs no maintenance.
|
|
188
|
+
def build_autoscale_maintenance
|
|
189
|
+
return nil unless @autoscaler
|
|
190
|
+
|
|
191
|
+
Pgbus::Streams::PoolAutoscaler::Maintenance.new(
|
|
192
|
+
autoscaler: @autoscaler,
|
|
193
|
+
interval: @config.streams_pool_autoscale_interval,
|
|
194
|
+
application_name_prefix: @config.streams_application_name
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def build_pump
|
|
199
|
+
threads = @config.streams_writer_threads
|
|
200
|
+
return nil unless threads.positive?
|
|
201
|
+
|
|
202
|
+
OutboundPump.new(
|
|
203
|
+
threads: threads,
|
|
204
|
+
ack_queue: @ack_queue,
|
|
205
|
+
on_dead: ->(conn) { @dispatch_queue << StreamEventDispatcher::DisconnectMessage.new(connection: conn) },
|
|
206
|
+
buffer_limit: @config.streams_writer_buffer_limit,
|
|
207
|
+
logger: @logger
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
141
211
|
def close_all_connections
|
|
142
212
|
sentinel_bytes = Pgbus::Streams::Envelope.message(
|
|
143
213
|
id: 0,
|
|
@@ -11,11 +11,17 @@ module Pgbus
|
|
|
11
11
|
# pattern.
|
|
12
12
|
#
|
|
13
13
|
# The write loop uses write_nonblock + IO.select so a slow client at most
|
|
14
|
-
# stalls
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
14
|
+
# stalls the CALLING thread's mutex-protected write for `deadline_ms`.
|
|
15
|
+
# During fanout the dispatcher IS that caller, writing to each connection
|
|
16
|
+
# serially, so K slow-but-not-yet-dead clients stack the deadline
|
|
17
|
+
# (~K * deadline_ms) before each is marked dead — the head-of-line block.
|
|
18
|
+
# Fanout writes therefore pass the SHORT streams_fanout_write_deadline_ms
|
|
19
|
+
# (not streams_write_deadline_ms) to bound that stall (issue #315 item 3).
|
|
20
|
+
# When the deadline expires with bytes still pending, we return :blocked;
|
|
21
|
+
# the caller (Connection#enqueue or Connection#write_comment) translates
|
|
22
|
+
# that into mark_dead!, and the heartbeat sweep unregisters the
|
|
23
|
+
# connection — the client then reconnects and replays the gap from the
|
|
24
|
+
# durable archive.
|
|
19
25
|
#
|
|
20
26
|
# Returns:
|
|
21
27
|
# :ok — all bytes written
|