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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +87 -0
  3. data/LICENSE +21 -0
  4. data/README.md +635 -0
  5. data/lib/generators/saga_forge/install/USAGE +28 -0
  6. data/lib/generators/saga_forge/install/install_generator.rb +60 -0
  7. data/lib/generators/saga_forge/migration_actions.rb +68 -0
  8. data/lib/generators/saga_forge/templates/initializer.rb +23 -0
  9. data/lib/generators/saga_forge/templates/install_saga_forge.rb +84 -0
  10. data/lib/generators/saga_forge/upgrade/USAGE +15 -0
  11. data/lib/generators/saga_forge/upgrade/upgrade_generator.rb +30 -0
  12. data/lib/saga_forge/application_record.rb +15 -0
  13. data/lib/saga_forge/base.rb +61 -0
  14. data/lib/saga_forge/compensation_job.rb +24 -0
  15. data/lib/saga_forge/compensation_runner.rb +142 -0
  16. data/lib/saga_forge/composite_retry_policy.rb +48 -0
  17. data/lib/saga_forge/configuration.rb +32 -0
  18. data/lib/saga_forge/dashboard/graph.rb +20 -0
  19. data/lib/saga_forge/definition.rb +268 -0
  20. data/lib/saga_forge/event.rb +16 -0
  21. data/lib/saga_forge/execution/compensation_facade.rb +21 -0
  22. data/lib/saga_forge/execution/facade.rb +45 -0
  23. data/lib/saga_forge/execution/post_commit.rb +53 -0
  24. data/lib/saga_forge/execution/runner.rb +240 -0
  25. data/lib/saga_forge/execution_job.rb +41 -0
  26. data/lib/saga_forge/publisher.rb +43 -0
  27. data/lib/saga_forge/railtie.rb +17 -0
  28. data/lib/saga_forge/retention_job.rb +28 -0
  29. data/lib/saga_forge/retry_policy.rb +107 -0
  30. data/lib/saga_forge/router.rb +70 -0
  31. data/lib/saga_forge/state.rb +113 -0
  32. data/lib/saga_forge/sweeper_job.rb +80 -0
  33. data/lib/saga_forge/timeout_job.rb +96 -0
  34. data/lib/saga_forge/version.rb +3 -0
  35. data/lib/saga_forge.rb +81 -0
  36. metadata +140 -0
data/README.md ADDED
@@ -0,0 +1,635 @@
1
+ # SagaForge
2
+
3
+ [![CI](https://github.com/radioactive-labs/saga_forge/actions/workflows/main.yml/badge.svg)](https://github.com/radioactive-labs/saga_forge/actions/workflows/main.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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.