Browse Source
fix: reduce N+1 queries in S3 versioned object list operations (#7814)
fix: reduce N+1 queries in S3 versioned object list operations (#7814)
* fix: achieve single-scan efficiency for S3 versioned object listing When listing objects in a versioning-enabled bucket, the original code triggered multiple getEntry calls per versioned object (up to 12 with retries), causing excessive 'find' operations visible in Grafana and leading to high memory usage. This fix achieves single-scan efficiency by caching list metadata (size, ETag, mtime, owner) directly in the .versions directory: 1. Add new Extended keys for caching list metadata in .versions dir 2. Update upload/copy/multipart paths to cache metadata when creating versions 3. Update getLatestVersionEntryFromDirectoryEntry to use cached metadata (zero getEntry calls when cache is available) 4. Update updateLatestVersionAfterDeletion to maintain cache consistency Performance improvement for N versioned objects: - Before: N×1 to N×12 find operations per list request - After: 0 extra find operations (all metadata from single scan) This matches the efficiency of normal (non-versioned) object listing. * Update s3api_object_versioning.go * s3api: fix ETag handling for versioned objects and simplify delete marker creation - Add Md5 attribute to synthetic logicalEntry for single-part uploads to ensure filer.ETag() returns correct value in ListObjects response - Simplify delete marker creation by initializing entry directly in mkFile callback - Add bytes and encoding/hex imports for ETag parsing * s3api: preserve default attributes in delete marker mkFile callback Only modify Mtime field instead of replacing the entire Attributes struct, preserving default values like Crtime, FileMode, Uid, and Gid that mkFile initializes. * s3api: fix ETag handling in newListEntry for multipart uploads Prioritize ExtETagKey from Extended attributes before falling back to filer.ETag(). This properly handles multipart upload ETags (format: md5-parts) for versioned objects, where the synthetic entry has cached ETag metadata but no chunks to calculate from. * s3api: reduce code duplication in delete marker creation Extract deleteMarkerExtended map to be reused in both mkFile callback and deleteMarkerEntry construction. * test: add multipart upload versioning tests for ETag verification Add tests to verify that multipart uploaded objects in versioned buckets have correct ETags when listed: - TestMultipartUploadVersioningListETag: Basic multipart upload with 2 parts - TestMultipartUploadMultipleVersionsListETag: Multiple multipart versions - TestMixedSingleAndMultipartVersionsListETag: Mix of single-part and multipart These tests cover a bug where synthetic entries for versioned objects didn't include proper ETag handling for multipart uploads. * test: add delete marker test for multipart uploaded versioned objects TestMultipartUploadDeleteMarkerListBehavior verifies: - Delete marker creation hides object from ListObjectsV2 - ListObjectVersions shows both version and delete marker - Version ETag (multipart format) is preserved after delete marker - Object can be accessed by version ID after delete marker - Removing delete marker restores object visibility * refactor: address code review feedback - test: use assert.ElementsMatch for ETag verification (more idiomatic) - s3api: optimize newListEntry ETag logic (check ExtETagKey first) - s3api: fix edge case in ETag parsing (>= 2 instead of > 2) * s3api: prevent stale cached metadata and preserve existing extended attrs - setCachedListMetadata: clear old cached keys before setting new values to prevent stale data when new version lacks certain fields (e.g., owner) - createDeleteMarker: merge extended attributes instead of overwriting to preserve any existing metadata on the entry * s3api: extract clearCachedVersionMetadata to reduce code duplication - clearCachedVersionMetadata: clears only metadata fields (size, mtime, etag, owner, deleteMarker) - clearCachedListMetadata: now reuses clearCachedVersionMetadata + clears ID/filename - setCachedListMetadata: uses clearCachedVersionMetadata (not clearCachedListMetadata because caller has already set ID/filename) * s3api: share timestamp between version entry and cache entry Capture versionMtime once before mkFile and reuse for both: - versionEntry.Attributes.Mtime in the mkFile callback - versionEntryForCache.Attributes.Mtime for list caching This keeps list vs. HEAD LastModified timestamps aligned. * s3api: remove amzAccountId variable shadowing in multipart upload Extract amzAccountId before mkFile callback and reuse in both places, similar to how versionMtime is handled. Avoids confusion from redeclaring the same variable.pull/7817/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 794 additions and 55 deletions
-
521test/s3/versioning/s3_versioning_multipart_test.go
-
25weed/s3api/filer_multipart.go
-
6weed/s3api/s3_constants/extend_key.go
-
13weed/s3api/s3api_object_handlers.go
-
3weed/s3api/s3api_object_handlers_copy.go
-
54weed/s3api/s3api_object_handlers_list.go
-
9weed/s3api/s3api_object_handlers_put.go
-
214weed/s3api/s3api_object_versioning.go
@ -0,0 +1,521 @@ |
|||||
|
package s3api |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"context" |
||||
|
"crypto/md5" |
||||
|
"fmt" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
|
||||
|
"github.com/aws/aws-sdk-go-v2/aws" |
||||
|
"github.com/aws/aws-sdk-go-v2/service/s3" |
||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types" |
||||
|
"github.com/stretchr/testify/assert" |
||||
|
"github.com/stretchr/testify/require" |
||||
|
) |
||||
|
|
||||
|
// TestMultipartUploadVersioningListETag tests that multipart uploaded objects
|
||||
|
// in versioned buckets have correct ETags when listed.
|
||||
|
// This covers a bug where synthetic entries for versioned objects didn't include
|
||||
|
// proper ETag handling for multipart uploads (ETags with format "<md5>-<parts>").
|
||||
|
func TestMultipartUploadVersioningListETag(t *testing.T) { |
||||
|
client := getS3Client(t) |
||||
|
bucketName := getNewBucketName() |
||||
|
|
||||
|
// Create bucket
|
||||
|
createBucket(t, client, bucketName) |
||||
|
defer deleteBucket(t, client, bucketName) |
||||
|
|
||||
|
// Enable versioning
|
||||
|
_, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
VersioningConfiguration: &types.VersioningConfiguration{ |
||||
|
Status: types.BucketVersioningStatusEnabled, |
||||
|
}, |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to enable versioning") |
||||
|
|
||||
|
// Create multipart upload
|
||||
|
objectKey := "multipart-test-object" |
||||
|
createResp, err := client.CreateMultipartUpload(context.TODO(), &s3.CreateMultipartUploadInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to create multipart upload") |
||||
|
|
||||
|
uploadId := *createResp.UploadId |
||||
|
|
||||
|
// Upload 2 parts (minimum 5MB per part except last)
|
||||
|
partSize := 5 * 1024 * 1024 // 5MB
|
||||
|
part1Data := bytes.Repeat([]byte("a"), partSize) |
||||
|
part2Data := bytes.Repeat([]byte("b"), partSize) |
||||
|
|
||||
|
// Calculate MD5 for each part
|
||||
|
part1MD5 := md5.Sum(part1Data) |
||||
|
part2MD5 := md5.Sum(part2Data) |
||||
|
|
||||
|
// Upload part 1
|
||||
|
uploadPart1Resp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
UploadId: aws.String(uploadId), |
||||
|
PartNumber: aws.Int32(1), |
||||
|
Body: bytes.NewReader(part1Data), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to upload part 1") |
||||
|
|
||||
|
// Upload part 2
|
||||
|
uploadPart2Resp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
UploadId: aws.String(uploadId), |
||||
|
PartNumber: aws.Int32(2), |
||||
|
Body: bytes.NewReader(part2Data), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to upload part 2") |
||||
|
|
||||
|
// Complete multipart upload
|
||||
|
completeResp, err := client.CompleteMultipartUpload(context.TODO(), &s3.CompleteMultipartUploadInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
UploadId: aws.String(uploadId), |
||||
|
MultipartUpload: &types.CompletedMultipartUpload{ |
||||
|
Parts: []types.CompletedPart{ |
||||
|
{ |
||||
|
ETag: uploadPart1Resp.ETag, |
||||
|
PartNumber: aws.Int32(1), |
||||
|
}, |
||||
|
{ |
||||
|
ETag: uploadPart2Resp.ETag, |
||||
|
PartNumber: aws.Int32(2), |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to complete multipart upload") |
||||
|
|
||||
|
// Verify the ETag from CompleteMultipartUpload has the multipart format (md5-parts)
|
||||
|
completeETag := strings.Trim(*completeResp.ETag, "\"") |
||||
|
assert.Contains(t, completeETag, "-", "Multipart ETag should contain '-' (format: md5-parts)") |
||||
|
assert.True(t, strings.HasSuffix(completeETag, "-2"), "Multipart ETag should end with '-2' for 2 parts") |
||||
|
|
||||
|
t.Logf("CompleteMultipartUpload ETag: %s", completeETag) |
||||
|
t.Logf("Part 1 MD5: %x", part1MD5) |
||||
|
t.Logf("Part 2 MD5: %x", part2MD5) |
||||
|
|
||||
|
// HeadObject should return the same ETag
|
||||
|
headResp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to head object") |
||||
|
|
||||
|
headETag := strings.Trim(*headResp.ETag, "\"") |
||||
|
assert.Equal(t, completeETag, headETag, "HeadObject ETag should match CompleteMultipartUpload ETag") |
||||
|
|
||||
|
// ListObjectsV2 should return the same ETag
|
||||
|
listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Prefix: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to list objects") |
||||
|
require.Len(t, listResp.Contents, 1, "Should have exactly one object") |
||||
|
|
||||
|
listETag := strings.Trim(*listResp.Contents[0].ETag, "\"") |
||||
|
assert.Equal(t, completeETag, listETag, "ListObjectsV2 ETag should match CompleteMultipartUpload ETag") |
||||
|
assert.NotEmpty(t, listETag, "ListObjectsV2 ETag should not be empty") |
||||
|
|
||||
|
t.Logf("ListObjectsV2 ETag: %s", listETag) |
||||
|
|
||||
|
// ListObjectVersions should also return the correct ETag
|
||||
|
versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Prefix: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to list object versions") |
||||
|
require.Len(t, versionsResp.Versions, 1, "Should have exactly one version") |
||||
|
|
||||
|
versionETag := strings.Trim(*versionsResp.Versions[0].ETag, "\"") |
||||
|
assert.Equal(t, completeETag, versionETag, "ListObjectVersions ETag should match CompleteMultipartUpload ETag") |
||||
|
assert.NotEmpty(t, versionETag, "ListObjectVersions ETag should not be empty") |
||||
|
|
||||
|
t.Logf("ListObjectVersions ETag: %s", versionETag) |
||||
|
} |
||||
|
|
||||
|
// TestMultipartUploadMultipleVersionsListETag tests that multiple versions
|
||||
|
// of multipart uploaded objects all have correct ETags when listed.
|
||||
|
func TestMultipartUploadMultipleVersionsListETag(t *testing.T) { |
||||
|
client := getS3Client(t) |
||||
|
bucketName := getNewBucketName() |
||||
|
|
||||
|
// Create bucket
|
||||
|
createBucket(t, client, bucketName) |
||||
|
defer deleteBucket(t, client, bucketName) |
||||
|
|
||||
|
// Enable versioning
|
||||
|
_, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
VersioningConfiguration: &types.VersioningConfiguration{ |
||||
|
Status: types.BucketVersioningStatusEnabled, |
||||
|
}, |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to enable versioning") |
||||
|
|
||||
|
objectKey := "multipart-multi-version-object" |
||||
|
partSize := 5 * 1024 * 1024 // 5MB
|
||||
|
var expectedETags []string |
||||
|
|
||||
|
// Create 3 versions using multipart upload
|
||||
|
for version := 1; version <= 3; version++ { |
||||
|
// Create multipart upload
|
||||
|
createResp, err := client.CreateMultipartUpload(context.TODO(), &s3.CreateMultipartUploadInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to create multipart upload for version %d", version) |
||||
|
|
||||
|
uploadId := *createResp.UploadId |
||||
|
|
||||
|
// Create unique data for each version
|
||||
|
partData := bytes.Repeat([]byte(fmt.Sprintf("%d", version)), partSize) |
||||
|
|
||||
|
// Upload single part (still results in multipart ETag format)
|
||||
|
uploadPartResp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
UploadId: aws.String(uploadId), |
||||
|
PartNumber: aws.Int32(1), |
||||
|
Body: bytes.NewReader(partData), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to upload part for version %d", version) |
||||
|
|
||||
|
// Complete multipart upload
|
||||
|
completeResp, err := client.CompleteMultipartUpload(context.TODO(), &s3.CompleteMultipartUploadInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
UploadId: aws.String(uploadId), |
||||
|
MultipartUpload: &types.CompletedMultipartUpload{ |
||||
|
Parts: []types.CompletedPart{ |
||||
|
{ |
||||
|
ETag: uploadPartResp.ETag, |
||||
|
PartNumber: aws.Int32(1), |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to complete multipart upload for version %d", version) |
||||
|
|
||||
|
etag := strings.Trim(*completeResp.ETag, "\"") |
||||
|
expectedETags = append(expectedETags, etag) |
||||
|
t.Logf("Version %d ETag: %s", version, etag) |
||||
|
} |
||||
|
|
||||
|
// ListObjectVersions should return all versions with correct ETags
|
||||
|
versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Prefix: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to list object versions") |
||||
|
require.Len(t, versionsResp.Versions, 3, "Should have exactly 3 versions") |
||||
|
|
||||
|
// Collect ETags from the listing
|
||||
|
var listedETags []string |
||||
|
for _, v := range versionsResp.Versions { |
||||
|
etag := strings.Trim(*v.ETag, "\"") |
||||
|
listedETags = append(listedETags, etag) |
||||
|
assert.NotEmpty(t, etag, "Version ETag should not be empty") |
||||
|
assert.Contains(t, etag, "-", "Multipart ETag should contain '-'") |
||||
|
} |
||||
|
|
||||
|
t.Logf("Expected ETags: %v", expectedETags) |
||||
|
t.Logf("Listed ETags: %v", listedETags) |
||||
|
|
||||
|
// Verify all expected ETags are present (order may differ due to version ordering)
|
||||
|
assert.ElementsMatch(t, expectedETags, listedETags, "Listed ETags should match all expected ETags") |
||||
|
|
||||
|
// Regular ListObjectsV2 should return only the latest version with correct ETag
|
||||
|
listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Prefix: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to list objects") |
||||
|
require.Len(t, listResp.Contents, 1, "Should have exactly one object in regular listing") |
||||
|
|
||||
|
listETag := strings.Trim(*listResp.Contents[0].ETag, "\"") |
||||
|
// The latest version (version 3) should be the one shown
|
||||
|
assert.Equal(t, expectedETags[2], listETag, "ListObjectsV2 should show latest version's ETag") |
||||
|
} |
||||
|
|
||||
|
// TestMixedSingleAndMultipartVersionsListETag tests that a mix of
|
||||
|
// single-part and multipart uploaded versions all have correct ETags.
|
||||
|
func TestMixedSingleAndMultipartVersionsListETag(t *testing.T) { |
||||
|
client := getS3Client(t) |
||||
|
bucketName := getNewBucketName() |
||||
|
|
||||
|
// Create bucket
|
||||
|
createBucket(t, client, bucketName) |
||||
|
defer deleteBucket(t, client, bucketName) |
||||
|
|
||||
|
// Enable versioning
|
||||
|
_, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
VersioningConfiguration: &types.VersioningConfiguration{ |
||||
|
Status: types.BucketVersioningStatusEnabled, |
||||
|
}, |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to enable versioning") |
||||
|
|
||||
|
objectKey := "mixed-upload-versions" |
||||
|
|
||||
|
// Version 1: Regular PutObject (single-part, pure MD5 ETag)
|
||||
|
content1 := []byte("This is version 1 content - single part upload") |
||||
|
putResp1, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
Body: bytes.NewReader(content1), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to put version 1") |
||||
|
etag1 := strings.Trim(*putResp1.ETag, "\"") |
||||
|
assert.NotContains(t, etag1, "-", "Single-part ETag should not contain '-'") |
||||
|
t.Logf("Version 1 (PutObject) ETag: %s", etag1) |
||||
|
|
||||
|
// Version 2: Multipart upload
|
||||
|
partSize := 5 * 1024 * 1024 |
||||
|
partData := bytes.Repeat([]byte("x"), partSize) |
||||
|
|
||||
|
createResp, err := client.CreateMultipartUpload(context.TODO(), &s3.CreateMultipartUploadInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to create multipart upload") |
||||
|
|
||||
|
uploadPartResp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
UploadId: createResp.UploadId, |
||||
|
PartNumber: aws.Int32(1), |
||||
|
Body: bytes.NewReader(partData), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to upload part") |
||||
|
|
||||
|
completeResp, err := client.CompleteMultipartUpload(context.TODO(), &s3.CompleteMultipartUploadInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
UploadId: createResp.UploadId, |
||||
|
MultipartUpload: &types.CompletedMultipartUpload{ |
||||
|
Parts: []types.CompletedPart{ |
||||
|
{ |
||||
|
ETag: uploadPartResp.ETag, |
||||
|
PartNumber: aws.Int32(1), |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to complete multipart upload") |
||||
|
etag2 := strings.Trim(*completeResp.ETag, "\"") |
||||
|
assert.Contains(t, etag2, "-", "Multipart ETag should contain '-'") |
||||
|
t.Logf("Version 2 (Multipart) ETag: %s", etag2) |
||||
|
|
||||
|
// Version 3: Another regular PutObject
|
||||
|
content3 := []byte("This is version 3 content - another single part upload") |
||||
|
putResp3, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
Body: bytes.NewReader(content3), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to put version 3") |
||||
|
etag3 := strings.Trim(*putResp3.ETag, "\"") |
||||
|
assert.NotContains(t, etag3, "-", "Single-part ETag should not contain '-'") |
||||
|
t.Logf("Version 3 (PutObject) ETag: %s", etag3) |
||||
|
|
||||
|
// ListObjectVersions should return all 3 versions with correct ETags
|
||||
|
versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Prefix: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to list object versions") |
||||
|
require.Len(t, versionsResp.Versions, 3, "Should have exactly 3 versions") |
||||
|
|
||||
|
var listedETags []string |
||||
|
for _, v := range versionsResp.Versions { |
||||
|
etag := strings.Trim(*v.ETag, "\"") |
||||
|
assert.NotEmpty(t, etag, "Version ETag should not be empty") |
||||
|
listedETags = append(listedETags, etag) |
||||
|
t.Logf("Listed version %s ETag: %s, IsLatest: %v", *v.VersionId, etag, *v.IsLatest) |
||||
|
} |
||||
|
|
||||
|
// Verify all ETags were found
|
||||
|
assert.ElementsMatch(t, []string{etag1, etag2, etag3}, listedETags, "Listed ETags should match all expected ETags") |
||||
|
|
||||
|
// Regular ListObjectsV2 should return only the latest (version 3)
|
||||
|
listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Prefix: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to list objects") |
||||
|
require.Len(t, listResp.Contents, 1, "Should have exactly one object") |
||||
|
|
||||
|
listETag := strings.Trim(*listResp.Contents[0].ETag, "\"") |
||||
|
assert.Equal(t, etag3, listETag, "ListObjectsV2 should show latest version's ETag (version 3)") |
||||
|
} |
||||
|
|
||||
|
// TestMultipartUploadDeleteMarkerListBehavior tests that delete markers work correctly
|
||||
|
// with multipart uploaded objects in versioned buckets.
|
||||
|
func TestMultipartUploadDeleteMarkerListBehavior(t *testing.T) { |
||||
|
client := getS3Client(t) |
||||
|
bucketName := getNewBucketName() |
||||
|
|
||||
|
// Create bucket
|
||||
|
createBucket(t, client, bucketName) |
||||
|
defer deleteBucket(t, client, bucketName) |
||||
|
|
||||
|
// Enable versioning
|
||||
|
_, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
VersioningConfiguration: &types.VersioningConfiguration{ |
||||
|
Status: types.BucketVersioningStatusEnabled, |
||||
|
}, |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to enable versioning") |
||||
|
|
||||
|
objectKey := "multipart-delete-marker-test" |
||||
|
partSize := 5 * 1024 * 1024 // 5MB
|
||||
|
|
||||
|
// Create multipart upload
|
||||
|
createResp, err := client.CreateMultipartUpload(context.TODO(), &s3.CreateMultipartUploadInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to create multipart upload") |
||||
|
|
||||
|
// Upload 2 parts
|
||||
|
part1Data := bytes.Repeat([]byte("a"), partSize) |
||||
|
part2Data := bytes.Repeat([]byte("b"), partSize) |
||||
|
|
||||
|
uploadPart1Resp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
UploadId: createResp.UploadId, |
||||
|
PartNumber: aws.Int32(1), |
||||
|
Body: bytes.NewReader(part1Data), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to upload part 1") |
||||
|
|
||||
|
uploadPart2Resp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
UploadId: createResp.UploadId, |
||||
|
PartNumber: aws.Int32(2), |
||||
|
Body: bytes.NewReader(part2Data), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to upload part 2") |
||||
|
|
||||
|
// Complete multipart upload
|
||||
|
completeResp, err := client.CompleteMultipartUpload(context.TODO(), &s3.CompleteMultipartUploadInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
UploadId: createResp.UploadId, |
||||
|
MultipartUpload: &types.CompletedMultipartUpload{ |
||||
|
Parts: []types.CompletedPart{ |
||||
|
{ETag: uploadPart1Resp.ETag, PartNumber: aws.Int32(1)}, |
||||
|
{ETag: uploadPart2Resp.ETag, PartNumber: aws.Int32(2)}, |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to complete multipart upload") |
||||
|
|
||||
|
multipartETag := strings.Trim(*completeResp.ETag, "\"") |
||||
|
multipartVersionId := *completeResp.VersionId |
||||
|
t.Logf("Multipart upload completed: ETag=%s, VersionId=%s", multipartETag, multipartVersionId) |
||||
|
|
||||
|
// Verify object is visible in ListObjectsV2
|
||||
|
listBeforeDelete, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Prefix: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to list objects before delete") |
||||
|
require.Len(t, listBeforeDelete.Contents, 1, "Object should be visible before delete") |
||||
|
assert.Equal(t, multipartETag, strings.Trim(*listBeforeDelete.Contents[0].ETag, "\""), |
||||
|
"Listed ETag should match multipart ETag before delete") |
||||
|
|
||||
|
// Delete object (creates delete marker)
|
||||
|
deleteResp, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to delete object") |
||||
|
require.NotNil(t, deleteResp.DeleteMarker, "Should create delete marker") |
||||
|
assert.True(t, *deleteResp.DeleteMarker, "DeleteMarker should be true") |
||||
|
require.NotNil(t, deleteResp.VersionId, "Delete marker should have version ID") |
||||
|
|
||||
|
deleteMarkerVersionId := *deleteResp.VersionId |
||||
|
t.Logf("Delete marker created: VersionId=%s", deleteMarkerVersionId) |
||||
|
|
||||
|
// ListObjectsV2 should NOT show the object anymore
|
||||
|
listAfterDelete, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Prefix: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to list objects after delete") |
||||
|
assert.Empty(t, listAfterDelete.Contents, "Object should NOT be visible after delete marker") |
||||
|
|
||||
|
// ListObjectVersions should show both the original version AND the delete marker
|
||||
|
versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Prefix: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to list object versions") |
||||
|
|
||||
|
// Should have 1 version (the multipart object)
|
||||
|
require.Len(t, versionsResp.Versions, 1, "Should have exactly 1 version (the multipart object)") |
||||
|
version := versionsResp.Versions[0] |
||||
|
assert.Equal(t, multipartVersionId, *version.VersionId, "Version ID should match") |
||||
|
assert.Equal(t, multipartETag, strings.Trim(*version.ETag, "\""), "Version ETag should match multipart ETag") |
||||
|
assert.False(t, *version.IsLatest, "Multipart version should NOT be latest (delete marker is latest)") |
||||
|
|
||||
|
// Should have 1 delete marker
|
||||
|
require.Len(t, versionsResp.DeleteMarkers, 1, "Should have exactly 1 delete marker") |
||||
|
deleteMarker := versionsResp.DeleteMarkers[0] |
||||
|
assert.Equal(t, deleteMarkerVersionId, *deleteMarker.VersionId, "Delete marker version ID should match") |
||||
|
assert.True(t, *deleteMarker.IsLatest, "Delete marker should be latest") |
||||
|
|
||||
|
t.Logf("ListObjectVersions: 1 version (ETag=%s), 1 delete marker", multipartETag) |
||||
|
|
||||
|
// Access the specific version by version ID - should still work
|
||||
|
getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
VersionId: aws.String(multipartVersionId), |
||||
|
}) |
||||
|
require.NoError(t, err, "Should be able to get object by version ID after delete marker") |
||||
|
defer getResp.Body.Close() |
||||
|
|
||||
|
assert.Equal(t, multipartETag, strings.Trim(*getResp.ETag, "\""), |
||||
|
"GetObject with version ID should return correct ETag") |
||||
|
assert.Equal(t, int64(partSize*2), *getResp.ContentLength, |
||||
|
"GetObject with version ID should return correct size") |
||||
|
|
||||
|
t.Logf("Successfully retrieved version %s after delete marker", multipartVersionId) |
||||
|
|
||||
|
// Delete the delete marker to "undelete" the object
|
||||
|
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Key: aws.String(objectKey), |
||||
|
VersionId: aws.String(deleteMarkerVersionId), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to delete the delete marker") |
||||
|
|
||||
|
// ListObjectsV2 should show the object again
|
||||
|
listAfterUndelete, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ |
||||
|
Bucket: aws.String(bucketName), |
||||
|
Prefix: aws.String(objectKey), |
||||
|
}) |
||||
|
require.NoError(t, err, "Failed to list objects after undelete") |
||||
|
require.Len(t, listAfterUndelete.Contents, 1, "Object should be visible again after removing delete marker") |
||||
|
assert.Equal(t, multipartETag, strings.Trim(*listAfterUndelete.Contents[0].ETag, "\""), |
||||
|
"Undeleted object should have correct multipart ETag") |
||||
|
|
||||
|
t.Logf("Object restored after delete marker removal, ETag=%s", multipartETag) |
||||
|
} |
||||
|
|
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue