[POC][SQL][PYTHON] Observe-backed accumulator (SparkSession.accumulator) - #57990
Draft
HyukjinKwon wants to merge 3 commits into
Draft
[POC][SQL][PYTHON] Observe-backed accumulator (SparkSession.accumulator)#57990HyukjinKwon wants to merge 3 commits into
HyukjinKwon wants to merge 3 commits into
Conversation
HyukjinKwon
force-pushed
the
observed-accumulator
branch
from
August 13, 2026 22:36
a6ad3f9 to
18f15b6
Compare
HyukjinKwon
added a commit
to HyukjinKwon/spark
that referenced
this pull request
Aug 14, 2026
…, Scala HOF; enable Connect filter/composed Review fixes (apache#57990 review of 7a87d3b): - #1 Same-named accumulators collided across sessions: the classic JVM registry was process-global keyed by name. Key it by (session UUID, name). The harvest listener captures its registering session's UUID at construction so Structured Streaming micro-batch clones (which share the listener but have a fresh UUID) still attribute to the creating session. Python reads via the creating session's UUID over py4j. - #2 Align accumulator(...) arg order across languages: Python is now accumulator(name, zero=0, merge=None), matching Scala's name-first signature. - #3 A Scala UDF capturing an accumulator inside a higher-order-function lambda now fails fast (via checkNoUnrewritten) instead of silently returning zero; the rewrite no longer descends into lambda bodies. Added a Scala test. Also: - Connect: filter-position and composed-expression harvest do work through the Connect client (verified on a freshly-built connect assembly; the earlier local failure was a stale in-process server jar), so the parity tests are un-skipped. - Use PySpark's error framework (OBSERVED_ACCUMULATOR_DIFFERENT_SESSION) for the cross-session guard, and add full type annotations to observed_accumulator.py (fixes the mypy Python-linter check). - Tests: session-isolation (Scala + Python classic) and Scala HOF fail-fast. Co-authored-by: Isaac
HyukjinKwon
force-pushed
the
observed-accumulator
branch
from
August 15, 2026 01:15
bbac94e to
02f5c76
Compare
…accumulator) Add an observe-backed accumulator whose value is carried through the query plan and aggregated by a CollectMetrics (df.observe) node instead of the scheduler's accumulator side channel, so it is exactly-once (task retries, speculation, and stage recomputation do not double count). Surface matches a classic accumulator: create it from the session (`spark.accumulator(name, zero)` / Scala `accumulator(name)` and `accumulator[T](name, zero, merge)`), call `add` inside a plain UDF, run an action, then read `value` on the driver. A plain UDF that references the accumulator is detected (closure inspection on Scala; a marker name on Python) and rewritten by the analyzer rule InjectObservedAccumulators wherever it appears as a projected column, in a filter condition, or nested in a larger expression; an accumulator UDF left in a position the rule cannot observe fails fast rather than silently returning zero. Details: - Client-facing API (ObservedAccumulator / TypedObservedAccumulator) lives in sql/api; the rule, harvest listener, and JVM registry are in sql/core. - Numeric (exact Long / Double) and custom-merge (serialized partial + collect_list + driver fold) accumulators, in scalar UDFs, vectorized pandas/Arrow UDFs, and operator UDFs (mapInPandas/applyInPandas/mapInArrow/ applyInArrow). A single UDF may reference several accumulators. - The classic JVM registry is keyed by (session UUID, name) so same-named accumulators in different sessions do not collide; the harvest listener attributes to the creating session (survives Structured Streaming clones). - Cross-session reads raise (PySparkRuntimeError OBSERVED_ACCUMULATOR_DIFFERENT_SESSION on Python). - The struct wrapper participates in whole-stage codegen. - Works on classic (Scala + Python) and Spark Connect (PySpark). Higher-order functions and the Scala Connect client are follow-ups. Tests: ObservedAccumulatorSuite (sql/core), test_observed_accumulator and its Connect parity suite (PySpark). Co-authored-by: Isaac
HyukjinKwon
force-pushed
the
observed-accumulator
branch
2 times, most recently
from
August 16, 2026 12:45
18837f8 to
ad1cdfa
Compare
Force the RemoteSparkSession-spawned server to load freshly-built classes (so sql/core carries the ObservedAccumulator rule), capture its output to a file, and re-add the numeric Scala Connect E2E test with detection/forwarding diagnostics. Verifies whether the fix makes the server-side rule run and the Scala client harvest work. Debug commit to be cleaned up. Co-authored-by: Isaac
Decisively distinguish "rule not registered on the spawned Connect server" from "rule registered but detection misses the Scala-UDF-over-Connect path": print at apply() entry regardless of the mayReferenceAccumulator guard. Co-authored-by: Isaac
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.
What changes were proposed in this pull request?
This is a [WIP] draft / proof-of-concept (no JIRA yet) exploring an observe-backed
accumulator: an accumulator whose value is carried through the query plan and aggregated by a
CollectMetrics(df.observe) node instead of the scheduler's accumulator side channel.The user-facing surface matches a classic accumulator —
spark.accumulator(...),acc.add(...)inside a UDF,
acc.valueon the driver. A plain UDF that references the accumulator is detected(its closure is inspected) and rewritten by the analyzer to observe the per-row delta, wherever it
appears as a projected column, in a
filtercondition, or nested in a larger expression. Works onclassic and Spark Connect, in Scala and PySpark, so code migrates unchanged. If an accumulator UDF
ends up in a position the rule cannot observe, it fails fast rather than silently returning zero
(see below).
How it works (numeric, scalar UDF; ASCII):
Because the value rides through the query plan (not the scheduler's side channel), it is
exactly-once: task retries, speculation, and stage recomputation do not double count. Operator
UDFs (
mapInPandas/applyInPandas/mapInArrow/applyInArrow) work the same way, except thehidden delta column is added to the operator's output and observed directly (no struct wrapper).
More examples
Double-valued accumulator (
add(Double)→doubleValue):Vectorized
pandas_udf(callacc.addonce per batch):mapInPandasandapplyInPandas(operator UDFs):Arbitrary (non-numeric) type via a custom
merge— each task folds its partial, the partial isserialized, gathered with
collect_list, and folded on the driver withmerge. Works in scalarand operator UDFs, in both Python and Scala:
A single UDF may reference several accumulators (numeric and/or typed); each is detected and
harvested independently:
Structured Streaming via
foreachBatch(value accumulates across micro-batches):Included:
sql/core/.../ObservedAccumulator.scala+sql/api):SparkSession.accumulator(name)(numeric) and
SparkSession.accumulator[T](name, zero, merge)(typed, the AccumulatorV2 analog);ObservedAccumulator(add/value/doubleValue) andTypedObservedAccumulator[T](
add/value); a resolution ruleInjectObservedAccumulatorsthat detects the accumulator(s)(Scala: closure reflection; Python: marker) and rewrites the plan (materialize a
struct(value, delta*)once, hoist oneCollectMetricsaggregate per delta, project the valueback); and a
QueryExecutionListenerthat harvests the values.spark.accumulatoron classic and Connect sessions. The UDF is tagged so the JVMrule fires for
df.withColumn;acc.valuereads the harvested value over py4j (classic) orfrom the Connect client registry (
is_remote()selects the source).server forwards observed metrics from server-injected
CollectMetricsnodes (keyed by__oa_metric_<name>, no client Observation), and the Python Connect client captures them into aper-client registry (numeric sums, exact ints, and -- for typed accumulators -- the collected
partials the driver folds). Harvesting through the Scala Connect client is a follow-up (see edge cases).
Because Catalyst cannot see the
add()calls inside an opaque UDF body, the UDF leaves adetectable marker (its name) and emits its per-row delta as hidden struct fields; the rule turns
those into observed aggregates. The UDF is marked non-deterministic so the optimizer cannot
duplicate it (value and aggregated delta must come from a single evaluation per row).
Why are the changes needed?
Classic accumulators updated inside transformations have no exactly-once guarantee: task
retries, speculation, and stage recomputation can apply
add()more than once. An observe-backedvalue is derived from the rows that actually survive at the observe point, so it is
exactly-once by construction.
Scope / limitation (by design): DataFrame/UDF-scoped. It cannot back
add()inside arbitraryRDD closures (no plan node for observe to attach to). An API-compatible, more-correct accumulator
for the DataFrame/UDF path, not a universal replacement for
SparkContextaccumulators.Does this PR introduce any user-facing change?
Yes — a new API:
SparkSession.accumulator(...)returning anObservedAccumulator(and the typedaccumulator[T](name, zero, merge)), withadd(...)(inside a UDF) andvalue/doubleValue(onthe driver), in Scala and PySpark. No existing behavior changes. As a draft, the API shape is open
for discussion.
How was this patch tested?
ObservedAccumulatorSuite(sql/core): plainudfvia the built-in rule,exactly-once single evaluation, cross-query accumulation, struct-to-scalar rewrite, a
double-valued accumulator,
foreachBatch, a typed custom-merge accumulator, multipleaccumulators (numeric + typed) in one UDF, detection of an accumulator held indirectly
(nested collection / object field), evaluation under both codegen (
CODEGEN_ONLY) andinterpreted (
NO_CODEGEN), cross-session rejection, an accumulator UDF in a filtercondition and composed in a larger expression, a fail-fast unobservable position, and a
cross-check that the observed value matches a classic
SparkContextaccumulator acrossseveral cases.
python/pyspark/sql/tests/test_observed_accumulator.pyplus a Connect paritysuite (
tests/connect/test_parity_observed_accumulator.py), registered indev/sparktestsupport/modules.py: scalar (row-at-a-time and vectorized pandas/Arrow), operatorUDFs, numeric and custom-merge, multiple accumulators, exact-
Long, cross-session rejection, thesame cross-check against a classic
SparkContextaccumulator, filter / composed positions andthe fail-fast unobservable position, and closure-detection coverage for nested containers,
captured object attributes, and helper functions (the pure detection logic is also unit-verified
without Spark).
UserDefinedFunctionE2ETestSuitecovers a numeric anda typed accumulator harvested through the Scala Connect client.
Run and passing locally on both classic and Spark Connect.
Module layout
The client-facing API (
ObservedAccumulator/TypedObservedAccumulator) lives insql/api,shared by the classic and Spark Connect Scala clients. The analyzer rule, harvest listener, struct
wrapper, and JVM registry (
InjectObservedAccumulators/ObservedAccumulatorRegistry) stay insql/core(they need catalyst).valuecomes fromprivate[sql] SparkSessionhooksoverridden per runtime: classic reads the JVM registry; the Scala Connect client reads a
client-side registry harvested from server responses (Scala Connect client harvest is a follow-up).
Automatic UDF detection
A plain UDF that merely calls
acc.add()is recognized and rewritten automatically. Detection isa bounded (depth- and node-capped), cycle-safe, best-effort walk of the closure graph on both
sides, so an accumulator is found however it is held — a captured variable/global, nested inside a
collection or map at any depth, an attribute of a captured object, or referenced by a helper
function the UDF calls. Framework "gateway" objects (a captured
SparkSession/DataFrame/etc.)are not traversed, and every access is guarded so detection never throws. Missing an accumulator
would silently return the zero value, so the walk errs toward finding too much (over-detection only
injects an observe whose delta is zero).
InjectObservedAccumulatorsreflects over theScalaUDF's closure and wraps thescalar UDF in
ObservedAccumulatorStructWrapper, which resets the buffers, evaluates the UDF, andemits
struct(value, delta*)— one delta field per referenced accumulator (numeric →Double,typed → serialized
Binarypartial). The wrapper has adoGenCode(participates in whole-stagecodegen) and is non-deterministic.
UserDefinedFunctioninspects a scalar UDF's closure the same way and rewrites it toemit the equivalent multi-delta struct.
The rewrite fires wherever the UDF occurs in a
Project(a column or nested in a larger expression,e.g.
select(udf($"x") + 1)) or aFiltercondition (e.g.filter(udf($"x"))) -- the struct ismaterialized once in a lower
Projectso the delta is observed exactly once per input row.Fail fast, not silent
Detection is a heuristic and the rewrite only reaches certain positions, so the risk is a silent
zero. To avoid that, after rewriting, an accumulator UDF still left in a position the rule cannot
observe -- inside an
Aggregate/Join/Window/Sortexpression, a higher-order-function lambda,or nested as an argument to another UDF -- raises a clear error instead of returning the zero value.
(A closure whose accumulator detection missed entirely still cannot be caught this way.)
Cross-session safety
State is scoped per session: the classic JVM registry is keyed by the creating session's UUID (not
the bare name), so two sessions that each create an accumulator named
"bad"do not collide; theConnect client already scopes state per client. The harvest listener captures its registering
session, so a Structured Streaming micro-batch clone (a fresh session that shares the listener)
still attributes to the creating session.
An accumulator is harvested by the session that created it, so reading it while a different
SparkSessionis active would silently return the zero value. Both the Scala and Pythonvalueraise instead (skipping the check when no session is active, so ordinary single-session use never
trips).
Types
Any value type is supported. Two paths:
with
sum. Python integer accumulators carry an exactLongdelta (matching a classicLongAccumulator, no precision loss past 2^53); floating accumulators carry aDouble. Scala:add(Long)/add(Double),value: Long/doubleValue: Double; PySparkvaluefollows thezerotype.spark.accumulator(zero, name, merge=fn)(PySpark) /spark.accumulator[T](name, zero, merge)(Scala), the analog of a classicAccumulatorV2: eachtask folds its partial (
add→merge), the partial is serialized, gathered withcollect_list,and folded on the driver with
merge. Works in scalar UDFs and in all four operator UDFs(
mapInPandas/applyInPandas/mapInArrow/applyInArrow), on both classic and Connect.Edge cases / follow-ons
transform/filter/… with an accumulator UDF in the lambda) are afollow-up; for now such usage fails fast with a clear error (accumulate in a top-level UDF or
an operator UDF instead) rather than silently mis-counting.
(it is the same rule the green PySpark Connect parity suite exercises), and the Scala client-side
harvest is implemented, but it is not yet verified end-to-end: the
connect-client-jvmE2Eharness spawns its server from assembled jars that do not carry this PR's freshly-built
sql/core, so the rule is absent on that server and the Scala client harvest path is neverexercised there. (A classic closure-serialization test confirms detection survives the exact
serialize/deserialize Connect does to ship a UDF.)
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)