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
@@ -0,0 +1,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module RSpec
5
+ # rubocop:disable Metrics/ModuleLength
6
+ module Matchers
7
+ # rubocop:disable Metrics/BlockLength
8
+ ::RSpec::Matchers.define :be_success do
9
+ match do |subject|
10
+ subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
11
+ subject.success?
12
+ end
13
+
14
+ failure_message do |subject|
15
+ result = subject.respond_to?(:result) ? subject.result : subject
16
+ if result&.failure?
17
+ format_failure_message(result)
18
+ elsif subject.respond_to?(:reactor_instance)
19
+ "expected reactor to be success, but failed (Status: #{subject.reactor_instance.context.status})"
20
+ else
21
+ "expected #{subject.inspect} to be success"
22
+ end
23
+ end
24
+
25
+ def format_failure_message(error)
26
+ # Safely extract values
27
+ err_msg = error.respond_to?(:error) ? error.error.to_s : error.to_s
28
+ ex_class = error.respond_to?(:exception_class) ? error.exception_class : nil
29
+ step = error.respond_to?(:step_name) ? error.step_name : nil
30
+ file = error.respond_to?(:file_path) ? error.file_path : nil
31
+ line = error.respond_to?(:line_number) ? error.line_number : nil
32
+ snippet = error.respond_to?(:code_snippet) ? error.code_snippet : nil
33
+ backtrace = error.respond_to?(:backtrace) ? error.backtrace : nil
34
+
35
+ lines = []
36
+ lines << "Error: #{ex_class || "UnknownError"}"
37
+ lines << err_msg.to_s
38
+ lines << "Step: :#{step}" if step
39
+ lines << "File: #{file}:#{line}" if file
40
+
41
+ append_snippet(lines, snippet)
42
+ append_backtrace(lines, backtrace)
43
+
44
+ lines.join("\n")
45
+ end
46
+
47
+ def append_snippet(lines, snippet)
48
+ return unless snippet.is_a?(Array) && !snippet.empty?
49
+
50
+ lines << ""
51
+ snippet.each do |s|
52
+ prefix = s[:target] ? "--> " : " "
53
+ lines << "#{prefix}#{s[:content]}"
54
+ end
55
+ end
56
+
57
+ def append_backtrace(lines, backtrace)
58
+ return unless backtrace && !backtrace.empty?
59
+
60
+ lines << ""
61
+ lines << "Backtrace:"
62
+ lines << backtrace.take(10).map { |l| "- #{l}" }
63
+ end
64
+ end
65
+
66
+ ::RSpec::Matchers.define :be_failure do
67
+ match do |subject|
68
+ subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
69
+ subject.failure?
70
+ end
71
+
72
+ failure_message do |_subject|
73
+ "expected reactor to be failure, but succeeded"
74
+ end
75
+ end
76
+
77
+ ::RSpec::Matchers.define :have_run_step do |step_name|
78
+ match do |subject|
79
+ subject.ensure_executed!
80
+ @trace = subject.reactor_instance.context.execution_trace
81
+ @entry = @trace.find { |t| t[:step].to_s == step_name.to_s }
82
+
83
+ return false unless @entry
84
+
85
+ matches_result?(subject, step_name) && matches_order?
86
+ end
87
+
88
+ def matches_result?(subject, step_name)
89
+ return true unless @check_result
90
+
91
+ actual_result = subject.step_result(step_name)
92
+ if @expected_result.is_a?(Regexp)
93
+ actual_result.to_s.match?(@expected_result)
94
+ else
95
+ values_match?(@expected_result, actual_result)
96
+ end
97
+ end
98
+
99
+ def matches_order?
100
+ return true unless @after_step
101
+
102
+ after_index = @trace.index(@entry)
103
+ before_entry = @trace.find { |t| t[:step].to_s == @after_step.to_s }
104
+
105
+ return false unless before_entry
106
+
107
+ after_index > @trace.index(before_entry)
108
+ end
109
+
110
+ chain :returning do |value|
111
+ @check_result = true
112
+ @expected_result = value
113
+ end
114
+
115
+ chain :after do |step|
116
+ @after_step = step
117
+ end
118
+
119
+ failure_message do |subject|
120
+ msg = "expected reactor to have run step :#{step_name}"
121
+ if @check_result
122
+ actual_result = subject.step_result(step_name)
123
+ msg += " returning #{@expected_result.inspect}, but returned #{actual_result.inspect}"
124
+ end
125
+ msg += " after :#{@after_step}" if @after_step
126
+ msg
127
+ end
128
+ end
129
+
130
+ ::RSpec::Matchers.define :have_retried_step do |step_name|
131
+ match do |subject|
132
+ subject.ensure_executed!
133
+ attempts = subject.reactor_instance.context.retry_context.attempts_for_step(step_name)
134
+ retries = attempts - 1
135
+
136
+ if @expected_retries
137
+ retries == @expected_retries
138
+ else
139
+ retries.positive?
140
+ end
141
+ end
142
+
143
+ chain :times do |count|
144
+ @expected_retries = count
145
+ end
146
+
147
+ failure_message do |_subject|
148
+ msg = "expected reactor to have retried step :#{step_name}"
149
+ msg += " #{@expected_retries} times" if @expected_retries
150
+ msg
151
+ end
152
+ end
153
+
154
+ ::RSpec::Matchers.define :have_validation_error do |field|
155
+ match do |subject|
156
+ subject.ensure_executed!
157
+ return false unless subject.failure?
158
+
159
+ # Try to get validation errors from failure reason
160
+ reason = subject.reactor_instance.context.failure_reason || {}
161
+
162
+ # If failure is InputValidationError, it might be serialized differently
163
+ # Or stored in validation_errors key
164
+ errors = reason["validation_errors"] || reason[:validation_errors]
165
+
166
+ if errors
167
+ errors.key?(field.to_s) || errors.key?(field.to_sym)
168
+ else
169
+ false
170
+ end
171
+ end
172
+
173
+ failure_message do |_subject|
174
+ "expected reactor to have validation error on :#{field}"
175
+ end
176
+ end
177
+
178
+ # Matcher to check if reactor is paused at an interrupt
179
+ ::RSpec::Matchers.define :be_paused do
180
+ match do |subject|
181
+ subject.ensure_executed!
182
+ subject.paused?
183
+ end
184
+
185
+ failure_message do |subject|
186
+ "expected reactor to be paused, but status was #{subject.reactor_instance.context.status}"
187
+ end
188
+
189
+ failure_message_when_negated do |_subject|
190
+ "expected reactor not to be paused, but it is"
191
+ end
192
+ end
193
+
194
+ # Matcher to check if reactor is paused at a specific interrupt step
195
+ # Works with both single and multiple concurrent interrupts
196
+ ::RSpec::Matchers.define :be_paused_at do |*step_names|
197
+ match do |subject|
198
+ subject.ensure_executed!
199
+ return false unless subject.paused?
200
+
201
+ ready_steps = subject.ready_interrupt_steps
202
+ step_names.all? { |name| ready_steps.include?(name.to_sym) }
203
+ end
204
+
205
+ failure_message do |subject|
206
+ if subject.paused?
207
+ ready_steps = subject.ready_interrupt_steps
208
+ if step_names.size == 1
209
+ "expected reactor to be paused at :#{step_names.first}, " \
210
+ "but ready interrupt steps are: #{ready_steps.inspect}"
211
+ else
212
+ "expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
213
+ "but ready interrupt steps are: #{ready_steps.inspect}"
214
+ end
215
+ else
216
+ "expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
217
+ "but status was #{subject.reactor_instance.context.status}"
218
+ end
219
+ end
220
+
221
+ failure_message_when_negated do |_subject|
222
+ "expected reactor not to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, but it is"
223
+ end
224
+ end
225
+
226
+ # Matcher to check the exact set of ready interrupt steps
227
+ ::RSpec::Matchers.define :have_ready_interrupts do |*expected_steps|
228
+ match do |subject|
229
+ subject.ensure_executed!
230
+ return false unless subject.paused?
231
+
232
+ actual_steps = subject.ready_interrupt_steps.sort
233
+ expected = expected_steps.map(&:to_sym).sort
234
+ actual_steps == expected
235
+ end
236
+
237
+ failure_message do |subject|
238
+ if subject.paused?
239
+ actual_steps = subject.ready_interrupt_steps
240
+ "expected ready interrupt steps to be #{expected_steps.map { |s| ":#{s}" }}, " \
241
+ "but got #{actual_steps.inspect}"
242
+ else
243
+ "expected reactor to be paused with ready interrupt steps, " \
244
+ "but status was #{subject.reactor_instance.context.status}"
245
+ end
246
+ end
247
+
248
+ failure_message_when_negated do |subject|
249
+ actual_steps = subject.ready_interrupt_steps
250
+ "expected ready interrupt steps not to be #{expected_steps.map { |s| ":#{s}" }}, " \
251
+ "but it was #{actual_steps.inspect}"
252
+ end
253
+ end
254
+
255
+ # ---------------------------------------------------------------------
256
+ # Lock / Semaphore / Rate-limit / Period state matchers
257
+ # ---------------------------------------------------------------------
258
+ #
259
+ # These assert against the live Redis state via the configured storage
260
+ # adapter, so they work for any test that has actually exercised the
261
+ # reactor (or interacted with the primitives directly).
262
+
263
+ def self.coordination_adapter
264
+ RubyReactor.configuration.storage_adapter
265
+ end
266
+
267
+ # Distinguishes `RubyReactor::Skipped` from a plain `Success`. Works on
268
+ # any object with a `skipped?` predicate.
269
+ #
270
+ # Examples:
271
+ # expect(result).to be_skipped
272
+ # expect(result).to be_skipped.because(:period)
273
+ # expect(result).to be_skipped.at_step(:second)
274
+ ::RSpec::Matchers.define :be_skipped do
275
+ match do |subject|
276
+ subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
277
+ actual = subject.respond_to?(:result) ? subject.result : subject
278
+ next false unless actual.respond_to?(:skipped?) && actual.skipped?
279
+ next false if @expected_reason && actual.reason != @expected_reason
280
+ next false if @expected_step && actual.step_name != @expected_step
281
+
282
+ true
283
+ end
284
+
285
+ chain :because do |reason|
286
+ @expected_reason = reason
287
+ end
288
+
289
+ chain :at_step do |step|
290
+ @expected_step = step
291
+ end
292
+
293
+ failure_message do |subject|
294
+ actual = subject.respond_to?(:result) ? subject.result : subject
295
+ if !actual.respond_to?(:skipped?) || !actual.skipped?
296
+ "expected result to be Skipped, got #{actual.class}"
297
+ elsif @expected_reason && actual.reason != @expected_reason
298
+ "expected Skipped reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
299
+ else
300
+ "expected Skipped at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
301
+ end
302
+ end
303
+
304
+ failure_message_when_negated do
305
+ "expected result not to be Skipped"
306
+ end
307
+ end
308
+
309
+ # Asserts that an exclusive lock is currently held in Redis. Subject is
310
+ # the user-provided lock key (without the "lock:" prefix).
311
+ #
312
+ # expect("order:42").to be_locked
313
+ # expect("order:42").to be_locked.by("ctx-abc")
314
+ ::RSpec::Matchers.define :be_locked do
315
+ match do |key|
316
+ info = Matchers.coordination_adapter.lock_info("lock:#{key}")
317
+ next false unless info
318
+ next true unless @expected_owner
319
+
320
+ info[:owner] == @expected_owner
321
+ end
322
+
323
+ chain :by do |owner|
324
+ @expected_owner = owner
325
+ end
326
+
327
+ failure_message do |key|
328
+ info = Matchers.coordination_adapter.lock_info("lock:#{key}")
329
+ if info.nil?
330
+ "expected lock 'lock:#{key}' to be held, but it is free"
331
+ else
332
+ "expected lock 'lock:#{key}' to be held by #{@expected_owner.inspect}, " \
333
+ "but is held by #{info[:owner].inspect}"
334
+ end
335
+ end
336
+
337
+ failure_message_when_negated do |key|
338
+ info = Matchers.coordination_adapter.lock_info("lock:#{key}")
339
+ "expected lock 'lock:#{key}' not to be held, but is held by #{info[:owner].inspect}"
340
+ end
341
+ end
342
+
343
+ # Asserts the number of unallocated semaphore tokens. Subject is the
344
+ # user-provided semaphore name (without the "semaphore:" prefix).
345
+ #
346
+ # expect("api_limit").to have_available_tokens(3)
347
+ ::RSpec::Matchers.define :have_available_tokens do |expected|
348
+ match do |name|
349
+ Matchers.coordination_adapter.semaphore_state(name)[:available] == expected
350
+ end
351
+
352
+ failure_message do |name|
353
+ state = Matchers.coordination_adapter.semaphore_state(name)
354
+ "expected semaphore '#{name}' to have #{expected} available tokens, " \
355
+ "got #{state[:available]} (held: #{state[:held]}, limit: #{state[:limit]})"
356
+ end
357
+ end
358
+
359
+ # Asserts the number of currently-checked-out semaphore tokens.
360
+ #
361
+ # expect("api_limit").to have_held_tokens(2)
362
+ ::RSpec::Matchers.define :have_held_tokens do |expected|
363
+ match do |name|
364
+ Matchers.coordination_adapter.semaphore_state(name)[:held] == expected
365
+ end
366
+
367
+ failure_message do |name|
368
+ state = Matchers.coordination_adapter.semaphore_state(name)
369
+ "expected semaphore '#{name}' to have #{expected} held tokens, " \
370
+ "got #{state[:held]} (available: #{state[:available]}, limit: #{state[:limit]})"
371
+ end
372
+ end
373
+
374
+ # Asserts the current rate-limit counter for a (key_base, period) pair.
375
+ # Use `.for(period_unit)` to specify which window.
376
+ #
377
+ # expect("stripe:42").to have_rate_limit_count(3).for(:second)
378
+ ::RSpec::Matchers.define :have_rate_limit_count do |expected|
379
+ match do |key_base|
380
+ raise ArgumentError, "have_rate_limit_count requires .for(period)" unless @period
381
+
382
+ Matchers.coordination_adapter.rate_limit_count(key_base, @period) == expected
383
+ end
384
+
385
+ chain :for do |period|
386
+ @period = period
387
+ end
388
+
389
+ failure_message do |key_base|
390
+ actual = Matchers.coordination_adapter.rate_limit_count(key_base, @period)
391
+ "expected rate-limit '#{key_base}' (#{@period}) count to be #{expected}, got #{actual}"
392
+ end
393
+ end
394
+
395
+ # Asserts that a `with_period` bucket has been marked. Use `.for(period)`.
396
+ #
397
+ # expect("daily_report:7").to be_period_marked.for(:day)
398
+ ::RSpec::Matchers.define :be_period_marked do
399
+ match do |key_base|
400
+ raise ArgumentError, "be_period_marked requires .for(period)" unless @period
401
+
402
+ Matchers.coordination_adapter.period_marker?(key_base, @period)
403
+ end
404
+
405
+ chain :for do |period|
406
+ @period = period
407
+ end
408
+
409
+ failure_message do |key_base|
410
+ "expected period bucket #{RubyReactor::Period.key(key_base, @period).inspect} to be marked, but it is not"
411
+ end
412
+
413
+ failure_message_when_negated do |key_base|
414
+ "expected period bucket #{RubyReactor::Period.key(key_base, @period).inspect} not to be marked, but it is"
415
+ end
416
+ end
417
+
418
+ # Add more matchers as per plan
419
+ # rubocop:enable Metrics/BlockLength
420
+ end
421
+ # rubocop:enable Metrics/ModuleLength
422
+ end
423
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_reactor"
4
+
5
+ module RubyReactor
6
+ module RSpec
7
+ # Patch StepExecutor to handle inline async execution during tests.
8
+ # This ensures that when a worker runs inline (e.g. Sidekiq::Testing.inline!),
9
+ # the calling executor can pick up the state change immediately and continue,
10
+ # preventing stalled execution in nested async scenarios.
11
+ module StepExecutorPatch
12
+ def execute_step(step_config)
13
+ # 1. Call original implementation
14
+ result = super
15
+
16
+ # 2. Add test-specific logic for inline async execution
17
+ # Only interfere if we got an AsyncResult and we are in a testing environment that supports inline execution
18
+ if should_check_inline_completion?(result)
19
+ # Check if it finished or paused inline (e.g. Sidekiq::Testing.inline!)
20
+ refresh_context_from_storage
21
+
22
+ # If the step itself now has a result, it means it ran inline
23
+ if @context.has_result?(step_config.name)
24
+ # If the step failed, we should return failure
25
+ return reconstruct_failure(@context.failure_reason) if @context.failed?
26
+
27
+ return nil # Continue to next step
28
+ end
29
+
30
+ # If the overall reactor finished or paused for other reasons (e.g. error in worker)
31
+ if @context.finished? || @context.status.to_s == "paused"
32
+ return reconstruct_failure(@context.failure_reason) if @context.failed?
33
+
34
+ if @context.status.to_s == "paused"
35
+ return RubyReactor::InterruptResult.new(
36
+ execution_id: @context.context_id,
37
+ intermediate_results: @context.intermediate_results
38
+ )
39
+ end
40
+ return nil # Finished successfully
41
+ end
42
+ end
43
+
44
+ # Return original result if no intervention needed
45
+ result
46
+ end
47
+
48
+ private
49
+
50
+ def refresh_context_from_storage
51
+ storage = RubyReactor::Configuration.instance.storage_adapter
52
+ reactor_class_name = @reactor_class.name
53
+ serialized = storage.retrieve_context(@context.context_id, reactor_class_name)
54
+ return unless serialized
55
+
56
+ reloaded_context = Context.deserialize_from_retry(serialized)
57
+
58
+ # Update local context state
59
+ @context.status = reloaded_context.status
60
+ @context.failure_reason = reloaded_context.failure_reason
61
+ @context.cancelled = reloaded_context.cancelled
62
+ @context.cancellation_reason = reloaded_context.cancellation_reason
63
+ @context.intermediate_results.merge!(reloaded_context.intermediate_results)
64
+ @context.execution_trace = reloaded_context.execution_trace
65
+ @context.private_data.merge!(reloaded_context.private_data)
66
+ @context.retry_context = reloaded_context.retry_context
67
+ @context.composed_contexts.merge!(reloaded_context.composed_contexts)
68
+ @context.map_operations.merge!(reloaded_context.map_operations)
69
+ @context.map_metadata = reloaded_context.map_metadata
70
+
71
+ # Also need to mark step as completed in dependency graph
72
+ reloaded_context.intermediate_results.each_key do |step_name|
73
+ @dependency_graph.complete_step(step_name.to_sym)
74
+ end
75
+ end
76
+
77
+ def should_check_inline_completion?(result)
78
+ return false unless result.is_a?(RubyReactor::AsyncResult) || result.is_a?(RubyReactor::RetryQueuedResult)
79
+ return true if defined?(Sidekiq::Testing) && Sidekiq::Testing.inline?
80
+
81
+ false
82
+ end
83
+ end
84
+ end
85
+ end