ruby_reactor 0.8.4 → 0.8.5

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 (72) 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 +196 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +47 -11
  7. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  8. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  9. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  10. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  11. data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
  12. data/lib/ruby_reactor/dsl/step_builder.rb +91 -19
  13. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  14. data/lib/ruby_reactor/error/rescuable.rb +28 -0
  15. data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
  16. data/lib/ruby_reactor/executor/result_handler.rb +18 -16
  17. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  18. data/lib/ruby_reactor/executor/step_executor.rb +59 -49
  19. data/lib/ruby_reactor/executor.rb +38 -4
  20. data/lib/ruby_reactor/map/collector.rb +21 -11
  21. data/lib/ruby_reactor/map/dispatcher.rb +29 -3
  22. data/lib/ruby_reactor/map/element_executor.rb +9 -3
  23. data/lib/ruby_reactor/map/helpers.rb +32 -2
  24. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  25. data/lib/ruby_reactor/reactor.rb +24 -0
  26. data/lib/ruby_reactor/rspec/matchers.rb +19 -3
  27. data/lib/ruby_reactor/step/compose_step.rb +7 -1
  28. data/lib/ruby_reactor/step/map_step.rb +109 -4
  29. data/lib/ruby_reactor/step.rb +7 -0
  30. data/lib/ruby_reactor/step_worker.rb +46 -22
  31. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  32. data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
  33. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor/web/api.rb +1 -1
  36. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
  37. data/lib/ruby_reactor/web/public/index.html +1 -1
  38. data/lib/ruby_reactor/worker.rb +3 -1
  39. data/lib/ruby_reactor.rb +17 -6
  40. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  41. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  42. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  43. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  44. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  45. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  46. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  47. data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
  48. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  49. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  50. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  51. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  53. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  54. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  55. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  56. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  57. data/specs/007-execution-flow-analysis/plan.md +127 -0
  58. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  59. data/specs/007-execution-flow-analysis/research.md +202 -0
  60. data/specs/007-execution-flow-analysis/spec.md +270 -0
  61. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  62. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  63. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  64. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  65. data/specs/008-rollback-reliability/data-model.md +139 -0
  66. data/specs/008-rollback-reliability/plan.md +233 -0
  67. data/specs/008-rollback-reliability/quickstart.md +105 -0
  68. data/specs/008-rollback-reliability/research.md +653 -0
  69. data/specs/008-rollback-reliability/spec.md +561 -0
  70. data/specs/008-rollback-reliability/tasks.md +1110 -0
  71. data/specs/future_improvements.md +48 -0
  72. metadata +35 -2
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ # map: research H14–H18, user questions Q1 and Q3. Every element reactor runs
4
+ # e1 → e2 and both declare undo/compensate; element i == 2 fails at e2.
5
+
6
+ module P
7
+ FAILS_AT_TWO = ->(inputs) { inputs.i == 2 }
8
+ ITEMS = { items: [0, 1, 2, 3] }.freeze
9
+
10
+ class Elem < Base
11
+ tag "e"
12
+ input :i
13
+ input :from_a, optional: true
14
+ pstep :e1, idx: true
15
+ pstep :e2, after: :e1, idx: true, fail: FAILS_AT_TWO
16
+ end
17
+
18
+ class ElemOk < Base
19
+ tag "e"
20
+ input :i
21
+ input :from_a, optional: true
22
+ pstep :e1, idx: true
23
+ pstep :e2, after: :e1, idx: true
24
+ end
25
+
26
+ # Shared parent shape: a → map :m → b. Fan-out, fail_fast and whether b
27
+ # fails vary per probe. (Parameter names must not shadow the MapBuilder
28
+ # methods `fan_out` / `fail_fast` / `element` used inside the block.)
29
+ def self.map_parent(name, element_class, fanned: false, tolerant: false, b_fails: false)
30
+ klass = Class.new(Base) do
31
+ input :items
32
+ pstep :a
33
+ map :m, element_class do
34
+ source input(:items)
35
+ argument :i, element(:m)
36
+ argument :from_a, result(:a)
37
+ fan_out(fanned)
38
+ fail_fast(!tolerant)
39
+ end
40
+ pstep :b, after: :m, fail: b_fails
41
+ end
42
+ const_set(name, klass)
43
+ end
44
+
45
+ map_parent(:Map01, Elem)
46
+ map_parent(:Map02, Elem, tolerant: true)
47
+ map_parent(:Map03, ElemOk, b_fails: true)
48
+ map_parent(:Map04, Elem, fanned: true)
49
+ map_parent(:Map05, Elem, fanned: true, tolerant: true)
50
+ map_parent(:Map06, ElemOk, fanned: true, b_fails: true)
51
+
52
+ class ElemRetry < Base
53
+ tag "e"
54
+ input :i
55
+ input :from_a, optional: true
56
+ pstep :e1, idx: true
57
+ pstep :e2, after: :e1, idx: true, fail_times: 1, retries: { max_attempts: 2, base_delay: 0 }
58
+ end
59
+
60
+ map_parent(:Map09, ElemRetry, fanned: true)
61
+ map_parent(:Map10, ElemRetry)
62
+
63
+ class ElemHalts < Base
64
+ tag "e"
65
+ input :i
66
+ input :from_a, optional: true
67
+ pstep :e1, idx: true
68
+ pstep :e2, after: :e1, idx: true do
69
+ run do |inputs, _ctx|
70
+ Probe.rec("run:e.e2[#{inputs.i}]")
71
+ inputs.i == 1 ? RubyReactor.Halt(reason: "element done early") : RubyReactor.Success(inputs.i)
72
+ end
73
+ end
74
+ end
75
+
76
+ map_parent(:Map11, ElemHalts)
77
+
78
+ class ElemWithUnit < Base
79
+ tag "e"
80
+ input :i
81
+ input :from_a, optional: true
82
+ pstep :e1, idx: true
83
+ pstep :u, kind: :async_step, after: :e1, idx: true
84
+ pstep :e2, after: :e1, idx: true, fail: ->(inputs) { inputs.i == 1 }
85
+ end
86
+
87
+ map_parent(:Map12, ElemWithUnit, fanned: true)
88
+
89
+ class ChildWithMap < Base
90
+ tag "child"
91
+ input :items
92
+ pstep :c0
93
+ map :m, Elem do
94
+ source input(:items)
95
+ argument :i, element(:m)
96
+ end
97
+ end
98
+
99
+ class Map07 < Base
100
+ input :items
101
+ pstep :a
102
+ compose(:child, ChildWithMap) { argument :items, input(:items) }
103
+ end
104
+
105
+ class Kid < Base
106
+ tag "k"
107
+ pstep :k1
108
+ end
109
+
110
+ class ElemComposes < Base
111
+ tag "e"
112
+ input :i
113
+ pstep :e1, idx: true
114
+ compose :kid, Kid
115
+ pstep :e2, after: %i[e1 kid], idx: true, fail: FAILS_AT_TWO
116
+ end
117
+
118
+ class Map08 < Base
119
+ input :items
120
+ map :m, ElemComposes do
121
+ source input(:items)
122
+ argument :i, element(:m)
123
+ end
124
+ end
125
+ end
126
+
127
+ def elements(*indexes, fail_at: nil)
128
+ indexes.flat_map do |i|
129
+ base = ["run:e.e1[#{i}]", "run:e.e2[#{i}]"]
130
+ i == fail_at ? base + ["compensate:e.e2[#{i}]", "undo:e.e1[#{i}]"] : base
131
+ end
132
+ end
133
+
134
+ Probe.scenario "S-map-01", "a → map(4 elems, inline, fail_fast; elem 2 fails) → b [Q1]",
135
+ mode: :inline,
136
+ expected: %w[run:a run:e.e1[0] run:e.e2[0] run:e.e1[1] run:e.e2[1] run:e.e1[2] run:e.e2[2]
137
+ compensate:e.e2[2] undo:e.e1[2] undo:e.e2[1] undo:e.e1[1] undo:e.e2[0] undo:e.e1[0]
138
+ undo:a => failure(m)] do
139
+ P::Map01.run(P::ITEMS)
140
+ end
141
+
142
+ Probe.scenario "S-map-02", "a → map(inline, fail_fast false; elem 2 fails) → b",
143
+ mode: :inline,
144
+ expected: ["run:a", *elements(0, 1, 2, 3, fail_at: 2), "run:b", "=>", "success"] do
145
+ P::Map02.run(P::ITEMS)
146
+ end
147
+
148
+ Probe.scenario "S-map-03", "a → map(inline, all ok) → b(fails) [Q1: later failure]",
149
+ mode: :inline,
150
+ expected: %w[run:a run:e.e1[0] run:e.e2[0] run:e.e1[1] run:e.e2[1] run:e.e1[2] run:e.e2[2]
151
+ run:e.e1[3] run:e.e2[3] run:b compensate:b undo:e.e2[3] undo:e.e1[3] undo:e.e2[2]
152
+ undo:e.e1[2] undo:e.e2[1] undo:e.e1[1] undo:e.e2[0] undo:e.e1[0] undo:a =>
153
+ failure(b)] do
154
+ P::Map03.run(P::ITEMS)
155
+ end
156
+
157
+ Probe.scenario "S-map-04", "a → map(fan_out, fail_fast; elem 2 fails) → b",
158
+ mode: :worker,
159
+ expected: %w[run:a run:e.e1[0] run:e.e2[0] run:e.e1[1] run:e.e2[1] run:e.e1[2] run:e.e2[2]
160
+ compensate:e.e2[2] undo:e.e1[2] undo:e.e2[1] undo:e.e1[1] undo:e.e2[0] undo:e.e1[0]
161
+ undo:a => failure(m)] do
162
+ Probe.run_async(P::Map04, P::ITEMS)
163
+ end
164
+
165
+ Probe.scenario "S-map-05", "a → map(fan_out, fail_fast false; elem 2 fails) → b",
166
+ mode: :worker,
167
+ expected: ["run:a", *elements(0, 1, 2, 3, fail_at: 2), "run:b", "=>", "success"] do
168
+ Probe.run_async(P::Map05, P::ITEMS)
169
+ end
170
+
171
+ Probe.scenario "S-map-06", "a → map(fan_out, all ok) → b(fails)",
172
+ mode: :worker,
173
+ expected: %w[run:a run:e.e1[0] run:e.e2[0] run:e.e1[1] run:e.e2[1] run:e.e1[2] run:e.e2[2]
174
+ run:e.e1[3] run:e.e2[3] run:b compensate:b undo:e.e2[3] undo:e.e1[3] undo:e.e2[2]
175
+ undo:e.e1[2] undo:e.e2[1] undo:e.e1[1] undo:e.e2[0] undo:e.e1[0] undo:a =>
176
+ failure(b)] do
177
+ Probe.run_async(P::Map06, P::ITEMS)
178
+ end
179
+
180
+ Probe.scenario "S-map-07", "a → compose(c0 → map(elem 2 fails))",
181
+ mode: :inline,
182
+ expected: %w[run:a run:child.c0 run:e.e1[0] run:e.e2[0] run:e.e1[1] run:e.e2[1] run:e.e1[2]
183
+ run:e.e2[2] compensate:e.e2[2] undo:e.e1[2] undo:e.e2[1] undo:e.e1[1] undo:e.e2[0]
184
+ undo:e.e1[0] undo:child.c0 undo:a => failure(child)] do
185
+ P::Map07.run(P::ITEMS)
186
+ end
187
+
188
+ Probe.scenario "S-map-08", "map(elem: e1 → compose(k1) → e2; elem 2 fails)",
189
+ mode: :inline,
190
+ expected: %w[run:e.e1[0] run:k.k1 run:e.e2[0] run:e.e1[1] run:k.k1 run:e.e2[1] run:e.e1[2]
191
+ run:k.k1 run:e.e2[2] compensate:e.e2[2] undo:k.k1 undo:e.e1[2] undo:e.e2[1]
192
+ undo:k.k1 undo:e.e1[1] undo:e.e2[0] undo:k.k1 undo:e.e1[0] => failure(m)] do
193
+ P::Map08.run(P::ITEMS)
194
+ end
195
+
196
+ Probe.scenario "S-map-04b", "map(fan_out, fail_fast; elem 2 fails), element jobs performed 3,2,1,0",
197
+ mode: :worker,
198
+ expected: %w[run:a run:e.e1[3] run:e.e2[3] run:e.e1[2] run:e.e2[2] compensate:e.e2[2]
199
+ undo:e.e1[2] undo:e.e2[3] undo:e.e1[3] undo:a => failure(m)] do
200
+ dispatched = P::Map04.run(P::ITEMS)
201
+ RubyReactor::Adapters::Sidekiq::MapElementWorker.jobs.reverse!
202
+ Probe.drain
203
+ P::Map04.find(dispatched.execution_id)
204
+ end
205
+
206
+ Probe.scenario "S-map-09", "a → map(fan_out; each e2 fails once, retries 2) → b",
207
+ mode: :worker,
208
+ expected: %w[run:a run:e.e1[0] run:e.e2[0] retry:e2#1 run:e.e1[1] run:e.e2[1] retry:e2#1
209
+ run:e.e2[0] run:e.e2[1] run:b => success] do
210
+ Probe.run_async(P::Map09, { items: [0, 1] })
211
+ end
212
+
213
+ Probe.scenario "S-map-10", "a → map(inline; each e2 fails once, retries 2) → b",
214
+ mode: :inline,
215
+ expected: %w[run:a run:e.e1[0] run:e.e2[0] retry:e2#1 run:e.e2[0] run:e.e1[1] run:e.e2[1] retry:e2#1
216
+ run:e.e2[1] run:b => success] do
217
+ P::Map10.run({ items: [0, 1] })
218
+ end
219
+
220
+ Probe.scenario "S-map-11", "a → map(inline; elem 1 returns Halt) → b",
221
+ mode: :inline, expected: %w[run:a run:e.e1[0] run:e.e2[0] run:e.e1[1] run:e.e2[1] => halt] do
222
+ P::Map11.run({ items: [0, 1, 2] })
223
+ end
224
+
225
+ Probe.scenario "S-map-12", "a → map(fan_out; element e1 → async_step u, e2; elem 1 fails) → b",
226
+ mode: :worker,
227
+ expected: %w[run:a run:e.e1[0] run:e.e2[0] run:e.e1[1] run:e.e2[1] compensate:e.e2[1]
228
+ undo:e.e1[1] undo:e.e2[0] undo:e.e1[0] undo:a run:e.u[0] run:e.u[1] => failure(m)] do
229
+ result = Probe.run_async(P::Map12, { items: [0, 1] })
230
+ Probe.note("StepWorker jobs left after drain: #{RubyReactor::Adapters::Sidekiq::StepWorker.jobs.size}")
231
+ result
232
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ # async_step / async_reactor: research H19–H22. Parents run inline; units run
4
+ # in their own jobs. Reader scenarios use Sidekiq inline! (the unit completes at
5
+ # enqueue) because a same-process reader in fake mode would wait out
6
+ # async_wait_timeout for a job nobody performs.
7
+
8
+ module P
9
+ # r reads result(:u); opts in to compensation by failing on a Failure.
10
+ module Reader
11
+ def reader(name, source)
12
+ pstep name do
13
+ argument :seen, result(source)
14
+ run do |inputs, _ctx|
15
+ Probe.rec("run:#{name}")
16
+ failed = inputs.seen.is_a?(RubyReactor::Failure)
17
+ failed ? RubyReactor.Failure("#{name} saw a failed unit") : RubyReactor.Success(inputs.seen)
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ class Async01 < Base
24
+ pstep :a
25
+ pstep :u, kind: :async_step, after: :a, fail: true
26
+ pstep :b, after: :a
27
+ end
28
+
29
+ class Async02 < Base
30
+ extend Reader
31
+
32
+ pstep :a
33
+ pstep :u, kind: :async_step, after: :a, fail: true
34
+ reader :r, :u
35
+ end
36
+
37
+ class Async03 < Base
38
+ pstep :a
39
+ pstep :u, kind: :async_step, after: :a
40
+ pstep :b, after: :a, fail: true
41
+ end
42
+
43
+ class Async04 < Base
44
+ pstep :a
45
+ async_reactor(:child, ChildFails) { argument :from_a, result(:a) }
46
+ pstep :b, after: :a
47
+ end
48
+
49
+ class Async05 < Base
50
+ extend Reader
51
+
52
+ pstep :a
53
+ async_reactor(:child, ChildFails) { argument :from_a, result(:a) }
54
+ reader :r, :child
55
+ end
56
+
57
+ class Async06 < Base
58
+ pstep :a
59
+ async_reactor(:child, ChildOk) { argument :from_a, result(:a) }
60
+ pstep :b, after: :a, fail: true
61
+ end
62
+
63
+ class Async07 < Base
64
+ pstep :a
65
+ pstep :u, kind: :async_step, after: :a, fail: true, retries: { max_attempts: 3, base_delay: 0 }
66
+ pstep :b, after: :a
67
+ end
68
+ end
69
+
70
+ Probe.scenario "S-async-01", "a → async_step u(fails), no reader; b",
71
+ mode: :"inline parent + drained StepWorker",
72
+ expected: %w[run:a run:b run:u compensate:u => success] do
73
+ result = P::Async01.run({})
74
+ Probe.drain
75
+ result
76
+ end
77
+
78
+ Probe.scenario "S-async-02", "a → async_step u(fails) → r reads u and fails",
79
+ mode: :"inline parent + Sidekiq inline!",
80
+ expected: %w[run:a run:u compensate:u run:r compensate:r undo:a => failure(r)] do
81
+ Probe.inline_jobs { P::Async02.run({}) }
82
+ end
83
+
84
+ Probe.scenario "S-async-03", "a → async_step u(ok); b(fails), no reader",
85
+ mode: :"inline parent + drained StepWorker",
86
+ expected: %w[run:a run:b compensate:b undo:a run:u => failure(b)] do
87
+ result = P::Async03.run({})
88
+ Probe.drain
89
+ result
90
+ end
91
+
92
+ Probe.scenario "S-async-04", "a → async_reactor child(c1 → c2 fails), no reader; b",
93
+ mode: :"inline parent + drained Worker",
94
+ expected: %w[run:a run:b run:child.c1 run:child.c2 compensate:child.c2 undo:child.c1 => success] do
95
+ result = P::Async04.run({})
96
+ Probe.drain
97
+ result
98
+ end
99
+
100
+ Probe.scenario "S-async-05", "a → async_reactor child(c1 → c2 fails) → r reads child and fails",
101
+ mode: :"inline parent + Sidekiq inline!",
102
+ expected: %w[run:a run:child.c1 run:child.c2 compensate:child.c2 undo:child.c1 run:r compensate:r
103
+ undo:a => failure(r)] do
104
+ Probe.inline_jobs { P::Async05.run({}) }
105
+ end
106
+
107
+ Probe.scenario "S-async-06", "a → async_reactor child(ok); b(fails)",
108
+ mode: :"inline parent + drained Worker",
109
+ expected: %w[run:a run:b compensate:b undo:a run:child.c1 run:child.c2 => failure(b)] do
110
+ result = P::Async06.run({})
111
+ Probe.drain
112
+ result
113
+ end
114
+
115
+ Probe.scenario "S-async-07", "a → async_step u(always fails, retries 3), no reader; b",
116
+ mode: :"inline parent + drained StepWorker",
117
+ expected: %w[run:a run:b run:u run:u run:u compensate:u => success] do
118
+ result = P::Async07.run({})
119
+ Probe.drain
120
+ result
121
+ end
122
+
123
+ # Fake mode: nobody performs the unit, so the same-process reader waits out
124
+ # async_wait_timeout (2s here). The unit is drained afterwards.
125
+ Probe.scenario "S-async-08", "a → async_step u → r reads u; unit never finishes in time",
126
+ mode: :"inline parent + drained StepWorker (after)",
127
+ expected: %w[run:a undo:a run:u compensate:u => failure(r)] do
128
+ result = P::Async02.run({})
129
+ Probe.note("error=#{result.error.to_s.lines.first&.strip}")
130
+ Probe.drain
131
+ result
132
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # background: research H8, H23. The worker path (Worker#perform →
4
+ # Executor#resume_execution) against the same shapes as the inline baseline.
5
+
6
+ module P
7
+ class Bg01 < Base
8
+ background all: true
9
+ pstep :a
10
+ pstep :b, after: :a
11
+ pstep :c, after: :b, fail: true
12
+ end
13
+
14
+ class Bg02 < Base
15
+ pstep :a
16
+ pstep :b, after: :a
17
+ pstep :c, after: :b, fail: true
18
+ background after: :a
19
+ end
20
+
21
+ class Bg03 < Base
22
+ background all: true
23
+ pstep :a
24
+ pstep :b, after: :a, fail: true, retries: { max_attempts: 3, base_delay: 0 }
25
+ end
26
+
27
+ class Bg04 < Base
28
+ background all: true
29
+ pstep :a
30
+ pstep :b, after: :a, fail_times: 1, retries: { max_attempts: 2, base_delay: 0 }
31
+ pstep :c, after: :b
32
+ end
33
+ end
34
+
35
+ Probe.scenario "S-bg-01", "background all: a → b → c(fails)",
36
+ mode: :worker, expected: %w[run:a run:b run:c compensate:c undo:b undo:a => failure(c)] do
37
+ Probe.run_async(P::Bg01)
38
+ end
39
+
40
+ Probe.scenario "S-bg-02", "a | background after: :a | b → c(fails) (a ran in the caller)",
41
+ mode: :"caller + worker", expected: %w[run:a run:b run:c compensate:c undo:b undo:a => failure(c)] do
42
+ P::Bg02.run({})
43
+ Probe.note("caller returned with #{Probe.pending_jobs} worker job(s) queued")
44
+ id = RubyReactor::Adapters::Sidekiq::Worker.jobs.first["args"].first
45
+ Probe.drain
46
+ P::Bg02.find(id)
47
+ end
48
+
49
+ Probe.scenario "S-bg-03", "background all: a → b(always fails, retries 3)",
50
+ mode: :worker,
51
+ expected: %w[run:a run:b retry:b#1 run:b retry:b#2 run:b compensate:b undo:a => failure(b)] do
52
+ Probe.run_async(P::Bg03)
53
+ end
54
+
55
+ Probe.scenario "S-bg-04", "background all: a → b(fails once, retries 2) → c",
56
+ mode: :worker, expected: %w[run:a run:b retry:b#1 run:b run:c => success] do
57
+ Probe.run_async(P::Bg04)
58
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Locks, semaphores and retries relative to rollback: research H2, H7, H13,
4
+ # H24, H25. Inline runs unless stated.
5
+
6
+ module P
7
+ def self.hold_lock(key, owner)
8
+ RubyReactor::Lock.new(key, owner: owner, ttl: 60, wait: 0, auto_extend: false).acquire
9
+ end
10
+
11
+ def self.lock_count(key)
12
+ RubyReactor.configuration.storage_adapter.lock_info("lock:#{key}")&.fetch(:count)
13
+ end
14
+
15
+ class Lock01 < Base
16
+ with_lock { |_inputs| "rk" }
17
+ pstep :a
18
+ pstep :b, after: :a, fail: true
19
+ end
20
+
21
+ class Lock02 < Base
22
+ pstep :a
23
+ pstep(:b, after: :a) { with_lock { |_args| "sk" } }
24
+ pstep :c, after: :b, fail: true
25
+ end
26
+
27
+ class Lock03 < Base
28
+ pstep :a
29
+ pstep(:b, after: :a) { with_lock { |_args| "sk" } }
30
+ end
31
+
32
+ class Lock04 < Base
33
+ pstep :a
34
+ pstep(:b, after: :a) { with_semaphore(limit: 1) { |_args| "sem" } }
35
+ pstep :c, after: :b, fail: true
36
+ end
37
+
38
+ # c's body takes b's key as another owner (a concurrent forward run), so b's
39
+ # undo cannot re-take it within rollback_wait.
40
+ class Lock05 < Base
41
+ pstep :a
42
+ pstep(:b, after: :a) { with_lock(rollback_wait: 0.3) { |_args| "sk" } }
43
+ pstep :c, after: :b do
44
+ run do |_inputs, _ctx|
45
+ Probe.rec("run:c")
46
+ P.hold_lock("sk", "intruder")
47
+ RubyReactor.Failure("boom c")
48
+ end
49
+ end
50
+ end
51
+
52
+ class LockedChild < Base
53
+ tag "child"
54
+ with_lock { |_inputs| "rk" }
55
+ pstep :c1
56
+ end
57
+
58
+ class Lock06 < Base
59
+ with_lock { |_inputs| "rk" }
60
+ pstep :a
61
+ compose :child, LockedChild
62
+ pstep :b, after: :child do
63
+ run do |_inputs, _ctx|
64
+ Probe.rec("run:b(rk count=#{P.lock_count("rk")})")
65
+ RubyReactor.Failure("boom b")
66
+ end
67
+ end
68
+ end
69
+
70
+ class Retry01 < Base
71
+ pstep :a
72
+ pstep :b, after: :a, fail: true, retries: { max_attempts: 3, base_delay: 0 }
73
+ end
74
+
75
+ class Retry02 < Base
76
+ pstep :a
77
+ pstep :b, after: :a, retries: { max_attempts: 3, base_delay: 0 } do
78
+ run do |_inputs, _ctx|
79
+ Probe.rec("run:b")
80
+ fail!("permanent", retry: false)
81
+ end
82
+ end
83
+ end
84
+
85
+ class Retry03 < Base
86
+ pstep :a
87
+ pstep :b, after: :a, fail_times: 1, retries: { max_attempts: 2, base_delay: 0 }
88
+ pstep :c, after: :b
89
+ end
90
+
91
+ class Retry04 < Base
92
+ pstep :a
93
+ pstep :b, after: :a, fail: :raise, retries: { max_attempts: 2, base_delay: 0 }
94
+ end
95
+ end
96
+
97
+ Probe.scenario "S-lock-01", "reactor with_lock(rk): a → b(fails)",
98
+ mode: :inline,
99
+ expected: %w[lock_acquired:rk run:a run:b compensate:b undo:a lock_released:lock:rk => failure(b)] do
100
+ # Reactor-level lock_released reports the prefixed key; lock_acquired does not.
101
+ P::Lock01.run({})
102
+ end
103
+
104
+ Probe.scenario "S-lock-02", "a → b(step with_lock sk) → c(fails)",
105
+ mode: :inline,
106
+ expected: %w[run:a lock_acquired:sk run:b lock_released:sk run:c compensate:c lock_acquired:sk undo:b
107
+ lock_released:sk undo:a => failure(c)] do
108
+ P::Lock02.run({})
109
+ end
110
+
111
+ Probe.scenario "S-lock-03", "a → b(step with_lock sk, key held by another owner)",
112
+ mode: :inline, expected: %w[run:a undo:a => failure(b)] do
113
+ P.hold_lock("sk", "someone-else")
114
+ P::Lock03.run({})
115
+ end
116
+
117
+ Probe.scenario "S-lock-04", "a → b(step with_semaphore limit 1) → c(fails)",
118
+ mode: :inline,
119
+ expected: %w[run:a semaphore_acquired:sem run:b semaphore_released:sem run:c compensate:c
120
+ semaphore_acquired:sem undo:b semaphore_released:sem undo:a => failure(c)] do
121
+ P::Lock04.run({})
122
+ end
123
+
124
+ Probe.scenario "S-lock-05", "a → b(step lock sk, rollback_wait 0.3) → c(takes sk as intruder, fails)",
125
+ mode: :inline,
126
+ expected: %w[run:a lock_acquired:sk run:b lock_released:sk run:c compensate:c undo:a
127
+ => failure(c)] do
128
+ Probe.rollback_note(P::Lock05.run({}))
129
+ end
130
+
131
+ Probe.scenario "S-lock-06", "reactor with_lock(rk): a → compose(child with_lock rk: c1) → b(fails)",
132
+ mode: :inline,
133
+ expected: ["lock_acquired:rk", "run:a", "lock_acquired:rk", "run:child.c1", "lock_released:lock:rk",
134
+ "run:b(rk count=1)", "compensate:b", "undo:child.c1", "undo:a", "lock_released:lock:rk",
135
+ "=>", "failure(b)"] do
136
+ P::Lock06.run({})
137
+ end
138
+
139
+ Probe.scenario "S-retry-01", "a → b(always fails, retries 3)",
140
+ mode: :inline,
141
+ expected: %w[run:a run:b retry:b#1 run:b retry:b#2 run:b compensate:b undo:a => failure(b)] do
142
+ P::Retry01.run({})
143
+ end
144
+
145
+ Probe.scenario "S-retry-02", "a → b(fail! retry: false, retries 3)",
146
+ mode: :inline, expected: %w[run:a run:b compensate:b undo:a => failure(b)] do
147
+ P::Retry02.run({})
148
+ end
149
+
150
+ Probe.scenario "S-retry-03", "a → b(fails once, retries 2) → c",
151
+ mode: :inline, expected: %w[run:a run:b retry:b#1 run:b run:c => success] do
152
+ P::Retry03.run({})
153
+ end
154
+
155
+ Probe.scenario "S-retry-04", "a → b(raises, retries 2)",
156
+ mode: :inline, expected: %w[run:a run:b retry:b#1 run:b compensate:b undo:a => failure(b)] do
157
+ P::Retry04.run({})
158
+ end