cmdx 1.9.1 → 1.10.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/.cursor/prompts/llms.md +3 -13
- data/CHANGELOG.md +10 -0
- data/LLM.md +429 -376
- data/README.md +1 -1
- data/docs/basics/setup.md +17 -0
- data/docs/callbacks.md +1 -1
- data/docs/getting_started.md +13 -2
- data/docs/retries.md +121 -0
- data/docs/tips_and_tricks.md +2 -1
- data/examples/stoplight_circuit_breaker.md +36 -0
- data/lib/cmdx/configuration.rb +15 -0
- data/lib/cmdx/executor.rb +31 -14
- data/lib/cmdx/version.rb +1 -1
- data/mkdocs.yml +3 -1
- metadata +3 -1
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
Build business logic that’s powerful, predictable, and maintainable.
|
|
8
8
|
|
|
9
|
-
[Documentation](https://drexed.github.io/cmdx) · [Changelog](./CHANGELOG.md) · [Report Bug](https://github.com/drexed/cmdx/issues) · [Request Feature](https://github.com/drexed/cmdx/issues)
|
|
9
|
+
[Documentation](https://drexed.github.io/cmdx) · [Changelog](./CHANGELOG.md) · [Report Bug](https://github.com/drexed/cmdx/issues) · [Request Feature](https://github.com/drexed/cmdx/issues) · [LLM.md](https://raw.githubusercontent.com/drexed/cmdx/refs/heads/main/LLM.md)
|
|
10
10
|
|
|
11
11
|
<img alt="Version" src="https://img.shields.io/gem/v/cmdx">
|
|
12
12
|
<img alt="Build" src="https://github.com/drexed/cmdx/actions/workflows/ci.yml/badge.svg">
|
data/docs/basics/setup.md
CHANGED
|
@@ -24,6 +24,22 @@ end
|
|
|
24
24
|
IncompleteTask.execute #=> raises CMDx::UndefinedMethodError
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
## Rollback
|
|
28
|
+
|
|
29
|
+
Undo any operations linked to the given status, helping to restore a pristine state.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
class ValidateDocument < CMDx::Task
|
|
33
|
+
def work
|
|
34
|
+
# Your logic here...
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rollback
|
|
38
|
+
# Your undo logic...
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
27
43
|
## Inheritance
|
|
28
44
|
|
|
29
45
|
Share configuration across tasks using inheritance:
|
|
@@ -65,3 +81,4 @@ Tasks follow a predictable execution pattern:
|
|
|
65
81
|
| **Execution** | `executing` | `success`/`failed`/`skipped` | `work` method runs |
|
|
66
82
|
| **Completion** | `executed` | `success`/`failed`/`skipped` | Result finalized |
|
|
67
83
|
| **Freezing** | `executed` | `success`/`failed`/`skipped` | Task becomes immutable |
|
|
84
|
+
| **Rollback** | `executed` | `failed`/`skipped` | Work undone |
|
data/docs/callbacks.md
CHANGED
data/docs/getting_started.md
CHANGED
|
@@ -78,6 +78,16 @@ CMDx.configure do |config|
|
|
|
78
78
|
end
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
### Rollback
|
|
82
|
+
|
|
83
|
+
Control when a `rollback` of task execution is called.
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
CMDx.configure do |config|
|
|
87
|
+
config.rollback_on = ["failed"] # String or Array[String]
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
81
91
|
### Backtraces
|
|
82
92
|
|
|
83
93
|
Enable detailed backtraces for non-fault exceptions to improve debugging. Optionally clean up stack traces to remove framework noise.
|
|
@@ -258,7 +268,8 @@ class GenerateInvoice < CMDx::Task
|
|
|
258
268
|
deprecated: true, # Task deprecations
|
|
259
269
|
retries: 3, # Non-fault exception retries
|
|
260
270
|
retry_on: [External::ApiError], # List of exceptions to retry on
|
|
261
|
-
retry_jitter: 1
|
|
271
|
+
retry_jitter: 1, # Space between retry iteration, eg: current retry num + 1
|
|
272
|
+
rollback_on: ["failed", "skipped"], # Rollback on override
|
|
262
273
|
)
|
|
263
274
|
|
|
264
275
|
def work
|
|
@@ -269,7 +280,7 @@ end
|
|
|
269
280
|
|
|
270
281
|
!!! warning "Important"
|
|
271
282
|
|
|
272
|
-
Retries reuse the same context. By default, all `StandardError` exceptions are retried unless you specify `retry_on
|
|
283
|
+
Retries reuse the same context. By default, all `StandardError` exceptions (including faults) are retried unless you specify `retry_on` option for specific matches.
|
|
273
284
|
|
|
274
285
|
### Registrations
|
|
275
286
|
|
data/docs/retries.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Retries
|
|
2
|
+
|
|
3
|
+
CMDx provides automatic retry functionality for tasks that encounter transient failures. This is essential for handling temporary issues like network timeouts, rate limits, or database locks without manual intervention.
|
|
4
|
+
|
|
5
|
+
## Basic Usage
|
|
6
|
+
|
|
7
|
+
Configure retries upto n attempts without any delay.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class FetchExternalData < CMDx::Task
|
|
11
|
+
settings retries: 3
|
|
12
|
+
|
|
13
|
+
def work
|
|
14
|
+
response = HTTParty.get("https://api.example.com/data")
|
|
15
|
+
context.data = response.parsed_response
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
When an exception occurs during execution, CMDx automatically retries up to the configured limit.
|
|
21
|
+
|
|
22
|
+
## Selective Retries
|
|
23
|
+
|
|
24
|
+
By default, CMDx retries on `StandardError` and its subclasses. Narrow this to specific exception types:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
class ProcessPayment < CMDx::Task
|
|
28
|
+
settings retries: 5, retry_on: [Stripe::RateLimitError, Net::ReadTimeout]
|
|
29
|
+
|
|
30
|
+
def work
|
|
31
|
+
# Your logic here...
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
!!! warning "Important"
|
|
37
|
+
|
|
38
|
+
Only exceptions matching the `retry_on` configuration will trigger retries. Uncaught exceptions immediately fail the task.
|
|
39
|
+
|
|
40
|
+
## Retry Jitter
|
|
41
|
+
|
|
42
|
+
Add delays between retry attempts to avoid overwhelming external services or to implement exponential backoff strategies.
|
|
43
|
+
|
|
44
|
+
### Fixed Value
|
|
45
|
+
|
|
46
|
+
Use a numeric value to calculate linear delay (`jitter * current_retry`):
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
class ImportRecords < CMDx::Task
|
|
50
|
+
settings retries: 3, retry_jitter: 0.5
|
|
51
|
+
|
|
52
|
+
def work
|
|
53
|
+
# Delays: 0s, 0.5s (retry 1), 1.0s (retry 2), 1.5s (retry 3)
|
|
54
|
+
context.records = ExternalAPI.fetch_records
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Symbol References
|
|
60
|
+
|
|
61
|
+
Define an instance method for custom delay logic:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
class SyncInventory < CMDx::Task
|
|
65
|
+
settings retries: 5, retry_jitter: :exponential_backoff
|
|
66
|
+
|
|
67
|
+
def work
|
|
68
|
+
context.inventory = InventoryAPI.sync
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def exponential_backoff(current_retry)
|
|
74
|
+
2 ** current_retry # 2s, 4s, 8s, 16s, 32s
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Proc or Lambda
|
|
80
|
+
|
|
81
|
+
Pass a proc for inline delay calculations:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
class PollJobStatus < CMDx::Task
|
|
85
|
+
# Proc
|
|
86
|
+
settings retries: 10, retry_jitter: proc { |retry_count| [retry_count * 0.5, 5.0].min }
|
|
87
|
+
|
|
88
|
+
# Lambda
|
|
89
|
+
settings retries: 10, retry_jitter: ->(retry_count) { [retry_count * 0.5, 5.0].min }
|
|
90
|
+
|
|
91
|
+
def work
|
|
92
|
+
# Delays: 0.5s, 1.0s, 1.5s, 2.0s, 2.5s, 3.0s, 3.5s, 4.0s, 4.5s, 5.0s (capped)
|
|
93
|
+
context.status = JobAPI.check_status(context.job_id)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Class or Module
|
|
99
|
+
|
|
100
|
+
Implement reusable delay logic in dedicated modules and classes:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
class ExponentialBackoff
|
|
104
|
+
def call(task, retry_count)
|
|
105
|
+
base_delay = task.context.base_delay || 1.0
|
|
106
|
+
[base_delay * (2 ** retry_count), 60.0].min
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
class FetchUserProfile < CMDx::Task
|
|
111
|
+
# Class or Module
|
|
112
|
+
settings retries: 4, retry_jitter: ExponentialBackoff
|
|
113
|
+
|
|
114
|
+
# Instance
|
|
115
|
+
settings retries: 4, retry_jitter: ExponentialBackoff.new
|
|
116
|
+
|
|
117
|
+
def work
|
|
118
|
+
# Your logic here...
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
```
|
data/docs/tips_and_tricks.md
CHANGED
|
@@ -145,7 +145,8 @@ class ConfigureCompany < CMDx::Task
|
|
|
145
145
|
end
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
-
##
|
|
148
|
+
## More Examples
|
|
149
149
|
|
|
150
150
|
- [Active Record Query Tagging](https://github.com/drexed/cmdx/blob/main/examples/active_record_query_tagging.md)
|
|
151
151
|
- [Paper Trail Whatdunnit](https://github.com/drexed/cmdx/blob/main/examples/paper_trail_whatdunnit.md)
|
|
152
|
+
- [Stoplight Circuit Breaker](https://github.com/drexed/cmdx/blob/main/examples/stoplight_circuit_breaker.md)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Stoplight Circuit Breaker
|
|
2
|
+
|
|
3
|
+
Integrate circuit breakers to protect external service calls and prevent cascading failures when dependencies are unavailable.
|
|
4
|
+
|
|
5
|
+
<https://github.com/bolshakov/stoplight>
|
|
6
|
+
|
|
7
|
+
### Setup
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# lib/cmdx_stoplight_middleware.rb
|
|
11
|
+
class CmdxStoplightMiddleware
|
|
12
|
+
def self.call(task, **options, &)
|
|
13
|
+
light = Stoplight(options[:name] || task.class.name, **options)
|
|
14
|
+
light.run(&)
|
|
15
|
+
rescue Stoplight::Error::RedLight => e
|
|
16
|
+
task.result.tap { |r| r.fail!("[#{e.class}] #{e.message}", cause: e) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Usage
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
class MyTask < CMDx::Task
|
|
25
|
+
# With default options
|
|
26
|
+
register :middleware, CmdxStoplightMiddleware
|
|
27
|
+
|
|
28
|
+
# With stoplight options
|
|
29
|
+
register :middleware, CmdxStoplightMiddleware, cool_off_time: 10
|
|
30
|
+
|
|
31
|
+
def work
|
|
32
|
+
# Do work...
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
end
|
|
36
|
+
```
|
data/lib/cmdx/configuration.rb
CHANGED
|
@@ -9,6 +9,9 @@ module CMDx
|
|
|
9
9
|
# @rbs DEFAULT_BREAKPOINTS: Array[String]
|
|
10
10
|
DEFAULT_BREAKPOINTS = %w[failed].freeze
|
|
11
11
|
|
|
12
|
+
# @rbs DEFAULT_ROLLPOINTS: Array[String]
|
|
13
|
+
DEFAULT_ROLLPOINTS = %w[failed].freeze
|
|
14
|
+
|
|
12
15
|
# Returns the middleware registry for task execution.
|
|
13
16
|
#
|
|
14
17
|
# @return [MiddlewareRegistry] The middleware registry
|
|
@@ -110,6 +113,16 @@ module CMDx
|
|
|
110
113
|
# @rbs @exception_handler: (Proc | nil)
|
|
111
114
|
attr_accessor :exception_handler
|
|
112
115
|
|
|
116
|
+
# Returns the statuses that trigger a task execution rollback.
|
|
117
|
+
#
|
|
118
|
+
# @return [Array<String>] Array of status names that trigger rollback
|
|
119
|
+
#
|
|
120
|
+
# @example
|
|
121
|
+
# config.rollback_on = ["failed", "skipped"]
|
|
122
|
+
#
|
|
123
|
+
# @rbs @rollback_on: Array[String]
|
|
124
|
+
attr_accessor :rollback_on
|
|
125
|
+
|
|
113
126
|
# Initializes a new Configuration instance with default values.
|
|
114
127
|
#
|
|
115
128
|
# Creates new registry instances for middlewares, callbacks, coercions, and
|
|
@@ -131,6 +144,7 @@ module CMDx
|
|
|
131
144
|
|
|
132
145
|
@task_breakpoints = DEFAULT_BREAKPOINTS
|
|
133
146
|
@workflow_breakpoints = DEFAULT_BREAKPOINTS
|
|
147
|
+
@rollback_on = DEFAULT_ROLLPOINTS
|
|
134
148
|
|
|
135
149
|
@backtrace = false
|
|
136
150
|
@backtrace_cleaner = nil
|
|
@@ -162,6 +176,7 @@ module CMDx
|
|
|
162
176
|
validators: @validators,
|
|
163
177
|
task_breakpoints: @task_breakpoints,
|
|
164
178
|
workflow_breakpoints: @workflow_breakpoints,
|
|
179
|
+
rollback_on: @rollback_on,
|
|
165
180
|
backtrace: @backtrace,
|
|
166
181
|
backtrace_cleaner: @backtrace_cleaner,
|
|
167
182
|
exception_handler: @exception_handler,
|
data/lib/cmdx/executor.rb
CHANGED
|
@@ -118,15 +118,12 @@ module CMDx
|
|
|
118
118
|
#
|
|
119
119
|
# @return [Boolean] Whether execution should halt
|
|
120
120
|
#
|
|
121
|
-
# @example
|
|
122
|
-
# halt_execution?(fault_exception)
|
|
123
|
-
#
|
|
124
121
|
# @rbs (Exception exception) -> bool
|
|
125
122
|
def halt_execution?(exception)
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
statuses = task.class.settings[:breakpoints] || task.class.settings[:task_breakpoints]
|
|
124
|
+
statuses = Array(statuses).map(&:to_s).uniq
|
|
128
125
|
|
|
129
|
-
|
|
126
|
+
statuses.include?(exception.result.status)
|
|
130
127
|
end
|
|
131
128
|
|
|
132
129
|
# Determines if execution should be retried based on retry configuration.
|
|
@@ -135,9 +132,6 @@ module CMDx
|
|
|
135
132
|
#
|
|
136
133
|
# @return [Boolean] Whether execution should be retried
|
|
137
134
|
#
|
|
138
|
-
# @example
|
|
139
|
-
# retry_execution?(standard_error)
|
|
140
|
-
#
|
|
141
135
|
# @rbs (Exception exception) -> bool
|
|
142
136
|
def retry_execution?(exception)
|
|
143
137
|
available_retries = (task.class.settings[:retries] || 0).to_i
|
|
@@ -157,7 +151,18 @@ module CMDx
|
|
|
157
151
|
task.to_h.merge!(reason:, remaining_retries:)
|
|
158
152
|
end
|
|
159
153
|
|
|
160
|
-
jitter = task.class.settings[:retry_jitter]
|
|
154
|
+
jitter = task.class.settings[:retry_jitter]
|
|
155
|
+
jitter =
|
|
156
|
+
if jitter.is_a?(Symbol)
|
|
157
|
+
task.send(jitter, current_retries)
|
|
158
|
+
elsif jitter.is_a?(Proc)
|
|
159
|
+
task.instance_exec(current_retries, &jitter)
|
|
160
|
+
elsif jitter.respond_to?(:call)
|
|
161
|
+
jitter.call(task, current_retries)
|
|
162
|
+
else
|
|
163
|
+
jitter.to_f * current_retries
|
|
164
|
+
end
|
|
165
|
+
|
|
161
166
|
sleep(jitter) if jitter.positive?
|
|
162
167
|
|
|
163
168
|
true
|
|
@@ -169,9 +174,6 @@ module CMDx
|
|
|
169
174
|
#
|
|
170
175
|
# @raise [Exception] The provided exception
|
|
171
176
|
#
|
|
172
|
-
# @example
|
|
173
|
-
# raise_exception(standard_error)
|
|
174
|
-
#
|
|
175
177
|
# @rbs (Exception exception) -> void
|
|
176
178
|
def raise_exception(exception)
|
|
177
179
|
Chain.clear
|
|
@@ -244,7 +246,7 @@ module CMDx
|
|
|
244
246
|
invoke_callbacks(:on_bad) if task.result.bad?
|
|
245
247
|
end
|
|
246
248
|
|
|
247
|
-
# Finalizes execution by freezing the task and
|
|
249
|
+
# Finalizes execution by freezing the task, logging results, and rolling back work.
|
|
248
250
|
#
|
|
249
251
|
# @rbs () -> Result
|
|
250
252
|
def finalize_execution!
|
|
@@ -253,6 +255,8 @@ module CMDx
|
|
|
253
255
|
|
|
254
256
|
freeze_execution!
|
|
255
257
|
clear_chain!
|
|
258
|
+
|
|
259
|
+
rollback_execution!
|
|
256
260
|
end
|
|
257
261
|
|
|
258
262
|
# Logs the execution result at the configured log level.
|
|
@@ -309,5 +313,18 @@ module CMDx
|
|
|
309
313
|
Chain.clear
|
|
310
314
|
end
|
|
311
315
|
|
|
316
|
+
# Rolls back the work of a task.
|
|
317
|
+
#
|
|
318
|
+
# @rbs () -> void
|
|
319
|
+
def rollback_execution!
|
|
320
|
+
return unless task.respond_to?(:rollback)
|
|
321
|
+
|
|
322
|
+
statuses = task.class.settings[:rollback_on]
|
|
323
|
+
statuses = Array(statuses).map(&:to_s).uniq
|
|
324
|
+
return unless statuses.include?(task.result.status)
|
|
325
|
+
|
|
326
|
+
task.rollback
|
|
327
|
+
end
|
|
328
|
+
|
|
312
329
|
end
|
|
313
330
|
end
|
data/lib/cmdx/version.rb
CHANGED
data/mkdocs.yml
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
site_name: CMDx
|
|
4
4
|
site_url: https://drexed.github.io/cmdx/
|
|
5
|
-
site_description: Build business logic that's powerful, predictable, and
|
|
5
|
+
site_description: Build business logic that's powerful, predictable, and maintainable.
|
|
6
6
|
site_author: drexed
|
|
7
7
|
repo_name: drexed/cmdx
|
|
8
8
|
repo_url: https://github.com/drexed/cmdx
|
|
@@ -108,9 +108,11 @@ nav:
|
|
|
108
108
|
- Middlewares: middlewares.md
|
|
109
109
|
- Logging: logging.md
|
|
110
110
|
- Internationalization: internationalization.md
|
|
111
|
+
- Retries: retries.md
|
|
111
112
|
- Deprecation: deprecation.md
|
|
112
113
|
- Workflows: workflows.md
|
|
113
114
|
- Tips and Tricks: tips_and_tricks.md
|
|
115
|
+
- LLM.md: https://raw.githubusercontent.com/drexed/cmdx/refs/heads/main/LLM.md
|
|
114
116
|
|
|
115
117
|
extra:
|
|
116
118
|
generator: false
|
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: 1.
|
|
4
|
+
version: 1.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Juan Gomez
|
|
@@ -240,11 +240,13 @@ files:
|
|
|
240
240
|
- docs/outcomes/result.md
|
|
241
241
|
- docs/outcomes/states.md
|
|
242
242
|
- docs/outcomes/statuses.md
|
|
243
|
+
- docs/retries.md
|
|
243
244
|
- docs/stylesheets/extra.css
|
|
244
245
|
- docs/tips_and_tricks.md
|
|
245
246
|
- docs/workflows.md
|
|
246
247
|
- examples/active_record_query_tagging.md
|
|
247
248
|
- examples/paper_trail_whatdunnit.md
|
|
249
|
+
- examples/stoplight_circuit_breaker.md
|
|
248
250
|
- lib/cmdx.rb
|
|
249
251
|
- lib/cmdx/.DS_Store
|
|
250
252
|
- lib/cmdx/attribute.rb
|