cmdx 2.1.1 → 2.3.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 +20 -1
- data/TODO.md +353 -0
- data/lib/cmdx/callbacks.rb +38 -7
- data/lib/cmdx/circuit_breaker.rb +234 -0
- data/lib/cmdx/coercions.rb +1 -0
- data/lib/cmdx/configuration.rb +2 -1
- data/lib/cmdx/deprecation.rb +2 -2
- data/lib/cmdx/fault.rb +38 -0
- data/lib/cmdx/idempotency.rb +172 -0
- data/lib/cmdx/input.rb +12 -2
- data/lib/cmdx/inputs.rb +4 -0
- data/lib/cmdx/lock.rb +191 -0
- data/lib/cmdx/pipeline.rb +1 -9
- data/lib/cmdx/retry.rb +20 -2
- data/lib/cmdx/runtime.rb +44 -20
- data/lib/cmdx/store_key.rb +47 -0
- data/lib/cmdx/stores/memory.rb +155 -0
- data/lib/cmdx/stores/redis.rb +111 -0
- data/lib/cmdx/stores.rb +111 -0
- data/lib/cmdx/task.rb +118 -8
- data/lib/cmdx/telemetry.rb +34 -4
- data/lib/cmdx/util.rb +7 -0
- data/lib/cmdx/version.rb +1 -1
- data/lib/cmdx.rb +11 -0
- data/lib/generators/cmdx/templates/install.rb +27 -8
- data/mkdocs.yml +8 -1
- metadata +10 -2
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
module Stores
|
|
5
|
+
# Delegates to a Redis-compatible client (`redis-rb`, `MockRedis`, etc.).
|
|
6
|
+
# Keys are prefixed to avoid collisions with unrelated application 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] prepended to every key (trailing `:` added when missing)
|
|
21
|
+
def initialize(redis:, namespace: "cmdx:store")
|
|
22
|
+
@redis = redis
|
|
23
|
+
@namespace = namespace.end_with?(":") ? namespace : "#{namespace}:"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param key [String, Symbol]
|
|
27
|
+
# @return [String, nil]
|
|
28
|
+
def get(key)
|
|
29
|
+
@redis.get(prefixed(key))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param key [String, Symbol]
|
|
33
|
+
# @param value [#to_s]
|
|
34
|
+
# @param ttl [Integer, nil]
|
|
35
|
+
# @param nx [Boolean]
|
|
36
|
+
# @param xx [Boolean]
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
# @raise [ArgumentError] when `nx` and `xx` are both true, or `ttl` is invalid
|
|
39
|
+
def set(key, value, ttl: nil, nx: false, xx: false) # rubocop:disable Naming/PredicateMethod -- Redis SET contract
|
|
40
|
+
key = prefixed(key)
|
|
41
|
+
ttl = Stores.ttl!(ttl) if ttl
|
|
42
|
+
Stores.nx_xx!(nx, xx)
|
|
43
|
+
|
|
44
|
+
options = {}
|
|
45
|
+
options[:ex] = ttl if ttl
|
|
46
|
+
options[:nx] = true if nx
|
|
47
|
+
options[:xx] = true if xx
|
|
48
|
+
|
|
49
|
+
if options.empty?
|
|
50
|
+
@redis.set(key, value.to_s)
|
|
51
|
+
true
|
|
52
|
+
else
|
|
53
|
+
!!@redis.set(key, value.to_s, **options)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param key [String, Symbol]
|
|
58
|
+
# @param ttl [Integer, nil]
|
|
59
|
+
# @return [Integer]
|
|
60
|
+
# @raise [ArgumentError] when `ttl` is invalid
|
|
61
|
+
def incr(key, ttl: nil)
|
|
62
|
+
key = prefixed(key)
|
|
63
|
+
ttl = Stores.ttl!(ttl) if ttl
|
|
64
|
+
|
|
65
|
+
count = @redis.incr(key)
|
|
66
|
+
@redis.expire(key, ttl) if ttl
|
|
67
|
+
count
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @param key [String, Symbol]
|
|
71
|
+
# @return [Integer, nil] seconds until expiry, or `nil` when missing or expired
|
|
72
|
+
def ttl(key)
|
|
73
|
+
key = prefixed(key)
|
|
74
|
+
seconds = @redis.ttl(key)
|
|
75
|
+
seconds if seconds.is_a?(Integer) && seconds.positive?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @param key [String, Symbol]
|
|
79
|
+
# @return [Integer]
|
|
80
|
+
def decr(key, ttl: nil)
|
|
81
|
+
key = prefixed(key)
|
|
82
|
+
Stores.ttl!(ttl) if ttl
|
|
83
|
+
|
|
84
|
+
@redis.decr(key)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param key [String, Symbol]
|
|
88
|
+
# @return [Integer]
|
|
89
|
+
def del(key)
|
|
90
|
+
@redis.del(prefixed(key))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @param key [String, Symbol]
|
|
94
|
+
# @param expected [#to_s]
|
|
95
|
+
# @return [Boolean] whether the key was removed
|
|
96
|
+
def del_if(key, expected) # rubocop:disable Naming/PredicateMethod -- store delete contract
|
|
97
|
+
key = prefixed(key)
|
|
98
|
+
expected = expected.to_s
|
|
99
|
+
deleted = @redis.eval(DEL_IF_SCRIPT, keys: [key], argv: [expected])
|
|
100
|
+
deleted.to_i.positive?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def prefixed(key)
|
|
106
|
+
"#{@namespace}#{Stores.key!(key)}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
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,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`, `
|
|
7
|
-
#
|
|
6
|
+
# `retry_on`, `idempotent_by`, `lock_with`, `circuit_breaker`, `deprecation`,
|
|
7
|
+
# and `settings`.
|
|
8
|
+
# Invoked via {.execute} (safe) or {.execute!} (strict, raises on failure).
|
|
8
9
|
#
|
|
9
10
|
# Inheritance: every registry accessor (middlewares, callbacks, coercions,
|
|
10
11
|
# validators, executors, mergers, telemetry, inputs, outputs) lazily clones from the
|
|
@@ -18,9 +19,11 @@ module CMDx
|
|
|
18
19
|
class << self
|
|
19
20
|
|
|
20
21
|
# Declares exceptions to retry on. Builds on the superclass's `Retry`.
|
|
21
|
-
# Passing no exceptions returns the current (possibly
|
|
22
|
+
# Passing no exceptions and no options returns the current (possibly
|
|
23
|
+
# inherited) Retry. Pass `false` to disable retries on this class.
|
|
22
24
|
#
|
|
23
|
-
# @param exceptions [Array<Class>]
|
|
25
|
+
# @param exceptions [Array<Class, false>] exception classes; `false` alone
|
|
26
|
+
# disables retries on this class
|
|
24
27
|
# @param options [Hash{Symbol => Object}] see {Retry#initialize}
|
|
25
28
|
# @option options [Integer] :limit (see {Retry#initialize})
|
|
26
29
|
# @option options [Float] :delay (see {Retry#initialize})
|
|
@@ -33,14 +36,118 @@ module CMDx
|
|
|
33
36
|
def retry_on(*exceptions, **options, &)
|
|
34
37
|
@retry_on ||=
|
|
35
38
|
if superclass.respond_to?(:retry_on)
|
|
36
|
-
superclass.retry_on
|
|
39
|
+
superclass.retry_on
|
|
37
40
|
else
|
|
38
|
-
Retry.
|
|
41
|
+
Retry.disabled
|
|
39
42
|
end
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
@retry_on =
|
|
45
|
+
if exceptions == [false]
|
|
46
|
+
Retry.disabled
|
|
47
|
+
else
|
|
48
|
+
@retry_on.build(exceptions, options, &)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Declares a stable idempotency key from context (or a callable). Builds on
|
|
53
|
+
# the superclass's {Idempotency}. Passing no `key` and no options returns the
|
|
54
|
+
# current (possibly inherited) configuration. Pass `false` to disable.
|
|
55
|
+
#
|
|
56
|
+
# @param key [Symbol, String, Proc, #call, false, nil] context key or
|
|
57
|
+
# resolver; `false` disables idempotency on this class
|
|
58
|
+
# @param options [Hash{Symbol => Object}] see {Idempotency#initialize}
|
|
59
|
+
# @option options [Integer] :ttl (see {Idempotency#initialize})
|
|
60
|
+
# @option options [String] :namespace (see {Idempotency#initialize})
|
|
61
|
+
# @option options [Symbol] :on_duplicate (see {Idempotency#initialize})
|
|
62
|
+
# @option options [Symbol, Proc, #call] :if gate `(task)` for claiming
|
|
63
|
+
# @option options [Symbol, Proc, #call] :unless gate `(task)` for claiming
|
|
64
|
+
# @yieldparam task [Task] optional first argument when the block is
|
|
65
|
+
# `instance_exec`'d (same as callbacks)
|
|
66
|
+
# @return [Idempotency]
|
|
67
|
+
# @see Idempotency#process
|
|
68
|
+
def idempotent_by(key = nil, **options, &block)
|
|
69
|
+
@idempotent_by ||=
|
|
70
|
+
if superclass.respond_to?(:idempotent_by)
|
|
71
|
+
superclass.idempotent_by
|
|
72
|
+
else
|
|
73
|
+
Idempotency.disabled
|
|
74
|
+
end
|
|
42
75
|
|
|
43
|
-
@
|
|
76
|
+
@idempotent_by =
|
|
77
|
+
if key == false
|
|
78
|
+
Idempotency.disabled
|
|
79
|
+
else
|
|
80
|
+
@idempotent_by.build(key || block, options)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Declares a distributed lock from context (or a callable). Builds on the
|
|
85
|
+
# superclass's {Lock}. Passing no `key` and no options returns the current
|
|
86
|
+
# (possibly inherited) configuration. Pass `false` to disable.
|
|
87
|
+
#
|
|
88
|
+
# @param key [Symbol, String, Proc, #call, false, nil] context key or
|
|
89
|
+
# resolver; `false` disables locking on this class
|
|
90
|
+
# @param options [Hash{Symbol => Object}] see {Lock#initialize}
|
|
91
|
+
# @option options [Integer] :ttl (see {Lock#initialize})
|
|
92
|
+
# @option options [String] :namespace (see {Lock#initialize})
|
|
93
|
+
# @option options [Numeric, false, nil] :wait (see {Lock#initialize})
|
|
94
|
+
# @option options [Symbol] :on_busy (see {Lock#initialize})
|
|
95
|
+
# @option options [Symbol, Proc, #call] :if gate `(task)` for acquiring
|
|
96
|
+
# @option options [Symbol, Proc, #call] :unless gate `(task)` for acquiring
|
|
97
|
+
# @yieldparam task [Task] optional first argument when the block is
|
|
98
|
+
# `instance_exec`'d (same as callbacks)
|
|
99
|
+
# @return [Lock]
|
|
100
|
+
# @see Lock#process
|
|
101
|
+
def lock_with(key = nil, **options, &block)
|
|
102
|
+
@lock_with ||=
|
|
103
|
+
if superclass.respond_to?(:lock_with)
|
|
104
|
+
superclass.lock_with
|
|
105
|
+
else
|
|
106
|
+
Lock.disabled
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@lock_with =
|
|
110
|
+
if key == false
|
|
111
|
+
Lock.disabled
|
|
112
|
+
else
|
|
113
|
+
@lock_with.build(key || block, options)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Declares a shared circuit breaker for the task. Builds on the
|
|
118
|
+
# superclass's {CircuitBreaker}. Passing no `key` and no options returns the
|
|
119
|
+
# current (possibly inherited) configuration. Pass `false` to disable.
|
|
120
|
+
#
|
|
121
|
+
# @param key [Symbol, String, Proc, #call, false, nil] `:class` scopes one
|
|
122
|
+
# breaker per task class; other forms resolve a store segment like
|
|
123
|
+
# {Idempotency}; `false` disables the breaker on this class
|
|
124
|
+
# @param options [Hash{Symbol => Object}] see {CircuitBreaker#initialize}
|
|
125
|
+
# @option options [Integer] :threshold (see {CircuitBreaker#initialize})
|
|
126
|
+
# @option options [Integer] :cool_off (see {CircuitBreaker#initialize})
|
|
127
|
+
# @option options [Integer] :window (see {CircuitBreaker#initialize})
|
|
128
|
+
# @option options [String] :namespace (see {CircuitBreaker#initialize})
|
|
129
|
+
# @option options [Symbol] :on_open (see {CircuitBreaker#initialize})
|
|
130
|
+
# @option options [Array<Class>] :on (see {CircuitBreaker#initialize})
|
|
131
|
+
# @option options [Symbol, Proc, #call] :if gate `(task)` before checking
|
|
132
|
+
# @option options [Symbol, Proc, #call] :unless gate `(task)` before checking
|
|
133
|
+
# @yieldparam task [Task] optional first argument when the block is
|
|
134
|
+
# `instance_exec`'d (same as callbacks)
|
|
135
|
+
# @return [CircuitBreaker]
|
|
136
|
+
# @see CircuitBreaker#process
|
|
137
|
+
def circuit_breaker(key = nil, **options, &block)
|
|
138
|
+
@circuit_breaker ||=
|
|
139
|
+
if superclass.respond_to?(:circuit_breaker)
|
|
140
|
+
superclass.circuit_breaker
|
|
141
|
+
else
|
|
142
|
+
CircuitBreaker.disabled
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@circuit_breaker =
|
|
146
|
+
if key == false
|
|
147
|
+
CircuitBreaker.disabled
|
|
148
|
+
else
|
|
149
|
+
@circuit_breaker.build(key || block, options)
|
|
150
|
+
end
|
|
44
151
|
end
|
|
45
152
|
|
|
46
153
|
# Reads or extends this class's {Settings}. Inherits from the superclass.
|
|
@@ -274,6 +381,7 @@ module CMDx
|
|
|
274
381
|
# @option options [Symbol, Proc, #call] :if
|
|
275
382
|
# @option options [Symbol, Proc, #call] :unless
|
|
276
383
|
# @option options [Boolean] :required
|
|
384
|
+
# @option options [Boolean] :allow_nil (see {Input#allow_nil})
|
|
277
385
|
# @option options [Object] :coerce (see {Coercions#extract})
|
|
278
386
|
# @option options [Object] :validate (see {Validators#extract})
|
|
279
387
|
# @yield nested-input DSL block (see {Inputs::ChildBuilder})
|
|
@@ -307,6 +415,7 @@ module CMDx
|
|
|
307
415
|
# @option options [Symbol, Proc, #call] :transform mutator applied after coercion
|
|
308
416
|
# @option options [Symbol, Proc, #call] :if
|
|
309
417
|
# @option options [Symbol, Proc, #call] :unless
|
|
418
|
+
# @option options [Boolean] :allow_nil (see {Input#allow_nil})
|
|
310
419
|
# @option options [Object] :coerce (see {Coercions#extract})
|
|
311
420
|
# @option options [Object] :validate (see {Validators#extract})
|
|
312
421
|
# @yield nested-input DSL block (see {Inputs::ChildBuilder})
|
|
@@ -329,6 +438,7 @@ module CMDx
|
|
|
329
438
|
# @option options [Symbol, Proc, #call] :transform mutator applied after coercion
|
|
330
439
|
# @option options [Symbol, Proc, #call] :if
|
|
331
440
|
# @option options [Symbol, Proc, #call] :unless
|
|
441
|
+
# @option options [Boolean] :allow_nil (see {Input#allow_nil})
|
|
332
442
|
# @option options [Object] :coerce (see {Coercions#extract})
|
|
333
443
|
# @option options [Object] :validate (see {Validators#extract})
|
|
334
444
|
# @yield nested-input DSL block (see {Inputs::ChildBuilder})
|
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,44 @@ 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
|
|
33
43
|
].freeze
|
|
34
44
|
|
|
45
|
+
class << self
|
|
46
|
+
|
|
47
|
+
# Dispatches `payload` when `task`'s telemetry registry has subscribers
|
|
48
|
+
# for `name`. No-op when nothing is subscribed.
|
|
49
|
+
#
|
|
50
|
+
# @param task [Task]
|
|
51
|
+
# @param name [Symbol] one of {EVENTS}
|
|
52
|
+
# @param root [Boolean] whether this task started the active chain
|
|
53
|
+
# @param payload [Hash{Symbol => Object}] extra fields merged into the event payload
|
|
54
|
+
# @return [void]
|
|
55
|
+
def emit(task, name, root: false, **payload)
|
|
56
|
+
telemetry = task.class.telemetry
|
|
57
|
+
return unless telemetry.subscribed?(name)
|
|
58
|
+
|
|
59
|
+
event = Event.build(task, name, root:, payload:)
|
|
60
|
+
telemetry.emit(name, event)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
end
|
|
64
|
+
|
|
35
65
|
attr_reader :registry
|
|
36
66
|
|
|
37
67
|
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)
|
|
@@ -146,8 +150,15 @@ require_relative "cmdx/outputs"
|
|
|
146
150
|
require_relative "cmdx/callbacks"
|
|
147
151
|
require_relative "cmdx/middlewares"
|
|
148
152
|
require_relative "cmdx/telemetry"
|
|
153
|
+
require_relative "cmdx/stores/memory"
|
|
154
|
+
require_relative "cmdx/stores/redis"
|
|
155
|
+
require_relative "cmdx/stores"
|
|
149
156
|
require_relative "cmdx/settings"
|
|
157
|
+
require_relative "cmdx/store_key"
|
|
150
158
|
require_relative "cmdx/retry"
|
|
159
|
+
require_relative "cmdx/idempotency"
|
|
160
|
+
require_relative "cmdx/lock"
|
|
161
|
+
require_relative "cmdx/circuit_breaker"
|
|
151
162
|
require_relative "cmdx/deprecation"
|
|
152
163
|
require_relative "cmdx/context"
|
|
153
164
|
require_relative "cmdx/chain"
|
|
@@ -79,7 +79,8 @@ CMDx.configure do |config|
|
|
|
79
79
|
# Callbacks
|
|
80
80
|
# https://drexed.github.io/cmdx/configuration/#callbacks
|
|
81
81
|
# ===========================================================================
|
|
82
|
-
# Hook into a task's lifecycle.
|
|
82
|
+
# Hook into a task's lifecycle. Pre-work hooks receive the task; post-work
|
|
83
|
+
# hooks (`after_execution`, every `on_*`) also receive the finalized Signal.
|
|
83
84
|
#
|
|
84
85
|
# Available events:
|
|
85
86
|
# :before_execution, :before_validation,
|
|
@@ -88,8 +89,8 @@ CMDx.configure do |config|
|
|
|
88
89
|
# :on_success, :on_skipped, :on_failed,
|
|
89
90
|
# :on_ok, :on_ko
|
|
90
91
|
#
|
|
91
|
-
# config.callbacks.register(:on_failed, proc do |task|
|
|
92
|
-
# Rails.logger.error("[cmdx] #{task.class.name} failed: #{
|
|
92
|
+
# config.callbacks.register(:on_failed, proc do |task, signal|
|
|
93
|
+
# Rails.logger.error("[cmdx] #{task.class.name} failed: #{signal.reason}")
|
|
93
94
|
# end)
|
|
94
95
|
|
|
95
96
|
# ===========================================================================
|
|
@@ -100,11 +101,19 @@ CMDx.configure do |config|
|
|
|
100
101
|
# fires if nobody's listening, so unused events cost nothing.
|
|
101
102
|
#
|
|
102
103
|
# Events and their extra payload keys:
|
|
103
|
-
# :task_started
|
|
104
|
-
# :task_deprecated
|
|
105
|
-
# :task_retried
|
|
106
|
-
# :task_rolled_back
|
|
107
|
-
# :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 }
|
|
108
117
|
#
|
|
109
118
|
# Every event also carries: event.cid, event.xid, event.tid, event.task,
|
|
110
119
|
# event.type, event.name, event.root, event.payload, event.timestamp.
|
|
@@ -167,6 +176,16 @@ CMDx.configure do |config|
|
|
|
167
176
|
# Bugsnag.notify("Deprecated task invoked: #{task.class.name}")
|
|
168
177
|
# end)
|
|
169
178
|
|
|
179
|
+
# ===========================================================================
|
|
180
|
+
# Store
|
|
181
|
+
# https://drexed.github.io/cmdx/configuration/#store
|
|
182
|
+
# ===========================================================================
|
|
183
|
+
# Shared key/value scratch space (`CMDx::Stores.get/set/incr/del`) for
|
|
184
|
+
# idempotency keys, rate limits, and similar cross-request state. Defaults to
|
|
185
|
+
# an in-memory adapter; point at Redis in production.
|
|
186
|
+
#
|
|
187
|
+
# config.store = CMDx::Stores::Redis.new(redis: Redis.current, namespace: "cmdx:store")
|
|
188
|
+
|
|
170
189
|
# ===========================================================================
|
|
171
190
|
# Executors
|
|
172
191
|
# https://drexed.github.io/cmdx/workflows/#parallel-execution
|
data/mkdocs.yml
CHANGED
|
@@ -121,7 +121,7 @@ plugins:
|
|
|
121
121
|
- basics/chain.md: Execution chain tracking for related tasks within threads
|
|
122
122
|
Interruptions:
|
|
123
123
|
- interruptions/signals.md: Intentional task interruption using skip! and fail! methods
|
|
124
|
-
- interruptions/faults.md:
|
|
124
|
+
- interruptions/faults.md: execute! exceptions for failed tasks with result, task, context, chain, and cleaned backtrace
|
|
125
125
|
- interruptions/exceptions.md: Complete exception hierarchy (Error, DeprecationError, ImplementationError, MiddlewareError, Fault) and execute vs execute! semantics
|
|
126
126
|
Outcomes:
|
|
127
127
|
- outcomes/result.md: Result objects exposing execution state, status, context, and metadata
|
|
@@ -141,6 +141,9 @@ plugins:
|
|
|
141
141
|
- logging.md: Structured logging with multiple formatters (JSON, KeyValue, Logstash, Line, Raw)
|
|
142
142
|
- internationalization.md: Multi-language support for error messages and validations (90+ locales)
|
|
143
143
|
- retries.md: Automatic retry functionality for transient failures with jitter and selective retries
|
|
144
|
+
- idempotency.md: Preventing duplicate execution for retried jobs and webhooks with idempotent_by
|
|
145
|
+
- 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
|
|
144
147
|
- deprecation.md: Managing deprecated tasks with logging, warnings, or execution prevention
|
|
145
148
|
- outputs.md: Declaring expected context outputs that must be set after task execution
|
|
146
149
|
- workflows.md: Composing multiple tasks into sequential pipelines with conditional execution
|
|
@@ -181,6 +184,10 @@ nav:
|
|
|
181
184
|
- Logging: logging.md
|
|
182
185
|
- Internationalization: internationalization.md
|
|
183
186
|
- Retries: retries.md
|
|
187
|
+
- Idempotency: idempotency.md
|
|
188
|
+
- Locks: locks.md
|
|
189
|
+
- Circuit breaker: circuit_breaker.md
|
|
190
|
+
- Stores: stores.md
|
|
184
191
|
- Deprecation: deprecation.md
|
|
185
192
|
- Outputs: outputs.md
|
|
186
193
|
- Workflows: workflows.md
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cmdx
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Juan Gomez
|
|
@@ -204,10 +204,12 @@ files:
|
|
|
204
204
|
- LICENSE.txt
|
|
205
205
|
- README.md
|
|
206
206
|
- Rakefile
|
|
207
|
+
- TODO.md
|
|
207
208
|
- lib/cmdx.rb
|
|
208
209
|
- lib/cmdx/.DS_Store
|
|
209
210
|
- lib/cmdx/callbacks.rb
|
|
210
211
|
- lib/cmdx/chain.rb
|
|
212
|
+
- lib/cmdx/circuit_breaker.rb
|
|
211
213
|
- lib/cmdx/coercions.rb
|
|
212
214
|
- lib/cmdx/coercions/array.rb
|
|
213
215
|
- lib/cmdx/coercions/big_decimal.rb
|
|
@@ -236,8 +238,10 @@ files:
|
|
|
236
238
|
- lib/cmdx/executors/thread.rb
|
|
237
239
|
- lib/cmdx/fault.rb
|
|
238
240
|
- lib/cmdx/i18n_proxy.rb
|
|
241
|
+
- lib/cmdx/idempotency.rb
|
|
239
242
|
- lib/cmdx/input.rb
|
|
240
243
|
- lib/cmdx/inputs.rb
|
|
244
|
+
- lib/cmdx/lock.rb
|
|
241
245
|
- lib/cmdx/log_formatters/json.rb
|
|
242
246
|
- lib/cmdx/log_formatters/key_value.rb
|
|
243
247
|
- lib/cmdx/log_formatters/line.rb
|
|
@@ -266,6 +270,10 @@ files:
|
|
|
266
270
|
- lib/cmdx/runtime.rb
|
|
267
271
|
- lib/cmdx/settings.rb
|
|
268
272
|
- lib/cmdx/signal.rb
|
|
273
|
+
- lib/cmdx/store_key.rb
|
|
274
|
+
- lib/cmdx/stores.rb
|
|
275
|
+
- lib/cmdx/stores/memory.rb
|
|
276
|
+
- lib/cmdx/stores/redis.rb
|
|
269
277
|
- lib/cmdx/task.rb
|
|
270
278
|
- lib/cmdx/telemetry.rb
|
|
271
279
|
- lib/cmdx/util.rb
|
|
@@ -312,7 +320,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
312
320
|
- !ruby/object:Gem::Version
|
|
313
321
|
version: '0'
|
|
314
322
|
requirements: []
|
|
315
|
-
rubygems_version: 4.0.
|
|
323
|
+
rubygems_version: 4.0.17
|
|
316
324
|
specification_version: 4
|
|
317
325
|
summary: CMDx is a framework for building maintainable business processes.
|
|
318
326
|
test_files: []
|