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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # Calendar-aligned bucket helpers for `with_period` dedup gating.
5
+ #
6
+ # A *bucket* is a deterministic string derived from the current UTC time and
7
+ # the configured period. Two calls in the same bucket dedup to the same
8
+ # Redis marker key; calls that cross a calendar boundary land in different
9
+ # buckets and run again.
10
+ module Period
11
+ SYMBOLIC_PERIODS = {
12
+ second: 1,
13
+ minute: 60,
14
+ hour: 60 * 60,
15
+ day: 60 * 60 * 24,
16
+ week: 60 * 60 * 24 * 7,
17
+ month: 60 * 60 * 24 * 31,
18
+ year: 60 * 60 * 24 * 366
19
+ }.freeze
20
+
21
+ # Build the bucket id for a given period at a given moment. UTC, calendar
22
+ # aligned for symbolic periods, index-based for integer seconds.
23
+ def self.bucket_id(every, now: Time.now.utc)
24
+ case every
25
+ when :second then now.strftime("%Y-%m-%dT%H-%M-%S")
26
+ when :minute then now.strftime("%Y-%m-%dT%H-%M")
27
+ when :hour then now.strftime("%Y-%m-%dT%H")
28
+ when :day then now.strftime("%Y-%m-%d")
29
+ when :week then now.strftime("%G-W%V")
30
+ when :month then now.strftime("%Y-%m")
31
+ when :year then now.strftime("%Y")
32
+ when Integer
33
+ raise ArgumentError, "Period seconds must be positive" unless every.positive?
34
+
35
+ "i#{now.to_i / every}"
36
+ else
37
+ raise ArgumentError, "Unknown period: #{every.inspect}"
38
+ end
39
+ end
40
+
41
+ # TTL for the marker. Twice the period length so the marker survives clock
42
+ # skew across the boundary and reliably dedups the very next attempt.
43
+ def self.ttl_seconds(every)
44
+ base = period_seconds(every)
45
+ base * 2
46
+ end
47
+
48
+ def self.period_seconds(every)
49
+ case every
50
+ when Symbol
51
+ SYMBOLIC_PERIODS.fetch(every) do
52
+ raise ArgumentError, "Unknown period: #{every.inspect}"
53
+ end
54
+ when Integer
55
+ raise ArgumentError, "Period seconds must be positive" unless every.positive?
56
+
57
+ every
58
+ else
59
+ raise ArgumentError, "Unknown period: #{every.inspect}"
60
+ end
61
+ end
62
+
63
+ def self.key(base, every, now: Time.now.utc)
64
+ "period:#{base}:#{bucket_id(every, now: now)}"
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # Distributed rate limiter (fixed-window counter, multi-window aware).
5
+ #
6
+ # A `RateLimit` is configured with one or more (period, limit) tuples.
7
+ # `check_and_increment!` atomically verifies every window has headroom and,
8
+ # if so, increments all of them. The check uses a single Lua script so
9
+ # nothing slips through between read and write.
10
+ #
11
+ # When any window is over-limit the call raises `ExceededError` carrying a
12
+ # `retry_after_seconds` hint (time until the tightest failing bucket rolls).
13
+ # The Sidekiq worker uses this hint to schedule a precise snooze.
14
+ class RateLimit
15
+ class ExceededError < StandardError
16
+ attr_reader :retry_after_seconds, :key_base, :limit, :period_seconds, :period_name
17
+
18
+ def initialize(message, retry_after_seconds:, key_base:, limit:, period_seconds:, period_name:)
19
+ super(message)
20
+ @retry_after_seconds = retry_after_seconds
21
+ @key_base = key_base
22
+ @limit = limit
23
+ @period_seconds = period_seconds
24
+ @period_name = period_name
25
+ end
26
+ end
27
+
28
+ attr_reader :key_base, :limits
29
+
30
+ # @param key_base [String] caller-provided key (e.g. "stripe:account_42")
31
+ # @param limits [Array<Hash>] each hash needs :period_seconds, :limit,
32
+ # and :name (used in the Redis bucket key and the error message)
33
+ def initialize(key_base, limits:)
34
+ @key_base = key_base
35
+ @limits = limits
36
+ end
37
+
38
+ def check_and_increment!
39
+ now = Time.now.to_i
40
+ keys = @limits.map { |spec| bucket_key(spec, now) }
41
+ argv = [now]
42
+ @limits.each do |spec|
43
+ argv << spec[:period_seconds]
44
+ argv << spec[:limit]
45
+ argv << spec[:period_seconds] * 2 # TTL: generous, auto-cleans stale buckets
46
+ end
47
+
48
+ allowed, retry_after, failed_index = adapter.rate_limit_check_and_increment(keys, argv)
49
+ return true if allowed == 1
50
+
51
+ failed = @limits[failed_index - 1]
52
+ raise ExceededError.new(
53
+ "Rate limit '#{@key_base}' exceeded (#{failed[:limit]}/#{failed[:name]}); " \
54
+ "retry in #{retry_after}s",
55
+ retry_after_seconds: retry_after,
56
+ key_base: @key_base,
57
+ limit: failed[:limit],
58
+ period_seconds: failed[:period_seconds],
59
+ period_name: failed[:name]
60
+ )
61
+ end
62
+
63
+ private
64
+
65
+ def bucket_key(spec, now)
66
+ bucket_id = now / spec[:period_seconds]
67
+ "rate:#{@key_base}:#{spec[:name]}:#{bucket_id}"
68
+ end
69
+
70
+ def adapter
71
+ RubyReactor.configuration.storage_adapter
72
+ end
73
+ end
74
+ end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
+ # rubocop:disable Metrics/ClassLength
4
5
  class Reactor
