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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Adapters
5
+ module Sidekiq
6
+ # Rolls back one element of a fan-out map (009 R-07).
7
+ class MapElementRollbackWorker
8
+ include ::Sidekiq::Worker
9
+
10
+ def perform(arguments)
11
+ RubyReactor::Map::ElementRollback.perform(arguments)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -58,7 +58,7 @@ module RubyReactor
58
58
  # rubocop:disable Metrics/ParameterLists
59
59
  def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
60
60
  strict_ordering:, parent_context_id:, parent_reactor_class_name:,
61
- step_name:, batch_size: nil, serialized_context: nil, fail_fast: nil)
61
+ step_name:, batch_size: nil, serialized_context: nil, atomic: nil)
62
62
  job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_async(
63
63
  {
64
64
  "map_id" => map_id,
@@ -72,7 +72,7 @@ module RubyReactor
72
72
  "step_name" => step_name,
73
73
  "batch_size" => batch_size,
74
74
  "serialized_context" => serialized_context,
75
- "fail_fast" => fail_fast
75
+ "atomic" => atomic
76
76
  }
77
77
  )
78
78
  RubyReactor::DispatchResult.new(job_id: job_id)
@@ -81,7 +81,7 @@ module RubyReactor
81
81
  def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
82
82
  reactor_class_info:, strict_ordering:, parent_context_id:,
83
83
  parent_reactor_class_name:, step_name:, batch_size: nil,
84
- serialized_context: nil, fail_fast: nil)
84
+ serialized_context: nil, atomic: nil)
85
85
  job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_in(
86
86
  delay,
87
87
  {
@@ -96,7 +96,7 @@ module RubyReactor
96
96
  "step_name" => step_name,
97
97
  "batch_size" => batch_size,
98
98
  "serialized_context" => serialized_context,
99
- "fail_fast" => fail_fast
99
+ "atomic" => atomic
100
100
  }
101
101
  )
102
102
  # Return an DispatchResult so RetryManager#handle_async_retry recognises the
@@ -105,6 +105,34 @@ module RubyReactor
105
105
  end
106
106
  # rubocop:enable Metrics/ParameterLists
107
107
 
108
+ # One element's rollback in a fan-out map's distributed rollback (009
109
+ # R-07); the `_in` form is its requeue on a contended element lock.
110
+ def self.perform_map_element_rollback_async(**args)
111
+ job_id = RubyReactor::Adapters::Sidekiq::MapElementRollbackWorker.perform_async(map_element_rollback_payload(**args))
112
+ RubyReactor::DispatchResult.new(job_id: job_id)
113
+ end
114
+
115
+ def self.perform_map_element_rollback_in(delay, **args)
116
+ job_id = RubyReactor::Adapters::Sidekiq::MapElementRollbackWorker.perform_in(
117
+ delay, map_element_rollback_payload(**args)
118
+ )
119
+ RubyReactor::DispatchResult.new(job_id: job_id)
120
+ end
121
+
122
+ # rubocop:disable Metrics/ParameterLists
123
+ def self.map_element_rollback_payload(map_id:, position:, element_context_id:, reactor_class_info:,
124
+ parent_reactor_class_name:, step_name:, batch_size:, owner_context_id:,
125
+ owner_reactor_class_name:, attempt: 0)
126
+ {
127
+ "map_id" => map_id, "position" => position, "element_context_id" => element_context_id,
128
+ "reactor_class_info" => reactor_class_info, "parent_reactor_class_name" => parent_reactor_class_name,
129
+ "step_name" => step_name, "batch_size" => batch_size, "owner_context_id" => owner_context_id,
130
+ "owner_reactor_class_name" => owner_reactor_class_name, "attempt" => attempt
131
+ }
132
+ end
133
+ # rubocop:enable Metrics/ParameterLists
134
+ private_class_method :map_element_rollback_payload
135
+
108
136
  # rubocop:disable Metrics/ParameterLists
109
137
  def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
110
138
  strict_ordering:, timeout:)
@@ -29,6 +29,12 @@ module RubyReactor
29
29
  :cancelled, :cancellation_reason, :parent_context_id, :retried_from_id, :status, :failure_reason,
30
30
  :middlewares
31
31
 
