pgbus 0.3.3 → 0.3.4

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/pgbus/dead_letter_controller.rb +17 -0
  3. data/app/controllers/pgbus/jobs_controller.rb +36 -0
  4. data/app/controllers/pgbus/locks_controller.rb +25 -0
  5. data/app/frontend/pgbus/application.js +45 -0
  6. data/app/models/pgbus/job_lock.rb +16 -8
  7. data/app/models/pgbus/uniqueness_key.rb +36 -0
  8. data/app/views/pgbus/dead_letter/_messages_table.html.erb +22 -2
  9. data/app/views/pgbus/dead_letter/index.html.erb +9 -1
  10. data/app/views/pgbus/jobs/_enqueued_table.html.erb +36 -6
  11. data/app/views/pgbus/jobs/_failed_table.html.erb +35 -4
  12. data/app/views/pgbus/locks/index.html.erb +53 -28
  13. data/config/locales/da.yml +3 -7
  14. data/config/locales/de.yml +3 -7
  15. data/config/locales/en.yml +33 -7
  16. data/config/locales/es.yml +3 -7
  17. data/config/locales/fi.yml +3 -7
  18. data/config/locales/fr.yml +3 -7
  19. data/config/locales/it.yml +3 -7
  20. data/config/locales/ja.yml +3 -7
  21. data/config/locales/nb.yml +3 -7
  22. data/config/locales/nl.yml +3 -7
  23. data/config/locales/pt.yml +3 -7
  24. data/config/locales/sv.yml +3 -7
  25. data/config/routes.rb +12 -1
  26. data/lib/generators/pgbus/migrate_job_locks_generator.rb +56 -0
  27. data/lib/generators/pgbus/templates/add_uniqueness_keys.rb.erb +13 -0
  28. data/lib/generators/pgbus/templates/migrate_job_locks_to_uniqueness_keys.rb.erb +33 -0
  29. data/lib/pgbus/active_job/executor.rb +34 -20
  30. data/lib/pgbus/client.rb +18 -2
  31. data/lib/pgbus/process/dispatcher.rb +33 -10
  32. data/lib/pgbus/process/worker.rb +4 -1
  33. data/lib/pgbus/recurring/schedule.rb +38 -35
  34. data/lib/pgbus/stat_buffer.rb +92 -0
  35. data/lib/pgbus/uniqueness.rb +24 -39
  36. data/lib/pgbus/version.rb +1 -1
  37. data/lib/pgbus/web/data_source.rb +46 -15
  38. metadata +6 -1
@@ -16,23 +16,13 @@ module Pgbus
16
16
 
17
17
  def enqueue_task(task, run_at:)
18
18
  queue = resolve_queue(task)
19
+ acquired_key = acquire_uniqueness_lock(task)
19
20
 
20
- # Check uniqueness lock before enqueuing. If the job class declares
21
- # ensures_uniqueness, we acquire the lock here so duplicate recurring
22
- # enqueues are rejected while a previous instance is still queued or running.
23
- if uniqueness_locked?(task)
24
- Pgbus.logger.debug do
25
- "[Pgbus] Recurring task #{task.key} skipped: uniqueness lock held"
26
- end
27
- return
28
- end
21
+ return if acquired_key == :already_locked
29
22
 
30
23
  RecurringExecution.record(task.key, run_at) do
31
24
  payload = build_payload(task)
32
25
  headers = build_headers(task, run_at)
33
-
34
- # Inject uniqueness metadata into the payload so the worker knows
35
- # to release the lock after execution.
36
26
  payload = inject_uniqueness_metadata(task, payload)
37
27
 
38
28
  Pgbus.client.ensure_queue(queue)
@@ -44,7 +34,11 @@ module Pgbus
44
34
  end
45
35
  end
46
36
  rescue AlreadyRecorded
37
+ release_uniqueness_lock(acquired_key)
47
38
  Pgbus.logger.debug { "[Pgbus] Recurring task #{task.key} already enqueued for #{run_at.iso8601}" }
39
+ rescue StandardError
40
+ release_uniqueness_lock(acquired_key)
41
+ raise
48
42
  end
49
43
 
50
44
  def build_payload(task)
@@ -112,36 +106,45 @@ module Pgbus
112
106
  }
