mirror of
https://github.com/versity/versitygw.git
synced 2026-09-25 09:24:22 +00:00
S3 conditional writes (If-Match / If-None-Match: *) were evaluated with a check-then-act pattern: PutObjectWithPostFunc and CompleteMultipartUploadWithCopy read the current etag, evaluated the precondition, and only later published the replacement via link/rename. Under concurrent conditional PUTs to the same key, many writers could read the same old state, all pass the check, and all succeed, breaking compare-and-swap coordination built on conditional writes (observed by Buzz's git object-store A3 conformance probe: 32-way races returned up to 32 winners instead of exactly 1x2xx + 31x412). Introduce a per-object publish lock (lockObjectPublish) that every object publication path holds across condition re-evaluation, metadata stores, and the final link: - Exclusion is an advisory file lock (flock on unix, LockFileEx on windows) on bucket/.sgwtmp/objlock/<shard>, where shard is the first byte of sha256(key). The gateway is stateless and multiple gateway processes may share one filesystem, so a process-local mutex alone is not sufficient; file locks provide cross-process and (via NFSv4 lock semantics) cross-client exclusion. A process-local striped mutex is taken alongside so in-process contention never thrashes the filesystem lock. Lock files are empty, bounded (max 256 per bucket), and never unlinked to avoid the unlink/recreate flock race that cannot be detected reliably on NFS. - Request bodies are staged to the temp file before the lock is taken; the lock covers only the short commit phase. The kernel releases the lock on close or process death, so failures and crashes cannot leave a stale lock. If the filesystem does not support advisory locking (e.g. NFS mounted with -o nolock), the gateway falls back to process-local exclusion and warns once. - The pre-staging precondition check is kept as an advisory fast-fail; the authoritative check runs under the lock. Unconditional PUTs, directory-object PUTs, CopyObject (via PutObject), scoutfs (via PutObjectWithPostFunc/CompleteMultipartUploadWithCopy), and multipart completion all participate. - For conditional writes on versioned buckets the version snapshot is deferred until the precondition is confirmed under the lock, so losing writers no longer create spurious version snapshots. - The postprocess hook now runs before any path-based metadata is written, so a failing hook no longer leaves stale sidecar attributes (previously the new etag was visible in sidecar mode even when publication failed). Add regression tests covering 32-way If-Match and If-None-Match: * races for both ordinary PUT and multipart completion, mixed conditional/unconditional races, sequential semantics, per-key independence, and cleanup after failed publication, for both xattr and sidecar metadata modes. Add an external SigV4 test (tests/conditionalrace) that replicates the Buzz A3 probe against a live gateway, optionally across two gateway processes sharing one backend directory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
576 lines
18 KiB
Go
576 lines
18 KiB
Go
// Copyright 2026 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package posix
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"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/versity/versitygw/backend/meta"
|
|
"github.com/versity/versitygw/s3err"
|
|
"github.com/versity/versitygw/s3response"
|
|
)
|
|
|
|
const condRaceWriters = 32
|
|
|
|
// metaModes returns the metadata backends supported by the posix backend
|
|
// that should both be exercised by the conditional-put tests.
|
|
func metaModes(t *testing.T) map[string]func(t *testing.T) (meta.MetadataStorer, PosixOpts) {
|
|
t.Helper()
|
|
return map[string]func(t *testing.T) (meta.MetadataStorer, PosixOpts){
|
|
"xattr": func(t *testing.T) (meta.MetadataStorer, PosixOpts) {
|
|
return meta.XattrMeta{}, PosixOpts{NewDirPerm: 0755}
|
|
},
|
|
"sidecar": func(t *testing.T) (meta.MetadataStorer, PosixOpts) {
|
|
dir := t.TempDir()
|
|
sc, err := meta.NewSideCar(dir)
|
|
if err != nil {
|
|
t.Fatalf("new sidecar: %v", err)
|
|
}
|
|
return sc, PosixOpts{NewDirPerm: 0755, SideCarDir: dir}
|
|
},
|
|
}
|
|
}
|
|
|
|
func newTestPosix(t *testing.T, mkMeta func(t *testing.T) (meta.MetadataStorer, PosixOpts)) *Posix {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
storer, opts := mkMeta(t)
|
|
p, err := New(root, storer, opts)
|
|
if err != nil {
|
|
t.Fatalf("new posix: %v", err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func createTestBucket(t *testing.T, p *Posix, bucket string) {
|
|
t.Helper()
|
|
err := p.CreateBucket(context.Background(), &s3.CreateBucketInput{
|
|
Bucket: &bucket,
|
|
CreateBucketConfiguration: &types.CreateBucketConfiguration{},
|
|
}, []byte{})
|
|
if err != nil {
|
|
t.Fatalf("create bucket: %v", err)
|
|
}
|
|
}
|
|
|
|
func testPut(p *Posix, bucket, key string, body []byte, ifMatch, ifNoneMatch *string) (s3response.PutObjectOutput, error) {
|
|
return p.PutObject(context.Background(), s3response.PutObjectInput{
|
|
Bucket: &bucket,
|
|
Key: &key,
|
|
Body: bytes.NewReader(body),
|
|
ContentLength: aws.Int64(int64(len(body))),
|
|
IfMatch: ifMatch,
|
|
IfNoneMatch: ifNoneMatch,
|
|
})
|
|
}
|
|
|
|
func getTestObject(t *testing.T, p *Posix, bucket, key string) ([]byte, string) {
|
|
t.Helper()
|
|
out, err := p.GetObject(context.Background(), &s3.GetObjectInput{
|
|
Bucket: &bucket,
|
|
Key: &key,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get object: %v", err)
|
|
}
|
|
defer out.Body.Close()
|
|
data, err := io.ReadAll(out.Body)
|
|
if err != nil {
|
|
t.Fatalf("read object body: %v", err)
|
|
}
|
|
return data, *out.ETag
|
|
}
|
|
|
|
// trimEtag strips the surrounding quotes the same way the s3api layer does
|
|
// before passing If-Match values to the backend.
|
|
func trimEtag(etag string) string {
|
|
return strings.Trim(etag, "\"")
|
|
}
|
|
|
|
func isPreconditionFailed(err error) bool {
|
|
var pe s3err.PreconditionFailedError
|
|
if errors.As(err, &pe) {
|
|
return pe.HTTPStatusCode == http.StatusPreconditionFailed
|
|
}
|
|
var ae s3err.APIError
|
|
return errors.As(err, &ae) && ae.HTTPStatusCode == http.StatusPreconditionFailed
|
|
}
|
|
|
|
type raceOutcome struct {
|
|
idx int
|
|
etag string
|
|
err error
|
|
}
|
|
|
|
// runRace launches one goroutine per writer, releases them all through a
|
|
// common start barrier, and collects the outcome of each writer.
|
|
func runRace(t *testing.T, n int, writer func(i int) (string, error)) []raceOutcome {
|
|
t.Helper()
|
|
start := make(chan struct{})
|
|
var ready, done sync.WaitGroup
|
|
results := make([]raceOutcome, n)
|
|
for i := range n {
|
|
ready.Add(1)
|
|
done.Add(1)
|
|
go func(i int) {
|
|
defer done.Done()
|
|
ready.Done()
|
|
<-start
|
|
etag, err := writer(i)
|
|
results[i] = raceOutcome{idx: i, etag: etag, err: err}
|
|
}(i)
|
|
}
|
|
ready.Wait()
|
|
close(start)
|
|
done.Wait()
|
|
return results
|
|
}
|
|
|
|
// classifyRace asserts exactly one winner and all losers 412, returning the
|
|
// winner outcome.
|
|
func classifyRace(t *testing.T, results []raceOutcome) raceOutcome {
|
|
t.Helper()
|
|
var winners []raceOutcome
|
|
for _, r := range results {
|
|
switch {
|
|
case r.err == nil:
|
|
winners = append(winners, r)
|
|
case isPreconditionFailed(r.err):
|
|
default:
|
|
t.Errorf("writer %d: unexpected error (want nil or 412): %v", r.idx, r.err)
|
|
}
|
|
}
|
|
if len(winners) != 1 {
|
|
t.Fatalf("expected exactly 1 winner among %d classified writers, got %d",
|
|
len(results), len(winners))
|
|
}
|
|
return winners[0]
|
|
}
|
|
|
|
func raceBody(i int) []byte {
|
|
return fmt.Appendf(nil, "conditional-race-writer-%02d-%s", i,
|
|
string(bytes.Repeat([]byte{byte('a' + i%26)}, 64)))
|
|
}
|
|
|
|
func TestPosixConditionalPutIfNoneMatchRace(t *testing.T) {
|
|
for mode, mkMeta := range metaModes(t) {
|
|
t.Run(mode, func(t *testing.T) {
|
|
p := newTestPosix(t, mkMeta)
|
|
bucket := "testbucket"
|
|
createTestBucket(t, p, bucket)
|
|
|
|
for round := range 3 {
|
|
key := fmt.Sprintf("obj-inm-%d", round)
|
|
results := runRace(t, condRaceWriters, func(i int) (string, error) {
|
|
res, err := testPut(p, bucket, key, raceBody(i), nil, aws.String("*"))
|
|
return res.ETag, err
|
|
})
|
|
winner := classifyRace(t, results)
|
|
|
|
data, etag := getTestObject(t, p, bucket, key)
|
|
if !bytes.Equal(data, raceBody(winner.idx)) {
|
|
t.Errorf("round %d: final object bytes do not match winner %d",
|
|
round, winner.idx)
|
|
}
|
|
if etag != winner.etag {
|
|
t.Errorf("round %d: final etag %q does not match winner etag %q",
|
|
round, etag, winner.etag)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPosixConditionalPutIfMatchRace(t *testing.T) {
|
|
for mode, mkMeta := range metaModes(t) {
|
|
t.Run(mode, func(t *testing.T) {
|
|
p := newTestPosix(t, mkMeta)
|
|
bucket := "testbucket"
|
|
key := "obj-ifmatch"
|
|
createTestBucket(t, p, bucket)
|
|
|
|
seed, err := testPut(p, bucket, key, []byte("seed"), nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("seed put: %v", err)
|
|
}
|
|
curEtag := seed.ETag
|
|
|
|
// several rounds to make timing-sensitive failures visible;
|
|
// each round races against the previous round's winner etag.
|
|
// bodies are unique per round and per writer so that every
|
|
// successful write necessarily changes the etag
|
|
for round := range 5 {
|
|
raceBody := func(i int) []byte {
|
|
return fmt.Appendf(nil, "round-%d-%s", round, raceBody(i))
|
|
}
|
|
results := runRace(t, condRaceWriters, func(i int) (string, error) {
|
|
res, err := testPut(p, bucket, key, raceBody(i), aws.String(trimEtag(curEtag)), nil)
|
|
return res.ETag, err
|
|
})
|
|
winner := classifyRace(t, results)
|
|
|
|
data, etag := getTestObject(t, p, bucket, key)
|
|
if !bytes.Equal(data, raceBody(winner.idx)) {
|
|
t.Errorf("round %d: final object bytes do not match winner %d",
|
|
round, winner.idx)
|
|
}
|
|
if etag != winner.etag {
|
|
t.Errorf("round %d: final etag %q does not match winner etag %q",
|
|
round, etag, winner.etag)
|
|
}
|
|
curEtag = winner.etag
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPosixConditionalPutSequential(t *testing.T) {
|
|
for mode, mkMeta := range metaModes(t) {
|
|
t.Run(mode, func(t *testing.T) {
|
|
p := newTestPosix(t, mkMeta)
|
|
bucket := "testbucket"
|
|
createTestBucket(t, p, bucket)
|
|
|
|
// if-none-match on absent object succeeds
|
|
res, err := testPut(p, bucket, "obj", []byte("first"), nil, aws.String("*"))
|
|
if err != nil {
|
|
t.Fatalf("if-none-match create: %v", err)
|
|
}
|
|
|
|
// if-none-match on existing object fails 412
|
|
_, err = testPut(p, bucket, "obj", []byte("second"), nil, aws.String("*"))
|
|
if !isPreconditionFailed(err) {
|
|
t.Errorf("if-none-match on existing object: want 412, got %v", err)
|
|
}
|
|
|
|
// stale if-match fails 412
|
|
_, err = testPut(p, bucket, "obj", []byte("third"),
|
|
aws.String("\"deadbeefdeadbeefdeadbeefdeadbeef\""), nil)
|
|
if !isPreconditionFailed(err) {
|
|
t.Errorf("stale if-match: want 412, got %v", err)
|
|
}
|
|
|
|
// correct if-match succeeds
|
|
res2, err := testPut(p, bucket, "obj", []byte("fourth"), aws.String(trimEtag(res.ETag)), nil)
|
|
if err != nil {
|
|
t.Fatalf("valid if-match: %v", err)
|
|
}
|
|
data, etag := getTestObject(t, p, bucket, "obj")
|
|
if string(data) != "fourth" || etag != res2.ETag {
|
|
t.Errorf("unexpected final state after if-match put")
|
|
}
|
|
|
|
// if-match on missing object keeps existing project behavior (NoSuchKey)
|
|
_, err = testPut(p, bucket, "missing", []byte("x"), aws.String(trimEtag(res2.ETag)), nil)
|
|
if !errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) {
|
|
t.Errorf("if-match on missing object: want NoSuchKey, got %v", err)
|
|
}
|
|
|
|
// object content untouched by the failed attempts
|
|
data, _ = getTestObject(t, p, bucket, "obj")
|
|
if string(data) != "fourth" {
|
|
t.Errorf("failed conditional puts modified the object: %q", data)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPosixConditionalPutUnconditionalRace races an unconditional PUT with an
|
|
// If-None-Match create. Per S3 semantics some serial order must exist: the
|
|
// conditional writer may win or lose, but since the unconditional write always
|
|
// succeeds and any valid serialization orders the unconditional write after a
|
|
// successful conditional create (otherwise the conditional write would have
|
|
// observed the object and failed), the final object must always be the
|
|
// unconditional writer's.
|
|
func TestPosixConditionalPutUnconditionalRace(t *testing.T) {
|
|
for mode, mkMeta := range metaModes(t) {
|
|
t.Run(mode, func(t *testing.T) {
|
|
p := newTestPosix(t, mkMeta)
|
|
bucket := "testbucket"
|
|
createTestBucket(t, p, bucket)
|
|
|
|
for round := range 10 {
|
|
key := fmt.Sprintf("obj-mixed-%d", round)
|
|
uncondBody := fmt.Appendf(nil, "unconditional-%d", round)
|
|
condBody := fmt.Appendf(nil, "conditional-%d", round)
|
|
|
|
var uncondEtag string
|
|
results := runRace(t, 2, func(i int) (string, error) {
|
|
if i == 0 {
|
|
res, err := testPut(p, bucket, key, uncondBody, nil, nil)
|
|
uncondEtag = res.ETag
|
|
return res.ETag, err
|
|
}
|
|
res, err := testPut(p, bucket, key, condBody, nil, aws.String("*"))
|
|
return res.ETag, err
|
|
})
|
|
|
|
if results[0].err != nil {
|
|
t.Fatalf("round %d: unconditional put failed: %v", round, results[0].err)
|
|
}
|
|
if results[1].err != nil && !isPreconditionFailed(results[1].err) {
|
|
t.Fatalf("round %d: conditional put unexpected error: %v", round, results[1].err)
|
|
}
|
|
|
|
data, etag := getTestObject(t, p, bucket, key)
|
|
if !bytes.Equal(data, uncondBody) || etag != uncondEtag {
|
|
t.Errorf("round %d: final object is not the unconditional writer's "+
|
|
"(cond err=%v)", round, results[1].err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPosixConditionalPutDistinctKeysConcurrent(t *testing.T) {
|
|
for mode, mkMeta := range metaModes(t) {
|
|
t.Run(mode, func(t *testing.T) {
|
|
p := newTestPosix(t, mkMeta)
|
|
bucket := "testbucket"
|
|
createTestBucket(t, p, bucket)
|
|
|
|
results := runRace(t, condRaceWriters, func(i int) (string, error) {
|
|
key := fmt.Sprintf("distinct-key-%02d", i)
|
|
res, err := testPut(p, bucket, key, raceBody(i), nil, aws.String("*"))
|
|
return res.ETag, err
|
|
})
|
|
for _, r := range results {
|
|
if r.err != nil {
|
|
t.Errorf("writer %d to its own key failed: %v", r.idx, r.err)
|
|
}
|
|
}
|
|
for i := range condRaceWriters {
|
|
key := fmt.Sprintf("distinct-key-%02d", i)
|
|
data, _ := getTestObject(t, p, bucket, key)
|
|
if !bytes.Equal(data, raceBody(i)) {
|
|
t.Errorf("key %s has wrong content", key)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testCompleteMultipart(t *testing.T, p *Posix, bucket, key string, body []byte, ifMatch, ifNoneMatch *string) (string, func() (string, error)) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
mp, err := p.CreateMultipartUpload(ctx, s3response.CreateMultipartUploadInput{
|
|
Bucket: &bucket,
|
|
Key: &key,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create multipart upload: %v", err)
|
|
}
|
|
uploadID := mp.UploadId
|
|
part, err := p.UploadPart(ctx, &s3.UploadPartInput{
|
|
Bucket: &bucket,
|
|
Key: &key,
|
|
UploadId: &uploadID,
|
|
PartNumber: aws.Int32(1),
|
|
ContentLength: aws.Int64(int64(len(body))),
|
|
Body: bytes.NewReader(body),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upload part: %v", err)
|
|
}
|
|
return uploadID, func() (string, error) {
|
|
res, _, err := p.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
|
|
Bucket: &bucket,
|
|
Key: &key,
|
|
UploadId: &uploadID,
|
|
MultipartUpload: &types.CompletedMultipartUpload{
|
|
Parts: []types.CompletedPart{
|
|
{ETag: part.ETag, PartNumber: aws.Int32(1)},
|
|
},
|
|
},
|
|
IfMatch: ifMatch,
|
|
IfNoneMatch: ifNoneMatch,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return *res.ETag, nil
|
|
}
|
|
}
|
|
|
|
func TestPosixCompleteMultipartUploadIfNoneMatchRace(t *testing.T) {
|
|
for mode, mkMeta := range metaModes(t) {
|
|
t.Run(mode, func(t *testing.T) {
|
|
p := newTestPosix(t, mkMeta)
|
|
bucket := "testbucket"
|
|
key := "obj-mp-inm"
|
|
createTestBucket(t, p, bucket)
|
|
|
|
completes := make([]func() (string, error), condRaceWriters)
|
|
bodies := make([][]byte, condRaceWriters)
|
|
for i := range condRaceWriters {
|
|
bodies[i] = raceBody(i)
|
|
_, completes[i] = testCompleteMultipart(t, p, bucket, key,
|
|
bodies[i], nil, aws.String("*"))
|
|
}
|
|
|
|
results := runRace(t, condRaceWriters, func(i int) (string, error) {
|
|
return completes[i]()
|
|
})
|
|
winner := classifyRace(t, results)
|
|
|
|
data, etag := getTestObject(t, p, bucket, key)
|
|
if !bytes.Equal(data, bodies[winner.idx]) {
|
|
t.Errorf("final object bytes do not match winning completer %d", winner.idx)
|
|
}
|
|
if etag != winner.etag {
|
|
t.Errorf("final etag %q does not match winner etag %q", etag, winner.etag)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPosixCompleteMultipartUploadIfMatchRace(t *testing.T) {
|
|
for mode, mkMeta := range metaModes(t) {
|
|
t.Run(mode, func(t *testing.T) {
|
|
p := newTestPosix(t, mkMeta)
|
|
bucket := "testbucket"
|
|
key := "obj-mp-ifmatch"
|
|
createTestBucket(t, p, bucket)
|
|
|
|
seed, err := testPut(p, bucket, key, []byte("seed"), nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("seed put: %v", err)
|
|
}
|
|
|
|
completes := make([]func() (string, error), condRaceWriters)
|
|
bodies := make([][]byte, condRaceWriters)
|
|
for i := range condRaceWriters {
|
|
bodies[i] = raceBody(i)
|
|
_, completes[i] = testCompleteMultipart(t, p, bucket, key,
|
|
bodies[i], aws.String(trimEtag(seed.ETag)), nil)
|
|
}
|
|
|
|
results := runRace(t, condRaceWriters, func(i int) (string, error) {
|
|
return completes[i]()
|
|
})
|
|
winner := classifyRace(t, results)
|
|
|
|
data, etag := getTestObject(t, p, bucket, key)
|
|
if !bytes.Equal(data, bodies[winner.idx]) {
|
|
t.Errorf("final object bytes do not match winning completer %d", winner.idx)
|
|
}
|
|
if etag != winner.etag {
|
|
t.Errorf("final etag %q does not match winner etag %q", etag, winner.etag)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// strayTmpFiles returns any regular files under the bucket's temp dir that are
|
|
// not part of the multipart staging area or the object lock directory. Any
|
|
// leftover file here indicates a leaked staging tmpfile.
|
|
func strayTmpFiles(t *testing.T, bucket string) []string {
|
|
t.Helper()
|
|
var stray []string
|
|
root := filepath.Join(bucket, MetaTmpDir)
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
base := filepath.Base(path)
|
|
if base == "multipart" || base == "objlock" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
stray = append(stray, path)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk tmp dir: %v", err)
|
|
}
|
|
return stray
|
|
}
|
|
|
|
func TestPosixConditionalPutFailureCleanup(t *testing.T) {
|
|
for mode, mkMeta := range metaModes(t) {
|
|
t.Run(mode, func(t *testing.T) {
|
|
p := newTestPosix(t, mkMeta)
|
|
bucket := "testbucket"
|
|
key := "obj-cleanup"
|
|
createTestBucket(t, p, bucket)
|
|
|
|
res, err := testPut(p, bucket, key, []byte("base"), nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("seed put: %v", err)
|
|
}
|
|
|
|
// failed precondition leaves no staging garbage behind
|
|
_, err = testPut(p, bucket, key, []byte("cond"), nil, aws.String("*"))
|
|
if !isPreconditionFailed(err) {
|
|
t.Fatalf("want 412, got %v", err)
|
|
}
|
|
if stray := strayTmpFiles(t, bucket); len(stray) != 0 {
|
|
t.Errorf("stray tmp files after 412: %v", stray)
|
|
}
|
|
|
|
// injected publication failure: the post-process hook fails after
|
|
// staging, before publication. The object must be unchanged, no
|
|
// garbage left, and the key must still be writable afterwards
|
|
// (i.e. no stale lock is left behind).
|
|
_, err = p.PutObjectWithPostFunc(context.Background(), s3response.PutObjectInput{
|
|
Bucket: &bucket,
|
|
Key: &key,
|
|
Body: bytes.NewReader([]byte("injected")),
|
|
ContentLength: aws.Int64(int64(len("injected"))),
|
|
IfMatch: aws.String(trimEtag(res.ETag)),
|
|
}, func(*os.File) error { return errors.New("injected publication failure") })
|
|
if err == nil {
|
|
t.Fatalf("expected injected publication failure to propagate")
|
|
}
|
|
data, _ := getTestObject(t, p, bucket, key)
|
|
if string(data) != "base" {
|
|
t.Errorf("object modified by failed publication: %q", data)
|
|
}
|
|
if stray := strayTmpFiles(t, bucket); len(stray) != 0 {
|
|
t.Errorf("stray tmp files after injected failure: %v", stray)
|
|
}
|
|
|
|
// key still writable, conditional protocol still functional
|
|
res2, err := testPut(p, bucket, key, []byte("after"), aws.String(trimEtag(res.ETag)), nil)
|
|
if err != nil {
|
|
t.Fatalf("put after injected failure: %v", err)
|
|
}
|
|
data, etag := getTestObject(t, p, bucket, key)
|
|
if string(data) != "after" || etag != res2.ETag {
|
|
t.Errorf("unexpected final state after recovery put")
|
|
}
|
|
})
|
|
}
|
|
}
|