chore: warn when a Compatible serde declines in convert - #5593
Open
andygrove wants to merge 2 commits into
Open
Conversation
A serde whose getSupportLevel returns Compatible is promising that convert will succeed. When convert returns None instead, the enclosing operator falls back to Spark and the JVM codegen dispatcher never gets a chance at the expression, because dispatchIfFallback is only reached from the Unsupported and Incompatible arms of exprToProtoInternal. Log a warning when that happens, so the cases show up instead of silently costing a fallback. Declines caused by a child that could not be serialized are not violations, so suppress the warning when any strict descendant already carries a fallback reason -- otherwise a single unsupported leaf would warn once per ancestor. Related to #5574.
The first version decided whether a decline was the node's own fault by searching the subtree for a descendant carrying a fallback reason. That runs on every convert-returns-None, so along a failing path through a large expression tree each ancestor re-walks its own subtree. This codebase demonstrably builds very large trees -- createBalancedBinaryExpr exists because And/Or chains nest deeper than protobuf's 100-level recursion limit -- so that is a real plan-time cost. Count conversion failures on the planning thread instead and compare the counter across the convert call. An unchanged counter means no nested exprToProtoInternal declined, so the decline originated in this node. Besides being O(1), this is more accurate: it also catches children the serde synthesised inside convert (a Cast wrapper, say), which are not in the expression's subtree and so were invisible to the old search. Verified equivalent on CometArrayExpressionSuite: 1 warning before and after, same expression.
sunchao
reviewed
Aug 31, 2026
sunchao
left a comment
Member
There was a problem hiding this comment.
The counter adds thread-local work even with WARN disabled, while the PR base has neither this counter nor the new warning. Could you add a focused HEAD-versus-base planning comparison for one wide successful projection and one nested-fallback shape? Please match the hardware, build, Spark/JDK profile, warmup and inputs, and report planning time, allocations and warning counts with WARN on and off. Outside the timed work, please verify unchanged results and intended native/dispatcher/fallback routing, including that the successful projection reaches the native path. A synthesized child can be included in the nested case.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
Related to #5574.
That issue is about the dispatcher being unreachable from
convert. This PR does not fix that — it adds the diagnostic that makes the affected cases visible, so the cleanup in #5574 can be scoped from evidence rather than from a static sweep.Rationale for this change
A serde whose
getSupportLevelreturnsCompatibleis promising thatconvertwill succeed. WhenconvertreturnsNoneinstead, two things happen and neither is obvious:dispatchIfFallbackis only reached from theUnsupportedandIncompatiblearms ofexprToProtoInternal(QueryPlanSerde.scala:941and:968).So a check that lives in
convertinstead ofgetSupportLevelsilently costs the user a whole-operator fallback that the dispatcher could have absorbed. Today nothing surfaces that.What changes are included in this PR?
A warning on the
Compatiblearm whenconvertreturnsNone, plus a newCometSerdeInvariantSuite.The one piece of actual logic is the suppression rule.
convertrecurses into children, so aCompatibleserde legitimately returnsNonewhen a child could not be serialized —getSupportLevelinspects the node, not the subtree. Without a guard, a single unsupported leaf would warn once for every ancestor on the way up.The guard counts conversion failures on the planning thread and compares the counter across the
convertcall: if no nestedexprToProtoInternaldeclined, the decline originated in this node. That is O(1). An earlier revision searched the subtree for a descendant carrying a fallback reason, which is O(n) per decline and therefore O(n·depth) along a failing path — a real cost here, sincecreateBalancedBinaryExprexists precisely becauseAnd/Orchains nest deeper than protobuf's 100-level recursion limit. The counter is also strictly more accurate: it catches children the serde synthesised insideconvert(aCastwrapper, say), which are not in the expression's subtree and so were invisible to the search. Verified equivalent onCometArrayExpressionSuite— one warning, same expression, before and after.Two categories warn without being a serde bug, and both are documented in the scaladoc:
getSupportLevelis not giveninputs, so aninputs-dependent decline cannot be moved there.CometAttributeReferenceis the only one today.spark.comet.exec.scalaUDF.codegen.enabled=false, every serde that reportsCompatibleand then routes toemitJvmCodegenDispatchwarns, because the disabled-config check lives in the dispatcher rather than ingetSupportLevel. Those are genuine instances of the invariant violation; moving that check up belongs with Codegen dispatcher is unreachable fromconvert, so serdes that decline there never get a dispatch attempt #5574, not here.How are these changes tested?
New
CometSerdeInvariantSuite(5 tests, registered in both PR workflows): warns on a realCompatible-then-Nonepath, stays quiet when only a child failed, stays quiet when the parent tags itself and a child failed, warns when the node is the only tagged expression, and warns with a placeholder when no reason was recorded. The tests capture WARN output through a log4j2 appender attached to theQueryPlanSerdelogger.On noise, which was the main thing I wanted to check before proposing this:
CometArrayExpressionSuite(57 tests) produced exactly one warning, from the test that disables the dispatcher config, and it is a true positive:Under the default configuration it is silent.