pgmq-ruby 0.7.2 → 0.8.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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ # Minimal single-threaded consumer for one or more queues
5
+ #
6
+ # Wraps an existing {PGMQ::Client} with a blocking `each` loop, similar to the simple consumer of rdkafka-ruby. It
7
+ # long-polls the queue with `read_with_poll` (or several queues with `read_multi_with_poll`), yields one message at a
8
+ # time and acknowledges it (delete or archive) on its own queue when the block returns. The consumer does not own the
9
+ # client: closing the client stays the caller's job.
10
+ #
11
+ # Delivery is at-least-once. When the block raises, the message is not acknowledged and becomes visible again once
12
+ # its visibility timeout expires. When processing takes longer than `vt`, another reader may receive the same message.
13
+ #
14
+ # @example Basic usage
15
+ # client = PGMQ::Client.new(ENV["DATABASE_URL"])
16
+ # consumer = PGMQ::Consumer.new(client, "orders", vt: 30)
17
+ #
18
+ # trap("TERM") { consumer.stop }
19
+ # trap("INT") { consumer.stop }
20
+ #
21
+ # consumer.each do |message|
22
+ # process(JSON.parse(message.message))
23
+ # end
24
+ #
25
+ # client.close
26
+ #
27
+ # @example Several queues
28
+ # consumer = PGMQ::Consumer.new(client, %w[orders emails], vt: 30)
29
+ #
30
+ # consumer.each do |message|
31
+ # puts "#{message.queue_name}: #{message.message}"
32
+ # end
33
+ class Consumer
34
+ # Validates the options before the consumer is built
35
+ CONTRACT = Contracts::Consumer.new
36
+
37
+ private_constant :CONTRACT
38
+
39
+ # @return [PGMQ::Client] client used for all queue operations
40
+ attr_reader :client
41
+
42
+ # @return [Array<String>] names of the consumed queues
43
+ attr_reader :queue_names
44
+
45
+ # Creates a new consumer
46
+ #
47
+ # @param client [PGMQ::Client] client used for all queue operations
48
+ # @param queue_names [String, Array<String>] name of the queue to consume, or names of up to 50 queues
49
+ # @param vt [Integer] visibility timeout in seconds for read messages
50
+ # @param batch_size [Integer] number of messages to read per queue per poll (still yielded one at a time)
51
+ # @param max_poll_seconds [Integer] how long a single poll waits for messages
52
+ # @param poll_interval_ms [Integer] interval between server-side checks within a poll
53
+ # @param on_success [Symbol] `:delete` or `:archive`, applied after the block returns
54
+ # @param max_attempts [Integer, nil] archive without yielding once `read_ct` exceeds this value (nil disables).
55
+ # Requires `batch_size: 1`; with several queues, one message is then read per poll
56
+ # @raise [PGMQ::Errors::ConfigurationError] when an option or the list of queues is invalid
57
+ # @raise [PGMQ::Errors::InvalidQueueNameError] when a queue name is invalid
58
+ #
59
+ # @example
60
+ # consumer = PGMQ::Consumer.new(client, "orders", on_success: :archive, max_attempts: 5)
61
+ def initialize(
62
+ client,
63
+ queue_names,
64
+ vt: Client::DEFAULT_VT,
65
+ batch_size: 1,
66
+ max_poll_seconds: 5,
67
+ poll_interval_ms: 100,
68
+ on_success: :delete,
69
+ max_attempts: nil
70
+ )
71
+ queue_names = Array(queue_names)
72
+
73
+ CONTRACT.validate!(
74
+ {
75
+ queue_names: queue_names,
76
+ vt: vt,
77
+ batch_size: batch_size,
78
+ max_poll_seconds: max_poll_seconds,
79
+ poll_interval_ms: poll_interval_ms,
80
+ on_success: on_success,
81
+ max_attempts: max_attempts
82
+ },
83
+ Errors::ConfigurationError
84
+ )
85
+
86
+ @client = client
87
+ @queue_names = queue_names.map { |name| QueueName.validate!(name) }.freeze
88
+ @vt = vt
89
+ @batch_size = batch_size
90
+ @max_poll_seconds = max_poll_seconds
91
+ @poll_interval_ms = poll_interval_ms
92
+ @on_success = on_success
93
+ @max_attempts = max_attempts
94
+ @poll_offset = 0
95
+ @stopped = false
96
+ end
97
+
98
+ # Consumes messages until {#stop} is called
99
+ #
100
+ # Each message is acknowledged with the `on_success` action on its own queue after the block returns. An exception
101
+ # raised by the block stops the loop and is re-raised: that message is left to reappear after its visibility
102
+ # timeout, and the not yet yielded messages of the same batch are released at once. Messages read more than
103
+ # `max_attempts` times are archived without being yielded.
104
+ #
105
+ # {#stop} takes effect once the messages of the current poll are processed, or after the current poll (up to
106
+ # `max_poll_seconds`) when the queue is empty. Messages that were read are always yielded, so a stop never counts
107
+ # a read that was not processed.
108
+ #
109
+ # @yieldparam message [PGMQ::Message] message to process, with `queue_name` set
110
+ # @return [nil]
111
+ # @raise [ArgumentError] when no block is given
112
+ #
113
+ # @example
114
+ # consumer.each { |message| puts message.message }
115
+ def each
116
+ raise ArgumentError, "a block is required" unless block_given?
117
+
118
+ until @stopped
119
+ pending = poll
120
+
121
+ begin
122
+ until pending.empty?
123
+ message = pending.shift
124
+
125
+ if exhausted?(message)
126
+ @client.archive(message.queue_name, message.msg_id)
127
+ next
128
+ end
129
+
130
+ yield message
131
+
132
+ acknowledge(message)
133
+ end
134
+ ensure
135
+ release(pending)
136
+ end
137
+ end
138
+
139
+ nil
140
+ end
141
+
142
+ # Requests the loop in {#each} to finish
143
+ #
144
+ # Only sets a flag, so it is safe to call from a signal trap or from another thread. A stopped consumer stays
145
+ # stopped: further calls to {#each} return at once.
146
+ #
147
+ # @return [void]
148
+ def stop
149
+ @stopped = true
150
+ end
151
+
152
+ # @return [Boolean] true once {#stop} was called
153
+ def stopped?
154
+ @stopped
155
+ end
156
+
157
+ private
158
+
159
+ # Reads the next messages
160
+ #
161
+ # With several queues the read order rotates each poll, so a busy queue listed first does not starve the others.
162
+ #
163
+ # @return [Array<PGMQ::Message>] read messages, each with its `queue_name` set
164
+ def poll
165
+ if @queue_names.size == 1
166
+ queue_name = @queue_names.first
167
+
168
+ messages = @client.read_with_poll(
169
+ queue_name,
170
+ vt: @vt,
171
+ qty: @batch_size,
172
+ max_poll_seconds: @max_poll_seconds,
173
+ poll_interval_ms: @poll_interval_ms
174
+ )
175
+
176
+ return messages.map { |message| message.with(queue_name: queue_name) }
177
+ end
178
+
179
+ queue_names = @queue_names.rotate(@poll_offset)
180
+ @poll_offset = (@poll_offset + 1) % @queue_names.size
181
+
182
+ @client.read_multi_with_poll(
183
+ queue_names,
184
+ vt: @vt,
185
+ qty: @batch_size,
186
+ # A message released after a failure keeps its raised read_ct, so max_attempts allows one read per poll
187
+ limit: @max_attempts.nil? ? nil : 1,
188
+ max_poll_seconds: @max_poll_seconds,
189
+ poll_interval_ms: @poll_interval_ms
190
+ )
191
+ end
192
+
193
+ # @param message [PGMQ::Message] read message
194
+ # @return [Boolean] true when the message exceeded the allowed number of reads
195
+ def exhausted?(message)
196
+ !@max_attempts.nil? && message.read_ct.to_i > @max_attempts
197
+ end
198
+
199
+ # @param message [PGMQ::Message] processed message
200
+ def acknowledge(message)
201
+ if @on_success == :archive
202
+ @client.archive(message.queue_name, message.msg_id)
203
+ else
204
+ @client.delete(message.queue_name, message.msg_id)
205
+ end
206
+ end
207
+
208
+ # Makes read but not yielded messages visible again right away
209
+ #
210
+ # Failures are ignored so they never replace an error raised by the block; the messages then reappear after
211
+ # their visibility timeout.
212
+ #
213
+ # @param messages [Array<PGMQ::Message>] messages to release
214
+ def release(messages)
215
+ messages.group_by(&:queue_name).each do |queue_name, queue_messages|
216
+ @client.set_vt_batch(queue_name, queue_messages.map(&:msg_id), vt: 0)
217
+ rescue
218
+ nil
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ module Contractable
5
+ # Base contract for all the contracts that check data format
6
+ #
7
+ # @example
8
+ # class MyContract < PGMQ::Contractable::Contract
9
+ # self.error_messages = { "id_format" => "must be a String" }
10
+ #
11
+ # required(:id) { |id| id.is_a?(String) }
12
+ # end
13
+ #
14
+ # MyContract.new.validate!({ id: 1 }, PGMQ::Errors::ConfigurationError)
15
+ #
16
+ # @note This contract does NOT support rules inheritance.
17
+ class Contract
18
+ # Constant representing a miss during dig
19
+ # We use it as a result value not to return an array with found object and a state to prevent additional array
20
+ # allocation
21
+ DIG_MISS = Object.new
22
+
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.
25
+ EMPTY_ARRAY = [].freeze
26
+
27
+ private_constant :DIG_MISS, :EMPTY_ARRAY
28
+
29
+ # Class level API definitions
30
+ class << self
31
+ # @return [Array<Rule>] all the validation rules defined for a given contract
32
+ attr_reader :rules
33
+
34
+ # @return [Hash{String => String}] error messages keyed by "<path>_<error key>" or "<error key>"
35
+ attr_accessor :error_messages
36
+
37
+ # Allows for definition of a scope/namespace for nested validations
38
+ #
39
+ # @param path [Symbol] path in the hash for nesting
40
+ #
41
+ # @example
42
+ # nested(:key) do
43
+ # required(:inside) { |inside| inside.is_a?(String) }
44
+ # end
45
+ def nested(path, &)
46
+ init_accu
47
+ @nested << path
48
+ begin
49
+ instance_eval(&)
50
+ ensure
51
+ # Always pop, even if the block raises, so a rescued exception during contract definition does not
52
+ # prefix `path` onto every rule defined afterwards
53
+ @nested.pop
54
+ end
55
+ end
56
+
57
+ # Defines a rule for a required field (required means, that will automatically create an error if missing)
58
+ #
59
+ # @param keys [Array<Symbol>] single or full path
60
+ # @param block [Proc] validation rule
61
+ def required(*keys, &block)
62
+ init_accu
63
+ @rules << Rule.new(@nested + keys, :required, block).freeze
64
+ end
65
+
66
+ # Defines a rule for an optional field (no error when missing)
67
+ #
68
+ # @param keys [Array<Symbol>] single or full path
69
+ # @param block [Proc] validation rule
70
+ def optional(*keys, &block)
71
+ init_accu
72
+ @rules << Rule.new(@nested + keys, :optional, block).freeze
73
+ end
74
+
75
+ # Defines a virtual rule that validates the whole data rather than a single key. Unlike
76
+ # `required`/`optional`, the block receives the full data and returns its own errors.
77
+ #
78
+ # @param block [Proc] validation rule, called with `(data, errors, contract)`. It must return either a
79
+ # 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
+ #
82
+ # @note The returned error `Array` is owned by the contract: `#call` prepends the current scope onto each pair
83
+ # in place and collects them, so a rule must return a freshly built `Array` on every call.
84
+ def virtual(&block)
85
+ init_accu
86
+ @rules << Rule.new([], :virtual, block).freeze
87
+ end
88
+
89
+ private
90
+
91
+ # Initializes nestings and rules building accumulator
92
+ def init_accu
93
+ @nested ||= []
94
+ @rules ||= []
95
+ end
96
+ end
97
+
98
+ # Runs the validation
99
+ #
100
+ # @param data [Hash] hash with data we want to validate
101
+ # @param scope [Array<String, Integer>] scope of this contract (if any) or empty array if no parent scope is
102
+ # needed if contract starts from root
103
+ # @return [Result] validation result
104
+ def call(data, scope: EMPTY_ARRAY)
105
+ errors = []
106
+
107
+ # A non-Hash root has no keys to dig, so every required rule reports missing
108
+ data_is_hash = data.is_a?(Hash)
109
+
110
+ self.class.rules.each do |rule|
111
+ if rule.type == :virtual
112
+ result = rule.validator.call(data, errors, self)
113
+
114
+ # Any non-Array result (true, but also nil/false) means "no errors"
115
+ next unless result.is_a?(Array)
116
+
117
+ result.each do |sub_result|
118
+ sub_result[0] = scope + sub_result[0]
119
+ end
120
+
121
+ errors.push(*result)
122
+ else
123
+ for_checking = data_is_hash ? dig(data, rule.path) : DIG_MISS
124
+
125
+ if DIG_MISS.equal?(for_checking)
126
+ errors << [scope + rule.path, :missing] if rule.type == :required
127
+ else
128
+ result = rule.validator.call(for_checking, data, errors, self)
129
+
130
+ next if result == true
131
+
132
+ errors << [scope + rule.path, result || :format]
133
+ end
134
+ end
135
+ end
136
+
137
+ return Result.success if errors.empty?
138
+
139
+ Result.new(errors, self)
140
+ end
141
+
142
+ # Runs the validation like `#call`, but raises instead of returning a failed `Result`
143
+ #
144
+ # @param data [Hash] data for validation
145
+ # @param error_class [Class] error class that should be used when validation fails
146
+ # @param scope [Array<String, Integer>] scope of this contract (if any) or empty array if no parent scope is
147
+ # needed if contract starts from root
148
+ # @return [Boolean] true
149
+ # @raise [StandardError] any error provided in the error_class that inherits from the standard error
150
+ def validate!(data, error_class, scope: EMPTY_ARRAY)
151
+ result = call(data, scope: scope)
152
+
153
+ return true if result.success?
154
+
155
+ raise error_class, result.errors
156
+ end
157
+
158
+ private
159
+
160
+ # Tries to dig for a given key in a hash and returns it, or DIG_MISS when not found (dig returns nil and we
161
+ # don't know if it wasn't the digged key value)
162
+ #
163
+ # @param data [Hash]
164
+ # @param keys [Array<Symbol>]
165
+ # @return [DIG_MISS, Object] found element or DIG_MISS indicating that not found
166
+ def dig(data, keys)
167
+ case keys.length
168
+ when 1
169
+ data.fetch(keys[0], DIG_MISS)
170
+ when 2
171
+ mid = data.fetch(keys[0], DIG_MISS)
172
+
173
+ return DIG_MISS if DIG_MISS.equal?(mid)
174
+ return DIG_MISS unless mid.is_a?(Hash)
175
+
176
+ mid.fetch(keys[1], DIG_MISS)
177
+ else
178
+ current = data
179
+
180
+ keys.each do |nesting|
181
+ return DIG_MISS unless current.is_a?(Hash)
182
+
183
+ current = current.fetch(nesting, DIG_MISS)
184
+
185
+ return DIG_MISS if DIG_MISS.equal?(current)
186
+ end
187
+
188
+ current
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ module Contractable
5
+ # Representation of a validation result with resolved error messages
6
+ class Result
7
+ # @return [Hash{Symbol => String}] error messages keyed by the dot-joined path of the invalid value
8
+ attr_reader :errors
9
+
10
+ # Empty hash that we use when no errors to save on allocations
11
+ EMPTY_HASH = {}.freeze
12
+
13
+ private_constant :EMPTY_HASH
14
+
15
+ class << self
16
+ # @return [Result] cached frozen success result to avoid allocating a new Result on every successful contract
17
+ # validation. Safe because successful results are identical regardless of contract.
18
+ def success
19
+ @success ||= new([], nil).freeze
20
+ end
21
+ end
22
+
23
+ # Builds a result object and remaps (if needed) error keys to proper error messages
24
+ #
25
+ # @param errors [Array<Array>] array with sub-arrays with paths and error keys
26
+ # @param contract [Object] contract that generated the error
27
+ def initialize(errors, contract)
28
+ # Short track to skip object allocation for the happy path
29
+ if errors.empty?
30
+ @errors = EMPTY_HASH
31
+ return
32
+ end
33
+
34
+ hashed = {}
35
+
36
+ errors.each do |error|
37
+ scope = error.first.map(&:to_s).join(".").to_sym
38
+
39
+ # This will allow for usage of custom messages instead of yaml keys if needed
40
+ hashed[scope] = if error.last.is_a?(String)
41
+ error.last
42
+ else
43
+ build_message(contract, scope, error.last)
44
+ end
45
+ end
46
+
47
+ @errors = hashed
48
+ end
49
+
50
+ # @return [Boolean] true if no errors
51
+ def success?
52
+ errors.empty?
53
+ end
54
+
55
+ private
56
+
57
+ # Builds message based on the error messages and scope
58
+ #
59
+ # @param contract [Object] contract for which we build the result
60
+ # @param scope [Symbol] path to the key that has an error
61
+ # @param error_key [Symbol] error key for yaml errors lookup
62
+ # @return [String] error message
63
+ def build_message(contract, scope, error_key)
64
+ messages = contract.class.error_messages
65
+
66
+ scope_parts = scope.to_s.split(".")
67
+
68
+ # Try full scope first, then progressively remove from beginning, so dynamic scopes (for example a batch
69
+ # index in 'messages.3.message') fall back to the key's own message
70
+ (0..scope_parts.length).each do |i|
71
+ current_scope_parts = scope_parts[i..]
72
+
73
+ key = if current_scope_parts.empty?
74
+ error_key.to_s
75
+ else
76
+ "#{current_scope_parts.join(".")}_#{error_key}"
77
+ end
78
+
79
+ return messages[key] if messages.key?(key)
80
+ end
81
+
82
+ # If nothing found, raise the original error
83
+ messages.fetch(error_key.to_s) do
84
+ messages.fetch("#{scope}_#{error_key}")
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ module Contractable
5
+ # Representation of a single validation rule
6
+ Rule = Struct.new(:path, :type, :validator)
7
+ end
8
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ # Contract layer for validating user input before it reaches PostgreSQL
5
+ #
6
+ # Extracted from karafka-core's contractable layer, without its dependencies. It allows for nested validations,
7
+ # virtual rules and YAML based error messages.
8
+ #
9
+ # @note It is thread-safe to run but validations definitions should happen before threads are used.
10
+ module Contractable
11
+ end
12
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ module Contracts
5
+ # Validates the storage settings of {PGMQ::Client::Autovacuum#tune_autovacuum}
6
+ #
7
+ # Parameter names cannot be bound, so only the known names may reach the ALTER TABLE statement, and only values
8
+ # that coerce to a number.
9
+ class Autovacuum < Contractable::Contract
10
+ # Storage parameters that can be set: the union of the queue and archive default keys
11
+ STORAGE_PARAMS = (
12
+ Client::Autovacuum::DEFAULT_QUEUE_SETTINGS.keys + Client::Autovacuum::DEFAULT_ARCHIVE_SETTINGS.keys
13
+ ).uniq.freeze
14
+
15
+ # Storage parameters whose values are Floats; everything else is an Integer
16
+ FLOAT_SETTINGS = %i[
17
+ autovacuum_vacuum_scale_factor
18
+ autovacuum_analyze_scale_factor
19
+ ].freeze
20
+
21
+ self.error_messages = Contracts.error_messages("autovacuum")
22
+
23
+ %i[queue_settings archive_settings].each do |key|
24
+ required(key) do |settings|
25
+ next :format unless (settings.keys - STORAGE_PARAMS).empty?
26
+ next :values unless settings.all? { |name, value| numeric_setting?(name, value) }
27
+
28
+ true
29
+ end
30
+ end
31
+
32
+ class << self
33
+ # @param name [Symbol] storage parameter name
34
+ # @param value [Object] value to check
35
+ # @return [Boolean] true when the value coerces to the Float or Integer the parameter needs
36
+ def numeric_setting?(name, value)
37
+ if FLOAT_SETTINGS.include?(name)
38
+ !Float(value, exception: false).nil?
39
+ else
40
+ !Integer(value, exception: false).nil?
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ module Contracts
5
+ # Validates the messages and headers arrays of a batch produce
6
+ class Batch < Contractable::Contract
7
+ self.error_messages = Contracts.error_messages("batch")
8
+
9
+ required(:messages) { |messages| messages.is_a?(Array) }
10
+ optional(:headers) { |headers| headers.nil? || headers.is_a?(Array) }
11
+
12
+ virtual do |data, errors|
13
+ next true unless errors.empty?
14
+ next true if data[:headers].nil?
15
+ next true if data[:headers].length == data[:messages].length
16
+
17
+ [[[:headers], :length]]
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ module Contracts
5
+ # Validates the configuration of {PGMQ::Connection}
6
+ #
7
+ # Each key is optional, so one contract serves the constructor and the class-level settings.
8
+ class Connection < Contractable::Contract
9
+ self.error_messages = Contracts.error_messages("connection")
10
+
11
+ optional(:conn_params) do |conn_params|
12
+ next :empty if conn_params.nil?
13
+ next true if conn_params.respond_to?(:call) || conn_params.is_a?(String)
14
+
15
+ conn_params.is_a?(Hash) && !conn_params.empty?
16
+ end
17
+
18
+ optional(:reconnectable_error_patterns) do |patterns|
19
+ Array(patterns).all? { |pattern| pattern.is_a?(String) || pattern.is_a?(Regexp) }
20
+ end
21
+
22
+ optional(:reconnectable_error_classes) do |classes|
23
+ Array(classes).all? { |klass| klass.is_a?(Class) && klass <= Exception }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ module Contracts
5
+ # Validates the options of {PGMQ::Consumer}
6
+ #
7
+ # Queue names themselves are validated by {PGMQ::QueueName}.
8
+ class Consumer < Contractable::Contract
9
+ # Actions that can be taken on a processed message
10
+ ON_SUCCESS_ACTIONS = %i[delete archive].freeze
11
+
12
+ self.error_messages = Contracts.error_messages("consumer")
13
+
14
+ required(:queue_names) do |queue_names|
15
+ next :empty if queue_names.empty?
16
+ next :too_many if queue_names.size > MultiQueue::MAX_QUEUES
17
+ # An invalid name is reported by PGMQ::QueueName.validate! with its own error class
18
+ next true unless queue_names.all? { |name| QueueName.valid?(name) }
19
+ # pgmq folds queue names to lower case, so names differing only in case refer to the same queue
20
+ next :unique unless queue_names.map { |name| name.to_s.downcase }.uniq.size == queue_names.size
21
+
22
+ true
23
+ end
24
+
25
+ required(:on_success) { |on_success| ON_SUCCESS_ACTIONS.include?(on_success) }
26
+
27
+ %i[vt batch_size max_poll_seconds poll_interval_ms].each do |key|
28
+ required(key) { |value| value.is_a?(Integer) && value.positive? }
29
+ end
30
+
31
+ optional(:max_attempts) do |max_attempts|
32
+ max_attempts.nil? || (max_attempts.is_a?(Integer) && max_attempts.positive?)
33
+ end
34
+
35
+ # Every read raises read_ct, so with larger batches the messages released after a failure would have their
36
+ # unprocessed read counted as a failed attempt
37
+ virtual do |data, errors|
38
+ next true unless errors.empty?
39
+ next true if data[:max_attempts].nil? || data[:batch_size] == 1
40
+
41
+ [[[:max_attempts], :batch_size]]
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGMQ
4
+ module Contracts
5
+ # Validates a message and its headers before they are bound
6
+ #
7
+ # The pg gem reads a Hash bind parameter as a parameter spec and binds `nil` as SQL NULL, so a non-String value
8
+ # could be stored as NULL without an error.
9
+ class Message < Contractable::Contract
10
+ self.error_messages = Contracts.error_messages("message")
11
+
12
+ required(:message) { |message| message.is_a?(String) }
13
+ optional(:headers) { |headers| headers.nil? || headers.is_a?(String) }
14
+ end
15
+ end
16
+ end