ruby_reactor 0.3.2 → 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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-config.json +18 -0
  3. data/.release-please-manifest.json +3 -0
  4. data/.tool-versions +1 -0
  5. data/CHANGELOG.md +21 -0
  6. data/README.md +80 -4
  7. data/lib/ruby_reactor/context.rb +5 -2
  8. data/lib/ruby_reactor/context_serializer.rb +56 -3
  9. data/lib/ruby_reactor/dsl/reactor.rb +10 -1
  10. data/lib/ruby_reactor/executor/result_handler.rb +1 -12
  11. data/lib/ruby_reactor/executor.rb +7 -1
  12. data/lib/ruby_reactor/map/result_enumerator.rb +4 -3
  13. data/lib/ruby_reactor/rate_limit.rb +2 -2
  14. data/lib/ruby_reactor/reactor.rb +11 -4
  15. data/lib/ruby_reactor/sidekiq_workers/worker.rb +58 -1
  16. data/lib/ruby_reactor/storage/redis_adapter.rb +10 -3
  17. data/lib/ruby_reactor/storage/redis_locking.rb +17 -0
  18. data/lib/ruby_reactor/utils/backtrace_location.rb +37 -0
  19. data/lib/ruby_reactor/version.rb +1 -1
  20. data/lib/ruby_reactor/web/api.rb +68 -8
  21. data/lib/ruby_reactor/web/coordination_serializer.rb +180 -0
  22. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +1 -0
  23. data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +21 -0
  24. data/lib/ruby_reactor/web/public/index.html +2 -2
  25. data/lib/ruby_reactor.rb +7 -2
  26. metadata +11 -54
  27. data/documentation/DAG.md +0 -457
  28. data/documentation/README.md +0 -135
  29. data/documentation/async_reactors.md +0 -381
  30. data/documentation/composition.md +0 -199
  31. data/documentation/core_concepts.md +0 -676
  32. data/documentation/data_pipelines.md +0 -230
  33. data/documentation/examples/inventory_management.md +0 -748
  34. data/documentation/examples/order_processing.md +0 -380
  35. data/documentation/examples/payment_processing.md +0 -565
  36. data/documentation/getting_started.md +0 -242
  37. data/documentation/images/failed_order_processing.png +0 -0
  38. data/documentation/images/payment_workflow.png +0 -0
  39. data/documentation/interrupts.md +0 -163
  40. data/documentation/locks_and_semaphores.md +0 -459
  41. data/documentation/retry_configuration.md +0 -362
  42. data/documentation/testing.md +0 -994
  43. data/gui/.gitignore +0 -24
  44. data/gui/README.md +0 -73
  45. data/gui/eslint.config.js +0 -23
  46. data/gui/index.html +0 -13
  47. data/gui/package-lock.json +0 -5925
  48. data/gui/package.json +0 -46
  49. data/gui/postcss.config.js +0 -6
  50. data/gui/public/vite.svg +0 -1
  51. data/gui/src/App.css +0 -42
  52. data/gui/src/App.tsx +0 -51
  53. data/gui/src/assets/react.svg +0 -1
  54. data/gui/src/components/DagVisualizer.tsx +0 -424
  55. data/gui/src/components/Dashboard.tsx +0 -163
  56. data/gui/src/components/ErrorBoundary.tsx +0 -47
  57. data/gui/src/components/ReactorDetail.tsx +0 -135
  58. data/gui/src/components/StepInspector.tsx +0 -492
  59. data/gui/src/components/__tests__/DagVisualizer.test.tsx +0 -140
  60. data/gui/src/components/__tests__/ReactorDetail.test.tsx +0 -111
  61. data/gui/src/components/__tests__/StepInspector.test.tsx +0 -408
  62. data/gui/src/globals.d.ts +0 -7
  63. data/gui/src/index.css +0 -14
  64. data/gui/src/lib/utils.ts +0 -13
  65. data/gui/src/main.tsx +0 -14
  66. data/gui/src/test/setup.ts +0 -11
  67. data/gui/tailwind.config.js +0 -11
  68. data/gui/tsconfig.app.json +0 -28
  69. data/gui/tsconfig.json +0 -7
  70. data/gui/tsconfig.node.json +0 -26
  71. data/gui/vite.config.ts +0 -8
  72. data/gui/vitest.config.ts +0 -13
  73. data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +0 -19
  74. data/lib/ruby_reactor/web/public/assets/index-_z-6BvuM.css +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de5c91a5450cb522097965aa4348760438b60babd05da33f9f7ec1a07fe9bd07
4
- data.tar.gz: d565e81c6ba6e057b56912311bf16dee95ce0c441eb76f2b893658b751568b2c
3
+ metadata.gz: 729d8c7da4954534a2775a360d79fc96326d76b5ca69c7361ca0433e4bd6571e
4
+ data.tar.gz: e9e545d2ea937135f19c7c04697993125fdfc38d57fc71d6a7b0aede9bf5efa9
5
5
  SHA512:
