ruby_reactor 0.3.0 → 0.3.2

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +145 -9
  3. data/documentation/README.md +20 -8
  4. data/documentation/async_reactors.md +46 -34
  5. data/documentation/core_concepts.md +75 -61
  6. data/documentation/examples/inventory_management.md +2 -3
  7. data/documentation/examples/order_processing.md +92 -77
  8. data/documentation/examples/payment_processing.md +28 -117
  9. data/documentation/getting_started.md +112 -94
  10. data/documentation/interrupts.md +9 -7
  11. data/documentation/locks_and_semaphores.md +459 -0
  12. data/documentation/retry_configuration.md +19 -14
  13. data/documentation/testing.md +994 -0
  14. data/lib/ruby_reactor/configuration.rb +19 -2
  15. data/lib/ruby_reactor/context.rb +13 -5
  16. data/lib/ruby_reactor/context_serializer.rb +55 -4
  17. data/lib/ruby_reactor/dsl/lockable.rb +130 -0
  18. data/lib/ruby_reactor/dsl/reactor.rb +3 -2
  19. data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
  20. data/lib/ruby_reactor/executor/result_handler.rb +27 -2
  21. data/lib/ruby_reactor/executor/retry_manager.rb +15 -7
  22. data/lib/ruby_reactor/executor/step_executor.rb +29 -99
  23. data/lib/ruby_reactor/executor.rb +148 -15
  24. data/lib/ruby_reactor/lock.rb +92 -0
  25. data/lib/ruby_reactor/map/collector.rb +16 -15
  26. data/lib/ruby_reactor/map/element_executor.rb +90 -104
  27. data/lib/ruby_reactor/map/execution.rb +2 -1
  28. data/lib/ruby_reactor/map/helpers.rb +2 -1
  29. data/lib/ruby_reactor/map/result_enumerator.rb +1 -1
  30. data/lib/ruby_reactor/period.rb +67 -0
  31. data/lib/ruby_reactor/rate_limit.rb +74 -0
  32. data/lib/ruby_reactor/reactor.rb +175 -16
  33. data/lib/ruby_reactor/rspec/helpers.rb +17 -0
  34. data/lib/ruby_reactor/rspec/matchers.rb +423 -0
  35. data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
  36. data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
  37. data/lib/ruby_reactor/rspec.rb +18 -0
  38. data/lib/ruby_reactor/semaphore.rb +58 -0
  39. data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +10 -5
  40. data/lib/ruby_reactor/sidekiq_workers/worker.rb +69 -9
  41. data/lib/ruby_reactor/step/compose_step.rb +0 -1
  42. data/lib/ruby_reactor/step/map_step.rb +11 -18
  43. data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
  44. data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +32 -24
  47. data/lib/ruby_reactor.rb +119 -10
  48. metadata +16 -3
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- class AsyncRouter
4
+ class SidekiqAdapter
5
5
  def self.perform_async(serialized_context, reactor_class_name = nil, intermediate_results: {})
6
6
  job_id = SidekiqWorkers::Worker.perform_async(serialized_context, reactor_class_name)
7
7
  context = ContextSerializer.deserialize(serialized_context)
@@ -20,7 +20,7 @@ module RubyReactor
20
20
  def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
21
21
  strict_ordering:, parent_context_id:, parent_reactor_class_name:, step_name:,
22
22
  batch_size: nil, serialized_context: nil, fail_fast: nil)
23
- RubyReactor::SidekiqWorkers::MapElementWorker.perform_async(
23
+ job_id = RubyReactor::SidekiqWorkers::MapElementWorker.perform_async(
24
24
  {
25
25
  "map_id" => map_id,
26
26
  "element_id" => element_id,
@@ -36,6 +36,7 @@ module RubyReactor
36
36
  "fail_fast" => fail_fast
37
37
  }
38
38
  )
39
+ RubyReactor::AsyncResult.new(job_id: job_id)
39
40
  end
40
41
 
41
42
  def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
@@ -63,7 +64,7 @@ module RubyReactor
63
64
  # rubocop:disable Metrics/ParameterLists
64
65
  def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
65
66
  strict_ordering:, timeout:)
