ruby_reactor 0.8.4 → 0.8.6

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +17 -14
  5. data/CHANGELOG.md +254 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +66 -15
  8. data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
  10. data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
  12. data/lib/ruby_reactor/context.rb +14 -1
  13. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  14. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  15. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  16. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  18. data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
  19. data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
  20. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  21. data/lib/ruby_reactor/error/rescuable.rb +33 -0
  22. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
  24. data/lib/ruby_reactor/executor/result_handler.rb +85 -63
  25. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  26. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  27. data/lib/ruby_reactor/executor/step_executor.rb +61 -49
  28. data/lib/ruby_reactor/executor.rb +221 -7
  29. data/lib/ruby_reactor/map/collector.rb +31 -89
  30. data/lib/ruby_reactor/map/dispatcher.rb +65 -8
  31. data/lib/ruby_reactor/map/element_executor.rb +21 -11
  32. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  33. data/lib/ruby_reactor/map/helpers.rb +11 -125
  34. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  35. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  36. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  37. data/lib/ruby_reactor/map.rb +17 -0
  38. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  39. data/lib/ruby_reactor/reactor.rb +65 -2
  40. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  41. data/lib/ruby_reactor/rspec/matchers.rb +59 -24
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  43. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  44. data/lib/ruby_reactor/rspec.rb +3 -6
  45. data/lib/ruby_reactor/step/compose_step.rb +10 -1
  46. data/lib/ruby_reactor/step/map_step.rb +144 -36
  47. data/lib/ruby_reactor/step.rb +7 -0
  48. data/lib/ruby_reactor/step_worker.rb +46 -22
  49. data/lib/ruby_reactor/storage/adapter.rb +18 -0
  50. data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
  51. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  52. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  53. data/lib/ruby_reactor/sweeper.rb +3 -1
  54. data/lib/ruby_reactor/version.rb +1 -1
  55. data/lib/ruby_reactor/web/api.rb +11 -1
  56. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
  57. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  58. data/lib/ruby_reactor/web/public/index.html +2 -2
  59. data/lib/ruby_reactor/worker.rb +7 -3
  60. data/lib/ruby_reactor.rb +17 -6
  61. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  62. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  63. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  64. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  65. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  66. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  67. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  68. data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
  69. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  72. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
  73. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  74. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  75. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  76. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  77. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  78. data/specs/007-execution-flow-analysis/plan.md +127 -0
  79. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  80. data/specs/007-execution-flow-analysis/research.md +202 -0
  81. data/specs/007-execution-flow-analysis/spec.md +270 -0
  82. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  83. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  84. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  85. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  86. data/specs/008-rollback-reliability/data-model.md +139 -0
  87. data/specs/008-rollback-reliability/plan.md +233 -0
  88. data/specs/008-rollback-reliability/quickstart.md +105 -0
  89. data/specs/008-rollback-reliability/research.md +653 -0
  90. data/specs/008-rollback-reliability/spec.md +561 -0
  91. data/specs/008-rollback-reliability/tasks.md +1110 -0
  92. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  93. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  94. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  95. data/specs/009-distributed-map-undo/data-model.md +151 -0
  96. data/specs/009-distributed-map-undo/plan.md +323 -0
  97. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  98. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  99. data/specs/009-distributed-map-undo/research.md +585 -0
  100. data/specs/009-distributed-map-undo/spec.md +360 -0
  101. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  102. data/specs/future_improvements.md +64 -28
  103. metadata +54 -3
  104. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -0,0 +1,39 @@
