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.
@@ -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
@@ -22,6 +22,7 @@ module CMDx
22
22
  complex: Coercions::Complex,
23
23
  date: Coercions::Date,
24
24
  date_time: Coercions::DateTime,
25
+ decimal: Coercions::BigDecimal,
25
26
  float: Coercions::Float,
26
27
  hash: Coercions::Hash,
27
28
  integer: Coercions::Integer,
@@ -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"
@@ -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