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,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGMQ
|
|
4
|
+
class Client
|
|
5
|
+
# Message sending operations
|
|
6
|
+
#
|
|
7
|
+
# This module handles sending messages to queues, both individual messages
|
|
8
|
+
# and batches. Users must serialize messages to JSON strings themselves.
|
|
9
|
+
module Producer
|
|
10
|
+
# Sends 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 delay [Integer] delay in seconds before message becomes visible
|
|
15
|
+
# @return [String] message ID as string
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# msg_id = client.send("orders", '{"order_id":123,"total":99.99}')
|
|
19
|
+
#
|
|
20
|
+
# @example With delay
|
|
21
|
+
# msg_id = client.send("orders", '{"data":"value"}', delay: 60)
|
|
22
|
+
#
|
|
23
|
+
# @note Users must serialize to JSON themselves. Higher-level frameworks
|
|
24
|
+
# should handle serialization.
|
|
25
|
+
def send(
|
|
26
|
+
queue_name,
|
|
27
|
+
message,
|
|
28
|
+
delay: 0
|
|
29
|
+
)
|
|
30
|
+
validate_queue_name!(queue_name)
|
|
31
|
+
|
|
32
|
+
result = with_connection do |conn|
|
|
33
|
+
conn.exec_params(
|
|
34
|
+
'SELECT * FROM pgmq.send($1::text, $2::jsonb, $3::integer)',
|
|
35
|
+
[queue_name, message, delay]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
result[0]['send']
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Sends multiple messages to a queue in a batch
|
|
43
|
+
#
|
|
44
|
+
# @param queue_name [String] name of the queue
|
|
45
|
+
# @param messages [Array<Hash>] array of message payloads
|
|
46
|
+
# @param delay [Integer] delay in seconds before messages become visible
|
|
47
|
+
# @return [Array<Integer>] array of message IDs
|
|
48
|
+
#
|
|
49
|
+
# @example
|
|
50
|
+
# ids = client.send_batch("orders", [
|
|
51
|
+
# { order_id: 1 },
|
|
52
|
+
# { order_id: 2 },
|
|
53
|
+
# { order_id: 3 }
|
|
54
|
+
# ])
|
|
55
|
+
def send_batch(
|
|
56
|
+
queue_name,
|
|
57
|
+
messages,
|
|
58
|
+
delay: 0
|
|
59
|
+
)
|
|
60
|
+
validate_queue_name!(queue_name)
|
|
61
|
+
return [] if messages.empty?
|
|
62
|
+
|
|
63
|
+
# Use PostgreSQL array parameter binding for security
|
|
64
|
+
# PG gem will properly encode the array values
|
|
65
|
+
result = with_connection do |conn|
|
|
66
|
+
# Create array encoder for proper PostgreSQL array formatting
|
|
67
|
+
encoder = PG::TextEncoder::Array.new
|
|
68
|
+
encoded_array = encoder.encode(messages)
|
|
69
|
+
|
|
70
|
+
conn.exec_params(
|
|
71
|
+
'SELECT * FROM pgmq.send_batch($1::text, $2::jsonb[], $3::integer)',
|
|
72
|
+
[queue_name, encoded_array, delay]
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
result.map { |row| row['send_batch'] }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
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 [void]
|
|
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")
|
|
19
|
+
def create(queue_name)
|
|
20
|
+
validate_queue_name!(queue_name)
|
|
21
|
+
|
|
22
|
+
with_connection do |conn|
|
|
23
|
+
conn.exec_params('SELECT pgmq.create($1::text)', [queue_name])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Creates a partitioned queue
|
|
30
|
+
#
|
|
31
|
+
# Requires pg_partman extension to be installed
|
|
32
|
+
#
|
|
33
|
+
# @param queue_name [String] name of the queue
|
|
34
|
+
# @param partition_interval [String] partition interval (e.g., "daily", "10000")
|
|
35
|
+
# @param retention_interval [String] retention interval (e.g., "7 days", "100000")
|
|
36
|
+
# @return [void]
|
|
37
|
+
#
|
|
38
|
+
# @example
|
|
39
|
+
# client.create_partitioned("big_queue",
|
|
40
|
+
# partition_interval: "daily",
|
|
41
|
+
# retention_interval: "7 days"
|
|
42
|
+
# )
|
|
43
|
+
def create_partitioned(
|
|
44
|
+
queue_name,
|
|
45
|
+
partition_interval: '10000',
|
|
46
|
+
retention_interval: '100000'
|
|
47
|
+
)
|
|
48
|
+
validate_queue_name!(queue_name)
|
|
49
|
+
|
|
50
|
+
with_connection do |conn|
|
|
51
|
+
conn.exec_params(
|
|
52
|
+
'SELECT pgmq.create_partitioned($1::text, $2::text, $3::text)',
|
|
53
|
+
[queue_name, partition_interval, retention_interval]
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Creates an unlogged queue for higher throughput (no crash recovery)
|
|
61
|
+
#
|
|
62
|
+
# @param queue_name [String] name of the queue
|
|
63
|
+
# @return [void]
|
|
64
|
+
#
|
|
65
|
+
# @example
|
|
66
|
+
# client.create_unlogged("fast_queue")
|
|
67
|
+
def create_unlogged(queue_name)
|
|
68
|
+
validate_queue_name!(queue_name)
|
|
69
|
+
|
|
70
|
+
with_connection do |conn|
|
|
71
|
+
conn.exec_params('SELECT pgmq.create_unlogged($1::text)', [queue_name])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Drops a queue and its archive table
|
|
78
|
+
#
|
|
79
|
+
# @param queue_name [String] name of the queue to drop
|
|
80
|
+
# @return [Boolean] true if queue was dropped
|
|
81
|
+
#
|
|
82
|
+
# @example
|
|
83
|
+
# client.drop_queue("old_queue")
|
|
84
|
+
def drop_queue(queue_name)
|
|
85
|
+
validate_queue_name!(queue_name)
|
|
86
|
+
|
|
87
|
+
result = with_connection do |conn|
|
|
88
|
+
conn.exec_params('SELECT pgmq.drop_queue($1::text)', [queue_name])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
return false if result.ntuples.zero?
|
|
92
|
+
|
|
93
|
+
result[0]['drop_queue'] == 't'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Lists all queues
|
|
97
|
+
#
|
|
98
|
+
# @return [Array<PGMQ::QueueMetadata>] array of queue metadata objects
|
|
99
|
+
#
|
|
100
|
+
# @example
|
|
101
|
+
# queues = client.list_queues
|
|
102
|
+
# queues.each { |q| puts q.queue_name }
|
|
103
|
+
def list_queues
|
|
104
|
+
result = with_connection do |conn|
|
|
105
|
+
conn.exec('SELECT * FROM pgmq.list_queues()')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
result.map { |row| QueueMetadata.new(row) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
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.send('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 sending 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
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pg'
|
|
4
|
+
require 'connection_pool'
|
|
5
|
+
|
|
6
|
+
module PGMQ
|
|
7
|
+
# Manages database connections for PGMQ
|
|
8
|
+
#
|
|
9
|
+
# Supports multiple connection strategies:
|
|
10
|
+
# - Connection strings
|
|
11
|
+
# - Hash of connection parameters
|
|
12
|
+
# - Callable objects (for Rails ActiveRecord integration)
|
|
13
|
+
#
|
|
14
|
+
# @example With connection string
|
|
15
|
+
# conn = PGMQ::Connection.new("postgres://localhost/mydb")
|
|
16
|
+
#
|
|
17
|
+
# @example With connection hash
|
|
18
|
+
# conn = PGMQ::Connection.new(host: 'localhost', dbname: 'mydb')
|
|
19
|
+
#
|
|
20
|
+
# @example With Rails ActiveRecord (reuses Rails connection pool)
|
|
21
|
+
# conn = PGMQ::Connection.new(-> { ActiveRecord::Base.connection.raw_connection })
|
|
22
|
+
class Connection
|
|
23
|
+
# Default connection pool size
|
|
24
|
+
DEFAULT_POOL_SIZE = 5
|
|
25
|
+
|
|
26
|
+
# Default connection pool timeout in seconds
|
|
27
|
+
DEFAULT_POOL_TIMEOUT = 5
|
|
28
|
+
|
|
29
|
+
# @return [ConnectionPool] the connection pool
|
|
30
|
+
attr_reader :pool
|
|
31
|
+
|
|
32
|
+
# Creates a new connection manager
|
|
33
|
+
#
|
|
34
|
+
# @param conn_params [String, Hash, Proc] connection parameters or callable
|
|
35
|
+
# @param pool_size [Integer] size of the connection pool
|
|
36
|
+
# @param pool_timeout [Integer] connection pool timeout in seconds
|
|
37
|
+
# @param auto_reconnect [Boolean] automatically reconnect on connection errors
|
|
38
|
+
# @raise [PGMQ::Errors::ConfigurationError] if conn_params is nil or invalid
|
|
39
|
+
def initialize(
|
|
40
|
+
conn_params,
|
|
41
|
+
pool_size: DEFAULT_POOL_SIZE,
|
|
42
|
+
pool_timeout: DEFAULT_POOL_TIMEOUT,
|
|
43
|
+
auto_reconnect: true
|
|
44
|
+
)
|
|
45
|
+
if conn_params.nil?
|
|
46
|
+
raise(
|
|
47
|
+
PGMQ::Errors::ConfigurationError,
|
|
48
|
+
'Connection parameters are required'
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@conn_params = normalize_connection_params(conn_params)
|
|
53
|
+
@pool_size = pool_size
|
|
54
|
+
@pool_timeout = pool_timeout
|
|
55
|
+
@auto_reconnect = auto_reconnect
|
|
56
|
+
@pool = create_pool
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Executes a block with a connection from the pool
|
|
60
|
+
#
|
|
61
|
+
# @yield [PG::Connection] database connection
|
|
62
|
+
# @return [Object] result of the block
|
|
63
|
+
# @raise [PGMQ::Errors::ConnectionError] if connection fails
|
|
64
|
+
def with_connection
|
|
65
|
+
retries = @auto_reconnect ? 1 : 0
|
|
66
|
+
attempts = 0
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
@pool.with do |conn|
|
|
70
|
+
# Health check: verify connection is alive
|
|
71
|
+
verify_connection!(conn) if @auto_reconnect
|
|
72
|
+
|
|
73
|
+
yield conn
|
|
74
|
+
end
|
|
75
|
+
rescue PG::Error => e
|
|
76
|
+
attempts += 1
|
|
77
|
+
|
|
78
|
+
# If connection error and auto_reconnect enabled, try once more
|
|
79
|
+
retry if attempts <= retries && connection_lost_error?(e)
|
|
80
|
+
|
|
81
|
+
raise PGMQ::Errors::ConnectionError, "Database connection error: #{e.message}"
|
|
82
|
+
rescue ConnectionPool::TimeoutError => e
|
|
83
|
+
raise PGMQ::Errors::ConnectionError, "Connection pool timeout: #{e.message}"
|
|
84
|
+
rescue ConnectionPool::PoolShuttingDownError => e
|
|
85
|
+
raise PGMQ::Errors::ConnectionError, "Connection pool is closed: #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Closes all connections in the pool
|
|
90
|
+
# @return [void]
|
|
91
|
+
def close
|
|
92
|
+
@pool.shutdown { |conn| conn.close unless conn.finished? }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns connection pool statistics
|
|
96
|
+
#
|
|
97
|
+
# @return [Hash] statistics about the connection pool
|
|
98
|
+
# @example
|
|
99
|
+
# stats = connection.stats
|
|
100
|
+
# # => { size: 5, available: 3 }
|
|
101
|
+
def stats
|
|
102
|
+
{
|
|
103
|
+
size: @pool_size,
|
|
104
|
+
available: @pool.available
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Checks if the error indicates a lost connection
|
|
111
|
+
# @param error [PG::Error] the error to check
|
|
112
|
+
# @return [Boolean] true if connection was lost
|
|
113
|
+
def connection_lost_error?(error)
|
|
114
|
+
# Common connection lost errors
|
|
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'
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
message = error.message.downcase
|
|
125
|
+
lost_connection_messages.any? { |pattern| message.include?(pattern) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Verifies a connection is alive and working
|
|
129
|
+
# @param conn [PG::Connection] connection to verify
|
|
130
|
+
# @raise [PG::Error] if connection is not working
|
|
131
|
+
def verify_connection!(conn)
|
|
132
|
+
# Quick check - is connection object in bad state?
|
|
133
|
+
return unless conn.finished?
|
|
134
|
+
|
|
135
|
+
# Connection is finished/closed, try to reset it
|
|
136
|
+
conn.reset
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Normalizes various connection parameter formats
|
|
140
|
+
# @param params [String, Hash, Proc]
|
|
141
|
+
# @return [Hash, Proc]
|
|
142
|
+
# @raise [PGMQ::Errors::ConfigurationError] if params format is invalid
|
|
143
|
+
def normalize_connection_params(params)
|
|
144
|
+
return params if params.respond_to?(:call) # Callable (e.g., proc for Rails)
|
|
145
|
+
return parse_connection_string(params) if params.is_a?(String)
|
|
146
|
+
return params if params.is_a?(Hash) && !params.empty?
|
|
147
|
+
|
|
148
|
+
raise PGMQ::Errors::ConfigurationError, 'Invalid connection parameters format'
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Parses a PostgreSQL connection string
|
|
152
|
+
# @param conn_string [String] connection string (e.g., "postgres://user:pass@host/db")
|
|
153
|
+
# @return [Hash] connection parameters
|
|
154
|
+
def parse_connection_string(conn_string)
|
|
155
|
+
# PG::Connection.conninfo_parse is available in pg >= 0.20
|
|
156
|
+
if PG::Connection.respond_to?(:conninfo_parse)
|
|
157
|
+
PG::Connection.conninfo_parse(conn_string).each_with_object({}) do |info, hash|
|
|
158
|
+
hash[info[:keyword].to_sym] = info[:val] if info[:val]
|
|
159
|
+
end
|
|
160
|
+
else
|
|
161
|
+
# Fallback: pass the string directly and let PG handle it
|
|
162
|
+
{ conninfo: conn_string }
|
|
163
|
+
end
|
|
164
|
+
rescue PG::Error => e
|
|
165
|
+
raise PGMQ::Errors::ConfigurationError, "Invalid connection string: #{e.message}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Creates the connection pool
|
|
169
|
+
# @return [ConnectionPool]
|
|
170
|
+
def create_pool
|
|
171
|
+
params = @conn_params
|
|
172
|
+
|
|
173
|
+
ConnectionPool.new(size: @pool_size, timeout: @pool_timeout) do
|
|
174
|
+
create_connection(params)
|
|
175
|
+
end
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
raise PGMQ::Errors::ConnectionError, "Failed to create connection pool: #{e.message}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Creates a single database connection
|
|
181
|
+
# @param params [Hash, Proc] connection parameters or callable
|
|
182
|
+
# @return [PG::Connection]
|
|
183
|
+
def create_connection(params)
|
|
184
|
+
# If we have a callable (e.g., for Rails), call it to get the connection
|
|
185
|
+
return params.call if params.respond_to?(:call)
|
|
186
|
+
|
|
187
|
+
# Create new connection from parameters
|
|
188
|
+
# Low-level library: return all values as strings from PostgreSQL
|
|
189
|
+
# No automatic type conversion - let higher-level frameworks handle parsing
|
|
190
|
+
# conn.type_map_for_results intentionally NOT set
|
|
191
|
+
PG.connect(params[:conninfo] || params)
|
|
192
|
+
rescue PG::Error => e
|
|
193
|
+
raise PGMQ::Errors::ConnectionError, "Failed to connect to database: #{e.message}"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
data/lib/pgmq/errors.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGMQ
|
|
4
|
+
# PGMQ errors namespace
|
|
5
|
+
module Errors
|
|
6
|
+
# Base error class for all PGMQ errors
|
|
7
|
+
class BaseError < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised when connection to PostgreSQL fails or is lost
|
|
10
|
+
class ConnectionError < BaseError; end
|
|
11
|
+
|
|
12
|
+
# Raised when a queue operation is attempted on a non-existent queue
|
|
13
|
+
class QueueNotFoundError < BaseError; end
|
|
14
|
+
|
|
15
|
+
# Raised when a message cannot be found
|
|
16
|
+
class MessageNotFoundError < BaseError; end
|
|
17
|
+
|
|
18
|
+
# Raised when message serialization fails
|
|
19
|
+
class SerializationError < BaseError; end
|
|
20
|
+
|
|
21
|
+
# Raised when message deserialization fails
|
|
22
|
+
class DeserializationError < BaseError; end
|
|
23
|
+
|
|
24
|
+
# Raised when configuration is invalid
|
|
25
|
+
class ConfigurationError < BaseError; end
|
|
26
|
+
|
|
27
|
+
# Raised when an invalid queue name is provided
|
|
28
|
+
class InvalidQueueNameError < BaseError; end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/pgmq/message.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGMQ
|
|
4
|
+
# Represents a message read from a PGMQ queue
|
|
5
|
+
#
|
|
6
|
+
# Returns raw values from PostgreSQL without transformation.
|
|
7
|
+
# Higher-level frameworks should handle parsing, deserialization, etc.
|
|
8
|
+
#
|
|
9
|
+
# @example Reading a message (raw values)
|
|
10
|
+
# msg = client.read("my_queue", vt: 30)
|
|
11
|
+
# puts msg.msg_id # => "123" (String from PG)
|
|
12
|
+
# puts msg.read_ct # => "1" (String from PG)
|
|
13
|
+
# puts msg.enqueued_at # => "2025-01-15 10:30:00+00" (String from PG)
|
|
14
|
+
# puts msg.vt # => "2025-01-15 10:30:30+00" (String from PG)
|
|
15
|
+
# puts msg.message # => "{\"order_id\":456}" (Raw JSONB string)
|
|
16
|
+
# puts msg.headers # => "{\"trace_id\":\"abc123\"}" (Raw JSONB string, optional)
|
|
17
|
+
# puts msg.queue_name # => "my_queue" (only present for multi-queue operations)
|
|
18
|
+
class Message < Data.define(
|
|
19
|
+
:msg_id, :read_ct, :enqueued_at, :vt, :message, :headers, :queue_name
|
|
20
|
+
)
|
|
21
|
+
class << self
|
|
22
|
+
# Creates a new Message from a database row
|
|
23
|
+
# @param row [Hash] database row from PG result
|
|
24
|
+
# @return [Message]
|
|
25
|
+
def new(row, **)
|
|
26
|
+
# Return raw values as-is from PostgreSQL
|
|
27
|
+
# No parsing, no deserialization, no transformation
|
|
28
|
+
# The pg gem returns JSONB as String by default
|
|
29
|
+
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
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Alias for msg_id (common in messaging systems)
|
|
42
|
+
# @return [String]
|
|
43
|
+
alias id msg_id
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/pgmq/metrics.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module PGMQ
|
|
6
|
+
# Represents metrics for a PGMQ queue
|
|
7
|
+
#
|
|
8
|
+
# @example Getting queue metrics
|
|
9
|
+
# metrics = client.metrics("my_queue")
|
|
10
|
+
# puts metrics.queue_length # => 42
|
|
11
|
+
# puts metrics.oldest_msg_age_sec # => 3600
|
|
12
|
+
class Metrics < Data.define(
|
|
13
|
+
:queue_name,
|
|
14
|
+
:queue_length,
|
|
15
|
+
:newest_msg_age_sec,
|
|
16
|
+
:oldest_msg_age_sec,
|
|
17
|
+
:total_messages,
|
|
18
|
+
:scrape_time
|
|
19
|
+
)
|
|
20
|
+
class << self
|
|
21
|
+
# Creates a new Metrics object from a database row
|
|
22
|
+
# @param row [Hash] database row from PG result
|
|
23
|
+
# @return [Metrics]
|
|
24
|
+
def new(row, **)
|
|
25
|
+
# Return raw values as-is from PostgreSQL
|
|
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']
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module PGMQ
|
|
6
|
+
# Represents metadata about a PGMQ queue
|
|
7
|
+
#
|
|
8
|
+
# @example Listing queues
|
|
9
|
+
# queues = client.list_queues
|
|
10
|
+
# queues.each do |q|
|
|
11
|
+
# puts "#{q.queue_name} (partitioned: #{q.is_partitioned})"
|
|
12
|
+
# end
|
|
13
|
+
class QueueMetadata < Data.define(:queue_name, :created_at, :is_partitioned, :is_unlogged)
|
|
14
|
+
class << self
|
|
15
|
+
# Creates a new QueueMetadata object from a database row
|
|
16
|
+
# @param row [Hash] database row from PG result
|
|
17
|
+
# @return [QueueMetadata]
|
|
18
|
+
def new(row, **)
|
|
19
|
+
# Return raw values as-is from PostgreSQL
|
|
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']
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Alias for is_partitioned
|
|
30
|
+
# @return [String] 't' or 'f' from PostgreSQL
|
|
31
|
+
alias partitioned? is_partitioned
|
|
32
|
+
|
|
33
|
+
# Alias for is_unlogged
|
|
34
|
+
# @return [String] 't' or 'f' from PostgreSQL
|
|
35
|
+
alias unlogged? is_unlogged
|
|
36
|
+
end
|
|
37
|
+
end
|