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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -1
- data/TODO.md +353 -0
- data/lib/cmdx/circuit_breaker.rb +234 -0
- data/lib/cmdx/configuration.rb +2 -1
- data/lib/cmdx/deprecation.rb +2 -2
- data/lib/cmdx/idempotency.rb +172 -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 +42 -19
- 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 +115 -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 +23 -5
- data/mkdocs.yml +8 -1
- metadata +9 -1
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
7
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
151
|
+
start = Util.monotonic_now(:float_millisecond)
|
|
150
152
|
yield
|
|
151
153
|
ensure
|
|
152
|
-
@duration =
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|