pgbus 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97572298add12c78b7d9f3ebb6a50af36a9803380d42202a66031ce7f1018d4c
4
- data.tar.gz: b5498b051a4c1d134c2944d1b0c556124fef49a7276ee32cbe0172fac12f6c8d
3
+ metadata.gz: d43bc708b048e8b81cae4c4954686fcd0f227fd8bc2498c599ab231f262ef607
4
+ data.tar.gz: 465eba8207a366a8f639f110e8131d064b46267161ffbf969b86aa470de56bfa
5
5
  SHA512:
6
- metadata.gz: ec8b0af9bac7c6156b12effa0ce3d3ecf9a29f06c091f5117c5567114c00f44c6dea26666306ff9b084c0b616aa3e47b5080d986c1159fcedf64e5fbfe1f909a
7
- data.tar.gz: 0227ab4f57df35bd89c4dac8a022df20a16d8c30eaaff0cbc75c8a49e8debf9dba62e1fb54b1ac26c327aab6199398ac2fe1575db9b1ad1062fb0d6b21083ffc
6
+ metadata.gz: cbc395ea4f504b5295b7a90c50981aef8cce7b7a060a8488db2b8a3a73d9d08ae8a571a067626e1e269a501e87790f230231018d527096ebba89a6a743a390ac
7
+ data.tar.gz: 12ff4342c5fb3b1b30c328c7f373dcfcfd2db72d87d4a079e0538ba6857ca89b952078a6150ae495b560815bb637f396768397ccb250b98371096e146cb37b23
data/README.md CHANGED
@@ -463,6 +463,36 @@ end
463
463
 
464
464
  When a limit is hit, the worker drains its thread pool, exits, and the supervisor forks a fresh process. RSS memory is sampled from `/proc/self/statm` (Linux) or `ps -o rss` (macOS).
465
465
 
466
+ ### Async execution mode (fibers)
467
+
468
+ Workers can optionally execute jobs as fibers instead of threads. This is ideal for I/O-bound workloads (HTTP calls, email delivery, LLM API calls) where jobs spend most of their time waiting on network I/O.
469
+
470
+ ```ruby
471
+ Pgbus.configure do |config|
472
+ # Global: all workers use async mode
473
+ config.execution_mode = :async
474
+
475
+ # Or per-worker: mix thread and async workers
476
+ config.workers = [
477
+ { queues: %w[webhooks emails], threads: 100, execution_mode: :async },
478
+ { queues: %w[default], threads: 5 } # stays thread-based
479
+ ]
480
+ end
481
+ ```
482
+
483
+ **Prerequisites:**
484
+
485
+ 1. Add `gem "async"` to your Gemfile
486
+ 2. Set `config.active_support.isolation_level = :fiber` in your Rails app
487
+
488
+ **Why it reduces connections:** In thread mode, each thread holds a database connection while waiting on I/O. With 50 threads, that's 50 connections. In async mode, 50 fibers share 3-5 connections because fibers yield during I/O and only one runs at a time.
489
+
490
+ **CLI flag:** `pgbus start --execution-mode async`
491
+
492
+ **Safety:** Messages stay in PGMQ with visibility timeout protection regardless of execution mode. If a fiber or worker crashes, the visibility timeout expires and messages become available for re-read. No data loss risk.
493
+
494
+ **Not recommended for:** CPU-bound jobs (image processing, heavy computation). These block the single reactor thread and should use thread mode.
495
+
466
496
  ## Routing and ordering
467
497
 
468
498
  How messages flow between producers and the workers that handle them: priority sub-queues, consumer priority for active/standby workers, and single-active-consumer for strict ordering.
@@ -675,7 +705,7 @@ Without the plugin, Puma closes hijacked SSE sockets abruptly during graceful re
675
705
 
676
706
  ### Requirements
677
707
 
