pgbus 0.1.4 → 0.1.6

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +326 -11
  3. data/app/controllers/pgbus/api/insights_controller.rb +16 -0
  4. data/app/controllers/pgbus/insights_controller.rb +10 -0
  5. data/app/controllers/pgbus/locks_controller.rb +9 -0
  6. data/app/controllers/pgbus/outbox_controller.rb +10 -0
  7. data/app/controllers/pgbus/queues_controller.rb +10 -0
  8. data/app/helpers/pgbus/application_helper.rb +34 -0
  9. data/app/models/pgbus/job_lock.rb +82 -0
  10. data/app/models/pgbus/job_stat.rb +94 -0
  11. data/app/models/pgbus/outbox_entry.rb +10 -0
  12. data/app/models/pgbus/queue_state.rb +33 -0
  13. data/app/views/layouts/pgbus/application.html.erb +33 -8
  14. data/app/views/pgbus/dashboard/_stats_cards.html.erb +24 -18
  15. data/app/views/pgbus/insights/show.html.erb +161 -0
  16. data/app/views/pgbus/locks/index.html.erb +53 -0
  17. data/app/views/pgbus/outbox/index.html.erb +55 -0
  18. data/app/views/pgbus/queues/_queues_list.html.erb +15 -1
  19. data/config/routes.rb +7 -0
  20. data/lib/generators/pgbus/add_job_locks_generator.rb +52 -0
  21. data/lib/generators/pgbus/add_job_stats_generator.rb +52 -0
  22. data/lib/generators/pgbus/add_outbox_generator.rb +52 -0
  23. data/lib/generators/pgbus/add_queue_states_generator.rb +51 -0
  24. data/lib/generators/pgbus/add_recurring_generator.rb +1 -1
  25. data/lib/generators/pgbus/install_generator.rb +1 -1
  26. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +21 -0
  27. data/lib/generators/pgbus/templates/add_job_stats.rb.erb +18 -0
  28. data/lib/generators/pgbus/templates/add_outbox.rb.erb +25 -0
  29. data/lib/generators/pgbus/templates/add_queue_states.rb.erb +16 -0
  30. data/lib/generators/pgbus/upgrade_pgmq_generator.rb +1 -1
  31. data/lib/pgbus/active_job/adapter.rb +64 -9
  32. data/lib/pgbus/active_job/executor.rb +67 -5
  33. data/lib/pgbus/circuit_breaker.rb +112 -0
  34. data/lib/pgbus/client.rb +127 -50
  35. data/lib/pgbus/configuration.rb +55 -1
  36. data/lib/pgbus/dedup_cache.rb +76 -0
  37. data/lib/pgbus/engine.rb +1 -0
  38. data/lib/pgbus/event_bus/handler.rb +13 -2
  39. data/lib/pgbus/outbox/poller.rb +117 -0
  40. data/lib/pgbus/outbox.rb +30 -0
  41. data/lib/pgbus/process/consumer_priority.rb +64 -0
  42. data/lib/pgbus/process/dispatcher.rb +75 -0
  43. data/lib/pgbus/process/heartbeat.rb +3 -1
  44. data/lib/pgbus/process/lifecycle.rb +111 -0
  45. data/lib/pgbus/process/queue_lock.rb +87 -0
  46. data/lib/pgbus/process/supervisor.rb +46 -6
  47. data/lib/pgbus/process/wake_signal.rb +53 -0
  48. data/lib/pgbus/process/worker.rb +117 -21
  49. data/lib/pgbus/queue_factory.rb +62 -0
  50. data/lib/pgbus/rate_counter.rb +81 -0
  51. data/lib/pgbus/recurring/schedule.rb +1 -1
  52. data/lib/pgbus/uniqueness.rb +169 -0
  53. data/lib/pgbus/version.rb +1 -1
  54. data/lib/pgbus/web/data_source.rb +136 -2
  55. data/lib/pgbus.rb +9 -0
  56. metadata +31 -1
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Pgbus
6
+ class CircuitBreaker
7
+ attr_reader :config
8
+
9
+ def initialize(config: Pgbus.configuration)
10
+ @config = config
11
+ @failure_counts = Concurrent::Map.new
12
+ @pause_cache = Concurrent::Map.new
13
+ @pause_cache_ttl = 30 # seconds
14
+ end
15
+
16
+ def record_success(queue_name)
17
+ @failure_counts.delete(queue_name)
18
+ end
19
+
20
+ def record_failure(queue_name)
21
+ return unless config.circuit_breaker_enabled
22
+
23
+ count = @failure_counts.compute(queue_name) { |val| (val || 0) + 1 }
24
+
25
+ return unless count >= config.circuit_breaker_threshold
26
+
27
+ trip!(queue_name, count)
28
+ end
29
+
30
+ def paused?(queue_name)
31
+ cached = @pause_cache[queue_name]
32
+ return cached[:paused] if cached && (Time.now - cached[:checked_at]) < @pause_cache_ttl
33
+
34
+ paused = check_paused(queue_name)
35
+ @pause_cache[queue_name] = { paused: paused, checked_at: Time.now }
36
+ paused
37
+ end
38
+
39
+ def pause!(queue_name, reason: nil)
40
+ QueueState.pause!(queue_name, reason: reason)
41
+ invalidate_cache(queue_name)
42
+ end
43
+
44
+ def resume!(queue_name)
45
+ QueueState.resume!(queue_name)
46
+ @failure_counts.delete(queue_name)
47
+ invalidate_cache(queue_name)
48
+ end
49
+
50
+ def invalidate_cache(queue_name = nil)
51
+ if queue_name
52
+ @pause_cache.delete(queue_name)
53
+ else
54
+ @pause_cache.clear
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def trip!(queue_name, failure_count)
61
+ trip_count = current_trip_count(queue_name) + 1
62
+ backoff = calculate_backoff(trip_count)
63
+ resume_at = Time.current + backoff
64
+
65
+ Pgbus.logger.warn do
66
+ "[Pgbus] Circuit breaker tripped for #{queue_name}: #{failure_count} consecutive failures, " \
67
+ "backoff #{backoff}s (trip ##{trip_count})"
68
+ end
69
+
70
+ QueueState.find_or_initialize_by(queue_name: queue_name).update!(
71
+ paused: true,
72
+ paused_reason: "circuit_breaker: #{failure_count} consecutive failures",
73
+ paused_at: Time.current,
74
+ circuit_breaker_trip_count: trip_count,
75
+ circuit_breaker_resume_at: resume_at
76
+ )
77
+
78
+ @failure_counts.delete(queue_name)
79
+ invalidate_cache(queue_name)
80
+ rescue StandardError => e
81
+ Pgbus.logger.error { "[Pgbus] Circuit breaker trip failed for #{queue_name}: #{e.message}" }
82
+ end
83
+
84
+ def check_paused(queue_name)
85
+ state = QueueState.find_by(queue_name: queue_name)
86
+ return false unless state&.paused?
87
+
88
+ # Auto-resume if circuit breaker backoff has expired
89
+ if state.circuit_breaker_resume_at && Time.current >= state.circuit_breaker_resume_at
90
+ QueueState.resume!(queue_name)
91
+ Pgbus.logger.info { "[Pgbus] Circuit breaker auto-resumed #{queue_name}" }
92
+ return false
93
+ end
94
+
95
+ true
96
+ rescue StandardError => e
97
+ Pgbus.logger.warn { "[Pgbus] Circuit breaker pause check failed for #{queue_name}: #{e.message}" }
98
+ false
99
+ end
100
+
101
+ def current_trip_count(queue_name)
102
+ QueueState.find_by(queue_name: queue_name)&.circuit_breaker_trip_count || 0
103
+ rescue StandardError
104
+ 0
105
+ end
106
+
107
+ def calculate_backoff(trip_count)
108
+ backoff = config.circuit_breaker_base_backoff * (2**(trip_count - 1))
109
+ [backoff, config.circuit_breaker_max_backoff].min
110
+ end
111
+ end
112
+ end
data/lib/pgbus/client.rb CHANGED
@@ -19,23 +19,24 @@ 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
35
+ @queue_strategy = QueueFactory.for(config)
28
36
  end
