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,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ module Stores
5
+ # Delegates to a Redis-compatible client (`redis-rb`, `MockRedis`, etc.).
6
+ # Optional `namespace` prefixes keys when Redis is shared with other data.
7
+ #
8
+ # The client must respond to `get`, `set`, `incr`, `expire`, `ttl`, `del`, and `eval`.
9
+ class Redis
10
+
11
+ DEL_IF_SCRIPT = <<~LUA
12
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
13
+ return redis.call('DEL', KEYS[1])
14
+ else
15
+ return 0
16
+ end
17
+ LUA
18
+
19
+ # @param redis [#get, #set, #incr, #expire, #del, #eval] backing client
20
+ # @param namespace [String] optional prefix for every key (trailing `:` added when
21
+ # missing; empty string stores logical keys as-is, matching {Memory})
22
+ def initialize(redis:, namespace: EMPTY_STRING)
23
+ @redis = redis
24
+ @namespace = namespace_prefix(namespace)
25
+ end
26
+
27
+ # @param key [String, Symbol]
28
+ # @return [String, nil]
29
+ def get(key)
30
+ @redis.get(prefixed(key))
31
+ end
32
+
33
+ # @param key [String, Symbol]
34
+ # @param value [#to_s]
35
+ # @param ttl [Integer, nil]
36
+ # @param nx [Boolean]
37
+ # @param xx [Boolean]
38
+ # @return [Boolean]
39
+ # @raise [ArgumentError] when `nx` and `xx` are both true, or `ttl` is invalid
40
+ def set(key, value, ttl: nil, nx: false, xx: false) # rubocop:disable Naming/PredicateMethod -- Redis SET contract
41
+ key = prefixed(key)
42
+ ttl = Stores.ttl!(ttl) if ttl
43
+ Stores.nx_xx!(nx, xx)
44
+
45
+ options = {}
46
+ options[:ex] = ttl if ttl
47
+ options[:nx] = true if nx
48
+ options[:xx] = true if xx
49
+
50
+ if options.empty?
51
+ @redis.set(key, value.to_s)
52
+ true
53
+ else
54
+ !!@redis.set(key, value.to_s, **options)
55
+ end
56
+ end
57
+
58
+ # @param key [String, Symbol]
59
+ # @param ttl [Integer, nil]
60
+ # @return [Integer]
61
+ # @raise [ArgumentError] when `ttl` is invalid
62
+ def incr(key, ttl: nil)
63
+ key = prefixed(key)
64
+ ttl = Stores.ttl!(ttl) if ttl
65
+
66
+ count = @redis.incr(key)
67
+ @redis.expire(key, ttl) if ttl
68
+ count
69
+ end
70
+
71
+ # @param key [String, Symbol]
72
+ # @return [Integer, nil] seconds until expiry, or `nil` when missing or expired
73
+ def ttl(key)
74
+ key = prefixed(key)
75
+ seconds = @redis.ttl(key)
76
+ seconds if seconds.is_a?(Integer) && seconds.positive?
77
+ end
78
+
79
+ # @param key [String, Symbol]
80
+ # @return [Integer]
81
+ def decr(key, ttl: nil)
82
+ key = prefixed(key)
83
+ Stores.ttl!(ttl) if ttl
84
+
85
+ @redis.decr(key)
86
+ end
87
+
88
+ # @param key [String, Symbol]
89
+ # @return [Integer]
90
+ def del(key)
91
+ @redis.del(prefixed(key))
92
+ end
93
+
94
+ # @param key [String, Symbol]
95
+ # @param expected [#to_s]
96
+ # @return [Boolean] whether the key was removed
97
+ def del_if(key, expected) # rubocop:disable Naming/PredicateMethod -- store delete contract
98
+ key = prefixed(key)
99
+ deleted = @redis.eval(DEL_IF_SCRIPT, keys: [key], argv: [expected.to_s])
100
+ deleted.to_i.positive?
101
+ end
102
+
103
+ private
104
+
105
+ def prefixed(key)
106
+ "#{@namespace}#{Stores.key!(key)}"
107
+ end
108
+
109
+ def namespace_prefix(namespace)
110
+ str = namespace.to_s
111
+ return EMPTY_STRING if str.empty?
112
+
113
+ str.end_with?(":") ? str : "#{str}:"
114
+ end
115
+
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Key/value scratch space for idempotency keys, locks, rate limits, circuit breakers,
5
+ # checkpoints, and cached results. {Stores} delegates to
6
+ # {Configuration#store}; swap the configured backend (for example
7
+ # {Stores::Memory} or {Stores::Redis}) at boot.
8
+ module Stores
9
+
10
+ extend self
11
+
12
+ # @param key [String, Symbol]
13
+ # @return [String, nil] stored value, or `nil` when missing or expired
14
+ # @raise [ArgumentError] when `key` is blank
15
+ # @see Configuration#store
16
+ def get(key)
17
+ CMDx.configuration.store.get(key)
18
+ end
19
+
20
+ # @param key [String, Symbol]
21
+ # @param value [#to_s]
22
+ # @param ttl [Integer, nil]
23
+ # @param nx [Boolean]
24
+ # @param xx [Boolean]
25
+ # @return [Boolean] whether the value was written
26
+ # @raise [ArgumentError] when `key` is blank, `ttl` is invalid, or `nx` and `xx` are both true
27
+ def set(key, value, ttl: nil, nx: false, xx: false)
28
+ CMDx.configuration.store.set(key, value, ttl:, nx:, xx:)
29
+ end
30
+
31
+ # @param key [String, Symbol]
32
+ # @param ttl [Integer, nil] when set, refreshes expiry on every increment
33
+ # @return [Integer] value after increment
34
+ # @raise [ArgumentError] when `key` is blank or `ttl` is invalid
35
+ def incr(key, ttl: nil)
36
+ CMDx.configuration.store.incr(key, ttl:)
37
+ end
38
+
39
+ # @param key [String, Symbol]
40
+ # @param ttl [Integer, nil] unused; accepted for API symmetry with {#incr}
41
+ # @return [Integer] value after decrement
42
+ # @raise [ArgumentError] when `key` is blank
43
+ def decr(key, ttl: nil)
44
+ CMDx.configuration.store.decr(key, ttl:)
45
+ end
46
+
47
+ # @param key [String, Symbol]
48
+ # @return [Integer] `1` when a key was removed, `0` otherwise
49
+ # @raise [ArgumentError] when `key` is blank
50
+ def del(key)
51
+ CMDx.configuration.store.del(key)
52
+ end
53
+
54
+ # @param key [String, Symbol]
55
+ # @param expected [#to_s] value that must match the stored string for deletion
56
+ # @return [Boolean] whether the key was removed
57
+ # @raise [ArgumentError] when `key` is blank
58
+ # @raise [NotImplementedError] when the configured store does not implement {#del_if}
59
+ def del_if(key, expected)
60
+ store = CMDx.configuration.store
61
+ raise NotImplementedError, "store must implement del_if(key, expected) for lock release" unless store.respond_to?(:del_if)
62
+
63
+ store.del_if(key, expected)
64
+ end
65
+
66
+ # @param key [String, Symbol]
67
+ # @return [Integer, nil] seconds until expiry when the backend supports
68
+ # {#ttl}; `nil` when the key is missing, expired, or TTL is unavailable
69
+ # @raise [ArgumentError] when `key` is blank
70
+ def remaining_ttl(key)
71
+ store = CMDx.configuration.store
72
+ return unless store.respond_to?(:ttl)
73
+
74
+ seconds = store.ttl(key)
75
+ seconds if seconds.is_a?(Integer) && seconds.positive?
76
+ end
77
+
78
+ # @param key [String, Symbol]
79
+ # @return [String]
80
+ # @raise [ArgumentError] when `key` is blank
81
+ # @api private
82
+ def key!(key)
83
+ str = key.to_s
84
+ raise ArgumentError, "store key must be non-empty" if str.empty?
85
+
86
+ str
87
+ end
88
+
89
+ # @param ttl [Integer]
90
+ # @return [Integer]
91
+ # @raise [ArgumentError] when `ttl` is not a positive Integer
92
+ # @api private
93
+ def ttl!(ttl)
94
+ return ttl if ttl.is_a?(Integer) && ttl.positive?
95
+
96
+ raise ArgumentError, "store ttl must be a positive Integer (got #{ttl.inspect})"
97
+ end
98
+
99
+ # @param nx [Boolean]
100
+ # @param xx [Boolean]
101
+ # @return [void]
102
+ # @raise [ArgumentError] when both flags are true
103
+ # @api private
104
+ def nx_xx!(nx, xx)
105
+ return unless nx && xx
106
+
107
+ raise ArgumentError, "store set cannot use nx and xx together"
108
+ end
109
+
110
+ end
111
+ end
data/lib/cmdx/task.rb CHANGED
@@ -3,8 +3,10 @@
3
3
  module CMDx
