Skip to content

Commit 836e546

Browse files
committed
liquid, limes: add support for non-standard units
Until now, only a fixed enumeration of units was supported in Limes. For example, the units "MiB", "GiB" and "TiB" were supported, but nothing inbetween those. Compute raised a demand to us to be able to have resources with more granular units in order to express RAM quota for flavor groups. Suppose that there is a flavor group where the smallest flavor is 128 GiB and all other flavors are an integer multiple of that, quota should only be given out and commitments only be made in 128 GiB increments. Since, within LIQUID and Limes, all amounts (quota, usage, capacity, commitments) are taken to be integer multiples of the resource's defined unit, it is therefore desirable to declare this resource with a unit of "128 GiB". I considered the option of retaining Unit with the basic definition of `type Unit string`, but found the idea of having to parse a Unit of e.g. "128 GiB" into a structured value for each operation to be undesirable. Therefore, Unit had to become a struct (even though that introduces its own set of pain points like having to manage the zero value). Because the parsing and serialization logic for Unit values ended up having significant functional overlap with the existing parsing and serialization for ValueWithUnit, I created a new base type Amount to avoid code duplication. Unit now holds just an instance of type Amount, and provides its own choices for parsing and serialization on top. ValueWithUnit retains its current structure because of backwards compatibility requirements, but defers most of its work into Amount. I had to move all those types (Amount, Unit, ValueWithUnit) into a single internal package because they need to access API within each other that I do not want to expose publicly. Specifically, ValueWithUnit needs to reach the Amount value hidden within type Unit. Amount is not an exported type and remains an implementation detail. Unit gained some new interface implementations to cover things that were previously handled by it being a plain string: - `encoding/json.Marshaler` for JSON serialization - `database/sql.Scanner` for SQL deserialization - `database/sql/driver.Valuer` for SQL serialization While moving things around, I also took the opportunity to remove several unused pieces from the public API: - `UnitUnspecified` has (de facto) been replaced by `Option[Unit]` - `FractionalValueError` and `IncompatibleUnitsError` are not explicitly used by any callers and have been replaced by `fmt.Errorf` **Testing done:** I have vendored this branch into both limes and limesctl, and got the tests to pass with only minimal changes: - Casts of the form `string(unit)` had to be replaced with `unit.String()`. - Where `Unit` was used as a struct field with `json:",omitempty"`, the linter reminded me to make those into `omitzero`.
1 parent 817d027 commit 836e546

File tree

14 files changed

+688
-274
lines changed

14 files changed

+688
-274
lines changed

internal/testhelper/testhelper.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,25 @@ func AssertNoErr(t *testing.T, err error) {
1818
}
1919
}
2020