66
- RubyReactor::SidekiqWorkers::MapCollectorWorker.perform_async(
67
+ job_id = RubyReactor::SidekiqWorkers::MapCollectorWorker.perform_async(
67
68
  {
68
69
  "parent_context_id" => parent_context_id,
69
70
  "map_id" => map_id,
@@ -73,12 +74,15 @@ module RubyReactor
73
74
  "timeout" => timeout
74
75
  }
75
76
  )
77
+ RubyReactor::AsyncResult.new(job_id: job_id)
76
78
  end
77
- # rubocop:enable Metrics/ParameterLists
78
79
 
80
+ # rubocop:enable Metrics/ParameterLists
81
+ # rubocop:disable Metrics/ParameterLists
79
82
  def self.perform_map_execution_async(map_id:, serialized_inputs:, reactor_class_info:, strict_ordering:,
80
83
  parent_context_id:, parent_reactor_class_name:, step_name:, fail_fast: nil)
81
- RubyReactor::SidekiqWorkers::MapExecutionWorker.perform_async(
84
+ # rubocop:enable Metrics/ParameterLists
85
+ job_id = RubyReactor::SidekiqWorkers::MapExecutionWorker.perform_async(
82
86
  {
83
87
  "map_id" => map_id,
84
88
  "serialized_inputs" => serialized_inputs,
@@ -90,6 +94,7 @@ module RubyReactor
90
94
  "fail_fast" => fail_fast
91
95
  }
92
96
  )
97
+ RubyReactor::AsyncResult.new(job_id: job_id)
93
98
  end
94
99
  end
95
100
  end
@@ -17,7 +17,7 @@ module RubyReactor
17
17
  # Handle infrastructure failures (network, Redis, etc.)
18
18
  end
19
19
 
20
- def perform(serialized_context, reactor_class_name = nil)
20
+ def perform(serialized_context, reactor_class_name = nil, snooze_count = 0)
21
21
  context = ContextSerializer.deserialize(serialized_context)
22
22
 
23
23
  # If reactor_class_name is provided, use it to get the reactor class
@@ -35,20 +35,80 @@ module RubyReactor
35
35
  # Mark that we're executing inline to prevent nested async calls
36
36
  context.inline_async_execution = true
37
37
 
38
- # Resume execution from the failed step
39
- executor = Executor.new(context.reactor_class, {}, context)
40
- executor.resume_execution
41
- executor.save_context
38
+ begin
39
+ # Resume execution from the failed step
40
+ executor = Executor.new(context.reactor_class, {}, context)
41
+ executor.resume_execution
42
+ executor.save_context
42
43
 
43
- # Return the executor (which now has the result stored in it)
44
- executor
44
+ # Return the executor (which now has the result stored in it)
45
+ executor
46
+ rescue RubyReactor::Lock::AcquisitionError,
47
+ RubyReactor::Semaphore::AcquisitionError,
48
+ RubyReactor::RateLimit::ExceededError => e
49
+ # Snooze on expected concurrency or rate contention. We avoid
50
+ # Sidekiq's native retry path so this doesn't burn the job's retry
51
+ # budget or appear as an error in dashboards. After the configured
52
+ # cap is reached we escalate by marking the reactor as failed.
53
+ handle_snooze(serialized_context, reactor_class_name, context, snooze_count, e)
54
+ end
45
55
  end
46
56
 
47
57
  private
48
58
 
59
+ def handle_snooze(serialized_context, reactor_class_name, context, snooze_count, error)
60
+ config = RubyReactor.configuration
61
+ max = config.lock_snooze_max_attempts
62
+
63
+ if max != :infinity && snooze_count >= max
64
+ escalate_snooze(context, snooze_count, error)
65
+ return
66
+ end
67
+
68
+ delay = compute_snooze_delay(config, error)
69
+ self.class.perform_in(delay, serialized_context, reactor_class_name, snooze_count + 1)
70
+ end
71
+
72
+ # Use the error's `retry_after_seconds` hint when available
73
+ # (RateLimit::ExceededError carries the time until the bucket rolls);
74
+ # otherwise fall back to the configured base + jitter for lock/semaphore
75
+ # contention which has no precise hint.
76
+ def compute_snooze_delay(config, error)
77
+ jitter = config.lock_snooze_jitter.to_f
78
+ jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
79
+
80
+ if error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
81
+ [error.retry_after_seconds.to_f, 0.1].max + jitter_amount
82
+ else
83
+ config.lock_snooze_base_delay.to_f + jitter_amount
84
+ end
85
+ end
86
+
87
+ def escalate_snooze(context, snooze_count, error)
88
+ RubyReactor.configuration.logger.warn(
89
+ "RubyReactor snooze limit reached after #{snooze_count} attempts " \
90
+ "for context #{context.context_id}: #{error.message}"
91
+ )
92
+
93
+ context.status = :failed
94
+ context.failure_reason = {
95
+ message: error.message,
96
+ exception_class: error.class.name,
97
+ snooze_attempts: snooze_count
98
+ }
99
+
100
+ serialized = ContextSerializer.serialize(context)
101
+ reactor_class_name = context.reactor_class&.name || "AnonymousReactor"
102
+ RubyReactor.configuration.storage_adapter.store_context(
103
+ context.context_id,
104
+ serialized,
105
+ reactor_class_name
106
+ )
107
+ end
108
+
49
109
  def log_infrastructure_failure(msg, exception)
50
- ::Sidekiq.logger.error("RubyReactor infrastructure failure: #{exception.message}")
51
- ::Sidekiq.logger.error("Job details: #{msg.inspect}")
110
+ RubyReactor.configuration.logger.error("RubyReactor infrastructure failure: #{exception.message}")
111
+ RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
52
112
  end
53
113
  end
54
114
  end
@@ -84,7 +84,6 @@ module RubyReactor
84
84
  def link_contexts(child_context, parent_context)
85
85
  child_context.parent_context = parent_context
86
86
  child_context.root_context = parent_context.root_context || parent_context
87
- child_context.test_mode = parent_context.test_mode
88
87
  child_context.inline_async_execution = parent_context.inline_async_execution
89
88
  end
90
89
 
@@ -57,7 +57,9 @@ module RubyReactor
57
57
  private
58
58
 
59
59
  def should_run_async?(arguments, context)
60
- arguments[:async] && !context.inline_async_execution
60
+ return false if context.inline_async_execution
61
+
62
+ arguments[:async]
61
63
  end
62
64
 
63
65
  def run_inline(arguments, context)
@@ -118,30 +120,21 @@ module RubyReactor
118
120
  def link_contexts(child_context, parent_context)
119
121
  child_context.parent_context = parent_context
120
122
  child_context.root_context = parent_context.root_context || parent_context
121
- child_context.test_mode = parent_context.test_mode
122
123
  child_context.inline_async_execution = parent_context.inline_async_execution
123
124
  end
124
125
 
125
- def process_results(results, collect_block, fail_fast = true)
126
+ def process_results(results, collect_block, _fail_fast = true)
126
127
  if collect_block
127
128
  begin
128
129
  # Collect block receives Result objects when fail_fast is false, values when true
129
- RubyReactor::Success(collect_block.call(results))
130
+ return RubyReactor::Success(collect_block.call(results))
130
131
  rescue StandardError => e
131
- RubyReactor::Failure(e)
132
+ return RubyReactor::Failure(e)
132
133
  end
133
- elsif fail_fast
134
- # Default behavior when no collect block
135
- # Current behavior: results are already values
136
- RubyReactor::Success(results)
137
- else
138
- # New behavior: extract successful values only
139
- # New behavior: extract successful values only IF fail_fast is true behavior implies only values
140
- # However, if fail_fast is false, we want to return results as is, or if logic dictates otherwise.
141
- # wait, if fail_fast=false, we expect Result objects so we can check if success/failure.
142
- # If we return only successes, we hide failures.
143
- RubyReactor::Success(results)
144
134
  end
135
+
136
+ # Simplified: both branches returned Success(results)
137
+ RubyReactor::Success(results)
145
138
  end
146
139
 
147
140
  def extract_path(value, path)
@@ -203,7 +196,7 @@ module RubyReactor
203
196
  argument_mappings: arguments[:argument_mappings],
204
197
  strict_ordering: arguments[:strict_ordering],
205
198
  mapped_reactor_class: arguments[:mapped_reactor_class],
206
- fail_fast: arguments[:fail_fast]
199
+ fail_fast: arguments[:fail_fast].nil? || arguments[:fail_fast]
207
200
  )
208
201
  queue_collector(map_id, context, step_name, arguments[:strict_ordering])
209
202
  "map:#{map_id}"
@@ -284,7 +277,7 @@ module RubyReactor
284
277
  map_id: map_id, serialized_inputs: serialized_inputs,
285
278
  reactor_class_info: reactor_class_info, strict_ordering: arguments[:strict_ordering],
286
279
  parent_context_id: context.context_id, parent_reactor_class_name: context.reactor_class.name,
287
- step_name: step_name.to_s, fail_fast: arguments[:fail_fast]
280
+ step_name: step_name.to_s, fail_fast: arguments[:fail_fast].nil? || arguments[:fail_fast]
288
281
  )
