Files
seaweedfs/test/s3/versioning/s3_delete_idempotency_test.go
T
Chris LuandGitHub 2961448a36 test: cover delete idempotency on versioned object-locked buckets (#10512)
* test: cover delete idempotency on versioned object-locked buckets

Backup clients probe and retract lock keys continuously, so they routinely
delete keys and versions that are already gone, and they batch those deletes
alongside keys that do exist. S3 makes all of that succeed; returning an error
turns ordinary lock arbitration into a job failure.

The behaviour is correct today but had no coverage, and it runs through the
object-lock retention check, which is the most likely place for a missing
object to start being reported as an error.

Covers: deleting a key that never existed, deleting a well-formed version id
that names nothing (twice, and without disturbing the version that does
exist), and a batch whose middle key is missing — every requested key must
come back under its own name rather than silently taking another row's slot.

* test: verify the deletes actually took effect, not just that they returned
2026-07-31 19:44:30 -07:00

157 lines
5.6 KiB
Go

package s3api
import (
"bytes"
"context"
"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"
)
// Backup clients probe and retract lock keys constantly, so they delete keys and
// versions that may already be gone. S3 makes those deletes succeed; answering an
// error instead turns routine lock arbitration into a job failure. Object lock is
// enabled here because the retention check runs before the delete and is the most
// likely place for a missing object to be reported as an error.
func TestDeleteMissingObjectSucceeds(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucketWithObjectLock(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
// A key that never existed.
resp, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String("never-written.lock"),
})
require.NoError(t, err, "deleting a key that never existed must succeed")
require.NotNil(t, resp.DeleteMarker, "a versioned bucket records the delete as a marker")
assert.True(t, *resp.DeleteMarker)
require.NotNil(t, resp.VersionId)
// The response can claim a marker the backend never stored, so confirm it is
// really in the version history under the id the response handed back.
versions, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
Bucket: aws.String(bucketName),
Prefix: aws.String("never-written.lock"),
})
require.NoError(t, err)
found := false
for _, m := range versions.DeleteMarkers {
if m.Key != nil && *m.Key == "never-written.lock" && m.VersionId != nil && *m.VersionId == *resp.VersionId {
found = true
}
}
assert.True(t, found, "the delete marker reported by the response must be persisted")
}
func TestDeleteAbsentVersionSucceeds(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucketWithObjectLock(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
objectKey := "arbitration.lock"
put, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
Body: bytes.NewReader([]byte("held")),
})
require.NoError(t, err)
require.NotNil(t, put.VersionId)
// Well-formed version id that names no stored version. Deleting it is a no-op,
// not an error, and must not disturb the version that does exist.
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
VersionId: aws.String("deadbeefdeadbeefdeadbeefdeadbeef"),
})
require.NoError(t, err, "deleting a version that does not exist must succeed")
head, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
VersionId: put.VersionId,
})
require.NoError(t, err, "the real version must survive the no-op delete")
require.NotNil(t, head.VersionId)
assert.Equal(t, *put.VersionId, *head.VersionId)
// Repeating the delete of the real version is also a no-op rather than an error.
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
VersionId: put.VersionId,
})
require.NoError(t, err)
// Confirm the first delete actually removed the version. Without this a
// backend that ignored every version-specific delete would satisfy both calls
// and the idempotency the test claims to cover would never be exercised.
_, err = client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
VersionId: put.VersionId,
})
require.Error(t, err, "the version must be gone after the first delete")
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
VersionId: put.VersionId,
})
require.NoError(t, err, "repeating a version delete must stay a no-op")
}
// A missing key in the middle of a batch must not consume another key's result
// slot: every request key has to come back under its own name.
func TestMultiObjectDeleteAttributesMissingKeys(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucketWithObjectLock(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
present := []string{"batch-1.lock", "batch-3.lock"}
for _, key := range present {
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
Body: bytes.NewReader([]byte("held")),
})
require.NoError(t, err)
}
requested := []string{"batch-1.lock", "batch-2-missing.lock", "batch-3.lock"}
objects := make([]types.ObjectIdentifier, 0, len(requested))
for _, key := range requested {
objects = append(objects, types.ObjectIdentifier{Key: aws.String(key)})
}
resp, err := client.DeleteObjects(context.TODO(), &s3.DeleteObjectsInput{
Bucket: aws.String(bucketName),
Delete: &types.Delete{Objects: objects},
})
require.NoError(t, err)
assert.Empty(t, resp.Errors, "a missing key is not a batch error")
require.Len(t, resp.Deleted, len(requested))
got := make(map[string]bool, len(resp.Deleted))
for _, d := range resp.Deleted {
require.NotNil(t, d.Key, "every result row must carry the key it belongs to")
assert.NotEmpty(t, *d.Key, "an empty key means a row lost its identity")
got[*d.Key] = true
}
for _, key := range requested {
assert.True(t, got[key], "no result row for %s", key)
}
}