saga_forge 0.1.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 +7 -0
- data/CHANGELOG.md +87 -0
- data/LICENSE +21 -0
- data/README.md +635 -0
- data/lib/generators/saga_forge/install/USAGE +28 -0
- data/lib/generators/saga_forge/install/install_generator.rb +60 -0
- data/lib/generators/saga_forge/migration_actions.rb +68 -0
- data/lib/generators/saga_forge/templates/initializer.rb +23 -0
- data/lib/generators/saga_forge/templates/install_saga_forge.rb +84 -0
- data/lib/generators/saga_forge/upgrade/USAGE +15 -0
- data/lib/generators/saga_forge/upgrade/upgrade_generator.rb +30 -0
- data/lib/saga_forge/application_record.rb +15 -0
- data/lib/saga_forge/base.rb +61 -0
- data/lib/saga_forge/compensation_job.rb +24 -0
- data/lib/saga_forge/compensation_runner.rb +142 -0
- data/lib/saga_forge/composite_retry_policy.rb +48 -0
- data/lib/saga_forge/configuration.rb +32 -0
- data/lib/saga_forge/dashboard/graph.rb +20 -0
- data/lib/saga_forge/definition.rb +268 -0
- data/lib/saga_forge/event.rb +16 -0
- data/lib/saga_forge/execution/compensation_facade.rb +21 -0
- data/lib/saga_forge/execution/facade.rb +45 -0
- data/lib/saga_forge/execution/post_commit.rb +53 -0
- data/lib/saga_forge/execution/runner.rb +240 -0
- data/lib/saga_forge/execution_job.rb +41 -0
- data/lib/saga_forge/publisher.rb +43 -0
- data/lib/saga_forge/railtie.rb +17 -0
- data/lib/saga_forge/retention_job.rb +28 -0
- data/lib/saga_forge/retry_policy.rb +107 -0
- data/lib/saga_forge/router.rb +70 -0
- data/lib/saga_forge/state.rb +113 -0
- data/lib/saga_forge/sweeper_job.rb +80 -0
- data/lib/saga_forge/timeout_job.rb +96 -0
- data/lib/saga_forge/version.rb +3 -0
- data/lib/saga_forge.rb +81 -0
- metadata +140 -0
data/README.md
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
# SagaForge
|
|
2
|
+
|
|
3
|
+
[](https://github.com/radioactive-labs/saga_forge/actions/workflows/main.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
**Sagas for Rails on ActiveJob**: one Ruby file per workflow, a durable event
|
|
7
|
+
ledger, out-of-order events that heal themselves, and automatic rollback of
|
|
8
|
+
every committed step when something downstream fails.
|
|
9
|
+
|
|
10
|
+
The moment a workflow spans more than one system ("charge the card, reserve
|
|
11
|
+
the stock, dispatch the shipment"), the failure cases start arriving one at a
|
|
12
|
+
time: the settlement webhook that lands before your own transaction commits,
|
|
13
|
+
the same webhook delivered twice, the payment that fails after inventory was
|
|
14
|
+
already reserved, the poison event that should stop the world for one order
|
|
15
|
+
but not for the other ten thousand. SagaForge is a saga engine that handles
|
|
16
|
+
all of it. You write a single class that reads top to bottom; SagaForge
|
|
17
|
+
persists every incoming event before touching it, parks the ones that arrive
|
|
18
|
+
early, commits each step atomically, and when a step declares failure, unwinds
|
|
19
|
+
the steps that already committed, in reverse order.
|
|
20
|
+
|
|
21
|
+
Two tables, a handful of jobs, plain Ruby classes, and no separate server to
|
|
22
|
+
run. A management dashboard, in the style of ChronoForge's, is planned as a
|
|
23
|
+
companion gem. Works with any ActiveJob backend on Rails 7.1+.
|
|
24
|
+
|
|
25
|
+
### 30-second tour
|
|
26
|
+
|
|
27
|
+
A saga is one file. States exist only where the next fact comes from outside
|
|
28
|
+
(a webhook, a human, another saga); everything else chains inline:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# app/sagas/order_fulfillment_saga.rb
|
|
32
|
+
class OrderFulfillmentSaga < SagaForge::Base
|
|
33
|
+
correlate_by :order_id
|
|
34
|
+
retry_policy max_attempts: 5, base: 2, cap: 60 # class-wide default (optional)
|
|
35
|
+
|
|
36
|
+
# Kick off payment. The gateway acknowledges immediately with a PENDING
|
|
37
|
+
# intent; no money has moved yet. Settlement is decided out of band and
|
|
38
|
+
# arrives later as a webhook: that is the async boundary the next state
|
|
39
|
+
# parks on.
|
|
40
|
+
start_with :order_placed, compensate: :cancel_payment do |saga, payload|
|
|
41
|
+
saga.context[:items] = payload[:items]
|
|
42
|
+
intent = PaymentGateway.create_intent(payload[:total], idempotency_key: saga.correlation_id)
|
|
43
|
+
saga.context[:intent_id] = intent.id
|
|
44
|
+
end # falls through to :awaiting_settlement
|
|
45
|
+
|
|
46
|
+
# The webhook controller announces the outcome:
|
|
47
|
+
# SagaForge.publish :payment_settled, order_id: order.id
|
|
48
|
+
during :awaiting_settlement, on: :payment_settled, compensate: :release_inventory do |saga, _payload|
|
|
49
|
+
Warehouse.reserve(saga.context[:items], key: saga.correlation_id)
|
|
50
|
+
Shipping.dispatch(saga.correlation_id)
|
|
51
|
+
saga.publish :order_fulfilled, order_id: saga.correlation_id # delivered on commit
|
|
52
|
+
end # falls through to :completed
|
|
53
|
+
|
|
54
|
+
during :awaiting_settlement, on: :payment_failed do |saga, payload|
|
|
55
|
+
saga.fail! reason: payload[:decline_code] # unwinds committed steps, in reverse
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
finish_with :completed
|
|
59
|
+
|
|
60
|
+
compensation :cancel_payment do |saga|
|
|
61
|
+
next unless saga.context[:intent_id] # self-guard: context records what happened
|
|
62
|
+
PaymentGateway.cancel_or_refund(saga.context[:intent_id], # cancel if pending, refund if settled
|
|
63
|
+
idempotency_key: "undo:#{saga.correlation_id}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
compensation :release_inventory do |saga|
|
|
67
|
+
Warehouse.release(saga.context[:items], key: saga.correlation_id)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The rest of the world talks to it by publishing facts:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# From a webhook controller, a job, a model callback, anywhere outside a saga:
|
|
76
|
+
SagaForge.publish :payment_settled, order_id: 42
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
SagaForge handles the rest: persisting the event before any job touches it,
|
|
80
|
+
parking it if it arrived early, retrying the handler on transient errors,
|
|
81
|
+
halting the one instance that hit a poison pill, and keeping a queryable
|
|
82
|
+
ledger of everything that happened. See
|
|
83
|
+
[Delivery guarantees](#delivery-guarantees) for exactly what is promised.
|
|
84
|
+
|
|
85
|
+
## Installation
|
|
86
|
+
|
|
87
|
+
Add to your Gemfile:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
gem "saga_forge"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Then:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
bundle install
|
|
97
|
+
bin/rails g saga_forge:install
|
|
98
|
+
bin/rails db:migrate
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`saga_forge:install` writes the initializer and installs SagaForge's migration
|
|
102
|
+
in one step. For a separate database, pass `--database=NAME` (see
|
|
103
|
+
[Multiple databases](#multiple-databases)). After a gem update, run
|
|
104
|
+
`bin/rails g saga_forge:upgrade` to copy any migrations your app is missing;
|
|
105
|
+
it skips what you already have, so it is safe to run on every upgrade.
|
|
106
|
+
|
|
107
|
+
Saga classes live in `app/sagas/`. SagaForge eager-loads that directory on
|
|
108
|
+
each boot and code reload, so every saga is registered with the event router
|
|
109
|
+
even in development, where Rails would otherwise load classes lazily.
|
|
110
|
+
|
|
111
|
+
## Defining a saga
|
|
112
|
+
|
|
113
|
+
Six macros make up the whole grammar:
|
|
114
|
+
|
|
115
|
+
| Macro | Meaning |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `correlate_by :key` or `correlate_by { \|payload, event\| ... }` | How this saga recognizes itself in a payload. The symbol form reads `payload[:key]`; the block form gets the event name too, for per-event vocabularies or composite keys. Required, one per class. |
|
|
118
|
+
| `start_with :event do ...` | The kickoff. Creates the saga instance when a new correlation id shows up. Exactly one per class. |
|
|
119
|
+
| `during :state, on: :event do ...` | Handles one event in one state. One event maps to exactly one state per saga; registering it twice is a boot error. |
|
|
120
|
+
| `finish_with :state` | Declares a terminal state. At least one required; multiple allowed. |
|
|
121
|
+
| `compensation :name do \|saga\| ...` | A rollback step, referenced from handlers via `compensate: :name`. |
|
|
122
|
+
| `retry_policy ...` | Class-wide default retry policy (see [Retries](#retries)). |
|
|
123
|
+
|
|
124
|
+
Handlers take options: `compensate:` (the rollback owed once this step
|
|
125
|
+
commits), `timeout:` and `on_timeout:` (see [Timeouts](#timeouts)), and
|
|
126
|
+
`retry_policy:` (a per-handler override).
|
|
127
|
+
|
|
128
|
+
### The chain
|
|
129
|
+
|
|
130
|
+
The default flow is built from file order: `start_with`, then each distinct
|
|
131
|
+
`during` state in the order it first appears, then the first `finish_with`. A
|
|
132
|
+
block that completes without an explicit verb advances to its state's
|
|
133
|
+
successor. Reordering `during` blocks rewires the workflow; that is
|
|
134
|
+
intentional (inserting a state splices it into the chain), but it means block
|
|
135
|
+
order is semantics, not style.
|
|
136
|
+
|
|
137
|
+
Definitions are validated at boot: a missing `correlate_by`, an event
|
|
138
|
+
registered under two states, a `compensate:` naming an undeclared
|
|
139
|
+
compensation, a `timeout:` without `on_timeout:` (or the reverse), or a
|
|
140
|
+
missing `finish_with` all raise when the class loads, not at 3am when the
|
|
141
|
+
timer fires.
|
|
142
|
+
|
|
143
|
+
### Verbs
|
|
144
|
+
|
|
145
|
+
Inside a handler block, `saga` responds to:
|
|
146
|
+
|
|
147
|
+
| Verb | Meaning |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| (fall through) | Advance to the state's declared successor. The common case. |
|
|
150
|
+
| `saga.transition_to :state` | Jump: branch, skip, or rejoin the mainline. An undeclared target raises. |
|
|
151
|
+
| `saga.fail!(reason: nil)` | Halt forward flow and run the compensations of every committed step, last first. |
|
|
152
|
+
| `saga.context` | A JSON-backed hash, staged in memory and persisted at commit. Also the sole input to compensations: write what rollback will need. |
|
|
153
|
+
| `saga.publish :event, **payload` | Emit a fact for other sagas, delivered only if this block commits. |
|
|
154
|
+
| `saga.correlation_id` / `saga.current_state` | Read-only identity and position at block entry. |
|
|
155
|
+
|
|
156
|
+
The last verb called wins; only `fail!` stops the block mid-flight.
|
|
157
|
+
|
|
158
|
+
Sagas are strictly forward-only: every processed event advances, branches
|
|
159
|
+
(`transition_to` to a state the saga hasn't visited yet), or fails — never
|
|
160
|
+
loops or revisits a state. Trying to re-enter a state the saga has already
|
|
161
|
+
resided in raises `SagaForge::ForwardOnlyError`; that marks the event
|
|
162
|
+
`failed`, the same as any other handler bug, for an operator to fix and
|
|
163
|
+
`resume!`.
|
|
164
|
+
|
|
165
|
+
### Branching
|
|
166
|
+
|
|
167
|
+
There is no condition DSL. Data-driven branches are plain Ruby (`if` plus
|
|
168
|
+
`transition_to`, fall through on the other path); event-driven branches are
|
|
169
|
+
multiple `during` blocks on the same state, one per event the world might
|
|
170
|
+
send, as `payment_settled` / `payment_failed` above. A detour rejoins the
|
|
171
|
+
mainline by transitioning back into it.
|
|
172
|
+
|
|
173
|
+
Two facts that arrive in unpredictable order are modeled as two sequential
|
|
174
|
+
states. The early arrival parks and is re-delivered automatically when the
|
|
175
|
+
saga reaches its state (see [Ordering](#ordering-early-events-park-and-self-heal)),
|
|
176
|
+
so arrival order is free while processing order stays deterministic.
|
|
177
|
+
|
|
178
|
+
## Publishing events
|
|
179
|
+
|
|
180
|
+
There are two ways an event reaches a saga, and they are not interchangeable.
|
|
181
|
+
|
|
182
|
+
**`SagaForge.publish(event_name, **payload)`** is the external entry point:
|
|
183
|
+
call it from controllers, webhook handlers, jobs, anywhere outside saga
|
|
184
|
+
execution. The event is persisted first (the row inserts join whatever
|
|
185
|
+
transaction is open at the call site) and one `ExecutionJob` per recipient is
|
|
186
|
+
enqueued right after. Idempotency is structural, not something you supply:
|
|
187
|
+
because a forward-only saga handles each event name at most once, the unique
|
|
188
|
+
index on `(saga_class, correlation_id, event_name)` makes a duplicate
|
|
189
|
+
delivery a no-op automatically, whether the duplicate is a webhook
|
|
190
|
+
redelivery or a saga-to-saga fan-in from another saga's `saga.publish`. There
|
|
191
|
+
is no idempotency key to pass in and no payload-determinism discipline to
|
|
192
|
+
maintain across retries. Calling `SagaForge.publish` from inside a saga block
|
|
193
|
+
raises `SagaForge::UnstagedPublishError`, because that is almost always the
|
|
194
|
+
ghost-event bug described next.
|
|
195
|
+
|
|
196
|
+
**`saga.publish(event_name, **payload)`** is the verb inside saga blocks,
|
|
197
|
+
compensations included. Recipients are resolved immediately (so a missing
|
|
198
|
+
correlation key fails loudly at the call site, under the block's retry
|
|
199
|
+
policy), but the rows are only staged in memory. The step's own commit inserts
|
|
200
|
+
them, atomically with the state transition that produced them. A block that
|
|
201
|
+
raises, or calls `fail!`, discards everything it staged; nothing it published
|
|
202
|
+
ever surfaces. Without this, a failed step's events would cascade into
|
|
203
|
+
downstream sagas that have no idea the step was rolled back.
|
|
204
|
+
|
|
205
|
+
Events are broadcast facts, not addressed messages. One publish fans out to
|
|
206
|
+
every saga class that registers the event, and each recipient extracts its own
|
|
207
|
+
correlation id via its `correlate_by`:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
SagaForge.publish :payment_settled, order_id: 42, shipment_ref: "SHP-9"
|
|
211
|
+
|
|
212
|
+
# OrderFulfillmentSaga: correlate_by :order_id -> instance 42
|
|
213
|
+
# ShipmentSaga: correlate_by { |p, _| p[:shipment_ref] } -> instance SHP-9
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Registering an event is the claim "this event is for me", so a `correlate_by`
|
|
217
|
+
that returns nil for a registered event is treated as a contract bug: the
|
|
218
|
+
whole publish fails with `MissingCorrelationError` naming the class, and zero
|
|
219
|
+
rows are inserted. There are no silent skips and no partial deliveries. Need
|
|
220
|
+
narrower delivery? Name the event more specifically.
|
|
221
|
+
|
|
222
|
+
## Ordering: early events park and self-heal
|
|
223
|
+
|
|
224
|
+
Every incoming event is registered (through the saga's definition) for exactly
|
|
225
|
+
one state. When an event's job runs, SagaForge compares that registered state
|
|
226
|
+
with the saga's current state:
|
|
227
|
+
|
|
228
|
+
1. Match: the handler runs.
|
|
229
|
+
2. Early (the saga has not reached that state yet, or does not exist yet):
|
|
230
|
+
the job re-enqueues itself with a short wait (`config.stall_wait`, default
|
|
231
|
+
3 seconds) and tries again. Being early is not an error; these spins never
|
|
232
|
+
touch any retry budget.
|
|
233
|
+
3. Still early after `config.stall_budget` spins (default 3): the event
|
|
234
|
+
parks as `stalled` and stops consuming queue cycles. The saga itself is
|
|
235
|
+
untouched.
|
|
236
|
+
4. Whenever a commit advances the saga's state, parked events registered for
|
|
237
|
+
the new state are re-delivered automatically, in the order they were
|
|
238
|
+
recorded.
|
|
239
|
+
|
|
240
|
+
So a `review_passed` webhook that arrives before `payment_settled` waits its
|
|
241
|
+
turn, and the saga processes both in the declared order no matter what order
|
|
242
|
+
the network delivered them in.
|
|
243
|
+
|
|
244
|
+
### The stall budget
|
|
245
|
+
|
|
246
|
+
The two knobs multiply into how long an early event keeps spinning before it
|
|
247
|
+
parks: `stall_budget` spins of `stall_wait` each, so the default 3 by 3
|
|
248
|
+
seconds is roughly nine seconds of cheap queue-spinning. It is a floor, not an
|
|
249
|
+
exact clock, since each spin is a re-enqueue and adds a little queue latency.
|
|
250
|
+
Those spins cost nothing but queue cycles and never consume a retry attempt.
|
|
251
|
+
|
|
252
|
+
Size the budget to how far out of order you actually expect events to arrive.
|
|
253
|
+
The default absorbs a predecessor that lags by up to about nine seconds, which
|
|
254
|
+
covers a webhook provider having a slow moment. If a predecessor event
|
|
255
|
+
routinely lags further (a settlement that can take ten minutes to confirm, an
|
|
256
|
+
upstream saga that runs long), raise `config.stall_budget` so the follower
|
|
257
|
+
waits it out instead of parking and waiting for the sweeper. Lower
|
|
258
|
+
`config.stall_wait` to spin more tightly when you want early events picked up
|
|
259
|
+
faster and don't mind the extra enqueues.
|
|
260
|
+
|
|
261
|
+
Parking is not a dead end. It just stops the busy-wait: a parked event costs
|
|
262
|
+
nothing until it is re-delivered. Re-delivery happens the moment a commit
|
|
263
|
+
lands the saga in the event's state (the commit's own after-effects flip every
|
|
264
|
+
matching `stalled` row back to `pending` and re-enqueue it, resetting the
|
|
265
|
+
budget), and `SagaForge::SweeperJob` re-checks parked events on its own cadence
|
|
266
|
+
as a backstop, so an event whose saga advanced during a crash is still
|
|
267
|
+
recovered. You can also force it by hand with `record.retry_stalled!`.
|
|
268
|
+
|
|
269
|
+
Events for a saga already in a terminal state are marked processed with a
|
|
270
|
+
discard note (logged, harmless). Truly orphaned events (a correlation id no
|
|
271
|
+
saga ever starts) park and show up in the `stalled` scope for an operator to
|
|
272
|
+
inspect.
|
|
273
|
+
|
|
274
|
+
### Poison pills halt one instance, not the system
|
|
275
|
+
|
|
276
|
+
A handler that exhausts its retries marks its event row `failed` with the full
|
|
277
|
+
traceback. While an instance has a failed event, SagaForge refuses to process
|
|
278
|
+
further events for that instance; they accumulate as `pending` and nothing is
|
|
279
|
+
lost. Other instances are unaffected. `YourSaga.suspended` lists the stuck
|
|
280
|
+
instances; fix the code, deploy, and call `resume!` (see
|
|
281
|
+
[Operator API](#operator-api)).
|
|
282
|
+
|
|
283
|
+
## The execution model
|
|
284
|
+
|
|
285
|
+
Each event is processed as one atomic unit:
|
|
286
|
+
|
|
287
|
+
1. The handler block runs in memory, with no lock and no open transaction.
|
|
288
|
+
Side effects (API calls, mail, other services) happen here.
|
|
289
|
+
2. One transaction then commits everything: the saga row is locked, its
|
|
290
|
+
version is checked (a concurrent commit loses cleanly and retries), the
|
|
291
|
+
new state and context are written (along with `last_active_at`, and
|
|
292
|
+
`finalized_at` if the new state is terminal), the event flips to
|
|
293
|
+
`processed` (with `last_processed_at`), and any staged publishes insert.
|
|
294
|
+
3. After the commit: staged events enqueue their jobs, parked events for the
|
|
295
|
+
new state re-deliver, and timeout timers arm.
|
|
296
|
+
|
|
297
|
+
A crash or a retryable error before the commit leaves no trace; the block
|
|
298
|
+
re-runs whole, from the top. That is the entire model, and it puts one
|
|
299
|
+
requirement on your code: **blocks must be idempotent at the external
|
|
300
|
+
boundary**. Give every external call an idempotency key. The convention is
|
|
301
|
+
`saga.correlation_id`, plus a qualifier when one saga makes several calls of
|
|
302
|
+
the same kind (`"undo:#{saga.correlation_id}"`). On a re-run, completed calls
|
|
303
|
+
no-op at the boundary that actually matters (the payment gateway, the
|
|
304
|
+
warehouse) and return their original results.
|
|
305
|
+
|
|
306
|
+
If whole-block re-runs get expensive (several slow external calls where a
|
|
307
|
+
late failure re-executes the earlier ones), split the state: that is what
|
|
308
|
+
states are for. A single step that needs internal, resumable sub-steps is a
|
|
309
|
+
[ChronoForge](https://github.com/radioactive-labs/chrono_forge) workflow the
|
|
310
|
+
saga kicks off and awaits (see
|
|
311
|
+
[SagaForge or ChronoForge?](#sagaforge-or-chronoforge)).
|
|
312
|
+
|
|
313
|
+
## Retries
|
|
314
|
+
|
|
315
|
+
A retry policy is a value object: `max_attempts` (nil for unbounded), `base`
|
|
316
|
+
and `cap` for exponential backoff (`min(cap, base * 2^(attempts-1))`, with
|
|
317
|
+
equal jitter), and `retry_on` (nil retries any `StandardError`, `[]` retries
|
|
318
|
+
nothing, a list matches those classes and their subclasses).
|
|
319
|
+
|
|
320
|
+
Resolution order: per-handler `retry_policy:` override, then the class-wide
|
|
321
|
+
`retry_policy` macro, then the site default (3 attempts, 30 second cap).
|
|
322
|
+
Compensation blocks use a far more tolerant default (10 attempts, 10 minute
|
|
323
|
+
cap), because giving up halfway through a rollback is worse than retrying for
|
|
324
|
+
a while.
|
|
325
|
+
|
|
326
|
+
Pass an array for a composite policy: the first entry matching the raised
|
|
327
|
+
error applies, each with its own independent attempt budget, so a rate limit
|
|
328
|
+
can retry ten times while a decline fails fast:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
during :awaiting_settlement, on: :payment_settled, retry_policy: [
|
|
332
|
+
SagaForge::RetryPolicy.new(retry_on: [Net::ReadTimeout], max_attempts: 5),
|
|
333
|
+
SagaForge::RetryPolicy.new(retry_on: [Carrier::RateLimit], max_attempts: 10, base: 5),
|
|
334
|
+
SagaForge::RetryPolicy.new(retry_on: [Payment::Declined], max_attempts: 1)
|
|
335
|
+
] do |saga, payload|
|
|
336
|
+
# ...
|
|
337
|
+
end
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Attempt counts and per-error budgets are tracked on the event row itself, so
|
|
341
|
+
they survive worker restarts and are visible when you inspect a stuck event.
|
|
342
|
+
Exhaustion, or an error no policy matches, marks the event `failed`; nothing
|
|
343
|
+
ever escapes to ActiveJob's dead-letter handling.
|
|
344
|
+
|
|
345
|
+
## Compensation
|
|
346
|
+
|
|
347
|
+
`saga.fail!(reason:)` (or an operator's `compensate!`) triggers rollback.
|
|
348
|
+
There is no separately maintained rollback log to drift out of sync; what is
|
|
349
|
+
owed is derived from the ledger. SagaForge reads this instance's `processed`
|
|
350
|
+
events in order, maps each through its handler's `compensate:` declaration,
|
|
351
|
+
removes duplicates (distinct events whose handlers share one `compensate:`
|
|
352
|
+
declaration owe a single compensation run, not one per event), and runs the
|
|
353
|
+
result last-in-first-out. Each
|
|
354
|
+
compensation commits individually, so a crash mid-rollback resumes exactly
|
|
355
|
+
where it left off. When the list drains, the saga lands in `:compensated`
|
|
356
|
+
(or `:cancelled`, for an operator's `cancel!`).
|
|
357
|
+
|
|
358
|
+
Compensations take no arguments because context is the snapshot: everything a
|
|
359
|
+
rollback needs was written by its forward block and committed atomically with
|
|
360
|
+
it. That leads to two conventions worth following:
|
|
361
|
+
|
|
362
|
+
- Self-guard for conditional work: `next unless saga.context[:intent_id]`.
|
|
363
|
+
The guard reads what actually happened, so a compensation for work that
|
|
364
|
+
never ran is a clean no-op. (Use `next`, not `return`, in these blocks.)
|
|
365
|
+
- Later steps should append rather than overwrite context keys that earlier
|
|
366
|
+
steps' compensations read (arrays or maps for repeated values).
|
|
367
|
+
|
|
368
|
+
A step whose block failed never committed, so it owes no compensation and
|
|
369
|
+
left nothing in context for one to read. If its side effects did land
|
|
370
|
+
externally, the rule is **resume, then compensate**: fix the code, `resume!`
|
|
371
|
+
(the idempotent re-run commits the facts), then compensate if you still want
|
|
372
|
+
to. `compensate!` on an instance with failed events warns to this effect.
|
|
373
|
+
|
|
374
|
+
## Timeouts
|
|
375
|
+
|
|
376
|
+
A handler can bound how long its saga waits in a state:
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
during :awaiting_settlement, on: :payment_settled,
|
|
380
|
+
timeout: 30.minutes, on_timeout: :fail! do |saga, payload|
|
|
381
|
+
# ...
|
|
382
|
+
end
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`on_timeout:` takes `:fail!` (compensate and land in `:compensated`, with
|
|
386
|
+
`"timeout"` recorded as the reason) or a state name (timeout as a branch: move
|
|
387
|
+
on and handle it there). Under the hood each commit arms a fresh timer
|
|
388
|
+
stamped with the saga's version; a stale timer that fires after the saga has
|
|
389
|
+
moved on notices the version changed and discards itself.
|
|
390
|
+
|
|
391
|
+
One operational consequence: expired timers showing up in your queue's
|
|
392
|
+
history are expected litter, not a leak. Volume is bounded by how often the
|
|
393
|
+
state handles events times the timeout duration, not by saga count.
|
|
394
|
+
|
|
395
|
+
## Operations
|
|
396
|
+
|
|
397
|
+
### Scheduling the sweeper and retention
|
|
398
|
+
|
|
399
|
+
Enqueued jobs are hints; the rows are the obligations. `SagaForge::SweeperJob`
|
|
400
|
+
is the delivery guarantee: it re-enqueues aged pending events (a worker
|
|
401
|
+
crashed between commit and enqueue), re-drives sagas stranded mid-compensation,
|
|
402
|
+
and re-delivers parked events whose saga is already sitting at their state.
|
|
403
|
+
`SagaForge::RetentionJob` prunes processed events, but only for sagas that
|
|
404
|
+
have reached a terminal state, because active sagas derive their rollback
|
|
405
|
+
plan from that history. Schedule both. With Solid Queue:
|
|
406
|
+
|
|
407
|
+
```yaml
|
|
408
|
+
# config/recurring.yml
|
|
409
|
+
saga_forge_sweeper:
|
|
410
|
+
class: SagaForge::SweeperJob
|
|
411
|
+
schedule: every 30 seconds
|
|
412
|
+
saga_forge_retention:
|
|
413
|
+
class: SagaForge::RetentionJob
|
|
414
|
+
schedule: every day at 4am
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Any scheduler that can enqueue a job on a cadence works (`sidekiq-cron`,
|
|
418
|
+
`whenever`, plain cron). Keep the sweeper's schedule at or below
|
|
419
|
+
`config.sweep_interval` so nothing sits stranded longer than intended.
|
|
420
|
+
|
|
421
|
+
### Operator API
|
|
422
|
+
|
|
423
|
+
```ruby
|
|
424
|
+
OrderFulfillmentSaga.find_by_correlation(42)
|
|
425
|
+
OrderFulfillmentSaga.in_state(:awaiting_settlement)
|
|
426
|
+
OrderFulfillmentSaga.stalled # instances with at least one parked event
|
|
427
|
+
OrderFulfillmentSaga.suspended # instances with at least one failed event
|
|
428
|
+
OrderFulfillmentSaga.to_mermaid # the compiled chain as a Mermaid diagram
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
| Call | Purpose |
|
|
432
|
+
| --- | --- |
|
|
433
|
+
| `record.history` | This instance's ledger rows, chronological. |
|
|
434
|
+
| `record.events.stalled` / `record.events.failed` | The parked / failed rows themselves. |
|
|
435
|
+
| `record.retry_stalled!` | Re-deliver parked events now, without waiting for a state change. |
|
|
436
|
+
| `record.resume!` | Reset failed events (attempts, budgets, error) and re-fire them, after a code fix. |
|
|
437
|
+
| `record.compensate!` | Run the rollback from wherever the saga currently sits. Returns `false` if there was nothing to do. |
|
|
438
|
+
| `record.cancel!(reason:)` | Compensate, then land in `:cancelled` instead of `:compensated`. |
|
|
439
|
+
|
|
440
|
+
`retry_stalled!` and `resume!` refuse (return `false`, log a warning) on a
|
|
441
|
+
terminal or already-compensating instance, since there is no live state for a
|
|
442
|
+
re-delivery to land against. All recovery writes are status-scoped, so racing
|
|
443
|
+
a live worker cannot regress an event that just processed.
|
|
444
|
+
|
|
445
|
+
### Queue adapters
|
|
446
|
+
|
|
447
|
+
SagaForge has no runtime dependency on Solid Queue; any ActiveJob adapter
|
|
448
|
+
works. When `SolidQueue` is loaded, the execution, compensation, and timeout
|
|
449
|
+
jobs opt into `limits_concurrency` keyed per saga instance, and the sweeper
|
|
450
|
+
and retention jobs cap themselves to one in-flight run each. That is a
|
|
451
|
+
throughput optimization, not a correctness requirement: every commit locks the
|
|
452
|
+
saga row and checks its version regardless of adapter, so concurrent or
|
|
453
|
+
out-of-order delivery is safe everywhere.
|
|
454
|
+
|
|
455
|
+
## Delivery guarantees
|
|
456
|
+
|
|
457
|
+
What SagaForge actually promises, so you know what to build on:
|
|
458
|
+
|
|
459
|
+
- **Events are durable from the moment of publish.** `SagaForge.publish`
|
|
460
|
+
persists the ledger row before any job is enqueued; the sweeper re-delivers
|
|
461
|
+
if the enqueue itself is lost. Nothing is silently dropped, and every event
|
|
462
|
+
row remains queryable afterward as an audit trail.
|
|
463
|
+
- **Handler execution is at-least-once.** A block can run more than once for
|
|
464
|
+
the same event (a crash after your API call but before the commit). Your
|
|
465
|
+
external calls carry idempotency keys; SagaForge's own bookkeeping needs
|
|
466
|
+
nothing from you.
|
|
467
|
+
- **Saga-to-saga events are exactly-once.** Staged publishes insert
|
|
468
|
+
atomically with the step's commit; the `(saga_class, correlation_id,
|
|
469
|
+
event_name)` unique index makes a re-delivery after commit a no-op. A
|
|
470
|
+
re-run before commit re-stages in memory only. Failed blocks leak nothing.
|
|
471
|
+
- **Processing order is deterministic per instance.** Arrival order is free;
|
|
472
|
+
the state chain plus parking means handlers always run in declared order.
|
|
473
|
+
Across different instances and different sagas there is no ordering.
|
|
474
|
+
- **The saga row never lies.** `current_state` is always the true position.
|
|
475
|
+
Stalls and failures are recorded on the event that stalled or failed, never
|
|
476
|
+
by mutating the saga, so "stalled" and "suspended" are derived views, not
|
|
477
|
+
states you can get stuck in by accident. Three timestamps are persisted
|
|
478
|
+
alongside these facts, written atomically in the same commit as their
|
|
479
|
+
cause, so they can't drift: `saga_forge_states.last_active_at` (updated on
|
|
480
|
+
every advancing or compensation commit), `saga_forge_states.finalized_at`
|
|
481
|
+
(set once, when the saga reaches a terminal state), and
|
|
482
|
+
`saga_forge_events.last_processed_at` (set when an event becomes
|
|
483
|
+
`processed`).
|
|
484
|
+
|
|
485
|
+
## Configuration
|
|
486
|
+
|
|
487
|
+
`bin/rails g saga_forge:install` generates
|
|
488
|
+
`config/initializers/saga_forge.rb`, which documents every option inline:
|
|
489
|
+
|
|
490
|
+
| Option | Default | What it controls |
|
|
491
|
+
| --- | --- | --- |
|
|
492
|
+
| `stall_wait` | `3.seconds` | How long an early event waits between queue spins. |
|
|
493
|
+
| `stall_budget` | `3` | Spins before an early event parks as `stalled`. |
|
|
494
|
+
| `sweep_interval` | `30.seconds` | Age at which the sweeper considers something stranded. |
|
|
495
|
+
| `retention` | `90.days` | Age past which processed events of terminal sagas are pruned. |
|
|
496
|
+
| `job_queue` | `:default` | ActiveJob queue for hot-path jobs (execution, compensation, timeout). At scale, point it at a dedicated queue (e.g. `:sagas`) with its own worker — but then you must run a worker for that queue, or sagas silently never process. |
|
|
497
|
+
| `maintenance_queue` | `job_queue` | Queue for housekeeping jobs (sweeper, retention). Defaults to `job_queue`; set it to isolate recovery/pruning on a separate, typically lower-priority, queue. |
|
|
498
|
+
| `database` | `nil` | Named database for SagaForge's tables (see below). |
|
|
499
|
+
| `connects_to` | `nil` | Raw `connects_to` hash for custom roles/shards; wins over `database`. |
|
|
500
|
+
| `primary_key_type` | `nil` | Primary key type for SagaForge's tables (see below). |
|
|
501
|
+
|
|
502
|
+
### Primary keys
|
|
503
|
+
|
|
504
|
+
SagaForge's two tables (`saga_forge_states`, `saga_forge_events`) follow
|
|
505
|
+
`config.primary_key_type`: leave it `nil` to inherit your app's own default
|
|
506
|
+
(its `config.generators` setting, or bigint), or set it explicitly (e.g.
|
|
507
|
+
`:uuid`) to force a type. Correlation ids are stored as strings, so your
|
|
508
|
+
domain keys can be integers, UUIDs, or anything else without configuration.
|
|
509
|
+
|
|
510
|
+
### Multiple databases
|
|
511
|
+
|
|
512
|
+
To keep SagaForge's tables out of your primary database, install with a
|
|
513
|
+
`--database` flag:
|
|
514
|
+
|
|
515
|
+
```bash
|
|
516
|
+
bin/rails g saga_forge:install --database=saga_forge
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
That writes `config.database = :saga_forge` to the initializer and installs
|
|
520
|
+
the migration into `db/saga_forge_migrate` (not the primary `db/migrate`), so
|
|
521
|
+
each database keeps its own `schema_migrations`. Both models inherit from one
|
|
522
|
+
abstract `SagaForge::ApplicationRecord`, so `config.database` points the
|
|
523
|
+
whole engine at that connection. Add the database to `database.yml`:
|
|
524
|
+
|
|
525
|
+
```yaml
|
|
526
|
+
# config/database.yml
|
|
527
|
+
production:
|
|
528
|
+
primary:
|
|
529
|
+
<<: *default
|
|
530
|
+
database: my_app_production
|
|
531
|
+
saga_forge:
|
|
532
|
+
<<: *default
|
|
533
|
+
database: my_app_saga_forge
|
|
534
|
+
migrations_paths: db/saga_forge_migrate
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
Then run `bin/rails db:migrate:saga_forge`. After a gem upgrade,
|
|
538
|
+
`bin/rails g saga_forge:upgrade` (no flag needed) reads `config.database`
|
|
539
|
+
back out of the initializer and installs any missing migrations into the
|
|
540
|
+
right directory. Re-running `install` with a different `--database` than last
|
|
541
|
+
time prompts before overwriting the initializer; pass `--force` in scripts.
|
|
542
|
+
|
|
543
|
+
For custom roles or shards, set `config.connects_to` to a hash passed
|
|
544
|
+
straight to Rails'
|
|
545
|
+
[`connects_to`](https://guides.rubyonrails.org/active_record_multiple_databases.html);
|
|
546
|
+
it takes precedence over `config.database`. Left unset (the default),
|
|
547
|
+
SagaForge stays on the app's primary connection. This is independent of where
|
|
548
|
+
your ActiveJob backend stores its own tables.
|
|
549
|
+
|
|
550
|
+
## Why not just chain jobs?
|
|
551
|
+
|
|
552
|
+
A workflow as "job A enqueues job B enqueues job C" works right up until it
|
|
553
|
+
meets production traffic. Then each gap becomes a small project:
|
|
554
|
+
|
|
555
|
+
- **Out-of-order webhooks.** The settlement webhook races your own commit and
|
|
556
|
+
loses; your handler finds no order and gives up, or worse, half-processes.
|
|
557
|
+
SagaForge persists the event and parks it until the saga is ready.
|
|
558
|
+
- **Duplicate delivery.** Webhook providers redeliver; queues are
|
|
559
|
+
at-least-once. SagaForge's dedup is structural: a forward-only saga handles
|
|
560
|
+
each event name once, so the `(saga_class, correlation_id, event_name)`
|
|
561
|
+
unique index makes a redelivered webhook a no-op with no idempotency key
|
|
562
|
+
from the caller.
|
|
563
|
+
- **Partial failure.** Payment failed after inventory was reserved. Which
|
|
564
|
+
cleanup jobs do you enqueue, in what order, from what data? SagaForge
|
|
565
|
+
derives the rollback from what actually committed and runs it in reverse,
|
|
566
|
+
with the context each step saved for exactly this purpose.
|
|
567
|
+
- **Poison events.** One malformed order throws in every retry, and either
|
|
568
|
+
clogs your queue's retry machinery or gets dropped by its dead-letter
|
|
569
|
+
policy. SagaForge fails the event, halts just that instance, and gives you
|
|
570
|
+
`suspended` plus `resume!`.
|
|
571
|
+
- **Ghost events.** A step publishes "order fulfilled", then fails and rolls
|
|
572
|
+
back, but the downstream listener already fired. Staged publishes make that
|
|
573
|
+
impossible: events surface only if the step that produced them committed.
|
|
574
|
+
- **"What state is order 42 in?"** With chained jobs the answer is
|
|
575
|
+
archaeology across queue dashboards and log lines. Here it is a column:
|
|
576
|
+
`OrderFulfillmentSaga.find_by_correlation(42).current_state`, with the full
|
|
577
|
+
event history one call away.
|
|
578
|
+
|
|
579
|
+
None of these is hard on its own. Building and maintaining all of them
|
|
580
|
+
together, for every workflow in the app, is the work SagaForge takes off your
|
|
581
|
+
plate.
|
|
582
|
+
|
|
583
|
+
## SagaForge or ChronoForge?
|
|
584
|
+
|
|
585
|
+
[ChronoForge](https://github.com/radioactive-labs/chrono_forge) is the sibling
|
|
586
|
+
gem, and they divide the space deliberately:
|
|
587
|
+
|
|
588
|
+
| | SagaForge | ChronoForge |
|
|
589
|
+
| --- | --- | --- |
|
|
590
|
+
| Shape | Event-driven state machine | Imperative durable workflow |
|
|
591
|
+
| Driven by | Facts arriving from outside (webhooks, humans, other sagas) | Its own `perform` method running to completion |
|
|
592
|
+
| Waiting | A state parks until the event arrives | `wait_until` / `wait` polls or sleeps |
|
|
593
|
+
| Retry granularity | Split states | Durable steps (`durably_execute`) inside one method |
|
|
594
|
+
| Failure handling | Compensation: unwind committed steps in reverse | Retry/replay until the workflow completes |
|
|
595
|
+
| Best at | Long-lived, multi-party processes where the next step is someone else's move | Multi-step operations your app owns end to end |
|
|
596
|
+
|
|
597
|
+
Rule of thumb: if the workflow mostly waits for the outside world and needs
|
|
598
|
+
an unwind story, it is a saga. If it is a sequence of steps your own code
|
|
599
|
+
drives and should survive crashes and retries, it is a ChronoForge
|
|
600
|
+
workflow. They compose: a saga step that needs many resumable sub-steps kicks
|
|
601
|
+
off a ChronoForge workflow, and the workflow's completion publishes the event
|
|
602
|
+
the saga is parked on.
|
|
603
|
+
|
|
604
|
+
If you need cross-language workflow orchestration with its own control plane,
|
|
605
|
+
that is [Temporal](https://temporal.io)'s territory; SagaForge deliberately
|
|
606
|
+
stays a two-table Rails gem on your existing job backend.
|
|
607
|
+
|
|
608
|
+
## Development
|
|
609
|
+
|
|
610
|
+
After cloning, install dependencies and generate the per-Rails-version
|
|
611
|
+
gemfiles:
|
|
612
|
+
|
|
613
|
+
```bash
|
|
614
|
+
bundle install
|
|
615
|
+
bundle exec appraisal install # writes gemfiles/*.gemfile
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
The default rake task runs the tests and Standard:
|
|
619
|
+
|
|
620
|
+
```bash
|
|
621
|
+
bundle exec rake # test + lint
|
|
622
|
+
bin/appraise # the full appraisal matrix
|
|
623
|
+
bundle exec appraisal rails-7.1 rake test # one Rails version
|
|
624
|
+
DB_ADAPTER=postgresql bundle exec rake test # against PostgreSQL
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
Available appraisals: `rails-7.1`, `rails-8.0`, `rails-8.1`. CI runs the same
|
|
628
|
+
matrix across Ruby 3.2, 3.3, and 3.4, plus a PostgreSQL 16 lane; the
|
|
629
|
+
PostgreSQL lane is the one that exercises savepoint behavior SQLite cannot
|
|
630
|
+
reproduce, so keep it green. The full design history lives under
|
|
631
|
+
[`docs/superpowers/`](docs/superpowers/).
|
|
632
|
+
|
|
633
|
+
## License
|
|
634
|
+
|
|
635
|
+
SagaForge is released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Installs SagaForge: writes config/initializers/saga_forge.rb and copies its
|
|
3
|
+
migration into db/migrate (or db/NAME_migrate for a multi-database setup).
|
|
4
|
+
Idempotent: migrations that already exist are skipped, so re-running is
|
|
5
|
+
safe.
|
|
6
|
+
|
|
7
|
+
Pass --database=NAME to run SagaForge on its own database. This sets
|
|
8
|
+
config.database in the initializer and installs the migration into
|
|
9
|
+
db/NAME_migrate instead of db/migrate, so a later `saga_forge:upgrade` run
|
|
10
|
+
(e.g. after a gem update) still targets the right place without repeating
|
|
11
|
+
the flag.
|
|
12
|
+
|
|
13
|
+
Re-running this generator with a different --database than last time
|
|
14
|
+
changes config/initializers/saga_forge.rb's contents, so Thor will prompt
|
|
15
|
+
to overwrite it. Pass --force to skip the prompt in scripts/CI.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
bin/rails generate saga_forge:install
|
|
19
|
+
|
|
20
|
+
This creates:
|
|
21
|
+
config/initializers/saga_forge.rb
|
|
22
|
+
|
|
23
|
+
and copies SagaForge's migration into db/migrate.
|
|
24
|
+
|
|
25
|
+
bin/rails generate saga_forge:install --database=saga
|
|
26
|
+
|
|
27
|
+
This sets config.database = :saga in the initializer and copies the
|
|
28
|
+
migration into db/saga_migrate instead.
|