cmdx 2.2.0 → 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,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/lock.rb ADDED
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Declared via {Task.lock_with}. After inputs resolve, acquires a store lease
5
+ # (`SET` with `nx: true`) and runs idempotency (when declared), the full
6
+ # `retry_on` loop, and output verification inside that lease.
7
+ #
8
+ # Store keys are `namespace:TaskName:segment` (see {StoreKey.compose}). The
9
+ # stored value is the holding task's `tid`. Release uses a conditional delete so
10
+ # a late releaser cannot drop a newer holder's lease after TTL expiry.
11
+ #
12
+ # After a successful acquire, the lease is released in `ensure` for any exit from
13
+ # the wrapped block (success, `skip!` / `fail!`, or uncaught error). The lease
14
+ # is not released between in-process `retry_on` attempts.
15
+ class Lock
16
+
17
+ class << self
18
+
19
+ # @return [Lock] disabled configuration (no key)
20
+ def disabled
21
+ DISABLED
22
+ end
23
+
24
+ end
25
+
26
+ attr_reader :key
27
+
28
+ # @param key [Symbol, String, Proc, #call, nil] segment resolved per task;
29
+ # `nil` disables locking. Symbols read from `context`; `Proc` / blocks are
30
+ # `instance_exec`'d on the task (with `task` as the first block argument,
31
+ # same as callbacks); other `#call`ables receive the task. Blank segments
32
+ # skip acquiring.
33
+ # @param options [Hash{Symbol => Object}]
34
+ # @option options [Integer] :ttl (300) lease expiry in seconds when the holder
35
+ # does not release; size for worst-case hold time including the retry loop
36
+ # @option options [String] :namespace ("cmdx:lock") first segment of the store key
37
+ # @option options [Numeric, false, nil] :wait (0) max seconds to wait for the
38
+ # lease; `0`, `nil`, or `false` try once
39
+ # @option options [Symbol] :on_busy (:fail) `:skip` or `:fail` when the lease
40
+ # cannot be acquired within `:wait`
41
+ # @option options [Symbol, Proc, #call] :if gate `(task)` for acquiring
42
+ # @option options [Symbol, Proc, #call] :unless gate `(task)` for acquiring
43
+ def initialize(key, options = EMPTY_HASH)
44
+ @key = key
45
+ @options = options.freeze
46
+ end
47
+
48
+ # @return [String]
49
+ def namespace
50
+ @options[:namespace] || "cmdx:lock"
51
+ end
52
+
53
+ # @return [Integer]
54
+ def ttl
55
+ @options[:ttl] || 300
56
+ end
57
+
58
+ # @return [Float]
59
+ def wait
60
+ raw = @options[:wait]
61
+ return 0.0 if raw.nil? || raw == false
62
+
63
+ Float(raw)
64
+ end
65
+
66
+ # @return [Symbol] `:skip` or `:fail`
67
+ def on_busy
68
+ @options[:on_busy] || :fail
69
+ end
70
+
71
+ # @return [Symbol, Proc, #call, nil]
72
+ def condition_if
73
+ @options[:if]
74
+ end
75
+
76
+ # @return [Symbol, Proc, #call, nil]
77
+ def condition_unless
78
+ @options[:unless]
79
+ end
80
+
81
+ # @return [Boolean]
82
+ def enabled?
83
+ !key.nil?
84
+ end
85
+
86
+ # Returns a new Lock layering `new_options` onto the current one.
87
+ #
88
+ # @param new_key [Symbol, String, Proc, #call, nil]
89
+ # @param new_options [Hash{Symbol => Object}]
90
+ # @return [Lock]
91
+ def build(new_key, new_options)
92
+ return self if new_key.nil? && new_options.empty?
93
+
94
+ merged_key = new_key.nil? ? @key : new_key
95
+ merged_options = @options.merge(new_options)
96
+
97
+ self.class.new(merged_key, merged_options)
98
+ end
99
+
100
+ # Runs the given block behind a store lease when {#enabled?} and gates pass.
101
+ #
102
+ # @param task [Task]
103
+ # @yield invoked when the lease is acquired or locking is inactive
104
+ # @yieldreturn [Object] return value from the wrapped work
105
+ # @return [Object] the block's return value
106
+ # @note When the lease cannot be acquired, signals `skip!` or `fail!` on
107
+ # `task` — control does not return to the caller of {#process}. Busy
108
+ # metadata may include `:holder` and `:retry_after` when the backend
109
+ # exposes the current value and TTL.
110
+ # @raise [ArgumentError] when `on_busy` is not `:skip` or `:fail`, or `wait`
111
+ # is negative
112
+ def process(task)
113
+ return yield unless enabled?
114
+ return yield unless Util.satisfied?(condition_if, condition_unless, task)
115
+
116
+ segment = StoreKey.segment(task, key)
117
+ return yield if segment.nil?
118
+
119
+ store_key = StoreKey.compose(namespace, task.class.name, segment)
120
+ token = task.tid
121
+
122
+ if acquire_lock(store_key, token)
123
+ Telemetry.emit(task, :lock_acquired, lock_key: store_key)
124
+
125
+ begin
126
+ yield
127
+ ensure
128
+ release_lock(store_key, token)
129
+ end
130
+ else
131
+ busy!(task, store_key)
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ def acquire_lock(store_key, token) # rubocop:disable Naming/PredicateMethod -- returns whether the lease was taken
138
+ wait_seconds = wait
139
+ raise ArgumentError, "lock_with wait must be >= 0 (got #{wait_seconds})" if wait_seconds.negative?
140
+
141
+ deadline = (Util.monotonic_now + wait_seconds if wait_seconds.positive?)
142
+
143
+ loop do
144
+ return true if Stores.set(store_key, token, nx: true, ttl:)
145
+ return false unless deadline
146
+
147
+ break if Util.monotonic_now >= deadline
148
+
149
+ sleep_interval = [0.05, deadline - Util.monotonic_now].min
150
+ Kernel.sleep(sleep_interval) if sleep_interval.positive?
151
+ end
152
+
153
+ false
154
+ end
155
+
156
+ def release_lock(store_key, token)
157
+ Stores.del_if(store_key, token)
158
+ end
159
+
160
+ def busy!(task, store_key)
161
+ holder = Stores.get(store_key)
162
+ retry_after = Stores.remaining_ttl(store_key)
163
+
164
+ message = holder ? "lock held" : "lock not acquired"
165
+ metadata = {
166
+ code: :busy,
167
+ lock_key: store_key,
168
+ holder:,
169
+ retry_after:
170
+ }.compact
171
+
172
+ Telemetry.emit(task, :lock_busy, **metadata, on_busy:)
173
+
174
+ case on_busy
175
+ when :fail
176
+ task.fail!(message, **metadata)
177
+ when :skip
178
+ task.skip!(message, **metadata)
179
+ else
180
+ raise ArgumentError, <<~MSG.chomp
181
+ lock_with on_busy must be :skip or :fail (got #{on_busy.inspect}).
182
+ See https://drexed.github.io/cmdx/locks/
183
+ MSG
184
+ end
185
+ end
186
+
187
+ DISABLED = new(nil).freeze
188
+ private_constant :DISABLED
189
+
190
+ end
191
+ end
data/lib/cmdx/pipeline.rb CHANGED
@@ -151,7 +151,7 @@ module CMDx
151
151
  new_opts = old_opts.merge(rolled_back: true).freeze
152
152
  result.instance_variable_set(:@options, new_opts)
153
153
 
154
- emit_telemetry(instance, :task_rolled_back)
154
+ Telemetry.emit(instance, :task_rolled_back, root: false)
155
155
 
156
156
  begin
157
157
  instance.rollback
@@ -166,14 +166,6 @@ module CMDx
166
166
  raise first_rollback_error if first_rollback_error
167
167
  end
168
168
 
169
- def emit_telemetry(instance, name, payload = EMPTY_HASH)
170
- telemetry = instance.class.telemetry
171
- return unless telemetry.subscribed?(name)
172
-
173
- event = Telemetry::Event.build(instance, name, root: false, payload:)
174
- telemetry.emit(name, event)
175
- end
176
-
177
169
  def aggregate(failures, continue:)
178
170
  return if failures.empty?
179
171
  return failures.first unless continue
data/lib/cmdx/retry.rb CHANGED
@@ -4,9 +4,19 @@ module CMDx
4
4
  # Configurable retry-on-exception wrapper around a task's `work`. Supports
5
5
  # exception list, attempt `:limit`, base `:delay`, `:max_delay` cap, and
6
6
  # `:jitter` strategy (symbol, proc, or a configured block). Declared via
7
- # `Task.retry_on` and accumulates across inheritance.
7
+ # {Task.retry_on} and accumulates across inheritance; {Task.retry_on} with
8
+ # `false` selects {Retry.disabled}.
8
9
  class Retry
9
10
 
11
+ class << self
12
+
13
+ # @return [Retry] disabled configuration (no exceptions)
14
+ def disabled
15
+ DISABLED
16
+ end
17
+
18
+ end
19
+
10
20
  attr_reader :exceptions
11
21
 
12
22
  # @param exceptions [Array<Class>] exceptions to retry on
@@ -67,6 +77,11 @@ module CMDx
67
77
  @options[:jitter] || @block
68
78
  end
69
79
 
80
+ # @return [Boolean]
81
+ def enabled?
82
+ !exceptions.empty?
83
+ end
84
+
70
85
  # Sleeps `attempt`'s jittered/bounded delay. No-op when the base delay is zero.
71
86
  #
72
87
  # Custom jitter callables (registry, task Symbol method, `Proc` / block via
@@ -125,7 +140,7 @@ module CMDx
125
140
  # @return [Object] the block's successful return value
126
141
  # @raise [Exception] the last caught exception once retries exhaust
127
142
  def process(task = nil, &)
128
- return yield(0) if exceptions.empty? || !limit.positive?
143
+ return yield(0) if !enabled? || !limit.positive?
129
144
 
130
145
  prev_delay = nil
131
146
  (limit + 1).times do |attempt|
@@ -148,5 +163,8 @@ module CMDx
148
163
  end
149
164
  end
150
165
 
166
+ DISABLED = new([], EMPTY_HASH).freeze
167
+ private_constant :DISABLED
168
+
151
169
  end
152
170
  end
data/lib/cmdx/runtime.rb CHANGED
@@ -3,8 +3,10 @@
3
3
  module CMDx
4
4
  # Orchestrates a task's full lifecycle: chain acquisition, middlewares,
5
5
  # telemetry, deprecation, callbacks, input resolution, `work` (wrapped in
6
- # retry), output verification, rollback on failure, result finalization,
7
- # and teardown (freeze + chain clear).
6
+ # {CircuitBreaker}, {Lock}, and {Idempotency} when declared, then {Retry};
7
+ # output verification runs inside the lock lease and idempotency claim when
8
+ # enabled),
9
+ # rollback on failure, result finalization, and teardown (freeze + chain clear).
8
10
  #
9
11
  # Signal propagation: `work` and the surrounding middleware/callback chain
10
12
  # both run inside `catch(Signal::TAG)`, so `success!` / `skip!` / `fail!` /
@@ -49,7 +51,7 @@ module CMDx
49
51
  acquire_chain
50
52
 
51
53
  run_middlewares do
52
- emit_telemetry(:task_started)
54
+ Telemetry.emit(@task, :task_started, root: @root)
53
55
  run_deprecation
54
56
  run_lifecycle
55
57
  end
@@ -84,7 +86,7 @@ module CMDx
84
86
 
85
87
  deprecation.execute(@task) do
86
88
  @deprecated = true
87
- emit_telemetry(:task_deprecated)
89
+ Telemetry.emit(@task, :task_deprecated, root: @root)
88
90
  end
89
91
  end
90
92
 
@@ -127,7 +129,7 @@ module CMDx
127
129
  duration: @duration
128
130
  ).tap do |result|