32
+ # Where this execution level's rollback stands while it is `rolling_back`
33
+ # (009 DM §1): `trigger`, `step`, `compensated`, `failure`, `failures`.
34
+ # Kept JSON-safe (string keys; `failure` / `failures` already serialized),
35
+ # so it round-trips unchanged. Nil otherwise.
36
+ attr_accessor :rollback
37
+
32
38
  # Transient, NOT serialized (absent from `to_h`, `serialize_for_retry`,
33
39
  # and `deserialize_from_retry` below — confirmed by
34
40
  # reentrancy_spec.rb US4-16). Overrides `StepCoordination#owner` for
@@ -76,6 +82,10 @@ module RubyReactor
76
82
  @status.to_s == "failed"
77
83
  end
78
84
 
85
+ def rolling_back?
86
+ @status.to_s == "rolling_back"
87
+ end
88
+
79
89
  def get_input(name, path = nil)
80
90
  value = Utils::FetchIndifferent.call(@inputs, name)
81
91
  return nil if value.nil?
@@ -149,7 +159,8 @@ module RubyReactor
149
159
  reactor_class: @reactor_class,
150
160
  execution_trace: @execution_trace,
151
161
  status: @status,
152
- failure_reason: @failure_reason
162
+ failure_reason: @failure_reason,
163
+ rollback: @rollback
153
164
  }
154
165
  end
155
166
 
@@ -175,6 +186,7 @@ module RubyReactor
175
186
  cancellation_reason: @cancellation_reason,
176
187
  status: @status,
177
188
  failure_reason: ContextSerializer.serialize_value(@failure_reason),
189
+ rollback: @rollback,
178
190
  parent_context_id: @parent_context&.context_id || @parent_context_id,
179
191
  retried_from_id: @retried_from_id
180
192
  }
@@ -200,6 +212,7 @@ module RubyReactor
200
212
  context.cancellation_reason = data["cancellation_reason"]
201
213
  context.status = data["status"] || "pending"
202
214
  context.failure_reason = ContextSerializer.deserialize_value(data["failure_reason"])
215
+ context.rollback = data["rollback"]
203
216
  context.parent_context_id = data["parent_context_id"]
204
217
  context.retried_from_id = data["retried_from_id"]
205
218
 
@@ -137,9 +137,38 @@ module RubyReactor
137
137
  builder = RubyReactor::Dsl::StepBuilder.new(name, impl, self)
138
138
  builder.instance_eval(&block) if block_given?
139
139
 
140
- steps[name] = builder.build(async_dispatch: :step)
140
+ config = builder.build(async_dispatch: :step)
141
+ check_async_step_undo!(builder, config, caller_locations(1, 1).first)
142
+ steps[name] = config
141
143
  end
142
144
 
145
+ # An async_step is never undone: its parent does not track it for undo
146
+ # (008 R-09/R-10), so an `undo` on it would be dead code. An inline one
147
+ # is rejected; a step class's is only warned about, because the same
148
+ # class is legitimately reused by ordinary steps, where it does run.
149
+ def check_async_step_undo!(builder, config, site)
150
+ if config.undo_block
151
+ raise RubyReactor::Error::ValidationError,
152
+ "`undo` on async_step :#{config.name} would never run: the parent never undoes an " \
153
+ "independent async unit. Put failure cleanup in the unit's `compensate` (it runs in the " \
154
+ "unit's job when its last attempt fails) or in the reading step's `compensate`; for cleanup " \
155
+ "that must run when the parent rolls back, use a `step`/`compose`/`map` (tracked for undo) " \
156
+ "or an `async_reactor` child whose steps declare `undo`."
157
+ end
158
+
159
+ return unless defines_undo?(config.impl)
160
+
161
+ builder.send(:warn_definition, site, nil,
162
+ "async_step :#{config.name} uses #{config.impl}; its `undo` will not run for this async " \
163
+ "use (async units are never undone).")
164
+ end
165
+ private :check_async_step_undo!
166
+
167
+ def defines_undo?(impl)
168
+ impl.is_a?(Class) && impl < RubyReactor::Step && impl.instance_method(:undo).owner != RubyReactor::Step
169
+ end
170
+ private :defines_undo?
171
+
143
172
  # Dispatch a whole nested reactor to run INDEPENDENTLY — linked
