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.
- checksums.yaml +4 -4
- data/.coditsu/ci.yml +3 -0
- data/.github/workflows/ci.yml +161 -0
- data/.github/workflows/push.yml +35 -0
- data/.gitignore +67 -0
- data/.rspec +1 -0
- data/.ruby-version +1 -0
- data/.yard-lint.yml +168 -0
- data/CHANGELOG.md +103 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +65 -0
- data/LICENSE +165 -0
- data/README.md +627 -0
- data/Rakefile +4 -0
- data/docker-compose.yml +22 -0
- data/lib/pgmq/client/consumer.rb +155 -0
- data/lib/pgmq/client/maintenance.rb +46 -0
- data/lib/pgmq/client/message_lifecycle.rb +240 -0
- data/lib/pgmq/client/metrics.rb +49 -0
- data/lib/pgmq/client/multi_queue.rb +193 -0
- data/lib/pgmq/client/producer.rb +80 -0
- data/lib/pgmq/client/queue_management.rb +112 -0
- data/lib/pgmq/client.rb +138 -0
- data/lib/pgmq/connection.rb +196 -0
- data/lib/pgmq/errors.rb +30 -0
- data/lib/pgmq/message.rb +45 -0
- data/lib/pgmq/metrics.rb +37 -0
- data/lib/pgmq/queue_metadata.rb +37 -0
- data/lib/pgmq/transaction.rb +105 -0
- data/lib/pgmq/version.rb +6 -0
- data/lib/pgmq.rb +53 -0
- data/pgmq-ruby.gemspec +32 -0
- data/renovate.json +18 -0
- metadata +66 -4
|
@@ -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
|