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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-review/SKILL.md +324 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/.specify/extensions.yml +10 -0
  5. data/.specify/feature.json +1 -1
  6. data/.specify/workflows/speckit/workflow.yml +13 -1
  7. data/.specify/workflows/workflow-registry.json +2 -2
  8. data/CHANGELOG.md +94 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +128 -93
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
  13. data/lib/ruby_reactor/context.rb +26 -0
  14. data/lib/ruby_reactor/context_serializer.rb +4 -2
  15. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
  16. data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
  18. data/lib/ruby_reactor/dsl/lockable.rb +76 -21
  19. data/lib/ruby_reactor/dsl/reactor.rb +8 -11
  20. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  21. data/lib/ruby_reactor/dsl/step_builder.rb +150 -13
  22. data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
  23. data/lib/ruby_reactor/error/execution_parked.rb +16 -0
  24. data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
  25. data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
  26. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  27. data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
  28. data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
  29. data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
  30. data/lib/ruby_reactor/executor/result_handler.rb +34 -13
  31. data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
  32. data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
  33. data/lib/ruby_reactor/executor/step_executor.rb +115 -11
  34. data/lib/ruby_reactor/executor.rb +90 -20
  35. data/lib/ruby_reactor/map/element_executor.rb +24 -2
  36. data/lib/ruby_reactor/map/helpers.rb +35 -11
  37. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
  38. data/lib/ruby_reactor/open_telemetry.rb +61 -24
  39. data/lib/ruby_reactor/retry_context.rb +31 -2
  40. data/lib/ruby_reactor/rspec/helpers.rb +15 -0
  41. data/lib/ruby_reactor/rspec/matchers.rb +92 -0
  42. data/lib/ruby_reactor/rspec/test_subject.rb +9 -5
  43. data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
  44. data/lib/ruby_reactor/step/compose_step.rb +22 -7
  45. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  46. data/lib/ruby_reactor/step/inputs.rb +57 -0
  47. data/lib/ruby_reactor/step/map_step.rb +32 -22
  48. data/lib/ruby_reactor/step.rb +59 -11
  49. data/lib/ruby_reactor/step_sweeper.rb +29 -1
  50. data/lib/ruby_reactor/step_worker.rb +260 -37
  51. data/lib/ruby_reactor/version.rb +1 -1
  52. data/lib/ruby_reactor/web/api.rb +72 -7
  53. data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
  54. data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
  55. data/lib/ruby_reactor/web/public/index.html +1 -1
  56. data/lib/ruby_reactor/worker.rb +56 -30
  57. data/lib/ruby_reactor.rb +30 -6
  58. data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
  59. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  60. data/specs/006-step-retry-declarations/data-model.md +58 -0
  61. data/specs/006-step-retry-declarations/plan.md +187 -0
  62. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  63. data/specs/006-step-retry-declarations/research.md +194 -0
  64. data/specs/006-step-retry-declarations/spec.md +453 -0
  65. data/specs/006-step-retry-declarations/tasks.md +382 -0
  66. data/specs/future_improvements.md +250 -0
  67. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  68. metadata +20 -28
  69. data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
  70. data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
  71. data/specs/002-step-input-contracts/data-model.md +0 -115
  72. data/specs/002-step-input-contracts/plan.md +0 -165
  73. data/specs/002-step-input-contracts/quickstart.md +0 -170
  74. data/specs/002-step-input-contracts/research.md +0 -233
  75. data/specs/002-step-input-contracts/spec.md +0 -359
  76. data/specs/002-step-input-contracts/tasks.md +0 -367
  77. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
  78. data/specs/004-inheritable-step-class/data-model.md +0 -116
  79. data/specs/004-inheritable-step-class/plan.md +0 -174
  80. data/specs/004-inheritable-step-class/quickstart.md +0 -112
  81. data/specs/004-inheritable-step-class/research.md +0 -308
  82. data/specs/004-inheritable-step-class/spec.md +0 -316
  83. data/specs/004-inheritable-step-class/tasks.md +0 -258
  84. data/specs/active_job.md +0 -259
  85. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
  86. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
  87. data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
  88. data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
  89. data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
  90. data/specs/deferred-003-step-lock-declarations/research.md +0 -196
  91. data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
  92. data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
  93. 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-Dw4KV4QY.js"></script>
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>
@@ -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::AsyncResultPending => e
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 parked async wait is likewise uncapped HERE: its bound is
151
- # `async_park_timeout`, enforced against `dispatched_at` at the wait
152
- # site — counting snoozes would double-bound it with the wrong unit.
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::AsyncResultPending))
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
- # Use the error's `retry_after_seconds` hint when available
169
- # (RateLimit::ExceededError carries the time until the bucket rolls);
170
- # otherwise fall back to the configured base + jitter for lock/semaphore
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
- jitter = config.lock_snooze_jitter.to_f
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
- return false if error.is_a?(RubyReactor::OrderedLock::WaitError)
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)
@@ -1,7 +1,7 @@
1
- # Specification Quality Checklist: Inheritable Step Class
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-11
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
- - Feature is a public-API refactor of a Ruby library, so the spec necessarily names the
35
- public authoring surface (`RubyReactor::Step`, `run`, `fail!`, `call`). These are the
36
- product, not implementation details; internal mechanics (prepend, singleton wrapping)
37
- appear only in the Input quote and in FR-011 as things that must NOT exist.
38
- - Key scope decision (single authoring style, mixin removed) resolved via the decision rule
39
- the description supplied; recorded under Assumptions rather than left as a clarification.
40
- - Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`
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.