pgbus 0.2.0 → 0.2.1

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: e06c29b37491ad78b26d4799f11baea645e4380b2ebb6950bec74ad7be339f13
4
- data.tar.gz: 712a67117a92021b45ab361476068c9885b03a8215ff02d8327e58f6b9a5abd2
3
+ metadata.gz: 35bd03aa1d30dde2aea609829f027d9a1355282fb049e22d4f3a20119039828a
4
+ data.tar.gz: 341150d6777d9747f19b8446692ffd262403eee0b2e687461176cce820af8177
5
5
  SHA512:
6
- metadata.gz: f3e083cd40e0a1d02577c015283d83e3aeb8a1205bde8bb21e5e4c5e6df8b70f031f894fb1bfc93771caaeb4d26b5e1c7cca08cfa01dfad0d31a2b946db997f4
7
- data.tar.gz: fe1d70f3a39bb849981a025b5d9fee6e2c447a0fc641ce771e606e42f2da8892a40e7cb68ecaa57aac47fe970c819cdd0c60f8c439d92fe1306662da309f1dfd
6
+ metadata.gz: dafeed2ede684fedb7a95b76ac3504c0987624ba49bdd5b3c9ea18c67a51f241ce626069b3d62f0dff617d9582da83490e8b527e600f396a334785d56010c405
7
+ data.tar.gz: 55a9207ec4e8bbe56ce2e8d7322f0bf242daf6b30f89ac87f4cd37dc4dad2565704503076e24b994a3c52aec31e822abfbdafe72ee11ef239227a703d823f875
@@ -156,7 +156,7 @@ module Pgbus
156
156
 
157
157
  def archive_from(queue_name, msg_id, source_queue: nil)
158
158
  if source_queue
159
- client.archive_from_queue(source_queue, msg_id)
159
+ client.archive_message(source_queue, msg_id, prefixed: false)
160
160
  else
161
161
  client.archive_message(queue_name, msg_id)
162
162
  end
data/lib/pgbus/client.rb CHANGED
@@ -19,18 +19,26 @@ module Pgbus
19
19
  require "pgmq"
20
20
  end
21
21
  @config = config
22
- # Force pool_size=1. PG::Connection (libpq) is not thread-safe.
23
- # When using the Rails lambda path (-> { AR::Base.connection.raw_connection }),
24
- # the pool would return the same underlying PG::Connection that ActiveRecord
25
- # also uses, causing concurrent access corruption (segfaults, result.ntuples
26
- # NoMethodError). A single-connection pool combined with @pgmq_mutex ensures
27
- # all PGMQ operations are serialized.
28
- @pgmq = PGMQ::Client.new(
29
- config.connection_options,
30
- pool_size: 1,
31
- pool_timeout: config.pool_timeout
32
- )
33
- @pgmq_mutex = Mutex.new
22
+ conn_opts = config.connection_options
23
+ @shared_connection = conn_opts.is_a?(Proc)
24
+
25
+ if @shared_connection
26
+ # When using the Rails lambda path (-> { AR::Base.connection.raw_connection }),
27
+ # the Proc returns the same underlying PG::Connection that ActiveRecord uses.
28
+ # PG::Connection (libpq) is not thread-safe — concurrent access causes
29
+ # segfaults and result corruption. Force pool_size=1 and serialize all
30
+ # operations through a mutex.
31
+ @pgmq = PGMQ::Client.new(conn_opts, pool_size: 1, pool_timeout: config.pool_timeout)
32
+ @pgmq_mutex = Mutex.new
33
+ else
34
+ # With a String URL or Hash params, pgmq-ruby creates its own dedicated
35
+ # PG::Connection per pool slot — no shared state with ActiveRecord.
36
+ # Use the configured pool_size and let pgmq-ruby's connection_pool handle
37
+ # concurrency internally (no mutex needed).
38
+ @pgmq = PGMQ::Client.new(conn_opts, pool_size: config.pool_size, pool_timeout: config.pool_timeout)
39
+ @pgmq_mutex = nil
40
+ end
41
+
34
42
  @queues_created = Concurrent::Map.new
