| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/hostbox" |
| 12 | "github.com/carlosframework/platform/internal/hostclaim" |
| 13 | "github.com/carlosframework/platform/internal/hostops" |
| 14 | "github.com/carlosframework/platform/internal/lease" |
| 15 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 16 | "github.com/carlosframework/platform/internal/park" |
| 17 | "github.com/carlosframework/platform/internal/provision" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | ) |
| 20 | |
| 21 | // The moves retirement evidence provider (Plan 5 Task 5, rulings K10, M7, |
| 22 | // M8). The fake bucket's clock starts at 2026-10-05T00:00:00Z and ticks a |
| 23 | // second per write, so every object a fixture writes is older than evAt. |
| 24 | var ( |
| 25 | evCreated = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) |
| 26 | evAt = time.Date(2026, 10, 5, 1, 0, 0, 0, time.UTC) // LastMove.At, the close |
| 27 | evNow = time.Date(2026, 10, 5, 2, 0, 0, 0, time.UTC) // the sweep's clock |
| 28 | ) |
| 29 | |
| 30 | const ( |
| 31 | evHost = "ev.example" |
| 32 | evAcct = "acct" |
| 33 | evApp = "app" |
| 34 | evMoveID = "mv_retired" |
| 35 | ) |
| 36 | |
| 37 | type evFixture struct { |
| 38 | inc, location string |
| 39 | claims release.Store |
| 40 | bucket *leasetest.VersionedBucket |
| 41 | rec *provision.Record |
| 42 | reads int |
| 43 | } |
| 44 | |
| 45 | // newEvFixture is a serving claim released by evMoveID, whose record closed |
| 46 | // that move at evAt and carries no live move. |
| 47 | func newEvFixture(t *testing.T) *evFixture { |
| 48 | t.Helper() |
| 49 | inc, err := hostclaim.Incarnation(evAcct, evApp, evHost, evCreated) |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | f := &evFixture{inc: inc, location: "instances/" + evHost + "/" + inc, claims: release.NewDirStore(t.TempDir()), bucket: leasetest.NewVersionedBucket()} |
| 54 | f.bucket.AddBucket("data") |
| 55 | f.rec = &provision.Record{Schema: 1, Host: evHost, App: evApp, Account: evAcct, CreatedAt: evCreated, |
| 56 | LastMove: &provision.MoveOutcome{ID: evMoveID, Outcome: "done", At: evAt}} |
| 57 | f.setClaim(t, func(c *hostclaim.Claim) { |
| 58 | 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, |
| 59 | Admissions: evAdmissions(evMoveID)} |
| 60 | c.Op = servingReleasedBy(evMoveID) |
| 61 | c.Touched = hostclaim.Touched{Complete: true, Regions: []string{"ie"}} |
| 62 | }) |
| 63 | return f |
| 64 | } |
| 65 | |
| 66 | // evAdmissions is the holder's admission history: an origin generation |
| 67 | // "old", then the move's prepared landing, which admits the generation |
| 68 | // named after the move (movesprobeowners.go), then any later generations, |
| 69 | // each the next one's predecessor. The last is the holder's current. |
| 70 | func evAdmissions(gens ...string) []hostclaim.AdmissionRecord { |
| 71 | out := []hostclaim.AdmissionRecord{{Session: 1, Box: "box", Generation: "old", Mode: "cold", Region: "ie"}} |
| 72 | for i, g := range gens { |
| 73 | out = append(out, hostclaim.AdmissionRecord{Session: uint64(i + 2), Box: "box", Generation: g, Predecessor: out[i].Generation, Mode: "cold", Region: "ie", PredecessorRegion: "ie"}) |
| 74 | } |
| 75 | return out |
| 76 | } |
| 77 | |
| 78 | // genKey is an object of generation gen at the fixture's live location. |
| 79 | func (f *evFixture) genKey(gen string) string { return f.location + "/g-" + gen + "/0000/db" } |
| 80 | |
| 81 | // servingReleasedBy is the serving op hostclaim.ReleaseMove leaves. |
| 82 | func servingReleasedBy(id string) hostclaim.Op { |
| 83 | return hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box", Journal: map[string]string{"released_move": id}} |
| 84 | } |
| 85 | |
| 86 | func (f *evFixture) setClaim(t *testing.T, mut func(*hostclaim.Claim)) { |
| 87 | t.Helper() |
| 88 | obs, err := hostclaim.Read(t.Context(), f.claims, evHost) |
| 89 | if err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | if _, err := hostclaim.Update(t.Context(), f.claims, obs, func(c *hostclaim.Claim) error { mut(c); return nil }); err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func (f *evFixture) observe(t *testing.T) hostclaim.Observed { |
| 98 | t.Helper() |
| 99 | obs, err := hostclaim.Read(t.Context(), f.claims, evHost) |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | return obs |
| 104 | } |
| 105 | |
| 106 | func (f *evFixture) census(t *testing.T) []lease.ObjectVersion { |
| 107 | t.Helper() |
| 108 | var out []lease.ObjectVersion |
| 109 | for _, p := range []string{"instances/" + evHost + "/", "moves/" + evHost + "/"} { |
| 110 | vs, err := f.bucket.ListVersions(t.Context(), "data", p) |
| 111 | if err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | out = append(out, vs...) |
| 115 | } |
| 116 | return out |
| 117 | } |
| 118 | |
| 119 | func (f *evFixture) provider() movesRetirementEvidence { |
| 120 | return movesRetirementEvidence{ |
| 121 | Records: func(_ context.Context, acct, app, host string) (*provision.Record, error) { |
| 122 | f.reads++ |
| 123 | if acct != evAcct || app != evApp || host != evHost { |
| 124 | return nil, fmt.Errorf("read %s/%s/%s", acct, app, host) |
| 125 | } |
| 126 | if f.rec == nil { |
| 127 | return nil, errors.New("no record") |
| 128 | } |
| 129 | cp := *f.rec |
| 130 | return &cp, nil |
| 131 | }, |
| 132 | Now: func() time.Time { return evNow }, |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // lane writes one object of a move's staging lane. |
| 137 | func (f *evFixture) lane(op, name string) { |
| 138 | f.bucket.Put("data", "moves/"+evHost+"/"+op+"/"+name, []byte(op)) |
| 139 | } |
| 140 | |
| 141 | func wantRefusal(t *testing.T, err error, anomaly bool) { |
| 142 | t.Helper() |
| 143 | var r *MovesSweepRefusal |
| 144 | if !errors.As(err, &r) { |
| 145 | t.Fatalf("err = %v, want a *MovesSweepRefusal", err) |
| 146 | } |
| 147 | if r.Host != evHost || r.Reason == "" || r.Anomaly != anomaly { |
| 148 | t.Fatalf("refusal = %+v, want host %s, a reason and anomaly %v", r, evHost, anomaly) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // bothRefuse asserts the two methods refuse alike: VerifyRetirement's |
| 153 | // label is the proof's, so neither may answer where the other refuses. |
| 154 | func bothRefuse(t *testing.T, f *evFixture, anomaly bool) { |
| 155 | t.Helper() |
| 156 | obs, census := f.observe(t), f.census(t) |
| 157 | if _, err := f.provider().VerifyMovesSweep(t.Context(), obs, census); err == nil { |
| 158 | t.Fatal("VerifyMovesSweep answered") |
| 159 | } else { |
| 160 | wantRefusal(t, err, anomaly) |
| 161 | } |
| 162 | inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| 163 | if err == nil || inc != "" || !at.IsZero() { |
| 164 | t.Fatalf("VerifyRetirement = %q, %v, %v; want a refusal and no evidence", inc, at, err) |
| 165 | } |
| 166 | wantRefusal(t, err, anomaly) |
| 167 | } |
| 168 | |
| 169 | // TestMovesSweepProofRetiredAtIsTheClose (M8): the proof binds the exact |
| 170 | // claim observation, names LastMove's id, the holder's incarnation and the |
| 171 | // close's time, and the engine's Check accepts it only for a listing that |
| 172 | // started after that close. |
| 173 | func TestMovesSweepProofRetiredAtIsTheClose(t *testing.T) { |
| 174 | f := newEvFixture(t) |
| 175 | f.lane(evMoveID, "move.json") |
| 176 | f.lane(evMoveID, "replica/0000/db") |
| 177 | f.bucket.Put("data", "moves/"+evHost+"/lock.json", []byte("lease")) |
| 178 | obs := f.observe(t) |
| 179 | proof, err := f.provider().VerifyMovesSweep(t.Context(), obs, f.census(t)) |
| 180 | if err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | want := park.MoveSweepProof{Host: evHost, ClaimETag: obs.ETag, MoveID: evMoveID, Incarnation: f.inc, RetiredAt: evAt} |
| 184 | if proof != want { |
| 185 | t.Fatalf("proof = %+v\nwant %+v", proof, want) |
| 186 | } |
| 187 | if err := proof.Check(obs, f.inc, evNow); err != nil { |
| 188 | t.Fatalf("a listing after the close: %v", err) |
| 189 | } |
| 190 | for _, listing := range []time.Time{evAt, evAt.Add(-time.Second)} { |
| 191 | if err := proof.Check(obs, f.inc, listing); !errors.Is(err, park.ErrUnsafe) { |
| 192 | t.Fatalf("a listing at %v, not after the close %v: %v", listing, evAt, err) |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // TestVerifyRetirementLabelsMovesResidueWithTheProofsIncarnation (M7): |
| 198 | // Decide labels every moves/<host> candidate with VerifyRetirement's |
| 199 | // answer, and Check requires the proof's incarnation to equal it, so the |
| 200 | // two answers come from one source. The evidence time is the close's, never |
| 201 | // zero and never the clock's. |
| 202 | func TestVerifyRetirementLabelsMovesResidueWithTheProofsIncarnation(t *testing.T) { |
| 203 | f := newEvFixture(t) |
| 204 | f.lane(evMoveID, "move.json") |
| 205 | f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| 206 | obs, census := f.observe(t), f.census(t) |
| 207 | inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| 208 | if err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | if inc != f.inc || !at.Equal(evAt) { |
| 212 | t.Fatalf("VerifyRetirement = %q, %v; want %q, %v", inc, at, f.inc, evAt) |
| 213 | } |
| 214 | proof, err := f.provider().VerifyMovesSweep(t.Context(), obs, census) |
| 215 | if err != nil { |
| 216 | t.Fatal(err) |
| 217 | } |
| 218 | if proof.Incarnation != inc { |
| 219 | t.Fatalf("label %q, proof %q", inc, proof.Incarnation) |
| 220 | } |
| 221 | if err := proof.Check(obs, inc, evNow); err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | // retirement runs the claim's retirement answer, the one the regional worker |
| 227 | // binds to every instances/<host> candidate, over the real census with this |
| 228 | // fixture's provider as the claim's moves evidence. |
| 229 | func (f *evFixture) retirement(t *testing.T, evidence hostbox.RegionalEvidence) (string, time.Time, error) { |
| 230 | t.Helper() |
| 231 | p := productionClaimRegional{Now: func() time.Time { return evNow }, Parks: &claimMoveParkSources{Evidence: evidence}} |
| 232 | return p.retirement(t.Context(), f.observe(t), f.census(t)) |
| 233 | } |
| 234 | |
| 235 | // TestRetirementLeavesQualifiedResidueToTheCensus (Q4): residue under |
| 236 | // instances/<host> outside the holder's live location is the claim's census |
| 237 | // to place, not the moves provider's to refuse. The census labels a |
| 238 | // qualified path by its own identity (ClaimRetirementCensus) and the claim's |
| 239 | // view refuses unmapped bare history per location, so the provider answers |
| 240 | // with the move's close and the moves/<host> lane sweep goes ahead. |
| 241 | func TestRetirementLeavesQualifiedResidueToTheCensus(t *testing.T) { |
| 242 | older := strings.Repeat("ab", 32) |
| 243 | for name, key := range map[string]string{ |
| 244 | "a legacy level object": "instances/" + evHost + "/0000/db", |
| 245 | "a legacy generation object": "instances/" + evHost + "/g-old/0000/db", |
| 246 | "an older incarnation": "instances/" + evHost + "/" + older + "/g-old/0000/db", |
| 247 | } { |
| 248 | t.Run(name, func(t *testing.T) { |
| 249 | f := newEvFixture(t) |
| 250 | f.lane(evMoveID, "move.json") |
| 251 | f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| 252 | f.bucket.Put("data", key, []byte("residue")) |
| 253 | inc, at, err := f.retirement(t, f.provider()) |
| 254 | if err != nil || inc != f.inc || !at.Equal(evAt) { |
| 255 | t.Fatalf("retirement = %q, %v, %v; want %q, %v and no refusal", inc, at, err, f.inc, evAt) |
| 256 | } |
| 257 | if _, err := f.provider().VerifyMovesSweep(t.Context(), f.observe(t), f.census(t)); err != nil { |
| 258 | t.Fatalf("the moves proof does not depend on instances residue: %v", err) |
| 259 | } |
| 260 | }) |
| 261 | } |
| 262 | t.Run("the census labels a qualified path by its own identity", func(t *testing.T) { |
| 263 | f := newEvFixture(t) |
| 264 | f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| 265 | f.bucket.Put("data", "instances/"+evHost+"/"+older+"/g-old/0000/db", []byte("older")) |
| 266 | labels, err := ClaimRetirementCensus(f.observe(t), f.census(t)) |
| 267 | if err != nil || labels["instances/"+evHost+"/"+older] != older { |
| 268 | t.Fatalf("labels = %v, %v", labels, err) |
| 269 | } |
| 270 | if _, _, err := f.retirement(t, f.provider()); err != nil { |
| 271 | t.Fatal(err) |
| 272 | } |
| 273 | }) |
| 274 | t.Run("an incarnation directory beside a bare holder", func(t *testing.T) { |
| 275 | f := newEvFixture(t) |
| 276 | f.bare(t) |
| 277 | f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| 278 | f.bucket.Put("data", "instances/"+evHost+"/"+f.inc+"/g-old/0000/db", []byte("scoped")) |
| 279 | inc, at, err := f.retirement(t, f.provider()) |
| 280 | if err != nil || inc != f.inc || !at.Equal(evAt) { |
| 281 | t.Fatalf("retirement = %q, %v, %v", inc, at, err) |
| 282 | } |
| 283 | }) |
| 284 | } |
| 285 | |
| 286 | // TestRetirementStillRefusesAGenerationTheMoveDidNotRetire (Q4): the close |
| 287 | // is the evidence time for every instances/<host> candidate the worker binds |
| 288 | // it to, so at the live location it must be true. retiredGenerations stays: |
| 289 | // a generation admitted from the move's own admission on, or written after |
| 290 | // the close, is refused with *NotMovesEvidence. A generation the move did |
| 291 | // retire passes beside it. |
| 292 | func TestRetirementStillRefusesAGenerationTheMoveDidNotRetire(t *testing.T) { |
| 293 | refused := func(t *testing.T, f *evFixture, gen string) { |
| 294 | t.Helper() |
| 295 | inc, at, err := f.retirement(t, f.provider()) |
| 296 | var n *NotMovesEvidence |
| 297 | if !errors.As(err, &n) || n.Generation != gen || inc != "" || !at.IsZero() { |
| 298 | t.Fatalf("retirement = %q, %v, %v; want *NotMovesEvidence for %q and no evidence", inc, at, err, gen) |
| 299 | } |
| 300 | if !errors.Is(err, ErrNotMovesEvidence) { |
| 301 | t.Fatalf("err does not unwrap to ErrNotMovesEvidence: %v", err) |
| 302 | } |
| 303 | } |
| 304 | t.Run("a generation the move retired passes", func(t *testing.T) { |
| 305 | f := newEvFixture(t) |
| 306 | f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| 307 | f.bucket.Put("data", f.genKey("old"), []byte("superseded by the move")) |
| 308 | inc, at, err := f.retirement(t, f.provider()) |
| 309 | if err != nil || inc != f.inc || !at.Equal(evAt) { |
| 310 | t.Fatalf("retirement = %q, %v, %v", inc, at, err) |
| 311 | } |
| 312 | }) |
| 313 | t.Run("a generation admitted after the move", func(t *testing.T) { |
| 314 | f := newEvFixture(t) |
| 315 | f.setClaim(t, func(c *hostclaim.Claim) { |
| 316 | c.Holder.Admissions = evAdmissions(evMoveID, "next") |
| 317 | c.Holder.Generation = "next" |
| 318 | }) |
| 319 | f.bucket.Put("data", f.genKey("next"), []byte("live")) |
| 320 | f.bucket.Put("data", f.genKey(evMoveID), []byte("superseded after the move")) |
| 321 | refused(t, f, evMoveID) |
| 322 | }) |
| 323 | t.Run("an object written after the close", func(t *testing.T) { |
| 324 | f := newEvFixture(t) |
| 325 | f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| 326 | f.bucket.Put("data", f.genKey("old"), []byte("late")) |
| 327 | f.rec.LastMove.At = time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC) |
| 328 | refused(t, f, "old") |
| 329 | }) |
| 330 | t.Run("a claim that holds no admission of the move", func(t *testing.T) { |
| 331 | f := newEvFixture(t) |
| 332 | f.setClaim(t, func(c *hostclaim.Claim) { c.Holder.Admissions = evAdmissions()[:1]; c.Holder.Generation = "old" }) |
| 333 | f.bucket.Put("data", f.genKey("old"), []byte("live")) |
| 334 | f.bucket.Put("data", f.genKey("other"), []byte("no admission to date it by")) |
| 335 | refused(t, f, "other") |
| 336 | }) |
| 337 | } |
| 338 | |
| 339 | // TestJoiningClaimRefusesQuietly (Q4): a joining claim (a parking sweep with |
| 340 | // no holder) and a free one name no record, so the moves provider has no |
| 341 | // close to prove from. It refuses with a *MovesSweepRefusal that is not an |
| 342 | // anomaly, never a dependency, and reads no record: the agent logs it once an |
| 343 | // hour and pages nobody. The claim's own free-host proof answers a free claim |
| 344 | // before it gets here (claimFreeMoveProof). |
| 345 | func TestJoiningClaimRefusesQuietly(t *testing.T) { |
| 346 | f := newEvFixture(t) |
| 347 | f.setClaim(t, func(c *hostclaim.Claim) { |
| 348 | c.Holder, c.Touched = nil, hostclaim.Touched{} |
| 349 | c.Op = hostclaim.Op{Kind: hostclaim.KindParking, ID: "op", Intent: map[string]string{"park_sweep": "v1"}} |
| 350 | c.Obligations = []hostclaim.Obligation{{ID: "chore", Op: "op", Kind: "park-replica", Region: "ie", Owner: "region:ie", Retirement: "ret", |
| 351 | Park: &hostclaim.ParkScope{Location: f.location, Incarnation: f.inc, EvidenceAt: evAt, RequestLimit: hostbox.RequestLimit}}} |
| 352 | }) |
| 353 | f.lane(evMoveID, "move.json") |
| 354 | var pages pagesSeen |
| 355 | l := &refusalLog{now: evNow} |
| 356 | r := newMovesRefusals("ie-box", pages.hook(), l.logf, l.clock) |
| 357 | for name, evidence := range map[string]hostbox.RegionalEvidence{"the provider": f.provider(), "the quiet wrapper": r.evidence(f.provider())} { |
| 358 | t.Run(name, func(t *testing.T) { |
| 359 | inc, at, err := f.retirement(t, evidence) |
| 360 | var dep *ClaimDependencyError |
| 361 | if errors.As(err, &dep) { |
| 362 | t.Fatalf("a dependency, not a refusal: %v", err) |
| 363 | } |
| 364 | wantRefusal(t, err, false) |
| 365 | if inc != "" || !at.IsZero() { |
| 366 | t.Fatalf("evidence %q, %v with a refusal", inc, at) |
| 367 | } |
| 368 | }) |
| 369 | } |
| 370 | if f.reads != 0 { |
| 371 | t.Fatalf("read %d records with no holder to name one", f.reads) |
| 372 | } |
| 373 | _, _, err := f.retirement(t, f.provider()) |
| 374 | r.report(err) |
| 375 | r.report(err) |
| 376 | if got := l.take(); len(got) != 1 { |
| 377 | t.Fatalf("logged %q; want one line within the hour", got) |
| 378 | } |
| 379 | if len(pages) != 0 { |
| 380 | t.Fatalf("a joining claim paged: %+v", pages) |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | // bare puts the holder at the activator's own lease location, |
| 385 | // instances/<host>: the only holder a move cuts (ruling T5-lease, |
| 386 | // movescut.go writerLease), so every moved host's holder. |
| 387 | func (f *evFixture) bare(t *testing.T) { |
| 388 | t.Helper() |
| 389 | f.location = "instances/" + evHost |
| 390 | f.setClaim(t, func(c *hostclaim.Claim) { c.Holder.Location = f.location }) |
| 391 | } |
| 392 | |
| 393 | // TestMovedHostAtABareLocationIsSwept (controller finding, Plan 12 review): |
| 394 | // a moved host's holder is at the bare location, and its objects there are |
| 395 | // its live location's, not residue outside it. The provider answers, and |
| 396 | // the worker queues the moves/<host> lane chore. |
| 397 | func TestMovedHostAtABareLocationIsSwept(t *testing.T) { |
| 398 | f := newEvFixture(t) |
| 399 | f.bare(t) |
| 400 | f.lane(evMoveID, "move.json") |
| 401 | f.lane(evMoveID, "replica/0000/db") |
| 402 | f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| 403 | obs, census := f.observe(t), f.census(t) |
| 404 | inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| 405 | if err != nil || inc != f.inc || !at.Equal(evAt) { |
| 406 | t.Fatalf("VerifyRetirement = %q, %v, %v; want %q, %v", inc, at, err, f.inc, evAt) |
| 407 | } |
| 408 | proof, err := f.provider().VerifyMovesSweep(t.Context(), obs, census) |
| 409 | if err != nil || proof.Incarnation != f.inc || !proof.RetiredAt.Equal(evAt) { |
| 410 | t.Fatalf("proof = %+v, %v", proof, err) |
| 411 | } |
| 412 | if err := f.worker(t, evNow).Run(t.Context()); !errors.Is(err, park.ErrPending) { |
| 413 | t.Fatalf("the lane chore waits for its horizon: %v", err) |
| 414 | } |
| 415 | moves, ok := f.chores(t)["moves/"+evHost] |
| 416 | if !ok || moves.Incarnation != f.inc || !moves.EvidenceAt.Equal(evNow) { |
| 417 | t.Fatalf("chores = %+v; want the moves/<host> chore at the listing's end", f.chores(t)) |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | // TestVerifyRetirementVouchesOnlyForWhatTheMoveRetired (fix round 1, |
| 422 | // ruling 1): the close is evidence only for what the move retired. At the |
| 423 | // live location that is a generation superseded at or before the move's |
| 424 | // own admission, every version of it older than the close. A generation |
| 425 | // admitted from the move's admission on was written past the close, so the |
| 426 | // provider has no evidence for it and says so with a typed error; the |
| 427 | // moves lane proof is unaffected. |
| 428 | func TestVerifyRetirementVouchesOnlyForWhatTheMoveRetired(t *testing.T) { |
| 429 | answers := func(t *testing.T, f *evFixture) { |
| 430 | t.Helper() |
| 431 | inc, at, err := f.provider().VerifyRetirement(t.Context(), f.observe(t), f.census(t)) |
| 432 | if err != nil || inc != f.inc || !at.Equal(evAt) { |
| 433 | t.Fatalf("VerifyRetirement = %q, %v, %v; want %q, %v", inc, at, err, f.inc, evAt) |
| 434 | } |
| 435 | } |
| 436 | notEvidence := func(t *testing.T, f *evFixture, gen string) { |
| 437 | t.Helper() |
| 438 | obs, census := f.observe(t), f.census(t) |
| 439 | inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| 440 | var ne *NotMovesEvidence |
| 441 | if !errors.As(err, &ne) || inc != "" || !at.IsZero() { |
| 442 | t.Fatalf("VerifyRetirement = %q, %v, %v; want a *NotMovesEvidence and no evidence", inc, at, err) |
| 443 | } |
| 444 | if ne.Host != evHost || ne.Generation != gen || ne.Reason == "" { |
| 445 | t.Fatalf("refusal = %+v, want generation %q", ne, gen) |
| 446 | } |
| 447 | if _, err := f.provider().VerifyMovesSweep(t.Context(), obs, census); err != nil { |
| 448 | t.Fatalf("the lane proof does not depend on the generations: %v", err) |
| 449 | } |
| 450 | } |
| 451 | t.Run("the current generation and the marker", func(t *testing.T) { |
| 452 | f := newEvFixture(t) |
| 453 | f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| 454 | f.bucket.Put("data", f.location+"/generation.json", []byte("marker")) |
| 455 | answers(t, f) |
| 456 | }) |
| 457 | t.Run("a generation the move's admission superseded", func(t *testing.T) { |
| 458 | f := newEvFixture(t) |
| 459 | f.bucket.Put("data", f.genKey("old"), []byte("old")) |
| 460 | f.bucket.Put("data", f.location+"/files/g-old/a.txt", []byte("old")) |
| 461 | answers(t, f) |
| 462 | }) |
| 463 | t.Run("the move's generation superseded after the close", func(t *testing.T) { |
| 464 | f := newEvFixture(t) |
| 465 | f.setClaim(t, func(c *hostclaim.Claim) { |
| 466 | c.Holder.Admissions, c.Holder.Generation, c.Holder.Session, c.Holder.Stamp = evAdmissions(evMoveID, "later"), "later", 3, 3 |
| 467 | }) |
| 468 | f.bucket.Put("data", f.genKey(evMoveID), []byte("superseded after the close")) |
| 469 | f.bucket.Put("data", f.genKey("later"), []byte("live")) |
| 470 | notEvidence(t, f, evMoveID) |
| 471 | }) |
| 472 | t.Run("a later generation superseded after the close", func(t *testing.T) { |
| 473 | f := newEvFixture(t) |
| 474 | f.setClaim(t, func(c *hostclaim.Claim) { |
| 475 | c.Holder.Admissions, c.Holder.Generation, c.Holder.Session, c.Holder.Stamp = evAdmissions(evMoveID, "later", "latest"), "latest", 4, 4 |
| 476 | }) |
| 477 | f.bucket.Put("data", f.genKey("later"), []byte("superseded after the close")) |
| 478 | notEvidence(t, f, "later") |
| 479 | }) |
| 480 | t.Run("no admission of the move in the claim", func(t *testing.T) { |
| 481 | f := newEvFixture(t) |
| 482 | f.setClaim(t, func(c *hostclaim.Claim) { |
| 483 | c.Holder.Admissions, c.Holder.Generation = evAdmissions("fresh"), "fresh" |
| 484 | }) |
| 485 | f.bucket.Put("data", f.genKey("old"), []byte("old")) |
| 486 | notEvidence(t, f, "old") |
| 487 | }) |
| 488 | t.Run("a retired generation written after the close", func(t *testing.T) { |
| 489 | f := newEvFixture(t) |
| 490 | f.rec.LastMove.At = time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC) |
| 491 | f.bucket.Put("data", f.genKey("old"), []byte("late")) |
| 492 | notEvidence(t, f, "old") |
| 493 | }) |
| 494 | t.Run("an object no generation owns", func(t *testing.T) { |
| 495 | f := newEvFixture(t) |
| 496 | f.bucket.Put("data", f.location+"/stray.bin", []byte("?")) |
| 497 | notEvidence(t, f, "") |
| 498 | }) |
| 499 | } |
| 500 | |
| 501 | // TestMovesSweepProofRefusesALiveMove (K10, M8): no proof while a move op |
| 502 | // is on the claim, while the record carries a live move, or while the |
| 503 | // claim was released by a move the record has not closed. |
| 504 | func TestMovesSweepProofRefusesALiveMove(t *testing.T) { |
| 505 | t.Run("the record carries a live move", func(t *testing.T) { |
| 506 | f := newEvFixture(t) |
| 507 | f.rec.Move = &provision.MoveState{ID: "mv_next", Incarnation: f.inc, To: "uk", State: provision.MovePreparing} |
| 508 | f.lane("mv_next", "move.json") |
| 509 | bothRefuse(t, f, false) |
| 510 | }) |
| 511 | for _, op := range []hostclaim.Op{ |
| 512 | {Kind: hostclaim.KindMoving, ID: "mv_next", Phase: hostclaim.PhaseMoving, Exec: "box"}, |
| 513 | {Kind: hostclaim.KindLanding, ID: evMoveID, Phase: hostclaim.PhaseCommitted, Exec: "box"}, |
| 514 | {Kind: hostclaim.KindRestoring, ID: evMoveID, Phase: hostclaim.PhaseOpened, Exec: "box"}, |
| 515 | {Kind: hostclaim.KindReverting, ID: evMoveID, Phase: hostclaim.PhaseOpened, Exec: "box"}, |
| 516 | } { |
| 517 | // The last three are the record-first close's window (M4): the |
| 518 | // record closed the move, the claim has not released it yet. |
| 519 | t.Run("the claim holds "+op.Kind, func(t *testing.T) { |
| 520 | f := newEvFixture(t) |
| 521 | f.setClaim(t, func(c *hostclaim.Claim) { c.Op = op }) |
| 522 | bothRefuse(t, f, false) |
| 523 | }) |
| 524 | } |
| 525 | t.Run("the record names no closed move", func(t *testing.T) { |
| 526 | f := newEvFixture(t) |
| 527 | f.rec.LastMove = nil |
| 528 | bothRefuse(t, f, false) |
| 529 | }) |
| 530 | } |
| 531 | |
| 532 | // TestMovesProofSurvivesAMetadataPlacement (fix round 1, ruling 2): the |
| 533 | // durable proof is the record's LastMove, bound to the holder's |
| 534 | // incarnation, with no move op on the claim. A later operation that |
| 535 | // replaces the serving op (here CompleteMetadataPlacement, which drops the |
| 536 | // released_move journal) leaves cleanup running, and so does a serving op |
| 537 | // released by a move whose record never carried it. |
| 538 | func TestMovesProofSurvivesAMetadataPlacement(t *testing.T) { |
| 539 | t.Run("a metadata placement", func(t *testing.T) { |
| 540 | f := newEvFixture(t) |
| 541 | f.lane(evMoveID, "move.json") |
| 542 | f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| 543 | f.setClaim(t, func(c *hostclaim.Claim) { |
| 544 | if err := hostops.BeginPlacement("pl_1", "op@example", "meta", evNow, []string{"ie"}, []string{"box"})(c); err != nil { |
| 545 | t.Fatal(err) |
| 546 | } |
| 547 | c.Op.Intent["app_id"] = "app_1" |
| 548 | if err := hostops.CompleteMetadataPlacement("pl_1", "app_1")(c); err != nil { |
| 549 | t.Fatal(err) |
| 550 | } |
| 551 | }) |
| 552 | obs := f.observe(t) |
| 553 | if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Journal["released_move"] != "" { |
| 554 | t.Fatalf("the placement left op %+v", obs.Claim.Op) |
| 555 | } |
| 556 | census := f.census(t) |
| 557 | proof, err := f.provider().VerifyMovesSweep(t.Context(), obs, census) |
| 558 | if err != nil || proof.MoveID != evMoveID || !proof.RetiredAt.Equal(evAt) { |
| 559 | t.Fatalf("proof = %+v, %v", proof, err) |
| 560 | } |
| 561 | if inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census); err != nil || inc != f.inc || !at.Equal(evAt) { |
| 562 | t.Fatalf("VerifyRetirement = %q, %v, %v", inc, at, err) |
| 563 | } |
| 564 | if err := f.worker(t, evNow).Run(t.Context()); !errors.Is(err, park.ErrPending) { |
| 565 | t.Fatalf("the lane chore waits for its horizon: %v", err) |
| 566 | } |
| 567 | if _, ok := f.chores(t)["moves/"+evHost]; !ok { |
| 568 | t.Fatalf("chores = %+v; want the moves/<host> chore", f.chores(t)) |
| 569 | } |
| 570 | }) |
| 571 | t.Run("a release by a move the record never carried", func(t *testing.T) { |
| 572 | f := newEvFixture(t) |
| 573 | f.setClaim(t, func(c *hostclaim.Claim) { c.Op = servingReleasedBy("mv_orphan") }) |
| 574 | if _, err := f.provider().VerifyMovesSweep(t.Context(), f.observe(t), f.census(t)); err != nil { |
| 575 | t.Fatalf("no lane of the orphan, so nothing unproven: %v", err) |
| 576 | } |
| 577 | f.bucket.Put("data", "moves/"+evHost+"/mv_orphan/move.json", []byte("orphan")) |
| 578 | f.rec.LastMove.At = time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC) |
| 579 | bothRefuse(t, f, true) |
| 580 | }) |
| 581 | } |
| 582 | |
| 583 | // TestMovesEvidenceRefusesAZeroTime (M7, M8): a zero time is never |
| 584 | // evidence: not the close's, and not a lane object's. |
| 585 | func TestMovesEvidenceRefusesAZeroTime(t *testing.T) { |
| 586 | t.Run("a zero close", func(t *testing.T) { |
| 587 | f := newEvFixture(t) |
| 588 | f.rec.LastMove.At = time.Time{} |
| 589 | bothRefuse(t, f, true) |
| 590 | }) |
| 591 | t.Run("a close not before the provider's clock", func(t *testing.T) { |
| 592 | f := newEvFixture(t) |
| 593 | f.rec.LastMove.At = evNow |
| 594 | bothRefuse(t, f, true) |
| 595 | }) |
| 596 | t.Run("an older lane object with no time", func(t *testing.T) { |
| 597 | f := newEvFixture(t) |
| 598 | obs := f.observe(t) |
| 599 | census := []lease.ObjectVersion{{Key: "moves/" + evHost + "/mv_older/move.json", VersionID: "v1", IsLatest: true}} |
| 600 | if _, err := f.provider().VerifyMovesSweep(t.Context(), obs, census); err == nil { |
| 601 | t.Fatal("a zero LastModified counted as before the close") |
| 602 | } else { |
| 603 | wantRefusal(t, err, true) |
| 604 | } |
| 605 | if _, _, err := f.provider().VerifyRetirement(t.Context(), obs, census); err == nil { |
| 606 | t.Fatal("VerifyRetirement answered over a lane with no time") |
| 607 | } |
| 608 | }) |
| 609 | } |
| 610 | |
| 611 | // TestMovesSweepProofRefusesAnUnknownLane (M8): every moves/<host>/<op>/ |
| 612 | // object is the closed move's own, or older than its close. An object of |
| 613 | // another op written after the close has an unknown writer. |
| 614 | func TestMovesSweepProofRefusesAnUnknownLane(t *testing.T) { |
| 615 | t.Run("an older lane is covered by the close", func(t *testing.T) { |
| 616 | f := newEvFixture(t) |
| 617 | f.lane("mv_older", "move.json") |
| 618 | f.lane("rb_return", "replica/0000/db") |
| 619 | if _, err := f.provider().VerifyMovesSweep(t.Context(), f.observe(t), f.census(t)); err != nil { |
| 620 | t.Fatal(err) |
| 621 | } |
| 622 | }) |
| 623 | t.Run("a lane written after the close", func(t *testing.T) { |
| 624 | f := newEvFixture(t) |
| 625 | f.rec.LastMove.At = time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC) // before every write |
| 626 | f.lane(evMoveID, "move.json") |
| 627 | if _, err := f.provider().VerifyMovesSweep(t.Context(), f.observe(t), f.census(t)); err != nil { |
| 628 | t.Fatalf("the closed move's own lane is its own: %v", err) |
| 629 | } |
| 630 | f.lane("mv_unknown", "move.json") |
| 631 | bothRefuse(t, f, true) |
| 632 | }) |
| 633 | t.Run("an object outside any lane", func(t *testing.T) { |
| 634 | f := newEvFixture(t) |
| 635 | f.bucket.Put("data", "moves/"+evHost+"/stray.json", []byte("?")) |
| 636 | bothRefuse(t, f, true) |
| 637 | }) |
| 638 | t.Run("an object outside the host's namespaces", func(t *testing.T) { |
| 639 | f := newEvFixture(t) |
| 640 | census := []lease.ObjectVersion{{Key: "moves/other.example/" + evMoveID + "/move.json", VersionID: "v1", LastModified: evAt.Add(-time.Hour)}} |
| 641 | if _, err := f.provider().VerifyMovesSweep(t.Context(), f.observe(t), census); err == nil { |
| 642 | t.Fatal("another host's object counted") |
| 643 | } |
| 644 | }) |
| 645 | } |
| 646 | |
| 647 | // TestMovesEvidenceRefusesAnotherIdentity: the record must be the holder's |
| 648 | // exact record, and a holderless claim has no record to prove from. |
| 649 | func TestMovesEvidenceRefusesAnotherIdentity(t *testing.T) { |
| 650 | t.Run("a record of another incarnation", func(t *testing.T) { |
| 651 | f := newEvFixture(t) |
| 652 | f.rec.CreatedAt = evCreated.Add(time.Hour) |
| 653 | bothRefuse(t, f, true) |
| 654 | }) |
| 655 | t.Run("a stale observation", func(t *testing.T) { |
| 656 | f := newEvFixture(t) |
| 657 | obs := f.observe(t) |
| 658 | obs.ETag = "" |
| 659 | if _, err := f.provider().VerifyMovesSweep(t.Context(), obs, f.census(t)); err == nil { |
| 660 | t.Fatal("answered without an etag") |
| 661 | } |
| 662 | }) |
| 663 | t.Run("a free claim", func(t *testing.T) { |
| 664 | f := newEvFixture(t) |
| 665 | f.setClaim(t, func(c *hostclaim.Claim) { c.Holder, c.Op = nil, hostclaim.Op{} }) |
| 666 | obs, census := f.observe(t), f.census(t) |
| 667 | _, err := f.provider().VerifyMovesSweep(t.Context(), obs, census) |
| 668 | wantRefusal(t, err, false) |
| 669 | if _, _, err := f.provider().VerifyRetirement(t.Context(), obs, census); err == nil { |
| 670 | t.Fatal("VerifyRetirement answered with no holder") |
| 671 | } else { |
| 672 | wantRefusal(t, err, false) |
| 673 | } |
| 674 | if f.reads != 0 { |
| 675 | t.Fatalf("read %d records with no holder to name one", f.reads) |
| 676 | } |
| 677 | }) |
| 678 | t.Run("no providers", func(t *testing.T) { |
| 679 | f := newEvFixture(t) |
| 680 | if _, err := (movesRetirementEvidence{}).VerifyMovesSweep(t.Context(), f.observe(t), nil); err == nil { |
| 681 | t.Fatal("answered with no record reader") |
| 682 | } |
| 683 | if _, _, err := (movesRetirementEvidence{Records: f.provider().Records}).VerifyRetirement(t.Context(), f.observe(t), nil); err == nil { |
| 684 | t.Fatal("answered with no clock") |
| 685 | } |
| 686 | }) |
| 687 | } |
| 688 | |
| 689 | // The provider satisfies both slots RegisterProductionClaimMoveParks reads: |
| 690 | // the regional evidence and, by type assertion, the sweep verifier. |
| 691 | var ( |
| 692 | _ hostbox.RegionalEvidence = movesRetirementEvidence{} |
| 693 | _ park.MoveSweepVerifier = movesRetirementEvidence{} |
| 694 | ) |
| 695 | |
| 696 | // --- against the claim's regional worker, with a fake store --- |
| 697 | |
| 698 | type evLease struct{} |
| 699 | |
| 700 | func (evLease) Check(context.Context) error { return nil } |
| 701 | func (evLease) Release(context.Context) error { return nil } |
| 702 | func (evLease) Close() {} |
| 703 | |
| 704 | type evLeases struct{} |
| 705 | |
| 706 | func (evLeases) Acquire(context.Context, string, string) (park.HeldLease, error) { |
| 707 | return evLease{}, nil |
| 708 | } |
| 709 | |
| 710 | type evEnrollment struct{} |
| 711 | |
| 712 | func (evEnrollment) VerifyRegional(context.Context, string, string, time.Duration) error { return nil } |
| 713 | |
| 714 | // worker is hostbox.RegionalWorker over park.Engine, wired as the claim's |
| 715 | // assembly wires them (host/claimassembly.go): the provider is the worker's |
| 716 | // Evidence and the engine's VerifyMovesSweep. This region (ie) is not the |
| 717 | // authoritative one, so the holder's location here is residue too. |
| 718 | func (f *evFixture) worker(t *testing.T, listing time.Time) hostbox.RegionalWorker { |
| 719 | t.Helper() |
| 720 | seq := 0 |
| 721 | p := f.provider() |
| 722 | location := f.location |
| 723 | engine := park.Engine{ |
| 724 | Store: f.bucket, Claims: f.claims, Bucket: "data", Region: "ie", Leases: evLeases{}, |
| 725 | Now: func() time.Time { return listing }, |
| 726 | NewID: func() string { seq++; return fmt.Sprintf("id%d", seq) }, |
| 727 | View: func(_ context.Context, o hostclaim.Observed) (park.View, error) { |
| 728 | return park.View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "uk", |
| 729 | GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}, nil |
| 730 | }, |
| 731 | Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }, |
| 732 | VerifyMovesSweep: p.VerifyMovesSweep, |
| 733 | } |
| 734 | return hostbox.RegionalWorker{ |
| 735 | Regional: hostbox.Regional{Engine: engine, Owner: func(context.Context, hostclaim.Observed) error { return nil }}, |
| 736 | Enrollment: evEnrollment{}, |
| 737 | Evidence: p, |
| 738 | } |
| 739 | } |
| 740 | |
| 741 | func (f *evFixture) chores(t *testing.T) map[string]hostclaim.ParkScope { |
| 742 | t.Helper() |
| 743 | out := map[string]hostclaim.ParkScope{} |
| 744 | for _, o := range f.observe(t).Claim.Obligations { |
| 745 | if o.Park == nil { |
| 746 | t.Fatalf("an unscoped chore: %+v", o) |
| 747 | } |
| 748 | out[o.Park.Location] = *o.Park |
| 749 | } |
| 750 | return out |
| 751 | } |
| 752 | |
| 753 | // TestAllowedSweepQueuesTheMovesChore: with the proof, the worker queues the |
| 754 | // moves/<host> chore under the serving session, labelled with the holder's |
| 755 | // incarnation, its evidence the listing's end, so it waits for its horizon. |
| 756 | // The holder location's residue in this non-authoritative region takes the |
| 757 | // close as its evidence: that horizon has passed, so the same pass parks it. |
| 758 | func TestAllowedSweepQueuesTheMovesChore(t *testing.T) { |
| 759 | f := newEvFixture(t) |
| 760 | f.lane(evMoveID, "move.json") |
| 761 | f.lane("mv_older", "replica/0000/db") |
| 762 | left := f.genKey("old") // superseded by the move's own admission |
| 763 | f.bucket.Put("data", left, []byte("left behind")) |
| 764 | err := f.worker(t, evNow).Run(t.Context()) |
| 765 | if !errors.Is(err, park.ErrPending) { |
| 766 | t.Fatalf("a new sweep waits for its horizon: %v", err) |
| 767 | } |
| 768 | chores := f.chores(t) |
| 769 | moves, ok := chores["moves/"+evHost] |
| 770 | if !ok || len(chores) != 1 { |
| 771 | t.Fatalf("chores = %+v; want the moves chore alone", chores) |
| 772 | } |
| 773 | if moves.Incarnation != f.inc || !moves.EvidenceAt.Equal(evNow) || moves.RequestLimit != hostbox.RequestLimit { |
| 774 | t.Fatalf("moves chore = %+v; want incarnation %s, evidence %v", moves, f.inc, evNow) |
| 775 | } |
| 776 | if vs, err := f.bucket.ListVersions(t.Context(), "data", left); err != nil || len(vs) != 0 { |
| 777 | t.Fatalf("the location's residue was not parked: %v, %v", vs, err) |
| 778 | } |
| 779 | if vs, err := f.bucket.ListVersions(t.Context(), "data", "parked/"+f.inc+"/"+evHost+"/"); err != nil || len(vs) == 0 { |
| 780 | t.Fatalf("no archive under the holder's incarnation: %v, %v", vs, err) |
| 781 | } |
| 782 | for _, o := range f.observe(t).Claim.Obligations { |
| 783 | if o.Op != "session:2" || o.Owner != "region:ie" { |
| 784 | t.Fatalf("chore %+v is not the serving session's regional chore", o) |
| 785 | } |
| 786 | } |
| 787 | if c := f.observe(t).Claim; c.Op.Kind != hostclaim.KindServing || c.Op.Journal["released_move"] != evMoveID { |
| 788 | t.Fatalf("the sweep changed the serving op: %+v", c.Op) |
| 789 | } |
| 790 | } |
| 791 | |
| 792 | // TestSweepSkipsWhileAMoveStages (Review Focus 3, K10): while a move op is |
| 793 | // on the claim the worker never asks for evidence; while the record carries |
| 794 | // a live move under a serving claim the provider refuses and nothing is |
| 795 | // queued. |
| 796 | func TestSweepSkipsWhileAMoveStages(t *testing.T) { |
| 797 | t.Run("a moving claim", func(t *testing.T) { |
| 798 | f := newEvFixture(t) |
| 799 | f.setClaim(t, func(c *hostclaim.Claim) { |
| 800 | c.Op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: "mv_next", Phase: hostclaim.PhaseMoving, Exec: "box"} |
| 801 | }) |
| 802 | f.rec.Move = &provision.MoveState{ID: "mv_next", Incarnation: f.inc, To: "uk", State: provision.MovePreparing} |
| 803 | f.lane("mv_next", "replica/0000/db") |
| 804 | if err := f.worker(t, evNow).Run(t.Context()); err != nil { |
| 805 | t.Fatal(err) |
| 806 | } |
| 807 | if len(f.chores(t)) != 0 || f.reads != 0 { |
| 808 | t.Fatalf("chores %+v, record reads %d", f.chores(t), f.reads) |
| 809 | } |
| 810 | }) |
| 811 | t.Run("a live move on the record", func(t *testing.T) { |
| 812 | f := newEvFixture(t) |
| 813 | f.rec.Move = &provision.MoveState{ID: "mv_next", Incarnation: f.inc, To: "uk", State: provision.MovePreparing} |
| 814 | f.lane("mv_next", "replica/0000/db") |
| 815 | err := f.worker(t, evNow).Run(t.Context()) |
| 816 | var r *MovesSweepRefusal |
| 817 | if !errors.As(err, &r) { |
| 818 | t.Fatalf("err = %v, want the provider's refusal", err) |
| 819 | } |
| 820 | if len(f.chores(t)) != 0 { |
| 821 | t.Fatalf("chores %+v", f.chores(t)) |
| 822 | } |
| 823 | }) |
| 824 | } |
| 825 | |
| 826 | // TestSweepRefusedUnlessTheCloseIsBeforeTheListing (K10): the worker's own |
| 827 | // Check drops the moves/<host> candidate when the newest retired move's |
| 828 | // close is not earlier than the listing; the rest of the decision stands. |
| 829 | func TestSweepRefusedUnlessTheCloseIsBeforeTheListing(t *testing.T) { |
| 830 | f := newEvFixture(t) |
| 831 | f.lane(evMoveID, "move.json") |
| 832 | f.bucket.Put("data", f.genKey("old"), []byte("left behind")) |
| 833 | err := f.worker(t, evAt).Run(t.Context()) |
| 834 | if !errors.Is(err, park.ErrPending) { |
| 835 | t.Fatalf("the location chore still queues: %v", err) |
| 836 | } |
| 837 | chores := f.chores(t) |
| 838 | if _, ok := chores["moves/"+evHost]; ok || len(chores) != 1 { |
| 839 | t.Fatalf("chores = %+v; want only the location's", chores) |
| 840 | } |
| 841 | if loc := chores[f.location]; loc.Incarnation != f.inc || !loc.EvidenceAt.Equal(evAt) { |
| 842 | t.Fatalf("location chore = %+v; want the close %v as its evidence", loc, evAt) |
| 843 | } |
| 844 | } |
| 845 | |
| 846 | // TestSweepScopeMembership (witness wSweepScopeMembership, M7): residue of |
| 847 | // an older incarnation is never the moves/<host> chore's. The provider no |
| 848 | // longer refuses the sweep for it (Q4): the engine queues it as its own |
| 849 | // chore under its own qualified identity and parks it, and the moves/<host> |
| 850 | // chore keeps the holder's. |
| 851 | func TestSweepScopeMembership(t *testing.T) { |
| 852 | f := newEvFixture(t) |
| 853 | older := strings.Repeat("cd", 32) |
| 854 | f.lane(evMoveID, "move.json") |
| 855 | residue := "instances/" + evHost + "/" + older + "/g-old/0000/db" |
| 856 | f.bucket.Put("data", residue, []byte("late")) |
| 857 | if err := f.worker(t, evNow).Run(t.Context()); !errors.Is(err, park.ErrPending) { |
| 858 | t.Fatalf("the chores wait for their horizons: %v", err) |
| 859 | } |
| 860 | chores := f.chores(t) |
| 861 | moves, ok := chores["moves/"+evHost] |
| 862 | if !ok || moves.Incarnation != f.inc { |
| 863 | t.Fatalf("chores = %+v; want the moves/<host> chore under the holder's incarnation", chores) |
| 864 | } |
| 865 | if _, ok := chores["instances/"+evHost+"/"+older]; ok || len(chores) != 1 { |
| 866 | t.Fatalf("chores = %+v; want only the moves/<host> chore pending", chores) |
| 867 | } |
| 868 | // The older incarnation's chore ran first, under its own identity, and |
| 869 | // parked its residue in the same pass. |
| 870 | if _, ok := f.bucket.Current("data", residue); ok { |
| 871 | t.Fatal("the older incarnation's residue was not parked") |
| 872 | } |
| 873 | } |
| 874 | |
| 875 | // TestFreeHostLanesAreTheClaimsProof (fix round 1, ruling 3; Q4): a free |
| 876 | // claim names no record, so moves can prove no closed move and the claim |
| 877 | // stays free. The free-host lane proof is the claim's own |
| 878 | // (claimFreeMoveProof, asked before the provider); a free claim that |
| 879 | // reaches the provider gets the quiet sweep refusal, not a dependency. |
| 880 | func TestFreeHostLanesAreTheClaimsProof(t *testing.T) { |
| 881 | f := newEvFixture(t) |
| 882 | f.setClaim(t, func(c *hostclaim.Claim) { c.Holder, c.Op, c.Touched = nil, hostclaim.Op{}, hostclaim.Touched{} }) |
| 883 | f.lane(evMoveID, "move.json") |
| 884 | err := f.worker(t, evNow).Run(t.Context()) |
| 885 | wantRefusal(t, err, false) |
| 886 | if c := f.observe(t).Claim; !c.Free() || len(c.Obligations) != 0 { |
| 887 | t.Fatalf("claim = %+v", c) |
| 888 | } |
| 889 | } |
| 890 | |
| 891 | // authoritative makes ie the authoritative region, with the marker at the |
| 892 | // holder's current generation and objects classified by the landing's |
| 893 | // layout, as Task 9a's view will. |
| 894 | func (f *evFixture) authoritative(t *testing.T, w *hostbox.RegionalWorker, current string) { |
| 895 | t.Helper() |
| 896 | marker := f.location + "/generation.json" |
| 897 | ref := park.Ref{Key: marker, VersionID: f.bucket.LatestID("data", marker)} |
| 898 | location := f.location |
| 899 | w.Regional.Engine.View = func(_ context.Context, o hostclaim.Observed) (park.View, error) { |
| 900 | return park.View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "ie", MarkerKnown: true, MarkerGeneration: current, Marker: ref, |
| 901 | GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}, nil |
| 902 | } |
| 903 | } |
| 904 | |
| 905 | // TestAuthoritativeSweepStampsTheCloseOnlyOnWhatTheMoveRetired (fix round 1, |
| 906 | // ruling 1): in the authoritative region the engine makes superseded |
| 907 | // generations park-generation candidates and binds them to |
| 908 | // VerifyRetirement's time. A generation the move's admission superseded |
| 909 | // gets the close; when the move's own generation was superseded after the |
| 910 | // close, the provider refuses and nothing is queued. |
| 911 | func TestAuthoritativeSweepStampsTheCloseOnlyOnWhatTheMoveRetired(t *testing.T) { |
| 912 | listing := evAt.Add(time.Minute) // before the close's horizon, so chores stay queued |
| 913 | t.Run("superseded by the move", func(t *testing.T) { |
| 914 | f := newEvFixture(t) |
| 915 | f.lane(evMoveID, "move.json") |
| 916 | f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| 917 | f.bucket.Put("data", f.location+"/generation.json", []byte("marker")) |
| 918 | f.bucket.Put("data", f.genKey("old"), []byte("superseded by the move")) |
| 919 | w := f.worker(t, listing) |
| 920 | f.authoritative(t, &w, evMoveID) |
| 921 | if err := w.Run(t.Context()); !errors.Is(err, park.ErrPending) { |
| 922 | t.Fatalf("the chores wait for their horizons: %v", err) |
| 923 | } |
| 924 | var gens []hostclaim.Obligation |
| 925 | for _, o := range f.observe(t).Claim.Obligations { |
| 926 | if o.Kind == "park-generation" { |
| 927 | gens = append(gens, o) |
| 928 | } |
| 929 | } |
| 930 | if len(gens) != 1 || gens[0].Generation != "old" || gens[0].Park.Location != f.location || !gens[0].Park.EvidenceAt.Equal(evAt) { |
| 931 | t.Fatalf("generation chores = %+v; want one for old at the close %v", gens, evAt) |
| 932 | } |
| 933 | if moves, ok := f.chores(t)["moves/"+evHost]; !ok || !moves.EvidenceAt.Equal(listing) { |
| 934 | t.Fatalf("chores = %+v; want the moves chore at the listing", f.chores(t)) |
| 935 | } |
| 936 | }) |
| 937 | t.Run("superseded after the close", func(t *testing.T) { |
| 938 | f := newEvFixture(t) |
| 939 | f.setClaim(t, func(c *hostclaim.Claim) { |
| 940 | c.Holder.Admissions, c.Holder.Generation, c.Holder.Session, c.Holder.Stamp = evAdmissions(evMoveID, "later"), "later", 3, 3 |
| 941 | }) |
| 942 | f.lane(evMoveID, "move.json") |
| 943 | f.bucket.Put("data", f.genKey("later"), []byte("live")) |
| 944 | f.bucket.Put("data", f.location+"/generation.json", []byte("marker")) |
| 945 | f.bucket.Put("data", f.genKey(evMoveID), []byte("written until after the close")) |
| 946 | w := f.worker(t, listing) |
| 947 | f.authoritative(t, &w, "later") |
| 948 | err := w.Run(t.Context()) |
| 949 | var ne *NotMovesEvidence |
| 950 | if !errors.As(err, &ne) || ne.Generation != evMoveID { |
| 951 | t.Fatalf("err = %v, want a *NotMovesEvidence for %s", err, evMoveID) |
| 952 | } |
| 953 | if len(f.observe(t).Claim.Obligations) != 0 { |
| 954 | t.Fatalf("chores %+v", f.observe(t).Claim.Obligations) |
| 955 | } |
| 956 | }) |
| 957 | } |
| 958 | |
| 959 | // TestMovesParkLockMatchesTheEngine (fix round 2): the provider mirrors the |
| 960 | // park engine's unexported lock predicate (park's isLock over locationOf), |
| 961 | // so it skips exactly the lease objects Decide skips. park.Decide is the |
| 962 | // oracle: over a one-key census in a non-authoritative region, where every |
| 963 | // non-lock object is a candidate, it decides nothing exactly for a lock. |
| 964 | func TestMovesParkLockMatchesTheEngine(t *testing.T) { |
| 965 | f := newEvFixture(t) |
| 966 | obs := f.observe(t) |
| 967 | view := park.View{ClaimETag: obs.ETag, Region: "ie", AuthoritativeRegion: "uk", |
| 968 | GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(f.location, r.Key) }} |
| 969 | other := strings.Repeat("ab", 32) |
| 970 | for _, key := range []string{ |
| 971 | "instances/" + evHost + "/lock.json", |
| 972 | "instances/" + evHost + "/" + f.inc + "/lock.json", |
| 973 | "instances/" + evHost + "/" + other + "/lock.json", |
| 974 | "moves/" + evHost + "/lock.json", |
| 975 | "moves/" + evHost + "/" + evMoveID + "/lock.json", |
| 976 | "instances/" + evHost + "/g-old/lock.json", |
| 977 | "instances/" + evHost + "/" + f.inc + "/g-old/lock.json", |
| 978 | "instances/" + evHost + "/lock.json.old", |
| 979 | "instances/" + evHost + "/0000/db", |
| 980 | } { |
| 981 | d, err := park.Decide(obs, view, []lease.ObjectVersion{{Key: key, VersionID: "v1", LastModified: evAt.Add(-time.Hour)}}, f.inc) |
| 982 | if err != nil { |
| 983 | t.Fatalf("%s: %v", key, err) |
| 984 | } |
| 985 | if engine, mirror := len(d.Candidates) == 0, movesParkLock(evHost, key); engine != mirror { |
| 986 | t.Errorf("%s: the engine skips it %v, the provider's mirror says lock %v", key, engine, mirror) |
| 987 | } |
| 988 | } |
| 989 | } |
| 990 | |
| 991 | // TestLiveLocationLeaseIsSweptNormally (fix round 2): the writer's lease, |
| 992 | // <holder.location>/lock.json (lease.Acquire), stands at every live |
| 993 | // location. It is the engine's to skip, never residue the close must |
| 994 | // vouch for, so the sweep proceeds, scoped and bare alike. |
| 995 | func TestLiveLocationLeaseIsSweptNormally(t *testing.T) { |
| 996 | for name, bare := range map[string]bool{"scoped": false, "bare": true} { |
| 997 | t.Run(name, func(t *testing.T) { |
| 998 | f := newEvFixture(t) |
| 999 | if bare { |
| 1000 | f.bare(t) |
| 1001 | } |
| 1002 | f.lane(evMoveID, "move.json") |
| 1003 | f.bucket.Put("data", f.genKey(evMoveID), []byte("live")) |
| 1004 | f.bucket.Put("data", f.location+"/generation.json", []byte("marker")) |
| 1005 | f.bucket.Put("data", f.location+"/lock.json", []byte("writer lease")) |
| 1006 | if _, _, err := f.provider().VerifyRetirement(t.Context(), f.observe(t), f.census(t)); err != nil { |
| 1007 | t.Fatalf("VerifyRetirement refused the writer's lease: %v", err) |
| 1008 | } |
| 1009 | listing := evAt.Add(time.Minute) |
| 1010 | w := f.worker(t, listing) |
| 1011 | f.authoritative(t, &w, evMoveID) |
| 1012 | if err := w.Run(t.Context()); !errors.Is(err, park.ErrPending) { |
| 1013 | t.Fatalf("the lane chore waits for its horizon: %v", err) |
| 1014 | } |
| 1015 | chores := f.chores(t) |
| 1016 | if moves, ok := chores["moves/"+evHost]; !ok || len(chores) != 1 || !moves.EvidenceAt.Equal(listing) { |
| 1017 | t.Fatalf("chores = %+v; want the moves chore alone", chores) |
| 1018 | } |
| 1019 | if vs, err := f.bucket.ListVersions(t.Context(), "data", f.location+"/lock.json"); err != nil || len(vs) != 1 { |
| 1020 | t.Fatalf("the lease was touched: %v, %v", vs, err) |
| 1021 | } |
| 1022 | }) |
| 1023 | } |
| 1024 | } |
| 1025 | |