pgbus 0.1.3 → 0.1.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/pgbus/outbox_controller.rb +10 -0
  3. data/app/controllers/pgbus/queues_controller.rb +10 -0
  4. data/app/helpers/pgbus/application_helper.rb +6 -0
  5. data/app/models/pgbus/outbox_entry.rb +10 -0
  6. data/app/models/pgbus/queue_state.rb +33 -0
  7. data/app/views/layouts/pgbus/application.html.erb +1 -0
  8. data/app/views/pgbus/dashboard/_stats_cards.html.erb +7 -1
  9. data/app/views/pgbus/outbox/index.html.erb +55 -0
  10. data/app/views/pgbus/queues/_queues_list.html.erb +15 -1
  11. data/config/routes.rb +4 -0
  12. data/lib/generators/pgbus/add_outbox_generator.rb +52 -0
  13. data/lib/generators/pgbus/add_queue_states_generator.rb +51 -0
  14. data/lib/generators/pgbus/templates/add_outbox.rb.erb +25 -0
  15. data/lib/generators/pgbus/templates/add_queue_states.rb.erb +16 -0
  16. data/lib/pgbus/active_job/adapter.rb +6 -5
  17. data/lib/pgbus/active_job/executor.rb +22 -5
  18. data/lib/pgbus/circuit_breaker.rb +112 -0
  19. data/lib/pgbus/client.rb +140 -49
  20. data/lib/pgbus/configuration.rb +54 -2
  21. data/lib/pgbus/dedup_cache.rb +76 -0
  22. data/lib/pgbus/engine.rb +6 -0
  23. data/lib/pgbus/event_bus/handler.rb +13 -2
  24. data/lib/pgbus/outbox/poller.rb +117 -0
  25. data/lib/pgbus/outbox.rb +30 -0
  26. data/lib/pgbus/process/dispatcher.rb +46 -0
  27. data/lib/pgbus/process/heartbeat.rb +3 -1
  28. data/lib/pgbus/process/lifecycle.rb +111 -0
  29. data/lib/pgbus/process/supervisor.rb +40 -5
  30. data/lib/pgbus/process/worker.rb +86 -19
  31. data/lib/pgbus/rate_counter.rb +81 -0
  32. data/lib/pgbus/recurring/schedule.rb +1 -1
  33. data/lib/pgbus/version.rb +1 -1
  34. data/lib/pgbus/web/data_source.rb +87 -2
  35. data/lib/pgbus.rb +35 -6
  36. data/lib/tasks/pgbus_pgmq.rake +5 -3
  37. metadata +15 -1
data/lib/pgbus/client.rb CHANGED
@@ -19,22 +19,26 @@ module Pgbus
19
19
  require "pgmq"
20
20
  end
21
21
  @config = config
22
+ # Force pool_size=1. PG::Connection (libpq) is not thread-safe.
23
+ # When using the Rails lambda path (-> { AR::Base.connection.raw_connection }),
24
+ # the pool would return the same underlying PG::Connection that ActiveRecord
25
+ # also uses, causing concurrent access corruption (segfaults, result.ntuples
26
+ # NoMethodError). A single-connection pool combined with @pgmq_mutex ensures
27
+ # all PGMQ operations are serialized.
22
28
  @pgmq = PGMQ::Client.new(
23
29
  config.connection_options,
24
- pool_size: config.pool_size,
30
+ pool_size: 1,
25
31
  pool_timeout: config.pool_timeout
26
32
  )
33
+ @pgmq_mutex = Mutex.new
27
34
  @queues_created = Concurrent::Map.new
28
35
  end
29
36
 
30
37
  def ensure_queue(name)
31
- full_name = config.queue_name(name)
32
- return if @queues_created[full_name]
33
-
34
- @queues_created.compute_if_absent(full_name) do
35
- @pgmq.create(full_name)
36
- @pgmq.enable_notify_insert(full_name, throttle_interval_ms: config.notify_throttle_ms) if config.listen_notify
37
- true
38
+ if priority_enabled?
39
+ config.priority_queue_names(name).each { |pq| ensure_single_queue(pq) }
40
+ else
41
+ ensure_single_queue(config.queue_name(name))
38
42
  end