35
43
  @queue_strategy = QueueFactory.for(config)
36
44
  @schema_ensured = false
@@ -69,7 +77,7 @@ module Pgbus
69
77
  full_name = config.queue_name(queue_name)
70
78
  ensure_queue(queue_name)
71
79
  serialized = payloads.map { |p| serialize(p) }
72
- serialized_headers = headers&.map { |h| serialize(h) }
80
+ serialized_headers = headers&.map { |h| h.nil? ? nil : serialize(h) }
73
81
  Instrumentation.instrument("pgbus.client.send_batch", queue: full_name, size: payloads.size) do
74
82
  synchronized { @pgmq.produce_batch(full_name, serialized, headers: serialized_headers, delay: delay) }
75
83
  end
@@ -128,31 +136,47 @@ module Pgbus
128
136
  end
129
137
  end
130
138
 
131
- def delete_message(queue_name, msg_id)
132
- full_name = config.queue_name(queue_name)
133
- synchronized { @pgmq.delete(full_name, msg_id) }
139
+ # Read from multiple queues in a single SQL query (UNION ALL).
140
+ # Each returned message includes a queue_name field identifying its source.
141
+ # queue_names should be logical names (prefix is added automatically).
142
+ def read_multi(queue_names, qty:, vt: nil)
143
+ full_names = queue_names.map { |q| config.queue_name(q) }
144
+ Instrumentation.instrument("pgbus.client.read_multi", queues: full_names, qty: qty) do
145
+ synchronized { @pgmq.read_multi(full_names, vt: vt || config.visibility_timeout, qty: qty) }
146
+ end
134
147
  end
135
148
 
136
- def archive_message(queue_name, msg_id)
137
- full_name = config.queue_name(queue_name)
138
- synchronized { @pgmq.archive(full_name, msg_id) }
149
+ # Delete a message. Pass prefixed: false when queue_name is already
150
+ # the full PGMQ queue name (e.g. from priority sub-queues or dashboard).
151
+ def delete_message(queue_name, msg_id, prefixed: true)
152
+ name = prefixed ? config.queue_name(queue_name) : queue_name
153
+ synchronized { @pgmq.delete(name, msg_id) }
139
154
  end
140
155
 
141
- def archive_from_queue(full_queue_name, msg_id)
142
- synchronized { @pgmq.archive(full_queue_name, msg_id) }
156
+ # Archive a message. Pass prefixed: false when queue_name is already
157
+ # the full PGMQ queue name.
158
+ def archive_message(queue_name, msg_id, prefixed: true)
159
+ name = prefixed ? config.queue_name(queue_name) : queue_name
160
+ synchronized { @pgmq.archive(name, msg_id) }
143
161
  end
144
162
 
145
- def extend_visibility(queue_name, msg_id, vt:)
146
- full_name = config.queue_name(queue_name)
147
- synchronized { @pgmq.set_vt(full_name, msg_id, vt: vt) }
163
+ # Batch archive — moves multiple messages to the archive table in one call.
164
+ def archive_batch(queue_name, msg_ids, prefixed: true)
165
+ name = prefixed ? config.queue_name(queue_name) : queue_name
166
+ synchronized { @pgmq.archive_batch(name, msg_ids) }
148
167
  end
149
168
 
150
- def set_visibility_timeout(queue_name, msg_id, vt:)
151
- synchronized { @pgmq.set_vt(queue_name, msg_id, vt: vt) }
169
+ # Batch delete — permanently removes multiple messages in one call.
170
+ def delete_batch(queue_name, msg_ids, prefixed: true)
171
+ name = prefixed ? config.queue_name(queue_name) : queue_name
172
+ synchronized { @pgmq.delete_batch(name, msg_ids) }
152
173
  end
153
174
 
