ruby_reactor 0.3.0 → 0.3.2

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +145 -9
  3. data/documentation/README.md +20 -8
  4. data/documentation/async_reactors.md +46 -34
  5. data/documentation/core_concepts.md +75 -61
  6. data/documentation/examples/inventory_management.md +2 -3
  7. data/documentation/examples/order_processing.md +92 -77
  8. data/documentation/examples/payment_processing.md +28 -117
  9. data/documentation/getting_started.md +112 -94
  10. data/documentation/interrupts.md +9 -7
  11. data/documentation/locks_and_semaphores.md +459 -0
  12. data/documentation/retry_configuration.md +19 -14
  13. data/documentation/testing.md +994 -0
  14. data/lib/ruby_reactor/configuration.rb +19 -2
  15. data/lib/ruby_reactor/context.rb +13 -5
  16. data/lib/ruby_reactor/context_serializer.rb +55 -4
  17. data/lib/ruby_reactor/dsl/lockable.rb +130 -0
  18. data/lib/ruby_reactor/dsl/reactor.rb +3 -2
  19. data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
  20. data/lib/ruby_reactor/executor/result_handler.rb +27 -2
  21. data/lib/ruby_reactor/executor/retry_manager.rb +15 -7
  22. data/lib/ruby_reactor/executor/step_executor.rb +29 -99
  23. data/lib/ruby_reactor/executor.rb +148 -15
  24. data/lib/ruby_reactor/lock.rb +92 -0
  25. data/lib/ruby_reactor/map/collector.rb +16 -15
  26. data/lib/ruby_reactor/map/element_executor.rb +90 -104
  27. data/lib/ruby_reactor/map/execution.rb +2 -1
  28. data/lib/ruby_reactor/map/helpers.rb +2 -1
  29. data/lib/ruby_reactor/map/result_enumerator.rb +1 -1
  30. data/lib/ruby_reactor/period.rb +67 -0
  31. data/lib/ruby_reactor/rate_limit.rb +74 -0
  32. data/lib/ruby_reactor/reactor.rb +175 -16
  33. data/lib/ruby_reactor/rspec/helpers.rb +17 -0
  34. data/lib/ruby_reactor/rspec/matchers.rb +423 -0
  35. data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
  36. data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
  37. data/lib/ruby_reactor/rspec.rb +18 -0
  38. data/lib/ruby_reactor/semaphore.rb +58 -0
  39. data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +10 -5
  40. data/lib/ruby_reactor/sidekiq_workers/worker.rb +69 -9
  41. data/lib/ruby_reactor/step/compose_step.rb +0 -1
  42. data/lib/ruby_reactor/step/map_step.rb +11 -18
  43. data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
  44. data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +32 -24
  47. data/lib/ruby_reactor.rb +119 -10
  48. metadata +16 -3
data/lib/ruby_reactor.rb CHANGED
@@ -4,6 +4,11 @@ require "zeitwerk"
4
4
  require "pathname"
5
5
  require_relative "ruby_reactor/registry"
6
6
  require_relative "ruby_reactor/utils/code_extractor"
7
+ require_relative "ruby_reactor/dsl/lockable" # Add this
8
+ require_relative "ruby_reactor/lock"
9
+ require_relative "ruby_reactor/semaphore"
10
+ require_relative "ruby_reactor/period"
11
+ require_relative "ruby_reactor/rate_limit"
7
12
 
8
13
  # Load dry-validation if available (for validation features)
9
14
  begin
@@ -20,7 +25,7 @@ rescue LoadError
20
25
  end
21
26
 
22
27
  loader = Zeitwerk::Loader.for_gem
23
- loader.inflector.inflect("api" => "API")
28
+ loader.inflector.inflect("api" => "API", "rspec" => "RSpec")
24
29
  loader.setup
25
30
 
26
31
  module RubyReactor
@@ -39,20 +44,76 @@ module RubyReactor
39
44
  def failure?
40
45
  false
41
46
  end
47
+
48
+ def skipped?
49
+ false
50
+ end
51
+
52
+ def to_h
53
+ { success: true, value: @value }
54
+ end
55
+ end
56
+
57
+ # A "clean halt" signal. Two ways to produce one:
58
+ #
59
+ # 1. Implicitly, when a reactor's `with_period` gate finds the bucket has
60
+ # already been claimed. The executor short-circuits before any step
61
+ # runs.
62
+ #
63
+ # 2. Explicitly, by returning `RubyReactor.Skipped(reason: "...")` from a
64
+ # step's `run` block. The reactor halts immediately — no further steps,
65
+ # and crucially **no compensation** of already-completed steps. Use this
66
+ # when a step discovers that the rest of the workflow is not needed
67
+ # (e.g. "user already opted out", "nothing to do this round") and the
68
+ # partial progress is still correct to keep.
69
+ #
70
+ # Subclass of Success so callers that only check `success?` continue to work;
71
+ # `skipped?` distinguishes it.
72
+ class Skipped < Success
73
+ attr_reader :reason, :period_key, :step_name
74
+
75
+ def initialize(reason: nil, period_key: nil, step_name: nil)
76
+ super(nil)
77
+ @reason = reason
78
+ @period_key = period_key
79
+ @step_name = step_name
80
+ end
81
+
82
+ def skipped?
83
+ true
84
+ end
42
85
  end
