pgmq-ruby 0.4.0 → 0.5.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,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ class Client
5
+ # Topic routing operations (AMQP-like patterns)
6
+ #
7
+ # This module provides AMQP-style topic routing for PGMQ, allowing messages
8
+ # to be routed to multiple queues based on pattern matching.
9
+ #
10
+ # Topic patterns support wildcards:
11
+ # - `*` matches exactly one word between dots (e.g., `orders.*` matches `orders.new`)
12
+ # - `#` matches zero or more words (e.g., `orders.#` matches `orders.new.urgent`)
13
+ #
14
+ # @note Requires PGMQ v1.11.0+
15
+ module Topics
16
+ # Binds a topic pattern to a queue
17
+ #
18
+ # Messages sent with routing keys matching this pattern will be delivered
19
+ # to the specified queue.
20
+ #
21
+ # @param pattern [String] topic pattern with optional wildcards (* or #)
22
+ # @param queue_name [String] name of the queue to bind
23
+ # @return [void]
24
+ #
25
+ # @example Bind exact routing key
26
+ # client.bind_topic("orders.new", "new_orders")
27
+ #
28
+ # @example Bind with single-word wildcard
29
+ # client.bind_topic("orders.*", "all_order_events")
30
+ #
31
+ # @example Bind with multi-word wildcard
32
+ # client.bind_topic("orders.#", "order_audit_log")
33
+ def bind_topic(pattern, queue_name)
34
+ validate_queue_name!(queue_name)
35
+
36
+ with_connection do |conn|
37
+ conn.exec_params(
38
+ "SELECT pgmq.bind_topic($1::text, $2::text)",
39
+ [pattern, queue_name]
40
+ )
41
+ end
42
+
43
+ nil
44
+ end
45
+
46
+ # Unbinds a topic pattern from a queue
47
+ #
48
+ # @param pattern [String] topic pattern to unbind
49
+ # @param queue_name [String] name of the queue to unbind from
50
+ # @return [Boolean] true if the binding was removed, false if it didn't exist
51
+ #
52
+ # @example
53
+ # client.unbind_topic("orders.new", "new_orders")
54
+ def unbind_topic(pattern, queue_name)
55
+ validate_queue_name!(queue_name)
56
+
57
+ result = with_connection do |conn|
58
+ conn.exec_params(
59
+ "SELECT pgmq.unbind_topic($1::text, $2::text)",
60
+ [pattern, queue_name]
61
+ )
62
+ end
63
+
64
+ result[0]["unbind_topic"] == "t"
65
+ end
66
+
67
+ # Sends a message via topic routing
68
+ #
69
+ # The message will be delivered to all queues whose bound patterns match
70
+ # the routing key.
71
+ #
72
+ # @param routing_key [String] dot-separated routing key (e.g., "orders.new.priority")
73
+ # @param message [String] message as JSON string
74
+ # @param headers [String, nil] optional headers as JSON string
75
+ # @param delay [Integer] delay in seconds before message becomes visible
76
+ # @return [Integer] count of queues the message was delivered to
77
+ #
78
+ # @example Basic topic send
79
+ # count = client.produce_topic("orders.new", '{"order_id":123}')
80
+ #
81
+ # @example With headers and delay
82
+ # count = client.produce_topic("orders.new.priority",
83
+ # '{"order_id":123}',
84
+ # headers: '{"trace_id":"abc"}',
85
+ # delay: 30)
86
+ def produce_topic(routing_key, message, headers: nil, delay: 0)
87
+ result = with_connection do |conn|
88
+ if headers
89
+ conn.exec_params(
90
+ "SELECT pgmq.send_topic($1::text, $2::jsonb, $3::jsonb, $4::integer)",
91
+ [routing_key, message, headers, delay]
92
+ )
93
+ elsif delay > 0
94
+ conn.exec_params(
95
+ "SELECT pgmq.send_topic($1::text, $2::jsonb, $3::integer)",
96
+ [routing_key, message, delay]
97
+ )
98
+ else
99
+ conn.exec_params(
100
+ "SELECT pgmq.send_topic($1::text, $2::jsonb)",
101
+ [routing_key, message]
102
+ )
103
+ end
104
+ end
105
+
106
+ result[0]["send_topic"].to_i
107
+ end
108
+
109
+ # Sends multiple messages via topic routing
110
+ #
111
+ # All messages will be delivered to all queues whose bound patterns match
112
+ # the routing key.
113
+ #
114
+ # @param routing_key [String] dot-separated routing key
115
+ # @param messages [Array<String>] array of message payloads as JSON strings
116
+ # @param headers [Array<String>, nil] optional array of headers as JSON strings
117
+ # @param delay [Integer] delay in seconds before messages become visible
118
+ # @return [Array<Hash>] array of hashes with :queue_name and :msg_id
119
+ #
120
+ # @example Batch topic send
121
+ # results = client.produce_batch_topic("orders.new", [
122
+ # '{"order_id":1}',
123
+ # '{"order_id":2}'
124
+ # ])
125
+ # # => [{ queue_name: "new_orders", msg_id: "1" }, ...]
126
+ def produce_batch_topic(routing_key, messages, headers: nil, delay: 0)
127
+ return [] if messages.empty?
128
+
129
+ if headers && headers.length != messages.length
130
+ raise ArgumentError,
131
+ "headers array length (#{headers.length}) must match messages array length (#{messages.length})"
132
+ end
133
+
134
+ result = with_connection do |conn|
135
+ encoder = PG::TextEncoder::Array.new
136
+ encoded_messages = encoder.encode(messages)
137
+
138
+ if headers
139
+ encoded_headers = encoder.encode(headers)
140
+ conn.exec_params(
141
+ "SELECT * FROM pgmq.send_batch_topic($1::text, $2::jsonb[], $3::jsonb[], $4::integer)",
142
+ [routing_key, encoded_messages, encoded_headers, delay]
143
+ )
144
+ elsif delay > 0
145
+ conn.exec_params(
146
+ "SELECT * FROM pgmq.send_batch_topic($1::text, $2::jsonb[], $3::integer)",
147
+ [routing_key, encoded_messages, delay]
148
+ )
149
+ else
150
+ conn.exec_params(
151
+ "SELECT * FROM pgmq.send_batch_topic($1::text, $2::jsonb[])",
152
+ [routing_key, encoded_messages]
153
+ )
154
+ end
155
+ end
156
+
157
+ result.map do |row|
158
+ { queue_name: row["queue_name"], msg_id: row["msg_id"] }
159
+ end
160
+ end
161
+
162
+ # Lists all topic bindings
163
+ #
164
+ # @param queue_name [String, nil] optional queue name to filter by
165
+ # @return [Array<Hash>] array of binding hashes with pattern, queue_name, bound_at
166
+ #
167
+ # @example List all bindings
168
+ # bindings = client.list_topic_bindings
169
+ # # => [{ pattern: "orders.*", queue_name: "orders", bound_at: "..." }, ...]
170
+ #
171
+ # @example List bindings for specific queue
172
+ # bindings = client.list_topic_bindings(queue_name: "orders")
173
+ def list_topic_bindings(queue_name: nil)
174
+ result = with_connection do |conn|
175
+ if queue_name
176
+ validate_queue_name!(queue_name)
177
+ conn.exec_params(
178
+ "SELECT pattern, queue_name, bound_at FROM pgmq.list_topic_bindings($1::text)",
179
+ [queue_name]
180
+ )
181
+ else
182
+ conn.exec("SELECT pattern, queue_name, bound_at FROM pgmq.list_topic_bindings()")
183
+ end
184
+ end
185
+
186
+ result.map do |row|
187
+ {
188
+ pattern: row["pattern"],
189
+ queue_name: row["queue_name"],
190
+ bound_at: row["bound_at"]
191
+ }
192
+ end
193
+ end
194
+
195
+ # Tests which queues a routing key would match
196
+ #
197
+ # Useful for debugging topic routing configurations.
198
+ #
199
+ # @param routing_key [String] routing key to test
200
+ # @return [Array<Hash>] array of matched bindings with pattern and queue_name
201
+ #
202
+ # @example Test routing
203
+ # matches = client.test_routing("orders.new.priority")
204
+ # # => [{ pattern: "orders.*", queue_name: "new_orders" }, ...]
205
+ def test_routing(routing_key)
206
+ result = with_connection do |conn|
207
+ conn.exec_params(
208
+ "SELECT pattern, queue_name FROM pgmq.test_routing($1::text)",
209
+ [routing_key]
210
+ )
211
+ end
212
+
213
+ result.map do |row|
214
+ { pattern: row["pattern"], queue_name: row["queue_name"] }
215
+ end
216
+ end
217
+
218
+ # Validates a routing key
219
+ #
220
+ # Routing keys are dot-separated words (no wildcards allowed).
221
+ # Returns false for invalid routing keys (PGMQ raises an error for invalid keys).
222
+ #
223
+ # @param routing_key [String] routing key to validate
224
+ # @return [Boolean] true if valid, false if invalid
225
+ #
226
+ # @example
227
+ # client.validate_routing_key("orders.new.priority") # => true
228
+ # client.validate_routing_key("orders.*") # => false (wildcards not allowed)
229
+ def validate_routing_key(routing_key)
230
+ result = with_connection do |conn|
231
+ conn.exec_params(
232
+ "SELECT pgmq.validate_routing_key($1::text)",
233
+ [routing_key]
234
+ )
235
+ end
236
+
237
+ result[0]["validate_routing_key"] == "t"
238
+ rescue PGMQ::Errors::ConnectionError => e
239
+ # PGMQ raises an error for invalid routing keys
240
+ return false if e.message.include?("invalid characters")
241
+
242
+ raise
243
+ end
244
+
245
+ # Validates a topic pattern
246
+ #
247
+ # Topic patterns can include wildcards: * (single word) or # (zero or more words).
248
+ #
249
+ # @param pattern [String] topic pattern to validate
250
+ # @return [Boolean] true if valid
251
+ #
252
+ # @example
253
+ # client.validate_topic_pattern("orders.*") # => true
254
+ # client.validate_topic_pattern("orders.#") # => true
255
+ # client.validate_topic_pattern("orders.new") # => true
256
+ def validate_topic_pattern(pattern)
257
+ result = with_connection do |conn|
258
+ conn.exec_params(
259
+ "SELECT pgmq.validate_topic_pattern($1::text)",
260
+ [pattern]
261
+ )
262
+ end
263
+
264
+ result[0]["validate_topic_pattern"] == "t"
265
+ end
266
+ end
267
+ end
268
+ end
data/lib/pgmq/client.rb CHANGED
@@ -31,8 +31,9 @@ module PGMQ
31
31
  include Consumer # Single-queue reading operations
