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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -1
- data/TODO.md +353 -0
- data/lib/cmdx/callbacks.rb +38 -7
- data/lib/cmdx/circuit_breaker.rb +234 -0
- data/lib/cmdx/coercions.rb +1 -0
- data/lib/cmdx/configuration.rb +2 -1
- data/lib/cmdx/deprecation.rb +2 -2
- data/lib/cmdx/fault.rb +38 -0
- data/lib/cmdx/idempotency.rb +172 -0
- data/lib/cmdx/input.rb +12 -2
- data/lib/cmdx/inputs.rb +4 -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 +44 -20
- 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 +118 -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 +27 -8
- data/mkdocs.yml +8 -1
- metadata +10 -2
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,17 +148,18 @@ 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)
|
|
156
158
|
callbacks = @task.class.callbacks
|
|
157
159
|
return if callbacks.empty?
|
|
158
160
|
|
|
159
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|