113
107
  end
114
108
 
115
- # Check if the job class has ensures_uniqueness and if its lock is currently held.
116
- # Returns true if the lock is held (skip enqueue), false otherwise.
117
- def uniqueness_locked?(task)
118
- return false unless task.class_name
109
+ # Acquire the uniqueness lock for a recurring task.
110
+ # Returns:
111
+ # nil — no uniqueness configured, proceed without lock
112
+ # :already_locked — lock held by a previous instance, caller should skip enqueue
113
+ # String — the lock key (lock was acquired, caller must release on failure)
114
+ def acquire_uniqueness_lock(task)
115
+ return nil unless task.class_name
119
116
 
120
117
  job_class = task.class_name.safe_constantize
121
- return false unless job_class
122
- return false unless job_class.respond_to?(:pgbus_uniqueness)
118
+ return nil unless job_class
119
+ return nil unless job_class.respond_to?(:pgbus_uniqueness)
123
120
 
124
121
  config = job_class.pgbus_uniqueness
125
- return false unless config
126
- return false unless config[:strategy] == :until_executed
122
+ return nil unless config
123
+ return nil unless config[:strategy] == :until_executed
127
124
 
128
125
  key = resolve_uniqueness_key(config, task)
129
- return false unless key
130
-
131
- # Try to acquire the lock. If it fails, the lock is already held.
132
- acquired = JobLock.acquire!(
133
- key,
134
- job_class: task.class_name,
135
- job_id: "recurring-#{task.key}",
136
- state: "queued",
137
- ttl: config[:lock_ttl]
138
- )
139
- # If we acquired it, great — the message will be enqueued with the lock held.
140
- # If not, a previous instance is still queued/running.
141
- !acquired
126
+ return nil unless key
127
+
128
+ acquired = UniquenessKey.acquire!(key, queue_name: resolve_queue(task), msg_id: 0)
129
+
130
+ if acquired
131
+ key
132
+ else
133
+ Pgbus.logger.debug { "[Pgbus] Recurring task #{task.key} skipped: uniqueness lock held" }
134
+ :already_locked
135
+ end
136
+ rescue StandardError => e
137
+ Pgbus.logger.warn { "[Pgbus] Uniqueness lock failed for #{task.key}: #{e.message}" }
138
+ nil # Fail open — allow enqueue if lock check errors
139
+ end
140
+
141
+ # Release a uniqueness lock. Safe to call with nil or :already_locked.
142
+ def release_uniqueness_lock(key)
143
+ return if key.nil? || key == :already_locked
144
+
145
+ UniquenessKey.release!(key)
142
146
  rescue StandardError => e
143
- Pgbus.logger.warn { "[Pgbus] Uniqueness check failed for #{task.key}: #{e.message}" }
144
- false # Fail open — allow enqueue if uniqueness check errors
147
+ Pgbus.logger.warn { "[Pgbus] Lock rollback failed: #{e.message}" }
145
148
  end
146
149
 
