ruby_reactor 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/speckit-review/SKILL.md +324 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions.yml +10 -0
- data/.specify/feature.json +1 -1
- data/.specify/workflows/speckit/workflow.yml +13 -1
- data/.specify/workflows/workflow-registry.json +2 -2
- data/CHANGELOG.md +94 -0
- data/CLAUDE.md +2 -2
- data/README.md +128 -93
- data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
- data/lib/ruby_reactor/context.rb +26 -0
- data/lib/ruby_reactor/context_serializer.rb +4 -2
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
- data/lib/ruby_reactor/dsl/lockable.rb +76 -21
- data/lib/ruby_reactor/dsl/reactor.rb +8 -11
- data/lib/ruby_reactor/dsl/retryable.rb +45 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +150 -13
- data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
- data/lib/ruby_reactor/error/execution_parked.rb +16 -0
- data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
- data/lib/ruby_reactor/executor/result_handler.rb +34 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
- data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
- data/lib/ruby_reactor/executor/step_executor.rb +115 -11
- data/lib/ruby_reactor/executor.rb +90 -20
- data/lib/ruby_reactor/map/element_executor.rb +24 -2
- data/lib/ruby_reactor/map/helpers.rb +35 -11
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
- data/lib/ruby_reactor/open_telemetry.rb +61 -24
- data/lib/ruby_reactor/retry_context.rb +31 -2
- data/lib/ruby_reactor/rspec/helpers.rb +15 -0
- data/lib/ruby_reactor/rspec/matchers.rb +92 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +9 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
- data/lib/ruby_reactor/step/compose_step.rb +22 -7
- data/lib/ruby_reactor/step/input_contract.rb +5 -0
- data/lib/ruby_reactor/step/inputs.rb +57 -0
- data/lib/ruby_reactor/step/map_step.rb +32 -22
- data/lib/ruby_reactor/step.rb +59 -11
- data/lib/ruby_reactor/step_sweeper.rb +29 -1
- data/lib/ruby_reactor/step_worker.rb +260 -37
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +72 -7
- data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
- data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +56 -30
- data/lib/ruby_reactor.rb +30 -6
- data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
- data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
- data/specs/006-step-retry-declarations/data-model.md +58 -0
- data/specs/006-step-retry-declarations/plan.md +187 -0
- data/specs/006-step-retry-declarations/quickstart.md +80 -0
- data/specs/006-step-retry-declarations/research.md +194 -0
- data/specs/006-step-retry-declarations/spec.md +453 -0
- data/specs/006-step-retry-declarations/tasks.md +382 -0
- data/specs/future_improvements.md +250 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +20 -28
- data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
- data/specs/002-step-input-contracts/data-model.md +0 -115
- data/specs/002-step-input-contracts/plan.md +0 -165
- data/specs/002-step-input-contracts/quickstart.md +0 -170
- data/specs/002-step-input-contracts/research.md +0 -233
- data/specs/002-step-input-contracts/spec.md +0 -359
- data/specs/002-step-input-contracts/tasks.md +0 -367
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
- data/specs/004-inheritable-step-class/data-model.md +0 -116
- data/specs/004-inheritable-step-class/plan.md +0 -174
- data/specs/004-inheritable-step-class/quickstart.md +0 -112
- data/specs/004-inheritable-step-class/research.md +0 -308
- data/specs/004-inheritable-step-class/spec.md +0 -316
- data/specs/004-inheritable-step-class/tasks.md +0 -258
- data/specs/active_job.md +0 -259
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
- data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
- data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
- data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
- data/specs/deferred-003-step-lock-declarations/research.md +0 -196
- data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
- data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
- data/specs/possible_feature.md +0 -22
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
---
|
|
2
|
+
|
|
3
|
+
description: "Task list for Step-Scoped Retry Declarations"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Tasks: Step-Scoped Retry Declarations
|
|
7
|
+
|
|
8
|
+
**Input**: Design documents from `specs/006-step-retry-declarations/`
|
|
9
|
+
|
|
10
|
+
**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md),
|
|
11
|
+
[data-model.md](data-model.md), [contracts/dsl-surface.md](contracts/dsl-surface.md),
|
|
12
|
+
[quickstart.md](quickstart.md)
|
|
13
|
+
|
|
14
|
+
**Tests**: REQUIRED. Constitution III requires test-first: write every spec task first and
|
|
15
|
+
see it fail before starting the implementation tasks in the same phase. Run specs against
|
|
16
|
+
real Redis. Background-path specs must use **constant-named** reactor and step classes (as in
|
|
17
|
+
`spec/ruby_reactor/retry_signals_spec.rb`), because a worker rehydrates reactors by class
|
|
18
|
+
name.
|
|
19
|
+
|
|
20
|
+
**Delivery order (spec FR-017)**: Phase 2 (US1, removing `retry_defaults`) is committed as a
|
|
21
|
+
standalone `feat!` with the suite green **before any later phase starts**. No later task may
|
|
22
|
+
read or mention `retry_defaults`, except the removal stub and its spec.
|
|
23
|
+
|
|
24
|
+
## Format: `[ID] [P?] [Story] Description`
|
|
25
|
+
|
|
26
|
+
- **[P]**: can run in parallel (different files, no dependency on an incomplete task)
|
|
27
|
+
- **[Story]**: the user story from spec.md (US1–US7)
|
|
28
|
+
|
|
29
|
+
## Path Conventions
|
|
30
|
+
|
|
31
|
+
Gem: `lib/ruby_reactor/`, `spec/`. Demo: `demo_app/`. New feature specs:
|
|
32
|
+
`spec/ruby_reactor/step_retries/` (one file per story).
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Phase 1: Setup
|
|
37
|
+
|
|
38
|
+
**Purpose**: record a green baseline so the regressions in the later phases can be told apart.
|
|
39
|
+
|
|
40
|
+
- [X] T001 Confirm Redis is reachable and record the baseline: run `bundle exec rspec spec/async_retry_dsl_spec.rb spec/async_retry_integration_spec.rb spec/ruby_reactor_spec.rb spec/ruby_reactor/retry_signals_spec.rb spec/ruby_reactor/retry_reexecution_spec.rb spec/ruby_reactor/order_processing_reactor_spec.rb` and `bundle exec rubocop` from the repo root. Note any pre-existing failures in the PR description (see the flaky-spec note: rerun a failing file alone before treating it as real).
|
|
41
|
+
- [X] T002 Create the directory `spec/ruby_reactor/step_retries/` for the new feature specs.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Phase 2: User Story 1 — Remove reactor-wide retry defaults first (Priority: P1) 🎯 MVP, ships first
|
|
46
|
+
|
|
47
|
+
**Goal**: `retry_defaults` no longer exists. Calling it fails at class definition. A step
|
|
48
|
+
with no `retries` runs once. Nothing reads a reactor-level default.
|
|
49
|
+
|
|
50
|
+
**Independent Test**: `Class.new(RubyReactor::Reactor) { retry_defaults max_attempts: 3 }`
|
|
51
|
+
raises `DeprecatedDslError`. A reactor whose failing step declares nothing runs it once.
|
|
52
|
+
`grep -rn retry_defaults lib spec demo_app documentation README.md llms*.txt` matches only
|
|
53
|
+
the stub and its spec.
|
|
54
|
+
|
|
55
|
+
### Tests for User Story 1 (write first, confirm failing)
|
|
56
|
+
|
|
57
|
+
- [X] T003 [US1] In `spec/async_retry_dsl_spec.rb`, replace the example `"supports retry_defaults class method"` (lines 23-33) with `"rejects the removed reactor-level retry_defaults"`. It should expect `Class.new(RubyReactor::Reactor) { retry_defaults max_attempts: 5, backoff: :linear, base_delay: 2 }` to raise `RubyReactor::Error::DeprecatedDslError` with a message matching `/retry_defaults.*removed/m` and `/retries/`. Add a second example: calling `retry_defaults` with no arguments on a reactor class raises the same error.
|
|
58
|
+
- [X] T004 [P] [US1] Create `spec/ruby_reactor/step_retries/removal_spec.rb` with these cases:
|
|
59
|
+
- (a) an anonymous reactor whose step `run` always returns `Failure("boom")`, with no `retries` anywhere, returns a failure after exactly 1 attempt (`reactor.context.retry_context.attempts_for_step(:x) == 1`, error not prefixed "failed after");
|
|
60
|
+
- (b) the same for a `compose` step whose block declares no `retries`: `steps[:c].retry_config[:max_attempts] == 1`;
|
|
61
|
+
- (c) the same for `async_reactor`: `steps[:a].retry_config[:max_attempts] == 1`;
|
|
62
|
+
- (d) the `DeprecatedDslError` message names the reactor when the class is constant-named (define `RemovalSpecNamedReactor` via `stub_const` + `Class.new`, then call `retry_defaults` inside `class_eval`).
|
|
63
|
+
|
|
64
|
+
### Implementation for User Story 1
|
|
65
|
+
|
|
66
|
+
- [X] T005 [US1] In `lib/ruby_reactor/dsl/reactor.rb`:
|
|
67
|
+
- delete line 14 (`base.instance_variable_set(:@retry_defaults, …)`);
|
|
68
|
+
- replace `def retry_defaults(**kwargs) … end` (lines 58-68) with `def retry_defaults(*, **)`, which raises `RubyReactor::Error::DeprecatedDslError`. Model the message on the `async` removal stub at lines 48-56 and follow the shape in `contracts/dsl-surface.md` §1: "`retry_defaults` has been removed from #{name || "this reactor"}: reactor-wide defaults silently applied only to steps declared after them. Declare `retries` on each step class (or step block) that should retry; a step with no `retries` runs once."
|
|
69
|
+
- [X] T006 [US1] In `lib/ruby_reactor/dsl/step_builder.rb`:
|
|
70
|
+
- initialize `@retry_config = nil` (line 33, currently `{}`);
|
|
71
|
+
- in `build` (line 163), pass `retry_config: @retry_config`;
|
|
72
|
+
- in `StepConfig`, add `NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze` and change line 271 to `@retry_config = config[:retry_config] || NO_RETRIES`.
|
|
73
|
+
|
|
74
|
+
Keep `StepBuilder#retries` for now (it is replaced in Phase 3).
|
|
75
|
+
- [X] T007 [P] [US1] In `lib/ruby_reactor/dsl/compose_builder.rb`, initialize `@retry_config = nil` (line 23) and pass `retry_config: @retry_config` in `build` (line 74).
|
|
76
|
+
- [X] T008 [P] [US1] In `lib/ruby_reactor/dsl/async_reactor_builder.rb`, initialize `@retry_config = nil` (line 20) and pass `retry_config: @retry_config` in `build` (line 52).
|
|
77
|
+
- [X] T009 [P] [US1] In `lib/ruby_reactor/executor/retry_manager.rb` (lines 27-35), make `calculate_backoff_delay` read only `step_config.retry_config[:backoff]` and `[:base_delay]`. Drop the now-unused `reactor_class` parameter from `calculate_backoff_delay` and from its callers `requeue_job_for_step_retry` and `handle_sync_retry` (keep `reactor_class` wherever it is still used, e.g. for `async?` and `MaxRetriesExhaustedFailure`).
|
|
78
|
+
- [X] T010 [P] [US1] In `lib/ruby_reactor/rspec/test_subject.rb`, delete the three `@retry_defaults = superclass.instance_variable_get(:@retry_defaults)` lines (559, 651, 739).
|
|
79
|
+
- [X] T011 [US1] Run `grep -rn "retry_config" lib` and check that no consumer calls `.empty?` on a builder's `@retry_config` or assumes it is a Hash before `StepConfig` normalizes it. Fix any hit in place.
|
|
80
|
+
- [X] T012 [P] [US1] Migrate `spec/ruby_reactor_spec.rb`:
|
|
81
|
+
- remove `retry_defaults max_attempts: 3` (line 14) and add `retries max_attempts: 3` inside the `validate_email`, `hash_password` and `create_user` step blocks;
|
|
82
|
+
- remove `retry_defaults max_attempts: 3` (line 221) and add `retries max_attempts: 3` inside `step :flaky_step`.
|
|
83
|
+
|
|
84
|
+
Do not change any assertion.
|
|
85
|
+
- [X] T013 [P] [US1] Migrate `spec/support/order_processing_reactor.rb`: remove `retry_defaults max_attempts: 5, backoff: :fixed, base_delay: 2` (line 28) and add `retries max_attempts: 5, backoff: :fixed, base_delay: 2` to each step without its own `retries` (`validate_order`, `check_inventory`, `reserve_inventory`, `process_payment`). Do not change `spec/ruby_reactor/order_processing_reactor_spec.rb`.
|
|
86
|
+
- [X] T014 [P] [US1] Apply the identical migration to `demo_app/spec/support/order_processing_reactor.rb` (the file is byte-identical to the gem copy today; keep it that way).
|
|
87
|
+
- [X] T015 [US1] Run `bundle exec rspec` (full suite) and `bundle exec rubocop`; everything is green, including T003/T004.
|
|
88
|
+
|
|
89
|
+
### Documentation for User Story 1 (REQUIRED, Constitution Development Workflow)
|
|
90
|
+
|
|
91
|
+
- [X] T016 [P] [US1] In `documentation/retry_configuration.md`:
|
|
92
|
+
- delete "### Reactor-Level Defaults" (lines 48-70);
|
|
93
|
+
- rewrite the "Complex Retry Scenarios" example (≈ line 176) so each step declares its own `retries`;
|
|
94
|
+
- change the intro sentence "configured at both reactor and step levels" to step level only;
|
|
95
|
+
- add a section "## Migrating from `retry_defaults`" with a before/after example (move the values onto each step that needs them; a step without `retries` runs once; `max_attempts: 0` is not valid, use `1`).
|
|
96
|
+
- [X] T017 [P] [US1] In `documentation/core_concepts.md`:
|
|
97
|
+
- line 327: drop "either reactor-level defaults or";
|
|
98
|
+
- line 345: "Retries are configured per step";
|
|
99
|
+
- ≈ line 360: replace the "Uses reactor defaults" step with an explicit `retries` line or no retries, and adjust the surrounding example so it no longer declares `retry_defaults`.
|
|
100
|
+
- [X] T018 [P] [US1] In `documentation/background_and_async.md`, delete "### Reactor-Level Defaults" (≈ lines 514-530) and adjust any cross-reference to it.
|
|
101
|
+
- [X] T019 [P] [US1] In `documentation/examples/order_processing.md` (line 95), `documentation/examples/payment_processing.md` (lines 77, 228, 300) and `documentation/examples/inventory_management.md` (lines 51, 461), replace each `retry_defaults …` with `retries …` on the step(s) in that example that do external I/O. Keep the same values.
|
|
102
|
+
- [X] T020 [P] [US1] Apply the same changes to the stale copies under `demo_app/documentation/`: `retry_configuration.md` (48-70, 173), `core_concepts.md` (220, 238, 253), `async_reactors.md` (394-404), and `examples/{payment_processing (47, 230, 302, 389), order_processing (47), inventory_management (49, 460)}.md`.
|
|
103
|
+
- [X] T021 [P] [US1] In `README.md` line 1486, change "how to configure retries at the reactor or step level" to "how to configure retries per step". Run `grep -n "retry_defaults" llms.txt llms-full.txt` and fix any hit.
|
|
104
|
+
- [X] T022 [US1] Gate: `grep -rn "retry_defaults" lib spec demo_app documentation README.md llms.txt llms-full.txt` matches only `lib/ruby_reactor/dsl/reactor.rb`, `spec/async_retry_dsl_spec.rb` and `spec/ruby_reactor/step_retries/removal_spec.rb`. `bundle exec rspec` and `bundle exec rubocop` are green. Commit as `feat!: remove reactor-wide retry_defaults`, with a `BREAKING CHANGE:` footer carrying the migration text from T016 (including the `max_attempts: 0` → `1` note, which applies from Phase 3).
|
|
105
|
+
|
|
106
|
+
**Checkpoint**: US1 is shippable on its own. Do not start Phase 3 until T022 is committed.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Phase 3: Foundational — shared `retries` vocabulary (blocks US2–US7)
|
|
111
|
+
|
|
112
|
+
**Purpose**: one `Dsl::Retryable` module gives step classes and every builder the same
|
|
113
|
+
`retries` keyword arguments, defaults and validation (research R3, R4; FR-001, FR-002,
|
|
114
|
+
FR-004).
|
|
115
|
+
|
|
116
|
+
**⚠️ CRITICAL**: no US2–US7 work until this phase is green.
|
|
117
|
+
|
|
118
|
+
- [X] T023 [P] Create `spec/ruby_reactor/step_retries/declaration_spec.rb` (validation, FR-004). For both a step class (`Class.new(RubyReactor::Step) { retries … }`) and an inline step block (`Class.new(RubyReactor::Reactor) { step(:s) { retries …; run { Success() } } }`):
|
|
119
|
+
- `retries` with no args stores `{max_attempts: 3, backoff: :exponential, base_delay: 1}`;
|
|
120
|
+
- `retries max_attempts: 5` keeps the other defaults;
|
|
121
|
+
- `ArgumentError` for `max_attempts: 0`, `-1`, `2.5` and `"3"` (message names the owner and `max_attempts`), for `backoff: :bogus` (names `backoff`) and for `base_delay: -1` (names `base_delay`);
|
|
122
|
+
- `base_delay: 0` and `base_delay: 0.5` are accepted.
|
|
123
|
+
|
|
124
|
+
Also assert that `compose` and `async_reactor` blocks raise the same `ArgumentError` for `backoff: :bogus`.
|
|
125
|
+
- [X] T024 Create `lib/ruby_reactor/dsl/retryable.rb` defining `RubyReactor::Dsl::Retryable` with:
|
|
126
|
+
- `BACKOFF_STRATEGIES = %i[exponential linear fixed].freeze`;
|
|
127
|
+
- `attr_reader :retry_config`;
|
|
128
|
+
- `retries(max_attempts: 3, backoff: :exponential, base_delay: 1)`, which validates as in `data-model.md` (Integer >= 1; strategy in the list; Numeric >= 0) and raises `ArgumentError, "#{retry_owner_label}: retries #{option} must be … (got #{value.inspect})"`, then sets `@retry_config = { max_attempts:, backoff:, base_delay: }`;
|
|
129
|
+
- `inherited(subclass)`: `super`, then copy `@retry_config` to the subclass when set (mirror `Dsl::Lockable::ClassMethods#inherited`, `lib/ruby_reactor/dsl/lockable.rb:33-40`);
|
|
130
|
+
- a private `retry_owner_label` returning `name&.to_s || inspect`.
|
|
131
|
+
|
|
132
|
+
Add `require_relative "ruby_reactor/dsl/retryable"` in `lib/ruby_reactor.rb` right after the `dsl/lockable` require (line 9).
|
|
133
|
+
- [X] T025 In `lib/ruby_reactor/step.rb`, add `extend RubyReactor::Dsl::Retryable` next to `extend RubyReactor::Dsl::Lockable::ClassMethods` (line 36), and update the class header comment ("The one `extend` is …") to mention both modules.
|
|
134
|
+
- [X] T026 In `lib/ruby_reactor/dsl/step_builder.rb`, `include RubyReactor::Dsl::Retryable`, delete `StepBuilder#retries` (lines 133-139), and remove `:retry_config` from the `attr_accessor` list (line 16). The module's reader replaces it.
|
|
135
|
+
- [X] T027 [P] In `lib/ruby_reactor/dsl/compose_builder.rb`, `include RubyReactor::Dsl::Retryable` and delete `ComposeBuilder#retries` (lines 47-53).
|
|
136
|
+
- [X] T028 [P] In `lib/ruby_reactor/dsl/async_reactor_builder.rb`, `include RubyReactor::Dsl::Retryable` and delete `AsyncReactorBuilder#retries` (lines 28-30).
|
|
137
|
+
- [X] T029 Run T023 plus `bundle exec rspec spec/async_retry_dsl_spec.rb spec/ruby_reactor/retry_signals_spec.rb spec/compose_spec.rb spec/ruby_reactor/dsl/` and `bundle exec rubocop lib/ruby_reactor/dsl/retryable.rb`. All green.
|
|
138
|
+
|
|
139
|
+
**Checkpoint**: the `retries` vocabulary is shared. Step classes can declare it, but reactors
|
|
140
|
+
don't read it yet.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Phase 4: User Story 2 — A step class declares its own retry policy (Priority: P1)
|
|
145
|
+
|
|
146
|
+
**Goal**: a reactor using a step class with no retry wiring gets the class's policy (FR-001,
|
|
147
|
+
FR-008 fallback, FR-011, FR-012). A direct call runs once (FR-013).
|
|
148
|
+
|
|
149
|
+
**Independent Test**: a class step declaring `retries max_attempts: 3, base_delay: 0`, whose
|
|
150
|
+
body fails twice, succeeds on attempt 3 in a reactor that has no `retries` line. When the body
|
|
151
|
+
always fails, the reactor fails after 3 attempts and compensates the earlier steps.
|
|
152
|
+
|
|
153
|
+
### Tests for User Story 2 (write first, confirm failing)
|
|
154
|
+
|
|
155
|
+
- [X] T030 [P] [US2] Create `spec/ruby_reactor/step_retries/class_policy_spec.rb` with:
|
|
156
|
+
- (a) fail twice then succeed → success, `attempts_for_step == 3`;
|
|
157
|
+
- (b) always fail → `RubyReactor::MaxRetriesExhaustedFailure` with message `"Step 'charge' failed after 3 attempts: …"`, and an earlier step's `compensate`/`undo` ran;
|
|
158
|
+
- (c) `retries max_attempts: 3, backoff: :linear, base_delay: 0.01` → the sleeps between attempts follow linear backoff: `allow_any_instance_of(RubyReactor::Executor::RetryManager).to receive(:sleep)`, then expect it to have received `sleep(0.01)` and then `sleep(0.02)`;
|
|
159
|
+
- (d) `retries max_attempts: 3` only → `steps[:charge].retry_config == {max_attempts: 3, backoff: :exponential, base_delay: 1}`;
|
|
160
|
+
- (e) a body doing `fail!(StandardError.new("x"), retry: false)` makes 1 attempt;
|
|
161
|
+
- (f) a step class with `input :amount, :integer` given `amount: "x"` makes 1 attempt (contract failure is non-retryable);
|
|
162
|
+
- (g) the step skipped by `where { false }` makes 0 attempts.
|
|
163
|
+
- [X] T031 [P] [US2] In `spec/ruby_reactor/step_retries/class_policy_spec.rb`, add a `describe "direct invocation"` example (FR-013): a step class with `retries max_attempts: 3` whose `run` counts calls and returns `Failure("x")`; `klass.run({})` returns a `Failure`, and the counter is 1. Repeat with the step called from another class step's `run` body inside a reactor: the inner direct call runs once per outer attempt.
|
|
164
|
+
|
|
165
|
+
### Implementation for User Story 2
|
|
166
|
+
|
|
167
|
+
- [X] T032 [US2] In `StepConfig` (`lib/ruby_reactor/dsl/step_builder.rb`), store the raw declaration (`@retry_config = config[:retry_config]`, no default). Remove `:retry_config` from `StepConfig`'s `attr_reader` and add, next to `lock_config` (≈ line 286): `def retry_config = @retry_config || (impl.retry_config if impl.respond_to?(:retry_config)) || NO_RETRIES`. Confirm that `retryable?` (≈ line 376) still reads through it.
|
|
168
|
+
- [X] T033 [US2] Check (no change expected, research R7) that `lib/ruby_reactor/executor/step_coordination.rb` `retry_pending?` (lines 533-540) is guarded by `context_state?` before touching `step_config.retry_config`. When `step_config` is a `Step` class, `retry_config` may be `nil` and must never be indexed. If there is any unguarded path, guard it with `&.` there.
|
|
169
|
+
- [X] T034 [US2] Run T030/T031 and `bundle exec rspec spec/ruby_reactor/step_spec.rb spec/ruby_reactor/step_inheritance_spec.rb spec/ruby_reactor/step_contract_retryable_spec.rb`. All green.
|
|
170
|
+
|
|
171
|
+
**Checkpoint**: the core feature works on the in-process path.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Phase 5: User Story 3 — Existing step-level declarations keep working (Priority: P1)
|
|
176
|
+
|
|
177
|
+
**Goal**: inline and step-block `retries` behave exactly as before, and the same line works
|
|
178
|
+
in a class body (FR-002, FR-003).
|
|
179
|
+
|
|
180
|
+
**Independent Test**: the existing retry specs pass unchanged, and an inline step and a class
|
|
181
|
+
step with the same declaration give identical outcomes.
|
|
182
|
+
|
|
183
|
+
- [X] T035 [P] [US3] Create `spec/ruby_reactor/step_retries/step_block_parity_spec.rb` with:
|
|
184
|
+
- (a) an inline step with `retries max_attempts: 3, backoff: :fixed, base_delay: 0` and a `run` block, failing twice → success on attempt 3;
|
|
185
|
+
- (b) a class step with **no** `retries` plus `step :s, Klass do retries max_attempts: 3, base_delay: 0 end` → retried 3 times, and `steps[:s].retry_config[:max_attempts] == 3`;
|
|
186
|
+
- (c) a parity table: the same failure sequence (fail, fail, succeed) and (always fail) run through an inline step and through a class step declaring the identical `retries` line → equal attempt counts, equal final result class, equal error message.
|
|
187
|
+
- [X] T036 [US3] Run the existing retry specs unchanged: `spec/async_retry_dsl_spec.rb`, `spec/async_retry_integration_spec.rb`, `spec/ruby_reactor/retry_signals_spec.rb`, `spec/ruby_reactor/retry_reexecution_spec.rb`, `spec/ruby_reactor/order_processing_reactor_spec.rb`, `spec/compose_spec.rb`. All green, with no edits to them in this phase.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Phase 6: User Story 4 — One declaration per step (Priority: P1)
|
|
192
|
+
|
|
193
|
+
**Goal**: `retries` on both the class and the step block is refused at reactor definition
|
|
194
|
+
(FR-009).
|
|
195
|
+
|
|
196
|
+
**Independent Test**: adding `retries` to a reactor step block for a class that declares
|
|
197
|
+
`retries` raises `Error::ValidationError` naming the reactor, the step and the class.
|
|
198
|
+
|
|
199
|
+
- [X] T037 [P] [US4] Create `spec/ruby_reactor/step_retries/conflict_spec.rb` with:
|
|
200
|
+
- (a) a class with `retries max_attempts: 3` plus `step :charge, Klass do retries max_attempts: 5 end` → `RubyReactor::Error::ValidationError` matching `/step :charge declares `retries` inline, but .* declares it too/` and `/subclass/`;
|
|
201
|
+
- (b) the same with the class's policy **inherited** from a parent → still refused;
|
|
202
|
+
- (c) a class without `retries` plus a block `retries` → no error;
|
|
203
|
+
- (d) a class with `retries max_attempts: 1` in a reactor → not retried (`attempts == 1`).
|
|
204
|
+
- [X] T038 [US4] In `lib/ruby_reactor/dsl/step_builder.rb`, add a private `check_retry_conflict!` next to `check_coordination_conflicts!` (≈ line 180) and call it from `build`. It returns unless `@retry_config && @impl.respond_to?(:retry_config) && @impl.retry_config`, then raises `Error::ValidationError`: "#{reactor_label} step :#{@name} declares `retries` inline, but #{@impl} declares it too. Keep ONE: drop the inline declaration to use #{@impl}'s, or remove it from #{@impl}. To vary the policy per workflow, subclass #{@impl} and declare `retries` there."
|
|
205
|
+
- [X] T039 [US4] Run T037 and `spec/ruby_reactor/step_coordination/declaration_spec.rb` (to check the neighboring lock conflict check is untouched). Green.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Phase 7: User Story 5 — The policy follows the step on every execution path (Priority: P2)
|
|
210
|
+
|
|
211
|
+
**Goal**: a class policy applies in background hand-off, mid-workflow hand-off,
|
|
212
|
+
`async_step`, and resume, and attempts carry across requeues (FR-011, FR-012).
|
|
213
|
+
|
|
214
|
+
**Independent Test**: the same always-failing class step makes exactly the declared number of
|
|
215
|
+
attempts synchronously and under `background all: true`.
|
|
216
|
+
|
|
217
|
+
- [X] T040 [P] [US5] Create `spec/ruby_reactor/step_retries/execution_paths_spec.rb` with constant-named classes defined at the top of the file (`StepRetriesPathsChargeStep < RubyReactor::Step` with `retries max_attempts: 3, backoff: :fixed, base_delay: 0` and a class-level attempt counter, plus reactors below). Cases:
|
|
218
|
+
- (a) a reactor with `background all: true`, run and drained (follow the pattern in `spec/ruby_reactor/retry_signals_spec.rb` / `spec/async_retry_integration_spec.rb`), always failing → final context failed, 3 attempts, earlier step compensated;
|
|
219
|
+
- (b) fail twice then succeed under `background all: true` → success, and `retry_context.attempts_for_step` stayed consistent across requeues;
|
|
220
|
+
- (c) a reactor with `background after: :first_step` → same count as (a);
|
|
221
|
+
- (d) `async_step :charge, StepRetriesPathsChargeStep` → `StepWorker` retries 3 times (`lib/ruby_reactor/step_worker.rb:344-349` reads `step_config.retry_config`);
|
|
222
|
+
- (e) a reactor with an `interrupt` before `:charge`, resumed, then failing → 3 attempts after the resume.
|
|
223
|
+
- [X] T041 [US5] Run T040. No runtime change is expected (research R5). If (d) fails, fix `lib/ruby_reactor/step_worker.rb` so it reads only `step_config.retry_config` (never `@retry_config` or `impl` directly). If (a)–(c) fail, fix the same kind of issue in `lib/ruby_reactor/executor/retry_manager.rb`.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Phase 8: User Story 6 — Step subclasses inherit the policy (Priority: P2)
|
|
228
|
+
|
|
229
|
+
**Goal**: subclasses inherit and can override, and the parent is unaffected (FR-010).
|
|
230
|
+
|
|
231
|
+
**Independent Test**: a base class with `retries max_attempts: 4`, one subclass that inherits
|
|
232
|
+
it and one that overrides with 2 → 4/4/2 attempts respectively.
|
|
233
|
+
|
|
234
|
+
- [X] T042 [P] [US6] Create `spec/ruby_reactor/step_retries/inheritance_spec.rb` with:
|
|
235
|
+
- (a) a subclass without `retries` → `retry_config` equals the parent's, and in a reactor it makes 4 attempts;
|
|
236
|
+
- (b) a subclass with `retries max_attempts: 2` → 2 attempts; the parent and a sibling still show 4;
|
|
237
|
+
- (c) a subclass-per-workflow: `ReactorA` uses the base class and `ReactorB` uses a subclass with its own policy; both load without a conflict error and retry as declared;
|
|
238
|
+
- (d) a subclass also inherits the parent's `with_lock` and `input` contract alongside `retries` (regression guard that `Retryable#inherited` calls `super`).
|
|
239
|
+
- [X] T043 [US6] Run T042. If (d) fails, fix the `super` chain in `lib/ruby_reactor/dsl/retryable.rb` `inherited`.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Phase 9: User Story 7 — Tests and operators can see the policy (Priority: P2)
|
|
244
|
+
|
|
245
|
+
**Goal**: the effective policy and its source can be looked up, and the shipped test surface
|
|
246
|
+
exercises class policies (FR-014, FR-015, FR-016).
|
|
247
|
+
|
|
248
|
+
**Independent Test**: `steps[:charge].retry_source == :step_class`, and `failing_at(:charge)`
|
|
249
|
+
on a class step is retried under the class policy (`have_retried_step(:charge).times(2)`).
|
|
250
|
+
|
|
251
|
+
- [X] T044 [P] [US7] Create `spec/ruby_reactor/step_retries/introspection_spec.rb` with:
|
|
252
|
+
- (a) `retry_source` is `:step_class`, `:step_block` or `:none` for the three kinds of steps;
|
|
253
|
+
- (b) `retry_config` for each matches the declaration or `NO_RETRIES`;
|
|
254
|
+
- (c) with a middleware registered (see `spec/ruby_reactor/middleware_spec.rb` for the pattern), a failing class step with `retries max_attempts: 3, base_delay: 0` emits `:retry_attempt` twice, with the step name and attempt numbers 1 and 2.
|
|
255
|
+
- [X] T045 [P] [US7] Create `spec/ruby_reactor/step_retries/test_surface_spec.rb` (`type: :reactor`, using only `test_reactor`, `failing_at`, `mock_step`, `have_retried_step`, `be_failure`, `be_success`):
|
|
256
|
+
- (a) `test_reactor(R, inputs).failing_at(:charge)` where `:charge` is a class step with `retries max_attempts: 3, base_delay: 0` → `be_failure` and `have_retried_step(:charge).times(2)`;
|
|
257
|
+
- (b) `mock_step(:charge) { |_a, ctx| ctx.retry_context.attempts_for_step(:charge) < 2 ? RubyReactor.Failure("x") : RubyReactor.Success(1) }` → `be_success` and `have_retried_step(:charge).times(1)`.
|
|
258
|
+
- [X] T046 [US7] In `StepConfig` (`lib/ruby_reactor/dsl/step_builder.rb`), add `retry_source`: `:step_block` if `@retry_config`, else `:step_class` if `impl.respond_to?(:retry_config) && impl.retry_config`, else `:none`. Run T044/T045. Green.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Phase 10: Demo (Constitution VI)
|
|
263
|
+
|
|
264
|
+
**Purpose**: a runnable proof through the public DSL (FR-018).
|
|
265
|
+
|
|
266
|
+
- [X] T047 [P] Create `demo_app/app/reactors/step_retry_demo_reactor.rb` defining, in one file:
|
|
267
|
+
- `StepRetryDemoLog` (a class-level attempt log with `reset!`);
|
|
268
|
+
- `FlakyChargeStep < RubyReactor::Step` with `input :fail_times, :integer` and `retries max_attempts: 3, backoff: :fixed, base_delay: 0.05`, which fails until the attempt counter reaches `fail_times` (log each attempt);
|
|
269
|
+
- `ReserveStockStep < RubyReactor::Step` with a `compensate` that records `StepRetryDemoLog.compensated = true`;
|
|
270
|
+
- `NotifyStep < RubyReactor::Step` with no `retries`, which fails when `fail_notify` is true.
|
|
271
|
+
|
|
272
|
+
`StepRetryDemoReactor < RubyReactor::Reactor` has inputs `fail_times` and `fail_notify` and steps `reserve_stock` → `charge` → `notify`, with no `retries` lines anywhere in the reactor. Model the file layout on `demo_app/app/reactors/step_lock_demo_reactor.rb`.
|
|
273
|
+
- [X] T048 Add `desc "StepRetryDemoReactor — retries declared on the STEP class; succeed-after-retry, exhaust-and-compensate, undeclared step runs once"` and `task step_retry: [:environment, :flush_redis]` to `demo_app/lib/tasks/demo_reactors.rake`. Touch `StepRetryDemoReactor` first (Zeitwerk note as in the `step_lock` task, ≈ line 685). It prints three scenarios:
|
|
274
|
+
1. `fail_times: 2` → `attempts=3 success?=true`;
|
|
275
|
+
2. `fail_times: 5` → `success?=false attempts=3 compensated=true`;
|
|
276
|
+
3. `fail_times: 0, fail_notify: true` → `notify attempts=1 success?=false`.
|
|
277
|
+
- [X] T049 [P] Create `demo_app/spec/reactors/step_retry_demo_reactor_spec.rb` (`type: :reactor`, `require "rails_helper"`) using only the shipped surface. It covers the three scenarios with `be_success`/`be_failure`, `have_retried_step(:charge).times(2)`, and `expect(reactor).not_to have_retried_step(:notify)`. The compensation assertion reads `StepRetryDemoLog.compensated` (application state, not reactor internals).
|
|
278
|
+
- [X] T050 Run the demo end to end in an isolated compose project: `docker compose -p rr-retry run --rm demo-app bin/rails demo:step_retry` and `docker compose -p rr-retry run --rm demo-app bundle exec rspec spec/reactors/step_retry_demo_reactor_spec.rb`. Confirm the printed outcomes match T048.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Phase 11: Polish & Cross-Cutting Concerns
|
|
283
|
+
|
|
284
|
+
- [X] T051 [P] In `documentation/retry_configuration.md`, add "### Declaring retries on a step class (preferred)" as the first example under "Basic Retry Configuration", with `ChargeCard` using `with_lock`, `input` and `retries` together (as in `contracts/dsl-surface.md` §2). Keep the inline example after it. Add:
|
|
285
|
+
- "### Where a step's policy comes from": the step block, then the step class, then none (runs once);
|
|
286
|
+
- "### One declaration per step": the conflict error and the subclassing recipe;
|
|
287
|
+
- "### Direct calls run once": `ChargeCard.run(args)` never retries, because only a reactor coordinates retries; contrast this with locks, which a direct call does take;
|
|
288
|
+
- validation rules for `max_attempts`, `backoff` and `base_delay`.
|
|
289
|
+
- [X] T052 [P] In `documentation/core_concepts.md` line 108 (the class-step lifecycle paragraph that says direct calls are coordinated like reactor calls), append: "Retries are not: a direct call runs once — only a reactor retries a step (see [Retry Configuration](retry_configuration.md#direct-calls-run-once))." In the retry section (≈ 345), lead with the step-class form.
|
|
290
|
+
- [X] T053 [P] In `README.md`:
|
|
291
|
+
- Features bullet (line 24): "**Retries**: per-step retry policies (declared on the step class or step block) with exponential, linear, or fixed backoff.";
|
|
292
|
+
- in "Defining Steps" (≈ line 245, the class-step example), add a `retries max_attempts: 3` line to the example step class with a one-line comment;
|
|
293
|
+
- Retry Configuration blurb (line 1486): mention the step-class form.
|
|
294
|
+
- [X] T054 [P] Mirror T051–T052 into `demo_app/documentation/retry_configuration.md` and `demo_app/documentation/core_concepts.md`.
|
|
295
|
+
- [X] T055 [P] Update `llms.txt` / `llms-full.txt` if they describe the retry DSL (`grep -n "retries" llms*.txt`), so they show the step-class form.
|
|
296
|
+
- [X] T056 Final gate: `bundle exec rspec`, `bundle exec rubocop`, and T050 are all green. Walk through `quickstart.md` Phase B. Commit as `feat: declare retries on step classes`.
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Dependencies & Execution Order
|
|
301
|
+
|
|
302
|
+
### Phase Dependencies
|
|
303
|
+
|
|
304
|
+
- **Phase 1 (Setup)**: none.
|
|
305
|
+
- **Phase 2 (US1, removal)**: after Setup. **Must be committed (T022) before anything else
|
|
306
|
+
starts** (spec FR-017).
|
|
307
|
+
- **Phase 3 (Foundational)**: after T022. Blocks US2–US7.
|
|
308
|
+
- **Phase 4 (US2)**: after Phase 3. T032 is the change the class policy needs to take effect.
|
|
309
|
+
- **Phases 5–9 (US3–US7)**: after Phase 4 (they all run class steps through a reactor, so they
|
|
310
|
+
need T032). They are independent of each other after that.
|
|
311
|
+
- **Phase 10 (Demo)**: after Phase 4. Its spec uses matchers exercised in US7 but needs no US7
|
|
312
|
+
code.
|
|
313
|
+
- **Phase 11 (Polish)**: after the stories it documents. T056 goes last.
|
|
314
|
+
|
|
315
|
+
### User Story Dependencies
|
|
316
|
+
|
|
317
|
+
```text
|
|
318
|
+
US1 (removal) ──commit──▶ Foundational ──▶ US2 ──┬──▶ US3
|
|
319
|
+
├──▶ US4
|
|
320
|
+
├──▶ US5
|
|
321
|
+
├──▶ US6
|
|
322
|
+
├──▶ US7
|
|
323
|
+
└──▶ Demo ──▶ Polish
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Within Each Phase
|
|
327
|
+
|
|
328
|
+
- Spec tasks are written first and must fail before the implementation tasks of that phase.
|
|
329
|
+
- `lib/ruby_reactor/dsl/step_builder.rb` is touched by T006, T026, T032, T038 and T046, in that
|
|
330
|
+
order, so those tasks are never [P] with each other.
|
|
331
|
+
|
|
332
|
+
### Parallel Opportunities
|
|
333
|
+
|
|
334
|
+
- **US1**: T004 ∥ T003 (different files); T007 ∥ T008 ∥ T009 ∥ T010 after T006; T012 ∥ T013
|
|
335
|
+
∥ T014; T016–T021 all in parallel.
|
|
336
|
+
- **Foundational**: T023 ∥ T024; T027 ∥ T028 after T024.
|
|
337
|
+
- **After US2**: the spec files T035, T037, T040, T042, T044 and T045 are all separate files
|
|
338
|
+
and can be written in parallel. The demo files T047 and T049 can run in parallel with
|
|
339
|
+
those.
|
|
340
|
+
- **Polish**: T051–T055 are separate files.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Parallel Example: after Phase 4
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
Task: "T035 step_block_parity_spec.rb (US3)"
|
|
348
|
+
Task: "T037 conflict_spec.rb (US4)"
|
|
349
|
+
Task: "T040 execution_paths_spec.rb (US5)"
|
|
350
|
+
Task: "T042 inheritance_spec.rb (US6)"
|
|
351
|
+
Task: "T044 introspection_spec.rb + T045 test_surface_spec.rb (US7)"
|
|
352
|
+
Task: "T047 step_retry_demo_reactor.rb + T049 its spec (Demo)"
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Implementation Strategy
|
|
358
|
+
|
|
359
|
+
### MVP = US1 (removal), shipped alone
|
|
360
|
+
|
|
361
|
+
1. Phase 1 → Phase 2.
|
|
362
|
+
2. **STOP**: T022 gate, `feat!` commit. This is releasable on its own and already makes
|
|
363
|
+
retries predictable (a step's policy is always on the step).
|
|
364
|
+
|
|
365
|
+
### Then the core feature
|
|
366
|
+
|
|
367
|
+
3. Phase 3 → Phase 4 (US2): step classes carry their policy. Validate independently with
|
|
368
|
+
T030/T031.
|
|
369
|
+
4. US4 (conflict) next: it is the correctness guard.
|
|
370
|
+
5. Then US3, US5, US6, US7 in any order or in parallel.
|
|
371
|
+
6. Demo → Polish → T056 `feat:` commit.
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## Notes
|
|
376
|
+
|
|
377
|
+
- Keep `lib/ruby_reactor/rspec/test_subject.rb` free of retry special cases. Mocks keep
|
|
378
|
+
`impl`, so the class policy follows automatically (research R8).
|
|
379
|
+
- Do not add a policy object or a reactor-level fallback of any kind (research R3, spec
|
|
380
|
+
FR-006).
|
|
381
|
+
- Spec quirk: specs sharing test Redis under load can flake (the async parked-wait spec and
|
|
382
|
+
the 1s Redis ping). Rerun a failing file alone before debugging.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Future improvements for review
|
|
2
|
+
|
|
3
|
+
## Guard
|
|
4
|
+
|
|
5
|
+
I want to add another hook feature to the step: `guard`
|
|
6
|
+
A guard concept is to execute after validations and before run.
|
|
7
|
+
example:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class SendEmail < RubyReactor::Step
|
|
11
|
+
input :email, :string, format?: /\A[^@\s]+@[^@\s]+\z/
|
|
12
|
+
|
|
13
|
+
def guard
|
|
14
|
+
fail!("Prevent spamming") if EmailService.sent_today?(inputs[:email])
|
|
15
|
+
success! # optional
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
# do the work
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
## Fenced context writes
|
|
24
|
+
|
|
25
|
+
**Status:** proposal, not scheduled. Follows from 005 (step coordination remediation), R-18 and
|
|
26
|
+
R-19 in `specs/005-step-coordination-remediation/research.md`.
|
|
27
|
+
|
|
28
|
+
### The rule
|
|
29
|
+
|
|
30
|
+
A context is written by one process only: the controlled execution that owns it.
|
|
31
|
+
|
|
32
|
+
- Async children (`async_step`, `async_reactor`, map elements) never write their parent. The
|
|
33
|
+
parent holds only the link written at dispatch; each child keeps its own record, and the
|
|
34
|
+
dashboard rebuilds its view from the links. 005 made this true for `async_step` (R-18).
|
|
35
|
+
- Today the rule is a convention: any code with a storage adapter can `SET` any context. This
|
|
36
|
+
proposal makes it a guarantee that the storage enforces.
|
|
37
|
+
|
|
38
|
+
### Why a convention is not enough
|
|
39
|
+
|
|
40
|
+
Holding the lock at some point is not the same as owning the data when the write lands:
|
|
41
|
+
|
|
42
|
+
| Failure | What happens today |
|
|
43
|
+
|---|---|
|
|
44
|
+
| **Paused holder**: a worker holding the `async:<root id>` lock stalls (GC, network, a long rollback wait) past `context_lock_ttl`. The lock expires, a redelivery takes it and makes progress, then the first worker wakes. | The first worker's next `store_context` is a plain `SET`. It overwrites the newer state. Its auto-extender fails silently. Nothing stops the write. |
|
|
45
|
+
| **Stale read**: `Worker#perform` loads the context *before* `resume_execution` takes the context lock. | A previous holder can write between that read and the lock. The new holder then runs on the older snapshot and saves over the newer one. |
|
|
46
|
+
| **Writer outside any execution**: a path writes with no lock at all. | It overwrites whatever the live execution saved. 005 fixed two of these (`StepWorker#save_root`, the map collector's post-resume save); others remain (below). |
|
|
47
|
+
|
|
48
|
+
The first two are the textbook reasons distributed locks need fencing. A lock that only *excludes*
|
|
49
|
+
is not enough. The storage must also *reject writes from anyone who no longer holds the lock*.
|
|
50
|
+
|
|
51
|
+
### Remaining writers outside a controlled execution
|
|
52
|
+
|
|
53
|
+
Found while auditing for 005 R-19:
|
|
54
|
+
|
|
55
|
+
| Writer | Where | Today |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `Worker.record_retries_exhausted` | backend's retries-exhausted hook | Plain read-modify-write, no lock. Marks the context failed even if a redelivery is live. |
|
|
58
|
+
| `Worker#escalate_snooze` | after the executor returned | Runs after the executor released the context lock. |
|
|
59
|
+
| `Worker#handle_deserialization_failure` | before any executor | Writes a failed payload with no lock. |
|
|
60
|
+
| Map collector, failure branch | `Map::Helpers#resume_parent_execution` | Holds `map_collect:<map_id>`, never the parent's `async:` lock. |
|
|
61
|
+
| `Reactor.cancel`, `Reactor.undo` | any process (app code, console) | `find` → mutate → `save_context`: a blind write racing a live worker. |
|
|
62
|
+
| `Reactor#continue` (interrupt resume) | web request / app code | Writes the payload before `resume_execution` takes the lock. |
|
|
63
|
+
| Synchronous `Reactor.run` / `Executor#execute` | caller's process | Never takes the context lock, so its saves are unfenced. |
|
|
64
|
+
|
|
65
|
+
Writers that already belong to the owning execution:
|
|
66
|
+
- `Executor#save_context` and `#checkpoint!` on the worker path;
|
|
67
|
+
- `RetryManager#requeue_job`;
|
|
68
|
+
- `StepExecutor#checkpoint_root!`;
|
|
69
|
+
- `MapStep#prepare_async_execution`.
|
|
70
|
+
|
|
71
|
+
Two writers only *create* a row that nothing has read yet:
|
|
72
|
+
- `Reactor#save_context` at enqueue;
|
|
73
|
+
- `AsyncReactorStep#save`, the child's first row.
|
|
74
|
+
|
|
75
|
+
### Proposal
|
|
76
|
+
|
|
77
|
+
Four parts. Parts 1 and 2 are the mechanism; parts 3 and 4 route the remaining writers through it.
|
|
78
|
+
|
|
79
|
+
#### 1. Ownership-checked, versioned writes (the fence)
|
|
80
|
+
|
|
81
|
+
- Every context row gets a version: a small key next to the blob,
|
|
82
|
+
`reactor:<Class>:context:<id>:v`, with the same TTL as the blob.
|
|
83
|
+
- Every write goes through one Lua script. The script takes:
|
|
84
|
+
- the lock key it claims to hold (`lock:async:<root id>`, or the map element's
|
|
85
|
+
`lock:map_element:<map>:<index>`);
|
|
86
|
+
- the owner token of that lock: the per-execution UUID `Executor#acquire_context_lock`
|
|
87
|
+
already generates;
|
|
88
|
+
- the version the writer loaded.
|
|
89
|
+
- The script writes only if **the lock is held by that token** *and* **the stored version equals
|
|
90
|
+
the loaded one**. It then bumps the version.
|
|
91
|
+
- It returns `ok`, `lost_lock` or `stale`:
|
|
92
|
+
|
|
93
|
+
```lua
|
|
94
|
+
-- KEYS: context_key, version_key, lock_key ARGV: blob, ttl, owner, expected_version
|
|
95
|
+
if redis.call('hget', KEYS[3], 'owner') ~= ARGV[3] then return 'lost_lock' end
|
|
96
|
+
local current = tonumber(redis.call('get', KEYS[2]) or '0')
|
|
97
|
+
if current ~= tonumber(ARGV[4]) then return 'stale' end
|
|
98
|
+
redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2])
|
|
99
|
+
redis.call('set', KEYS[2], current + 1, 'EX', ARGV[2])
|
|
100
|
+
return 'ok'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Why both checks:
|
|
104
|
+
- The **owner check** stops a paused holder from writing at all, even before anyone else has
|
|
105
|
+
written.
|
|
106
|
+
- The **version check** catches a stale read by the current holder, and any path this proposal
|
|
107
|
+
missed.
|
|
108
|
+
|
|
109
|
+
The lock and the data live in the same Redis, and the check runs atomically inside it, so the
|
|
110
|
+
owner comparison does the job of a fencing token. There are no clock or ordering assumptions.
|
|
111
|
+
If locks and contexts ever move to different stores, switch to classic monotonic fencing tokens
|
|
112
|
+
(`INCR` on acquire, highest-token-wins at the store).
|
|
113
|
+
|
|
114
|
+
**Creating a row** uses the same script with `expected_version = 0` and no lock check: a
|
|
115
|
+
create-only `SET NX` equivalent. It covers `Reactor#save_context` at enqueue and an
|
|
116
|
+
`async_reactor` child's first row.
|
|
117
|
+
|
|
118
|
+
#### 2. Lock, then load
|
|
119
|
+
|
|
120
|
+
- A worker takes the context lock **before** it reads the context. The restructure:
|
|
121
|
+
`Worker#perform` acquires `async:<root id>`, then retrieves and deserializes, then hands
|
|
122
|
+
the token and the loaded version to `Executor`. Today `resume_execution` acquires the lock
|
|
123
|
+
mid-way.
|
|
124
|
+
- A synchronous `Reactor.run` takes the same lock for its whole run. It is one `SET NX` and a
|
|
125
|
+
release; today it takes none.
|
|
126
|
+
- Composed children already run inside the root's lock and write through the root. They reuse
|
|
127
|
+
the root's token.
|
|
128
|
+
|
|
129
|
+
#### 3. External actors send requests, not writes
|
|
130
|
+
|
|
131
|
+
Operator actions must not write a live context. Make them requests the owning execution
|
|
132
|
+
consumes, the same pattern as async children writing their own records:
|
|
133
|
+
|
|
134
|
+
- **`Reactor.cancel` / `.undo`**:
|
|
135
|
+
- write a `cancel_requested` key, `reactor:<Class>:context:<id>:cancel`;
|
|
136
|
+
- the owning execution checks it at each step boundary and cancels or undoes itself, under its
|
|
137
|
+
own lock;
|
|
138
|
+
- if no execution is live (the context lock is free), `cancel` takes the lock itself and
|
|
139
|
+
applies the request directly: lock, load, fenced write.
|
|
140
|
+
- **`Reactor#continue`**: take the lock first (bounded wait, because it is a user action), load,
|
|
141
|
+
store the payload, resume. If the lock is held, return "busy — retry" instead of racing.
|
|
142
|
+
- **Worker bookkeeping** (`record_retries_exhausted`, `escalate_snooze`, deserialization
|
|
143
|
+
failure):
|
|
144
|
+
- take the context lock with `wait: 0`;
|
|
145
|
+
- if another process holds it, that process owns the outcome, so log and skip;
|
|
146
|
+
- move `escalate_snooze` inside the executor's lock scope instead of after it.
|
|
147
|
+
- **Map collector**: take the parent's `async:` lock for both branches. The success branch passes
|
|
148
|
+
the token into `resume_execution`, which must accept an already-held lock (re-entrant by owner).
|
|
149
|
+
|
|
150
|
+
#### 4. What a rejected write does
|
|
151
|
+
|
|
152
|
+
A write that comes back `lost_lock` or `stale` raises `Error::ContextOwnershipLost`:
|
|
153
|
+
- The executor stops at once. It saves nothing else, runs no further steps, and releases only
|
|
154
|
+
holds it still owns (every release is already owner-checked).
|
|
155
|
+
- It logs one structured line: `event="ruby_reactor.context.ownership_lost"`, with the context
|
|
156
|
+
id, the lock key and the expected and actual version.
|
|
157
|
+
- The worker does **not** snooze or retry the job. The current owner is responsible for the run.
|
|
158
|
+
- A rejection is a correctness signal, not an error to hide. Expose it in the log and in the
|
|
159
|
+
`:failed_reactor` middleware event, but never mark the context failed: its owner decides the
|
|
160
|
+
outcome.
|
|
161
|
+
|
|
162
|
+
### Constraints and open questions
|
|
163
|
+
|
|
164
|
+
- **Redis Cluster**: the script touches three keys, which must share a hash slot. Tag them with
|
|
165
|
+
the root id: `lock:async:{<root>}`, `reactor:<Class>:context:{<root>}`, and the version key. The
|
|
166
|
+
ordered lock already does this. Changing the key format needs a migration or a dual-read window.
|
|
167
|
+
- **Composed children's own rows**: `Executor#save_context` of a composed child also stores the
|
|
168
|
+
child under its own id. That is a second key in a different slot. Either tag it with the root
|
|
169
|
+
id too, or drop these rows. They are an observability path: the root blob already embeds the
|
|
170
|
+
child.
|
|
171
|
+
- **Inline test mode**: `Sidekiq::Testing.inline!` re-enters the worker inside a frame that holds
|
|
172
|
+
the lock, which is why `acquire_context_lock` is skipped there today. The fence needs the same
|
|
173
|
+
exemption, or re-entrancy by owner token.
|
|
174
|
+
- **Redis failover**: replication is asynchronous, so a failover can lose an acknowledged lock
|
|
175
|
+
or write. This proposal gives single-primary guarantees, the same as today's locks. Stronger
|
|
176
|
+
guarantees mean `WAIT`/`WAITAOF` on the lock write, or a different store. Document; don't solve
|
|
177
|
+
here.
|
|
178
|
+
- **Other records**: Step Result Records and map element results have the same shape:
|
|
179
|
+
- the dispatcher creates the record;
|
|
180
|
+
- the unit's job updates it under its liveness lock;
|
|
181
|
+
- it could be fenced the same way.
|
|
182
|
+
|
|
183
|
+
Nothing races there today, because the liveness lock drops concurrent duplicates. Decide
|
|
184
|
+
whether to fence them now or later.
|
|
185
|
+
|
|
186
|
+
### Rollout
|
|
187
|
+
|
|
188
|
+
- **SemVer**: MINOR, behind `config.fenced_context_writes` (default `false`) for one release.
|
|
189
|
+
Existing contexts have no version key, which is read as `0`.
|
|
190
|
+
- A rolling deploy mixes old workers (plain `SET`, no version bump) with new ones, so the new
|
|
191
|
+
workers would see spurious `stale` results. Enable the flag only after every worker runs the new
|
|
192
|
+
version. Flip the default in the next MAJOR.
|
|
193
|
+
- **Cost**: one `EVAL` in place of one `SET` per save; checkpoints already save once per step.
|
|
194
|
+
One extra `SET NX` and release per synchronous run.
|
|
195
|
+
|
|
196
|
+
### Test plan
|
|
197
|
+
|
|
198
|
+
All against real Redis, per the constitution.
|
|
199
|
+
|
|
200
|
+
1. **Paused holder**:
|
|
201
|
+
- take the lock with a short TTL and let it expire;
|
|
202
|
+
- a second owner takes it and writes;
|
|
203
|
+
- the first owner's write returns `lost_lock`, and the stored blob is the second owner's.
|
|
204
|
+
2. **Stale read**:
|
|
205
|
+
- load at version N;
|
|
206
|
+
- another writer (holding the lock through a handover) bumps the version to N+1;
|
|
207
|
+
- the write returns `stale`.
|
|
208
|
+
3. **Lock, then load**: a redelivery that races a finishing holder always runs on the holder's
|
|
209
|
+
final state. This is the same repro shape as 005 P4.
|
|
210
|
+
4. **One spec per writer** in the tables above, showing it goes through the fence, or takes the
|
|
211
|
+
lock and skips when it is held.
|
|
212
|
+
5. **Cancel of a live run**: the request is consumed at the next step boundary. The running
|
|
213
|
+
execution's saves are never overwritten by the canceller.
|
|
214
|
+
6. **Cluster slot co-location**, if Redis Cluster is supported: every key a script touches
|
|
215
|
+
hashes to one slot.
|
|
216
|
+
|
|
217
|
+
### Size
|
|
218
|
+
|
|
219
|
+
- Storage adapter: one new script and a versioned `retrieve_context`.
|
|
220
|
+
- Executor and `Worker`: lock, then load, and pass the token.
|
|
221
|
+
- Six writer paths rerouted, plus the cancel/undo request key.
|
|
222
|
+
- Docs: `locks_and_semaphores.md`, `background_and_async.md`, and the README's "Durability &
|
|
223
|
+
Recovery" section.
|
|
224
|
+
|
|
225
|
+
## Fan-out map inside a composed child
|
|
226
|
+
|
|
227
|
+
**Status:** bug, already present on main (0.8.1). Raised by the PR #56 review.
|
|
228
|
+
|
|
229
|
+
A root that composes a child, where the child runs a `fan_out` map, never finishes. The root
|
|
230
|
+
stays `running`.
|
|
231
|
+
|
|
232
|
+
- `MapStep#prepare_async_execution` stores the child under its own id.
|
|
233
|
+
- The map collector loads that child blob. Its `root_context` is nil, so the collector resumes
|
|
234
|
+
the child as a standalone execution. On a park, it requeues the child the same way.
|
|
235
|
+
- The child completes in its own blob. Nothing resumes the root, and the root's embedded copy of
|
|
236
|
+
the child stays at the map step.
|
|
237
|
+
|
|
238
|
+
To reproduce: `Root` composes `Child`, and `Child` runs a `map ... fan_out batch_size: 1`. Drain
|
|
239
|
+
the element, collector and `Worker` jobs. The root is still `running`.
|
|
240
|
+
|
|
241
|
+
A literal patch would have the collector write the root's blob. That breaks the single-writer
|
|
242
|
+
rule, because the collector holds only `map_collect:`, never the root's `async:` lock.
|
|
243
|
+
|
|
244
|
+
Direction:
|
|
245
|
+
- The collector records the map's completion on the map's own records, which it already does.
|
|
246
|
+
- The collector requeues the ROOT worker and does not resume the child itself.
|
|
247
|
+
- On re-entry, `MapStep` adopts the finished map: collect the results, don't dispatch again.
|
|
248
|
+
|
|
249
|
+
Spec: compose → fan-out map, with and without a park after the map. The root completes either
|
|
250
|
+
way.
|