Repository: open-cluster-management-io/sdk-go
Pull request #114: :sparkles: add CEL cost estimator
Author: login: haoqing0110
Labels: name: approved
name: lgtm
Created: 2025-04-22T03:12:01Z | Merged: 2025-04-28T03:21:16Z

<!--
Thanks for creating a pull request!

If this is your first time, please make sure to review CONTRIBUTING.MD.

Please copy the appropriate `:text:` or icon to the beginning of your PR title:

:sparkles: ✨ feature
:bug: 🐛 bug fix
:book: 📖 docs
:memo: 📝 proposal
:warning: ⚠️ breaking change
:seedling: 🌱 other/misc
:question: ❓ requires manual review/categorization

-->
## Summary

## Related issue(s)
https://github.com/open-cluster-management-io/ocm/issues/843
https://github.com/open-cluster-management-io/ocm/issues/955



Diff:
diff --git a/pkg/cel/common/values.go b/pkg/cel/common/values.go
index 8e8d37d55..8481602b9 100644
--- a/pkg/cel/common/values.go
+++ b/pkg/cel/common/values.go
@@ -4,12 +4,15 @@ import (
 	"reflect"
 
 	"github.com/google/cel-go/cel"
+	"github.com/google/cel-go/common/types/ref"
 	"github.com/google/cel-go/ext"
+	"github.com/google/cel-go/interpreter"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apiserver/pkg/cel/library"
 )
 
