Fix evaluation of NewerNoncurrentVersions (#21096)

- Move VersionPurgeStatus into replication package
- ilm: Evaluate policy w/ obj retention/replication
- lifecycle: Use Evaluator to enforce ILM in scanner
- Unit tests covering ILM, replication and retention
- Simplify NewEvaluator constructor
This commit is contained in:
Krishnan Parthasarathi
2025-04-02 23:45:06 -07:00
committed by GitHub
parent 07f31e574c
commit 01447d2438
20 changed files with 916 additions and 471 deletions

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2015-2025 MinIO, Inc.
//
// This file is part of MinIO 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 lifecycle
import (
"fmt"
"time"
objlock "github.com/minio/minio/internal/bucket/object/lock"
"github.com/minio/minio/internal/bucket/replication"
)
// Evaluator - evaluates lifecycle policy on objects for the given lifecycle
// configuration, lock retention configuration and replication configuration.
type Evaluator struct {
policy Lifecycle
lockRetention *objlock.Retention
replCfg *replication.Config
}
// NewEvaluator - creates a new evaluator with the given lifecycle
func NewEvaluator(policy Lifecycle) *Evaluator {
return &Evaluator{
policy: policy,
}
}
// WithLockRetention - sets the lock retention configuration for the evaluator
func (e *Evaluator) WithLockRetention(lr *objlock.Retention) *Evaluator {
e.lockRetention = lr
return e
}
// WithReplicationConfig - sets the replication configuration for the evaluator
func (e *Evaluator) WithReplicationConfig(rcfg *replication.Config) *Evaluator {
e.replCfg = rcfg
return e
}
// IsPendingReplication checks if the object is pending replication.
func (e *Evaluator) IsPendingReplication(obj ObjectOpts) bool {
if e.replCfg == nil {
return false
}
if e.replCfg.HasActiveRules(obj.Name, true) && !obj.VersionPurgeStatus.Empty() {
return true
}
return false
}
// IsObjectLocked checks if it is appropriate to remove an
// object according to locking configuration when this is lifecycle/ bucket quota asking.
// (copied over from enforceRetentionForDeletion)
func (e *Evaluator) IsObjectLocked(obj ObjectOpts) bool {
if e.lockRetention == nil || !e.lockRetention.LockEnabled {
return false
}
if obj.DeleteMarker {
return false
}
lhold := objlock.GetObjectLegalHoldMeta(obj.UserDefined)
if lhold.Status.Valid() && lhold.Status == objlock.LegalHoldOn {
return true
}
ret := objlock.GetObjectRetentionMeta(obj.UserDefined)
if ret.Mode.Valid() && (ret.Mode == objlock.RetCompliance || ret.Mode == objlock.RetGovernance) {
t, err := objlock.UTCNowNTP()
if err != nil {
// it is safe to assume that the object is locked when
// we can't get the current time
return true
}
if ret.RetainUntilDate.After(t) {
return true
}
}
return false
}
// eval will return a lifecycle event for each object in objs for a given time.
func (e *Evaluator) eval(objs []ObjectOpts, now time.Time) []Event {
events := make([]Event, len(objs))
var newerNoncurrentVersions int
loop:
for i, obj := range objs {
event := e.policy.eval(obj, now, newerNoncurrentVersions)
switch event.Action {
case DeleteAllVersionsAction, DelMarkerDeleteAllVersionsAction:
// Skip if bucket has object locking enabled; To prevent the
// possibility of violating an object retention on one of the
// noncurrent versions of this object.
if e.lockRetention != nil && e.lockRetention.LockEnabled {
event = Event{}
} else {
// No need to evaluate remaining versions' lifecycle
// events after DeleteAllVersionsAction*
events[i] = event
break loop
}
case DeleteVersionAction, DeleteRestoredVersionAction:
// Defensive code, should never happen
if obj.VersionID == "" {
event.Action = NoneAction
}
if e.IsObjectLocked(obj) {
event = Event{}
}
if e.IsPendingReplication(obj) {
event = Event{}
}
}
if !obj.IsLatest {
switch event.Action {
case DeleteVersionAction:
// this noncurrent version will be expired, nothing to add
default:
// this noncurrent version will be spared
newerNoncurrentVersions++
}
}
events[i] = event
}
return events
}
// Eval will return a lifecycle event for each object in objs
func (e *Evaluator) Eval(objs []ObjectOpts) ([]Event, error) {
if len(objs) != objs[0].NumVersions {
return nil, fmt.Errorf("number of versions mismatch, expected %d, got %d", objs[0].NumVersions, len(objs))
}
return e.eval(objs, time.Now().UTC()), nil
}

View File

@@ -0,0 +1,177 @@
// Copyright (c) 2015-2025 MinIO, Inc.
//
// This file is part of MinIO 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 lifecycle
import (
"fmt"
"testing"
"time"
"github.com/google/uuid"
)
func TestNewerNoncurrentVersions(t *testing.T) {
prepLifecycleCfg := func(tagKeys []string, retainVersions []int) Lifecycle {
var lc Lifecycle
for i := range retainVersions {
ruleID := fmt.Sprintf("rule-%d", i)
tag := Tag{
Key: tagKeys[i],
Value: "minio",
}
lc.Rules = append(lc.Rules, Rule{
ID: ruleID,
Status: "Enabled",
Filter: Filter{
Tag: tag,
set: true,
},
NoncurrentVersionExpiration: NoncurrentVersionExpiration{
NewerNoncurrentVersions: retainVersions[i],
set: true,
},
})
}
return lc
}
lc := prepLifecycleCfg([]string{"tag3", "tag4", "tag5"}, []int{3, 4, 5})
evaluator := NewEvaluator(lc)
tagKeys := []string{"tag3", "tag3", "tag3", "tag4", "tag4", "tag5", "tag5"}
verIDs := []string{
"0NdAikoUVNGEpCUuB9vl.XyoMftMXCSg", "19M6Z405yFZuYygnnU9jKzsOBamTZK_7", "0PmlJdFWi_9d6l_dAkWrrhP.bBgtFk6V", // spellchecker:disable-line
".MmRalFNNJyOLymgCtQ3.qsdoYpy8qkB", "Bjb4OlMW9Agx.Nrggh15iU6frGu2CLde", "ngBmUd_cVl6ckONI9XsKGpJjzimohrzZ", // spellchecker:disable-line
"T6m1heTHLUtnByW2IOWJ3zM4JP9xXt2O", // spellchecker:disable-line
}
wantEvents := []Event{
{Action: NoneAction},
{Action: NoneAction},
{Action: NoneAction},
{Action: NoneAction},
{Action: NoneAction},
{Action: NoneAction},
{Action: DeleteVersionAction},
}
var objs []ObjectOpts
curModTime := time.Date(2025, time.February, 10, 23, 0, 0, 0, time.UTC)
for i := range tagKeys {
obj := ObjectOpts{
Name: "obj",
VersionID: verIDs[i],
ModTime: curModTime.Add(time.Duration(-i) * time.Second),
UserTags: fmt.Sprintf("%s=minio", tagKeys[i]),
NumVersions: len(verIDs),
}
if i == 0 {
obj.IsLatest = true
} else {
obj.SuccessorModTime = curModTime.Add(time.Duration(-i+1) * time.Second)
}
objs = append(objs, obj)
}
now := time.Date(2025, time.February, 10, 23, 0, 0, 0, time.UTC)
gotEvents := evaluator.eval(objs, now)
for i := range wantEvents {
if gotEvents[i].Action != wantEvents[i].Action {
t.Fatalf("got %v, want %v", gotEvents[i], wantEvents[i])
}
}
lc = prepLifecycleCfg([]string{"tag3", "tag4", "tag5"}, []int{1, 2, 3})
objs = objs[:len(objs)-1]
wantEvents = []Event{
{Action: NoneAction},
{Action: NoneAction},
{Action: DeleteVersionAction},
{Action: NoneAction},
{Action: DeleteVersionAction},
{Action: NoneAction},
}
evaluator = NewEvaluator(lc)
gotEvents = evaluator.eval(objs, now)
for i := range wantEvents {
if gotEvents[i].Action != wantEvents[i].Action {
t.Fatalf("test-%d: got %v, want %v", i+1, gotEvents[i], wantEvents[i])
}
}
lc = Lifecycle{
Rules: []Rule{
{
ID: "AllVersionsExpiration",
Status: "Enabled",
Filter: Filter{},
Expiration: Expiration{
Days: 1,
DeleteAll: Boolean{
val: true,
set: true,
},
set: true,
},
},
},
}
now = time.Date(2025, time.February, 12, 23, 0, 0, 0, time.UTC)
evaluator = NewEvaluator(lc)
gotEvents = evaluator.eval(objs, now)
wantEvents = []Event{
{Action: DeleteAllVersionsAction},
{Action: NoneAction},
{Action: NoneAction},
{Action: NoneAction},
{Action: NoneAction},
{Action: NoneAction},
}
for i := range wantEvents {
if gotEvents[i].Action != wantEvents[i].Action {
t.Fatalf("test-%d: got %v, want %v", i+1, gotEvents[i], wantEvents[i])
}
}
}
func TestEmptyEvaluator(t *testing.T) {
var objs []ObjectOpts
curModTime := time.Date(2025, time.February, 10, 23, 0, 0, 0, time.UTC)
for i := range 5 {
obj := ObjectOpts{
Name: "obj",
VersionID: uuid.New().String(),
ModTime: curModTime.Add(time.Duration(-i) * time.Second),
NumVersions: 5,
}
if i == 0 {
obj.IsLatest = true
} else {
obj.SuccessorModTime = curModTime.Add(time.Duration(-i+1) * time.Second)
}
objs = append(objs, obj)
}
evaluator := NewEvaluator(Lifecycle{})
events, err := evaluator.Eval(objs)
if err != nil {
t.Fatal(err)
}
for _, event := range events {
if event.Action != NoneAction {
t.Fatalf("got %v, want %v", event.Action, NoneAction)
}
}
}

View File

@@ -28,6 +28,7 @@ import (
"github.com/google/uuid"
"github.com/minio/minio/internal/bucket/object/lock"
"github.com/minio/minio/internal/bucket/replication"
xhttp "github.com/minio/minio/internal/http"
)
@@ -310,6 +311,10 @@ type ObjectOpts struct {
TransitionStatus string
RestoreOngoing bool
RestoreExpires time.Time
// to determine if object is locked due to retention
UserDefined map[string]string
VersionPurgeStatus replication.VersionPurgeStatusType
ReplicationStatus replication.StatusType
}
// ExpiredObjectDeleteMarker returns true if an object version referred to by o
@@ -331,12 +336,12 @@ type Event struct {
// Eval returns the lifecycle event applicable now.
func (lc Lifecycle) Eval(obj ObjectOpts) Event {
return lc.eval(obj, time.Now().UTC())
return lc.eval(obj, time.Now().UTC(), 0)
}
// eval returns the lifecycle event applicable at the given now. If now is the
// zero value of time.Time, it returns the upcoming lifecycle event.
func (lc Lifecycle) eval(obj ObjectOpts, now time.Time) Event {
func (lc Lifecycle) eval(obj ObjectOpts, now time.Time, remainingVersions int) Event {
var events []Event
if obj.ModTime.IsZero() {
return Event{}
@@ -404,17 +409,22 @@ func (lc Lifecycle) eval(obj ObjectOpts, now time.Time) Event {
continue
}
// Skip rules with newer noncurrent versions specified. These rules are
// not handled at an individual version level. eval applies only to a
// specific version.
if !obj.IsLatest && rule.NoncurrentVersionExpiration.NewerNoncurrentVersions > 0 {
continue
}
if !obj.IsLatest && !rule.NoncurrentVersionExpiration.IsDaysNull() {
// Non current versions should be deleted if their age exceeds non current days configuration
// https://docs.aws.amazon.com/AmazonS3/latest/dev/intro-lifecycle-rules.html#intro-lifecycle-rules-actions
if expectedExpiry := ExpectedExpiryTime(obj.SuccessorModTime, int(rule.NoncurrentVersionExpiration.NoncurrentDays)); now.IsZero() || now.After(expectedExpiry) {
// NoncurrentVersionExpiration
if !obj.IsLatest && rule.NoncurrentVersionExpiration.set {
var (
retainedEnough bool
oldEnough bool
)
if rule.NoncurrentVersionExpiration.NewerNoncurrentVersions == 0 || remainingVersions >= rule.NoncurrentVersionExpiration.NewerNoncurrentVersions {
retainedEnough = true
}
expectedExpiry := ExpectedExpiryTime(obj.SuccessorModTime, int(rule.NoncurrentVersionExpiration.NoncurrentDays))
if now.IsZero() || now.After(expectedExpiry) {
oldEnough = true
}
// > For the deletion to occur, both the <NoncurrentDays> and the <NewerNoncurrentVersions> values must be exceeded.
// ref: https://docs.aws.amazon.com/AmazonS3/latest/dev/intro-lifecycle-rules.html#intro-lifecycle-rules-actions
if retainedEnough && oldEnough {
events = append(events, Event{
Action: DeleteVersionAction,
RuleID: rule.ID,
@@ -529,7 +539,7 @@ func ExpectedExpiryTime(modTime time.Time, days int) time.Time {
// SetPredictionHeaders sets time to expiry and transition headers on w for a
// given obj.
func (lc Lifecycle) SetPredictionHeaders(w http.ResponseWriter, obj ObjectOpts) {
event := lc.eval(obj, time.Time{})
event := lc.eval(obj, time.Time{}, 0)
switch event.Action {
case DeleteAction, DeleteVersionAction, DeleteAllVersionsAction, DelMarkerDeleteAllVersionsAction:
w.Header()[xhttp.AmzExpiration] = []string{

View File

@@ -960,7 +960,9 @@ func TestTransitionTier(t *testing.T) {
// Go back seven days in the past
now = now.Add(7 * 24 * time.Hour)
evt := lc.eval(obj1, now)
evaluator := NewEvaluator(lc)
evts := evaluator.eval([]ObjectOpts{obj1, obj2}, now)
evt := evts[0]
if evt.Action != TransitionAction {
t.Fatalf("Expected action: %s but got %s", TransitionAction, evt.Action)
}
@@ -968,7 +970,7 @@ func TestTransitionTier(t *testing.T) {
t.Fatalf("Expected TIER-1 but got %s", evt.StorageClass)
}
evt = lc.eval(obj2, now)
evt = evts[1]
if evt.Action != TransitionVersionAction {
t.Fatalf("Expected action: %s but got %s", TransitionVersionAction, evt.Action)
}
@@ -1036,14 +1038,16 @@ func TestTransitionTierWithPrefixAndTags(t *testing.T) {
// Go back seven days in the past
now = now.Add(7 * 24 * time.Hour)
evaluator := NewEvaluator(lc)
evts := evaluator.eval([]ObjectOpts{obj1, obj2, obj3}, now)
// Eval object 1
evt := lc.eval(obj1, now)
evt := evts[0]
if evt.Action != NoneAction {
t.Fatalf("Expected action: %s but got %s", NoneAction, evt.Action)
}
// Eval object 2
evt = lc.eval(obj2, now)
evt = evts[1]
if evt.Action != TransitionAction {
t.Fatalf("Expected action: %s but got %s", TransitionAction, evt.Action)
}
@@ -1052,7 +1056,7 @@ func TestTransitionTierWithPrefixAndTags(t *testing.T) {
}
// Eval object 3
evt = lc.eval(obj3, now)
evt = evts[2]
if evt.Action != TransitionAction {
t.Fatalf("Expected action: %s but got %s", TransitionAction, evt.Action)
}
@@ -1466,7 +1470,9 @@ func TestDeleteAllVersions(t *testing.T) {
NumVersions: 4,
}
event := lc.eval(opts, time.Time{})
evaluator := NewEvaluator(lc)
events := evaluator.eval([]ObjectOpts{opts}, time.Time{})
event := events[0]
if event.Action != TransitionAction {
t.Fatalf("Expected %v action but got %v", TransitionAction, event.Action)
}
@@ -1503,7 +1509,9 @@ func TestDeleteAllVersions(t *testing.T) {
DeleteMarker: true,
NumVersions: 4,
}
event = lc.eval(opts, time.Time{})
evaluator = NewEvaluator(lc)
events = evaluator.eval([]ObjectOpts{opts}, time.Time{})
event = events[0]
if event.Action != DelMarkerDeleteAllVersionsAction {
t.Fatalf("Expected %v action but got %v", DelMarkerDeleteAllVersionsAction, event.Action)
}

View File

@@ -51,3 +51,27 @@ func (s StatusType) String() string {
func (s StatusType) Empty() bool {
return string(s) == ""
}
// VersionPurgeStatusType represents status of a versioned delete or permanent delete w.r.t bucket replication
type VersionPurgeStatusType string
const (
// VersionPurgePending - versioned delete replication is pending.
VersionPurgePending VersionPurgeStatusType = "PENDING"
// VersionPurgeComplete - versioned delete replication is now complete, erase version on disk.
VersionPurgeComplete VersionPurgeStatusType = "COMPLETE"
// VersionPurgeFailed - versioned delete replication failed.
VersionPurgeFailed VersionPurgeStatusType = "FAILED"
)
// Empty returns true if purge status was not set.
func (v VersionPurgeStatusType) Empty() bool {
return string(v) == ""
}
// Pending returns true if the version is pending purge.
func (v VersionPurgeStatusType) Pending() bool {
return v == VersionPurgePending || v == VersionPurgeFailed
}

View File

@@ -109,3 +109,55 @@ func (z Type) Msgsize() (s int) {
s = msgp.IntSize
return
}
// DecodeMsg implements msgp.Decodable
func (z *VersionPurgeStatusType) DecodeMsg(dc *msgp.Reader) (err error) {
{
var zb0001 string
zb0001, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err)
return
}
(*z) = VersionPurgeStatusType(zb0001)
}
return
}
// EncodeMsg implements msgp.Encodable
func (z VersionPurgeStatusType) EncodeMsg(en *msgp.Writer) (err error) {
err = en.WriteString(string(z))
if err != nil {
err = msgp.WrapError(err)
return
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z VersionPurgeStatusType) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
o = msgp.AppendString(o, string(z))
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *VersionPurgeStatusType) UnmarshalMsg(bts []byte) (o []byte, err error) {
{
var zb0001 string
zb0001, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
(*z) = VersionPurgeStatusType(zb0001)
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z VersionPurgeStatusType) Msgsize() (s int) {
s = msgp.StringPrefixSize + len(string(z))
return
}