seeds: an aborted --from's row goes before its rollback, and the finish fences its replica delete (Plan 6 Task 4 fix round 1)
Box (review I1a): reconcile no longer rolls back an aborted --from seed whose host still has a row on this box. It removes the row the way the removal pass removes a retired record's row (Provisioned, this pass's scope and kind, not a participant, through claimRetire), and a later pass that finds no row rolls the seed back. An import's row is the instance it replaces and is never removed here. Console (review I1b, I2): finishSeed deletes no replica key while the host's lease is held by lease.LockState's own rule (an expired lease does not block); re-reads the record before listing the replica and before every batch of deletes, stopping unless it still names this seed rolled-back at the same etag; and deletes the record on the last etag it verified. Also from the review: the data bucket is checked before any teardown, a claim-armed console refuses, and tests for the prefix boundary, the leftover-region skip, a truncated listing, the CAS retry, and two assertions that could not fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+623
−21
internal/console/seedfinish.go+99 −21internal/console/seedfinish_test.go+393 −0internal/provision/reconcile.go+8 −0internal/provision/seedreconcile.go+39 −0internal/provision/seedreconcile_test.go+84 −0
CI passed — run details
diff --git a/internal/console/seedfinish.go b/internal/console/seedfinish.go| index 22b173641..016350cfe 100644 |
| --- a/internal/console/seedfinish.go |
| +++ b/internal/console/seedfinish.go |
| @@ -12,8 +12,10 @@ import ( |
| "fmt" |
| "log" |
| "os" |
| + "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -34,9 +36,13 @@ import ( |
| // data bucket (M11). It is this seed's: the record is create-only and |
| // the create door's leftover-replica rail refused the host if anything |
| // was there, so only this seed's instance has written there since. |
| -// 4. The record, by CAS on the etag of the read that saw this seed in |
| -// rolled-back. It goes last because until then it holds the hostname, |
| -// so a re-import of the host never meets a leftover store. |
| +// That holds only while the record still names this seed, so the |
| +// record is re-read before the listing and before every batch of |
| +// deletes (review I2); and nothing is deleted while the host's lease is |
| +// held, because a row may still be writing (review I1). |
| +// 4. The record, by CAS on the etag of the last read that saw this seed |
| +// in rolled-back. It goes last because until then it holds the |
| +// hostname, so a re-import of the host never meets a leftover store. |
| // |
| // Every step is idempotent: a finish that fails part way answers an error |
| // with the record still rolled-back, and the next finish (the caller's |
| @@ -45,7 +51,14 @@ import ( |
| // storeTeardownTimeout. Callers that finish inline on a request's behalf |
| // pass context.WithoutCancel of it, so a departing client cannot cut a |
| // teardown midway. |
| +// |
| +// A console with host claims armed refuses: its record deletes go through |
| +// the claim (retireClaimHostRecord), and the seed doors refuse such a |
| +// console anyway (seedRegionReady). |
| func (s *Server) finishSeed(ctx context.Context, sqid, app, host, seedID string) error { |
| + if s.cfg.HostClaim { |
| + return fmt.Errorf("finish seed %s of %s: this console has host claims armed, and finishes no seed", seedID, host) |
| + } |
| if err := seeds.Check(host, seedID); err != nil { |
| return fmt.Errorf("finish seed %q of %q: %w", seedID, host, err) |
| } |
| @@ -69,19 +82,26 @@ func (s *Server) finishSeedOnce(ctx context.Context, sqid, app, host, seedID str |
| if rec.Seed == nil || rec.Seed.ID != seedID || rec.Seed.State != seeds.StateRolledBack { |
| return nil |
| } |
| - if err := s.finishSeedStore(ctx, sqid, app, host, seedID, rec.Environment); err != nil { |
| - return err |
| - } |
| + // Checked before anything is destroyed: without it the store would be |
| + // torn down and every later finish fail with the record holding the |
| + // host. |
| if s.cfg.SeedData == nil { |
| return fmt.Errorf("finish seed %s of %s: this console has no data bucket to delete its snapshot from", seedID, host) |
| } |
| + if err := s.finishSeedStore(ctx, sqid, app, host, seedID, rec.Environment); err != nil { |
| + return err |
| + } |
| if err := s.cfg.SeedData.Delete(ctx, seeds.SnapshotKey(host, seedID)); err != nil && !errors.Is(err, os.ErrNotExist) { |
| return fmt.Errorf("delete the snapshot of seed %s of %s: %w", seedID, host, err) |
| } |
| if rec.Seed.Kind == seeds.KindReplica { |
| - if err := s.deleteSeedReplica(ctx, rec); err != nil { |
| + var gone bool |
| + if etag, gone, err = s.deleteSeedReplica(ctx, rec, etag); err != nil { |
| return err |
| } |
| + if gone { |
| + return nil // another finish deleted the record |
| + } |
| } |
| if err := release.DeleteIfMatch(ctx, s.cfg.Releases, provision.RecordKey(sqid, app, host), etag); err != nil && !errors.Is(err, os.ErrNotExist) { |
| return fmt.Errorf("delete the record of %s: %w", host, err) |
| @@ -146,30 +166,88 @@ func (s *Server) otherInstanceInEnvironment(ctx context.Context, sqid, app, host |
| return "", nil |
| } |
| +// seedReplicaBatch is how many replica keys are deleted between two |
| +// re-reads of the record. |
| +const seedReplicaBatch = 100 |
| + |
| // deleteSeedReplica deletes every key under a --from seed's replica prefix |
| -// in the data bucket. The rail that makes this safe (createleftovers.go) |
| -// only checked a record this console can see; a record outside that |
| -// (another region, a pool, a fleet) was never checked, so its prefix here |
| -// is not known to be this seed's and is left alone. |
| -func (s *Server) deleteSeedReplica(ctx context.Context, rec *provision.Record) error { |
| +// in the data bucket, and answers the etag the record delete is to be |
| +// conditional on (the last read that saw this seed rolled-back), or gone |
| +// when the record was deleted meanwhile (another finish). |
| +// |
| +// The rail that makes the delete safe (createleftovers.go) only checked a |
| +// record this console can see; a record outside that (another region, a |
| +// pool, a fleet) was never checked, so its prefix here is not known to be |
| +// this seed's and is left alone. |
| +// |
| +// Nothing is deleted while the host's lease (lock.json under the prefix) is |
| +// held by the lease package's own rule (lease.LockState): a row this seed |
| +// started may still be running and writing there. A lease that is absent, |
| +// released or expired past its margin does not block, so a box that died |
| +// does not hold the host for ever. A held lease is an error, and the |
| +// record stays rolled-back for the next finish. |
| +func (s *Server) deleteSeedReplica(ctx context.Context, rec *provision.Record, etag string) (string, bool, error) { |
| + sqid, app, host, seedID := rec.Account, rec.App, rec.Host, rec.Seed.ID |
| if !s.leftoverCheckApplies(rec.Region, rec.Pool) { |
| - log.Printf("console: finish seed %s of %s: its replica is outside this console's data bucket; not deleting it", rec.Seed.ID, rec.Host) |
| - return nil |
| + log.Printf("console: finish seed %s of %s: its replica is outside this console's data bucket; not deleting it", seedID, host) |
| + return etag, false, nil |
| + } |
| + // The read before the teardown can be a minute old: the host may have |
| + // been finished and created again since, by a new instance that is |
| + // writing here now. |
| + etag, gone, err := s.seedRecordETag(ctx, sqid, app, host, seedID) |
| + if gone || err != nil { |
| + return "", gone, err |
| + } |
| + location := activator.HibernatePrefix(s.cfg.HibernatePrefix, host) |
| + held, owner, expires, err := lease.LockState(ctx, s.cfg.HibernateBucket, location, s.cfg.HibernateRegion) |
| + if err != nil { |
| + return "", false, fmt.Errorf("read the lease of %s: %w", host, err) |
| + } |
| + if held { |
| + return "", false, fmt.Errorf("finish seed %s of %s: its lease is held by %q until %s, so its replica is not deleted yet", |
| + seedID, host, owner, expires.UTC().Format(time.RFC3339)) |
| } |
| // The trailing slash keeps app.example.co from covering |
| // app.example.com's keys. |
| - prefix := activator.HibernatePrefix(s.cfg.HibernatePrefix, rec.Host) + "/" |
| + prefix := location + "/" |
| keys, err := s.cfg.SeedData.List(ctx, prefix) |
| if err != nil { |
| - return fmt.Errorf("list the replica of %s: %w", rec.Host, err) |
| + return "", false, fmt.Errorf("list the replica of %s: %w", host, err) |
| } |
| - for _, key := range keys { |
| - if err := s.cfg.SeedData.Delete(ctx, key); err != nil && !errors.Is(err, os.ErrNotExist) { |
| - return fmt.Errorf("delete the replica of %s: %w", rec.Host, err) |
| + for start := 0; start < len(keys); start += seedReplicaBatch { |
| + now, gone, err := s.seedRecordETag(ctx, sqid, app, host, seedID) |
| + if gone || err != nil { |
| + return "", gone, err |
| + } |
| + if now != etag { |
| + return "", false, fmt.Errorf("finish seed %s of %s: the record changed while its replica was being deleted", seedID, host) |
| + } |
| + for _, key := range keys[start:min(start+seedReplicaBatch, len(keys))] { |
| + if err := s.cfg.SeedData.Delete(ctx, key); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return "", false, fmt.Errorf("delete the replica of %s: %w", host, err) |
| + } |
| } |
| } |
| if len(keys) > 0 { |
| - log.Printf("console: finish seed %s of %s: deleted %d replica objects under %s", rec.Seed.ID, rec.Host, len(keys), prefix) |
| + log.Printf("console: finish seed %s of %s: deleted %d replica objects under %s", seedID, host, len(keys), prefix) |
| } |
| - return nil |
| + return etag, false, nil |
| +} |
| + |
| +// seedRecordETag re-reads host's record and answers its etag while it |
| +// still names seedID in rolled-back, or gone when there is no record. A |
| +// record that names anything else is an error, not "done": the finish only |
| +// says done when the seed's own record is gone. |
| +func (s *Server) seedRecordETag(ctx context.Context, sqid, app, host, seedID string) (etag string, gone bool, err error) { |
| + rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, sqid, app, host) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + return "", true, nil |
| + case err != nil: |
| + return "", false, fmt.Errorf("read the record of %s: %w", host, err) |
| + case rec.Seed == nil || rec.Seed.ID != seedID || rec.Seed.State != seeds.StateRolledBack: |
| + return "", false, fmt.Errorf("finish seed %s of %s: the record no longer names this seed rolled-back", seedID, host) |
| + } |
| + return etag, false, nil |
| } |
diff --git a/internal/console/seedfinish_test.go b/internal/console/seedfinish_test.go| index aae4a6342..b352416ac 100644 |
| --- a/internal/console/seedfinish_test.go |
| +++ b/internal/console/seedfinish_test.go |
| @@ -3,13 +3,18 @@ package console |
| import ( |
| "context" |
| "errors" |
| + "fmt" |
| + "net/http/httptest" |
| "os" |
| "path/filepath" |
| "slices" |
| + "strings" |
| "testing" |
| "time" |
| "github.com/carlosframework/platform/internal/dbdoor/dbdoortest" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -19,15 +24,57 @@ import ( |
| // finishFixture is the --from fixture with private storage on and one data |
| // bucket, as production has it: seeds/ and every instance's replica in the |
| // same store, so the leftover-replica rail and the finish see one bucket. |
| +// The host's lease is read by the lease package itself (lease.LockState), |
| +// so it is served from leasetest's S3 server; leases answers for it. |
| func finishFixture(t *testing.T) fromFixture { |
| t.Helper() |
| f := newFromFixture(t, 3) |
| f.data = release.NewDirStore(f.root) |
| f.fx.srv.cfg.SeedData = f.data |
| enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| + leases := leasetest.NewVersionedBucket() |
| + leases.AddBucket(f.fx.srv.cfg.HibernateBucket) |
| + handler, _ := leasetest.NewS3Server(leases) |
| + srv := httptest.NewServer(handler) |
| + t.Cleanup(srv.Close) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") |
| + t.Setenv("AWS_SESSION_TOKEN", "") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| + t.Setenv("AWS_CONFIG_FILE", t.TempDir()+"/absent") |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", t.TempDir()+"/absent") |
| + t.Setenv("CARLOS_S3_ENDPOINT", srv.URL) |
| + f.fx.srv.cfg.HibernateRegion = "test-region" |
| + finishLeases[f.fx.srv] = leases |
| + t.Cleanup(func() { delete(finishLeases, f.fx.srv) }) |
| return f |
| } |
| +// finishLeases is each finish fixture's lease bucket. |
| +var finishLeases = map[*Server]*leasetest.VersionedBucket{} |
| + |
| +// putLease writes host's lock.json as the litestream leaser does, expiring |
| +// at expires. |
| +func (f fromFixture) putLease(t *testing.T, host string, expires time.Time) { |
| + t.Helper() |
| + finishLeases[f.fx.srv].Put(f.fx.srv.cfg.HibernateBucket, "instances/"+host+"/lock.json", |
| + []byte(`{"generation":1,"expires_at":"`+expires.UTC().Format(time.RFC3339Nano)+`","owner":"box-a"}`)) |
| +} |
| + |
| +// putReplicaKeys writes n stand-in LTX objects under host's replica prefix. |
| +func (f fromFixture) putReplicaKeys(t *testing.T, host string, n int) []string { |
| + t.Helper() |
| + var keys []string |
| + for i := 1; i <= n; i++ { |
| + k := fmt.Sprintf("instances/%s/0000/%016x-%016x.ltx", host, i, i) |
| + if _, err := f.data.Put(context.Background(), k, []byte("x"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + keys = append(keys, k) |
| + } |
| + return keys |
| +} |
| + |
| // rollBack walks a seed the way a box does once it has been aborted: the |
| // abort, then the box's CAS to rolled-back. |
| func (f fromFixture) rollBack(t *testing.T, host, id string) { |
| @@ -344,6 +391,10 @@ type failingRecordList struct { |
| prefix string |
| } |
| +func (s failingRecordList) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| + return release.DeleteIfMatch(ctx, s.Store, key, etag) |
| +} |
| + |
| func (s failingRecordList) List(ctx context.Context, prefix string) ([]string, error) { |
| if prefix == s.prefix { |
| return nil, errors.New("the bucket is unreachable") |
| @@ -421,6 +472,9 @@ func TestFinishSeedChecksTheSeedBeforeAnyKey(t *testing.T) { |
| if _, err := f.data.Put(ctx, sentinel, []byte("someone else's"), ""); err != nil { |
| t.Fatal(err) |
| } |
| + // A rolled-back record carrying the bad id: without the check, the |
| + // finish would match it and delete the sentinel. |
| + f.seedRecord(t, fromHost, "canary", "sd_bad", seeds.KindImport, seeds.StateRolledBack) |
| for _, c := range []struct{ host, id string }{{fromHost, "sd_bad"}, {"../shop", newSeedID(t)}} { |
| if err := f.fx.srv.finishSeed(ctx, "bab", fromApp, c.host, c.id); !errors.Is(err, seeds.ErrBadID) && !errors.Is(err, seeds.ErrBadHost) { |
| t.Fatalf("finishSeed(%q, %q) = %v; want a seeds.Check error", c.host, c.id, err) |
| @@ -438,3 +492,342 @@ func TestFinishSeedWithNoRecord(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| + |
| +// rolledBackFrom writes a --from record rolled back on host, with replica |
| +// keys under its prefix, and answers its seed id and those keys. |
| +func (f fromFixture) rolledBackFrom(t *testing.T, host string) (string, []string) { |
| + t.Helper() |
| + id := newSeedID(t) |
| + f.seedRecord(t, host, "canary", id, seeds.KindReplica, seeds.StateRolledBack) |
| + return id, f.putReplicaKeys(t, host, 3) |
| +} |
| + |
| +// Review I1 (b): a lease that is present and unexpired by the lease |
| +// package's own rule blocks the replica step. Nothing is deleted, the |
| +// error is retryable and the record stays rolled-back. An absent lease, or |
| +// one expired past its margin (a box that died), does not block. |
| +func TestFinishSeedWaitsForTheHostsLease(t *testing.T) { |
| + // Expiries are relative to the moment each case writes its lease: the |
| + // margin is seconds, and earlier cases take most of one. |
| + for name, c := range map[string]struct { |
| + expiresIn *time.Duration |
| + blocked bool |
| + }{ |
| + "held": {expiresIn: ptr(time.Minute), blocked: true}, |
| + "just expired": {expiresIn: ptr(-lease.RenewMargin / 2), blocked: true}, |
| + "expired past margin": {expiresIn: ptr(-2 * lease.RenewMargin)}, |
| + "absent": {}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + f := finishFixture(t) |
| + id, keys := f.rolledBackFrom(t, fromHost) |
| + if c.expiresIn != nil { |
| + f.putLease(t, fromHost, time.Now().Add(*c.expiresIn)) |
| + } |
| + err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id) |
| + if !c.blocked { |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := replicaKeys(t, f, fromHost); len(got) != 0 { |
| + t.Fatalf("replica after the finish: %v", got) |
| + } |
| + f.requireRecordGone(t, fromHost) |
| + return |
| + } |
| + if err == nil { |
| + t.Fatal("a held lease answered nil") |
| + } |
| + if got := replicaKeys(t, f, fromHost); !slices.Equal(got, keys) { |
| + t.Fatalf("replica under a held lease:\n before %v\n after %v", keys, got) |
| + } |
| + if got, err := f.record(t, fromHost); err != nil || got.Seed.State != seeds.StateRolledBack { |
| + t.Fatalf("record under a held lease = %+v, %v; want it kept, rolled-back", got, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func ptr[T any](v T) *T { return &v } |
| + |
| +// recreatingData runs recreate once: before the first Delete whose key |
| +// onDelete matches, or after the first List whose prefix onList matches. |
| +type recreatingData struct { |
| + release.Store |
| + onDelete, onList func(key string) bool |
| + recreate func() |
| + done bool |
| +} |
| + |
| +func (d *recreatingData) fire(match func(string) bool, key string) { |
| + if !d.done && match != nil && match(key) { |
| + d.done = true |
| + d.recreate() |
| + } |
| +} |
| + |
| +func (d *recreatingData) Delete(ctx context.Context, key string) error { |
| + d.fire(d.onDelete, key) |
| + return d.Store.Delete(ctx, key) |
| +} |
| + |
| +func (d *recreatingData) List(ctx context.Context, prefix string) ([]string, error) { |
| + keys, err := d.Store.List(ctx, prefix) |
| + d.fire(d.onList, prefix) |
| + return keys, err |
| +} |
| + |
| +// recreateHost deletes host's record and writes a new, seedless instance |
| +// of it, as another finish and then a new create would. |
| +func (f fromFixture) recreateHost(t *testing.T, host string) func() { |
| + return func() { |
| + ctx := context.Background() |
| + if err := provision.DeleteRecord(ctx, f.fx.rel, "bab", fromApp, host); err != nil { |
| + t.Error(err) |
| + } |
| + if err := provision.CreateRecord(ctx, f.fx.rel, provision.Record{Schema: 1, Host: host, Slug: "canary", |
| + App: fromApp, Account: "bab", Channel: "stable", Environment: "canary", CreatedAt: time.Now().UTC()}); err != nil { |
| + t.Error(err) |
| + } |
| + } |
| +} |
| + |
| +// Review I2: the replica delete is fenced on the record. A record deleted |
| +// and created again for the same host, between the store teardown and the |
| +// replica step, or while the replica is listed, keeps every replica key: |
| +// they may be the new instance's. The finish answers an error (the |
| +// record it was finishing is not gone in the sense that matters: the host |
| +// has a record), and the new record is untouched. |
| +func TestFinishSeedNeverDeletesANewInstancesReplica(t *testing.T) { |
| + for name, wrap := range map[string]func(f fromFixture) *recreatingData{ |
| + "before the replica step": func(f fromFixture) *recreatingData { |
| + return &recreatingData{Store: f.data, recreate: f.recreateHost(t, fromHost), |
| + onDelete: func(key string) bool { return strings.HasPrefix(key, seeds.Prefix) }} |
| + }, |
| + "while it is listed": func(f fromFixture) *recreatingData { |
| + return &recreatingData{Store: f.data, recreate: f.recreateHost(t, fromHost), |
| + onList: func(prefix string) bool { return strings.HasPrefix(prefix, "instances/") }} |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + f := finishFixture(t) |
| + id, keys := f.rolledBackFrom(t, fromHost) |
| + d := wrap(f) |
| + f.fx.srv.cfg.SeedData = d |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err == nil { |
| + t.Fatal("a re-created host answered nil") |
| + } |
| + if !d.done { |
| + t.Fatal("the record was never re-created: the test did not reach its window") |
| + } |
| + if got := replicaKeys(t, f, fromHost); !slices.Equal(got, keys) { |
| + t.Fatalf("the new instance's replica:\n before %v\n after %v", keys, got) |
| + } |
| + if got, err := f.record(t, fromHost); err != nil || got.Seed != nil { |
| + t.Fatalf("the new record = %+v, %v; want it untouched", got, err) |
| + } |
| + // The sweep's next finish of the old seed leaves the new |
| + // instance alone and is done. |
| + f.fx.srv.cfg.SeedData = f.data |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := replicaKeys(t, f, fromHost); !slices.Equal(got, keys) { |
| + t.Fatalf("the retry touched the new instance's replica: %v", got) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A record rewritten in place while the replica is listed (an ordinary |
| +// writer, the same seed still rolled-back) stops the deletes too: the |
| +// finish only deletes under the record it read. The next finish resumes. |
| +func TestFinishSeedStopsWhenTheRecordChangesMidReplica(t *testing.T) { |
| + f := finishFixture(t) |
| + id, keys := f.rolledBackFrom(t, fromHost) |
| + key := provision.RecordKey("bab", fromApp, fromHost) |
| + f.fx.srv.cfg.SeedData = &recreatingData{Store: f.data, |
| + onList: func(prefix string) bool { return strings.HasPrefix(prefix, "instances/") }, |
| + recreate: func() { |
| + ctx := context.Background() |
| + raw, etag, err := f.fx.rel.Get(ctx, key) |
| + if err == nil { |
| + _, err = f.fx.rel.Put(ctx, key, append(raw, '\n'), etag) |
| + } |
| + if err != nil { |
| + t.Error(err) |
| + } |
| + }} |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err == nil { |
| + t.Fatal("a record changed mid-replica answered nil") |
| + } |
| + if got := replicaKeys(t, f, fromHost); !slices.Equal(got, keys) { |
| + t.Fatalf("replica after the record changed:\n before %v\n after %v", keys, got) |
| + } |
| + f.fx.srv.cfg.SeedData = f.data |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := replicaKeys(t, f, fromHost); len(got) != 0 { |
| + t.Fatalf("replica after the resumed finish: %v", got) |
| + } |
| + f.requireRecordGone(t, fromHost) |
| +} |
| + |
| +// A record deleted (not re-created) during the replica step was finished |
| +// by another finish: nil, and nothing more is deleted. |
| +func TestFinishSeedStopsWhenAnotherFinishDeletedTheRecord(t *testing.T) { |
| + f := finishFixture(t) |
| + id, keys := f.rolledBackFrom(t, fromHost) |
| + f.fx.srv.cfg.SeedData = &recreatingData{Store: f.data, |
| + onList: func(prefix string) bool { return strings.HasPrefix(prefix, "instances/") }, |
| + recreate: func() { |
| + if err := provision.DeleteRecord(context.Background(), f.fx.rel, "bab", fromApp, fromHost); err != nil { |
| + t.Error(err) |
| + } |
| + }} |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := replicaKeys(t, f, fromHost); !slices.Equal(got, keys) { |
| + t.Fatalf("replica after the record went: %v", got) |
| + } |
| +} |
| + |
| +// The prefix stops at the host: a host that extends this one's name keeps |
| +// its replica. |
| +func TestFinishSeedReplicaPrefixStopsAtTheHost(t *testing.T) { |
| + f := finishFixture(t) |
| + id, _ := f.rolledBackFrom(t, fromHost) |
| + sibling := f.putReplicaKeys(t, fromHost+".au", 2) |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := replicaKeys(t, f, fromHost); len(got) != 0 { |
| + t.Fatalf("replica after the finish: %v", got) |
| + } |
| + if got := replicaKeys(t, f, fromHost+".au"); !slices.Equal(got, sibling) { |
| + t.Fatalf("the sibling host's replica:\n before %v\n after %v", sibling, got) |
| + } |
| +} |
| + |
| +// A replica outside this console's data bucket (another region) is not |
| +// known to be the seed's, and is left alone; the record still goes. |
| +func TestFinishSeedLeavesAReplicaOutsideItsBucket(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + rec := provision.Record{Schema: 1, Host: fromHost, Slug: "canary", App: fromApp, Account: "bab", Channel: "stable", |
| + Environment: "canary", Region: "elsewhere", CreatedAt: time.Now().UTC(), |
| + Seed: &provision.SeedSource{ID: id, Kind: seeds.KindReplica, State: seeds.StateRolledBack, By: "bob@keymail.dev", At: time.Now().UTC()}} |
| + if err := provision.CreateRecord(context.Background(), f.fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + keys := f.putReplicaKeys(t, fromHost, 2) |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := replicaKeys(t, f, fromHost); !slices.Equal(got, keys) { |
| + t.Fatalf("a replica outside this bucket was touched: %v", got) |
| + } |
| + f.requireRecordGone(t, fromHost) |
| +} |
| + |
| +// More instances than one listing holds: the finish cannot tell whether |
| +// the store is shared, so it does nothing and answers an error. |
| +func TestFinishSeedRefusesATruncatedListing(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| + f.markedStore(t, id) |
| + for i := range instanceListMax { |
| + if err := provision.CreateRecord(context.Background(), f.fx.rel, provision.Record{Schema: 1, Host: fmt.Sprintf("i%d.shop.example.com", i), |
| + Slug: fmt.Sprintf("i%d", i), App: fromApp, Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err == nil { |
| + t.Fatal("a truncated listing answered nil") |
| + } |
| + if f.canaryStore(t) == nil { |
| + t.Fatal("the store went on a truncated listing") |
| + } |
| + if _, err := f.record(t, fromHost); err != nil { |
| + t.Fatalf("the record went on a truncated listing: %v", err) |
| + } |
| +} |
| + |
| +// touchOnDelete rewrites the record once (an ordinary writer's update) |
| +// just before the finish's conditional delete, so that delete loses. |
| +type touchOnDelete struct { |
| + release.Store |
| + key string |
| + touched bool |
| +} |
| + |
| +func (s *touchOnDelete) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| + if key == s.key && !s.touched { |
| + s.touched = true |
| + raw, cur, err := s.Store.Get(ctx, key) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Store.Put(ctx, key, append(raw, '\n'), cur); err != nil { |
| + return err |
| + } |
| + } |
| + return release.DeleteIfMatch(ctx, s.Store, key, etag) |
| +} |
| + |
| +// A record changed between the finish's read and its delete is read again |
| +// once, and the finish completes. |
| +func TestFinishSeedRetriesALostRecordDelete(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| + rel := &touchOnDelete{Store: f.fx.rel, key: provision.RecordKey("bab", fromApp, fromHost)} |
| + f.fx.srv.cfg.Releases = rel |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !rel.touched { |
| + t.Fatal("the record was never touched: the test did not reach its window") |
| + } |
| + f.requireRecordGone(t, fromHost) |
| +} |
| + |
| +// A console with no data bucket refuses before anything is destroyed: the |
| +// store this seed made still stands, with the record. |
| +func TestFinishSeedNeedsADataBucketBeforeTheTeardown(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| + f.markedStore(t, id) |
| + f.fx.srv.cfg.SeedData = nil |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err == nil { |
| + t.Fatal("no data bucket answered nil") |
| + } |
| + if f.canaryStore(t) == nil { |
| + t.Fatal("the store was torn down by a finish that could not complete") |
| + } |
| + if _, err := f.record(t, fromHost); err != nil { |
| + t.Fatalf("the record went: %v", err) |
| + } |
| +} |
| + |
| +// A console with host claims armed finishes no seed. |
| +func TestFinishSeedRefusesAClaimConsole(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| + f.markedStore(t, id) |
| + f.fx.srv.cfg.HostClaim = true |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err == nil { |
| + t.Fatal("a claim console answered nil") |
| + } |
| + if f.canaryStore(t) == nil { |
| + t.Fatal("a claim console tore the store down") |
| + } |
| + if _, err := f.record(t, fromHost); err != nil { |
| + t.Fatalf("a claim console deleted the record: %v", err) |
| + } |
| +} |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 87a9fee57..d8c5428a4 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -27,6 +27,7 @@ import ( |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| ) |
| // ReconcileConfig is everything one pass reads its world through — the same |
| @@ -1102,6 +1103,13 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| case seedSkip: |
| continue |
| case seedFinishAbort: |
| + // A --from seed this box minted is still running: its row |
| + // goes first, and a later pass that finds no row rolls the |
| + // seed back (seedreconcile.go). |
| + if ok && rec.Seed.Kind == seeds.KindReplica { |
| + retireAbortedSeedRow(ctx, reg, cfg, rec, existing) |
| + continue |
| + } |
| finishAbortedSeed(ctx, s, cfg, rec) |
| continue |
| } |
diff --git a/internal/provision/seedreconcile.go b/internal/provision/seedreconcile.go| index 4475952c9..ad41d2f2e 100644 |
| --- a/internal/provision/seedreconcile.go |
| +++ b/internal/provision/seedreconcile.go |
| @@ -6,6 +6,7 @@ import ( |
| "log" |
| "os" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/seeds" |
| ) |
| @@ -73,6 +74,44 @@ func seedAction(rec Record, hasRow bool, cfg ReconcileConfig, nested func() (boo |
| return seedSkip |
| } |
| +// retireAbortedSeedRow removes the row of an aborted --from seed that this |
| +// box minted (task-4 review I1). The seed can be aborted after its publish |
| +// landed (a lost publish answer), and then a box may have minted the row and |
| +// be running it, holding the host's lease and writing its replica. The |
| +// console's finish deletes that replica, so the row must stop first. The |
| +// record stays aborted this pass; a later pass that finds no row rolls the |
| +// seed back (finishAbortedSeed). |
| +// |
| +// It removes the row exactly as the removal pass removes a row whose record |
| +// is gone, with the same authority: a Provisioned row of this pass's own |
| +// kind and scope, not a running operation's participant, through |
| +// claimRetire (reg.Delete, which the activator follows by stopping it). It |
| +// also requires the row to be this record's (account and app). A row it |
| +// may not remove is left alone, logged, and the seed stays aborted. |
| +// |
| +// Only --from seeds: an import's row is the instance the import replaces, |
| +// which its rollback restores (Plan 7) and never removes. |
| +func retireAbortedSeedRow(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, rec Record, rt registry.Route) { |
| + if rt.Kind == SidecarKind || !rt.Provisioned || !cfg.inScope(rt.Region, rt.Pool, everywhereRoute(rt)) || |
| + rt.Account != rec.Account || rt.App != rec.App { |
| + log.Printf("provision: %s: aborted seed %s has a row this pass may not remove; leaving the seed aborted", rec.Host, rec.Seed.ID) |
| + return |
| + } |
| + if cfg.KeepParticipant != nil && cfg.KeepParticipant(rt.Host) { |
| + log.Printf("provision: %s: aborted seed %s: not removing its row: participant of a running operation", rec.Host, rec.Seed.ID) |
| + return |
| + } |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would remove %s route %s (its seed %s was aborted)", rt.Kind, rt.Host, rec.Seed.ID) |
| + return |
| + } |
| + if err := cfg.claimRetire(ctx, reg, rt); err != nil { |
| + log.Printf("provision: remove %s route %s (its seed %s was aborted): %v", rt.Kind, rt.Host, rec.Seed.ID, err) |
| + return |
| + } |
| + log.Printf("provision: removed %s route %s (its seed %s was aborted); the next pass rolls it back", rt.Kind, rt.Host, rec.Seed.ID) |
| +} |
| + |
| // finishAbortedSeed is §3.2's no-journal path for an aborted seed: delete |
| // this seed's snapshot (only this seed's id, codex R4-1), then CAS the |
| // record to rolled-back. Idempotent: an absent snapshot and a lost CAS are |
diff --git a/internal/provision/seedreconcile_test.go b/internal/provision/seedreconcile_test.go| index 080dae8aa..56a2fafcf 100644 |
| --- a/internal/provision/seedreconcile_test.go |
| +++ b/internal/provision/seedreconcile_test.go |
| @@ -157,3 +157,87 @@ func TestReconcileDoesNotMintASeedThatWouldNotHibernate(t *testing.T) { |
| t.Errorf("minted %+v", rt) |
| } |
| } |
| + |
| +// A --from seed aborted after this box minted its row (a lost publish |
| +// answer): the row is removed first, the way a retired record's row is, and |
| +// the seed stays aborted with its snapshot. The next pass, which finds no |
| +// row, rolls it back. |
| +func TestReconcileRemovesAnAbortedFromSeedsRowBeforeRollingBack(t *testing.T) { |
| + s, sqid := setup(t) |
| + data := dirStore(t) |
| + rec := seededRecord(t, s, sqid, seeds.KindReplica, seeds.StateReady) |
| + snap := seeds.SnapshotKey(rec.Host, rec.Seed.ID) |
| + if _, err := data.Put(ctx, snap, []byte("db"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + if err := Reconcile(ctx, s, reg, nestedSeedCfg(data)); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, rec.Host) |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(rec.Seed.ID)); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := Reconcile(ctx, s, reg, nestedSeedCfg(data)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ := reg.Get(rec.Host); rt != nil { |
| + t.Fatalf("the aborted seed's row is still up: %+v", rt) |
| + } |
| + got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host) |
| + if err != nil || got.Seed.State != seeds.StateAborted { |
| + t.Fatalf("record after the row's removal = %+v, %v; want still aborted", got.Seed, err) |
| + } |
| + if _, _, err := data.Get(ctx, snap); err != nil { |
| + t.Fatalf("the snapshot went with the row: %v", err) |
| + } |
| + |
| + if err := Reconcile(ctx, s, reg, nestedSeedCfg(data)); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = GetRecord(ctx, s, rec.Account, rec.App, rec.Host) |
| + if err != nil || got.Seed.State != seeds.StateRolledBack { |
| + t.Fatalf("record on the pass with no row = %+v, %v; want rolled-back", got.Seed, err) |
| + } |
| + if _, _, err := data.Get(ctx, snap); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("snapshot after the rollback: %v, want it deleted", err) |
| + } |
| + if rt, _ := reg.Get(rec.Host); rt != nil { |
| + t.Fatalf("a rolled-back seed was minted again: %+v", rt) |
| + } |
| +} |
| + |
| +// A row this pass has no authority over (not Provisioned: hand-added) is |
| +// left alone, and the seed stays aborted while it stands. |
| +func TestReconcileLeavesAnAbortedSeedsUnownedRow(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := seededRecord(t, s, sqid, seeds.KindReplica, seeds.StateAborted) |
| + reg := regWith(t, registry.Route{Host: rec.Host, Socket: "/run/carlos/hand.sock", DBPath: "/data/hand.db", |
| + App: rec.App, Account: rec.Account, Region: "ie", Pool: "tito"}) |
| + if err := Reconcile(ctx, s, reg, nestedSeedCfg(dirStore(t))); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, rec.Host) |
| + got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host) |
| + if err != nil || got.Seed.State != seeds.StateAborted { |
| + t.Fatalf("record = %+v, %v; want still aborted while the row stands", got.Seed, err) |
| + } |
| +} |
| + |
| +// An import's row is the instance it replaces: an aborted import never |
| +// removes it, and rolls back as before (its swap's rollback is Plan 7's). |
| +func TestReconcileNeverRemovesAnAbortedImportsRow(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := seededRecord(t, s, sqid, seeds.KindImport, seeds.StateAborted) |
| + reg := regWith(t, registry.Route{Host: rec.Host, Socket: DefaultSocket(rec.Host), DBPath: DefaultDBPath("/data", rec.Host), |
| + App: rec.App, Account: rec.Account, Region: "ie", Pool: "tito", Provisioned: true}) |
| + if err := Reconcile(ctx, s, reg, nestedSeedCfg(dirStore(t))); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, rec.Host) |
| + got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host) |
| + if err != nil || got.Seed.State != seeds.StateRolledBack { |
| + t.Fatalf("record = %+v, %v; want rolled-back", got.Seed, err) |
| + } |
| +} |