pgmq-ruby 0.1.0 → 0.4.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,94 @@
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
+
45
+ # Enables PostgreSQL NOTIFY when messages are inserted into a queue
46
+ #
47
+ # When enabled, PostgreSQL will send a NOTIFY event on message insert,
48
+ # allowing clients to use LISTEN instead of polling. The throttle interval
49
+ # prevents notification storms during high-volume inserts.
50
+ #
51
+ # @param queue_name [String] name of the queue
52
+ # @param throttle_interval_ms [Integer] minimum ms between notifications (default: 250)
53
+ # @return [void]
54
+ #
55
+ # @example Enable with default throttle (250ms)
56
+ # client.enable_notify_insert("orders")
57
+ #
58
+ # @example Enable with custom throttle (1 second)
59
+ # client.enable_notify_insert("orders", throttle_interval_ms: 1000)
60
+ #
61
+ # @example Disable throttling (notify on every insert)
62
+ # client.enable_notify_insert("orders", throttle_interval_ms: 0)
63
+ def enable_notify_insert(queue_name, throttle_interval_ms: 250)
64
+ validate_queue_name!(queue_name)
65
+
66
+ with_connection do |conn|
67
+ conn.exec_params(
68
+ 'SELECT pgmq.enable_notify_insert($1::text, $2::integer)',
69
+ [queue_name, throttle_interval_ms]
70
+ )
71
+ end
72
+
73
+ nil
74
+ end
75
+
76
+ # Disables PostgreSQL NOTIFY for a queue
77
+ #
78
+ # @param queue_name [String] name of the queue
79
+ # @return [void]
80
+ #
81
+ # @example
82
+ # client.disable_notify_insert("orders")
83
+ def disable_notify_insert(queue_name)
84
+ validate_queue_name!(queue_name)
85
+
86
+ with_connection do |conn|
87
+ conn.exec_params('SELECT pgmq.disable_notify_insert($1::text)', [queue_name])
88
+ end
89
+
90
+ nil
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,332 @@
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
+ # Pops multiple messages atomically (atomic read + delete for batch)
31
+ #
32
+ # @param queue_name [String] name of the queue
33
+ # @param qty [Integer] maximum number of messages to pop
34
+ # @return [Array<PGMQ::Message>] array of message objects (empty if queue is empty)
35
+ #
36
+ # @example Pop up to 10 messages
37
+ # messages = client.pop_batch("orders", 10)
38
+ # messages.each { |msg| process(msg.payload) }
39
+ def pop_batch(queue_name, qty)
40
+ validate_queue_name!(queue_name)
41
+ return [] if qty <= 0
42
+
43
+ result = with_connection do |conn|
44
+ conn.exec_params('SELECT * FROM pgmq.pop($1::text, $2::integer)', [queue_name, qty])
45
+ end
46
+
47
+ result.map { |row| Message.new(row) }
48
+ end
49
+
50
+ # Deletes a message from the queue
51
+ #
52
+ # @param queue_name [String] name of the queue
53
+ # @param msg_id [Integer] message ID to delete
54
+ # @return [Boolean] true if message was deleted
55
+ #
56
+ # @example
57
+ # client.delete("orders", 123)
58
+ def delete(
59
+ queue_name,
60
+ msg_id
61
+ )
62
+ validate_queue_name!(queue_name)
63
+
64
+ result = with_connection do |conn|
65
+ conn.exec_params(
66
+ 'SELECT pgmq.delete($1::text, $2::bigint)',
67
+ [queue_name, msg_id]
68
+ )
69
+ end
70
+
71
+ return false if result.ntuples.zero?
72
+
73
+ result[0]['delete'] == 't'
74
+ end
75
+
76
+ # Deletes multiple messages from the queue
77
+ #
78
+ # @param queue_name [String] name of the queue
79
+ # @param msg_ids [Array<Integer>] array of message IDs to delete
80
+ # @return [Array<Integer>] array of successfully deleted message IDs
81
+ #
82
+ # @example
83
+ # deleted = client.delete_batch("orders", [101, 102, 103])
84
+ def delete_batch(
85
+ queue_name,
86
+ msg_ids
87
+ )
88
+ validate_queue_name!(queue_name)
89
+ return [] if msg_ids.empty?
90
+
91
+ # Use PostgreSQL array parameter binding
92
+ result = with_connection do |conn|
93
+ encoder = PG::TextEncoder::Array.new
94
+ encoded_array = encoder.encode(msg_ids)
95
+
96
+ conn.exec_params(
97
+ 'SELECT * FROM pgmq.delete($1::text, $2::bigint[])',
98
+ [queue_name, encoded_array]
99
+ )
100
+ end
101
+
102
+ result.map { |row| row['delete'] }
103
+ end
104
+
105
+ # Deletes specific messages from multiple queues in a single transaction
106
+ #
107
+ # Efficiently deletes messages across different queues atomically.
108
+ # Useful when processing related messages from different queues.
109
+ #
110
+ # @param deletions [Hash] hash of queue_name => array of msg_ids
111
+ # @return [Hash] hash of queue_name => array of deleted msg_ids
112
+ #
113
+ # @example Delete messages from multiple queues
114
+ # client.delete_multi({
115
+ # 'orders' => [1, 2, 3],
116
+ # 'notifications' => [5, 6],
117
+ # 'emails' => [10]
118
+ # })
119
+ # # => { 'orders' => [1, 2, 3], 'notifications' => [5, 6], 'emails' => [10] }
120
+ #
121
+ # @example Clean up after batch processing across queues
122
+ # messages = client.read_multi(['q1', 'q2', 'q3'], qty: 10)
123
+ # deletions = messages.group_by(&:queue_name).transform_values { |mss| mss.map(&:msg_id) }
124
+ # client.delete_multi(deletions)
125
+ def delete_multi(deletions)
126
+ raise ArgumentError, 'deletions must be a hash' unless deletions.is_a?(Hash)
127
+ return {} if deletions.empty?
128
+
129
+ # Validate all queue names
130
+ deletions.each_key { |qn| validate_queue_name!(qn) }
131
+
132
+ transaction do |txn|
133
+ result = {}
134
+ deletions.each do |queue_name, msg_ids|
135
+ next if msg_ids.empty?
136
+
137
+ deleted_ids = txn.delete_batch(queue_name, msg_ids)
138
+ result[queue_name] = deleted_ids
139
+ end
140
+ result
141
+ end
142
+ end
143
+
144
+ # Archives a message
145
+ #
146
+ # @param queue_name [String] name of the queue
147
+ # @param msg_id [Integer] message ID to archive
148
+ # @return [Boolean] true if message was archived
149
+ #
150
+ # @example
151
+ # client.archive("orders", 123)
152
+ def archive(
153
+ queue_name,
154
+ msg_id
155
+ )
156
+ validate_queue_name!(queue_name)
157
+
158
+ result = with_connection do |conn|
159
+ conn.exec_params(
160
+ 'SELECT pgmq.archive($1::text, $2::bigint)',
161
+ [queue_name, msg_id]
162
+ )
163
+ end
164
+
165
+ return false if result.ntuples.zero?
166
+
167
+ result[0]['archive'] == 't'
168
+ end
169
+
170
+ # Archives multiple messages
171
+ #
172
+ # @param queue_name [String] name of the queue
173
+ # @param msg_ids [Array<Integer>] array of message IDs to archive
174
+ # @return [Array<Integer>] array of successfully archived message IDs
175
+ #
176
+ # @example
177
+ # archived = client.archive_batch("orders", [101, 102, 103])
178
+ def archive_batch(
179
+ queue_name,
180
+ msg_ids
181
+ )
182
+ validate_queue_name!(queue_name)
183
+ return [] if msg_ids.empty?
184
+
185
+ # Use PostgreSQL array parameter binding
186
+ result = with_connection do |conn|
187
+ encoder = PG::TextEncoder::Array.new
188
+ encoded_array = encoder.encode(msg_ids)
189
+
190
+ conn.exec_params(
191
+ 'SELECT * FROM pgmq.archive($1::text, $2::bigint[])',
192
+ [queue_name, encoded_array]
193
+ )
194
+ end
195
+
196
+ result.map { |row| row['archive'] }
197
+ end
198
+
199
+ # Archives specific messages from multiple queues in a single transaction
200
+ #
201
+ # Efficiently archives messages across different queues atomically.
202
+ #
203
+ # @param archives [Hash] hash of queue_name => array of msg_ids
204
+ # @return [Hash] hash of queue_name => array of archived msg_ids
205
+ #
206
+ # @example Archive messages from multiple queues
207
+ # client.archive_multi({
208
+ # 'orders' => [1, 2],
209
+ # 'notifications' => [5]
210
+ # })
211
+ def archive_multi(archives)
212
+ raise ArgumentError, 'archives must be a hash' unless archives.is_a?(Hash)
213
+ return {} if archives.empty?
214
+
215
+ # Validate all queue names
216
+ archives.each_key { |qn| validate_queue_name!(qn) }
217
+
218
+ transaction do |txn|
219
+ result = {}
220
+ archives.each do |queue_name, msg_ids|
221
+ next if msg_ids.empty?
222
+
223
+ archived_ids = txn.archive_batch(queue_name, msg_ids)
224
+ result[queue_name] = archived_ids
225
+ end
226
+ result
227
+ end
228
+ end
229
+
230
+ # Updates the visibility timeout for a message
231
+ #
232
+ # @param queue_name [String] name of the queue
233
+ # @param msg_id [Integer] message ID
234
+ # @param vt_offset [Integer] visibility timeout offset in seconds
235
+ # @return [PGMQ::Message, nil] updated message or nil if not found
236
+ #
237
+ # @example
238
+ # # Extend processing time by 60 more seconds
239
+ # msg = client.set_vt("orders", 123, vt_offset: 60)
240
+ def set_vt(
241
+ queue_name,
242
+ msg_id,
243
+ vt_offset:
244
+ )
245
+ validate_queue_name!(queue_name)
246
+
247
+ result = with_connection do |conn|
248
+ conn.exec_params(
249
+ 'SELECT * FROM pgmq.set_vt($1::text, $2::bigint, $3::integer)',
250
+ [queue_name, msg_id, vt_offset]
251
+ )
252
+ end
253
+
254
+ return nil if result.ntuples.zero?
255
+
256
+ Message.new(result[0])
257
+ end
258
+
259
+ # Updates visibility timeout for multiple messages
260
+ #
261
+ # @param queue_name [String] name of the queue
262
+ # @param msg_ids [Array<Integer>] array of message IDs
263
+ # @param vt_offset [Integer] visibility timeout offset in seconds
264
+ # @return [Array<PGMQ::Message>] array of updated messages
265
+ #
266
+ # @example
267
+ # # Extend processing time for multiple messages
268
+ # messages = client.set_vt_batch("orders", [101, 102, 103], vt_offset: 60)
269
+ def set_vt_batch(
270
+ queue_name,
271
+ msg_ids,
272
+ vt_offset:
273
+ )
274
+ validate_queue_name!(queue_name)
275
+ return [] if msg_ids.empty?
276
+
277
+ result = with_connection do |conn|
278
+ encoder = PG::TextEncoder::Array.new
279
+ encoded_array = encoder.encode(msg_ids)
280
+
281
+ conn.exec_params(
282
+ 'SELECT * FROM pgmq.set_vt($1::text, $2::bigint[], $3::integer)',
283
+ [queue_name, encoded_array, vt_offset]
284
+ )
285
+ end
286
+
287
+ result.map { |row| Message.new(row) }
288
+ end
289
+
290
+ # Updates visibility timeout for messages across multiple queues in a single transaction
291
+ #
292
+ # Efficiently updates visibility timeouts across different queues atomically.
293
+ # Useful when processing related messages from different queues and needing
294
+ # to extend their visibility timeouts together.
295
+ #
296
+ # @param updates [Hash] hash of queue_name => array of msg_ids
297
+ # @param vt_offset [Integer] visibility timeout offset in seconds (applied to all)
298
+ # @return [Hash] hash of queue_name => array of updated PGMQ::Message objects
299
+ #
300
+ # @example Extend visibility timeout for messages from multiple queues
301
+ # client.set_vt_multi({
302
+ # 'orders' => [1, 2, 3],
303
+ # 'notifications' => [5, 6],
304
+ # 'emails' => [10]
305
+ # }, vt_offset: 60)
306
+ # # => { 'orders' => [<Message>, ...], 'notifications' => [...], 'emails' => [...] }
307
+ #
308
+ # @example Extend timeout after batch reading from multiple queues
309
+ # messages = client.read_multi(['q1', 'q2', 'q3'], qty: 10)
310
+ # updates = messages.group_by(&:queue_name).transform_values { |msgs| msgs.map(&:msg_id) }
311
+ # client.set_vt_multi(updates, vt_offset: 120)
312
+ def set_vt_multi(updates, vt_offset:)
313
+ raise ArgumentError, 'updates must be a hash' unless updates.is_a?(Hash)
314
+ return {} if updates.empty?
315
+
316
+ # Validate all queue names
317
+ updates.each_key { |qn| validate_queue_name!(qn) }
318
+
319
+ transaction do |txn|
320
+ result = {}
321
+ updates.each do |queue_name, msg_ids|
322
+ next if msg_ids.empty?
323
+
324
+ updated_messages = txn.set_vt_batch(queue_name, msg_ids, vt_offset: vt_offset)
325
+ result[queue_name] = updated_messages
326
+ end
327
+ result
328
+ end
329
+ end
330
+ end
331
+ end
332
+ 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