| new file mode 100644 |
| index 000000000..a3d7afc9d |
| --- /dev/null |
| +++ b/internal/host/movesevidence_test.go |
| @@ -0,0 +1,583 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The moves retirement evidence provider (Plan 5 Task 5, rulings K10, M7, |
| +// M8). The fake bucket's clock starts at 2026-10-05T00:00:00Z and ticks a |
| +// second per write, so every object a fixture writes is older than evAt. |
| +var ( |
| + evCreated = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) |
| + evAt = time.Date(2026, 10, 5, 1, 0, 0, 0, time.UTC) // LastMove.At, the close |
| + evNow = time.Date(2026, 10, 5, 2, 0, 0, 0, time.UTC) // the sweep's clock |
| +) |
| + |
| +const ( |
| + evHost = "ev.example" |
| + evAcct = "acct" |
| + evApp = "app" |
| + evMoveID = "mv_retired" |
| +) |
| + |
| +type evFixture struct { |
| + inc, location string |
| + claims release.Store |
| + bucket *leasetest.VersionedBucket |
| + rec *provision.Record |
| + reads int |
| +} |
| + |
| +// newEvFixture is a serving claim released by evMoveID, whose record closed |
| +// that move at evAt and carries no live move. |
| +func newEvFixture(t *testing.T) *evFixture { |
| + t.Helper() |
| + inc, err := hostclaim.Incarnation(evAcct, evApp, evHost, evCreated) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f := &evFixture{inc: inc, location: "instances/" + evHost + "/" + inc, claims: release.NewDirStore(t.TempDir()), bucket: leasetest.NewVersionedBucket()} |
| + f.bucket.AddBucket("data") |
| + 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.Op = servingReleasedBy(evMoveID) |
| + c.Touched = hostclaim.Touched{Complete: true, Regions: []string{"ie"}} |
| + }) |
| + return f |
| +} |
| + |
| +// 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}} |
| +} |
| + |
| +func (f *evFixture) setClaim(t *testing.T, mut func(*hostclaim.Claim)) { |
| + t.Helper() |
| + obs, err := hostclaim.Read(t.Context(), f.claims, evHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(t.Context(), f.claims, obs, func(c *hostclaim.Claim) error { mut(c); return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (f *evFixture) observe(t *testing.T) hostclaim.Observed { |
| + t.Helper() |
| + obs, err := hostclaim.Read(t.Context(), f.claims, evHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return obs |
| +} |
| + |
| +func (f *evFixture) census(t *testing.T) []lease.ObjectVersion { |
| + t.Helper() |
| + var out []lease.ObjectVersion |
| + for _, p := range []string{"instances/" + evHost + "/", "moves/" + evHost + "/"} { |
| + vs, err := f.bucket.ListVersions(t.Context(), "data", p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out = append(out, vs...) |
| + } |
| + return out |
| +} |
| + |
| +func (f *evFixture) provider() movesRetirementEvidence { |
| + return movesRetirementEvidence{ |
| + Records: func(_ context.Context, acct, app, host string) (*provision.Record, error) { |
| + f.reads++ |
| + if acct != evAcct || app != evApp || host != evHost { |
| + return nil, fmt.Errorf("read %s/%s/%s", acct, app, host) |
| + } |
| + if f.rec == nil { |
| + return nil, errors.New("no record") |
| + } |
| + cp := *f.rec |
| + return &cp, nil |
| + }, |
| + Now: func() time.Time { return evNow }, |
| + } |
| +} |
| + |
| +// lane writes one object of a move's staging lane. |
| +func (f *evFixture) lane(op, name string) { |
| + f.bucket.Put("data", "moves/"+evHost+"/"+op+"/"+name, []byte(op)) |
| +} |
| + |
| +func wantRefusal(t *testing.T, err error, anomaly bool) { |
| + t.Helper() |
| + var r *MovesSweepRefusal |
| + if !errors.As(err, &r) { |
| + t.Fatalf("err = %v, want a *MovesSweepRefusal", err) |
| + } |
| + if r.Host != evHost || r.Reason == "" || r.Anomaly != anomaly { |
| + t.Fatalf("refusal = %+v, want host %s, a reason and anomaly %v", r, evHost, anomaly) |
| + } |
| +} |
| + |
| +// bothRefuse asserts the two methods refuse alike: VerifyRetirement's |
| +// label is the proof's, so neither may answer where the other refuses. |
| +func bothRefuse(t *testing.T, f *evFixture, anomaly bool) { |
| + t.Helper() |
| + obs, census := f.observe(t), f.census(t) |
| + if _, err := f.provider().VerifyMovesSweep(t.Context(), obs, census); err == nil { |
| + t.Fatal("VerifyMovesSweep answered") |
| + } else { |
| + wantRefusal(t, err, anomaly) |
| + } |
| + inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| + if err == nil || inc != "" || !at.IsZero() { |
| + t.Fatalf("VerifyRetirement = %q, %v, %v; want a refusal and no evidence", inc, at, err) |
| + } |
| + wantRefusal(t, err, anomaly) |
| +} |
| + |
| +// TestMovesSweepProofRetiredAtIsTheClose (M8): the proof binds the exact |
| +// claim observation, names LastMove's id, the holder's incarnation and the |
| +// close's time, and the engine's Check accepts it only for a listing that |
| +// started after that close. |
| +func TestMovesSweepProofRetiredAtIsTheClose(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.lane(evMoveID, "move.json") |
| + f.lane(evMoveID, "replica/0000/db") |
| + f.bucket.Put("data", "moves/"+evHost+"/lock.json", []byte("lease")) |
| + obs := f.observe(t) |
| + proof, err := f.provider().VerifyMovesSweep(t.Context(), obs, f.census(t)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := park.MoveSweepProof{Host: evHost, ClaimETag: obs.ETag, MoveID: evMoveID, Incarnation: f.inc, RetiredAt: evAt} |
| + if proof != want { |
| + t.Fatalf("proof = %+v\nwant %+v", proof, want) |
| + } |
| + if err := proof.Check(obs, f.inc, evNow); err != nil { |
| + t.Fatalf("a listing after the close: %v", err) |
| + } |
| + for _, listing := range []time.Time{evAt, evAt.Add(-time.Second)} { |
| + if err := proof.Check(obs, f.inc, listing); !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatalf("a listing at %v, not after the close %v: %v", listing, evAt, err) |
| + } |
| + } |
| +} |
| + |
| +// TestVerifyRetirementLabelsMovesResidueWithTheProofsIncarnation (M7): |
| +// Decide labels every moves/<host> candidate with VerifyRetirement's |
| +// answer, and Check requires the proof's incarnation to equal it, so the |
| +// two answers come from one source. The evidence time is the close's, never |
| +// zero and never the clock's. |
| +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")) |
| + obs, census := f.observe(t), f.census(t) |
| + inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if inc != f.inc || !at.Equal(evAt) { |
| + t.Fatalf("VerifyRetirement = %q, %v; want %q, %v", inc, at, f.inc, evAt) |
| + } |
| + proof, err := f.provider().VerifyMovesSweep(t.Context(), obs, census) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if proof.Incarnation != inc { |
| + t.Fatalf("label %q, proof %q", inc, proof.Incarnation) |
| + } |
| + if err := proof.Check(obs, inc, evNow); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// TestVerifyRetirementRefusesLegacyResidue (M7): residue under |
| +// instances/<host> outside the holder's live location has no moves-owned |
| +// archived-incarnation history. VerifyRetirement refuses it with a typed |
| +// dependency error (the legacy census is claim Plan 12's), which skips the |
| +// whole at-rest sweep. The moves proof itself is unaffected. |
| +func TestVerifyRetirementRefusesLegacyResidue(t *testing.T) { |
| + for name, key := range map[string]string{ |
| + "a legacy level object": "instances/" + evHost + "/0000/db", |
| + "a legacy generation object": "instances/" + evHost + "/g-old/0000/db", |
| + "an older incarnation": "instances/" + evHost + "/" + strings.Repeat("ab", 32) + "/g-old/0000/db", |
| + } { |
| + 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", key, []byte("legacy")) |
| + obs, census := f.observe(t), f.census(t) |
| + inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| + var dep *ClaimDependencyError |
| + if !errors.As(err, &dep) || inc != "" || !at.IsZero() { |
| + t.Fatalf("VerifyRetirement = %q, %v, %v; want a *ClaimDependencyError and no evidence", inc, at, err) |
| + } |
| + if _, err := f.provider().VerifyMovesSweep(t.Context(), obs, census); err != nil { |
| + t.Fatalf("the moves proof does not depend on instances residue: %v", err) |
| + } |
| + }) |
| + } |
| + t.Run("a holder in the legacy location form", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.setClaim(t, func(c *hostclaim.Claim) { c.Holder.Location = "instances/" + evHost }) |
| + f.bucket.Put("data", "instances/"+evHost+"/0000/db", []byte("live")) |
| + _, _, err := f.provider().VerifyRetirement(t.Context(), f.observe(t), f.census(t)) |
| + var dep *ClaimDependencyError |
| + if !errors.As(err, &dep) { |
| + t.Fatalf("err = %v, want a *ClaimDependencyError", err) |
| + } |
| + }) |
| +} |
| + |
| +// 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. |
| +func TestMovesSweepProofRefusesALiveMove(t *testing.T) { |
| + t.Run("the record carries a live move", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.rec.Move = &provision.MoveState{ID: "mv_next", Incarnation: f.inc, To: "uk", State: provision.MovePreparing} |
| + f.lane("mv_next", "move.json") |
| + bothRefuse(t, f, false) |
| + }) |
| + for _, op := range []hostclaim.Op{ |
| + {Kind: hostclaim.KindMoving, ID: "mv_next", Phase: hostclaim.PhaseMoving, Exec: "box"}, |
| + {Kind: hostclaim.KindLanding, ID: evMoveID, Phase: hostclaim.PhaseCommitted, Exec: "box"}, |
| + {Kind: hostclaim.KindRestoring, ID: evMoveID, Phase: hostclaim.PhaseOpened, Exec: "box"}, |
| + {Kind: hostclaim.KindReverting, ID: evMoveID, Phase: hostclaim.PhaseOpened, Exec: "box"}, |
| + } { |
| + // The last three are the record-first close's window (M4): the |
| + // record closed the move, the claim has not released it yet. |
| + t.Run("the claim holds "+op.Kind, func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.setClaim(t, func(c *hostclaim.Claim) { c.Op = op }) |
| + bothRefuse(t, f, false) |
| + }) |
| + } |
| + t.Run("the claim was released by another move", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.setClaim(t, func(c *hostclaim.Claim) { c.Op = servingReleasedBy("mv_orphan") }) |
| + bothRefuse(t, f, true) |
| + }) |
| + t.Run("the serving claim names no released move", 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) |
| + }) |
| + t.Run("the record names no closed move", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.rec.LastMove = nil |
| + bothRefuse(t, f, false) |
| + }) |
| +} |
| + |
| +// TestMovesEvidenceRefusesAZeroTime (M7, M8): a zero time is never |
| +// evidence: not the close's, and not a lane object's. |
| +func TestMovesEvidenceRefusesAZeroTime(t *testing.T) { |
| + t.Run("a zero close", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.rec.LastMove.At = time.Time{} |
| + bothRefuse(t, f, true) |
| + }) |
| + t.Run("a close not before the provider's clock", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.rec.LastMove.At = evNow |
| + bothRefuse(t, f, true) |
| + }) |
| + t.Run("an older lane object with no time", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + obs := f.observe(t) |
| + census := []lease.ObjectVersion{{Key: "moves/" + evHost + "/mv_older/move.json", VersionID: "v1", IsLatest: true}} |
| + if _, err := f.provider().VerifyMovesSweep(t.Context(), obs, census); err == nil { |
| + t.Fatal("a zero LastModified counted as before the close") |
| + } else { |
| + wantRefusal(t, err, true) |
| + } |
| + if _, _, err := f.provider().VerifyRetirement(t.Context(), obs, census); err == nil { |
| + t.Fatal("VerifyRetirement answered over a lane with no time") |
| + } |
| + }) |
| +} |
| + |
| +// TestMovesSweepProofRefusesAnUnknownLane (M8): every moves/<host>/<op>/ |
| +// object is the closed move's own, or older than its close. An object of |
| +// another op written after the close has an unknown writer. |
| +func TestMovesSweepProofRefusesAnUnknownLane(t *testing.T) { |
| + t.Run("an older lane is covered by the close", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.lane("mv_older", "move.json") |
| + f.lane("rb_return", "replica/0000/db") |
| + if _, err := f.provider().VerifyMovesSweep(t.Context(), f.observe(t), f.census(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + t.Run("a lane 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) // before every write |
| + f.lane(evMoveID, "move.json") |
| + if _, err := f.provider().VerifyMovesSweep(t.Context(), f.observe(t), f.census(t)); err != nil { |
| + t.Fatalf("the closed move's own lane is its own: %v", err) |
| + } |
| + f.lane("mv_unknown", "move.json") |
| + bothRefuse(t, f, true) |
| + }) |
| + t.Run("an object outside any lane", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.bucket.Put("data", "moves/"+evHost+"/stray.json", []byte("?")) |
| + bothRefuse(t, f, true) |
| + }) |
| + t.Run("an object outside the host's namespaces", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + census := []lease.ObjectVersion{{Key: "moves/other.example/" + evMoveID + "/move.json", VersionID: "v1", LastModified: evAt.Add(-time.Hour)}} |
| + if _, err := f.provider().VerifyMovesSweep(t.Context(), f.observe(t), census); err == nil { |
| + t.Fatal("another host's object counted") |
| + } |
| + }) |
| +} |
| + |
| +// TestMovesEvidenceRefusesAnotherIdentity: the record must be the holder's |
| +// exact record, and a holderless claim has no record to prove from. |
| +func TestMovesEvidenceRefusesAnotherIdentity(t *testing.T) { |
| + t.Run("a record of another incarnation", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.rec.CreatedAt = evCreated.Add(time.Hour) |
| + bothRefuse(t, f, true) |
| + }) |
| + t.Run("a stale observation", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + obs := f.observe(t) |
| + obs.ETag = "" |
| + if _, err := f.provider().VerifyMovesSweep(t.Context(), obs, f.census(t)); err == nil { |
| + t.Fatal("answered without an etag") |
| + } |
| + }) |
| + t.Run("a free claim", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.setClaim(t, func(c *hostclaim.Claim) { c.Holder, c.Op = nil, hostclaim.Op{} }) |
| + obs, census := f.observe(t), f.census(t) |
| + _, err := f.provider().VerifyMovesSweep(t.Context(), obs, census) |
| + var dep *ClaimDependencyError |
| + if !errors.As(err, &dep) { |
| + t.Fatalf("err = %v, want a *ClaimDependencyError", err) |
| + } |
| + if _, _, err := f.provider().VerifyRetirement(t.Context(), obs, census); !errors.As(err, &dep) { |
| + t.Fatalf("err = %v, want a *ClaimDependencyError", err) |
| + } |
| + if f.reads != 0 { |
| + t.Fatalf("read %d records with no holder to name one", f.reads) |
| + } |
| + }) |
| + t.Run("no providers", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + if _, err := (movesRetirementEvidence{}).VerifyMovesSweep(t.Context(), f.observe(t), nil); err == nil { |
| + t.Fatal("answered with no record reader") |
| + } |
| + if _, _, err := (movesRetirementEvidence{Records: f.provider().Records}).VerifyRetirement(t.Context(), f.observe(t), nil); err == nil { |
| + t.Fatal("answered with no clock") |
| + } |
| + }) |
| +} |
| + |
| +// The provider satisfies both slots RegisterProductionClaimMoveParks reads: |
| +// the regional evidence and, by type assertion, the sweep verifier. |
| +var ( |
| + _ hostbox.RegionalEvidence = movesRetirementEvidence{} |
| + _ park.MoveSweepVerifier = movesRetirementEvidence{} |
| +) |
| + |
| +// --- against the claim's regional worker, with a fake store --- |
| + |
| +type evLease struct{} |
| + |
| +func (evLease) Check(context.Context) error { return nil } |
| +func (evLease) Release(context.Context) error { return nil } |
| +func (evLease) Close() {} |
| + |
| +type evLeases struct{} |
| + |
| +func (evLeases) Acquire(context.Context, string, string) (park.HeldLease, error) { |
| + return evLease{}, nil |
| +} |
| + |
| +type evEnrollment struct{} |
| + |
| +func (evEnrollment) VerifyRegional(context.Context, string, string, time.Duration) error { return nil } |
| + |
| +// worker is hostbox.RegionalWorker over park.Engine, wired as the claim's |
| +// assembly wires them (host/claimassembly.go): the provider is the worker's |
| +// Evidence and the engine's VerifyMovesSweep. This region (ie) is not the |
| +// authoritative one, so the holder's location here is residue too. |
| +func (f *evFixture) worker(t *testing.T, listing time.Time) hostbox.RegionalWorker { |
| + t.Helper() |
| + seq := 0 |
| + p := f.provider() |
| + 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 |
| + }, |
| + Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }, |
| + VerifyMovesSweep: p.VerifyMovesSweep, |
| + } |
| + return hostbox.RegionalWorker{ |
| + Regional: hostbox.Regional{Engine: engine, Owner: func(context.Context, hostclaim.Observed) error { return nil }}, |
| + Enrollment: evEnrollment{}, |
| + Evidence: p, |
| + } |
| +} |
| + |
| +func (f *evFixture) chores(t *testing.T) map[string]hostclaim.ParkScope { |
| + t.Helper() |
| + out := map[string]hostclaim.ParkScope{} |
| + for _, o := range f.observe(t).Claim.Obligations { |
| + if o.Park == nil { |
| + t.Fatalf("an unscoped chore: %+v", o) |
| + } |
| + out[o.Park.Location] = *o.Park |
| + } |
| + return out |
| +} |
| + |
| +// TestAllowedSweepQueuesTheMovesChore: with the proof, the worker queues the |
| +// moves/<host> chore under the serving session, labelled with the holder's |
| +// incarnation, its evidence the listing's end, so it waits for its horizon. |
| +// The holder location's residue in this non-authoritative region takes the |
| +// close as its evidence: that horizon has passed, so the same pass parks it. |
| +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" |
| + f.bucket.Put("data", left, []byte("left behind")) |
| + err := f.worker(t, evNow).Run(t.Context()) |
| + if !errors.Is(err, park.ErrPending) { |
| + t.Fatalf("a new sweep waits for its horizon: %v", err) |
| + } |
| + chores := f.chores(t) |
| + moves, ok := chores["moves/"+evHost] |
| + if !ok || len(chores) != 1 { |
| + t.Fatalf("chores = %+v; want the moves chore alone", chores) |
| + } |
| + if moves.Incarnation != f.inc || !moves.EvidenceAt.Equal(evNow) || moves.RequestLimit != hostbox.RequestLimit { |
| + t.Fatalf("moves chore = %+v; want incarnation %s, evidence %v", moves, f.inc, evNow) |
| + } |
| + if vs, err := f.bucket.ListVersions(t.Context(), "data", left); err != nil || len(vs) != 0 { |
| + t.Fatalf("the location's residue was not parked: %v, %v", vs, err) |
| + } |
| + if vs, err := f.bucket.ListVersions(t.Context(), "data", "parked/"+f.inc+"/"+evHost+"/"); err != nil || len(vs) == 0 { |
| + 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" { |
| + t.Fatalf("chore %+v is not the serving session's regional chore", o) |
| + } |
| + } |
| + if c := f.observe(t).Claim; c.Op.Kind != hostclaim.KindServing || c.Op.Journal["released_move"] != evMoveID { |
| + t.Fatalf("the sweep changed the serving op: %+v", c.Op) |
| + } |
| +} |
| + |
| +// TestSweepSkipsWhileAMoveStages (Review Focus 3, K10): while a move op is |
| +// on the claim the worker never asks for evidence; while the record carries |
| +// a live move under a serving claim the provider refuses and nothing is |
| +// queued. |
| +func TestSweepSkipsWhileAMoveStages(t *testing.T) { |
| + t.Run("a moving claim", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.setClaim(t, func(c *hostclaim.Claim) { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: "mv_next", Phase: hostclaim.PhaseMoving, Exec: "box"} |
| + }) |
| + f.rec.Move = &provision.MoveState{ID: "mv_next", Incarnation: f.inc, To: "uk", State: provision.MovePreparing} |
| + f.lane("mv_next", "replica/0000/db") |
| + if err := f.worker(t, evNow).Run(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(f.chores(t)) != 0 || f.reads != 0 { |
| + t.Fatalf("chores %+v, record reads %d", f.chores(t), f.reads) |
| + } |
| + }) |
| + t.Run("a live move on the record", func(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.rec.Move = &provision.MoveState{ID: "mv_next", Incarnation: f.inc, To: "uk", State: provision.MovePreparing} |
| + f.lane("mv_next", "replica/0000/db") |
| + err := f.worker(t, evNow).Run(t.Context()) |
| + var r *MovesSweepRefusal |
| + if !errors.As(err, &r) { |
| + t.Fatalf("err = %v, want the provider's refusal", err) |
| + } |
| + if len(f.chores(t)) != 0 { |
| + t.Fatalf("chores %+v", f.chores(t)) |
| + } |
| + }) |
| +} |
| + |
| +// TestSweepRefusedUnlessTheCloseIsBeforeTheListing (K10): the worker's own |
| +// Check drops the moves/<host> candidate when the newest retired move's |
| +// close is not earlier than the listing; the rest of the decision stands. |
| +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")) |
| + err := f.worker(t, evAt).Run(t.Context()) |
| + if !errors.Is(err, park.ErrPending) { |
| + t.Fatalf("the location chore still queues: %v", err) |
| + } |
| + chores := f.chores(t) |
| + if _, ok := chores["moves/"+evHost]; ok || len(chores) != 1 { |
| + t.Fatalf("chores = %+v; want only the location's", chores) |
| + } |
| + if loc := chores[f.location]; loc.Incarnation != f.inc || !loc.EvidenceAt.Equal(evAt) { |
| + t.Fatalf("location chore = %+v; want the close %v as its evidence", loc, evAt) |
| + } |
| +} |
| + |
| +// TestSweepScopeMembership (witness wSweepScopeMembership, M7): residue of |
| +// an older incarnation is never the moves/<host> chore's. Without its own |
| +// evidence the provider refuses the whole sweep, so nothing is queued. |
| +func TestSweepScopeMembership(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.lane(evMoveID, "move.json") |
| + f.bucket.Put("data", "instances/"+evHost+"/"+strings.Repeat("cd", 32)+"/g-old/0000/db", []byte("late")) |
| + err := f.worker(t, evNow).Run(t.Context()) |
| + var dep *ClaimDependencyError |
| + if !errors.As(err, &dep) { |
| + t.Fatalf("err = %v, want the legacy census dependency", err) |
| + } |
| + if len(f.chores(t)) != 0 { |
| + t.Fatalf("chores %+v", f.chores(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) { |
| + 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") |
| + err := f.worker(t, evNow).Run(t.Context()) |
| + var dep *ClaimDependencyError |
| + if !errors.As(err, &dep) { |
| + t.Fatalf("err = %v, want a *ClaimDependencyError", err) |
| + } |
| + if c := f.observe(t).Claim; !c.Free() || len(c.Obligations) != 0 { |
| + t.Fatalf("claim = %+v", c) |
| + } |
| +} |