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
@@ -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
- @shutting_down = false
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
- { jobs_processed: @jobs_processed.value, jobs_failed: @jobs_failed.value, started_at: @started_at }
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
- break if @shutting_down && @pool.queue_length.zero?
37
- break if recycle_needed? && @pool.queue_length.zero?
55
+ check_recycle
38
56
 
39
- claim_and_execute unless @shutting_down || recycle_needed?
40
- interruptible_sleep(config.polling_interval) if (@shutting_down || recycle_needed?) && !@pool.queue_length.zero?
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
- @shutting_down = true
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
- @shutting_down = true
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 interruptible_sleep(config.polling_interval) if idle <= 0
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
- interruptible_sleep(config.polling_interval)
98
+ @wake_signal.wait(timeout: poll_interval)
67
99
  return
68
100
  end
69
101
 
70
- tagged_messages.each do |queue_name, message|
71
- @pool.post { process_message(message, queue_name) }
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
- if queues.size == 1
79
- queue = queues.first
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 / queues.size.to_f).ceil, 1].max
84
- queues.flat_map do |q|
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 process_message(message, queue_name)
94
- result = @executor.execute(message, queue_name)
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
- @jobs_failed.increment if result == :failed
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: { queues: queues, threads: threads, pid: ::Process.pid }
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
@@ -87,7 +87,7 @@ module Pgbus
87
87
  end
88
88
 
89
89
  def resolve_queue(task)
90
- @config.queue_name(task.queue_name || @config.default_queue)
90
+ task.queue_name || @config.default_queue
91
91
  end
92
92
 
93
93
  def build_headers(task, run_at)
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.1.4"
4
+ VERSION = "0.1.6"
5
5
  end