pgmq-ruby 0.8.2 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 943d0ebf4cffa43c1163f651575cbfb0e9785fc675f388ef7b208538e9dab8a3
4
- data.tar.gz: fd9ab2693a0bf87c6230330ac8c4e046c585c420ecbdce0b77b06a6b652fd266
3
+ metadata.gz: 79570c7c6e4591584c43e16f99064d7d249148601a74d8ff853791511d9d87ee
4
+ data.tar.gz: 235af4a2895ffb1dd3a3b29b2e2f7448b8c0e32899af3a62d7f30fc9c7277d50
5
5
  SHA512:
6
- metadata.gz: 6cc612c51d2b89e7ecfd3ea6879eb3e731a8ce96cba961863119a34b9dd094b0e5c139045d0c0cdf0df964130ec0e65a9df4961defe6843603767e9369b60800
7
- data.tar.gz: ff1caf1d8012504204f5d793b577c0c3603ba2d156257786a32fab73618d52971d0c49edfba0860f9a5ad9dd56e34dca272891e594b55521761f514fd8fdc053
6
+ metadata.gz: 9483f8855343ad8a5a9292d76544726624380bef0d14dcfe8321ade758ebb32d71543a28a87176ffe323a4577bc0d1c2a9407510d1bf6bd5e41d360ec416aedd
7
+ data.tar.gz: 8a09f3994b147dece5c30e449362331337e89e0fbe0315b18b727552b234a5de2f7c74a48fd0329ff9b650d9c0ddc27080a89b4910d807d61b83afbe000421d9
data/CHANGELOG.md CHANGED
@@ -1,12 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 (2026-11-10)
4
+
5
+ ### Producer
6
+ - **[Breaking]** `produce_topic` and `produce_batch_topic` raise `PGMQ::Errors::MessageInvalidError` when a message is
7
+ not a String or its headers are neither a String nor `nil`, like `produce` and `produce_batch`. Most such calls
8
+ succeeded before and now raise: non-String messages and headers were stored as JSON (Integer, Float, `true`, `false`
9
+ and Array values) or as NULL (a `nil` message; Hash headers in `produce_topic` with an integer delay), and
10
+ `produce_topic` with an integer delay dropped `false` headers. The other `nil` and Hash cases raised
11
+ `PGMQ::Errors::ConnectionError`.
12
+
13
+ ### Validation
14
+ - **[Change]** A contract check allocates its errors Array on the first error only, so valid input allocates one
15
+ object less per validated message in `produce`, `produce_batch`, `produce_topic` and `produce_batch_topic`.
16
+ Until then a `PGMQ::Contractable::Contract` rule gets a frozen empty `errors` Array: a custom rule that appends to it
17
+ raises `FrozenError`.
18
+
19
+ ### Connection Management
20
+ - **[Fix]** `PGMQ::Connection#verify_connection!` resets a pooled connection whose status is anything other than
21
+ `PG::CONNECTION_OK` (#217). It reset only `CONNECTION_BAD` before, so a connection left in an intermediate state
22
+ (`CONNECTION_STARTED`, `CONNECTION_MADE`, ...) by a reset or connect interrupted before it completed (a
23
+ `Thread#raise` during `PG::Connection#reset`, a `reset_start` never polled to completion) passed the health check,
24
+ and every call on it, the `auto_reconnect` retry included, raised `PGMQ::Errors::ConnectionError` (`PG::UnableToSend:
25
+ no connection to the server`) until `reload` or a process restart. The next checkout now resets it.
26
+ - **[Fix]** `PGMQ::Connection#with_connection` resets a pooled connection when the block raises `PG::ConnectionBad`
27
+ or `PG::UnableToSend`, before the connection goes back to the pool. A connection can pass the checkout health check
28
+ and still fail on the next send; it went back to the pool broken, and with `auto_reconnect: false` (or when it still
29
+ reported `PG::CONNECTION_OK`) every later checkout failed the same way until `reload`. The caller still gets
30
+ `PGMQ::Errors::ConnectionError`. When the reset fails too, the connection is closed: with `auto_reconnect` the next
31
+ checkout resets it; without it, the next operation on it fails once and that failure resets it. Connections returned
32
+ by a callable (e.g. ActiveRecord's raw connection) are not reset or closed.
33
+
3
34
  ## 0.8.2 (2026-11-02)
4
35
 
5
36
  ### Queue Naming
6
37
  - **[Fix]** The `InvalidQueueNameError` message for a too-long queue name states the real limit: "exceeds maximum
7
38
  length of 47 characters" (was 48; a 48-character name was already rejected). The README now gives 47 as the maximum
8
39
  and no longer calls queue names case-sensitive: names differing only in case share one table (PGMQ lower-cases
9
- table names) but get separate `pgmq.meta` rows, so use one spelling per queue.### Documentation
40
+ table names) but get separate `pgmq.meta` rows, so use one spelling per queue.
41
+
42
+ ### Documentation
10
43
  - **[Fix]** The README `wait_for_notify` example said the call returns the notification payload; it returns the
11
44
  channel name (`nil` on timeout).
12
45
 
data/README.md CHANGED
@@ -93,7 +93,6 @@ This gem provides complete support for all core PGMQ SQL functions. Based on the
93
93
  | | Consumer | Minimal single-threaded `PGMQ::Consumer` loop over one or more queues with ack on success | ✅ |
94
94
  | | Queue Validation | 47-character limit and name validation | ✅ |
95
95
  | | Connection Pooling | Thread-safe connection pool for concurrency | ✅ |
96
- | | Pluggable Serializers | JSON (default) with custom serializer support | ✅ |
97
96
 
98
97
  ## Requirements
99
98
 
@@ -308,6 +307,9 @@ Every PGMQ operation checks out a pooled connection through `client.with_connect
308
307
  The method is public, so you can run PostgreSQL statements PGMQ does not wrap
309
308
  without standing up a second connection pool. The connection is health-checked
310
309
  (when `auto_reconnect` is enabled) and returned to the pool when the block exits.
310
+ If the block raises `PG::ConnectionBad` or `PG::UnableToSend`, the connection is
311
+ reset before it goes back to the pool (connections from a callable are left as
312
+ they are); you still get `PGMQ::Errors::ConnectionError`.
311
313
 
312
314
  ```ruby
313
315
  # Fire a custom NOTIFY alongside your queues
@@ -1230,8 +1232,8 @@ end
1230
1232
 
1231
1233
  Invalid arguments are rejected by a contract before they reach PostgreSQL, with a validation error:
1232
1234
 
1233
- - `PGMQ::Errors::MessageInvalidError` from `produce`, `produce_batch` and `produce_batch_topic`, for example when a
1234
- message is not a String or its headers are neither a String nor `nil`
1235
+ - `PGMQ::Errors::MessageInvalidError` from `produce`, `produce_batch`, `produce_topic` and `produce_batch_topic`, for
1236
+ example when a message is not a String or its headers are neither a String nor `nil`
1235
1237
  - `PGMQ::Errors::MultiQueueInvalidError` from the multi-queue operations (`read_multi`, `read_multi_with_poll`,
1236
1238
  `pop_multi`, `delete_multi`, `archive_multi`, `set_vt_multi`)
1237
1239
  - `PGMQ::Errors::AutovacuumSettingsInvalidError` from `tune_autovacuum` for an unknown storage parameter or a
@@ -1268,9 +1270,6 @@ bin/integrations
1268
1270
 
1269
1271
  # Run a specific integration spec
1270
1272
  bin/integrations spec/integration/basic_produce_consume_spec.rb
1271
-
1272
- # Run console
1273
- bundle exec bin/console
1274
1273
  ```
1275
1274
 
1276
1275
  ## Author
@@ -103,17 +103,9 @@ module PGMQ
103
103
  delay: 0
104
104
  )
105
105
  validate_queue_name!(queue_name)
106
- BATCH_CONTRACT.validate!({ messages: messages, headers: headers }, Errors::MessageInvalidError)
106
+ validate_messages!(messages, headers)
107
107
  return [] if messages.empty?
108
108
 
109
- messages.each_with_index do |message, index|
110
- MESSAGE_CONTRACT.validate!(
111
- { message: message, headers: headers && headers[index] },
112
- Errors::MessageInvalidError,
113
- scope: ["messages", index]
114
- )
115
- end
116
-
117
109
  result = with_connection do |conn|
118
110
  encoder = PG::TextEncoder::Array.new
119
111
  encoded_messages = encoder.encode(messages)
@@ -73,6 +73,8 @@ module PGMQ
73
73
  # @param delay [Numeric, ActiveSupport::Duration] delay in seconds before message becomes visible (integer,
74
74
  # float or Duration; a float such as 1.5 needs PGMQ v1.10.0+)
75
75
  # @return [Integer] count of queues the message was delivered to
76
+ # @raise [PGMQ::Errors::MessageInvalidError] if message is not a String, or headers is neither a String nor
77
+ # nil
76
78
  #
77
79
  # @example Basic topic send
78
80
  # count = client.produce_topic("orders.new", '{"order_id":123}')
@@ -83,6 +85,8 @@ module PGMQ
83
85
  # headers: '{"trace_id":"abc"}',
84
86
  # delay: 30)
85
87
  def produce_topic(routing_key, message, headers: nil, delay: 0)
88
+ MESSAGE_CONTRACT.validate!({ message: message, headers: headers }, Errors::MessageInvalidError)
89
+
86
90
  # send_topic takes whole seconds only, so a fractional delay goes through send_batch_topic
87
91
  if delay.is_a?(Numeric) && !delay.integer?
88
92
  return produce_batch_topic(routing_key, [message], headers: headers && [headers], delay: delay).size
@@ -122,7 +126,8 @@ module PGMQ
122
126
  # become visible, including ActiveSupport::TimeWithZone (PGMQ v1.10.0+)
123
127
  # @return [Array<Hash>] array of hashes with :queue_name and :msg_id
124
128
  # @raise [PGMQ::Errors::MessageInvalidError] if messages is not an Array, headers is neither an Array nor nil,
125
- # or headers length doesn't match messages length
129
+ # headers length doesn't match messages length, a message is not a String, or a header is neither a String
130
+ # nor nil
126
131
  #
127
132
  # @example Batch topic send
128
133
  # results = client.produce_batch_topic("orders.new", [
@@ -131,7 +136,7 @@ module PGMQ
131
136
  # ])
132
137
  # # => [{ queue_name: "new_orders", msg_id: "1" }, ...]
133
138
  def produce_batch_topic(routing_key, messages, headers: nil, delay: 0)
134
- BATCH_CONTRACT.validate!({ messages: messages, headers: headers }, Errors::MessageInvalidError)
139
+ validate_messages!(messages, headers)
135
140
  return [] if messages.empty?
136
141
 
137
142
  result = with_connection do |conn|
data/lib/pgmq/client.rb CHANGED
@@ -173,6 +173,28 @@ module PGMQ
173
173
  nil
174
174
  end
175
175
 
176
+ # Validates a batch of messages and their headers before they are bound
177
+ #
178
+ # @param messages [Array<String>] message payloads as JSON strings
179
+ # @param headers [Array<String, nil>, nil] headers as JSON strings, one per message
180
+ # @return [void]
181
+ # @raise [PGMQ::Errors::MessageInvalidError] if messages is not an Array, headers is neither an Array nor nil,
182
+ # headers length doesn't match messages length, a message is not a String, or a header is neither a String nor
183
+ # nil
184
+ def validate_messages!(messages, headers)
185
+ BATCH_CONTRACT.validate!({ messages: messages, headers: headers }, Errors::MessageInvalidError)
186
+
187
+ messages.each_with_index do |message, index|
188
+ MESSAGE_CONTRACT.validate!(
189
+ { message: message, headers: headers && headers[index] },
190
+ Errors::MessageInvalidError,
191
+ scope: ["messages", index]
192
+ )
193
+ end
194
+
195
+ nil
196
+ end
197
+
176
198
  # Builds the SQL argument and bind value for a +delay:+ given in seconds or as an absolute Time
177
199
  #
178
200
  # PostgreSQL rejects a Float such as +1.5+ for an +integer+ bind, so a non-Integer Numeric is passed to the
@@ -116,11 +116,17 @@ module PGMQ
116
116
  @pool_size = pool_size
117
117
  @pool_timeout = pool_timeout
118
118
  @auto_reconnect = auto_reconnect
119
+ # A callable hands us connections it may share with its owner (e.g. ActiveRecord's raw connection), so we never
120
+ # reset or close them after a failure; only connections we open ourselves are reset.
121
+ @owns_connections = !conn_params.respond_to?(:call)
119
122
  @pool = create_pool
120
123
  end
121
124
 
122
125
  # Executes a block with a connection from the pool
123
126
  #
127
+ # When the block raises `PG::ConnectionBad` or `PG::UnableToSend`, the connection is reset before it goes back to
128
+ # the pool, so the next checkout gets a working one. Connections from a callable are left as they are.
129
+ #
124
130
  # @yield [PG::Connection] database connection
125
131
  # @return [Object] result of the block
126
132
  # @raise [PGMQ::Errors::ConnectionError] if the block raises any `PG::Error` (the original is in `#cause`), or a
@@ -134,7 +140,12 @@ module PGMQ
134
140
  # Health check: verify connection is alive
135
141
  verify_connection!(conn) if @auto_reconnect
136
142
 
137
- yield conn
143
+ begin
144
+ yield conn
145
+ rescue *LOST_CONNECTION_ERRORS
146
+ reset_lost_connection(conn) if @owns_connections
147
+ raise
148
+ end
138
149
  end
139
150
  rescue PG::Error => e
140
151
  attempts += 1
@@ -161,8 +172,8 @@ module PGMQ
161
172
  #
162
173
  # Use this to recover from a connection that libpq still reports as +CONNECTION_OK+ but which is in fact wedged -
163
174
  # e.g. after a wall-clock timeout (+Timeout.timeout+) interrupted a query mid-flight, leaving the socket poisoned so
164
- # it re-hangs on reuse. {#verify_connection!} cannot catch that case (the connection does not report
165
- # +CONNECTION_BAD+), so the caller must discard it explicitly; +reload+ is the safe, pool-wide way to do so.
175
+ # it re-hangs on reuse. {#verify_connection!} cannot catch that case (the connection still reports
176
+ # +CONNECTION_OK+), so the caller must discard it explicitly; +reload+ is the safe, pool-wide way to do so.
166
177
  #
167
178
  # @return [void]
168
179
  # @example Discard the pool after a query had to be force-interrupted
@@ -191,6 +202,10 @@ module PGMQ
191
202
 
192
203
  private
193
204
 
205
+ # libpq's dedicated connection-failure classes. A pooled connection that raises one of them mid-operation is reset.
206
+ LOST_CONNECTION_ERRORS = [PG::ConnectionBad, PG::UnableToSend].freeze
207
+ private_constant :LOST_CONNECTION_ERRORS
208
+
194
209
  # Messages libpq raises when the server/pooler has already torn down the socket. The list has grown organically with
195
210
  # each pooler/TLS variant we see in the wild; the class check below catches future variants that libpq raises as
196
211
  # `PG::ConnectionBad` or `PG::UnableToSend` without waiting for a new message to hit production.
@@ -220,7 +235,7 @@ module PGMQ
220
235
  # @return [Boolean] true if the connection was lost and a retry on a
221
236
  # fresh connection is appropriate
222
237
  def connection_lost_error?(error)
223
- return true if error.is_a?(PG::ConnectionBad) || error.is_a?(PG::UnableToSend)
238
+ return true if LOST_CONNECTION_ERRORS.any? { |klass| error.is_a?(klass) }
224
239
 
225
240
  extra_classes = self.class.reconnectable_error_classes
226
241
  return true if extra_classes.any? { |klass| error.is_a?(klass) }
@@ -240,19 +255,36 @@ module PGMQ
240
255
 
241
256
  # Verifies a connection is alive and working.
242
257
  #
243
- # Also resets when the connection reports `PG::CONNECTION_BAD`, which happens when the server (or an intermediate
244
- # pooler such as PgBouncer) has closed the socket while the client-side `PG::Connection` object still exists.
245
- # `#finished?` alone only catches connections closed explicitly from the client side.
258
+ # Also resets when the connection reports any status other than `PG::CONNECTION_OK`: `CONNECTION_BAD` when the
259
+ # server (or an intermediate pooler such as PgBouncer) closed the socket while the client-side `PG::Connection`
260
+ # object still exists, and an intermediate state (`CONNECTION_STARTED`, `CONNECTION_MADE`, ...) when a previous
261
+ # reset or connect was interrupted before it completed. libpq refuses to send on either. `#finished?` alone only
262
+ # catches connections closed explicitly from the client side.
246
263
  #
247
264
  # @param conn [PG::Connection] connection to verify
248
265
  # @raise [PG::Error] if the reset itself fails
249
266
  def verify_connection!(conn)
250
267
  return conn.reset if conn.finished?
251
- return conn.reset if conn.status == PG::CONNECTION_BAD
268
+ return conn.reset unless conn.status == PG::CONNECTION_OK
252
269
 
253
270
  nil
254
271
  end
255
272
 
273
+ # Resets a connection that failed mid-operation so it does not go back to the pool broken. A connection can pass
274
+ # {#verify_connection!} at checkout (`CONNECTION_OK`) and still fail on the next send.
275
+ #
276
+ # A failing reset must not replace the error the caller sees, so it is swallowed and the connection is closed
277
+ # instead. With `auto_reconnect`, the next checkout sees it as `finished?` and resets it. Without it, the next
278
+ # operation on it fails with `PG::ConnectionBad` ("connection is closed"), and that failure resets it.
279
+ #
280
+ # @param conn [PG::Connection] connection the failed operation used
281
+ # @return [void]
282
+ def reset_lost_connection(conn)
283
+ conn.reset
284
+ rescue
285
+ conn.close unless conn.finished?
286
+ end
287
+
256
288
  # Normalizes various connection parameter formats
257
289
  # @param params [String, Hash, Proc]
258
290
  # @return [Hash, Proc]
@@ -21,7 +21,8 @@ module PGMQ
21
21
  DIG_MISS = Object.new
22
22
 
23
23
  # Empty array for scope default to avoid allocating a new Array on each `#call` / `#validate!` invocation. Safe
24
- # because scope is never mutated - it is only used in `scope + rule.path` which creates a new Array.
24
+ # because scope is never mutated - it is only used in `scope + rule.path` which creates a new Array. Also passed
25
+ # to validators as `errors` until the first error.
25
26
  EMPTY_ARRAY = [].freeze
26
27
 
27
28
  private_constant :DIG_MISS, :EMPTY_ARRAY
@@ -77,7 +78,7 @@ module PGMQ
77
78
  #
78
79
  # @param block [Proc] validation rule, called with `(data, errors, contract)`. It must return either a
79
80
  # non-Array (`true`/`nil`/`false`) for "no errors", or an `Array` of `[path, message]` error pairs (where
80
- # `path` is itself an `Array` of symbols).
81
+ # `path` is itself an `Array` of symbols). `errors` holds the errors found so far and must not be mutated.
81
82
  #
82
83
  # @note The returned error `Array` is owned by the contract: `#call` prepends the current scope onto each pair
83
84
  # in place and collects them, so a rule must return a freshly built `Array` on every call.
@@ -102,39 +103,41 @@ module PGMQ
102
103
  # needed if contract starts from root
103
104
  # @return [Result] validation result
104
105
  def call(data, scope: EMPTY_ARRAY)
105
- errors = []
106
+ # Allocated on the first error, so valid data allocates no Array
107
+ errors = nil
106
108
 
107
109
  # A non-Hash root has no keys to dig, so every required rule reports missing
108
110
  data_is_hash = data.is_a?(Hash)
109
111
 
110
112
  self.class.rules.each do |rule|
111
113
  if rule.type == :virtual
112
- result = rule.validator.call(data, errors, self)
114
+ result = rule.validator.call(data, errors || EMPTY_ARRAY, self)
113
115
 
114
116
  # Any non-Array result (true, but also nil/false) means "no errors"
115
117
  next unless result.is_a?(Array)
118
+ next if result.empty?
116
119
 
117
120
  result.each do |sub_result|
118
121
  sub_result[0] = scope + sub_result[0]
119
122
  end
120
123
 
121
- errors.push(*result)
124
+ (errors ||= []).push(*result)
122
125
  else
123
126
  for_checking = data_is_hash ? dig(data, rule.path) : DIG_MISS
124
127
 
125
128
  if DIG_MISS.equal?(for_checking)
126
- errors << [scope + rule.path, :missing] if rule.type == :required
129
+ (errors ||= []) << [scope + rule.path, :missing] if rule.type == :required
127
130
  else
128
- result = rule.validator.call(for_checking, data, errors, self)
131
+ result = rule.validator.call(for_checking, data, errors || EMPTY_ARRAY, self)
129
132
 
130
133
  next if result == true
131
134
 
132
- errors << [scope + rule.path, result || :format]
135
+ (errors ||= []) << [scope + rule.path, result || :format]
133
136
  end
134
137
  end
135
138
  end
136
139
 
137
- return Result.success if errors.empty?
140
+ return Result.success unless errors
138
141
 
139
142
  Result.new(errors, self)
140
143
  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.8.2"
5
+ VERSION = "0.9.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgmq-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Mensfeld