host: a claims-on box releases the offline open and revert through today's ReleaseHold
Task 7 fix round 1, item 1. offlineRelease dispatches: with no judge (claims on) the open and the revert release through moveCfg.ReleaseHold, the row's pair untouched, so a row at an incarnation the record has since left is released rather than wedged; with a judge, releaseObserved and ReleaseHoldSeq with the observed pair as before. TestOfflineMoveE2E gains the claims-on open and revert over rows at incarnation 1 under a record rewritten to 2.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+119
−23
internal/host/movesoffline.go+36 −11internal/host/movesoffline_e2e_test.go+72 −3internal/host/movesoffline_land.go+8 −9internal/host/movesoffline_land_test.go+3 −0
diff --git a/internal/host/movesoffline.go b/internal/host/movesoffline.go| index aff34ded4..3052233d5 100644 |
| --- a/internal/host/movesoffline.go |
| +++ b/internal/host/movesoffline.go |
| @@ -315,9 +315,37 @@ func offlinePair(rec provision.Record) registry.Pair { |
| return p |
| } |
| -// releaseObserved is an offline release under reconcileMu and the host's |
| -// hold lock (the caller holds both): the record is observed afresh through |
| -// the box's judge, and the row is released for the move only while that |
| +// errNoOfflineRelease is an offline release on a box with no registry to |
| +// release through. |
| +var errNoOfflineRelease = errors.New("offline move: no registry to release the hold through") |
| + |
| +// offlineRelease is the offline open's and revert's release, under |
| +// reconcileMu and the host's hold lock (the caller holds both). With no |
| +// judge (claims on) it is today's release exactly: moveCfg.ReleaseHold under |
| +// the move's op, the row's pair untouched, so a row at an incarnation the |
| +// record has since left is released rather than wedged. With a judge it is |
| +// releaseObserved. true means the release ran (the caller re-reads the row); |
| +// false with no error means this pass skips it. |
| +func (w *movesWorker) offlineRelease(ctx context.Context, s release.Store, rec provision.Record, want string) (bool, error) { |
| + cfg := w.cfg |
| + if cfg.Judge == nil { |
| + if cfg.ReleaseHold == nil { |
| + return false, errNoOfflineRelease |
| + } |
| + if _, err := cfg.ReleaseHold(rec.Host, rec.Move.ID); err != nil { |
| + return false, err |
| + } |
| + return true, nil |
| + } |
| + if cfg.ReleaseHoldSeq == nil { |
| + return false, errNoOfflineRelease |
| + } |
| + return w.releaseObserved(ctx, s, rec, want) |
| +} |
| + |
| +// releaseObserved is an offline release under a judge, under reconcileMu and |
| +// the host's hold lock (the caller holds both): the record is observed |
| +// afresh through the box's judge, and the row is released for the move only while that |
| // record still carries this move in state want, with the observed record's |
| // pair (the source's revert: the abort's; the target's open: open's). A |
| // record that moved on, or that is absent, stale or retired, is not |
| @@ -1138,19 +1166,16 @@ func (w *movesWorker) offlineSourceGone(ctx context.Context, s release.Store, re |
| // has finished is never released. A pass that starts after the release |
| // lists the record at offline-reverting, which holds nothing. |
| func (w *movesWorker) offlineSourceRevert(ctx context.Context, s release.Store, rec provision.Record) error { |
| - cfg := w.cfg |
| m, host := rec.Move, rec.Host |
| - if cfg.ReleaseHoldSeq == nil { |
| - return errors.New("offline move: no registry to release the hold through") |
| - } |
| - // The release names the abort's pair, from the record observed under |
| - // the locks (releaseObserved): a record no longer reverting this move |
| - // releases nothing this pass. |
| + // Under a judge the release names the abort's pair, from the record |
| + // observed under the locks: a record no longer reverting this move |
| + // releases nothing this pass. Claims on, today's release |
| + // (offlineRelease). |
| var released bool |
| var relErr error |
| reconcileMu.Lock() |
| provision.WithHostHoldLock(host, func() { |
| - released, relErr = w.releaseObserved(ctx, s, rec, provision.OfflineReverting) |
| + released, relErr = w.offlineRelease(ctx, s, rec, provision.OfflineReverting) |
| }) |
| reconcileMu.Unlock() |
| if relErr != nil { |
diff --git a/internal/host/movesoffline_e2e_test.go b/internal/host/movesoffline_e2e_test.go| index 193eca8b3..6e5ae03e0 100644 |
| --- a/internal/host/movesoffline_e2e_test.go |
| +++ b/internal/host/movesoffline_e2e_test.go |
| @@ -315,9 +315,12 @@ func newE2EWorld(t *testing.T, opts landOpts) *e2eWorld { |
| if err := provision.PutManifest(ctx, rs, wd.acct, "app", provision.Manifest{Schema: 1, App: "app", Domain: "example.com"}); err != nil { |
| t.Fatal(err) |
| } |
| - if opts.lifetime { |
| + switch { |
| + case opts.lifetime: |
| wd.createLifetime(offHost, 1) |
| - } else { |
| + case opts.incarnation > 0: |
| + wd.createAt(offHost, opts.incarnation) |
| + default: |
| wd.create(offHost) |
| } |
| @@ -1086,6 +1089,10 @@ func TestOfflineMoveE2E(t *testing.T) { |
| t.Run("done, through a lifetime", func(t *testing.T) { offlineMoveDone(t, landOpts{lifetime: true}) }) |
| t.Run("aborted from landed, through a lifetime", offlineMoveAbortedFromLanded) |
| t.Run("a release skips a record that moved on", offlineReleaseSkipsAMovedRecord) |
| + // Task 7 fix round 1: a claims-on box (no judge) releases through |
| + // today's ReleaseHold, whatever incarnation its held rows carry. |
| + t.Run("claims on, open over a row of an older incarnation", func(t *testing.T) { offlineClaimsOnOlderRow(t, false) }) |
| + t.Run("claims on, revert over a row of an older incarnation", func(t *testing.T) { offlineClaimsOnOlderRow(t, true) }) |
| // Harness Run 9, finding 7: the doors driven with nothing between them |
| // but what production runs (each door's serial bump, the boxes' own |
| // nudges and the offline re-assert loop), first back to back, then with |
| @@ -1566,8 +1573,30 @@ func (wd *offWorld) createLifetime(host string, inc uint64) { |
| provision.HostIncarnation{Account: wd.acct, App: "app", Incarnation: inc, State: provision.SlotLive, Intent: "create-e2e"}, ""); err != nil { |
| wd.t.Fatal(err) |
| } |
| + wd.createAt(host, inc) |
| +} |
| + |
| +// createAt creates host's record carrying incarnation inc, with no slot. |
| +func (wd *offWorld) createAt(host string, inc uint64) { |
| + wd.t.Helper() |
| rec := provision.Record{Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], Account: wd.acct, App: "app", Region: "", Channel: "stable", CreatedAt: wd.now, Incarnation: inc} |
| - if err := provision.CreateRecord(ctx, wd.s, rec); err != nil { |
| + if err := provision.CreateRecord(context.Background(), wd.s, rec); err != nil { |
| + wd.t.Fatal(err) |
| + } |
| +} |
| + |
| +// reincarnate rewrites the record at incarnation inc, the move and every |
| +// other field kept, through the record writer: the record a claims-on box |
| +// reads after its hostname was recreated under rows it already holds. |
| +func (wd *offWorld) reincarnate(inc uint64) { |
| + wd.t.Helper() |
| + ctx := context.Background() |
| + rec, etag, err := provision.GetRecordETag(ctx, wd.s, wd.acct, "app", offHost) |
| + if err != nil { |
| + wd.t.Fatal(err) |
| + } |
| + rec.Incarnation = inc |
| + if err := provision.UpdateRecord(ctx, wd.s, *rec, etag); err != nil { |
| wd.t.Fatal(err) |
| } |
| } |
| @@ -1649,3 +1678,43 @@ func offlineReleaseSkipsAMovedRecord(t *testing.T) { |
| t.Fatalf("fixture: state %s", st) |
| } |
| } |
| + |
| +// requireRowAt: b's row is held by op at incarnation inc ("" is ready). |
| +func (w *e2eWorld) requireRowAt(b *offBox, inc uint64, op, when string) registry.Route { |
| + w.t.Helper() |
| + rt := b.row(offHost) |
| + if rt == nil || rt.Incarnation != inc || rt.HoldOp != op || (op != "" && !registry.IsHold(rt.Status)) || (op == "" && rt.Status != "ready") { |
| + w.t.Fatalf("%s: %s's row %+v, want %q at incarnation %d", when, b.name, rt, op, inc) |
| + } |
| + return *rt |
| +} |
| + |
| +// A claims-on box reads no slot and holds through SetHoldIf, so its rows |
| +// can carry an incarnation the record has since left (a hostname recreated |
| +// under rows the box still holds). The open and the revert release through |
| +// today's ReleaseHold, so such a row is released, never wedged. |
| +func offlineClaimsOnOlderRow(t *testing.T, revert bool) { |
| + w := newE2EWorld(t, landOpts{incarnation: 1}) |
| + if w.ie.w.cfg.Judge != nil || w.uk.w.cfg.Judge != nil { |
| + t.Fatal("fixture: a box has a judge") |
| + } |
| + w.begin() |
| + w.requireRowAt(w.ie, 1, w.id, "after start") |
| + w.seal() |
| + w.flipAndLand() |
| + w.requireRowAt(w.uk, 1, w.id, "after the landing") |
| + w.wd.reincarnate(2) |
| + if !revert { |
| + w.accept("open", "", provision.OfflineOpen) |
| + w.until("the open", nextIs("finish")) |
| + w.requireRowAt(w.uk, 1, "", "after open") |
| + return |
| + } |
| + w.accept("abort", "", provision.OfflineReverting) |
| + w.until("the revert", nextIs("finish")) |
| + w.requireRowAt(w.ie, 1, "", "after the revert") |
| + if rt := w.uk.row(offHost); rt != nil { |
| + t.Fatalf("uk-box has a row after the revert: %+v", rt) |
| + } |
| +} |
| + |
diff --git a/internal/host/movesoffline_land.go b/internal/host/movesoffline_land.go| index 6eaf1f6ab..3dc24e6e7 100644 |
| --- a/internal/host/movesoffline_land.go |
| +++ b/internal/host/movesoffline_land.go |
| @@ -123,8 +123,9 @@ func (w *movesWorker) landCrash(point string) error { |
| // still held by the move, every landing item is ensured and verified against |
| // the seal once more, and the verified report is written; only when every |
| // item is OK is the hold released (under reconcileMu and the host's hold |
| -// lock, as the source's revert releases its own), with open's lifetime pair |
| -// from the record observed there (releaseObserved). The row then reads ready: |
| +// lock, as the source's revert releases its own): under a judge with open's |
| +// lifetime pair from the record observed there, claims on through today's |
| +// ReleaseHold (offlineRelease). The row then reads ready: |
| // ordinary adoption stages the channel's build, and the first request |
| // promotes moves/<host>/. Then the move's archive prefix is deleted from this |
| // box's bucket and the box reports opened. A pass after the release finds the |
| @@ -157,17 +158,15 @@ func (w *movesWorker) offlineTargetOpen(ctx context.Context, s release.Store, re |
| return fmt.Errorf("offline move %s: %s: not opening: %s not OK on this box", m.ID, host, strings.Join(bad, ", ")) |
| } |
| items = r.Items |
| - if cfg.ReleaseHoldSeq == nil { |
| - return errors.New("offline move: no registry to release the hold through") |
| - } |
| - // The release names open's pair, from the record observed under the |
| - // locks (releaseObserved): a record no longer open for this move |
| - // releases nothing this pass. |
| + // Under a judge the release names open's pair, from the record |
| + // observed under the locks: a record no longer open for this move |
| + // releases nothing this pass. Claims on, today's release |
| + // (offlineRelease). |
| var released bool |
| var relErr error |
| reconcileMu.Lock() |
| provision.WithHostHoldLock(host, func() { |
| - released, relErr = w.releaseObserved(ctx, s, rec, provision.OfflineOpen) |
| + released, relErr = w.offlineRelease(ctx, s, rec, provision.OfflineOpen) |
| }) |
| reconcileMu.Unlock() |
| if relErr != nil { |
diff --git a/internal/host/movesoffline_land_test.go b/internal/host/movesoffline_land_test.go| index a8dbc8507..5953ea24a 100644 |
| --- a/internal/host/movesoffline_land_test.go |
| +++ b/internal/host/movesoffline_land_test.go |
| @@ -90,6 +90,9 @@ type landOpts struct { |
| // host's incarnation slot at 1, live, then the record at incarnation 1 |
| // (the create protocol's order, self-serve moves plan 1 Task 3). |
| lifetime bool |
| + // incarnation creates the record at that incarnation with no slot, as |
| + // a claims-on box (no judge) sees one; only without judged or lifetime. |
| + incarnation uint64 |
| } |
| // landWorld is offHost at home (ie-a, data-ie) moving to uk (uk-box, |