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.
@@ -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
- # `Task.retry_on` and accumulates across inheritance.
7
+ # {Task.retry_on} and accumulates across inheritance; {Task.retry_on} with
8
+ # `false` selects {Retry.disabled}.
8
9
  class Retry
9
10
 
11
+ class << self
12
+
13
+ # @return [Retry] disabled configuration (no exceptions)
14
+ def disabled
15
+ DISABLED
16
+ end
17
+
18
+ end
19
+
10
20
  attr_reader :exceptions
11
21
 
12
22
  # @param exceptions [Array<Class>] exceptions to retry on
@@ -67,6 +77,11 @@ module CMDx
67
77
  @options[:jitter] || @block
68
78
  end
69
79
 
80
+ # @return [Boolean]
81
+ def enabled?
82
+ !exceptions.empty?
83
+ end
84
+
70
85
  # Sleeps `attempt`'s jittered/bounded delay. No-op when the base delay is zero.
71
86
  #
72
87
  # Custom jitter callables (registry, task Symbol method, `Proc` / block via
@@ -125,7 +140,7 @@ module CMDx
125
140
  # @return [Object] the block's successful return value
126
141
  # @raise [Exception] the last caught exception once retries exhaust
127
142
  def process(task = nil, &)
128
- return yield(0) if exceptions.empty? || !limit.positive?
143
+ return yield(0) if !enabled? || !limit.positive?
129
144
 
130
145
  prev_delay = nil
131
146
  (limit + 1).times do |attempt|
@@ -148,5 +163,8 @@ module CMDx
148
163
  end
149
164
  end
150
165
 
166
+ DISABLED = new([], EMPTY_HASH).freeze
167
+ private_constant :DISABLED
168
+
151
169
  end
152
170
  end
data/lib/cmdx/runtime.rb CHANGED
@@ -3,8 +3,10 @@
3
3
  module CMDx
4
4
  # Orchestrates a task's full lifecycle: chain acquisition, middlewares,
5
5
  # telemetry, deprecation, callbacks, input resolution, `work` (wrapped in
6
- # retry), output verification, rollback on failure, result finalization,
7
- # and teardown (freeze + chain clear).
6
+ # {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
- emit_telemetry(:task_started)
54
+ Telemetry.emit(@task, :task_started, root: @root)
53
55
  run_deprecation
54
56
  run_lifecycle
55
57
  end
@@ -84,7 +86,7 @@ module CMDx
84
86
 
85
87
  deprecation.execute(@task) do
86
88
  @deprecated = true
87
- emit_telemetry(:task_deprecated)
89
+ Telemetry.emit(@task, :task_deprecated, root: @root)
88
90
  end
89
91
  end
90
92
 
@@ -127,7 +129,7 @@ module CMDx
127
129
  duration: @duration
128
130
  ).tap do |result|
129
131
  @root ? Chain.current.unshift(result) : Chain.current.push(result)
130
- emit_telemetry(:task_executed, result:)
132
+ Telemetry.emit(@task, :task_executed, root: @root, result:)
131
133
  @task.logger.info do
132
134
  exclusions = @task.class.settings.log_exclusions
133
135
  exclusions.empty? ? result : result.to_h.except(*exclusions)
@@ -146,10 +148,10 @@ module CMDx
146
148
  end
147
149
 
148
150
  def measure_duration
149
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
151
+ start = Util.monotonic_now(:float_millisecond)
150
152
  yield
151
153
  ensure
152
- @duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
154
+ @duration = Util.monotonic_now(:float_millisecond) - start
153
155
  end
154
156
 
155
157
  def run_callbacks(event)
@@ -170,8 +172,18 @@ module CMDx
170
172
  def perform_work
171
173
  @signal = catch(Signal::TAG) do
172
174
  resolve_inputs!
173
- retry_execution { @task.work }
174
- verify_outputs!
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
- emit_telemetry(:task_rolled_back)
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
- emit_telemetry(:task_retried, attempt:) if attempt.positive?
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