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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -1
- data/README.md +238 -12
- data/app/controllers/active_durable/executions_controller.rb +2 -1
- data/app/helpers/active_durable/dashboard_helper.rb +12 -4
- data/app/views/active_durable/executions/index.html.erb +1 -1
- data/app/views/active_durable/executions/show.html.erb +9 -6
- data/app/views/layouts/active_durable/application.html.erb +3 -3
- data/lib/active_durable/configuration.rb +30 -0
- data/lib/active_durable/errors.rb +53 -8
- data/lib/active_durable/execution.rb +22 -2
- data/lib/active_durable/flow.rb +147 -31
- data/lib/active_durable/flow_parallel.rb +38 -12
- data/lib/active_durable/notebook.rb +17 -4
- data/lib/active_durable/operations.rb +55 -17
- data/lib/active_durable/pruner.rb +19 -4
- data/lib/active_durable/record.rb +9 -1
- data/lib/active_durable/runner.rb +45 -10
- data/lib/active_durable/serializer.rb +42 -5
- data/lib/active_durable/signal_record.rb +18 -2
- data/lib/active_durable/step.rb +20 -0
- data/lib/active_durable/sweeper.rb +1 -1
- data/lib/active_durable/testing.rb +18 -13
- data/lib/active_durable/version.rb +1 -1
- data/lib/active_durable.rb +79 -11
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +15 -6
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_context.rb.tt +19 -0
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_continued_as.rb.tt +21 -0
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_signal_name.rb.tt +22 -0
- data/lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt +72 -0
- data/lib/generators/active_durable/upgrade/upgrade_generator.rb +4 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47ef8eb3bb5a133221ed52ce914ec952ff7f73f552b5352d4558b96e6738458c
|
|
4
|
+
data.tar.gz: af132b5a177125726a6aed8699e7cf4391c70e9e4fbac566531ab87c3204f998
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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,
|
|
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.**
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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
|
-
|
|
595
|
-
|
|
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.
|
|
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
|
|
85
|
+
%w[blocked pending sleeping waiting].include?(execution.status) && !execution.compensating &&
|
|
79
86
|
steps.none? { |step| step.kind == "pivot" && step.completed? }
|
|
80
87
|
end
|
|
81
88
|
|
|
@@ -109,7 +116,8 @@ module ActiveDurable
|
|
|
109
116
|
|
|
110
117
|
def state_word(state)
|
|
111
118
|
{
|
|
112
|
-
"completed" => "done", "failed" => "failed", "
|
|
119
|
+
"completed" => "done", "failed" => "failed", "blocked" => "blocked", "retrying" => "retrying",
|
|
120
|
+
"waiting" => "waiting for a signal",
|
|
113
121
|
"sleeping" => "sleeping", "undone" => "undone", "next" => "up next", "running" => "running"
|
|
114
122
|
}.fetch(state.to_s, state.to_s)
|
|
115
123
|
end
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
<% items = track_items(execution, @steps_by_execution.fetch(execution.id, [])) %>
|
|
44
44
|
<tr data-id="<%= execution.id %>" data-status="<%= execution.status %>">
|
|
45
45
|
<td class="exec">
|
|
46
|
-
<%=
|
|
46
|
+
<%= execution_link(execution.id, class: "exec-link") %>
|
|
47
47
|
<span class="recipe"><%= execution.recipe %>, version <%= execution.recipe_version %></span>
|
|
48
48
|
</td>
|
|
49
49
|
<td>
|
|
@@ -15,7 +15,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 <%=
|
|
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) && @
|
|
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) && @
|
|
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(@
|
|
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
|
|
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><%=
|
|
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; }
|