| 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 |