console: the seeds sweep (Plan 8 Task 2)
StartSeedsPass, a claim worker every five minutes (spec §3.1, §5.6): snapshots listed before records (M2), records strictly, any listing error ends the pass; unreadable hosts and hosts two records hold are kept whole. Stale reserving --from (1h) and awaiting-upload/uploaded imports (7d) are abandoned by provision.SeedAbortStale, a CAS from the exact state read (M5); a snapshot is deleted when its own seed is placed, committed, aborted or rolled-back (M6), when another placed/committed seed superseded it, or when no record names it and it is over an hour old; rolled-back seeds are finished after the decisions. Off on a host-claim console and in every claim inventory (M3). finishSeed's sharing check reads strictly and counts an unreadable record as a sharer (M7).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+829
−14
cmd/console/main.go+5 −0internal/console/claim_invariant_fixtures_test.go+1 −0internal/console/claim_invariant_probes_worker_test.go+2 −1internal/console/claim_workers.go+1 −0internal/console/seedfinish.go+33 −13internal/console/seedfinish_test.go+21 −0internal/console/seedsweep.go+283 −0internal/console/seedsweep_test.go+405 −0internal/console/testdata/claim-routes.txt+1 −0internal/provision/seed.go+22 −0internal/provision/seed_test.go+55 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 4be3a03f7..61deef85c 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -463,6 +463,11 @@ func serve() { |
| // The run sweep (internal/console/runsweep.go): expires queued runs, |
| // ends lost ones, deletes a day-old run's objects. Same placement rule. |
| go srv.RunClaimWorker(context.Background(), "StartRunSweep") |
| + // The seeds sweep (internal/console/seedsweep.go, seeded-instances spec |
| + // §3.1, §5.6): abandons stale reservations and imports, finishes |
| + // rolled-back seeds, deletes snapshots no live seed needs. Same |
| + // placement rule as every pass above. |
| + go srv.RunClaimWorker(context.Background(), "StartSeedsPass") |
| // The relay's writer lease and its converging reconcile |
| // (internal/console/relayops.go). Same placement rule as every pass |
| // above, and it returns immediately when CARLOS_RELAY_REGIONS is empty — |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index ac91e0547..88cf378d8 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -1452,6 +1452,7 @@ var claimFixtures = map[string]claimFixture{ |
| "worker PublishMovesConsole": claimNonApp("the moves console record (control/moves-console.json): the platform's moves protocol and key fingerprint; no app, and nothing with the moves switch off"), |
| "worker ReportApexSquats": claimRead("logs apex squats found in the registry; writes nothing"), |
| "worker StartRunSweep": claimRead("off on a host-claim console, where every run route refuses: it logs that and writes nothing"), |
| + "worker StartSeedsPass": claimRead("off on a host-claim console, where every seed door and finishSeed refuse: it logs that and writes nothing"), |
| "worker StartAlertWatcher": claimNonApp("box alert state under alerts/boxes and operator mail"), |
| "worker StartEconomicsPass": claimNonApp("daily usage census, statements and bills: deployment and account economics"), |
| "worker StartGeoipJobs": claimNonApp("GeoIP orphan sweep, D1 expiry and feed status under control/geoip/: the platform database, no app"), |
diff --git a/internal/console/claim_invariant_probes_worker_test.go b/internal/console/claim_invariant_probes_worker_test.go| index 25e0c6b0c..6e01a384a 100644 |
| --- a/internal/console/claim_invariant_probes_worker_test.go |
| +++ b/internal/console/claim_invariant_probes_worker_test.go |
| @@ -74,7 +74,8 @@ var claimWorkerProbes = map[string]claimProbe{ |
| cx.addRoute(t, registry.Route{Host: "titogo.bab.oncarlos.com", Kind: "instance", Status: "ready", |
| App: "kass", Account: "bab", Addr: "127.0.0.1:9301"}) |
| }, "apex squat", "titogo.bab.oncarlos.com"), |
| - "worker StartRunSweep": claimLogWorkerProbe("StartRunSweep", nil, "run sweep", "host-claim console"), |
| + "worker StartRunSweep": claimLogWorkerProbe("StartRunSweep", nil, "run sweep", "host-claim console"), |
| + "worker StartSeedsPass": claimLogWorkerProbe("StartSeedsPass", nil, "seeds pass", "host-claim console"), |
| "worker LogPlacementDiscrepancies": claimLogWorkerProbe("LogPlacementDiscrepancies", claimRegionalApp, |
| msgPlacementDiscrepancy("drift.go.tito.io", "blog", "uk")), |
diff --git a/internal/console/claim_workers.go b/internal/console/claim_workers.go| index 89bedaca3..21983223d 100644 |
| --- a/internal/console/claim_workers.go |
| +++ b/internal/console/claim_workers.go |
| @@ -14,6 +14,7 @@ func (s *Server) registerClaimWorkers() { |
| "StartScanPass": s.StartScanPass, |
| "StartExpiryPass": s.StartExpiryPass, |
| "StartRunSweep": s.StartRunSweep, |
| + "StartSeedsPass": s.StartSeedsPass, |
| "StartRelay": s.StartRelay, |
| "StartMailEventPush": s.StartMailEventPush, |
| "StartDoorKeyResign": s.StartDoorKeyResign, |
diff --git a/internal/console/seedfinish.go b/internal/console/seedfinish.go| index 016350cfe..f1cea9c43 100644 |
| --- a/internal/console/seedfinish.go |
| +++ b/internal/console/seedfinish.go |
| @@ -12,6 +12,7 @@ import ( |
| "fmt" |
| "log" |
| "os" |
| + "slices" |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| @@ -29,8 +30,9 @@ import ( |
| // 1. The store of the record's environment, under the store's walk lock |
| // and re-read there, is torn down only while its Declared.Seed is |
| // seedID (preflight M9), and only when no other instance names that |
| -// environment (M10). A store with another seed, or none, is never |
| -// touched. |
| +// environment (M10), read strictly: a record of the app that will |
| +// not decode counts as one (Plan 8 M7). A store with another seed, or |
| +// none, is never touched. |
| // 2. The seed's snapshot, seeds/<host>/<id>/snapshot.db. |
| // 3. For a --from seed, every key under the host's replica prefix in the |
| // data bucket (M11). It is this seed's: the record is create-only and |
| @@ -125,10 +127,15 @@ func (s *Server) finishSeedStore(ctx context.Context, sqid, app, host, seedID, e |
| if store == nil || store.Declared == nil || store.Declared.Seed != seedID { |
| return nil |
| } |
| - other, err := s.otherInstanceInEnvironment(ctx, sqid, app, host, env) |
| + other, unread, err := s.otherInstanceInEnvironment(ctx, sqid, app, host, env) |
| if err != nil { |
| return err |
| } |
| + if unread { |
| + log.Printf("console: finish seed %s of %s/%s/%s: OPERATOR: the record of instance %s could not be read, so it may use environment %q, and the store seed %s made (bucket %s) is left standing; tear it down by hand if it is not wanted", |
| + seedID, sqid, app, host, other, env, seedID, storeBucketName(store)) |
| + return nil |
| + } |
| if other != "" { |
| log.Printf("console: finish seed %s of %s/%s/%s: OPERATOR: instance %s also uses environment %q, so the store seed %s made (bucket %s) is left standing; tear it down by hand if it is not wanted", |
| seedID, sqid, app, host, other, env, seedID, storeBucketName(store)) |
| @@ -147,23 +154,36 @@ func (s *Server) finishSeedStore(ctx context.Context, sqid, app, host, seedID, e |
| } |
| // otherInstanceInEnvironment names an instance of app other than host |
| -// whose record names env, or "" when there is none. A listing it cannot |
| -// complete is an error: without it the finish cannot tell whether the |
| -// store is shared. |
| -func (s *Server) otherInstanceInEnvironment(ctx context.Context, sqid, app, host, env string) (string, error) { |
| - recs, truncated, err := provision.ListRecords(ctx, s.cfg.Releases, sqid, app, instanceListMax) |
| +// whose record names env, or "" when there is none. It reads strictly |
| +// (provision.ListRecordsStrict, preflight M7): it runs unattended from the |
| +// seeds sweep, and its "none" tears a store down. A listing it cannot |
| +// complete, or one with more than instanceListMax records, is an error: |
| +// the finish cannot tell whether the store is shared, so the record stays |
| +// rolled-back for a later finish. A record that reads but will not decode, |
| +// or disagrees with its path, counts as sharing: it is named, with unread |
| +// true, since what it says about env is not known. |
| +func (s *Server) otherInstanceInEnvironment(ctx context.Context, sqid, app, host, env string) (other string, unread bool, err error) { |
| + recs, unreadable, err := provision.ListRecordsStrict(ctx, s.cfg.Releases, sqid, app, instanceListMax) |
| + if errors.Is(err, provision.ErrRecordsTruncated) { |
| + return "", false, fmt.Errorf("list the instances of %s/%s: more than %d, so the finish cannot tell who uses environment %q", sqid, app, instanceListMax, env) |
| + } |
| if err != nil { |
| - return "", fmt.Errorf("list the instances of %s/%s: %w", sqid, app, err) |
| + return "", false, fmt.Errorf("list the instances of %s/%s: %w", sqid, app, err) |
| } |
| - if truncated { |
| - return "", fmt.Errorf("list the instances of %s/%s: more than %d, so the finish cannot tell who uses environment %q", sqid, app, instanceListMax, env) |
| + if len(unreadable) > 0 { |
| + hosts := make([]string, 0, len(unreadable)) |
| + for h := range unreadable { |
| + hosts = append(hosts, h) |
| + } |
| + slices.Sort(hosts) |
| + return hosts[0], true, nil |
| } |
| for _, r := range recs { |
| if r.Host != host && r.Environment == env { |
| - return r.Host, nil |
| + return r.Host, false, nil |
| } |
| } |
| - return "", nil |
| + return "", false, nil |
| } |
| // seedReplicaBatch is how many replica keys are deleted between two |
diff --git a/internal/console/seedfinish_test.go b/internal/console/seedfinish_test.go| index b352416ac..8b1340cb7 100644 |
| --- a/internal/console/seedfinish_test.go |
| +++ b/internal/console/seedfinish_test.go |
| @@ -385,6 +385,27 @@ func TestFinishSeedSparesAStoreAnotherInstanceUses(t *testing.T) { |
| f.requireRecordGone(t, fromHost) |
| } |
| +// A record of the app that will not decode may name the environment: the |
| +// finish cannot know it does not, so the store is spared as for a sharer. |
| +// The sweep runs this finish unattended (Plan 8 M7). |
| +func TestFinishSeedSparesAStoreWhenARecordIsUnreadable(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| + if _, err := f.fx.rel.Put(context.Background(), provision.RecordKey("bab", fromApp, "other.shop.example.com"), []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.markedStore(t, id) |
| + key := objstore.KeyIn("bab", fromApp, "canary") |
| + before := f.rawKey(t, f.fx.rel, key) |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if after := f.rawKey(t, f.fx.rel, key); after != before { |
| + t.Fatalf("a store an unreadable record may use was touched:\n before %s\n after %s", before, after) |
| + } |
| +} |
| + |
| // failingRecordList fails every listing of the app's records. |
| type failingRecordList struct { |
| release.Store |
diff --git a/internal/console/seedsweep.go b/internal/console/seedsweep.go| new file mode 100644 |
| index 000000000..cc78d4ffb |
| --- /dev/null |
| +++ b/internal/console/seedsweep.go |
| @@ -0,0 +1,283 @@ |
| +// The seeds sweep (seeded-instances spec §3.1, §5.6): the console's |
| +// cleanup of abandoned seed state. It abandons, by a guarded CAS, a seed |
| +// stuck where no box will ever move it; it finishes a rolled-back seed; |
| +// and it deletes a snapshot under seeds/ that no live seed needs. It runs |
| +// unattended and deletes customer data, so every deletion rests on |
| +// positive knowledge: two complete listings, taken in the order that makes |
| +// "no record names it" exact, and a record set with no gap in it. |
| + |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log" |
| + "os" |
| + "sort" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| +) |
| + |
| +const ( |
| + seedsPassInterval = 5 * time.Minute |
| + // seedReserveTTL is how long a --from seed may stay reserving. The |
| + // door's copy is bounded at about 52 minutes (store walk, a 30-minute |
| + // restore, 60s, a 20-minute upload, the publish), so an hour is past |
| + // any live copy. |
| + seedReserveTTL = time.Hour |
| + // seedImportTTL is how long an import may wait for its upload, or for |
| + // the app to say its process is stopped. |
| + seedImportTTL = 7 * 24 * time.Hour |
| + // seedOrphanAge is how old a snapshot no record names must be before |
| + // it is deleted. |
| + seedOrphanAge = time.Hour |
| +) |
| + |
| +// The tests' seams, nil in production. seedsPassBetweenListings runs after |
| +// the snapshot listing and before the record listing. |
| +// seedsPassBeforeAbort runs inside each abandonment's mutation, after the |
| +// record is read and before it is written, with the call's number (1, then |
| +// 2 on SetRecordSeed's retry). |
| +var ( |
| + seedsPassBetweenListings func() |
| + seedsPassBeforeAbort func(call int) |
| +) |
| + |
| +// StartSeedsPass runs the seeds sweep now and every five minutes, until |
| +// ctx is done. A console with host claims armed runs none: its record |
| +// writes go through the claim, every seed door refuses it, and finishSeed |
| +// refuses it too. |
| +func (s *Server) StartSeedsPass(ctx context.Context) { |
| + if s.cfg.HostClaim { |
| + log.Printf("console: seeds pass: off on a host-claim console, where every seed door refuses") |
| + return |
| + } |
| + if s.cfg.Releases == nil || s.cfg.SeedData == nil { |
| + return |
| + } |
| + if _, ok := s.cfg.SeedData.(release.ModLister); !ok { |
| + log.Printf("console: seeds pass: the data bucket cannot list last-modified times, so this sweep deletes no unreferenced snapshot") |
| + } |
| + t := time.NewTicker(seedsPassInterval) |
| + defer t.Stop() |
| + for { |
| + s.seedsPass(ctx, time.Now()) |
| + select { |
| + case <-ctx.Done(): |
| + return |
| + case <-t.C: |
| + } |
| + } |
| +} |
| + |
| +// seedStale reports a seed stuck where no box moves it: a --from seed |
| +// reserving for over seedReserveTTL, or an import waiting for its upload or |
| +// its stop for over seedImportTTL. Both times are the console's: At is |
| +// stamped by the door that reserved or began the seed. |
| +func seedStale(seed *provision.SeedSource, now time.Time) bool { |
| + switch { |
| + case seed == nil: |
| + return false |
| + case seed.Kind == seeds.KindReplica && seed.State == seeds.StateReserving: |
| + return now.Sub(seed.At) > seedReserveTTL |
| + case seed.Import() && (seed.State == seeds.StateAwaitingUpload || seed.State == seeds.StateUploaded): |
| + return now.Sub(seed.At) > seedImportTTL |
| + } |
| + return false |
| +} |
| + |
| +// seedsPass is one sweep. In order: |
| +// |
| +// 1. The snapshots under seeds/, with their last-modified times. Listed |
| +// first: every door writes its record before its snapshot (spec §4 |
| +// step 7, §5.1), so a snapshot in this listing whose seed is live has |
| +// its record in the next one. A seed begun after this listing has no |
| +// snapshot in it, so nothing below can reach it. |
| +// 2. Every record, strictly. Any listing error, here or in step 1, ends |
| +// the pass: nothing is decided on a partial set. A host with a record |
| +// that would not decode is unreadable; a host two records hold is |
| +// ambiguous. Nothing of either is touched: no snapshot deleted, no seed |
| +// abandoned or finished. |
| +// 3. The decisions, one per snapshot, from the two listings alone. |
| +// 4. Each stale seed abandoned by a CAS from the exact state read |
| +// (provision.SeedAbortStale); the box rolls back its part. |
| +// 5. The snapshots deleted: those decided in step 3, and each abandoned |
| +// seed's own once its CAS has landed. |
| +// 6. Each rolled-back seed finished (finishSeed re-reads its record). |
| +// |
| +// Every delete names one exact seeds/<host>/<id>/snapshot.db, so it never |
| +// reaches another seed's. |
| +func (s *Server) seedsPass(ctx context.Context, now time.Time) { |
| + if s.cfg.HostClaim || s.cfg.Releases == nil || s.cfg.SeedData == nil { |
| + return |
| + } |
| + var snaps []release.KeyMod |
| + ml, walk := s.cfg.SeedData.(release.ModLister) |
| + if walk { |
| + var err error |
| + if snaps, err = ml.ListMod(ctx, seeds.Prefix); err != nil { |
| + log.Printf("console: seeds pass: list snapshots: %v (nothing swept this pass)", err) |
| + return |
| + } |
| + } |
| + if seedsPassBetweenListings != nil { |
| + seedsPassBetweenListings() |
| + } |
| + recs, unreadable, err := provision.ListAllRecordsStrict(ctx, s.cfg.Releases) |
| + if err != nil { |
| + log.Printf("console: seeds pass: list records: %v (nothing swept this pass)", err) |
| + return |
| + } |
| + byHost := map[string][]provision.Record{} |
| + for _, rec := range recs { |
| + byHost[rec.Host] = append(byHost[rec.Host], rec) |
| + } |
| + // held reports a host the pass must not touch, logging why once. |
| + logged := map[string]bool{} |
| + held := func(host string) bool { |
| + switch { |
| + case unreadable[host]: |
| + if !logged[host] { |
| + log.Printf("console: seeds pass: a record of %s could not be read; nothing of %s is swept this pass", host, host) |
| + } |
| + case len(byHost[host]) > 1: |
| + if !logged[host] { |
| + log.Printf("console: seeds pass: OPERATOR: %d records hold %s; nothing of %s is swept until one remains", len(byHost[host]), host, host) |
| + } |
| + default: |
| + return false |
| + } |
| + logged[host] = true |
| + return true |
| + } |
| + |
| + // Step 3: the decisions. |
| + var condemned []string |
| + waiting := map[[2]string]string{} // host, id -> key: deleted only if this pass abandons the seed |
| + for _, o := range snaps { |
| + host, id, ok := seeds.ParseSnapshotKey(o.Key) |
| + if !ok || held(host) { |
| + continue |
| + } |
| + var rec *provision.Record |
| + if rs := byHost[host]; len(rs) == 1 { |
| + rec = &rs[0] |
| + } |
| + switch snapshotDecision(rec, id, now, o.Mod) { |
| + case snapshotDelete: |
| + condemned = append(condemned, o.Key) |
| + case snapshotIfAbandoned: |
| + waiting[[2]string{host, id}] = o.Key |
| + } |
| + } |
| + |
| + // Step 4: the abandonments. |
| + for _, rec := range recs { |
| + if !seedStale(rec.Seed, now) || held(rec.Host) { |
| + continue |
| + } |
| + if s.abandonSeed(ctx, rec) { |
| + if key, ok := waiting[[2]string{rec.Host, rec.Seed.ID}]; ok { |
| + condemned = append(condemned, key) |
| + } |
| + } |
| + } |
| + |
| + // Step 5: the snapshot deletes. |
| + sort.Strings(condemned) |
| + for _, key := range condemned { |
| + if err := s.cfg.SeedData.Delete(ctx, key); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("console: seeds pass: delete %s: %v", key, err) |
| + continue |
| + } |
| + log.Printf("console: seeds pass: deleted %s", key) |
| + } |
| + |
| + // Step 6: the finishes. |
| + for _, rec := range recs { |
| + if rec.Seed == nil || rec.Seed.State != seeds.StateRolledBack || held(rec.Host) { |
| + continue |
| + } |
| + if err := s.finishSeed(ctx, rec.Account, rec.App, rec.Host, rec.Seed.ID); err != nil { |
| + log.Printf("console: seeds pass: finish seed %s of %s: %v", rec.Seed.ID, rec.Host, err) |
| + } |
| + } |
| +} |
| + |
| +type snapshotVerdict int |
| + |
| +const ( |
| + snapshotKeep snapshotVerdict = iota |
| + snapshotDelete |
| + snapshotIfAbandoned // keep, unless this pass abandons the seed by CAS |
| +) |
| + |
| +// snapshotDecision decides one snapshot of seed id from the one record that |
| +// holds its host (nil for none), and the snapshot's last-modified time. Its |
| +// age is the console's clock less the bucket's stamp, so skew between |
| +// the two moves it; it only ever decides a snapshot no record names, which |
| +// the listing order has already shown is no live seed's, so the hour is a |
| +// margin and not the proof. |
| +// |
| +// - The record names id: deleted when the seed is placed or committed |
| +// (the host's own replica exists, so the box's delete was lost) or |
| +// aborted or rolled-back (terminal: nothing places it, and the box's |
| +// own rollback deletes the same key; this also covers a trashed app's |
| +// aborted seed, which no box finishes). A seed stale at now waits on |
| +// its abandonment. Any other state is a seed still wanted: kept. |
| +// - The record names another seed, placed or committed: that seed |
| +// superseded this one (spec §3.1). |
| +// - Otherwise no record names it (spec §3.1): deleted once it is over |
| +// seedOrphanAge old. |
| +func snapshotDecision(rec *provision.Record, id string, now, mod time.Time) snapshotVerdict { |
| + if rec != nil && rec.Seed != nil { |
| + state := rec.Seed.State |
| + placed := state == seeds.StatePlaced || state == seeds.StateCommitted |
| + if rec.Seed.ID == id { |
| + switch { |
| + case placed || rec.Seed.Abandoned(): |
| + return snapshotDelete |
| + case seedStale(rec.Seed, now): |
| + return snapshotIfAbandoned |
| + } |
| + return snapshotKeep |
| + } |
| + if placed { |
| + return snapshotDelete |
| + } |
| + } |
| + if now.Sub(mod) > seedOrphanAge { |
| + return snapshotDelete |
| + } |
| + return snapshotKeep |
| +} |
| + |
| +// abandonSeed moves rec's stale seed to aborted by a CAS from exactly the |
| +// state the pass read, and answers whether that CAS landed. A seed that |
| +// moved on meanwhile (the door published, the upload landed, the app said |
| +// stopped) is left alone. |
| +func (s *Server) abandonSeed(ctx context.Context, rec provision.Record) bool { |
| + seed := *rec.Seed |
| + abort := provision.SeedAbortStale(seed.ID, seed.State, seed.At) |
| + call := 0 |
| + _, err := provision.SetRecordSeed(ctx, s.cfg.Releases, rec.Account, rec.App, rec.Host, func(r *provision.Record) error { |
| + call++ |
| + if seedsPassBeforeAbort != nil { |
| + seedsPassBeforeAbort(call) |
| + } |
| + return abort(r) |
| + }) |
| + if err != nil { |
| + if !errors.Is(err, provision.ErrSeedConflict) { |
| + log.Printf("console: seeds pass: abandon seed %s of %s: %v", seed.ID, rec.Host, err) |
| + } |
| + return false |
| + } |
| + log.Printf("console: seeds pass: abandoned seed %s of %s/%s/%s (%s since %s)", seed.ID, rec.Account, rec.App, rec.Host, |
| + seed.State, seed.At.UTC().Format(time.RFC3339)) |
| + s.bumpSerial(ctx) |
| + return true |
| +} |
diff --git a/internal/console/seedsweep_test.go b/internal/console/seedsweep_test.go| new file mode 100644 |
| index 000000000..8d3ce250e |
| --- /dev/null |
| +++ b/internal/console/seedsweep_test.go |
| @@ -0,0 +1,405 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| +) |
| + |
| +// sweepFixture is a console with a data bucket for seeds: a DirStore, so |
| +// a snapshot's last-modified time is its file's mtime and a test can age |
| +// it. Records are app shop's in the operator account. |
| +type sweepFixture struct { |
| + fx *consoleFixture |
| + data release.Store |
| + root string |
| +} |
| + |
| +const sweepApp = "shop" |
| + |
| +func newSweepFixture(t *testing.T) sweepFixture { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + root := t.TempDir() |
| + data := release.NewDirStore(root) |
| + fx.srv.cfg.SeedData = data |
| + return sweepFixture{fx: fx, data: data, root: root} |
| +} |
| + |
| +// seeded writes a record of shop on host whose seed is a fresh id, of |
| +// kind, in state, stamped at, and answers the id. |
| +func (f sweepFixture) seeded(t *testing.T, host, kind, state string, at time.Time) string { |
| + t.Helper() |
| + id := newSeedID(t) |
| + f.recordOf(t, sweepApp, host, &provision.SeedSource{ID: id, Kind: kind, State: state, By: "bob@example.com", At: at}) |
| + return id |
| +} |
| + |
| +// recordOf writes a record of app on host with seed (nil for none). |
| +func (f sweepFixture) recordOf(t *testing.T, app, host string, seed *provision.SeedSource) { |
| + t.Helper() |
| + rec := provision.Record{Schema: 1, Host: host, Slug: strings.Split(host, ".")[0], App: app, Account: release.OpsAccount, |
| + Channel: "stable", CreatedAt: time.Now().UTC(), Seed: seed} |
| + if err := provision.CreateRecord(context.Background(), f.fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// snapshot writes seeds/<host>/<id>/snapshot.db last modified age ago. |
| +func (f sweepFixture) snapshot(t *testing.T, host, id string, age time.Duration) string { |
| + t.Helper() |
| + key := seeds.SnapshotKey(host, id) |
| + if _, err := f.data.Put(context.Background(), key, []byte("db"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + // DirStore keeps a key at root/key, and its ListMod reports the file's |
| + // mtime. |
| + when := time.Now().Add(-age) |
| + if err := os.Chtimes(filepath.Join(f.root, filepath.FromSlash(key)), when, when); err != nil { |
| + t.Fatal(err) |
| + } |
| + return key |
| +} |
| + |
| +func (f sweepFixture) snapshotExists(t *testing.T, key string) bool { |
| + t.Helper() |
| + _, _, err := f.data.Get(context.Background(), key) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal(err) |
| + } |
| + return err == nil |
| +} |
| + |
| +// state is the seed state of shop's record on host, or "gone". |
| +func (f sweepFixture) state(t *testing.T, host string) string { |
| + t.Helper() |
| + rec, err := provision.GetRecord(context.Background(), f.fx.rel, release.OpsAccount, sweepApp, host) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + return "gone" |
| + case err != nil: |
| + t.Fatal(err) |
| + } |
| + return rec.Seed.State |
| +} |
| + |
| +func (f sweepFixture) pass(now time.Time) { f.fx.srv.seedsPass(context.Background(), now) } |
| + |
| +func TestSweepLeavesALiveReservation(t *testing.T) { |
| + f := newSweepFixture(t) |
| + now := time.Now() |
| + id := f.seeded(t, "canary.shop.example.com", seeds.KindReplica, seeds.StateReserving, now.Add(-10*time.Minute)) |
| + key := f.snapshot(t, "canary.shop.example.com", id, 10*time.Minute) |
| + f.pass(now) |
| + if got := f.state(t, "canary.shop.example.com"); got != seeds.StateReserving || !f.snapshotExists(t, key) { |
| + t.Fatalf("a live reservation was touched: %s, snapshot kept %v", got, f.snapshotExists(t, key)) |
| + } |
| + // Past the hour it is abandoned by CAS, and its snapshot goes with it. |
| + f.pass(now.Add(2 * time.Hour)) |
| + if got := f.state(t, "canary.shop.example.com"); got != seeds.StateAborted { |
| + t.Fatalf("a stale reservation = %s, want aborted", got) |
| + } |
| + if f.snapshotExists(t, key) { |
| + t.Fatal("the snapshot of a seed this pass abandoned survived") |
| + } |
| +} |
| + |
| +// A --from copy is bounded at about 52 minutes (30m restore, 60s, 20m |
| +// upload): a reservation younger than the hour is never abandoned. |
| +func TestSweepReservationThresholdOutlastsTheCopy(t *testing.T) { |
| + if seedReserveTTL <= 30*time.Minute+time.Minute+20*time.Minute { |
| + t.Fatalf("seedReserveTTL %s does not outlast the --from copy's bound", seedReserveTTL) |
| + } |
| + f := newSweepFixture(t) |
| + now := time.Now() |
| + f.seeded(t, "copy.shop.example.com", seeds.KindReplica, seeds.StateReserving, now.Add(-55*time.Minute)) |
| + f.pass(now) |
| + if got := f.state(t, "copy.shop.example.com"); got != seeds.StateReserving { |
| + t.Fatalf("a 55-minute reservation = %s, want reserving", got) |
| + } |
| +} |
| + |
| +func TestSweepAbandonsOnlyStaleImports(t *testing.T) { |
| + f := newSweepFixture(t) |
| + now := time.Now() |
| + old, young := now.Add(-8*24*time.Hour), now.Add(-6*24*time.Hour) |
| + f.seeded(t, "waiting.shop.example.com", seeds.KindImport, seeds.StateAwaitingUpload, old) |
| + f.seeded(t, "uploaded.shop.example.com", seeds.KindImport, seeds.StateUploaded, old) |
| + f.seeded(t, "recent.shop.example.com", seeds.KindImport, seeds.StateUploaded, young) |
| + f.seeded(t, "ready.shop.example.com", seeds.KindImport, seeds.StateReady, old) |
| + f.seeded(t, "refused.shop.example.com", seeds.KindImport, seeds.StateRefused, old) |
| + f.seeded(t, "fresh.shop.example.com", seeds.KindReplica, seeds.StateReady, old) |
| + f.pass(now) |
| + for host, want := range map[string]string{ |
| + "waiting.shop.example.com": seeds.StateAborted, |
| + "uploaded.shop.example.com": seeds.StateAborted, |
| + "recent.shop.example.com": seeds.StateUploaded, |
| + "ready.shop.example.com": seeds.StateReady, |
| + "refused.shop.example.com": seeds.StateRefused, |
| + "fresh.shop.example.com": seeds.StateReady, |
| + } { |
| + if got := f.state(t, host); got != want { |
| + t.Errorf("%s = %s, want %s", host, got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestSweepDeletesOnlyUnreferencedOrSupersededSnapshots(t *testing.T) { |
| + f := newSweepFixture(t) |
| + now := time.Now() |
| + keep, gone := map[string]string{}, map[string]string{} |
| + |
| + live := f.seeded(t, "a.shop.example.com", seeds.KindReplica, seeds.StateReady, now) |
| + keep["a snapshot a live record needs"] = f.snapshot(t, "a.shop.example.com", live, 3*time.Hour) |
| + gone["an orphan over an hour old beside a live seed"] = f.snapshot(t, "a.shop.example.com", newSeedID(t), 3*time.Hour) |
| + keep["a young unreferenced snapshot (its door may be between record and upload)"] = |
| + f.snapshot(t, "nobody.shop.example.com", newSeedID(t), 10*time.Minute) |
| + gone["an orphan with no record, over an hour old"] = f.snapshot(t, "left.shop.example.com", newSeedID(t), 3*time.Hour) |
| + |
| + f.seeded(t, "b.shop.example.com", seeds.KindReplica, seeds.StatePlaced, now) |
| + gone["a young snapshot superseded by a placed seed"] = f.snapshot(t, "b.shop.example.com", newSeedID(t), 5*time.Minute) |
| + f.seeded(t, "c.shop.example.com", seeds.KindImport, seeds.StateCommitted, now) |
| + gone["a young snapshot superseded by a committed seed"] = f.snapshot(t, "c.shop.example.com", newSeedID(t), 5*time.Minute) |
| + f.seeded(t, "d.shop.example.com", seeds.KindImport, seeds.StateAborted, now) |
| + keep["a young snapshot beside another seed that is not placed"] = f.snapshot(t, "d.shop.example.com", newSeedID(t), 5*time.Minute) |
| + |
| + own := func(host, kind, state string) string { |
| + return f.snapshot(t, host, f.seeded(t, host, kind, state, now), 5*time.Minute) |
| + } |
| + gone["its own placed seed's (the box's delete was lost)"] = own("e.shop.example.com", seeds.KindReplica, seeds.StatePlaced) |
| + gone["its own committed seed's"] = own("f.shop.example.com", seeds.KindImport, seeds.StateCommitted) |
| + gone["its own aborted seed's"] = own("g.shop.example.com", seeds.KindImport, seeds.StateAborted) |
| + keep["its own refused seed's"] = own("h.shop.example.com", seeds.KindImport, seeds.StateRefused) |
| + keep["its own swapped seed's"] = own("i.shop.example.com", seeds.KindImport, seeds.StateSwapped) |
| + keep["its own ready import's"] = own("j.shop.example.com", seeds.KindImport, seeds.StateReady) |
| + |
| + // Not a snapshot: nothing else under seeds/ is the sweep's. |
| + stray := seeds.SnapshotPrefix("k.shop.example.com", newSeedID(t)) + "other.db" |
| + if _, err := f.data.Put(context.Background(), stray, []byte("x"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + keep["an object that is not a snapshot"] = stray |
| + |
| + f.pass(now) |
| + for why, key := range keep { |
| + if !f.snapshotExists(t, key) { |
| + t.Errorf("deleted %s", why) |
| + } |
| + } |
| + for why, key := range gone { |
| + if f.snapshotExists(t, key) { |
| + t.Errorf("kept %s", why) |
| + } |
| + } |
| + if got := f.state(t, "g.shop.example.com"); got != seeds.StateAborted { |
| + t.Errorf("the aborted record = %s; the sweep deletes its snapshot and leaves the record to the box", got) |
| + } |
| +} |
| + |
| +func TestSweepKeepsSnapshotsOfAnUnreadableRecord(t *testing.T) { |
| + f := newSweepFixture(t) |
| + host := "a.shop.example.com" |
| + id := f.seeded(t, host, seeds.KindReplica, seeds.StatePlaced, time.Now()) |
| + key := f.snapshot(t, host, id, 3*time.Hour) |
| + orphan := f.snapshot(t, host, newSeedID(t), 3*time.Hour) |
| + rk := provision.RecordKey(release.OpsAccount, sweepApp, host) |
| + _, etag, err := f.fx.rel.Get(context.Background(), rk) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.fx.rel.Put(context.Background(), rk, []byte("{not json"), etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.pass(time.Now()) |
| + if !f.snapshotExists(t, key) || !f.snapshotExists(t, orphan) { |
| + t.Fatalf("an unreadable record's host lost a snapshot: own kept %v, other kept %v", f.snapshotExists(t, key), f.snapshotExists(t, orphan)) |
| + } |
| +} |
| + |
| +// Two records holding one host: neither decides for the other, so the |
| +// sweep keeps everything of the host and abandons nothing on it. |
| +func TestSweepKeepsAHostTwoRecordsHold(t *testing.T) { |
| + f := newSweepFixture(t) |
| + now := time.Now() |
| + host := "twice.shop.example.com" |
| + id := f.seeded(t, host, seeds.KindReplica, seeds.StateReserving, now.Add(-2*time.Hour)) |
| + f.recordOf(t, "blog", host, &provision.SeedSource{ID: newSeedID(t), Kind: seeds.KindReplica, State: seeds.StatePlaced, |
| + By: "bob@example.com", At: now}) |
| + own := f.snapshot(t, host, id, 3*time.Hour) |
| + orphan := f.snapshot(t, host, newSeedID(t), 3*time.Hour) |
| + f.pass(now) |
| + if !f.snapshotExists(t, own) || !f.snapshotExists(t, orphan) { |
| + t.Fatalf("a host two records hold lost a snapshot: own kept %v, other kept %v", f.snapshotExists(t, own), f.snapshotExists(t, orphan)) |
| + } |
| + if got := f.state(t, host); got != seeds.StateReserving { |
| + t.Fatalf("a host two records hold had its seed moved to %s", got) |
| + } |
| +} |
| + |
| +// The app stops the import between the abort's read and its write: the |
| +// abort's CAS loses, the retry finds the seed moved on and refuses, and |
| +// nothing is deleted. |
| +func TestSweepAbandonsOnlyByCAS(t *testing.T) { |
| + f := newSweepFixture(t) |
| + host := "imp.shop.example.com" |
| + old := time.Now().Add(-8 * 24 * time.Hour) |
| + id := f.seeded(t, host, seeds.KindImport, seeds.StateUploaded, old) |
| + key := f.snapshot(t, host, id, 8*24*time.Hour) |
| + var calls []int |
| + seedsPassBeforeAbort = func(call int) { |
| + calls = append(calls, call) |
| + if call != 1 { |
| + return |
| + } |
| + if _, err := provision.SetRecordSeed(context.Background(), f.fx.rel, release.OpsAccount, sweepApp, host, provision.SeedStopped(id)); err != nil { |
| + t.Errorf("the app's stop: %v", err) |
| + } |
| + } |
| + t.Cleanup(func() { seedsPassBeforeAbort = nil }) |
| + f.pass(time.Now()) |
| + if len(calls) != 2 { |
| + t.Fatalf("the abort's mutation ran %d times, want 2 (a write that lost, then a refused retry)", len(calls)) |
| + } |
| + if got := f.state(t, host); got != seeds.StateReady || !f.snapshotExists(t, key) { |
| + t.Fatalf("state %s, snapshot kept %v; want the app's stop to win and nothing deleted", got, f.snapshotExists(t, key)) |
| + } |
| +} |
| + |
| +func TestSweepFinishesARolledBackSeed(t *testing.T) { |
| + f := newSweepFixture(t) |
| + host := "gone.shop.example.com" |
| + id := f.seeded(t, host, seeds.KindImport, seeds.StateRolledBack, time.Now()) |
| + key := f.snapshot(t, host, id, time.Minute) |
| + f.pass(time.Now()) |
| + if got := f.state(t, host); got != "gone" { |
| + t.Fatalf("a rolled-back record after the sweep: %s", got) |
| + } |
| + if f.snapshotExists(t, key) { |
| + t.Fatal("a finished seed's snapshot survived") |
| + } |
| +} |
| + |
| +// The host is deleted and seeded again between the two listings. Listed |
| +// snapshots first, the new seed's snapshot is not in the listing, so the |
| +// placed record of the old seed cannot condemn it (the doors write the |
| +// record before the snapshot). |
| +func TestSweepListsSnapshotsBeforeRecords(t *testing.T) { |
| + f := newSweepFixture(t) |
| + now := time.Now() |
| + host := "again.shop.example.com" |
| + f.seeded(t, host, seeds.KindReplica, seeds.StatePlaced, now.Add(-time.Hour)) |
| + var fresh string |
| + seedsPassBetweenListings = func() { |
| + if err := f.fx.rel.Delete(context.Background(), provision.RecordKey(release.OpsAccount, sweepApp, host)); err != nil { |
| + t.Error(err) |
| + } |
| + id := f.seeded(t, host, seeds.KindReplica, seeds.StateReady, now) |
| + fresh = f.snapshot(t, host, id, time.Minute) |
| + } |
| + t.Cleanup(func() { seedsPassBetweenListings = nil }) |
| + f.pass(now) |
| + if fresh == "" { |
| + t.Fatal("the seam never ran") |
| + } |
| + if !f.snapshotExists(t, fresh) { |
| + t.Fatal("the new seed's snapshot was deleted as superseded") |
| + } |
| +} |
| + |
| +// failingAccountList fails the record walk's first listing. |
| +type failingAccountList struct{ release.Store } |
| + |
| +func (s failingAccountList) List(ctx context.Context, prefix string) ([]string, error) { |
| + if prefix == "accounts/" { |
| + return nil, errors.New("the bucket is unreachable") |
| + } |
| + return s.Store.List(ctx, prefix) |
| +} |
| + |
| +// failingModList is a data bucket whose listing fails. |
| +type failingModList struct{ release.Store } |
| + |
| +func (failingModList) ListMod(context.Context, string) ([]release.KeyMod, error) { |
| + return nil, errors.New("the bucket is unreachable") |
| +} |
| + |
| +// A listing that fails is no knowledge: the pass deletes nothing and |
| +// abandons nothing. |
| +func TestSweepDecidesNothingOnAFailedListing(t *testing.T) { |
| + for name, broke := range map[string]func(f sweepFixture){ |
| + "records": func(f sweepFixture) { f.fx.srv.cfg.Releases = failingAccountList{f.fx.rel} }, |
| + "snapshots": func(f sweepFixture) { f.fx.srv.cfg.SeedData = failingModList{f.data} }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + f := newSweepFixture(t) |
| + now := time.Now() |
| + stale := f.seeded(t, "stale.shop.example.com", seeds.KindReplica, seeds.StateReserving, now.Add(-2*time.Hour)) |
| + staleKey := f.snapshot(t, "stale.shop.example.com", stale, 2*time.Hour) |
| + orphan := f.snapshot(t, "left.shop.example.com", newSeedID(t), 3*time.Hour) |
| + f.seeded(t, "done.shop.example.com", seeds.KindImport, seeds.StateRolledBack, now) |
| + broke(f) |
| + f.pass(now) |
| + if got := f.state(t, "stale.shop.example.com"); got != seeds.StateReserving { |
| + t.Errorf("a stale reservation = %s after a failed listing", got) |
| + } |
| + if got := f.state(t, "done.shop.example.com"); got != seeds.StateRolledBack { |
| + t.Errorf("a rolled-back record = %s after a failed listing", got) |
| + } |
| + if !f.snapshotExists(t, staleKey) || !f.snapshotExists(t, orphan) { |
| + t.Error("a snapshot was deleted after a failed listing") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// noModList is a data bucket that cannot list last-modified times. |
| +type noModList struct{ release.Store } |
| + |
| +// Without last-modified times no snapshot is judged, but stale seeds are |
| +// still abandoned and rolled-back ones finished. |
| +func TestSweepWithoutListModStillAbandonsAndFinishes(t *testing.T) { |
| + f := newSweepFixture(t) |
| + now := time.Now() |
| + f.seeded(t, "stale.shop.example.com", seeds.KindReplica, seeds.StateReserving, now.Add(-2*time.Hour)) |
| + f.seeded(t, "done.shop.example.com", seeds.KindImport, seeds.StateRolledBack, now) |
| + orphan := f.snapshot(t, "left.shop.example.com", newSeedID(t), 3*time.Hour) |
| + f.fx.srv.cfg.SeedData = noModList{f.data} |
| + f.pass(now) |
| + if got := f.state(t, "stale.shop.example.com"); got != seeds.StateAborted { |
| + t.Errorf("a stale reservation = %s, want aborted", got) |
| + } |
| + if got := f.state(t, "done.shop.example.com"); got != "gone" { |
| + t.Errorf("a rolled-back record = %s, want gone", got) |
| + } |
| + if !f.snapshotExists(t, orphan) { |
| + t.Error("an orphan was deleted with no last-modified time to judge it by") |
| + } |
| +} |
| + |
| +// A console with host claims armed runs no sweep: its record writes go |
| +// through the claim, and every seed door refuses it. |
| +func TestSweepOffOnAHostClaimConsole(t *testing.T) { |
| + f := newSweepFixture(t) |
| + now := time.Now() |
| + f.seeded(t, "stale.shop.example.com", seeds.KindReplica, seeds.StateReserving, now.Add(-2*time.Hour)) |
| + orphan := f.snapshot(t, "left.shop.example.com", newSeedID(t), 3*time.Hour) |
| + f.fx.srv.cfg.HostClaim = true |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + f.fx.srv.StartSeedsPass(ctx) // returns at once |
| + if ctx.Err() != nil { |
| + t.Fatal("StartSeedsPass ran on a host-claim console") |
| + } |
| + f.pass(now) |
| + if got := f.state(t, "stale.shop.example.com"); got != seeds.StateReserving || !f.snapshotExists(t, orphan) { |
| + t.Fatalf("a host-claim console swept: %s, orphan kept %v", got, f.snapshotExists(t, orphan)) |
| + } |
| +} |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 881f2f38c..72b4654b7 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -265,3 +265,4 @@ worker StartMailEventPush |
| worker StartRelay |
| worker StartRunSweep |
| worker StartScanPass |
| +worker StartSeedsPass |
diff --git a/internal/provision/seed.go b/internal/provision/seed.go| index f0f82c7ca..5378a0733 100644 |
| --- a/internal/provision/seed.go |
| +++ b/internal/provision/seed.go |
| @@ -231,6 +231,28 @@ func SeedAbortUploadBecause(id, reason string) func(*Record) error { |
| } |
| } |
| +// SeedAbortStale is the console's seeds sweep abandoning a seed it judged |
| +// stale (spec §3.1, §5.6): SeedAbort, but only while the record's seed is |
| +// exactly what the sweep read, the same id in the same state since the same |
| +// At. The sweep judged staleness from that state and At, so a seed that |
| +// moved on in any way (published, uploaded, stopped, aborted by its own |
| +// door) is refused with ErrSeedConflict, and SetRecordSeed's retry after a |
| +// lost race never aborts a seed the sweep did not judge. Every state |
| +// SeedAbort refuses stays refused. |
| +func SeedAbortStale(id, state string, at time.Time) func(*Record) error { |
| + abort := SeedAbort(id) |
| + return func(rec *Record) error { |
| + if err := seedIn(rec, id, "", state); err != nil { |
| + return err |
| + } |
| + if !rec.Seed.At.Equal(at) { |
| + return fmt.Errorf("%w: seed %s was stamped again (at %s, read at %s)", ErrSeedConflict, id, |
| + rec.Seed.At.UTC().Format(time.RFC3339Nano), at.UTC().Format(time.RFC3339Nano)) |
| + } |
| + return abort(rec) |
| + } |
| +} |
| + |
| // 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 { |
diff --git a/internal/provision/seed_test.go b/internal/provision/seed_test.go| index c851712e4..3058d3ce1 100644 |
| --- a/internal/provision/seed_test.go |
| +++ b/internal/provision/seed_test.go |
| @@ -436,3 +436,58 @@ func TestOrdinaryWritersCannotAddOrDropASeed(t *testing.T) { |
| t.Fatalf("after the refusal: seed = %+v, %v; want it kept", got.Seed, err) |
| } |
| } |
| + |
| +// The sweep's abort lands only on the seed exactly as the sweep read it: |
| +// the same id, in the same state, since the same At. A seed that moved on |
| +// (published, stopped) or was re-stamped is refused, and so is a state |
| +// SeedAbort itself refuses. |
| +func TestSeedAbortStaleOnlyFromWhatWasRead(t *testing.T) { |
| + ctx := context.Background() |
| + for _, c := range []struct{ kind, state string }{ |
| + {seeds.KindReplica, seeds.StateReserving}, |
| + {seeds.KindImport, seeds.StateAwaitingUpload}, |
| + {seeds.KindImport, seeds.StateUploaded}, |
| + } { |
| + s := release.NewDirStore(t.TempDir()) |
| + seed := testSeed(t, c.kind, c.state) |
| + rec := seedTestRecord(t, s, seed) |
| + got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortStale(seed.ID, c.state, seed.At)) |
| + if err != nil || got.Seed.State != seeds.StateAborted || got.Seed.Reason != "" { |
| + t.Fatalf("%s %s: %+v, %v", c.kind, c.state, got, err) |
| + } |
| + } |
| + |
| + s := release.NewDirStore(t.TempDir()) |
| + seed := testSeed(t, seeds.KindImport, seeds.StateUploaded) |
| + rec := seedTestRecord(t, s, seed) |
| + other, _ := seeds.NewID() |
| + for name, mut := range map[string]func(*Record) error{ |
| + "another id": SeedAbortStale(other, seeds.StateUploaded, seed.At), |
| + "another state": SeedAbortStale(seed.ID, seeds.StateAwaitingUpload, seed.At), |
| + "another moment": SeedAbortStale(seed.ID, seeds.StateUploaded, seed.At.Add(time.Second)), |
| + } { |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, mut); !errors.Is(err, ErrSeedConflict) { |
| + t.Fatalf("%s: err = %v, want ErrSeedConflict", name, err) |
| + } |
| + } |
| + // The app stops it: the seed moved on, and the abort read before that |
| + // is refused. |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedStopped(seed.ID)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortStale(seed.ID, seeds.StateUploaded, seed.At)); !errors.Is(err, ErrSeedConflict) { |
| + t.Fatalf("after the stop: err = %v, want ErrSeedConflict", err) |
| + } |
| + got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host) |
| + if err != nil || got.Seed.State != seeds.StateReady { |
| + t.Fatalf("after a refused stale abort: %+v, %v; want ready", got, err) |
| + } |
| + |
| + // A state SeedAbort refuses stays refused: a placed --from seed. |
| + s = release.NewDirStore(t.TempDir()) |
| + seed = testSeed(t, seeds.KindReplica, seeds.StatePlaced) |
| + rec = seedTestRecord(t, s, seed) |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortStale(seed.ID, seeds.StatePlaced, seed.At)); !errors.Is(err, ErrSeedConflict) { |
| + t.Fatalf("placed --from: err = %v, want ErrSeedConflict", err) |
| + } |
| +} |