ruby_reactor 0.8.2 → 0.8.4
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/.claude/skills/speckit-review/SKILL.md +324 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions.yml +10 -0
- data/.specify/feature.json +1 -1
- data/.specify/workflows/speckit/workflow.yml +13 -1
- data/.specify/workflows/workflow-registry.json +2 -2
- data/CHANGELOG.md +94 -0
- data/CLAUDE.md +2 -2
- data/README.md +128 -93
- data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
- data/lib/ruby_reactor/context.rb +26 -0
- data/lib/ruby_reactor/context_serializer.rb +4 -2
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
- data/lib/ruby_reactor/dsl/lockable.rb +76 -21
- data/lib/ruby_reactor/dsl/reactor.rb +8 -11
- data/lib/ruby_reactor/dsl/retryable.rb +45 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +150 -13
- data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
- data/lib/ruby_reactor/error/execution_parked.rb +16 -0
- data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
- data/lib/ruby_reactor/executor/result_handler.rb +34 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
- data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
- data/lib/ruby_reactor/executor/step_executor.rb +115 -11
- data/lib/ruby_reactor/executor.rb +90 -20
- data/lib/ruby_reactor/map/element_executor.rb +24 -2
- data/lib/ruby_reactor/map/helpers.rb +35 -11
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
- data/lib/ruby_reactor/open_telemetry.rb +61 -24
- data/lib/ruby_reactor/retry_context.rb +31 -2
- data/lib/ruby_reactor/rspec/helpers.rb +15 -0
- data/lib/ruby_reactor/rspec/matchers.rb +92 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +9 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
- data/lib/ruby_reactor/step/compose_step.rb +22 -7
- data/lib/ruby_reactor/step/input_contract.rb +5 -0
- data/lib/ruby_reactor/step/inputs.rb +57 -0
- data/lib/ruby_reactor/step/map_step.rb +32 -22
- data/lib/ruby_reactor/step.rb +59 -11
- data/lib/ruby_reactor/step_sweeper.rb +29 -1
- data/lib/ruby_reactor/step_worker.rb +260 -37
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +72 -7
- data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
- data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +56 -30
- data/lib/ruby_reactor.rb +30 -6
- data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
- data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
- data/specs/006-step-retry-declarations/data-model.md +58 -0
- data/specs/006-step-retry-declarations/plan.md +187 -0
- data/specs/006-step-retry-declarations/quickstart.md +80 -0
- data/specs/006-step-retry-declarations/research.md +194 -0
- data/specs/006-step-retry-declarations/spec.md +453 -0
- data/specs/006-step-retry-declarations/tasks.md +382 -0
- data/specs/future_improvements.md +250 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +20 -28
- data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
- data/specs/002-step-input-contracts/data-model.md +0 -115
- data/specs/002-step-input-contracts/plan.md +0 -165
- data/specs/002-step-input-contracts/quickstart.md +0 -170
- data/specs/002-step-input-contracts/research.md +0 -233
- data/specs/002-step-input-contracts/spec.md +0 -359
- data/specs/002-step-input-contracts/tasks.md +0 -367
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
- data/specs/004-inheritable-step-class/data-model.md +0 -116
- data/specs/004-inheritable-step-class/plan.md +0 -174
- data/specs/004-inheritable-step-class/quickstart.md +0 -112
- data/specs/004-inheritable-step-class/research.md +0 -308
- data/specs/004-inheritable-step-class/spec.md +0 -316
- data/specs/004-inheritable-step-class/tasks.md +0 -258
- data/specs/active_job.md +0 -259
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
- data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
- data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
- data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
- data/specs/deferred-003-step-lock-declarations/research.md +0 -196
- data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
- data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
- data/specs/possible_feature.md +0 -22
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>ui</title>
|
|
8
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
+
<script type="module" crossorigin src="./assets/index-CeZU-ESu.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="./assets/index-BQvIWPdx.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
data/lib/ruby_reactor/worker.rb
CHANGED
|
@@ -10,6 +10,46 @@ module RubyReactor
|
|
|
10
10
|
module Worker
|
|
11
11
|
TERMINAL_STATUSES = %w[completed failed cancelled skipped].freeze
|
|
12
12
|
|
|
13
|
+
# Use the error's `retry_after_seconds` hint when available
|
|
14
|
+
# (RateLimit::ExceededError carries the time until the bucket rolls);
|
|
15
|
+
# otherwise fall back to the configured base + jitter for lock/semaphore
|
|
16
|
+
# contention which has no precise hint. Module-level (not just an
|
|
17
|
+
# instance method) so `StepCoordination::Contended` — which wraps a
|
|
18
|
+
# contention error but is not itself a snooze-worthy reactor-level
|
|
19
|
+
# error — can reuse the identical hint logic from the async_step worker
|
|
20
|
+
# (T036) without including this whole module.
|
|
21
|
+
#
|
|
22
|
+
# OrderedLock::WaitError is deliberately excluded from the hint path: its
|
|
23
|
+
# `retry_after_seconds` is the poison-pill window (the upper bound before
|
|
24
|
+
# a *dead* blocker is force-advanced), NOT how long the *live* blocker
|
|
25
|
+
# will take — which is usually milliseconds. Snoozing for the full window
|
|
26
|
+
# would make every out-of-order nonce sleep up to poison_pill_timeout even
|
|
27
|
+
# though its blocker finishes immediately, collapsing throughput. Re-poll
|
|
28
|
+
# at the base delay instead; poison auto-advance still clears a genuinely
|
|
29
|
+
# dead blocker on a later gate.
|
|
30
|
+
def self.snooze_delay(config, error)
|
|
31
|
+
jitter = config.lock_snooze_jitter.to_f
|
|
32
|
+
jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
|
|
33
|
+
|
|
34
|
+
if hinted_retry?(error)
|
|
35
|
+
[error.retry_after_seconds.to_f, 0.1].max + jitter_amount
|
|
36
|
+
else
|
|
37
|
+
config.lock_snooze_base_delay.to_f + jitter_amount
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.hinted_retry?(error)
|
|
42
|
+
# `StepCoordination::Contended` wraps the WaitError as `.original` —
|
|
43
|
+
# unwrap so the same exclusion applies whether the caller is the
|
|
44
|
+
# reactor-level ordered lock (raises WaitError directly) or a step's
|
|
45
|
+
# (raises Contended, whose OWN `retry_after_seconds` just forwards the
|
|
46
|
+
# wrapped error's hint unchanged).
|
|
47
|
+
original = error.respond_to?(:original) ? error.original : error
|
|
48
|
+
return false if original.is_a?(RubyReactor::OrderedLock::WaitError)
|
|
49
|
+
|
|
50
|
+
error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
|
|
51
|
+
end
|
|
52
|
+
|
|
13
53
|
# Last line of observability when a job burns its whole retry budget on an
|
|
14
54
|
# infrastructure failure and the backend then discards it (Sidekiq runs
|
|
15
55
|
# with `dead: false`): without this, the context would stay "running"
|
|
@@ -93,8 +133,11 @@ module RubyReactor
|
|
|
93
133
|
RubyReactor::Semaphore::AcquisitionError,
|
|
94
134
|
RubyReactor::RateLimit::ExceededError,
|
|
95
135
|
RubyReactor::OrderedLock::WaitError,
|
|
96
|
-
RubyReactor::Error::
|
|
97
|
-
# Snooze on expected concurrency, rate, or ordering contention
|
|
136
|
+
RubyReactor::Error::ExecutionParked => e
|
|
137
|
+
# Snooze on expected concurrency, rate, or ordering contention, or on
|
|
138
|
+
# a park signal (a step's contention, or an awaited background result)
|
|
139
|
+
# — raised at any nesting depth, after every executor on the stack has
|
|
140
|
+
# parked its own holds and saved.
|
|
98
141
|
# OrderedLock::WaitError carries a poison-pill-derived retry hint,
|
|
99
142
|
# consumed by compute_snooze_delay below. We avoid the framework's native
|
|
100
143
|
# retry path so this doesn't burn the job's retry budget or appear
|
|
@@ -147,12 +190,14 @@ module RubyReactor
|
|
|
147
190
|
# duplicate of the *same* execution may wait arbitrarily long for the
|
|
148
191
|
# live original to finish (e.g. a sweeper re-enqueue racing a slow but
|
|
149
192
|
# alive worker). Capping it would fail a legitimately-waiting duplicate.
|
|
150
|
-
# A
|
|
151
|
-
# `async_park_timeout
|
|
152
|
-
#
|
|
193
|
+
# A park signal is likewise uncapped HERE, bounded where it is raised:
|
|
194
|
+
# an async wait by `async_park_timeout` against `dispatched_at`, a
|
|
195
|
+
# step's contention by `lock_snooze_max_attempts` on that step's own
|
|
196
|
+
# contention counter (`StepExecutor#handle_contention`) — counting
|
|
197
|
+
# snoozes too would double-bound either with the wrong unit.
|
|
153
198
|
capped = !(error.is_a?(RubyReactor::OrderedLock::WaitError) ||
|
|
154
199
|
error.is_a?(RubyReactor::Lock::ContextLockContention) ||
|
|
155
|
-
error.is_a?(RubyReactor::Error::
|
|
200
|
+
error.is_a?(RubyReactor::Error::ExecutionParked))
|
|
156
201
|
|
|
157
202
|
if capped && max != :infinity && snooze_count >= max
|
|
158
203
|
escalate_snooze(context, snooze_count, error)
|
|
@@ -165,34 +210,15 @@ module RubyReactor
|
|
|
165
210
|
self.class.perform_in(delay, context_id, reactor_class_name, snooze_count + 1)
|
|
166
211
|
end
|
|
167
212
|
|
|
168
|
-
#
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
# contention which has no precise hint.
|
|
172
|
-
#
|
|
173
|
-
# OrderedLock::WaitError is deliberately excluded from the hint path: its
|
|
174
|
-
# `retry_after_seconds` is the poison-pill window (the upper bound before
|
|
175
|
-
# a *dead* blocker is force-advanced), NOT how long the *live* blocker
|
|
176
|
-
# will take — which is usually milliseconds. Snoozing for the full window
|
|
177
|
-
# would make every out-of-order nonce sleep up to poison_pill_timeout even
|
|
178
|
-
# though its blocker finishes immediately, collapsing throughput. Re-poll
|
|
179
|
-
# at the base delay instead; poison auto-advance still clears a genuinely
|
|
180
|
-
# dead blocker on a later gate.
|
|
213
|
+
# Instance methods delegate to the module functions above — worker
|
|
214
|
+
# behavior is unchanged, just relocated so other callers (StepWorker,
|
|
215
|
+
# T036) can reuse the same logic without a Worker instance.
|
|
181
216
|
def compute_snooze_delay(config, error)
|
|
182
|
-
|
|
183
|
-
jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
|
|
184
|
-
|
|
185
|
-
if hinted_retry?(error)
|
|
186
|
-
[error.retry_after_seconds.to_f, 0.1].max + jitter_amount
|
|
187
|
-
else
|
|
188
|
-
config.lock_snooze_base_delay.to_f + jitter_amount
|
|
189
|
-
end
|
|
217
|
+
Worker.snooze_delay(config, error)
|
|
190
218
|
end
|
|
191
219
|
|
|
192
220
|
def hinted_retry?(error)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
|
|
221
|
+
Worker.hinted_retry?(error)
|
|
196
222
|
end
|
|
197
223
|
|
|
198
224
|
def escalate_snooze(context, snooze_count, error)
|
data/lib/ruby_reactor.rb
CHANGED
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
require_relative "ruby_reactor/registry"
|
|
8
8
|
require_relative "ruby_reactor/utils/code_extractor"
|
|
9
9
|
require_relative "ruby_reactor/dsl/lockable" # Add this
|
|
10
|
+
require_relative "ruby_reactor/dsl/retryable"
|
|
10
11
|
require_relative "ruby_reactor/lock"
|
|
11
12
|
require_relative "ruby_reactor/ordered_lock"
|
|
12
13
|
require_relative "ruby_reactor/semaphore"
|
|
@@ -133,14 +134,14 @@ module RubyReactor
|
|
|
133
134
|
|
|
134
135
|
class Failure
|
|
135
136
|
attr_reader :error, :retryable, :step_name, :inputs, :backtrace, :reactor_name, :step_arguments, :exception_class,
|
|
136
|
-
:file_path, :line_number, :code_snippet, :validation_errors
|
|
137
|
+
:file_path, :line_number, :code_snippet, :validation_errors, :rollback_failures
|
|
137
138
|
|
|
138
|
-
# rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
139
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
|
|
139
140
|
def initialize(error, retryable: nil, step_name: nil, inputs: {}, backtrace: nil, redact_inputs: [],
|
|
140
141
|
reactor_name: nil, step_arguments: {}, exception_class: nil,
|
|
141
142
|
file_path: nil, line_number: nil, code_snippet: nil, invalid_payload: false, validation_errors: nil,
|
|
142
|
-
**opts)
|
|
143
|
-
# rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
143
|
+
rollback_failures: nil, **opts)
|
|
144
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
|
|
144
145
|
retryable = opts[:retry] if opts.key?(:retry) # `retry:` wins over `retryable:` when both are given
|
|
145
146
|
@error = error
|
|
146
147
|
|
|
@@ -159,6 +160,7 @@ module RubyReactor
|
|
|
159
160
|
line_number ||= attributes[:line_number]
|
|
160
161
|
code_snippet ||= attributes[:code_snippet]
|
|
161
162
|
validation_errors ||= attributes[:validation_errors]
|
|
163
|
+
rollback_failures ||= attributes[:rollback_failures]
|
|
162
164
|
end
|
|
163
165
|
|
|
164
166
|
@retryable = if retryable.nil?
|
|
@@ -179,6 +181,9 @@ module RubyReactor
|
|
|
179
181
|
@code_snippet = code_snippet
|
|
180
182
|
@invalid_payload = invalid_payload
|
|
181
183
|
@validation_errors = validation_errors
|
|
184
|
+
# Every undo/compensation that did not complete during this failure's
|
|
185
|
+
# rollback (005 FR-004): `{ step:, kind:, key:, reason:, message: }`.
|
|
186
|
+
@rollback_failures = normalize_rollback_failures(rollback_failures)
|
|
182
187
|
end
|
|
183
188
|
|
|
184
189
|
def success?
|
|
@@ -236,6 +241,7 @@ module RubyReactor
|
|
|
236
241
|
line_number: @line_number,
|
|
237
242
|
code_snippet: @code_snippet,
|
|
238
243
|
validation_errors: @validation_errors,
|
|
244
|
+
rollback_failures: @rollback_failures,
|
|
239
245
|
backtrace: @backtrace
|
|
240
246
|
}
|
|
241
247
|
end
|
|
@@ -334,9 +340,26 @@ module RubyReactor
|
|
|
334
340
|
file_path: err[:file_path],
|
|
335
341
|
line_number: err[:line_number],
|
|
336
342
|
code_snippet: err[:code_snippet],
|
|
337
|
-
validation_errors: err[:validation_errors]
|
|
343
|
+
validation_errors: err[:validation_errors],
|
|
344
|
+
rollback_failures: err[:rollback_failures]
|
|
338
345
|
}
|
|
339
346
|
end
|
|
347
|
+
|
|
348
|
+
ROLLBACK_FAILURE_SYMBOLS = %i[step kind reason].freeze
|
|
349
|
+
private_constant :ROLLBACK_FAILURE_SYMBOLS
|
|
350
|
+
|
|
351
|
+
# A stored failure comes back with string keys (and, through plain JSON,
|
|
352
|
+
# string values); `step`, `kind` and `reason` are Symbols on a live one.
|
|
353
|
+
def normalize_rollback_failures(entries)
|
|
354
|
+
return [] unless entries.is_a?(Array)
|
|
355
|
+
|
|
356
|
+
entries.map do |entry|
|
|
357
|
+
entry.to_h do |k, v|
|
|
358
|
+
key = k.to_sym
|
|
359
|
+
[key, ROLLBACK_FAILURE_SYMBOLS.include?(key) && v ? v.to_sym : v]
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
340
363
|
end
|
|
341
364
|
|
|
342
365
|
# Sentinel returned when a step's work is handed off to a worker job and is
|
|
@@ -365,8 +388,9 @@ module RubyReactor
|
|
|
365
388
|
end
|
|
366
389
|
|
|
367
390
|
# Global helper methods
|
|
391
|
+
# A step returning its own inputs (`Success(inputs)`) stores the Hash.
|
|
368
392
|
def self.Success(value = nil)
|
|
369
|
-
Success.new(value)
|
|
393
|
+
Success.new(value.is_a?(Step::Inputs) ? value.to_h : value)
|
|
370
394
|
end
|
|
371
395
|
|
|
372
396
|
def self.Failure(error, **kwargs)
|
data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
# Specification Quality Checklist:
|
|
1
|
+
# Specification Quality Checklist: Step-Scoped Retry Declarations
|
|
2
2
|
|
|
3
3
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
4
|
-
**Created**: 2026-09-
|
|
4
|
+
**Created**: 2026-09-25
|
|
5
5
|
**Feature**: [spec.md](../spec.md)
|
|
6
6
|
|
|
7
7
|
## Content Quality
|
|
@@ -31,10 +31,11 @@
|
|
|
31
31
|
|
|
32
32
|
## Notes
|
|
33
33
|
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
- The product is a library, so its user-facing DSL words (`retries`, `compose`,
|
|
35
|
+
`async_reactor`, `map`) are the user vocabulary, not implementation detail. Internal classes,
|
|
36
|
+
files, and storage are not named.
|
|
37
|
+
- Resolved 2026-09-25: a direct call to a step class runs once. Only the reactor coordinates
|
|
38
|
+
retries (FR-013, Clarifications).
|
|
39
|
+
- Removing reactor-wide defaults was added from the user's follow-up messages. It is US1 and is
|
|
40
|
+
delivered first as a standalone change (FR-005 to FR-007, FR-017 delivery order, FR-020
|
|
41
|
+
migration note); step class work (US2 onward) starts after it.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Contract: Retry DSL Surface
|
|
2
|
+
|
|
3
|
+
The public API after this feature. Anything not listed here keeps its current behavior.
|
|
4
|
+
|
|
5
|
+
## 1. Removed: `retry_defaults` (Phase A)
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class PaymentReactor < RubyReactor::Reactor
|
|
9
|
+
retry_defaults max_attempts: 3 # => raises at class definition
|
|
10
|
+
end
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- Raises `RubyReactor::Error::DeprecatedDslError` (a `ValidationError`) when the class body
|
|
14
|
+
runs, whatever the arguments.
|
|
15
|
+
- Message (shape): ``"`retry_defaults` has been removed from PaymentReactor: reactor-wide
|
|
16
|
+
defaults silently applied only to steps declared after them. Declare `retries` on each step
|
|
17
|
+
class (or step block) that should retry; a step with no `retries` runs once."``
|
|
18
|
+
- There is no reader. `PaymentReactor.retry_defaults` with no arguments raises the same error.
|
|
19
|
+
|
|
20
|
+
## 2. `retries` on a step class (Phase B)
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
class ChargeCard < RubyReactor::Step
|
|
24
|
+
with_lock { |i| "card:#{i[:card_token]}" }
|
|
25
|
+
input :card_token, :string
|
|
26
|
+
retries max_attempts: 3, backoff: :exponential, base_delay: 5 # all keywords optional
|
|
27
|
+
|
|
28
|
+
def run = Success(PaymentService.charge(inputs[:card_token]))
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Call | Result |
|
|
33
|
+
|----------------------------------------|------------------------------------------------------------|
|
|
34
|
+
| `retries` | `{max_attempts: 3, backoff: :exponential, base_delay: 1}` |
|
|
35
|
+
| `retries max_attempts: 5` | `{max_attempts: 5, backoff: :exponential, base_delay: 1}` |
|
|
36
|
+
| `retries max_attempts: 1` | explicit "never retry" |
|
|
37
|
+
| `retries max_attempts: 0` / `2.5` / `"3"` | `ArgumentError` naming `ChargeCard`, `max_attempts`, value |
|
|
38
|
+
| `retries backoff: :jitter` | `ArgumentError` naming `ChargeCard`, `backoff`, value |
|
|
39
|
+
| `retries base_delay: -1` | `ArgumentError` naming `ChargeCard`, `base_delay`, value |
|
|
40
|
+
| `ChargeCard.retry_config` | the hash above, or `nil` if never declared |
|
|
41
|
+
|
|
42
|
+
Inheritance: `class ChargeCardEU < ChargeCard; end` returns ChargeCard's policy from
|
|
43
|
+
`retry_config`. Redeclaring in `ChargeCardEU` changes only `ChargeCardEU`.
|
|
44
|
+
|
|
45
|
+
## 3. `retries` in a reactor step block (unchanged vocabulary)
|
|
46
|
+
|
|
47
|
+
The same module provides it, so the table above also applies inside `step`, `async_step`,
|
|
48
|
+
`compose` and `async_reactor` blocks (the error names the step, e.g. `charge_card`).
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
step :charge_card do # inline: valid, as today
|
|
52
|
+
retries max_attempts: 3, backoff: :exponential, base_delay: 5
|
|
53
|
+
run { |args, _ctx| PaymentService.charge(args[:card_token]) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
step :charge_card, LegacyCharge do # class without its own policy: valid, as today
|
|
57
|
+
retries max_attempts: 3
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
step :charge_card, ChargeCard do # ChargeCard declares retries: REFUSED
|
|
61
|
+
retries max_attempts: 5
|
|
62
|
+
end
|
|
63
|
+
# => Error::ValidationError at reactor definition:
|
|
64
|
+
# "PaymentReactor step :charge_card declares `retries` inline, but ChargeCard declares it
|
|
65
|
+
# too. Keep ONE: drop the inline declaration to use ChargeCard's, or remove it from
|
|
66
|
+
# ChargeCard. To vary the policy per workflow, subclass ChargeCard."
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 4. Effective policy introspection
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
PaymentReactor.steps[:charge_card].retry_config # => {max_attempts: 3, backoff: …, base_delay: …}
|
|
73
|
+
PaymentReactor.steps[:charge_card].retry_source # => :step_class | :step_block | :none
|
|
74
|
+
PaymentReactor.steps[:charge_card].retryable? # => max_attempts > 1
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 5. Direct invocation
|
|
78
|
+
|
|
79
|
+
`ChargeCard.run(args)` / `.call(args)` runs **once**, whatever `retries` says, and returns
|
|
80
|
+
its `Failure` to the caller. Only a reactor coordinates retries. (Locks, by contrast, are
|
|
81
|
+
taken on a direct call.)
|
|
82
|
+
|
|
83
|
+
## 6. Runtime (unchanged)
|
|
84
|
+
|
|
85
|
+
Under a class policy, retry behavior is the same as under a step-block policy with the same
|
|
86
|
+
values: which failures are retried (`Failure#retryable?`, `fail!(retry: false)`, input
|
|
87
|
+
contract failures), attempt counting across requeues, in-process sleep versus background
|
|
88
|
+
`perform_in`, `MaxRetriesExhaustedFailure`, the `retry_attempt` middleware event, and
|
|
89
|
+
compensation after the last attempt.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Data Model: Step-Scoped Retry Declarations
|
|
2
|
+
|
|
3
|
+
No persisted data changes. The per-execution attempt record (`RetryContext#step_attempts`)
|
|
4
|
+
and its serialization stay the same. Everything below is class-level configuration held in
|
|
5
|
+
memory.
|
|
6
|
+
|
|
7
|
+
## Retry Policy
|
|
8
|
+
|
|
9
|
+
A frozen-shape hash. It is the same shape every consumer reads today.
|
|
10
|
+
|
|
11
|
+
| Key | Type | Default | Validation (declaration time, `ArgumentError`) |
|
|
12
|
+
|----------------|-----------------------------------|----------------|------------------------------------------------|
|
|
13
|
+
| `max_attempts` | Integer | `3` (declared) | `Integer`, `>= 1` |
|
|
14
|
+
| `backoff` | Symbol | `:exponential` | one of `:exponential`, `:linear`, `:fixed` |
|
|
15
|
+
| `base_delay` | Numeric (incl. AS::Duration) | `1` | `Numeric`, `>= 0` |
|
|
16
|
+
|
|
17
|
+
`NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }` is the effective
|
|
18
|
+
policy when nothing is declared. `retryable?` is false for it.
|
|
19
|
+
|
|
20
|
+
## Declaration holders
|
|
21
|
+
|
|
22
|
+
| Holder | How it declares | Reader | Inheritance |
|
|
23
|
+
|-------------------------------------|--------------------------------------|--------------------------|----------------------------------------|
|
|
24
|
+
| Step class (`< RubyReactor::Step`) | `retries …` in class body | `.retry_config` (or nil) | copied to subclass on `inherited` |
|
|
25
|
+
| `StepBuilder` (step / async_step) | `retries …` in the reactor step block | `#retry_config` (or nil) | n/a |
|
|
26
|
+
| `ComposeBuilder`, `AsyncReactorBuilder` | `retries …` in the block | `#retry_config` (or nil) | n/a |
|
|
27
|
+
| Reactor class | **none**: `retry_defaults` raises `DeprecatedDslError` | n/a | n/a |
|
|
28
|
+
|
|
29
|
+
All four `retries` entry points come from one module (`Dsl::Retryable`), so vocabulary,
|
|
30
|
+
defaults and validation are identical.
|
|
31
|
+
|
|
32
|
+
## Effective policy (`StepConfig`)
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
StepConfig#retry_config
|
|
36
|
+
= own (from builder) -> source :step_block
|
|
37
|
+
| impl.retry_config (impl responds, non-nil) -> source :step_class
|
|
38
|
+
| NO_RETRIES -> source :none
|
|
39
|
+
|
|
40
|
+
StepConfig#retry_source ∈ { :step_block, :step_class, :none }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Resolved lazily on each read, like `lock_config`. The reactor class is never consulted.
|
|
44
|
+
|
|
45
|
+
## Definition-time rules
|
|
46
|
+
|
|
47
|
+
| Rule | Where | Error |
|
|
48
|
+
|--------------------------------------------------------------------|-------------------------------|------------------------------|
|
|
49
|
+
| Invalid policy value | `Retryable#retries` | `ArgumentError` |
|
|
50
|
+
| `retries` in step block **and** on `impl` (own or inherited) | `StepBuilder#build` | `Error::ValidationError` |
|
|
51
|
+
| `retry_defaults` called on a reactor | `Dsl::Reactor.retry_defaults` | `Error::DeprecatedDslError` |
|
|
52
|
+
|
|
53
|
+
## Runtime (unchanged)
|
|
54
|
+
|
|
55
|
+
The attempt record is `RetryContext#step_attempts[step_name]`, kept through serialization and
|
|
56
|
+
requeue. `RetryManager` (in-process and background requeue) and `StepWorker` (`async_step`)
|
|
57
|
+
compare it with `step_config.retry_config[:max_attempts]`. A direct `Step.run` never
|
|
58
|
+
consults a policy (`StepCoordination#retry_pending?` is false for direct calls).
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Implementation Plan: Step-Scoped Retry Declarations
|
|
2
|
+
|
|
3
|
+
**Branch**: `retry_confs_in_steps` | **Date**: 2026-09-25 | **Spec**: [spec.md](spec.md)
|
|
4
|
+
|
|
5
|
+
**Input**: Feature specification from `specs/006-step-retry-declarations/spec.md`
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
|
|
9
|
+
Two phases, delivered in this order (spec FR-017):
|
|
10
|
+
|
|
11
|
+
- **Phase A: remove `retry_defaults`.** `retry_defaults` becomes a stub that raises
|
|
12
|
+
`DeprecatedDslError`. Every read of it is deleted: the three builder fallbacks, the
|
|
13
|
+
`RetryManager` fallback, the `TestSubject` copies, and the `included` ivar. After this,
|
|
14
|
+
`StepConfig`'s policy can come only from its own block. Ships as a standalone `feat!`
|
|
15
|
+
commit with the suite green.
|
|
16
|
+
- **Phase B: `retries` on step classes.** A new `Dsl::Retryable` module, modeled on
|
|
17
|
+
`Dsl::Lockable::ClassMethods`, provides `retries` with validation, a `retry_config` reader,
|
|
18
|
+
and `inherited` propagation. `RubyReactor::Step` extends it; the step, compose and
|
|
19
|
+
async-reactor builders include it, which replaces their duplicated `retries` methods.
|
|
20
|
+
`StepConfig#retry_config` resolves lazily in the order own declaration, then
|
|
21
|
+
`impl.retry_config`, then `NO_RETRIES`, the same way `lock_config` does. Declaring the policy
|
|
22
|
+
both on the class and in the step block is refused, like locks.
|
|
23
|
+
|
|
24
|
+
Runtime retry code (`RetryManager`, `StepWorker`, `RetryContext`) is unchanged apart from
|
|
25
|
+
deleting the fallback. Direct `Step.run` already never retries (research R7).
|
|
26
|
+
|
|
27
|
+
## Technical Context
|
|
28
|
+
|
|
29
|
+
**Language/Version**: Ruby >= 3.0
|
|
30
|
+
|
|
31
|
+
**Primary Dependencies**: existing only: dry-validation, Sidekiq/ActiveJob adapters. No new
|
|
32
|
+
gems.
|
|
33
|
+
|
|
34
|
+
**Storage**: Redis (unchanged; no new keys, no serialization change)
|
|
35
|
+
|
|
36
|
+
**Testing**: RSpec against real Redis (`bundle exec rspec`); `demo_app` RSpec with the shipped
|
|
37
|
+
`RubyReactor::RSpec` surface; RuboCop
|
|
38
|
+
|
|
39
|
+
**Target Platform**: Ruby gem (MRI), sync and background (Sidekiq/ActiveJob) execution
|
|
40
|
+
|
|
41
|
+
**Project Type**: library (gem) + `demo_app` Rails integration example
|
|
42
|
+
|
|
43
|
+
**Performance Goals**: no runtime cost beyond one extra `||` per `retry_config` read
|
|
44
|
+
|
|
45
|
+
**Constraints**: public API change. The removal is breaking (`feat!`, migration note). Phase B
|
|
46
|
+
is additive. Behavior for reactors that never used `retry_defaults` must not change.
|
|
47
|
+
|
|
48
|
+
**Scale/Scope**: about 8 lib files touched, 1 new module; ~4 spec files migrated; 1 new spec
|
|
49
|
+
file; 1 demo reactor + rake task + spec; ~10 docs files
|
|
50
|
+
|
|
51
|
+
## Constitution Check
|
|
52
|
+
|
|
53
|
+
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
|
54
|
+
|
|
55
|
+
| Principle | Status | Notes |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| I. Gem-first | ✅ | All code in `lib/`, reachable via `require "ruby_reactor"`; no host coupling. |
|
|
58
|
+
| II. Saga integrity | ✅ | Compensation after the last attempt is unchanged; the new specs cover exhaust-then-compensate on class steps. |
|
|
59
|
+
| III. Test-first, real infra | ✅ | Each phase starts with failing specs; background paths drained against real Redis; no Redis mocking. |
|
|
60
|
+
| IV. Observability | ✅ | `retry_attempt` events and `MaxRetriesExhaustedFailure` unchanged; `StepConfig#retry_source` adds introspection. |
|
|
61
|
+
| V. Simplicity & SemVer | ✅ | One module replaces 3 duplicated methods (net deletion); no policy object (R3). Removal ships as `feat!` with a migration note (R9). |
|
|
62
|
+
| VI. Demo-app proof | ✅ | `StepRetryDemoReactor` + `demo:step_retry` + spec using only the shipped matchers (`have_retried_step`, `be_failure`, `be_success`). |
|
|
63
|
+
| Workflow: class-based steps in docs/examples | ✅ | Docs lead with the step class form. |
|
|
64
|
+
|
|
65
|
+
- [x] Documentation impact identified: which `README.md` sections and which file(s) under
|
|
66
|
+
`./documentation` this feature will require updating (Constitution Development Workflow;
|
|
67
|
+
carried into tasks.md as a required task):
|
|
68
|
+
- **Phase A**: `documentation/retry_configuration.md` (delete "Reactor-Level Defaults", add
|
|
69
|
+
a "Migrating from `retry_defaults`" section), `documentation/core_concepts.md` (≈360,
|
|
70
|
+
remove "Uses reactor defaults"), `documentation/background_and_async.md` (≈482-530),
|
|
71
|
+
`documentation/examples/{inventory_management,order_processing,payment_processing}.md`,
|
|
72
|
+
the `demo_app/documentation/` copies of the same, README line 1486 ("reactor or step
|
|
73
|
+
level"), and `llms*.txt` if they mention it.
|
|
74
|
+
- **Phase B**: `documentation/retry_configuration.md` (step class form first; precedence;
|
|
75
|
+
conflict rule; subclassing to vary per workflow; direct call runs once, unlike locks),
|
|
76
|
+
`documentation/core_concepts.md` retry section, the README Features bullet (line 24),
|
|
77
|
+
and the README Retry Configuration blurb.
|
|
78
|
+
|
|
79
|
+
**Post-design re-check**: ✅ no violations. Complexity Tracking is empty.
|
|
80
|
+
|
|
81
|
+
## Project Structure
|
|
82
|
+
|
|
83
|
+
### Documentation (this feature)
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
specs/006-step-retry-declarations/
|
|
87
|
+
├── spec.md
|
|
88
|
+
├── plan.md # this file
|
|
89
|
+
├── research.md # Phase 0
|
|
90
|
+
├── data-model.md # Phase 1
|
|
91
|
+
├── quickstart.md # Phase 1
|
|
92
|
+
├── contracts/
|
|
93
|
+
│ └── dsl-surface.md # Phase 1
|
|
94
|
+
├── checklists/
|
|
95
|
+
│ └── requirements.md
|
|
96
|
+
└── tasks.md # /speckit-tasks
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Source Code (repository root)
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
lib/ruby_reactor/
|
|
103
|
+
├── dsl/
|
|
104
|
+
│ ├── retryable.rb # NEW (B): retries + validation, retry_config, inherited
|
|
105
|
+
│ ├── reactor.rb # A: retry_defaults -> DeprecatedDslError stub; drop included ivar
|
|
106
|
+
│ ├── step_builder.rb # A: drop reactor fallback; B: include Retryable, drop own
|
|
107
|
+
│ │ # retries, check_retry_conflict!, StepConfig lazy
|
|
108
|
+
│ │ # retry_config + retry_source + NO_RETRIES
|
|
109
|
+
│ ├── compose_builder.rb # A: drop fallback; B: include Retryable, drop own retries
|
|
110
|
+
│ └── async_reactor_builder.rb # A: drop fallback; B: include Retryable, drop own retries
|
|
111
|
+
├── step.rb # B: extend Dsl::Retryable
|
|
112
|
+
├── executor/retry_manager.rb # A: drop reactor_class.retry_defaults fallbacks
|
|
113
|
+
└── rspec/test_subject.rb # A: drop 3 @retry_defaults copies
|
|
114
|
+
|
|
115
|
+
spec/
|
|
116
|
+
├── async_retry_dsl_spec.rb # A: retry_defaults example -> asserts removal error
|
|
117
|
+
├── ruby_reactor_spec.rb # A: move retry_defaults into step-level retries
|
|
118
|
+
├── support/order_processing_reactor.rb # A: same
|
|
119
|
+
└── ruby_reactor/step_retries/*_spec.rb # NEW: removal (A); declaration, class_policy,
|
|
120
|
+
# step_block_parity, conflict, execution_paths,
|
|
121
|
+
# inheritance, introspection, test_surface (B)
|
|
122
|
+
|
|
123
|
+
demo_app/
|
|
124
|
+
├── spec/support/order_processing_reactor.rb # A: drop retry_defaults
|
|
125
|
+
├── app/reactors/step_retry_demo_reactor.rb # NEW (B)
|
|
126
|
+
├── lib/tasks/demo_reactors.rake # B: demo:step_retry
|
|
127
|
+
└── spec/reactors/step_retry_demo_reactor_spec.rb # NEW (B)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Structure Decision**: single gem layout (`lib/`, `spec/`) plus `demo_app/`, as in every
|
|
131
|
+
earlier feature. The new module lives next to `dsl/lockable.rb`, which it mirrors.
|
|
132
|
+
|
|
133
|
+
## Phase A: remove `retry_defaults` (ships first, standalone)
|
|
134
|
+
|
|
135
|
+
1. **Red**: change `spec/async_retry_dsl_spec.rb:23-33` to expect `DeprecatedDslError`,
|
|
136
|
+
message included. Add: a subclass of `RubyReactor::Reactor` with no retry declarations runs
|
|
137
|
+
a failing step exactly once.
|
|
138
|
+
2. **Green**: stub in `dsl/reactor.rb`; delete the `included` ivar; builders pass their own
|
|
139
|
+
`@retry_config` (initialized `nil`, not `{}`); `StepConfig` defaults to `NO_RETRIES` when
|
|
140
|
+
nil; `RetryManager#calculate_backoff_delay` reads only `step_config.retry_config` (drop the
|
|
141
|
+
now-unused `reactor_class` argument or rename it `_reactor_class`); delete the
|
|
142
|
+
`TestSubject` copies.
|
|
143
|
+
3. Migrate `spec/ruby_reactor_spec.rb`, `spec/support/order_processing_reactor.rb` and
|
|
144
|
+
`demo_app/spec/support/order_processing_reactor.rb` to step-level `retries` on the steps
|
|
145
|
+
that relied on the default. Keep their assertions unchanged, so behavior is proven
|
|
146
|
+
identical.
|
|
147
|
+
4. Docs (see the Constitution Check list) and the `BREAKING CHANGE:` footer with the
|
|
148
|
+
migration text.
|
|
149
|
+
5. Gate: `grep retry_defaults` is clean except the stub and its spec; `rspec` and `rubocop`
|
|
150
|
+
are green. Commit `feat!: remove reactor-wide retry_defaults`.
|
|
151
|
+
|
|
152
|
+
## Phase B: `retries` on step classes
|
|
153
|
+
|
|
154
|
+
1. **Red**: `spec/ruby_reactor/step_retries/*_spec.rb` (one file per story) covering the quickstart table.
|
|
155
|
+
2. **Green**:
|
|
156
|
+
- `Dsl::Retryable` (R3, R4): `BACKOFF_STRATEGIES`, `retries`, `retry_config`,
|
|
157
|
+
`inherited`.
|
|
158
|
+
- `Step` extends it. `StepBuilder`, `ComposeBuilder` and `AsyncReactorBuilder` include it
|
|
159
|
+
and delete their own `retries`; remove `retry_config` from `StepBuilder`'s
|
|
160
|
+
`attr_accessor`.
|
|
161
|
+
- `StepConfig` (R5): store `@retry_config` raw; lazy `retry_config`; `retry_source`.
|
|
162
|
+
- `StepBuilder#check_retry_conflict!` (R6), called from `build` next to
|
|
163
|
+
`check_coordination_conflicts!`.
|
|
164
|
+
3. **Demo** (Constitution VI): `StepRetryDemoReactor` with class steps, showing
|
|
165
|
+
succeed-after-retry, exhaust-then-compensate, and a single-attempt undeclared step, using
|
|
166
|
+
`base_delay` near zero so the task runs fast; the `demo:step_retry` task depends on
|
|
167
|
+
`[:environment, :flush_redis]`; the spec uses only `test_reactor`, `failing_at`/`mock_step`,
|
|
168
|
+
`be_success`, `be_failure` and `have_retried_step`.
|
|
169
|
+
4. Docs (Phase B list above).
|
|
170
|
+
5. Gate: `rspec`, `rubocop`, and the docker demo run. Commit `feat: declare retries on step
|
|
171
|
+
classes`.
|
|
172
|
+
|
|
173
|
+
## Risks
|
|
174
|
+
|
|
175
|
+
- **Hidden `{}`-vs-`nil` assumptions.** Code that checks `retry_config.empty?` breaks once
|
|
176
|
+
the builders pass `nil`. Mitigation: `grep -rn "retry_config" lib` during Phase A; every
|
|
177
|
+
consumer goes through `StepConfig#retry_config`, which never returns nil.
|
|
178
|
+
- **`name` collision in validation messages.** Builders' `name` is the step symbol and
|
|
179
|
+
`Step.name` is the class name. Both are what the message should show. An anonymous step
|
|
180
|
+
class has `name == nil`, so fall back to `inspect`.
|
|
181
|
+
- **Rubocop.** `inherited` in an included module triggers no cop today (Lockable does the
|
|
182
|
+
same). The unused `reactor_class` argument in `RetryManager` needs removing or renaming to
|
|
183
|
+
`_reactor_class`.
|
|
184
|
+
|
|
185
|
+
## Complexity Tracking
|
|
186
|
+
|
|
187
|
+
No violations. The table is empty.
|