cmdx 2.2.0 → 2.4.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 +21 -1
- data/TODO.md +353 -0
- data/lib/cmdx/circuit_breaker.rb +221 -0
- data/lib/cmdx/configuration.rb +4 -1
- data/lib/cmdx/deprecation.rb +2 -2
- data/lib/cmdx/idempotency.rb +171 -0
- data/lib/cmdx/limiters/fixed_window.rb +30 -0
- data/lib/cmdx/limiters/leaky_bucket.rb +75 -0
- data/lib/cmdx/limiters.rb +107 -0
- data/lib/cmdx/lock.rb +192 -0
- data/lib/cmdx/pipeline.rb +1 -9
- data/lib/cmdx/rate_limit.rb +244 -0
- data/lib/cmdx/retry.rb +20 -2
- data/lib/cmdx/runtime.rb +51 -19
- data/lib/cmdx/store_key.rb +78 -0
- data/lib/cmdx/stores/memory.rb +158 -0
- data/lib/cmdx/stores/redis.rb +118 -0
- data/lib/cmdx/stores.rb +111 -0
- data/lib/cmdx/task.rb +171 -12
- data/lib/cmdx/telemetry.rb +36 -4
- data/lib/cmdx/util.rb +7 -0
- data/lib/cmdx/version.rb +1 -1
- data/lib/cmdx.rb +15 -0
- data/lib/generators/cmdx/templates/install.rb +41 -5
- data/mkdocs.yml +76 -21
- metadata +13 -1
|
@@ -0,0 +1,171 @@
|
|
|
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`, only options merge and the existing resolver is kept. A disabled
|
|
79
|
+
# config stays disabled when only options are passed (a key is still required
|
|
80
|
+
# to enable).
|
|
81
|
+
#
|
|
82
|
+
# @param new_key [Symbol, String, Proc, #call, nil]
|
|
83
|
+
# @param new_options [Hash{Symbol => Object}]
|
|
84
|
+
# @return [Idempotency]
|
|
85
|
+
def build(new_key, new_options)
|
|
86
|
+
return self if new_key.nil? && new_options.empty?
|
|
87
|
+
|
|
88
|
+
merged_key = new_key.nil? ? @key : new_key
|
|
89
|
+
merged_options = @options.merge(new_options)
|
|
90
|
+
|
|
91
|
+
self.class.new(merged_key, merged_options)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Runs the given block behind a store claim when {#enabled?} and gates pass.
|
|
95
|
+
# Releases the claim on `StandardError` or a non-returning exit (`skip!`,
|
|
96
|
+
# `fail!`) so `retry_on` can run `work` again; marks the slot `completed`
|
|
97
|
+
# after the block returns successfully.
|
|
98
|
+
#
|
|
99
|
+
# @param task [Task]
|
|
100
|
+
# @yield invoked when the claim succeeds or idempotency is inactive
|
|
101
|
+
# @yieldreturn [Object] return value from the wrapped work (ignored by callers)
|
|
102
|
+
# @return [Object] the block's return value
|
|
103
|
+
# @note On duplicate keys, signals `skip!` or `fail!` on `task` — control
|
|
104
|
+
# does not return to the caller of {#process}.
|
|
105
|
+
# @raise [ArgumentError] when `on_duplicate` is not `:skip` or `:fail`
|
|
106
|
+
# @raise [IdempotencyError] when the store refuses the `completed` transition
|
|
107
|
+
def process(task)
|
|
108
|
+
return yield unless enabled?
|
|
109
|
+
return yield unless Util.satisfied?(condition_if, condition_unless, task)
|
|
110
|
+
|
|
111
|
+
store_key = StoreKey.task_key(task, namespace:, resolver: key)
|
|
112
|
+
return yield if store_key.nil?
|
|
113
|
+
|
|
114
|
+
if Stores.set(store_key, PROCESSING, nx: true, ttl:)
|
|
115
|
+
Telemetry.emit(task, :idempotency_claimed, idempotency_key: store_key)
|
|
116
|
+
|
|
117
|
+
succeeded = false
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
yield
|
|
121
|
+
succeeded = true
|
|
122
|
+
rescue StandardError
|
|
123
|
+
Stores.del(store_key)
|
|
124
|
+
raise
|
|
125
|
+
ensure
|
|
126
|
+
Stores.del(store_key) unless succeeded
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
mark_completed!(store_key, task)
|
|
130
|
+
else
|
|
131
|
+
duplicate!(task, store_key)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def mark_completed!(store_key, task)
|
|
138
|
+
if Stores.set(store_key, COMPLETED, xx: true, ttl:)
|
|
139
|
+
Telemetry.emit(task, :idempotency_completed, idempotency_key: store_key)
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
Stores.del(store_key)
|
|
144
|
+
raise IdempotencyError, "failed to mark idempotency key completed (#{store_key})"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def duplicate!(task, store_key)
|
|
148
|
+
state = Stores.get(store_key)
|
|
149
|
+
message = "duplicate request (state=#{state})"
|
|
150
|
+
metadata = { code: :duplicate, idempotency_key: store_key, state: }
|
|
151
|
+
|
|
152
|
+
Telemetry.emit(task, :idempotency_duplicate, **metadata)
|
|
153
|
+
|
|
154
|
+
case on_duplicate
|
|
155
|
+
when :fail
|
|
156
|
+
task.fail!(message, **metadata)
|
|
157
|
+
when :skip
|
|
158
|
+
task.skip!(message, **metadata)
|
|
159
|
+
else
|
|
160
|
+
raise ArgumentError, <<~MSG.chomp
|
|
161
|
+
idempotent_by on_duplicate must be :fail or :skip (got #{on_duplicate.inspect}).
|
|
162
|
+
See https://drexed.github.io/cmdx/idempotency/
|
|
163
|
+
MSG
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
DISABLED = new(nil).freeze
|
|
168
|
+
private_constant :DISABLED
|
|
169
|
+
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
class Limiters
|
|
5
|
+
# Fixed-window counter using {Stores.incr} with TTL refresh on each hit.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module FixedWindow
|
|
9
|
+
|
|
10
|
+
extend self
|
|
11
|
+
|
|
12
|
+
# @param quota [RateLimit::Quota]
|
|
13
|
+
# @return [RateLimit::Admission]
|
|
14
|
+
def call(quota)
|
|
15
|
+
count = Stores.incr(quota.store_key, ttl: quota.per)
|
|
16
|
+
admitted = count <= quota.max.to_i
|
|
17
|
+
retry_after = admitted ? nil : Stores.remaining_ttl(quota.store_key)
|
|
18
|
+
|
|
19
|
+
RateLimit::Admission.new(
|
|
20
|
+
admitted:,
|
|
21
|
+
count:,
|
|
22
|
+
level: nil,
|
|
23
|
+
retry_after:,
|
|
24
|
+
limiter: :fixed_window
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
class Limiters
|
|
5
|
+
# Continuous leaky bucket backed by a single store key (`store_key:lb`).
|
|
6
|
+
# Leaks one unit every `per` seconds; admits when `level + cost <= max`.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module LeakyBucket
|
|
10
|
+
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
# @param quota [RateLimit::Quota]
|
|
14
|
+
# @return [RateLimit::Admission]
|
|
15
|
+
def call(quota)
|
|
16
|
+
state_key = "#{quota.store_key}:lb"
|
|
17
|
+
now = Time.now.to_f
|
|
18
|
+
level, last_at = parse_state(Stores.get(state_key), now)
|
|
19
|
+
|
|
20
|
+
leaked = (now - last_at) / quota.per
|
|
21
|
+
if leaked.positive?
|
|
22
|
+
level -= leaked
|
|
23
|
+
level = 0.0 if level.negative?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
cost = quota.cost
|
|
27
|
+
capacity = quota.max
|
|
28
|
+
projected = level + cost
|
|
29
|
+
admitted = projected <= capacity
|
|
30
|
+
|
|
31
|
+
level = projected if admitted
|
|
32
|
+
|
|
33
|
+
Stores.set(state_key, "#{level}:#{now}", ttl: bucket_ttl(quota))
|
|
34
|
+
|
|
35
|
+
retry_after =
|
|
36
|
+
unless admitted
|
|
37
|
+
overflow = projected - capacity
|
|
38
|
+
overflow = 0 if overflow.negative?
|
|
39
|
+
(overflow * quota.per).ceil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
RateLimit::Admission.new(
|
|
43
|
+
admitted:,
|
|
44
|
+
count: nil,
|
|
45
|
+
level:,
|
|
46
|
+
retry_after:,
|
|
47
|
+
limiter: :leaky_bucket
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def parse_state(raw, now)
|
|
54
|
+
return [0.0, now] if raw.nil? || raw.empty?
|
|
55
|
+
|
|
56
|
+
sep = raw.index(":")
|
|
57
|
+
unless sep
|
|
58
|
+
level = Float(raw, exception: false) || 0.0
|
|
59
|
+
return [level, now]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
level = Float(raw[0, sep], exception: false) || 0.0
|
|
63
|
+
last_at = Float(raw[(sep + 1)..], exception: false) || now
|
|
64
|
+
[level, last_at]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def bucket_ttl(quota)
|
|
68
|
+
base = quota.max * quota.per
|
|
69
|
+
ttl = base.positive? ? base * 2 : quota.per * 2
|
|
70
|
+
ttl.to_i
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# Registry of named rate-limit strategies used by {RateLimit}. Ships with
|
|
5
|
+
# `:fixed_window` and `:leaky_bucket`. A limiter is any callable accepting
|
|
6
|
+
# a {RateLimit::Quota} and returning a {RateLimit::Admission}.
|
|
7
|
+
class Limiters
|
|
8
|
+
|
|
9
|
+
attr_reader :registry
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@registry = {
|
|
13
|
+
fixed_window: Limiters::FixedWindow,
|
|
14
|
+
leaky_bucket: Limiters::LeakyBucket
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param source [Limiters] registry to duplicate
|
|
19
|
+
# @return [void]
|
|
20
|
+
def initialize_copy(source)
|
|
21
|
+
@registry = source.registry.dup
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Registers a named limiter, overwriting any existing entry.
|
|
25
|
+
#
|
|
26
|
+
# @param name [Symbol]
|
|
27
|
+
# @param callable [#call, nil] pass either this or a block
|
|
28
|
+
# @param block [#call, nil] limiter callable when `callable` is omitted
|
|
29
|
+
# @yieldparam quota [RateLimit::Quota]
|
|
30
|
+
# @yieldreturn [RateLimit::Admission]
|
|
31
|
+
# @return [Limiters] self for chaining
|
|
32
|
+
# @raise [ArgumentError] when both `callable` and a block are given, or
|
|
33
|
+
# when the resolved limiter isn't callable
|
|
34
|
+
def register(name, callable = nil, &block)
|
|
35
|
+
limiter = callable || block
|
|
36
|
+
|
|
37
|
+
if callable && block
|
|
38
|
+
raise ArgumentError, "limiter: provide either a callable or a block, not both"
|
|
39
|
+
elsif !limiter.respond_to?(:call)
|
|
40
|
+
raise ArgumentError, <<~MSG.chomp
|
|
41
|
+
limiter must respond to #call (got #{limiter.class}).
|
|
42
|
+
See https://drexed.github.io/cmdx/rate_limit/#custom-limiters-via-the-limiters-registry
|
|
43
|
+
MSG
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
registry[name.to_sym] = limiter
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param name [Symbol]
|
|
51
|
+
# @return [Limiters] self for chaining
|
|
52
|
+
def deregister(name)
|
|
53
|
+
registry.delete(name)
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param name [Symbol]
|
|
58
|
+
# @return [Boolean] whether a limiter is registered under `name`
|
|
59
|
+
def key?(name)
|
|
60
|
+
registry.key?(name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param name [Symbol]
|
|
64
|
+
# @return [#call] the registered limiter
|
|
65
|
+
# @raise [UnknownEntryError] when `name` isn't registered
|
|
66
|
+
def lookup(name)
|
|
67
|
+
registry[name] || begin
|
|
68
|
+
raise UnknownEntryError, <<~MSG.chomp
|
|
69
|
+
unknown limiter #{name.inspect}; registered: #{registry.keys.inspect}.
|
|
70
|
+
See https://drexed.github.io/cmdx/rate_limit/#built-in-limiters
|
|
71
|
+
MSG
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Resolves a `:limiter` spec to a concrete callable.
|
|
76
|
+
#
|
|
77
|
+
# @param spec [Symbol, #call, nil]
|
|
78
|
+
# @return [#call, nil]
|
|
79
|
+
# @raise [UnknownEntryError] when `spec` is an unknown symbol or not callable
|
|
80
|
+
def resolve(spec)
|
|
81
|
+
case spec
|
|
82
|
+
when NilClass
|
|
83
|
+
nil
|
|
84
|
+
when Symbol
|
|
85
|
+
lookup(spec)
|
|
86
|
+
else
|
|
87
|
+
return spec if spec.respond_to?(:call)
|
|
88
|
+
|
|
89
|
+
raise UnknownEntryError, <<~MSG.chomp
|
|
90
|
+
unknown limiter #{spec.inspect}; expected a Symbol from #{registry.keys.inspect} or a callable.
|
|
91
|
+
See https://drexed.github.io/cmdx/rate_limit/#built-in-limiters
|
|
92
|
+
MSG
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
def empty?
|
|
98
|
+
registry.empty?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Integer]
|
|
102
|
+
def size
|
|
103
|
+
registry.size
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/cmdx/lock.rb
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
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. When
|
|
87
|
+
# `new_key` is non-nil it replaces the resolver; when `new_key` is `nil`,
|
|
88
|
+
# only options merge and the existing resolver is kept.
|
|
89
|
+
#
|
|
90
|
+
# @param new_key [Symbol, String, Proc, #call, nil]
|
|
91
|
+
# @param new_options [Hash{Symbol => Object}]
|
|
92
|
+
# @return [Lock]
|
|
93
|
+
def build(new_key, new_options)
|
|
94
|
+
return self if new_key.nil? && new_options.empty?
|
|
95
|
+
|
|
96
|
+
merged_key = new_key.nil? ? @key : new_key
|
|
97
|
+
merged_options = @options.merge(new_options)
|
|
98
|
+
|
|
99
|
+
self.class.new(merged_key, merged_options)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Runs the given block behind a store lease when {#enabled?} and gates pass.
|
|
103
|
+
#
|
|
104
|
+
# @param task [Task]
|
|
105
|
+
# @yield invoked when the lease is acquired or locking is inactive
|
|
106
|
+
# @yieldreturn [Object] return value from the wrapped work
|
|
107
|
+
# @return [Object] the block's return value
|
|
108
|
+
# @note When the lease cannot be acquired, signals `skip!` or `fail!` on
|
|
109
|
+
# `task` — control does not return to the caller of {#process}. Busy
|
|
110
|
+
# metadata may include `:holder` and `:retry_after` when the backend
|
|
111
|
+
# exposes the current value and TTL.
|
|
112
|
+
# @raise [ArgumentError] when `on_busy` is not `:skip` or `:fail`, or `wait`
|
|
113
|
+
# is negative
|
|
114
|
+
def process(task)
|
|
115
|
+
return yield unless enabled?
|
|
116
|
+
return yield unless Util.satisfied?(condition_if, condition_unless, task)
|
|
117
|
+
|
|
118
|
+
store_key = StoreKey.task_key(task, namespace:, resolver: key)
|
|
119
|
+
return yield if store_key.nil?
|
|
120
|
+
|
|
121
|
+
token = task.tid
|
|
122
|
+
|
|
123
|
+
if acquire_lock(store_key, token)
|
|
124
|
+
Telemetry.emit(task, :lock_acquired, lock_key: store_key)
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
yield
|
|
128
|
+
ensure
|
|
129
|
+
release_lock(store_key, token)
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
busy!(task, store_key)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def acquire_lock(store_key, token) # rubocop:disable Naming/PredicateMethod -- returns whether the lease was taken
|
|
139
|
+
wait_seconds = wait
|
|
140
|
+
raise ArgumentError, "lock_with wait must be >= 0 (got #{wait_seconds})" if wait_seconds.negative?
|
|
141
|
+
|
|
142
|
+
deadline = (Util.monotonic_now + wait_seconds if wait_seconds.positive?)
|
|
143
|
+
|
|
144
|
+
loop do
|
|
145
|
+
return true if Stores.set(store_key, token, nx: true, ttl:)
|
|
146
|
+
return false unless deadline
|
|
147
|
+
|
|
148
|
+
break if Util.monotonic_now >= deadline
|
|
149
|
+
|
|
150
|
+
sleep_interval = [0.05, deadline - Util.monotonic_now].min
|
|
151
|
+
Kernel.sleep(sleep_interval) if sleep_interval.positive?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def release_lock(store_key, token)
|
|
158
|
+
Stores.del_if(store_key, token)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def busy!(task, store_key)
|
|
162
|
+
holder = Stores.get(store_key)
|
|
163
|
+
retry_after = Stores.remaining_ttl(store_key)
|
|
164
|
+
|
|
165
|
+
message = holder ? "lock held" : "lock not acquired"
|
|
166
|
+
metadata = {
|
|
167
|
+
code: :busy,
|
|
168
|
+
lock_key: store_key,
|
|
169
|
+
holder:,
|
|
170
|
+
retry_after:
|
|
171
|
+
}.compact
|
|
172
|
+
|
|
173
|
+
Telemetry.emit(task, :lock_busy, **metadata, on_busy:)
|
|
174
|
+
|
|
175
|
+
case on_busy
|
|
176
|
+
when :fail
|
|
177
|
+
task.fail!(message, **metadata)
|
|
178
|
+
when :skip
|
|
179
|
+
task.skip!(message, **metadata)
|
|
180
|
+
else
|
|
181
|
+
raise ArgumentError, <<~MSG.chomp
|
|
182
|
+
lock_with on_busy must be :fail or :skip (got #{on_busy.inspect}).
|
|
183
|
+
See https://drexed.github.io/cmdx/locks/
|
|
184
|
+
MSG
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
DISABLED = new(nil).freeze
|
|
189
|
+
private_constant :DISABLED
|
|
190
|
+
|
|
191
|
+
end
|
|
192
|
+
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
|