ruby_reactor 0.1.0 → 0.3.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -2
  3. data/README.md +177 -3
  4. data/Rakefile +25 -0
  5. data/documentation/data_pipelines.md +90 -84
  6. data/documentation/images/failed_order_processing.png +0 -0
  7. data/documentation/images/payment_workflow.png +0 -0
  8. data/documentation/interrupts.md +161 -0
  9. data/gui/.gitignore +24 -0
  10. data/gui/README.md +73 -0
  11. data/gui/eslint.config.js +23 -0
  12. data/gui/index.html +13 -0
  13. data/gui/package-lock.json +5925 -0
  14. data/gui/package.json +46 -0
  15. data/gui/postcss.config.js +6 -0
  16. data/gui/public/vite.svg +1 -0
  17. data/gui/src/App.css +42 -0
  18. data/gui/src/App.tsx +51 -0
  19. data/gui/src/assets/react.svg +1 -0
  20. data/gui/src/components/DagVisualizer.tsx +424 -0
  21. data/gui/src/components/Dashboard.tsx +163 -0
  22. data/gui/src/components/ErrorBoundary.tsx +47 -0
  23. data/gui/src/components/ReactorDetail.tsx +135 -0
  24. data/gui/src/components/StepInspector.tsx +492 -0
  25. data/gui/src/components/__tests__/DagVisualizer.test.tsx +140 -0
  26. data/gui/src/components/__tests__/ReactorDetail.test.tsx +111 -0
  27. data/gui/src/components/__tests__/StepInspector.test.tsx +408 -0
  28. data/gui/src/globals.d.ts +7 -0
  29. data/gui/src/index.css +14 -0
  30. data/gui/src/lib/utils.ts +13 -0
  31. data/gui/src/main.tsx +14 -0
  32. data/gui/src/test/setup.ts +11 -0
  33. data/gui/tailwind.config.js +11 -0
  34. data/gui/tsconfig.app.json +28 -0
  35. data/gui/tsconfig.json +7 -0
  36. data/gui/tsconfig.node.json +26 -0
  37. data/gui/vite.config.ts +8 -0
  38. data/gui/vitest.config.ts +13 -0
  39. data/lib/ruby_reactor/async_router.rb +12 -8
  40. data/lib/ruby_reactor/context.rb +35 -9
  41. data/lib/ruby_reactor/context_serializer.rb +15 -0
  42. data/lib/ruby_reactor/dependency_graph.rb +2 -0
  43. data/lib/ruby_reactor/dsl/compose_builder.rb +8 -0
  44. data/lib/ruby_reactor/dsl/interrupt_builder.rb +48 -0
  45. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +21 -0
  46. data/lib/ruby_reactor/dsl/map_builder.rb +14 -2
  47. data/lib/ruby_reactor/dsl/reactor.rb +12 -0
  48. data/lib/ruby_reactor/dsl/step_builder.rb +4 -0
  49. data/lib/ruby_reactor/executor/compensation_manager.rb +60 -27
  50. data/lib/ruby_reactor/executor/graph_manager.rb +2 -0
  51. data/lib/ruby_reactor/executor/result_handler.rb +118 -39
  52. data/lib/ruby_reactor/executor/retry_manager.rb +12 -1
  53. data/lib/ruby_reactor/executor/step_executor.rb +38 -4
  54. data/lib/ruby_reactor/executor.rb +86 -13
  55. data/lib/ruby_reactor/interrupt_result.rb +20 -0
  56. data/lib/ruby_reactor/map/collector.rb +71 -35
  57. data/lib/ruby_reactor/map/dispatcher.rb +162 -0
  58. data/lib/ruby_reactor/map/element_executor.rb +62 -56
  59. data/lib/ruby_reactor/map/execution.rb +44 -4
  60. data/lib/ruby_reactor/map/helpers.rb +44 -6
  61. data/lib/ruby_reactor/map/result_enumerator.rb +105 -0
  62. data/lib/ruby_reactor/reactor.rb +187 -1
  63. data/lib/ruby_reactor/registry.rb +25 -0
  64. data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -1
  65. data/lib/ruby_reactor/step/compose_step.rb +22 -6
  66. data/lib/ruby_reactor/step/map_step.rb +78 -19
  67. data/lib/ruby_reactor/storage/adapter.rb +32 -0
  68. data/lib/ruby_reactor/storage/redis_adapter.rb +213 -11
  69. data/lib/ruby_reactor/template/dynamic_source.rb +32 -0
  70. data/lib/ruby_reactor/utils/code_extractor.rb +31 -0
  71. data/lib/ruby_reactor/version.rb +1 -1
  72. data/lib/ruby_reactor/web/api.rb +206 -0
  73. data/lib/ruby_reactor/web/application.rb +53 -0
  74. data/lib/ruby_reactor/web/config.ru +5 -0
  75. data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +19 -0
  76. data/lib/ruby_reactor/web/public/assets/index-_z-6BvuM.css +1 -0
  77. data/lib/ruby_reactor/web/public/index.html +14 -0
  78. data/lib/ruby_reactor/web/public/vite.svg +1 -0
  79. data/lib/ruby_reactor.rb +94 -28
  80. data/llms-full.txt +66 -0
  81. data/llms.txt +7 -0
  82. metadata +66 -2