39
43
  rescue PGMQ::Errors::ConnectionError => e
40
44
  raise Pgbus::SchemaNotReady,
@@ -46,79 +50,107 @@ module Pgbus
46
50
  return if @queues_created[dlq_name]
47
51
 
48
52
  @queues_created.compute_if_absent(dlq_name) do
49
- @pgmq.create(dlq_name)
53
+ synchronized { @pgmq.create(dlq_name) }
50
54
  true
51
55
  end
52
56
  end
53
57
 
54
- def send_message(queue_name, payload, headers: nil, delay: 0)
55
- full_name = config.queue_name(queue_name)
58
+ def send_message(queue_name, payload, headers: nil, delay: 0, priority: nil)
59
+ target = resolve_target_queue(queue_name, priority)
56
60
  ensure_queue(queue_name)
57
- Instrumentation.instrument("pgbus.client.send_message", queue: full_name) do
58
- @pgmq.produce(full_name, serialize(payload), headers: headers && serialize(headers), delay: delay)
61
+ Instrumentation.instrument("pgbus.client.send_message", queue: target) do
62
+ synchronized { @pgmq.produce(target, serialize(payload), headers: headers && serialize(headers), delay: delay) }
59
63
  end
60
64
  end
61
65
 
62
66
  def send_batch(queue_name, payloads, headers: nil, delay: 0)
63
67
  full_name = config.queue_name(queue_name)
64
68
  ensure_queue(queue_name)
69
+ serialized = payloads.map { |p| serialize(p) }
70
+ serialized_headers = headers&.map { |h| serialize(h) }
65
71
  Instrumentation.instrument("pgbus.client.send_batch", queue: full_name, size: payloads.size) do
66
- serialized = payloads.map { |p| serialize(p) }
67
- serialized_headers = headers&.map { |h| serialize(h) }
68
- @pgmq.produce_batch(full_name, serialized, headers: serialized_headers, delay: delay)
72
+ synchronized { @pgmq.produce_batch(full_name, serialized, headers: serialized_headers, delay: delay) }
69
73
  end
70
74
  end
71
75
 
72
76
  def read_message(queue_name, vt: nil)
73
77
  full_name = config.queue_name(queue_name)
74
78
  Instrumentation.instrument("pgbus.client.read_message", queue: full_name) do
75
- @pgmq.read(full_name, vt: vt || config.visibility_timeout)
79
+ synchronized { @pgmq.read(full_name, vt: vt || config.visibility_timeout) }
76
80
  end
77
81
  end
78
82
 
79
83
  def read_batch(queue_name, qty:, vt: nil)
80
84
  full_name = config.queue_name(queue_name)
81
85
  Instrumentation.instrument("pgbus.client.read_batch", queue: full_name, qty: qty) do
82
- @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty)
86
+ synchronized { @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) }
87
+ end
88
+ end
89
+
90
+ # Read from priority sub-queues, highest priority (p0) first.
91
+ # Returns [priority_queue_name, messages] pairs.
92
+ def read_batch_prioritized(queue_name, qty:, vt: nil)
93
+ return (read_batch(queue_name, qty: qty, vt: vt) || []).map { |m| [config.queue_name(queue_name), m] } unless priority_enabled?
94
+
95
+ remaining = qty
96
+ results = []
97
+
98
+ config.priority_queue_names(queue_name).each do |pq_name|
99
+ break if remaining <= 0
100
+
101
+ msgs = Instrumentation.instrument("pgbus.client.read_batch", queue: pq_name, qty: remaining) do
102
+ synchronized { @pgmq.read_batch(pq_name, vt: vt || config.visibility_timeout, qty: remaining) }
103
+ end || []
104
+
105
+ msgs.each { |m| results << [pq_name, m] }
106
+ remaining -= msgs.size
83
107
  end
108
+
109
+ results
84
110
  end
85
111
 
86
112
  def read_with_poll(queue_name, qty:, vt: nil, max_poll_seconds: 5, poll_interval_ms: 100)
87
113
  full_name = config.queue_name(queue_name)
