pgbus 0.1.1 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2741aec28cfa0ab8a9f2b88131a74a304198dbaa3426b0aca4b54b9f4c615a35
4
- data.tar.gz: 0f164183827f32996556d304ad13dc5a00a9491f2a3ca19cbaa9a3b8aa21c975
3
+ metadata.gz: 2aad79af8c595a48d879b9bad0ebaaf43d40843cdbcb9dcc7a2dad0023257e53
4
+ data.tar.gz: 53bda3ba2e7d1d0f1935d183193bff5b48adeca8a4da44b6d55f9ba743e62d8e
5
5
  SHA512:
6
- metadata.gz: d2ce8858dcd751ff70d653b847ffc8db202e655ff77d70276f8b538141bce8c964589e6ee2ed9ff68f9b78f080c952ac6661fd16ce2e555fd0d1578659e75adc
7
- data.tar.gz: 2a55f808a68f8dbe879a7a6fdc674372fe42f3cb8ecbc91388b0f54b0c3c74f7e7b8424767f566caee1395e4393110f698b2b1c73c13d78fd2fe0105b59ed20a
6
+ metadata.gz: c9cfcd463d6b91591f9f037969ca1b30c2dc29ba0ce7a8eeefa829ef2b12eee71c4aba0f5b8d02b3336d6cc38b04db7cc51d749ca97774d4afa4e2246f8d6ccc
7
+ data.tar.gz: a50591b61b5bf49a2b5e14750622ba7ca0681d3db5f47d801542a23eb3147a8b9d5a605fcd0c55264ad4068e75f3d827ad550d2d58cd2026fa8a4351f72eea8a
@@ -7,7 +7,7 @@ module Pgbus
7
7
  class Adapter
8
8
  def enqueue(active_job)
9
9
  queue = active_job.queue_name || Pgbus.configuration.default_queue
10
- payload_hash = JSON.parse(Serializer.serialize_job(active_job))
10
+ payload_hash = Serializer.serialize_job_hash(active_job)
11
11
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
12
12
  payload_hash = inject_batch_metadata(payload_hash)
13
13
 
@@ -16,7 +16,7 @@ module Pgbus
16
16
 
17
17
  def enqueue_at(active_job, timestamp)
18
18
  queue = active_job.queue_name || Pgbus.configuration.default_queue
19
- payload_hash = JSON.parse(Serializer.serialize_job(active_job))
19
+ payload_hash = Serializer.serialize_job_hash(active_job)
20
20
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
21
21
  payload_hash = inject_batch_metadata(payload_hash)
22
22
  delay = [(timestamp - Time.now.to_f).ceil, 0].max
@@ -88,7 +88,7 @@ module Pgbus
88
88
  def enqueue_immediate(queue, jobs)
89
89
  return if jobs.empty?
90
90
 
91
- payloads = jobs.map { |j| JSON.parse(Serializer.serialize_job(j)) }
91
+ payloads = jobs.map { |j| Serializer.serialize_job_hash(j) }
92
92
  msg_ids = Pgbus.client.send_batch(queue, payloads)
93
93
 
94
94
  unless msg_ids.is_a?(Array) && msg_ids.size == jobs.size
@@ -28,8 +28,8 @@ module Pgbus
28
28
  Instrumentation.instrument("pgbus.executor.execute", queue: queue_name, job_class: job_class) do
29
29
  job = ::ActiveJob::Base.deserialize(payload)
30
30
  execute_job(job)
31
- job_succeeded = true
32
31
  client.archive_message(queue_name, message.msg_id.to_i)
32
+ job_succeeded = true
33
33
  end
34
34
 
35
35
  instrument("pgbus.job_completed", queue: queue_name, job_class: job_class)
@@ -41,10 +41,9 @@ module Pgbus
41
41
  # Semaphore is released only on success or dead-lettering.
42
42
  :failed
43
43
  ensure
44
- # Signal concurrency and batch AFTER archive, in an ensure block so they
45
- # fire even if archive_message raises. This prevents the semaphore slot
46
- # from being stuck until expiry when the archive DB call fails after the
47
- # job has already completed successfully.
44
+ # Signal concurrency and batch only when the job was archived successfully.
45
+ # job_succeeded is set AFTER archive_message, so if archive fails the
46
+ # semaphore slot stays held until VT expires and the job is retried.
48
47
  if job_succeeded
49
48
  signal_concurrency(payload)
