active_durable 0.6.0 → 0.8.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/README.md +238 -12
  4. data/app/controllers/active_durable/executions_controller.rb +2 -1
  5. data/app/helpers/active_durable/dashboard_helper.rb +12 -4
  6. data/app/views/active_durable/executions/index.html.erb +1 -1
  7. data/app/views/active_durable/executions/show.html.erb +9 -6
  8. data/app/views/layouts/active_durable/application.html.erb +3 -3
  9. data/lib/active_durable/configuration.rb +30 -0
  10. data/lib/active_durable/errors.rb +53 -8
  11. data/lib/active_durable/execution.rb +22 -2
  12. data/lib/active_durable/flow.rb +147 -31
  13. data/lib/active_durable/flow_parallel.rb +38 -12
  14. data/lib/active_durable/notebook.rb +17 -4
  15. data/lib/active_durable/operations.rb +55 -17
  16. data/lib/active_durable/pruner.rb +19 -4
  17. data/lib/active_durable/record.rb +9 -1
  18. data/lib/active_durable/runner.rb +45 -10
  19. data/lib/active_durable/serializer.rb +42 -5
  20. data/lib/active_durable/signal_record.rb +18 -2
  21. data/lib/active_durable/step.rb +20 -0
  22. data/lib/active_durable/sweeper.rb +1 -1
  23. data/lib/active_durable/testing.rb +18 -13
  24. data/lib/active_durable/version.rb +1 -1
  25. data/lib/active_durable.rb +79 -11
  26. data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +15 -6
  27. data/lib/generators/active_durable/upgrade/templates/add_active_durable_context.rb.tt +19 -0
  28. data/lib/generators/active_durable/upgrade/templates/add_active_durable_continued_as.rb.tt +21 -0
  29. data/lib/generators/active_durable/upgrade/templates/add_active_durable_signal_name.rb.tt +22 -0
  30. data/lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt +72 -0
  31. data/lib/generators/active_durable/upgrade/upgrade_generator.rb +4 -1
  32. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6ce41040ab6f0cb0036bfe36b30b831a1f91a7da099b9f9e3914568716ef6e6
4
- data.tar.gz: ac2223d523ec0edf3470b8b9b66b5a892d2521156ab0446e874feff3104c59ff
3
+ metadata.gz: 47ef8eb3bb5a133221ed52ce914ec952ff7f73f552b5352d4558b96e6738458c
4
+ data.tar.gz: af132b5a177125726a6aed8699e7cf4391c70e9e4fbac566531ab87c3204f998
5
5
  SHA512:
