ruby_reactor 0.8.2 → 0.8.4
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/.claude/skills/speckit-review/SKILL.md +324 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions.yml +10 -0
- data/.specify/feature.json +1 -1
- data/.specify/workflows/speckit/workflow.yml +13 -1
- data/.specify/workflows/workflow-registry.json +2 -2
- data/CHANGELOG.md +94 -0
- data/CLAUDE.md +2 -2
- data/README.md +128 -93
- data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
- data/lib/ruby_reactor/context.rb +26 -0
- data/lib/ruby_reactor/context_serializer.rb +4 -2
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
- data/lib/ruby_reactor/dsl/lockable.rb +76 -21
- data/lib/ruby_reactor/dsl/reactor.rb +8 -11
- data/lib/ruby_reactor/dsl/retryable.rb +45 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +150 -13
- data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
- data/lib/ruby_reactor/error/execution_parked.rb +16 -0
- data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
- data/lib/ruby_reactor/executor/result_handler.rb +34 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
- data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
- data/lib/ruby_reactor/executor/step_executor.rb +115 -11
- data/lib/ruby_reactor/executor.rb +90 -20
- data/lib/ruby_reactor/map/element_executor.rb +24 -2
- data/lib/ruby_reactor/map/helpers.rb +35 -11
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
- data/lib/ruby_reactor/open_telemetry.rb +61 -24
- data/lib/ruby_reactor/retry_context.rb +31 -2
- data/lib/ruby_reactor/rspec/helpers.rb +15 -0
- data/lib/ruby_reactor/rspec/matchers.rb +92 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +9 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
- data/lib/ruby_reactor/step/compose_step.rb +22 -7
- data/lib/ruby_reactor/step/input_contract.rb +5 -0
- data/lib/ruby_reactor/step/inputs.rb +57 -0
- data/lib/ruby_reactor/step/map_step.rb +32 -22
- data/lib/ruby_reactor/step.rb +59 -11
- data/lib/ruby_reactor/step_sweeper.rb +29 -1
- data/lib/ruby_reactor/step_worker.rb +260 -37
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +72 -7
- data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
- data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +56 -30
- data/lib/ruby_reactor.rb +30 -6
- data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
- data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
- data/specs/006-step-retry-declarations/data-model.md +58 -0
- data/specs/006-step-retry-declarations/plan.md +187 -0
- data/specs/006-step-retry-declarations/quickstart.md +80 -0
- data/specs/006-step-retry-declarations/research.md +194 -0
- data/specs/006-step-retry-declarations/spec.md +453 -0
- data/specs/006-step-retry-declarations/tasks.md +382 -0
- data/specs/future_improvements.md +250 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +20 -28
- data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
- data/specs/002-step-input-contracts/data-model.md +0 -115
- data/specs/002-step-input-contracts/plan.md +0 -165
- data/specs/002-step-input-contracts/quickstart.md +0 -170
- data/specs/002-step-input-contracts/research.md +0 -233
- data/specs/002-step-input-contracts/spec.md +0 -359
- data/specs/002-step-input-contracts/tasks.md +0 -367
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
- data/specs/004-inheritable-step-class/data-model.md +0 -116
- data/specs/004-inheritable-step-class/plan.md +0 -174
- data/specs/004-inheritable-step-class/quickstart.md +0 -112
- data/specs/004-inheritable-step-class/research.md +0 -308
- data/specs/004-inheritable-step-class/spec.md +0 -316
- data/specs/004-inheritable-step-class/tasks.md +0 -258
- data/specs/active_job.md +0 -259
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
- data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
- data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
- data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
- data/specs/deferred-003-step-lock-declarations/research.md +0 -196
- data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
- data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
- data/specs/possible_feature.md +0 -22
data/lib/ruby_reactor/web/api.rb
CHANGED
|
@@ -47,6 +47,9 @@ module RubyReactor
|
|
|
47
47
|
structure = self.class.build_structure(reactor_class) if reactor_class.respond_to?(:steps)
|
|
48
48
|
|
|
49
49
|
api_status = self.class.reactor_status(data)
|
|
50
|
+
class_name = data[:reactor_class]&.to_s
|
|
51
|
+
runs = self.class.async_step_runs(data[:composed_contexts], class_name)
|
|
52
|
+
trace = self.class.with_async_step_runs(data[:execution_trace] || [], runs)
|
|
50
53
|
|
|
51
54
|
response_data = {
|
|
52
55
|
id: data[:context_id],
|
|
@@ -55,7 +58,9 @@ module RubyReactor
|
|
|
55
58
|
current_step: data[:current_step].to_s,
|
|
56
59
|
retry_count: data[:retry_count] || 0,
|
|
57
60
|
undo_stack: data[:undo_stack] || [],
|
|
58
|
-
step_attempts:
|
|
61
|
+
step_attempts: self.class.with_async_step_attempts(
|
|
62
|
+
data.dig(:retry_context, :step_attempts) || {}, runs
|
|
63
|
+
),
|
|
59
64
|
created_at: data[:started_at],
|
|
60
65
|
inputs: data[:inputs],
|
|
61
66
|
intermediate_results: self.class.with_map_summaries(
|
|
@@ -65,15 +70,14 @@ module RubyReactor
|
|
|
65
70
|
# Once per reactor, never per step: the old per-step `async`
|
|
66
71
|
# field is gone because there is now exactly one hand-off point.
|
|
67
72
|
background_handoff: self.class.background_handoff_for(reactor_class),
|
|
68
|
-
steps:
|
|
69
|
-
composed_contexts: self.class.hydrate_composed_contexts(
|
|
70
|
-
data[:composed_contexts] || {},
|
|
71
|
-
data[:reactor_class]&.to_s
|
|
72
|
-
),
|
|
73
|
+
steps: trace,
|
|
74
|
+
composed_contexts: self.class.hydrate_composed_contexts(data[:composed_contexts] || {}, class_name),
|
|
73
75
|
coordination: CoordinationSerializer.build(
|
|
74
76
|
reactor_class,
|
|
75
77
|
inputs: data[:inputs],
|
|
76
|
-
context_id: data[:context_id]
|
|
78
|
+
context_id: data[:context_id],
|
|
79
|
+
execution_trace: trace,
|
|
80
|
+
private_data: data[:private_data] || {}
|
|
77
81
|
),
|
|
78
82
|
error: data[:failure_reason]
|
|
79
83
|
}
|
|
@@ -294,11 +298,72 @@ module RubyReactor
|
|
|
294
298
|
when "map_ref" then hydrate_map_ref(value, reactor_class_name)
|
|
295
299
|
when "async_step_ref" then hydrate_async_step_ref(value, reactor_class_name)
|
|
296
300
|
when "async_reactor_ref" then hydrate_async_reactor_ref(value)
|
|
301
|
+
when "composed" then hydrate_composed_child(value)
|
|
297
302
|
else value
|
|
298
303
|
end
|
|
299
304
|
end
|
|
300
305
|
end
|
|
301
306
|
|
|
307
|
+
# An inline `compose` child is embedded in the parent's blob, so its own
|
|
308
|
+
# `async_step` links are rebuilt the same way, one level down.
|
|
309
|
+
def self.hydrate_composed_child(value)
|
|
310
|
+
child = value[:context] || value["context"]
|
|
311
|
+
return value unless child.is_a?(RubyReactor::Context)
|
|
312
|
+
|
|
313
|
+
class_name = RubyReactor.reactor_storage_name(child.reactor_class)
|
|
314
|
+
runs = async_step_runs(child.composed_contexts, class_name)
|
|
315
|
+
view = child.to_h.merge(
|
|
316
|
+
execution_trace: with_async_step_runs(child.execution_trace, runs),
|
|
317
|
+
composed_contexts: hydrate_composed_contexts(child.composed_contexts, class_name)
|
|
318
|
+
)
|
|
319
|
+
value.merge(context: view)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Single writer: an `async_step` unit never writes its parent, which
|
|
323
|
+
# holds only the `:async_step_ref` written at dispatch. The unit's run —
|
|
324
|
+
# arguments, attempts, when it started — is on its Step Result Record.
|
|
325
|
+
# `{ step_name => record }` for every linked unit that has started.
|
|
326
|
+
def self.async_step_runs(composed_contexts, reactor_class_name)
|
|
327
|
+
return {} unless composed_contexts.is_a?(Hash)
|
|
328
|
+
|
|
329
|
+
composed_contexts.each_with_object({}) do |(name, ref), runs|
|
|
330
|
+
next unless (ref[:type] || ref["type"]).to_s == "async_step_ref"
|
|
331
|
+
|
|
332
|
+
record = async_step_record(ref, reactor_class_name)
|
|
333
|
+
runs[name] = record if record&.key?("started_at")
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def self.async_step_record(ref_data, reactor_class_name)
|
|
338
|
+
context_id = ref_data[:context_id] || ref_data["context_id"]
|
|
339
|
+
name = ref_data[:name] || ref_data["name"]
|
|
340
|
+
return nil unless context_id && name
|
|
341
|
+
|
|
342
|
+
RubyReactor.configuration.storage_adapter.retrieve_step_result(context_id, name, reactor_class_name)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# The parent's own trace, with each started unit's `:run` entry placed by
|
|
346
|
+
# its start time — the entry a same-process step writes for itself. A
|
|
347
|
+
# unit that already has one (a context saved before units stopped
|
|
348
|
+
# writing their parent) is left as it is.
|
|
349
|
+
def self.with_async_step_runs(trace, runs)
|
|
350
|
+
runs.each_with_object(trace.dup) do |(name, record), merged|
|
|
351
|
+
next if merged.any? { |e| (e[:type] || e["type"]).to_s == "run" && (e[:step] || e["step"]).to_s == name.to_s }
|
|
352
|
+
|
|
353
|
+
started = Time.iso8601(record["started_at"])
|
|
354
|
+
entry = { type: :run, step: name, timestamp: started, background: true,
|
|
355
|
+
arguments: ContextSerializer.deserialize_value(record["arguments"]) }
|
|
356
|
+
later = merged.index { |e| e[:timestamp].is_a?(Time) && e[:timestamp] > started }
|
|
357
|
+
merged.insert(later || merged.size, entry)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def self.with_async_step_attempts(step_attempts, runs)
|
|
362
|
+
runs.each_with_object(step_attempts.dup) do |(name, record), merged|
|
|
363
|
+
merged[name] = record["attempts"] if record["attempts"]
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
302
367
|
# The reference lives on the parent's context; the outcome lives in the
|
|
303
368
|
# Step Result Record. Resolve it so the dashboard can show whether the
|
|
304
369
|
# unit is still dispatched or has landed, mirroring hydrate_map_ref.
|
|
@@ -4,7 +4,7 @@ module RubyReactor
|
|
|
4
4
|
module Web
|
|
5
5
|
class CoordinationSerializer
|
|
6
6
|
class << self
|
|
7
|
-
def build(reactor_class, inputs:, context_id:)
|
|
7
|
+
def build(reactor_class, inputs:, context_id:, execution_trace: [], private_data: {})
|
|
8
8
|
return {} unless reactor_class
|
|
9
9
|
|
|
10
10
|
adapter = RubyReactor.configuration.storage_adapter
|
|
@@ -20,18 +20,122 @@ module RubyReactor
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
if reactor_class.rate_limit_config
|
|
23
|
-
result[:rate_limit] = build_rate_limit(reactor_class.rate_limit_config,
|
|
23
|
+
result[:rate_limit] = build_rate_limit(resolve_rate_limit(reactor_class.rate_limit_config),
|
|
24
|
+
normalized_inputs, adapter)
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
if reactor_class.period_config
|
|
27
28
|
result[:period] = build_period(reactor_class.period_config, normalized_inputs, adapter)
|
|
28
29
|
end
|
|
29
30
|
|
|
31
|
+
if reactor_class.respond_to?(:steps)
|
|
32
|
+
steps = build_steps(reactor_class, normalized_inputs, context_id, execution_trace, adapter)
|
|
33
|
+
result[:steps] = steps unless steps.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
waiting = private_data[:step_contention] || private_data["step_contention"] ||
|
|
37
|
+
async_step_waiting(reactor_class, context_id, adapter)
|
|
38
|
+
result[:waiting] = normalize_waiting(waiting) if waiting
|
|
39
|
+
|
|
30
40
|
result
|
|
31
41
|
end
|
|
32
42
|
|
|
33
43
|
private
|
|
34
44
|
|
|
45
|
+
# US7/FR-029: one row per coordinating step PER PRIMITIVE it declares,
|
|
46
|
+
# keyed to the step's OWN resolved arguments (from its latest `:run`
|
|
47
|
+
# trace entry), not the reactor's inputs. A step declaring both
|
|
48
|
+
# `with_lock` and `with_semaphore` is two rows — both gates are active,
|
|
49
|
+
# so an operator has to be able to see both. A step not yet reached is
|
|
50
|
+
# reported "pending" rather than omitted, so the list is stable.
|
|
51
|
+
def build_steps(reactor_class, inputs, context_id, execution_trace, adapter)
|
|
52
|
+
reactor_class.steps.flat_map do |name, step_config|
|
|
53
|
+
next [] unless step_config.respond_to?(:declares_coordination?) && step_config.declares_coordination?
|
|
54
|
+
|
|
55
|
+
build_step_entries(name, step_config, inputs, context_id, execution_trace, adapter)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_step_entries(name, step_config, inputs, context_id, execution_trace, adapter) # rubocop:disable Metrics/ParameterLists
|
|
60
|
+
entry = latest_run_entry(execution_trace, name)
|
|
61
|
+
declarations = step_config.coordination_declarations
|
|
62
|
+
return [{ step: name.to_s, state: "pending" }] if declarations.empty?
|
|
63
|
+
|
|
64
|
+
# One PENDING row per declared primitive too, matching the reached
|
|
65
|
+
# shape below — a step declaring both a lock and a semaphore has two
|
|
66
|
+
# gates to wait on, and collapsing them to a single primitive-less
|
|
67
|
+
# row hides which.
|
|
68
|
+
if entry.nil?
|
|
69
|
+
return declarations.keys.map do |primitive|
|
|
70
|
+
{ step: name.to_s, primitive: primitive.to_s, state: "pending" }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The trace records the RESOLVED arguments; the execution keyed off
|
|
75
|
+
# `coordination_arguments` of them, so the same function is applied
|
|
76
|
+
# here. A redacted value was never recorded, so a key computed from it
|
|
77
|
+
# would name a different Redis key than the one held — report it as
|
|
78
|
+
# unavailable instead of probing the wrong one.
|
|
79
|
+
traced = entry[:arguments] || entry["arguments"] || {}
|
|
80
|
+
if traced.is_a?(Hash) && traced.value?(RubyReactor::Step::InputContract::REDACTED)
|
|
81
|
+
return declarations.keys.map do |primitive|
|
|
82
|
+
{ step: name.to_s, primitive: primitive.to_s, key: nil,
|
|
83
|
+
key_error: "key unavailable: the step's arguments are redacted" }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
args = step_config.coordination_arguments(traced.transform_keys(&:to_sym), inputs)
|
|
87
|
+
|
|
88
|
+
declarations.map do |primitive, config|
|
|
89
|
+
built = build_step_primitive(primitive, config, args, context_id, adapter)
|
|
90
|
+
{ step: name.to_s, primitive: primitive.to_s }.merge(built)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_step_primitive(primitive, config, args, context_id, adapter)
|
|
95
|
+
case primitive
|
|
96
|
+
when :lock then build_lock(config, args, context_id, adapter)
|
|
97
|
+
when :semaphore then build_semaphore(config, args, adapter)
|
|
98
|
+
when :rate_limit then build_rate_limit(resolve_rate_limit(config), args, adapter)
|
|
99
|
+
when :period then build_period(config, args, adapter)
|
|
100
|
+
else { key: resolve_key(config[:key_proc], args) }
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
{ key: nil, key_error: e.message }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def latest_run_entry(execution_trace, step_name)
|
|
107
|
+
Array(execution_trace).reverse_each.find do |e|
|
|
108
|
+
type = e[:type] || e["type"]
|
|
109
|
+
step = e[:step] || e["step"]
|
|
110
|
+
type.to_s == "run" && step.to_s == step_name.to_s
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A parked `async_step` keeps its "waiting on" marker on its own Step
|
|
115
|
+
# Result Record, never on the parent's context (005 R-09).
|
|
116
|
+
def async_step_waiting(reactor_class, context_id, adapter)
|
|
117
|
+
return nil unless context_id && reactor_class.respond_to?(:steps)
|
|
118
|
+
|
|
119
|
+
namespace = RubyReactor.reactor_storage_name(reactor_class)
|
|
120
|
+
reactor_class.steps.each do |name, step_config|
|
|
121
|
+
next unless step_config.respond_to?(:async_dispatch) && step_config.async_dispatch == :step
|
|
122
|
+
|
|
123
|
+
record = adapter.retrieve_step_result(context_id, name, namespace)
|
|
124
|
+
return record["waiting"] if record.is_a?(Hash) && record["waiting"]
|
|
125
|
+
end
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def normalize_waiting(waiting)
|
|
130
|
+
{
|
|
131
|
+
step: (waiting[:step] || waiting["step"]).to_s,
|
|
132
|
+
key: waiting[:key] || waiting["key"],
|
|
133
|
+
primitive: (waiting[:primitive] || waiting["primitive"]).to_s,
|
|
134
|
+
attempts: waiting[:attempts] || waiting["attempts"],
|
|
135
|
+
next_attempt_at: waiting[:next_attempt_at] || waiting["next_attempt_at"]
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
35
139
|
def normalize_inputs(inputs)
|
|
36
140
|
return {} unless inputs.is_a?(Hash)
|
|
37
141
|
|
|
@@ -134,6 +238,20 @@ module RubyReactor
|
|
|
134
238
|
}
|
|
135
239
|
end
|
|
136
240
|
|
|
241
|
+
# `with_rate_limit(:name)` stores only the name; the renderer needs the
|
|
242
|
+
# registered windows and the name-as-key the limiter actually uses
|
|
243
|
+
# (mirrors `StepCoordination#rate_limit_key_and_limits`).
|
|
244
|
+
def resolve_rate_limit(config)
|
|
245
|
+
return config unless config[:name]
|
|
246
|
+
|
|
247
|
+
name = config[:name]
|
|
248
|
+
{ limits: RubyReactor.configuration.rate_limits.fetch(name), key_proc: ->(_args) { name.to_s } }
|
|
249
|
+
rescue StandardError => e
|
|
250
|
+
# An unregistered name is a config mistake, not a reason for the
|
|
251
|
+
# dashboard to 500 — surface it as this row's key_error.
|
|
252
|
+
{ limits: [], key_proc: ->(_args) { raise e } }
|
|
253
|
+
end
|
|
254
|
+
|
|
137
255
|
def map_limits(limits)
|
|
138
256
|
Array(limits).map do |window|
|
|
139
257
|
{
|