Files
minio/cmd/erasure-server-pool-lock-domain_test.go
T
Feng Ruohang 45b1e4bc00 fix(restore): stage native restores outside the object lock
The unreleased native-restore fixes after RELEASE.2026-10-26T00-00-00Z
let the lock decide who owns a restore. Since fa42a0982 a single-pool
worker holds the object lock for the whole tier download, and since
7f38bf628 a busy lock means "a restore runs". 7f38bf628 also keeps the
restore's data in a marked multipart upload. Two regressions followed:

- While an old version restored, every GET, HEAD and write of the same
  key waited for the download. On a single pool this is new; with
  several pools the released code already held the lock that long.
- An ordinary upload whose completion failed after staging its metadata
  became InvalidUploadID for every caller, and could not be aborted for
  an hour. The marker that hid restore uploads could not tell them apart
  once a completion had rewritten the upload's xl.meta.

A restore now runs in two phases:

- The worker erasure-codes the tier copy into the tmp volume of the
  version's set, without the object lock, as heal does. A drive that
  fails any part, or misses a part when CheckParts runs before the
  commit, takes no part in the commit.
- A restore lock per version tells whether a restore runs. POST takes it
  before the object lock and hands it to the worker. It is a liveness
  hint: only the checks under the object lock decide what is written.
- The commit takes the object lock briefly, on one pool or several. It
  reads the version again and checks the attempt's ownership, the
  version identity, the tier copy, the part layout and the erasure
  layout. It then publishes with RenameData drive by drive, never
  through erasure renameData's undo, and needs the write quorum. The
  replaced data directory goes only when every published drive reports
  the same one.
- A publication that reached some drives but not the write quorum
  converges under the same lock hold, in bounded retries after it: back
  to the unrestored version on every drive that holds it, or to a
  committed restore when the copy turns out to be on a write quorum.
  Each pass first checks that every drive holds what the version held
  before the commit, the commit wrote, or a convergence wrote, and stops
  on anything else. It deletes no directory.

Multipart goes back to main: erasure-multipart.go and
bucket-object-lock.go are identical to 69fcf50cd. The commit lock, the
marker and the restore exceptions of multipart completion and of the
null-version Object Lock check are gone, and single-pool completion
again takes the upload lock first and the object lock right before it
commits. With several pools, abandoned-data cleanup now takes the pools
lock that writes take, as heal does, so it cannot remove a directory
that a commit moved in place and has not yet referenced. The restore
capability becomes protocol 2, and a queued worker checks it again.

Recovery after a coordinator crash during publication, and storage RPCs
that complete after their caller returned, stay excluded. Main has the
same exposure, and they are tracked separately.

Tests:
- New regression tests cover both problems. They fail on f85d4a5fa,
  and the multipart one also passes on main.
- Fault-injection tests cover a publication below the write quorum,
  including seven of twelve drives on 8+4; lost answers; convergence
  retries and stops; the old data directory; a stale inline marker; a
  recreated tmp volume; a published revision read across its expiry; a
  second POST; a target deleted during the download; a tier 404; and
  pools-locked abandoned cleanup.
- Thirteen tests of the upload marker from 7f38bf628 are dropped, and
  so is TestNativeRestoreConcurrentCompletion from fa42a0982, which
  completed the restore's upload from a client. Four tests are reframed
  for the new semantics, including the lock-domain restore test, which
  now gates the commit.
- Three self-recursive WriteMetadata test doubles are fixed, and the
  helpers of the dropped tests are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-06 11:30:29 +08:00

1186 lines
43 KiB
Go

