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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6ce41040ab6f0cb0036bfe36b30b831a1f91a7da099b9f9e3914568716ef6e6
4
- data.tar.gz: ac2223d523ec0edf3470b8b9b66b5a892d2521156ab0446e874feff3104c59ff
3
+ metadata.gz: ca565450baafb042b5ec363d2d7c101f24edd3281b180d43c173eaeff8dd8df8
4
+ data.tar.gz: 96ceecb07cf0d85b0d8aaf604dececee56301983d16055de408e533c53059da6
5
5
  SHA512:
6
- metadata.gz: c95a763c1808a55ceebf13c02aa1e53f29830601b2547f6175c6bb375f1b78b9a5c5cd02aa2da505afac8989259f2047f588448aef34ce3bdb98a61dc2037e85
7
- data.tar.gz: f12d9dff80e6c7e77bdaa40cd3683356bfc443b04eb442d59a5ca2e8e3da5cd9ecf2f0f97df180014c41c723b49ffb6b80fcfc111833f02994856fef460f61c3
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
- ## [Unreleased]
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, with `bin/jobs` next to the server.
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.** Anything else the recipe raises, and a `NameError` or `NoMethodError` inside a step,
432
- blocks the execution instead of undoing it: a typo in a deploy must never refund your customers. Fix the code and
433
- call `ActiveDurable.retry(id)`, or press Retry in the dashboard, and the saga carries on from where it stopped. To
434
- reject the work for a business reason outside a step, call `flow.abort!(reason)`.
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 where it stopped
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
- All three refuse an execution a worker is running right now. A rerun runs the chosen step and the following ones
595
- again with new tickets, so they have effects again; a blocked original becomes `superseded`.
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. Failed steps got a fresh set of attempts."
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 running sleeping waiting].include?(execution.status) && !execution.compensating &&
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", "retrying" => "retrying", "waiting" => "waiting for a signal",
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
- <%= link_to execution.id, execution_path(execution), class: "exec-link" %>
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 <%= link_to @execution.forked_from, execution_path(@execution.forked_from), class: "mono" %>.
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) && @forward_steps.any?) %>
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) && @forward_steps.any? %>
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(@forward_steps.map(&:name), rerun_default(@forward_steps)), id: "rerun-from" %>
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 != "failed" %>
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><%= link_to rerun.id, execution_path(rerun), class: "mono" %> <%= status_pill(rerun.status) %></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; end
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; end
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 NameError or NoMethodError (a typo, a missing class or
55
- # method). Retrying cannot fix it and undoing the saga would punish customers for a bug, so the execution is
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.to_s[0, 2000],
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
@@ -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
- if entry.nil?
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
- raise StopForward, name if compensating?
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 NotSerializable, InvalidRecipe
291
- raise
292
- rescue NameError => e # NoMethodError too: a bug in the code, not a failure of the outside world
293
- raise CodeError.new(name, e)
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
- @notebook.complete!(name, kind: kind, position: position, result: result)
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")