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,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# Declared via {Task.rate_limit}. After inputs resolve, consults a shared
|
|
5
|
+
# limiter once per execution (retries do not consume additional quota) and
|
|
6
|
+
# short-circuits before the circuit breaker when the bucket is full.
|
|
7
|
+
#
|
|
8
|
+
# Store keys come from {StoreKey.task_key} with `class_scoped: true` — either
|
|
9
|
+
# `namespace:TaskName` for `{StoreKey::CLASS_KEY}` (`:class`) or
|
|
10
|
+
# `namespace:TaskName:segment` when a resolver yields a segment.
|
|
11
|
+
class RateLimit
|
|
12
|
+
|
|
13
|
+
# Limiter decision for one admission attempt.
|
|
14
|
+
#
|
|
15
|
+
# Built-in limiters set `count` for `:fixed_window` and `level` for
|
|
16
|
+
# `:leaky_bucket`; custom strategies may populate either or both. Omitted
|
|
17
|
+
# fields are stripped from telemetry and result metadata via `compact`.
|
|
18
|
+
#
|
|
19
|
+
# @attr admitted [Boolean] whether the run may proceed
|
|
20
|
+
# @attr count [Integer, nil] counter after increment (`:fixed_window`)
|
|
21
|
+
# @attr level [Float, nil] bucket fill after leak math (`:leaky_bucket`)
|
|
22
|
+
# @attr retry_after [Integer, nil] seconds until retry might succeed
|
|
23
|
+
# @attr limiter [Symbol, #call] strategy name or callable identity
|
|
24
|
+
Admission = Data.define(:admitted, :count, :level, :retry_after, :limiter) do
|
|
25
|
+
# @return [Boolean] same as `admitted`
|
|
26
|
+
def admitted?
|
|
27
|
+
admitted
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Inputs for a limiter callable (`CMDx::Limiters` or `limiter:` `Proc`).
|
|
32
|
+
#
|
|
33
|
+
# @attr task [Task] task being admitted
|
|
34
|
+
# @attr store_key [String] base store key (limiters may append suffixes)
|
|
35
|
+
# @attr max [Integer] capacity or window max from the declaration
|
|
36
|
+
# @attr per [Integer] window or leak interval in seconds
|
|
37
|
+
# @attr cost [Numeric] units charged when admitted (`:leaky_bucket`)
|
|
38
|
+
# @attr limiter [Symbol, #call] resolved strategy spec
|
|
39
|
+
# @attr options [Hash{Symbol => Object}] frozen declaration options
|
|
40
|
+
Quota = Data.define(:task, :store_key, :max, :per, :cost, :limiter, :options)
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
|
|
44
|
+
# @return [RateLimit] disabled configuration (no key)
|
|
45
|
+
def disabled
|
|
46
|
+
DISABLED
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :key
|
|
52
|
+
|
|
53
|
+
# @param key [Symbol, String, Proc, #call, nil] `:class`, context `Symbol`,
|
|
54
|
+
# static `String`, or callable; `nil` disables rate limiting
|
|
55
|
+
# @param options [Hash{Symbol => Object}]
|
|
56
|
+
# @option options [Integer] :max maximum admissions per window or bucket capacity
|
|
57
|
+
# @option options [Integer] :per window length in seconds (`:fixed_window`) or
|
|
58
|
+
# seconds to leak one unit (`:leaky_bucket`)
|
|
59
|
+
# @option options [Symbol, #call] :limiter (`:fixed_window`, `:leaky_bucket`, or custom)
|
|
60
|
+
# @option options [Numeric] :cost (1) units charged per admitted execution
|
|
61
|
+
# @option options [String] :namespace ("cmdx:rl") first segment of store keys
|
|
62
|
+
# @option options [Symbol] :on_limit (:fail) `:fail` or `:skip` when over limit
|
|
63
|
+
# @option options [Symbol, Proc, #call] :if gate `(task)` before checking
|
|
64
|
+
# @option options [Symbol, Proc, #call] :unless gate `(task)` before checking
|
|
65
|
+
def initialize(key, options = EMPTY_HASH)
|
|
66
|
+
@key = key
|
|
67
|
+
@options = options.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [String]
|
|
71
|
+
def namespace
|
|
72
|
+
@options[:namespace] || "cmdx:rl"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Integer, nil]
|
|
76
|
+
def max
|
|
77
|
+
@options[:max]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Integer, nil]
|
|
81
|
+
def per
|
|
82
|
+
@options[:per]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Symbol]
|
|
86
|
+
def limiter
|
|
87
|
+
@options[:limiter] || :fixed_window
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Numeric]
|
|
91
|
+
def cost
|
|
92
|
+
raw = @options[:cost]
|
|
93
|
+
return 1 if raw.nil?
|
|
94
|
+
|
|
95
|
+
Float(raw)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Symbol] `:fail` or `:skip`
|
|
99
|
+
def on_limit
|
|
100
|
+
@options[:on_limit] || :fail
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Symbol, Proc, #call, nil]
|
|
104
|
+
def condition_if
|
|
105
|
+
@options[:if]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Symbol, Proc, #call, nil]
|
|
109
|
+
def condition_unless
|
|
110
|
+
@options[:unless]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [Boolean]
|
|
114
|
+
def enabled?
|
|
115
|
+
!key.nil?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Returns a new RateLimit layering `new_options` onto the current one.
|
|
119
|
+
#
|
|
120
|
+
# @param new_key [Symbol, String, Proc, #call, nil]
|
|
121
|
+
# @param new_options [Hash{Symbol => Object}]
|
|
122
|
+
# @return [RateLimit]
|
|
123
|
+
def build(new_key, new_options)
|
|
124
|
+
return self if new_key.nil? && new_options.empty?
|
|
125
|
+
|
|
126
|
+
merged_key =
|
|
127
|
+
if new_key.nil?
|
|
128
|
+
enabled? || new_options.empty? ? @key : StoreKey::CLASS_KEY
|
|
129
|
+
else
|
|
130
|
+
new_key
|
|
131
|
+
end
|
|
132
|
+
merged_options = @options.merge(new_options)
|
|
133
|
+
|
|
134
|
+
self.class.new(merged_key, merged_options)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Runs the limiter once, then yields when admitted or inactive.
|
|
138
|
+
#
|
|
139
|
+
# @param task [Task]
|
|
140
|
+
# @yield invoked when admitted or rate limiting is inactive
|
|
141
|
+
# @yieldreturn [Object] return value from the wrapped work
|
|
142
|
+
# @return [Object] the block's return value
|
|
143
|
+
# @note When over limit, signals `skip!` or `fail!` on `task` — control does
|
|
144
|
+
# not return to the caller of {#process}.
|
|
145
|
+
# @raise [ArgumentError] when `max` / `per` are missing or invalid, or
|
|
146
|
+
# `on_limit` is not `:fail` or `:skip`
|
|
147
|
+
def process(task)
|
|
148
|
+
return yield unless enabled?
|
|
149
|
+
return yield unless Util.satisfied?(condition_if, condition_unless, task)
|
|
150
|
+
|
|
151
|
+
store_key = StoreKey.task_key(task, namespace:, resolver: key, class_scoped: true)
|
|
152
|
+
return yield if store_key.nil?
|
|
153
|
+
|
|
154
|
+
quota = Quota.new(
|
|
155
|
+
task:,
|
|
156
|
+
store_key:,
|
|
157
|
+
max: Integer(max),
|
|
158
|
+
per: Integer(per),
|
|
159
|
+
cost:,
|
|
160
|
+
limiter:,
|
|
161
|
+
options: @options
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
admission = invoke_limiter(task, quota)
|
|
165
|
+
if admission.admitted?
|
|
166
|
+
admitted!(task, store_key, admission, quota)
|
|
167
|
+
yield
|
|
168
|
+
else
|
|
169
|
+
exceeded!(task, store_key, admission, quota)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def invoke_limiter(task, quota)
|
|
176
|
+
callable =
|
|
177
|
+
if limiter.is_a?(Proc)
|
|
178
|
+
->(q) { task.instance_exec(q, &limiter) }
|
|
179
|
+
else
|
|
180
|
+
limiters_registry(task).resolve(limiter)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
callable.call(quota)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def limiters_registry(task)
|
|
187
|
+
if task.class.respond_to?(:limiters)
|
|
188
|
+
task.class.limiters
|
|
189
|
+
else
|
|
190
|
+
CMDx.configuration.limiters
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def admitted!(task, store_key, admission, quota)
|
|
195
|
+
payload = admission_payload(store_key, admission, quota)
|
|
196
|
+
|
|
197
|
+
Telemetry.emit(task, :rate_limit_admitted, **payload)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def exceeded!(task, store_key, admission, quota)
|
|
201
|
+
message = exceeded_message(admission, quota)
|
|
202
|
+
metadata = admission_payload(store_key, admission, quota).merge(code: :rate_limited)
|
|
203
|
+
|
|
204
|
+
Telemetry.emit(task, :rate_limit_exceeded, **metadata, on_limit:)
|
|
205
|
+
|
|
206
|
+
case on_limit
|
|
207
|
+
when :fail
|
|
208
|
+
task.fail!(message, **metadata)
|
|
209
|
+
when :skip
|
|
210
|
+
task.skip!(message, **metadata)
|
|
211
|
+
else
|
|
212
|
+
raise ArgumentError, <<~MSG.chomp
|
|
213
|
+
rate_limit on_limit must be :fail or :skip (got #{on_limit.inspect}).
|
|
214
|
+
See https://drexed.github.io/cmdx/rate_limit/
|
|
215
|
+
MSG
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def exceeded_message(admission, quota)
|
|
220
|
+
parts = ["rate limited"]
|
|
221
|
+
parts << "#{admission.count}/#{quota.max}" if admission.count
|
|
222
|
+
parts << "level=#{admission.level}" if admission.level
|
|
223
|
+
parts << "per #{quota.per}s"
|
|
224
|
+
parts << "(#{quota.limiter})"
|
|
225
|
+
parts.join(" ")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def admission_payload(store_key, admission, quota)
|
|
229
|
+
{
|
|
230
|
+
rate_limit_key: store_key,
|
|
231
|
+
limiter: admission.limiter,
|
|
232
|
+
max: quota.max,
|
|
233
|
+
per: quota.per,
|
|
234
|
+
count: admission.count,
|
|
235
|
+
level: admission.level,
|
|
236
|
+
retry_after: admission.retry_after
|
|
237
|
+
}.compact
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
DISABLED = new(nil).freeze
|
|
241
|
+
private_constant :DISABLED
|
|
242
|
+
|
|
243
|
+
end
|
|
244
|
+
end
|
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
|
+
# {RateLimit}, {CircuitBreaker}, {Lock}, and {Idempotency} when declared,
|
|
7
|
+
# then {Retry}; output verification runs inside the lock lease and idempotency
|
|
8
|
+
# claim when enabled), rollback on failure, result finalization, and teardown
|
|
9
|
+
# (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,18 @@ module CMDx
|
|
|
170
172
|
def perform_work
|
|
171
173
|
@signal = catch(Signal::TAG) do
|
|
172
174
|
resolve_inputs!
|
|
173
|
-
|
|
174
|
-
|
|
175
|
+
|
|
176
|
+
rate_limit_execution do
|
|
177
|
+
circuit_execution do
|
|
178
|
+
lock_execution do
|
|
179
|
+
idempotent_execution do
|
|
180
|
+
retry_execution { @task.work }
|
|
181
|
+
verify_outputs!
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
175
187
|
Signal.success(nil, metadata: @task.metadata)
|
|
176
188
|
rescue Fault => e
|
|
177
189
|
Signal.echoed(e.result, cause: e, metadata: @task.metadata)
|
|
@@ -186,7 +198,7 @@ module CMDx
|
|
|
186
198
|
return unless @task.respond_to?(:rollback)
|
|
187
199
|
|
|
188
200
|
@rolled_back = true
|
|
189
|
-
|
|
201
|
+
Telemetry.emit(@task, :task_rolled_back, root: @root)
|
|
190
202
|
@task.rollback
|
|
191
203
|
end
|
|
192
204
|
|
|
@@ -198,10 +210,38 @@ module CMDx
|
|
|
198
210
|
signal_errors!
|
|
199
211
|
end
|
|
200
212
|
|
|
213
|
+
def rate_limit_execution(&)
|
|
214
|
+
limit = @task.class.rate_limit
|
|
215
|
+
return yield unless limit.enabled?
|
|
216
|
+
|
|
217
|
+
limit.process(@task, &)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def circuit_execution(&)
|
|
221
|
+
breaker = @task.class.circuit_breaker
|
|
222
|
+
return yield unless breaker.enabled?
|
|
223
|
+
|
|
224
|
+
breaker.process(@task, &)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def lock_execution(&)
|
|
228
|
+
lock = @task.class.lock_with
|
|
229
|
+
return yield unless lock.enabled?
|
|
230
|
+
|
|
231
|
+
lock.process(@task, &)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def idempotent_execution(&)
|
|
235
|
+
idem = @task.class.idempotent_by
|
|
236
|
+
return yield unless idem.enabled?
|
|
237
|
+
|
|
238
|
+
idem.process(@task, &)
|
|
239
|
+
end
|
|
240
|
+
|
|
201
241
|
def retry_execution
|
|
202
242
|
@task.class.retry_on.process(@task) do |attempt|
|
|
203
243
|
@retries = attempt
|
|
204
|
-
|
|
244
|
+
Telemetry.emit(@task, :task_retried, root: @root, attempt:) if attempt.positive?
|
|
205
245
|
yield
|
|
206
246
|
end
|
|
207
247
|
|
|
@@ -222,13 +262,5 @@ module CMDx
|
|
|
222
262
|
throw(Signal::TAG, Signal.failed(@task.errors.to_s, metadata: @task.metadata))
|
|
223
263
|
end
|
|
224
264
|
|
|
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
265
|
end
|
|
234
266
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# Shared store key helpers for store-backed task features ({Idempotency},
|
|
5
|
+
# {CircuitBreaker}, {Lock}, {RateLimit}, and others).
|
|
6
|
+
#
|
|
7
|
+
# {compose} joins colon-separated segments, each coerced with {Stores.key!}.
|
|
8
|
+
# {segment} resolves a per-invocation suffix from a declaration (symbol,
|
|
9
|
+
# proc, callable, or literal).
|
|
10
|
+
module StoreKey
|
|
11
|
+
|
|
12
|
+
extend self
|
|
13
|
+
|
|
14
|
+
# Resolver for one breaker or limiter per task class (see {CircuitBreaker},
|
|
15
|
+
# {RateLimit}). Not used for segment-required features ({Idempotency},
|
|
16
|
+
# {Lock}).
|
|
17
|
+
CLASS_KEY = :class
|
|
18
|
+
|
|
19
|
+
# @param segments [Array<Object>] key segments coerced with {Stores.key!}
|
|
20
|
+
# @return [String] joined key
|
|
21
|
+
# @raise [ArgumentError] when any segment is empty after coercion
|
|
22
|
+
def compose(*segments)
|
|
23
|
+
segments.map! { |s| Stores.key!(s) }.join(":")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Resolves a store key segment from a task. Used by {Idempotency},
|
|
27
|
+
# {CircuitBreaker}, and other store-backed declarations.
|
|
28
|
+
#
|
|
29
|
+
# @param task [Task]
|
|
30
|
+
# @param resolver [Symbol, String, Proc, #call]
|
|
31
|
+
# @param skip [Object, nil] when `resolver == skip`, returns `nil` without
|
|
32
|
+
# reading context (for example {CLASS_KEY})
|
|
33
|
+
# @return [String, nil] non-empty segment, or `nil` when blank or skipped
|
|
34
|
+
def segment(task, resolver, skip: nil)
|
|
35
|
+
return if skip && resolver == skip
|
|
36
|
+
|
|
37
|
+
raw =
|
|
38
|
+
case resolver
|
|
39
|
+
when Symbol
|
|
40
|
+
task.context[resolver]
|
|
41
|
+
when Proc
|
|
42
|
+
task.instance_exec(task, &resolver)
|
|
43
|
+
else
|
|
44
|
+
return resolver.call(task) if resolver.respond_to?(:call)
|
|
45
|
+
|
|
46
|
+
resolver
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
str = raw&.to_s
|
|
50
|
+
str if str && !str.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Builds the base store key for a task invocation.
|
|
54
|
+
#
|
|
55
|
+
# @param task [Task]
|
|
56
|
+
# @param namespace [String]
|
|
57
|
+
# @param resolver [Symbol, String, Proc, #call]
|
|
58
|
+
# @param class_scoped [Boolean] when true, {CLASS_KEY} (`:class`) resolves to
|
|
59
|
+
# `namespace:TaskName` without reading context; blank segments for other
|
|
60
|
+
# resolvers return `nil` instead of raising
|
|
61
|
+
# @return [String, nil] full key, or `nil` when the segment is blank
|
|
62
|
+
# @see RateLimit#process
|
|
63
|
+
# @see CircuitBreaker#process
|
|
64
|
+
def task_key(task, namespace:, resolver:, class_scoped: false)
|
|
65
|
+
skip = class_scoped ? CLASS_KEY : nil
|
|
66
|
+
segment_value = segment(task, resolver, skip:)
|
|
67
|
+
|
|
68
|
+
if segment_value.nil?
|
|
69
|
+
return compose(namespace, task.class.name) if class_scoped && resolver == CLASS_KEY
|
|
70
|
+
|
|
71
|
+
return nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
compose(namespace, task.class.name, segment_value)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
entry = @entries[key]
|
|
22
|
+
return unless entry
|
|
23
|
+
return if expired?(entry)
|
|
24
|
+
|
|
25
|
+
entry[:value]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param key [String, Symbol]
|
|
30
|
+
# @param value [#to_s]
|
|
31
|
+
# @param ttl [Integer, nil] seconds until expiry (`nil` = no expiry)
|
|
32
|
+
# @param nx [Boolean] set only when the key is absent
|
|
33
|
+
# @param xx [Boolean] set only when the key is present
|
|
34
|
+
# @return [Boolean] whether the value was written
|
|
35
|
+
# @raise [ArgumentError] when `nx` and `xx` are both true, or `ttl` is invalid
|
|
36
|
+
def set(key, value, ttl: nil, nx: false, xx: false)
|
|
37
|
+
key = Stores.key!(key)
|
|
38
|
+
ttl = Stores.ttl!(ttl) if ttl
|
|
39
|
+
Stores.nx_xx!(nx, xx)
|
|
40
|
+
|
|
41
|
+
expires_at = ttl ? Util.monotonic_now + ttl : nil
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
entry = @entries[key]
|
|
44
|
+
present = entry && !expired?(entry)
|
|
45
|
+
|
|
46
|
+
return false if nx && present
|
|
47
|
+
return false if xx && !present
|
|
48
|
+
|
|
49
|
+
@entries[key] = { value: value.to_s, expires_at: }
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param key [String, Symbol]
|
|
55
|
+
# @param ttl [Integer, nil] when set, refreshes expiry on every increment
|
|
56
|
+
# @return [Integer] value after increment
|
|
57
|
+
# @raise [ArgumentError] when `ttl` is invalid
|
|
58
|
+
def incr(key, ttl: nil)
|
|
59
|
+
key = Stores.key!(key)
|
|
60
|
+
ttl = Stores.ttl!(ttl) if ttl
|
|
61
|
+
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
entry = @entries[key]
|
|
64
|
+
if entry && expired?(entry)
|
|
65
|
+
@entries.delete(key)
|
|
66
|
+
entry = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
count = entry ? entry[:value].to_i + 1 : 1
|
|
70
|
+
expires_at = (Util.monotonic_now + ttl if ttl)
|
|
71
|
+
|
|
72
|
+
@entries[key] = { value: count.to_s, expires_at: }
|
|
73
|
+
count
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param key [String, Symbol]
|
|
78
|
+
# @return [Integer, nil] whole seconds until expiry, or `nil` when missing or expired
|
|
79
|
+
def ttl(key)
|
|
80
|
+
key = Stores.key!(key)
|
|
81
|
+
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
entry = @entries[key]
|
|
84
|
+
return unless entry
|
|
85
|
+
return if expired?(entry)
|
|
86
|
+
|
|
87
|
+
expires_at = entry[:expires_at]
|
|
88
|
+
return unless expires_at
|
|
89
|
+
|
|
90
|
+
remaining = expires_at - Util.monotonic_now
|
|
91
|
+
remaining.positive? ? remaining.ceil : nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param key [String, Symbol]
|
|
96
|
+
# @return [Integer] value after decrement (floors at `0`, removing the key)
|
|
97
|
+
# @raise [ArgumentError] when `ttl` is invalid (only when `ttl` is passed)
|
|
98
|
+
def decr(key, ttl: nil)
|
|
99
|
+
key = Stores.key!(key)
|
|
100
|
+
Stores.ttl!(ttl) if ttl
|
|
101
|
+
|
|
102
|
+
@mutex.synchronize do
|
|
103
|
+
entry = @entries[key]
|
|
104
|
+
if entry && expired?(entry)
|
|
105
|
+
@entries.delete(key)
|
|
106
|
+
entry = nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
count = entry ? entry[:value].to_i : 0
|
|
110
|
+
count = count.positive? ? count - 1 : 0
|
|
111
|
+
expires_at = entry&.fetch(:expires_at, nil)
|
|
112
|
+
|
|
113
|
+
if count.zero?
|
|
114
|
+
@entries.delete(key)
|
|
115
|
+
else
|
|
116
|
+
@entries[key] = { value: count.to_s, expires_at: }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
count
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param key [String, Symbol]
|
|
124
|
+
# @return [Integer] `1` when a key was removed, `0` otherwise
|
|
125
|
+
def del(key)
|
|
126
|
+
key = Stores.key!(key)
|
|
127
|
+
|
|
128
|
+
@mutex.synchronize do
|
|
129
|
+
@entries.delete(key) ? 1 : 0
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @param key [String, Symbol]
|
|
134
|
+
# @param expected [#to_s]
|
|
135
|
+
# @return [Boolean] whether the key was removed
|
|
136
|
+
def del_if(key, expected)
|
|
137
|
+
key = Stores.key!(key)
|
|
138
|
+
|
|
139
|
+
@mutex.synchronize do
|
|
140
|
+
entry = @entries[key]
|
|
141
|
+
return false unless entry && !expired?(entry)
|
|
142
|
+
return false unless entry[:value] == expected.to_s
|
|
143
|
+
|
|
144
|
+
@entries.delete(key)
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def expired?(entry)
|
|
152
|
+
expires_at = entry[:expires_at]
|
|
153
|
+
expires_at && Util.monotonic_now >= expires_at
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|