144
173
  # to this one by execution id for traceability, but excluded from its
145
174
  # compensation graph. Fire-and-forget unless a later step reads
@@ -10,7 +10,6 @@ module RubyReactor
10
10
  # ordering nonce.
11
11
  class AsyncReactorBuilder
12
12
  include RubyReactor::Dsl::TemplateHelpers
13
- include RubyReactor::Dsl::Retryable
14
13
 
15
14
  attr_accessor :name, :child_reactor_class, :argument_mappings
16
15
 
@@ -19,13 +18,23 @@ module RubyReactor
19
18
  @child_reactor_class = child_reactor_class
20
19
  @reactor = reactor
21
20
  @argument_mappings = {}
22
- @retry_config = nil
23
21
  end
24
22
 
25
23
  def argument(child_input_name, source)
26
24
  @argument_mappings[child_input_name] = source
27
25
  end
28
26
 
27
+ # A parent never retries a nested reactor as a whole (008 R-14): the
28
+ # child owns its steps' retries. Kept as a stub to name the replacement.
29
+ def retries(*)
30
+ raise RubyReactor::Error::DeprecatedDslError.new(
31
+ "`retries` on an `async_reactor` has been removed: a parent never retries a nested " \
32
+ "reactor as a whole. Declare `retries` on :#{@name}'s child reactor's own steps " \
33
+ "(`retries max_attempts: 3` in the step block or the step class); the child retries them itself.",
34
+ step: @name
35
+ )
36
+ end
37
+
29
38
  def build
30
39
  RubyReactor::Dsl::StepConfig.new(
31
40
  async_dispatch: :reactor,
@@ -41,12 +50,9 @@ module RubyReactor
41
50
  # that reads `result(:name)` and decides to fail.
42
51
  compensate_block: nil,
43
52
  undo_block: nil,
44
- conditions: [],
45
- guards: [],
46
53
  dependencies: dependencies_from_mappings,
47
54
  args_validator: nil,
48
- output_validator: nil,
49
- retry_config: @retry_config
55
+ output_validator: nil
50
56
  )
51
57
  end
52
58
 
@@ -4,7 +4,6 @@ module RubyReactor
4
4
  module Dsl
5
5
  class ComposeBuilder
6
6
  include RubyReactor::Dsl::TemplateHelpers
7
- include RubyReactor::Dsl::Retryable
8
7
 
9
8
  attr_accessor :name, :composed_reactor_class, :argument_mappings
10
9
 
@@ -21,7 +20,6 @@ module RubyReactor
21
20
  end
22
21
  @reactor = reactor
23
22
  @argument_mappings = {}
24
- @retry_config = nil
25
23
  end
26
24
 
27
25
  def argument(composed_input_name, source)
@@ -45,6 +43,17 @@ module RubyReactor
45
43
  )
46
44
  end
47
45
 
46
+ # A parent never retries a nested reactor as a whole (008 R-14): the
47
+ # child owns its steps' retries. Kept as a stub to name the replacement.
48
+ def retries(*)
49
+ raise RubyReactor::Error::DeprecatedDslError.new(
50
+ "`retries` on a `compose` has been removed: a parent never retries a nested " \
51
+ "reactor as a whole. Declare `retries` on :#{@name}'s child reactor's own steps " \
52
+ "(`retries max_attempts: 3` in the step block or the step class); the child retries them itself.",
53
+ step: @name
54
+ )
55
+ end
56
+
48
57
  def build
49
58
  warn_if_child_has_ordered_lock!
50
59
  dependencies = extract_dependencies_from_mappings
@@ -59,12 +68,9 @@ module RubyReactor
59
68
  run_block: nil,
60
69
  compensate_block: nil,
61
70
  undo_block: nil,
62
- conditions: [],
63
- guards: [],
64
71
  dependencies: dependencies,
65
72
  args_validator: nil,
66
- output_validator: nil,
67
- retry_config: @retry_config
73
+ output_validator: nil
68
74
  }
69
75
 
70
76
  RubyReactor::Dsl::StepConfig.new(step_config)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ # Definition-time warnings for the DSL builders, printed once per