5
6
  include RubyReactor::Dsl::Reactor
7
+ include RubyReactor::Dsl::Lockable
6
8
 
7
9
  attr_reader :context, :result, :undo_trace, :execution_trace
8
10
 
@@ -64,34 +66,71 @@ module RubyReactor
64
66
  def initialize(context = {})
65
67
  @context = context
66
68
  @result = :unexecuted
67
- @undo_trace = []
68
- @execution_trace = []
69
+
70
+ if @context.is_a?(Context)
71
+ @execution_trace = @context.execution_trace || []
72
+ @undo_trace = @execution_trace.select { |e| e[:type] == :undo }
73
+ @result = reconstruct_result
74
+ else
75
+ @undo_trace = []
76
+ @execution_trace = []
77
+ end
69
78
  end
70
79
 
80
+ # rubocop:disable Metrics/MethodLength
71
81
  def run(inputs = {})
72
- if self.class.async?
73
- # For async reactors, enqueue the job and return immediately
74
- context = Context.new(inputs, self.class)
75
- serialized_context = ContextSerializer.serialize(context)
76
- configuration.async_router.perform_async(serialized_context)
82
+ # For all reactors, initialize context first to capture execution ID
83
+ @context = @context.is_a?(Context) ? @context : Context.new(inputs, self.class)
84
+
85
+ # Validate inputs
86
+ validation_result = self.class.validate_inputs(inputs)
87
+ if validation_result.failure?
88
+ @result = validation_result
89
+ @context.status = "failed"
90
+ @context.failure_reason = {
91
+ message: validation_result.error.message,
92
+ validation_errors: validation_result.error.field_errors
93
+ }
94
+ save_context
95
+ return validation_result
96
+ end
97
+
98
+ if self.class.async? && !@context.inline_async_execution
99
+ # For async reactors, queue a job for the whole reactor
100
+ @context.status = :running
101
+ save_context
102
+
103
+ serialized_context = ContextSerializer.serialize(@context)
104
+ @result = configuration.async_router.perform_async(serialized_context, self.class.name,
105
+ intermediate_results: @context.intermediate_results)
106
+
107
+ # Even if it's an AsyncResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
108
+ # Check storage to see if it's already finished or paused (interrupted).
109
+ begin
110
+ reloaded = self.class.find(@context.context_id)
111
+ if reloaded.finished? || reloaded.context.status.to_s == "paused"
112
+ @context = reloaded.context
113
+ @result = reloaded.result
114
+ @execution_trace = reloaded.execution_trace
115
+ @undo_trace = reloaded.undo_trace
116
+ return @result
117
+ end
118
+ rescue StandardError
119
+ # Ignore if not found or other errors during reload check
120
+ end
121
+
77
122
  else
78
123
  # For sync reactors (potentially with async steps), execute normally
79
124
  context = @context.is_a?(Context) ? @context : nil
80
125
  executor = Executor.new(self.class, inputs, context)
81
126
  @result = executor.execute
82
-
83
127
  @context = executor.context
84
-
85
- # Merge traces
86
- @undo_trace = executor.undo_trace
87
128
  @execution_trace = executor.execution_trace
88
-
89
- # If execution returned an AsyncResult (from step-level async), return it
90
- return @result if @result.is_a?(RubyReactor::AsyncResult)
91
-
92
- @result
129
+ @undo_trace = executor.undo_trace
93
130
  end
131
+ @result
94
132
  end
133
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
95
134
 
96
135
  def continue(payload:, step_name:, idempotency_key: nil)
97
136
  _ = idempotency_key
@@ -178,6 +217,125 @@ module RubyReactor
178
217
  raise Error::DependencyError, "Dependency graph contains cycles"
179
218
  end
180
219
 
