| new file mode 100644 |
| index 000000000..9661d77bf |
| --- /dev/null |
| +++ b/internal/console/lifetimes_retire_test.go |
| @@ -0,0 +1,1305 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "os" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| +) |
| + |
| +// Retirement through a tombstone (lifetimes_retire.go), driven through the |
| +// real doors, server, store and SQLite of a claims-off console with |
| +// lifetimes on, over the ltStore bucket that can fail, pause or record any |
| +// call (lifetimes_create_test.go). |
| + |
| +// ---- fixtures ------------------------------------------------------------- |
| + |
| +// del is the real DELETE door for host. |
| +func (l ltOn) del(t *testing.T, host string) (int, string) { |
| + t.Helper() |
| + code, _, body := rawReq(t, http.MethodDelete, instancesURL(l.fx, "bab", ltApp)+"/"+host, l.member, "") |
| + return code, body |
| +} |
| + |
| +// mustCreate creates host through the real door and returns the record. |
| +func (l ltOn) mustCreate(t *testing.T, host string) provision.Record { |
| + t.Helper() |
| + code, body := l.create(t, host) |
| + if code != http.StatusCreated { |
| + t.Fatalf("create %s = %d %s", host, code, body) |
| + } |
| + raw, _ := rawOf(t, l.fx.rel, ltApp, host) |
| + return decodeRec(t, raw) |
| +} |
| + |
| +// requireRetired: no body under bab/app/host, and the slot {n, retired, |
| +// bab/app}. |
| +func requireRetired(t *testing.T, st release.Store, app, host string, n uint64) { |
| + t.Helper() |
| + if raw, _ := rawOf(t, st, app, host); raw != nil { |
| + t.Fatalf("a body is still under %s: %s", host, raw) |
| + } |
| + slot, _ := slotOf(t, st, host) |
| + if slot == nil || slot.Incarnation != n || slot.State != provision.SlotRetired || slot.Account != "bab" || slot.App != app { |
| + t.Fatalf("slot of %s = %+v, want {%d, retired, bab/%s}", host, slot, n, app) |
| + } |
| +} |
| + |
| +// lastRetirement is host's newest retirement row. |
| +func lastRetirement(t *testing.T, st *Store, host string) retireRow { |
| + t.Helper() |
| + rows := retireRows(t, st, host) |
| + if len(rows) == 0 { |
| + t.Fatalf("no retirement of %s", host) |
| + } |
| + return rows[len(rows)-1] |
| +} |
| + |
| +// onNth runs fn before the nth call of op on key, then lets the call go on. |
| +func onNth(op, key string, nth int32, fn func()) func(string, string) error { |
| + var n atomic.Int32 |
| + return func(o, k string) error { |
| + if o == op && k == key && n.Add(1) == nth { |
| + fn() |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// bump is an ordinary update of bab/app/host's record straight in st: the |
| +// channel flipped through WriteRecord, so record_seq advances. |
| +func bump(t *testing.T, st release.Store, app, host string) string { |
| + t.Helper() |
| + raw, etag := rawOf(t, st, app, host) |
| + rec := decodeRec(t, raw) |
| + rec.Channel = map[bool]string{true: "edge", false: "stable"}[rec.Channel != "edge"] |
| + body, _ := json.Marshal(rec) |
| + e, _, err := provision.WriteRecord(context.Background(), st, recKey(app, host), raw, body, etag) |
| + if err != nil { |
| + t.Fatalf("bump %s: %v", host, err) |
| + } |
| + return e |
| +} |
| + |
| +// sqlFailRetirement makes every move of record_retirements to state fail |
| +// until the returned func drops the trigger. |
| +func sqlFailRetirement(t *testing.T, st *Store, name, when string) func() { |
| + t.Helper() |
| + if _, err := st.db.Exec(`CREATE TRIGGER ` + name + ` BEFORE UPDATE ON record_retirements WHEN ` + when + ` |
| +BEGIN SELECT RAISE(ABORT, 'test: the retirement update is refused'); END`); err != nil { |
| + t.Fatal(err) |
| + } |
| + var once sync.Once |
| + return func() { |
| + once.Do(func() { |
| + if _, err := st.db.Exec(`DROP TRIGGER ` + name); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// retireOn runs the retirement core on srv in a unit admitted on. |
| +func retireOn(t *testing.T, srv *Server, host string, expect provision.RetireExpectation) error { |
| + t.Helper() |
| + return srv.retireRecordWithLifetime(onCtx(t, srv), "bab", ltApp, host, expect) |
| +} |
| + |
| +// quarantined is every quarantine copy of bab/shop/host. |
| +func quarantined(t *testing.T, st release.Store, host string) [][]byte { |
| + t.Helper() |
| + keys, err := st.List(context.Background(), "control/quarantine/bab/"+ltApp+"/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out [][]byte |
| + for _, k := range keys { |
| + if strings.HasPrefix(k, "control/quarantine/bab/"+ltApp+"/"+host+".") { |
| + b, _, err := st.Get(context.Background(), k) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out = append(out, b) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// slotRetiredWhenKeyLeaves records, at the moment the record key's body is |
| +// removed, whether the slot was already retired. It fails the test if the |
| +// key is removed under a live slot. |
| +func slotRetiredWhenKeyLeaves(t *testing.T, l ltOn, host string) func(string, string) error { |
| + t.Helper() |
| + return func(op, key string) error { |
| + if (op == "delete-if-match" || op == "delete") && key == recKey(ltApp, host) { |
| + slot, _, err := provision.ReadHostIncarnation(context.Background(), l.fx.rel, host) |
| + if err == nil && slot != nil && slot.State == provision.SlotLive && slotOwnedBy(slot, "bab", ltApp) { |
| + t.Errorf("the body of %s was removed under the live slot %+v", host, *slot) |
| + } |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// ---- the delete door -------------------------------------------------------- |
| + |
| +func TestDeleteDoorRetiresThroughTheTombstone(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + if code, body := l.del(t, ltOne); code != http.StatusOK || !strings.Contains(body, "deleted") { |
| + t.Fatalf("delete = %d %s", code, body) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.Kind != retireNormal || r.State != lifetimeCommitted || r.Incarnation != 1 { |
| + t.Fatalf("retirement = %+v, want one committed normal at 1", r) |
| + } |
| + ops := l.bucket.opsOf(recKey(ltApp, ltOne)) |
| + // The create's PUT, the tombstone PUT, then the tombstone's conditional |
| + // delete; never a plain delete of the key. |
| + var puts, deletes int |
| + for _, op := range ops { |
| + switch op { |
| + case "put": |
| + puts++ |
| + case "delete-if-match": |
| + deletes++ |
| + case "delete": |
| + t.Fatalf("ops on the key = %v: a plain delete", ops) |
| + } |
| + } |
| + if puts != 2 || deletes != 1 { |
| + t.Fatalf("ops on the key = %v, want the create, the tombstone and its conditional delete", ops) |
| + } |
| +} |
| + |
| +func TestSlotRetiresBeforeTheKeyIsAbsent(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + var tombstoned atomic.Bool |
| + l.bucket.setHook(chain(slotRetiredWhenKeyLeaves(t, l, ltOne), func(op, key string) error { |
| + if op == "put" && key == provision.HostIncarnationKey(ltOne) { |
| + raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + tombstoned.Store(raw != nil && decodeRec(t, raw).Retired) |
| + } |
| + return nil |
| + })) |
| + if code, body := l.del(t, ltOne); code != http.StatusOK { |
| + t.Fatalf("delete = %d %s", code, body) |
| + } |
| + if !tombstoned.Load() { |
| + t.Fatal("the slot was retired before the tombstone landed") |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| +} |
| + |
| +func TestDeleteDoorAbsentPathAdmitsAndRetiresOrphans(t *testing.T) { |
| + t.Run("initial absence", func(t *testing.T) { |
| + l := newLtOn(t) |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 1, State: provision.SlotLive, Account: "bab", App: ltApp, Intent: "create lost"}) |
| + if code, body := l.del(t, ltOne); code != http.StatusOK || !strings.Contains(body, "deleted") { |
| + t.Fatalf("delete = %d %s", code, body) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.Kind != retireOrphan || r.State != lifetimeCommitted { |
| + t.Fatalf("retirement = %+v, want a committed orphan", r) |
| + } |
| + }) |
| + t.Run("the absent path admits", func(t *testing.T) { |
| + l := newLtOn(t) |
| + if err := l.fx.st.BeginRetirement(Retirement{OpID: "other", Kind: retireOrphan, Account: "bab", App: "web", Host: ltOne, Incarnation: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, body := l.del(t, ltOne); code != http.StatusConflict || ltErr(body) != msgLifetimeSettling { |
| + t.Fatalf("delete = %d %s, want 409 settling", code, body) |
| + } |
| + }) |
| + t.Run("absence found on the judge's re-read", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + // A delayed deletion takes the record after the door's first read: |
| + // its ETag expectation conflicts, and the re-decision finds absence. |
| + var once sync.Once |
| + l.bucket.setAfter(func(op, key string, err error) { |
| + if op == "get" && key == recKey(ltApp, ltOne) && err == nil { |
| + once.Do(func() { |
| + if err := l.fx.rel.Delete(context.Background(), recKey(ltApp, ltOne)); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + } |
| + }) |
| + if code, body := l.del(t, ltOne); code != http.StatusOK || !strings.Contains(body, "deleted") { |
| + t.Fatalf("delete = %d %s", code, body) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.Kind != retireOrphan || r.State != lifetimeCommitted { |
| + t.Fatalf("retirement = %+v, want the owned slot retired as an orphan", r) |
| + } |
| + }) |
| + t.Run("a record created since is untouched", func(t *testing.T) { |
| + l := newLtOn(t) |
| + var once sync.Once |
| + var created string |
| + l.bucket.setAfter(func(op, key string, err error) { |
| + if op == "get" && key == recKey(ltApp, ltOne) && errors.Is(err, os.ErrNotExist) { |
| + once.Do(func() { created = legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") }) |
| + } |
| + }) |
| + if code, body := l.del(t, ltOne); code != http.StatusOK || !strings.Contains(body, "deleted") { |
| + t.Fatalf("delete = %d %s", code, body) |
| + } |
| + if created == "" { |
| + t.Fatal("the record was never created behind the door's read") |
| + } |
| + if _, etag := rawOf(t, l.fx.rel, ltApp, ltOne); etag != created { |
| + t.Fatalf("the record created since = %q, want it untouched (%q)", etag, created) |
| + } |
| + if rows := retireRows(t, l.fx.st, ltOne); len(rows) != 0 { |
| + t.Fatalf("retirements = %+v, want none", rows) |
| + } |
| + }) |
| +} |
| + |
| +func TestStaleCleanupRetiresALiveOwnedSlotFirst(t *testing.T) { |
| + for _, body := range []string{"fieldless", "corrupt"} { |
| + t.Run(body, func(t *testing.T) { |
| + l := newLtOn(t) |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 1, State: provision.SlotLive, Account: "bab", App: ltApp, Intent: "create late"}) |
| + if body == "fieldless" { |
| + legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| + } else if _, err := l.fx.rel.Put(context.Background(), recKey(ltApp, ltOne), []byte(`{"schema":`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A blocked incarnation-1 create-only write, released after the |
| + // retirement. |
| + late, _ := json.Marshal(provision.Record{Schema: 1, Host: ltOne, App: ltApp, Account: "bab", Channel: "stable", Incarnation: 1, CreatedAt: time.Now().UTC()}) |
| + l.bucket.setHook(slotRetiredWhenKeyLeaves(t, l, ltOne)) |
| + if code, resp := l.del(t, ltOne); code != http.StatusOK { |
| + t.Fatalf("delete = %d %s", code, resp) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.Kind != retireCleanup || r.State != lifetimeCommitted { |
| + t.Fatalf("retirement = %+v, want a committed cleanup", r) |
| + } |
| + if body == "corrupt" { |
| + if q := quarantined(t, l.fx.rel, ltOne); len(q) != 1 || string(q[0]) != `{"schema":` { |
| + t.Fatalf("quarantine = %q, want the corrupt bytes", q) |
| + } |
| + } |
| + if _, _, err := provision.WriteRecord(provision.WithRecordSequence(context.Background()), l.fx.rel, recKey(ltApp, ltOne), []byte(`{}`), late, ""); err != nil { |
| + t.Fatalf("the late create-only write = %v", err) |
| + } |
| + if obs := l.fx.srv.judge.Observe(context.Background(), l.fx.rel, "bab", ltApp, ltOne); obs.Verdict != provision.LifetimeStale { |
| + t.Fatalf("the late body is %v, want stale under the retired slot", obs.Verdict) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestDeleteDoorRemovesStaleAndCorruptRecordsConditionally(t *testing.T) { |
| + slots := map[string]provision.HostIncarnation{ |
| + "a retired slot": {Incarnation: 1, State: provision.SlotRetired, Account: "bab", App: ltApp, Intent: "retire old"}, |
| + "a foreign slot": {Incarnation: 1, State: provision.SlotLive, Account: "bab", App: "web", Intent: "create web"}, |
| + } |
| + for name, slot := range slots { |
| + for _, body := range []string{"fieldless", "corrupt"} { |
| + t.Run(name+", "+body, func(t *testing.T) { |
| + l := newLtOn(t) |
| + slotETag := writeSlot(t, l.fx.rel, ltOne, slot) |
| + if body == "fieldless" { |
| + legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| + } else if _, err := l.fx.rel.Put(context.Background(), recKey(ltApp, ltOne), []byte(`[1,2`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, resp := l.del(t, ltOne); code != http.StatusOK { |
| + t.Fatalf("delete = %d %s", code, resp) |
| + } |
| + if raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne); raw != nil { |
| + t.Fatalf("the body survived: %s", raw) |
| + } |
| + if _, e := slotOf(t, l.fx.rel, ltOne); e != slotETag { |
| + t.Fatal("the slot was written: it is not this key's to retire") |
| + } |
| + if rows := retireRows(t, l.fx.st, ltOne); len(rows) != 0 { |
| + t.Fatalf("retirements = %+v, want a plain conditional delete", rows) |
| + } |
| + for _, op := range l.bucket.opsOf(recKey(ltApp, ltOne)) { |
| + if op == "delete" { |
| + t.Fatal("a plain delete of the key") |
| + } |
| + } |
| + if body == "corrupt" && len(quarantined(t, l.fx.rel, ltOne)) != 1 { |
| + t.Fatal("the corrupt body was not quarantined first") |
| + } |
| + }) |
| + } |
| + } |
| + t.Run("a body that changes under the conditional delete is settling", func(t *testing.T) { |
| + l := newLtOn(t) |
| + writeSlot(t, l.fx.rel, ltOne, slots["a retired slot"]) |
| + legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| + l.bucket.setHook(onNth("delete-if-match", recKey(ltApp, ltOne), 1, func() { |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if _, err := l.fx.rel.Put(context.Background(), recKey(ltApp, ltOne), bytes.Replace(raw, []byte(`"stable"`), []byte(`"edge"`), 1), etag); err != nil { |
| + t.Error(err) |
| + } |
| + })) |
| + code, resp := l.del(t, ltOne) |
| + if code == http.StatusOK { |
| + t.Fatalf("delete = %d %s, want a refusal: the body changed under it", code, resp) |
| + } |
| + if raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne); raw == nil { |
| + t.Fatal("the changed body was deleted") |
| + } |
| + }) |
| +} |
| + |
| +func TestSeedFinishRetiresThroughTheTombstone(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + l.fx.srv.cfg.SeedData = release.NewDirStore(t.TempDir()) |
| + id, err := seeds.NewID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The record carries a rolled-back import, as a box leaves it. |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + rec := decodeRec(t, raw) |
| + rec.Seed = &provision.SeedSource{ID: id, Kind: seeds.KindImport, State: seeds.StateRolledBack, By: "alice@keymail.dev", At: time.Now().UTC()} |
| + body, _ := json.Marshal(rec) |
| + if _, _, err := provision.WriteRecord(context.Background(), l.fx.rel, recKey(ltApp, ltOne), raw, body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := l.fx.srv.finishSeed(onCtx(t, l.fx.srv), "bab", ltApp, ltOne, id); err != nil { |
| + t.Fatalf("finish = %v", err) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.Kind != retireNormal || r.State != lifetimeCommitted { |
| + t.Fatalf("retirement = %+v, want a committed normal", r) |
| + } |
| + var tombstone bool |
| + for _, op := range l.bucket.opsOf(recKey(ltApp, ltOne)) { |
| + tombstone = tombstone || op == "put" |
| + if op == "delete" { |
| + t.Fatal("a plain delete of the key") |
| + } |
| + } |
| + if !tombstone { |
| + t.Fatal("no tombstone was written") |
| + } |
| +} |
| + |
| +// ---- the retirement's own branches ----------------------------------------- |
| + |
| +func TestRetireRefusesWhenTheRecordChangedInItsLifetime(t *testing.T) { |
| + t.Run("the core", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + // An ordinary update lands between the retirement's read and its |
| + // tombstone CAS. |
| + var changed string |
| + l.bucket.setHook(onNth("put", recKey(ltApp, ltOne), 1, func() { changed = bump(t, l.fx.rel, ltApp, ltOne) })) |
| + err := retireOn(t, l.fx.srv, ltOne, retireAny) |
| + if !errors.Is(err, errRetireRefused) { |
| + t.Fatalf("retire = %v, want errRetireRefused", err) |
| + } |
| + r := lastRetirement(t, l.fx.st, ltOne) |
| + if r.State != lifetimeRefused { |
| + t.Fatalf("retirement = %+v, want refused", r) |
| + } |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + rec := decodeRec(t, raw) |
| + if rec.Retired || rec.Channel != "edge" || etag == changed { |
| + t.Fatalf("record = %s, want the update intact and fenced", raw) |
| + } |
| + if slot, _ := slotOf(t, l.fx.rel, ltOne); slot == nil || slot.State != provision.SlotLive { |
| + t.Fatalf("slot = %+v, want it live", slot) |
| + } |
| + }) |
| + t.Run("the door", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + l.bucket.setHook(onNth("put", recKey(ltApp, ltOne), 1, func() { bump(t, l.fx.rel, ltApp, ltOne) })) |
| + if code, body := l.del(t, ltOne); code != http.StatusConflict || ltErr(body) != msgRetireRefused { |
| + t.Fatalf("delete = %d %s, want 409 %q", code, body, msgRetireRefused) |
| + } |
| + }) |
| +} |
| + |
| +func TestRetireVoidsAgainstASuccessor(t *testing.T) { |
| + t.Run("a record of another incarnation", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + l.bucket.setHook(onNth("put", recKey(ltApp, ltOne), 1, func() { |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + rec := decodeRec(t, raw) |
| + rec.Incarnation = 7 |
| + body, _ := json.Marshal(rec) |
| + if _, _, err := provision.WriteRecord(context.Background(), l.fx.rel, recKey(ltApp, ltOne), raw, body, etag); err != nil { |
| + t.Error(err) |
| + } |
| + })) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireAny); err != nil { |
| + t.Fatalf("retire = %v, want nil (void)", err) |
| + } |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.State != lifetimeVoid { |
| + t.Fatalf("retirement = %+v, want void", r) |
| + } |
| + raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if rec := decodeRec(t, raw); rec.Incarnation != 7 || rec.Retired || rec.RecordSeq < 3 { |
| + t.Fatalf("record = %s, want the other incarnation intact and fenced", raw) |
| + } |
| + }) |
| + t.Run("a successor slot", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + var succ string |
| + l.bucket.setHook(onNth("put", recKey(ltApp, ltOne), 1, func() { |
| + succ = writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 2, State: provision.SlotLive, Account: "bab", App: "web", Intent: "create web"}) |
| + })) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireAny); err != nil { |
| + t.Fatalf("retire = %v, want nil (void)", err) |
| + } |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.State != lifetimeVoid { |
| + t.Fatalf("retirement = %+v, want void", r) |
| + } |
| + if _, e := slotOf(t, l.fx.rel, ltOne); e != succ { |
| + t.Fatal("the successor's slot was written") |
| + } |
| + }) |
| +} |
| + |
| +func TestRetireNeverMovesTheSlotBackwards(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + // The retirement's tombstone write fails: the row is pending at N=1. |
| + l.bucket.setHook(failNth("put", recKey(ltApp, ltOne), 1, false)) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireAny); !errors.Is(err, errLifetimePending) { |
| + t.Fatalf("retire = %v, want pending", err) |
| + } |
| + l.bucket.setHook(nil) |
| + r := lastRetirement(t, l.fx.st, ltOne) |
| + // The hostname moved on to incarnation 2 meanwhile (outside this |
| + // console's admission). |
| + succ := writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 2, State: provision.SlotLive, Account: "bab", App: ltApp, Intent: "create two"}) |
| + rows, _ := l.fx.st.PendingRetirements() |
| + disp, _ := l.fx.srv.retireSlotLocked(context.Background(), rows[0]) |
| + if disp != slotSuperseded { |
| + t.Fatalf("the late slot write = %v, want superseded", disp) |
| + } |
| + recoverOn(t, l.restart(t)) |
| + if got := lastRetirement(t, l.fx.st, ltOne); got.OpID != r.OpID || got.State != lifetimeVoid { |
| + t.Fatalf("retirement = %+v, want void", got) |
| + } |
| + if slot, e := slotOf(t, l.fx.rel, ltOne); e != succ || slot.Incarnation != 2 { |
| + t.Fatalf("slot = %+v, want {2, live} untouched", slot) |
| + } |
| +} |
| + |
| +func TestTombstoneConflictWithAbsentRecordStillRetiresTheSlot(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + // An older delayed deletion takes the record before the tombstone CAS. |
| + l.bucket.setHook(onNth("put", recKey(ltApp, ltOne), 1, func() { |
| + if err := l.fx.rel.Delete(context.Background(), recKey(ltApp, ltOne)); err != nil { |
| + t.Error(err) |
| + } |
| + })) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireAny); err != nil { |
| + t.Fatalf("retire = %v", err) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.State != lifetimeCommitted { |
| + t.Fatalf("retirement = %+v, want committed", r) |
| + } |
| + for _, op := range l.bucket.opsOf(recKey(ltApp, ltOne)) { |
| + if op == "delete-if-match" { |
| + t.Fatal("a tombstone delete ran with no tombstone to remove") |
| + } |
| + } |
| +} |
| + |
| +func TestOrphanSlotIsRetired(t *testing.T) { |
| + l := newLtOn(t) |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 3, State: provision.SlotLive, Account: "bab", App: ltApp, Intent: "create lost"}) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireAny); err != nil { |
| + t.Fatalf("retire = %v", err) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 3) |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.Kind != retireOrphan || r.State != lifetimeCommitted { |
| + t.Fatalf("retirement = %+v", r) |
| + } |
| +} |
| + |
| +func TestOrphanRetirementSurvivesARestoredRecord(t *testing.T) { |
| + l := newLtOn(t) |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 1, State: provision.SlotLive, Account: "bab", App: ltApp, Intent: "create lost"}) |
| + // O1's slot write fails: the orphan retirement is pending. |
| + l.bucket.setHook(failNth("put", provision.HostIncarnationKey(ltOne), 1, false)) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireAny); !errors.Is(err, errLifetimePending) { |
| + t.Fatalf("retire = %v, want pending", err) |
| + } |
| + l.bucket.setHook(nil) |
| + // The record comes back under the key (a late write of the lost create). |
| + late, _ := json.Marshal(provision.Record{Schema: 1, Host: ltOne, App: ltApp, Account: "bab", Channel: "stable", Incarnation: 1, CreatedAt: time.Now().UTC()}) |
| + if _, err := l.fx.rel.Put(context.Background(), recKey(ltApp, ltOne), late, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + recoverOn(t, l.restart(t)) |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.Kind != retireOrphan || r.State != lifetimeCommitted { |
| + t.Fatalf("retirement = %+v, want committed", r) |
| + } |
| +} |
| + |
| +func TestExpectationsGateRetirement(t *testing.T) { |
| + t.Run("an incarnation the record no longer has", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + if code, body := l.del(t, ltOne); code != http.StatusOK { |
| + t.Fatalf("delete = %d %s", code, body) |
| + } |
| + if rec := l.mustCreate(t, ltOne); rec.Incarnation != 2 { |
| + t.Fatalf("recreated at %d, want 2", rec.Incarnation) |
| + } |
| + _, before := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + n := len(retireRows(t, l.fx.st, ltOne)) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireExpectIncarnation(1)); err != nil { |
| + t.Fatalf("retire = %v, want nil", err) |
| + } |
| + if _, after := rawOf(t, l.fx.rel, ltApp, ltOne); after != before || len(retireRows(t, l.fx.st, ltOne)) != n { |
| + t.Fatal("the record at 2 was touched") |
| + } |
| + }) |
| + t.Run("retireAny retires what is current", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireAny); err != nil { |
| + t.Fatal(err) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + }) |
| + t.Run("an ETag that moved is a conflict", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + _, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + bump(t, l.fx.rel, ltApp, ltOne) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireExpectETag(etag)); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("retire = %v, want a CAS conflict", err) |
| + } |
| + if raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne); raw == nil || len(retireRows(t, l.fx.st, ltOne)) != 0 { |
| + t.Fatal("a conflicting retirement touched the record") |
| + } |
| + }) |
| + t.Run("absent when present leaves it", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireExpectAbsent); err != nil { |
| + t.Fatal(err) |
| + } |
| + if raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne); raw == nil { |
| + t.Fatal("an absent expectation removed a present record") |
| + } |
| + }) |
| + t.Run("two expectations are a programming error", func(t *testing.T) { |
| + l := newLtOn(t) |
| + err := retireOn(t, l.fx.srv, ltOne, provision.RetireExpectation{Absent: true, Incarnation: 1}) |
| + if !errors.Is(err, errRetireExpectation) { |
| + t.Fatalf("retire = %v, want errRetireExpectation", err) |
| + } |
| + }) |
| +} |
| + |
| +func TestLegacyRetirementIsBoundToTheCallersRead(t *testing.T) { |
| + l := newLtOn(t) |
| + legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| + _, read := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + // An ordinary update lands between the caller's read and the marker CAS |
| + // (the first PUT of the key is the migration's marker). |
| + l.bucket.setHook(onNth("put", recKey(ltApp, ltOne), 1, func() { bump(t, l.fx.rel, ltApp, ltOne) })) |
| + err := retireOn(t, l.fx.srv, ltOne, retireExpectETag(read)) |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("retire = %v, want a CAS conflict", err) |
| + } |
| + raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if rec := decodeRec(t, raw); rec.Channel != "edge" || rec.Retired || rec.IncarnationMigrating != "" { |
| + t.Fatalf("record = %s, want the update intact and unmarked", raw) |
| + } |
| + var state string |
| + if err := l.fx.st.db.QueryRow(`SELECT state FROM record_migrations WHERE host = ?`, ltOne).Scan(&state); err != nil || state != lifetimeSuperseded { |
| + t.Fatalf("migration = %q %v, want superseded", state, err) |
| + } |
| + if rows := retireRows(t, l.fx.st, ltOne); len(rows) != 0 { |
| + t.Fatalf("retirements = %+v, want none", rows) |
| + } |
| +} |
| + |
| +func TestDeleteDoorRefusesAnImportThatAppearedAfterItsFirstRead(t *testing.T) { |
| + l := newLtOn(t) |
| + legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| + id, _ := seeds.NewID() |
| + var once sync.Once |
| + l.bucket.setAfter(func(op, key string, err error) { |
| + if op == "get" && key == recKey(ltApp, ltOne) && err == nil { |
| + once.Do(func() { |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + rec := decodeRec(t, raw) |
| + rec.Seed = &provision.SeedSource{ID: id, Kind: seeds.KindImport, State: seeds.StateReady, By: "alice@keymail.dev", At: time.Now().UTC()} |
| + body, _ := json.Marshal(rec) |
| + if _, err := l.fx.rel.Put(context.Background(), recKey(ltApp, ltOne), body, etag); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + } |
| + }) |
| + code, body := l.del(t, ltOne) |
| + if code != http.StatusConflict || !strings.Contains(ltErr(body), "Abort the import instead") { |
| + t.Fatalf("delete = %d %s, want the import rule's 409", code, body) |
| + } |
| + raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if raw == nil || decodeRec(t, raw).Seed == nil { |
| + t.Fatal("the import was deleted") |
| + } |
| +} |
| + |
| +func TestDelayedDeleteCannotEraseASuccessor(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + // The first console's tombstone delete (N3) is held in flight. |
| + gate, hook := pauseNth(t, "delete-if-match", recKey(ltApp, ltOne), 1) |
| + l.bucket.setHook(hook) |
| + answered := make(chan int, 1) |
| + go func() { code, _ := l.del(t, ltOne); answered <- code }() |
| + waitFor(t, gate.entered, "the tombstone delete") |
| + // A second console finishes the retirement and the hostname's next |
| + // lifetime is created. |
| + second := l.restart(t) |
| + l.bucket.setHook(nil) |
| + recoverOn(t, second) |
| + life, _ := l.fx.st.AppLife(OpsAccountID, ltApp) |
| + rec := provision.Record{Schema: 1, Host: ltOne, App: ltApp, Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC()} |
| + if _, err := second.createRecordWithLifetime(onCtx(t, second), rec, life.Lifetime); err != nil { |
| + t.Fatalf("the successor's create = %v", err) |
| + } |
| + _, succ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + gate.open() |
| + <-answered |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if raw == nil || etag != succ || decodeRec(t, raw).Incarnation != 2 { |
| + t.Fatalf("record = %s, want the successor at 2 intact", raw) |
| + } |
| +} |
| + |
| +// ---- recovery --------------------------------------------------------------- |
| + |
| +// pendingRetire starts a retirement of ltOne's record through the core and |
| +// makes it stop at the step named, pending: "begin" (the tombstone write |
| +// fails), "n1" (the slot write fails after the tombstone), "n2" (the |
| +// tombstone delete fails after the slot), "n3" (the commit fails). |
| +func (l ltOn) pendingRetire(t *testing.T, step string) retireRow { |
| + t.Helper() |
| + var drop func() |
| + switch step { |
| + case "begin": |
| + l.bucket.setHook(failNth("put", recKey(ltApp, ltOne), 1, false)) |
| + case "n1": |
| + l.bucket.setHook(failNth("put", provision.HostIncarnationKey(ltOne), 1, false)) |
| + case "n2": |
| + l.bucket.setHook(failNth("delete-if-match", recKey(ltApp, ltOne), 1, false)) |
| + case "n3": |
| + drop = sqlFailRetirement(t, l.fx.st, "lt_fail_retire_commit", "NEW.state = 'committed'") |
| + default: |
| + t.Fatalf("no step %q", step) |
| + } |
| + err := retireOn(t, l.fx.srv, ltOne, retireAny) |
| + l.bucket.setHook(nil) |
| + if drop != nil { |
| + drop() |
| + } |
| + if !errors.Is(err, errLifetimePending) { |
| + t.Fatalf("retire stopping at %s = %v, want pending", step, err) |
| + } |
| + r := lastRetirement(t, l.fx.st, ltOne) |
| + if r.State != lifetimePending { |
| + t.Fatalf("retirement = %+v, want pending", r) |
| + } |
| + return r |
| +} |
| + |
| +// recoveredTo runs one recovery pass on a restarted console and requires the |
| +// retirement's state. |
| +func (l ltOn) recoveredTo(t *testing.T, op, state string) { |
| + t.Helper() |
| + recoverOn(t, l.restart(t)) |
| + for _, r := range retireRows(t, l.fx.st, ltOne) { |
| + if r.OpID == op { |
| + if r.State != state { |
| + t.Fatalf("retirement %s = %+v, want %s", op, r, state) |
| + } |
| + return |
| + } |
| + } |
| + t.Fatalf("no retirement %s", op) |
| +} |
| + |
| +func TestRetirementRecoveryBranches(t *testing.T) { |
| + ctx := context.Background() |
| + for _, step := range []string{"begin", "n1", "n2", "n3"} { |
| + t.Run("normal, crashed at "+step, func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, step) |
| + l.recoveredTo(t, r.OpID, lifetimeCommitted) |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + }) |
| + } |
| + t.Run("normal, a second crash during recovery", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + second := l.restart(t) |
| + second.cfg.Releases.(*ltStore).setHook(failNth("put", provision.HostIncarnationKey(ltOne), 1, false)) |
| + recoverOn(t, second) |
| + l.bucket.setHook(nil) |
| + if got := lastRetirement(t, l.fx.st, ltOne); got.State != lifetimePending { |
| + t.Fatalf("after the second crash = %+v, want pending", got) |
| + } |
| + l.recoveredTo(t, r.OpID, lifetimeCommitted) |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + }) |
| + t.Run("normal, a changed body is refused after the fence", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + bump(t, l.fx.rel, ltApp, ltOne) |
| + l.recoveredTo(t, r.OpID, lifetimeRefused) |
| + raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if rec := decodeRec(t, raw); rec.Retired || rec.Channel != "edge" { |
| + t.Fatalf("record = %s, want the changed body intact", raw) |
| + } |
| + }) |
| + t.Run("normal, another incarnation is void after the fence", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + rec := decodeRec(t, raw) |
| + rec.Incarnation = 5 |
| + body, _ := json.Marshal(rec) |
| + if _, _, err := provision.WriteRecord(ctx, l.fx.rel, recKey(ltApp, ltOne), raw, body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + l.recoveredTo(t, r.OpID, lifetimeVoid) |
| + if raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne); raw == nil || decodeRec(t, raw).Incarnation != 5 { |
| + t.Fatalf("record = %s, want the other incarnation intact", raw) |
| + } |
| + }) |
| + t.Run("any kind, a successor slot is void", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 2, State: provision.SlotLive, Account: "bab", App: "web", Intent: "create web"}) |
| + l.recoveredTo(t, r.OpID, lifetimeVoid) |
| + }) |
| + t.Run("normal, a corrupt body converts to cleanup", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + _, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if _, err := l.fx.rel.Put(ctx, recKey(ltApp, ltOne), []byte(`{"host":`), etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + l.bucket.setHook(slotRetiredWhenKeyLeaves(t, l, ltOne)) |
| + l.recoveredTo(t, r.OpID, lifetimeCommitted) |
| + if got := lastRetirement(t, l.fx.st, ltOne); got.Kind != retireCleanup { |
| + t.Fatalf("retirement = %+v, want converted to cleanup", got) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + if len(quarantined(t, l.fx.rel, ltOne)) == 0 { |
| + t.Fatal("the corrupt body was not quarantined") |
| + } |
| + }) |
| + t.Run("normal, a delayed slot write that landed", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + // The slot write lands but its answer is lost. |
| + var n atomic.Int32 |
| + l.bucket.setAfter(func(op, key string, err error) {}) |
| + l.bucket.setHook(func(op, key string) error { |
| + if op == "put" && key == provision.HostIncarnationKey(ltOne) && n.Add(1) == 1 { |
| + return errBucketDown |
| + } |
| + return nil |
| + }) |
| + // Write the retired slot behind the failed answer, as the bucket did. |
| + r := func() retireRow { |
| + err := retireOn(t, l.fx.srv, ltOne, retireAny) |
| + if !errors.Is(err, errLifetimePending) { |
| + t.Fatalf("retire = %v, want pending", err) |
| + } |
| + return lastRetirement(t, l.fx.st, ltOne) |
| + }() |
| + l.bucket.setHook(nil) |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 1, State: provision.SlotRetired, Account: "bab", App: ltApp, Intent: "retire " + r.OpID}) |
| + l.recoveredTo(t, r.OpID, lifetimeCommitted) |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + }) |
| + t.Run("normal, the tombstone lost to a deletion: committed without N3", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + if err := l.fx.rel.Delete(ctx, recKey(ltApp, ltOne)); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := len(l.bucket.opsOf(recKey(ltApp, ltOne))) |
| + l.recoveredTo(t, r.OpID, lifetimeCommitted) |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + for _, op := range l.bucket.opsOf(recKey(ltApp, ltOne))[before:] { |
| + if op == "delete-if-match" || op == "put" { |
| + t.Fatalf("recovery wrote the absent key: %s", op) |
| + } |
| + } |
| + }) |
| + t.Run("an unreadable slot leaves it pending", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + second := l.restart(t) |
| + l.bucket.setHook(failNth("get", provision.HostIncarnationKey(ltOne), 1, true)) |
| + recoverOn(t, second) |
| + l.bucket.setHook(nil) |
| + if got := lastRetirement(t, l.fx.st, ltOne); got.OpID != r.OpID || got.State != lifetimePending { |
| + t.Fatalf("retirement = %+v, want pending", got) |
| + } |
| + if raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne); raw == nil || decodeRec(t, raw).Retired { |
| + t.Fatal("an unreadable observation acted") |
| + } |
| + }) |
| + t.Run("a slot that is neither its own nor a successor stays pending", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 1, State: provision.SlotLive, Account: "bab", App: ltApp, Intent: "create someone-else"}) |
| + l.recoveredTo(t, r.OpID, lifetimePending) |
| + }) |
| + for _, step := range []string{"c1", "c2"} { |
| + t.Run("cleanup, crashed at "+step, func(t *testing.T) { |
| + l := newLtOn(t) |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 1, State: provision.SlotLive, Account: "bab", App: ltApp, Intent: "create late"}) |
| + legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| + if step == "c1" { |
| + l.bucket.setHook(failNth("put", provision.HostIncarnationKey(ltOne), 1, false)) |
| + } else { |
| + l.bucket.setHook(failNth("delete-if-match", recKey(ltApp, ltOne), 1, false)) |
| + } |
| + _, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireExpectETag(etag)); !errors.Is(err, errLifetimePending) { |
| + t.Fatalf("retire = %v, want pending", err) |
| + } |
| + l.bucket.setHook(slotRetiredWhenKeyLeaves(t, l, ltOne)) |
| + r := lastRetirement(t, l.fx.st, ltOne) |
| + if r.Kind != retireCleanup { |
| + t.Fatalf("retirement = %+v, want cleanup", r) |
| + } |
| + l.recoveredTo(t, r.OpID, lifetimeCommitted) |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + }) |
| + } |
| +} |
| + |
| +func TestCorruptBodyIsQuarantinedSlotFirst(t *testing.T) { |
| + ctx := context.Background() |
| + const malformed = `{"schema":1,"host":` |
| + // corruptBeforeTombstone makes the body malformed JSON between the |
| + // observation and the tombstone CAS. |
| + corruptBeforeTombstone := func(t *testing.T, l ltOn) func(string, string) error { |
| + return onNth("put", recKey(ltApp, ltOne), 1, func() { |
| + _, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if _, err := l.fx.rel.Put(ctx, recKey(ltApp, ltOne), []byte(malformed), etag); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + } |
| + // late is a delayed incarnation-1 create-only write of the original |
| + // create: it can land only on an absent key. |
| + late := func(t *testing.T, l ltOn) error { |
| + body, _ := json.Marshal(provision.Record{Schema: 1, Host: ltOne, App: ltApp, Account: "bab", Channel: "stable", Incarnation: 1, CreatedAt: time.Now().UTC()}) |
| + _, _, err := provision.WriteRecord(provision.WithRecordSequence(ctx), l.fx.rel, recKey(ltApp, ltOne), []byte(`{}`), body, "") |
| + return err |
| + } |
| + finished := func(t *testing.T, l ltOn) { |
| + t.Helper() |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + r := lastRetirement(t, l.fx.st, ltOne) |
| + if r.Kind != retireCleanup || r.State != lifetimeCommitted { |
| + t.Fatalf("retirement = %+v, want a committed cleanup", r) |
| + } |
| + q := quarantined(t, l.fx.rel, ltOne) |
| + if len(q) == 0 || string(q[0]) != malformed { |
| + t.Fatalf("quarantine = %q, want the malformed bytes", q) |
| + } |
| + if err := late(t, l); err != nil { |
| + t.Fatalf("the delayed create after the retirement = %v", err) |
| + } |
| + if obs := l.fx.srv.judge.Observe(ctx, l.fx.rel, "bab", ltApp, ltOne); obs.Verdict != provision.LifetimeStale { |
| + t.Fatalf("the restored record is %v, want stale", obs.Verdict) |
| + } |
| + } |
| + t.Run("in one call", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + l.bucket.setHook(chain(corruptBeforeTombstone(t, l), slotRetiredWhenKeyLeaves(t, l, ltOne))) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireAny); err != nil { |
| + t.Fatalf("retire = %v", err) |
| + } |
| + finished(t, l) |
| + }) |
| + crashes := map[string]func(t *testing.T, l ltOn) (func(string, string) error, func()){ |
| + "after the copy": func(t *testing.T, l ltOn) (func(string, string) error, func()) { |
| + return nil, sqlFailRetirement(t, l.fx.st, "lt_fail_convert", "NEW.kind = 'cleanup'") |
| + }, |
| + "after the conversion": func(t *testing.T, l ltOn) (func(string, string) error, func()) { |
| + return failNth("put", provision.HostIncarnationKey(ltOne), 1, false), nil |
| + }, |
| + "after C1": func(t *testing.T, l ltOn) (func(string, string) error, func()) { |
| + return failNth("delete-if-match", recKey(ltApp, ltOne), 1, false), nil |
| + }, |
| + } |
| + for name, crash := range crashes { |
| + t.Run("crashed "+name, func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + fail, drop := crash(t, l) |
| + hooks := []func(string, string) error{corruptBeforeTombstone(t, l), slotRetiredWhenKeyLeaves(t, l, ltOne)} |
| + if fail != nil { |
| + hooks = append(hooks, fail) |
| + } |
| + l.bucket.setHook(chain(hooks...)) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireAny); !errors.Is(err, errLifetimePending) { |
| + t.Fatalf("retire = %v, want pending", err) |
| + } |
| + if drop != nil { |
| + drop() |
| + } |
| + l.bucket.setHook(slotRetiredWhenKeyLeaves(t, l, ltOne)) |
| + // The delayed original create is released in the interval: the |
| + // key is present (corrupt) or the slot already retired, so it |
| + // never lands under a live slot. |
| + if err := late(t, l); err == nil { |
| + if slot, _ := slotOf(t, l.fx.rel, ltOne); slot.State == provision.SlotLive { |
| + t.Fatal("the delayed create landed under the live slot") |
| + } |
| + } |
| + r := lastRetirement(t, l.fx.st, ltOne) |
| + l.recoveredTo(t, r.OpID, lifetimeCommitted) |
| + if raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne); raw != nil { |
| + t.Fatalf("a body is left: %s", raw) |
| + } |
| + finished(t, l) |
| + }) |
| + } |
| + t.Run("the quarantine delete loses to a repaired body", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + repaired, _ := json.Marshal(provision.Record{Schema: 1, Host: ltOne, App: ltApp, Account: "bab", Channel: "edge", Incarnation: 1, CreatedAt: time.Now().UTC()}) |
| + l.bucket.setHook(chain(corruptBeforeTombstone(t, l), onNth("delete-if-match", recKey(ltApp, ltOne), 1, func() { |
| + _, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if _, err := l.fx.rel.Put(ctx, recKey(ltApp, ltOne), repaired, etag); err != nil { |
| + t.Error(err) |
| + } |
| + }))) |
| + if err := retireOn(t, l.fx.srv, ltOne, retireAny); err != nil { |
| + t.Fatalf("retire = %v", err) |
| + } |
| + finished(t, l) |
| + }) |
| +} |
| + |
| +func TestTerminationFencesFirst(t *testing.T) { |
| + seq := func(t *testing.T, l ltOn) uint64 { |
| + raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + return decodeRec(t, raw).RecordSeq |
| + } |
| + t.Run("refused fences the changed record", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + bump(t, l.fx.rel, ltApp, ltOne) |
| + before := seq(t, l) |
| + l.recoveredTo(t, r.OpID, lifetimeRefused) |
| + if after := seq(t, l); after != before+1 { |
| + t.Fatalf("record_seq %d -> %d, want the fence to advance it", before, after) |
| + } |
| + }) |
| + t.Run("void on another incarnation fences it", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + rec := decodeRec(t, raw) |
| + rec.Incarnation = 9 |
| + body, _ := json.Marshal(rec) |
| + if _, _, err := provision.WriteRecord(context.Background(), l.fx.rel, recKey(ltApp, ltOne), raw, body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := seq(t, l) |
| + l.recoveredTo(t, r.OpID, lifetimeVoid) |
| + if after := seq(t, l); after != before+1 { |
| + t.Fatalf("record_seq %d -> %d, want the fence to advance it", before, after) |
| + } |
| + }) |
| + t.Run("a fence that cannot be written leaves it pending", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + bump(t, l.fx.rel, ltApp, ltOne) |
| + before := seq(t, l) |
| + second := l.restart(t) |
| + l.bucket.setHook(failNth("put", recKey(ltApp, ltOne), 1, true)) |
| + recoverOn(t, second) |
| + l.bucket.setHook(nil) |
| + if got := lastRetirement(t, l.fx.st, ltOne); got.OpID != r.OpID || got.State != lifetimePending { |
| + t.Fatalf("retirement = %+v, want pending", got) |
| + } |
| + if after := seq(t, l); after != before { |
| + t.Fatal("the record changed") |
| + } |
| + }) |
| + t.Run("a successor slot terminates on slot evidence alone", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.mustCreate(t, ltOne) |
| + r := l.pendingRetire(t, "begin") |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 2, State: provision.SlotLive, Account: "bab", App: "web", Intent: "create web"}) |
| + before := len(l.bucket.opsOf(recKey(ltApp, ltOne))) |
| + l.recoveredTo(t, r.OpID, lifetimeVoid) |
| + for _, op := range l.bucket.opsOf(recKey(ltApp, ltOne))[before:] { |
| + if op == "put" { |
| + t.Fatal("a record fence on slot evidence") |
| + } |
| + } |
| + }) |
| +} |
| + |
| +// ---- detach, the sibling undo, the orphan rule --------------------------------- |
| + |
| +func TestDetachMapsLifetimeErrors(t *testing.T) { |
| + attach := func(t *testing.T, l ltOn) { |
| + t.Helper() |
| + code, _, body := rawReq(t, http.MethodPost, domainsURL(l.fx, "bab", ltApp), l.member, |
| + `{"host":"`+ltDomain+`","kind":"static","channel":"stable"}`) |
| + if code != http.StatusOK { |
| + t.Fatalf("attach = %d %s", code, body) |
| + } |
| + } |
| + cases := []struct { |
| + name string |
| + arrange func(t *testing.T, l ltOn) |
| + msg string |
| + }{ |
| + {"another operation is settling", func(t *testing.T, l ltOn) { |
| + if err := l.fx.st.BeginRetirement(Retirement{OpID: "other", Kind: retireOrphan, Account: "bab", App: "web", Host: ltDomain, Incarnation: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, claimDetachInProgressMsg}, |
| + {"the record changed in its lifetime", func(t *testing.T, l ltOn) { |
| + l.bucket.setHook(onNth("put", recKey(ltApp, ltDomain), 1, func() { bump(t, l.fx.rel, ltApp, ltDomain) })) |
| + }, msgRetireRefused}, |
| + {"the retirement is pending", func(t *testing.T, l ltOn) { |
| + l.bucket.setHook(failNth("put", recKey(ltApp, ltDomain), 1, false)) |
| + }, claimDetachInProgressMsg}, |
| + } |
| + for _, c := range cases { |
| + for _, door := range []string{"api", "browser"} { |
| + t.Run(c.name+", "+door, func(t *testing.T) { |
| + l := newLtOn(t) |
| + attach(t, l) |
| + c.arrange(t, l) |
| + if door == "api" { |
| + code, _, body := rawReq(t, http.MethodDelete, domainsURL(l.fx, "bab", ltApp)+"/"+ltDomain, l.member, "") |
| + if code != http.StatusConflict || ltErr(body) != c.msg { |
| + t.Fatalf("detach = %d %s, want 409 %q", code, body, c.msg) |
| + } |
| + } else { |
| + ref := l.fx.srv.detachDomain(onCtx(t, l.fx.srv), l.acct(t), ltApp, ltDomain, "alice@keymail.dev") |
| + if ref == nil || ref.Status != http.StatusConflict || ref.Msg != c.msg { |
| + t.Fatalf("detach = %+v, want 409 %q", ref, c.msg) |
| + } |
| + } |
| + l.bucket.setHook(nil) |
| + if got := claimHolder(t, l.fx.rel, ltDomain); got == "" { |
| + t.Fatal("the claim was released over a refused or pending record removal") |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestSiblingUndoRetiresOnlyTheLifetimeItCreated(t *testing.T) { |
| + const host = "web.bab.oncarlos.test" |
| + setup := func(t *testing.T) ltOn { |
| + l := newLtOn(t) |
| + l.fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + enableInstances(t, l.fx, ltApp, provision.Manifest{Schema: 1, App: ltApp, Domain: "bab.oncarlos.test", Channel: "stable"}) |
| + return l |
| + } |
| + claimSibling := func(t *testing.T, l ltOn, then func()) { |
| + var once sync.Once |
| + l.bucket.setAfter(func(op, key string, err error) { |
| + if op == "put" && key == recKey(ltApp, host) && err == nil { |
| + once.Do(func() { |
| + if _, err := l.fx.st.CreateAppIn(OpsAccountID, "web", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Error(err) |
| + } |
| + if then != nil { |
| + then() |
| + } |
| + }) |
| + } |
| + }) |
| + } |
| + t.Run("its own lifetime is retired", func(t *testing.T) { |
| + l := setup(t) |
| + claimSibling(t, l, nil) |
| + if code, body := l.create(t, host); code != http.StatusConflict || !strings.Contains(body, "platform apex") { |
| + t.Fatalf("create = %d %s, want the sibling refusal", code, body) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, host, 1) |
| + }) |
| + t.Run("a successor lifetime is left", func(t *testing.T) { |
| + l := setup(t) |
| + claimSibling(t, l, func() { |
| + // Another lifetime of the hostname replaced this one before the |
| + // undo ran. |
| + _, slotETag := slotOf(t, l.fx.rel, host) |
| + if _, err := provision.WriteHostIncarnation(context.Background(), l.fx.rel, host, |
| + provision.HostIncarnation{Incarnation: 2, State: provision.SlotLive, Account: "bab", App: ltApp, Intent: "create other"}, slotETag); err != nil { |
| + t.Error(err) |
| + } |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, host) |
| + rec := decodeRec(t, raw) |
| + rec.Incarnation = 2 |
| + body, _ := json.Marshal(rec) |
| + if _, _, err := provision.WriteRecord(context.Background(), l.fx.rel, recKey(ltApp, host), raw, body, etag); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + if code, body := l.create(t, host); code != http.StatusConflict { |
| + t.Fatalf("create = %d %s, want the sibling refusal", code, body) |
| + } |
| + raw, _ := rawOf(t, l.fx.rel, ltApp, host) |
| + if raw == nil || decodeRec(t, raw).Incarnation != 2 { |
| + t.Fatalf("record = %s, want the successor at 2 untouched", raw) |
| + } |
| + }) |
| +} |
| + |
| +func TestOrphanSlotReclaimedByTheNextCreate(t *testing.T) { |
| + t.Run("a live slot with no record and no pending op", func(t *testing.T) { |
| + l := newLtOn(t) |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 1, State: provision.SlotLive, Account: "bab", App: ltApp, Intent: "create lost"}) |
| + if rec := l.mustCreate(t, ltOne); rec.Incarnation != 2 { |
| + t.Fatalf("created at %d, want 2", rec.Incarnation) |
| + } |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.Kind != retireOrphan || r.State != lifetimeCommitted { |
| + t.Fatalf("retirement = %+v, want a committed orphan", r) |
| + } |
| + }) |
| + t.Run("with a pending op it is refused", func(t *testing.T) { |
| + l := newLtOn(t) |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 1, State: provision.SlotLive, Account: "bab", App: "web", Intent: "create web"}) |
| + if err := l.fx.st.BeginRetirement(Retirement{OpID: "held", Kind: retireNormal, Account: "bab", App: "web", Host: ltOne, Incarnation: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, body := l.create(t, ltOne); code != http.StatusConflict { |
| + t.Fatalf("create = %d %s, want 409", code, body) |
| + } |
| + if rows := allocRows(t, l.fx.st, ltOne); len(rows) != 0 { |
| + t.Fatalf("allocations = %+v, want none", rows) |
| + } |
| + }) |
| + t.Run("a stale body under the owner key is removed by a create under another key", func(t *testing.T) { |
| + l := newLtOn(t) |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 1, State: provision.SlotRetired, Account: "bab", App: "web", Intent: "retire web"}) |
| + if _, err := l.fx.rel.Put(context.Background(), recKey("web", ltOne), mustJSON(t, provision.Record{Schema: 1, Host: ltOne, App: "web", Account: "bab", Channel: "stable", Incarnation: 1, CreatedAt: time.Now().UTC()}), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + l.mustCreate(t, ltOne) |
| + if raw, _ := rawOf(t, l.fx.rel, "web", ltOne); raw != nil { |
| + t.Fatalf("the owner's stale body survived: %s", raw) |
| + } |
| + }) |
| +} |
| + |
| +func mustJSON(t *testing.T, v any) []byte { |
| + t.Helper() |
| + b, err := json.Marshal(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| +func TestCancellationIsOneTransaction(t *testing.T) { |
| + t.Run("a crash after the handover leaves one pending row", func(t *testing.T) { |
| + l := newLtOn(t) |
| + a := l.pendingCreate(t, ltOne, "record") |
| + trashShop(t, l) |
| + second := l.restart(t) |
| + // O1's slot write fails after the void and the retirement land. |
| + l.bucket.setHook(failNth("put", provision.HostIncarnationKey(ltOne), 1, false)) |
| + recoverOn(t, second) |
| + l.bucket.setHook(nil) |
| + pending := 0 |
| + for _, r := range allocRows(t, l.fx.st, ltOne) { |
| + if r.State == lifetimePending { |
| + pending++ |
| + } |
| + } |
| + rets := retireRows(t, l.fx.st, ltOne) |
| + for _, r := range rets { |
| + if r.State == lifetimePending { |
| + pending++ |
| + } |
| + } |
| + if pending != 1 || len(rets) != 1 || rets[0].Cancels != a.OpID || rets[0].State != lifetimePending { |
| + t.Fatalf("allocations %+v, retirements %+v: want exactly the pending retirement", allocRows(t, l.fx.st, ltOne), rets) |
| + } |
| + oneAlloc(t, l.fx.st, ltOne, lifetimeVoid) |
| + recoverOn(t, l.restart(t)) |
| + if r := lastRetirement(t, l.fx.st, ltOne); r.State != lifetimeCommitted { |
| + t.Fatalf("retirement = %+v, want committed", r) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + }) |
| + t.Run("a crash inside the handover changes nothing", func(t *testing.T) { |
| + l := newLtOn(t) |
| + l.pendingCreate(t, ltOne, "record") |
| + trashShop(t, l) |
| + if _, err := l.fx.st.db.Exec(`CREATE TRIGGER lt_fail_handover BEFORE INSERT ON record_retirements |
| +BEGIN SELECT RAISE(ABORT, 'test: the handover is refused'); END`); err != nil { |
| + t.Fatal(err) |
| + } |
| + recoverOn(t, l.restart(t)) |
| + oneAlloc(t, l.fx.st, ltOne, lifetimePending) |
| + if rows := retireRows(t, l.fx.st, ltOne); len(rows) != 0 { |
| + t.Fatalf("retirements = %+v, want none", rows) |
| + } |
| + if _, err := l.fx.st.db.Exec(`DROP TRIGGER lt_fail_handover`); err != nil { |
| + t.Fatal(err) |
| + } |
| + recoverOn(t, l.restart(t)) |
| + oneAlloc(t, l.fx.st, ltOne, lifetimeVoid) |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + }) |
| +} |