package saga
import (
"context"
"errors"
"fmt"
"log/slog"
)
type OrderState struct {
OrderID, CustomerID string
Total int64
PaymentID, ShipmentID string
}
// Step = a local transaction in one service + its semantic undo.
type Step struct {1
Name string
Do func(ctx context.Context, s *OrderState) error
Undo func(ctx context.Context, s *OrderState) error // nil: nothing to undo
}
// Journal persists progress so a crashed saga can be resumed or compensated.
type Journal interface {
Record(ctx context.Context, orderID, step, status string)
}
type Orchestrator struct {
Steps []Step
Journal Journal
}
func (o *Orchestrator) Run(ctx context.Context, st *OrderState) error {
for i, step := range o.Steps {
o.Journal.Record(ctx, st.OrderID, step.Name, "STARTED")
if err := step.Do(ctx, st); err != nil {
o.Journal.Record(ctx, st.OrderID, step.Name, "FAILED")
cerr := o.compensate(context.WithoutCancel(ctx), st, o.Steps[:i])2
return errors.Join(fmt.Errorf("saga failed at %s: %w", step.Name, err), cerr)
}
o.Journal.Record(ctx, st.OrderID, step.Name, "DONE")
}
return nil
}
func (o *Orchestrator) compensate(ctx context.Context, st *OrderState, done []Step) error {
var errs []error
for i := len(done) - 1; i >= 0; i-- { // reverse order3
if done[i].Undo == nil {
continue
}
if err := done[i].Undo(ctx, st); err != nil {
slog.Error("compensation failed, retry later", "order", st.OrderID, "step", done[i].Name, "err", err)
errs = append(errs, err)
continue
}
o.Journal.Record(ctx, st.OrderID, done[i].Name, "COMPENSATED")
}
return errors.Join(errs...)
}