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
@@ -407,10 +407,19 @@ module Pgbus
407
407
  dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
408
408
  prefix = "#{config.queue_prefix}_"
409
409
 
410
+ # Stream queues share the job namespace (pgbus_<name>) but must never
411
+ # be adopted by a wildcard worker: a worker would claim durable
412
+ # broadcasts, fail to deserialize them, and DLQ-move them out of the
413
+ # stream's replay history. The registry is what tells them apart.
414
+ # Reset first so a stream created since the last resolve is excluded.
415
+ Pgbus::StreamQueue.reset_cache!
416
+ stream_names = Pgbus::StreamQueue.all_names
417
+
410
418
  conn = Pgbus.configuration.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
411
419
  all_queues = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
412
420
  resolved = all_queues
413
421
  .reject { |q| q.end_with?(dlq_suffix) }
422
+ .reject { |q| stream_names.include?(q) }
414
423
  .map { |q| q.delete_prefix(prefix) }
415
424
 
416
425
  if resolved.empty?
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Streams
5
+ # Self-tuning decision logic for the dedicated streams DB pool (issue #323).
6
+ #
7
+ # A PURE DECISION OBJECT — it owns no thread and no connection. Two callers
8
+ # drive it, both feeding it a live-headroom reading:
9
+ # * the streamer's Listener, as a periodic maintenance check on its idle
10
+ # LISTEN connection (see Maintenance below); and
11
+ # * a publisher (Client#send_stream_message), throttled, on the job pool.
12
+ # #evaluate reads the streams pool's busy ratio, decides, and resizes via the
13
+ # #325 hot-swap primitive.
14
+ #
15
+ # Cadence is slow (streams_pool_autoscale_interval, default 5 min), so the
16
+ # interval itself is the debounce: it acts on each check with no multi-sample
17
+ # hysteresis and no cooldown. A sustained burst converges over a few checks
18
+ # (one grow step each); a swap's transient (a freshly-swapped connection_pool
19
+ # is lazy → reads busy ≈ 0) simply means "no shrink this check" and is gone by
20
+ # the next check minutes later.
21
+ #
22
+ # Decision priority per check:
23
+ # 1. EMERGENCY SHRINK — DB free connections critically low → resize to
24
+ # baseline now (protect the DB; overrides the busy signal).
25
+ # 2. GROW — pool saturated AND a fair share of real headroom exists.
26
+ # 3. SHRINK — pool idle → step toward baseline.
27
+ # 4. HOLD.
28
+ #
29
+ # SAFETY (proven in the #323 design; independent of cadence):
30
+ # * No multi-process exhaustion: four stacked grow guards (GROW_RESERVE
31
+ # gate, SAFETY peer inflation, STEP_MAX, per-process floor(free/2)).
32
+ # * No grow↔emergency limit cycle: GROW_RESERVE (0.20·maxc) ≥ 4×
33
+ # EMERGENCY_MARGIN (0.05·maxc) → a ~15% dead-zone.
34
+ class PoolAutoscaler
35
+ GROW_THRESHOLD = 0.85 # busy_ratio to grow
36
+ SHRINK_THRESHOLD = 0.30 # busy_ratio to shrink
37
+ FAIR_FRACTION = 0.25 # claim only ¼ of the computed fair share per grow
38
+ SAFETY = 1.5 # inflate peer count → deflate fair share (undercount guard)
39
+ STEP_MAX = 4 # hard cap on connections added per single grow
40
+
41
+ # SQL a caller runs on a connection to gather headroom. Public so the
42
+ # Listener/publisher can issue it; $1 is the application_name LIKE pattern.
43
+ HEADROOM_SQL = <<~SQL
44
+ SELECT current_setting('max_connections')::int AS maxc,
45
+ count(*) AS used,
46
+ count(DISTINCT application_name)
47
+ FILTER (WHERE application_name LIKE $1) AS peers
48
+ FROM pg_stat_activity
49
+ SQL
50
+
51
+ def initialize(client:, config:, logger: Pgbus.logger)
52
+ @client = client
53
+ @config = config
54
+ @logger = logger
55
+ @baseline = config.streams_pool_size
56
+ end
57
+
58
+ # One maintenance decision. `headroom` is {maxc:, used:, peers:} from a
59
+ # caller's query, or nil if it failed (→ HOLD). Returns the action taken
60
+ # (:emergency_shrink / :grow / :shrink / :hold) — handy for tests + logging.
61
+ #
62
+ # `allow_shrink:` gates the NORMAL idle shrink (priority 3). The streamer
63
+ # passes true (it sees the sustained idle picture across replay reads). A
64
+ # publisher passes false: it only ever reacts to its OWN publish pressure
65
+ # (grow), and leaves idle-shrink to the streamer/consumer. Emergency shrink
66
+ # is NEVER gated — a DB running out of connections must always be relieved,
67
+ # whoever notices first.
68
+ def evaluate(headroom, allow_shrink: true)
69
+ return :hold if headroom.nil?
70
+
71
+ free = headroom[:maxc] - headroom[:used]
72
+
73
+ # PRIORITY 1 — EMERGENCY SHRINK (keys off DB `free`, an external fact).
74
+ return emergency_shrink(free, headroom[:maxc]) if free < emergency_margin(headroom[:maxc])
75
+
76
+ size, busy_ratio = pool_busy
77
+ return :hold if size.nil?
78
+
79
+ if busy_ratio >= GROW_THRESHOLD
80
+ maybe_grow(size, free, headroom)
81
+ elsif allow_shrink && busy_ratio < SHRINK_THRESHOLD
82
+ maybe_shrink(size)
83
+ else
84
+ :hold
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ # PRIORITY 2 — GROW into a bounded fair share of live headroom.
91
+ def maybe_grow(size, free, headroom)
92
+ return :hold if free <= grow_reserve(headroom[:maxc]) # not enough headroom
93
+ return :hold if size <= 1 # a telemetry checkout would starve publishing
94
+
95
+ delta = grow_delta(free, headroom[:peers])
96
+ return :hold if delta < 1
97
+
98
+ target = size + delta
99
+ cap = @config.streams_pool_max
100
+ target = [target, cap].min if cap
101
+ return :hold if target <= size
102
+
103
+ act(size, target, :grow)
104
+ end
105
+
106
+ # PRIORITY 3 — SHRINK one step toward the baseline.
107
+ def maybe_shrink(size)
108
+ return :hold if size <= @baseline
109
+
110
+ act(size, [@baseline, size - STEP_MAX].max, :shrink)
111
+ end
112
+
113
+ def emergency_shrink(free, maxc)
114
+ return :hold if pool_current_size <= @baseline
115
+
116
+ if swapped?(@client.resize_streams_pool(@baseline))
117
+ @logger.warn do
118
+ "[Pgbus::Streams::PoolAutoscaler] EMERGENCY streams-pool shrink to " \
119
+ "#{@baseline} (free=#{free}/#{maxc})"
120
+ end
121
+ :emergency_shrink
122
+ else
123
+ # The DB is critically low on connections but the shrink was a no-op
124
+ # (unchanged / shared-AR). This is the one case where a failed resize
125
+ # matters most — leave a trace instead of silently reporting success.
126
+ @logger.warn do
127
+ "[Pgbus::Streams::PoolAutoscaler] EMERGENCY streams-pool shrink to " \
128
+ "#{@baseline} did NOT apply (free=#{free}/#{maxc}) — resize was a no-op"
129
+ end
130
+ :hold
131
+ end
132
+ end
133
+
134
+ # fair_share = free / (peers · SAFETY); claim FAIR_FRACTION, never more than
135
+ # STEP_MAX or half the remaining headroom (leave ≥half for unknown peers →
136
+ # geometric contraction → N processes can't collectively exhaust the DB).
137
+ def grow_delta(free, peers)
138
+ peer_count = [peers.to_i, 1].max
139
+ fair_share = free.to_f / (peer_count * SAFETY)
140
+ [(FAIR_FRACTION * fair_share).floor, STEP_MAX, (free / 2)].min
141
+ end
142
+
143
+ def act(from_size, target_size, kind)
144
+ if swapped?(@client.resize_streams_pool(target_size))
145
+ @logger.info { "[Pgbus::Streams::PoolAutoscaler] streams pool #{from_size}->#{target_size} (#{kind})" }
146
+ kind
147
+ else
148
+ :hold # unchanged / shared-AR no-op
149
+ end
150
+ end
151
+
152
+ # Margins self-derive from live max_connections; the 4× gap forbids a
153
+ # grow↔emergency limit cycle.
154
+ def emergency_margin(maxc) = [5, (0.05 * maxc).ceil].max
155
+ def grow_reserve(maxc) = [20, (0.20 * maxc).ceil].max
156
+
157
+ def pool_busy
158
+ stats = @client.streams_pool_stats
159
+ return [nil, nil] if stats.nil? || stats.empty?
160
+
161
+ size = stats[:size]
162
+ available = stats[:available]
163
+ return [nil, nil] if size.nil? || available.nil? || size <= 0
164
+
165
+ [size, (size - available).to_f / size]
166
+ end
167
+
168
+ def pool_current_size
169
+ stats = @client.streams_pool_stats
170
+ stats.is_a?(Hash) ? stats[:size].to_i : 0
171
+ end
172
+
173
+ def swapped?(result) = result.is_a?(Pgbus::Client::ResizablePool::SwapStats)
174
+
175
+ # Wraps an autoscaler as a Listener maintenance task: on each throttled idle
176
+ # window the Listener calls #run(conn); this queries live headroom on THAT
177
+ # connection (the streamer's existing LISTEN connection — no extra
178
+ # connection) and hands it to the autoscaler. `interval` is the throttle
179
+ # (seconds) the Listener honors between runs.
180
+ class Maintenance
181
+ attr_reader :interval
182
+
183
+ def initialize(autoscaler:, interval:, application_name_prefix:)
184
+ @autoscaler = autoscaler
185
+ @interval = interval
186
+ @like = "#{application_name_prefix}_%"
187
+ end
188
+
189
+ def run(conn)
190
+ row = conn.exec_params(HEADROOM_SQL, [@like]).first
191
+ # Explicit positional hash (braces) — evaluate now takes an optional
192
+ # allow_shrink: keyword, so a bare hash would be parsed as keywords.
193
+ # The streamer allows shrink (it sees the sustained idle picture).
194
+ @autoscaler.evaluate(
195
+ { maxc: row["maxc"].to_i, used: row["used"].to_i, peers: row["peers"].to_i },
196
+ allow_shrink: true
197
+ )
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Pgbus
6
+ module Streams
7
+ # Publisher-side throttled autoscale trigger (issue #323 follow-up).
8
+ #
9
+ # The streamer autoscales via a periodic check on its idle LISTEN connection,
10
+ # but a pure-*publisher* process (a worker that fans out broadcasts via
11
+ # Client#send_stream_message but serves no SSE) has no streamer — so its
12
+ # streams pool would never autoscale. This closes that gap: each publish
13
+ # opportunistically triggers a headroom check, throttled to at most once per
14
+ # `interval` seconds across all publisher threads.
15
+ #
16
+ # OFF THE HOT PATH: the publishing thread only does a lock-free
17
+ # compare-and-set to claim the throttle window and, if it wins, posts the
18
+ # actual work to a background single-thread executor and returns immediately.
19
+ # It never runs the query or the resize inline. A quiet (non-publishing)
20
+ # process does zero work — this object is only ever built by the first publish
21
+ # (Client#streams_pool_trigger), and only when autoscale is on and the pool is
22
+ # dedicated, so a process that never publishes a stream never spawns the
23
+ # executor thread. There is no always-on timer.
24
+ #
25
+ # AT MOST ONE CHECK AT A TIME is enforced primarily by the single-thread
26
+ # executor (it serializes run_check calls end-to-end) plus the throttle CAS
27
+ # (one post per interval). The @running flag is a belt-and-suspenders
28
+ # invariant that becomes load-bearing only if a MULTI-threaded executor is
29
+ # injected (the executor: seam) — then it is the sole guard against two
30
+ # overlapping check bodies. It is cheap (one AtomicBoolean per check), so it
31
+ # stays.
32
+ #
33
+ # The query runs through the JOB pool, NOT the streams pool: pg_stat_activity
34
+ # is global, so any connection to the same database reads the same headroom,
35
+ # and using the job pool means the check can never starve on a saturated
36
+ # streams pool (the very pool it's trying to grow). The decision reuses
37
+ # PoolAutoscaler#evaluate with allow_shrink: false — a publisher only ever
38
+ # reacts to its own publish pressure (grow) and to DB exhaustion (emergency
39
+ # shrink); the streamer/consumer handles normal idle shrink.
40
+ class PoolTrigger
41
+ def initialize(autoscaler:, job_pool:, interval:, application_name_prefix:, clock: nil,
42
+ executor: nil, logger: Pgbus.logger)
43
+ @autoscaler = autoscaler
44
+ @job_pool = job_pool
45
+ @interval = interval
46
+ @like = "#{application_name_prefix}_%"
47
+ @clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
48
+ @logger = logger
49
+ # A single background worker with a GENUINELY bounded 1-slot queue that
50
+ # DISCARDS overflow. NOTE: Concurrent::SingleThreadExecutor silently drops
51
+ # max_queue: (it hardcodes an unbounded queue), so build the base
52
+ # ThreadPoolExecutor directly to actually honor the bound. Built eagerly
53
+ # (assigned once, safely published with the object) — the quiet-process
54
+ # "no thread" guarantee is owned by Client#streams_pool_trigger (this
55
+ # object only exists after a real publish with autoscale on), so there's
56
+ # no reason to lazy-build inside. Injectable for tests.
57
+ @executor = executor || Concurrent::ThreadPoolExecutor.new(
58
+ min_threads: 1, max_threads: 1, max_queue: 1, fallback_policy: :discard
59
+ )
60
+ # nil = never checked. AtomicReference gives a lock-free compare-and-set
61
+ # so exactly one concurrent publisher claims each throttle window.
62
+ @last_check = Concurrent::AtomicReference.new(nil)
63
+ # At-most-one-check guard — load-bearing only under an injected
64
+ # multi-threaded executor (see class comment).
65
+ @running = Concurrent::AtomicBoolean.new(false)
66
+ end
67
+
68
+ # Called from the publish path. Claims the throttle window with a single CAS
69
+ # and, if it wins, defers the actual check to the background executor. Does
70
+ # NOT run the query or resize on the calling thread. Never raises.
71
+ def maybe_check
72
+ return unless claim_window
73
+
74
+ @executor.post { run_check }
75
+ rescue StandardError => e
76
+ # A post after shutdown raises RejectedExecutionError; swallow it so a
77
+ # publish never fails. The check body itself is guarded inside run_check.
78
+ @logger.debug { "[Pgbus::Streams::PoolTrigger] dispatch failed: #{e.class}: #{e.message}" }
79
+ end
80
+
81
+ # Stop the background executor (idempotent). For a clean Client#close.
82
+ def shutdown
83
+ @executor.shutdown
84
+ @executor.wait_for_termination(5)
85
+ end
86
+
87
+ private
88
+
89
+ # True for exactly one caller per `interval` window; false for the rest.
90
+ # A losing CAS (another thread advanced @last_check first) also returns
91
+ # false, so only one thread posts the check per window.
92
+ def claim_window
93
+ now = @clock.call
94
+ last = @last_check.get
95
+ return false if last && (now - last) < @interval
96
+
97
+ @last_check.compare_and_set(last, now)
98
+ end
99
+
100
+ # Runs on the executor thread. Skips if a prior check is still in flight so
101
+ # a slow query can't queue up work. Fully guarded — a failure here never
102
+ # escapes the executor.
103
+ def run_check
104
+ return unless @running.make_true # false if already running
105
+
106
+ begin
107
+ headroom = read_headroom
108
+ @autoscaler.evaluate(headroom, allow_shrink: false) if headroom
109
+ ensure
110
+ @running.make_false
111
+ end
112
+ rescue StandardError => e
113
+ @logger.debug { "[Pgbus::Streams::PoolTrigger] check failed: #{e.class}: #{e.message}" }
114
+ end
115
+
116
+ def read_headroom
117
+ @job_pool.with_connection do |conn|
118
+ row = conn.exec_params(PoolAutoscaler::HEADROOM_SQL, [@like]).first
119
+ { maxc: row["maxc"].to_i, used: row["used"].to_i, peers: row["peers"].to_i }
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -62,5 +62,28 @@ module Pgbus
62
62
 