6
+ # declaration site for the process. The including builder sets `@reactor`.
7
+ module DefinitionWarnings
8
+ def self.deprecation_sites
9
+ @deprecation_sites ||= Set.new
10
+ end
11
+
12
+ private
13
+
14
+ def warn_deprecation(site, message)
15
+ warn_definition(site, "DEPRECATION:", "#{message} Removal no earlier than the next MAJOR.")
16
+ end
17
+
18
+ def warn_definition(site, prefix, message)
19
+ location = "#{site.path}:#{site.lineno}"
20
+ return unless DefinitionWarnings.deprecation_sites.add?(location)
21
+
22
+ warn ["[RubyReactor]", prefix, location, reactor_label, message].compact.join(" ")
23
+ end
24
+
25
+ def reactor_label
26
+ @reactor&.name || @reactor.inspect
27
+ end
28
+ end
29
+ end
30
+ end
@@ -73,9 +73,7 @@ module RubyReactor
73
73
  validation_schema: @validation_schema,
74
74
  max_attempts: @max_attempts,
75
75
  resume_mode: @resume_mode,
76
- dependencies: @dependencies,
77
- conditions: @conditions,
78
- guards: @guards
76
+ dependencies: @dependencies
79
77
  }
80
78
 
81
79
  RubyReactor::Dsl::InterruptStepConfig.new(step_config)
@@ -4,6 +4,7 @@ module RubyReactor
4
4
  module Dsl
5
5
  class MapBuilder
6
6
  include RubyReactor::Dsl::TemplateHelpers
7
+ include RubyReactor::Dsl::DefinitionWarnings
7
8
 
8
9
  attr_accessor :name, :mapped_reactor_class, :argument_mappings, :source_enumerable
9
10
 
@@ -25,7 +26,7 @@ module RubyReactor
25
26
  @batch_size = nil
26
27
  @source_enumerable = nil
27
28
  @collect_block = nil
28
- @fail_fast = true # Default: stop on first error
29
+ @atomic = true # Default: every element succeeds, or none is kept
29
30
  end
30
31
 
31
32
  def argument(mapped_input_name, source)
@@ -41,10 +42,10 @@ module RubyReactor
41
42
  end
42
43
 
43
44
  # Run every element as its own background job. `batch_size` caps how many
44
- # element jobs are enqueued at a time (back pressure); without it the whole
45
- # source fans out at once. A fan-out map is a hand-off point: the reactor
46
- # stops at the map and resumes in a worker once the collector has every
47
- # element's outcome.
45
+ # element jobs one throw enqueues (back pressure), forward and rollback;
46
+ # without it, `Map::DEFAULT_BATCH_SIZE` (50) does. A fan-out map is a
47
+ # hand-off point: the reactor stops at the map and resumes in a worker
48
+ # once the collector has every element's outcome.
48
49
  def fan_out(enabled = true, batch_size: nil)
49
50
  @fan_out = enabled
50
51
  self.batch_size(batch_size) unless batch_size.nil?
@@ -83,11 +84,30 @@ module RubyReactor
83
84
  @collect_block = block
84
85
  end
85
86
 
87
+ # On (the default), the map succeeds only if every element does: the
88
+ # first element failure fails it, no new element starts, and every
89
+ # completed element is rolled back. Off, it completes with every
90
+ # element's outcome; a failed element rolls itself back, the others are
91
+ # kept.
92
+ def atomic(enabled = true)
93
+ @atomic_declared = true
94
+ @atomic = enabled
95
+ end
96
+
97
+ # The old name for `atomic` (009 R-11), which read as "stop early".
86
98
  def fail_fast(enabled = true)
87
- @fail_fast = enabled
99
+ @fail_fast_site = caller_locations(1, 1).first
100
+ @atomic = enabled
101
+ warn_deprecation(@fail_fast_site, "map :#{@name} declares fail_fast. Use atomic (same meaning).")
88
102
  end
89
103
 
90
104
  def build
105
+ if @atomic_declared && @fail_fast_site
106
+ raise RubyReactor::Error::ValidationError.new(
107
+ "map :#{@name} declares both fail_fast and atomic; declare only atomic", step: @name
108
+ )
109
+ end
110
+
91
111
  dependencies = extract_dependencies_from_mappings
