ruby_reactor 0.5.1 → 0.5.3
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 +179 -26
- data/lib/ruby_reactor/configuration.rb +66 -2
- data/lib/ruby_reactor/context_serializer.rb +9 -4
- data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
- data/lib/ruby_reactor/dsl/lockable.rb +41 -1
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +7 -2
- data/lib/ruby_reactor/executor/step_executor.rb +25 -5
- data/lib/ruby_reactor/executor.rb +166 -52
- data/lib/ruby_reactor/lock.rb +13 -0
- data/lib/ruby_reactor/map/collector.rb +41 -0
- data/lib/ruby_reactor/map/dispatcher.rb +42 -0
- data/lib/ruby_reactor/map/element_executor.rb +39 -0
- data/lib/ruby_reactor/map/helpers.rb +10 -3
- data/lib/ruby_reactor/map/sweeper.rb +110 -0
- data/lib/ruby_reactor/ordered_lock.rb +158 -0
- data/lib/ruby_reactor/reactor.rb +48 -5
- 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_adapter.rb +9 -8
- data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +73 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +82 -36
- data/lib/ruby_reactor/step/map_step.rb +18 -2
- data/lib/ruby_reactor/storage/redis_adapter.rb +84 -60
- data/lib/ruby_reactor/storage/redis_locking.rb +8 -0
- data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
- data/lib/ruby_reactor/sweeper.rb +58 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +43 -0
- metadata +9 -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
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
# Re-enqueues non-terminal top-level reactor contexts whose worker died.
|
|
5
|
+
#
|
|
6
|
+
# The per-context liveness lock (`async:<id>`, Phase 1) is the signal: a live
|
|
7
|
+
# worker holds and auto-extends it, so its ABSENCE on a context still marked
|
|
8
|
+
# `running` means the worker crashed without finishing. The sweeper re-enqueues
|
|
9
|
+
# such contexts by id (identity-only payload, Phase 2).
|
|
10
|
+
#
|
|
11
|
+
# `run_once` is pure and idempotent — call it periodically; the cadence is the
|
|
12
|
+
# host's to wire (sidekiq-cron, sidekiq-scheduler, a self-rescheduling worker,
|
|
13
|
+
# or external cron). The interval bounds recovery latency. No scheduling
|
|
14
|
+
# dependency is added to the gem.
|
|
15
|
+
#
|
|
16
|
+
# Safety depends on Phase 1: if a context is mis-judged dead (GC pause, liveness
|
|
17
|
+
# race) and re-enqueued while its worker is actually alive, the duplicate hits
|
|
18
|
+
# the live lock -> ContextLockContention -> uncapped snooze -> no double run.
|
|
19
|
+
#
|
|
20
|
+
# Map fan-out (element/collector jobs) is NOT covered here — those contexts
|
|
21
|
+
# carry parent_context_id and scan_reactors filters them out (F6). The map
|
|
22
|
+
# sweeper (Phase 5) owns them.
|
|
23
|
+
class Sweeper
|
|
24
|
+
# Default upper bound on contexts inspected per sweep. scan_reactors caps its
|
|
25
|
+
# result at this count; a host with more in-flight reactors than this should
|
|
26
|
+
# raise it (or sweep more frequently).
|
|
27
|
+
DEFAULT_LIMIT = 1000
|
|
28
|
+
|
|
29
|
+
def self.run_once(limit: DEFAULT_LIMIT)
|
|
30
|
+
new.run_once(limit: limit)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(storage: nil, async_router: nil, logger: nil)
|
|
34
|
+
@storage = storage || RubyReactor.configuration.storage_adapter
|
|
35
|
+
@async_router = async_router || RubyReactor.configuration.async_router
|
|
36
|
+
@logger = logger || RubyReactor.configuration.logger
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Scans stored top-level reactors and re-enqueues the running-but-unlocked
|
|
40
|
+
# ones. Returns the number of contexts re-enqueued.
|
|
41
|
+
def run_once(limit: DEFAULT_LIMIT)
|
|
42
|
+
reenqueued = 0
|
|
43
|
+
|
|
44
|
+
@storage.scan_reactors(count: limit).each do |reactor|
|
|
45
|
+
next unless reactor[:status] == "running" # non-terminal only
|
|
46
|
+
next if @storage.lock_held?("async:#{reactor[:id]}") # worker alive -> leave alone
|
|
47
|
+
|
|
48
|
+
@async_router.perform_async(reactor[:id], reactor[:class])
|
|
49
|
+
reenqueued += 1
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
# One bad record must not abort the whole sweep.
|
|
52
|
+
@logger.warn("RubyReactor::Sweeper failed to re-enqueue #{reactor[:id]}: #{e.class}: #{e.message}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
reenqueued
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/ruby_reactor/version.rb
CHANGED
data/lib/ruby_reactor.rb
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require "zeitwerk"
|
|
4
4
|
require "pathname"
|
|
5
|
+
require "securerandom"
|
|
5
6
|
require_relative "ruby_reactor/registry"
|
|
6
7
|
require_relative "ruby_reactor/utils/code_extractor"
|
|
7
8
|
require_relative "ruby_reactor/dsl/lockable" # Add this
|
|
8
9
|
require_relative "ruby_reactor/lock"
|
|
10
|
+
require_relative "ruby_reactor/ordered_lock"
|
|
9
11
|
require_relative "ruby_reactor/semaphore"
|
|
10
12
|
require_relative "ruby_reactor/period"
|
|
11
13
|
require_relative "ruby_reactor/rate_limit"
|
|
@@ -329,6 +331,47 @@ module RubyReactor
|
|
|
329
331
|
Configuration.instance
|
|
330
332
|
end
|
|
331
333
|
|
|
334
|
+
# The name under which a reactor class's durable state is keyed in storage
|
|
335
|
+
# (`reactor:<name>:context:<id>`, map metadata, etc.). MUST be stable across
|
|
336
|
+
# processes: the enqueuing process writes the blob under this name and a
|
|
337
|
+
# *different* worker process reads it back by the same name. So an anonymous
|
|
338
|
+
# class falls back to a fixed constant, NOT `object_id` — object_id is
|
|
339
|
+
# process-local and would make the worker's read key miss the writer's key.
|
|
340
|
+
# The context_id in the key still disambiguates distinct anonymous reactors.
|
|
341
|
+
# (A truly anonymous class can't be reconstituted by name in another process,
|
|
342
|
+
# so cross-process resume of one is inherently unsupported; this only keeps
|
|
343
|
+
# the keys self-consistent within a process — e.g. inline tests.)
|
|
344
|
+
def self.reactor_storage_name(reactor_class)
|
|
345
|
+
return "AnonymousReactor" if reactor_class.nil?
|
|
346
|
+
|
|
347
|
+
reactor_class.name || "AnonymousReactor"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Kick the self-rescheduling recovery sweeper chain. Call once per cluster —
|
|
351
|
+
# typically from an initializer (`RubyReactor.start_sweeper!`). Idempotent:
|
|
352
|
+
# calling it on every process boot is safe because the worker claims each tick
|
|
353
|
+
# by time-window, so duplicate kicks collapse to a single chain. No-op when
|
|
354
|
+
# `config.sweeper_enabled` is false. Returns the scheduled job id, or nil when
|
|
355
|
+
# disabled or when this window's tick was already claimed by another caller.
|
|
356
|
+
def self.start_sweeper!
|
|
357
|
+
return unless configuration.sweeper_enabled
|
|
358
|
+
|
|
359
|
+
SidekiqWorkers::SweeperWorker.schedule_next
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Run both recovery sweepers exactly once and return their counts. The
|
|
363
|
+
# synchronous escape hatch for hosts that schedule recovery with their own
|
|
364
|
+
# cron / k8s CronJob instead of the in-cluster chain (set
|
|
365
|
+
# `config.sweeper_enabled = false` and call this from `rake ruby_reactor:sweep`
|
|
366
|
+
# or a binstub).
|
|
367
|
+
def self.sweep_once(limit: nil)
|
|
368
|
+
limit ||= configuration.sweeper_limit
|
|
369
|
+
{
|
|
370
|
+
reactors: Sweeper.run_once(limit: limit),
|
|
371
|
+
maps: Map::Sweeper.run_once(limit: limit)
|
|
372
|
+
}
|
|
373
|
+
end
|
|
374
|
+
|
|
332
375
|
def self.root
|
|
333
376
|
Pathname.new(File.expand_path("..", __dir__))
|
|
334
377
|
end
|
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.3
|
|
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
|
|
@@ -135,10 +136,12 @@ files:
|
|
|
135
136
|
- lib/ruby_reactor/map/element_executor.rb
|
|
136
137
|
- lib/ruby_reactor/map/helpers.rb
|
|
137
138
|
- lib/ruby_reactor/map/result_enumerator.rb
|
|
139
|
+
- lib/ruby_reactor/map/sweeper.rb
|
|
138
140
|
- lib/ruby_reactor/max_retries_exhausted_failure.rb
|
|
139
141
|
- lib/ruby_reactor/middleware.rb
|
|
140
142
|
- lib/ruby_reactor/middleware_runner.rb
|
|
141
143
|
- lib/ruby_reactor/open_telemetry.rb
|
|
144
|
+
- lib/ruby_reactor/ordered_lock.rb
|
|
142
145
|
- lib/ruby_reactor/period.rb
|
|
143
146
|
- lib/ruby_reactor/rate_limit.rb
|
|
144
147
|
- lib/ruby_reactor/rate_limit_registry.rb
|
|
@@ -149,12 +152,15 @@ files:
|
|
|
149
152
|
- lib/ruby_reactor/rspec.rb
|
|
150
153
|
- lib/ruby_reactor/rspec/helpers.rb
|
|
151
154
|
- lib/ruby_reactor/rspec/matchers.rb
|
|
155
|
+
- lib/ruby_reactor/rspec/sidekiq_helpers.rb
|
|
152
156
|
- lib/ruby_reactor/rspec/step_executor_patch.rb
|
|
157
|
+
- lib/ruby_reactor/rspec/storage_reset.rb
|
|
153
158
|
- lib/ruby_reactor/rspec/test_subject.rb
|
|
154
159
|
- lib/ruby_reactor/semaphore.rb
|
|
155
160
|
- lib/ruby_reactor/sidekiq_adapter.rb
|
|
156
161
|
- lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb
|
|
157
162
|
- lib/ruby_reactor/sidekiq_workers/map_element_worker.rb
|
|
163
|
+
- lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb
|
|
158
164
|
- lib/ruby_reactor/sidekiq_workers/worker.rb
|
|
159
165
|
- lib/ruby_reactor/step.rb
|
|
160
166
|
- lib/ruby_reactor/step/compose_step.rb
|
|
@@ -163,6 +169,8 @@ files:
|
|
|
163
169
|
- lib/ruby_reactor/storage/configuration.rb
|
|
164
170
|
- lib/ruby_reactor/storage/redis_adapter.rb
|
|
165
171
|
- lib/ruby_reactor/storage/redis_locking.rb
|
|
172
|
+
- lib/ruby_reactor/storage/redis_ordered_locking.rb
|
|
173
|
+
- lib/ruby_reactor/sweeper.rb
|
|
166
174
|
- lib/ruby_reactor/template/base.rb
|
|
167
175
|
- lib/ruby_reactor/template/dynamic_source.rb
|
|
168
176
|
- lib/ruby_reactor/template/element.rb
|