| index 281966eb5..2e7c0f1f6 100644 |
| --- a/internal/host/buildstate_test.go |
| +++ b/internal/host/buildstate_test.go |
| @@ -11,6 +11,7 @@ import ( |
| |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -31,7 +32,7 @@ func testBuildStates(t *testing.T) (*buildStates, release.Store, *registry.Regis |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance", App: "km", Hibernate: true}); err != nil { |
| + if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance", App: "km", Hibernate: true, Socket: "/run/carlos/a.sock"}); err != nil { |
| t.Fatal(err) |
| } |
| s := release.NewDirStore(t.TempDir()) |
| @@ -65,7 +66,7 @@ func TestRefreshSeesAReplicaAndAState(t *testing.T) { |
| if _, err := s.Put(ctx, "inst/a.example.com/generations/0000/x.ltx", []byte("x"), ""); err != nil { |
| t.Fatal(err) |
| } |
| - if err := b.write(ctx, "a.example.com", release.BuildState{LastExecuted: "v1", Completed: 2}); err != nil { |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v1", Completed: 2}); err != nil { |
| t.Fatal(err) |
| } |
| if err := b.refresh(ctx, "a.example.com"); err != nil { |
| @@ -82,7 +83,7 @@ func TestRefreshSeesAReplicaAndAState(t *testing.T) { |
| func TestAFailedRefreshIsRecorded(t *testing.T) { |
| b, s, reg := testBuildStates(t) |
| ctx := context.Background() |
| - if err := b.write(ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| t.Fatal(err) |
| } |
| if err := b.refresh(ctx, "a.example.com"); err != nil { |
| @@ -99,7 +100,16 @@ func TestAFailedRefreshIsRecorded(t *testing.T) { |
| if c.ReadErr == "" || !c.Known || c.State == nil || c.State.LastExecuted != "v1" { |
| t.Fatalf("copy = %+v; want the last state kept and the failure recorded", c) |
| } |
| - if err := b.write(ctx, "a.example.com", release.BuildState{LastExecuted: "v2"}); err != nil { |
| + // update never writes over an object it cannot read: the merge would |
| + // drop what that object recorded. |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v2"}); err == nil { |
| + t.Fatal("an update wrote over an unparseable build state") |
| + } |
| + if c := copyOf(t, reg); c.State == nil || c.State.LastExecuted != "v1" { |
| + t.Fatalf("copy = %+v; a refused update changed it", c) |
| + } |
| + // Repaired by hand, the next good read clears the failure. |
| + if _, err := s.Put(ctx, key, []byte(`{"schema":1,"host":"a.example.com","last_executed":"v2"}`), mustEtag(t, s, key)); err != nil { |
| t.Fatal(err) |
| } |
| if err := b.refresh(ctx, "a.example.com"); err != nil { |
| @@ -160,23 +170,38 @@ func TestAStateForAnotherHostIsAReadError(t *testing.T) { |
| } |
| } |
| |
| -// refreshAll covers exactly the activator-managed exec instances. |
| -func TestRefreshAllCoversOnlyExecInstances(t *testing.T) { |
| +// refreshAll covers the activator-managed exec instances and the standalone |
| +// workers (ruling R-WORKER-ROWS): every row whose spawns run under a lease |
| +// in the replica bucket. |
| +func TestRefreshAllCoversExecInstancesAndWorkers(t *testing.T) { |
| b, _, reg := testBuildStates(t) |
| for _, rt := range []registry.Route{ |
| - {Host: "unit.example.com", Kind: "instance", App: "km", Hibernate: true, Unit: "km-unit"}, |
| - {Host: "awake.example.com", Kind: "instance", App: "km"}, |
| - {Host: "plat.example.com", Kind: "instance", App: "km", Hibernate: true, Platform: true}, |
| + {Host: "w.example.com", Kind: "sidecar", App: "km", Hibernate: true}, |
| + {Host: "always.example.com", Kind: "sidecar", App: "km"}, |
| + {Host: "unit.example.com", Kind: "instance", App: "km", Hibernate: true, Socket: "/run/u.sock", Unit: "km-unit"}, |
| + {Host: "backed.example.com", Kind: "instance", App: "km", Hibernate: true, Socket: "/run/b.sock", Backing: "unit"}, |
| + {Host: "backedw.example.com", Kind: "sidecar", App: "km", Hibernate: true, Backing: "unit"}, |
| + {Host: "awake.example.com", Kind: "instance", App: "km", Socket: "/run/w.sock"}, |
| + {Host: "plat.example.com", Kind: "instance", App: "km", Hibernate: true, Socket: "/run/p.sock", Platform: true}, |
| + {Host: "svc.example.com", Kind: "service", App: "km", Hibernate: true, Socket: "/run/s.sock"}, |
| } { |
| if err := reg.Upsert(rt); err != nil { |
| t.Fatal(err) |
| } |
| } |
| - b.refreshAll(context.Background()) |
| - if c := copyOf(t, reg); !c.Known { |
| - t.Fatalf("the exec instance was not refreshed: %+v", c) |
| + if skipped := b.refreshAll(context.Background()); skipped != 0 { |
| + t.Fatalf("skipped %d", skipped) |
| + } |
| + for _, h := range []string{"a.example.com", "w.example.com", "always.example.com"} { |
| + rt, err := reg.Get(h) |
| + if err != nil || rt == nil { |
| + t.Fatalf("%s: %v", h, err) |
| + } |
| + if c, _ := release.ParseBuildStateCopy(rt.BuildState); !c.Known { |
| + t.Fatalf("%s was not refreshed: %+v", h, c) |
| + } |
| } |
| - for _, h := range []string{"unit.example.com", "awake.example.com", "plat.example.com"} { |
| + for _, h := range []string{"unit.example.com", "backed.example.com", "backedw.example.com", "awake.example.com", "plat.example.com", "svc.example.com"} { |
| rt, err := reg.Get(h) |
| if err != nil || rt == nil { |
| t.Fatalf("%s: %v", h, err) |
| @@ -187,13 +212,90 @@ func TestRefreshAllCoversOnlyExecInstances(t *testing.T) { |
| } |
| } |
| |
| +// Ruling R-BS-REFRESH: refreshAll stops at its budget; the hosts it did not |
| +// reach keep their last copy and are counted. |
| +func TestRefreshAllStopsAtItsBudget(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + ctx := context.Background() |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := copyOf(t, reg) |
| + if err := reg.Upsert(registry.Route{Host: "w.example.com", Kind: "sidecar", App: "km", Hibernate: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + b.budget = time.Nanosecond |
| + if skipped := b.refreshAll(ctx); skipped != 2 { |
| + t.Fatalf("skipped %d, want 2", skipped) |
| + } |
| + if after := copyOf(t, reg); after.Column() != before.Column() { |
| + t.Fatalf("a host not reached changed its copy: %+v -> %+v", before, after) |
| + } |
| + if rt, _ := reg.Get("w.example.com"); rt.BuildState != "" { |
| + t.Fatalf("a host not reached got a copy: %s", rt.BuildState) |
| + } |
| +} |
| + |
| +// listCounter records how the replica was listed; hidePages hides the |
| +// bounded listing so the fallback is exercised. |
| +type listCounter struct { |
| + release.Store |
| + pages []int |
| + full int |
| +} |
| + |
| +func (l *listCounter) List(ctx context.Context, prefix string) ([]string, error) { |
| + l.full++ |
| + return l.Store.List(ctx, prefix) |
| +} |
| + |
| +type pagedCounter struct{ *listCounter } |
| + |
| +func (p pagedCounter) ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error) { |
| + p.pages = append(p.pages, limit) |
| + return p.Store.(release.PageLister).ListPage(ctx, prefix, startAfter, limit) |
| +} |
| + |
| +// Ruling R-BS-REFRESH: three keys decide the replica, so a store with a |
| +// bounded listing is asked for three, never for the whole prefix; one |
| +// without it still answers through List. |
| +func TestTheReplicaIsDecidedByABoundedListing(t *testing.T) { |
| + ctx := context.Background() |
| + dir := release.NewDirStore(t.TempDir()) |
| + for _, k := range []string{"inst/a/build-state.json", "inst/a/lock.json", "inst/a/generations/0000/a.ltx", "inst/a/generations/0000/b.ltx", "inst/a/generations/0001/c.ltx"} { |
| + if _, err := dir.Put(ctx, k, []byte("x"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + lc := &listCounter{Store: dir} |
| + paged := pagedCounter{lc} |
| + if ok, err := hasReplica(ctx, paged, "inst/a"); err != nil || !ok { |
| + t.Fatalf("paged: %v, %v", ok, err) |
| + } |
| + if lc.full != 0 || len(lc.pages) != 1 || lc.pages[0] != 3 { |
| + t.Fatalf("the whole prefix was listed %d times; pages asked %v, want [3]", lc.full, lc.pages) |
| + } |
| + if ok, err := hasReplica(ctx, lc, "inst/a"); err != nil || !ok || lc.full != 1 { |
| + t.Fatalf("fallback: %v, %v, %d lists", ok, err, lc.full) |
| + } |
| + lockOnly := release.NewDirStore(t.TempDir()) |
| + for _, k := range []string{"inst/a/build-state.json", "inst/a/lock.json"} { |
| + if _, err := lockOnly.Put(ctx, k, []byte("x"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if ok, err := hasReplica(ctx, lockOnly, "inst/a"); err != nil || ok { |
| + t.Fatalf("lock and state alone: %v, %v", ok, err) |
| + } |
| +} |
| + |
| // --- where the object lives (ruling R-CLAIMPREFIX) --- |
| |
| // Host claims off: the hibernate bucket, at the hibernate prefix. |
| func TestClaimsOffUsesTheHibernatePrefix(t *testing.T) { |
| b, s, _ := testBuildStates(t) |
| ctx := context.Background() |
| - if err := b.write(ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| t.Fatal(err) |
| } |
| if _, _, err := s.Get(ctx, "inst/a.example.com/build-state.json"); err != nil { |
| @@ -211,7 +313,7 @@ func TestAContextLocationWins(t *testing.T) { |
| return hostclaim.Observed{}, errors.New("the claim must not be read when the wake names its lease") |
| } |
| ctx := activator.WithBuildStateLocation(context.Background(), activator.BuildStateLocation{Bucket: "data", Prefix: "instances/a.example.com/inc1"}) |
| - if err := b.write(ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| t.Fatal(err) |
| } |
| if _, _, err := other.Get(ctx, "instances/a.example.com/inc1/build-state.json"); err != nil { |
| @@ -244,7 +346,7 @@ func TestClaimsOnUsesTheHolderLocationInTheActorBucket(t *testing.T) { |
| b.claimBucket = "data" |
| b.claims = claimedAt("instances/a.example.com/inc1") |
| ctx := context.Background() |
| - if err := b.write(ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| t.Fatal(err) |
| } |
| if _, _, err := data.Get(ctx, "instances/a.example.com/inc1/build-state.json"); err != nil { |
| @@ -292,7 +394,7 @@ func TestAnUnreadableClaimIsAReadError(t *testing.T) { |
| b.claimBucket = "data" |
| b.claims = claimedAt("instances/a.example.com") |
| ctx := context.Background() |
| - if err := b.write(ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| t.Fatal(err) |
| } |
| if err := b.refresh(ctx, "a.example.com"); err != nil { |
| @@ -309,7 +411,7 @@ func TestAnUnreadableClaimIsAReadError(t *testing.T) { |
| if c.ReadErr == "" || !c.Known || c.State == nil || c.State.LastExecuted != "v1" { |
| t.Fatalf("copy = %+v; want the last state kept and the failure recorded", c) |
| } |
| - if err := b.write(ctx, "a.example.com", release.BuildState{LastExecuted: "v2"}); err == nil { |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v2"}); err == nil { |
| t.Fatal("a claimed host's build state was written without its claim") |
| } |
| }) |
| @@ -338,3 +440,170 @@ func TestStoresAreOpenedPerBucketAndCached(t *testing.T) { |
| t.Fatal("a bucket the box does not lease in was opened") |
| } |
| } |
| + |
| +func put(t *testing.T, b *buildStates, ctx context.Context, host string, s release.BuildState) error { |
| + t.Helper() |
| + _, err := b.update(ctx, host, func(*release.BuildState) release.BuildState { return s }) |
| + return err |
| +} |
| + |
| +// --- update (ruling R-BS-UPDATE) --- |
| + |
| +// bump is a merge that keeps what it read and raises Completed by one. |
| +func bump(cur *release.BuildState) release.BuildState { |
| + next := release.BuildState{} |
| + if cur != nil { |
| + next = *cur |
| + } |
| + next.Completed++ |
| + return next |
| +} |
| + |
| +// Concurrent updates for one host (an instance spawn and its worker or |
| +// sidecar spawns, under the same lease) never lose a merge. |
| +func TestConcurrentUpdatesKeepEveryMerge(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + ctx := context.Background() |
| + const n = 8 |
| + errs := make(chan error, n) |
| + for i := 0; i < n; i++ { |
| + go func() { |
| + _, err := b.update(ctx, "a.example.com", bump) |
| + errs <- err |
| + }() |
| + } |
| + for i := 0; i < n; i++ { |
| + if err := <-errs; err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + st, _, err := b.read(ctx, "a.example.com") |
| + if err != nil || st == nil || st.Completed != n { |
| + t.Fatalf("state = %+v, %v; want Completed %d", st, err, n) |
| + } |
| + if c := copyOf(t, reg); !c.Known || !c.Replica || c.State == nil || c.State.Completed != n { |
| + t.Fatalf("copy = %+v", c) |
| + } |
| +} |
| + |
| +// A write that lands between update's read and its Put (another process) |
| +// is not overwritten: update re-reads and merges again. |
| +func TestAnUpdateRacedBetweenReadAndPutMergesAgain(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + ctx := context.Background() |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v1", Completed: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + raced := false |
| + b.beforePut = func(ctx context.Context, s release.Store, key string) { |
| + if raced { |
| + return |
| + } |
| + raced = true |
| + other := []byte(`{"schema":1,"host":"a.example.com","last_executed":"v1","resolved":{"id":7,"target":"v3"},"completed":6}`) |
| + if _, err := s.Put(ctx, key, other, mustEtag(t, s, key)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + merges := 0 |
| + got, err := b.update(ctx, "a.example.com", func(cur *release.BuildState) release.BuildState { |
| + merges++ |
| + next := *cur |
| + next.LastExecuted = "v2" |
| + return next |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if merges != 2 || got.LastExecuted != "v2" || got.Completed != 6 || got.Resolved == nil || got.Resolved.ID != 7 { |
| + t.Fatalf("merges %d, got %+v; want the raced write's Completed and Resolved kept", merges, got) |
| + } |
| + st, _, err := b.read(ctx, "a.example.com") |
| + if err != nil || st == nil || st.Completed != 6 || st.LastExecuted != "v2" { |
| + t.Fatalf("stored %+v, %v", st, err) |
| + } |
| + if c := copyOf(t, reg); c.State == nil || c.State.Completed != 6 || c.State.LastExecuted != "v2" { |
| + t.Fatalf("copy = %+v", c) |
| + } |
| +} |
| + |
| +// A conflict on every try is an error, and the copy keeps what it had. |
| +func TestAPersistentConflictIsAnError(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + ctx := context.Background() |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := copyOf(t, reg) |
| + n := 0 |
| + b.beforePut = func(ctx context.Context, s release.Store, key string) { |
| + n++ |
| + other := []byte(fmt.Sprintf(`{"schema":1,"host":"a.example.com","last_executed":"v1","completed":%d}`, n)) |
| + if _, err := s.Put(ctx, key, other, mustEtag(t, s, key)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := b.update(ctx, "a.example.com", bump); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("err = %v; want a CAS conflict", err) |
| + } |
| + if n != updateTries { |
| + t.Fatalf("tried %d times, want %d", n, updateTries) |
| + } |
| + if after := copyOf(t, reg); after.Column() != before.Column() { |
| + t.Fatalf("a lost update changed the copy: %+v -> %+v", before, after) |
| + } |
| +} |
| + |
| +// The first update on a host with no object is a create-only write. |
| +func TestTheFirstUpdateCreates(t *testing.T) { |
| + b, s, _ := testBuildStates(t) |
| + ctx := context.Background() |
| + b.beforePut = func(ctx context.Context, st release.Store, key string) { |
| + if _, _, err := st.Get(ctx, key); err == nil { |
| + return |
| + } |
| + if _, err := st.Put(ctx, key, []byte(`{"schema":1,"host":"a.example.com","completed":4}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := b.update(ctx, "a.example.com", bump) |
| + if err != nil || got.Completed != 5 { |
| + t.Fatalf("got %+v, %v; want the created object merged (Completed 5)", got, err) |
| + } |
| + if _, _, err := s.Get(ctx, "inst/a.example.com/build-state.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Minor (fix round 1): with host claims on only the claim bucket is |
| +// registered, so a hibernate bucket that is the same bucket with no region |
| +// set cannot fail the box. |
| +func TestClaimsOnRegistersOnlyTheClaimBucket(t *testing.T) { |
| + read := func(context.Context, string) (hostclaim.Observed, error) { return hostclaim.Observed{}, nil } |
| + cfg := &activator.Config{Bucket: "data", Region: "", Prefix: "hib", |
| + HostWrites: hostwrite.Config{Enabled: true}, |
| + Claims: &activator.ClaimRuntime{Bucket: "data", LeaseRegion: "eu-west-1", Read: read}} |
| + regions, err := buildStateRegions(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(regions) != 1 || regions["data"] != "eu-west-1" { |
| + t.Fatalf("regions = %v", regions) |
| + } |
| + b, err := newBuildStates(cfg, nil, "") |
| + if err != nil || b == nil || b.claimBucket != "data" || b.claims == nil { |
| + t.Fatalf("newBuildStates = %+v, %v", b, err) |
| + } |
| + cfg.Claims.Bucket = "other" |
| + if regions, err := buildStateRegions(cfg); err != nil || len(regions) != 1 || regions["other"] != "eu-west-1" { |
| + t.Fatalf("regions = %v, %v; want only the claim bucket", regions, err) |
| + } |
| + cfg.HostWrites.Enabled = false |
| + if regions, err := buildStateRegions(cfg); err != nil || len(regions) != 1 || regions["data"] != "" { |
| + t.Fatalf("claims off: regions = %v, %v", regions, err) |
| + } |
| + cfg.HostWrites.Enabled, cfg.Claims = true, nil |
| + if _, err := buildStateRegions(cfg); err == nil { |
| + t.Fatal("claims on with no claim runtime was accepted") |
| + } |
| +} |