@@ -6,6 +6,61 @@ module RubyReactor
6
6
 
7
7
  attr_reader :context, :result, :undo_trace, :execution_trace
8
8
 
9
+ def self.find(id)
10
+ reactor_class_name = name
11
+ serialized_context = configuration.storage_adapter.retrieve_context(id, reactor_class_name)
12
+ raise Error::ValidationError, "Context '#{id}' not found" unless serialized_context
13
+
14
+ context = Context.deserialize_from_retry(serialized_context)
15
+ new(context)
16
+ end
17
+
18
+ def self.find_by_correlation_id(correlation_id)
19
+ reactor_class_name = name
20
+ context_id = configuration.storage_adapter.retrieve_context_id_by_correlation_id(
21
+ correlation_id,
22
+ reactor_class_name
23
+ )
24
+ raise Error::ValidationError, "Correlation ID '#{correlation_id}' not found" unless context_id
25
+
26
+ find(context_id)
27
+ end
28
+
29
+ def self.continue(id:, payload:, step_name:, idempotency_key: nil)
30
+ reactor = find(id)
31
+ result = reactor.continue(payload: payload, step_name: step_name, idempotency_key: idempotency_key)
32
+
33
+ if result.is_a?(RubyReactor::Failure) && result.respond_to?(:invalid_payload?) && result.invalid_payload?
34
+ # Raise exception to match expected behavior (strict mode for class method)
35
+ # We do NOT cancel the reactor, allowing the user to retry with valid payload
36
+ raise Error::InputValidationError, result.error
37
+ end
38
+
39
+ result
40
+ end
41
+
42
+ def self.continue_by_correlation_id(correlation_id:, payload:, step_name:, idempotency_key: nil)
43
+ reactor = find_by_correlation_id(correlation_id)
44
+ # We delegate to the class-level continue method to ensure auto-compensation logic applies
45
+ # by using the context ID found by find_by_correlation_id
46
+ continue(id: reactor.context.context_id, payload: payload, step_name: step_name, idempotency_key: idempotency_key)
47
+ end
48
+
49
+ def self.cancel(id:, reason:)
50
+ reactor = find(id)
51
+ reactor.cancel(reason)
52
+ end
53
+
54
+ def self.undo(id)
55
+ reactor = find(id)
56
+ reactor.undo
57
+ cancel(id: id, reason: "Undo triggered")
58
+ end
59
+
60
+ def self.configuration
61
+ RubyReactor::Configuration.instance
62
+ end
63
+
9
64
  def initialize(context = {})
10
65
  @context = context
11
66
  @result = :unexecuted
@@ -21,11 +76,13 @@ module RubyReactor
21
76
  configuration.async_router.perform_async(serialized_context)
22
77
  else
23
78
  # For sync reactors (potentially with async steps), execute normally
24
- executor = Executor.new(self.class, inputs)
79
+ context = @context.is_a?(Context) ? @context : nil
80
+ executor = Executor.new(self.class, inputs, context)
25
81
  @result = executor.execute
