Skip to content

Commit 031a864

Browse files
authored
refactor: use pagination for ListObjects to reduce memory usage (#12)
Fixes #11 - Add ListObjectsPaginated method with MaxKeys limit and continuation token - Add ListObjectsChannel method for streaming object iteration - Update BrowseBucket to use pagination (100 objects per page) - Update DeleteFolder to stream objects instead of loading all into memory - Update DownloadZip to stream objects instead of loading all into memory - Add pagination types: ListObjectsOptions, ListObjectsResult - Update mocks and journey tests for new methods
1 parent 7ab376d commit 031a864

File tree

5 files changed

+218
-56
lines changed

5 files changed

+218
-56
lines changed

cmd/server/bucket_journey_test.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,13 @@ func TestObjectBrowserJourney(t *testing.T) {
109109
creds := services.Credentials{Endpoint: "play.minio.io:9000", AccessKey: "admin", SecretKey: "password"}
110110
mockFactory.On("NewClient", creds).Return(mockClient, nil)
111111

112-
// Mock Object Operations
113-
mockClient.On("ListObjects", mock.Anything, "my-bucket", mock.Anything).Return([]minio.ObjectInfo{
114-
{Key: "file1.txt", Size: 123, LastModified: time.Now()},
112+
// Mock Object Operations - use ListObjectsPaginated for BrowseBucket
113+
mockClient.On("ListObjectsPaginated", mock.Anything, "my-bucket", mock.Anything).Return(services.ListObjectsResult{
114+
Objects: []minio.ObjectInfo{
115+
{Key: "file1.txt", Size: 123, LastModified: time.Now()},
116+
},
117+
IsTruncated: false,
118+
NextContinuationToken: "",
115119
}, nil)
116120
mockClient.On("PutObject", mock.Anything, "my-bucket", "testfile.txt", mock.Anything, mock.Anything, mock.Anything).Return(minio.UploadInfo{}, nil)
117121

@@ -161,14 +165,16 @@ func TestZipDownloadJourney(t *testing.T) {
161165
creds := services.Credentials{Endpoint: "play.minio.io:9000", AccessKey: "admin", SecretKey: "password"}
162166
mockFactory.On("NewClient", creds).Return(mockClient, nil)
163167

164-
// Mock listing objects in a folder
165-
mockClient.On("ListObjects", mock.Anything, "my-bucket", mock.MatchedBy(func(opts minio.ListObjectsOptions) bool {
168+
// Mock listing objects in a folder using channel-based streaming
169+
objectsChan := make(chan minio.ObjectInfo, 3)
170+
objectsChan <- minio.ObjectInfo{Key: "folder/file1.txt", Size: 13, LastModified: time.Now()}
171+
objectsChan <- minio.ObjectInfo{Key: "folder/file2.txt", Size: 13, LastModified: time.Now()}
172+
objectsChan <- minio.ObjectInfo{Key: "folder/subfolder/file3.txt", Size: 17, LastModified: time.Now()}
173+
close(objectsChan)
174+
175+
mockClient.On("ListObjectsChannel", mock.Anything, "my-bucket", mock.MatchedBy(func(opts minio.ListObjectsOptions) bool {
166176
return opts.Prefix == "folder/" && opts.Recursive == true
167-
})).Return([]minio.ObjectInfo{
168-
{Key: "folder/file1.txt", Size: 13, LastModified: time.Now()},
169-
{Key: "folder/file2.txt", Size: 13, LastModified: time.Now()},
170-
{Key: "folder/subfolder/file3.txt", Size: 17, LastModified: time.Now()},
171-
}, nil)
177+
})).Return((<-chan minio.ObjectInfo)(objectsChan))
172178

173179
// Mock getting each object's content
174180
mockClient.On("GetObjectReader", mock.Anything, "my-bucket", "folder/file1.txt", mock.Anything).

cmd/server/mocks_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,16 @@ func (m *MockMinioClient) ListObjects(ctx context.Context, bucketName string, op
152152
return args.Get(0).([]minio.ObjectInfo), args.Error(1)
153153
}
154154

155+
func (m *MockMinioClient) ListObjectsPaginated(ctx context.Context, bucketName string, opts services.ListObjectsOptions) (services.ListObjectsResult, error) {
156+
args := m.Called(ctx, bucketName, opts)
157+
return args.Get(0).(services.ListObjectsResult), args.Error(1)
158+
}
159+
160+
func (m *MockMinioClient) ListObjectsChannel(ctx context.Context, bucketName string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo {
161+
args := m.Called(ctx, bucketName, opts)
162+
return args.Get(0).(<-chan minio.ObjectInfo)
163+
}
164+
155165
func (m *MockMinioClient) PutObject(ctx context.Context, bucketName, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) {
156166
args := m.Called(ctx, bucketName, objectName, reader, objectSize, opts)
157167
return args.Get(0).(minio.UploadInfo), args.Error(1)

internal/handlers/buckets_handler.go

Lines changed: 47 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ func (h *BucketsHandler) DeleteBucket(c echo.Context) error {
152152
return c.NoContent(http.StatusOK)
153153
}
154154

155-
// BrowseBucket renders the object browser with folder support
155+
// BrowseBucket renders the object browser with folder support and pagination
156156
func (h *BucketsHandler) BrowseBucket(c echo.Context) error {
157157
creds, err := GetCredentialsOrRedirect(c)
158158
if err != nil {
@@ -161,16 +161,19 @@ func (h *BucketsHandler) BrowseBucket(c echo.Context) error {
161161

162162
bucketName := c.Param("bucketName")
163163
prefix := c.QueryParam("prefix")
164+
continuationToken := c.QueryParam("continuation")
164165

165166
client, err := h.minioFactory.NewClient(*creds)
166167
if err != nil {
167168
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to connect to MinIO")
168169
}
169170

170-
// List objects with prefix for folder support
171-
rawObjects, err := client.ListObjects(c.Request().Context(), bucketName, minio.ListObjectsOptions{
172-
Prefix: prefix,
173-
Recursive: false, // Non-recursive to get folders
171+
// List objects with pagination to limit memory usage
172+
result, err := client.ListObjectsPaginated(c.Request().Context(), bucketName, services.ListObjectsOptions{
173+
Prefix: prefix,
174+
Recursive: false, // Non-recursive to get folders
175+
MaxKeys: services.DefaultPageSize,
176+
ContinuationToken: continuationToken,
174177
})
175178
if err != nil {
176179
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list objects")
@@ -180,7 +183,7 @@ func (h *BucketsHandler) BrowseBucket(c echo.Context) error {
180183
var folders []models.FolderInfo
181184
seenFolders := make(map[string]bool)
182185

183-
for _, obj := range rawObjects {
186+
for _, obj := range result.Objects {
184187
// Check if it's a folder (ends with /)
185188
if strings.HasSuffix(obj.Key, "/") {
186189
folderName := strings.TrimPrefix(obj.Key, prefix)
@@ -235,12 +238,14 @@ func (h *BucketsHandler) BrowseBucket(c echo.Context) error {
235238
}
236239

237240
return c.Render(http.StatusOK, "browser", map[string]interface{}{
238-
"ActiveNav": "buckets",
239-
"BucketName": bucketName,
240-
"Prefix": prefix,
241-
"Objects": objects,
242-
"Folders": folders,
243-
"Breadcrumbs": breadcrumbs,
241+
"ActiveNav": "buckets",
242+
"BucketName": bucketName,
243+
"Prefix": prefix,
244+
"Objects": objects,
245+
"Folders": folders,
246+
"Breadcrumbs": breadcrumbs,
247+
"HasMore": result.IsTruncated,
248+
"NextContinuationToken": result.NextContinuationToken,
244249
})
245250
}
246251

@@ -387,7 +392,7 @@ func (h *BucketsHandler) CreateFolder(c echo.Context) error {
387392
return HTMXRedirect(c, "/buckets/"+bucketName+"?prefix="+prefix)
388393
}
389394

390-
// DeleteFolder deletes a folder and all its contents
395+
// DeleteFolder deletes a folder and all its contents using streaming
391396
func (h *BucketsHandler) DeleteFolder(c echo.Context) error {
392397
creds, err := GetCredentials(c)
393398
if err != nil {
@@ -402,17 +407,17 @@ func (h *BucketsHandler) DeleteFolder(c echo.Context) error {
402407
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to connect to MinIO")
403408
}
404409

405-
// List all objects with this prefix
406-
objectsList, err := client.ListObjects(c.Request().Context(), bucketName, minio.ListObjectsOptions{
410+
// Stream objects and delete one at a time to avoid loading all into memory
411+
objectsChan := client.ListObjectsChannel(c.Request().Context(), bucketName, minio.ListObjectsOptions{
407412
Prefix: prefix,
408413
Recursive: true,
409414
})
410-
if err != nil {
411-
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list objects")
412-
}
413415

414-
// Delete all objects
415-
for _, obj := range objectsList {
416+
// Delete objects as they stream in
417+
for obj := range objectsChan {
418+
if obj.Err != nil {
419+
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list objects: "+obj.Err.Error())
420+
}
416421
err := client.RemoveObject(c.Request().Context(), bucketName, obj.Key, minio.RemoveObjectOptions{})
417422
if err != nil {
418423
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete object: "+obj.Key)
@@ -422,7 +427,7 @@ func (h *BucketsHandler) DeleteFolder(c echo.Context) error {
422427
return c.NoContent(http.StatusOK)
423428
}
424429

425-
// DownloadZip streams a folder as a ZIP archive
430+
// DownloadZip streams a folder as a ZIP archive using streaming object listing
426431
func (h *BucketsHandler) DownloadZip(c echo.Context) error {
427432
creds, err := GetCredentialsOrRedirect(c)
428433
if err != nil {
@@ -437,31 +442,9 @@ func (h *BucketsHandler) DownloadZip(c echo.Context) error {
437442
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to connect to MinIO")
438443
}
439444

440-
// List all objects with this prefix recursively
441-
objectsList, err := client.ListObjects(c.Request().Context(), bucketName, minio.ListObjectsOptions{
442-
Prefix: prefix,
443-
Recursive: true,
444-
})
445-
if err != nil {
446-
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list objects")
447-
}
448-
449-
// Filter out folder markers (objects ending with /)
450-
var files []minio.ObjectInfo
451-
for _, obj := range objectsList {
452-
if !strings.HasSuffix(obj.Key, "/") {
453-
files = append(files, obj)
454-
}
455-
}
456-
457-
if len(files) == 0 {
458-
return echo.NewHTTPError(http.StatusNotFound, "No files to download")
459-
}
460-
461445
// Determine ZIP filename from prefix or bucket name
462446
zipName := bucketName + ".zip"
463447
if prefix != "" {
464-
// Remove trailing slash and get the folder name
465448
folderName := strings.TrimSuffix(prefix, "/")
466449
if idx := strings.LastIndex(folderName, "/"); idx >= 0 {
467450
folderName = folderName[idx+1:]
@@ -478,12 +461,26 @@ func (h *BucketsHandler) DownloadZip(c echo.Context) error {
478461
zipWriter := zip.NewWriter(c.Response().Writer)
479462
defer func() { _ = zipWriter.Close() }()
480463

481-
// Add each file to the ZIP
482-
for _, obj := range files {
464+
// Stream objects and add to ZIP one at a time to avoid loading all into memory
465+
objectsChan := client.ListObjectsChannel(c.Request().Context(), bucketName, minio.ListObjectsOptions{
466+
Prefix: prefix,
467+
Recursive: true,
468+
})
469+
470+
fileCount := 0
471+
for obj := range objectsChan {
472+
if obj.Err != nil {
473+
continue
474+
}
475+
476+
// Skip folder markers (objects ending with /)
477+
if strings.HasSuffix(obj.Key, "/") {
478+
continue
479+
}
480+
483481
// Get the file content
484482
reader, _, err := client.GetObjectReader(c.Request().Context(), bucketName, obj.Key, minio.GetObjectOptions{})
485483
if err != nil {
486-
// Log error but continue with other files
487484
continue
488485
}
489486

@@ -501,8 +498,12 @@ func (h *BucketsHandler) DownloadZip(c echo.Context) error {
501498
if err != nil {
502499
continue
503500
}
501+
fileCount++
504502
}
505503

504+
// Note: If fileCount == 0, headers are already sent so we can't return an error.
505+
// The ZIP will just be empty, which is acceptable behavior.
506+
506507
return nil
507508
}
508509

internal/services/minio_factory.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@ import (
1717
"github.com/minio/minio-go/v7/pkg/tags"
1818
)
1919

20+
// DefaultPageSize is the default number of objects to return per page
21+
const DefaultPageSize = 100
22+
23+
// ListObjectsOptions extends minio.ListObjectsOptions with pagination
24+
type ListObjectsOptions struct {
25+
Prefix string
26+
Recursive bool
27+
MaxKeys int
28+
ContinuationToken string
29+
}
30+
31+
// ListObjectsResult contains paginated results from ListObjectsPaginated
32+
type ListObjectsResult struct {
33+
Objects []minio.ObjectInfo
34+
IsTruncated bool
35+
NextContinuationToken string
36+
}
37+
2038
// MinioAdminClient is an interface for the madmin methods we use
2139
type MinioAdminClient interface {
2240
ServerInfo(ctx context.Context, opts ...func(*madmin.ServerInfoOpts)) (madmin.InfoMessage, error)
@@ -60,6 +78,8 @@ type MinioClient interface {
6078

6179
// Object Operations
6280
ListObjects(ctx context.Context, bucketName string, opts minio.ListObjectsOptions) ([]minio.ObjectInfo, error)
81+
ListObjectsPaginated(ctx context.Context, bucketName string, opts ListObjectsOptions) (ListObjectsResult, error)
82+
ListObjectsChannel(ctx context.Context, bucketName string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo
6383
PutObject(ctx context.Context, bucketName, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error)
6484
GetObject(ctx context.Context, bucketName, objectName string, opts minio.GetObjectOptions) (*minio.Object, error)
6585
GetObjectReader(ctx context.Context, bucketName, objectName string, opts minio.GetObjectOptions) (io.ReadCloser, int64, error)
@@ -124,6 +144,55 @@ func (c *WrappedMinioClient) ListObjects(ctx context.Context, bucketName string,
124144
return objects, nil
125145
}
126146

147+
func (c *WrappedMinioClient) ListObjectsPaginated(ctx context.Context, bucketName string, opts ListObjectsOptions) (ListObjectsResult, error) {
148+
maxKeys := opts.MaxKeys
149+
if maxKeys <= 0 {
150+
maxKeys = DefaultPageSize
151+
}
152+
153+
minioOpts := minio.ListObjectsOptions{
154+
Prefix: opts.Prefix,
155+
Recursive: opts.Recursive,
156+
}
157+
158+
// Use StartAfter for continuation (MinIO uses marker-based pagination)
159+
if opts.ContinuationToken != "" {
160+
minioOpts.StartAfter = opts.ContinuationToken
161+
}
162+
163+
var objects []minio.ObjectInfo
164+
var lastKey string
165+
166+
for obj := range c.client.ListObjects(ctx, bucketName, minioOpts) {
167+
if obj.Err != nil {
168+
return ListObjectsResult{}, obj.Err
169+
}
170+
171+
objects = append(objects, obj)
172+
lastKey = obj.Key
173+
174+
// Stop after maxKeys objects
175+
if len(objects) >= maxKeys {
176+
break
177+
}
178+
}
179+
180+
result := ListObjectsResult{
181+
Objects: objects,
182+
IsTruncated: len(objects) >= maxKeys,
183+
}
184+
185+
if result.IsTruncated {
186+
result.NextContinuationToken = lastKey
187+
}
188+
189+
return result, nil
190+
}
191+
192+
func (c *WrappedMinioClient) ListObjectsChannel(ctx context.Context, bucketName string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo {
193+
return c.client.ListObjects(ctx, bucketName, opts)
194+
}
195+
127196
func (c *WrappedMinioClient) PutObject(ctx context.Context, bucketName, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) {
128197
return c.client.PutObject(ctx, bucketName, objectName, reader, objectSize, opts)
129198
}

0 commit comments

Comments
 (0)