29
37
 
30
38
  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
- end
39
+ @queue_strategy.physical_queue_names(name).each { |pq| ensure_single_queue(pq) }
39
40
  rescue PGMQ::Errors::ConnectionError => e
40
41
  raise Pgbus::SchemaNotReady,
41
42
  "PGMQ schema is not available (#{e.message}). Run `rails db:migrate` for the pgbus database."
@@ -46,79 +47,111 @@ module Pgbus
46
47
  return if @queues_created[dlq_name]
47
48
 
48
49
  @queues_created.compute_if_absent(dlq_name) do
49
- @pgmq.create(dlq_name)
50
+ synchronized { @pgmq.create(dlq_name) }
50
51
  true
51
52
  end
52
53
  end
53
54
 
54
- def send_message(queue_name, payload, headers: nil, delay: 0)
55
- full_name = config.queue_name(queue_name)
55
+ def send_message(queue_name, payload, headers: nil, delay: 0, priority: nil)
56
+ target = @queue_strategy.target_queue(queue_name, priority)
56
57
  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)
58
+ Instrumentation.instrument("pgbus.client.send_message", queue: target) do
59
+ synchronized { @pgmq.produce(target, serialize(payload), headers: headers && serialize(headers), delay: delay) }
59
60
  end
60
61
  end
