ruby_reactor 0.6.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 +125 -1
- data/CLAUDE.md +5 -0
- data/README.md +155 -25
- data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
- data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
- data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
- data/lib/ruby_reactor/async_waiter.rb +113 -0
- data/lib/ruby_reactor/configuration.rb +30 -1
- data/lib/ruby_reactor/context.rb +28 -0
- data/lib/ruby_reactor/context_serializer.rb +15 -0
- data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
- data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
- data/lib/ruby_reactor/dsl/lockable.rb +3 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
- data/lib/ruby_reactor/dsl/reactor.rb +29 -10
- data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
- data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
- data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
- data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
- data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +11 -11
- data/lib/ruby_reactor/executor/result_handler.rb +47 -11
- data/lib/ruby_reactor/executor/retry_manager.rb +13 -6
- data/lib/ruby_reactor/executor/step_executor.rb +77 -24
- data/lib/ruby_reactor/executor.rb +179 -23
- data/lib/ruby_reactor/lock.rb +19 -0
- data/lib/ruby_reactor/map/element_executor.rb +13 -2
- data/lib/ruby_reactor/map/helpers.rb +10 -8
- data/lib/ruby_reactor/map/result_enumerator.rb +7 -1
- data/lib/ruby_reactor/map/result_summary.rb +63 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/open_telemetry.rb +8 -5
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +25 -2
- data/lib/ruby_reactor/rspec/matchers.rb +61 -11
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
- data/lib/ruby_reactor/rspec/test_subject.rb +74 -19
- data/lib/ruby_reactor/semaphore.rb +10 -0
- data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
- data/lib/ruby_reactor/step/compose_step.rb +1 -1
- data/lib/ruby_reactor/step/map_step.rb +6 -2
- data/lib/ruby_reactor/step.rb +10 -4
- data/lib/ruby_reactor/step_signals.rb +33 -0
- data/lib/ruby_reactor/step_sweeper.rb +72 -0
- data/lib/ruby_reactor/step_worker.rb +260 -0
- data/lib/ruby_reactor/storage/adapter.rb +26 -1
- data/lib/ruby_reactor/storage/redis_adapter.rb +4 -71
- data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
- data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
- data/lib/ruby_reactor/sweeper.rb +7 -1
- data/lib/ruby_reactor/sweeper_job.rb +1 -0
- data/lib/ruby_reactor/template/result.rb +151 -5
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +117 -22
- 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/worker.rb +53 -3
- data/lib/ruby_reactor.rb +80 -10
- data/specs/active_job.md +1 -1
- metadata +23 -3
- data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
|
@@ -8,9 +8,13 @@ module RubyReactor
|
|
|
8
8
|
class RedisAdapter < Adapter
|
|
9
9
|
include RedisLocking
|
|
10
10
|
include RedisOrderedLocking
|
|
11
|
+
include RedisStepResults
|
|
12
|
+
include RedisPubSub
|
|
13
|
+
include RedisReactorScan
|
|
11
14
|
|
|
12
15
|
def initialize(redis_config)
|
|
13
16
|
super()
|
|
17
|
+
@redis_config = redis_config
|
|
14
18
|
@redis = Redis.new(redis_config)
|
|
15
19
|
end
|
|
16
20
|
|
|
@@ -174,45 +178,10 @@ module RubyReactor
|
|
|
174
178
|
@redis.del(key)
|
|
175
179
|
end
|
|
176
180
|
|
|
177
|
-
def subscribe(channel, &block)
|
|
178
|
-
@redis.subscribe(channel, &block)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def publish(channel, message)
|
|
182
|
-
@redis.publish(channel, message)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
181
|
def expire(key, seconds)
|
|
186
182
|
@redis.expire(key, seconds)
|
|
187
183
|
end
|
|
188
184
|
|
|
189
|
-
# New methods for API
|
|
190
|
-
def scan_reactors(pattern: "reactor:*:context:*", count: 50)
|
|
191
|
-
# Use SCAN to find keys matching the pattern
|
|
192
|
-
results = []
|
|
193
|
-
batch_keys = []
|
|
194
|
-
|
|
195
|
-
# scan_each yields keys. We buffer them to use MGET efficiently.
|
|
196
|
-
# We request a batch size from Redis (count: 100) to reduce roundtrips.
|
|
197
|
-
@redis.scan_each(match: pattern, count: 100) do |key|
|
|
198
|
-
batch_keys << key
|
|
199
|
-
|
|
200
|
-
# specific batch size for MGET processing
|
|
201
|
-
if batch_keys.size >= 50
|
|
202
|
-
results.concat(fetch_and_filter_reactors(batch_keys))
|
|
203
|
-
batch_keys = []
|
|
204
|
-
|
|
205
|
-
# Stop if we have enough results
|
|
206
|
-
return results.take(count) if results.size >= count
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
# Process remaining keys
|
|
211
|
-
results.concat(fetch_and_filter_reactors(batch_keys)) if batch_keys.any?
|
|
212
|
-
|
|
213
|
-
results.take(count)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
185
|
def find_context_by_id(context_id)
|
|
217
186
|
# We don't know the reactor class, so we search for the ID
|
|
218
187
|
pattern = "reactor:*:context:#{context_id}"
|
|
@@ -230,23 +199,6 @@ module RubyReactor
|
|
|
230
199
|
JSON.parse(json)
|
|
231
200
|
end
|
|
232
201
|
|
|
233
|
-
def determine_status(data)
|
|
234
|
-
status = data["status"].to_s
|
|
235
|
-
return status if status && %w[failed paused completed running skipped pending].include?(status)
|
|
236
|
-
return "cancelled" if data["cancelled"]
|
|
237
|
-
# Heuristic
|
|
238
|
-
return "failed" if data["retry_count"]&.positive? && !data["current_step"].nil?
|
|
239
|
-
return "running" if data["current_step"]
|
|
240
|
-
return "completed" if execution_evidence?(data)
|
|
241
|
-
|
|
242
|
-
"pending"
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def execution_evidence?(data)
|
|
246
|
-
(data["execution_trace"] || []).any? ||
|
|
247
|
-
(data["intermediate_results"] || {}).any?
|
|
248
|
-
end
|
|
249
|
-
|
|
250
202
|
def store_map_element_context_id(map_id, context_id, reactor_class_name)
|
|
251
203
|
key = map_element_contexts_key(map_id, reactor_class_name)
|
|
252
204
|
@redis.rpush(key, context_id)
|
|
@@ -320,25 +272,6 @@ module RubyReactor
|
|
|
320
272
|
RubyReactor.configuration.context_ttl
|
|
321
273
|
end
|
|
322
274
|
|
|
323
|
-
def fetch_and_filter_reactors(keys)
|
|
324
|
-
return [] if keys.empty?
|
|
325
|
-
|
|
326
|
-
json_results = @redis.mget(*keys)
|
|
327
|
-
|
|
328
|
-
json_results.compact.map do |json|
|
|
329
|
-
data = JSON.parse(json)
|
|
330
|
-
next if data["parent_context_id"] # Skip nested reactors
|
|
331
|
-
|
|
332
|
-
{
|
|
333
|
-
id: data["context_id"],
|
|
334
|
-
class: data["reactor_class"],
|
|
335
|
-
status: determine_status(data),
|
|
336
|
-
created_at: data["started_at"],
|
|
337
|
-
failure: data["failure_reason"]
|
|
338
|
-
}
|
|
339
|
-
end.compact
|
|
340
|
-
end
|
|
341
|
-
|
|
342
275
|
def context_key(context_id, reactor_class_name)
|
|
343
276
|
"reactor:#{reactor_class_name}:context:#{context_id}"
|
|
344
277
|
end
|
|
@@ -123,6 +123,13 @@ module RubyReactor
|
|
|
123
123
|
@redis.del("#{key}:init")
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
# Whether a token is still checked out — used to re-adopt a slot held
|
|
127
|
+
# across a parked async wait.
|
|
128
|
+
def semaphore_held(key, token)
|
|
129
|
+
@redis.sismember("#{key}:held", token)
|
|
130
|
+
end
|
|
131
|
+
alias semaphore_held? semaphore_held
|
|
132
|
+
|
|
126
133
|
def semaphore_acquire(key, timeout: 0)
|
|
127
134
|
held_key = "#{key}:held"
|
|
128
135
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Storage
|
|
5
|
+
# The completion-signal channel behind the notified wait. Pure latency
|
|
6
|
+
# optimisation: at-most-once, unpersisted, never load-bearing — every waiting
|
|
7
|
+
# path ends at a durable record, so a lost signal costs a fallback interval
|
|
8
|
+
# and never correctness.
|
|
9
|
+
module RedisPubSub
|
|
10
|
+
# SUBSCRIBE puts a connection into subscriber mode — every other command on
|
|
11
|
+
# it then fails — so this MUST NOT use the shared client, or one waiter
|
|
12
|
+
# would poison storage for the whole process. A dedicated connection is
|
|
13
|
+
# opened per subscription and closed on the way out.
|
|
14
|
+
#
|
|
15
|
+
# Blocks the calling thread until the block returns truthy for a message
|
|
16
|
+
# (completion signals are one-shot) or the thread is killed.
|
|
17
|
+
def subscribe(channel, &block)
|
|
18
|
+
connection = Redis.new(@redis_config)
|
|
19
|
+
connection.subscribe(channel) do |on|
|
|
20
|
+
on.message { |_channel, message| connection.unsubscribe if block.call(message) }
|
|
21
|
+
end
|
|
22
|
+
ensure
|
|
23
|
+
connection&.close
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def publish(channel, message)
|
|
27
|
+
@redis.publish(channel, message)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
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
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Storage
|
|
5
|
+
# The durable outcome of one `async_step`, keyed by (parent context, step
|
|
6
|
+
# name). It lives OUTSIDE the parent's serialized context blob on purpose: a
|
|
7
|
+
# worker writes it concurrently with the still-running parent, and two
|
|
8
|
+
# writers on one blob race.
|
|
9
|
+
#
|
|
10
|
+
# The `dispatched` record is written before the job is enqueued, so it also
|
|
11
|
+
# serves as the re-attach marker on recovery — "a record exists" is
|
|
12
|
+
# exactly the question "was this already dispatched?".
|
|
13
|
+
module RedisStepResults
|
|
14
|
+
def store_step_result(context_id, step_name, record, reactor_class_name)
|
|
15
|
+
key = step_result_key(context_id, step_name, reactor_class_name)
|
|
16
|
+
# Shares context_ttl with the parent: the record must not outlive what it
|
|
17
|
+
# belongs to, and must not expire before it either.
|
|
18
|
+
@redis.set(key, JSON.generate(record), ex: durability_ttl)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def retrieve_step_result(context_id, step_name, reactor_class_name)
|
|
22
|
+
json = @redis.get(step_result_key(context_id, step_name, reactor_class_name))
|
|
23
|
+
return nil unless json
|
|
24
|
+
|
|
25
|
+
JSON.parse(json)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Every record, for StepSweeper — a unit whose job was lost leaves nothing
|
|
29
|
+
# else behind to find it by, since the parent only parks on the read side.
|
|
30
|
+
def scan_step_results(count: 1000)
|
|
31
|
+
records = []
|
|
32
|
+
|
|
33
|
+
@redis.scan_each(match: "reactor:*:context:*:step_result:*", count: 100) do |key|
|
|
34
|
+
json = @redis.get(key)
|
|
35
|
+
records << JSON.parse(json) if json
|
|
36
|
+
return records if records.size >= count
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
records
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def step_result_key(context_id, step_name, reactor_class_name)
|
|
45
|
+
"reactor:#{reactor_class_name}:context:#{context_id}:step_result:#{step_name}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/ruby_reactor/sweeper.rb
CHANGED
|
@@ -17,6 +17,12 @@ module RubyReactor
|
|
|
17
17
|
# race) and re-enqueued while its worker is actually alive, the duplicate hits
|
|
18
18
|
# the live lock -> ContextLockContention -> uncapped snooze -> no double run.
|
|
19
19
|
#
|
|
20
|
+
# `async_reactor` children ARE covered: each runs as its own job, so a lost
|
|
21
|
+
# job strands one exactly like a top-level reactor, and a fire-and-forget
|
|
22
|
+
# child has no parent left waiting to notice. They are identified by the
|
|
23
|
+
# `async_dispatched` marker rather than by absence of a parent, so compose
|
|
24
|
+
# children — same parent link, but run inline — are never re-enqueued.
|
|
25
|
+
#
|
|
20
26
|
# Map fan-out (element/collector jobs) is NOT covered here — those contexts
|
|
21
27
|
# carry parent_context_id and scan_reactors filters them out (F6). The map
|
|
22
28
|
# sweeper (Phase 5) owns them.
|
|
@@ -41,7 +47,7 @@ module RubyReactor
|
|
|
41
47
|
def run_once(limit: DEFAULT_LIMIT)
|
|
42
48
|
reenqueued = 0
|
|
43
49
|
|
|
44
|
-
@storage.scan_reactors(count: limit).each do |reactor|
|
|
50
|
+
@storage.scan_reactors(count: limit, include_dispatched_children: true).each do |reactor|
|
|
45
51
|
next unless reactor[:status] == "running" # non-terminal only
|
|
46
52
|
next if @storage.lock_held?("async:#{reactor[:id]}") # worker alive -> leave alone
|
|
47
53
|
|
|
@@ -38,6 +38,7 @@ module RubyReactor
|
|
|
38
38
|
def run_sweeps(config)
|
|
39
39
|
RubyReactor::Sweeper.run_once(limit: config.sweeper_limit)
|
|
40
40
|
RubyReactor::Map::Sweeper.run_once(limit: config.sweeper_limit)
|
|
41
|
+
RubyReactor::StepSweeper.run_once(limit: config.sweeper_limit)
|
|
41
42
|
rescue StandardError => e
|
|
42
43
|
config.logger.error("RubyReactor sweeper sweep failed: #{e.class}: #{e.message}")
|
|
43
44
|
end
|
|
@@ -11,15 +11,16 @@ module RubyReactor
|
|
|
11
11
|
@path = path
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# For an ordinary step this is exactly what it always was: read the
|
|
15
|
+
# recorded result. The async branch only engages when there is no recorded
|
|
16
|
+
# result AND the context carries an async reference for this name, so a
|
|
17
|
+
# synchronous reference costs one extra hash lookup and nothing else.
|
|
14
18
|
def resolve(context)
|
|
15
19
|
value = context.get_result(@step_name)
|
|
20
|
+
value = resolve_async_reference(context) if value.nil?
|
|
16
21
|
return nil if value.nil?
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
extract_path(value, @path)
|
|
20
|
-
else
|
|
21
|
-
value
|
|
22
|
-
end
|
|
23
|
+
@path ? extract_path(value, @path) : value
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def inspect
|
|
@@ -30,8 +31,153 @@ module RubyReactor
|
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
# Seconds a WORKER-side reader blocks in-thread before parking. Long
|
|
35
|
+
# enough that a unit finishing "immediately" (the common case) resolves
|
|
36
|
+
# without a park round-trip; short enough that a genuinely slow unit
|
|
37
|
+
# frees the worker thread quickly. Not configurable — the tunable bounds
|
|
38
|
+
# are `async_wait_timeout` (blocking) and `async_park_timeout` (parked).
|
|
39
|
+
PARK_GRACE = 5.0
|
|
40
|
+
|
|
33
41
|
private
|
|
34
42
|
|
|
43
|
+
# Block (bounded) until the dispatched unit is terminal,
|
|
44
|
+
# then inject its outcome.
|
|
45
|
+
def resolve_async_reference(context)
|
|
46
|
+
ref = async_reference(context)
|
|
47
|
+
return nil unless ref
|
|
48
|
+
|
|
49
|
+
case fetch(ref, :type).to_s
|
|
50
|
+
when "async_step_ref" then await_async_step(context, ref)
|
|
51
|
+
when "async_reactor_ref" then await_async_reactor(context, ref)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def async_reference(context)
|
|
56
|
+
ref = context.composed_contexts[@step_name] ||
|
|
57
|
+
context.composed_contexts[@step_name.to_s] ||
|
|
58
|
+
context.composed_contexts[@step_name.to_sym]
|
|
59
|
+
ref if ref.is_a?(Hash)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Read semantics: on Success the reader gets the same raw
|
|
63
|
+
# deserialized value a same-process step would have produced. On Failure it
|
|
64
|
+
# gets the `Failure` OBJECT — a same-process failure would have halted the
|
|
65
|
+
# reactor before any reader ran, so there is no sync behavior to mirror,
|
|
66
|
+
# and handing over the Failure is precisely what lets the reader see it and
|
|
67
|
+
# decide whether to compensate.
|
|
68
|
+
def await_async_step(context, ref)
|
|
69
|
+
reactor_class_name = RubyReactor.reactor_storage_name(context.reactor_class)
|
|
70
|
+
record = awaited(
|
|
71
|
+
context, ref,
|
|
72
|
+
channel: RubyReactor.async_step_channel(context.context_id, @step_name)
|
|
73
|
+
) do
|
|
74
|
+
stored = storage.retrieve_step_result(context.context_id, @step_name, reactor_class_name)
|
|
75
|
+
stored if stored && fetch(stored, :status).to_s == "completed"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
value = ContextSerializer.deserialize_value(fetch(record, :result))
|
|
79
|
+
return value if fetch(record, :success)
|
|
80
|
+
|
|
81
|
+
RubyReactor::Failure.new(value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The child is an ordinary addressable reactor, so "terminal" is just its
|
|
85
|
+
# own context row reaching a terminal status — no extra storage primitive.
|
|
86
|
+
# A child PAUSED at an interrupt is deliberately not terminal: the reader
|
|
87
|
+
# keeps waiting (and may time out) unless the child is resumed.
|
|
88
|
+
def await_async_reactor(context, ref)
|
|
89
|
+
execution_id = fetch(ref, :execution_id)
|
|
90
|
+
reactor_class_name = fetch(ref, :reactor_class_name)
|
|
91
|
+
|
|
92
|
+
data = awaited(context, ref, channel: RubyReactor.async_reactor_channel(execution_id)) do
|
|
93
|
+
stored = storage.retrieve_context(execution_id, reactor_class_name)
|
|
94
|
+
stored if stored && terminal_status?(stored)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
child_result(data)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A synchronous caller (a request thread, a rake task) blocks in the
|
|
101
|
+
# notified wait, bounded tight by `async_wait_timeout` — it has a thread
|
|
102
|
+
# to spare and a host timeout to stay under. Inside a WORKER the same
|
|
103
|
+
# read must not pin the thread: after PARK_GRACE it raises
|
|
104
|
+
# `AsyncResultPending`, the executor keeps its locks held and the job
|
|
105
|
+
# re-enqueues itself, re-entering this method on redelivery. Total parked
|
|
106
|
+
# time is bounded by `async_park_timeout` measured from `dispatched_at`.
|
|
107
|
+
def awaited(context, ref, channel:, &check)
|
|
108
|
+
return RubyReactor::AsyncWaiter.new(channel: channel, &check).wait unless worker_process?(context)
|
|
109
|
+
|
|
110
|
+
begin
|
|
111
|
+
RubyReactor::AsyncWaiter.new(channel: channel, timeout: PARK_GRACE, &check).wait
|
|
112
|
+
rescue Error::AsyncWaitTimeoutError
|
|
113
|
+
raise park_expired_error(channel) if park_deadline_passed?(ref)
|
|
114
|
+
|
|
115
|
+
raise Error::AsyncResultPending.new(
|
|
116
|
+
"async result for '#{@step_name}' still pending on '#{channel}'; parking the caller",
|
|
117
|
+
channel: channel
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def worker_process?(context)
|
|
123
|
+
(context.root_context || context).inline_async_execution
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def park_deadline_passed?(ref)
|
|
127
|
+
limit = RubyReactor.configuration.async_park_timeout
|
|
128
|
+
return false if limit == :infinity
|
|
129
|
+
|
|
130
|
+
Time.now - park_clock_start(ref) >= limit
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# `dispatched_at` round-trips through serialization, so it may come back
|
|
134
|
+
# as a string; an unparseable/missing stamp starts the clock at the first
|
|
135
|
+
# park rather than waiving the bound.
|
|
136
|
+
def park_clock_start(ref)
|
|
137
|
+
raw = fetch(ref, :dispatched_at)
|
|
138
|
+
return raw if raw.is_a?(Time)
|
|
139
|
+
|
|
140
|
+
begin
|
|
141
|
+
Time.parse(raw.to_s)
|
|
142
|
+
rescue ArgumentError, TypeError
|
|
143
|
+
ref[:dispatched_at] = Time.now
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def park_expired_error(channel)
|
|
148
|
+
Error::AsyncWaitTimeoutError.new(
|
|
149
|
+
"Parked wait for '#{@step_name}' on '#{channel}' exceeded " \
|
|
150
|
+
"`RubyReactor.configuration.async_park_timeout` " \
|
|
151
|
+
"(#{RubyReactor.configuration.async_park_timeout}s since dispatch) without the unit " \
|
|
152
|
+
"reaching a terminal state — check that a worker is consuming the queue, or raise the " \
|
|
153
|
+
"timeout if this unit is legitimately slower."
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def terminal_status?(data)
|
|
158
|
+
%w[completed failed cancelled skipped].include?(fetch(data, :status).to_s)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The child's real Success/Failure, never the enqueue-time DispatchResult —
|
|
162
|
+
# the reader is supposed to inspect `.success?` / `.value` / `.error`.
|
|
163
|
+
def child_result(data)
|
|
164
|
+
context = RubyReactor::Context.deserialize_from_retry(data)
|
|
165
|
+
return context.failure_reason if context.failure_reason.is_a?(RubyReactor::Failure)
|
|
166
|
+
return RubyReactor::Failure.new(context.failure_reason || "child reactor failed") if context.failed?
|
|
167
|
+
|
|
168
|
+
return_step = context.reactor_class.respond_to?(:return_step) ? context.reactor_class.return_step : nil
|
|
169
|
+
RubyReactor::Success.new(return_step ? context.get_result(return_step) : nil)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def storage
|
|
173
|
+
RubyReactor.configuration.storage_adapter
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Records round-trip through JSON, so a key may come back as a string.
|
|
177
|
+
def fetch(hash, key)
|
|
178
|
+
hash[key] || hash[key.to_s]
|
|
179
|
+
end
|
|
180
|
+
|
|
35
181
|
def extract_path(value, path)
|
|
36
182
|
if path.is_a?(Symbol) && value.respond_to?(:[])
|
|
37
183
|
value[path]
|
data/lib/ruby_reactor/version.rb
CHANGED