Skip to content

Commit df5400e

Browse files
gustavobertoiclaude
andcommitted
feat(state): C1 — saga_phase v2 migration + resumability CRUD (spec 08/09)
Adds the up-saga resumability foundation that gates the whole orchestrator (C5): - state schema v2: the `saga_phase` table keyed by (ctx, workspace, scope, phase) with status/fingerprint/timestamps/error; forward-only, backup-before-migrate. - CRUD: StartPhase (upsert, clears prior state), SatisfyPhase, FailPhase, GetPhase, PhaseSatisfied (the fingerprint-matched skip check a re-run uses), PhasesFor, ClearPhase (compensation). All scoped by Docker context, intended under the flock. Race-clean; migration v1->v2 + phase lifecycle + fingerprint re-arm tested. First chunk of the nightly M2->M7 build (see PROGRESS.md). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 17e0209 commit df5400e

4 files changed

Lines changed: 237 additions & 1 deletion

File tree

‎PROGRESS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ and verification. **Merge model:** PR-per-chunk, auto-merge on green CI.
1919
## Frontier
2020

2121
### M2-remainder (core saga)
22-
- [ ] C1 state v2 `saga_phase` table + CRUD — TODO *(kickoff)*
22+
- [x] C1 state v2 `saga_phase` table + CRUD — REVIEW (PR `nightly/C1`)
2323
- [ ] C2 docker `ContainerInspect`/`ContainerLogs` + mock — TODO *(kickoff)*
2424
- [ ] C3a config `healthcheck:`/`hooks:` structs — TODO *(kickoff)*
2525
- [ ] C3b `internal/health` thin poller — BLOCKED (C2, C3a)