129
131
  @root ? Chain.current.unshift(result) : Chain.current.push(result)
130
- emit_telemetry(:task_executed, result:)
132
+ Telemetry.emit(@task, :task_executed, root: @root, result:)
131
133
  @task.logger.info do
132
134
  exclusions = @task.class.settings.log_exclusions
133
135
  exclusions.empty? ? result : result.to_h.except(*exclusions)
@@ -146,10 +148,10 @@ module CMDx
146
148
  end
147
149
 
148
150
  def measure_duration
149
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
151
+ start = Util.monotonic_now(:float_millisecond)
150
152
  yield
151
153
  ensure
152
- @duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
154
+ @duration = Util.monotonic_now(:float_millisecond) - start
153
155
  end
154
156
 
155
157
  def run_callbacks(event)
@@ -170,8 +172,16 @@ module CMDx
170
172
  def perform_work
171
173
  @signal = catch(Signal::TAG) do
172
174
  resolve_inputs!
173
- retry_execution { @task.work }
174
- verify_outputs!
175
+
176
+ circuit_execution do
177
+ lock_execution do
178
+ idempotent_execution do
179
+ retry_execution { @task.work }
180
+ verify_outputs!
181
+ end
182
+ end
183
+ end
184
+
175
185
  Signal.success(nil, metadata: @task.metadata)
