carlos / platform Public

release: PageLister, an optional bounded listing

List, ListMod and ListSizes all return every key under a prefix. That is
right for a sweep that wants the whole picture and wrong for a queue
drain that wants the oldest few: the drain caps how many items it
handles per app per tick, and without a bounded listing it would
enumerate a flooded app's entire queue every thirty seconds regardless
of that cap — paying for the listing it had deliberately declined to act
on.

Optional and type-asserted, like ModLister and SizeLister beside it, not
a Store method. Store has eleven implementations — the two real stores,
the prefix wrapper, fleetapi and seven test fakes — and adding a method
would break all of them for one caller.

The S3 test runs against an httptest fake, not a live bucket and not an
env gate. An earlier draft of the plan said to gate it "like every other
S3Store test", which was wrong: those tests already stand up an
in-process fake, and gating this one would have left the only path that
matters unexercised in CI. It asserts start-after and max-keys are
actually ON THE WIRE, because a drain that quietly enumerated everything
would look correct from the outside while the cap did nothing.

DirStore leans on ListMod already being key-sorted, so after and limit
are a filter and a slice rather than a second walk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev cf8ecdb7b07e0b705f5ef813dd5aca7d1197ef3e parent c01de1c
2 files changed, +190 −0
  • internal/release/pagelister_test.go +119 −0
  • internal/release/store.go +71 −0