678
- - **Puma 6.1+ or Falcon.** Streams use `rack.hijack`. Puma 6.1+ supports it via partial hijack (thread-releasing, see [puma/puma#1009](https://github.com/puma/puma/issues/1009)). Falcon supports it via [protocol-rack](https://github.com/socketry/protocol-rack)'s adapter layer same `env["rack.hijack?"]` + `env["rack.hijack"]` shape as Puma, so `Pgbus::Web::StreamApp` needs no server-specific code paths. Unicorn, Pitchfork, and Passenger return HTTP 501 from the streams endpoint.
708
+ - **Puma 6.1+ or Falcon.** Streams use `rack.hijack` by default. Puma 6.1+ supports it via partial hijack (thread-releasing, see [puma/puma#1009](https://github.com/puma/puma/issues/1009)). Falcon supports both the hijack path (via [protocol-rack](https://github.com/socketry/protocol-rack)'s emulation) and a native streaming body path (`streams_falcon_streaming_body = true`) that integrates with Falcon's fiber scheduler for better backpressure and connection lifecycle management. Unicorn, Pitchfork, and Passenger return HTTP 501 from the streams endpoint.
679
709
  - **PostgreSQL LISTEN/NOTIFY.** `config.listen_notify = true` (the default). Stream queues override PGMQ's 250ms NOTIFY throttle to 0 so every broadcast fires individually.
680
710
  - **HTTP/2 or HTTP/3 in production.** SSE has a 6-connection-per-origin limit on HTTP/1.1; HTTP/2 lifts it. Falcon supports HTTP/2 natively without a reverse proxy.
681
711
 
@@ -695,9 +725,22 @@ Pgbus.configure do |c|
695
725
  c.streams_idle_timeout = 3_600 # close idle connections after 1h
696
726
  c.streams_listen_health_check_ms = 250 # PG LISTEN keepalive + ensure_listening ack budget
697
727
  c.streams_write_deadline_ms = 5_000 # write_nonblock deadline
728
+ c.streams_falcon_streaming_body = false # opt-in: Falcon-native streaming body
698
729
  end
699
730
  ```
700
731
 
732
+ #### Falcon-native streaming body (opt-in)
733
+
734
+ When running on Falcon, enable native streaming body support for better integration with Falcon's fiber scheduler:
735
+
736
+ ```ruby
737
+ c.streams_falcon_streaming_body = true
738
+ ```
739
+
740
+ With this flag, `StreamApp` returns `[200, headers, Writable]` instead of hijacking the socket. Falcon drives the response lifecycle with proper backpressure, connection cleanup, and fiber-scheduled IO. SSE writes go through `Protocol::HTTP::Body::Writable` which is fiber-safe and yields to other fibers when blocked.
741
+
742
+ Without the flag (default), Falcon uses the same `rack.hijack` path as Puma via protocol-rack's emulation. Both paths are tested and work correctly — the streaming body path is an optimization for Falcon deployments that want tighter scheduler integration.
743
+
701
744
  ### How it works
702
745
 
703
746
  Stream broadcasts are stored in PGMQ queues prefixed `pgbus_stream_*`. Each broadcast is assigned a monotonic `msg_id` by PGMQ. The `pgbus_stream_from` helper captures the current `MAX(msg_id)` at render time and embeds it in the HTML as `since-id`. When the SSE client connects, it sends that cursor as `?since=` on the first request and as `Last-Event-ID` on reconnects. The streamer replays from `pgmq.q_*` (live) UNION `pgmq.a_*` (archive) for any `msg_id > cursor`, then switches to LISTEN/NOTIFY for the live path. There is no message identity gap between the render and the subscribe — the cursor model guarantees every broadcast is delivered exactly once, in order, even across reconnects.
@@ -75,22 +75,34 @@ module Pgbus
75
75
  end
76
76
  end
77
77
 
78
- # Build the SSE endpoint URL by asking the engine where its
79
- # `:streams` mount point lives, then appending the signed name.
80
- # The base comes from Pgbus::Engine.routes.url_helpers.streams_path
81
- # so the URL follows whatever mount point the host app chose for
82
- # the engine ("/pgbus", "/admin/dashboard", etc.). A
83
- # `NoMethodError` fallback covers the test-only context where
84
- # the helper is included in a plain class outside a Rails
85
- # request and the engine's url_helpers aren't wired in.
78
+ # Build the SSE endpoint URL for the given signed stream name.
79
+ #
80
+ # Resolution order:
81
+ # 1. `config.streams_path` explicit override, useful when the
82
+ # engine is mounted behind an auth constraint but the SSE
83
+ # endpoint is mounted publicly at a separate path:
84
+ #
85
+ # # config/routes.rb
86
+ # authenticate :user, ->(u) { u.admin? } do
87
+ # mount Pgbus::Engine => "/admin/jobs"
88
+ # end
89
+ # mount Pgbus::Web::StreamApp.new => "/pgbus/streams"
90
+ #
91
+ # # config/initializers/pgbus.rb
92
+ # Pgbus.configure { |c| c.streams_path = "/pgbus/streams" }
93
+ #
94
+ # 2. Engine route helper — derives the path from wherever the
95
+ # host app mounted the engine.
96
+ #
97
+ # 3. Fallback `/pgbus/streams` — test-only context where the
98
+ # engine's url_helpers aren't wired in.
86
99
  def pgbus_stream_src(signed_name)
100
+ base = Pgbus.configuration.streams_path
101
+ return "#{base.delete_suffix("/")}/#{signed_name}" if base
102
+
87
103
  base = Pgbus::Engine.routes.url_helpers.streams_path
88
104
  "#{base}/#{signed_name}"
89
105
  rescue NameError
90
- # NameError covers both uninitialized-constant (Pgbus::Engine
91
- # not loaded, e.g. plain-Ruby unit specs) and NoMethodError
92
- # (a NameError subclass) when the routes helper chain isn't
93
- # wired in.
94
106
  "/pgbus/streams/#{signed_name}"
95
107
  end
96
108
 
data/lib/pgbus/cli.rb CHANGED
@@ -42,6 +42,7 @@ module Pgbus
42
42
  options = parse_start_options(args)
43
43
 
44
44
  Pgbus.configuration.workers = options[:queues] if options[:queues]
45
+ Pgbus.configuration.execution_mode = options[:execution_mode].to_sym if options[:execution_mode]
45
46
  apply_capsule_filter(options[:capsule]) if options[:capsule]
46
47
  apply_role_filter(options)
47
48
  end
@@ -70,6 +71,10 @@ module Pgbus
70
71
  opts.on("--dispatcher-only", "Run only the dispatcher (the maintenance pod pattern)") do
71
72
  options[:dispatcher_only] = true
72
73
  end
74
+
75
+ opts.on("--execution-mode MODE", "Execution mode: threads (default) or async") do |v|
76
+ options[:execution_mode] = v
77
+ end
73
78
  end.parse!(args.dup)
74
79
  options
75
80
  end
@@ -165,6 +170,8 @@ module Pgbus
165
170
  pattern — exactly one of these per deployment)
166
171
  --dispatcher-only Run only the dispatcher (the maintenance pod
167
172
  pattern)
173
+ --execution-mode Execution mode: threads (default) or async
174
+ (fiber-based, lower connection usage)
168
175
  HELP
169
176
  end
170
177
  end
@@ -11,7 +11,7 @@ module Pgbus
11
11
  attr_accessor :default_queue, :queue_prefix
12
12
 
13
13
  # Worker settings
14
- attr_accessor :polling_interval, :prefetch_limit
14
+ attr_accessor :polling_interval, :prefetch_limit, :execution_mode
15
15
  attr_reader :workers, :visibility_timeout # rubocop:disable Style/AccessorGrouping
16
16
 
17
17
  # Supervisor role selection.
@@ -90,7 +90,7 @@ module Pgbus
90
90
  :metrics_enabled
91
91
 
92
92
  # Streams (turbo-rails replacement, SSE-based)
93
- attr_accessor :streams_enabled, :streams_queue_prefix, :streams_signed_name_secret,
93
+ attr_accessor :streams_enabled, :streams_path, :streams_queue_prefix, :streams_signed_name_secret,
94
94
  :streams_default_retention, :streams_retention, :streams_heartbeat_interval,
95
95
  :streams_max_connections, :streams_idle_timeout, :streams_listen_health_check_ms,
96
96
  :streams_write_deadline_ms, :streams_falcon_streaming_body,
@@ -111,6 +111,7 @@ module Pgbus
111
111
  @visibility_timeout = 30
112
112
 
113
113
  @prefetch_limit = nil
114
+ @execution_mode = :threads
114
115
 
115
116
  @max_jobs_per_worker = nil
116
117
  @max_memory_mb = nil
@@ -168,6 +169,7 @@ module Pgbus
168
169
  @metrics_enabled = true
169
170
 
170
171
  @streams_enabled = true
172
+ @streams_path = nil
171
173
  @streams_queue_prefix = "pgbus_stream"
172
174
  @streams_signed_name_secret = nil
173
175
  @streams_default_retention = 5 * 60 # 5 minutes
@@ -215,6 +217,13 @@ module Pgbus
215
217
  (0...priority_levels).map { |p| priority_queue_name(name, p) }
216
218
  end
217
219
 
220
+ # Returns the execution mode for a specific worker config hash,
221
+ # falling back to the global execution_mode setting.
222
+ def execution_mode_for(worker_config)
223
+ mode = worker_config[:execution_mode] || worker_config["execution_mode"] || execution_mode
224
+ ExecutionPools.normalize_mode(mode)
225
+ end
226
+
218
227
  VALID_PGMQ_SCHEMA_MODES = %i[auto extension embedded].freeze
219
228
 
220
229
  def pgmq_schema_mode=(mode)
@@ -241,9 +250,16 @@ module Pgbus
241
250
  raise ArgumentError, "retry_backoff_jitter must be between 0 and 1"
242
251
  end
243
252
 
253
+ # Validate global execution_mode
254
+ ExecutionPools.normalize_mode(execution_mode)
255
+
244
256
  Array(workers).each do |w|
245
257
  threads = w[:threads] || w["threads"] || 5
246
258
  raise ArgumentError, "worker threads must be > 0" unless threads.is_a?(Integer) && threads.positive?
259
+
260
+ # Validate per-worker execution_mode override if present
261
+ mode = w[:execution_mode] || w["execution_mode"]
262
+ ExecutionPools.normalize_mode(mode) if mode
247
263
  end
248
264
 
249
265
  raise ArgumentError, "prefetch_limit must be > 0" if prefetch_limit && !(prefetch_limit.is_a?(Integer) && prefetch_limit.positive?)
@@ -469,6 +485,11 @@ module Pgbus
469
485
  # default formula provides.
470
486
  POOL_SIZE_WARN_THRESHOLD = 50
471
487
 
488
+ # Connections needed per async worker: one for the reactor's serial
489
+ # execution, one for polling, one for headroom. Fibers share connections
490
+ # because only one runs at a time per reactor thread.
491
+ ASYNC_POOL_CONNECTIONS = 3
492
+
472
493
  def resolved_pool_size
473
494
  return pool_size if pool_size
474
495
 
@@ -608,10 +629,19 @@ module Pgbus
608
629
  raise ArgumentError,
609
630
  "#{group} threads must be a positive integer, got #{threads.inspect}"
610
631
  end
611
- threads
632
+
633
+ if async_execution_mode?(entry)
634
+ ASYNC_POOL_CONNECTIONS
635
+ else
636
+ threads
637
+ end
612
638
  end
613
639
  end
614
640
 
641
+ def async_execution_mode?(entry)
642
+ execution_mode_for(entry) == :async
643
+ end
644
+
615
645
  def warn_if_oversized(size)
616
646
  return unless size > POOL_SIZE_WARN_THRESHOLD
617
647
 
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module ExecutionPools
5
+ class AsyncPool
6
+ attr_reader :capacity
7
+
8
+ IDLE_WAIT_INTERVAL = 0.01
9
+
10
+ def initialize(capacity:, on_state_change: nil)
11
+ @capacity = capacity
12
+ @on_state_change = on_state_change
13
+ @available_capacity = capacity
14
+ @mutex = Mutex.new
15
+ @state_mutex = Mutex.new
16
+ @shutdown_flag = false
17
+ @fatal_error = nil
18
+ @boot_queue = Thread::Queue.new
19
+ @pending = Thread::Queue.new
20
+
21
+ validate_dependencies!
22
+ @reactor_thread = start_reactor
23
+ result = @boot_queue.pop
24
+ raise result if result.is_a?(Exception)
25
+ end
26
+
27
+ def post(&block)
28
+ raise_if_fatal!
29
+ raise "Execution pool is shutting down" if shutdown?
30
+
31
+ reserved = false
32
+ reserve_capacity!
33
+ reserved = true
34
+ @pending << block
35
+ rescue StandardError
36
+ restore_capacity if reserved
37
+ raise
38
+ end
39
+
40
+ def available_capacity
41
+ raise_if_fatal!
42
+ @mutex.synchronize { @available_capacity }
43
+ end
44
+
45
+ def idle?
46
+ available_capacity.positive?
47
+ end
48
+
49
+ def shutdown
50
+ @state_mutex.synchronize do
51
+ return false if @shutdown_flag
52
+
53
+ @shutdown_flag = true
54
+ end
55
+ end
56
+
57
+ def shutdown?
58
+ @state_mutex.synchronize { @shutdown_flag }
59
+ end
60
+
61
+ def wait_for_termination(timeout)
62
+ @reactor_thread&.join(timeout)
63
+ end
64
+
65
+ def kill
66
+ shutdown
67
+ @reactor_thread&.kill
68
+ end
69
+
70
+ def metadata
71
+ inflight = @mutex.synchronize { @available_capacity }
72
+ {
73
+ mode: :async,
74
+ capacity: @capacity,
75
+ busy: @capacity - inflight
76
+ }
77
+ end
78
+
79
+ private
80
+
81
+ def validate_dependencies!
82
+ require "async"
83
+ require "async/semaphore"
84
+ rescue LoadError => e
85
+ raise LoadError,
86
+ "Async execution mode requires the `async` gem. " \
87
+ "Add `gem \"async\"` to your Gemfile. Original error: #{e.message}"
88
+ end
89
+
90
+ # rubocop:disable Lint/RescueException
91
+ def start_reactor
92
+ Thread.new do
93
+ Thread.current.name = "pgbus-async-reactor-#{object_id}"
94
+ Async do |task|
95
+ semaphore = Async::Semaphore.new(@capacity, parent: task)
96
+ @boot_queue << :ready
97
+
98
+ wait_for_executions(semaphore)
99
+ wait_for_inflight
100
+ end
101
+ rescue Exception => e
102
+ register_fatal_error(e)
103
+ raise
104
+ end
105
+ end
106
+ # rubocop:enable Lint/RescueException
107
+
108
+ def wait_for_executions(semaphore)
109
+ loop do
110
+ schedule_pending(semaphore)
111
+ break if shutdown? && @pending.empty?
112
+
113
+ sleep(IDLE_WAIT_INTERVAL) if @pending.empty?
114
+ end
115
+ end
116
+
117
+ def schedule_pending(semaphore)
118
+ while (block = next_pending)
119
+ semaphore.async do
120
+ perform(block)
121
+ end
122
+ end
123
+ end
124
+
125
+ def next_pending
126
+ @pending.pop(true)
127
+ rescue ThreadError
128
+ nil
129
+ end
130
+
131
+ def perform(block)
132
+ block.call
133
+ rescue StandardError => e
134
+ Pgbus.logger.error { "[Pgbus] Async pool fiber error: #{e.class}: #{e.message}" }
135
+ ensure
136
+ restore_capacity
137
+ end
138
+
139
+ def reserve_capacity!
140
+ @mutex.synchronize do
141
+ raise "Execution pool is at capacity" if @available_capacity <= 0
142
+
143
+ @available_capacity -= 1
144
+ end
145
+ end
146
+
147
+ def restore_capacity
148
+ should_notify = @mutex.synchronize do
149
+ @available_capacity += 1
150
+ @available_capacity.positive?
151
+ end
152
+ @on_state_change&.call if should_notify
153
+ end
154
+
155
+ def register_fatal_error(error)
156
+ @state_mutex.synchronize { @fatal_error ||= error }
157
+ @boot_queue << error if @boot_queue.empty?
158
+ @on_state_change&.call
159
+ end
160
+
161
+ def raise_if_fatal!
162
+ error = @state_mutex.synchronize { @fatal_error }
163
+ raise error if error
164
+ end
165
+
166
+ def wait_for_inflight
167
+ sleep(IDLE_WAIT_INTERVAL) while inflight?
168
+ end
169
+
170
+ def inflight?
171
+ @mutex.synchronize { @available_capacity < @capacity }
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Pgbus
6
+ module ExecutionPools
7
+ class ThreadPool
8
+ attr_reader :capacity
9
+
10
+ def initialize(capacity:, on_state_change: nil)
11
+ @capacity = capacity
12
+ @on_state_change = on_state_change
13
+ @available_capacity = Concurrent::AtomicFixnum.new(capacity)
14
+ @pool = Concurrent::FixedThreadPool.new(capacity)
15
+ end
16
+
17
+ def post(&block)
18
+ @available_capacity.decrement
19
+ begin
20
+ @pool.post do
21
+ block.call
22
+ ensure
23
+ @available_capacity.increment
24
+ @on_state_change&.call
25
+ end
26
+ rescue StandardError
27
+ @available_capacity.increment
28
+ raise
29
+ end
30
+ end
31
+
32
+ def available_capacity
33
+ @available_capacity.value
34
+ end
35
+
36
+ def idle?
37
+ available_capacity.positive?
38
+ end
39
+
40
+ def shutdown
41
+ @pool.shutdown
42
+ end
43
+
44
+ def wait_for_termination(timeout)
45
+ @pool.wait_for_termination(timeout)
46
+ end
47
+
48
+ def kill
49
+ @pool.kill
50
+ end
51
+
52
+ def metadata
53
+ {
54
+ mode: :threads,
55
+ capacity: @capacity,
56
+ busy: @capacity - available_capacity
57
+ }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module ExecutionPools
5
+ class << self
6
+ def build(mode:, capacity:, on_state_change: nil)
7
+ case normalize_mode(mode)
8
+ when :threads
9
+ ThreadPool.new(capacity: capacity, on_state_change: on_state_change)
10
+ when :async
11
+ AsyncPool.new(capacity: capacity, on_state_change: on_state_change)
12
+ end
13
+ end
14
+
15
+ def normalize_mode(mode)
16
+ case mode.to_s
17
+ when "", "threads"
18
+ :threads
19
+ when "async", "fiber"
20
+ :async
21
+ else
22
+ raise ArgumentError, "Unknown execution_mode: #{mode.inspect}. Expected one of: :threads, :async, :fiber"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -7,14 +7,15 @@ module Pgbus
7
7
  class Consumer
8
8
  include SignalHandler
9
9
 
10
- attr_reader :topics, :threads, :config
10
+ attr_reader :topics, :threads, :config, :execution_mode
11
11
 
12
- def initialize(topics:, threads: 3, config: Pgbus.configuration)
12
+ def initialize(topics:, threads: 3, config: Pgbus.configuration, execution_mode: :threads)
13
13
  @topics = Array(topics)
14
14
  @threads = threads
15
15
  @config = config
16
+ @execution_mode = ExecutionPools.normalize_mode(execution_mode)
16
17
  @shutting_down = false
17
- @pool = Concurrent::FixedThreadPool.new(threads)
18
+ @pool = ExecutionPools.build(mode: @execution_mode, capacity: threads)
18
19
  @registry = EventBus::Registry.instance
19
20
  end
20
21
 
@@ -53,7 +54,7 @@ module Pgbus
53
54
  end
54
55
 
55
56
  def consume
56
- idle = @pool.max_length - @pool.queue_length
57
+ idle = @pool.available_capacity
57
58
  return interruptible_sleep(config.polling_interval) if idle <= 0
58
59
 
59
60
  tagged_messages = if @queue_names.size == 1
@@ -60,6 +60,7 @@ module Pgbus
60
60
  threads = worker_config[:threads] || worker_config["threads"] || 5
61
61
  single_active = worker_config[:single_active_consumer] || worker_config["single_active_consumer"] || false
62
62
  priority = worker_config[:consumer_priority] || worker_config["consumer_priority"] || 0
63
+ exec_mode = config.execution_mode_for(worker_config)
63
64
 
64
65
  pid = fork do
65
66
  restore_signals
@@ -68,7 +69,8 @@ module Pgbus
68
69
  bootstrap_queues
69
70
  worker = Worker.new(
70
71
  queues: queues, threads: threads, config: config,
71
- single_active_consumer: single_active, consumer_priority: priority
72
+ single_active_consumer: single_active, consumer_priority: priority,
73
+ execution_mode: exec_mode
72
74
  )
73
75
  worker.run
74
76
  end
@@ -79,7 +81,7 @@ module Pgbus
79
81
  end
80
82
 
81
83
  @forks[pid] = { type: :worker, config: worker_config }
82
- Pgbus.logger.info { "[Pgbus] Forked worker pid=#{pid} queues=#{queues.join(",")}" }
84
+ Pgbus.logger.info { "[Pgbus] Forked worker pid=#{pid} queues=#{queues.join(",")} mode=#{exec_mode}" }
83
85
  rescue Errno::EAGAIN, Errno::ENOMEM => e
84
86
  Pgbus.logger.error { "[Pgbus] Fork failed for worker: #{e.message}" }
85
87
  end
@@ -7,14 +7,16 @@ module Pgbus
7
7
  class Worker
8
8
  include SignalHandler
9
9
 
10
- attr_reader :queues, :threads, :config
10
+ attr_reader :queues, :threads, :config, :execution_mode
11
11
 
12
12
  def initialize(queues:, threads: 5, config: Pgbus.configuration,
13
- single_active_consumer: false, consumer_priority: 0)
13
+ single_active_consumer: false, consumer_priority: 0,
14
+ execution_mode: :threads)
14
15
  @queues = Array(queues)
15
16
  @wildcard = @queues.include?("*")
16
17
  @threads = threads
17
18
  @config = config
19
+ @execution_mode = ExecutionPools.normalize_mode(execution_mode)
18
20
  @single_active_consumer = single_active_consumer
19
21
  @consumer_priority = consumer_priority
20
22
  @lifecycle = Lifecycle.new
@@ -27,10 +29,14 @@ module Pgbus
27
29
  @started_at_monotonic = monotonic_now
28
30
  @stat_buffer = config.stats_enabled ? Pgbus::StatBuffer.new : nil
29
31
  @executor = Pgbus::ActiveJob::Executor.new(stat_buffer: @stat_buffer)
30
- @pool = Concurrent::FixedThreadPool.new(threads)
32
+ @wake_signal = WakeSignal.new
33
+ @pool = ExecutionPools.build(
34
+ mode: @execution_mode,
35
+ capacity: threads,
36
+ on_state_change: -> { @wake_signal.notify! }
37
+ )
31
38
  @circuit_breaker = Pgbus::CircuitBreaker.new(config: config)
32
39
  @queue_lock = QueueLock.new if @single_active_consumer
33
- @wake_signal = WakeSignal.new
34
40
  end
35
41
 
36
42
  def stats
@@ -39,12 +45,13 @@ module Pgbus
39
45
  jobs_failed: @jobs_failed.value,
40
46
  in_flight: @in_flight.value,
41
47
  state: @lifecycle.state,
48
+ execution_mode: @execution_mode,
42
49
  consumer_priority: @consumer_priority,
43
50
  single_active_consumer: @single_active_consumer,
44
51
  locked_queues: @queue_lock&.held_queues || [],
45
52
  rates: @rate_counter.rates,
46
53
  started_at: @started_at
47
- }
54
+ }.merge(@pool.metadata)
48
55
  end
49
56
 
50
57
  def run
@@ -52,7 +59,10 @@ module Pgbus
52
59
  start_heartbeat
53
60
  resolve_wildcard_queues
54
61
  @lifecycle.transition_to!(:running)
55
- Pgbus.logger.info { "[Pgbus] Worker started: queues=#{queues.join(",")} threads=#{threads} pid=#{::Process.pid}" }
62
+ Pgbus.logger.info do
63
+ "[Pgbus] Worker started: queues=#{queues.join(",")} threads=#{threads} " \
64
+ "mode=#{@execution_mode} pid=#{::Process.pid}"
65
+ end
56
66
 
57
67
  loop do
58
68
  process_signals
@@ -60,7 +70,7 @@ module Pgbus
60
70
  refresh_wildcard_queues
61
71
 
62
72
  break if @lifecycle.stopped?
63
- break if @lifecycle.draining? && @pool.queue_length.zero?
73
+ break if @lifecycle.draining? && @pool.idle?
64
74
 
65
75
  claim_and_execute if @lifecycle.can_process?
66
76
  @stat_buffer&.flush_if_due
@@ -97,7 +107,7 @@ module Pgbus
97
107
  def claim_and_execute
98
108
  poll_interval = effective_polling_interval
99
109
 
100
- idle = @pool.max_length - @pool.queue_length
110
+ idle = @pool.available_capacity
101
111
  return @wake_signal.wait(timeout: poll_interval) if idle <= 0
102
112
 
103
113
  if config.prefetch_limit
@@ -332,7 +342,7 @@ module Pgbus
332
342
  kind: "worker",
333
343
  metadata: {
334
344
  queues: queues, threads: threads, pid: ::Process.pid,
335
- consumer_priority: @consumer_priority
345
+ execution_mode: @execution_mode, consumer_priority: @consumer_priority
336
346
  },
337
347
  on_beat: -> { @rate_counter.snapshot! }
338
348
  )
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.6.3"
4
+ VERSION = "0.6.5"
5
5
  end
