ruby_reactor 0.5.0 → 0.5.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/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +199 -30
- data/lib/ruby_reactor/configuration.rb +7 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
- data/lib/ruby_reactor/dsl/lockable.rb +60 -29
- data/lib/ruby_reactor/dsl/reactor.rb +38 -7
- data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
- data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
- data/lib/ruby_reactor/executor/result_handler.rb +35 -8
- data/lib/ruby_reactor/executor/step_executor.rb +10 -5
- data/lib/ruby_reactor/executor.rb +145 -50
- data/lib/ruby_reactor/ordered_lock.rb +158 -0
- data/lib/ruby_reactor/rate_limit.rb +28 -0
- data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
- data/lib/ruby_reactor/reactor.rb +41 -0
- data/lib/ruby_reactor/rspec/helpers.rb +6 -0
- data/lib/ruby_reactor/rspec/matchers.rb +66 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
- data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
- data/lib/ruby_reactor/rspec.rb +37 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +50 -8
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -0
- data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
- data/lib/ruby_reactor/validation/base.rb +4 -1
- data/lib/ruby_reactor/validation/input_validator.rb +4 -2
- data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +1 -0
- metadata +7 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Storage
|
|
5
|
+
# Ordered Lock Primitives — used by `with_ordered_lock` to enforce
|
|
6
|
+
# strict transaction ordering via a monotonically increasing nonce
|
|
7
|
+
# assigned at enqueue time. See `RubyReactor::OrderedLock`.
|
|
8
|
+
#
|
|
9
|
+
# Storage layout (hash-tagged so a Redis cluster keeps them on the same
|
|
10
|
+
# shard):
|
|
11
|
+
# STRING ordered_lock:{<key>}:next — last-assigned nonce
|
|
12
|
+
# STRING ordered_lock:{<key>}:last_completed — last-advanced nonce
|
|
13
|
+
# HASH ordered_lock:{<key>}:assigned_at — { nonce => unix_ts }
|
|
14
|
+
# STRING ordered_lock:{<key>}:first_failed — nonce of the FIRST run
|
|
15
|
+
# whose terminal status
|
|
16
|
+
# was Failure (strict mode
|
|
17
|
+
# poison marker; 0 / unset
|
|
18
|
+
# if no failure yet).
|
|
19
|
+
# STRING ordered_lock:{<key>}:epoch — generation counter,
|
|
20
|
+
# bumped each time a fresh
|
|
21
|
+
# batch starts (nonce 1).
|
|
22
|
+
# Captured at assign and
|
|
23
|
+
# carried by every gate /
|
|
24
|
+
# advance call so a stale
|
|
25
|
+
# straggler from a drained
|
|
26
|
+
# batch (whose nonce numbers
|
|
27
|
+
# the next batch reuses)
|
|
28
|
+
# is fenced out as a no-op.
|
|
29
|
+
#
|
|
30
|
+
# When `last_completed == next`, the next/last_completed/assigned_at/
|
|
31
|
+
# first_failed keys are garbage-collected by the ADVANCE script and the next
|
|
32
|
+
# assign starts at 1 again — a fresh batch always starts un-poisoned. The
|
|
33
|
+
# `epoch` key is deliberately NOT GC'd (only TTL-expires when fully idle) so
|
|
34
|
+
# the generation keeps incrementing across back-to-back batches.
|
|
35
|
+
module RedisOrderedLocking # rubocop:disable Metrics/ModuleLength
|
|
36
|
+
ASSIGN_SCRIPT = <<~LUA
|
|
37
|
+
local next_key = KEYS[1]
|
|
38
|
+
local last_key = KEYS[2]
|
|
39
|
+
local at_key = KEYS[3]
|
|
40
|
+
local epoch_key = KEYS[4]
|
|
41
|
+
local now = ARGV[1]
|
|
42
|
+
local ttl = tonumber(ARGV[2])
|
|
43
|
+
|
|
44
|
+
local nonce = redis.call('incr', next_key)
|
|
45
|
+
redis.call('expire', next_key, ttl)
|
|
46
|
+
|
|
47
|
+
if redis.call('exists', last_key) == 0 then
|
|
48
|
+
redis.call('set', last_key, 0, 'EX', ttl)
|
|
49
|
+
else
|
|
50
|
+
redis.call('expire', last_key, ttl)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
-- nonce == 1 means `next` was absent (first ever, or GC'd after a full
|
|
54
|
+
-- drain), so this is the start of a fresh batch -> bump the generation.
|
|
55
|
+
-- Later nonces in the same batch read the epoch the nonce-1 caller set.
|
|
56
|
+
local epoch
|
|
57
|
+
if nonce == 1 then
|
|
58
|
+
epoch = redis.call('incr', epoch_key)
|
|
59
|
+
else
|
|
60
|
+
epoch = tonumber(redis.call('get', epoch_key) or '1')
|
|
61
|
+
end
|
|
62
|
+
redis.call('expire', epoch_key, ttl)
|
|
63
|
+
|
|
64
|
+
redis.call('hset', at_key, nonce, now)
|
|
65
|
+
redis.call('expire', at_key, ttl)
|
|
66
|
+
return {nonce, epoch}
|
|
67
|
+
LUA
|
|
68
|
+
|
|
69
|
+
CAN_PROCEED_SCRIPT = <<~LUA
|
|
70
|
+
local next_key = KEYS[1]
|
|
71
|
+
local last_key = KEYS[2]
|
|
72
|
+
local at_key = KEYS[3]
|
|
73
|
+
local fail_key = KEYS[4]
|
|
74
|
+
local epoch_key = KEYS[5]
|
|
75
|
+
local my = tonumber(ARGV[1])
|
|
76
|
+
local now = tonumber(ARGV[2])
|
|
77
|
+
local pp = tonumber(ARGV[3])
|
|
78
|
+
local my_epoch = tonumber(ARGV[4] or '0')
|
|
79
|
+
|
|
80
|
+
local last = tonumber(redis.call('get', last_key) or '0')
|
|
81
|
+
local first_failed = tonumber(redis.call('get', fail_key) or '0')
|
|
82
|
+
|
|
83
|
+
-- Stale-batch fence: a caller carrying an epoch from a drained batch
|
|
84
|
+
-- (whose nonce numbers the current batch reused) must not gate against
|
|
85
|
+
-- or poison-advance this batch. my_epoch == 0 is a legacy/no-epoch
|
|
86
|
+
-- caller (e.g. an in-flight job from before this field existed) — skip.
|
|
87
|
+
local cur_epoch = tonumber(redis.call('get', epoch_key) or '0')
|
|
88
|
+
if my_epoch > 0 and my_epoch ~= cur_epoch then
|
|
89
|
+
return {'stale', 0, last, first_failed}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
-- Drained-batch fence: both counters absent means this caller's batch
|
|
93
|
+
-- fully drained and was GC'd (or wholly TTL-expired) while it slept —
|
|
94
|
+
-- e.g. a poison-passed straggler waking between the drain and the next
|
|
95
|
+
-- batch's first assign (epoch not yet bumped, so the stale fence can't
|
|
96
|
+
-- catch it). It may run late (poison semantics) but must NOT enter the
|
|
97
|
+
-- poison loop below: SET on the missing last_key would resurrect the
|
|
98
|
+
-- cursor with no TTL and let every nonce of the NEXT batch gate
|
|
99
|
+
-- straight through. Only both-absent is conclusive — a mid-batch
|
|
100
|
+
-- next_key TTL hiccup leaves last_key in place and proceeds normally.
|
|
101
|
+
-- Returns a DISTINCT 'drained_go' (not plain 'go') so the executor can
|
|
102
|
+
-- tell this apart: a genuine late straggler should run, but a Sidekiq
|
|
103
|
+
-- at-least-once redelivery of an already-terminal context must NOT
|
|
104
|
+
-- re-execute. The executor consults the stored context status to decide.
|
|
105
|
+
if redis.call('exists', next_key) == 0 and redis.call('exists', last_key) == 0 then
|
|
106
|
+
return {'drained_go', 0, last, first_failed}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
-- Liveness heartbeat: a gate check proves this caller is alive (about
|
|
110
|
+
-- to run, or snoozing on lock/rate contention and re-checking), so
|
|
111
|
+
-- restamp its own assigned_at. assigned_at is otherwise set once at
|
|
112
|
+
-- ENQUEUE, meaning a job that merely sat in a deep queue longer than
|
|
113
|
+
-- poison_pill_timeout would be poison-passed the moment a successor
|
|
114
|
+
-- gates — this restamp gives it a full pp window from the time it
|
|
115
|
+
-- actually starts. hexists guard: never resurrect an entry that an
|
|
116
|
+
-- out-of-order terminal advance already deleted.
|
|
117
|
+
if my > last and redis.call('hexists', at_key, my) == 1 then
|
|
118
|
+
redis.call('hset', at_key, my, now)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if my <= last then
|
|
122
|
+
return {'go', 0, last, first_failed}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if my == last + 1 then
|
|
126
|
+
return {'go', 0, last, first_failed}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
-- Drain consecutive poisoned blockers in one shot. Without this loop a
|
|
130
|
+
-- cluster of N dead blockers takes N snooze rounds to clear (one round
|
|
131
|
+
-- per blocker); with it, a single can_proceed call sweeps them all.
|
|
132
|
+
-- Bounded by `my` so the loop runs at most O(stream length) per call.
|
|
133
|
+
local advanced_via_poison = false
|
|
134
|
+
while last + 1 < my do
|
|
135
|
+
local blocker = last + 1
|
|
136
|
+
local at = tonumber(redis.call('hget', at_key, blocker) or '0')
|
|
137
|
+
-- Only a blocker with a recent assigned_at timestamp is genuinely in
|
|
138
|
+
-- flight; stop draining there. `at == 0` means the timer is gone (an
|
|
139
|
+
-- out-of-order advance deleted it, or the assigned_at hash expired),
|
|
140
|
+
-- so the blocker can never make progress on its own — advance past it
|
|
141
|
+
-- rather than stalling forever (the original `break` here was a
|
|
142
|
+
-- permanent head-of-line hang, the exact thing poison-pill prevents).
|
|
143
|
+
if at > 0 and (now - at) <= pp then
|
|
144
|
+
break
|
|
145
|
+
end
|
|
146
|
+
redis.call('set', last_key, blocker, 'KEEPTTL')
|
|
147
|
+
redis.call('hdel', at_key, blocker)
|
|
148
|
+
last = blocker
|
|
149
|
+
advanced_via_poison = true
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
if my <= last then
|
|
153
|
+
return {advanced_via_poison and 'poison_advance' or 'go', 0, last, first_failed}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if my == last + 1 then
|
|
157
|
+
return {advanced_via_poison and 'poison_advance' or 'go', 0, last, first_failed}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
local blocker = last + 1
|
|
161
|
+
local blocker_assigned = tonumber(redis.call('hget', at_key, blocker) or '0')
|
|
162
|
+
local hint
|
|
163
|
+
if blocker_assigned > 0 then
|
|
164
|
+
hint = pp - (now - blocker_assigned)
|
|
165
|
+
else
|
|
166
|
+
hint = pp
|
|
167
|
+
end
|
|
168
|
+
if hint < 1 then hint = 1 end
|
|
169
|
+
return {'wait', hint, last, first_failed}
|
|
170
|
+
LUA
|
|
171
|
+
|
|
172
|
+
ADVANCE_SCRIPT = <<~LUA
|
|
173
|
+
local next_key = KEYS[1]
|
|
174
|
+
local last_key = KEYS[2]
|
|
175
|
+
local at_key = KEYS[3]
|
|
176
|
+
local fail_key = KEYS[4]
|
|
177
|
+
local epoch_key = KEYS[5]
|
|
178
|
+
local my = tonumber(ARGV[1])
|
|
179
|
+
local failed = tonumber(ARGV[2]) == 1
|
|
180
|
+
local ttl = tonumber(ARGV[3])
|
|
181
|
+
local my_epoch = tonumber(ARGV[4] or '0')
|
|
182
|
+
|
|
183
|
+
-- Stale-batch fence: an advance carrying an epoch from a drained batch
|
|
184
|
+
-- whose nonce numbers were reused must not mutate the current batch's
|
|
185
|
+
-- counters. This is the core protection against a slow straggler from a
|
|
186
|
+
-- prior batch corrupting a later one. my_epoch == 0 = legacy caller.
|
|
187
|
+
local cur_epoch = tonumber(redis.call('get', epoch_key) or '0')
|
|
188
|
+
if my_epoch > 0 and my_epoch ~= cur_epoch then
|
|
189
|
+
return tonumber(redis.call('get', last_key) or '0')
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
-- Drained-batch fence (mirrors CAN_PROCEED): a late terminal from a
|
|
193
|
+
-- batch that already drained and GC'd must be a complete no-op. Without
|
|
194
|
+
-- it, an in-order straggler advance (my == 0 + 1) would SET last_key
|
|
195
|
+
-- with KEEPTTL on a missing key — resurrecting a TTL-less cursor that
|
|
196
|
+
-- un-gates every nonce of the next batch — and a failed straggler
|
|
197
|
+
-- would write fail_key, strict-poisoning a batch that hasn't started.
|
|
198
|
+
if redis.call('exists', next_key) == 0 and redis.call('exists', last_key) == 0 then
|
|
199
|
+
return 0
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
local last = tonumber(redis.call('get', last_key) or '0')
|
|
203
|
+
|
|
204
|
+
-- Record the chain poison marker for ANY terminal failure ahead of the
|
|
205
|
+
-- cursor (my > last), not just the in-order successor; strict-mode
|
|
206
|
+
-- chain-skip relies on the SMALLEST failed nonce being recorded. A
|
|
207
|
+
-- failure at or behind the cursor (my <= last) is deliberately NOT
|
|
208
|
+
-- recorded: that guard is what keeps a Sidekiq duplicate redelivery of
|
|
209
|
+
-- an already-terminated failure from re-poisoning a chain that moved
|
|
210
|
+
-- on. Cost of the trade-off: a run the poison-advance already passed
|
|
211
|
+
-- (cursor moved beyond it) that later fails does NOT poison the chain
|
|
212
|
+
-- — by then ordering was already ceded for that nonce and successors
|
|
213
|
+
-- may have run.
|
|
214
|
+
if failed and my > last then
|
|
215
|
+
local existing = tonumber(redis.call('get', fail_key) or '0')
|
|
216
|
+
if existing == 0 or my < existing then
|
|
217
|
+
redis.call('set', fail_key, my, 'EX', ttl)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if my == last + 1 then
|
|
222
|
+
redis.call('set', last_key, my, 'KEEPTTL')
|
|
223
|
+
redis.call('hdel', at_key, my)
|
|
224
|
+
last = my
|
|
225
|
+
|
|
226
|
+
local nxt = tonumber(redis.call('get', next_key) or '0')
|
|
227
|
+
-- Guard with `nxt > 0` (mirroring SKIP_SCRIPT): a missing/expired
|
|
228
|
+
-- next_key reads as 0 and `last >= 0` would otherwise GC live
|
|
229
|
+
-- counters mid-sequence, resetting numbering and dropping the marker.
|
|
230
|
+
if last >= nxt and nxt > 0 then
|
|
231
|
+
redis.call('del', next_key)
|
|
232
|
+
redis.call('del', last_key)
|
|
233
|
+
redis.call('del', at_key)
|
|
234
|
+
redis.call('del', fail_key)
|
|
235
|
+
end
|
|
236
|
+
return last
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
redis.call('hdel', at_key, my)
|
|
240
|
+
return last
|
|
241
|
+
LUA
|
|
242
|
+
|
|
243
|
+
# Liveness restamp for a nonce that is actively executing its steps. The
|
|
244
|
+
# CAN_PROCEED heartbeat only fires when a job runs its gate; a blocker that
|
|
245
|
+
# passed the gate and is now running long steps never re-gates, so without
|
|
246
|
+
# this a successor would poison-advance past a still-running blocker once
|
|
247
|
+
# its steps outlast poison_pill_timeout — a silent ordering violation. A
|
|
248
|
+
# background thread calls this every pp/3 seconds while steps run.
|
|
249
|
+
#
|
|
250
|
+
# Guards: epoch-fenced (a stale-batch straggler must not touch the current
|
|
251
|
+
# batch) and hexists-guarded (never resurrect a timer a terminal advance
|
|
252
|
+
# already deleted — if we were already poison-passed, stay passed).
|
|
253
|
+
HEARTBEAT_SCRIPT = <<~LUA
|
|
254
|
+
local at_key = KEYS[1]
|
|
255
|
+
local epoch_key = KEYS[2]
|
|
256
|
+
local my = ARGV[1]
|
|
257
|
+
local now = ARGV[2]
|
|
258
|
+
local my_epoch = tonumber(ARGV[3] or '0')
|
|
259
|
+
|
|
260
|
+
local cur_epoch = tonumber(redis.call('get', epoch_key) or '0')
|
|
261
|
+
if my_epoch > 0 and my_epoch ~= cur_epoch then
|
|
262
|
+
return 0
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
if redis.call('hexists', at_key, my) == 1 then
|
|
266
|
+
redis.call('hset', at_key, my, now)
|
|
267
|
+
return 1
|
|
268
|
+
end
|
|
269
|
+
return 0
|
|
270
|
+
LUA
|
|
271
|
+
|
|
272
|
+
SKIP_SCRIPT = <<~LUA
|
|
273
|
+
local next_key = KEYS[1]
|
|
274
|
+
local last_key = KEYS[2]
|
|
275
|
+
local at_key = KEYS[3]
|
|
276
|
+
local fail_key = KEYS[4]
|
|
277
|
+
local my = tonumber(ARGV[1])
|
|
278
|
+
|
|
279
|
+
-- Drained-batch fence (mirrors CAN_PROCEED/ADVANCE): an ops `skip!` of a
|
|
280
|
+
-- nonce whose batch already drained must not SET last_key on a missing
|
|
281
|
+
-- key — KEEPTTL on an absent key would create a TTL-less cursor and
|
|
282
|
+
-- un-gate the next batch. Nothing to skip in a drained batch anyway.
|
|
283
|
+
if redis.call('exists', next_key) == 0 and redis.call('exists', last_key) == 0 then
|
|
284
|
+
return 0
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
local last = tonumber(redis.call('get', last_key) or '0')
|
|
288
|
+
|
|
289
|
+
if my > last then
|
|
290
|
+
-- KEEPTTL: forcing the cursor forward must not strip the sequence TTL
|
|
291
|
+
-- and leave a persistent key behind for a sequence that never drains.
|
|
292
|
+
redis.call('set', last_key, my, 'KEEPTTL')
|
|
293
|
+
end
|
|
294
|
+
redis.call('hdel', at_key, my)
|
|
295
|
+
|
|
296
|
+
last = tonumber(redis.call('get', last_key) or '0')
|
|
297
|
+
local nxt = tonumber(redis.call('get', next_key) or '0')
|
|
298
|
+
if last >= nxt and nxt > 0 then
|
|
299
|
+
redis.call('del', next_key)
|
|
300
|
+
redis.call('del', last_key)
|
|
301
|
+
redis.call('del', at_key)
|
|
302
|
+
redis.call('del', fail_key)
|
|
303
|
+
end
|
|
304
|
+
return last
|
|
305
|
+
LUA
|
|
306
|
+
|
|
307
|
+
# Returns `[nonce, epoch]`: the assigned nonce plus the generation it
|
|
308
|
+
# belongs to. The caller stashes both so later gate/advance calls can be
|
|
309
|
+
# fenced if the batch drains and its numbers get reused.
|
|
310
|
+
def ordered_lock_assign(key, ttl: 86_400, now: Time.now.to_i)
|
|
311
|
+
next_k, last_k, at_k, _fail_k, epoch_k = ordered_lock_keys(key)
|
|
312
|
+
nonce, epoch = @redis.eval(
|
|
313
|
+
ASSIGN_SCRIPT, keys: [next_k, last_k, at_k, epoch_k], argv: [now.to_s, ttl]
|
|
314
|
+
)
|
|
315
|
+
[nonce.to_i, epoch.to_i]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def ordered_lock_can_proceed(key, nonce:, poison_pill_timeout:, epoch: 0, now: Time.now.to_i)
|
|
319
|
+
state, retry_after, last_completed, first_failed = @redis.eval(
|
|
320
|
+
CAN_PROCEED_SCRIPT,
|
|
321
|
+
keys: ordered_lock_keys(key),
|
|
322
|
+
argv: [nonce.to_i, now.to_i, poison_pill_timeout.to_i, epoch.to_i]
|
|
323
|
+
)
|
|
324
|
+
[state.to_s, retry_after.to_i, last_completed.to_i, first_failed.to_i]
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def ordered_lock_advance(key, nonce:, failed: false, epoch: 0, ttl: 86_400)
|
|
328
|
+
@redis.eval(
|
|
329
|
+
ADVANCE_SCRIPT,
|
|
330
|
+
keys: ordered_lock_keys(key),
|
|
331
|
+
argv: [nonce.to_i, failed ? 1 : 0, ttl.to_i, epoch.to_i]
|
|
332
|
+
).to_i
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def ordered_lock_skip(key, nonce:)
|
|
336
|
+
@redis.eval(SKIP_SCRIPT, keys: ordered_lock_keys(key), argv: [nonce.to_i]).to_i
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Restamp a running nonce's assigned_at to keep it from being poison-passed
|
|
340
|
+
# while its steps execute. Returns 1 if restamped, 0 if fenced (stale
|
|
341
|
+
# epoch) or the timer was already gone. `now` is injectable for testing.
|
|
342
|
+
def ordered_lock_heartbeat(key, nonce:, epoch: 0, now: Time.now.to_i)
|
|
343
|
+
_next_k, _last_k, at_k, _fail_k, epoch_k = ordered_lock_keys(key)
|
|
344
|
+
@redis.eval(
|
|
345
|
+
HEARTBEAT_SCRIPT, keys: [at_k, epoch_k], argv: [nonce.to_i, now.to_i, epoch.to_i]
|
|
346
|
+
).to_i
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def ordered_lock_reset(key)
|
|
350
|
+
@redis.del(*ordered_lock_keys(key))
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def ordered_lock_peek(key)
|
|
354
|
+
next_k, last_k, at_k, fail_k = ordered_lock_keys(key)
|
|
355
|
+
next_v = @redis.get(next_k)
|
|
356
|
+
last_v = @redis.get(last_k)
|
|
357
|
+
fail_v = @redis.get(fail_k)
|
|
358
|
+
in_flight = @redis.hkeys(at_k).map(&:to_i).sort
|
|
359
|
+
{
|
|
360
|
+
next: next_v ? next_v.to_i : 0,
|
|
361
|
+
last_completed: last_v ? last_v.to_i : 0,
|
|
362
|
+
in_flight: in_flight,
|
|
363
|
+
first_failed: fail_v ? fail_v.to_i : 0
|
|
364
|
+
}
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Order matters: callers destructure positionally and some scripts take a
|
|
368
|
+
# subset. `epoch` is appended last so existing 4-key destructures keep
|
|
369
|
+
# working unchanged.
|
|
370
|
+
def ordered_lock_keys(key)
|
|
371
|
+
tag = "{#{key}}"
|
|
372
|
+
[
|
|
373
|
+
"ordered_lock:#{tag}:next",
|
|
374
|
+
"ordered_lock:#{tag}:last_completed",
|
|
375
|
+
"ordered_lock:#{tag}:assigned_at",
|
|
376
|
+
"ordered_lock:#{tag}:first_failed",
|
|
377
|
+
"ordered_lock:#{tag}:epoch"
|
|
378
|
+
]
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
@@ -19,7 +19,10 @@ module RubyReactor
|
|
|
19
19
|
|
|
20
20
|
def failure(errors)
|
|
21
21
|
error = RubyReactor::Error::InputValidationError.new(errors)
|
|
22
|
-
|
|
22
|
+
# Carry the structured field errors on the Failure itself so every
|
|
23
|
+
# validation failure shape (reactor inputs via `run`, step args, step
|
|
24
|
+
# output) exposes `validation_errors` uniformly.
|
|
25
|
+
RubyReactor.Failure(error, validation_errors: errors)
|
|
23
26
|
end
|
|
24
27
|
end
|
|
25
28
|
end
|
|
@@ -5,14 +5,16 @@ require "dry-validation"
|
|
|
5
5
|
module RubyReactor
|
|
6
6
|
module Validation
|
|
7
7
|
class InputValidator < Base
|
|
8
|
-
attr_reader :schema
|
|
8
|
+
attr_reader :schema, :wrap_key
|
|
9
9
|
|
|
10
|
-
def initialize(schema)
|
|
10
|
+
def initialize(schema, wrap_key: nil)
|
|
11
11
|
super()
|
|
12
12
|
@schema = schema
|
|
13
|
+
@wrap_key = wrap_key
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def call(data)
|
|
17
|
+
data = { @wrap_key => data } if @wrap_key
|
|
16
18
|
result = schema.call(data)
|
|
17
19
|
|
|
18
20
|
if result.success?
|
|
@@ -12,6 +12,88 @@ module RubyReactor
|
|
|
12
12
|
def self.build_contract_from_block(&block)
|
|
13
13
|
Class.new(Dry::Validation::Contract, &block).new
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
# Form 1 / 1b — a single named value with an optional type and predicates.
|
|
17
|
+
#
|
|
18
|
+
# build_inline(:age, :integer, false, { gteq?: 18 })
|
|
19
|
+
# => required(:age).filled(:integer, gteq?: 18)
|
|
20
|
+
# build_inline(:user, User, false, {})
|
|
21
|
+
# => required(:user).filled(type?: User)
|
|
22
|
+
# build_inline(:bio, :string, true, { max_size?: 100 })
|
|
23
|
+
# => optional(:bio).maybe(:string, max_size?: 100)
|
|
24
|
+
def self.build_inline(name, type, optional, predicates)
|
|
25
|
+
rules = [[name, type, optional, predicates]]
|
|
26
|
+
Dry::Schema.Params do
|
|
27
|
+
SchemaBuilder.apply_inline_rules(self, rules)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Form 2 — the block receives the macro already bound to the name and
|
|
32
|
+
# optionality (`required(name)` / `optional(name)`). Because it is invoked
|
|
33
|
+
# as a block argument (not via instance_eval) the caller's `self` and
|
|
34
|
+
# lexical scope (class constants, helper methods) stay reachable.
|
|
35
|
+
def self.build_macro(name, optional, &block)
|
|
36
|
+
macro_method = optional ? :optional : :required
|
|
37
|
+
Dry::Schema.Params do
|
|
38
|
+
block.call(public_send(macro_method, name))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Compose per-argument inline rules with an optional `validate_args`
|
|
43
|
+
# input (block or pre-built schema). Block/inline rules are applied first;
|
|
44
|
+
# a pre-built schema is merged last so its rules win.
|
|
45
|
+
def self.build_args(inline_rules, validate_input)
|
|
46
|
+
block = validate_input.is_a?(Proc) ? validate_input : nil
|
|
47
|
+
prebuilt = !validate_input.nil? && block.nil? ? schema_for(validate_input) : nil
|
|
48
|
+
|
|
49
|
+
composed = build_args_base(inline_rules, block)
|
|
50
|
+
|
|
51
|
+
if prebuilt && composed
|
|
52
|
+
composed.merge(prebuilt)
|
|
53
|
+
elsif prebuilt
|
|
54
|
+
prebuilt
|
|
55
|
+
else
|
|
56
|
+
composed
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.build_args_base(inline_rules, block)
|
|
61
|
+
return Dry::Schema.Params(&block) if inline_rules.empty? && block
|
|
62
|
+
return nil if inline_rules.empty?
|
|
63
|
+
|
|
64
|
+
Dry::Schema.Params do
|
|
65
|
+
SchemaBuilder.apply_inline_rules(self, inline_rules)
|
|
66
|
+
instance_exec(&block) if block
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Apply a list of inline rules ([name, type, optional, predicates]) onto a
|
|
71
|
+
# dry-schema DSL context.
|
|
72
|
+
def self.apply_inline_rules(dsl, rules)
|
|
73
|
+
rules.each do |name, type, optional, predicates|
|
|
74
|
+
macro_method = optional ? :optional : :required
|
|
75
|
+
rule_method = optional ? :maybe : :filled
|
|
76
|
+
positional = type.is_a?(Symbol) ? [type] : []
|
|
77
|
+
kwargs = type.is_a?(Module) ? { type?: type, **predicates } : predicates
|
|
78
|
+
|
|
79
|
+
macro = dsl.public_send(macro_method, name)
|
|
80
|
+
if kwargs.empty?
|
|
81
|
+
macro.public_send(rule_method, *positional)
|
|
82
|
+
else
|
|
83
|
+
macro.public_send(rule_method, *positional, **kwargs)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Unwrap an InputValidator into its underlying dry-schema; pass dry-schemas
|
|
89
|
+
# through unchanged.
|
|
90
|
+
def self.schema_for(schema_or_validator)
|
|
91
|
+
if schema_or_validator.is_a?(RubyReactor::Validation::InputValidator)
|
|
92
|
+
schema_or_validator.schema
|
|
93
|
+
else
|
|
94
|
+
schema_or_validator
|
|
95
|
+
end
|
|
96
|
+
end
|
|
15
97
|
end
|
|
16
98
|
end
|
|
17
99
|
end
|
data/lib/ruby_reactor/version.rb
CHANGED
data/lib/ruby_reactor.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "ruby_reactor/registry"
|
|
|
6
6
|
require_relative "ruby_reactor/utils/code_extractor"
|
|
7
7
|
require_relative "ruby_reactor/dsl/lockable" # Add this
|
|
8
8
|
require_relative "ruby_reactor/lock"
|
|
9
|
+
require_relative "ruby_reactor/ordered_lock"
|
|
9
10
|
require_relative "ruby_reactor/semaphore"
|
|
10
11
|
require_relative "ruby_reactor/period"
|
|
11
12
|
require_relative "ruby_reactor/rate_limit"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_reactor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Artur
|
|
@@ -125,6 +125,7 @@ files:
|
|
|
125
125
|
- lib/ruby_reactor/executor/compensation_manager.rb
|
|
126
126
|
- lib/ruby_reactor/executor/graph_manager.rb
|
|
127
127
|
- lib/ruby_reactor/executor/input_validator.rb
|
|
128
|
+
- lib/ruby_reactor/executor/ordered_lock_support.rb
|
|
128
129
|
- lib/ruby_reactor/executor/result_handler.rb
|
|
129
130
|
- lib/ruby_reactor/executor/retry_manager.rb
|
|
130
131
|
- lib/ruby_reactor/executor/step_executor.rb
|
|
@@ -139,8 +140,10 @@ files:
|
|
|
139
140
|
- lib/ruby_reactor/middleware.rb
|
|
140
141
|
- lib/ruby_reactor/middleware_runner.rb
|
|
141
142
|
- lib/ruby_reactor/open_telemetry.rb
|
|
143
|
+
- lib/ruby_reactor/ordered_lock.rb
|
|
142
144
|
- lib/ruby_reactor/period.rb
|
|
143
145
|
- lib/ruby_reactor/rate_limit.rb
|
|
146
|
+
- lib/ruby_reactor/rate_limit_registry.rb
|
|
144
147
|
- lib/ruby_reactor/reactor.rb
|
|
145
148
|
- lib/ruby_reactor/registry.rb
|
|
146
149
|
- lib/ruby_reactor/retry_context.rb
|
|
@@ -148,7 +151,9 @@ files:
|
|
|
148
151
|
- lib/ruby_reactor/rspec.rb
|
|
149
152
|
- lib/ruby_reactor/rspec/helpers.rb
|
|
150
153
|
- lib/ruby_reactor/rspec/matchers.rb
|
|
154
|
+
- lib/ruby_reactor/rspec/sidekiq_helpers.rb
|
|
151
155
|
- lib/ruby_reactor/rspec/step_executor_patch.rb
|
|
156
|
+
- lib/ruby_reactor/rspec/storage_reset.rb
|
|
152
157
|
- lib/ruby_reactor/rspec/test_subject.rb
|
|
153
158
|
- lib/ruby_reactor/semaphore.rb
|
|
154
159
|
- lib/ruby_reactor/sidekiq_adapter.rb
|
|
@@ -162,6 +167,7 @@ files:
|
|
|
162
167
|
- lib/ruby_reactor/storage/configuration.rb
|
|
163
168
|
- lib/ruby_reactor/storage/redis_adapter.rb
|
|
164
169
|
- lib/ruby_reactor/storage/redis_locking.rb
|
|
170
|
+
- lib/ruby_reactor/storage/redis_ordered_locking.rb
|
|
165
171
|
- lib/ruby_reactor/template/base.rb
|
|
166
172
|
- lib/ruby_reactor/template/dynamic_source.rb
|
|
167
173
|
- lib/ruby_reactor/template/element.rb
|