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") } } // blockingCond is a DirStore whose GetIfChanged, once armed, parks until // released: a hung index GET holding the watch's poll lock. type blockingCond struct { *DirStore mu sync.Mutex block chan struct{} // nil = no blocking entered chan struct{} } func (b *blockingCond) arm() (entered, release chan struct{}) { b.mu.Lock() defer b.mu.Unlock() b.block, b.entered = make(chan struct{}), make(chan struct{}, 1) return b.entered, b.block } func (b *blockingCond) GetIfChanged(ctx context.Context, key, etag string) ([]byte, string, bool, error) { b.mu.Lock() block, entered := b.block, b.entered b.mu.Unlock() if block != nil { entered <- struct{}{} <-block } return b.DirStore.GetIfChanged(ctx, key, etag) } // A Poll waiting behind a stuck one gives up when its context ends. The // give-up is a failed poll like any other: the watch is unknown until the // next good one (a frozen observation must never vouch for a gated read), // but it keeps its ETag and entries, so that next poll is a 304 that // reports nothing moved. func TestChangeWatchPollLockWaitHonoursContext(t *testing.T) { t.Parallel() d := indexedDirStore(t) writeScoped(t, d, "apps/bab/shop/instances/h.json") b := &blockingCond{DirStore: d} w := NewChangeWatch(b) if _, err := w.Poll(context.Background()); err != nil { t.Fatal(err) } gen, _ := w.Gen("apps/bab/shop") entered, release := b.arm() stuck := make(chan error, 1) go func() { _, err := w.Poll(context.Background()) stuck <- err }() <-entered ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() done := make(chan error, 1) go func() { _, err := w.Poll(ctx) done <- err }() select { case err := <-done: if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("waiting Poll returned %v, want the context's error", err) } case <-time.After(5 * time.Second): close(release) t.Fatal("a Poll waiting behind a stuck one ignored its context") } if w.Usable() { t.Fatal("a give-up on the lock left the watch usable: its observation is frozen") } if _, ok := w.Gen("apps/bab/shop"); ok { t.Fatal("Gen vouched for a scope after a give-up") } if _, ok := w.Observed(); ok { t.Fatal("Observed vouched after a give-up") } close(release) if err := <-stuck; err != nil { t.Fatalf("the stuck Poll, released: %v", err) } if g, ok := w.Gen("apps/bab/shop"); !ok || g != gen { t.Fatalf("Gen after the stuck poll answered = %d, %v; want %d, true", g, ok, gen) } } // A Poll whose context has already ended reads nothing, and is a failed // poll: the watch is unknown until the next good one, which is a 304 // because the watch kept its ETag. func TestChangeWatchCancelledPollIsUnknownThenA304(t *testing.T) { t.Parallel() d := indexedDirStore(t) writeScoped(t, d, "apps/bab/shop/instances/h.json") c := &condCounter{DirStore: d} w := NewChangeWatch(c) if _, err := w.Poll(context.Background()); err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := w.Poll(ctx); !errors.Is(err, context.Canceled) { t.Fatalf("a cancelled Poll returned %v", err) } if w.Usable() { t.Fatal("a cancelled Poll left the watch usable") } moved, err := w.Poll(context.Background()) if err != nil || moved != nil || !w.Usable() { t.Fatalf("recovery = %v, %v, usable %v; want nothing moved and usable", moved, err, w.Usable()) } seen := c.seen() if last := seen[len(seen)-1]; last == "" { t.Fatalf("the recovery poll was unconditional (etags %q): the give-up dropped the ETag", seen) } }