32
32
  include MultiQueue # Multi-queue operations
33
33
  include MessageLifecycle # Message state transitions (pop, delete, archive)
34
- include Maintenance # Queue maintenance (purge, detach_archive)
34
+ include Maintenance # Queue maintenance (purge, notifications)
35
35
  include Metrics # Monitoring and metrics
36
+ include Topics # Topic routing (AMQP-like patterns, PGMQ v1.11.0+)
36
37
 
37
38
  # Default visibility timeout in seconds
38
39
  DEFAULT_VT = 30
@@ -65,15 +66,15 @@ module PGMQ
65
66
  auto_reconnect: true
66
67
  )
67
68
  @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
69
+ conn_params
70
+ else
71
+ Connection.new(
72
+ conn_params,
73
+ pool_size: pool_size,
74
+ pool_timeout: pool_timeout,
75
+ auto_reconnect: auto_reconnect
76
+ )
77
+ end
77
78
  end
78
79
 
79
80
  # Closes all connections in the pool
@@ -109,7 +110,7 @@ module PGMQ
109
110
  if queue_name.nil? || queue_name.to_s.strip.empty?
110
111
  raise(
111
112
  Errors::InvalidQueueNameError,
112
- 'Queue name cannot be empty'
113
+ "Queue name cannot be empty"
113
114
  )
