console: a migration adopts a marker whose operation is gone, the pass selects such records, and an idle pass waits ten minutes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+235
−30
internal/console/lifetimes_migrate.go+82 −23internal/console/lifetimes_migrate_test.go+153 −7
diff --git a/internal/console/lifetimes_migrate.go b/internal/console/lifetimes_migrate.go| index d7612ad60..f300f6151 100644 |
| --- a/internal/console/lifetimes_migrate.go |
| +++ b/internal/console/lifetimes_migrate.go |
| @@ -30,7 +30,10 @@ import ( |
| // operation's and finished, never stranded. |
| const ( |
| - lifetimesMigrateInterval = 60 * time.Second |
| + // lifetimesMigrateInterval is the pass's cadence while it finds work; |
| + // lifetimesMigrateIdleInterval after a pass that found no candidate. |
| + lifetimesMigrateInterval = 60 * time.Second |
| + lifetimesMigrateIdleInterval = 10 * time.Minute |
| // migrationPassLimit is how many records one pass migrates at most. |
| migrationPassLimit = 50 |
| // migrationMarkRounds bounds an unbound migration's marker CAS retries |
| @@ -62,6 +65,14 @@ func fieldless(rec *provision.Record) bool { |
| return rec != nil && rec.Incarnation == 0 && rec.IncarnationMigrating == "" && !rec.Retired |
| } |
| +// preLifetime reports whether rec has no lifetime yet: no incarnation, not a |
| +// tombstone, marked or not. The pass selects these; a marker on one it is |
| +// admitted for belongs to an operation whose row is gone, and the migration |
| +// adopts it. |
| +func preLifetime(rec *provision.Record) bool { |
| + return rec != nil && rec.Incarnation == 0 && !rec.Retired |
| +} |
| + |
| // migrationAction is what the dispatcher chose. |
| type migrationAction int |
| @@ -69,6 +80,7 @@ const ( |
| migrateOwnMarker migrationAction = iota + 1 // the record carries this migration's marker: (2), (3) |
| migrateDone // incarnation 1 under this migration's slot: committed |
| migrateMark // eligible: (1) |
| + migrateAdopt // another operation's marker and no slot: (1) over it |
| migrateMoved // bound, eligible at another ETag: fence, superseded, a CAS conflict |
| migrateWait // unreadable: pending |
| migrateQuarantine // a corrupt body and no slot: copy it aside, delete it, superseded |
| @@ -91,10 +103,16 @@ func migrationDispatch(obs provision.Observation, m Migration) migrationAction { |
| return migrateOwnMarker |
| case !rec.Retired && rec.IncarnationMigrating == "" && rec.Incarnation == 1 && migrationOwnsSlot(obs.Slot, m): |
| return migrateDone |
| - case fieldless(rec) && obs.Slot == nil: |
| + case preLifetime(rec) && obs.Slot == nil: |
| + // Unmarked, or marked by another operation: admitted under the |
| + // hostname lock, no other operation of the hostname is pending, so |
| + // that marker's row is gone and nothing will finish it but us. |
| if m.ExpectETag != "" && obs.ETag != m.ExpectETag { |
| return migrateMoved |
| } |
| + if rec.IncarnationMigrating != "" { |
| + return migrateAdopt |
| + } |
| return migrateMark |
| } |
| return migrateSupersede |
| @@ -167,7 +185,7 @@ func (s *Server) migrationStepsLocked(ctx context.Context, m Migration) (*provis |
| return obs.Rec, obs.Raw, obs.ETag, lifetimeCommitted, nil |
| case migrateOwnMarker: |
| return s.migrationFinishLocked(ctx, m, obs) |
| - case migrateMark: |
| + case migrateMark, migrateAdopt: |
| if marks == migrationMarkRounds { |
| logf("the record kept changing under %d marker writes (pending)", marks) |
| return migrationPending() |
| @@ -216,6 +234,9 @@ func (s *Server) migrationStepsLocked(ctx context.Context, m Migration) (*provis |
| if !obs.Present { |
| return s.settleMigration(m, lifetimeSuperseded, nil) |
| } |
| + if obs.Verdict == provision.LifetimeCorrupt { |
| + s.lifetimeLogHourly("migration-corrupt "+m.OpID, "console: lifetimes: migration %s: the body at %s is corrupt under a slot and needs operator attention", m.OpID, key) |
| + } |
| _, redispatch, err := s.terminateFenced(ctx, m.Account, m.App, m.Host, fenceOwner{MarkerID: m.OpID}, func() error { |
| return s.store.SetMigrationState(m.OpID, lifetimeSuperseded) |
| }) |
| @@ -232,15 +253,23 @@ func (s *Server) migrationStepsLocked(ctx context.Context, m Migration) (*provis |
| return migrationPending() |
| } |
| -// migrationMarkLocked is step (1): the observed bytes with the marker set, |
| -// by CAS from the observed ETag, the record sequence on. It returns the |
| -// marked ETag. |
| +// migrationMarkLocked is step (1): the observed bytes with this migration's |
| +// marker set, by CAS from the observed ETag, the record sequence on. Over |
| +// another operation's marker (an adoption) the write carries that marker's |
| +// id, the one writer the marker admits. It returns the marked ETag. |
| func (s *Server) migrationMarkLocked(ctx context.Context, m Migration, obs provision.Observation) (string, error) { |
| + writer, err := provision.RecordMarker(obs.Raw) |
| + if err != nil { |
| + return "", err |
| + } |
| + if writer == "" { |
| + writer = m.OpID |
| + } |
| marked, err := provision.MarkMigrating(obs.Raw, m.OpID) |
| if err != nil { |
| return "", err |
| } |
| - wctx := provision.WithMigration(provision.WithRecordSequence(ctx), m.OpID) |
| + wctx := provision.WithMigration(provision.WithRecordSequence(ctx), writer) |
| etag, _, err := provision.WriteRecord(wctx, s.cfg.Releases, provision.RecordKey(m.Account, m.App, m.Host), obs.Raw, marked, obs.ETag) |
| return etag, err |
| } |
| @@ -368,34 +397,64 @@ func (s *Server) StartIncarnationMigrationPass(ctx context.Context) { |
| if !s.lifetimesGoverned() { |
| return |
| } |
| - lifetimesWorker(ctx, lifetimesMigrateInterval, func(ctx context.Context) { s.incarnationMigrationIteration(ctx) }) |
| + // Task 0's worker lifecycle, paced: it never returns because of the |
| + // gate's state, and each iteration admits for itself. |
| + for { |
| + ran, candidates := s.incarnationMigrationPass(ctx) |
| + t := time.NewTimer(migrationPassInterval(ran, candidates)) |
| + select { |
| + case <-ctx.Done(): |
| + t.Stop() |
| + return |
| + case <-t.C: |
| + } |
| + } |
| +} |
| + |
| +// migrationPassInterval is the wait before the next pass: ten minutes after |
| +// a pass that ran and found no candidate, 60 seconds otherwise. |
| +func migrationPassInterval(ran bool, candidates int) time.Duration { |
| + if ran && candidates == 0 { |
| + return lifetimesMigrateIdleInterval |
| + } |
| + return lifetimesMigrateInterval |
| } |
| // incarnationMigrationIteration is one pass as a unit: skipped unless the |
| // flag is set and the unit is admitted on. It reports whether the pass ran. |
| func (s *Server) incarnationMigrationIteration(ctx context.Context) bool { |
| + ran, _ := s.incarnationMigrationPass(ctx) |
| + return ran |
| +} |
| + |
| +// incarnationMigrationPass is incarnationMigrationIteration with the number |
| +// of candidates the pass found. |
| +func (s *Server) incarnationMigrationPass(ctx context.Context) (ran bool, candidates int) { |
| if !s.cfg.LifetimesMigrate { |
| - return false |
| + return false, 0 |
| } |
| - return s.lifetimesIteration(ctx, "incarnation migration", true, true, func(ctx context.Context) { |
| - if err := s.migrateFieldlessRecords(ctx); err != nil { |
| + ran = s.lifetimesIteration(ctx, "incarnation migration", true, true, func(ctx context.Context) { |
| + var err error |
| + if candidates, err = s.migratePreLifetimeRecords(ctx); err != nil { |
| log.Printf("console: incarnation migration pass: %v", err) |
| } |
| }) |
| + return ran, candidates |
| } |
| -// migrateFieldlessRecords is one pass: at most migrationPassLimit fieldless |
| -// records, oldest first, each under s.lockApp then the hostname lock, |
| -// admitted, and migrated unbound only while it is still fieldless and |
| -// unmarked with no slot for its hostname. |
| -func (s *Server) migrateFieldlessRecords(ctx context.Context) error { |
| +// migratePreLifetimeRecords is one pass: at most migrationPassLimit records |
| +// with no lifetime yet (fieldless, or carrying a marker whose operation is |
| +// gone), oldest first, each under s.lockApp then the hostname lock, |
| +// admitted, and migrated unbound only while it still has no lifetime and no |
| +// slot for its hostname. It returns how many candidates it found. |
| +func (s *Server) migratePreLifetimeRecords(ctx context.Context) (int, error) { |
| recs, _, err := provision.ListAllRecordsTolerantJudged(ctx, s.cfg.Releases, s.judge) |
| if err != nil { |
| - return fmt.Errorf("list the records: %w", err) |
| + return 0, fmt.Errorf("list the records: %w", err) |
| } |
| var candidates []provision.Record |
| for i := range recs { |
| - if fieldless(&recs[i]) { |
| + if preLifetime(&recs[i]) { |
| candidates = append(candidates, recs[i]) |
| } |
| } |
| @@ -406,9 +465,9 @@ func (s *Server) migrateFieldlessRecords(ctx context.Context) error { |
| } |
| return provision.RecordKey(a.Account, a.App, a.Host) < provision.RecordKey(b.Account, b.App, b.Host) |
| }) |
| - migrated := 0 |
| + attempted := 0 |
| for _, rec := range candidates { |
| - if migrated == migrationPassLimit { |
| + if attempted == migrationPassLimit { |
| break |
| } |
| _ = s.withAppHost(rec.Account, rec.App, rec.Host, func() error { |
| @@ -416,10 +475,10 @@ func (s *Server) migrateFieldlessRecords(ctx context.Context) error { |
| return nil |
| } |
| obs, err := s.classifyLocked(ctx, rec.Account, rec.App, rec.Host) |
| - if err != nil || !obs.Present || !fieldless(obs.Rec) || obs.Slot != nil { |
| + if err != nil || !obs.Present || !preLifetime(obs.Rec) || obs.Slot != nil { |
| return nil |
| } |
| - migrated++ |
| + attempted++ |
| _, _, _, state, err := s.migrateHostLifetimeLocked(ctx, rec.Account, rec.App, rec.Host, "") |
| if err != nil || state != lifetimeCommitted { |
| log.Printf("console: incarnation migration pass: %s/%s/%s: %s %v", rec.Account, rec.App, rec.Host, state, err) |
| @@ -427,5 +486,5 @@ func (s *Server) migrateFieldlessRecords(ctx context.Context) error { |
| return nil |
| }) |
| } |
| - return nil |
| + return len(candidates), nil |
| } |
diff --git a/internal/console/lifetimes_migrate_test.go b/internal/console/lifetimes_migrate_test.go| index 98e70b7eb..3c0c7d1c0 100644 |
| --- a/internal/console/lifetimes_migrate_test.go |
| +++ b/internal/console/lifetimes_migrate_test.go |
| @@ -700,7 +700,7 @@ func TestMigrationRecoveryBranches(t *testing.T) { |
| t.Fatalf("slot = %+v, want %+v untouched", slot, want) |
| } |
| }) |
| - t.Run("another operation's marker", func(t *testing.T) { |
| + t.Run("another operation's marker, no slot: adopted", func(t *testing.T) { |
| l := newLtOn(t) |
| e := legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| beginMig(t, l, migOp, "") |
| @@ -709,14 +709,58 @@ func TestMigrationRecoveryBranches(t *testing.T) { |
| if _, err := l.fx.rel.Put(ctx, key, other, e); err != nil { |
| t.Fatal(err) |
| } |
| + // The first recovery adopts the marker, then stops at the slot. |
| + srv := l.restart(t) |
| + l.bucket.setHook(failNth("put", slotKey, 1, false)) |
| + recoverOn(t, srv) |
| + l.bucket.setHook(nil) |
| + row := oneMig(t, l.fx.st, ltOne, lifetimePending) |
| + raw, etag := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if got := decodeRec(t, raw); got.IncarnationMigrating != migOp || got.RecordSeq != 1 || row.Marked != etag { |
| + t.Fatalf("record = %s, row %+v: want this migration's marker over the other, record_seq 1, the marked ETag persisted", raw, row) |
| + } |
| + recoverAgain(t, l) |
| + oneMig(t, l.fx.st, ltOne, lifetimeCommitted) |
| + requireMigrated(t, l.fx.rel, ltOne, migOp) |
| + }) |
| + t.Run("another operation's marker under a foreign slot", func(t *testing.T) { |
| + l := newLtOn(t) |
| + e := legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| + beginMig(t, l, migOp, "") |
| + a, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + other, _ := provision.MarkMigrating(a, "other") |
| + if _, err := l.fx.rel.Put(ctx, key, other, e); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := provision.HostIncarnation{Incarnation: 1, State: provision.SlotLive, Account: "bab", App: "blog", Intent: "create other"} |
| + writeSlot(t, l.fx.rel, ltOne, want) |
| recoverAgain(t, l) |
| oneMig(t, l.fx.st, ltOne, lifetimeSuperseded) |
| raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| if got := decodeRec(t, raw); got.IncarnationMigrating != "other" || got.RecordSeq != 1 { |
| - t.Fatalf("record = %s, want the other marker kept and the record fenced", raw) |
| + t.Fatalf("record = %s, want the other marker left and the record fenced", raw) |
| } |
| - if slot, _ := slotOf(t, l.fx.rel, ltOne); slot != nil { |
| - t.Fatalf("slot = %+v, want none", slot) |
| + if slot, _ := slotOf(t, l.fx.rel, ltOne); slot == nil || *slot != want { |
| + t.Fatalf("slot = %+v, want %+v untouched", slot, want) |
| + } |
| + }) |
| + t.Run("own marker under its slot bound to another body", func(t *testing.T) { |
| + l := newLtOn(t) |
| + e := legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| + beginMig(t, l, migOp, "") |
| + a, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + marked, _ := provision.MarkMigrating(a, migOp) |
| + me, err := l.fx.rel.Put(ctx, key, marked, e) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 1, State: provision.SlotLive, Account: "bab", App: ltApp, |
| + Intent: "migration " + migOp, RecordETag: "another-body"}) |
| + recoverAgain(t, l) |
| + row := oneMig(t, l.fx.st, ltOne, lifetimeCommitted) |
| + requireMigrated(t, l.fx.rel, ltOne, migOp) |
| + if slot, _ := slotOf(t, l.fx.rel, ltOne); slot.RecordETag != me || row.Marked != me { |
| + t.Fatalf("slot bound to %q, row %+v: want the slot rebound to the marked record %q", slot.RecordETag, row, me) |
| } |
| }) |
| t.Run("a slot not of this migration", func(t *testing.T) { |
| @@ -837,8 +881,17 @@ func TestMigrationSkipsAFieldlessRecordBesideAnUnrelatedSlot(t *testing.T) { |
| // ---- the judge agrees ----------------------------------------------------------- |
| func TestJudgeKeepsAMarkedRecordUnderItsMigrationSlot(t *testing.T) { |
| + for _, start := range []string{"a fieldless record", "a marker whose operation is gone"} { |
| + t.Run(start, func(t *testing.T) { testJudgeAlongTheMigration(t, start != "a fieldless record") }) |
| + } |
| +} |
| + |
| +// testJudgeAlongTheMigration runs one migration with its slot write and its |
| +// final write held and asks the judge at every step; adopt starts from a |
| +// record marked by an operation that has no row. |
| +func testJudgeAlongTheMigration(t *testing.T, adopt bool) { |
| l := newLtOn(t) |
| - legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| + e := legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| key, slotKey := recKey(ltApp, ltOne), provision.HostIncarnationKey(ltOne) |
| judge := provision.NewLifetimeJudge() |
| observe := func(want provision.LifetimeVerdict, what string) provision.Observation { |
| @@ -849,6 +902,16 @@ func TestJudgeKeepsAMarkedRecordUnderItsMigrationSlot(t *testing.T) { |
| } |
| return obs |
| } |
| + if adopt { |
| + a, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + lost, _ := provision.MarkMigrating(a, "lost") |
| + if _, err := l.fx.rel.Put(context.Background(), key, lost, e); err != nil { |
| + t.Fatal(err) |
| + } |
| + if obs := observe(provision.LifetimeValid, "marked by a lost operation, no slot"); obs.Rec.IncarnationMigrating != "lost" { |
| + t.Fatalf("observation = %+v, want the lost marker", obs) |
| + } |
| + } |
| sgate, shook := pauseNth(t, "put", slotKey, 1) |
| fgate, fhook := pauseNth(t, "put", key, 2) |
| l.bucket.setHook(chain(shook, fhook)) |
| @@ -856,8 +919,8 @@ func TestJudgeKeepsAMarkedRecordUnderItsMigrationSlot(t *testing.T) { |
| waitFor(t, sgate.entered, "the slot write") |
| obs := observe(provision.LifetimeValid, "marked, no slot") |
| - if obs.Rec.IncarnationMigrating == "" || obs.Slot != nil { |
| - t.Fatalf("observation = %+v, want the marked record and no slot", obs) |
| + if obs.Rec.IncarnationMigrating == "" || obs.Rec.IncarnationMigrating == "lost" || obs.Slot != nil { |
| + t.Fatalf("observation = %+v, want this migration's marked record and no slot", obs) |
| } |
| op := obs.Rec.IncarnationMigrating |
| @@ -944,3 +1007,86 @@ func TestMigrationWaitsForAnotherPendingOperation(t *testing.T) { |
| oneMig(t, l.fx.st, ltOne, lifetimeCommitted) |
| requireMigrated(t, l.fx.rel, ltOne, migOp) |
| } |
| + |
| +func TestMigrationPassAdoptsAMarkerWhoseOperationIsGone(t *testing.T) { |
| + l := newLtOn(t) |
| + l.fx.srv.cfg.LifetimesMigrate = true |
| + e := legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| + a, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + lost, _ := provision.MarkMigrating(a, "lost") |
| + if _, err := l.fx.rel.Put(context.Background(), recKey(ltApp, ltOne), lost, e); err != nil { |
| + t.Fatal(err) |
| + } |
| + // No row for "lost" and no slot: an ordinary writer can only stall on it. |
| + t.Cleanup(provision.SetRecordMigrationWaitForTest(30*time.Millisecond, 10*time.Millisecond)) |
| + if err := provision.SetRecordRegion(onCtx(t, l.fx.srv), l.bucket, "bab", ltApp, ltOne, "uk"); !errors.Is(err, provision.ErrRecordMigrating) { |
| + t.Fatalf("a writer over the stranded marker = %v, want ErrRecordMigrating", err) |
| + } |
| + ran, candidates := l.fx.srv.incarnationMigrationPass(context.Background()) |
| + if !ran || candidates != 1 { |
| + t.Fatalf("the pass ran %v with %d candidates, want it to select the marked record", ran, candidates) |
| + } |
| + row := oneMig(t, l.fx.st, ltOne, lifetimeCommitted) |
| + requireMigrated(t, l.fx.rel, ltOne, row.OpID) |
| + if err := provision.SetRecordRegion(onCtx(t, l.fx.srv), l.bucket, "bab", ltApp, ltOne, "uk"); err != nil { |
| + t.Fatalf("a writer after the adoption = %v", err) |
| + } |
| +} |
| + |
| +func TestMigrationPassSlowsDownWhenIdle(t *testing.T) { |
| + for _, c := range []struct { |
| + ran bool |
| + candidates int |
| + want time.Duration |
| + }{ |
| + {true, 0, 10 * time.Minute}, |
| + {true, 1, time.Minute}, |
| + {true, 120, time.Minute}, |
| + {false, 0, time.Minute}, |
| + } { |
| + if got := migrationPassInterval(c.ran, c.candidates); got != c.want { |
| + t.Fatalf("migrationPassInterval(%v, %d) = %v, want %v", c.ran, c.candidates, got, c.want) |
| + } |
| + } |
| + // The pass reports what it found: one candidate, then none. |
| + l := newLtOn(t) |
| + l.fx.srv.cfg.LifetimesMigrate = true |
| + legacyBody(t, l.fx.rel, ltApp, ltOne, "stable") |
| + if ran, n := l.fx.srv.incarnationMigrationPass(context.Background()); !ran || n != 1 { |
| + t.Fatalf("first pass = %v %d, want one candidate", ran, n) |
| + } |
| + if ran, n := l.fx.srv.incarnationMigrationPass(context.Background()); !ran || n != 0 || migrationPassInterval(ran, n) != lifetimesMigrateIdleInterval { |
| + t.Fatalf("second pass = %v %d, want none and the idle cadence", ran, n) |
| + } |
| +} |
| + |
| +func TestMigrationFenceReadsOneMarkerAcrossSpellings(t *testing.T) { |
| + l := newLtOn(t) |
| + ctx := context.Background() |
| + rec := provision.Record{Schema: 1, Host: ltOne, Slug: "one", App: ltApp, Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC()} |
| + body, _ := json.Marshal(rec) |
| + // Two spellings with different values; DecodeRecord reads the last. |
| + body = bytes.Replace(body, []byte(`{`), []byte(`{"Incarnation_Migrating":"first","incarnation_migrating":"second",`), 1) |
| + if _, err := l.fx.rel.Put(ctx, recKey(ltApp, ltOne), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := provision.RecordMarker(body); err != nil || got != "second" || decodeRec(t, body).IncarnationMigrating != "second" { |
| + t.Fatalf("RecordMarker = %q %v, want the marker DecodeRecord reads", got, err) |
| + } |
| + var res fenceResult |
| + _ = l.fx.srv.withHostname(ltOne, func() error { |
| + var err error |
| + res, _, err = l.fx.srv.fenceRecord(ctx, "bab", ltApp, ltOne, fenceOwner{Tombstone: true}) |
| + if err != nil { |
| + t.Fatalf("the fence = %v, want it written", err) |
| + } |
| + return nil |
| + }) |
| + if res != fenced { |
| + t.Fatalf("the fence = %v, want fenced", res) |
| + } |
| + raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne) |
| + if got := decodeRec(t, raw); got.RecordSeq != 1 || got.IncarnationMigrating != "second" { |
| + t.Fatalf("record = %s, want the same body fenced", raw) |
| + } |
| +} |