| index 14a0f8bef..67a9e87e4 100644 |
| --- a/internal/host/movesevidence_test.go |
| +++ b/internal/host/movesevidence_test.go |
| @@ -10,6 +10,7 @@ import ( |
| |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/lease/leasetest" |
| "github.com/carlosframework/platform/internal/park" |
| @@ -54,13 +55,29 @@ func newEvFixture(t *testing.T) *evFixture { |
| f.rec = &provision.Record{Schema: 1, Host: evHost, App: evApp, Account: evAcct, CreatedAt: evCreated, |
| LastMove: &provision.MoveOutcome{ID: evMoveID, Outcome: "done", At: evAt}} |
| f.setClaim(t, func(c *hostclaim.Claim) { |
| - c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey(evAcct, evApp, evHost), Account: evAcct, App: evApp, Incarnation: inc, CreatedAt: evCreated, Location: f.location, Stamp: 1, Session: 1} |
| + c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey(evAcct, evApp, evHost), Account: evAcct, App: evApp, Incarnation: inc, CreatedAt: evCreated, Location: f.location, Stamp: 2, Session: 2, WriterBox: "box", Generation: evMoveID, |
| + Admissions: evAdmissions(evMoveID)} |
| c.Op = servingReleasedBy(evMoveID) |
| c.Touched = hostclaim.Touched{Complete: true, Regions: []string{"ie"}} |
| }) |
| return f |
| } |
| |
| +// evAdmissions is the holder's admission history: an origin generation |
| +// "old", then the move's prepared landing, which admits the generation |
| +// named after the move (movesprobeowners.go), then any later generations, |
| +// each the next one's predecessor. The last is the holder's current. |
| +func evAdmissions(gens ...string) []hostclaim.AdmissionRecord { |
| + out := []hostclaim.AdmissionRecord{{Session: 1, Box: "box", Generation: "old", Mode: "cold", Region: "ie"}} |
| + for i, g := range gens { |
| + out = append(out, hostclaim.AdmissionRecord{Session: uint64(i + 2), Box: "box", Generation: g, Predecessor: out[i].Generation, Mode: "cold", Region: "ie", PredecessorRegion: "ie"}) |
| + } |
| + return out |
| +} |
| + |
| +// genKey is an object of generation gen at the fixture's live location. |
| +func (f *evFixture) genKey(gen string) string { return f.location + "/g-" + gen + "/0000/db" } |
| + |
| // servingReleasedBy is the serving op hostclaim.ReleaseMove leaves. |
| func servingReleasedBy(id string) hostclaim.Op { |
| return hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box", Journal: map[string]string{"released_move": id}} |
| @@ -185,7 +202,7 @@ func TestMovesSweepProofRetiredAtIsTheClose(t *testing.T) { |
| func TestVerifyRetirementLabelsMovesResidueWithTheProofsIncarnation(t *testing.T) { |
| f := newEvFixture(t) |
| f.lane(evMoveID, "move.json") |
| - f.bucket.Put("data", f.location+"/g-live/0000/db", []byte("live")) |
| + f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| obs, census := f.observe(t), f.census(t) |
| inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| if err != nil { |
| @@ -220,7 +237,7 @@ func TestVerifyRetirementRefusesLegacyResidue(t *testing.T) { |
| t.Run(name, func(t *testing.T) { |
| f := newEvFixture(t) |
| f.lane(evMoveID, "move.json") |
| - f.bucket.Put("data", f.location+"/g-live/0000/db", []byte("live")) |
| + f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| f.bucket.Put("data", key, []byte("legacy")) |
| obs, census := f.observe(t), f.census(t) |
| inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| @@ -236,7 +253,7 @@ func TestVerifyRetirementRefusesLegacyResidue(t *testing.T) { |
| t.Run("an incarnation directory beside a bare holder", func(t *testing.T) { |
| f := newEvFixture(t) |
| f.bare(t) |
| - f.bucket.Put("data", "instances/"+evHost+"/g-live/0000/db", []byte("live")) |
| + f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| f.bucket.Put("data", "instances/"+evHost+"/"+f.inc+"/g-old/0000/db", []byte("scoped")) |
| _, _, err := f.provider().VerifyRetirement(t.Context(), f.observe(t), f.census(t)) |
| var dep *ClaimDependencyError |
| @@ -264,7 +281,7 @@ func TestMovedHostAtABareLocationIsSwept(t *testing.T) { |
| f.bare(t) |
| f.lane(evMoveID, "move.json") |
| f.lane(evMoveID, "replica/0000/db") |
| - f.bucket.Put("data", f.location+"/g-live/0000/db", []byte("live")) |
| + f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| obs, census := f.observe(t), f.census(t) |
| inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| if err != nil || inc != f.inc || !at.Equal(evAt) { |
| @@ -283,6 +300,86 @@ func TestMovedHostAtABareLocationIsSwept(t *testing.T) { |
| } |
| } |
| |
| +// TestVerifyRetirementVouchesOnlyForWhatTheMoveRetired (fix round 1, |
| +// ruling 1): the close is evidence only for what the move retired. At the |
| +// live location that is a generation superseded at or before the move's |
| +// own admission, every version of it older than the close. A generation |
| +// admitted from the move's admission on was written past the close, so the |
| +// provider has no evidence for it and says so with a typed error; the |
| +// moves lane proof is unaffected. |
| +func TestVerifyRetirementVouchesOnlyForWhatTheMoveRetired(t *testing.T) { |
| + answers := func(t *testing.T, f *evFixture) { |
| + t.Helper() |
| + inc, at, err := f.provider().VerifyRetirement(t.Context(), f.observe(t), f.census(t)) |
| + if err != nil || inc != f.inc || !at.Equal(evAt) { |
| + t.Fatalf("VerifyRetirement = %q, %v, %v; want %q, %v", inc, at, err, f.inc, evAt) |
| + } |
| + } |
| + notEvidence := func(t *testing.T, f *evFixture, gen string) { |
| + t.Helper() |
| + obs, census := f.observe(t), f.census(t) |
| + inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| + var ne *NotMovesEvidence |
| + if !errors.As(err, &ne) || inc != "" || !at.IsZero() { |
| + t.Fatalf("VerifyRetirement = %q, %v, %v; want a *NotMovesEvidence and no evidence", inc, at, err) |
| + } |
| + if ne.Host != evHost || ne.Generation != gen || ne.Reason == "" { |
| + t.Fatalf("refusal = %+v, want generation %q", ne, gen) |
| + } |
| + if _, err := f.provider().VerifyMovesSweep(t.Context(), obs, census); err != nil { |
| + t.Fatalf("the lane proof does not depend on the generations: %v", err) |
| + } |
| + } |
| + t.Run("the current generation and the marker", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| + f.bucket.Put("data", f.location+"/generation.json", []byte("marker")) |
| + answers(t, f) |
| + }) |
| + t.Run("a generation the move's admission superseded", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.bucket.Put("data", f.genKey("old"), []byte("old")) |
| + f.bucket.Put("data", f.location+"/files/g-old/a.txt", []byte("old")) |
| + answers(t, f) |
| + }) |
| + t.Run("the move's generation superseded after the close", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.setClaim(t, func(c *hostclaim.Claim) { |
| + c.Holder.Admissions, c.Holder.Generation, c.Holder.Session, c.Holder.Stamp = evAdmissions(evMoveID, "later"), "later", 3, 3 |
| + }) |
| + f.bucket.Put("data", f.genKey(evMoveID), []byte("superseded after the close")) |
| + f.bucket.Put("data", f.genKey("later"), []byte("live")) |
| + notEvidence(t, f, evMoveID) |
| + }) |
| + t.Run("a later generation superseded after the close", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.setClaim(t, func(c *hostclaim.Claim) { |
| + c.Holder.Admissions, c.Holder.Generation, c.Holder.Session, c.Holder.Stamp = evAdmissions(evMoveID, "later", "latest"), "latest", 4, 4 |
| + }) |
| + f.bucket.Put("data", f.genKey("later"), []byte("superseded after the close")) |
| + notEvidence(t, f, "later") |
| + }) |
| + t.Run("no admission of the move in the claim", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.setClaim(t, func(c *hostclaim.Claim) { |
| + c.Holder.Admissions, c.Holder.Generation = evAdmissions("fresh"), "fresh" |
| + }) |
| + f.bucket.Put("data", f.genKey("old"), []byte("old")) |
| + notEvidence(t, f, "old") |
| + }) |
| + t.Run("a retired generation written after the close", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.rec.LastMove.At = time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC) |
| + f.bucket.Put("data", f.genKey("old"), []byte("late")) |
| + notEvidence(t, f, "old") |
| + }) |
| + t.Run("an object no generation owns", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.bucket.Put("data", f.location+"/stray.bin", []byte("?")) |
| + notEvidence(t, f, "") |
| + }) |
| +} |
| + |
| // TestMovesSweepProofRefusesALiveMove (K10, M8): no proof while a move op |
| // is on the claim, while the record carries a live move, or while the |
| // claim was released by a move the record has not closed. |
| @@ -307,20 +404,61 @@ func TestMovesSweepProofRefusesALiveMove(t *testing.T) { |
| bothRefuse(t, f, false) |
| }) |
| } |
| - t.Run("the claim was released by another move", func(t *testing.T) { |
| + t.Run("the record names no closed move", func(t *testing.T) { |
| f := newEvFixture(t) |
| - f.setClaim(t, func(c *hostclaim.Claim) { c.Op = servingReleasedBy("mv_orphan") }) |
| - bothRefuse(t, f, true) |
| + f.rec.LastMove = nil |
| + bothRefuse(t, f, false) |
| }) |
| - t.Run("the serving claim names no released move", func(t *testing.T) { |
| +} |
| + |
| +// TestMovesProofSurvivesAMetadataPlacement (fix round 1, ruling 2): the |
| +// durable proof is the record's LastMove, bound to the holder's |
| +// incarnation, with no move op on the claim. A later operation that |
| +// replaces the serving op (here CompleteMetadataPlacement, which drops the |
| +// released_move journal) leaves cleanup running, and so does a serving op |
| +// released by a move whose record never carried it. |
| +func TestMovesProofSurvivesAMetadataPlacement(t *testing.T) { |
| + t.Run("a metadata placement", func(t *testing.T) { |
| f := newEvFixture(t) |
| - f.setClaim(t, func(c *hostclaim.Claim) { c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"} }) |
| - bothRefuse(t, f, false) |
| + f.lane(evMoveID, "move.json") |
| + f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| + f.setClaim(t, func(c *hostclaim.Claim) { |
| + if err := hostops.BeginPlacement("pl_1", "op@example", "meta", evNow, []string{"ie"}, []string{"box"})(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + c.Op.Intent["app_id"] = "app_1" |
| + if err := hostops.CompleteMetadataPlacement("pl_1", "app_1")(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + obs := f.observe(t) |
| + if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Journal["released_move"] != "" { |
| + t.Fatalf("the placement left op %+v", obs.Claim.Op) |
| + } |
| + census := f.census(t) |
| + proof, err := f.provider().VerifyMovesSweep(t.Context(), obs, census) |
| + if err != nil || proof.MoveID != evMoveID || !proof.RetiredAt.Equal(evAt) { |
| + t.Fatalf("proof = %+v, %v", proof, err) |
| + } |
| + if inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census); err != nil || inc != f.inc || !at.Equal(evAt) { |
| + t.Fatalf("VerifyRetirement = %q, %v, %v", inc, at, err) |
| + } |
| + if err := f.worker(t, evNow).Run(t.Context()); !errors.Is(err, park.ErrPending) { |
| + t.Fatalf("the lane chore waits for its horizon: %v", err) |
| + } |
| + if _, ok := f.chores(t)["moves/"+evHost]; !ok { |
| + t.Fatalf("chores = %+v; want the moves/<host> chore", f.chores(t)) |
| + } |
| }) |
| - t.Run("the record names no closed move", func(t *testing.T) { |
| + t.Run("a release by a move the record never carried", func(t *testing.T) { |
| f := newEvFixture(t) |
| - f.rec.LastMove = nil |
| - bothRefuse(t, f, false) |
| + f.setClaim(t, func(c *hostclaim.Claim) { c.Op = servingReleasedBy("mv_orphan") }) |
| + if _, err := f.provider().VerifyMovesSweep(t.Context(), f.observe(t), f.census(t)); err != nil { |
| + t.Fatalf("no lane of the orphan, so nothing unproven: %v", err) |
| + } |
| + f.bucket.Put("data", "moves/"+evHost+"/mv_orphan/move.json", []byte("orphan")) |
| + f.rec.LastMove.At = time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC) |
| + bothRefuse(t, f, true) |
| }) |
| } |
| |
| @@ -464,12 +602,14 @@ func (f *evFixture) worker(t *testing.T, listing time.Time) hostbox.RegionalWork |
| t.Helper() |
| seq := 0 |
| p := f.provider() |
| + location := f.location |
| engine := park.Engine{ |
| Store: f.bucket, Claims: f.claims, Bucket: "data", Region: "ie", Leases: evLeases{}, |
| Now: func() time.Time { return listing }, |
| NewID: func() string { seq++; return fmt.Sprintf("id%d", seq) }, |
| View: func(_ context.Context, o hostclaim.Observed) (park.View, error) { |
| - return park.View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "uk"}, nil |
| + return park.View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "uk", |
| + GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}, nil |
| }, |
| Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }, |
| VerifyMovesSweep: p.VerifyMovesSweep, |
| @@ -502,7 +642,7 @@ func TestAllowedSweepQueuesTheMovesChore(t *testing.T) { |
| f := newEvFixture(t) |
| f.lane(evMoveID, "move.json") |
| f.lane("mv_older", "replica/0000/db") |
| - left := f.location + "/g-left/0000/db" |
| + left := f.genKey("old") // superseded by the move's own admission |
| f.bucket.Put("data", left, []byte("left behind")) |
| err := f.worker(t, evNow).Run(t.Context()) |
| if !errors.Is(err, park.ErrPending) { |
| @@ -523,7 +663,7 @@ func TestAllowedSweepQueuesTheMovesChore(t *testing.T) { |
| t.Fatalf("no archive under the holder's incarnation: %v, %v", vs, err) |
| } |
| for _, o := range f.observe(t).Claim.Obligations { |
| - if o.Op != "session:1" || o.Owner != "region:ie" { |
| + if o.Op != "session:2" || o.Owner != "region:ie" { |
| t.Fatalf("chore %+v is not the serving session's regional chore", o) |
| } |
| } |
| @@ -572,7 +712,7 @@ func TestSweepSkipsWhileAMoveStages(t *testing.T) { |
| func TestSweepRefusedUnlessTheCloseIsBeforeTheListing(t *testing.T) { |
| f := newEvFixture(t) |
| f.lane(evMoveID, "move.json") |
| - f.bucket.Put("data", f.location+"/g-left/0000/db", []byte("left behind")) |
| + f.bucket.Put("data", f.genKey("old"), []byte("left behind")) |
| err := f.worker(t, evAt).Run(t.Context()) |
| if !errors.Is(err, park.ErrPending) { |
| t.Fatalf("the location chore still queues: %v", err) |
| @@ -603,10 +743,11 @@ func TestSweepScopeMembership(t *testing.T) { |
| } |
| } |
| |
| -// TestFreeHostSweepIsRefusedWithoutAHolder: a free claim names no record, |
| -// so no closed move can be proven and the claim stays free. Its residue is |
| -// the legacy census's (M7, claim Plan 12). |
| -func TestFreeHostSweepIsRefusedWithoutAHolder(t *testing.T) { |
| +// TestFreeHostLaneProofIsClaimPlan12s (fix round 1, ruling 3): a free claim |
| +// names no record, so moves can prove no closed move and the claim stays |
| +// free. The free-host lane proof is transferred to claim Plan 12 (confirmed |
| +// by platform-1e); this test pins the refusal until then. |
| +func TestFreeHostLaneProofIsClaimPlan12s(t *testing.T) { |
| f := newEvFixture(t) |
| f.setClaim(t, func(c *hostclaim.Claim) { c.Holder, c.Op, c.Touched = nil, hostclaim.Op{}, hostclaim.Touched{} }) |
| f.lane(evMoveID, "move.json") |
| @@ -619,3 +760,71 @@ func TestFreeHostSweepIsRefusedWithoutAHolder(t *testing.T) { |
| t.Fatalf("claim = %+v", c) |
| } |
| } |
| + |
| +// authoritative makes ie the authoritative region, with the marker at the |
| +// holder's current generation and objects classified by the landing's |
| +// layout, as Task 9a's view will. |
| +func (f *evFixture) authoritative(t *testing.T, w *hostbox.RegionalWorker, current string) { |
| + t.Helper() |
| + marker := f.location + "/generation.json" |
| + ref := park.Ref{Key: marker, VersionID: f.bucket.LatestID("data", marker)} |
| + location := f.location |
| + w.Regional.Engine.View = func(_ context.Context, o hostclaim.Observed) (park.View, error) { |
| + return park.View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "ie", MarkerKnown: true, MarkerGeneration: current, Marker: ref, |
| + GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}, nil |
| + } |
| +} |
| + |
| +// TestAuthoritativeSweepStampsTheCloseOnlyOnWhatTheMoveRetired (fix round 1, |
| +// ruling 1): in the authoritative region the engine makes superseded |
| +// generations park-generation candidates and binds them to |
| +// VerifyRetirement's time. A generation the move's admission superseded |
| +// gets the close; when the move's own generation was superseded after the |
| +// close, the provider refuses and nothing is queued. |
| +func TestAuthoritativeSweepStampsTheCloseOnlyOnWhatTheMoveRetired(t *testing.T) { |
| + listing := evAt.Add(time.Minute) // before the close's horizon, so chores stay queued |
| + t.Run("superseded by the move", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.lane(evMoveID, "move.json") |
| + f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| + f.bucket.Put("data", f.location+"/generation.json", []byte("marker")) |
| + f.bucket.Put("data", f.genKey("old"), []byte("superseded by the move")) |
| + w := f.worker(t, listing) |
| + f.authoritative(t, &w, evMoveID) |
| + if err := w.Run(t.Context()); !errors.Is(err, park.ErrPending) { |
| + t.Fatalf("the chores wait for their horizons: %v", err) |
| + } |
| + var gens []hostclaim.Obligation |
| + for _, o := range f.observe(t).Claim.Obligations { |
| + if o.Kind == "park-generation" { |
| + gens = append(gens, o) |
| + } |
| + } |
| + if len(gens) != 1 || gens[0].Generation != "old" || gens[0].Park.Location != f.location || !gens[0].Park.EvidenceAt.Equal(evAt) { |
| + t.Fatalf("generation chores = %+v; want one for old at the close %v", gens, evAt) |
| + } |
| + if moves, ok := f.chores(t)["moves/"+evHost]; !ok || !moves.EvidenceAt.Equal(listing) { |
| + t.Fatalf("chores = %+v; want the moves chore at the listing", f.chores(t)) |
| + } |
| + }) |
| + t.Run("superseded after the close", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.setClaim(t, func(c *hostclaim.Claim) { |
| + c.Holder.Admissions, c.Holder.Generation, c.Holder.Session, c.Holder.Stamp = evAdmissions(evMoveID, "later"), "later", 3, 3 |
| + }) |
| + f.lane(evMoveID, "move.json") |
| + f.bucket.Put("data", f.genKey("later"), []byte("live")) |
| + f.bucket.Put("data", f.location+"/generation.json", []byte("marker")) |
| + f.bucket.Put("data", f.genKey(evMoveID), []byte("written until after the close")) |
| + w := f.worker(t, listing) |
| + f.authoritative(t, &w, "later") |
| + err := w.Run(t.Context()) |
| + var ne *NotMovesEvidence |
| + if !errors.As(err, &ne) || ne.Generation != evMoveID { |
| + t.Fatalf("err = %v, want a *NotMovesEvidence for %s", err, evMoveID) |
| + } |
| + if len(f.observe(t).Claim.Obligations) != 0 { |
| + t.Fatalf("chores %+v", f.observe(t).Claim.Obligations) |
| + } |
| + }) |
| +} |