pgbus 0.1.5 → 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 (39) 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/helpers/pgbus/application_helper.rb +28 -0
  7. data/app/models/pgbus/job_lock.rb +82 -0
  8. data/app/models/pgbus/job_stat.rb +94 -0
  9. data/app/views/layouts/pgbus/application.html.erb +32 -8
  10. data/app/views/pgbus/dashboard/_stats_cards.html.erb +20 -20
  11. data/app/views/pgbus/insights/show.html.erb +161 -0
  12. data/app/views/pgbus/locks/index.html.erb +53 -0
  13. data/config/routes.rb +3 -0
  14. data/lib/generators/pgbus/add_job_locks_generator.rb +52 -0
  15. data/lib/generators/pgbus/add_job_stats_generator.rb +52 -0
  16. data/lib/generators/pgbus/add_outbox_generator.rb +1 -1
  17. data/lib/generators/pgbus/add_queue_states_generator.rb +1 -1
  18. data/lib/generators/pgbus/add_recurring_generator.rb +1 -1
  19. data/lib/generators/pgbus/install_generator.rb +1 -1
  20. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +21 -0
  21. data/lib/generators/pgbus/templates/add_job_stats.rb.erb +18 -0
  22. data/lib/generators/pgbus/upgrade_pgmq_generator.rb +1 -1
  23. data/lib/pgbus/active_job/adapter.rb +58 -4
  24. data/lib/pgbus/active_job/executor.rb +45 -0
  25. data/lib/pgbus/client.rb +8 -22
  26. data/lib/pgbus/configuration.rb +6 -0
  27. data/lib/pgbus/engine.rb +1 -0
  28. data/lib/pgbus/process/consumer_priority.rb +64 -0
  29. data/lib/pgbus/process/dispatcher.rb +29 -0
  30. data/lib/pgbus/process/queue_lock.rb +87 -0
  31. data/lib/pgbus/process/supervisor.rb +6 -1
  32. data/lib/pgbus/process/wake_signal.rb +53 -0
  33. data/lib/pgbus/process/worker.rb +36 -6
  34. data/lib/pgbus/queue_factory.rb +62 -0
  35. data/lib/pgbus/uniqueness.rb +169 -0
  36. data/lib/pgbus/version.rb +1 -1
  37. data/lib/pgbus/web/data_source.rb +49 -0
  38. data/lib/pgbus.rb +1 -0
  39. metadata +17 -1
@@ -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,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.5"
4
+ VERSION = "0.1.6"
5
5
  end
@@ -497,6 +497,55 @@ module Pgbus
497
497
  []
498
498
  end
499
499
 
500
+ # Job locks
501
+ def job_locks
502
+ JobLock.order(locked_at: :desc).limit(100).map do |lock|
503
+ {
504
+ lock_key: lock.lock_key,
505
+ job_class: lock.job_class,
506
+ job_id: lock.job_id,
507
+ state: lock.state,
508
+ owner_pid: lock.owner_pid,
509
+ owner_hostname: lock.owner_hostname,
510
+ locked_at: lock.locked_at,
511
+ expires_at: lock.expires_at,
512
+ age_seconds: lock.locked_at ? (Time.current - lock.locked_at).to_i : nil
513
+ }
514
+ end
515
+ rescue StandardError => e
516
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching job locks: #{e.message}" }
517
+ []
518
+ end
519
+
520
+ # Job stats
521
+ def job_stats_summary(minutes: 60)
522
+ JobStat.summary(minutes: minutes)
523
+ rescue StandardError => e
524
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching job stats summary: #{e.message}" }
525
+ { total: 0, success: 0, failed: 0, dead_lettered: 0, avg_duration_ms: 0, max_duration_ms: 0 }
526
+ end
527
+
528
+ def job_throughput(minutes: 60)
529
+ JobStat.throughput(minutes: minutes).map { |time, count| { time: time, count: count } }
530
+ rescue StandardError => e
531
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching throughput: #{e.message}" }
532
+ []
533
+ end
534
+
535
+ def job_status_counts(minutes: 60)
536
+ JobStat.status_counts(minutes: minutes)
537
+ rescue StandardError => e
538
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching status counts: #{e.message}" }
539
+ {}
540
+ end
541
+
542
+ def slowest_job_classes(limit: 10, minutes: 60)
543
+ JobStat.slowest_classes(limit: limit, minutes: minutes)
544
+ rescue StandardError => e
545
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching slowest classes: #{e.message}" }
546
+ []
547
+ end
548
+
500
549
  # Subscriber registry
501
550
  def registered_subscribers
502
551
  EventBus::Registry.instance.subscribers.map do |s|
data/lib/pgbus.rb CHANGED
@@ -9,6 +9,7 @@ module Pgbus
9
9
  class QueueNotFoundError < Error; end
10
10
  class DeadLetterError < Error; end
11
11
  class ConcurrencyLimitExceeded < Error; end
12
+ class JobNotUnique < Error; end
12
13
  class SchemaNotReady < Error; end
13
14
 
14
15
  class << self
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.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -107,12 +107,15 @@ files:
107
107
  - LICENSE.txt
108
108
  - README.md
109
109
  - Rakefile
110
+ - app/controllers/pgbus/api/insights_controller.rb
110
111
  - app/controllers/pgbus/api/stats_controller.rb
111
112
  - app/controllers/pgbus/application_controller.rb
112
113
  - app/controllers/pgbus/dashboard_controller.rb
113
114
  - app/controllers/pgbus/dead_letter_controller.rb
114
115
  - app/controllers/pgbus/events_controller.rb
116
+ - app/controllers/pgbus/insights_controller.rb
115
117
  - app/controllers/pgbus/jobs_controller.rb
118
+ - app/controllers/pgbus/locks_controller.rb
116
119
  - app/controllers/pgbus/outbox_controller.rb
117
120
  - app/controllers/pgbus/processes_controller.rb
118
121
  - app/controllers/pgbus/queues_controller.rb
@@ -121,6 +124,8 @@ files:
121
124
  - app/models/pgbus/application_record.rb
122
125
  - app/models/pgbus/batch_entry.rb
123
126
  - app/models/pgbus/blocked_execution.rb
127
+ - app/models/pgbus/job_lock.rb
128
+ - app/models/pgbus/job_stat.rb
124
129
  - app/models/pgbus/outbox_entry.rb
125
130
  - app/models/pgbus/process_entry.rb
126
131
  - app/models/pgbus/processed_event.rb
@@ -139,10 +144,12 @@ files:
139
144
  - app/views/pgbus/dead_letter/show.html.erb
140
145
  - app/views/pgbus/events/index.html.erb
141
146
  - app/views/pgbus/events/show.html.erb
147
+ - app/views/pgbus/insights/show.html.erb
142
148
  - app/views/pgbus/jobs/_enqueued_table.html.erb
143
149
  - app/views/pgbus/jobs/_failed_table.html.erb
144
150
  - app/views/pgbus/jobs/index.html.erb
145
151
  - app/views/pgbus/jobs/show.html.erb
152
+ - app/views/pgbus/locks/index.html.erb
146
153
  - app/views/pgbus/outbox/index.html.erb
147
154
  - app/views/pgbus/processes/_processes_table.html.erb
148
155
  - app/views/pgbus/processes/index.html.erb
@@ -155,10 +162,14 @@ files:
155
162
  - config/routes.rb
156
163
  - exe/pgbus
157
164
  - lib/active_job/queue_adapters/pgbus_adapter.rb
165
+ - lib/generators/pgbus/add_job_locks_generator.rb
166
+ - lib/generators/pgbus/add_job_stats_generator.rb
158
167
  - lib/generators/pgbus/add_outbox_generator.rb
159
168
  - lib/generators/pgbus/add_queue_states_generator.rb
160
169
  - lib/generators/pgbus/add_recurring_generator.rb
161
170
  - lib/generators/pgbus/install_generator.rb
171
+ - lib/generators/pgbus/templates/add_job_locks.rb.erb
172
+ - lib/generators/pgbus/templates/add_job_stats.rb.erb
162
173
  - lib/generators/pgbus/templates/add_outbox.rb.erb
163
174
  - lib/generators/pgbus/templates/add_queue_states.rb.erb
164
175
  - lib/generators/pgbus/templates/add_recurring_tables.rb.erb
@@ -193,12 +204,16 @@ files:
193
204
  - lib/pgbus/pgmq_schema.rb
194
205
  - lib/pgbus/pgmq_schema/pgmq_v1.11.0.sql
195
206
  - lib/pgbus/process/consumer.rb
207
+ - lib/pgbus/process/consumer_priority.rb
196
208
  - lib/pgbus/process/dispatcher.rb
197
209
  - lib/pgbus/process/heartbeat.rb
198
210
  - lib/pgbus/process/lifecycle.rb
211
+ - lib/pgbus/process/queue_lock.rb
199
212
  - lib/pgbus/process/signal_handler.rb
200
213
  - lib/pgbus/process/supervisor.rb
214
+ - lib/pgbus/process/wake_signal.rb
201
215
  - lib/pgbus/process/worker.rb
216
+ - lib/pgbus/queue_factory.rb
202
217
  - lib/pgbus/rate_counter.rb
203
218
  - lib/pgbus/recurring/already_recorded.rb
204
219
  - lib/pgbus/recurring/command_job.rb
@@ -207,6 +222,7 @@ files:
207
222
  - lib/pgbus/recurring/scheduler.rb
208
223
  - lib/pgbus/recurring/task.rb
209
224
  - lib/pgbus/serializer.rb
225
+ - lib/pgbus/uniqueness.rb
210
226
  - lib/pgbus/version.rb
211
227
  - lib/pgbus/web/authentication.rb
212
228
  - lib/pgbus/web/data_source.rb