host, console: an archive is lost only when the reads say so; scoped holders are refused at the move door and never repinned or cut automatically; the real rebuild headroom (Task 5 fix round 2)
sealedOnDisk hashes the sealed file alone and returns read errors; the manifest comes from the journal, so losing only the local manifest is not a loss, and a read error is never one. errArchiveLost is reached only after the file read and the snapshot listing both answered. The console door answers 409 with MV-N167's sentence for a holder at the scoped location (or, when it knows the activator's prefix, at any other location than it), before any claim write. decideReady carries the round's holder and neither repins nor requests an automatic cut for one the cut would refuse, so a scoped automatic move writes nothing after its promotion. The requested and admitted checks stay for replays and races.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+271
−39
internal/console/apiinstancemove.go+20 −0internal/console/apiinstancemove_test.go+52 −2internal/host/movesarchive.go+40 −17internal/host/movescut_test.go+135 −17internal/host/movesphase1.go+19 −2internal/host/movesphase1_test.go+5 −1
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 8135f45fd..9d61be74b 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -11,6 +11,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| @@ -125,6 +126,13 @@ func phase1MoveCopy() moveCopy { |
| } |
| } |
| +// moveScopedHolder is MV-N167's sentence, the one the cut's admission |
| +// records (provision.RefusedReplicaLayout); the door speaks it for a holder |
| +// it will not move (ruling T5-lease). A literal, because the copy gate |
| +// reads this file's sentences; TestMoveDoorRefusesAScopedHolder pins it |
| +// equal to the recorded constant. |
| +const moveScopedHolder = "the instance keeps its data under a per-incarnation path, which moves cannot cut yet" |
| + |
| // moveView is the flat answer every move door speaks — titogo's consumer |
| // contract (teamtito/titogo#1848, region-doors design): state, target, one |
| // freshness stamp. "none" is a real state, not an error: a host with no |
| @@ -520,6 +528,18 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| jsonError(w, http.StatusConflict, "the instance is under a "+obs.Claim.Op.Kind+" operation. wait for it to finish") |
| return |
| } |
| + // Ruling T5-lease (Task 5 fix round 2): Phase 1 cuts only a holder |
| + // whose location is the activator's own lease prefix. A scoped holder |
| + // (instances/<host>/<incarnation>) is refused here, before any claim |
| + // write, with the sentence the cut's admission would record (MV-N167); |
| + // when this console knows the activator's prefix, so is any other |
| + // location than the activator's. Moving such a host waits for a later |
| + // plan. |
| + if loc := strings.TrimSuffix(obs.Claim.Holder.Location, "/"); loc == "instances/"+host+"/"+inc || |
| + (s.cfg.HibernatePrefix != "" && loc != activator.HibernatePrefix(s.cfg.HibernatePrefix, host)) { |
| + jsonError(w, http.StatusConflict, moveScopedHolder) |
| + return |
| + } |
| // §7.1 binds the source to the claim's executor (round-5 finding 7). |
| if obs.Claim.Op.Exec != sel.Source { |
| jsonError(w, http.StatusConflict, "the source box "+obs.Claim.Op.Exec+" is not the eligible box of "+from) |
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go| index da1b4cec6..e34bdb809 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -361,13 +361,14 @@ func errorText(body string) string { |
| } |
| // writeServingClaim writes the host's first claim: serving under box, held |
| -// by the titogo instance of incarnation inc. Update validates what it |
| +// by the titogo instance of incarnation inc, at the root location the |
| +// activator's lease lives at (the only one Phase 1 moves, ruling T5-lease). Update validates what it |
| // writes, so an invalid fixture dies here and not in the door. |
| func writeServingClaim(t *testing.T, rel release.Store, host, inc string, created time.Time, box, region string) { |
| t.Helper() |
| _, err := hostclaim.Update(context.Background(), rel, hostclaim.Observed{Host: host, Absent: true}, func(c *hostclaim.Claim) error { |
| c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "titogo", host), Account: "bab", App: "titogo", |
| - Incarnation: inc, CreatedAt: created, Location: "instances/" + host + "/" + inc, Stamp: 1} |
| + Incarnation: inc, CreatedAt: created, Location: "instances/" + host, Stamp: 1} |
| c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: box} |
| c.Touched = hostclaim.Touched{Complete: true, Regions: []string{region}, Boxes: []string{box}} |
| return nil |
| @@ -1367,3 +1368,52 @@ func TestMoveDoorGateSentences(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// Fix round 2 of Task 5 (ruling T5-lease): Phase 1 cuts only a holder whose |
| +// location is the activator's own lease prefix, so the door refuses a |
| +// scoped holder (instances/<host>/<incarnation>) at request time with the |
| +// recorded sentence (MV-N167), before any claim write. It does so whether |
| +// or not this console knows the activator's prefix; a root holder passes. |
| +func TestMoveDoorRefusesAScopedHolder(t *testing.T) { |
| + for _, prefix := range []string{"", "instances"} { |
| + t.Run("prefix "+prefix, func(t *testing.T) { |
| + fx, token := operatorInstanceFixture(t, "code-inst-move-scoped") |
| + fx.srv.cfg.HibernatePrefix = prefix |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| + t.Fatalf("create = %d %s", code, out) |
| + } |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + inc := claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie") |
| + rewriteClaim(t, fx, "acme.go.tito.io", func(c *hostclaim.Claim) { c.Holder.Location = "instances/acme.go.tito.io/" + inc }) |
| + before := claimBytes(t, fx, "acme.go.tito.io") |
| + code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`) |
| + if code != http.StatusConflict || errorText(out) != provision.RefusedReplicaLayout { |
| + t.Fatalf("a scoped holder = %d %s, want 409 %q", code, out, provision.RefusedReplicaLayout) |
| + } |
| + if claimBytes(t, fx, "acme.go.tito.io") != before { |
| + t.Fatal("a refused request wrote the claim") |
| + } |
| + rewriteClaim(t, fx, "acme.go.tito.io", func(c *hostclaim.Claim) { c.Holder.Location = "instances/acme.go.tito.io" }) |
| + if code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`); code != http.StatusAccepted { |
| + t.Fatalf("a root holder = %d %s, want 202", code, out) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// rewriteClaim edits the host's claim in place, as a test fixture. |
| +func rewriteClaim(t *testing.T, fx *consoleFixture, host string, edit func(*hostclaim.Claim)) { |
| + t.Helper() |
| + obs, err := hostclaim.Read(context.Background(), fx.rel, host) |
| + if err != nil || obs.Absent { |
| + t.Fatalf("claim: %v absent=%v", err, obs.Absent) |
| + } |
| + if _, err := hostclaim.Update(context.Background(), fx.rel, obs, func(c *hostclaim.Claim) error { edit(c); return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/host/movesarchive.go b/internal/host/movesarchive.go| index da0acc368..28387a8b1 100644 |
| --- a/internal/host/movesarchive.go |
| +++ b/internal/host/movesarchive.go |
| @@ -27,11 +27,15 @@ import ( |
| // on the source's data disk under <JournalDir>/archive/ for the life of |
| // the move, so the cut can publish the very object the target verified |
| // (G14). That costs up to archive.Cap (256 MiB) of the source's data disk |
| -// while a move is in flight, and a rebuild (the stopped files differ from |
| -// the verified archive) holds the old and the new one together until the |
| -// new one lands and pruneArchives removes the old: up to twice archive.Cap |
| -// for the length of one build. The target's T6 does not price this |
| -// source-side headroom; that is a Plan 6 follow-up. |
| +// while a move is in flight. A rebuild (the stopped files differ from the |
| +// verified archive) is worse for the length of one build: the previous |
| +// sealed archive is still there, buildArchive writes the compressed |
| +// plaintext (archive.tar.zst) to a temp directory beside it, and then |
| +// seals that into the new sealed archive before pruneArchives removes the |
| +// previous one. With incompressible files all three approach the payload |
| +// size, so the worst case is about three times archive.Cap plus the |
| +// sealing overhead. The target's T6 does not price this source-side |
| +// headroom; that is a Plan 6 follow-up. |
| // cutArchiveStep is the archive's identity in the op journal, written |
| // before any PUT so a replay publishes the same object (codex 19): the |
| // archive's name and digests, the two move-snapshots/ keys it is published |
| @@ -106,7 +110,14 @@ func (c *cutRun) cutArchive(ctx context.Context, plain string) (builtArchive, [] |
| reuse := ledger.ArchivePlain == plain && ledger.ArchiveVersions != nil && ledger.ArchiveVersions.Pinned() && ledger.ArchiveSealed != "" |
| if reuse { |
| kept := builtArchive{Name: archiveName(ledger.ArchiveSealed), Sealed: ledger.ArchiveSealed, Plain: plain, dir: adir} |
| - if m, ok := keptManifest(kept); ok && sealedOnDisk(kept) { |
| + keptThere, err := sealedOnDisk(kept) |
| + if err != nil { |
| + return builtArchive{}, nil, "", err |
| + } |
| + // The kept manifest is needed here, before anything is |
| + // journalled: without it the verified archive is simply not |
| + // reused, and a new one is built. |
| + if m, ok := keptManifest(kept); ok && keptThere { |
| kept.Bytes, kept.Entries = m.Bytes, len(m.Entries) |
| if st, err := os.Stat(kept.sealedPath()); err == nil { |
| kept.SealedSize = st.Size() |
| @@ -166,7 +177,10 @@ func (c *cutRun) publishCutArchive(ctx context.Context, own lease.MirrorStore, v |
| snap := moves.SnapshotsPrefix(host, id) |
| sealedSnap, manifestSnap := step.SealedKey, step.ManifestKey |
| manifest := []byte(step.Manifest) |
| - onDisk := sealedOnDisk(a) |
| + onDisk, err := sealedOnDisk(a) |
| + if err != nil { |
| + return nil, err |
| + } |
| if onDisk { |
| fp, ok := vs.(lease.FilePutIfAbsent) |
| if !ok { |
| @@ -185,8 +199,10 @@ func (c *cutRun) publishCutArchive(ctx context.Context, own lease.MirrorStore, v |
| return nil, err |
| } |
| } else { |
| - // No file: only what was published under the identity can finish |
| - // it. A listing that failed decides nothing (ruling 4). |
| + // No file (the read succeeded and found none, or a file that is not |
| + // this archive): only what was published under the identity can |
| + // finish it. A listing that failed decides nothing (ruling 4). The |
| + // loss is established only here, by reads that answered. |
| sealedThere, _, err := c.snapshotState(ctx, vs, sealedSnap) |
| if err != nil { |
| return nil, err |
| @@ -220,7 +236,6 @@ func (c *cutRun) publishCutArchive(ctx context.Context, own lease.MirrorStore, v |
| sealedKey, manifestKey := prefix+a.Name+".sealed", prefix+a.Name+".json" |
| var sv, mv string |
| var msize int64 |
| - var err error |
| if onDisk { |
| if sv, err = c.gm.PutFileVersioned(ctx, c.gm.dst, sealedKey, a.sealedPath(), a.SealedSize, digestHex(a.Sealed)); err != nil { |
| return nil, fmt.Errorf("stage the sealed archive: %w", err) |
| @@ -265,14 +280,22 @@ func (c *cutRun) publishCutArchive(ctx context.Context, own lease.MirrorStore, v |
| return []moves.SealedObject{{Key: sealedKey, VersionID: sv, Size: a.SealedSize}, {Key: manifestKey, VersionID: mv, Size: msize}}, nil |
| } |
| -// sealedOnDisk reports whether the archive's sealed file and manifest are |
| -// on this disk and the sealed file hashes to its name. |
| -func sealedOnDisk(a builtArchive) bool { |
| - if _, err := os.Stat(a.manifestPath()); err != nil { |
| - return false |
| - } |
| +// sealedOnDisk reports whether the archive's sealed file is on this disk |
| +// and hashes to its name. The sealed file alone is what a publication |
| +// needs: the manifest's bytes are journalled with the identity, so losing |
| +// only the local manifest loses nothing (fix round 2). Absence, and a file |
| +// whose digest is not the name, are false with no error; a read that |
| +// fails is an error, never absence, since a loss is declared on this |
| +// answer and a read error establishes none. |
| +func sealedOnDisk(a builtArchive) (bool, error) { |
| sum, err := fileSHA256(a.sealedPath()) |
| - return err == nil && sum == a.Sealed |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, fmt.Errorf("read %s: %w", a.sealedPath(), err) |
| + } |
| + return sum == a.Sealed, nil |
| } |
| // keptManifest reads the manifest of an archive kept on disk; it must |
diff --git a/internal/host/movescut_test.go b/internal/host/movescut_test.go| index d4f79e049..38617cb8d 100644 |
| --- a/internal/host/movescut_test.go |
| +++ b/internal/host/movescut_test.go |
| @@ -167,17 +167,7 @@ func (h *cutHarness) ready(t *testing.T) { |
| t.Helper() |
| h.start(t, "manual") |
| if !h.scoped { |
| - // The Phase 1 harness keeps the replica under the scoped location; |
| - // the cut's fixture is the root one, so the same objects move |
| - // there and the claim follows. |
| - scoped := "instances/" + p1Host + "/" + h.inc + "/" |
| - for k, o := range h.f.buckets["data-ie"] { |
| - if rel, ok := strings.CutPrefix(k, scoped); ok { |
| - delete(h.f.buckets["data-ie"], k) |
| - h.f.store("data-ie", cutRoot+"/"+rel, o) |
| - } |
| - } |
| - rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) { c.Holder.Location = cutRoot }) |
| + rootHolder(t, h.phase1Harness) |
| } |
| h.pass(t) |
| h.targetPass(t, h.target()) |
| @@ -187,6 +177,22 @@ func (h *cutHarness) ready(t *testing.T) { |
| } |
| } |
| +// rootHolder moves a started Phase 1 harness to the root location: the |
| +// Phase 1 harness keeps the replica under the scoped location, the cut |
| +// accepts only the root one (ruling T5-lease), so the same objects move |
| +// there and the claim follows. |
| +func rootHolder(t *testing.T, h *phase1Harness) { |
| + t.Helper() |
| + scoped := "instances/" + p1Host + "/" + h.inc + "/" |
| + for k, o := range h.f.buckets["data-ie"] { |
| + if rel, ok := strings.CutPrefix(k, scoped); ok { |
| + delete(h.f.buckets["data-ie"], k) |
| + h.f.store("data-ie", cutRoot+"/"+rel, o) |
| + } |
| + } |
| + rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) { c.Holder.Location = cutRoot }) |
| +} |
| + |
| // cut is the console's cut door: ready to cutting/requested. |
| func (h *cutHarness) cut(t *testing.T) { |
| t.Helper() |
| @@ -237,6 +243,7 @@ func (h *cutHarness) journalArchive(t *testing.T) cutArchiveStep { |
| if err := json.Unmarshal([]byte(j.Steps[cutStepArchive]), &step); err != nil { |
| t.Fatalf("no archive identity journalled: %v", err) |
| } |
| + step.dir = archiveDir(h.cfg.DataDir, p1Host, p1ID) |
| return step |
| } |
| @@ -397,20 +404,25 @@ func TestCutRefusesAScopedHolder(t *testing.T) { |
| h.cut(t) |
| h.pass(t) |
| check(t, h, "at requested") |
| + // Fix round 2: the refusal stands and nothing loops. decideReady never |
| + // repins a scoped holder, so later passes write nothing, and the door |
| + // refuses a cut with no pin. |
| + refused := h.move(t) |
| h.pass(t) |
| - if m := h.move(t); m.State != provision.MoveReady || m.Inputs == nil || m.Reason != provision.RefusedReplicaLayout { |
| - t.Fatalf("the passing round did not pin again under the standing reason: %+v", m) |
| - } |
| - h.cut(t) |
| h.pass(t) |
| - check(t, h, "the second cut") |
| + if m := h.move(t); m.State != provision.MoveReady || m.Inputs != nil || m.Reason != provision.RefusedReplicaLayout || m.UpdatedAt != refused.UpdatedAt { |
| + t.Fatalf("passes after the refusal wrote the record: %+v", m) |
| + } |
| + g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc} |
| + if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveGuardedCut(g)); !errors.Is(err, provision.ErrMoveConflict) { |
| + t.Fatalf("a cut of the refused move: %v", err) |
| + } |
| h2 := newCutHarness(t) |
| h2.scoped = true |
| h2.ready(t) |
| h2.cut(t) |
| now := h2.clock.Now() |
| - g := provision.MoveGuard{ID: p1ID, Incarnation: h2.inc} |
| if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120))); err != nil { |
| t.Fatal(err) |
| } |
| @@ -1470,3 +1482,109 @@ func TestCutCrashMatrix(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// Fix round 2, item 1: a loss is declared only when it is established. The |
| +// sealed file alone, with the journalled manifest, is enough to publish |
| +// the identity: losing only the local manifest before the first PUT does |
| +// not revert. A read of the sealed file that fails is an error, never a |
| +// loss: the record stays staged, the identity stands, and once the file |
| +// reads again the replay finishes it. |
| +func TestArchiveLossIsDeclaredOnlyWhenEstablished(t *testing.T) { |
| + t.Run("only the manifest is gone", func(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.ready(t) |
| + h.writeOnStop(t) |
| + h.crashAt = "archive-journaled" |
| + h.cut(t) |
| + h.crashPass(t) |
| + first := h.journalArchive(t) |
| + if err := os.Remove(first.manifestPath()); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.freshWorker() |
| + h.pass(t) |
| + m := h.move(t) |
| + if m.State != provision.MoveLanding || m.Source.Files.Sealed != first.Sealed || !sealNamesArchive(h.seal(t), first.Sealed) { |
| + t.Fatalf("a kept sealed file with its manifest journalled: %+v files %+v", m, m.Source.Files) |
| + } |
| + snap := moves.SnapshotsPrefix(p1Host, p1ID) |
| + if _, ok := h.f.buckets["data-ie"][snap+first.Name+".json"]; !ok { |
| + t.Fatal("the manifest was not published from the journal") |
| + } |
| + }) |
| + t.Run("the sealed file cannot be read", func(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.ready(t) |
| + h.writeOnStop(t) |
| + h.crashAt = "archive-journaled" |
| + h.cut(t) |
| + h.crashPass(t) |
| + first := h.journalArchive(t) |
| + // The sealed path answers a read error (EISDIR), not absence. |
| + kept, err := os.ReadFile(first.sealedPath()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Remove(first.sealedPath()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(first.sealedPath(), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.freshWorker() |
| + h.pass(t) |
| + m := h.move(t) |
| + if m.State != provision.MoveCutting || m.CutPhase != provision.CutStaged { |
| + t.Fatalf("a read error decided the archive: %+v", m) |
| + } |
| + if again := h.journalArchive(t); again.Sealed != first.Sealed { |
| + t.Fatalf("the identity changed from %s to %s", first.Name, again.Name) |
| + } |
| + if got := snapshotSealed(h); len(got) != 0 { |
| + t.Fatalf("snapshots %v, want none", got) |
| + } |
| + if err := os.Remove(first.sealedPath()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(first.sealedPath(), kept, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.freshWorker() |
| + h.pass(t) |
| + m = h.move(t) |
| + if m.State != provision.MoveLanding || m.Source.Files.Sealed != first.Sealed || !sealNamesArchive(h.seal(t), first.Sealed) { |
| + t.Fatalf("once the file read again: %+v files %+v", m, m.Source.Files) |
| + } |
| + }) |
| +} |
| + |
| +// Fix round 2, item 2: an automatic move on a scoped holder does not loop. |
| +// decideReady never repins or requests a cut for a holder whose location is |
| +// not the activator's prefix, so repeated passes write nothing: the record's |
| +// stamp and the claim's generation stand. |
| +func TestScopedAutomaticMoveDoesNotLoop(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.scoped = true |
| + h.start(t, "auto") |
| + h.pass(t) |
| + h.targetPass(t, h.target()) |
| + h.pass(t) |
| + rec := h.read(t, p1Host) |
| + if rec.Move.State == provision.MoveCutting { |
| + t.Fatalf("a cut was requested for a scoped holder: %+v", rec.Move) |
| + } |
| + claimGen := h.claim(t).Gen |
| + for i := 0; i < 3; i++ { |
| + h.pass(t) |
| + again := h.read(t, p1Host) |
| + if again.Move.State == provision.MoveCutting || again.Move.UpdatedAt != rec.Move.UpdatedAt || again.Move.State != rec.Move.State { |
| + t.Fatalf("pass %d wrote the record: %+v (was %+v)", i, again.Move, rec.Move) |
| + } |
| + if h.claim(t).Gen != claimGen { |
| + t.Fatalf("pass %d wrote the claim", i) |
| + } |
| + } |
| + if h.ev.calls != 0 || len(h.holds) != 0 { |
| + t.Fatal("something stopped") |
| + } |
| +} |
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go| index 05dd2991d..8bba3b79a 100644 |
| --- a/internal/host/movesphase1.go |
| +++ b/internal/host/movesphase1.go |
| @@ -204,6 +204,9 @@ type srcRound struct { |
| // replicaBytes is the replica's logical size this round |
| // (replicaLogicalSize), carried as SourceReport.ReplicaBytes. |
| replicaBytes int64 |
| + // holder is the claim holder this round read under the lease, for |
| + // decideReady's layout rule (ruling T5-lease). |
| + holder hostclaim.Holder |
| } |
| // preparingRound is §7.3's source round (sourceRound), then Ready, the |
| @@ -387,7 +390,7 @@ func (w *movesWorker) sourceRound(ctx context.Context, s release.Store, rec prov |
| if err != nil { |
| return nil, nil, err |
| } |
| - sr := &srcRound{deltaBytes: round.DeltaBytes(), replicaBytes: replicaBytes} |
| + sr := &srcRound{deltaBytes: round.DeltaBytes(), replicaBytes: replicaBytes, holder: *obs.Claim.Holder} |
| // The files archive, rebuilt only when its plaintext digest changes. A |
| // file that changed under the walk ends the round here, before any |
| // report or readiness: the archive the target checked is no longer the |
| @@ -850,6 +853,20 @@ func (w *movesWorker) decideReady(ctx context.Context, s release.Store, rec prov |
| // A ready move with no pin is one a cut's admission sent back (G1): the |
| // same rule pins it again, from this passing round, before any cut. |
| repin := state == provision.MoveReady && rec.Move.Inputs == nil |
| + // Ruling T5-lease (fix round 2): a holder whose location is not the |
| + // activator's lease prefix cannot be cut, so a ready move on one is |
| + // neither pinned again nor cut automatically. Nothing is written: the |
| + // door refuses such a move at request time and the cut's admission |
| + // refuses a replay, so a move that got here waits, with a notice, |
| + // until an operator aborts it. |
| + // (Its promotion to ready still happens, so the operator sees it there.) |
| + _, layoutErr := writerLease(cfg.OwnPrefix, rec.Host, r.holder) |
| + if layoutErr != nil && (repin || rec.Move.Cut == "auto") { |
| + w.notice(rec.Host+"#layout", "moves: %s: move %s cannot be cut (%v); no repin and no automatic cut", rec.Host, g.ID, layoutErr) |
| + if repin { |
| + return nil |
| + } |
| + } |
| if state == provision.MovePreparing || repin { |
| target, err := readTargetReport(ctx, s, rec) |
| if err != nil { |
| @@ -882,7 +899,7 @@ func (w *movesWorker) decideReady(ctx context.Context, s release.Store, rec prov |
| } |
| state = provision.MoveReady |
| } |
| - if state == provision.MoveReady && rec.Move.Cut == "auto" { |
| + if state == provision.MoveReady && rec.Move.Cut == "auto" && layoutErr == nil { |
| if _, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, provision.MoveGuardedCut(g)); err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: the automatic cut of %s was refused (%v)", rec.Host, g.ID, err) |
diff --git a/internal/host/movesphase1_test.go b/internal/host/movesphase1_test.go| index 3b765a877..5d3c95b2a 100644 |
| --- a/internal/host/movesphase1_test.go |
| +++ b/internal/host/movesphase1_test.go |
| @@ -634,10 +634,13 @@ func TestArchiveIsRebuiltOnlyWhenPlainChanges(t *testing.T) { |
| } |
| } |
| -// cut == "auto": from ready the source itself marks the cut requested. |
| +// cut == "auto": from ready the source itself marks the cut requested, for |
| +// a holder at the root location (the only one the cut accepts, ruling |
| +// T5-lease; TestScopedAutomaticMoveDoesNotLoop covers the other). |
| func TestAutoCutMarksCuttingFromReady(t *testing.T) { |
| h := newPhase1Harness(t) |
| h.start(t, "auto") |
| + rootHolder(t, h) |
| h.pass(t) |
| h.targetPass(t, h.targetCfg()) |
| h.pass(t) |
| @@ -794,6 +797,7 @@ func readyAuto(t *testing.T) *phase1Harness { |
| t.Helper() |
| h := newPhase1Harness(t) |
| h.start(t, "manual") |
| + rootHolder(t, h) // an automatic cut is requested only for a root holder (ruling T5-lease) |
| h.pass(t) |
| h.targetPass(t, h.targetCfg()) |
| h.pass(t) |