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.
- checksums.yaml +4 -4
- data/.yardopts +8 -0
- data/CHANGELOG.md +52 -2
- data/README.md +97 -17
- data/app/controllers/active_durable/executions_controller.rb +2 -1
- data/app/helpers/active_durable/dashboard_helper.rb +1 -1
- data/app/views/active_durable/executions/index.html.erb +3 -2
- data/app/views/active_durable/executions/show.html.erb +10 -0
- data/app/views/layouts/active_durable/application.html.erb +4 -2
- data/lib/active_durable/configuration.rb +4 -0
- data/lib/active_durable/engine.rb +2 -4
- data/lib/active_durable/errors.rb +30 -0
- data/lib/active_durable/execution.rb +9 -2
- data/lib/active_durable/flow.rb +126 -15
- data/lib/active_durable/flow_parallel.rb +17 -0
- data/lib/active_durable/lease.rb +2 -0
- data/lib/active_durable/notebook.rb +4 -2
- data/lib/active_durable/open_telemetry.rb +14 -2
- data/lib/active_durable/operations.rb +5 -2
- data/lib/active_durable/parallel.rb +15 -0
- data/lib/active_durable/prune_job.rb +16 -0
- data/lib/active_durable/pruner.rb +34 -0
- data/lib/active_durable/record.rb +2 -0
- data/lib/active_durable/registry.rb +4 -0
- data/lib/active_durable/retry_policy.rb +2 -0
- data/lib/active_durable/run_job.rb +3 -0
- data/lib/active_durable/runner.rb +34 -2
- data/lib/active_durable/serializer.rb +2 -0
- data/lib/active_durable/signal_record.rb +2 -0
- data/lib/active_durable/step.rb +10 -0
- data/lib/active_durable/sweep_job.rb +3 -0
- data/lib/active_durable/sweeper.rb +26 -6
- data/lib/active_durable/testing.rb +8 -0
- data/lib/active_durable/version.rb +2 -1
- data/lib/active_durable.rb +115 -11
- data/lib/generators/active_durable/install/install_generator.rb +2 -0
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +1 -0
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_prune_index.rb.tt +14 -0
- data/lib/generators/active_durable/upgrade/upgrade_generator.rb +39 -0
- data/lib/tasks/active_durable.rake +13 -2
- metadata +30 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6ce41040ab6f0cb0036bfe36b30b831a1f91a7da099b9f9e3914568716ef6e6
|
|
4
|
+
data.tar.gz: ac2223d523ec0edf3470b8b9b66b5a892d2521156ab0446e874feff3104c59ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c95a763c1808a55ceebf13c02aa1e53f29830601b2547f6175c6bb375f1b78b9a5c5cd02aa2da505afac8989259f2047f588448aef34ce3bdb98a61dc2037e85
|
|
7
|
+
data.tar.gz: f12d9dff80e6c7e77bdaa40cd3683356bfc443b04eb442d59a5ca2e8e3da5cd9ecf2f0f97df180014c41c723b49ffb6b80fcfc111833f02994856fef460f61c3
|
data/.yardopts
ADDED
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/).
|
|
6
|
-
|
|
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/
|
|
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
|
|
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
|
|
149
|
-
|
|
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
|
|
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.
|
|
253
|
-
<% when "
|
|
254
|
-
<% when "
|
|
255
|
-
<%
|
|
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:
|
|
338
|
-
running --> blocked:
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
47
|
+
steps.where.not(kind: %w[undo hook]).reorder(:position).to_a
|
|
41
48
|
end
|
|
42
49
|
end
|
|
43
50
|
end
|