Skip to content
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
38 changes: 31 additions & 7 deletions cue/cuex/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -1490,8 +1490,9 @@ func ready(call pendingCall, pending []pendingCall, stillPending map[string]bool
}

// paramsResolved reports whether a call's parameters have a value yet. A call
// with no parameters at all has nothing to wait for; what the provider makes
// of that is its own business, as it always was.
// with no $params has nothing to check here: a legacy call's top-level fields
// hold its outputs as well as its inputs, and those stay unset until it runs, so
// it is ordered by the calls it reads (callDependencies) alone.
//
// A NativeProviderFn is handed the parameters as a cue.Value and may mean to
// take them unresolved - a schema, or an open disjunction. Such a call is
Expand Down Expand Up @@ -1537,16 +1538,17 @@ func waitsForPeer(call pendingCall, pending []pendingCall, stillPending map[stri
// References are therefore followed through fields that are not themselves
// calls, and stop at ones that are - what a call holds is that call's business
// and reading into it here means reading whatever it has rendered.
//
// A call with no $params is written in the legacy style: its inputs are its own
// top-level fields, so those are what it reads.
//
// wait: op.#ConditionalWait & {continue: req.$returns != _|_}
func callDependencies(call pendingCall, pending []pendingCall, executed map[string]bool) []string {
if len(pending) < 2 {
return nil // no peer to read, so nothing to read from one
}
params := call.value.LookupPath(paramsPath)
if !params.Exists() {
return nil
}
var queue []reference
if !collectReferences(params, &queue, 0) {
if !collectInputReferences(call.value, &queue) {
return peerKeys(call, pending)
}
waits := map[string]bool{}
Expand Down Expand Up @@ -1605,6 +1607,28 @@ func callDependencies(call pendingCall, pending []pendingCall, executed map[stri
return keys
}

// collectInputReferences collects the references a call's inputs make: its
// $params, or for a legacy call with none, each of its own top-level fields,
// hidden ones included, since a provider may read those too. The
// fields and not the call's expression, which is the conjunction with the
// definition the call is made from, and reading into that definition reads the
// package it lives in rather than anything the call was given.
func collectInputReferences(call cue.Value, into *[]reference) bool {
if params := call.LookupPath(paramsPath); params.Exists() {
return collectReferences(params, into, 0)
}
it, err := call.Fields(cue.Optional(true), cue.Hidden(true))
if err != nil {
return false
}
for it.Next() {
if !collectReferences(it.Value(), into, 1) {
return false
}
}
return true
}

// reference is a path some value reads, kept with the root it is relative to
// so it can be looked up again.
type reference struct {
Expand Down
186 changes: 186 additions & 0 deletions cue/cuex/legacy_call_order_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/*
Copyright 2026 The KubeVela Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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 cuex_test

import (
"context"
"encoding/json"
"strings"
"sync"
"testing"

"cuelang.org/go/cue"
"github.com/stretchr/testify/require"

"github.com/kubevela/pkg/cue/cuex"
cuexruntime "github.com/kubevela/pkg/cue/cuex/runtime"
)

// deepNesting is past cuex's maxReferenceDepth (100), the depth it looks for
// references to; a test reaching it must nest deeper than that bound.
const deepNesting = 110

// legacyFn is a provider in the shape kubevela/workflow's legacy providers take: it
// is handed the whole call, with its inputs at the top level and no $params.
type legacyFn func(value cue.Value) (cue.Value, error)

func (fn legacyFn) Call(_ context.Context, value cue.Value) (cue.Value, error) {
return fn(value)
}

type seqParams struct {
Params struct {
V int `json:"v"`
} `json:"$params"`
}

type seqReturns struct {
Returns struct {
V int `json:"v"`
} `json:"$returns"`
}

// seqCompiler offers a new-style call (#New, $params to $returns), a legacy call that
// produces output on its own fields (#Make), and legacy calls recording what their
// input held when they ran: `ready` (#Check), the hidden `_ready` (#CheckHidden), or
// a leaf nested deepNesting levels down (#CheckDeep). The package also declares a top-level
// bottom, as kubevela/workflow's legacy op package does (NoExist: _|_), which is
// what a call's inputs must be read without reaching into.
func seqCompiler(t *testing.T, seen *[]bool) *cuex.Compiler {
var mu sync.Mutex
pkg, err := cuexruntime.NewInternalPackage("seq", `
package seq

#New: {
#do: "new"
#provider: "seq"
$params: v: int
$returns?: v: int
}

#Make: {
#do: "make"
#provider: "seq"
value?: {...}
}

#Check: {
#do: "check"
#provider: "seq"
ready: *false | bool
}

#CheckHidden: {
#do: "check-hidden"
#provider: "seq"
...
}

#CheckDeep: {
#do: "check-deep"
#provider: "seq"
...
}

NoExist: _|_`, map[string]cuexruntime.ProviderFn{
"new": cuexruntime.GenericProviderFn[seqParams, seqReturns](func(_ context.Context, in *seqParams) (*seqReturns, error) {
out := &seqReturns{}
out.Returns.V = in.Params.V
return out, nil
}),
// Reads its input from the hidden field _ready of the template's own package.
"check-hidden": legacyFn(func(value cue.Value) (cue.Value, error) {
ready, _ := value.LookupPath(cue.MakePath(cue.Hid("_ready", "_"))).Bool()
mu.Lock()
*seen = append(*seen, ready)
mu.Unlock()
return value, nil
}),
// Reports whether the leaf deepNesting levels down under deep is resolved.
"check-deep": legacyFn(func(value cue.Value) (cue.Value, error) {
leaf := value.LookupPath(cue.ParsePath("deep" + strings.Repeat(".a", deepNesting)))
_, err := leaf.Int64()
mu.Lock()
*seen = append(*seen, err == nil)
mu.Unlock()
return value, nil
}),
"make": legacyFn(func(value cue.Value) (cue.Value, error) {
return value.FillPath(cue.ParsePath("value"), map[string]any{"v": 1}), nil
}),
// Decoded as kubevela/workflow's legacy providers decode their call: the
// whole value, marshalled to JSON.
"check": legacyFn(func(value cue.Value) (cue.Value, error) {
bs, err := value.MarshalJSON()
if err != nil {
return value, err
}
var in struct {
Ready bool `json:"ready"`
}
if err := json.Unmarshal(bs, &in); err != nil {
return value, err
}
mu.Lock()
*seen = append(*seen, in.Ready)
mu.Unlock()
return value, nil
}),
})
require.NoError(t, err)
return cuex.NewCompilerWithInternalPackages(pkg)
}

// A call with no $params reads its inputs from its top-level fields, so those are what
// it depends on: it must run after the calls they read, not alongside them.
func TestLegacyCallWaitsForTheCallItReads(t *testing.T) {
// Each reader comes before what it reads, so traversal order alone cannot pass.
cases := map[string]string{
// workflow's request: op.#ConditionalWait & {continue: req.$returns != _|_}
"a legacy call reading a new-style call's $returns": `
import "vela/seq"
wait: seq.#Check & {ready: req.$returns != _|_}
req: seq.#New & {$params: v: 1}
`,
// workflow's apply-job: a legacy wait reading what a legacy apply wrote
"a legacy call reading another legacy call's output": `
import "vela/seq"
wait: seq.#Check & {ready: apply.value != _|_}
apply: seq.#Make & {}
`,
"a legacy call reading through a hidden field": `
import "vela/seq"
wait: seq.#CheckHidden & {_ready: req.$returns != _|_}
req: seq.#New & {$params: v: 1}
`,
// Its only reference sits past the depth references are looked for, so it is
// never found: the call must wait on every peer to see it resolved.
"a legacy call nested too deep to read": `
import "vela/seq"
wait: seq.#CheckDeep & {deep: ` + strings.Repeat("{a: ", deepNesting) + "req.$returns.v" + strings.Repeat("}", deepNesting) + `}
req: seq.#New & {$params: v: 1}
`,
}
for name, src := range cases {
t.Run(name, func(t *testing.T) {
var seen []bool
_, err := seqCompiler(t, &seen).CompileString(context.Background(), src)
require.NoError(t, err)
require.Equal(t, []bool{true}, seen, "the check must run once, after what it reads")
})
}
}
Loading