114
115
  end
115
116
 
@@ -131,7 +132,7 @@ module PGMQ
131
132
  raise(
132
133
  Errors::InvalidQueueNameError,
133
134
  "Invalid queue name '#{queue_name}': must start with a letter or underscore " \
134
- 'and contain only letters, digits, and underscores'
135
+ "and contain only letters, digits, and underscores"
135
136
  )
136
137
  end
137
138
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'pg'
4
- require 'connection_pool'
3
+ require "pg"
4
+ require "connection_pool"
5
5
 
6
6
  module PGMQ
7
7
  # Manages database connections for PGMQ
@@ -45,7 +45,7 @@ module PGMQ
45
45
  if conn_params.nil?
46
46
  raise(
47
47
  PGMQ::Errors::ConfigurationError,
48
- 'Connection parameters are required'
48
+ "Connection parameters are required"
49
49
  )
50
50
  end
51
51
 
@@ -113,12 +113,12 @@ module PGMQ
113
113
  def connection_lost_error?(error)
114
114
  # Common connection lost errors
115
115
  lost_connection_messages = [
116
- 'server closed the connection',
117
- 'connection not open',
118
- 'no connection to the server',
119
- 'terminating connection',
120
- 'connection to server was lost',
121
- 'could not receive data from server'
116
+ "server closed the connection",
117
+ "connection not open",
118
+ "no connection to the server",
119
+ "terminating connection",
120
+ "connection to server was lost",
121
+ "could not receive data from server"
122
122
  ]