147
150
  # Resolve the uniqueness key for a recurring task.
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Thread-safe buffer that accumulates job stats in memory and flushes
5
+ # them to the database in batches. This avoids one INSERT per job
6
+ # execution, replacing it with periodic bulk inserts.
7
+ class StatBuffer
8
+ DEFAULT_FLUSH_SIZE = 100
9
+ DEFAULT_FLUSH_INTERVAL = 5 # seconds
10
+
11
+ attr_reader :flush_size, :flush_interval
12
+
13
+ def initialize(flush_size: DEFAULT_FLUSH_SIZE, flush_interval: DEFAULT_FLUSH_INTERVAL)
14
+ @flush_size = flush_size
15
+ @flush_interval = flush_interval
16
+ @buffer = []
17
+ @mutex = Mutex.new
18
+ @last_flush_at = monotonic_now
19
+ @stopped = false
20
+ end
21
+
22
+ # Append a stat entry to the buffer. Flushes automatically when
23
+ # the buffer reaches flush_size.
24
+ def push(attrs)
25
+ should_flush = false
26
+
27
+ @mutex.synchronize do
28
+ @buffer << attrs
29
+ should_flush = @buffer.size >= @flush_size
30
+ end
31
+
32
+ flush if should_flush
33
+ end
34
+
35
+ # Flush buffered stats to the database. Safe to call from any thread.
36
+ def flush
37
+ entries = nil
38
+
39
+ @mutex.synchronize do
40
+ return if @buffer.empty?
41
+
42
+ entries = @buffer.dup
43
+ @buffer.clear
44
+ @last_flush_at = monotonic_now
45
+ end
46
+
47
+ write_to_database(entries) if entries&.any?
48
+ end
49
+
50
+ # Flush if the interval has elapsed since the last flush.
51
+ # Called by the dispatcher on its maintenance tick.
52
+ def flush_if_due
53
+ due = @mutex.synchronize { monotonic_now - @last_flush_at >= @flush_interval }
54
+ flush if due
55
+ end
56
+
57
+ def stop
58
+ @stopped = true
59
+ flush
60
+ end
61
+
62
+ def size
63
+ @mutex.synchronize { @buffer.size }
64
+ end
65
+
66
+ private
67
+
68
+ def write_to_database(entries)
69
+ return unless JobStat.table_exists?
70
+
71
+ columns = %i[job_class queue_name status duration_ms]
72
+ columns.push(:enqueue_latency_ms, :retry_count) if JobStat.latency_columns?
73
+
74
+ rows = entries.map do |e|
75
+ row = [e[:job_class], e[:queue_name], e[:status], e[:duration_ms]]
76
+ row.push(e[:enqueue_latency_ms], e[:retry_count]) if JobStat.latency_columns?
77
+ row
78
+ end
79
+
80
+ JobStat.insert_all(
81
+ rows.map { |row| columns.zip(row).to_h },
82
+ record_timestamps: true
83
+ )
84
+ rescue StandardError => e
85
+ Pgbus.logger.debug { "[Pgbus] Stat buffer flush failed: #{e.message}" }
86
+ end
87
+
88
+ def monotonic_now
89
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
90
+ end
91
+ end
92
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/concern"
4
- require "socket"
5
4
 
6
5
  module Pgbus
7
6
  # Job uniqueness guarantees: prevent duplicate jobs from running concurrently.
@@ -10,14 +9,16 @@ module Pgbus
10
9
  # uniqueness ensures AT MOST ONE job with a given key exists in the system
11
10
  # at any time — from enqueue through completion.
12
11
  #
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)
12
+ # Lock lifecycle (advisory lock + thin lookup table):
13
+ # 1. Enqueue: pg_advisory_xact_lock serializes concurrent attempts,
14
+ # then INSERT INTO pgbus_uniqueness_keys ON CONFLICT DO NOTHING.
15
+ # The lock row lives as long as the job is in the queue or executing.
16
+ # 2. Execution: PGMQ's visibility timeout is the execution lock —
17
+ # no separate claim_for_execution step needed.
18
+ # 3. Completion/DLQ: DELETE FROM pgbus_uniqueness_keys WHERE lock_key = ?.
19
+ # 4. Crash recovery: if a worker dies, VT expires, the message becomes
20
+ # readable again. The uniqueness key row stays (correctly — the job
21
+ # hasn't finished). The next worker picks it up and executes.
21
22
  #
22
23
  # Strategies:
23
24
  # :until_executed — Lock acquired at enqueue, held through execution, released on
@@ -44,7 +45,8 @@ module Pgbus
44
45
  STRATEGY_KEY = "pgbus_uniqueness_strategy"
45
46
  TTL_KEY = "pgbus_uniqueness_lock_ttl"
46
47
 
47
- # 24 hours last-resort fallback only. The reaper handles normal crash recovery.
48
+ # TTL is kept for metadata compatibility but no longer drives lock expiry.
49
+ # The lock exists until the job completes or is dead-lettered.
48
50
  DEFAULT_LOCK_TTL = 24 * 60 * 60
49
51
 
50
52
  VALID_STRATEGIES = %i[until_executed while_executing].freeze
@@ -115,54 +117,37 @@ module Pgbus
115
117
  end
116
118
 
117
119
  # 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)
120
+ # Uses pg_advisory_xact_lock to serialize concurrent attempts.
121
+ # Returns :acquired, :locked, or :no_lock.
122
+ def acquire_enqueue_lock(key, active_job, queue_name: nil, msg_id: nil)
121
123
  config = uniqueness_config(active_job)