@@ -62,11 +62,15 @@ module Pgbus
62
62
  cursor = parse_cursor(env, request)
63
63
  return bad_request("invalid cursor: #{cursor}") if cursor.is_a?(String)
64
64
 
65
- return unsupported_server unless env["rack.hijack?"]
66
-
67
65
  return over_capacity if streamer.registry.size >= config.streams_max_connections
68
66
 
69
- hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context)
67
+ if config.streams_falcon_streaming_body
68
+ streaming_body_response(stream_name: stream_name, since_id: cursor, context: context)
69
+ elsif env["rack.hijack?"]
70
+ hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context)
71
+ else
72
+ unsupported_server
73
+ end
70
74
  rescue StandardError => e
71
75
  logger.error { "[Pgbus::StreamApp] #{e.class}: #{e.message}" }
72
76
  server_error
@@ -127,6 +131,38 @@ module Pgbus
127
131
  [-1, {}, []]
128
132
  end
129
133
 
134
+ def streaming_body_response(stream_name:, since_id:, context: nil)
135
+ require "protocol/http/body/writable" unless defined?(::Protocol::HTTP::Body::Writable)
136
+ require_relative "streamer/falcon_connection" unless defined?(Pgbus::Web::Streamer::FalconConnection)
137
+
138
+ body = ::Protocol::HTTP::Body::Writable.new
139
+
140
+ body.write(Pgbus::Streams::Envelope.retry_directive(2_000))
141
+ body.write(Pgbus::Streams::Envelope.comment(
142
+ "pgbus stream open since_id=#{since_id} stream=#{stream_name}"
143
+ ))
144
+
145
+ connection = Pgbus::Web::Streamer::FalconConnection.new(
146
+ id: SecureRandom.hex(8),
147
+ stream_name: stream_name,
148
+ body: body,
149
+ since_id: since_id,
150
+ write_deadline_ms: config.streams_write_deadline_ms,
151
+ context: context
152
+ )
153
+ streamer.register(connection)
154
+
155
+ [200, sse_headers, body]
156
+ end
157
+
158
+ def sse_headers
159
+ {
160
+ "content-type" => "text/event-stream",
161
+ "cache-control" => "no-cache, no-transform",
162
+ "x-accel-buffering" => "no"
163
+ }
164
+ end
165
+
130
166
  def write_headers(io, stream_name:, since_id:)