61
62
 
62
63
  def send_batch(queue_name, payloads, headers: nil, delay: 0)
63
64
  full_name = config.queue_name(queue_name)
64
65
  ensure_queue(queue_name)
66
+ serialized = payloads.map { |p| serialize(p) }
67
+ serialized_headers = headers&.map { |h| serialize(h) }
65
68
  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)
69
+ synchronized { @pgmq.produce_batch(full_name, serialized, headers: serialized_headers, delay: delay) }
69
70
  end
70
71
  end
71
72
 
72
73
  def read_message(queue_name, vt: nil)
73
74
  full_name = config.queue_name(queue_name)
74
75
  Instrumentation.instrument("pgbus.client.read_message", queue: full_name) do
75
- @pgmq.read(full_name, vt: vt || config.visibility_timeout)
76
+ synchronized { @pgmq.read(full_name, vt: vt || config.visibility_timeout) }
76
77
  end
77
78
  end
78
79
 
79
80
  def read_batch(queue_name, qty:, vt: nil)
80
81
  full_name = config.queue_name(queue_name)
81
82
  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)
83
+ synchronized { @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) }
83
84
  end
84
85
  end
85
86
 
87
+ # Read from priority sub-queues, highest priority (p0) first.
88
+ # Returns [priority_queue_name, messages] pairs.
89
+ def read_batch_prioritized(queue_name, qty:, vt: nil)
90
+ unless @queue_strategy.priority?
91
+ return (read_batch(queue_name, qty: qty, vt: vt) || []).map do |m|
92
+ [config.queue_name(queue_name), m]
93
+ end
94
+ end
95
+
96
+ remaining = qty
97
+ results = []
98
+
99
+ config.priority_queue_names(queue_name).each do |pq_name|
100
+ break if remaining <= 0
101
+
102
+ msgs = Instrumentation.instrument("pgbus.client.read_batch", queue: pq_name, qty: remaining) do
103
+ synchronized { @pgmq.read_batch(pq_name, vt: vt || config.visibility_timeout, qty: remaining) }
104
+ end || []
105
+
106
+ msgs.each { |m| results << [pq_name, m] }
107
+ remaining -= msgs.size
108
+ end
109
+
110
+ results
111
+ end
112
+
86
113
  def read_with_poll(queue_name, qty:, vt: nil, max_poll_seconds: 5, poll_interval_ms: 100)
87
114
  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
- )
115
+ synchronized do
116
+ @pgmq.read_with_poll(
117
+ full_name,
118
+ vt: vt || config.visibility_timeout,
119
+ qty: qty,
120
+ max_poll_seconds: max_poll_seconds,
121
+ poll_interval_ms: poll_interval_ms
122
+ )
123
+ end
95
124
  end
96
125
 
97
126
  def delete_message(queue_name, msg_id)
98
127
  full_name = config.queue_name(queue_name)
99
- @pgmq.delete(full_name, msg_id)
128
+ synchronized { @pgmq.delete(full_name, msg_id) }
100
129
  end
101
130
 
102
131
  def archive_message(queue_name, msg_id)
103
132
  full_name = config.queue_name(queue_name)
104
- @pgmq.archive(full_name, msg_id)
133
+ synchronized { @pgmq.archive(full_name, msg_id) }
134
+ end
135
+
136
+ def archive_from_queue(full_queue_name, msg_id)
137
+ synchronized { @pgmq.archive(full_queue_name, msg_id) }
105
138
  end
106
139
 
107
140
  def extend_visibility(queue_name, msg_id, vt:)