123
123
 
124
124
  message = error.message.downcase
@@ -145,7 +145,7 @@ module PGMQ
145
145
  return parse_connection_string(params) if params.is_a?(String)
146
146
  return params if params.is_a?(Hash) && !params.empty?
147
147
 
148
- raise PGMQ::Errors::ConfigurationError, 'Invalid connection parameters format'
148
+ raise PGMQ::Errors::ConfigurationError, "Invalid connection parameters format"
149
149
  end
150
150
 
151
151
  # Parses a PostgreSQL connection string
@@ -173,7 +173,7 @@ module PGMQ
173
173
  ConnectionPool.new(size: @pool_size, timeout: @pool_timeout) do
174
174
  create_connection(params)
175
175
  end
176
- rescue StandardError => e
176
+ rescue => e
177
177
  raise PGMQ::Errors::ConnectionError, "Failed to create connection pool: #{e.message}"
178
178
  end
179
179
 
data/lib/pgmq/message.rb CHANGED
@@ -11,12 +11,13 @@ module PGMQ
11
11
  # puts msg.msg_id # => "123" (String from PG)
12
12
  # puts msg.read_ct # => "1" (String from PG)
13
13
  # puts msg.enqueued_at # => "2025-01-15 10:30:00+00" (String from PG)
14
+ # puts msg.last_read_at # => "2025-01-15 10:31:00+00" (String from PG, nil if never read)
14
15
  # puts msg.vt # => "2025-01-15 10:30:30+00" (String from PG)
15
16
  # puts msg.message # => "{\"order_id\":456}" (Raw JSONB string)
16
17
  # puts msg.headers # => "{\"trace_id\":\"abc123\"}" (Raw JSONB string, optional)
17
18
  # puts msg.queue_name # => "my_queue" (only present for multi-queue operations)
18
19
  class Message < Data.define(
19
- :msg_id, :read_ct, :enqueued_at, :vt, :message, :headers, :queue_name
20
+ :msg_id, :read_ct, :enqueued_at, :last_read_at, :vt, :message, :headers, :queue_name
20
21
  )
21
22
  class << self
22
23
  # Creates a new Message from a database row
@@ -27,19 +28,20 @@ module PGMQ
27
28
  # No parsing, no deserialization, no transformation
28
29
  # The pg gem returns JSONB as String by default
