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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3006157f5e5d98af8e442130f87f0427f15868e84e44db171c1f620d0f606ef5
4
- data.tar.gz: 92e7a594a9ef6b4367bfbeb3fc71b807d7d495d9e977007af8a28b498c0730d9
3
+ metadata.gz: 140f9e96a60ea339e1fd9c95d26e4d06569a1286deab7cfa9851d97c84f69184
4
+ data.tar.gz: 2923f0db4e2bc30849fb703e9b614610d1b75361b944ab845b98bf41c2ad1d4f
5
5
  SHA512:
6
- metadata.gz: 33ab98bd8eaa2d36c5f01cc790bb09ed644a1f4a01cd561545333c83059cfb1656f9349e684920f2377a99e8fe0fd5409f9e6199e8966358718f37c560cef911
7
- data.tar.gz: 5276f8e49a7697ce9fca44f64d7bc393429b8e75636f3e8b5865a9388f14f6e96aa9c039f7c3e4efa583e9d7d5c0097a845ca4878dfd73e6a74ea76e1e5b72cf
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.3.0] - UNRELEASED
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
@@ -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 are `namespace:TaskName` or `namespace:TaskName:segment`, with
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 = "open"
13
- FAILURES = "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
- base = store_base(task)
129
- return yield if base.nil?
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(base, OPEN)
132
- failures_key = StoreKey.compose(base, FAILURES)
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
@@ -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
@@ -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` and only options change, the existing resolver is kept (option-only
79
- # updates such as `idempotent_by ttl: 600`).
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 = new_key.nil? ? @key : new_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
- segment = StoreKey.segment(task, key)
111
- return yield if segment.nil?
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 :skip or :fail (got #{on_duplicate.inspect}).
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 = new_key.nil? ? @key : new_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
- segment = StoreKey.segment(task, key)
117
- return yield if segment.nil?
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 :skip or :fail (got #{on_busy.inspect}).
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, then {Retry};
7
- # output verification runs inside the lock lease and idempotency claim when
8
- # enabled),
9
- # rollback on failure, result finalization, and teardown (freeze + chain clear).
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
- circuit_execution do
177
- lock_execution do
178
- idempotent_execution do
179
- retry_execution { @task.work }
180
- verify_outputs!
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?
@@ -1,19 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
- # Builds colon-separated store keys with the same non-empty coercion as
5
- # {Stores.key!}. Used by {Idempotency} (`namespace:TaskName:segment`),
6
- # {CircuitBreaker} (`namespace:TaskName` with `:failures` / `:open` suffixes),
7
- # {Lock} (`namespace:TaskName:segment`), and other store-backed helpers.
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.filter_map { |s| Stores.key!(s) }.join(":")
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 `CircuitBreaker::CLASS_KEY`)
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
@@ -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
- expected = expected.to_s
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
@@ -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
- # Keys are prefixed to avoid collisions with unrelated application data.
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] prepended to every key (trailing `:` added when missing)
21
- def initialize(redis:, namespace: "cmdx:store")
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.end_with?(":") ? 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
- expected = expected.to_s
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`, `deprecation`,
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
@@ -40,6 +40,8 @@ module CMDx
40
40
  circuit_open
41
41
  circuit_tripped
42
42
  circuit_reset
43
+ rate_limit_admitted
44
+ rate_limit_exceeded
43
45
  ].freeze
44
46
 
45
47
  class << self
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.3.0"
6
+ VERSION = "2.4.0"
7
7
 
8
8
  end
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, namespace: "cmdx:store")
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
- Attributes:
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
- Features:
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
- - logging.md: Structured logging with multiple formatters (JSON, KeyValue, Logstash, Line, Raw)
142
- - internationalization.md: Multi-language support for error messages and validations (90+ locales)
143
- - retries.md: Automatic retry functionality for transient failures with jitter and selective retries
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
- - stores.md: Shared key/value store for idempotency, rate limits, and cross-worker coordination
147
- - deprecation.md: Managing deprecated tasks with logging, warnings, or execution prevention
148
- - outputs.md: Declaring expected context outputs that must be set after task execution
149
- - workflows.md: Composing multiple tasks into sequential pipelines with conditional execution
150
- More:
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 Started: getting_started.md
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
- - Inputs:
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
- - Features:
208
+ - Outputs: outputs.md
209
+ - Hooks:
182
210
  - Callbacks: callbacks.md
183
211
  - Middlewares: middlewares.md
184
- - Logging: logging.md
185
- - Internationalization: internationalization.md
186
- - Retries: retries.md
187
- - Idempotency: idempotency.md
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
- - Stores: stores.md
191
- - Deprecation: deprecation.md
192
- - Outputs: outputs.md
193
- - Workflows: workflows.md
194
- - More:
221
+ - Retries: retries.md
222
+ - Observability:
223
+ - Logging: logging.md
224
+ - Internationalization: internationalization.md
225
+ - Adoption:
195
226
  - Testing: testing.md
196
- - Tips and Tricks: tips_and_tricks.md
227
+ - Tips and tricks: tips_and_tricks.md
197
228
  - Comparison: comparison.md
198
229
  - v1 → v2 Migration: v2-migration.md
199
- - Examples: https://github.com/drexed/cmdx/blob/main/examples/index.md
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
- - Ecosystem:
206
- - cmdx-i18n: https://github.com/drexed/cmdx-i18n
207
- - cmdx-rspec: https://github.com/drexed/cmdx-rspec
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.3.0
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