carlos / platform Public

fix-reconcile-listing

Squashed from fix-reconcile-listing into main.
claude agent, operated by paul@keymail.dev 91bb1a2ea13b8de969dfe9ad3cbe61e40374c01b parent b2a76df
5 files changed, +496 −6
  • internal/host/steering_loop_test.go +10 −0
  • internal/provision/applisting_test.go +337 −0
  • internal/provision/reconcile.go +117 −5
  • internal/provision/sidecar_reconcile.go +4 −1
  • internal/release/store.go +28 −0

CI passed — run details

diff --git a/internal/host/steering_loop_test.go b/internal/host/steering_loop_test.go
index 3cf390200..6e2ba9add 100644
--- a/internal/host/steering_loop_test.go
+++ b/internal/host/steering_loop_test.go
@@ -33,6 +33,16 @@ func (f failingListStore) List(ctx context.Context, prefix string) ([]string, er
return f.Store.List(ctx, prefix)
}
+// ListDirs fails the same prefix: the record readers walk apps/ by its
+// directories now (provision.listAppSubtree), so "the apps listing fails"
+// is a ListDirs of apps/ failing as much as a List of it.
+func (f failingListStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ if prefix == f.prefix {
+ return nil, f.err
+ }
+ return f.Store.ListDirs(ctx, prefix)
+}
+
// failingGetStore lists fine but refuses ONE named key — the half-read that
// would otherwise fold to a short desired set and delete the missing box's
// records.
diff --git a/internal/provision/applisting_test.go b/internal/provision/applisting_test.go
new file mode 100644
index 000000000..71a61c3af
--- /dev/null
+++ b/internal/provision/applisting_test.go
@@ -0,0 +1,337 @@
+package provision
+
+import (
+ "context"
+ "errors"
+ "reflect"
+ "slices"
+ "sort"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// bucketShaped is a store that promises bucket-shaped ListDirs, as the S3
+// store does, and records every listing asked of it. DirStore's own ListDirs
+// has that shape; it just does not advertise it.
+type bucketShaped struct {
+ release.Store
+ mu sync.Mutex
+ lists []string
+ dirs []string
+ // failDirs and failList fail one prefix each.
+ failDirs, failList string
+}
+
+func (s *bucketShaped) BucketShapedListDirs() bool { return true }
+
+func (s *bucketShaped) List(ctx context.Context, prefix string) ([]string, error) {
+ s.mu.Lock()
+ s.lists = append(s.lists, prefix)
+ s.mu.Unlock()
+ if prefix == s.failList {
+ return nil, errors.New("list refused")
+ }
+ return s.Store.List(ctx, prefix)
+}
+
+func (s *bucketShaped) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ s.mu.Lock()
+ s.dirs = append(s.dirs, prefix)
+ s.mu.Unlock()
+ if prefix == s.failDirs {
+ return nil, errors.New("listdirs refused")
+ }
+ return s.Store.ListDirs(ctx, prefix)
+}
+
+func (s *bucketShaped) listedAllOfApps() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, p := range s.lists {
+ if p == "apps/" {
+ return true
+ }
+ }
+ return false
+}
+
+// fleetShaped answers ListDirs the way the fleet door does: scoped to one
+// account, ListDirs("apps/") is that account's APP directories. It makes no
+// bucket-shape promise, which is the point.
+type fleetShaped struct {
+ release.Store
+ acct string
+}
+
+func (s fleetShaped) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ if prefix == "apps/" {
+ return s.Store.ListDirs(ctx, "apps/"+s.acct+"/")
+ }
+ return s.Store.ListDirs(ctx, prefix)
+}
+
+// A bucket with everything a production one holds under apps/: records of
+// both kinds, the logs and release artifacts that dwarf them, an account that
+// has not materialized, a legacy account-less app, and apps named like the
+// release plane's own words.
+func seedAppListingBucket(t *testing.T) release.Store {
+ t.Helper()
+ s := release.NewDirStore(t.TempDir())
+ for _, k := range []string{
+ "apps/bab/shop/instances/a.example.com.json",
+ "apps/bab/shop/instances/b.example.com.json",
+ "apps/bab/shop/instances.json",
+ "apps/bab/shop/sidecars/bot.json",
+ "apps/bab/shop/logs/2026/10/05/chunk-1.jsonl",
+ "apps/bab/shop/logs/2026/10/05/chunk-2.jsonl",
+ "apps/bab/shop/releases/v1/manifest.json",
+ "apps/bab/shop/channels/edge.json",
+ "apps/bab/blog/instances/c.example.com.json",
+ "apps/bab/blog/instances/nested/too/deep.json",
+ "apps/bad/site/sidecars/w.json",
+ "apps/bad/site/instances/d.example.com.json",
+ "apps/zzz/ghost/instances/e.example.com.json", // account not materialized
+ "apps/legacyapp/channels/edge.json", // account-less
+ "apps/bab/channels/instances/f.example.com.json",
+ "apps/bab/releases/instances/g.example.com.json",
+ } {
+ if _, err := s.Put(context.Background(), k, []byte(`{}`), ""); err != nil {
+ t.Fatalf("seed %s: %v", k, err)
+ }
+ }
+ return s
+}
+
+func parsedRecords(keys []string, parse func(string) bool) []string {
+ var out []string
+ for _, k := range keys {
+ if parse(k) {
+ out = append(out, k)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// On a bucket-shaped store the walk finds exactly the records the full
+// listing did, for both record kinds, and never lists the whole of apps/.
+func TestAppSubtreeWalkMatchesTheFullListing(t *testing.T) {
+ inner := seedAppListingBucket(t)
+ known := map[string]bool{"bab": true, "bad": true}
+ all, err := inner.List(context.Background(), "apps/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range []struct {
+ sub string
+ parse func(string) bool
+ }{
+ {"instances/", func(k string) bool { _, _, _, ok := ParseRecordKey(k, known); return ok }},
+ {"sidecars/", func(k string) bool { _, _, _, ok := ParseSidecarKey(k, known); return ok }},
+ } {
+ s := &bucketShaped{Store: inner}
+ got, partial, err := listAppSubtree(context.Background(), s, known, c.sub)
+ if err != nil || partial {
+ t.Fatalf("%s: partial=%v err=%v", c.sub, partial, err)
+ }
+ if g, w := parsedRecords(got, c.parse), parsedRecords(all, c.parse); !reflect.DeepEqual(g, w) {
+ t.Fatalf("%s: the walk found %v, the full listing %v", c.sub, g, w)
+ }
+ for _, p := range s.lists {
+ if !strings.HasSuffix(p, "/"+c.sub) {
+ t.Fatalf("%s: listed %q — the walk lists only <app>/%s prefixes", c.sub, p, c.sub)
+ }
+ }
+ }
+}
+
+// A store that makes no bucket-shape promise is listed the old way. The fleet
+// door's ListDirs("apps/") is its account's app directories; walked as if it
+// were account directories, it yields no records at all, which reconcile
+// would read as every route retired.
+func TestAppSubtreeListsTheOldWayWithoutTheBucketShape(t *testing.T) {
+ fleet := fleetShaped{Store: seedAppListingBucket(t), acct: "bab"}
+ known := map[string]bool{"bab": true}
+ parse := func(k string) bool { _, _, _, ok := ParseRecordKey(k, known); return ok }
+ got, partial, err := listAppSubtree(context.Background(), fleet, known, "instances/")
+ if err != nil || partial {
+ t.Fatalf("partial=%v err=%v", partial, err)
+ }
+ want := []string{
+ "apps/bab/blog/instances/c.example.com.json",
+ "apps/bab/shop/instances/a.example.com.json",
+ "apps/bab/shop/instances/b.example.com.json",
+ }
+ if g := parsedRecords(got, parse); !reflect.DeepEqual(g, want) {
+ t.Fatalf("records on a fleet-shaped store = %v, want %v", g, want)
+ }
+ if release.HasBucketListDirs(release.NewDirStore(t.TempDir())) {
+ t.Fatal("DirStore promises bucket-shaped ListDirs; it walks its whole root per List, so it must be listed the old way")
+ }
+}
+
+// One account's or one app's listing failing is partial, not fatal: the rest
+// still come back, and the caller is told some were not seen.
+func TestAppSubtreeOneListingFailingIsPartial(t *testing.T) {
+ known := map[string]bool{"bab": true, "bad": true}
+ parse := func(k string) bool { _, _, _, ok := ParseRecordKey(k, known); return ok }
+ for _, c := range []struct {
+ name string
+ s *bucketShaped
+ want []string
+ }{
+ {"an app", &bucketShaped{failList: "apps/bab/blog/instances/"}, []string{
+ "apps/bab/shop/instances/a.example.com.json",
+ "apps/bab/shop/instances/b.example.com.json",
+ "apps/bad/site/instances/d.example.com.json",
+ }},
+ {"an account", &bucketShaped{failDirs: "apps/bab/"}, []string{
+ "apps/bad/site/instances/d.example.com.json",
+ }},
+ } {
+ c.s.Store = seedAppListingBucket(t)
+ got, partial, err := listAppSubtree(context.Background(), c.s, known, "instances/")
+ if err != nil {
+ t.Fatalf("%s: one failed listing failed the walk: %v", c.name, err)
+ }
+ if !partial {
+ t.Fatalf("%s: a failed listing was not reported as partial", c.name)
+ }
+ if g := parsedRecords(got, parse); !reflect.DeepEqual(g, c.want) {
+ t.Fatalf("%s: records = %v, want %v", c.name, g, c.want)
+ }
+ }
+}
+
+// All three record readers, on a bucket-shaped store with real records: they
+// find the records, mint the same route they mint off a plain store, and
+// never list the whole of apps/.
+func TestRecordReadersWalkABucketShapedStore(t *testing.T) {
+ inner, sqid := setup(t)
+ if err := CreateRecord(ctx, inner, acmeRecord(sqid)); err != nil {
+ t.Fatal(err)
+ }
+ cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}
+
+ plainReg := regWith(t)
+ if err := Reconcile(ctx, inner, plainReg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ s := &bucketShaped{Store: inner}
+ reg := regWith(t)
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ want, got := *mustGet(t, plainReg, "acme.go.tito.io"), *mustGet(t, reg, "acme.go.tito.io")
+ want.CreatedAt, got.CreatedAt = 0, 0
+ if got != want {
+ t.Fatalf("minted over the walk = %+v, over the full listing %+v", got, want)
+ }
+
+ recs, err := ListAllRecords(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(recs) != 1 || recs[0].Host != "acme.go.tito.io" {
+ t.Fatalf("ListAllRecords = %+v, want the one acme record", recs)
+ }
+ if err := ReconcileSidecars(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if s.listedAllOfApps() {
+ t.Fatalf("a record reader listed the whole of apps/: %v", s.lists)
+ }
+}
+
+// Reconcile treats a partial walk as it treats an unreadable record: the
+// routes it could not see are not retired.
+func TestReconcileKeepsRoutesItCouldNotList(t *testing.T) {
+ inner, sqid := setup(t)
+ if err := CreateRecord(ctx, inner, acmeRecord(sqid)); err != nil {
+ t.Fatal(err)
+ }
+ cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}
+ reg := regWith(t)
+ if err := Reconcile(ctx, inner, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ s := &bucketShaped{Store: inner, failList: RecordsPrefix(sqid, "tito")}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt == nil {
+ t.Fatalf("a route whose records could not be listed was removed: %+v, %v", rt, err)
+ }
+}
+
+// ListAllRecords refuses a partial walk rather than report a short record
+// set: the moves and steering passes act on what it returns, and a missing
+// record reads to them as an instance that no longer exists.
+func TestListAllRecordsRefusesAPartialWalk(t *testing.T) {
+ inner, sqid := setup(t)
+ if err := CreateRecord(ctx, inner, acmeRecord(sqid)); err != nil {
+ t.Fatal(err)
+ }
+ s := &bucketShaped{Store: inner, failList: RecordsPrefix(sqid, "tito")}
+ if recs, err := ListAllRecords(ctx, s); err == nil {
+ t.Fatalf("ListAllRecords over a failed app listing = %+v, nil; want an error", recs)
+ }
+}
+
+// The sidecar reader over the walk mints the sidecar it mints over the full
+// listing, and keeps it on the next pass. Returning no sidecar keys at all
+// would otherwise pass every other test here.
+func TestSidecarReconcileOverTheWalkMintsAndKeeps(t *testing.T) {
+ f := sidecarSetup(t)
+ f.putManifest(t, "eleven", SidecarManifest{
+ Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}, Channel: "stable"})
+ f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", Channel: "stable"})
+ s := &bucketShaped{Store: f.store}
+ cfg := ReconcileConfig{DataDir: f.dir}
+ for pass := 1; pass <= 2; pass++ {
+ if err := ReconcileSidecars(ctx, s, f.reg, cfg); err != nil {
+ t.Fatalf("pass %d: %v", pass, err)
+ }
+ hasRoute(t, f.reg, f.host("bot", "eleven"), "the walk lost the sidecar")
+ }
+ if s.listedAllOfApps() {
+ t.Fatalf("the sidecar reader listed the whole of apps/: %v", s.lists)
+ }
+}
+
+// reversedList answers List in reverse order and makes no bucket-shape
+// promise: Store.List does not promise an order.
+type reversedList struct{ release.Store }
+
+func (s reversedList) List(ctx context.Context, prefix string) ([]string, error) {
+ keys, err := s.Store.List(ctx, prefix)
+ slices.Reverse(keys)
+ return keys, err
+}
+
+// ListAllRecords returns records in a stable order whatever order the store
+// lists in, on the fallback path as on the walk.
+func TestListAllRecordsIsSortedOnTheFallback(t *testing.T) {
+ inner, sqid := setup(t)
+ for _, slug := range []string{"acme", "zeta", "beta"} {
+ rec := acmeRecord(sqid)
+ rec.Host, rec.Slug = slug+".go.tito.io", slug
+ if err := CreateRecord(ctx, inner, rec); err != nil {
+ t.Fatal(err)
+ }
+ }
+ recs, err := ListAllRecords(ctx, reversedList{inner})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var hosts []string
+ for _, r := range recs {
+ hosts = append(hosts, r.Host)
+ }
+ if !slices.IsSorted(hosts) {
+ t.Fatalf("ListAllRecords over a reverse-listing store = %v, want sorted", hosts)
+ }
+}
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index d6a55ef1b..b77b5e50e 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -21,6 +21,7 @@ import (
"os"
"slices"
"strings"
+ "sync"
"time"
"github.com/carlosframework/platform/internal/domains"
@@ -716,10 +717,13 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
}
// --- collect in-scope records --------------------------------------------
- keys, err := s.List(ctx, "apps/")
+ keys, partial, err := listAppSubtree(ctx, s, known, "instances/")
if err != nil {
return fmt.Errorf("provision: list apps: %w", err)
}
+ if partial {
+ readFailed = true // unseen records must not read as retired
+ }
recs := map[string]Record{} // host -> record, in-scope only
// protect holds hosts whose record KEY parsed (ParseRecordKey succeeded)
// but whose body could not be trusted — unparseable JSON, or a body that
@@ -1323,13 +1327,15 @@ func ListAllRecords(ctx context.Context, s release.Store) ([]Record, error) {
if readFailed {
return nil, errors.New("provision: an account manifest could not be read cleanly — refusing to report a partial record set")
}
- keys, err := s.List(ctx, "apps/")
+ // Sorted (listAppSubtree sorts) so the result — and anything folded
+ // from it — is stable call to call, whatever order the store lists in.
+ keys, partial, err := listAppSubtree(ctx, s, known, "instances/")
if err != nil {
return nil, fmt.Errorf("provision: list apps: %w", err)
}
- // Sorted so the result — and anything folded from it — is stable
- // call to call, whatever order the store lists in.
- slices.Sort(keys)
+ if partial {
+ return nil, errors.New("provision: an app's records could not be listed — refusing to report a partial record set")
+ }
var recs []Record
for _, key := range keys {
acct, app, host, ok := ParseRecordKey(key, known)
@@ -1355,6 +1361,112 @@ func ListAllRecords(ctx context.Context, s release.Store) ([]Record, error) {
return recs, nil
}
+// listAppSubtree returns the keys a record reader needs to see: every key
+// under apps/<acct>/<app>/<sub> for each known account and each of its apps,
+// sorted — or, on a store that cannot be walked that way, every key under
+// apps/, as the readers always listed. The callers parse every key exactly as
+// before (ParseRecordKey, ParseSidecarKey); this changes only which keys they
+// are shown.
+//
+// It exists because List(ctx, "apps/") returns every object under every app —
+// every release artifact and every log chunk — to find the records among
+// them. On the flagship on 2026-10-05 apps/ held 59,288 objects, 50,695 of
+// them logs, for 152 instance records: sixty list pages per reader, three
+// readers on every serial bump, and most of why a box took 16 to 43 seconds
+// to notice a promote (#231). The walk is one ListDirs of apps/, one per
+// known account, and one small List per app.
+//
+// It finds the same records the full listing did. A record's account is
+// always one of known (the parsers refuse any other), and its app is a
+// directory directly under the account, so no record lives anywhere the walk
+// does not look — PROVIDED ListDirs answers in the bucket's shape. Only a
+// store that says so (release.HasBucketListDirs: the S3 store, and a prefix
+// store over one) is walked; the fleet door, whose ListDirs("apps/") is its
+// own account's app directories, and DirStore, which lists by walking its
+// whole root, are listed the old way. Assuming the shape on the fleet door
+// found no records at all, and the reconcile pass would have read that as
+// every route retired.
+//
+// Failure comes in two sizes. The top-level listing failing is an error, as
+// the single List failing was. One account's or one app's listing failing is
+// PARTIAL: the keys that were listed come back, and partial reports that some
+// were not. The callers treat that as they treat a record they could not
+// read — removal is vetoed for the whole pass, everything that was seen still
+// converges — because a partial record set must never read as "the bucket
+// retired the rest", and one app's bad minute must not stop every other app's
+// routes from being minted.
+func listAppSubtree(ctx context.Context, s release.Store, known map[string]bool, sub string) (keys []string, partial bool, err error) {
+ if !release.HasBucketListDirs(s) {
+ keys, err := s.List(ctx, "apps/")
+ slices.Sort(keys) // Store.List promises no order; the walk's is sorted
+ return keys, false, err
+ }
+ accts, err := s.ListDirs(ctx, "apps/")
+ if err != nil {
+ return nil, false, err
+ }
+ var acctPrefixes []string
+ for _, ap := range accts {
+ if known[strings.TrimSuffix(strings.TrimPrefix(ap, "apps/"), "/")] {
+ acctPrefixes = append(acctPrefixes, ap)
+ }
+ }
+
+ var (
+ mu sync.Mutex
+ prefixes []string
+ out []string
+ )
+ fail := func(what string, err error) {
+ log.Printf("provision: listing %s: %v — its records are unseen this pass", what, err)
+ partial = true
+ }
+ // A handful at a time, accounts and apps alike: each is one small round
+ // trip, and a box walks a few dozen accounts and a few hundred apps.
+ forEach(acctPrefixes, func(ap string) {
+ apps, err := s.ListDirs(ctx, ap)
+ mu.Lock()
+ defer mu.Unlock()
+ if err != nil {
+ fail(ap, err)
+ return
+ }
+ for _, p := range apps {
+ prefixes = append(prefixes, p+sub)
+ }
+ })
+ forEach(prefixes, func(p string) {
+ keys, err := s.List(ctx, p)
+ mu.Lock()
+ defer mu.Unlock()
+ if err != nil {
+ fail(p, err)
+ return
+ }
+ out = append(out, keys...)
+ })
+ slices.Sort(out)
+ return out, partial, nil
+}
+
+// forEach runs f over items, listWalkers at a time, and returns when all
+// have finished.
+func forEach(items []string, f func(string)) {
+ const listWalkers = 8
+ var wg sync.WaitGroup
+ sem := make(chan struct{}, listWalkers)
+ for _, it := range items {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func(it string) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ f(it)
+ }(it)
+ }
+ wg.Wait()
+}
+
// poolServed reports whether pool is one this box serves. An empty
// cfg.Pools means the shared "" pool ONLY, never "every pool" — the same
// convention domains.Config's AppsDomain=="" carries: unconfigured means the
diff --git a/internal/provision/sidecar_reconcile.go b/internal/provision/sidecar_reconcile.go
index 687dd9349..b8ee42868 100644
--- a/internal/provision/sidecar_reconcile.go
+++ b/internal/provision/sidecar_reconcile.go
@@ -239,10 +239,13 @@ func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Regis
}
// --- collect in-scope records --------------------------------------------
- keys, err := s.List(ctx, "apps/")
+ keys, partial, err := listAppSubtree(ctx, s, known, "sidecars/")
if err != nil {
return fmt.Errorf("provision: list apps: %w", err)
}
+ if partial {
+ readFailed = true // unseen records must not read as retired
+ }
recs := map[string]SidecarRecord{} // host -> record, in-scope only
// protect holds hosts whose record KEY parsed but whose body could not be
// trusted — unparseable JSON, a body disagreeing with its own path, or a
diff --git a/internal/release/store.go b/internal/release/store.go
index c296106f7..93edf1c67 100644
--- a/internal/release/store.go
+++ b/internal/release/store.go
@@ -79,6 +79,30 @@ type prefixStore struct {
base string // always "" or ends in "/"
}
+// BucketListDirs is the optional half of Store that says ListDirs answers in
+// the bucket's own shape: ListDirs("apps/") is the account directories, and
+// ListDirs("apps/<acct>/") that account's app directories, exactly as an S3
+// delimiter listing of the deployment bucket would be. A reader may then walk
+// the tree by its directories instead of listing every key under it.
+//
+// Optional, because not every Store has that shape. The fleet door scopes a
+// box to its own account and answers ListDirs("apps/") with that account's
+// APP directories, and DirStore lists by walking its whole root, so walking
+// it per app is slower than one List. A Store that does not say so is read
+// the old way.
+type BucketListDirs interface {
+ BucketShapedListDirs() bool
+}
+
+// HasBucketListDirs reports whether s promises bucket-shaped ListDirs.
+func HasBucketListDirs(s Store) bool {
+ b, ok := s.(BucketListDirs)
+ return ok && b.BucketShapedListDirs()
+}
+
+// BucketShapedListDirs: rebasing keeps the shape of the store underneath.
+func (p prefixStore) BucketShapedListDirs() bool { return HasBucketListDirs(p.Store) }
+
// NewPrefixStore wraps s so every key is rebased under prefix. An empty
// prefix returns s UNCHANGED (not a prefixStore with an empty base) — every
// deployment that never sets the namespacing env var (every platform box
@@ -739,6 +763,10 @@ func (s *S3Store) ListSizes(ctx context.Context, prefix string) ([]KeySize, erro
return out, nil
}
+// BucketShapedListDirs: S3Store IS the deployment bucket, and its ListDirs
+// is the delimiter listing of it.
+func (s *S3Store) BucketShapedListDirs() bool { return true }
+
// 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