ruby_reactor 0.8.3 → 0.8.5
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/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +208 -0
- data/CLAUDE.md +1 -1
- data/README.md +140 -102
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +10 -7
- data/lib/ruby_reactor/dsl/compose_builder.rb +10 -11
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
- 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 +131 -33
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +28 -0
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
- data/lib/ruby_reactor/executor/result_handler.rb +21 -18
- data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +59 -49
- data/lib/ruby_reactor/executor.rb +38 -4
- data/lib/ruby_reactor/map/collector.rb +21 -11
- data/lib/ruby_reactor/map/dispatcher.rb +29 -3
- data/lib/ruby_reactor/map/element_executor.rb +9 -3
- data/lib/ruby_reactor/map/helpers.rb +32 -2
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/reactor.rb +24 -0
- data/lib/ruby_reactor/rspec/matchers.rb +19 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
- data/lib/ruby_reactor/step/compose_step.rb +14 -4
- 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 +140 -25
- data/lib/ruby_reactor/step.rb +20 -7
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +1 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +3 -1
- data/lib/ruby_reactor.rb +20 -7
- data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
- 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/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- data/specs/future_improvements.md +48 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +47 -2
|
@@ -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-CQbgHtd0.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
|
@@ -8,7 +8,7 @@ module RubyReactor
|
|
|
8
8
|
# `Adapters::ActiveJob::Compat` on ActiveJob::Base) — nothing here references
|
|
9
9
|
# a specific backend.
|
|
10
10
|
module Worker
|
|
11
|
-
TERMINAL_STATUSES = %w[completed failed cancelled skipped].freeze
|
|
11
|
+
TERMINAL_STATUSES = %w[completed failed cancelled skipped aborted].freeze
|
|
12
12
|
|
|
13
13
|
# Use the error's `retry_after_seconds` hint when available
|
|
14
14
|
# (RateLimit::ExceededError carries the time until the bucket rolls);
|
|
@@ -89,6 +89,8 @@ module RubyReactor
|
|
|
89
89
|
reactor_class_name ||= RubyReactor.reactor_storage_name(nil)
|
|
90
90
|
data = RubyReactor.configuration.storage_adapter.retrieve_context(context_id, reactor_class_name)
|
|
91
91
|
return if data.nil?
|
|
92
|
+
# An aborted run is never resumed forward: only a manual undo applies (008 R-08).
|
|
93
|
+
return if (data["status"] || data[:status]).to_s == "aborted"
|
|
92
94
|
|
|
93
95
|
begin
|
|
94
96
|
context = ContextSerializer.deserialize_hash(data)
|
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"
|
|
@@ -102,10 +103,11 @@ module RubyReactor
|
|
|
102
103
|
undef_method :skipped?
|
|
103
104
|
end
|
|
104
105
|
|
|
105
|
-
# Marks a single step as skipped
|
|
106
|
-
# exactly
|
|
107
|
-
#
|
|
108
|
-
#
|
|
106
|
+
# Marks a single step as skipped: an instrumentation mark (008 R-19). The
|
|
107
|
+
# run continues exactly as for a Success — the value flows to dependants via
|
|
108
|
+
# `result(:step)`, hand-offs and period marks happen — except `skipped?` is
|
|
109
|
+
# true, the trace records it, and the step is not enrolled for undo (it had
|
|
110
|
+
# nothing to do, so there is nothing to revert).
|
|
109
111
|
class Skipped < Success
|
|
110
112
|
# Sentinel distinguishing "no value argument given" (the old Halt call
|
|
111
113
|
# shape reused this class's name) from an explicit `Skipped(nil)`.
|
|
@@ -132,6 +134,10 @@ module RubyReactor
|
|
|
132
134
|
end
|
|
133
135
|
|
|
134
136
|
class Failure
|
|
137
|
+
# A stack overflow's backtrace runs to thousands of frames; the Failure is
|
|
138
|
+
# stored with the context, so keep the frames that locate the cause.
|
|
139
|
+
MAX_BACKTRACE_FRAMES = 100
|
|
140
|
+
|
|
135
141
|
attr_reader :error, :retryable, :step_name, :inputs, :backtrace, :reactor_name, :step_arguments, :exception_class,
|
|
136
142
|
:file_path, :line_number, :code_snippet, :validation_errors, :rollback_failures
|
|
137
143
|
|
|
@@ -172,7 +178,7 @@ module RubyReactor
|
|
|
172
178
|
@inputs = inputs
|
|
173
179
|
@step_arguments = step_arguments
|
|
174
180
|
raw_backtrace ||= backtrace || (@error.respond_to?(:backtrace) ? @error.backtrace : caller)
|
|
175
|
-
@backtrace = filter_backtrace(raw_backtrace)
|
|
181
|
+
@backtrace = cap_backtrace(filter_backtrace(raw_backtrace))
|
|
176
182
|
@redact_inputs = redact_inputs
|
|
177
183
|
@exception_class = exception_class || (@error.is_a?(Exception) ? @error.class.name : nil)
|
|
178
184
|
@file_path = file_path
|
|
@@ -293,6 +299,12 @@ module RubyReactor
|
|
|
293
299
|
msg << backtrace.take(10).map { |line| " #{line}" }.join("\n")
|
|
294
300
|
end
|
|
295
301
|
|
|
302
|
+
def cap_backtrace(backtrace)
|
|
303
|
+
return backtrace if backtrace.nil? || backtrace.size <= MAX_BACKTRACE_FRAMES
|
|
304
|
+
|
|
305
|
+
backtrace.first(MAX_BACKTRACE_FRAMES) << "... #{backtrace.size - MAX_BACKTRACE_FRAMES} more frames"
|
|
306
|
+
end
|
|
307
|
+
|
|
296
308
|
def filter_backtrace(backtrace)
|
|
297
309
|
return backtrace if ENV["RUBY_REACTOR_DEBUG"] == "true"
|
|
298
310
|
return backtrace if backtrace.nil? || backtrace.empty?
|
|
@@ -344,7 +356,7 @@ module RubyReactor
|
|
|
344
356
|
}
|
|
345
357
|
end
|
|
346
358
|
|
|
347
|
-
ROLLBACK_FAILURE_SYMBOLS = %i[step kind reason].freeze
|
|
359
|
+
ROLLBACK_FAILURE_SYMBOLS = %i[step kind reason map_step].freeze
|
|
348
360
|
private_constant :ROLLBACK_FAILURE_SYMBOLS
|
|
349
361
|
|
|
350
362
|
# A stored failure comes back with string keys (and, through plain JSON,
|
|
@@ -387,8 +399,9 @@ module RubyReactor
|
|
|
387
399
|
end
|
|
388
400
|
|
|
389
401
|
# Global helper methods
|
|
402
|
+
# A step returning its own inputs (`Success(inputs)`) stores the Hash.
|
|
390
403
|
def self.Success(value = nil)
|
|
391
|
-
Success.new(value)
|
|
404
|
+
Success.new(value.is_a?(Step::Inputs) ? value.to_h : value)
|
|
392
405
|
end
|
|
393
406
|
|
|
394
407
|
def self.Failure(error, **kwargs)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Specification Quality Checklist: Step-Scoped Retry Declarations
|
|
2
|
+
|
|
3
|
+
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
4
|
+
**Created**: 2026-09-25
|
|
5
|
+
**Feature**: [spec.md](../spec.md)
|
|
6
|
+
|
|
7
|
+
## Content Quality
|
|
8
|
+
|
|
9
|
+
- [x] No implementation details (languages, frameworks, APIs)
|
|
10
|
+
- [x] Focused on user value and business needs
|
|
11
|
+
- [x] Written for non-technical stakeholders
|
|
12
|
+
- [x] All mandatory sections completed
|
|
13
|
+
|
|
14
|
+
## Requirement Completeness
|
|
15
|
+
|
|
16
|
+
- [x] No [NEEDS CLARIFICATION] markers remain
|
|
17
|
+
- [x] Requirements are testable and unambiguous
|
|
18
|
+
- [x] Success criteria are measurable
|
|
19
|
+
- [x] Success criteria are technology-agnostic (no implementation details)
|
|
20
|
+
- [x] All acceptance scenarios are defined
|
|
21
|
+
- [x] Edge cases are identified
|
|
22
|
+
- [x] Scope is clearly bounded
|
|
23
|
+
- [x] Dependencies and assumptions identified
|
|
24
|
+
|
|
25
|
+
## Feature Readiness
|
|
26
|
+
|
|
27
|
+
- [x] All functional requirements have clear acceptance criteria
|
|
28
|
+
- [x] User scenarios cover primary flows
|
|
29
|
+
- [x] Feature meets measurable outcomes defined in Success Criteria
|
|
30
|
+
- [x] No implementation details leak into specification
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
|
|
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.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Quickstart: Validating Step-Scoped Retry Declarations
|
|
2
|
+
|
|
3
|
+
The API is in [contracts/dsl-surface.md](contracts/dsl-surface.md) and the resolution rules
|
|
4
|
+
are in [data-model.md](data-model.md).
|
|
5
|
+
|
|
6
|
+
## Prerequisites
|
|
7
|
+
|
|
8
|
+
- Ruby >= 3.0, `bundle install`
|
|
9
|
+
- Redis reachable by the suite (Constitution III): `docker compose up -d redis` or a local
|
|
10
|
+
`redis-server`
|
|
11
|
+
- For the demo: `docker compose up` (see the constitution, Principle VI §4)
|
|
12
|
+
|
|
13
|
+
## Phase A: `retry_defaults` removed (validate before starting Phase B)
|
|
14
|
+
|
|
15
|
+
1. The removal error fires at definition time:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle exec ruby -Ilib -e 'require "ruby_reactor"; Class.new(RubyReactor::Reactor) { retry_defaults max_attempts: 3 }'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Expected: `RubyReactor::Error::DeprecatedDslError`, with a message pointing to step-level
|
|
22
|
+
`retries`.
|
|
23
|
+
|
|
24
|
+
2. No references remain outside the removal stub and its spec:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
grep -rn "retry_defaults" lib spec demo_app README.md documentation llms*.txt
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Expected: only `lib/ruby_reactor/dsl/reactor.rb` (the stub) and the spec asserting it
|
|
31
|
+
raises.
|
|
32
|
+
|
|
33
|
+
3. The full suite and lint are green:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bundle exec rspec && bundle exec rubocop
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Phase B: `retries` on step classes
|
|
40
|
+
|
|
41
|
+
Run the feature specs, then the full suite:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bundle exec rspec spec/ruby_reactor/step_retries/
|
|
45
|
+
bundle exec rspec && bundle exec rubocop
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
What the feature spec must show (each maps to a spec story):
|
|
49
|
+
|
|
50
|
+
| Scenario | Expected | Spec |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| Class declares 3 attempts, body fails twice, reactor has no wiring | success on attempt 3 | US2 |
|
|
53
|
+
| Same class, body always fails | `MaxRetriesExhaustedFailure`, 3 attempts, compensation runs | US2 |
|
|
54
|
+
| Inline `retries` vs class `retries`, same values and failures | same attempts, delays, outcome | US3 |
|
|
55
|
+
| Class `retries` + step block `retries` | `ValidationError` at reactor definition | US4 |
|
|
56
|
+
| Class `retries max_attempts: 1` | not retried | US4 |
|
|
57
|
+
| Same class under `background all: true` (drained) | attempts requeued, same count | US5 |
|
|
58
|
+
| Same class as `async_step` | `StepWorker` honors the class policy | US5 |
|
|
59
|
+
| Subclass without / with its own `retries` | inherits / overrides; parent unchanged | US6 |
|
|
60
|
+
| `failing_at` / `mock_step` on a class step | still retried under the class policy | US7 |
|
|
61
|
+
| `steps[:x].retry_source` | `:step_class` / `:step_block` / `:none` | US7 |
|
|
62
|
+
| `ChargeCard.run(args)` directly, failing | one attempt, `Failure` returned | FR-013 |
|
|
63
|
+
| `retries backoff: :bogus` on a class | `ArgumentError` at class definition | FR-004 |
|
|
64
|
+
|
|
65
|
+
## Demo acceptance run
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
docker compose run --rm demo-app bin/rails demo:step_retry
|
|
69
|
+
docker compose run --rm demo-app bundle exec rspec spec/reactors/step_retry_demo_reactor_spec.rb
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The rake task prints three outcomes:
|
|
73
|
+
|
|
74
|
+
1. a class step that succeeds after retries (`attempts=3 success?=true`);
|
|
75
|
+
2. a class step that exhausts its retries, with an earlier step compensated
|
|
76
|
+
(`success?=false attempts=3 compensated=true`);
|
|
77
|
+
3. a step with no declaration that is attempted once (`attempts=1`).
|
|
78
|
+
|
|
79
|
+
When running from a git worktree, pass an isolated compose project name (`-p <name>`) so
|
|
80
|
+
the fixed container names don't collide with another worktree's stack.
|