// Copyright (c) 2026 Feng Ruohang
//
// This file is part of Silo Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/amztime"
objectlock "github.com/minio/minio/internal/bucket/object/lock"
"github.com/minio/minio/internal/bucket/versioning"
"github.com/minio/minio/internal/crypto"
"github.com/minio/minio/internal/dsync"
"github.com/minio/minio/internal/hash"
xhttp "github.com/minio/minio/internal/http"
)
// A single pool whose two erasure sets share the test drives, but lock as the
// sets of a distributed pool do whose sets use different hosts. With one
// shared local table the sets are one lock domain, as on a single node.
const (
ldShared = "shared" // one local lock table for both sets
ldLocal = "local-split" // a local lock table per set
ldDsync = "dsync-disjoint" // in-process dsync, no locker in common
ldOverlap = "dsync-overlap" // in-process dsync, one of three lockers in common
)
var ldModes = []string{ldShared, ldLocal, ldDsync, ldOverlap}
// ldWait is how long an operation must stay blocked behind a held commit.
const ldWait = 400 * time.Millisecond
type ldGateKey struct{}
// ldGate holds the storage commit of the operation whose context carries it.
type ldGate struct {
entered, release chan struct{}
once, open sync.Once
}
func newLDGate() *ldGate {
return &ldGate{entered: make(chan struct{}), release: make(chan struct{})}
}
func (g *ldGate) let() { g.open.Do(func() { close(g.release) }) }
func ldHold(ctx context.Context) {
g, _ := ctx.Value(ldGateKey{}).(*ldGate)
if g == nil {
return
}
g.once.Do(func() { close(g.entered) })
select {
case <-g.release:
case <-ctx.Done():
}
}
// ldGateDisk holds every metadata commit of a gated operation.
type ldGateDisk struct{ StorageAPI }
func (d ldGateDisk) RenameData(ctx context.Context, srcVolume, srcPath string, fi FileInfo, dstVolume, dstPath string, opts RenameOptions) (RenameDataResp, error) {
ldHold(ctx)
return d.StorageAPI.RenameData(ctx, srcVolume, srcPath, fi, dstVolume, dstPath, opts)
}
func (d ldGateDisk) RenamePart(ctx context.Context, srcVolume, srcPath, dstVolume, dstPath string, meta []byte, skipParent string) error {
ldHold(ctx)
return d.StorageAPI.RenamePart(ctx, srcVolume, srcPath, dstVolume, dstPath, meta, skipParent)
}
func (d ldGateDisk) DeleteVersion(ctx context.Context, volume, path string, fi FileInfo, forceDelMarker bool, opts DeleteOptions) error {
ldHold(ctx)
return d.StorageAPI.DeleteVersion(ctx, volume, path, fi, forceDelMarker, opts)
}
func (d ldGateDisk) DeleteVersions(ctx context.Context, volume string, versions []FileInfoVersions, opts DeleteOptions) []error {
ldHold(ctx)
return d.StorageAPI.DeleteVersions(ctx, volume, versions, opts)
}
func (d ldGateDisk) WriteMetadata(ctx context.Context, origvolume, volume, path string, fi FileInfo) error {
ldHold(ctx)
return d.StorageAPI.WriteMetadata(ctx, origvolume, volume, path, fi)
}
func (d ldGateDisk) UpdateMetadata(ctx context.Context, volume, path string, fi FileInfo, opts UpdateMetadataOpts) error {
ldHold(ctx)
return d.StorageAPI.UpdateMetadata(ctx, volume, path, fi, opts)
}
// ldSplit turns the single pool of z into two sets that share its drives and
// lock as mode says. Their drives hold gated commits.
func ldSplit(t *testing.T, z *erasureServerPools, mode string) {
t.Helper()
pool := z.serverPools[0]
sets, count := pool.sets, pool.setCount
t.Cleanup(func() { pool.sets, pool.setCount = sets, count })
first, second := *sets[0], *sets[0]
second.setIndex = 1
switch mode {
case ldShared:
case ldLocal:
first.nsMutex, second.nsMutex = newNSLock(false), newNSLock(false)
case ldDsync, ldOverlap:
l := make([]dsync.NetLocker, 8)
for i := range l {
l[i] = newLocker()
}
a, b := l[:4], l[4:]
if mode == ldOverlap {
// Each set can grant its quorum of two without the shared one.
a, b = l[:3], l[2:5]
}
first.nsMutex, second.nsMutex = newNSLock(true), newNSLock(true)
first.getLockers = func() ([]dsync.NetLocker, string) { return slices.Clone(a), "lock-domain-test" }
second.getLockers = func() ([]dsync.NetLocker, string) { return slices.Clone(b), "lock-domain-test" }
default:
t.Fatalf("unknown mode %q", mode)
}
disks := sets[0].getDisks
gated := func() []StorageAPI {
ds := disks()
for i, d := range ds {
if d != nil {
ds[i] = ldGateDisk{d}
}
}
return ds
}
first.getDisks, second.getDisks = gated, gated
pool.sets, pool.setCount = []*erasureObjects{&first, &second}, 2
}
// ldPool returns a split single pool and its unversioned test bucket.
func ldPool(t *testing.T, mode string) (*erasureServerPools, string) {
t.Helper()
z, bucket := bulkDeletePools(t, 1)
ldSplit(t, z, mode)
return z, bucket
}
// ldKey returns a name with prefix that hashes to set.
func ldKey(t *testing.T, z *erasureServerPools, prefix string, set int) string {
t.Helper()
for i := range 1000 {
key := fmt.Sprintf("%s-%d", prefix, i)
if z.serverPools[0].getHashedSetIndex(key) == set {
return key
}
}
t.Fatalf("no name with prefix %q hashes to set %d", prefix, set)
return ""
}
// ldOverlap runs hold until it is inside its storage commit, then runs other.
// It reports whether other was still waiting after ldWait, then lets hold
// commit and returns both results.
func ldOverlapRun(t *testing.T, hold, other func(context.Context) error) (waited bool, holdErr, otherErr error) {
t.Helper()
g := newLDGate()
defer g.let()
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
defer cancel()
held := make(chan error, 1)
go func() { held <- hold(context.WithValue(ctx, ldGateKey{}, g)) }()
select {
case <-g.entered:
case err := <-held:
t.Fatalf("held operation finished before its commit: %v", err)
case <-ctx.Done():
t.Fatal("held operation never reached its commit")
}
done := make(chan error, 1)
go func() { done <- other(ctx) }()
select {
case otherErr = <-done:
case <-time.After(ldWait):
waited = true
}
g.let()
holdErr = <-held
if waited {
otherErr = <-done
}
return waited, holdErr, otherErr
}
func ldReader(r io.Reader, size int64) *PutObjReader {
hr, err := hash.NewReader(context.Background(), r, size, "", "", size)
if err != nil {
panic(err)
}
return NewPutObjReader(hr)
}
func ldPut(ctx context.Context, z *erasureServerPools, bucket, key, body string) error {
_, err := z.PutObject(ctx, bucket, key, ldReader(strings.NewReader(body), int64(len(body))), ObjectOptions{})
return err
}
func ldMustPut(t *testing.T, z *erasureServerPools, bucket, key, body string) {
t.Helper()
if err := ldPut(t.Context(), z, bucket, key, body); err != nil {
t.Fatal(err)
}
}
func ldBody(t *testing.T, z *erasureServerPools, bucket, key string) (string, ObjectInfo) {
t.Helper()
gr, err := z.GetObjectNInfo(t.Context(), bucket, key, nil, nil, ObjectOptions{})
if err != nil {
t.Fatalf("read %s: %v", key, err)
}
defer gr.Close()
body, err := io.ReadAll(gr)
if err != nil {
t.Fatal(err)
}
return string(body), gr.ObjInfo
}
func ldAbsent(t *testing.T, z *erasureServerPools, bucket, key string) {
t.Helper()
if _, err := z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{}); !isErrObjectNotFound(err) {
t.Errorf("%s: %v, want not found", key, err)
}
}
// ldMetadataCopy is a metadata-only self copy of key from a snapshot read
// without its lock, as the COPY handler read a copy onto itself.
func ldMetadataCopy(ctx context.Context, z *erasureServerPools, bucket, key, meta string) error {
oi, err := z.GetObjectInfo(ctx, bucket, key, ObjectOptions{NoLock: true})
if err != nil {
return err
}
oi.metadataOnly = true
oi.UserDefined = cloneMSS(oi.UserDefined)
oi.UserDefined["x-amz-meta-copied"] = meta
_, err = z.CopyObject(ctx, bucket, key, bucket, key, oi, ObjectOptions{}, ObjectOptions{})
return err
}
// In one pool, a DELETE, bulk DELETE, COPY or tag update of a key must wait for
// a write of the same key that is inside its commit, and the reverse, whatever
// the hosts of the key's set are.
func TestLockDomainSinglePoolWritersExclude(t *testing.T) {
type tc struct {
name string
setup func(t *testing.T, z *erasureServerPools, bucket, key string)
hold, other func(ctx context.Context, z *erasureServerPools, bucket, key string) error
check func(t *testing.T, z *erasureServerPools, bucket, key string, holdErr, otherErr error)
}
seed := func(t *testing.T, z *erasureServerPools, bucket, key string) { ldMustPut(t, z, bucket, key, "seed") }
put := func(ctx context.Context, z *erasureServerPools, bucket, key string) error {
return ldPut(ctx, z, bucket, key, "written")
}
noErrs := func(t *testing.T, holdErr, otherErr error) {
t.Helper()
if holdErr != nil || otherErr != nil {
t.Fatalf("held: %v, other: %v", holdErr, otherErr)
}
}
cases := []tc{{
name: "put/delete",
setup: seed,
hold: put,
other: func(ctx context.Context, z *erasureServerPools, bucket, key string) error {
_, err := z.DeleteObject(ctx, bucket, key, ObjectOptions{})
return err
},
check: func(t *testing.T, z *erasureServerPools, bucket, key string, holdErr, otherErr error) {
noErrs(t, holdErr, otherErr)
ldAbsent(t, z, bucket, key)
},
}, {
// The If-Match check and the delete are atomic with writers: the
// condition is checked against the PUT's version.
name: "put/delete-if-match",
setup: seed,
hold: put,
other: func(ctx context.Context, z *erasureServerPools, bucket, key string) error {
seedETag := getMD5Hash([]byte("seed"))
_, err := z.DeleteObject(ctx, bucket, key, ObjectOptions{CheckPrecondFn: func(oi ObjectInfo) bool { return oi.ETag != seedETag }})
return err
},
check: func(t *testing.T, z *erasureServerPools, bucket, key string, holdErr, otherErr error) {
if holdErr != nil || !isErrPreconditionFailed(otherErr) {
t.Fatalf("put: %v; If-Match delete of the replaced version: %v, want precondition failed", holdErr, otherErr)
}
if body, _ := ldBody(t, z, bucket, key); body != "written" {
t.Errorf("body %q, want the PUT's", body)
}
},
}, {
// ILM's delete-all deletes one key through prefix deletion.
name: "put/delete-all",
setup: seed,
hold: put,
other: func(ctx context.Context, z *erasureServerPools, bucket, key string) error {
_, err := z.DeleteObject(ctx, bucket, key, ObjectOptions{DeletePrefix: true, DeletePrefixObject: true})
return err
},
check: func(t *testing.T, z *erasureServerPools, bucket, key string, holdErr, otherErr error) {
noErrs(t, holdErr, otherErr)
ldAbsent(t, z, bucket, key)
},
}, {
name: "put/bulk-delete",
setup: seed,
hold: put,
other: func(ctx context.Context, z *erasureServerPools, bucket, key string) error {
_, errs := z.DeleteObjects(ctx, bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key}}}, ObjectOptions{})
return errs[0]
},
check: func(t *testing.T, z *erasureServerPools, bucket, key string, holdErr, otherErr error) {
noErrs(t, holdErr, otherErr)
ldAbsent(t, z, bucket, key)
},
}, {
name: "put/copy-metadata",
setup: seed,
hold: put,
other: func(ctx context.Context, z *erasureServerPools, bucket, key string) error {
return ldMetadataCopy(ctx, z, bucket, key, "1")
},
check: func(t *testing.T, z *erasureServerPools, bucket, key string, holdErr, otherErr error) {
noErrs(t, holdErr, otherErr)
if body, _ := ldBody(t, z, bucket, key); body != "written" {
t.Errorf("body %q after copy, want the PUT's", body)
}
},
}, {
name: "put/copy-rewrite",
setup: func(t *testing.T, z *erasureServerPools, bucket, key string) {
ldMustPut(t, z, bucket, key, "seed")
ldMustPut(t, z, bucket, key+"-src", "copied")
},
hold: put,
other: func(ctx context.Context, z *erasureServerPools, bucket, key string) error {
gr, err := z.GetObjectNInfo(ctx, bucket, key+"-src", nil, nil, ObjectOptions{})
if err != nil {
return err
}
defer gr.Close()
src := gr.ObjInfo
src.PutObjReader = ldReader(gr, src.Size)
_, err = z.CopyObject(ctx, bucket, key+"-src", bucket, key, src, ObjectOptions{}, ObjectOptions{})
return err
},
check: func(t *testing.T, z *erasureServerPools, bucket, key string, holdErr, otherErr error) {
noErrs(t, holdErr, otherErr)
if body, _ := ldBody(t, z, bucket, key); body != "copied" {
t.Errorf("body %q, want the copy's, which ran after the PUT", body)
}
},
}, {
name: "delete/tags",
setup: seed,
hold: func(ctx context.Context, z *erasureServerPools, bucket, key string) error {
_, err := z.DeleteObject(ctx, bucket, key, ObjectOptions{})
return err
},
other: func(ctx context.Context, z *erasureServerPools, bucket, key string) error {
_, err := z.PutObjectTags(ctx, bucket, key, "k=v", ObjectOptions{})
return err
},
check: func(t *testing.T, z *erasureServerPools, bucket, key string, holdErr, otherErr error) {
if holdErr != nil || !isErrObjectNotFound(otherErr) {
t.Fatalf("delete: %v; tags: %v, want not found", holdErr, otherErr)
}
ldAbsent(t, z, bucket, key)
},
}, {
name: "copy/tags",
setup: seed,
hold: func(ctx context.Context, z *erasureServerPools, bucket, key string) error {
return ldMetadataCopy(ctx, z, bucket, key, "1")
},
other: func(ctx context.Context, z *erasureServerPools, bucket, key string) error {
_, err := z.PutObjectTags(ctx, bucket, key, "k=v", ObjectOptions{})
return err
},
check: func(t *testing.T, z *erasureServerPools, bucket, key string, holdErr, otherErr error) {
noErrs(t, holdErr, otherErr)
oi, err := z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{})
if err != nil || oi.UserTags != "k=v" || oi.UserDefined["x-amz-meta-copied"] != "1" {
t.Errorf("after copy then tags: tags %q, meta %q, %v", oi.UserTags, oi.UserDefined["x-amz-meta-copied"], err)
}
},
}}
for _, mode := range ldModes {
t.Run(mode, func(t *testing.T) {
z, bucket := ldPool(t, mode)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
key := ldKey(t, z, strings.ReplaceAll(c.name, "/", "-"), 1)
c.setup(t, z, bucket, key)
waited, holdErr, otherErr := ldOverlapRun(t,
func(ctx context.Context) error { return c.hold(ctx, z, bucket, key) },
func(ctx context.Context) error { return c.other(ctx, z, bucket, key) })
if !waited {
holder, contender, _ := strings.Cut(c.name, "/")
t.Errorf("%s ran while %s held its commit (other: %v)", contender, holder, otherErr)
}
c.check(t, z, bucket, key, holdErr, otherErr)
})
}
})
}
}
// ldUpload starts an upload of key whose upload resource, and that of its
// first part, hash to the set of key when sameSet is set.
func ldUpload(t *testing.T, z *erasureServerPools, bucket, key string, sameSet bool) string {
t.Helper()
pool := z.serverPools[0]
for range 64 {
res, err := z.NewMultipartUpload(t.Context(), bucket, key, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
set := pool.getHashedSetIndex(key)
if !sameSet || (pool.getHashedSetIndex(pathJoin(key, res.UploadID)) == set &&
pool.getHashedSetIndex(pathJoin(key, res.UploadID, "1")) == set) {
return res.UploadID
}
if err := z.AbortMultipartUpload(t.Context(), bucket, key, res.UploadID, ObjectOptions{}); err != nil {
t.Fatal(err)
}
}
t.Fatal("no upload ID with the wanted hashes")
return ""
}
// The upload-ID guards of complete and abort exclude a PutObjectPart of the
// same upload. Get-info and list-parts read, as PutObjectPart's own guard
// does, and do not wait for it.
func TestLockDomainUploadGuards(t *testing.T) {
part := []byte("the only part")
partETag := getMD5Hash(part)
putPart := func(ctx context.Context, z *erasureServerPools, bucket, key, id string) error {
_, err := z.PutObjectPart(ctx, bucket, key, id, 1, ldReader(bytes.NewReader(part), int64(len(part))), ObjectOptions{})
return err
}
for _, mode := range ldModes {
t.Run(mode, func(t *testing.T) {
z, bucket := ldPool(t, mode)
for _, op := range []string{"complete", "abort", "info", "list"} {
t.Run(op, func(t *testing.T) {
key := ldKey(t, z, "upload-"+op, 1)
id := ldUpload(t, z, bucket, key, false)
waited, holdErr, otherErr := ldOverlapRun(t,
func(ctx context.Context) error { return putPart(ctx, z, bucket, key, id) },
func(ctx context.Context) error {
var err error
switch op {
case "complete":
_, err = z.CompleteMultipartUpload(ctx, bucket, key, id, []CompletePart{{PartNumber: 1, ETag: partETag}}, ObjectOptions{})
case "abort":
err = z.AbortMultipartUpload(ctx, bucket, key, id, ObjectOptions{})
case "info":
_, err = z.GetMultipartInfo(ctx, bucket, key, id, ObjectOptions{})
case "list":
_, err = z.ListObjectParts(ctx, bucket, key, id, 0, 10, ObjectOptions{})
}
return err
})
exclusive := op == "complete" || op == "abort"
if waited != exclusive {
t.Errorf("%s waited=%v for a part upload inside its commit, want %v (err %v)", op, waited, exclusive, otherErr)
}
if holdErr != nil || otherErr != nil {
t.Fatalf("part: %v, %s: %v", holdErr, op, otherErr)
}
switch op {
case "complete":
if body, _ := ldBody(t, z, bucket, key); body != string(part) {
t.Errorf("completed object %q", body)
}
case "abort":
if _, err := z.GetMultipartInfo(t.Context(), bucket, key, id, ObjectOptions{}); !errors.As(err, &InvalidUploadID{}) {
t.Errorf("upload after abort: %v", err)
}
}
})
}
})
}
}
// Bulk delete groups its names by set and must still wait for a completion
// of one of them, also when the batch names the completion's upload
// resource itself; neither may hold the other.
func TestLockDomainBulkDeleteAgainstUploads(t *testing.T) {
part := []byte("the only part")
partETag := getMD5Hash(part)
for _, mode := range ldModes {
t.Run(mode, func(t *testing.T) {
z, bucket := ldPool(t, mode)
for _, alias := range []bool{false, true} {
t.Run(fmt.Sprintf("complete/alias=%v", alias), func(t *testing.T) {
key := ldKey(t, z, fmt.Sprintf("bulk-complete-%v", alias), 1)
id := ldUpload(t, z, bucket, key, false)
if _, err := z.PutObjectPart(t.Context(), bucket, key, id, 1, mustGetPutObjReader(t, bytes.NewReader(part), int64(len(part)), "", ""), ObjectOptions{}); err != nil {
t.Fatal(err)
}
names := []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key}}}
if alias {
names = append(names, ObjectToDelete{ObjectV: ObjectV{ObjectName: pathJoin(key, id)}})
}
waited, holdErr, otherErr := ldOverlapRun(t,
func(ctx context.Context) error {
_, err := z.CompleteMultipartUpload(ctx, bucket, key, id, []CompletePart{{PartNumber: 1, ETag: partETag}}, ObjectOptions{})
return err
},
func(ctx context.Context) error {
_, errs := z.DeleteObjects(ctx, bucket, names, ObjectOptions{})
return errors.Join(errs...)
})
if !waited {
t.Errorf("bulk delete ran while the completion held its commit (%v)", otherErr)
}
if holdErr != nil || otherErr != nil {
t.Fatalf("complete: %v, bulk: %v", holdErr, otherErr)
}
ldAbsent(t, z, bucket, key)
})
}
// The names alias the locks of a part upload; they are other keys
// and need not wait, but neither side may block the other.
t.Run("put-part/alias", func(t *testing.T) {
key := ldKey(t, z, "bulk-part", 1)
id := ldUpload(t, z, bucket, key, true)
names := []ObjectToDelete{{ObjectV: ObjectV{ObjectName: pathJoin(key, id)}}, {ObjectV: ObjectV{ObjectName: pathJoin(key, id, "1")}}}
_, holdErr, otherErr := ldOverlapRun(t,
func(ctx context.Context) error {
_, err := z.PutObjectPart(ctx, bucket, key, id, 1, ldReader(bytes.NewReader(part), int64(len(part))), ObjectOptions{})
return err
},
func(ctx context.Context) error {
_, errs := z.DeleteObjects(ctx, bucket, names, ObjectOptions{})
return errors.Join(errs...)
})
if holdErr != nil || otherErr != nil {
t.Fatalf("part: %v, bulk: %v", holdErr, otherErr)
}
if _, err := z.CompleteMultipartUpload(t.Context(), bucket, key, id, []CompletePart{{PartNumber: 1, ETag: partETag}}, ObjectOptions{}); err != nil {
t.Fatalf("upload after the bulk delete: %v", err)
}
})
})
}
}
// A group of a bulk delete whose lock is held by a writer of its set fails
// alone; the other set's group commits. A canceled request fails the groups it
// has not run, keeps the ones it has, and leaves no lock behind.
func TestLockDomainBulkDeleteGroups(t *testing.T) {
for _, mode := range ldModes {
t.Run(mode, func(t *testing.T) {
z, bucket := ldPool(t, mode)
split := mode != ldShared
for _, cancelIt := range []bool{false, true} {
t.Run(fmt.Sprintf("cancel=%v", cancelIt), func(t *testing.T) {
k0 := ldKey(t, z, fmt.Sprintf("group-%v", cancelIt), 0)
k1 := ldKey(t, z, fmt.Sprintf("group-%v", cancelIt), 1)
ldMustPut(t, z, bucket, k0, "zero")
ldMustPut(t, z, bucket, k1, "one")
// A writer of k1 in the lock domain of its writes.
writer := z.serverPools[0].getHashedSet(k1).NewNSLock(bucket, k1)
wctx, err := writer.GetLock(t.Context(), globalOperationTimeout)
if err != nil {
t.Fatal(err)
}
var once sync.Once
unlock := func() { once.Do(func() { writer.Unlock(wctx) }) }
defer unlock()
ctx, cancel := context.WithTimeout(t.Context(), 1500*time.Millisecond)
defer cancel()
names := []ObjectToDelete{{ObjectV: ObjectV{ObjectName: k1}}, {ObjectV: ObjectV{ObjectName: k0}}}
type result struct {
objs []DeletedObject
errs []error
}
done := make(chan result, 1)
go func() {
objs, errs := z.DeleteObjects(ctx, bucket, names, ObjectOptions{})
done <- result{objs, errs}
}()
if cancelIt {
if split {
// Cancel once the first group has committed.
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := z.GetObjectInfo(t.Context(), bucket, k0, ObjectOptions{}); isErrObjectNotFound(err) {
break
}
if time.Now().After(deadline) {
t.Fatal("set 0's group did not commit")
}
time.Sleep(5 * time.Millisecond)
}
} else {
time.Sleep(200 * time.Millisecond)
}
cancel()
}
res := <-done
if res.objs[0].ObjectName != k1 || res.objs[1].ObjectName != k0 {
t.Fatalf("results out of order: %+v", res.objs)
}
if res.errs[0] == nil {
t.Errorf("k1 deleted while its writer held the lock")
}
if cancelIt && !errors.Is(res.errs[0], context.Canceled) {
t.Errorf("k1 after cancellation: %v", res.errs[0])
}
if split != (res.errs[1] == nil) {
t.Errorf("k0 (other set): %v, want committed=%v", res.errs[1], split)
}
unlock()
if body, _ := ldBody(t, z, bucket, k1); body != "one" {
t.Errorf("k1 = %q", body)
}
if split {
ldAbsent(t, z, bucket, k0)
}
// No lock is left behind.
lk := z.serverPools[0].getHashedSet(k1).NewNSLock(bucket, k1)
lctx, err := lk.GetLock(t.Context(), newDynamicTimeout(5*time.Second, 5*time.Second))
if err != nil {
t.Fatalf("lock left behind: %v", err)
}
lk.Unlock(lctx)
})
}
})
}
}
// ldBulkScenario runs one versioned bulk delete over names of both sets and
// describes its results and the versions left, without generated version IDs.
func ldBulkScenario(t *testing.T, mode string) []string {
t.Helper()
z, _ := ldPool(t, mode)
bucket := "lock-domain-versioned"
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{VersioningEnabled: true}); err != nil {
t.Fatal(err)
}
vput := func(key, body string) ObjectInfo {
oi, err := z.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, strings.NewReader(body), int64(len(body)), "", ""), ObjectOptions{Versioned: true})
if err != nil {
t.Fatal(err)
}
return oi
}
a, b, c := ldKey(t, z, "map-a", 1), ldKey(t, z, "map-b", 0), ldKey(t, z, "map-c", 1)
dir := ldKey(t, z, "map-dir", 0) + "/"
va1, va2 := vput(a, "a1"), vput(a, "a2")
vb := vput(b, "b1")
vput(c, "c1")
if _, err := z.PutObject(t.Context(), bucket, dir, mustGetPutObjReader(t, strings.NewReader(""), 0, "", ""), ObjectOptions{Versioned: true}); err != nil {
t.Fatal(err)
}
absent := mustGetUUID()
invalid := strings.Repeat("x", 1100)
names := []ObjectToDelete{
{ObjectV: ObjectV{ObjectName: a}}, // marker
{ObjectV: ObjectV{ObjectName: b, VersionID: vb.VersionID}}, // purge
{ObjectV: ObjectV{ObjectName: invalid}}, // invalid
{ObjectV: ObjectV{ObjectName: a, VersionID: va1.VersionID}}, // purge, same key
{ObjectV: ObjectV{ObjectName: c}}, // marker
{ObjectV: ObjectV{ObjectName: dir}}, // directory marker
{ObjectV: ObjectV{ObjectName: "map-missing"}}, // absent
{ObjectV: ObjectV{ObjectName: a, VersionID: va1.VersionID}}, // duplicate
{ObjectV: ObjectV{ObjectName: b, VersionID: absent}}, // absent version
}
// Names hash with the deployment ID: compare them by role.
label := strings.NewReplacer(dir, "DIR/", a, "A", b, "B", c, "C", invalid, "INVALID",
va1.VersionID, "a1", va2.VersionID, "a2", vb.VersionID, "b1", absent, "absent")
objs, errs := z.DeleteObjects(t.Context(), bucket, names, ObjectOptions{Versioned: true})
var out []string
for i := range names {
o := objs[i]
vid := label.Replace(o.VersionID)
if len(vid) > 10 {
vid = "generated"
}
out = append(out, label.Replace(fmt.Sprintf("%d %s vid=%q marker=%v markerVid=%v found=%v err=%v",
i, o.ObjectName, vid, o.DeleteMarker, o.DeleteMarkerVersionID != "", o.found, errs[i])))
}
for _, key := range []string{a, b, c, dir} {
info, err := z.ListObjectVersions(t.Context(), bucket, key, "", "", "", 100)
if err != nil {
t.Fatal(err)
}
var vs []string
for _, v := range info.Objects {
if v.Name != key {
continue
}
id := "other"
switch v.VersionID {
case va2.VersionID:
id = "a2"
case va1.VersionID:
id = "a1"
case vb.VersionID:
id = "b1"
}
vs = append(vs, fmt.Sprintf("%s:marker=%v:latest=%v", id, v.DeleteMarker, v.IsLatest))
}
out = append(out, label.Replace(key)+" "+strings.Join(vs, ","))
}
return out
}
// Grouping by set keeps every result where its name was and gives the same
// results and the same versions as the one bulk lock of a single node.
func TestLockDomainBulkDeleteMapping(t *testing.T) {
want := ldBulkScenario(t, ldShared)
for _, mode := range ldModes[1:] {
t.Run(mode, func(t *testing.T) {
got := ldBulkScenario(t, mode)
if !slices.Equal(got, want) {
t.Errorf("results differ from the single bulk lock:\n got %q\nwant %q", got, want)
}
})
}
}
// A restore takes the object lock of its set only to commit, not for its
// download. DELETE and COPY of the object wait for that commit, also where
// the sets of the pool lock with lockers of their own, and the restore still
// completes.
func TestLockDomainRestoreExcludesDeleteAndCopy(t *testing.T) {
for _, mode := range ldModes {
for _, op := range []string{"delete", "copy"} {
t.Run(mode+"/"+op, func(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
ldSplit(t, r.z, mode)
r.object = ldKey(t, r.z, "restore-"+op, 1)
oi, body := nativeRestoreSeed(t, r, 0, false, false)
waited, restoreErr, opErr := ldOverlapRun(t, func(ctx context.Context) error {
return r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
}, func(ctx context.Context) error {
switch op {
case "delete":
_, err := r.z.DeleteObject(ctx, r.bucket, r.object, r.opts(""))
return err
default:
ropts := r.opts(oi.VersionID)
ropts.NoLock = true
cur, err := r.z.GetObjectInfo(ctx, r.bucket, r.object, ropts)
if err != nil {
return err
}
cur.metadataOnly = true
cur.UserDefined = cloneMSS(cur.UserDefined)
cur.UserDefined["x-amz-meta-copied"] = "1"
vopts := r.opts(oi.VersionID)
_, err = r.z.CopyObject(ctx, r.bucket, r.object, r.bucket, r.object, cur, vopts, vopts)
return err
}
})
if !waited {
t.Errorf("%s ran during the restore's commit (%v)", op, opErr)
}
if restoreErr != nil {
t.Fatalf("restore: %v", restoreErr)
}
if opErr != nil {
t.Fatalf("%s after the restore: %v", op, opErr)
}
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
t.Fatalf("restored contents: %v", err)
}
})
}
}
}
// ldAPI is a split single pool served by the S3 API router.
func ldAPI(t *testing.T, mode string) (*erasureServerPools, string, http.Handler) {
t.Helper()
z, bucket := ldPool(t, mode)
if err := newTestConfig(globalMinioDefaultRegion, z); err != nil {
t.Fatal(err)
}
return z, bucket, initTestAPIEndPoints(z, nil)
}
func ldRequest(ctx context.Context, t *testing.T, router http.Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
req, err := newTestSignedRequestV4(method, path, int64(len(body)), strings.NewReader(body), globalActiveCred.AccessKey, globalActiveCred.SecretKey, headers)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req.WithContext(ctx))
return rec
}
func ldCopyRequest(ctx context.Context, t *testing.T, router http.Handler, bucket, src, dst string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
h := map[string]string{xhttp.AmzCopySource: "/" + bucket + "/" + src}
for k, v := range headers {
h[k] = v
}
return ldRequest(ctx, t, router, http.MethodPut, "/"+bucket+"/"+dst, "", h)
}
// The COPY handler locks its destination, and a copy onto itself reads its
// source, in the lock domain of the object's writes. A copy onto itself must
// read the version that the PUT before it committed.
func TestLockDomainCopyHandler(t *testing.T) {
for _, mode := range ldModes {
t.Run(mode, func(t *testing.T) {
z, bucket, router := ldAPI(t, mode)
for _, self := range []bool{false, true} {
t.Run(fmt.Sprintf("self=%v", self), func(t *testing.T) {
key := ldKey(t, z, fmt.Sprintf("copy-handler-%v", self), 1)
src := key + "-src"
ldMustPut(t, z, bucket, key, "seed")
ldMustPut(t, z, bucket, src, "source")
var rec *httptest.ResponseRecorder
waited, holdErr, _ := ldOverlapRun(t,
func(ctx context.Context) error { return ldPut(ctx, z, bucket, key, "written") },
func(ctx context.Context) error {
if self {
rec = ldCopyRequest(ctx, t, router, bucket, key, key, map[string]string{
xhttp.AmzMetadataDirective: "REPLACE", "x-amz-meta-copied": "1",
})
} else {
rec = ldCopyRequest(ctx, t, router, bucket, src, key, nil)
}
return nil
})
if !waited {
t.Errorf("COPY ran while a PUT of its destination held its commit")
}
if holdErr != nil || rec.Code != http.StatusOK {
t.Fatalf("put: %v; copy: %d %s", holdErr, rec.Code, rec.Body.String())
}
body, oi := ldBody(t, z, bucket, key)
want := "source"
if self {
want = "written"
if oi.UserDefined["X-Amz-Meta-Copied"] != "1" {
t.Errorf("copied metadata missing: %v", oi.UserDefined)
}
}
if body != want || oi.ETag != getMD5Hash([]byte(want)) {
t.Errorf("after the copy: body %q etag %q, want %q with its own ETag", body, oi.ETag, want)
}
})
}
})
}
}
// Copies in opposite directions always make progress: between keys of
// different sets, and where both locks share one domain, as on a single node
// or with several pools.
func TestLockDomainCopyHandlerReciprocal(t *testing.T) {
for _, mode := range append(slices.Clone(ldModes), "pools") {
t.Run(mode, func(t *testing.T) {
var z *erasureServerPools
var bucket string
var router http.Handler
keys := [2]string{"reciprocal-a", "reciprocal-b"}
if mode == "pools" {
z, bucket = bulkDeletePools(t, 2)
if err := newTestConfig(globalMinioDefaultRegion, z); err != nil {
t.Fatal(err)
}
router = initTestAPIEndPoints(z, nil)
} else {
z, bucket, router = ldAPI(t, mode)
keys = [2]string{ldKey(t, z, "reciprocal", 0), ldKey(t, z, "reciprocal", 1)}
}
// Not inlined: the source is read while the destination is written.
body := strings.Repeat("reciprocal copy source ", 1<<14)
for _, k := range keys {
ldMustPut(t, z, bucket, k, body)
}
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
for round := range 8 {
var wg sync.WaitGroup
codes := make([]int, 2)
start := make(chan struct{})
for i := range keys {
wg.Add(1)
go func() {
defer wg.Done()
<-start
codes[i] = ldCopyRequest(ctx, t, router, bucket, keys[i], keys[1-i], nil).Code
}()
}
close(start)
wg.Wait()
if codes[0] != http.StatusOK || codes[1] != http.StatusOK {
t.Fatalf("round %d: reciprocal copies returned %v", round, codes)
}
}
})
}
}
// The COPY handler looks up its destination while it holds the destination's
// lock: Object Lock's retention check and the tier sweeper's lookup must not
// take that lock again.
func TestLockDomainCopyHandlerDestinationLookups(t *testing.T) {
prevTier := globalTierConfigMgr
t.Cleanup(func() { globalTierConfigMgr = prevTier })
for _, mode := range ldModes {
t.Run(mode, func(t *testing.T) {
z, _, router := ldAPI(t, mode)
globalTierConfigMgr = NewTierConfigMgr()
globalTierConfigMgr.Tiers["WARM"] = madmin.TierConfig{Version: madmin.TierConfigVer, Type: madmin.S3, Name: "WARM", S3: &madmin.TierS3{Bucket: "warm"}}
bucket := "lock-domain-worm"
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{LockEnabled: true, VersioningEnabled: true}); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
src := ldKey(t, z, "lookup-src", 0)
if _, err := z.PutObject(t.Context(), bucket, src, mustGetPutObjReader(t, strings.NewReader(""), 0, "", ""), ObjectOptions{Versioned: true}); err != nil {
t.Fatal(err)
}
for _, dst := range []string{ldKey(t, z, "lookup-dst", 1), ldKey(t, z, "lookup-dir", 1) + "/"} {
if rec := ldCopyRequest(ctx, t, router, bucket, src, dst, nil); rec.Code != http.StatusOK {
t.Fatalf("copy to %s: %d %s", dst, rec.Code, rec.Body.String())
}
// Self copy, metadata only.
if rec := ldCopyRequest(ctx, t, router, bucket, dst, dst, map[string]string{
xhttp.AmzMetadataDirective: "REPLACE", "x-amz-meta-copied": "1",
}); rec.Code != http.StatusOK {
t.Fatalf("self copy of %s: %d %s", dst, rec.Code, rec.Body.String())
}
}
})
}
}
// SelectObjectContent reads under the lock of the object's writes.
func TestLockDomainSelectWaitsForWriter(t *testing.T) {
const request = `<SelectObjectContentRequest><Expression>SELECT * FROM S3Object</Expression><ExpressionType>SQL</ExpressionType>` +
`<InputSerialization><CSV><FileHeaderInfo>NONE</FileHeaderInfo></CSV></InputSerialization>` +
`<OutputSerialization><CSV/></OutputSerialization></SelectObjectContentRequest>`
for _, mode := range ldModes {
t.Run(mode, func(t *testing.T) {
z, bucket, router := ldAPI(t, mode)
key := ldKey(t, z, "select", 1)
ldMustPut(t, z, bucket, key, "seed,1\n")
var rec *httptest.ResponseRecorder
waited, holdErr, _ := ldOverlapRun(t,
func(ctx context.Context) error { return ldPut(ctx, z, bucket, key, "written,2\n") },
func(ctx context.Context) error {
rec = ldRequest(ctx, t, router, http.MethodPost, "/"+bucket+"/"+key+"?select&select-type=2", request, nil)
return nil
})
if !waited {
t.Errorf("SELECT ran while a PUT held its commit")
}
if holdErr != nil || rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte("written,2")) {
t.Fatalf("put: %v; select: %d %q", holdErr, rec.Code, rec.Body.String())
}
})
}
}
// Batch key rotation locks the object in the lock domain of its writes.
func TestLockDomainKeyRotationWaitsForWriter(t *testing.T) {
for _, mode := range ldModes {
t.Run(mode, func(t *testing.T) {
z, bucket := ldPool(t, mode)
key := ldKey(t, z, "rotate", 1)
ldMustPut(t, z, bucket, key, "seed")
job := &BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: sses3}}
// The rotation's listing found an SSE-S3 object; it reads it again
// under the lock.
listed := ObjectInfo{Bucket: bucket, Name: key, UserDefined: map[string]string{crypto.MetaSealedKeyS3: "listed"}}
waited, _, _ := ldOverlapRun(t,
func(ctx context.Context) error { return ldPut(ctx, z, bucket, key, "written") },
func(ctx context.Context) error { return job.KeyRotate(ctx, z, listed) })
if !waited {
t.Errorf("key rotation ran while a PUT held its commit")
}
})
}
}
// ldCopyGate holds a COPY of one destination inside the object layer's
// CopyObject, after the handler's Object Lock check, and reports when a
// metadata update of that destination reaches the object layer.
type ldCopyGate struct {
ObjectLayer
bucket, object string
copying, updating chan struct{}
release chan struct{}
copyOnce, updatingOnce sync.Once
}
func (g *ldCopyGate) CopyObject(ctx context.Context, srcBucket, srcObject, dstBucket, dstObject string, info ObjectInfo, srcOpts, dstOpts ObjectOptions) (ObjectInfo, error) {
if dstBucket == g.bucket && dstObject == g.object {
g.copyOnce.Do(func() { close(g.copying) })
select {
case <-g.release:
case <-ctx.Done():
return ObjectInfo{}, ctx.Err()
}
}
return g.ObjectLayer.CopyObject(ctx, srcBucket, srcObject, dstBucket, dstObject, info, srcOpts, dstOpts)
}
func (g *ldCopyGate) PutObjectMetadata(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
if bucket == g.bucket && object == g.object {
g.updatingOnce.Do(func() { close(g.updating) })
}
return g.ObjectLayer.PutObjectMetadata(ctx, bucket, object, opts)
}
// A retention or legal-hold update of a COPY destination cannot land between
// the handler's Object Lock check and the copy: it waits for the copy, which
// then succeeds, and is applied after it.
func TestLockDomainCopyExcludesObjectLockUpdates(t *testing.T) {
for _, mode := range append(slices.Clone(ldModes), "pools") {
t.Run(mode, func(t *testing.T) {
var z *erasureServerPools
if mode == "pools" {
z, _ = bulkDeletePools(t, 2)
} else {
z, _ = ldPool(t, mode)
}
if err := newTestConfig(globalMinioDefaultRegion, z); err != nil {
t.Fatal(err)
}
bucket := "lock-domain-worm-copy"
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{LockEnabled: true, VersioningEnabled: true}); err != nil {
t.Fatal(err)
}
plain := initTestAPIEndPoints(z, nil)
put := func(key string) {
t.Helper()
if rec := ldRequest(t.Context(), t, plain, http.MethodPut, "/"+bucket+"/"+key, "", nil); rec.Code != http.StatusOK {
t.Fatalf("PUT %s: %d %s", key, rec.Code, rec.Body.String())
}
}
put("copy-source/")
for _, self := range []bool{false, true} {
for _, state := range []string{"GOVERNANCE", "COMPLIANCE", "LEGAL-HOLD"} {
t.Run(fmt.Sprintf("self=%v/%s", self, state), func(t *testing.T) {
// Directory objects: Object Lock checks their null version.
key := fmt.Sprintf("copy-destination-%v-%s/", self, state)
if mode != "pools" {
for z.serverPools[0].getHashedSetIndex(encodeDirObject(key)) != 1 {
key = "x" + key
}
}
put(key)
g := &ldCopyGate{
ObjectLayer: z, bucket: bucket, object: key,
copying: make(chan struct{}), updating: make(chan struct{}), release: make(chan struct{}),
}
setObjectLayer(g)
defer setObjectLayer(z)
router := initTestAPIEndPoints(g, nil)
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
release := sync.OnceFunc(func() { close(g.release) })
var wg sync.WaitGroup
defer func() { release(); cancel(); wg.Wait() }()
src := "copy-source/"
if self {
src = key
}
copied, updated := make(chan *httptest.ResponseRecorder, 1), make(chan *httptest.ResponseRecorder, 1)
wg.Add(1)
go func() {
defer wg.Done()
copied <- ldCopyRequest(ctx, t, router, bucket, src, key, map[string]string{
xhttp.AmzMetadataDirective: "REPLACE", "x-amz-meta-copied": "1",
})
}()
select {
case <-g.copying:
case rec := <-copied:
t.Fatalf("COPY returned before its write: %d %s", rec.Code, rec.Body.String())
case <-ctx.Done():
t.Fatal("COPY never reached its write")
}
query := url.Values{"versionId": {nullVersionID}}
body := `<LegalHold><Status>ON</Status></LegalHold>`
if state == "LEGAL-HOLD" {
query.Set("legal-hold", "")
} else {
query.Set("retention", "")
body = fmt.Sprintf(`<Retention><Mode>%s</Mode><RetainUntilDate>%s</RetainUntilDate></Retention>`,
state, amztime.ISO8601Format(UTCNow().Add(365*24*time.Hour)))
}
sum := md5.Sum([]byte(body))
wg.Add(1)
go func() {
defer wg.Done()
updated <- ldRequest(ctx, t, router, http.MethodPut, makeTestTargetURL("", bucket, key, query), body,
map[string]string{xhttp.ContentMD5: base64.StdEncoding.EncodeToString(sum[:])})
}()
select {
case <-g.updating:
case rec := <-updated:
t.Fatalf("update returned before reaching the object layer: %d %s", rec.Code, rec.Body.String())
case <-ctx.Done():
t.Fatal("update never reached the object layer")
}
select {
case rec := <-updated:
t.Errorf("%s update ran between the COPY's Object Lock check and its write: %d", state, rec.Code)
updated <- rec
case <-time.After(ldWait):
}
release()
for name, result := range map[string]chan *httptest.ResponseRecorder{"COPY": copied, "update": updated} {
select {
case rec := <-result:
if rec.Code != http.StatusOK {
t.Errorf("%s: %d %s", name, rec.Code, rec.Body.String())
}
case <-ctx.Done():
t.Fatalf("%s did not finish", name)
}
}
after, err := z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: nullVersionID})
if err != nil {
t.Fatal(err)
}
if after.UserDefined["X-Amz-Meta-Copied"] != "1" {
t.Errorf("copied metadata missing: %v", after.UserDefined)
}
if state == "LEGAL-HOLD" {
if objectlock.GetObjectLegalHoldMeta(after.UserDefined).Status != objectlock.LegalHoldOn {
t.Error("legal hold applied before the copy was lost")
}
} else if string(objectlock.GetObjectRetentionMeta(after.UserDefined).Mode) != state {
t.Error("retention applied before the copy was lost")
}
})
}
}
})
}
}