[WIP][POC][SQL][PYTHON] Observe-backed accumulator (SparkSession.accumulator) - #57990
Draft
HyukjinKwon wants to merge 17 commits into
Draft
[WIP][POC][SQL][PYTHON] Observe-backed accumulator (SparkSession.accumulator)#57990HyukjinKwon wants to merge 17 commits into
HyukjinKwon wants to merge 17 commits into
Conversation
Adds an accumulator whose value is carried through the query plan and aggregated by a CollectMetrics (df.observe) node instead of the scheduler side channel, so it is exactly-once under task retries, speculation, and stage recomputation. - Scala (sql/core): SparkSession.accumulator, ObservedAccumulator, an InjectObservedAccumulators resolution rule that auto-inserts the observe node for df.withColumn (detecting a marker on the UDF, since Catalyst cannot see add() inside the UDF body), and a QueryExecutionListener that harvests the value into a driver registry. - PySpark: spark.accumulator on classic and Connect sessions, implemented over the public observe API so it works in both. - Tests: Scala suites for the explicit .apply path and the seamless withColumn path, plus a Python suite. Scope: DataFrame/UDF-scoped by construction. It cannot back .add() inside arbitrary RDD closures (no plan node for observe to attach to). See OBSERVED_ACCUMULATOR_DESIGN.md for the architecture and boundary. Generated-by: Claude Code (Opus 4.8) Co-authored-by: Isaac
- acc.udf(f) now returns a plain UserDefinedFunction used like any UDF, instead of a custom ObservedUDF with overloaded apply. The explicit (rule-off) path is acc.observe(df, column, outCol). - Wire InjectObservedAccumulators as a built-in post-hoc resolution rule in BaseSessionStateBuilder instead of a spark.sql.extensions injection. It stays an analyzer rule (not an optimizer rule) because it changes the column's output schema from struct to the scalar value, and a DataFrame's schema comes from the analyzed plan. - Remove the design note from the PR. Co-authored-by: Isaac
- Extend InjectObservedAccumulators to also match PythonUDF. The rule runs on the
JVM logical plan, which PySpark (classic and Connect) also builds, so the same
rewrite applies. It reads the value and delta by struct ordinal (0 and 1), so it
is language-agnostic.
- PySpark acc.udf now tags the PythonUDF with the marker name and marks it
non-deterministic, so df.withColumn("v", parse("raw")) is seamless with no
explicit .apply.
- Value bridge (classic): the accumulator registers the JVM harvest listener on
creation and acc.value reads the JVM registry via py4j. On Spark Connect there is
no local JVM, so the seamless value read is not wired there yet; use .apply
(Observation-based), which works over the wire.
- Add a seamless PySpark test (runs in a full build, where the JVM rule is present).
Co-authored-by: Isaac
…ect (PySpark) Make the seamless observed-accumulator value work on PySpark-on-Connect: - Connect server: after execution, capture observed metrics from server-injected CollectMetrics nodes (the ObservedAccumulator rule, named __oa_seamless_*) and forward them in the ExecutePlanResponse. They carry the metric key __oa_metric_<name> and have no client-registered Observation or plan id. Additive; existing observe/Observation behavior is unchanged. - Python Connect client: route __oa_metric_* observed metrics into a per-client registry (mirroring the existing __python_accumulator__ path); ObservedAccumulator reads it for the seamless path on Connect, selecting the classic (JVM registry) vs Connect (client registry) source via is_remote(). The Scala Connect client still needs the API moved into a shared module; left as a follow-on. Co-authored-by: Isaac
… and Connect) - Move the client-facing ObservedAccumulator (add/udf/observe/value) to sql/api so both the classic and Spark Connect Scala clients can use spark.accumulator. The analyzer rule, harvest listener, and JVM registry stay in sql/core (ObservedAccumulatorRegistry), which need catalyst. - value's seamless component is provided by a private[sql] SparkSession hook (observedAccumulatorSeamlessValue), overridden by the classic session to read the JVM registry (the Connect override is a follow-up). The explicit observe() path returns its value via Observations, which already works over the wire on Connect. - Register the harvest listener lazily and idempotently on the first spark.accumulator(...) call (removes the manual enableSeamless step and fixes a multi-register double-count). - Remove the dead install() helper; set the Python versionadded to 4.4.0. Classic Scala suites pass (4/4). Connect client value bridge lands in a follow-up commit. Co-authored-by: Isaac
…umulator Override observedAccumulatorSeamlessValue on the Connect SparkSession to read a client-side registry, and harvest server-injected ObservedAccumulator metrics (keys prefixed __oa_metric_, not tied to a client Observation) from responses as they stream in (executeInternal). With the sql/api API move, the Scala Connect client can now use spark.accumulator with both the explicit observe() path (value via Observations) and the seamless df.withColumn path (value via the client registry). Compiles; the Connect end-to-end path is CI-verified (no local Connect harness here). Co-authored-by: Isaac
…DF's closure Add ObservedAccumulatorStructWrapper (a CodegenFallback expression that resets the per-row buffer, evaluates the wrapped scalar UDF, then reads the delta back, emitting struct(value, delta)) and closure reflection in InjectObservedAccumulators (ClosureCleaner-style: scan the ScalaUDF's function fields, following $outer / nested lambdas) to detect a captured ObservedAccumulator. A plain functions.udf that merely calls acc.add() is then rewritten automatically -- no acc.udf wrapper needed. The wrapper is non-deterministic so it is evaluated once per row. Runtime-verified on classic: a new test with a plain UDF (not acc.udf) that captures the accumulator passes -- value correct, output rewritten to scalar, single evaluation. Co-authored-by: Isaac
…ed in a plain UDF closure Mirror the Scala closure detection for PySpark. UserDefinedFunction.__init__ now calls a hook (defensively, never breaking ordinary UDF creation) that inspects a scalar (SQL_BATCHED_UDF) UDF's closure and globals for a captured ObservedAccumulator; if found, it rewrites the UDF in place to emit struct(value, delta), tags it with the marker name, and marks it non-deterministic (which also skips transpilation), so the JVM InjectObservedAccumulators rule harvests it. A plain @udf that calls acc.add() then works with no acc.udf wrapper. No-op for UDFs that do not capture an accumulator and for pandas/Arrow UDFs. The detection logic is unit-verified locally (closure-cell and global capture, no false positive). End-to-end (rule firing on the Python plan) is CI-verified; added a plain-@udf seamless test. Co-authored-by: Isaac
…e; foreachBatch parity Match SparkContext.accumulator's surface (add/value only). A plain UDF that references the accumulator is detected via closure inspection and rewritten automatically, so the acc.udf and acc.observe helpers are removed (Scala + PySpark) along with the Observation-based value path (the built-in rule + registry now cover classic and Connect). - sql/api ObservedAccumulator: add/value only; value comes entirely from the seamless hook. - Scala rule: ScalaUDF is matched by closure detection only (Python still matches by marker). - Tests rewritten to use a plain functions.udf / @udf; consolidated the Scala suites. Added a foreachBatch (streaming) test -- runtime-verified on classic that the accumulator records inside foreachBatch across micro-batches, matching classic accumulators. - session.py docstring/example use a plain @udf. Co-authored-by: Isaac
Enable the closure-detection transform for SQL_ARROW_BATCHED_UDF (Arrow-optimized scalar udf, useArrow=True). It is invoked per row like SQL_BATCHED_UDF -- Arrow is only the serialization -- so the same row-at-a-time delta wrapper applies. Vectorized pandas UDFs and the mapInPandas/mapInArrow/applyInPandas/applyInArrow operators need a different per-batch/group approach and are not covered here. Co-authored-by: Isaac
… UDFs Extend seamless support to the remaining UDF shapes (closure detection + observe): - Scalar pandas_udf and Arrow scalar udf (SQL_SCALAR_PANDAS_UDF / SQL_SCALAR_ARROW_UDF and the elementwise variants): the transform emits a struct batch whose delta column carries the batch-total delta on its first row (observe sum totals it); acc.add(count) once per batch. - mapInPandas / mapInArrow / applyInPandas / applyInArrow: these are separate operators the JVM rule cannot touch, so a hidden __oa_delta column is added to the operator output and an observe node (__oa_seamless_*, metric __oa_metric_<name>) is injected then dropped -- the classic listener / Connect client registry harvest it by name/key as for the scalar path. Limitations (unverified here -- needs pandas/pyarrow + a full build; CI-verified): applyIn* handles the single-DataFrame/Table form only (bails to the original for the iterator form); mapIn* may miss delta added strictly after the final output batch or when a partition emits no rows; the Arrow-batch column append assumes a recent pyarrow. Co-authored-by: Isaac
…calar + operator UDFs) Wire the same seamless hooks on the Spark Connect Python client so migrating classic -> Connect needs no code change: - Connect UserDefinedFunction.__init__ runs the closure-detection transform (its attributes match classic), covering scalar udf / pandas_udf / Arrow scalar udf on Connect. - Connect _map_partitions (mapInPandas/mapInArrow) and GroupedData applyInPandas/applyInArrow inject the hidden delta column + observe, parity with the classic mixins. The Connect server already forwards the server-injected metrics and the client registry harvests them, so acc.value works over the wire. Add a Connect parity test (skips only the SparkContext-accumulator invocation-count check, which Connect lacks) and register it; make the test drain helper Connect-safe. Co-authored-by: Isaac
Generalize the internal delta to Double so an accumulator supports both Long and Double: - add(Long) and add(Double); value: Long (rounded) and doubleValue: Double (Scala). PySpark value returns int for an int zero (counter) and float for a float zero; add accepts int/float. - Executor buffer, struct delta field, JVM + Connect client registries, and the SparkSession seamless hook are Double; the harvest listener / Connect capture read doubleValue. Adds a classic double-accumulator test. (Scala classic verification via CI/build; the Connect and PySpark double paths are CI-verified.) Co-authored-by: Isaac
acc.udf/acc.observe were removed (seamless-only), so scrub the remaining mentions in code comments and correct the rule doc: the marker-matched path is the PySpark-tagged path (the JVM can't inspect a pickled Python closure), not acc.udf. Co-authored-by: Isaac
value now blocks until the harvest listener has processed queued events -- classic drains the listener bus, Connect harvests synchronously while the response streams -- so it reflects every completed query rather than racing the async listener. Tests no longer need an explicit drain. Verified on classic (5/5). Co-authored-by: Isaac
…om-merge accumulators (#4) - Doc build: replace Scaladoc [[...]] links in the accumulator sources with plain backticks; the unidoc/javadoc step failed on unresolvable references (fatal). (Fixes the failing CI job.) - #1: applyInPandas/applyInArrow now support the iterator form (route generator funcs through the iterator wrapper, passing *args so the (key, iterator) form works). - #4: custom (arbitrary-merge) accumulators via spark.accumulator(zero, name, merge=fn). add() folds into a per-task object partial; the pandas operator wrapper serializes the partial onto a tagged marker row, gathers partials with collect_list into an Observation, and value folds them on the driver with the user's merge. Numeric (Long/Double) stays the SQL-sum fast path. Pandas operators for now; Arrow-custom and scalar-custom are follow-ups. Also fixed a latent bug: the Python operator path referenced ObservedAccumulator.MetricPrefix/NodePrefix which were undefined. - Custom-merge and numeric buffer logic unit-verified without Spark; end-to-end is CI. Co-authored-by: Isaac
Format Python touched files with ruff 0.14.8 (the master lint toolchain), convert lambda assignments to nested defs (E731), modernize the two new test footers to `from pyspark.testing import main; main()`, drop an unused import, and apply scalafmt to the touched Scala files. 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 — with no wrapper API. A plain UDF that references theaccumulator is detected (its closure is inspected) and rewritten by the analyzer to observe the
per-row delta. Works on classic and Spark Connect, so code migrates unchanged.
More examples
Double-valued accumulator (
add(Double)→doubleValue):Vectorized
pandas_udf(callacc.addonce per batch):mapInPandasandapplyInPandas(operator UDFs):Structured Streaming via
foreachBatch(value accumulates across micro-batches):Included:
sql/core/.../ObservedAccumulator.scala+sql/api):SparkSession.accumulator(implicit extension);
ObservedAccumulatorwithadd/value/doubleValue; a resolution ruleInjectObservedAccumulatorsthat detects the accumulator (Scala: closure reflection; Python:marker) and rewrites the plan (materialize the struct once, hoist
CollectMetrics(sum(delta)),project the value back); and a
QueryExecutionListenerthat harvests the value.BaseSessionStateBuilder, post-hoc), so nospark.sql.extensionswiring. It matches bothScalaUDFandPythonUDF(it runs on the JVMlogical plan, which PySpark builds too) and reads value/delta by struct ordinal, so it is
language-agnostic. It must run during analysis (not the optimizer) because it changes the
column's output type from a struct to the scalar value, and a DataFrame's schema comes from the
analyzed plan.
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).Connect 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 (mirroring the existing
__python_accumulator__path).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 a hidden struct field; the rule turns
that into an observed aggregate. 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), nor merge semantics not expressible as a SQL
aggregate. 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 anObservedAccumulatorwithadd(...)(inside a UDF) andvalue(on the driver), in Scala and PySpark. No existing behaviorchanges. As a draft, the API shape is open for discussion.
How was this patch tested?
ObservedAccumulatorSuite(explicitobservepath) andObservedAccumulatorSeamlessSuite(all plain
udf, via the built-in rule) — pass locally. Cover correctness, exactly-once singleevaluation, cross-query accumulation, struct-to-scalar rewrite, a double-valued accumulator,
and
foreachBatch.python/pyspark/sql/tests/test_observed_accumulator.pyplus a Connect parity suite(
tests/connect/test_parity_observed_accumulator.py), registered indev/sparktestsupport/modules.py— plain@udfseamless, cumulative, double-valued, and closuredetection. The Python closure detection logic is also unit-verified without Spark.
Module layout
The client-facing API (
ObservedAccumulator:add/value/doubleValue) lives insql/api,shared by the classic and Spark Connect Scala clients. The analyzer rule, harvest listener, and
JVM registry (
InjectObservedAccumulators/ObservedAccumulatorRegistry) stay insql/core(they need catalyst).
value's seamless component comes from aprivate[sql] SparkSessionhookoverridden per runtime: classic reads the JVM registry; the Scala Connect client reads a
client-side registry harvested from server responses. The harvest listener registers lazily and
idempotently on first
spark.accumulator(...).100% seamless (no wrapper)
A plain UDF that merely calls
acc.add()is recognized and rewritten automatically — no wrapperor special decorator:
InjectObservedAccumulatorsreflects over theScalaUDF's closure(
ClosureCleaner-style, following$outer/nested lambdas) for a capturedObservedAccumulator,and wraps the scalar UDF in
ObservedAccumulatorStructWrapper(resets the buffer, evaluates theUDF, reads the delta →
struct(value, delta);CodegenFallback, non-deterministic).UserDefinedFunction.__init__inspects a scalar UDF's__closure__/__globals__for a captured
ObservedAccumulatorand rewrites it the same way.Streaming
Works inside
foreachBatch, matching classic accumulators — the per-micro-batchbatchDFis anordinary batch DataFrame, so the built-in rule fires and the listener harvests per batch
(cumulative across batches). Runtime-verified on classic.
UDF coverage
Seamless support (a plain UDF that references the accumulator is detected via closure inspection
and rewritten — no wrapper) spans:
udf, plain and Arrow-optimized — classic runtime-verified.pandas_udf/ Arrow scalarudf— the transform emits a struct batch withthe batch-total delta on its first row (observe
sumtotals it); callacc.add(count)once perbatch.
mapInPandas/mapInArrow/applyInPandas/applyInArrow— these areseparate operators the projection rule can't touch, so a hidden
__oa_deltacolumn is added tothe operator's output and an
observenode is injected in Python and then dropped (harvested byname/key exactly like the scalar path — no extra JVM change). Hooked on both classic and
Connect (classic mixins + Connect
_map_partitions/GroupedData).applyIn*handles both thesingle-DataFrame/Table and the iterator forms. (See edge cases below.)
Classic ⇄ Connect parity: the seamless hooks are wired in both the classic and Connect Python
UDF classes and operator methods, and
acc.valuereads the classic JVM registry or the Connectclient registry via
is_remote()— so code migrates classic ⇄ Connect unchanged. A Connect paritytest runs the shared suite on a Connect session.
Everything except the row-at-a-time scalar classic path is CI/real-build-verified only (needs
pandas/pyarrow, a full build, and a Connect harness); the Python closure detection + schema helpers
are unit-verified.
Types
Long/Double— the SQLsumfast path:add(Long)/add(Double);value: Longand
doubleValue: Double; PySparkvaluefollows thezerotype.spark.accumulator(zero, name, merge=fn): each task folds itspartial (
add→merge), the partial is serialized, gathered withcollect_list, and folded onthe driver with
merge(bounded by #partitions, not #rows — the generalAccumulatorV2pattern).Implemented for PySpark pandas
mapInPandas/applyInPandas. Arrow-custom and scalar-custom(no partition boundary in a scalar UDF) are follow-ups.
Edge cases / follow-ons
mapIn*: numeric delta added strictly after the final output batch, or in a partition that emitszero rows, is not captured (documented; rare).
valueblocks on query completion on classic (drains the listener bus); Connect harvestssynchronously.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)