+// BaseEnvOpts defines the base CEL environment options
 var BaseEnvOpts = []cel.EnvOption{
 	cel.OptionalTypes(),
 	ext.Strings(),
@@ -20,9 +23,25 @@ var BaseEnvOpts = []cel.EnvOption{
 	library.IP(),
 	library.CIDR(),
 	library.Format(),
+	library.SemverLib(),
 }
 
-// ConvertObjectToUnstructured converts any object to an unstructured.Unstructured object.
+// BaseEnvCostEstimator implements CEL's interpretable.ActualCostEstimator
+type BaseEnvCostEstimator struct {
+	// Wraps a CEL cost estimator with additional functionality
+	CostEstimator interpreter.ActualCostEstimator
+}
+
+// CallCost implements runtime cost estimation for CEL function calls
+func (b *BaseEnvCostEstimator) CallCost(function, overloadId string, args []ref.Val, result ref.Val) *uint64 {
+	if b.CostEstimator != nil {
+		return b.CostEstimator.CallCost(function, overloadId, args, result)
+	}
+	k8sEstimator := &library.CostEstimator{}
+	return k8sEstimator.CallCost(function, overloadId, args, result)
+}
+
+// ConvertObjectToUnstructured converts any object to an unstructured.Unstructured object
 func ConvertObjectToUnstructured(obj interface{}) (*unstructured.Unstructured, error) {
 	if obj == nil || reflect.ValueOf(obj).IsNil() {
 		return &unstructured.Unstructured{Object: nil}, nil
diff --git a/pkg/cel/library/cost.go b/pkg/cel/library/cost.go
new file mode 100644
index 000000000..d7b18e21f
--- /dev/null
+++ b/pkg/cel/library/cost.go
@@ -0,0 +1,94 @@
+package library
+
+import (
+	"math"
+
+	"github.com/google/cel-go/common"
+	"github.com/google/cel-go/common/types"
+	"github.com/google/cel-go/common/types/ref"
+	"github.com/google/cel-go/common/types/traits"
+)
+
+// CostEstimator implements CEL's interpretable.ActualCostEstimator for runtime cost estimation
+type CostEstimator struct{}
+
+func actualSize(value ref.Val) uint64 {
+	if sz, ok := value.(traits.Sizer); ok {
+		return uint64(sz.Size().(types.Int))
+	}
+	return 1
+}
+
+// CallCost calculates the runtime cost for CEL function calls
+func (l *CostEstimator) CallCost(function, overloadId string, args []ref.Val, result ref.Val) *uint64 {
+	switch function {
+	case "scores":
+		// each scores returns a list
+		var totalCost uint64 = common.ListCreateBaseCost
+		if result != nil {
+			if lister, ok := result.(traits.Lister); ok {
+				// each item is a map
+				size := uint64(lister.Size().(types.Int))
+				totalCost += size * (common.SelectAndIdentCost + common.MapCreateBaseCost)
+			}
+		}
+		return &totalCost
+	case "parseJSON":
+		var totalCost uint64 = common.MapCreateBaseCost
+		if len(args) >= 1 {
+			// Calculate the traversal cost of input string
+			inputSize := actualSize(args[0])
+			traversalCost := uint64(math.Ceil(float64(inputSize) * common.StringTraversalCostFactor))
+			// Recursively calculate the cost of result structure
+			totalCost = traversalCost + calculateStructCost(result)
+		}
+		return &totalCost
+	default:
+		return nil
+	}
+}
+
+// calculateStructCost recursively calculates the cost of data structures
+func calculateStructCost(val ref.Val) uint64 {
+	if val == nil {
+		return 0
+	}
+
+	switch v := val.(type) {
+	case traits.Mapper:
+		return calculateMapCost(v)
+	case traits.Lister:
+		return calculateListCost(v)
+	default:
+		return common.ConstCost
+	}
+}
+
+// calculateMapCost computes cost for map structures
+func calculateMapCost(v traits.Mapper) uint64 {
+	cost := uint64(common.MapCreateBaseCost)
+
+	it := v.Iterator()
+	for it.HasNext() == types.True {
+		key := it.Next()
+		if value := v.Get(key); value != nil {
+			cost += calculateStructCost(value)
+		}
+	}
+
+	return cost
+}
+
+// calculateListCost computes cost for list structures
+func calculateListCost(v traits.Lister) uint64 {
+	cost := uint64(common.ListCreateBaseCost)
+
+	size := v.Size().(types.Int)
+	for i := types.Int(0); i < size; i++ {
+		if item := v.Get(types.Int(i)); item != nil {
+			cost += calculateStructCost(item)
+		}
+	}
+
+	return cost
+}
diff --git a/pkg/cel/library/cost_test.go b/pkg/cel/library/cost_test.go
new file mode 100644
index 000000000..94b5d2315
--- /dev/null
+++ b/pkg/cel/library/cost_test.go
@@ -0,0 +1,153 @@
+package library
+
+import (
+	"math"
+	"testing"
+
+	"github.com/google/cel-go/cel"
+	"github.com/google/cel-go/common"
+	"github.com/google/cel-go/common/types"
+	"github.com/google/cel-go/common/types/ref"
+	clusterapiv1 "open-cluster-management.io/api/cluster/v1"
+	ocmcelcommon "open-cluster-management.io/sdk-go/pkg/cel/common"
+	testinghelpers "open-cluster-management.io/sdk-go/pkg/testing"
+)
+
+func TestLibraryCost(t *testing.T) {
+	cases := []struct {
+		name              string
+		expr              string
+		cluster           *clusterapiv1.ManagedCluster
+		expectRuntimeCost uint64
+	}{
+		// Test cases for scores function
+		{
+			name:              "scores_empty",
+			expr:              `managedCluster.scores("fake-score")`,
+			cluster:           testinghelpers.NewManagedCluster("test").Build(),
+			expectRuntimeCost: common.ListCreateBaseCost + common.SelectAndIdentCost, // 11
+		},
+		{
+			name:              "scores_with_items",
+			expr:              `managedCluster.scores("test-score")`,
+			cluster:           testinghelpers.NewManagedCluster("test").Build(),
+			expectRuntimeCost: common.ListCreateBaseCost + 2*(common.SelectAndIdentCost+common.MapCreateBaseCost) + common.SelectAndIdentCost, // 73
+		},
+		// Test cases for parseJSON function
+		{
+			name:              "parse empty string",
+			expr:              `''.parseJSON()`,
+			expectRuntimeCost: common.ConstCost, // 0
+		},
+		{
+			name:              "parse simple string",
+			expr:              `'hello'.parseJSON()`,
+			expectRuntimeCost: uint64(math.Ceil(5 * common.StringTraversalCostFactor)), // 1
+		},
+		{
+			name:              "parse simple object",
+			expr:              `'{"key":"value"}'.parseJSON()`,
+			expectRuntimeCost: uint64(math.Ceil(15*common.StringTraversalCostFactor)) + common.MapCreateBaseCost, // 32
+		},
+		{
+			name:              "parse nested object",
+			expr:              `'{"nested": {"key": "value"}}'.parseJSON()`,
+			expectRuntimeCost: uint64(math.Ceil(28*common.StringTraversalCostFactor)) + 2*common.MapCreateBaseCost, // 63
+		},
+		{
+			name:              "parse array",
+			expr:              `'[1,2,3]'.parseJSON()`,
+			expectRuntimeCost: uint64(math.Ceil(7*common.StringTraversalCostFactor)) + common.ListCreateBaseCost, // 11
+		},
+		{
+			name:              "parse array of objects",
+			expr:              `'[{"id": 1}, {"id": 2}]'.parseJSON()`,
+			expectRuntimeCost: uint64(math.Ceil(22*common.StringTraversalCostFactor)) + common.ListCreateBaseCost + 2*common.MapCreateBaseCost, // 73
+		},
+	}
+
+	estimator := &CostEstimator{}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			envOpts := append([]cel.EnvOption{ManagedClusterLib(&fakeScoreLister{}), JsonLib()}, ocmcelcommon.BaseEnvOpts...)
+			env, err := cel.NewEnv(envOpts...)
+			if err != nil {
+				t.Fatalf("%v", err)
+			}
+
+			compiled, issues := env.Compile(tc.expr)
+			if issues.Err() != nil {
+				t.Fatalf("%v", issues.Err())
+			}
+
+			convertedCluster, err := ocmcelcommon.ConvertObjectToUnstructured(tc.cluster)
+			if err != nil {
+				t.Fatalf("%v", err)
+			}
+
+			// Test runtime cost
+			prog, err := env.Program(compiled,
+				cel.CostTracking(estimator),
+			)
+			if err != nil {
+				t.Fatalf("%v", err)
+			}
+
+			_, evalDetails, _ := prog.Eval(map[string]interface{}{
+				"managedCluster": convertedCluster.Object,
+			})
+
+			if evalDetails.ActualCost() == nil {
+				t.Fatal("Expected non-nil ActualCost")
+			}
+
+			if *evalDetails.ActualCost() != tc.expectRuntimeCost {
+				t.Errorf("Runtime cost = %v, want %v", *evalDetails.ActualCost(), tc.expectRuntimeCost)
+			}
+		})
+	}
+}
+
+// Helper test for actualSize function
+func TestActualSizeCalculation(t *testing.T) {
+	cases := []struct {
+		name     string
+		input    ref.Val
+		wantSize uint64
+	}{
+		{
+			name:     "string_size",
+			input:    types.String("test"),
+			wantSize: 4,
+		},
+		{
+			name:     "empty_string",
+			input:    types.String(""),
+			wantSize: 0,
+		},
+		{
+			name:     "list_size",
+			input:    types.NewStringList(types.DefaultTypeAdapter, []string{"a", "b", "c"}),
+			wantSize: 3,
+		},
+		{
+			name:     "empty_list",
+			input:    types.NewStringList(types.DefaultTypeAdapter, []string{}),
+			wantSize: 0,
+		},
+		{
+			name:     "non_sizer_type",
+			input:    types.Bool(true),
+			wantSize: 1,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := actualSize(tc.input)
+			if got != tc.wantSize {
+				t.Errorf("actualSize(%v) = %v, want %v", tc.input, got, tc.wantSize)
+			}
+		})
+	}
+}


