You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
engine: Continue-As-New compaction rediscovers references with a 394-line walker over the proto AST and retains every step whenever it cannot name one; cel-go's partial evaluation names the exact steps.<id>.<output> trails an expression touches, dynamic indexes included #1484
refs.go is the one CEL-reference walker both drivers share (pkg/flowstate/v1/refs.go:9-20): RootedStepRef (:96) pattern-matches select chains rooted at steps, CollectRefsFromExpr (:138) recurses the google.api.expr.v1alpha1.Expr, and retainAllSteps (:344) is the fail-closed answer "for a walk that has lost the ability to say which step an expression references". docs/ARCHITECTURE.md records the tension under "Static reference analysis is conservative by necessity": the walker "cannot see dynamically constructed expressions", so a steps[inputs.name].value or a steps[vars.stage + "_deploy"].status carries every step forward at every Continue-As-New, and CollectNodeRefs (:377) has grown a Truncated arm and a list of edge cases (:14-20: a map-literal key position, has() on a field a continue_on_error step never set, a whole-step reference beside a field reference) each "found the hard way once".
cel-go has had the mechanism this reimplements since before the pin, and it is in v0.31.0: cel.PartialVars(vars, cel.AttributePattern("steps")) marks a root unknown, cel.OptPartialEval evaluates everything else, and the result is a types.Unknown whose GetAttributeTrails (common/types/unknown.go) lists every steps.<id>.<output>... path the expression actually reached — including through a dynamic index whose operand (inputs.name, vars.stage) is known at compaction time, and including has() (unknown propagation through presence tests landed in cel-go v0.29). Env.ResidualAst is the same machinery pointed the other way.
Why now
Compaction runs in workflow code at the Continue-As-New seam, which is the one place a wrong answer is not retried away: under-retaining fails the run at the next read, over-retaining charges history on every segment (the "spec travels with the run" tension). A precise answer for the dynamic cases is a correctness win and a payload win at once, and it removes the reason refs.go has to be extended by hand every time the language grows a shape (the #492 incident recorded at :361-370 is the pattern).
Desired outcome
Reference discovery for compaction is a partial evaluation of each remaining node's expressions with steps unknown and the run's inputs, vars and locals known, collecting attribute trails into the same refs map NeededOutputs reads today. The AST walker remains the fallback whenever partial evaluation returns an error rather than a value or an unknown, so the fail-closed default is unchanged.
Acceptance criteria
Shared conformance cases (both drivers) in which steps[inputs.name].value and steps[vars.stage + "_deploy"].status compact to exactly one retained step each, where today every step is retained; the existing refs_test.go cases pass unchanged.
Compaction is bounded by Limits like every other evaluation (partial evaluation is an evaluation), and its cost is charged per node rather than per run.
refs.go's hand-matched edge cases are each covered by a test that runs both the walker and the partial evaluator and asserts they agree, so the walker can be retired only when the corpus says so.
Constraints and dependencies
Workflow-side determinism holds: partial evaluation is a pure function of the AST, the known values and the pinned interpreter.
Comprehensions: fix: track unknowns through comprehensions cel-expr/cel-go#1341 ("track unknowns through comprehensions") is open at f8478f1, so an unknown that flows through map/filter/sum/reduce may today collapse to an error or a value rather than a trail. Until it lands, any expression containing a comprehension over steps keeps the walker's answer; the corpus must include that shape so the fallback is exercised, not assumed.
Check-before-plan (sibling issue) is not a prerequisite — PartialVars works on parsed ASTs — but with absolute attributes the trails are cheaper to compute.
Callee isolation (CollectNodeRefs, :361-376): a call: body is still not walked, for the reason recorded there.
Open questions
Do AsyncJoinTargets and LoopResultsReferenced (the other two callers named at refs.go:361-364) move to the same mechanism in the same change, or only Continue-As-New compaction first?
Observed behavior
refs.gois the one CEL-reference walker both drivers share (pkg/flowstate/v1/refs.go:9-20):RootedStepRef(:96) pattern-matches select chains rooted atsteps,CollectRefsFromExpr(:138) recurses thegoogle.api.expr.v1alpha1.Expr, andretainAllSteps(:344) is the fail-closed answer "for a walk that has lost the ability to say which step an expression references".docs/ARCHITECTURE.mdrecords the tension under "Static reference analysis is conservative by necessity": the walker "cannot see dynamically constructed expressions", so asteps[inputs.name].valueor asteps[vars.stage + "_deploy"].statuscarries every step forward at every Continue-As-New, andCollectNodeRefs(:377) has grown aTruncatedarm and a list of edge cases (:14-20: a map-literal key position,has()on a field acontinue_on_errorstep never set, a whole-step reference beside a field reference) each "found the hard way once".cel-go has had the mechanism this reimplements since before the pin, and it is in v0.31.0:
cel.PartialVars(vars, cel.AttributePattern("steps"))marks a root unknown,cel.OptPartialEvalevaluates everything else, and the result is atypes.UnknownwhoseGetAttributeTrails(common/types/unknown.go) lists everysteps.<id>.<output>...path the expression actually reached — including through a dynamic index whose operand (inputs.name,vars.stage) is known at compaction time, and includinghas()(unknown propagation through presence tests landed in cel-go v0.29).Env.ResidualAstis the same machinery pointed the other way.Why now
Compaction runs in workflow code at the Continue-As-New seam, which is the one place a wrong answer is not retried away: under-retaining fails the run at the next read, over-retaining charges history on every segment (the "spec travels with the run" tension). A precise answer for the dynamic cases is a correctness win and a payload win at once, and it removes the reason
refs.gohas to be extended by hand every time the language grows a shape (the #492 incident recorded at:361-370is the pattern).Desired outcome
Reference discovery for compaction is a partial evaluation of each remaining node's expressions with
stepsunknown and the run'sinputs,varsand locals known, collecting attribute trails into the samerefsmapNeededOutputsreads today. The AST walker remains the fallback whenever partial evaluation returns an error rather than a value or an unknown, so the fail-closed default is unchanged.Acceptance criteria
steps[inputs.name].valueandsteps[vars.stage + "_deploy"].statuscompact to exactly one retained step each, where today every step is retained; the existingrefs_test.gocases pass unchanged.has(steps.checkout.error)case (Continue-As-New compaction drops a tolerated step whose only later reference is a has()-guarded absent field #176) still keeps the step's key with the field absent.Limitslike every other evaluation (partial evaluation is an evaluation), and its cost is charged per node rather than per run.refs.go's hand-matched edge cases are each covered by a test that runs both the walker and the partial evaluator and asserts they agree, so the walker can be retired only when the corpus says so.Constraints and dependencies
f8478f1, so an unknown that flows throughmap/filter/sum/reducemay today collapse to an error or a value rather than a trail. Until it lands, any expression containing a comprehension overstepskeeps the walker's answer; the corpus must include that shape so the fallback is exercised, not assumed.PartialVarsworks on parsed ASTs — but with absolute attributes the trails are cheaper to compute.CollectNodeRefs,:361-376): acall:body is still not walked, for the reason recorded there.Open questions
AsyncJoinTargetsandLoopResultsReferenced(the other two callers named atrefs.go:361-364) move to the same mechanism in the same change, or only Continue-As-New compaction first?