mirror of
https://github.com/pgsty/minio.git
synced 2026-11-10 01:31:52 +03:00
32ab506924
In a single pool, DELETE in every form, bulk delete, the CopyObject destination, SelectObjectContent, batch key rotation and the upload-ID guards of complete, abort, get-info and list-parts locked through the first erasure set, while PUT, multipart commit, PutObjectPart, tag, retention, legal-hold and metadata updates, restore, transition and heal lock in the set the object hashes to. When the sets of a pool use different hosts, as every automatic layout of 18 or more hosts does, the two locks ask different lockers and do not exclude each other, and concurrent read-modify-writes of xl.meta can lose acknowledged versions and delete markers or leave an object on a minority of drives. Route these locks through NewObjectNSLock, which returns the lock of the object's own set in a single pool and the pools lock otherwise. Upload guards choose the set by their object and keep the name object/uploadID. True prefix deletion and the locks of names that are not objects keep their domain. Bulk delete groups its names by set and takes one set's lock at a time, in ascending set index; a failed group fails alone. A single node, whose sets share one local lock table, keeps its one bulk lock. The COPY handler takes its source and destination locks before it opens the source, sorted, and transactionally: bounded waits while holding a lock, release in reverse order and retry with new lock instances, one deadline. It reads the source under them, passes NoLock to every nested call, and runs that work in the locks' context. This also removes a deadlock of copies in opposite directions wherever both locks were in one domain, such as a single node or several pools. Lock names and RPCs are unchanged, but a pool is protected only when every node runs this change: affected pools must be upgraded and rolled back with a drained restart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Feng Ruohang <rh@vonng.com>
1200 lines
44 KiB
Go
1200 lines
44 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) (holdErr, otherErr error, waited bool) {
|
|
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 holdErr, otherErr, waited
|
|
}
|
|
|
|
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)
|
|
holdErr, otherErr, waited := 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 {
|
|
t.Errorf("%s ran while %s held its commit (other: %v)", c.name[strings.Index(c.name, "/")+1:], c.name[:strings.Index(c.name, "/")], 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)
|
|
holdErr, otherErr, waited := 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)}})
|
|
}
|
|
holdErr, otherErr, waited := 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 holds the object lock of its set for its download. DELETE and COPY
|
|
// of the object must wait for it.
|
|
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, _ := nativeRestoreSeed(t, r, 0, false, false)
|
|
blocked := &nativeRestoreBlockedTier{WarmBackend: globalTierConfigMgr.drivercache["WARM"], entered: make(chan struct{}), release: make(chan struct{})}
|
|
globalTierConfigMgr.drivercache["WARM"] = blocked
|
|
var once sync.Once
|
|
unblock := func() { once.Do(func() { close(blocked.release) }) }
|
|
defer unblock()
|
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
|
defer cancel()
|
|
restored := make(chan error, 1)
|
|
go func() {
|
|
restored <- r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
|
}()
|
|
select {
|
|
case <-blocked.entered:
|
|
case <-ctx.Done():
|
|
t.Fatal("restore never started its download")
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
switch op {
|
|
case "delete":
|
|
_, err := r.z.DeleteObject(ctx, r.bucket, r.object, r.opts(""))
|
|
done <- err
|
|
case "copy":
|
|
ropts := r.opts(oi.VersionID)
|
|
ropts.NoLock = true
|
|
cur, err := r.z.GetObjectInfo(ctx, r.bucket, r.object, ropts)
|
|
if err != nil {
|
|
done <- err
|
|
return
|
|
}
|
|
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)
|
|
done <- err
|
|
}
|
|
}()
|
|
var opErr error
|
|
select {
|
|
case opErr = <-done:
|
|
t.Errorf("%s ran during the restore (%v)", op, opErr)
|
|
case <-time.After(ldWait):
|
|
unblock()
|
|
opErr = <-done
|
|
}
|
|
unblock()
|
|
if err := <-restored; err != nil {
|
|
t.Fatalf("restore: %v", err)
|
|
}
|
|
if opErr != nil {
|
|
t.Fatalf("%s after the restore: %v", op, opErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
holdErr, _, waited := 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
|
|
holdErr, _, waited := 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")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|