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
@@ -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.9
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
@@ -1,73 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
- require "erb"
5
-
6
- module Pgbus
7
- module ConfigLoader
8
- module_function
9
-
10
- def load(path, env: nil)
11
- env ||= (defined?(Rails) && Rails.respond_to?(:env) && Rails.env) || ENV.fetch("PGBUS_ENV", "development")
12
- raw = File.read(path)
13
- parsed = YAML.safe_load(ERB.new(raw).result, permitted_classes: [Symbol], aliases: true)
14
- # Distinguish sectioned (top-level env keys mapping to Hashes) from
15
- # flat (top-level setter keys mapping to scalars/arrays). parsed.key?(env)
16
- # alone can't tell them apart, so a flat file silently lost its typo
17
- # warnings whenever no env section happened to match its keys.
18
- if sectioned?(parsed)
19
- if parsed.key?(env)
20
- apply(parsed.fetch(env))
21
- else
22
- warn_missing_env_section(parsed, env)
23
- end
24
- else
25
- apply(parsed)
26
- end
27
- end
28
-
29
- # A file is "sectioned" (top-level keys are env names, e.g. development:/
30
- # production:) rather than "flat" (top-level keys are Configuration
31
- # setters) when every top-level value is a Hash AND none of the
32
- # top-level keys is itself a real Configuration setter. The setter check
33
- # is what distinguishes the two: env section names (production, staging,
34
- # ...) are never real setters, so a legitimate flat file whose settings
35
- # all happen to be Hash-valued (connection_params:, streams_retention:,
36
- # recurring_tasks:, ...) is correctly classified as flat instead of being
37
- # misread as sectioned-with-no-matching-env and silently skipped.
38
- def sectioned?(parsed)
39
- return false unless parsed.is_a?(Hash) && parsed.any? && parsed.values.all?(Hash)
40
-
41
- config = Pgbus.configuration
42
- parsed.keys.none? { |key| config.respond_to?(:"#{key}=") }
43
- end
44
-
45
- # A sectioned file with no section for the current env has nothing to
46
- # apply for this process — that's a real gap (typo'd env name, or a
47
- # config file that just doesn't cover this deployment), so it must be
48
- # loud instead of a silent no-op.
49
- def warn_missing_env_section(parsed, env)
50
- Pgbus.configuration.logger.warn do
51
- "[Pgbus] No configuration section for env #{env.inspect} — " \
52
- "available sections: #{parsed.keys.inspect}. Nothing was applied."
53
- end
54
- end
55
-
56
- def apply(hash, warn_unknown: true)
57
- config = Pgbus.configuration
58
- hash.each do |key, value|
59
- setter = :"#{key}="
60
- if config.respond_to?(setter)
61
- config.public_send(setter, value)
62
- elsif warn_unknown
63
- config.logger.warn { "[Pgbus] Unknown configuration key ignored: #{key.inspect} — check for typos in pgbus.yml" }
64
- end
65
- end
66
- # Validate eagerly so a bad YAML value aborts boot with an ArgumentError
67
- # naming the offending key, instead of failing later in a worker path.
68
- # Honors an `eager_validation: false` key in the same hash (applied above).
69
- config.validate! if config.eager_validation
70
- config
71
- end
72
- end
73
- end