220
+ def reconstruct_result
221
+ case @context.status.to_s
222
+ when "completed" then reconstruct_success_result
223
+ when "failed" then reconstruct_failure_result
224
+ when "paused" then reconstruct_paused_result
225
+ else :unexecuted
226
+ end
227
+ end
228
+
229
+ def reconstruct_success_result
230
+ rs = self.class.respond_to?(:returns) ? self.class.returns : nil
231
+ val = if rs
232
+ @context.intermediate_results[rs.to_sym] || @context.intermediate_results[rs.to_s]
233
+ else
234
+ find_last_step_result
235
+ end
236
+ Success.new(val)
237
+ end
238
+
239
+ def find_last_step_result
240
+ last_run = @execution_trace.reverse.find { |e| e[:type] == :run || e["type"] == "run" }
241
+ return unless last_run
242
+
243
+ step_name = last_run[:step] || last_run["step"]
244
+ @context.intermediate_results[step_name.to_sym] || @context.intermediate_results[step_name.to_s]
245
+ end
246
+
247
+ def reconstruct_failure_result
248
+ reason = @context.failure_reason || {}
249
+ return reason if reason.is_a?(RubyReactor::Failure)
250
+
251
+ # Use string keys preferred, fallback to symbol
252
+ r = ->(k) { reason[k.to_s] || reason[k.to_sym] }
253
+
254
+ Failure.new(
255
+ r[:message],
256
+ step_name: r[:step_name],
257
+ inputs: r[:inputs] || {},
258
+ backtrace: r[:backtrace],
259
+ reactor_name: r[:reactor_name],
260
+ step_arguments: r[:step_arguments] || {},
261
+ exception_class: r[:exception_class],
262
+ file_path: r[:file_path],
263
+ line_number: r[:line_number],
264
+ code_snippet: r[:code_snippet],
265
+ validation_errors: r[:validation_errors],
266
+ retryable: r[:retryable],
267
+ invalid_payload: r[:invalid_payload]
268
+ )
269
+ end
270
+
271
+ def reconstruct_paused_result
272
+ InterruptResult.new(
273
+ execution_id: @context.context_id,
274
+ intermediate_results: @context.intermediate_results
275
+ )
276
+ end
277
+
278
+ def initialize_and_validate_run?(inputs)
279
+ # For all reactors, initialize context first to capture execution ID
280
+ @context = @context.is_a?(Context) ? @context : Context.new(inputs, self.class)
281
+
282
+ validation_result = self.class.validate_inputs(inputs)
283
+ if validation_result.failure?
284
+ handle_validation_failure(validation_result)
285
+ return false
286
+ end
287
+ true
288
+ end
289
+
290
+ def handle_validation_failure(result)
291
+ @result = result
292
+ @context.status = "failed"
293
+ @context.failure_reason = {
294
+ message: result.error.message,
295
+ validation_errors: result.error.field_errors
296
+ }
297
+ save_context
298
+ end
299
+
300
+ def perform_async_run
301
+ @context.status = :running
302
+ save_context
303
+
304
+ serialized_context = ContextSerializer.serialize(@context)
305
+ @result = configuration.async_router.perform_async(serialized_context, self.class.name,
306
+ intermediate_results: @context.intermediate_results)
307
+
308
+ check_for_inline_completion
309
+ end
310
+
311
+ def check_for_inline_completion
312
+ # Even if it's an AsyncResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
313
+ # Check storage to see if it's already finished or paused (interrupted).
314
+ reloaded = self.class.find(@context.context_id)
315
+ if reloaded.finished? || reloaded.context.status.to_s == "paused"
316
+ update_state_from_reloaded(reloaded)
317
+ @result
318
+ end
319
+ rescue StandardError
320
+ # Ignore if not found or other errors during reload check
321
+ end
322
+
323
+ def update_state_from_reloaded(reloaded)
324
+ @context = reloaded.context
325
+ @result = reloaded.result
326
+ @execution_trace = reloaded.execution_trace
327
+ @undo_trace = reloaded.undo_trace
328
+ end
329
+
330
+ def perform_sync_run(inputs)
331
+ context = @context.is_a?(Context) ? @context : nil
332
+ executor = Executor.new(self.class, inputs, context)
333
+ @result = executor.execute
334
+ @context = executor.context
335
+ @execution_trace = executor.execution_trace
336
+ @undo_trace = executor.undo_trace
337
+ end
338
+
181
339
  def validate_continue_step!(step_name)
182
340
  return if step_name.to_s == @context.current_step.to_s
183
341
 
@@ -258,4 +416,5 @@ module RubyReactor
258
416
  storage.store_context(@context.context_id, serialized_context, reactor_class_name)
259
417
  end
260
418
  end
419
+ # rubocop:enable Metrics/ClassLength
261
420
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module RSpec
5
+ module Helpers
6
+ def test_reactor(reactor_class, inputs, context: {}, async: nil, process_jobs: true)
7
+ TestSubject.new(
8
+ reactor_class: reactor_class,
9
+ inputs: inputs,
10
+ context: context,
11
+ async: async,
12
+ process_jobs: process_jobs
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end