ruby_reactor 0.3.1 → 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 +114 -5
- 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 +182 -0
- data/lib/ruby_reactor/configuration.rb +18 -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/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 +70 -8
- 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 +9 -2
|
@@ -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,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_reactor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Artur
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: dry-validation
|
|
@@ -107,6 +107,7 @@ files:
|
|
|
107
107
|
- documentation/images/failed_order_processing.png
|
|
108
108
|
- documentation/images/payment_workflow.png
|
|
109
109
|
- documentation/interrupts.md
|
|
110
|
+
- documentation/locks_and_semaphores.md
|
|
110
111
|
- documentation/retry_configuration.md
|
|
111
112
|
- documentation/testing.md
|
|
112
113
|
- gui/.gitignore
|
|
@@ -147,6 +148,7 @@ files:
|
|
|
147
148
|
- lib/ruby_reactor/dsl/compose_builder.rb
|
|
148
149
|
- lib/ruby_reactor/dsl/interrupt_builder.rb
|
|
149
150
|
- lib/ruby_reactor/dsl/interrupt_step_config.rb
|
|
151
|
+
- lib/ruby_reactor/dsl/lockable.rb
|
|
150
152
|
- lib/ruby_reactor/dsl/map_builder.rb
|
|
151
153
|
- lib/ruby_reactor/dsl/reactor.rb
|
|
152
154
|
- lib/ruby_reactor/dsl/step_builder.rb
|
|
@@ -170,6 +172,7 @@ files:
|
|
|
170
172
|
- lib/ruby_reactor/executor/retry_manager.rb
|
|
171
173
|
- lib/ruby_reactor/executor/step_executor.rb
|
|
172
174
|
- lib/ruby_reactor/interrupt_result.rb
|
|
175
|
+
- lib/ruby_reactor/lock.rb
|
|
173
176
|
- lib/ruby_reactor/map/collector.rb
|
|
174
177
|
- lib/ruby_reactor/map/dispatcher.rb
|
|
175
178
|
- lib/ruby_reactor/map/element_executor.rb
|
|
@@ -177,6 +180,8 @@ files:
|
|
|
177
180
|
- lib/ruby_reactor/map/helpers.rb
|
|
178
181
|
- lib/ruby_reactor/map/result_enumerator.rb
|
|
179
182
|
- lib/ruby_reactor/max_retries_exhausted_failure.rb
|
|
183
|
+
- lib/ruby_reactor/period.rb
|
|
184
|
+
- lib/ruby_reactor/rate_limit.rb
|
|
180
185
|
- lib/ruby_reactor/reactor.rb
|
|
181
186
|
- lib/ruby_reactor/registry.rb
|
|
182
187
|
- lib/ruby_reactor/retry_context.rb
|
|
@@ -186,6 +191,7 @@ files:
|
|
|
186
191
|
- lib/ruby_reactor/rspec/matchers.rb
|
|
187
192
|
- lib/ruby_reactor/rspec/step_executor_patch.rb
|
|
188
193
|
- lib/ruby_reactor/rspec/test_subject.rb
|
|
194
|
+
- lib/ruby_reactor/semaphore.rb
|
|
189
195
|
- lib/ruby_reactor/sidekiq_adapter.rb
|
|
190
196
|
- lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb
|
|
191
197
|
- lib/ruby_reactor/sidekiq_workers/map_element_worker.rb
|
|
@@ -197,6 +203,7 @@ files:
|
|
|
197
203
|
- lib/ruby_reactor/storage/adapter.rb
|
|
198
204
|
- lib/ruby_reactor/storage/configuration.rb
|
|
199
205
|
- lib/ruby_reactor/storage/redis_adapter.rb
|
|
206
|
+
- lib/ruby_reactor/storage/redis_locking.rb
|
|
200
207
|
- lib/ruby_reactor/template/base.rb
|
|
201
208
|
- lib/ruby_reactor/template/dynamic_source.rb
|
|
202
209
|
- lib/ruby_reactor/template/element.rb
|