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.
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,17 +148,18 @@ 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)
156
158
  callbacks = @task.class.callbacks
157
159
  return if callbacks.empty?
158
160
 
159
- callbacks.process(event, @task)
161
+ signal = Callbacks::POST_WORK_EVENTS.include?(event) ? @signal : nil
162
+ callbacks.process(event, @task, signal)
160
163
  end
161
164
 
162
165
  def run_around(event, &)
@@ -169,8 +172,16 @@ module CMDx
169
172
  def perform_work
170
173
  @signal = catch(Signal::TAG) do
171
174
  resolve_inputs!
172
- retry_execution { @task.work }
173
- 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
+
174
185
  Signal.success(nil, metadata: @task.metadata)
175
186
  rescue Fault => e
176
187
  Signal.echoed(e.result, cause: e, metadata: @task.metadata)
@@ -185,7 +196,7 @@ module CMDx
185
196
  return unless @task.respond_to?(:rollback)
186
197
 
187
198
  @rolled_back = true
188
- emit_telemetry(:task_rolled_back)
199
+ Telemetry.emit(@task, :task_rolled_back, root: @root)
189
200
  @task.rollback
190
201
  end
191
202
 
@@ -197,10 +208,31 @@ module CMDx
197
208
  signal_errors!
198
209
  end
199
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
+
200
232
  def retry_execution
201
233
  @task.class.retry_on.process(@task) do |attempt|
202
234
  @retries = attempt
203
- emit_telemetry(:task_retried, attempt:) if attempt.positive?
235
+ Telemetry.emit(@task, :task_retried, root: @root, attempt:) if attempt.positive?
204
236
  yield
205
237
  end
206
238
 
@@ -221,13 +253,5 @@ module CMDx
221
253
  throw(Signal::TAG, Signal.failed(@task.errors.to_s, metadata: @task.metadata))
222
254
  end
223
255
 
224
- def emit_telemetry(name, payload = EMPTY_HASH)
225
- telemetry = @task.class.telemetry
226
- return unless telemetry.subscribed?(name)
227
-
228
- event = Telemetry::Event.build(@task, name, root: @root, payload:)
229
- telemetry.emit(name, event)
230
- end
231
-
232
256
  end
233
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
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ module Stores
5
+ # Process-local key/value store with optional per-key TTL. Thread-safe via
6
+ # an internal mutex. Suitable for tests and single-process dev; not shared
7
+ # across workers.
8
+ class Memory
9
+
10
+ def initialize
11
+ @mutex = Mutex.new
12
+ @entries = {}
13
+ end
14
+
15
+ # @param key [String, Symbol]
16
+ # @return [String, nil] stored value, or `nil` when missing or expired
17
+ def get(key)
18
+ key = Stores.key!(key)
19
+ @mutex.synchronize do
20
+ entry = @entries[key]
21
+ return unless entry
22
+ return if expired?(entry)
23
+
24
+ entry[:value]
25
+ end
26
+ end
27
+
28
+ # @param key [String, Symbol]
29
+ # @param value [#to_s]
30
+ # @param ttl [Integer, nil] seconds until expiry (`nil` = no expiry)
31
+ # @param nx [Boolean] set only when the key is absent
32
+ # @param xx [Boolean] set only when the key is present
33
+ # @return [Boolean] whether the value was written
34
+ # @raise [ArgumentError] when `nx` and `xx` are both true, or `ttl` is invalid
35
+ def set(key, value, ttl: nil, nx: false, xx: false)
36
+ key = Stores.key!(key)
37
+ ttl = Stores.ttl!(ttl) if ttl
38
+ Stores.nx_xx!(nx, xx)
39
+
40
+ expires_at = ttl ? Util.monotonic_now + ttl : nil
41
+ @mutex.synchronize do
42
+ entry = @entries[key]
43
+ present = entry && !expired?(entry)
44
+
45
+ return false if nx && present
46
+ return false if xx && !present
47
+
48
+ @entries[key] = { value: value.to_s, expires_at: }
49
+ true
50
+ end
51
+ end
52
+
53
+ # @param key [String, Symbol]
54
+ # @param ttl [Integer, nil] when set, refreshes expiry on every increment
55
+ # @return [Integer] value after increment
56
+ # @raise [ArgumentError] when `ttl` is invalid
57
+ def incr(key, ttl: nil)
58
+ key = Stores.key!(key)
59
+ ttl = Stores.ttl!(ttl) if ttl
60
+
61
+ @mutex.synchronize do
62
+ entry = @entries[key]
63
+ if entry && expired?(entry)
64
+ @entries.delete(key)
65
+ entry = nil
66
+ end
67
+
68
+ count = entry ? entry[:value].to_i + 1 : 1
69
+ expires_at = (Util.monotonic_now + ttl if ttl)
70
+
71
+ @entries[key] = { value: count.to_s, expires_at: }
72
+ count
73
+ end
74
+ end
75
+
76
+ # @param key [String, Symbol]
77
+ # @return [Integer, nil] whole seconds until expiry, or `nil` when missing or expired
78
+ def ttl(key)
79
+ key = Stores.key!(key)
80
+ @mutex.synchronize do
81
+ entry = @entries[key]
82
+ return unless entry
83
+ return if expired?(entry)
84
+
85
+ expires_at = entry[:expires_at]
86
+ return unless expires_at
87
+
88
+ remaining = expires_at - Util.monotonic_now
89
+ remaining.positive? ? remaining.ceil : nil
90
+ end
91
+ end
92
+
93
+ # @param key [String, Symbol]
94
+ # @return [Integer] value after decrement (floors at `0`, removing the key)
95
+ # @raise [ArgumentError] when `ttl` is invalid (only when `ttl` is passed)
96
+ def decr(key, ttl: nil)
97
+ key = Stores.key!(key)
98
+ Stores.ttl!(ttl) if ttl
99
+
100
+ @mutex.synchronize do
101
+ entry = @entries[key]
102
+ if entry && expired?(entry)
103
+ @entries.delete(key)
104
+ entry = nil
105
+ end
106
+
107
+ count = entry ? entry[:value].to_i : 0
108
+ count = count.positive? ? count - 1 : 0
109
+ expires_at = entry&.fetch(:expires_at, nil)
110
+
111
+ if count.zero?
112
+ @entries.delete(key)
113
+ else
114
+ @entries[key] = { value: count.to_s, expires_at: }
115
+ end
116
+
117
+ count
118
+ end
119
+ end
120
+
121
+ # @param key [String, Symbol]
122
+ # @return [Integer] `1` when a key was removed, `0` otherwise
123
+ def del(key)
124
+ key = Stores.key!(key)
125
+ @mutex.synchronize do
126
+ @entries.delete(key) ? 1 : 0
127
+ end
128
+ end
129
+
130
+ # @param key [String, Symbol]
131
+ # @param expected [#to_s]
132
+ # @return [Boolean] whether the key was removed
133
+ def del_if(key, expected)
134
+ key = Stores.key!(key)
135
+ expected = expected.to_s
136
+ @mutex.synchronize do
137
+ entry = @entries[key]
138
+ return false unless entry && !expired?(entry)
139
+ return false unless entry[:value] == expected
140
+
141
+ @entries.delete(key)
142
+ true
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ def expired?(entry)
149
+ expires_at = entry[:expires_at]
150
+ expires_at && Util.monotonic_now >= expires_at
151
+ end
152
+
153
+ end
154
+ end
155
+ end