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
|
@@ -7,10 +7,13 @@ require_relative "executor/retry_manager"
|
|
|
7
7
|
require_relative "executor/compensation_manager"
|
|
8
8
|
require_relative "executor/result_handler"
|
|
9
9
|
require_relative "executor/step_executor"
|
|
10
|
+
require_relative "executor/ordered_lock_support"
|
|
10
11
|
|
|
11
12
|
module RubyReactor
|
|
12
13
|
# rubocop:disable Metrics/ClassLength
|
|
13
14
|
class Executor
|
|
15
|
+
include OrderedLockSupport
|
|
16
|
+
|
|
14
17
|
attr_reader :reactor_class, :context, :dependency_graph, :compensation_manager, :retry_manager, :result_handler,
|
|
15
18
|
:step_executor, :result, :middlewares
|
|
16
19
|
|
|
@@ -35,12 +38,24 @@ module RubyReactor
|
|
|
35
38
|
retry_manager: @retry_manager,
|
|
36
39
|
result_handler: @result_handler,
|
|
37
40
|
compensation_manager: @compensation_manager,
|
|
38
|
-
middlewares: @middlewares
|
|
41
|
+
middlewares: @middlewares,
|
|
42
|
+
# Save-per-step durable checkpoint. checkpoint! resolves the ROOT
|
|
43
|
+
# context, so this same callback — wired into every executor including
|
|
44
|
+
# the nested ones ComposeStep builds — always advances the root blob
|
|
45
|
+
# (F8): a mid-child crash re-runs one sub-step, not the whole child.
|
|
46
|
+
# `throttle: true` lets checkpoint_min_interval coalesce these mid-run
|
|
47
|
+
# writes (default 0 = write every step); the terminal save still runs.
|
|
48
|
+
on_step_complete: -> { checkpoint!(throttle: true) }
|
|
39
49
|
}
|
|
40
50
|
)
|
|
41
51
|
@result = nil
|
|
42
52
|
@acquired_lock = nil
|
|
43
53
|
@acquired_semaphore = nil
|
|
54
|
+
@acquired_context_lock = nil
|
|
55
|
+
@context_lock_owner = nil
|
|
56
|
+
@contention_snooze = false
|
|
57
|
+
@skip_context_persist = false
|
|
58
|
+
@last_checkpoint_at = nil
|
|
44
59
|
end
|
|
45
60
|
|
|
46
61
|
def self.resolve_middlewares(reactor_class)
|
|
@@ -71,9 +86,13 @@ module RubyReactor
|
|
|
71
86
|
middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
|
|
72
87
|
completed = false
|
|
73
88
|
|
|
74
|
-
|
|
89
|
+
enter_ordered_lock_scope
|
|
90
|
+
# short_circuit_result covers both the strict ordered-lock chain skip
|
|
91
|
+
# and the already-marked period bucket.
|
|
92
|
+
short = short_circuit_result
|
|
93
|
+
if short
|
|
75
94
|
completed = true
|
|
76
|
-
return
|
|
95
|
+
return short_circuit!(short)
|
|
77
96
|
end
|
|
78
97
|
|
|
79
98
|
# Validate inputs BEFORE consuming a rate-limit slot or grabbing a
|
|
@@ -109,7 +128,9 @@ module RubyReactor
|
|
|
109
128
|
rescue RubyReactor::Lock::AcquisitionError,
|
|
110
129
|
RubyReactor::Semaphore::AcquisitionError,
|
|
111
130
|
RubyReactor::RateLimit::ExceededError,
|
|
112
|
-
RubyReactor::RateLimitRegistry::UnknownLimitError
|
|
131
|
+
RubyReactor::RateLimitRegistry::UnknownLimitError,
|
|
132
|
+
RubyReactor::OrderedLock::WaitError => e
|
|
133
|
+
@contention_snooze = true
|
|
113
134
|
raise e
|
|
114
135
|
rescue StandardError => e
|
|
115
136
|
@result = @result_handler.handle_execution_error(e)
|
|
@@ -118,77 +139,107 @@ module RubyReactor
|
|
|
118
139
|
@result
|
|
119
140
|
ensure
|
|
120
141
|
release_locks
|
|
121
|
-
|
|
142
|
+
leave_ordered_lock_scope
|
|
143
|
+
save_context if persist_context? && !skip_context_persist?
|
|
122
144
|
|
|
145
|
+
emit_lifecycle_completion(completed)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Contention errors (lock/semaphore/rate-limit/ordered-lock wait) are
|
|
149
|
+
# expected "try again later" signals, not failures — the worker snoozes
|
|
150
|
+
# and re-runs. Emitting `failed_reactor` for them floods dashboards with
|
|
151
|
+
# phantom failures (one per snooze round), so route them to a distinct
|
|
152
|
+
# `snooze_reactor` event instead.
|
|
153
|
+
def emit_lifecycle_completion(completed)
|
|
123
154
|
if completed
|
|
124
155
|
middlewares.on(:complete_reactor, reactor_class.name, @result, @context)
|
|
156
|
+
elsif @contention_snooze
|
|
157
|
+
middlewares.on(:snooze_reactor, reactor_class.name, $ERROR_INFO, @context)
|
|
125
158
|
else
|
|
126
159
|
middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
|
|
127
160
|
end
|
|
128
161
|
end
|
|
129
162
|
|
|
130
|
-
def resume_execution # rubocop:disable Metrics/MethodLength
|
|
163
|
+
def resume_execution # rubocop:disable Metrics/MethodLength,Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
|
|
131
164
|
middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
|
|
132
165
|
completed = false
|
|
166
|
+
|
|
133
167
|
# A fresh async reactor run reaches the worker through resume_execution
|
|
134
168
|
# (it never calls execute), so the period and rate-limit gates that live
|
|
135
169
|
# in execute must be applied here too. Genuine resumes (a step already ran
|
|
136
170
|
# or we paused mid-flight, so current_step is set) must NOT re-gate: a
|
|
137
171
|
# paused reactor must not throttle or skip itself on the way back in.
|
|
138
172
|
first_run = first_execution?
|
|
139
|
-
begin
|
|
140
|
-
@context.status = :running
|
|
141
173
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
enter_ordered_lock_scope
|
|
175
|
+
# ordered-lock skip applies on any run; the period gate only on a fresh
|
|
176
|
+
# first run (a genuine resume must not skip itself when its own marker
|
|
177
|
+
# eventually lands).
|
|
178
|
+
short = ordered_lock_short_circuit
|
|
179
|
+
short ||= check_period_gate if first_run
|
|
180
|
+
if short
|
|
181
|
+
completed = true
|
|
182
|
+
return short_circuit!(short)
|
|
183
|
+
end
|
|
147
184
|
|
|
148
|
-
|
|
185
|
+
@context.status = :running
|
|
186
|
+
check_rate_limit if first_run
|
|
187
|
+
|
|
188
|
+
# Per-context liveness lock: serializes duplicate deliveries of the same
|
|
189
|
+
# root context (e.g. a sweeper re-enqueue racing a still-live worker) and
|
|
190
|
+
# doubles as the sweeper's "worker alive" signal. Only the ROOT executor
|
|
191
|
+
# holds it — composed/nested children resume inline under the root worker
|
|
192
|
+
# and must not contend on the root's own key.
|
|
193
|
+
acquire_context_lock
|
|
194
|
+
|
|
195
|
+
# Resumes intentionally skip check_rate_limit (a paused run must not
|
|
196
|
+
# block itself on resume), so acquire lock/semaphore directly rather
|
|
197
|
+
# than via acquire_locks.
|
|
198
|
+
acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
|
|
199
|
+
acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
|
|
149
200
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
201
|
+
# Post-lock re-check (see execute) — closes the period race for the
|
|
202
|
+
# first run of a locked async reactor.
|
|
203
|
+
if first_run && (skipped = check_period_gate)
|
|
204
|
+
completed = true
|
|
205
|
+
return finalize_skipped(skipped)
|
|
206
|
+
end
|
|
156
207
|
|
|
157
|
-
|
|
158
|
-
|
|
208
|
+
prepare_for_resume
|
|
209
|
+
save_context
|
|
159
210
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
211
|
+
@result = if @context.current_step
|
|
212
|
+
execute_current_step_and_continue
|
|
213
|
+
else
|
|
214
|
+
execute_remaining_steps
|
|
215
|
+
end
|
|
165
216
|
|
|
166
|
-
|
|
167
|
-
|
|
217
|
+
update_context_status(@result)
|
|
218
|
+
mark_period_on_success(@result)
|
|
168
219
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
220
|
+
handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
|
|
221
|
+
completed = true
|
|
222
|
+
@result
|
|
223
|
+
rescue RubyReactor::Lock::AcquisitionError,
|
|
224
|
+
RubyReactor::Semaphore::AcquisitionError,
|
|
225
|
+
RubyReactor::RateLimit::ExceededError,
|
|
226
|
+
RubyReactor::RateLimitRegistry::UnknownLimitError,
|
|
227
|
+
RubyReactor::OrderedLock::WaitError => e
|
|
228
|
+
@contention_snooze = true
|
|
229
|
+
raise e
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
handle_resume_error(e)
|
|
232
|
+
update_context_status(@result)
|
|
233
|
+
completed = true
|
|
234
|
+
@result
|
|
235
|
+
ensure
|
|
236
|
+
release_locks
|
|
237
|
+
@acquired_context_lock&.release
|
|
238
|
+
@acquired_context_lock = nil
|
|
239
|
+
leave_ordered_lock_scope
|
|
240
|
+
save_context unless skip_context_persist?
|
|
185
241
|
|
|
186
|
-
|
|
187
|
-
middlewares.on(:complete_reactor, reactor_class.name, @result, @context)
|
|
188
|
-
else
|
|
189
|
-
middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
|
|
190
|
-
end
|
|
191
|
-
end
|
|
242
|
+
emit_lifecycle_completion(completed)
|
|
192
243
|
end
|
|
193
244
|
|
|
194
245
|
def undo_all
|
|
@@ -209,13 +260,40 @@ module RubyReactor
|
|
|
209
260
|
|
|
210
261
|
def save_context
|
|
211
262
|
storage = RubyReactor::Configuration.instance.storage_adapter
|
|
212
|
-
reactor_class_name =
|
|
263
|
+
reactor_class_name = RubyReactor.reactor_storage_name(@reactor_class)
|
|
213
264
|
|
|
214
265
|
# Serialize context
|
|
215
266
|
serialized_context = ContextSerializer.serialize(@context)
|
|
216
267
|
storage.store_context(@context.context_id, serialized_context, reactor_class_name)
|
|
217
268
|
end
|
|
218
269
|
|
|
270
|
+
# Durable per-step checkpoint. Unlike save_context (which serializes THIS
|
|
271
|
+
# executor's @context — the observability path, F1), checkpoint! always
|
|
272
|
+
# serializes and stores the ROOT context under the root's key — the unit the
|
|
273
|
+
# async worker rehydrates by id. For a top-level reactor root == @context; for
|
|
274
|
+
# a composed/nested child it stores the root with the child's live state
|
|
275
|
+
# embedded via composed_contexts. TTL is re-stamped on every write (Phase 4).
|
|
276
|
+
def checkpoint!(throttle: false)
|
|
277
|
+
return if throttle && !checkpoint_due?
|
|
278
|
+
|
|
279
|
+
root = @context.root_context || @context
|
|
280
|
+
storage = RubyReactor::Configuration.instance.storage_adapter
|
|
281
|
+
reactor_class_name = RubyReactor.reactor_storage_name(root.reactor_class)
|
|
282
|
+
storage.store_context(root.context_id, ContextSerializer.serialize(root), reactor_class_name)
|
|
283
|
+
@last_checkpoint_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Whether a throttled (per-step) checkpoint is due. With checkpoint_min_interval
|
|
287
|
+
# <= 0 (default) every step checkpoints; otherwise mid-run checkpoints are
|
|
288
|
+
# coalesced to at most one per interval. The first step of a run always writes
|
|
289
|
+
# (@last_checkpoint_at is nil), and the run's terminal save is never throttled.
|
|
290
|
+
def checkpoint_due?
|
|
291
|
+
interval = RubyReactor.configuration.checkpoint_min_interval.to_f
|
|
292
|
+
return true if interval <= 0 || @last_checkpoint_at.nil?
|
|
293
|
+
|
|
294
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_checkpoint_at) >= interval
|
|
295
|
+
end
|
|
296
|
+
|
|
219
297
|
def persist_context?
|
|
220
298
|
@context.status.to_s != "pending" ||
|
|
221
299
|
@context.execution_trace.any? ||
|
|
@@ -308,6 +386,42 @@ module RubyReactor
|
|
|
308
386
|
RubyReactor::Period.key(base, config[:every])
|
|
309
387
|
end
|
|
310
388
|
|
|
389
|
+
# Per-execution liveness lock on the root context id. Owner is a fresh UUID
|
|
390
|
+
# per execution (NOT the context_id): a duplicate delivery of the *same*
|
|
391
|
+
# context from a different worker must be blocked, so reentrancy by id would
|
|
392
|
+
# defeat the guard. Only the root executor acquires — a composed/nested child
|
|
393
|
+
# resumes inline under the root worker and shares the root's lock, so it must
|
|
394
|
+
# not try to re-acquire the same key with a different owner (self-deadlock).
|
|
395
|
+
def acquire_context_lock
|
|
396
|
+
root = @context.root_context || @context
|
|
397
|
+
return unless root.equal?(@context) # only the root executor holds it
|
|
398
|
+
# In Sidekiq::Testing.inline! the retry/snooze `perform_in` re-enters the
|
|
399
|
+
# worker synchronously, nested inside this still-running frame that holds
|
|
400
|
+
# the lock — it would self-contend forever. The lock guards concurrent
|
|
401
|
+
# cross-process delivery, which cannot happen under inline testing, so skip.
|
|
402
|
+
return if inline_testing_mode?
|
|
403
|
+
|
|
404
|
+
lock = RubyReactor::Lock.new(
|
|
405
|
+
"async:#{root.context_id}",
|
|
406
|
+
owner: @context_lock_owner ||= SecureRandom.uuid,
|
|
407
|
+
ttl: RubyReactor.configuration.context_lock_ttl,
|
|
408
|
+
wait: 0, # fail fast -> snooze; never block the worker thread
|
|
409
|
+
auto_extend: true # keep the liveness signal fresh while we run
|
|
410
|
+
)
|
|
411
|
+
lock.acquire
|
|
412
|
+
@acquired_context_lock = lock
|
|
413
|
+
rescue RubyReactor::Lock::AcquisitionError => e
|
|
414
|
+
# We lost the race to a live original holding this context's lock. We did
|
|
415
|
+
# no work, so we must NOT persist on the way out — saving our (older)
|
|
416
|
+
# rehydrated snapshot would clobber the original's newer checkpoint.
|
|
417
|
+
@skip_context_persist = true
|
|
418
|
+
raise RubyReactor::Lock::ContextLockContention.new(e.message, context_lock_key: "async:#{root.context_id}")
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def inline_testing_mode?
|
|
422
|
+
defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
|
|
423
|
+
end
|
|
424
|
+
|
|
311
425
|
def acquire_exclusive_lock
|
|
312
426
|
config = @reactor_class.lock_config
|
|
313
427
|
key = config[:key_proc].call(@context.inputs)
|
data/lib/ruby_reactor/lock.rb
CHANGED
|
@@ -4,6 +4,19 @@ module RubyReactor
|
|
|
4
4
|
class Lock
|
|
5
5
|
class AcquisitionError < StandardError; end
|
|
6
6
|
|
|
7
|
+
# Raised specifically for the per-context liveness lock (`async:<id>`).
|
|
8
|
+
# Carries the bare key so the worker can exempt it from the snooze cap:
|
|
9
|
+
# a duplicate of the *same* execution may legitimately wait arbitrarily
|
|
10
|
+
# long for the live original to finish.
|
|
11
|
+
class ContextLockContention < AcquisitionError
|
|
12
|
+
attr_reader :context_lock_key
|
|
13
|
+
|
|
14
|
+
def initialize(message, context_lock_key:)
|
|
15
|
+
super(message)
|
|
16
|
+
@context_lock_key = context_lock_key
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
7
20
|
# Minimum interval between auto-extend pings; protects very small TTLs.
|
|
8
21
|
MIN_EXTEND_INTERVAL = 1.0
|
|
9
22
|
|
|
@@ -8,6 +8,42 @@ module RubyReactor
|
|
|
8
8
|
def self.perform(arguments)
|
|
9
9
|
arguments = arguments.transform_keys(&:to_sym)
|
|
10
10
|
map_id = arguments[:map_id]
|
|
11
|
+
|
|
12
|
+
# Serialize concurrent collector deliveries for the SAME map (eager queue +
|
|
13
|
+
# counter-zero trigger + sweeper re-trigger could otherwise all resume the
|
|
14
|
+
# parent at once and both write its context). A dedicated map_collect lock
|
|
15
|
+
# is used rather than the parent's own lock so it never conflicts with the
|
|
16
|
+
# context lock the parent's resume_execution acquires for itself.
|
|
17
|
+
lock = acquire_collect_lock(map_id)
|
|
18
|
+
return if lock == :contended
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
perform_collection(arguments)
|
|
22
|
+
ensure
|
|
23
|
+
lock.release if lock.respond_to?(:release)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.acquire_collect_lock(map_id)
|
|
28
|
+
return :inline if inline_testing_mode?
|
|
29
|
+
|
|
30
|
+
lock = RubyReactor::Lock.new(
|
|
31
|
+
"map_collect:#{map_id}",
|
|
32
|
+
owner: SecureRandom.uuid, ttl: RubyReactor.configuration.context_lock_ttl,
|
|
33
|
+
wait: 0, auto_extend: true
|
|
34
|
+
)
|
|
35
|
+
lock.acquire
|
|
36
|
+
lock
|
|
37
|
+
rescue RubyReactor::Lock::AcquisitionError
|
|
38
|
+
:contended
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.inline_testing_mode?
|
|
42
|
+
defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.perform_collection(arguments)
|
|
46
|
+
map_id = arguments[:map_id]
|
|
11
47
|
parent_context_id = arguments[:parent_context_id]
|
|
12
48
|
parent_reactor_class_name = arguments[:parent_reactor_class_name]
|
|
13
49
|
step_name = arguments[:step_name]
|
|
@@ -18,6 +54,11 @@ module RubyReactor
|
|
|
18
54
|
parent_context_data = storage.retrieve_context(parent_context_id, parent_reactor_class_name)
|
|
19
55
|
parent_context = RubyReactor::Context.deserialize_from_retry(parent_context_data)
|
|
20
56
|
|
|
57
|
+
# Idempotency: if the parent already recorded this map step's result, a
|
|
58
|
+
# prior collector already resumed it. Re-resuming would double-execute the
|
|
59
|
+
# steps after the map. Skip.
|
|
60
|
+
return if parent_context.intermediate_results.key?(step_name.to_sym)
|
|
61
|
+
|
|
21
62
|
# Check if all tasks are completed
|
|
22
63
|
metadata = storage.retrieve_map_metadata(map_id, parent_reactor_class_name)
|
|
23
64
|
total_count = metadata ? metadata["count"].to_i : 0
|
|
@@ -104,6 +104,48 @@ module RubyReactor
|
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# Re-dispatch a SPECIFIC index whose result slot is missing (Phase 5c, used
|
|
108
|
+
# by the map sweeper). Index-driven rather than offset-driven: resolve the
|
|
109
|
+
# source from the stored parent context and pick source[index]. Idempotent
|
|
110
|
+
# because store_map_result HSETs by index — a re-run overwrites slot `index`,
|
|
111
|
+
# never duplicates.
|
|
112
|
+
def self.requeue_index(map_meta, index)
|
|
113
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
114
|
+
parent_class_name = map_meta["parent_reactor_class_name"]
|
|
115
|
+
parent_context = load_parent_context_from_storage(map_meta["parent_context_id"], parent_class_name, storage)
|
|
116
|
+
|
|
117
|
+
arguments = {
|
|
118
|
+
map_id: map_meta["map_id"],
|
|
119
|
+
step_name: map_meta["step_name"],
|
|
120
|
+
strict_ordering: map_meta["strict_ordering"],
|
|
121
|
+
parent_context_id: map_meta["parent_context_id"],
|
|
122
|
+
parent_reactor_class_name: parent_class_name,
|
|
123
|
+
fail_fast: map_meta["fail_fast"],
|
|
124
|
+
batch_size: map_meta["batch_size"]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
source = resolve_source(arguments, parent_context)
|
|
128
|
+
element = element_at(source, index)
|
|
129
|
+
|
|
130
|
+
queue_element_job(element, index, {
|
|
131
|
+
map_id: map_meta["map_id"],
|
|
132
|
+
arguments: arguments,
|
|
133
|
+
context: parent_context,
|
|
134
|
+
reactor_class_info: map_meta["reactor_class_info"],
|
|
135
|
+
step_name: map_meta["step_name"]
|
|
136
|
+
})
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.element_at(source, index)
|
|
140
|
+
if source.is_a?(Array)
|
|
141
|
+
source[index]
|
|
142
|
+
elsif source.respond_to?(:offset) && source.respond_to?(:limit)
|
|
143
|
+
source.offset(index).limit(1).to_a.first
|
|
144
|
+
else
|
|
145
|
+
source.drop(index).first
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
107
149
|
def self.queue_element_job(element, index, options)
|
|
108
150
|
arguments = options[:arguments]
|
|
109
151
|
context = options[:context]
|
|
@@ -8,6 +8,45 @@ module RubyReactor
|
|
|
8
8
|
def self.perform(arguments)
|
|
9
9
|
arguments = arguments.transform_keys(&:to_sym)
|
|
10
10
|
|
|
11
|
+
# Per-element liveness lock (Phase 5b): its presence is the map sweeper's
|
|
12
|
+
# "element alive" signal, and it serializes duplicate deliveries so a
|
|
13
|
+
# re-run can't double-decrement the counter (M3). A duplicate of a live
|
|
14
|
+
# element is dropped — the live original stores the result and finalizes.
|
|
15
|
+
lock = acquire_element_lock(arguments)
|
|
16
|
+
return if lock == :contended
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
perform_element(arguments)
|
|
20
|
+
ensure
|
|
21
|
+
lock.release if lock.respond_to?(:release)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.acquire_element_lock(arguments)
|
|
26
|
+
# In Sidekiq::Testing.inline! an element's async-retry perform_map_element_in
|
|
27
|
+
# re-enters synchronously inside this frame; the lock would self-contend.
|
|
28
|
+
# It only guards concurrent cross-process delivery, impossible inline.
|
|
29
|
+
return :inline if inline_testing_mode?
|
|
30
|
+
|
|
31
|
+
lock = RubyReactor::Lock.new(
|
|
32
|
+
"map_element:#{arguments[:map_id]}:#{arguments[:index]}",
|
|
33
|
+
owner: SecureRandom.uuid, ttl: RubyReactor.configuration.context_lock_ttl,
|
|
34
|
+
wait: 0, auto_extend: true
|
|
35
|
+
)
|
|
36
|
+
lock.acquire
|
|
37
|
+
lock
|
|
38
|
+
rescue RubyReactor::Lock::AcquisitionError
|
|
39
|
+
RubyReactor.configuration.logger.info(
|
|
40
|
+
"RubyReactor map element #{arguments[:map_id]}:#{arguments[:index]} already in flight; dropping duplicate"
|
|
41
|
+
)
|
|
42
|
+
:contended
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.inline_testing_mode?
|
|
46
|
+
defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.perform_element(arguments)
|
|
11
50
|
context = hydrate_or_create_context(arguments)
|
|
12
51
|
# The element already runs inside its own background worker, so any async
|
|
13
52
|
# steps (and async retries) must execute inline here rather than handing
|
|
@@ -108,10 +108,17 @@ module RubyReactor
|
|
|
108
108
|
executor.resume_execution
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
# Checkpoint the ROOT, not the sub (F9/C2). When the map is embedded in a
|
|
112
|
+
# composed sub-reactor, parent_context is the *sub*; storing only the sub
|
|
113
|
+
# would leave the root blob stale and a rehydrate-by-root-id resume would
|
|
114
|
+
# lose the map's completion. Resolve the root (which embeds the sub's
|
|
115
|
+
# post-map state via composed_contexts) and store that. For a top-level
|
|
116
|
+
# map parent_context IS the root, so this is unchanged.
|
|
117
|
+
root = parent_context.root_context || parent_context
|
|
111
118
|
storage.store_context(
|
|
112
|
-
|
|
113
|
-
ContextSerializer.serialize(
|
|
114
|
-
|
|
119
|
+
root.context_id,
|
|
120
|
+
ContextSerializer.serialize(root),
|
|
121
|
+
RubyReactor.reactor_storage_name(root.reactor_class)
|
|
115
122
|
)
|
|
116
123
|
end
|
|
117
124
|
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Map
|
|
5
|
+
# Recovers map fan-out from a hard kill (Phase 5d). Maps are the path most
|
|
6
|
+
# exposed to a lost job: one missing element result hangs the whole map and
|
|
7
|
+
# its parent forever. The unifying signal is the results hash — index-keyed
|
|
8
|
+
# and idempotent (HSET) — so completion is authoritative on `missing`, not on
|
|
9
|
+
# the fragile counter:
|
|
10
|
+
#
|
|
11
|
+
# missing = (0...count) - HKEYS(results)
|
|
12
|
+
#
|
|
13
|
+
# For each active map:
|
|
14
|
+
# * missing indices with NO live element lock are re-dispatched (M1/M4/M5).
|
|
15
|
+
# * if nothing is missing but the parent never resumed, the collector is
|
|
16
|
+
# re-triggered (M2) — gated so it never fires while a collector or the
|
|
17
|
+
# parent is alive, or after the parent already collected.
|
|
18
|
+
#
|
|
19
|
+
# `run_once` is pure and idempotent; the host wires the cadence (same contract
|
|
20
|
+
# as RubyReactor::Sweeper).
|
|
21
|
+
class Sweeper
|
|
22
|
+
def self.run_once(limit: 1000)
|
|
23
|
+
new.run_once(limit: limit)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(storage: nil, async_router: nil, logger: nil)
|
|
27
|
+
@storage = storage || RubyReactor.configuration.storage_adapter
|
|
28
|
+
@async_router = async_router || RubyReactor.configuration.async_router
|
|
29
|
+
@logger = logger || RubyReactor.configuration.logger
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns { redispatched:, recollected: } counts.
|
|
33
|
+
def run_once(limit: 1000)
|
|
34
|
+
redispatched = 0
|
|
35
|
+
recollected = 0
|
|
36
|
+
|
|
37
|
+
@storage.scan_maps(count: limit).each do |meta|
|
|
38
|
+
missing = missing_indices(meta)
|
|
39
|
+
if missing.any?
|
|
40
|
+
redispatched += redispatch_missing(meta, missing)
|
|
41
|
+
elsif recollect?(meta)
|
|
42
|
+
retrigger_collector(meta)
|
|
43
|
+
recollected += 1
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
@logger.warn("RubyReactor::Map::Sweeper failed on map #{meta["map_id"]}: #{e.class}: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
{ redispatched: redispatched, recollected: recollected }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def missing_indices(meta)
|
|
55
|
+
@storage.missing_map_indices(meta["map_id"], meta["count"].to_i, meta["parent_reactor_class_name"])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def redispatch_missing(meta, missing)
|
|
59
|
+
count = 0
|
|
60
|
+
missing.each do |index|
|
|
61
|
+
next if @storage.lock_held?("map_element:#{meta["map_id"]}:#{index}") # element alive
|
|
62
|
+
|
|
63
|
+
RubyReactor::Map::Dispatcher.requeue_index(meta, index)
|
|
64
|
+
count += 1
|
|
65
|
+
end
|
|
66
|
+
count
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# All results are in. Re-trigger the collector only if no collector/parent is
|
|
70
|
+
# alive and the parent has not already collected this step.
|
|
71
|
+
def recollect?(meta)
|
|
72
|
+
return false if @storage.lock_held?("map_collect:#{meta["map_id"]}") # a collector is running
|
|
73
|
+
return false if parent_live_lock?(meta) # parent execution alive
|
|
74
|
+
return false if parent_already_collected?(meta)
|
|
75
|
+
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# N1: a nested map's parent is a map element running under a `map_element:`
|
|
80
|
+
# lock, not an `async:` lock. Derive the right key from metadata.
|
|
81
|
+
def parent_live_lock?(meta)
|
|
82
|
+
if meta["parent_is_map_element"]
|
|
83
|
+
@storage.lock_held?("map_element:#{meta["outer_map_id"]}:#{meta["outer_index"]}")
|
|
84
|
+
else
|
|
85
|
+
@storage.lock_held?("async:#{meta["parent_context_id"]}")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parent_already_collected?(meta)
|
|
90
|
+
data = @storage.retrieve_context(meta["parent_context_id"], meta["parent_reactor_class_name"])
|
|
91
|
+
return false unless data
|
|
92
|
+
|
|
93
|
+
results = data["intermediate_results"] || {}
|
|
94
|
+
status = data["status"].to_s
|
|
95
|
+
results.key?(meta["step_name"].to_s) || %w[completed failed skipped].include?(status)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def retrigger_collector(meta)
|
|
99
|
+
@async_router.perform_map_collection_async(
|
|
100
|
+
parent_context_id: meta["parent_context_id"],
|
|
101
|
+
map_id: meta["map_id"],
|
|
102
|
+
parent_reactor_class_name: meta["parent_reactor_class_name"],
|
|
103
|
+
step_name: meta["step_name"],
|
|
104
|
+
strict_ordering: meta["strict_ordering"],
|
|
105
|
+
timeout: 3600
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|