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
|
@@ -34,9 +34,22 @@ module RubyReactor
|
|
|
34
34
|
}
|
|
35
35
|
)
|
|
36
36
|
@result = nil
|
|
37
|
+
@acquired_lock = nil
|
|
38
|
+
@acquired_semaphore = nil
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
def execute
|
|
42
|
+
skipped = check_period_gate
|
|
43
|
+
if skipped
|
|
44
|
+
@result = skipped
|
|
45
|
+
update_context_status(@result)
|
|
46
|
+
save_context
|
|
47
|
+
return @result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
check_rate_limit
|
|
51
|
+
acquire_locks
|
|
52
|
+
|
|
40
53
|
input_validator = InputValidator.new(@reactor_class, @context)
|
|
41
54
|
input_validator.validate!
|
|
42
55
|
|
|
@@ -49,18 +62,25 @@ module RubyReactor
|
|
|
49
62
|
|
|
50
63
|
@result = @step_executor.execute_all_steps
|
|
51
64
|
update_context_status(@result)
|
|
65
|
+
mark_period_on_success(@result)
|
|
52
66
|
handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
|
|
53
67
|
@result
|
|
68
|
+
rescue RubyReactor::Lock::AcquisitionError,
|
|
69
|
+
RubyReactor::Semaphore::AcquisitionError,
|
|
70
|
+
RubyReactor::RateLimit::ExceededError => e
|
|
71
|
+
raise e
|
|
54
72
|
rescue StandardError => e
|
|
55
73
|
@result = @result_handler.handle_execution_error(e)
|
|
56
74
|
update_context_status(@result)
|
|
57
75
|
@result
|
|
58
76
|
ensure
|
|
77
|
+
release_locks
|
|
59
78
|
save_context
|
|
60
79
|
end
|
|
61
80
|
|
|
62
81
|
def resume_execution
|
|
63
82
|
@context.status = :running
|
|
83
|
+
acquire_locks
|
|
64
84
|
prepare_for_resume
|
|
65
85
|
save_context
|
|
66
86
|
|
|
@@ -71,15 +91,21 @@ module RubyReactor
|
|
|
71
91
|
end
|
|
72
92
|
|
|
73
93
|
update_context_status(@result)
|
|
94
|
+
mark_period_on_success(@result)
|
|
74
95
|
|
|
75
96
|
handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
|
|
76
97
|
|
|
77
98
|
@result
|
|
99
|
+
rescue RubyReactor::Lock::AcquisitionError,
|
|
100
|
+
RubyReactor::Semaphore::AcquisitionError,
|
|
101
|
+
RubyReactor::RateLimit::ExceededError => e
|
|
102
|
+
raise e
|
|
78
103
|
rescue StandardError => e
|
|
79
104
|
handle_resume_error(e)
|
|
80
105
|
update_context_status(@result)
|
|
81
106
|
@result
|
|
82
107
|
ensure
|
|
108
|
+
release_locks
|
|
83
109
|
save_context
|
|
84
110
|
end
|
|
85
111
|
|
|
@@ -110,12 +136,122 @@ module RubyReactor
|
|
|
110
136
|
|
|
111
137
|
private
|
|
112
138
|
|
|
139
|
+
def acquire_locks
|
|
140
|
+
acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
|
|
141
|
+
acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Consume one slot from each configured rate-limit window. Raises
|
|
145
|
+
# `RubyReactor::RateLimit::ExceededError` (carrying a `retry_after_seconds`
|
|
146
|
+
# hint) if any window is full. Only consulted on initial `execute`; resumes
|
|
147
|
+
# never re-check (a paused reactor must not block itself on resume).
|
|
148
|
+
def check_rate_limit
|
|
149
|
+
return unless @reactor_class.respond_to?(:rate_limit_config) && @reactor_class.rate_limit_config
|
|
150
|
+
|
|
151
|
+
config = @reactor_class.rate_limit_config
|
|
152
|
+
key_base = config[:key_proc].call(@context.inputs)
|
|
153
|
+
|
|
154
|
+
RubyReactor::RateLimit.new(key_base, limits: config[:limits]).check_and_increment!
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Returns a Skipped result if the period bucket is already marked, else nil.
|
|
158
|
+
# Only consulted on initial `execute`; resumes never re-check (a paused run
|
|
159
|
+
# must not skip itself when its own marker eventually appears).
|
|
160
|
+
def check_period_gate
|
|
161
|
+
return nil unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
|
|
162
|
+
|
|
163
|
+
config = @reactor_class.period_config
|
|
164
|
+
key = period_key(config)
|
|
165
|
+
return nil unless RubyReactor.configuration.storage_adapter.period_seen?(key)
|
|
166
|
+
|
|
167
|
+
RubyReactor::Skipped.new(reason: :period, period_key: key)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def mark_period_on_success(result)
|
|
171
|
+
return unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
|
|
172
|
+
return unless result.is_a?(RubyReactor::Success)
|
|
173
|
+
return if result.is_a?(RubyReactor::Skipped)
|
|
174
|
+
|
|
175
|
+
config = @reactor_class.period_config
|
|
176
|
+
ttl = RubyReactor::Period.ttl_seconds(config[:every])
|
|
177
|
+
RubyReactor.configuration.storage_adapter.period_mark(period_key(config), ttl)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def period_key(config)
|
|
181
|
+
base = config[:key_proc].call(@context.inputs)
|
|
182
|
+
RubyReactor::Period.key(base, config[:every])
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def acquire_exclusive_lock
|
|
186
|
+
config = @reactor_class.lock_config
|
|
187
|
+
key = config[:key_proc].call(@context.inputs)
|
|
188
|
+
|
|
189
|
+
# Use root context ID as owner to allow re-entrancy across nested reactors
|
|
190
|
+
owner = (@context.root_context || @context).context_id
|
|
191
|
+
|
|
192
|
+
lock = RubyReactor::Lock.new(
|
|
193
|
+
key,
|
|
194
|
+
owner: owner,
|
|
195
|
+
ttl: config[:ttl],
|
|
196
|
+
wait: contention_wait(config[:wait]),
|
|
197
|
+
auto_extend: config.fetch(:auto_extend, true)
|
|
198
|
+
)
|
|
199
|
+
lock.acquire
|
|
200
|
+
@acquired_lock = lock
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def acquire_semaphore
|
|
204
|
+
config = @reactor_class.semaphore_config
|
|
205
|
+
key = config[:key_proc].call(@context.inputs)
|
|
206
|
+
limit = config[:limit]
|
|
207
|
+
|
|
208
|
+
semaphore = RubyReactor::Semaphore.new(key, limit: limit, wait: contention_wait(config[:wait]))
|
|
209
|
+
semaphore.acquire
|
|
210
|
+
@acquired_semaphore = semaphore
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Inside a Sidekiq worker we'd rather snooze the job via perform_in than
|
|
214
|
+
# tie up the worker thread on a BLPOP / sleep loop. The non-blocking path
|
|
215
|
+
# fails fast and the Worker rescue branch reschedules.
|
|
216
|
+
def contention_wait(configured_wait)
|
|
217
|
+
return 0 if @context.inline_async_execution
|
|
218
|
+
|
|
219
|
+
configured_wait
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def release_locks
|
|
223
|
+
release_one("semaphore", @acquired_semaphore) if @acquired_semaphore
|
|
224
|
+
@acquired_semaphore = nil
|
|
225
|
+
|
|
226
|
+
return unless @acquired_lock
|
|
227
|
+
|
|
228
|
+
release_one("lock", @acquired_lock)
|
|
229
|
+
@acquired_lock = nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def release_one(kind, primitive)
|
|
233
|
+
released = primitive.release
|
|
234
|
+
return if released
|
|
235
|
+
|
|
236
|
+
RubyReactor.configuration.logger.warn(
|
|
237
|
+
"RubyReactor #{kind} '#{primitive.key}' was not held at release time " \
|
|
238
|
+
"(likely TTL expired or owner changed)"
|
|
239
|
+
)
|
|
240
|
+
rescue StandardError => e
|
|
241
|
+
# Never let release break the ensure chain — log and move on.
|
|
242
|
+
RubyReactor.configuration.logger.warn(
|
|
243
|
+
"RubyReactor failed to release #{kind} '#{primitive.key}': #{e.message}"
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
113
247
|
def update_context_status(result)
|
|
114
248
|
return unless result
|
|
115
249
|
|
|
116
250
|
case result
|
|
117
251
|
when RubyReactor::AsyncResult
|
|
118
252
|
@context.status = :running
|
|
253
|
+
when RubyReactor::Skipped
|
|
254
|
+
@context.status = :skipped
|
|
119
255
|
when RubyReactor::Success
|
|
120
256
|
@context.status = :completed
|
|
121
257
|
when RubyReactor::Failure
|
|
@@ -148,8 +284,15 @@ module RubyReactor
|
|
|
148
284
|
@result = @step_executor.execute_all_steps
|
|
149
285
|
else
|
|
150
286
|
case result
|
|
151
|
-
|
|
152
|
-
|
|
287
|
+
# Skipped must be listed before Success (Skipped < Success) so the
|
|
288
|
+
# halt path wins over the "continue with remaining steps" path.
|
|
289
|
+
when RubyReactor::Skipped,
|
|
290
|
+
RetryQueuedResult,
|
|
291
|
+
RubyReactor::Failure,
|
|
292
|
+
RubyReactor::AsyncResult,
|
|
293
|
+
RubyReactor::InterruptResult
|
|
294
|
+
# Terminal: step was skipped, requeued, failed, paused, or handed
|
|
295
|
+
# off to async. Return the result as-is.
|
|
153
296
|
@result = result
|
|
154
297
|
when RubyReactor::Success
|
|
155
298
|
# Step succeeded, continue with remaining steps
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class Lock
|
|
5
|
+
class AcquisitionError < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Minimum interval between auto-extend pings; protects very small TTLs.
|
|
8
|
+
MIN_EXTEND_INTERVAL = 1.0
|
|
9
|
+
|
|
10
|
+
attr_reader :key, :owner, :ttl, :wait, :auto_extend
|
|
11
|
+
|
|
12
|
+
def initialize(key, owner:, ttl: 60, wait: 0, auto_extend: true)
|
|
13
|
+
@key = "lock:#{key}"
|
|
14
|
+
@owner = owner
|
|
15
|
+
@ttl = ttl
|
|
16
|
+
@wait = wait
|
|
17
|
+
@auto_extend = auto_extend
|
|
18
|
+
@extender = nil
|
|
19
|
+
@extender_running = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def acquire
|
|
23
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
|
+
|
|
25
|
+
loop do
|
|
26
|
+
if adapter.lock_acquire(@key, @owner, @ttl)
|
|
27
|
+
start_extender if @auto_extend
|
|
28
|
+
return true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if @wait.zero? || (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) >= @wait
|
|
32
|
+
raise AcquisitionError, "Could not acquire lock '#{@key}' for owner '#{@owner}'"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sleep 0.1
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def release
|
|
40
|
+
stop_extender
|
|
41
|
+
adapter.lock_release(@key, @owner)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def synchronize
|
|
45
|
+
acquire
|
|
46
|
+
yield
|
|
47
|
+
ensure
|
|
48
|
+
release
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def start_extender
|
|
54
|
+
return if @extender&.alive?
|
|
55
|
+
|
|
56
|
+
interval = [@ttl / 3.0, MIN_EXTEND_INTERVAL].max
|
|
57
|
+
@extender_running = true
|
|
58
|
+
|
|
59
|
+
@extender = Thread.new do
|
|
60
|
+
while @extender_running
|
|
61
|
+
sleep interval
|
|
62
|
+
break unless @extender_running
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
adapter.lock_extend(@key, @owner, @ttl)
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
RubyReactor.configuration.logger.warn(
|
|
68
|
+
"Lock auto-extend failed for '#{@key}': #{e.message}"
|
|
69
|
+
)
|
|
70
|
+
break
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stop_extender
|
|
77
|
+
@extender_running = false
|
|
78
|
+
thread = @extender
|
|
79
|
+
@extender = nil
|
|
80
|
+
return unless thread
|
|
81
|
+
|
|
82
|
+
thread.wakeup if thread.alive?
|
|
83
|
+
thread.join(0.1)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
# Best-effort shutdown; never let extender teardown break release.
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def adapter
|
|
89
|
+
RubyReactor.configuration.storage_adapter
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -60,6 +60,7 @@ module RubyReactor
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def [](index)
|
|
63
|
+
index += count if index.negative?
|
|
63
64
|
return nil if index.negative? || index >= count
|
|
64
65
|
|
|
65
66
|
results = @storage.retrieve_map_results_batch(
|
|
@@ -80,15 +81,15 @@ module RubyReactor
|
|
|
80
81
|
end
|
|
81
82
|
|
|
82
83
|
def last
|
|
83
|
-
self[
|
|
84
|
+
self[-1]
|
|
84
85
|
end
|
|
85
86
|
|
|
86
87
|
def successes
|
|
87
|
-
lazy.
|
|
88
|
+
lazy.grep(RubyReactor::Success).map(&:value)
|
|
88
89
|
end
|
|
89
90
|
|
|
90
91
|
def failures
|
|
91
|
-
lazy.
|
|
92
|
+
lazy.grep(RubyReactor::Failure).map(&:error)
|
|
92
93
|
end
|
|
93
94
|
|
|
94
95
|
private
|
|
@@ -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:) # rubocop:disable Metrics/ParameterLists
|
|
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
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
module RSpec
|
|
5
|
+
# rubocop:disable Metrics/ModuleLength
|
|
5
6
|
module Matchers
|
|
6
7
|
# rubocop:disable Metrics/BlockLength
|
|
7
8
|
::RSpec::Matchers.define :be_success do
|
|
8
9
|
match do |subject|
|
|
9
|
-
subject.ensure_executed!
|
|
10
|
+
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
10
11
|
subject.success?
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
failure_message do |subject|
|
|
14
|
-
result = subject.result
|
|
15
|
+
result = subject.respond_to?(:result) ? subject.result : subject
|
|
15
16
|
if result&.failure?
|
|
16
17
|
format_failure_message(result)
|
|
17
|
-
|
|
18
|
+
elsif subject.respond_to?(:reactor_instance)
|
|
18
19
|
"expected reactor to be success, but failed (Status: #{subject.reactor_instance.context.status})"
|
|
20
|
+
else
|
|
21
|
+
"expected #{subject.inspect} to be success"
|
|
19
22
|
end
|
|
20
23
|
end
|
|
21
24
|
|
|
@@ -62,7 +65,7 @@ module RubyReactor
|
|
|
62
65
|
|
|
63
66
|
::RSpec::Matchers.define :be_failure do
|
|
64
67
|
match do |subject|
|
|
65
|
-
subject.ensure_executed!
|
|
68
|
+
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
66
69
|
subject.failure?
|
|
67
70
|
end
|
|
68
71
|
|
|
@@ -249,8 +252,172 @@ module RubyReactor
|
|
|
249
252
|
end
|
|
250
253
|
end
|
|
251
254
|
|
|
255
|
+
# ---------------------------------------------------------------------
|
|
256
|
+
# Lock / Semaphore / Rate-limit / Period state matchers
|
|
257
|
+
# ---------------------------------------------------------------------
|
|
258
|
+
#
|
|
259
|
+
# These assert against the live Redis state via the configured storage
|
|
260
|
+
# adapter, so they work for any test that has actually exercised the
|
|
261
|
+
# reactor (or interacted with the primitives directly).
|
|
262
|
+
|
|
263
|
+
def self.coordination_adapter
|
|
264
|
+
RubyReactor.configuration.storage_adapter
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Distinguishes `RubyReactor::Skipped` from a plain `Success`. Works on
|
|
268
|
+
# any object with a `skipped?` predicate.
|
|
269
|
+
#
|
|
270
|
+
# Examples:
|
|
271
|
+
# expect(result).to be_skipped
|
|
272
|
+
# expect(result).to be_skipped.because(:period)
|
|
273
|
+
# expect(result).to be_skipped.at_step(:second)
|
|
274
|
+
::RSpec::Matchers.define :be_skipped do
|
|
275
|
+
match do |subject|
|
|
276
|
+
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
277
|
+
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
278
|
+
next false unless actual.respond_to?(:skipped?) && actual.skipped?
|
|
279
|
+
next false if @expected_reason && actual.reason != @expected_reason
|
|
280
|
+
next false if @expected_step && actual.step_name != @expected_step
|
|
281
|
+
|
|
282
|
+
true
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
chain :because do |reason|
|
|
286
|
+
@expected_reason = reason
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
chain :at_step do |step|
|
|
290
|
+
@expected_step = step
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
failure_message do |subject|
|
|
294
|
+
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
295
|
+
if !actual.respond_to?(:skipped?) || !actual.skipped?
|
|
296
|
+
"expected result to be Skipped, got #{actual.class}"
|
|
297
|
+
elsif @expected_reason && actual.reason != @expected_reason
|
|
298
|
+
"expected Skipped reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
|
|
299
|
+
else
|
|
300
|
+
"expected Skipped at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
failure_message_when_negated do
|
|
305
|
+
"expected result not to be Skipped"
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Asserts that an exclusive lock is currently held in Redis. Subject is
|
|
310
|
+
# the user-provided lock key (without the "lock:" prefix).
|
|
311
|
+
#
|
|
312
|
+
# expect("order:42").to be_locked
|
|
313
|
+
# expect("order:42").to be_locked.by("ctx-abc")
|
|
314
|
+
::RSpec::Matchers.define :be_locked do
|
|
315
|
+
match do |key|
|
|
316
|
+
info = Matchers.coordination_adapter.lock_info("lock:#{key}")
|
|
317
|
+
next false unless info
|
|
318
|
+
next true unless @expected_owner
|
|
319
|
+
|
|
320
|
+
info[:owner] == @expected_owner
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
chain :by do |owner|
|
|
324
|
+
@expected_owner = owner
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
failure_message do |key|
|
|
328
|
+
info = Matchers.coordination_adapter.lock_info("lock:#{key}")
|
|
329
|
+
if info.nil?
|
|
330
|
+
"expected lock 'lock:#{key}' to be held, but it is free"
|
|
331
|
+
else
|
|
332
|
+
"expected lock 'lock:#{key}' to be held by #{@expected_owner.inspect}, " \
|
|
333
|
+
"but is held by #{info[:owner].inspect}"
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
failure_message_when_negated do |key|
|
|
338
|
+
info = Matchers.coordination_adapter.lock_info("lock:#{key}")
|
|
339
|
+
"expected lock 'lock:#{key}' not to be held, but is held by #{info[:owner].inspect}"
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Asserts the number of unallocated semaphore tokens. Subject is the
|
|
344
|
+
# user-provided semaphore name (without the "semaphore:" prefix).
|
|
345
|
+
#
|
|
346
|
+
# expect("api_limit").to have_available_tokens(3)
|
|
347
|
+
::RSpec::Matchers.define :have_available_tokens do |expected|
|
|
348
|
+
match do |name|
|
|
349
|
+
Matchers.coordination_adapter.semaphore_state(name)[:available] == expected
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
failure_message do |name|
|
|
353
|
+
state = Matchers.coordination_adapter.semaphore_state(name)
|
|
354
|
+
"expected semaphore '#{name}' to have #{expected} available tokens, " \
|
|
355
|
+
"got #{state[:available]} (held: #{state[:held]}, limit: #{state[:limit]})"
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Asserts the number of currently-checked-out semaphore tokens.
|
|
360
|
+
#
|
|
361
|
+
# expect("api_limit").to have_held_tokens(2)
|
|
362
|
+
::RSpec::Matchers.define :have_held_tokens do |expected|
|
|
363
|
+
match do |name|
|
|
364
|
+
Matchers.coordination_adapter.semaphore_state(name)[:held] == expected
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
failure_message do |name|
|
|
368
|
+
state = Matchers.coordination_adapter.semaphore_state(name)
|
|
369
|
+
"expected semaphore '#{name}' to have #{expected} held tokens, " \
|
|
370
|
+
"got #{state[:held]} (available: #{state[:available]}, limit: #{state[:limit]})"
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Asserts the current rate-limit counter for a (key_base, period) pair.
|
|
375
|
+
# Use `.for(period_unit)` to specify which window.
|
|
376
|
+
#
|
|
377
|
+
# expect("stripe:42").to have_rate_limit_count(3).for(:second)
|
|
378
|
+
::RSpec::Matchers.define :have_rate_limit_count do |expected|
|
|
379
|
+
match do |key_base|
|
|
380
|
+
raise ArgumentError, "have_rate_limit_count requires .for(period)" unless @period
|
|
381
|
+
|
|
382
|
+
Matchers.coordination_adapter.rate_limit_count(key_base, @period) == expected
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
chain :for do |period|
|
|
386
|
+
@period = period
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
failure_message do |key_base|
|
|
390
|
+
actual = Matchers.coordination_adapter.rate_limit_count(key_base, @period)
|
|
391
|
+
"expected rate-limit '#{key_base}' (#{@period}) count to be #{expected}, got #{actual}"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Asserts that a `with_period` bucket has been marked. Use `.for(period)`.
|
|
396
|
+
#
|
|
397
|
+
# expect("daily_report:7").to be_period_marked.for(:day)
|
|
398
|
+
::RSpec::Matchers.define :be_period_marked do
|
|
399
|
+
match do |key_base|
|
|
400
|
+
raise ArgumentError, "be_period_marked requires .for(period)" unless @period
|
|
401
|
+
|
|
402
|
+
Matchers.coordination_adapter.period_marker?(key_base, @period)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
chain :for do |period|
|
|
406
|
+
@period = period
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
failure_message do |key_base|
|
|
410
|
+
"expected period bucket #{RubyReactor::Period.key(key_base, @period).inspect} to be marked, but it is not"
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
failure_message_when_negated do |key_base|
|
|
414
|
+
"expected period bucket #{RubyReactor::Period.key(key_base, @period).inspect} not to be marked, but it is"
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
252
418
|
# Add more matchers as per plan
|
|
253
419
|
# rubocop:enable Metrics/BlockLength
|
|
254
420
|
end
|
|
421
|
+
# rubocop:enable Metrics/ModuleLength
|
|
255
422
|
end
|
|
256
423
|
end
|