29
30
  super(
30
- msg_id: row['msg_id'],
31
- read_ct: row['read_ct'],
32
- enqueued_at: row['enqueued_at'],
33
- vt: row['vt'],
34
- message: row['message'],
35
- headers: row['headers'], # JSONB column for metadata (optional)
36
- queue_name: row['queue_name'] # nil for single-queue operations
31
+ msg_id: row["msg_id"],
32
+ read_ct: row["read_ct"],
33
+ enqueued_at: row["enqueued_at"],
34
+ last_read_at: row["last_read_at"], # nil if message has never been read
35
+ vt: row["vt"],
36
+ message: row["message"],
37
+ headers: row["headers"], # JSONB column for metadata (optional)
38
+ queue_name: row["queue_name"] # nil for single-queue operations
37
39
  )
38
40
  end
39
41
  end
40
42
 
41
43
  # Alias for msg_id (common in messaging systems)
42
44
  # @return [String]
43
- alias id msg_id
45
+ alias_method :id, :msg_id
44
46
  end
45
47
  end
data/lib/pgmq/metrics.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'time'
3
+ require "time"
4
4
 
5
5
  module PGMQ
6
6
  # Represents metrics for a PGMQ queue
@@ -24,12 +24,12 @@ module PGMQ
24
24
  def new(row, **)
25
25
  # Return raw values as-is from PostgreSQL
26
26
  super(
27
- queue_name: row['queue_name'],
28
- queue_length: row['queue_length'],
29
- newest_msg_age_sec: row['newest_msg_age_sec'],
30
- oldest_msg_age_sec: row['oldest_msg_age_sec'],
31
- total_messages: row['total_messages'],
32
- scrape_time: row['scrape_time']
27
+ queue_name: row["queue_name"],
28
+ queue_length: row["queue_length"],
29
+ newest_msg_age_sec: row["newest_msg_age_sec"],
30
+ oldest_msg_age_sec: row["oldest_msg_age_sec"],
31
+ total_messages: row["total_messages"],
32
+ scrape_time: row["scrape_time"]
33
33
  )
34
34
  end
35
35
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'time'
3
+ require "time"
4
4
 
5
5
  module PGMQ
6
6
  # Represents metadata about a PGMQ queue
@@ -18,20 +18,20 @@ module PGMQ
18
18
  def new(row, **)
19
19
  # Return raw values as-is from PostgreSQL
20
20
  super(
21
- queue_name: row['queue_name'],
22
- created_at: row['created_at'],
23
- is_partitioned: row['is_partitioned'],
24
- is_unlogged: row['is_unlogged']
21
+ queue_name: row["queue_name"],
22
+ created_at: row["created_at"],
23
+ is_partitioned: row["is_partitioned"],
24
+ is_unlogged: row["is_unlogged"]
25
25
  )
26
26
  end
27
27
  end
28
28
 
29
29
  # Alias for is_partitioned
30
30
  # @return [String] 't' or 'f' from PostgreSQL
31
- alias partitioned? is_partitioned
31
+ alias_method :partitioned?, :is_partitioned
32
32
 
33
33
  # Alias for is_unlogged
34
34
  # @return [String] 't' or 'f' from PostgreSQL
35
- alias unlogged? is_unlogged
35
+ alias_method :unlogged?, :is_unlogged
36
36
  end
37
37
  end
data/lib/pgmq/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module PGMQ
4
4
  # Current version of the pgmq-ruby gem
5
- VERSION = '0.4.0'
5
+ VERSION = "0.5.0"
6
6
  end
data/lib/pgmq.rb CHANGED
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'zeitwerk'
4
- require 'time'
3
+ require "zeitwerk"
4
+ require "time"
5
5
 
6
6
  loader = Zeitwerk::Loader.for_gem
7
7
  loader.inflector.inflect(
8
- 'pgmq' => 'PGMQ'
8
+ "pgmq" => "PGMQ"
9
9
  )
10
10
  loader.setup
11
11
  loader.eager_load