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.
- checksums.yaml +4 -4
- data/.coditsu/ci.yml +3 -0
- data/.github/workflows/ci.yml +163 -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 +275 -0
- data/CHANGELOG.md +125 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +65 -0
- data/LICENSE +165 -0
- data/README.md +687 -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 +94 -0
- data/lib/pgmq/client/message_lifecycle.rb +332 -0
- data/lib/pgmq/client/metrics.rb +49 -0
- data/lib/pgmq/client/multi_queue.rb +193 -0
- data/lib/pgmq/client/producer.rb +125 -0
- data/lib/pgmq/client/queue_management.rb +128 -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 +92 -0
- data/lib/pgmq/version.rb +6 -0
- data/lib/pgmq.rb +53 -0
- data/pgmq-ruby.gemspec +32 -0
- data/renovate.json +11 -0
- metadata +67 -5
|
@@ -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
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGMQ
|
|
4
|
+
class Client
|
|
5
|
+
# Message producing operations
|
|
6
|
+
#
|
|
7
|
+
# This module handles producing messages to queues, both individual messages
|
|
8
|
+
# and batches. Users must serialize messages to JSON strings themselves.
|
|
9
|
+
module Producer
|
|
10
|
+
# Produces a message to a queue
|
|
11
|
+
#
|
|
12
|
+
# @param queue_name [String] name of the queue
|
|
13
|
+
# @param message [String] message as JSON string (for PostgreSQL JSONB)
|
|
14
|
+
# @param headers [String, nil] optional headers as JSON string (for metadata, routing, tracing)
|
|
15
|
+
# @param delay [Integer] delay in seconds before message becomes visible
|
|
16
|
+
# @return [String] message ID as string
|
|
17
|
+
#
|
|
18
|
+
# @example Basic produce
|
|
19
|
+
# msg_id = client.produce("orders", '{"order_id":123,"total":99.99}')
|
|
20
|
+
#
|
|
21
|
+
# @example With delay
|
|
22
|
+
# msg_id = client.produce("orders", '{"data":"value"}', delay: 60)
|
|
23
|
+
#
|
|
24
|
+
# @example With headers for routing/tracing
|
|
25
|
+
# msg_id = client.produce("orders", '{"order_id":123}',
|
|
26
|
+
# headers: '{"trace_id":"abc123","priority":"high"}')
|
|
27
|
+
#
|
|
28
|
+
# @example With headers and delay
|
|
29
|
+
# msg_id = client.produce("orders", '{"order_id":123}',
|
|
30
|
+
# headers: '{"correlation_id":"req-456"}',
|
|
31
|
+
# delay: 30)
|
|
32
|
+
#
|
|
33
|
+
# @note Users must serialize to JSON themselves. Higher-level frameworks
|
|
34
|
+
# should handle serialization.
|
|
35
|
+
def produce(
|
|
36
|
+
queue_name,
|
|
37
|
+
message,
|
|
38
|
+
headers: nil,
|
|
39
|
+
delay: 0
|
|
40
|
+
)
|
|
41
|
+
validate_queue_name!(queue_name)
|
|
42
|
+
|
|
43
|
+
result = with_connection do |conn|
|
|
44
|
+
if headers
|
|
45
|
+
conn.exec_params(
|
|
46
|
+
'SELECT * FROM pgmq.send($1::text, $2::jsonb, $3::jsonb, $4::integer)',
|
|
47
|
+
[queue_name, message, headers, delay]
|
|
48
|
+
)
|
|
49
|
+
else
|
|
50
|
+
conn.exec_params(
|
|
51
|
+
'SELECT * FROM pgmq.send($1::text, $2::jsonb, $3::integer)',
|
|
52
|
+
[queue_name, message, delay]
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
result[0]['send']
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Produces multiple messages to a queue in a batch
|
|
61
|
+
#
|
|
62
|
+
# @param queue_name [String] name of the queue
|
|
63
|
+
# @param messages [Array<String>] array of message payloads as JSON strings
|
|
64
|
+
# @param headers [Array<String>, nil] optional array of headers as JSON strings (must match messages length)
|
|
65
|
+
# @param delay [Integer] delay in seconds before messages become visible
|
|
66
|
+
# @return [Array<String>] array of message IDs
|
|
67
|
+
# @raise [ArgumentError] if headers array length doesn't match messages length
|
|
68
|
+
#
|
|
69
|
+
# @example Basic batch produce
|
|
70
|
+
# ids = client.produce_batch("orders", [
|
|
71
|
+
# '{"order_id":1}',
|
|
72
|
+
# '{"order_id":2}',
|
|
73
|
+
# '{"order_id":3}'
|
|
74
|
+
# ])
|
|
75
|
+
#
|
|
76
|
+
# @example With headers (one per message)
|
|
77
|
+
# ids = client.produce_batch("orders",
|
|
78
|
+
# ['{"order_id":1}', '{"order_id":2}'],
|
|
79
|
+
# headers: ['{"priority":"high"}', '{"priority":"low"}'])
|
|
80
|
+
#
|
|
81
|
+
# @example With headers and delay
|
|
82
|
+
# ids = client.produce_batch("orders",
|
|
83
|
+
# ['{"order_id":1}', '{"order_id":2}'],
|
|
84
|
+
# headers: ['{"trace_id":"a"}', '{"trace_id":"b"}'],
|
|
85
|
+
# delay: 60)
|
|
86
|
+
def produce_batch(
|
|
87
|
+
queue_name,
|
|
88
|
+
messages,
|
|
89
|
+
headers: nil,
|
|
90
|
+
delay: 0
|
|
91
|
+
)
|
|
92
|
+
validate_queue_name!(queue_name)
|
|
93
|
+
return [] if messages.empty?
|
|
94
|
+
|
|
95
|
+
if headers && headers.length != messages.length
|
|
96
|
+
raise ArgumentError,
|
|
97
|
+
"headers array length (#{headers.length}) must match messages array length (#{messages.length})"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Use PostgreSQL array parameter binding for security
|
|
101
|
+
# PG gem will properly encode the array values
|
|
102
|
+
result = with_connection do |conn|
|
|
103
|
+
# Create array encoder for proper PostgreSQL array formatting
|
|
104
|
+
encoder = PG::TextEncoder::Array.new
|
|
105
|
+
encoded_messages = encoder.encode(messages)
|
|
106
|
+
|
|
107
|
+
if headers
|
|
108
|
+
encoded_headers = encoder.encode(headers)
|
|
109
|
+
conn.exec_params(
|
|
110
|
+
'SELECT * FROM pgmq.send_batch($1::text, $2::jsonb[], $3::jsonb[], $4::integer)',
|
|
111
|
+
[queue_name, encoded_messages, encoded_headers, delay]
|
|
112
|
+
)
|
|
113
|
+
else
|
|
114
|
+
conn.exec_params(
|
|
115
|
+
'SELECT * FROM pgmq.send_batch($1::text, $2::jsonb[], $3::integer)',
|
|
116
|
+
[queue_name, encoded_messages, delay]
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
result.map { |row| row['send_batch'] }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGMQ
|
|
4
|
+
class Client
|
|
5
|
+
# Queue management operations (create, drop, list)
|
|
6
|
+
#
|
|
7
|
+
# This module handles all queue lifecycle operations including creating queues
|
|
8
|
+
# (standard, partitioned, unlogged), dropping queues, and listing existing queues.
|
|
9
|
+
module QueueManagement
|
|
10
|
+
# Creates a new queue
|
|
11
|
+
#
|
|
12
|
+
# @param queue_name [String] name of the queue to create
|
|
13
|
+
# @return [Boolean] true if queue was created, false if it already existed
|
|
14
|
+
# @raise [PGMQ::Errors::InvalidQueueNameError] if queue name is invalid
|
|
15
|
+
# @raise [PGMQ::Errors::ConnectionError] if database operation fails
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# client.create("orders") # => true (created)
|
|
19
|
+
# client.create("orders") # => false (already exists)
|
|
20
|
+
def create(queue_name)
|
|
21
|
+
validate_queue_name!(queue_name)
|
|
22
|
+
|
|
23
|
+
with_connection do |conn|
|
|
24
|
+
existed = queue_exists?(conn, queue_name)
|
|
25
|
+
conn.exec_params('SELECT pgmq.create($1::text)', [queue_name])
|
|
26
|
+
!existed
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Creates a partitioned queue
|
|
31
|
+
#
|
|
32
|
+
# Requires pg_partman extension to be installed
|
|
33
|
+
#
|
|
34
|
+
# @param queue_name [String] name of the queue
|
|
35
|
+
# @param partition_interval [String] partition interval (e.g., "daily", "10000")
|
|
36
|
+
# @param retention_interval [String] retention interval (e.g., "7 days", "100000")
|
|
37
|
+
# @return [Boolean] true if queue was created, false if it already existed
|
|
38
|
+
#
|
|
39
|
+
# @example
|
|
40
|
+
# client.create_partitioned("big_queue",
|
|
41
|
+
# partition_interval: "daily",
|
|
42
|
+
# retention_interval: "7 days"
|
|
43
|
+
# ) # => true
|
|
44
|
+
def create_partitioned(
|
|
45
|
+
queue_name,
|
|
46
|
+
partition_interval: '10000',
|
|
47
|
+
retention_interval: '100000'
|
|
48
|
+
)
|
|
49
|
+
validate_queue_name!(queue_name)
|
|
50
|
+
|
|
51
|
+
with_connection do |conn|
|
|
52
|
+
existed = queue_exists?(conn, queue_name)
|
|
53
|
+
conn.exec_params(
|
|
54
|
+
'SELECT pgmq.create_partitioned($1::text, $2::text, $3::text)',
|
|
55
|
+
[queue_name, partition_interval, retention_interval]
|
|
56
|
+
)
|
|
57
|
+
!existed
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Creates an unlogged queue for higher throughput (no crash recovery)
|
|
62
|
+
#
|
|
63
|
+
# @param queue_name [String] name of the queue
|
|
64
|
+
# @return [Boolean] true if queue was created, false if it already existed
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# client.create_unlogged("fast_queue") # => true
|
|
68
|
+
def create_unlogged(queue_name)
|
|
69
|
+
validate_queue_name!(queue_name)
|
|
70
|
+
|
|
71
|
+
with_connection do |conn|
|
|
72
|
+
existed = queue_exists?(conn, queue_name)
|
|
73
|
+
conn.exec_params('SELECT pgmq.create_unlogged($1::text)', [queue_name])
|
|
74
|
+
!existed
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Drops a queue and its archive table
|
|
79
|
+
#
|
|
80
|
+
# @param queue_name [String] name of the queue to drop
|
|
81
|
+
# @return [Boolean] true if queue was dropped
|
|
82
|
+
#
|
|
83
|
+
# @example
|
|
84
|
+
# client.drop_queue("old_queue")
|
|
85
|
+
def drop_queue(queue_name)
|
|
86
|
+
validate_queue_name!(queue_name)
|
|
87
|
+
|
|
88
|
+
result = with_connection do |conn|
|
|
89
|
+
conn.exec_params('SELECT pgmq.drop_queue($1::text)', [queue_name])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
return false if result.ntuples.zero?
|
|
93
|
+
|
|
94
|
+
result[0]['drop_queue'] == 't'
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Lists all queues
|
|
98
|
+
#
|
|
99
|
+
# @return [Array<PGMQ::QueueMetadata>] array of queue metadata objects
|
|
100
|
+
#
|
|
101
|
+
# @example
|
|
102
|
+
# queues = client.list_queues
|
|
103
|
+
# queues.each { |q| puts q.queue_name }
|
|
104
|
+
def list_queues
|
|
105
|
+
result = with_connection do |conn|
|
|
106
|
+
conn.exec('SELECT * FROM pgmq.list_queues()')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
result.map { |row| QueueMetadata.new(row) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Checks if a queue exists in the pgmq.meta table
|
|
115
|
+
#
|
|
116
|
+
# @param conn [PG::Connection] database connection
|
|
117
|
+
# @param queue_name [String] name of the queue to check
|
|
118
|
+
# @return [Boolean] true if queue exists, false otherwise
|
|
119
|
+
def queue_exists?(conn, queue_name)
|
|
120
|
+
result = conn.exec_params(
|
|
121
|
+
'SELECT 1 FROM pgmq.meta WHERE queue_name = $1 LIMIT 1',
|
|
122
|
+
[queue_name]
|
|
123
|
+
)
|
|
124
|
+
result.ntuples.positive?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/pgmq/client.rb
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGMQ
|
|
4
|
+
# Low-level client for interacting with PGMQ (Postgres Message Queue)
|
|
5
|
+
#
|
|
6
|
+
# This is a thin wrapper around PGMQ SQL functions. For higher-level
|
|
7
|
+
# abstractions (job processing, retries, Rails integration), use pgmq-framework.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# client = PGMQ::Client.new(
|
|
11
|
+
# host: 'localhost',
|
|
12
|
+
# dbname: 'mydb',
|
|
13
|
+
# user: 'postgres',
|
|
14
|
+
# password: 'postgres'
|
|
15
|
+
# )
|
|
16
|
+
# client.create('my_queue')
|
|
17
|
+
# msg_id = client.produce('my_queue', '{"data":"value"}')
|
|
18
|
+
# msg = client.read('my_queue', vt: 30)
|
|
19
|
+
# client.delete('my_queue', msg.msg_id)
|
|
20
|
+
#
|
|
21
|
+
# @example With Rails/ActiveRecord (reuses Rails connection pool)
|
|
22
|
+
# client = PGMQ::Client.new(-> { ActiveRecord::Base.connection.raw_connection })
|
|
23
|
+
#
|
|
24
|
+
# @example With connection string
|
|
25
|
+
# client = PGMQ::Client.new('postgres://localhost/mydb')
|
|
26
|
+
class Client
|
|
27
|
+
# Include functional modules (order matters for discoverability)
|
|
28
|
+
include Transaction # Transaction support (already existed)
|
|
29
|
+
include QueueManagement # Queue lifecycle (create, drop, list)
|
|
30
|
+
include Producer # Message producing operations
|
|
31
|
+
include Consumer # Single-queue reading operations
|
|
32
|
+
include MultiQueue # Multi-queue operations
|
|
33
|
+
include MessageLifecycle # Message state transitions (pop, delete, archive)
|
|
34
|
+
include Maintenance # Queue maintenance (purge, detach_archive)
|
|
35
|
+
include Metrics # Monitoring and metrics
|
|
36
|
+
|
|
37
|
+
# Default visibility timeout in seconds
|
|
38
|
+
DEFAULT_VT = 30
|
|
39
|
+
|
|
40
|
+
# @return [PGMQ::Connection] the connection manager
|
|
41
|
+
attr_reader :connection
|
|
42
|
+
|
|
43
|
+
# Creates a new PGMQ client
|
|
44
|
+
#
|
|
45
|
+
# @param conn_params [String, Hash, Proc, PGMQ::Connection, nil] connection parameters
|
|
46
|
+
# @param pool_size [Integer] connection pool size
|
|
47
|
+
# @param pool_timeout [Integer] connection pool timeout in seconds
|
|
48
|
+
# @param auto_reconnect [Boolean] automatically reconnect on connection errors (default: true)
|
|
49
|
+
#
|
|
50
|
+
# @example Connection string
|
|
51
|
+
# client = PGMQ::Client.new('postgres://user:pass@localhost/db')
|
|
52
|
+
#
|
|
53
|
+
# @example Connection hash
|
|
54
|
+
# client = PGMQ::Client.new(host: 'localhost', dbname: 'mydb', user: 'postgres')
|
|
55
|
+
#
|
|
56
|
+
# @example Inject existing connection (for Rails)
|
|
57
|
+
# client = PGMQ::Client.new(-> { ActiveRecord::Base.connection.raw_connection })
|
|
58
|
+
#
|
|
59
|
+
# @example Disable auto-reconnect
|
|
60
|
+
# client = PGMQ::Client.new(auto_reconnect: false)
|
|
61
|
+
def initialize(
|
|
62
|
+
conn_params = nil,
|
|
63
|
+
pool_size: Connection::DEFAULT_POOL_SIZE,
|
|
64
|
+
pool_timeout: Connection::DEFAULT_POOL_TIMEOUT,
|
|
65
|
+
auto_reconnect: true
|
|
66
|
+
)
|
|
67
|
+
@connection = if conn_params.is_a?(Connection)
|
|
68
|
+
conn_params
|
|
69
|
+
else
|
|
70
|
+
Connection.new(
|
|
71
|
+
conn_params,
|
|
72
|
+
pool_size: pool_size,
|
|
73
|
+
pool_timeout: pool_timeout,
|
|
74
|
+
auto_reconnect: auto_reconnect
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Closes all connections in the pool
|
|
80
|
+
# @return [void]
|
|
81
|
+
def close
|
|
82
|
+
@connection.close
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Returns connection pool statistics
|
|
86
|
+
#
|
|
87
|
+
# @return [Hash] statistics about the connection pool
|
|
88
|
+
# @example
|
|
89
|
+
# stats = client.stats
|
|
90
|
+
# # => { size: 5, available: 3 }
|
|
91
|
+
def stats
|
|
92
|
+
@connection.stats
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Executes a block with a database connection
|
|
98
|
+
# @yield [PG::Connection] database connection
|
|
99
|
+
# @return [Object] result of the block
|
|
100
|
+
def with_connection(&)
|
|
101
|
+
@connection.with_connection(&)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Validates a queue name
|
|
105
|
+
# @param queue_name [String] queue name to validate
|
|
106
|
+
# @return [void]
|
|
107
|
+
# @raise [PGMQ::Errors::InvalidQueueNameError] if name is invalid
|
|
108
|
+
def validate_queue_name!(queue_name)
|
|
109
|
+
if queue_name.nil? || queue_name.to_s.strip.empty?
|
|
110
|
+
raise(
|
|
111
|
+
Errors::InvalidQueueNameError,
|
|
112
|
+
'Queue name cannot be empty'
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# PGMQ creates tables with prefixes (pgmq.q_<name>, pgmq.a_<name>)
|
|
117
|
+
# PostgreSQL has a 63-character limit for identifiers, but PGMQ enforces 48
|
|
118
|
+
# to account for prefixes and potential suffixes
|
|
119
|
+
if queue_name.to_s.length >= 48
|
|
120
|
+
raise(
|
|
121
|
+
Errors::InvalidQueueNameError,
|
|
122
|
+
"Queue name '#{queue_name}' exceeds maximum length of 48 characters " \
|
|
123
|
+
"(current length: #{queue_name.to_s.length})"
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# PostgreSQL identifier rules: start with letter or underscore,
|
|
128
|
+
# contain only letters, digits, underscores
|
|
129
|
+
return if queue_name.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
|
|
130
|
+
|
|
131
|
+
raise(
|
|
132
|
+
Errors::InvalidQueueNameError,
|
|
133
|
+
"Invalid queue name '#{queue_name}': must start with a letter or underscore " \
|
|
134
|
+
'and contain only letters, digits, and underscores'
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|