ruby_reactor 0.7.0 → 0.8.0
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/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +92 -15
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +8 -1
- data/CHANGELOG.md +151 -0
- data/CLAUDE.md +2 -2
- data/README.md +149 -40
- data/lib/ruby_reactor/context.rb +9 -2
- data/lib/ruby_reactor/context_serializer.rb +13 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/lockable.rb +2 -2
- data/lib/ruby_reactor/dsl/reactor.rb +36 -18
- data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
- data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
- data/lib/ruby_reactor/executor/result_handler.rb +44 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
- data/lib/ruby_reactor/executor/step_executor.rb +27 -14
- data/lib/ruby_reactor/executor.rb +20 -15
- data/lib/ruby_reactor/map/element_executor.rb +7 -1
- data/lib/ruby_reactor/map/helpers.rb +9 -7
- data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/open_telemetry.rb +7 -4
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +64 -17
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
- data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
- data/lib/ruby_reactor/step/compose_step.rb +56 -75
- data/lib/ruby_reactor/step/input_contract.rb +128 -0
- data/lib/ruby_reactor/step/map_step.rb +178 -215
- data/lib/ruby_reactor/step.rb +119 -18
- data/lib/ruby_reactor/step_signals.rb +37 -0
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/template/result.rb +9 -2
- data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +23 -6
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor.rb +61 -9
- data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
- data/specs/002-step-input-contracts/data-model.md +115 -0
- data/specs/002-step-input-contracts/plan.md +165 -0
- data/specs/002-step-input-contracts/quickstart.md +170 -0
- data/specs/002-step-input-contracts/research.md +233 -0
- data/specs/002-step-input-contracts/spec.md +359 -0
- data/specs/002-step-input-contracts/tasks.md +367 -0
- data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
- data/specs/004-inheritable-step-class/data-model.md +116 -0
- data/specs/004-inheritable-step-class/plan.md +174 -0
- data/specs/004-inheritable-step-class/quickstart.md +112 -0
- data/specs/004-inheritable-step-class/research.md +308 -0
- data/specs/004-inheritable-step-class/spec.md +316 -0
- data/specs/004-inheritable-step-class/tasks.md +258 -0
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
- data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
- data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
- data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
- data/specs/deferred-003-step-lock-declarations/research.md +196 -0
- data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
- data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
- data/specs/possible_feature.md +22 -0
- metadata +34 -11
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
- data/specs/001-background-async-steps/checklists/requirements.md +0 -39
- data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
- data/specs/001-background-async-steps/data-model.md +0 -117
- data/specs/001-background-async-steps/plan.md +0 -168
- data/specs/001-background-async-steps/quickstart.md +0 -102
- data/specs/001-background-async-steps/research.md +0 -150
- data/specs/001-background-async-steps/spec.md +0 -146
- data/specs/001-background-async-steps/tasks.md +0 -271
data/lib/ruby_reactor/step.rb
CHANGED
|
@@ -1,36 +1,137 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RubyReactor
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
# The single inheritable base every class-based step derives from:
|
|
5
|
+
#
|
|
6
|
+
# class MyStep < RubyReactor::Step
|
|
7
|
+
# input :amount, :integer
|
|
8
|
+
# def run = Success(charged: inputs[:amount])
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# Lifecycle of every class-level call (`.run`/`.call`, `.undo`, `.compensate`):
|
|
12
|
+
#
|
|
13
|
+
# 1. Resolve `inputs`: the given arguments with the contract's defaults
|
|
14
|
+
# applied. `run`, `undo`, and `compensate` all see the same values.
|
|
15
|
+
# 2. `.run` ONLY: enforce the declared input contract, raising
|
|
16
|
+
# `Error::InputValidationError` before any instance exists. `.undo` and
|
|
17
|
+
# `.compensate` NEVER enforce it: rollback must not fail on the very
|
|
18
|
+
# inputs that may have caused the failure.
|
|
19
|
+
# 3. Build a FRESH instance, never reused across actions. An ivar set in
|
|
20
|
+
# `run` is gone by the time `undo` runs on its own instance, so async
|
|
21
|
+
# execution running `run` and `undo` in different processes behaves
|
|
22
|
+
# identically to running both in one.
|
|
23
|
+
# 4. Invoke the matching instance method, translating any `StepSignals`
|
|
24
|
+
# throw (`success!`/`skip!`/`fail!`/`halt!`) into its result wrapper.
|
|
25
|
+
#
|
|
26
|
+
# No `prepend`/`extend`/`define_method`/`method_missing` — every step in the
|
|
27
|
+
# class reads top to bottom as ordinary method calls.
|
|
28
|
+
class Step
|
|
29
|
+
include RubyReactor::StepSignals
|
|
30
|
+
|
|
31
|
+
attr_reader :inputs, :context, :result, :reason
|
|
32
|
+
|
|
33
|
+
def initialize(inputs, context, result: nil, reason: nil)
|
|
34
|
+
@inputs = inputs
|
|
35
|
+
@context = context
|
|
36
|
+
@result = result
|
|
37
|
+
@reason = reason
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
raise NotImplementedError, "#{self.class} must implement #run"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def undo
|
|
45
|
+
RubyReactor.Skipped()
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def compensate
|
|
49
|
+
RubyReactor.Skipped()
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# rubocop:disable Naming/MethodName
|
|
53
|
+
def Success(value = nil)
|
|
54
|
+
RubyReactor.Success(value)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def Failure(...)
|
|
58
|
+
RubyReactor.Failure(...)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def Halt(reason: nil, **kwargs)
|
|
62
|
+
RubyReactor.Halt(reason: reason, **kwargs)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def Skipped(...)
|
|
66
|
+
RubyReactor.Skipped(...)
|
|
7
67
|
end
|
|
68
|
+
# rubocop:enable Naming/MethodName
|
|
8
69
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
70
|
+
class << self
|
|
71
|
+
def run(arguments, context)
|
|
72
|
+
validated = enforce_contract!(arguments)
|
|
73
|
+
catch(StepSignals::TAG) { new(validated, context).run }
|
|
13
74
|
end
|
|
75
|
+
alias call run
|
|
14
76
|
|
|
15
|
-
|
|
16
|
-
|
|
77
|
+
# Same `inputs` as `.run` (defaults applied), but NEVER enforces the contract.
|
|
78
|
+
def undo(result, arguments, context)
|
|
79
|
+
catch(StepSignals::TAG) { new(with_defaults(arguments), context, result: result).undo }
|
|
17
80
|
end
|
|
18
81
|
|
|
19
|
-
|
|
20
|
-
|
|
82
|
+
# Same `inputs` as `.run` (defaults applied), but NEVER enforces the contract.
|
|
83
|
+
def compensate(reason, arguments, context)
|
|
84
|
+
catch(StepSignals::TAG) { new(with_defaults(arguments), context, reason: reason).compensate }
|
|
21
85
|
end
|
|
22
|
-
# rubocop:enable Naming/MethodName
|
|
23
86
|
|
|
24
|
-
def
|
|
25
|
-
|
|
87
|
+
def input(...)
|
|
88
|
+
own_input_contract.input(...)
|
|
89
|
+
@input_contract = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_inputs(...)
|
|
93
|
+
own_input_contract.validate_inputs(...)
|
|
94
|
+
@input_contract = nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def input_contract
|
|
98
|
+
@input_contract ||=
|
|
99
|
+
if superclass.respond_to?(:declares_inputs?) && superclass.declares_inputs?
|
|
100
|
+
superclass.input_contract.merge(own_input_contract)
|
|
101
|
+
else
|
|
102
|
+
own_input_contract
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def declared_inputs
|
|
107
|
+
input_contract.declarations
|
|
26
108
|
end
|
|
27
109
|
|
|
28
|
-
def
|
|
29
|
-
|
|
110
|
+
def required_input_names
|
|
111
|
+
input_contract.required_names
|
|
30
112
|
end
|
|
31
113
|
|
|
32
|
-
def
|
|
33
|
-
|
|
114
|
+
def declares_inputs?
|
|
115
|
+
!input_contract.empty?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def own_input_contract
|
|
121
|
+
@own_input_contract ||= Step::InputContract.new(owner: self)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def with_defaults(arguments)
|
|
125
|
+
input_contract.apply_defaults(arguments)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def enforce_contract!(arguments)
|
|
129
|
+
return arguments unless declares_inputs?
|
|
130
|
+
|
|
131
|
+
input_contract.enforce!(arguments)
|
|
132
|
+
rescue Error::InputValidationError => e
|
|
133
|
+
e.step_name = name
|
|
134
|
+
raise
|
|
34
135
|
end
|
|
35
136
|
end
|
|
36
137
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
# `success!` / `skip!` / `fail!` / `halt!` — end a step body immediately
|
|
5
|
+
# with the matching signal, from any call depth. Mixed into both authoring
|
|
6
|
+
# surfaces (class steps and inline `run` blocks) so the helpers behave
|
|
7
|
+
# identically in either style (contracts/step-helpers.md).
|
|
8
|
+
#
|
|
9
|
+
# Implemented as throw/catch rather than an exception: a `throw` passes
|
|
10
|
+
# straight through `rescue Exception` while `ensure` blocks still run
|
|
11
|
+
# (verified in research.md R2), so a step's own broad rescue cannot swallow
|
|
12
|
+
# the author's intended outcome. For a class-based step, the catching
|
|
13
|
+
# `catch(StepSignals::TAG)` lives on RubyReactor::Step's own class-level
|
|
14
|
+
# `run`/`undo`/`compensate` (so every caller — the executor, the async
|
|
15
|
+
# worker, a direct call — gets identical translation for free); for an
|
|
16
|
+
# inline `run_block`/`compensate_block`/`undo_block` step, it lives at the
|
|
17
|
+
# invocation site in step_executor.rb / compensation_manager.rb.
|
|
18
|
+
module StepSignals
|
|
19
|
+
TAG = :ruby_reactor_step_signal
|
|
20
|
+
|
|
21
|
+
def success!(value = nil)
|
|
22
|
+
throw TAG, RubyReactor.Success(value)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def skip!(...)
|
|
26
|
+
throw TAG, RubyReactor.Skipped(...)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fail!(error, **opts)
|
|
30
|
+
throw TAG, RubyReactor.Failure(error, **opts)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def halt!(reason: nil, **kwargs)
|
|
34
|
+
throw TAG, RubyReactor.Halt(reason: reason, **kwargs)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -56,6 +56,9 @@ module RubyReactor
|
|
|
56
56
|
context = load_step_context
|
|
57
57
|
return record_missing_parent unless context
|
|
58
58
|
|
|
59
|
+
# A fresh worker process has never run the reactor, so the inferred
|
|
60
|
+
# wiring for name-resolved inputs does not exist here yet.
|
|
61
|
+
context.reactor_class&.validate_definition!
|
|
59
62
|
step_config = context.reactor_class&.steps&.[](@step_name)
|
|
60
63
|
return record_missing_step unless step_config
|
|
61
64
|
|
|
@@ -113,6 +116,7 @@ module RubyReactor
|
|
|
113
116
|
result =
|
|
114
117
|
if step_config.has_run_block?
|
|
115
118
|
args = arguments.empty? ? context.inputs : arguments
|
|
119
|
+
args = step_config.inline_contract.enforce!(args) if step_config.inline_contract
|
|
116
120
|
step_config.run_block.call(args, context)
|
|
117
121
|
elsif step_config.has_impl?
|
|
118
122
|
step_config.impl.run(arguments, context)
|
|
@@ -121,6 +125,12 @@ module RubyReactor
|
|
|
121
125
|
end
|
|
122
126
|
|
|
123
127
|
normalize(result)
|
|
128
|
+
rescue Error::InputValidationError => e
|
|
129
|
+
# Same shape the executor builds, and never retried: the same arguments
|
|
130
|
+
# fail the same contract on every attempt.
|
|
131
|
+
RubyReactor.Failure(e, validation_errors: e.field_errors, step_name: @step_name,
|
|
132
|
+
step_arguments: e.step_arguments || {}, reactor_name: @reactor_class_name,
|
|
133
|
+
retryable: false)
|
|
124
134
|
rescue StandardError => e
|
|
125
135
|
RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name)
|
|
126
136
|
end
|
|
@@ -158,16 +168,21 @@ module RubyReactor
|
|
|
158
168
|
# Write first, publish second. The record is the answer; the signal only
|
|
159
169
|
# saves the reader a fallback interval.
|
|
160
170
|
def complete(result, context)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
+
record = {
|
|
172
|
+
"status" => "completed",
|
|
173
|
+
"success" => result.success?,
|
|
174
|
+
"result" => ContextSerializer.serialize_value(result.success? ? result.value : result.to_h),
|
|
175
|
+
"completed_at" => Time.now.iso8601
|
|
176
|
+
}
|
|
177
|
+
# A `halt!` reports success? == true but carries no value, so without
|
|
178
|
+
# this the reader cannot tell it from an ordinary success returning nil.
|
|
179
|
+
# `skip!` needs nothing extra: Skipped keeps its value, and the reader is
|
|
180
|
+
# meant to see that value exactly as a same-process step would.
|
|
181
|
+
if result.is_a?(RubyReactor::Halt)
|
|
182
|
+
record["signal"] = "halt"
|
|
183
|
+
record["reason"] = result.reason
|
|
184
|
+
end
|
|
185
|
+
storage.store_step_result(@step_context_id, @step_name, record, @reactor_class_name)
|
|
171
186
|
log(result.success? ? :info : :warn, result.success? ? "completed" : "completed_with_failure")
|
|
172
187
|
storage.publish(RubyReactor.async_step_channel(@step_context_id, @step_name), "done")
|
|
173
188
|
result
|
|
@@ -88,6 +88,10 @@ module RubyReactor
|
|
|
88
88
|
raise NotImplementedError
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
def scan_reactors_page(pattern: "*", cursor: "0", count: 50, include_dispatched_children: false)
|
|
92
|
+
raise NotImplementedError
|
|
93
|
+
end
|
|
94
|
+
|
|
91
95
|
def find_context_by_id(context_id)
|
|
92
96
|
raise NotImplementedError
|
|
93
97
|
end
|
|
@@ -10,6 +10,7 @@ module RubyReactor
|
|
|
10
10
|
include RedisOrderedLocking
|
|
11
11
|
include RedisStepResults
|
|
12
12
|
include RedisPubSub
|
|
13
|
+
include RedisReactorScan
|
|
13
14
|
|
|
14
15
|
def initialize(redis_config)
|
|
15
16
|
super()
|
|
@@ -181,33 +182,6 @@ module RubyReactor
|
|
|
181
182
|
@redis.expire(key, seconds)
|
|
182
183
|
end
|
|
183
184
|
|
|
184
|
-
# New methods for API
|
|
185
|
-
def scan_reactors(pattern: "reactor:*:context:*", count: 50, include_dispatched_children: false)
|
|
186
|
-
# Use SCAN to find keys matching the pattern
|
|
187
|
-
results = []
|
|
188
|
-
batch_keys = []
|
|
189
|
-
|
|
190
|
-
# scan_each yields keys. We buffer them to use MGET efficiently.
|
|
191
|
-
# We request a batch size from Redis (count: 100) to reduce roundtrips.
|
|
192
|
-
@redis.scan_each(match: pattern, count: 100) do |key|
|
|
193
|
-
batch_keys << key
|
|
194
|
-
|
|
195
|
-
# specific batch size for MGET processing
|
|
196
|
-
if batch_keys.size >= 50
|
|
197
|
-
results.concat(fetch_and_filter_reactors(batch_keys, include_dispatched_children))
|
|
198
|
-
batch_keys = []
|
|
199
|
-
|
|
200
|
-
# Stop if we have enough results
|
|
201
|
-
return results.take(count) if results.size >= count
|
|
202
|
-
end
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
# Process remaining keys
|
|
206
|
-
results.concat(fetch_and_filter_reactors(batch_keys, include_dispatched_children)) if batch_keys.any?
|
|
207
|
-
|
|
208
|
-
results.take(count)
|
|
209
|
-
end
|
|
210
|
-
|
|
211
185
|
def find_context_by_id(context_id)
|
|
212
186
|
# We don't know the reactor class, so we search for the ID
|
|
213
187
|
pattern = "reactor:*:context:#{context_id}"
|
|
@@ -225,23 +199,6 @@ module RubyReactor
|
|
|
225
199
|
JSON.parse(json)
|
|
226
200
|
end
|
|
227
201
|
|
|
228
|
-
def determine_status(data)
|
|
229
|
-
status = data["status"].to_s
|
|
230
|
-
return status if status && %w[failed paused completed running skipped pending].include?(status)
|
|
231
|
-
return "cancelled" if data["cancelled"]
|
|
232
|
-
# Heuristic
|
|
233
|
-
return "failed" if data["retry_count"]&.positive? && !data["current_step"].nil?
|
|
234
|
-
return "running" if data["current_step"]
|
|
235
|
-
return "completed" if execution_evidence?(data)
|
|
236
|
-
|
|
237
|
-
"pending"
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def execution_evidence?(data)
|
|
241
|
-
(data["execution_trace"] || []).any? ||
|
|
242
|
-
(data["intermediate_results"] || {}).any?
|
|
243
|
-
end
|
|
244
|
-
|
|
245
202
|
def store_map_element_context_id(map_id, context_id, reactor_class_name)
|
|
246
203
|
key = map_element_contexts_key(map_id, reactor_class_name)
|
|
247
204
|
@redis.rpush(key, context_id)
|
|
@@ -315,34 +272,6 @@ module RubyReactor
|
|
|
315
272
|
RubyReactor.configuration.context_ttl
|
|
316
273
|
end
|
|
317
274
|
|
|
318
|
-
def fetch_and_filter_reactors(keys, include_dispatched_children = false)
|
|
319
|
-
return [] if keys.empty?
|
|
320
|
-
|
|
321
|
-
json_results = @redis.mget(*keys)
|
|
322
|
-
|
|
323
|
-
json_results.compact.map do |json|
|
|
324
|
-
data = JSON.parse(json)
|
|
325
|
-
next if data["parent_context_id"] && !(include_dispatched_children && dispatched_child?(data))
|
|
326
|
-
# Skip non-context records (e.g. async_step Step Result Records) whose
|
|
327
|
-
# keys are a "reactor:*:context:*" substring match on the SCAN glob
|
|
328
|
-
# (context:#{id}:step_result:#{name}) but aren't a reactor context.
|
|
329
|
-
next unless data["reactor_class"]
|
|
330
|
-
|
|
331
|
-
{
|
|
332
|
-
id: data["context_id"],
|
|
333
|
-
class: data["reactor_class"],
|
|
334
|
-
status: determine_status(data),
|
|
335
|
-
created_at: data["started_at"],
|
|
336
|
-
failure: data["failure_reason"]
|
|
337
|
-
}
|
|
338
|
-
end.compact
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
# An `async_reactor` child owns its own job, so a lost job strands it like
|
|
342
|
-
# a top-level reactor. Compose children (inline) and map elements
|
|
343
|
-
# (Map::Sweeper's) carry no marker, so neither is swept.
|
|
344
|
-
def dispatched_child?(data) = data.dig("private_data", "async_dispatched")
|
|
345
|
-
|
|
346
275
|
def context_key(context_id, reactor_class_name)
|
|
347
276
|
"reactor:#{reactor_class_name}:context:#{context_id}"
|
|
348
277
|
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Storage
|
|
5
|
+
# Listing/scanning reactor contexts for the dashboard/API: a capped
|
|
6
|
+
# single-shot scan (`scan_reactors`, used by the sweeper) and a
|
|
7
|
+
# cursor-paginated variant (`scan_reactors_page`, used by `GET /reactors`
|
|
8
|
+
# so a client can page through a large result set in batches instead of
|
|
9
|
+
# one capped call).
|
|
10
|
+
module RedisReactorScan
|
|
11
|
+
def scan_reactors(pattern: "reactor:*:context:*", count: 50, include_dispatched_children: false)
|
|
12
|
+
# Use SCAN to find keys matching the pattern
|
|
13
|
+
results = []
|
|
14
|
+
batch_keys = []
|
|
15
|
+
|
|
16
|
+
# scan_each yields keys. We buffer them to use MGET efficiently.
|
|
17
|
+
# We request a batch size from Redis (count: 100) to reduce roundtrips.
|
|
18
|
+
@redis.scan_each(match: pattern, count: 100) do |key|
|
|
19
|
+
batch_keys << key
|
|
20
|
+
|
|
21
|
+
# specific batch size for MGET processing
|
|
22
|
+
if batch_keys.size >= 50
|
|
23
|
+
results.concat(fetch_and_filter_reactors(batch_keys, include_dispatched_children))
|
|
24
|
+
batch_keys = []
|
|
25
|
+
|
|
26
|
+
# Stop if we have enough results
|
|
27
|
+
return results.take(count) if results.size >= count
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Process remaining keys
|
|
32
|
+
results.concat(fetch_and_filter_reactors(batch_keys, include_dispatched_children)) if batch_keys.any?
|
|
33
|
+
|
|
34
|
+
results.take(count)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# `cursor` is an offset into a freshly re-sorted full key scan ("0" for
|
|
38
|
+
# the first page); the returned `cursor` is "0" once there is nothing
|
|
39
|
+
# left to fetch.
|
|
40
|
+
#
|
|
41
|
+
# Redis's own SCAN cursor can't be windowed to exactly `count` items:
|
|
42
|
+
# one `SCAN` call is free to return far more matches than its `count`
|
|
43
|
+
# hint once the keyspace is small (as it is here), so slicing that
|
|
44
|
+
# single batch down to `count` and reporting the raw cursor as the next
|
|
45
|
+
# page silently drops the overflow — a real bug this replaced. Instead
|
|
46
|
+
# we scan the full matching keyspace every call (cheap at dashboard
|
|
47
|
+
# scale), sort it for a stable order across calls, and slice a plain
|
|
48
|
+
# offset window out of it. A page can come back shorter than `count`
|
|
49
|
+
# when some keys in its window get filtered out by
|
|
50
|
+
# `fetch_and_filter_reactors` (dispatched children, non-context keys)
|
|
51
|
+
# — the cursor still advances correctly since it tracks raw key
|
|
52
|
+
# position, not filtered result count.
|
|
53
|
+
def scan_reactors_page(pattern: "reactor:*:context:*", cursor: "0", count: 50, include_dispatched_children: false)
|
|
54
|
+
offset = cursor.to_i
|
|
55
|
+
offset = 0 if offset.negative?
|
|
56
|
+
|
|
57
|
+
all_keys = []
|
|
58
|
+
@redis.scan_each(match: pattern, count: 100) { |key| all_keys << key }
|
|
59
|
+
all_keys.sort!
|
|
60
|
+
|
|
61
|
+
window = all_keys[offset, count] || []
|
|
62
|
+
next_offset = offset + window.size
|
|
63
|
+
next_cursor = next_offset < all_keys.size ? next_offset.to_s : "0"
|
|
64
|
+
|
|
65
|
+
{ reactors: fetch_and_filter_reactors(window, include_dispatched_children), cursor: next_cursor }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def determine_status(data)
|
|
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)
|
|
71
|
+
return "cancelled" if data["cancelled"]
|
|
72
|
+
# Heuristic
|
|
73
|
+
return "failed" if data["retry_count"]&.positive? && !data["current_step"].nil?
|
|
74
|
+
return "running" if data["current_step"]
|
|
75
|
+
return "completed" if execution_evidence?(data)
|
|
76
|
+
|
|
77
|
+
"pending"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def execution_evidence?(data)
|
|
81
|
+
(data["execution_trace"] || []).any? ||
|
|
82
|
+
(data["intermediate_results"] || {}).any?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def fetch_and_filter_reactors(keys, include_dispatched_children = false)
|
|
88
|
+
return [] if keys.empty?
|
|
89
|
+
|
|
90
|
+
json_results = @redis.mget(*keys)
|
|
91
|
+
|
|
92
|
+
json_results.compact.map do |json|
|
|
93
|
+
data = JSON.parse(json)
|
|
94
|
+
next if data["parent_context_id"] && !(include_dispatched_children && dispatched_child?(data))
|
|
95
|
+
# Skip non-context records (e.g. async_step Step Result Records) whose
|
|
96
|
+
# keys are a "reactor:*:context:*" substring match on the SCAN glob
|
|
97
|
+
# (context:#{id}:step_result:#{name}) but aren't a reactor context.
|
|
98
|
+
next unless data["reactor_class"]
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
id: data["context_id"],
|
|
102
|
+
class: data["reactor_class"],
|
|
103
|
+
status: determine_status(data),
|
|
104
|
+
created_at: data["started_at"],
|
|
105
|
+
failure: data["failure_reason"]
|
|
106
|
+
}
|
|
107
|
+
end.compact
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# An `async_reactor` child owns its own job, so a lost job strands it like
|
|
111
|
+
# a top-level reactor. Compose children (inline) and map elements
|
|
112
|
+
# (Map::Sweeper's) carry no marker, so neither is swept.
|
|
113
|
+
def dispatched_child?(data) = data.dig("private_data", "async_dispatched")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -76,7 +76,14 @@ module RubyReactor
|
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
value = ContextSerializer.deserialize_value(fetch(record, :result))
|
|
79
|
-
|
|
79
|
+
if fetch(record, :success)
|
|
80
|
+
# Same rule as Failure below: a halt has no same-process equivalent
|
|
81
|
+
# for a reader to mirror, so hand over the signal itself.
|
|
82
|
+
return RubyReactor.Halt(reason: fetch(record, :reason), step_name: @step_name) if
|
|
83
|
+
fetch(record, :signal).to_s == "halt"
|
|
84
|
+
|
|
85
|
+
return value
|
|
86
|
+
end
|
|
80
87
|
|
|
81
88
|
RubyReactor::Failure.new(value)
|
|
82
89
|
end
|
|
@@ -175,7 +182,7 @@ module RubyReactor
|
|
|
175
182
|
|
|
176
183
|
# Records round-trip through JSON, so a key may come back as a string.
|
|
177
184
|
def fetch(hash, key)
|
|
178
|
-
hash
|
|
185
|
+
Utils::FetchIndifferent.call(hash, key)
|
|
179
186
|
end
|
|
180
187
|
|
|
181
188
|
def extract_path(value, path)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Utils
|
|
5
|
+
# Presence-aware symbol/string lookup: a supplied `false` is returned as
|
|
6
|
+
# `false`, never swallowed by an `a || b` fallback into `nil`.
|
|
7
|
+
class FetchIndifferent
|
|
8
|
+
def self.call(hash, key)
|
|
9
|
+
hash.key?(key.to_sym) ? hash[key.to_sym] : hash[key.to_s]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/ruby_reactor/version.rb
CHANGED
data/lib/ruby_reactor/web/api.rb
CHANGED
|
@@ -5,7 +5,7 @@ require_relative "coordination_serializer"
|
|
|
5
5
|
|
|
6
6
|
module RubyReactor
|
|
7
7
|
module Web
|
|
8
|
-
# rubocop:disable Metrics/BlockLength
|
|
8
|
+
# rubocop:disable Metrics/BlockLength, Metrics/ClassLength
|
|
9
9
|
class API < Roda
|
|
10
10
|
plugin :json
|
|
11
11
|
plugin :all_verbs
|
|
@@ -13,9 +13,19 @@ module RubyReactor
|
|
|
13
13
|
route do |r|
|
|
14
14
|
r.on "reactors" do
|
|
15
15
|
r.is do
|
|
16
|
-
# GET /api/reactors
|
|
16
|
+
# GET /api/reactors?limit=N&cursor=C (default limit 50, capped at 500).
|
|
17
|
+
# Body stays a bare array (existing clients keep working unchanged);
|
|
18
|
+
# the next page's cursor rides in the X-Next-Cursor header ("0" means
|
|
19
|
+
# there is no next page). A client that wants every reactor pages
|
|
20
|
+
# through by re-requesting with ?cursor=<X-Next-Cursor> until it gets "0".
|
|
17
21
|
r.get do
|
|
18
|
-
|
|
22
|
+
limit = self.class.scan_limit(r.params["limit"])
|
|
23
|
+
cursor = r.params["cursor"] || "0"
|
|
24
|
+
|
|
25
|
+
adapter = RubyReactor::Configuration.instance.storage_adapter
|
|
26
|
+
page = adapter.scan_reactors_page(cursor: cursor, count: limit)
|
|
27
|
+
response.headers["X-Next-Cursor"] = page[:cursor]
|
|
28
|
+
page[:reactors]
|
|
19
29
|
rescue StandardError => e
|
|
20
30
|
response.status = 500
|
|
21
31
|
{ error: e.message, backtrace: e.backtrace.first(5) }
|
|
@@ -150,9 +160,16 @@ module RubyReactor
|
|
|
150
160
|
end
|
|
151
161
|
end
|
|
152
162
|
|
|
163
|
+
def self.scan_limit(raw)
|
|
164
|
+
limit = raw.to_i
|
|
165
|
+
return 50 unless limit.positive?
|
|
166
|
+
|
|
167
|
+
[limit, 500].min
|
|
168
|
+
end
|
|
169
|
+
|
|
153
170
|
def self.reactor_status(data)
|
|
154
|
-
status = data[:status].to_s
|
|
155
|
-
return status if %w[failed paused completed running
|
|
171
|
+
status = data[:status].to_s == "skipped" ? "halted" : data[:status].to_s
|
|
172
|
+
return status if %w[failed paused completed running halted pending].include?(status)
|
|
156
173
|
return "cancelled" if data[:cancelled]
|
|
157
174
|
return "running" if data[:current_step]
|
|
158
175
|
return "completed" if execution_evidence?(data)
|
|
@@ -347,6 +364,6 @@ module RubyReactor
|
|
|
347
364
|
}
|
|
348
365
|
end
|
|
349
366
|
end
|
|
350
|
-
# rubocop:enable Metrics/BlockLength
|
|
367
|
+
# rubocop:enable Metrics/BlockLength, Metrics/ClassLength
|
|
351
368
|
end
|
|
352
369
|
end
|