ruby_reactor 0.4.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7769f6488ce48db7f07cd9a7bb9774f697cadc6433293ca926a9e1c27b28ce3e
4
- data.tar.gz: 9dd0d5edd43c8b7629474ca2c12dd8f1c755d43f7fab3bc33aaefb86a183ba7c
3
+ metadata.gz: 729d8c7da4954534a2775a360d79fc96326d76b5ca69c7361ca0433e4bd6571e
4
+ data.tar.gz: e9e545d2ea937135f19c7c04697993125fdfc38d57fc71d6a7b0aede9bf5efa9
5
5
  SHA512:
6
- metadata.gz: afbabcc37d4dbba1a6fcb83fb15b88160979802b7cd5d69fd0aed47538080c4abd3196b40a7f7464047f94c2aafa6d5317e825446179e9129a960c0e5d119742
7
- data.tar.gz: caef3d4d35e37a0c9bc35b273ad20f8a0dc31810e35cf2ea1b50df57d347762c85d2a42aa6b3125401e324725d0e8f17f9d1acfecb5f8f3bafeeaf9a7308a5d2
6
+ metadata.gz: 23aac29ebf4c4e018fc3ab4bffbc46d79fa68ea74d783034fb984094cb7d97e791fc194c767ab59fb4992be267c171f6cbcc1897fb6160df4bedeb88faa3f080
7
+ data.tar.gz: edffc8600293e035e1d318a4f4b7f7240ec67aa06704e941465e06c6eadda16e1939c36e2a826bd0b92183728360cb3babab4b26b877548559f61a64f2977c4a
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "include-component-in-tag": false,
4
+ "include-v-in-tag": true,
5
+ "always-update": true,
3
6
  "packages": {
4
7
  ".": {
5
8
  "release-type": "ruby",
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.4.0"
2
+ ".": "0.4.1"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.1](https://github.com/arturictus/ruby_reactor/compare/v0.4.0...v0.4.1) (2026-05-25)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * trigger release pipeline ([#29](https://github.com/arturictus/ruby_reactor/issues/29)) ([862478b](https://github.com/arturictus/ruby_reactor/commit/862478b3d0811b00e920119057bf4c1bfb1808af))
9
+ * trigger release workflows ([#31](https://github.com/arturictus/ruby_reactor/issues/31)) ([ed44dcd](https://github.com/arturictus/ruby_reactor/commit/ed44dcd00e3288e2fab99f9794821943dacc1d4b))
10
+
3
11
  ## [0.4.0](https://github.com/arturictus/ruby_reactor/compare/ruby_reactor-v0.3.2...ruby_reactor/v0.4.0) (2026-05-17)
4
12
 
5
13
 
@@ -5,7 +5,7 @@ module RubyReactor
5
5
  attr_accessor :inputs, :intermediate_results, :private_data, :current_step, :retry_count, :concurrency_key,
6
6
  :retry_context, :reactor_class, :execution_trace, :inline_async_execution, :undo_stack,
7
7
  :parent_context, :root_context, :composed_contexts, :context_id, :map_operations, :map_metadata,
8
- :cancelled, :cancellation_reason, :parent_context_id, :status, :failure_reason
8
+ :cancelled, :cancellation_reason, :parent_context_id, :retried_from_id, :status, :failure_reason
9
9
 
10
10
  def initialize(inputs = {}, reactor_class = nil)
11
11
  @context_id = SecureRandom.uuid
@@ -29,6 +29,7 @@ module RubyReactor
29
29
  @failure_reason = nil
30
30
  @parent_context = nil
31
31
  @parent_context_id = nil
32
+ @retried_from_id = nil
32
33
  @root_context = nil
33
34
  end
34
35
 
@@ -119,7 +120,8 @@ module RubyReactor
119
120
  cancellation_reason: @cancellation_reason,
120
121
  status: @status,
121
122
  failure_reason: ContextSerializer.serialize_value(@failure_reason),
122
- parent_context_id: @parent_context&.context_id || @parent_context_id
123
+ parent_context_id: @parent_context&.context_id || @parent_context_id,
124
+ retried_from_id: @retried_from_id
123
125
  }
124
126
  end
125
127
 
@@ -144,6 +146,7 @@ module RubyReactor
144
146
  context.status = data["status"] || "pending"
145
147
  context.failure_reason = ContextSerializer.deserialize_value(data["failure_reason"])
146
148
  context.parent_context_id = data["parent_context_id"]
149
+ context.retried_from_id = data["retried_from_id"]
147
150
 
148
151
  context
149
152
  end
@@ -181,19 +181,63 @@ module RubyReactor
181
181
  def simplify_for_api(value)
182
182
  case value
183
183
  when Hash
184
- value.each_with_object({}) do |(k, v), hash|
184
+ simplified = value.each_with_object({}) do |(k, v), hash|
185
185
  hash[k.to_s] = simplify_for_api(v)
186
186
  end
187
+ enrich_failure_for_api(simplified)
187
188
  when Array
188
189
  value.map { |v| simplify_for_api(v) }
189
190
  when Success, Failure, Context
190
- simplify_for_api(value.to_h)
191
+ enrich_failure_for_api(simplify_for_api(value.to_h))
191
192
  when Symbol
192
193
  value.to_s
193
194
  else
194
195
  value
195
196
  end
196
197
  end
198
+
199
+ def enrich_failure_for_api(hash)
200
+ return hash unless hash.is_a?(Hash)
201
+ return hash unless failure_payload?(hash)
202
+
203
+ hash = flatten_typed_failure(hash) if hash["_type"] == "Failure"
204
+
205
+ if hash["code_snippet"].is_a?(Array) && !hash["code_snippet"].empty?
206
+ return hash
207
+ end
208
+
209
+ file_path, line_number = resolve_failure_location(hash)
210
+ return hash unless file_path && line_number
211
+
212
+ snippet = RubyReactor::Utils::CodeExtractor.extract(file_path, line_number)
213
+ return hash unless snippet
214
+
215
+ normalized_snippet = snippet.map { |line| line.transform_keys(&:to_s) }
216
+
217
+ hash.merge(
218
+ "file_path" => file_path,
219
+ "line_number" => line_number,
220
+ "code_snippet" => normalized_snippet
221
+ )
222
+ end
223
+
224
+ def failure_payload?(hash)
225
+ hash["_type"] == "Failure" || (hash.key?("step_name") && hash["backtrace"].is_a?(Array))
226
+ end
227
+
228
+ def flatten_typed_failure(hash)
229
+ flattened = hash.dup
230
+ flattened.delete("_type")
231
+ flattened.merge("message" => hash["error"] || hash["message"])
232
+ end
233
+
234
+ def resolve_failure_location(hash)
235
+ file_path = hash["file_path"]
236
+ line_number = hash["line_number"]
237
+ return [file_path, line_number] if file_path && line_number
238
+
239
+ RubyReactor::Utils::BacktraceLocation.extract(hash["backtrace"])
240
+ end
197
241
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
198
242
 
199
243
  private
@@ -152,12 +152,21 @@ module RubyReactor
152
152
  # Entry point for running the reactor
153
153
  def run(inputs = {})
154
154
  reactor = new
155
- reactor.run(inputs)
155
+ result = reactor.run(inputs)
156
+ attach_execution_id!(result, reactor.context.context_id)
156
157
  end
157
158
 
158
159
  def call(inputs = {})
159
160
  run(inputs)
160
161
  end
162
+
163
+ def attach_execution_id!(result, execution_id)
164
+ return result if result.respond_to?(:execution_id) && result.execution_id
165
+
166
+ result.define_singleton_method(:execution_id) { execution_id }
167
+ result
168
+ end
169
+ private :attach_execution_id!
161
170
  end
162
171
  end
163
172
  end
@@ -189,18 +189,7 @@ module RubyReactor
189
189
  end
190
190
 
191
191
  def extract_location(backtrace)
192
- return [nil, nil] unless backtrace && !backtrace.empty?
193
-
194
- # Filter out internal reactor frames if needed, or just take the first one
195
- # For now, let's take the first line of the backtrace which should be the error source
196
- # But we might want to skip our own internal frames if we want to point to user code
197
- # Let's start with the top frame, assuming backtrace is already correct (from original error)
198
-
199
- first_line = backtrace.first
200
- match = first_line.match(/^(.+?):(\d+)(?::in `.*')?$/)
201
- return [nil, nil] unless match
202
-
203
- [match[1], match[2].to_i]
192
+ RubyReactor::Utils::BacktraceLocation.extract(backtrace)
204
193
  end
205
194
  end
206
195
  end
@@ -75,7 +75,7 @@ module RubyReactor
75
75
  @result
76
76
  ensure
77
77
  release_locks
78
- save_context
78
+ save_context if persist_context?
79
79
  end
80
80
 
81
81
  def resume_execution
@@ -134,6 +134,12 @@ module RubyReactor
134
134
  storage.store_context(@context.context_id, serialized_context, reactor_class_name)
135
135
  end
136
136
 
137
+ def persist_context?
138
+ @context.status.to_s != "pending" ||
139
+ @context.execution_trace.any? ||
140
+ @context.intermediate_results.any?
141
+ end
142
+
137
143
  private
138
144
 
139
145
  def acquire_locks
@@ -10,10 +10,17 @@ module RubyReactor
10
10
 
11
11
  def self.find(id)
12
12
  reactor_class_name = name
13
- serialized_context = configuration.storage_adapter.retrieve_context(id, reactor_class_name)
14
- raise Error::ValidationError, "Context '#{id}' not found" unless serialized_context
15
-
16
- context = Context.deserialize_from_retry(serialized_context)
13
+ raw_data = configuration.storage_adapter.retrieve_context(id, reactor_class_name)
14
+ raise Error::ValidationError, "Context '#{id}' not found" unless raw_data
15
+
16
+ context = case raw_data
17
+ when String
18
+ ContextSerializer.deserialize(raw_data)
19
+ when Hash
20
+ Context.deserialize_from_retry(raw_data)
21
+ else
22
+ raise Error::ValidationError, "Invalid context format for '#{id}'"
23
+ end
17
24
  new(context)
18
25
  end
19
26
 
@@ -196,13 +196,20 @@ module RubyReactor
196
196
  end
197
197
 
198
198
  def determine_status(data)
199
- return data["status"] if data["status"] && %w[failed paused completed running].include?(data["status"])
199
+ status = data["status"].to_s
200
+ return status if status && %w[failed paused completed running skipped pending].include?(status)
200
201
  return "cancelled" if data["cancelled"]
201
202
  # Heuristic
202
203
  return "failed" if data["retry_count"]&.positive? && !data["current_step"].nil?
203
- return "completed" unless data["current_step"]
204
+ return "running" if data["current_step"]
205
+ return "completed" if execution_evidence?(data)
204
206
 
205
- "running"
207
+ "pending"
208
+ end
209
+
210
+ def execution_evidence?(data)
211
+ (data["execution_trace"] || []).any? ||
212
+ (data["intermediate_results"] || {}).any?
206
213
  end
207
214
 
208
215
  def store_map_element_context_id(map_id, context_id, reactor_class_name)
@@ -245,6 +245,23 @@ module RubyReactor
245
245
  def period_marker?(key_base, every, now: Time.now.utc)
246
246
  @redis.exists?(RubyReactor::Period.key(key_base, every, now: now))
247
247
  end
248
+
249
+ # TTL in seconds for a held lock (-2 if key does not exist).
250
+ def lock_ttl(prefixed_key)
251
+ @redis.ttl(prefixed_key)
252
+ end
253
+
254
+ # TTL in seconds for the current rate-limit bucket (-2 if unset).
255
+ def rate_limit_ttl(key_base, every, now: Time.now.to_i)
256
+ period_seconds = RubyReactor::Period.period_seconds(every)
257
+ bucket = now / period_seconds
258
+ @redis.ttl("rate:#{key_base}:#{every}:#{bucket}")
259
+ end
260
+
261
+ # TTL in seconds for a period marker (-2 if unset).
262
+ def period_ttl(key_base, every, now: Time.now.utc)
263
+ @redis.ttl(RubyReactor::Period.key(key_base, every, now: now))
264
+ end
248
265
  end
249
266
  # rubocop:enable Naming/PredicateMethod
250
267
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Utils
5
+ class BacktraceLocation
6
+ # Ruby 3.x backtraces use single-quoted method names; older formats use backticks.
7
+ LINE_PATTERN = /^(.+?):(\d+)(?::in .*)?$/
8
+
9
+ def self.parse(line)
10
+ match = line.match(LINE_PATTERN)
11
+ return nil unless match
12
+
13
+ [match[1], match[2].to_i]
14
+ end
15
+
16
+ def self.internal_path?(file_path)
17
+ file_path.start_with?(RubyReactor.internal_lib_path)
18
+ end
19
+
20
+ def self.extract(backtrace)
21
+ return [nil, nil] unless backtrace.is_a?(Array) && backtrace.any?
22
+
23
+ skip_internal = ENV["RUBY_REACTOR_DEBUG"] != "true"
24
+
25
+ backtrace.each do |line|
26
+ file_path, line_number = parse(line)
27
+ next unless file_path
28
+ next if skip_internal && internal_path?(file_path)
29
+
30
+ return [file_path, line_number]
31
+ end
32
+
33
+ parse(backtrace.first) || [nil, nil]
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "roda"
4
+ require_relative "coordination_serializer"
4
5
 
5
6
  module RubyReactor
6
7
  module Web
@@ -35,16 +36,12 @@ module RubyReactor
35
36
 
36
37
  structure = self.class.build_structure(reactor_class) if reactor_class.respond_to?(:steps)
37
38
 
39
+ api_status = self.class.reactor_status(data)
40
+
38
41
  response_data = {
39
42
  id: data[:context_id],
40
43
  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]
44
- "cancelled"
45
- else
46
- (data[:current_step] ? "running" : "completed")
47
- end,
44
+ status: api_status,
48
45
  current_step: data[:current_step].to_s,
49
46
  retry_count: data[:retry_count] || 0,
50
47
  undo_stack: data[:undo_stack] || [],
@@ -58,6 +55,11 @@ module RubyReactor
58
55
  data[:composed_contexts] || {},
59
56
  data[:reactor_class]&.to_s
60
57
  ),
58
+ coordination: CoordinationSerializer.build(
59
+ reactor_class,
60
+ inputs: data[:inputs],
61
+ context_id: data[:context_id]
62
+ ),
61
63
  error: data[:failure_reason]
62
64
  }
63
65
 
@@ -66,7 +68,43 @@ module RubyReactor
66
68
 
67
69
  # POST /api/reactors/:id/retry
68
70
  r.post "retry" do
69
- { success: true, message: "Retry scheduled" }
71
+ data = RubyReactor::Configuration.instance.storage_adapter.find_context_by_id(reactor_id)
72
+ unless data
73
+ response.status = 404
74
+ next { error: "Reactor not found" }
75
+ end
76
+
77
+ deserialized = ContextSerializer.deserialize_value(data)
78
+ unless self.class.reactor_status(deserialized) == "failed"
79
+ response.status = 422
80
+ next { error: "Reactor can only be retried when failed" }
81
+ end
82
+
83
+ reactor_class_name = deserialized[:reactor_class].to_s
84
+ reactor_class = Context.resolve_reactor_class(reactor_class_name)
85
+ unless reactor_class
86
+ response.status = 422
87
+ next { error: "Reactor class '#{reactor_class_name}' not found" }
88
+ end
89
+
90
+ begin
91
+ inputs = self.class.extract_retry_inputs(deserialized)
92
+ result = reactor_class.run(inputs)
93
+ new_id = result.execution_id
94
+
95
+ new_reactor = reactor_class.find(new_id)
96
+ new_reactor.context.retried_from_id = reactor_id
97
+ new_reactor.context.retry_count = (deserialized[:retry_count] || 0) + 1
98
+ new_reactor.send(:save_context)
99
+
100
+ { success: true, id: new_id }
101
+ rescue RubyReactor::Error::ValidationError => e
102
+ response.status = 422
103
+ { error: e.message }
104
+ rescue StandardError => e
105
+ response.status = 500
106
+ { error: e.message }
107
+ end
70
108
  end
71
109
 
72
110
  # POST /api/reactors/:id/cancel
@@ -107,6 +145,28 @@ module RubyReactor
107
145
  end
108
146
  end
109
147
 
148
+ def self.reactor_status(data)
149
+ status = data[:status].to_s
150
+ return status if %w[failed paused completed running skipped pending].include?(status)
151
+ return "cancelled" if data[:cancelled]
152
+ return "running" if data[:current_step]
153
+ return "completed" if execution_evidence?(data)
154
+
155
+ "pending"
156
+ end
157
+
158
+ def self.execution_evidence?(data)
159
+ (data[:execution_trace] || []).any? ||
160
+ (data[:intermediate_results] || {}).any?
161
+ end
162
+
163
+ def self.extract_retry_inputs(data)
164
+ inputs = data[:inputs] || {}
165
+ return {} unless inputs.is_a?(Hash)
166
+
167
+ inputs.transform_keys(&:to_sym)
168
+ end
169
+
110
170
  def self.build_structure(reactor_class)
111
171
  return {} unless reactor_class.respond_to?(:steps)
112
172
 
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Web
5
+ class CoordinationSerializer
6
+ class << self
7
+ def build(reactor_class, inputs:, context_id:)
8
+ return {} unless reactor_class
9
+
10
+ adapter = RubyReactor.configuration.storage_adapter
11
+ normalized_inputs = normalize_inputs(inputs)
12
+ result = {}
13
+
14
+ if reactor_class.lock_config
15
+ result[:lock] = build_lock(reactor_class.lock_config, normalized_inputs, context_id, adapter)
16
+ end
17
+
18
+ if reactor_class.semaphore_config
19
+ result[:semaphore] = build_semaphore(reactor_class.semaphore_config, normalized_inputs, adapter)
20
+ end
21
+
22
+ if reactor_class.rate_limit_config
23
+ result[:rate_limit] = build_rate_limit(reactor_class.rate_limit_config, normalized_inputs, adapter)
24
+ end
25
+
26
+ if reactor_class.period_config
27
+ result[:period] = build_period(reactor_class.period_config, normalized_inputs, adapter)
28
+ end
29
+
30
+ result
31
+ end
32
+
33
+ private
34
+
35
+ def normalize_inputs(inputs)
36
+ return {} unless inputs.is_a?(Hash)
37
+
38
+ inputs.transform_keys(&:to_sym)
39
+ end
40
+
41
+ def resolve_key(key_proc, inputs)
42
+ key_proc.call(inputs).to_s
43
+ end
44
+
45
+ def build_lock(config, inputs, context_id, adapter)
46
+ key = resolve_key(config[:key_proc], inputs)
47
+ prefixed = "lock:#{key}"
48
+ info = adapter.lock_info(prefixed)
49
+ ttl = adapter.lock_ttl(prefixed)
50
+
51
+ {
52
+ configured: {
53
+ ttl: config[:ttl],
54
+ wait: config[:wait],
55
+ auto_extend: config.fetch(:auto_extend, true)
56
+ },
57
+ key: key,
58
+ state: lock_state(info, context_id, ttl)
59
+ }
60
+ rescue StandardError => e
61
+ lock_error_payload(config, e)
62
+ end
63
+
64
+ def lock_state(info, context_id, ttl)
65
+ if info
66
+ {
67
+ held: true,
68
+ owner: info[:owner],
69
+ owned_by_this_context: info[:owner] == context_id,
70
+ reentrant_count: info[:count],
71
+ ttl: ttl
72
+ }
73
+ else
74
+ { held: false, ttl: ttl }
75
+ end
76
+ end
77
+
78
+ def lock_error_payload(config, error)
79
+ {
80
+ configured: {
81
+ ttl: config[:ttl],
82
+ wait: config[:wait],
83
+ auto_extend: config.fetch(:auto_extend, true)
84
+ },
85
+ key: nil,
86
+ key_error: error.message
87
+ }
88
+ end
89
+
90
+ def build_semaphore(config, inputs, adapter)
91
+ key = resolve_key(config[:key_proc], inputs)
92
+ state = adapter.semaphore_state(key)
93
+
94
+ {
95
+ configured: {
96
+ limit: config[:limit],
97
+ wait: config[:wait]
98
+ },
99
+ key: key,
100
+ state: state
101
+ }
102
+ rescue StandardError => e
103
+ {
104
+ configured: { limit: config[:limit], wait: config[:wait] },
105
+ key: nil,
106
+ key_error: e.message
107
+ }
108
+ end
109
+
110
+ def build_rate_limit(config, inputs, adapter)
111
+ key = resolve_key(config[:key_proc], inputs)
112
+ now = Time.now.to_i
113
+ windows = config[:limits].map do |window|
114
+ every = window[:name].to_sym
115
+ {
116
+ name: window[:name],
117
+ limit: window[:limit],
118
+ period_seconds: window[:period_seconds],
119
+ count: adapter.rate_limit_count(key, every, now: now),
120
+ ttl: adapter.rate_limit_ttl(key, every, now: now)
121
+ }
122
+ end
123
+
124
+ {
125
+ configured: {
126
+ limits: config[:limits].map do |window|
127
+ {
128
+ name: window[:name],
129
+ limit: window[:limit],
130
+ period_seconds: window[:period_seconds]
131
+ }
132
+ end
133
+ },
134
+ key: key,
135
+ state: windows
136
+ }
137
+ rescue StandardError => e
138
+ {
139
+ configured: {
140
+ limits: config[:limits]&.map do |window|
141
+ {
142
+ name: window[:name],
143
+ limit: window[:limit],
144
+ period_seconds: window[:period_seconds]
145
+ }
146
+ end
147
+ },
148
+ key: nil,
149
+ key_error: e.message
150
+ }
151
+ end
152
+
153
+ def build_period(config, inputs, adapter)
154
+ key = resolve_key(config[:key_proc], inputs)
155
+ every = config[:every]
156
+ bucket_key = RubyReactor::Period.key(key, every)
157
+ marked = adapter.period_marker?(key, every)
158
+ ttl = adapter.period_ttl(key, every)
159
+
160
+ {
161
+ configured: { every: every.to_s },
162
+ key: key,
163
+ bucket_key: bucket_key,
164
+ state: {
165
+ marked: marked,
166
+ ttl: ttl
167
+ }
168
+ }
169
+ rescue StandardError => e
170
+ {
171
+ configured: { every: config[:every].to_s },
172
+ key: nil,
173
+ bucket_key: nil,
174
+ key_error: e.message
175
+ }
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end