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>
This commit is contained in:
Feng Ruohang
2026-11-06 19:59:42 +08:00
parent 852e2a6707
commit b10fd0e9c0
8 changed files with 470 additions and 37 deletions
+27 -4
View File
@@ -69,6 +69,10 @@ var (
// 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.
@@ -377,12 +381,31 @@ func (w *deleteAllWrites) add(fi FileInfo) {
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.TransitionStatus != "" || fi.TransitionTier != "" || fi.TransitionedObjName != "" {
if fi.deleteAllTierRefuses() {
return errDeleteAllTiered
}
if lockEnabled && enforceRetentionForDeletion(ctx, fi.ToObjectInfo(bucket, object, true)) {
@@ -413,15 +436,15 @@ func (er erasureObjects) deleteAllDrives(ctx context.Context, bucket, object str
continue
}
if errs[i] != nil {
return nil, toObjectErr(errErasureReadQuorum, bucket, object)
return nil, errDeleteAllHistoryUnavailable
}
var xl xlMetaV2
if err := xl.LoadOrConvert(raws[i].Buf); err != nil {
return nil, toObjectErr(errErasureReadQuorum, bucket, object)
return nil, errDeleteAllHistoryUnavailable
}
versions, err := xl.ListVersions(bucket, object, false)
if err != nil {
return nil, toObjectErr(errErasureReadQuorum, bucket, object)
return nil, errDeleteAllHistoryUnavailable
}
for _, fi := range versions {
if fi.TierFreeVersion() || !ids[deleteAllVersionID(fi.VersionID)] {