4
4
  # Base class for all units of work. Subclasses override `#work` and
5
5
  # declare their contract via `required`, `optional`, `output`, `callbacks`,
6
- # `retry_on`, `deprecation`, and `settings`. Invoked via {.execute} (safe)
7
- # or {.execute!} (strict, raises on failure).
6
+ # `retry_on`, `idempotent_by`, `lock_with`, `circuit_breaker`, `rate_limit`,
7
+ # `deprecation`, and `settings`.
8
+ #
9
+ # Invoked via {.execute} (safe) or {.execute!} (strict, raises on failure).
8
10
  #
9
11
  # Inheritance: every registry accessor (middlewares, callbacks, coercions,
10
12
  # validators, executors, mergers, telemetry, inputs, outputs) lazily clones from the
@@ -18,9 +20,11 @@ module CMDx
18
20
  class << self
19
21
 
20
22
  # Declares exceptions to retry on. Builds on the superclass's `Retry`.
21
- # Passing no exceptions returns the current (possibly inherited) Retry.
23
+ # Passing no exceptions and no options returns the current (possibly
24
+ # inherited) Retry. Pass `false` to disable retries on this class.
22
25
  #
23
- # @param exceptions [Array<Class>]
26
+ # @param exceptions [Array<Class, false>] exception classes; `false` alone
27
+ # disables retries on this class
24
28
  # @param options [Hash{Symbol => Object}] see {Retry#initialize}
