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.
@@ -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.
@@ -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})
@@ -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.1.1"
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"
@@ -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. Each callback receives the task instance.
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: #{task.metadata[:reason]}")
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 { attempt: Integer }
106
- # :task_rolled_back {}
107
- # :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 }
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: CMDx::Fault raised by execute! with task class, signal, and cleaned backtrace
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.1.1
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.13
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: []