176
186
  rescue Fault => e
177
187
  Signal.echoed(e.result, cause: e, metadata: @task.metadata)
@@ -186,7 +196,7 @@ module CMDx
186
196
  return unless @task.respond_to?(:rollback)
187
197
 
188
198
  @rolled_back = true
189
- emit_telemetry(:task_rolled_back)
199
+ Telemetry.emit(@task, :task_rolled_back, root: @root)
190
200
  @task.rollback
191
201
  end
192
202
 
@@ -198,10 +208,31 @@ module CMDx
198
208
  signal_errors!
199
209
  end
200
210
 
211
+ def circuit_execution(&)
212
+ breaker = @task.class.circuit_breaker
213
+ return yield unless breaker.enabled?
214
+
215
+ breaker.process(@task, &)
216
+ end
217
+
218
+ def lock_execution(&)
219
+ lock = @task.class.lock_with
220
+ return yield unless lock.enabled?
221
+
222
+ lock.process(@task, &)
223
+ end
224
+
225
+ def idempotent_execution(&)
226
+ idem = @task.class.idempotent_by
227
+ return yield unless idem.enabled?
228
+
229
+ idem.process(@task, &)
230
+ end
231
+
201
232
  def retry_execution
202
233
  @task.class.retry_on.process(@task) do |attempt|
