Josh Rosen created SPARK-58154:
----------------------------------

             Summary: Several expressions hold shared mutable state that is 
unprotected by the stateful-expression mechanism, corrupting results under 
concurrent evaluation
                 Key: SPARK-58154
                 URL: https://issues.apache.org/jira/browse/SPARK-58154
             Project: Spark
          Issue Type: Improvement
          Components: SQL
    Affects Versions: 4.2.0, 4.0.0, 4.1.0, 3.5.0, 4.3.0
            Reporter: Josh Rosen


Several Catalyst expressions keep mutable evaluation state in instance fields 
but are not marked {{{}stateful{}}}.

When the same expression _instance_ is evaluated from two threads at once, that 
state is corrupted: queries silently return wrong results or, for some of the 
expressions, fail with confusing errors.

I'm filing this single ticket to report an entire family of 
conceptually-related expression statefulness bugs. Some of these may require 
separate classes of fixes, in which case we can split sub-tasks or tickets from 
this parent issue.
h3. Affected expressions found so far (this list may be incomplete)
 # *{{{}NamedLambdaVariable{}}}, i.e. every higher-order function 
({{{}transform{}}}, {{{}filter{}}}, {{{}aggregate{}}}, {{{}zip_with{}}}, ...) – 
the most affected.* The lambda variable's current value lives in an 
{{AtomicReference}} on the expression instance and is rewritten for every array 
element in every row, so concurrent evaluation corrupts a large fraction of all 
rows: lambda bodies compute over elements installed by the other thread, and 
results contain values leaked from other rows (or possibly {_}other queries{_}, 
if subplans are shared).

Both interpreted evaluation and codegen go through the same 
{{{}AtomicReference{}}}. Note that marking the _enclosing_ function stateful 
does not help: {{TransformKeys}} is already {{stateful = true}} (for its map 
builder), but fresh copies preserve non-stateful children, so its lambda 
variables are still shared. Silent wrong results; reproduced on master (repro 
below).
 # *{{to_json}} ({{{}StructsToJson{}}}) – also reproduced on master.* Each 
instance shares one {{{}CharArrayWriter{}}}/{{{}JacksonGenerator{}}}. 
Concurrent evaluation of a shared instance usually fails with 
{{{}com.fasterxml.jackson.core.JsonGenerationException: Can not start an 
object, expecting field name{}}}, and occasionally returns silently corrupted 
JSON instead (e.g. {{"a":0} for input \{"a":0}). }}Notably, this shared state 
lives in an evaluator object that the expression embeds as a literal constant 
in its RuntimeReplaceable replacement tree, so copying the expression tree 
still reuses the one evaluator – this state is out of reach of the 
stateful-copy mechanism even though the wrapping {{Invoke}} is marked stateful.

 # *The other JSON/CSV/XML conversion functions* – {{{}to_csv{}}}, {{to_xml}} 