88
- @pgmq.read_with_poll(
89
- full_name,
90
- vt: vt || config.visibility_timeout,
91
- qty: qty,
92
- max_poll_seconds: max_poll_seconds,
93
- poll_interval_ms: poll_interval_ms
94
- )
114
+ synchronized do
115
+ @pgmq.read_with_poll(
116
+ full_name,
117
+ vt: vt || config.visibility_timeout,
118
+ qty: qty,
119
+ max_poll_seconds: max_poll_seconds,
120
+ poll_interval_ms: poll_interval_ms
121
+ )
122
+ end
95
123
  end
96
124
 
97
125
  def delete_message(queue_name, msg_id)
98
126
  full_name = config.queue_name(queue_name)
99
- @pgmq.delete(full_name, msg_id)
127
+ synchronized { @pgmq.delete(full_name, msg_id) }
100
128
  end
101
129
 
102
130
  def archive_message(queue_name, msg_id)
103
131
  full_name = config.queue_name(queue_name)
104
- @pgmq.archive(full_name, msg_id)
132
+ synchronized { @pgmq.archive(full_name, msg_id) }
133
+ end
134
+
135
+ def archive_from_queue(full_queue_name, msg_id)
136
+ synchronized { @pgmq.archive(full_queue_name, msg_id) }
105
137
  end
106
138
 
107
139
  def extend_visibility(queue_name, msg_id, vt:)
108
140
  full_name = config.queue_name(queue_name)
109
- @pgmq.set_vt(full_name, msg_id, vt: vt)
141
+ synchronized { @pgmq.set_vt(full_name, msg_id, vt: vt) }
110
142
  end
111
143
 
112
144
  def set_visibility_timeout(queue_name, msg_id, vt:)
113
- @pgmq.set_vt(queue_name, msg_id, vt: vt)
145
+ synchronized { @pgmq.set_vt(queue_name, msg_id, vt: vt) }
114
146
  end
115
147
 
116
148
  def delete_from_queue(queue_name, msg_id)
117
- @pgmq.delete(queue_name, msg_id)
149
+ synchronized { @pgmq.delete(queue_name, msg_id) }
118
150
  end
119
151
 
120
- def transaction(&)
121
- @pgmq.transaction(&)
152
+ def transaction(&block)
153
+ synchronized { @pgmq.transaction(&block) }
122
154
  end
123
155
 
124
156
  def move_to_dead_letter(queue_name, message)
@@ -126,50 +158,109 @@ module Pgbus
126
158
  dlq_name = config.dead_letter_queue_name(queue_name)
127
159
  full_queue = config.queue_name(queue_name)
128
160
 
129
- @pgmq.transaction do |txn|
130
- txn.produce(dlq_name, message.message, headers: message.headers)
131
- txn.delete(full_queue, message.msg_id.to_i)
161
+ synchronized do
162
+ @pgmq.transaction do |txn|
163
+ txn.produce(dlq_name, message.message, headers: message.headers)
164
+ txn.delete(full_queue, message.msg_id.to_i)
165
+ end
132
166
  end
133
167
  end
134
168
 
135
169
  def metrics(queue_name = nil)
136
- if queue_name
137
- @pgmq.metrics(config.queue_name(queue_name))
138
- else
139
- @pgmq.metrics_all
170
+ synchronized do
171
+ if queue_name
172
+ @pgmq.metrics(config.queue_name(queue_name))
173
+ else
174
+ @pgmq.metrics_all
175
+ end
140
176
  end
141
177
  end
142
178
 
143
179
  def list_queues
144
- @pgmq.list_queues
180
+ synchronized { @pgmq.list_queues }
145
181
  end
146
182
 
147
183
  def purge_queue(queue_name)
