ruby_reactor 0.6.0 → 0.7.0

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +114 -1
  5. data/CLAUDE.md +5 -0
  6. data/README.md +131 -16
  7. data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
  8. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
  10. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
  11. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
  13. data/lib/ruby_reactor/async_waiter.rb +113 -0
  14. data/lib/ruby_reactor/configuration.rb +30 -1
  15. data/lib/ruby_reactor/context.rb +21 -0
  16. data/lib/ruby_reactor/context_serializer.rb +2 -0
  17. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  18. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  19. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  20. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  21. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  22. data/lib/ruby_reactor/dsl/lockable.rb +1 -1
  23. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  24. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  25. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  26. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  27. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  28. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  29. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  30. data/lib/ruby_reactor/executor/ordered_lock_support.rb +2 -2
  31. data/lib/ruby_reactor/executor/result_handler.rb +12 -1
  32. data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
  33. data/lib/ruby_reactor/executor/step_executor.rb +58 -11
  34. data/lib/ruby_reactor/executor.rb +162 -8
  35. data/lib/ruby_reactor/lock.rb +19 -0
  36. data/lib/ruby_reactor/map/element_executor.rb +6 -1
  37. data/lib/ruby_reactor/map/helpers.rb +1 -1
  38. data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
  39. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  40. data/lib/ruby_reactor/open_telemetry.rb +1 -1
  41. data/lib/ruby_reactor/reactor.rb +25 -2
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
  43. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  44. data/lib/ruby_reactor/rspec/test_subject.rb +66 -11
  45. data/lib/ruby_reactor/semaphore.rb +10 -0
  46. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  47. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  48. data/lib/ruby_reactor/step/map_step.rb +1 -1
  49. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  50. data/lib/ruby_reactor/step_worker.rb +260 -0
  51. data/lib/ruby_reactor/storage/adapter.rb +22 -1
  52. data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
  53. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  54. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  55. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  56. data/lib/ruby_reactor/sweeper.rb +7 -1
  57. data/lib/ruby_reactor/sweeper_job.rb +1 -0
  58. data/lib/ruby_reactor/template/result.rb +151 -5
  59. data/lib/ruby_reactor/version.rb +1 -1
  60. data/lib/ruby_reactor/web/api.rb +94 -16
  61. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
  62. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +22 -0
  63. data/lib/ruby_reactor/web/public/index.html +2 -2
  64. data/lib/ruby_reactor/worker.rb +53 -3
  65. data/lib/ruby_reactor.rb +24 -3
  66. data/specs/001-background-async-steps/checklists/requirements.md +39 -0
  67. data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
  68. data/specs/001-background-async-steps/data-model.md +117 -0
  69. data/specs/001-background-async-steps/plan.md +168 -0
  70. data/specs/001-background-async-steps/quickstart.md +102 -0
  71. data/specs/001-background-async-steps/research.md +150 -0
  72. data/specs/001-background-async-steps/spec.md +146 -0
  73. data/specs/001-background-async-steps/tasks.md +271 -0
  74. data/specs/active_job.md +1 -1
  75. metadata +27 -3
  76. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  77. data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
