active_durable 0.5.0 → 0.6.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +8 -0
  3. data/CHANGELOG.md +52 -2
  4. data/README.md +97 -17
  5. data/app/controllers/active_durable/executions_controller.rb +2 -1
  6. data/app/helpers/active_durable/dashboard_helper.rb +1 -1
  7. data/app/views/active_durable/executions/index.html.erb +3 -2
  8. data/app/views/active_durable/executions/show.html.erb +10 -0
  9. data/app/views/layouts/active_durable/application.html.erb +4 -2
  10. data/lib/active_durable/configuration.rb +4 -0
  11. data/lib/active_durable/engine.rb +2 -4
  12. data/lib/active_durable/errors.rb +30 -0
  13. data/lib/active_durable/execution.rb +9 -2
  14. data/lib/active_durable/flow.rb +126 -15
  15. data/lib/active_durable/flow_parallel.rb +17 -0
  16. data/lib/active_durable/lease.rb +2 -0
  17. data/lib/active_durable/notebook.rb +4 -2
  18. data/lib/active_durable/open_telemetry.rb +14 -2
  19. data/lib/active_durable/operations.rb +5 -2
  20. data/lib/active_durable/parallel.rb +15 -0
  21. data/lib/active_durable/prune_job.rb +16 -0
  22. data/lib/active_durable/pruner.rb +34 -0
  23. data/lib/active_durable/record.rb +2 -0
  24. data/lib/active_durable/registry.rb +4 -0
  25. data/lib/active_durable/retry_policy.rb +2 -0
  26. data/lib/active_durable/run_job.rb +3 -0
  27. data/lib/active_durable/runner.rb +34 -2
  28. data/lib/active_durable/serializer.rb +2 -0
  29. data/lib/active_durable/signal_record.rb +2 -0
  30. data/lib/active_durable/step.rb +10 -0
  31. data/lib/active_durable/sweep_job.rb +3 -0
  32. data/lib/active_durable/sweeper.rb +26 -6
  33. data/lib/active_durable/testing.rb +8 -0
  34. data/lib/active_durable/version.rb +2 -1
  35. data/lib/active_durable.rb +115 -11
  36. data/lib/generators/active_durable/install/install_generator.rb +2 -0
  37. data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +1 -0
  38. data/lib/generators/active_durable/upgrade/templates/add_active_durable_prune_index.rb.tt +14 -0
  39. data/lib/generators/active_durable/upgrade/upgrade_generator.rb +39 -0
  40. data/lib/tasks/active_durable.rake +13 -2
  41. metadata +30 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 593d546eb6e8e46a70c03be3945934ed0c8a189937a5a27256478783cd345df0
4
- data.tar.gz: 3226b77ada034c6bf49830dfdb30457904e14104b430b4cfb0a9def3d3de4124
3
+ metadata.gz: d6ce41040ab6f0cb0036bfe36b30b831a1f91a7da099b9f9e3914568716ef6e6
4
+ data.tar.gz: ac2223d523ec0edf3470b8b9b66b5a892d2521156ab0446e874feff3104c59ff
5
5
  SHA512:
