carlos / platform Public

release: SizeLister optional extension for sized listings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b021b68e6c46aec108c86352b326b291edadba1e parent 6441e1a
2 files changed, +108 −0
  • internal/release/store.go +82 −0
  • internal/release/store_test.go +26 −0
diff --git a/internal/release/store.go b/internal/release/store.go
index 3b57fe196..73896ebff 100644
--- a/internal/release/store.go
+++ b/internal/release/store.go
@@ -168,6 +168,26 @@ var (
_ ModLister = (*S3Store)(nil)
)
+// KeySize is one object and its byte size.
+type KeySize struct {
+ Key string
+ Size int64
+}
+
+// SizeLister is the optional sized-listing extension the economics storage
+// sweep needs. Like ModLister it is NOT part of Store and NOT forwarded by
+// NewPrefixStore — callers type-assert and degrade ("unswept") when the
+// backing store cannot answer.
+type SizeLister interface {
+ ListSizes(ctx context.Context, prefix string) ([]KeySize, error)
+}
+
+// Both real stores answer ListSizes; only fakes fall back.
+var (
+ _ SizeLister = (*DirStore)(nil)
+ _ SizeLister = (*S3Store)(nil)
+)
+
// ---- DirStore ---------------------------------------------------------------
// DirStore keeps objects as files under a root directory — the test double
@@ -307,6 +327,48 @@ func (d *DirStore) ListMod(_ context.Context, prefix string) ([]KeyMod, error) {
return out, err
}
+// ListSizes mirrors ListMod's walk, collecting each file's byte size instead
+// of its mtime.
+func (d *DirStore) ListSizes(_ context.Context, prefix string) ([]KeySize, error) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ var out []KeySize
+ err := filepath.WalkDir(d.root, func(p string, de fs.DirEntry, err error) error {
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil // an empty store lists empty, like an empty bucket
+ }
+ return err
+ }
+ if de.IsDir() || strings.HasSuffix(p, ".tmp") {
+ return nil
+ }
+ rel, err := filepath.Rel(d.root, p)
+ if err != nil {
+ return err
+ }
+ key := filepath.ToSlash(rel)
+ if !strings.HasPrefix(key, prefix) {
+ return nil
+ }
+ info, err := de.Info()
+ if err != nil {
+ // Vanished between readdir and stat — another process's delete
+ // racing this walk (in-process writers all hold d.mu). A file
+ // that is gone is not listed; it does not break the listing.
+ // Untestable deterministically, so pinned by comment alone.
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ return err
+ }
+ out = append(out, KeySize{Key: key, Size: info.Size()})
+ return nil
+ })
+ sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
+ return out, err
+}
+
// ListDirs is DirStore's delimiter-aware listing: one directory read, no
// walk. A missing directory lists empty, like a bucket prefix with nothing
// under it. Only directories are returned, so a file sitting at this level
@@ -498,6 +560,26 @@ func (s *S3Store) ListMod(ctx context.Context, prefix string) ([]KeyMod, error)
return out, nil
}
+// ListSizes mirrors ListMod's paginator loop, collecting each object's byte
+// size instead of its LastModified stamp.
+func (s *S3Store) ListSizes(ctx context.Context, prefix string) ([]KeySize, error) {
+ var out []KeySize
+ p := awss3.NewListObjectsV2Paginator(s.Client, &awss3.ListObjectsV2Input{
+ Bucket: &s.Bucket,
+ Prefix: &prefix,
+ })
+ for p.HasMorePages() {
+ page, err := p.NextPage(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for _, o := range page.Contents {
+ out = append(out, KeySize{Key: aws.ToString(o.Key), Size: aws.ToInt64(o.Size)})
+ }
+ }
+ return out, nil
+}
+
// ListDirs is the Delimiter:"/" listing: S3 rolls everything below one level
// up into a CommonPrefix, so the response is bounded by the number of child
// prefixes rather than by the total object count beneath them. Objects AT
diff --git a/internal/release/store_test.go b/internal/release/store_test.go
index 539311e72..c0bbe99a5 100644
--- a/internal/release/store_test.go
+++ b/internal/release/store_test.go
@@ -217,6 +217,32 @@ func TestDirStoreListMod(t *testing.T) {
}
}
+func TestDirStoreListSizes(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := s.Put(ctx, "apps/bab/hello/one.bin", []byte("12345"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, "apps/bab/hello/two.bin", []byte("1234567890"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, "apps/other/x.bin", []byte("1"), ""); err != nil {
+ t.Fatal(err)
+ }
+ sizes, err := s.ListSizes(ctx, "apps/bab/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ total := int64(0)
+ for _, ks := range sizes {
+ total += ks.Size
+ }
+ if len(sizes) != 2 || total != 15 {
+ t.Fatalf("sizes = %+v", sizes)
+ }
+ var _ SizeLister = s // interface satisfaction
+}
+
// TestS3StoreLive exercises the production store against a real bucket —
// only when CARLOS_TEST_S3_BUCKET is set; the type otherwise just compiles.
func TestS3StoreLive(t *testing.T) {