25
29
  # @option options [Integer] :limit (see {Retry#initialize})
26
30
  # @option options [Float] :delay (see {Retry#initialize})
@@ -33,14 +37,155 @@ module CMDx
33
37
  def retry_on(*exceptions, **options, &)
34
38
  @retry_on ||=
35
39
  if superclass.respond_to?(:retry_on)
36
- superclass.retry_on.build(exceptions, options, &)
40
+ superclass.retry_on
41
+ else
42
+ Retry.disabled
43
+ end
44
+
45
+ @retry_on =
46
+ if exceptions == [false]
47
+ Retry.disabled
48
+ else
49
+ @retry_on.build(exceptions, options, &)
50
+ end
51
+ end
52
+
53
+ # Declares a stable idempotency key from context (or a callable). Builds on
54
+ # the superclass's {Idempotency}. Passing no `key` and no options returns the
55
+ # current (possibly inherited) configuration. Pass `false` to disable.
56
+ #
57
+ # @param key [Symbol, String, Proc, #call, false, nil] context key or
58
+ # resolver; `false` disables idempotency on this class
59
+ # @param options [Hash{Symbol => Object}] see {Idempotency#initialize}
60
+ # @option options [Integer] :ttl (see {Idempotency#initialize})
61
+ # @option options [String] :namespace (see {Idempotency#initialize})
62
+ # @option options [Symbol] :on_duplicate (see {Idempotency#initialize})
63
+ # @option options [Symbol, Proc, #call] :if gate `(task)` for claiming
64
+ # @option options [Symbol, Proc, #call] :unless gate `(task)` for claiming
65
+ # @yieldparam task [Task] optional first argument when the block is
66
+ # `instance_exec`'d (same as callbacks)
67
+ # @return [Idempotency]
68
+ # @see Idempotency#process
69
+ def idempotent_by(key = nil, **options, &block)
70
+ @idempotent_by ||=
71
+ if superclass.respond_to?(:idempotent_by)
72
+ superclass.idempotent_by
73
+ else
74
+ Idempotency.disabled
75
+ end
76
+
77
+ @idempotent_by =
78
+ if key == false
79
+ Idempotency.disabled
80
+ else
81
+ @idempotent_by.build(key || block, options)
82
+ end
83
+ end
84
+
85
+ # Declares a distributed lock from context (or a callable). Builds on the
86
+ # superclass's {Lock}. Passing no `key` and no options returns the current
87
+ # (possibly inherited) configuration. Pass `false` to disable.
88
+ #
89
+ # @param key [Symbol, String, Proc, #call, false, nil] context key or
90
+ # resolver; `false` disables locking on this class
91
+ # @param options [Hash{Symbol => Object}] see {Lock#initialize}
92
+ # @option options [Integer] :ttl (see {Lock#initialize})
93
+ # @option options [String] :namespace (see {Lock#initialize})
94
+ # @option options [Numeric, false, nil] :wait (see {Lock#initialize})
95
+ # @option options [Symbol] :on_busy (see {Lock#initialize})
96
+ # @option options [Symbol, Proc, #call] :if gate `(task)` for acquiring
97
+ # @option options [Symbol, Proc, #call] :unless gate `(task)` for acquiring
98
+ # @yieldparam task [Task] optional first argument when the block is
99
+ # `instance_exec`'d (same as callbacks)
100
+ # @return [Lock]
101
+ # @see Lock#process
102
+ def lock_with(key = nil, **options, &block)
103
+ @lock_with ||=
104
+ if superclass.respond_to?(:lock_with)
105
+ superclass.lock_with
106
+ else
107
+ Lock.disabled
108
+ end
109
+
110
+ @lock_with =
111
+ if key == false
112
+ Lock.disabled
37
113
  else
38
- Retry.new(exceptions, options, &)
114
+ @lock_with.build(key || block, options)
39
115
  end
116
+ end
40
117
 
41
- return @retry_on if exceptions.empty?
118
+ # Declares a shared circuit breaker for the task. Builds on the
119
+ # superclass's {CircuitBreaker}. Passing no `key` and no options returns the
120
+ # current (possibly inherited) configuration. Pass `false` to disable.
121
+ #
122
+ # @param key [Symbol, String, Proc, #call, false, nil] `:class` scopes one
123
+ # breaker per task class; other forms resolve a store segment like
124
+ # {Idempotency}; `false` disables the breaker on this class
125
+ # @param options [Hash{Symbol => Object}] see {CircuitBreaker#initialize}
126
+ # @option options [Integer] :threshold (see {CircuitBreaker#initialize})
127
+ # @option options [Integer] :cool_off (see {CircuitBreaker#initialize})
128
+ # @option options [Integer] :window (see {CircuitBreaker#initialize})
129
+ # @option options [String] :namespace (see {CircuitBreaker#initialize})
130
+ # @option options [Symbol] :on_open (see {CircuitBreaker#initialize})
131
+ # @option options [Array<Class>] :on (see {CircuitBreaker#initialize})
132
+ # @option options [Symbol, Proc, #call] :if gate `(task)` before checking
133
+ # @option options [Symbol, Proc, #call] :unless gate `(task)` before checking
134
+ # @yieldparam task [Task] optional first argument when the block is
135
+ # `instance_exec`'d (same as callbacks)
136
+ # @return [CircuitBreaker]
137
+ # @see CircuitBreaker#process
138
+ def circuit_breaker(key = nil, **options, &block)
139
+ @circuit_breaker ||=
140
+ if superclass.respond_to?(:circuit_breaker)
141
+ superclass.circuit_breaker
142
+ else
143
+ CircuitBreaker.disabled
144
+ end
42
145
 
43
- @retry_on = @retry_on.build(exceptions, options, &)
146
+ @circuit_breaker =
147
+ if key == false
148
+ CircuitBreaker.disabled
149
+ else
150
+ @circuit_breaker.build(key || block, options)
151
+ end
152
+ end
153
+
154
+ # Declares a shared rate limit for the task. Builds on the superclass's
155
+ # {RateLimit}. Passing no `key` and no options returns the current
156
+ # (possibly inherited) configuration. Pass `false` to disable.
157
+ #
158
+ # @param key [Symbol, String, Proc, #call, false, nil] `:class` scopes one
159
+ # limit per task class; other forms resolve a store segment like
160
+ # {Idempotency}; `false` disables rate limiting on this class
161
+ # @param options [Hash{Symbol => Object}] see {RateLimit#initialize}
162
+ # @option options [Integer] :max (see {RateLimit#initialize})
163
+ # @option options [Integer] :per (see {RateLimit#initialize})
164
+ # @option options [Symbol, #call] :limiter (see {RateLimit#initialize})
165
+ # @option options [Numeric] :cost (see {RateLimit#initialize})
166
+ # @option options [String] :namespace (see {RateLimit#initialize})
167
+ # @option options [Symbol] :on_limit (see {RateLimit#initialize})
168
+ # @option options [Symbol, Proc, #call] :if gate `(task)` before checking
169
+ # @option options [Symbol, Proc, #call] :unless gate `(task)` before checking
170
+ # @yieldparam task [Task] optional first argument when the block is
171
+ # `instance_exec`'d (same as callbacks)
172
+ # @return [RateLimit]
173
+ # @see RateLimit#process
174
+ # @see StoreKey.task_key
175
+ def rate_limit(key = nil, **options, &block)
176
+ @rate_limit ||=
177
+ if superclass.respond_to?(:rate_limit)
178
+ superclass.rate_limit
179
+ else
180
+ RateLimit.disabled
181
+ end
182
+
183
+ @rate_limit =
184
+ if key == false
185
+ RateLimit.disabled
186
+ else
187
+ @rate_limit.build(key || block, options)
188
+ end
44
189
  end
45
190
 
46
191
  # Reads or extends this class's {Settings}. Inherits from the superclass.
@@ -156,6 +301,16 @@ module CMDx
156
301
  end
157
302
  end
158
303
 
304
+ # @return [Limiters] cloned from superclass/configuration on first call
305
+ def limiters
306
+ @limiters ||=
307
+ if superclass.respond_to?(:limiters)
308
+ superclass.limiters.dup
309
+ else
310
+ CMDx.configuration.limiters.dup
311
+ end
312
+ end
313
+
159
314
  # @return [Deprecators] cloned from superclass/configuration on first call
160
315
  def deprecators
161
316
  @deprecators ||=
@@ -168,7 +323,7 @@ module CMDx
168
323
 
169
324
  # Dispatches to the appropriate registry's `register` method.
170
325
  #
171
- # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output]
326
+ # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :limiter, :deprecator, :input, :output]
172
327
  # @return [Object] the registry's self
173
328
  # @raise [ArgumentError] when `type` is unknown
174
329
  def register(type, ...)
@@ -187,6 +342,8 @@ module CMDx
187
342
  mergers.register(...)
188
343
  when :retrier
189
344
  retriers.register(...)
345
+ when :limiter
346
+ limiters.register(...)
190
347
  when :deprecator
191
348
  deprecators.register(...)
192
349
  when :input
@@ -196,7 +353,7 @@ module CMDx
196
353
  else
197
354
  raise ArgumentError, <<~MSG.chomp
198
355
  unknown registry type #{type.inspect};
199
- expected one of [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output].
356
+ expected one of [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :limiter, :deprecator, :input, :output].
200
357
  See https://drexed.github.io/cmdx/configuration/#registrations-register-deregister
201
358
  MSG
202
359
  end
@@ -204,7 +361,7 @@ module CMDx
204
361
 
205
362
  # Dispatches to the appropriate registry's `deregister` method.
206
363
  #
207
- # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output]
364
+ # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :limiter, :deprecator, :input, :output]
208
365
  # @return [Object] the registry's self
209
366
  # @raise [ArgumentError] when `type` is unknown
210
367
  def deregister(type, ...)
@@ -223,6 +380,8 @@ module CMDx
223
380
  mergers.deregister(...)
224
381
  when :retrier
225
382
  retriers.deregister(...)
383
+ when :limiter
384
+ limiters.deregister(...)
226
385
  when :deprecator
227
386
  deprecators.deregister(...)
228
387
  when :input
@@ -232,7 +391,7 @@ module CMDx
232
391
  else
233
392
  raise ArgumentError, <<~MSG.chomp
234
393
  unknown registry type #{type.inspect};
235
- expected one of [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output].
394
+ expected one of [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :limiter, :deprecator, :input, :output].
236
395
  See https://drexed.github.io/cmdx/configuration/#registrations-register-deregister
237
396
  MSG
238
397
  end
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
- # Pub/sub for runtime lifecycle events (see {EVENTS}). Subscribers are
5
- # callables receiving an {Event} data object. Runtime emits events only when
6
- # subscribers are registered so telemetry has zero cost when unused.
4
+ # Pub/sub for runtime lifecycle and infrastructure events (see {EVENTS}).
5
+ # Subscribers are callables receiving an {Event} data object. {emit}
6
+ # dispatches only when subscribers are registered so telemetry has zero cost
7
+ # when unused.
7
8
  class Telemetry
8
9
 
9
10
  # Immutable event payload passed to subscribers.
@@ -23,15 +24,46 @@ module CMDx
23
24
  end
24
25
  end
25
26
 
26
- # Lifecycle event names Runtime emits.
27
+ # Lifecycle and infrastructure event names the runtime and store-backed
28
+ # helpers emit.
27
29
  EVENTS = %i[
28
30
  task_started
29
31
  task_deprecated
30
32
  task_retried
31
33
  task_rolled_back
32
34
  task_executed
35
+ lock_acquired
36
+ lock_busy
37
+ idempotency_claimed
38
+ idempotency_completed
39
+ idempotency_duplicate
40
+ circuit_open
41
+ circuit_tripped
42
+ circuit_reset
43
+ rate_limit_admitted
44
+ rate_limit_exceeded
33
45
  ].freeze
34
46
 
47
+ class << self
48
+
49
+ # Dispatches `payload` when `task`'s telemetry registry has subscribers
50
+ # for `name`. No-op when nothing is subscribed.
51
+ #
52
+ # @param task [Task]
53
+ # @param name [Symbol] one of {EVENTS}
54
+ # @param root [Boolean] whether this task started the active chain
55
+ # @param payload [Hash{Symbol => Object}] extra fields merged into the event payload
56
+ # @return [void]
57
+ def emit(task, name, root: false, **payload)
58
+ telemetry = task.class.telemetry
59
+ return unless telemetry.subscribed?(name)
60
+
61
+ event = Event.build(task, name, root:, payload:)
62
+ telemetry.emit(name, event)
63
+ end
64
+
65
+ end
66
+
35
67
  attr_reader :registry
36
68
 
37
69
  def initialize
data/lib/cmdx/util.rb CHANGED
@@ -69,6 +69,13 @@ module CMDx
69
69
  unless?(condition_unless, receiver, *args)
70
70
  end
71
71
 
72
+ # Returns the current time in seconds since the process started.
73
+ #
74
+ # @return [Float]
75
+ def monotonic_now(unit = :float_second)
76
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, unit)
77
+ end
78
+
72
79
  # Recursively merges two Hash-like trees. When both values at a key are
73
80
  # Hashes, they merge recursively; otherwise the right-hand value wins
74
81
  # (last-write-wins). When either top-level operand is not a Hash, returns
data/lib/cmdx/version.rb CHANGED
@@ -3,6 +3,6 @@
3
3
  module CMDx
4
4
 
5
5
  # Gem version. Bumped on release; mirrored in the gemspec.
6
- VERSION = "2.2.0"
6
+ VERSION = "2.4.0"
7
7
 
8
8
  end
data/lib/cmdx.rb CHANGED
@@ -49,6 +49,10 @@ module CMDx
49
49
  # continuation, which would otherwise silently skip the task body.
50
50
  CallbackError = Class.new(Error)
51
51
 
52
+ # Raised when the idempotency store refuses to transition the key from
53
+ # `processing` to `completed`.
54
+ IdempotencyError = Class.new(Error)
55
+
52
56
  # Raised when a task or workflow attempts to define an input where an
53
57
  # accessor with the same name already exists.
54
58
  DefinitionError = Class.new(Error)
@@ -120,6 +124,9 @@ require_relative "cmdx/validators/numeric"
120
124
  require_relative "cmdx/validators/presence"
121
125
  require_relative "cmdx/validators/validate"
122
126
  require_relative "cmdx/validators"
127
+ require_relative "cmdx/stores/memory"
128
+ require_relative "cmdx/stores/redis"
129
+ require_relative "cmdx/stores"
123
130
  require_relative "cmdx/executors/thread"
124
131
  require_relative "cmdx/executors/fiber"
125
132
  require_relative "cmdx/executors"
@@ -135,6 +142,9 @@ require_relative "cmdx/retriers/linear"
135
142
  require_relative "cmdx/retriers/fibonacci"
136
143
  require_relative "cmdx/retriers/decorrelated_jitter"
137
144
  require_relative "cmdx/retriers"
145
+ require_relative "cmdx/limiters/fixed_window"
146
+ require_relative "cmdx/limiters/leaky_bucket"
147
+ require_relative "cmdx/limiters"
138
148
  require_relative "cmdx/deprecators/log"
139
149
  require_relative "cmdx/deprecators/warn"
140
150
  require_relative "cmdx/deprecators/error"
@@ -147,7 +157,12 @@ require_relative "cmdx/callbacks"
147
157
  require_relative "cmdx/middlewares"
148
158
  require_relative "cmdx/telemetry"
149
159
  require_relative "cmdx/settings"
160
+ require_relative "cmdx/store_key"
150
161
  require_relative "cmdx/retry"
162
+ require_relative "cmdx/idempotency"
163
+ require_relative "cmdx/lock"
164
+ require_relative "cmdx/circuit_breaker"
165
+ require_relative "cmdx/rate_limit"
151
166
  require_relative "cmdx/deprecation"
152
167
  require_relative "cmdx/context"
153
168
  require_relative "cmdx/chain"
@@ -101,11 +101,21 @@ CMDx.configure do |config|
101
101
  # fires if nobody's listening, so unused events cost nothing.
102
102
  #
103
103
  # Events and their extra payload keys:
104
- # :task_started {}
105
- # :task_deprecated {}
106
- # :task_retried { attempt: Integer }
107
- # :task_rolled_back {}
108
- # :task_executed { result: CMDx::Result }
104
+ # :task_started {}
105
+ # :task_deprecated {}
106
+ # :task_retried { attempt: Integer }
107
+ # :task_rolled_back {}
108
+ # :task_executed { result: CMDx::Result }
109
+ # :lock_acquired { lock_key: String }
110
+ # :lock_busy { code: :busy, lock_key:, holder:, retry_after:, on_busy: }
111
+ # :idempotency_claimed { idempotency_key: String }
112
+ # :idempotency_completed { idempotency_key: String }
113
+ # :idempotency_duplicate { code: :duplicate, idempotency_key:, state: }
114
+ # :circuit_open { code: :circuit_open, circuit_breaker_key:, retry_after:, on_open: }
115
+ # :circuit_tripped { circuit_breaker_key:, failures_key:, failure_count:, threshold:, cool_off: }
116
+ # :circuit_reset { failures_key: String }
117
+ # :rate_limit_admitted { rate_limit_key:, limiter:, max:, per:, ... }
118
+ # :rate_limit_exceeded { code: :rate_limited, rate_limit_key:, limiter:, on_limit:, ... }
109
119
  #
110
120
  # Every event also carries: event.cid, event.xid, event.tid, event.task,
111
121
  # event.type, event.name, event.root, event.payload, event.timestamp.
@@ -154,6 +164,21 @@ CMDx.configure do |config|
154
164
  # [delay * (2**(attempt - 1)), 30.0].min
155
165
  # end)
156
166
 
167
+ # ===========================================================================
168
+ # Limiters
169
+ # https://drexed.github.io/cmdx/rate_limit/
170
+ # ===========================================================================
171
+ # Limiters decide whether a `rate_limit` declaration admits an execution.
172
+ # Built-ins: :fixed_window, :leaky_bucket.
173
+ #
174
+ # config.limiters.register(:strict, proc do |quota|
175
+ # count = CMDx::Stores.incr(quota.store_key, ttl: quota.per)
176
+ # admitted = count <= quota.max
177
+ # retry_after = admitted ? nil : CMDx::Stores.remaining_ttl(quota.store_key)
178
+ #
179
+ # CMDx::RateLimit::Admission.new(admitted:, count:, level: nil, retry_after:, limiter: :strict)
180
+ # end)
181
+
157
182
  # ===========================================================================
158
183
  # Deprecators
159
184
  # https://drexed.github.io/cmdx/deprecation/
@@ -168,6 +193,17 @@ CMDx.configure do |config|
168
193
  # Bugsnag.notify("Deprecated task invoked: #{task.class.name}")
169
194
  # end)
170
195
 
196
+ # ===========================================================================
197
+ # Store
198
+ # https://drexed.github.io/cmdx/configuration/#store
199
+ # ===========================================================================
200
+ # Shared key/value scratch space (`CMDx::Stores.get/set/incr/del`) for
201
+ # idempotency keys, rate limits, and similar cross-request state. Defaults to
202
+ # an in-memory adapter; point at Redis in production.
203
+ #
204
+ # config.store = CMDx::Stores::Redis.new(redis: Redis.current)
205
+ # config.store = CMDx::Stores::Redis.new(redis: Redis.current, namespace: "myapp:cmdx")
206
+
171
207
  # ===========================================================================
172
208
  # Executors
173
209
  # https://drexed.github.io/cmdx/workflows/#parallel-execution