Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
### Fixed

- **aws/ec2:** `GetOfferingDetails` always priced the convertible offering and its doc comment wrongly said the class does not change prices. Added `GetOfferingDetailsForClass` to price the class the purchase uses; `GetOfferingDetails` keeps the convertible default (#570).
- **A Multi-AZ size-flexible RDS reservation now covers Single-AZ demand in the
duplicate-purchase guard** (#323): it counts as twice the size units of its
family, so a recent Multi-AZ db.r6g.large PostgreSQL reservation suppresses
two Single-AZ db.r6g.large recommendations. Single-AZ never covers Multi-AZ,
and exact-class engines such as SQL Server keep their key.
- **Size-flexible RDS reservations now cover Single-AZ and Multi-AZ demand in
the duplicate-purchase guard** (#323): Single-AZ and Multi-AZ share one
family budget in size units, and a Multi-AZ node costs twice the units. A
recent Multi-AZ db.r6g.large PostgreSQL reservation suppresses two Single-AZ
db.r6g.large recommendations, and a Single-AZ db.r6g.xlarge suppresses one
Multi-AZ db.r6g.large. Exact-class engines such as SQL Server keep their key.
- **The duplicate-purchase guard now treats regional Linux/Unix default-tenancy
EC2 reservations as size-flexible** (#323): a recent m5.xlarge reservation
suppresses two m5.large recommendations in the same region, counted in
normalized units. Zonal, Windows/RHEL/SUSE, dedicated, wildcard and
unknown-size entries stay exact-type, as do the families AWS excludes from
size flexibility (G4ad, G4dn, G5, G5g, G6, G6e, G6f, Gr6, Gr6f, hpc7a, P5,
Inf1, Inf2, u7i-6tb, u7i-8tb). A partly spent reservation is never credited
as a whole node.
- **Unbounded AWS pagination loops now stop at a page cap and honour
cancellation** (#561): the existing-commitment and valid-resource-type scans
for ElastiCache, RDS, OpenSearch, Redshift and EC2 check the context before
Expand Down
53 changes: 53 additions & 0 deletions pkg/common/ec2_size.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package common //nolint:revive // pkg/common is the module's core domain package, imported by name (common.X) throughout this repo and by consumer-mcp

import "strings"

// ec2NormalizationFactors maps EC2 instance sizes to their AWS normalization factors.
// "metal" is deliberately absent: its factor equals the family's largest size
// (i3.metal 128, m5.metal 192, m5zn.metal 96), and some families have no metal
// size, so it cannot be a family-independent constant. Callers that know it set
// RIInfo.NormalizationFactor.
// See: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ri-modification-instancemove.html
var ec2NormalizationFactors = map[string]float64{
"nano": 0.25,
"micro": 0.5,
"small": 1,
"medium": 2,
"large": 4,
"xlarge": 8,
"2xlarge": 16,
"3xlarge": 24,
"4xlarge": 32,
"6xlarge": 48,
"8xlarge": 64,
"9xlarge": 72,
"10xlarge": 80,
"12xlarge": 96,
"16xlarge": 128,
"18xlarge": 144,
"24xlarge": 192,
}

// EC2NormalizationFactor returns the AWS normalization factor of an EC2 instance size such
// as "xlarge" (8). It returns 0 when the size is not recognized, including "metal".
func EC2NormalizationFactor(size string) float64 {
return ec2NormalizationFactors[size]
}

// ec2NotSizeFlexibleFamilies are the families whose regional Linux/Unix reservations are
// still not size-flexible ("Instance size flexibility > Limitations"):
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/apply_ri.html#ri-instance-size-flexibility-limitations
// Re-read that list when AWS adds a family: a missing entry lets the duplicate guard
// credit a reservation to sizes it does not cover.
var ec2NotSizeFlexibleFamilies = map[string]bool{
"g4ad": true, "g4dn": true, "g5": true, "g5g": true, "g6": true, "g6e": true, "g6f": true,
"gr6": true, "gr6f": true, "hpc7a": true, "p5": true, "inf1": true, "inf2": true,
"u7i-6tb": true, "u7i-8tb": true,
}

// EC2SizeFlexibleFamily reports whether a regional Linux/Unix default-tenancy reservation of
// the instance family (the part of the type before the dot, such as "m5" or "u7i-6tb") can be
// size-flexible. It is case-insensitive and false for the families AWS excludes.
func EC2SizeFlexibleFamily(family string) bool {
return !ec2NotSizeFlexibleFamilies[strings.ToLower(strings.TrimSpace(family))]
}
25 changes: 25 additions & 0 deletions pkg/common/ec2_size_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package common //nolint:revive // see ec2_size.go

import "testing"

func TestEC2NormalizationFactor(t *testing.T) {
t.Parallel()
for size, want := range map[string]float64{"nano": 0.25, "large": 4, "xlarge": 8, "24xlarge": 192, "metal": 0, "bogus": 0, "": 0} {
if got := EC2NormalizationFactor(size); got != want {
t.Errorf("EC2NormalizationFactor(%q) = %v, want %v", size, got, want)
}
}
}

func TestEC2SizeFlexibleFamily(t *testing.T) {
t.Parallel()
for family, want := range map[string]bool{
"m5": true, "c5n": true, "t3": true, "g5": false, "G5": false, "g4dn": false, "g4ad": false, "g5g": false,
"gr6f": false, "p5": false, "p4d": true, "inf1": false, "inf2": false, "hpc7a": false, "hpc7g": true,
"u7i-6tb": false, "u7i-8tb": false, "u7i-12tb": true, "g6e": false, "g4": true,
} {
if got := EC2SizeFlexibleFamily(family); got != want {
t.Errorf("EC2SizeFlexibleFamily(%q) = %v, want %v", family, got, want)
}
}
}
38 changes: 7 additions & 31 deletions pkg/exchange/reshape.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"math"
"sort"
"strings"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
)

// RIInfo describes a Reserved Instance for reshape analysis.
Expand Down Expand Up @@ -360,7 +362,7 @@ func nfProximityComponent(off OfferingOption, src RIInfo) float64 {
srcNF := src.NormalizationFactor
if srcNF <= 0 {
_, size := parseInstanceType(src.InstanceType)
srcNF = normalizationFactors[size]
srcNF = common.EC2NormalizationFactor(size)
}
if srcNF <= 0 || off.NormalizationFactor <= 0 {
return 0.5
Expand All @@ -373,32 +375,6 @@ func nfProximityComponent(off OfferingOption, src RIInfo) float64 {
return 1.0 - deviation
}

// normalizationFactors maps EC2 instance sizes to their AWS normalization factors.
// "metal" is deliberately absent: its factor equals the family's largest size
// (i3.metal 128, m5.metal 192, m5zn.metal 96), and some families have no metal
// size, so it cannot be a family-independent constant. Callers that know it set
// RIInfo.NormalizationFactor.
// See: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ri-modification-instancemove.html
var normalizationFactors = map[string]float64{
"nano": 0.25,
"micro": 0.5,
"small": 1,
"medium": 2,
"large": 4,
"xlarge": 8,
"2xlarge": 16,
"3xlarge": 24,
"4xlarge": 32,
"6xlarge": 48,
"8xlarge": 64,
"9xlarge": 72,
"10xlarge": 80,
"12xlarge": 96,
"16xlarge": 128,
"18xlarge": 144,
"24xlarge": 192,
}

// sizeOrder lists instance sizes from smallest to largest for iteration.
var sizeOrder = []string{
"nano", "micro", "small", "medium", "large", "xlarge",
Expand All @@ -420,7 +396,7 @@ func parseInstanceType(instanceType string) (family, size string) {
// NormalizationFactorForSize returns the normalization factor for a given instance size.
// Returns 0 if the size is unknown, including "metal" (family-dependent).
func NormalizationFactorForSize(size string) float64 {
return normalizationFactors[size]
return common.EC2NormalizationFactor(size)
}

// AnalyzeReshaping identifies underutilized convertible RIs and suggests optimal
Expand Down Expand Up @@ -453,7 +429,7 @@ func resolveNormFactor(ri RIInfo, size string) float64 {
if ri.NormalizationFactor != 0 {
return ri.NormalizationFactor
}
return normalizationFactors[size]
return common.EC2NormalizationFactor(size)
}

// analyzeRI evaluates a single RI and returns a reshape recommendation if it is
Expand Down Expand Up @@ -730,7 +706,7 @@ func findBestFit(normalizedUsed, normalizedPurchased float64) (size string, coun

for i := len(sizeOrder) - 1; i >= 0; i-- {
s := sizeOrder[i]
nf := normalizationFactors[s]
nf := common.EC2NormalizationFactor(s)
if nf <= 0 || nf > normalizedUsed {
continue
}
Expand All @@ -743,7 +719,7 @@ func findBestFit(normalizedUsed, normalizedPurchased float64) (size string, coun
// Only usage below the smallest size's factor falls back to that size;
// anything else that reached here simply does not fit.
s := sizeOrder[0]
nf := normalizationFactors[s]
nf := common.EC2NormalizationFactor(s)
if normalizedUsed < nf && nf <= normalizedPurchased {
return s, 1
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/exchange/reshape_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"math"
"testing"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -209,7 +210,7 @@ func TestAnalyzeReshaping_TargetNeverExceedsOwnedCapacity(t *testing.T) {
continue
}
_, tsz := parseInstanceType(rec.TargetInstanceType)
target := normalizationFactors[tsz] * float64(rec.TargetCount)
target := common.EC2NormalizationFactor(tsz) * float64(rec.TargetCount)
assert.LessOrEqualf(t, target, rec.NormalizedPurchased,
"%dx %s @%.0f%% -> %dx %s", count, ri.InstanceType, util, rec.TargetCount, rec.TargetInstanceType)
}
Expand Down
7 changes: 4 additions & 3 deletions pkg/recfilter/dedupe.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ func buildExistingCommitmentsMap(commitments []common.Commitment, logf Logf) map
normalizedDeployment := common.NormalizeDeploymentName(c.Deployment)
resourceType, nodeUnits := c.ResourceType, 1
if family, units, ok := rdsFlexKey(c.Provider, c.Service, normalizedEngine, c.ResourceType); ok {
resourceType, nodeUnits = family, units
resourceType, nodeUnits = family, units*rdsDeploymentFactor(normalizedDeployment)
normalizedDeployment = rdsBudgetDeployment(normalizedDeployment)
}
key := dedupeKey(resourceType, c.Region, normalizedEngine, normalizedDeployment)
existingMap[key] += c.Count * nodeUnits
Expand Down Expand Up @@ -268,7 +269,8 @@ func adjustSingleRecommendation(rec common.Recommendation, existingMap map[strin
covers := []elastiCacheCover{{engine, 1, 1}}
if family, units, ok := rdsFlexKey(rec.Provider, rec.Service, engine, rec.ResourceType); ok {
// A size-flexible budget holds units: one node of this size spends units of them.
resourceType, covers = family, []elastiCacheCover{{engine, 1, units}}
resourceType, covers = family, []elastiCacheCover{{engine, 1, units * rdsDeploymentFactor(deployment)}}
deployment = rdsBudgetDeployment(deployment)
}
key := dedupeKey(resourceType, rec.Region, engine, deployment)
if isElastiCache {
Expand All @@ -284,7 +286,6 @@ func adjustSingleRecommendation(rec common.Recommendation, existingMap map[strin
existingMap[candidate] -= spent
}
}

if remaining <= 0 {
logf.printf(" [DuplicateChecker] SKIP %s: recent commitments cover recommended %d", key, rec.Count)
return common.Recommendation{Count: 0}, nil
Expand Down
44 changes: 37 additions & 7 deletions pkg/recfilter/dedupe_ec2.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ type ec2Entry struct {
platform, tenancy string
scope scopeState
count, wildcards int
// flexUnits is the quarter normalized units of one node when the entry is a
// regional Linux/Unix default-tenancy reservation of a known size, which pays
// for any size of its family (see ec2FlexUnits); 0 keeps the entry exact-type.
flexUnits int
// flexLeft is the unspent budget in the same units and the single source of truth
// for a flex entry; count is unused for it.
flexLeft int
}

func (e *ec2Entry) covers(platform, tenancy string, scope scopeState) bool {
Expand All @@ -69,8 +76,10 @@ func (e *ec2Entry) covers(platform, tenancy string, scope scopeState) bool {
// still suppresses, as for every other service.
type ec2Dedupe struct {
entries map[string][]*ec2Entry
warned map[string]bool
logf Logf
// families lists the size-flexible entries by family and region, in input order.
families map[string][]*ec2Entry
warned map[string]bool
logf Logf
}

func isAWSCompute(p common.ProviderType, s common.ServiceType) bool {
Expand All @@ -82,7 +91,7 @@ func ec2BaseKey(resourceType, region string) string {
}

func newEC2Dedupe(commitments []common.Commitment, logf Logf) *ec2Dedupe {
d := &ec2Dedupe{entries: map[string][]*ec2Entry{}, warned: map[string]bool{}, logf: logf}
d := &ec2Dedupe{entries: map[string][]*ec2Entry{}, families: map[string][]*ec2Entry{}, warned: map[string]bool{}, logf: logf}
for i := range commitments {
c := &commitments[i]
if !isAWSCompute(c.Provider, c.Service) {
Expand All @@ -93,13 +102,35 @@ func newEC2Dedupe(commitments []common.Commitment, logf Logf) *ec2Dedupe {
e.wildcards = wildcardCount(e)
key := ec2BaseKey(c.ResourceType, c.Region)
d.entries[key] = append(d.entries[key], e)
if family, units, ok := ec2FlexKey(c.ResourceType, c.Region, platform, tenancy); ok && scope.kind == scopeRegionalKind {
e.flexUnits, e.flexLeft = units, max(e.count, 0)*units
d.families[family] = append(d.families[family], e)
}
}
for _, list := range d.entries {
sort.SliceStable(list, func(i, j int) bool { return list[i].wildcards < list[j].wildcards })
}
return d
}

// nodesLeft is the whole nodes of the entry's own size still unspent. A flex entry whose
// leftover budget is below one node has none, so a partly spent node is never credited twice.
func (e *ec2Entry) nodesLeft() int {
if e.flexUnits > 0 {
return e.flexLeft / e.flexUnits
}
return e.count
}

func (e *ec2Entry) spend(nodes int) int {
if e.flexUnits > 0 {
e.flexLeft -= nodes * e.flexUnits
} else {
e.count -= nodes
}
return nodes
}

func wildcardCount(e *ec2Entry) int {
n := 0
for _, wild := range []bool{e.platform == "", e.tenancy == "", e.scope.kind == scopeUnknown,
Expand Down Expand Up @@ -174,13 +205,12 @@ func (d *ec2Dedupe) adjust(rec common.Recommendation) (common.Recommendation, er
if remaining <= 0 {
break
}
if e.count <= 0 || !e.covers(platform, tenancy, scope) {
if e.nodesLeft() <= 0 || !e.covers(platform, tenancy, scope) {
continue
}
used := min(e.count, remaining)
e.count -= used
remaining -= used
remaining -= e.spend(min(e.nodesLeft(), remaining))
}
remaining = d.consumeFamily(rec, platform, tenancy, remaining)
if remaining <= 0 {
d.logf.printf(" [DuplicateChecker] SKIP %s: recent commitments cover recommended %d", ec2BaseKey(rec.ResourceType, rec.Region), rec.Count)
return common.Recommendation{Count: 0}, nil
Expand Down
58 changes: 58 additions & 0 deletions pkg/recfilter/dedupe_ec2_flex.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package recfilter

import (
"fmt"
"strings"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
)

// ec2FlexKey reports the family key and the quarter normalized units of one node for an
// EC2 instance type whose platform and tenancy are exactly Linux/Unix and default. A
// regional reservation of that kind pays for any size of the same family in its region
// (https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/apply_ri.html), so the family
// replaces the exact type in the key and the budget is counted in units (quarters of a
// normalized unit, so nano stays an integer). Anything else returns false and keeps the
// exact-type key: other platforms and dedicated tenancy are not size-flexible, and an
// empty (wildcard) platform or tenancy may not be, so it must never join or spend a
// flex budget. Callers also require a regional scope for entries: zonal
// reservations are not size-flexible. Sizes without a known factor, such as metal, and families AWS excludes
// (common.EC2SizeFlexibleFamily) stay exact.
func ec2FlexKey(resourceType, region, platform, tenancy string) (family string, units int, ok bool) {
if platform != "linux/unix" || tenancy != "default" {
return "", 0, false
}
prefix, size, found := strings.Cut(resourceType, ".")
nf := common.EC2NormalizationFactor(size)
if !found || prefix == "" || nf == 0 || !common.EC2SizeFlexibleFamily(prefix) {
return "", 0, false
}
return fmt.Sprintf("ec2flex|%s|%s", strings.ToLower(prefix), region), int(nf * 4), true
}

// consumeFamily spends the size-flexible budget of the recommendation's family on what is
// still uncovered and returns the count left. The budget is the unspent units of every regional
// Linux/Unix default-tenancy entry of the family, spent in input order. The covered nodes
// round down and the spent units are exact, so a fractional remainder is never credited.
func (d *ec2Dedupe) consumeFamily(rec common.Recommendation, platform, tenancy string, remaining int) int {
family, recUnits, ok := ec2FlexKey(rec.ResourceType, rec.Region, platform, tenancy)
if !ok || remaining <= 0 {
return remaining
}
entries := d.families[family]
budget := 0
for _, e := range entries {
budget += e.flexLeft
}
covered := min(budget/recUnits, remaining)
need := covered * recUnits
for _, e := range entries {
if need <= 0 {
break
}
take := min(e.flexLeft, need)
e.flexLeft -= take
need -= take
}
return remaining - covered
}
Loading
Loading