From 7a060cab1edd5bbc17da7f703bbd1ab7415b6f7c Mon Sep 17 00:00:00 2001 From: mr javad seydi Date: Sat, 15 Aug 2026 13:39:02 +0330 Subject: [PATCH] feat(ilm): relocate hot objects across server pools by GET frequency Keep NVMe/HDD pool pairs useful without remote tiering: promote objects that are read often, demote previously moved objects once they go idle, and leave the feature off until operators set a two-pool topology. Signed-off-by: mr javad seydi --- cmd/bucket-lifecycle-audit.go | 1 + cmd/config-current.go | 5 +- cmd/data-scanner.go | 14 + cmd/data-usage-cache.go | 65 +- cmd/data-usage-cache_gen.go | 614 ++++++++- cmd/data-usage-utils.go | 7 +- cmd/erasure-server-pool.go | 115 +- cmd/ilm-access-tier.go | 1123 +++++++++++++++++ cmd/ilm-access-tier_test.go | 263 ++++ cmd/ilm-access-tracker.go | 598 +++++++++ cmd/ilm-access-tracker_gen.go | 742 +++++++++++ cmd/ilm-access-tracker_gen_test.go | 349 +++++ cmd/ilm-access-tracker_test.go | 95 ++ cmd/ilm-config.go | 30 + cmd/lceventsrc_string.go | 5 +- cmd/metrics-v3-ilm.go | 37 + cmd/metrics-v3.go | 11 + cmd/object-api-interface.go | 4 + cmd/object-handlers.go | 2 + cmd/server-main.go | 5 + cmd/xl-storage.go | 5 + docs/bucket/lifecycle/README.md | 122 ++ .../lifecycle/setup_ilm_access_tiering.sh | 37 + docs/metrics/prometheus/list.md | 11 + .../bucket/lifecycle/access-transition.go | 129 ++ .../lifecycle/access-transition_test.go | 219 ++++ internal/bucket/lifecycle/lifecycle.go | 63 +- internal/bucket/lifecycle/rule.go | 6 +- internal/config/ilm/help.go | 93 ++ internal/config/ilm/ilm.go | 203 +++ internal/config/ilm/ilm_test.go | 109 ++ 31 files changed, 5041 insertions(+), 41 deletions(-) create mode 100644 cmd/ilm-access-tier.go create mode 100644 cmd/ilm-access-tier_test.go create mode 100644 cmd/ilm-access-tracker.go create mode 100644 cmd/ilm-access-tracker_gen.go create mode 100644 cmd/ilm-access-tracker_gen_test.go create mode 100644 cmd/ilm-access-tracker_test.go create mode 100755 docs/bucket/lifecycle/setup_ilm_access_tiering.sh create mode 100644 internal/bucket/lifecycle/access-transition.go create mode 100644 internal/bucket/lifecycle/access-transition_test.go create mode 100644 internal/config/ilm/ilm_test.go diff --git a/cmd/bucket-lifecycle-audit.go b/cmd/bucket-lifecycle-audit.go index 1fa76e090aeb2..5f8c6fafec0e9 100644 --- a/cmd/bucket-lifecycle-audit.go +++ b/cmd/bucket-lifecycle-audit.go @@ -39,6 +39,7 @@ const ( lcEventSrc_s3PutObject lcEventSrc_s3CopyObject lcEventSrc_s3CompleteMultipartUpload + lcEventSrc_AccessTier ) //revive:enable:var-naming diff --git a/cmd/config-current.go b/cmd/config-current.go index 9e17c0d78c9df..80abfb84be6db 100644 --- a/cmd/config-current.go +++ b/cmd/config-current.go @@ -227,7 +227,7 @@ func initHelp() { }, config.HelpKV{ Key: config.ILMSubSys, - Description: "manage ILM settings for expiration and transition workers", + Description: "manage ILM settings for expiration, transition, and access-tier workers", Optional: true, }, } @@ -704,6 +704,9 @@ func applyDynamicConfigForSubSys(ctx context.Context, objAPI ObjectLayer, s conf if globalExpiryState != nil { globalExpiryState.ResizeWorkers(ilmCfg.ExpirationWorkers) } + if globalAccessTierState != nil { + globalAccessTierState.UpdateWorkers(ilmCfg.AccessWorkers) + } globalILMConfig.update(ilmCfg) } } diff --git a/cmd/data-scanner.go b/cmd/data-scanner.go index 288f4d7162c4a..e2182e6c46023 100644 --- a/cmd/data-scanner.go +++ b/cmd/data-scanner.go @@ -899,6 +899,7 @@ type scannerItem struct { objectName string // Only the object name without prefixes. replication replicationConfig lifeCycle *lifecycle.Lifecycle + poolIdx int Typ fs.FileMode heal struct { enabled bool @@ -909,6 +910,7 @@ type scannerItem struct { type sizeSummary struct { totalSize int64 + hotTierSize int64 versions uint64 deleteMarkers uint64 replicatedSize int64 @@ -1159,6 +1161,14 @@ eventLoop: globalExpiryState.enqueueNoncurrentVersions(i.bucket, toDel, noncurrentEvents) } i.alertExcessiveVersions(remainingVersions, cumulativeSize) + if globalILMConfig.accessTieringEnabled() { + for idx, oi := range objInfos { + if oi.IsLatest && events[idx].Action == lifecycle.NoneAction { + applyAccessTransition(ctx, i, oi) + break + } + } + } } func evalActionFromLifecycle(ctx context.Context, lc lifecycle.Lifecycle, lr lock.Retention, rcfg *replication.Config, obj ObjectInfo) lifecycle.Event { @@ -1474,6 +1484,8 @@ const ( ILMFreeVersionDelete = "ilm:free-version-delete" // ILMTransition - audit trail for ILM transitioning. ILMTransition = " ilm:transition" + // ILMAccessTier - audit trail for moving objects between server pools. + ILMAccessTier = "ilm:access-tier" ) func auditLogLifecycle(ctx context.Context, oi ObjectInfo, event string, tags map[string]string, traceFn func(event string, metadata map[string]string, err error)) { @@ -1485,6 +1497,8 @@ func auditLogLifecycle(ctx context.Context, oi ObjectInfo, event string, tags ma apiName = "ILMFreeVersionDelete" case ILMTransition: apiName = "ILMTransition" + case ILMAccessTier: + apiName = "ILMAccessTier" } auditLogInternal(ctx, AuditLogOptions{ Event: event, diff --git a/cmd/data-usage-cache.go b/cmd/data-usage-cache.go index 5752daae057f7..8d1e78aba91ad 100644 --- a/cmd/data-usage-cache.go +++ b/cmd/data-usage-cache.go @@ -61,6 +61,7 @@ type dataUsageEntry struct { Children dataUsageHashMap `msg:"ch"` // These fields do no include any children. Size int64 `msg:"sz"` + HotTierSize int64 `msg:"hts"` Objects uint64 `msg:"os"` Versions uint64 `msg:"vs"` // Versions that are not delete markers. DeleteMarkers uint64 `msg:"dms"` @@ -134,8 +135,8 @@ func (ts tierStats) add(u tierStats) tierStats { } } -//msgp:encode ignore dataUsageEntryV2 dataUsageEntryV3 dataUsageEntryV4 dataUsageEntryV5 dataUsageEntryV6 dataUsageEntryV7 -//msgp:marshal ignore dataUsageEntryV2 dataUsageEntryV3 dataUsageEntryV4 dataUsageEntryV5 dataUsageEntryV6 dataUsageEntryV7 +//msgp:encode ignore dataUsageEntryV2 dataUsageEntryV3 dataUsageEntryV4 dataUsageEntryV5 dataUsageEntryV6 dataUsageEntryV7 dataUsageEntryV8 +//msgp:marshal ignore dataUsageEntryV2 dataUsageEntryV3 dataUsageEntryV4 dataUsageEntryV5 dataUsageEntryV6 dataUsageEntryV7 dataUsageEntryV8 //msgp:tuple dataUsageEntryV2 type dataUsageEntryV2 struct { @@ -199,14 +200,30 @@ type dataUsageEntryV7 struct { Compacted bool `msg:"c"` } +// dataUsageEntryV8 is the on-disk shape before access-tier accounting was +// introduced. Keep it so caches written by the previous release decode +// without being discarded. +type dataUsageEntryV8 struct { + Children dataUsageHashMap `msg:"ch"` + // These fields do no include any children. + Size int64 `msg:"sz"` + Objects uint64 `msg:"os"` + Versions uint64 `msg:"vs"` + DeleteMarkers uint64 `msg:"dms"` + ObjSizes sizeHistogram `msg:"szs"` + ObjVersions versionsHistogram `msg:"vh"` + AllTierStats *allTierStats `msg:"ats,omitempty"` + Compacted bool `msg:"c"` +} + // dataUsageCache contains a cache of data usage entries latest version. type dataUsageCache struct { Info dataUsageCacheInfo Cache map[string]dataUsageEntry } -//msgp:encode ignore dataUsageCacheV2 dataUsageCacheV3 dataUsageCacheV4 dataUsageCacheV5 dataUsageCacheV6 dataUsageCacheV7 -//msgp:marshal ignore dataUsageCacheV2 dataUsageCacheV3 dataUsageCacheV4 dataUsageCacheV5 dataUsageCacheV6 dataUsageCacheV7 +//msgp:encode ignore dataUsageCacheV2 dataUsageCacheV3 dataUsageCacheV4 dataUsageCacheV5 dataUsageCacheV6 dataUsageCacheV7 dataUsageCacheV8 +//msgp:marshal ignore dataUsageCacheV2 dataUsageCacheV3 dataUsageCacheV4 dataUsageCacheV5 dataUsageCacheV6 dataUsageCacheV7 dataUsageCacheV8 // dataUsageCacheV2 contains a cache of data usage entries version 2. type dataUsageCacheV2 struct { @@ -244,6 +261,12 @@ type dataUsageCacheV7 struct { Cache map[string]dataUsageEntryV7 } +// dataUsageCacheV8 contains a cache of data usage entries version 8. +type dataUsageCacheV8 struct { + Info dataUsageCacheInfo + Cache map[string]dataUsageEntryV8 +} + //msgp:ignore dataUsageEntryInfo type dataUsageEntryInfo struct { Name string @@ -272,6 +295,7 @@ type dataUsageCacheInfo struct { func (e *dataUsageEntry) addSizes(summary sizeSummary) { e.Size += summary.totalSize + e.HotTierSize += summary.hotTierSize e.Versions += summary.versions e.DeleteMarkers += summary.deleteMarkers e.ObjSizes.add(summary.totalSize) @@ -291,6 +315,7 @@ func (e *dataUsageEntry) merge(other dataUsageEntry) { e.Versions += other.Versions e.DeleteMarkers += other.DeleteMarkers e.Size += other.Size + e.HotTierSize += other.HotTierSize for i, v := range other.ObjSizes[:] { e.ObjSizes[i] += v @@ -431,6 +456,7 @@ func (d *dataUsageCache) dui(path string, buckets []BucketInfo) DataUsageInfo { flat := d.flatten(*e) dui := DataUsageInfo{ LastUpdate: d.Info.LastUpdate, + ScannerCycle: d.Info.NextCycle, ObjectsTotalCount: flat.Objects, VersionsTotalCount: flat.Versions, DeleteMarkersTotalCount: flat.DeleteMarkers, @@ -781,6 +807,7 @@ func (d *dataUsageCache) bucketsUsageInfo(buckets []BucketInfo) map[string]Bucke flat := d.flatten(*e) bui := BucketUsageInfo{ Size: uint64(flat.Size), + HotTierSize: uint64(max(flat.HotTierSize, 0)), VersionsCount: flat.Versions, ObjectsCount: flat.Objects, DeleteMarkersCount: flat.DeleteMarkers, @@ -980,7 +1007,8 @@ func (d *dataUsageCache) save(ctx context.Context, store objectIO, name string) // Bumping the cache version will drop data from previous versions // and write new data with the new version. const ( - dataUsageCacheVerCurrent = 8 + dataUsageCacheVerCurrent = 9 + dataUsageCacheVerV8 = 8 dataUsageCacheVerV7 = 7 dataUsageCacheVerV6 = 6 dataUsageCacheVerV5 = 5 @@ -1181,6 +1209,33 @@ func (d *dataUsageCache) deserialize(r io.Reader) error { } } + return nil + case dataUsageCacheVerV8: + // Zstd compressed. + dec, err := zstd.NewReader(r, zstd.WithDecoderConcurrency(2)) + if err != nil { + return err + } + defer dec.Close() + dold := &dataUsageCacheV8{} + if err = dold.DecodeMsg(msgp.NewReader(dec)); err != nil { + return err + } + d.Info = dold.Info + d.Cache = make(map[string]dataUsageEntry, len(dold.Cache)) + for k, v := range dold.Cache { + d.Cache[k] = dataUsageEntry{ + Children: v.Children, + Size: v.Size, + Objects: v.Objects, + Versions: v.Versions, + DeleteMarkers: v.DeleteMarkers, + ObjSizes: v.ObjSizes, + ObjVersions: v.ObjVersions, + AllTierStats: v.AllTierStats, + Compacted: v.Compacted, + } + } return nil case dataUsageCacheVerCurrent: // Zstd compressed. diff --git a/cmd/data-usage-cache_gen.go b/cmd/data-usage-cache_gen.go index df9d34a164a7e..369e073dd6811 100644 --- a/cmd/data-usage-cache_gen.go +++ b/cmd/data-usage-cache_gen.go @@ -1591,6 +1591,145 @@ func (z *dataUsageCacheV7) Msgsize() (s int) { return } +// DecodeMsg implements msgp.Decodable +func (z *dataUsageCacheV8) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "Info": + err = z.Info.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, "Info") + return + } + case "Cache": + var zb0002 uint32 + zb0002, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err, "Cache") + return + } + if z.Cache == nil { + z.Cache = make(map[string]dataUsageEntryV8, zb0002) + } else if len(z.Cache) > 0 { + clear(z.Cache) + } + for zb0002 > 0 { + zb0002-- + var za0001 string + za0001, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Cache") + return + } + var za0002 dataUsageEntryV8 + err = za0002.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, "Cache", za0001) + return + } + z.Cache[za0001] = za0002 + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *dataUsageCacheV8) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "Info": + bts, err = z.Info.UnmarshalMsg(bts) + if err != nil { + err = msgp.WrapError(err, "Info") + return + } + case "Cache": + var zb0002 uint32 + zb0002, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Cache") + return + } + if z.Cache == nil { + z.Cache = make(map[string]dataUsageEntryV8, zb0002) + } else if len(z.Cache) > 0 { + clear(z.Cache) + } + for zb0002 > 0 { + var za0002 dataUsageEntryV8 + zb0002-- + var za0001 string + za0001, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Cache") + return + } + bts, err = za0002.UnmarshalMsg(bts) + if err != nil { + err = msgp.WrapError(err, "Cache", za0001) + return + } + z.Cache[za0001] = za0002 + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *dataUsageCacheV8) Msgsize() (s int) { + s = 1 + 5 + z.Info.Msgsize() + 6 + msgp.MapHeaderSize + if z.Cache != nil { + for za0001, za0002 := range z.Cache { + _ = za0002 + s += msgp.StringPrefixSize + len(za0001) + za0002.Msgsize() + } + } + return +} + // DecodeMsg implements msgp.Decodable func (z *dataUsageEntry) DecodeMsg(dc *msgp.Reader) (err error) { var field []byte @@ -1623,6 +1762,12 @@ func (z *dataUsageEntry) DecodeMsg(dc *msgp.Reader) (err error) { err = msgp.WrapError(err, "Size") return } + case "hts": + z.HotTierSize, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "HotTierSize") + return + } case "os": z.Objects, err = dc.ReadUint64() if err != nil { @@ -1801,12 +1946,12 @@ func (z *dataUsageEntry) DecodeMsg(dc *msgp.Reader) (err error) { // EncodeMsg implements msgp.Encodable func (z *dataUsageEntry) EncodeMsg(en *msgp.Writer) (err error) { // check for omitted fields - zb0001Len := uint32(9) - var zb0001Mask uint16 /* 9 bits */ + zb0001Len := uint32(10) + var zb0001Mask uint16 /* 10 bits */ _ = zb0001Mask if z.AllTierStats == nil { zb0001Len-- - zb0001Mask |= 0x80 + zb0001Mask |= 0x100 } // variable map header, size zb0001Len err = en.Append(0x80 | uint8(zb0001Len)) @@ -1836,6 +1981,16 @@ func (z *dataUsageEntry) EncodeMsg(en *msgp.Writer) (err error) { err = msgp.WrapError(err, "Size") return } + // write "hts" + err = en.Append(0xa3, 0x68, 0x74, 0x73) + if err != nil { + return + } + err = en.WriteInt64(z.HotTierSize) + if err != nil { + err = msgp.WrapError(err, "HotTierSize") + return + } // write "os" err = en.Append(0xa2, 0x6f, 0x73) if err != nil { @@ -1900,7 +2055,7 @@ func (z *dataUsageEntry) EncodeMsg(en *msgp.Writer) (err error) { return } } - if (zb0001Mask & 0x80) == 0 { // if not omitted + if (zb0001Mask & 0x100) == 0 { // if not omitted // write "ats" err = en.Append(0xa3, 0x61, 0x74, 0x73) if err != nil { @@ -1981,12 +2136,12 @@ func (z *dataUsageEntry) EncodeMsg(en *msgp.Writer) (err error) { func (z *dataUsageEntry) MarshalMsg(b []byte) (o []byte, err error) { o = msgp.Require(b, z.Msgsize()) // check for omitted fields - zb0001Len := uint32(9) - var zb0001Mask uint16 /* 9 bits */ + zb0001Len := uint32(10) + var zb0001Mask uint16 /* 10 bits */ _ = zb0001Mask if z.AllTierStats == nil { zb0001Len-- - zb0001Mask |= 0x80 + zb0001Mask |= 0x100 } // variable map header, size zb0001Len o = append(o, 0x80|uint8(zb0001Len)) @@ -2003,6 +2158,9 @@ func (z *dataUsageEntry) MarshalMsg(b []byte) (o []byte, err error) { // string "sz" o = append(o, 0xa2, 0x73, 0x7a) o = msgp.AppendInt64(o, z.Size) + // string "hts" + o = append(o, 0xa3, 0x68, 0x74, 0x73) + o = msgp.AppendInt64(o, z.HotTierSize) // string "os" o = append(o, 0xa2, 0x6f, 0x73) o = msgp.AppendUint64(o, z.Objects) @@ -2024,7 +2182,7 @@ func (z *dataUsageEntry) MarshalMsg(b []byte) (o []byte, err error) { for za0002 := range z.ObjVersions { o = msgp.AppendUint64(o, z.ObjVersions[za0002]) } - if (zb0001Mask & 0x80) == 0 { // if not omitted + if (zb0001Mask & 0x100) == 0 { // if not omitted // string "ats" o = append(o, 0xa3, 0x61, 0x74, 0x73) if z.AllTierStats == nil { @@ -2088,6 +2246,12 @@ func (z *dataUsageEntry) UnmarshalMsg(bts []byte) (o []byte, err error) { err = msgp.WrapError(err, "Size") return } + case "hts": + z.HotTierSize, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "HotTierSize") + return + } case "os": z.Objects, bts, err = msgp.ReadUint64Bytes(bts) if err != nil { @@ -2265,7 +2429,7 @@ func (z *dataUsageEntry) UnmarshalMsg(bts []byte) (o []byte, err error) { // Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message func (z *dataUsageEntry) Msgsize() (s int) { - s = 1 + 3 + z.Children.Msgsize() + 3 + msgp.Int64Size + 3 + msgp.Uint64Size + 3 + msgp.Uint64Size + 4 + msgp.Uint64Size + 4 + msgp.ArrayHeaderSize + (dataUsageBucketLen * (msgp.Uint64Size)) + 3 + msgp.ArrayHeaderSize + (dataUsageVersionLen * (msgp.Uint64Size)) + 4 + s = 1 + 3 + z.Children.Msgsize() + 3 + msgp.Int64Size + 4 + msgp.Int64Size + 3 + msgp.Uint64Size + 3 + msgp.Uint64Size + 4 + msgp.Uint64Size + 4 + msgp.ArrayHeaderSize + (dataUsageBucketLen * (msgp.Uint64Size)) + 3 + msgp.ArrayHeaderSize + (dataUsageVersionLen * (msgp.Uint64Size)) + 4 if z.AllTierStats == nil { s += msgp.NilSize } else { @@ -3258,6 +3422,438 @@ func (z *dataUsageEntryV7) Msgsize() (s int) { return } +// DecodeMsg implements msgp.Decodable +func (z *dataUsageEntryV8) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + var zb0001Mask uint8 /* 1 bits */ + _ = zb0001Mask + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "ch": + err = z.Children.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, "Children") + return + } + case "sz": + z.Size, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "Size") + return + } + case "os": + z.Objects, err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "Objects") + return + } + case "vs": + z.Versions, err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "Versions") + return + } + case "dms": + z.DeleteMarkers, err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "DeleteMarkers") + return + } + case "szs": + var zb0002 uint32 + zb0002, err = dc.ReadArrayHeader() + if err != nil { + err = msgp.WrapError(err, "ObjSizes") + return + } + if zb0002 != uint32(dataUsageBucketLen) { + err = msgp.ArrayError{Wanted: uint32(dataUsageBucketLen), Got: zb0002} + return + } + for za0001 := range z.ObjSizes { + z.ObjSizes[za0001], err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "ObjSizes", za0001) + return + } + } + case "vh": + var zb0003 uint32 + zb0003, err = dc.ReadArrayHeader() + if err != nil { + err = msgp.WrapError(err, "ObjVersions") + return + } + if zb0003 != uint32(dataUsageVersionLen) { + err = msgp.ArrayError{Wanted: uint32(dataUsageVersionLen), Got: zb0003} + return + } + for za0002 := range z.ObjVersions { + z.ObjVersions[za0002], err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "ObjVersions", za0002) + return + } + } + case "ats": + if dc.IsNil() { + err = dc.ReadNil() + if err != nil { + err = msgp.WrapError(err, "AllTierStats") + return + } + z.AllTierStats = nil + } else { + if z.AllTierStats == nil { + z.AllTierStats = new(allTierStats) + } + var zb0004 uint32 + zb0004, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err, "AllTierStats") + return + } + for zb0004 > 0 { + zb0004-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err, "AllTierStats") + return + } + switch msgp.UnsafeString(field) { + case "ts": + var zb0005 uint32 + zb0005, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers") + return + } + if z.AllTierStats.Tiers == nil { + z.AllTierStats.Tiers = make(map[string]tierStats, zb0005) + } else if len(z.AllTierStats.Tiers) > 0 { + clear(z.AllTierStats.Tiers) + } + for zb0005 > 0 { + zb0005-- + var za0003 string + za0003, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers") + return + } + var za0004 tierStats + var zb0006 uint32 + zb0006, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003) + return + } + for zb0006 > 0 { + zb0006-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003) + return + } + switch msgp.UnsafeString(field) { + case "ts": + za0004.TotalSize, err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "TotalSize") + return + } + case "nv": + za0004.NumVersions, err = dc.ReadInt() + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "NumVersions") + return + } + case "no": + za0004.NumObjects, err = dc.ReadInt() + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "NumObjects") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003) + return + } + } + } + z.AllTierStats.Tiers[za0003] = za0004 + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err, "AllTierStats") + return + } + } + } + } + zb0001Mask |= 0x1 + case "c": + z.Compacted, err = dc.ReadBool() + if err != nil { + err = msgp.WrapError(err, "Compacted") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + // Clear omitted fields. + if (zb0001Mask & 0x1) == 0 { + z.AllTierStats = nil + } + + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *dataUsageEntryV8) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + var zb0001Mask uint8 /* 1 bits */ + _ = zb0001Mask + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "ch": + bts, err = z.Children.UnmarshalMsg(bts) + if err != nil { + err = msgp.WrapError(err, "Children") + return + } + case "sz": + z.Size, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "Size") + return + } + case "os": + z.Objects, bts, err = msgp.ReadUint64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "Objects") + return + } + case "vs": + z.Versions, bts, err = msgp.ReadUint64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "Versions") + return + } + case "dms": + z.DeleteMarkers, bts, err = msgp.ReadUint64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "DeleteMarkers") + return + } + case "szs": + var zb0002 uint32 + zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "ObjSizes") + return + } + if zb0002 != uint32(dataUsageBucketLen) { + err = msgp.ArrayError{Wanted: uint32(dataUsageBucketLen), Got: zb0002} + return + } + for za0001 := range z.ObjSizes { + z.ObjSizes[za0001], bts, err = msgp.ReadUint64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "ObjSizes", za0001) + return + } + } + case "vh": + var zb0003 uint32 + zb0003, bts, err = msgp.ReadArrayHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "ObjVersions") + return + } + if zb0003 != uint32(dataUsageVersionLen) { + err = msgp.ArrayError{Wanted: uint32(dataUsageVersionLen), Got: zb0003} + return + } + for za0002 := range z.ObjVersions { + z.ObjVersions[za0002], bts, err = msgp.ReadUint64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "ObjVersions", za0002) + return + } + } + case "ats": + if msgp.IsNil(bts) { + bts, err = msgp.ReadNilBytes(bts) + if err != nil { + return + } + z.AllTierStats = nil + } else { + if z.AllTierStats == nil { + z.AllTierStats = new(allTierStats) + } + var zb0004 uint32 + zb0004, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "AllTierStats") + return + } + for zb0004 > 0 { + zb0004-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err, "AllTierStats") + return + } + switch msgp.UnsafeString(field) { + case "ts": + var zb0005 uint32 + zb0005, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers") + return + } + if z.AllTierStats.Tiers == nil { + z.AllTierStats.Tiers = make(map[string]tierStats, zb0005) + } else if len(z.AllTierStats.Tiers) > 0 { + clear(z.AllTierStats.Tiers) + } + for zb0005 > 0 { + var za0004 tierStats + zb0005-- + var za0003 string + za0003, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers") + return + } + var zb0006 uint32 + zb0006, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003) + return + } + for zb0006 > 0 { + zb0006-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003) + return + } + switch msgp.UnsafeString(field) { + case "ts": + za0004.TotalSize, bts, err = msgp.ReadUint64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "TotalSize") + return + } + case "nv": + za0004.NumVersions, bts, err = msgp.ReadIntBytes(bts) + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "NumVersions") + return + } + case "no": + za0004.NumObjects, bts, err = msgp.ReadIntBytes(bts) + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "NumObjects") + return + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003) + return + } + } + } + z.AllTierStats.Tiers[za0003] = za0004 + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err, "AllTierStats") + return + } + } + } + } + zb0001Mask |= 0x1 + case "c": + z.Compacted, bts, err = msgp.ReadBoolBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Compacted") + return + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + // Clear omitted fields. + if (zb0001Mask & 0x1) == 0 { + z.AllTierStats = nil + } + + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *dataUsageEntryV8) Msgsize() (s int) { + s = 1 + 3 + z.Children.Msgsize() + 3 + msgp.Int64Size + 3 + msgp.Uint64Size + 3 + msgp.Uint64Size + 4 + msgp.Uint64Size + 4 + msgp.ArrayHeaderSize + (dataUsageBucketLen * (msgp.Uint64Size)) + 3 + msgp.ArrayHeaderSize + (dataUsageVersionLen * (msgp.Uint64Size)) + 4 + if z.AllTierStats == nil { + s += msgp.NilSize + } else { + s += 1 + 3 + msgp.MapHeaderSize + if z.AllTierStats.Tiers != nil { + for za0003, za0004 := range z.AllTierStats.Tiers { + _ = za0004 + s += msgp.StringPrefixSize + len(za0003) + 1 + 3 + msgp.Uint64Size + 3 + msgp.IntSize + 3 + msgp.IntSize + } + } + } + s += 2 + msgp.BoolSize + return +} + // DecodeMsg implements msgp.Decodable func (z *dataUsageHash) DecodeMsg(dc *msgp.Reader) (err error) { { diff --git a/cmd/data-usage-utils.go b/cmd/data-usage-utils.go index 1f6b3f11e2487..4bdb4869c1b69 100644 --- a/cmd/data-usage-utils.go +++ b/cmd/data-usage-utils.go @@ -46,7 +46,8 @@ type BucketTargetUsageInfo struct { // - total objects in a bucket // - object size histogram per bucket type BucketUsageInfo struct { - Size uint64 `json:"size"` + Size uint64 `json:"size"` + HotTierSize uint64 `json:"hotTierSize,omitempty"` // Following five fields suffixed with V1 are here for backward compatibility // Total Size for objects that have not yet been replicated ReplicationPendingSizeV1 uint64 `json:"objectsPendingReplicationTotalSize"` @@ -78,6 +79,10 @@ type DataUsageInfo struct { // LastUpdate is the timestamp of when the data usage info was last updated. // This does not indicate a full scan. LastUpdate time.Time `json:"lastUpdate"` + // ScannerCycle changes only after a complete scanner pass. Background + // consumers use it to distinguish a partial cache update from a baseline + // that has visited every bucket and server pool. + ScannerCycle uint32 `json:"scannerCycle,omitempty"` // Objects total count across all buckets ObjectsTotalCount uint64 `json:"objectsCount"` diff --git a/cmd/erasure-server-pool.go b/cmd/erasure-server-pool.go index dc65d06eed038..28cc5d2780373 100644 --- a/cmd/erasure-server-pool.go +++ b/cmd/erasure-server-pool.go @@ -615,7 +615,17 @@ func (z *erasureServerPools) getPoolIdxExistingNoLock(ctx context.Context, bucke }) } -func (z *erasureServerPools) getPoolIdxNoLock(ctx context.Context, bucket, object string, size int64) (idx int, err error) { +func (z *erasureServerPools) getPoolIdxNoLock(ctx context.Context, bucket, object string, size int64, dstPoolIdx *int) (idx int, err error) { + if dstPoolIdx != nil { + if *dstPoolIdx < 0 || *dstPoolIdx >= len(z.serverPools) { + return -1, errInvalidArgument + } + if z.IsSuspended(*dstPoolIdx) || z.IsPoolRebalancing(*dstPoolIdx) { + return -1, toObjectErr(errDiskFull) + } + return *dstPoolIdx, nil + } + idx, err = z.getPoolIdxExistingNoLock(ctx, bucket, object) if err != nil && !isErrObjectNotFound(err) { return idx, err @@ -634,7 +644,17 @@ func (z *erasureServerPools) getPoolIdxNoLock(ctx context.Context, bucket, objec // getPoolIdx returns the found previous object and its corresponding pool idx, // if none are found falls back to most available space pool, this function is // designed to be only used by PutObject, CopyObject (newObject creation) and NewMultipartUpload. -func (z *erasureServerPools) getPoolIdx(ctx context.Context, bucket, object string, size int64) (idx int, err error) { +func (z *erasureServerPools) getPoolIdx(ctx context.Context, bucket, object string, size int64, dstPoolIdx *int) (idx int, err error) { + if dstPoolIdx != nil { + if *dstPoolIdx < 0 || *dstPoolIdx >= len(z.serverPools) { + return -1, errInvalidArgument + } + if z.IsSuspended(*dstPoolIdx) || z.IsPoolRebalancing(*dstPoolIdx) { + return -1, toObjectErr(errDiskFull) + } + return *dstPoolIdx, nil + } + pinfo, _, err := z.getPoolInfoExistingWithOpts(ctx, bucket, object, ObjectOptions{ SkipDecommissioned: true, SkipRebalancing: true, @@ -656,6 +676,13 @@ func (z *erasureServerPools) getPoolIdx(ctx context.Context, bucket, object stri return idx, nil } +func dataMovementDstPool(opts ObjectOptions) *int { + if !opts.DataMovement { + return nil + } + return opts.DstPoolIdx +} + func (z *erasureServerPools) Shutdown(ctx context.Context) error { g := errgroup.WithNErrs(len(z.serverPools)) @@ -1090,14 +1117,20 @@ func (z *erasureServerPools) PutObject(ctx context.Context, bucket string, objec object = encodeDirObject(object) if z.SinglePool() { - _, err := z.getPoolIdx(ctx, bucket, object, data.Size()) + idx, err := z.getPoolIdx(ctx, bucket, object, data.Size(), dataMovementDstPool(opts)) if err != nil { return ObjectInfo{}, err } + if dataMovementDstPool(opts) != nil && idx == opts.SrcPoolIdx { + return ObjectInfo{}, DataMovementOverwriteErr{ + Bucket: bucket, Object: object, VersionID: opts.VersionID, + Err: errDataMovementSrcDstPoolSame, + } + } return z.serverPools[0].PutObject(ctx, bucket, object, data, opts) } - idx, err := z.getPoolIdx(ctx, bucket, object, data.Size()) + idx, err := z.getPoolIdx(ctx, bucket, object, data.Size(), dataMovementDstPool(opts)) if err != nil { return ObjectInfo{}, err } @@ -1145,6 +1178,28 @@ func (z *erasureServerPools) DeleteObject(ctx context.Context, bucket string, ob return ObjectInfo{}, z.deletePrefix(ctx, bucket, object) } + // Access-tier moves must recreate delete markers on the explicitly + // selected destination. The regular data-movement path discovers a pool + // from existing object state, which is ambiguous while both source and + // destination temporarily contain the version stack. + if dstPoolIdx := dataMovementDstPool(opts); dstPoolIdx != nil { + if *dstPoolIdx < 0 || *dstPoolIdx >= len(z.serverPools) { + return ObjectInfo{}, errInvalidArgument + } + if *dstPoolIdx == opts.SrcPoolIdx { + return ObjectInfo{}, DataMovementOverwriteErr{ + Bucket: bucket, Object: decodeDirObject(object), VersionID: opts.VersionID, + Err: errDataMovementSrcDstPoolSame, + } + } + if z.IsSuspended(*dstPoolIdx) || z.IsPoolRebalancing(*dstPoolIdx) { + return ObjectInfo{}, toObjectErr(errDiskFull) + } + objInfo, err = z.serverPools[*dstPoolIdx].DeleteObject(ctx, bucket, object, opts) + objInfo.Name = decodeDirObject(object) + return objInfo, err + } + gopts := opts gopts.NoLock = true @@ -1323,10 +1378,16 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec dstOpts.NoLock = true } - poolIdx, err := z.getPoolIdxNoLock(ctx, dstBucket, dstObject, srcInfo.Size) + poolIdx, err := z.getPoolIdxNoLock(ctx, dstBucket, dstObject, srcInfo.Size, dataMovementDstPool(dstOpts)) if err != nil { return objInfo, err } + if dataMovementDstPool(dstOpts) != nil && poolIdx == dstOpts.SrcPoolIdx { + return ObjectInfo{}, DataMovementOverwriteErr{ + Bucket: dstBucket, Object: dstObject, VersionID: dstOpts.VersionID, + Err: errDataMovementSrcDstPoolSame, + } + } if cpSrcDstSame && srcInfo.metadataOnly { // Version ID is set for the destination and source == destination version ID. @@ -1771,29 +1832,41 @@ func (z *erasureServerPools) NewMultipartUpload(ctx context.Context, bucket, obj }() if z.SinglePool() { + idx, err := z.getPoolIdx(ctx, bucket, object, -1, dataMovementDstPool(opts)) + if err != nil { + return nil, err + } + if dataMovementDstPool(opts) != nil && idx == opts.SrcPoolIdx { + return nil, DataMovementOverwriteErr{ + Bucket: bucket, Object: object, VersionID: opts.VersionID, + Err: errDataMovementSrcDstPoolSame, + } + } return z.serverPools[0].NewMultipartUpload(ctx, bucket, object, opts) } - for idx, pool := range z.serverPools { - if z.IsSuspended(idx) || z.IsPoolRebalancing(idx) { - continue - } + if dataMovementDstPool(opts) == nil { + for idx, pool := range z.serverPools { + if z.IsSuspended(idx) || z.IsPoolRebalancing(idx) { + continue + } - result, err := pool.ListMultipartUploads(ctx, bucket, object, "", "", "", maxUploadsList) - if err != nil { - return nil, err - } - // If there is a multipart upload with the same bucket/object name, - // create the new multipart in the same pool, this will avoid - // creating two multiparts uploads in two different pools - if len(result.Uploads) != 0 { - return z.serverPools[idx].NewMultipartUpload(ctx, bucket, object, opts) + result, err := pool.ListMultipartUploads(ctx, bucket, object, "", "", "", maxUploadsList) + if err != nil { + return nil, err + } + // If there is a multipart upload with the same bucket/object name, + // create the new multipart in the same pool, this will avoid + // creating two multiparts uploads in two different pools. + if len(result.Uploads) != 0 { + return z.serverPools[idx].NewMultipartUpload(ctx, bucket, object, opts) + } } } // any parallel writes on the object will block for this poolIdx // to return since this holds a read lock on the namespace. - idx, err := z.getPoolIdx(ctx, bucket, object, -1) + idx, err := z.getPoolIdx(ctx, bucket, object, -1, dataMovementDstPool(opts)) if err != nil { return nil, err } @@ -1827,7 +1900,7 @@ func (z *erasureServerPools) PutObjectPart(ctx context.Context, bucket, object, } if z.SinglePool() { - _, err := z.getPoolIdx(ctx, bucket, object, data.Size()) + _, err := z.getPoolIdx(ctx, bucket, object, data.Size(), dataMovementDstPool(opts)) if err != nil { return PartInfo{}, err } @@ -2987,7 +3060,7 @@ func (z *erasureServerPools) DecomTieredObject(ctx context.Context, bucket, obje defer ns.Unlock(lkctx) opts.NoLock = true } - idx, err := z.getPoolIdxNoLock(ctx, bucket, object, fi.Size) + idx, err := z.getPoolIdxNoLock(ctx, bucket, object, fi.Size, dataMovementDstPool(opts)) if err != nil { return err } diff --git a/cmd/ilm-access-tier.go b/cmd/ilm-access-tier.go new file mode 100644 index 0000000000000..28007b1411f48 --- /dev/null +++ b/cmd/ilm-access-tier.go @@ -0,0 +1,1123 @@ +// Copyright (c) 2015-2026 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 . + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "maps" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/minio/minio/internal/bucket/lifecycle" + "github.com/minio/minio/internal/config/ilm" + "github.com/minio/minio/internal/hash" +) + +const ( + accessTierMetadataKey = ReservedMetadataPrefixLower + "ilm-atier" + accessTierQueueSize = 100000 +) + +var ( + errAccessTierNotEligible = errors.New("object is no longer eligible for access tiering") + errAccessTierTooManyVersion = errors.New("object has too many versions for access tiering") + errAccessTierRemoteVersion = errors.New("access tiering does not move remotely transitioned versions") +) + +type accessTierDirection uint8 + +const ( + accessTierPromote accessTierDirection = iota + 1 + accessTierDemote +) + +type accessTierTask struct { + ctx context.Context + bucket string + object string + src int + dst int + direction accessTierDirection + bytes uint64 // promotion reservation; zero for demotion +} + +// accessTierState owns the leader-only mover queues and the usage deltas made +// since the data scanner's last published snapshot. +type accessTierState struct { + ctx context.Context + objAPI ObjectLayer + z *erasureServerPools + + promoteCh chan accessTierTask + demoteCh chan accessTierTask + + mu sync.Mutex + numWorkers int + workerStops []chan struct{} + pending map[string]struct{} + baseUsage map[string]uint64 + baseTotal uint64 + delta map[string]uint64 + deltaCycle uint32 + deltaCycleValid bool + reserved map[string]uint64 + usageAt time.Time + usageCycle uint32 + usageCycleSeen bool + usageReady bool + + active atomic.Int64 + promotions atomic.Uint64 + demotions atomic.Uint64 + bytesMoved atomic.Uint64 + failures atomic.Uint64 + skippedWatermark atomic.Uint64 + skippedMaxSize atomic.Uint64 + skippedQuota atomic.Uint64 +} + +var globalAccessTierState *accessTierState + +func newAccessTierState(ctx context.Context) *accessTierState { + return &accessTierState{ + ctx: ctx, + promoteCh: make(chan accessTierTask, accessTierQueueSize), + demoteCh: make(chan accessTierTask, accessTierQueueSize), + pending: make(map[string]struct{}), + baseUsage: make(map[string]uint64), + delta: make(map[string]uint64), + reserved: make(map[string]uint64), + } +} + +func (s *accessTierState) Init(objAPI ObjectLayer) { + if s == nil { + return + } + z, ok := objAPI.(*erasureServerPools) + if !ok { + return + } + + s.mu.Lock() + s.objAPI = objAPI + s.z = z + s.updateWorkersLocked(globalILMConfig.accessCfg().AccessWorkers) + s.mu.Unlock() + + go s.runLeader() +} + +func (s *accessTierState) UpdateWorkers(n int) { + if s == nil || n < 1 { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.objAPI == nil { + return + } + s.updateWorkersLocked(n) +} + +func (s *accessTierState) updateWorkersLocked(n int) { + for s.numWorkers < n { + stop := make(chan struct{}) + s.workerStops = append(s.workerStops, stop) + go s.worker(stop) + s.numWorkers++ + } + for s.numWorkers > n { + last := len(s.workerStops) - 1 + close(s.workerStops[last]) + s.workerStops = s.workerStops[:last] + s.numWorkers-- + } +} + +func (s *accessTierState) PendingTasks() int { + if s == nil { + return 0 + } + return len(s.promoteCh) + len(s.demoteCh) +} + +func (s *accessTierState) ActiveTasks() int64 { + if s == nil { + return 0 + } + return s.active.Load() +} + +func accessTierPendingKey(bucket, object string) string { + return accessKey(bucket, object) +} + +func (s *accessTierState) enqueueDemote(task accessTierTask) bool { + key := accessTierPendingKey(task.bucket, task.object) + s.mu.Lock() + if _, exists := s.pending[key]; exists { + s.mu.Unlock() + return false + } + s.pending[key] = struct{}{} + s.mu.Unlock() + + select { + case <-task.ctx.Done(): + s.releasePending(task, false) + return false + case s.demoteCh <- task: + return true + default: + s.releasePending(task, false) + return false + } +} + +func addUsage(a, b uint64) uint64 { + if ^uint64(0)-a < b { + return ^uint64(0) + } + return a + b +} + +func (s *accessTierState) bucketUsageLocked(bucket string) uint64 { + return addUsage(addUsage(s.baseUsage[bucket], s.delta[bucket]), s.reserved[bucket]) +} + +func (s *accessTierState) totalUsageLocked() uint64 { + var delta uint64 + var reserved uint64 + for _, v := range s.delta { + delta = addUsage(delta, v) + } + for _, v := range s.reserved { + reserved = addUsage(reserved, v) + } + return addUsage(addUsage(s.baseTotal, delta), reserved) +} + +// reservePromotion makes all queued promotions participate in quota checks, +// so one sweep cannot admit many objects against the same stale scanner value. +func (s *accessTierState) reservePromotion(task accessTierTask, cfg ilm.Config, quota uint64) (string, bool) { + key := accessTierPendingKey(task.bucket, task.object) + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.pending[key]; exists { + return "", false + } + if !s.usageReady { + switch { + case cfg.AccessMaxSize > 0: + return "max-size", false + case quota > 0: + return "quota", false + } + } + if cfg.AccessMaxSize > 0 && addUsage(s.totalUsageLocked(), task.bytes) > cfg.AccessMaxSize { + return "max-size", false + } + if quota > 0 && addUsage(s.bucketUsageLocked(task.bucket), task.bytes) > quota { + return "quota", false + } + s.pending[key] = struct{}{} + s.reserved[task.bucket] = addUsage(s.reserved[task.bucket], task.bytes) + return "", true +} + +func (s *accessTierState) replaceReservation(bucket string, old, current uint64, cfg ilm.Config, quota uint64) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + reserved := s.reserved[bucket] + if old >= reserved { + reserved = 0 + } else { + reserved -= old + } + s.reserved[bucket] = reserved + if cfg.AccessMaxSize > 0 && addUsage(s.totalUsageLocked(), current) > cfg.AccessMaxSize { + s.reserved[bucket] = addUsage(s.reserved[bucket], old) + return "max-size", false + } + if quota > 0 && addUsage(s.bucketUsageLocked(bucket), current) > quota { + s.reserved[bucket] = addUsage(s.reserved[bucket], old) + return "quota", false + } + s.reserved[bucket] = addUsage(s.reserved[bucket], current) + return "", true +} + +func (s *accessTierState) enqueuePromote(task accessTierTask, cfg ilm.Config, quota uint64) bool { + reason, ok := s.reservePromotion(task, cfg, quota) + if !ok { + s.recordSkip(reason) + return false + } + select { + case <-task.ctx.Done(): + s.releasePending(task, true) + return false + case s.promoteCh <- task: + return true + default: + s.releasePending(task, true) + return false + } +} + +func (s *accessTierState) releasePending(task accessTierTask, releaseReservation bool) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.pending, accessTierPendingKey(task.bucket, task.object)) + if releaseReservation { + if task.bytes >= s.reserved[task.bucket] { + delete(s.reserved, task.bucket) + } else { + s.reserved[task.bucket] -= task.bytes + } + } +} + +func (s *accessTierState) completeTask(task accessTierTask, moved uint64) { + s.mu.Lock() + delete(s.pending, accessTierPendingKey(task.bucket, task.object)) + if task.direction == accessTierPromote { + if task.bytes >= s.reserved[task.bucket] { + delete(s.reserved, task.bucket) + } else { + s.reserved[task.bucket] -= task.bytes + } + s.delta[task.bucket] = addUsage(s.delta[task.bucket], moved) + if s.usageCycleSeen { + s.deltaCycle = s.usageCycle + s.deltaCycleValid = true + } + } + s.mu.Unlock() + + if task.direction == accessTierPromote { + s.promotions.Add(1) + } else { + s.demotions.Add(1) + } + s.bytesMoved.Add(moved) +} + +func (s *accessTierState) recordSkip(reason string) { + switch reason { + case "watermark": + s.skippedWatermark.Add(1) + case "max-size": + s.skippedMaxSize.Add(1) + case "quota": + s.skippedQuota.Add(1) + } +} + +func (s *accessTierState) nextTask(stop <-chan struct{}) (accessTierTask, bool) { + // Prefer demotion whenever one is already waiting, without starving + // promotions when the demotion queue is empty. + select { + case <-stop: + return accessTierTask{}, false + default: + } + select { + case task := <-s.demoteCh: + return task, true + default: + } + select { + case <-stop: + return accessTierTask{}, false + case <-s.ctx.Done(): + return accessTierTask{}, false + case task := <-s.demoteCh: + return task, true + case task := <-s.promoteCh: + return task, true + } +} + +func (s *accessTierState) worker(stop <-chan struct{}) { + for { + task, ok := s.nextTask(stop) + if !ok { + return + } + s.active.Add(1) + err := s.processTask(task) + s.active.Add(-1) + if err != nil { + if !errors.Is(err, errAccessTierNotEligible) && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) && !errors.Is(err, context.Canceled) { + s.failures.Add(1) + ilmLogIf(task.ctx, fmt.Errorf("access tier move failed for %s/%s: %w", task.bucket, task.object, err)) + } + } + } +} + +func (s *accessTierState) runLeader() { + for s.ctx.Err() == nil { + leaderCtx, cancel := globalLeaderLock.GetLock(s.ctx) + s.runSweeps(leaderCtx) + cancel() + } +} + +func (s *accessTierState) runSweeps(ctx context.Context) { + cfg := globalILMConfig.accessCfg() + interval := cfg.AccessFlush + if interval <= 0 { + interval = time.Minute + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + newCfg := globalILMConfig.accessCfg() + if newCfg.AccessFlush > 0 && newCfg.AccessFlush != interval { + interval = newCfg.AccessFlush + ticker.Reset(interval) + } + cfg = newCfg + if !cfg.AccessTiering { + continue + } + if !validAccessTopology(s.z, cfg) { + ilmLogOnceIf(ctx, fmt.Errorf("access tiering needs at least two existing server pools; configured pools are %v", cfg.AccessPools), "access-tier-topology") + continue + } + if s.z.IsRebalanceStarted(ctx) || s.z.IsDecommissionRunning() { + continue + } + s.refreshUsage(ctx) + s.sweepDemotions(ctx, cfg) + s.sweepPromotions(ctx, cfg) + } + } +} + +func validAccessTopology(z *erasureServerPools, cfg ilm.Config) bool { + if z == nil || len(cfg.AccessPools) < 2 || len(z.serverPools) < 2 { + return false + } + for _, idx := range cfg.AccessPools { + if idx < 0 || idx >= len(z.serverPools) { + return false + } + } + return true +} + +func (s *accessTierState) refreshUsage(ctx context.Context) { + dui, err := loadDataUsageFromBackend(ctx, s.objAPI) + if err != nil || dui.LastUpdate.IsZero() { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if !dui.LastUpdate.After(s.usageAt) { + return + } + cycleAdvanced := s.usageCycleSeen && dui.ScannerCycle != s.usageCycle + s.baseUsage = make(map[string]uint64, len(dui.BucketsUsage)) + s.baseTotal = 0 + for bucket, usage := range dui.BucketsUsage { + // Only retain buckets that actually hold hot-tier bytes. Keeping a + // zero entry per bucket would emit one Prometheus series per bucket + // in the cluster from hotUsageSnapshot. + if usage.HotTierSize == 0 { + continue + } + s.baseUsage[bucket] = usage.HotTierSize + s.baseTotal = addUsage(s.baseTotal, usage.HotTierSize) + } + if !s.usageCycleSeen { + s.usageCycleSeen = true + s.usageCycle = dui.ScannerCycle + if len(s.delta) > 0 && !s.deltaCycleValid { + s.deltaCycle = dui.ScannerCycle + s.deltaCycleValid = true + } + } else if cycleAdvanced { + s.usageCycle = dui.ScannerCycle + // A move can finish while the next scanner pass is already in + // progress. Retain promotion deltas until one additional complete + // pass has elapsed, so quotas can over-count temporarily but never + // under-count. Demotions rely only on the scanner to release space. + if s.deltaCycleValid && scannerCyclesApart(dui.ScannerCycle, s.deltaCycle) >= 2 { + clear(s.delta) + s.deltaCycleValid = false + } + s.usageReady = true + } + s.usageAt = dui.LastUpdate +} + +func scannerCyclesApart(current, previous uint32) uint32 { + distance := current - previous + if distance >= 1<<31 { + return 0 + } + return distance +} + +func (s *accessTierState) hotUsageSnapshot() map[string]uint64 { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + out := make(map[string]uint64, len(s.baseUsage)+len(s.delta)+len(s.reserved)) + for _, m := range []map[string]uint64{s.baseUsage, s.delta, s.reserved} { + for bucket := range m { + if used := s.bucketUsageLocked(bucket); used > 0 { + out[bucket] = used + } + } + } + return out +} + +func (s *accessTierState) sweepDemotions(ctx context.Context, cfg ilm.Config) { + for _, candidate := range globalAccessTracker.collectDemoteCandidates(ctx, s.objAPI, cfg) { + if candidate.Bucket == minioMetaBucket { + continue + } + pinfo, _, err := s.z.getPoolInfoExistingWithOpts(ctx, candidate.Bucket, candidate.Object, ObjectOptions{ + SkipDecommissioned: true, + SkipRebalancing: true, + }) + if err != nil || pinfo.Index != candidate.Pool { + continue + } + cold, _ := cfg.ColdPool() + if pinfo.Index == cold || !accessPoolIsHotter(cfg, pinfo.Index, cold) { + continue + } + lc, err := globalLifecycleSys.Get(candidate.Bucket) + if err != nil || !accessDemoteEligible(lc, pinfo.ObjInfo, pinfo.Index, cfg, time.Now()) { + continue + } + s.enqueueDemote(accessTierTask{ + ctx: ctx, bucket: candidate.Bucket, object: candidate.Object, + src: pinfo.Index, dst: cold, direction: accessTierDemote, + }) + } +} + +func (s *accessTierState) sweepPromotions(ctx context.Context, cfg ilm.Config) { + snapshot := globalAccessTracker.snapshot() + if snapshot == nil { + return + } + hot, _ := cfg.HotPool() + lifecycles := make(map[string]*lifecycle.Lifecycle) + missing := make(map[string]struct{}) + for key, entry := range snapshot.entries { + bucket, object, ok := splitAccessKey(key) + if !ok || bucket == minioMetaBucket { + continue + } + lc := lifecycles[bucket] + if lc == nil { + if _, skip := missing[bucket]; skip { + continue + } + var err error + lc, err = globalLifecycleSys.Get(bucket) + if err != nil || !lc.HasAccessTransition() { + missing[bucket] = struct{}{} + continue + } + lifecycles[bucket] = lc + } + + if !accessHitsMightPromote(lc, object, entry, snapshot.binWidth) { + continue + } + + pinfo, _, err := s.z.getPoolInfoExistingWithOpts(ctx, bucket, object, ObjectOptions{ + SkipDecommissioned: true, + SkipRebalancing: true, + }) + if err != nil || pinfo.Index == hot || pinfo.ObjInfo.DeleteMarker || pinfo.ObjInfo.IsDir || pinfo.ObjInfo.IsRemote() { + continue + } + rule, _, ok := lc.AccessRule(pinfo.ObjInfo.ToLifecycleOpts()) + if !ok || entry.hits(rule.Window.D(), snapshot.binWidth) < uint64(rule.PromoteAfterAccesses) { + continue + } + if rule.Window.D() > cfg.HistoryWindow() { + ilmLogOnceIf(ctx, fmt.Errorf("access tier rule window %s for %s/%s exceeds retained history %s and is clamped", rule.Window.D(), bucket, object, cfg.HistoryWindow()), "access-window-"+bucket) + } + bytes, err := accessObjectBytes(ctx, s.z, pinfo.Index, bucket, object) + if err != nil { + continue + } + if !s.promotionWatermarkOK(ctx, cfg, hot, bucket, object, bytes) { + s.recordSkip("watermark") + continue + } + s.enqueuePromote(accessTierTask{ + ctx: ctx, bucket: bucket, object: object, src: pinfo.Index, dst: hot, + direction: accessTierPromote, bytes: bytes, + }, cfg, lc.AccessQuotaBytes()) + } +} + +// accessHitsMightPromote is the cheap prefilter for sweepPromotions: prefix +// plus hit count, without a quorum metadata read. Size and tag filters are +// applied later via AccessRule once object info is in hand. +func accessHitsMightPromote(lc *lifecycle.Lifecycle, object string, entry accessEntry, binWidth int64) bool { + if lc == nil { + return false + } + for _, rule := range lc.Rules { + if rule.Status == lifecycle.Disabled || rule.AccessTransition.IsNull() { + continue + } + prefix := rule.GetPrefix() + if prefix != "" && !strings.HasPrefix(object, prefix) { + continue + } + at := rule.AccessTransition + if entry.hits(at.Window.D(), binWidth) >= uint64(at.PromoteAfterAccesses) { + return true + } + } + return false +} + +func (s *accessTierState) promotionWatermarkOK(ctx context.Context, cfg ilm.Config, hot int, bucket, object string, bytes uint64) bool { + if bytes > ^uint64(0)>>1 { + return false + } + spaces := s.z.getServerPoolsAvailableSpace(ctx, bucket, object, int64(bytes)) + if hot < 0 || hot >= len(spaces) { + return false + } + space := spaces[hot] + return space.Available > 0 && space.MaxUsedPct < cfg.AccessPromoteWatermark +} + +func accessPoolIsHotter(cfg ilm.Config, src, dst int) bool { + srcPos, dstPos := -1, -1 + for pos, pool := range cfg.AccessPools { + if pool == src { + srcPos = pos + } + if pool == dst { + dstPos = pos + } + } + return srcPos >= 0 && dstPos >= 0 && srcPos < dstPos +} + +// accessTierStamp renders the marker written to every version a move touches. +// parseAccessTierStamp is its inverse; keep the two together. +func accessTierStamp(pool int, movedAt int64) string { + return strconv.Itoa(pool) + ":" + strconv.FormatInt(movedAt, 10) +} + +func parseAccessTierStamp(meta map[string]string) (pool int, movedAt time.Time, ok bool) { + value, ok := meta[accessTierMetadataKey] + if !ok { + return 0, time.Time{}, false + } + poolText, stampText, ok := strings.Cut(value, ":") + if !ok { + return 0, time.Time{}, false + } + pool, err := strconv.Atoi(poolText) + if err != nil { + return 0, time.Time{}, false + } + stamp, err := strconv.ParseInt(stampText, 10, 64) + if err != nil || stamp <= 0 { + return 0, time.Time{}, false + } + return pool, time.Unix(0, stamp), true +} + +func accessDemoteEligible(lc *lifecycle.Lifecycle, oi ObjectInfo, pool int, cfg ilm.Config, now time.Time) bool { + if lc == nil || oi.DeleteMarker || oi.IsDir || oi.IsRemote() { + return false + } + markedPool, movedAt, ok := parseAccessTierStamp(oi.UserDefined) + if !ok || markedPool != pool || now.Sub(movedAt) < cfg.AccessMinResidency { + return false + } + cold, ok := cfg.ColdPool() + if !ok || !accessPoolIsHotter(cfg, pool, cold) { + return false + } + rule, _, ok := lc.AccessRule(oi.ToLifecycleOpts()) + if !ok { + return false + } + last := globalAccessTracker.lastAccess(oi.Bucket, oi.Name) + if !last.IsZero() && now.Sub(last) < rule.DemoteAfterIdle.D() { + return false + } + hits := globalAccessTracker.hits(oi.Bucket, oi.Name, rule.Window.D()) + return hits <= uint64(rule.DemoteAfterAccesses) && hits < uint64(rule.PromoteAfterAccesses) +} + +// applyAccessTransition runs on scanner nodes and publishes eligible demotion +// candidates to the node's next access-counter shard. +func applyAccessTransition(_ context.Context, item *scannerItem, oi ObjectInfo) { + if item == nil || item.lifeCycle == nil || globalAccessTierState == nil || !item.lifeCycle.HasAccessTransition() { + return + } + cfg := globalILMConfig.accessCfg() + if !cfg.AccessTiering || !accessDemoteEligible(item.lifeCycle, oi, item.poolIdx, cfg, time.Now()) { + return + } + globalAccessTracker.noteDemoteCandidate(item.bucket, item.objectPath(), item.poolIdx) +} + +func (s *accessTierState) processTask(task accessTierTask) error { + completed := false + defer func() { + if !completed { + s.releasePending(task, task.direction == accessTierPromote) + } + }() + if err := task.ctx.Err(); err != nil { + return err + } + cfg := globalILMConfig.accessCfg() + if !cfg.AccessTiering || !validAccessTopology(s.z, cfg) || s.z.IsRebalanceStarted(task.ctx) || s.z.IsDecommissionRunning() { + return errAccessTierNotEligible + } + if s.z.IsSuspended(task.dst) || s.z.IsPoolRebalancing(task.dst) { + return errAccessTierNotEligible + } + hot, _ := cfg.HotPool() + cold, _ := cfg.ColdPool() + if task.direction == accessTierPromote && task.dst != hot || task.direction == accessTierDemote && task.dst != cold { + return errAccessTierNotEligible + } + + reserved := task.bytes + var auditOI ObjectInfo + moved, err := moveObjectPool(task.ctx, s.z, task.bucket, task.object, task.src, task.dst, + func(latest ObjectInfo, exactBytes uint64) error { + auditOI = latest + lc, err := globalLifecycleSys.Get(task.bucket) + if err != nil { + return errAccessTierNotEligible + } + rule, _, ok := lc.AccessRule(latest.ToLifecycleOpts()) + if !ok { + return errAccessTierNotEligible + } + hits := globalAccessTracker.hits(task.bucket, task.object, rule.Window.D()) + if task.direction == accessTierDemote { + if !accessDemoteEligible(lc, latest, task.src, cfg, time.Now()) { + return errAccessTierNotEligible + } + return nil + } + if hits < uint64(rule.PromoteAfterAccesses) { + return errAccessTierNotEligible + } + if !s.promotionWatermarkOK(task.ctx, cfg, task.dst, task.bucket, task.object, exactBytes) { + s.recordSkip("watermark") + return errAccessTierNotEligible + } + reason, ok := s.replaceReservation(task.bucket, reserved, exactBytes, cfg, lc.AccessQuotaBytes()) + if !ok { + s.recordSkip(reason) + return errAccessTierNotEligible + } + reserved = exactBytes + return nil + }) + task.bytes = reserved + if err != nil { + return err + } + s.completeTask(task, moved) + completed = true + + if auditOI.Bucket == "" { + auditOI = ObjectInfo{Bucket: task.bucket, Name: task.object} + } + direction := "demote" + if task.direction == accessTierPromote { + direction = "promote" + } + tags := newLifecycleAuditEvent(lcEventSrc_AccessTier, lifecycle.Event{}).Tags() + tags["ilm-access-direction"] = direction + tags["ilm-access-src-pool"] = strconv.Itoa(task.src) + tags["ilm-access-dst-pool"] = strconv.Itoa(task.dst) + auditLogLifecycle(task.ctx, auditOI, ILMAccessTier, tags, globalLifecycleSys.trace(auditOI)) + return nil +} + +// accessObjectVersions resolves a single object's complete version stack from +// the specified source pool using the same quorum listing primitive as +// rebalance/decommission. +func accessObjectVersions(ctx context.Context, z *erasureServerPools, src int, bucket, object string) ([]FileInfo, error) { + if src < 0 || src >= len(z.serverPools) { + return nil, errInvalidArgument + } + set := z.serverPools[src].getHashedSet(encodeDirObject(object)) + disks, _ := set.getOnlineDisksWithHealing(false) + if len(disks) == 0 { + return nil, errErasureReadQuorum + } + listingQuorum := (set.setDriveCount + 1) / 2 + resolver := metadataResolutionParams{dirQuorum: listingQuorum, objQuorum: listingQuorum, bucket: bucket} + + var ( + mu sync.Mutex + versions []FileInfo + found bool + foundErr error + ) + consume := func(entry metaCacheEntry) { + if entry.name != object { + return + } + mu.Lock() + defer mu.Unlock() + if found { + return + } + fivs, err := entry.fileInfoVersions(bucket) + if err != nil { + foundErr = err + found = true + return + } + versions = append(versions, fivs.Versions...) + found = true + } + err := listPathRaw(ctx, listPathRawOptions{ + disks: disks, bucket: bucket, path: object, recursive: true, + minDisks: listingQuorum, reportNotFound: false, + agreed: consume, + partial: func(entries metaCacheEntries, _ []error) { + if entry, ok := entries.resolve(&resolver); ok { + consume(*entry) + } + }, + }) + if err != nil { + return nil, err + } + if foundErr != nil { + return nil, foundErr + } + if !found || len(versions) == 0 { + return nil, ObjectNotFound{Bucket: bucket, Object: object} + } + if int64(len(versions)) > scannerExcessObjectVersions.Load() { + return nil, errAccessTierTooManyVersion + } + for _, version := range versions { + if version.IsRemote() { + return nil, errAccessTierRemoteVersion + } + } + if len(versions) == 1 && versions[0].Deleted { + return nil, errAccessTierNotEligible + } + versionsSorter(versions).reverse() + return versions, nil +} + +func accessObjectBytes(ctx context.Context, z *erasureServerPools, src int, bucket, object string) (uint64, error) { + versions, err := accessObjectVersions(ctx, z, src, bucket, object) + if err != nil { + return 0, err + } + var total uint64 + for _, version := range versions { + if !version.Deleted && version.Size > 0 { + total = addUsage(total, uint64(version.Size)) + } + } + return total, nil +} + +func deleteAccessTierPoolObject(ctx context.Context, z *erasureServerPools, pool int, bucket, object string) error { + _, err := z.serverPools[pool].DeleteObject(ctx, bucket, encodeDirObject(object), ObjectOptions{ + DeletePrefix: true, DeletePrefixObject: true, NoLock: true, NoAuditLog: true, + }) + return err +} + +// rollbackAccessTierDestination removes only what this move wrote. +// +// A blanket prefix delete would be wrong: the top-level namespace lock is +// taken on pool 0 / set 0 (see erasureServerPools.NewNSLock), while a client +// PutObject locks the hashed set of whichever pool it lands in, and lockers +// are per set. A concurrent client write can therefore land on the +// destination while a move is in flight, and it must survive our rollback. +// +// Versioned writes are removed by exact version ID, which can never touch a +// client's version. An unversioned write has no ID to target, so it is only +// removed while it still carries this move's own marker stamp. +func rollbackAccessTierDestination(ctx context.Context, z *erasureServerPools, dst int, bucket, object string, written []string, movedAt int64) { + stamp := accessTierStamp(dst, movedAt) + for _, versionID := range written { + if versionID == "" { + // Unversioned bucket: a single version with no ID. Only drop it + // if it is still the copy this move made. + oi, err := z.serverPools[dst].GetObjectInfo(ctx, bucket, encodeDirObject(object), ObjectOptions{NoLock: true}) + if err != nil { + if !isErrObjectNotFound(err) && !isErrVersionNotFound(err) { + ilmLogIf(ctx, fmt.Errorf("access tier rollback failed for %s/%s in pool %d: %w", bucket, object, dst, err)) + } + continue + } + if oi.UserDefined[accessTierMetadataKey] != stamp { + ilmLogIf(ctx, fmt.Errorf("access tier rollback skipped for %s/%s in pool %d: destination was overwritten concurrently", bucket, object, dst)) + continue + } + if err := deleteAccessTierPoolObject(ctx, z, dst, bucket, object); err != nil { + ilmLogIf(ctx, fmt.Errorf("access tier rollback failed for %s/%s in pool %d: %w", bucket, object, dst, err)) + } + continue + } + _, err := z.serverPools[dst].DeleteObject(ctx, bucket, encodeDirObject(object), ObjectOptions{ + Versioned: true, VersionID: versionID, NoLock: true, NoAuditLog: true, + }) + if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) { + ilmLogIf(ctx, fmt.Errorf("access tier rollback failed for %s/%s version %s in pool %d: %w", bucket, object, versionID, dst, err)) + } + } +} + +// moveObjectPool copies the complete version stack oldest-first and removes +// the source only after every destination write succeeded. +// +// The namespace lock excludes concurrent deletes, which take the same +// top-level lock. It does not exclude a concurrent PutObject, which locks the +// hashed set of its target pool; see rollbackAccessTierDestination for how +// that is contained. +func moveObjectPool(ctx context.Context, z *erasureServerPools, bucket, object string, src, dst int, beforeCopy func(ObjectInfo, uint64) error) (uint64, error) { + if bucket == minioMetaBucket || src == dst || src < 0 || dst < 0 || src >= len(z.serverPools) || dst >= len(z.serverPools) { + return 0, errAccessTierNotEligible + } + if z.IsRebalanceStarted(ctx) || z.IsDecommissionRunning() || z.IsSuspended(dst) || z.IsPoolRebalancing(dst) { + return 0, errAccessTierNotEligible + } + + lk := z.NewNSLock(bucket, object) + lkctx, err := lk.GetLock(ctx, globalDeleteOperationTimeout) + if err != nil { + return 0, err + } + ctx = lkctx.Context() + defer lk.Unlock(lkctx) + + versions, err := accessObjectVersions(ctx, z, src, bucket, object) + if err != nil { + return 0, err + } + versioned := globalBucketVersioningSys.PrefixEnabled(bucket, object) + latest := versions[len(versions)-1].ToObjectInfo(bucket, object, versioned) + var total uint64 + for _, version := range versions { + if !version.Deleted && version.Size > 0 { + total = addUsage(total, uint64(version.Size)) + } + } + if beforeCopy != nil { + if err := beforeCopy(latest, total); err != nil { + return 0, err + } + } + + // A failed earlier attempt may have copied the full stack but failed to + // remove the source. It is safe to discard only a destination carrying + // our internal marker; an unrelated split-brain copy is left untouched. + dstOI, dstErr := z.serverPools[dst].GetObjectInfo(ctx, bucket, encodeDirObject(object), ObjectOptions{NoLock: true}) + if dstErr == nil { + markedPool, _, marked := parseAccessTierStamp(dstOI.UserDefined) + if !marked || markedPool != dst { + return 0, errAccessTierNotEligible + } + if err := deleteAccessTierPoolObject(ctx, z, dst, bucket, object); err != nil { + return 0, err + } + } else if !isErrObjectNotFound(dstErr) && !isErrVersionNotFound(dstErr) { + return 0, dstErr + } + + movedAt := time.Now().UnixNano() + var written []string + rollbackDestination := true + defer func() { + if len(written) == 0 || !rollbackDestination { + return + } + // Roll back before releasing the namespace lock. Use a fresh timeout + // when leadership cancellation caused the original failure. + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), globalDeleteOperationTimeout.Timeout()) + defer cancel() + rollbackAccessTierDestination(cleanupCtx, z, dst, bucket, object, written, movedAt) + }() + + set := z.serverPools[src].getHashedSet(encodeDirObject(object)) + for _, version := range versions { + if err := ctx.Err(); err != nil { + return 0, err + } + versionID := version.VersionID + if versionID == "" { + versionID = nullVersionID + } + if version.Deleted { + written = append(written, version.VersionID) + _, err = z.serverPools[dst].DeleteObject(ctx, bucket, encodeDirObject(object), ObjectOptions{ + Versioned: true, VersionID: versionID, MTime: version.ModTime, + DeleteReplication: version.ReplicationState, + SrcPoolIdx: src, DstPoolIdx: &dst, DataMovement: true, + DeleteMarker: true, NoLock: true, NoAuditLog: true, + }) + if err != nil { + return 0, err + } + continue + } + + gr, err := set.GetObjectNInfo(ctx, bucket, encodeDirObject(object), nil, http.Header{}, ObjectOptions{ + VersionID: versionID, NoDecryption: true, NoLock: true, NoAuditLog: true, + }) + if err != nil { + return 0, err + } + written = append(written, version.VersionID) + if err = moveAccessTierVersion(ctx, z, src, dst, bucket, gr, movedAt); err != nil { + return 0, err + } + } + + // Re-read while still holding the namespace lock and refuse the source + // delete if the latest version changed despite the lock contract. + current, err := z.serverPools[src].GetObjectInfo(ctx, bucket, encodeDirObject(object), ObjectOptions{NoLock: true}) + if err != nil { + return 0, err + } + if !current.ModTime.Equal(latest.ModTime) || current.VersionID != latest.VersionID || current.ETag != latest.ETag || current.DeleteMarker != latest.DeleteMarker { + return 0, errAccessTierNotEligible + } + // Once source deletion starts, an error can be an uncertain commit. Keep + // the complete destination in that case: preserving two copies is safer + // than risking zero. + rollbackDestination = false + err = deleteAccessTierPoolObject(ctx, z, src, bucket, object) + if err != nil { + return 0, err + } + return total, nil +} + +func moveAccessTierVersion(ctx context.Context, z *erasureServerPools, src, dst int, bucket string, gr *GetObjectReader, movedAt int64) (err error) { + oi := gr.ObjInfo + defer gr.Close() + + actualSize, err := oi.GetActualSize() + if err != nil { + return err + } + metadata := maps.Clone(oi.UserDefined) + if metadata == nil { + metadata = make(map[string]string, 1) + } + metadata[accessTierMetadataKey] = accessTierStamp(dst, movedAt) + + if oi.isMultipart() { + res, err := z.NewMultipartUpload(ctx, bucket, oi.Name, ObjectOptions{ + VersionID: oi.VersionID, UserDefined: metadata, NoAuditLog: true, + NoLock: true, DataMovement: true, SrcPoolIdx: src, DstPoolIdx: &dst, + }) + if err != nil { + return err + } + abort := true + defer func() { + if abort { + z.AbortMultipartUpload(ctx, bucket, oi.Name, res.UploadID, ObjectOptions{NoAuditLog: true}) + } + }() + parts := make([]CompletePart, len(oi.Parts)) + for i, part := range oi.Parts { + hr, err := hash.NewReader(ctx, io.LimitReader(gr, part.Size), part.Size, "", "", part.ActualSize) + if err != nil { + return err + } + pi, err := z.PutObjectPart(ctx, bucket, oi.Name, res.UploadID, part.Number, NewPutObjReader(hr), ObjectOptions{ + PreserveETag: part.ETag, + IndexCB: func() []byte { return part.Index }, + NoAuditLog: true, NoLock: true, + }) + if err != nil { + return err + } + parts[i] = CompletePart{ + ETag: pi.ETag, PartNumber: pi.PartNumber, + ChecksumCRC32: pi.ChecksumCRC32, ChecksumCRC32C: pi.ChecksumCRC32C, + ChecksumSHA1: pi.ChecksumSHA1, ChecksumSHA256: pi.ChecksumSHA256, + } + } + _, err = z.CompleteMultipartUpload(ctx, bucket, oi.Name, res.UploadID, parts, ObjectOptions{ + SrcPoolIdx: src, DstPoolIdx: &dst, DataMovement: true, + VersionID: oi.VersionID, MTime: oi.ModTime, NoLock: true, NoAuditLog: true, + }) + if err != nil { + return err + } + abort = false + return nil + } + + hr, err := hash.NewReader(ctx, io.LimitReader(gr, oi.Size), oi.Size, "", "", actualSize) + if err != nil { + return err + } + opts := ObjectOptions{ + SrcPoolIdx: src, DstPoolIdx: &dst, DataMovement: true, + VersionID: oi.VersionID, MTime: oi.ModTime, UserDefined: metadata, + PreserveETag: oi.ETag, NoLock: true, NoAuditLog: true, + } + if len(oi.Parts) > 0 { + opts.IndexCB = func() []byte { return oi.Parts[0].Index } + } + _, err = z.PutObject(ctx, bucket, oi.Name, NewPutObjReader(hr), opts) + return err +} diff --git a/cmd/ilm-access-tier_test.go b/cmd/ilm-access-tier_test.go new file mode 100644 index 0000000000000..981c47149777b --- /dev/null +++ b/cmd/ilm-access-tier_test.go @@ -0,0 +1,263 @@ +// Copyright (c) 2015-2026 MinIO, Inc. + +package cmd + +import ( + "bytes" + "context" + "errors" + "strconv" + "strings" + "testing" + "time" + + "github.com/klauspost/compress/zstd" + "github.com/minio/minio/internal/bucket/lifecycle" + "github.com/minio/minio/internal/config/ilm" + "github.com/tinylib/msgp/msgp" +) + +func accessLifecycleForTest(t *testing.T) *lifecycle.Lifecycle { + t.Helper() + xml := "" + + "accessEnabled" + + "10m100" + + "51h" + + "" + lc, err := lifecycle.ParseLifecycleConfig(strings.NewReader(xml)) + if err != nil { + t.Fatal(err) + } + return lc +} + +func TestAccessTierStampAndDemoteEligibility(t *testing.T) { + oldTracker := globalAccessTracker + globalAccessTracker = newAccessTracker() + t.Cleanup(func() { globalAccessTracker = oldTracker }) + + now := time.Now() + cfg := ilm.Config{ + AccessTiering: true, AccessPools: []int{0, 1}, + AccessBinWidth: time.Minute, AccessBins: 12, + AccessMinResidency: 30 * time.Minute, + } + oi := ObjectInfo{ + Bucket: "bucket", Name: "object", Size: 10, IsLatest: true, + UserDefined: map[string]string{ + accessTierMetadataKey: "0:" + strconv.FormatInt(now.Add(-2*time.Hour).UnixNano(), 10), + }, + } + if !accessDemoteEligible(accessLifecycleForTest(t), oi, 0, cfg, now) { + t.Fatal("cold, resident object should be demotion eligible") + } + oi.UserDefined[accessTierMetadataKey] = "0:" + strconv.FormatInt(now.Add(-time.Minute).UnixNano(), 10) + if accessDemoteEligible(accessLifecycleForTest(t), oi, 0, cfg, now) { + t.Fatal("object inside minimum residency was eligible") + } + oi.UserDefined[accessTierMetadataKey] = "broken" + if accessDemoteEligible(accessLifecycleForTest(t), oi, 0, cfg, now) { + t.Fatal("object with malformed marker was eligible") + } +} + +func TestAccessTierReservationsEnforceCaps(t *testing.T) { + state := newAccessTierState(t.Context()) + state.usageReady = true + state.baseUsage["a"] = 80 + state.baseTotal = 180 + + cfg := ilm.Config{AccessMaxSize: 200} + task := accessTierTask{ctx: t.Context(), bucket: "a", object: "one", bytes: 30} + if reason, ok := state.reservePromotion(task, cfg, 0); ok || reason != "max-size" { + t.Fatalf("max-size reserve = %q/%v", reason, ok) + } + + cfg.AccessMaxSize = 0 + if reason, ok := state.reservePromotion(task, cfg, 100); ok || reason != "quota" { + t.Fatalf("quota reserve = %q/%v", reason, ok) + } + + task.bytes = 20 + if reason, ok := state.reservePromotion(task, cfg, 100); !ok || reason != "" { + t.Fatalf("valid reserve = %q/%v", reason, ok) + } + if got := state.bucketUsageLocked("a"); got != 100 { + t.Fatalf("reserved bucket usage = %d, want 100", got) + } + state.releasePending(task, true) +} + +func TestDataMovementDestinationIsGated(t *testing.T) { + dst := 0 + if got := dataMovementDstPool(ObjectOptions{DstPoolIdx: &dst}); got != nil { + t.Fatal("destination honored without DataMovement") + } + if got := dataMovementDstPool(ObjectOptions{DataMovement: true, DstPoolIdx: &dst}); got == nil || *got != 0 { + t.Fatalf("destination = %v", got) + } +} + +func TestForcedDestinationPoolSelection(t *testing.T) { + z := &erasureServerPools{serverPools: make([]*erasureSets, 2)} + dst := 1 + if got, err := z.getPoolIdx(context.Background(), "bucket", "object", 1, &dst); err != nil || got != dst { + t.Fatalf("destination = %d, err = %v", got, err) + } + bad := 2 + if _, err := z.getPoolIdx(context.Background(), "bucket", "object", 1, &bad); !errors.Is(err, errInvalidArgument) { + t.Fatalf("out-of-range error = %v", err) + } + z.poolMeta.Pools = make([]PoolStatus, 2) + z.poolMeta.Pools[1].Decommission = &PoolDecommissionInfo{} + if _, err := z.getPoolIdx(context.Background(), "bucket", "object", 1, &dst); err == nil { + t.Fatal("suspended destination was accepted") + } +} + +func TestHotTierAccounting(t *testing.T) { + entry := dataUsageEntry{} + entry.addSizes(sizeSummary{totalSize: 100, hotTierSize: 60, versions: 1}) + entry.merge(dataUsageEntry{Size: 50, HotTierSize: 25}) + if entry.Size != 150 || entry.HotTierSize != 85 { + t.Fatalf("usage = size:%d hot:%d", entry.Size, entry.HotTierSize) + } +} + +func TestScannerCyclesApart(t *testing.T) { + tests := []struct { + current, previous uint32 + want uint32 + }{ + {current: 12, previous: 10, want: 2}, + {current: 0, previous: ^uint32(0), want: 1}, + // A cycle counter reset is not evidence that a complete pass covered + // recent moves, so it must not release conservative deltas. + {current: 1, previous: 100, want: 0}, + } + for _, tt := range tests { + if got := scannerCyclesApart(tt.current, tt.previous); got != tt.want { + t.Fatalf("scannerCyclesApart(%d, %d) = %d, want %d", tt.current, tt.previous, got, tt.want) + } + } +} + +func TestDataUsageCacheV8Migration(t *testing.T) { + var encoded bytes.Buffer + if err := encoded.WriteByte(dataUsageCacheVerV8); err != nil { + t.Fatal(err) + } + zw, err := zstd.NewWriter(&encoded) + if err != nil { + t.Fatal(err) + } + mw := msgp.NewWriter(zw) + if err = mw.WriteMapHeader(2); err != nil { + t.Fatal(err) + } + if err = mw.WriteString("Info"); err != nil { + t.Fatal(err) + } + info := dataUsageCacheInfo{Name: dataUsageRoot, NextCycle: 17, LastUpdate: time.Now().UTC()} + if err = info.EncodeMsg(mw); err != nil { + t.Fatal(err) + } + if err = mw.WriteString("Cache"); err != nil { + t.Fatal(err) + } + if err = mw.WriteMapHeader(1); err != nil { + t.Fatal(err) + } + if err = mw.WriteString("entry"); err != nil { + t.Fatal(err) + } + // A v8 entry has no hts field. Encoding only populated fields also + // verifies that its map decoder retains normal msgpack compatibility. + if err = mw.WriteMapHeader(2); err != nil { + t.Fatal(err) + } + if err = mw.WriteString("sz"); err != nil { + t.Fatal(err) + } + if err = mw.WriteInt64(123); err != nil { + t.Fatal(err) + } + if err = mw.WriteString("os"); err != nil { + t.Fatal(err) + } + if err = mw.WriteUint64(2); err != nil { + t.Fatal(err) + } + if err = mw.Flush(); err != nil { + t.Fatal(err) + } + if err = zw.Close(); err != nil { + t.Fatal(err) + } + + var migrated dataUsageCache + if err = migrated.deserialize(bytes.NewReader(encoded.Bytes())); err != nil { + t.Fatal(err) + } + entry := migrated.Cache["entry"] + if entry.Size != 123 || entry.Objects != 2 || entry.HotTierSize != 0 { + t.Fatalf("migrated entry = size:%d objects:%d hot:%d", entry.Size, entry.Objects, entry.HotTierSize) + } + if migrated.Info.NextCycle != 17 || !migrated.Info.LastUpdate.Equal(info.LastUpdate) { + t.Fatalf("migrated cache info = %+v", migrated.Info) + } +} + +// The stamp written on every moved version and the stamp the rollback path +// matches against must agree, otherwise rollback silently skips its own work. +func TestAccessTierStampRoundTrip(t *testing.T) { + movedAt := time.Now().UnixNano() + stamp := accessTierStamp(3, movedAt) + + pool, at, ok := parseAccessTierStamp(map[string]string{accessTierMetadataKey: stamp}) + if !ok { + t.Fatalf("stamp %q did not parse", stamp) + } + if pool != 3 { + t.Fatalf("pool = %d, want 3", pool) + } + if at.UnixNano() != movedAt { + t.Fatalf("movedAt = %d, want %d", at.UnixNano(), movedAt) + } + // A different move of the same object must not match, so a concurrent + // client overwrite is never mistaken for our own copy. + if stamp == accessTierStamp(3, movedAt+1) { + t.Fatal("stamps from distinct moves collided") + } + if stamp == accessTierStamp(4, movedAt) { + t.Fatal("stamps from distinct pools collided") + } +} + +func TestAccessHitsMightPromote(t *testing.T) { + lc := accessLifecycleForTest(t) + hot := accessEntry{Bins: []uint32{100}, HeadAt: 0} + cold := accessEntry{Bins: []uint32{1}, HeadAt: 0} + if !accessHitsMightPromote(lc, "object", hot, 60) { + t.Fatal("object above promote threshold was rejected") + } + if accessHitsMightPromote(lc, "object", cold, 60) { + t.Fatal("object below promote threshold was accepted") + } + + xml := "logsEnabled" + + "logs/" + + "10m100" + + "51h" + + "" + prefixed, err := lifecycle.ParseLifecycleConfig(strings.NewReader(xml)) + if err != nil { + t.Fatal(err) + } + if accessHitsMightPromote(prefixed, "data/object", hot, 60) { + t.Fatal("object outside the rule prefix was accepted") + } + if !accessHitsMightPromote(prefixed, "logs/object", hot, 60) { + t.Fatal("object inside the rule prefix was rejected") + } +} diff --git a/cmd/ilm-access-tracker.go b/cmd/ilm-access-tracker.go new file mode 100644 index 0000000000000..11a8c5bdaffd2 --- /dev/null +++ b/cmd/ilm-access-tracker.go @@ -0,0 +1,598 @@ +// Copyright (c) 2015-2026 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 . + +package cmd + +import ( + "cmp" + "context" + "fmt" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/minio/minio/internal/config/ilm" + "github.com/zeebo/xxh3" +) + +//go:generate msgp -file=$GOFILE -unexported +//msgp:ignore accessTracker mergedAccess + +const ( + // accessTrackerPrefix is where each node publishes its own counters. + // One object per node, merged by every node on a timer. + accessTrackerPrefix = minioConfigPrefix + "/ilm/access" + + // accessQueueSize bounds the GET -> tracker handoff. Overflow drops + // samples rather than slowing down reads. + accessQueueSize = 100000 + + // accessMaxDemoteCandidates bounds what the scanner may hand over in a + // single flush interval. + accessMaxDemoteCandidates = 100000 + + // accessShardStaleFactor multiplies the flush interval to decide when a + // peer's counters are too old to trust, e.g. after a node is removed. + accessShardStaleFactor = 5 +) + +func accessShardFresh(now int64, shard accessShard, stale int64) bool { + if shard.UpdatedAt <= 0 { + return false + } + if stale <= 0 { + return true + } + age := now - shard.UpdatedAt + return age >= -stale && age <= stale +} + +// accessEntry is a rolling hit counter for one object. Bins[0] is the current +// bin and each subsequent bin is one bin-width older, so a rule asking for +// "100 hits in 10 minutes" sums the newest ceil(10m/binWidth) bins. +// +// A fixed window is used rather than an exponentially decayed score because +// the rule is stated to operators in exactly those terms. +type accessEntry struct { + Bins []uint32 `msg:"b"` + HeadAt int64 `msg:"h"` // unix seconds at the start of Bins[0] + LastAt int64 `msg:"l"` // unix seconds of the most recent hit +} + +// demoteCandidate is an object the scanner found sitting on a fast pool with +// no recent reads. Candidates ride the node's own counter shard so they reach +// the leader without a new peer RPC. +type demoteCandidate struct { + Bucket string `msg:"b"` + Object string `msg:"o"` + Pool int `msg:"p"` +} + +// accessShard is what one node publishes. BinWidth is carried so a peer that +// has not yet picked up a configuration change is ignored rather than merged +// with mismatched bins. +type accessShard struct { + UpdatedAt int64 `msg:"u"` + BinWidth int64 `msg:"bw"` + Entries map[string]accessEntry `msg:"e"` + Demote []demoteCandidate `msg:"d"` +} + +// binStart truncates a unix timestamp to the start of its bin. +func binStart(now, binWidth int64) int64 { + if binWidth <= 0 { + return now + } + return now - now%binWidth +} + +// rollTo advances the counter to now, zeroing the bins that elapsed since the +// last update and resizing if the configured bin count changed. +func (e *accessEntry) rollTo(now, binWidth int64, nbins int) { + if nbins <= 0 || binWidth <= 0 { + return + } + if len(e.Bins) != nbins { + resized := make([]uint32, nbins) + copy(resized, e.Bins) + e.Bins = resized + } + head := binStart(now, binWidth) + if e.HeadAt == 0 { + e.HeadAt = head + return + } + steps := (head - e.HeadAt) / binWidth + if steps <= 0 { + return + } + if steps >= int64(nbins) { + clear(e.Bins) + } else { + copy(e.Bins[steps:], e.Bins[:nbins-int(steps)]) + clear(e.Bins[:steps]) + } + e.HeadAt = head +} + +// hits returns the number of accesses recorded over the newest bins covering +// window. A window longer than the configured history is clamped to it. +// +// The newest bin is partial, so the covered span is between window-binWidth +// and window. Operators tune resolution with ilm access_bin_width. +func (e accessEntry) hits(window time.Duration, binWidth int64) uint64 { + if binWidth <= 0 || len(e.Bins) == 0 { + return 0 + } + n := int((int64(window/time.Second) + binWidth - 1) / binWidth) + if n < 1 { + n = 1 + } + if n > len(e.Bins) { + n = len(e.Bins) + } + var total uint64 + for _, v := range e.Bins[:n] { + total += uint64(v) + } + return total +} + +// total is the whole retained history, used to decide what to evict. +func (e accessEntry) total() uint64 { + var t uint64 + for _, v := range e.Bins { + t += uint64(v) + } + return t +} + +// mergeFrom adds another node's counters for the same object. Both sides must +// already be rolled to the same head. +func (e *accessEntry) mergeFrom(o accessEntry) { + for i := range e.Bins { + if i < len(o.Bins) { + total := uint64(e.Bins[i]) + uint64(o.Bins[i]) + if total > uint64(^uint32(0)) { + total = uint64(^uint32(0)) + } + e.Bins[i] = uint32(total) + } + } + if o.LastAt > e.LastAt { + e.LastAt = o.LastAt + } +} + +// mergedAccess is an immutable cluster-wide snapshot. Readers take it from an +// atomic pointer, so the hot scanner and sweep paths never take a lock. +type mergedAccess struct { + entries map[string]accessEntry + binWidth int64 + at int64 +} + +func (m *mergedAccess) hits(key string, window time.Duration) uint64 { + if m == nil { + return 0 + } + e, ok := m.entries[key] + if !ok { + return 0 + } + return e.hits(window, m.binWidth) +} + +func (m *mergedAccess) lastAccess(key string) int64 { + if m == nil { + return 0 + } + return m.entries[key].LastAt +} + +// accessTracker records how often each object is read. +// +// Ownership is deliberately narrow: the live counter map is touched only by +// run()'s goroutine, so it needs no lock. Everything read from elsewhere goes +// through the immutable merged snapshot. +type accessTracker struct { + ch chan string + enabled atomic.Bool + merged atomic.Pointer[mergedAccess] + + // Demote candidates arrive from scanner goroutines, so this one does + // need a lock. It is small: only objects we previously promoted. + demoteMu sync.Mutex + demote map[string]demoteCandidate + + dropped atomic.Uint64 +} + +var globalAccessTracker = newAccessTracker() + +func newAccessTracker() *accessTracker { + return &accessTracker{ + ch: make(chan string, accessQueueSize), + demote: make(map[string]demoteCandidate), + } +} + +// accessKey is the tracker's map key. Bucket names cannot contain '/', so the +// join is unambiguous. +func accessKey(bucket, object string) string { + return bucket + "/" + object +} + +func splitAccessKey(key string) (bucket, object string, ok bool) { + bucket, object, ok = strings.Cut(key, "/") + if !ok || bucket == "" || object == "" { + return "", "", false + } + return bucket, object, true +} + +// note records one read. It is called from the GET path and must never block +// or allocate meaningfully: on a full queue the sample is dropped. +func (t *accessTracker) note(bucket, object string) { + if t == nil || !t.enabled.Load() { + return + } + select { + case t.ch <- accessKey(bucket, object): + default: + t.dropped.Add(1) + } +} + +// noteDemoteCandidate is called by the scanner for an object it found on a +// fast pool that has gone quiet. The leader picks these up on the next merge. +func (t *accessTracker) noteDemoteCandidate(bucket, object string, pool int) { + if t == nil || !t.enabled.Load() { + return + } + t.demoteMu.Lock() + defer t.demoteMu.Unlock() + if len(t.demote) >= accessMaxDemoteCandidates { + return + } + t.demote[accessKey(bucket, object)] = demoteCandidate{Bucket: bucket, Object: object, Pool: pool} +} + +// hits reports cluster-wide accesses to an object over window. +func (t *accessTracker) hits(bucket, object string, window time.Duration) uint64 { + if t == nil { + return 0 + } + return t.merged.Load().hits(accessKey(bucket, object), window) +} + +// lastAccess reports the cluster-wide time an object was last read. A zero +// time means "no read on record", which for demotion purposes is idle. +func (t *accessTracker) lastAccess(bucket, object string) time.Time { + if t == nil { + return time.Time{} + } + sec := t.merged.Load().lastAccess(accessKey(bucket, object)) + if sec == 0 { + return time.Time{} + } + return time.Unix(sec, 0) +} + +// snapshot returns the current merged view, or nil if none has been published. +func (t *accessTracker) snapshot() *mergedAccess { + if t == nil { + return nil + } + return t.merged.Load() +} + +// takeDemoteCandidates drains and returns the pending candidates. +func (t *accessTracker) takeDemoteCandidates() []demoteCandidate { + t.demoteMu.Lock() + defer t.demoteMu.Unlock() + if len(t.demote) == 0 { + return nil + } + out := make([]demoteCandidate, 0, len(t.demote)) + for _, c := range t.demote { + out = append(out, c) + } + t.demote = make(map[string]demoteCandidate) + return out +} + +// restoreDemoteCandidates puts candidates back when the publisher is still +// busy. Dropping access samples is acceptable; dropping the only scanner +// discovery of an idle promoted object would delay demotion by a full scan. +func (t *accessTracker) restoreDemoteCandidates(candidates []demoteCandidate) { + if len(candidates) == 0 { + return + } + t.demoteMu.Lock() + defer t.demoteMu.Unlock() + for _, c := range candidates { + if len(t.demote) >= accessMaxDemoteCandidates { + return + } + t.demote[accessKey(c.Bucket, c.Object)] = c + } +} + +// shardName is this node's counter object. The node name is hashed so that +// host:port never has to be escaped into an object key. +func (t *accessTracker) shardName() string { + return fmt.Sprintf("%s/%016x.bin", accessTrackerPrefix, xxh3.HashString(globalLocalNodeName)) +} + +// run owns the live counter map. It drains reads, and on every flush interval +// hands a marshaled shard to a background publisher. +// +// Everything here is best effort: this is accounting for a background data +// movement decision, not a durability path. +func (t *accessTracker) run(ctx context.Context, objAPI ObjectLayer) { + cfg := globalILMConfig.accessCfg() + t.enabled.Store(cfg.AccessTiering) + + live := make(map[string]accessEntry) + if cfg.AccessTiering { + if buf, err := readConfig(ctx, objAPI, t.shardName()); err == nil { + var previous accessShard + if _, err = previous.UnmarshalMsg(buf); err == nil && previous.BinWidth == int64(cfg.AccessBinWidth/time.Second) { + now := time.Now().Unix() + for key, entry := range previous.Entries { + entry.rollTo(now, previous.BinWidth, cfg.AccessBins) + if entry.total() != 0 { + live[key] = entry + } + } + } + } + } + ticker := time.NewTicker(cfg.AccessFlush) + defer ticker.Stop() + + // One publisher goroutine keeps object-layer I/O off the drain loop. + type pub struct { + shard accessShard + cfg ilm.Config + } + pubCh := make(chan pub, 1) + go func() { + for p := range pubCh { + t.publish(ctx, objAPI, p.shard, p.cfg) + } + }() + defer close(pubCh) + + for { + select { + case <-ctx.Done(): + return + + case key := <-t.ch: + now := time.Now().Unix() + e := live[key] + e.rollTo(now, int64(cfg.AccessBinWidth/time.Second), cfg.AccessBins) + if len(e.Bins) > 0 { + if e.Bins[0] != ^uint32(0) { + e.Bins[0]++ + } + } + e.LastAt = now + live[key] = e + + case <-ticker.C: + newCfg := globalILMConfig.accessCfg() + t.enabled.Store(newCfg.AccessTiering) + if newCfg.AccessFlush != cfg.AccessFlush && newCfg.AccessFlush > 0 { + ticker.Reset(newCfg.AccessFlush) + } + cfg = newCfg + if !cfg.AccessTiering { + // Feature turned off: release the counters rather than + // holding a stale working set for the process lifetime. + clear(live) + t.merged.Store(nil) + continue + } + + now := time.Now().Unix() + binWidth := int64(cfg.AccessBinWidth / time.Second) + t.evict(live, now, binWidth, cfg) + + shard := accessShard{ + UpdatedAt: now, + BinWidth: binWidth, + Entries: make(map[string]accessEntry, len(live)), + Demote: t.takeDemoteCandidates(), + } + for k, e := range live { + e.Bins = slices.Clone(e.Bins) + shard.Entries[k] = e + } + select { + case pubCh <- pub{shard: shard, cfg: cfg}: + default: + // Previous publish still running; skip this round + // rather than queueing work we cannot keep up with. + t.restoreDemoteCandidates(shard.Demote) + } + } + } +} + +// evict rolls every counter forward and drops the ones with no hits left in +// the retained history, then enforces the tracked-object cap. +// +// ponytail: amortized O(n) sweep on the flush tick; a heap would only pay off +// past ~10M tracked keys. +func (t *accessTracker) evict(live map[string]accessEntry, now, binWidth int64, cfg ilm.Config) { + for k, e := range live { + e.rollTo(now, binWidth, cfg.AccessBins) + if e.total() == 0 { + delete(live, k) + continue + } + live[k] = e + } + if len(live) <= cfg.AccessMaxTracked { + return + } + // Over the cap: keep the hottest and drop the rest. Objects that fall + // out are cold by construction, which is exactly what the demotion path + // already assumes about anything missing from the map. + type kt struct { + key string + total uint64 + } + all := make([]kt, 0, len(live)) + for k, e := range live { + all = append(all, kt{k, e.total()}) + } + slices.SortFunc(all, func(a, b kt) int { return cmp.Compare(b.total, a.total) }) + for _, x := range all[cfg.AccessMaxTracked:] { + delete(live, x.key) + } +} + +// publish writes this node's shard and rebuilds the merged snapshot from every +// node's shard. Both halves are best effort. +func (t *accessTracker) publish(ctx context.Context, objAPI ObjectLayer, shard accessShard, cfg ilm.Config) { + buf, err := shard.MarshalMsg(nil) + if err != nil { + ilmLogIf(ctx, err) + return + } + if err := saveConfig(ctx, objAPI, t.shardName(), buf); err != nil { + ilmLogIf(ctx, err) + // Still rebuild the snapshot below: our own counters are already + // in hand and a stale peer view beats no view. + } + t.merged.Store(t.mergeShards(ctx, objAPI, shard, cfg)) +} + +// mergeShards sums every live node's counters, including our own in-memory +// shard so this node's most recent reads are never a flush behind. +func (t *accessTracker) mergeShards(ctx context.Context, objAPI ObjectLayer, own accessShard, cfg ilm.Config) *mergedAccess { + now := time.Now().Unix() + binWidth := int64(cfg.AccessBinWidth / time.Second) + out := &mergedAccess{ + entries: make(map[string]accessEntry, len(own.Entries)), + binWidth: binWidth, + at: now, + } + + add := func(s accessShard) { + if s.BinWidth != binWidth { + // A peer has not yet picked up a bin-width change; merging + // its bins would silently mis-scale the counts. + return + } + for k, e := range s.Entries { + e.rollTo(now, binWidth, cfg.AccessBins) + cur, ok := out.entries[k] + if !ok { + cur = accessEntry{Bins: make([]uint32, cfg.AccessBins)} + } + cur.mergeFrom(e) + out.entries[k] = cur + } + } + + add(own) + + ownName := t.shardName() + stale := int64(cfg.AccessFlush/time.Second) * accessShardStaleFactor + for _, name := range t.listShards(ctx, objAPI) { + if name == ownName { + continue + } + buf, err := readConfig(ctx, objAPI, name) + if err != nil { + continue + } + var s accessShard + if _, err := s.UnmarshalMsg(buf); err != nil { + continue + } + if !accessShardFresh(now, s, stale) { + continue // node is gone or wedged + } + add(s) + } + return out +} + +func (t *accessTracker) listShards(ctx context.Context, objAPI ObjectLayer) []string { + res, err := objAPI.ListObjects(ctx, minioMetaBucket, accessTrackerPrefix+"/", "", "", maxObjectList) + if err != nil { + return nil + } + names := make([]string, 0, len(res.Objects)) + for _, o := range res.Objects { + if strings.HasSuffix(o.Name, ".bin") { + names = append(names, o.Name) + } + } + return names +} + +// collectDemoteCandidates returns every node's pending demote candidates. Only +// the leader calls this, right before running a demotion pass. +// +// Our own shard is read back rather than skipped: run() drains the local map +// into the published shard, so anything found since the last leader pass lives +// there, not in memory. The local map is still drained here to pick up +// candidates recorded since that publish. +func (t *accessTracker) collectDemoteCandidates(ctx context.Context, objAPI ObjectLayer, cfg ilm.Config) []demoteCandidate { + seen := make(map[string]struct{}) + var out []demoteCandidate + + for _, c := range t.takeDemoteCandidates() { + key := accessKey(c.Bucket, c.Object) + seen[key] = struct{}{} + out = append(out, c) + } + + now := time.Now().Unix() + stale := int64(cfg.AccessFlush/time.Second) * accessShardStaleFactor + for _, name := range t.listShards(ctx, objAPI) { + buf, err := readConfig(ctx, objAPI, name) + if err != nil { + continue + } + var s accessShard + if _, err := s.UnmarshalMsg(buf); err != nil { + continue + } + if !accessShardFresh(now, s, stale) { + continue + } + for _, c := range s.Demote { + key := accessKey(c.Bucket, c.Object) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, c) + } + } + return out +} diff --git a/cmd/ilm-access-tracker_gen.go b/cmd/ilm-access-tracker_gen.go new file mode 100644 index 0000000000000..e533e3cacfa01 --- /dev/null +++ b/cmd/ilm-access-tracker_gen.go @@ -0,0 +1,742 @@ +// Code generated by github.com/tinylib/msgp DO NOT EDIT. + +package cmd + +import ( + "github.com/tinylib/msgp/msgp" +) + +// DecodeMsg implements msgp.Decodable +func (z *accessEntry) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "b": + var zb0002 uint32 + zb0002, err = dc.ReadArrayHeader() + if err != nil { + err = msgp.WrapError(err, "Bins") + return + } + if cap(z.Bins) >= int(zb0002) { + z.Bins = (z.Bins)[:zb0002] + } else { + z.Bins = make([]uint32, zb0002) + } + for za0001 := range z.Bins { + z.Bins[za0001], err = dc.ReadUint32() + if err != nil { + err = msgp.WrapError(err, "Bins", za0001) + return + } + } + case "h": + z.HeadAt, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "HeadAt") + return + } + case "l": + z.LastAt, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "LastAt") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z *accessEntry) EncodeMsg(en *msgp.Writer) (err error) { + // map header, size 3 + // write "b" + err = en.Append(0x83, 0xa1, 0x62) + if err != nil { + return + } + err = en.WriteArrayHeader(uint32(len(z.Bins))) + if err != nil { + err = msgp.WrapError(err, "Bins") + return + } + for za0001 := range z.Bins { + err = en.WriteUint32(z.Bins[za0001]) + if err != nil { + err = msgp.WrapError(err, "Bins", za0001) + return + } + } + // write "h" + err = en.Append(0xa1, 0x68) + if err != nil { + return + } + err = en.WriteInt64(z.HeadAt) + if err != nil { + err = msgp.WrapError(err, "HeadAt") + return + } + // write "l" + err = en.Append(0xa1, 0x6c) + if err != nil { + return + } + err = en.WriteInt64(z.LastAt) + if err != nil { + err = msgp.WrapError(err, "LastAt") + return + } + return +} + +// MarshalMsg implements msgp.Marshaler +func (z *accessEntry) MarshalMsg(b []byte) (o []byte, err error) { + o = msgp.Require(b, z.Msgsize()) + // map header, size 3 + // string "b" + o = append(o, 0x83, 0xa1, 0x62) + o = msgp.AppendArrayHeader(o, uint32(len(z.Bins))) + for za0001 := range z.Bins { + o = msgp.AppendUint32(o, z.Bins[za0001]) + } + // string "h" + o = append(o, 0xa1, 0x68) + o = msgp.AppendInt64(o, z.HeadAt) + // string "l" + o = append(o, 0xa1, 0x6c) + o = msgp.AppendInt64(o, z.LastAt) + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *accessEntry) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "b": + var zb0002 uint32 + zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Bins") + return + } + if cap(z.Bins) >= int(zb0002) { + z.Bins = (z.Bins)[:zb0002] + } else { + z.Bins = make([]uint32, zb0002) + } + for za0001 := range z.Bins { + z.Bins[za0001], bts, err = msgp.ReadUint32Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "Bins", za0001) + return + } + } + case "h": + z.HeadAt, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "HeadAt") + return + } + case "l": + z.LastAt, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "LastAt") + return + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *accessEntry) Msgsize() (s int) { + s = 1 + 2 + msgp.ArrayHeaderSize + (len(z.Bins) * (msgp.Uint32Size)) + 2 + msgp.Int64Size + 2 + msgp.Int64Size + return +} + +// DecodeMsg implements msgp.Decodable +func (z *accessShard) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "u": + z.UpdatedAt, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "UpdatedAt") + return + } + case "bw": + z.BinWidth, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "BinWidth") + return + } + case "e": + var zb0002 uint32 + zb0002, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err, "Entries") + return + } + if z.Entries == nil { + z.Entries = make(map[string]accessEntry, zb0002) + } else if len(z.Entries) > 0 { + clear(z.Entries) + } + for zb0002 > 0 { + zb0002-- + var za0001 string + za0001, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Entries") + return + } + var za0002 accessEntry + err = za0002.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, "Entries", za0001) + return + } + z.Entries[za0001] = za0002 + } + case "d": + var zb0003 uint32 + zb0003, err = dc.ReadArrayHeader() + if err != nil { + err = msgp.WrapError(err, "Demote") + return + } + if cap(z.Demote) >= int(zb0003) { + z.Demote = (z.Demote)[:zb0003] + } else { + z.Demote = make([]demoteCandidate, zb0003) + } + for za0003 := range z.Demote { + var zb0004 uint32 + zb0004, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err, "Demote", za0003) + return + } + for zb0004 > 0 { + zb0004-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err, "Demote", za0003) + return + } + switch msgp.UnsafeString(field) { + case "b": + z.Demote[za0003].Bucket, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Demote", za0003, "Bucket") + return + } + case "o": + z.Demote[za0003].Object, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Demote", za0003, "Object") + return + } + case "p": + z.Demote[za0003].Pool, err = dc.ReadInt() + if err != nil { + err = msgp.WrapError(err, "Demote", za0003, "Pool") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err, "Demote", za0003) + return + } + } + } + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z *accessShard) EncodeMsg(en *msgp.Writer) (err error) { + // map header, size 4 + // write "u" + err = en.Append(0x84, 0xa1, 0x75) + if err != nil { + return + } + err = en.WriteInt64(z.UpdatedAt) + if err != nil { + err = msgp.WrapError(err, "UpdatedAt") + return + } + // write "bw" + err = en.Append(0xa2, 0x62, 0x77) + if err != nil { + return + } + err = en.WriteInt64(z.BinWidth) + if err != nil { + err = msgp.WrapError(err, "BinWidth") + return + } + // write "e" + err = en.Append(0xa1, 0x65) + if err != nil { + return + } + err = en.WriteMapHeader(uint32(len(z.Entries))) + if err != nil { + err = msgp.WrapError(err, "Entries") + return + } + for za0001, za0002 := range z.Entries { + err = en.WriteString(za0001) + if err != nil { + err = msgp.WrapError(err, "Entries") + return + } + err = za0002.EncodeMsg(en) + if err != nil { + err = msgp.WrapError(err, "Entries", za0001) + return + } + } + // write "d" + err = en.Append(0xa1, 0x64) + if err != nil { + return + } + err = en.WriteArrayHeader(uint32(len(z.Demote))) + if err != nil { + err = msgp.WrapError(err, "Demote") + return + } + for za0003 := range z.Demote { + // map header, size 3 + // write "b" + err = en.Append(0x83, 0xa1, 0x62) + if err != nil { + return + } + err = en.WriteString(z.Demote[za0003].Bucket) + if err != nil { + err = msgp.WrapError(err, "Demote", za0003, "Bucket") + return + } + // write "o" + err = en.Append(0xa1, 0x6f) + if err != nil { + return + } + err = en.WriteString(z.Demote[za0003].Object) + if err != nil { + err = msgp.WrapError(err, "Demote", za0003, "Object") + return + } + // write "p" + err = en.Append(0xa1, 0x70) + if err != nil { + return + } + err = en.WriteInt(z.Demote[za0003].Pool) + if err != nil { + err = msgp.WrapError(err, "Demote", za0003, "Pool") + return + } + } + return +} + +// MarshalMsg implements msgp.Marshaler +func (z *accessShard) MarshalMsg(b []byte) (o []byte, err error) { + o = msgp.Require(b, z.Msgsize()) + // map header, size 4 + // string "u" + o = append(o, 0x84, 0xa1, 0x75) + o = msgp.AppendInt64(o, z.UpdatedAt) + // string "bw" + o = append(o, 0xa2, 0x62, 0x77) + o = msgp.AppendInt64(o, z.BinWidth) + // string "e" + o = append(o, 0xa1, 0x65) + o = msgp.AppendMapHeader(o, uint32(len(z.Entries))) + for za0001, za0002 := range z.Entries { + o = msgp.AppendString(o, za0001) + o, err = za0002.MarshalMsg(o) + if err != nil { + err = msgp.WrapError(err, "Entries", za0001) + return + } + } + // string "d" + o = append(o, 0xa1, 0x64) + o = msgp.AppendArrayHeader(o, uint32(len(z.Demote))) + for za0003 := range z.Demote { + // map header, size 3 + // string "b" + o = append(o, 0x83, 0xa1, 0x62) + o = msgp.AppendString(o, z.Demote[za0003].Bucket) + // string "o" + o = append(o, 0xa1, 0x6f) + o = msgp.AppendString(o, z.Demote[za0003].Object) + // string "p" + o = append(o, 0xa1, 0x70) + o = msgp.AppendInt(o, z.Demote[za0003].Pool) + } + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *accessShard) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "u": + z.UpdatedAt, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "UpdatedAt") + return + } + case "bw": + z.BinWidth, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "BinWidth") + return + } + case "e": + var zb0002 uint32 + zb0002, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Entries") + return + } + if z.Entries == nil { + z.Entries = make(map[string]accessEntry, zb0002) + } else if len(z.Entries) > 0 { + clear(z.Entries) + } + for zb0002 > 0 { + var za0002 accessEntry + zb0002-- + var za0001 string + za0001, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Entries") + return + } + bts, err = za0002.UnmarshalMsg(bts) + if err != nil { + err = msgp.WrapError(err, "Entries", za0001) + return + } + z.Entries[za0001] = za0002 + } + case "d": + var zb0003 uint32 + zb0003, bts, err = msgp.ReadArrayHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Demote") + return + } + if cap(z.Demote) >= int(zb0003) { + z.Demote = (z.Demote)[:zb0003] + } else { + z.Demote = make([]demoteCandidate, zb0003) + } + for za0003 := range z.Demote { + var zb0004 uint32 + zb0004, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Demote", za0003) + return + } + for zb0004 > 0 { + zb0004-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err, "Demote", za0003) + return + } + switch msgp.UnsafeString(field) { + case "b": + z.Demote[za0003].Bucket, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Demote", za0003, "Bucket") + return + } + case "o": + z.Demote[za0003].Object, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Demote", za0003, "Object") + return + } + case "p": + z.Demote[za0003].Pool, bts, err = msgp.ReadIntBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Demote", za0003, "Pool") + return + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err, "Demote", za0003) + return + } + } + } + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *accessShard) Msgsize() (s int) { + s = 1 + 2 + msgp.Int64Size + 3 + msgp.Int64Size + 2 + msgp.MapHeaderSize + if z.Entries != nil { + for za0001, za0002 := range z.Entries { + _ = za0002 + s += msgp.StringPrefixSize + len(za0001) + za0002.Msgsize() + } + } + s += 2 + msgp.ArrayHeaderSize + for za0003 := range z.Demote { + s += 1 + 2 + msgp.StringPrefixSize + len(z.Demote[za0003].Bucket) + 2 + msgp.StringPrefixSize + len(z.Demote[za0003].Object) + 2 + msgp.IntSize + } + return +} + +// DecodeMsg implements msgp.Decodable +func (z *demoteCandidate) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "b": + z.Bucket, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Bucket") + return + } + case "o": + z.Object, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Object") + return + } + case "p": + z.Pool, err = dc.ReadInt() + if err != nil { + err = msgp.WrapError(err, "Pool") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z demoteCandidate) EncodeMsg(en *msgp.Writer) (err error) { + // map header, size 3 + // write "b" + err = en.Append(0x83, 0xa1, 0x62) + if err != nil { + return + } + err = en.WriteString(z.Bucket) + if err != nil { + err = msgp.WrapError(err, "Bucket") + return + } + // write "o" + err = en.Append(0xa1, 0x6f) + if err != nil { + return + } + err = en.WriteString(z.Object) + if err != nil { + err = msgp.WrapError(err, "Object") + return + } + // write "p" + err = en.Append(0xa1, 0x70) + if err != nil { + return + } + err = en.WriteInt(z.Pool) + if err != nil { + err = msgp.WrapError(err, "Pool") + return + } + return +} + +// MarshalMsg implements msgp.Marshaler +func (z demoteCandidate) MarshalMsg(b []byte) (o []byte, err error) { + o = msgp.Require(b, z.Msgsize()) + // map header, size 3 + // string "b" + o = append(o, 0x83, 0xa1, 0x62) + o = msgp.AppendString(o, z.Bucket) + // string "o" + o = append(o, 0xa1, 0x6f) + o = msgp.AppendString(o, z.Object) + // string "p" + o = append(o, 0xa1, 0x70) + o = msgp.AppendInt(o, z.Pool) + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *demoteCandidate) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "b": + z.Bucket, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Bucket") + return + } + case "o": + z.Object, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Object") + return + } + case "p": + z.Pool, bts, err = msgp.ReadIntBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Pool") + return + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z demoteCandidate) Msgsize() (s int) { + s = 1 + 2 + msgp.StringPrefixSize + len(z.Bucket) + 2 + msgp.StringPrefixSize + len(z.Object) + 2 + msgp.IntSize + return +} diff --git a/cmd/ilm-access-tracker_gen_test.go b/cmd/ilm-access-tracker_gen_test.go new file mode 100644 index 0000000000000..85ab06a4ed723 --- /dev/null +++ b/cmd/ilm-access-tracker_gen_test.go @@ -0,0 +1,349 @@ +// Code generated by github.com/tinylib/msgp DO NOT EDIT. + +package cmd + +import ( + "bytes" + "testing" + + "github.com/tinylib/msgp/msgp" +) + +func TestMarshalUnmarshalaccessEntry(t *testing.T) { + v := accessEntry{} + bts, err := v.MarshalMsg(nil) + if err != nil { + t.Fatal(err) + } + left, err := v.UnmarshalMsg(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left) + } + + left, err = msgp.Skip(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after Skip(): %q", len(left), left) + } +} + +func BenchmarkMarshalMsgaccessEntry(b *testing.B) { + v := accessEntry{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + v.MarshalMsg(nil) + } +} + +func BenchmarkAppendMsgaccessEntry(b *testing.B) { + v := accessEntry{} + bts := make([]byte, 0, v.Msgsize()) + bts, _ = v.MarshalMsg(bts[0:0]) + b.SetBytes(int64(len(bts))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + bts, _ = v.MarshalMsg(bts[0:0]) + } +} + +func BenchmarkUnmarshalaccessEntry(b *testing.B) { + v := accessEntry{} + bts, _ := v.MarshalMsg(nil) + b.ReportAllocs() + b.SetBytes(int64(len(bts))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := v.UnmarshalMsg(bts) + if err != nil { + b.Fatal(err) + } + } +} + +func TestEncodeDecodeaccessEntry(t *testing.T) { + v := accessEntry{} + var buf bytes.Buffer + msgp.Encode(&buf, &v) + + m := v.Msgsize() + if buf.Len() > m { + t.Log("WARNING: TestEncodeDecodeaccessEntry Msgsize() is inaccurate") + } + + vn := accessEntry{} + err := msgp.Decode(&buf, &vn) + if err != nil { + t.Error(err) + } + + buf.Reset() + msgp.Encode(&buf, &v) + err = msgp.NewReader(&buf).Skip() + if err != nil { + t.Error(err) + } +} + +func BenchmarkEncodeaccessEntry(b *testing.B) { + v := accessEntry{} + var buf bytes.Buffer + msgp.Encode(&buf, &v) + b.SetBytes(int64(buf.Len())) + en := msgp.NewWriter(msgp.Nowhere) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + v.EncodeMsg(en) + } + en.Flush() +} + +func BenchmarkDecodeaccessEntry(b *testing.B) { + v := accessEntry{} + var buf bytes.Buffer + msgp.Encode(&buf, &v) + b.SetBytes(int64(buf.Len())) + rd := msgp.NewEndlessReader(buf.Bytes(), b) + dc := msgp.NewReader(rd) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := v.DecodeMsg(dc) + if err != nil { + b.Fatal(err) + } + } +} + +func TestMarshalUnmarshalaccessShard(t *testing.T) { + v := accessShard{} + bts, err := v.MarshalMsg(nil) + if err != nil { + t.Fatal(err) + } + left, err := v.UnmarshalMsg(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left) + } + + left, err = msgp.Skip(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after Skip(): %q", len(left), left) + } +} + +func BenchmarkMarshalMsgaccessShard(b *testing.B) { + v := accessShard{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + v.MarshalMsg(nil) + } +} + +func BenchmarkAppendMsgaccessShard(b *testing.B) { + v := accessShard{} + bts := make([]byte, 0, v.Msgsize()) + bts, _ = v.MarshalMsg(bts[0:0]) + b.SetBytes(int64(len(bts))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + bts, _ = v.MarshalMsg(bts[0:0]) + } +} + +func BenchmarkUnmarshalaccessShard(b *testing.B) { + v := accessShard{} + bts, _ := v.MarshalMsg(nil) + b.ReportAllocs() + b.SetBytes(int64(len(bts))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := v.UnmarshalMsg(bts) + if err != nil { + b.Fatal(err) + } + } +} + +func TestEncodeDecodeaccessShard(t *testing.T) { + v := accessShard{} + var buf bytes.Buffer + msgp.Encode(&buf, &v) + + m := v.Msgsize() + if buf.Len() > m { + t.Log("WARNING: TestEncodeDecodeaccessShard Msgsize() is inaccurate") + } + + vn := accessShard{} + err := msgp.Decode(&buf, &vn) + if err != nil { + t.Error(err) + } + + buf.Reset() + msgp.Encode(&buf, &v) + err = msgp.NewReader(&buf).Skip() + if err != nil { + t.Error(err) + } +} + +func BenchmarkEncodeaccessShard(b *testing.B) { + v := accessShard{} + var buf bytes.Buffer + msgp.Encode(&buf, &v) + b.SetBytes(int64(buf.Len())) + en := msgp.NewWriter(msgp.Nowhere) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + v.EncodeMsg(en) + } + en.Flush() +} + +func BenchmarkDecodeaccessShard(b *testing.B) { + v := accessShard{} + var buf bytes.Buffer + msgp.Encode(&buf, &v) + b.SetBytes(int64(buf.Len())) + rd := msgp.NewEndlessReader(buf.Bytes(), b) + dc := msgp.NewReader(rd) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := v.DecodeMsg(dc) + if err != nil { + b.Fatal(err) + } + } +} + +func TestMarshalUnmarshaldemoteCandidate(t *testing.T) { + v := demoteCandidate{} + bts, err := v.MarshalMsg(nil) + if err != nil { + t.Fatal(err) + } + left, err := v.UnmarshalMsg(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left) + } + + left, err = msgp.Skip(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after Skip(): %q", len(left), left) + } +} + +func BenchmarkMarshalMsgdemoteCandidate(b *testing.B) { + v := demoteCandidate{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + v.MarshalMsg(nil) + } +} + +func BenchmarkAppendMsgdemoteCandidate(b *testing.B) { + v := demoteCandidate{} + bts := make([]byte, 0, v.Msgsize()) + bts, _ = v.MarshalMsg(bts[0:0]) + b.SetBytes(int64(len(bts))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + bts, _ = v.MarshalMsg(bts[0:0]) + } +} + +func BenchmarkUnmarshaldemoteCandidate(b *testing.B) { + v := demoteCandidate{} + bts, _ := v.MarshalMsg(nil) + b.ReportAllocs() + b.SetBytes(int64(len(bts))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := v.UnmarshalMsg(bts) + if err != nil { + b.Fatal(err) + } + } +} + +func TestEncodeDecodedemoteCandidate(t *testing.T) { + v := demoteCandidate{} + var buf bytes.Buffer + msgp.Encode(&buf, &v) + + m := v.Msgsize() + if buf.Len() > m { + t.Log("WARNING: TestEncodeDecodedemoteCandidate Msgsize() is inaccurate") + } + + vn := demoteCandidate{} + err := msgp.Decode(&buf, &vn) + if err != nil { + t.Error(err) + } + + buf.Reset() + msgp.Encode(&buf, &v) + err = msgp.NewReader(&buf).Skip() + if err != nil { + t.Error(err) + } +} + +func BenchmarkEncodedemoteCandidate(b *testing.B) { + v := demoteCandidate{} + var buf bytes.Buffer + msgp.Encode(&buf, &v) + b.SetBytes(int64(buf.Len())) + en := msgp.NewWriter(msgp.Nowhere) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + v.EncodeMsg(en) + } + en.Flush() +} + +func BenchmarkDecodedemoteCandidate(b *testing.B) { + v := demoteCandidate{} + var buf bytes.Buffer + msgp.Encode(&buf, &v) + b.SetBytes(int64(buf.Len())) + rd := msgp.NewEndlessReader(buf.Bytes(), b) + dc := msgp.NewReader(rd) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := v.DecodeMsg(dc) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/cmd/ilm-access-tracker_test.go b/cmd/ilm-access-tracker_test.go new file mode 100644 index 0000000000000..32955ecb3439a --- /dev/null +++ b/cmd/ilm-access-tracker_test.go @@ -0,0 +1,95 @@ +// Copyright (c) 2015-2026 MinIO, Inc. + +package cmd + +import ( + "math" + "testing" + "time" + + "github.com/minio/minio/internal/config/ilm" +) + +func TestAccessEntryRollAndHits(t *testing.T) { + entry := accessEntry{Bins: []uint32{3, 2, 1}, HeadAt: 100, LastAt: 107} + entry.rollTo(120, 10, 3) + want := []uint32{0, 0, 3} + for i := range want { + if entry.Bins[i] != want[i] { + t.Fatalf("bins = %v, want %v", entry.Bins, want) + } + } + if entry.HeadAt != 120 || entry.LastAt != 107 { + t.Fatalf("head/last = %d/%d", entry.HeadAt, entry.LastAt) + } + + entry = accessEntry{Bins: []uint32{10, 20, 30}, HeadAt: 120} + if got := entry.hits(20*time.Second, 10); got != 30 { + t.Fatalf("20s hits = %d, want 30", got) + } + if got := entry.hits(21*time.Second, 10); got != 60 { + t.Fatalf("21s hits = %d, want 60", got) + } + entry.rollTo(200, 10, 3) + if got := entry.total(); got != 0 { + t.Fatalf("expired total = %d, want 0", got) + } +} + +func TestAccessEntryMergeSaturates(t *testing.T) { + entry := accessEntry{Bins: []uint32{math.MaxUint32 - 1}} + entry.mergeFrom(accessEntry{Bins: []uint32{10}, LastAt: 50}) + if entry.Bins[0] != math.MaxUint32 || entry.LastAt != 50 { + t.Fatalf("merged entry = %+v", entry) + } +} + +func TestAccessTrackerEvictsColdest(t *testing.T) { + tracker := newAccessTracker() + live := map[string]accessEntry{ + "a": {Bins: []uint32{1}, HeadAt: 100}, + "b": {Bins: []uint32{5}, HeadAt: 100}, + "c": {Bins: []uint32{3}, HeadAt: 100}, + } + tracker.evict(live, 100, 10, ilm.Config{AccessBins: 1, AccessMaxTracked: 2}) + if len(live) != 2 { + t.Fatalf("len = %d, want 2", len(live)) + } + if _, ok := live["a"]; ok { + t.Fatal("coldest entry was retained") + } +} + +func TestAccessKeyRoundTrip(t *testing.T) { + key := accessKey("bucket", "a/b/c") + bucket, object, ok := splitAccessKey(key) + if !ok || bucket != "bucket" || object != "a/b/c" { + t.Fatalf("split %q = %q/%q/%v", key, bucket, object, ok) + } +} + +func TestAccessShardFresh(t *testing.T) { + const now = int64(1000) + if !accessShardFresh(now, accessShard{UpdatedAt: 950}, 100) { + t.Fatal("fresh shard rejected") + } + if accessShardFresh(now, accessShard{UpdatedAt: 899}, 100) { + t.Fatal("stale shard accepted") + } + if accessShardFresh(now, accessShard{UpdatedAt: 1101}, 100) { + t.Fatal("far-future shard accepted") + } + if accessShardFresh(now, accessShard{}, 100) { + t.Fatal("zero timestamp accepted") + } +} + +func TestAccessTrackerRestoresDemoteCandidates(t *testing.T) { + tracker := newAccessTracker() + candidate := demoteCandidate{Bucket: "bucket", Object: "object", Pool: 1} + tracker.restoreDemoteCandidates([]demoteCandidate{candidate}) + got := tracker.takeDemoteCandidates() + if len(got) != 1 || got[0] != candidate { + t.Fatalf("restored candidates = %+v, want %+v", got, candidate) + } +} diff --git a/cmd/ilm-config.go b/cmd/ilm-config.go index 83e7e305b392c..03b4d0d7c1ffa 100644 --- a/cmd/ilm-config.go +++ b/cmd/ilm-config.go @@ -19,6 +19,7 @@ package cmd import ( "sync" + "time" "github.com/minio/minio/internal/config/ilm" ) @@ -27,6 +28,16 @@ var globalILMConfig = ilmConfig{ cfg: ilm.Config{ ExpirationWorkers: 100, TransitionWorkers: 100, + // Access tiering stays off until configured, but the counter + // geometry must be sane from the start: the tracker divides by + // AccessBinWidth before any config is loaded. + AccessPromoteWatermark: 85, + AccessBinWidth: time.Minute, + AccessBins: 12, + AccessFlush: time.Minute, + AccessMinResidency: 24 * time.Hour, + AccessWorkers: 10, + AccessMaxTracked: 1000000, }, } @@ -49,6 +60,25 @@ func (c *ilmConfig) getTransitionWorkers() int { return c.cfg.TransitionWorkers } +// accessCfg returns a copy of the access tiering settings. Callers take the +// whole struct rather than one getter per field because the promotion and +// demotion paths need a consistent view of several knobs at once. +func (c *ilmConfig) accessCfg() ilm.Config { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.cfg +} + +// accessTieringEnabled is the cheap gate used on the scanner path. +func (c *ilmConfig) accessTieringEnabled() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + _, ok := c.cfg.HotPool() + return ok +} + func (c *ilmConfig) update(cfg ilm.Config) { c.mu.Lock() defer c.mu.Unlock() diff --git a/cmd/lceventsrc_string.go b/cmd/lceventsrc_string.go index 3d859dc405125..17cda8a14b978 100644 --- a/cmd/lceventsrc_string.go +++ b/cmd/lceventsrc_string.go @@ -19,11 +19,12 @@ func _() { _ = x[lcEventSrc_s3PutObject-8] _ = x[lcEventSrc_s3CopyObject-9] _ = x[lcEventSrc_s3CompleteMultipartUpload-10] + _ = x[lcEventSrc_AccessTier-11] } -const _lcEventSrc_name = "NoneHealScannerDecomRebals3HeadObjects3GetObjects3ListObjectss3PutObjects3CopyObjects3CompleteMultipartUpload" +const _lcEventSrc_name = "NoneHealScannerDecomRebals3HeadObjects3GetObjects3ListObjectss3PutObjects3CopyObjects3CompleteMultipartUploadAccessTier" -var _lcEventSrc_index = [...]uint8{0, 4, 8, 15, 20, 25, 37, 48, 61, 72, 84, 109} +var _lcEventSrc_index = [...]uint8{0, 4, 8, 15, 20, 25, 37, 48, 61, 72, 84, 109, 119} func (i lcEventSrc) String() string { idx := int(i) - 0 diff --git a/cmd/metrics-v3-ilm.go b/cmd/metrics-v3-ilm.go index 604beaae78cb2..f4beed6820455 100644 --- a/cmd/metrics-v3-ilm.go +++ b/cmd/metrics-v3-ilm.go @@ -26,6 +26,17 @@ const ( transitionActiveTasks = "transition_active_tasks" transitionPendingTasks = "transition_pending_tasks" transitionMissedImmediateTasks = "transition_missed_immediate_tasks" + accessTierActiveTasks = "access_tier_active_tasks" + accessTierPendingTasks = "access_tier_pending_tasks" + accessTierPromotionsTotal = "access_tier_promotions_total" + accessTierDemotionsTotal = "access_tier_demotions_total" + accessTierBytesMovedTotal = "access_tier_bytes_moved_total" + accessTierFailuresTotal = "access_tier_failures_total" + accessTierSkippedWatermark = "access_tier_skipped_watermark_total" + accessTierSkippedMaxSize = "access_tier_skipped_max_size_total" + accessTierSkippedQuota = "access_tier_skipped_bucket_quota_total" + accessTierHotBytes = "access_tier_hot_bytes" + accessTierSamplesDropped = "access_tier_samples_dropped_total" versionsScanned = "versions_scanned" ) @@ -34,6 +45,17 @@ var ( ilmTransitionActiveTasksMD = NewGaugeMD(transitionActiveTasks, "Number of active ILM transition tasks") ilmTransitionPendingTasksMD = NewGaugeMD(transitionPendingTasks, "Number of pending ILM transition tasks in the queue") ilmTransitionMissedImmediateTasksMD = NewCounterMD(transitionMissedImmediateTasks, "Number of missed immediate ILM transition tasks") + ilmAccessTierActiveTasksMD = NewGaugeMD(accessTierActiveTasks, "Number of active access-tier pool moves") + ilmAccessTierPendingTasksMD = NewGaugeMD(accessTierPendingTasks, "Number of pending access-tier pool moves") + ilmAccessTierPromotionsTotalMD = NewCounterMD(accessTierPromotionsTotal, "Total objects promoted by access-tier ILM") + ilmAccessTierDemotionsTotalMD = NewCounterMD(accessTierDemotionsTotal, "Total objects demoted by access-tier ILM") + ilmAccessTierBytesMovedTotalMD = NewCounterMD(accessTierBytesMovedTotal, "Total logical bytes moved by access-tier ILM") + ilmAccessTierFailuresTotalMD = NewCounterMD(accessTierFailuresTotal, "Total failed access-tier ILM moves") + ilmAccessTierSkippedWatermarkMD = NewCounterMD(accessTierSkippedWatermark, "Promotions skipped because the hot pool reached its watermark") + ilmAccessTierSkippedMaxSizeMD = NewCounterMD(accessTierSkippedMaxSize, "Promotions skipped because the cluster hot-tier size cap was reached") + ilmAccessTierSkippedQuotaMD = NewCounterMD(accessTierSkippedQuota, "Promotions skipped because the bucket hot-tier quota was reached") + ilmAccessTierHotBytesMD = NewGaugeMD(accessTierHotBytes, "Logical bytes currently accounted to the hot tier", "bucket") + ilmAccessTierSamplesDroppedMD = NewCounterMD(accessTierSamplesDropped, "GET samples dropped because the access tracker queue was full") ilmVersionsScannedMD = NewCounterMD(versionsScanned, "Total number of object versions checked for ILM actions since server start") ) @@ -47,6 +69,21 @@ func loadILMMetrics(_ context.Context, m MetricValues, _ *metricsCache) error { m.Set(transitionPendingTasks, float64(globalTransitionState.PendingTasks())) m.Set(transitionMissedImmediateTasks, float64(globalTransitionState.MissedImmediateTasks())) } + if globalAccessTierState != nil { + m.Set(accessTierActiveTasks, float64(globalAccessTierState.ActiveTasks())) + m.Set(accessTierPendingTasks, float64(globalAccessTierState.PendingTasks())) + m.Set(accessTierPromotionsTotal, float64(globalAccessTierState.promotions.Load())) + m.Set(accessTierDemotionsTotal, float64(globalAccessTierState.demotions.Load())) + m.Set(accessTierBytesMovedTotal, float64(globalAccessTierState.bytesMoved.Load())) + m.Set(accessTierFailuresTotal, float64(globalAccessTierState.failures.Load())) + m.Set(accessTierSkippedWatermark, float64(globalAccessTierState.skippedWatermark.Load())) + m.Set(accessTierSkippedMaxSize, float64(globalAccessTierState.skippedMaxSize.Load())) + m.Set(accessTierSkippedQuota, float64(globalAccessTierState.skippedQuota.Load())) + for bucket, bytes := range globalAccessTierState.hotUsageSnapshot() { + m.Set(accessTierHotBytes, float64(bytes), "bucket", bucket) + } + } + m.Set(accessTierSamplesDropped, float64(globalAccessTracker.dropped.Load())) m.Set(versionsScanned, float64(globalScannerMetrics.lifetime(scannerMetricILM))) return nil diff --git a/cmd/metrics-v3.go b/cmd/metrics-v3.go index 93749258cb11a..5b578448d4364 100644 --- a/cmd/metrics-v3.go +++ b/cmd/metrics-v3.go @@ -390,6 +390,17 @@ func newMetricGroups(r *prometheus.Registry) *metricsV3Collection { ilmTransitionActiveTasksMD, ilmTransitionPendingTasksMD, ilmTransitionMissedImmediateTasksMD, + ilmAccessTierActiveTasksMD, + ilmAccessTierPendingTasksMD, + ilmAccessTierPromotionsTotalMD, + ilmAccessTierDemotionsTotalMD, + ilmAccessTierBytesMovedTotalMD, + ilmAccessTierFailuresTotalMD, + ilmAccessTierSkippedWatermarkMD, + ilmAccessTierSkippedMaxSizeMD, + ilmAccessTierSkippedQuotaMD, + ilmAccessTierHotBytesMD, + ilmAccessTierSamplesDroppedMD, ilmVersionsScannedMD, }, loadILMMetrics, diff --git a/cmd/object-api-interface.go b/cmd/object-api-interface.go index 06c73741078d3..565b10fd134b7 100644 --- a/cmd/object-api-interface.go +++ b/cmd/object-api-interface.go @@ -117,6 +117,10 @@ type ObjectOptions struct { SkipRebalancing bool SrcPoolIdx int // set by PutObject/CompleteMultipart operations due to rebalance; used to prevent rebalance src, dst pools to be the same + // DstPoolIdx forces a data-movement write onto a specific server pool. + // It is ignored unless DataMovement is true; a pointer keeps pool zero + // distinguishable from the unset value. + DstPoolIdx *int DataMovement bool // indicates an going decommisionning or rebalacing diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index 296817ed5352e..4639833c9095c 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -563,6 +563,8 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj return } + globalAccessTracker.note(bucket, object) + // Notify object accessed via a GET request. sendEvent(eventArgs{ EventName: event.ObjectAccessedGet, diff --git a/cmd/server-main.go b/cmd/server-main.go index a547581f78a67..60b1ce29dc26e 100644 --- a/cmd/server-main.go +++ b/cmd/server-main.go @@ -500,6 +500,7 @@ func initAllSubsystems(ctx context.Context) { globalTierConfigMgr = NewTierConfigMgr() globalTransitionState = newTransitionState(GlobalContext) + globalAccessTierState = newAccessTierState(GlobalContext) globalSiteResyncMetrics = newSiteResyncMetrics(GlobalContext) } @@ -1057,6 +1058,10 @@ func serverMain(ctx *cli.Context) { bootstrapTrace("globalTransitionState.Init", func() { globalTransitionState.Init(newObject) }) + bootstrapTrace("globalAccessTierState.Init", func() { + globalAccessTierState.Init(newObject) + go globalAccessTracker.run(GlobalContext, newObject) + }) go func() { // Initialize transition tier configuration manager diff --git a/cmd/xl-storage.go b/cmd/xl-storage.go index f3a6231c3daab..4049f7dc55c1f 100644 --- a/cmd/xl-storage.go +++ b/cmd/xl-storage.go @@ -583,6 +583,7 @@ func (s *xlStorage) NSScanner(ctx context.Context, cache dataUsageCache, updates } poolIdx, setIdx, _ := s.GetDiskLoc() + hotPool, hotPoolOK := globalILMConfig.accessCfg().HotPool() disks, err := objAPI.GetDisks(poolIdx, setIdx) if err != nil { @@ -592,6 +593,7 @@ func (s *xlStorage) NSScanner(ctx context.Context, cache dataUsageCache, updates cache.Info.updates = updates dataUsageInfo, err := scanDataFolder(ctx, disks, s, cache, func(item scannerItem) (sizeSummary, error) { + item.poolIdx = poolIdx // Look for `xl.meta/xl.json' at the leaf. if !strings.HasSuffix(item.Path, SlashSeparator+xlStorageFormatFile) && !strings.HasSuffix(item.Path, SlashSeparator+xlStorageFormatFileV1) { @@ -655,6 +657,9 @@ func (s *xlStorage) NSScanner(ctx context.Context, cache dataUsageCache, updates sizeS.versions++ } sizeS.totalSize += sz + if hotPoolOK && poolIdx == hotPool { + sizeS.hotTierSize += sz + } // Skip tier accounting if object version is a delete-marker or a free-version // tracking deleted transitioned objects diff --git a/docs/bucket/lifecycle/README.md b/docs/bucket/lifecycle/README.md index de63333695612..3e5f5d5dd49dc 100644 --- a/docs/bucket/lifecycle/README.md +++ b/docs/bucket/lifecycle/README.md @@ -226,6 +226,128 @@ aws s3api restore-object --bucket srcbucket \ Note that transition event notification is a Silo extension. +## 5. Access-based tiering between server pools + +Silo can move frequently read objects to a faster server pool and return them +to a slower pool after they become idle. This is different from remote ILM +transition: the object remains a native local object and all versions move +together. + +Access tiering requires at least two server pools. Pool indices follow the +order on the server command line: + +~~~sh +silo server /srv/nvme{1...4} /srv/hdd{1...8} +# pool 0 (fast) pool 1 (slow) +~~~ + +Server pools are erasure-coding expansion units, not individual drives. Each +pool should consist of internally homogeneous media. + +The feature is disabled by default. Configure the topology and safety limits +with `mc admin config set`; ILM is a dynamic subsystem, so this applies without +a restart. Environment variables (`MINIO_ILM_ACCESS_TIERING`, +`MINIO_ILM_ACCESS_POOLS`, `MINIO_ILM_ACCESS_MAX_SIZE`, +`MINIO_ILM_ACCESS_PROMOTE_WATERMARK`, `MINIO_ILM_ACCESS_BIN_WIDTH`, +`MINIO_ILM_ACCESS_BINS`, `MINIO_ILM_ACCESS_FLUSH`, +`MINIO_ILM_ACCESS_MIN_RESIDENCY`, `MINIO_ILM_ACCESS_WORKERS`, +`MINIO_ILM_ACCESS_MAX_TRACKED`) are read at process start and override the +stored config. + +~~~sh +mc admin config set local ilm \ + access_tiering=on \ + access_pools="0,1" \ + access_max_size="2TiB" \ + access_promote_watermark=85 \ + access_bin_width=1m \ + access_bins=12 \ + access_flush=1m \ + access_min_residency=24h \ + access_workers=10 \ + access_max_tracked=1000000 +~~~ + +The pool list is ordered hottest to coldest. With three or more pools, +promotion always targets the first index and demotion always targets the last; +intermediate pools are not hop targets. An access_max_size value of zero means +no cluster-wide logical-byte cap. Promotion also stops when the hottest pool +reaches access_promote_watermark. + +New PUTs still land via the usual free-space pool picker; they are not steered +onto the cold pool. Size the capacity pool larger than the hot pool so new +objects tend to land there. + +Add an AccessTransition to the bucket lifecycle XML: + +~~~xml + + 500GiB + + hot-logs + Enabled + + + logs/ + 65536 + + + + 10m + 100 + 5 + 24h + + + +~~~ + +This promotes a matching object after 100 successful GETs in 10 minutes. An +object becomes eligible to return to the coldest configured pool only after +access tiering has already moved it (the `x-minio-internal-ilm-atier` stamp), +it has stayed put for the server-wide minimum residency, it has been idle at +least 24 hours, and it has no more than 5 GETs in the window. Objects that +landed on the hot pool via a normal PUT never demote. Prefix, tag, and +object-size lifecycle filters are honored. + +Access-based moves are a parallel path: they are not lifecycle `Eval` actions +and do not appear in S3 prediction headers. If the same object is also due +for age-based remote `Transition` or expiry, that scanner action wins and +demotion discovery is skipped for that pass; promotions still run from the +GET tracker. Site replication copies expiry rules only, same as remote +Transition, so AccessTransition stays local to the cluster. + +AccessTierQuota is an optional bucket-wide cap. Promotion checks, in order: + +1. hot-pool used percentage; +2. cluster-wide access_max_size; +3. bucket AccessTierQuota. + +Demotion is not blocked by these caps and is processed before promotion. +Access moves pause during rebalance or decommission, never target a suspended +pool, skip remotely transitioned objects and objects with excessive version +counts, and recheck eligibility while holding the object namespace lock. + +The hit counter is intentionally best effort. Only successfully served GET +requests count; HEAD requests do not. Counters are merged across nodes and +bounded by access_max_tracked. A rule window longer than +access_bin_width multiplied by access_bins is clamped to retained history. + +AccessTransition and AccessTierQuota are Silo lifecycle extensions. A stock +AWS SDK that reads and rewrites the lifecycle configuration may discard +unknown fields. Use a raw signed S3 PUT lifecycle request, such as +[setup_ilm_access_tiering.sh](setup_ilm_access_tiering.sh), when installing +the rule. Save the XML above as `rule.xml`, then run: + +~~~sh +AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin \ + ./setup_ilm_access_tiering.sh http://127.0.0.1:9000 testbucket us-east-1 rule.xml +~~~ + +Access-tier activity is exposed under /minio/metrics/v3/ilm, including move +counts, moved bytes, queue depth, hot bytes per bucket, failed moves, dropped +GET samples, and separate skip counters for each capacity limit. + ## Explore Further - [MinIO Go client API reference (S3-compatible SDK)](https://pkg.go.dev/github.com/minio/minio-go/v7) diff --git a/docs/bucket/lifecycle/setup_ilm_access_tiering.sh b/docs/bucket/lifecycle/setup_ilm_access_tiering.sh new file mode 100755 index 0000000000000..250a10f882392 --- /dev/null +++ b/docs/bucket/lifecycle/setup_ilm_access_tiering.sh @@ -0,0 +1,37 @@ +#!/bin/sh + +# Install a lifecycle XML document without an SDK normalizing away Silo's +# AccessTransition and AccessTierQuota extension elements. +set -eu + +if [ "$#" -ne 4 ]; then + echo "usage: AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... $0 ENDPOINT BUCKET REGION LIFECYCLE_XML" >&2 + exit 2 +fi + +endpoint=$1 +bucket=$2 +region=$3 +lifecycle_file=$4 + +: "$AWS_ACCESS_KEY_ID" +: "$AWS_SECRET_ACCESS_KEY" + +if [ ! -r "$lifecycle_file" ]; then + echo "cannot read lifecycle document: $lifecycle_file" >&2 + exit 2 +fi + +content_md5=$(openssl dgst -md5 -binary "$lifecycle_file" | openssl base64) +endpoint=$(printf '%s' "$endpoint" | sed 's:/*$::') + +curl --fail-with-body --silent --show-error \ + --request PUT \ + --aws-sigv4 "aws:amz:$region:s3" \ + --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \ + --header "Content-MD5: $content_md5" \ + --header "Content-Type: application/xml" \ + --data-binary "@$lifecycle_file" \ + "$endpoint/$bucket?lifecycle" + +echo "installed lifecycle configuration on $bucket" diff --git a/docs/metrics/prometheus/list.md b/docs/metrics/prometheus/list.md index ec8cd6c208543..050e2577bc10f 100644 --- a/docs/metrics/prometheus/list.md +++ b/docs/metrics/prometheus/list.md @@ -225,6 +225,17 @@ For deployments with [bucket](https://silo.pgsty.com/administration/bucket-repli | `minio_node_ilm_transition_active_tasks` | Number of active ILM transition tasks. | | `minio_node_ilm_transition_pending_tasks` | Number of pending ILM transition tasks in the queue. | | `minio_node_ilm_transition_missed_immediate_tasks` | Number of missed immediate ILM transition tasks. | +| `minio_node_ilm_access_tier_active_tasks` | Number of active access-tier pool moves. | +| `minio_node_ilm_access_tier_pending_tasks` | Number of pending access-tier pool moves. | +| `minio_node_ilm_access_tier_promotions_total` | Total objects promoted by access-tier ILM. | +| `minio_node_ilm_access_tier_demotions_total` | Total objects demoted by access-tier ILM. | +| `minio_node_ilm_access_tier_bytes_moved_total` | Total logical bytes moved by access-tier ILM. | +| `minio_node_ilm_access_tier_failures_total` | Total failed access-tier ILM moves. | +| `minio_node_ilm_access_tier_skipped_watermark_total` | Promotions skipped because the hot pool reached its watermark. | +| `minio_node_ilm_access_tier_skipped_max_size_total` | Promotions skipped because the cluster hot-tier size cap was reached. | +| `minio_node_ilm_access_tier_skipped_bucket_quota_total` | Promotions skipped because the bucket hot-tier quota was reached. | +| `minio_node_ilm_access_tier_hot_bytes` | Logical bytes currently accounted to the hot tier, labeled by bucket. | +| `minio_node_ilm_access_tier_samples_dropped_total` | GET samples dropped because the access tracker queue was full. | | `minio_node_ilm_versions_scanned` | Total number of object versions checked for ilm actions since server start. | | `minio_node_ilm_action_count_delete_action` | Total action outcome of lifecycle checks since server start for deleting object | | `minio_node_ilm_action_count_delete_version_action` | Total action outcome of lifecycle checks since server start for deleting a version | diff --git a/internal/bucket/lifecycle/access-transition.go b/internal/bucket/lifecycle/access-transition.go new file mode 100644 index 0000000000000..ebdf9f325f499 --- /dev/null +++ b/internal/bucket/lifecycle/access-transition.go @@ -0,0 +1,129 @@ +// Copyright (c) 2015-2026 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 . + +package lifecycle + +import ( + "encoding/xml" + "time" +) + +var ( + errAccessInvalidDuration = Errorf("Window and DemoteAfterIdle must be valid Go durations, e.g. 10m or 24h") + errAccessInvalidWindow = Errorf("Window must be a positive duration with AccessTransition") + errAccessInvalidPromote = Errorf("PromoteAfterAccesses must be a positive integer with AccessTransition") + errAccessInvalidDemote = Errorf("DemoteAfterAccesses must be smaller than PromoteAfterAccesses and 0 or greater") + errAccessInvalidIdle = Errorf("DemoteAfterIdle must be a positive duration no shorter than Window") + errAccessInvalidQuotaSize = Errorf("AccessTierQuota must be a valid size, e.g. 500GiB") +) + +// Duration is a time.Duration that marshals to and from an XML element +// holding a Go duration string, e.g. 10m. +type Duration time.Duration + +// UnmarshalXML parses a duration string such as "10m" or "24h". +func (d *Duration) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error { + var s string + if err := dec.DecodeElement(&s, &start); err != nil { + return err + } + dur, err := time.ParseDuration(s) + if err != nil { + return errAccessInvalidDuration + } + *d = Duration(dur) + return nil +} + +// MarshalXML encodes a non-zero duration, and nothing otherwise. +func (d Duration) MarshalXML(enc *xml.Encoder, start xml.StartElement) error { + if d == 0 { + return nil + } + return enc.EncodeElement(time.Duration(d).String(), start) +} + +// D returns the value as a time.Duration. +func (d Duration) D() time.Duration { + return time.Duration(d) +} + +// AccessTransition is a Silo extension to the S3 lifecycle rule. It relocates +// an object between server pools based on how often it is read, rather than on +// its age: an object read at least PromoteAfterAccesses times within Window +// moves to the fastest configured pool, and moves back once it has been idle +// for DemoteAfterIdle and its windowed hit count has fallen to +// DemoteAfterAccesses or below. +// +// The gap between the two thresholds, together with DemoteAfterIdle and the +// server-side access_min_residency, is what keeps an object from oscillating +// between pools. +type AccessTransition struct { + XMLName xml.Name `xml:"AccessTransition"` + Window Duration `xml:"Window,omitempty"` + PromoteAfterAccesses int `xml:"PromoteAfterAccesses,omitempty"` + DemoteAfterAccesses int `xml:"DemoteAfterAccesses,omitempty"` + DemoteAfterIdle Duration `xml:"DemoteAfterIdle,omitempty"` + + set bool +} + +// IsNull returns true if no usable access transition is configured. +func (a AccessTransition) IsNull() bool { + return !a.set || a.PromoteAfterAccesses <= 0 +} + +// MarshalXML encodes an AccessTransition element, and nothing if unset. +func (a AccessTransition) MarshalXML(enc *xml.Encoder, start xml.StartElement) error { + if !a.set { + return nil + } + type accessTransitionWrapper AccessTransition + return enc.EncodeElement(accessTransitionWrapper(a), start) +} + +// UnmarshalXML decodes an AccessTransition element. +func (a *AccessTransition) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error { + type accessTransitionWrapper AccessTransition + var atw accessTransitionWrapper + if err := dec.DecodeElement(&atw, &start); err != nil { + return err + } + *a = AccessTransition(atw) + a.set = true + return nil +} + +// Validate checks the AccessTransition element. +func (a AccessTransition) Validate() error { + if !a.set { + return nil + } + if a.Window <= 0 { + return errAccessInvalidWindow + } + if a.PromoteAfterAccesses <= 0 { + return errAccessInvalidPromote + } + if a.DemoteAfterAccesses < 0 || a.DemoteAfterAccesses >= a.PromoteAfterAccesses { + return errAccessInvalidDemote + } + if a.DemoteAfterIdle <= 0 || a.DemoteAfterIdle < a.Window { + return errAccessInvalidIdle + } + return nil +} diff --git a/internal/bucket/lifecycle/access-transition_test.go b/internal/bucket/lifecycle/access-transition_test.go new file mode 100644 index 0000000000000..dd54146108069 --- /dev/null +++ b/internal/bucket/lifecycle/access-transition_test.go @@ -0,0 +1,219 @@ +// Copyright (c) 2015-2026 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 . + +package lifecycle + +import ( + "bytes" + "encoding/xml" + "strings" + "testing" + "time" + + "github.com/minio/minio/internal/bucket/object/lock" +) + +const accessTieringXML = ` + 500GiB + + hot-logs + Enabled + logs/65536 + + 10m + 100 + 5 + 24h + + +` + +func TestAccessTransitionParse(t *testing.T) { + lc, err := ParseLifecycleConfig(strings.NewReader(accessTieringXML)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if err := lc.Validate(lock.Retention{}); err != nil { + t.Fatalf("validate: %v", err) + } + if got := lc.AccessQuotaBytes(); got != 500*1024*1024*1024 { + t.Fatalf("quota = %d, want %d", got, 500*1024*1024*1024) + } + if !lc.HasAccessTransition() { + t.Fatal("HasAccessTransition = false, want true") + } + at := lc.Rules[0].AccessTransition + if at.Window.D() != 10*time.Minute { + t.Fatalf("window = %v, want 10m", at.Window.D()) + } + if at.DemoteAfterIdle.D() != 24*time.Hour { + t.Fatalf("idle = %v, want 24h", at.DemoteAfterIdle.D()) + } + if at.PromoteAfterAccesses != 100 || at.DemoteAfterAccesses != 5 { + t.Fatalf("thresholds = %d/%d, want 100/5", at.PromoteAfterAccesses, at.DemoteAfterAccesses) + } +} + +// A round trip through Marshal must preserve both the rule element and the +// bucket-wide quota, since PutBucketLifecycle stores whatever we re-encode. +func TestAccessTransitionRoundTrip(t *testing.T) { + lc, err := ParseLifecycleConfig(strings.NewReader(accessTieringXML)) + if err != nil { + t.Fatalf("parse: %v", err) + } + buf, err := xml.Marshal(lc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !bytes.Contains(buf, []byte("500GiB")) { + t.Fatalf("quota lost in round trip: %s", buf) + } + got, err := ParseLifecycleConfig(bytes.NewReader(buf)) + if err != nil { + t.Fatalf("reparse: %v", err) + } + if got.AccessQuotaBytes() != lc.AccessQuotaBytes() { + t.Fatalf("quota %d != %d", got.AccessQuotaBytes(), lc.AccessQuotaBytes()) + } + if got.Rules[0].AccessTransition != lc.Rules[0].AccessTransition { + t.Fatalf("rule %+v != %+v", got.Rules[0].AccessTransition, lc.Rules[0].AccessTransition) + } +} + +// A rule with no AccessTransition must not emit an empty element - otherwise +// every existing lifecycle config would change shape on rewrite. +func TestAccessTransitionUnsetNotMarshalled(t *testing.T) { + lc, err := ParseLifecycleConfig(strings.NewReader(` + oldEnableda/ + 3`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + buf, err := xml.Marshal(lc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if bytes.Contains(buf, []byte("AccessTransition")) || bytes.Contains(buf, []byte("AccessTierQuota")) { + t.Fatalf("unset elements emitted: %s", buf) + } + if lc.HasAccessTransition() { + t.Fatal("HasAccessTransition = true for a plain expiry rule") + } +} + +func TestAccessTransitionValidate(t *testing.T) { + tests := []struct { + name string + at AccessTransition + err error + }{ + {"ok", AccessTransition{Window: Duration(10 * time.Minute), PromoteAfterAccesses: 100, DemoteAfterAccesses: 5, DemoteAfterIdle: Duration(time.Hour), set: true}, nil}, + {"unset", AccessTransition{}, nil}, + {"zero window", AccessTransition{PromoteAfterAccesses: 100, DemoteAfterIdle: Duration(time.Hour), set: true}, errAccessInvalidWindow}, + {"zero promote", AccessTransition{Window: Duration(time.Minute), DemoteAfterIdle: Duration(time.Hour), set: true}, errAccessInvalidPromote}, + {"demote >= promote", AccessTransition{Window: Duration(time.Minute), PromoteAfterAccesses: 5, DemoteAfterAccesses: 5, DemoteAfterIdle: Duration(time.Hour), set: true}, errAccessInvalidDemote}, + {"negative demote", AccessTransition{Window: Duration(time.Minute), PromoteAfterAccesses: 5, DemoteAfterAccesses: -1, DemoteAfterIdle: Duration(time.Hour), set: true}, errAccessInvalidDemote}, + {"idle shorter than window", AccessTransition{Window: Duration(time.Hour), PromoteAfterAccesses: 5, DemoteAfterIdle: Duration(time.Minute), set: true}, errAccessInvalidIdle}, + {"zero idle", AccessTransition{Window: Duration(time.Minute), PromoteAfterAccesses: 5, set: true}, errAccessInvalidIdle}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := tc.at.Validate(); err != tc.err { + t.Fatalf("err = %v, want %v", err, tc.err) + } + }) + } +} + +func TestAccessTierQuotaInvalid(t *testing.T) { + lc, err := ParseLifecycleConfig(strings.NewReader(` + not-a-size + rEnabled3 + `)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if err := lc.Validate(lock.Retention{}); err != errAccessInvalidQuotaSize { + t.Fatalf("err = %v, want %v", err, errAccessInvalidQuotaSize) + } + // An invalid quota that somehow reached the evaluator means "unlimited", + // never "zero bytes allowed". + if got := lc.AccessQuotaBytes(); got != 0 { + t.Fatalf("quota = %d, want 0 (unlimited)", got) + } +} + +func TestAccessTransitionBadDuration(t *testing.T) { + _, err := ParseLifecycleConfig(strings.NewReader(` + rEnabled + ten minutes1 + `)) + if err == nil { + t.Fatal("expected a parse error for a malformed duration") + } +} + +// AccessRule must reuse the standard rule filtering: prefix, tags, size and +// Status all have to be honored. +func TestAccessRuleFiltering(t *testing.T) { + lc, err := ParseLifecycleConfig(strings.NewReader(accessTieringXML)) + if err != nil { + t.Fatalf("parse: %v", err) + } + tests := []struct { + name string + obj ObjectOpts + want bool + }{ + {"match", ObjectOpts{Name: "logs/a.log", Size: 1 << 20, IsLatest: true}, true}, + {"wrong prefix", ObjectOpts{Name: "data/a.log", Size: 1 << 20, IsLatest: true}, false}, + {"too small", ObjectOpts{Name: "logs/a.log", Size: 1024, IsLatest: true}, false}, + {"no name", ObjectOpts{Size: 1 << 20}, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, id, ok := lc.AccessRule(tc.obj) + if ok != tc.want { + t.Fatalf("ok = %v, want %v", ok, tc.want) + } + if ok && id != "hot-logs" { + t.Fatalf("ruleID = %q, want hot-logs", id) + } + }) + } + + lc.Rules[0].Status = Disabled + if _, _, ok := lc.AccessRule(ObjectOpts{Name: "logs/a.log", Size: 1 << 20, IsLatest: true}); ok { + t.Fatal("disabled rule still matched") + } + if lc.HasAccessTransition() { + t.Fatal("HasAccessTransition = true with only a disabled rule") + } +} + +func TestAccessTransitionCountsAsActiveRule(t *testing.T) { + lc, err := ParseLifecycleConfig(strings.NewReader(accessTieringXML)) + if err != nil { + t.Fatal(err) + } + if !lc.HasActiveRules("logs/2026") { + t.Fatal("access-only lifecycle rule was not active for its prefix") + } + if lc.HasActiveRules("data/") { + t.Fatal("access rule was active outside its prefix") + } +} diff --git a/internal/bucket/lifecycle/lifecycle.go b/internal/bucket/lifecycle/lifecycle.go index 97c4200f27762..5ee9e9e3723d5 100644 --- a/internal/bucket/lifecycle/lifecycle.go +++ b/internal/bucket/lifecycle/lifecycle.go @@ -26,6 +26,7 @@ import ( "strings" "time" + "github.com/dustin/go-humanize" "github.com/google/uuid" "github.com/minio/minio/internal/bucket/object/lock" "github.com/minio/minio/internal/bucket/replication" @@ -101,11 +102,56 @@ func (a Action) Delete() bool { // Lifecycle - Configuration for bucket lifecycle. type Lifecycle struct { - XMLName xml.Name `xml:"LifecycleConfiguration"` + XMLName xml.Name `xml:"LifecycleConfiguration"` + // AccessTierQuota caps how many bytes of this bucket access-based ILM + // may keep on the fastest pool, e.g. "500GiB". Empty means unlimited. + // It is bucket-wide rather than per-rule so it cannot be declared + // inconsistently by two rules matching the same object. + AccessTierQuota string `xml:"AccessTierQuota,omitempty"` Rules []Rule `xml:"Rule"` ExpiryUpdatedAt *time.Time `xml:"ExpiryUpdatedAt,omitempty"` } +// HasAccessTransition returns 'true' if any enabled rule carries a usable +// AccessTransition. Used as a cheap per-bucket gate before consulting the +// access tracker. +func (lc Lifecycle) HasAccessTransition() bool { + for _, rule := range lc.Rules { + if rule.Status == Disabled { + continue + } + if !rule.AccessTransition.IsNull() { + return true + } + } + return false +} + +// AccessRule returns the first enabled rule matching obj that carries a usable +// AccessTransition, along with its rule ID. +func (lc Lifecycle) AccessRule(obj ObjectOpts) (AccessTransition, string, bool) { + for _, rule := range lc.FilterRules(obj) { + if !rule.AccessTransition.IsNull() { + return rule.AccessTransition, rule.ID, true + } + } + return AccessTransition{}, "", false +} + +// AccessQuotaBytes returns the bucket-wide fast-pool byte cap, 0 meaning +// unlimited. The value is validated at PUT time, so a parse failure here is +// treated as unlimited rather than as an error. +func (lc Lifecycle) AccessQuotaBytes() uint64 { + if lc.AccessTierQuota == "" { + return 0 + } + sz, err := humanize.ParseBytes(lc.AccessTierQuota) + if err != nil { + return 0 + } + return sz +} + // HasTransition returns 'true' if lifecycle document has Transition enabled. func (lc Lifecycle) HasTransition() bool { for _, rule := range lc.Rules { @@ -158,6 +204,12 @@ func (lc *Lifecycle) UnmarshalXML(d *xml.Decoder, start xml.StartElement) (err e return err } lc.ExpiryUpdatedAt = &t + case "AccessTierQuota": + var q string + if err = d.DecodeElement(&q, &se); err != nil { + return err + } + lc.AccessTierQuota = q default: return xml.UnmarshalError(fmt.Sprintf("expected element type but have <%s>", se.Name.Local)) } @@ -208,6 +260,9 @@ func (lc Lifecycle) HasActiveRules(prefix string) bool { if !rule.Transition.IsNull() { // this allows for Transition.Days to be zero. return true } + if !rule.AccessTransition.IsNull() { + return true + } } return false } @@ -248,6 +303,12 @@ func (lc Lifecycle) Validate(lr lock.Retention) error { return errLifecycleNoRule } + if lc.AccessTierQuota != "" { + if _, err := humanize.ParseBytes(lc.AccessTierQuota); err != nil { + return errAccessInvalidQuotaSize + } + } + // Validate all the rules in the lifecycle config for _, r := range lc.Rules { if err := r.Validate(); err != nil { diff --git a/internal/bucket/lifecycle/rule.go b/internal/bucket/lifecycle/rule.go index 2fb006066ca05..0ca5669a3959c 100644 --- a/internal/bucket/lifecycle/rule.go +++ b/internal/bucket/lifecycle/rule.go @@ -41,6 +41,7 @@ type Rule struct { Expiration Expiration `xml:"Expiration,omitempty"` Transition Transition `xml:"Transition,omitempty"` DelMarkerExpiration DelMarkerExpiration `xml:"DelMarkerExpiration,omitempty"` + AccessTransition AccessTransition `xml:"AccessTransition,omitempty"` // FIXME: add a type to catch unsupported AbortIncompleteMultipartUpload AbortIncompleteMultipartUpload `xml:"AbortIncompleteMultipartUpload,omitempty"` NoncurrentVersionExpiration NoncurrentVersionExpiration `xml:"NoncurrentVersionExpiration,omitempty"` NoncurrentVersionTransition NoncurrentVersionTransition `xml:"NoncurrentVersionTransition,omitempty"` @@ -171,10 +172,13 @@ func (r Rule) Validate() error { if err := r.validateNoncurrentTransition(); err != nil { return err } + if err := r.AccessTransition.Validate(); err != nil { + return err + } if (!r.Filter.Tag.IsEmpty() || len(r.Filter.And.Tags) != 0) && !r.DelMarkerExpiration.Empty() { return errInvalidRuleDelMarkerExpiration } - if !r.Expiration.set && !r.Transition.set && !r.NoncurrentVersionExpiration.set && !r.NoncurrentVersionTransition.set && r.DelMarkerExpiration.Empty() { + if !r.Expiration.set && !r.Transition.set && !r.NoncurrentVersionExpiration.set && !r.NoncurrentVersionTransition.set && r.DelMarkerExpiration.Empty() && !r.AccessTransition.set { return errXMLNotWellFormed } return nil diff --git a/internal/config/ilm/help.go b/internal/config/ilm/help.go index 9cbf30b157679..ceec4bb035015 100644 --- a/internal/config/ilm/help.go +++ b/internal/config/ilm/help.go @@ -22,10 +22,43 @@ import "github.com/minio/minio/internal/config" const ( transitionWorkers = "transition_workers" expirationWorkers = "expiration_workers" + + accessTiering = "access_tiering" + accessPools = "access_pools" + accessMaxSize = "access_max_size" + accessPromoteWatermark = "access_promote_watermark" + accessBinWidth = "access_bin_width" + accessBins = "access_bins" + accessFlush = "access_flush" + accessMinResidency = "access_min_residency" + accessWorkers = "access_workers" + accessMaxTracked = "access_max_tracked" + // EnvILMTransitionWorkers env variable to configure number of transition workers EnvILMTransitionWorkers = "MINIO_ILM_TRANSITION_WORKERS" // EnvILMExpirationWorkers env variable to configure number of expiration workers EnvILMExpirationWorkers = "MINIO_ILM_EXPIRATION_WORKERS" + + // EnvILMAccessTiering env variable to enable access based tiering + EnvILMAccessTiering = "MINIO_ILM_ACCESS_TIERING" + // EnvILMAccessPools env variable listing pool indices hottest first + EnvILMAccessPools = "MINIO_ILM_ACCESS_POOLS" + // EnvILMAccessMaxSize env variable capping bytes held on the hottest pool + EnvILMAccessMaxSize = "MINIO_ILM_ACCESS_MAX_SIZE" + // EnvILMAccessPromoteWatermark env variable for the hottest pool fill limit + EnvILMAccessPromoteWatermark = "MINIO_ILM_ACCESS_PROMOTE_WATERMARK" + // EnvILMAccessBinWidth env variable for the access counter resolution + EnvILMAccessBinWidth = "MINIO_ILM_ACCESS_BIN_WIDTH" + // EnvILMAccessBins env variable for the number of access counter bins + EnvILMAccessBins = "MINIO_ILM_ACCESS_BINS" + // EnvILMAccessFlush env variable for the counter publish and sweep interval + EnvILMAccessFlush = "MINIO_ILM_ACCESS_FLUSH" + // EnvILMAccessMinResidency env variable for the anti-thrash floor + EnvILMAccessMinResidency = "MINIO_ILM_ACCESS_MIN_RESIDENCY" + // EnvILMAccessWorkers env variable to configure number of access tiering workers + EnvILMAccessWorkers = "MINIO_ILM_ACCESS_WORKERS" + // EnvILMAccessMaxTracked env variable capping tracked objects per node + EnvILMAccessMaxTracked = "MINIO_ILM_ACCESS_MAX_TRACKED" ) var ( @@ -48,5 +81,65 @@ var ( Description: `set the number of expiration workers` + defaultHelpPostfix(expirationWorkers), Optional: true, }, + config.HelpKV{ + Key: accessTiering, + Type: "on|off", + Description: `move objects between fast and slow server pools based on how often they are read` + defaultHelpPostfix(accessTiering), + Optional: true, + }, + config.HelpKV{ + Key: accessPools, + Type: "string", + Description: `server pool indices for access tiering, hottest first, e.g. "0,1"` + defaultHelpPostfix(accessPools), + Optional: true, + }, + config.HelpKV{ + Key: accessMaxSize, + Type: "string", + Description: `cap total bytes access tiering keeps on the fastest pool, e.g. "2TiB", 0 for unlimited` + defaultHelpPostfix(accessMaxSize), + Optional: true, + }, + config.HelpKV{ + Key: accessPromoteWatermark, + Type: "number", + Description: `stop promoting once the fastest pool is this percent full` + defaultHelpPostfix(accessPromoteWatermark), + Optional: true, + }, + config.HelpKV{ + Key: accessBinWidth, + Type: "duration", + Description: `resolution of the access counter` + defaultHelpPostfix(accessBinWidth), + Optional: true, + }, + config.HelpKV{ + Key: accessBins, + Type: "number", + Description: `number of access counter bins; bins times bin width caps the rule Window` + defaultHelpPostfix(accessBins), + Optional: true, + }, + config.HelpKV{ + Key: accessFlush, + Type: "duration", + Description: `how often access counters are published and a promotion sweep runs` + defaultHelpPostfix(accessFlush), + Optional: true, + }, + config.HelpKV{ + Key: accessMinResidency, + Type: "duration", + Description: `minimum time an object stays on a pool after access tiering moved it` + defaultHelpPostfix(accessMinResidency), + Optional: true, + }, + config.HelpKV{ + Key: accessWorkers, + Type: "number", + Description: `set the number of access tiering workers` + defaultHelpPostfix(accessWorkers), + Optional: true, + }, + config.HelpKV{ + Key: accessMaxTracked, + Type: "number", + Description: `maximum number of objects each node keeps access counters for` + defaultHelpPostfix(accessMaxTracked), + Optional: true, + }, } ) diff --git a/internal/config/ilm/ilm.go b/internal/config/ilm/ilm.go index 3ecf68fae63e4..cb88e69954ba1 100644 --- a/internal/config/ilm/ilm.go +++ b/internal/config/ilm/ilm.go @@ -18,12 +18,29 @@ package ilm import ( + "errors" "strconv" + "strings" + "time" + "github.com/dustin/go-humanize" "github.com/minio/minio/internal/config" "github.com/minio/pkg/v3/env" ) +// Errors returned when the access tiering configuration is unusable. +var ( + ErrAccessPoolsInvalid = errors.New("ilm access_pools must be a comma separated list of distinct pool indices, hottest first, e.g. \"0,1\"") + ErrAccessPoolsTooFew = errors.New("ilm access_pools needs at least two pools to move objects between") + ErrAccessWatermarkInvalid = errors.New("ilm access_promote_watermark must be between 1 and 100") + ErrAccessBinsInvalid = errors.New("ilm access_bins must be between 2 and 64") + ErrAccessBinWidthInvalid = errors.New("ilm access_bin_width must be at least 1s") + ErrAccessFlushInvalid = errors.New("ilm access_flush must be at least 1s") + ErrAccessResidencyInvalid = errors.New("ilm access_min_residency cannot be negative") + ErrAccessWorkersInvalid = errors.New("ilm access_workers must be a positive integer") + ErrAccessTrackedInvalid = errors.New("ilm access_max_tracked must be a positive integer") +) + // DefaultKVS default configuration values for ILM subsystem var DefaultKVS = config.KVS{ config.KV{ @@ -34,12 +51,124 @@ var DefaultKVS = config.KVS{ Key: expirationWorkers, Value: "100", }, + config.KV{ + Key: accessTiering, + Value: config.EnableOff, + }, + config.KV{ + Key: accessPools, + Value: "", + }, + config.KV{ + Key: accessMaxSize, + Value: "0", + }, + config.KV{ + Key: accessPromoteWatermark, + Value: "85", + }, + config.KV{ + Key: accessBinWidth, + Value: "1m", + }, + config.KV{ + Key: accessBins, + Value: "12", + }, + config.KV{ + Key: accessFlush, + Value: "1m", + }, + config.KV{ + Key: accessMinResidency, + Value: "24h", + }, + config.KV{ + Key: accessWorkers, + Value: "10", + }, + config.KV{ + Key: accessMaxTracked, + Value: "1000000", + }, } // Config represents the different configuration values for ILM subsystem type Config struct { TransitionWorkers int ExpirationWorkers int + + // AccessTiering enables access-frequency driven relocation of objects + // between server pools. Off by default: it moves data. + AccessTiering bool + // AccessPools lists pool indices hottest first, e.g. []int{0, 1}. + AccessPools []int + // AccessMaxSize caps total bytes held on the hottest pool, 0 == unlimited. + AccessMaxSize uint64 + // AccessPromoteWatermark stops promotion once the hottest pool is this + // percentage full. + AccessPromoteWatermark int + // AccessBinWidth and AccessBins size the rolling hit counter. Their + // product is the longest rule Window that can be evaluated. + AccessBinWidth time.Duration + AccessBins int + // AccessFlush is how often each node publishes its counters and the + // leader merges them and runs a promotion sweep. + AccessFlush time.Duration + // AccessMinResidency is the minimum time an object stays put after a + // move, regardless of what the counters say. + AccessMinResidency time.Duration + AccessWorkers int + // AccessMaxTracked caps how many objects each node keeps counters for. + AccessMaxTracked int +} + +// HotPool returns the index of the hottest configured pool and whether access +// tiering is usable at all. +func (c Config) HotPool() (int, bool) { + if !c.AccessTiering || len(c.AccessPools) < 2 { + return -1, false + } + return c.AccessPools[0], true +} + +// ColdPool returns the index of the coldest configured pool, i.e. where +// demoted objects go. +func (c Config) ColdPool() (int, bool) { + if !c.AccessTiering || len(c.AccessPools) < 2 { + return -1, false + } + return c.AccessPools[len(c.AccessPools)-1], true +} + +// HistoryWindow is the longest window the rolling counter can answer for. +func (c Config) HistoryWindow() time.Duration { + return time.Duration(c.AccessBins) * c.AccessBinWidth +} + +// parseAccessPools parses "0,1" into []int{0, 1}, rejecting duplicates and +// negative indices. An empty string yields no pools, which disables the +// feature rather than erroring - operators enable the switch before they +// configure the topology. +func parseAccessPools(s string) ([]int, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + var pools []int + seen := make(map[int]struct{}) + for _, f := range strings.Split(s, ",") { + idx, err := strconv.Atoi(strings.TrimSpace(f)) + if err != nil || idx < 0 { + return nil, ErrAccessPoolsInvalid + } + if _, dup := seen[idx]; dup { + return nil, ErrAccessPoolsInvalid + } + seen[idx] = struct{}{} + pools = append(pools, idx) + } + return pools, nil } // LookupConfig - lookup ilm config and override with valid environment settings if any. @@ -65,5 +194,79 @@ func LookupConfig(kvs config.KVS) (cfg Config, err error) { cfg.TransitionWorkers = tw cfg.ExpirationWorkers = ew + + if err := cfg.lookupAccess(kvs); err != nil { + return cfg, err + } return cfg, nil } + +func (c *Config) lookupAccess(kvs config.KVS) (err error) { + c.AccessTiering, err = config.ParseBool(env.Get(EnvILMAccessTiering, kvs.GetWithDefault(accessTiering, DefaultKVS))) + if err != nil { + return err + } + + if c.AccessPools, err = parseAccessPools(env.Get(EnvILMAccessPools, kvs.GetWithDefault(accessPools, DefaultKVS))); err != nil { + return err + } + // Enabling the feature without a usable topology is a configuration + // error worth surfacing at set time rather than silently doing nothing. + if c.AccessTiering && len(c.AccessPools) < 2 { + return ErrAccessPoolsTooFew + } + + maxSize := env.Get(EnvILMAccessMaxSize, kvs.GetWithDefault(accessMaxSize, DefaultKVS)) + if maxSize == "" || maxSize == "0" { + c.AccessMaxSize = 0 + } else if c.AccessMaxSize, err = humanize.ParseBytes(maxSize); err != nil { + return err + } + + if c.AccessPromoteWatermark, err = strconv.Atoi(env.Get(EnvILMAccessPromoteWatermark, kvs.GetWithDefault(accessPromoteWatermark, DefaultKVS))); err != nil { + return err + } + if c.AccessPromoteWatermark < 1 || c.AccessPromoteWatermark > 100 { + return ErrAccessWatermarkInvalid + } + + if c.AccessBinWidth, err = time.ParseDuration(env.Get(EnvILMAccessBinWidth, kvs.GetWithDefault(accessBinWidth, DefaultKVS))); err != nil { + return err + } + if c.AccessBinWidth < time.Second { + return ErrAccessBinWidthInvalid + } + if c.AccessBins, err = strconv.Atoi(env.Get(EnvILMAccessBins, kvs.GetWithDefault(accessBins, DefaultKVS))); err != nil { + return err + } + if c.AccessBins < 2 || c.AccessBins > 64 { + return ErrAccessBinsInvalid + } + + if c.AccessFlush, err = time.ParseDuration(env.Get(EnvILMAccessFlush, kvs.GetWithDefault(accessFlush, DefaultKVS))); err != nil { + return err + } + if c.AccessFlush < time.Second { + return ErrAccessFlushInvalid + } + if c.AccessMinResidency, err = time.ParseDuration(env.Get(EnvILMAccessMinResidency, kvs.GetWithDefault(accessMinResidency, DefaultKVS))); err != nil { + return err + } + if c.AccessMinResidency < 0 { + return ErrAccessResidencyInvalid + } + + if c.AccessWorkers, err = strconv.Atoi(env.Get(EnvILMAccessWorkers, kvs.GetWithDefault(accessWorkers, DefaultKVS))); err != nil { + return err + } + if c.AccessWorkers < 1 { + return ErrAccessWorkersInvalid + } + if c.AccessMaxTracked, err = strconv.Atoi(env.Get(EnvILMAccessMaxTracked, kvs.GetWithDefault(accessMaxTracked, DefaultKVS))); err != nil { + return err + } + if c.AccessMaxTracked < 1 { + return ErrAccessTrackedInvalid + } + return nil +} diff --git a/internal/config/ilm/ilm_test.go b/internal/config/ilm/ilm_test.go new file mode 100644 index 0000000000000..35e8689ff1b78 --- /dev/null +++ b/internal/config/ilm/ilm_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2015-2026 MinIO, Inc. + +package ilm + +import ( + "errors" + "reflect" + "testing" + "time" + + "github.com/minio/minio/internal/config" +) + +func clearAccessEnv(t *testing.T) { + t.Helper() + for _, name := range []string{ + EnvILMAccessTiering, EnvILMAccessPools, EnvILMAccessMaxSize, + EnvILMAccessPromoteWatermark, EnvILMAccessBinWidth, EnvILMAccessBins, + EnvILMAccessFlush, EnvILMAccessMinResidency, EnvILMAccessWorkers, + EnvILMAccessMaxTracked, + } { + // The env helper treats an empty value as unset. t.Setenv restores the + // caller's exact value automatically when the test finishes. + t.Setenv(name, "") + } +} + +func TestLookupAccessDefaults(t *testing.T) { + clearAccessEnv(t) + cfg, err := LookupConfig(DefaultKVS.Clone()) + if err != nil { + t.Fatal(err) + } + if cfg.AccessTiering { + t.Fatal("access tiering must default off") + } + if cfg.AccessBinWidth != time.Minute || cfg.AccessBins != 12 || cfg.AccessFlush != time.Minute { + t.Fatalf("unexpected counter defaults: width=%s bins=%d flush=%s", cfg.AccessBinWidth, cfg.AccessBins, cfg.AccessFlush) + } + if cfg.AccessWorkers != 10 || cfg.AccessMaxTracked != 1000000 { + t.Fatalf("unexpected worker/map defaults: %d/%d", cfg.AccessWorkers, cfg.AccessMaxTracked) + } +} + +func TestLookupAccessEnabled(t *testing.T) { + clearAccessEnv(t) + kvs := DefaultKVS.Clone() + kvs.Set(accessTiering, config.EnableOn) + kvs.Set(accessPools, "2, 0, 1") + kvs.Set(accessMaxSize, "2GiB") + kvs.Set(accessPromoteWatermark, "90") + kvs.Set(accessBinWidth, "30s") + kvs.Set(accessBins, "20") + kvs.Set(accessFlush, "15s") + kvs.Set(accessMinResidency, "2h") + kvs.Set(accessWorkers, "7") + kvs.Set(accessMaxTracked, "1234") + + cfg, err := LookupConfig(kvs) + if err != nil { + t.Fatal(err) + } + if !cfg.AccessTiering || !reflect.DeepEqual(cfg.AccessPools, []int{2, 0, 1}) { + t.Fatalf("unexpected topology: enabled=%v pools=%v", cfg.AccessTiering, cfg.AccessPools) + } + if cfg.AccessMaxSize != 2<<30 || cfg.HistoryWindow() != 10*time.Minute { + t.Fatalf("unexpected size/history: %d/%s", cfg.AccessMaxSize, cfg.HistoryWindow()) + } + if hot, ok := cfg.HotPool(); !ok || hot != 2 { + t.Fatalf("HotPool = %d/%v", hot, ok) + } + if cold, ok := cfg.ColdPool(); !ok || cold != 1 { + t.Fatalf("ColdPool = %d/%v", cold, ok) + } +} + +func TestLookupAccessRejectsInvalidValues(t *testing.T) { + clearAccessEnv(t) + tests := []struct { + key, value string + want error + }{ + {accessPools, "0,0", ErrAccessPoolsInvalid}, + {accessPromoteWatermark, "0", ErrAccessWatermarkInvalid}, + {accessBinWidth, "500ms", ErrAccessBinWidthInvalid}, + {accessBins, "1", ErrAccessBinsInvalid}, + {accessFlush, "0s", ErrAccessFlushInvalid}, + {accessMinResidency, "-1s", ErrAccessResidencyInvalid}, + {accessWorkers, "0", ErrAccessWorkersInvalid}, + {accessMaxTracked, "0", ErrAccessTrackedInvalid}, + } + for _, tc := range tests { + t.Run(tc.key, func(t *testing.T) { + kvs := DefaultKVS.Clone() + kvs.Set(tc.key, tc.value) + _, err := LookupConfig(kvs) + if !errors.Is(err, tc.want) { + t.Fatalf("error = %v, want %v", err, tc.want) + } + }) + } + + kvs := DefaultKVS.Clone() + kvs.Set(accessTiering, config.EnableOn) + kvs.Set(accessPools, "0") + if _, err := LookupConfig(kvs); !errors.Is(err, ErrAccessPoolsTooFew) { + t.Fatalf("error = %v, want %v", err, ErrAccessPoolsTooFew) + } +}