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