154
- def delete_from_queue(queue_name, msg_id)
155
- synchronized { @pgmq.delete(queue_name, msg_id) }
175
+ # Set visibility timeout. Pass prefixed: false when queue_name is already
176
+ # the full PGMQ queue name.
177
+ def set_visibility_timeout(queue_name, msg_id, vt:, prefixed: true)
178
+ name = prefixed ? config.queue_name(queue_name) : queue_name
179
+ synchronized { @pgmq.set_vt(name, msg_id, vt: vt) }
156
180
  end
157
181
 
158
182
  def transaction(&block)
@@ -327,11 +351,15 @@ module Pgbus
327
351
  end
328
352
  end
329
353
 
330
- # Serialize all PGMQ operations through a single mutex.
331
- # PG::Connection is not thread-safe concurrent access from worker
332
- # threads causes segfaults and result corruption.
354
+ # Serialize PGMQ operations through a mutex when sharing a connection
355
+ # with ActiveRecord (Proc path). When pgmq-ruby owns its own connections
356
+ # (String/Hash path), the internal connection_pool handles concurrency.
333
357
  def synchronized(&)
334
- @pgmq_mutex.synchronize(&)
358
+ if @pgmq_mutex
359
+ @pgmq_mutex.synchronize(&)
360
+ else
361
+ yield
362
+ end
335
363
  end
336
364
 
337
365
  def serialize(data)
@@ -7,13 +7,7 @@ module Pgbus
7
7
 
8
8
  def publish(routing_key, payload, headers: nil, delay: 0)
9
9
  event_data = build_event_data(payload)
10
-
11
- if Pgbus.client.pgmq.respond_to?(:produce_topic)
12
- Pgbus.client.publish_to_topic(routing_key, event_data, headers: headers, delay: delay)
13
- else
14
- # Fallback: send directly to queues matching the routing key
15
- Pgbus.client.send_message(routing_key, event_data, headers: headers, delay: delay)
16
- end
10
+ Pgbus.client.publish_to_topic(routing_key, event_data, headers: headers, delay: delay)
17
11
  end
18
12
 
19
13
  def publish_later(routing_key, payload, delay:, headers: nil)
@@ -54,10 +54,7 @@ module Pgbus
54
54
  .to_a
55
55
  break if entries.empty?
56
56
 
57
- entries.each do |entry|
58
- succeeded += 1 if publish_entry(entry)
59
- end
60
-
57
+ succeeded = publish_entries(entries)
61
58
  published += succeeded
62
59
  break if succeeded.zero? || entries.size < config.outbox_batch_size
63
60
  end
@@ -74,24 +71,65 @@ module Pgbus
74
71
 
75
72
  private
76
73
 
