cmdx 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Declared via {Task.idempotent_by}. After inputs resolve, claims a shared
5
+ # store slot and runs the `retry_on` loop inside that claim so duplicate
6
+ # invocations with the same logical key short-circuit before `work` repeats.
7
+ #
8
+ # Store keys are `namespace:TaskName:segment` (see {StoreKey.compose}). Values
9
+ # move `processing` → `completed` after the wrapped block returns (including
10
+ # output verification when invoked from {Runtime}); the key is deleted on
11
+ # `StandardError` or non-success signals (`skip!` / `fail!`) so retries can
12
+ # reclaim.
13
+ class Idempotency
14
+
15
+ PROCESSING = "processing"
16
+ COMPLETED = "completed"
17
+
18
+ class << self
19
+
20
+ # @return [Idempotency] disabled configuration (no key)
21
+ def disabled
22
+ DISABLED
23
+ end
24
+
25
+ end
26
+
27
+ attr_reader :key
28
+
29
+ # @param key [Symbol, String, Proc, #call, nil] segment resolved per task;
30
+ # `nil` disables idempotency. Symbols read from `context`; `Proc` /
31
+ # blocks are `instance_exec`'d on the task (with `task` as the first
32
+ # block argument, same as callbacks); other `#call`ables receive the
33
+ # task. Blank segments skip claiming.
34
+ # @param options [Hash{Symbol => Object}]
35
+ # @option options [Integer] :ttl (300) expiry in seconds for store entries
36
+ # @option options [String] :namespace ("cmdx:idem") first segment of the store key
37
+ # @option options [Symbol] :on_duplicate (:skip) `:skip` or `:fail` when the
38
+ # slot is already claimed
39
+ # @option options [Symbol, Proc, #call] :if gate `(task)` for claiming
40
+ # @option options [Symbol, Proc, #call] :unless gate `(task)` for claiming
41
+ def initialize(key, options = EMPTY_HASH)
42
+ @key = key
43
+ @options = options.freeze
44
+ end
45
+
46
+ # @return [String]
47
+ def namespace
48
+ @options[:namespace] || "cmdx:idem"
49
+ end
50
+
51
+ # @return [Symbol] `:skip` or `:fail`
52
+ def on_duplicate
53
+ @options[:on_duplicate] || :skip
54
+ end
55
+
56
+ # @return [Integer]
57
+ def ttl
58
+ @options[:ttl] || 300
59
+ end
60
+
61
+ # @return [Symbol, Proc, #call, nil]
62
+ def condition_if
63
+ @options[:if]
64
+ end
65
+
66
+ # @return [Symbol, Proc, #call, nil]
67
+ def condition_unless
68
+ @options[:unless]
69
+ end
70
+
71
+ # @return [Boolean]
72
+ def enabled?
73
+ !key.nil?
74
+ end
75
+
76
+ # Returns a new Idempotency layering `new_options` onto the current one.
77
+ # When `new_key` is non-nil it replaces the resolver; when `new_key` is
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).
81
+ #
82
+ # @param new_key [Symbol, String, Proc, #call, nil]
83
+ # @param new_options [Hash{Symbol => Object}]
84
+ # @return [Idempotency]
85
+ def build(new_key, new_options)
86
+ return self if new_key.nil? && new_options.empty?
87
+
88
+ merged_key = new_key.nil? ? @key : new_key
89
+ merged_options = @options.merge(new_options)
90
+
91
+ self.class.new(merged_key, merged_options)
92
+ end
93
+
94
+ # Runs the given block behind a store claim when {#enabled?} and gates pass.
95
+ # Releases the claim on `StandardError` or a non-returning exit (`skip!`,
96
+ # `fail!`) so `retry_on` can run `work` again; marks the slot `completed`
97
+ # after the block returns successfully.
98
+ #
99
+ # @param task [Task]
100
+ # @yield invoked when the claim succeeds or idempotency is inactive
101
+ # @yieldreturn [Object] return value from the wrapped work (ignored by callers)
102
+ # @return [Object] the block's return value
103
+ # @note On duplicate keys, signals `skip!` or `fail!` on `task` — control
104
+ # does not return to the caller of {#process}.
105
+ # @raise [ArgumentError] when `on_duplicate` is not `:skip` or `:fail`
106
+ # @raise [IdempotencyError] when the store refuses the `completed` transition
107
+ def process(task)
108
+ return yield unless enabled?
109
+ return yield unless Util.satisfied?(condition_if, condition_unless, task)
110
+
111
+ store_key = StoreKey.task_key(task, namespace:, resolver: key)
112
+ return yield if store_key.nil?
113
+
114
+ if Stores.set(store_key, PROCESSING, nx: true, ttl:)
115
+ Telemetry.emit(task, :idempotency_claimed, idempotency_key: store_key)
116
+
117
+ succeeded = false
118
+
119
+ begin
120
+ yield
121
+ succeeded = true
122
+ rescue StandardError
123
+ Stores.del(store_key)
124
+ raise
125
+ ensure
126
+ Stores.del(store_key) unless succeeded
127
+ end
128
+
129
+ mark_completed!(store_key, task)
130
+ else
131
+ duplicate!(task, store_key)
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ def mark_completed!(store_key, task)
138
+ if Stores.set(store_key, COMPLETED, xx: true, ttl:)
139
+ Telemetry.emit(task, :idempotency_completed, idempotency_key: store_key)
140
+ return
141
+ end
142
+
143
+ Stores.del(store_key)
144
+ raise IdempotencyError, "failed to mark idempotency key completed (#{store_key})"
145
+ end
146
+
147
+ def duplicate!(task, store_key)
148
+ state = Stores.get(store_key)
149
+ message = "duplicate request (state=#{state})"
150
+ metadata = { code: :duplicate, idempotency_key: store_key, state: }
151
+
152
+ Telemetry.emit(task, :idempotency_duplicate, **metadata)
153
+
154
+ case on_duplicate
155
+ when :fail
156
+ task.fail!(message, **metadata)
157
+ when :skip
158
+ task.skip!(message, **metadata)
159
+ else
160
+ raise ArgumentError, <<~MSG.chomp
161
+ idempotent_by on_duplicate must be :fail or :skip (got #{on_duplicate.inspect}).
162
+ See https://drexed.github.io/cmdx/idempotency/
163
+ MSG
164
+ end
165
+ end
166
+
167
+ DISABLED = new(nil).freeze
168
+ private_constant :DISABLED
169
+
170
+ end
171
+ 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 ADDED
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Declared via {Task.lock_with}. After inputs resolve, acquires a store lease
5
+ # (`SET` with `nx: true`) and runs idempotency (when declared), the full
6
+ # `retry_on` loop, and output verification inside that lease.
7
+ #
8
+ # Store keys are `namespace:TaskName:segment` (see {StoreKey.compose}). The
9
+ # stored value is the holding task's `tid`. Release uses a conditional delete so
10
+ # a late releaser cannot drop a newer holder's lease after TTL expiry.
11
+ #
12
+ # After a successful acquire, the lease is released in `ensure` for any exit from
13
+ # the wrapped block (success, `skip!` / `fail!`, or uncaught error). The lease
14
+ # is not released between in-process `retry_on` attempts.
15
+ class Lock
16
+
17
+ class << self
18
+
19
+ # @return [Lock] disabled configuration (no key)
20
+ def disabled
21
+ DISABLED
22
+ end
23
+
24
+ end
25
+
26
+ attr_reader :key
27
+
28
+ # @param key [Symbol, String, Proc, #call, nil] segment resolved per task;
29
+ # `nil` disables locking. Symbols read from `context`; `Proc` / blocks are
30
+ # `instance_exec`'d on the task (with `task` as the first block argument,
31
+ # same as callbacks); other `#call`ables receive the task. Blank segments
32
+ # skip acquiring.
33
+ # @param options [Hash{Symbol => Object}]
34
+ # @option options [Integer] :ttl (300) lease expiry in seconds when the holder
35
+ # does not release; size for worst-case hold time including the retry loop
36
+ # @option options [String] :namespace ("cmdx:lock") first segment of the store key
37
+ # @option options [Numeric, false, nil] :wait (0) max seconds to wait for the
38
+ # lease; `0`, `nil`, or `false` try once
39
+ # @option options [Symbol] :on_busy (:fail) `:skip` or `:fail` when the lease
40
+ # cannot be acquired within `:wait`
41
+ # @option options [Symbol, Proc, #call] :if gate `(task)` for acquiring
42
+ # @option options [Symbol, Proc, #call] :unless gate `(task)` for acquiring
43
+ def initialize(key, options = EMPTY_HASH)
44
+ @key = key
45
+ @options = options.freeze
46
+ end
47
+
48
+ # @return [String]
49
+ def namespace
50
+ @options[:namespace] || "cmdx:lock"
51
+ end
52
+
53
+ # @return [Integer]
54
+ def ttl
55
+ @options[:ttl] || 300
56
+ end
57
+
58
+ # @return [Float]
59
+ def wait
60
+ raw = @options[:wait]
61
+ return 0.0 if raw.nil? || raw == false
62
+
63
+ Float(raw)
64
+ end
65
+
66
+ # @return [Symbol] `:skip` or `:fail`
67
+ def on_busy
68
+ @options[:on_busy] || :fail
69
+ end
70
+
71
+ # @return [Symbol, Proc, #call, nil]
72
+ def condition_if
73
+ @options[:if]
74
+ end
75
+
76
+ # @return [Symbol, Proc, #call, nil]
77
+ def condition_unless
78
+ @options[:unless]
79
+ end
80
+
81
+ # @return [Boolean]
82
+ def enabled?
83
+ !key.nil?
84
+ end
85
+
86
+ # Returns a new Lock layering `new_options` onto the current one. 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.
89
+ #
90
+ # @param new_key [Symbol, String, Proc, #call, nil]
91
+ # @param new_options [Hash{Symbol => Object}]
92
+ # @return [Lock]
93
+ def build(new_key, new_options)
94
+ return self if new_key.nil? && new_options.empty?
95
+
96
+ merged_key = new_key.nil? ? @key : new_key
97
+ merged_options = @options.merge(new_options)
98
+
99
+ self.class.new(merged_key, merged_options)
100
+ end
101
+
102
+ # Runs the given block behind a store lease when {#enabled?} and gates pass.
103
+ #
104
+ # @param task [Task]
105
+ # @yield invoked when the lease is acquired or locking is inactive
106
+ # @yieldreturn [Object] return value from the wrapped work
107
+ # @return [Object] the block's return value
108
+ # @note When the lease cannot be acquired, signals `skip!` or `fail!` on
109
+ # `task` — control does not return to the caller of {#process}. Busy
110
+ # metadata may include `:holder` and `:retry_after` when the backend
111
+ # exposes the current value and TTL.
112
+ # @raise [ArgumentError] when `on_busy` is not `:skip` or `:fail`, or `wait`
113
+ # is negative
114
+ def process(task)
115
+ return yield unless enabled?
116
+ return yield unless Util.satisfied?(condition_if, condition_unless, task)
117
+
118
+ store_key = StoreKey.task_key(task, namespace:, resolver: key)
119
+ return yield if store_key.nil?
120
+
121
+ token = task.tid
122
+
123
+ if acquire_lock(store_key, token)
124
+ Telemetry.emit(task, :lock_acquired, lock_key: store_key)
125
+
126
+ begin
127
+ yield
128
+ ensure
129
+ release_lock(store_key, token)
130
+ end
131
+ else
132
+ busy!(task, store_key)
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ def acquire_lock(store_key, token) # rubocop:disable Naming/PredicateMethod -- returns whether the lease was taken
139
+ wait_seconds = wait
140
+ raise ArgumentError, "lock_with wait must be >= 0 (got #{wait_seconds})" if wait_seconds.negative?
141
+
142
+ deadline = (Util.monotonic_now + wait_seconds if wait_seconds.positive?)
143
+
144
+ loop do
145
+ return true if Stores.set(store_key, token, nx: true, ttl:)
146
+ return false unless deadline
147
+
148
+ break if Util.monotonic_now >= deadline
149
+
150
+ sleep_interval = [0.05, deadline - Util.monotonic_now].min
151
+ Kernel.sleep(sleep_interval) if sleep_interval.positive?
152
+ end
153
+
154
+ false
155
+ end
156
+
157
+ def release_lock(store_key, token)
158
+ Stores.del_if(store_key, token)
159
+ end
160
+
161
+ def busy!(task, store_key)
162
+ holder = Stores.get(store_key)
163
+ retry_after = Stores.remaining_ttl(store_key)
164
+
165
+ message = holder ? "lock held" : "lock not acquired"
166
+ metadata = {
167
+ code: :busy,
168
+ lock_key: store_key,
169
+ holder:,
170
+ retry_after:
171
+ }.compact
172
+
173
+ Telemetry.emit(task, :lock_busy, **metadata, on_busy:)
174
+
175
+ case on_busy
176
+ when :fail
177
+ task.fail!(message, **metadata)
178
+ when :skip
179
+ task.skip!(message, **metadata)
180
+ else
181
+ raise ArgumentError, <<~MSG.chomp
182
+ lock_with on_busy must be :fail or :skip (got #{on_busy.inspect}).
183
+ See https://drexed.github.io/cmdx/locks/
184
+ MSG
185
+ end
186
+ end
187
+
188
+ DISABLED = new(nil).freeze
189
+ private_constant :DISABLED
190
+
191
+ end
192
+ end
data/lib/cmdx/pipeline.rb CHANGED
@@ -151,7 +151,7 @@ module CMDx
151
151
  new_opts = old_opts.merge(rolled_back: true).freeze
152
152
  result.instance_variable_set(:@options, new_opts)
153
153
 
154
- emit_telemetry(instance, :task_rolled_back)
154
+ Telemetry.emit(instance, :task_rolled_back, root: false)
155
155
 
156
156
  begin
157
157
  instance.rollback
@@ -166,14 +166,6 @@ module CMDx
166
166
  raise first_rollback_error if first_rollback_error
167
167
  end
168
168
 
169
- def emit_telemetry(instance, name, payload = EMPTY_HASH)
170
- telemetry = instance.class.telemetry
171
- return unless telemetry.subscribed?(name)
172
-
173
- event = Telemetry::Event.build(instance, name, root: false, payload:)
174
- telemetry.emit(name, event)
175
- end
176
-
177
169
  def aggregate(failures, continue:)
178
170
  return if failures.empty?
179
171
  return failures.first unless continue