Files
minio/cmd/erasure-server-pool-lifecycle.go
T

282 lines
10 KiB
Go

// Copyright (c) 2026 Denis Babchuk
//
// 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"
"sort"
"sync"
"time"
"github.com/minio/minio/internal/bucket/lifecycle"
)
// ILM decides on the versions of an object that one pool holds: the scanner
// and the drive heal read the drives of one set, decommission and rebalance
// walk one pool, and a GET or HEAD of a version reads the pool that holds it.
// IsLatest, NumVersions and SuccessorModTime count only these. An object can
// have versions in several pools: from two concurrent first writes, from
// overwrites that went to another pool, from data movement. The expiries
// these decisions run delete in every pool, so a version that is the latest
// in its own pool but not of the object had a newer version hidden under a
// delete marker or deleted with all versions, and a delete marker without
// noncurrent versions in its pool was removed over the versions of another
// pool. Deciding on fewer versions only delays the expiry of a noncurrent
// version: its successor in the pool is not older than the one of the object,
// and fewer newer versions count against NewerNoncurrentVersions.
//
// Before an expiry deletes in any pool, it is evaluated again with the
// versions of the object in all pools, as the scanner would evaluate it if
// one pool held them all. An expiry that no longer applies is not run, as the
// recheck of a set does it: the scanner queues it again once the rule
// applies to the object.
// objectVersions returns the versions of object that a read quorum of the
// drives of the set agree on, newest first, without free versions. It returns
// no versions when the set does not hold the object, and an error when too
// few drives answer to tell.
func (er erasureObjects) objectVersions(ctx context.Context, bucket, object string) ([]FileInfo, error) {
disks := er.getDisks()
rawFileInfos, errs := readAllRawFileInfo(ctx, disks, bucket, object, false)
readQuorum := (len(disks) + 1) / 2
var (
metas [][]xlMetaV2ShallowVersion
metaV uint8
unknown int
)
for i, rf := range rawFileInfos {
if errs[i] != nil {
if !errors.Is(errs[i], errFileNotFound) && !errors.Is(errs[i], errVolumeNotFound) {
unknown++
}
continue
}
var xl xlMetaV2
if err := xl.LoadOrConvert(rf.Buf); err != nil {
unknown++
continue
}
metas = append(metas, xl.versions)
metaV = xl.metaV
}
if len(metas) < readQuorum {
if len(metas)+unknown < readQuorum {
return nil, nil // not in this set
}
return nil, toObjectErr(errErasureReadQuorum, bucket, object)
}
merged := mergeXLV2Versions(readQuorum, false, 0, metas...)
if len(merged) == 0 {
for _, versions := range metas {
if len(versions) > 0 {
return nil, toObjectErr(errErasureReadQuorum, bucket, object)
}
}
return nil, nil
}
all, err := xlMetaV2{versions: merged, metaV: metaV}.ListVersions(bucket, object, false)
if err != nil {
return nil, toObjectErr(err, bucket, object)
}
versions := all[:0]
for _, fi := range all {
if !fi.TierFreeVersion() {
versions = append(versions, fi)
}
}
return versions, nil
}
// lifecycleEventsAllPools returns the versions of object in all pools,
// newest first, and the lifecycle event of each as the scanner evaluates it
// on them. A version with copies in several pools, as an interrupted move
// leaves it, counts once.
func (z *erasureServerPools) lifecycleEventsAllPools(ctx context.Context, bucket, object string) ([]ObjectInfo, []lifecycle.Event, error) {
lc, err := globalLifecycleSys.Get(bucket)
if err != nil {
if errors.Is(err, BucketLifecycleNotFound{Bucket: bucket}) {
return nil, nil, nil
}
return nil, nil, err
}
lr, err := globalBucketObjectLockSys.Get(bucket)
if err != nil {
return nil, nil, err
}
rcfg, err := getReplicationConfig(ctx, bucket)
if err != nil {
return nil, nil, err
}
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, nil, err
}
}
var all []FileInfo
for _, versions := range poolVersions {
all = append(all, versions...)
}
// Each pool lists its versions newest first. On equal times the lower
// pool comes first, as reads choose it.
sort.SliceStable(all, func(i, j int) bool { return all[i].ModTime.After(all[j].ModTime) })
seen := make(map[string]struct{}, len(all))
versions := all[:0]
for _, fi := range all {
if _, ok := seen[fi.VersionID]; ok {
continue
}
seen[fi.VersionID] = struct{}{}
versions = append(versions, fi)
}
vc, _ := globalBucketVersioningSys.Get(bucket)
versioned := vc != nil && vc.Versioned(decodeDirObject(object))
objInfos := make([]ObjectInfo, len(versions))
objOpts := make([]lifecycle.ObjectOpts, len(versions))
for i := range versions {
fi := versions[i]
fi.IsLatest = i == 0
fi.NumVersions = len(versions)
fi.SuccessorModTime = time.Time{}
if i > 0 {
fi.SuccessorModTime = versions[i-1].ModTime
}
objInfos[i] = fi.ToObjectInfo(bucket, object, versioned)
objOpts[i] = objInfos[i].ToLifecycleOpts()
}
events, err := lifecycle.NewEvaluator(*lc).WithLockRetention(&lr).WithReplicationConfig(rcfg).Eval(objOpts)
if err != nil {
return nil, nil, err
}
return objInfos, events, nil
}
// lifecycleAllPools holds the versions of an object in all pools and their
// lifecycle events, see lifecycleEventsAllPools.
type lifecycleAllPools struct {
versions []ObjectInfo
events []lifecycle.Event
err error
}
// checkExpiryAllPools returns nil when the ILM expiry that opts describe
// still applies to object with the versions it has in all pools, see
// lifecycleAllPools.expiryApplies.
func (z *erasureServerPools) checkExpiryAllPools(ctx context.Context, bucket, object string, opts ObjectOptions) error {
var all lifecycleAllPools
all.versions, all.events, all.err = z.lifecycleEventsAllPools(ctx, bucket, object)
return all.expiryApplies(bucket, object, opts)
}
// expiryApplies returns nil when the ILM expiry that opts describe applies
// to the versions, and otherwise the error a set returns for an expiry that
// no longer applies:
// - an expiry of all versions (DeletePrefix) needs the latest version to
// expire with all versions;
// - a delete marker (no VersionID) hides the latest version, which has to
// expire;
// - a version (VersionID) has to expire by itself: a noncurrent version,
// or a delete marker without noncurrent versions.
func (all lifecycleAllPools) expiryApplies(bucket, object string, opts ObjectOptions) error {
if all.err != nil {
if _, ok := all.err.(InsufficientReadQuorum); ok {
return InsufficientWriteQuorum{}
}
return all.err
}
name := decodeDirObject(object)
switch {
case opts.DeletePrefix:
if len(all.events) > 0 && all.events[0].Action.DeleteAll() {
return nil
}
case opts.VersionID == "":
if len(all.events) > 0 && (all.events[0].Action == lifecycle.DeleteAction || all.events[0].Action == lifecycle.DeleteAllVersionsAction) {
return nil
}
default:
for i, oi := range all.versions {
if oi.VersionID == opts.VersionID || oi.VersionID == "" && opts.VersionID == nullVersionID {
if all.events[i].Action == lifecycle.DeleteVersionAction {
return nil
}
break
}
}
return VersionNotFound{Bucket: bucket, Object: name, VersionID: opts.VersionID}
}
return ObjectNotFound{Bucket: bucket, Object: name}
}
// expiryNeedsAllPools reports whether an ILM expiry in opts deletes or hides
// versions by a rule that depends on the other versions of the object. A
// restored copy expires by its own restore date. Without versions each copy
// of an object is a write of its own, and a set rechecks the rule on its
// copy (deleteObjectFromAllPools).
func (z *erasureServerPools) expiryNeedsAllPools(opts ObjectOptions) bool {
if !opts.Expiration.Expire || z.SinglePool() || opts.Transition.ExpireRestored {
return false
}
return opts.DeletePrefix || opts.VersionID != "" || opts.Versioned || opts.VersionSuspended
}
// migrationExpiryApplies reports whether the ILM expiry evt, which a
// decommission or rebalance decided for oi on the versions of the pool it
// walks, applies with the versions of the object in all pools too. Only then
// may the walk leave the version to the expiry instead of moving it. A
// version that is the latest in the walked pool can be a noncurrent version
// of the object, and a delete marker alone in that pool can hide versions in
// another one. Such a version is moved like any other, and the scanner
// applies the rule in the pool that holds the object then.
func (z *erasureServerPools) migrationExpiryApplies(ctx context.Context, oi ObjectInfo, evt lifecycle.Event) bool {
// The options the expiry deletes with, see
// applyExpiryOnNonTransitionedObjects and expireTransitionedObject.
opts := ObjectOptions{
Expiration: ExpirationOptions{Expire: true},
Versioned: globalBucketVersioningSys.PrefixEnabled(oi.Bucket, oi.Name),
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(oi.Bucket, oi.Name),
}
if evt.Action.DeleteVersioned() {
opts.VersionID = oi.VersionID
}
if evt.Action.DeleteAll() {
opts.DeletePrefix = true
}
opts.Transition.ExpireRestored = evt.Action.DeleteRestored()
if !z.expiryNeedsAllPools(opts) {
return true
}
return z.checkExpiryAllPools(ctx, oi.Bucket, encodeDirObject(oi.Name), opts) == nil
}