ruby_reactor 0.8.4 → 0.8.5
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-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +196 -0
- data/CLAUDE.md +1 -1
- data/README.md +47 -11
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
- data/lib/ruby_reactor/dsl/step_builder.rb +91 -19
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +28 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
- data/lib/ruby_reactor/executor/result_handler.rb +18 -16
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +59 -49
- data/lib/ruby_reactor/executor.rb +38 -4
- data/lib/ruby_reactor/map/collector.rb +21 -11
- data/lib/ruby_reactor/map/dispatcher.rb +29 -3
- data/lib/ruby_reactor/map/element_executor.rb +9 -3
- data/lib/ruby_reactor/map/helpers.rb +32 -2
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/reactor.rb +24 -0
- data/lib/ruby_reactor/rspec/matchers.rb +19 -3
- data/lib/ruby_reactor/step/compose_step.rb +7 -1
- data/lib/ruby_reactor/step/map_step.rb +109 -4
- data/lib/ruby_reactor/step.rb +7 -0
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +1 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +3 -1
- data/lib/ruby_reactor.rb +17 -6
- data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- data/specs/future_improvements.md +48 -0
- metadata +35 -2
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Specification Quality Checklist: Execution Flow & Compensation Analysis
|
|
2
|
+
|
|
3
|
+
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
4
|
+
**Created**: 2026-09-26
|
|
5
|
+
**Feature**: [spec.md](../spec.md)
|
|
6
|
+
|
|
7
|
+
## Content Quality
|
|
8
|
+
|
|
9
|
+
- [x] No implementation details (languages, frameworks, APIs)
|
|
10
|
+
- [x] Focused on user value and business needs
|
|
11
|
+
- [x] Written for non-technical stakeholders
|
|
12
|
+
- [x] All mandatory sections completed
|
|
13
|
+
|
|
14
|
+
## Requirement Completeness
|
|
15
|
+
|
|
16
|
+
- [x] No [NEEDS CLARIFICATION] markers remain
|
|
17
|
+
- [x] Requirements are testable and unambiguous
|
|
18
|
+
- [x] Success criteria are measurable
|
|
19
|
+
- [x] Success criteria are technology-agnostic (no implementation details)
|
|
20
|
+
- [x] All acceptance scenarios are defined
|
|
21
|
+
- [x] Edge cases are identified
|
|
22
|
+
- [x] Scope is clearly bounded
|
|
23
|
+
- [x] Dependencies and assumptions identified
|
|
24
|
+
|
|
25
|
+
## Feature Readiness
|
|
26
|
+
|
|
27
|
+
- [x] All functional requirements have clear acceptance criteria
|
|
28
|
+
- [x] User scenarios cover primary flows
|
|
29
|
+
- [x] Feature meets measurable outcomes defined in Success Criteria
|
|
30
|
+
- [x] No implementation details leak into specification
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
|
|
34
|
+
- Stakeholders for this research are library maintainers and reactor authors, so library
|
|
35
|
+
vocabulary (step, compose, map, compensate, undo, inline/background) is the domain language, not
|
|
36
|
+
implementation detail. The `compensate_all` / `compensate_each` names appear only because the
|
|
37
|
+
user proposed them as candidates to evaluate.
|
|
38
|
+
- Validation passed on iteration 1. No clarifications needed: scope ambiguities (interrupts,
|
|
39
|
+
doc edits, evidence method) were resolved as documented assumptions.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Contract: Report Structure
|
|
2
|
+
|
|
3
|
+
The deliverable's "interface" is the set of documents a maintainer reads and the probe output a
|
|
4
|
+
reviewer re-runs. This contract fixes what each must contain so the report can be checked
|
|
5
|
+
mechanically (see quickstart.md).
|
|
6
|
+
|
|
7
|
+
## `analysis/README.md`
|
|
8
|
+
|
|
9
|
+
1. **Scope & baseline**: commit, constructs covered, what is out of scope.
|
|
10
|
+
2. **How to read**: vocabulary (research D4), evidence labels (D5), status/severity scales (D6/D7).
|
|
11
|
+
3. **Answers**: one subsection per user question, in this order:
|
|
12
|
+
- Q1 *Are already-executed map elements compensated individually?*
|
|
13
|
+
- Q2 *When a composed reactor fails, are previously completed composed reactors compensated?*
|
|
14
|
+
- Q3 *Would `compensate_all` / `compensate_each` on `map` close a real gap?*
|
|
15
|
+
|
|
16
|
+
Each answer MUST open with a one-line verdict (`Yes` / `No` / `Depends: …`), then list the
|
|
17
|
+
conditions, then evidence labels, then links to the matrix rows, invariants and findings.
|
|
18
|
+
4. **Top findings**: the High-severity findings, one line each with links.
|
|
19
|
+
5. **File index**.
|
|
20
|
+
|
|
21
|
+
## `analysis/execution-order.md`
|
|
22
|
+
|
|
23
|
+
1. **Construct lifecycles**: one subsection per construct (step, compose, map inline, map fan-out,
|
|
24
|
+
async_step, async_reactor, background reactor, interrupt). Each has a numbered lifecycle from
|
|
25
|
+
scheduling to terminal state, saying where rollback hooks attach and where locks are held.
|
|
26
|
+
2. **Rollback algorithm**: the generic compensate → reverse-undo sequence with a Mermaid diagram.
|
|
27
|
+
3. **Order matrix**: one table per construct. Columns: `Scenario id | shape | failure at | mode |
|
|
28
|
+
ordered events | left in place | evidence`. No blank cells. Unreachable cells say
|
|
29
|
+
`not reachable: <reason>`.
|
|
30
|
+
4. **Cross-cutting sections**: locks (lifetime vs rollback), retries (attempts vs rollback),
|
|
31
|
+
failure kinds (which path each takes: rollback / no rollback / never-started), each with its
|
|
32
|
+
own table.
|
|
33
|
+
|
|
34
|
+
## `analysis/invariants.md`
|
|
35
|
+
|
|
36
|
+
A table or list of `INV-nn` entries with every Invariant field from data-model.md, grouped by
|
|
37
|
+
area. It ends with a **coverage summary**: counts by status, and the list of invariants with
|
|
38
|
+
`coverage: none`.
|
|
39
|
+
|
|
40
|
+
## `analysis/findings-and-options.md`
|
|
41
|
+
|
|
42
|
+
1. **Findings**, ranked High → Low, with every Finding field.
|
|
43
|
+
2. **Documentation audit**: a table `file:line | quoted claim | actual | finding id`.
|
|
44
|
+
3. **Options**, grouped under the finding they address, with every Option field. The map
|
|
45
|
+
`compensate_all` / `compensate_each` evaluation MUST appear here and compare both shapes on:
|
|
46
|
+
failure semantics (fail_fast on/off), inline vs fan-out, interaction with element retries,
|
|
47
|
+
interaction with a later step failing, and data availability (what `all_elements` / `element`
|
|
48
|
+
would contain).
|
|
49
|
+
4. A closing note: every option is a proposal pending later analysis.
|
|
50
|
+
|
|
51
|
+
## `evidence/output.txt`
|
|
52
|
+
|
|
53
|
+
Produced by `evidence/run.rb`. It has one block per scenario:
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
== S-plain-01 a → b(fails) → c [inline]
|
|
57
|
+
expected: run:a run:b compensate:b undo:a => failure(b)
|
|
58
|
+
observed: run:a run:b compensate:b undo:a => failure(b)
|
|
59
|
+
MATCH
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`MISMATCH` blocks are kept, not hidden. A `MISMATCH` means the report's expectation was wrong and
|
|
63
|
+
the report MUST be corrected to the observed sequence (the observation wins). Scenarios whose
|
|
64
|
+
purpose is to show a counter-example set `expected` to the *observed* behavior and state the
|
|
65
|
+
violation in the report.
|
|
66
|
+
|
|
67
|
+
## Cross-reference rules
|
|
68
|
+
|
|
69
|
+
- Every `[O: S-…]` label in `analysis/*.md` resolves to a block in `output.txt`.
|
|
70
|
+
- Every finding lists at least one scenario or `[R]` citation.
|
|
71
|
+
- Every High finding has at least two options, each with a stated con (SC-004).
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Data Model: Execution Flow & Compensation Analysis
|
|
2
|
+
|
|
3
|
+
The "data" here is the report's own structure. Every entity below has a stable ID so report files
|
|
4
|
+
can cross-reference each other and cite probes.
|
|
5
|
+
|
|
6
|
+
## Scenario
|
|
7
|
+
|
|
8
|
+
A reactor shape + failure location + conditions. It is one matrix cell, and usually one probe.
|
|
9
|
+
|
|
10
|
+
| Field | Description |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `id` | `S-<area>-<nn>`; area ∈ `plain`, `retry`, `compose`, `map`, `async`, `bg`, `lock`, `intr`, `edge` |
|
|
13
|
+
| `shape` | Constructs and nesting, e.g. `a → compose(c1 → c2) → b` |
|
|
14
|
+
| `failure_at` | Which step fails and how (`returns Failure`, `raises`, `contended`, `validation`, `compensate fails`, …) |
|
|
15
|
+
| `mode` | `inline` or `worker` (+ which worker path) |
|
|
16
|
+
| `conditions` | Locks / retries / fail_fast / fan_out flags in effect |
|
|
17
|
+
| `expected` | Ordered event list the report claims (see Trace event) |
|
|
18
|
+
| `left_in_place` | Completed work that nothing rolls back |
|
|
19
|
+
| `evidence` | `[R]`, `[O]`, `[T]` labels (research D5) |
|
|
20
|
+
|
|
21
|
+
Rule: a matrix cell is **filled** when `expected` and `left_in_place` are both stated, or when it is
|
|
22
|
+
marked `not reachable` with a one-line reason (SC-001).
|
|
23
|
+
|
|
24
|
+
## Trace event
|
|
25
|
+
|
|
26
|
+
One entry in an observed or expected sequence.
|
|
27
|
+
|
|
28
|
+
| Kind | Format | Source |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| Body | `run:<step>`, `compensate:<step>`, `undo:<step>` | probe step bodies |
|
|
31
|
+
| Element body | `run:<step>[<i>]`, `undo:<step>[<i>]` | probe map element steps |
|
|
32
|
+
| Middleware | `<event>:<subject>` e.g. `lock_acquired:acct:1`, `retry_attempt:charge#2` | recorder middleware |
|
|
33
|
+
| Outcome | `=> success`, `=> failure(<step>)`, `=> halt`, `=> paused` | probe footer |
|
|
34
|
+
|
|
35
|
+
Nested reactors prefix the step with the child name when they would otherwise be ambiguous,
|
|
36
|
+
e.g. `run:child.c1`.
|
|
37
|
+
|
|
38
|
+
## Invariant
|
|
39
|
+
|
|
40
|
+
| Field | Description |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `id` | `INV-<nn>` |
|
|
43
|
+
| `statement` | A testable proposition ("Compensation never runs for a step whose body never started") |
|
|
44
|
+
| `scope` | Constructs/modes it applies to |
|
|
45
|
+
| `status` | `HOLDS` · `VIOLATED` · `CONDITIONAL` · `UNDETERMINED` (research D6) |
|
|
46
|
+
| `conditions` | Required when `CONDITIONAL` |
|
|
47
|
+
| `evidence` | `[R]`/`[O]`/`[T]` labels; a `VIOLATED` status MUST cite a reproducible counter-example (`[O]`) |
|
|
48
|
+
| `coverage` | Existing spec(s) exercising it, or `none` |
|
|
49
|
+
|
|
50
|
+
## Finding
|
|
51
|
+
|
|
52
|
+
| Field | Description |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `id` | `F-<nn>` |
|
|
55
|
+
| `title` | One line |
|
|
56
|
+
| `severity` | `High` · `Medium` · `Low` (research D7) |
|
|
57
|
+
| `scenario` | Scenario id(s) that show it |
|
|
58
|
+
| `reader_expects` | What someone reading the DSL/docs would expect |
|
|
59
|
+
| `actual` | What happens, with evidence |
|
|
60
|
+
| `doc_conflict` | Quoted README/documentation text + `file:line`, or `none` |
|
|
61
|
+
| `related` | Invariant ids |
|
|
62
|
+
|
|
63
|
+
## Improvement option
|
|
64
|
+
|
|
65
|
+
| Field | Description |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `id` | `O-<finding>-<letter>` e.g. `O-03-a` |
|
|
68
|
+
| `addresses` | Finding id(s) |
|
|
69
|
+
| `sketch` | DSL/behavior shape (pseudo-code allowed) |
|
|
70
|
+
| `pros` / `cons` | At least one con is required (SC-004) |
|
|
71
|
+
| `compatibility` | Breaking? Migration impact? |
|
|
72
|
+
| `open_questions` | What must be decided before choosing |
|
|
73
|
+
| `status` | Always `proposal`, never a decision |
|
|
74
|
+
|
|
75
|
+
## Relationships
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
Scenario 1─* Trace event
|
|
79
|
+
Scenario *─* Invariant (a scenario is evidence for invariants)
|
|
80
|
+
Finding *─* Scenario (shown by)
|
|
81
|
+
Finding *─* Invariant (violates / conditions)
|
|
82
|
+
Option *─1..* Finding (addresses)
|
|
83
|
+
```
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Evidence harness for specs/007-execution-flow-analysis. Research artifact,
|
|
4
|
+
# NOT library or test-suite code. It runs throwaway reactors against the real
|
|
5
|
+
# test Redis through the real worker bodies (Sidekiq fake mode + drain) and
|
|
6
|
+
# prints the observed forward/rollback event order next to the order the
|
|
7
|
+
# report claims.
|
|
8
|
+
|
|
9
|
+
require "bundler/setup"
|
|
10
|
+
require "logger"
|
|
11
|
+
require "ruby_reactor"
|
|
12
|
+
require "sidekiq/testing"
|
|
13
|
+
|
|
14
|
+
Sidekiq::Testing.fake!
|
|
15
|
+
Sidekiq.configure_client do |config|
|
|
16
|
+
config.redis = { url: ENV.fetch("RUBY_REACTOR_TEST_REDIS_URL", "redis://localhost:6780") }
|
|
17
|
+
config.logger = Logger.new(IO::NULL)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module Probe
|
|
21
|
+
REDIS_URL = ENV.fetch("RUBY_REACTOR_TEST_REDIS_URL", "redis://localhost:6780")
|
|
22
|
+
|
|
23
|
+
# Drained in this order until quiet. Delayed jobs (perform_in) run at once.
|
|
24
|
+
WORKERS = [
|
|
25
|
+
RubyReactor::Adapters::Sidekiq::Worker,
|
|
26
|
+
RubyReactor::Adapters::Sidekiq::MapElementWorker,
|
|
27
|
+
RubyReactor::Adapters::Sidekiq::MapCollectorWorker,
|
|
28
|
+
RubyReactor::Adapters::Sidekiq::StepWorker
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
@events = []
|
|
32
|
+
@notes = []
|
|
33
|
+
@counters = Hash.new(0)
|
|
34
|
+
@tally = { match: 0, mismatch: 0 }
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
attr_reader :events, :notes, :counters, :tally
|
|
38
|
+
|
|
39
|
+
def rec(event) = @events << event
|
|
40
|
+
def note(text) = @notes << text
|
|
41
|
+
|
|
42
|
+
# Uniform step labels: "run:a", "undo:child.c1", "run:e1[2]".
|
|
43
|
+
def label(prefix, name, index = nil)
|
|
44
|
+
base = [prefix, name].compact.join(".")
|
|
45
|
+
index.nil? ? base : "#{base}[#{index}]"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The value a probe `compensate` / `undo` body returns.
|
|
49
|
+
def rollback_result(mode, what)
|
|
50
|
+
case mode
|
|
51
|
+
when :fail then RubyReactor.Failure("#{what} failed")
|
|
52
|
+
when :raise then raise "#{what} raised"
|
|
53
|
+
else RubyReactor.Success()
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Same loop as RubyReactor::RSpec::SidekiqHelpers.drain_async_jobs, inlined
|
|
58
|
+
# because requiring that file pulls in the RSpec matchers.
|
|
59
|
+
def drain(max_iterations: 100)
|
|
60
|
+
max_iterations.times do
|
|
61
|
+
busy = false
|
|
62
|
+
WORKERS.each do |worker|
|
|
63
|
+
while (job = worker.jobs.shift)
|
|
64
|
+
worker.new.perform(*job["args"])
|
|
65
|
+
busy = true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
return unless busy
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pending_jobs = WORKERS.sum { |w| w.jobs.size }
|
|
73
|
+
|
|
74
|
+
# Jobs run synchronously at enqueue (Sidekiq::Testing.inline!). Used only
|
|
75
|
+
# where a same-process reader must see a unit finish; labelled in the mode.
|
|
76
|
+
def inline_jobs(&block) = Sidekiq::Testing.inline!(&block)
|
|
77
|
+
|
|
78
|
+
# Run, drain every worker job, then reload the execution's terminal state.
|
|
79
|
+
def run_async(reactor_class, inputs = {})
|
|
80
|
+
dispatched = reactor_class.run(inputs)
|
|
81
|
+
drain
|
|
82
|
+
reactor_class.find(dispatched.execution_id)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Prints Failure#rollback_failures as step/kind/reason; returns the result.
|
|
86
|
+
def rollback_note(result)
|
|
87
|
+
list = result.respond_to?(:rollback_failures) ? Array(result.rollback_failures) : []
|
|
88
|
+
note("rollback_failures=#{list.map { |f| "#{f[:step]}/#{f[:kind]}/#{f[:reason]}" }}")
|
|
89
|
+
result
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def outcome(result)
|
|
93
|
+
result = result.result if result.is_a?(RubyReactor::Reactor)
|
|
94
|
+
case result
|
|
95
|
+
when String then result
|
|
96
|
+
when RubyReactor::Halt then "halt"
|
|
97
|
+
when RubyReactor::Success then "success"
|
|
98
|
+
when RubyReactor::Failure then "failure(#{result.step_name || "?"})"
|
|
99
|
+
when RubyReactor::InterruptResult then "paused"
|
|
100
|
+
when RubyReactor::DispatchResult then "dispatched"
|
|
101
|
+
when :unexecuted then "running"
|
|
102
|
+
else result.inspect
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def reset!
|
|
107
|
+
RubyReactor.configuration.storage_adapter.instance_variable_get(:@redis).flushdb
|
|
108
|
+
WORKERS.each { |w| w.jobs.clear }
|
|
109
|
+
[@events, @notes].each(&:clear)
|
|
110
|
+
@counters.clear
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Runs one scenario and prints expected vs observed. The block returns the
|
|
114
|
+
# final result (a Result, a reloaded Reactor, or a String).
|
|
115
|
+
def scenario(id, title, mode:, expected:, &block)
|
|
116
|
+
return if ENV["PROBE"] && !id.include?(ENV["PROBE"])
|
|
117
|
+
|
|
118
|
+
reset!
|
|
119
|
+
observed = @events + ["=>", outcome(run_block(&block))]
|
|
120
|
+
report(id, title, mode, expected, observed)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def run_block(&block)
|
|
126
|
+
block.call
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
"raised(#{e.class}: #{e.message.lines.first&.strip})"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def report(id, title, mode, expected, observed)
|
|
132
|
+
ok = observed == expected
|
|
133
|
+
@tally[ok ? :match : :mismatch] += 1
|
|
134
|
+
puts "== #{id} #{title} [#{mode}]"
|
|
135
|
+
puts "expected: #{expected.join(" ")}"
|
|
136
|
+
puts "observed: #{observed.join(" ")}"
|
|
137
|
+
@notes.each { |n| puts "note: #{n}" }
|
|
138
|
+
puts ok ? "MATCH" : "MISMATCH"
|
|
139
|
+
puts
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Recording middleware: the shipped hook surface, no patching. Only events
|
|
144
|
+
# that say something about ordering relative to rollback are kept.
|
|
145
|
+
class Recorder < RubyReactor::Middleware
|
|
146
|
+
def on(event, *args)
|
|
147
|
+
case event
|
|
148
|
+
when :lock_acquired, :lock_released, :semaphore_acquired, :semaphore_released
|
|
149
|
+
Probe.rec("#{event}:#{args[0]}")
|
|
150
|
+
when :retry_attempt
|
|
151
|
+
Probe.rec("retry:#{args[0]}##{args[1]}")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Reactor-class DSL sugar so a probe step is one line:
|
|
157
|
+
#
|
|
158
|
+
# pstep :b, after: :a, fail: true
|
|
159
|
+
# pstep :e1, idx: true, fail: ->(inputs) { inputs.i == 2 }
|
|
160
|
+
# pstep :c, fail: :raise, compensate: :fail, undo: :raise
|
|
161
|
+
# pstep :d, fail_times: 1, retries: { max_attempts: 2, base_delay: 0 }
|
|
162
|
+
module Steps
|
|
163
|
+
def tag(value = nil)
|
|
164
|
+
value ? @probe_tag = value : @probe_tag
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# opts: fail:, fail_times:, retries: {…}, compensate: :ok|:fail|:raise, undo: (same),
|
|
168
|
+
# kind: :step (default) | :async_step
|
|
169
|
+
def pstep(name, after: nil, idx: false, **opts, &extra)
|
|
170
|
+
body = Steps.body(tag, name, idx, opts)
|
|
171
|
+
on_compensate = Steps.rollback(tag, name, idx, "compensate", opts[:compensate])
|
|
172
|
+
on_undo = Steps.rollback(tag, name, idx, "undo", opts[:undo])
|
|
173
|
+
public_send(opts.fetch(:kind, :step), name) do
|
|
174
|
+
wait_for(*Array(after)) if after
|
|
175
|
+
argument :i, input(:i) if idx
|
|
176
|
+
retries(**opts[:retries]) if opts[:retries]
|
|
177
|
+
run(&body)
|
|
178
|
+
compensate(&on_compensate)
|
|
179
|
+
# 008 R-09: an async_step is never undone, so `undo` on one is rejected.
|
|
180
|
+
undo(&on_undo) unless opts[:kind] == :async_step
|
|
181
|
+
instance_eval(&extra) if extra
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# `failure`: `fail: true | :raise | ->(inputs) {}` or `fail_times: n`.
|
|
186
|
+
def self.body(prefix, name, idx, failure)
|
|
187
|
+
lambda do |inputs, _ctx|
|
|
188
|
+
lbl = Probe.label(prefix, name, idx ? inputs.i : nil)
|
|
189
|
+
Probe.rec("run:#{lbl}")
|
|
190
|
+
failing = failing?(lbl, inputs, failure)
|
|
191
|
+
raise "boom #{lbl}" if failing && failure[:fail] == :raise
|
|
192
|
+
|
|
193
|
+
failing ? RubyReactor.Failure("boom #{lbl}") : RubyReactor.Success("#{lbl}-value")
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.failing?(lbl, inputs, failure)
|
|
198
|
+
return (Probe.counters[lbl] += 1) <= failure[:fail_times] if failure[:fail_times]
|
|
199
|
+
|
|
200
|
+
fail = failure[:fail]
|
|
201
|
+
fail.respond_to?(:call) ? fail.call(inputs) : fail
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.rollback(prefix, name, idx, kind, mode)
|
|
205
|
+
lambda do |_error_or_result, inputs, _ctx|
|
|
206
|
+
lbl = Probe.label(prefix, name, idx ? inputs.i : nil)
|
|
207
|
+
Probe.rec("#{kind}:#{lbl}")
|
|
208
|
+
Probe.rollback_result(mode, "#{kind} #{lbl}")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
RubyReactor.configure do |config|
|
|
215
|
+
config.storage.adapter = :redis
|
|
216
|
+
config.storage.redis_url = Probe::REDIS_URL
|
|
217
|
+
config.async_router = RubyReactor::Adapters::Sidekiq::Router
|
|
218
|
+
config.logger = Logger.new(IO::NULL)
|
|
219
|
+
config.middlewares = [Probe::Recorder]
|
|
220
|
+
config.lock_snooze_jitter = 0
|
|
221
|
+
config.async_wait_timeout = 2
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Probe reactors live under P so workers can resolve them by constant name.
|
|
225
|
+
module P
|
|
226
|
+
class Base < RubyReactor::Reactor
|
|
227
|
+
extend Probe::Steps
|
|
228
|
+
end
|
|
229
|
+
end
|