pgbus 0.3.2 → 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 (44) 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/blocked_execution.rb +1 -1
  7. data/app/models/pgbus/job_lock.rb +16 -8
  8. data/app/models/pgbus/uniqueness_key.rb +36 -0
  9. data/app/views/pgbus/dead_letter/_messages_table.html.erb +22 -2
  10. data/app/views/pgbus/dead_letter/index.html.erb +9 -1
  11. data/app/views/pgbus/jobs/_enqueued_table.html.erb +36 -6
  12. data/app/views/pgbus/jobs/_failed_table.html.erb +35 -4
  13. data/app/views/pgbus/locks/index.html.erb +53 -28
  14. data/config/locales/da.yml +19 -23
  15. data/config/locales/de.yml +19 -23
  16. data/config/locales/en.yml +48 -22
  17. data/config/locales/es.yml +19 -23
  18. data/config/locales/fi.yml +19 -23
  19. data/config/locales/fr.yml +19 -23
  20. data/config/locales/it.yml +19 -23
  21. data/config/locales/ja.yml +19 -23
  22. data/config/locales/nb.yml +19 -23
  23. data/config/locales/nl.yml +19 -23
  24. data/config/locales/pt.yml +19 -23
  25. data/config/locales/sv.yml +19 -23
  26. data/config/routes.rb +12 -1
  27. data/lib/generators/pgbus/migrate_job_locks_generator.rb +56 -0
  28. data/lib/generators/pgbus/templates/add_uniqueness_keys.rb.erb +13 -0
  29. data/lib/generators/pgbus/templates/migrate_job_locks_to_uniqueness_keys.rb.erb +33 -0
  30. data/lib/pgbus/active_job/adapter.rb +9 -4
  31. data/lib/pgbus/active_job/executor.rb +38 -19
  32. data/lib/pgbus/circuit_breaker.rb +2 -2
  33. data/lib/pgbus/client.rb +18 -2
  34. data/lib/pgbus/concurrency/blocked_execution.rb +3 -3
  35. data/lib/pgbus/concurrency/semaphore.rb +2 -2
  36. data/lib/pgbus/process/dispatcher.rb +53 -26
  37. data/lib/pgbus/process/worker.rb +7 -3
  38. data/lib/pgbus/recurring/schedule.rb +39 -36
  39. data/lib/pgbus/recurring/scheduler.rb +1 -1
  40. data/lib/pgbus/stat_buffer.rb +92 -0
  41. data/lib/pgbus/uniqueness.rb +24 -39
  42. data/lib/pgbus/version.rb +1 -1
  43. data/lib/pgbus/web/data_source.rb +46 -15
  44. metadata +6 -1
@@ -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.2"
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.2
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