26
82
 
27
83
  @context = executor.context
28
84
 
85
+ # Merge traces
29
86
  @undo_trace = executor.undo_trace
30
87
  @execution_trace = executor.execution_trace
31
88
 
@@ -36,6 +93,55 @@ module RubyReactor
36
93
  end
37
94
  end
38
95
 
96
+ def continue(payload:, step_name:, idempotency_key: nil)
97
+ _ = idempotency_key
98
+
99
+ unless @context.current_step
100
+ raise Error::ValidationError, "Cannot resume: context does not have a current step (was it interrupted?)"
101
+ end
102
+
103
+ if @context.cancelled
104
+ raise Error::ValidationError,
105
+ "Cannot resume: reactor has been cancelled (Reason: #{@context.cancellation_reason})"
106
+ end
107
+
108
+ validate_continue_step!(step_name)
109
+
110
+ if (failure = validate_continue_payload(payload, step_name))
111
+ return failure
112
+ end
113
+
114
+ target_step = step_name
115
+ @context.set_result(target_step, payload)
116
+
117
+ # Resume execution
118
+ executor = Executor.new(self.class, {}, @context)
119
+ @result = executor.resume_execution
120
+
121
+ @context = executor.context
122
+ @undo_trace = executor.undo_trace
123
+ @execution_trace = executor.execution_trace
124
+
125
+ @result
126
+ rescue Error::InputValidationError => e
127
+ # This might catch other validations, but here we specifically want payload validation.
128
+ # The block above handles payload validation explicitly.
129
+ RubyReactor::Failure(e.message, invalid_payload: true)
130
+ end
131
+
132
+ def undo
133
+ executor = Executor.new(self.class, {}, @context)
134
+ executor.undo_all
135
+ executor.save_context
136
+ end
137
+
138
+ def cancel(reason)
139
+ @context.cancelled = true
140
+ @context.cancellation_reason = reason
141
+ @context.status = "cancelled"
142
+ save_context
143
+ end
144
+
39
145
  def validate!
40
146
  # Validate reactor configuration
41
147
  validate_steps!
@@ -71,5 +177,85 @@ module RubyReactor
71
177
 
72
178
  raise Error::DependencyError, "Dependency graph contains cycles"
73
179
  end
180
+
181
+ def validate_continue_step!(step_name)
182
+ return if step_name.to_s == @context.current_step.to_s
183
+
184
+ # Build graph to check if step is ready
185
+ graph_manager = Executor::GraphManager.new(self.class, DependencyGraph.new, @context)
186
+ graph_manager.build_and_validate!
187
+ graph_manager.mark_completed_steps_from_context
188
+ ready_steps = graph_manager.dependency_graph.ready_steps.map(&:name).map(&:to_s)
189
+
190
+ return if ready_steps.include?(step_name.to_s)
191
+
192
+ raise Error::ValidationError,
193
+ "Cannot resume: expected step '#{@context.current_step}' " \
194
+ "or ready steps #{ready_steps} but got '#{step_name}'"
195
+ end
196
+
197
+ def validate_continue_payload(payload, step_name)
198
+ step_config = self.class.steps[step_name.to_sym]
199
+ return unless step_config&.validation_schema
200
+
201
+ validation = step_config.validation_schema.call(payload)
202
+
203
+ return unless validation.failure?
204
+
205
+ # Track attempts
206
+ step_key = step_name.to_sym
207
+ @context.private_data[:interrupt_attempts] ||= {}
208
+ @context.private_data[:interrupt_attempts][step_key] ||= 0
209
+ @context.private_data[:interrupt_attempts][step_key] += 1
210
+
211
+ save_context # Persist the attempt count
212
+
213
+ current_attempts = @context.private_data[:interrupt_attempts][step_key]
214
+ max_attempts = step_config.max_attempts
215
+
216
+ if max_attempts != :infinity && current_attempts >= max_attempts
217
+ # Max attempts reached - Fail and Compensate
218
+ undo
219
+
220
+ # Instead of cancelling, we mark as failed so it shows up as failed in UI
221
+ @context.status = "failed"
222
+ @context.failure_reason = {
223
+ message: "Validation failed after #{max_attempts} attempts",
224
+ step_name: step_name,
225
+ errors: validation.errors.to_h,
226
+ payload: payload,
227
+ step_arguments: payload,
228
+ attempts: current_attempts,
229
+ validation_errors: validation.errors.to_h
230
+ }
231
+
232
+ save_context
233
+
234
+ return RubyReactor::Failure(
235
+ "Validation failed after #{max_attempts} attempts",
236
+ step_name: step_name,
237
+ step_arguments: payload,
238
+ validation_errors: validation.errors.to_h
239
+ )
240
+ end
241
+
242
+ failure = RubyReactor::Failure(validation.errors.to_h)
243
+ # We need a way to mark this failure as a validation failure
244
+ # For now, we rely on the error object inside Failure or just return Failure
245
+ # The PRD requires `result.invalid_payload?` to be true.
246
+ # Since we don't have that method on Failure yet, we might need to enhance Failure
247
+ # OR wrap it. For now, let's assume Failure wraps the error and we can check it.
248
+ # We'll use a specific error type to identify it.
249
+ failure.instance_variable_set(:@type, :input_validation)
250
+ def failure.invalid_payload? = true
251
+ failure
252
+ end
253
+
254
+ def save_context
255
+ storage = configuration.storage_adapter
256
+ reactor_class_name = self.class.name || "AnonymousReactor-#{self.class.object_id}"
257
+ serialized_context = ContextSerializer.serialize(@context)
258
+ storage.store_context(@context.context_id, serialized_context, reactor_class_name)
259
+ end
74
260
  end
75
261
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module RubyReactor
6
+ # Registry for dynamically created reactor classes (e.g. from inline map/compose)
7
+ # This avoids polluting the global constant namespace with runtime-generated constants.
8
+ class Registry
9
+ @reactors = Concurrent::Map.new
10
+
11
+ class << self
12
+ def register(name, klass)
13
+ @reactors[name] = klass
14
+ end
15
+
16
+ def find(name)
17
+ @reactors[name]
18
+ end
19
+
20
+ def clear!
21
+ @reactors.clear
22
+ end
23
+ end
24
+ end
25
+ end
@@ -37,8 +37,8 @@ module RubyReactor
37
37
 
38
38
  # Resume execution from the failed step
39
39
  executor = Executor.new(context.reactor_class, {}, context)
40
- executor.compensation_manager.undo_stack.concat(context.undo_stack)
41
40
  executor.resume_execution
41
+ executor.save_context
42
42
 
43
43
  # Return the executor (which now has the result stored in it)
44
44
  executor
@@ -29,12 +29,28 @@ module RubyReactor
29
29
  handle_execution_result(result)
30
30
  end
31
31
 
32
- def self.compensate(_reason, _arguments, _context)
33
- # TODO: Implement proper compensation for composed reactors
34
- # This requires tracking the execution state of the composed reactor
35
- # and being able to trigger compensation on its completed steps.
36
- # For now, we assume the composed reactor handles its own compensation
37
- # or that compensation is not needed for composed steps.
32
+ def self.compensate(_reason, arguments, context)
33
+ step_name = context.current_step
34
+ composed_data = context.composed_contexts[step_name]
35
+ return RubyReactor.Success() unless composed_data && composed_data[:context]
36
+
37
+ child_context = composed_data[:context]
38
+ executor = RubyReactor::Executor.new(arguments[:composed_reactor_class], {}, child_context)
39
+ executor.undo_all
40
+ executor.save_context
41
+
42
+ RubyReactor.Success()
43
+ end
44
+
45
+ def self.undo(_result, arguments, context)
46
+ step_name = context.current_step
47
+ composed_data = context.composed_contexts[step_name]
48
+ return RubyReactor.Success() unless composed_data && composed_data[:context]
49
+
50
+ child_context = composed_data[:context]
51
+ executor = RubyReactor::Executor.new(arguments[:composed_reactor_class], {}, child_context)
52
+ executor.undo_all
53
+ executor.save_context
38
54
 
39
55
  RubyReactor.Success()
40
56
  end
