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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RubyReactor
|
|
4
|
-
class
|
|
4
|
+
class SidekiqAdapter
|
|
5
5
|
def self.perform_async(serialized_context, reactor_class_name = nil, intermediate_results: {})
|
|
6
6
|
job_id = SidekiqWorkers::Worker.perform_async(serialized_context, reactor_class_name)
|
|
7
7
|
context = ContextSerializer.deserialize(serialized_context)
|
|
@@ -20,7 +20,7 @@ module RubyReactor
|
|
|
20
20
|
def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
21
21
|
strict_ordering:, parent_context_id:, parent_reactor_class_name:, step_name:,
|
|
22
22
|
batch_size: nil, serialized_context: nil, fail_fast: nil)
|
|
23
|
-
RubyReactor::SidekiqWorkers::MapElementWorker.perform_async(
|
|
23
|
+
job_id = RubyReactor::SidekiqWorkers::MapElementWorker.perform_async(
|
|
24
24
|
{
|
|
25
25
|
"map_id" => map_id,
|
|
26
26
|
"element_id" => element_id,
|
|
@@ -36,6 +36,7 @@ module RubyReactor
|
|
|
36
36
|
"fail_fast" => fail_fast
|
|
37
37
|
}
|
|
38
38
|
)
|
|
39
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
@@ -63,7 +64,7 @@ module RubyReactor
|
|
|
63
64
|
# rubocop:disable Metrics/ParameterLists
|
|
64
65
|
def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
|
|
65
66
|
strict_ordering:, timeout:)
|
|
66
|
-
RubyReactor::SidekiqWorkers::MapCollectorWorker.perform_async(
|
|
67
|
+
job_id = RubyReactor::SidekiqWorkers::MapCollectorWorker.perform_async(
|
|
67
68
|
{
|
|
68
69
|
"parent_context_id" => parent_context_id,
|
|
69
70
|
"map_id" => map_id,
|
|
@@ -73,12 +74,15 @@ module RubyReactor
|
|
|
73
74
|
"timeout" => timeout
|
|
74
75
|
}
|
|
75
76
|
)
|
|
77
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
76
78
|
end
|
|
77
|
-
# rubocop:enable Metrics/ParameterLists
|
|
78
79
|
|
|
80
|
+
# rubocop:enable Metrics/ParameterLists
|
|
81
|
+
# rubocop:disable Metrics/ParameterLists
|
|
79
82
|
def self.perform_map_execution_async(map_id:, serialized_inputs:, reactor_class_info:, strict_ordering:,
|
|
80
83
|
parent_context_id:, parent_reactor_class_name:, step_name:, fail_fast: nil)
|
|
81
|
-
|
|
84
|
+
# rubocop:enable Metrics/ParameterLists
|
|
85
|
+
job_id = RubyReactor::SidekiqWorkers::MapExecutionWorker.perform_async(
|
|
82
86
|
{
|
|
83
87
|
"map_id" => map_id,
|
|
84
88
|
"serialized_inputs" => serialized_inputs,
|
|
@@ -90,6 +94,7 @@ module RubyReactor
|
|
|
90
94
|
"fail_fast" => fail_fast
|
|
91
95
|
}
|
|
92
96
|
)
|
|
97
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
93
98
|
end
|
|
94
99
|
end
|
|
95
100
|
end
|
|
@@ -17,7 +17,7 @@ module RubyReactor
|
|
|
17
17
|
# Handle infrastructure failures (network, Redis, etc.)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def perform(serialized_context, reactor_class_name = nil)
|
|
20
|
+
def perform(serialized_context, reactor_class_name = nil, snooze_count = 0)
|
|
21
21
|
context = ContextSerializer.deserialize(serialized_context)
|
|
22
22
|
|
|
23
23
|
# If reactor_class_name is provided, use it to get the reactor class
|
|
@@ -35,20 +35,80 @@ module RubyReactor
|
|
|
35
35
|
# Mark that we're executing inline to prevent nested async calls
|
|
36
36
|
context.inline_async_execution = true
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
begin
|
|
39
|
+
# Resume execution from the failed step
|
|
40
|
+
executor = Executor.new(context.reactor_class, {}, context)
|
|
41
|
+
executor.resume_execution
|
|
42
|
+
executor.save_context
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
# Return the executor (which now has the result stored in it)
|
|
45
|
+
executor
|
|
46
|
+
rescue RubyReactor::Lock::AcquisitionError,
|
|
47
|
+
RubyReactor::Semaphore::AcquisitionError,
|
|
48
|
+
RubyReactor::RateLimit::ExceededError => e
|
|
49
|
+
# Snooze on expected concurrency or rate contention. We avoid
|
|
50
|
+
# Sidekiq's native retry path so this doesn't burn the job's retry
|
|
51
|
+
# budget or appear as an error in dashboards. After the configured
|
|
52
|
+
# cap is reached we escalate by marking the reactor as failed.
|
|
53
|
+
handle_snooze(serialized_context, reactor_class_name, context, snooze_count, e)
|
|
54
|
+
end
|
|
45
55
|
end
|
|
46
56
|
|
|
47
57
|
private
|
|
48
58
|
|
|
59
|
+
def handle_snooze(serialized_context, reactor_class_name, context, snooze_count, error)
|
|
60
|
+
config = RubyReactor.configuration
|
|
61
|
+
max = config.lock_snooze_max_attempts
|
|
62
|
+
|
|
63
|
+
if max != :infinity && snooze_count >= max
|
|
64
|
+
escalate_snooze(context, snooze_count, error)
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
delay = compute_snooze_delay(config, error)
|
|
69
|
+
self.class.perform_in(delay, serialized_context, reactor_class_name, snooze_count + 1)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Use the error's `retry_after_seconds` hint when available
|
|
73
|
+
# (RateLimit::ExceededError carries the time until the bucket rolls);
|
|
74
|
+
# otherwise fall back to the configured base + jitter for lock/semaphore
|
|
75
|
+
# contention which has no precise hint.
|
|
76
|
+
def compute_snooze_delay(config, error)
|
|
77
|
+
jitter = config.lock_snooze_jitter.to_f
|
|
78
|
+
jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
|
|
79
|
+
|
|
80
|
+
if error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
|
|
81
|
+
[error.retry_after_seconds.to_f, 0.1].max + jitter_amount
|
|
82
|
+
else
|
|
83
|
+
config.lock_snooze_base_delay.to_f + jitter_amount
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def escalate_snooze(context, snooze_count, error)
|
|
88
|
+
RubyReactor.configuration.logger.warn(
|
|
89
|
+
"RubyReactor snooze limit reached after #{snooze_count} attempts " \
|
|
90
|
+
"for context #{context.context_id}: #{error.message}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
context.status = :failed
|
|
94
|
+
context.failure_reason = {
|
|
95
|
+
message: error.message,
|
|
96
|
+
exception_class: error.class.name,
|
|
97
|
+
snooze_attempts: snooze_count
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
serialized = ContextSerializer.serialize(context)
|
|
101
|
+
reactor_class_name = context.reactor_class&.name || "AnonymousReactor"
|
|
102
|
+
RubyReactor.configuration.storage_adapter.store_context(
|
|
103
|
+
context.context_id,
|
|
104
|
+
serialized,
|
|
105
|
+
reactor_class_name
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
49
109
|
def log_infrastructure_failure(msg, exception)
|
|
50
|
-
|
|
51
|
-
|
|
110
|
+
RubyReactor.configuration.logger.error("RubyReactor infrastructure failure: #{exception.message}")
|
|
111
|
+
RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
|
|
52
112
|
end
|
|
53
113
|
end
|
|
54
114
|
end
|
|
@@ -84,7 +84,6 @@ module RubyReactor
|
|
|
84
84
|
def link_contexts(child_context, parent_context)
|
|
85
85
|
child_context.parent_context = parent_context
|
|
86
86
|
child_context.root_context = parent_context.root_context || parent_context
|
|
87
|
-
child_context.test_mode = parent_context.test_mode
|
|
88
87
|
child_context.inline_async_execution = parent_context.inline_async_execution
|
|
89
88
|
end
|
|
90
89
|
|
|
@@ -57,7 +57,9 @@ module RubyReactor
|
|
|
57
57
|
private
|
|
58
58
|
|
|
59
59
|
def should_run_async?(arguments, context)
|
|
60
|
-
|
|
60
|
+
return false if context.inline_async_execution
|
|
61
|
+
|
|
62
|
+
arguments[:async]
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
def run_inline(arguments, context)
|
|
@@ -118,30 +120,21 @@ module RubyReactor
|
|
|
118
120
|
def link_contexts(child_context, parent_context)
|
|
119
121
|
child_context.parent_context = parent_context
|
|
120
122
|
child_context.root_context = parent_context.root_context || parent_context
|
|
121
|
-
child_context.test_mode = parent_context.test_mode
|
|
122
123
|
child_context.inline_async_execution = parent_context.inline_async_execution
|
|
123
124
|
end
|
|
124
125
|
|
|
125
|
-
def process_results(results, collect_block,
|
|
126
|
+
def process_results(results, collect_block, _fail_fast = true)
|
|
126
127
|
if collect_block
|
|
127
128
|
begin
|
|
128
129
|
# Collect block receives Result objects when fail_fast is false, values when true
|
|
129
|
-
RubyReactor::Success(collect_block.call(results))
|
|
130
|
+
return RubyReactor::Success(collect_block.call(results))
|
|
130
131
|
rescue StandardError => e
|
|
131
|
-
RubyReactor::Failure(e)
|
|
132
|
+
return RubyReactor::Failure(e)
|
|
132
133
|
end
|
|
133
|
-
elsif fail_fast
|
|
134
|
-
# Default behavior when no collect block
|
|
135
|
-
# Current behavior: results are already values
|
|
136
|
-
RubyReactor::Success(results)
|
|
137
|
-
else
|
|
138
|
-
# New behavior: extract successful values only
|
|
139
|
-
# New behavior: extract successful values only IF fail_fast is true behavior implies only values
|
|
140
|
-
# However, if fail_fast is false, we want to return results as is, or if logic dictates otherwise.
|
|
141
|
-
# wait, if fail_fast=false, we expect Result objects so we can check if success/failure.
|
|
142
|
-
# If we return only successes, we hide failures.
|
|
143
|
-
RubyReactor::Success(results)
|
|
144
134
|
end
|
|
135
|
+
|
|
136
|
+
# Simplified: both branches returned Success(results)
|
|
137
|
+
RubyReactor::Success(results)
|
|
145
138
|
end
|
|
146
139
|
|
|
147
140
|
def extract_path(value, path)
|
|
@@ -203,7 +196,7 @@ module RubyReactor
|
|
|
203
196
|
argument_mappings: arguments[:argument_mappings],
|
|
204
197
|
strict_ordering: arguments[:strict_ordering],
|
|
205
198
|
mapped_reactor_class: arguments[:mapped_reactor_class],
|
|
206
|
-
fail_fast: arguments[:fail_fast]
|
|
199
|
+
fail_fast: arguments[:fail_fast].nil? || arguments[:fail_fast]
|
|
207
200
|
)
|
|
208
201
|
queue_collector(map_id, context, step_name, arguments[:strict_ordering])
|
|
209
202
|
"map:#{map_id}"
|
|
@@ -284,7 +277,7 @@ module RubyReactor
|
|
|
284
277
|
map_id: map_id, serialized_inputs: serialized_inputs,
|
|
285
278
|
reactor_class_info: reactor_class_info, strict_ordering: arguments[:strict_ordering],
|
|
286
279
|
parent_context_id: context.context_id, parent_reactor_class_name: context.reactor_class.name,
|
|
287
|
-
step_name: step_name.to_s, fail_fast: arguments[:fail_fast]
|
|
280
|
+
step_name: step_name.to_s, fail_fast: arguments[:fail_fast].nil? || arguments[:fail_fast]
|
|
288
281
|
)
|
|
289
282
|
end
|
|
290
283
|
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Storage
|
|
5
|
+
# Adapter contract uses non-`?` names for methods that return booleans
|
|
6
|
+
# (`lock_acquire`, `semaphore_release`, etc). Renaming would break the
|
|
7
|
+
# public storage adapter API, so silence the predicate-name cop here.
|
|
8
|
+
# rubocop:disable Naming/PredicateMethod
|
|
9
|
+
module RedisLocking
|
|
10
|
+
# Scripts for Lock Primitives
|
|
11
|
+
LOCK_ACQUIRE_SCRIPT = <<~LUA
|
|
12
|
+
local key = KEYS[1]
|
|
13
|
+
local owner = ARGV[1]
|
|
14
|
+
local ttl = tonumber(ARGV[2])
|
|
15
|
+
|
|
16
|
+
if redis.call('exists', key) == 0 then
|
|
17
|
+
redis.call('hset', key, 'owner', owner)
|
|
18
|
+
redis.call('hset', key, 'count', 1)
|
|
19
|
+
redis.call('expire', key, ttl)
|
|
20
|
+
return 1
|
|
21
|
+
elseif redis.call('hget', key, 'owner') == owner then
|
|
22
|
+
redis.call('hincrby', key, 'count', 1)
|
|
23
|
+
redis.call('expire', key, ttl)
|
|
24
|
+
return 1
|
|
25
|
+
else
|
|
26
|
+
return 0
|
|
27
|
+
end
|
|
28
|
+
LUA
|
|
29
|
+
|
|
30
|
+
LOCK_RELEASE_SCRIPT = <<~LUA
|
|
31
|
+
local key = KEYS[1]
|
|
32
|
+
local owner = ARGV[1]
|
|
33
|
+
|
|
34
|
+
if redis.call('hget', key, 'owner') == owner then
|
|
35
|
+
local new_count = redis.call('hincrby', key, 'count', -1)
|
|
36
|
+
if new_count <= 0 then
|
|
37
|
+
redis.call('del', key)
|
|
38
|
+
end
|
|
39
|
+
return 1
|
|
40
|
+
else
|
|
41
|
+
return 0
|
|
42
|
+
end
|
|
43
|
+
LUA
|
|
44
|
+
|
|
45
|
+
LOCK_EXTEND_SCRIPT = <<~LUA
|
|
46
|
+
local key = KEYS[1]
|
|
47
|
+
local owner = ARGV[1]
|
|
48
|
+
local ttl = tonumber(ARGV[2])
|
|
49
|
+
|
|
50
|
+
if redis.call('hget', key, 'owner') == owner then
|
|
51
|
+
redis.call('expire', key, ttl)
|
|
52
|
+
return 1
|
|
53
|
+
else
|
|
54
|
+
return 0
|
|
55
|
+
end
|
|
56
|
+
LUA
|
|
57
|
+
|
|
58
|
+
# Lock Primitives
|
|
59
|
+
|
|
60
|
+
def lock_acquire(key, owner, ttl)
|
|
61
|
+
result = @redis.eval(LOCK_ACQUIRE_SCRIPT, keys: [key], argv: [owner, ttl])
|
|
62
|
+
result == 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def lock_release(key, owner)
|
|
66
|
+
result = @redis.eval(LOCK_RELEASE_SCRIPT, keys: [key], argv: [owner])
|
|
67
|
+
result == 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def lock_extend(key, owner, ttl)
|
|
71
|
+
result = @redis.eval(LOCK_EXTEND_SCRIPT, keys: [key], argv: [owner, ttl])
|
|
72
|
+
result == 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Semaphore Primitives
|
|
76
|
+
#
|
|
77
|
+
# Storage layout:
|
|
78
|
+
# LIST <key> — available token UUIDs
|
|
79
|
+
# SET <key>:held — tokens currently checked out
|
|
80
|
+
# STRING <key>:init — sentinel marking that init has run; value = limit
|
|
81
|
+
#
|
|
82
|
+
# Tokens are unique UUIDs so release can verify the caller actually holds
|
|
83
|
+
# one before pushing it back, blocking double-release and over-cap RPUSH.
|
|
84
|
+
|
|
85
|
+
SEM_ACQUIRE_SCRIPT = <<~LUA
|
|
86
|
+
local list_key = KEYS[1]
|
|
87
|
+
local held_key = KEYS[2]
|
|
88
|
+
local token = redis.call('lpop', list_key)
|
|
89
|
+
if not token then return false end
|
|
90
|
+
redis.call('sadd', held_key, token)
|
|
91
|
+
return token
|
|
92
|
+
LUA
|
|
93
|
+
|
|
94
|
+
SEM_RELEASE_SCRIPT = <<~LUA
|
|
95
|
+
local list_key = KEYS[1]
|
|
96
|
+
local held_key = KEYS[2]
|
|
97
|
+
local limit = tonumber(ARGV[1])
|
|
98
|
+
local token = ARGV[2]
|
|
99
|
+
if redis.call('srem', held_key, token) == 0 then
|
|
100
|
+
return 0
|
|
101
|
+
end
|
|
102
|
+
if redis.call('llen', list_key) >= limit then
|
|
103
|
+
return 0
|
|
104
|
+
end
|
|
105
|
+
redis.call('rpush', list_key, token)
|
|
106
|
+
return 1
|
|
107
|
+
LUA
|
|
108
|
+
|
|
109
|
+
SEMAPHORE_TTL = 86_400
|
|
110
|
+
|
|
111
|
+
def semaphore_init(key, limit)
|
|
112
|
+
return false unless @redis.set("#{key}:init", limit, nx: true, ex: SEMAPHORE_TTL)
|
|
113
|
+
|
|
114
|
+
tokens = Array.new(limit) { SecureRandom.uuid }
|
|
115
|
+
@redis.rpush(key, tokens)
|
|
116
|
+
@redis.expire(key, SEMAPHORE_TTL)
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def semaphore_reset(key)
|
|
121
|
+
@redis.del(key)
|
|
122
|
+
@redis.del("#{key}:held")
|
|
123
|
+
@redis.del("#{key}:init")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def semaphore_acquire(key, timeout: 0)
|
|
127
|
+
held_key = "#{key}:held"
|
|
128
|
+
|
|
129
|
+
token = if timeout.to_f.positive?
|
|
130
|
+
result = @redis.blpop(key, timeout: timeout)
|
|
131
|
+
next_token = result&.last
|
|
132
|
+
@redis.sadd(held_key, next_token) if next_token
|
|
133
|
+
next_token
|
|
134
|
+
else
|
|
135
|
+
@redis.eval(SEM_ACQUIRE_SCRIPT, keys: [key, held_key], argv: [])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
return nil unless token
|
|
139
|
+
|
|
140
|
+
@redis.expire(held_key, SEMAPHORE_TTL)
|
|
141
|
+
token
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def semaphore_release(key, token, limit)
|
|
145
|
+
return false unless token
|
|
146
|
+
|
|
147
|
+
result = @redis.eval(
|
|
148
|
+
SEM_RELEASE_SCRIPT,
|
|
149
|
+
keys: [key, "#{key}:held"],
|
|
150
|
+
argv: [limit, token]
|
|
151
|
+
)
|
|
152
|
+
result == 1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def semaphore_exists?(key)
|
|
156
|
+
@redis.exists?("#{key}:init")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Period Primitives — used by `with_period` to dedup bucketed runs.
|
|
160
|
+
|
|
161
|
+
def period_seen?(key)
|
|
162
|
+
@redis.exists?(key)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def period_mark(key, ttl)
|
|
166
|
+
@redis.set(key, "1", ex: ttl)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Rate Limit Primitives — fixed-window counter, supports multiple
|
|
170
|
+
# windows in one atomic call. Two-pass inside Lua so a miss on the Nth
|
|
171
|
+
# window does not leave the previous N-1 incremented.
|
|
172
|
+
#
|
|
173
|
+
# ARGV layout: [now, period_1, limit_1, ttl_1, period_2, limit_2, ttl_2, ...]
|
|
174
|
+
# KEYS: [bucket_key_1, bucket_key_2, ...]
|
|
175
|
+
# Returns: [allowed (1|0), retry_after_seconds, failed_index]
|
|
176
|
+
|
|
177
|
+
RATE_LIMIT_SCRIPT = <<~LUA
|
|
178
|
+
local now = tonumber(ARGV[1])
|
|
179
|
+
local n = #KEYS
|
|
180
|
+
|
|
181
|
+
for i = 1, n do
|
|
182
|
+
local base = 2 + (i - 1) * 3
|
|
183
|
+
local period = tonumber(ARGV[base])
|
|
184
|
+
local lim = tonumber(ARGV[base + 1])
|
|
185
|
+
local count = tonumber(redis.call('get', KEYS[i]) or '0')
|
|
186
|
+
if count >= lim then
|
|
187
|
+
local retry_after = period - (now % period)
|
|
188
|
+
if retry_after <= 0 then retry_after = 1 end
|
|
189
|
+
return {0, retry_after, i}
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
for i = 1, n do
|
|
194
|
+
local base = 2 + (i - 1) * 3
|
|
195
|
+
local ttl = tonumber(ARGV[base + 2])
|
|
196
|
+
local count = redis.call('incr', KEYS[i])
|
|
197
|
+
if count == 1 then
|
|
198
|
+
redis.call('expire', KEYS[i], ttl)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
return {1, 0, 0}
|
|
203
|
+
LUA
|
|
204
|
+
|
|
205
|
+
def rate_limit_check_and_increment(keys, argv)
|
|
206
|
+
@redis.eval(RATE_LIMIT_SCRIPT, keys: keys, argv: argv)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Inspectors — used by RSpec matchers to assert on lock/semaphore/
|
|
210
|
+
# rate-limit/period state without leaking Redis-specific calls into
|
|
211
|
+
# test code.
|
|
212
|
+
|
|
213
|
+
# Returns { owner:, count: } for a held lock, or nil if free.
|
|
214
|
+
# `prefixed_key` is the full key (e.g. "lock:order:42").
|
|
215
|
+
def lock_info(prefixed_key)
|
|
216
|
+
return nil unless @redis.exists?(prefixed_key)
|
|
217
|
+
|
|
218
|
+
data = @redis.hgetall(prefixed_key)
|
|
219
|
+
return nil if data.empty?
|
|
220
|
+
|
|
221
|
+
{ owner: data["owner"], count: data["count"].to_i }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Returns { available:, held:, limit: } for a semaphore. `name` is the
|
|
225
|
+
# user-provided semaphore key (without the "semaphore:" prefix).
|
|
226
|
+
def semaphore_state(name)
|
|
227
|
+
prefix = "semaphore:#{name}"
|
|
228
|
+
{
|
|
229
|
+
available: @redis.llen(prefix),
|
|
230
|
+
held: @redis.scard("#{prefix}:held"),
|
|
231
|
+
limit: @redis.get("#{prefix}:init").to_i
|
|
232
|
+
}
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Current count for a rate-limit bucket. `key_base` is the user's
|
|
236
|
+
# `with_rate_limit` key, `every` is the period (symbol or seconds).
|
|
237
|
+
def rate_limit_count(key_base, every, now: Time.now.to_i)
|
|
238
|
+
period_seconds = RubyReactor::Period.period_seconds(every)
|
|
239
|
+
bucket = now / period_seconds
|
|
240
|
+
@redis.get("rate:#{key_base}:#{every}:#{bucket}").to_i
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Has a period bucket been marked? `key_base` is the user's
|
|
244
|
+
# `with_period` key, `every` is the period.
|
|
245
|
+
def period_marker?(key_base, every, now: Time.now.utc)
|
|
246
|
+
@redis.exists?(RubyReactor::Period.key(key_base, every, now: now))
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
# rubocop:enable Naming/PredicateMethod
|
|
250
|
+
end
|
|
251
|
+
end
|
data/lib/ruby_reactor/version.rb
CHANGED
data/lib/ruby_reactor/web/api.rb
CHANGED
|
@@ -24,39 +24,44 @@ module RubyReactor
|
|
|
24
24
|
r.on String do |reactor_id|
|
|
25
25
|
# GET /api/reactors/:id
|
|
26
26
|
r.get do
|
|
27
|
-
|
|
28
|
-
return { error: "Reactor not found" } unless
|
|
27
|
+
raw_data = RubyReactor::Configuration.instance.storage_adapter.find_context_by_id(reactor_id)
|
|
28
|
+
return { error: "Reactor not found" } unless raw_data
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
# Clean data for API usage
|
|
31
|
+
data = ContextSerializer.deserialize_value(raw_data)
|
|
32
|
+
|
|
33
|
+
reactor_class = data[:reactor_class] ? Object.const_get(data[:reactor_class].to_s) : nil
|
|
31
34
|
structure = {}
|
|
32
35
|
|
|
33
36
|
structure = self.class.build_structure(reactor_class) if reactor_class.respond_to?(:steps)
|
|
34
37
|
|
|
35
|
-
{
|
|
36
|
-
id: data[
|
|
37
|
-
class: data[
|
|
38
|
-
status: if %w[failed paused completed running].include?(data[
|
|
39
|
-
data[
|
|
40
|
-
elsif data[
|
|
38
|
+
response_data = {
|
|
39
|
+
id: data[:context_id],
|
|
40
|
+
class: data[:reactor_class].to_s,
|
|
41
|
+
status: if %w[failed paused completed running].include?(data[:status].to_s)
|
|
42
|
+
data[:status].to_s
|
|
43
|
+
elsif data[:cancelled]
|
|
41
44
|
"cancelled"
|
|
42
45
|
else
|
|
43
|
-
(data[
|
|
46
|
+
(data[:current_step] ? "running" : "completed")
|
|
44
47
|
end,
|
|
45
|
-
current_step: data[
|
|
46
|
-
retry_count: data[
|
|
47
|
-
undo_stack: data[
|
|
48
|
-
step_attempts: data.dig(
|
|
49
|
-
created_at: data[
|
|
50
|
-
inputs: data[
|
|
51
|
-
intermediate_results: data[
|
|
48
|
+
current_step: data[:current_step].to_s,
|
|
49
|
+
retry_count: data[:retry_count] || 0,
|
|
50
|
+
undo_stack: data[:undo_stack] || [],
|
|
51
|
+
step_attempts: data.dig(:retry_context, :step_attempts) || {},
|
|
52
|
+
created_at: data[:started_at],
|
|
53
|
+
inputs: data[:inputs],
|
|
54
|
+
intermediate_results: data[:intermediate_results],
|
|
52
55
|
structure: structure,
|
|
53
|
-
steps: data[
|
|
56
|
+
steps: data[:execution_trace] || [],
|
|
54
57
|
composed_contexts: self.class.hydrate_composed_contexts(
|
|
55
|
-
data[
|
|
56
|
-
data[
|
|
58
|
+
data[:composed_contexts] || {},
|
|
59
|
+
data[:reactor_class]&.to_s
|
|
57
60
|
),
|
|
58
|
-
error: data[
|
|
61
|
+
error: data[:failure_reason]
|
|
59
62
|
}
|
|
63
|
+
|
|
64
|
+
ContextSerializer.simplify_for_api(response_data)
|
|
60
65
|
end
|
|
61
66
|
|
|
62
67
|
# POST /api/reactors/:id/retry
|
|
@@ -159,7 +164,8 @@ module RubyReactor
|
|
|
159
164
|
return {} unless composed_contexts.is_a?(Hash)
|
|
160
165
|
|
|
161
166
|
composed_contexts.transform_values do |value|
|
|
162
|
-
|
|
167
|
+
type = value[:type] || value["type"]
|
|
168
|
+
if ["map_ref", :map_ref].include?(type)
|
|
163
169
|
hydrate_map_ref(value, reactor_class_name)
|
|
164
170
|
else
|
|
165
171
|
value
|
|
@@ -169,10 +175,12 @@ module RubyReactor
|
|
|
169
175
|
|
|
170
176
|
def self.hydrate_map_ref(ref_data, reactor_class_name)
|
|
171
177
|
storage = RubyReactor.configuration.storage_adapter
|
|
172
|
-
map_id = ref_data["map_id"]
|
|
178
|
+
map_id = ref_data[:map_id] || ref_data["map_id"]
|
|
173
179
|
|
|
174
180
|
# Use the specific element reactor class if available, otherwise fallback to parent
|
|
175
|
-
target_reactor_class = ref_data[
|
|
181
|
+
target_reactor_class = ref_data[:element_reactor_class] ||
|
|
182
|
+
ref_data["element_reactor_class"] ||
|
|
183
|
+
reactor_class_name
|
|
176
184
|
|
|
177
185
|
# 1. Check for specific failure (O(1))
|
|
178
186
|
# Stored by ResultHandler when a map element fails
|