ruby_reactor 0.4.1 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 729d8c7da4954534a2775a360d79fc96326d76b5ca69c7361ca0433e4bd6571e
4
- data.tar.gz: e9e545d2ea937135f19c7c04697993125fdfc38d57fc71d6a7b0aede9bf5efa9
3
+ metadata.gz: 134fc0713b22bad85e193592696b24417cb3c30afe8e1e7cec41328df4dafeb5
4
+ data.tar.gz: 469b9dc316a61f0814b964ff1afd9292bf4a98790807e367ffda3c575c62e4f5
5
5
  SHA512:
6
- metadata.gz: 23aac29ebf4c4e018fc3ab4bffbc46d79fa68ea74d783034fb984094cb7d97e791fc194c767ab59fb4992be267c171f6cbcc1897fb6160df4bedeb88faa3f080
7
- data.tar.gz: edffc8600293e035e1d318a4f4b7f7240ec67aa06704e941465e06c6eadda16e1939c36e2a826bd0b92183728360cb3babab4b26b877548559f61a64f2977c4a
6
+ metadata.gz: 6c28f483542f87de327e23554783dfffc1d92b89c181c85d1317763aaa0a4f824f07cfbc8dbc9589f9b814bae2d4debe11acaf87c84a9b9f6b7a501110a71ff4
7
+ data.tar.gz: b856bce9fb30b8e2e8e1b27d01b4801ffa87e95b2214cccddd73d1b0447b5801dd3f90a722977e6955786b81e70b60ac2339450552df216df37fb334a05735b5
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.4.1"
2
+ ".": "0.5.0"
3
3
  }
data/.rubocop.yml CHANGED
@@ -14,6 +14,7 @@ AllCops:
14
14
  - db/**/*
15
15
  - config/**/*
16
16
  - demo_app/**/*
17
+ - .ruby-lsp/**/*
17
18
 
18
19
  Style/StringLiterals:
19
20
  EnforcedStyle: double_quotes
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/arturictus/ruby_reactor/compare/v0.4.1...v0.5.0) (2026-06-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * Middlewares & OpenTelemetry ([#32](https://github.com/arturictus/ruby_reactor/issues/32)) ([a9e10ce](https://github.com/arturictus/ruby_reactor/commit/a9e10ceb6fa6381ead57a5905931343f8d1182d1))
9
+
3
10
  ## [0.4.1](https://github.com/arturictus/ruby_reactor/compare/v0.4.0...v0.4.1) (2026-05-25)
4
11
 
5
12
 
data/README.md CHANGED
@@ -475,6 +475,8 @@ end
475
475
 
476
476
  By using `async true` with `batch_size`, the system applies **Back Pressure** to efficiently manage resources. [Read more about Back Pressure & Resource Management](documentation/data_pipelines.md#back-pressure--resource-management).
477
477
 
478
+ `batch_size` is optional: with `async true` alone, RubyReactor fans out one worker per element (defaulting the batch size to the full source size) and aggregates the outcomes into a `ResultEnumerator` — convenient for small collections, but with no back pressure. See [Async Without `batch_size`](documentation/data_pipelines.md#async-without-batch_size).
479
+
478
480
  #### Map with Dynamic Source (ActiveRecord)
479
481
 
480
482
  You can use a block for `source` to dynamically fetch data, such as from ActiveRecord queries. The result is wrapped in a `ResultEnumerator` for easy access to successes and failures.
@@ -859,6 +861,10 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
859
861
 
860
862
  Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), and calendar-bucketed dedup (`with_period`, returning `Skipped` results). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-async contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, and once-per-day/month/year scheduling patterns.
861
863
 
864
+ ### [Middlewares & OpenTelemetry](documentation/middlewares.md)
865
+
866
+ Hook into the execution lifecycle with observer middlewares. Covers the full set of lifecycle events (reactor, step, retry, compensation/undo, async hand-off, locks/semaphores), writing and registering custom middlewares (global and per-reactor), and the built-in `RubyReactor::OpenTelemetry` tracing middleware — span structure, input/argument redaction, distributed trace propagation across async/retry boundaries, and custom exporters.
867
+
862
868
  ### Examples
863
869
  - [Order Processing](documentation/examples/order_processing.md) - Complete order processing workflow example
864
870
  - [Payment Processing](documentation/examples/payment_processing.md) - Payment handling with compensation
@@ -871,7 +877,7 @@ Coordinate access to shared resources across processes with Redis-backed primiti
871
877
  - [X] `map` step to iterate over arrays in parallel
872
878
  - [X] `compose` special step to execute reactors as step
873
879
  - [X] `interrupt` to pause and resume reactors
874
- - [ ] Middlewares
880
+ - [X] Middlewares
875
881
  - [ ] Async ruby to parallelize same level steps
876
882
  - [x] Web dashboard to inspect reactor results and errors
877
883
  - [ ] Multiple storage adapters
@@ -880,7 +886,7 @@ Coordinate access to shared resources across processes with Redis-backed primiti
880
886
  - [ ] Multiple Async adapters
881
887
  - [X] Sidekiq
882
888
  - [ ] ActiveJob
883
- - [ ] OpenTelemetry support
889
+ - [X] OpenTelemetry support
884
890
  - [X] locks
885
891
 
886
892
  ## Development
@@ -8,7 +8,8 @@ module RubyReactor
8
8
  include Singleton
9
9
 
10
10
  attr_writer :sidekiq_queue, :sidekiq_retry_count, :logger, :async_router,
11
- :lock_snooze_base_delay, :lock_snooze_jitter, :lock_snooze_max_attempts
11
+ :lock_snooze_base_delay, :lock_snooze_jitter, :lock_snooze_max_attempts,
12
+ :middlewares
12
13
 
13
14
  def sidekiq_queue
14
15
  @sidekiq_queue ||= :default
@@ -54,5 +55,9 @@ module RubyReactor
54
55
  raise "Unknown storage adapter: #{storage.adapter}"
55
56
  end
56
57
  end
58
+
59
+ def middlewares
60
+ @middlewares ||= []
61
+ end
57
62
  end
58
63
  end
@@ -5,7 +5,8 @@ module RubyReactor
5
5
  attr_accessor :inputs, :intermediate_results, :private_data, :current_step, :retry_count, :concurrency_key,
6
6
  :retry_context, :reactor_class, :execution_trace, :inline_async_execution, :undo_stack,
7
7
  :parent_context, :root_context, :composed_contexts, :context_id, :map_operations, :map_metadata,
8
- :cancelled, :cancellation_reason, :parent_context_id, :retried_from_id, :status, :failure_reason
8
+ :cancelled, :cancellation_reason, :parent_context_id, :retried_from_id, :status, :failure_reason,
9
+ :middlewares
9
10
 
10
11
  def initialize(inputs = {}, reactor_class = nil)
11
12
  @context_id = SecureRandom.uuid
@@ -202,9 +202,7 @@ module RubyReactor
202
202
 
203
203
  hash = flatten_typed_failure(hash) if hash["_type"] == "Failure"
204
204
 
205
- if hash["code_snippet"].is_a?(Array) && !hash["code_snippet"].empty?
206
- return hash
207
- end
205
+ return hash if hash["code_snippet"].is_a?(Array) && !hash["code_snippet"].empty?
208
206
 
209
207
  file_path, line_number = resolve_failure_location(hash)
210
208
  return hash unless file_path && line_number
@@ -123,8 +123,12 @@ module RubyReactor
123
123
  @return_step
124
124
  end
125
125
 
126
- def middleware(middleware_class)
127
- middlewares << middleware_class
126
+ def middleware(middleware_class, **options)
127
+ middlewares << if options.empty?
128
+ middleware_class
129
+ else
130
+ [middleware_class, options]
131
+ end
128
132
  end
129
133
 
130
134
  def validate_inputs(inputs_hash)
@@ -51,61 +51,89 @@ module RubyReactor
51
51
 
52
52
  private
53
53
 
54
+ def middlewares
55
+ @context.middlewares || RubyReactor::MiddlewareRunner.new([])
56
+ end
57
+
54
58
  def compensate_step(step_config, error, arguments)
55
- compensate_result = if step_config.compensate_block
56
- step_config.compensate_block.call(error, arguments, @context)
57
- elsif step_config.has_impl?
58
- step_config.impl.compensate(error, arguments, @context)
59
- else
60
- RubyReactor.Success() # Default compensation
61
- end
62
-
63
- # Ensure we have a value to log
64
- logged_result = if compensate_result.respond_to?(:value)
65
- compensate_result.value
66
- elsif compensate_result.respond_to?(:error)
67
- compensate_result.error
68
- else
69
- compensate_result
70
- end
59
+ middlewares.on(:start_compensation, step_config.name, error, arguments, @context)
60
+ begin
61
+ compensate_result = if step_config.compensate_block
62
+ step_config.compensate_block.call(error, arguments, @context)
63
+ elsif step_config.has_impl?
64
+ step_config.impl.compensate(error, arguments, @context)
65
+ else
66
+ RubyReactor.Success() # Default compensation
67
+ end
71
68
 
72
- @context.execution_trace << {
73
- type: :compensate,
74
- step: step_config.name,
75
- timestamp: Time.now,
76
- result: logged_result,
77
- arguments: arguments
78
- }
79
- @undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
80
- compensate_result
69
+ # Ensure we have a value to log
70
+ logged_result = if compensate_result.respond_to?(:value)
71
+ compensate_result.value
72
+ elsif compensate_result.respond_to?(:error)
73
+ compensate_result.error
74
+ else
75
+ compensate_result
76
+ end
77
+
78
+ @context.execution_trace << {
79
+ type: :compensate,
80
+ step: step_config.name,
81
+ timestamp: Time.now,
82
+ result: logged_result,
83
+ arguments: arguments
84
+ }
85
+ @undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
86
+
87
+ if compensate_result.is_a?(RubyReactor::Failure)
88
+ middlewares.on(:failed_compensation, step_config.name, compensate_result, @context)
89
+ else
90
+ middlewares.on(:complete_compensation, step_config.name, compensate_result, @context)
91
+ end
92
+
93
+ compensate_result
94
+ rescue StandardError => e
95
+ middlewares.on(:failed_compensation, step_config.name, e, @context)
96
+ raise e
97
+ end
81
98
  end
82
99
 
83
100
  def undo_step(step_config, result, arguments)
84
- undo_result = if step_config.undo_block
85
- step_config.undo_block.call(result.value, arguments, @context)
86
- elsif step_config.has_impl?
87
- step_config.impl.undo(result.value, arguments, @context)
88
- else
89
- RubyReactor.Success()
90
- end
91
-
92
- # Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
93
- logged_result = if undo_result.respond_to?(:value)
94
- undo_result.value
95
- elsif undo_result.respond_to?(:error)
96
- undo_result.error
101
+ middlewares.on(:start_undo, step_config.name, result, arguments, @context)
102
+ begin
103
+ undo_result = if step_config.undo_block
104
+ step_config.undo_block.call(result.value, arguments, @context)
105
+ elsif step_config.has_impl?
106
+ step_config.impl.undo(result.value, arguments, @context)
97
107
  else
98
- undo_result
108
+ RubyReactor.Success()
99
109
  end
100
110
 
101
- @context.execution_trace << { type: :undo, step: step_config.name, timestamp: Time.now, result: logged_result,
102
- arguments: arguments }
103
- undo_result
104
- rescue StandardError => e
105
- # Log undo failure but don't halt the rollback process
106
- @context.execution_trace << { type: :undo_failure, step: step_config.name, timestamp: Time.now,
107
- error: e.message }
108
- RubyReactor.Failure(e)
111
+ # Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
112
+ logged_result = if undo_result.respond_to?(:value)
113
+ undo_result.value
114
+ elsif undo_result.respond_to?(:error)
115
+ undo_result.error
116
+ else
117
+ undo_result
118
+ end
119
+
120
+ @context.execution_trace << { type: :undo, step: step_config.name, timestamp: Time.now, result: logged_result,
121
+ arguments: arguments }
122
+
123
+ if undo_result.is_a?(RubyReactor::Failure)
124
+ middlewares.on(:failed_undo, step_config.name, undo_result, @context)
125
+ else
126
+ middlewares.on(:complete_undo, step_config.name, undo_result, @context)
127
+ end
128
+
129
+ undo_result
130
+ rescue StandardError => e
131
+ middlewares.on(:failed_undo, step_config.name, e, @context)
132
+ # Log undo failure but don't halt the rollback process
133
+ @context.execution_trace << { type: :undo_failure, step: step_config.name, timestamp: Time.now,
134
+ error: e.message }
135
+ RubyReactor.Failure(e)
136
+ end
109
137
  end
110
138
  end
111
139
  end
@@ -3,8 +3,9 @@
3
3
  module RubyReactor
4
4
  class Executor
5
5
  class RetryManager
6
- def initialize(context)
6
+ def initialize(context, middlewares = nil)
7
7
  @context = context
8
+ @middlewares = middlewares || context.middlewares || Executor.middlewares_for(context.reactor_class)
8
9
  end
9
10
 
10
11
  def execute_with_retry(step_config, reactor_class)
@@ -47,11 +48,10 @@ module RubyReactor
47
48
  @context.root_context || @context
48
49
  end
49
50
 
50
- puts "SERIALIZING CONTEXT: #{context_to_serialize.reactor_class.name}"
51
- puts "INPUTS KEYS: #{context_to_serialize.inputs.keys}" if context_to_serialize.respond_to?(:inputs)
52
-
53
51
  reactor_class_name = context_to_serialize.reactor_class.name
54
52
 
53
+ @middlewares.on(:before_async_enqueue, context_to_serialize)
54
+
55
55
  serialized_context = ContextSerializer.serialize(context_to_serialize)
56
56
 
57
57
  if @context.map_metadata
@@ -68,7 +68,8 @@ module RubyReactor
68
68
  parent_reactor_class_name: map_args[:parent_reactor_class_name],
69
69
  step_name: map_args[:step_name],
70
70
  batch_size: map_args[:batch_size],
71
- serialized_context: serialized_context
71
+ serialized_context: serialized_context,
72
+ fail_fast: map_args[:fail_fast]
72
73
  )
73
74
  else
74
75
  configuration.async_router.perform_in(delay, serialized_context, reactor_class_name)
@@ -111,6 +112,15 @@ module RubyReactor
111
112
  end
112
113
 
113
114
  def handle_retryable_failure(step_config, reactor_class, result)
115
+ attempt_number = @context.retry_context.attempts_for_step(step_config.name)
116
+ @middlewares.on(
117
+ :retry_attempt,
118
+ step_config.name,
119
+ attempt_number,
120
+ result.error,
121
+ @context
122
+ )
123
+
114
124
  # Check if we should requeue (async retry)
115
125
  is_async = reactor_class.async? || step_config.async? ||
116
126
  @context.root_context&.reactor_class&.async? ||
@@ -10,6 +10,7 @@ module RubyReactor
10
10
  @retry_manager = managers[:retry_manager]
11
11
  @result_handler = managers[:result_handler]
12
12
  @compensation_manager = managers[:compensation_manager]
13
+ @middlewares = managers[:middlewares] || context.middlewares || Executor.middlewares_for(reactor_class)
13
14
  end
14
15
 
15
16
  def execute_all_steps
@@ -61,12 +62,28 @@ module RubyReactor
61
62
  return RubyReactor.Success(@context.get_result(step_config.name))
62
63
  end
63
64
 
64
- if step_config.interrupt?
65
- handle_interrupt_step(step_config)
66
- elsif step_config.async? && !@context.inline_async_execution
67
- handle_async_step(step_config)
68
- else
69
- execute_step_with_retry(step_config)
65
+ resolved_arguments = resolve_arguments(step_config)
66
+
67
+ @middlewares.on(:start_step, step_config.name, resolved_arguments, @context)
68
+ completed = false
69
+ begin
70
+ result = if step_config.interrupt?
71
+ handle_interrupt_step(step_config)
72
+ elsif step_config.async? && !@context.inline_async_execution
73
+ handle_async_step(step_config)
74
+ else
75
+ execute_step_with_retry(step_config, resolved_arguments)
76
+ end
77
+ completed = true
78
+ if result.is_a?(RubyReactor::Failure)
79
+ @middlewares.on(:failed_step, step_config.name, result, @context)
80
+ else
81
+ @middlewares.on(:complete_step, step_config.name, result, @context)
82
+ end
83
+ result
84
+ rescue Exception => e # rubocop:disable Lint/RescueException
85
+ @middlewares.on(:failed_step, step_config.name, e, @context) unless completed
86
+ raise
70
87
  end
71
88
  end
72
89
 
@@ -95,24 +112,22 @@ module RubyReactor
95
112
  )
96
113
  end
97
114
 
98
- def execute_step_with_retry(step_config)
115
+ def execute_step_with_retry(step_config, resolved_arguments = nil)
116
+ resolved_arguments ||= resolve_arguments(step_config)
99
117
  result = @retry_manager.execute_with_retry(step_config, @reactor_class) do
100
- safe_execute_step_sync(step_config)
118
+ safe_execute_step_sync(step_config, resolved_arguments)
101
119
  end
102
120
 
103
121
  unless result.is_a?(RetryQueuedResult) || result.is_a?(RubyReactor::AsyncResult)
104
- resolved_arguments = resolve_arguments(step_config)
105
122
  @result_handler.handle_step_result(step_config, result, resolved_arguments)
106
123
  end
107
124
 
108
125
  result
109
126
  end
110
127
 
111
- def safe_execute_step_sync(step_config)
112
- resolved_arguments = {}
113
- execute_step_sync_without_result_handling(step_config) do |args|
114
- resolved_arguments = args
115
- end
128
+ def safe_execute_step_sync(step_config, resolved_arguments = nil)
129
+ resolved_arguments ||= resolve_arguments(step_config)
130
+ execute_step_sync_without_result_handling(step_config, resolved_arguments)
116
131
  rescue StandardError => e
117
132
  # Identify redacted inputs
118
133
  redact_inputs = @reactor_class.inputs.select { |_, config| config[:redact] }.keys
@@ -127,7 +142,7 @@ module RubyReactor
127
142
  )
128
143
  end
129
144
 
130
- def execute_step_sync(step_config)
145
+ def execute_step_sync(step_config, resolved_arguments = nil)
131
146
  @context.with_step(step_config.name) do
132
147
  # Check conditions and guards
133
148
  unless step_config.should_run?(@context)
@@ -136,7 +151,7 @@ module RubyReactor
136
151
  end
137
152
 
138
153
  # Resolve arguments
139
- resolved_arguments = resolve_arguments(step_config)
154
+ resolved_arguments ||= resolve_arguments(step_config)
140
155
 
141
156
  # Validate arguments if validator is defined
142
157
  validate_step_arguments(step_config, resolved_arguments)
@@ -150,7 +165,7 @@ module RubyReactor
150
165
  end
151
166
 
152
167
  # Execute step without handling the result (used during retries)
153
- def execute_step_sync_without_result_handling(step_config)
168
+ def execute_step_sync_without_result_handling(step_config, resolved_arguments = nil)
154
169
  @context.with_step(step_config.name) do
155
170
  # Check conditions and guards
156
171
  unless step_config.should_run?(@context)
@@ -159,7 +174,7 @@ module RubyReactor
159
174
  end
160
175
 
161
176
  # Resolve arguments
162
- resolved_arguments = resolve_arguments(step_config)
177
+ resolved_arguments ||= resolve_arguments(step_config)
163
178
 
164
179
  yield resolved_arguments if block_given?
165
180
 
@@ -181,6 +196,9 @@ module RubyReactor
181
196
  context_to_serialize = @context.root_context || @context
182
197
  reactor_class_name = context_to_serialize.reactor_class.name
183
198
 
199
+ # Inject OTel context before serialization
200
+ @middlewares.on(:before_async_enqueue, context_to_serialize)
201
+
184
202
  serialized_context = ContextSerializer.serialize(context_to_serialize)
185
203
 
186
204
  configuration.async_router.perform_async(