mirror of
https://github.com/pgsty/minio.git
synced 2026-10-29 03:57:19 +03:00
ea7c794a94
Signed-off-by: Feng Ruohang <rh@vonng.com>
521 lines
18 KiB
Go
521 lines
18 KiB
Go
// Copyright (c) 2026 Feng Ruohang
|
|
//
|
|
// This file is part of Silo Object Storage.
|
|
//
|
|
// 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"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/minio/minio/internal/grid"
|
|
)
|
|
|
|
// Pause at the real disk health callback after staging xl.meta. Unlike a sleep,
|
|
// this fixes cancellation between staging and the final metadata rename.
|
|
type stagedRenameContext struct {
|
|
context.Context
|
|
meta string
|
|
entered, release chan struct{}
|
|
once sync.Once
|
|
tracker healthDiskCtxValue
|
|
}
|
|
|
|
func (c *stagedRenameContext) Value(key any) any {
|
|
if _, ok := key.(healthDiskCtxKey); ok {
|
|
if _, err := os.Stat(c.meta); err == nil {
|
|
c.once.Do(func() {
|
|
close(c.entered)
|
|
<-c.release
|
|
})
|
|
}
|
|
return &c.tracker
|
|
}
|
|
return c.Context.Value(key)
|
|
}
|
|
|
|
func TestRenameCancellationLocalWaitsForDisk(t *testing.T) {
|
|
disk, root, err := newXLStorageTestSetup(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer disk.Close()
|
|
if err := disk.MakeVol(t.Context(), "bucket"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
var lastSuccess int64
|
|
gate := &stagedRenameContext{
|
|
Context: ctx, meta: filepath.Join(root, minioMetaTmpBucket, "staged", xlStorageFormatFile),
|
|
entered: make(chan struct{}), release: make(chan struct{}),
|
|
tracker: healthDiskCtxValue{lastSuccess: &lastSuccess},
|
|
}
|
|
fi := newFileInfo("object", 2, 2)
|
|
fi.DataDir, fi.ModTime, fi.Erasure.Index = mustGetUUID(), UTCNow(), 1
|
|
fi.Fresh = true
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := disk.RenameData(gate, minioMetaTmpBucket, "staged", fi, "bucket", "object", RenameOptions{})
|
|
done <- err
|
|
}()
|
|
<-gate.entered
|
|
cancel()
|
|
synctest.Wait()
|
|
select {
|
|
case err := <-done:
|
|
t.Errorf("RenameData returned %v while its disk worker was still running", err)
|
|
default:
|
|
}
|
|
close(gate.release)
|
|
synctest.Wait()
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("canceled staged write: %v", err)
|
|
}
|
|
default:
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "bucket", "object", xlStorageFormatFile)); !os.IsNotExist(err) {
|
|
t.Fatalf("canceled staged write committed metadata: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRenameCancellationRPCWaitsForDisk(t *testing.T) {
|
|
for _, operation := range []string{"regular", "inline", "write-metadata", "update-metadata", "undo"} {
|
|
t.Run(operation, func(t *testing.T) {
|
|
tg, err := grid.SetupTestGrid(2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tg.Cleanup()
|
|
entered, release := make(chan struct{}), make(chan struct{})
|
|
var committed atomic.Bool
|
|
handle := func(*RenameDataHandlerParams) (*RenameDataResp, *grid.RemoteErr) {
|
|
close(entered)
|
|
<-release
|
|
committed.Store(true)
|
|
return &RenameDataResp{OldDataDir: "previous-generation"}, nil
|
|
}
|
|
const route = "/cancel-commit"
|
|
if err := storageRenameDataRPC.Register(tg.Managers[1], handle, route); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := storageRenameDataInlineRPC.Register(tg.Managers[1], func(p *RenameDataInlineHandlerParams) (*RenameDataResp, *grid.RemoteErr) {
|
|
defer p.Recycle()
|
|
return handle(&p.RenameDataHandlerParams)
|
|
}, route); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta := func(*MetadataHandlerParams) (grid.NoPayload, *grid.RemoteErr) {
|
|
_, err := handle(nil)
|
|
return grid.NewNoPayload(), err
|
|
}
|
|
if err := storageWriteMetadataRPC.Register(tg.Managers[1], meta, route); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := storageUpdateMetadataRPC.Register(tg.Managers[1], meta, route); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := storageDeleteVersionRPC.Register(tg.Managers[1], func(*DeleteVersionHandlerParams) (grid.NoPayload, *grid.RemoteErr) {
|
|
return meta(nil)
|
|
}, route); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client := &storageRESTClient{gridConn: tg.Managers[0].Connection(tg.Hosts[1]).Subroute(route)}
|
|
client.diskID.Store(new(string))
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
fi := FileInfo{}
|
|
if operation == "inline" {
|
|
fi.Data = []byte("payload")
|
|
}
|
|
type result struct {
|
|
resp RenameDataResp
|
|
err error
|
|
}
|
|
done := make(chan result, 1)
|
|
go func() {
|
|
var resp RenameDataResp
|
|
var err error
|
|
switch operation {
|
|
case "write-metadata":
|
|
err = client.WriteMetadata(ctx, "", "bucket", "object", fi)
|
|
case "update-metadata":
|
|
err = client.UpdateMetadata(ctx, "bucket", "object", fi, UpdateMetadataOpts{})
|
|
case "undo":
|
|
err = client.DeleteVersion(ctx, "bucket", "object", fi, false, DeleteOptions{UndoWrite: true})
|
|
default:
|
|
resp, err = client.RenameData(ctx, minioMetaTmpBucket, "staged", fi, "bucket", "object", RenameOptions{})
|
|
}
|
|
done <- result{resp, err}
|
|
}()
|
|
<-entered
|
|
cancel()
|
|
var early *result
|
|
select {
|
|
case r := <-done:
|
|
early = &r
|
|
t.Errorf("RPC returned %v before the remote disk completed", r.err)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
close(release)
|
|
if early == nil {
|
|
select {
|
|
case r := <-done:
|
|
wantOld := ""
|
|
if operation == "regular" || operation == "inline" {
|
|
wantOld = "previous-generation"
|
|
}
|
|
if r.err != nil || r.resp.OldDataDir != wantOld || !committed.Load() {
|
|
t.Errorf("lost completed result needed for rollback: %+v", r)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("RPC did not finish after disk completion")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenameCancellationAlreadyCanceledRPC(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
// A canceled call must not access the connection or send a new mutation.
|
|
client := &storageRESTClient{}
|
|
client.diskID.Store(new(string))
|
|
defer func() {
|
|
if p := recover(); p != nil {
|
|
t.Errorf("canceled mutation reached the connection: %v", p)
|
|
}
|
|
}()
|
|
_, err := client.RenameData(ctx, minioMetaTmpBucket, "staged", FileInfo{}, "bucket", "object", RenameOptions{})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("pre-canceled RenameData: %v", err)
|
|
}
|
|
}
|
|
|
|
type gatedRenameDisk struct {
|
|
StorageAPI
|
|
client *storageRESTClient
|
|
}
|
|
|
|
func (d gatedRenameDisk) RenameData(ctx context.Context, sv, sp string, fi FileInfo, dv, dp string, opts RenameOptions) (RenameDataResp, error) {
|
|
return d.client.RenameData(ctx, sv, sp, fi, dv, dp, opts)
|
|
}
|
|
|
|
func (d gatedRenameDisk) WriteMetadata(ctx context.Context, ov, volume, path string, fi FileInfo) error {
|
|
return d.client.WriteMetadata(ctx, ov, volume, path, fi)
|
|
}
|
|
|
|
func (d gatedRenameDisk) DeleteVersion(ctx context.Context, volume, path string, fi FileInfo, force bool, opts DeleteOptions) error {
|
|
return d.client.DeleteVersion(ctx, volume, path, fi, force, opts)
|
|
}
|
|
|
|
// Use real disk commits behind the real RPC client. The barrier fixes the
|
|
// order: every commit has started, then cancellation, then remote completion.
|
|
func TestRenameCancellationKeepsLockUntilRollback(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
size int
|
|
versioned bool
|
|
copy bool
|
|
shared bool
|
|
}{
|
|
{32, false, false, false},
|
|
{2 << 20, false, false, false},
|
|
{32, true, false, false},
|
|
{2 << 20, true, false, false},
|
|
{32, false, true, false},
|
|
{2 << 20, false, true, false},
|
|
{32, false, false, true},
|
|
{2 << 20, false, false, true},
|
|
} {
|
|
t.Run(fmt.Sprintf("bytes-%d/other-versions-%t/copy-%t/shared-%t", tc.size, tc.versioned, tc.copy, tc.shared), func(t *testing.T) {
|
|
size := tc.size
|
|
z, bucket := consistencyPools(t)
|
|
object := "cancel-rollback"
|
|
old := strings.Repeat("a", size)
|
|
base := UTCNow().Add(-time.Hour)
|
|
var sharedVersions [2]string
|
|
for pool := range 2 {
|
|
if tc.versioned {
|
|
putConsistencyObject(t, z, bucket, object, pool, "retained-version", ObjectOptions{Versioned: true, MTime: base.Add(-time.Minute)})
|
|
}
|
|
putConsistencyObject(t, z, bucket, object, pool, old, ObjectOptions{MTime: base})
|
|
if tc.shared {
|
|
info, err := z.serverPools[pool].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info.metadataOnly, info.versionOnly = true, true
|
|
copied, err := z.serverPools[pool].getHashedSet(object).CopyObject(t.Context(), bucket, object, bucket, object, info,
|
|
ObjectOptions{}, ObjectOptions{Versioned: true, MTime: base.Add(time.Minute)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sharedVersions[pool] = copied.VersionID
|
|
}
|
|
}
|
|
copyInfo, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
copyInfo.metadataOnly = true
|
|
copyInfo.UserDefined["copy-test"] = "canceled"
|
|
// Exercise the production rollback handler and wire options as
|
|
// well as the client. A local DeleteVersion bypasses that boundary.
|
|
globalLocalDrivesMu.Lock()
|
|
previousSets, previousMap := globalLocalSetDrives, globalLocalDrivesMap
|
|
globalLocalSetDrives = nil
|
|
globalLocalDrivesMap = make(map[string]StorageAPI)
|
|
for _, pool := range z.serverPools {
|
|
for _, disk := range pool.getHashedSet(object).getDisks() {
|
|
globalLocalDrivesMap[disk.Endpoint().String()] = disk
|
|
}
|
|
}
|
|
globalLocalDrivesMu.Unlock()
|
|
t.Cleanup(func() {
|
|
globalLocalDrivesMu.Lock()
|
|
globalLocalSetDrives, globalLocalDrivesMap = previousSets, previousMap
|
|
globalLocalDrivesMu.Unlock()
|
|
})
|
|
tg, err := grid.SetupTestGrid(2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tg.Cleanup()
|
|
entered := make(chan struct{}, 32)
|
|
release := make(chan struct{})
|
|
var releaseOnce sync.Once
|
|
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
|
defer unblock()
|
|
var completed atomic.Int32
|
|
var injectCommitFailure atomic.Bool
|
|
injectCommitFailure.Store(true)
|
|
for pi, pool := range z.serverPools {
|
|
set := pool.getHashedSet(object)
|
|
disks, switchDisks := installListingDiskSwitch(set)
|
|
defer switchDisks(disks)
|
|
wrapped := append([]StorageAPI(nil), disks...)
|
|
for i, disk := range disks {
|
|
route := fmt.Sprintf("/cancel-rollback/%d/%d", pi, i)
|
|
server := storageRESTServer{endpoint: disk.Endpoint()}
|
|
if err := storageDeleteVersionRPC.Register(tg.Managers[1], server.DeleteVersionHandler, route); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
handle := func(p *RenameDataHandlerParams) (*RenameDataResp, *grid.RemoteErr) {
|
|
entered <- struct{}{}
|
|
<-release
|
|
// Cancellation alone must not undo a successful quorum.
|
|
// This rollback fixture also fails half the disks, so its
|
|
// original payload/lock assertions require a real failed commit.
|
|
defer completed.Add(1)
|
|
if i >= len(disks)/2 && injectCommitFailure.Load() {
|
|
return nil, grid.NewRemoteErr(errDiskNotFound)
|
|
}
|
|
resp, err := disk.RenameData(context.Background(), p.SrcVolume, p.SrcPath, p.FI, p.DstVolume, p.DstPath, p.Opts)
|
|
if err != nil {
|
|
return nil, grid.NewRemoteErr(err)
|
|
}
|
|
return &resp, nil
|
|
}
|
|
if err := storageRenameDataRPC.Register(tg.Managers[1], handle, route); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := storageRenameDataInlineRPC.Register(tg.Managers[1], func(p *RenameDataInlineHandlerParams) (*RenameDataResp, *grid.RemoteErr) {
|
|
defer p.Recycle()
|
|
return handle(&p.RenameDataHandlerParams)
|
|
}, route); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := storageWriteMetadataRPC.Register(tg.Managers[1], func(p *MetadataHandlerParams) (grid.NoPayload, *grid.RemoteErr) {
|
|
if p.Volume == bucket && p.FilePath == object {
|
|
entered <- struct{}{}
|
|
<-release
|
|
defer completed.Add(1)
|
|
}
|
|
err := disk.WriteMetadata(context.Background(), p.OrigVolume, p.Volume, p.FilePath, p.FI)
|
|
return grid.NewNoPayload(), grid.NewRemoteErr(err)
|
|
}, route); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client := &storageRESTClient{gridConn: tg.Managers[0].Connection(tg.Hosts[1]).Subroute(route)}
|
|
client.diskID.Store(new(string))
|
|
wrapped[i] = gatedRenameDisk{disk, client}
|
|
}
|
|
switchDisks(wrapped)
|
|
}
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
pending := bytes.Repeat([]byte("b"), size)
|
|
reader := mustGetPutObjReader(t, bytes.NewReader(pending), int64(size), "", "")
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
var err error
|
|
if tc.copy {
|
|
_, err = z.CopyObject(ctx, bucket, object, bucket, object, copyInfo, ObjectOptions{}, ObjectOptions{})
|
|
} else {
|
|
_, err = z.PutObject(ctx, bucket, object, reader, ObjectOptions{})
|
|
}
|
|
done <- err
|
|
}()
|
|
n := z.serverPools[0].getHashedSet(object).setDriveCount
|
|
for range n {
|
|
select {
|
|
case <-entered:
|
|
case err := <-done:
|
|
t.Fatalf("write ended before commit barrier: %v", err)
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("write did not reach every disk")
|
|
}
|
|
}
|
|
cancel()
|
|
var early bool
|
|
select {
|
|
case err := <-done:
|
|
early = true
|
|
t.Errorf("mutation returned before remote commits completed: %v", err)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
lock := z.NewNSLock(bucket, object)
|
|
lockCtx, cancelLock := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
|
lc, lockErr := lock.GetLock(lockCtx, newDynamicTimeout(50*time.Millisecond, 50*time.Millisecond))
|
|
cancelLock()
|
|
if lockErr == nil {
|
|
lock.Unlock(lc)
|
|
t.Error("namespace lock released before remote commits and rollback")
|
|
}
|
|
unblock()
|
|
if early {
|
|
// Keep the old-code failure deterministic; don't race cleanup
|
|
// against handlers intentionally still running at the barrier.
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for completed.Load() != int32(n) {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("remote handlers did not finish")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
return
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("canceled write unexpectedly acknowledged")
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("canceled write did not finish rollback")
|
|
}
|
|
if completed.Load() != int32(n) {
|
|
t.Fatal("returned without accounting for every remote commit")
|
|
}
|
|
injectCommitFailure.Store(false)
|
|
for pi, pool := range z.serverPools {
|
|
for _, version := range []string{"", nullVersionID} {
|
|
r, err := pool.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{VersionID: version})
|
|
if err != nil {
|
|
t.Fatalf("pool %d lost its acknowledged copy (version %q): %v", pi, version, err)
|
|
}
|
|
body, err := io.ReadAll(r)
|
|
r.Close()
|
|
if err != nil || string(body) != old {
|
|
t.Errorf("pool %d rollback lost acknowledged payload (version %q): bytes=%d err=%v", pi, version, len(body), err)
|
|
}
|
|
}
|
|
}
|
|
got, err := z.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := io.ReadAll(got)
|
|
got.Close()
|
|
if err != nil || string(body) != old {
|
|
t.Fatalf("rollback lost acknowledged payload: bytes=%d err=%v", len(body), err)
|
|
}
|
|
newBody := bytes.Repeat([]byte("c"), size)
|
|
if _, err := z.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, bytes.NewReader(newBody), int64(size), "", ""), ObjectOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err = z.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err = io.ReadAll(got)
|
|
got.Close()
|
|
if err != nil || !bytes.Equal(body, newBody) {
|
|
t.Fatalf("late commit replaced subsequent successful PUT: bytes=%d err=%v", len(body), err)
|
|
}
|
|
if tc.shared {
|
|
for pi, pool := range z.serverPools {
|
|
r, err := pool.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{VersionID: sharedVersions[pi]})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := io.ReadAll(r)
|
|
r.Close()
|
|
if err != nil || string(body) != old {
|
|
t.Errorf("commit cleanup destroyed shared version on pool %d: bytes=%d err=%v", pi, len(body), err)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenameCancellationStaleRollbackPreservesNewWrite(t *testing.T) {
|
|
z, bucket := consistencyPools(t)
|
|
const object = "stale-rollback"
|
|
putConsistencyObject(t, z, bucket, object, 0, "canceled-generation", ObjectOptions{})
|
|
disks := z.serverPools[0].getHashedSet(object).getDisks()
|
|
stale := make([]FileInfo, len(disks))
|
|
for i, disk := range disks {
|
|
var err error
|
|
stale[i], err = disk.ReadVersion(t.Context(), "", bucket, object, nullVersionID, ReadOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
putConsistencyObject(t, z, bucket, object, 0, "new-acknowledged-generation", ObjectOptions{})
|
|
for i, disk := range disks {
|
|
err := disk.DeleteVersion(t.Context(), bucket, object, stale[i], false, DeleteOptions{UndoWrite: true})
|
|
if err != nil && !errors.Is(err, errFileVersionNotFound) {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
got, err := z.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatalf("stale rollback removed the new acknowledged write: %v", err)
|
|
}
|
|
defer got.Close()
|
|
body, err := io.ReadAll(got)
|
|
if err != nil || string(body) != "new-acknowledged-generation" {
|
|
t.Fatalf("stale rollback changed the new acknowledged payload: %q, %v", body, err)
|
|
}
|
|
}
|