From a0ba5d882fe4baf29b02847356bab89135174b1f Mon Sep 17 00:00:00 2001 From: Fabio Araujo Date: Fri, 27 Mar 2026 19:49:31 +0000 Subject: [PATCH] fix: support configurable cacheTTL input Add cacheTTL to the sequencer function input schema so composition authors can tune function response caching for large sequencing and deletion workflows. Preserve default behavior when unspecified and return a fatal result for invalid duration values. Signed-off-by: Fabio Araujo --- README.md | 19 ++++ fn.go | 10 ++ fn_test.go | 99 +++++++++++++++++++ input/v1beta1/input.go | 7 ++ .../sequencer.fn.crossplane.io_inputs.yaml | 6 ++ 5 files changed, 141 insertions(+) diff --git a/README.md b/README.md index 3a25a71..af9d8f4 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,25 @@ In other words, the following rules apply: See `example/composition-regex.yaml` for a complete example. +### Function Response Caching + +You can set `cacheTTL` to control the Function response cache time-to-live. +This is useful for tuning reconciliation behavior in large compositions. + +```yaml + - step: sequence-creation + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + cacheTTL: 5m + rules: + - sequence: + - first + - second +``` + ### Composite Readiness Enabling the `resetCompositeReadiness` flag causes the function to set the Composite's `Ready` flag to `False` when at least one desired resource is deleted from the request. This prevents the Composite resource from entering the `Ready` diff --git a/fn.go b/fn.go index 27ccfad..f08b563 100644 --- a/fn.go +++ b/fn.go @@ -7,12 +7,14 @@ import ( "maps" "regexp" "strings" + "time" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" apiextensionsv1beta1 "github.com/crossplane/crossplane/v2/apis/apiextensions/v1beta1" protectionv1beta1 "github.com/crossplane/crossplane/v2/apis/protection/v1beta1" "github.com/crossplane/function-sequencer/input/v1beta1" + "google.golang.org/protobuf/types/known/durationpb" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" v1 "github.com/crossplane/function-sdk-go/proto/v1" @@ -57,6 +59,14 @@ func (f *Function) RunFunction(_ context.Context, req *v1.RunFunctionRequest) (* response.Fatal(rsp, errors.Wrapf(err, "cannot get Function input from %T", req)) return rsp, nil } + if in.CacheTTL != "" { + dur, err := time.ParseDuration(in.CacheTTL) + if err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set cacheTTL")) + return rsp, nil + } + rsp.Meta.Ttl = durationpb.New(dur) + } // Get the desired composed resources from the request. desiredComposed, err := request.GetDesiredComposedResources(req) diff --git a/fn_test.go b/fn_test.go index bd35bb3..a21c9bd 100644 --- a/fn_test.go +++ b/fn_test.go @@ -3,6 +3,7 @@ package main import ( "context" "testing" + "time" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" "github.com/crossplane/function-sequencer/input/v1beta1" @@ -1769,3 +1770,101 @@ func TestRunFunction(t *testing.T) { }) } } + +func TestRunFunctionCacheTTL(t *testing.T) { + target := v1.Target_TARGET_COMPOSITE + xr := `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":1}}` + + cases := map[string]struct { + reason string + input *v1beta1.Input + want *v1.RunFunctionResponse + }{ + "ValidCacheTTL": { + reason: "The function should override the response TTL when cacheTTL is provided", + input: &v1beta1.Input{ + CacheTTL: "5m", + Rules: []v1beta1.SequencingRule{ + {Sequence: []resource.Name{"first", "second"}}, + }, + }, + want: &v1.RunFunctionResponse{ + Meta: &v1.ResponseMeta{Ttl: durationpb.New(5 * time.Minute)}, + Results: []*v1.Result{ + { + Severity: v1.Severity_SEVERITY_NORMAL, + Message: "Delaying creation of resource(s) matching \"second\" because \"first\" does not exist yet", + Target: &target, + }, + }, + Desired: &v1.State{ + Composite: &v1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*v1.Resource{}, + }, + }, + }, + "InvalidCacheTTL": { + reason: "The function should return a fatal result when cacheTTL cannot be parsed", + input: &v1beta1.Input{ + CacheTTL: "5x", + Rules: []v1beta1.SequencingRule{ + {Sequence: []resource.Name{"first", "second"}}, + }, + }, + want: &v1.RunFunctionResponse{ + Meta: &v1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*v1.Result{ + { + Severity: v1.Severity_SEVERITY_FATAL, + Message: "cannot set cacheTTL: time: unknown unit \"x\" in duration \"5x\"", + Target: &target, + }, + }, + Desired: &v1.State{ + Composite: &v1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*v1.Resource{ + "second": { + Resource: resource.MustStructJSON(xr), + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f := &Function{log: logging.NewNopLogger()} + req := &v1.RunFunctionRequest{ + Input: resource.MustStructObject(tc.input), + Observed: &v1.State{ + Composite: &v1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*v1.Resource{}, + }, + Desired: &v1.State{ + Composite: &v1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*v1.Resource{ + "second": { + Resource: resource.MustStructJSON(xr), + }, + }, + }, + } + rsp, err := f.RunFunction(context.Background(), req) + if diff := cmp.Diff(tc.want, rsp, protocmp.Transform()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want rsp, +got rsp:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(nil, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 62482b9..c564cc2 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -41,6 +41,13 @@ type Input struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` + // CacheTTL sets the time-to-live for the Function response. + // Function response caching is an alpha feature in Crossplane and can + // change in future releases. + // +optional + // +kubebuilder:default:="1m" + CacheTTL string `json:"cacheTTL,omitempty"` + // EnableDeletionSequencing controls the automatic creation of Usage/ClusterUsage resources from the dependency tree // defined by the rule sequences. // +kubebuilder:object:default=false diff --git a/package/input/sequencer.fn.crossplane.io_inputs.yaml b/package/input/sequencer.fn.crossplane.io_inputs.yaml index 5f0149c..d81a079 100644 --- a/package/input/sequencer.fn.crossplane.io_inputs.yaml +++ b/package/input/sequencer.fn.crossplane.io_inputs.yaml @@ -28,6 +28,12 @@ spec: may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string + cacheTTL: + description: |- + CacheTTL sets the time-to-live for the Function response. + Function response caching is an alpha feature in Crossplane and can + change in future releases. + type: string enableDeletionSequencing: description: |- EnableDeletionSequencing controls the automatic creation of Usage/ClusterUsage resources from the dependency tree