77
- def publish_entry(entry)
78
- if entry.routing_key.present?
79
- Pgbus.client.publish_to_topic(
80
- entry.routing_key,
81
- entry.payload,
82
- headers: entry.headers,
83
- delay: entry.delay || 0
84
- )
85
- else
86
- Pgbus.client.send_message(
87
- entry.queue_name,
88
- entry.payload,
89
- headers: entry.headers,
90
- delay: entry.delay || 0,
91
- priority: entry.priority
92
- )
74
+ def publish_entries(entries)
75
+ # Partition: topic-routed entries must be published individually
76
+ # (different routing keys), direct-queue entries can be batched.
77
+ topic_entries, queue_entries = entries.partition { |e| e.routing_key.present? }
78
+
79
+ succeeded = 0
80
+ topic_entries.each { |e| succeeded += 1 if publish_single(e) }
81
+ succeeded += publish_queue_batch(queue_entries)
82
+ succeeded
83
+ end
84
+
85
+ def publish_single(entry)
86
+ Pgbus.client.publish_to_topic(
87
+ entry.routing_key,
88
+ entry.payload,
89
+ headers: entry.headers,
90
+ delay: entry.delay || 0
91
+ )
92
+ entry.update!(published_at: Time.current)
93
+ true
94
+ rescue StandardError => e
95
+ Pgbus.logger.error { "[Pgbus] Failed to publish outbox entry #{entry.id}: #{e.message}" }
96
+ false
97
+ end
98
+
99
+ # Group direct-queue entries by (queue_name, priority, delay) and
100
+ # use send_batch for each group to reduce round-trips.
101
+ def publish_queue_batch(entries)
102
+ return 0 if entries.empty?
103
+
104
+ succeeded = 0
105
+ entries.group_by { |e| [e.queue_name, e.priority, e.delay || 0] }.each do |(queue, _priority, delay), group|
106
+ payloads = group.map(&:payload)
107
+ headers = group.map(&:headers)
108
+ headers = nil if headers.all?(&:blank?)
109
+
110
+ Pgbus.client.send_batch(queue, payloads, headers: headers, delay: delay)
111
+ now = Time.current
112
+ group.each { |e| e.update!(published_at: now) }
113
+ succeeded += group.size
114
+ rescue StandardError => e
115
+ Pgbus.logger.error { "[Pgbus] Failed to batch-publish #{group.size} outbox entries: #{e.message}" }
116
+ # Fall back to individual publishing for this group
117
+ group.each { |entry| succeeded += 1 if publish_single_queue(entry) }
93
118
  end
119
+ succeeded
120
+ end
94
121
 
122
+ # Fallback for individual publishing when a batch fails.
123
+ # Intentionally omits priority to match send_batch routing behavior
124
+ # (base queue only), ensuring consistent queue placement regardless
125
+ # of whether the batch or fallback path is used.
126
+ def publish_single_queue(entry)
127
+ Pgbus.client.send_message(
128
+ entry.queue_name,
129
+ entry.payload,
130
+ headers: entry.headers,
131
+ delay: entry.delay || 0
132
+ )
95
133
  entry.update!(published_at: Time.current)
96
134
  true
97
135
  rescue StandardError => e
@@ -56,9 +56,12 @@ module Pgbus
56
56
  idle = @pool.max_length - @pool.queue_length
57
57
  return interruptible_sleep(config.polling_interval) if idle <= 0
58
58
 
59
- tagged_messages = @queue_names.flat_map do |queue_name|
60
- (Pgbus.client.read_batch(queue_name, qty: idle) || []).map { |m| [queue_name, m] }
61
- end.first(idle)
59
+ tagged_messages = if @queue_names.size == 1
60
+ queue = @queue_names.first
61
+ (Pgbus.client.read_batch(queue, qty: idle) || []).map { |m| [queue, m] }
62
+ else
63
+ fetch_multi_consumer(idle)
64
+ end
62
65
 
63
66
  if tagged_messages.empty?
64
67
  interruptible_sleep(config.polling_interval)
@@ -94,6 +97,16 @@ module Pgbus
94
97
  # the next read will route to DLQ above.
95
98
  end
96
99
 
100
+ def fetch_multi_consumer(qty)
101
+ messages = Pgbus.client.read_multi(@queue_names, qty: qty) || []
102
+ prefix = "#{config.queue_prefix}_"
103
+
104
+ messages.map do |m|
105
+ logical = m.queue_name&.delete_prefix(prefix) || @queue_names.first
106
+ [logical, m]
107
+ end
108
+ end
109
+
97
110
  def pattern_overlaps?(topic_filter, subscription_pattern)
98
111
  # Simple check: if either is a subset of the other
99
112
  topic_filter == subscription_pattern ||
@@ -107,7 +107,7 @@ module Pgbus
107
107
  end
108
108
 
109
109
  # Returns an array of [queue_name, message] pairs so we always know
110
- # which queue each message came from (PGMQ messages don't carry this).
110
+ # which queue each message came from.
111
111
  def fetch_messages(qty)
112
112
  active_queues = queues.reject { |q| @circuit_breaker.paused?(q) }
