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
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
module Streamer
|
|
6
|
+
# Off-thread durable-stream fanout writer (issue #321).
|
|
7
|
+
#
|
|
8
|
+
# The dispatcher must never block on a slow client's socket write. When
|
|
9
|
+
# streams_writer_threads > 0, StreamEventDispatcher#handle_durable_wake
|
|
10
|
+
# hands each (connection, filtered envelopes, batch_max) to this pump
|
|
11
|
+
# instead of writing inline. The pump owns N worker threads; each
|
|
12
|
+
# connection is pinned to ONE worker by `id.hash % N`, so a connection's
|
|
13
|
+
# frames stay strictly ordered and its per-io mutex is never contended
|
|
14
|
+
# across workers.
|
|
15
|
+
#
|
|
16
|
+
# The pump calls the UNCHANGED Connection#enqueue on a worker thread (the
|
|
17
|
+
# blocking write, last_msg_id_sent advance, and mark_dead! all move off
|
|
18
|
+
# the dispatcher), then reports back:
|
|
19
|
+
# - success → a WriteAckMessage(connection, accepted_max) onto ack_queue.
|
|
20
|
+
# accepted_max is the highest msg_id actually written (or
|
|
21
|
+
# the batch_max for a fully-filtered empty batch, so the
|
|
22
|
+
# dispatcher's scan cursor still advances past the hidden
|
|
23
|
+
# window). The dispatcher — the SOLE owner of @scanned_cursor
|
|
24
|
+
# — applies it on its own thread. This keeps the lockless
|
|
25
|
+
# single-owner invariant intact: the writer only reports a
|
|
26
|
+
# value, it never mutates dispatcher state.
|
|
27
|
+
# - failure → the injected on_dead callback (which posts a
|
|
28
|
+
# DisconnectMessage), NOT an ack. A failed write must never
|
|
29
|
+
# advance the cursor past a frame that never reached the
|
|
30
|
+
# socket (issue #321 B2). The dispatcher then scrubs the
|
|
31
|
+
# connection's state deterministically (B4), even on an
|
|
32
|
+
# otherwise-quiet stream.
|
|
33
|
+
#
|
|
34
|
+
# EPHEMERAL frames are NEVER routed here — they have no archive to replay,
|
|
35
|
+
# so an async drop would be unrecoverable (issue #321 B1). #post raises
|
|
36
|
+
# ArgumentError on a negative msg_id as a defense-in-depth guard against a
|
|
37
|
+
# future refactor accidentally offloading one.
|
|
38
|
+
class OutboundPump
|
|
39
|
+
WriteJob = Data.define(:connection, :envelopes, :batch_max, :deadline_ms)
|
|
40
|
+
private_constant :WriteJob
|
|
41
|
+
|
|
42
|
+
DRAIN = :__drain__
|
|
43
|
+
|
|
44
|
+
def initialize(threads:, ack_queue:, on_dead:, buffer_limit: 0, logger: Pgbus.logger)
|
|
45
|
+
raise ArgumentError, "threads must be positive" unless threads.positive?
|
|
46
|
+
|
|
47
|
+
@ack_queue = ack_queue
|
|
48
|
+
@on_dead = on_dead
|
|
49
|
+
@buffer_limit = buffer_limit
|
|
50
|
+
@logger = logger
|
|
51
|
+
# One partition per worker. Each is a Partition wrapping a bounded
|
|
52
|
+
# per-connection buffer so the drop-oldest policy is per connection,
|
|
53
|
+
# not per partition (a fast connection can't be starved by a slow one
|
|
54
|
+
# sharing its worker beyond ordering).
|
|
55
|
+
@partitions = Array.new(threads) { Partition.new(buffer_limit, @logger) }
|
|
56
|
+
@threads = []
|
|
57
|
+
@started = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def start
|
|
61
|
+
return self if @started
|
|
62
|
+
|
|
63
|
+
@started = true
|
|
64
|
+
@partitions.each do |partition|
|
|
65
|
+
@threads << Thread.new { run_worker(partition) }
|
|
66
|
+
end
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# True while any writer thread is still alive. Lets callers assert the
|
|
71
|
+
# pump's OWN threads stopped after #stop without inspecting the global
|
|
72
|
+
# Thread.list (which is noisy and can't distinguish a pump leak from
|
|
73
|
+
# unrelated thread churn).
|
|
74
|
+
def alive?
|
|
75
|
+
@threads.any?(&:alive?)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Snapshot of the pump's live writer threads — for test introspection.
|
|
79
|
+
def worker_threads
|
|
80
|
+
@threads.dup
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Hand a durable fanout write to the pump. Returns immediately — the
|
|
84
|
+
# dispatcher does not block. Raises on a negative (ephemeral) msg_id.
|
|
85
|
+
def post(connection, envelopes, batch_max, deadline_ms:)
|
|
86
|
+
if envelopes.any? { |e| e.msg_id.negative? }
|
|
87
|
+
raise ArgumentError,
|
|
88
|
+
"OutboundPump received an ephemeral (negative msg_id) envelope; " \
|
|
89
|
+
"ephemeral fanout must stay inline on the dispatcher thread (issue #321 B1)"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
partition_for(connection).push(
|
|
93
|
+
WriteJob.new(connection: connection, envelopes: envelopes,
|
|
94
|
+
batch_max: batch_max, deadline_ms: deadline_ms)
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Graceful drain: signal every partition to flush what it holds, then
|
|
99
|
+
# join each worker bounded by the write deadline. Never Thread#kill — a
|
|
100
|
+
# kill mid write_nonblock corrupts IO state (mirrors
|
|
101
|
+
# StreamEventDispatcher#stop). Idempotent.
|
|
102
|
+
def stop
|
|
103
|
+
return self unless @started
|
|
104
|
+
|
|
105
|
+
@started = false
|
|
106
|
+
@partitions.each { |p| p.push(DRAIN) }
|
|
107
|
+
@threads.each do |t|
|
|
108
|
+
next if t.join(join_timeout_seconds)
|
|
109
|
+
|
|
110
|
+
@logger.warn { "[Pgbus::Streamer::OutboundPump] writer thread did not drain within #{join_timeout_seconds}s" }
|
|
111
|
+
end
|
|
112
|
+
@threads.clear
|
|
113
|
+
self
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def partition_for(connection)
|
|
119
|
+
@partitions[connection.id.hash % @partitions.size]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def join_timeout_seconds
|
|
123
|
+
5
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def run_worker(partition)
|
|
127
|
+
loop do
|
|
128
|
+
job = partition.pop
|
|
129
|
+
break if job == DRAIN
|
|
130
|
+
|
|
131
|
+
process(job)
|
|
132
|
+
end
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
@logger.error { "[Pgbus::Streamer::OutboundPump] worker crashed: #{e.class}: #{e.message}" }
|
|
135
|
+
raise
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def process(job)
|
|
139
|
+
conn = job.connection
|
|
140
|
+
return if conn.dead?
|
|
141
|
+
|
|
142
|
+
written = conn.enqueue(job.envelopes, deadline_ms: job.deadline_ms)
|
|
143
|
+
|
|
144
|
+
if conn.dead?
|
|
145
|
+
@on_dead.call(conn)
|
|
146
|
+
else
|
|
147
|
+
@ack_queue << StreamEventDispatcher::WriteAckMessage.new(
|
|
148
|
+
connection: conn,
|
|
149
|
+
accepted_max: accepted_max_for(job, written)
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
@logger.error { "[Pgbus::Streamer::OutboundPump] write failed for #{job.connection.id}: #{e.class}: #{e.message}" }
|
|
154
|
+
safe_mark_dead(job.connection)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The scan cursor may advance to:
|
|
158
|
+
# - the highest msg_id actually written, or
|
|
159
|
+
# - the batch_max when the filtered batch was empty (advance past the
|
|
160
|
+
# audience-hidden window so read_after moves forward), or
|
|
161
|
+
# - the connection's current cursor when every frame was a dedup
|
|
162
|
+
# no-op (a max-guarded no-op on the dispatcher side).
|
|
163
|
+
def accepted_max_for(job, written)
|
|
164
|
+
return job.batch_max if job.envelopes.empty?
|
|
165
|
+
|
|
166
|
+
written.map(&:msg_id).max || job.connection.last_msg_id_sent
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def safe_mark_dead(connection)
|
|
170
|
+
connection.mark_dead!
|
|
171
|
+
@on_dead.call(connection)
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
@logger.debug { "[Pgbus::Streamer::OutboundPump] on_dead failed: #{e.class}: #{e.message}" }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# A worker's inbox: a control channel (for the DRAIN sentinel, always
|
|
177
|
+
# delivered) plus a bounded per-connection buffer. When buffer_limit is
|
|
178
|
+
# positive and a connection's pending frames exceed it, the OLDEST
|
|
179
|
+
# durable frame for that connection is dropped — safe because durable
|
|
180
|
+
# frames are archive-recoverable on reconnect (issue #321). A drop is
|
|
181
|
+
# logged (throttled) so it isn't silent.
|
|
182
|
+
class Partition
|
|
183
|
+
# Total durable frames this partition has dropped under buffer_limit.
|
|
184
|
+
# Monotonic; read by tests and folded into a throttled operator log.
|
|
185
|
+
attr_reader :dropped
|
|
186
|
+
|
|
187
|
+
def initialize(buffer_limit, logger)
|
|
188
|
+
@buffer_limit = buffer_limit
|
|
189
|
+
@logger = logger
|
|
190
|
+
@jobs = [] # FIFO of WriteJob (and the DRAIN sentinel)
|
|
191
|
+
@pending = Hash.new(0) # connection.id → buffered frame count
|
|
192
|
+
@mutex = Mutex.new
|
|
193
|
+
@cond = ConditionVariable.new
|
|
194
|
+
@dropped = 0
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def push(job)
|
|
198
|
+
@mutex.synchronize do
|
|
199
|
+
if job == DRAIN
|
|
200
|
+
@jobs << job
|
|
201
|
+
else
|
|
202
|
+
enforce_limit(job)
|
|
203
|
+
@jobs << job
|
|
204
|
+
@pending[job.connection.id] += job.envelopes.size
|
|
205
|
+
end
|
|
206
|
+
@cond.signal
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def pop
|
|
211
|
+
@mutex.synchronize do
|
|
212
|
+
@cond.wait(@mutex) while @jobs.empty?
|
|
213
|
+
job = @jobs.shift
|
|
214
|
+
@pending[job.connection.id] -= job.envelopes.size unless job == DRAIN
|
|
215
|
+
job
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
|
|
221
|
+
# Caller holds @mutex. Drop the oldest still-buffered frames for this
|
|
222
|
+
# connection until adding `job`'s frames keeps it at/under the cap.
|
|
223
|
+
def enforce_limit(job)
|
|
224
|
+
return unless @buffer_limit.positive?
|
|
225
|
+
|
|
226
|
+
cid = job.connection.id
|
|
227
|
+
while @pending[cid] + job.envelopes.size > @buffer_limit
|
|
228
|
+
dropped = drop_oldest_for(cid)
|
|
229
|
+
break unless dropped # nothing left to drop → let it through
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Caller holds @mutex. Remove the oldest queued WriteJob for cid,
|
|
234
|
+
# decrementing the pending count. Returns the dropped job or nil.
|
|
235
|
+
# A drop is logged (throttled to powers of two so a sustained
|
|
236
|
+
# overflow can't flood the log) so lost frames aren't silent.
|
|
237
|
+
def drop_oldest_for(cid)
|
|
238
|
+
idx = @jobs.index { |j| j != DRAIN && j.connection.id == cid }
|
|
239
|
+
return nil unless idx
|
|
240
|
+
|
|
241
|
+
dropped = @jobs.delete_at(idx)
|
|
242
|
+
@pending[cid] -= dropped.envelopes.size
|
|
243
|
+
@dropped += 1
|
|
244
|
+
log_drop(cid) if @dropped.nobits?(@dropped - 1) # power-of-two throttle
|
|
245
|
+
dropped
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def log_drop(cid)
|
|
249
|
+
@logger.warn do
|
|
250
|
+
"[Pgbus::Streamer::OutboundPump] dropped #{@dropped} durable frame(s) " \
|
|
251
|
+
"under buffer_limit=#{@buffer_limit} (connection #{cid}) — the client " \
|
|
252
|
+
"replays them from the archive on reconnect"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
private_constant :Partition
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -35,6 +35,13 @@ module Pgbus
|
|
|
35
35
|
WakeMessage = Listener::WakeMessage
|
|
36
36
|
ConnectMessage = Data.define(:connection)
|
|
37
37
|
DisconnectMessage = Data.define(:connection)
|
|
38
|
+
# Posted by the OutboundPump (issue #321) after a successful off-thread
|
|
39
|
+
# durable fanout write, carrying the highest msg_id the writer actually
|
|
40
|
+
# committed for the connection. The dispatcher — the sole owner of
|
|
41
|
+
# @scanned_cursor — drains these via apply_acks and advances the scan
|
|
42
|
+
# cursor by accepted_max, so a failed/partial write never advances the
|
|
43
|
+
# cursor past a frame that didn't reach the socket.
|
|
44
|
+
WriteAckMessage = Data.define(:connection, :accepted_max)
|
|
38
45
|
# Posted by the Heartbeat once per tick with the current presence
|
|
39
46
|
# connections, so the touch (a last_seen_at refresh) runs on the
|
|
40
47
|
# dispatcher thread where AR connections are released each pass.
|
|
@@ -68,13 +75,21 @@ module Pgbus
|
|
|
68
75
|
def initialize(client:, registry:, listener:, dispatch_queue:,
|
|
69
76
|
logger: Pgbus.logger, read_limit: DEFAULT_READ_LIMIT,
|
|
70
77
|
filters: nil, config: nil, stream_counter: nil,
|
|
71
|
-
presence_provider: nil)
|
|
78
|
+
presence_provider: nil, pump: nil, ack_queue: nil)
|
|
72
79
|
@client = client
|
|
73
80
|
@registry = registry
|
|
74
81
|
@listener = listener
|
|
75
82
|
@queue = dispatch_queue
|
|
76
83
|
@logger = logger
|
|
77
84
|
@read_limit = read_limit
|
|
85
|
+
# Off-thread durable fanout (issue #321). When @pump is non-nil
|
|
86
|
+
# (streams_writer_threads > 0), handle_durable_wake hands each
|
|
87
|
+
# registered-connection write to the pump instead of writing inline;
|
|
88
|
+
# the pump reports back an accepted-max on @ack_queue, which the
|
|
89
|
+
# dispatcher drains in apply_acks to advance @scanned_cursor. When
|
|
90
|
+
# nil (default), fanout writes stay inline — the pre-#321 behavior.
|
|
91
|
+
@pump = pump
|
|
92
|
+
@ack_queue = ack_queue
|
|
78
93
|
# Vends a presence handle for a logical stream name. Injected so
|
|
79
94
|
# tests can record join/leave/touch without a DB. Production
|
|
80
95
|
# defaults to the real per-stream Presence via Pgbus.stream.
|
|
@@ -90,6 +105,14 @@ module Pgbus
|
|
|
90
105
|
# process-wide setting. Falls back to the global config
|
|
91
106
|
# for production call sites that don't specify one.
|
|
92
107
|
@config = config || Pgbus.configuration
|
|
108
|
+
# Two write deadlines (issue #315 item 3). Fanout writes run serially
|
|
109
|
+
# on this single thread, so a slow client stalls the connections
|
|
110
|
+
# queued behind it — the short fanout deadline bounds that stall.
|
|
111
|
+
# Connect-replay writes run once per subscribe (not in the hot loop),
|
|
112
|
+
# so a fresh client catching up from the archive keeps the full
|
|
113
|
+
# deadline and isn't spuriously evicted.
|
|
114
|
+
@fanout_write_deadline_ms = @config.streams_fanout_write_deadline_ms
|
|
115
|
+
@connect_write_deadline_ms = @config.streams_write_deadline_ms
|
|
93
116
|
@stream_counter = stream_counter || StreamCounter.new
|
|
94
117
|
# stream_name → Array<[connection, Array<Envelope>]>
|
|
95
118
|
@in_flight = Hash.new { |h, k| h[k] = [] }
|
|
@@ -149,6 +172,12 @@ module Pgbus
|
|
|
149
172
|
|
|
150
173
|
def run_loop
|
|
151
174
|
while @running
|
|
175
|
+
# Apply any writer acks before doing anything else, so the scan
|
|
176
|
+
# cursor is current for the wake we're about to process — without
|
|
177
|
+
# this top-of-loop drain a wake burst would compute minimum_cursor
|
|
178
|
+
# from a stale floor and re-read/re-filter the whole un-acked
|
|
179
|
+
# window every wake (the re-read storm, issue #321).
|
|
180
|
+
apply_acks
|
|
152
181
|
msg = @queue.pop
|
|
153
182
|
break if msg == :__stop__
|
|
154
183
|
|
|
@@ -227,6 +256,9 @@ module Pgbus
|
|
|
227
256
|
end
|
|
228
257
|
|
|
229
258
|
def handle_durable_wake(stream, registered, in_flight_pairs, started_at)
|
|
259
|
+
# Drain writer acks before computing the read floor so a burst of
|
|
260
|
+
# wakes doesn't re-read the un-acked window (issue #321).
|
|
261
|
+
apply_acks
|
|
230
262
|
min_seen = minimum_cursor(registered, in_flight_pairs)
|
|
231
263
|
raw_envelopes = @client.read_after(stream, after_id: min_seen, limit: @read_limit)
|
|
232
264
|
return if raw_envelopes.empty?
|
|
@@ -235,8 +267,18 @@ module Pgbus
|
|
|
235
267
|
max_msg_id = envelopes.map(&:msg_id).max
|
|
236
268
|
|
|
237
269
|
registered.each do |conn|
|
|
238
|
-
|
|
239
|
-
|
|
270
|
+
visible = visible_envelopes_for(envelopes, conn)
|
|
271
|
+
if @pump
|
|
272
|
+
# Off-thread: hand the write to the pump and DEFER the scan-cursor
|
|
273
|
+
# advance to the ack (which carries the writer's accepted max).
|
|
274
|
+
# Post even when `visible` is empty, with batch max, so an
|
|
275
|
+
# audience-filtered-away batch still advances the cursor past the
|
|
276
|
+
# hidden window (issue #321 B2). The pump raises on ephemerals.
|
|
277
|
+
@pump.post(conn, visible, max_msg_id, deadline_ms: @fanout_write_deadline_ms)
|
|
278
|
+
else
|
|
279
|
+
safe_enqueue(conn, visible)
|
|
280
|
+
advance_scanned_cursor(conn, max_msg_id)
|
|
281
|
+
end
|
|
240
282
|
end
|
|
241
283
|
in_flight_pairs.each do |(conn, buffer)|
|
|
242
284
|
buffer.concat(visible_envelopes_for(envelopes, conn))
|
|
@@ -321,13 +363,18 @@ module Pgbus
|
|
|
321
363
|
limit: @read_limit
|
|
322
364
|
)
|
|
323
365
|
initial = raw_initial.map { |e| unwrap_stream_envelope(e) }
|
|
324
|
-
|
|
366
|
+
# Connect-replay (steps 3 & 4) uses the FULL write deadline, not the
|
|
367
|
+
# short fanout one: this runs once per subscribe, outside the serial
|
|
368
|
+
# hot loop, and a new client catching up a large archive backlog must
|
|
369
|
+
# not be killed by the 250ms fanout budget (issue #315 item 3).
|
|
370
|
+
safe_enqueue(connection, visible_envelopes_for(initial, connection),
|
|
371
|
+
deadline_ms: @connect_write_deadline_ms)
|
|
325
372
|
|
|
326
373
|
# Step 4: drain the in-flight buffer (anything published between
|
|
327
374
|
# step 2 and now). Connection#enqueue dedupes by cursor, so
|
|
328
375
|
# overlap with step 3 is safe. The buffer entries were already
|
|
329
376
|
# filtered when enqueued by handle_wake, so no re-filter here.
|
|
330
|
-
safe_enqueue(connection, buffer)
|
|
377
|
+
safe_enqueue(connection, buffer, deadline_ms: @connect_write_deadline_ms)
|
|
331
378
|
|
|
332
379
|
# Step 5: promote to the main registry. From this point the
|
|
333
380
|
# regular WakeMessage path handles the connection. If the
|
|
@@ -440,11 +487,46 @@ module Pgbus
|
|
|
440
487
|
@scanned_cursor[connection] = msg_id if msg_id > current
|
|
441
488
|
end
|
|
442
489
|
|
|
443
|
-
|
|
490
|
+
# Drain every pending writer ack and advance the scan cursor by the
|
|
491
|
+
# writer's accepted max (issue #321). Runs ONLY on the dispatcher
|
|
492
|
+
# thread, so @scanned_cursor keeps its single-owner invariant — the
|
|
493
|
+
# pump only reports a value via @ack_queue, it never mutates cursor
|
|
494
|
+
# state. Non-blocking: stops when the queue is empty. A no-op when
|
|
495
|
+
# offload is off (@ack_queue nil). advance_scanned_cursor is
|
|
496
|
+
# monotonic-max-guarded, so a re-applied or cross-connection-reordered
|
|
497
|
+
# ack is idempotent.
|
|
498
|
+
#
|
|
499
|
+
# Drops a stale ack for a connection that is no longer registered: the
|
|
500
|
+
# ack queue is drained independently of DisconnectMessages, so a
|
|
501
|
+
# successful-write ack can arrive AFTER handle_disconnect has already
|
|
502
|
+
# deleted @scanned_cursor[conn] (e.g. the heartbeat swept an idle client
|
|
503
|
+
# mid-write). Advancing the cursor then would resurrect the connection
|
|
504
|
+
# in @scanned_cursor and leak that entry forever. The equal? check also
|
|
505
|
+
# rejects an ack for a since-replaced connection object sharing the same
|
|
506
|
+
# id.
|
|
507
|
+
def apply_acks
|
|
508
|
+
return unless @ack_queue
|
|
509
|
+
|
|
510
|
+
loop do
|
|
511
|
+
ack = @ack_queue.pop(true)
|
|
512
|
+
next unless @registry.lookup(ack.connection.id).equal?(ack.connection)
|
|
513
|
+
|
|
514
|
+
advance_scanned_cursor(ack.connection, ack.accepted_max)
|
|
515
|
+
rescue ThreadError
|
|
516
|
+
break # queue drained
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# deadline_ms defaults to the SHORT fanout deadline: every hot-loop
|
|
521
|
+
# fanout call site (handle_durable_wake, handle_ephemeral_wake) uses
|
|
522
|
+
# it implicitly. handle_connect passes @connect_write_deadline_ms
|
|
523
|
+
# explicitly so a new client's initial replay keeps the full window
|
|
524
|
+
# (issue #315 item 3).
|
|
525
|
+
def safe_enqueue(connection, envelopes_or_buffer, deadline_ms: @fanout_write_deadline_ms)
|
|
444
526
|
return if connection.dead?
|
|
445
527
|
return if envelopes_or_buffer.empty?
|
|
446
528
|
|
|
447
|
-
connection.enqueue(envelopes_or_buffer)
|
|
529
|
+
connection.enqueue(envelopes_or_buffer, deadline_ms: deadline_ms)
|
|
448
530
|
end
|
|
449
531
|
|
|
450
532
|
def prune_dead(connections)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pgbus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -158,6 +158,7 @@ files:
|
|
|
158
158
|
- app/models/pgbus/recurring_execution.rb
|
|
159
159
|
- app/models/pgbus/recurring_task.rb
|
|
160
160
|
- app/models/pgbus/semaphore.rb
|
|
161
|
+
- app/models/pgbus/stream_queue.rb
|
|
161
162
|
- app/models/pgbus/stream_stat.rb
|
|
162
163
|
- app/models/pgbus/uniqueness_key.rb
|
|
163
164
|
- app/views/layouts/pgbus/application.html.erb
|
|
@@ -215,6 +216,7 @@ files:
|
|
|
215
216
|
- lib/generators/pgbus/add_presence_generator.rb
|
|
216
217
|
- lib/generators/pgbus/add_queue_states_generator.rb
|
|
217
218
|
- lib/generators/pgbus/add_recurring_generator.rb
|
|
219
|
+
- lib/generators/pgbus/add_stream_queues_generator.rb
|
|
218
220
|
- lib/generators/pgbus/add_stream_stats_generator.rb
|
|
219
221
|
- lib/generators/pgbus/add_uniqueness_keys_generator.rb
|
|
220
222
|
- lib/generators/pgbus/install_generator.rb
|
|
@@ -228,11 +230,12 @@ files:
|
|
|
228
230
|
- lib/generators/pgbus/templates/add_presence.rb.erb
|
|
229
231
|
- lib/generators/pgbus/templates/add_queue_states.rb.erb
|
|
230
232
|
- lib/generators/pgbus/templates/add_recurring_tables.rb.erb
|
|
233
|
+
- lib/generators/pgbus/templates/add_stream_queues.rb.erb
|
|
231
234
|
- lib/generators/pgbus/templates/add_stream_stats.rb.erb
|
|
232
235
|
- lib/generators/pgbus/templates/add_uniqueness_keys.rb.erb
|
|
236
|
+
- lib/generators/pgbus/templates/initializer.rb.erb
|
|
233
237
|
- lib/generators/pgbus/templates/migrate_job_locks_to_uniqueness_keys.rb.erb
|
|
234
238
|
- lib/generators/pgbus/templates/migration.rb.erb
|
|
235
|
-
- lib/generators/pgbus/templates/pgbus.yml.erb
|
|
236
239
|
- lib/generators/pgbus/templates/pgbus_binstub.erb
|
|
237
240
|
- lib/generators/pgbus/templates/recurring.yml.erb
|
|
238
241
|
- lib/generators/pgbus/templates/tune_autovacuum.rb.erb
|
|
@@ -256,10 +259,10 @@ files:
|
|
|
256
259
|
- lib/pgbus/client/ensure_stream_queue.rb
|
|
257
260
|
- lib/pgbus/client/notify_stream.rb
|
|
258
261
|
- lib/pgbus/client/read_after.rb
|
|
262
|
+
- lib/pgbus/client/resizable_pool.rb
|
|
259
263
|
- lib/pgbus/concurrency.rb
|
|
260
264
|
- lib/pgbus/concurrency/blocked_execution.rb
|
|
261
265
|
- lib/pgbus/concurrency/semaphore.rb
|
|
262
|
-
- lib/pgbus/config_loader.rb
|
|
263
266
|
- lib/pgbus/configuration.rb
|
|
264
267
|
- lib/pgbus/configuration/capsule_dsl.rb
|
|
265
268
|
- lib/pgbus/dedup_cache.rb
|
|
@@ -275,7 +278,6 @@ files:
|
|
|
275
278
|
- lib/pgbus/execution_pools/async_pool.rb
|
|
276
279
|
- lib/pgbus/execution_pools/thread_pool.rb
|
|
277
280
|
- lib/pgbus/failed_event_recorder.rb
|
|
278
|
-
- lib/pgbus/generators/config_converter.rb
|
|
279
281
|
- lib/pgbus/generators/database_target_detector.rb
|
|
280
282
|
- lib/pgbus/generators/migration_detector.rb
|
|
281
283
|
- lib/pgbus/instrumentation.rb
|
|
@@ -350,6 +352,8 @@ files:
|
|
|
350
352
|
- lib/pgbus/streams/envelope.rb
|
|
351
353
|
- lib/pgbus/streams/filters.rb
|
|
352
354
|
- lib/pgbus/streams/key.rb
|
|
355
|
+
- lib/pgbus/streams/pool_autoscaler.rb
|
|
356
|
+
- lib/pgbus/streams/pool_trigger.rb
|
|
353
357
|
- lib/pgbus/streams/presence.rb
|
|
354
358
|
- lib/pgbus/streams/renderer.rb
|
|
355
359
|
- lib/pgbus/streams/signed_name.rb
|
|
@@ -378,6 +382,7 @@ files:
|
|
|
378
382
|
- lib/pgbus/web/streamer/instance.rb
|
|
379
383
|
- lib/pgbus/web/streamer/io_writer.rb
|
|
380
384
|
- lib/pgbus/web/streamer/listener.rb
|
|
385
|
+
- lib/pgbus/web/streamer/outbound_pump.rb
|
|
381
386
|
- lib/pgbus/web/streamer/registry.rb
|
|
382
387
|
- lib/pgbus/web/streamer/stream_counter.rb
|
|
383
388
|
- lib/pgbus/web/streamer/stream_event_dispatcher.rb
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
# Pgbus configuration
|
|
2
|
-
# https://github.com/mhenrixon/pgbus
|
|
3
|
-
|
|
4
|
-
default: &default
|
|
5
|
-
# Queue prefix for all PGMQ queues
|
|
6
|
-
queue_prefix: pgbus
|
|
7
|
-
|
|
8
|
-
# Default queue name (without prefix)
|
|
9
|
-
default_queue: default
|
|
10
|
-
|
|
11
|
-
# Connection pool for PGMQ client.
|
|
12
|
-
# pool_size auto-tunes from your worker thread counts:
|
|
13
|
-
# sum(workers.threads) + sum(event_consumers.threads) + 2
|
|
14
|
-
# Set explicitly only if you need a tighter or looser pool than that.
|
|
15
|
-
# pool_size: 5
|
|
16
|
-
pool_timeout: 5
|
|
17
|
-
|
|
18
|
-
# Use PostgreSQL LISTEN/NOTIFY for instant job wake-up
|
|
19
|
-
listen_notify: true
|
|
20
|
-
|
|
21
|
-
# Visibility timeout in seconds (how long a message is invisible after being read)
|
|
22
|
-
visibility_timeout: 30
|
|
23
|
-
|
|
24
|
-
# Dead letter queue
|
|
25
|
-
max_retries: 5
|
|
26
|
-
|
|
27
|
-
# Idempotency deduplication TTL (seconds)
|
|
28
|
-
idempotency_ttl: 604800 # 7 days
|
|
29
|
-
|
|
30
|
-
# Worker definitions
|
|
31
|
-
workers:
|
|
32
|
-
- queues:
|
|
33
|
-
- critical
|
|
34
|
-
- default
|
|
35
|
-
threads: 5
|
|
36
|
-
- queues:
|
|
37
|
-
- low
|
|
38
|
-
threads: 2
|
|
39
|
-
|
|
40
|
-
# Worker recycling (prevents memory bloat)
|
|
41
|
-
max_jobs_per_worker: 10000
|
|
42
|
-
max_memory_mb: 512
|
|
43
|
-
# max_worker_lifetime: 3600 # seconds
|
|
44
|
-
|
|
45
|
-
# Dispatcher settings (maintenance tasks)
|
|
46
|
-
dispatch_interval: 1.0
|
|
47
|
-
|
|
48
|
-
# Event consumers (uncomment to enable event bus)
|
|
49
|
-
# event_consumers:
|
|
50
|
-
# - topics:
|
|
51
|
-
# - "orders.#"
|
|
52
|
-
# threads: 3
|
|
53
|
-
# - topics:
|
|
54
|
-
# - "notifications.#"
|
|
55
|
-
# threads: 1
|
|
56
|
-
|
|
57
|
-
development:
|
|
58
|
-
<<: *default
|
|
59
|
-
workers:
|
|
60
|
-
- queues:
|
|
61
|
-
- default
|
|
62
|
-
threads: 2
|
|
63
|
-
max_jobs_per_worker: ~
|
|
64
|
-
max_memory_mb: ~
|
|
65
|
-
|
|
66
|
-
test:
|
|
67
|
-
<<: *default
|
|
68
|
-
workers:
|
|
69
|
-
- queues:
|
|
70
|
-
- default
|
|
71
|
-
threads: 1
|
|
72
|
-
polling_interval: 0.01
|
|
73
|
-
visibility_timeout: 5
|
|
74
|
-
|
|
75
|
-
production:
|
|
76
|
-
<<: *default
|
data/lib/pgbus/config_loader.rb
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
require "erb"
|
|
5
|
-
|
|
6
|
-
module Pgbus
|
|
7
|
-
module ConfigLoader
|
|
8
|
-
module_function
|
|
9
|
-
|
|
10
|
-
def load(path, env: nil)
|
|
11
|
-
env ||= (defined?(Rails) && Rails.respond_to?(:env) && Rails.env) || ENV.fetch("PGBUS_ENV", "development")
|
|
12
|
-
raw = File.read(path)
|
|
13
|
-
parsed = YAML.safe_load(ERB.new(raw).result, permitted_classes: [Symbol], aliases: true)
|
|
14
|
-
# Distinguish sectioned (top-level env keys mapping to Hashes) from
|
|
15
|
-
# flat (top-level setter keys mapping to scalars/arrays). parsed.key?(env)
|
|
16
|
-
# alone can't tell them apart, so a flat file silently lost its typo
|
|
17
|
-
# warnings whenever no env section happened to match its keys.
|
|
18
|
-
if sectioned?(parsed)
|
|
19
|
-
if parsed.key?(env)
|
|
20
|
-
apply(parsed.fetch(env))
|
|
21
|
-
else
|
|
22
|
-
warn_missing_env_section(parsed, env)
|
|
23
|
-
end
|
|
24
|
-
else
|
|
25
|
-
apply(parsed)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# A file is "sectioned" (top-level keys are env names, e.g. development:/
|
|
30
|
-
# production:) rather than "flat" (top-level keys are Configuration
|
|
31
|
-
# setters) when every top-level value is a Hash AND none of the
|
|
32
|
-
# top-level keys is itself a real Configuration setter. The setter check
|
|
33
|
-
# is what distinguishes the two: env section names (production, staging,
|
|
34
|
-
# ...) are never real setters, so a legitimate flat file whose settings
|
|
35
|
-
# all happen to be Hash-valued (connection_params:, streams_retention:,
|
|
36
|
-
# recurring_tasks:, ...) is correctly classified as flat instead of being
|
|
37
|
-
# misread as sectioned-with-no-matching-env and silently skipped.
|
|
38
|
-
def sectioned?(parsed)
|
|
39
|
-
return false unless parsed.is_a?(Hash) && parsed.any? && parsed.values.all?(Hash)
|
|
40
|
-
|
|
41
|
-
config = Pgbus.configuration
|
|
42
|
-
parsed.keys.none? { |key| config.respond_to?(:"#{key}=") }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# A sectioned file with no section for the current env has nothing to
|
|
46
|
-
# apply for this process — that's a real gap (typo'd env name, or a
|
|
47
|
-
# config file that just doesn't cover this deployment), so it must be
|
|
48
|
-
# loud instead of a silent no-op.
|
|
49
|
-
def warn_missing_env_section(parsed, env)
|
|
50
|
-
Pgbus.configuration.logger.warn do
|
|
51
|
-
"[Pgbus] No configuration section for env #{env.inspect} — " \
|
|
52
|
-
"available sections: #{parsed.keys.inspect}. Nothing was applied."
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def apply(hash, warn_unknown: true)
|
|
57
|
-
config = Pgbus.configuration
|
|
58
|
-
hash.each do |key, value|
|
|
59
|
-
setter = :"#{key}="
|
|
60
|
-
if config.respond_to?(setter)
|
|
61
|
-
config.public_send(setter, value)
|
|
62
|
-
elsif warn_unknown
|
|
63
|
-
config.logger.warn { "[Pgbus] Unknown configuration key ignored: #{key.inspect} — check for typos in pgbus.yml" }
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
# Validate eagerly so a bad YAML value aborts boot with an ArgumentError
|
|
67
|
-
# naming the offending key, instead of failing later in a worker path.
|
|
68
|
-
# Honors an `eager_validation: false` key in the same hash (applied above).
|
|
69
|
-
config.validate! if config.eager_validation
|
|
70
|
-
config
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|