console: lifetimes end to end across a landed create's recoveries, a late legacy create and a replayed create interleaved with a move and a retirement
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+452
−0
internal/console/lifetimes_e2e_test.go+452 −0
diff --git a/internal/console/lifetimes_e2e_test.go b/internal/console/lifetimes_e2e_test.go| new file mode 100644 |
| index 000000000..c04e20ffd |
| --- /dev/null |
| +++ b/internal/console/lifetimes_e2e_test.go |
| @@ -0,0 +1,452 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// Lifetimes end to end on the console (self-serve moves plan 1, Task 9): |
| +// the interleavings of design §11 that cross a create, its recovery, a move |
| +// and a retirement, driven through the lifetimes-on console of |
| +// lifetimes_create_test.go (newLtOn: the real server, doors, SQLite and a |
| +// bucket that can fail, pause or record any call) and restarted consoles |
| +// over the same SQLite and bucket. |
| +// |
| +// A move here is the offline doors' own record write (offlineApply, the |
| +// function every offline door ends in) applied in the doors' order, start |
| +// to finish: the doors' box-evidence checks are the move's business, which |
| +// TestOfflineMoveE2E drives against real boxes; what these witnesses need |
| +// is the record the move leaves. A box's view of the bucket is one |
| +// claims-off box reconcile pass (provision.Reconcile with a lifetime judge, |
| +// as the agent's reconcileSerializedWith runs it) into a registry of its |
| +// own. |
| + |
| +// moveThrough runs one whole offline move of host, from its region to to, |
| +// through srv's offline record write in a unit admitted on. |
| +func (l ltOn) moveThrough(t *testing.T, srv *Server, host, id, to string) provision.Record { |
| + t.Helper() |
| + ctx := onCtx(t, srv) |
| + d := offlineDoor{acct: l.acct(t), app: ltApp, host: host} |
| + raw, _ := rawOf(t, l.fx.rel, ltApp, host) |
| + from := decodeRec(t, raw).Region |
| + now := l.clock.Now() |
| + var rec *provision.Record |
| + for _, mut := range []func(*provision.Record) error{ |
| + provision.OfflineStart(id, from, to, "paul@keymail.dev", now), |
| + provision.OfflineSeal(id, "ie-a", []string{"ie-a"}), |
| + provision.OfflineSealedBy(id, "ie-a", provision.MoveSource{}), |
| + provision.OfflineFlip(id), |
| + provision.OfflineLandedBy(id, to), |
| + provision.OfflineOpenTransition(id, now), |
| + provision.OfflineFinishingTransition(id), |
| + provision.OfflineFinish(id, now), |
| + } { |
| + var err error |
| + if rec, err = srv.offlineApply(ctx, d, mut); err != nil { |
| + t.Fatalf("the move of %s: %v", host, err) |
| + } |
| + } |
| + if rec.Move != nil || rec.MoveSeq < 3 || rec.LastMove == nil || rec.LastMove.ID != id { |
| + t.Fatalf("after the move the record is %+v", rec) |
| + } |
| + return *rec |
| +} |
| + |
| +// ltBox is one claims-off box over the console's bucket: its own registry |
| +// and data directory. |
| +type ltBox struct { |
| + reg *registry.Registry |
| + dd string |
| + // claimsOn: the box judges nothing (a claims-on box, the control). |
| + claimsOn bool |
| +} |
| + |
| +func newLtBox(t *testing.T) ltBox { |
| + t.Helper() |
| + dd := t.TempDir() |
| + reg, err := registry.Open(filepath.Join(dd, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + return ltBox{reg: reg, dd: dd} |
| +} |
| + |
| +// pass is one reconcile pass of the box in region over l's bucket, judged |
| +// as a claims-off agent judges it, and the box's row for host afterwards |
| +// (nil: none). |
| +func (b ltBox) pass(t *testing.T, l ltOn, region, host string) *registry.Route { |
| + t.Helper() |
| + cfg := provision.ReconcileConfig{Region: region, DataDir: b.dd} |
| + if !b.claimsOn { |
| + cfg.Judge = provision.NewLifetimeJudge() |
| + } |
| + if err := provision.Reconcile(context.Background(), l.fx.rel, b.reg, cfg); err != nil { |
| + t.Fatalf("the box's reconcile: %v", err) |
| + } |
| + rt, err := b.reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return rt |
| +} |
| + |
| +// requireValid: srv's judged readers in a unit admitted on read host's |
| +// record, at incarnation n with ETag etag. |
| +func requireValid(t *testing.T, srv *Server, host string, n uint64, etag, when string) { |
| + t.Helper() |
| + ctx := onCtx(t, srv) |
| + rec, got, err := srv.readRecord(ctx, "bab", ltApp, host) |
| + if err != nil || rec.Incarnation != n || got != etag { |
| + t.Fatalf("%s: readRecord = %+v %q %v, want incarnation %d at %q", when, rec, got, err, n, etag) |
| + } |
| + if obs := srv.observeRecord(ctx, "bab", ltApp, host); !obs.Present || obs.Verdict != provision.LifetimeValid { |
| + t.Fatalf("%s: observeRecord = present %v, %v; want valid", when, obs.Present, obs.Verdict) |
| + } |
| + recs, _, err := srv.listRecords(ctx, "bab", ltApp, instanceListMax) |
| + if err != nil || !strings.Contains(","+recordHostsOf(recs)+",", ","+host+",") { |
| + t.Fatalf("%s: listRecords = %s %v, want %s in it", when, recordHostsOf(recs), err, host) |
| + } |
| +} |
| + |
| +// TestLandedCreateThenSecondRecoveryCrash is §11's "a create whose record |
| +// landed and is served, a crash before the allocation commits, and |
| +// recovery marking it committed with the account still serving, including |
| +// a second crash during recovery". |
| +func TestLandedCreateThenSecondRecoveryCrash(t *testing.T) { |
| + l := newLtOn(t) |
| + box := newLtBox(t) |
| + a := l.pendingCreate(t, ltOne, "commit") // the record landed; the commit did not |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if raw == nil { |
| + t.Fatal("fixture: the record did not land before the commit failed") |
| + } |
| + _, slotETag := slotOf(t, l.fx.rel, ltOne) |
| + // served: every judged reader of the console, the instance door and a |
| + // box all see the record. |
| + unchanged := func(when string) { |
| + t.Helper() |
| + if now, tag := rawOf(t, l.fx.rel, ltApp, ltOne); tag != etag || !bytes.Equal(now, raw) { |
| + t.Fatalf("%s: the record moved: %s", when, now) |
| + } |
| + if _, tag := slotOf(t, l.fx.rel, ltOne); tag != slotETag { |
| + t.Fatalf("%s: the slot moved", when) |
| + } |
| + if rt := box.pass(t, l, "", ltOne); rt == nil || rt.Status != "ready" || rt.Incarnation != 1 { |
| + t.Fatalf("%s: the box's row is %+v, want ready at incarnation 1", when, rt) |
| + } |
| + } |
| + requireValid(t, l.fx.srv, ltOne, 1, etag, "after the crash") |
| + if code, _, body := rawReq(t, http.MethodGet, instancesURL(l.fx, "bab", ltApp)+"/"+ltOne, l.member, ""); code != http.StatusOK { |
| + t.Fatalf("GET after the crash = %d %s", code, body) |
| + } |
| + unchanged("after the crash") |
| + |
| + // The first recovery, on a restarted console, crashes at the same |
| + // point: the landed branch's commit fails. |
| + second := l.restart(t) |
| + drop := sqlFailCommit(t, l.fx.st) |
| + recoverOn(t, second) |
| + drop() |
| + oneAlloc(t, l.fx.st, ltOne, lifetimePending) |
| + requireValid(t, second, ltOne, 1, etag, "after the second crash") |
| + unchanged("after the second crash") |
| + // The hostname's creates wait on the pending allocation meanwhile. |
| + if code, body := l.create(t, ltOne); code != http.StatusConflict || ltErr(body) != msgLifetimeSettling { |
| + t.Fatalf("a create while pending = %d %s, want 409 settling", code, body) |
| + } |
| + |
| + // A third console's recovery commits it; nothing in the bucket moves. |
| + third := l.restart(t) |
| + recoverOn(t, third) |
| + got := oneAlloc(t, l.fx.st, ltOne, lifetimeCommitted) |
| + if got.OpID != a.OpID { |
| + t.Fatalf("committed %s, want %s", got.OpID, a.OpID) |
| + } |
| + requireServing(t, l.fx.rel, ltApp, ltOne, 1, a.OpID) |
| + requireValid(t, third, ltOne, 1, etag, "after recovery") |
| + unchanged("after recovery") |
| +} |
| + |
| +// TestLateLegacyCreateIsInert is §11's "an old console's fieldless create |
| +// landing after an upgrade, a retry, a move and a retirement, ignored by |
| +// every consumer and removed by the next create". |
| +func TestLateLegacyCreateIsInert(t *testing.T) { |
| + l := newLtOn(t) // the upgrade: lifetimes on |
| + srv := l.fx.srv |
| + // The old console's create was issued before the upgrade and is still |
| + // in flight. The user retries on the new console, the instance moves |
| + // away and back, and it is deleted. |
| + if rec := l.mustCreate(t, ltOne); rec.Incarnation != 1 { |
| + t.Fatalf("the retry created incarnation %d", rec.Incarnation) |
| + } |
| + l.moveThrough(t, srv, ltOne, "mv_"+strings.Repeat("a", 26), "uk") |
| + l.moveThrough(t, srv, ltOne, "mv_"+strings.Repeat("d", 26), "") |
| + // A home box serves the instance back home from the record, minted at |
| + // the second move's (1, 6); it is down from here until the stale body |
| + // has landed. |
| + box, control := newLtBox(t), newLtBox(t) |
| + control.claimsOn = true |
| + if rt := box.pass(t, l, "", ltOne); rt == nil || rt.Incarnation != 1 || rt.ServingSeq != 6 { |
| + t.Fatalf("the home box's row after the moves = %+v, want (1, 6)", rt) |
| + } |
| + if rt := control.pass(t, l, "", ltOne); rt == nil { |
| + t.Fatal("fixture: the control box has no row") |
| + } |
| + if code, body := l.del(t, ltOne); code != http.StatusOK { |
| + t.Fatalf("delete = %d %s", code, body) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + |
| + // Now the old console's create-only write lands on the absent key. |
| + staleETag := legacyBody(t, l.fx.rel, ltApp, ltOne, "edge") |
| + staleRaw, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + slot, slotETag := slotOf(t, l.fx.rel, ltOne) |
| + |
| + // Ignored by every consumer. |
| + ctx := onCtx(t, srv) |
| + if _, _, err := provision.ReadRecordJudged(context.Background(), srv.cfg.Releases, srv.judge, "bab", ltApp, ltOne); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("ReadRecordJudged = %v, want not found", err) |
| + } |
| + if _, _, err := srv.readRecord(ctx, "bab", ltApp, ltOne); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("readRecord = %v, want not found", err) |
| + } |
| + if obs := srv.observeRecord(ctx, "bab", ltApp, ltOne); !obs.Present || obs.Verdict != provision.LifetimeStale { |
| + t.Errorf("observeRecord = present %v, %v; want a present stale body", obs.Present, obs.Verdict) |
| + } |
| + if recs, _, err := srv.listRecords(ctx, "bab", ltApp, instanceListMax); err != nil || len(recs) != 0 { |
| + t.Errorf("listRecords = %s %v, want none", recordHostsOf(recs), err) |
| + } |
| + if recs, unreadable, err := srv.listAllRecordsStrict(ctx); err != nil || len(unreadable) != 0 || len(recs) != 0 { |
| + t.Errorf("listAllRecordsStrict = %s %v %v, want none", recordHostsOf(recs), unreadable, err) |
| + } |
| + requireListing(t, l.lifetimesFx) |
| + if code, _, body := rawReq(t, http.MethodGet, instancesURL(l.fx, "bab", ltApp)+"/"+ltOne, l.member, ""); code != http.StatusNotFound { |
| + t.Errorf("GET = %d %s, want 404", code, body) |
| + } |
| + rec := decodeRec(t, staleRaw) |
| + rec.Gate = "maintenance" |
| + if err := provision.UpdateRecord(ctx, srv.cfg.Releases, rec, staleETag); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("UpdateRecord = %v, want not found", err) |
| + } |
| + if _, err := srv.offlineApply(ctx, offlineDoor{acct: l.acct(t), app: ltApp, host: ltOne}, |
| + provision.OfflineStart("mv_"+strings.Repeat("b", 26), "", "uk", "paul@keymail.dev", l.clock.Now())); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("the offline start's write = %v, want not found", err) |
| + } |
| + srv.cfg.LifetimesMigrate = true |
| + if !srv.incarnationMigrationIteration(context.Background()) { |
| + t.Fatal("the migration pass did not run with lifetimes on") |
| + } |
| + if rows := migRows(t, l.fx.st, ltOne); len(rows) != 0 { |
| + t.Errorf("migrations = %+v, want none", rows) |
| + } |
| + recoverOn(t, srv) |
| + // The box, back from its outage, removes the row of the lifetime that |
| + // ended rather than keeping it for the body now under the key (the |
| + // control, a claims-on box, keeps it: unjudged the body is a valid |
| + // legacy record of its region), and a fresh box mints nothing from it. |
| + if rt := box.pass(t, l, "", ltOne); rt != nil { |
| + t.Errorf("the box kept lifetime 1's row beside the stale body: %+v", rt) |
| + } |
| + if rt := newLtBox(t).pass(t, l, "", ltOne); rt != nil { |
| + t.Errorf("a fresh claims-off box minted the stale body: %+v", rt) |
| + } |
| + if rt := control.pass(t, l, "", ltOne); rt == nil { |
| + t.Fatal("fixture: unjudged, the box drops the row too, so the judged pass proves nothing") |
| + } |
| + if now, tag := rawOf(t, l.fx.rel, ltApp, ltOne); tag != staleETag || !bytes.Equal(now, staleRaw) { |
| + t.Fatalf("a consumer wrote the stale body:\n%s", now) |
| + } |
| + if now, tag := slotOf(t, l.fx.rel, ltOne); tag != slotETag || *now != *slot { |
| + t.Fatalf("a consumer wrote the slot: %+v", now) |
| + } |
| + |
| + // Removed by the next create, on its own ETag. |
| + if code, body := l.create(t, ltOne); code != http.StatusCreated { |
| + t.Fatalf("the next create = %d %s", code, body) |
| + } |
| + var next allocRow |
| + for _, a := range allocRows(t, l.fx.st, ltOne) { |
| + if a.Incarnation == 2 { |
| + next = a |
| + } |
| + } |
| + if next.State != lifetimeCommitted || next.Stale != staleETag { |
| + t.Fatalf("the next create's allocation = %+v, want committed at 2 over the stale %q", next, staleETag) |
| + } |
| + if !contains(l.bucket.opsOf(recKey(ltApp, ltOne)), "delete-if-match") { |
| + t.Fatal("the stale body was not deleted on its ETag") |
| + } |
| + created := requireServing(t, l.fx.rel, ltApp, ltOne, 2, next.OpID) |
| + if created.Channel == "edge" { |
| + t.Fatalf("the record is the stale body's: %+v", created) |
| + } |
| + if rt := box.pass(t, l, "", ltOne); rt == nil || rt.Incarnation != 2 || rt.ServingSeq != 0 { |
| + t.Fatalf("the box's row after the next create = %+v, want (2, 0)", rt) |
| + } |
| +} |
| + |
| +// lateAnswer is what the first console's create door answered, late. |
| +type lateAnswer struct { |
| + code int |
| + body string |
| +} |
| + |
| +// replayedAndMoved is the interleave's start: the first console's |
| +// create-only record write held in flight, a second console replaying the |
| +// create from SQLite, and a completed move. It returns the second console, |
| +// the held write's gate and the first console's answer to come. |
| +func replayedAndMoved(t *testing.T, l ltOn) (*Server, *pauseGate, <-chan lateAnswer) { |
| + t.Helper() |
| + gate, hook := pauseNth(t, "put", recKey(ltApp, ltOne), 1) |
| + l.bucket.setHook(hook) |
| + answered := make(chan lateAnswer, 1) |
| + go func() { code, body := l.create(t, ltOne); answered <- lateAnswer{code, body} }() |
| + waitFor(t, gate.entered, "the first console's record write") |
| + |
| + // A second console replays the create from SQLite. |
| + second := l.restart(t) |
| + recoverOn(t, second) |
| + a := oneAlloc(t, l.fx.st, ltOne, lifetimeCommitted) |
| + requireServing(t, l.fx.rel, ltApp, ltOne, 1, a.OpID) |
| + |
| + // A completed move. |
| + if rec := l.moveThrough(t, second, ltOne, "mv_"+strings.Repeat("c", 26), "uk"); rec.MoveSeq != 3 { |
| + t.Fatalf("after the move move_seq is %d", rec.MoveSeq) |
| + } |
| + return second, gate, answered |
| +} |
| + |
| +// TestDelayedCreateReplayMoveAndRetirementInterleave is §11's "a replayed |
| +// create, a completed move, a delete that crashes between the tombstone and |
| +// the slot, and the late original create failing on the tombstone, then the |
| +// slot retired and the tombstone removed". The second subtest is the other |
| +// half of §6.4's sentence on it: once the tombstone is gone the slot is |
| +// already retired, so the late write that lands then is stale to every |
| +// consumer, and the next create replaces it. |
| +func TestDelayedCreateReplayMoveAndRetirementInterleave(t *testing.T) { |
| + t.Run("the late create fails on the tombstone", func(t *testing.T) { |
| + l := newLtOn(t) |
| + key, slotKey := recKey(ltApp, ltOne), provision.HostIncarnationKey(ltOne) |
| + second, gate, answered := replayedAndMoved(t, l) |
| + // A box in uk serves the moved instance. |
| + box := newLtBox(t) |
| + if rt := box.pass(t, l, "uk", ltOne); rt == nil || rt.Incarnation != 1 || rt.ServingSeq != 3 { |
| + t.Fatalf("the uk box's row after the move = %+v, want (1, 3)", rt) |
| + } |
| + |
| + // The delete crashes between the tombstone and the slot. |
| + l.bucket.setHook(failNth("put", slotKey, 1, false)) |
| + if err := retireOn(t, second, ltOne, retireAny); !errors.Is(err, errLifetimePending) { |
| + t.Fatalf("the delete = %v, want pending", err) |
| + } |
| + l.bucket.setHook(nil) |
| + r := lastRetirement(t, l.fx.st, ltOne) |
| + if r.State != lifetimePending { |
| + t.Fatalf("retirement = %+v, want pending", r) |
| + } |
| + tombRaw, tombETag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if tomb := decodeRec(t, tombRaw); !tomb.Retired || tomb.Incarnation != 1 { |
| + t.Fatalf("under the key after the crash: %s, want the tombstone of 1", tombRaw) |
| + } |
| + if slot, _ := slotOf(t, l.fx.rel, ltOne); slot == nil || slot.State != provision.SlotLive || slot.Incarnation != 1 { |
| + t.Fatalf("slot after the crash = %+v, want {1, live}", slot) |
| + } |
| + // The tombstone is absent to the box under the live slot. |
| + if rt := box.pass(t, l, "uk", ltOne); rt != nil { |
| + t.Fatalf("the uk box kept its row beside the tombstone: %+v", rt) |
| + } |
| + |
| + // The late original create arrives: its create-only write fails on |
| + // the tombstone, and the first console answers that it could not |
| + // confirm. |
| + gate.open() |
| + late := <-answered |
| + if late.code != http.StatusBadGateway || ltErr(late.body) != msgLifetimeCreatePending { |
| + t.Fatalf("the late create answered %d %s, want 502 %q", late.code, late.body, msgLifetimeCreatePending) |
| + } |
| + if now, tag := rawOf(t, l.fx.rel, ltApp, ltOne); tag != tombETag || !bytes.Equal(now, tombRaw) { |
| + t.Fatalf("the late create changed the tombstone: %s", now) |
| + } |
| + if rows := allocRows(t, l.fx.st, ltOne); len(rows) != 1 || rows[0].State != lifetimeCommitted { |
| + t.Fatalf("allocations after the late create = %+v, want the one, committed", rows) |
| + } |
| + |
| + if rt := box.pass(t, l, "uk", ltOne); rt != nil { |
| + t.Fatalf("the uk box minted a row after the late create: %+v", rt) |
| + } |
| + |
| + // Recovery: the slot retired, then the tombstone removed. |
| + l.recoveredTo(t, r.OpID, lifetimeCommitted) |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + if ops := l.bucket.opsOf(key); !contains(ops, "delete-if-match") { |
| + t.Fatalf("record ops = %v, want the tombstone deleted on its ETag", ops) |
| + } |
| + // The hostname's next lifetime is 2. |
| + if rec := l.mustCreate(t, ltOne); rec.Incarnation != 2 { |
| + t.Fatalf("the next create is at %d, want 2", rec.Incarnation) |
| + } |
| + }) |
| + |
| + t.Run("the late create lands after the tombstone went", func(t *testing.T) { |
| + l := newLtOn(t) |
| + second, gate, answered := replayedAndMoved(t, l) |
| + l.bucket.setHook(nil) |
| + if err := retireOn(t, second, ltOne, retireAny); err != nil { |
| + t.Fatalf("the delete = %v", err) |
| + } |
| + requireRetired(t, l.fx.rel, ltApp, ltOne, 1) |
| + |
| + // The late original create's write lands on the absent key: the |
| + // record of lifetime 1 is back under the slot {1, retired}. |
| + gate.open() |
| + late := <-answered |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if raw == nil || decodeRec(t, raw).Incarnation != 1 { |
| + t.Fatalf("fixture: the late write did not land (answered %d %s): %s", late.code, late.body, raw) |
| + } |
| + // The first console's commit meets the allocation recovery already |
| + // committed, so its door cannot confirm the create. |
| + if late.code != http.StatusBadGateway || ltErr(late.body) != msgLifetimeCreatePending { |
| + t.Fatalf("the late create answered %d %s, want 502 %q", late.code, late.body, msgLifetimeCreatePending) |
| + } |
| + if rows := allocRows(t, l.fx.st, ltOne); len(rows) != 1 || rows[0].State != lifetimeCommitted { |
| + t.Fatalf("allocations after the late create = %+v, want the one, committed", rows) |
| + } |
| + // Stale to every consumer. |
| + ctx := onCtx(t, second) |
| + if _, _, err := second.readRecord(ctx, "bab", ltApp, ltOne); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("readRecord = %v, want not found", err) |
| + } |
| + if code, _, body := rawReq(t, http.MethodGet, instancesURL(l.fx, "bab", ltApp)+"/"+ltOne, l.member, ""); code != http.StatusNotFound { |
| + t.Fatalf("GET = %d %s, want 404", code, body) |
| + } |
| + if rt := newLtBox(t).pass(t, l, "uk", ltOne); rt != nil { |
| + t.Fatalf("a box minted the late record: %+v", rt) |
| + } |
| + // The next create replaces it, on its ETag. |
| + code, body := l.create(t, ltOne) |
| + if code != http.StatusCreated { |
| + t.Fatalf("the next create = %d %s", code, body) |
| + } |
| + var next allocRow |
| + for _, a := range allocRows(t, l.fx.st, ltOne) { |
| + if a.Incarnation == 2 { |
| + next = a |
| + } |
| + } |
| + if next.State != lifetimeCommitted || next.Stale != etag { |
| + t.Fatalf("the next create's allocation = %+v, want committed at 2 over the late body's %q", next, etag) |
| + } |
| + requireServing(t, l.fx.rel, ltApp, ltOne, 2, next.OpID) |
| + }) |
| +} |