@@ -0,0 +1,146 @@
1
+ # Feature Specification: Background Execution & Real Async Steps
2
+
3
+ **Feature Branch**: `001-background-async-steps`
4
+
5
+ **Created**: 2026-08-16
6
+
7
+ **Status**: Draft
8
+
9
+ **Input**: User description: "I want to rename to fix some confusing naming and create a feature for proper async steps. The current async naming doesn't makes much sense, it should be renamed to background... The other change is to for real do async steps and reactors. Understanding an async step running in an independent process, sidekiq worker in this case..."
10
+
11
+ ## Clarifications
12
+
13
+ ### Session 2026-08-16
14
+
15
+ - Q: When an `async_step`'s independent worker fails and nothing in the parent reactor is waiting on its result, should the parent's saga compensation trigger automatically? → A: No. `async_step` and `async_reactor` share the same compensation model — a failure only surfaces, and compensation only happens, if a later step explicitly reads that step's/reactor's result via `result(:name)` and itself decides to return `Failure`. An async unit's own failure never automatically compensates its parent.
16
+ - Q: When a step still running in the calling process reaches a `result(:name)` reference that isn't ready yet, how should the wait be implemented? → A: The calling thread blocks in a bounded poll loop against the durable store until the result appears or the timeout elapses. The reactor's existing synchronous `.run` call contract is unchanged — waiting never implicitly hands remaining execution off to a worker.
17
+ - Q: How should the FR-005 wait timeout be configured? → A: A single library-wide default set via the existing global `Configuration` is sufficient for v1; no per-reactor or per-`result()` override syntax is introduced by this feature.
18
+
19
+ ### Session 2026-08-20
20
+
21
+ - Q: Is a tight poll loop good enough for the FR-005 wait, or should completion be pushed to the waiter? → A: Revised (supersedes the "bounded poll loop" mechanism from the 2026-08-16 session; the blocking-on-the-calling-thread contract and the timeout bound are unchanged): the primary wake-up is a completion notification — the finishing worker durably writes the result record **first**, then publishes a completion signal; the waiter subscribes **before** checking the record (closing the completed-before-subscribe race), then blocks on the notification with a coarse periodic fallback re-check of the record (notifications are at-most-once and may be missed on reconnect — the durable record remains the sole source of truth, so a missed signal degrades to fallback-poll latency, never to a wrong answer). The FR-005 timeout still bounds the total wait.
22
+ - Q: Should locks be reentrant across the `async_reactor` boundary, to avoid deadlocks when parent and child declare the same lock key? → A: No. Owner-based reentrancy stays as-is for `compose` (sequential, same logical thread of control), but an `async_reactor` child runs concurrently with its parent — sharing the lock owner would put both inside the critical section at once, silently breaking mutual exclusion, which is worse than the deadlock. Instead the deadlock is made impossible to hit silently: at dispatch time, if the child's lock key collides with a lock the parent execution currently holds, the dispatch step fails immediately with a clear error (see FR-015) rather than guaranteeing a wait-timeout later.
23
+
24
+ ### Session 2026-08-22
25
+
26
+ - Q: Should the hand-off point be expressible relative to the *following* step as well, not just the preceding one? → A: Yes — `background before: :third` is supported alongside `background after: :second`. The two are complementary ways of naming a single cut point: `after:` pins the named step as the **last** step to run in the calling process; `before:` pins it as the **first** step to run in the worker. In a linear reactor the two coincide (`after: :second` ≡ `before: :third` when `:third` immediately follows `:second`); in a branching workflow they pin different steps, which is precisely when the author needs to choose which guarantee they actually want. Still exactly one hand-off point per reactor: one `background` declaration carrying exactly one of the two keys (FR-002). `background before: <the reactor's first step>` is legal and meaningful — it moves every step body to the worker while still validating inputs in the calling process, so an invalid-input failure surfaces synchronously to the caller instead of inside a worker (which is what distinguishes it from whole-reactor async).
27
+
28
+ ## User Scenarios & Testing *(mandatory)*
29
+
30
+ ### User Story 1 - Unambiguous background hand-off (Priority: P1)
31
+
32
+ A reactor author currently marks a step `async: true` to hand execution off to a background worker, but if several steps carry that flag only the first one actually takes effect — the rest are silently ignored. The author wants one clear, reactor-level declaration that names the hand-off point, with no ambiguity about which point is the real one — and wants to express it from whichever side reads more naturally for the workflow: "everything after this step runs in the background" (`after:`) or "this step and everything past it runs in the background" (`before:`).
33
+
34
+ **Why this priority**: This is a correctness and clarity fix on an existing, already-shipped capability. It removes a footgun that silently produces wrong behavior today, and it is the foundation the other stories build on.
35
+
36
+ **Independent Test**: Define a reactor with `step :first`, `step :second`, `background after: :second`, `step :third`. Confirm `:first` and `:second` run in the calling process, and `:third` runs after hand-off to a worker process. Repeat with the same reactor declared as `background before: :third` and confirm identical behavior in this linear case. Confirm the reactor definition has no remaining per-step async flag.
37
+
38
+ **Acceptance Scenarios**:
39
+
40
+ 1. **Given** a reactor with `background after: :second` declared once, **When** the reactor executes, **Then** steps before and including `:second` run in the current process and steps after `:second` run via an independent worker.
41
+ 2. **Given** a reactor with `background before: :third` declared once, **When** the reactor executes, **Then** `:third` and every step not yet executed at that moment run via an independent worker, and `:third` itself never runs in the calling process.
42
+ 3. **Given** a reactor definition that still uses the old per-step `async: true` flag, **When** the reactor class is loaded/defined, **Then** the system raises a clear definition-time error identifying the deprecated syntax instead of silently accepting it.
43
+ 4. **Given** a reactor with a `background` declaration naming a step that is not defined in that reactor (via either `after:` or `before:`), **When** the reactor class is defined, **Then** the system raises a clear definition-time error.
44
+ 5. **Given** a `background` declaration carrying both `after:` and `before:`, or carrying neither, **When** the reactor class is defined, **Then** the system raises a clear definition-time error — a hand-off point must be named exactly once, from exactly one side.
45
+
46
+ ---
47
+
48
+ ### User Story 2 - Async steps that truly run independently (Priority: P2)
49
+
50
+ A reactor author wants a single step (e.g. `send_email`) to execute in an independent worker process while the rest of the reactor keeps running in the current process. A later step in the same reactor needs to consume that async step's output once it is ready.
51
+
52
+ **Why this priority**: This is the first genuinely new capability — running one unit of work off the critical path while the reactor continues — and it is the dependency other in-reactor steps rely on via `result(...)`. Compensation for an `async_step`'s failure is opt-in via that same `result(...)` read, not automatic (see Clarifications).
53
+
54
+ **Independent Test**: Define a reactor with `async_step :send_email`, followed by `step :do_something_same_thread`, followed by `step :check_email` that declares `argument :email, result(:send_email)`. Confirm `:do_something_same_thread` can complete without waiting on `:send_email`, and `:check_email` receives the correct, fully-formed result of `:send_email` once it becomes available.
55
+
56
+ **Acceptance Scenarios**:
57
+
58
+ 1. **Given** a reactor with `async_step :send_email` followed by a same-process step with no dependency on it, **When** the reactor executes, **Then** the same-process step is not blocked waiting for `:send_email` to finish.
59
+ 2. **Given** a later step that declares `argument :email, result(:send_email)`, **When** that step is reached before `:send_email` has completed, **Then** the reactor waits for `:send_email`'s durable result before running the step, and injects the deserialized result as the argument.
60
+ 3. **Given** an `async_step` that raises/fails in its independent worker, **When** the failure occurs and no later step reads its result, **Then** the owning reactor's saga compensation is NOT automatically triggered; the failure is recorded/logged only. Compensation only happens if a later step reads `result(:send_email)` and, on seeing a failure, itself returns `Failure`.
61
+
62
+ ---
63
+
64
+ ### User Story 3 - Fire-and-forget async reactors (Priority: P3)
65
+
66
+ A reactor author wants to kick off an entire nested reactor (e.g. `create_profile`) to run independently, tracked for observability but explicitly outside the parent reactor's compensation graph — its failure should never automatically roll back the parent. For cases where the parent does care about the outcome (e.g. `create_account`), a later step should be able to read the async reactor's result and decide for itself whether to trigger compensation.
67
+
68
+ **Why this priority**: This extends story 2's independent-execution model from a single step to a whole nested reactor, and is the most involved because it touches the saga/compensation boundary between parent and child.
69
+
70
+ **Independent Test**: Define a reactor with `async_reactor :create_profile` and no step referencing its result — confirm a forced failure of `create_profile` does not compensate the parent. Separately, define `async_reactor :create_account` followed by `step :verify_all` with `argument :account, result(:create_account)` and a `run` block that inspects success/failure — confirm the block receives the account reactor's outcome and can choose to return `Failure` (triggering parent compensation) or `Success`.
71
+
72
+ **Acceptance Scenarios**:
73
+
74
+ 1. **Given** an `async_reactor :create_profile` with no downstream step reading its result, **When** `create_profile`'s execution fails, **Then** the parent reactor's already-completed steps are not compensated as a result of that failure.
75
+ 2. **Given** an `async_reactor :create_account` and a later step that declares `argument :account, result(:create_account)`, **When** the parent reaches that step before `create_account` has finished, **Then** the parent waits for `create_account`'s durable result before running the step.
76
+ 3. **Given** the `run` block of `:verify_all` inspecting `args[:account]` and explicitly returning `Failure`, **When** that block executes, **Then** the parent reactor's compensation is triggered as it would be for any other step returning `Failure`.
77
+ 4. **Given** an `async_reactor` execution, **When** it starts, **Then** its execution id is linked to the parent reactor's execution for traceability (e.g. in logs/dashboard), without adding it to the parent's compensation graph.
78
+
79
+ ---
80
+
81
+ ### Edge Cases
82
+
83
+ - What happens when a reactor declares `background` more than once (in any combination of `after:`/`before:`)? → System MUST reject the reactor definition at definition time with a clear error (see FR-002); only a single hand-off point is permitted.
84
+ - What happens when `background before:` names the reactor's very first step? → Legal and meaningful: every step body runs in the worker, but inputs are still validated in the calling process, so an invalid-input failure surfaces synchronously to the caller. This is the deliberate difference from whole-reactor async (which validates inside the worker), so it is not rejected as redundant.
85
+ - What happens when the step named by `background` never executes (skipped by a `where`/guard condition, or the reactor fails earlier)? → The hand-off never triggers and the reactor completes or fails entirely in the calling process. Identical for both `after:` and `before:`; no step is ever stranded, because the hand-off only ever moves *remaining* work.
86
+ - What happens when a step waiting on `result(:async_step_name)` never receives a result because the independent worker crashed or never ran? → Governed by the wait policy in FR-005.
87
+ - What happens when `async_step`/`async_reactor` is declared but nothing ever references its result? → It still executes to completion in its independent worker; no waiting occurs anywhere in the parent. The parent may therefore reach a terminal state (`completed`/`failed`) with that work still pending — this is the declared fire-and-forget model, not an inconsistency, and the parent's terminal status makes no claim about the async unit's outcome.
88
+ - What happens when the parent context is TTL-expired or swept before an unread async unit's worker runs? → Dispatch extends the parent context's retention window so it outlives the dispatched unit (FR-018); if the context is nevertheless gone, the worker records a failure outcome for that unit and logs it (FR-012) instead of raising or retrying forever. Nothing waits on it by definition — an unread unit has no reader — so the only requirement is that the failure is observable.
89
+ - What happens when an `async_step` is declared after a `background after:` hand-off point? → The async step's work is already off the calling process by virtue of the hand-off; it still runs in its own independent worker and follows the same result/wait/failure semantics.
90
+ - What happens if a reactor is recovered/resumed (crash recovery) while one of its `async_step`/`async_reactor` results is still pending? → Recovery MUST re-attach to the still-pending async work rather than re-dispatching it, consistent with existing durability/recovery guarantees.
91
+ - What happens when `returns` names an `async_step` or `async_reactor`? → Rejected at definition time with a clear error: the reactor's return value must come from a same-process step. A reactor that needs an async unit's outcome as its return value must read it through an ordinary step (`argument :x, result(:name)`) and return that step instead. (Allowing it would make reactor completion itself block or return an absent value — out of scope for v1.)
92
+ - What happens when a reactor marked with the whole-reactor async flag also declares `background` (either form)? → Rejected at definition time: the entire reactor already runs in a worker, so a hand-off point inside it is meaningless and would otherwise be silently ignored — the exact silent-no-op failure mode this feature exists to eliminate.
93
+ - What happens when an awaited `async_reactor`'s child pauses at an interrupt step instead of finishing? → Paused is not terminal, so the reader's wait continues and, if the child is not resumed within the bound, ends in the FR-005 timeout failure. Documented behavior, not an error: an operator resumes the child (existing interrupt mechanism) and a retry of the reading step then finds the result.
94
+ - What happens when an `async_reactor`'s child declares the same exclusive-lock key its parent currently holds? → The dispatch step fails immediately with a clear error (FR-015). Without this guard the pattern is a guaranteed deadlock-until-timeout: the parent holds the lock for its whole execution (including any wait on the child's result) while the child snoozes waiting for that same lock. A child that genuinely needs to share the parent's critical section belongs in `compose`, not `async_reactor`.
95
+ - What happens when a reactor that already passed its `background` hand-off pauses at an interrupt and is later resumed? → `continue` runs the remaining steps inline in the delivering process — the hand-off is edge-triggered on reaching its named step and never re-fires. A reactor that needs the resume itself to go to a worker declares `interrupt :name, resume: :background`, which validates the payload synchronously in the caller and enqueues the remainder (see contracts/public-dsl.md).
96
+ - What happens when the completion notification is lost (waiter reconnecting, signal published before subscribe)? → Nothing is lost but latency: the durable record is written before the signal is published, the waiter checks the record after subscribing, and a coarse fallback re-check catches any missed signal within the FR-005 bound.
97
+
98
+ ## Requirements *(mandatory)*
99
+
100
+ ### Functional Requirements
101
+
102
+ - **FR-001**: System MUST provide a reactor-level `background` declaration that marks a single hand-off point, after which all remaining steps execute via an independent worker process. The point MUST be expressible from either side: `background after: <step_name>` (the named step is the last to run in the calling process) or `background before: <step_name>` (the named step is the first to run in the worker, and never runs in the calling process).
103
+ - **FR-002**: System MUST reject, at reactor-definition time, any reactor that declares `background` more than once, that names a step not defined in that reactor, or whose `background` declaration carries both `after:` and `before:` or neither — a hand-off point must be named exactly once, from exactly one side.
104
+ - **FR-003**: System MUST remove the per-step hand-off `async` flag/DSL method entirely — both on regular steps and on `compose` steps (which carry the same flag) — and reactors still using it MUST fail at definition time with an error that names the deprecated syntax and points to its replacement (`background`, `async_step`, `async_reactor`). The `async` option inside a `map` block is NOT covered by this removal: it is a map-internal element-dispatch mode, a different mechanism with different semantics, and stays as-is.
105
+ - **FR-004**: System MUST provide an `async_step` declaration whose work executes in an independent worker process, without blocking other same-process steps that do not depend on it.
106
+ - **FR-005**: When any step declares `argument :x, result(:name)` referencing an `async_step` or `async_reactor` named `:name` that has not yet completed, System MUST block the executing process/thread — without implicitly changing the reactor's synchronous execution mode — until the durable result is available or a single library-wide configurable timeout (set via the existing global `Configuration`, no per-reactor or per-reference override in this feature) is exceeded, then fail that step with a clear timeout error. The wait MUST be notification-driven (completion signal published by the finishing worker after the durable record is written; waiter subscribes before checking the record) with a coarse periodic fallback re-check of the durable record, so that the common case completes with near-zero added latency and a missed notification degrades only to fallback-poll latency, never to a wrong or lost result (see Clarifications, Session 2026-08-20).
107
+ - **FR-006**: System MUST durably persist `async_step` and `async_reactor` outcomes using the existing serialization mechanism — an `async_reactor`'s outcome through the nested execution's own persisted state, an `async_step`'s outcome in a durable per-step record — so that a dependent step deserializes the same result shape a same-process step would produce.
108
+ - **FR-007**: System MUST provide an `async_reactor` declaration that dispatches a nested reactor to run in an independent worker process.
109
+ - **FR-008**: System MUST link an `async_reactor`'s execution id to the parent reactor's execution/context for traceability (logs, dashboard) without adding that nested execution to the parent's compensation graph. This link MUST be recorded on the parent's own context (in the same structural location the system already uses to reference other child executions, e.g. `compose`/`map`), not only in an external log line, so it survives independently of logging configuration and can be reloaded/queried later.
110
+ - **FR-009**: System MUST NOT automatically trigger parent-reactor compensation when an `async_reactor`'s execution fails; the parent's own steps MUST remain the only place compensation for that parent is decided.
111
+ - **FR-010**: A step in the parent reactor MUST be able to reference an `async_reactor`'s result via `result(:name)` the same way it references an `async_step`'s result, subject to the wait/timeout policy in FR-005, and inspect that result's success/failure to decide whether to return `Success` or `Failure` itself.
112
+ - **FR-011**: If an `async_step` fails in its independent worker, System MUST NOT automatically trigger the owning reactor's saga compensation — the same fire-and-forget compensation model as `async_reactor` (see FR-009). Compensation for an `async_step` failure MUST only happen when a later step explicitly reads its result via `result(:name)` and itself decides to return `Failure`.
113
+ - **FR-012**: System MUST emit structured, machine-parseable log entries (key=value or JSON) for: background hand-off, `async_step` dispatch and completion, and `async_reactor` dispatch and completion. Every entry MUST carry reactor name, step name, and execution id. Entries recording a **failure** — an `async_step` failing in its worker, an `async_reactor` child reaching a failed terminal state, or a dispatch-time failure — MUST additionally carry the failure reason and the unit's redacted inputs, because under the fire-and-forget compensation model (FR-009, FR-011) such a failure may never surface anywhere else.
114
+ - **FR-017**: When a reactor execution is recovered or resumed while an `async_step` or `async_reactor` it dispatched is still pending, System MUST re-attach to that pending work rather than re-dispatching it — the dispatch path MUST consult the durable dispatch marker (the Step Result Record's `dispatched` status for an `async_step`; the recorded `:async_reactor_ref` execution id for an `async_reactor`) before enqueueing, and skip enqueue when one is already present. Re-attaching means the step is treated as already dispatched: readers wait on the existing record exactly as they would have before the crash.
115
+ - **FR-018**: A parent reactor MUST be permitted to reach a terminal state while `async_step`/`async_reactor` work it dispatched is still pending — that is the declared fire-and-forget model, not an error — and the pending work MUST still be able to complete. Dispatch MUST therefore extend the parent context's retention window so it outlives the dispatched unit, and a worker that nonetheless finds its parent context missing (expired or swept) MUST record a failure outcome for that unit and log it per FR-012, rather than raising an unhandled error or retrying indefinitely.
116
+ - **FR-013**: System MUST document the breaking change (removal of the per-step `async` flag) with a migration note, per the project's semantic-versioning policy for public API changes.
117
+ - **FR-014**: The existing web dashboard MUST render `async_step` and `async_reactor` as distinct, recognizable step types (not fall back to a generic/unknown type), and MUST let an operator drill into an `async_reactor`'s linked execution the same way it already lets them drill into a `compose`d or `map`ped child, using the FR-008 link recorded on the parent's context.
118
+ - **FR-015**: Exclusive-lock ownership MUST NOT be shared across the `async_reactor` boundary (parent and child run concurrently — shared ownership would break mutual exclusion). Instead, at dispatch time, if the child reactor declares an exclusive lock (or a single-slot semaphore, which has the same circular-wait shape) whose resolved key equals one currently held by the dispatching execution, the dispatch step MUST fail immediately with an error naming both the lock key and the parent/child reactors — never proceed into a wait that can only end in timeout. Lock reentrancy for `compose` (same logical thread of control) is unchanged.
119
+ - **FR-016**: Dispatching an `async_reactor` MUST apply the same pre-enqueue safeguards as a top-level asynchronous reactor run — child input validation and, where the child declares ordered locking, enqueue-time ordering assignment — so a child never starts with invalid inputs or silently loses ordering guarantees. A dispatch-time validation failure fails the dispatching step itself (normal saga handling in the parent), distinct from a failure during the child's independent execution (which follows FR-009).
120
+
121
+ ### Key Entities
122
+
123
+ - **Background Hand-off Point**: The single `background` declaration in a reactor that marks where remaining execution moves from the calling process to an independent worker, named from exactly one side — `after: <step_name>` (that step is the last to run in the calling process) or `before: <step_name>` (that step is the first to run in the worker).
124
+ - **Async Step**: A step whose unit of work executes in an independent worker process, producing a durably stored result that other steps in the same reactor may depend on and wait for.
125
+ - **Async Reactor**: A nested reactor execution dispatched to an independent worker, linked to its parent's context by execution id (in the same structural location `compose`/`map` already use, so the dashboard can render and drill into it) for traceability, but excluded from the parent's automatic compensation graph.
126
+ - **Step Result Record**: The durable, serialized record of a completed async step's output, keyed for lookup by any step that references it via `result(:name)`. (An async reactor's output is reached through that nested execution's own durable record rather than a separate copy.)
127
+
128
+ ## Success Criteria *(mandatory)*
129
+
130
+ ### Measurable Outcomes
131
+
132
+ - **SC-001**: Reactor authors can express a background hand-off with exactly one declarative line per reactor, and zero reactor definitions in the codebase reference the old per-step async flag after migration.
133
+ - **SC-002**: In a reactor with an `async_step`, same-process steps that do not depend on it complete without waiting on it, while a step that explicitly depends on its result always receives the correct, fully-deserialized result once available.
134
+ - **SC-003**: An `async_step`'s or `async_reactor`'s failure never causes the parent reactor's completed steps to be rolled back unless a later step explicitly reads its result and triggers compensation itself — verified by failure-injection specs covering both paths (failure with no reader, failure with a reader that propagates) with zero unintended rollbacks.
135
+ - **SC-004**: 100% of reactor definitions still written against the old per-step async flag fail fast at definition time with an actionable error, rather than silently running with incorrect behavior.
136
+ - **SC-005**: A step waiting on an async result is never left hanging indefinitely — it either receives the result or a clear timeout failure within the configured bound, in all tested scenarios.
137
+ - **SC-006**: An operator viewing a running reactor in the dashboard can identify every `async_step` and `async_reactor` it launched and, for `async_reactor`, open the linked execution — with zero manual log-correlation required.
138
+
139
+ ## Assumptions
140
+
141
+ - The project's existing pluggable job backend (Sidekiq or ActiveJob, selected via `configuration.async_router`) remains the independent worker mechanism for background hand-off, `async_step`, and `async_reactor` dispatch — this feature does not hardcode Sidekiq and must work identically on either configured backend.
142
+ - Redis remains the durable store for step/reactor results, consistent with the project's existing durability model.
143
+ - "Independent process" means a separate worker job (a new Sidekiq or ActiveJob job, per the configured backend), not a thread or fiber within the originating process.
144
+ - This is a breaking (MAJOR, per SemVer) change to the public step DSL; updating `README.md`, `CHANGELOG.md`, and `demo_app/` to the new syntax is required delivery work but not itself a testable acceptance criterion of this spec.
145
+ - The wait timeout for `result()` references to async work (FR-005) is a single library-wide value exposed via the existing global `Configuration`; per-reactor or per-reference overrides are out of scope for this feature. Its default is 30 seconds.
146
+ - Verification of async orchestration (a caller blocked on `result()` while an independent worker completes the work) runs against a **real** worker consuming a **real** queue — a live `sidekiq` process against the test Redis for the Sidekiq backend, and the ActiveJob `:async` adapter for the ActiveJob backend — per constitution Principle III. In-memory queue fakes (`Sidekiq::Testing.fake!`, ActiveJob `:test`) remain in use only for unit-level assertions that do not involve a blocked caller (definition-time guards, dispatch shape, read semantics on an already-completed record).
@@ -0,0 +1,271 @@
1
+ ---
2
+
3
+ description: "Task list for Background Execution & Real Async Steps"
4
+ ---
5
+
6
+ # Tasks: Background Execution & Real Async Steps
7
+
8
+ **Input**: Design documents from `/specs/001-background-async-steps/`
9
+
10
+ **Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/public-dsl.md, quickstart.md
11
+
12
+ **Tests**: Test tasks ARE included and are written first. Two reasons: the project constitution makes RSpec Red-Green-Refactor non-negotiable (Principle III, against real Redis — no mocked Redis/Sidekiq for integration paths), and the feature request explicitly asked for tests plus `demo_app` examples driven only by the gem's built-in spec helpers.
13
+
14
+ **Organization**: Tasks are grouped by user story so each can be implemented, tested, and shipped independently.
15
+
16
+ ## Format: `[ID] [P?] [Story] Description`
17
+
18
+ - **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
19
+ - **[Story]**: US1 / US2 / US3 (maps to spec.md user stories)
20
+ - Exact file paths included in every task
21
+
22
+ ## Path Conventions
23
+
24
+ Single-project Ruby gem: `lib/ruby_reactor/**` (implementation), `spec/**` (gem specs), plus the bundled `demo_app/` Rails example, `documentation/`, and `gui/` (dashboard frontend).
25
+
26
+ ---
27
+
28
+ ## Phase 1: Setup (Shared Infrastructure)
29
+
30
+ **Purpose**: Small additions every later phase leans on. No behavior change yet.
31
+
32
+ - [X] T001 [P] Add `RubyReactor::Error::AsyncWaitTimeoutError` in `lib/ruby_reactor/error/async_wait_timeout_error.rb` and require it from `lib/ruby_reactor.rb` (raised when an FR-005 wait exceeds its bound)
33
+ - [X] T002 [P] Add `RubyReactor::Error::DeprecatedDslError` in `lib/ruby_reactor/error/deprecated_dsl_error.rb` (subclass of `Error::ValidationError` so existing rescues still catch it) and require it from `lib/ruby_reactor.rb`
34
+ - [X] T003 [P] Add `Configuration#async_wait_timeout` (memoized reader + `attr_writer`, matching the `context_ttl`/`context_lock_ttl` idiom) with a documented default in `lib/ruby_reactor/configuration.rb`
35
+ - [X] T004 [P] Add a shared RSpec context that runs an example group against both backends (Sidekiq fake mode and ActiveJob `:test` adapter) in `spec/support/async_backends.rb`, so every new async spec asserts backend-agnosticism per spec.md Assumptions
36
+
37
+ ---
38
+
39
+ ## Phase 2: Foundational (Blocking Prerequisites)
40
+
41
+ **Purpose**: Remove the old per-step `async` flag and repair everything that reads it, plus build the notified-wait core shared by US2 and US3.
42
+
43
+ **⚠️ CRITICAL**: Removing `StepConfig#async?` breaks `Web::API`, `TestSubject`, and existing fixtures at once. This phase must land as one coherent unit before any user story work begins.
44
+
45
+ ### Tests (write first, confirm failing)
46
+
47
+ - [X] T005 [P] Spec: `async true` inside a `step` block raises `DeprecatedDslError` at class-definition time (not run time), with a message naming `background after:`/`before:`, `async_step`, `async_reactor` — `spec/ruby_reactor/dsl/deprecated_async_flag_spec.rb`
48
+ - [X] T006 [P] Spec: `async true` inside a `compose` block raises the same definition-time error, and the `map`-internal `async` option still works untouched — `spec/ruby_reactor/dsl/deprecated_async_flag_spec.rb`
49
+ - [X] T007 [P] Spec: `AsyncWaiter` returns immediately when the durable target is already terminal, wakes on a published signal, still resolves when no signal is ever published (fallback re-check), and raises `AsyncWaitTimeoutError` at the bound — `spec/ruby_reactor/async_waiter_spec.rb`
50
+
51
+ ### Implementation
52
+
53
+ - [X] T008 Remove the `async` DSL method and `@async`/`async?` from `StepBuilder` and `StepConfig`, raising `DeprecatedDslError` from a retained `async` method stub, in `lib/ruby_reactor/dsl/step_builder.rb` (FR-003)
54
+ - [X] T009 Remove the `async` DSL method from `ComposeBuilder` (raise `DeprecatedDslError`) and drop the `async:` key from its built step config in `lib/ruby_reactor/dsl/compose_builder.rb` (FR-003; it set the same `StepConfig` flag — see research.md decision 1)
55
+ - [X] T010 Add `:async_step_ref` / `:async_reactor_ref` to the documented `composed_contexts` `type:` union (alongside `:composed` and `:map_ref`) in `lib/ruby_reactor/context.rb` — comment-level convention plus any shared constants; no serialization change
56
+ - [X] T011 Implement `RubyReactor::AsyncWaiter` in `lib/ruby_reactor/async_waiter.rb`: subscribe-first, then check the durable target, then block on the signal with a coarse fallback re-check, bounded by `Configuration#async_wait_timeout`; takes a channel and a terminal-check callable so US2 and US3 share one core (research.md decision 4)
57
+ - [X] T012 Add dedicated-connection subscribe support to `lib/ruby_reactor/storage/redis_adapter.rb` so `subscribe` never blocks the shared client (`SUBSCRIBE` puts a connection into subscriber mode); keep the existing `Storage::Adapter#subscribe`/`#publish` interface signatures intact
58
+ - [X] T013 Update `Web::API.determine_step_type` (drop the `config.async?` branch) and `build_structure` (drop the per-step `async:` field) in `lib/ruby_reactor/web/api.rb` so the dashboard survives the flag removal
59
+ - [X] T014 Update `TestSubject#prepare_execution_class` (`force_sync` branch) and `#apply_mock_interceptor` to stop mutating the removed `@async` step flag in `lib/ruby_reactor/rspec/test_subject.rb` (research.md decision 6)
60
+ - [X] T015 Migrate every existing gem fixture and spec that uses the removed per-step flag to the new DSL across `spec/support/**` and `spec/ruby_reactor/**`, and run `bundle exec rspec` to confirm no other call sites remain
61
+
62
+ **Checkpoint**: Old flag is gone, suite is green, notified-wait core exists. User stories can begin.
63
+
64
+ ---
65
+
66
+ ## Phase 3: User Story 1 - Unambiguous background hand-off (Priority: P1) 🎯 MVP
67
+
68
+ **Goal**: Replace the silently-ambiguous per-step `async` flag with a single reactor-level `background` declaration that hands off all remaining steps to a worker, with the cut point nameable from either side (`after:` = named step is last in-process; `before:` = named step is first in the worker).
69
+
70
+ **Independent Test**: Define a reactor with `step :first`, `step :second`, `background after: :second`, `step :third`; confirm `:first`/`:second` run in the calling process, `.run` returns an `DispatchResult`, and `:third` runs in the dispatched job. Redeclare the same reactor as `background before: :third` and confirm identical behavior in this linear case. Confirm the deprecated flag raises at definition time.
71
+
72
+ ### Tests for User Story 1 (write first, confirm failing)
73
+
74
+ - [X] T016 [P] [US1] Fixture reactors for background hand-off in `spec/support/reactors/background_reactors.rb`: linear `after:` and `before:` pairs, a **branching** reactor where the two forms pin different steps, plus the invalid variants (duplicate declaration, unknown step, both keys, neither key, whole-reactor-async conflict, `returns` conflict)
75
+ - [X] T017 [P] [US1] Spec: `after:` hand-off boundary — steps up to and including the named step run in the calling process, `.run` returns an `DispatchResult`, remaining steps run in the drained job, and compensation for a worker-side failure behaves exactly as for a same-process failure (US1 scenario 1, SC-001) — `spec/ruby_reactor/dsl/reactor_background_spec.rb`
76
+ - [X] T018 [P] [US1] Spec: `before:` hand-off boundary — the named step never executes in the calling process and runs in the worker; the linear `after:`/`before:` pair from T016 produces identical outcomes; the branching fixture shows each form pinning its own named step (US1 scenario 2) — `spec/ruby_reactor/dsl/reactor_background_spec.rb`
77
+ - [X] T019 [P] [US1] Spec: definition-time guards — duplicate `background`, an unknown step name via either key, both `after:` and `before:` supplied, neither supplied, and `background` combined with whole-reactor `async true` each raise (US1 scenarios 4-5, FR-002, SC-004) — `spec/ruby_reactor/dsl/reactor_background_spec.rb`
78
+ - [X] T020 [P] [US1] Spec: trigger edge behavior — hand-off is keyed to *reaching* the named step, not to lexical position (declaration may sit anywhere in the class body); in a DAG with a parallel branch, steps already ready-and-executed before the trigger ran in the calling process; a named step skipped by a `where`/guard never triggers hand-off and the run completes in-process; and the hand-off never re-triggers inside the worker — `spec/ruby_reactor/dsl/reactor_background_spec.rb`
79
+
80
+ ### Implementation for User Story 1
81
+
82
+ - [X] T021 [US1] Add the `background(after: nil, before: nil)` class macro in `lib/ruby_reactor/dsl/reactor.rb`, storing a normalized `{ mode: :after|:before, step: }` hand-off point behind one reader, plus its definition-time guards (single declaration, known step, exactly one of the two keys, no whole-reactor-`async` combination) (FR-001, FR-002)
83
+ - [X] T022 [US1] Re-key the hand-off trigger in `lib/ruby_reactor/executor/step_executor.rb` from the removed per-step `async?` to the reactor's hand-off point — a **post-execution** check when `mode == :after` (fire once the named step's result is recorded) and a **pre-execution** check when `mode == :before` (fire instead of running the named step, leaving its graph node incomplete for the worker) — reusing the existing `handle_async_step` body (checkpoint-before-enqueue → `async_router.perform_async` → `DispatchResult`) unchanged for both, inside the existing `inline_async_execution` guard (FR-001, research.md decision 1)
84
+ - [X] T023 [US1] Expose the hand-off point once per reactor as the normalized `{ mode:, step: }` pair in `Web::API.build_structure` in `lib/ruby_reactor/web/api.rb`, replacing the per-step `async:` field dropped in T013
85
+ - [X] T024 [US1] Redefine `TestSubject`'s `async: false` / `run_async(false)` to suppress the `background` hand-off (running the reactor fully in-process) in `lib/ruby_reactor/rspec/test_subject.rb`, preserving the option's existing purpose under the new DSL
86
+
87
+ **Checkpoint**: `background after:` and `background before:` both fully work and are independently testable. This is a shippable MVP — the rename/bugfix half of the feature.
88
+
89
+ ---
90
+
91
+ ## Phase 4: User Story 2 - Async steps that truly run independently (Priority: P2)
92
+
93
+ **Goal**: `async_step` dispatches one step's work to its own worker job while the calling process keeps running other ready steps; dependent steps consume the result through `result(:name)`.
94
+
95
+ **Independent Test**: Reactor with `async_step :send_email`, then an unrelated `step :do_something_same_thread`, then `step :check_email` reading `result(:send_email)`. Confirm the unrelated step is not blocked and the reader receives the correct deserialized value.
96
+
97
+ ### Tests for User Story 2 (write first, confirm failing)
98
+
99
+ - [X] T025 [P] [US2] Fixture reactors for async_step (independent sibling, awaited reader, failing async step with and without a reader, never-completing step for the timeout case) in `spec/support/reactors/async_step_reactors.rb`
100
+ - [X] T026 [P] [US2] Spec: `store_step_result`/`retrieve_step_result` round-trip against real Redis, including the `dispatched` → `completed` status transition and `context_ttl` expiry — `spec/ruby_reactor/storage/step_result_spec.rb`
101
+ - [X] T027 [P] [US2] Spec: dispatch does not block — a sibling step with no dependency on the async step completes while the async job is still queued (US2 scenario 1, SC-002) — `spec/ruby_reactor/dsl/async_step_spec.rb`
102
+ - [X] T028 [P] [US2] Spec: read semantics — on `Success` the reader receives the raw deserialized value (same shape a same-process step yields); on `Failure` the reader receives the `Failure` object itself for inspection (US2 scenario 2, FR-006) — `spec/ruby_reactor/dsl/async_step_spec.rb`
103
+ - [X] T029 [P] [US2] Spec: compensation is opt-in — a failing async step with no reader leaves the parent uncompensated, while a reader that inspects the failure and returns `Failure` does trigger compensation (US2 scenario 3, FR-011, SC-003) — `spec/ruby_reactor/dsl/async_step_spec.rb`
104
+ - [X] T030 [P] [US2] Spec: wait bound and race-freedom — a never-completing async step fails the reader with a timeout rather than hanging (SC-005); a step that completes *before* the reader subscribes is still resolved; a completion with no signal published is still caught by the fallback re-check — `spec/ruby_reactor/dsl/async_step_wait_spec.rb`
105
+ - [X] T031 [P] [US2] Spec: an `async_step` declared after a `background` hand-off point still dispatches to its own job rather than degrading to inline execution inside the worker — `spec/ruby_reactor/dsl/async_step_spec.rb`
106
+ - [X] T032 [P] [US2] Spec: `returns :async_step_name` raises at class-definition time — `spec/ruby_reactor/dsl/async_step_spec.rb`
107
+
108
+ ### Implementation for User Story 2
109
+
110
+ - [X] T033 [P] [US2] Add `store_step_result` / `retrieve_step_result` to the `Storage::Adapter` interface in `lib/ruby_reactor/storage/adapter.rb` and implement them in `lib/ruby_reactor/storage/redis_adapter.rb`, modeled on the existing `store_map_result`/`retrieve_map_results` pair and stamping `context_ttl` (FR-006, data-model.md "Step Result Record")
111
+ - [X] T034 [P] [US2] Add the `async_step(name, impl = nil, &block)` class macro in `lib/ruby_reactor/dsl/reactor.rb`, building a normal `StepConfig` plus a dispatch-mode marker argument key so every existing step option (`argument`, `run`, `compensate`, `undo`, `retries`, validators) keeps working (FR-004)
112
+ - [X] T035 [P] [US2] Implement the framework-agnostic single-step worker body in `lib/ruby_reactor/step_worker.rb`: load the parent context by id, resolve just that step's arguments, run it, write the Step Result Record, then publish the completion signal (write-before-publish ordering)
113
+ - [X] T036 [P] [US2] Add the Sidekiq worker binding in `lib/ruby_reactor/adapters/sidekiq/step_worker.rb`, mirroring `adapters/sidekiq/map_element_worker.rb`
114
+ - [X] T037 [P] [US2] Add the ActiveJob worker binding in `lib/ruby_reactor/adapters/active_job/step_worker.rb`, mirroring `adapters/active_job/map_element_worker.rb`
115
+ - [X] T038 [US2] Add a `perform_step_async` dispatch entry point to both routers (`lib/ruby_reactor/adapters/sidekiq/router.rb`, `lib/ruby_reactor/adapters/active_job/router.rb`) so async-step dispatch stays backend-agnostic
116
+ - [X] T039 [US2] Implement async-step dispatch in `lib/ruby_reactor/executor/step_executor.rb` in strict order: write the Step Result Record (`dispatched`) and the `composed_contexts[:name] = { type: :async_step_ref, ... }` reference, then enqueue, then `dependency_graph.complete_step` so unrelated siblings proceed — and deliberately do *not* gate dispatch on `inline_async_execution` (FR-004, FR-008, research.md decision 2)
117
+ - [X] T040 [US2] Add the `:async_step_ref` branch to `Template::Result#resolve` in `lib/ruby_reactor/template/result.rb`: when the step has no in-context result but carries an async-step ref, delegate to `AsyncWaiter` against the Step Result Record, injecting the raw value on Success and the `Failure` object on failure (FR-005, FR-010)
118
+ - [X] T041 [US2] Add the `returns` × `async_step` definition-time guard in `lib/ruby_reactor/dsl/reactor.rb` (spec Edge Cases)
119
+ - [X] T042 [US2] Add the `async_step` step type to `Web::API.determine_step_type` and an `:async_step_ref` resolution branch to `hydrate_composed_contexts` (mirroring `hydrate_map_ref`) in `lib/ruby_reactor/web/api.rb` (FR-014)
120
+ - [X] T043 [US2] Add an `#async_step` traversal helper to `lib/ruby_reactor/rspec/test_subject.rb` mirroring `#composed`/`#map`, and make `async: false` run async steps inline
121
+
122
+ **Checkpoint**: US1 and US2 both work independently.
123
+
124
+ ---
125
+
126
+ ## Phase 5: User Story 3 - Fire-and-forget async reactors (Priority: P3)
127
+
128
+ **Goal**: `async_reactor` dispatches a whole nested reactor to run independently — linked to the parent for traceability, excluded from its compensation graph, readable on demand via `result(:name)`.
129
+
130
+ **Independent Test**: `async_reactor :create_profile` with no reader — forcing the child to fail leaves the parent uncompensated. Separately, `async_reactor :create_account` plus a `step :verify_all` reading `result(:create_account)` — the block sees the child's real outcome and can return `Success` or `Failure`.
131
+
132
+ ### Tests for User Story 3 (write first, confirm failing)
133
+
134
+ - [X] T044 [P] [US3] Fixture parent/child reactors for async_reactor (fire-and-forget, awaited, same-lock-key collision, invalid-child-inputs, interrupt-paused child) in `spec/support/reactors/async_reactor_reactors.rb`
135
+ - [X] T045 [P] [US3] Spec: fire-and-forget isolation — a child failure with no downstream reader never compensates the parent (US3 scenario 1, FR-009, SC-003) — `spec/ruby_reactor/dsl/async_reactor_spec.rb`
136
+ - [X] T046 [P] [US3] Spec: awaited outcome — the reader blocks until the child is terminal, receives the child's real `Success`/`Failure` (not the enqueue-time `DispatchResult`), and its explicit `Failure` triggers parent compensation (US3 scenarios 2-3, FR-010) — `spec/ruby_reactor/dsl/async_reactor_spec.rb`
137
+ - [X] T047 [P] [US3] Spec: FR-015 deadlock guard — a child declaring the parent's currently-held lock key fails at dispatch with an error naming the key, both reactors, and the three remediations; a single-slot semaphore collides the same way; a *different* key dispatches normally — `spec/ruby_reactor/dsl/async_reactor_locks_spec.rb`
138
+ - [X] T048 [P] [US3] Spec: FR-016 pre-enqueue safeguards — invalid child inputs fail the dispatching step in the parent (normal saga handling), and a child declaring `with_ordered_lock` receives its nonce at enqueue — `spec/ruby_reactor/dsl/async_reactor_dispatch_spec.rb`
139
+ - [X] T049 [P] [US3] Spec: a child paused at an interrupt is not terminal — the reader waits and times out per FR-005, then resolves after the child is resumed — `spec/ruby_reactor/dsl/async_reactor_spec.rb`
140
+ - [X] T050 [P] [US3] Spec: the parent's context carries an `:async_reactor_ref` with the child's `execution_id`, and `returns :async_reactor_name` raises at definition time (FR-008) — `spec/ruby_reactor/dsl/async_reactor_spec.rb`
141
+
142
+ ### Implementation for User Story 3
143
+
144
+ - [X] T051 [P] [US3] Add the `async_reactor(name, child_reactor_class, &block)` class macro (with `compose`-shaped `argument` mappings) in `lib/ruby_reactor/dsl/reactor.rb`, plus its `returns` guard (FR-007)
145
+ - [X] T052 [US3] Implement the dispatch step in `lib/ruby_reactor/step/async_reactor_step.rb`, reusing the full pre-enqueue sequence extracted from `Reactor#run` — child input validation → ordered-lock nonce assignment → persist child context → enqueue — never raw `perform_async`, and registering no `compensate`/`undo` block (FR-007, FR-009, FR-016, research.md decision 3)
146
+ - [X] T053 [US3] Implement the FR-015 deadlock guard in the dispatch path: resolve the child's `lock_config[:key_proc]` (and single-slot `semaphore_config`) against the mapped child inputs and fail the dispatch step immediately on collision with a lock the dispatching execution holds, with an error enumerating the three ranked remediations (research.md decision 9)
147
+ - [X] T054 [US3] Write the `composed_contexts[:name] = { type: :async_reactor_ref, execution_id:, reactor_class_name:, dispatched_at: }` reference synchronously in the dispatching step (FR-008, data-model.md)
148
+ - [X] T055 [US3] Publish the completion signal after the terminal context save in `lib/ruby_reactor/executor.rb`, so an awaited child wakes its parent's waiter (research.md decision 4, data-model.md "Completion Signal")
149
+ - [X] T056 [US3] Add the `:async_reactor_ref` branch to `Template::Result#resolve` in `lib/ruby_reactor/template/result.rb`: wait via `AsyncWaiter` against the linked execution's own context row, treating `paused` as non-terminal, and inject the child's result object (FR-005, FR-010)
150
+ - [X] T057 [US3] Add the `async_reactor` step type to `Web::API.determine_step_type`, an `:async_reactor_ref` resolution branch to `hydrate_composed_contexts`, and child-graph recursion via `extract_inner_class`/`nested_structure` as `compose`/`map` already do, in `lib/ruby_reactor/web/api.rb` (FR-014)
151
+ - [X] T058 [US3] Add an `#async_reactor` traversal helper to `lib/ruby_reactor/rspec/test_subject.rb` mirroring `#composed`, and make `async: false` run async reactors inline
152
+
153
+ **Checkpoint**: All three user stories are independently functional.
154
+
155
+ ---
156
+
157
+ ## Phase 6: Polish & Cross-Cutting Concerns
158
+
159
+ **Purpose**: Observability, the dashboard frontend, the demo app, documentation, and release hygiene.
160
+
161
+ - [X] T059 Emit structured log entries (reactor name, step name, execution id) for background hand-off, `async_step` dispatch/completion, and `async_reactor` dispatch/completion, following the existing middleware event pattern, across `lib/ruby_reactor/executor/step_executor.rb` and `lib/ruby_reactor/step_worker.rb` (FR-012)
162
+ - [X] T060 [P] Render the `async_step` and `async_reactor` step types (icon, color, and for async_reactor a drill-down link to the linked execution) in `gui/src/components/DagVisualizer.tsx` (FR-014)
163
+ - [X] T061 [P] Render the new step types and their status/result panels in `gui/src/components/StepInspector.tsx` (FR-014)
164
+ - [X] T062 [P] Spec: `Web::API` returns the new step types and hydrates both new ref types, including the async_reactor child's nested structure — `spec/ruby_reactor/web/api_spec.rb`
165
+ - [X] T063 Replace `demo_app/app/reactors/partial_async_reactor.rb` with a `background after:`-based example (the old per-step `async true` syntax no longer parses) and rename it to `demo_app/app/reactors/background_demo_reactor.rb`
166
+ - [X] T064 [P] Add `demo_app/app/reactors/async_step_demo_reactor.rb` demonstrating `async_step` plus a `result()` reader (the `send_email` example from the spec)
167
+ - [X] T065 [P] Add `demo_app/app/reactors/async_reactor_demo_reactor.rb` demonstrating fire-and-forget alongside an awaited child whose outcome the parent inspects
168
+ - [X] T066 [P] Add `demo_app/spec/reactors/background_demo_reactor_spec.rb` using only the built-in `test_reactor` helper and matchers
169
+ - [X] T067 [P] Add `demo_app/spec/reactors/async_step_demo_reactor_spec.rb` using only the built-in spec helpers
170
+ - [X] T068 [P] Add `demo_app/spec/reactors/async_reactor_demo_reactor_spec.rb` using only the built-in spec helpers
171
+ - [X] T069 Rewrite the "Step-Level Async" section as `background after:`/`before:` (documenting which step each form pins, and the DAG caveat) and add new `async_step` / `async_reactor` sections (covering the notified wait, the opt-in compensation model, and the FR-015 lock guidance) in `documentation/async_reactors.md`
172
+ - [X] T070 [P] Add the `async_reactor` vs `compose` cross-reference (fire-and-forget/uncompensated vs synchronous/compensation-linked, and when a lock collision means you wanted `compose`) in `documentation/composition.md`
173
+ - [X] T071 Mirror the T069/T070 edits into the duplicate copies under `demo_app/documentation/` so the two trees do not drift (research.md decision 7)
174
+ - [X] T072 Rewrite the "Step-Level Async" subsection and add `async_step`/`async_reactor` coverage in `README.md`
175
+ - [X] T073 Add the breaking-change entry with a migration note (per-step and compose `async` → `background after:` / `async_step` / `async_reactor`) under the correct semantic heading in `CHANGELOG.md` (FR-013)
176
+ - [X] T074 Run `bundle exec rubocop` (no `--disable-pending-cops`) and fix all offenses across the changed files
177
+ - [X] T075 Execute the full `quickstart.md` validation: both backends green, `demo_app` specs green, and the dashboard verified in a browser (do not close FR-014 on passing specs alone)
178
+
179
+ ---
180
+
181
+ ## Dependencies & Execution Order
182
+
183
+ ### Phase Dependencies
184
+
185
+ - **Setup (Phase 1)**: No dependencies — start immediately
186
+ - **Foundational (Phase 2)**: Needs T001-T004 — **BLOCKS all user stories**
187
+ - **User Story 1 (Phase 3)**: Needs Phase 2
188
+ - **User Story 2 (Phase 4)**: Needs Phase 2 (specifically `AsyncWaiter` from T011)
189
+ - **User Story 3 (Phase 5)**: Needs Phase 2 (specifically `AsyncWaiter` from T011)
190
+ - **Polish (Phase 6)**: Needs the user stories whose surface it documents/renders
191
+
192
+ ### User Story Dependencies
193
+
194
+ - **US1 (P1)**: Independent after Phase 2. Ships alone as the MVP.
195
+ - **US2 (P2)**: Independent after Phase 2 — does not require US1. (T031 asserts the US1 interaction only if US1 is present.)
196
+ - **US3 (P3)**: Independent after Phase 2 — does not require US1 or US2. Shares only the `AsyncWaiter` core built in Phase 2.
197
+
198
+ ### Within Each User Story
199
+
200
+ - Tests first, confirmed failing (constitution Principle III)
201
+ - Fixtures → storage/adapters → DSL macro → executor dispatch → `Template::Result` wait branch → dashboard/test-helper surface
202
+ - Story complete and independently green before moving to the next priority
203
+
204
+ ### Parallel Opportunities
205
+
206
+ - All of Phase 1 (T001-T004) runs in parallel
207
+ - Phase 2 tests T005-T007 run in parallel; then T008/T009 and T010-T012 are separate files
208
+ - Every story's test tasks are `[P]` — write the whole story's spec set at once
209
+ - In US2: T033 (storage), T034 (DSL), T035-T037 (workers) are independent files
210
+ - In US3: T051 (DSL) is independent of the dispatch-step work
211
+ - Across teams: after Phase 2, US1, US2, and US3 can be developed simultaneously
212
+ - Polish: T060/T061 (GUI), T064/T065 (demo reactors), T066-T068 (demo specs), T070 (docs) all parallelize
213
+
214
+ ---
215
+
216
+ ## Parallel Example: User Story 2
217
+
218
+ ```bash
219
+ # Write the whole US2 spec set together (all fail initially):
220
+ Task: "Fixture reactors for async_step in spec/support/reactors/async_step_reactors.rb"
221
+ Task: "Storage round-trip spec in spec/ruby_reactor/storage/step_result_spec.rb"
222
+ Task: "Non-blocking dispatch spec in spec/ruby_reactor/dsl/async_step_spec.rb"
223
+ Task: "Wait bound and race-freedom spec in spec/ruby_reactor/dsl/async_step_wait_spec.rb"
224
+
225
+ # Then build the independent implementation pieces together:
226
+ Task: "Storage primitives in lib/ruby_reactor/storage/adapter.rb + redis_adapter.rb"
227
+ Task: "async_step macro in lib/ruby_reactor/dsl/reactor.rb"
228
+ Task: "Worker body in lib/ruby_reactor/step_worker.rb"
229
+ Task: "Sidekiq binding in lib/ruby_reactor/adapters/sidekiq/step_worker.rb"
230
+ Task: "ActiveJob binding in lib/ruby_reactor/adapters/active_job/step_worker.rb"
231
+ ```
232
+
233
+ ---
234
+
235
+ ## Implementation Strategy
236
+
237
+ ### MVP First (User Story 1 only)
238
+
239
+ 1. Phase 1: Setup (T001-T004)
240
+ 2. Phase 2: Foundational (T005-T015) — the breaking removal plus the shared waiter
241
+ 3. Phase 3: User Story 1 (T016-T024)
242
+ 4. **STOP and VALIDATE**: `background after:` works end to end; deprecated syntax fails loudly at definition time
243
+ 5. This alone is a shippable MAJOR release — it fixes the reported footgun without adding new surface
244
+
245
+ ### Incremental Delivery
246
+
247
+ 1. Setup + Foundational → old flag gone, suite green, waiter available
248
+ 2. + US1 → hand-off is unambiguous → ship (MVP)
249
+ 3. + US2 → real per-step async work → ship
250
+ 4. + US3 → independent nested reactors → ship
251
+ 5. + Polish → dashboard, demo app, docs, CHANGELOG → release
252
+
253
+ ### Parallel Team Strategy
254
+
255
+ With three developers, after Phase 2 lands as one unit:
256
+
257
+ - Developer A: US1 (T016-T024), then T063/T066 and the README/docs hand-off sections
258
+ - Developer B: US2 (T025-T043), then T064/T067
259
+ - Developer C: US3 (T044-T058), then T065/T068
260
+ - Whoever finishes first picks up the GUI pair (T060/T061)
261
+
262
+ ---
263
+
264
+ ## Notes
265
+
266
+ - **Phase 2 is a single atomic landing.** Removing `StepConfig#async?` breaks `Web::API`, `TestSubject`, and existing fixtures simultaneously — T008 through T015 should merge together, not incrementally.
267
+ - **Real Redis, always.** Constitution Principle III forbids mocked Redis/Sidekiq state for integration and contract tests; `Sidekiq::Testing.inline!` is unit-level only.
268
+ - **Both backends, every async spec.** Use the shared context from T004 rather than asserting against Sidekiq alone.
269
+ - **Two documentation trees.** `documentation/` and `demo_app/documentation/` are duplicates — T071 exists specifically to prevent drift.
270
+ - **The dispatch-time ordering in T039 is load-bearing**: durable record and context ref *before* enqueue (the F2 rule), or a crash between the two leaves a job with no record.
271
+ - `[P]` tasks touch different files with no incomplete dependencies; commit after each task or logical group.
data/specs/active_job.md CHANGED
@@ -29,7 +29,7 @@ The **enqueue side** is already behind a seam:
29
29
  ([sidekiq_adapter.rb](../lib/ruby_reactor/sidekiq_adapter.rb)):