6
- metadata.gz: f0f0adcff14966f0b8cf7710218a6a6df9b27152593ea4d0d29c90f1968967817b467c33b9803dac0b7aaec6ff105c291a50ecf1335f15882b1969c4d7f7d2bd
7
- data.tar.gz: 1ecba4e399e4f9a247ea9717e9727a50c86d7389aa41a4e53fe3aa9817a100e04a91ecfdc7a2dc51333b24ae9b94a666f1a3c2d5e1987132a48949970a210592
6
+ metadata.gz: c95a763c1808a55ceebf13c02aa1e53f29830601b2547f6175c6bb375f1b78b9a5c5cd02aa2da505afac8989259f2047f588448aef34ce3bdb98a61dc2037e85
7
+ data.tar.gz: f12d9dff80e6c7e77bdaa40cd3683356bfc443b04eb442d59a5ca2e8e3da5cd9ecf2f0f97df180014c41c723b49ffb6b80fcfc111833f02994856fef460f61c3
data/.yardopts ADDED
@@ -0,0 +1,8 @@
1
+ --markup markdown
2
+ --readme README.md
3
+ --title "ActiveDurable"
4
+ --no-private
5
+ --hide-api private
6
+ lib/**/*.rb
7
+ -
8
+ CHANGELOG.md
data/CHANGELOG.md CHANGED
@@ -2,13 +2,49 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
- [Semantic Versioning](https://semver.org/). Before 1.0 the database schema may change between minor versions:
6
- regenerate the migration when upgrading.
5
+ [Semantic Versioning](https://semver.org/). Schema changes ship as new migrations: after updating the gem, run
6
+ `bin/rails generate active_durable:upgrade` and `bin/rails db:migrate`.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-10-06
11
+
12
+ Usable in a real app: found by a first-time user's walkthrough, plus hooks, cleanup, upgrades and an API reference.
13
+
14
+ Upgrading from 0.5: `bin/rails generate active_durable:upgrade && bin/rails db:migrate`. Note the behavior change
15
+ below: a plain exception raised by a recipe now blocks the saga instead of undoing it; use `flow.abort!` for business
16
+ rejections.
17
+
18
+ ### Added
19
+
20
+ - `flow.on(:completed) { ... }` and `flow.on(:compensated) { ... }`: hooks to update your own records when a saga
21
+ ends. Declared before the first step, so a saga undone early still knows them. Each runs once, in a transaction
22
+ with the notebook entry that records it (exactly once for database changes, covered by the crash tester); a failing
23
+ hook blocks the execution and `ActiveDurable.retry` runs only the hook. The dashboard lists them in the notebook,
24
+ and OpenTelemetry traces them.
25
+ - Cleanup: `ActiveDurable.prune(older_than:)`, `ActiveDurable::PruneJob` and `bin/rails active_durable:prune` delete
26
+ finished executions (completed, compensated, superseded) older than `config.keep_finished_for` (30 days by
27
+ default), with their notebook and signals, in batches. Active and blocked executions are never deleted.
28
+ - An API reference: the public API has YARD docs (parameters, returns, errors, examples) and the internals are
29
+ marked `@api private`, so rubydoc.info shows only what an app calls. `.yardopts` configures it.
30
+ - `bin/rails generate active_durable:upgrade` adds the migrations a newer version needs, skipping the ones the app
31
+ already has. The first one is an index on `durable_executions (status, updated_at)` for the cleanup and the
32
+ sweeper; new installs get it from `active_durable:install`.
33
+
10
34
  ### Changed
11
35
 
36
+ - **A bug no longer undoes a saga.** Only a step that fails for good, or `flow.abort!`, undoes the finished steps.
37
+ Any other error raised by the recipe, and a `NameError` or `NoMethodError` inside a step (now
38
+ `ActiveDurable::CodeError`, not retried), blocks the execution instead: a typo in a deploy used to refund every
39
+ saga that woke up with it. Fix the code and call `ActiveDurable.retry`. A business rejection raised as a plain
40
+ exception in the recipe body now blocks too: use `flow.abort!` (or raise `ActiveDurable::Abort`).
41
+ - The repository moved to [webresstudio/active_durable](https://github.com/webresstudio/active_durable). Links to the
42
+ old address redirect.
43
+ - A website, in English and Spanish, with an interactive simulator of a checkout: pick what goes wrong (a crash, a
44
+ refused parcel, a declined card…) and watch the steps, the notebook and the outside world. Source in `site/`, built
45
+ by `bin/site` and published to GitHub Pages by `.github/workflows/pages.yml`. The gem's homepage and both READMEs
46
+ link to it. Its simulator also shows a bug in a deploy (the saga is blocked, nothing is undone, a retry carries it
47
+ on) and the order's own status, set by `flow.on(:compensated)`.
12
48
  - README: the quick start recipe reads top to bottom, with one-line undos and the Stripe calls in a `Payments`
13
49
  module where charging and refunding sit side by side. A new section, "In a Rails app", sets up a Rails app step by
14
50
  step: install, job backend, sweeper, the initializer with every setting, routes, where each piece of code goes
@@ -16,6 +52,20 @@ regenerate the migration when upgrading.
16
52
  before going to production. The settings moved there from "Observability".
17
53
  - Specs cover undos without arguments (`-> { ... }`), a `Method` as an undo, and `flow.abort!` inside a step, which
18
54
  skips the step's remaining retries.
55
+ - gemspec: Active Job, Active Record and Active Support are required `>= 6.1, < 9`, the versions CI tests, instead
56
+ of any version from 6.1 on. The duplicate homepage link is gone, so `gem build` no longer warns.
57
+
58
+ ### Fixed
59
+
60
+ - The rake tasks were loaded twice (Rails already loads an engine's `lib/tasks`), so each one ran twice.
61
+ - `bin/rails active_durable:sweep` with the `:async` adapter (the Rails default in development) enqueued jobs that died
62
+ with the rake process. It now runs the due executions itself.
63
+ - Dashboard: the execution id no longer widens its column (long ids wrap), and long problems take two lines, with the
64
+ full message on hover.
65
+ - README: `Payments` turns Stripe's errors into its own, so the recipe does not depend on Stripe; development with
66
+ `:async` is explained (sagas started from the console are lost until the sweeper runs); a Minitest example. The
67
+ order page shows the order's own status, written by the saga (`mark_shipped`, `flow.on(:compensated)`), instead of
68
+ the saga's status, which said "Processing…" for days after the parcel left.
19
69
 
20
70
  ## [0.5.0] - 2026-10-06
21
71
 
data/README.md CHANGED
@@ -5,22 +5,25 @@
5
5
  </p>
6
6
 
7
7
  <p align="center">
8
- <a href="https://github.com/williamromero/active_durable/actions/workflows/main.yml"><img src="https://github.com/williamromero/active_durable/actions/workflows/main.yml/badge.svg" alt="CI"></a>
8
+ <a href="https://github.com/webresstudio/active_durable/actions/workflows/main.yml"><img src="https://github.com/webresstudio/active_durable/actions/workflows/main.yml/badge.svg" alt="CI"></a>
9
9
  <img src="https://img.shields.io/badge/ruby-3.1%2B-CC342D?logo=ruby&logoColor=white" alt="Ruby 3.1 and newer">
10
10
  <img src="https://img.shields.io/badge/rails-6.1%2B-D30001?logo=rubyonrails&logoColor=white" alt="Rails 6.1 and newer">
11
11
  <img src="https://img.shields.io/badge/PostgreSQL%20%C2%B7%20MySQL%20%C2%B7%20SQLite-tested-3DD6A0" alt="PostgreSQL, MySQL and SQLite">
12
12
  <img src="https://img.shields.io/badge/no%20Redis-no%20extra%20servers-7EA6FF" alt="No Redis, no extra servers">
13
13
  <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-B08CFF" alt="MIT license"></a>
14
+ <a href="https://webresstudio.github.io/active_durable/"><img src="https://img.shields.io/badge/website-try%20the%20simulator-F2B641" alt="Website: try the interactive simulator"></a>
14
15
  </p>
15
16
 
16
17
  <p align="center">
18
+ <a href="https://webresstudio.github.io/active_durable/"><b>Website</b></a> ·
17
19
  <a href="#quick-start">Quick start</a> ·
18
20
  <a href="#in-a-rails-app">In a Rails app</a> ·
19
21
  <a href="#how-it-works">How it works</a> ·
20
22
  <a href="#the-building-blocks">Building blocks</a> ·
21
23
  <a href="#dashboard">Dashboard</a> ·
22
24
  <a href="#testing-the-crash-tester">Crash tester</a> ·
23
- <a href="#compatibility">Compatibility</a>
25
+ <a href="#compatibility">Compatibility</a> ·
26
+ <a href="https://rubydoc.info/gems/active_durable">API reference</a>
24
27
  </p>
25
28
 
26
29
  ---
@@ -61,6 +64,7 @@ Write the recipe once, in `app/sagas/`:
61
64
  # app/sagas/checkout_saga.rb
62
65
  CheckoutSaga = Durable.define(:checkout) do |flow, order_id:|
63
66
  order = Order.find(order_id)
67
+ flow.on(:compensated) { order.update!(status: "cancelled") } # once every finished step was undone
64
68
 
65
69
  # Touches only your database: committed together with its checkpoint, so it runs exactly once.
66
70
  flow.transaction :reserve_stock, undo: -> { order.release_stock! } do
@@ -71,7 +75,7 @@ CheckoutSaga = Durable.define(:checkout) do |flow, order_id:|
71
75
  # Talks to the outside world: the ticket is an idempotency key that never changes for this step.
72
76
  payment = flow.step :charge, undo: ->(charge, ticket) { Payments.refund(charge, ticket) } do |ticket|
73
77
  Payments.charge(order, ticket)
74
- rescue Stripe::CardError => e
78
+ rescue Payments::CardDeclined => e
75
79
  flow.abort!(e.message) # a declined card is not retried: the stock is released right away
76
80
  end
77
81
 
@@ -79,6 +83,7 @@ CheckoutSaga = Durable.define(:checkout) do |flow, order_id:|
79
83
  flow.pivot :dispatch do |ticket|
80
84
  { "tracking" => Carrier.ship(order.id, reference: ticket).tracking_number, "payment" => payment["id"] }
81
85
  end
86
+ flow.transaction(:mark_shipped) { order.update!(status: "shipped") } # your own status, for your pages
82
87
 
83
88
  flow.step(:confirmation_email) { OrderMailer.shipped(order.id).deliver_now && true }
84
89
  flow.sleep(:wait_for_delivery, 3.days) # no worker is held while it sleeps
@@ -91,12 +96,16 @@ The calls to Stripe live in a plain module, doing and undoing side by side. The
91
96
  ```ruby
92
97
  # app/services/payments.rb
93
98
  module Payments
99
+ class CardDeclined < StandardError; end
100
+
94
101
  def self.charge(order, ticket)
95
102
  intent = Stripe::PaymentIntent.create(
96
103
  { amount: order.total_cents, currency: "usd", customer: order.user.stripe_id, confirm: true },
97
104
  { idempotency_key: ticket }
98
105
  )
99
106
  { "id" => intent.id } # written in the notebook, so it must fit in JSON
107
+ rescue Stripe::CardError => e
108
+ raise CardDeclined, e.message # the recipe speaks your language, not Stripe's
100
109
  end
101
110
 
102
111
  def self.refund(charge, ticket)
@@ -131,6 +140,9 @@ Run the three commands from the [quick start](#quick-start). The migration creat
131
140
  exactly once only because the notebook and your data commit together. A queue in its own database, like Solid
132
141
  Queue's in Rails 8, is fine.
133
142
 
143
+ When you update the gem, run `bin/rails generate active_durable:upgrade` and then `bin/rails db:migrate`: it adds only
144
+ the migrations your app is missing, and running it twice changes nothing.
145
+
134
146
  ### 2. A job backend
135
147
 
136
148
  ActiveDurable runs on Active Job, so it uses the backend you already have. New Rails 8 apps come with Solid Queue;
@@ -145,8 +157,11 @@ config.solid_queue.connects_to = { database: { writing: :queue } }
145
157
 
146
158
  - **Workers must listen to the saga queue.** It is `:default` unless you change `config.queue_name`; if you do, add
147
159
  it to your workers (Solid Queue's `config/queue.yml`, Sidekiq's `-q`).
148
- - **In development** Rails' default `:async` adapter runs jobs inside the server, sleeps and retries included. With
149
- Solid Queue, run `bin/jobs` next to the server.
160
+ - **In development** Rails' default `:async` adapter keeps each job in the memory of the process that enqueued it.
161
+ The server runs its own, sleeps and retries included, but a saga started from the console, `bin/rails runner`, a
162
+ rake task or `db/seeds.rb` is lost when that process exits, and scheduled wake-ups are lost when the server
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.
150
165
 
151
166
  ### 3. The sweeper
152
167
 
@@ -159,11 +174,18 @@ production:
159
174
  active_durable_sweep:
160
175
  class: ActiveDurable::SweepJob
161
176
  schedule: every minute
177
+ active_durable_prune:
178
+ class: ActiveDurable::PruneJob
179
+ schedule: every day at 4am
162
180
  ```
163
181
 
164
182
  With another backend, schedule `ActiveDurable::SweepJob` in its own scheduler (GoodJob cron, sidekiq-cron), or run
165
183
  `bin/rails active_durable:sweep` from cron.
166
184
 
185
+ The second entry is the cleanup: finished executions (completed, undone or superseded) are kept for
186
+ `config.keep_finished_for`, 30 days by default, and then `ActiveDurable::PruneJob` deletes them with their notebook.
187
+ Active and blocked executions are never deleted. Without it, every saga stays in the database forever.
188
+
167
189
  ### 4. The initializer
168
190
 
169
191
  The settings, with their defaults:
@@ -179,6 +201,7 @@ ActiveDurable.configure do |config|
179
201
  config.backoff = ->(attempt) { [2**attempt, 3600].min } # or [5, 30, 300], or a number
180
202
  config.parallel_concurrency = 4 # threads per flow.parallel
181
203
  config.sweep_grace = 1.minute # the sweeper leaves executions this young alone
204
+ config.keep_finished_for = 30.days # then ActiveDurable::PruneJob deletes finished ones
182
205
 
183
206
  # Who may open the dashboard outside development and test. With Devise (HTTP basic auth: see Dashboard):
184
207
  config.dashboard_authorize = ->(controller) { controller.request.env["warden"]&.user&.admin? }
@@ -237,7 +260,7 @@ class OrdersController < ApplicationController
237
260
  end
238
261
  end
239
262
 
240
- # app/models/order.rb
263
+ # app/models/order.rb: orders has a status column ("placed" by default) that the saga updates
241
264
  class Order < ApplicationRecord
242
265
  def checkout
243
266
  Durable.find("checkout-#{id}")
@@ -245,15 +268,16 @@ class Order < ApplicationRecord
245
268
  end
246
269
  ```
247
270
 
248
- The `id:` ties the saga to its order, so the order page can show where the saga is:
271
+ The `id:` ties the saga to its order: `order.checkout` is for your team and the dashboard. The page shows the order's
272
+ own status, which the saga writes as it goes (`mark_shipped`, `flow.on(:compensated)`), not the saga's: a shipped
273
+ order still has a saga sleeping three days before it asks for a review.
249
274
 
250
275
  ```erb
251
276
  <%# app/views/orders/show.html.erb %>
252
- <% case @order.checkout.status %>
253
- <% when "completed" %> Your order is confirmed.
254
- <% when "compensated" %> We could not complete it and refunded you.
255
- <% when "blocked" %> We are looking into it.
256
- <% else %> Processing…
277
+ <% case @order.status %>
278
+ <% when "shipped" %> Your order is on its way.
279
+ <% when "cancelled" %> We could not complete it and refunded you.
280
+ <% else %> Processing…
257
281
  <% end %>
258
282
  ```
259
283
 
@@ -275,6 +299,7 @@ the notebook, not to the job: if your backend retries the job too, the copy find
275
299
  | stop retrying a business failure | `flow.abort!`, like the declined card above |
276
300
  | see what is running | the [dashboard](#dashboard), or `ActiveDurable::RunJob` in your backend's UI |
277
301
  | hear about a stuck saga | the `blocked.active_durable` [event](#observability) |
302
+ | delete finished sagas | schedule `ActiveDurable::PruneJob` once a day (step 3) |
278
303
  | run a saga inline in tests | `ActiveDurable::Testing.drain(id)` |
279
304
  | start or wake sagas from your own jobs | call `Durable.start` or `Durable.signal` there |
280
305
 
@@ -301,6 +326,26 @@ it "charges once and confirms the order" do
301
326
  end
302
327
  ```
303
328
 
329
+ With Minitest, the Rails default:
330
+
331
+ ```ruby
332
+ # test/test_helper.rb
333
+ require "active_durable/testing"
334
+
335
+ class ActiveSupport::TestCase
336
+ setup { ActiveDurable::Testing.reset! }
337
+ end
338
+
339
+ # test/services/place_order_test.rb
340
+ class PlaceOrderTest < ActiveSupport::TestCase
341
+ test "charges once and confirms the order" do
342
+ order = PlaceOrder.call(email: "ana@example.com", total_cents: 4200)
343
+
344
+ assert_equal "completed", ActiveDurable::Testing.drain(order.checkout.id).status
345
+ end
346
+ end
347
+ ```
348
+
304
349
  `drain` runs the saga right there, without a worker. Stub Stripe as you already do, then let the
305
350
  [crash tester](#testing-the-crash-tester) kill the saga at every point.
306
351
 
@@ -308,6 +353,7 @@ end
308
353
 
309
354
  - [ ] Workers are running and listen to `config.queue_name`.
310
355
  - [ ] The sweeper runs every minute.
356
+ - [ ] The cleanup runs every day, or you keep every execution on purpose.
311
357
  - [ ] `dashboard_authorize` is set; without it the dashboard answers 403.
312
358
  - [ ] `lease_duration` is longer than your slowest step.
313
359
  - [ ] Every step that calls an outside service passes the ticket as its idempotency key.
@@ -334,8 +380,8 @@ stateDiagram-v2
334
380
  sleeping --> running: wake-up time
335
381
  waiting --> running: Durable.signal
336
382
  running --> completed: every step done
337
- running --> compensated: failure before the pivot, undos ran
338
- running --> blocked: needs a person
383
+ running --> compensated: a step failed for good, or flow.abort!, before the pivot
384
+ running --> blocked: a bug, a failure after the pivot or a failing hook
339
385
  blocked --> pending: ActiveDurable.retry
340
386
  ```
341
387
 
@@ -357,6 +403,7 @@ Three rules follow from replaying the recipe:
357
403
  | `flow.parallel(name) { \|branches\| ... }` | several steps at the same time | each branch like a step |
358
404
  | `flow.sleep(name, 3.days)` | waiting without holding a worker | — |
359
405
  | `flow.wait_for(name, timeout:)` | waiting for `Durable.signal` | a timeout fails the saga |
406
+ | `flow.on(:completed) { ... }` | updating your own records when the saga ends (also `:compensated`) | blocks; a retry runs the hook again |
360
407
 
361
408
  Steps take `undo:`, `retry:` (`3`, `false` or `{ attempts:, backoff: }`) and `undo_on_failure:`.
362
409
 
@@ -377,11 +424,15 @@ idempotency key, Stripe answers with the first result instead of charging twice.
377
424
 
378
425
  <br>
379
426
 
380
- When a step runs out of attempts, raises `ActiveDurable::Abort`, or the recipe raises, every finished step is undone,
381
- last one first. Each undo is written in the notebook too, so a crash in the middle of undoing resumes where it
427
+ When a step runs out of attempts or calls `flow.abort!`, every finished step is undone, last one first. Each undo is written in the notebook too, so a crash in the middle of undoing resumes where it
382
428
  stopped. An undo receives `(result, undo_ticket, step_ticket)` and takes as many as it declares: `-> { ... }` takes
383
429
  none. Any object that responds to `call` works too, such as `Payments.method(:refund)`.
384
430
 
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)`.
435
+
385
436
  A step that failed is not undone, because it did not happen. The exception is a step whose failure may hide a
386
437
  success, like a charge whose answer timed out: declare `undo_on_failure: true` and its undo runs with `nil` as the
387
438
  result, so it can look the outcome up with the step's ticket.
@@ -409,6 +460,32 @@ everything. After it, steps cannot declare `undo:` and are retried with backoff
409
460
 
410
461
  </details>
411
462
 
463
+ <details>
464
+ <summary><b>When a saga ends: hooks</b></summary>
465
+
466
+ <br>
467
+
468
+ `flow.on(:completed)` and `flow.on(:compensated)` run once the saga ends that way, to update your own records:
469
+
470
+ ```ruby
471
+ CheckoutSaga = Durable.define(:checkout) do |flow, order_id:|
472
+ order = Order.find(order_id)
473
+ flow.on(:completed) { order.update!(status: "delivered") }
474
+ flow.on(:compensated) { order.update!(status: "cancelled") }
475
+
476
+ flow.transaction(:reserve_stock, undo: -> { order.release_stock! }) { ... }
477
+ end
478
+ ```
479
+
480
+ Declare them before the first step: a saga undone at its first step never reaches the lines after it. `completed`
481
+ runs after the last step and `compensated` after the last undo, each in a transaction together with the notebook
482
+ entry that records it, so a hook that only touches your database runs exactly once, even if the process dies. If a
483
+ hook raises, the execution is blocked, and `ActiveDurable.retry` runs the hook again, not the steps.
484
+
485
+ For progress before the end (paid, shipped), write a step: `flow.transaction(:mark_shipped) { ... }`.
486
+
487
+ </details>
488
+
412
489
  <details>
413
490
  <summary><b>Sleeping and waiting for signals</b></summary>
414
491
 
@@ -511,6 +588,7 @@ end
511
588
  ActiveDurable.retry("checkout-7") # blocked: try again where it stopped
512
589
  ActiveDurable.compensate("checkout-7", reason: "customer cancelled") # undo everything (only before the pivot)
513
590
  ActiveDurable.rerun("checkout-7", from: :ship) # a new execution reusing steps before :ship
591
+ ActiveDurable.prune(older_than: 30.days) # delete finished executions and their notebook
514
592
  ```
515
593
 
516
594
  All three refuse an execution a worker is running right now. A rerun runs the chosen step and the following ones
@@ -554,7 +632,7 @@ end
554
632
 
555
633
  | Event | Payload |
556
634
  | --- | --- |
557
- | `execution` / `step` / `compensation` / `undo` | `execution_id` (and `recipe`, `step`, `kind`) |
635
+ | `execution` / `step` / `compensation` / `undo` / `hook` | `execution_id` (and `recipe`, `step`, `kind`) |
558
636
  | `completed` / `compensated` | `execution_id`, `recipe` |
559
637
  | `blocked` | `execution_id`, `recipe`, `error` |
560
638
  | `retried` / `compensation_requested` / `rerun` | operator actions |
@@ -606,6 +684,8 @@ Every feature works on every version. Things your app may need on older Rails, u
606
684
  - A step runs **at least once**; with an idempotency key it has its effect once. `flow.transaction` runs exactly
607
685
  once, because its change and its checkpoint commit together.
608
686
  - One worker at a time per execution: taking an execution and every write are fenced by a lease token.
687
+ - A hook (`flow.on`) runs once; exactly once if it only touches your database.
688
+ - A bug never undoes a saga: an error in the code blocks it until you fix it and call `ActiveDurable.retry`.
609
689
  - Sagas do not isolate each other: two sagas can see each other's intermediate states.
610
690
  - `flow.transaction` is atomic only when the notebook lives in the same database as your data.
611
691
 
@@ -23,8 +23,9 @@ module ActiveDurable
23
23
  @execution = Execution.find(params[:id])
24
24
  steps = @execution.steps.to_a
25
25
  @steps = steps
26
- @forward_steps = steps.reject(&:undo?).sort_by { |step| step.position.to_i }
26
+ @forward_steps = steps.select(&:forward?).sort_by { |step| step.position.to_i }
27
27
  @undo_steps = steps.select(&:undo?)
28
+ @hook_steps = steps.select(&:hook?)
28
29
  @signals = @execution.signals.to_a
29
30
  @reruns = Execution.where(forked_from: @execution.id).order(:created_at).to_a
30
31
  end
@@ -87,7 +87,7 @@ module ActiveDurable
87
87
  # their parallel step, undone steps are marked, and an active execution gets a "next" ghost block.
88
88
  def track_items(execution, steps)
89
89
  undone = steps.select { |step| step.undo? && step.completed? }.to_set { |step| step.name.delete_suffix(":undo") }
90
- branches, main = steps.reject(&:undo?).partition { |step| step.position.nil? }
90
+ branches, main = steps.select(&:forward?).partition { |step| step.position.nil? }
91
91
 
92
92
  items = main.sort_by(&:position).map do |step|
93
93
  build_item(step, undone, branches.select { |branch| branch.name.start_with?("#{step.name}/") })
@@ -42,7 +42,7 @@
42
42
  <% @executions.each do |execution| %>
43
43
  <% items = track_items(execution, @steps_by_execution.fetch(execution.id, [])) %>
44
44
  <tr data-id="<%= execution.id %>" data-status="<%= execution.status %>">
45
- <td>
45
+ <td class="exec">
46
46
  <%= link_to execution.id, execution_path(execution), class: "exec-link" %>
47
47
  <span class="recipe"><%= execution.recipe %>, version <%= execution.recipe_version %></span>
48
48
  </td>
@@ -69,7 +69,8 @@
69
69
  <% if compensating_now?(execution) %><%= status_pill("compensating") %><% end %>
70
70
  </td>
71
71
  <td class="when"><%= execution.wake_at ? when_text(execution.wake_at) : tag.span("—", class: "muted") %></td>
72
- <td class="problem"><%= execution.error&.dig("message").to_s.truncate(110) %></td>
72
+ <% problem = execution.error&.dig("message").to_s %>
73
+ <td class="problem"><span title="<%= problem %>"><%= problem.truncate(110) %></span></td>
73
74
  <td class="when"><%= when_text(execution.updated_at) %></td>
74
75
  </tr>
75
76
  <% end %>
@@ -158,6 +158,16 @@
158
158
  <td class="error"><%= step.error&.dig("message").to_s.truncate(120) %></td>
159
159
  </tr>
160
160
  <% end %>
161
+ <% @hook_steps.each do |step| %>
162
+ <tr>
163
+ <td></td>
164
+ <td><strong>on :<%= step.name.delete_prefix("~") %></strong><span class="k">hook</span></td>
165
+ <td><%= status_pill(step.status) %></td>
166
+ <td><%= step.attempts %></td>
167
+ <td></td>
168
+ <td></td>
169
+ </tr>
170
+ <% end %>
161
171
  </tbody>
162
172
  </table>
163
173
  </div>
@@ -113,10 +113,12 @@
113
113
  tbody tr { transition: background-color .2s; }
114
114
  tbody tr:hover { background: var(--sunken); }
115
115
  tr.changed { animation: rowflash 1.8s ease; }
116
- .exec-link { font: 700 .95rem/1.2 var(--f-code); white-space: nowrap; text-decoration: none; color: var(--ink); }
116
+ td.exec { min-width: 20ch; max-width: 30ch; }
117
+ .exec-link { font: 700 .95rem/1.2 var(--f-code); overflow-wrap: anywhere; text-decoration: none; color: var(--ink); }
117
118
  .exec-link:hover { text-decoration: underline; }
118
119
  .recipe { display: block; color: var(--soft); font-size: .82rem; margin-top: 2px; }
119
- .problem { color: var(--ruby); max-width: 34ch; }
120
+ .problem { color: var(--ruby); min-width: 24ch; max-width: 40ch; }
121
+ .problem span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
120
122
  .when { white-space: nowrap; font-variant-numeric: tabular-nums; }
121
123
  .status { display: inline-flex; align-items: center; gap: 7px; padding: 4px 10px 4px 8px; border-radius: 999px;
122
124
  background: var(--cb); color: var(--c); font-weight: 700; font-size: .82rem; white-space: nowrap; }
@@ -25,6 +25,9 @@ module ActiveDurable
25
25
  # The sweeper ignores executions touched more recently than this, to leave room for their own job.
26
26
  attr_accessor :sweep_grace
27
27
 
28
+ # How long ActiveDurable.prune, PruneJob and rake active_durable:prune keep finished executions.
29
+ attr_accessor :keep_finished_for
30
+
28
31
  # Maximum threads used by flow.parallel. Your connection pool needs at least this many + 1 connections.
29
32
  attr_accessor :parallel_concurrency
30
33
 
@@ -46,6 +49,7 @@ module ActiveDurable
46
49
  @undo_attempts = 10
47
50
  @backoff = ->(attempt) { [2**attempt, 3600].min }
48
51
  @sweep_grace = 60
52
+ @keep_finished_for = 30 * 24 * 3600
49
53
  @parallel_concurrency = 4
50
54
  @clock = -> { Time.current }
51
55
  @dashboard_authorize = nil
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveDurable
4
- # The dashboard and the rake tasks. Mount it in config/routes.rb:
4
+ # The dashboard. Mount it in config/routes.rb:
5
5
  #
6
6
  # mount ActiveDurable::Engine => "/durable"
7
7
  class Engine < ::Rails::Engine
@@ -17,8 +17,6 @@ module ActiveDurable
17
17
  end
18
18
  end
19
19
 
20
- rake_tasks do
21
- load File.expand_path("../tasks/active_durable.rake", __dir__)
22
- end
20
+ # The rake tasks in lib/tasks are loaded by Rails::Engine itself: loading them here too would run them twice.
23
21
  end
24
22
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  # Error classes. Everything a recipe may want to rescue inherits from ActiveDurable::Error.
4
4
  module ActiveDurable
5
+ # The base class of every error ActiveDurable raises.
5
6
  class Error < StandardError; end
6
7
 
7
8
  # Raised when a recipe name (or version) has not been defined.
@@ -39,14 +40,43 @@ module ActiveDurable
39
40
  # An undo kept failing after all its attempts. The execution is blocked for a human to review.
40
41
  class UndoFailed < Error; end
41
42
 
43
+ # A flow.on(:completed) or flow.on(:compensated) hook raised. The execution is blocked; ActiveDurable.retry runs
44
+ # the hook again once it is fixed.
45
+ class HookFailed < Error
46
+ attr_reader :step_name
47
+
48
+ def initialize(event, error)
49
+ @step_name = "~#{event}"
50
+ super("flow.on(:#{event}) failed: #{error.class}: #{error.message}")
51
+ end
52
+ end
53
+
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.
57
+ class CodeError < Error
58
+ attr_reader :step_name
59
+
60
+ def initialize(step_name, error)
61
+ @step_name = step_name
62
+ super("step :#{step_name} cannot run: #{error.class}: #{error.message}")
63
+ end
64
+ end
65
+
42
66
  # Control flow signals. They inherit from Exception on purpose: a `rescue => e` inside
43
67
  # user code must not swallow them, otherwise a lost lease could keep writing.
68
+ #
69
+ # @api private
44
70
  class ControlFlow < Exception; end # rubocop:disable Lint/InheritException
45
71
 
46
72
  # Another worker took over this execution (our lease expired). Stop without writing anything else.
73
+ #
74
+ # @api private
47
75
  class LeaseLost < ControlFlow; end
48
76
 
49
77
  # While compensating, replay reached a step that never completed: stop moving forward.
78
+ #
79
+ # @api private
50
80
  class StopForward < ControlFlow; end
51
81
 
52
82
  # Serializes an exception for the notebook and the dashboard.
@@ -5,8 +5,13 @@ module ActiveDurable
5
5
  class Execution < Record
6
6
  self.table_name = "durable_executions"
7
7
 
8
+ # Statuses of an execution that will move on by itself: waiting for a worker, running, sleeping until a
9
+ # wake-up time (a sleep or a retry) or waiting for a signal.
8
10
  ACTIVE = %w[pending running sleeping waiting].freeze
11
+ # Statuses where nothing happens without a person: done, undone, blocked (needs {ActiveDurable.retry},
12
+ # {ActiveDurable.compensate} or a fix), or replaced by a rerun.
9
13
  TERMINAL = %w[completed compensated blocked superseded].freeze
14
+ # Every status.
10
15
  STATUSES = (ACTIVE + TERMINAL).freeze
11
16
 
12
17
  attribute :input, JSON_TYPE, default: -> { {} }
@@ -27,17 +32,19 @@ module ActiveDurable
27
32
  # the sweeper finds the pending row and enqueues it.
28
33
  after_create_commit { ActiveDurable.enqueue(id) }
29
34
 
35
+ # @return [Boolean] whether it will move on by itself
30
36
  def active?
31
37
  ACTIVE.include?(status)
32
38
  end
33
39
 
40
+ # @return [Boolean] whether it needs a person to move again, or finished
34
41
  def terminal?
35
42
  TERMINAL.include?(status)
36
43
  end
37
44
 
38
- # The forward steps in recipe order (undo entries excluded).
45
+ # The forward steps in recipe order (undo and hook entries excluded).
39
46
  def notebook
40
- steps.where.not(kind: "undo").reorder(:position).to_a
47
+ steps.where.not(kind: %w[undo hook]).reorder(:position).to_a
41
48
  end
42
49
  end
43
50
  end