1
+ # Specification Quality Checklist: Execution Flow & Compensation Analysis
2
+
3
+ **Purpose**: Validate specification completeness and quality before proceeding to planning
4
+ **Created**: 2026-09-26
5
+ **Feature**: [spec.md](../spec.md)
6
+
7
+ ## Content Quality
8
+
9
+ - [x] No implementation details (languages, frameworks, APIs)
10
+ - [x] Focused on user value and business needs
11
+ - [x] Written for non-technical stakeholders
12
+ - [x] All mandatory sections completed
13
+
14
+ ## Requirement Completeness
15
+
16
+ - [x] No [NEEDS CLARIFICATION] markers remain
17
+ - [x] Requirements are testable and unambiguous
18
+ - [x] Success criteria are measurable
19
+ - [x] Success criteria are technology-agnostic (no implementation details)
20
+ - [x] All acceptance scenarios are defined
21
+ - [x] Edge cases are identified
22
+ - [x] Scope is clearly bounded
23
+ - [x] Dependencies and assumptions identified
24
+
25
+ ## Feature Readiness
26
+
27
+ - [x] All functional requirements have clear acceptance criteria
28
+ - [x] User scenarios cover primary flows
29
+ - [x] Feature meets measurable outcomes defined in Success Criteria
30
+ - [x] No implementation details leak into specification
31
+
32
+ ## Notes
33
+
34
+ - Stakeholders for this research are library maintainers and reactor authors, so library
35
+ vocabulary (step, compose, map, compensate, undo, inline/background) is the domain language, not
36
+ implementation detail. The `compensate_all` / `compensate_each` names appear only because the
37
+ user proposed them as candidates to evaluate.
38
+ - Validation passed on iteration 1. No clarifications needed: scope ambiguities (interrupts,
39
+ doc edits, evidence method) were resolved as documented assumptions.
@@ -0,0 +1,71 @@
1
+ # Contract: Report Structure
2
+
3
+ The deliverable's "interface" is the set of documents a maintainer reads and the probe output a
4
+ reviewer re-runs. This contract fixes what each must contain so the report can be checked
5
+ mechanically (see quickstart.md).
6
+
7
+ ## `analysis/README.md`
8
+
9
+ 1. **Scope & baseline**: commit, constructs covered, what is out of scope.
10
+ 2. **How to read**: vocabulary (research D4), evidence labels (D5), status/severity scales (D6/D7).
11
+ 3. **Answers**: one subsection per user question, in this order:
12
+ - Q1 *Are already-executed map elements compensated individually?*
13
+ - Q2 *When a composed reactor fails, are previously completed composed reactors compensated?*
14
+ - Q3 *Would `compensate_all` / `compensate_each` on `map` close a real gap?*
15
+
16
+ Each answer MUST open with a one-line verdict (`Yes` / `No` / `Depends: …`), then list the
17
+ conditions, then evidence labels, then links to the matrix rows, invariants and findings.
18
+ 4. **Top findings**: the High-severity findings, one line each with links.
19
+ 5. **File index**.
20
+
21
+ ## `analysis/execution-order.md`
22
+
23
+ 1. **Construct lifecycles**: one subsection per construct (step, compose, map inline, map fan-out,
24
+ async_step, async_reactor, background reactor, interrupt). Each has a numbered lifecycle from
25
+ scheduling to terminal state, saying where rollback hooks attach and where locks are held.
26
+ 2. **Rollback algorithm**: the generic compensate → reverse-undo sequence with a Mermaid diagram.
27
+ 3. **Order matrix**: one table per construct. Columns: `Scenario id | shape | failure at | mode |
28
+ ordered events | left in place | evidence`. No blank cells. Unreachable cells say
29
+ `not reachable: <reason>`.
30
+ 4. **Cross-cutting sections**: locks (lifetime vs rollback), retries (attempts vs rollback),
31
+ failure kinds (which path each takes: rollback / no rollback / never-started), each with its
32
+ own table.
33
+
34
+ ## `analysis/invariants.md`
35
+
36
+ A table or list of `INV-nn` entries with every Invariant field from data-model.md, grouped by
37
+ area. It ends with a **coverage summary**: counts by status, and the list of invariants with
38
+ `coverage: none`.
39
+
40
+ ## `analysis/findings-and-options.md`
41
+
42
+ 1. **Findings**, ranked High → Low, with every Finding field.
43
+ 2. **Documentation audit**: a table `file:line | quoted claim | actual | finding id`.
44
+ 3. **Options**, grouped under the finding they address, with every Option field. The map
45
+ `compensate_all` / `compensate_each` evaluation MUST appear here and compare both shapes on:
46
+ failure semantics (fail_fast on/off), inline vs fan-out, interaction with element retries,
47
+ interaction with a later step failing, and data availability (what `all_elements` / `element`
48
+ would contain).
49
+ 4. A closing note: every option is a proposal pending later analysis.
50
+
51
+ ## `evidence/output.txt`
52
+
53
+ Produced by `evidence/run.rb`. It has one block per scenario:
54
+
55
+ ```text
56
+ == S-plain-01 a → b(fails) → c [inline]
57
+ expected: run:a run:b compensate:b undo:a => failure(b)
58
+ observed: run:a run:b compensate:b undo:a => failure(b)
59
+ MATCH
60
+ ```
61
+
62
+ `MISMATCH` blocks are kept, not hidden. A `MISMATCH` means the report's expectation was wrong and
63
+ the report MUST be corrected to the observed sequence (the observation wins). Scenarios whose
64
+ purpose is to show a counter-example set `expected` to the *observed* behavior and state the
65
+ violation in the report.
66
+
67
+ ## Cross-reference rules
68
+
69
+ - Every `[O: S-…]` label in `analysis/*.md` resolves to a block in `output.txt`.
70
+ - Every finding lists at least one scenario or `[R]` citation.
71
+ - Every High finding has at least two options, each with a stated con (SC-004).
@@ -0,0 +1,83 @@
1
+ # Data Model: Execution Flow & Compensation Analysis
2
+
3
+ The "data" here is the report's own structure. Every entity below has a stable ID so report files
4
+ can cross-reference each other and cite probes.
5
+
6
+ ## Scenario
7
+
8
+ A reactor shape + failure location + conditions. It is one matrix cell, and usually one probe.
9
+
10
+ | Field | Description |
11
+ |---|---|
12
+ | `id` | `S-<area>-<nn>`; area ∈ `plain`, `retry`, `compose`, `map`, `async`, `bg`, `lock`, `intr`, `edge` |
13
+ | `shape` | Constructs and nesting, e.g. `a → compose(c1 → c2) → b` |
14
+ | `failure_at` | Which step fails and how (`returns Failure`, `raises`, `contended`, `validation`, `compensate fails`, …) |
15
+ | `mode` | `inline` or `worker` (+ which worker path) |
16
+ | `conditions` | Locks / retries / fail_fast / fan_out flags in effect |
17
+ | `expected` | Ordered event list the report claims (see Trace event) |
18
+ | `left_in_place` | Completed work that nothing rolls back |
19
+ | `evidence` | `[R]`, `[O]`, `[T]` labels (research D5) |
20
+
21
+ Rule: a matrix cell is **filled** when `expected` and `left_in_place` are both stated, or when it is
22
+ marked `not reachable` with a one-line reason (SC-001).
23
+
24
+ ## Trace event
25
+
26
+ One entry in an observed or expected sequence.
27
+
28
+ | Kind | Format | Source |
29
+ |---|---|---|
30
+ | Body | `run:<step>`, `compensate:<step>`, `undo:<step>` | probe step bodies |
31
+ | Element body | `run:<step>[<i>]`, `undo:<step>[<i>]` | probe map element steps |
32
+ | Middleware | `<event>:<subject>` e.g. `lock_acquired:acct:1`, `retry_attempt:charge#2` | recorder middleware |
33
+ | Outcome | `=> success`, `=> failure(<step>)`, `=> halt`, `=> paused` | probe footer |
34
+
35
+ Nested reactors prefix the step with the child name when they would otherwise be ambiguous,
36
+ e.g. `run:child.c1`.
37
+
38
+ ## Invariant
39
+
40
+ | Field | Description |
41
+ |---|---|
42
+ | `id` | `INV-<nn>` |
43
+ | `statement` | A testable proposition ("Compensation never runs for a step whose body never started") |
44
+ | `scope` | Constructs/modes it applies to |
45
+ | `status` | `HOLDS` · `VIOLATED` · `CONDITIONAL` · `UNDETERMINED` (research D6) |
46
+ | `conditions` | Required when `CONDITIONAL` |
47
+ | `evidence` | `[R]`/`[O]`/`[T]` labels; a `VIOLATED` status MUST cite a reproducible counter-example (`[O]`) |
48
+ | `coverage` | Existing spec(s) exercising it, or `none` |
49
+
50
+ ## Finding
51
+
52
+ | Field | Description |
53
+ |---|---|
54
+ | `id` | `F-<nn>` |
55
+ | `title` | One line |
56
+ | `severity` | `High` · `Medium` · `Low` (research D7) |
57
+ | `scenario` | Scenario id(s) that show it |
58
+ | `reader_expects` | What someone reading the DSL/docs would expect |
59
+ | `actual` | What happens, with evidence |
60
+ | `doc_conflict` | Quoted README/documentation text + `file:line`, or `none` |
61
+ | `related` | Invariant ids |
62
+
63
+ ## Improvement option
64
+
65
+ | Field | Description |
66
+ |---|---|
67
+ | `id` | `O-<finding>-<letter>` e.g. `O-03-a` |
68
+ | `addresses` | Finding id(s) |
69
+ | `sketch` | DSL/behavior shape (pseudo-code allowed) |
70
+ | `pros` / `cons` | At least one con is required (SC-004) |
71
+ | `compatibility` | Breaking? Migration impact? |
72
+ | `open_questions` | What must be decided before choosing |
73
+ | `status` | Always `proposal`, never a decision |
74
+
75
+ ## Relationships
76
+
77
+ ```text
78
+ Scenario 1─* Trace event
79
+ Scenario *─* Invariant (a scenario is evidence for invariants)
80
+ Finding *─* Scenario (shown by)
81
+ Finding *─* Invariant (violates / conditions)
82
+ Option *─1..* Finding (addresses)
83
+ ```
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Evidence harness for specs/007-execution-flow-analysis. Research artifact,
4
+ # NOT library or test-suite code. It runs throwaway reactors against the real
5
+ # test Redis through the real worker bodies (Sidekiq fake mode + drain) and
6
+ # prints the observed forward/rollback event order next to the order the
7
+ # report claims.
8
+
9
+ require "bundler/setup"
10
+ require "logger"
11
+ require "ruby_reactor"
12
+ require "sidekiq/testing"
13
+
14
+ Sidekiq::Testing.fake!
15
+ Sidekiq.configure_client do |config|
16
+ config.redis = { url: ENV.fetch("RUBY_REACTOR_TEST_REDIS_URL", "redis://localhost:6780") }
17
+ config.logger = Logger.new(IO::NULL)
18
+ end
19
+
20
+ module Probe
21
+ REDIS_URL = ENV.fetch("RUBY_REACTOR_TEST_REDIS_URL", "redis://localhost:6780")
22
+
23
+ # Drained in this order until quiet. Delayed jobs (perform_in) run at once.
24
+ WORKERS = [
25
+ RubyReactor::Adapters::Sidekiq::Worker,
26
+ RubyReactor::Adapters::Sidekiq::MapElementWorker,
27
+ RubyReactor::Adapters::Sidekiq::MapCollectorWorker,
28
+ # 009 R-04: a fan-out map rolls back one element per job.
29
+ RubyReactor::Adapters::Sidekiq::MapElementRollbackWorker,
30
+ RubyReactor::Adapters::Sidekiq::StepWorker
31
+ ].freeze
32
+
33
+ @events = []
34
+ @notes = []
35
+ @counters = Hash.new(0)
36
+ @tally = { match: 0, mismatch: 0 }
37
+
38
+ class << self
39
+ attr_reader :events, :notes, :counters, :tally
40
+
41
+ def rec(event) = @events << event
42
+ def note(text) = @notes << text
43
+
44
+ # Uniform step labels: "run:a", "undo:child.c1", "run:e1[2]".
45
+ def label(prefix, name, index = nil)
46
+ base = [prefix, name].compact.join(".")
47
+ index.nil? ? base : "#{base}[#{index}]"
48
+ end
49
+
50
+ # The value a probe `compensate` / `undo` body returns.
51
+ def rollback_result(mode, what)
52
+ case mode
53
+ when :fail then RubyReactor.Failure("#{what} failed")
54
+ when :raise then raise "#{what} raised"
55
+ else RubyReactor.Success()
56
+ end
57
+ end
58
+
59
+ # Same loop as RubyReactor::RSpec::SidekiqHelpers.drain_async_jobs, inlined
60
+ # because requiring that file pulls in the RSpec matchers.
61
+ def drain(max_iterations: 100)
62
+ max_iterations.times do
63
+ busy = false
64
+ WORKERS.each do |worker|
65
+ while (job = worker.jobs.shift)
66
+ worker.new.perform(*job["args"])
67
+ busy = true
68
+ end
69
+ end
70
+ return unless busy
71
+ end
72
+ end
73
+
74
+ def pending_jobs = WORKERS.sum { |w| w.jobs.size }
75
+
76
+ # Jobs run synchronously at enqueue (Sidekiq::Testing.inline!). Used only
77
+ # where a same-process reader must see a unit finish; labelled in the mode.
78
+ def inline_jobs(&block) = Sidekiq::Testing.inline!(&block)
79
+
80
+ # Run, drain every worker job, then reload the execution's terminal state.
81
+ def run_async(reactor_class, inputs = {})
82
+ dispatched = reactor_class.run(inputs)
83
+ drain
84
+ reactor_class.find(dispatched.execution_id)
85
+ end
86
+
87
+ # Prints Failure#rollback_failures as step/kind/reason; returns the result.
88
+ def rollback_note(result)
89
+ list = result.respond_to?(:rollback_failures) ? Array(result.rollback_failures) : []
90
+ note("rollback_failures=#{list.map { |f| "#{f[:step]}/#{f[:kind]}/#{f[:reason]}" }}")
91
+ result
92
+ end
93
+
94
+ def outcome(result)
95
+ result = result.result if result.is_a?(RubyReactor::Reactor)
96
+ case result
97
+ when String then result
98
+ when RubyReactor::Halt then "halt"
99
+ when RubyReactor::Success then "success"
100
+ when RubyReactor::Failure then "failure(#{result.step_name || "?"})"
101
+ when RubyReactor::InterruptResult then "paused"
102
+ when RubyReactor::DispatchResult then "dispatched"
103
+ when :unexecuted then "running"
104
+ else result.inspect
105
+ end
106
+ end
107
+
108
+ def reset!
109
+ RubyReactor.configuration.storage_adapter.instance_variable_get(:@redis).flushdb
110
+ WORKERS.each { |w| w.jobs.clear }
111
+ [@events, @notes].each(&:clear)
112
+ @counters.clear
113
+ end
114
+
115
+ # Runs one scenario and prints expected vs observed. The block returns the
116
+ # final result (a Result, a reloaded Reactor, or a String).
117
+ def scenario(id, title, mode:, expected:, &block)
118
+ return if ENV["PROBE"] && !id.include?(ENV["PROBE"])
119
+
120
+ reset!
121
+ observed = @events + ["=>", outcome(run_block(&block))]
122
+ report(id, title, mode, expected, observed)
123
+ end
124
+
125
+ private
126
+
127
+ def run_block(&block)
128
+ block.call
129
+ rescue StandardError => e
130
+ "raised(#{e.class}: #{e.message.lines.first&.strip})"
131
+ end
132
+
133
+ def report(id, title, mode, expected, observed)
134
+ ok = observed == expected
135
+ @tally[ok ? :match : :mismatch] += 1
136
+ puts "== #{id} #{title} [#{mode}]"
137
+ puts "expected: #{expected.join(" ")}"
138
+ puts "observed: #{observed.join(" ")}"
139
+ @notes.each { |n| puts "note: #{n}" }
140
+ puts ok ? "MATCH" : "MISMATCH"
141
+ puts
142
+ end
143
+ end
144
+
145
+ # Recording middleware: the shipped hook surface, no patching. Only events
146
+ # that say something about ordering relative to rollback are kept.
147
+ class Recorder < RubyReactor::Middleware
148
+ def on(event, *args)
149
+ case event
150
+ when :lock_acquired, :lock_released, :semaphore_acquired, :semaphore_released
151
+ Probe.rec("#{event}:#{args[0]}")
152
+ when :retry_attempt
153
+ Probe.rec("retry:#{args[0]}##{args[1]}")
154
+ end
155
+ end
156
+ end
157
+
158
+ # Reactor-class DSL sugar so a probe step is one line:
159
+ #
160
+ # pstep :b, after: :a, fail: true
161
+ # pstep :e1, idx: true, fail: ->(inputs) { inputs.i == 2 }
162
+ # pstep :c, fail: :raise, compensate: :fail, undo: :raise
163
+ # pstep :d, fail_times: 1, retries: { max_attempts: 2, base_delay: 0 }
164
+ module Steps
165
+ def tag(value = nil)
166
+ value ? @probe_tag = value : @probe_tag
167
+ end
168
+
169
+ # opts: fail:, fail_times:, retries: {…}, compensate: :ok|:fail|:raise, undo: (same),
170
+ # kind: :step (default) | :async_step
171
+ def pstep(name, after: nil, idx: false, **opts, &extra)
172
+ body = Steps.body(tag, name, idx, opts)
173
+ on_compensate = Steps.rollback(tag, name, idx, "compensate", opts[:compensate])
174
+ on_undo = Steps.rollback(tag, name, idx, "undo", opts[:undo])
175
+ public_send(opts.fetch(:kind, :step), name) do
176
+ wait_for(*Array(after)) if after
177
+ argument :i, input(:i) if idx
178
+ retries(**opts[:retries]) if opts[:retries]
179
+ run(&body)
180
+ compensate(&on_compensate)
181
+ # 008 R-09: an async_step is never undone, so `undo` on one is rejected.
182
+ undo(&on_undo) unless opts[:kind] == :async_step
183
+ instance_eval(&extra) if extra
184
+ end
185
+ end
186
+
187
+ # `failure`: `fail: true | :raise | ->(inputs) {}` or `fail_times: n`.
188
+ def self.body(prefix, name, idx, failure)
189
+ lambda do |inputs, _ctx|
190
+ lbl = Probe.label(prefix, name, idx ? inputs.i : nil)
191
+ Probe.rec("run:#{lbl}")
192
+ failing = failing?(lbl, inputs, failure)
193
+ raise "boom #{lbl}" if failing && failure[:fail] == :raise
194
+
195
+ failing ? RubyReactor.Failure("boom #{lbl}") : RubyReactor.Success("#{lbl}-value")
196
+ end
197
+ end
198
+
199
+ def self.failing?(lbl, inputs, failure)
200
+ return (Probe.counters[lbl] += 1) <= failure[:fail_times] if failure[:fail_times]
201
+
202
+ fail = failure[:fail]
203
+ fail.respond_to?(:call) ? fail.call(inputs) : fail
204
+ end
205
+
206
+ def self.rollback(prefix, name, idx, kind, mode)
207
+ lambda do |_error_or_result, inputs, _ctx|
208
+ lbl = Probe.label(prefix, name, idx ? inputs.i : nil)
209
+ Probe.rec("#{kind}:#{lbl}")
210
+ Probe.rollback_result(mode, "#{kind} #{lbl}")
211
+ end
212
+ end
213
+ end
214
+ end
215
+
216
+ RubyReactor.configure do |config|
217
+ config.storage.adapter = :redis
218
+ config.storage.redis_url = Probe::REDIS_URL
219
+ config.async_router = RubyReactor::Adapters::Sidekiq::Router
220
+ config.logger = Logger.new(IO::NULL)
221
+ config.middlewares = [Probe::Recorder]
222
+ config.lock_snooze_jitter = 0
223
+ config.async_wait_timeout = 2
224
+ end
225
+
226
+ # Probe reactors live under P so workers can resolve them by constant name.
227
+ module P
228
+ class Base < RubyReactor::Reactor
229
+ extend Probe::Steps
230
+ end
231
+ end