ruby_reactor 0.8.1 → 0.8.3

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-review/SKILL.md +324 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/.specify/extensions.yml +10 -0
  5. data/.specify/feature.json +1 -1
  6. data/.specify/workflows/speckit/workflow.yml +13 -1
  7. data/.specify/workflows/workflow-registry.json +2 -2
  8. data/CHANGELOG.md +89 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +36 -3
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
  13. data/lib/ruby_reactor/context.rb +26 -0
  14. data/lib/ruby_reactor/context_serializer.rb +4 -2
  15. data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
  16. data/lib/ruby_reactor/dsl/lockable.rb +76 -21
  17. data/lib/ruby_reactor/dsl/step_builder.rb +112 -1
  18. data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
  19. data/lib/ruby_reactor/error/execution_parked.rb +16 -0
  20. data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
  21. data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
  22. data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
  24. data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
  25. data/lib/ruby_reactor/executor/result_handler.rb +31 -11
  26. data/lib/ruby_reactor/executor/retry_manager.rb +9 -1
  27. data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
  28. data/lib/ruby_reactor/executor/step_executor.rb +115 -11
  29. data/lib/ruby_reactor/executor.rb +90 -20
  30. data/lib/ruby_reactor/map/element_executor.rb +24 -2
  31. data/lib/ruby_reactor/map/helpers.rb +35 -11
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +61 -24
  34. data/lib/ruby_reactor/retry_context.rb +31 -2
  35. data/lib/ruby_reactor/rspec/helpers.rb +19 -3
  36. data/lib/ruby_reactor/rspec/matchers.rb +92 -0
  37. data/lib/ruby_reactor/rspec/test_subject.rb +209 -46
  38. data/lib/ruby_reactor/rspec.rb +8 -1
  39. data/lib/ruby_reactor/step/async_reactor_step.rb +40 -24
  40. data/lib/ruby_reactor/step/compose_step.rb +14 -3
  41. data/lib/ruby_reactor/step/map_step.rb +4 -4
  42. data/lib/ruby_reactor/step.rb +49 -7
  43. data/lib/ruby_reactor/step_sweeper.rb +29 -1
  44. data/lib/ruby_reactor/step_worker.rb +260 -37
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +72 -7
  47. data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
  48. data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
  49. data/lib/ruby_reactor/web/public/index.html +1 -1
  50. data/lib/ruby_reactor/worker.rb +56 -30
  51. data/lib/ruby_reactor.rb +27 -5
  52. data/specs/future_improvements.md +250 -0
  53. data/specs/scoped_rspec_matchers.md +158 -0
  54. metadata +9 -28
  55. data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
  56. data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
  57. data/specs/002-step-input-contracts/data-model.md +0 -115
  58. data/specs/002-step-input-contracts/plan.md +0 -165
  59. data/specs/002-step-input-contracts/quickstart.md +0 -170
  60. data/specs/002-step-input-contracts/research.md +0 -233
  61. data/specs/002-step-input-contracts/spec.md +0 -359
  62. data/specs/002-step-input-contracts/tasks.md +0 -367
  63. data/specs/004-inheritable-step-class/checklists/requirements.md +0 -40
  64. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
  65. data/specs/004-inheritable-step-class/data-model.md +0 -116
  66. data/specs/004-inheritable-step-class/plan.md +0 -174
  67. data/specs/004-inheritable-step-class/quickstart.md +0 -112
  68. data/specs/004-inheritable-step-class/research.md +0 -308
  69. data/specs/004-inheritable-step-class/spec.md +0 -316
  70. data/specs/004-inheritable-step-class/tasks.md +0 -258
  71. data/specs/active_job.md +0 -259
  72. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
  73. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
  74. data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
  75. data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
  76. data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
  77. data/specs/deferred-003-step-lock-declarations/research.md +0 -196
  78. data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
  79. data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
  80. data/specs/possible_feature.md +0 -22
@@ -47,6 +47,9 @@ module RubyReactor
47
47
  structure = self.class.build_structure(reactor_class) if reactor_class.respond_to?(:steps)
48
48
 
49
49
  api_status = self.class.reactor_status(data)
50
+ class_name = data[:reactor_class]&.to_s
51
+ runs = self.class.async_step_runs(data[:composed_contexts], class_name)
52
+ trace = self.class.with_async_step_runs(data[:execution_trace] || [], runs)
50
53
 