203
234
  @retries = attempt
204
- emit_telemetry(:task_retried, attempt:) if attempt.positive?
235
+ Telemetry.emit(@task, :task_retried, root: @root, attempt:) if attempt.positive?
205
236
  yield
206
237
  end
207
238
 
@@ -222,13 +253,5 @@ module CMDx
222
253
  throw(Signal::TAG, Signal.failed(@task.errors.to_s, metadata: @task.metadata))
223
254
  end
224
255
 
225
- def emit_telemetry(name, payload = EMPTY_HASH)
226
- telemetry = @task.class.telemetry
227
- return unless telemetry.subscribed?(name)
228
-
229
- event = Telemetry::Event.build(@task, name, root: @root, payload:)
230
- telemetry.emit(name, event)
231
- end
232
-
233
256
  end
234
257
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Builds colon-separated store keys with the same non-empty coercion as
5
+ # {Stores.key!}. Used by {Idempotency} (`namespace:TaskName:segment`),
6
+ # {CircuitBreaker} (`namespace:TaskName` with `:failures` / `:open` suffixes),
7
+ # {Lock} (`namespace:TaskName:segment`), and other store-backed helpers.
8
+ module StoreKey
9
+
10
+ extend self
11
+
12
+ # @param segments [Array<Object>] key segments coerced with {Stores.key!}
13
+ # @return [String] joined key
14
+ # @raise [ArgumentError] when any segment is empty after coercion
15
+ def compose(*segments)
16
+ segments.filter_map { |s| Stores.key!(s) }.join(":")
17
+ end
18
+
19
+ # Resolves a store key segment from a task. Used by {Idempotency},
20
+ # {CircuitBreaker}, and other store-backed declarations.
21
+ #
22
+ # @param task [Task]
23
+ # @param resolver [Symbol, String, Proc, #call]
24
+ # @param skip [Object, nil] when `resolver == skip`, returns `nil` without
25
+ # reading context (for example `CircuitBreaker::CLASS_KEY`)
26
+ # @return [String, nil] non-empty segment, or `nil` when blank or skipped
27
+ def segment(task, resolver, skip: nil)
28
+ return if skip && resolver == skip
29
+
30
+ raw =
31
+ case resolver
32
+ when Symbol
33
+ task.context[resolver]
34
+ when Proc
35
+ task.instance_exec(task, &resolver)
36
+ else
37
+ return resolver.call(task) if resolver.respond_to?(:call)
38
+
39
+ resolver
40
+ end
41
+
42
+ str = raw&.to_s
43
+ str if str && !str.empty?
44
+ end
45
+
46
+ end
47
+ end