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.
- checksums.yaml +4 -4
- data/app/controllers/pgbus/outbox_controller.rb +10 -0
- data/app/controllers/pgbus/queues_controller.rb +10 -0
- data/app/helpers/pgbus/application_helper.rb +6 -0
- data/app/models/pgbus/outbox_entry.rb +10 -0
- data/app/models/pgbus/queue_state.rb +33 -0
- data/app/views/layouts/pgbus/application.html.erb +1 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +7 -1
- data/app/views/pgbus/outbox/index.html.erb +55 -0
- data/app/views/pgbus/queues/_queues_list.html.erb +15 -1
- data/config/routes.rb +4 -0
- data/lib/generators/pgbus/add_outbox_generator.rb +52 -0
- data/lib/generators/pgbus/add_queue_states_generator.rb +51 -0
- data/lib/generators/pgbus/templates/add_outbox.rb.erb +25 -0
- data/lib/generators/pgbus/templates/add_queue_states.rb.erb +16 -0
- data/lib/pgbus/active_job/adapter.rb +6 -5
- data/lib/pgbus/active_job/executor.rb +22 -5
- data/lib/pgbus/circuit_breaker.rb +112 -0
- data/lib/pgbus/client.rb +140 -49
- data/lib/pgbus/configuration.rb +54 -2
- data/lib/pgbus/dedup_cache.rb +76 -0
- data/lib/pgbus/engine.rb +6 -0
- data/lib/pgbus/event_bus/handler.rb +13 -2
- data/lib/pgbus/outbox/poller.rb +117 -0
- data/lib/pgbus/outbox.rb +30 -0
- data/lib/pgbus/process/dispatcher.rb +46 -0
- data/lib/pgbus/process/heartbeat.rb +3 -1
- data/lib/pgbus/process/lifecycle.rb +111 -0
- data/lib/pgbus/process/supervisor.rb +40 -5
- data/lib/pgbus/process/worker.rb +86 -19
- data/lib/pgbus/rate_counter.rb +81 -0
- data/lib/pgbus/recurring/schedule.rb +1 -1
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +87 -2
- data/lib/pgbus.rb +35 -6
- data/lib/tasks/pgbus_pgmq.rake +5 -3
- 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:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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:
|
|
58
|
-
@pgmq.produce(
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/pgbus/outbox.rb
ADDED
|
@@ -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
|