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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -7
  3. data/README.md +34 -7
  4. data/Rakefile +22 -1
  5. data/app/models/pgbus/stream_queue.rb +87 -0
  6. data/lib/generators/pgbus/add_stream_queues_generator.rb +50 -0
  7. data/lib/generators/pgbus/install_generator.rb +3 -3
  8. data/lib/generators/pgbus/templates/add_stream_queues.rb.erb +11 -0
  9. data/lib/generators/pgbus/templates/initializer.rb.erb +62 -0
  10. data/lib/generators/pgbus/templates/migration.rb.erb +14 -0
  11. data/lib/generators/pgbus/update_generator.rb +6 -66
  12. data/lib/pgbus/client/ensure_stream_queue.rb +15 -1
  13. data/lib/pgbus/client/read_after.rb +7 -6
  14. data/lib/pgbus/client/resizable_pool.rb +160 -0
  15. data/lib/pgbus/client.rb +200 -1
  16. data/lib/pgbus/configuration.rb +170 -9
  17. data/lib/pgbus/doctor.rb +49 -2
  18. data/lib/pgbus/engine.rb +30 -3
  19. data/lib/pgbus/generators/migration_detector.rb +7 -0
  20. data/lib/pgbus/mcp/rack_app.rb +18 -6
  21. data/lib/pgbus/process/dispatcher.rb +105 -23
  22. data/lib/pgbus/process/worker.rb +9 -0
  23. data/lib/pgbus/streams/pool_autoscaler.rb +202 -0
  24. data/lib/pgbus/streams/pool_trigger.rb +124 -0
  25. data/lib/pgbus/streams/turbo_broadcastable.rb +23 -0
  26. data/lib/pgbus/streams.rb +2 -2
  27. data/lib/pgbus/version.rb +1 -1
  28. data/lib/pgbus/web/streamer/connection.rb +22 -6
  29. data/lib/pgbus/web/streamer/falcon_connection.rb +17 -5
  30. data/lib/pgbus/web/streamer/instance.rb +75 -5
  31. data/lib/pgbus/web/streamer/io_writer.rb +11 -5
  32. data/lib/pgbus/web/streamer/listener.rb +61 -1
  33. data/lib/pgbus/web/streamer/outbound_pump.rb +260 -0
  34. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +89 -7
  35. metadata +9 -4
  36. data/lib/generators/pgbus/templates/pgbus.yml.erb +0 -76
  37. data/lib/pgbus/config_loader.rb +0 -73
  38. 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, :last_msg_id_sent, :context
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
- def enqueue(envelopes)
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: @write_deadline_ms)
71
+ result = @writer.write(self, bytes, deadline_ms: deadline_ms)
56
72
  if result == :ok
57
- @last_msg_id_sent = envelope.msg_id unless ephemeral
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, :last_msg_id_sent, :context
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
- def enqueue(envelopes)
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 = envelope.msg_id
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. Send pgbus:shutdown sentinel to every connection and close
116
- # their sockets. We do this AFTER stopping the dispatcher so
117
- # no one else is writing to these IOs concurrently.
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 *its own* mutex-protected write for `deadline_ms`, never the
15
- # dispatcher or heartbeat thread. When the deadline expires with bytes
16
- # still pending, we return :blocked; the caller (Connection#enqueue or
17
- # Connection#write_comment) translates that into mark_dead!, and the
18
- # heartbeat sweep eventually unregisters the connection.
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:, logger: Pgbus.logger)
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