6
- metadata.gz: dde49e045303aeb4bcf414ea30b7d85d8fb8a782f873dd9ebb4a9d7d0524e74de37ba4e53ddc11ac6dea360bf1226f946d0c50e74e685da2ac70394065f436d6
7
- data.tar.gz: 7b9b0b5bf75866876bdc1811dfc1118272963337431148f480d6570e19bfc2e9d93a6fdfd91a3629734501a590e23be579b98ff2e2f4f125831410351e0e6865
6
+ metadata.gz: 23aac29ebf4c4e018fc3ab4bffbc46d79fa68ea74d783034fb984094cb7d97e791fc194c767ab59fb4992be267c171f6cbcc1897fb6160df4bedeb88faa3f080
7
+ data.tar.gz: edffc8600293e035e1d318a4f4b7f7240ec67aa06704e941465e06c6eadda16e1939c36e2a826bd0b92183728360cb3babab4b26b877548559f61a64f2977c4a
@@ -0,0 +1,18 @@
1
+ {
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,
6
+ "packages": {
7
+ ".": {
8
+ "release-type": "ruby",
9
+ "package-name": "ruby_reactor",
10
+ "version-file": "lib/ruby_reactor/version.rb",
11
+ "changelog-path": "CHANGELOG.md",
12
+ "bump-minor-pre-major": true,
13
+ "bump-patch-for-minor-pre-major": false,
14
+ "draft": false,
15
+ "prerelease": false
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.1"
3
+ }
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 3.4.8
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
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
+
11
+ ## [0.4.0](https://github.com/arturictus/ruby_reactor/compare/ruby_reactor-v0.3.2...ruby_reactor/v0.4.0) (2026-05-17)
12
+
13
+
14
+ ### Features
15
+
16
+ * `AsyncResult` returning intermediate_results ([#10](https://github.com/arturictus/ruby_reactor/issues/10)) ([0cb96d6](https://github.com/arturictus/ruby_reactor/commit/0cb96d66e88097665998601276e38e1c2249c581))
17
+ * enhance deserialization error handling in Sidekiq worker ([#23](https://github.com/arturictus/ruby_reactor/issues/23)) ([60dde95](https://github.com/arturictus/ruby_reactor/commit/60dde95606d52cc6a9d352ad0117b4092a1ebb9d))
18
+ * Enhance failure messages with step, reactor, redacted inputs, a… ([#11](https://github.com/arturictus/ruby_reactor/issues/11)) ([952feae](https://github.com/arturictus/ruby_reactor/commit/952feaeb6ebbe5fbe2daf470263d8e769ba64138))
19
+ * Introduce reactor interrupt functionality, allowing pausing and… ([#13](https://github.com/arturictus/ruby_reactor/issues/13)) ([53d0861](https://github.com/arturictus/ruby_reactor/commit/53d0861f0238f0e2247e581b0a27cba2f42cfba6))
20
+ * Rspec helpers ([#19](https://github.com/arturictus/ruby_reactor/issues/19)) ([cb71f80](https://github.com/arturictus/ruby_reactor/commit/cb71f80c0708dacf6c10c0beac88446b00f30f54))
21
+ * Web Dashboard ([#14](https://github.com/arturictus/ruby_reactor/issues/14)) ([80255dd](https://github.com/arturictus/ruby_reactor/commit/80255dd40800af8f6ed804de9c6f151331742fd5))
data/README.md CHANGED
@@ -132,22 +132,40 @@ puts result.value # => "Hello from Ruby Reactor!"
132
132
 
133
133
  ## Web Dashboard
134
134
 
135
- RubyReactor comes with a built-in web dashboard to inspect reactor executions, view logs, and retry failed steps.
135
+ RubyReactor ships with a built-in web dashboard to inspect reactor executions, view logs, and retry failed steps. The dashboard is a Rack app (a [Roda](https://roda.jeremyevans.net/) application) bundled inside the gem with its pre-compiled JS/CSS assets — no extra install or asset build step is required.
136
136
 
137
137
  ### Rails Installation
138
138
 
139
- Mount the dashboard engine in your `config/routes.rb`:
139
+ Add the gem to your `Gemfile`:
140
+
141
+ ```ruby
142
+ gem "ruby_reactor"
143
+ ```
144
+
145
+ Then mount the dashboard in your `config/routes.rb`:
140
146
 
141
147
  ```ruby
142
148
  Rails.application.routes.draw do
143
149
  # ... other routes
144
- mount RubyReactor::Web::Application => '/ruby_reactor'
150
+ mount RubyReactor::Web::Application => "/ruby_reactor"
145
151
  end
146
152
  ```
147
153
 
154
+ That's it — visit `/ruby_reactor` and the UI loads. `RubyReactor::Web::Application` is autoloaded by Zeitwerk on first reference, so no extra `require` is needed.
155
+
156
+ ### Rack / Sinatra / Standalone
157
+
158
+ Because it's a plain Rack app, you can mount it anywhere `call(env)` is accepted:
159
+
160
+ ```ruby
161
+ # config.ru
162
+ require "ruby_reactor/web/application"
163
+ run RubyReactor::Web::Application
164
+ ```
165
+
148
166
  ![RubyReactor Dashboard Screenshot](documentation/images/failed_order_processing.png)
149
167
 
150
- You can secure the dashboard using standard Rails authentication methods (e.g., `authenticate` block with Devise).
168
+ You can secure the dashboard using standard Rails authentication methods (e.g., wrapping the `mount` line in an `authenticate` block with Devise, or in a `constraints` block).
151
169
 
152
170
  ## Usage
153
171
 
@@ -871,6 +889,64 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
871
889
 
872
890
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
873
891
 
892
+ ### Running Redis for the test suite
893
+
894
+ The gem's RSpec suite expects Redis on port `6780` (see [spec/spec_helper.rb](spec/spec_helper.rb)). Start it via Docker Compose:
895
+
896
+ ```bash
897
+ docker compose up -d redis-test
898
+ ```
899
+
900
+ Then run the suite:
901
+
902
+ ```bash
903
+ bundle exec rspec
904
+ ```
905
+
906
+ Stop it when done:
907
+
908
+ ```bash
909
+ docker compose stop redis-test
910
+ ```
911
+
912
+ ### Running the demo Rails app
913
+
914
+ The demo Rails app under [demo_app/](demo_app/) has its own Redis (port `6380`) and bind-mounts the repo so edits to `lib/` are live. Two ways to run it:
915
+
916
+ **Option A — fully containerized (Redis + Rails + Sidekiq):**
917
+
918
+ ```bash
919
+ docker compose up demo-redis demo-app demo-sidekiq
920
+ ```
921
+
922
+ App available at <http://localhost:3789>.
923
+
924
+ **Option B — Redis in Docker, Rails on host:**
925
+
926
+ ```bash
927
+ docker compose up -d demo-redis
928
+
929
+ cd demo_app
930
+ bin/rails db:prepare
931
+ REDIS_URL=redis://localhost:6380/1 bin/rails server
932
+ # in another shell, if you need Sidekiq:
933
+ REDIS_URL=redis://localhost:6380/1 bundle exec sidekiq
934
+ ```
935
+
936
+ To run the demo app specs:
937
+
938
+ ```bash
939
+ cd demo_app
940
+ bundle exec rspec
941
+ ```
942
+
943
+ Tear everything down:
944
+
945
+ ```bash
946
+ docker compose down # stop containers
947
+ docker compose down -v # also remove demo_redis_data + bundle_cache volumes
948
+ ```
949
+
874
950
  ## Contributing
875
951
 
876
952
  Bug reports and pull requests are welcome on GitHub at https://github.com/arturictus/ruby_reactor. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/arturictus/ruby_reactor/blob/main/CODE_OF_CONDUCT.md).
@@ -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
@@ -145,7 +145,7 @@ module RubyReactor
145
145
  when "Regexp"
146
146
  Regexp.new(value["source"], value["options"])
147
147
  when "GlobalID"
148
- GlobalID::Locator.locate(value["gid"])
148
+ locate_global_id(value["gid"])
149
149
  when "Template::Element"
150
150
  RubyReactor::Template::Element.new(value["map_name"], value["path"])
151
151
  when "Template::Input"
@@ -181,23 +181,76 @@ 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
200
244
 
245
+ def locate_global_id(gid)
246
+ unless defined?(GlobalID::Locator)
247
+ raise RubyReactor::Error::DeserializationError,
248
+ "globalid gem is required to deserialize GlobalID values (gid: #{gid})"
249
+ end
250
+
251
+ GlobalID::Locator.locate(gid)
252
+ end
253
+
201
254
  def validate_size(data)
202
255
  size = data.bytesize
203
256
  return if size <= MAX_CONTEXT_SIZE
@@ -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
@@ -60,6 +60,7 @@ module RubyReactor
60
60
  end
61
61
 
62
62
  def [](index)
63
+ index += count if index.negative?
63
64
  return nil if index.negative? || index >= count
64
65
 
65
66
  results = @storage.retrieve_map_results_batch(
@@ -80,15 +81,15 @@ module RubyReactor
80
81
  end
81
82
 
82
83
  def last
83
- self[count - 1]
84
+ self[-1]
84
85
  end
85
86
 
86
87
  def successes
87
- lazy.select { |result| result.is_a?(RubyReactor::Success) }.map(&:value)
88
+ lazy.grep(RubyReactor::Success).map(&:value)
88
89
  end
89
90
 
90
91
  def failures
91
- lazy.select { |result| result.is_a?(RubyReactor::Failure) }.map(&:error)
92
+ lazy.grep(RubyReactor::Failure).map(&:error)
92
93
  end
93
94
 
94
95
  private
@@ -15,7 +15,7 @@ module RubyReactor
15
15
  class ExceededError < StandardError
16
16
  attr_reader :retry_after_seconds, :key_base, :limit, :period_seconds, :period_name
17
17
 
18
- def initialize(message, retry_after_seconds:, key_base:, limit:, period_seconds:, period_name:)
18
+ def initialize(message, retry_after_seconds:, key_base:, limit:, period_seconds:, period_name:) # rubocop:disable Metrics/ParameterLists
19
19
  super(message)
20
20
  @retry_after_seconds = retry_after_seconds
21
21
  @key_base = key_base
@@ -42,7 +42,7 @@ module RubyReactor
42
42
  @limits.each do |spec|
43
43
  argv << spec[:period_seconds]
44
44
  argv << spec[:limit]
45
- argv << spec[:period_seconds] * 2 # TTL: generous, auto-cleans stale buckets
45
+ argv << (spec[:period_seconds] * 2) # TTL: generous, auto-cleans stale buckets
46
46
  end
47
47
 
48
48
  allowed, retry_after, failed_index = adapter.rate_limit_check_and_increment(keys, argv)
@@ -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
 
@@ -18,7 +18,16 @@ module RubyReactor
18
18
  end
19
19
 
20
20
  def perform(serialized_context, reactor_class_name = nil, snooze_count = 0)
21
- context = ContextSerializer.deserialize(serialized_context)
21
+ begin
22
+ context = ContextSerializer.deserialize(serialized_context)
23
+ rescue RubyReactor::Error::DeserializationError,
24
+ RubyReactor::Error::SchemaVersionError => e
25
+ # Permanent failures — retrying the same blob will keep failing.
26
+ # Mark the context as failed (best-effort) and return so Sidekiq
27
+ # does not burn its retry budget.
28
+ handle_deserialization_failure(serialized_context, reactor_class_name, e)
29
+ return
30
+ end
22
31
 
23
32
  # If reactor_class_name is provided, use it to get the reactor class
24
33
  # This handles cases where the class can't be found via const_get
@@ -110,6 +119,54 @@ module RubyReactor
110
119
  RubyReactor.configuration.logger.error("RubyReactor infrastructure failure: #{exception.message}")
111
120
  RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
112
121
  end
122
+
123
+ def handle_deserialization_failure(serialized_context, reactor_class_name, error)
124
+ metadata = extract_failure_metadata(serialized_context)
125
+ context_id = metadata[:context_id]
126
+ resolved_reactor_class_name = reactor_class_name || metadata[:reactor_class_name]
127
+
128
+ RubyReactor.configuration.logger.error(
129
+ "RubyReactor deserialization failure for context " \
130
+ "#{context_id || "unknown"}: #{error.class.name}: #{error.message}"
131
+ )
132
+
133
+ return unless context_id && resolved_reactor_class_name
134
+
135
+ payload = build_failed_context_payload(context_id, resolved_reactor_class_name, error)
136
+ RubyReactor.configuration.storage_adapter.store_context(
137
+ context_id,
138
+ payload,
139
+ resolved_reactor_class_name
140
+ )
141
+ rescue StandardError => e
142
+ # Don't let a persistence failure mask the original deserialization error.
143
+ RubyReactor.configuration.logger.error(
144
+ "RubyReactor failed to persist deserialization failure: #{e.class.name}: #{e.message}"
145
+ )
146
+ end
147
+
148
+ def extract_failure_metadata(serialized_context)
149
+ data = JSON.parse(serialized_context)
150
+ {
151
+ context_id: data["context_id"],
152
+ reactor_class_name: data["reactor_class"]
153
+ }
154
+ rescue StandardError
155
+ {}
156
+ end
157
+
158
+ def build_failed_context_payload(context_id, reactor_class_name, error)
159
+ JSON.generate(
160
+ "schema_version" => ContextSerializer::SCHEMA_VERSION,
161
+ "context_id" => context_id,
162
+ "reactor_class" => reactor_class_name,
163
+ "status" => "failed",
164
+ "failure_reason" => {
165
+ "message" => error.message,
166
+ "exception_class" => error.class.name
167
+ }
168
+ )
169
+ end
113
170
  end
114
171
  end
115
172
  end
@@ -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.3.2"
4
+ VERSION = "0.4.1"
5
5
  end