43
86
 
44
87
  class Failure
45
88
  attr_reader :error, :retryable, :step_name, :inputs, :backtrace, :reactor_name, :step_arguments, :exception_class,
46
89
  :file_path, :line_number, :code_snippet, :validation_errors
47
90
 
48
- # rubocop:disable Metrics/ParameterLists
91
+ # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
49
92
  def initialize(error, retryable: nil, step_name: nil, inputs: {}, backtrace: nil, redact_inputs: [],
50
93
  reactor_name: nil, step_arguments: {}, exception_class: nil,
51
94
  file_path: nil, line_number: nil, code_snippet: nil, invalid_payload: false, validation_errors: nil)
52
- # rubocop:enable Metrics/ParameterLists
95
+ # rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
53
96
  @error = error
97
+
98
+ # Handle case where error is a serialized hash (e.g. from async failure propagation)
99
+ if @error.is_a?(Hash)
100
+ attributes = extract_attributes_from_hash(@error)
101
+ @error = attributes[:error]
102
+ retryable = attributes[:retryable] if retryable.nil?
103
+ step_name ||= attributes[:step_name]
104
+ reactor_name ||= attributes[:reactor_name]
105
+ inputs = attributes[:inputs] if inputs.empty?
106
+ step_arguments = attributes[:step_arguments] if step_arguments.empty?
107
+ raw_backtrace ||= attributes[:backtrace] || backtrace
108
+ exception_class ||= attributes[:exception_class]
109
+ file_path ||= attributes[:file_path]
110
+ line_number ||= attributes[:line_number]
111
+ code_snippet ||= attributes[:code_snippet]
112
+ validation_errors ||= attributes[:validation_errors]
113
+ end
114
+
54
115
  @retryable = if retryable.nil?
55
- error.respond_to?(:retryable?) ? error.retryable? : true
116
+ @error.respond_to?(:retryable?) ? @error.retryable? : true
56
117
  else
57
118
  retryable
58
119
  end
@@ -60,10 +121,10 @@ module RubyReactor
60
121
  @reactor_name = reactor_name
61
122
  @inputs = inputs
62
123
  @step_arguments = step_arguments
63
- raw_backtrace = backtrace || (error.respond_to?(:backtrace) ? error.backtrace : caller)
124
+ raw_backtrace ||= backtrace || (@error.respond_to?(:backtrace) ? @error.backtrace : caller)
64
125
  @backtrace = filter_backtrace(raw_backtrace)
65
126
  @redact_inputs = redact_inputs
66
- @exception_class = exception_class || (error.is_a?(Exception) ? error.class.name : nil)
127
+ @exception_class = exception_class || (@error.is_a?(Exception) ? @error.class.name : nil)
67
128
  @file_path = file_path
68
129
  @line_number = line_number
69
130
  @code_snippet = code_snippet
@@ -83,6 +144,10 @@ module RubyReactor
83
144
  @retryable
84
145
  end
85
146
 
147
+ def skipped?
148
+ false
149
+ end
150
+
86
151
  def invalid_payload?
87
152
  @invalid_payload
88
153
  end
@@ -99,6 +164,28 @@ module RubyReactor
99
164
  msg.join("\n")
100
165
  end
101
166
 
167
+ def to_s
168
+ message
169
+ end
170
+
171
+ def to_h
172
+ {
173
+ success: false,
174
+ error: error_message,
175
+ step_name: @step_name,
176
+ inputs: @inputs,
177
+ redact_inputs: @redact_inputs,
178
+ reactor_name: @reactor_name,
179
+ step_arguments: @step_arguments,
180
+ exception_class: @exception_class,
181
+ file_path: @file_path,
182
+ line_number: @line_number,
183
+ code_snippet: @code_snippet,
184
+ validation_errors: @validation_errors,
185
+ backtrace: @backtrace
186
+ }
187
+ end
188
+
102
189
  private
103
190
 
104
191
  def build_header
@@ -147,10 +234,6 @@ module RubyReactor
147
234
  msg << backtrace.take(10).map { |line| " #{line}" }.join("\n")
148
235
  end
149
236
 
150
- def to_s
151
- message
152
- end
153
-
154
237
  def filter_backtrace(backtrace)
155
238
  return backtrace if ENV["RUBY_REACTOR_DEBUG"] == "true"
156
239
  return backtrace if backtrace.nil? || backtrace.empty?
@@ -177,6 +260,26 @@ module RubyReactor
177
260
  def error_message
178
261
  @error.respond_to?(:message) ? @error.message : @error.to_s
179
262
  end