(shared {{CharArrayWriter}} + generator, like {{{}to_json{}}}) and 
{{{}from_json{}}}, {{{}from_csv{}}}, {{from_xml}} (shared parser objects, e.g. 
{{{}FailureSafeParser{}}}) – keep similar shared evaluator state, though not 
all via the literal-embedding pattern above; they are in scope for this audit.

 # *{{format_number}} ({{{}FormatNumber{}}})* shares a 
{{java.text.DecimalFormat}} (documented as not thread-safe) plus last-argument 
caches in interpreted evaluation, so concurrent evaluation can produce 
malformed output. Its codegen path keeps this state per generated-class 
instance and appears unaffected.

 # *{{{}regexp_replace{}}}, {{{}regexp_extract{}}}/{{{}regexp_extract_all{}}}, 
{{translate}}* cache the last pattern/replacement/dictionary in instance 
fields. With literal pattern arguments, concurrent recomputation is idempotent 
and harmless; with non-foldable (per-row) pattern arguments, the caches can 
tear across threads (e.g. one thread's {{lastRegex}} paired with the other 
thread's compiled {{{}Pattern{}}}), yielding wrong results.

h3. Reproduction (PySpark, current master, default configs)

Silent wrong results from {{transform}} when two threads collect sibling 
DataFrames:
{code:python}
import threading
from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder.master("local[2]").getOrCreate()

N, M, THREADS = 100, 1000, 2

vals = ",".join(f"({i})" for i in range(N))
# Inline VALUES becomes a LocalRelation, so ConvertToLocalRelation evaluates the
# projection on the driver during each derived query's optimization.
t = spark.sql(f"SELECT id FROM VALUES {vals} AS t(id)").select(
    "id",
    F.transform(
        F.sequence(F.col("id") * M, F.col("id") * M + M - 1), lambda x: x + 1
    ).alias("t"),
)
# Sibling DataFrames: distinct Datasets/QueryExecutions whose logical plans
# share t's expression objects, including ONE NamedLambdaVariable instance.
branches = [t.select("id", "t") for _ in range(THREADS)]

results = [None] * THREADS

def work(k):
    results[k] = branches[k].collect()

threads = [threading.Thread(target=work, args=(k,)) for k in range(THREADS)]
for th in threads: th.start()
for th in threads: th.join()

bad = sum(1 for res in results for r in res
          if r["t"] != list(range(r["id"] * M + 1, r["id"] * M + M + 1)))
print("corrupted rows:", bad, "of", THREADS * N)
{code}
Observed on master (5.0.0-SNAPSHOT), five consecutive runs:
{noformat}
corrupted rows: 107 of 200
corrupted rows: 71 of 200
corrupted rows: 70 of 200
corrupted rows: 106 of 200
corrupted rows: 98 of 200
{noformat}
With {{THREADS = 1}} the same harness reports 0 corrupted rows on every run. 
Scaling up (4 threads, 300 rows x 2000 elements) corrupted ~99% of rows in 
every trial. Replacing the {{transform}} column with {{to_json(struct(id))}} 
(2000 rows) in the same harness threw the {{JsonGenerationException}} above in 
every run, and in some runs additionally returned silently corrupted JSON for 
rows in the thread that did not fail.
h3. Paths that can lead to concurrent evaluation

There are multiple paths that can result in concurrent expression instance 
evaluation:
 * *Driver side (all currently-maintained versions):* DataFrames derived from a 
common parent share the parent plan's expression instances, and 
{{ConvertToLocalRelation}} evaluates projections over LocalRelations eagerly on 
the driver, inside each derived query's optimization. As a result, the pattern 
of building several DataFrames from one base DataFrame and collecting them from 
a Python/Scala thread pool evaluates the shared instances concurrently. The 
reproduction below uses this path.
 * *Executor side (mainly 3.5.x and earlier, but still technically possible in 
later versions):* before SPARK-44705 (4.0.0) made {{PythonRunner}} 
single-threaded, operators upstream of a Python UDF ran in a separate "writer" 
thread within each task. Optimizer rules that copy expression instances into 
multiple plan positions – e.g. filter pushdown through a {{Project}} via 
{{{}replaceAlias{}}}, or {{InferFiltersFromGenerate}} before SPARK-37392 (fixed 
in 3.3.0) – could place the same instance on both sides of that thread 
boundary. Spark 4.x closes that boundary under default configurations, though 
SPARK-56642 (4.3.0/master) adds an opt-in pipelined mode 
({{{}spark.python.udf.pipelined.enabled{}}}) that reintroduces a writer thread.

h3. Related tickets

Two earlier fixes hardened the fresh-copy mechanism for expressions that _are_ 
marked stateful – SPARK-49628 ({{{}ConstantFolding{}}} should copy stateful 
expressions before evaluating) and SPARK-53275 (incorrect ordering in 
interpreted mode when the sort order includes stateful expressions) – but the 
expressions above carry no such marking, so the copy mechanism those fixes 
invoke skips them. Also related: SPARK-25223 (closed, Won't Do) described the 
{{NamedLambdaVariable}} value-passing design as fragile but did not report a 
correctness bug; SPARK-44705 made {{PythonRunner}} single-threaded, which 
incidentally narrowed (but did not close) the executor-side exposure; 
SPARK-56642 adds an opt-in mode that reintroduces the writer thread.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to