pgbus 0.9.9 → 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 +25 -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/mcp/rack_app.rb +18 -6
- data/lib/pgbus/process/dispatcher.rb +105 -23
- 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
|
@@ -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
|
|
@@ -48,8 +48,20 @@ module Pgbus
|
|
|
48
48
|
# Required — the reconnect loop always rebuilds rather than resetting a
|
|
49
49
|
# possibly-dead socket, so every caller (production and tests) must pass
|
|
50
50
|
# one.
|
|
51
|
+
# dispatch_queue_limit (issue #315 item 3): 0 = unbounded (default). A
|
|
52
|
+
# positive value makes handle_notify DROP a durable wake when the
|
|
53
|
+
# dispatch queue is at/over the cap — safe because the next durable
|
|
54
|
+
# wake for that stream re-reads from the min cursor. Ephemeral wakes
|
|
55
|
+
# and Connect/Disconnect messages are never dropped.
|
|
56
|
+
# `maintenance:` is an optional periodic callback (issue #323 pool
|
|
57
|
+
# autoscaler). On the listener's idle health-check window — the only place
|
|
58
|
+
# the non-thread-safe LISTEN connection is safely idle — it is invoked at
|
|
59
|
+
# most once per `maintenance.interval` seconds with THIS connection, so a
|
|
60
|
+
# maintenance query (e.g. pg_stat_activity headroom) reuses the existing
|
|
61
|
+
# idle connection instead of opening its own. nil disables it.
|
|
51
62
|
def initialize(pg_connection:, dispatch_queue:, health_check_ms:,
|
|
52
|
-
connection_factory:,
|
|
63
|
+
connection_factory:, dispatch_queue_limit: 0, maintenance: nil,
|
|
64
|
+
logger: Pgbus.logger, clock: nil)
|
|
53
65
|
raise ArgumentError, "connection_factory is required" unless connection_factory.respond_to?(:call)
|
|
54
66
|
|
|
55
67
|
@conn = pg_connection
|
|
@@ -57,12 +69,22 @@ module Pgbus
|
|
|
57
69
|
@health_check_ms = health_check_ms
|
|
58
70
|
@logger = logger
|
|
59
71
|
@connection_factory = connection_factory
|
|
72
|
+
@dispatch_queue_limit = dispatch_queue_limit
|
|
73
|
+
@maintenance = maintenance
|
|
74
|
+
@clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
|
|
75
|
+
@maintenance_last = nil
|
|
76
|
+
@dropped_wakes = 0
|
|
60
77
|
@listening_to = Set.new
|
|
61
78
|
@commands = Queue.new
|
|
62
79
|
@running = false
|
|
63
80
|
@thread = nil
|
|
64
81
|
end
|
|
65
82
|
|
|
83
|
+
# Count of durable wakes dropped due to the dispatch-queue cap. Read by
|
|
84
|
+
# tests and available for operator introspection. Only mutated on the
|
|
85
|
+
# Listener thread (in handle_notify), so no synchronization needed.
|
|
86
|
+
attr_reader :dropped_wakes
|
|
87
|
+
|
|
66
88
|
def start
|
|
67
89
|
return if @running
|
|
68
90
|
|
|
@@ -202,11 +224,49 @@ module Pgbus
|
|
|
202
224
|
queue_name = queue_name_from(channel)
|
|
203
225
|
return unless queue_name
|
|
204
226
|
|
|
227
|
+
# Backpressure applies ONLY to durable wakes (payload nil): they
|
|
228
|
+
# self-heal because the next durable wake for the stream re-reads
|
|
229
|
+
# from the min cursor. Ephemeral wakes (payload present) carry the
|
|
230
|
+
# only copy of their HTML with no archive to replay, so they are
|
|
231
|
+
# never dropped (issue #315 item 3). @dispatch_queue.size on a stdlib
|
|
232
|
+
# Queue is internally synchronized; this runs only on the Listener
|
|
233
|
+
# thread and touches no dispatcher-owned state.
|
|
234
|
+
if payload.nil? && @dispatch_queue_limit.positive? &&
|
|
235
|
+
@dispatch_queue.size >= @dispatch_queue_limit
|
|
236
|
+
@dropped_wakes += 1
|
|
237
|
+
if (@dropped_wakes % 100) == 1
|
|
238
|
+
@logger.warn do
|
|
239
|
+
"[Pgbus::Streamer::Listener] dispatch queue at limit " \
|
|
240
|
+
"(#{@dispatch_queue_limit}); dropped durable wake for " \
|
|
241
|
+
"#{queue_name} (total dropped: #{@dropped_wakes})"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
return
|
|
245
|
+
end
|
|
246
|
+
|
|
205
247
|
@dispatch_queue << WakeMessage.new(queue_name: queue_name, payload: payload)
|
|
206
248
|
end
|
|
207
249
|
|
|
208
250
|
def run_health_check
|
|
209
251
|
@conn.exec("SELECT 1")
|
|
252
|
+
run_maintenance
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Periodic maintenance on the idle LISTEN connection, throttled to
|
|
256
|
+
# @maintenance.interval (issue #323). Runs on the listener thread, so it
|
|
257
|
+
# is the only place the non-thread-safe @conn may be queried outside
|
|
258
|
+
# wait_for_notify. Fail-soft: a raising maintenance callback is logged and
|
|
259
|
+
# never disturbs the listen loop.
|
|
260
|
+
def run_maintenance
|
|
261
|
+
return unless @maintenance
|
|
262
|
+
|
|
263
|
+
now = @clock.call
|
|
264
|
+
return if @maintenance_last && (now - @maintenance_last) < @maintenance.interval
|
|
265
|
+
|
|
266
|
+
@maintenance_last = now
|
|
267
|
+
@maintenance.run(@conn)
|
|
268
|
+
rescue StandardError => e
|
|
269
|
+
@logger.debug { "[Pgbus::Streamer::Listener] maintenance raised: #{e.class}: #{e.message}" }
|
|
210
270
|
end
|
|
211
271
|
|
|
212
272
|
# Retry reconnect until we succeed (fresh conn + every channel
|
|
@@ -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
|