provision: the seed transitions and the row column (Plan 2 Task 3)
SetRecordSeed and the ten seed transitions on updateRecordSeeding, each scoped to its seed id, kind and from-states; ErrSeedConflict; SeedColumn. An import's abort is refused while a run is open (RunOpenError). Carried from Task 2: guard tests for adding a seed to a seedless record and for claimRawUpdateRecord dropping one; the run-series tests use the seeds constants and the SeedKindImport/SeedCommitted aliases are gone; the two test workarounds now go through SetRecordSeed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+444
−32
internal/console/apiruns_test.go+2 −1internal/host/runexec_linux_test.go+8 −12internal/provision/run.go+0 −8internal/provision/run_test.go+28 −11internal/provision/seed.go+145 −0internal/provision/seed_test.go+261 −0
CI cancelled — superseded by a newer push — run details
diff --git a/internal/console/apiruns_test.go b/internal/console/apiruns_test.go| index 08ef18226..822f1f169 100644 |
| --- a/internal/console/apiruns_test.go |
| +++ b/internal/console/apiruns_test.go |
| @@ -19,6 +19,7 @@ import ( |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/runs" |
| + "github.com/carlosframework/platform/internal/seeds" |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| @@ -248,7 +249,7 @@ func TestAPIRunCreateRefusesInOrder(t *testing.T) { |
| t.Errorf("%q: %d %s", c.msg, status, body) |
| } |
| } |
| - putShopRecord(t, fx.consoleFixture, provision.Record{Host: "import.example.com", Seed: &provision.SeedSource{Kind: provision.SeedKindImport, State: "placed"}}) |
| + putShopRecord(t, fx.consoleFixture, provision.Record{Host: "import.example.com", Seed: &provision.SeedSource{Kind: seeds.KindImport, State: seeds.StatePlaced}}) |
| if status, body := postRun(t, fx.runsURL("import.example.com"), fx.member, okMeta(), nil); status != http.StatusConflict || errorOf(t, body) != "import.example.com is an import that is not committed yet. finish or abort the import first" { |
| t.Errorf("import: %d %s", status, body) |
| } |
diff --git a/internal/host/runexec_linux_test.go b/internal/host/runexec_linux_test.go| index 7e3082b7a..d7933bf02 100644 |
| --- a/internal/host/runexec_linux_test.go |
| +++ b/internal/host/runexec_linux_test.go |
| @@ -4,7 +4,6 @@ import ( |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| - "encoding/json" |
| "errors" |
| "io" |
| "os" |
| @@ -19,6 +18,7 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/runs" |
| + "github.com/carlosframework/platform/internal/seeds" |
| ) |
| type fakeHandle struct { |
| @@ -383,17 +383,13 @@ func TestRunRefusesAnIncarnationThatDoesNotMatch(t *testing.T) { |
| func TestRunRefusesAnImportUncommittedAtLaunch(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| rec, req := pickedRun(t, s, 60) |
| - cur, etag, err := provision.GetRecordETag(context.Background(), s, rec.Account, rec.App, rec.Host) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - cur.Seed = &provision.SeedSource{Kind: provision.SeedKindImport, State: "placed"} |
| - // A seed is written by a seed transition, not UpdateRecord: put the body. |
| - body, err := json.Marshal(cur) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - if _, err := s.Put(context.Background(), provision.RecordKey(cur.Account, cur.App, cur.Host), body, etag); err != nil { |
| + // Admission refuses an uncommitted import, so no walk of the seed |
| + // transitions reaches a picked run on one: the launch check is the |
| + // backstop. Give the record its seed through the seed writer directly. |
| + if _, err := provision.SetRecordSeed(context.Background(), s, rec.Account, rec.App, rec.Host, func(r *provision.Record) error { |
| + r.Seed = &provision.SeedSource{ID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: seeds.KindImport, State: seeds.StatePlaced} |
| + return nil |
| + }); err != nil { |
| t.Fatal(err) |
| } |
| if res := runWith(t, s, &fakeLauncher{handle: &fakeHandle{}}, rec, req, newRunHandle()); res.Refused != runs.RefusedImport { |
diff --git a/internal/provision/run.go b/internal/provision/run.go| index 4d146dcf9..b40a226e9 100644 |
| --- a/internal/provision/run.go |
| +++ b/internal/provision/run.go |
| @@ -7,7 +7,6 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/release" |
| - "github.com/carlosframework/platform/internal/seeds" |
| ) |
| // RunState is a `carlos run` on the instance record (run spec §5.1). Box |
| @@ -40,13 +39,6 @@ func (r *RunState) Open() bool { |
| return r != nil && (r.State == RunQueued || r.State == RunPicked || r.State == RunCancel) |
| } |
| -// The seed kind and state the run series reads; the values are the seeds |
| -// package's. |
| -const ( |
| - SeedKindImport = seeds.KindImport |
| - SeedCommitted = seeds.StateCommitted |
| -) |
| - |
| // ImportUncommitted reports whether the record's seed is an import that is |
| // not committed yet (seeded spec §5.4): a run is refused then, at the door |
| // and again at launch (run spec §5.1 step 9, codex R2-2). |
diff --git a/internal/provision/run_test.go b/internal/provision/run_test.go| index 7c53a995f..504cc499b 100644 |
| --- a/internal/provision/run_test.go |
| +++ b/internal/provision/run_test.go |
| @@ -9,6 +9,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| ) |
| func runTestRecord(t *testing.T, s release.Store) Record { |
| @@ -122,19 +123,35 @@ func TestRunAdmitRefusesAMoveAndAnUncommittedImport(t *testing.T) { |
| if _, err := setRun(t, s, rec, RunAdmit(RunAdmission{ID: "run_a", Deadline: at})); !errors.Is(err, ErrRunMoving) { |
| t.Fatalf("admit while moving: %v, want ErrRunMoving", err) |
| } |
| + // An import walks its path by the seed transitions; admission is tried |
| + // at each state on the way. |
| s2 := release.NewDirStore(t.TempDir()) |
| - imp := runTestRecord(t, s2) |
| - for state, refused := range map[string]bool{"swapped": true, "placed": true, "committed": false} { |
| - // Setting a seed is a seed transition's job; ordinary writers are refused. |
| - if _, err := updateRecordSeeding(context.Background(), s2, imp.Account, imp.App, imp.Host, func(r *Record) error { |
| - r.Seed, r.ActiveRun = &SeedSource{Kind: SeedKindImport, State: state}, nil |
| - return nil |
| - }, true); err != nil { |
| - t.Fatal(err) |
| + seedID, err := seeds.NewID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + imp := Record{Schema: 1, Host: "a.go.example.com", Slug: "s", App: "app", Account: "bab", Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC(), |
| + Seed: &SeedSource{ID: seedID, Kind: seeds.KindImport, State: seeds.StateAwaitingUpload}} |
| + if err := CreateRecord(context.Background(), s2, imp); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, step := range []struct { |
| + mut func(*Record) error |
| + state string |
| + refused bool |
| + }{ |
| + {SeedUploaded(seedID), seeds.StateUploaded, true}, |
| + {SeedStopped(seedID), seeds.StateReady, true}, |
| + {SeedSwapped(seedID), seeds.StateSwapped, true}, |
| + {SeedPlace(seedID), seeds.StatePlaced, true}, |
| + {SeedCommit(seedID), seeds.StateCommitted, false}, |
| + } { |
| + if _, err := SetRecordSeed(context.Background(), s2, imp.Account, imp.App, imp.Host, step.mut); err != nil { |
| + t.Fatalf("to %s: %v", step.state, err) |
| } |
| - _, err := setRun(t, s2, imp, RunAdmit(RunAdmission{ID: "run_" + state, Deadline: at})) |
| - if refused != errors.Is(err, ErrRunImport) { |
| - t.Errorf("import %s: err %v, refused want %v (codex R2-2)", state, err, refused) |
| + _, err := setRun(t, s2, imp, RunAdmit(RunAdmission{ID: "run_" + step.state, Deadline: at})) |
| + if step.refused != errors.Is(err, ErrRunImport) { |
| + t.Errorf("import %s: err %v, refused want %v (codex R2-2)", step.state, err, step.refused) |
| } |
| } |
| } |
diff --git a/internal/provision/seed.go b/internal/provision/seed.go| index 64febc2b4..084b04f12 100644 |
| --- a/internal/provision/seed.go |
| +++ b/internal/provision/seed.go |
| @@ -1,9 +1,13 @@ |
| package provision |
| import ( |
| + "context" |
| "errors" |
| + "fmt" |
| + "slices" |
| "time" |
| + "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/seeds" |
| ) |
| @@ -84,3 +88,144 @@ func seedEqual(a, b *SeedSource) bool { |
| x.At, y.At = time.Time{}, time.Time{} |
| return x == y |
| } |
| + |
| +// ErrSeedConflict is a seed transition the record's seed does not allow: |
| +// another seed's id, the wrong kind, or the wrong state. It is the answer, |
| +// not a race (a race is retried once by the scaffold, which re-reads and |
| +// re-applies the transition). |
| +var ErrSeedConflict = errors.New("provision: seed transition refused") |
| + |
| +// SetRecordSeed applies one seed transition to the record by CAS on its |
| +// etag, retrying once on a lost race (updateRecord's scaffold). It is the |
| +// only writer of Record.Seed after creation. |
| +func SetRecordSeed(ctx context.Context, s release.Store, acct, app, host string, mut func(*Record) error) (*Record, error) { |
| + return updateRecordSeeding(ctx, s, acct, app, host, mut, true) |
| +} |
| + |
| +// seedIn checks one row of the transition table: the record's seed must be |
| +// id, of kind (any kind when ""), in one of from. |
| +func seedIn(rec *Record, id, kind string, from ...string) error { |
| + switch { |
| + case rec.Seed == nil || rec.Seed.ID != id: |
| + return fmt.Errorf("%w: the record's seed is not %s", ErrSeedConflict, id) |
| + case kind != "" && rec.Seed.Kind != kind: |
| + return fmt.Errorf("%w: seed %s is a %s seed", ErrSeedConflict, id, rec.Seed.Kind) |
| + case !slices.Contains(from, rec.Seed.State): |
| + return fmt.Errorf("%w: seed %s is %s", ErrSeedConflict, id, rec.Seed.State) |
| + } |
| + return nil |
| +} |
| + |
| +// seedStep is a transition that is one row of the table: checked by |
| +// seedIn, then the seed moves to to and also (when set) records the rest. |
| +func seedStep(id, kind string, from []string, to string, also func(*SeedSource)) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := seedIn(rec, id, kind, from...); err != nil { |
| + return err |
| + } |
| + rec.Seed.State = to |
| + if also != nil { |
| + also(rec.Seed) |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// SeedPublishReplica is --from's publish (spec §4 step 7.3): reserving to |
| +// ready, with what the restore actually produced. |
| +func SeedPublishReplica(id, txid, sha string, bytes int64) func(*Record) error { |
| + return seedStep(id, seeds.KindReplica, []string{seeds.StateReserving}, seeds.StateReady, func(s *SeedSource) { |
| + s.TXID, s.SHA256, s.Bytes = txid, sha, bytes |
| + }) |
| +} |
| + |
| +// SeedUploaded is the upload's last step (spec §5.2 step 5). |
| +func SeedUploaded(id string) func(*Record) error { |
| + return seedStep(id, seeds.KindImport, []string{seeds.StateAwaitingUpload}, seeds.StateUploaded, nil) |
| +} |
| + |
| +// SeedStopped is the app saying its own process is down (spec §5.3). |
| +func SeedStopped(id string) func(*Record) error { |
| + return seedStep(id, seeds.KindImport, []string{seeds.StateUploaded}, seeds.StateReady, nil) |
| +} |
| + |
| +// SeedSwapped is the box's CAS after its swap transaction (spec §5.4). |
| +func SeedSwapped(id string) func(*Record) error { |
| + return seedStep(id, seeds.KindImport, []string{seeds.StateReady}, seeds.StateSwapped, nil) |
| +} |
| + |
| +// SeedPlace is the box's CAS after the first upload of the host's own |
| +// replica (spec §3.4 step 5): a --from seed from ready, an import from |
| +// swapped. An import is never placed from ready: that would skip its swap. |
| +func SeedPlace(id string) func(*Record) error { |
| + return func(rec *Record) error { |
| + from := seeds.StateReady |
| + if rec.Seed.Import() { |
| + from = seeds.StateSwapped |
| + } |
| + return seedStep(id, "", []string{from}, seeds.StatePlaced, nil)(rec) |
| + } |
| +} |
| + |
| +// SeedCommit is the app's verdict that the instance serves (spec §5.4): |
| +// refused before placed, because nothing has been verified yet. |
| +func SeedCommit(id string) func(*Record) error { |
| + return seedStep(id, seeds.KindImport, []string{seeds.StatePlaced}, seeds.StateCommitted, nil) |
| +} |
| + |
| +// SeedRefuse is the box declining the swap, naming why (spec §5.4). |
| +func SeedRefuse(id, reason string) func(*Record) error { |
| + return seedStep(id, seeds.KindImport, []string{seeds.StateReady}, seeds.StateRefused, func(s *SeedSource) { |
| + s.Reason = reason |
| + }) |
| +} |
| + |
| +// SeedAbort abandons a seed (spec §5.6, §4 step 7): an import from any |
| +// state before committed, a --from seed while reserving or ready. |
| +// |
| +// An import's abort is also refused while a run is open on the instance |
| +// (spec §5.6, run spec §5.1), although run admission already refuses an |
| +// uncommitted import: a RunOpenError, so errors.Is(err, ErrRunOpen). Both |
| +// specs scope that rule to the import; a --from seed's abort is its door's |
| +// own failure path, and a run admitted on the reserved record must not |
| +// leave it stuck reserving. |
| +func SeedAbort(id string) func(*Record) error { |
| + return func(rec *Record) error { |
| + from := []string{seeds.StateReserving, seeds.StateReady} |
| + if rec.Seed.Import() { |
| + from = []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateReady, |
| + seeds.StateSwapped, seeds.StatePlaced, seeds.StateRefused} |
| + } |
| + if err := seedIn(rec, id, "", from...); err != nil { |
| + return err |
| + } |
| + if rec.Seed.Import() && rec.ActiveRun.Open() { |
| + return &RunOpenError{Run: *rec.ActiveRun} |
| + } |
| + rec.Seed.State = seeds.StateAborted |
| + return nil |
| + } |
| +} |
| + |
| +// SeedRolledBack is the box's last word on an aborted seed (spec §5.6, and |
| +// §3.2's no-journal path). The console finishes from here. |
| +func SeedRolledBack(id string) func(*Record) error { |
| + return seedStep(id, "", []string{seeds.StateAborted}, seeds.StateRolledBack, nil) |
| +} |
| + |
| +// SeedRolledBackBeforeSwap is the delete door's shortcut for an import no |
| +// box has swapped (spec §5.6, codex R2-7). |
| +func SeedRolledBackBeforeSwap(id string) func(*Record) error { |
| + return seedStep(id, seeds.KindImport, []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused}, |
| + seeds.StateRolledBack, nil) |
| +} |
| + |
| +// SeedColumn is the registry column a record's row carries (seed_source): |
| +// "" for a seedless record. |
| +func SeedColumn(rec Record) string { |
| + if rec.Seed == nil { |
| + return "" |
| + } |
| + return seeds.RowSeed{ID: rec.Seed.ID, Kind: rec.Seed.Kind, SHA256: rec.Seed.SHA256, |
| + Bytes: rec.Seed.Bytes, State: rec.Seed.State}.Encode() |
| +} |
diff --git a/internal/provision/seed_test.go b/internal/provision/seed_test.go| index 9aad56346..7d5c06798 100644 |
| --- a/internal/provision/seed_test.go |
| +++ b/internal/provision/seed_test.go |
| @@ -125,3 +125,264 @@ func TestSeedPredicates(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestSeedImportWalksItsPath(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + seed := testSeed(t, seeds.KindImport, seeds.StateAwaitingUpload) |
| + rec := seedTestRecord(t, s, seed) |
| + for _, step := range []struct { |
| + mut func(*Record) error |
| + want string |
| + }{ |
| + {SeedUploaded(seed.ID), seeds.StateUploaded}, |
| + {SeedStopped(seed.ID), seeds.StateReady}, |
| + {SeedSwapped(seed.ID), seeds.StateSwapped}, |
| + {SeedPlace(seed.ID), seeds.StatePlaced}, |
| + {SeedCommit(seed.ID), seeds.StateCommitted}, |
| + } { |
| + got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, step.mut) |
| + if err != nil || got.Seed.State != step.want { |
| + t.Fatalf("to %s: %+v, %v", step.want, got, err) |
| + } |
| + } |
| + // After commit, abort is refused (spec §5.6). |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); !errors.Is(err, ErrSeedConflict) { |
| + t.Fatalf("abort after commit: err = %v, want ErrSeedConflict", err) |
| + } |
| +} |
| + |
| +func TestSeedReplicaPublishesWhatWasRestored(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + seed := testSeed(t, seeds.KindReplica, seeds.StateReserving) |
| + seed.From = "acme.example.com" |
| + rec := seedTestRecord(t, s, seed) |
| + sha := strings.Repeat("c", 64) |
| + got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedPublishReplica(seed.ID, "4211", sha, 8192)) |
| + if err != nil || got.Seed.State != seeds.StateReady || got.Seed.TXID != "4211" || got.Seed.SHA256 != sha || got.Seed.Bytes != 8192 { |
| + t.Fatalf("publish: %+v, %v", got.Seed, err) |
| + } |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedCommit(seed.ID)); !errors.Is(err, ErrSeedConflict) { |
| + t.Fatalf("commit of a replica seed: err = %v, want ErrSeedConflict", err) |
| + } |
| +} |
| + |
| +func TestSeedPlaceFollowsTheKind(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + seed := testSeed(t, seeds.KindImport, seeds.StateReady) |
| + rec := seedTestRecord(t, s, seed) |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedPlace(seed.ID)); !errors.Is(err, ErrSeedConflict) { |
| + t.Fatalf("an import placed from ready: err = %v, want ErrSeedConflict", err) |
| + } |
| +} |
| + |
| +func TestSeedTransitionsAreScopedToTheirID(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + seed := testSeed(t, seeds.KindImport, seeds.StateAborted) |
| + rec := seedTestRecord(t, s, seed) |
| + other, _ := seeds.NewID() |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(other)); !errors.Is(err, ErrSeedConflict) { |
| + t.Fatalf("another id's rollback: err = %v, want ErrSeedConflict", err) |
| + } |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(seed.ID)); err != nil { |
| + t.Fatalf("its own rollback: %v", err) |
| + } |
| +} |
| + |
| +func TestSeedAbortAndRollbackBeforeSwap(t *testing.T) { |
| + ctx := context.Background() |
| + for _, state := range []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused} { |
| + s := release.NewDirStore(t.TempDir()) |
| + seed := testSeed(t, seeds.KindImport, state) |
| + rec := seedTestRecord(t, s, seed) |
| + got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBackBeforeSwap(seed.ID)) |
| + if err != nil || got.Seed.State != seeds.StateRolledBack { |
| + t.Fatalf("%s: %+v, %v", state, got, err) |
| + } |
| + } |
| + for _, state := range []string{seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced} { |
| + s := release.NewDirStore(t.TempDir()) |
| + seed := testSeed(t, seeds.KindImport, state) |
| + rec := seedTestRecord(t, s, seed) |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBackBeforeSwap(seed.ID)); !errors.Is(err, ErrSeedConflict) { |
| + t.Fatalf("%s straight to rolled-back: err = %v, want ErrSeedConflict", state, err) |
| + } |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); err != nil { |
| + t.Fatalf("%s abort: %v", state, err) |
| + } |
| + } |
| +} |
| + |
| +func TestSeedRefuseRecordsItsReason(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + seed := testSeed(t, seeds.KindImport, seeds.StateReady) |
| + rec := seedTestRecord(t, s, seed) |
| + got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRefuse(seed.ID, "the route belongs to app blog")) |
| + if err != nil || got.Seed.State != seeds.StateRefused || got.Seed.Reason != "the route belongs to app blog" { |
| + t.Fatalf("refuse: %+v, %v", got.Seed, err) |
| + } |
| +} |
| + |
| +func TestSeedColumn(t *testing.T) { |
| + if SeedColumn(Record{}) != "" { |
| + t.Error("a seedless record has a column value") |
| + } |
| + seed := testSeed(t, seeds.KindReplica, seeds.StateReady) |
| + seed.SHA256, seed.Bytes = strings.Repeat("d", 64), 12 |
| + got, err := seeds.ParseRowSeed(SeedColumn(Record{Seed: seed})) |
| + if err != nil || got == nil || got.ID != seed.ID || got.Kind != seeds.KindReplica || got.SHA256 != seed.SHA256 || got.Bytes != 12 || got.State != seeds.StateReady { |
| + t.Fatalf("column = %+v, %v", got, err) |
| + } |
| +} |
| + |
| +// The whole transition table (spec §3.2, §5.6), checked cell by cell: every |
| +// transition, for each kind, from every state, under its own id and under |
| +// another. A cell not in the table is ErrSeedConflict and leaves the seed |
| +// as it was. |
| +func TestSeedTransitionTable(t *testing.T) { |
| + all := []string{seeds.StateReserving, seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateReady, |
| + seeds.StateSwapped, seeds.StatePlaced, seeds.StateCommitted, seeds.StateRefused, seeds.StateAborted, seeds.StateRolledBack} |
| + type cell struct{ kind, from string } |
| + table := map[string]struct { |
| + mut func(id string) func(*Record) error |
| + to map[cell]string |
| + }{ |
| + "publish": {func(id string) func(*Record) error { return SeedPublishReplica(id, "1", strings.Repeat("e", 64), 1) }, |
| + map[cell]string{{seeds.KindReplica, seeds.StateReserving}: seeds.StateReady}}, |
| + "uploaded": {SeedUploaded, map[cell]string{{seeds.KindImport, seeds.StateAwaitingUpload}: seeds.StateUploaded}}, |
| + "stopped": {SeedStopped, map[cell]string{{seeds.KindImport, seeds.StateUploaded}: seeds.StateReady}}, |
| + "swapped": {SeedSwapped, map[cell]string{{seeds.KindImport, seeds.StateReady}: seeds.StateSwapped}}, |
| + "place": {SeedPlace, map[cell]string{ |
| + {seeds.KindReplica, seeds.StateReady}: seeds.StatePlaced, |
| + {seeds.KindImport, seeds.StateSwapped}: seeds.StatePlaced}}, |
| + "commit": {SeedCommit, map[cell]string{{seeds.KindImport, seeds.StatePlaced}: seeds.StateCommitted}}, |
| + "refuse": {func(id string) func(*Record) error { return SeedRefuse(id, "no") }, |
| + map[cell]string{{seeds.KindImport, seeds.StateReady}: seeds.StateRefused}}, |
| + "abort": {SeedAbort, map[cell]string{ |
| + {seeds.KindReplica, seeds.StateReserving}: seeds.StateAborted, |
| + {seeds.KindReplica, seeds.StateReady}: seeds.StateAborted, |
| + {seeds.KindImport, seeds.StateAwaitingUpload}: seeds.StateAborted, |
| + {seeds.KindImport, seeds.StateUploaded}: seeds.StateAborted, |
| + {seeds.KindImport, seeds.StateReady}: seeds.StateAborted, |
| + {seeds.KindImport, seeds.StateSwapped}: seeds.StateAborted, |
| + {seeds.KindImport, seeds.StatePlaced}: seeds.StateAborted, |
| + {seeds.KindImport, seeds.StateRefused}: seeds.StateAborted}}, |
| + "rolled back": {SeedRolledBack, map[cell]string{ |
| + {seeds.KindReplica, seeds.StateAborted}: seeds.StateRolledBack, |
| + {seeds.KindImport, seeds.StateAborted}: seeds.StateRolledBack}}, |
| + "rolled back before swap": {SeedRolledBackBeforeSwap, map[cell]string{ |
| + {seeds.KindImport, seeds.StateAwaitingUpload}: seeds.StateRolledBack, |
| + {seeds.KindImport, seeds.StateUploaded}: seeds.StateRolledBack, |
| + {seeds.KindImport, seeds.StateRefused}: seeds.StateRolledBack}}, |
| + } |
| + other, err := seeds.NewID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, tr := range table { |
| + for _, kind := range []string{seeds.KindReplica, seeds.KindImport} { |
| + for _, from := range all { |
| + seed := testSeed(t, kind, from) |
| + // Under another id, no cell moves. |
| + rec := Record{Seed: cloneSeed(seed)} |
| + if err := tr.mut(other)(&rec); !errors.Is(err, ErrSeedConflict) || !seedEqual(rec.Seed, seed) { |
| + t.Errorf("%s, %s %s, another id: err %v, seed %+v", name, kind, from, err, rec.Seed) |
| + } |
| + rec = Record{Seed: cloneSeed(seed)} |
| + err := tr.mut(seed.ID)(&rec) |
| + want, ok := tr.to[cell{kind, from}] |
| + switch { |
| + case ok && (err != nil || rec.Seed.State != want): |
| + t.Errorf("%s, %s %s: err %v, state %s; want %s", name, kind, from, err, rec.Seed.State, want) |
| + case !ok && (!errors.Is(err, ErrSeedConflict) || !seedEqual(rec.Seed, seed)): |
| + t.Errorf("%s, %s %s: err %v, seed %+v; want ErrSeedConflict and no change", name, kind, from, err, rec.Seed) |
| + } |
| + } |
| + } |
| + // A seedless record has nothing to move. |
| + var rec Record |
| + if err := tr.mut(other)(&rec); !errors.Is(err, ErrSeedConflict) || rec.Seed != nil { |
| + t.Errorf("%s on a seedless record: err %v, seed %+v", name, err, rec.Seed) |
| + } |
| + } |
| +} |
| + |
| +// An import's abort is refused while a run is open on the instance (spec |
| +// §5.6, run spec §5.1), although run admission already refuses an |
| +// uncommitted import. Once the run is terminal the abort goes through. |
| +func TestSeedAbortOfAnImportWaitsForAnOpenRun(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + seed := testSeed(t, seeds.KindImport, seeds.StatePlaced) |
| + rec := Record{ |
| + Schema: 1, Host: "canary.example.com", Slug: "canary", App: "shop", Account: "bae", |
| + Channel: "stable", CreatedAt: time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC), Seed: seed, |
| + ActiveRun: &RunState{ID: "run_a", State: RunQueued, Deadline: time.Now().Add(time.Minute)}, |
| + } |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); !errors.Is(err, ErrRunOpen) { |
| + t.Fatalf("abort with a run open: err = %v, want ErrRunOpen", err) |
| + } |
| + if got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || got.Seed.State != seeds.StatePlaced { |
| + t.Fatalf("after the refusal: %+v, %v; want the seed still placed", got, err) |
| + } |
| + if _, err := SetRecordRun(ctx, s, rec.Account, rec.App, rec.Host, RunExpire("run_a")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); err != nil || got.Seed.State != seeds.StateAborted { |
| + t.Fatalf("abort with the run expired: %+v, %v", got, err) |
| + } |
| +} |
| + |
| +// A --from seed's abort is the door's own failure path (spec §4 step 7): a |
| +// run admitted on the reserved record must not leave it stuck reserving. |
| +func TestSeedAbortOfAReplicaIgnoresAnOpenRun(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + seed := testSeed(t, seeds.KindReplica, seeds.StateReserving) |
| + rec := seedTestRecord(t, s, seed) |
| + if _, err := SetRecordRun(ctx, s, rec.Account, rec.App, rec.Host, RunAdmit(RunAdmission{ID: "run_a", Deadline: time.Now().Add(time.Minute)})); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); err != nil || got.Seed.State != seeds.StateAborted { |
| + t.Fatalf("abort: %+v, %v", got, err) |
| + } |
| +} |
| + |
| +// The guard's other two directions (Task 2): an ordinary writer cannot give |
| +// a seedless record a seed, and the etag-guarded writer cannot drop one. |
| +func TestOrdinaryWritersCannotAddOrDropASeed(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := seedTestRecord(t, s, nil) |
| + _, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { |
| + r.Seed = testSeed(t, seeds.KindImport, seeds.StateCommitted) |
| + return nil |
| + }) |
| + if !errors.Is(err, ErrSeedProtected) { |
| + t.Fatalf("adding a seed through updateRecord: err = %v, want ErrSeedProtected", err) |
| + } |
| + if got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || got.Seed != nil { |
| + t.Fatalf("after the refusal: seed = %+v, %v; want none", got.Seed, err) |
| + } |
| + |
| + s2 := release.NewDirStore(t.TempDir()) |
| + seeded := seedTestRecord(t, s2, testSeed(t, seeds.KindReplica, seeds.StatePlaced)) |
| + cur, etag, err := GetRecordETag(ctx, s2, seeded.Account, seeded.App, seeded.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cur.Seed = nil |
| + if err := claimRawUpdateRecord(ctx, s2, *cur, etag); !errors.Is(err, ErrSeedProtected) { |
| + t.Fatalf("claimRawUpdateRecord dropping the seed: err = %v, want ErrSeedProtected", err) |
| + } |
| + if got, err := GetRecord(ctx, s2, seeded.Account, seeded.App, seeded.Host); err != nil || got.Seed == nil || got.Seed.ID != seeded.Seed.ID { |
| + t.Fatalf("after the refusal: seed = %+v, %v; want it kept", got.Seed, err) |
| + } |
| +} |