6
- metadata.gz: c95a763c1808a55ceebf13c02aa1e53f29830601b2547f6175c6bb375f1b78b9a5c5cd02aa2da505afac8989259f2047f588448aef34ce3bdb98a61dc2037e85
7
- data.tar.gz: f12d9dff80e6c7e77bdaa40cd3683356bfc443b04eb442d59a5ca2e8e3da5cd9ecf2f0f97df180014c41c723b49ffb6b80fcfc111833f02994856fef460f61c3
6
+ metadata.gz: fe0584f97ce0594ee30c2186b8a10ac91e57505172b758cf8e667b290a6cb387e0da3566db685c8070798bdab84f32f225a7f2f1787aabc9b106bbe069ceb11e
7
+ data.tar.gz: 863396b2acc54f007f65c80379e3bcbac0512eb05888d3a91372cddfd88333e5b3536d25798949eaf2200a85fd021ae540a65ffd4d1d1078716e0221247a609b
data/CHANGELOG.md CHANGED
@@ -5,7 +5,105 @@ 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.8.0] - 2026-10-22
9
+
10
+ For sagas that live long and talk to people, such as a WhatsApp agent that books appointments: a mailbox for
11
+ messages, a fresh notebook for long conversations, and the tenant in every run.
12
+
13
+ Upgrading from 0.7: `bin/rails generate active_durable:upgrade && bin/rails db:migrate`. It adds three nullable
14
+ columns (`durable_executions.context`, `durable_executions.continued_as` with its index, `durable_steps.signal_name`);
15
+ nothing is rewritten. Note the first change below: hash keys now come back sorted.
16
+
17
+ ### Added
18
+
19
+ - `flow.continue_as_new(**input)` closes the execution and starts a new one of the same recipe (its latest version)
20
+ with that input, the same context and the signals nobody read yet, in one transaction. Ids follow the chain
21
+ `order-1`, `order-1~2`, `order-1~3`. The new `continued` status; `Durable.find(id, latest: true)`.
22
+ `Durable.signal` to any id of the chain reaches the newest execution, also during the hand-off.
23
+ - `flow.wait_for(name, signal:, all:)`: several waits share one signal name, like a mailbox, and `all: true` takes
24
+ every pending signal at once, returning their payloads in order.
25
+ - `config.context`: an object with `capture` (when an execution starts) and `wrap(context) { }` (around every run,
26
+ undo, hook and `flow.parallel` branch). `Durable.start(..., context: {...})` gives it explicitly. Reruns keep it.
27
+ - `ActiveDurable::Testing.drain(id, expire_waits: false)` stops at a wait instead of fast-forwarding to its
28
+ timeout, to test a conversation turn by turn. `drain` follows `continue_as_new`.
29
+ - The dashboard shows `continued` executions and links to the one that carries on.
30
+
31
+ ### Changed
32
+
33
+ - Hash keys in step results, inputs, signal payloads and `flow.parallel` results are sorted, on the first run and
34
+ when read back. PostgreSQL's jsonb and MySQL's JSON store keys shorter-first, so a replay used to read a hash whose
35
+ keys were in another order than the first run returned. That broke anything that compares bytes, such as an AI
36
+ provider that checks the conversation it is sent again. Executions in flight during the upgrade see their keys in
37
+ a new order once.
38
+ - `Durable.signal` locks the execution row while it writes the signal.
39
+ - `Testing.drain` delivers each signal given in `signals:` once per call, instead of every time a step of that name
40
+ waits.
41
+ - `Durable.start` takes `context:`, so `context` can no longer be an input key.
42
+ - `ActiveDurable.retry`, `compensate` and `rerun` on a continued execution raise, naming the one that carries on.
43
+ - `ActiveDurable.prune` deletes a continued execution only together with the one that carried it on.
44
+
45
+ ## [0.7.0] - 2026-10-07
46
+
47
+ Fixes from an architecture and security review. Most of them stop a saga from paying twice or undoing the wrong
48
+ thing.
49
+
50
+ Upgrading from 0.6: `bin/rails generate active_durable:upgrade && bin/rails db:migrate`. The new migration only
51
+ changes MySQL databases: it rebuilds the three tables and blocks writes to them while it runs, so run it at a quiet
52
+ time, or prune first. Note the behavior changes below: more errors block instead of undoing, `retry` no longer
53
+ resets every failed step, rerun ids are random, and `Durable.start` refuses some ids.
54
+
55
+ ### Changed
56
+
57
+ - More errors count as bugs and block the saga instead of being retried and undone: `ArgumentError`, `TypeError`,
58
+ `IndexError` and `KeyError`, `FrozenError`, `ZeroDivisionError`, `RangeError`, `NoMatchingPatternError`,
59
+ `LocalJumpError`, `RegexpError` and `EncodingError`, besides `NameError` and `NoMethodError`. The list is the new
60
+ `config.code_errors`, which takes classes or names, so an app can add its own errors or drop one.
61
+ - A step that blocks is written in the notebook as `blocked`, and runs again after `ActiveDurable.retry`. Rescuing
62
+ its error in the recipe no longer lets the saga carry on.
63
+ - `ActiveDurable.retry` gives a fresh set of attempts only to the step that blocked the saga (with its whole
64
+ `flow.parallel`) and to steps that hit a bug. Compensating, it still resets every failed undo.
65
+ - Rerun ids are `<id>~rerun-<random>` instead of `<id>~rerun-<count>`.
66
+ - `ActiveDurable.compensate` refuses `running` executions, even after their lease ran out.
67
+ - `Durable.start` refuses ids that are blank, contain `/`, a NUL character or bytes that are not UTF-8.
68
+ - The serializer converts binary strings that are valid UTF-8 (such as `Net::HTTP` bodies) and strings in other
69
+ encodings to UTF-8, and rejects NUL characters and invalid bytes, in values and keys.
70
+
71
+ ### Fixed
72
+
73
+ - `retry` ran again a failed step the recipe had already handled: with a fallback (Stripe fails, the recipe pays
74
+ with PayPal) and a later bug, retrying charged twice. `rerun` did the same; it now copies the failed steps before
75
+ the chosen one too.
76
+ - A step whose result the database refused (a NUL on PostgreSQL, bytes that are not UTF-8 on MySQL and SQLite, any
77
+ failed write) counted as a failed attempt: it ran again, then the saga was undone without undoing it. It now
78
+ blocks.
79
+ - Undoing a saga by hand skipped a step declared `undo_on_failure` that was between attempts or blocked, and the
80
+ finished branches of a `flow.parallel` stopped halfway.
81
+ - An operator could undo a saga while its worker was still inside a step longer than `lease_duration`.
82
+ - A pending signal nobody was waiting for (a duplicate webhook) made the saga enqueue itself again forever.
83
+ - Signals sent to a blocked saga were refused and lost.
84
+ - `rerun` lost the undos of the `flow.parallel` branches before the chosen step, and from a branch it ran nothing;
85
+ the dashboard no longer offers branches.
86
+ - On MySQL, ids and step names that differ only in case or accents were treated as the same. The new migration
87
+ makes those columns `utf8mb4_bin`.
88
+ - `raise ActiveRecord::Rollback` inside `flow.transaction`, an undo or a hook counted as success.
89
+ - On Rails 6.1 and 7.0 with MySQL, two concurrent `start` calls with the same id inside the app's transaction made
90
+ the second raise `RecordNotFound`.
91
+ - Rerun ids collided after pruning, and could give a new rerun the tickets of a pruned one.
92
+ - An id with `/` broke the whole dashboard list.
93
+ - `LoadError`, `NotImplementedError` and `SystemStackError` left the saga running, retried forever by the sweeper.
94
+ - A bug in an undo used up all its attempts before blocking; it now blocks at once.
95
+ - Error messages are cleaned up (invalid bytes, NUL) before they are stored.
96
+ - README: how to run Solid Queue in development, and the sweeper and cleanup entries go under the `production:` key
97
+ Rails already wrote in `config/recurring.yml`. Pasting a second `production:` key silently dropped Rails' own
98
+ `clear_solid_queue_finished_jobs` task.
99
+
100
+ ### Added
101
+
102
+ - Benchmarks in `benchmarks/`: the cost of a step and of resuming a long saga (`throughput.rb`), and many worker
103
+ processes on the same sagas, optionally killing one with SIGKILL every second (`load.rb`, `CHAOS=1`). The README
104
+ has a "Performance" section with the results on PostgreSQL, MySQL and SQLite.
105
+ - README: what counts as a bug, `config.code_errors`, and new limits: ids are forgotten after pruning, the lease and
106
+ slow steps, clocks, and secrets in error messages.
9
107
 
