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.
@@ -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
@@ -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`, `deprecation`, and `settings`. Invoked via {.execute} (safe)
7
- # or {.execute!} (strict, raises on failure).
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 inherited) Retry.
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.build(exceptions, options, &)
39
+ superclass.retry_on
37
40
  else
38
- Retry.new(exceptions, options, &)
41
+ Retry.disabled
39
42
  end
40
43
 
41
- return @retry_on if exceptions.empty?
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
- @retry_on = @retry_on.build(exceptions, options, &)
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.
@@ -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}). Subscribers are
5
- # callables receiving an {Event} data object. Runtime emits events only when
6
- # subscribers are registered so telemetry has zero cost when unused.
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 Runtime emits.
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
@@ -3,6 +3,6 @@
3
3
  module CMDx
4
4
 
5
5
  # Gem version. Bumped on release; mirrored in the gemspec.
6
- VERSION = "2.2.0"
6
+ VERSION = "2.3.0"
7
7
 
8
8
  end
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 { attempt: Integer }
107
- # :task_rolled_back {}
108
- # :task_executed { result: CMDx::Result }
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