148
- @pgmq.purge_queue(config.queue_name(queue_name))
184
+ synchronized { @pgmq.purge_queue(config.queue_name(queue_name)) }
185
+ end
186
+
187
+ def purge_archive(queue_name, older_than:, batch_size: 1000)
188
+ full_name = config.queue_name(queue_name)
189
+ sanitized = full_name.gsub(/[^a-zA-Z0-9_]/, "")
190
+ total = 0
191
+
192
+ sql = "DELETE FROM pgmq.a_#{sanitized} " \
193
+ "WHERE ctid = ANY(ARRAY(SELECT ctid FROM pgmq.a_#{sanitized} WHERE enqueued_at < $1 LIMIT $2))"
194
+
195
+ loop do
196
+ deleted = synchronized do
197
+ @pgmq.pool.with { |conn| conn.exec_params(sql, [older_than, batch_size]).cmd_tuples }
198
+ end
199
+ total += deleted
200
+ break if deleted < batch_size
201
+ end
202
+
203
+ total
149
204
  end
150
205
 
151
206
  # Topic routing
152
207
  def bind_topic(pattern, queue_name)
153
208
  full_name = config.queue_name(queue_name)
154
209
  ensure_queue(queue_name)
155
- @pgmq.bind_topic(pattern, full_name)
210
+ synchronized { @pgmq.bind_topic(pattern, full_name) }
156
211
  end
157
212
 
158
213
  def publish_to_topic(routing_key, payload, headers: nil, delay: 0)
159
- @pgmq.produce_topic(
160
- routing_key,
161
- serialize(payload),
162
- headers: headers && serialize(headers),
163
- delay: delay
164
- )
214
+ synchronized do
215
+ @pgmq.produce_topic(
216
+ routing_key,
217
+ serialize(payload),
218
+ headers: headers && serialize(headers),
219
+ delay: delay
220
+ )
221
+ end
165
222
  end
166
223
 
167
224
  def close
168
- @pgmq.close
225
+ synchronized { @pgmq.close }
169
226
  end
170
227
 
171
228
  private
172
229
 
230
+ def ensure_single_queue(full_name)
231
+ return if @queues_created[full_name]
232
+
233
+ @queues_created.compute_if_absent(full_name) do
234
+ synchronized do
235
+ @pgmq.create(full_name)
236
+ @pgmq.enable_notify_insert(full_name, throttle_interval_ms: config.notify_throttle_ms) if config.listen_notify
237
+ end
238
+ true
239
+ end
240
+ end
241
+
242
+ def priority_enabled?
243
+ config.priority_levels && config.priority_levels > 1
244
+ end
245
+
246
+ def resolve_target_queue(queue_name, priority)
247
+ if priority_enabled? && priority
248
+ clamped = priority.clamp(0, config.priority_levels - 1)
249
+ config.priority_queue_name(queue_name, clamped)
250
+ elsif priority_enabled?
251
+ config.priority_queue_name(queue_name, config.default_priority.clamp(0, config.priority_levels - 1))
252
+ else
253
+ config.queue_name(queue_name)
254
+ end
255
+ end
256
+
257
+ # Serialize all PGMQ operations through a single mutex.
258
+ # PG::Connection is not thread-safe — concurrent access from worker
259
+ # threads causes segfaults and result corruption.
260
+ def synchronized(&)
261
+ @pgmq_mutex.synchronize(&)
262
+ end
263
+
173
264
  def serialize(data)
174
265
  case data
175
266
  when String
@@ -11,7 +11,7 @@ module Pgbus
11
11
  attr_accessor :default_queue, :queue_prefix
12
12
 
13
13
  # Worker settings
14
- attr_accessor :workers, :polling_interval, :visibility_timeout
14
+ attr_accessor :workers, :polling_interval, :visibility_timeout, :prefetch_limit
15
15
 
16
16
  # Worker recycling
17
17
  attr_accessor :max_jobs_per_worker, :max_memory_mb, :max_worker_lifetime
@@ -19,9 +19,22 @@ module Pgbus
19
19
  # Dispatcher settings
20
20
  attr_accessor :dispatch_interval
21
21
 
22
+ # Circuit breaker
23
+ attr_accessor :circuit_breaker_enabled, :circuit_breaker_threshold,
24
+ :circuit_breaker_base_backoff, :circuit_breaker_max_backoff
25
+
22
26
  # Dead letter queue
23
27
  attr_accessor :max_retries, :dead_letter_queue_suffix
24
28
 
