order sagas
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled

This commit is contained in:
2026-06-21 11:33:20 +02:00
parent 484f2364fb
commit 1a365de071
25 changed files with 4224 additions and 1 deletions
+301
View File
@@ -0,0 +1,301 @@
package flow
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"sync"
)
// Phase identifies when a hook fires relative to its step's action.
type Phase string
const (
PhaseBefore Phase = "before"
PhaseAfter Phase = "after"
PhaseOnError Phase = "onError"
)
// State is the mutable bag shared across a single flow run. Actions and hooks
// read and write Vars to pass data down the chain (e.g. an authorization
// reference produced by one step and consumed by the next). ID is the domain
// entity the flow operates on (an order id, for the order flows).
type State struct {
ID uint64
Vars map[string]any
Logger *slog.Logger
}
// NewState returns an empty state for entity id.
func NewState(id uint64, logger *slog.Logger) *State {
if logger == nil {
logger = slog.Default()
}
return &State{ID: id, Vars: map[string]any{}, Logger: logger}
}
// Action performs a step's work. params is the step's opaque JSON config.
type Action func(ctx context.Context, st *State, params json.RawMessage) error
// Predicate gates a step (the step runs only when it returns true).
type Predicate func(ctx context.Context, st *State) (bool, error)
// HookInfo describes the step and phase a hook is firing for.
type HookInfo struct {
Step string
Phase Phase
// Err is the action error when Phase == PhaseOnError, else nil.
Err error
}
// Hook reacts around a step. Hook errors never abort the flow — they are logged
// — so observability/notification hooks can't break the business transaction.
type Hook func(ctx context.Context, st *State, info HookInfo, params json.RawMessage) error
// Registry holds the named actions, predicates and hooks a flow can reference.
type Registry struct {
mu sync.RWMutex
actions map[string]Action
predicates map[string]Predicate
hooks map[string]Hook
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry {
return &Registry{
actions: map[string]Action{},
predicates: map[string]Predicate{},
hooks: map[string]Hook{},
}
}
// Action registers an action under name (overwrites an existing one).
func (r *Registry) Action(name string, fn Action) {
r.mu.Lock()
defer r.mu.Unlock()
r.actions[name] = fn
}
// Predicate registers a predicate under name.
func (r *Registry) Predicate(name string, fn Predicate) {
r.mu.Lock()
defer r.mu.Unlock()
r.predicates[name] = fn
}
// Hook registers a hook under name.
func (r *Registry) Hook(name string, fn Hook) {
r.mu.Lock()
defer r.mu.Unlock()
r.hooks[name] = fn
}
func (r *Registry) action(name string) (Action, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
a, ok := r.actions[name]
return a, ok
}
func (r *Registry) predicate(name string) (Predicate, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.predicates[name]
return p, ok
}
func (r *Registry) hook(name string) (Hook, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
h, ok := r.hooks[name]
return h, ok
}
// Capabilities is the set of registered names, surfaced so an editor UI can
// offer exactly the actions/predicates/hooks a flow may reference.
type Capabilities struct {
Actions []string `json:"actions"`
Predicates []string `json:"predicates"`
Hooks []string `json:"hooks"`
}
// Capabilities returns the sorted registered names.
func (r *Registry) Capabilities() Capabilities {
r.mu.RLock()
defer r.mu.RUnlock()
return Capabilities{
Actions: sortedKeys(r.actions),
Predicates: sortedKeys(r.predicates),
Hooks: sortedKeys(r.hooks),
}
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// StepResult records the outcome of one step.
type StepResult struct {
Name string `json:"name"`
Skipped bool `json:"skipped,omitempty"`
Error string `json:"error,omitempty"`
}
// Result is the outcome of a whole flow run.
type Result struct {
Flow string `json:"flow"`
Steps []StepResult `json:"steps"`
Compensated []string `json:"compensated,omitempty"`
Failed bool `json:"failed"`
}
// Engine runs flow definitions against a registry.
type Engine struct {
reg *Registry
logger *slog.Logger
}
// NewEngine returns an engine bound to reg.
func NewEngine(reg *Registry, logger *slog.Logger) *Engine {
if logger == nil {
logger = slog.Default()
}
return &Engine{reg: reg, logger: logger}
}
// Validate checks that every action, predicate and hook a definition references
// is registered. Call it at load time to fail fast on a bad flow config.
func (e *Engine) Validate(def *Definition) error {
for _, s := range def.Steps {
if _, ok := e.reg.action(s.Action); !ok {
return fmt.Errorf("flow %q step %q: unknown action %q", def.Name, s.Name, s.Action)
}
if s.When != "" {
if _, ok := e.reg.predicate(s.When); !ok {
return fmt.Errorf("flow %q step %q: unknown predicate %q", def.Name, s.Name, s.When)
}
}
if s.Compensate != nil {
if _, ok := e.reg.action(s.Compensate.Action); !ok {
return fmt.Errorf("flow %q step %q: unknown compensate action %q", def.Name, s.Name, s.Compensate.Action)
}
}
for _, hs := range [][]HookRef{s.Hooks.Before, s.Hooks.After, s.Hooks.OnError} {
for _, h := range hs {
if _, ok := e.reg.hook(h.Type); !ok {
return fmt.Errorf("flow %q step %q: unknown hook %q", def.Name, s.Name, h.Type)
}
}
}
}
return nil
}
// Run executes the flow. On a step error (without ContinueOnError) it fires the
// step's on-error hooks, compensates every already-executed step that declared a
// compensating action (in reverse), and returns the error. Hook failures are
// logged, never fatal.
func (e *Engine) Run(ctx context.Context, def *Definition, st *State) (*Result, error) {
res := &Result{Flow: def.Name}
var executed []Step // steps that completed, for reverse compensation
for _, step := range def.Steps {
if step.When != "" {
pred, ok := e.reg.predicate(step.When)
if !ok {
return res, e.fail(res, step, fmt.Errorf("unknown predicate %q", step.When))
}
run, err := pred(ctx, st)
if err != nil {
return res, e.abort(ctx, res, &executed, step, err, st)
}
if !run {
res.Steps = append(res.Steps, StepResult{Name: step.Name, Skipped: true})
st.Logger.Info("flow step skipped", "flow", def.Name, "step", step.Name)
continue
}
}
action, ok := e.reg.action(step.Action)
if !ok {
return res, e.fail(res, step, fmt.Errorf("unknown action %q", step.Action))
}
e.fireHooks(ctx, st, step.Hooks.Before, HookInfo{Step: step.Name, Phase: PhaseBefore})
err := action(ctx, st, step.Params)
if err != nil {
e.fireHooks(ctx, st, step.Hooks.OnError, HookInfo{Step: step.Name, Phase: PhaseOnError, Err: err})
if step.ContinueOnError {
res.Steps = append(res.Steps, StepResult{Name: step.Name, Error: err.Error()})
st.Logger.Warn("flow step failed, continuing", "flow", def.Name, "step", step.Name, "err", err)
executed = append(executed, step)
continue
}
return res, e.abort(ctx, res, &executed, step, err, st)
}
e.fireHooks(ctx, st, step.Hooks.After, HookInfo{Step: step.Name, Phase: PhaseAfter})
res.Steps = append(res.Steps, StepResult{Name: step.Name})
executed = append(executed, step)
}
return res, nil
}
// fail records a step error without compensation (used for config errors found
// before the action ran, so nothing needs undoing for this step).
func (e *Engine) fail(res *Result, step Step, err error) error {
res.Failed = true
res.Steps = append(res.Steps, StepResult{Name: step.Name, Error: err.Error()})
return fmt.Errorf("flow %q step %q: %w", res.Flow, step.Name, err)
}
// abort records the failing step, then compensates executed steps in reverse.
func (e *Engine) abort(ctx context.Context, res *Result, executed *[]Step, step Step, cause error, st *State) error {
res.Failed = true
res.Steps = append(res.Steps, StepResult{Name: step.Name, Error: cause.Error()})
e.compensate(ctx, res, *executed, st)
return fmt.Errorf("flow %q step %q: %w", res.Flow, step.Name, cause)
}
// compensate runs the compensating action of each executed step in reverse.
func (e *Engine) compensate(ctx context.Context, res *Result, executed []Step, st *State) {
for i := len(executed) - 1; i >= 0; i-- {
step := executed[i]
if step.Compensate == nil {
continue
}
action, ok := e.reg.action(step.Compensate.Action)
if !ok {
st.Logger.Error("flow compensation skipped: unknown action",
"flow", res.Flow, "step", step.Name, "action", step.Compensate.Action)
continue
}
if err := action(ctx, st, step.Compensate.Params); err != nil {
st.Logger.Error("flow compensation failed", "flow", res.Flow, "step", step.Name, "err", err)
continue
}
res.Compensated = append(res.Compensated, step.Name)
}
}
// fireHooks runs every hook for a phase, logging (never propagating) failures.
func (e *Engine) fireHooks(ctx context.Context, st *State, refs []HookRef, info HookInfo) {
for _, ref := range refs {
h, ok := e.reg.hook(ref.Type)
if !ok {
st.Logger.Error("flow hook skipped: unknown type", "hook", ref.Type, "step", info.Step)
continue
}
if err := h(ctx, st, info, ref.Params); err != nil {
st.Logger.Warn("flow hook error", "hook", ref.Type, "step", info.Step, "phase", info.Phase, "err", err)
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package flow
import (
"context"
"encoding/json"
"fmt"
"testing"
)
func TestRunRecordsOrderAndHooks(t *testing.T) {
reg := NewRegistry()
var trace []string
reg.Action("a", func(_ context.Context, _ *State, _ json.RawMessage) error {
trace = append(trace, "a")
return nil
})
reg.Action("b", func(_ context.Context, _ *State, _ json.RawMessage) error {
trace = append(trace, "b")
return nil
})
reg.Hook("rec", func(_ context.Context, _ *State, info HookInfo, _ json.RawMessage) error {
trace = append(trace, string(info.Phase)+":"+info.Step)
return nil
})
def := &Definition{Name: "t", Steps: []Step{
{Name: "s1", Action: "a", Hooks: Hooks{Before: []HookRef{{Type: "rec"}}, After: []HookRef{{Type: "rec"}}}},
{Name: "s2", Action: "b"},
}}
res, err := NewEngine(reg, nil).Run(context.Background(), def, NewState(1, nil))
if err != nil {
t.Fatal(err)
}
if res.Failed || len(res.Steps) != 2 {
t.Fatalf("unexpected result %+v", res)
}
want := []string{"before:s1", "a", "after:s1", "b"}
if fmt.Sprint(trace) != fmt.Sprint(want) {
t.Fatalf("trace = %v, want %v", trace, want)
}
}
func TestPredicateSkipsStep(t *testing.T) {
reg := NewRegistry()
ran := false
reg.Action("a", func(_ context.Context, _ *State, _ json.RawMessage) error { ran = true; return nil })
reg.Predicate("never", func(_ context.Context, _ *State) (bool, error) { return false, nil })
def := &Definition{Name: "t", Steps: []Step{{Name: "s1", Action: "a", When: "never"}}}
res, err := NewEngine(reg, nil).Run(context.Background(), def, NewState(1, nil))
if err != nil {
t.Fatal(err)
}
if ran {
t.Fatal("action ran despite predicate=false")
}
if !res.Steps[0].Skipped {
t.Fatal("step not marked skipped")
}
}
func TestContinueOnError(t *testing.T) {
reg := NewRegistry()
reg.Action("boom", func(_ context.Context, _ *State, _ json.RawMessage) error { return fmt.Errorf("x") })
reg.Action("ok", func(_ context.Context, _ *State, _ json.RawMessage) error { return nil })
def := &Definition{Name: "t", Steps: []Step{
{Name: "s1", Action: "boom", ContinueOnError: true},
{Name: "s2", Action: "ok"},
}}
res, err := NewEngine(reg, nil).Run(context.Background(), def, NewState(1, nil))
if err != nil {
t.Fatalf("continue-on-error should not abort: %v", err)
}
if res.Steps[0].Error == "" || res.Steps[1].Error != "" {
t.Fatalf("unexpected step results %+v", res.Steps)
}
}
func TestValidateCatchesUnknownRefs(t *testing.T) {
reg := NewRegistry()
reg.Action("a", func(_ context.Context, _ *State, _ json.RawMessage) error { return nil })
eng := NewEngine(reg, nil)
if err := eng.Validate(&Definition{Name: "t", Steps: []Step{{Name: "s", Action: "missing"}}}); err == nil {
t.Fatal("expected unknown-action error")
}
if err := eng.Validate(&Definition{Name: "t", Steps: []Step{{Name: "s", Action: "a"}}}); err != nil {
t.Fatalf("valid def rejected: %v", err)
}
}
+67
View File
@@ -0,0 +1,67 @@
// Package flow is a generic, JSON-configurable orchestration engine. A flow is a
// sequence of named steps; each step runs a registered action, may be gated by a
// registered predicate, can fire registered hooks (before/after/on-error), and
// may declare a compensating action used to roll back on failure (the saga
// pattern). Actions, predicates and hooks are registered by name — the same
// "interface + interchangeable impls, wired in one place" seam the CMS uses for
// its event listeners. The flow definition itself is plain JSON, so flows live
// alongside the app's other JSON config (schemas, blueprints) and can be edited
// without code changes.
package flow
import "encoding/json"
// Definition is a whole flow, loadable from JSON.
type Definition struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Steps []Step `json:"steps"`
}
// Step is one unit of work in a flow.
type Step struct {
// Name identifies the step in logs, hooks and results.
Name string `json:"name"`
// Action is the registered action to run.
Action string `json:"action"`
// Params is opaque JSON passed to the action.
Params json.RawMessage `json:"params,omitempty"`
// When, if set, names a registered predicate; the step is skipped when it
// returns false.
When string `json:"when,omitempty"`
// Hooks fire around the action.
Hooks Hooks `json:"hooks,omitempty"`
// Compensate, if set, is the action used to undo this step when a later step
// fails (executed in reverse order during rollback).
Compensate *ActionRef `json:"compensate,omitempty"`
// ContinueOnError records the error and proceeds instead of rolling back.
ContinueOnError bool `json:"continueOnError,omitempty"`
}
// ActionRef names an action and its params (used for compensation).
type ActionRef struct {
Action string `json:"action"`
Params json.RawMessage `json:"params,omitempty"`
}
// Hooks groups the hook references that fire at each phase of a step.
type Hooks struct {
Before []HookRef `json:"before,omitempty"`
After []HookRef `json:"after,omitempty"`
OnError []HookRef `json:"onError,omitempty"`
}
// HookRef names a registered hook and its params.
type HookRef struct {
Type string `json:"type"`
Params json.RawMessage `json:"params,omitempty"`
}
// Parse loads a flow definition from JSON bytes.
func Parse(data []byte) (*Definition, error) {
var d Definition
if err := json.Unmarshal(data, &d); err != nil {
return nil, err
}
return &d, nil
}
+89
View File
@@ -0,0 +1,89 @@
package flow
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// RegisterBuiltinHooks adds the generic hooks every deployment can use: "log"
// (structured log line) and "webhook" (HTTP POST of the event). Domain packages
// register their own hooks (e.g. "amqp_emit") on the same registry.
func RegisterBuiltinHooks(reg *Registry) {
reg.Hook("log", logHook)
reg.Hook("noop", func(context.Context, *State, HookInfo, json.RawMessage) error { return nil })
reg.Hook("webhook", webhookHook(&http.Client{Timeout: 10 * time.Second}))
}
// logHook emits a structured log line for the step/phase. Params (optional):
//
// {"message": "custom text"}
func logHook(_ context.Context, st *State, info HookInfo, params json.RawMessage) error {
msg := "flow hook"
if len(params) > 0 {
var p struct {
Message string `json:"message"`
}
if err := json.Unmarshal(params, &p); err == nil && p.Message != "" {
msg = p.Message
}
}
args := []any{"step", info.Step, "phase", info.Phase, "id", st.ID}
if info.Err != nil {
args = append(args, "err", info.Err)
}
st.Logger.Info(msg, args...)
return nil
}
// webhookHook POSTs a JSON event to a configured URL. Params:
//
// {"url": "https://...", "headers": {"X-Token": "..."}}
//
// The body is {step, phase, id, error, vars}. Returned as a closure so the HTTP
// client (and its timeout/transport) is shared across invocations.
func webhookHook(client *http.Client) Hook {
return func(ctx context.Context, st *State, info HookInfo, params json.RawMessage) error {
var p struct {
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
}
if err := json.Unmarshal(params, &p); err != nil {
return fmt.Errorf("webhook: bad params: %w", err)
}
if p.URL == "" {
return fmt.Errorf("webhook: missing url")
}
errStr := ""
if info.Err != nil {
errStr = info.Err.Error()
}
body, _ := json.Marshal(map[string]any{
"step": info.Step,
"phase": info.Phase,
"id": st.ID,
"error": errStr,
"vars": st.Vars,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.URL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
for k, v := range p.Headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("webhook: %s returned %d", p.URL, resp.StatusCode)
}
return nil
}
}