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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
  3. data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
  4. data/.release-please-manifest.json +1 -1
  5. data/.specify/feature.json +1 -1
  6. data/.specify/memory/constitution.md +79 -12
  7. data/.specify/templates/tasks-template.md +7 -0
  8. data/CHANGELOG.md +125 -1
  9. data/CLAUDE.md +5 -0
  10. data/README.md +155 -25
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
  12. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  13. data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
  14. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
  15. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  16. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
  17. data/lib/ruby_reactor/async_waiter.rb +113 -0
  18. data/lib/ruby_reactor/configuration.rb +30 -1
  19. data/lib/ruby_reactor/context.rb +28 -0
  20. data/lib/ruby_reactor/context_serializer.rb +15 -0
  21. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  22. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  23. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  24. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  25. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  26. data/lib/ruby_reactor/dsl/lockable.rb +3 -3
  27. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  28. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  29. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  30. data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
  31. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  32. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  33. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  34. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  35. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  36. data/lib/ruby_reactor/executor/ordered_lock_support.rb +11 -11
  37. data/lib/ruby_reactor/executor/result_handler.rb +47 -11
  38. data/lib/ruby_reactor/executor/retry_manager.rb +13 -6
  39. data/lib/ruby_reactor/executor/step_executor.rb +77 -24
  40. data/lib/ruby_reactor/executor.rb +179 -23
  41. data/lib/ruby_reactor/lock.rb +19 -0
  42. data/lib/ruby_reactor/map/element_executor.rb +13 -2
  43. data/lib/ruby_reactor/map/helpers.rb +10 -8
  44. data/lib/ruby_reactor/map/result_enumerator.rb +7 -1
  45. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  46. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  47. data/lib/ruby_reactor/open_telemetry.rb +8 -5
  48. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  49. data/lib/ruby_reactor/reactor.rb +25 -2
  50. data/lib/ruby_reactor/rspec/matchers.rb +61 -11
  51. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
  52. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  53. data/lib/ruby_reactor/rspec/test_subject.rb +74 -19
  54. data/lib/ruby_reactor/semaphore.rb +10 -0
  55. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  56. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  57. data/lib/ruby_reactor/step/map_step.rb +6 -2
  58. data/lib/ruby_reactor/step.rb +10 -4
  59. data/lib/ruby_reactor/step_signals.rb +33 -0
  60. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  61. data/lib/ruby_reactor/step_worker.rb +260 -0
  62. data/lib/ruby_reactor/storage/adapter.rb +26 -1
  63. data/lib/ruby_reactor/storage/redis_adapter.rb +4 -71
  64. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  65. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  66. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  67. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  68. data/lib/ruby_reactor/sweeper.rb +7 -1
  69. data/lib/ruby_reactor/sweeper_job.rb +1 -0
  70. data/lib/ruby_reactor/template/result.rb +151 -5
  71. data/lib/ruby_reactor/version.rb +1 -1
  72. data/lib/ruby_reactor/web/api.rb +117 -22
  73. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  74. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  75. data/lib/ruby_reactor/web/public/index.html +2 -2
  76. data/lib/ruby_reactor/worker.rb +53 -3
  77. data/lib/ruby_reactor.rb +80 -10
  78. data/specs/active_job.md +1 -1
  79. metadata +23 -3
  80. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  81. 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
@@ -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
- if @path
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]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.1"
5
5
  end