50
49
  signal_batch_completed(payload)
data/lib/pgbus/batch.rb CHANGED
@@ -134,10 +134,12 @@ module Pgbus
134
134
  end
135
135
 
136
136
  def enqueue_callback(class_name, properties)
137
- job_class = class_name.constantize
137
+ job_class = class_name.safe_constantize
138
+ unless job_class && job_class < ::ActiveJob::Base
139
+ Pgbus.logger.error { "[Pgbus] Batch callback class invalid or not an ActiveJob: #{class_name}" }
140
+ return
141
+ end
138
142
  job_class.perform_later(properties)
139
- rescue NameError => e
140
- Pgbus.logger.error { "[Pgbus] Batch callback class not found: #{class_name}: #{e.message}" }
141
143
  end
142
144
  end
143
145
  end
data/lib/pgbus/client.rb CHANGED
@@ -24,28 +24,27 @@ module Pgbus
24
24
  pool_size: config.pool_size,
25
25
  pool_timeout: config.pool_timeout
26
26
  )
27
- @queues_created = {}
28
- @mutex = Mutex.new
27
+ @queues_created = Concurrent::Map.new
29
28
  end
30
29
 
31
30
  def ensure_queue(name)
32
31
  full_name = config.queue_name(name)
33
- @mutex.synchronize do
34
- return if @queues_created[full_name]
32
+ return if @queues_created[full_name]
35
33
 
34
+ @queues_created.compute_if_absent(full_name) do
36
35
  @pgmq.create(full_name)
37
36
  @pgmq.enable_notify_insert(full_name, throttle_interval_ms: config.notify_throttle_ms) if config.listen_notify
38
- @queues_created[full_name] = true
37
+ true
39
38
  end
40
39
  end
41
40
 
42
41
  def ensure_dead_letter_queue(name)
43
42
  dlq_name = config.dead_letter_queue_name(name)
44
- @mutex.synchronize do
45
- return if @queues_created[dlq_name]
43
+ return if @queues_created[dlq_name]
46
44
 
45
+ @queues_created.compute_if_absent(dlq_name) do
47
46
  @pgmq.create(dlq_name)
48
- @queues_created[dlq_name] = true
47
+ true
49
48
  end
50
49
  end
51
50
 
@@ -116,6 +116,21 @@ module Pgbus
116
116
  @pgmq_schema_mode = mode
117
117
  end
118
118
 
119
+ def validate!
120
+ raise ArgumentError, "pool_size must be > 0" unless pool_size.is_a?(Numeric) && pool_size.positive?
121
+ raise ArgumentError, "pool_timeout must be > 0" unless pool_timeout.is_a?(Numeric) && pool_timeout.positive?
122
+ raise ArgumentError, "polling_interval must be > 0" unless polling_interval.is_a?(Numeric) && polling_interval.positive?
123
+ raise ArgumentError, "visibility_timeout must be > 0" unless visibility_timeout.is_a?(Numeric) && visibility_timeout.positive?
124
+ raise ArgumentError, "max_retries must be >= 0" unless max_retries.is_a?(Integer) && max_retries >= 0
125
+
126
+ workers.each do |w|
127
+ threads = w[:threads] || w["threads"] || 5
128
+ raise ArgumentError, "worker threads must be > 0" unless threads.is_a?(Integer) && threads.positive?
129
+ end
130
+
131
+ self
132
+ end
133
+
119
134
  def connection_options
120
135
  if database_url
121
136
  database_url
@@ -17,11 +17,7 @@ module Pgbus
17
17
  raw = JSON.parse(message.message)
18
18
  event = build_event(raw)
19
19
 
20
- if self.class.idempotent?
21
- return :skipped if already_processed?(event.event_id)
22
-
23
- mark_processed!(event.event_id)
24
- end
20
+ return :skipped if self.class.idempotent? && !claim_idempotency?(event.event_id)
25
21
 
26
22
  handle(event)
27
23
  instrument("pgbus.event_processed", event_id: event.event_id, handler: self.class.name)
@@ -51,15 +47,16 @@ module Pgbus
51
47
  ActiveSupport::Notifications.instrument(event_name, payload)
52
48
  end
53
49
 