92
112
  dependencies << @source_enumerable.step_name if @source_enumerable.is_a?(RubyReactor::Template::Result)
93
113
 
@@ -123,14 +143,12 @@ module RubyReactor
123
143
  strict_ordering: { source: RubyReactor::Template::Value.new(@strict_ordering) },
124
144
  batch_size: { source: RubyReactor::Template::Value.new(@batch_size) },
125
145
  collect_block: { source: RubyReactor::Template::Value.new(@collect_block) },
126
- fail_fast: { source: RubyReactor::Template::Value.new(@fail_fast) },
146
+ atomic: { source: RubyReactor::Template::Value.new(@atomic) },
127
147
  fan_out: { source: RubyReactor::Template::Value.new(@fan_out) }
128
148
  },
129
149
  run_block: nil,
130
150
  compensate_block: nil,
131
151
  undo_block: nil,
132
- conditions: [],
133
- guards: [],
134
152
  dependencies: dependencies.uniq,
135
153
  args_validator: nil,
136
154
  output_validator: nil
@@ -7,13 +7,14 @@ module RubyReactor
7
7
  include RubyReactor::Dsl::ValidationHelpers
8
8
  include RubyReactor::Dsl::Lockable::ClassMethods
9
9
  include RubyReactor::Dsl::Retryable
10
+ include RubyReactor::Dsl::DefinitionWarnings
10
11
 
11
12
  COORDINATION_MACROS = {
12
13
  lock_config: "with_lock", semaphore_config: "with_semaphore", rate_limit_config: "with_rate_limit",
13
14
  period_config: "with_period", ordered_lock_config: "with_ordered_lock"
14
15
  }.freeze
15
16
 
16
- attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
17
+ attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
17
18
  :dependencies, :args_validator, :output_validator
18
19
 
19
20
  def initialize(name, impl = nil, reactor = nil)
@@ -24,8 +25,6 @@ module RubyReactor
24
25
  @run_block = nil
25
26
  @compensate_block = nil
26
27
  @undo_block = nil
27
- @conditions = []
28
- @guards = []
29
28
  @dependencies = []
30
29
  @arg_validations = []
31
30
  @validate_args_input = nil
@@ -36,11 +35,6 @@ module RubyReactor
36
35
  @rule_sites = []
37
36
  end
38
37
 
39
- # Deprecation notices print once per declaration site for the process.
40
- def self.deprecation_sites
41
- @deprecation_sites ||= Set.new
42
- end
43
-
44
38
  def argument(name, source, type = nil, transform: nil, **predicates)
45
39
  @arguments[name] = {
46
40
  source: source,
@@ -78,12 +72,15 @@ module RubyReactor
78
72
  @undo_block = block
79
73
  end
80
74
 
81
- def where(&predicate)
82
- @conditions << predicate
75
+ # `where`/`guard` were a second, pre-body skip mechanism with failure
76
+ # rules of their own (008 R-15). A step decides to skip itself from its
77
+ # body instead, so both stay only to name the replacement.
78
+ def where(*)
79
+ raise_removed_condition!(:where)
83
80
  end
84
81
 
85
- def guard(&guard_fn)
86
- @guards << guard_fn
82
+ def guard(*)
83
+ raise_removed_condition!(:guard)
87
84
  end
88
85
 
89
86
  def wait_for(*step_names)
@@ -148,8 +145,6 @@ module RubyReactor
148
145
  run_block: @run_block,
149
146
  compensate_block: @compensate_block,
150
147
  undo_block: @undo_block,
151
- conditions: @conditions,
152
- guards: @guards,
153
148
  dependencies: @dependencies,
154
149
  args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
155
150
  output_validator: @output_validator,
@@ -167,6 +162,15 @@ module RubyReactor
167
162
 
168
163
  private
169
164
 
165
+ def raise_removed_condition!(keyword)
166
+ raise RubyReactor::Error::DeprecatedDslError.new(
167
+ "`#{keyword}` inside a step block has been removed (`where`/`guard` are gone). To skip " \
168
+ ":#{@name}, return `Skipped(value)` (or call `skip!(value)`) from its `run` body; the " \
169
+ "reactor continues with that value, exactly as for a Success.",
170
+ step: @name
171
+ )
172
+ end
173
+
170
174
  # The same primitive declared BOTH inline and on the step class is
171
175
  # ambiguous — two keys for one slot of the fixed acquisition order, and
172
176
  # `StepConfig`'s readers would silently let the inline one win. Refuse it
@@ -236,27 +240,15 @@ module RubyReactor
236
240
  "the step instead (`validate_inputs` in #{target}).")
