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,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
@@ -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