30
30
  `perform_async`, `perform_in`, `perform_map_element_async`,
31
31
  `perform_map_element_in`, `perform_map_collection_async`. All return
32
- `RubyReactor::AsyncResult`.
32
+ `RubyReactor::DispatchResult`.
33
33
  - An adapter for any other queueing backend just needs to implement that
34
34
  same 5-method contract and assign it to `config.async_router`. **This part
35
35
  needs no rework.**
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_reactor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artur
@@ -131,6 +131,7 @@ files:
131
131
  - ".specify/workflows/workflow-registry.json"
132
132
  - ".tool-versions"
133
133
  - CHANGELOG.md
134
+ - CLAUDE.md
134
135
  - CODE_OF_CONDUCT.md
135
136
  - README.md
136
137
  - Rakefile
@@ -139,17 +140,22 @@ files:
139
140
  - lib/ruby_reactor/adapters/active_job/map_collector_worker.rb
140
141
  - lib/ruby_reactor/adapters/active_job/map_element_worker.rb
141
142
  - lib/ruby_reactor/adapters/active_job/router.rb
143
+ - lib/ruby_reactor/adapters/active_job/step_worker.rb
142
144
  - lib/ruby_reactor/adapters/active_job/sweeper_worker.rb
143
145
  - lib/ruby_reactor/adapters/active_job/worker.rb