29
+ # Priority queues
30
+ attr_accessor :priority_levels, :default_priority
31
+
32
+ # Archive compaction
33
+ attr_accessor :archive_retention, :archive_compaction_interval, :archive_compaction_batch_size
34
+
35
+ # Transactional outbox
36
+ attr_accessor :outbox_enabled, :outbox_poll_interval, :outbox_batch_size, :outbox_retention
37
+
25
38
  # Event bus
26
39
  attr_accessor :idempotency_ttl
27
40
 
@@ -62,15 +75,34 @@ module Pgbus
62
75
  @polling_interval = 0.1
63
76
  @visibility_timeout = 30
64
77
 
78
+ @prefetch_limit = nil
79
+
65
80
  @max_jobs_per_worker = nil
66
81
  @max_memory_mb = nil
67
82
  @max_worker_lifetime = nil
68
83
 
69
84
  @dispatch_interval = 1.0
70
85
 
86
+ @circuit_breaker_enabled = true
87
+ @circuit_breaker_threshold = 5
88
+ @circuit_breaker_base_backoff = 30
89
+ @circuit_breaker_max_backoff = 600
90
+
71
91
  @max_retries = 5
72
92
  @dead_letter_queue_suffix = "_dlq"
73
93
 
94
+ @priority_levels = nil
95
+ @default_priority = 1
96
+
97
+ @archive_retention = 7 * 24 * 3600 # 7 days
98
+ @archive_compaction_interval = 3600
99
+ @archive_compaction_batch_size = 1000
100
+
101
+ @outbox_enabled = false
102
+ @outbox_poll_interval = 1.0
103
+ @outbox_batch_size = 100
104
+ @outbox_retention = 24 * 3600 # 1 day
105
+
74
106
  @idempotency_ttl = 7 * 24 * 3600 # 7 days
75
107
 
76
108
  @logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
@@ -105,6 +137,16 @@ module Pgbus
105
137
  "#{queue_name(name)}#{dead_letter_queue_suffix}"
106
138
  end
107
139
 
140
+ def priority_queue_name(name, priority)
141
+ "#{queue_name(name)}_p#{priority}"
142
+ end
143
+
144
+ def priority_queue_names(name)
145
+ return [queue_name(name)] unless priority_levels && priority_levels > 1
146
+
147
+ (0...priority_levels).map { |p| priority_queue_name(name, p) }
148
+ end
149
+
108
150
  VALID_PGMQ_SCHEMA_MODES = %i[auto extension embedded].freeze
109
151
 
110
152
  def pgmq_schema_mode=(mode)
@@ -128,6 +170,12 @@ module Pgbus
128
170
  raise ArgumentError, "worker threads must be > 0" unless threads.is_a?(Integer) && threads.positive?
129
171
  end
130
172
 
173
+ raise ArgumentError, "prefetch_limit must be > 0" if prefetch_limit && !(prefetch_limit.is_a?(Integer) && prefetch_limit.positive?)
174
+
175
+ if priority_levels && !(priority_levels.is_a?(Integer) && priority_levels >= 1 && priority_levels <= 10)
176
+ raise ArgumentError, "priority_levels must be an integer between 1 and 10"
177
+ end
178
+
131
179
  self
132
180
  end
133
181
 
@@ -137,7 +185,11 @@ module Pgbus
137
185
  elsif connection_params
138
186
  connection_params
139
187
  elsif defined?(ActiveRecord::Base)
140
- -> { ActiveRecord::Base.connection.raw_connection }
188
+ if connects_to
189
+ -> { Pgbus::ApplicationRecord.connection.raw_connection }
190
+ else
191
+ -> { ActiveRecord::Base.connection.raw_connection }
192
+ end
141
193
  else
142
194
  raise ConfigurationError, "No database connection configured. " \