108
141
  full_name = config.queue_name(queue_name)
109
- @pgmq.set_vt(full_name, msg_id, vt: vt)
142
+ synchronized { @pgmq.set_vt(full_name, msg_id, vt: vt) }
110
143
  end
111
144
 
112
145
  def set_visibility_timeout(queue_name, msg_id, vt:)
113
- @pgmq.set_vt(queue_name, msg_id, vt: vt)
146
+ synchronized { @pgmq.set_vt(queue_name, msg_id, vt: vt) }
114
147
  end
115
148
 
116
149
  def delete_from_queue(queue_name, msg_id)
117
- @pgmq.delete(queue_name, msg_id)
150
+ synchronized { @pgmq.delete(queue_name, msg_id) }
118
151
  end
119
152
 
120
- def transaction(&)
121
- @pgmq.transaction(&)
153
+ def transaction(&block)
154
+ synchronized { @pgmq.transaction(&block) }
122
155
  end
123
156
 
124
157
  def move_to_dead_letter(queue_name, message)
@@ -126,50 +159,94 @@ module Pgbus
126
159
  dlq_name = config.dead_letter_queue_name(queue_name)
127
160
  full_queue = config.queue_name(queue_name)
128
161
 
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)
162
+ synchronized do
163
+ @pgmq.transaction do |txn|
164
+ txn.produce(dlq_name, message.message, headers: message.headers)
165
+ txn.delete(full_queue, message.msg_id.to_i)
166
+ end
132
167
  end
133
168
  end
134
169
 
135
170
  def metrics(queue_name = nil)
136
- if queue_name
137
- @pgmq.metrics(config.queue_name(queue_name))
138
- else
139
- @pgmq.metrics_all
171
+ synchronized do
172
+ if queue_name
173
+ @pgmq.metrics(config.queue_name(queue_name))
174
+ else
175
+ @pgmq.metrics_all
176
+ end
140
177
  end
141
178
  end
142
179
 
143
180
  def list_queues
144
- @pgmq.list_queues
181
+ synchronized { @pgmq.list_queues }
145
182
  end
146
183
 
147
184
  def purge_queue(queue_name)
148
- @pgmq.purge_queue(config.queue_name(queue_name))
185
+ synchronized { @pgmq.purge_queue(config.queue_name(queue_name)) }
186
+ end
187
+
188
+ def purge_archive(queue_name, older_than:, batch_size: 1000)
189
+ full_name = config.queue_name(queue_name)
190
+ sanitized = full_name.gsub(/[^a-zA-Z0-9_]/, "")
191
+ total = 0
192
+
193
+ sql = "DELETE FROM pgmq.a_#{sanitized} " \
194
+ "WHERE ctid = ANY(ARRAY(SELECT ctid FROM pgmq.a_#{sanitized} WHERE enqueued_at < $1 LIMIT $2))"
195
+
196
+ loop do
197
+ deleted = synchronized do
198
+ @pgmq.pool.with { |conn| conn.exec_params(sql, [older_than, batch_size]).cmd_tuples }
199
+ end
200
+ total += deleted
201
+ break if deleted < batch_size
202
+ end
203
+
204
+ total
149
205
  end
150
206
 
151
207
  # Topic routing
152
208
  def bind_topic(pattern, queue_name)
153
209
  full_name = config.queue_name(queue_name)
154
210
  ensure_queue(queue_name)
155
- @pgmq.bind_topic(pattern, full_name)
211
+ synchronized { @pgmq.bind_topic(pattern, full_name) }
156
212
  end
157
213
 
158
214
  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
- )
215
+ synchronized do
216
+ @pgmq.produce_topic(
217
+ routing_key,
218
+ serialize(payload),
219
+ headers: headers && serialize(headers),
220
+ delay: delay
221
+ )
222
+ end
165
223
  end
166
224
 
167
225
  def close
168
- @pgmq.close
226
+ synchronized { @pgmq.close }
169
227
  end
170
228
 
171
229
  private
172
230
 
231
+ def ensure_single_queue(full_name)
232
+ return if @queues_created[full_name]
233
+
234
+ @queues_created.compute_if_absent(full_name) do
235
+ synchronized do
236
+ @pgmq.create(full_name)
237
+ @pgmq.enable_notify_insert(full_name, throttle_interval_ms: config.notify_throttle_ms) if config.listen_notify
238
+ end
239
+ true
240
+ end
241
+ end
242
+
243
+ # Serialize all PGMQ operations through a single mutex.
244
+ # PG::Connection is not thread-safe — concurrent access from worker
245
+ # threads causes segfaults and result corruption.
246
+ def synchronized(&)
247
+ @pgmq_mutex.synchronize(&)
248
+ end
249
+
173
250
  def serialize(data)