144
146
  - lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb
145
147
  - lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb
146
148
  - lib/ruby_reactor/adapters/sidekiq/router.rb
149
+ - lib/ruby_reactor/adapters/sidekiq/step_worker.rb
147
150
  - lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb
148
151
  - lib/ruby_reactor/adapters/sidekiq/worker.rb
152
+ - lib/ruby_reactor/async_waiter.rb
149
153
  - lib/ruby_reactor/configuration.rb
150
154
  - lib/ruby_reactor/context.rb
151
155
  - lib/ruby_reactor/context_serializer.rb
152
156
  - lib/ruby_reactor/dependency_graph.rb
157
+ - lib/ruby_reactor/dsl/async_macros.rb
158
+ - lib/ruby_reactor/dsl/async_reactor_builder.rb
153
159
  - lib/ruby_reactor/dsl/compose_builder.rb
154
160
  - lib/ruby_reactor/dsl/interrupt_builder.rb
155
161
  - lib/ruby_reactor/dsl/interrupt_step_config.rb
@@ -159,10 +165,13 @@ files:
159
165
  - lib/ruby_reactor/dsl/step_builder.rb
160
166
  - lib/ruby_reactor/dsl/template_helpers.rb
161
167
  - lib/ruby_reactor/dsl/validation_helpers.rb
