mirror of
https://github.com/pgsty/minio.git
synced 2026-11-11 01:55:30 +03:00
fix(ilm): require an authoritative cross-pool history before lifecycle expiry
The merged all-pool check from #279 decided on the versions that a read quorum of each set merged non-strictly. A drive that missed one metadata update could therefore supply the revision used for the decision, and a partially unreadable set could drop a committed version from the history. Both let an expiry delete data the rule did not cover, including a version under legal hold. - objectVersions accepts a revision only when max(n/2+1, the version's metadata read quorum) drives hold it, identifies revisions by the header plus a sha256 over a canonical, length-prefixed encoding of the normalized metadata, filters non-accepted revisions before the strict merge, and fails closed on omitted versions, too many unreadable drives, or cancellation. - Lock fields are read with the evaluator's key precedence. Cross-pool copies that disagree on hold or retention must be strictly ordered by valid timestamps; copies with different ModTime are separate writes and an older protected write refuses the object. The set safeguard re-checks protection and pending purges on its fresh read for checked version deletes. - Duplicate copies with semantically different replication or purge state refuse the object. - Batch expiry announces and audits only completed or already-absent removals. Reviewed adversarially over seven rounds; the final round approved with no remaining findings in scope. All-versions expiry through deletePrefix and tiered delete-all dispatch are tracked separately. Signed-off-by: Feng Ruohang <rh@vonng.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -94,23 +94,31 @@ func TestPoolsLifecycleDuplicateLockMetadata(t *testing.T) {
|
||||
setLifecycleCopyMetadata(t, s, key, id, pool, metadata, tc.newerPool1 && pool == 1)
|
||||
}
|
||||
versions, events, err := s.z.lifecycleEventsAllPools(t.Context(), s.bucket, key)
|
||||
if err != nil {
|
||||
switch {
|
||||
case tc.newerPool1:
|
||||
// Another ModTime makes the copies separate writes. The
|
||||
// held older one refuses the object instead of merging.
|
||||
if !errors.Is(err, errLifecycleNotApplicable) {
|
||||
t.Fatalf("held older write: %v", err)
|
||||
}
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(versions) != 2 || versions[1].VersionID != id || versions[1].NumVersions != 2 {
|
||||
t.Fatalf("wrong version history: %+v", versions)
|
||||
}
|
||||
state := storedObjectLockState(versions[1].UserDefined)
|
||||
if state.mode != tc.mode || state.legalHold != tc.hold {
|
||||
t.Errorf("merged state=%+v, want mode=%s hold=%s", state, tc.mode, tc.hold)
|
||||
}
|
||||
want := lifecycle.NoneAction
|
||||
if tc.delete {
|
||||
want = lifecycle.DeleteVersionAction
|
||||
}
|
||||
// Read-side expiry rechecks consume the same event producer.
|
||||
if events[1].Action != want {
|
||||
t.Errorf("expiry event=%s want=%s", events[1].Action, want)
|
||||
default:
|
||||
if len(versions) != 2 || versions[1].VersionID != id || versions[1].NumVersions != 2 {
|
||||
t.Fatalf("wrong version history: %+v", versions)
|
||||
}
|
||||
state := storedObjectLockState(versions[1].UserDefined)
|
||||
if state.mode != tc.mode || state.legalHold != tc.hold {
|
||||
t.Errorf("merged state=%+v, want mode=%s hold=%s", state, tc.mode, tc.hold)
|
||||
}
|
||||
want := lifecycle.NoneAction
|
||||
if tc.delete {
|
||||
want = lifecycle.DeleteVersionAction
|
||||
}
|
||||
// Read-side expiry rechecks consume the same event producer.
|
||||
if events[1].Action != want {
|
||||
t.Errorf("expiry event=%s want=%s", events[1].Action, want)
|
||||
}
|
||||
}
|
||||
opts := ObjectOptions{Versioned: true, Expiration: ExpirationOptions{Expire: true}}
|
||||
if batch {
|
||||
|
||||
Reference in New Issue
Block a user