122
124
  return :no_lock unless config
123
125
  return :no_lock unless config[:strategy] == :until_executed
124
126
 
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
- )
127
+ acquired = if msg_id && queue_name
128
+ UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: msg_id)
129
+ else
130
+ # Pre-produce check: use advisory lock + ON CONFLICT
131
+ UniquenessKey.acquire!(key, queue_name: queue_name || "pending", msg_id: msg_id || 0)
132
+ end
132
133
  acquired ? :acquired : :locked
133
134
  end
134
135
 
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
136
  # Acquire the uniqueness lock at execution time (:while_executing only).
142
- # Lock state: executing with owner_pid.
143
137
  # Returns true if acquired, false if another instance is running.
144
138
  def acquire_execution_lock(key, payload)
145
139
  strategy = extract_strategy(payload)
146
140
  return true unless strategy == :while_executing
147
141
 
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
- )
142
+ queue_name = payload["queue_name"] || "unknown"
143
+ UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: 0)
159
144
  end
160
145
 
161
146
  # Release the uniqueness lock after execution completes.
162
147
  def release_lock(key)
163
148
  return unless key
164
149
 
165
- JobLock.release!(key)
150
+ UniquenessKey.release!(key)
166
151
  end
167
152
  end
168
153
  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.3.3"
4
+ VERSION = "0.3.4"
5
5
  end
@@ -57,10 +57,12 @@ module Pgbus
57
57
  end
58
58
 
59
59
  def purge_queue(name)
60
+ release_uniqueness_keys_for_queue(name)
60
61
  @client.purge_queue(name, prefixed: false)
61
62
  end
62
63
 
63
64
  def drop_queue(name)
65
+ release_uniqueness_keys_for_queue(name)
64
66
  @client.drop_queue(name, prefixed: false)
65
67
  end
66
68
 
@@ -533,23 +535,43 @@ module Pgbus
533
535
  []
534
536
  end
535
537
 
536
- # Job locks
538
+ # Lock management
539
+ def discard_lock(lock_key)
540
+ UniquenessKey.where(lock_key: lock_key).delete_all
541
+ rescue StandardError => e
542
+ Pgbus.logger.debug { "[Pgbus::Web] Error discarding lock #{lock_key}: #{e.message}" }
543
+ 0
544
+ end
545
+
546
+ def discard_locks(lock_keys)
547
+ return 0 if lock_keys.empty?
548
+
549
+ UniquenessKey.where(lock_key: lock_keys).delete_all
550
+ rescue StandardError => e
551
+ Pgbus.logger.debug { "[Pgbus::Web] Error discarding locks: #{e.message}" }
552
+ 0
553
+ end
554
+
555
+ def discard_all_locks
556
+ UniquenessKey.delete_all
557
+ rescue StandardError => e
558
+ Pgbus.logger.debug { "[Pgbus::Web] Error discarding all locks: #{e.message}" }
559
+ 0
560
+ end
561
+
562
+ # Job uniqueness keys
537
563
  def job_locks
538
- JobLock.order(locked_at: :desc).limit(100).map do |lock|
564
+ UniquenessKey.order(created_at: :desc).limit(100).map do |key|
539
565
  {
540
- lock_key: lock.lock_key,
541
- job_class: lock.job_class,
542
- job_id: lock.job_id,
543
- state: lock.state,
544
- owner_pid: lock.owner_pid,
545
- owner_hostname: lock.owner_hostname,
546
- locked_at: lock.locked_at,
547
- expires_at: lock.expires_at,
548
- age_seconds: lock.locked_at ? (Time.current - lock.locked_at).to_i : nil
566
+ lock_key: key.lock_key,
567
+ queue_name: key.queue_name,
568
+ msg_id: key.msg_id,
569
+ created_at: key.created_at,
570
+ age_seconds: key.created_at ? (Time.current - key.created_at).to_i : nil
549
571
  }
550
572
  end
551
573
  rescue StandardError => e
552
- Pgbus.logger.debug { "[Pgbus::Web] Error fetching job locks: #{e.message}" }
574
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching uniqueness keys: #{e.message}" }
553
575
  []
554
576
  end
555
577
 