113
113
  active_queues = active_queues.select { |q| @queue_lock.try_lock(q) } if @single_active_consumer
@@ -120,10 +120,7 @@ module Pgbus
120
120
  messages = Pgbus.client.read_batch(queue, qty: qty) || []
121
121
  messages.map { |m| [queue, m] }
122
122
  else
123
- per_queue = [(qty / active_queues.size.to_f).ceil, 1].max
124
- active_queues.flat_map do |q|
125
- (Pgbus.client.read_batch(q, qty: per_queue) || []).map { |m| [q, m] }
126
- end.first(qty)
123
+ fetch_multi(active_queues, qty)
127
124
  end
128
125
  rescue StandardError => e
129
126
  Pgbus.logger.error { "[Pgbus] Error fetching messages: #{e.message}" }
@@ -147,6 +144,19 @@ module Pgbus
147
144
  results
148
145
  end
149
146
 
147
+ # Use pgmq-ruby's read_multi to read from all queues in a single
148
+ # SQL query (UNION ALL). Each returned message carries a queue_name
149
+ # field so we can map it back to the logical queue.
150
+ def fetch_multi(active_queues, qty)
151
+ messages = Pgbus.client.read_multi(active_queues, qty: qty) || []
152
+ prefix = "#{config.queue_prefix}_"
153
+
154
+ messages.map do |m|
155
+ logical = m.queue_name&.delete_prefix(prefix) || active_queues.first
156
+ [logical, m]
157
+ end
158
+ end
159
+
150
160
  def priority_enabled?
151
161
  config.priority_levels && config.priority_levels > 1
152
162
  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.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -105,11 +105,11 @@ module Pgbus
105
105
  end
106
106
 
107
107
  def retry_job(queue_name, msg_id)
108
- @client.set_visibility_timeout(queue_name, msg_id.to_i, vt: 0)
108
+ @client.set_visibility_timeout(queue_name, msg_id.to_i, vt: 0, prefixed: false)
109
109
  end
110
110
 
111
111
  def discard_job(queue_name, msg_id)
112
- @client.archive_message(queue_name, msg_id.to_i)
112
+ @client.archive_message(queue_name, msg_id.to_i, prefixed: false)
113
113
  end
114
114
 
115
115
  # Failed events
@@ -269,7 +269,7 @@ module Pgbus
269
269
 
270
270
  def discard_dlq_message(queue_name, msg_id)
271
271
  # queue_name here is the full DLQ name (already prefixed)
272
- @client.delete_from_queue(queue_name, msg_id.to_i)
272
+ @client.delete_message(queue_name, msg_id.to_i, prefixed: false)
273
273
  true
274
274
  rescue StandardError => e
275
275
  Pgbus.logger.debug { "[Pgbus::Web] Error discarding DLQ message #{msg_id}: #{e.message}" }
@@ -290,14 +290,16 @@ module Pgbus
290
290
 
291
291
  def discard_all_dlq
292
292
  messages = dlq_messages(page: 1, per_page: 1000)
293
- count = 0
294
- messages.each do |m|
295
- discard_dlq_message(m[:queue_name], m[:msg_id]) && count += 1
293
+ return 0 if messages.empty?
294
+
295
+ # Group by queue for batch delete — one call per DLQ instead of N calls
296
+ messages.group_by { |m| m[:queue_name] }.sum do |queue_name, msgs|
297
+ ids = msgs.map { |m| m[:msg_id].to_i }
298
+ @client.delete_batch(queue_name, ids, prefixed: false).size
296
299
  rescue StandardError => e
297
- Pgbus.logger.debug { "[Pgbus::Web] Error discarding DLQ message #{m[:msg_id]}: #{e.message}" }
298
- next
300
+ Pgbus.logger.debug { "[Pgbus::Web] Error batch-discarding DLQ messages from #{queue_name}: #{e.message}" }
301
+ 0
299
302
  end
300
- count
301
303
  end
302
304
 
303
305
  # Processes
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.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson