cmdx 2.3.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 +11 -1
- data/lib/cmdx/circuit_breaker.rb +8 -21
- data/lib/cmdx/configuration.rb +3 -1
- data/lib/cmdx/idempotency.rb +7 -8
- 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 +7 -6
- data/lib/cmdx/rate_limit.rb +244 -0
- data/lib/cmdx/runtime.rb +18 -9
- data/lib/cmdx/store_key.rb +37 -6
- data/lib/cmdx/stores/memory.rb +5 -2
- data/lib/cmdx/stores/redis.rb +13 -6
- data/lib/cmdx/task.rb +58 -6
- data/lib/cmdx/telemetry.rb +2 -0
- data/lib/cmdx/version.rb +1 -1
- data/lib/cmdx.rb +7 -3
- data/lib/generators/cmdx/templates/install.rb +19 -1
- data/mkdocs.yml +75 -27
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 140f9e96a60ea339e1fd9c95d26e4d06569a1286deab7cfa9851d97c84f69184
|
|
4
|
+
data.tar.gz: 2923f0db4e2bc30849fb703e9b614610d1b75361b944ab845b98bf41c2ad1d4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f2c85ae86fcc8f920e9685a2a9a99c00f16f794ffec743fed1b3def2355e1058b20b763ea03347a8d8a7e15d52211f661a3b33580043f1a1354f878c0205c1b6
|
|
7
|
+
data.tar.gz: 7b4396cf1ed6d3e5eadb2cb388c5ac01c8f5c8874b9d8ca6b0e0539f18c19081447555fa21d178def486248c17756b88150cece92d1c92411e80bc2d778f30a6
|
data/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## UNRELEASED
|
|
8
8
|
|
|
9
|
-
## [2.
|
|
9
|
+
## [2.4.0] - 2026-10-22
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `Task.rate_limit` — admission control via `CMDx::Stores` once per `execute` (retries do not consume extra quota); built-in `:fixed_window` and `:leaky_bucket` limiters with `CMDx::Limiters` registry (register custom strategies globally or with `register :limiter`); `on_limit:` `:fail` / `:skip`, `if` / `unless` gates; runs after input resolution and before `circuit_breaker` in `Runtime`
|
|
13
|
+
- Telemetry events `:rate_limit_admitted` and `:rate_limit_exceeded` for rate-limited tasks (gated by `Telemetry#subscribed?` like other store-backed helpers)
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- `Stores::Redis` — default `namespace` is now `""` so Redis keys match logical store keys (for example `cmdx:idem:MyTask:segment`); set `namespace` explicitly when sharing a Redis instance with non-CMDx data (previously defaulted to `cmdx:store`)
|
|
17
|
+
- Move integration recipes from `examples/` into published docs under `docs/cookbook/` with a dedicated **Cookbook** site tab
|
|
18
|
+
|
|
19
|
+
## [2.3.0] - 2026-10-20
|
|
10
20
|
|
|
11
21
|
### Added
|
|
12
22
|
- `Task.lock_with` — distributed lease via `CMDx::Stores` so only one worker runs the same logical key at a time; optional `wait:` polling, `on_busy:` `:fail` / `:skip`, and `if` / `unless` gates; wraps `idempotent_by` and the `retry_on` loop in `Runtime`; pass `false` on a subclass to disable
|
data/lib/cmdx/circuit_breaker.rb
CHANGED
|
@@ -5,13 +5,12 @@ module CMDx
|
|
|
5
5
|
# store counters before the `retry_on` loop; opens after repeated failures
|
|
6
6
|
# and short-circuits until `cool_off` elapses.
|
|
7
7
|
#
|
|
8
|
-
# Store keys
|
|
8
|
+
# Store keys come from {StoreKey.task_key} with `class_scoped: true`, plus
|
|
9
9
|
# `:failures` and `:open` suffix segments (see {StoreKey.compose}).
|
|
10
10
|
class CircuitBreaker
|
|
11
11
|
|
|
12
|
-
OPEN
|
|
13
|
-
FAILURES
|
|
14
|
-
CLASS_KEY = :class
|
|
12
|
+
OPEN = "open"
|
|
13
|
+
FAILURES = "failures"
|
|
15
14
|
|
|
16
15
|
class << self
|
|
17
16
|
|
|
@@ -101,7 +100,7 @@ module CMDx
|
|
|
101
100
|
|
|
102
101
|
merged_key =
|
|
103
102
|
if new_key.nil?
|
|
104
|
-
enabled? || new_options.empty? ? @key : CLASS_KEY
|
|
103
|
+
enabled? || new_options.empty? ? @key : StoreKey::CLASS_KEY
|
|
105
104
|
else
|
|
106
105
|
new_key
|
|
107
106
|
end
|
|
@@ -125,11 +124,11 @@ module CMDx
|
|
|
125
124
|
return yield unless enabled?
|
|
126
125
|
return yield unless Util.satisfied?(condition_if, condition_unless, task)
|
|
127
126
|
|
|
128
|
-
|
|
129
|
-
return yield if
|
|
127
|
+
store_key = StoreKey.task_key(task, namespace:, resolver: key, class_scoped: true)
|
|
128
|
+
return yield if store_key.nil?
|
|
130
129
|
|
|
131
|
-
open_key = StoreKey.compose(
|
|
132
|
-
failures_key = StoreKey.compose(
|
|
130
|
+
open_key = StoreKey.compose(store_key, OPEN)
|
|
131
|
+
failures_key = StoreKey.compose(store_key, FAILURES)
|
|
133
132
|
open!(task, open_key) if circuit_open?(open_key)
|
|
134
133
|
|
|
135
134
|
begin
|
|
@@ -144,18 +143,6 @@ module CMDx
|
|
|
144
143
|
|
|
145
144
|
private
|
|
146
145
|
|
|
147
|
-
def store_base(task)
|
|
148
|
-
segment = StoreKey.segment(task, key, skip: CLASS_KEY)
|
|
149
|
-
|
|
150
|
-
if segment.nil?
|
|
151
|
-
return StoreKey.compose(namespace, task.class.name) if key == CLASS_KEY
|
|
152
|
-
|
|
153
|
-
return nil
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
StoreKey.compose(namespace, task.class.name, segment)
|
|
157
|
-
end
|
|
158
|
-
|
|
159
146
|
def circuit_open?(open_key)
|
|
160
147
|
Stores.get(open_key)
|
|
161
148
|
rescue StandardError
|
data/lib/cmdx/configuration.rb
CHANGED
|
@@ -10,7 +10,7 @@ module CMDx
|
|
|
10
10
|
class Configuration
|
|
11
11
|
|
|
12
12
|
attr_accessor :middlewares, :callbacks, :coercions, :validators, :executors,
|
|
13
|
-
:mergers, :retriers, :deprecators, :telemetry, :store, :correlation_id, :default_locale,
|
|
13
|
+
:mergers, :retriers, :limiters, :deprecators, :telemetry, :store, :correlation_id, :default_locale,
|
|
14
14
|
:strict_context, :backtrace_cleaner, :log_exclusions, :log_formatter,
|
|
15
15
|
:log_level, :logger
|
|
16
16
|
|
|
@@ -22,6 +22,7 @@ module CMDx
|
|
|
22
22
|
@executors = Executors.new
|
|
23
23
|
@mergers = Mergers.new
|
|
24
24
|
@retriers = Retriers.new
|
|
25
|
+
@limiters = Limiters.new
|
|
25
26
|
@deprecators = Deprecators.new
|
|
26
27
|
@telemetry = Telemetry.new
|
|
27
28
|
@store = Stores::Memory.new
|
|
@@ -86,6 +87,7 @@ module CMDx
|
|
|
86
87
|
@executors
|
|
87
88
|
@mergers
|
|
88
89
|
@retriers
|
|
90
|
+
@limiters
|
|
89
91
|
@deprecators
|
|
90
92
|
@telemetry
|
|
91
93
|
].freeze
|
data/lib/cmdx/idempotency.rb
CHANGED
|
@@ -75,8 +75,9 @@ module CMDx
|
|
|
75
75
|
|
|
76
76
|
# Returns a new Idempotency layering `new_options` onto the current one.
|
|
77
77
|
# When `new_key` is non-nil it replaces the resolver; when `new_key` is
|
|
78
|
-
# `nil
|
|
79
|
-
#
|
|
78
|
+
# `nil`, only options merge and the existing resolver is kept. A disabled
|
|
79
|
+
# config stays disabled when only options are passed (a key is still required
|
|
80
|
+
# to enable).
|
|
80
81
|
#
|
|
81
82
|
# @param new_key [Symbol, String, Proc, #call, nil]
|
|
82
83
|
# @param new_options [Hash{Symbol => Object}]
|
|
@@ -84,7 +85,7 @@ module CMDx
|
|
|
84
85
|
def build(new_key, new_options)
|
|
85
86
|
return self if new_key.nil? && new_options.empty?
|
|
86
87
|
|
|
87
|
-
merged_key
|
|
88
|
+
merged_key = new_key.nil? ? @key : new_key
|
|
88
89
|
merged_options = @options.merge(new_options)
|
|
89
90
|
|
|
90
91
|
self.class.new(merged_key, merged_options)
|
|
@@ -107,10 +108,8 @@ module CMDx
|
|
|
107
108
|
return yield unless enabled?
|
|
108
109
|
return yield unless Util.satisfied?(condition_if, condition_unless, task)
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
return yield if
|
|
112
|
-
|
|
113
|
-
store_key = StoreKey.compose(namespace, task.class.name, segment)
|
|
111
|
+
store_key = StoreKey.task_key(task, namespace:, resolver: key)
|
|
112
|
+
return yield if store_key.nil?
|
|
114
113
|
|
|
115
114
|
if Stores.set(store_key, PROCESSING, nx: true, ttl:)
|
|
116
115
|
Telemetry.emit(task, :idempotency_claimed, idempotency_key: store_key)
|
|
@@ -159,7 +158,7 @@ module CMDx
|
|
|
159
158
|
task.skip!(message, **metadata)
|
|
160
159
|
else
|
|
161
160
|
raise ArgumentError, <<~MSG.chomp
|
|
162
|
-
idempotent_by on_duplicate must be :
|
|
161
|
+
idempotent_by on_duplicate must be :fail or :skip (got #{on_duplicate.inspect}).
|
|
163
162
|
See https://drexed.github.io/cmdx/idempotency/
|
|
164
163
|
MSG
|
|
165
164
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
class Limiters
|
|
5
|
+
# Fixed-window counter using {Stores.incr} with TTL refresh on each hit.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module FixedWindow
|
|
9
|
+
|
|
10
|
+
extend self
|
|
11
|
+
|
|
12
|
+
# @param quota [RateLimit::Quota]
|
|
13
|
+
# @return [RateLimit::Admission]
|
|
14
|
+
def call(quota)
|
|
15
|
+
count = Stores.incr(quota.store_key, ttl: quota.per)
|
|
16
|
+
admitted = count <= quota.max.to_i
|
|
17
|
+
retry_after = admitted ? nil : Stores.remaining_ttl(quota.store_key)
|
|
18
|
+
|
|
19
|
+
RateLimit::Admission.new(
|
|
20
|
+
admitted:,
|
|
21
|
+
count:,
|
|
22
|
+
level: nil,
|
|
23
|
+
retry_after:,
|
|
24
|
+
limiter: :fixed_window
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
class Limiters
|
|
5
|
+
# Continuous leaky bucket backed by a single store key (`store_key:lb`).
|
|
6
|
+
# Leaks one unit every `per` seconds; admits when `level + cost <= max`.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module LeakyBucket
|
|
10
|
+
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
# @param quota [RateLimit::Quota]
|
|
14
|
+
# @return [RateLimit::Admission]
|
|
15
|
+
def call(quota)
|
|
16
|
+
state_key = "#{quota.store_key}:lb"
|
|
17
|
+
now = Time.now.to_f
|
|
18
|
+
level, last_at = parse_state(Stores.get(state_key), now)
|
|
19
|
+
|
|
20
|
+
leaked = (now - last_at) / quota.per
|
|
21
|
+
if leaked.positive?
|
|
22
|
+
level -= leaked
|
|
23
|
+
level = 0.0 if level.negative?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
cost = quota.cost
|
|
27
|
+
capacity = quota.max
|
|
28
|
+
projected = level + cost
|
|
29
|
+
admitted = projected <= capacity
|
|
30
|
+
|
|
31
|
+
level = projected if admitted
|
|
32
|
+
|
|
33
|
+
Stores.set(state_key, "#{level}:#{now}", ttl: bucket_ttl(quota))
|
|
34
|
+
|
|
35
|
+
retry_after =
|
|
36
|
+
unless admitted
|
|
37
|
+
overflow = projected - capacity
|
|
38
|
+
overflow = 0 if overflow.negative?
|
|
39
|
+
(overflow * quota.per).ceil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
RateLimit::Admission.new(
|
|
43
|
+
admitted:,
|
|
44
|
+
count: nil,
|
|
45
|
+
level:,
|
|
46
|
+
retry_after:,
|
|
47
|
+
limiter: :leaky_bucket
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def parse_state(raw, now)
|
|
54
|
+
return [0.0, now] if raw.nil? || raw.empty?
|
|
55
|
+
|
|
56
|
+
sep = raw.index(":")
|
|
57
|
+
unless sep
|
|
58
|
+
level = Float(raw, exception: false) || 0.0
|
|
59
|
+
return [level, now]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
level = Float(raw[0, sep], exception: false) || 0.0
|
|
63
|
+
last_at = Float(raw[(sep + 1)..], exception: false) || now
|
|
64
|
+
[level, last_at]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def bucket_ttl(quota)
|
|
68
|
+
base = quota.max * quota.per
|
|
69
|
+
ttl = base.positive? ? base * 2 : quota.per * 2
|
|
70
|
+
ttl.to_i
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# Registry of named rate-limit strategies used by {RateLimit}. Ships with
|
|
5
|
+
# `:fixed_window` and `:leaky_bucket`. A limiter is any callable accepting
|
|
6
|
+
# a {RateLimit::Quota} and returning a {RateLimit::Admission}.
|
|
7
|
+
class Limiters
|
|
8
|
+
|
|
9
|
+
attr_reader :registry
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@registry = {
|
|
13
|
+
fixed_window: Limiters::FixedWindow,
|
|
14
|
+
leaky_bucket: Limiters::LeakyBucket
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param source [Limiters] registry to duplicate
|
|
19
|
+
# @return [void]
|
|
20
|
+
def initialize_copy(source)
|
|
21
|
+
@registry = source.registry.dup
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Registers a named limiter, overwriting any existing entry.
|
|
25
|
+
#
|
|
26
|
+
# @param name [Symbol]
|
|
27
|
+
# @param callable [#call, nil] pass either this or a block
|
|
28
|
+
# @param block [#call, nil] limiter callable when `callable` is omitted
|
|
29
|
+
# @yieldparam quota [RateLimit::Quota]
|
|
30
|
+
# @yieldreturn [RateLimit::Admission]
|
|
31
|
+
# @return [Limiters] self for chaining
|
|
32
|
+
# @raise [ArgumentError] when both `callable` and a block are given, or
|
|
33
|
+
# when the resolved limiter isn't callable
|
|
34
|
+
def register(name, callable = nil, &block)
|
|
35
|
+
limiter = callable || block
|
|
36
|
+
|
|
37
|
+
if callable && block
|
|
38
|
+
raise ArgumentError, "limiter: provide either a callable or a block, not both"
|
|
39
|
+
elsif !limiter.respond_to?(:call)
|
|
40
|
+
raise ArgumentError, <<~MSG.chomp
|
|
41
|
+
limiter must respond to #call (got #{limiter.class}).
|
|
42
|
+
See https://drexed.github.io/cmdx/rate_limit/#custom-limiters-via-the-limiters-registry
|
|
43
|
+
MSG
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
registry[name.to_sym] = limiter
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param name [Symbol]
|
|
51
|
+
# @return [Limiters] self for chaining
|
|
52
|
+
def deregister(name)
|
|
53
|
+
registry.delete(name)
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param name [Symbol]
|
|
58
|
+
# @return [Boolean] whether a limiter is registered under `name`
|
|
59
|
+
def key?(name)
|
|
60
|
+
registry.key?(name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param name [Symbol]
|
|
64
|
+
# @return [#call] the registered limiter
|
|
65
|
+
# @raise [UnknownEntryError] when `name` isn't registered
|
|
66
|
+
def lookup(name)
|
|
67
|
+
registry[name] || begin
|
|
68
|
+
raise UnknownEntryError, <<~MSG.chomp
|
|
69
|
+
unknown limiter #{name.inspect}; registered: #{registry.keys.inspect}.
|
|
70
|
+
See https://drexed.github.io/cmdx/rate_limit/#built-in-limiters
|
|
71
|
+
MSG
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Resolves a `:limiter` spec to a concrete callable.
|
|
76
|
+
#
|
|
77
|
+
# @param spec [Symbol, #call, nil]
|
|
78
|
+
# @return [#call, nil]
|
|
79
|
+
# @raise [UnknownEntryError] when `spec` is an unknown symbol or not callable
|
|
80
|
+
def resolve(spec)
|
|
81
|
+
case spec
|
|
82
|
+
when NilClass
|
|
83
|
+
nil
|
|
84
|
+
when Symbol
|
|
85
|
+
lookup(spec)
|
|
86
|
+
else
|
|
87
|
+
return spec if spec.respond_to?(:call)
|
|
88
|
+
|
|
89
|
+
raise UnknownEntryError, <<~MSG.chomp
|
|
90
|
+
unknown limiter #{spec.inspect}; expected a Symbol from #{registry.keys.inspect} or a callable.
|
|
91
|
+
See https://drexed.github.io/cmdx/rate_limit/#built-in-limiters
|
|
92
|
+
MSG
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
def empty?
|
|
98
|
+
registry.empty?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Integer]
|
|
102
|
+
def size
|
|
103
|
+
registry.size
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/cmdx/lock.rb
CHANGED
|
@@ -83,7 +83,9 @@ module CMDx
|
|
|
83
83
|
!key.nil?
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
# Returns a new Lock layering `new_options` onto the current one.
|
|
86
|
+
# Returns a new Lock layering `new_options` onto the current one. When
|
|
87
|
+
# `new_key` is non-nil it replaces the resolver; when `new_key` is `nil`,
|
|
88
|
+
# only options merge and the existing resolver is kept.
|
|
87
89
|
#
|
|
88
90
|
# @param new_key [Symbol, String, Proc, #call, nil]
|
|
89
91
|
# @param new_options [Hash{Symbol => Object}]
|
|
@@ -91,7 +93,7 @@ module CMDx
|
|
|
91
93
|
def build(new_key, new_options)
|
|
92
94
|
return self if new_key.nil? && new_options.empty?
|
|
93
95
|
|
|
94
|
-
merged_key
|
|
96
|
+
merged_key = new_key.nil? ? @key : new_key
|
|
95
97
|
merged_options = @options.merge(new_options)
|
|
96
98
|
|
|
97
99
|
self.class.new(merged_key, merged_options)
|
|
@@ -113,10 +115,9 @@ module CMDx
|
|
|
113
115
|
return yield unless enabled?
|
|
114
116
|
return yield unless Util.satisfied?(condition_if, condition_unless, task)
|
|
115
117
|
|
|
116
|
-
|
|
117
|
-
return yield if
|
|
118
|
+
store_key = StoreKey.task_key(task, namespace:, resolver: key)
|
|
119
|
+
return yield if store_key.nil?
|
|
118
120
|
|
|
119
|
-
store_key = StoreKey.compose(namespace, task.class.name, segment)
|
|
120
121
|
token = task.tid
|
|
121
122
|
|
|
122
123
|
if acquire_lock(store_key, token)
|
|
@@ -178,7 +179,7 @@ module CMDx
|
|
|
178
179
|
task.skip!(message, **metadata)
|
|
179
180
|
else
|
|
180
181
|
raise ArgumentError, <<~MSG.chomp
|
|
181
|
-
lock_with on_busy must be :
|
|
182
|
+
lock_with on_busy must be :fail or :skip (got #{on_busy.inspect}).
|
|
182
183
|
See https://drexed.github.io/cmdx/locks/
|
|
183
184
|
MSG
|
|
184
185
|
end
|
|
@@ -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/runtime.rb
CHANGED
|
@@ -3,10 +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
|
-
# {CircuitBreaker}, {Lock}, and {Idempotency} when declared,
|
|
7
|
-
# output verification runs inside the lock lease and idempotency
|
|
8
|
-
# enabled),
|
|
9
|
-
#
|
|
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).
|
|
10
10
|
#
|
|
11
11
|
# Signal propagation: `work` and the surrounding middleware/callback chain
|
|
12
12
|
# both run inside `catch(Signal::TAG)`, so `success!` / `skip!` / `fail!` /
|
|
@@ -173,11 +173,13 @@ module CMDx
|
|
|
173
173
|
@signal = catch(Signal::TAG) do
|
|
174
174
|
resolve_inputs!
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
181
183
|
end
|
|
182
184
|
end
|
|
183
185
|
end
|
|
@@ -208,6 +210,13 @@ module CMDx
|
|
|
208
210
|
signal_errors!
|
|
209
211
|
end
|
|
210
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
|
+
|
|
211
220
|
def circuit_execution(&)
|
|
212
221
|
breaker = @task.class.circuit_breaker
|
|
213
222
|
return yield unless breaker.enabled?
|
data/lib/cmdx/store_key.rb
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CMDx
|
|
4
|
-
#
|
|
5
|
-
# {
|
|
6
|
-
#
|
|
7
|
-
# {
|
|
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).
|
|
8
10
|
module StoreKey
|
|
9
11
|
|
|
10
12
|
extend self
|
|
11
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
|
+
|
|
12
19
|
# @param segments [Array<Object>] key segments coerced with {Stores.key!}
|
|
13
20
|
# @return [String] joined key
|
|
14
21
|
# @raise [ArgumentError] when any segment is empty after coercion
|
|
15
22
|
def compose(*segments)
|
|
16
|
-
segments.
|
|
23
|
+
segments.map! { |s| Stores.key!(s) }.join(":")
|
|
17
24
|
end
|
|
18
25
|
|
|
19
26
|
# Resolves a store key segment from a task. Used by {Idempotency},
|
|
@@ -22,7 +29,7 @@ module CMDx
|
|
|
22
29
|
# @param task [Task]
|
|
23
30
|
# @param resolver [Symbol, String, Proc, #call]
|
|
24
31
|
# @param skip [Object, nil] when `resolver == skip`, returns `nil` without
|
|
25
|
-
# reading context (for example
|
|
32
|
+
# reading context (for example {CLASS_KEY})
|
|
26
33
|
# @return [String, nil] non-empty segment, or `nil` when blank or skipped
|
|
27
34
|
def segment(task, resolver, skip: nil)
|
|
28
35
|
return if skip && resolver == skip
|
|
@@ -43,5 +50,29 @@ module CMDx
|
|
|
43
50
|
str if str && !str.empty?
|
|
44
51
|
end
|
|
45
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
|
+
|
|
46
77
|
end
|
|
47
78
|
end
|
data/lib/cmdx/stores/memory.rb
CHANGED
|
@@ -16,6 +16,7 @@ module CMDx
|
|
|
16
16
|
# @return [String, nil] stored value, or `nil` when missing or expired
|
|
17
17
|
def get(key)
|
|
18
18
|
key = Stores.key!(key)
|
|
19
|
+
|
|
19
20
|
@mutex.synchronize do
|
|
20
21
|
entry = @entries[key]
|
|
21
22
|
return unless entry
|
|
@@ -77,6 +78,7 @@ module CMDx
|
|
|
77
78
|
# @return [Integer, nil] whole seconds until expiry, or `nil` when missing or expired
|
|
78
79
|
def ttl(key)
|
|
79
80
|
key = Stores.key!(key)
|
|
81
|
+
|
|
80
82
|
@mutex.synchronize do
|
|
81
83
|
entry = @entries[key]
|
|
82
84
|
return unless entry
|
|
@@ -122,6 +124,7 @@ module CMDx
|
|
|
122
124
|
# @return [Integer] `1` when a key was removed, `0` otherwise
|
|
123
125
|
def del(key)
|
|
124
126
|
key = Stores.key!(key)
|
|
127
|
+
|
|
125
128
|
@mutex.synchronize do
|
|
126
129
|
@entries.delete(key) ? 1 : 0
|
|
127
130
|
end
|
|
@@ -132,11 +135,11 @@ module CMDx
|
|
|
132
135
|
# @return [Boolean] whether the key was removed
|
|
133
136
|
def del_if(key, expected)
|
|
134
137
|
key = Stores.key!(key)
|
|
135
|
-
|
|
138
|
+
|
|
136
139
|
@mutex.synchronize do
|
|
137
140
|
entry = @entries[key]
|
|
138
141
|
return false unless entry && !expired?(entry)
|
|
139
|
-
return false unless entry[:value] == expected
|
|
142
|
+
return false unless entry[:value] == expected.to_s
|
|
140
143
|
|
|
141
144
|
@entries.delete(key)
|
|
142
145
|
true
|
data/lib/cmdx/stores/redis.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module CMDx
|
|
4
4
|
module Stores
|
|
5
5
|
# Delegates to a Redis-compatible client (`redis-rb`, `MockRedis`, etc.).
|
|
6
|
-
#
|
|
6
|
+
# Optional `namespace` prefixes keys when Redis is shared with other data.
|
|
7
7
|
#
|
|
8
8
|
# The client must respond to `get`, `set`, `incr`, `expire`, `ttl`, `del`, and `eval`.
|
|
9
9
|
class Redis
|
|
@@ -17,10 +17,11 @@ module CMDx
|
|
|
17
17
|
LUA
|
|
18
18
|
|
|
19
19
|
# @param redis [#get, #set, #incr, #expire, #del, #eval] backing client
|
|
20
|
-
# @param namespace [String]
|
|
21
|
-
|
|
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)
|
|
22
23
|
@redis = redis
|
|
23
|
-
@namespace = namespace
|
|
24
|
+
@namespace = namespace_prefix(namespace)
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# @param key [String, Symbol]
|
|
@@ -95,8 +96,7 @@ module CMDx
|
|
|
95
96
|
# @return [Boolean] whether the key was removed
|
|
96
97
|
def del_if(key, expected) # rubocop:disable Naming/PredicateMethod -- store delete contract
|
|
97
98
|
key = prefixed(key)
|
|
98
|
-
|
|
99
|
-
deleted = @redis.eval(DEL_IF_SCRIPT, keys: [key], argv: [expected])
|
|
99
|
+
deleted = @redis.eval(DEL_IF_SCRIPT, keys: [key], argv: [expected.to_s])
|
|
100
100
|
deleted.to_i.positive?
|
|
101
101
|
end
|
|
102
102
|
|
|
@@ -106,6 +106,13 @@ module CMDx
|
|
|
106
106
|
"#{@namespace}#{Stores.key!(key)}"
|
|
107
107
|
end
|
|
108
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
|
+
|
|
109
116
|
end
|
|
110
117
|
end
|
|
111
118
|
end
|
data/lib/cmdx/task.rb
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
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`, `idempotent_by`, `lock_with`, `circuit_breaker`, `
|
|
7
|
-
# and `settings`.
|
|
6
|
+
# `retry_on`, `idempotent_by`, `lock_with`, `circuit_breaker`, `rate_limit`,
|
|
7
|
+
# `deprecation`, and `settings`.
|
|
8
|
+
#
|
|
8
9
|
# Invoked via {.execute} (safe) or {.execute!} (strict, raises on failure).
|
|
9
10
|
#
|
|
10
11
|
# Inheritance: every registry accessor (middlewares, callbacks, coercions,
|
|
@@ -150,6 +151,43 @@ module CMDx
|
|
|
150
151
|
end
|
|
151
152
|
end
|
|
152
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
|
|
189
|
+
end
|
|
190
|
+
|
|
153
191
|
# Reads or extends this class's {Settings}. Inherits from the superclass.
|
|
154
192
|
#
|
|
155
193
|
# @param options [Hash{Symbol => Object}] merged onto the current settings
|
|
@@ -263,6 +301,16 @@ module CMDx
|
|
|
263
301
|
end
|
|
264
302
|
end
|
|
265
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
|
+
|
|
266
314
|
# @return [Deprecators] cloned from superclass/configuration on first call
|
|
267
315
|
def deprecators
|
|
268
316
|
@deprecators ||=
|
|
@@ -275,7 +323,7 @@ module CMDx
|
|
|
275
323
|
|
|
276
324
|
# Dispatches to the appropriate registry's `register` method.
|
|
277
325
|
#
|
|
278
|
-
# @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]
|
|
279
327
|
# @return [Object] the registry's self
|
|
280
328
|
# @raise [ArgumentError] when `type` is unknown
|
|
281
329
|
def register(type, ...)
|
|
@@ -294,6 +342,8 @@ module CMDx
|
|
|
294
342
|
mergers.register(...)
|
|
295
343
|
when :retrier
|
|
296
344
|
retriers.register(...)
|
|
345
|
+
when :limiter
|
|
346
|
+
limiters.register(...)
|
|
297
347
|
when :deprecator
|
|
298
348
|
deprecators.register(...)
|
|
299
349
|
when :input
|
|
@@ -303,7 +353,7 @@ module CMDx
|
|
|
303
353
|
else
|
|
304
354
|
raise ArgumentError, <<~MSG.chomp
|
|
305
355
|
unknown registry type #{type.inspect};
|
|
306
|
-
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].
|
|
307
357
|
See https://drexed.github.io/cmdx/configuration/#registrations-register-deregister
|
|
308
358
|
MSG
|
|
309
359
|
end
|
|
@@ -311,7 +361,7 @@ module CMDx
|
|
|
311
361
|
|
|
312
362
|
# Dispatches to the appropriate registry's `deregister` method.
|
|
313
363
|
#
|
|
314
|
-
# @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]
|
|
315
365
|
# @return [Object] the registry's self
|
|
316
366
|
# @raise [ArgumentError] when `type` is unknown
|
|
317
367
|
def deregister(type, ...)
|
|
@@ -330,6 +380,8 @@ module CMDx
|
|
|
330
380
|
mergers.deregister(...)
|
|
331
381
|
when :retrier
|
|
332
382
|
retriers.deregister(...)
|
|
383
|
+
when :limiter
|
|
384
|
+
limiters.deregister(...)
|
|
333
385
|
when :deprecator
|
|
334
386
|
deprecators.deregister(...)
|
|
335
387
|
when :input
|
|
@@ -339,7 +391,7 @@ module CMDx
|
|
|
339
391
|
else
|
|
340
392
|
raise ArgumentError, <<~MSG.chomp
|
|
341
393
|
unknown registry type #{type.inspect};
|
|
342
|
-
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].
|
|
343
395
|
See https://drexed.github.io/cmdx/configuration/#registrations-register-deregister
|
|
344
396
|
MSG
|
|
345
397
|
end
|
data/lib/cmdx/telemetry.rb
CHANGED
data/lib/cmdx/version.rb
CHANGED
data/lib/cmdx.rb
CHANGED
|
@@ -124,6 +124,9 @@ require_relative "cmdx/validators/numeric"
|
|
|
124
124
|
require_relative "cmdx/validators/presence"
|
|
125
125
|
require_relative "cmdx/validators/validate"
|
|
126
126
|
require_relative "cmdx/validators"
|
|
127
|
+
require_relative "cmdx/stores/memory"
|
|
128
|
+
require_relative "cmdx/stores/redis"
|
|
129
|
+
require_relative "cmdx/stores"
|
|
127
130
|
require_relative "cmdx/executors/thread"
|
|
128
131
|
require_relative "cmdx/executors/fiber"
|
|
129
132
|
require_relative "cmdx/executors"
|
|
@@ -139,6 +142,9 @@ require_relative "cmdx/retriers/linear"
|
|
|
139
142
|
require_relative "cmdx/retriers/fibonacci"
|
|
140
143
|
require_relative "cmdx/retriers/decorrelated_jitter"
|
|
141
144
|
require_relative "cmdx/retriers"
|
|
145
|
+
require_relative "cmdx/limiters/fixed_window"
|
|
146
|
+
require_relative "cmdx/limiters/leaky_bucket"
|
|
147
|
+
require_relative "cmdx/limiters"
|
|
142
148
|
require_relative "cmdx/deprecators/log"
|
|
143
149
|
require_relative "cmdx/deprecators/warn"
|
|
144
150
|
require_relative "cmdx/deprecators/error"
|
|
@@ -150,15 +156,13 @@ require_relative "cmdx/outputs"
|
|
|
150
156
|
require_relative "cmdx/callbacks"
|
|
151
157
|
require_relative "cmdx/middlewares"
|
|
152
158
|
require_relative "cmdx/telemetry"
|
|
153
|
-
require_relative "cmdx/stores/memory"
|
|
154
|
-
require_relative "cmdx/stores/redis"
|
|
155
|
-
require_relative "cmdx/stores"
|
|
156
159
|
require_relative "cmdx/settings"
|
|
157
160
|
require_relative "cmdx/store_key"
|
|
158
161
|
require_relative "cmdx/retry"
|
|
159
162
|
require_relative "cmdx/idempotency"
|
|
160
163
|
require_relative "cmdx/lock"
|
|
161
164
|
require_relative "cmdx/circuit_breaker"
|
|
165
|
+
require_relative "cmdx/rate_limit"
|
|
162
166
|
require_relative "cmdx/deprecation"
|
|
163
167
|
require_relative "cmdx/context"
|
|
164
168
|
require_relative "cmdx/chain"
|
|
@@ -114,6 +114,8 @@ CMDx.configure do |config|
|
|
|
114
114
|
# :circuit_open { code: :circuit_open, circuit_breaker_key:, retry_after:, on_open: }
|
|
115
115
|
# :circuit_tripped { circuit_breaker_key:, failures_key:, failure_count:, threshold:, cool_off: }
|
|
116
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:, ... }
|
|
117
119
|
#
|
|
118
120
|
# Every event also carries: event.cid, event.xid, event.tid, event.task,
|
|
119
121
|
# event.type, event.name, event.root, event.payload, event.timestamp.
|
|
@@ -162,6 +164,21 @@ CMDx.configure do |config|
|
|
|
162
164
|
# [delay * (2**(attempt - 1)), 30.0].min
|
|
163
165
|
# end)
|
|
164
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
|
+
|
|
165
182
|
# ===========================================================================
|
|
166
183
|
# Deprecators
|
|
167
184
|
# https://drexed.github.io/cmdx/deprecation/
|
|
@@ -184,7 +201,8 @@ CMDx.configure do |config|
|
|
|
184
201
|
# idempotency keys, rate limits, and similar cross-request state. Defaults to
|
|
185
202
|
# an in-memory adapter; point at Redis in production.
|
|
186
203
|
#
|
|
187
|
-
# config.store = CMDx::Stores::Redis.new(redis: Redis.current
|
|
204
|
+
# config.store = CMDx::Stores::Redis.new(redis: Redis.current)
|
|
205
|
+
# config.store = CMDx::Stores::Redis.new(redis: Redis.current, namespace: "myapp:cmdx")
|
|
188
206
|
|
|
189
207
|
# ===========================================================================
|
|
190
208
|
# Executors
|
data/mkdocs.yml
CHANGED
|
@@ -128,34 +128,61 @@ plugins:
|
|
|
128
128
|
- outcomes/states.md: Execution lifecycle states (initialized, executing, complete, interrupted)
|
|
129
129
|
- outcomes/statuses.md: Business outcome statuses (success, skipped, failed) and transitions
|
|
130
130
|
- outcomes/errors.md: Structured per-attribute error container accumulated during input/output verification and exposed on result.errors
|
|
131
|
-
|
|
131
|
+
Contracts:
|
|
132
132
|
- inputs/definitions.md: Attribute declarations (required/optional), validation, and type coercion
|
|
133
133
|
- inputs/naming.md: Customizing accessor method names with prefixes and suffixes
|
|
134
134
|
- inputs/coercions.md: Automatic type conversion for inputs (string to integer, date parsing, etc.)
|
|
135
135
|
- inputs/validations.md: Input validation rules (presence, length, format, numeric, inclusion, exclusion)
|
|
136
136
|
- inputs/defaults.md: Default values for optional inputs (static, dynamic, callable)
|
|
137
137
|
- inputs/transformations.md: Value transformation after coercion but before validation
|
|
138
|
-
|
|
138
|
+
- outputs.md: Declaring expected context outputs that must be set after task execution
|
|
139
|
+
Hooks:
|
|
139
140
|
- callbacks.md: Execution lifecycle callbacks (before_execution, on_success, on_failure, etc.)
|
|
140
141
|
- middlewares.md: Cross-cutting concerns wrapping task execution (authentication, caching, timeouts)
|
|
141
|
-
|
|
142
|
-
-
|
|
143
|
-
-
|
|
142
|
+
Composition:
|
|
143
|
+
- workflows.md: Composing multiple tasks into sequential pipelines with conditional execution
|
|
144
|
+
- deprecation.md: Managing deprecated tasks with logging, warnings, or execution prevention
|
|
145
|
+
Resilience:
|
|
146
|
+
- stores.md: Shared key/value store for idempotency, rate limits, locks, and cross-worker coordination
|
|
147
|
+
- locks.md: Single-flight leases via Stores so concurrent workers do not run the same logical work
|
|
144
148
|
- idempotency.md: Preventing duplicate execution for retried jobs and webhooks with idempotent_by
|
|
149
|
+
- rate_limit.md: Admission control per window with fixed-window and leaky-bucket limiters
|
|
145
150
|
- circuit_breaker.md: Automatic circuit breaking for failing dependencies with threshold, cool-off, and window options
|
|
146
|
-
-
|
|
147
|
-
|
|
148
|
-
-
|
|
149
|
-
-
|
|
150
|
-
|
|
151
|
+
- retries.md: Automatic retry functionality for transient failures with jitter and selective retries
|
|
152
|
+
Observability:
|
|
153
|
+
- logging.md: Structured logging with multiple formatters (JSON, KeyValue, Logstash, Line, Raw)
|
|
154
|
+
- internationalization.md: Multi-language support for error messages and validations (90+ locales)
|
|
155
|
+
Adoption:
|
|
151
156
|
- testing.md: Best practices for testing CMDx tasks and workflows with RSpec
|
|
152
157
|
- tips_and_tricks.md: Best practices, patterns, and techniques for maintainable CMDx applications
|
|
153
158
|
- comparison.md: Comparison with other command/service object frameworks
|
|
154
159
|
- v2-migration.md: Migration guide from CMDx 1.x to 2.0
|
|
160
|
+
Ecosystem:
|
|
161
|
+
- ecosystem.md: Satellite gems (cmdx-i18n, cmdx-rspec)
|
|
162
|
+
References:
|
|
163
|
+
- references.md: API docs, llms.txt exports, and repository AI skills
|
|
164
|
+
Cookbook:
|
|
165
|
+
- cookbook/index.md: Integration recipes for jobs, Rails, auth, observability, and more
|
|
166
|
+
"Cookbook: Rails and Active Record":
|
|
167
|
+
- cookbook/active_record_database_transaction.md: Wrap task work in an Active Record transaction
|
|
168
|
+
- cookbook/active_record_query_tagging.md: Tag SQL queries with task metadata
|
|
169
|
+
- cookbook/paper_trail_whatdunnit.md: Paper Trail attribution for running tasks
|
|
170
|
+
"Cookbook: API and integration":
|
|
171
|
+
- cookbook/graphql_resolvers.md: Resolve GraphQL fields with CMDx tasks
|
|
172
|
+
- cookbook/openapi_schema_generation.md: Generate OpenAPI schemas from task inputs
|
|
173
|
+
- cookbook/pub_sub_task_chaining.md: Chain tasks from pub/sub events
|
|
174
|
+
"Cookbook: Cross-cutting concerns":
|
|
175
|
+
- cookbook/background_execution.md: Run tasks via Active Job or Sidekiq
|
|
176
|
+
- cookbook/store_wrappers.md: Test doubles and instrumented store wrappers
|
|
177
|
+
- cookbook/flipper_feature_flags.md: Gate tasks behind Flipper feature flags
|
|
178
|
+
- cookbook/pundit_authorization.md: Authorize tasks with Pundit before work runs
|
|
179
|
+
- cookbook/timeout_guard.md: Bound task execution time with a timeout guard
|
|
180
|
+
- cookbook/sentry_error_tracking.md: Report CMDx faults to Sentry
|
|
181
|
+
- cookbook/opentelemetry_tracing.md: Distributed tracing for task execution
|
|
155
182
|
nav:
|
|
156
183
|
- Home: index.md
|
|
157
184
|
- Documentation:
|
|
158
|
-
- Getting
|
|
185
|
+
- Getting started: getting_started.md
|
|
159
186
|
- Configuration: configuration.md
|
|
160
187
|
- Basics:
|
|
161
188
|
- Setup: basics/setup.md
|
|
@@ -171,40 +198,61 @@ nav:
|
|
|
171
198
|
- States: outcomes/states.md
|
|
172
199
|
- Statuses: outcomes/statuses.md
|
|
173
200
|
- Errors: outcomes/errors.md
|
|
174
|
-
-
|
|
201
|
+
- Contracts:
|
|
175
202
|
- Definitions: inputs/definitions.md
|
|
176
203
|
- Naming: inputs/naming.md
|
|
177
204
|
- Coercions: inputs/coercions.md
|
|
178
205
|
- Validations: inputs/validations.md
|
|
179
206
|
- Defaults: inputs/defaults.md
|
|
180
207
|
- Transformations: inputs/transformations.md
|
|
181
|
-
|
|
208
|
+
- Outputs: outputs.md
|
|
209
|
+
- Hooks:
|
|
182
210
|
- Callbacks: callbacks.md
|
|
183
211
|
- Middlewares: middlewares.md
|
|
184
|
-
|
|
185
|
-
-
|
|
186
|
-
-
|
|
187
|
-
|
|
212
|
+
- Composition:
|
|
213
|
+
- Workflows: workflows.md
|
|
214
|
+
- Deprecation: deprecation.md
|
|
215
|
+
- Resilience:
|
|
216
|
+
- Stores: stores.md
|
|
188
217
|
- Locks: locks.md
|
|
218
|
+
- Idempotency: idempotency.md
|
|
219
|
+
- Rate limit: rate_limit.md
|
|
189
220
|
- Circuit breaker: circuit_breaker.md
|
|
190
|
-
-
|
|
191
|
-
|
|
192
|
-
-
|
|
193
|
-
-
|
|
194
|
-
-
|
|
221
|
+
- Retries: retries.md
|
|
222
|
+
- Observability:
|
|
223
|
+
- Logging: logging.md
|
|
224
|
+
- Internationalization: internationalization.md
|
|
225
|
+
- Adoption:
|
|
195
226
|
- Testing: testing.md
|
|
196
|
-
- Tips and
|
|
227
|
+
- Tips and tricks: tips_and_tricks.md
|
|
197
228
|
- Comparison: comparison.md
|
|
198
229
|
- v1 → v2 Migration: v2-migration.md
|
|
199
|
-
|
|
230
|
+
- Ecosystem:
|
|
231
|
+
- cmdx-i18n: https://github.com/drexed/cmdx-i18n
|
|
232
|
+
- cmdx-rspec: https://github.com/drexed/cmdx-rspec
|
|
200
233
|
- References:
|
|
201
234
|
- API Documentation: https://drexed.github.io/cmdx/api/index.html
|
|
202
235
|
- llms.txt: https://drexed.github.io/cmdx/llms.txt
|
|
203
236
|
- llms-full.txt: https://drexed.github.io/cmdx/llms-full.txt
|
|
204
237
|
- AI Skills: https://github.com/drexed/cmdx/blob/main/skills
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
238
|
+
- Cookbook:
|
|
239
|
+
- Overview: cookbook/index.md
|
|
240
|
+
- Rails and Active Record:
|
|
241
|
+
- Active Record Database Transaction: cookbook/active_record_database_transaction.md
|
|
242
|
+
- Active Record Query Tagging: cookbook/active_record_query_tagging.md
|
|
243
|
+
- Paper Trail Whatdunnit: cookbook/paper_trail_whatdunnit.md
|
|
244
|
+
- API and integration:
|
|
245
|
+
- GraphQL Resolvers: cookbook/graphql_resolvers.md
|
|
246
|
+
- OpenAPI Schema Generation: cookbook/openapi_schema_generation.md
|
|
247
|
+
- PubSub Task Chaining: cookbook/pub_sub_task_chaining.md
|
|
248
|
+
- Cross-cutting concerns:
|
|
249
|
+
- Background Execution: cookbook/background_execution.md
|
|
250
|
+
- Store Wrappers: cookbook/store_wrappers.md
|
|
251
|
+
- Flipper Feature Flags: cookbook/flipper_feature_flags.md
|
|
252
|
+
- Pundit Authorization: cookbook/pundit_authorization.md
|
|
253
|
+
- Timeout Guard: cookbook/timeout_guard.md
|
|
254
|
+
- Sentry Error Tracking: cookbook/sentry_error_tracking.md
|
|
255
|
+
- OpenTelemetry Tracing: cookbook/opentelemetry_tracing.md
|
|
208
256
|
- Blog: blog/index.md
|
|
209
257
|
|
|
210
258
|
extra:
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cmdx
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Juan Gomez
|
|
@@ -241,6 +241,9 @@ files:
|
|
|
241
241
|
- lib/cmdx/idempotency.rb
|
|
242
242
|
- lib/cmdx/input.rb
|
|
243
243
|
- lib/cmdx/inputs.rb
|
|
244
|
+
- lib/cmdx/limiters.rb
|
|
245
|
+
- lib/cmdx/limiters/fixed_window.rb
|
|
246
|
+
- lib/cmdx/limiters/leaky_bucket.rb
|
|
244
247
|
- lib/cmdx/lock.rb
|
|
245
248
|
- lib/cmdx/log_formatters/json.rb
|
|
246
249
|
- lib/cmdx/log_formatters/key_value.rb
|
|
@@ -257,6 +260,7 @@ files:
|
|
|
257
260
|
- lib/cmdx/outputs.rb
|
|
258
261
|
- lib/cmdx/pipeline.rb
|
|
259
262
|
- lib/cmdx/railtie.rb
|
|
263
|
+
- lib/cmdx/rate_limit.rb
|
|
260
264
|
- lib/cmdx/result.rb
|
|
261
265
|
- lib/cmdx/retriers.rb
|
|
262
266
|
- lib/cmdx/retriers/bounded_random.rb
|