@@ -786,7 +808,7 @@ module Pgbus
786
808
 
787
809
  payload = payload_str.is_a?(String) ? JSON.parse(payload_str) : payload_str
788
810
  key = payload[Uniqueness::METADATA_KEY]
789
- JobLock.release!(key) if key
811
+ UniquenessKey.release!(key) if key
790
812
  rescue JSON::ParserError => e
791
813
  Pgbus.logger.debug { "[Pgbus::Web] Error parsing payload for lock release: #{e.message}" }
792
814
  end
@@ -804,7 +826,7 @@ module Pgbus
804
826
  nil
805
827
  end
806
828
 
807
- JobLock.where(lock_key: keys).delete_all if keys.any?
829
+ UniquenessKey.where(lock_key: keys).delete_all if keys.any?
808
830
  rescue StandardError => e
809
831
  Pgbus.logger.debug { "[Pgbus::Web] Error releasing locks for messages: #{e.message}" }
810
832
  end
@@ -822,10 +844,19 @@ module Pgbus
822
844
  nil
823
845
  end
824
846
 
825
- JobLock.where(lock_key: keys).delete_all if keys.any?
847
+ UniquenessKey.where(lock_key: keys).delete_all if keys.any?
826
848
  rescue StandardError => e
827
849
  Pgbus.logger.debug { "[Pgbus::Web] Error releasing locks for failed events: #{e.message}" }
828
850
  end
851
+
852
+ # Release all uniqueness keys associated with a queue before purge/drop.
853
+ # Scans queue messages for uniqueness metadata and deletes matching rows.
854
+ def release_uniqueness_keys_for_queue(queue_name)
855
+ messages = query_queue_messages_raw(queue_name, 10_000, 0)
856
+ release_locks_for_messages(messages)
857
+ rescue StandardError => e
858
+ Pgbus.logger.debug { "[Pgbus::Web] Error releasing uniqueness keys for queue #{queue_name}: #{e.message}" }
859
+ end
829
860
  end
830
861
  end
831
862
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -155,6 +155,7 @@ files:
155
155
  - app/models/pgbus/recurring_execution.rb
156
156
  - app/models/pgbus/recurring_task.rb
157
157
  - app/models/pgbus/semaphore.rb
158
+ - app/models/pgbus/uniqueness_key.rb
158
159
  - app/views/layouts/pgbus/application.html.erb
159
160
  - app/views/pgbus/dashboard/_processes_table.html.erb
160
161
  - app/views/pgbus/dashboard/_queues_table.html.erb
@@ -204,12 +205,15 @@ files:
204
205
  - lib/generators/pgbus/add_queue_states_generator.rb
205
206
  - lib/generators/pgbus/add_recurring_generator.rb
206
207
  - lib/generators/pgbus/install_generator.rb
208
+ - lib/generators/pgbus/migrate_job_locks_generator.rb
207
209
  - lib/generators/pgbus/templates/add_job_locks.rb.erb
208
210
  - lib/generators/pgbus/templates/add_job_stats.rb.erb
209
211
  - lib/generators/pgbus/templates/add_job_stats_latency.rb.erb
210
212
  - lib/generators/pgbus/templates/add_outbox.rb.erb
211
213
  - lib/generators/pgbus/templates/add_queue_states.rb.erb
212
214
  - lib/generators/pgbus/templates/add_recurring_tables.rb.erb
215
+ - lib/generators/pgbus/templates/add_uniqueness_keys.rb.erb
216
+ - lib/generators/pgbus/templates/migrate_job_locks_to_uniqueness_keys.rb.erb
213
217
  - lib/generators/pgbus/templates/migration.rb.erb
214
218
  - lib/generators/pgbus/templates/pgbus.yml.erb
215
219
  - lib/generators/pgbus/templates/pgbus_binstub.erb
@@ -261,6 +265,7 @@ files:
261
265
  - lib/pgbus/recurring/scheduler.rb
262
266
  - lib/pgbus/recurring/task.rb
263
267
  - lib/pgbus/serializer.rb
268
+ - lib/pgbus/stat_buffer.rb
264
269
  - lib/pgbus/uniqueness.rb
265
270
  - lib/pgbus/version.rb
266
271
  - lib/pgbus/web/authentication.rb