ruby_reactor 0.8.3 → 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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +12 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +93 -91
  7. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
  8. data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
  9. data/lib/ruby_reactor/dsl/reactor.rb +8 -11
  10. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  11. data/lib/ruby_reactor/dsl/step_builder.rb +40 -14
  12. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  13. data/lib/ruby_reactor/executor/compensation_manager.rb +2 -2
  14. data/lib/ruby_reactor/executor/result_handler.rb +3 -2
  15. data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
  16. data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
  17. data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
  18. data/lib/ruby_reactor/step/compose_step.rb +8 -4
  19. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  20. data/lib/ruby_reactor/step/inputs.rb +57 -0
  21. data/lib/ruby_reactor/step/map_step.rb +32 -22
  22. data/lib/ruby_reactor/step.rb +13 -7
  23. data/lib/ruby_reactor/version.rb +1 -1
  24. data/lib/ruby_reactor.rb +3 -1
  25. data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
  26. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  27. data/specs/006-step-retry-declarations/data-model.md +58 -0
  28. data/specs/006-step-retry-declarations/plan.md +187 -0
  29. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  30. data/specs/006-step-retry-declarations/research.md +194 -0
  31. data/specs/006-step-retry-declarations/spec.md +453 -0
  32. data/specs/006-step-retry-declarations/tasks.md +382 -0
  33. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  34. metadata +13 -1
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"
@@ -387,8 +388,9 @@ module RubyReactor
387
388
  end
388
389
 
389
390
  # Global helper methods
391
+ # A step returning its own inputs (`Success(inputs)`) stores the Hash.
390
392
  def self.Success(value = nil)
391
- Success.new(value)
393
+ Success.new(value.is_a?(Step::Inputs) ? value.to_h : value)
392
394
  end
393
395
 
394
396
  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.
