active_durable 0.6.0 → 0.7.0
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/CHANGELOG.md +62 -1
- data/README.md +82 -10
- data/app/controllers/active_durable/executions_controller.rb +2 -1
- data/app/helpers/active_durable/dashboard_helper.rb +10 -2
- data/app/views/active_durable/executions/index.html.erb +1 -1
- data/app/views/active_durable/executions/show.html.erb +6 -6
- data/app/views/layouts/active_durable/application.html.erb +3 -3
- data/lib/active_durable/configuration.rb +14 -0
- data/lib/active_durable/errors.rb +53 -8
- data/lib/active_durable/execution.rb +7 -0
- data/lib/active_durable/flow.rb +46 -9
- data/lib/active_durable/flow_parallel.rb +31 -9
- data/lib/active_durable/notebook.rb +13 -2
- data/lib/active_durable/operations.rb +52 -16
- data/lib/active_durable/pruner.rb +1 -3
- data/lib/active_durable/runner.rb +17 -7
- data/lib/active_durable/serializer.rb +27 -3
- data/lib/active_durable/signal_record.rb +11 -0
- data/lib/active_durable/step.rb +10 -0
- data/lib/active_durable/sweeper.rb +1 -1
- data/lib/active_durable/testing.rb +1 -1
- data/lib/active_durable/version.rb +1 -1
- data/lib/active_durable.rb +25 -5
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +11 -6
- data/lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt +72 -0
- data/lib/generators/active_durable/upgrade/upgrade_generator.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca565450baafb042b5ec363d2d7c101f24edd3281b180d43c173eaeff8dd8df8
|
|
4
|
+
data.tar.gz: 96ceecb07cf0d85b0d8aaf604dececee56301983d16055de408e533c53059da6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a6dafa621dba36aac0429fc264922d876d378390aa7fccebf05a085bbcb1f2e001d7bf57d202075299e47cbbb20d29af86df62000adca22367bec76ab18dd16b
|
|
7
|
+
data.tar.gz: 7b4c90ddb5104811ab17be9532fe2c4ca029bae139eeeaafdef1bd04a222fa4c2896c34b2ce44376d3ff13222473e76276a991c125fcdbe057937d7e6a649896
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,68 @@ All notable changes to this project are documented here. The format follows
|
|
|
5
5
|
[Semantic Versioning](https://semver.org/). Schema changes ship as new migrations: after updating the gem, run
|
|
6
6
|
`bin/rails generate active_durable:upgrade` and `bin/rails db:migrate`.
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.7.0] - 2026-10-07
|
|
9
|
+
|
|
10
|
+
Fixes from an architecture and security review. Most of them stop a saga from paying twice or undoing the wrong
|
|
11
|
+
thing.
|
|
12
|
+
|
|
13
|
+
Upgrading from 0.6: `bin/rails generate active_durable:upgrade && bin/rails db:migrate`. The new migration only
|
|
14
|
+
changes MySQL databases: it rebuilds the three tables and blocks writes to them while it runs, so run it at a quiet
|
|
15
|
+
time, or prune first. Note the behavior changes below: more errors block instead of undoing, `retry` no longer
|
|
16
|
+
resets every failed step, rerun ids are random, and `Durable.start` refuses some ids.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- More errors count as bugs and block the saga instead of being retried and undone: `ArgumentError`, `TypeError`,
|
|
21
|
+
`IndexError` and `KeyError`, `FrozenError`, `ZeroDivisionError`, `RangeError`, `NoMatchingPatternError`,
|
|
22
|
+
`LocalJumpError`, `RegexpError` and `EncodingError`, besides `NameError` and `NoMethodError`. The list is the new
|
|
23
|
+
`config.code_errors`, which takes classes or names, so an app can add its own errors or drop one.
|
|
24
|
+
- A step that blocks is written in the notebook as `blocked`, and runs again after `ActiveDurable.retry`. Rescuing
|
|
25
|
+
its error in the recipe no longer lets the saga carry on.
|
|
26
|
+
- `ActiveDurable.retry` gives a fresh set of attempts only to the step that blocked the saga (with its whole
|
|
27
|
+
`flow.parallel`) and to steps that hit a bug. Compensating, it still resets every failed undo.
|
|
28
|
+
- Rerun ids are `<id>~rerun-<random>` instead of `<id>~rerun-<count>`.
|
|
29
|
+
- `ActiveDurable.compensate` refuses `running` executions, even after their lease ran out.
|
|
30
|
+
- `Durable.start` refuses ids that are blank, contain `/`, a NUL character or bytes that are not UTF-8.
|
|
31
|
+
- The serializer converts binary strings that are valid UTF-8 (such as `Net::HTTP` bodies) and strings in other
|
|
32
|
+
encodings to UTF-8, and rejects NUL characters and invalid bytes, in values and keys.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `retry` ran again a failed step the recipe had already handled: with a fallback (Stripe fails, the recipe pays
|
|
37
|
+
with PayPal) and a later bug, retrying charged twice. `rerun` did the same; it now copies the failed steps before
|
|
38
|
+
the chosen one too.
|
|
39
|
+
- A step whose result the database refused (a NUL on PostgreSQL, bytes that are not UTF-8 on MySQL and SQLite, any
|
|
40
|
+
failed write) counted as a failed attempt: it ran again, then the saga was undone without undoing it. It now
|
|
41
|
+
blocks.
|
|
42
|
+
- Undoing a saga by hand skipped a step declared `undo_on_failure` that was between attempts or blocked, and the
|
|
43
|
+
finished branches of a `flow.parallel` stopped halfway.
|
|
44
|
+
- An operator could undo a saga while its worker was still inside a step longer than `lease_duration`.
|
|
45
|
+
- A pending signal nobody was waiting for (a duplicate webhook) made the saga enqueue itself again forever.
|
|
46
|
+
- Signals sent to a blocked saga were refused and lost.
|
|
47
|
+
- `rerun` lost the undos of the `flow.parallel` branches before the chosen step, and from a branch it ran nothing;
|
|
48
|
+
the dashboard no longer offers branches.
|
|
49
|
+
- On MySQL, ids and step names that differ only in case or accents were treated as the same. The new migration
|
|
50
|
+
makes those columns `utf8mb4_bin`.
|
|
51
|
+
- `raise ActiveRecord::Rollback` inside `flow.transaction`, an undo or a hook counted as success.
|
|
52
|
+
- On Rails 6.1 and 7.0 with MySQL, two concurrent `start` calls with the same id inside the app's transaction made
|
|
53
|
+
the second raise `RecordNotFound`.
|
|
54
|
+
- Rerun ids collided after pruning, and could give a new rerun the tickets of a pruned one.
|
|
55
|
+
- An id with `/` broke the whole dashboard list.
|
|
56
|
+
- `LoadError`, `NotImplementedError` and `SystemStackError` left the saga running, retried forever by the sweeper.
|
|
57
|
+
- A bug in an undo used up all its attempts before blocking; it now blocks at once.
|
|
58
|
+
- Error messages are cleaned up (invalid bytes, NUL) before they are stored.
|
|
59
|
+
- README: how to run Solid Queue in development, and the sweeper and cleanup entries go under the `production:` key
|
|
60
|
+
Rails already wrote in `config/recurring.yml`. Pasting a second `production:` key silently dropped Rails' own
|
|
61
|
+
`clear_solid_queue_finished_jobs` task.
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- Benchmarks in `benchmarks/`: the cost of a step and of resuming a long saga (`throughput.rb`), and many worker
|
|
66
|
+
processes on the same sagas, optionally killing one with SIGKILL every second (`load.rb`, `CHAOS=1`). The README
|
|
67
|
+
has a "Performance" section with the results on PostgreSQL, MySQL and SQLite.
|
|
68
|
+
- README: what counts as a bug, `config.code_errors`, and new limits: ids are forgotten after pruning, the lease and
|
|
69
|
+
slow steps, clocks, and secrets in error messages.
|
|
9
70
|
|
|
10
71
|
## [0.6.0] - 2026-10-06
|
|
11
72
|
|
data/README.md
CHANGED
|
@@ -161,12 +161,22 @@ config.solid_queue.connects_to = { database: { writing: :queue } }
|
|
|
161
161
|
The server runs its own, sleeps and retries included, but a saga started from the console, `bin/rails runner`, a
|
|
162
162
|
rake task or `db/seeds.rb` is lost when that process exits, and scheduled wake-ups are lost when the server
|
|
163
163
|
restarts. A minute later, `bin/rails active_durable:sweep` picks them up: with `:async` it runs them right there.
|
|
164
|
-
Or run Solid Queue in development too,
|
|
164
|
+
Or run Solid Queue in development too, so jobs live in the database: give `development` a `queue` database in
|
|
165
|
+
`config/database.yml` (like the one `production` has, with `migrations_paths: db/queue_migrate`), add the two
|
|
166
|
+
lines below, run `bin/rails db:prepare`, and start `bin/jobs` next to the server.
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
# config/environments/development.rb
|
|
170
|
+
config.active_job.queue_adapter = :solid_queue
|
|
171
|
+
config.solid_queue.connects_to = { database: { writing: :queue } }
|
|
172
|
+
```
|
|
165
173
|
|
|
166
174
|
### 3. The sweeper
|
|
167
175
|
|
|
168
176
|
The safety net: every minute it enqueues executions that lost their job, because the process died between the
|
|
169
|
-
commit and the enqueue or a worker died holding a lease. With Solid Queue
|
|
177
|
+
commit and the enqueue or a worker died holding a lease. With Solid Queue, add these entries under the
|
|
178
|
+
`production:` key Rails already wrote in `config/recurring.yml` (a second `production:` key would silently replace
|
|
179
|
+
it), and under a `development:` key too if you run Solid Queue in development:
|
|
170
180
|
|
|
171
181
|
```yaml
|
|
172
182
|
# config/recurring.yml
|
|
@@ -202,6 +212,7 @@ ActiveDurable.configure do |config|
|
|
|
202
212
|
config.parallel_concurrency = 4 # threads per flow.parallel
|
|
203
213
|
config.sweep_grace = 1.minute # the sweeper leaves executions this young alone
|
|
204
214
|
config.keep_finished_for = 30.days # then ActiveDurable::PruneJob deletes finished ones
|
|
215
|
+
config.code_errors += ["Payments::Misconfigured"] # your own errors that mean a bug (see "A bug is not a failure")
|
|
205
216
|
|
|
206
217
|
# Who may open the dashboard outside development and test. With Devise (HTTP basic auth: see Dashboard):
|
|
207
218
|
config.dashboard_authorize = ->(controller) { controller.request.env["warden"]&.user&.admin? }
|
|
@@ -428,10 +439,33 @@ When a step runs out of attempts or calls `flow.abort!`, every finished step is
|
|
|
428
439
|
stopped. An undo receives `(result, undo_ticket, step_ticket)` and takes as many as it declares: `-> { ... }` takes
|
|
429
440
|
none. Any object that responds to `call` works too, such as `Payments.method(:refund)`.
|
|
430
441
|
|
|
431
|
-
**A bug is not a failure.**
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
442
|
+
**A bug is not a failure.** Undoing a saga refunds money and releases stock, so only a step that failed for good or
|
|
443
|
+
`flow.abort!` triggers it. These block the execution instead, without retrying, because a typo in a deploy must never
|
|
444
|
+
refund your customers:
|
|
445
|
+
|
|
446
|
+
- anything the recipe raises outside a step;
|
|
447
|
+
- inside a step, an error that means the code is wrong: `NameError` (and `NoMethodError`), `ArgumentError`,
|
|
448
|
+
`TypeError`, `IndexError` (and `KeyError`), `FrozenError`, `ZeroDivisionError`, `RangeError`,
|
|
449
|
+
`NoMatchingPatternError`, `LocalJumpError`, `RegexpError`, `EncodingError`, and the errors outside
|
|
450
|
+
`StandardError` such as `LoadError`, `NotImplementedError` and `SystemStackError`;
|
|
451
|
+
- a step whose result cannot be stored (a NUL character, bytes that are not UTF-8): it already acted.
|
|
452
|
+
|
|
453
|
+
Fix the code and call `ActiveDurable.retry(id)`, or press Retry in the dashboard, and the saga carries on from where it
|
|
454
|
+
stopped. Rescuing the error in the recipe does not help: the saga stops at the next step and blocks anyway. To reject
|
|
455
|
+
the work for a business reason, call `flow.abort!(reason)`.
|
|
456
|
+
|
|
457
|
+
The list is `config.code_errors`. Add your own errors, as a class or as a name (a name also matches subclasses, and
|
|
458
|
+
works before the gem that defines it is loaded), or drop one:
|
|
459
|
+
|
|
460
|
+
```ruby
|
|
461
|
+
ActiveDurable.configure do |config|
|
|
462
|
+
config.code_errors << "Payments::Misconfigured"
|
|
463
|
+
config.code_errors -= ["ArgumentError"] # if a gem you call raises it for bad input
|
|
464
|
+
end
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
An `ArgumentError` from bad data, like an invalid date, blocks too: a person decides. To treat one of these errors as
|
|
468
|
+
a failure in a single step, rescue it inside the step and raise another error, or call `flow.abort!`.
|
|
435
469
|
|
|
436
470
|
A step that failed is not undone, because it did not happen. The exception is a step whose failure may hide a
|
|
437
471
|
success, like a charge whose answer timed out: declare `undo_on_failure: true` and its undo runs with `nil` as the
|
|
@@ -492,7 +526,7 @@ For progress before the end (paid, shipped), write a step: `flow.transaction(:ma
|
|
|
492
526
|
<br>
|
|
493
527
|
|
|
494
528
|
`flow.sleep` writes the wake-up time down and releases the worker. `flow.wait_for` does the same until a signal
|
|
495
|
-
arrives. Signals can arrive before the saga starts waiting.
|
|
529
|
+
arrives. Signals can arrive before the saga starts waiting, or while it is blocked: they are kept until it does.
|
|
496
530
|
|
|
497
531
|
```ruby
|
|
498
532
|
kyc = flow.wait_for(:kyc_done, timeout: 2.hours)
|
|
@@ -585,14 +619,21 @@ end
|
|
|
585
619
|
## Operating sagas
|
|
586
620
|
|
|
587
621
|
```ruby
|
|
588
|
-
ActiveDurable.retry("checkout-7") # blocked: try again
|
|
622
|
+
ActiveDurable.retry("checkout-7") # blocked: try again the step that blocked it
|
|
589
623
|
ActiveDurable.compensate("checkout-7", reason: "customer cancelled") # undo everything (only before the pivot)
|
|
590
624
|
ActiveDurable.rerun("checkout-7", from: :ship) # a new execution reusing steps before :ship
|
|
591
625
|
ActiveDurable.prune(older_than: 30.days) # delete finished executions and their notebook
|
|
592
626
|
```
|
|
593
627
|
|
|
594
|
-
|
|
595
|
-
|
|
628
|
+
`retry` gives the step that blocked the saga a fresh set of attempts. Failed steps the recipe already handled stay
|
|
629
|
+
failed: if Stripe failed and the recipe paid with PayPal instead, a retry never runs Stripe again.
|
|
630
|
+
|
|
631
|
+
A rerun (`<id>~rerun-<random>`) keeps the steps before the chosen one, failed ones and parallel branches included,
|
|
632
|
+
and runs the chosen step and the following ones again with new tickets, so they have effects again. A blocked
|
|
633
|
+
original becomes `superseded`.
|
|
634
|
+
|
|
635
|
+
None of them touches an execution a worker is running. `compensate` refuses a `running` execution even after its
|
|
636
|
+
lease ran out: the worker may still be inside a slow step, and if it died, the sweeper resumes it.
|
|
596
637
|
|
|
597
638
|
## Testing: the crash tester
|
|
598
639
|
|
|
@@ -679,6 +720,28 @@ Every feature works on every version. Things your app may need on older Rails, u
|
|
|
679
720
|
| Temporal | the Temporal server | written by hand | a Temporal cluster |
|
|
680
721
|
| **ActiveDurable** | **your database** | **yes, in reverse, with a point of no return** | **nothing extra** |
|
|
681
722
|
|
|
723
|
+
## Performance
|
|
724
|
+
|
|
725
|
+
Measured on an Apple M1 Ultra with Ruby 4.0.7 and Rails 8.1, each database on the same machine. The scripts are in
|
|
726
|
+
[`benchmarks/`](benchmarks/README.md), so you can run them on yours.
|
|
727
|
+
|
|
728
|
+
| | PostgreSQL 16 | MySQL 9.6 | SQLite 3 |
|
|
729
|
+
| --- | --- | --- | --- |
|
|
730
|
+
| A step (`flow.step`), one process | 1.8 ms | 3.0 ms | 0.45 ms |
|
|
731
|
+
| A step (`flow.transaction`), one process | 2.0 ms | 2.8 ms | 0.45 ms |
|
|
732
|
+
| Resuming a saga with 1,000 finished steps | 17 ms | 21 ms | 9 ms |
|
|
733
|
+
| 2,000 sagas of 5 steps, 8 worker processes | 6.9 s (290 sagas/s) | 8.1 s (246 sagas/s) | 1,000 sagas, 4 workers: 6.5 s |
|
|
734
|
+
| The same, killing a worker with SIGKILL every second | 9.2 s, 9 workers killed | 13.2 s, 13 killed | — |
|
|
735
|
+
| Duplicate charges | 0 | 0 | 0 |
|
|
736
|
+
|
|
737
|
+
A step costs about four queries: the lease check that fences the write, the notebook row and the commit that makes
|
|
738
|
+
it durable. In the load tests each call to the outside world takes 5 ms, so a saga spends most of its time waiting,
|
|
739
|
+
as in a real app. When a worker dies mid-step, another one runs that step again with the same ticket: on PostgreSQL 2
|
|
740
|
+
of the 2,000 charges were sent twice, and the idempotency key made them one.
|
|
741
|
+
|
|
742
|
+
The same test in a Rails 8 app with Solid Queue: 300 checkouts, every Solid Queue process killed with SIGKILL twice
|
|
743
|
+
while sagas were halfway through. All of them settled, the refunds and hooks ran once, and no order was charged twice.
|
|
744
|
+
|
|
682
745
|
## Guarantees and limits
|
|
683
746
|
|
|
684
747
|
- A step runs **at least once**; with an idempotency key it has its effect once. `flow.transaction` runs exactly
|
|
@@ -687,6 +750,15 @@ Every feature works on every version. Things your app may need on older Rails, u
|
|
|
687
750
|
- A hook (`flow.on`) runs once; exactly once if it only touches your database.
|
|
688
751
|
- A bug never undoes a saga: an error in the code blocks it until you fix it and call `ActiveDurable.retry`.
|
|
689
752
|
- Sagas do not isolate each other: two sagas can see each other's intermediate states.
|
|
753
|
+
- `Durable.start(id:)` is idempotent while the execution exists. Once it is pruned (`keep_finished_for`, 30 days),
|
|
754
|
+
the same id starts a new saga with the same tickets: keep finished executions longer than a webhook can be
|
|
755
|
+
delivered again.
|
|
756
|
+
- The lease is renewed on each notebook write. A step longer than `lease_duration` can run twice at the same time,
|
|
757
|
+
with the same ticket. Keep the workers' clocks in sync (NTP): the lease compares times written by different
|
|
758
|
+
machines.
|
|
759
|
+
- Error messages are stored in the notebook and reach the dashboard, the logs, the `blocked` event and
|
|
760
|
+
OpenTelemetry: keep secrets and personal data out of them.
|
|
761
|
+
- Ids cannot be blank or contain `/`. Ids and step names compare exactly, also on MySQL.
|
|
690
762
|
- `flow.transaction` is atomic only when the notebook lives in the same database as your data.
|
|
691
763
|
|
|
692
764
|
## Development
|
|
@@ -24,6 +24,7 @@ module ActiveDurable
|
|
|
24
24
|
steps = @execution.steps.to_a
|
|
25
25
|
@steps = steps
|
|
26
26
|
@forward_steps = steps.select(&:forward?).sort_by { |step| step.position.to_i }
|
|
27
|
+
@rerun_steps = @forward_steps.select(&:position) # a parallel branch cannot be a starting point
|
|
27
28
|
@undo_steps = steps.select(&:undo?)
|
|
28
29
|
@hook_steps = steps.select(&:hook?)
|
|
29
30
|
@signals = @execution.signals.to_a
|
|
@@ -32,7 +33,7 @@ module ActiveDurable
|
|
|
32
33
|
|
|
33
34
|
def retry_now
|
|
34
35
|
ActiveDurable.retry(params[:id])
|
|
35
|
-
redirect_to execution_path(params[:id]), notice: "Retrying.
|
|
36
|
+
redirect_to execution_path(params[:id]), notice: "Retrying. The step that blocked it got a fresh set of attempts."
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
def compensate
|
|
@@ -57,6 +57,13 @@ module ActiveDurable
|
|
|
57
57
|
safe_join(name.to_s.split(/(?<=_)/), tag.wbr)
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# Ids with a slash cannot be routed; versions before 0.7 accepted them, so they are listed without a link.
|
|
61
|
+
def execution_link(id, **options)
|
|
62
|
+
return tag.span(id, title: "Ids with a slash have no page; use the console", **options) if id.include?("/")
|
|
63
|
+
|
|
64
|
+
link_to(id, execution_path(id), **options)
|
|
65
|
+
end
|
|
66
|
+
|
|
60
67
|
def ticket_for(execution, step)
|
|
61
68
|
"#{execution.id}:#{step.name}"
|
|
62
69
|
end
|
|
@@ -75,7 +82,7 @@ module ActiveDurable
|
|
|
75
82
|
end
|
|
76
83
|
|
|
77
84
|
def can_compensate?(execution, steps)
|
|
78
|
-
%w[blocked pending
|
|
85
|
+
%w[blocked pending sleeping waiting].include?(execution.status) && !execution.compensating &&
|
|
79
86
|
steps.none? { |step| step.kind == "pivot" && step.completed? }
|
|
80
87
|
end
|
|
81
88
|
|
|
@@ -109,7 +116,8 @@ module ActiveDurable
|
|
|
109
116
|
|
|
110
117
|
def state_word(state)
|
|
111
118
|
{
|
|
112
|
-
"completed" => "done", "failed" => "failed", "
|
|
119
|
+
"completed" => "done", "failed" => "failed", "blocked" => "blocked", "retrying" => "retrying",
|
|
120
|
+
"waiting" => "waiting for a signal",
|
|
113
121
|
"sleeping" => "sleeping", "undone" => "undone", "next" => "up next", "running" => "running"
|
|
114
122
|
}.fetch(state.to_s, state.to_s)
|
|
115
123
|
end
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
<% items = track_items(execution, @steps_by_execution.fetch(execution.id, [])) %>
|
|
44
44
|
<tr data-id="<%= execution.id %>" data-status="<%= execution.status %>">
|
|
45
45
|
<td class="exec">
|
|
46
|
-
<%=
|
|
46
|
+
<%= execution_link(execution.id, class: "exec-link") %>
|
|
47
47
|
<span class="recipe"><%= execution.recipe %>, version <%= execution.recipe_version %></span>
|
|
48
48
|
</td>
|
|
49
49
|
<td>
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
Started <%= when_text(@execution.created_at) %>, last change <%= when_text(@execution.updated_at) %>.
|
|
16
16
|
<% if @execution.wake_at %>Wakes <%= when_text(@execution.wake_at) %>.<% end %>
|
|
17
17
|
<% if @execution.forked_from %>
|
|
18
|
-
Rerun of <%=
|
|
18
|
+
Rerun of <%= execution_link(@execution.forked_from, class: "mono") %>.
|
|
19
19
|
<% end %>
|
|
20
20
|
</p>
|
|
21
21
|
</div>
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
</section>
|
|
30
30
|
<% end %>
|
|
31
31
|
|
|
32
|
-
<% if can_retry?(@execution) || can_compensate?(@execution, @forward_steps) || (can_rerun?(@execution) && @
|
|
32
|
+
<% if can_retry?(@execution) || can_compensate?(@execution, @forward_steps) || (can_rerun?(@execution) && @rerun_steps.any?) %>
|
|
33
33
|
<section class="actions" aria-label="Actions">
|
|
34
34
|
<% if can_retry?(@execution) %>
|
|
35
35
|
<%= button_to retry_execution_path(@execution), class: "primary", form_class: "inline" do %>Retry<% end %>
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
Undo everything
|
|
41
41
|
<% end %>
|
|
42
42
|
<% end %>
|
|
43
|
-
<% if can_rerun?(@execution) && @
|
|
43
|
+
<% if can_rerun?(@execution) && @rerun_steps.any? %>
|
|
44
44
|
<%= form_with url: rerun_execution_path(@execution), method: :post, class: "inline", local: true do %>
|
|
45
45
|
<label for="rerun-from">Run again from</label>
|
|
46
|
-
<%= select_tag :from, options_for_select(@
|
|
46
|
+
<%= select_tag :from, options_for_select(@rerun_steps.map(&:name), rerun_default(@rerun_steps)), id: "rerun-from" %>
|
|
47
47
|
<button type="submit">Rerun</button>
|
|
48
48
|
<% end %>
|
|
49
49
|
<p class="note">A rerun is a new execution. Steps before the one you choose are reused; that step and the ones
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
<% items.each_with_index do |item, index| %>
|
|
66
66
|
<% if index.positive? %>
|
|
67
67
|
<% previous = items[index - 1] %>
|
|
68
|
-
<% if previous.kind == "pivot" && previous.state
|
|
68
|
+
<% if previous.kind == "pivot" && !%w[failed blocked].include?(previous.state) %>
|
|
69
69
|
<span class="gate" style="--i: <%= index %>"><span>point of no return</span></span>
|
|
70
70
|
<% end %>
|
|
71
71
|
<% wire = if item.state == "undone" || (backwards && previous.state == "undone") then "back"
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
<section class="panel">
|
|
200
200
|
<h2>Reruns</h2>
|
|
201
201
|
<% @reruns.each do |rerun| %>
|
|
202
|
-
<p><%=
|
|
202
|
+
<p><%= execution_link(rerun.id, class: "mono") %> <%= status_pill(rerun.status) %></p>
|
|
203
203
|
<% end %>
|
|
204
204
|
</section>
|
|
205
205
|
<% end %>
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
.tone-good, .st-completed { --c: var(--jade); --cb: var(--jade-bg); }
|
|
34
34
|
.tone-wait, .st-retrying, .st-waiting, .st-sleeping { --c: var(--amber); --cb: var(--amber-bg); }
|
|
35
35
|
.tone-busy, .st-running, .st-next { --c: var(--cobalt); --cb: var(--cobalt-bg); }
|
|
36
|
-
.tone-bad, .st-failed { --c: var(--ruby); --cb: var(--ruby-bg); }
|
|
36
|
+
.tone-bad, .st-failed, .st-blocked { --c: var(--ruby); --cb: var(--ruby-bg); }
|
|
37
37
|
.tone-undo, .st-undone { --c: var(--violet); --cb: var(--violet-bg); }
|
|
38
38
|
.tone-muted { --c: var(--slate); --cb: var(--slate-bg); }
|
|
39
39
|
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
animation-delay: calc(var(--i, 0) * 45ms); }
|
|
141
141
|
.mini .b.st-completed { background: var(--c); }
|
|
142
142
|
.mini .b.k-pivot { transform: rotate(45deg) scale(.86); animation-name: pop-pivot; }
|
|
143
|
-
.mini .b.st-failed { animation: pop .35s both, shake .5s .5s ease 2; }
|
|
143
|
+
.mini .b.st-failed, .mini .b.st-blocked { animation: pop .35s both, shake .5s .5s ease 2; }
|
|
144
144
|
.mini .b.st-undone { background: repeating-linear-gradient(135deg, var(--cb) 0 3px, var(--c) 3px 5px); }
|
|
145
145
|
.mini .b.st-next { border-style: dashed; background: transparent; animation: pop .35s both, beat 1.2s ease-in-out infinite; }
|
|
146
146
|
.mini .b.st-retrying, .mini .b.st-sleeping { animation: pop .35s both, beat 2.2s ease-in-out infinite; }
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
.block.st-completed { --cb: color-mix(in srgb, var(--jade-bg) 85%, var(--surface)); }
|
|
195
195
|
.block.k-pivot { border-width: 3px; }
|
|
196
196
|
.block.k-pivot .kind { color: var(--amber); }
|
|
197
|
-
.block.st-failed { animation: drop .55s both, shake .5s .7s ease 2; box-shadow: 0 7px 0 var(--c2), 0 0 0 6px color-mix(in srgb, var(--ruby) 18%, transparent); }
|
|
197
|
+
.block.st-failed, .block.st-blocked { animation: drop .55s both, shake .5s .7s ease 2; box-shadow: 0 7px 0 var(--c2), 0 0 0 6px color-mix(in srgb, var(--ruby) 18%, transparent); }
|
|
198
198
|
.block.st-undone { background: repeating-linear-gradient(135deg, var(--violet-bg) 0 10px, color-mix(in srgb, var(--violet) 22%, var(--violet-bg)) 10px 20px); }
|
|
199
199
|
.block.st-undone h3 { text-decoration: line-through; text-decoration-thickness: 2px; }
|
|
200
200
|
.block.st-next { background: transparent; border-style: dashed; box-shadow: none; animation: drop .55s both, beat 1.4s ease-in-out infinite; }
|
|
@@ -39,6 +39,19 @@ module ActiveDurable
|
|
|
39
39
|
# development and test only: closed in production, staging and any other environment.
|
|
40
40
|
attr_accessor :dashboard_authorize
|
|
41
41
|
|
|
42
|
+
# Errors that mean the code is wrong, not the outside world. A step that raises one of them is neither retried
|
|
43
|
+
# nor undone: the execution is blocked until the code is fixed and someone calls ActiveDurable.retry. Classes
|
|
44
|
+
# or class names; a name also matches subclasses and needs no loaded gem. Add your own with
|
|
45
|
+
# `config.code_errors << "Payments::Misconfigured"`, or drop one with `config.code_errors -= ["ArgumentError"]`.
|
|
46
|
+
# Errors outside StandardError (LoadError, NotImplementedError, SystemStackError) always count as bugs.
|
|
47
|
+
attr_accessor :code_errors
|
|
48
|
+
|
|
49
|
+
# The default {#code_errors}. NameError covers NoMethodError, and IndexError covers KeyError.
|
|
50
|
+
DEFAULT_CODE_ERRORS = %w[
|
|
51
|
+
NameError ArgumentError TypeError IndexError FrozenError ZeroDivisionError RangeError
|
|
52
|
+
NoMatchingPatternError LocalJumpError RegexpError EncodingError
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
42
55
|
attr_writer :logger
|
|
43
56
|
|
|
44
57
|
def initialize
|
|
@@ -53,6 +66,7 @@ module ActiveDurable
|
|
|
53
66
|
@parallel_concurrency = 4
|
|
54
67
|
@clock = -> { Time.current }
|
|
55
68
|
@dashboard_authorize = nil
|
|
69
|
+
@code_errors = DEFAULT_CODE_ERRORS.dup
|
|
56
70
|
@logger = nil
|
|
57
71
|
end
|
|
58
72
|
|
|
@@ -18,7 +18,10 @@ module ActiveDurable
|
|
|
18
18
|
class RecipeChanged < Error; end
|
|
19
19
|
|
|
20
20
|
# A step returned something that cannot be stored in the notebook as JSON.
|
|
21
|
-
class NotSerializable < Error
|
|
21
|
+
class NotSerializable < Error
|
|
22
|
+
# @api private
|
|
23
|
+
attr_accessor :step_name
|
|
24
|
+
end
|
|
22
25
|
|
|
23
26
|
# Raise it inside a step to reject the work for a business reason: no retries, straight to compensation.
|
|
24
27
|
class Abort < Error; end
|
|
@@ -37,8 +40,15 @@ module ActiveDurable
|
|
|
37
40
|
end
|
|
38
41
|
end
|
|
39
42
|
|
|
40
|
-
# An undo kept failing after all its attempts. The execution is blocked for a human to review.
|
|
41
|
-
class UndoFailed < Error
|
|
43
|
+
# An undo kept failing after all its attempts, or hit a bug. The execution is blocked for a human to review.
|
|
44
|
+
class UndoFailed < Error
|
|
45
|
+
attr_reader :step_name
|
|
46
|
+
|
|
47
|
+
def initialize(message = nil, step_name: nil)
|
|
48
|
+
@step_name = step_name
|
|
49
|
+
super(message)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
42
52
|
|
|
43
53
|
# A flow.on(:completed) or flow.on(:compensated) hook raised. The execution is blocked; ActiveDurable.retry runs
|
|
44
54
|
# the hook again once it is fixed.
|
|
@@ -51,9 +61,10 @@ module ActiveDurable
|
|
|
51
61
|
end
|
|
52
62
|
end
|
|
53
63
|
|
|
54
|
-
# A step whose code cannot run as written: it raised
|
|
55
|
-
#
|
|
56
|
-
# blocked until the code is fixed and someone calls ActiveDurable.retry.
|
|
64
|
+
# A step whose code cannot run as written: it raised one of {Configuration#code_errors} (a typo, a missing
|
|
65
|
+
# key, a wrong argument). Retrying cannot fix it and undoing the saga would punish customers for a bug, so the
|
|
66
|
+
# execution is blocked until the code is fixed and someone calls ActiveDurable.retry. Rescuing it in the recipe
|
|
67
|
+
# does not help: the saga stops at the next step and blocks anyway.
|
|
57
68
|
class CodeError < Error
|
|
58
69
|
attr_reader :step_name
|
|
59
70
|
|
|
@@ -63,6 +74,17 @@ module ActiveDurable
|
|
|
63
74
|
end
|
|
64
75
|
end
|
|
65
76
|
|
|
77
|
+
# A step ran, but the database refused to record its result. Running it again could repeat its effect and
|
|
78
|
+
# undoing the saga would skip it, so the execution is blocked at that step.
|
|
79
|
+
class CheckpointFailed < Error
|
|
80
|
+
attr_reader :step_name
|
|
81
|
+
|
|
82
|
+
def initialize(step_name, error)
|
|
83
|
+
@step_name = step_name
|
|
84
|
+
super("step :#{step_name} ran, but its result could not be recorded: #{error.class}: #{error.message}")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
66
88
|
# Control flow signals. They inherit from Exception on purpose: a `rescue => e` inside
|
|
67
89
|
# user code must not swallow them, otherwise a lost lease could keep writing.
|
|
68
90
|
#
|
|
@@ -79,13 +101,36 @@ module ActiveDurable
|
|
|
79
101
|
# @api private
|
|
80
102
|
class StopForward < ControlFlow; end
|
|
81
103
|
|
|
82
|
-
# Serializes an exception for the notebook and the dashboard.
|
|
104
|
+
# Serializes an exception for the notebook and the dashboard. The message is cleaned up first: it may quote the
|
|
105
|
+
# very bytes the database refused.
|
|
83
106
|
def self.dump_error(error, step: nil)
|
|
84
107
|
{
|
|
85
108
|
"class" => error.class.name,
|
|
86
|
-
"message" => error.message
|
|
109
|
+
"message" => storable_text(error.message)[0, 2000],
|
|
87
110
|
"step" => step,
|
|
88
111
|
"at" => now.utc.iso8601(6)
|
|
89
112
|
}.compact
|
|
90
113
|
end
|
|
114
|
+
|
|
115
|
+
# Whether an error means the code is wrong rather than the outside world: it is one of config.code_errors (or a
|
|
116
|
+
# subclass), or one of the errors Ruby raises outside StandardError, such as LoadError or SystemStackError.
|
|
117
|
+
#
|
|
118
|
+
# @api private
|
|
119
|
+
def self.code_error?(error)
|
|
120
|
+
return true unless error.is_a?(StandardError)
|
|
121
|
+
|
|
122
|
+
names = config.code_errors.map { |item| item.is_a?(Module) ? item.name : item.to_s }
|
|
123
|
+
error.class.ancestors.any? { |ancestor| names.include?(ancestor.name) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @api private
|
|
127
|
+
def self.storable_text(text)
|
|
128
|
+
text = text.to_s
|
|
129
|
+
text = if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding)
|
|
130
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub("?")
|
|
131
|
+
else
|
|
132
|
+
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
|
|
133
|
+
end
|
|
134
|
+
text.delete("\u0000")
|
|
135
|
+
end
|
|
91
136
|
end
|
|
@@ -11,6 +11,8 @@ module ActiveDurable
|
|
|
11
11
|
# Statuses where nothing happens without a person: done, undone, blocked (needs {ActiveDurable.retry},
|
|
12
12
|
# {ActiveDurable.compensate} or a fix), or replaced by a rerun.
|
|
13
13
|
TERMINAL = %w[completed compensated blocked superseded].freeze
|
|
14
|
+
# Statuses that never change again: signals are refused and ActiveDurable.prune may delete them.
|
|
15
|
+
FINISHED = %w[completed compensated superseded].freeze
|
|
14
16
|
# Every status.
|
|
15
17
|
STATUSES = (ACTIVE + TERMINAL).freeze
|
|
16
18
|
|
|
@@ -42,6 +44,11 @@ module ActiveDurable
|
|
|
42
44
|
TERMINAL.include?(status)
|
|
43
45
|
end
|
|
44
46
|
|
|
47
|
+
# @return [Boolean] whether it will never change again (blocked is not finished: it can be retried)
|
|
48
|
+
def finished?
|
|
49
|
+
FINISHED.include?(status)
|
|
50
|
+
end
|
|
51
|
+
|
|
45
52
|
# The forward steps in recipe order (undo and hook entries excluded).
|
|
46
53
|
def notebook
|
|
47
54
|
steps.where.not(kind: %w[undo hook]).reorder(:position).to_a
|
data/lib/active_durable/flow.rb
CHANGED
|
@@ -32,6 +32,12 @@ module ActiveDurable
|
|
|
32
32
|
# @api private
|
|
33
33
|
attr_reader :undo_stack
|
|
34
34
|
|
|
35
|
+
# The bug (or unrecorded result) that stopped this run. Once set, no other step runs and the execution is
|
|
36
|
+
# blocked, even if the recipe rescued the error.
|
|
37
|
+
#
|
|
38
|
+
# @api private
|
|
39
|
+
attr_reader :blocked_by
|
|
40
|
+
|
|
35
41
|
# @api private
|
|
36
42
|
def initialize(runner, compensating:)
|
|
37
43
|
@runner = runner
|
|
@@ -43,6 +49,7 @@ module ActiveDurable
|
|
|
43
49
|
@seen = {}
|
|
44
50
|
@undo_stack = []
|
|
45
51
|
@hooks = {}
|
|
52
|
+
@blocked_by = nil
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
# @return [String] the id of the execution this recipe is running for
|
|
@@ -206,7 +213,7 @@ module ActiveDurable
|
|
|
206
213
|
signal = SignalRecord.next_for(execution_id, name)
|
|
207
214
|
return consume_signal(signal, name, position).deep_dup if signal
|
|
208
215
|
|
|
209
|
-
|
|
216
|
+
unless entry&.waiting? # first time here, or retried after a timeout: wait again, with a new deadline
|
|
210
217
|
deadline = timeout && (now + timeout)
|
|
211
218
|
@notebook.wait!(name, kind: "wait", position: position, wake_at: deadline)
|
|
212
219
|
@runner.suspend!(deadline, "waiting")
|
|
@@ -226,6 +233,7 @@ module ActiveDurable
|
|
|
226
233
|
#
|
|
227
234
|
# @api private
|
|
228
235
|
def finish!
|
|
236
|
+
raise blocked_by if blocked_by
|
|
229
237
|
return if compensating?
|
|
230
238
|
|
|
231
239
|
missing = @notebook.forward_entries.reject { |entry| @seen.key?(entry.name) }
|
|
@@ -267,7 +275,11 @@ module ActiveDurable
|
|
|
267
275
|
remember_failure(name, kind, undo, options)
|
|
268
276
|
raise StepFailed.new(name, entry.error&.fetch("message", nil))
|
|
269
277
|
end
|
|
270
|
-
|
|
278
|
+
if compensating?
|
|
279
|
+
# Stopped between attempts or on a bug: it may have acted, so undo_on_failure applies.
|
|
280
|
+
remember_failure(name, kind, undo, options) if entry&.unfinished?
|
|
281
|
+
raise StopForward, name
|
|
282
|
+
end
|
|
271
283
|
|
|
272
284
|
@runner.suspend!(entry.wake_at, "sleeping") if entry&.retrying? && entry.wake_at && entry.wake_at > now
|
|
273
285
|
|
|
@@ -280,28 +292,51 @@ module ActiveDurable
|
|
|
280
292
|
ActiveDurable.crash_point(:before_step, name)
|
|
281
293
|
result = ActiveDurable.instrument("step", execution_id: execution_id, step: name, kind: kind) do
|
|
282
294
|
if kind == "transaction"
|
|
283
|
-
@notebook.transaction { record_result(name, kind, position, block.call(ticket)) }
|
|
295
|
+
@notebook.transaction(records: name) { record_result(name, kind, position, block.call(ticket)) }
|
|
284
296
|
else
|
|
285
297
|
record_result(name, kind, position, block.call(ticket))
|
|
286
298
|
end
|
|
287
299
|
end
|
|
288
300
|
ActiveDurable.crash_point(:after_record, name)
|
|
289
301
|
result
|
|
290
|
-
rescue
|
|
291
|
-
|
|
292
|
-
rescue
|
|
293
|
-
|
|
294
|
-
rescue StandardError => e
|
|
302
|
+
rescue Abort => e
|
|
303
|
+
handle_failure(name, kind, position, entry, undo, options, e)
|
|
304
|
+
rescue NotSerializable, InvalidRecipe, CheckpointFailed => e
|
|
305
|
+
block_step!(name, kind, position, entry, e)
|
|
306
|
+
rescue StandardError, ScriptError, SystemStackError => e
|
|
307
|
+
raise if blocked_by # a nested step already blocked the run
|
|
308
|
+
|
|
309
|
+
return block_step!(name, kind, position, entry, CodeError.new(name, e)) if ActiveDurable.code_error?(e)
|
|
310
|
+
|
|
295
311
|
handle_failure(name, kind, position, entry, undo, options, e)
|
|
296
312
|
end
|
|
297
313
|
|
|
314
|
+
# Outside a transaction the step has already acted when its result is recorded, so a write the database
|
|
315
|
+
# refuses is not a failure of the step: it blocks. Inside flow.transaction it rolled back with the step.
|
|
298
316
|
def record_result(name, kind, position, value)
|
|
299
317
|
result = Serializer.normalize(value, "the result of :#{name}")
|
|
300
318
|
ActiveDurable.crash_point(:after_call, name)
|
|
301
|
-
|
|
319
|
+
begin
|
|
320
|
+
@notebook.complete!(name, kind: kind, position: position, result: result)
|
|
321
|
+
rescue StandardError => e
|
|
322
|
+
raise if kind == "transaction"
|
|
323
|
+
|
|
324
|
+
raise CheckpointFailed.new(name, e)
|
|
325
|
+
end
|
|
302
326
|
result
|
|
303
327
|
end
|
|
304
328
|
|
|
329
|
+
# Retrying cannot fix a bug and undoing the saga would punish customers for it: the step is written down as
|
|
330
|
+
# blocked, and nothing else runs until someone fixes the code and calls ActiveDurable.retry. It keeps its
|
|
331
|
+
# attempts, since a bug is not a failure of the outside world.
|
|
332
|
+
def block_step!(name, kind, position, entry, error)
|
|
333
|
+
error.step_name ||= name if error.respond_to?(:step_name=)
|
|
334
|
+
@blocked_by = error
|
|
335
|
+
@notebook.block!(name, kind: kind, position: position, attempts: entry&.attempts || 0,
|
|
336
|
+
error: ActiveDurable.dump_error(error, step: name))
|
|
337
|
+
raise error
|
|
338
|
+
end
|
|
339
|
+
|
|
305
340
|
def handle_failure(name, kind, position, entry, undo, options, error)
|
|
306
341
|
attempts = (entry&.attempts || 0) + 1
|
|
307
342
|
default = @pivoted ? config.after_pivot_attempts : config.step_attempts
|
|
@@ -348,6 +383,8 @@ module ActiveDurable
|
|
|
348
383
|
end
|
|
349
384
|
|
|
350
385
|
def visit!(name, kind)
|
|
386
|
+
raise blocked_by if blocked_by
|
|
387
|
+
|
|
351
388
|
name = name.to_s
|
|
352
389
|
raise InvalidRecipe, "step names cannot be blank" if name.empty?
|
|
353
390
|
raise InvalidRecipe, "step names cannot end in ':undo' (#{name})" if name.end_with?(":undo")
|