168
+ - lib/ruby_reactor/error/async_result_pending.rb
169
+ - lib/ruby_reactor/error/async_wait_timeout_error.rb
162
170
  - lib/ruby_reactor/error/base.rb
163
171
  - lib/ruby_reactor/error/compensation_error.rb
164
172
  - lib/ruby_reactor/error/context_too_large_error.rb
165
173
  - lib/ruby_reactor/error/dependency_error.rb
174
+ - lib/ruby_reactor/error/deprecated_dsl_error.rb
166
175
  - lib/ruby_reactor/error/deserialization_error.rb
167
176
  - lib/ruby_reactor/error/input_validation_error.rb
168
177
  - lib/ruby_reactor/error/schema_version_error.rb
@@ -170,6 +179,7 @@ files:
170
179
  - lib/ruby_reactor/error/undo_error.rb
171
180
  - lib/ruby_reactor/error/validation_error.rb
172
181
  - lib/ruby_reactor/executor.rb
182
+ - lib/ruby_reactor/executor/async_step_dispatch.rb
173
183
  - lib/ruby_reactor/executor/compensation_manager.rb
174
184
  - lib/ruby_reactor/executor/graph_manager.rb
175
185
  - lib/ruby_reactor/executor/input_validator.rb
@@ -184,6 +194,7 @@ files:
184
194
  - lib/ruby_reactor/map/element_executor.rb
