Skip to content

[SPARK-58205][SQL] Mark JSON/CSV/XML expressions stateful to prevent shared-evaluator data races#57354

Open
vinodkc wants to merge 2 commits into
apache:masterfrom
vinodkc:fix-spark58204-json-csv-xml-stateful
Open

[SPARK-58205][SQL] Mark JSON/CSV/XML expressions stateful to prevent shared-evaluator data races#57354
vinodkc wants to merge 2 commits into
apache:masterfrom
vinodkc:fix-spark58204-json-csv-xml-stateful

Conversation

@vinodkc

@vinodkc vinodkc commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Adds override def stateful: Boolean = true to ten JSON/CSV/XML expressions:
GetJsonObject, JsonTuple, JsonToStructs, StructsToJson, SchemaOfJson, CsvToStructs, StructsToCsv, SchemaOfCsv, XmlToStructs, and StructsToXml.

Why are the changes needed?

These expressions hold mutable state in @transient lazy val evaluator fields but did not override stateful (which defaults to false). As a result, freshCopyIfContainsStatefulExpression() never made fresh copies of them. When the same expression node appears more than once in a query plan — e.g. df.select(from_json(col, schema), from_json(col, schema)) — both references share the same underlying evaluator instance. In interpreted execution this causes data-race corruption and silently incorrect results.

Does this PR introduce any user-facing change?

No. Queries that were previously correct continue to produce the same results. Queries that previously silently produced incorrect results due to evaluator sharing now produce correct results.

How was this patch tested?

Added one unit test per suite (JsonExpressionsSuite, CsvExpressionsSuite,XmlExpressionsSuite)

Was this patch authored or co-authored using generative AI tooling?

No

@vinodkc
vinodkc force-pushed the fix-spark58204-json-csv-xml-stateful branch from 128a44f to 0427728 Compare July 19, 2026 15:49

@HyukjinKwon HyukjinKwon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 blocking, 0 non-blocking, 0 nits.
The stateful override is correct and effective for 7 of the 10 expressions. The 3 RuntimeReplaceable expressions and a missed sibling need another look.

Correctness (2)

  • jsonExpressions.scala:524 / 593, csvExpressions.scala:191: stateful is dead at execution for the RuntimeReplaceable subset (StructsToJson, SchemaOfJson, SchemaOfCsv) — see inline
  • JsonExpressionsSuite.scala:1060: the new test passes but doesn't exercise the executed path for the RuntimeReplaceable claim — see inline

Design / architecture (1)

  • MultiGetJsonObject (jsonExpressions.scala:176, not in this diff) holds per-row mutable evaluator state but is not marked stateful — same pattern as the fix, on a node where it would be effective. MultiGetJsonObjectEvaluator resets a shared outputBuffer per call (JsonExpressionEvalUtils.scala:614,761) and holds fallbackEvaluators, a Seq of mutable GetJsonObjectEvaluators that are setJson-mutated (:610-620). It's created by OptimizeCsvJsonExprs (:231) and can appear duplicated in a Project. Consider adding override def stateful: Boolean = true here too.

Verification

Traced how stateful reaches execution for each expression kind. For the 7 that own eval/nullSafeEval and are not RuntimeReplaceable, freshCopyIfContainsStatefulExpression (Expression.scala:176) copies the node at execution and its @transient lazy val evaluator re-initializes fresh per copy — the fix is effective. For the 3 RuntimeReplaceable expressions, ReplaceExpressions (Finish Analysis batch, Optimizer.scala:338) rewrites the node to Invoke/StaticInvoke(Literal(evaluator), ...) before any execution-time freshCopy; RuntimeReplaceable.eval asserts input == null (Expression.scala:462), so the node is never row-evaluated in place and its stateful override no longer exists at execution. After replacement the evaluator is pinned inside a Literal (a non-stateful LeafExpression), and freshCopy on a Literal returns this — so both copies keep the same evaluator. SchemaOfJson/SchemaOfCsv additionally require a foldable child and hold only config lazy-vals (no per-row var), so they are constant-folded and can't race — the overrides are unnecessary and inconsistent with the unmarked SchemaOfXml.

PR description suggestions

  • Document: the description lists the ten expressions but does not distinguish the RuntimeReplaceable subset (StructsToJson, SchemaOfJson, SchemaOfCsv), where the override behaves differently — worth noting the mechanism/scope and justifying or dropping them.

override protected def withNewChildInternal(newChild: Expression): StructsToJson =
copy(child = newChild)

override def stateful: Boolean = true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StructsToJson is RuntimeReplaceable, so this override is dead at execution. ReplaceExpressions (Finish Analysis batch, Optimizer.scala:338) rewrites this node to Invoke(Literal(evaluator), ...) before any execution-time freshCopyIfContainsStatefulExpression, and RuntimeReplaceable.eval asserts input == null (Expression.scala:462) so it's never row-evaluated in place. After replacement the evaluator is pinned inside a Literal (non-stateful LeafExpression), and freshCopy on a Literal returns this — both copies keep the same evaluator. So if a shared-node race is real for StructsToJson (its evaluator does hold mutable writer/gen and it accepts a non-foldable child), this override does not fix it; the sharing would have to be broken where the evaluator actually lives (inside replacement).

}
}

override def stateful: Boolean = true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SchemaOfJson is RuntimeReplaceable and requires a foldable child (NON_FOLDABLE_INPUT check above), and SchemaOfJsonEvaluator holds only config lazy-vals (no per-row var). A foldable input is constant-folded via a single eval, so no multi-eval race is possible — this override is unnecessary. It's also inconsistent with SchemaOfXml (same RuntimeReplaceable + config-only-evaluator shape), which is correctly left unmarked. Suggest dropping it (and the matching one on SchemaOfCsv).

override protected def withNewChildInternal(newChild: Expression): SchemaOfCsv =
copy(child = newChild)

override def stateful: Boolean = true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as SchemaOfJson: SchemaOfCsv is RuntimeReplaceable, requires a foldable child, and SchemaOfCsvEvaluator holds only config lazy-vals — constant-folded, so no race is possible and the override is unnecessary/dead at execution. Inconsistent with the unmarked SchemaOfXml. Suggest dropping it.

assert(jsonTuple.freshCopyIfContainsStatefulExpression() ne jsonTuple)
}

test("StructsToJson and SchemaOfJson are stateful and produce fresh copies with " +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts copy1.replacement...evaluator ne copy2.replacement...evaluator on the pre-replacement node — each freshCopy trivially has its own @transient lazy val evaluator, so this passes regardless. But it doesn't reflect the executed plan: ReplaceExpressions materializes the Invoke once and the evaluator is shared through the resulting Literal (see the StructsToJson comment). So for the RuntimeReplaceable expressions this assertion is green without exercising the runtime sharing it's meant to guard. If the intent is to prove StructsToJson no longer races, the test needs to go through replacement (e.g. run ReplaceExpressions / an end-to-end duplicated-projection query) rather than inspecting .replacement on the un-replaced node.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants