Your checkout flow works until the settlement webhook lands early, lands twice, or payment fails after inventory was already reserved. SagaForge is the saga engine for workflows the outside world drives.
SagaForge is a gem over your existing database and ActiveJob backend: two tables, a handful of jobs, no separate server. Every incoming event is persisted before it is processed, early arrivals park until the saga is ready, each step commits atomically, and when a step declares failure the steps that already committed unwind in reverse. Works with any ActiveJob backend on Rails 7.1+.
# Gemfile
gem "saga_forge"
bundle install
rails g saga_forge:install
rails db:migrate
Want SagaForge's tables in their own database? rails g saga_forge:install --database=saga_forge.
A saga is one file that reads top to bottom. States exist only where the next fact comes from outside; everything else chains inline:
class OrderFulfillmentSaga < SagaForge::Base
correlate_by :order_id
# Create a PENDING payment intent; settlement arrives later, by webhook.
start_with :order_placed, compensate: :cancel_payment do |saga, payload|
saga.context[:items] = payload[:items]
intent = PaymentGateway.create_intent(payload[:total], idempotency_key: saga.correlation_id)
saga.context[:intent_id] = intent.id
end # falls through to :awaiting_settlement
during :awaiting_settlement, on: :payment_settled, compensate: :release_inventory do |saga, _|
Warehouse.reserve(saga.context[:items], key: saga.correlation_id)
Shipping.dispatch(saga.correlation_id)
end # falls through to :completed
during :awaiting_settlement, on: :payment_failed do |saga, payload|
saga.fail! reason: payload[:decline_code] # unwinds committed steps, in reverse
end
finish_with :completed
compensation(:cancel_payment) { |saga| # cancel or refund, guarded by context }
compensation(:release_inventory) { |saga| # put the stock back }
end
# The world talks to it by publishing facts, e.g. from a webhook controller:
SagaForge.publish :payment_settled, event_id: webhook.id, order_id: 42
The webhook can arrive before the saga is ready (it parks and re-delivers itself), twice (the event_id makes the duplicate a no-op), or as a failure (every committed step unwinds, last first, using the context each step saved).
event_id, and remains queryable afterward as the audit trail.suspended lists the stuck ones; resume! re-fires after the fix.They are siblings. If the workflow mostly waits for the outside world and needs an unwind story, it is a saga. If it is a sequence of steps your own code drives and should survive crashes and retries, it is a ChronoForge workflow. They compose: a saga step that needs resumable sub-steps kicks off a ChronoForge workflow and parks on its completion event.