mirror of
https://github.com/pgsty/minio.git
synced 2026-10-17 07:05:59 +03:00
e90cb2067d
Continue PR #221 while retaining its original commits and authorship. Route latest-only fallback reads through the namespace lock used by normal writes, propagate terminal scan errors before cache completion, and retain delete markers until the cross-pool merge has resolved them. Recount narrowly eligible latest null-version metadata when mixed erasure layouts prevent selection. Keep each EC group separate and preserve the requested quorum, strict resolution, and version-listing behavior. Add regression coverage for locking, terminal errors, cache completion, delete markers, mixed EC layouts, and HTTP behavior; run focused race tests in CI. Verified with make test, focused race tests, and local distributed single- and multi-pool rolling-restart workloads with exact final oracles. Refs: https://github.com/pgsty/silo/issues/218 Refs: https://github.com/pgsty/silo/pull/221 Signed-off-by: Feng Ruohang <rh@vonng.com>
223 lines
8.1 KiB
Go
223 lines
8.1 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"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func crossParityNullVersion(t *testing.T, generation int64, data, parity int) xlMetaV2ShallowVersion {
|
|
t.Helper()
|
|
fi := newFileInfo("bucket/object", data, parity)
|
|
fi.Erasure.Index = 1
|
|
fi.DataDir = "11111111-1111-1111-1111-111111111111"
|
|
fi.ModTime = time.Unix(generation, 0).UTC()
|
|
fi.Size = 1024
|
|
fi.Parts = []ObjectPartInfo{{Number: 1, Size: fi.Size, ActualSize: fi.Size}}
|
|
fi.Metadata = map[string]string{"etag": fmt.Sprintf("%032d", generation)}
|
|
var xl xlMetaV2
|
|
if err := xl.AddVersion(fi); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return xl.versions[0]
|
|
}
|
|
|
|
// A restarted node can retain four 12+4 shards while a successful degraded
|
|
// write leaves twelve newer 8+8 shards. Neither disk order nor the minority's
|
|
// EC must hide the independently sufficient newer group.
|
|
func TestMergeXLV2LatestNullCrossParity(t *testing.T) {
|
|
versions := []xlMetaV2ShallowVersion{
|
|
crossParityNullVersion(t, 100, 12, 4),
|
|
crossParityNullVersion(t, 200, 8, 8),
|
|
crossParityNullVersion(t, 300, 10, 6),
|
|
}
|
|
for _, tt := range []struct {
|
|
name string
|
|
counts [3]int
|
|
empty int
|
|
quorum int
|
|
want int
|
|
}{
|
|
{"new-twelve-old-four", [3]int{4, 12}, 0, 8, 1},
|
|
{"old-twelve-new-four", [3]int{12, 4}, 0, 8, 0},
|
|
{"new-nine-old-seven", [3]int{7, 9}, 0, 8, 1},
|
|
{"eight-meets-eight", [3]int{4, 8}, 4, 8, 1},
|
|
{"eight-does-not-meet-nine", [3]int{8, 8}, 0, 9, -1},
|
|
{"three-subquorums", [3]int{7, 5, 4}, 0, 8, -1},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var input [][]xlMetaV2ShallowVersion
|
|
for group, count := range tt.counts {
|
|
for range count {
|
|
input = append(input, []xlMetaV2ShallowVersion{versions[group]})
|
|
}
|
|
}
|
|
input = append(input, make([][]xlMetaV2ShallowVersion, tt.empty)...)
|
|
for order, streams := range quorumVersionOrders(input) {
|
|
got := mergeXLV2Versions(tt.quorum, false, 1, streams...)
|
|
if tt.want < 0 {
|
|
if len(got) != 0 {
|
|
t.Fatalf("order %d: subquorum groups were combined: %v", order, got)
|
|
}
|
|
continue
|
|
}
|
|
if len(got) != 1 || got[0].header != versions[tt.want].header {
|
|
t.Fatalf("order %d: got %v, want group %d", order, got, tt.want)
|
|
}
|
|
xl := xlMetaV2{versions: got}
|
|
fi, err := xl.ToFileInfo("bucket", "object", "", false, true)
|
|
if err != nil || !fi.IsValid() || fi.Erasure.DataBlocks != int(versions[tt.want].header.EcM) {
|
|
t.Fatalf("order %d: selected metadata is invalid: %+v, %v", order, fi, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMergeXLV2LatestNullCrossParityScope(t *testing.T) {
|
|
old := crossParityNullVersion(t, 100, 12, 4)
|
|
newer := crossParityNullVersion(t, 200, 8, 8)
|
|
for _, kind := range []string{"strict", "all-versions", "uuid", "delete", "free", "history", "legacy-modern-ec", "partial-ec"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
a, b := old, newer
|
|
strict, requested := false, 1
|
|
switch kind {
|
|
case "strict":
|
|
strict = true
|
|
case "all-versions":
|
|
requested = 0
|
|
case "uuid":
|
|
a.header.VersionID, b.header.VersionID = [16]byte{1}, [16]byte{1}
|
|
case "delete":
|
|
b.header.Type = DeleteType
|
|
case "free":
|
|
b.header.Flags |= xlFlagFreeVersion
|
|
case "legacy-modern-ec":
|
|
b.header.EcN, b.header.EcM = 0, 0
|
|
case "partial-ec":
|
|
b.header.EcN = 0
|
|
}
|
|
// These previously unresolved inputs remain outside the repair.
|
|
// Strict and legacy-header cases use an older majority followed by
|
|
// a newer minority; the other cases use the opposite grouping.
|
|
input := make([][]xlMetaV2ShallowVersion, 16)
|
|
for i := range input {
|
|
input[i] = []xlMetaV2ShallowVersion{b}
|
|
if i >= 12 {
|
|
input[i] = []xlMetaV2ShallowVersion{a}
|
|
}
|
|
}
|
|
if kind == "strict" || kind == "legacy-modern-ec" || kind == "partial-ec" {
|
|
for i := range input {
|
|
input[i] = []xlMetaV2ShallowVersion{a}
|
|
if i >= 12 {
|
|
input[i] = []xlMetaV2ShallowVersion{b}
|
|
}
|
|
}
|
|
}
|
|
if kind == "history" {
|
|
history := old
|
|
history.header.VersionID = [16]byte{1}
|
|
input[0] = append(input[0], history)
|
|
}
|
|
if got := mergeXLV2Versions(8, strict, requested, input...); len(got) != 0 {
|
|
t.Fatalf("excluded input changed: %v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCountXLV2MixedECGroups(t *testing.T) {
|
|
a := crossParityNullVersion(t, 100, 12, 4)
|
|
b := crossParityNullVersion(t, 100, 8, 8)
|
|
c := crossParityNullVersion(t, 200, 10, 6)
|
|
// Same-time groups remain separate, and equal counts have a deterministic
|
|
// final EC tiebreak even though sortsBefore does not compare EC itself.
|
|
input := [][]xlMetaV2ShallowVersion{{a}, {a}, {a}, {b}, {b}, {b}, {c}}
|
|
for order, streams := range quorumVersionOrders(input) {
|
|
tops := make([]xlMetaV2ShallowVersion, len(streams))
|
|
for i := range streams {
|
|
tops[i] = streams[i][0]
|
|
}
|
|
for range 20 {
|
|
got, count := countXLV2Versions(tops, c.header, c, false, true)
|
|
if count != 3 || got.header != a.header {
|
|
t.Fatalf("order %d: got %v, count %d; want distinct group of three", order, got.header, count)
|
|
}
|
|
}
|
|
}
|
|
differentSig := a
|
|
differentSig.header.Signature[0]++
|
|
if _, count := countXLV2Versions([]xlMetaV2ShallowVersion{a, a, differentSig}, a.header, a, false, true); count != 3 {
|
|
t.Fatalf("non-strict signature groups did not combine: count %d", count)
|
|
}
|
|
// Same EC with different time or flags must not accumulate votes either.
|
|
flags := a
|
|
flags.header.Flags ^= xlFlagInlineData
|
|
for _, other := range []xlMetaV2ShallowVersion{crossParityNullVersion(t, 200, 12, 4), flags} {
|
|
got, count := countXLV2Versions([]xlMetaV2ShallowVersion{a, a, other, other}, a.header, a, false, true)
|
|
if count != 2 {
|
|
t.Fatalf("incompatible header groups combined: %v, count %d", got.header, count)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Exercise the regular full-metadata read and HTTP LIST with actual on-disk
|
|
// object metadata, not merely the metadata selection helper.
|
|
func TestListCrossParityReadableMajority(t *testing.T) {
|
|
for _, size := range []int{1024, 256 << 10} {
|
|
t.Run(fmt.Sprintf("bytes-%d", size), func(t *testing.T) {
|
|
z, bucket, router := nullQuorumBackend(t)
|
|
set := z.serverPools[0].sets[0]
|
|
disks := set.getDisks()
|
|
const object = "object"
|
|
mtime := time.Now().UTC().Add(-time.Hour)
|
|
oldBody := bytes.Repeat([]byte{'a'}, size)
|
|
newBody := bytes.Repeat([]byte{'b'}, size)
|
|
nullQuorumPut(t, z, bucket, object, oldBody, ObjectOptions{MTime: mtime})
|
|
old := make([][]byte, len(disks))
|
|
for i, disk := range disks {
|
|
old[i] = mustReadNullQuorumMeta(t, disk, bucket, object)
|
|
}
|
|
nullQuorumPut(t, z, bucket, object, newBody, ObjectOptions{MTime: mtime.Add(time.Minute), MaxParity: true})
|
|
for i := 12; i < len(disks); i++ {
|
|
if err := disks[i].WriteAll(t.Context(), bucket, object+"/"+xlStorageFormatFile, old[i]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, readData := range []bool{false, true} {
|
|
fi, _, _, err := set.getObjectFileInfo(t.Context(), bucket, object, ObjectOptions{}, readData)
|
|
if err != nil || !fi.ModTime.Equal(mtime.Add(time.Minute)) || fi.Erasure.DataBlocks != 8 {
|
|
t.Fatalf("readData=%v: full metadata read lost readable majority: %+v, %v", readData, fi, err)
|
|
}
|
|
}
|
|
if get := nullQuorumRequest(t, router, http.MethodGet, getGetObjectURL("", bucket, object)); get.Code != http.StatusOK || !bytes.Equal(get.Body.Bytes(), newBody) {
|
|
t.Fatalf("GET lost readable majority: status %d", get.Code)
|
|
}
|
|
if code, keys := nullQuorumListV2Keys(t, router, bucket); code != http.StatusOK || !slices.Equal(keys, []string{object}) {
|
|
t.Fatalf("LIST lost readable majority: %d %v", code, keys)
|
|
}
|
|
})
|
|
}
|
|
}
|