diff --git a/internal/release/pagelister_test.go b/internal/release/pagelister_test.go
new file mode 100644
index 000000000..14ed6704b
--- /dev/null
+++ b/internal/release/pagelister_test.go
@@ -0,0 +1,119 @@
+package release
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestDirStorePageListerOrdersAndBounds(t *testing.T) {
+ ctx := context.Background()
+ d := NewDirStore(t.TempDir())
+ for _, k := range []string{"q/c", "q/a", "q/e", "q/b", "q/d", "other/z"} {
+ if _, err := d.Put(ctx, k, []byte("x"), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // Sorted, bounded, and the prefix is respected.
+ got, err := d.ListPage(ctx, "q/", "", 3)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"q/a", "q/b", "q/c"}
+ if fmt.Sprint(got) != fmt.Sprint(want) {
+ t.Fatalf("first page = %v, want %v", got, want)
+ }
+
+ // startAfter is EXCLUSIVE, so paging is "the last key I saw".
+ got, err = d.ListPage(ctx, "q/", "q/c", 3)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"q/d", "q/e"}; fmt.Sprint(got) != fmt.Sprint(want) {
+ t.Fatalf("second page = %v, want %v", got, want)
+ }
+
+ // Paging to the end terminates rather than repeating.
+ got, err = d.ListPage(ctx, "q/", "q/e", 3)
+ if err != nil || len(got) != 0 {
+ t.Fatalf("past the end = %v, %v", got, err)
+ }
+
+ // limit <= 0 means no bound.
+ got, err = d.ListPage(ctx, "q/", "", 0)
+ if err != nil || len(got) != 5 {
+ t.Fatalf("unbounded = %v, %v", got, err)
+ }
+}
+
+// The S3 half, against an in-process fake rather than a live bucket.
+//
+// The point of this test is that ListPage puts start-after and max-keys ON THE
+// WIRE. Nothing else in the package does — the other listings paginate through
+// everything — and a drain that silently enumerated a flooded app's whole
+// queue every 30 seconds would still look correct from the outside.
+func TestS3StorePageListerBoundsTheRequest(t *testing.T) {
+ var gotStartAfter, gotMaxKeys, gotPrefix string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ gotStartAfter, gotMaxKeys, gotPrefix = q.Get("start-after"), q.Get("max-keys"), q.Get("prefix")
+ w.Header().Set("Content-Type", "application/xml")
+ fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
+<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+ <Name>b</Name><KeyCount>2</KeyCount><IsTruncated>true</IsTruncated>
+ <Contents><Key>q/d</Key><Size>1</Size></Contents>
+ <Contents><Key>q/e</Key><Size>1</Size></Contents>
+</ListBucketResult>`)
+ }))
+ defer srv.Close()
+
+ rgwEnv(t, srv.URL)
+ s, err := NewS3Store(context.Background(), "b")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ keys, err := s.ListPage(context.Background(), "q/", "q/c", 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fmt.Sprint(keys) != fmt.Sprint([]string{"q/d", "q/e"}) {
+ t.Fatalf("keys = %v", keys)
+ }
+ if gotStartAfter != "q/c" {
+ t.Fatalf("start-after = %q, want the caller's cursor on the wire", gotStartAfter)
+ }
+ if gotMaxKeys != "2" {
+ t.Fatalf("max-keys = %q, want the bound on the wire — otherwise the cap is a lie", gotMaxKeys)
+ }
+ if gotPrefix != "q/" {
+ t.Fatalf("prefix = %q", gotPrefix)
+ }
+}
+
+// An empty cursor sends no start-after at all, rather than an empty one.
+func TestS3StorePageListerOmitsEmptyCursor(t *testing.T) {
+ var sawStartAfter bool
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, sawStartAfter = r.URL.Query()["start-after"]
+ w.Header().Set("Content-Type", "application/xml")
+ fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
+<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><KeyCount>0</KeyCount></ListBucketResult>`)
+ }))
+ defer srv.Close()
+
+ rgwEnv(t, srv.URL)
+ s, err := NewS3Store(context.Background(), "b")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.ListPage(context.Background(), "q/", "", 10); err != nil {
+ t.Fatal(err)
+ }
+ if sawStartAfter {
+ t.Fatal("an empty cursor was sent as start-after=")
+ }
+}
diff --git a/internal/release/store.go b/internal/release/store.go
index 3cdb23a1c..8ee6ca5c9 100644
--- a/internal/release/store.go
+++ b/internal/release/store.go
@@ -188,6 +188,32 @@ var (
_ SizeLister = (*S3Store)(nil)
)
+// PageLister is the optional BOUNDED listing. List, ListMod and ListSizes all
+// return every key under a prefix, which is right for a sweep that wants the
+// whole picture and wrong for a queue drain that wants the oldest few.
+//
+// The scan queue is the caller. It caps how many items it handles per app per
+// tick, and without a bounded listing it would enumerate a flooded app's whole
+// queue every 30 seconds regardless of that cap — paying for the listing it
+// deliberately declined to act on.
+//
+// Optional and type-asserted, like ModLister and SizeLister above, NOT a Store
+// method: Store has eleven implementations across the real stores, the prefix
+// wrapper, fleetapi and seven test fakes, and a new method would break all of
+// them for one caller's benefit. Callers degrade to List.
+//
+// startAfter is EXCLUSIVE and keys come back sorted, so paging is
+// "startAfter = the last key I saw". limit <= 0 means no bound.
+type PageLister interface {
+ ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error)
+}
+
+// Both real stores answer ListPage; only fakes fall back.
+var (
+ _ PageLister = (*DirStore)(nil)
+ _ PageLister = (*S3Store)(nil)
+)
+
// ---- DirStore ---------------------------------------------------------------
// DirStore keeps objects as files under a root directory — the test double
@@ -286,6 +312,26 @@ func (d *DirStore) List(ctx context.Context, prefix string) ([]string, error) {
return out, err
}
+// ListPage is the bounded listing. It leans on ListMod being key-sorted, so
+// "after" and "limit" are a filter and a slice rather than a second walk.
+func (d *DirStore) ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error) {
+ kms, err := d.ListMod(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]string, 0, len(kms))
+ for _, km := range kms {
+ if km.Key <= startAfter {
+ continue
+ }
+ out = append(out, km.Key)
+ if limit > 0 && len(out) == limit {
+ break
+ }
+ }
+ return out, nil
+}
+
// ListMod is the listing with each file's mtime alongside, key-sorted.
func (d *DirStore) ListMod(_ context.Context, prefix string) ([]KeyMod, error) {
d.mu.Lock()
@@ -550,6 +596,31 @@ func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) {
return out, nil
}
+// ListPage is the bounded listing, and unlike its siblings it does NOT
+// paginate: one request, at most limit keys, which is the whole point.
+//
+// S3 returns keys in lexicographic order and StartAfter is exclusive, so this
+// maps onto the API directly.
+func (s *S3Store) ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error) {
+ in := &awss3.ListObjectsV2Input{Bucket: &s.Bucket, Prefix: &prefix}
+ if startAfter != "" {
+ in.StartAfter = &startAfter
+ }
+ if limit > 0 {
+ n := int32(limit)
+ in.MaxKeys = &n
+ }
+ out, err := s.Client.ListObjectsV2(ctx, in)
+ if err != nil {
+ return nil, err
+ }
+ keys := make([]string, 0, len(out.Contents))
+ for _, o := range out.Contents {
+ keys = append(keys, aws.ToString(o.Key))
+ }
+ return keys, nil
+}
+
// ListMod is the listing keeping the LastModified S3 already sends with
// every entry.
func (s *S3Store) ListMod(ctx context.Context, prefix string) ([]KeyMod, error) {