| new file mode 100644 |
| index 000000000..eb0d605a8 |
| --- /dev/null |
| +++ b/internal/release/changewatch_test.go |
| @@ -0,0 +1,436 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "io/fs" |
| + "reflect" |
| + "sort" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// condCounter wraps a DirStore and records the etag every GetIfChanged call |
| +// carried, so a test can see that the second poll was conditional. |
| +type condCounter struct { |
| + *DirStore |
| + mu sync.Mutex |
| + etags []string |
| + getErr error // when set, GetIfChanged fails with it |
| +} |
| + |
| +func (c *condCounter) GetIfChanged(ctx context.Context, key, etag string) ([]byte, string, bool, error) { |
| + c.mu.Lock() |
| + c.etags = append(c.etags, etag) |
| + err := c.getErr |
| + c.mu.Unlock() |
| + if err != nil { |
| + return nil, "", false, err |
| + } |
| + return c.DirStore.GetIfChanged(ctx, key, etag) |
| +} |
| + |
| +func (c *condCounter) setErr(err error) { |
| + c.mu.Lock() |
| + c.getErr = err |
| + c.mu.Unlock() |
| +} |
| + |
| +func (c *condCounter) seen() []string { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return append([]string(nil), c.etags...) |
| +} |
| + |
| +func writeScoped(t *testing.T, s Store, keys ...string) { |
| + t.Helper() |
| + for _, k := range keys { |
| + if _, err := s.Put(context.Background(), k, []byte("x"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func TestChangeWatchFirstPollReportsAll(t *testing.T) { |
| + t.Parallel() |
| + d := indexedDirStore(t) |
| + writeScoped(t, d, "apps/bab/shop/instances/h.json", "accounts/acme/members.json") |
| + w := NewChangeWatch(d) |
| + if w.Usable() { |
| + t.Fatal("a watch that has not polled is usable") |
| + } |
| + if _, ok := w.Gen("apps/bab/shop"); ok { |
| + t.Fatal("Gen ok before the first poll") |
| + } |
| + moved, err := w.Poll(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []string{"accounts/acme", "apps/bab/shop"} |
| + if !reflect.DeepEqual(moved, want) { |
| + t.Fatalf("moved = %v, want %v", moved, want) |
| + } |
| + if !w.Usable() { |
| + t.Fatal("not usable after a good poll") |
| + } |
| + if g, ok := w.Gen("apps/bab/shop"); !ok || g != 1 { |
| + t.Fatalf("Gen = %d, %v; want 1, true", g, ok) |
| + } |
| + if g, ok := w.Gen("apps/bab/other"); !ok || g != 0 { |
| + t.Fatalf("Gen of an unlisted scope = %d, %v; want 0, true", g, ok) |
| + } |
| +} |
| + |
| +func TestChangeWatchUnchangedIs304(t *testing.T) { |
| + t.Parallel() |
| + c := &condCounter{DirStore: indexedDirStore(t)} |
| + writeScoped(t, c.DirStore, "apps/bab/shop/instances/h.json") |
| + w := NewChangeWatch(c) |
| + if _, err := w.Poll(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + moved, err := w.Poll(context.Background()) |
| + if err != nil || moved != nil { |
| + t.Fatalf("second poll = %v, %v; want nil, nil", moved, err) |
| + } |
| + seen := c.seen() |
| + if len(seen) != 2 || seen[0] != "" || seen[1] == "" { |
| + t.Fatalf("etags sent = %q; want an empty one, then the index's", seen) |
| + } |
| +} |
| + |
| +func TestChangeWatchReportsOnlyMoved(t *testing.T) { |
| + t.Parallel() |
| + d := indexedDirStore(t) |
| + writeScoped(t, d, "apps/bab/shop/instances/h.json", "accounts/acme/members.json") |
| + w := NewChangeWatch(d) |
| + if _, err := w.Poll(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + writeScoped(t, d, "apps/bab/shop/instances/i.json", "control/certs/example.com.json") |
| + moved, err := w.Poll(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []string{"apps/bab/shop", "control/certs"} |
| + if !reflect.DeepEqual(moved, want) { |
| + t.Fatalf("moved = %v, want %v", moved, want) |
| + } |
| + if g, _ := w.Gen("apps/bab/shop"); g != 2 { |
| + t.Fatalf("Gen = %d, want 2", g) |
| + } |
| +} |
| + |
| +func TestChangeWatchAbsentIndexIsUsableEmpty(t *testing.T) { |
| + t.Parallel() |
| + d := indexedDirStore(t) |
| + w := NewChangeWatch(d) |
| + moved, err := w.Poll(context.Background()) |
| + if err != nil || len(moved) != 0 { |
| + t.Fatalf("poll = %v, %v; want nothing", moved, err) |
| + } |
| + if !w.Usable() { |
| + t.Fatal("an absent index is the pre-first-bump state and must be usable") |
| + } |
| + if obs, ok := w.Observed(); !ok || len(obs) != 0 { |
| + t.Fatalf("Observed = %v, %v; want empty, true", obs, ok) |
| + } |
| + // The first write then shows up as an appearance. |
| + writeScoped(t, d, "apps/bab/shop/instances/h.json") |
| + moved, err = w.Poll(context.Background()) |
| + if err != nil || !reflect.DeepEqual(moved, []string{"apps/bab/shop"}) { |
| + t.Fatalf("poll after first write = %v, %v", moved, err) |
| + } |
| +} |
| + |
| +func TestChangeWatchDeniedIsUnusable(t *testing.T) { |
| + t.Parallel() |
| + c := &condCounter{DirStore: indexedDirStore(t)} |
| + writeScoped(t, c.DirStore, "apps/bab/shop/instances/h.json") |
| + c.setErr(fmt.Errorf("get %s: %w", ChangeIndexKey, fs.ErrPermission)) |
| + w := NewChangeWatch(c) |
| + moved, err := w.Poll(context.Background()) |
| + if !errors.Is(err, fs.ErrPermission) || moved != nil { |
| + t.Fatalf("poll = %v, %v; want the access error", moved, err) |
| + } |
| + if w.Usable() { |
| + t.Fatal("usable after a denied read") |
| + } |
| + if _, ok := w.Gen("apps/bab/shop"); ok { |
| + t.Fatal("Gen ok while unusable") |
| + } |
| + if _, ok := w.Observed(); ok { |
| + t.Fatal("Observed ok while unusable") |
| + } |
| +} |
| + |
| +func TestChangeWatchUsableThenErrorThenRecovers(t *testing.T) { |
| + t.Parallel() |
| + c := &condCounter{DirStore: indexedDirStore(t)} |
| + writeScoped(t, c.DirStore, "apps/bab/shop/instances/h.json", "accounts/acme/members.json") |
| + w := NewChangeWatch(c) |
| + if _, err := w.Poll(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + c.setErr(errors.New("network down")) |
| + writeScoped(t, c.DirStore, "apps/bab/shop/instances/i.json") |
| + if _, err := w.Poll(context.Background()); err == nil { |
| + t.Fatal("poll succeeded through an error") |
| + } |
| + if w.Usable() { |
| + t.Fatal("still usable after an error") |
| + } |
| + c.setErr(nil) |
| + moved, err := w.Poll(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(moved, []string{"apps/bab/shop"}) { |
| + t.Fatalf("moved = %v; want the scope that rose while unusable", moved) |
| + } |
| + if !w.Usable() { |
| + t.Fatal("not usable after recovering") |
| + } |
| + // An unchanged index after an error recovers without a spurious move. |
| + c.setErr(errors.New("blip")) |
| + _, _ = w.Poll(context.Background()) |
| + c.setErr(nil) |
| + moved, err = w.Poll(context.Background()) |
| + if err != nil || moved != nil || !w.Usable() { |
| + t.Fatalf("recovery on an unchanged index = %v, %v, usable %v", moved, err, w.Usable()) |
| + } |
| +} |
| + |
| +func TestChangeWatchUnknownSchemaIsUnusable(t *testing.T) { |
| + t.Parallel() |
| + d := NewDirStore(t.TempDir()) |
| + if _, err := d.Put(context.Background(), ChangeIndexKey, []byte(`{"schema":2,"entries":{"apps/a/b":1}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + w := NewChangeWatch(d) |
| + _, err := w.Poll(context.Background()) |
| + if !errors.Is(err, ErrChangeIndexSchema) { |
| + t.Fatalf("err = %v, want ErrChangeIndexSchema", err) |
| + } |
| + if w.Usable() { |
| + t.Fatal("usable on an unknown schema") |
| + } |
| + // A malformed body is unknown too. |
| + _, etag, _ := d.Get(context.Background(), ChangeIndexKey) |
| + if _, err := d.Put(context.Background(), ChangeIndexKey, []byte(`not json`), etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := w.Poll(context.Background()); err == nil || w.Usable() { |
| + t.Fatalf("malformed index: err %v, usable %v", err, w.Usable()) |
| + } |
| +} |
| + |
| +func TestChangeWatchWithoutExtensionIsUnusable(t *testing.T) { |
| + t.Parallel() |
| + w := NewChangeWatch(bareStore{NewDirStore(t.TempDir())}) |
| + _, err := w.Poll(context.Background()) |
| + if !errors.Is(err, errors.ErrUnsupported) { |
| + t.Fatalf("err = %v, want ErrUnsupported", err) |
| + } |
| + if w.Usable() { |
| + t.Fatal("usable without ConditionalGetter") |
| + } |
| + // The prefix wrapper over such a store answers ErrUnsupported itself. |
| + w = NewChangeWatch(NewPrefixStore(bareStore{NewDirStore(t.TempDir())}, "base/")) |
| + if _, err := w.Poll(context.Background()); err == nil || w.Usable() { |
| + t.Fatalf("prefixed bare store: err %v, usable %v", err, w.Usable()) |
| + } |
| +} |
| + |
| +func TestChangeWatchObservedIsACopy(t *testing.T) { |
| + t.Parallel() |
| + d := indexedDirStore(t) |
| + writeScoped(t, d, "apps/bab/shop/instances/h.json") |
| + w := NewChangeWatch(d) |
| + if _, err := w.Poll(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, _ := w.Observed() |
| + obs["apps/bab/shop"] = 99 |
| + delete(obs, "nothing") |
| + if g, _ := w.Gen("apps/bab/shop"); g != 1 { |
| + t.Fatalf("mutating Observed changed the watch: Gen = %d", g) |
| + } |
| +} |
| + |
| +func TestChangeWatchConcurrentPolls(t *testing.T) { |
| + t.Parallel() |
| + d := indexedDirStore(t) |
| + writeScoped(t, d, "apps/bab/shop/instances/h.json") |
| + w := NewChangeWatch(d) |
| + var wg sync.WaitGroup |
| + var mu sync.Mutex |
| + var all []string |
| + for range 8 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + moved, err := w.Poll(context.Background()) |
| + if err != nil { |
| + t.Error(err) |
| + } |
| + w.Gen("apps/bab/shop") |
| + w.Observed() |
| + mu.Lock() |
| + all = append(all, moved...) |
| + mu.Unlock() |
| + }() |
| + } |
| + wg.Wait() |
| + sort.Strings(all) |
| + if !reflect.DeepEqual(all, []string{"apps/bab/shop"}) { |
| + t.Fatalf("scope reported %v across racing polls; want once", all) |
| + } |
| +} |
| + |
| +func TestReadGateDue(t *testing.T) { |
| + t.Parallel() |
| + const maxAge = 30 * time.Minute |
| + t0 := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC) |
| + const scope = "apps/bab/shop" |
| + ctx := context.Background() |
| + |
| + usableWatch := func(t *testing.T) (*ChangeWatch, *DirStore) { |
| + d := indexedDirStore(t) |
| + writeScoped(t, d, "apps/bab/shop/instances/h.json") |
| + w := NewChangeWatch(d) |
| + if _, err := w.Poll(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + return w, d |
| + } |
| + |
| + tests := []struct { |
| + name string |
| + run func(t *testing.T) bool // returns Due's answer |
| + want bool |
| + }{ |
| + {"nil watch is due", func(t *testing.T) bool { |
| + _, due := NewReadGate(nil, maxAge).Due(scope, t0) |
| + return due |
| + }, true}, |
| + {"unusable watch is due", func(t *testing.T) bool { |
| + g := NewReadGate(NewChangeWatch(NewDirStore(t.TempDir())), maxAge) |
| + _, due := g.Due(scope, t0) |
| + return due |
| + }, true}, |
| + {"never marked is due", func(t *testing.T) bool { |
| + w, _ := usableWatch(t) |
| + _, due := NewReadGate(w, maxAge).Due(scope, t0) |
| + return due |
| + }, true}, |
| + {"marked ok, same gen, within age is not due", func(t *testing.T) bool { |
| + w, _ := usableWatch(t) |
| + g := NewReadGate(w, maxAge) |
| + gen, _ := g.Due(scope, t0) |
| + g.Mark(scope, gen, t0, true) |
| + _, due := g.Due(scope, t0.Add(maxAge-time.Second)) |
| + return due |
| + }, false}, |
| + {"generation moved is due", func(t *testing.T) bool { |
| + w, d := usableWatch(t) |
| + g := NewReadGate(w, maxAge) |
| + gen, _ := g.Due(scope, t0) |
| + g.Mark(scope, gen, t0, true) |
| + writeScoped(t, d, "apps/bab/shop/instances/i.json") |
| + if _, err := w.Poll(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, due := g.Due(scope, t0.Add(time.Second)) |
| + return due |
| + }, true}, |
| + {"other scope moving is not due", func(t *testing.T) bool { |
| + w, d := usableWatch(t) |
| + g := NewReadGate(w, maxAge) |
| + gen, _ := g.Due(scope, t0) |
| + g.Mark(scope, gen, t0, true) |
| + writeScoped(t, d, "accounts/acme/members.json") |
| + if _, err := w.Poll(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, due := g.Due(scope, t0.Add(time.Second)) |
| + return due |
| + }, false}, |
| + {"age at maxAge is due", func(t *testing.T) bool { |
| + w, _ := usableWatch(t) |
| + g := NewReadGate(w, maxAge) |
| + gen, _ := g.Due(scope, t0) |
| + g.Mark(scope, gen, t0, true) |
| + _, due := g.Due(scope, t0.Add(maxAge)) |
| + return due |
| + }, true}, |
| + {"last mark failed is due", func(t *testing.T) bool { |
| + w, _ := usableWatch(t) |
| + g := NewReadGate(w, maxAge) |
| + gen, _ := g.Due(scope, t0) |
| + g.Mark(scope, gen, t0, false) |
| + _, due := g.Due(scope, t0.Add(time.Second)) |
| + return due |
| + }, true}, |
| + {"a failed mark after a good one is due", func(t *testing.T) bool { |
| + w, _ := usableWatch(t) |
| + g := NewReadGate(w, maxAge) |
| + gen, _ := g.Due(scope, t0) |
| + g.Mark(scope, gen, t0, true) |
| + g.Mark(scope, gen, t0.Add(time.Minute), false) |
| + _, due := g.Due(scope, t0.Add(2*time.Minute)) |
| + return due |
| + }, true}, |
| + {"watch going unusable makes a marked scope due", func(t *testing.T) bool { |
| + c := &condCounter{DirStore: indexedDirStore(t)} |
| + writeScoped(t, c.DirStore, "apps/bab/shop/instances/h.json") |
| + w := NewChangeWatch(c) |
| + if _, err := w.Poll(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + g := NewReadGate(w, maxAge) |
| + gen, _ := g.Due(scope, t0) |
| + g.Mark(scope, gen, t0, true) |
| + c.setErr(errors.New("down")) |
| + _, _ = w.Poll(ctx) |
| + _, due := g.Due(scope, t0.Add(time.Second)) |
| + return due |
| + }, true}, |
| + } |
| + for _, tc := range tests { |
| + t.Run(tc.name, func(t *testing.T) { |
| + t.Parallel() |
| + if got := tc.run(t); got != tc.want { |
| + t.Fatalf("due = %v, want %v", got, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestReadGateCapturedGenSurvivesRaceWithPoll(t *testing.T) { |
| + t.Parallel() |
| + ctx := context.Background() |
| + d := indexedDirStore(t) |
| + writeScoped(t, d, "apps/bab/shop/instances/h.json") |
| + w := NewChangeWatch(d) |
| + if _, err := w.Poll(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + g := NewReadGate(w, time.Hour) |
| + t0 := time.Now() |
| + gen, due := g.Due("apps/bab/shop", t0) |
| + if !due { |
| + t.Fatal("never-marked scope not due") |
| + } |
| + // A write lands and is polled while the read is in flight. |
| + writeScoped(t, d, "apps/bab/shop/instances/i.json") |
| + if _, err := w.Poll(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + g.Mark("apps/bab/shop", gen, t0, true) // the generation captured BEFORE the read |
| + if _, due := g.Due("apps/bab/shop", t0); !due { |
| + t.Fatal("a mark of the pre-read generation hid a write that landed during the read") |
| + } |
| +} |