ruby_reactor 0.3.1 → 0.4.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 +4 -4
- data/.release-please-config.json +15 -0
- data/.release-please-manifest.json +3 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +13 -0
- data/README.md +194 -9
- data/lib/ruby_reactor/configuration.rb +18 -1
- data/lib/ruby_reactor/context_serializer.rb +10 -1
- data/lib/ruby_reactor/dsl/lockable.rb +130 -0
- data/lib/ruby_reactor/executor/result_handler.rb +19 -0
- data/lib/ruby_reactor/executor/step_executor.rb +5 -0
- data/lib/ruby_reactor/executor.rb +145 -2
- data/lib/ruby_reactor/lock.rb +92 -0
- data/lib/ruby_reactor/map/result_enumerator.rb +4 -3
- data/lib/ruby_reactor/period.rb +67 -0
- data/lib/ruby_reactor/rate_limit.rb +74 -0
- data/lib/ruby_reactor/reactor.rb +1 -0
- data/lib/ruby_reactor/rspec/matchers.rb +171 -4
- data/lib/ruby_reactor/semaphore.rb +58 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +128 -9
- 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.rb +49 -0
- metadata +13 -51
- data/documentation/DAG.md +0 -457
- data/documentation/README.md +0 -123
- data/documentation/async_reactors.md +0 -369
- data/documentation/composition.md +0 -199
- data/documentation/core_concepts.md +0 -662
- data/documentation/data_pipelines.md +0 -230
- data/documentation/examples/inventory_management.md +0 -749
- data/documentation/examples/order_processing.md +0 -365
- data/documentation/examples/payment_processing.md +0 -654
- data/documentation/getting_started.md +0 -224
- data/documentation/images/failed_order_processing.png +0 -0
- data/documentation/images/payment_workflow.png +0 -0
- data/documentation/interrupts.md +0 -161
- data/documentation/retry_configuration.md +0 -357
- data/documentation/testing.md +0 -812
- data/gui/.gitignore +0 -24
- data/gui/README.md +0 -73
- data/gui/eslint.config.js +0 -23
- data/gui/index.html +0 -13
- data/gui/package-lock.json +0 -5925
- data/gui/package.json +0 -46
- data/gui/postcss.config.js +0 -6
- data/gui/public/vite.svg +0 -1
- data/gui/src/App.css +0 -42
- data/gui/src/App.tsx +0 -51
- data/gui/src/assets/react.svg +0 -1
- data/gui/src/components/DagVisualizer.tsx +0 -424
- data/gui/src/components/Dashboard.tsx +0 -163
- data/gui/src/components/ErrorBoundary.tsx +0 -47
- data/gui/src/components/ReactorDetail.tsx +0 -135
- data/gui/src/components/StepInspector.tsx +0 -492
- data/gui/src/components/__tests__/DagVisualizer.test.tsx +0 -140
- data/gui/src/components/__tests__/ReactorDetail.test.tsx +0 -111
- data/gui/src/components/__tests__/StepInspector.test.tsx +0 -408
- data/gui/src/globals.d.ts +0 -7
- data/gui/src/index.css +0 -14
- data/gui/src/lib/utils.ts +0 -13
- data/gui/src/main.tsx +0 -14
- data/gui/src/test/setup.ts +0 -11
- data/gui/tailwind.config.js +0 -11
- data/gui/tsconfig.app.json +0 -28
- data/gui/tsconfig.json +0 -7
- data/gui/tsconfig.node.json +0 -26
- data/gui/vite.config.ts +0 -8
- data/gui/vitest.config.ts +0 -13
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class Semaphore
|
|
5
|
+
class AcquisitionError < StandardError; end
|
|
6
|
+
|
|
7
|
+
attr_reader :key, :limit, :wait, :token
|
|
8
|
+
|
|
9
|
+
def initialize(key, limit: 1, wait: 0)
|
|
10
|
+
@key = "semaphore:#{key}"
|
|
11
|
+
@limit = limit
|
|
12
|
+
@wait = wait
|
|
13
|
+
@token = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def acquire # rubocop:disable Naming/PredicateMethod
|
|
17
|
+
ensure_initialized
|
|
18
|
+
|
|
19
|
+
token = adapter.semaphore_acquire(@key, timeout: @wait)
|
|
20
|
+
raise AcquisitionError, "Could not acquire semaphore '#{@key}' within #{@wait} seconds" unless token
|
|
21
|
+
|
|
22
|
+
@token = token
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns true if a held token was returned to the pool.
|
|
27
|
+
# Idempotent: a second release (or one without a prior acquire) is a no-op.
|
|
28
|
+
def release
|
|
29
|
+
return false unless @token
|
|
30
|
+
|
|
31
|
+
result = adapter.semaphore_release(@key, @token, @limit)
|
|
32
|
+
@token = nil
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def synchronize
|
|
37
|
+
acquire
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
release
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def ensure_initialized
|
|
46
|
+
# Double-checked locking pattern optimized for Redis
|
|
47
|
+
# 1. Optimistic check (if key exists)
|
|
48
|
+
return if adapter.semaphore_exists?(@key)
|
|
49
|
+
|
|
50
|
+
# 2. Try to init
|
|
51
|
+
adapter.semaphore_init(@key, @limit)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def adapter
|
|
55
|
+
RubyReactor.configuration.storage_adapter
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -17,8 +17,17 @@ module RubyReactor
|
|
|
17
17
|
# Handle infrastructure failures (network, Redis, etc.)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def perform(serialized_context, reactor_class_name = nil)
|
|
21
|
-
|
|
20
|
+
def perform(serialized_context, reactor_class_name = nil, snooze_count = 0)
|
|
21
|
+
begin
|
|
22
|
+
context = ContextSerializer.deserialize(serialized_context)
|
|
23
|
+
rescue RubyReactor::Error::DeserializationError,
|
|
24
|
+
RubyReactor::Error::SchemaVersionError => e
|
|
25
|
+
# Permanent failures — retrying the same blob will keep failing.
|
|
26
|
+
# Mark the context as failed (best-effort) and return so Sidekiq
|
|
27
|
+
# does not burn its retry budget.
|
|
28
|
+
handle_deserialization_failure(serialized_context, reactor_class_name, e)
|
|
29
|
+
return
|
|
30
|
+
end
|
|
22
31
|
|
|
23
32
|
# If reactor_class_name is provided, use it to get the reactor class
|
|
24
33
|
# This handles cases where the class can't be found via const_get
|
|
@@ -35,18 +44,128 @@ module RubyReactor
|
|
|
35
44
|
# Mark that we're executing inline to prevent nested async calls
|
|
36
45
|
context.inline_async_execution = true
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
begin
|
|
48
|
+
# Resume execution from the failed step
|
|
49
|
+
executor = Executor.new(context.reactor_class, {}, context)
|
|
50
|
+
executor.resume_execution
|
|
51
|
+
executor.save_context
|
|
52
|
+
|
|
53
|
+
# Return the executor (which now has the result stored in it)
|
|
54
|
+
executor
|
|
55
|
+
rescue RubyReactor::Lock::AcquisitionError,
|
|
56
|
+
RubyReactor::Semaphore::AcquisitionError,
|
|
57
|
+
RubyReactor::RateLimit::ExceededError => e
|
|
58
|
+
# Snooze on expected concurrency or rate contention. We avoid
|
|
59
|
+
# Sidekiq's native retry path so this doesn't burn the job's retry
|
|
60
|
+
# budget or appear as an error in dashboards. After the configured
|
|
61
|
+
# cap is reached we escalate by marking the reactor as failed.
|
|
62
|
+
handle_snooze(serialized_context, reactor_class_name, context, snooze_count, e)
|
|
63
|
+
end
|
|
43
64
|
end
|
|
44
65
|
|
|
45
66
|
private
|
|
46
67
|
|
|
68
|
+
def handle_snooze(serialized_context, reactor_class_name, context, snooze_count, error)
|
|
69
|
+
config = RubyReactor.configuration
|
|
70
|
+
max = config.lock_snooze_max_attempts
|
|
71
|
+
|
|
72
|
+
if max != :infinity && snooze_count >= max
|
|
73
|
+
escalate_snooze(context, snooze_count, error)
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
delay = compute_snooze_delay(config, error)
|
|
78
|
+
self.class.perform_in(delay, serialized_context, reactor_class_name, snooze_count + 1)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Use the error's `retry_after_seconds` hint when available
|
|
82
|
+
# (RateLimit::ExceededError carries the time until the bucket rolls);
|
|
83
|
+
# otherwise fall back to the configured base + jitter for lock/semaphore
|
|
84
|
+
# contention which has no precise hint.
|
|
85
|
+
def compute_snooze_delay(config, error)
|
|
86
|
+
jitter = config.lock_snooze_jitter.to_f
|
|
87
|
+
jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
|
|
88
|
+
|
|
89
|
+
if error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
|
|
90
|
+
[error.retry_after_seconds.to_f, 0.1].max + jitter_amount
|
|
91
|
+
else
|
|
92
|
+
config.lock_snooze_base_delay.to_f + jitter_amount
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def escalate_snooze(context, snooze_count, error)
|
|
97
|
+
RubyReactor.configuration.logger.warn(
|
|
98
|
+
"RubyReactor snooze limit reached after #{snooze_count} attempts " \
|
|
99
|
+
"for context #{context.context_id}: #{error.message}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
context.status = :failed
|
|
103
|
+
context.failure_reason = {
|
|
104
|
+
message: error.message,
|
|
105
|
+
exception_class: error.class.name,
|
|
106
|
+
snooze_attempts: snooze_count
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
serialized = ContextSerializer.serialize(context)
|
|
110
|
+
reactor_class_name = context.reactor_class&.name || "AnonymousReactor"
|
|
111
|
+
RubyReactor.configuration.storage_adapter.store_context(
|
|
112
|
+
context.context_id,
|
|
113
|
+
serialized,
|
|
114
|
+
reactor_class_name
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
47
118
|
def log_infrastructure_failure(msg, exception)
|
|
48
|
-
|
|
49
|
-
|
|
119
|
+
RubyReactor.configuration.logger.error("RubyReactor infrastructure failure: #{exception.message}")
|
|
120
|
+
RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def handle_deserialization_failure(serialized_context, reactor_class_name, error)
|
|
124
|
+
metadata = extract_failure_metadata(serialized_context)
|
|
125
|
+
context_id = metadata[:context_id]
|
|
126
|
+
resolved_reactor_class_name = reactor_class_name || metadata[:reactor_class_name]
|
|
127
|
+
|
|
128
|
+
RubyReactor.configuration.logger.error(
|
|
129
|
+
"RubyReactor deserialization failure for context " \
|
|
130
|
+
"#{context_id || "unknown"}: #{error.class.name}: #{error.message}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return unless context_id && resolved_reactor_class_name
|
|
134
|
+
|
|
135
|
+
payload = build_failed_context_payload(context_id, resolved_reactor_class_name, error)
|
|
136
|
+
RubyReactor.configuration.storage_adapter.store_context(
|
|
137
|
+
context_id,
|
|
138
|
+
payload,
|
|
139
|
+
resolved_reactor_class_name
|
|
140
|
+
)
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
# Don't let a persistence failure mask the original deserialization error.
|
|
143
|
+
RubyReactor.configuration.logger.error(
|
|
144
|
+
"RubyReactor failed to persist deserialization failure: #{e.class.name}: #{e.message}"
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def extract_failure_metadata(serialized_context)
|
|
149
|
+
data = JSON.parse(serialized_context)
|
|
150
|
+
{
|
|
151
|
+
context_id: data["context_id"],
|
|
152
|
+
reactor_class_name: data["reactor_class"]
|
|
153
|
+
}
|
|
154
|
+
rescue StandardError
|
|
155
|
+
{}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def build_failed_context_payload(context_id, reactor_class_name, error)
|
|
159
|
+
JSON.generate(
|
|
160
|
+
"schema_version" => ContextSerializer::SCHEMA_VERSION,
|
|
161
|
+
"context_id" => context_id,
|
|
162
|
+
"reactor_class" => reactor_class_name,
|
|
163
|
+
"status" => "failed",
|
|
164
|
+
"failure_reason" => {
|
|
165
|
+
"message" => error.message,
|
|
166
|
+
"exception_class" => error.class.name
|
|
167
|
+
}
|
|
168
|
+
)
|
|
50
169
|
end
|
|
51
170
|
end
|
|
52
171
|
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.rb
CHANGED
|
@@ -4,6 +4,11 @@ require "zeitwerk"
|
|
|
4
4
|
require "pathname"
|
|
5
5
|
require_relative "ruby_reactor/registry"
|
|
6
6
|
require_relative "ruby_reactor/utils/code_extractor"
|
|
7
|
+
require_relative "ruby_reactor/dsl/lockable" # Add this
|
|
8
|
+
require_relative "ruby_reactor/lock"
|
|
9
|
+
require_relative "ruby_reactor/semaphore"
|
|
10
|
+
require_relative "ruby_reactor/period"
|
|
11
|
+
require_relative "ruby_reactor/rate_limit"
|
|
7
12
|
|
|
8
13
|
# Load dry-validation if available (for validation features)
|
|
9
14
|
begin
|
|
@@ -40,11 +45,45 @@ module RubyReactor
|
|
|
40
45
|
false
|
|
41
46
|
end
|
|
42
47
|
|
|
48
|
+
def skipped?
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
43
52
|
def to_h
|
|
44
53
|
{ success: true, value: @value }
|
|
45
54
|
end
|
|
46
55
|
end
|
|
47
56
|
|
|
57
|
+
# A "clean halt" signal. Two ways to produce one:
|
|
58
|
+
#
|
|
59
|
+
# 1. Implicitly, when a reactor's `with_period` gate finds the bucket has
|
|
60
|
+
# already been claimed. The executor short-circuits before any step
|
|
61
|
+
# runs.
|
|
62
|
+
#
|
|
63
|
+
# 2. Explicitly, by returning `RubyReactor.Skipped(reason: "...")` from a
|
|
64
|
+
# step's `run` block. The reactor halts immediately — no further steps,
|
|
65
|
+
# and crucially **no compensation** of already-completed steps. Use this
|
|
66
|
+
# when a step discovers that the rest of the workflow is not needed
|
|
67
|
+
# (e.g. "user already opted out", "nothing to do this round") and the
|
|
68
|
+
# partial progress is still correct to keep.
|
|
69
|
+
#
|
|
70
|
+
# Subclass of Success so callers that only check `success?` continue to work;
|
|
71
|
+
# `skipped?` distinguishes it.
|
|
72
|
+
class Skipped < Success
|
|
73
|
+
attr_reader :reason, :period_key, :step_name
|
|
74
|
+
|
|
75
|
+
def initialize(reason: nil, period_key: nil, step_name: nil)
|
|
76
|
+
super(nil)
|
|
77
|
+
@reason = reason
|
|
78
|
+
@period_key = period_key
|
|
79
|
+
@step_name = step_name
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def skipped?
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
48
87
|
class Failure
|
|
49
88
|
attr_reader :error, :retryable, :step_name, :inputs, :backtrace, :reactor_name, :step_arguments, :exception_class,
|
|
50
89
|
:file_path, :line_number, :code_snippet, :validation_errors
|
|
@@ -105,6 +144,10 @@ module RubyReactor
|
|
|
105
144
|
@retryable
|
|
106
145
|
end
|
|
107
146
|
|
|
147
|
+
def skipped?
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
|
|
108
151
|
def invalid_payload?
|
|
109
152
|
@invalid_payload
|
|
110
153
|
end
|
|
@@ -271,6 +314,12 @@ module RubyReactor
|
|
|
271
314
|
Failure.new(error, **kwargs)
|
|
272
315
|
end
|
|
273
316
|
|
|
317
|
+
# Build a `Skipped` result. Return one from a step's `run` block to halt the
|
|
318
|
+
# reactor cleanly without triggering compensation of previous steps.
|
|
319
|
+
def self.Skipped(reason: nil, **kwargs)
|
|
320
|
+
Skipped.new(reason: reason, **kwargs)
|
|
321
|
+
end
|
|
322
|
+
|
|
274
323
|
def self.configure
|
|
275
324
|
yield(Configuration.instance) if block_given?
|
|
276
325
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_reactor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Artur
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: dry-validation
|
|
@@ -89,56 +88,15 @@ executables: []
|
|
|
89
88
|
extensions: []
|
|
90
89
|
extra_rdoc_files: []
|
|
91
90
|
files:
|
|
91
|
+
- ".release-please-config.json"
|
|
92
|
+
- ".release-please-manifest.json"
|
|
92
93
|
- ".rspec"
|
|
93
94
|
- ".rubocop.yml"
|
|
95
|
+
- ".tool-versions"
|
|
96
|
+
- CHANGELOG.md
|
|
94
97
|
- CODE_OF_CONDUCT.md
|
|
95
98
|
- README.md
|
|
96
99
|
- Rakefile
|
|
97
|
-
- documentation/DAG.md
|
|
98
|
-
- documentation/README.md
|
|
99
|
-
- documentation/async_reactors.md
|
|
100
|
-
- documentation/composition.md
|
|
101
|
-
- documentation/core_concepts.md
|
|
102
|
-
- documentation/data_pipelines.md
|
|
103
|
-
- documentation/examples/inventory_management.md
|
|
104
|
-
- documentation/examples/order_processing.md
|
|
105
|
-
- documentation/examples/payment_processing.md
|
|
106
|
-
- documentation/getting_started.md
|
|
107
|
-
- documentation/images/failed_order_processing.png
|
|
108
|
-
- documentation/images/payment_workflow.png
|
|
109
|
-
- documentation/interrupts.md
|
|
110
|
-
- documentation/retry_configuration.md
|
|
111
|
-
- documentation/testing.md
|
|
112
|
-
- gui/.gitignore
|
|
113
|
-
- gui/README.md
|
|
114
|
-
- gui/eslint.config.js
|
|
115
|
-
- gui/index.html
|
|
116
|
-
- gui/package-lock.json
|
|
117
|
-
- gui/package.json
|
|
118
|
-
- gui/postcss.config.js
|
|
119
|
-
- gui/public/vite.svg
|
|
120
|
-
- gui/src/App.css
|
|
121
|
-
- gui/src/App.tsx
|
|
122
|
-
- gui/src/assets/react.svg
|
|
123
|
-
- gui/src/components/DagVisualizer.tsx
|
|
124
|
-
- gui/src/components/Dashboard.tsx
|
|
125
|
-
- gui/src/components/ErrorBoundary.tsx
|
|
126
|
-
- gui/src/components/ReactorDetail.tsx
|
|
127
|
-
- gui/src/components/StepInspector.tsx
|
|
128
|
-
- gui/src/components/__tests__/DagVisualizer.test.tsx
|
|
129
|
-
- gui/src/components/__tests__/ReactorDetail.test.tsx
|
|
130
|
-
- gui/src/components/__tests__/StepInspector.test.tsx
|
|
131
|
-
- gui/src/globals.d.ts
|
|
132
|
-
- gui/src/index.css
|
|
133
|
-
- gui/src/lib/utils.ts
|
|
134
|
-
- gui/src/main.tsx
|
|
135
|
-
- gui/src/test/setup.ts
|
|
136
|
-
- gui/tailwind.config.js
|
|
137
|
-
- gui/tsconfig.app.json
|
|
138
|
-
- gui/tsconfig.json
|
|
139
|
-
- gui/tsconfig.node.json
|
|
140
|
-
- gui/vite.config.ts
|
|
141
|
-
- gui/vitest.config.ts
|
|
142
100
|
- lib/ruby_reactor.rb
|
|
143
101
|
- lib/ruby_reactor/configuration.rb
|
|
144
102
|
- lib/ruby_reactor/context.rb
|
|
@@ -147,6 +105,7 @@ files:
|
|
|
147
105
|
- lib/ruby_reactor/dsl/compose_builder.rb
|
|
148
106
|
- lib/ruby_reactor/dsl/interrupt_builder.rb
|
|
149
107
|
- lib/ruby_reactor/dsl/interrupt_step_config.rb
|
|
108
|
+
- lib/ruby_reactor/dsl/lockable.rb
|
|
150
109
|
- lib/ruby_reactor/dsl/map_builder.rb
|
|
151
110
|
- lib/ruby_reactor/dsl/reactor.rb
|
|
152
111
|
- lib/ruby_reactor/dsl/step_builder.rb
|
|
@@ -170,6 +129,7 @@ files:
|
|
|
170
129
|
- lib/ruby_reactor/executor/retry_manager.rb
|
|
171
130
|
- lib/ruby_reactor/executor/step_executor.rb
|
|
172
131
|
- lib/ruby_reactor/interrupt_result.rb
|
|
132
|
+
- lib/ruby_reactor/lock.rb
|
|
173
133
|
- lib/ruby_reactor/map/collector.rb
|
|
174
134
|
- lib/ruby_reactor/map/dispatcher.rb
|
|
175
135
|
- lib/ruby_reactor/map/element_executor.rb
|
|
@@ -177,6 +137,8 @@ files:
|
|
|
177
137
|
- lib/ruby_reactor/map/helpers.rb
|
|
178
138
|
- lib/ruby_reactor/map/result_enumerator.rb
|
|
179
139
|
- lib/ruby_reactor/max_retries_exhausted_failure.rb
|
|
140
|
+
- lib/ruby_reactor/period.rb
|
|
141
|
+
- lib/ruby_reactor/rate_limit.rb
|
|
180
142
|
- lib/ruby_reactor/reactor.rb
|
|
181
143
|
- lib/ruby_reactor/registry.rb
|
|
182
144
|
- lib/ruby_reactor/retry_context.rb
|
|
@@ -186,6 +148,7 @@ files:
|
|
|
186
148
|
- lib/ruby_reactor/rspec/matchers.rb
|
|
187
149
|
- lib/ruby_reactor/rspec/step_executor_patch.rb
|
|
188
150
|
- lib/ruby_reactor/rspec/test_subject.rb
|
|
151
|
+
- lib/ruby_reactor/semaphore.rb
|
|
189
152
|
- lib/ruby_reactor/sidekiq_adapter.rb
|
|
190
153
|
- lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb
|
|
191
154
|
- lib/ruby_reactor/sidekiq_workers/map_element_worker.rb
|
|
@@ -197,6 +160,7 @@ files:
|
|
|
197
160
|
- lib/ruby_reactor/storage/adapter.rb
|
|
198
161
|
- lib/ruby_reactor/storage/configuration.rb
|
|
199
162
|
- lib/ruby_reactor/storage/redis_adapter.rb
|
|
163
|
+
- lib/ruby_reactor/storage/redis_locking.rb
|
|
200
164
|
- lib/ruby_reactor/template/base.rb
|
|
201
165
|
- lib/ruby_reactor/template/dynamic_source.rb
|
|
202
166
|
- lib/ruby_reactor/template/element.rb
|
|
@@ -226,7 +190,6 @@ metadata:
|
|
|
226
190
|
homepage_uri: https://github.com/arturictus/ruby_reactor
|
|
227
191
|
source_code_uri: https://github.com/arturictus/ruby_reactor
|
|
228
192
|
changelog_uri: https://github.com/arturictus/ruby_reactor/blob/main/CHANGELOG.md
|
|
229
|
-
post_install_message:
|
|
230
193
|
rdoc_options: []
|
|
231
194
|
require_paths:
|
|
232
195
|
- lib
|
|
@@ -241,8 +204,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
241
204
|
- !ruby/object:Gem::Version
|
|
242
205
|
version: '0'
|
|
243
206
|
requirements: []
|
|
244
|
-
rubygems_version:
|
|
245
|
-
signing_key:
|
|
207
|
+
rubygems_version: 4.0.10
|
|
246
208
|
specification_version: 4
|
|
247
209
|
summary: A dynamic, concurrent, dependency-resolving saga orchestrator for Ruby.
|
|
248
210
|
test_files: []
|