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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/README.md +109 -6
- data/config/locales/errors.yml +46 -0
- data/lib/pgmq/client/autovacuum.rb +22 -33
- data/lib/pgmq/client/consumer.rb +2 -2
- data/lib/pgmq/client/maintenance.rb +6 -0
- data/lib/pgmq/client/message_lifecycle.rb +8 -5
- data/lib/pgmq/client/multi_queue.rb +13 -13
- data/lib/pgmq/client/producer.rb +32 -41
- data/lib/pgmq/client/queue_management.rb +25 -5
- data/lib/pgmq/client/topics.rb +23 -28
- data/lib/pgmq/client.rb +33 -1
- data/lib/pgmq/connection.rb +22 -46
- data/lib/pgmq/consumer.rb +222 -0
- data/lib/pgmq/contractable/contract.rb +193 -0
- data/lib/pgmq/contractable/result.rb +89 -0
- data/lib/pgmq/contractable/rule.rb +8 -0
- data/lib/pgmq/contractable.rb +12 -0
- data/lib/pgmq/contracts/autovacuum.rb +46 -0
- data/lib/pgmq/contracts/batch.rb +21 -0
- data/lib/pgmq/contracts/connection.rb +27 -0
- data/lib/pgmq/contracts/consumer.rb +45 -0
- data/lib/pgmq/contracts/message.rb +16 -0
- data/lib/pgmq/contracts/multi_queue.rb +28 -0
- data/lib/pgmq/contracts.rb +16 -0
- data/lib/pgmq/errors.rb +27 -1
- data/lib/pgmq/metrics.rb +11 -2
- data/lib/pgmq/transaction.rb +50 -3
- data/lib/pgmq/version.rb +1 -1
- data/lib/pgmq.rb +8 -1
- data/pgmq-ruby.gemspec +1 -1
- metadata +15 -2
|
@@ -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,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
|