Files
minio/cmd/metacache-parts-retry_test.go
T
Feng Ruohang 87c1c6b3dc fix(listing): confirm current groups and bound recovery lock waits
Distinguish all-drive native absence from read uncertainty and confirm current pool targets before omitting a recovered name. Cover both existing single-pool writer lock domains, release before encoding or output, and preserve cache terminal errors without blocking the HTTP error response on remote publication.

Keep ordinary entries free of recovery I/O and locks. Bound the selective short lock-conflict wait, preserve existing retry intervals and quorum, and make retries cancelable with late-grant cleanup coverage. Restore the original raw listing callback and unchanged scanner/healing/migration callers now that recovery errors originate in the merger.

Add deterministic deletion, pool ordering, cache, lock-routing and cancellation regressions to race CI. Focused cmd/dsync/lsync race suites and verifiers pass; complete current-main integration and bare-binary performance validation are recorded in PR #239.

Refs: https://github.com/pgsty/silo/issues/218
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-17 11:18:08 +08:00

445 lines
15 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"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/minio/minio/internal/grid"
)
// Read real cache objects and use an actual peer RPC, counting only the block
// under test. Gates control publication, never replace quorum or EOF decisions.
type listingCachePartsProbe struct {
state atomic.Pointer[metacache]
rpcFails atomic.Bool
reads atomic.Int64
mu sync.Mutex
probes []int
checks []time.Time
onProbe func(int) bool
onCheck func(int)
target string
readErr error
}
func (p *listingCachePartsProbe) setState(o listPathOptions, failed bool) {
m := o.newMetacache()
m.lastUpdate = time.Now()
if failed {
m.status, m.error = scanStateError, "cache producer failed"
} else {
m.status = scanStateStarted
}
p.state.Store(&m)
}
func (p *listingCachePartsProbe) snapshot() ([]int, []time.Time) {
p.mu.Lock()
defer p.mu.Unlock()
return slices.Clone(p.probes), slices.Clone(p.checks)
}
type listingCachePartsDisk struct {
StorageAPI
index int
probe *listingCachePartsProbe
}
func (d *listingCachePartsDisk) ReadXL(ctx context.Context, bucket, object string, readData bool) (RawFileInfo, error) {
if bucket == minioMetaBucket && object == d.probe.target {
d.probe.reads.Add(1)
if d.probe.readErr != nil {
return RawFileInfo{}, d.probe.readErr
}
}
return d.StorageAPI.ReadXL(ctx, bucket, object, readData)
}
func (d *listingCachePartsDisk) ReadVersion(ctx context.Context, origVolume, volume, path, versionID string, opts ReadOptions) (FileInfo, error) {
if volume == minioMetaBucket && path == d.probe.target {
d.probe.mu.Lock()
d.probe.probes = append(d.probe.probes, d.index)
d.probe.mu.Unlock()
if d.probe.onProbe != nil && d.probe.onProbe(d.index) {
return FileInfo{}, errFileNotFound
}
}
return d.StorageAPI.ReadVersion(ctx, origVolume, volume, path, versionID, opts)
}
func listingCachePartsBackend(t *testing.T, part int) (*erasureObjects, listPathOptions, *listingCachePartsProbe, func([]StorageAPI)) {
t.Helper()
z, bucket, _ := nullQuorumBackend(t)
set := z.serverPools[0].sets[0]
o := listPathOptions{ID: mustGetUUID(), Bucket: bucket, Limit: 100, Recursive: true, IncludeDirectories: true}
p := &listingCachePartsProbe{target: o.objectPath(part)}
p.setState(o, false)
disks, switchDisks := installListingDiskSwitch(set)
wrapped := slices.Clone(disks)
for i, disk := range disks {
wrapped[i] = &listingCachePartsDisk{StorageAPI: disk, index: i, probe: p}
}
switchDisks(wrapped)
tg, err := grid.SetupTestGrid(2)
if err != nil {
t.Fatal(err)
}
previous := globalNotificationSys
t.Cleanup(func() {
globalNotificationSys = previous
tg.Cleanup()
})
if err := getMetacacheListingRPC.Register(tg.Managers[1], func(got *listPathOptions) (*metacache, *grid.RemoteErr) {
if got.ID != o.ID || got.Create {
return nil, grid.NewRemoteErr(errors.New("invalid cache state lookup"))
}
p.mu.Lock()
p.checks = append(p.checks, time.Now())
n := len(p.checks)
p.mu.Unlock()
if p.onCheck != nil {
p.onCheck(n)
}
if p.rpcFails.Load() {
return nil, grid.NewRemoteErr(errors.New("cache state RPC failed"))
}
m := *p.state.Load()
return &m, nil
}); err != nil {
t.Fatal(err)
}
client := &peerRESTClient{gridConn: func() *grid.Connection { return tg.Managers[0].Connection(tg.Hosts[1]) }}
globalNotificationSys = &NotificationSys{peerClients: []*peerRESTClient{client}, allPeerClients: []*peerRESTClient{client}}
return set, o, p, switchDisks
}
func putListingCachePart(t *testing.T, set *erasureObjects, o listPathOptions, part int, name string, eos bool) {
t.Helper()
var buf bytes.Buffer
w := newMetacacheWriter(&buf, 0)
if err := w.write(metaCacheEntry{name: name}); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
block := metacacheBlock{n: part, First: name, Last: name, EOS: eos}
_, err := set.putMetacacheObject(t.Context(), o.objectPath(part), mustGetPutObjReader(t, bytes.NewReader(buf.Bytes()), int64(buf.Len()), "", ""), ObjectOptions{UserDefined: block.headerKV()})
if err != nil {
t.Fatal(err)
}
}
func TestStreamMetadataPartsHealthyNoStateRPC(t *testing.T) {
set, o, p, _ := listingCachePartsBackend(t, 1)
putListingCachePart(t, set, o, 0, "a/", false)
putListingCachePart(t, set, o, 1, "b/", true)
entries, err := set.streamMetadataParts(t.Context(), o)
if !errors.Is(err, io.EOF) || entries.len() != 2 || entries.o[0].name != "a/" || entries.o[1].name != "b/" {
t.Fatalf("healthy two-block read: entries=%v err=%v", entries.o, err)
}
probes, checks := p.snapshot()
if len(probes) != 0 || len(checks) != 0 {
t.Fatalf("healthy read added probes=%v state RPCs=%d", probes, len(checks))
}
t.Logf("healthy persisted blocks: state RPCs=0 retry probes=0 target ReadXL=%d", p.reads.Load())
}
func TestStreamMetadataPartsTerminalError(t *testing.T) {
for _, part := range []int{0, 1} {
t.Run(fmt.Sprintf("part=%d", part), func(t *testing.T) {
set, o, p, _ := listingCachePartsBackend(t, part)
if part > 0 {
putListingCachePart(t, set, o, 0, "a/", false)
}
p.setState(o, true)
start := time.Now()
entries, err := set.streamMetadataParts(t.Context(), o)
elapsed := time.Since(start)
if err == nil || errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "cache producer failed") || entries.len() != part {
t.Fatalf("failed cache became successful: entries=%v err=%v", entries.o, err)
}
probes, checks := p.snapshot()
if len(checks) != 1 || len(probes) != 0 || elapsed > time.Second {
t.Fatalf("terminal error waited for disk retries: elapsed=%v state RPCs=%d probes=%v", elapsed, len(checks), probes)
}
t.Logf("terminal part=%d elapsed=%v state RPCs=%d probes=%d target ReadXL=%d", part, elapsed, len(checks), len(probes), p.reads.Load())
})
}
}
func TestStreamMetadataPartsLateTerminalError(t *testing.T) {
set, o, p, _ := listingCachePartsBackend(t, 1)
putListingCachePart(t, set, o, 0, "a/", false)
p.onProbe = func(int) bool {
p.setState(o, true)
return false
}
start := time.Now()
entries, err := set.streamMetadataParts(t.Context(), o)
elapsed := time.Since(start)
probes, checks := p.snapshot()
if err == nil || errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "cache producer failed") || entries.len() != 1 {
t.Fatalf("late producer error lost: entries=%v err=%v", entries.o, err)
}
if elapsed > time.Second || len(probes) != 2 || !slices.Equal(probes, []int{0, 1}) || len(checks) != 2 {
t.Fatalf("late error retry work: elapsed=%v probes=%v state RPCs=%d", elapsed, probes, len(checks))
}
if checks[1].Sub(checks[0]) < 250*time.Millisecond || p.reads.Load() > 16 {
t.Fatalf("unbounded retry: state interval=%v target ReadXL=%d", checks[1].Sub(checks[0]), p.reads.Load())
}
t.Logf("late terminal elapsed=%v state RPCs=%d probes=%v target ReadXL=%d", elapsed, len(checks), probes, p.reads.Load())
}
type listingCachePartsResult struct {
entries metaCacheEntriesSorted
err error
}
func startListingCacheParts(t *testing.T, set *erasureObjects, o listPathOptions) (<-chan listingCachePartsResult, context.CancelFunc) {
t.Helper()
ctx, cancel := context.WithCancel(t.Context())
done := make(chan listingCachePartsResult, 1)
joined := make(chan struct{})
go func() {
defer close(joined)
entries, err := set.streamMetadataParts(ctx, o)
done <- listingCachePartsResult{entries: entries, err: err}
}()
t.Cleanup(func() {
cancel()
select {
case <-joined:
case <-time.After(8 * time.Second):
t.Error("cache continuation worker did not finish")
}
})
return done, cancel
}
func receiveListingCacheParts(t *testing.T, done <-chan listingCachePartsResult) listingCachePartsResult {
t.Helper()
select {
case result := <-done:
return result
case <-time.After(8 * time.Second):
t.Fatal("cache continuation did not finish")
return listingCachePartsResult{}
}
}
func TestStreamMetadataPartsGeneratingThenPublished(t *testing.T) {
set, o, p, _ := listingCachePartsBackend(t, 1)
putListingCachePart(t, set, o, 0, "a/", false)
checked := make(chan struct{})
p.onCheck = func(n int) {
if n == 1 {
close(checked)
}
}
// The first selected replica is absent even after the producer publishes
// the block elsewhere. Rotation must reach a readable replica.
p.onProbe = func(index int) bool { return index == 0 }
done, _ := startListingCacheParts(t, set, o)
select {
case <-checked:
case <-time.After(5 * time.Second):
t.Fatal("missing block did not check producer state")
}
select {
case result := <-done:
t.Fatalf("still-generating cache finished early: %+v", result)
case <-time.After(100 * time.Millisecond):
}
putListingCachePart(t, set, o, 1, "b/", true)
result := receiveListingCacheParts(t, done)
if !errors.Is(result.err, io.EOF) || result.entries.len() != 2 || result.entries.o[0].name != "a/" || result.entries.o[1].name != "b/" {
t.Fatalf("published second block lost: entries=%v err=%v", result.entries.o, result.err)
}
probes, checks := p.snapshot()
if len(probes) < 2 || probes[0] != 0 || probes[1] != 1 {
t.Fatalf("retry did not rotate: probes=%v checks=%d", probes, len(checks))
}
t.Logf("later block recovered: state RPCs=%d probes=%v target ReadXL=%d", len(checks), probes, p.reads.Load())
}
func TestStreamMetadataPartsRPCFailure(t *testing.T) {
set, o, p, _ := listingCachePartsBackend(t, 1)
putListingCachePart(t, set, o, 0, "a/", false)
p.rpcFails.Store(true)
entries, err := set.streamMetadataParts(t.Context(), o)
if err == nil || errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "cache state RPC failed") || entries.len() != 1 {
t.Fatalf("RPC failure became successful EOF: entries=%v err=%v", entries.o, err)
}
probes, checks := p.snapshot()
if len(checks) != 1 || len(probes) != 0 {
t.Fatalf("state RPC failure continued retrying: probes=%v checks=%d", probes, len(checks))
}
}
func TestStreamMetadataPartsMissingIndex(t *testing.T) {
set, o, p, _ := listingCachePartsBackend(t, 0)
putListingCachePart(t, set, o, 0, "a/", false)
o.Marker = "b/"
p.setState(o, true)
entries, err := set.streamMetadataParts(t.Context(), o)
if err == nil || errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "cache producer failed") || entries.len() != 0 {
t.Fatalf("unpublished index became successful EOF: entries=%v err=%v", entries.o, err)
}
probes, checks := p.snapshot()
if len(checks) != 1 || len(probes) != 0 {
t.Fatalf("missing index delayed terminal state: probes=%v checks=%d", probes, len(checks))
}
}
func TestStreamMetadataPartsOtherErrorRetriesOnce(t *testing.T) {
set, o, p, _ := listingCachePartsBackend(t, 0)
p.readErr = errVolumeNotFound
entries, err := set.streamMetadataParts(t.Context(), o)
if err == nil || errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "reading first part metadata:") || entries.len() != 0 {
t.Fatalf("unexpected metadata error result: entries=%v err=%v", entries.o, err)
}
probes, checks := p.snapshot()
if p.reads.Load() != 32 || len(probes) != 0 || len(checks) != 1 {
t.Fatalf("other metadata error did not get exactly one full retry: ReadXL=%d probes=%v checks=%d", p.reads.Load(), probes, len(checks))
}
}
func TestStreamMetadataPartsMissingBlockWorkBound(t *testing.T) {
set, o, p, _ := listingCachePartsBackend(t, 0)
checked := make(chan struct{})
p.onCheck = func(n int) {
if n == 3 {
close(checked)
}
}
start := time.Now()
done, cancel := startListingCacheParts(t, set, o)
select {
case <-checked:
case <-time.After(5 * time.Second):
t.Fatal("missing cache stopped checking producer state")
}
cancel()
result := receiveListingCacheParts(t, done)
probes, checks := p.snapshot()
if !errors.Is(result.err, context.Canceled) || result.entries.len() != 0 {
t.Fatalf("generating cache became successful EOF: %+v", result)
}
if len(probes) != 3 || !slices.Equal(probes, []int{0, 1, 2}) || p.reads.Load() != 16 || len(checks) != 3 {
t.Fatalf("missing block exceeded work bound: probes=%v target ReadXL=%d checks=%d", probes, p.reads.Load(), len(checks))
}
for i := 1; i < len(checks); i++ {
// Receive timestamps allow a small amount of peer scheduling jitter.
if checks[i].Sub(checks[i-1]) < 240*time.Millisecond {
t.Fatalf("cache state queried too often: %v", checks)
}
}
t.Logf("still missing for %v: state RPCs=%d probes=%v target ReadXL=%d", time.Since(start), len(checks), probes, p.reads.Load())
}
func TestStreamMetadataPartsCancelRetryWait(t *testing.T) {
set, o, p, _ := listingCachePartsBackend(t, 1)
putListingCachePart(t, set, o, 0, "a/", false)
probed := make(chan struct{})
p.onProbe = func(index int) bool {
if index == 1 {
close(probed)
}
return false
}
done, cancel := startListingCacheParts(t, set, o)
select {
case <-probed:
case <-time.After(5 * time.Second):
t.Fatal("retry probe did not run")
}
// Let the probe return and enter the regular 250 ms wait before canceling.
time.Sleep(20 * time.Millisecond)
start := time.Now()
cancel()
result := receiveListingCacheParts(t, done)
elapsed := time.Since(start)
if !errors.Is(result.err, context.Canceled) || elapsed > 150*time.Millisecond {
t.Fatalf("retry wait ignored cancellation: elapsed=%v err=%v", elapsed, result.err)
}
t.Logf("cancel during retry wait: elapsed=%v", elapsed)
}
func TestStreamMetadataPartsChangedDisks(t *testing.T) {
for _, empty := range []bool{false, true} {
t.Run(fmt.Sprintf("empty=%v", empty), func(t *testing.T) {
testStreamMetadataPartsChangedDisks(t, empty)
})
}
}
func testStreamMetadataPartsChangedDisks(t *testing.T, empty bool) {
set, o, p, switchDisks := listingCachePartsBackend(t, 1)
putListingCachePart(t, set, o, 0, "a/", false)
disks := set.getDisks()
probeDone, restore := make(chan struct{}), make(chan struct{})
p.onProbe = func(index int) bool {
if index == 0 {
// A later probe must safely skip an empty/offline disk snapshot.
if empty {
switchDisks([]StorageAPI{})
} else {
switchDisks([]StorageAPI{nil})
}
close(probeDone)
return true
}
return false
}
p.onCheck = func(n int) {
if n == 2 {
close(restore)
}
}
done, _ := startListingCacheParts(t, set, o)
select {
case <-probeDone:
case <-time.After(5 * time.Second):
t.Fatal("initial retry probe did not run")
}
select {
case <-restore:
case <-time.After(5 * time.Second):
t.Fatal("empty disk snapshot prevented cache state checks")
}
switchDisks(disks)
putListingCachePart(t, set, o, 1, "b/", true)
result := receiveListingCacheParts(t, done)
if !errors.Is(result.err, io.EOF) || result.entries.len() != 2 {
t.Fatalf("changed disks did not recover: entries=%v err=%v", result.entries.o, result.err)
}
}