237
241
  end
238
242
 
239
- def warn_deprecation(site, message)
240
- location = "#{site.path}:#{site.lineno}"
241
- return unless StepBuilder.deprecation_sites.add?(location)
242
-
243
- warn "[RubyReactor] DEPRECATION: #{location} #{reactor_label} #{message} " \
244
- "Removal no earlier than the next MAJOR."
245
- end
246
-
247
243
  def owned_contract
248
244
  @inline_contract || (@impl.input_contract if @impl.respond_to?(:declares_inputs?) && @impl.declares_inputs?)
249
245
  end
250
-
251
- def reactor_label
252
- @reactor&.name || @reactor.inspect
253
- end
254
246
  end
255
247
 
256
248
  class StepConfig
257
249
  NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze
258
250
 
259
- attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
251
+ attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
260
252
  :dependencies, :args_validator, :output_validator, :async_dispatch,
261
253
  :inline_contract
262
254
 
@@ -268,8 +260,6 @@ module RubyReactor
268
260
  @run_block = config[:run_block]
269
261
  @compensate_block = config[:compensate_block]
270
262
  @undo_block = config[:undo_block]
271
- @conditions = config[:conditions] || []
272
- @guards = config[:guards] || []
273
263
  @dependencies = config[:dependencies] || []
274
264
  @args_validator = config[:args_validator]
275
265
  @output_validator = config[:output_validator]
@@ -358,6 +348,51 @@ module RubyReactor
358
348
  input_contract ? input_contract.enforce!(args) : args
359
349
  end
360
350
 
351
+ # The step's `argument` wiring resolved against `context`: each source,
352
+ # then its `transform`. The one copy every process uses (StepExecutor,
353
+ # StepWorker). Any raise becomes an `ArgumentResolutionError`, a
354
+ # never-started failure attributed to this step (008 R-06) — except a
355
+ # park signal (a worker waiting on an async result), which is not a failure.
356
+ def resolve_arguments(context)
357
+ arguments.to_h do |arg_name, arg_config|
358
+ value = arg_config[:source].resolve(context)
359
+ value = arg_config[:transform].call(value) if arg_config[:transform]
360
+ [arg_name, value]
361
+ end
362
+ rescue Error::ExecutionParked
363
+ raise
364
+ rescue Error::Rescuable => e
365
+ raise never_started(Error::ArgumentResolutionError, "could not resolve its arguments", e)
366
+ end
367
+
368
+ # Rollback dispatch, the same wherever the step rolls back
369
+ # (CompensationManager, StepWorker): the inline block, else the class
370
+ # step, else Skipped — nothing defined, rollback continues. Coordination,
371
+ # trace and middleware stay with the caller.
372
+ def call_compensate(error, arguments, context)
373
+ catch(StepSignals::TAG) do
374
+ if compensate_block
375
+ compensate_block.call(error, wrap_inputs(arguments), context)
376
+ elsif has_impl?
377
+ impl.compensate(error, arguments, context)
378
+ else
379
+ RubyReactor.Skipped()
380
+ end
381
+ end
382
+ end
383
+
384
+ def call_undo(result_value, arguments, context)
385
+ catch(StepSignals::TAG) do
386
+ if undo_block
387
+ undo_block.call(result_value, wrap_inputs(arguments), context)
388
+ elsif has_impl?
389
+ impl.undo(result_value, arguments, context)
390
+ else
391
+ RubyReactor.Skipped()
392
+ end
393
+ end
394
+ end
395
+
361
396
  # The step's work as a reactor runs it. Coordination is NOT taken here:
362
397
  # the caller (StepExecutor / StepWorker) takes this config's effective
363
398
  # declarations — inline and class alike — in one fixed order around it.
@@ -381,6 +416,27 @@ module RubyReactor
381
416
  !@async_dispatch.nil?
