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
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -9,35 +9,56 @@ module Pgbus
|
|
|
9
9
|
|
|
10
10
|
attr_reader :queues, :threads, :config
|
|
11
11
|
|
|
12
|
-
def initialize(queues:, threads: 5, config: Pgbus.configuration
|
|
12
|
+
def initialize(queues:, threads: 5, config: Pgbus.configuration,
|
|
13
|
+
single_active_consumer: false, consumer_priority: 0)
|
|
13
14
|
@queues = Array(queues)
|
|
14
15
|
@threads = threads
|
|
15
16
|
@config = config
|
|
16
|
-
@
|
|
17
|
+
@single_active_consumer = single_active_consumer
|
|
18
|
+
@consumer_priority = consumer_priority
|
|
19
|
+
@lifecycle = Lifecycle.new
|
|
17
20
|
@jobs_processed = Concurrent::AtomicFixnum.new(0)
|
|
18
21
|
@jobs_failed = Concurrent::AtomicFixnum.new(0)
|
|
22
|
+
@in_flight = Concurrent::AtomicFixnum.new(0)
|
|
23
|
+
@rate_counter = RateCounter.new(:processed, :failed, :dequeued)
|
|
19
24
|
@started_at = Time.now
|
|
20
25
|
@executor = Pgbus::ActiveJob::Executor.new
|
|
21
26
|
@pool = Concurrent::FixedThreadPool.new(threads)
|
|
27
|
+
@circuit_breaker = Pgbus::CircuitBreaker.new(config: config)
|
|
28
|
+
@queue_lock = QueueLock.new if @single_active_consumer
|
|
29
|
+
@wake_signal = WakeSignal.new
|
|
22
30
|
end
|
|
23
31
|
|
|
24
32
|
def stats
|
|
25
|
-
{
|
|
33
|
+
{
|
|
34
|
+
jobs_processed: @jobs_processed.value,
|
|
35
|
+
jobs_failed: @jobs_failed.value,
|
|
36
|
+
in_flight: @in_flight.value,
|
|
37
|
+
state: @lifecycle.state,
|
|
38
|
+
consumer_priority: @consumer_priority,
|
|
39
|
+
single_active_consumer: @single_active_consumer,
|
|
40
|
+
locked_queues: @queue_lock&.held_queues || [],
|
|
41
|
+
rates: @rate_counter.rates,
|
|
42
|
+
started_at: @started_at
|
|
43
|
+
}
|
|
26
44
|
end
|
|
27
45
|
|
|
28
46
|
def run
|
|
29
47
|
setup_signals
|
|
30
48
|
start_heartbeat
|
|
31
49
|
resolve_wildcard_queues
|
|
50
|
+
@lifecycle.transition_to!(:running)
|
|
32
51
|
Pgbus.logger.info { "[Pgbus] Worker started: queues=#{queues.join(",")} threads=#{threads} pid=#{::Process.pid}" }
|
|
33
52
|
|
|
34
53
|
loop do
|
|
35
54
|
process_signals
|
|
36
|
-
|
|
37
|
-
break if recycle_needed? && @pool.queue_length.zero?
|
|
55
|
+
check_recycle
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
break if @lifecycle.stopped?
|
|
58
|
+
break if @lifecycle.draining? && @pool.queue_length.zero?
|
|
59
|
+
|
|
60
|
+
claim_and_execute if @lifecycle.can_process?
|
|
61
|
+
@wake_signal.wait(timeout: config.polling_interval) if @lifecycle.draining? || @lifecycle.paused?
|
|
41
62
|
end
|
|
42
63
|
|
|
43
64
|
shutdown
|
|
@@ -45,43 +66,62 @@ module Pgbus
|
|
|
45
66
|
|
|
46
67
|
def graceful_shutdown
|
|
47
68
|
Pgbus.logger.info { "[Pgbus] Worker shutting down gracefully..." }
|
|
48
|
-
@
|
|
69
|
+
@lifecycle.transition_to(:draining)
|
|
70
|
+
@wake_signal.notify!
|
|
49
71
|
end
|
|
50
72
|
|
|
51
73
|
def immediate_shutdown
|
|
52
74
|
Pgbus.logger.warn { "[Pgbus] Worker shutting down immediately!" }
|
|
53
|
-
@
|
|
75
|
+
@lifecycle.transition_to!(:stopped)
|
|
76
|
+
@wake_signal.notify!
|
|
54
77
|
@pool.kill
|
|
55
78
|
end
|
|
56
79
|
|
|
57
80
|
private
|
|
58
81
|
|
|
59
82
|
def claim_and_execute
|
|
83
|
+
poll_interval = effective_polling_interval
|
|
84
|
+
|
|
60
85
|
idle = @pool.max_length - @pool.queue_length
|
|
61
|
-
return
|
|
86
|
+
return @wake_signal.wait(timeout: poll_interval) if idle <= 0
|
|
87
|
+
|
|
88
|
+
if config.prefetch_limit
|
|
89
|
+
available = config.prefetch_limit - @in_flight.value
|
|
90
|
+
return @wake_signal.wait(timeout: poll_interval) if available <= 0
|
|
91
|
+
|
|
92
|
+
idle = [idle, available].min
|
|
93
|
+
end
|
|
62
94
|
|
|
63
95
|
tagged_messages = fetch_messages(idle)
|
|
64
96
|
|
|
65
97
|
if tagged_messages.empty?
|
|
66
|
-
|
|
98
|
+
@wake_signal.wait(timeout: poll_interval)
|
|
67
99
|
return
|
|
68
100
|
end
|
|
69
101
|
|
|
70
|
-
|
|
71
|
-
|
|
102
|
+
@rate_counter.increment(:dequeued, tagged_messages.size)
|
|
103
|
+
tagged_messages.each do |queue_name, message, source_queue|
|
|
104
|
+
@in_flight.increment
|
|
105
|
+
@pool.post { process_message(message, queue_name, source_queue: source_queue) }
|
|
72
106
|
end
|
|
73
107
|
end
|
|
74
108
|
|
|
75
109
|
# Returns an array of [queue_name, message] pairs so we always know
|
|
76
110
|
# which queue each message came from (PGMQ messages don't carry this).
|
|
77
111
|
def fetch_messages(qty)
|
|
78
|
-
|
|
79
|
-
|
|
112
|
+
active_queues = queues.reject { |q| @circuit_breaker.paused?(q) }
|
|
113
|
+
active_queues = active_queues.select { |q| @queue_lock.try_lock(q) } if @single_active_consumer
|
|
114
|
+
return [] if active_queues.empty?
|
|
115
|
+
|
|
116
|
+
if priority_enabled?
|
|
117
|
+
fetch_prioritized(active_queues, qty)
|
|
118
|
+
elsif active_queues.size == 1
|
|
119
|
+
queue = active_queues.first
|
|
80
120
|
messages = Pgbus.client.read_batch(queue, qty: qty) || []
|
|
81
121
|
messages.map { |m| [queue, m] }
|
|
82
122
|
else
|
|
83
|
-
per_queue = [(qty /
|
|
84
|
-
|
|
123
|
+
per_queue = [(qty / active_queues.size.to_f).ceil, 1].max
|
|
124
|
+
active_queues.flat_map do |q|
|
|
85
125
|
(Pgbus.client.read_batch(q, qty: per_queue) || []).map { |m| [q, m] }
|
|
86
126
|
end.first(qty)
|
|
87
127
|
end
|
|
@@ -90,13 +130,45 @@ module Pgbus
|
|
|
90
130
|
[]
|
|
91
131
|
end
|
|
92
132
|
|
|
93
|
-
def
|
|
94
|
-
|
|
133
|
+
def fetch_prioritized(active_queues, qty)
|
|
134
|
+
remaining = qty
|
|
135
|
+
results = []
|
|
136
|
+
|
|
137
|
+
active_queues.each do |q|
|
|
138
|
+
break if remaining <= 0
|
|
139
|
+
|
|
140
|
+
batch = Pgbus.client.read_batch_prioritized(q, qty: remaining)
|
|
141
|
+
batch.each do |physical_queue, message|
|
|
142
|
+
results << [q, message, physical_queue]
|
|
143
|
+
end
|
|
144
|
+
remaining -= batch.size
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
results
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def priority_enabled?
|
|
151
|
+
config.priority_levels && config.priority_levels > 1
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def process_message(message, queue_name, source_queue: nil)
|
|
155
|
+
result = @executor.execute(message, queue_name, source_queue: source_queue)
|
|
95
156
|
@jobs_processed.increment
|
|
96
|
-
@
|
|
157
|
+
@rate_counter.increment(:processed)
|
|
158
|
+
if result == :failed
|
|
159
|
+
@jobs_failed.increment
|
|
160
|
+
@rate_counter.increment(:failed)
|
|
161
|
+
@circuit_breaker.record_failure(queue_name)
|
|
162
|
+
else
|
|
163
|
+
@circuit_breaker.record_success(queue_name)
|
|
164
|
+
end
|
|
97
165
|
rescue StandardError => e
|
|
98
166
|
@jobs_failed.increment
|
|
167
|
+
@rate_counter.increment(:failed)
|
|
168
|
+
@circuit_breaker.record_failure(queue_name)
|
|
99
169
|
Pgbus.logger.error { "[Pgbus] Unhandled error processing message: #{e.message}" }
|
|
170
|
+
ensure
|
|
171
|
+
@in_flight.decrement
|
|
100
172
|
end
|
|
101
173
|
|
|
102
174
|
# Resolve "*" to all non-DLQ queues from pgmq.meta, stripping the prefix.
|
|
@@ -125,6 +197,13 @@ module Pgbus
|
|
|
125
197
|
@queues = [config.default_queue]
|
|
126
198
|
end
|
|
127
199
|
|
|
200
|
+
def check_recycle
|
|
201
|
+
return unless @lifecycle.running? && recycle_needed?
|
|
202
|
+
|
|
203
|
+
@lifecycle.transition_to(:draining)
|
|
204
|
+
@wake_signal.notify!
|
|
205
|
+
end
|
|
206
|
+
|
|
128
207
|
def recycle_needed?
|
|
129
208
|
exceeded_max_jobs? || exceeded_max_memory? || exceeded_max_lifetime?
|
|
130
209
|
end
|
|
@@ -162,10 +241,26 @@ module Pgbus
|
|
|
162
241
|
end
|
|
163
242
|
end
|
|
164
243
|
|
|
244
|
+
def effective_polling_interval
|
|
245
|
+
return config.polling_interval if @consumer_priority.zero?
|
|
246
|
+
|
|
247
|
+
ConsumerPriority.effective_polling_interval(
|
|
248
|
+
base_interval: config.polling_interval,
|
|
249
|
+
my_priority: @consumer_priority,
|
|
250
|
+
max_priority: ConsumerPriority.max_active_priority(queues, ::Process.pid)
|
|
251
|
+
)
|
|
252
|
+
rescue StandardError
|
|
253
|
+
config.polling_interval
|
|
254
|
+
end
|
|
255
|
+
|
|
165
256
|
def start_heartbeat
|
|
166
257
|
@heartbeat = Heartbeat.new(
|
|
167
258
|
kind: "worker",
|
|
168
|
-
metadata: {
|
|
259
|
+
metadata: {
|
|
260
|
+
queues: queues, threads: threads, pid: ::Process.pid,
|
|
261
|
+
consumer_priority: @consumer_priority
|
|
262
|
+
},
|
|
263
|
+
on_beat: -> { @rate_counter.snapshot! }
|
|
169
264
|
)
|
|
170
265
|
@heartbeat.start
|
|
171
266
|
end
|
|
@@ -174,6 +269,7 @@ module Pgbus
|
|
|
174
269
|
Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
|
|
175
270
|
@pool.shutdown
|
|
176
271
|
@pool.wait_for_termination(30)
|
|
272
|
+
@queue_lock&.unlock_all
|
|
177
273
|
@heartbeat&.stop
|
|
178
274
|
restore_signals
|
|
179
275
|
Pgbus.logger.info { "[Pgbus] Worker stopped. Processed: #{@jobs_processed.value}, Failed: #{@jobs_failed.value}" }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
# Dispatches queue operations based on queue type (standard vs priority).
|
|
5
|
+
# Replaces conditional `priority_enabled?` checks scattered through Client
|
|
6
|
+
# with a single strategy object selected at initialization.
|
|
7
|
+
#
|
|
8
|
+
# Inspired by LavinMQ's QueueFactory which dispatches queue creation by
|
|
9
|
+
# type: standard, durable, priority, stream, delayed.
|
|
10
|
+
module QueueFactory
|
|
11
|
+
def self.for(config)
|
|
12
|
+
if config.priority_levels && config.priority_levels > 1
|
|
13
|
+
PriorityStrategy.new(config)
|
|
14
|
+
else
|
|
15
|
+
StandardStrategy.new(config)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Standard single-queue strategy: one PGMQ queue per logical name.
|
|
20
|
+
class StandardStrategy
|
|
21
|
+
def initialize(config)
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def physical_queue_names(name)
|
|
26
|
+
[@config.queue_name(name)]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def target_queue(name, _priority)
|
|
30
|
+
@config.queue_name(name)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def priority?
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Priority sub-queue strategy: N PGMQ queues per logical name (_p0.._pN).
|
|
39
|
+
class PriorityStrategy
|
|
40
|
+
def initialize(config)
|
|
41
|
+
@config = config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def physical_queue_names(name)
|
|
45
|
+
@config.priority_queue_names(name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def target_queue(name, priority)
|
|
49
|
+
level = if priority
|
|
50
|
+
priority.clamp(0, @config.priority_levels - 1)
|
|
51
|
+
else
|
|
52
|
+
@config.default_priority.clamp(0, @config.priority_levels - 1)
|
|
53
|
+
end
|
|
54
|
+
@config.priority_queue_name(name, level)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def priority?
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
# Thread-safe rate counter inspired by LavinMQ's rate_stats macro.
|
|
7
|
+
# Tracks absolute counts via AtomicFixnum and computes rates as
|
|
8
|
+
# deltas between periodic snapshots.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# counter = RateCounter.new(:enqueued, :dequeued, :failed)
|
|
12
|
+
# counter.increment(:enqueued)
|
|
13
|
+
# counter.snapshot! # call periodically (e.g. every 5s)
|
|
14
|
+
# counter.rate(:enqueued) # => msgs/s since last snapshot
|
|
15
|
+
# counter.rates # => { enqueued: 12.4, dequeued: 10.1, failed: 0.2 }
|
|
16
|
+
class RateCounter
|
|
17
|
+
attr_reader :names
|
|
18
|
+
|
|
19
|
+
def initialize(*names)
|
|
20
|
+
@names = names.map(&:to_sym)
|
|
21
|
+
@counters = {}
|
|
22
|
+
@last_values = {}
|
|
23
|
+
@rates = {}
|
|
24
|
+
@last_snapshot_at = monotonic_now
|
|
25
|
+
|
|
26
|
+
@names.each do |name|
|
|
27
|
+
@counters[name] = Concurrent::AtomicFixnum.new(0)
|
|
28
|
+
@last_values[name] = 0
|
|
29
|
+
@rates[name] = 0.0
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def increment(name, delta = 1)
|
|
34
|
+
@counters.fetch(name).increment(delta)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def count(name)
|
|
38
|
+
@counters.fetch(name).value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def rate(name)
|
|
42
|
+
@rates.fetch(name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def rates
|
|
46
|
+
@names.to_h { |name| [name, @rates[name]] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def counts
|
|
50
|
+
@names.to_h { |name| [name, @counters[name].value] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def snapshot!
|
|
54
|
+
now = monotonic_now
|
|
55
|
+
elapsed = now - @last_snapshot_at
|
|
56
|
+
return if elapsed <= 0
|
|
57
|
+
|
|
58
|
+
@names.each do |name|
|
|
59
|
+
current = @counters[name].value
|
|
60
|
+
delta = current - @last_values[name]
|
|
61
|
+
@rates[name] = (delta / elapsed).round(1)
|
|
62
|
+
@last_values[name] = current
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@last_snapshot_at = now
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_h
|
|
69
|
+
{
|
|
70
|
+
counts: counts,
|
|
71
|
+
rates: rates
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def monotonic_now
|
|
78
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
# Job uniqueness guarantees: prevent duplicate jobs from running concurrently.
|
|
8
|
+
#
|
|
9
|
+
# Unlike concurrency limits (which allow N concurrent jobs for the same key),
|
|
10
|
+
# uniqueness ensures AT MOST ONE job with a given key exists in the system
|
|
11
|
+
# at any time — from enqueue through completion.
|
|
12
|
+
#
|
|
13
|
+
# Lock lifecycle:
|
|
14
|
+
# 1. Enqueue: lock acquired (state: queued), no owner yet
|
|
15
|
+
# 2. Execution start: lock transitions to (state: executing, owner_pid: PID)
|
|
16
|
+
# 3. Completion/DLQ: lock released (row deleted)
|
|
17
|
+
# 4. Crash recovery: reaper detects orphaned locks by cross-referencing
|
|
18
|
+
# owner_pid against pgbus_processes heartbeats
|
|
19
|
+
# 5. Last resort: expires_at TTL (default 24h) handles the case where
|
|
20
|
+
# even the reaper can't run (entire supervisor dead)
|
|
21
|
+
#
|
|
22
|
+
# Strategies:
|
|
23
|
+
# :until_executed — Lock acquired at enqueue, held through execution, released on
|
|
24
|
+
# completion or DLQ. Prevents duplicate enqueue AND duplicate execution.
|
|
25
|
+
#
|
|
26
|
+
# :while_executing — Lock acquired at execution start, released on completion.
|
|
27
|
+
# Allows duplicate enqueue (multiple copies in queue) but only one
|
|
28
|
+
# executes at a time.
|
|
29
|
+
#
|
|
30
|
+
# Usage:
|
|
31
|
+
# class ImportOrderJob < ApplicationJob
|
|
32
|
+
# ensures_uniqueness strategy: :until_executed,
|
|
33
|
+
# key: ->(order_id) { "import-order-#{order_id}" },
|
|
34
|
+
# on_conflict: :reject
|
|
35
|
+
#
|
|
36
|
+
# def perform(order_id)
|
|
37
|
+
# # Only one instance of this job per order_id can exist at a time
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
module Uniqueness
|
|
41
|
+
extend ActiveSupport::Concern
|
|
42
|
+
|
|
43
|
+
METADATA_KEY = "pgbus_uniqueness_key"
|
|
44
|
+
STRATEGY_KEY = "pgbus_uniqueness_strategy"
|
|
45
|
+
TTL_KEY = "pgbus_uniqueness_lock_ttl"
|
|
46
|
+
|
|
47
|
+
# 24 hours — last-resort fallback only. The reaper handles normal crash recovery.
|
|
48
|
+
DEFAULT_LOCK_TTL = 24 * 60 * 60
|
|
49
|
+
|
|
50
|
+
VALID_STRATEGIES = %i[until_executed while_executing].freeze
|
|
51
|
+
VALID_CONFLICTS = %i[reject discard log].freeze
|
|
52
|
+
|
|
53
|
+
class_methods do
|
|
54
|
+
def ensures_uniqueness(strategy: :until_executed, key: nil, lock_ttl: DEFAULT_LOCK_TTL, on_conflict: :reject)
|
|
55
|
+
raise ArgumentError, "strategy must be one of: #{VALID_STRATEGIES.join(", ")}" unless VALID_STRATEGIES.include?(strategy)
|
|
56
|
+
raise ArgumentError, "on_conflict must be one of: #{VALID_CONFLICTS.join(", ")}" unless VALID_CONFLICTS.include?(on_conflict)
|
|
57
|
+
|
|
58
|
+
valid_ttl = lock_ttl.is_a?(Numeric) || (defined?(ActiveSupport::Duration) && lock_ttl.is_a?(ActiveSupport::Duration))
|
|
59
|
+
raise ArgumentError, "lock_ttl must be a positive number or Duration" unless valid_ttl && lock_ttl.positive?
|
|
60
|
+
raise ArgumentError, "key must be callable (Proc or lambda)" if key && !key.respond_to?(:call)
|
|
61
|
+
|
|
62
|
+
@pgbus_uniqueness = {
|
|
63
|
+
strategy: strategy,
|
|
64
|
+
key: key || ->(*) { name },
|
|
65
|
+
lock_ttl: lock_ttl,
|
|
66
|
+
on_conflict: on_conflict
|
|
67
|
+
}.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def pgbus_uniqueness
|
|
71
|
+
@pgbus_uniqueness
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class << self
|
|
76
|
+
def resolve_key(active_job)
|
|
77
|
+
config = uniqueness_config(active_job)
|
|
78
|
+
return nil unless config
|
|
79
|
+
|
|
80
|
+
args = active_job.arguments
|
|
81
|
+
last = args.last
|
|
82
|
+
if last.is_a?(Hash) && last.each_key.all?(Symbol)
|
|
83
|
+
config[:key].call(*args[...-1], **last)
|
|
84
|
+
else
|
|
85
|
+
config[:key].call(*args)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def inject_metadata(active_job, payload_hash)
|
|
90
|
+
config = uniqueness_config(active_job)
|
|
91
|
+
return payload_hash unless config
|
|
92
|
+
|
|
93
|
+
key = resolve_key(active_job)
|
|
94
|
+
return payload_hash unless key
|
|
95
|
+
|
|
96
|
+
payload_hash.merge(
|
|
97
|
+
METADATA_KEY => key,
|
|
98
|
+
STRATEGY_KEY => config[:strategy].to_s,
|
|
99
|
+
TTL_KEY => config[:lock_ttl]
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def extract_key(payload)
|
|
104
|
+
payload&.dig(METADATA_KEY)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def extract_strategy(payload)
|
|
108
|
+
payload&.dig(STRATEGY_KEY)&.to_sym
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def uniqueness_config(active_job)
|
|
112
|
+
return nil unless active_job.class.respond_to?(:pgbus_uniqueness)
|
|
113
|
+
|
|
114
|
+
active_job.class.pgbus_uniqueness
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Acquire the uniqueness lock at enqueue time (:until_executed only).
|
|
118
|
+
# Lock state: queued, no owner_pid yet.
|
|
119
|
+
# Returns :acquired or :locked.
|
|
120
|
+
def acquire_enqueue_lock(key, active_job)
|
|
121
|
+
config = uniqueness_config(active_job)
|
|
122
|
+
return :no_lock unless config
|
|
123
|
+
return :no_lock unless config[:strategy] == :until_executed
|
|
124
|
+
|
|
125
|
+
acquired = JobLock.acquire!(
|
|
126
|
+
key,
|
|
127
|
+
job_class: active_job.class.name,
|
|
128
|
+
job_id: active_job.job_id,
|
|
129
|
+
state: "queued",
|
|
130
|
+
ttl: config[:lock_ttl]
|
|
131
|
+
)
|
|
132
|
+
acquired ? :acquired : :locked
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Transition a queued lock to executing state when the worker picks it up.
|
|
136
|
+
# Called for :until_executed jobs at execution start.
|
|
137
|
+
def claim_for_execution!(key, ttl:)
|
|
138
|
+
JobLock.claim_for_execution!(key, owner_pid: ::Process.pid, owner_hostname: Socket.gethostname, ttl: ttl)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Acquire the uniqueness lock at execution time (:while_executing only).
|
|
142
|
+
# Lock state: executing with owner_pid.
|
|
143
|
+
# Returns true if acquired, false if another instance is running.
|
|
144
|
+
def acquire_execution_lock(key, payload)
|
|
145
|
+
strategy = extract_strategy(payload)
|
|
146
|
+
return true unless strategy == :while_executing
|
|
147
|
+
|
|
148
|
+
ttl = payload[TTL_KEY] || DEFAULT_LOCK_TTL
|
|
149
|
+
|
|
150
|
+
JobLock.acquire!(
|
|
151
|
+
key,
|
|
152
|
+
job_class: payload["job_class"],
|
|
153
|
+
job_id: payload["job_id"],
|
|
154
|
+
state: "executing",
|
|
155
|
+
owner_pid: ::Process.pid,
|
|
156
|
+
owner_hostname: Socket.gethostname,
|
|
157
|
+
ttl: ttl
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Release the uniqueness lock after execution completes.
|
|
162
|
+
def release_lock(key)
|
|
163
|
+
return unless key
|
|
164
|
+
|
|
165
|
+
JobLock.release!(key)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
data/lib/pgbus/version.rb
CHANGED