Files
minio/cmd/erasure-server-pool-delete-all.go
T
Feng Ruohang b10fd0e9c0 fix(ilm): report refused purges of all versions, and account their keys
An expiry of all versions of a key purges exactly that key, and the
purge can refuse it or stop early. Those outcomes were hard to see, and
the scanner reported a key it had queued as if it were already gone.

- Refusals and failures of a purge are counted in
  expiry_incomplete_total with their own reasons: tier_unsupported (a
  version is in a remote tier), budget (stopped after progress; the next
  expiry continues), changed (the latest version a batch matched
  changed) and history_unavailable. They are traced with the reason and
  send no removal event. A partial purge keeps its audit record, now
  with the reason. A tier refusal is also logged, once per key and hour,
  in words.
- A drive that cannot be read or decoded during the purge's own drive
  check now returns errDeleteAllHistoryUnavailable, which names the
  cause and stays a write quorum error (503) for API callers.
- The scanner does not queue an expiry of all versions whose latest
  version is hot when the pool holds a version in a remote tier: the
  purge would refuse it each cycle. It counts each such observation as
  tier_unsupported_skipped, once per drive scanner and cycle. A tiered
  version only another pool holds is unknown here, so the purge still
  decides; a transitioned latest version keeps the transitioned expiry.
  The tier predicate is shared with the purge's own check, for FileInfo
  and ObjectInfo, and excludes free versions.
- The scanner accounts every version of a key under an expiry of all
  versions at its size, queued or not, instead of as already removed.
  Usage, and so hard quota, drops after a fresh scan of the subtree once
  the purge succeeded. The alerts on many or large versions count the
  versions of a key that is not queued.
- A lifecycle expiry never deletes a prefix: DeleteObject refuses
  DeletePrefix with Expiration.Expire as InvalidArgument. No caller
  sends it since expiries of all versions became purges; a force delete
  of a prefix is unchanged.

Not done from the plan: queuing an MRF heal per version when a drive's
xl.meta fails to decode. Object heal does not rewrite metadata that
fails its CRC check (it reports the drive as unknown and skips it), so
those heals would not repair the drive. Such a key stays refused until
its metadata on that drive is repaired.

Tests: TestPurgeAllVersionsRefusalReasons,
TestPurgeAllVersionsRefusesCorruptDrive,
TestScannerSkipsDeleteAllForTieredKey and
TestPoolsDeleteObjectRejectsExpirePrefix are new. Tests that expected
the scanner to queue a refused purge, the predictive accounting, or
the old prefix options are converted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-06 19:59:42 +08:00