@@ -28,6 +28,9 @@ module RubyReactor
28
28
  inputs = {}
29
29
 
30
30
  mappings.each do |mapped_input_name, source|
31
+ # Handle serialized template objects (Hashes from Sidekiq)
32
+ source = ContextSerializer.deserialize_value(source) if source.is_a?(Hash) && source["_type"]
33
+
31
34
  value = if source.is_a?(RubyReactor::Template::Element)
32
35
  # Handle element reference
33
36
  # For now assuming element() refers to the current map's element
@@ -88,6 +91,25 @@ module RubyReactor
88
91
 
89
92
  link_contexts(child_context, context)
90
93
 
94
+ map_id = "#{context.context_id}:#{context.current_step}"
95
+ storage = RubyReactor.configuration.storage_adapter
96
+ storage.store_map_element_context_id(map_id, child_context.context_id, context.reactor_class.name)
97
+
98
+ # Set map metadata for failure handling
99
+ child_context.map_metadata = {
100
+ map_id: map_id,
101
+ parent_reactor_class_name: context.reactor_class.name,
102
+ index: nil # Inline map execution doesn't track index in metadata currently, but could
103
+ }
104
+
105
+ # Store reference in composed_contexts so the UI knows where to find elements
106
+ context.composed_contexts[context.current_step] = {
107
+ name: context.current_step,
108
+ type: :map_ref,
109
+ map_id: map_id,
110
+ element_reactor_class: arguments[:mapped_reactor_class].name
111
+ }
112
+
91
113
  executor = RubyReactor::Executor.new(arguments[:mapped_reactor_class], {}, child_context)
92
114
  executor.execute
93
115
  executor.result
@@ -114,8 +136,11 @@ module RubyReactor
114
136
  RubyReactor::Success(results)
115
137
  else
116
138
  # New behavior: extract successful values only
117
- successes = results.select(&:success?).map(&:value)
118
- RubyReactor::Success(successes)
139
+ # New behavior: extract successful values only IF fail_fast is true behavior implies only values
140
+ # However, if fail_fast is false, we want to return results as is, or if logic dictates otherwise.
141
+ # wait, if fail_fast=false, we expect Result objects so we can check if success/failure.
142
+ # If we return only successes, we hide failures.
143
+ RubyReactor::Success(results)
119
144
  end
120
145
  end
121
146
 
@@ -134,24 +159,58 @@ module RubyReactor
134
159
  def run_async(arguments, context, step_name)
135
160
  map_id = "#{context.context_id}:#{step_name}"
136
161
  context.map_operations[step_name.to_s] = map_id
137
- prepare_async_execution(context, map_id, arguments[:source].count)
162
+ prepare_async_execution(context, map_id, arguments[:source].size)
138
163
 
139
164
  reactor_class_info = build_reactor_class_info(arguments[:mapped_reactor_class], context, step_name)
140
165
 
141
- job_id = if arguments[:batch_size]
142
- storage = RubyReactor.configuration.storage_adapter
143
- storage.set_last_queued_index(map_id, arguments[:batch_size] - 1, context.reactor_class.name)
144
- queue_fan_out(
145
- map_id: map_id, arguments: arguments, context: context,
146
- reactor_class_info: reactor_class_info, step_name: step_name,
147
- limit: arguments[:batch_size]
148
- )
149
- else
150
- queue_single_worker(map_id: map_id, arguments: arguments, context: context,
151
- reactor_class_info: reactor_class_info, step_name: step_name)
152
- end
166
+ initialize_map_metadata(map_id, arguments, context, reactor_class_info)
167
+
168
+ job_id = dispatch_async_map(map_id, arguments, context, reactor_class_info, step_name)
169
+
170
+ # Store reference in composed_contexts so the UI knows where to find elements
171
+ context.composed_contexts[step_name.to_s] = {
172
+ name: step_name.to_s,
173
+ type: :map_ref,
174
+ map_id: map_id,
175
+ element_reactor_class: arguments[:mapped_reactor_class].name
176
+ }
177
+
178
+ RubyReactor::AsyncResult.new(
179
+ job_id: job_id,
180
+ intermediate_results: context.intermediate_results,
181
+ execution_id: context.context_id
182
+ )
183
+ end
184
+
185
+ def initialize_map_metadata(map_id, arguments, context, reactor_class_info)
186
+ storage = RubyReactor.configuration.storage_adapter
187
+ storage.initialize_map_operation(
188
+ map_id, arguments[:source].size, context.reactor_class.name,
189
+ strict_ordering: arguments[:strict_ordering], reactor_class_info: reactor_class_info
190
+ )
191
+ end
153
192
 
