Optimize billing efficiency (#5362)

* billing reconciler changed from controller trigger to scheduled task execution.

* with manager runner

* with manager runner

* change cmv billing with manager runner

* optimize log

* fix reconcile

* add billing with wait group goroutine

* add logger

* get the user cr using the cacheless client

* optimize

* Removes mongo Aggregate processing data that is too slow and uses in-memory batch user metering data.

* fix

* remove the owner that does not need to be updated

* optimize log && fix the genGroupKey GenerateBillingDataFromRecords rules

* make format
This commit is contained in:
Jiahui
2025-02-10 16:33:16 +08:00
committed by GitHub
parent 232bc84584
commit 5cfeb599ca
9 changed files with 654 additions and 308 deletions
@@ -59,6 +59,33 @@ import (
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
type CVMTaskRunner struct {
DBClient database.Interface
Logger logr.Logger
*AccountReconciler
}
func (r *CVMTaskRunner) Start(ctx context.Context) error {
ticker := time.NewTicker(env.GetDurationEnvWithDefault("BILLING_CVM_INTERVAL", 10*time.Minute))
defer func() {
ticker.Stop()
r.Logger.Info("stop billing cvm")
}()
for {
select {
case <-ticker.C:
r.Logger.Info("start billing cvm", "time", time.Now().Format(time.RFC3339))
err := r.BillingCVM()
if err != nil {
r.Logger.Error(err, "fail to billing cvm")
}
r.Logger.Info("end billing cvm", "time", time.Now().Format(time.RFC3339))
case <-ctx.Done():
return nil
}
}
}
const (
ACCOUNTNAMESPACEENV = "ACCOUNT_NAMESPACE"
DEFAULTACCOUNTNAMESPACE = "sealos-system"
@@ -22,27 +22,63 @@ import (
"strings"
"time"
"k8s.io/client-go/rest"
"k8s.io/client-go/kubernetes/scheme"
"github.com/labring/sealos/controllers/pkg/utils/env"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
userv1 "github.com/labring/sealos/controllers/user/api/v1"
ctrl "sigs.k8s.io/controller-runtime"
"github.com/labring/sealos/controllers/pkg/types"
v12 "github.com/labring/sealos/controllers/account/api/v1"
"github.com/labring/sealos/controllers/pkg/resources"
"sigs.k8s.io/controller-runtime/pkg/controller"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"github.com/labring/sealos/controllers/pkg/database"
v1 "github.com/labring/sealos/controllers/user/api/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)
type BillingTaskRunner struct {
*BillingReconciler
}
func (r *BillingTaskRunner) Start(ctx context.Context) error {
if err := r.ExecuteBillingTask(); err != nil {
r.Logger.Error(err, "failed to execute billing task")
}
defer func() {
r.Logger.Info("stop billing reconcile", "time", time.Now().Format(time.RFC3339))
}()
now := time.Now()
nextHour := now.Truncate(time.Hour).Add(time.Hour).Add(5 * time.Minute)
r.Logger.Info("next billing reconcile time", "time", nextHour.Format(time.RFC3339))
time.Sleep(nextHour.Sub(now))
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
if err := r.ExecuteBillingTask(); err != nil {
r.Logger.Error(err, "failed to execute billing task")
}
select {
case <-ticker.C:
if err := r.ExecuteBillingTask(); err != nil {
r.Logger.Error(err, "failed to execute billing task")
}
case <-ctx.Done():
return nil
}
}
}
const (
UserNamespacePrefix = "ns-"
ResourceQuotaPrefix = "quota-"
@@ -55,83 +91,110 @@ type BillingReconciler struct {
client.Client
Scheme *runtime.Scheme
logr.Logger
DBClient database.Account
AccountV2 database.AccountV2
Properties *resources.PropertyTypeLS
DBClient database.Account
AccountV2 database.AccountV2
Properties *resources.PropertyTypeLS
concurrentLimit int64
}
//+kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=resourcequotas,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=get;list;watch;create;update;patch;delete
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the Billing object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.11.2/pkg/reconcile
func (r *BillingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
r.Logger.V(1).Info("Reconcile Billing: ", "req.NamespacedName", req.NamespacedName)
ns := &corev1.Namespace{}
if err := r.Get(ctx, req.NamespacedName, ns); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if ns.DeletionTimestamp != nil {
r.Logger.V(1).Info("namespace is deleting", "namespace", ns)
return ctrl.Result{}, nil
}
owner := ns.Labels[v1.UserLabelOwnerKey]
nsList, err := getOwnNsList(r.Client, owner)
func (r *BillingReconciler) ExecuteBillingTask() error {
r.Logger.Info("start billing reconcile", "time", time.Now().Format(time.RFC3339))
ownerListMap, err := r.getRecentUsedOwners()
if err != nil {
r.Logger.Error(err, "get own namespace list failed")
return ctrl.Result{Requeue: true}, err
return fmt.Errorf("failed to get the owner list of the recently used resource: %w", err)
}
r.Logger.V(1).Info("own namespace list", "own", owner, "nsList", nsList)
now := time.Now()
currentHourTime := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.Local).UTC()
queryTime := currentHourTime.Add(-1 * time.Hour)
err = r.reconcileOwnerListBatch(ownerListMap, env.GetIntEnvWithDefault("BILLING_RECONCILE_BATCH_COUNT", 200), time.Now(), r.reconcileOwnerList)
if err != nil {
return fmt.Errorf("failed to reconcile owner list batch: %w", err)
}
r.Logger.Info("finish billing reconcile", "time", time.Now().Format(time.RFC3339))
return nil
}
// TODO r.处理Unsettle状态的账单
if exist, lastUpdateTime, _ := r.DBClient.GetBillingLastUpdateTime(owner, v12.Consumption); exist {
if lastUpdateTime.Equal(currentHourTime) || lastUpdateTime.After(currentHourTime) {
return ctrl.Result{Requeue: true, RequeueAfter: time.Until(currentHourTime.Add(1*time.Hour + 10*time.Minute))}, nil
}
// 24小时内的数据,从上次更新时间开始计算,否则从当前时间起算
if lastUpdateTime.After(currentHourTime.Add(-24 * time.Hour)) {
queryTime = lastUpdateTime
}
func (r *BillingReconciler) reconcileOwnerList(ownerListMap map[string][]string, now time.Time) error {
endHourTime := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.Local).UTC()
startHourTime := endHourTime.Add(-1 * time.Hour)
var ownerList, failedList []string
for owner := range ownerListMap {
ownerList = append(ownerList, owner)
}
updateOwnerList, err := r.DBClient.GetOwnersRecentUpdates(ownerList, endHourTime)
if err != nil {
return fmt.Errorf("get owners without recent updates failed: %w", err)
}
orderList := []string{}
consumAmount := int64(0)
// 计算上次billing到当前的时间之间的整点,左开右闭
for t := queryTime.Truncate(time.Hour).Add(time.Hour); t.Before(currentHourTime) || t.Equal(currentHourTime); t = t.Add(time.Hour) {
ids, amount, err := r.DBClient.GenerateBillingData(t.Add(-1*time.Hour), t, r.Properties, nsList, getUsername(owner))
if err != nil {
return ctrl.Result{}, fmt.Errorf("generate billing data failed: %w", err)
}
orderList = append(orderList, ids...)
consumAmount += amount
// remove the owner that does not need to be updated
for _, owner := range updateOwnerList {
delete(ownerListMap, owner)
}
if consumAmount > 0 {
if err := r.rechargeBalance(owner, consumAmount); err != nil {
for i := range orderList {
if err := r.DBClient.UpdateBillingStatus(orderList[i], resources.Unsettled); err != nil {
r.Logger.Error(err, "update billing status failed", "id", orderList[i])
}
r.Logger.Info("get owners recent updates", "already update owner count", len(updateOwnerList), "remaining owner count", len(ownerListMap))
ownerBillings, err := r.DBClient.GenerateBillingData(startHourTime, endHourTime, r.Properties, ownerListMap)
if err != nil {
return fmt.Errorf("generate billing data failed: %w", err)
}
r.Logger.Info("generate billing data", "count", len(ownerBillings))
for owner, billings := range ownerBillings {
amount := int64(0)
orderIDs := make([]string, 0, len(billings))
for _, billing := range billings {
amount += billing.Amount
orderIDs = append(orderIDs, billing.OrderID)
}
if err = r.DBClient.SaveBillings(billings...); err != nil {
r.Logger.Error(err, "save billings failed", "owner", owner, "amount", amount)
failedList = append(failedList, owner)
continue
}
if err := r.rechargeBalance(owner, amount); err != nil {
r.Logger.Error(err, "recharge balance failed", "owner", owner, "amount", amount)
failedList = append(failedList, owner)
if err := r.DBClient.UpdateBillingStatus(orderIDs, resources.Unsettled); err != nil {
r.Logger.Error(err, "update billing unsettled status failed", "orderIDs", orderIDs)
}
return ctrl.Result{}, fmt.Errorf("recharge balance failed: %w", err)
}
r.Logger.V(1).Info("success recharge balance", "owner", owner, "amount", consumAmount)
}
return ctrl.Result{Requeue: true, RequeueAfter: time.Until(currentHourTime.Add(1*time.Hour + 10*time.Minute))}, nil
if len(failedList) > 0 {
r.Logger.Error(fmt.Errorf("failed to reconcile owner list: %v", failedList), "failed to reconcile owner list")
}
return nil
}
// reconcileOwnerListBatch process ownerlistmap in batch mode
func (r *BillingReconciler) reconcileOwnerListBatch(
ownerListMap map[string][]string, // The owner -> namespaces mapping needs to be handled
batchSize int, // number of owners processed per batch
now time.Time, // current time
reconcileFunc func(map[string][]string, time.Time) error, // processing function
) error {
if batchSize <= 0 {
return fmt.Errorf("batch size must be greater than zero")
}
owners := make([]string, 0, len(ownerListMap)) // store all owners
for owner := range ownerListMap {
owners = append(owners, owner)
}
total := len(owners)
for i := 0; i < total; i += batchSize {
end := i + batchSize
if end > total {
end = total
}
batchOwners := owners[i:end] // the owner list of the current batch
batchOwnerMap := make(map[string][]string, len(batchOwners))
for _, owner := range batchOwners {
batchOwnerMap[owner] = ownerListMap[owner] // example retrieve a namespace
}
// call processing logic
if err := reconcileFunc(batchOwnerMap, now); err != nil {
return fmt.Errorf("failed to reconcile batch from %d to %d: %w", i, end, err)
}
r.Logger.Info("reconcile batch", "from", i, "to", end)
}
return nil
}
func (r *BillingReconciler) rechargeBalance(owner string, amount int64) (err error) {
@@ -144,48 +207,90 @@ func (r *BillingReconciler) rechargeBalance(owner string, amount int64) (err err
return nil
}
func getOwnNsList(clt client.Client, user string) ([]string, error) {
nsList := &corev1.NamespaceList{}
if err := clt.List(context.Background(), nsList, client.MatchingLabels{v1.UserLabelOwnerKey: user}); err != nil {
return nil, fmt.Errorf("list namespace failed: %w", err)
func (r *BillingReconciler) getRecentUsedOwners() (map[string][]string, error) {
now := time.Now()
endHourTime := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.Local).UTC()
startHourTime := endHourTime.Add(-1 * time.Hour)
namespaceList, err := r.DBClient.GetTimeUsedNamespaceList(startHourTime, endHourTime)
if err != nil {
return nil, fmt.Errorf("get recent owners failed: %w", err)
}
nsListStr := make([]string, len(nsList.Items))
for i := range nsList.Items {
nsListStr[i] = nsList.Items[i].Name
nsToOwnerMap, err := GetAllUser()
if err != nil {
return nil, fmt.Errorf("get all user failed: %w", err)
}
return nsListStr, nil
}
func (r *BillingReconciler) initDB() error {
return r.DBClient.CreateBillingIfNotExist()
}
// SetupWithManager sets up the controller with the Manager.
func (r *BillingReconciler) SetupWithManager(mgr ctrl.Manager, rateOpts controller.Options) error {
r.Logger = ctrl.Log.WithName("controller").WithName("Billing")
if err := r.initDB(); err != nil {
r.Logger.Error(err, "init db failed")
r.Logger.Info("get owner and namespace", "owner count", len(nsToOwnerMap), "namespace count", len(namespaceList))
usedOwnerList := make(map[string][]string)
for _, ns := range namespaceList {
if owner, ok := nsToOwnerMap[ns]; ok {
if _, ok := usedOwnerList[owner]; !ok {
usedOwnerList[owner] = []string{}
}
usedOwnerList[owner] = append(usedOwnerList[owner], ns)
}
}
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.Namespace{}, builder.WithPredicates(predicate.Funcs{
CreateFunc: func(createEvent event.CreateEvent) bool {
own, ok := createEvent.Object.GetLabels()[v1.UserLabelOwnerKey]
return ok && getUsername(createEvent.Object.GetName()) == own
},
UpdateFunc: func(_ event.UpdateEvent) bool {
return false
},
DeleteFunc: func(_ event.DeleteEvent) bool {
return false
},
GenericFunc: func(_ event.GenericEvent) bool {
return false
},
})).
WithOptions(rateOpts).
Complete(r)
r.Logger.Info("get all user", "count", len(usedOwnerList))
return usedOwnerList, nil
}
func getUsername(namespace string) string {
return strings.TrimPrefix(namespace, UserNamespacePrefix)
}
func (r *BillingReconciler) Init() error {
r.Logger = ctrl.Log.WithName("controller").WithName("Billing")
if err := r.DBClient.CreateBillingIfNotExist(); err != nil {
return fmt.Errorf("create billing collection failed: %w", err)
}
r.concurrentLimit = env.GetInt64EnvWithDefault("BILLING_CONCURRENT_LIMIT", 100)
return nil
}
// map[namespace]owner
func GetAllUser() (map[string]string, error) {
err := userv1.AddToScheme(scheme.Scheme)
if err != nil {
return nil, fmt.Errorf("unable to add scheme: %v", err)
}
config, err := rest.InClusterConfig()
if err != nil {
return nil, fmt.Errorf("unable to build config: %v", err)
}
//TODO from cluster config
//config, err := clientcmd.BuildConfigFromFlags("", os.Getenv("KUBECONFIG"))
//if err != nil {
// return nil, fmt.Errorf("unable to build config: %v", err)
//}
k8sClt, err := client.New(config, client.Options{Scheme: scheme.Scheme})
if err != nil {
return nil, fmt.Errorf("unable to create client: %v", err)
}
nsToOwnerMap := make(map[string]string)
listOpts := &client.ListOptions{
Limit: 5000,
}
for {
userMetaList := &metav1.PartialObjectMetadataList{}
userMetaList.SetGroupVersionKind(userv1.GroupVersion.WithKind("UserList"))
if err := k8sClt.List(context.Background(), userMetaList, listOpts); err != nil {
return nil, fmt.Errorf("failed to list instances: %v", err)
}
for _, user := range userMetaList.Items {
owner := user.Annotations[userv1.UserLabelOwnerKey]
if owner == "" {
continue
}
nsToOwnerMap["ns-"+user.Name] = owner
}
token := userMetaList.GetContinue()
if token == "" {
break
}
listOpts.Continue = token
}
return nsToOwnerMap, nil
}
@@ -246,6 +246,18 @@ func (r *DebtReconciler) reconcile(ctx context.Context, userCr, userID string) e
return nil
}
func getOwnNsList(clt client.Client, user string) ([]string, error) {
nsList := &corev1.NamespaceList{}
if err := clt.List(context.Background(), nsList, client.MatchingLabels{userv1.UserLabelOwnerKey: user}); err != nil {
return nil, fmt.Errorf("list namespace failed: %w", err)
}
nsListStr := make([]string, len(nsList.Items))
for i := range nsList.Items {
nsListStr[i] = nsList.Items[i].Name
}
return nsListStr, nil
}
var ErrAccountNotExist = errors.New("account not exist")
/*
+36 -19
View File
@@ -29,7 +29,6 @@ import (
notificationv1 "github.com/labring/sealos/controllers/pkg/notification/api/v1"
"github.com/labring/sealos/controllers/pkg/resources"
"github.com/labring/sealos/controllers/pkg/types"
"github.com/labring/sealos/controllers/pkg/utils/env"
rate "github.com/labring/sealos/controllers/pkg/utils/rate"
userv1 "github.com/labring/sealos/controllers/user/api/v1"
@@ -218,14 +217,23 @@ func main() {
setupLog.Error(err, "unable to get property type")
os.Exit(1)
}
if err = (&controllers.BillingReconciler{
billingReconciler := controllers.BillingReconciler{
DBClient: dbClient,
Properties: resources.DefaultPropertyTypeLS,
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
AccountV2: v2Account,
}).SetupWithManager(mgr, rateOpts); err != nil {
setupManagerError(err, "Billing")
}
if err = billingReconciler.Init(); err != nil {
setupLog.Error(err, "unable to init billing reconciler")
os.Exit(1)
}
billingTaskRunner := &controllers.BillingTaskRunner{
BillingReconciler: &billingReconciler,
}
if err := mgr.Add(billingTaskRunner); err != nil {
setupLog.Error(err, "unable to add billing task runner")
os.Exit(1)
}
if err = (&controllers.PodReconciler{
@@ -261,23 +269,32 @@ func main() {
os.Exit(1)
}
go func() {
if cvmDBClient == nil {
setupLog.Info("CVM DB client is nil, skip billing cvm")
return
if cvmDBClient != nil {
cvmTaskRunner := &controllers.CVMTaskRunner{
DBClient: cvmDBClient,
Logger: ctrl.Log.WithName("CVMTaskRunner"),
AccountReconciler: accountReconciler,
}
ticker := time.NewTicker(env.GetDurationEnvWithDefault("BILLING_CVM_INTERVAL", 10*time.Minute))
defer ticker.Stop()
for {
setupLog.Info("start billing cvm", "time", time.Now().Format(time.RFC3339))
err := accountReconciler.BillingCVM()
if err != nil {
setupLog.Error(err, "fail to billing cvm")
}
setupLog.Info("end billing cvm", "time", time.Now().Format(time.RFC3339))
<-ticker.C
if err := mgr.Add(cvmTaskRunner); err != nil {
setupLog.Error(err, "unable to add cvm task runner")
os.Exit(1)
}
}()
}
//go func() {
// now := time.Now()
// nextHour := now.Truncate(time.Hour).Add(time.Hour)
// time.Sleep(nextHour.Sub(now))
//
// ticker := time.NewTicker(time.Hour)
// defer ticker.Stop()
// for {
// setupLog.Info("start billing reconcile", "time", time.Now().Format(time.RFC3339))
// if err := billingReconciler.ExecuteBillingTask(); err != nil {
// setupLog.Error(err, "failed to execute billing task")
// }
// <-ticker.C
// }
//}()
setupLog.Info("starting manager")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
+5
View File
@@ -15,3 +15,8 @@
package common
type Type int
const (
Consumption Type = iota
SubConsumption
)
+4 -2
View File
@@ -47,19 +47,21 @@ type CVM interface {
type Account interface {
GetBillingLastUpdateTime(owner string, _type common.Type) (bool, time.Time, error)
GetOwnersRecentUpdates(ownerList []string, checkTime time.Time) ([]string, error)
GetTimeUsedNamespaceList(startTime, endTime time.Time) ([]string, error)
SaveBillings(billing ...*resources.Billing) error
SaveObjTraffic(obs ...*types.ObjectStorageTraffic) error
GetAllLatestObjTraffic(startTime, endTime time.Time) ([]types.ObjectStorageTraffic, error)
HandlerTimeObjBucketSentTraffic(startTime, endTime time.Time, bucket string) (int64, error)
GetTimeObjBucketBucket(startTime, endTime time.Time) ([]string, error)
GetUnsettingBillingHandler(owner string) ([]resources.BillingHandler, error)
UpdateBillingStatus(orderID string, status resources.BillingStatus) error
UpdateBillingStatus(orderIDs []string, status resources.BillingStatus) error
GetUpdateTimeForCategoryAndPropertyFromMetering(category string, property string) (time.Time, error)
GetAllPayment() ([]resources.Billing, error)
InitDefaultPropertyTypeLS() error
SavePropertyTypes(types []resources.PropertyType) error
GetBillingCount(accountType common.Type, startTime, endTime time.Time) (count, amount int64, err error)
GenerateBillingData(startTime, endTime time.Time, prols *resources.PropertyTypeLS, namespaces []string, owner string) (orderID []string, amount int64, err error)
GenerateBillingData(startTime, endTime time.Time, prols *resources.PropertyTypeLS, ownerToNS map[string][]string) (map[string][]*resources.Billing, error)
InsertMonitor(ctx context.Context, monitors ...*resources.Monitor) error
GetDistinctMonitorCombinations(startTime, endTime time.Time) ([]resources.Monitor, error)
DropMonitorCollectionsOlderThan(days int) error
+283 -153
View File
@@ -149,6 +149,87 @@ func (m *mongoDB) GetBillingLastUpdateTime(owner string, _type common.Type) (boo
return false, time.Time{}, fmt.Errorf("failed to convert time field to primitive.DateTime: %v", result["time"])
}
func (m *mongoDB) GetOwnersRecentUpdates(ownerList []string, checkTime time.Time) ([]string, error) {
// MongoDB filter
filter := bson.M{
"owner": bson.M{"$in": ownerList},
"type": common.Consumption,
"app_type": bson.M{
"$ne": resources.AppType[resources.CVM],
},
}
// Aggregate query: Group by owner to get the latest time
pipeline := mongo.Pipeline{
{{Key: "$match", Value: filter}},
{{Key: "$sort", Value: bson.D{{Key: "owner", Value: 1}, {Key: "time", Value: -1}}}}, // Sort by owner first, then by time in descending order
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$owner"},
{Key: "lastUpdateTime", Value: bson.D{{Key: "$first", Value: "$time"}}}, // fetch latest Time
}}},
}
// execute aggregate query
cursor, err := m.getBillingCollection().Aggregate(context.Background(), pipeline)
if err != nil {
return nil, fmt.Errorf("failed to execute aggregate query: %w", err)
}
defer cursor.Close(context.Background())
// get all the data out at once
var results []struct {
Owner string `bson:"_id"`
LastUpdateRaw primitive.DateTime `bson:"lastUpdateTime"`
}
if err := cursor.All(context.Background(), &results); err != nil {
return nil, fmt.Errorf("failed to decode cursor: %w", err)
}
// use map to store query results
latestUpdates := make(map[string]time.Time, len(results))
for _, result := range results {
latestUpdates[result.Owner] = result.LastUpdateRaw.Time()
}
// **In-memory processing: Filters owners that have been updated since checkTime**
var updatedOwners []string
for _, owner := range ownerList {
lastUpdateTime, exists := latestUpdates[owner]
if exists && (lastUpdateTime.After(checkTime) || lastUpdateTime.Equal(checkTime)) {
updatedOwners = append(updatedOwners, owner)
}
}
return updatedOwners, nil
}
func (m *mongoDB) GetTimeUsedNamespaceList(startTime, endTime time.Time) ([]string, error) {
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.D{{Key: "time", Value: bson.D{{Key: "$gte", Value: startTime}, {Key: "$lt", Value: endTime}}}}}},
{{Key: "$group", Value: bson.D{{Key: "_id", Value: "$category"}}}},
{{Key: "$project", Value: bson.D{{Key: "_id", Value: 0}, {Key: "namespace", Value: "$_id"}}}},
}
cursor, err := m.getMonitorCollection(startTime).Aggregate(context.Background(), pipeline)
if err != nil {
return nil, fmt.Errorf("aggregate error: %v", err)
}
defer cursor.Close(context.Background())
var namespaces []string
for cursor.Next(context.Background()) {
var result struct {
Namespace string `bson:"namespace"`
}
err := cursor.Decode(&result)
if err != nil {
return nil, fmt.Errorf("decode error: %v", err)
}
namespaces = append(namespaces, result.Namespace)
}
if err = cursor.Err(); err != nil {
return nil, fmt.Errorf("cursor error: %v", err)
}
return namespaces, nil
}
func (m *mongoDB) GetUnsettingBillingHandler(owner string) ([]resources.BillingHandler, error) {
filter := bson.M{
"owner": owner,
@@ -178,15 +259,15 @@ func (m *mongoDB) GetUnsettingBillingHandler(owner string) ([]resources.BillingH
return results, nil
}
func (m *mongoDB) UpdateBillingStatus(orderID string, status resources.BillingStatus) error {
func (m *mongoDB) UpdateBillingStatus(orderIDs []string, status resources.BillingStatus) error {
// create a query filter
filter := bson.M{"order_id": orderID}
filter := bson.M{"order_id": bson.M{"$in": orderIDs}}
update := bson.M{
"$set": bson.M{
"status": status,
},
}
_, err := m.getBillingCollection().UpdateOne(context.Background(), filter, update)
_, err := m.getBillingCollection().UpdateMany(context.Background(), filter, update)
if err != nil {
return fmt.Errorf("update error: %v", err)
}
@@ -421,185 +502,234 @@ func (m *mongoDB) SavePropertyTypes(types []resources.PropertyType) error {
return err
}
func (m *mongoDB) GenerateBillingData(startTime, endTime time.Time, prols *resources.PropertyTypeLS, namespaces []string, owner string) (orderID []string, amount int64, err error) {
minutes := endTime.Sub(startTime).Minutes()
groupStage := bson.D{
primitive.E{Key: "_id", Value: bson.D{{Key: "type", Value: "$type"}, {Key: "name", Value: "$name"}, {Key: "category", Value: "$category"}, {Key: "parent_type", Value: "$parent_type"}, {Key: "parent_name", Value: "$parent_name"}}},
primitive.E{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}},
}
projectStage := bson.D{
primitive.E{Key: "_id", Value: 0},
primitive.E{Key: "type", Value: "$_id.type"},
primitive.E{Key: "name", Value: "$_id.name"},
primitive.E{Key: "parent_type", Value: "$_id.parent_type"},
primitive.E{Key: "parent_name", Value: "$_id.parent_name"},
primitive.E{Key: "category", Value: "$_id.category"},
}
// initialize the used phase
usedStage := bson.M{}
// Build the $group and $project phases dynamically from EnumMap
for key, value := range prols.EnumMap {
keyStr := strconv.Itoa(int(key))
// $max - $min;
// When max is not zero, the minimum value other than the zero value is used to prevent some data from obtaining a value in special cases
// max-min=0 if the hour has only one data piece or no data piece
if value.PriceType == resources.DIF {
// for non 0 $min
minWithCondition := bson.D{
{Key: "$min", Value: bson.D{
{Key: "$cond", Value: bson.A{
bson.D{{Key: "$eq", Value: bson.A{"$used." + keyStr, 0}}},
nil, // 将0值排除在外
"$used." + keyStr,
}},
}},
}
groupStage = append(groupStage,
primitive.E{Key: keyStr + "_max", Value: bson.D{{Key: "$max", Value: "$used." + keyStr}}}, // 正常计算$max
primitive.E{Key: keyStr + "_min", Value: minWithCondition},
)
// added to the used phase
usedStage[keyStr] = bson.D{{Key: "$subtract", Value: bson.A{
"$" + keyStr + "_max",
"$" + keyStr + "_min",
}}}
continue
}
if value.PriceType == resources.SUM {
groupStage = append(groupStage, primitive.E{Key: keyStr, Value: bson.D{{Key: "$sum", Value: "$used." + keyStr}}})
usedStage[keyStr] = bson.D{{Key: "$toInt", Value: "$" + keyStr}}
continue
}
groupStage = append(groupStage, primitive.E{Key: keyStr, Value: bson.D{{Key: "$sum", Value: "$used." + keyStr}}})
usedStage[keyStr] = bson.D{{Key: "$toInt", Value: bson.D{{Key: "$round", Value: bson.D{{Key: "$divide", Value: bson.A{
"$" + keyStr, minutes}}}}}}}
}
// add the used phase to the $project phase
projectStage = append(projectStage, primitive.E{Key: "used", Value: usedStage})
// construction-pipeline
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.D{{Key: "time", Value: bson.D{{Key: "$gte", Value: startTime}, {Key: "$lt", Value: endTime}}}, {Key: "category", Value: bson.D{{Key: "$in", Value: namespaces}}}}}},
{{Key: "$group", Value: groupStage}},
{{Key: "$project", Value: projectStage}},
}
cursor, err := m.getMonitorCollection(startTime).Aggregate(context.Background(), pipeline)
func (m *mongoDB) GenerateBillingData(startTime, endTime time.Time, prols *resources.PropertyTypeLS, ownerToNS map[string][]string) (map[string][]*resources.Billing, error) {
ownerMonitors, err := m.FetchOwnerMonitorRecords(startTime, endTime, ownerToNS)
if err != nil {
return nil, 0, fmt.Errorf("aggregate error: %v", err)
return nil, fmt.Errorf("failed to fetch monitor records: %v", err)
}
var ownerBillings = make(map[string][]*resources.Billing)
for owner, monitors := range ownerMonitors {
billings, err := GenerateBillingDataFromRecords(monitors, prols, startTime, endTime, owner)
if err != nil {
return nil, fmt.Errorf("failed to generate billing data: %v", err)
}
ownerBillings[owner] = billings
}
return ownerBillings, nil
}
func (m *mongoDB) FetchOwnerMonitorRecords(startTime, endTime time.Time, ownerToNS map[string][]string) (map[string][]resources.Monitor, error) {
// collect all namespaces to avoid repetition
nsSet := make(map[string]struct{})
for _, nsList := range ownerToNS {
for _, ns := range nsList {
nsSet[ns] = struct{}{}
}
}
namespaces := make([]string, 0, len(nsSet))
for ns := range nsSet {
namespaces = append(namespaces, ns)
}
// get all matching monitor records from mongodb
collection := m.getMonitorCollection(startTime)
filter := bson.M{
"time": bson.M{"$gte": startTime, "$lt": endTime},
"category": bson.M{"$in": namespaces}, // 查询所有涉及的 namespaces
}
cursor, err := collection.Find(context.Background(), filter)
if err != nil {
return nil, fmt.Errorf("failed to find monitor records: %w", err)
}
defer cursor.Close(context.Background())
var appCostsMap = make(map[string]map[string][]resources.AppCost)
// map[ns/type]int64
var nsTypeAmount = make(map[string]int64)
// reading mongodb data
var allRecords []resources.Monitor
if err := cursor.All(context.Background(), &allRecords); err != nil {
return nil, fmt.Errorf("failed to decode monitor records: %w", err)
}
for cursor.Next(context.Background()) {
var result struct {
Type uint8 `bson:"type"`
Namespace string `bson:"category"`
Name string `bson:"name"`
ParentType uint8 `bson:"parent_type"`
ParentName string `bson:"parent_name"`
Used resources.EnumUsedMap `bson:"used"`
}
err := cursor.Decode(&result)
if err != nil {
return nil, 0, fmt.Errorf("decode error: %v", err)
}
//TODO delete
//logger.Info("generate billing data", "result", result)
if _, ok := appCostsMap[result.Namespace]; !ok {
appCostsMap[result.Namespace] = make(map[string][]resources.AppCost)
}
strType := strconv.Itoa(int(result.Type))
if _, ok := appCostsMap[result.Namespace][strType]; !ok {
appCostsMap[result.Namespace][strType] = make([]resources.AppCost, 0)
}
appCost := resources.AppCost{
Type: result.Type,
Used: result.Used,
Name: result.Name,
UsedAmount: make(map[uint8]int64),
}
// Calculate the amount and set the used value
for property := range result.Used {
if prop, ok := prols.EnumMap[property]; ok {
if prop.UnitPrice > 0 {
appCost.UsedAmount[property] = int64(math.Ceil(float64(result.Used[property]) * prop.UnitPrice))
appCost.Amount += appCost.UsedAmount[property]
// build the mapping of owner monitor data
ownerMonitorRecords := make(map[string][]resources.Monitor)
for _, record := range allRecords {
for owner, nsList := range ownerToNS {
// Only the records of the namespace that belong to the owner are saved
for _, ns := range nsList {
if record.Category == ns {
ownerMonitorRecords[owner] = append(ownerMonitorRecords[owner], record)
break // avoid duplicate additions
}
}
}
if appCost.Amount == 0 {
continue
}
key := result.Namespace + "/" + strType
if result.ParentType != 0 && result.ParentName != "" {
key = result.Namespace + "/" + strconv.Itoa(int(result.ParentType)) + "/" + result.ParentName
}
nsTypeAmount[key] += appCost.Amount
appCostsMap[result.Namespace][key] = append(appCostsMap[result.Namespace][key], appCost)
}
return ownerMonitorRecords, nil
}
func GenerateBillingDataFromRecords(records []resources.Monitor, prols *resources.PropertyTypeLS, startTime, endTime time.Time, owner string) (billings []*resources.Billing, err error) {
// Calculate the interval (minutes) to ensure that the divisor is not 0
minutes := math.Max(endTime.Sub(startTime).Minutes(), 1)
// 存储分组后的数据
aggregatedMap := make(map[string]*struct {
resources.Monitor
UsedValues map[uint8][]int64
Count int64
})
// 分组 key 生成规则
genGroupKey := func(rec resources.Monitor) string {
return fmt.Sprintf("%s/%d/%s", rec.Category, rec.Type, rec.Name)
}
// 遍历所有记录,按分组键聚合
for _, rec := range records {
key := genGroupKey(rec)
if _, ok := aggregatedMap[key]; !ok {
aggregatedMap[key] = &struct {
resources.Monitor
UsedValues map[uint8][]int64
Count int64
}{
Monitor: rec,
UsedValues: make(map[uint8][]int64),
Count: 0,
}
}
aggregatedMap[key].Count++
for k, v := range rec.Used {
//aggregatedMap[key].UsedValues[k] = append(aggregatedMap[key].UsedValues[k], v)
if _, exists := aggregatedMap[key].UsedValues[k]; !exists {
aggregatedMap[key].UsedValues[k] = make([]int64, 0, len(records))
}
aggregatedMap[key].UsedValues[k] = append(aggregatedMap[key].UsedValues[k], v)
}
}
// 存储最终计费数据
// map[namespace]map[app_type | parent_type/parent_name][]resources.AppCost
appCostsMap := make(map[string]map[string][]resources.AppCost)
nsTypeAmount := make(map[string]map[string]int64)
calculateFinalUsed := func(values map[uint8][]int64, prols *resources.PropertyTypeLS, minutes float64) map[uint8]int64 {
finalUsed := make(map[uint8]int64)
for propKey, vals := range values {
if prop, ok := prols.EnumMap[propKey]; ok {
finalUsed[propKey] = computeUsedValue(vals, prop, minutes)
}
}
return finalUsed
}
// 计算最终 Used 数据
for _, agg := range aggregatedMap {
finalUsed := calculateFinalUsed(agg.UsedValues, prols, minutes)
// 计算费用
appCost := resources.AppCost{
Type: agg.Type,
Name: agg.Name,
Used: finalUsed,
UsedAmount: make(map[uint8]int64),
}
var totalAmount int64
for propKey, usedVal := range finalUsed {
if prop, ok := prols.EnumMap[propKey]; ok {
if prop.UnitPrice > 0 {
feeFloat := float64(usedVal) * prop.UnitPrice
if feeFloat > math.MaxInt64 {
return nil, fmt.Errorf("fee calculation overflow: %f", feeFloat)
}
fee := int64(math.Ceil(feeFloat))
appCost.UsedAmount[propKey] = fee
totalAmount += fee
}
}
}
if totalAmount == 0 {
continue
}
appCost.Amount = totalAmount
groupKey := strconv.Itoa(int(agg.Type))
if agg.ParentType != 0 && agg.ParentName != "" {
groupKey = strconv.Itoa(int(agg.ParentType)) + "/" + agg.ParentName
}
ns := agg.Category
if _, ok := nsTypeAmount[ns]; !ok {
nsTypeAmount[ns] = make(map[string]int64)
}
nsTypeAmount[ns][groupKey] += totalAmount
if _, ok := appCostsMap[ns]; !ok {
appCostsMap[ns] = make(map[string][]resources.AppCost)
}
appCostsMap[ns][groupKey] = append(appCostsMap[ns][groupKey], appCost)
}
billings = make([]*resources.Billing, 0)
// 生成 Billing 数据
for ns, appCostMap := range appCostsMap {
for tp, appCost := range appCostMap {
amountt := nsTypeAmount[tp]
if amountt == 0 {
for tp, appCostList := range appCostMap {
amount := nsTypeAmount[ns][tp]
if amount <= 0 {
continue
}
id, err := gonanoid.New(12)
if err != nil {
return nil, 0, fmt.Errorf("generate billing id error: %v", err)
return nil, fmt.Errorf("generate billing id error: %v", err)
}
// tp = ns/type/parentName && parentName not contain "/"
appType, appName := 0, ""
switch strings.Count(tp, "/") {
case 1:
appType, _ = strconv.Atoi(strings.Split(tp, "/")[1])
case 2:
appType, _ = strconv.Atoi(strings.Split(tp, "/")[1])
appName = strings.Split(tp, "/")[2]
parts := strings.Split(tp, "/")
appType, _ := strconv.Atoi(parts[0])
appName := ""
if len(parts) > 1 {
appName = parts[1]
}
billing := resources.Billing{
billings = append(billings, &resources.Billing{
OrderID: id,
Type: Consumption,
Namespace: ns,
AppType: uint8(appType),
AppName: appName,
AppCosts: appCost,
Amount: amountt,
AppCosts: appCostList,
Amount: amount,
Owner: owner,
Time: endTime,
Status: resources.Settled,
}
amount += amountt
orderID = append(orderID, id)
// Insert the billing document
_, err = m.getBillingCollection().InsertOne(context.Background(), billing)
if err != nil {
return nil, 0, fmt.Errorf("insert error: %v", err)
}
//TODO delete
//logger.Info("generate billing data", "billing", billing)
})
}
}
return billings, nil
}
if err = cursor.Err(); err != nil {
return nil, 0, fmt.Errorf("cursor error: %v", err)
func computeUsedValue(usedValues []int64, prop resources.PropertyType, minutes float64) int64 {
switch prop.PriceType {
case resources.DIF:
var maxVal int64 = -math.MaxInt64
var minVal int64 = math.MaxInt64
for _, v := range usedValues {
if v > maxVal {
maxVal = v
}
if v != 0 && v < minVal {
minVal = v
}
}
if maxVal > minVal {
return maxVal - minVal
}
return 0
case resources.SUM:
var sum int64
for _, v := range usedValues {
sum += v
}
return sum
default:
var sum int64
for _, v := range usedValues {
sum += v
}
return int64(math.Round(float64(sum) / minutes))
}
return orderID, amount, nil
}
func (m *mongoDB) GetUpdateTimeForCategoryAndPropertyFromMetering(category string, property string) (time.Time, error) {
+69 -21
View File
@@ -294,27 +294,6 @@ info generate billing data used {2 ns-7uyfrr47 pay-xy map[0:325 1:166 2:0]}
cpu: 500m
memory: 256Mi
*/
func TestMongoDB_GenerateBillingData(t *testing.T) {
dbCTX := context.Background()
m, err := NewMongoInterface(dbCTX, os.Getenv("MONGODB_URI"))
if err != nil {
t.Errorf("failed to connect mongo: error = %v", err)
}
defer func() {
if err = m.Disconnect(dbCTX); err != nil {
t.Errorf("failed to disconnect mongo: error = %v", err)
}
}()
queryTime := time.Now().UTC()
ids, amount, err := m.GenerateBillingData(queryTime.Add(-1*time.Hour), queryTime, resources.DefaultPropertyTypeLS, []string{"ns-7uyfrr47", "ns-1jc12uh6", "ns-ezplle8l"}, "1jc12uh6")
if err != nil {
t.Fatalf("failed to generate billing data: %v", err)
}
t.Logf("generate billing data used %v", amount)
t.Logf("generate billing data used %v", ids)
}
func TestMongoDB_SetPropertyTypeLS(t *testing.T) {
dbCTX := context.Background()
@@ -479,3 +458,72 @@ func Test_mongoDB_GetTimeObjBucketBucket(t *testing.T) {
t.Logf("bucket: %#+v", bucket)
}
}
func Test_mongoDB_GetTimeUsedOwnerList(t *testing.T) {
dbCTX := context.Background()
m, err := NewMongoInterface(dbCTX, "")
if err != nil {
t.Errorf("failed to connect mongo: error = %v", err)
}
defer func() {
if err = m.Disconnect(dbCTX); err != nil {
t.Errorf("failed to disconnect mongo: error = %v", err)
}
}()
owners, err := m.GetTimeUsedNamespaceList(time.Now().UTC().Add(-time.Hour), time.Now().UTC())
if err != nil {
t.Fatalf("failed to get time used owner list: %v", err)
}
t.Logf("get time used owner list success: %v", owners)
}
func Test_mongoDB_GenerateBillingData(t *testing.T) {
dbCTX := context.Background()
m, err := NewMongoInterface(dbCTX, os.Getenv("MONGO_URI"))
if err != nil {
t.Errorf("failed to connect mongo: error = %v", err)
}
defer func() {
if err = m.Disconnect(dbCTX); err != nil {
t.Errorf("failed to disconnect mongo: error = %v", err)
}
}()
prols := resources.DefaultPropertyTypeLS
ownerToNS := map[string][]string{
"ax1uut8w": {"ns-tnw80mhk", "ns-ax1uut8w"},
}
billings, err := m.GenerateBillingData(time.Now().UTC().Add(-time.Hour), time.Now().UTC(), prols, ownerToNS)
if err != nil {
t.Fatalf("failed to generate billing data: %v", err)
}
for _, billing := range billings {
for _, bill := range billing {
t.Logf("%+v\n", bill)
}
}
}
func Test_mongoDB_GetOwnersWithoutRecentUpdates(t *testing.T) {
dbCTX := context.Background()
m, err := NewMongoInterface(dbCTX, "")
if err != nil {
t.Errorf("failed to connect mongo: error = %v", err)
}
defer func() {
if err = m.Disconnect(dbCTX); err != nil {
t.Errorf("failed to disconnect mongo: error = %v", err)
}
}()
now := time.Now().UTC()
endHourTime := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.Local).UTC()
owners, err := m.GetOwnersRecentUpdates([]string{"nfhmc74p"}, endHourTime)
if err != nil {
t.Fatalf("failed to get owners without recent updates: %v", err)
}
t.Logf("get owners without recent updates success: %v", owners)
}
+1 -1
View File
@@ -135,7 +135,7 @@ type Billing struct {
// if type = Transfer, then transfer is not nil
Transfer *Transfer `json:"transfer" bson:"transfer,omitempty"`
Detail string `json:"detail" bson:"detail,omitempty"`
UserUID uuid.UUID `json:"user_uid" bson:"user_uid,omitempty"`
//UserUID uuid.UUID `json:"user_uid" bson:"user_uid,omitempty"`
}
type Payment struct {