131
167
  io.write(Pgbus::Streams::Envelope.http_response_headers)
132
168
  io.write(Pgbus::Streams::Envelope.retry_directive(2_000))
@@ -70,6 +70,10 @@ module Pgbus
70
70
  result
71
71
  end
72
72
 
73
+ def write_sentinel(bytes)
74
+ @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
75
+ end
76
+
73
77
  def idle_for
74
78
  monotonic - @last_write_at
75
79
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ module Streamer
6
+ # Connection adapter for Falcon's native streaming body path.
7
+ # Wraps a Protocol::HTTP::Body::Writable instead of a raw IO socket.
8
+ #
9
+ # Satisfies the same duck-type interface as Connection so the
10
+ # Dispatcher, Heartbeat, and Instance can use either type
11
+ # interchangeably. Key difference: no IoWriter — writes go
12
+ # directly to body.write which is backed by Thread::Queue and
13
+ # is fiber-safe under Falcon's scheduler.
14
+ class FalconConnection
15
+ attr_reader :id, :stream_name, :io, :mutex, :last_msg_id_sent, :context
16
+
17
+ def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil)
18
+ @id = id
19
+ @stream_name = stream_name
20
+ @body = body
21
+ @io = body
22
+ @last_msg_id_sent = since_id.to_i
23
+ @write_deadline_ms = write_deadline_ms
24
+ @mutex = Mutex.new
25
+ @dead = false
26
+ @closed = false
27
+ @created_at = monotonic
28
+ @last_write_at = @created_at
29
+ @context = context
30
+ end
31
+
32
+ def enqueue(envelopes)
33
+ written = []
34
+ envelopes.each do |envelope|
35
+ next if envelope.msg_id <= @last_msg_id_sent
36
+
37
+ bytes = Pgbus::Streams::Envelope.message(
38
+ id: envelope.msg_id,
39
+ event: "turbo-stream",
40
+ data: envelope.payload
41
+ )
42
+
43
+ result = write_to_body(bytes)
44
+ if result == :ok
45
+ @last_msg_id_sent = envelope.msg_id
46
+ @last_write_at = monotonic
47
+ written << envelope
48
+ else
49
+ mark_dead!
50
+ break
51
+ end
52
+ end
53
+ written
54
+ end
55
+
56
+ def write_comment(text)
57
+ bytes = Pgbus::Streams::Envelope.comment(text)
58
+ result = write_to_body(bytes)
59
+ if result == :ok
60
+ @last_write_at = monotonic
61
+ else
62
+ mark_dead!
63
+ end
64
+ result
65
+ end
66
+
67
+ def write_sentinel(bytes)
68
+ write_to_body(bytes)
69
+ end
70
+
71
+ def close
72
+ @mutex.synchronize do
73
+ return if @closed
74
+
75
+ @closed = true
76
+ mark_dead!
77
+ @body.close_write
78
+ end
79
+ rescue StandardError => e
80
+ Pgbus.logger&.debug { "[Pgbus::Streamer::FalconConnection] close failed: #{e.class}: #{e.message}" }
81
+ end
82
+
83
+ def idle_for
84
+ monotonic - @last_write_at
85
+ end
86
+
87
+ def dead?
88
+ @dead || @body.closed?
89
+ end
90
+
91
+ def mark_dead!
92
+ @dead = true
93
+ end
94
+
95
+ private
96
+
97
+ def write_to_body(bytes)
98
+ @mutex.synchronize do
99
+ return :closed if @dead || @body.closed?
100
+
101
+ @body.write(bytes)
102
+ :ok
103
+ end
104
+ rescue Protocol::HTTP::Body::Writable::Closed
105
+ :closed
106
+ end
107
+
108
+ def monotonic
109
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -134,14 +134,9 @@ module Pgbus
134
134
  event: "pgbus:shutdown",
135
135
  data: '{"reason":"worker_restart"}'
136
136
  )
137
- deadline_ms = @config.streams_write_deadline_ms
138
137
 
139
138
  @registry.each_connection do |connection|
140
- # IoWriter holds the connection's mutex, so this write is
141
- # serialised against any write the dispatcher/heartbeat
142
- # might still be performing if their stop hadn't fully
143
- # returned yet.
144
- safely { IoWriter.write(connection, sentinel_bytes, deadline_ms: deadline_ms) }
139
+ safely { connection.write_sentinel(sentinel_bytes) }
145
140
  safely { connection.close }
146
141
  end
147
142
  end
@@ -113,6 +113,14 @@ module Pgbus
113
113
  @conn.wait_for_notify(timeout_s) do |channel, _pid, _payload|
114
114
  handle_notify(channel)
115
115
  end || run_health_check
116
+ rescue IOError => e
117
+ # #stop closes the PG connection to interrupt
118
+ # wait_for_notify, which raises IOError ("stream closed
119
+ # in another thread"). This is expected — exit cleanly.
120
+ break unless @running
121
+
122
+ @logger.warn { "[Pgbus::Streamer::Listener] IO error (#{e.class}: #{e.message}) — reconnecting" }
123
+ reconnect!
116
124
  rescue PG::Error => e
117
125
  break unless @running
118
126
 
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.6.3
4
+ version: 0.6.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -256,6 +256,9 @@ files:
256
256
  - lib/pgbus/event_bus/publisher.rb
257
257
  - lib/pgbus/event_bus/registry.rb
258
258
  - lib/pgbus/event_bus/subscriber.rb
259
+ - lib/pgbus/execution_pools.rb
260
+ - lib/pgbus/execution_pools/async_pool.rb
261
+ - lib/pgbus/execution_pools/thread_pool.rb
259
262
  - lib/pgbus/failed_event_recorder.rb
260
263
  - lib/pgbus/generators/config_converter.rb
261
264
  - lib/pgbus/generators/database_target_detector.rb
@@ -303,6 +306,7 @@ files:
303
306
  - lib/pgbus/web/stream_app.rb
304
307
  - lib/pgbus/web/streamer.rb
305
308
  - lib/pgbus/web/streamer/connection.rb
309
+ - lib/pgbus/web/streamer/falcon_connection.rb
306
310
  - lib/pgbus/web/streamer/heartbeat.rb
307
311
  - lib/pgbus/web/streamer/instance.rb
308
312
  - lib/pgbus/web/streamer/io_writer.rb