ruby_reactor 0.7.0 → 0.7.1
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 +79 -12
- data/.specify/templates/tasks-template.md +7 -0
- data/CHANGELOG.md +11 -0
- data/CLAUDE.md +2 -2
- data/README.md +24 -9
- data/lib/ruby_reactor/context.rb +7 -0
- data/lib/ruby_reactor/context_serializer.rb +13 -0
- data/lib/ruby_reactor/dsl/lockable.rb +2 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +11 -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 +35 -10
- data/lib/ruby_reactor/executor/retry_manager.rb +4 -1
- data/lib/ruby_reactor/executor/step_executor.rb +19 -13
- data/lib/ruby_reactor/executor.rb +17 -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/open_telemetry.rb +7 -4
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/rspec/matchers.rb +61 -11
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
- data/lib/ruby_reactor/step/map_step.rb +5 -1
- data/lib/ruby_reactor/step.rb +10 -4
- data/lib/ruby_reactor/step_signals.rb +33 -0
- 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/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 +56 -7
- metadata +7 -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
|
@@ -264,18 +264,18 @@ module RubyReactor
|
|
|
264
264
|
RubyReactor.configuration.storage_adapter
|
|
265
265
|
end
|
|
266
266
|
|
|
267
|
-
# Distinguishes `RubyReactor::
|
|
268
|
-
# any object with a `
|
|
267
|
+
# Distinguishes `RubyReactor::Halt` (a clean halt) from a plain
|
|
268
|
+
# `Success`. Works on any object with a `halted?` predicate.
|
|
269
269
|
#
|
|
270
270
|
# Examples:
|
|
271
|
-
# expect(result).to
|
|
272
|
-
# expect(result).to
|
|
273
|
-
# expect(result).to
|
|
274
|
-
::RSpec::Matchers.define :
|
|
271
|
+
# expect(result).to be_halted
|
|
272
|
+
# expect(result).to be_halted.because(:period)
|
|
273
|
+
# expect(result).to be_halted.at_step(:second)
|
|
274
|
+
::RSpec::Matchers.define :be_halted do
|
|
275
275
|
match do |subject|
|
|
276
276
|
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
277
277
|
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
278
|
-
next false unless actual.respond_to?(:
|
|
278
|
+
next false unless actual.respond_to?(:halted?) && actual.halted?
|
|
279
279
|
next false if @expected_reason && actual.reason != @expected_reason
|
|
280
280
|
next false if @expected_step && actual.step_name != @expected_step
|
|
281
281
|
|
|
@@ -292,12 +292,62 @@ module RubyReactor
|
|
|
292
292
|
|
|
293
293
|
failure_message do |subject|
|
|
294
294
|
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
295
|
-
if !actual.respond_to?(:
|
|
296
|
-
"expected result to be
|
|
295
|
+
if !actual.respond_to?(:halted?) || !actual.halted?
|
|
296
|
+
"expected result to be Halt, got #{actual.class}"
|
|
297
297
|
elsif @expected_reason && actual.reason != @expected_reason
|
|
298
|
-
"expected
|
|
298
|
+
"expected Halt reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
|
|
299
299
|
else
|
|
300
|
-
"expected
|
|
300
|
+
"expected Halt at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
failure_message_when_negated do
|
|
305
|
+
"expected result not to be Halt"
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Asserts that a STEP was skipped — either the subject itself is a
|
|
310
|
+
# `RubyReactor::Skipped` result, or the execution trace records a
|
|
311
|
+
# `:skipped` entry (optionally for a specific step via `.at_step`). A
|
|
312
|
+
# halted run never satisfies this — halting and skipping are distinct.
|
|
313
|
+
#
|
|
314
|
+
# Examples:
|
|
315
|
+
# expect(result).to be_skipped
|
|
316
|
+
# expect(subject).to be_skipped.at_step(:maybe_sync)
|
|
317
|
+
::RSpec::Matchers.define :be_skipped do
|
|
318
|
+
match do |subject|
|
|
319
|
+
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
320
|
+
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
321
|
+
next false if actual.respond_to?(:halted?) && actual.halted?
|
|
322
|
+
|
|
323
|
+
entries = skipped_trace_entries(subject)
|
|
324
|
+
next entries.any? { |e| e[:step].to_s == @expected_step.to_s } if @expected_step
|
|
325
|
+
next true if entries.any?
|
|
326
|
+
|
|
327
|
+
actual.respond_to?(:skipped?) && actual.skipped?
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
chain :at_step do |step|
|
|
331
|
+
@expected_step = step
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def skipped_trace_entries(subject)
|
|
335
|
+
trace = if subject.respond_to?(:reactor_instance)
|
|
336
|
+
subject.reactor_instance.context.execution_trace
|
|
337
|
+
elsif subject.respond_to?(:execution_trace)
|
|
338
|
+
subject.execution_trace
|
|
339
|
+
else
|
|
340
|
+
[]
|
|
341
|
+
end
|
|
342
|
+
trace.select { |e| e[:type].to_s == "skipped" }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
failure_message do |subject|
|
|
346
|
+
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
347
|
+
if @expected_step
|
|
348
|
+
"expected a skipped step named #{@expected_step.inspect}, found none in the execution trace"
|
|
349
|
+
else
|
|
350
|
+
"expected result to be Skipped, got #{actual.class}"
|
|
301
351
|
end
|
|
302
352
|
end
|
|
303
353
|
|
|
@@ -300,8 +300,8 @@ module RubyReactor
|
|
|
300
300
|
end
|
|
301
301
|
end
|
|
302
302
|
RubyReactor::Success.new(val)
|
|
303
|
-
when "skipped"
|
|
304
|
-
|
|
303
|
+
when "halted", "skipped" # "skipped" is the legacy name for a halt
|
|
304
|
+
halted_result(ctx)
|
|
305
305
|
when "running"
|
|
306
306
|
# Try to determine if it is truly running or if we just missed the completion
|
|
307
307
|
if @process_jobs && AsyncTestHelpers.active?
|
|
@@ -326,14 +326,14 @@ module RubyReactor
|
|
|
326
326
|
end
|
|
327
327
|
|
|
328
328
|
# A clean halt: either a `with_period` gate or a step returning
|
|
329
|
-
# `RubyReactor.
|
|
330
|
-
#
|
|
329
|
+
# `RubyReactor.Halt(...)`. The sync run already produced the exact
|
|
330
|
+
# Halt (reason/step intact) — surface it. For async runs the worker
|
|
331
331
|
# swallows the return value, so rebuild from the trace.
|
|
332
|
-
def
|
|
333
|
-
return @run_result if @run_result.is_a?(RubyReactor::
|
|
332
|
+
def halted_result(ctx)
|
|
333
|
+
return @run_result if @run_result.is_a?(RubyReactor::Halt)
|
|
334
334
|
|
|
335
|
-
entry = ctx.execution_trace.reverse.find { |t| t[:type].to_s == "
|
|
336
|
-
RubyReactor::
|
|
335
|
+
entry = ctx.execution_trace.reverse.find { |t| t[:type].to_s == "halt" }
|
|
336
|
+
RubyReactor::Halt.new(reason: entry&.dig(:reason), step_name: entry&.dig(:step))
|
|
337
337
|
end
|
|
338
338
|
|
|
339
339
|
def success?
|
|
@@ -64,7 +64,7 @@ module RubyReactor
|
|
|
64
64
|
|
|
65
65
|
def run_inline(arguments, context)
|
|
66
66
|
results = execute_inline_map(arguments, context)
|
|
67
|
-
return results if results.is_a?(RubyReactor::Failure)
|
|
67
|
+
return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
|
|
68
68
|
|
|
69
69
|
process_results(results, arguments[:collect_block], arguments[:fail_fast])
|
|
70
70
|
end
|
|
@@ -76,6 +76,10 @@ module RubyReactor
|
|
|
76
76
|
arguments[:source].each do |element|
|
|
77
77
|
result = execute_single_element(element, arguments, context)
|
|
78
78
|
|
|
79
|
+
# An element-level Halt propagates as a run halt: stop immediately
|
|
80
|
+
# rather than being collected as a (nil) value.
|
|
81
|
+
return result if result.is_a?(RubyReactor::Halt)
|
|
82
|
+
|
|
79
83
|
if fail_fast && result.failure?
|
|
80
84
|
return result # Stop immediately on first failure
|
|
81
85
|
end
|
data/lib/ruby_reactor/step.rb
CHANGED
|
@@ -7,6 +7,8 @@ module RubyReactor
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
module ClassMethods
|
|
10
|
+
include RubyReactor::StepSignals
|
|
11
|
+
|
|
10
12
|
# rubocop:disable Naming/MethodName
|
|
11
13
|
def Success(value = nil)
|
|
12
14
|
RubyReactor::Success(value)
|
|
@@ -16,8 +18,12 @@ module RubyReactor
|
|
|
16
18
|
RubyReactor::Failure(error)
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
def
|
|
20
|
-
RubyReactor.
|
|
21
|
+
def Halt(reason: nil, **kwargs)
|
|
22
|
+
RubyReactor.Halt(reason: reason, **kwargs)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def Skipped(...)
|
|
26
|
+
RubyReactor.Skipped(...)
|
|
21
27
|
end
|
|
22
28
|
# rubocop:enable Naming/MethodName
|
|
23
29
|
|
|
@@ -26,11 +32,11 @@ module RubyReactor
|
|
|
26
32
|
end
|
|
27
33
|
|
|
28
34
|
def compensate(_reason, _arguments, _context)
|
|
29
|
-
RubyReactor.
|
|
35
|
+
RubyReactor.Skipped() # Default: nothing defined, rollback continues
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
def undo(_result, _arguments, _context)
|
|
33
|
-
RubyReactor.
|
|
39
|
+
RubyReactor.Skipped() # Default: nothing defined, rollback continues
|
|
34
40
|
end
|
|
35
41
|
end
|
|
36
42
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
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. The catching `catch(StepSignals::TAG)` lives
|
|
13
|
+
# at each step-body invocation site (step_executor.rb, compensation_manager.rb).
|
|
14
|
+
module StepSignals
|
|
15
|
+
TAG = :ruby_reactor_step_signal
|
|
16
|
+
|
|
17
|
+
def success!(value = nil)
|
|
18
|
+
throw TAG, RubyReactor.Success(value)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def skip!(...)
|
|
22
|
+
throw TAG, RubyReactor.Skipped(...)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fail!(error, **opts)
|
|
26
|
+
throw TAG, RubyReactor.Failure(error, **opts)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def halt!(reason: nil, **kwargs)
|
|
30
|
+
throw TAG, RubyReactor.Halt(reason: reason, **kwargs)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -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
|
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
|