ruby_reactor 0.8.4 → 0.8.6
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/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +17 -14
- data/CHANGELOG.md +254 -0
- data/CLAUDE.md +1 -1
- data/README.md +66 -15
- data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
- data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
- data/lib/ruby_reactor/context.rb +14 -1
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
- data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +33 -0
- data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
- data/lib/ruby_reactor/executor/result_handler.rb +85 -63
- data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +61 -49
- data/lib/ruby_reactor/executor.rb +221 -7
- data/lib/ruby_reactor/map/collector.rb +31 -89
- data/lib/ruby_reactor/map/dispatcher.rb +65 -8
- data/lib/ruby_reactor/map/element_executor.rb +21 -11
- data/lib/ruby_reactor/map/element_rollback.rb +158 -0
- data/lib/ruby_reactor/map/helpers.rb +11 -125
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/map/step_rollback.rb +136 -0
- data/lib/ruby_reactor/map/sweeper.rb +66 -8
- data/lib/ruby_reactor/map.rb +17 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
- data/lib/ruby_reactor/reactor.rb +65 -2
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +59 -24
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
- data/lib/ruby_reactor/rspec.rb +3 -6
- data/lib/ruby_reactor/step/compose_step.rb +10 -1
- data/lib/ruby_reactor/step/map_step.rb +144 -36
- data/lib/ruby_reactor/step.rb +7 -0
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +18 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
- data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/sweeper.rb +3 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +11 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
- data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +7 -3
- data/lib/ruby_reactor.rb +17 -6
- data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
- data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
- data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
- data/specs/009-distributed-map-undo/data-model.md +151 -0
- data/specs/009-distributed-map-undo/plan.md +323 -0
- data/specs/009-distributed-map-undo/pr-description.md +78 -0
- data/specs/009-distributed-map-undo/quickstart.md +154 -0
- data/specs/009-distributed-map-undo/research.md +585 -0
- data/specs/009-distributed-map-undo/spec.md +360 -0
- data/specs/009-distributed-map-undo/tasks.md +1060 -0
- data/specs/future_improvements.md +64 -28
- metadata +54 -3
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
|
@@ -88,7 +88,13 @@ module RubyReactor
|
|
|
88
88
|
log(:error, "failed", error: "#{e.class}: #{e.message}")
|
|
89
89
|
complete(RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name, retryable: false),
|
|
90
90
|
context)
|
|
91
|
-
|
|
91
|
+
# Arguments raised: the body never started, so it is neither retried nor
|
|
92
|
+
# compensated (008 R-06).
|
|
93
|
+
rescue Error::ArgumentResolutionError => e
|
|
94
|
+
log(:error, "failed", error: "#{e.class}: #{e.message}")
|
|
95
|
+
complete(RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name, retryable: false,
|
|
96
|
+
exception_class: e.exception_class), context)
|
|
97
|
+
rescue Error::Rescuable => e
|
|
92
98
|
# The unit's failure belongs in its record, where a reader can see it.
|
|
93
99
|
# Raising instead would hand the job to the backend's retry machinery to
|
|
94
100
|
# fail identically N more times while every reader waits out its timeout.
|
|
@@ -238,17 +244,7 @@ module RubyReactor
|
|
|
238
244
|
end
|
|
239
245
|
|
|
240
246
|
def run_step(context, step_config)
|
|
241
|
-
|
|
242
|
-
# arguments are validated or any hold is taken, exactly as
|
|
243
|
-
# `StepExecutor#execute_step_sync` orders it. `complete` persists the
|
|
244
|
-
# nil result, so the reader sees the same skipped unit a same-process
|
|
245
|
-
# step would produce.
|
|
246
|
-
unless step_config.should_run?(context)
|
|
247
|
-
log(:info, "skipped")
|
|
248
|
-
return RubyReactor.Success(nil)
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
arguments = resolve_arguments(step_config, context)
|
|
247
|
+
arguments = step_config.resolve_arguments(context)
|
|
252
248
|
# Reactor-side `argument`/`validate_args` rules gate the step BEFORE its
|
|
253
249
|
# coordination is acquired, exactly as `StepExecutor#execute_step_sync`
|
|
254
250
|
# orders them — an async_step must not take a lock (or spend a rate-limit
|
|
@@ -289,9 +285,43 @@ module RubyReactor
|
|
|
289
285
|
end
|
|
290
286
|
end
|
|
291
287
|
|
|
288
|
+
compensate_unit(context, step_config, result, arguments)
|
|
292
289
|
result
|
|
293
290
|
end
|
|
294
291
|
|
|
292
|
+
# 008 R-09: the unit compensates ITSELF, once, here in its own job, after
|
|
293
|
+
# its final attempt failed — whether or not any step ever reads it, the
|
|
294
|
+
# way an `async_reactor` child rolls itself back. Never for a success,
|
|
295
|
+
# skip or halt, and never for a body that never started (invalid
|
|
296
|
+
# arguments), exactly the executor's rule. Same coordination re-take,
|
|
297
|
+
# middleware events and failure recording as an in-process compensate.
|
|
298
|
+
# The outcome goes on this unit's record (`complete`); the parent's
|
|
299
|
+
# context is never written here (single writer), so the in-memory trace
|
|
300
|
+
# entry the compensate appends stays local to this job.
|
|
301
|
+
def compensate_unit(context, step_config, result, arguments)
|
|
302
|
+
return unless result.is_a?(RubyReactor::Failure) && body_started?(result.error)
|
|
303
|
+
|
|
304
|
+
manager = Executor::CompensationManager.new(context)
|
|
305
|
+
outcome = manager.compensate(step_config, result.error, arguments)
|
|
306
|
+
@compensation = {
|
|
307
|
+
"status" => compensation_status(outcome),
|
|
308
|
+
"rollback_failures" => ContextSerializer.serialize_value(manager.rollback_failures),
|
|
309
|
+
"completed_at" => Time.now.iso8601
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def body_started?(error)
|
|
314
|
+
return false if error.is_a?(Error::InputValidationError)
|
|
315
|
+
|
|
316
|
+
Executor::CompensationManager::NEVER_STARTED_ERROR_CLASSES.none? { |klass| error.is_a?(klass) }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def compensation_status(outcome)
|
|
320
|
+
return "failed" if outcome.is_a?(RubyReactor::Failure)
|
|
321
|
+
|
|
322
|
+
outcome.respond_to?(:skipped?) && outcome.skipped? ? "skipped" : "completed"
|
|
323
|
+
end
|
|
324
|
+
|
|
295
325
|
# Same check and same structured, non-retryable shape `StepExecutor`
|
|
296
326
|
# produces — the same arguments fail the same rules on every attempt.
|
|
297
327
|
def validate_arguments(step_config, arguments)
|
|
@@ -330,7 +360,7 @@ module RubyReactor
|
|
|
330
360
|
retryable: false)
|
|
331
361
|
rescue Executor::StepCoordination::Contended, Executor::StepCoordination::KeyError
|
|
332
362
|
raise
|
|
333
|
-
rescue
|
|
363
|
+
rescue Error::Rescuable => e
|
|
334
364
|
RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name)
|
|
335
365
|
end
|
|
336
366
|
|
|
@@ -356,14 +386,6 @@ module RubyReactor
|
|
|
356
386
|
RubyReactor.Success(result)
|
|
357
387
|
end
|
|
358
388
|
|
|
359
|
-
def resolve_arguments(step_config, context)
|
|
360
|
-
step_config.arguments.to_h do |arg_name, arg_config|
|
|
361
|
-
value = arg_config[:source].resolve(context)
|
|
362
|
-
value = arg_config[:transform].call(value) if arg_config[:transform]
|
|
363
|
-
[arg_name, value]
|
|
364
|
-
end
|
|
365
|
-
end
|
|
366
|
-
|
|
367
389
|
# Write first, publish second. The record is the answer; the signal only
|
|
368
390
|
# saves the reader a fallback interval.
|
|
369
391
|
def complete(result, context)
|
|
@@ -394,8 +416,10 @@ module RubyReactor
|
|
|
394
416
|
|
|
395
417
|
# This delivery's run, once the body was reached: `started_at`,
|
|
396
418
|
# `arguments` (redacted, serialized) and `attempts`. Empty before that.
|
|
419
|
+
# Plus `compensation` when the unit compensated itself.
|
|
397
420
|
def run_fields
|
|
398
|
-
@run || {}
|
|
421
|
+
fields = @run || {}
|
|
422
|
+
@compensation ? fields.merge("compensation" => @compensation) : fields
|
|
399
423
|
end
|
|
400
424
|
|
|
401
425
|
def record_missing_parent
|
|
@@ -56,6 +56,10 @@ module RubyReactor
|
|
|
56
56
|
raise NotImplementedError
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
def decrement_map_counter_by(map_id, amount, reactor_class_name)
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
end
|
|
62
|
+
|
|
59
63
|
def subscribe(channel, &block)
|
|
60
64
|
raise NotImplementedError
|
|
61
65
|
end
|
|
@@ -103,6 +107,20 @@ module RubyReactor
|
|
|
103
107
|
def retrieve_map_element_context_ids(map_id, reactor_class_name)
|
|
104
108
|
raise NotImplementedError
|
|
105
109
|
end
|
|
110
|
+
|
|
111
|
+
def claim_map_owner_signal(map_id, reactor_class_name)
|
|
112
|
+
raise NotImplementedError
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Map rollback records (009 DM §5); see RedisMapRollback.
|
|
116
|
+
%i[count_map_element_context_ids retrieve_map_element_context_ids_from_tail start_map_rollback
|
|
117
|
+
retrieve_map_rollback_metadata claim_map_rollback_positions retrieve_map_rollback_offset
|
|
118
|
+
store_map_rollback_outcome count_map_rollback_outcomes map_rollback_outcome_stored?
|
|
119
|
+
stored_map_rollback_positions each_map_rollback_outcome map_rollback_indexes_seen
|
|
120
|
+
mark_map_rollback_handed_off map_rollback_handed_off? claim_map_rollback_signal map_rollback_summary
|
|
121
|
+
scan_map_rollbacks].each do |method|
|
|
122
|
+
define_method(method) { |*, **| raise NotImplementedError }
|
|
123
|
+
end
|
|
106
124
|
end
|
|
107
125
|
end
|
|
108
126
|
end
|
|
@@ -11,6 +11,7 @@ module RubyReactor
|
|
|
11
11
|
include RedisStepResults
|
|
12
12
|
include RedisPubSub
|
|
13
13
|
include RedisReactorScan
|
|
14
|
+
include RedisMapRollback
|
|
14
15
|
|
|
15
16
|
def initialize(redis_config)
|
|
16
17
|
super()
|
|
@@ -70,7 +71,8 @@ module RubyReactor
|
|
|
70
71
|
# rubocop:disable Metrics/ParameterLists
|
|
71
72
|
def initialize_map_operation(map_id, count, parent_reactor_class_name, reactor_class_info:, strict_ordering: true,
|
|
72
73
|
parent_context_id: nil, step_name: nil, parent_is_map_element: false,
|
|
73
|
-
outer_map_id: nil, outer_index: nil
|
|
74
|
+
outer_map_id: nil, outer_index: nil, owner_context_id: nil,
|
|
75
|
+
owner_reactor_class_name: nil, batch_size: nil, atomic: nil)
|
|
74
76
|
# Ensure counter is set
|
|
75
77
|
set_map_counter(map_id, count, parent_reactor_class_name)
|
|
76
78
|
|
|
@@ -79,7 +81,9 @@ module RubyReactor
|
|
|
79
81
|
# nested-map fields (parent_is_map_element + outer_map_id/outer_index)
|
|
80
82
|
# record which liveness lock the parent actually holds (N1): a nested
|
|
81
83
|
# map's parent is itself a map element running under a `map_element:` lock,
|
|
82
|
-
# not an `async:` lock.
|
|
84
|
+
# not an `async:` lock. The owner is the top-level run the collector
|
|
85
|
+
# resumes (009 R-03); `batch_size` and `atomic` let a re-dispatched
|
|
86
|
+
# element keep both (R-10).
|
|
83
87
|
key = "reactor:#{parent_reactor_class_name}:map:#{map_id}:metadata"
|
|
84
88
|
metadata = {
|
|
85
89
|
map_id: map_id,
|
|
@@ -92,12 +96,22 @@ module RubyReactor
|
|
|
92
96
|
parent_is_map_element: parent_is_map_element,
|
|
93
97
|
outer_map_id: outer_map_id,
|
|
94
98
|
outer_index: outer_index,
|
|
99
|
+
owner_context_id: owner_context_id,
|
|
100
|
+
owner_reactor_class_name: owner_reactor_class_name,
|
|
101
|
+
batch_size: batch_size,
|
|
102
|
+
atomic: atomic,
|
|
95
103
|
created_at: Time.now.to_i
|
|
96
104
|
}
|
|
97
105
|
@redis.set(key, metadata.to_json, ex: durability_ttl)
|
|
98
106
|
end
|
|
99
107
|
# rubocop:enable Metrics/ParameterLists
|
|
100
108
|
|
|
109
|
+
# Claimed once, by the collector that found the map settled: only that
|
|
110
|
+
# one enqueues the owner's Worker (009 DM §3).
|
|
111
|
+
def claim_map_owner_signal(map_id, reactor_class_name) # rubocop:disable Naming/PredicateMethod
|
|
112
|
+
!!@redis.set("reactor:#{reactor_class_name}:map:#{map_id}:owner_signalled", "1", nx: true, ex: durability_ttl)
|
|
113
|
+
end
|
|
114
|
+
|
|
101
115
|
# Enumerate active map operations for the map sweeper (Phase 5d). Returns
|
|
102
116
|
# the parsed metadata hash for each (includes map_id, count,
|
|
103
117
|
# parent_context_id, step_name, parent_reactor_class_name, and the nested-map
|
|
@@ -105,6 +119,8 @@ module RubyReactor
|
|
|
105
119
|
def scan_maps(count: 1000)
|
|
106
120
|
results = []
|
|
107
121
|
@redis.scan_each(match: "reactor:*:map:*:metadata", count: 100) do |key|
|
|
122
|
+
next if key.end_with?(":rollback:metadata") # a hash, listed by scan_map_rollbacks
|
|
123
|
+
|
|
108
124
|
json = @redis.get(key)
|
|
109
125
|
next unless json
|
|
110
126
|
|
|
@@ -133,6 +149,15 @@ module RubyReactor
|
|
|
133
149
|
@redis.decr(key)
|
|
134
150
|
end
|
|
135
151
|
|
|
152
|
+
# Settles `amount` indices at once (a fail-fast dispatcher claiming the
|
|
153
|
+
# ones it never dispatched). Returns the count left.
|
|
154
|
+
def decrement_map_counter_by(map_id, amount, reactor_class_name)
|
|
155
|
+
key = map_counter_key(map_id, reactor_class_name)
|
|
156
|
+
left = @redis.decrby(key, amount)
|
|
157
|
+
@redis.expire(key, durability_ttl)
|
|
158
|
+
left
|
|
159
|
+
end
|
|
160
|
+
|
|
136
161
|
def set_last_queued_index(map_id, index, reactor_class_name)
|
|
137
162
|
key = map_last_queued_index_key(map_id, reactor_class_name)
|
|
138
163
|
@redis.set(key, index, ex: durability_ttl)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Storage
|
|
5
|
+
# The records of one fan-out map's distributed rollback (009 DM §5), under
|
|
6
|
+
# `reactor:<P>:map:<map_id>:rollback:…`. Element rollback jobs write only
|
|
7
|
+
# these and their own element context, never a reactor context (I-1).
|
|
8
|
+
#
|
|
9
|
+
# Positions count from the tail of the element-context index, anchored at
|
|
10
|
+
# the `total` the rollback started with, so an id a late duplicate appends
|
|
11
|
+
# afterwards never shifts them.
|
|
12
|
+
module RedisMapRollback
|
|
13
|
+
# Creates the metadata only if no rollback of this map exists (I-3).
|
|
14
|
+
START_ROLLBACK_SCRIPT = <<~LUA
|
|
15
|
+
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
|
|
16
|
+
redis.call('HSET', KEYS[1], unpack(ARGV, 2))
|
|
17
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
18
|
+
return 1
|
|
19
|
+
LUA
|
|
20
|
+
|
|
21
|
+
FAILED_OUTCOMES = %w[failed element_in_flight context_unavailable].freeze
|
|
22
|
+
|
|
23
|
+
def count_map_element_context_ids(map_id, reactor_class_name)
|
|
24
|
+
@redis.llen(map_element_contexts_key(map_id, reactor_class_name))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Ids at tail positions `position...position + count`, in tail order.
|
|
28
|
+
def retrieve_map_element_context_ids_from_tail(map_id, reactor_class_name, position, count, total:)
|
|
29
|
+
stop = total - 1 - position
|
|
30
|
+
return [] if stop.negative?
|
|
31
|
+
|
|
32
|
+
start = [total - position - count, 0].max
|
|
33
|
+
@redis.lrange(map_element_contexts_key(map_id, reactor_class_name), start, stop).reverse
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# `[created, metadata]`: a second start finds the first one's records.
|
|
37
|
+
def start_map_rollback(map_id, reactor_class_name, **meta)
|
|
38
|
+
fields = meta.merge(map_id: map_id, parent_reactor_class_name: reactor_class_name, started_at: Time.now.to_i)
|
|
39
|
+
argv = fields.flat_map { |k, v| [k.to_s, JSON.generate(v)] }
|
|
40
|
+
created = @redis.eval(START_ROLLBACK_SCRIPT, keys: [map_rollback_key(map_id, reactor_class_name, :metadata)],
|
|
41
|
+
argv: [durability_ttl, *argv])
|
|
42
|
+
[created == 1, retrieve_map_rollback_metadata(map_id, reactor_class_name)]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def retrieve_map_rollback_metadata(map_id, reactor_class_name)
|
|
46
|
+
parse_rollback_metadata(@redis.hgetall(map_rollback_key(map_id, reactor_class_name, :metadata)))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Claims the next `count` positions (R-02), clipped to the total.
|
|
50
|
+
def claim_map_rollback_positions(map_id, reactor_class_name, count)
|
|
51
|
+
key = map_rollback_key(map_id, reactor_class_name, :offset)
|
|
52
|
+
stop = @redis.incrby(key, count)
|
|
53
|
+
@redis.expire(key, durability_ttl)
|
|
54
|
+
total = retrieve_map_rollback_metadata(map_id, reactor_class_name)&.fetch("total", 0).to_i
|
|
55
|
+
(stop - count)...[stop, total].min
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def retrieve_map_rollback_offset(map_id, reactor_class_name)
|
|
59
|
+
@redis.get(map_rollback_key(map_id, reactor_class_name, :offset)).to_i
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The first outcome for a position wins: a duplicate delivery, which
|
|
63
|
+
# finds the element already undone, must not replace a `failed` outcome
|
|
64
|
+
# with its own `undone`. Returns whether this call stored it.
|
|
65
|
+
def store_map_rollback_outcome(map_id, reactor_class_name, position, outcome) # rubocop:disable Naming/PredicateMethod
|
|
66
|
+
results = map_rollback_key(map_id, reactor_class_name, :results)
|
|
67
|
+
indexes = map_rollback_key(map_id, reactor_class_name, :indexes)
|
|
68
|
+
index = outcome["index"] || outcome[:index]
|
|
69
|
+
stored, = @redis.multi do |tx|
|
|
70
|
+
tx.hsetnx(results, position.to_s, JSON.generate(outcome))
|
|
71
|
+
tx.expire(results, durability_ttl)
|
|
72
|
+
tx.sadd(indexes, index.to_s) unless index.nil?
|
|
73
|
+
tx.expire(indexes, durability_ttl)
|
|
74
|
+
end
|
|
75
|
+
[true, 1].include?(stored)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def count_map_rollback_outcomes(map_id, reactor_class_name)
|
|
79
|
+
@redis.hlen(map_rollback_key(map_id, reactor_class_name, :results))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stored_map_rollback_positions(map_id, reactor_class_name)
|
|
83
|
+
@redis.hkeys(map_rollback_key(map_id, reactor_class_name, :results)).map(&:to_i)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def map_rollback_outcome_stored?(map_id, reactor_class_name, position)
|
|
87
|
+
@redis.hexists(map_rollback_key(map_id, reactor_class_name, :results), position.to_s)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Yields `position, outcome` in bounded reads.
|
|
91
|
+
def each_map_rollback_outcome(map_id, reactor_class_name)
|
|
92
|
+
@redis.hscan_each(map_rollback_key(map_id, reactor_class_name, :results), count: 500) do |position, json|
|
|
93
|
+
yield position.to_i, JSON.parse(json)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# One boolean per index: whether a rollback job saw that element (R-09).
|
|
98
|
+
def map_rollback_indexes_seen(map_id, reactor_class_name, indexes)
|
|
99
|
+
key = map_rollback_key(map_id, reactor_class_name, :indexes)
|
|
100
|
+
@redis.pipelined { |pipe| indexes.each { |index| pipe.sismember(key, index.to_s) } }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def mark_map_rollback_handed_off(map_id, reactor_class_name)
|
|
104
|
+
@redis.set(map_rollback_key(map_id, reactor_class_name, :handed_off), "1", ex: durability_ttl)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def map_rollback_handed_off?(map_id, reactor_class_name)
|
|
108
|
+
@redis.exists?(map_rollback_key(map_id, reactor_class_name, :handed_off))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def claim_map_rollback_signal(map_id, reactor_class_name) # rubocop:disable Naming/PredicateMethod
|
|
112
|
+
!!@redis.set(map_rollback_key(map_id, reactor_class_name, :signalled), "1", nx: true, ex: durability_ttl)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Progress for the dashboard (R-15), or nil when this map never rolled back.
|
|
116
|
+
def map_rollback_summary(map_id, reactor_class_name)
|
|
117
|
+
meta = retrieve_map_rollback_metadata(map_id, reactor_class_name)
|
|
118
|
+
return nil unless meta
|
|
119
|
+
|
|
120
|
+
failed = 0
|
|
121
|
+
each_map_rollback_outcome(map_id, reactor_class_name) do |_, outcome|
|
|
122
|
+
failed += 1 if FAILED_OUTCOMES.include?(outcome["outcome"])
|
|
123
|
+
end
|
|
124
|
+
total = meta["total"].to_i
|
|
125
|
+
settled = count_map_rollback_outcomes(map_id, reactor_class_name)
|
|
126
|
+
{ total: total, settled: settled, outstanding: total - settled, failed: failed }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Every started map rollback, for `Map::Sweeper` (S-5).
|
|
130
|
+
def scan_map_rollbacks(count: 1000)
|
|
131
|
+
results = []
|
|
132
|
+
@redis.scan_each(match: "reactor:*:map:*:rollback:metadata", count: 100) do |key|
|
|
133
|
+
meta = parse_rollback_metadata(@redis.hgetall(key))
|
|
134
|
+
results << meta if meta
|
|
135
|
+
return results if results.size >= count
|
|
136
|
+
end
|
|
137
|
+
results
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def parse_rollback_metadata(raw)
|
|
143
|
+
return nil if raw.nil? || raw.empty?
|
|
144
|
+
|
|
145
|
+
raw.transform_values { |value| JSON.parse(value) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def map_rollback_key(map_id, reactor_class_name, suffix)
|
|
149
|
+
"reactor:#{reactor_class_name}:map:#{map_id}:rollback:#{suffix}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -67,7 +67,7 @@ module RubyReactor
|
|
|
67
67
|
|
|
68
68
|
def determine_status(data)
|
|
69
69
|
status = data["status"].to_s == "skipped" ? "halted" : data["status"].to_s # "skipped" is the legacy halt name
|
|
70
|
-
return status if %w[failed paused completed running halted pending].include?(status)
|
|
70
|
+
return status if %w[failed paused completed running rolling_back halted pending aborted].include?(status)
|
|
71
71
|
return "cancelled" if data["cancelled"]
|
|
72
72
|
# Heuristic
|
|
73
73
|
return "failed" if data["retry_count"]&.positive? && !data["current_step"].nil?
|
data/lib/ruby_reactor/sweeper.rb
CHANGED
|
@@ -48,7 +48,9 @@ module RubyReactor
|
|
|
48
48
|
reenqueued = 0
|
|
49
49
|
|
|
50
50
|
@storage.scan_reactors(count: limit, include_dispatched_children: true).each do |reactor|
|
|
51
|
-
|
|
51
|
+
# Non-terminal only. A `rolling_back` run whose owner resume was lost
|
|
52
|
+
# is re-enqueued too: unsettled, it simply hands off again (009 S-5).
|
|
53
|
+
next unless %w[running rolling_back].include?(reactor[:status])
|
|
52
54
|
next if @storage.lock_held?("async:#{reactor[:id]}") # worker alive -> leave alone
|
|
53
55
|
|
|
54
56
|
@async_router.perform_async(reactor[:id], reactor[:class])
|
data/lib/ruby_reactor/version.rb
CHANGED
data/lib/ruby_reactor/web/api.rb
CHANGED
|
@@ -173,7 +173,7 @@ module RubyReactor
|
|
|
173
173
|
|
|
174
174
|
def self.reactor_status(data)
|
|
175
175
|
status = data[:status].to_s == "skipped" ? "halted" : data[:status].to_s
|
|
176
|
-
return status if %w[failed paused completed running halted pending].include?(status)
|
|
176
|
+
return status if %w[failed paused completed running rolling_back halted pending aborted].include?(status)
|
|
177
177
|
return "cancelled" if data[:cancelled]
|
|
178
178
|
return "running" if data[:current_step]
|
|
179
179
|
return "completed" if execution_evidence?(data)
|
|
@@ -393,7 +393,17 @@ module RubyReactor
|
|
|
393
393
|
ref_data.merge("context" => child)
|
|
394
394
|
end
|
|
395
395
|
|
|
396
|
+
# A map that rolled back distributed also carries its progress, `{
|
|
397
|
+
# total, settled, outstanding, failed }` (009 R-15). The records outlive
|
|
398
|
+
# the run, so a client shows it only while the run is `rolling_back`.
|
|
396
399
|
def self.hydrate_map_ref(ref_data, reactor_class_name)
|
|
400
|
+
map_id = ref_data[:map_id] || ref_data["map_id"]
|
|
401
|
+
hydrated = hydrate_map_element(ref_data, reactor_class_name)
|
|
402
|
+
rollback = map_id && RubyReactor.configuration.storage_adapter.map_rollback_summary(map_id, reactor_class_name)
|
|
403
|
+
rollback ? hydrated.merge("rollback" => rollback.transform_keys(&:to_s)) : hydrated
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def self.hydrate_map_element(ref_data, reactor_class_name)
|
|
397
407
|
storage = RubyReactor.configuration.storage_adapter
|
|
398
408
|
map_id = ref_data[:map_id] || ref_data["map_id"]
|
|
399
409
|
|