Skip to content

Commit cb128b4

Browse files
feat(orchestrate): C5a — resumable saga engine (spec 09) (#8)
The load-bearing spine of the up saga: an ordered Phase driver that is resumable, crash-safe, compensating, and observable. Owns no domain logic — it sequences Phases under the lock+ledger spine. - Phase: name/scope, Mutating, AlwaysRun, Fingerprint, Run, Compensate. - Saga.Run: per phase — compute fingerprint, SKIP iff saga_phase is satisfied for that exact fingerprint (a changed fingerprint or a started-but-not- satisfied row re-arms it → crash mid-phase re-runs), StartPhase under the flock, run the body lock-free (the phase owns its own short mutating critical sections), then Satisfy/Fail under the flock. A panic in Run becomes a failure, never a CLI crash. - Compensation: on a downstream failure the succeeded mutating phases unwind in REVERSE (best-effort; a comp error is logged, not fatal), and each unwound phase row is cleared so a re-run redoes it. Non-mutating phases and intentionally-kept mutations (the shared network, provisioned data) declare no Compensate — a failed up never destroys data (spec 09 hard rule). - Output contract: a Record per phase ({phase,scope,status,durationMs,error, detail}, error null on success) for --json; FormatPlain for non-TTY; an Emit callback for live streaming; AnyFailed for the exit code. Every transition also writes an event_log row. Unit-tested against a REAL temp ledger + fake phases: happy path, skip-on- satisfied, fingerprint re-arm, AlwaysRun (secrets), failure→reverse compensation, resume-after-failure (crash recovery), panic→failure, the JSON error:null contract, plain rendering, fingerprint stability, Emit streaming. This is C5a (the engine). The concrete daemon phases (clone/network/shared/ provision/generate/compose-up/hooks wiring + the shared-postgres host-port coupling + the integration test) are C5b. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent de88a7a commit cb128b4

2 files changed

Lines changed: 569 additions & 0 deletions

File tree

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
// Package orchestrate sequences the devstack subsystems into the resumable,
2+
// crash-safe `up` saga (spec 09). It owns no domain logic — it drives ordered,
3+
// named Phases under the concurrency spine (internal/lock + internal/state), and
4+
// makes the multi-phase operation resumable (skip phases already satisfied for an
5+
// unchanged input fingerprint), crash-safe (a phase interrupted mid-run re-runs
6+
// because only `satisfied` skips), and observable (a Record per phase for the
7+
// plain/--json contract + an event_log trail).
8+
//
9+
// This file is the engine (C5a): the Phase model and the Saga driver. The
10+
// concrete daemon phases (clone/network/shared/provision/generate/compose-up/
11+
// hooks) that wire the real modules are assembled on top (C5b).
12+
package orchestrate
13+
14+
import (
15+
"context"
16+
"crypto/sha256"
17+
"encoding/hex"
18+
"fmt"
19+
"io"
20+
"time"
21+
22+
"github.com/open-source-cloud/devstack/internal/lock"
23+
"github.com/open-source-cloud/devstack/internal/state"
24+
)
25+
26+
// Phase statuses in the output contract (spec 09 §output-contract).
27+
const (
28+
StatusOK = "ok"
29+
StatusSkipped = "skipped"
30+
StatusFailed = "failed"
31+
)
32+
33+
// Phase is one named, idempotent, resumable step of the saga (spec 09 §phases).
34+
type Phase struct {
35+
// Name is the saga_phase key + the Record label (preflight|clone|network|…).
36+
Name string
37+
// Scope is "" for a workspace-wide phase or a project name for a per-project one.
38+
Scope string
39+
// Mutating marks a phase that changed global state; only mutating phases with a
40+
// Compensate are unwound (in reverse) when a LATER phase fails.
41+
Mutating bool
42+
// AlwaysRun forces the phase to run every time, never skipped or fingerprinted
43+
// (the `secrets` phase: values are resolved in memory and never cached, spec 09).
44+
AlwaysRun bool
45+
// Fingerprint returns the SHA-256-able digest of the phase's resolved inputs; a
46+
// changed digest re-arms the phase. nil ⇒ the empty fingerprint (a config-free
47+
// phase like network-ensure, which is idempotent regardless).
48+
Fingerprint func(context.Context) (string, error)
49+
// Run executes the phase. It manages its own short, lock-held mutating critical
50+
// sections (network/port/ref/provision); long work (pulls, clones, health
51+
// polling) runs lock-free — the saga does not hold the flock across Run.
52+
Run func(context.Context) (detail any, err error)
53+
// Compensate undoes this phase's global mutation when a downstream phase fails.
54+
// nil ⇒ no compensation (non-mutating phases, or mutations intentionally kept,
55+
// e.g. the shared network and provisioned data — spec 09 §compensation).
56+
Compensate func(context.Context) error
57+
}
58+
59+
// Record is one phase's machine-readable outcome (spec 09 §output-contract). It
60+
// serializes to the documented `--json` element; Error is null on success.
61+
type Record struct {
62+
Phase string `json:"phase"`
63+
Scope string `json:"scope,omitempty"`
64+
Status string `json:"status"` // ok | skipped | failed
65+
DurationMs int64 `json:"durationMs"`
66+
Error *string `json:"error"`
67+
Detail any `json:"detail,omitempty"`
68+
69+
fingerprint string // internal: carried to the satisfied row
70+
}
71+
72+
// Saga drives an ordered phase list with resumability + compensation.
73+
type Saga struct {
74+
Workspace string
75+
DB *state.DB
76+
LockPath string
77+
// Emit, if set, receives each Record as it completes (live streaming for the
78+
// CLI checklist); rendering must never block — keep it cheap.
79+
Emit func(Record)
80+
// clock is injectable for tests; defaults to time.Now.
81+
clock func() time.Time
82+
}
83+
84+
func (s *Saga) now() time.Time {
85+
if s.clock != nil {
86+
return s.clock()
87+
}
88+
return time.Now()
89+
}
90+
91+
// Run executes the phases in order. It returns the Record for every attempted
92+
// phase and a non-nil error iff a phase failed (after compensation has unwound
93+
// the mutating phases that had succeeded, in reverse order). Phases after a
94+
// failure are not attempted.
95+
func (s *Saga) Run(ctx context.Context, phases []Phase) ([]Record, error) {
96+
records := make([]Record, 0, len(phases))
97+
var done []Phase // succeeded mutating phases, in execution order (for unwind)
98+
99+
for _, p := range phases {
100+
rec, err := s.runPhase(ctx, p)
101+
records = append(records, rec)
102+
s.emit(rec)
103+
104+
if err != nil {
105+
s.compensate(ctx, done)
106+
return records, fmt.Errorf("phase %q failed: %w", p.Name, err)
107+
}
108+
if rec.Status == StatusOK && p.Mutating && p.Compensate != nil {
109+
done = append(done, p)
110+
}
111+
}
112+
return records, nil
113+
}
114+
115+
func (s *Saga) runPhase(ctx context.Context, p Phase) (Record, error) {
116+
start := s.now()
117+
rec := Record{Phase: p.Name, Scope: p.Scope}
118+
119+
fp, err := s.fingerprint(ctx, p)
120+
if err != nil {
121+
return s.finishFail(rec, start, fmt.Errorf("fingerprint: %w", err)), err
122+
}
123+
rec.fingerprint = fp
124+
125+
// Skip iff already satisfied for this exact fingerprint (spec 09 resumability).
126+
if !p.AlwaysRun {
127+
satisfied, err := s.DB.PhaseSatisfied(s.Workspace, p.Scope, p.Name, fp)
128+
if err != nil {
129+
return s.finishFail(rec, start, err), err
130+
}
131+
if satisfied {
132+
rec.Status = StatusSkipped
133+
rec.DurationMs = s.since(start)
134+
return rec, nil
135+
}
136+
}
137+
138+
// Mark started (under the flock) — a `started`-but-not-`satisfied` row is what a
139+
// crashed run re-runs on the next invocation.
140+
if err := s.withLock(ctx, func() error {
141+
return s.DB.StartPhase(s.Workspace, p.Scope, p.Name, fp)
142+
}); err != nil {
143+
return s.finishFail(rec, start, err), err
144+
}
145+
s.DB.LogEvent("saga", s.qualified(p), "started")
146+
147+
detail, runErr := s.runBody(ctx, p)
148+
rec.Detail = detail
149+
if runErr != nil {
150+
_ = s.withLock(ctx, func() error {
151+
return s.DB.FailPhase(s.Workspace, p.Scope, p.Name, runErr.Error())
152+
})
153+
s.DB.LogEvent("saga", s.qualified(p), "failed: "+runErr.Error())
154+
return s.finishFail(rec, start, runErr), runErr
155+
}
156+
157+
if err := s.withLock(ctx, func() error {
158+
return s.DB.SatisfyPhase(s.Workspace, p.Scope, p.Name)
159+
}); err != nil {
160+
return s.finishFail(rec, start, err), err
161+
}
162+
rec.Status = StatusOK
163+
rec.DurationMs = s.since(start)
164+
s.DB.LogEvent("saga", s.qualified(p), fmt.Sprintf("satisfied in %dms", rec.DurationMs))
165+
return rec, nil
166+
}
167+
168+
// runBody invokes a phase's Run, converting a panic into an error so one phase
169+
// can never crash the whole CLI (the saga must always reach compensation).
170+
func (s *Saga) runBody(ctx context.Context, p Phase) (detail any, err error) {
171+
defer func() {
172+
if r := recover(); r != nil {
173+
err = fmt.Errorf("panic in phase %q: %v", p.Name, r)
174+
}
175+
}()
176+
if p.Run == nil {
177+
return nil, nil
178+
}
179+
return p.Run(ctx)
180+
}
181+
182+
// compensate unwinds the succeeded mutating phases in reverse, clearing each
183+
// phase row so a re-run redoes it. A compensation error is logged, not fatal —
184+
// best-effort cleanup must not mask the original failure.
185+
func (s *Saga) compensate(ctx context.Context, done []Phase) {
186+
for i := len(done) - 1; i >= 0; i-- {
187+
p := done[i]
188+
if p.Compensate != nil {
189+
if err := p.Compensate(ctx); err != nil {
190+
s.DB.LogEvent("saga", s.qualified(p), "compensation failed: "+err.Error())
191+
} else {
192+
s.DB.LogEvent("saga", s.qualified(p), "compensated")
193+
}
194+
}
195+
_ = s.withLock(ctx, func() error {
196+
return s.DB.ClearPhase(s.Workspace, p.Scope, p.Name)
197+
})
198+
}
199+
}
200+
201+
func (s *Saga) fingerprint(ctx context.Context, p Phase) (string, error) {
202+
if p.AlwaysRun || p.Fingerprint == nil {
203+
return "", nil
204+
}
205+
return p.Fingerprint(ctx)
206+
}
207+
208+
func (s *Saga) withLock(ctx context.Context, fn func() error) error {
209+
if s.LockPath == "" {
210+
return fn() // tests without a lock path run unlocked
211+
}
212+
return lock.WithLock(ctx, s.LockPath, fn)
213+
}
214+
215+
func (s *Saga) finishFail(rec Record, start time.Time, err error) Record {
216+
rec.Status = StatusFailed
217+
rec.DurationMs = s.since(start)
218+
msg := err.Error()
219+
rec.Error = &msg
220+
return rec
221+
}
222+
223+
func (s *Saga) since(start time.Time) int64 { return s.now().Sub(start).Milliseconds() }
224+
225+
func (s *Saga) emit(rec Record) {
226+
if s.Emit != nil {
227+
s.Emit(rec)
228+
}
229+
}
230+
231+
func (s *Saga) qualified(p Phase) string {
232+
if p.Scope == "" {
233+
return p.Name
234+
}
235+
return p.Scope + "/" + p.Name
236+
}
237+
238+
// Fingerprint hashes its parts into a stable hex digest for a phase's
239+
// Fingerprint func (config bytes, params, resolved versions). Order-sensitive.
240+
func Fingerprint(parts ...string) string {
241+
h := sha256.New()
242+
for _, p := range parts {
243+
_, _ = io.WriteString(h, p)
244+
_, _ = h.Write([]byte{0}) // length-independent separator
245+
}
246+
return hex.EncodeToString(h.Sum(nil))
247+
}
248+
249+
// FormatPlain renders one Record as a single non-TTY line (spec 09 plain mode):
250+
//
251+
// [ok] network (12ms)
252+
// [skipped] generate
253+
// [failed] compose-up: service api exited (1)
254+
func FormatPlain(r Record) string {
255+
label := r.Phase
256+
if r.Scope != "" {
257+
label = r.Scope + "/" + r.Phase
258+
}
259+
switch r.Status {
260+
case StatusSkipped:
261+
return fmt.Sprintf("[skipped] %s", label)
262+
case StatusFailed:
263+
msg := ""
264+
if r.Error != nil {
265+
msg = ": " + *r.Error
266+
}
267+
return fmt.Sprintf("[failed] %s (%dms)%s", label, r.DurationMs, msg)
268+
default:
269+
return fmt.Sprintf("[ok] %s (%dms)", label, r.DurationMs)
270+
}
271+
}
272+
273+
// AnyFailed reports whether any record failed (the saga's process exit code is
274+
// non-zero iff this is true, spec 09).
275+
func AnyFailed(records []Record) bool {
276+
for _, r := range records {
277+
if r.Status == StatusFailed {
278+
return true
279+
}
280+
}
281+
return false
282+
}

0 commit comments

Comments
 (0)