Skip to content

Calculator Package Refactoring and Performance Optimizations #1383

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

Merged
merged 18 commits into from
Aug 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
466abe8
refactor: optimize contract cost calculation with improved node handl…
0oM4R Jul 3, 2025
66c0139
chore: update tests
0oM4R Jul 3, 2025
a577b14
Merge branch 'development_calculateOverdue' of github.com:threefoldte…
0oM4R Jul 3, 2025
b9ffd33
test: add tests for nodeContracts on rented node
0oM4R Jul 3, 2025
56b886e
Merge branch 'development_calculateOverdue' of github.com:threefoldte…
0oM4R Jul 3, 2025
8ce545a
fix: update test case to handle zero standard overdraft and include b…
0oM4R Jul 3, 2025
ed84d12
perf: parallelize contract cost calculation using goroutines and wait…
0oM4R Jul 3, 2025
d00d589
test: add more contract test cases for calculateTotalContractsOverdue…
0oM4R Jul 7, 2025
abe4c9d
feat: apply stacking discount to unique name and public ips on rented…
0oM4R Jul 7, 2025
7ce02df
remove unused files
0oM4R Jul 7, 2025
b70b97e
refactor: remove debug prints, update docs
0oM4R Jul 10, 2025
c97c709
refactor: improve concurrency handling in contract cost calculation, …
0oM4R Jul 10, 2025
543f96e
Merge branch 'development' of github.com:threefoldtech/tfgrid-sdk-go …
0oM4R Jul 10, 2025
04057b8
refactor: update contract cost calculation add more test cases
0oM4R Jul 10, 2025
b5ba595
refactor: move overdue contract test cases to calculate_test.go
0oM4R Jul 10, 2025
ffbb4bf
refactor: replace error list with multierror package for better error…
0oM4R Jul 13, 2025
fa04340
fix: handle nil node case in contract overdue calculation
0oM4R Aug 11, 2025
09f9e03
mend
0oM4R Aug 11, 2025
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
5 changes: 5 additions & 0 deletions go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1045,8 +1045,10 @@ github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixH
github.com/huin/goutil v0.0.0-20170803182201-1ca381bf3150/go.mod h1:PpLOETDnJ0o3iZrZfqZzyLl6l7F3c6L1oWn7OICBi6o=
github.com/hydrogen18/memlistener v0.0.0-20200120041712-dcc25e7acd91/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE=
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhKWFeDesPjMj+wCHReeknARU3wqlyN4=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/influxdata/influxdb v1.8.3/go.mod h1:JugdFhsvvI8gadxOI6noqNeeBHvWNTbfYGtiAn+2jhI=
Expand Down Expand Up @@ -1157,6 +1159,7 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/lyft/protoc-gen-star/v2 v2.0.3/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
github.com/lyft/protoc-gen-star/v2 v2.0.4-0.20230330145011-496ad1ac90a4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
github.com/machinebox/graphql v0.2.2/go.mod h1:F+kbVMHuwrQ5tYgU9JXlnskM8nOaFxCAEolaQybkjWA=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
Expand Down Expand Up @@ -1508,9 +1511,11 @@ go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg=
Expand Down
149 changes: 101 additions & 48 deletions grid-client/calculator/calculate.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"fmt"
"math"
"math/big"
"sync"
"time"

"github.com/centrifuge/go-substrate-rpc-client/v4/types"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
substrate "github.com/threefoldtech/tfchain/clients/tfchain-client-go"
"github.com/threefoldtech/tfgrid-sdk-go/grid-client/subi"
Expand Down Expand Up @@ -64,6 +66,7 @@ func (c *Calculator) CalculateCost(cru, mru, hru, sru uint64, publicIP, certifie
}