10
108
  ## [0.6.0] - 2026-10-06
11
109
 
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,8 @@ 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")
216
+ config.context = TenantContext # who the work is for, in every run (see "Context")
205
217
 
206
218
  # Who may open the dashboard outside development and test. With Devise (HTTP basic auth: see Dashboard):
207
219
  config.dashboard_authorize = ->(controller) { controller.request.env["warden"]&.user&.admin? }
@@ -402,7 +414,8 @@ Three rules follow from replaying the recipe:
402
414
  | `flow.pivot(name) { \|ticket\| ... }` | the step after which there is no going back | retried, then the saga is undone |
403
415
  | `flow.parallel(name) { \|branches\| ... }` | several steps at the same time | each branch like a step |
404
416
  | `flow.sleep(name, 3.days)` | waiting without holding a worker | — |
405
- | `flow.wait_for(name, timeout:)` | waiting for `Durable.signal` | a timeout fails the saga |
417
+ | `flow.wait_for(name, timeout:)` | waiting for `Durable.signal` (`signal:` and `all:` for a mailbox) | a timeout fails the saga |
418
+ | `flow.continue_as_new(**input)` | a long-lived saga, such as a chat, that starts a fresh notebook | refused inside a step or with undos pending |
406
419
  | `flow.on(:completed) { ... }` | updating your own records when the saga ends (also `:compensated`) | blocks; a retry runs the hook again |
407
420
 
408
421
  Steps take `undo:`, `retry:` (`3`, `false` or `{ attempts:, backoff: }`) and `undo_on_failure:`.