174
251
  case data
175
252
  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
 
@@ -46,6 +59,9 @@ module Pgbus
46
59
  # Requires a matching entry in config/database.yml under the "pgbus" key.
47
60
  attr_accessor :connects_to
48
61
 
62
+ # Job stats
63
+ attr_accessor :stats_retention, :stats_enabled
64
+
49
65
  # Web dashboard
50
66
  attr_accessor :web_auth, :web_refresh_interval, :web_per_page, :web_live_updates, :web_data_source
51
67
 
@@ -62,15 +78,34 @@ module Pgbus
62
78
  @polling_interval = 0.1
63
79
  @visibility_timeout = 30
64
80
 
81
+ @prefetch_limit = nil
82
+
65
83
  @max_jobs_per_worker = nil
66
84
  @max_memory_mb = nil
67
85
  @max_worker_lifetime = nil
68
86
 
69
87
  @dispatch_interval = 1.0
70
88
 
89
+ @circuit_breaker_enabled = true
90
+ @circuit_breaker_threshold = 5
91
+ @circuit_breaker_base_backoff = 30
92
+ @circuit_breaker_max_backoff = 600
93
+
71
94
  @max_retries = 5
72
95
  @dead_letter_queue_suffix = "_dlq"
73
96
 
97
+ @priority_levels = nil
98
+ @default_priority = 1
99
+
100
+ @archive_retention = 7 * 24 * 3600 # 7 days
101
+ @archive_compaction_interval = 3600
102
+ @archive_compaction_batch_size = 1000
103
+
104
+ @outbox_enabled = false
105
+ @outbox_poll_interval = 1.0
106
+ @outbox_batch_size = 100
107
+ @outbox_retention = 24 * 3600 # 1 day
108
+
74
109
  @idempotency_ttl = 7 * 24 * 3600 # 7 days
75
110
 
76
111
  @logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
@@ -88,6 +123,9 @@ module Pgbus
88
123
  @skip_recurring = false
89
124
  @recurring_execution_retention = 7 * 24 * 3600 # 7 days
90
125
 
126
+ @stats_enabled = true
127
+ @stats_retention = 7 * 24 * 3600 # 7 days
128
+
91
129
  @connects_to = nil
92
130
 
93
131
  @web_auth = nil
@@ -105,6 +143,16 @@ module Pgbus
105
143
  "#{queue_name(name)}#{dead_letter_queue_suffix}"
106
144
  end
107
145
 
146
+ def priority_queue_name(name, priority)
147
+ "#{queue_name(name)}_p#{priority}"
148
+ end
149
+
150
+ def priority_queue_names(name)
151
+ return [queue_name(name)] unless priority_levels && priority_levels > 1
152
+
153
+ (0...priority_levels).map { |p| priority_queue_name(name, p) }
154
+ end
155
+
108
156
  VALID_PGMQ_SCHEMA_MODES = %i[auto extension embedded].freeze
109
157
 
110
158
  def pgmq_schema_mode=(mode)
@@ -128,6 +176,12 @@ module Pgbus
128
176
  raise ArgumentError, "worker threads must be > 0" unless threads.is_a?(Integer) && threads.positive?
129
177
  end
130
178
 
179
+ raise ArgumentError, "prefetch_limit must be > 0" if prefetch_limit && !(prefetch_limit.is_a?(Integer) && prefetch_limit.positive?)
180
+
181
+ if priority_levels && !(priority_levels.is_a?(Integer) && priority_levels >= 1 && priority_levels <= 10)
182
+ raise ArgumentError, "priority_levels must be an integer between 1 and 10"
183
+ end
184
+
131
185
  self
132
186
  end
133
187
 
@@ -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
@@ -34,6 +34,7 @@ module Pgbus
34
34
  initializer "pgbus.active_job" do
35
35
  ActiveSupport.on_load(:active_job) do
36
36
  include Pgbus::Concurrency
37
+ include Pgbus::Uniqueness
37
38
  end
38
39
  end
39
40
 
@@ -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