63
63
  ::Turbo::StreamsChannel.singleton_class.prepend(TurboBroadcastable)
64
64
  end
65
+
66
+ # turbo-rails' async broadcast helpers (`broadcast_*_later_to`, the
67
+ # default for `broadcasts_to`/`broadcasts_refreshes`) enqueue these three
68
+ # ActiveJobs, which ship with no `queue_as` and so land on the default
69
+ # queue — where a render+broadcast can wait behind long-running jobs
70
+ # before the browser sees the update (#311). When the operator sets
71
+ # `config.streams_broadcast_queue`, route them to that dedicated queue so
72
+ # a `realtime:`-style worker capsule can isolate broadcast latency from job
73
+ # throughput. Called from the engine's turbo_broadcastable initializer.
74
+ # No-op when the queue is nil or turbo-rails is not loaded.
75
+ def self.install_broadcast_queue!(queue_name)
76
+ return if queue_name.nil?
77
+
78
+ %w[
79
+ Turbo::Streams::ActionBroadcastJob
80
+ Turbo::Streams::BroadcastJob
81
+ Turbo::Streams::BroadcastStreamJob
82
+ ].each do |const_name|
83
+ next unless Object.const_defined?(const_name)
84
+
85
+ Object.const_get(const_name).queue_as(queue_name)
86
+ end
87
+ end
65
88
  end
66
89
  end
data/lib/pgbus/streams.rb CHANGED
@@ -157,10 +157,10 @@ module Pgbus
157
157
  }
158
158
  Instrumentation.instrument("pgbus.stream.broadcast", instrument_payload) do
159
159
  if transaction
160
- transaction.after_commit { @client.send_message(@name, wrapped) }
160
+ transaction.after_commit { @client.send_stream_message(@name, wrapped) }
161
161
  nil
162
162
  else
163
- @client.send_message(@name, wrapped)
163
+ @client.send_stream_message(@name, wrapped)
164
164
  end
165
165
  end
166
166
  end
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.9.10"
4
+ VERSION = "0.9.11"
5
5
  end
@@ -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