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.
- checksums.yaml +4 -4
- data/.release-please-config.json +18 -0
- data/.release-please-manifest.json +3 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +21 -0
- data/README.md +80 -4
- data/lib/ruby_reactor/context.rb +5 -2
- data/lib/ruby_reactor/context_serializer.rb +56 -3
- data/lib/ruby_reactor/dsl/reactor.rb +10 -1
- data/lib/ruby_reactor/executor/result_handler.rb +1 -12
- data/lib/ruby_reactor/executor.rb +7 -1
- data/lib/ruby_reactor/map/result_enumerator.rb +4 -3
- data/lib/ruby_reactor/rate_limit.rb +2 -2
- data/lib/ruby_reactor/reactor.rb +11 -4
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +58 -1
- data/lib/ruby_reactor/storage/redis_adapter.rb +10 -3
- data/lib/ruby_reactor/storage/redis_locking.rb +17 -0
- data/lib/ruby_reactor/utils/backtrace_location.rb +37 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +68 -8
- data/lib/ruby_reactor/web/coordination_serializer.rb +180 -0
- data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +21 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor.rb +7 -2
- metadata +11 -54
- data/documentation/DAG.md +0 -457
- data/documentation/README.md +0 -135
- data/documentation/async_reactors.md +0 -381
- data/documentation/composition.md +0 -199
- data/documentation/core_concepts.md +0 -676
- data/documentation/data_pipelines.md +0 -230
- data/documentation/examples/inventory_management.md +0 -748
- data/documentation/examples/order_processing.md +0 -380
- data/documentation/examples/payment_processing.md +0 -565
- data/documentation/getting_started.md +0 -242
- data/documentation/images/failed_order_processing.png +0 -0
- data/documentation/images/payment_workflow.png +0 -0
- data/documentation/interrupts.md +0 -163
- data/documentation/locks_and_semaphores.md +0 -459
- data/documentation/retry_configuration.md +0 -362
- data/documentation/testing.md +0 -994
- data/gui/.gitignore +0 -24
- data/gui/README.md +0 -73
- data/gui/eslint.config.js +0 -23
- data/gui/index.html +0 -13
- data/gui/package-lock.json +0 -5925
- data/gui/package.json +0 -46
- data/gui/postcss.config.js +0 -6
- data/gui/public/vite.svg +0 -1
- data/gui/src/App.css +0 -42
- data/gui/src/App.tsx +0 -51
- data/gui/src/assets/react.svg +0 -1
- data/gui/src/components/DagVisualizer.tsx +0 -424
- data/gui/src/components/Dashboard.tsx +0 -163
- data/gui/src/components/ErrorBoundary.tsx +0 -47
- data/gui/src/components/ReactorDetail.tsx +0 -135
- data/gui/src/components/StepInspector.tsx +0 -492
- data/gui/src/components/__tests__/DagVisualizer.test.tsx +0 -140
- data/gui/src/components/__tests__/ReactorDetail.test.tsx +0 -111
- data/gui/src/components/__tests__/StepInspector.test.tsx +0 -408
- data/gui/src/globals.d.ts +0 -7
- data/gui/src/index.css +0 -14
- data/gui/src/lib/utils.ts +0 -13
- data/gui/src/main.tsx +0 -14
- data/gui/src/test/setup.ts +0 -11
- data/gui/tailwind.config.js +0 -11
- data/gui/tsconfig.app.json +0 -28
- data/gui/tsconfig.json +0 -7
- data/gui/tsconfig.node.json +0 -26
- data/gui/vite.config.ts +0 -8
- data/gui/vitest.config.ts +0 -13
- data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +0 -19
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 729d8c7da4954534a2775a360d79fc96326d76b5ca69c7361ca0433e4bd6571e
|
|
4
|
+
data.tar.gz: e9e545d2ea937135f19c7c04697993125fdfc38d57fc71d6a7b0aede9bf5efa9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
}
|
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
|
|
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
|
-
|
|
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 =>
|
|
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
|

|
|
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).
|
data/lib/ruby_reactor/context.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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[
|
|
84
|
+
self[-1]
|
|
84
85
|
end
|
|
85
86
|
|
|
86
87
|
def successes
|
|
87
|
-
lazy.
|
|
88
|
+
lazy.grep(RubyReactor::Success).map(&:value)
|
|
88
89
|
end
|
|
89
90
|
|
|
90
91
|
def failures
|
|
91
|
-
lazy.
|
|
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)
|
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -10,10 +10,17 @@ module RubyReactor
|
|
|
10
10
|
|
|
11
11
|
def self.find(id)
|
|
12
12
|
reactor_class_name = name
|
|
13
|
-
|
|
14
|
-
raise Error::ValidationError, "Context '#{id}' not found" unless
|
|
15
|
-
|
|
16
|
-
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
|
-
|
|
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
|
-
|
|
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 "
|
|
204
|
+
return "running" if data["current_step"]
|
|
205
|
+
return "completed" if execution_evidence?(data)
|
|
204
206
|
|
|
205
|
-
"
|
|
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
|
data/lib/ruby_reactor/version.rb
CHANGED