@@ -428,10 +441,33 @@ When a step runs out of attempts or calls `flow.abort!`, every finished step is
428
441
  stopped. An undo receives `(result, undo_ticket, step_ticket)` and takes as many as it declares: `-> { ... }` takes
429
442
  none. Any object that responds to `call` works too, such as `Payments.method(:refund)`.
430
443
 
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)`.
444
+ **A bug is not a failure.** Undoing a saga refunds money and releases stock, so only a step that failed for good or
445
+ `flow.abort!` triggers it. These block the execution instead, without retrying, because a typo in a deploy must never
446
+ refund your customers:
447
+
448
+ - anything the recipe raises outside a step;
449
+ - inside a step, an error that means the code is wrong: `NameError` (and `NoMethodError`), `ArgumentError`,
450
+ `TypeError`, `IndexError` (and `KeyError`), `FrozenError`, `ZeroDivisionError`, `RangeError`,
451
+ `NoMatchingPatternError`, `LocalJumpError`, `RegexpError`, `EncodingError`, and the errors outside
452
+ `StandardError` such as `LoadError`, `NotImplementedError` and `SystemStackError`;
453
+ - a step whose result cannot be stored (a NUL character, bytes that are not UTF-8): it already acted.
454
+
455
+ Fix the code and call `ActiveDurable.retry(id)`, or press Retry in the dashboard, and the saga carries on from where it
456
+ stopped. Rescuing the error in the recipe does not help: the saga stops at the next step and blocks anyway. To reject
457
+ the work for a business reason, call `flow.abort!(reason)`.
458
+
459
+ The list is `config.code_errors`. Add your own errors, as a class or as a name (a name also matches subclasses, and
460
+ works before the gem that defines it is loaded), or drop one:
461
+
462
+ ```ruby
463
+ ActiveDurable.configure do |config|
464
+ config.code_errors << "Payments::Misconfigured"
465
+ config.code_errors -= ["ArgumentError"] # if a gem you call raises it for bad input
466
+ end
467
+ ```
468
+
469
+ An `ArgumentError` from bad data, like an invalid date, blocks too: a person decides. To treat one of these errors as
470
+ a failure in a single step, rescue it inside the step and raise another error, or call `flow.abort!`.
435
471
 
436
472
  A step that failed is not undone, because it did not happen. The exception is a step whose failure may hide a
437
473
  success, like a charge whose answer timed out: declare `undo_on_failure: true` and its undo runs with `nil` as the
@@ -492,7 +528,7 @@ For progress before the end (paid, shipped), write a step: `flow.transaction(:ma
492
528
  <br>
493
529
 
494
530
  `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.
531
+ arrives. Signals can arrive before the saga starts waiting, or while it is blocked: they are kept until it does.
496
532
 
497
533
  ```ruby
498
534
  kyc = flow.wait_for(:kyc_done, timeout: 2.hours)
@@ -503,6 +539,18 @@ Durable.signal("loan-42", :kyc_done, verified: true)
503
539
 
504
540
  Pass `id:` to `Durable.start` to choose the execution id (the call becomes idempotent).
505
541
 
542
+ **A mailbox.** By default the signal has the step's name. A conversation waits for the customer on every turn, so
543
+ each turn gets its own step name and all of them share one signal name with `signal:`. A message that arrives while
544
+ the saga is busy waits in the mailbox for the next turn. With `all: true` the wait takes every pending message at
545
+ once and returns them in order, so five messages in a row are one turn, not five:
546
+
547
+ ```ruby
548
+ messages = flow.wait_for("reply.#{turn}", signal: :message, all: true, timeout: 1.day)
549
+
550
+ # in the WhatsApp webhook
551
+ Durable.signal(conversation.execution_id, :message, text: params[:text])
552
+ ```
553
+
506
554
  </details>
507
555
 
508
556
  <details>
@@ -528,6 +576,57 @@ the finished branches are undone in the order they finished. Give your connectio
528
576
 
529
577
  </details>
530
578
 
579
+ <details>
580
+ <summary><b>Context: the tenant in every run</b></summary>
581
+
582
+ <br>
583
+
584
+ A saga runs in a worker, days after the request that started it, so `Current.tenant` and friends are gone.
585
+ `config.context` carries them: `capture` runs when the execution starts and its JSON is stored with it; `wrap` runs
586
+ around every run of it, including undos, hooks and each `flow.parallel` branch thread.
587
+
588
+ ```ruby
589
+ module TenantContext
590
+ def self.capture = { "tenant_id" => Current.tenant_id }
591
+ def self.wrap(context, &run) = Current.set(tenant_id: context["tenant_id"], &run)
592
+ end
593
+
594
+ ActiveDurable.configure { |config| config.context = TenantContext }
595
+
596
+ Durable.start(:checkout, order_id: order.id) # captures the current tenant
597
+ Durable.start(:welcome, tenant_id: t.id, context: { "tenant_id" => t.id }) # or gives it explicitly
598
+ ```
599
+
600
+ `wrap` must call the block once. Executions started without a context get `{}`.
601
+
602
+ </details>
603
+
604
+ <details>
605
+ <summary><b>Long conversations: continue as new</b></summary>
606
+
607
+ <br>
608
+
609
+ Every run replays the recipe from the top, reading the notebook. A chat that has gone on for 200 turns reads 1,000
610
+ entries before it can answer. `flow.continue_as_new` closes the execution and starts a fresh one of the same recipe
611
+ (its latest version) with the input you give it, usually a summary, plus the same context and the messages nobody
612
+ read yet. All of it happens in one transaction:
613
+
614
+ ```ruby
615
+ Durable.define(:chat) do |flow, conversation_id:, turn: 0, summary: nil|
616
+ # ... the turn ...
617
+ flow.continue_as_new(conversation_id: conversation_id, turn: turn + 1, summary: summary) if turn % 20 == 19
618
+ end
619
+ ```
620
+
621
+ The new execution's id is the old one plus `~2` (then `~3`), so its steps get new tickets. `Durable.signal` to any id
622
+ of the chain reaches the newest execution, even one sent during the hand-off, and
623
+ `Durable.find(id, latest: true)` returns it. Compute the input from step results, so a replay computes the same.
624
+ `continue_as_new` is refused inside a step and while undos are pending (those steps could never be undone); after
625
+ `flow.pivot` it is allowed. The old execution's hooks do not run, and it is pruned together with the one that
626
+ carries on.
627
+
628
+ </details>
629
+
531
630
  <details>
532
631
  <summary><b>Changing a recipe while sagas are in flight</b></summary>
533
632
 
@@ -547,6 +646,91 @@ New executions use the highest version; each execution keeps the one it started
547
646
 
548
647
  </details>
549
648
 
649
+ ## Example: a WhatsApp assistant that books appointments
650
+
651
+ First in plain words, then the code.
652
+
653
+ **What it does.** A customer writes to a bakery on WhatsApp: "Hi, can I come on Friday to order a cake?". An
654
+ assistant (an AI that can see the calendar) answers, offers the free times and books the one the customer picks. The
655
+ conversation can take two minutes or two days. The customer may send three messages in a row, or disappear and come
656
+ back tomorrow. Meanwhile your servers restart, you deploy, a process dies.
657
+
658
+ **The notebook.** Think of a cook who ticks every step of a recipe in a notebook. When someone interrupts, the cook
659
+ does not trust their memory: they reread the notebook from the top, skip every step with a tick, and carry on from
660
+ the first one without it. ActiveDurable does the same with your code. Each step's result is written in your database,
661
+ and every time the saga wakes up (a message arrives, a timer rings) it rereads the recipe and skips what is done. That
662
+ is why the assistant never books twice, even if the server dies right after booking: the booking has its tick. (A
663
+ step that died halfway runs again with the same ticket, so the calendar and WhatsApp can recognize a repeat.)
664
+
665
+ **Turns.** A turn is one exchange: the customer writes, the assistant answers. Each turn adds three ticks: the
666
+ customer's messages, the AI's answer (which may book) and the WhatsApp message sent back. The AI's answer is written
667
+ down too, so rereading never asks the AI again: no double cost, and the very same text.
668
+
669
+ **The mailbox.** Every message goes to one mailbox, `:message`. If the customer writes while the assistant is
670
+ thinking, the message waits there for the next turn. If they send five in a row, the assistant takes all five at
671
+ once: one turn, not five.
672
+
673
+ **A fresh page.** After 20 turns the notebook has 60 ticks to reread every time something happens. Instead of making
674
+ the same page longer, `continue_as_new` starts a new page with a short summary ("Ana wants a cake on Friday, saw the
675
+ times at 10 and 3"), keeping the messages nobody read yet.
676
+
677
+ **Who it is for.** One app serves many bakeries. `config.context` writes down which one the conversation belongs to
678
+ when it starts, and gives it back on every run, so each step sees the right calendar.
679
+
680
+ ```ruby
681
+ # config/initializers/active_durable.rb
682
+ module TenantContext
683
+ def self.capture = { "tenant_id" => Current.tenant_id }
684
+ def self.wrap(context, &run) = Current.set(tenant_id: context["tenant_id"], &run)
685
+ end
686
+ ActiveDurable.configure { |config| config.context = TenantContext }
687
+
688
+ # app/sagas/booking_assistant_saga.rb
689
+ BookingAssistantSaga = Durable.define(:booking_assistant) do |flow, conversation_id:, turn: 0, summary: nil|
690
+ conversation = Conversation.find(conversation_id)
691
+ history = summary ? [{ "role" => "user", "content" => "What happened so far: #{summary}" }] : []
692
+ booked = nil
693
+
694
+ 20.times do |i|
695
+ n = turn + i
696
+ # 1. The customer's messages: every one that arrived since the last turn.
697
+ messages = flow.wait_for("customer.#{n}", signal: :message, all: true)
698
+ history << { "role" => "user", "content" => messages.map { |m| m["text"] }.join("\n") }
699
+
700
+ # 2. The AI's answer. When it decides to book, it calls Calendar.book(slot, key: ticket): booked once.
701
+ reply = flow.step("assistant.#{n}") { |ticket| BookingAssistant.answer(history, ticket: ticket) }
702
+ history << { "role" => "assistant", "content" => reply["text"] }
703
+
704
+ # 3. The answer. If the process dies right after sending it, this step runs again with the same ticket:
705
+ # WhatsApp.send_text keeps the ticket with the message it sent and skips sending it twice.
706
+ flow.step("send.#{n}") { |ticket| WhatsApp.send_text(conversation.phone, reply["text"], key: ticket) }
707
+
708
+ if reply["booked"]
709
+ booked = reply
710
+ break
711
+ end
712
+ end
713
+ next booked if booked
714
+
715
+ # 20 turns without a booking: a fresh page with a summary.
716
+ summary = flow.step("summary.#{turn}") { BookingAssistant.summarize(history) }
717
+ flow.continue_as_new(conversation_id: conversation_id, turn: turn + 20, summary: summary["text"])
718
+ end
719
+
720
+ # app/controllers/whatsapp_webhooks_controller.rb
721
+ def create
722
+ conversation = Conversation.open_for(params[:from]) # a new one once the last one ended; tenant: Current.tenant_id
723
+ id = "chat-#{conversation.id}"
724
+ Durable.start(:booking_assistant, id: id, conversation_id: conversation.id) # idempotent: starts it once
725
+ Durable.signal(id, :message, text: params[:text]) # reaches the newest page of the chain
726
+ head :ok
727
+ end
728
+ ```
729
+
730
+ To close conversations where the customer went quiet, give the wait a `timeout: 1.day` and rescue
731
+ `ActiveDurable::StepFailed`. To test it turn by turn: `Testing.drain(id, expire_waits: false)`, then `Durable.signal`,
732
+ then drain again.
733
+
550
734
  ## Dashboard
551
735
 
552
736
  ```ruby
@@ -585,14 +769,21 @@ end
585
769
  ## Operating sagas
586
770
 
587
771
  ```ruby
588
- ActiveDurable.retry("checkout-7") # blocked: try again where it stopped
772
+ ActiveDurable.retry("checkout-7") # blocked: try again the step that blocked it
589
773
  ActiveDurable.compensate("checkout-7", reason: "customer cancelled") # undo everything (only before the pivot)
590
774
  ActiveDurable.rerun("checkout-7", from: :ship) # a new execution reusing steps before :ship
591
775
  ActiveDurable.prune(older_than: 30.days) # delete finished executions and their notebook
592
776
  ```
593
777
 
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`.
778
+ `retry` gives the step that blocked the saga a fresh set of attempts. Failed steps the recipe already handled stay
779
+ failed: if Stripe failed and the recipe paid with PayPal instead, a retry never runs Stripe again.
780
+
781
+ A rerun (`<id>~rerun-<random>`) keeps the steps before the chosen one, failed ones and parallel branches included,
782
+ and runs the chosen step and the following ones again with new tickets, so they have effects again. A blocked
783
+ original becomes `superseded`.
784
+
785
+ None of them touches an execution a worker is running. `compensate` refuses a `running` execution even after its
786
+ lease ran out: the worker may still be inside a slow step, and if it died, the sweeper resumes it.
596
787
 
597
788
  ## Testing: the crash tester
598
789
 
@@ -613,7 +804,8 @@ point, kills it right there, and finishes it with a new worker. It is the fastes
613
804
  idempotent.
614
805
 
615
806
  `ActiveDurable::Testing.drain(id, signals: { name => payload })` runs an execution synchronously, fast-forwarding
616
- sleeps, retries and expired leases.
807
+ sleeps, retries and expired leases, and follows `continue_as_new`. It fast-forwards wait timeouts too; to test a
808
+ conversation turn by turn, pass `expire_waits: false`, send a signal and drain again.
617
809
 
618
810
  ## Observability
619
811
 
@@ -679,6 +871,28 @@ Every feature works on every version. Things your app may need on older Rails, u
679
871
  | Temporal | the Temporal server | written by hand | a Temporal cluster |
680
872
  | **ActiveDurable** | **your database** | **yes, in reverse, with a point of no return** | **nothing extra** |
681
873
 
874
+ ## Performance
875
+
876
+ 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
877
+ [`benchmarks/`](benchmarks/README.md), so you can run them on yours.
878
+
879
+ | | PostgreSQL 16 | MySQL 9.6 | SQLite 3 |
880
+ | --- | --- | --- | --- |
881
+ | A step (`flow.step`), one process | 1.8 ms | 3.0 ms | 0.45 ms |
882
+ | A step (`flow.transaction`), one process | 2.0 ms | 2.8 ms | 0.45 ms |
883
+ | Resuming a saga with 1,000 finished steps | 17 ms | 21 ms | 9 ms |
884
+ | 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 |
885
+ | The same, killing a worker with SIGKILL every second | 9.2 s, 9 workers killed | 13.2 s, 13 killed | — |
886
+ | Duplicate charges | 0 | 0 | 0 |
887
+
888
+ A step costs about four queries: the lease check that fences the write, the notebook row and the commit that makes
889
+ it durable. In the load tests each call to the outside world takes 5 ms, so a saga spends most of its time waiting,
890
+ as in a real app. When a worker dies mid-step, another one runs that step again with the same ticket: on PostgreSQL 2
891
+ of the 2,000 charges were sent twice, and the idempotency key made them one.
892
+
893
+ The same test in a Rails 8 app with Solid Queue: 300 checkouts, every Solid Queue process killed with SIGKILL twice
894
+ while sagas were halfway through. All of them settled, the refunds and hooks ran once, and no order was charged twice.
895
+
682
896
  ## Guarantees and limits
683
897
 
684
898
  - A step runs **at least once**; with an idempotency key it has its effect once. `flow.transaction` runs exactly
@@ -687,7 +901,19 @@ Every feature works on every version. Things your app may need on older Rails, u
687
901
  - A hook (`flow.on`) runs once; exactly once if it only touches your database.
688
902
  - A bug never undoes a saga: an error in the code blocks it until you fix it and call `ActiveDurable.retry`.
689
903
  - Sagas do not isolate each other: two sagas can see each other's intermediate states.
904
+ - `Durable.start(id:)` is idempotent while the execution exists. Once it is pruned (`keep_finished_for`, 30 days),
905
+ the same id starts a new saga with the same tickets: keep finished executions longer than a webhook can be
906
+ delivered again.
907
+ - The lease is renewed on each notebook write. A step longer than `lease_duration` can run twice at the same time,
908
+ with the same ticket. Keep the workers' clocks in sync (NTP): the lease compares times written by different
909
+ machines.
910
+ - Error messages are stored in the notebook and reach the dashboard, the logs, the `blocked` event and
911
+ OpenTelemetry: keep secrets and personal data out of them.
912
+ - Ids cannot be blank or contain `/`. Ids and step names compare exactly, also on MySQL.
690
913
  - `flow.transaction` is atomic only when the notebook lives in the same database as your data.
914
+ - Step results, inputs and signals come back with their hash keys sorted, on the first run and on every replay, since
915
+ PostgreSQL and MySQL do not keep the order you wrote them in.
916
+ - A replay reads the whole notebook. For sagas that grow without end, such as a chat, use `flow.continue_as_new`.
691
917
 
692
918
  ## Development
693
919
 
@@ -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
@@ -8,14 +8,14 @@ module ActiveDurable
8
8
 
9
9
  STATUS_TONES = {
10
10
  "completed" => "good", "compensated" => "undo", "blocked" => "bad", "superseded" => "muted",
11
- "running" => "busy", "pending" => "busy", "sleeping" => "wait", "waiting" => "wait",
11
+ "continued" => "muted", "running" => "busy", "pending" => "busy", "sleeping" => "wait", "waiting" => "wait",
12
12
  "retrying" => "wait", "failed" => "bad", "undone" => "undo"
13
13
  }.freeze
14
14
 
15
15
  STATUS_WORDS = {
16
16
  "pending" => "Queued", "running" => "Running", "sleeping" => "Sleeping", "waiting" => "Waiting",
17
17
  "completed" => "Completed", "compensated" => "Undone", "blocked" => "Needs a person",
18
- "superseded" => "Superseded"
18
+ "superseded" => "Superseded", "continued" => "Continued"
19
19
  }.freeze
20
20
 
21
21
  def status_pill(status)
@@ -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,10 @@
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
+ <% end %>
20
+ <% if @execution.continued? %>
21
+ Continued as <%= execution_link(@execution.continued_as, class: "mono") %>.
19
22
  <% end %>
20
23
  </p>
21
24
  </div>
@@ -29,7 +32,7 @@
29
32
  </section>
30
33
  <% end %>
31
34
 
32
- <% if can_retry?(@execution) || can_compensate?(@execution, @forward_steps) || (can_rerun?(@execution) && @forward_steps.any?) %>
35
+ <% if can_retry?(@execution) || can_compensate?(@execution, @forward_steps) || (can_rerun?(@execution) && @rerun_steps.any?) %>
33
36
  <section class="actions" aria-label="Actions">
34
37
  <% if can_retry?(@execution) %>
35
38
  <%= button_to retry_execution_path(@execution), class: "primary", form_class: "inline" do %>Retry<% end %>
@@ -40,10 +43,10 @@
40
43
  Undo everything
41
44
  <% end %>
42
45
  <% end %>
43
- <% if can_rerun?(@execution) && @forward_steps.any? %>
46
+ <% if can_rerun?(@execution) && @rerun_steps.any? %>
44
47
  <%= form_with url: rerun_execution_path(@execution), method: :post, class: "inline", local: true do %>
45
48
  <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" %>
49
+ <%= select_tag :from, options_for_select(@rerun_steps.map(&:name), rerun_default(@rerun_steps)), id: "rerun-from" %>
47
50
  <button type="submit">Rerun</button>
48
51
  <% end %>
49
52
  <p class="note">A rerun is a new execution. Steps before the one you choose are reused; that step and the ones
@@ -65,7 +68,7 @@
65
68
  <% items.each_with_index do |item, index| %>
66
69
  <% if index.positive? %>
67
70
  <% previous = items[index - 1] %>
68
- <% if previous.kind == "pivot" && previous.state != "failed" %>
71
+ <% if previous.kind == "pivot" && !%w[failed blocked].include?(previous.state) %>
69
72
  <span class="gate" style="--i: <%= index %>"><span>point of no return</span></span>
70
73
  <% end %>
71
74
  <% wire = if item.state == "undone" || (backwards && previous.state == "undone") then "back"
@@ -199,7 +202,7 @@
199
202
  <section class="panel">
200
203
  <h2>Reruns</h2>
201
204
  <% @reruns.each do |rerun| %>
202
- <p><%= link_to rerun.id, execution_path(rerun), class: "mono" %> <%= status_pill(rerun.status) %></p>
205
+ <p><%= execution_link(rerun.id, class: "mono") %> <%= status_pill(rerun.status) %></p>
203
206
  <% end %>
204
207
  </section>
205
208
  <% 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; }