Skip to content

Commit 301061a

Browse files
feat(orchestrate): C5b — wire the real up-saga phases (spec 09) (#9)
Assemble the concrete up phases over the C5a engine (BuildUp → []Phase): preflight → network → generate → shared(health-gated) → compose-up(per project) → hooks(postUp, per project) - preflight: docker daemon reachable (full doctor matrix is X6). - network: idempotent EnsureNetwork(devstack_shared) under the flock — never auto-removed (no compensation). - generate: GenerateAll + writeIfChanged; runs BEFORE the compose phases (the files must exist to `up`) and re-arms on a config edit (fingerprint = the on-disk workspace/devstack yaml). - shared: RegisterUp ref rows + `compose up -d` only the shared services the requested projects `uses`, then health-gate each (Healthy if it declares a healthcheck, else Started) via internal/health. Compensation drops the refs. - compose-up (per project): `compose up -d` (+ optional --build); compensation `compose down` (never -v — a failed up never drops volumes). Re-armed by a devstack.yaml edit. - hooks (per project): postUp via internal/hooks (OSExecer + the DB ledger + flock). Engine refinement: a FAILED mutating phase now runs its OWN compensation too (it may have partially applied — e.g. compose-up created containers), while its saga_phase row is KEPT failed (so `status` can surface it); succeeded phases still unwind in reverse with their rows cleared. Deferred + flagged (slot in as more phases, no engine change): clone (gitx), provision (needs the shared-Postgres host-port coupling), secrets (M4/S6), trust (N5), firstRun hooks (need the provisioned-volume scope_key). Wiring is unit-tested with a mock docker client + a fake compose runner + a real temp ledger + a real temp workspace over the embedded templates: full happy path (network ensured, refs added, both stacks up'd, files written, postUp ran), near-instant re-run (all skips bar AlwaysRun preflight/hooks), and a project compose-up failure that compensates the shared refs to zero and downs the project. The real end-to-end daemon test lands with G1's isolation harness (parameterized devstack-it-<pid> network/prefix — the saga must not touch a real devstack_shared). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cb128b4 commit 301061a

3 files changed

Lines changed: 626 additions & 11 deletions

File tree

‎internal/orchestrate/orchestrate.go‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ func (s *Saga) Run(ctx context.Context, phases []Phase) ([]Record, error) {
102102
s.emit(rec)
103103

104104
if err != nil {
105-
s.compensate(ctx, done)
105+
s.compensate(ctx, &p, done)
106106
return records, fmt.Errorf("phase %q failed: %w", p.Name, err)
107107
}
108108
if rec.Status == StatusOK && p.Mutating && p.Compensate != nil {
@@ -179,25 +179,36 @@ func (s *Saga) runBody(ctx context.Context, p Phase) (detail any, err error) {
179179
return p.Run(ctx)
180180
}
181181

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 —
182+
// compensate unwinds mutating work after a failure. The FAILED phase's own
183+
// compensation runs first (it may have partially applied — e.g. compose-up
184+
// created some containers) but its row is KEPT as failed so `status` can surface
185+
// it. Then the succeeded mutating phases unwind in reverse, each with its row
186+
// cleared so a re-run redoes it. A compensation error is logged, not fatal —
184187
// best-effort cleanup must not mask the original failure.
185-
func (s *Saga) compensate(ctx context.Context, done []Phase) {
188+
func (s *Saga) compensate(ctx context.Context, failed *Phase, done []Phase) {
189+
if failed != nil && failed.Mutating && failed.Compensate != nil {
190+
s.runCompensation(ctx, *failed)
191+
}
186192
for i := len(done) - 1; i >= 0; i-- {
187193
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-
}
194+
s.runCompensation(ctx, p)
195195
_ = s.withLock(ctx, func() error {
196196
return s.DB.ClearPhase(s.Workspace, p.Scope, p.Name)
197197
})
198198
}
199199
}
200200

201+
func (s *Saga) runCompensation(ctx context.Context, p Phase) {
202+
if p.Compensate == nil {
203+
return
204+
}
205+
if err := p.Compensate(ctx); err != nil {
206+
s.DB.LogEvent("saga", s.qualified(p), "compensation failed: "+err.Error())
207+
} else {
208+
s.DB.LogEvent("saga", s.qualified(p), "compensated")
209+
}
210+
}
211+
201212
func (s *Saga) fingerprint(ctx context.Context, p Phase) (string, error) {
202213
if p.AlwaysRun || p.Fingerprint == nil {
203214
return "", nil

0 commit comments

Comments
 (0)