Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

liquid: import various algorithms and utils from package limes #196

Merged
merged 1 commit into from
Jan 8, 2025
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ reusability. Feel free to add to this.
* [httpapi](./httpapi) contains opinionated base machinery for assembling and exposing an API consisting of HTTP endpoints.
* [httpext](./httpext) adds some convenience functions to [net/http](https://golang.org/pkg/http/).
* [jobloop](./jobloop) contains the Job trait, which abstracts over reusable implementations of worker loops.
* [liquidapi](./liquidapi) is a server runtime for microservices implementing [LIQUID API](https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid).
* [liquidapi](./liquidapi) contains a server runtime and various other utilities for microservices implementing the [LIQUID API](https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid).
* [logg](./logg) adds some convenience functions to [log](https://golang.org/pkg/log/).
* [mock](./mock) contains basic mocks and test doubles.
* [must](./must) contains convenience functions for quickly exiting on fatal errors without the need for excessive `if err != nil`.
Expand Down
3 changes: 2 additions & 1 deletion gophercloudext/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
*******************************************************************************/

// Package gophercloudext contains convenience functions for use with [Gophercloud].
// It is specifically intended as a lightweight replacement for [gophercloud/utils] with fewer dependencies.
// Its func NewProviderClient is specifically intended as a lightweight replacement for [gophercloud/utils] with fewer dependencies,
// but there are also other generalized utility functions.
//
// [Gophercloud]: https://github.com/gophercloud/gophercloud
// [gophercloud/utils]: https://github.com/gophercloud/utils
Expand Down
47 changes: 47 additions & 0 deletions gophercloudext/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*******************************************************************************
*
* Copyright 2024 SAP SE
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You should have received a copy of the License along with this
* program. If not, you may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*******************************************************************************/

package gophercloudext

import (
"fmt"

"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens"
)

// GetProjectIDFromTokenScope returns the project ID from the client's token scope.
//
// This is useful in applications that usually operate on the cloud-admin level,
// when using an API endpoint that requires a project ID in its URL.
// Usually this is then overridden by a query parameter like "?all_projects=True".
func GetProjectIDFromTokenScope(provider *gophercloud.ProviderClient) (string, error) {
result, ok := provider.GetAuthResult().(tokens.CreateResult)
if !ok {
return "", fmt.Errorf("%T is not a %T", provider.GetAuthResult(), tokens.CreateResult{})
}
project, err := result.ExtractProject()
if err != nil {
return "", err
}
if project == nil || project.ID == "" {
return "", fmt.Errorf(`expected "id" attribute in "project" section, but got %#v`, project)
}
return project.ID, nil
}
183 changes: 183 additions & 0 deletions liquidapi/algorithms.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*******************************************************************************
*
* Copyright 2024 SAP SE
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You should have received a copy of the License along with this
* program. If not, you may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*******************************************************************************/

package liquidapi

import (
"encoding/json"
"math"
"slices"

"github.com/sapcc/go-api-declarations/liquid"

"github.com/sapcc/go-bits/logg"
)

// DistributeFairly takes a number of resource requests, as well as a total
// available capacity, and tries to fulfil all requests as fairly as possible.
//
// If the sum of all requests exceeds the available total, this uses the
// <https://en.wikipedia.org/wiki/Largest_remainder_method>.
func DistributeFairly[K comparable](total uint64, requested map[K]uint64) map[K]uint64 {
// easy case: all requests can be granted
sumOfRequests := uint64(0)
for _, request := range requested {
sumOfRequests += request
}
if sumOfRequests <= total {
return requested
}

// a completely fair distribution would require using these floating-point values...
exact := make(map[K]float64, len(requested))
for key, request := range requested {
exact[key] = float64(total) * float64(request) / float64(sumOfRequests)
}

// ...but we have to round to uint64
fair := make(map[K]uint64, len(requested))
keys := make([]K, 0, len(requested))
totalOfFair := uint64(0)
for key := range requested {
floor := uint64(math.Floor(exact[key]))
fair[key] = floor
totalOfFair += floor
keys = append(keys, key)
}

// now we have `sum(fair) <= total` because the fractional parts were ignored;
// to fix this, we distribute one more to the highest fractional parts, e.g.
//
// total = 15
// requested = [ 4, 6, 7 ]
// exact = [ 3.529..., 5.294..., 6.176... ]
// fair before adjustment = [ 3, 5, 6 ]
// missing = 1
// fair after adjustment = [ 4, 5, 6 ] -> because exact[0] had the largest fractional part
//
missing := total - totalOfFair
slices.SortFunc(keys, func(lhs, rhs K) int {
leftRemainder := exact[lhs] - math.Floor(exact[lhs])
rightRemainder := exact[rhs] - math.Floor(exact[rhs])
switch {
case leftRemainder < rightRemainder:
return -1
case leftRemainder > rightRemainder:
return +1
default:
return 0
}
})
for _, key := range keys[len(keys)-int(missing):] { //nolint:gosec // algorithm ensures that no overflow happens on uint64 -> int cast
fair[key] += 1
}
return fair
}

// DistributeDemandFairly is used to distribute cluster capacity or cluster-wide usage between different resources.
// Each tier of demand is distributed fairly (while supplies last).
//
// Then anything not yet distributed is split according to the given balance numbers.
// For example, if balance = { "foo": 3, "bar": 1 }, then "foo" gets 3/4 of the remaining capacity, "bar" gets 1/4, and all other resources do not get anything extra.
func DistributeDemandFairly[K comparable](total uint64, demands map[K]liquid.ResourceDemandInAZ, balance map[K]float64) map[K]uint64 {
// setup phase to make each of the paragraphs below as identical as possible (for clarity)
requests := make(map[K]uint64)
result := make(map[K]uint64)
remaining := total

// tier 1: usage
for k, demand := range demands {
requests[k] = demand.Usage
}
grantedAmount := DistributeFairly(remaining, requests)
for k := range demands {
remaining -= grantedAmount[k]
result[k] += grantedAmount[k]
}
if logg.ShowDebug {
resultJSON, err := json.Marshal(result)
if err == nil {
logg.Debug("DistributeDemandFairly after phase 1: " + string(resultJSON))
}
}

// tier 2: unused commitments
for k, demand := range demands {
requests[k] = demand.UnusedCommitments
}
grantedAmount = DistributeFairly(remaining, requests)
for k := range demands {
remaining -= grantedAmount[k]
result[k] += grantedAmount[k]
}
if logg.ShowDebug {
resultJSON, err := json.Marshal(result)
if err == nil {
logg.Debug("DistributeDemandFairly after phase 2: " + string(resultJSON))
}
}

// tier 3: pending commitments
for k, demand := range demands {
requests[k] = demand.PendingCommitments
}
grantedAmount = DistributeFairly(remaining, requests)
for k := range demands {
remaining -= grantedAmount[k]
result[k] += grantedAmount[k]
}
if logg.ShowDebug {
resultJSON, err := json.Marshal(result)
if err == nil {
logg.Debug("DistributeDemandFairly after phase 3: " + string(resultJSON))
}
}

// final phase: distribute remainder according to the given balance
if remaining == 0 {
return result
}
for k := range demands {
// This requests incorrect ratios if `remaining` and `balance[k]` are so
// large that `balance[k] * remaining` falls outside the range of uint64.
//
// I'm accepting this since this scenario is very unlikely, and only made
// sure that there are no weird overflows, truncations and such.
requests[k] = clampFloatToUint64(balance[k] * float64(remaining))
}
grantedAmount = DistributeFairly(remaining, requests)
for k := range demands {
remaining -= grantedAmount[k]
result[k] += grantedAmount[k]
}
if logg.ShowDebug {
resultJSON, err := json.Marshal(result)
if err == nil {
logg.Debug("DistributeDemandFairly after balance: " + string(resultJSON))
}
}

return result
}

func clampFloatToUint64(x float64) uint64 {
x = max(x, 0)
x = min(x, math.MaxUint64)
return uint64(x)
}
Loading
Loading