cmdx 2.1.1 → 2.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/CHANGELOG.md +20 -1
- data/TODO.md +353 -0
- data/lib/cmdx/callbacks.rb +38 -7
- data/lib/cmdx/circuit_breaker.rb +234 -0
- data/lib/cmdx/coercions.rb +1 -0
- data/lib/cmdx/configuration.rb +2 -1
- data/lib/cmdx/deprecation.rb +2 -2
- data/lib/cmdx/fault.rb +38 -0
- data/lib/cmdx/idempotency.rb +172 -0
- data/lib/cmdx/input.rb +12 -2
- data/lib/cmdx/inputs.rb +4 -0
- data/lib/cmdx/lock.rb +191 -0
- data/lib/cmdx/pipeline.rb +1 -9
- data/lib/cmdx/retry.rb +20 -2
- data/lib/cmdx/runtime.rb +44 -20
- data/lib/cmdx/store_key.rb +47 -0
- data/lib/cmdx/stores/memory.rb +155 -0
- data/lib/cmdx/stores/redis.rb +111 -0
- data/lib/cmdx/stores.rb +111 -0
- data/lib/cmdx/task.rb +118 -8
- data/lib/cmdx/telemetry.rb +34 -4
- data/lib/cmdx/util.rb +7 -0
- data/lib/cmdx/version.rb +1 -1
- data/lib/cmdx.rb +11 -0
- data/lib/generators/cmdx/templates/install.rb +27 -8
- data/mkdocs.yml +8 -1
- metadata +10 -2
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# Declared via {Task.circuit_breaker}. After inputs resolve, consults shared
|
|
5
|
+
# store counters before the `retry_on` loop; opens after repeated failures
|
|
6
|
+
# and short-circuits until `cool_off` elapses.
|
|
7
|
+
#
|
|
8
|
+
# Store keys are `namespace:TaskName` or `namespace:TaskName:segment`, with
|
|
9
|
+
# `:failures` and `:open` suffix segments (see {StoreKey.compose}).
|
|
10
|
+
class CircuitBreaker
|
|
11
|
+
|
|
12
|
+
OPEN = "open"
|
|
13
|
+
FAILURES = "failures"
|
|
14
|
+
CLASS_KEY = :class
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
|
|
18
|
+
# @return [CircuitBreaker] disabled configuration
|
|
19
|
+
def disabled
|
|
20
|
+
DISABLED
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :key
|
|
26
|
+
|
|
27
|
+
# @param key [Symbol, String, Proc, #call, nil] `:class` (default when
|
|
28
|
+
# enabling via options only), context `Symbol`, static `String`, or
|
|
29
|
+
# callable; `nil` disables the breaker
|
|
30
|
+
# @param options [Hash{Symbol => Object}]
|
|
31
|
+
# @option options [Integer] :threshold (5) failures before the circuit opens
|
|
32
|
+
# @option options [Integer] :cool_off (30) seconds the circuit stays open
|
|
33
|
+
# @option options [Integer] :window (60) sliding window for the failure counter
|
|
34
|
+
# @option options [String] :namespace ("cmdx:cb") first segment of store keys
|
|
35
|
+
# @option options [Symbol] :on_open (:fail) `:fail` or `:skip` when open
|
|
36
|
+
# @option options [Array<Class>] :on (`[StandardError]`) exception classes that
|
|
37
|
+
# increment the failure counter
|
|
38
|
+
# @option options [Symbol, Proc, #call] :if gate `(task)` before checking
|
|
39
|
+
# @option options [Symbol, Proc, #call] :unless gate `(task)` before checking
|
|
40
|
+
def initialize(key, options = EMPTY_HASH)
|
|
41
|
+
@key = key
|
|
42
|
+
@options = options.freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String]
|
|
46
|
+
def namespace
|
|
47
|
+
@options[:namespace] || "cmdx:cb"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Integer]
|
|
51
|
+
def threshold
|
|
52
|
+
@options[:threshold] || 5
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Integer]
|
|
56
|
+
def cool_off
|
|
57
|
+
@options[:cool_off] || 30
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Integer]
|
|
61
|
+
def window
|
|
62
|
+
@options[:window] || 60
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Array<Class>]
|
|
66
|
+
def on_exceptions
|
|
67
|
+
@options.fetch(:on) { [StandardError] }.flatten
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Symbol] `:fail` or `:skip`
|
|
71
|
+
def on_open
|
|
72
|
+
@options[:on_open] || :fail
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Symbol, Proc, #call, nil]
|
|
76
|
+
def condition_if
|
|
77
|
+
@options[:if]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Symbol, Proc, #call, nil]
|
|
81
|
+
def condition_unless
|
|
82
|
+
@options[:unless]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
def enabled?
|
|
87
|
+
!key.nil?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns a new CircuitBreaker layering `new_options` onto the current one.
|
|
91
|
+
# When `new_key` is non-nil it replaces the resolver; when `new_key` is
|
|
92
|
+
# `nil` and only options change, the existing resolver is kept. When the
|
|
93
|
+
# breaker was disabled and only options are passed, the resolver becomes
|
|
94
|
+
# `:class`.
|
|
95
|
+
#
|
|
96
|
+
# @param new_key [Symbol, String, Proc, #call, nil]
|
|
97
|
+
# @param new_options [Hash{Symbol => Object}]
|
|
98
|
+
# @return [CircuitBreaker]
|
|
99
|
+
def build(new_key, new_options)
|
|
100
|
+
return self if new_key.nil? && new_options.empty?
|
|
101
|
+
|
|
102
|
+
merged_key =
|
|
103
|
+
if new_key.nil?
|
|
104
|
+
enabled? || new_options.empty? ? @key : CLASS_KEY
|
|
105
|
+
else
|
|
106
|
+
new_key
|
|
107
|
+
end
|
|
108
|
+
merged_options = @options.merge(new_options)
|
|
109
|
+
|
|
110
|
+
self.class.new(merged_key, merged_options)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Runs the given block when the circuit is closed (or inactive). Records
|
|
114
|
+
# failures for configured exception classes and clears the failure counter
|
|
115
|
+
# after a successful run.
|
|
116
|
+
#
|
|
117
|
+
# @param task [Task]
|
|
118
|
+
# @yield invoked when the circuit is closed or the breaker is inactive
|
|
119
|
+
# @yieldreturn [Object] return value from the wrapped work
|
|
120
|
+
# @return [Object] the block's return value
|
|
121
|
+
# @note When the circuit is open, signals `fail!` or `skip!` on `task` —
|
|
122
|
+
# control does not return to the caller of {#process}.
|
|
123
|
+
# @raise [ArgumentError] when `on_open` is not `:fail` or `:skip`
|
|
124
|
+
def process(task)
|
|
125
|
+
return yield unless enabled?
|
|
126
|
+
return yield unless Util.satisfied?(condition_if, condition_unless, task)
|
|
127
|
+
|
|
128
|
+
base = store_base(task)
|
|
129
|
+
return yield if base.nil?
|
|
130
|
+
|
|
131
|
+
open_key = StoreKey.compose(base, OPEN)
|
|
132
|
+
failures_key = StoreKey.compose(base, FAILURES)
|
|
133
|
+
open!(task, open_key) if circuit_open?(open_key)
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
yield
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
record_failure(e, failures_key, open_key, task)
|
|
139
|
+
raise e
|
|
140
|
+
else
|
|
141
|
+
clear_failures(failures_key, task)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def store_base(task)
|
|
148
|
+
segment = StoreKey.segment(task, key, skip: CLASS_KEY)
|
|
149
|
+
|
|
150
|
+
if segment.nil?
|
|
151
|
+
return StoreKey.compose(namespace, task.class.name) if key == CLASS_KEY
|
|
152
|
+
|
|
153
|
+
return nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
StoreKey.compose(namespace, task.class.name, segment)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def circuit_open?(open_key)
|
|
160
|
+
Stores.get(open_key)
|
|
161
|
+
rescue StandardError
|
|
162
|
+
false
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def record_failure(exception, failures_key, open_key, task)
|
|
166
|
+
return unless counts_as_failure?(exception)
|
|
167
|
+
|
|
168
|
+
trip_open(failures_key, open_key, task)
|
|
169
|
+
rescue StandardError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def clear_failures(failures_key, task)
|
|
174
|
+
cleared = Stores.del(failures_key)
|
|
175
|
+
Telemetry.emit(task, :circuit_reset, failures_key:) if cleared
|
|
176
|
+
rescue StandardError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def counts_as_failure?(exception)
|
|
181
|
+
list = on_exceptions
|
|
182
|
+
return false if list.empty?
|
|
183
|
+
|
|
184
|
+
list.any? { |klass| exception.is_a?(klass) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def trip_open(failures_key, open_key, task)
|
|
188
|
+
count = Stores.incr(failures_key, ttl: window)
|
|
189
|
+
return unless count >= threshold
|
|
190
|
+
|
|
191
|
+
Stores.set(open_key, OPEN, ttl: cool_off)
|
|
192
|
+
Stores.del(failures_key)
|
|
193
|
+
|
|
194
|
+
Telemetry.emit(
|
|
195
|
+
task,
|
|
196
|
+
:circuit_tripped,
|
|
197
|
+
circuit_breaker_key: open_key,
|
|
198
|
+
failures_key:,
|
|
199
|
+
failure_count: count,
|
|
200
|
+
threshold:,
|
|
201
|
+
cool_off:
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def open!(task, open_key)
|
|
206
|
+
message = "circuit open"
|
|
207
|
+
metadata = {
|
|
208
|
+
code: :circuit_open,
|
|
209
|
+
circuit_breaker_key: open_key
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
remaining = Stores.remaining_ttl(open_key)
|
|
213
|
+
metadata[:retry_after] = remaining if remaining
|
|
214
|
+
|
|
215
|
+
Telemetry.emit(task, :circuit_open, **metadata, on_open:)
|
|
216
|
+
|
|
217
|
+
case on_open
|
|
218
|
+
when :fail
|
|
219
|
+
task.fail!(message, **metadata)
|
|
220
|
+
when :skip
|
|
221
|
+
task.skip!(message, **metadata)
|
|
222
|
+
else
|
|
223
|
+
raise ArgumentError, <<~MSG.chomp
|
|
224
|
+
circuit_breaker on_open must be :fail or :skip (got #{on_open.inspect}).
|
|
225
|
+
See https://drexed.github.io/cmdx/circuit_breaker/
|
|
226
|
+
MSG
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
DISABLED = new(nil).freeze
|
|
231
|
+
private_constant :DISABLED
|
|
232
|
+
|
|
233
|
+
end
|
|
234
|
+
end
|
data/lib/cmdx/coercions.rb
CHANGED
data/lib/cmdx/configuration.rb
CHANGED
|
@@ -10,7 +10,7 @@ module CMDx
|
|
|
10
10
|
class Configuration
|
|
11
11
|
|
|
12
12
|
attr_accessor :middlewares, :callbacks, :coercions, :validators, :executors,
|
|
13
|
-
:mergers, :retriers, :deprecators, :telemetry, :correlation_id, :default_locale,
|
|
13
|
+
:mergers, :retriers, :deprecators, :telemetry, :store, :correlation_id, :default_locale,
|
|
14
14
|
:strict_context, :backtrace_cleaner, :log_exclusions, :log_formatter,
|
|
15
15
|
:log_level, :logger
|
|
16
16
|
|
|
@@ -24,6 +24,7 @@ module CMDx
|
|
|
24
24
|
@retriers = Retriers.new
|
|
25
25
|
@deprecators = Deprecators.new
|
|
26
26
|
@telemetry = Telemetry.new
|
|
27
|
+
@store = Stores::Memory.new
|
|
27
28
|
|
|
28
29
|
@correlation_id = nil
|
|
29
30
|
@default_locale = "en"
|
data/lib/cmdx/deprecation.rb
CHANGED
|
@@ -25,14 +25,14 @@ module CMDx
|
|
|
25
25
|
# @raise [DeprecationError] when `value` is `:error`
|
|
26
26
|
# @raise [ArgumentError] when `value` is an unsupported type
|
|
27
27
|
def execute(task)
|
|
28
|
-
return if @value.nil?
|
|
29
|
-
return unless Util.satisfied?(@options[:if], @options[:unless], task)
|
|
28
|
+
return if @value.nil? || !Util.satisfied?(@options[:if], @options[:unless], task)
|
|
30
29
|
|
|
31
30
|
yield
|
|
32
31
|
|
|
33
32
|
case @value
|
|
34
33
|
when Symbol
|
|
35
34
|
registry = deprecators_registry(task)
|
|
35
|
+
|
|
36
36
|
if registry.key?(@value)
|
|
37
37
|
registry.lookup(@value).call(task)
|
|
38
38
|
else
|
data/lib/cmdx/fault.rb
CHANGED
|
@@ -124,5 +124,43 @@ module CMDx
|
|
|
124
124
|
@result.chain
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
# Enriches the stdlib detailed message with validation errors and
|
|
128
|
+
# propagation context for IRB and other debug formatters. {#message} is
|
|
129
|
+
# unchanged.
|
|
130
|
+
#
|
|
131
|
+
# @param highlight [Boolean] forwarded to `Exception#detailed_message`
|
|
132
|
+
# @param order [Symbol] forwarded to `Exception#detailed_message`
|
|
133
|
+
# @return [String]
|
|
134
|
+
def detailed_message(highlight: true, order: :top)
|
|
135
|
+
lines = [super]
|
|
136
|
+
append_errors_lines(lines)
|
|
137
|
+
append_origin_line(lines)
|
|
138
|
+
lines.join("\n")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [String] human-readable representation
|
|
142
|
+
def inspect
|
|
143
|
+
"#<#{self.class} task=#{task.name} tid=#{result.tid} cid=#{result.cid} xid=#{result.xid} reason=#{result.reason.inspect} errors=#{result.errors.to_h}>"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def append_errors_lines(lines)
|
|
149
|
+
return if result.errors.empty?
|
|
150
|
+
|
|
151
|
+
lines << " errors:"
|
|
152
|
+
result.errors.each do |key, messages|
|
|
153
|
+
messages.each { |message| lines << " #{key}: #{message}" }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def append_origin_line(lines)
|
|
158
|
+
origin = result.origin
|
|
159
|
+
return unless origin
|
|
160
|
+
|
|
161
|
+
reason = origin.reason || "no reason"
|
|
162
|
+
lines << " origin: #{origin.task.name} (#{reason})"
|
|
163
|
+
end
|
|
164
|
+
|
|
127
165
|
end
|
|
128
166
|
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# Declared via {Task.idempotent_by}. After inputs resolve, claims a shared
|
|
5
|
+
# store slot and runs the `retry_on` loop inside that claim so duplicate
|
|
6
|
+
# invocations with the same logical key short-circuit before `work` repeats.
|
|
7
|
+
#
|
|
8
|
+
# Store keys are `namespace:TaskName:segment` (see {StoreKey.compose}). Values
|
|
9
|
+
# move `processing` → `completed` after the wrapped block returns (including
|
|
10
|
+
# output verification when invoked from {Runtime}); the key is deleted on
|
|
11
|
+
# `StandardError` or non-success signals (`skip!` / `fail!`) so retries can
|
|
12
|
+
# reclaim.
|
|
13
|
+
class Idempotency
|
|
14
|
+
|
|
15
|
+
PROCESSING = "processing"
|
|
16
|
+
COMPLETED = "completed"
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
|
|
20
|
+
# @return [Idempotency] disabled configuration (no key)
|
|
21
|
+
def disabled
|
|
22
|
+
DISABLED
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :key
|
|
28
|
+
|
|
29
|
+
# @param key [Symbol, String, Proc, #call, nil] segment resolved per task;
|
|
30
|
+
# `nil` disables idempotency. Symbols read from `context`; `Proc` /
|
|
31
|
+
# blocks are `instance_exec`'d on the task (with `task` as the first
|
|
32
|
+
# block argument, same as callbacks); other `#call`ables receive the
|
|
33
|
+
# task. Blank segments skip claiming.
|
|
34
|
+
# @param options [Hash{Symbol => Object}]
|
|
35
|
+
# @option options [Integer] :ttl (300) expiry in seconds for store entries
|
|
36
|
+
# @option options [String] :namespace ("cmdx:idem") first segment of the store key
|
|
37
|
+
# @option options [Symbol] :on_duplicate (:skip) `:skip` or `:fail` when the
|
|
38
|
+
# slot is already claimed
|
|
39
|
+
# @option options [Symbol, Proc, #call] :if gate `(task)` for claiming
|
|
40
|
+
# @option options [Symbol, Proc, #call] :unless gate `(task)` for claiming
|
|
41
|
+
def initialize(key, options = EMPTY_HASH)
|
|
42
|
+
@key = key
|
|
43
|
+
@options = options.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [String]
|
|
47
|
+
def namespace
|
|
48
|
+
@options[:namespace] || "cmdx:idem"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Symbol] `:skip` or `:fail`
|
|
52
|
+
def on_duplicate
|
|
53
|
+
@options[:on_duplicate] || :skip
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Integer]
|
|
57
|
+
def ttl
|
|
58
|
+
@options[:ttl] || 300
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Symbol, Proc, #call, nil]
|
|
62
|
+
def condition_if
|
|
63
|
+
@options[:if]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Symbol, Proc, #call, nil]
|
|
67
|
+
def condition_unless
|
|
68
|
+
@options[:unless]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def enabled?
|
|
73
|
+
!key.nil?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns a new Idempotency layering `new_options` onto the current one.
|
|
77
|
+
# When `new_key` is non-nil it replaces the resolver; when `new_key` is
|
|
78
|
+
# `nil` and only options change, the existing resolver is kept (option-only
|
|
79
|
+
# updates such as `idempotent_by ttl: 600`).
|
|
80
|
+
#
|
|
81
|
+
# @param new_key [Symbol, String, Proc, #call, nil]
|
|
82
|
+
# @param new_options [Hash{Symbol => Object}]
|
|
83
|
+
# @return [Idempotency]
|
|
84
|
+
def build(new_key, new_options)
|
|
85
|
+
return self if new_key.nil? && new_options.empty?
|
|
86
|
+
|
|
87
|
+
merged_key = new_key.nil? ? @key : new_key
|
|
88
|
+
merged_options = @options.merge(new_options)
|
|
89
|
+
|
|
90
|
+
self.class.new(merged_key, merged_options)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Runs the given block behind a store claim when {#enabled?} and gates pass.
|
|
94
|
+
# Releases the claim on `StandardError` or a non-returning exit (`skip!`,
|
|
95
|
+
# `fail!`) so `retry_on` can run `work` again; marks the slot `completed`
|
|
96
|
+
# after the block returns successfully.
|
|
97
|
+
#
|
|
98
|
+
# @param task [Task]
|
|
99
|
+
# @yield invoked when the claim succeeds or idempotency is inactive
|
|
100
|
+
# @yieldreturn [Object] return value from the wrapped work (ignored by callers)
|
|
101
|
+
# @return [Object] the block's return value
|
|
102
|
+
# @note On duplicate keys, signals `skip!` or `fail!` on `task` — control
|
|
103
|
+
# does not return to the caller of {#process}.
|
|
104
|
+
# @raise [ArgumentError] when `on_duplicate` is not `:skip` or `:fail`
|
|
105
|
+
# @raise [IdempotencyError] when the store refuses the `completed` transition
|
|
106
|
+
def process(task)
|
|
107
|
+
return yield unless enabled?
|
|
108
|
+
return yield unless Util.satisfied?(condition_if, condition_unless, task)
|
|
109
|
+
|
|
110
|
+
segment = StoreKey.segment(task, key)
|
|
111
|
+
return yield if segment.nil?
|
|
112
|
+
|
|
113
|
+
store_key = StoreKey.compose(namespace, task.class.name, segment)
|
|
114
|
+
|
|
115
|
+
if Stores.set(store_key, PROCESSING, nx: true, ttl:)
|
|
116
|
+
Telemetry.emit(task, :idempotency_claimed, idempotency_key: store_key)
|
|
117
|
+
|
|
118
|
+
succeeded = false
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
yield
|
|
122
|
+
succeeded = true
|
|
123
|
+
rescue StandardError
|
|
124
|
+
Stores.del(store_key)
|
|
125
|
+
raise
|
|
126
|
+
ensure
|
|
127
|
+
Stores.del(store_key) unless succeeded
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
mark_completed!(store_key, task)
|
|
131
|
+
else
|
|
132
|
+
duplicate!(task, store_key)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def mark_completed!(store_key, task)
|
|
139
|
+
if Stores.set(store_key, COMPLETED, xx: true, ttl:)
|
|
140
|
+
Telemetry.emit(task, :idempotency_completed, idempotency_key: store_key)
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
Stores.del(store_key)
|
|
145
|
+
raise IdempotencyError, "failed to mark idempotency key completed (#{store_key})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def duplicate!(task, store_key)
|
|
149
|
+
state = Stores.get(store_key)
|
|
150
|
+
message = "duplicate request (state=#{state})"
|
|
151
|
+
metadata = { code: :duplicate, idempotency_key: store_key, state: }
|
|
152
|
+
|
|
153
|
+
Telemetry.emit(task, :idempotency_duplicate, **metadata)
|
|
154
|
+
|
|
155
|
+
case on_duplicate
|
|
156
|
+
when :fail
|
|
157
|
+
task.fail!(message, **metadata)
|
|
158
|
+
when :skip
|
|
159
|
+
task.skip!(message, **metadata)
|
|
160
|
+
else
|
|
161
|
+
raise ArgumentError, <<~MSG.chomp
|
|
162
|
+
idempotent_by on_duplicate must be :skip or :fail (got #{on_duplicate.inspect}).
|
|
163
|
+
See https://drexed.github.io/cmdx/idempotency/
|
|
164
|
+
MSG
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
DISABLED = new(nil).freeze
|
|
169
|
+
private_constant :DISABLED
|
|
170
|
+
|
|
171
|
+
end
|
|
172
|
+
end
|
data/lib/cmdx/input.rb
CHANGED
|
@@ -22,6 +22,7 @@ module CMDx
|
|
|
22
22
|
# @option options [Symbol, Proc, #call] :if
|
|
23
23
|
# @option options [Symbol, Proc, #call] :unless
|
|
24
24
|
# @option options [Boolean] :required
|
|
25
|
+
# @option options [Boolean] :allow_nil (see {#allow_nil})
|
|
25
26
|
def initialize(name, children: EMPTY_ARRAY, **options)
|
|
26
27
|
@name = name.to_sym
|
|
27
28
|
@children = children.freeze
|
|
@@ -78,6 +79,14 @@ module CMDx
|
|
|
78
79
|
@options.fetch(:required, false)
|
|
79
80
|
end
|
|
80
81
|
|
|
82
|
+
# When `true`, a `nil` value after defaults skips coercion, transform, and
|
|
83
|
+
# validation. Per-validator `:allow_nil` only skips individual rules.
|
|
84
|
+
#
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
def allow_nil
|
|
87
|
+
@options.fetch(:allow_nil, false)
|
|
88
|
+
end
|
|
89
|
+
|
|
81
90
|
# Computed accessor/reader method name. Uses `:as` when provided,
|
|
82
91
|
# otherwise combines `:prefix`, `name`, and `:suffix` around the source.
|
|
83
92
|
#
|
|
@@ -131,7 +140,8 @@ module CMDx
|
|
|
131
140
|
# @note "Required" here means "the key is present in the source"; an
|
|
132
141
|
# explicit `nil` under an existing key satisfies the required check
|
|
133
142
|
# and is then routed through `:default`. Combine with `:presence` /
|
|
134
|
-
# `:validate` to reject explicit `nil` values
|
|
143
|
+
# `:validate` to reject explicit `nil` values, or `:allow_nil` (see
|
|
144
|
+
# {#allow_nil}) to leave `nil` unresolved.
|
|
135
145
|
#
|
|
136
146
|
# @param task [Task]
|
|
137
147
|
# @return [Object, nil] the resolved value (`nil` on failure)
|
|
@@ -189,7 +199,7 @@ module CMDx
|
|
|
189
199
|
end
|
|
190
200
|
|
|
191
201
|
value = apply_default(task) if value.nil?
|
|
192
|
-
return if value.nil? && !key_provided
|
|
202
|
+
return if value.nil? && (!key_provided || allow_nil)
|
|
193
203
|
|
|
194
204
|
@coercions ||= task.class.coercions.extract(@options)
|
|
195
205
|
value = task.class.coercions.coerce(task, accessor_name, value, @coercions)
|
data/lib/cmdx/inputs.rb
CHANGED
|
@@ -35,6 +35,7 @@ module CMDx
|
|
|
35
35
|
# @option options [Symbol, Proc, #call] :if
|
|
36
36
|
# @option options [Symbol, Proc, #call] :unless
|
|
37
37
|
# @option options [Boolean] :required
|
|
38
|
+
# @option options [Boolean] :allow_nil (see {Input#allow_nil})
|
|
38
39
|
# @option options [Object] :coerce forwarded with declaration (see {Coercions#extract})
|
|
39
40
|
# @option options [Object] :validate forwarded with declaration (see {Validators#extract})
|
|
40
41
|
# @return [Inputs] self for chaining
|
|
@@ -138,6 +139,7 @@ module CMDx
|
|
|
138
139
|
# @option options [Symbol, Proc, #call] :if
|
|
139
140
|
# @option options [Symbol, Proc, #call] :unless
|
|
140
141
|
# @option options [Boolean] :required
|
|
142
|
+
# @option options [Boolean] :allow_nil (see {Input#allow_nil})
|
|
141
143
|
# @option options [Object] :coerce forwarded with declaration (see {Coercions#extract})
|
|
142
144
|
# @option options [Object] :validate forwarded with declaration (see {Validators#extract})
|
|
143
145
|
# @yield nested child input DSL
|
|
@@ -162,6 +164,7 @@ module CMDx
|
|
|
162
164
|
# @option options [Symbol, Proc, #call] :transform mutator applied after coercion
|
|
163
165
|
# @option options [Symbol, Proc, #call] :if
|
|
164
166
|
# @option options [Symbol, Proc, #call] :unless
|
|
167
|
+
# @option options [Boolean] :allow_nil (see {Input#allow_nil})
|
|
165
168
|
# @option options [Object] :coerce forwarded with declaration (see {Coercions#extract})
|
|
166
169
|
# @option options [Object] :validate forwarded with declaration (see {Validators#extract})
|
|
167
170
|
# @yield nested child input DSL
|
|
@@ -185,6 +188,7 @@ module CMDx
|
|
|
185
188
|
# @option options [Symbol, Proc, #call] :transform mutator applied after coercion
|
|
186
189
|
# @option options [Symbol, Proc, #call] :if
|
|
187
190
|
# @option options [Symbol, Proc, #call] :unless
|
|
191
|
+
# @option options [Boolean] :allow_nil (see {Input#allow_nil})
|
|
188
192
|
# @option options [Object] :coerce forwarded with declaration (see {Coercions#extract})
|
|
189
193
|
# @option options [Object] :validate forwarded with declaration (see {Validators#extract})
|
|
190
194
|
# @yield nested child input DSL
|