Skip to content

Commit 110f80c

Browse files
committed
Fix pagination for larger buckets
Fixes #90
1 parent d6bf985 commit 110f80c

7 files changed

Lines changed: 549 additions & 47 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ HTTP request
4747

4848
### Key handler categories
4949

50-
- **Views** (`HandleBucketsView`, `HandleBucketView`): Render full-page HTML templates.
50+
- **Views** (`HandleBucketsView`, `HandleBucketView`): Render full-page HTML templates. `HandleBucketView` has two listing paths, picked by `cursorPagingPossible`: the default name-ascending view is listed one page at a time through S3's own `StartAfter`/`MaxKeys` paging (`listObjectPage`), which is why it has no total count; every other view (other sorts, search, `All`, versions) needs the whole prefix and falls back to a scan capped at `maxScanObjects` (`listAllObjects`).
5151
- **CRUD** (`HandleCreateBucket`, `HandleCreateObject`, `HandleDeleteBucket`, `HandleDeleteObject`): REST-ish JSON/form handlers.
5252
- **Bulk** (`HandleBulkDeleteObjects`, `HandleBulkDownloadObjects`): Batch delete or ZIP-stream multiple objects.
5353
- **URL** (`HandleGenerateURL`): Returns presigned S3 download URLs.

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,21 @@ These variables apply to the whole app and are never prefixed:
8787
- `TIMEOUT`: The read and write timeout in seconds (default to `600` - 10 minutes)
8888
- `ROOT_URL`: A root URL prefix if running behind a reverse proxy (defaults to unset)
8989

90+
### Browsing large buckets
91+
92+
The default bucket view asks S3 for one page of objects at a time, so opening a
93+
bucket and stepping through it costs the same whether it holds ten objects or ten
94+
million. Because S3 can only list keys in ascending order and offers no search of
95+
its own, that page-at-a-time listing is possible only for the default view: it is
96+
sorted by name ascending and unsearched. Such a view has no total object count
97+
and no last page to jump to, since S3 never reports how much it did not return.
98+
99+
Sorting by another column, sorting descending, searching or choosing `All` items
100+
per page needs the whole location in memory instead, and so does `SHOW_VERSIONS`.
101+
Those listings stop after 10,000 objects and say so on the page — their counting,
102+
sorting and searching then cover only that many. Narrow the listing down with a
103+
search or by opening a folder to reach the rest.
104+
90105
### Build and Run Locally
91106

92107
1. Run `make build`

internal/app/s3manager/bucket_view.go

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ type listingQuery struct {
2525
PerPage int
2626
ShowAll bool
2727
Search string
28+
// Cursors is the trail of the cursors of the preceding pages, one per page,
29+
// as collected by the pager of a cursor-paged view. Keeping the whole trail
30+
// in the URL rather than just the current cursor is what lets such a view
31+
// still number its pages and step back to the previous one.
32+
Cursors []string
33+
}
34+
35+
// Cursor is the key the current page's listing resumes after, empty on the first
36+
// page.
37+
func (q listingQuery) Cursor() string {
38+
if len(q.Cursors) == 0 {
39+
return ""
40+
}
41+
42+
return q.Cursors[len(q.Cursors)-1]
2843
}
2944

3045
// HandleBucketView shows the details page of a bucket.
@@ -47,6 +62,8 @@ func HandleBucketView(instances S3Instances, templates fs.FS, opts Options) http
4762
ShowVersions bool
4863
VersionsUnavailable bool
4964
ShowMetadata bool
65+
Truncated bool
66+
MaxScanObjects int
5067
}
5168

5269
renderer := newPageRenderer(templates, "bucket.html.tmpl")
@@ -65,21 +82,32 @@ func HandleBucketView(instances S3Instances, templates fs.FS, opts Options) http
6582