21+
func AssertErr(t *testing.T, expected string, actual error) {
22+
t.Helper()
23+
switch {
24+
case actual == nil:
25+
t.Errorf("expected error %q, but got no error", expected)
26+
case actual.Error() != expected:
27+
t.Errorf("expected error: %s", expected)
28+
t.Errorf(" but got error: %s", actual.Error())
29+
}
30+
}
31+
32+
func CheckEquals[V comparable](t *testing.T, expected, actual V) {
33+
t.Helper()
34+
if expected != actual {
35+
t.Errorf("expected value: %#v", expected)
36+
t.Errorf(" but got value: %#v", actual)
37+
}
38+
}
39+
2140
func CheckDeepEquals(t *testing.T, expected, actual any) {
2241
t.Helper()
2342
if !reflect.DeepEqual(expected, actual) {

internal/units/amount.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// SPDX-FileCopyrightText: 2017-2026 SAP SE or an SAP affiliate company
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package units
5+
6+
import (
7+
"fmt"
8+
"strconv"
9+
"strings"
10+
)
11+
12+
// Amount describes an amount of a countable or measurable resource in terms of a base unit.
13+
// This type provides basic serialization and deserialization for unit strings,
14+
// e.g. between "1 KiB" and Amount{"B", 1024}.
15+
type Amount struct {
16+
Base BaseUnit
17+
Factor uint64
18+
}
19+
20+
// BaseUnit enumerates relevant base units for units and values that appear in
21+
// the Limes and LIQUID APIs.
22+
type BaseUnit string
23+
24+
const (
25+
// BaseUnitNone is used for countable (rather than measurable) resources.
26+
BaseUnitNone BaseUnit = ""
27+
// BaseUnitBytes is used for resources that are measured in bytes or any multiple thereof.
28+
BaseUnitBytes BaseUnit = "B"
29+
)
30+
31+
// Format is a bitfield enumerating permissible formats for describing amounts.
32+
// It is used by ParseAmount() and FormatAmount() to select which formats to accept/generate.
33+
type Format int
34+
35+
const (
36+
// EmptyFormat allows the empty string (denoting BaseUnitNone).
37+
EmptyFormat Format = 1 << iota
38+
// NumberOnlyFormat allows bare numbers like "42". Only positive integers are accepted.
39+
NumberOnlyFormat
40+
// UnitOnlyFormat allows bare units like "B" or "KiB". This does not include BareUnitNone.
41+
UnitOnlyFormat
42+
// NumberWithUnitFormat allows numbers with units, e.g. "23 MiB" or "1 B". This does not include BareUnitNone.
43+
NumberWithUnitFormat
44+
)
45+
46+
var bareUnitDefs = []struct {
47+
Symbol string
48+
Amount Amount
49+
}{
50+
// the algorithm in String() relies on this list being sorted in descending order of amount
51+
{"EiB", Amount{BaseUnitBytes, 1 << 60}},
52+
{"PiB", Amount{BaseUnitBytes, 1 << 50}},
53+
{"TiB", Amount{BaseUnitBytes, 1 << 40}},
54+
{"GiB", Amount{BaseUnitBytes, 1 << 30}},
55+
{"MiB", Amount{BaseUnitBytes, 1 << 20}},
56+
{"KiB", Amount{BaseUnitBytes, 1 << 10}},
57+
{"B", Amount{BaseUnitBytes, 1}},
58+
}
59+
60+
// ParseAmount parses a string representation of an amount, in one of the following forms:
61+
// - "<amount>", e.g. "42" (for BaseUnitNone)
62+
// - "<amount> <unit>", e.g. "23 MiB"
63+
// - "<unit>", e.g. "KiB" (only with allowBareUnit = true)
64+
func ParseAmount(input string, formats Format) (Amount, error) {
65+
acceptEmpty := (formats & EmptyFormat) == EmptyFormat
66+
acceptNumberOnly := (formats & NumberOnlyFormat) == NumberOnlyFormat
67+
acceptUnitOnly := (formats & UnitOnlyFormat) == UnitOnlyFormat
68+
acceptNumberWithUnit := (formats & NumberWithUnitFormat) == NumberWithUnitFormat
69+
70+
fields := strings.Fields(input)
71+
switch len(fields) {
72+
case 0:
73+
if acceptEmpty {
74+
return Amount{BaseUnitNone, 1}, nil
75+
}
76+
77+
case 1:
78+
if acceptUnitOnly {
79+
for _, def := range bareUnitDefs {
80+
if def.Symbol == fields[0] {
81+
return def.Amount, nil
82+
}
83+
}
84+
if !acceptNumberOnly {
85+
return Amount{}, fmt.Errorf("invalid value %q: not a known unit name", input)
86+
}
87+
}
88+
89+
if acceptNumberOnly {
90+
number, err := strconv.ParseUint(fields[0], 10, 64)
91+
if err != nil {
92+
if acceptUnitOnly {
93+
return Amount{}, fmt.Errorf("invalid value %q: not a known unit name, and parsing as number failed with: %w", input, err)
94+
} else {
95+
return Amount{}, fmt.Errorf("invalid value %q: %w", input, err)
96+
}
97+
}
98+
return Amount{BaseUnitNone, number}, nil
99+
}
100+
101+
case 2:
102+
if acceptNumberWithUnit {
103+
number, err := strconv.ParseUint(fields[0], 10, 64)
104+
if err != nil {
105+
return Amount{}, fmt.Errorf("invalid value %q: %w", input, err)
106+
}
107+
for _, def := range bareUnitDefs {
108+
if def.Symbol == fields[1] {
109+
return def.Amount.MultiplyBy(number)
110+
}
111+
}
112+
return Amount{}, fmt.Errorf("invalid value %q: no such unit", input)
113+
}
114+
}
115+
116+
formatsDisplay := make([]string, 0, 4)
117+
if acceptEmpty {
118+
formatsDisplay = append(formatsDisplay, `""`)
119+
}
120+
if acceptNumberOnly {
121+
formatsDisplay = append(formatsDisplay, `"<number>"`)
122+
}
123+
if acceptUnitOnly {
124+
formatsDisplay = append(formatsDisplay, `"<unit>"`)
125+
}
126+
if acceptNumberWithUnit {
127+
formatsDisplay = append(formatsDisplay, `"<number> <unit>"`)
128+
}
129+
if len(formatsDisplay) == 1 {
130+
return Amount{}, fmt.Errorf(`value %q does not match expected format (%s)`, input, formatsDisplay[0])
131+
} else {
132+
return Amount{}, fmt.Errorf(`value %q does not match any expected format (%s)`, input, strings.Join(formatsDisplay, " or "))
133+
}
134+
}
135+
136+
// MultiplyBy multiplies this amount by the given factor.
137+
//
138+
// Returns an error on integer overflow, e.g. when a bytes-based unit is larger than 2^64 bytes (16 EiB).
139+
func (a Amount) MultiplyBy(factor uint64) (Amount, error) {
140+
if factor == 0 {
141+
return Amount{Base: a.Base, Factor: 0}, nil
142+
}
143+
product := a.Factor * factor
144+
if product/factor != a.Factor {
145+
return Amount{}, fmt.Errorf("overflow while multiplying %s x %d", a.Format(NumberOnlyFormat|NumberWithUnitFormat), factor)
146+
}
147+
return Amount{Base: a.Base, Factor: product}, nil
148+
}
149+
150+
// Format serializes this Amount into a string representation.
151+
// Out of the given formats, the shortest possible format will be used.
152+
// Panics if none of the allowed formats can represent this Amount.
153+
func (a Amount) Format(formats Format) string {
154+
// for measured units, find the best unit to display them in without loss of precision
155+
// e.g. Amount{BaseUnitBytes, 524288} -> "512 KiB" (not "0.5 MiB" because amounts do not support fractional numbers)
156+
//
157+
// NOTE: This relies on `bareUnitDefs` being sorted such that the first match is always the best.
158+
unitSymbol, number := string(a.Base), a.Factor
159+
for _, def := range bareUnitDefs {
160+
if def.Amount.Base == a.Base && a.Factor%def.Amount.Factor == 0 {
161+
unitSymbol, number = def.Symbol, a.Factor/def.Amount.Factor
162+
break
163+
}
164+
}
165+
166+
// generate the most compact format that the caller allows
167+
if (formats & EmptyFormat) == EmptyFormat {
168+
if number == 1 && unitSymbol == "" {
169+
return ""
170+
}
171+
}
172+
if (formats & NumberOnlyFormat) == NumberOnlyFormat {
173+
if unitSymbol == "" {
174+
return strconv.FormatUint(number, 10)
175+
}
176+
}
177+
if (formats & UnitOnlyFormat) == UnitOnlyFormat {
178+
if number == 1 {
179+
return unitSymbol
180+
}
181+
}
182+
if (formats & NumberWithUnitFormat) == NumberWithUnitFormat {
183+
if unitSymbol != "" {
184+
return strconv.FormatUint(number, 10) + " " + unitSymbol
185+
}
186+
}
187+
188+
// caller has not allowed us to use any format that could display this amount
189+
panic(fmt.Sprintf("cannot display %#v with formats = 0x%x", a, formats))
190+
}

internal/units/limesv1.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// SPDX-FileCopyrightText: 2017-2026 SAP SE or an SAP affiliate company
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package units
5+
6+
import (
7+
"fmt"
8+
"strconv"
9+
"strings"
10+
)
11+
12+
// ParseInUnit parses the string representation of a value with this unit
13+
// (or any unit that can be converted to it).
14+
//
15+
// ParseInUnit(UnitMebibytes, "10 MiB") -> 10
16+
// ParseInUnit(UnitMebibytes, "10 GiB") -> 10240
17+
// ParseInUnit(UnitMebibytes, "10 KiB") -> error: incompatible unit
18+
// ParseInUnit(UnitMebibytes, "10") -> error: missing unit
19+
// ParseInUnit(UnitNone, "42") -> 42
20+
// ParseInUnit(UnitNone, "42 MiB") -> error: unexpected unit
21+
func ParseInUnit(u Unit, str string) (uint64, error) {
22+
amount, err := ParseAmount(str, NumberOnlyFormat|NumberWithUnitFormat)
23+
if err != nil {
24+
return 0, err
25+
}
26+
value := LimesV1ValueWithUnit{
27+
Value: amount.Factor,
28+
Unit: Unit{Amount{Base: amount.Base, Factor: 1}},
29+
}
30+
converted, err := value.ConvertTo(u)
31+
return converted.Value, err
32+
}
33+
34+
// LimesV1ValueWithUnit is used to represent values with units in subresources.
35+
// As the name implies, this type is only exposed in the Limes v1 API.
36+
type LimesV1ValueWithUnit struct {
37+
Value uint64 `json:"value"`
38+
Unit Unit `json:"unit"`
39+
}
40+
41+
// String implements the fmt.Stringer interface.
42+
// The value is serialized with the most appropriate unit:
43+
//
44+
// // prints: 1000000 MiB
45+
// fmt.Println(LimesV1ValueWithUnit{1000000,UnitMebibytes})
46+
// // prints: 1 TiB
47+
// fmt.Println(LimesV1ValueWithUnit{1048576,UnitMebibytes})
48+
func (v LimesV1ValueWithUnit) String() string {
49+
amount, err := v.Unit.amount.MultiplyBy(v.Value)
50+
if err == nil {
51+
return amount.Format(NumberOnlyFormat | NumberWithUnitFormat)
52+
}
53+
54+
// fallback: if converting to the base unit would overflow, print without conversion
55+
valueStr := strconv.FormatUint(v.Value, 10)
56+
if v.Unit == UnitNone {
57+
// defense in depth: not reachable in practice because LimesV1ValueWithUnit with
58+
// UnitNone would not be able to overflow MultiplyBy() above
59+
return valueStr
60+
} else {
61+
unitStr := v.Unit.amount.Format(UnitOnlyFormat | NumberWithUnitFormat)
62+
if strings.Contains(unitStr, " ") { // unit has a numeric multiplier by itself, e.g. "4 MiB"
63+
return valueStr + " x " + unitStr // e.g. "20 x 4 MiB"
64+
} else {
65+
return valueStr + " " + unitStr // e.g. "20 MiB"
66+
}
67+
}
68+
}
69+
70+
// ConvertTo returns an equal value in the given Unit. An error is returned if:
71+
// - the source unit cannot be converted to the target unit, or
72+
// - the conversion does not yield an integer value in the new unit.
73+
func (v LimesV1ValueWithUnit) ConvertTo(u Unit) (LimesV1ValueWithUnit, error) {
74+
if v.Unit == u {
75+
return v, nil
76+
}
77+
78+
base, sourceMultiple := v.Unit.Base()
79+
base2, targetMultiple := u.Base()
80+
if base != base2 {
81+
return LimesV1ValueWithUnit{}, fmt.Errorf(
82+
"cannot convert value %q to %s because units are incompatible",
83+
v.String(), toStringForError(u),
84+
)
85+
}
86+
87+
valueInBase := v.Value * sourceMultiple
88+
if valueInBase%targetMultiple != 0 {
89+
return LimesV1ValueWithUnit{}, fmt.Errorf(
90+
"value %q cannot be represented as integer number of %s",
91+
v.String(), toStringForError(u),
92+
)
93+
}
94+
95+
return LimesV1ValueWithUnit{
96+
Value: valueInBase / targetMultiple,
97+
Unit: u,
98+
}, nil
99+
}
100+
101+
func toStringForError(u Unit) string {
102+
if u == UnitNone {
103+
return "<count>"
104+
}
105+
return u.String()
106+
}

internal/units/limesv1_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// SPDX-FileCopyrightText: 2017-2026 SAP SE or an SAP affiliate company
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package units
5+
6+
import (
7+
"testing"
8+
9+
th "github.com/sapcc/go-api-declarations/internal/testhelper"
10+
)
11+
12+
func TestParseInUnit(t *testing.T) {
13+
// This executes all the examples from the docstring of func ParseInUnit().
14+
// Since ParseInUnit() calls ConvertTo(), this also provides coverage for ConvertTo().
15+
16+
value, err := ParseInUnit(UnitMebibytes, "10 MiB")
17+
th.AssertNoErr(t, err)
18+
th.CheckEquals(t, 10, value)
19+
20+
value, err = ParseInUnit(UnitMebibytes, "10 GiB")
21+
th.AssertNoErr(t, err)
22+
th.CheckEquals(t, 10240, value)
23+
24+
_, err = ParseInUnit(UnitMebibytes, "10 KiB")
25+
th.AssertErr(t, `value "10 KiB" cannot be represented as integer number of MiB`, err)
26+
27+
_, err = ParseInUnit(UnitMebibytes, "10")
28+
th.AssertErr(t, `cannot convert value "10" to MiB because units are incompatible`, err)
29+
30+
value, err = ParseInUnit(UnitNone, "42")
31+
th.AssertNoErr(t, err)
32+
th.CheckEquals(t, 42, value)
33+
34+
_, err = ParseInUnit(UnitNone, "42 MiB")
35+
th.AssertErr(t, `cannot convert value "42 MiB" to <count> because units are incompatible`, err)
36+
}
37+
38+
func TestValueWithUnitToString(t *testing.T) {
39+
// check behavior LimesV1ValueWithUnit.String() esp. with non-standard units and integer overflows
40+
41+
v := LimesV1ValueWithUnit{
42+
Value: 128,
43+
Unit: UnitKibibytes,
44+
}
45+
th.CheckEquals(t, "128 KiB", v.String())
46+
47+
v = LimesV1ValueWithUnit{
48+
Value: 128,
49+
Unit: mustMultiply(UnitKibibytes, 32),
50+
}
51+
th.CheckEquals(t, "4 MiB", v.String()) // uses nice formatting, i.e. neither "128 x 32 KiB" nor "4096 KiB"
52+
53+
v = LimesV1ValueWithUnit{
54+
// this value is equal to 2^75 bytes and overflows type Amount
55+
Value: 32768,
56+
Unit: UnitExbibytes,
57+
}
58+
th.CheckEquals(t, "32768 EiB", v.String()) // printed via fallback logic
59+
60+
v = LimesV1ValueWithUnit{
61+
// same value, but this time the fallback printing logic is more obvious
62+
Value: 16384,
63+
Unit: mustMultiply(UnitExbibytes, 2),
64+
}
65+
th.CheckEquals(t, "16384 x 2 EiB", v.String())
66+
}

0 commit comments

Comments
 (0)