289
282
  end
290
283
  end
@@ -6,6 +6,8 @@ require "json"
6
6
  module RubyReactor
7
7
  module Storage
8
8
  class RedisAdapter < Adapter
9
+ include RedisLocking
10
+
9
11
  def initialize(redis_config)
10
12
  super()
11
13
  @redis = Redis.new(redis_config)
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Storage
5
+ # Adapter contract uses non-`?` names for methods that return booleans
6
+ # (`lock_acquire`, `semaphore_release`, etc). Renaming would break the
7
+ # public storage adapter API, so silence the predicate-name cop here.
8
+ # rubocop:disable Naming/PredicateMethod
9
+ module RedisLocking
10
+ # Scripts for Lock Primitives
11
+ LOCK_ACQUIRE_SCRIPT = <<~LUA
12
+ local key = KEYS[1]
13
+ local owner = ARGV[1]
14
+ local ttl = tonumber(ARGV[2])
15
+
16
+ if redis.call('exists', key) == 0 then
17
+ redis.call('hset', key, 'owner', owner)
18
+ redis.call('hset', key, 'count', 1)
19
+ redis.call('expire', key, ttl)
20
+ return 1
21
+ elseif redis.call('hget', key, 'owner') == owner then
22
+ redis.call('hincrby', key, 'count', 1)
23
+ redis.call('expire', key, ttl)
24
+ return 1
25
+ else
26
+ return 0
27
+ end
28
+ LUA
29
+
30
+ LOCK_RELEASE_SCRIPT = <<~LUA
31
+ local key = KEYS[1]
32
+ local owner = ARGV[1]
33
+
34
+ if redis.call('hget', key, 'owner') == owner then
35
+ local new_count = redis.call('hincrby', key, 'count', -1)
36
+ if new_count <= 0 then
37
+ redis.call('del', key)
38
+ end
39
+ return 1
40
+ else
41
+ return 0
42
+ end
43
+ LUA
44
+
45
+ LOCK_EXTEND_SCRIPT = <<~LUA
46
+ local key = KEYS[1]
47
+ local owner = ARGV[1]
48
+ local ttl = tonumber(ARGV[2])
49
+
50
+ if redis.call('hget', key, 'owner') == owner then
51
+ redis.call('expire', key, ttl)
52
+ return 1
53
+ else
54
+ return 0
55
+ end
56
+ LUA
57
+
58
+ # Lock Primitives
59
+
60
+ def lock_acquire(key, owner, ttl)
61
+ result = @redis.eval(LOCK_ACQUIRE_SCRIPT, keys: [key], argv: [owner, ttl])
62
+ result == 1
63
+ end
64
+
65
+ def lock_release(key, owner)
66
+ result = @redis.eval(LOCK_RELEASE_SCRIPT, keys: [key], argv: [owner])
67
+ result == 1
68
+ end
69
+
70
+ def lock_extend(key, owner, ttl)
71
+ result = @redis.eval(LOCK_EXTEND_SCRIPT, keys: [key], argv: [owner, ttl])
72
+ result == 1
73
+ end
74
+
75
+ # Semaphore Primitives
76
+ #
77
+ # Storage layout:
78
+ # LIST <key> — available token UUIDs
79
+ # SET <key>:held — tokens currently checked out
80
+ # STRING <key>:init — sentinel marking that init has run; value = limit
81
+ #
82
+ # Tokens are unique UUIDs so release can verify the caller actually holds
83
+ # one before pushing it back, blocking double-release and over-cap RPUSH.
84
+
85
+ SEM_ACQUIRE_SCRIPT = <<~LUA
86
+ local list_key = KEYS[1]
87
+ local held_key = KEYS[2]
88
+ local token = redis.call('lpop', list_key)
89
+ if not token then return false end
90
+ redis.call('sadd', held_key, token)
91
+ return token
92
+ LUA
93
+
94
+ SEM_RELEASE_SCRIPT = <<~LUA
95
+ local list_key = KEYS[1]
96
+ local held_key = KEYS[2]
97
+ local limit = tonumber(ARGV[1])
98
+ local token = ARGV[2]
99
+ if redis.call('srem', held_key, token) == 0 then
100
+ return 0
101
+ end
102
+ if redis.call('llen', list_key) >= limit then
103
+ return 0
104
+ end
105
+ redis.call('rpush', list_key, token)
106
+ return 1
107
+ LUA
108
+
109
+ SEMAPHORE_TTL = 86_400
110
+
111
+ def semaphore_init(key, limit)
112
+ return false unless @redis.set("#{key}:init", limit, nx: true, ex: SEMAPHORE_TTL)
113
+
114
+ tokens = Array.new(limit) { SecureRandom.uuid }
115
+ @redis.rpush(key, tokens)
116
+ @redis.expire(key, SEMAPHORE_TTL)
117
+ true
118
+ end
119
+
120
+ def semaphore_reset(key)
121
+ @redis.del(key)
122
+ @redis.del("#{key}:held")
123
+ @redis.del("#{key}:init")
124
+ end
125
+
126
+ def semaphore_acquire(key, timeout: 0)
127
+ held_key = "#{key}:held"
128
+
129
+ token = if timeout.to_f.positive?
130
+ result = @redis.blpop(key, timeout: timeout)
131
+ next_token = result&.last
132
+ @redis.sadd(held_key, next_token) if next_token
133
+ next_token
134
+ else
135
+ @redis.eval(SEM_ACQUIRE_SCRIPT, keys: [key, held_key], argv: [])
136
+ end
137
+
138
+ return nil unless token
139
+
140
+ @redis.expire(held_key, SEMAPHORE_TTL)
141
+ token
142
+ end
143
+
144
+ def semaphore_release(key, token, limit)
145
+ return false unless token
146
+
147
+ result = @redis.eval(
148
+ SEM_RELEASE_SCRIPT,
149
+ keys: [key, "#{key}:held"],
150
+ argv: [limit, token]
151
+ )
152
+ result == 1
153
+ end
154
+
155
+ def semaphore_exists?(key)
156
+ @redis.exists?("#{key}:init")
157
+ end
158
+
159
+ # Period Primitives — used by `with_period` to dedup bucketed runs.
160
+
161
+ def period_seen?(key)
162
+ @redis.exists?(key)
163
+ end
164
+
165
+ def period_mark(key, ttl)
166
+ @redis.set(key, "1", ex: ttl)
167
+ end
168
+
169
+ # Rate Limit Primitives — fixed-window counter, supports multiple
170
+ # windows in one atomic call. Two-pass inside Lua so a miss on the Nth
171
+ # window does not leave the previous N-1 incremented.
172
+ #
173
+ # ARGV layout: [now, period_1, limit_1, ttl_1, period_2, limit_2, ttl_2, ...]
174
+ # KEYS: [bucket_key_1, bucket_key_2, ...]
175
+ # Returns: [allowed (1|0), retry_after_seconds, failed_index]
176
+
177
+ RATE_LIMIT_SCRIPT = <<~LUA
178
+ local now = tonumber(ARGV[1])
179
+ local n = #KEYS
180
+
181
+ for i = 1, n do
182
+ local base = 2 + (i - 1) * 3
183
+ local period = tonumber(ARGV[base])
184
+ local lim = tonumber(ARGV[base + 1])
185
+ local count = tonumber(redis.call('get', KEYS[i]) or '0')
186
+ if count >= lim then
187
+ local retry_after = period - (now % period)
188
+ if retry_after <= 0 then retry_after = 1 end
189
+ return {0, retry_after, i}
190
+ end
191
+ end
192
+
193
+ for i = 1, n do
194
+ local base = 2 + (i - 1) * 3
195
+ local ttl = tonumber(ARGV[base + 2])
196
+ local count = redis.call('incr', KEYS[i])
197
+ if count == 1 then
198
+ redis.call('expire', KEYS[i], ttl)
199
+ end
200
+ end
201
+
202
+ return {1, 0, 0}
203
+ LUA
204
+
205
+ def rate_limit_check_and_increment(keys, argv)
206
+ @redis.eval(RATE_LIMIT_SCRIPT, keys: keys, argv: argv)
207
+ end
208
+
209
+ # Inspectors — used by RSpec matchers to assert on lock/semaphore/
210
+ # rate-limit/period state without leaking Redis-specific calls into
211
+ # test code.
212
+
213
+ # Returns { owner:, count: } for a held lock, or nil if free.
214
+ # `prefixed_key` is the full key (e.g. "lock:order:42").
215
+ def lock_info(prefixed_key)
216
+ return nil unless @redis.exists?(prefixed_key)
217
+
218
+ data = @redis.hgetall(prefixed_key)
219
+ return nil if data.empty?
220
+
221
+ { owner: data["owner"], count: data["count"].to_i }
222
+ end
223
+
224
+ # Returns { available:, held:, limit: } for a semaphore. `name` is the
225
+ # user-provided semaphore key (without the "semaphore:" prefix).
226
+ def semaphore_state(name)
227
+ prefix = "semaphore:#{name}"
228
+ {
229
+ available: @redis.llen(prefix),
230
+ held: @redis.scard("#{prefix}:held"),
231
+ limit: @redis.get("#{prefix}:init").to_i
232
+ }
233
+ end
234
+
235
+ # Current count for a rate-limit bucket. `key_base` is the user's
236
+ # `with_rate_limit` key, `every` is the period (symbol or seconds).
237
+ def rate_limit_count(key_base, every, now: Time.now.to_i)
238
+ period_seconds = RubyReactor::Period.period_seconds(every)
239
+ bucket = now / period_seconds
240
+ @redis.get("rate:#{key_base}:#{every}:#{bucket}").to_i
241
+ end
242
+
243
+ # Has a period bucket been marked? `key_base` is the user's
244
+ # `with_period` key, `every` is the period.
245
+ def period_marker?(key_base, every, now: Time.now.utc)
246
+ @redis.exists?(RubyReactor::Period.key(key_base, every, now: now))
247
+ end
248
+ end
249
+ # rubocop:enable Naming/PredicateMethod
250
+ end
251
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.2"
5
5
  end
