github.com/minio/minio@v0.0.0-20240328213742-3f72439b8a27/cmd/batch-expire.go (about)

     1  // Copyright (c) 2015-2023 MinIO, Inc.
     2  //
     3  // This file is part of MinIO Object Storage stack
     4  //
     5  // This program is free software: you can redistribute it and/or modify
     6  // it under the terms of the GNU Affero General Public License as published by
     7  // the Free Software Foundation, either version 3 of the License, or
     8  // (at your option) any later version.
     9  //
    10  // This program is distributed in the hope that it will be useful
    11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
    12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13  // GNU Affero General Public License for more details.
    14  //
    15  // You should have received a copy of the GNU Affero General Public License
    16  // along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17  
    18  package cmd
    19  
    20  import (
    21  	"bytes"
    22  	"context"
    23  	"encoding/json"
    24  	"errors"
    25  	"fmt"
    26  	"io"
    27  	"net/http"
    28  	"runtime"
    29  	"strconv"
    30  	"time"
    31  
    32  	"github.com/minio/minio-go/v7/pkg/tags"
    33  	"github.com/minio/minio/internal/bucket/versioning"
    34  	xhttp "github.com/minio/minio/internal/http"
    35  	xioutil "github.com/minio/minio/internal/ioutil"
    36  	"github.com/minio/minio/internal/logger"
    37  	"github.com/minio/pkg/v2/env"
    38  	"github.com/minio/pkg/v2/wildcard"
    39  	"github.com/minio/pkg/v2/workers"
    40  	"gopkg.in/yaml.v3"
    41  )
    42  
    43  // expire: # Expire objects that match a condition
    44  //   apiVersion: v1
    45  //   bucket: mybucket # Bucket where this batch job will expire matching objects from
    46  //   prefix: myprefix # (Optional) Prefix under which this job will expire objects matching the rules below.
    47  //   rules:
    48  //     - type: object  # regular objects with zero or more older versions
    49  //       name: NAME # match object names that satisfy the wildcard expression.
    50  //       olderThan: 70h # match objects older than this value
    51  //       createdBefore: "2006-01-02T15:04:05.00Z" # match objects created before "date"
    52  //       tags:
    53  //         - key: name
    54  //           value: pick* # match objects with tag 'name', all values starting with 'pick'
    55  //       metadata:
    56  //         - key: content-type
    57  //           value: image/* # match objects with 'content-type', all values starting with 'image/'
    58  //       size:
    59  //         lessThan: "10MiB" # match objects with size less than this value (e.g. 10MiB)
    60  //         greaterThan: 1MiB # match objects with size greater than this value (e.g. 1MiB)
    61  //       purge:
    62  //           # retainVersions: 0 # (default) delete all versions of the object. This option is the fastest.
    63  //           # retainVersions: 5 # keep the latest 5 versions of the object.
    64  //
    65  //     - type: deleted # objects with delete marker as their latest version
    66  //       name: NAME # match object names that satisfy the wildcard expression.
    67  //       olderThan: 10h # match objects older than this value (e.g. 7d10h31s)
    68  //       createdBefore: "2006-01-02T15:04:05.00Z" # match objects created before "date"
    69  //       purge:
    70  //           # retainVersions: 0 # (default) delete all versions of the object. This option is the fastest.
    71  //           # retainVersions: 5 # keep the latest 5 versions of the object including delete markers.
    72  //
    73  //   notify:
    74  //     endpoint: https://notify.endpoint # notification endpoint to receive job completion status
    75  //     token: Bearer xxxxx # optional authentication token for the notification endpoint
    76  //
    77  //   retry:
    78  //     attempts: 10 # number of retries for the job before giving up
    79  //     delay: 500ms # least amount of delay between each retry
    80  
    81  //go:generate msgp -file $GOFILE
    82  
    83  // BatchJobExpirePurge type accepts non-negative versions to be retained
    84  type BatchJobExpirePurge struct {
    85  	line, col      int
    86  	RetainVersions int `yaml:"retainVersions" json:"retainVersions"`
    87  }
    88  
    89  var _ yaml.Unmarshaler = &BatchJobExpirePurge{}
    90  
    91  // UnmarshalYAML - BatchJobExpirePurge extends unmarshal to extract line, col
    92  func (p *BatchJobExpirePurge) UnmarshalYAML(val *yaml.Node) error {
    93  	type purge BatchJobExpirePurge
    94  	var tmp purge
    95  	err := val.Decode(&tmp)
    96  	if err != nil {
    97  		return err
    98  	}
    99  
   100  	*p = BatchJobExpirePurge(tmp)
   101  	p.line, p.col = val.Line, val.Column
   102  	return nil
   103  }
   104  
   105  // Validate returns nil if value is valid, ie > 0.
   106  func (p BatchJobExpirePurge) Validate() error {
   107  	if p.RetainVersions < 0 {
   108  		return BatchJobYamlErr{
   109  			line: p.line,
   110  			col:  p.col,
   111  			msg:  "retainVersions must be >= 0",
   112  		}
   113  	}
   114  	return nil
   115  }
   116  
   117  // BatchJobExpireFilter holds all the filters currently supported for batch replication
   118  type BatchJobExpireFilter struct {
   119  	line, col     int
   120  	OlderThan     time.Duration       `yaml:"olderThan,omitempty" json:"olderThan"`
   121  	CreatedBefore *time.Time          `yaml:"createdBefore,omitempty" json:"createdBefore"`
   122  	Tags          []BatchJobKV        `yaml:"tags,omitempty" json:"tags"`
   123  	Metadata      []BatchJobKV        `yaml:"metadata,omitempty" json:"metadata"`
   124  	Size          BatchJobSizeFilter  `yaml:"size" json:"size"`
   125  	Type          string              `yaml:"type" json:"type"`
   126  	Name          string              `yaml:"name" json:"name"`
   127  	Purge         BatchJobExpirePurge `yaml:"purge" json:"purge"`
   128  }
   129  
   130  var _ yaml.Unmarshaler = &BatchJobExpireFilter{}
   131  
   132  // UnmarshalYAML - BatchJobExpireFilter extends unmarshal to extract line, col
   133  // information
   134  func (ef *BatchJobExpireFilter) UnmarshalYAML(value *yaml.Node) error {
   135  	type expFilter BatchJobExpireFilter
   136  	var tmp expFilter
   137  	err := value.Decode(&tmp)
   138  	if err != nil {
   139  		return err
   140  	}
   141  	*ef = BatchJobExpireFilter(tmp)
   142  	ef.line, ef.col = value.Line, value.Column
   143  	return err
   144  }
   145  
   146  // Matches returns true if obj matches the filter conditions specified in ef.
   147  func (ef BatchJobExpireFilter) Matches(obj ObjectInfo, now time.Time) bool {
   148  	switch ef.Type {
   149  	case BatchJobExpireObject:
   150  		if obj.DeleteMarker {
   151  			return false
   152  		}
   153  	case BatchJobExpireDeleted:
   154  		if !obj.DeleteMarker {
   155  			return false
   156  		}
   157  	default:
   158  		// we should never come here, Validate should have caught this.
   159  		logger.LogOnceIf(context.Background(), fmt.Errorf("invalid filter type: %s", ef.Type), ef.Type)
   160  		return false
   161  	}
   162  
   163  	if len(ef.Name) > 0 && !wildcard.Match(ef.Name, obj.Name) {
   164  		return false
   165  	}
   166  	if ef.OlderThan > 0 && now.Sub(obj.ModTime) <= ef.OlderThan {
   167  		return false
   168  	}
   169  
   170  	if ef.CreatedBefore != nil && !obj.ModTime.Before(*ef.CreatedBefore) {
   171  		return false
   172  	}
   173  
   174  	if len(ef.Tags) > 0 && !obj.DeleteMarker {
   175  		// Only parse object tags if tags filter is specified.
   176  		var tagMap map[string]string
   177  		if len(obj.UserTags) != 0 {
   178  			t, err := tags.ParseObjectTags(obj.UserTags)
   179  			if err != nil {
   180  				return false
   181  			}
   182  			tagMap = t.ToMap()
   183  		}
   184  
   185  		for _, kv := range ef.Tags {
   186  			// Object (version) must match all tags specified in
   187  			// the filter
   188  			var match bool
   189  			for t, v := range tagMap {
   190  				if kv.Match(BatchJobKV{Key: t, Value: v}) {
   191  					match = true
   192  				}
   193  			}
   194  			if !match {
   195  				return false
   196  			}
   197  		}
   198  
   199  	}
   200  	if len(ef.Metadata) > 0 && !obj.DeleteMarker {
   201  		for _, kv := range ef.Metadata {
   202  			// Object (version) must match all x-amz-meta and
   203  			// standard metadata headers
   204  			// specified in the filter
   205  			var match bool
   206  			for k, v := range obj.UserDefined {
   207  				if !stringsHasPrefixFold(k, "x-amz-meta-") && !isStandardHeader(k) {
   208  					continue
   209  				}
   210  				// We only need to match x-amz-meta or standardHeaders
   211  				if kv.Match(BatchJobKV{Key: k, Value: v}) {
   212  					match = true
   213  				}
   214  			}
   215  			if !match {
   216  				return false
   217  			}
   218  		}
   219  	}
   220  
   221  	return ef.Size.InRange(obj.Size)
   222  }
   223  
   224  const (
   225  	// BatchJobExpireObject - object type
   226  	BatchJobExpireObject string = "object"
   227  	// BatchJobExpireDeleted - delete marker type
   228  	BatchJobExpireDeleted string = "deleted"
   229  )
   230  
   231  // Validate returns nil if ef has valid fields, validation error otherwise.
   232  func (ef BatchJobExpireFilter) Validate() error {
   233  	switch ef.Type {
   234  	case BatchJobExpireObject:
   235  	case BatchJobExpireDeleted:
   236  		if len(ef.Tags) > 0 || len(ef.Metadata) > 0 {
   237  			return BatchJobYamlErr{
   238  				line: ef.line,
   239  				col:  ef.col,
   240  				msg:  "delete type filter can't have tags or metadata",
   241  			}
   242  		}
   243  	default:
   244  		return BatchJobYamlErr{
   245  			line: ef.line,
   246  			col:  ef.col,
   247  			msg:  "invalid batch-expire type",
   248  		}
   249  	}
   250  
   251  	for _, tag := range ef.Tags {
   252  		if err := tag.Validate(); err != nil {
   253  			return err
   254  		}
   255  	}
   256  
   257  	for _, meta := range ef.Metadata {
   258  		if err := meta.Validate(); err != nil {
   259  			return err
   260  		}
   261  	}
   262  	if err := ef.Purge.Validate(); err != nil {
   263  		return err
   264  	}
   265  	if err := ef.Size.Validate(); err != nil {
   266  		return err
   267  	}
   268  	if ef.CreatedBefore != nil && !ef.CreatedBefore.Before(time.Now()) {
   269  		return BatchJobYamlErr{
   270  			line: ef.line,
   271  			col:  ef.col,
   272  			msg:  "CreatedBefore is in the future",
   273  		}
   274  	}
   275  	return nil
   276  }
   277  
   278  // BatchJobExpire represents configuration parameters for a batch expiration
   279  // job typically supplied in yaml form
   280  type BatchJobExpire struct {
   281  	line, col       int
   282  	APIVersion      string                 `yaml:"apiVersion" json:"apiVersion"`
   283  	Bucket          string                 `yaml:"bucket" json:"bucket"`
   284  	Prefix          string                 `yaml:"prefix" json:"prefix"`
   285  	NotificationCfg BatchJobNotification   `yaml:"notify" json:"notify"`
   286  	Retry           BatchJobRetry          `yaml:"retry" json:"retry"`
   287  	Rules           []BatchJobExpireFilter `yaml:"rules" json:"rules"`
   288  }
   289  
   290  var _ yaml.Unmarshaler = &BatchJobExpire{}
   291  
   292  // UnmarshalYAML - BatchJobExpire extends default unmarshal to extract line, col information.
   293  func (r *BatchJobExpire) UnmarshalYAML(val *yaml.Node) error {
   294  	type expireJob BatchJobExpire
   295  	var tmp expireJob
   296  	err := val.Decode(&tmp)
   297  	if err != nil {
   298  		return err
   299  	}
   300  
   301  	*r = BatchJobExpire(tmp)
   302  	r.line, r.col = val.Line, val.Column
   303  	return nil
   304  }
   305  
   306  // Notify notifies notification endpoint if configured regarding job failure or success.
   307  func (r BatchJobExpire) Notify(ctx context.Context, body io.Reader) error {
   308  	if r.NotificationCfg.Endpoint == "" {
   309  		return nil
   310  	}
   311  
   312  	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
   313  	defer cancel()
   314  
   315  	req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.NotificationCfg.Endpoint, body)
   316  	if err != nil {
   317  		return err
   318  	}
   319  
   320  	if r.NotificationCfg.Token != "" {
   321  		req.Header.Set("Authorization", r.NotificationCfg.Token)
   322  	}
   323  
   324  	clnt := http.Client{Transport: getRemoteInstanceTransport}
   325  	resp, err := clnt.Do(req)
   326  	if err != nil {
   327  		return err
   328  	}
   329  
   330  	xhttp.DrainBody(resp.Body)
   331  	if resp.StatusCode != http.StatusOK {
   332  		return errors.New(resp.Status)
   333  	}
   334  
   335  	return nil
   336  }
   337  
   338  // Expire expires object versions which have already matched supplied filter conditions
   339  func (r *BatchJobExpire) Expire(ctx context.Context, api ObjectLayer, vc *versioning.Versioning, objsToDel []ObjectToDelete) []error {
   340  	opts := ObjectOptions{
   341  		PrefixEnabledFn:  vc.PrefixEnabled,
   342  		VersionSuspended: vc.Suspended(),
   343  	}
   344  	_, errs := api.DeleteObjects(ctx, r.Bucket, objsToDel, opts)
   345  	return errs
   346  }
   347  
   348  const (
   349  	batchExpireName                 = "batch-expire.bin"
   350  	batchExpireFormat               = 1
   351  	batchExpireVersionV1            = 1
   352  	batchExpireVersion              = batchExpireVersionV1
   353  	batchExpireAPIVersion           = "v1"
   354  	batchExpireJobDefaultRetries    = 3
   355  	batchExpireJobDefaultRetryDelay = 250 * time.Millisecond
   356  )
   357  
   358  type objInfoCache map[string]*ObjectInfo
   359  
   360  func newObjInfoCache() objInfoCache {
   361  	return objInfoCache(make(map[string]*ObjectInfo))
   362  }
   363  
   364  func (oiCache objInfoCache) Add(toDel ObjectToDelete, oi *ObjectInfo) {
   365  	oiCache[fmt.Sprintf("%s-%s", toDel.ObjectName, toDel.VersionID)] = oi
   366  }
   367  
   368  func (oiCache objInfoCache) Get(toDel ObjectToDelete) (*ObjectInfo, bool) {
   369  	oi, ok := oiCache[fmt.Sprintf("%s-%s", toDel.ObjectName, toDel.VersionID)]
   370  	return oi, ok
   371  }
   372  
   373  func batchObjsForDelete(ctx context.Context, r *BatchJobExpire, ri *batchJobInfo, job BatchJobRequest, api ObjectLayer, wk *workers.Workers, expireCh <-chan []expireObjInfo) {
   374  	vc, _ := globalBucketVersioningSys.Get(r.Bucket)
   375  	retryAttempts := r.Retry.Attempts
   376  	delay := job.Expire.Retry.Delay
   377  	if delay == 0 {
   378  		delay = batchExpireJobDefaultRetryDelay
   379  	}
   380  
   381  	var i int
   382  	for toExpire := range expireCh {
   383  		select {
   384  		case <-ctx.Done():
   385  			return
   386  		default:
   387  		}
   388  		if i > 0 {
   389  			if wait := globalBatchConfig.ExpirationWait(); wait > 0 {
   390  				time.Sleep(wait)
   391  			}
   392  		}
   393  		i++
   394  		wk.Take()
   395  		go func(toExpire []expireObjInfo) {
   396  			defer wk.Give()
   397  
   398  			toExpireAll := make([]ObjectInfo, 0, len(toExpire))
   399  			toDel := make([]ObjectToDelete, 0, len(toExpire))
   400  			oiCache := newObjInfoCache()
   401  			for _, exp := range toExpire {
   402  				if exp.ExpireAll {
   403  					toExpireAll = append(toExpireAll, exp.ObjectInfo)
   404  					continue
   405  				}
   406  				// Cache ObjectInfo value via pointers for
   407  				// subsequent use to track objects which
   408  				// couldn't be deleted.
   409  				od := ObjectToDelete{
   410  					ObjectV: ObjectV{
   411  						ObjectName: exp.Name,
   412  						VersionID:  exp.VersionID,
   413  					},
   414  				}
   415  				toDel = append(toDel, od)
   416  				oiCache.Add(od, &exp.ObjectInfo)
   417  			}
   418  
   419  			var done bool
   420  			// DeleteObject(deletePrefix: true) to expire all versions of an object
   421  			for _, exp := range toExpireAll {
   422  				var success bool
   423  				for attempts := 1; attempts <= retryAttempts; attempts++ {
   424  					select {
   425  					case <-ctx.Done():
   426  						done = true
   427  					default:
   428  					}
   429  					stopFn := globalBatchJobsMetrics.trace(batchJobMetricExpire, ri.JobID, attempts)
   430  					_, err := api.DeleteObject(ctx, exp.Bucket, encodeDirObject(exp.Name), ObjectOptions{
   431  						DeletePrefix:       true,
   432  						DeletePrefixObject: true, // use prefix delete on exact object (this is an optimization to avoid fan-out calls)
   433  					})
   434  					if err != nil {
   435  						stopFn(exp, err)
   436  						logger.LogIf(ctx, fmt.Errorf("Failed to expire %s/%s versionID=%s due to %v (attempts=%d)", toExpire[i].Bucket, toExpire[i].Name, toExpire[i].VersionID, err, attempts))
   437  					} else {
   438  						stopFn(exp, err)
   439  						success = true
   440  						break
   441  					}
   442  				}
   443  				ri.trackMultipleObjectVersions(r.Bucket, exp, success)
   444  				if done {
   445  					break
   446  				}
   447  			}
   448  
   449  			if done {
   450  				return
   451  			}
   452  
   453  			// DeleteMultiple objects
   454  			toDelCopy := make([]ObjectToDelete, len(toDel))
   455  			for attempts := 1; attempts <= retryAttempts; attempts++ {
   456  				select {
   457  				case <-ctx.Done():
   458  					return
   459  				default:
   460  				}
   461  
   462  				stopFn := globalBatchJobsMetrics.trace(batchJobMetricExpire, ri.JobID, attempts)
   463  				// Copying toDel to select from objects whose
   464  				// deletion failed
   465  				copy(toDelCopy, toDel)
   466  				var failed int
   467  				errs := r.Expire(ctx, api, vc, toDel)
   468  				// reslice toDel in preparation for next retry
   469  				// attempt
   470  				toDel = toDel[:0]
   471  				for i, err := range errs {
   472  					if err != nil {
   473  						stopFn(toDelCopy[i], err)
   474  						logger.LogIf(ctx, fmt.Errorf("Failed to expire %s/%s versionID=%s due to %v (attempts=%d)", ri.Bucket, toDelCopy[i].ObjectName, toDelCopy[i].VersionID, err, attempts))
   475  						failed++
   476  						if attempts == retryAttempts { // all retry attempts failed, record failure
   477  							if oi, ok := oiCache.Get(toDelCopy[i]); ok {
   478  								ri.trackCurrentBucketObject(r.Bucket, *oi, false)
   479  							}
   480  						} else {
   481  							toDel = append(toDel, toDelCopy[i])
   482  						}
   483  					} else {
   484  						stopFn(toDelCopy[i], nil)
   485  						if oi, ok := oiCache.Get(toDelCopy[i]); ok {
   486  							ri.trackCurrentBucketObject(r.Bucket, *oi, true)
   487  						}
   488  					}
   489  				}
   490  
   491  				globalBatchJobsMetrics.save(ri.JobID, ri)
   492  
   493  				if failed == 0 {
   494  					break
   495  				}
   496  
   497  				// Add a delay between retry attempts
   498  				if attempts < retryAttempts {
   499  					time.Sleep(delay)
   500  				}
   501  			}
   502  		}(toExpire)
   503  	}
   504  }
   505  
   506  type expireObjInfo struct {
   507  	ObjectInfo
   508  	ExpireAll bool
   509  }
   510  
   511  // Start the batch expiration job, resumes if there was a pending job via "job.ID"
   512  func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJobRequest) error {
   513  	ri := &batchJobInfo{
   514  		JobID:     job.ID,
   515  		JobType:   string(job.Type()),
   516  		StartTime: job.Started,
   517  	}
   518  	if err := ri.load(ctx, api, job); err != nil {
   519  		return err
   520  	}
   521  
   522  	globalBatchJobsMetrics.save(job.ID, ri)
   523  	lastObject := ri.Object
   524  
   525  	now := time.Now().UTC()
   526  
   527  	workerSize, err := strconv.Atoi(env.Get("_MINIO_BATCH_EXPIRATION_WORKERS", strconv.Itoa(runtime.GOMAXPROCS(0)/2)))
   528  	if err != nil {
   529  		return err
   530  	}
   531  
   532  	wk, err := workers.New(workerSize)
   533  	if err != nil {
   534  		// invalid worker size.
   535  		return err
   536  	}
   537  
   538  	ctx, cancel := context.WithCancel(ctx)
   539  	defer cancel()
   540  
   541  	results := make(chan ObjectInfo, workerSize)
   542  	if err := api.Walk(ctx, r.Bucket, r.Prefix, results, WalkOptions{
   543  		Marker:       lastObject,
   544  		LatestOnly:   false, // we need to visit all versions of the object to implement purge: retainVersions
   545  		VersionsSort: WalkVersionsSortDesc,
   546  	}); err != nil {
   547  		// Do not need to retry if we can't list objects on source.
   548  		return err
   549  	}
   550  
   551  	// Goroutine to periodically save batch-expire job's in-memory state
   552  	saverQuitCh := make(chan struct{})
   553  	go func() {
   554  		saveTicker := time.NewTicker(10 * time.Second)
   555  		defer saveTicker.Stop()
   556  		for {
   557  			select {
   558  			case <-saveTicker.C:
   559  				// persist in-memory state to disk after every 10secs.
   560  				logger.LogIf(ctx, ri.updateAfter(ctx, api, 10*time.Second, job))
   561  
   562  			case <-ctx.Done():
   563  				// persist in-memory state immediately before exiting due to context cancellation.
   564  				logger.LogIf(ctx, ri.updateAfter(ctx, api, 0, job))
   565  				return
   566  
   567  			case <-saverQuitCh:
   568  				// persist in-memory state immediately to disk.
   569  				logger.LogIf(ctx, ri.updateAfter(ctx, api, 0, job))
   570  				return
   571  			}
   572  		}
   573  	}()
   574  
   575  	expireCh := make(chan []expireObjInfo, workerSize)
   576  	expireDoneCh := make(chan struct{})
   577  	go func() {
   578  		defer close(expireDoneCh)
   579  		batchObjsForDelete(ctx, r, ri, job, api, wk, expireCh)
   580  	}()
   581  
   582  	var (
   583  		prevObj       ObjectInfo
   584  		matchedFilter BatchJobExpireFilter
   585  		versionsCount int
   586  		toDel         []expireObjInfo
   587  	)
   588  	for result := range results {
   589  		// Apply filter to find the matching rule to apply expiry
   590  		// actions accordingly.
   591  		// nolint:gocritic
   592  		if result.IsLatest {
   593  			// send down filtered entries to be deleted using
   594  			// DeleteObjects method
   595  			if len(toDel) > 10 { // batch up to 10 objects/versions to be expired simultaneously.
   596  				xfer := make([]expireObjInfo, len(toDel))
   597  				copy(xfer, toDel)
   598  
   599  				var done bool
   600  				select {
   601  				case <-ctx.Done():
   602  					done = true
   603  				case expireCh <- xfer:
   604  					toDel = toDel[:0] // resetting toDel
   605  				}
   606  				if done {
   607  					break
   608  				}
   609  			}
   610  			var match BatchJobExpireFilter
   611  			var found bool
   612  			for _, rule := range r.Rules {
   613  				if rule.Matches(result, now) {
   614  					match = rule
   615  					found = true
   616  					break
   617  				}
   618  			}
   619  			if !found {
   620  				continue
   621  			}
   622  
   623  			prevObj = result
   624  			matchedFilter = match
   625  			versionsCount = 1
   626  			// Include the latest version
   627  			if matchedFilter.Purge.RetainVersions == 0 {
   628  				toDel = append(toDel, expireObjInfo{
   629  					ObjectInfo: result,
   630  					ExpireAll:  true,
   631  				})
   632  				continue
   633  			}
   634  		} else if prevObj.Name == result.Name {
   635  			if matchedFilter.Purge.RetainVersions == 0 {
   636  				continue // including latest version in toDel suffices, skipping other versions
   637  			}
   638  			versionsCount++
   639  		} else {
   640  			continue
   641  		}
   642  
   643  		if versionsCount <= matchedFilter.Purge.RetainVersions {
   644  			continue // retain versions
   645  		}
   646  		toDel = append(toDel, expireObjInfo{
   647  			ObjectInfo: result,
   648  		})
   649  	}
   650  	// Send any remaining objects downstream
   651  	if len(toDel) > 0 {
   652  		select {
   653  		case <-ctx.Done():
   654  		case expireCh <- toDel:
   655  		}
   656  	}
   657  	xioutil.SafeClose(expireCh)
   658  
   659  	<-expireDoneCh // waits for the expire goroutine to complete
   660  	wk.Wait()      // waits for all expire workers to retire
   661  
   662  	ri.Complete = ri.ObjectsFailed == 0
   663  	ri.Failed = ri.ObjectsFailed > 0
   664  	globalBatchJobsMetrics.save(job.ID, ri)
   665  
   666  	// Close the saverQuitCh - this also triggers saving in-memory state
   667  	// immediately one last time before we exit this method.
   668  	xioutil.SafeClose(saverQuitCh)
   669  
   670  	// Notify expire jobs final status to the configured endpoint
   671  	buf, _ := json.Marshal(ri)
   672  	if err := r.Notify(context.Background(), bytes.NewReader(buf)); err != nil {
   673  		logger.LogIf(context.Background(), fmt.Errorf("unable to notify %v", err))
   674  	}
   675  
   676  	return nil
   677  }
   678  
   679  //msgp:ignore batchExpireJobError
   680  type batchExpireJobError struct {
   681  	Code           string
   682  	Description    string
   683  	HTTPStatusCode int
   684  }
   685  
   686  func (e batchExpireJobError) Error() string {
   687  	return e.Description
   688  }
   689  
   690  // maxBatchRules maximum number of rules a batch-expiry job supports
   691  const maxBatchRules = 50
   692  
   693  // Validate validates the job definition input
   694  func (r *BatchJobExpire) Validate(ctx context.Context, job BatchJobRequest, o ObjectLayer) error {
   695  	if r == nil {
   696  		return nil
   697  	}
   698  
   699  	if r.APIVersion != batchExpireAPIVersion {
   700  		return batchExpireJobError{
   701  			Code:           "InvalidArgument",
   702  			Description:    "Unsupported batch expire API version",
   703  			HTTPStatusCode: http.StatusBadRequest,
   704  		}
   705  	}
   706  
   707  	if r.Bucket == "" {
   708  		return batchExpireJobError{
   709  			Code:           "InvalidArgument",
   710  			Description:    "Bucket argument missing",
   711  			HTTPStatusCode: http.StatusBadRequest,
   712  		}
   713  	}
   714  
   715  	if _, err := o.GetBucketInfo(ctx, r.Bucket, BucketOptions{}); err != nil {
   716  		if isErrBucketNotFound(err) {
   717  			return batchExpireJobError{
   718  				Code:           "NoSuchSourceBucket",
   719  				Description:    "The specified source bucket does not exist",
   720  				HTTPStatusCode: http.StatusNotFound,
   721  			}
   722  		}
   723  		return err
   724  	}
   725  
   726  	if len(r.Rules) > maxBatchRules {
   727  		return batchExpireJobError{
   728  			Code:           "InvalidArgument",
   729  			Description:    "Too many rules. Batch expire job can't have more than 100 rules",
   730  			HTTPStatusCode: http.StatusBadRequest,
   731  		}
   732  	}
   733  
   734  	for _, rule := range r.Rules {
   735  		if err := rule.Validate(); err != nil {
   736  			return batchExpireJobError{
   737  				Code:           "InvalidArgument",
   738  				Description:    fmt.Sprintf("Invalid batch expire rule: %s", err),
   739  				HTTPStatusCode: http.StatusBadRequest,
   740  			}
   741  		}
   742  	}
   743  
   744  	if err := r.Retry.Validate(); err != nil {
   745  		return batchExpireJobError{
   746  			Code:           "InvalidArgument",
   747  			Description:    fmt.Sprintf("Invalid batch expire retry configuration: %s", err),
   748  			HTTPStatusCode: http.StatusBadRequest,
   749  		}
   750  	}
   751  	return nil
   752  }