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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +14 -0
  4. data/README.md +179 -26
  5. data/lib/ruby_reactor/configuration.rb +66 -2
  6. data/lib/ruby_reactor/context_serializer.rb +9 -4
  7. data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
  8. data/lib/ruby_reactor/dsl/lockable.rb +41 -1
  9. data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
  10. data/lib/ruby_reactor/executor/retry_manager.rb +7 -2
  11. data/lib/ruby_reactor/executor/step_executor.rb +25 -5
  12. data/lib/ruby_reactor/executor.rb +166 -52
  13. data/lib/ruby_reactor/lock.rb +13 -0
  14. data/lib/ruby_reactor/map/collector.rb +41 -0
  15. data/lib/ruby_reactor/map/dispatcher.rb +42 -0
  16. data/lib/ruby_reactor/map/element_executor.rb +39 -0
  17. data/lib/ruby_reactor/map/helpers.rb +10 -3
  18. data/lib/ruby_reactor/map/sweeper.rb +110 -0
  19. data/lib/ruby_reactor/ordered_lock.rb +158 -0
  20. data/lib/ruby_reactor/reactor.rb +48 -5
  21. data/lib/ruby_reactor/rspec/helpers.rb +6 -0
  22. data/lib/ruby_reactor/rspec/matchers.rb +66 -0
  23. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
  24. data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
  25. data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
  26. data/lib/ruby_reactor/rspec.rb +37 -0
  27. data/lib/ruby_reactor/sidekiq_adapter.rb +9 -8
  28. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +73 -0
  29. data/lib/ruby_reactor/sidekiq_workers/worker.rb +82 -36
  30. data/lib/ruby_reactor/step/map_step.rb +18 -2
  31. data/lib/ruby_reactor/storage/redis_adapter.rb +84 -60
  32. data/lib/ruby_reactor/storage/redis_locking.rb +8 -0
  33. data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
  34. data/lib/ruby_reactor/sweeper.rb +58 -0
  35. data/lib/ruby_reactor/version.rb +1 -1
  36. data/lib/ruby_reactor.rb +43 -0
  37. 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.5.1"
4
+ VERSION = "0.5.3"
5
5
  end
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.1
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