‎internal/state/migrations.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type migration struct {
1313
// or remove a released one.
1414
var migrations = []migration{
1515
{version: 1, stmt: schemaV1},
16+
{version: 2, stmt: schemaV2},
1617
}
1718

1819
// schemaV1 is the initial ledger (spec 08 §Tables). Every mutable row is scoped
@@ -94,6 +95,25 @@ CREATE TABLE IF NOT EXISTS schema_version (
9495
);
9596
`
9697

98+
// schemaV2 (spec 08 §saga_phase) adds the up-saga resumability table: one row per
99+
// (workspace, scope, phase) recording its status + an input fingerprint, so a
100+
// re-run skips satisfied phases and a crash mid-saga resumes deterministically.
101+
const schemaV2 = `
102+
CREATE TABLE IF NOT EXISTS saga_phase (
103+
ctx TEXT NOT NULL,
104+
workspace TEXT NOT NULL,
105+
scope TEXT NOT NULL DEFAULT '', -- '' = workspace-wide, else project name
106+
phase TEXT NOT NULL, -- preflight|clone|network|shared|provision|secrets|generate|compose-up|hooks|...
107+
status TEXT NOT NULL, -- pending|started|satisfied|failed
108+
fingerprint TEXT NOT NULL DEFAULT '', -- SHA-256 of the phase's inputs
109+
started_at TEXT,
110+
satisfied_at TEXT,
111+
error TEXT,
112+
PRIMARY KEY (ctx, workspace, scope, phase),
113+
FOREIGN KEY (ctx) REFERENCES docker_context(name) ON DELETE CASCADE
114+
);
115+
`
116+
97117
// migrate applies any pending migrations inside a transaction per step, backing
98118
// up the DB file before the first mutating step. Forward-only.
99119
func (db *DB) migrate() error {

‎internal/state/saga.go‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package state
2+
3+
import (
4+
"database/sql"
5+
"fmt"
6+
)
7+
8+
// Saga phase statuses.
9+
const (
10+
PhasePending = "pending"
11+
PhaseStarted = "started"
12+
PhaseSatisfied = "satisfied"
13+
PhaseFailed = "failed"
14+
)
15+
16+
// SagaPhase is one row of the saga_phase resumability table (spec 08/09). A phase
17+
// is keyed by (workspace, scope, phase); scope is "" for workspace-wide phases or
18+
// a project name for per-project ones.
19+
type SagaPhase struct {
20+
Workspace string
21+
Scope string
22+
Phase string
23+
Status string
24+
Fingerprint string
25+
StartedAt string
26+
SatisfiedAt string
27+
Error string
28+
}
29+
30+
// StartPhase marks a phase started with the given input fingerprint (upsert).
31+
// Clears any prior satisfied/failed/error state for a fresh attempt. Hold the lock.
32+
func (db *DB) StartPhase(workspace, scope, phase, fingerprint string) error {
33+
_, err := db.Exec(`
34+
INSERT INTO saga_phase (ctx, workspace, scope, phase, status, fingerprint, started_at)
35+
VALUES (?,?,?,?,?,?,datetime('now'))
36+
ON CONFLICT(ctx, workspace, scope, phase)
37+
DO UPDATE SET status=excluded.status, fingerprint=excluded.fingerprint,
38+
started_at=excluded.started_at, satisfied_at=NULL, error=NULL`,
39+
db.Ctx, workspace, scope, phase, PhaseStarted, fingerprint)
40+
if err != nil {
41+
return fmt.Errorf("start phase %s/%s/%s: %w", workspace, scope, phase, err)
42+
}
43+
return nil
44+
}
45+
46+
// SatisfyPhase marks a phase satisfied (completed). Hold the lock.
47+
func (db *DB) SatisfyPhase(workspace, scope, phase string) error {
48+
_, err := db.Exec(`UPDATE saga_phase SET status=?, satisfied_at=datetime('now'), error=NULL
49+
WHERE ctx=? AND workspace=? AND scope=? AND phase=?`,
50+
PhaseSatisfied, db.Ctx, workspace, scope, phase)
51+
if err != nil {
52+
return fmt.Errorf("satisfy phase %s/%s/%s: %w", workspace, scope, phase, err)
53+
}
54+
return nil
55+
}
56+
57+
// FailPhase marks a phase failed with an error message. Hold the lock.
58+
func (db *DB) FailPhase(workspace, scope, phase, errMsg string) error {
59+
_, err := db.Exec(`UPDATE saga_phase SET status=?, error=?
60+
WHERE ctx=? AND workspace=? AND scope=? AND phase=?`,
61+
PhaseFailed, errMsg, db.Ctx, workspace, scope, phase)
62+
if err != nil {
63+
return fmt.Errorf("fail phase %s/%s/%s: %w", workspace, scope, phase, err)
64+
}
65+
return nil
66+
}
67+
68+
// GetPhase returns a phase row, ok=false if it has never been recorded.
69+
func (db *DB) GetPhase(workspace, scope, phase string) (SagaPhase, bool, error) {
70+
var p SagaPhase
71+
var started, satisfied, errMsg sql.NullString
72+
err := db.QueryRow(`SELECT workspace, scope, phase, status, fingerprint, started_at, satisfied_at, error
73+
FROM saga_phase WHERE ctx=? AND workspace=? AND scope=? AND phase=?`,
74+
db.Ctx, workspace, scope, phase).
75+
Scan(&p.Workspace, &p.Scope, &p.Phase, &p.Status, &p.Fingerprint, &started, &satisfied, &errMsg)
76+
if err == sql.ErrNoRows {
77+
return SagaPhase{}, false, nil
78+
}
79+
if err != nil {
80+
return SagaPhase{}, false, fmt.Errorf("get phase: %w", err)
81+
}
82+
p.StartedAt, p.SatisfiedAt, p.Error = started.String, satisfied.String, errMsg.String
83+
return p, true, nil
84+
}
85+
86+
// PhaseSatisfied reports whether a phase is already satisfied for the SAME input
87+
// fingerprint — the skip check a re-run uses to avoid redoing unchanged work. A
88+
// changed fingerprint (config/template/param edit) re-arms the phase.
89+
func (db *DB) PhaseSatisfied(workspace, scope, phase, fingerprint string) (bool, error) {
90+
p, ok, err := db.GetPhase(workspace, scope, phase)
91+
if err != nil || !ok {
92+
return false, err
93+
}
94+
return p.Status == PhaseSatisfied && p.Fingerprint == fingerprint, nil
95+
}
96+
97+
// PhasesFor returns all recorded phases for a workspace, ordered, for `status`.
98+
func (db *DB) PhasesFor(workspace string) ([]SagaPhase, error) {
99+
rows, err := db.Query(`SELECT workspace, scope, phase, status, fingerprint, started_at, satisfied_at, error
100+
FROM saga_phase WHERE ctx=? AND workspace=? ORDER BY scope, phase`, db.Ctx, workspace)
101+
if err != nil {
102+
return nil, fmt.Errorf("phases for %s: %w", workspace, err)
103+
}
104+
defer rows.Close()
105+
var out []SagaPhase
106+
for rows.Next() {
107+
var p SagaPhase
108+
var started, satisfied, errMsg sql.NullString
109+
if err := rows.Scan(&p.Workspace, &p.Scope, &p.Phase, &p.Status, &p.Fingerprint, &started, &satisfied, &errMsg); err != nil {
110+
return nil, err
111+
}
112+
p.StartedAt, p.SatisfiedAt, p.Error = started.String, satisfied.String, errMsg.String
113+
out = append(out, p)
114+
}
115+
return out, rows.Err()
116+
}
117+
118+
// ClearPhase removes a phase row (compensation / forced re-run). Hold the lock.
119+
func (db *DB) ClearPhase(workspace, scope, phase string) error {
120+
_, err := db.Exec(`DELETE FROM saga_phase WHERE ctx=? AND workspace=? AND scope=? AND phase=?`,
121+
db.Ctx, workspace, scope, phase)
122+
if err != nil {
123+
return fmt.Errorf("clear phase %s/%s/%s: %w", workspace, scope, phase, err)
124+
}
125+
return nil
126+
}

‎internal/state/saga_test.go‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package state
2+
3+
import "testing"
4+
5+
func TestSagaPhaseLifecycle(t *testing.T) {
6+
db := openTestDB(t)
7+
8+
// Unrecorded phase: not satisfied, GetPhase ok=false.
9+
if ok, _ := db.PhaseSatisfied("acme", "", "network", "fp1"); ok {
10+
t.Fatal("unrecorded phase should not be satisfied")
11+
}
12+
if _, ok, _ := db.GetPhase("acme", "", "network"); ok {
13+
t.Fatal("unrecorded phase should report ok=false")
14+
}
15+
16+
// Start → satisfied for fingerprint fp1.
17+
if err := db.StartPhase("acme", "", "network", "fp1"); err != nil {
18+
t.Fatal(err)
19+
}
20+
if err := db.SatisfyPhase("acme", "", "network"); err != nil {
21+
t.Fatal(err)
22+
}
23+
if ok, _ := db.PhaseSatisfied("acme", "", "network", "fp1"); !ok {
24+
t.Error("phase should be satisfied for the same fingerprint")
25+
}
26+
// A changed fingerprint re-arms the phase (skip check fails).
27+
if ok, _ := db.PhaseSatisfied("acme", "", "network", "fp2"); ok {
28+
t.Error("a changed fingerprint must NOT count as satisfied")
29+
}
30+
31+
p, ok, err := db.GetPhase("acme", "", "network")
32+
if err != nil || !ok {
33+
t.Fatalf("get: ok=%v err=%v", ok, err)
34+
}
35+
if p.Status != PhaseSatisfied || p.SatisfiedAt == "" || p.StartedAt == "" {
36+
t.Errorf("phase row = %+v", p)
37+
}
38+
}
39+
40+
func TestSagaPhaseRestartClearsState(t *testing.T) {
41+
db := openTestDB(t)
42+
_ = db.StartPhase("acme", "api", "provision", "fpA")
43+
_ = db.FailPhase("acme", "api", "provision", "boom")
44+
45+
p, _, _ := db.GetPhase("acme", "api", "provision")
46+
if p.Status != PhaseFailed || p.Error != "boom" {
47+
t.Fatalf("expected failed with error, got %+v", p)
48+
}
49+
// Re-start clears the prior error + satisfied_at.
50+
if err := db.StartPhase("acme", "api", "provision", "fpB"); err != nil {
51+
t.Fatal(err)
52+
}
53+
p, _, _ = db.GetPhase("acme", "api", "provision")
54+
if p.Status != PhaseStarted || p.Error != "" || p.Fingerprint != "fpB" {
55+
t.Errorf("restart did not reset state: %+v", p)
56+
}
57+
}
58+
59+
func TestSagaPhasesForAndClear(t *testing.T) {
60+
db := openTestDB(t)
61+
_ = db.StartPhase("acme", "", "network", "f")
62+
_ = db.SatisfyPhase("acme", "", "network")
63+
_ = db.StartPhase("acme", "api", "compose-up", "f")
64+
65+
got, err := db.PhasesFor("acme")
66+
if err != nil {
67+
t.Fatal(err)
68+
}
69+
if len(got) != 2 {
70+
t.Fatalf("phases = %d, want 2", len(got))
71+
}
72+
73+
if err := db.ClearPhase("acme", "", "network"); err != nil {
74+
t.Fatal(err)
75+
}
76+
if got, _ := db.PhasesFor("acme"); len(got) != 1 {
77+
t.Fatalf("phases after clear = %d, want 1", len(got))
78+
}
79+
}
80+
81+
func TestSchemaVersionIsTwo(t *testing.T) {
82+
db := openTestDB(t)
83+
v, err := db.SchemaVersion()
84+
if err != nil {
85+
t.Fatal(err)
86+
}
87+
if v != 2 {
88+
t.Errorf("schema version = %d, want 2 (saga_phase migration applied)", v)
89+
}
90+
}

0 commit comments

Comments
 (0)