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.
- checksums.yaml +4 -4
- data/README.md +326 -11
- data/app/controllers/pgbus/api/insights_controller.rb +16 -0
- data/app/controllers/pgbus/insights_controller.rb +10 -0
- data/app/controllers/pgbus/locks_controller.rb +9 -0
- 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 +34 -0
- data/app/models/pgbus/job_lock.rb +82 -0
- data/app/models/pgbus/job_stat.rb +94 -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 +33 -8
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +24 -18
- data/app/views/pgbus/insights/show.html.erb +161 -0
- data/app/views/pgbus/locks/index.html.erb +53 -0
- 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 +7 -0
- data/lib/generators/pgbus/add_job_locks_generator.rb +52 -0
- data/lib/generators/pgbus/add_job_stats_generator.rb +52 -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/add_recurring_generator.rb +1 -1
- data/lib/generators/pgbus/install_generator.rb +1 -1
- data/lib/generators/pgbus/templates/add_job_locks.rb.erb +21 -0
- data/lib/generators/pgbus/templates/add_job_stats.rb.erb +18 -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/generators/pgbus/upgrade_pgmq_generator.rb +1 -1
- data/lib/pgbus/active_job/adapter.rb +64 -9
- data/lib/pgbus/active_job/executor.rb +67 -5
- data/lib/pgbus/circuit_breaker.rb +112 -0
- data/lib/pgbus/client.rb +127 -50
- data/lib/pgbus/configuration.rb +55 -1
- data/lib/pgbus/dedup_cache.rb +76 -0
- data/lib/pgbus/engine.rb +1 -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/consumer_priority.rb +64 -0
- data/lib/pgbus/process/dispatcher.rb +75 -0
- data/lib/pgbus/process/heartbeat.rb +3 -1
- data/lib/pgbus/process/lifecycle.rb +111 -0
- data/lib/pgbus/process/queue_lock.rb +87 -0
- data/lib/pgbus/process/supervisor.rb +46 -6
- data/lib/pgbus/process/wake_signal.rb +53 -0
- data/lib/pgbus/process/worker.rb +117 -21
- data/lib/pgbus/queue_factory.rb +62 -0
- data/lib/pgbus/rate_counter.rb +81 -0
- data/lib/pgbus/recurring/schedule.rb +1 -1
- data/lib/pgbus/uniqueness.rb +169 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +136 -2
- data/lib/pgbus.rb +9 -0
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
58
|
-
@pgmq.produce(
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
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
|
|
|
@@ -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
|
@@ -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
|