382
417
  end
383
418
 
419
+ # Whether a success of this step is recorded for undo. `async_step` /
420
+ # `async_reactor` dispatches are independent units of work with their
421
+ # own compensation flows: the parent rolling back must not "undo" a
422
+ # dispatch whose unit runs (and may still succeed) elsewhere (008 R-10).
423
+ def rollback_tracked?
424
+ !async_dispatch?
425
+ end
426
+
427
+ # Whether a run of this step cut short by an interruption is still
428
+ # tracked for undo: the construct's own definition decides (008 R-16).
429
+ def undoes_partial_run?
430
+ rollback_tracked? && has_impl? && impl.respond_to?(:undoes_partial_run?) && impl.undoes_partial_run?
431
+ end
432
+
433
+ # What the undo record keeps of the step's resolved arguments: all of
434
+ # them, unless the step reads its rollback state elsewhere (a map reads
435
+ # its declaration and the map records, 009 R-14).
436
+ def rollback_arguments(resolved)
437
+ has_impl? && impl.respond_to?(:rollback_arguments) ? impl.rollback_arguments(resolved) : resolved
438
+ end
439
+
384
440
  def has_impl?
385
441
  !@impl.nil?
386
442
  end
@@ -403,14 +459,17 @@ module RubyReactor
403
459
  (retry_config[:max_attempts] || 0) > 1
404
460
  end
405
461
 
406
- def should_run?(context)
407
- @conditions.all? { |condition| condition.call(context) } &&
408
- @guards.all? { |guard| guard.call(context) }
409
- end
410
-
411
462
  def interrupt?
412
463
  false
413
464
  end
465
+
466
+ private
467
+
468
+ def never_started(error_class, what, cause)
469
+ error = error_class.new("Step '#{name}' #{what}: #{cause.message}", step: name, original_error: cause)
470
+ error.set_backtrace(cause.backtrace)
471
+ error
472
+ end
414
473
  end
415
474
  end
416
475
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Error
5
+ # A step's `argument` source, `transform` or result path raised. The step's
6
+ # body never started, so it is never compensated; completed steps are
7
+ # undone (008 R-06). The same inputs fail the same way, so never retried.
8
+ class ArgumentResolutionError < Base
9
+ attr_reader :exception_class
10
+
11
+ def initialize(message, step:, original_error:, context: nil)
12
+ super(message, step: step, context: context, original_error: original_error)
13
+ @exception_class = original_error.class.name
14
+ end
15
+
16
+ def retryable? = false
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Error
5
+ # `rescue Error::Rescuable` catches every exception reactor code can raise —
6
+ # standard or not (`NotImplementedError`, `SystemStackError`, a custom
7
+ # `Exception` subclass) — so it fails its step and rolls back (008 R-16).
8
+ #
9
+ # It lets through only interruptions: exceptions raised INTO running code
10
+ # from outside it. A signal (incl. `Interrupt`, `Sidekiq::Shutdown`), an
11
+ # exit, running out of memory, and the one an enclosing `Timeout.timeout`
12
+ # raises — rescuing that would stop the caller's timeout from ever firing.
13
+ # Running rollback code during any of them is unsafe; the executor marks
14
+ # the run `aborted` for a manual undo instead.
15
+ #
16
+ # Nor does it match `RollbackHandedOff`, a control signal (009 R-04): it
17
+ # must pass the `rescue Error::Rescuable` sites between `MapStep#undo` and
18
+ # the executor (`CompensationManager#undo_step`, `StepExecutor`), which
19
+ # would otherwise record it as an undo failure.
20
+ module Rescuable
21
+ INTERRUPTIONS = [SignalException, SystemExit, NoMemoryError].freeze
22
+
23
+ def self.===(exception)
24
+ exception.is_a?(Exception) && !interruption?(exception) && !exception.is_a?(RollbackHandedOff)
25
+ end
26
+
27
+ def self.interruption?(exception)
28
+ INTERRUPTIONS.any? { |klass| exception.is_a?(klass) } ||
29
+ (defined?(::Timeout::ExitException) && exception.is_a?(::Timeout::ExitException))
30
+ end
31
+ end
32
+ end
33
+ end