185
195
  - lib/ruby_reactor/map/helpers.rb
186
196
  - lib/ruby_reactor/map/result_enumerator.rb
197
+ - lib/ruby_reactor/map/result_summary.rb
187
198
  - lib/ruby_reactor/map/sweeper.rb
188
199
  - lib/ruby_reactor/max_retries_exhausted_failure.rb
189
200
  - lib/ruby_reactor/middleware.rb
@@ -208,13 +219,18 @@ files:
208
219
  - lib/ruby_reactor/rspec/test_subject.rb
209
220
  - lib/ruby_reactor/semaphore.rb
210
221
  - lib/ruby_reactor/step.rb
222
+ - lib/ruby_reactor/step/async_reactor_step.rb
211
223
  - lib/ruby_reactor/step/compose_step.rb
212
224
  - lib/ruby_reactor/step/map_step.rb
225
+ - lib/ruby_reactor/step_sweeper.rb
226
+ - lib/ruby_reactor/step_worker.rb
213
227
  - lib/ruby_reactor/storage/adapter.rb
214
228
  - lib/ruby_reactor/storage/configuration.rb
215
229
  - lib/ruby_reactor/storage/redis_adapter.rb
216
230
  - lib/ruby_reactor/storage/redis_locking.rb
217
231
  - lib/ruby_reactor/storage/redis_ordered_locking.rb