Reviews:
user: login: zhujian7
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T09:50:54Z
user: login: haoqing0110
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T02:11:44Z

Review comments:
diff_hunk: @@ -20,9 +23,25 @@ var BaseEnvOpts = []cel.EnvOption{
 	library.IP(),
 	library.CIDR(),
 	library.Format(),
+	library.SemverLib(),
 }
 
-// ConvertObjectToUnstructured converts any object to an unstructured.Unstructured object.
+// BaseEnvCostEstimator implements CEL's interpretable.ActualCostEstimator
+type BaseEnvCostEstimator struct {
+	// Wraps a CEL cost estimator with additional functionality
+	CostEstimator interpreter.ActualCostEstimator
+}
+
+// CallCost implements runtime cost estimation for CEL function calls
+func (b *BaseEnvCostEstimator) CallCost(function, overloadId string, args []ref.Val, result ref.Val) *uint64 {
+	k8sEstimator := &library.CostEstimator{}
+	if cost := k8sEstimator.CallCost(function, overloadId, args, result); cost != nil {
+		return cost
+	}
+	return b.CostEstimator.CallCost(function, overloadId, args, result)
path: pkg/cel/common/values.go
user: login: zhujian7
body: I am not famillar with the CostEstimator, want to understand who is expected to assign value for the BaseEnvCostEstimator.CostEstimator? should we add the nil check for the `b.CostEstimator`? 
created_at: 2025-04-25T09:44:58Z
author_association: MEMBER
diff_hunk: @@ -20,9 +23,25 @@ var BaseEnvOpts = []cel.EnvOption{
 	library.IP(),
 	library.CIDR(),
 	library.Format(),
+	library.SemverLib(),
 }
 
-// ConvertObjectToUnstructured converts any object to an unstructured.Unstructured object.
+// BaseEnvCostEstimator implements CEL's interpretable.ActualCostEstimator
+type BaseEnvCostEstimator struct {
+	// Wraps a CEL cost estimator with additional functionality
+	CostEstimator interpreter.ActualCostEstimator
+}
+
+// CallCost implements runtime cost estimation for CEL function calls
+func (b *BaseEnvCostEstimator) CallCost(function, overloadId string, args []ref.Val, result ref.Val) *uint64 {
+	k8sEstimator := &library.CostEstimator{}
+	if cost := k8sEstimator.CallCost(function, overloadId, args, result); cost != nil {
+		return cost
+	}
+	return b.CostEstimator.CallCost(function, overloadId, args, result)
path: pkg/cel/common/values.go
user: login: haoqing0110
body: Yes! We should check that!
created_at: 2025-04-28T02:11:44Z
author_association: MEMBER

PR comments:
user: login: haoqing0110
created_at: 2025-04-25T01:14:11Z
body: /assign @qiujian16 @zhujian7 
author_association: MEMBER
user: login: qiujian16
created_at: 2025-04-25T01:43:02Z
body: /approve
author_association: MEMBER
user: login: openshift-ci[bot]
created_at: 2025-04-25T01:43:13Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/open-cluster-management-io/sdk-go/pull/114#" title="Author self-approved">haoqing0110</a>*, *<a href="https://github.com/open-cluster-management-io/sdk-go/pull/114#issuecomment-2829192241" title="Approved">qiujian16</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=open-cluster-management-io%2Fsdk-go).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/open-cluster-management-io/sdk-go/blob/main/OWNERS)~~ [qiujian16]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: NONE
user: login: zhujian7
created_at: 2025-04-28T03:16:07Z
body: /lgtm
author_association: MEMBER