// CalculatePricesAfterDiscount calculates the prices after discount
// it takes the cost in USD and returns the prices after discount
func (c *Calculator) CalculatePricesAfterDiscount(cost float64) (dedicatedPrice, sharedPrice float64, err error) {
pricingPolicy, err := c.substrateConn.GetPricingPolicy(defaultPricingPolicyID)
if err != nil {
Expand Down Expand Up @@ -192,18 +195,33 @@ func (c Calculator) CalculateContractOverdue(id uint64, allowance time.Duration)
return 0, errors.Wrapf(err, "failed to get payment state for contract ID %d", id)
}

periodCostTFT, err := c.calculatePeriodCostTFT(time.Unix(int64(contractPaymentState.LastUpdatedSeconds), 0), contractInfo, allowance)
lastBillingAt := time.Unix(int64(contractPaymentState.LastUpdatedSeconds), 0)

// totalOverDraft represents the sum of standard and additional overdraft amounts for the contract TFT
totalOverDraftTFT := calculateTotalOverdraftTFT(&contractPaymentState)

node, err := c.getNode(contractInfo.ContractType)
if err != nil {
return 0, errors.Wrap(err, "failed to calculate period cost")
return 0, errors.Wrap(err, "failed to get node")
}

var isCertifiedNode bool
if node != nil {
isCertifiedNode = node.Certification.IsCertified
}
// totalOverDraft represents the sum of standard and additional overdraft amounts for the contract in TFT
totalOverDraftTFT := calculateTotalOverdraftTFT(&contractPaymentState)

unbilledNuTFT, err := c.getUnbilledAmountInTFT(uint64(contractInfo.ContractID))
unbilledNuTFT, err := c.getUnbilledAmountInTFT(contractInfo, isCertifiedNode)
if err != nil {
return 0, errors.Wrap(err, "failed to get unbilled amount")
}

var nodeInfo substrate.Node
if node != nil {
nodeInfo = *node
}
periodCostTFT, err := c.calculatePeriodCostTFT(lastBillingAt, *contractInfo, nodeInfo, allowance)
if err != nil {
return 0, errors.Wrap(err, "failed to calculate period cost")
}
totalOverDraftTFT += periodCostTFT + unbilledNuTFT

if contract.ContractType.IsRentContract {
Expand Down Expand Up @@ -232,18 +250,27 @@ func unitToTFT(units *big.Int) float64 {
}

// GetUnbilledAmountInTFT returns the amount unbilled for a given contract in TFT
func (c *Calculator) getUnbilledAmountInTFT(contractID uint64) (float64, error) {
billingInfo, err := c.substrateConn.GetContractBillingInfo(contractID)
//
// The amount unbilled is the amount that is not billed yet for a node contract
func (c *Calculator) getUnbilledAmountInTFT(contract *substrate.Contract, isCertifiedNode bool) (float64, error) {
if contract.ContractType.IsNameContract || contract.ContractType.IsRentContract ||
(contract.ContractType.IsNodeContract && contract.ContractType.NodeContract.PublicIPsCount == 0) {
return 0, nil
}
billingInfo, err := c.substrateConn.GetContractBillingInfo(uint64(contract.ContractID))
if err != nil && !errors.Is(err, substrate.ErrNotFound) {
return 0, err
}

var unbilled float64 = 0
if billingInfo.AmountUnbilled != types.U64(0) {
unbilled = float64(billingInfo.AmountUnbilled)
}
// amount unbilled is in unit-USD
unbilledUSD := unitToUSD(unbilled)

if isCertifiedNode {
unbilledUSD *= 1.25
}
return c.USDtoTFT(unbilledUSD)
}

Expand All @@ -253,29 +280,51 @@ func (c *Calculator) calculateTotalContractsOverdueOnNode(nodeID uint32, allowan
if err != nil {
return 0, errors.Wrapf(err, "failed to get contracts for node ID %d", nodeID)
}

var wg sync.WaitGroup
var mu sync.Mutex
wg.Add(len(contracts))

var totalCost int64 = 0
var result *multierror.Error

for _, contract := range contracts {
cost, err := c.CalculateContractOverdue(uint64(contract), allowance)
if err != nil {
if errors.Is(err, ErrContractDeleted) {
continue
go func(contract uint64) {
defer wg.Done()
cost, err := c.CalculateContractOverdue(contract, allowance)
if err != nil {
if errors.Is(err, ErrContractDeleted) {
return
}
mu.Lock()
result = multierror.Append(result, fmt.Errorf("error with contract %d: %w", contract, err))
mu.Unlock()
return
}
return 0, err
}
totalCost += cost
mu.Lock()
totalCost += cost
mu.Unlock()
}(uint64(contract))
}

wg.Wait()

if result != nil {
return 0, result.ErrorOrNil()
}
return totalCost, nil
}

// Calculates the cost with a period in TFT.
//
// The period is the time since last updated in seconds with the provided allowance time.
func (c *Calculator) calculatePeriodCostTFT(lastUpdatedSeconds time.Time, contract *substrate.Contract, allowance time.Duration) (float64, error) {
func (c *Calculator) calculatePeriodCostTFT(lastUpdatedSeconds time.Time, contract substrate.Contract, node substrate.Node, allowance time.Duration) (float64, error) {

// Calculate the elapsed seconds since last billing
elapsedSeconds := math.Ceil(time.Since(lastUpdatedSeconds).Seconds())
totalPeriodSeconds := elapsedSeconds + allowance.Seconds()

contractMonthlyCostUSD, err := c.calculateContractCost(contract)
contractMonthlyCostUSD, err := c.calculateContractCost(contract, node)
if err != nil {
return 0, errors.Wrap(err, "failed to calculate contract cost")
}
Expand All @@ -291,24 +340,18 @@ func (c *Calculator) calculatePeriodCostTFT(lastUpdatedSeconds time.Time, contra
}

// Calculates the cost of a contract per month in USD.
func (c *Calculator) calculateContractCost(contract *substrate.Contract) (float64, error) {
func (c *Calculator) calculateContractCost(contract substrate.Contract, node substrate.Node) (float64, error) {
if contract.ContractType.IsNameContract {
return c.calculateUniqueNameCost()
}

nodeID, err := getNodeID(contract)
if err != nil {
return 0, err
}

node, err := c.substrateConn.GetNode(nodeID)
if err != nil {
return 0, err
if node.ID == 0 {
return 0, errors.New("Invalid node")
}

if contract.ContractType.IsNodeContract {

nodeRentContract, err := c.substrateConn.GetNodeRentContract(nodeID)
nodeRentContract, err := c.substrateConn.GetNodeRentContract(uint32(node.ID))
if err != nil {
if errors.Is(err, substrate.ErrNotFound) {
return 0, nil
Expand All @@ -321,29 +364,36 @@ func (c *Calculator) calculateContractCost(contract *substrate.Contract) (float6

if contract.ContractType.IsRentContract {

return c.calculateRentCost(contract, *node)
return c.calculateRentCost(contract, node)
}
return 0, nil
}

// Calculates the cost of a unique name per month in USD.
func (c *Calculator) calculateUniqueNameCost() (float64, error) {
//TODO should we apply stacking discount?

pricingPolicy, err := c.substrateConn.GetPricingPolicy(defaultPricingPolicyID)
if err != nil {
return 0, err
}
// cost in unit-USD
monthlyCost := float64(pricingPolicy.UniqueName.Value) * 24 * 30
return unitToUSD(monthlyCost), nil

costUSD := unitToUSD(monthlyCost)

_, afterDiscount, err := c.CalculatePricesAfterDiscount(costUSD)
if err != nil {
return costUSD, nil
}
return afterDiscount, nil
}

// Calculates the cost of a node contract per month in USD.
//
// There are two cases for node contract cost:
// 1. Node contract on shared node: the cost of the used resources of (shared)
// 2. Node contract on rented node: the cost of the IPV4 only if the contact includes ipv4, else it will return zero.
func (c *Calculator) calculateNodeContractCost(contract *substrate.Contract, onCertifiedNode, isOnRentedNode bool) (float64, error) {
func (c *Calculator) calculateNodeContractCost(contract substrate.Contract, onCertifiedNode, isOnRentedNode bool) (float64, error) {
if !contract.ContractType.IsNodeContract {
return 0, fmt.Errorf("contract ID %d is not a node contract", contract.ContractID)
}
Expand All @@ -359,13 +409,15 @@ func (c *Calculator) calculateNodeContractCost(contract *substrate.Contract, onC
return 0, err
}
totalCost := cost * float64(publicIPsCount)

//TODO should we apply stacking discount?

if onCertifiedNode {
totalCost *= 1.25
}
return totalCost, nil

_, sharedPrice, err := c.CalculatePricesAfterDiscount(totalCost)
if err != nil {
return totalCost, err
}
return sharedPrice, nil
}

// Normal node contract on sharedNode
Expand Down Expand Up @@ -393,7 +445,7 @@ func (c *Calculator) calculateNodeContractCost(contract *substrate.Contract, onC
// Calculates the cost of a rent contract per month in USD.
//
// Rent contract cost is the cost of the node (dedicated discount applied) + the node extra fee
func (c *Calculator) calculateRentCost(contract *substrate.Contract, node substrate.Node) (float64, error) {
func (c *Calculator) calculateRentCost(contract substrate.Contract, node substrate.Node) (float64, error) {
if !contract.ContractType.IsRentContract {
return 0, fmt.Errorf("contract ID %d is not a rent contract", contract.ContractID)
}
Expand Down Expand Up @@ -425,17 +477,6 @@ func (c *Calculator) calculateRentCost(contract *substrate.Contract, node substr
return dedicatedPrice, nil
}

// getNodeID returns the node ID of a contract
func getNodeID(contract *substrate.Contract) (uint32, error) {
if contract.ContractType.IsNodeContract {
return uint32(contract.ContractType.NodeContract.Node), nil
}
if contract.ContractType.IsRentContract {
return uint32(contract.ContractType.RentContract.Node), nil
}
return 0, fmt.Errorf("contract id %d is not a node contract nor rent contract", contract.ContractID)
}

// convertBytesToGB converts bytes to gigabytes by dividing by 1024^3
func convertBytesToGB(bytes types.U64) uint64 {
return uint64(bytes) / 1024 / 1024 / 1024
Expand Down Expand Up @@ -474,3 +515,15 @@ func calculateTotalOverdraftTFT(paymentState *substrate.ContractPaymentState) fl
}
return unitToTFT(totalOverDraft.Int)
}

func (c Calculator) getNode(contractType substrate.ContractType) (node *substrate.Node, err error) {
if contractType.IsNodeContract {
return c.substrateConn.GetNode(uint32(contractType.NodeContract.Node))
}
if contractType.IsRentContract {
return c.substrateConn.GetNode(uint32(contractType.RentContract.Node))
}

// contract type is not a node contract nor rent contract, and that is fine as the node will not be used in name contract
return nil, nil
}
Loading
Loading