54
- def already_processed?(event_id)
55
- ProcessedEvent.exists?(event_id: event_id, handler_class: self.class.name)
56
- end
57
-
58
- def mark_processed!(event_id)
59
- ProcessedEvent.upsert(
50
+ # Atomically claim idempotency: INSERT ... ON CONFLICT DO NOTHING.
51
+ # Returns true if this handler claimed the event (row was inserted),
52
+ # false if another handler already processed it (conflict, no insert).
53
+ def claim_idempotency?(event_id)
54
+ result = ProcessedEvent.insert(
60
55
  { event_id: event_id, handler_class: self.class.name, processed_at: Time.now.utc },
61
56
  unique_by: %i[event_id handler_class]
62
57
  )
58
+ # insert returns an InsertAll::Result; inserted row count > 0 means we claimed it
59
+ result.rows.any?
63
60
  end
64
61
  end
65
62
  end
@@ -71,6 +71,12 @@ module Pgbus
71
71
  end
72
72
 
73
73
  def handle_message(message, queue_name)
74
+ if message.read_ct.to_i > config.max_retries
75
+ Pgbus.logger.warn { "[Pgbus] Consumer moving message #{message.msg_id} to DLQ after #{message.read_ct} reads" }
76
+ Pgbus.client.move_to_dead_letter(queue_name, message)
77
+ return
78
+ end
79
+
74
80
  raw = JSON.parse(message.message)
75
81
  routing_key = raw.dig("headers", "routing_key") || raw["routing_key"]
76
82
 
@@ -83,6 +89,9 @@ module Pgbus
83
89
  Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
84
90
  rescue StandardError => e
85
91
  Pgbus.logger.error { "[Pgbus] Consumer error: #{e.class}: #{e.message}" }
92
+ # Message stays in queue; VT will expire and it becomes available again.
93
+ # read_ct tracks delivery attempts — when it exceeds max_retries,
94
+ # the next read will route to DLQ above.
86
95
  end
87
96
 
88
97
  def pattern_overlaps?(topic_filter, subscription_pattern)
@@ -59,30 +59,20 @@ module Pgbus
59
59
  def run_maintenance
60
60
  now = Time.now
61
61
 
62
- if now - @last_cleanup_at >= CLEANUP_INTERVAL
63
- cleanup_processed_events
64
- @last_cleanup_at = now
65
- end
66
-
67
- if now - @last_reap_at >= REAP_INTERVAL
68
- reap_stale_processes
69
- @last_reap_at = now
70
- end
71
-
72
- if now - @last_concurrency_at >= CONCURRENCY_INTERVAL
73
- cleanup_concurrency
74
- @last_concurrency_at = now
75
- end
62
+ run_if_due(now, :@last_cleanup_at, CLEANUP_INTERVAL) { cleanup_processed_events }
63
+ run_if_due(now, :@last_reap_at, REAP_INTERVAL) { reap_stale_processes }
64
+ run_if_due(now, :@last_concurrency_at, CONCURRENCY_INTERVAL) { cleanup_concurrency }
65
+ run_if_due(now, :@last_batch_cleanup_at, BATCH_CLEANUP_INTERVAL) { cleanup_batches }
66
+ run_if_due(now, :@last_recurring_cleanup_at, RECURRING_CLEANUP_INTERVAL) { cleanup_recurring_executions }
67
+ end
76
68
 
77
- if now - @last_batch_cleanup_at >= BATCH_CLEANUP_INTERVAL
78
- cleanup_batches
79
- @last_batch_cleanup_at = now
80
- end
69
+ # Only update the timestamp when the block succeeds.
70
+ # On failure, the next tick retries instead of waiting the full interval.
71
+ def run_if_due(now, ivar, interval)
72
+ return unless now - instance_variable_get(ivar) >= interval
81
73
 
82
- if now - @last_recurring_cleanup_at >= RECURRING_CLEANUP_INTERVAL
83
- cleanup_recurring_executions
84
- @last_recurring_cleanup_at = now
85
- end
74
+ yield
75
+ instance_variable_set(ivar, now)
86
76
  rescue StandardError => e
87
77
  Pgbus.logger.error { "[Pgbus] Dispatcher maintenance error: #{e.message}" }
88
78
  end
@@ -69,8 +69,15 @@ module Pgbus
69
69
  worker.run
70
70
  end
71
71
 
72
+ unless pid
73
+ Pgbus.logger.error { "[Pgbus] Failed to fork worker for queues=#{queues.join(",")}" }
74
+ return
75
+ end
76
+
72
77
  @forks[pid] = { type: :worker, config: worker_config }
73
78
  Pgbus.logger.info { "[Pgbus] Forked worker pid=#{pid} queues=#{queues.join(",")}" }
79
+ rescue Errno::EAGAIN, Errno::ENOMEM => e
80
+ Pgbus.logger.error { "[Pgbus] Fork failed for worker: #{e.message}" }
74
81
  end
75
82
 
76
83
  def fork_dispatcher
@@ -82,8 +89,15 @@ module Pgbus
82
89
  dispatcher.run
83
90
  end
84
91
 
92
+ unless pid
93
+ Pgbus.logger.error { "[Pgbus] Failed to fork dispatcher" }
94
+ return
95
+ end
96
+
85
97
  @forks[pid] = { type: :dispatcher }
86
98
  Pgbus.logger.info { "[Pgbus] Forked dispatcher pid=#{pid}" }
99
+ rescue Errno::EAGAIN, Errno::ENOMEM => e
100
+ Pgbus.logger.error { "[Pgbus] Fork failed for dispatcher: #{e.message}" }
87
101
  end
88
102
 
89
103
  def boot_scheduler
@@ -103,8 +117,15 @@ module Pgbus
103
117
  scheduler.run
104
118
  end
105
119
 
120
+ unless pid
121
+ Pgbus.logger.error { "[Pgbus] Failed to fork scheduler" }
122
+ return
123
+ end
124
+
106
125
  @forks[pid] = { type: :scheduler }
107
126
  Pgbus.logger.info { "[Pgbus] Forked scheduler pid=#{pid}" }
127
+ rescue Errno::EAGAIN, Errno::ENOMEM => e
128
+ Pgbus.logger.error { "[Pgbus] Fork failed for scheduler: #{e.message}" }
108
129
  end
109
130
 
110
131
  def recurring_tasks_configured?
@@ -150,8 +171,15 @@ module Pgbus
150
171
  consumer.run
151
172
  end
152
173
 
174
+ unless pid
175
+ Pgbus.logger.error { "[Pgbus] Failed to fork consumer for topics=#{topics.join(",")}" }
176
+ return
177
+ end
178
+
153
179
  @forks[pid] = { type: :consumer, config: consumer_config }
154
180
  Pgbus.logger.info { "[Pgbus] Forked consumer pid=#{pid} topics=#{topics.join(",")}" }
181
+ rescue Errno::EAGAIN, Errno::ENOMEM => e
182
+ Pgbus.logger.error { "[Pgbus] Fork failed for consumer: #{e.message}" }
155
183
  end
156
184
 
157
185
  def monitor_loop
@@ -32,11 +32,12 @@ module Pgbus
32
32
  Pgbus.logger.info { "[Pgbus] Worker started: queues=#{queues.join(",")} threads=#{threads} pid=#{::Process.pid}" }
33
33
 
34
34
  loop do
35
- break if @shutting_down
36
- break if recycle_needed?
37
-
38
35
  process_signals
39
- claim_and_execute
36
+ break if @shutting_down && @pool.queue_length.zero?
37
+ break if recycle_needed? && @pool.queue_length.zero?
38
+
39
+ claim_and_execute unless @shutting_down || recycle_needed?
40
+ interruptible_sleep(config.polling_interval) if (@shutting_down || recycle_needed?) && !@pool.queue_length.zero?
40
41
  end
41
42
 
42
43
  shutdown
@@ -3,13 +3,25 @@
3
3
  module Pgbus
4
4
  module Recurring
5
5
  # Job class for command-based recurring tasks.
6
- # Executes a Ruby command string, similar to solid_queue's RecurringJob.
6
+ # Evaluates a method call chain on a constant, e.g. "OldRecord.cleanup!".
7
7
  #
8
- # NOTE: Only use this with trusted commands from config/recurring.yml.
9
- # Never expose this to user input.
8
+ # Only supports `ConstantName.method_name` and `ConstantName.method_name(args)`.
9
+ # Rejects arbitrary Ruby expressions for safety.
10
10
  class CommandJob < ::ActiveJob::Base
11
+ SAFE_COMMAND = /\A([A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)\.([a-z_][a-z0-9_!?]*)\z/
12
+
11
13
  def perform(command)
12
- eval(command) # rubocop:disable Security/Eval
14
+ match = SAFE_COMMAND.match(command)
15
+ unless match
16
+ raise ArgumentError,
17
+ "Unsafe recurring command: #{command.inspect}. " \
18
+ "Must be in the form 'ClassName.method_name'."
19
+ end
20
+
21
+ klass = match[1].safe_constantize
22
+ raise ArgumentError, "Unknown class in recurring command: #{match[1]}" unless klass
23
+
24
+ klass.public_send(match[2])
13
25
  end
14
26
  end
15
27
  end
@@ -15,6 +15,12 @@ module Pgbus
15
15
  end
16
16
  end
17
17
 
18
+ def serialize_job_hash(active_job)
19
+ Instrumentation.instrument("pgbus.serializer.serialize", kind: :job) do
20
+ active_job.serialize
21
+ end
22
+ end
23
+
18
24
  def deserialize_job(json_string)
19
25
  Instrumentation.instrument("pgbus.serializer.deserialize", kind: :job) do
20
26
  data = JSON.parse(json_string)
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.1"
4
+ VERSION = "0.1.2"
5
5
  end
@@ -129,7 +129,9 @@ module Pgbus
129
129
 
130
130
  connection.transaction do
131
131
  @client.send_message(event["queue_name"], payload, headers: headers)
132
- connection.execute("DELETE FROM pgbus_failed_events WHERE id = #{id.to_i}")
132
+ connection.exec_delete(
133
+ "DELETE FROM pgbus_failed_events WHERE id = $1", "Pgbus Delete Failed Event", [id.to_i]
134
+ )
133
135
  end
134
136
  true
135
137
  rescue StandardError => e
@@ -138,7 +140,9 @@ module Pgbus
138
140
  end
139
141
 
140
142
  def discard_failed_event(id)
141
- connection.execute("DELETE FROM pgbus_failed_events WHERE id = #{id.to_i}")
143
+ connection.exec_delete(
144
+ "DELETE FROM pgbus_failed_events WHERE id = $1", "Pgbus Delete Failed Event", [id.to_i]
145
+ )
142
146
  true
143
147
  rescue StandardError => e
144
148
  Pgbus.logger.debug { "[Pgbus::Web] Error discarding failed event #{id}: #{e.message}" }
@@ -147,18 +151,27 @@ module Pgbus
147
151
 
148
152
  def retry_all_failed
149
153
  count = 0
150
- connection.select_all("SELECT * FROM pgbus_failed_events").each do |event|
151
- payload = JSON.parse(event["payload"])
152
- headers = event["headers"]
153
- headers = JSON.parse(headers) if headers.is_a?(String)
154
-
155
- connection.transaction do
156
- @client.send_message(event["queue_name"], payload, headers: headers)
157
- connection.execute("DELETE FROM pgbus_failed_events WHERE id = #{event["id"].to_i}")
154
+ loop do
155
+ batch = connection.select_all(
156
+ "SELECT * FROM pgbus_failed_events ORDER BY id LIMIT 100", "Pgbus Retry Batch"
157
+ ).to_a
158
+ break if batch.empty?
159
+
160
+ batch.each do |event|
161
+ payload = JSON.parse(event["payload"])
162
+ headers = event["headers"]
163
+ headers = JSON.parse(headers) if headers.is_a?(String)
164
+
165
+ connection.transaction do
166
+ @client.send_message(event["queue_name"], payload, headers: headers)
167
+ connection.exec_delete(
168
+ "DELETE FROM pgbus_failed_events WHERE id = $1", "Pgbus Delete Failed Event", [event["id"].to_i]
169
+ )
170
+ end
171
+ count += 1
172
+ rescue StandardError => e
173
+ Pgbus.logger.error { "[Pgbus::Web] Failed to retry event #{event["id"]}: #{e.message}" }
158
174
  end
159
- count += 1
160
- rescue StandardError => e
161
- Pgbus.logger.error { "[Pgbus::Web] Failed to retry event #{event["id"]}: #{e.message}" }
162
175
  end
163
176
  count
164
177
  end
@@ -552,7 +565,10 @@ module Pgbus
552
565
  end
553
566
 
554
567
  def sanitize_name(name)
555
- name.gsub(/[^a-zA-Z0-9_]/, "")
568
+ sanitized = name.gsub(/[^a-zA-Z0-9_]/, "")
569
+ raise ArgumentError, "Invalid queue name: #{name.inspect}" if sanitized.empty?
570
+
571
+ sanitized
556
572
  end
557
573
 
558
574
  def parse_arguments(args)
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.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson