pgbus 0.9.10 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -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/process/dispatcher.rb +89 -19
  21. data/lib/pgbus/process/worker.rb +9 -0
  22. data/lib/pgbus/streams/pool_autoscaler.rb +202 -0
  23. data/lib/pgbus/streams/pool_trigger.rb +124 -0
  24. data/lib/pgbus/streams/turbo_broadcastable.rb +23 -0
  25. data/lib/pgbus/streams.rb +2 -2
  26. data/lib/pgbus/version.rb +1 -1
  27. data/lib/pgbus/web/streamer/connection.rb +22 -6
  28. data/lib/pgbus/web/streamer/falcon_connection.rb +17 -5
  29. data/lib/pgbus/web/streamer/instance.rb +75 -5
  30. data/lib/pgbus/web/streamer/io_writer.rb +11 -5
  31. data/lib/pgbus/web/streamer/listener.rb +61 -1
  32. data/lib/pgbus/web/streamer/outbound_pump.rb +260 -0
  33. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +89 -7
  34. metadata +9 -4
  35. data/lib/generators/pgbus/templates/pgbus.yml.erb +0 -76
  36. data/lib/pgbus/config_loader.rb +0 -73
  37. data/lib/pgbus/generators/config_converter.rb +0 -345
@@ -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
@@ -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
- safe_enqueue(conn, visible_envelopes_for(envelopes, conn))
239
- advance_scanned_cursor(conn, max_msg_id)
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
- safe_enqueue(connection, visible_envelopes_for(initial, connection))
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
- def safe_enqueue(connection, envelopes_or_buffer)
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.9.10
4
+ version: 0.9.11
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