@@ -0,0 +1,194 @@
1
+ # Research: Step-Scoped Retry Declarations
2
+
3
+ Every decision below was checked against the code on `retry_confs_in_steps` (base `900d6522`).
4
+ Nothing in Technical Context was left open.
5
+
6
+ ## R1 — How `retry_defaults` works today
7
+
8
+ **Finding**:
9
+
10
+ - `Dsl::Reactor.included` sets `@retry_defaults = {max_attempts: 3, …}` on
11
+ `RubyReactor::Reactor` **only** (`lib/ruby_reactor/dsl/reactor.rb:14`). Nothing copies it to
12
+ subclasses.
13
+ - The reader `retry_defaults` lazily returns `{max_attempts: 1, …}` for any subclass that never
14
+ called it (`dsl/reactor.rb:58-68`).
15
+ - `StepBuilder#build`, `ComposeBuilder#build` and `AsyncReactorBuilder#build` **snapshot**
16
+ `@reactor.retry_defaults` into `retry_config` when their own block declared none
17
+ (`step_builder.rb:163`, `compose_builder.rb:74`, `async_reactor_builder.rb:52`). So a
18
+ `retry_defaults` line only affects steps declared *after* it. This is the "unpredictable"
19
+ behavior the user called out.
20
+ - `RetryManager#calculate_backoff_delay` falls back to `reactor_class.retry_defaults` for
21
+ `backoff`/`base_delay` (`executor/retry_manager.rb:29-30`).
22
+ - `RSpec::TestSubject` copies `@retry_defaults` into its three generated execution subclasses
23
+ (`rspec/test_subject.rb:559, 651, 739`).
24
+
25
+ **Consequence**: a reactor that never called `retry_defaults` already runs undeclared steps
26
+ exactly once. Removing the feature changes behavior only for reactors that call it (spec US1
27
+ scenario 3).
28
+
29
+ **Callers to migrate**: `spec/async_retry_dsl_spec.rb:23-33`, `spec/ruby_reactor_spec.rb:14, 221`,
30
+ `spec/support/order_processing_reactor.rb:28`, `demo_app/spec/support/order_processing_reactor.rb:28`,
31
+ plus docs (see R10).
32
+
33
+ ## R2 — How to remove `retry_defaults`
34
+
35
+ **Decision**: replace the method with a stub that raises `Error::DeprecatedDslError` when the
36
+ class is defined, and delete every read of it (the builder fallbacks, the `RetryManager`
37
+ fallback, the `TestSubject` copies, and the `included` ivar).
38
+
39
+ **Rationale**: this is exactly how the project removed `async true` on reactors
40
+ (`dsl/reactor.rb:48-56`) and `async` in step/compose blocks (`step_builder.rb:119-131`). A call
41
+ fails loudly at load time with a migration message instead of being silently ignored. The
42
+ error names the reactor (`name` or `"anonymous reactor"`) and says: declare `retries` on each
43
+ step class or step block that needs it.
44
+
45
+ **Alternatives considered**:
46
+
47
+ - *Deprecation warning, keep working for one release*: rejected. The user asked to remove it
48
+ altogether, and keeping it alive also keeps the ordering bug from R1.
49
+ - *Delete the method outright (`NoMethodError`)*: rejected. It gives no migration hint and
50
+ breaks the project's established removal pattern.
51
+
52
+ **Delivery**: this is Phase A of the plan and ships first, as its own commit, with the full
53
+ suite green (spec FR-017).
54
+
55
+ ## R3 — Where `retries` lives for a step class
56
+
57
+ **Decision**: a new module `RubyReactor::Dsl::Retryable`, modeled on `Dsl::Lockable::ClassMethods`,
58
+ containing:
59
+
60
+ - `retries(max_attempts: 3, backoff: :exponential, base_delay: 1)`: validates, then stores
61
+ `@retry_config`;
62
+ - `retry_config`: the reader, `nil` when nothing was declared;
63
+ - `inherited(subclass)`: copies `@retry_config` to the subclass, exactly like
64
+ `Lockable#inherited`.
65
+
66
+ `RubyReactor::Step` **extends** it (class-level DSL). `StepBuilder`, `ComposeBuilder` and
67
+ `AsyncReactorBuilder` **include** it, which replaces their three identical `retries` methods.
68
+ The same is already done with `Lockable::ClassMethods` in `StepBuilder` (`step_builder.rb:8`).
69
+
70
+ **Rationale**: the user asked us to emulate the locks design. One module gives both forms the
71
+ same vocabulary, the same defaults and the same validation (spec FR-001/FR-002/FR-004), and it
72
+ removes duplication instead of adding it. Inheritance by copying in `inherited` matches locks,
73
+ so a subclass redeclaring its policy leaves its parent and siblings untouched (FR-010).
74
+
75
+ **Alternatives considered**:
76
+
77
+ - *Walk `superclass` in the reader (like `input_contract`)*: works too, but it would be a
78
+ second inheritance mechanism next to Lockable's. Rejected for consistency.
79
+ - *A `RetryPolicy` value object*: rejected. There is only one consumer shape (a 3-key hash
80
+ read by `RetryManager`/`StepWorker`), so an object would be an abstraction with no second
81
+ use case (Constitution V).
82
+
83
+ ## R4 — Validation of retry values
84
+
85
+ **Decision**: `Retryable#retries` raises `ArgumentError` when the declaration is made if:
86
+
87
+ - `max_attempts` is not an `Integer` `>= 1`;
88
+ - `backoff` is not one of `%i[exponential linear fixed]`;
89
+ - `base_delay` is not a `Numeric` `>= 0`.
90
+
91
+ The message names the owner (`name`: the step symbol in a builder, the class name on a step
92
+ class), the option and the bad value.
93
+
94
+ **Rationale**: `ArgumentError` at declaration time is what `with_period`, `with_rate_limit`
95
+ and `validate_rollback_wait!` already do (`dsl/lockable.rb`). An unknown backoff strategy
96
+ fails today only at the first retry (`RetryContext.calculate_backoff_delay` raises
97
+ `ArgumentError`, `retry_context.rb:94`), which can be in production, days later.
98
+ `ActiveSupport::Duration` (`5.seconds`) passes the `Numeric` check, because
99
+ `Duration#is_a?` delegates to its value, so the documented Rails idiom keeps working.
100
+
101
+ **Behavior change**: `max_attempts: 0`, which today means "no retry", is now refused and must
102
+ be written `max_attempts: 1` (spec Assumptions). It goes in the migration note.
103
+
104
+ ## R5 — Effective policy and precedence on `StepConfig`
105
+
106
+ **Decision**: `StepConfig` stores the builder's own declaration (`nil` when none) and resolves
107
+ it lazily, the same way `lock_config` does (`step_builder.rb:286-304`):
108
+
109
+ ```text
110
+ retry_config = own declaration || impl.retry_config (if impl responds) || NO_RETRIES
111
+ retry_source = :step_block | :step_class | :none
112
+ ```
113
+
114
+ `NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze`.
115
+ `retryable?`, `RetryManager`, `StepWorker` and `StepCoordination#retry_pending?` keep reading
116
+ `retry_config` unchanged. Every hash now carries all three keys, so the reactor fallbacks in
117
+ `RetryManager` are unnecessary and removed in Phase A.
118
+
119
+ **Rationale**: resolving lazily through `impl` is what makes the class policy apply on every
120
+ path that already consults `step_config.retry_config`, with no per-path changes: the
121
+ in-process `RetryManager`, the background requeue, `StepWorker` for `async_step`, and resume
122
+ (FR-011/FR-012). `retry_source` covers the introspection requirement (FR-014) with one method.
123
+
124
+ **Alternatives considered**: snapshotting `impl.retry_config` at build time. Rejected: it
125
+ diverges from the lock readers and would miss a class policy declared after the reactor
126
+ references the class (for example after a reopen or reload).
127
+
128
+ ## R6 — Conflict between class and step block
129
+
130
+ **Decision**: `StepBuilder#build` gains `check_retry_conflict!`, next to
131
+ `check_coordination_conflicts!`. It raises `Error::ValidationError` when the block declared
132
+ `retries` **and** `@impl.respond_to?(:retry_config) && @impl.retry_config`. The message has
133
+ the same shape as the lock one: `"<Reactor> step :<name> declares `retries` inline, but
134
+ <Class> declares it too. Keep ONE: …"`, and it also mentions subclassing as the way to vary
135
+ the policy per workflow.
136
+
137
+ **Rationale**: FR-009 asks for the same rule as locks. An inherited class policy counts as a
138
+ declaration, since `retry_config` returns the copied value.
139
+
140
+ `ComposeBuilder` and `AsyncReactorBuilder` need no check: their `impl` is an internal
141
+ `ComposeStep`/`AsyncReactorStep` that never declares retries.
142
+
143
+ ## R7 — Direct invocation (clarified: runs once)
144
+
145
+ **Finding**: `Step.run` never enters `RetryManager`. `StepCoordination#retry_pending?` already
146
+ returns `false` for direct calls (`context_state?` is `!@direct && …`,
147
+ `step_coordination.rb:533-540, 679-681`), and the existing comment says so: "A direct call has
148
+ no retry policy of its own — it is never retried."
149
+
150
+ **Decision**: no runtime change. Add one spec proving that a step class declaring
151
+ `retries max_attempts: 3` and called directly runs once and returns its failure. Document it
152
+ in `retry_configuration.md` and next to the direct-call docs, contrasting it with locks
153
+ (FR-013).
154
+
155
+ ## R8 — Test surface (`TestSubject`, matchers)
156
+
157
+ **Finding**: `mock_step` and `failing_at` replace only `@run_block` on the step config
158
+ (`test_subject.rb:797-826`), so `impl`, and with it the class policy, is kept. The
159
+ `have_retried_step` matcher (`rspec/matchers.rb:130`) reads attempt counts from the context,
160
+ not from the declaration.
161
+
162
+ **Decision**: no new matcher. Delete the three `@retry_defaults` copies (Phase A). Add specs
163
+ proving that `failing_at`/`mock_step` on a class step still retries under the class policy
164
+ (FR-015).
165
+
166
+ ## R9 — Versioning
167
+
168
+ **Finding**: the version is `0.8.3`, managed by release-please. Earlier breaking removals
169
+ were released as `feat!` with a `⚠ BREAKING CHANGES` section and a **Migration:** line in
170
+ `CHANGELOG.md` (for example `async true`, `CHANGELOG.md:329`).
171
+
172
+ **Decision**: Phase A lands as a `feat!:` commit whose `BREAKING CHANGE:` footer carries the
173
+ migration text. Release-please generates the CHANGELOG entry from it. The pre-1.0 bump size
174
+ is release-please's call, as with earlier breaking releases. Phase B is a plain `feat:`.
175
+
176
+ ## R10 — Documentation and demo impact
177
+
178
+ **`retry_defaults` appears in**:
179
+
180
+ - `documentation/retry_configuration.md` (the "Reactor-Level Defaults" section) and
181
+ `documentation/core_concepts.md` (≈ line 360, "Uses reactor defaults");
182
+ - `documentation/background_and_async.md` (≈ lines 482-530);
183
+ - `documentation/examples/{inventory_management,order_processing,payment_processing}.md`;
184
+ - the stale copies under `demo_app/documentation/` (`async_reactors.md`, the same `examples/`
185
+ files, `retry_configuration.md`).
186
+
187
+ **README**: the Features bullet (line 24) and the Retry Configuration blurb (line 1486,
188
+ "configure retries at the reactor or step level") both need rewording.
189
+
190
+ **Demo (Constitution VI)**: there is no retry-specific demo today. Retries appear only inline
191
+ in `signal_demo_reactor.rb` and `order_processing_reactor.rb`. Add `StepRetryDemoReactor` using
192
+ class steps, the `demo:step_retry` rake task, and
193
+ `demo_app/spec/reactors/step_retry_demo_reactor_spec.rb`, modeled on
194
+ `step_lock_demo_reactor.rb` and `inheritable_step_demo_reactor_spec.rb`.