51
54
  response_data = {
52
55
  id: data[:context_id],
@@ -55,7 +58,9 @@ module RubyReactor
55
58
  current_step: data[:current_step].to_s,
56
59
  retry_count: data[:retry_count] || 0,
57
60
  undo_stack: data[:undo_stack] || [],
58
- step_attempts: data.dig(:retry_context, :step_attempts) || {},
61
+ step_attempts: self.class.with_async_step_attempts(
62
+ data.dig(:retry_context, :step_attempts) || {}, runs
63
+ ),
59
64
  created_at: data[:started_at],
60
65
  inputs: data[:inputs],
61
66
  intermediate_results: self.class.with_map_summaries(
@@ -65,15 +70,14 @@ module RubyReactor
65
70
  # Once per reactor, never per step: the old per-step `async`
66
71
  # field is gone because there is now exactly one hand-off point.
67
72
  background_handoff: self.class.background_handoff_for(reactor_class),
68
- steps: data[:execution_trace] || [],
69
- composed_contexts: self.class.hydrate_composed_contexts(
70
- data[:composed_contexts] || {},
71
- data[:reactor_class]&.to_s
72
- ),
73
+ steps: trace,
74
+ composed_contexts: self.class.hydrate_composed_contexts(data[:composed_contexts] || {}, class_name),
73
75
  coordination: CoordinationSerializer.build(
74
76
  reactor_class,
75
77
  inputs: data[:inputs],
76
- context_id: data[:context_id]
78
+ context_id: data[:context_id],
79
+ execution_trace: trace,
80
+ private_data: data[:private_data] || {}
77
81
  ),
78
82
  error: data[:failure_reason]
79
83
  }
@@ -294,11 +298,72 @@ module RubyReactor
294
298
  when "map_ref" then hydrate_map_ref(value, reactor_class_name)
295
299
  when "async_step_ref" then hydrate_async_step_ref(value, reactor_class_name)
296
300
  when "async_reactor_ref" then hydrate_async_reactor_ref(value)
301
+ when "composed" then hydrate_composed_child(value)
297
302
  else value
298
303
  end
299
304
  end
300
305
  end
301
306
 
307
+ # An inline `compose` child is embedded in the parent's blob, so its own
308
+ # `async_step` links are rebuilt the same way, one level down.
309
+ def self.hydrate_composed_child(value)
310
+ child = value[:context] || value["context"]
311
+ return value unless child.is_a?(RubyReactor::Context)
312
+
313
+ class_name = RubyReactor.reactor_storage_name(child.reactor_class)
314
+ runs = async_step_runs(child.composed_contexts, class_name)
315
+ view = child.to_h.merge(
316
+ execution_trace: with_async_step_runs(child.execution_trace, runs),
317
+ composed_contexts: hydrate_composed_contexts(child.composed_contexts, class_name)
318
+ )
319
+ value.merge(context: view)
320
+ end
321
+
322
+ # Single writer: an `async_step` unit never writes its parent, which
323
+ # holds only the `:async_step_ref` written at dispatch. The unit's run —
324
+ # arguments, attempts, when it started — is on its Step Result Record.
325
+ # `{ step_name => record }` for every linked unit that has started.
326
+ def self.async_step_runs(composed_contexts, reactor_class_name)
327
+ return {} unless composed_contexts.is_a?(Hash)
328
+
329
+ composed_contexts.each_with_object({}) do |(name, ref), runs|
330
+ next unless (ref[:type] || ref["type"]).to_s == "async_step_ref"
331
+
332
+ record = async_step_record(ref, reactor_class_name)
333
+ runs[name] = record if record&.key?("started_at")
334
+ end
335
+ end
336
+
337
+ def self.async_step_record(ref_data, reactor_class_name)
338
+ context_id = ref_data[:context_id] || ref_data["context_id"]
339
+ name = ref_data[:name] || ref_data["name"]
340
+ return nil unless context_id && name
341
+
342
+ RubyReactor.configuration.storage_adapter.retrieve_step_result(context_id, name, reactor_class_name)
343
+ end
344
+
345
+ # The parent's own trace, with each started unit's `:run` entry placed by
346
+ # its start time — the entry a same-process step writes for itself. A
347
+ # unit that already has one (a context saved before units stopped
348
+ # writing their parent) is left as it is.
349
+ def self.with_async_step_runs(trace, runs)
350
+ runs.each_with_object(trace.dup) do |(name, record), merged|
351
+ next if merged.any? { |e| (e[:type] || e["type"]).to_s == "run" && (e[:step] || e["step"]).to_s == name.to_s }
352
+
353
+ started = Time.iso8601(record["started_at"])
354
+ entry = { type: :run, step: name, timestamp: started, background: true,
355
+ arguments: ContextSerializer.deserialize_value(record["arguments"]) }
356
+ later = merged.index { |e| e[:timestamp].is_a?(Time) && e[:timestamp] > started }
357
+ merged.insert(later || merged.size, entry)
358
+ end
359
+ end
360
+
361
+ def self.with_async_step_attempts(step_attempts, runs)
362
+ runs.each_with_object(step_attempts.dup) do |(name, record), merged|
363
+ merged[name] = record["attempts"] if record["attempts"]
364
+ end
365
+ end
366
+
302
367
  # The reference lives on the parent's context; the outcome lives in the
303
368
  # Step Result Record. Resolve it so the dashboard can show whether the
304
369
  # unit is still dispatched or has landed, mirroring hydrate_map_ref.
@@ -4,7 +4,7 @@ module RubyReactor
4
4
  module Web
5
5
  class CoordinationSerializer
6
6
  class << self
7
- def build(reactor_class, inputs:, context_id:)
7
+ def build(reactor_class, inputs:, context_id:, execution_trace: [], private_data: {})
8
8
  return {} unless reactor_class
9
9
 
10
10
  adapter = RubyReactor.configuration.storage_adapter
@@ -20,18 +20,122 @@ module RubyReactor
20
20
  end
21
21
 
22
22
  if reactor_class.rate_limit_config
23
- result[:rate_limit] = build_rate_limit(reactor_class.rate_limit_config, normalized_inputs, adapter)
23
+ result[:rate_limit] = build_rate_limit(resolve_rate_limit(reactor_class.rate_limit_config),
24
+ normalized_inputs, adapter)
24
25
  end
25
26
 
26
27
  if reactor_class.period_config
27
28
  result[:period] = build_period(reactor_class.period_config, normalized_inputs, adapter)
28
29
  end
29
30
 
31
+ if reactor_class.respond_to?(:steps)
32
+ steps = build_steps(reactor_class, normalized_inputs, context_id, execution_trace, adapter)
33
+ result[:steps] = steps unless steps.empty?
34
+ end
35
+
36
+ waiting = private_data[:step_contention] || private_data["step_contention"] ||
37
+ async_step_waiting(reactor_class, context_id, adapter)
38
+ result[:waiting] = normalize_waiting(waiting) if waiting
39
+
30
40
  result
31
41
  end
32
42
 
33
43
  private
34
44
 
45
+ # US7/FR-029: one row per coordinating step PER PRIMITIVE it declares,
46
+ # keyed to the step's OWN resolved arguments (from its latest `:run`
47
+ # trace entry), not the reactor's inputs. A step declaring both
48
+ # `with_lock` and `with_semaphore` is two rows — both gates are active,
49
+ # so an operator has to be able to see both. A step not yet reached is
50
+ # reported "pending" rather than omitted, so the list is stable.
51
+ def build_steps(reactor_class, inputs, context_id, execution_trace, adapter)
52
+ reactor_class.steps.flat_map do |name, step_config|
53
+ next [] unless step_config.respond_to?(:declares_coordination?) && step_config.declares_coordination?
54
+
55
+ build_step_entries(name, step_config, inputs, context_id, execution_trace, adapter)
56
+ end
57
+ end
58
+
59
+ def build_step_entries(name, step_config, inputs, context_id, execution_trace, adapter) # rubocop:disable Metrics/ParameterLists
60
+ entry = latest_run_entry(execution_trace, name)
61
+ declarations = step_config.coordination_declarations
62
+ return [{ step: name.to_s, state: "pending" }] if declarations.empty?
63
+
64
+ # One PENDING row per declared primitive too, matching the reached
65
+ # shape below — a step declaring both a lock and a semaphore has two
66
+ # gates to wait on, and collapsing them to a single primitive-less
67
+ # row hides which.
68
+ if entry.nil?
69
+ return declarations.keys.map do |primitive|
70
+ { step: name.to_s, primitive: primitive.to_s, state: "pending" }
71
+ end
72
+ end
73
+
74
+ # The trace records the RESOLVED arguments; the execution keyed off
75
+ # `coordination_arguments` of them, so the same function is applied
76
+ # here. A redacted value was never recorded, so a key computed from it
77
+ # would name a different Redis key than the one held — report it as
78
+ # unavailable instead of probing the wrong one.
79
+ traced = entry[:arguments] || entry["arguments"] || {}
80
+ if traced.is_a?(Hash) && traced.value?(RubyReactor::Step::InputContract::REDACTED)
81
+ return declarations.keys.map do |primitive|
82
+ { step: name.to_s, primitive: primitive.to_s, key: nil,
83
+ key_error: "key unavailable: the step's arguments are redacted" }
84
+ end
85
+ end
86
+ args = step_config.coordination_arguments(traced.transform_keys(&:to_sym), inputs)
87
+
88
+ declarations.map do |primitive, config|
89
+ built = build_step_primitive(primitive, config, args, context_id, adapter)
90
+ { step: name.to_s, primitive: primitive.to_s }.merge(built)
91
+ end
92
+ end
93
+
94
+ def build_step_primitive(primitive, config, args, context_id, adapter)
95
+ case primitive
96
+ when :lock then build_lock(config, args, context_id, adapter)
97
+ when :semaphore then build_semaphore(config, args, adapter)
98
+ when :rate_limit then build_rate_limit(resolve_rate_limit(config), args, adapter)
99
+ when :period then build_period(config, args, adapter)
100
+ else { key: resolve_key(config[:key_proc], args) }
101
+ end
102
+ rescue StandardError => e
103
+ { key: nil, key_error: e.message }
104
+ end
105
+
106
+ def latest_run_entry(execution_trace, step_name)
107
+ Array(execution_trace).reverse_each.find do |e|
108
+ type = e[:type] || e["type"]
109
+ step = e[:step] || e["step"]
110
+ type.to_s == "run" && step.to_s == step_name.to_s
111
+ end
112
+ end
113
+
114
+ # A parked `async_step` keeps its "waiting on" marker on its own Step
115
+ # Result Record, never on the parent's context (005 R-09).
116
+ def async_step_waiting(reactor_class, context_id, adapter)
117
+ return nil unless context_id && reactor_class.respond_to?(:steps)
118
+
119
+ namespace = RubyReactor.reactor_storage_name(reactor_class)
120
+ reactor_class.steps.each do |name, step_config|
121
+ next unless step_config.respond_to?(:async_dispatch) && step_config.async_dispatch == :step
122
+
123
+ record = adapter.retrieve_step_result(context_id, name, namespace)
124
+ return record["waiting"] if record.is_a?(Hash) && record["waiting"]
125
+ end
126
+ nil
127
+ end
128
+
129
+ def normalize_waiting(waiting)
130
+ {
131
+ step: (waiting[:step] || waiting["step"]).to_s,
132
+ key: waiting[:key] || waiting["key"],
133
+ primitive: (waiting[:primitive] || waiting["primitive"]).to_s,
134
+ attempts: waiting[:attempts] || waiting["attempts"],
135
+ next_attempt_at: waiting[:next_attempt_at] || waiting["next_attempt_at"]
136
+ }
137
+ end
138
+
35
139
  def normalize_inputs(inputs)
36
140
  return {} unless inputs.is_a?(Hash)
37
141
 
@@ -134,6 +238,20 @@ module RubyReactor
134
238
  }
135
239
  end
136
240
 
241
+ # `with_rate_limit(:name)` stores only the name; the renderer needs the
242
+ # registered windows and the name-as-key the limiter actually uses
243
+ # (mirrors `StepCoordination#rate_limit_key_and_limits`).
244
+ def resolve_rate_limit(config)
245
+ return config unless config[:name]
246
+
247
+ name = config[:name]
248
+ { limits: RubyReactor.configuration.rate_limits.fetch(name), key_proc: ->(_args) { name.to_s } }
249
+ rescue StandardError => e
250
+ # An unregistered name is a config mistake, not a reason for the
251
+ # dashboard to 500 — surface it as this row's key_error.
252
+ { limits: [], key_proc: ->(_args) { raise e } }
253
+ end
254
+
137
255
  def map_limits(limits)
138
256
  Array(limits).map do |window|
139
257
  {