6683
query := parseListingQuery(r.URL.Query())
6784
data := pageData{
68-
RootURL: opts.RootURL,
69-
BucketName: bucketName,
70-
CurrentPath: path,
71-
Paths: removeEmptyStrings(strings.Split(path, "/")),
72-
Endpoint: instance.Client.EndpointURL().String(),
73-
AllowDelete: opts.AllowDelete,
74-
CurrentS3: instance,
75-
S3Instances: instances,
76-
SortBy: query.SortBy,
77-
SortOrder: query.SortOrder,
78-
Search: query.Search,
79-
ShowMetadata: opts.ShowMetadata,
85+
RootURL: opts.RootURL,
86+
BucketName: bucketName,
87+
CurrentPath: path,
88+
Paths: removeEmptyStrings(strings.Split(path, "/")),
89+
Endpoint: instance.Client.EndpointURL().String(),
90+
AllowDelete: opts.AllowDelete,
91+
CurrentS3: instance,
92+
S3Instances: instances,
93+
SortBy: query.SortBy,
94+
SortOrder: query.SortOrder,
95+
Search: query.Search,
96+
ShowMetadata: opts.ShowMetadata,
97+
MaxScanObjects: maxScanObjects,
8098
}
8199

82-
objs, versionsShown, err := listObjects(r.Context(), instance.Client, bucketName, path, opts.ListRecursive, opts.ShowVersions)
100+
// The default view is served one page at a time straight from S3, which
101+
// keeps its cost independent of how many objects the bucket holds. Every
102+
// other view needs the whole prefix in memory to do its job.
103+
cursorPaging := cursorPagingPossible(query, opts)
104+
105+
var listing objectListing
106+
if cursorPaging {
107+
listing, err = listObjectPage(r.Context(), instance.Client, bucketName, path, query.Cursor(), opts.ListRecursive, query.PerPage)
108+
} else {
109+
listing, err = listAllObjects(r.Context(), instance.Client, bucketName, path, opts.ListRecursive, opts.ShowVersions)
110+
}
83111
if err != nil {
84112
// A failed listing is reported on the page itself so that the user
85113
// can switch instances or go back instead of being stuck on an
@@ -90,23 +118,43 @@ func HandleBucketView(instances S3Instances, templates fs.FS, opts Options) http
90118
return
91119
}
92120

93-
if versionsShown {
94-
annotateVersionGroups(objs)
95-
}
96-
if query.Search != "" {
97-
objs = filterObjects(objs, query.Search)
121+
if cursorPaging {
122+
data.objectPage = cursorPage(listing, query)
123+
} else {
124+
if listing.VersionsShown {
125+
annotateVersionGroups(listing.Objects)
126+
}
127+
if query.Search != "" {
128+
listing.Objects = filterObjects(listing.Objects, query.Search)
129+
}
130+
data.objectPage = paginateObjects(listing.Objects, query, listing.VersionsShown)
98131
}
99132

100-
data.objectPage = paginateObjects(objs, query, versionsShown)
101-
data.ShowVersions = versionsShown
133+
data.ShowVersions = listing.VersionsShown
134+
data.Truncated = listing.Truncated
102135
// Only warn about unavailable versions when there is content to show;
103136
// an empty bucket legitimately produces an empty versioned listing.
104-
data.VersionsUnavailable = opts.ShowVersions && !versionsShown && len(objs) > 0
137+
data.VersionsUnavailable = opts.ShowVersions && !listing.VersionsShown && len(listing.Objects) > 0
105138

106139
renderer(w, data)
107140
}
108141
}
109142

143+
// cursorPagingPossible reports whether a request can be served by listing only
144+
// the page it shows. S3 lists keys in ascending lexicographic order and can
145+
// resume after a given key, but it cannot sort by anything else, cannot list
146+
// backwards and has no search — so sorting by another column, sorting
147+
// descending, searching and asking for every object at once all still need the
148+
// full listing. So does a versioned listing, whose version groups have to be
149+
// assembled before they can be split into pages.
150+
func cursorPagingPossible(query listingQuery, opts Options) bool {
151+
return !opts.ShowVersions &&
152+
!query.ShowAll &&
153+
query.Search == "" &&
154+
query.SortBy == "key" &&
155+
query.SortOrder == "asc"
156+
}
157+
110158
// parseBucketPath extracts the bucket name and the path within the bucket from
111159
// a bucket view URL.
112160
func parseBucketPath(urlPath string) (string, string, error) {
@@ -127,6 +175,7 @@ func parseListingQuery(params url.Values) listingQuery {
127175
Page: 1,
128176
PerPage: defaultPerPage,
129177
Search: strings.TrimSpace(params.Get("search")),
178+
Cursors: removeEmptyStrings(params["cursor"]),
130179
}
131180

132181
if query.SortBy == "" {

0 commit comments

Comments
 (0)