@@ -24,39 +24,44 @@ module RubyReactor
24
24
  r.on String do |reactor_id|
25
25
  # GET /api/reactors/:id
26
26
  r.get do
27
- data = RubyReactor::Configuration.instance.storage_adapter.find_context_by_id(reactor_id)
28
- return { error: "Reactor not found" } unless data
27
+ raw_data = RubyReactor::Configuration.instance.storage_adapter.find_context_by_id(reactor_id)
28
+ return { error: "Reactor not found" } unless raw_data
29
29
 
30
- reactor_class = data["reactor_class"] ? Object.const_get(data["reactor_class"]) : nil
30
+ # Clean data for API usage
31
+ data = ContextSerializer.deserialize_value(raw_data)
32
+
33
+ reactor_class = data[:reactor_class] ? Object.const_get(data[:reactor_class].to_s) : nil
31
34
  structure = {}
32
35
 
33
36
  structure = self.class.build_structure(reactor_class) if reactor_class.respond_to?(:steps)
34
37
 
35
- {
36
- id: data["context_id"],
37
- class: data["reactor_class"],
38
- status: if %w[failed paused completed running].include?(data["status"])
39
- data["status"]
40
- elsif data["cancelled"]
38
+ response_data = {
39
+ id: data[:context_id],
40
+ class: data[:reactor_class].to_s,
41
+ status: if %w[failed paused completed running].include?(data[:status].to_s)
42
+ data[:status].to_s
43
+ elsif data[:cancelled]
41
44
  "cancelled"
42
45
  else
43
- (data["current_step"] ? "running" : "completed")
46
+ (data[:current_step] ? "running" : "completed")
44
47
  end,
45
- current_step: data["current_step"],
46
- retry_count: data["retry_count"] || 0,
47
- undo_stack: data["undo_stack"] || [],
48
- step_attempts: data.dig("retry_context", "step_attempts") || {},
49
- created_at: data["started_at"],
50
- inputs: data["inputs"],
51
- intermediate_results: data["intermediate_results"],
48
+ current_step: data[:current_step].to_s,
49
+ retry_count: data[:retry_count] || 0,
50
+ undo_stack: data[:undo_stack] || [],
51
+ step_attempts: data.dig(:retry_context, :step_attempts) || {},
52
+ created_at: data[:started_at],
53
+ inputs: data[:inputs],
54
+ intermediate_results: data[:intermediate_results],
52
55
  structure: structure,
53
- steps: data["execution_trace"] || [],
56
+ steps: data[:execution_trace] || [],
54
57
  composed_contexts: self.class.hydrate_composed_contexts(
55
- data["composed_contexts"] || {},
56
- data["reactor_class"]
58
+ data[:composed_contexts] || {},
59
+ data[:reactor_class]&.to_s
57
60
  ),
58
- error: data["failure_reason"]
61
+ error: data[:failure_reason]
59
62
  }
63
+
64
+ ContextSerializer.simplify_for_api(response_data)
60
65
  end
61
66
 
62
67
  # POST /api/reactors/:id/retry
@@ -159,7 +164,8 @@ module RubyReactor
159
164
  return {} unless composed_contexts.is_a?(Hash)
160
165
 
161
166
  composed_contexts.transform_values do |value|
162
- if ["map_ref", :map_ref].include?(value["type"])
167
+ type = value[:type] || value["type"]
168
+ if ["map_ref", :map_ref].include?(type)
163
169
  hydrate_map_ref(value, reactor_class_name)
164
170
  else
165
171
  value
@@ -169,10 +175,12 @@ module RubyReactor
169
175
 
170
176
  def self.hydrate_map_ref(ref_data, reactor_class_name)
171
177
  storage = RubyReactor.configuration.storage_adapter
172
- map_id = ref_data["map_id"]
178
+ map_id = ref_data[:map_id] || ref_data["map_id"]
173
179
 
174
180
  # Use the specific element reactor class if available, otherwise fallback to parent
175
- target_reactor_class = ref_data["element_reactor_class"] || reactor_class_name
181
+ target_reactor_class = ref_data[:element_reactor_class] ||
182
+ ref_data["element_reactor_class"] ||
183
+ reactor_class_name
176
184
 
177
185
  # 1. Check for specific failure (O(1))
178
186
  # Stored by ResultHandler when a map element fails