263
+
264
+ def extract_attributes_from_hash(error_hash)
265
+ # Ensure indifferent access
266
+ err = ->(k) { error_hash[k.to_s] || error_hash[k.to_sym] }
267
+
268
+ {
269
+ error: err[:message] || err[:error] || error_hash,
270
+ retryable: err[:retryable],
271
+ step_name: err[:step_name],
272
+ reactor_name: err[:reactor_name],
273
+ inputs: err[:inputs] || {},
274
+ step_arguments: err[:step_arguments] || {},
275
+ backtrace: err[:backtrace],
276
+ exception_class: err[:exception_class],
277
+ file_path: err[:file_path],
278
+ line_number: err[:line_number],
279
+ code_snippet: err[:code_snippet],
280
+ validation_errors: err[:validation_errors]
281
+ }
282
+ end
180
283
  end
181
284
 
182
285
  # Async result for background job execution
@@ -211,6 +314,12 @@ module RubyReactor
211
314
  Failure.new(error, **kwargs)
212
315
  end
213
316
 
317
+ # Build a `Skipped` result. Return one from a step's `run` block to halt the
318
+ # reactor cleanly without triggering compensation of previous steps.
319
+ def self.Skipped(reason: nil, **kwargs)
320
+ Skipped.new(reason: reason, **kwargs)
321
+ end
322
+
214
323
  def self.configure
215
324
  yield(Configuration.instance) if block_given?
216
325
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_reactor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artur
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-13 00:00:00.000000000 Z
11
+ date: 2026-05-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-validation
@@ -107,7 +107,9 @@ files:
107
107
  - documentation/images/failed_order_processing.png
108
108
  - documentation/images/payment_workflow.png
109
109
  - documentation/interrupts.md
110
+ - documentation/locks_and_semaphores.md
110
111
  - documentation/retry_configuration.md
112
+ - documentation/testing.md
111
113
  - gui/.gitignore
112
114
  - gui/README.md
113
115
  - gui/eslint.config.js
@@ -139,7 +141,6 @@ files:
139
141
  - gui/vite.config.ts
140
142
  - gui/vitest.config.ts
141
143
  - lib/ruby_reactor.rb
142
- - lib/ruby_reactor/async_router.rb
143
144
  - lib/ruby_reactor/configuration.rb
144
145
  - lib/ruby_reactor/context.rb
145
146
  - lib/ruby_reactor/context_serializer.rb
@@ -147,6 +148,7 @@ files:
147
148
  - lib/ruby_reactor/dsl/compose_builder.rb
148
149
  - lib/ruby_reactor/dsl/interrupt_builder.rb
149
150
  - lib/ruby_reactor/dsl/interrupt_step_config.rb
151
+ - lib/ruby_reactor/dsl/lockable.rb
150
152
  - lib/ruby_reactor/dsl/map_builder.rb
151
153
  - lib/ruby_reactor/dsl/reactor.rb
152
154
  - lib/ruby_reactor/dsl/step_builder.rb
@@ -170,6 +172,7 @@ files:
170
172
  - lib/ruby_reactor/executor/retry_manager.rb
171
173
  - lib/ruby_reactor/executor/step_executor.rb
172
174
  - lib/ruby_reactor/interrupt_result.rb
175
+ - lib/ruby_reactor/lock.rb
173
176
  - lib/ruby_reactor/map/collector.rb
174
177
  - lib/ruby_reactor/map/dispatcher.rb
175
178
  - lib/ruby_reactor/map/element_executor.rb
@@ -177,10 +180,19 @@ files:
177
180
  - lib/ruby_reactor/map/helpers.rb
178
181
  - lib/ruby_reactor/map/result_enumerator.rb
179
182
  - lib/ruby_reactor/max_retries_exhausted_failure.rb
183
+ - lib/ruby_reactor/period.rb
184
+ - lib/ruby_reactor/rate_limit.rb
180
185
  - lib/ruby_reactor/reactor.rb
181
186
  - lib/ruby_reactor/registry.rb
182
187
  - lib/ruby_reactor/retry_context.rb
183
188
  - lib/ruby_reactor/retry_queued_result.rb
189
+ - lib/ruby_reactor/rspec.rb
190
+ - lib/ruby_reactor/rspec/helpers.rb
191
+ - lib/ruby_reactor/rspec/matchers.rb
192
+ - lib/ruby_reactor/rspec/step_executor_patch.rb
193
+ - lib/ruby_reactor/rspec/test_subject.rb
194
+ - lib/ruby_reactor/semaphore.rb
195
+ - lib/ruby_reactor/sidekiq_adapter.rb
184
196
  - lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb
185
197
  - lib/ruby_reactor/sidekiq_workers/map_element_worker.rb
186
198
  - lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb
@@ -191,6 +203,7 @@ files:
191
203
  - lib/ruby_reactor/storage/adapter.rb
192
204
  - lib/ruby_reactor/storage/configuration.rb
193
205
  - lib/ruby_reactor/storage/redis_adapter.rb
206
+ - lib/ruby_reactor/storage/redis_locking.rb
194
207
  - lib/ruby_reactor/template/base.rb
195
208
  - lib/ruby_reactor/template/dynamic_source.rb
196
209
  - lib/ruby_reactor/template/element.rb