533 lines
20 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 (
"context"
"errors"
"fmt"
"slices"
"sort"
"sync"
"time"
xhttp "github.com/minio/minio/internal/http"
"github.com/pgsty/silo-pkg/v3/sync/errgroup"
)
// An expiry of all versions of a key (ExpiredObjectAllVersions,
// DelMarkerExpiration, a batch expiry that keeps no versions) removes the
// versions of that key and nothing else. On the drives, object a/b lives
// inside the directory of object a, next to the metadata and data of a. The
// purge therefore never deletes that directory: it deletes the versions of
// the key one by one, by version ID, with the version delete of the drives,
// which removes the directory only once it is empty.
//
// It decides and deletes under the object lock that DeleteObject holds, on
// the authoritative history of the key in all pools (objectVersions):
// - an ILM expiry needs the latest version to expire with all versions as
// the scanner evaluates it on all of them (lifecycleEventsAllPools); a
// batch expiry needs the version it matched to be the latest still;
// - it deletes exactly the versions it approved, with every copy of them
// in every pool and the older writes of their IDs, and nothing else;
// - before it deletes anything it reads every drive of every set that
// holds a copy, and refuses the whole key when a drive cannot be read or
// when any revision of an approved version there is transitioned or,
// when the bucket has Object Lock, protected by retention or a legal
// hold. It deletes on exactly the drives it read;
// - a batch expiry then authorizes every copy of every approved version,
// and every revision of one that a drive holds, for the submitter of the
// job, and the purge deletes nothing unless all of them are allowed;
// - it deletes the oldest version first and the latest version last and
// alone, once every older version is gone from every pool. A purge that
// stops early keeps the latest version, so it never exposes an older
// one, and the next expiry continues on a fresh history.
var (
// errDeleteAllChanged: the version a batch expiry matched is no longer
// the latest version of the key.
errDeleteAllChanged = fmt.Errorf("%w: the latest version changed", errLifecycleNotApplicable)
// errDeleteAllTiered: a version of the key is in a remote tier. Removing
// all versions is left to a later release, which hands the remote
// objects to the tier cleanup.
errDeleteAllTiered = fmt.Errorf("%w: a version is transitioned", errLifecycleNotApplicable)
// errDeleteAllBudget: the purge stopped at its budget, before the
// latest version.
errDeleteAllBudget = errors.New("purge of all versions stopped at its budget")
// errDeleteAllHistoryUnavailable: a drive that may hold a revision of
// an approved version could not be read or decoded. Callers see a
// write quorum error.
errDeleteAllHistoryUnavailable = fmt.Errorf("a drive that may hold a version of the key cannot be read or decoded: %w", InsufficientWriteQuorum{})
// errDeleteAllUnsupported: only a single-object delete of the pools
// purges all versions. Any other layer would turn the request into
// another delete.
errDeleteAllUnsupported = NotImplemented{Message: "purge of all versions is a single-object delete of the pools"}
)
// deleteAllBudget bounds one purge, which holds the object lock while it
// runs. It stops before the next version once it removed versions versions
// or ran for duration, its reads and checks included.
var deleteAllBudget = struct {
versions int
duration time.Duration
}{versions: 1000, duration: time.Minute}
// deleteAllRetries bounds how often a version delete that reached its write
// quorum is repeated on the drives that failed it.
const deleteAllRetries = 2
// deleteAllMatch identifies the latest version that a batch expiry matched.
type deleteAllMatch struct {
VersionID string
ModTime time.Time
}
// matches reports whether fi is the version m identifies.
func (m deleteAllMatch) matches(fi FileInfo) bool {
return deleteAllVersionID(m.VersionID) == deleteAllVersionID(fi.VersionID) && m.ModTime.Equal(fi.ModTime)
}
// deleteAllResult is what a purge of all versions confirmed.
type deleteAllResult struct {
// Latest is the latest version the purge approved.
Latest ObjectInfo
// Removed counts the versions, delete markers included, that are gone
// from every pool, Markers the delete markers among them. Free versions
// and further copies of a version do not count.
Removed, Markers int
// Started: the purge began to delete. A failure after that can have
// removed copies that Removed does not count.
Started bool
// Complete: every approved version is gone, the latest included.
Complete bool
}
// deleteAllVersionID returns versionID, "null" for the null version.
func deleteAllVersionID(versionID string) string {
if versionID == "" {
return nullVersionID
}
return versionID
}
// deleteAllInvalid returns why opts cannot ask for a purge of all versions.
// The purge is approved either as an ILM expiry or by a batch match, which
// also authorizes the versions it deletes, and it only deletes: it never
// creates a marker, addresses a single version, moves data or deletes a
// prefix.
func (opts ObjectOptions) deleteAllInvalid() error {
switch {
case !opts.DeleteAllVersions:
return errors.New("only a purge of all versions authorizes the versions it deletes")
case opts.DeletePrefix || opts.DeletePrefixObject:
return errors.New("a purge of all versions is not a prefix delete")
case opts.VersionID != "":
return errors.New("a purge of all versions addresses no single version")
case opts.DataMovement:
return errors.New("a purge of all versions moves no data")
case opts.DeleteMarker || opts.Versioned || opts.VersionSuspended:
return errors.New("a purge of all versions creates no delete marker")
case opts.ReplicationRequest || opts.InclFreeVersions || opts.Transition != (TransitionOptions{}) ||
opts.CheckPrecondFn != nil || opts.EvalMetadataFn != nil || opts.EvalRetentionBypassFn != nil ||
opts.Expiration.CheckedAllPools:
return errors.New("a purge of all versions takes no options of other deletes")
case opts.Expiration.Expire == (opts.DeleteAllLatest != nil):
return errors.New("a purge of all versions needs exactly one approval")
case (opts.DeleteAllLatest != nil) != (opts.deleteAllAuthorize != nil):
return errors.New("a batch purge of all versions, and only it, authorizes the versions it deletes")
}
return nil
}
// deleteAllCopy is the copy of a version in a pool: the revision of it that
// the authoritative history of the set of the key in that pool accepted.
// Copies of one write share its ModTime, since data movement keeps it and
// writes a new data dir. Other writes of a version ID, such as null versions
// written in several pools, have another ModTime.
type deleteAllCopy struct {
pool int
fi FileInfo
}
// deleteAllVersion is a version of the key, "null" for the null version, and
// its copies in all pools, the newest write first and, on equal times, the
// lower pool first. The first copy is the one reads choose.
type deleteAllVersion struct {
id string
copies []deleteAllCopy
}
// deleteAllHistory returns the versions of object in all pools, newest first,
// with the copies of each, from the authoritative history of each pool. It
// fails when a pool cannot tell the history.
func (z *erasureServerPools) deleteAllHistory(ctx context.Context, bucket, object string) ([]deleteAllVersion, error) {
poolVersions := make([][]FileInfo, len(z.serverPools))
errs := make([]error, len(z.serverPools))
var wg sync.WaitGroup
for i, pool := range z.serverPools {
wg.Add(1)
go func(i int, set *erasureObjects) {
defer wg.Done()
poolVersions[i], errs[i] = set.objectVersions(ctx, bucket, object)
}(i, pool.getHashedSet(object))
}
wg.Wait()
for _, err := range errs {
if err != nil {
return nil, err
}
}
var all []deleteAllCopy
for pool, versions := range poolVersions {
for _, fi := range versions {
if fi.ModTime.IsZero() {
// A write that cannot be ordered cannot be deleted in order.
return nil, toObjectErr(errErasureReadQuorum, bucket, object)
}
all = append(all, deleteAllCopy{pool: pool, fi: fi})
}
}
// The order of lifecycleEventsAllPools and of reads.
sort.SliceStable(all, func(i, j int) bool { return all[i].fi.ModTime.After(all[j].fi.ModTime) })
index := make(map[string]int, len(all))
var history []deleteAllVersion
for _, c := range all {
id := deleteAllVersionID(c.fi.VersionID)
if i, ok := index[id]; ok {
history[i].copies = append(history[i].copies, c)
continue
}
index[id] = len(history)
history = append(history, deleteAllVersion{id: id, copies: []deleteAllCopy{c}})
}
return history, nil
}
// deleteAllApproved reports whether history holds exactly the versions that
// the ILM evaluation approved, in the same order, the same writes chosen.
func deleteAllApproved(history []deleteAllVersion, approved []ObjectInfo) bool {
if len(history) != len(approved) {
return false
}
for i, v := range history {
got := v.copies[0].fi.ToObjectInfo("", "", false)
want := approved[i]
if v.id != deleteAllVersionID(want.VersionID) || !got.ModTime.Equal(want.ModTime) ||
got.DeleteMarker != want.DeleteMarker || got.ETag != want.ETag || got.Size != want.Size {
return false
}
}
return true
}
// deleteAllVersions deletes every version of object, whose expiry of all
// versions opts describe, and nothing else, see the comment above. The
// caller holds the object lock. It returns the approved latest version, and
// what it confirmed in opts.DeleteAllResult, also on an error. A key without
// versions is a success that removes nothing.
func (z *erasureServerPools) deleteAllVersions(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
res := opts.DeleteAllResult
if res == nil {
res = &deleteAllResult{}
}
*res = deleteAllResult{}
start := time.Now()
name := decodeDirObject(object)
fail := func(err error) (ObjectInfo, error) {
if isErrReadQuorum(err) {
err = InsufficientWriteQuorum{}
}
return ObjectInfo{Bucket: bucket, Name: name}, err
}
lr, err := globalBucketObjectLockSys.Get(bucket)
if err != nil {
return fail(err)
}
// Decide.
var approved []ObjectInfo
if opts.Expiration.Expire {
var all lifecycleAllPools
all.versions, all.events, all.err = z.lifecycleEventsAllPools(ctx, bucket, object)
if all.err != nil || len(all.versions) > 0 {
if err := all.expiryApplies(bucket, object, ObjectOptions{DeletePrefix: true}); err != nil {
return fail(err)
}
}
approved = all.versions
}
// The placement of the approved versions. This read may only confirm
// them: whatever differs from the approval refuses the key.
history, err := z.deleteAllHistory(ctx, bucket, object)
if err != nil {
return fail(err)
}
if opts.Expiration.Expire && !deleteAllApproved(history, approved) {
return fail(InsufficientWriteQuorum{})
}
if len(history) == 0 {
res.Complete = true
return ObjectInfo{Bucket: bucket, Name: name}, nil
}
if opts.DeleteAllLatest != nil && !opts.DeleteAllLatest.matches(history[0].copies[0].fi) {
return fail(errDeleteAllChanged)
}
if opts.Expiration.Expire {
res.Latest = approved[0]
} else {
vc, _ := globalBucketVersioningSys.Get(bucket)
res.Latest = history[0].copies[0].fi.ToObjectInfo(bucket, object, vc != nil && vc.Versioned(name))
}
res.Latest.Bucket, res.Latest.Name = bucket, name
// Check every write before deleting any: the accepted revisions, then
// every revision on every drive that the purge will delete on.
ids := make(map[string]bool, len(history))
held := make([]bool, len(z.serverPools))
for _, v := range history {
ids[v.id] = true
for _, c := range v.copies {
held[c.pool] = true
if err := deleteAllRefuses(ctx, bucket, object, c.fi, lr.LockEnabled); err != nil {
return fail(err)
}
}
}
// A batch expiry deletes as its submitter: every write it is about to
// delete, the accepted copies and every revision a drive holds, must be
// allowed with the version ID and tags it has now.
var writes *deleteAllWrites
if opts.deleteAllAuthorize != nil {
writes = &deleteAllWrites{bucket: bucket, name: name, seen: make(map[[2]string]bool, len(history))}
for _, v := range history {
for _, c := range v.copies {
writes.add(c.fi)
}
}
}
drives := make([][]StorageAPI, len(z.serverPools))
for pool := range z.serverPools {
if !held[pool] {
continue
}
if drives[pool], err = z.serverPools[pool].getHashedSet(object).deleteAllDrives(ctx, bucket, object, ids, lr.LockEnabled, writes); err != nil {
return fail(err)
}
}
if opts.deleteAllAuthorize != nil {
if err := opts.deleteAllAuthorize(writes.versions); err != nil {
return fail(err)
}
}
// Delete the oldest version first. The latest one is the last and is
// only reached once every older one is gone from every pool.
for i := len(history) - 1; i >= 0; i-- {
if err := ctx.Err(); err != nil {
return fail(err)
}
if res.Removed >= deleteAllBudget.versions || time.Since(start) >= deleteAllBudget.duration {
return fail(errDeleteAllBudget)
}
res.Started = true
if err := z.deleteAllCopies(ctx, bucket, object, history[i], drives); err != nil {
return fail(err)
}
res.Removed++
if history[i].copies[0].fi.Deleted {
res.Markers++
}
}
res.Complete = true
return res.Latest, nil
}
// deleteAllWrites collects, for the authorization of a batch purge, the
// writes it deletes: one of each distinct version ID and tags.
type deleteAllWrites struct {
bucket, name string
seen map[[2]string]bool
versions []ObjectInfo
}
// add adds the write fi unless w is nil or holds one with its version ID and
// tags.
func (w *deleteAllWrites) add(fi FileInfo) {
if w == nil {
return
}
key := [2]string{deleteAllVersionID(fi.VersionID), fi.Metadata[xhttp.AmzObjectTagging]}
if w.seen[key] {
return
}
w.seen[key] = true
w.versions = append(w.versions, fi.ToObjectInfo(w.bucket, w.name, true))
}
// tierRefusesDeleteAll reports whether a version with these transition
// fields makes the purge refuse its key: any version that is not a free
// version and is transitioned, being transitioned, or restored. A free
// version only records a remote object for the tier cleanup.
func tierRefusesDeleteAll(freeVersion bool, status, tier, remote string) bool {
return !freeVersion && (status != "" || tier != "" || remote != "")
}
// deleteAllTierRefuses: see tierRefusesDeleteAll.
func (fi FileInfo) deleteAllTierRefuses() bool {
return tierRefusesDeleteAll(fi.TierFreeVersion(), fi.TransitionStatus, fi.TransitionTier, fi.TransitionedObjName)
}
// deleteAllTierRefuses: see tierRefusesDeleteAll.
func (oi ObjectInfo) deleteAllTierRefuses() bool {
t := oi.TransitionedObject
return tierRefusesDeleteAll(t.FreeVersion, t.Status, t.Tier, t.Name)
}
// deleteAllRefuses returns why the purge must keep the key that holds the
// revision fi of an approved version: it is transitioned, whose remote
// object a later release hands to the tier cleanup, or, with Object Lock,
// protected by retention or a legal hold.
func deleteAllRefuses(ctx context.Context, bucket, object string, fi FileInfo, lockEnabled bool) error {
if fi.deleteAllTierRefuses() {
return errDeleteAllTiered
}
if lockEnabled && enforceRetentionForDeletion(ctx, fi.ToObjectInfo(bucket, object, true)) {
return ObjectLocked{Bucket: bucket, Object: decodeDirObject(object), VersionID: deleteAllVersionID(fi.VersionID)}
}
return nil
}
// deleteAllDrives reads object on every drive of the set and checks each
// revision of the versions ids that a drive holds with deleteAllRefuses, and
// adds it to writes.
// A minority of drives can hold a revision other than the accepted one, and
// heal would later remove what the purge leaves on a drive, so a drive that
// cannot be read or decoded refuses the key. A drive without the object
// holds nothing to check. It returns the drive handles it read, which the
// purge deletes on; a handle that a reconnect installs later is not used. A
// handle names a drive, not a backend: a backend that replaces the drive
// behind it starts empty and receives this object only from a write or a
// heal of it, which take the object lock the purge holds.
func (er erasureObjects) deleteAllDrives(ctx context.Context, bucket, object string, ids map[string]bool, lockEnabled bool, writes *deleteAllWrites) ([]StorageAPI, error) {
disks := slices.Clone(er.getDisks())
raws, errs := readAllRawFileInfo(ctx, disks, bucket, object, false)
if err := ctx.Err(); err != nil {
return nil, err
}
for i := range disks {
if errors.Is(errs[i], errFileNotFound) {
continue
}
if errs[i] != nil {
return nil, errDeleteAllHistoryUnavailable
}
var xl xlMetaV2
if err := xl.LoadOrConvert(raws[i].Buf); err != nil {
return nil, errDeleteAllHistoryUnavailable
}
versions, err := xl.ListVersions(bucket, object, false)
if err != nil {
return nil, errDeleteAllHistoryUnavailable
}
for _, fi := range versions {
if fi.TierFreeVersion() || !ids[deleteAllVersionID(fi.VersionID)] {
continue
}
if err := deleteAllRefuses(ctx, bucket, object, fi, lockEnabled); err != nil {
return nil, err
}
writes.add(fi)
}
}
return disks, nil
}
// deleteAllCopies deletes every copy of the version v: the older writes of
// its ID first, the copy that reads choose last, so that a failure leaves
// what a read returned before. It deletes on the drives that drives holds
// for the pool of a copy.
func (z *erasureServerPools) deleteAllCopies(ctx context.Context, bucket, object string, v deleteAllVersion, drives [][]StorageAPI) error {
for i := len(v.copies) - 1; i >= 0; i-- {
pool := v.copies[i].pool
set := z.serverPools[pool].getHashedSet(object)
if err := set.purgeVersion(ctx, bucket, object, v.id, drives[pool]); err != nil {
return toObjectErr(err, bucket, decodeDirObject(object), v.id)
}
}
return nil
}
// purgeVersion physically removes the version versionID of object from
// disks, the drives of the set that deleteAllDrives checked: no delete
// marker, no replication state, no free version. A drive that does not hold
// the version counts as done, so that a retry is idempotent. When the delete
// reaches its write quorum but fails on some drives, it is repeated on those
// while the caller still holds the object lock; what remains is left to
// heal. The caller holds the object lock.
func (er erasureObjects) purgeVersion(ctx context.Context, bucket, object, versionID string, disks []StorageAPI) error {
writeQuorum := len(disks)/2 + 1
fi := FileInfo{Name: object, VersionID: versionID}
// The purge checked that no drive it deletes on holds a transitioned
// revision. A free version would hand a remote object to the tier
// cleanup, so none is made.
fi.SetSkipTierFreeVersion()
errs := make([]error, len(disks))
for attempt := 0; attempt <= deleteAllRetries; attempt++ {
g := errgroup.WithNErrs(len(disks))
for index := range disks {
if attempt > 0 && (errs[index] == nil || disks[index] == nil) {
continue
}
g.Go(func() error {
if disks[index] == nil {
return errDiskNotFound
}
err := disks[index].DeleteVersion(ctx, bucket, object, fi, false, DeleteOptions{})
if errors.Is(err, errFileNotFound) || errors.Is(err, errFileVersionNotFound) {
return nil
}
return err
}, index)
}
for index, err := range g.Wait() {
if attempt == 0 || (errs[index] != nil && disks[index] != nil) {
errs[index] = err
}
}
if err := reduceWriteQuorumErrs(ctx, errs, objectOpIgnoredErrs, writeQuorum); err != nil {
er.addPartial(bucket, object, versionID)
return err
}
if !slices.ContainsFunc(errs, func(err error) bool { return err != nil }) {
return nil
}
retry := false
for index, err := range errs {
retry = retry || err != nil && disks[index] != nil
}
if !retry {
break
}
}
er.addPartial(bucket, object, versionID)
return nil
}