pgmq-ruby 0.1.0 → 0.3.0

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.
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ class Client
5
+ # Single-queue message reading operations
6
+ #
7
+ # This module handles reading messages from a single queue, including basic reads,
8
+ # batch reads, and long-polling for efficient message consumption.
9
+ module Consumer
10
+ # Reads a message from the queue
11
+ #
12
+ # @param queue_name [String] name of the queue
13
+ # @param vt [Integer] visibility timeout in seconds
14
+ # @param conditional [Hash] optional JSONB filter for message payload
15
+ # @return [PGMQ::Message, nil] message object or nil if queue is empty
16
+ #
17
+ # @example
18
+ # msg = client.read("orders", vt: 30)
19
+ # if msg
20
+ # process(msg.payload)
21
+ # client.delete("orders", msg.msg_id)
22
+ # end
23
+ #
24
+ # @example With conditional filtering
25
+ # msg = client.read("orders", vt: 30, conditional: { type: "priority", status: "pending" })
26
+ def read(
27
+ queue_name,
28
+ vt: DEFAULT_VT,
29
+ conditional: {}
30
+ )
31
+ validate_queue_name!(queue_name)
32
+
33
+ result = with_connection do |conn|
34
+ if conditional.empty?
35
+ conn.exec_params(
36
+ 'SELECT * FROM pgmq.read($1::text, $2::integer, $3::integer)',
37
+ [queue_name, vt, 1]
38
+ )
39
+ else
40
+ conn.exec_params(
41
+ 'SELECT * FROM pgmq.read($1::text, $2::integer, $3::integer, $4::jsonb)',
42
+ [queue_name, vt, 1, conditional.to_json]
43
+ )
44
+ end
45
+ end
46
+
47
+ return nil if result.ntuples.zero?
48
+
49
+ Message.new(result[0])
50
+ end
51
+
52
+ # Reads multiple messages from the queue
53
+ #
54
+ # @param queue_name [String] name of the queue
55
+ # @param vt [Integer] visibility timeout in seconds
56
+ # @param qty [Integer] number of messages to read
57
+ # @param conditional [Hash] optional JSONB filter for message payload
58
+ # @return [Array<PGMQ::Message>] array of messages
59
+ #
60
+ # @example
61
+ # messages = client.read_batch("orders", vt: 30, qty: 10)
62
+ # messages.each do |msg|
63
+ # process(msg.payload)
64
+ # client.delete("orders", msg.msg_id)
65
+ # end
66
+ #
67
+ # @example With conditional filtering
68
+ # messages = client.read_batch(
69
+ # "orders",
70
+ # vt: 30,
71
+ # qty: 10,
72
+ # conditional: { priority: "high" }
73
+ # )
74
+ def read_batch(
75
+ queue_name,
76
+ vt: DEFAULT_VT,
77
+ qty: 1,
78
+ conditional: {}
79
+ )
80
+ validate_queue_name!(queue_name)
81
+
82
+ result = with_connection do |conn|
83
+ if conditional.empty?
84
+ conn.exec_params(
85
+ 'SELECT * FROM pgmq.read($1::text, $2::integer, $3::integer)',
86
+ [queue_name, vt, qty]
87
+ )
88
+ else
89
+ conn.exec_params(
90
+ 'SELECT * FROM pgmq.read($1::text, $2::integer, $3::integer, $4::jsonb)',
91
+ [queue_name, vt, qty, conditional.to_json]
92
+ )
93
+ end
94
+ end
95
+
96
+ result.map { |row| Message.new(row) }
97
+ end
98
+
99
+ # Reads messages with long-polling support
100
+ #
101
+ # Polls the queue for messages, waiting up to max_poll_seconds if queue is empty
102
+ #
103
+ # @param queue_name [String] name of the queue
104
+ # @param vt [Integer] visibility timeout in seconds
105
+ # @param qty [Integer] number of messages to read
106
+ # @param max_poll_seconds [Integer] maximum time to poll in seconds
107
+ # @param poll_interval_ms [Integer] interval between polls in milliseconds
108
+ # @param conditional [Hash] optional JSONB filter for message payload
109
+ # @return [Array<PGMQ::Message>] array of messages
110
+ #
111
+ # @example
112
+ # messages = client.read_with_poll("orders",
113
+ # vt: 30,
114
+ # qty: 5,
115
+ # max_poll_seconds: 10,
116
+ # poll_interval_ms: 250
117
+ # )
118
+ #
119
+ # @example With conditional filtering
120
+ # messages = client.read_with_poll("orders",
121
+ # vt: 30,
122
+ # qty: 5,
123
+ # conditional: { status: "pending" }
124
+ # )
125
+ def read_with_poll(
126
+ queue_name,
127
+ vt: DEFAULT_VT,
128
+ qty: 1,
129
+ max_poll_seconds: 5,
130
+ poll_interval_ms: 100,
131
+ conditional: {}
132
+ )
133
+ validate_queue_name!(queue_name)
134
+
135
+ result = with_connection do |conn|
136
+ if conditional.empty?
137
+ conn.exec_params(
138
+ 'SELECT * FROM pgmq.read_with_poll($1::text, $2::integer, $3::integer, $4::integer, $5::integer)',
139
+ [queue_name, vt, qty, max_poll_seconds, poll_interval_ms]
140
+ )
141
+ else
142
+ sql = 'SELECT * FROM pgmq.read_with_poll($1::text, $2::integer, $3::integer, ' \
143
+ '$4::integer, $5::integer, $6::jsonb)'
144
+ conn.exec_params(
145
+ sql,
146
+ [queue_name, vt, qty, max_poll_seconds, poll_interval_ms, conditional.to_json]
147
+ )
148
+ end
149
+ end
150
+
151
+ result.map { |row| Message.new(row) }
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ class Client
5
+ # Queue maintenance operations
6
+ #
7
+ # This module handles queue maintenance tasks such as purging messages
8
+ # and detaching archive tables.
9
+ module Maintenance
10
+ # Purges all messages from a queue
11
+ #
12
+ # @param queue_name [String] name of the queue
13
+ # @return [Integer] number of messages purged
14
+ #
15
+ # @example
16
+ # count = client.purge_queue("old_queue")
17
+ # puts "Purged #{count} messages"
18
+ def purge_queue(queue_name)
19
+ validate_queue_name!(queue_name)
20
+
21
+ result = with_connection do |conn|
22
+ conn.exec_params('SELECT pgmq.purge_queue($1::text)', [queue_name])
23
+ end
24
+
25
+ result[0]['purge_queue']
26
+ end
27
+
28
+ # Detaches the archive table from PGMQ management
29
+ #
30
+ # @param queue_name [String] name of the queue
31
+ # @return [void]
32
+ #
33
+ # @example
34
+ # client.detach_archive("orders")
35
+ def detach_archive(queue_name)
36
+ validate_queue_name!(queue_name)
37
+
38
+ with_connection do |conn|
39
+ conn.exec_params('SELECT pgmq.detach_archive($1::text)', [queue_name])
40
+ end
41
+
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ class Client
5
+ # Message lifecycle operations (pop, delete, archive, visibility timeout)
6
+ #
7
+ # This module handles message state transitions including popping (atomic read+delete),
8
+ # deleting, archiving, and updating visibility timeouts.
9
+ module MessageLifecycle
10
+ # Pops a message (atomic read + delete)
11
+ #
12
+ # @param queue_name [String] name of the queue
13
+ # @return [PGMQ::Message, nil] message object or nil if queue is empty
14
+ #
15
+ # @example
16
+ # msg = client.pop("orders")
17
+ # process(msg.payload) if msg
18
+ def pop(queue_name)
19
+ validate_queue_name!(queue_name)
20
+
21
+ result = with_connection do |conn|
22
+ conn.exec_params('SELECT * FROM pgmq.pop($1::text)', [queue_name])
23
+ end
24
+
25
+ return nil if result.ntuples.zero?
26
+
27
+ Message.new(result[0])
28
+ end
29
+
30
+ # Deletes a message from the queue
31
+ #
32
+ # @param queue_name [String] name of the queue
33
+ # @param msg_id [Integer] message ID to delete
34
+ # @return [Boolean] true if message was deleted
35
+ #
36
+ # @example
37
+ # client.delete("orders", 123)
38
+ def delete(
39
+ queue_name,
40
+ msg_id
41
+ )
42
+ validate_queue_name!(queue_name)
43
+
44
+ result = with_connection do |conn|
45
+ conn.exec_params(
46
+ 'SELECT pgmq.delete($1::text, $2::bigint)',
47
+ [queue_name, msg_id]
48
+ )
49
+ end
50
+
51
+ return false if result.ntuples.zero?
52
+
53
+ result[0]['delete'] == 't'
54
+ end
55
+
56
+ # Deletes multiple messages from the queue
57
+ #
58
+ # @param queue_name [String] name of the queue
59
+ # @param msg_ids [Array<Integer>] array of message IDs to delete
60
+ # @return [Array<Integer>] array of successfully deleted message IDs
61
+ #
62
+ # @example
63
+ # deleted = client.delete_batch("orders", [101, 102, 103])
64
+ def delete_batch(
65
+ queue_name,
66
+ msg_ids
67
+ )
68
+ validate_queue_name!(queue_name)
69
+ return [] if msg_ids.empty?
70
+
71
+ # Use PostgreSQL array parameter binding
72
+ result = with_connection do |conn|
73
+ encoder = PG::TextEncoder::Array.new
74
+ encoded_array = encoder.encode(msg_ids)
75
+
76
+ conn.exec_params(
77
+ 'SELECT * FROM pgmq.delete($1::text, $2::bigint[])',
78
+ [queue_name, encoded_array]
79
+ )
80
+ end
81
+
82
+ result.map { |row| row['delete'] }
83
+ end
84
+
85
+ # Deletes specific messages from multiple queues in a single transaction
86
+ #
87
+ # Efficiently deletes messages across different queues atomically.
88
+ # Useful when processing related messages from different queues.
89
+ #
90
+ # @param deletions [Hash] hash of queue_name => array of msg_ids
91
+ # @return [Hash] hash of queue_name => array of deleted msg_ids
92
+ #
93
+ # @example Delete messages from multiple queues
94
+ # client.delete_multi({
95
+ # 'orders' => [1, 2, 3],
96
+ # 'notifications' => [5, 6],
97
+ # 'emails' => [10]
98
+ # })
99
+ # # => { 'orders' => [1, 2, 3], 'notifications' => [5, 6], 'emails' => [10] }
100
+ #
101
+ # @example Clean up after batch processing across queues
102
+ # messages = client.read_multi(['q1', 'q2', 'q3'], qty: 10)
103
+ # deletions = messages.group_by(&:queue_name).transform_values { |mss| mss.map(&:msg_id) }
104
+ # client.delete_multi(deletions)
105
+ def delete_multi(deletions)
106
+ raise ArgumentError, 'deletions must be a hash' unless deletions.is_a?(Hash)
107
+ return {} if deletions.empty?
108
+
109
+ # Validate all queue names
110
+ deletions.each_key { |qn| validate_queue_name!(qn) }
111
+
112
+ transaction do |txn|
113
+ result = {}
114
+ deletions.each do |queue_name, msg_ids|
115
+ next if msg_ids.empty?
116
+
117
+ deleted_ids = txn.delete_batch(queue_name, msg_ids)
118
+ result[queue_name] = deleted_ids
119
+ end
120
+ result
121
+ end
122
+ end
123
+
124
+ # Archives a message
125
+ #
126
+ # @param queue_name [String] name of the queue
127
+ # @param msg_id [Integer] message ID to archive
128
+ # @return [Boolean] true if message was archived
129
+ #
130
+ # @example
131
+ # client.archive("orders", 123)
132
+ def archive(
133
+ queue_name,
134
+ msg_id
135
+ )
136
+ validate_queue_name!(queue_name)
137
+
138
+ result = with_connection do |conn|
139
+ conn.exec_params(
140
+ 'SELECT pgmq.archive($1::text, $2::bigint)',
141
+ [queue_name, msg_id]
142
+ )
143
+ end
144
+
145
+ return false if result.ntuples.zero?
146
+
147
+ result[0]['archive'] == 't'
148
+ end
149
+
150
+ # Archives multiple messages
151
+ #
152
+ # @param queue_name [String] name of the queue
153
+ # @param msg_ids [Array<Integer>] array of message IDs to archive
154
+ # @return [Array<Integer>] array of successfully archived message IDs
155
+ #
156
+ # @example
157
+ # archived = client.archive_batch("orders", [101, 102, 103])
158
+ def archive_batch(
159
+ queue_name,
160
+ msg_ids
161
+ )
162
+ validate_queue_name!(queue_name)
163
+ return [] if msg_ids.empty?
164
+
165
+ # Use PostgreSQL array parameter binding
166
+ result = with_connection do |conn|
167
+ encoder = PG::TextEncoder::Array.new
168
+ encoded_array = encoder.encode(msg_ids)
169
+
170
+ conn.exec_params(
171
+ 'SELECT * FROM pgmq.archive($1::text, $2::bigint[])',
172
+ [queue_name, encoded_array]
173
+ )
174
+ end
175
+
176
+ result.map { |row| row['archive'] }
177
+ end
178
+
179
+ # Archives specific messages from multiple queues in a single transaction
180
+ #
181
+ # Efficiently archives messages across different queues atomically.
182
+ #
183
+ # @param archives [Hash] hash of queue_name => array of msg_ids
184
+ # @return [Hash] hash of queue_name => array of archived msg_ids
185
+ #
186
+ # @example Archive messages from multiple queues
187
+ # client.archive_multi({
188
+ # 'orders' => [1, 2],
189
+ # 'notifications' => [5]
190
+ # })
191
+ def archive_multi(archives)
192
+ raise ArgumentError, 'archives must be a hash' unless archives.is_a?(Hash)
193
+ return {} if archives.empty?
194
+
195
+ # Validate all queue names
196
+ archives.each_key { |qn| validate_queue_name!(qn) }
197
+
198
+ transaction do |txn|
199
+ result = {}
200
+ archives.each do |queue_name, msg_ids|
201
+ next if msg_ids.empty?
202
+
203
+ archived_ids = txn.archive_batch(queue_name, msg_ids)
204
+ result[queue_name] = archived_ids
205
+ end
206
+ result
207
+ end
208
+ end
209
+
210
+ # Updates the visibility timeout for a message
211
+ #
212
+ # @param queue_name [String] name of the queue
213
+ # @param msg_id [Integer] message ID
214
+ # @param vt_offset [Integer] visibility timeout offset in seconds
215
+ # @return [PGMQ::Message] updated message
216
+ #
217
+ # @example
218
+ # # Extend processing time by 60 more seconds
219
+ # msg = client.set_vt("orders", 123, vt_offset: 60)
220
+ def set_vt(
221
+ queue_name,
222
+ msg_id,
223
+ vt_offset:
224
+ )
225
+ validate_queue_name!(queue_name)
226
+
227
+ result = with_connection do |conn|
228
+ conn.exec_params(
229
+ 'SELECT * FROM pgmq.set_vt($1::text, $2::bigint, $3::integer)',
230
+ [queue_name, msg_id, vt_offset]
231
+ )
232
+ end
233
+
234
+ return nil if result.ntuples.zero?
235
+
236
+ Message.new(result[0])
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ class Client
5
+ # Queue metrics and monitoring
6
+ #
7
+ # This module handles retrieving queue metrics such as queue length,
8
+ # message age, and total message counts.
9
+ module Metrics
10
+ # Gets metrics for a specific queue
11
+ #
12
+ # @param queue_name [String] name of the queue
13
+ # @return [PGMQ::Metrics] metrics object
14
+ #
15
+ # @example
16
+ # metrics = client.metrics("orders")
17
+ # puts "Queue length: #{metrics.queue_length}"
18
+ # puts "Oldest message: #{metrics.oldest_msg_age_sec}s"
19
+ def metrics(queue_name)
20
+ validate_queue_name!(queue_name)
21
+
22
+ result = with_connection do |conn|
23
+ conn.exec_params('SELECT * FROM pgmq.metrics($1::text)', [queue_name])
24
+ end
25
+
26
+ return nil if result.ntuples.zero?
27
+
28
+ PGMQ::Metrics.new(result[0])
29
+ end
30
+
31
+ # Gets metrics for all queues
32
+ #
33
+ # @return [Array<PGMQ::Metrics>] array of metrics objects
34
+ #
35
+ # @example
36
+ # all_metrics = client.metrics_all
37
+ # all_metrics.each do |m|
38
+ # puts "#{m.queue_name}: #{m.queue_length} messages"
39
+ # end
40
+ def metrics_all
41
+ result = with_connection do |conn|
42
+ conn.exec('SELECT * FROM pgmq.metrics_all()')
43
+ end
44
+
45
+ result.map { |row| PGMQ::Metrics.new(row) }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ class Client
5
+ # Multi-queue operations
6
+ #
7
+ # This module handles efficient operations across multiple queues using single
8
+ # database queries with UNION ALL for optimal performance.
9
+ module MultiQueue
10
+ # Reads from multiple queues in a single query
11
+ #
12
+ # This is the most efficient way to monitor multiple queues with a single
13
+ # database connection. Uses UNION ALL to read from all queues in one query.
14
+ #
15
+ # @param queue_names [Array<String>] array of queue names to read from
16
+ # @param vt [Integer] visibility timeout in seconds
17
+ # @param qty [Integer] max messages to read per queue
18
+ # @param limit [Integer, nil] max total messages across all queues (nil = all)
19
+ # @return [Array<PGMQ::Message>] array of messages with queue_name attribute
20
+ #
21
+ # @example Read from multiple queues (gets first available from any queue)
22
+ # msg = client.read_multi(['orders', 'notifications', 'emails'], vt: 30, limit: 1).first
23
+ # puts "Got message from: #{msg.queue_name}"
24
+ #
25
+ # @example Read up to 5 messages from any of the queues
26
+ # messages = client.read_multi(['queue1', 'queue2', 'queue3'], vt: 30, qty: 5, limit: 5)
27
+ # messages.each do |msg|
28
+ # puts "Processing #{msg.queue_name}: #{msg.payload}"
29
+ # client.delete(msg.queue_name, msg.msg_id)
30
+ # end
31
+ #
32
+ # @example Poll all queues efficiently (single connection, single query)
33
+ # loop do
34
+ # messages = client.read_multi(
35
+ # ['orders', 'emails', 'webhooks'],
36
+ # vt: 30,
37
+ # qty: 10,
38
+ # limit: 10
39
+ # )
40
+ # break if messages.empty?
41
+ # messages.each { |msg| process(msg) }
42
+ # end
43
+ def read_multi(
44
+ queue_names,
45
+ vt: DEFAULT_VT,
46
+ qty: 1,
47
+ limit: nil
48
+ )
49
+ raise ArgumentError, 'queue_names must be an array' unless queue_names.is_a?(Array)
50
+ raise ArgumentError, 'queue_names cannot be empty' if queue_names.empty?
51
+ raise ArgumentError, 'queue_names cannot exceed 50 queues' if queue_names.size > 50
52
+
53
+ # Validate all queue names (prevents SQL injection)
54
+ queue_names.each { |qn| validate_queue_name!(qn) }
55
+
56
+ # Build UNION ALL query for all queues
57
+ # Note: Queue names are validated, so this is safe from SQL injection
58
+ union_queries = queue_names.map do |queue_name|
59
+ # Escape single quotes in queue name (though validation should prevent this)
60
+ escaped_name = queue_name.gsub("'", "''")
61
+ "SELECT '#{escaped_name}'::text as queue_name, * " \
62
+ "FROM pgmq.read('#{escaped_name}'::text, #{vt.to_i}, #{qty.to_i})"
63
+ end
64
+
65
+ sql = union_queries.join("\nUNION ALL\n")
66
+ sql += "\nLIMIT #{limit.to_i}" if limit
67
+
68
+ result = with_connection do |conn|
69
+ conn.exec(sql)
70
+ end
71
+
72
+ result.map do |row|
73
+ Message.new(row)
74
+ end
75
+ end
76
+
77
+ # Reads from multiple queues with long-polling (waits for messages)
78
+ #
79
+ # Efficiently polls multiple queues waiting for the first available message.
80
+ # This uses a single connection with periodic polling until a message arrives
81
+ # or the timeout is reached.
82
+ #
83
+ # @param queue_names [Array<String>] array of queue names to poll
84
+ # @param vt [Integer] visibility timeout in seconds
85
+ # @param qty [Integer] max messages to read per queue
86
+ # @param limit [Integer, nil] max total messages across all queues
87
+ # @param max_poll_seconds [Integer] maximum seconds to wait for messages
88
+ # @param poll_interval_ms [Integer] milliseconds between polls
89
+ # @return [Array<PGMQ::Message>] array of messages (empty if timeout)
90
+ #
91
+ # @example Wait for first available message from any queue
92
+ # msg = client.read_multi_with_poll(
93
+ # ['orders', 'notifications', 'emails'],
94
+ # vt: 30,
95
+ # max_poll_seconds: 5
96
+ # ).first
97
+ #
98
+ # @example Worker loop with efficient multi-queue polling
99
+ # loop do
100
+ # messages = client.read_multi_with_poll(
101
+ # ['queue1', 'queue2', 'queue3'],
102
+ # vt: 30,
103
+ # qty: 10,
104
+ # limit: 10,
105
+ # max_poll_seconds: 5
106
+ # )
107
+ #
108
+ # messages.each do |msg|
109
+ # process(msg.queue_name, msg.payload)
110
+ # client.delete(msg.queue_name, msg.msg_id)
111
+ # end
112
+ # end
113
+ def read_multi_with_poll(
114
+ queue_names,
115
+ vt: DEFAULT_VT,
116
+ qty: 1,
117
+ limit: nil,
118
+ max_poll_seconds: 5,
119
+ poll_interval_ms: 100
120
+ )
121
+ raise ArgumentError, 'queue_names must be an array' unless queue_names.is_a?(Array)
122
+ raise ArgumentError, 'queue_names cannot be empty' if queue_names.empty?
123
+ raise ArgumentError, 'queue_names cannot exceed 50 queues' if queue_names.size > 50
124
+
125
+ start_time = Time.now
126
+ poll_interval_seconds = poll_interval_ms / 1000.0
127
+
128
+ loop do
129
+ # Try to read from any queue
130
+ messages = read_multi(queue_names, vt: vt, qty: qty, limit: limit)
131
+ return messages if messages.any?
132
+
133
+ # Check timeout
134
+ elapsed = Time.now - start_time
135
+ break if elapsed >= max_poll_seconds
136
+
137
+ # Sleep for poll interval (or remaining time, whichever is less)
138
+ remaining = max_poll_seconds - elapsed
139
+ sleep [poll_interval_seconds, remaining].min
140
+ end
141
+
142
+ [] # Return empty array on timeout
143
+ end
144
+
145
+ # Pops a message from multiple queues (atomic read + delete)
146
+ #
147
+ # Efficiently gets and immediately deletes the first available message from
148
+ # any of the specified queues. Uses a single query with UNION ALL.
149
+ #
150
+ # @param queue_names [Array<String>] array of queue names
151
+ # @return [PGMQ::Message, nil] message with queue_name attribute, or nil if no messages
152
+ #
153
+ # @example Get first available message from any queue and auto-delete
154
+ # msg = client.pop_multi(['orders', 'notifications', 'emails'])
155
+ # if msg
156
+ # puts "Got message from #{msg.queue_name}"
157
+ # process(msg.payload)
158
+ # # No need to delete - already deleted!
159
+ # end
160
+ #
161
+ # @example Worker loop with atomic pop from multiple queues
162
+ # loop do
163
+ # msg = client.pop_multi(['queue1', 'queue2', 'queue3'])
164
+ # break unless msg
165
+ # process(msg.queue_name, msg.payload)
166
+ # end
167
+ def pop_multi(queue_names)
168
+ raise ArgumentError, 'queue_names must be an array' unless queue_names.is_a?(Array)
169
+ raise ArgumentError, 'queue_names cannot be empty' if queue_names.empty?
170
+ raise ArgumentError, 'queue_names cannot exceed 50 queues' if queue_names.size > 50
171
+
172
+ # Validate all queue names
173
+ queue_names.each { |qn| validate_queue_name!(qn) }
174
+
175
+ # Build UNION ALL query for all queues
176
+ union_queries = queue_names.map do |queue_name|
177
+ escaped_name = queue_name.gsub("'", "''")
178
+ "SELECT '#{escaped_name}'::text as queue_name, * FROM pgmq.pop('#{escaped_name}'::text)"
179
+ end
180
+
181
+ sql = "#{union_queries.join("\nUNION ALL\n")}\nLIMIT 1"
182
+
183
+ result = with_connection do |conn|
184
+ conn.exec(sql)
185
+ end
186
+
187
+ return nil if result.ntuples.zero?
188
+
189
+ Message.new(result[0])
190
+ end
191
+ end
192
+ end
193
+ end