| 1 | package park |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "reflect" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/hostclaim" |
| 12 | "github.com/carlosframework/platform/internal/lease" |
| 13 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 14 | ) |
| 15 | |
| 16 | func sweepFixture(t *testing.T) (*fixture, hostclaim.Observed, View, []lease.ObjectVersion) { |
| 17 | f := newFixture(t) |
| 18 | obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| 19 | obs, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| 20 | c.Obligations = nil |
| 21 | c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"} |
| 22 | c.Holder.Generation = "g3" |
| 23 | c.Holder.Session = 3 |
| 24 | c.Holder.Admissions = []hostclaim.AdmissionRecord{ |
| 25 | {Session: 2, Generation: "g2", Predecessor: "g1", Mode: "cold", Region: "ie", PredecessorRegion: "ie"}, |
| 26 | {Session: 3, Generation: "g3", Predecessor: "g2", Mode: "cold", Region: "ie", PredecessorRegion: "ie"}, |
| 27 | } |
| 28 | return nil |
| 29 | }) |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | p := f.ob.Park.Location + "/" |
| 34 | for _, g := range []string{"g0", "g1", "g2", "g3"} { |
| 35 | f.b.Put("data", p+g+"/data", []byte(g)) |
| 36 | } |
| 37 | f.b.Put("data", p+"generation.json", []byte("older")) |
| 38 | markerID := f.b.Put("data", p+"generation.json", []byte("g1")) |
| 39 | pinned := f.b.Put("data", p+"pinned/data", []byte("baseline")) |
| 40 | f.b.Put("data", p+"pinned/data", []byte("newer")) |
| 41 | classify := func(r Ref) (string, bool) { |
| 42 | s := strings.TrimPrefix(r.Key, p) |
| 43 | g, _, ok := strings.Cut(s, "/") |
| 44 | if r.Key == p+"generation.json" { |
| 45 | return "@marker", true |
| 46 | } |
| 47 | return g, ok |
| 48 | } |
| 49 | v := View{ClaimETag: obs.ETag, Region: "ie", AuthoritativeRegion: "ie", MarkerKnown: true, MarkerGeneration: "g1", Marker: Ref{p + "generation.json", markerID}, Pinned: map[Ref]bool{{p + "pinned/data", pinned}: true}, GenerationOf: classify} |
| 50 | vs, err := f.e.Discover(t.Context(), f.w.Host) |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | return f, obs, v, vs |
| 55 | } |
| 56 | |
| 57 | func TestSweepMovesNamespaceIsSeparateFromLiveInstance(t *testing.T) { |
| 58 | f := newFixture(t) |
| 59 | allowFixtureMoveSweep(f) |
| 60 | obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| 61 | if err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | _, err = hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| 65 | c.Obligations = nil |
| 66 | c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"} |
| 67 | return nil |
| 68 | }) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | liveKey := f.ob.Park.Location + "/0000/live" |
| 73 | liveID := f.b.Put("data", liveKey, []byte("live")) |
| 74 | for _, op := range []string{"move-1", f.ob.Park.Incarnation} { |
| 75 | f.b.Put("data", "moves/"+f.w.Host+"/"+op+"/staging", []byte(op)) |
| 76 | } |
| 77 | f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) { |
| 78 | return View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "ie", MarkerKnown: true, MarkerGeneration: hostclaim.RootGeneration, GenerationOf: func(Ref) (string, bool) { return hostclaim.RootGeneration, true }}, nil |
| 79 | } |
| 80 | d, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, f.ob.Park.RequestLimit) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | want := []Candidate{{Location: "moves/" + f.w.Host, Incarnation: f.ob.Park.Incarnation}} |
| 85 | if !reflect.DeepEqual(d.Candidates, want) { |
| 86 | t.Fatalf("staging candidates=%+v, want %+v", d.Candidates, want) |
| 87 | } |
| 88 | vs, err := f.b.ListVersions(t.Context(), "data", "moves/"+f.w.Host+"/") |
| 89 | if err != nil || len(vs) != 0 { |
| 90 | t.Fatalf("staging residue: %+v %v", vs, err) |
| 91 | } |
| 92 | body, err := f.b.GetVersion(t.Context(), "data", liveKey, liveID) |
| 93 | if err != nil || string(body) != "live" { |
| 94 | t.Fatalf("live instance changed: %q %v", body, err) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestSweepJoinsRunningHolderlessOperation(t *testing.T) { |
| 99 | f := newFixture(t) |
| 100 | f.seed() |
| 101 | obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| 102 | if err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | _, err = hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| 106 | c.Obligations = nil |
| 107 | return hostclaim.FreeAfterPark("op")(c) |
| 108 | }) |
| 109 | if err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | f.held.failed = true |
| 113 | if _, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, f.ob.Park.RequestLimit); !errors.Is(err, ErrChanged) { |
| 114 | t.Fatalf("first region queue: %v", err) |
| 115 | } |
| 116 | obs, err = hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| 117 | if err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | first := obs.Claim.Obligations[0] |
| 121 | second := f.e |
| 122 | second.Region = "uk" |
| 123 | b := leasetest.NewVersionedBucket() |
| 124 | b.AddBucket("data") |
| 125 | b.Put("data", f.ob.Park.Location+"/0000/data", []byte("second region")) |
| 126 | second.Store = b |
| 127 | second.View = func(_ context.Context, o hostclaim.Observed) (View, error) { |
| 128 | return View{ClaimETag: o.ETag, Region: "uk"}, nil |
| 129 | } |
| 130 | second.Authorize = func(_ context.Context, _ hostclaim.Observed, o hostclaim.Obligation) error { |
| 131 | if o.Op != first.Op || o.Region != "uk" { |
| 132 | return ErrUnsafe |
| 133 | } |
| 134 | return nil |
| 135 | } |
| 136 | if _, err := second.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, f.ob.Park.RequestLimit); !errors.Is(err, ErrChanged) { |
| 137 | t.Fatalf("second region did not join the running sweep: %v", err) |
| 138 | } |
| 139 | obs, err = hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| 140 | if err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | if obs.Claim.Holder != nil || obs.Claim.Op.ID != first.Op || len(obs.Claim.Obligations) != 2 || !reflect.DeepEqual(obs.Claim.Obligations[0], first) { |
| 144 | t.Fatalf("joined sweep: %+v", obs.Claim) |
| 145 | } |
| 146 | joined := obs.Claim.Obligations[1] |
| 147 | f.held.failed = false |
| 148 | if err := f.e.Run(t.Context(), Work{f.w.Host, first.ID, first.Op}); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | obs, err = hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| 152 | if err != nil || obs.Claim.Free() || len(obs.Claim.Obligations) != 1 { |
| 153 | t.Fatalf("first completion freed an outstanding sweep: %+v %v", obs.Claim, err) |
| 154 | } |
| 155 | if err := second.Run(t.Context(), Work{f.w.Host, joined.ID, joined.Op}); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | obs, err = hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| 159 | if err != nil || !obs.Claim.Free() { |
| 160 | t.Fatalf("last completion did not free the sweep: %+v %v", obs.Claim, err) |
| 161 | } |
| 162 | deadline := f.ob.Park.EvidenceAt.Add(SignatureAge + f.ob.Park.RequestLimit) |
| 163 | for _, region := range []string{"ie", "uk"} { |
| 164 | if !obs.Claim.QuietAfter[region].Equal(deadline) { |
| 165 | t.Fatalf("quiet_after[%s]=%v, want %v", region, obs.Claim.QuietAfter[region], deadline) |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | func TestSweepProtectsUnfinishedRunAndPins(t *testing.T) { |
| 170 | f, obs, v, vs := sweepFixture(t) |
| 171 | d, err := Decide(obs, v, vs, f.ob.Park.Incarnation) |
| 172 | if err != nil || len(d.Alerts) != 0 || len(d.Candidates) != 1 || d.Candidates[0].Generation != "g0" { |
| 173 | t.Fatalf("%+v %v", d, err) |
| 174 | } |
| 175 | f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) { |
| 176 | x := v |
| 177 | x.ClaimETag = o.ETag |
| 178 | return x, nil |
| 179 | } |
| 180 | if _, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, time.Second); err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | for _, x := range vs { |
| 184 | if strings.Contains(x.Key, "/g0/") { |
| 185 | continue |
| 186 | } |
| 187 | if _, err := f.b.GetVersion(t.Context(), "data", x.Key, x.VersionID); err != nil { |
| 188 | t.Fatalf("live version lost: %+v %v", x, err) |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | func TestSweepUnfinishedRunRegionalCoverage(t *testing.T) { |
| 194 | for _, scenario := range []string{"covered-foreign", "uncovered-foreign", "covered-local"} { |
| 195 | t.Run(scenario, func(t *testing.T) { |
| 196 | f, obs, v, _ := sweepFixture(t) |
| 197 | _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| 198 | c.Holder.Admissions = []hostclaim.AdmissionRecord{ |
| 199 | {Generation: "T", Predecessor: "g1", Region: "us", PredecessorRegion: "ie", Mode: "cold", Covered: scenario == "covered-local"}, |
| 200 | {Generation: "U", Predecessor: "T", Region: "uk", PredecessorRegion: "us", Mode: "cold", Covered: scenario == "covered-foreign"}, |
| 201 | {Generation: "g3", Predecessor: "U", Region: "ie", PredecessorRegion: "uk", Mode: "cold", Covered: scenario == "covered-foreign"}, |
| 202 | } |
| 203 | return nil |
| 204 | }) |
| 205 | if err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | for _, g := range []string{"T", "U"} { |
| 209 | f.b.Put("data", f.ob.Park.Location+"/"+g+"/data", []byte(g)) |
| 210 | } |
| 211 | before, err := f.e.Discover(t.Context(), f.w.Host) |
| 212 | if err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) { |
| 216 | x := v |
| 217 | x.ClaimETag = o.ETag |
| 218 | return x, nil |
| 219 | } |
| 220 | snapshot := f.b.Snapshot() |
| 221 | d, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, time.Second) |
| 222 | if err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | if scenario == "covered-local" { |
| 226 | if len(d.Alerts) != 1 || len(d.Candidates) != 0 || f.b.Snapshot() != snapshot { |
| 227 | t.Fatalf("covered local baseline did not refuse unchanged: %+v", d) |
| 228 | } |
| 229 | return |
| 230 | } |
| 231 | var got []string |
| 232 | for _, c := range d.Candidates { |
| 233 | got = append(got, c.Generation) |
| 234 | } |
| 235 | if len(d.Alerts) != 0 || !reflect.DeepEqual(got, []string{"T", "U", "g0", "g2"}) { |
| 236 | t.Fatalf("foreign links blocked sweep or were protected: %+v", d) |
| 237 | } |
| 238 | for _, x := range before { |
| 239 | g, _ := v.GenerationOf(ref(x)) |
| 240 | _, err := f.b.GetVersion(t.Context(), "data", x.Key, x.VersionID) |
| 241 | if g == "T" || g == "U" || g == "g0" || g == "g2" { |
| 242 | if !errors.Is(err, lease.ErrMirrorNotFound) { |
| 243 | t.Fatalf("dead version retained: %+v %v", x, err) |
| 244 | } |
| 245 | } else if err != nil { |
| 246 | t.Fatalf("live version lost: %+v %v", x, err) |
| 247 | } |
| 248 | } |
| 249 | completed, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| 250 | if err != nil || len(completed.Claim.Obligations) != 0 { |
| 251 | t.Fatalf("sweep did not complete: %+v %v", completed, err) |
| 252 | } |
| 253 | }) |
| 254 | } |
| 255 | } |
| 256 | func TestSweepDisagreementAndBusyRefuse(t *testing.T) { |
| 257 | f, obs, v, vs := sweepFixture(t) |
| 258 | v.MarkerGeneration = "unrelated" |
| 259 | d, err := Decide(obs, v, vs, f.ob.Park.Incarnation) |
| 260 | if err != nil || len(d.Alerts) != 1 || len(d.Candidates) != 0 { |
| 261 | t.Fatalf("%+v %v", d, err) |
| 262 | } |
| 263 | obs.Claim.Op.Kind = hostclaim.KindMoving |
| 264 | obs.Claim.Op.ID = "moving" |
| 265 | d, err = Decide(obs, v, vs, f.ob.Park.Incarnation) |
| 266 | if err != nil || len(d.Candidates) != 0 { |
| 267 | t.Fatalf("%+v %v", d, err) |
| 268 | } |
| 269 | } |
| 270 | func TestSweepDiscoversOldNamespaceAndMoves(t *testing.T) { |
| 271 | f, obs, v, vs := sweepFixture(t) |
| 272 | old := strings.Repeat("a", 64) |
| 273 | other := strings.Repeat("b", 64) |
| 274 | for _, key := range []string{"instances/" + f.w.Host + "/" + old + "/0000/a", "moves/" + f.w.Host + "/" + other + "/g-op/a", "instances/" + f.w.Host + "/0000/legacy", "moves/" + f.w.Host + "/g-old/staging"} { |
| 275 | id := f.b.Put("data", key, []byte("old")) |
| 276 | vs = append(vs, lease.ObjectVersion{Key: key, VersionID: id}) |
| 277 | } |
| 278 | d, err := Decide(obs, v, vs, f.ob.Park.Incarnation) |
| 279 | if err != nil || len(d.Candidates) != 4 { |
| 280 | t.Fatalf("%+v %v", d, err) |
| 281 | } |
| 282 | // Capped retired history is irrelevant to discovery. |
| 283 | obs.Claim.RetiredIncarnations = nil |
| 284 | v.Region = "us" |
| 285 | d, err = Decide(obs, v, vs, f.ob.Park.Incarnation) |
| 286 | if err != nil || len(d.Candidates) != 4 { |
| 287 | t.Fatalf("foreign location not residue: %+v %v", d, err) |
| 288 | } |
| 289 | } |
| 290 | func TestSweepFreeHostQueuesAndAcknowledges(t *testing.T) { |
| 291 | f := newFixture(t) |
| 292 | f.seed() |
| 293 | obs, _ := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| 294 | _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { c.Obligations = nil; return hostclaim.FreeAfterPark("op")(c) }) |
| 295 | if err != nil { |
| 296 | t.Fatal(err) |
| 297 | } |
| 298 | d, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, time.Second) |
| 299 | if err != nil || len(d.Candidates) != 1 { |
| 300 | t.Fatalf("%+v %v", d, err) |
| 301 | } |
| 302 | obs, err = hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| 303 | if err != nil || !obs.Claim.Free() || len(obs.Claim.Obligations) != 0 { |
| 304 | t.Fatalf("%+v %v", obs, err) |
| 305 | } |
| 306 | } |
| 307 | func TestSweepOperationOwnership(t *testing.T) { |
| 308 | for _, serving := range []bool{true, false} { |
| 309 | name := "holderless" |
| 310 | if serving { |
| 311 | name = "serving" |
| 312 | } |
| 313 | t.Run(name, func(t *testing.T) { |
| 314 | f, obs, v, _ := sweepFixture(t) |
| 315 | if !serving { |
| 316 | _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| 317 | c.Op = hostclaim.Op{Kind: hostclaim.KindParking, ID: "free"} |
| 318 | return hostclaim.FreeAfterPark("free")(c) |
| 319 | }) |
| 320 | if err != nil { |
| 321 | t.Fatal(err) |
| 322 | } |
| 323 | } |
| 324 | f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) { |
| 325 | x := v |
| 326 | x.ClaimETag = o.ETag |
| 327 | return x, nil |
| 328 | } |
| 329 | ids := []string{"generated-op", "chore", "retirement"} |
| 330 | wantOp, wantCalls := "generated-op", 3 |
| 331 | if serving { |
| 332 | ids, wantOp, wantCalls = ids[1:], "session:3", 2 |
| 333 | } |
| 334 | calls := 0 |
| 335 | f.e.NewID = func() string { |
| 336 | if calls >= len(ids) { |
| 337 | t.Fatal("unexpected generated operation id") |
| 338 | } |
| 339 | id := ids[calls] |
| 340 | calls++ |
| 341 | return id |
| 342 | } |
| 343 | authorized := false |
| 344 | f.e.Authorize = func(_ context.Context, _ hostclaim.Observed, o hostclaim.Obligation) error { |
| 345 | authorized = true |
| 346 | if o.Op != wantOp { |
| 347 | return ErrUnsafe |
| 348 | } |
| 349 | return nil |
| 350 | } |
| 351 | // Stop after queueing, before Run can acknowledge the persisted chore. |
| 352 | f.held.failed = true |
| 353 | if _, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, time.Second); !errors.Is(err, ErrChanged) { |
| 354 | t.Fatal(err) |
| 355 | } |
| 356 | if !authorized || calls != wantCalls { |
| 357 | t.Fatalf("authorized=%v ids=%d", authorized, calls) |
| 358 | } |
| 359 | queued, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| 360 | if err != nil { |
| 361 | t.Fatal(err) |
| 362 | } |
| 363 | if len(queued.Claim.Obligations) != 1 || queued.Claim.Obligations[0].Op != wantOp { |
| 364 | t.Fatalf("wrong chore owner: %+v", queued.Claim) |
| 365 | } |
| 366 | if serving { |
| 367 | if queued.Claim.Op.Kind != hostclaim.KindServing || queued.Claim.Holder.Session != 3 { |
| 368 | t.Fatal("serving state changed") |
| 369 | } |
| 370 | } else if queued.Claim.Holder != nil || queued.Claim.Op.ID != wantOp || queued.Claim.Op.Intent["park_sweep"] != "v1" { |
| 371 | t.Fatal("wrong holderless owner") |
| 372 | } |
| 373 | }) |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | func TestSweepPageFailureAndStaleView(t *testing.T) { |
| 378 | f, obs, v, vs := sweepFixture(t) |
| 379 | before := f.b.Snapshot() |
| 380 | f.b.FailListAfterPages = 1 |
| 381 | if _, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, time.Second); err == nil { |
| 382 | t.Fatal("short sweep acted") |
| 383 | } |
| 384 | if before != f.b.Snapshot() { |
| 385 | t.Fatal("mutation") |
| 386 | } |
| 387 | v.ClaimETag = "old" |
| 388 | if _, err := Decide(obs, v, vs, f.ob.Park.Incarnation); !errors.Is(err, ErrUnsafe) { |
| 389 | t.Fatal(err) |
| 390 | } |
| 391 | } |
| 392 | |