mirror of
https://github.com/pgsty/minio.git
synced 2026-11-09 01:01:52 +03:00
fix(replication): count resync success by outcome, not target existence
The resync worker classified each object by whether the target version merely existed (a tgt.StatObject HEAD), ignoring the outcome of the replicateObject/replicateDelete call it had just made. A quota-rejected update leaves the old version in place, so StatObject succeeded and the resync recorded a false success - reported as Completed / N success / 0 failed and persisted across restart (issue #139). #134's SSE-C HEAD marker made StatObject succeed for SSE-C too, exposing it there. The delete path had the mirror flaw (a failed delete leaves the object, so the HEAD succeeded), and FailedSize was never incremented (a failed 196,608-byte object counted as 1 failed / 0 bytes). replicateObject and replicateDelete already build the per-target replicatedInfos (each replicatedTargetInfo carries Arn, ReplicationStatus and Err) but discarded it. Return it (callers that only trigger replication ignore the value - a Go call statement discards it, so the queue paths are unchanged) and classify the resync from the target whose Arn == opts.arn via a small pure helper: - Completed without error -> replicated (+ that target's size, falling back to the object size). - Failed or errored -> failed (+ the object size, fixing FailedSize). - opts.arn absent from the result (not attempted) -> failed; a resync that cannot confirm the object reached the target is not a success. The StatObject-existence block (including the delete-marker/MethodNotAllowed special case, now subsumed by the delete outcome) is removed. #134's SSE-C HEAD marker is left intact - it is needed for genuine SSE-C success. Adds a table-driven regression for the classifier covering a completed update, a failed update over an existing version, an errored-but-Completed result, a delete failure, a delete-marker success (zero bytes) and an un-attempted ARN. Classifying by existence makes the failed cases count success and fails the test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7qJqWwy8oFA6aCXWRzXQe Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
@@ -528,3 +529,121 @@ func TestResyncFinishWaitsForInflightWorker(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestResyncResultFor asserts the resync worker classifies a target from the
|
||||
// actual replication outcome, not from whether the target version merely exists.
|
||||
// The key regression is the "failed update over an existing version" case: a
|
||||
// quota-rejected update leaves the old version in place, and counting existence
|
||||
// (the previous behavior) would score it a success. It also checks a genuine
|
||||
// success, an errored-but-Completed result, a delete failure, a delete-marker
|
||||
// success (zero bytes), and an ARN that was never attempted.
|
||||
func TestResyncResultFor(t *testing.T) {
|
||||
const arn = "arn:minio:replication::id:bucket"
|
||||
obj := ReplicateObjectInfo{Name: "obj", Bucket: "bucket", Size: 196608}
|
||||
deleteMarker := ReplicateObjectInfo{Name: "dm", Bucket: "bucket", Size: 0, DeleteMarker: true}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
roi ReplicateObjectInfo
|
||||
rinfos replicatedInfos
|
||||
wantRepl, wantReplSize, wantFail, wantFailSize int64
|
||||
}{
|
||||
{
|
||||
name: "completed update",
|
||||
roi: obj,
|
||||
rinfos: replicatedInfos{Targets: []replicatedTargetInfo{
|
||||
{Arn: arn, ReplicationStatus: replication.Completed, Size: 196608},
|
||||
}},
|
||||
wantRepl: 1, wantReplSize: 196608,
|
||||
},
|
||||
{
|
||||
name: "failed update over existing version",
|
||||
roi: obj,
|
||||
rinfos: replicatedInfos{Targets: []replicatedTargetInfo{
|
||||
{Arn: arn, ReplicationStatus: replication.Failed, Err: fmt.Errorf("quota exceeded"), Size: 196608},
|
||||
}},
|
||||
wantFail: 1, wantFailSize: 196608,
|
||||
},
|
||||
{
|
||||
name: "completed but errored is a failure",
|
||||
roi: obj,
|
||||
rinfos: replicatedInfos{Targets: []replicatedTargetInfo{
|
||||
{Arn: arn, ReplicationStatus: replication.Completed, Err: fmt.Errorf("boom"), Size: 196608},
|
||||
}},
|
||||
wantFail: 1, wantFailSize: 196608,
|
||||
},
|
||||
{
|
||||
name: "delete failed",
|
||||
roi: deleteMarker,
|
||||
rinfos: replicatedInfos{Targets: []replicatedTargetInfo{
|
||||
{Arn: arn, ReplicationStatus: replication.Failed},
|
||||
}},
|
||||
wantFail: 1, wantFailSize: 0,
|
||||
},
|
||||
{
|
||||
name: "delete marker replicated counts zero bytes",
|
||||
roi: deleteMarker,
|
||||
rinfos: replicatedInfos{Targets: []replicatedTargetInfo{
|
||||
{Arn: arn, ReplicationStatus: replication.Completed},
|
||||
}},
|
||||
wantRepl: 1, wantReplSize: 0,
|
||||
},
|
||||
{
|
||||
name: "arn not attempted is a failure",
|
||||
roi: obj,
|
||||
rinfos: replicatedInfos{Targets: []replicatedTargetInfo{
|
||||
{Arn: "arn:minio:replication::id2:bucket", ReplicationStatus: replication.Completed, Size: 196608},
|
||||
}},
|
||||
wantFail: 1, wantFailSize: 196608,
|
||||
},
|
||||
{
|
||||
name: "completed with zero size falls back to object size",
|
||||
roi: obj,
|
||||
rinfos: replicatedInfos{Targets: []replicatedTargetInfo{
|
||||
{Arn: arn, ReplicationStatus: replication.Completed, Size: 0},
|
||||
}},
|
||||
wantRepl: 1, wantReplSize: 196608,
|
||||
},
|
||||
{
|
||||
name: "version purge complete is a success",
|
||||
roi: ReplicateObjectInfo{Name: "purge", Bucket: "bucket", Size: 196608, VersionPurgeStatus: replication.VersionPurgePending},
|
||||
rinfos: replicatedInfos{Targets: []replicatedTargetInfo{
|
||||
// a successful purge sets only VersionPurgeStatus; ReplicationStatus stays empty.
|
||||
{Arn: arn, VersionPurgeStatus: replication.VersionPurgeComplete},
|
||||
}},
|
||||
wantRepl: 1, wantReplSize: 196608,
|
||||
},
|
||||
{
|
||||
name: "version purge failed is a failure",
|
||||
roi: ReplicateObjectInfo{Name: "purge", Bucket: "bucket", Size: 196608, VersionPurgeStatus: replication.VersionPurgePending},
|
||||
rinfos: replicatedInfos{Targets: []replicatedTargetInfo{
|
||||
{Arn: arn, VersionPurgeStatus: replication.VersionPurgeFailed, Err: fmt.Errorf("quota exceeded")},
|
||||
}},
|
||||
wantFail: 1, wantFailSize: 196608,
|
||||
},
|
||||
{
|
||||
name: "benign duplicate 412 is a success",
|
||||
roi: obj,
|
||||
rinfos: replicatedInfos{Targets: []replicatedTargetInfo{
|
||||
// the destination answers PreconditionFailed for an exact duplicate;
|
||||
// replicateAll keeps Completed but retains the error.
|
||||
{Arn: arn, ReplicationStatus: replication.Completed, Err: minio.ErrorResponse{Code: "PreconditionFailed"}, Size: 196608},
|
||||
}},
|
||||
wantRepl: 1, wantReplSize: 196608,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st := resyncResultFor(tc.rinfos, arn, tc.roi)
|
||||
if st.Object != tc.roi.Name || st.Bucket != tc.roi.Bucket {
|
||||
t.Fatalf("object/bucket = %s/%s, want %s/%s", st.Object, st.Bucket, tc.roi.Name, tc.roi.Bucket)
|
||||
}
|
||||
if st.ReplicatedCount != tc.wantRepl || st.ReplicatedSize != tc.wantReplSize ||
|
||||
st.FailedCount != tc.wantFail || st.FailedSize != tc.wantFailSize {
|
||||
t.Fatalf("resyncResultFor = {replicated:%d/%d failed:%d/%d}, want {%d/%d %d/%d}",
|
||||
st.ReplicatedCount, st.ReplicatedSize, st.FailedCount, st.FailedSize,
|
||||
tc.wantRepl, tc.wantReplSize, tc.wantFail, tc.wantFailSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user