154
- RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: context.intermediate_results)
193
+ def dispatch_async_map(map_id, arguments, context, reactor_class_info, step_name)
194
+ if arguments[:batch_size]
195
+ # Use new Dispatcher with Backpressure
196
+ RubyReactor::Map::Dispatcher.perform(
197
+ map_id: map_id,
198
+ parent_context_id: context.context_id,
199
+ parent_reactor_class_name: context.reactor_class.name,
200
+ source: arguments[:source],
201
+ batch_size: arguments[:batch_size],
202
+ step_name: step_name,
203
+ argument_mappings: arguments[:argument_mappings],
204
+ strict_ordering: arguments[:strict_ordering],
205
+ mapped_reactor_class: arguments[:mapped_reactor_class],
206
+ fail_fast: arguments[:fail_fast]
207
+ )
208
+ queue_collector(map_id, context, step_name, arguments[:strict_ordering])
209
+ "map:#{map_id}"
210
+ else
211
+ queue_single_worker(map_id: map_id, arguments: arguments, context: context,
212
+ reactor_class_info: reactor_class_info, step_name: step_name)
213
+ end
155
214
  end
156
215
 
157
216
  def prepare_async_execution(context, map_id, count)
@@ -174,11 +233,11 @@ module RubyReactor
174
233
  # rubocop:enable Metrics/ParameterLists
175
234
  storage = RubyReactor.configuration.storage_adapter
176
235
  storage.initialize_map_operation(
177
- map_id, arguments[:source].count, context.reactor_class.name,
236
+ map_id, arguments[:source].size, context.reactor_class.name,
178
237
  strict_ordering: arguments[:strict_ordering], reactor_class_info: reactor_class_info
179
238
  )
180
239
 
181
- limit ||= arguments[:source].count
240
+ limit ||= arguments[:source].size
182
241
  first_job_id = nil
183
242
  arguments[:source].each_with_index do |element, index|
184
243
  break if index >= limit
@@ -225,7 +284,7 @@ module RubyReactor
225
284
  map_id: map_id, serialized_inputs: serialized_inputs,
226
285
  reactor_class_info: reactor_class_info, strict_ordering: arguments[:strict_ordering],
227
286
  parent_context_id: context.context_id, parent_reactor_class_name: context.reactor_class.name,
228
- step_name: step_name.to_s
287
+ step_name: step_name.to_s, fail_fast: arguments[:fail_fast]
229
288
  )
230
289
  end
231
290
  end
@@ -46,6 +46,38 @@ module RubyReactor
46
46
  def expire(key, seconds)
47
47
  raise NotImplementedError
48
48
  end
49
+
50
+ def store_correlation_id(correlation_id, context_id, reactor_class_name)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ def retrieve_context_id_by_correlation_id(correlation_id, reactor_class_name)
55
+ raise NotImplementedError
56
+ end
57
+
58
+ def delete_correlation_id(correlation_id, reactor_class_name)
59
+ raise NotImplementedError
60
+ end
61
+
62
+ def delete_context(context_id, reactor_class_name)
63
+ raise NotImplementedError
64
+ end
65
+
66
+ def scan_reactors(pattern: "*", count: 50)
67
+ raise NotImplementedError
68
+ end
69
+
70
+ def find_context_by_id(context_id)
71
+ raise NotImplementedError
72
+ end
73
+
74
+ def store_map_element_context_id(map_id, context_id, reactor_class_name)
75
+ raise NotImplementedError
76
+ end
77
+
78
+ def retrieve_map_element_context_ids(map_id, reactor_class_name)
79
+ raise NotImplementedError
80
+ end
49
81
  end
50
82
  end
51
83
  end