Files
minio/cmd/erasure-server-pool-consistency_test.go
T
2026-09-15 11:27:02 +08:00

1309 lines
53 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"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
madmin "github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/bucket/replication"
xhttp "github.com/minio/minio/internal/http"
)
func consistencyPools(t *testing.T) (*erasureServerPools, string) {
t.Helper()
ctx, cancel := context.WithCancel(t.Context())
obj, dirs, err := prepareErasurePoolsWithContext(ctx)
if err != nil {
cancel()
t.Fatal(err)
}
z := obj.(*erasureServerPools)
previous := newObjectLayerFn()
setObjectLayer(z)
t.Cleanup(func() { cancel(); z.Shutdown(context.Background()); removeRoots(dirs); setObjectLayer(previous) })
bucket := "pool-consistency"
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
t.Fatal(err)
}
return z, bucket
}
func putConsistencyObject(t *testing.T, z *erasureServerPools, bucket, object string, pool int, body string, opts ObjectOptions) ObjectInfo {
t.Helper()
oi, err := z.serverPools[pool].PutObject(t.Context(), bucket, object,
mustGetPutObjReader(t, bytes.NewBufferString(body), int64(len(body)), "", ""), opts)
if err != nil {
t.Fatal(err)
}
return oi
}
func consistencyRequest(t *testing.T, router http.Handler, method, bucket, object, version string) *httptest.ResponseRecorder {
t.Helper()
url := getGetObjectURL("", bucket, object)
if version != "" {
url += "?versionId=" + version
}
req, err := newTestSignedRequestV4(method, url, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// Exercise the handler's real replication/retention callbacks, including the
// MethodNotAllowed metadata returned for an explicitly addressed delete marker.
func TestPoolsDeleteVersionAPI(t *testing.T) {
z, _ := consistencyPools(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{VersioningEnabled: true})
if err != nil {
t.Fatal(err)
}
for _, kind := range []string{"uuid", "null", "marker"} {
for primary := range 2 {
t.Run(fmt.Sprintf("%s/primary=%d", kind, primary), func(t *testing.T) {
object := fmt.Sprintf("%s-%d", kind, primary)
opts := ObjectOptions{Versioned: kind != "null", MTime: UTCNow().Add(-time.Hour)}
var addressed ObjectInfo
if kind == "marker" {
opts.VersionID, opts.DeleteMarker = mustGetUUID(), true
addressed, err = z.serverPools[primary].DeleteObject(ctx, bucket, object, opts)
if err != nil {
t.Fatal(err)
}
} else {
addressed = putConsistencyObject(t, z, bucket, object, primary, "payload", opts)
}
version := addressed.VersionID
if version == "" {
version = nullVersionID
}
opts.VersionID = version
opts.MTime = addressed.ModTime.Add(-time.Minute)
if kind == "marker" {
opts.DeleteMarker = true
if _, err := z.serverPools[1-primary].DeleteObject(ctx, bucket, object, opts); err != nil {
t.Fatal(err)
}
} else {
putConsistencyObject(t, z, bucket, object, 1-primary, "payload", opts)
}
latest := putConsistencyObject(t, z, bucket, object, 1-primary, "keep-latest", ObjectOptions{Versioned: true})
rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, version)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE: %d %s", rec.Code, rec.Body.String())
}
if kind == "marker" && (strings.Join(rec.Header()[xhttp.AmzDeleteMarker], "") != "true" || strings.Join(rec.Header()[xhttp.AmzVersionID], "") != version) {
t.Errorf("lost deleted marker response headers: %v", rec.Header())
}
for i, pool := range z.serverPools {
if _, err := pool.GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: version}); !isErrVersionNotFound(err) {
t.Errorf("pool %d retained addressed version: %v", i, err)
}
}
for _, method := range []string{http.MethodGet, http.MethodHead} {
if rec := consistencyRequest(t, router, method, bucket, object, version); rec.Code != http.StatusNotFound {
t.Errorf("%s after DELETE: %d %s", method, rec.Code, rec.Body.String())
}
}
if got, err := z.GetObjectInfo(ctx, bucket, object, ObjectOptions{}); err != nil || got.VersionID != latest.VersionID {
t.Errorf("DELETE changed another version: %+v, %v", got, err)
}
if rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, version); rec.Code != http.StatusNoContent {
t.Errorf("idempotent retry: %d %s", rec.Code, rec.Body.String())
}
})
}
}
if rec := consistencyRequest(t, router, http.MethodDelete, bucket, "absent-key", mustGetUUID()); rec.Code != http.StatusNoContent {
t.Errorf("absent key: %d %s", rec.Code, rec.Body.String())
}
}
type consistencyReadFaultDisk struct {
StorageAPI
bucket, object string
}
func (d consistencyReadFaultDisk) ReadVersion(ctx context.Context, origvolume, volume, path, version string, opts ReadOptions) (FileInfo, error) {
if volume == d.bucket && path == d.object {
return FileInfo{}, errDiskNotFound
}
return d.StorageAPI.ReadVersion(ctx, origvolume, volume, path, version, opts)
}
func (d consistencyReadFaultDisk) ReadXL(ctx context.Context, volume, path string, readData bool) (RawFileInfo, error) {
if volume == d.bucket && path == d.object {
return RawFileInfo{}, errDiskNotFound
}
return d.StorageAPI.ReadXL(ctx, volume, path, readData)
}
func TestPoolsDeleteVersionUnreadablePool(t *testing.T) {
z, _ := consistencyPools(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{VersioningEnabled: true})
if err != nil {
t.Fatal(err)
}
for _, holding := range []bool{false, true} {
for failed := range 2 {
t.Run(fmt.Sprintf("holding=%t/pool=%d", holding, failed), func(t *testing.T) {
object := fmt.Sprintf("unreadable-%t-%d", holding, failed)
oi := putConsistencyObject(t, z, bucket, object, 1-failed, "payload", ObjectOptions{Versioned: true})
if holding {
putConsistencyObject(t, z, bucket, object, failed, "payload", ObjectOptions{Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime})
}
set := z.serverPools[failed].getHashedSet(object)
getDisks := set.getDisks
faulty := append([]StorageAPI(nil), getDisks()...)
for i := range faulty {
faulty[i] = consistencyReadFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object}
}
set.getDisks = func() []StorageAPI { return faulty }
defer func() { set.getDisks = getDisks }()
if rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, oi.VersionID); rec.Code != http.StatusServiceUnavailable {
t.Errorf("unreadable pool DELETE: %d %s", rec.Code, rec.Body.String())
}
if _, err := z.serverPools[1-failed].GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: oi.VersionID}); err != nil {
t.Errorf("DELETE lost the readable copy: %v", err)
}
set.getDisks = getDisks
if rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, oi.VersionID); rec.Code != http.StatusNoContent {
t.Errorf("recovered pool retry: %d %s", rec.Code, rec.Body.String())
}
for i, pool := range z.serverPools {
if _, err := pool.GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: oi.VersionID}); !isErrVersionNotFound(err) {
t.Errorf("pool %d retained version after retry: %v", i, err)
}
}
})
}
}
}
type consistencyDeleteCountDisk struct {
StorageAPI
bucket, object string
metadataReads, deletes *atomic.Int32
}
func (d consistencyDeleteCountDisk) ReadVersion(ctx context.Context, origvolume, volume, path, version string, opts ReadOptions) (FileInfo, error) {
if volume == d.bucket && path == d.object {
d.metadataReads.Add(1)
}
return d.StorageAPI.ReadVersion(ctx, origvolume, volume, path, version, opts)
}
func (d consistencyDeleteCountDisk) DeleteVersion(ctx context.Context, volume, path string, fi FileInfo, force bool, opts DeleteOptions) error {
if volume == d.bucket && path == d.object {
d.deletes.Add(1)
}
return d.StorageAPI.DeleteVersion(ctx, volume, path, fi, force, opts)
}
func TestPoolsDeleteVersionSingleCopy(t *testing.T) {
z, bucket := consistencyPools(t)
for primary := range 2 {
t.Run(fmt.Sprintf("pool=%d", primary), func(t *testing.T) {
object := fmt.Sprintf("single-copy-%d", primary)
oi := putConsistencyObject(t, z, bucket, object, primary, "payload", ObjectOptions{Versioned: true})
var metadataReads, deletes [2]atomic.Int32
for i, pool := range z.serverPools {
set := pool.getHashedSet(object)
getDisks := set.getDisks
disks := append([]StorageAPI(nil), getDisks()...)
for j := range disks {
disks[j] = consistencyDeleteCountDisk{StorageAPI: disks[j], bucket: bucket, object: object, metadataReads: &metadataReads[i], deletes: &deletes[i]}
}
set.getDisks = func() []StorageAPI { return disks }
defer func() { set.getDisks = getDisks }()
}
metadata, retention := 0, 0
_, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{
Versioned: true, VersionID: oi.VersionID,
EvalMetadataFn: func(current *ObjectInfo, err error) (ReplicateDecision, error) {
metadata++
if err != nil || current.VersionID != oi.VersionID {
t.Errorf("metadata callback: %+v, %v", current, err)
}
// Count preflight reads before the physical delete reads its own metadata.
for i := range metadataReads {
if got := metadataReads[i].Load(); got != 16 {
t.Errorf("pool %d metadata reads before callbacks = %d; want once per disk (16)", i, got)
}
}
return ReplicateDecision{}, nil
},
EvalRetentionBypassFn: func(current ObjectInfo, err error) error {
retention++
return err
},
})
if err != nil || metadata != 1 || retention != 1 {
t.Fatalf("DELETE: %v, metadata=%d retention=%d", err, metadata, retention)
}
if deletes[primary].Load() != 16 || deletes[1-primary].Load() != 0 {
t.Errorf("physical deletes per pool = %d, %d; want once per holding disk only", deletes[0].Load(), deletes[1].Load())
}
})
}
}
func TestPoolsDeleteVersionReplicationPurge(t *testing.T) {
z, bucket := consistencyPools(t)
for _, marker := range []bool{false, true} {
t.Run(fmt.Sprintf("marker=%t", marker), func(t *testing.T) {
object := fmt.Sprintf("local-replication-purge-%t", marker)
version := mustGetUUID()
for _, pool := range z.serverPools {
opts := ObjectOptions{Versioned: true, VersionID: version, DeleteMarker: marker}
var err error
if marker {
_, err = pool.DeleteObject(t.Context(), bucket, object, opts)
} else {
_, err = pool.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, strings.NewReader("payload"), 7, "", ""), opts)
}
if err != nil {
t.Fatal(err)
}
}
dsc := ReplicateDecision{}
dsc.Set(newReplicateTargetDecision("arn1", true, false))
opts := ObjectOptions{Versioned: true, VersionID: version}
for attempt := range 2 {
calls := 0
opts.EvalMetadataFn = func(current *ObjectInfo, gerr error) (ReplicateDecision, error) {
calls++
wantMethodNotAllowed := marker || attempt > 0
if (wantMethodNotAllowed && !isErrMethodNotAllowed(gerr)) || (!wantMethodNotAllowed && gerr != nil) {
t.Errorf("pending purge callback lost set-layer read error: %v", gerr)
}
return dsc, nil
}
got, err := z.DeleteObject(t.Context(), bucket, object, opts)
if err != nil || calls != 1 || got.VersionPurgeStatus != replication.VersionPurgePending || got.replicationDecision != dsc.String() {
t.Fatalf("replication scheduling result: %+v, %v, calls=%d", got, err, calls)
}
for i, pool := range z.serverPools {
got, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: version})
if (err != nil && (!got.DeleteMarker || !isErrMethodNotAllowed(err))) || got.VersionPurgeStatus != replication.VersionPurgePending {
t.Errorf("pool %d lost pending purge: %+v, %v", i, got, err)
}
}
}
// The local worker completes the purge without ReplicationRequest.
// Both pending copies must be removed, including a pending marker purge.
opts.EvalMetadataFn = nil
opts.DeleteReplication = ReplicationState{
VersionPurgeStatusInternal: "arn1=COMPLETED;",
PurgeTargets: map[string]VersionPurgeStatusType{"arn1": replication.VersionPurgeComplete},
}
if _, err := z.DeleteObject(t.Context(), bucket, object, opts); err != nil {
t.Fatal(err)
}
for i, pool := range z.serverPools {
if _, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: version}); !isErrVersionNotFound(err) {
t.Errorf("pool %d retained completed replication purge: %v", i, err)
}
}
})
}
}
func TestPoolsDeleteVersionCleanupFailure(t *testing.T) {
z, bucket := consistencyPools(t)
for primary := range 2 {
t.Run(fmt.Sprintf("primary=%d", primary), func(t *testing.T) {
object := fmt.Sprintf("cleanup-failure-%d", primary)
oi := putConsistencyObject(t, z, bucket, object, primary, "payload", ObjectOptions{Versioned: true})
putConsistencyObject(t, z, bucket, object, 1-primary, "payload", ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime.Add(-time.Minute),
})
set := z.serverPools[1-primary].getHashedSet(object)
getDisks := set.getDisks
faulty := append([]StorageAPI(nil), getDisks()...)
for i := range faulty {
faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID}
}
set.getDisks = func() []StorageAPI { return faulty }
defer func() { set.getDisks = getDisks }()
opts := ObjectOptions{Versioned: true, VersionID: oi.VersionID}
if _, err := z.DeleteObject(t.Context(), bucket, object, opts); err == nil {
t.Error("acknowledged DELETE despite failed secondary cleanup")
}
if got, err := z.serverPools[primary].GetObjectInfo(t.Context(), bucket, object, opts); err != nil || got.ETag != oi.ETag {
t.Errorf("lost authoritative copy after secondary failure: %+v, %v", got, err)
}
set.getDisks = getDisks
if _, err := z.DeleteObject(t.Context(), bucket, object, opts); err != nil {
t.Fatal(err)
}
for i, pool := range z.serverPools {
if _, err := pool.GetObjectInfo(t.Context(), bucket, object, opts); !isErrVersionNotFound(err) {
t.Errorf("pool %d retained version after retry: %v", i, err)
}
}
})
}
}
func TestPoolsDeleteVersionCallbacks(t *testing.T) {
z, bucket := consistencyPools(t)
for _, marker := range []bool{false, true} {
t.Run(fmt.Sprintf("marker=%t", marker), func(t *testing.T) {
object := fmt.Sprintf("callbacks-%t", marker)
old, recent := "2026-09-09T09:00:00Z", "2026-09-09T10:00:00Z"
oi := putConsistencyObject(t, z, bucket, object, 0, "payload", ObjectOptions{
Versioned: true, UserDefined: poolLockMetadata("GOVERNANCE", "OFF", recent, old),
})
putConsistencyObject(t, z, bucket, object, 1, "payload", ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime,
UserDefined: poolLockMetadata("", "ON", old, recent),
})
if marker {
markerOpts := ObjectOptions{Versioned: true, VersionID: mustGetUUID(), DeleteMarker: true, MTime: UTCNow()}
for _, pool := range z.serverPools {
var err error
oi, err = pool.DeleteObject(t.Context(), bucket, object, markerOpts)
if err != nil {
t.Fatal(err)
}
}
}
check := func(current ObjectInfo, err error) {
t.Helper()
if current.VersionID != oi.VersionID || current.DeleteMarker != marker || (marker && !isErrMethodNotAllowed(err)) || (!marker && err != nil) {
t.Errorf("wrong callback version or read error: %+v, %v", current, err)
}
if !marker {
state := storedObjectLockState(current.UserDefined)
if state.mode != "GOVERNANCE" || state.legalHold != "ON" {
t.Errorf("callback did not reconcile independently ordered lock state: %+v", state)
}
}
}
for _, reject := range []string{"retention", "metadata", "none"} {
metadata, retention := 0, 0
denied := errors.New("callback denied deletion")
opts := ObjectOptions{
Versioned: true, VersionID: oi.VersionID,
EvalRetentionBypassFn: func(current ObjectInfo, err error) error {
retention++
check(current, err)
if reject == "retention" {
return denied
}
return nil
},
EvalMetadataFn: func(current *ObjectInfo, err error) (ReplicateDecision, error) {
metadata++
check(*current, err)
if reject == "metadata" {
return ReplicateDecision{}, denied
}
return ReplicateDecision{}, nil
},
}
_, err := z.DeleteObject(t.Context(), bucket, object, opts)
if reject == "none" {
if err != nil || metadata != 1 || retention != 1 {
t.Fatalf("DELETE: %v, metadata=%d retention=%d", err, metadata, retention)
}
} else if !errors.Is(err, denied) {
t.Fatalf("%s rejection was lost: %v", reject, err)
}
for i, pool := range z.serverPools {
current, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
if reject == "none" {
if !isErrVersionNotFound(err) {
t.Errorf("pool %d retained deleted version: %v", i, err)
}
} else if current.VersionID != oi.VersionID || (err != nil && (!marker || !isErrMethodNotAllowed(err))) {
t.Errorf("pool %d changed despite %s rejection: %+v, %v", i, reject, current, err)
}
}
}
})
}
}
func TestPoolsDeleteVersionSpecialCalls(t *testing.T) {
z, bucket := consistencyPools(t)
t.Run("incoming-marker-replication", func(t *testing.T) {
const object = "incoming-marker"
opts := ObjectOptions{Versioned: true, VersionID: mustGetUUID(), DeleteMarker: true, ReplicationRequest: true}
opts.SetReplicaStatus(replication.Replica)
if _, err := z.DeleteObject(t.Context(), bucket, object, opts); err != nil {
t.Fatalf("replication could not create an absent marker: %v", err)
}
if got, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: opts.VersionID}); !isErrMethodNotAllowed(err) || !got.DeleteMarker {
t.Fatalf("replicated marker missing: %+v, %v", got, err)
}
})
for _, rebalance := range []bool{false, true} {
t.Run(fmt.Sprintf("movement/rebalance=%t", rebalance), func(t *testing.T) {
object := fmt.Sprintf("movement-%t", rebalance)
putConsistencyObject(t, z, bucket, object, 1, "destination", ObjectOptions{Versioned: true})
opts := ObjectOptions{Versioned: true, VersionID: mustGetUUID(), DeleteMarker: true, MTime: UTCNow()}
if _, err := z.serverPools[0].DeleteObject(t.Context(), bucket, object, opts); err != nil {
t.Fatal(err)
}
if rebalance {
z.rebalMu.Lock()
z.rebalMeta = &rebalanceMeta{PoolStats: []*rebalanceStats{{Participating: true, Info: rebalanceInfo{Status: rebalStarted}}, {}}}
z.rebalMu.Unlock()
defer func() { z.rebalMu.Lock(); z.rebalMeta = nil; z.rebalMu.Unlock() }()
} else {
z.poolMetaMutex.Lock()
z.poolMeta.Pools[0].Decommission = &PoolDecommissionInfo{}
z.poolMetaMutex.Unlock()
defer func() { z.poolMetaMutex.Lock(); z.poolMeta.Pools[0].Decommission = nil; z.poolMetaMutex.Unlock() }()
}
// These are the marker-copy options used by rebalance/decommission;
// Source cleanup belongs to the mover.
opts.DataMovement, opts.SrcPoolIdx = true, 0
opts.SkipRebalancing, opts.SkipDecommissioned = rebalance, !rebalance
if _, err := z.DeleteObject(t.Context(), bucket, object, opts); err != nil {
t.Fatal(err)
}
for i, pool := range z.serverPools {
if got, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: opts.VersionID}); !isErrMethodNotAllowed(err) || !got.DeleteMarker {
t.Errorf("movement lost marker in pool %d: %+v, %v", i, got, err)
}
}
})
}
for _, expiration := range []bool{false, true} {
t.Run(fmt.Sprintf("scanner/expiration=%t", expiration), func(t *testing.T) {
object := fmt.Sprintf("scanner-%t", expiration)
oi := putConsistencyObject(t, z, bucket, object, 0, "payload", ObjectOptions{Versioned: true})
set := z.serverPools[1].getHashedSet(object)
getDisks := set.getDisks
faulty := append([]StorageAPI(nil), getDisks()...)
for i := range faulty {
faulty[i] = consistencyReadFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object}
}
set.getDisks = func() []StorageAPI { return faulty }
defer func() { set.getDisks = getDisks }()
opts := ObjectOptions{Versioned: true, VersionID: oi.VersionID, InclFreeVersions: !expiration, Expiration: ExpirationOptions{Expire: expiration}}
_, err := z.DeleteObject(t.Context(), bucket, object, opts)
if expiration {
// No lifecycle rule authorizes expiration. Preserve its existing
// version-not-found result, even with an unrelated unreadable pool.
if !isErrVersionNotFound(err) {
t.Errorf("expiration changed its scanner contract: %v", err)
}
} else if err != nil {
t.Errorf("free-version cleanup was forced through all-pool resolution: %v", err)
}
})
}
}
func TestPoolsDeleteUnversionedFanout(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "unversioned-fanout"
for i := range z.serverPools {
putConsistencyObject(t, z, bucket, object, i, "payload", ObjectOptions{})
}
if _, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{}); err != nil {
t.Fatal(err)
}
for i, pool := range z.serverPools {
if _, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); !isErrObjectNotFound(err) {
t.Errorf("unversioned fanout left pool %d readable: %v", i, err)
}
}
}
func TestPoolsConditionalDeleteVersionSelection(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "split-versions"
old := putConsistencyObject(t, z, bucket, object, 1, "old", ObjectOptions{Versioned: true, MTime: time.Now().Add(-time.Hour)})
latest := putConsistencyObject(t, z, bucket, object, 0, "latest", ObjectOptions{Versioned: true})
_, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{
Versioned: true, VersionID: old.VersionID, HasIfMatch: true,
CheckPrecondFn: func(oi ObjectInfo) bool { return oi.ETag != old.ETag },
})
if err != nil {
t.Fatalf("delete addressed version in pool 1: %v", err)
}
if _, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: old.VersionID}); !isErrVersionNotFound(err) {
t.Errorf("addressed version survived: %v", err)
}
if oi, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); err != nil || oi.VersionID != latest.VersionID {
t.Errorf("delete changed the latest version: %+v, %v", oi, err)
}
}
func TestPoolsConditionalDeleteDuplicateVersion(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "duplicate-version"
oi := putConsistencyObject(t, z, bucket, object, 0, "payload", ObjectOptions{Versioned: true})
putConsistencyObject(t, z, bucket, object, 1, "payload", ObjectOptions{Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime})
_, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{
Versioned: true, VersionID: oi.VersionID, HasIfMatch: true,
CheckPrecondFn: func(current ObjectInfo) bool { return current.ETag != oi.ETag },
})
if err != nil {
t.Fatal(err)
}
if _, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID}); !isErrVersionNotFound(err) {
t.Fatalf("success left a readable duplicate version: %v", err)
}
}
func TestPoolsConditionalDeleteReportsOtherPoolFailure(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "delete-failure"
putConsistencyObject(t, z, bucket, object, 1, "older", ObjectOptions{MTime: time.Now().Add(-time.Hour)})
oi := putConsistencyObject(t, z, bucket, object, 0, "latest", ObjectOptions{})
set := z.serverPools[1].getHashedSet(object)
getDisks := set.getDisks
faulty := append([]StorageAPI(nil), getDisks()...)
for i := range faulty {
faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object}
}
set.getDisks = func() []StorageAPI { return faulty }
defer func() { set.getDisks = getDisks }()
_, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{
HasIfMatch: true, CheckPrecondFn: func(current ObjectInfo) bool { return current.ETag != oi.ETag },
})
if err == nil {
t.Fatal("delete reported success despite the other pool's write failure")
}
}
func TestPoolsConditionalDeleteSerializesPut(t *testing.T) {
testPoolsConditionalDeleteWriter(t, false)
}
func TestPoolsConditionalDeleteSerializesCompletion(t *testing.T) {
testPoolsConditionalDeleteWriter(t, true)
}
func testPoolsConditionalDeleteWriter(t *testing.T, multipart bool) {
z, bucket := consistencyPools(t)
const object = "concurrent-put"
initial := putConsistencyObject(t, z, bucket, object, 1, "before", ObjectOptions{})
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
reader := mustGetPutObjReader(t, bytes.NewBufferString("after"), 5, "", "")
write := func() error {
_, err := z.PutObject(ctx, bucket, object, reader, ObjectOptions{})
return err
}
if multipart {
mp, err := z.serverPools[1].NewMultipartUpload(ctx, bucket, object, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
part, err := z.serverPools[1].PutObjectPart(ctx, bucket, object, mp.UploadID, 1, reader, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
write = func() error {
_, err := z.CompleteMultipartUpload(ctx, bucket, object, mp.UploadID,
[]CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{})
return err
}
}
checked, resume := make(chan struct{}), make(chan struct{})
var resumeOnce sync.Once
release := func() { resumeOnce.Do(func() { close(resume) }) }
defer release()
deleted := make(chan error, 1)
go func() {
_, err := z.DeleteObject(ctx, bucket, object, ObjectOptions{
HasIfMatch: true,
CheckPrecondFn: func(oi ObjectInfo) bool {
close(checked)
<-resume
return oi.ETag != initial.ETag
},
})
deleted <- err
}()
select {
case <-checked:
case err := <-deleted:
t.Fatalf("delete did not evaluate the precondition: %v", err)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
written := make(chan error, 1)
go func() { written <- write() }()
var early bool
select {
case err := <-written:
early = true
if err != nil {
t.Errorf("concurrent PUT: %v", err)
}
case <-time.After(250 * time.Millisecond):
}
release()
if err := <-deleted; err != nil {
t.Fatal(err)
}
if !early {
if err := <-written; err != nil {
t.Fatal(err)
}
}
if early {
t.Error("PUT committed while DELETE was between its comparison and removal")
}
if _, err := z.GetObjectInfo(ctx, bucket, object, ObjectOptions{}); err != nil {
t.Errorf("matching the old ETag removed the concurrent replacement: %v", err)
}
}
type consistencyGateReader struct {
io.Reader
entered, resume chan struct{}
once sync.Once
release sync.Once
}
func (r *consistencyGateReader) Read(p []byte) (int, error) {
r.once.Do(func() { close(r.entered); <-r.resume })
return r.Reader.Read(p)
}
func TestPoolsReplicaSerializesMetadataAndHealing(t *testing.T) {
for _, heal := range []bool{false, true} {
t.Run(fmt.Sprintf("heal=%t", heal), func(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "metadata-race"
old, recent := "2026-09-09T09:00:00Z", "2026-09-09T10:00:00Z"
meta := poolLockMetadata("GOVERNANCE", "OFF", old, old)
oi := putConsistencyObject(t, z, bucket, object, 1, "data", ObjectOptions{Versioned: true, UserDefined: meta})
z.poolMetaMutex.Lock()
z.poolMeta.Pools[0].Decommission = &PoolDecommissionInfo{}
z.poolMetaMutex.Unlock()
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
gate := &consistencyGateReader{Reader: strings.NewReader("data"), entered: make(chan struct{}), resume: make(chan struct{})}
release := func() { gate.release.Do(func() { close(gate.resume) }) }
defer release()
reader := mustGetPutObjReader(t, gate, 4, "", "")
written := make(chan error, 1)
go func() {
_, err := z.PutObject(ctx, bucket, object, reader, ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime,
ReplicaLockReconcile: true, UserDefined: maps.Clone(meta),
})
written <- err
}()
select {
case <-gate.entered:
case err := <-written:
t.Fatalf("write failed before consuming the body: %v", err)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
mutated := make(chan error, 1)
go func() {
var err error
if heal {
_, err = z.healObjectInPool(ctx, z.serverPools[1].getHashedSet(object), bucket, object, oi.VersionID, madmin.HealOpts{})
} else {
_, err = z.PutObjectMetadata(ctx, bucket, object, ObjectOptions{
VersionID: oi.VersionID, MTime: oi.ModTime,
EvalMetadataFn: func(current *ObjectInfo, _ error) (ReplicateDecision, error) {
current.UserDefined[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = "ON"
current.UserDefined[ReservedMetadataPrefixLower+ObjectLockLegalHoldTimestamp] = recent
return ReplicateDecision{}, nil
},
})
}
mutated <- err
}()
var early bool
select {
case err := <-mutated:
early = true
t.Errorf("mutation completed during the paused replica write: %v", err)
case <-time.After(250 * time.Millisecond):
}
release()
if err := <-written; err != nil {
t.Fatal(err)
}
if !early {
if err := <-mutated; err != nil {
t.Fatal(err)
}
}
got, err := z.GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: oi.VersionID})
if err != nil {
t.Fatal(err)
}
if !heal && storedObjectLockState(got.UserDefined).legalHold != "ON" {
t.Error("replica write rolled back the newer metadata update")
}
})
}
}
func TestPoolsConditionalDeletePreservesVersionHistory(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "conditional-marker"
older := putConsistencyObject(t, z, bucket, object, 1, "older", ObjectOptions{Versioned: true, MTime: time.Now().Add(-time.Hour)})
latest := putConsistencyObject(t, z, bucket, object, 0, "latest", ObjectOptions{Versioned: true})
_, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{
Versioned: true,
CheckPrecondFn: func(oi ObjectInfo) bool { return oi.ETag != older.ETag },
})
if !isErrPreconditionFailed(err) {
t.Fatalf("wrong latest ETag: %v", err)
}
marker, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{
Versioned: true,
CheckPrecondFn: func(oi ObjectInfo) bool { return oi.ETag != latest.ETag },
})
if err != nil || !marker.DeleteMarker {
t.Fatalf("logical delete did not create a marker: %+v, %v", marker, err)
}
if got, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); !isErrObjectNotFound(err) || !got.DeleteMarker {
t.Errorf("delete marker did not hide the split object: %+v, %v", got, err)
}
for _, version := range []string{older.VersionID, latest.VersionID} {
if _, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: version}); err != nil {
t.Errorf("conditional marker removed history %s: %v", version, err)
}
}
}
func poolLockMetadata(retention, hold, retentionTime, holdTime string) map[string]string {
return map[string]string{
strings.ToLower(xhttp.AmzObjectLockMode): retention,
strings.ToLower(xhttp.AmzObjectLockRetainUntilDate): "2030-01-01T00:00:00Z",
strings.ToLower(xhttp.AmzObjectLockLegalHold): hold,
ReservedMetadataPrefixLower + ObjectLockRetentionTimestamp: retentionTime,
ReservedMetadataPrefixLower + ObjectLockLegalHoldTimestamp: holdTime,
}
}
func TestPoolsReplicaIndependentLockWinners(t *testing.T) {
for _, tc := range []struct {
name string
multipart, rebalance bool
}{
{"put-decommission", false, false},
{"put-rebalance", false, true},
{"multipart-decommission", true, false},
{"multipart-rebalance", true, true},
} {
t.Run(tc.name, func(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "split-lock-state"
old, recent := "2026-09-09T09:00:00Z", "2026-09-09T10:00:00Z"
first := poolLockMetadata("GOVERNANCE", "OFF", recent, old)
second := poolLockMetadata("", "ON", old, recent)
oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{Versioned: true, UserDefined: first})
putConsistencyObject(t, z, bucket, object, 1, "data", ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime, UserDefined: second,
})
opts := ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime,
ReplicaLockReconcile: true, UserDefined: poolLockMetadata("", "OFF", old, old),
}
var uploadID string
var parts []CompletePart
if tc.multipart {
mp, err := z.serverPools[1].NewMultipartUpload(t.Context(), bucket, object, opts)
if err != nil {
t.Fatal(err)
}
uploadID = mp.UploadID
part, err := z.serverPools[1].PutObjectPart(t.Context(), bucket, object, uploadID, 1,
mustGetPutObjReader(t, bytes.NewBufferString("data"), 4, "", ""), ObjectOptions{})
if err != nil {
t.Fatal(err)
}
parts = []CompletePart{{PartNumber: 1, ETag: part.ETag}}
}
// The upload and both versions predate the routing change. Retention's
// winner remains in the draining pool; legal hold's winner is in pool 1.
if tc.rebalance {
z.rebalMu.Lock()
z.rebalMeta = &rebalanceMeta{PoolStats: []*rebalanceStats{
{Participating: true, Info: rebalanceInfo{Status: rebalStarted}}, {},
}}
z.rebalMu.Unlock()
} else {
z.poolMetaMutex.Lock()
z.poolMeta.Pools[0].Decommission = &PoolDecommissionInfo{}
z.poolMetaMutex.Unlock()
}
var err error
if tc.multipart {
_, err = z.CompleteMultipartUpload(t.Context(), bucket, object, uploadID, parts,
ObjectOptions{Versioned: true, MTime: oi.ModTime, ReplicaLockReconcile: true})
} else {
_, err = z.PutObject(t.Context(), bucket, object,
mustGetPutObjReader(t, bytes.NewBufferString("data"), 4, "", ""), opts)
}
if err != nil {
t.Fatal(err)
}
got, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
if err != nil {
t.Fatal(err)
}
state := storedObjectLockState(got.UserDefined)
if state.mode != "GOVERNANCE" || state.retentionTimestamp != recent || state.legalHold != "ON" || state.legalHoldTimestamp != recent {
t.Errorf("pooled read lost independently ordered lock state: %+v", state)
}
if _, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID}); !isErrVersionNotFound(err) {
t.Errorf("successful replacement left its stale competing copy: %v", err)
}
})
}
}
func TestPoolsReplicaSoleDrainingOwner(t *testing.T) {
for _, rebalance := range []bool{false, true} {
for _, null := range []bool{false, true} {
t.Run(fmt.Sprintf("rebalance=%t/null=%t", rebalance, null), func(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "sole-owner"
old, recent := "2026-09-09T09:00:00Z", "2026-09-09T10:00:00Z"
meta := poolLockMetadata("", "ON", recent, recent)
delete(meta, strings.ToLower(xhttp.AmzObjectLockRetainUntilDate))
oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{Versioned: !null, UserDefined: meta})
versionID := oi.VersionID
if null {
versionID = nullVersionID
// A different latest version must not donate its lock to the null version.
putConsistencyObject(t, z, bucket, object, 1, "other-version", ObjectOptions{Versioned: true})
}
if rebalance {
z.rebalMu.Lock()
z.rebalMeta = &rebalanceMeta{PoolStats: []*rebalanceStats{
{Participating: true, Info: rebalanceInfo{Status: rebalStarted}}, {},
}}
z.rebalMu.Unlock()
} else {
z.poolMetaMutex.Lock()
z.poolMeta.Pools[0].Decommission = &PoolDecommissionInfo{}
z.poolMetaMutex.Unlock()
}
_, err := z.PutObject(t.Context(), bucket, object,
mustGetPutObjReader(t, bytes.NewBufferString("data"), 4, "", ""), ObjectOptions{
Versioned: !null, VersionSuspended: null, VersionID: versionID, MTime: oi.ModTime,
ReplicaLockReconcile: true, UserDefined: poolLockMetadata("GOVERNANCE", "OFF", old, old),
})
if err != nil {
t.Fatal(err)
}
got, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: versionID})
if err != nil {
t.Fatal(err)
}
state := storedObjectLockState(got.UserDefined)
if state.mode != "" || state.retainUntil != "" || state.retentionTimestamp != recent || state.legalHold != "ON" {
t.Errorf("lost a removal or legal hold from the sole draining owner: %+v", state)
}
})
}
}
}
func TestPoolsMetadataUpdateUsesMergedVersion(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "metadata-update"
old, recent := "2026-09-09T09:00:00Z", "2026-09-09T10:00:00Z"
oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{
Versioned: true, UserDefined: poolLockMetadata("GOVERNANCE", "OFF", recent, old),
})
putConsistencyObject(t, z, bucket, object, 1, "data", ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime, UserDefined: poolLockMetadata("", "ON", old, recent),
})
latest := putConsistencyObject(t, z, bucket, object, 1, "latest", ObjectOptions{Versioned: true})
called := 0
_, err := z.PutObjectMetadata(t.Context(), bucket, object, ObjectOptions{
VersionID: oi.VersionID, MTime: oi.ModTime,
EvalMetadataFn: func(current *ObjectInfo, _ error) (ReplicateDecision, error) {
called++
state := storedObjectLockState(current.UserDefined)
if state.mode != "GOVERNANCE" || state.legalHold != "ON" {
return ReplicateDecision{}, fmt.Errorf("metadata policy evaluated stale state: %+v", state)
}
current.UserDefined["custom-update"] = "preserved"
return ReplicateDecision{}, nil
},
})
if err != nil || called != 1 {
t.Fatalf("metadata update: %v; callback count %d", err, called)
}
for i, pool := range z.serverPools {
got, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
if err != nil {
t.Fatal(err)
}
state := storedObjectLockState(got.UserDefined)
if state.mode != "GOVERNANCE" || state.legalHold != "ON" || got.UserDefined["custom-update"] != "preserved" {
t.Errorf("pool %d did not receive the merged update: %+v", i, got.UserDefined)
}
}
if current, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); err != nil || current.VersionID != latest.VersionID {
t.Errorf("updating an older version changed the latest: %+v, %v", current, err)
}
}
func TestPoolsReplicaMetadataCopyReconcilesLockAndTags(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "replica-metadata-copy"
old, recent := "2026-09-09T09:00:00Z", "2026-09-09T10:00:00Z"
meta := poolLockMetadata("GOVERNANCE", "OFF", recent, old)
meta[xhttp.AmzObjectTagging] = "key=new"
meta[ReservedMetadataPrefixLower+TaggingTimestamp] = recent
oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{Versioned: true, UserDefined: meta})
putConsistencyObject(t, z, bucket, object, 1, "data", ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime, UserDefined: poolLockMetadata("", "ON", old, recent),
})
stale := oi
stale.metadataOnly = true
stale.UserDefined = maps.Clone(oi.UserDefined)
stale.UserDefined[xhttp.AmzObjectTagging] = "key=old"
stale.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = old
_, err := z.CopyObject(t.Context(), bucket, object, bucket, object, stale,
ObjectOptions{VersionID: oi.VersionID}, ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime, ReplicaLockReconcile: true,
})
if err != nil {
t.Fatal(err)
}
got, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
if err != nil {
t.Fatal(err)
}
state := storedObjectLockState(got.UserDefined)
if state.mode != "GOVERNANCE" || state.legalHold != "ON" || got.UserTags != "key=new" {
t.Errorf("metadata replication rolled back a newer field: %+v", got.UserDefined)
}
}
func TestPoolsReplicaCleanupFailureCanRetry(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "replica-cleanup-failure"
old, recent := "2026-09-09T09:00:00Z", "2026-09-09T10:00:00Z"
oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{
Versioned: true, UserDefined: poolLockMetadata("GOVERNANCE", "ON", recent, recent),
})
z.poolMetaMutex.Lock()
z.poolMeta.Pools[0].Decommission = &PoolDecommissionInfo{}
z.poolMetaMutex.Unlock()
set := z.serverPools[0].getHashedSet(object)
getDisks := set.getDisks
faulty := append([]StorageAPI(nil), getDisks()...)
for i := range faulty {
faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID}
}
set.getDisks = func() []StorageAPI { return faulty }
defer func() { set.getDisks = getDisks }()
write := func() error {
_, err := z.PutObject(t.Context(), bucket, object,
mustGetPutObjReader(t, bytes.NewBufferString("data"), 4, "", ""), ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime, ReplicaLockReconcile: true,
UserDefined: poolLockMetadata("", "OFF", old, old),
})
return err
}
if err := write(); err == nil {
t.Fatal("replica replacement hid a competing-copy cleanup failure")
}
if got, err := z.serverPools[1].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID}); err != nil || storedObjectLockState(got.UserDefined).legalHold != "ON" {
t.Fatalf("cleanup failure lost the committed reconciled version: %+v, %v", got, err)
}
set.getDisks = getDisks
if err := write(); err != nil {
t.Fatalf("retry could not finish cleanup: %v", err)
}
if _, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID}); !isErrVersionNotFound(err) {
t.Errorf("retry left the competing version: %v", err)
}
}
func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
for _, test := range []struct {
name string
deleting bool
failPrimaryDelete bool
differentRemote bool
restored bool
unconditional bool
skipFreeVersion bool
}{
{name: "metadata-copy"},
{name: "restored-metadata-copy", restored: true},
{name: "metadata-copy-distinct-reference", differentRemote: true},
{name: "failed-primary-delete", deleting: true, failPrimaryDelete: true},
{name: "failed-primary-delete-distinct-reference", deleting: true, failPrimaryDelete: true, differentRemote: true},
{name: "successful-delete", deleting: true},
{name: "ordinary-failed-primary-delete", deleting: true, unconditional: true, failPrimaryDelete: true},
{name: "ordinary-successful-delete", deleting: true, unconditional: true},
{name: "ordinary-skip-free-version", deleting: true, unconditional: true, skipFreeVersion: true},
} {
t.Run(test.name, func(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "shared-tier-object"
metadata := map[string]string{
ReservedMetadataPrefixLower + TransitionStatus: "complete",
ReservedMetadataPrefixLower + TransitionTier: "TEST-TIER",
ReservedMetadataPrefixLower + TransitionedObjectName: "shared-remote-object",
ReservedMetadataPrefixLower + TransitionedVersionID: "shared-remote-version",
}
if test.restored {
metadata[xhttp.AmzRestore] = completedRestoreObj(time.Now().Add(time.Hour)).String()
}
oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{Versioned: true, UserDefined: metadata})
secondaryMetadata := maps.Clone(metadata)
if test.differentRemote {
secondaryMetadata[ReservedMetadataPrefixLower+TransitionedObjectName] = "other-remote-object"
}
putConsistencyObject(t, z, bucket, object, 1, "data", ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime, UserDefined: secondaryMetadata,
})
current, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
if err != nil || current.TransitionedObject.Status != "complete" || current.IsRemote() == test.restored {
t.Fatalf("fixture did not persist the tier reference: %+v, %v", current.TransitionedObject, err)
}
if test.failPrimaryDelete {
// The authoritative copy remains readable if its deletion fails.
// Retiring a secondary copy must not schedule its shared remote
// contents for garbage collection in that case.
set := z.serverPools[0].getHashedSet(object)
getDisks := set.getDisks
faulty := append([]StorageAPI(nil), getDisks()...)
for i := range faulty {
faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID}
}
set.getDisks = func() []StorageAPI { return faulty }
defer func() { set.getDisks = getDisks }()
}
if test.deleting {
opts := ObjectOptions{
Versioned: true, VersionID: oi.VersionID, SkipFreeVersion: test.skipFreeVersion,
CheckPrecondFn: func(info ObjectInfo) bool { return info.ETag != current.ETag },
}
if test.unconditional {
opts.CheckPrecondFn = nil
}
_, err := z.DeleteObject(t.Context(), bucket, object, opts)
if (err != nil) != test.failPrimaryDelete {
t.Fatalf("unexpected authoritative delete result: %v", err)
}
} else {
current.metadataOnly = true
current.UserDefined["metadata-update"] = "new"
_, err := z.CopyObject(t.Context(), bucket, object, bucket, object, current,
ObjectOptions{VersionID: oi.VersionID}, ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime, ReplicaLockReconcile: true,
})
if err != nil {
t.Fatal(err)
}
}
_, err = z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
primaryDeleted := test.deleting && !test.failPrimaryDelete
if primaryDeleted {
if !isErrVersionNotFound(err) {
t.Fatalf("authoritative copy survived successful delete: %v", err)
}
} else if err != nil {
t.Fatalf("lost retained authoritative copy: %v", err)
}
for pool, wantFree := range []bool{primaryDeleted, test.differentRemote} {
wantFree = wantFree && !test.skipFreeVersion
for _, disk := range z.serverPools[pool].getHashedSet(object).getDisks() {
data, err := disk.ReadAll(t.Context(), bucket, pathJoin(object, xlStorageFormatFile))
if errors.Is(err, errFileNotFound) && !wantFree {
continue
}
if err != nil {
t.Fatal(err)
}
versions, err := getFileInfoVersions(data, bucket, object, false)
if err != nil {
t.Fatal(err)
}
wantCount := 0
if wantFree {
wantCount = 1
}
if len(versions.FreeVersions) != wantCount {
t.Fatalf("pool %d has %d tier GC markers, want %d", pool, len(versions.FreeVersions), wantCount)
}
}
}
})
}
}
// Fault injection shared by general multi-pool regressions.
type consistencyDeleteFaultDisk struct {
StorageAPI
bucket, object, version string
}
func (d consistencyDeleteFaultDisk) DeleteVersion(ctx context.Context, volume, path string, fi FileInfo, forceDelMarker bool, opts DeleteOptions) error {
if volume == d.bucket && path == d.object && fi.VersionID == d.version {
return errDiskFull
}
return d.StorageAPI.DeleteVersion(ctx, volume, path, fi, forceDelMarker, opts)
}
// Exercise the surviving production rebalance copy, then interrupt the workflow
// before its later source cleanup. Repeated versions are reachable without the
// retired access-tier mover, and an API delete must remove both copies.
func TestPoolsDeleteVersionAfterInterruptedRebalance(t *testing.T) {
z, _ := consistencyPools(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{VersioningEnabled: true})
if err != nil {
t.Fatal(err)
}
for source := range 2 {
t.Run(fmt.Sprintf("source=%d", source), func(t *testing.T) {
object := fmt.Sprintf("interrupted-rebalance-%d", source)
original := putConsistencyObject(t, z, bucket, object, source, "original", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour)})
stats := []*rebalanceStats{{}, {}}
stats[source] = &rebalanceStats{Participating: true, Info: rebalanceInfo{Status: rebalStarted}}
z.rebalMu.Lock()
z.rebalMeta = &rebalanceMeta{PoolStats: stats}
z.rebalMu.Unlock()
defer func() { z.rebalMu.Lock(); z.rebalMeta = nil; z.rebalMu.Unlock() }()
gr, err := z.serverPools[source].GetObjectNInfo(ctx, bucket, object, nil, nil, ObjectOptions{VersionID: original.VersionID, NoLock: true})
if err != nil {
t.Fatal(err)
}
if err := z.rebalanceObject(ctx, source, bucket, gr); err != nil {
t.Fatalf("rebalance copy: %v", err)
}
// Simulate interruption after rebalanceObject, before the enclosing loop
// removes the source version stack. Stop rebalance before the API request.
z.rebalMu.Lock()
z.rebalMeta = nil
z.rebalMu.Unlock()
for i, pool := range z.serverPools {
got, err := pool.GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: original.VersionID})
if err != nil || got.VersionID != original.VersionID || !got.ModTime.Equal(original.ModTime) {
t.Fatalf("copy missing/changed in pool %d: %+v, %v", i, got, err)
}
}
later, err := z.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewBufferString("later"), 5, "", ""), ObjectOptions{Versioned: true})
if err != nil {
t.Fatal(err)
}
rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, original.VersionID)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE: %d %s", rec.Code, rec.Body.String())
}
for i, pool := range z.serverPools {
if _, err := pool.GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: original.VersionID}); !isErrVersionNotFound(err) {
t.Errorf("pool %d retained old version: %v", i, err)
}
}
if got, err := z.GetObjectInfo(ctx, bucket, object, ObjectOptions{}); err != nil || got.VersionID != later.VersionID {
t.Fatalf("other version changed: %+v, %v", got, err)
}
for _, method := range []string{http.MethodGet, http.MethodHead} {
if rec := consistencyRequest(t, router, method, bucket, object, original.VersionID); rec.Code != http.StatusNotFound {
t.Errorf("%s returned %d", method, rec.Code)
}
}
})
}
}
func TestPoolsDeleteDirectoryMarker(t *testing.T) {
z, _ := consistencyPools(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{VersioningEnabled: true})
if err != nil {
t.Fatal(err)
}
for holding := range 2 {
for _, unreadable := range []bool{false, true} {
t.Run(fmt.Sprintf("holding=%d/unreadable=%t", holding, unreadable), func(t *testing.T) {
object := fmt.Sprintf("directory-%d-%t/", holding, unreadable)
encoded := encodeDirObject(object)
putConsistencyObject(t, z, bucket, encoded, holding, "", ObjectOptions{})
set := z.serverPools[1-holding].getHashedSet(encoded)
getDisks := set.getDisks
defer func() { set.getDisks = getDisks }()
if unreadable {
disks := append([]StorageAPI(nil), getDisks()...)
for i := range disks {
disks[i] = consistencyReadFaultDisk{StorageAPI: disks[i], bucket: bucket, object: encoded}
}
set.getDisks = func() []StorageAPI { return disks }
}
// No explicit versionId: delOpts permanently deletes the null version.
rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, "")
if unreadable {
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("unreadable pool: %d %s", rec.Code, rec.Body.String())
}
if _, err := z.serverPools[holding].GetObjectInfo(ctx, bucket, encoded, ObjectOptions{VersionID: nullVersionID}); err != nil {
t.Fatalf("readable directory marker lost: %v", err)
}
set.getDisks = getDisks
rec = consistencyRequest(t, router, http.MethodDelete, bucket, object, "")
}
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE: %d %s", rec.Code, rec.Body.String())
}
for i, pool := range z.serverPools {
if _, err := pool.GetObjectInfo(ctx, bucket, encoded, ObjectOptions{VersionID: nullVersionID}); !isErrVersionNotFound(err) {
t.Errorf("pool %d retained null version: %v", i, err)
}
}
if rec := consistencyRequest(t, router, http.MethodHead, bucket, object, ""); rec.Code != http.StatusNotFound {
t.Errorf("directory still visible: %d", rec.Code)
}
})
}
}
}