143
195
  "Set Pgbus.configuration.database_url, connection_params, or use with Rails."
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Pgbus
6
+ # Thread-safe in-memory dedup cache with TTL.
7
+ # Sits in front of the pgbus_processed_events table to avoid
8
+ # hitting the database for recently-seen (event_id, handler_class) pairs.
9
+ #
10
+ # Inspired by LavinMQ's built-in message deduplication with LRU + TTL.
11
+ class DedupCache
12
+ DEFAULT_MAX_SIZE = 10_000
13
+ DEFAULT_TTL = 300 # 5 minutes
14
+
15
+ def initialize(max_size: DEFAULT_MAX_SIZE, ttl: DEFAULT_TTL)
16
+ @max_size = max_size
17
+ @ttl = ttl
18
+ @cache = Concurrent::Map.new
19
+ @insertion_order = Concurrent::Array.new
20
+ end
21
+
22
+ # Returns true if this key was already seen (duplicate).
23
+ # Returns false if it's new (first time seen — caller should proceed).
24
+ def seen?(key)
25
+ entry = @cache[key]
26
+ return false unless entry
27
+
28
+ if expired?(entry)
29
+ evict(key)
30
+ return false
31
+ end
32
+
33
+ true
34
+ end
35
+
36
+ # Mark a key as seen. Call this after successfully claiming idempotency
37
+ # in the database so future lookups skip the DB.
38
+ def mark!(key)
39
+ already_present = @cache.key?(key)
40
+ evict_oldest if !already_present && @cache.size >= @max_size
41
+
42
+ @cache[key] = monotonic_now
43
+ @insertion_order << key unless already_present
44
+ end
45
+
46
+ def size
47
+ @cache.size
48
+ end
49
+
50
+ def clear!
51
+ @cache.clear
52
+ @insertion_order.clear
53
+ end
54
+
55
+ private
56
+
57
+ def expired?(timestamp)
58
+ (monotonic_now - timestamp) > @ttl
59
+ end
60
+
61
+ def evict(key)
62
+ @cache.delete(key)
63
+ end
64
+
65
+ def evict_oldest
66
+ while @cache.size >= @max_size && !@insertion_order.empty?
67
+ oldest_key = @insertion_order.shift
68
+ @cache.delete(oldest_key)
69
+ end
70
+ end
71
+
72
+ def monotonic_now
73
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
74
+ end
75
+ end
76
+ end
data/lib/pgbus/engine.rb CHANGED
@@ -6,6 +6,12 @@ module Pgbus
6
6
  class Engine < ::Rails::Engine
7
7
  isolate_namespace Pgbus
8
8
 
9
+ # When pgbus was loaded before Rails, a separate Zeitwerk loader manages
10
+ # app/models. Tear it down before Rails' autoloader claims the same paths.
11
+ initializer "pgbus.release_models_loader", before: :set_autoload_paths do
12
+ Pgbus.teardown_models_loader!
13
+ end
14
+
9
15
  initializer "pgbus.configure" do |app|
10
16
  config_path = app.root.join("config", "pgbus.yml")
11
17
  Pgbus::ConfigLoader.load(config_path) if config_path.exist?
@@ -11,6 +11,10 @@ module Pgbus
11
11
  def idempotent?
12
12
  @idempotent == true
13
13
  end
14
+
15
+ def dedup_cache
16
+ @dedup_cache ||= DedupCache.new
17
+ end
14
18
  end
15
19
 
16
20
  def process(message)
@@ -50,13 +54,20 @@ module Pgbus
50
54
  # Atomically claim idempotency: INSERT ... ON CONFLICT DO NOTHING.
51
55
  # Returns true if this handler claimed the event (row was inserted),
52
56
  # false if another handler already processed it (conflict, no insert).
57
+ #
58
+ # Uses an in-memory dedup cache to skip the DB for recently-seen events.
53
59
  def claim_idempotency?(event_id)
60
+ cache_key = "#{event_id}:#{self.class.name}"
61
+ return false if self.class.dedup_cache.seen?(cache_key)
62
+
54
63
  result = ProcessedEvent.insert(
55
64
  { event_id: event_id, handler_class: self.class.name, processed_at: Time.now.utc },
56
65
  unique_by: %i[event_id handler_class]
57
66
  )
58
- # insert returns an InsertAll::Result; inserted row count > 0 means we claimed it
59
- result.rows.any?
67
+
68
+ claimed = result.rows.any?
69
+ self.class.dedup_cache.mark!(cache_key)
70
+ claimed
60
71
  end
61
72
  end
62
73
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Outbox
5
+ class Poller
6
+ include Process::SignalHandler
7
+
8
+ attr_reader :config
9
+
10
+ def initialize(config: Pgbus.configuration)
11
+ @config = config
12
+ @shutting_down = false
13
+ end
14
+
15
+ def run
16
+ setup_signals
17
+ start_heartbeat
18
+ Pgbus.logger.info { "[Pgbus] Outbox poller started: interval=#{config.outbox_poll_interval}s" }
19
+
20
+ loop do
21
+ break if @shutting_down
22
+
23
+ process_signals
24
+ break if @shutting_down
25
+
26
+ poll_and_publish
27
+ break if @shutting_down
28
+
29
+ interruptible_sleep(config.outbox_poll_interval)
30
+ end
31
+
32
+ shutdown
33
+ end
34
+
35
+ def graceful_shutdown
36
+ @shutting_down = true
37
+ end
38
+
39
+ def immediate_shutdown
40
+ @shutting_down = true
41
+ end
42
+
43
+ def poll_and_publish
44
+ published = 0
45
+
46
+ loop do
47
+ succeeded = 0
48
+
49
+ OutboxEntry.transaction do
50
+ entries = OutboxEntry.unpublished
51
+ .order(:id)
52
+ .limit(config.outbox_batch_size)
53
+ .lock("FOR UPDATE SKIP LOCKED")
54
+ .to_a
55
+ break if entries.empty?
56
+
57
+ entries.each do |entry|
58
+ succeeded += 1 if publish_entry(entry)
59
+ end
60
+
61
+ published += succeeded
62
+ break if succeeded.zero? || entries.size < config.outbox_batch_size
63
+ end
64
+
65
+ break if succeeded.zero?
66
+ end
67
+
68
+ Pgbus.logger.debug { "[Pgbus] Outbox published #{published} entries" } if published.positive?
69
+ published
70
+ rescue StandardError => e
71
+ Pgbus.logger.error { "[Pgbus] Outbox poll error: #{e.message}" }
72
+ 0
73
+ end
74
+
75
+ private
76
+
77
+ def publish_entry(entry)
78
+ if entry.routing_key.present?
79
+ Pgbus.client.publish_to_topic(
80
+ entry.routing_key,
81
+ entry.payload,
82
+ headers: entry.headers,
83
+ delay: entry.delay || 0
84
+ )
85
+ else
86
+ Pgbus.client.send_message(
87
+ entry.queue_name,
88
+ entry.payload,
89
+ headers: entry.headers,
90
+ delay: entry.delay || 0,
91
+ priority: entry.priority
92
+ )
93
+ end
94
+
95
+ entry.update!(published_at: Time.current)
96
+ true
97
+ rescue StandardError => e
98
+ Pgbus.logger.error { "[Pgbus] Failed to publish outbox entry #{entry.id}: #{e.message}" }
99
+ false
100
+ end
101
+
102
+ def start_heartbeat
103
+ @heartbeat = Process::Heartbeat.new(
104
+ kind: "outbox_poller",
105
+ metadata: { pid: ::Process.pid }
106
+ )
107
+ @heartbeat.start
108
+ end
109
+
110
+ def shutdown
111
+ @heartbeat&.stop
112
+ restore_signals
113
+ Pgbus.logger.info { "[Pgbus] Outbox poller stopped" }
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Outbox
5
+ module_function
6
+
7
+ def publish(queue_name, payload, headers: nil, priority: nil, delay: 0)
8
+ OutboxEntry.create!(
9
+ queue_name: queue_name,
10
+ payload: payload,
11
+ headers: headers,
12
+ priority: priority || Pgbus.configuration.default_priority,
13
+ delay: delay
14
+ )
15
+ end
16
+
17
+ def publish_event(routing_key, payload, headers: nil)
18
+ event_data = EventBus::Publisher.build_event_data(payload)
19
+ OutboxEntry.create!(
20
+ routing_key: routing_key,
21
+ payload: event_data,
22
+ headers: headers
23
+ )
24
+ end
25
+
26
+ def flush!
27
+ Poller.new.poll_and_publish
28
+ end
29
+ end
30
+ end