232
+ - lib/ruby_reactor/storage/redis_pub_sub.rb
233
+ - lib/ruby_reactor/storage/redis_step_results.rb
218
234
  - lib/ruby_reactor/sweeper.rb
219
235
  - lib/ruby_reactor/sweeper_job.rb
220
236
  - lib/ruby_reactor/template/base.rb
@@ -233,14 +249,22 @@ files:
233
249
  - lib/ruby_reactor/web/application.rb
234
250
  - lib/ruby_reactor/web/config.ru
235
251
  - lib/ruby_reactor/web/coordination_serializer.rb
236
- - lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css
237
- - lib/ruby_reactor/web/public/assets/index-D7IBZvos.js
252
+ - lib/ruby_reactor/web/public/assets/index-B46p-M6K.css
253
+ - lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js
238
254
  - lib/ruby_reactor/web/public/index.html
239
255
  - lib/ruby_reactor/web/public/vite.svg
240
256
  - lib/ruby_reactor/worker.rb
241
257
  - llms-full.txt
242
258
  - llms.txt
243
259
  - sig/ruby_reactor.rbs
260
+ - specs/001-background-async-steps/checklists/requirements.md
261
+ - specs/001-background-async-steps/contracts/public-dsl.md
262
+ - specs/001-background-async-steps/data-model.md
263
+ - specs/001-background-async-steps/plan.md
264
+ - specs/001-background-async-steps/quickstart.md
265
+ - specs/001-background-async-steps/research.md
266
+ - specs/001-background-async-steps/spec.md
267
+ - specs/001-background-async-steps/tasks.md
244
268
  - specs/active_job.md
245
269
  - teley/Dockerfile
246
270
  homepage: https://github.com/arturictus/ruby_reactor