cmdx 2.2.0 → 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 +11 -1
- data/TODO.md +353 -0
- data/lib/cmdx/circuit_breaker.rb +234 -0
- data/lib/cmdx/configuration.rb +2 -1
- data/lib/cmdx/deprecation.rb +2 -2
- data/lib/cmdx/idempotency.rb +172 -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 +42 -19
- 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 +115 -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 +23 -5
- data/mkdocs.yml +8 -1
- metadata +9 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
module Stores
|
|
5
|
+
# Process-local key/value store with optional per-key TTL. Thread-safe via
|
|
6
|
+
# an internal mutex. Suitable for tests and single-process dev; not shared
|
|
7
|
+
# across workers.
|
|
8
|
+
class Memory
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@entries = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param key [String, Symbol]
|
|
16
|
+
# @return [String, nil] stored value, or `nil` when missing or expired
|
|
17
|
+
def get(key)
|
|
18
|
+
key = Stores.key!(key)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
entry = @entries[key]
|
|
21
|
+
return unless entry
|
|
22
|
+
return if expired?(entry)
|
|
23
|
+
|
|
24
|
+
entry[:value]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param key [String, Symbol]
|
|
29
|
+
# @param value [#to_s]
|
|
30
|
+
# @param ttl [Integer, nil] seconds until expiry (`nil` = no expiry)
|
|
31
|
+
# @param nx [Boolean] set only when the key is absent
|
|
32
|
+
# @param xx [Boolean] set only when the key is present
|
|
33
|
+
# @return [Boolean] whether the value was written
|
|
34
|
+
# @raise [ArgumentError] when `nx` and `xx` are both true, or `ttl` is invalid
|
|
35
|
+
def set(key, value, ttl: nil, nx: false, xx: false)
|
|
36
|
+
key = Stores.key!(key)
|
|
37
|
+
ttl = Stores.ttl!(ttl) if ttl
|
|
38
|
+
Stores.nx_xx!(nx, xx)
|
|
39
|
+
|
|
40
|
+
expires_at = ttl ? Util.monotonic_now + ttl : nil
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
entry = @entries[key]
|
|
43
|
+
present = entry && !expired?(entry)
|
|
44
|
+
|
|
45
|
+
return false if nx && present
|
|
46
|
+
return false if xx && !present
|
|
47
|
+
|
|
48
|
+
@entries[key] = { value: value.to_s, expires_at: }
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param key [String, Symbol]
|
|
54
|
+
# @param ttl [Integer, nil] when set, refreshes expiry on every increment
|
|
55
|
+
# @return [Integer] value after increment
|
|
56
|
+
# @raise [ArgumentError] when `ttl` is invalid
|
|
57
|
+
def incr(key, ttl: nil)
|
|
58
|
+
key = Stores.key!(key)
|
|
59
|
+
ttl = Stores.ttl!(ttl) if ttl
|
|
60
|
+
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
entry = @entries[key]
|
|
63
|
+
if entry && expired?(entry)
|
|
64
|
+
@entries.delete(key)
|
|
65
|
+
entry = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
count = entry ? entry[:value].to_i + 1 : 1
|
|
69
|
+
expires_at = (Util.monotonic_now + ttl if ttl)
|
|
70
|
+
|
|
71
|
+
@entries[key] = { value: count.to_s, expires_at: }
|
|
72
|
+
count
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param key [String, Symbol]
|
|
77
|
+
# @return [Integer, nil] whole seconds until expiry, or `nil` when missing or expired
|
|
78
|
+
def ttl(key)
|
|
79
|
+
key = Stores.key!(key)
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
entry = @entries[key]
|
|
82
|
+
return unless entry
|
|
83
|
+
return if expired?(entry)
|
|
84
|
+
|
|
85
|
+
expires_at = entry[:expires_at]
|
|
86
|
+
return unless expires_at
|
|
87
|
+
|
|
88
|
+
remaining = expires_at - Util.monotonic_now
|
|
89
|
+
remaining.positive? ? remaining.ceil : nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @param key [String, Symbol]
|
|
94
|
+
# @return [Integer] value after decrement (floors at `0`, removing the key)
|
|
95
|
+
# @raise [ArgumentError] when `ttl` is invalid (only when `ttl` is passed)
|
|
96
|
+
def decr(key, ttl: nil)
|
|
97
|
+
key = Stores.key!(key)
|
|
98
|
+
Stores.ttl!(ttl) if ttl
|
|
99
|
+
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
entry = @entries[key]
|
|
102
|
+
if entry && expired?(entry)
|
|
103
|
+
@entries.delete(key)
|
|
104
|
+
entry = nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
count = entry ? entry[:value].to_i : 0
|
|
108
|
+
count = count.positive? ? count - 1 : 0
|
|
109
|
+
expires_at = entry&.fetch(:expires_at, nil)
|
|
110
|
+
|
|
111
|
+
if count.zero?
|
|
112
|
+
@entries.delete(key)
|
|
113
|
+
else
|
|
114
|
+
@entries[key] = { value: count.to_s, expires_at: }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
count
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param key [String, Symbol]
|
|
122
|
+
# @return [Integer] `1` when a key was removed, `0` otherwise
|
|
123
|
+
def del(key)
|
|
124
|
+
key = Stores.key!(key)
|
|
125
|
+
@mutex.synchronize do
|
|
126
|
+
@entries.delete(key) ? 1 : 0
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @param key [String, Symbol]
|
|
131
|
+
# @param expected [#to_s]
|
|
132
|
+
# @return [Boolean] whether the key was removed
|
|
133
|
+
def del_if(key, expected)
|
|
134
|
+
key = Stores.key!(key)
|
|
135
|
+
expected = expected.to_s
|
|
136
|
+
@mutex.synchronize do
|
|
137
|
+
entry = @entries[key]
|
|
138
|
+
return false unless entry && !expired?(entry)
|
|
139
|
+
return false unless entry[:value] == expected
|
|
140
|
+
|
|
141
|
+
@entries.delete(key)
|
|
142
|
+
true
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def expired?(entry)
|
|
149
|
+
expires_at = entry[:expires_at]
|
|
150
|
+
expires_at && Util.monotonic_now >= expires_at
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -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.
|
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"
|
|
@@ -101,11 +101,19 @@ 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 }
|
|
109
117
|
#
|
|
110
118
|
# Every event also carries: event.cid, event.xid, event.tid, event.task,
|
|
111
119
|
# event.type, event.name, event.root, event.payload, event.timestamp.
|
|
@@ -168,6 +176,16 @@ CMDx.configure do |config|
|
|
|
168
176
|
# Bugsnag.notify("Deprecated task invoked: #{task.class.name}")
|
|
169
177
|
# end)
|
|
170
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
|
+
|
|
171
189
|
# ===========================================================================
|
|
172
190
|
# Executors
|
|
173
191
|
# https://drexed.github.io/cmdx/workflows/#parallel-execution
|