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.
- checksums.yaml +4 -4
- data/README.md +145 -9
- data/documentation/README.md +20 -8
- data/documentation/async_reactors.md +46 -34
- data/documentation/core_concepts.md +75 -61
- data/documentation/examples/inventory_management.md +2 -3
- data/documentation/examples/order_processing.md +92 -77
- data/documentation/examples/payment_processing.md +28 -117
- data/documentation/getting_started.md +112 -94
- data/documentation/interrupts.md +9 -7
- data/documentation/locks_and_semaphores.md +459 -0
- data/documentation/retry_configuration.md +19 -14
- data/documentation/testing.md +994 -0
- data/lib/ruby_reactor/configuration.rb +19 -2
- data/lib/ruby_reactor/context.rb +13 -5
- data/lib/ruby_reactor/context_serializer.rb +55 -4
- data/lib/ruby_reactor/dsl/lockable.rb +130 -0
- data/lib/ruby_reactor/dsl/reactor.rb +3 -2
- data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
- data/lib/ruby_reactor/executor/result_handler.rb +27 -2
- data/lib/ruby_reactor/executor/retry_manager.rb +15 -7
- data/lib/ruby_reactor/executor/step_executor.rb +29 -99
- data/lib/ruby_reactor/executor.rb +148 -15
- data/lib/ruby_reactor/lock.rb +92 -0
- data/lib/ruby_reactor/map/collector.rb +16 -15
- data/lib/ruby_reactor/map/element_executor.rb +90 -104
- data/lib/ruby_reactor/map/execution.rb +2 -1
- data/lib/ruby_reactor/map/helpers.rb +2 -1
- data/lib/ruby_reactor/map/result_enumerator.rb +1 -1
- data/lib/ruby_reactor/period.rb +67 -0
- data/lib/ruby_reactor/rate_limit.rb +74 -0
- data/lib/ruby_reactor/reactor.rb +175 -16
- data/lib/ruby_reactor/rspec/helpers.rb +17 -0
- data/lib/ruby_reactor/rspec/matchers.rb +423 -0
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
- data/lib/ruby_reactor/rspec.rb +18 -0
- data/lib/ruby_reactor/semaphore.rb +58 -0
- data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +10 -5
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +69 -9
- data/lib/ruby_reactor/step/compose_step.rb +0 -1
- data/lib/ruby_reactor/step/map_step.rb +11 -18
- data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
- data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +32 -24
- data/lib/ruby_reactor.rb +119 -10
- 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
|
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -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
|
-
|
|
68
|
-
@
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|