This is an automated email from the ASF dual-hosted git repository.
HeartSaVioR pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 1f2d822b87ca [SPARK-57489][SS] Deterministic dropDuplicates key
resolution via shared analyzer rule
1f2d822b87ca is described below
commit 1f2d822b87ca1f96f5514fe1a2efb4dcc5d72f13
Author: Jungtaek Lim <[email protected]>
AuthorDate: Wed Jun 17 10:37:19 2026 +0900
[SPARK-57489][SS] Deterministic dropDuplicates key resolution via shared
analyzer rule
### What changes were proposed in this pull request?
`dropDuplicates` / `dropDuplicatesWithinWatermark` historically resolve
their key columns eagerly: in the DataFrame implementation for Spark Classic
(`Dataset.groupColsFromDropDuplicates`, via `colNames.toSet.toSeq`) and in the
Spark Connect planner (`transformDeduplicate`). The Spark Classic path's key
order therefore depends on Scala's `Set` iteration order, which is undocumented
and changed between Scala 2.12 and 2.13, and which also differs from Spark
Connect. Because streaming de [...]
This keeps binding by position, but makes the resolution deterministic and
shared between engines:
- Add an unresolved logical node `UnresolvedDeduplicate` and an analyzer
rule `ResolveDeduplicate` that both Spark Classic (`Dataset.dropDuplicates*`)
and Spark Connect (`transformDeduplicate`) defer to. The rule resolves the
requested columns with an explicit, order-preserving dedup (intentionally NOT
`Seq.distinct`/`Set`, whose ordering is not contractually guaranteed), so the
key order is deterministic and identical across Scala versions and engines.
- Add `spark.sql.dropDuplicates.deterministicKeyOrder.enabled` (default
`true`), persisted via the offset log (`OffsetSeqMetadata.relevantSQLConfs`,
with legacy default `false`), so the deterministic order applies only to newly
started streaming queries; existing checkpoints keep their original key order.
- The resolved `Deduplicate` / `DeduplicateWithinWatermark` node retains
the original recipe (a `DeduplicateSpec`: a `DeduplicateKeySpec` — explicit key
columns or all columns — plus `viaSparkClassic`). For streaming queries,
`MicroBatchExecution.logicalPlan` recomputes the key columns at query start
using the order pinned in the offset log (read via
`OffsetSeqMetadata.readValueOpt`, mirroring how
`ENABLE_STREAMING_SOURCE_EVOLUTION` is read). When the pinned value is the
legacy order, [...]
### Why are the changes needed?
Streaming deduplication binds state-store keys by position. A key order
that depends on Scala's `Set` iteration order is non-deterministic across Scala
versions and differs between Spark Classic and Spark Connect, which can break
streaming query restarts and cross-engine checkpoint compatibility.
### Does this PR introduce _any_ user-facing change?
Yes.
- For newly started streaming `dropDuplicates` /
`dropDuplicatesWithinWatermark` queries, the key columns are resolved in a
deterministic, first-occurrence order instead of a Scala-`Set`-dependent order.
The deduplication results (rows) are unchanged; only the internal key ordering
used for the state store changes.
- Existing checkpointed queries are unaffected: the effective order is
pinned per query via the offset log.
- Spark Classic and Spark Connect now resolve `dropDuplicates` keys
identically.
- An unresolvable `dropDuplicates` column now fails at query analysis
(consistent with `Project`/`Filter`) rather than at the API call in Spark
Classic. For Spark Connect, it now surfaces the analyzer's
`UNRESOLVED_COLUMN_AMONG_FIELD_NAMES` error instead of the Connect-specific
`CONNECT_INVALID_PLAN.UNRESOLVED_COLUMN_AMONG_FIELD_NAMES` (removed as dead
code), so both engines report the same error condition.
### How was this patch tested?
New tests:
- `ResolveDeduplicateSuite` — unit tests for the analyzer rule
(deterministic first-occurrence order, engine parity, Classic/Connect legacy
fallback, all-columns, within-watermark, duplicate-named columns, and
unknown-column failure).
- `StreamingDeduplicationConnectInteropSuite` — a checkpoint written by one
engine restarts under the other.
- `StreamingDeduplicationFallbackSuite` — an existing checkpoint without
the conf falls back to the legacy key order; a newly started query pins the
deterministic order and keeps it on restart.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.8
Closes #56546 from HeartSaVioR/dropduplicates-deterministic-key-order.
Authored-by: Jungtaek Lim <[email protected]>
Signed-off-by: Jungtaek Lim <[email protected]>
(cherry picked from commit 86deed7748a96cf6a35b4079b9eb08beb8e9ff1c)
Signed-off-by: Jungtaek Lim <[email protected]>
---
.../src/main/resources/error/error-conditions.json | 5 -
.../spark/sql/catalyst/analysis/Analyzer.scala | 1 +
.../sql/catalyst/analysis/ResolveDeduplicate.scala | 126 ++++++++++++++++
.../spark/sql/catalyst/analysis/unresolved.scala | 33 ++++-
.../spark/sql/catalyst/optimizer/Optimizer.scala | 18 +--
.../plans/logical/basicLogicalOperators.scala | 38 ++++-
.../spark/sql/catalyst/trees/TreePatterns.scala | 1 +
.../org/apache/spark/sql/internal/SQLConf.scala | 15 ++
.../analysis/ResolveDeduplicateSuite.scala | 164 +++++++++++++++++++++
.../sql/connect/planner/InvalidInputErrors.scala | 5 -
.../sql/connect/planner/SparkConnectPlanner.scala | 31 ++--
...StreamingDeduplicationConnectInteropSuite.scala | 142 ++++++++++++++++++
.../connect/planner/SparkConnectProtoSuite.scala | 14 +-
.../org/apache/spark/sql/classic/Dataset.scala | 55 +++----
.../spark/sql/execution/SparkStrategies.scala | 6 +-
.../streaming/checkpointing/OffsetSeq.scala | 6 +-
.../streaming/runtime/MicroBatchExecution.scala | 27 +++-
.../StreamingDeduplicationFallbackSuite.scala | 143 ++++++++++++++++++
18 files changed, 750 insertions(+), 80 deletions(-)
diff --git a/common/utils/src/main/resources/error/error-conditions.json
b/common/utils/src/main/resources/error/error-conditions.json
index 2dfb780cfff4..411cddbd16f7 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -1453,11 +1453,6 @@
"Unknown Analyze Method <other>!"
]
},
- "UNRESOLVED_COLUMN_AMONG_FIELD_NAMES" : {
- "message" : [
- "Cannot resolve column name \"<colName>\" among (<fieldNames>)."
- ]
- },
"UNRESOLVED_NAMED_LAMBDA_VARIABLE_REQUIRES_NAME_PART" : {
"message" : [
"UnresolvedNamedLambdaVariable requires at least one name part!"
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
index a97e0fac6ce5..98b973abeb8a 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
@@ -564,6 +564,7 @@ class Analyzer(
ResolveBinaryArithmetic ::
new ResolveIdentifierClause(earlyBatches) ::
ResolveUnion ::
+ ResolveDeduplicate ::
ResolveZip ::
FlattenSequentialStreamingUnion ::
ValidateSequentialStreamingUnion ::
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveDeduplicate.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveDeduplicate.scala
new file mode 100644
index 000000000000..8d6b94b728e4
--- /dev/null
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveDeduplicate.scala
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.analysis
+
+import scala.collection.mutable
+
+import org.apache.spark.sql.catalyst.expressions.Attribute
+import org.apache.spark.sql.catalyst.plans.logical.{Deduplicate,
DeduplicateAllColumnsAsKey, DeduplicateKeyColumns, DeduplicateSpec,
DeduplicateWithinWatermark, LogicalPlan}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.catalyst.trees.TreePattern.UNRESOLVED_DEDUPLICATE
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Resolves [[UnresolvedDeduplicate]] (built by `Dataset.dropDuplicates*` in
Spark Classic and by
+ * the Deduplicate relation in the Spark Connect planner) into a
[[Deduplicate]] /
+ * [[DeduplicateWithinWatermark]] with resolved key attributes, shared by both
engines.
+ *
+ * The key attributes are computed with
[[SQLConf.DROP_DUPLICATES_DETERMINISTIC_KEY_ORDER]] read
+ * from the current session: true (the default) produces a stable,
first-occurrence ordering; false
+ * reproduces each engine's legacy resolution. The resolved node also carries
the original recipe
+ * (a `DeduplicateSpec` holding the key spec and `viaSparkClassic`) so
streaming queries can
+ * recompute the keys at query start with the value pinned in the offset log
(see
+ * `MicroBatchExecution.logicalPlan` and [[computeKeys]]). See SPARK-57489.
+ */
+object ResolveDeduplicate extends Rule[LogicalPlan] {
+
+ override def apply(plan: LogicalPlan): LogicalPlan =
plan.resolveOperatorsUpWithPruning(
+ _.containsPattern(UNRESOLVED_DEDUPLICATE)) {
+ case d: UnresolvedDeduplicate if d.child.resolved =>
+ val orderDeterministically =
conf.getConf(SQLConf.DROP_DUPLICATES_DETERMINISTIC_KEY_ORDER)
+ val spec = DeduplicateSpec(d.keySpec, d.viaSparkClassic)
+ val keys = computeKeys(d.child, spec, orderDeterministically,
conf.resolver)
+ if (d.withinWatermark) {
+ DeduplicateWithinWatermark(keys, d.child, Some(spec))
+ } else {
+ Deduplicate(keys, d.child, Some(spec))
+ }
+ }
+
+ /**
+ * Computes the deduplication key attributes from the requested recipe
against `child`'s output.
+ * Shared by the analyzer rule (with the session config) and by the
streaming bootstrap (with the
+ * value pinned in the offset log), so the ordering rules stay in one place.
+ *
+ * @param orderDeterministically when true, a stable first-occurrence order;
when false, the
+ * legacy (engine-specific) order selected by `spec.viaSparkClassic`.
+ */
+ def computeKeys(
+ child: LogicalPlan,
+ spec: DeduplicateSpec,
+ orderDeterministically: Boolean,
+ resolver: Resolver): Seq[Attribute] = {
+ spec.keySpec match {
+ case DeduplicateAllColumnsAsKey =>
+ // All child columns are keys. The deterministic order and legacy
Spark Connect both key on
+ // the child output directly (in output order, no name resolution);
only legacy Spark
+ // Classic reorders the names through a Set (see
legacyClassicColumnNames).
+ if (!orderDeterministically && spec.viaSparkClassic) {
+ resolveColumnNames(child,
legacyClassicColumnNames(child.output.map(_.name)), resolver)
+ } else {
+ child.output
+ }
+ case DeduplicateKeyColumns(colNames) =>
+ val orderedNames = if (orderDeterministically) {
+ dedupKeepingOrder(colNames)
+ } else if (spec.viaSparkClassic) {
+ legacyClassicColumnNames(colNames)
+ } else {
+ // Legacy Spark Connect resolution: no dedup, caller-provided input
order.
+ colNames
+ }
+ resolveColumnNames(child, orderedNames, resolver)
+ }
+ }
+
+ /**
+ * Reproduces Spark Classic's legacy dedup-key resolution (SPARK-31990),
which collapsed the
+ * requested column names through a `Set`. The resulting (Set iteration)
order is preserved for
+ * existing queries whose checkpoints predate the deterministic order.
Shared by the all-columns
+ * and subset forms so both stay faithful to Spark Classic.
+ */
+ private def legacyClassicColumnNames(names: Seq[String]): Seq[String] =
names.toSet.toSeq
+
+ private def resolveColumnNames(
+ child: LogicalPlan, names: Seq[String], resolver: Resolver):
Seq[Attribute] = {
+ names.flatMap { colName =>
+ // It is possible there is more than one column with the same name, so
we call filter
+ // instead of find.
+ val cols = child.output.filter(col => resolver(col.name, colName))
+ if (cols.isEmpty) {
+ throw
QueryCompilationErrors.cannotResolveColumnNameAmongAttributesError(
+ colName, child.output.map(_.name).mkString(", "))
+ }
+ cols
+ }
+ }
+
+ /**
+ * Deduplicate `names` preserving first-occurrence order. The stable
ordering is an invariant for
+ * stateful operators, so we rely only on the documented contract of `Seq`
traversal and explicit
+ * append, not on `Seq.distinct`/`Set` (whose ordering is not contractually
guaranteed).
+ * See SPARK-57489.
+ */
+ private def dedupKeepingOrder(names: Seq[String]): Seq[String] = {
+ val seen = mutable.HashSet.empty[String]
+ val out = mutable.ArrayBuffer.empty[String]
+ names.foreach(name => if (seen.add(name)) out += name)
+ out.toSeq
+ }
+}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/unresolved.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/unresolved.scala
index a5b467d0f081..fc51dd8c72d4 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/unresolved.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/unresolved.scala
@@ -23,7 +23,7 @@ import org.apache.spark.sql.catalyst.{FunctionIdentifier,
InternalRow, TableIden
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext,
ExprCode}
import org.apache.spark.sql.catalyst.parser.CatalystSqlParser
-import org.apache.spark.sql.catalyst.plans.logical.{LeafNode, LogicalPlan,
SupportsSubquery, UnaryNode}
+import org.apache.spark.sql.catalyst.plans.logical.{DeduplicateKeySpec,
LeafNode, LogicalPlan, SupportsSubquery, UnaryNode}
import org.apache.spark.sql.catalyst.trees.TreePattern._
import org.apache.spark.sql.catalyst.util._
import org.apache.spark.sql.catalyst.util.TypeUtils.toSQLId
@@ -1168,6 +1168,37 @@ case class UnresolvedQualify(condition: Expression,
child: LogicalPlan) extends
final override val nodePatterns: Seq[TreePattern] = Seq(UNRESOLVED_QUALIFY)
}
+/**
+ * An unresolved logical plan for `dropDuplicates` /
`dropDuplicatesWithinWatermark`. It holds the
+ * user-requested deduplication columns (by name, or "all columns") and is
resolved by the
+ * `ResolveDeduplicate` analyzer rule into a [[Deduplicate]] /
[[DeduplicateWithinWatermark]].
+ *
+ * Resolving the keys in the analyzer (rather than eagerly in the DataFrame
API or the Spark Connect
+ * planner) lets both engines share one resolution with a stable key order. A
stable order matters
+ * for streaming deduplication, whose state store binds keys by position: a
different key order
+ * across restarts would break state-store key-schema compatibility. See
SPARK-57489.
+ *
+ * @param keySpec which columns form the deduplication key (an explicit set,
or all columns).
+ * @param withinWatermark when true, resolves to `DeduplicateWithinWatermark`.
+ * @param viaSparkClassic whether the deduplication was requested via Spark
Classic
+ * (`Dataset.dropDuplicates*`, true) or Spark Connect
(`transformDeduplicate`, false). This only
+ * matters on the legacy-fallback path, consulted ONLY when the
deterministic key order is
+ * disabled (an existing query restored from a checkpoint that predates this
change): Spark
+ * Classic reproduces its legacy resolution (`toSet`-based dedup, `Set`
order) while Spark
+ * Connect reproduces its own (no dedup, input order). Ignored on the
deterministic path.
+ */
+case class UnresolvedDeduplicate(
+ keySpec: DeduplicateKeySpec,
+ withinWatermark: Boolean,
+ viaSparkClassic: Boolean,
+ child: LogicalPlan) extends UnaryNode {
+ override lazy val resolved: Boolean = false
+ override def output: Seq[Attribute] = child.output
+ override protected def withNewChildInternal(newChild: LogicalPlan):
UnresolvedDeduplicate =
+ copy(child = newChild)
+ final override val nodePatterns: Seq[TreePattern] =
Seq(UNRESOLVED_DEDUPLICATE)
+}
+
/**
* A place holder expression used in random functions, will be replaced after
analyze.
*/
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
index 6bc7b8d85ef5..51c6a3399b39 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
@@ -894,9 +894,9 @@ object RemoveNoopUnion extends Rule[LogicalPlan] {
_.containsAllPatterns(DISTINCT_LIKE, UNION)) {
case d @ Distinct(u: Union) =>
d.withNewChildren(Seq(simplifyUnion(u)))
- case d @ Deduplicate(_, u: Union) =>
+ case d @ Deduplicate(_, u: Union, _) =>
d.withNewChildren(Seq(simplifyUnion(u)))
- case d @ DeduplicateWithinWatermark(_, u: Union) =>
+ case d @ DeduplicateWithinWatermark(_, u: Union, _) =>
d.withNewChildren(Seq(simplifyUnion(u)))
}
}
@@ -1858,12 +1858,12 @@ object CombineUnions extends Rule[LogicalPlan] {
case SequentialOrSimpleUnion(u) => flattenUnion(u, false)
case Distinct(SequentialOrSimpleUnion(u)) => Distinct(flattenUnion(u,
true))
// Only handle distinct-like 'Deduplicate', where the keys == output
- case Deduplicate(keys: Seq[Attribute], SequentialOrSimpleUnion(u))
+ case d @ Deduplicate(keys: Seq[Attribute], SequentialOrSimpleUnion(u), _)
if AttributeSet(keys) == u.outputSet =>
- Deduplicate(keys, flattenUnion(u, true))
- case DeduplicateWithinWatermark(keys: Seq[Attribute],
SequentialOrSimpleUnion(u))
+ d.copy(child = flattenUnion(u, true))
+ case d @ DeduplicateWithinWatermark(keys: Seq[Attribute],
SequentialOrSimpleUnion(u), _)
if AttributeSet(keys) == u.outputSet =>
- DeduplicateWithinWatermark(keys, flattenUnion(u, true))
+ d.copy(child = flattenUnion(u, true))
}
private def flattenUnion(union: UnionBase, flattenDistinct: Boolean):
UnionBase = {
@@ -1894,7 +1894,7 @@ object CombineUnions extends Rule[LogicalPlan] {
case Distinct(SequentialOrSimpleUnion(u)) if flattenDistinct &&
canMerge(u) =>
stack.pushAll(u.children.reverse)
// Only handle distinct-like 'Deduplicate', where the keys == output
- case Deduplicate(keys: Seq[Attribute], SequentialOrSimpleUnion(u))
+ case Deduplicate(keys: Seq[Attribute], SequentialOrSimpleUnion(u), _)
if flattenDistinct && canMerge(u) && AttributeSet(keys) ==
u.outputSet =>
stack.pushAll(u.children.reverse)
case SequentialOrSimpleUnion(u) if canMerge(u) =>
@@ -1907,7 +1907,7 @@ object CombineUnions extends Rule[LogicalPlan] {
canPushProjectionThroughUnion(project) =>
stack.pushAll(pushProjectionThroughUnion(projectList, u).reverse)
case project @ Project(
- projectList, Deduplicate(keys: Seq[Attribute],
SequentialOrSimpleUnion(u)))
+ projectList, Deduplicate(keys: Seq[Attribute],
SequentialOrSimpleUnion(u), _))
if projectList.forall(_.deterministic) && flattenDistinct &&
canMerge(u) &&
AttributeSet(keys) == u.outputSet &&
canPushProjectionThroughUnion(project) =>
stack.pushAll(pushProjectionThroughUnion(projectList, u).reverse)
@@ -2709,7 +2709,7 @@ object ReplaceDistinctWithAggregate extends
Rule[LogicalPlan] {
*/
object ReplaceDeduplicateWithAggregate extends Rule[LogicalPlan] {
def apply(plan: LogicalPlan): LogicalPlan = plan transformUpWithNewOutput {
- case d @ Deduplicate(keys, child) if !child.isStreaming =>
+ case d @ Deduplicate(keys, child, _) if !child.isStreaming =>
val keyExprIds = keys.map(_.exprId)
val generatedAliasesMap = new mutable.HashMap[Attribute, Alias]();
val aggCols = child.output.map { attr =>
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
index 5f900bc96602..673edaab1368 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
@@ -2313,10 +2313,35 @@ case class OneRowRelation() extends LeafNode {
}
}
+/**
+ * How the deduplication keys are specified: either an explicit set of column
names
+ * ([[DeduplicateKeyColumns]]) or all of the child's columns
([[DeduplicateAllColumnsAsKey]]).
+ */
+sealed trait DeduplicateKeySpec
+case class DeduplicateKeyColumns(colNames: Seq[String]) extends
DeduplicateKeySpec
+case object DeduplicateAllColumnsAsKey extends DeduplicateKeySpec
+
+/**
+ * The original recipe behind a [[Deduplicate]] /
[[DeduplicateWithinWatermark]] node, set by the
+ * `ResolveDeduplicate` analyzer rule and retained so a streaming query can
recompute its key
+ * attributes at query start in the ordering pinned in the offset log (see
+ * `ResolveDeduplicate.computeKeys`). A `None` spec on a node means it was not
built from
+ * `dropDuplicates*` (e.g. an internally/test-constructed node) and its keys
must NOT be recomputed.
+ *
+ * @param keySpec which columns form the deduplication key.
+ * @param viaSparkClassic whether this was built via Spark Classic
(`Dataset.dropDuplicates*`, true)
+ * or Spark Connect (`transformDeduplicate`, false). Only consulted when
recomputing the keys in
+ * the legacy order, where the two engines historically differed. See
SPARK-57489.
+ */
+case class DeduplicateSpec(
+ keySpec: DeduplicateKeySpec,
+ viaSparkClassic: Boolean)
+
/** A logical plan for `dropDuplicates`. */
case class Deduplicate(
keys: Seq[Attribute],
- child: LogicalPlan) extends UnaryNode {
+ child: LogicalPlan,
+ dedupSpec: Option[DeduplicateSpec] = None) extends UnaryNode {
override def maxRows: Option[Long] = child.maxRows
override def output: Seq[Attribute] = {
val base = child.output
@@ -2326,9 +2351,15 @@ case class Deduplicate(
override protected def withNewChildInternal(newChild: LogicalPlan):
Deduplicate =
copy(child = newChild)
override def isStateful: Boolean = child.isStreaming
+ // `dedupSpec` is internal metadata used only to recompute keys on streaming
restart; keep it out
+ // of the tree string (EXPLAIN output and golden plans).
+ override protected def stringArgs: Iterator[Any] = Iterator(keys, child)
}
-case class DeduplicateWithinWatermark(keys: Seq[Attribute], child:
LogicalPlan) extends UnaryNode {
+case class DeduplicateWithinWatermark(
+ keys: Seq[Attribute],
+ child: LogicalPlan,
+ dedupSpec: Option[DeduplicateSpec] = None) extends UnaryNode {
// Ensure that references include event time columns so they are not pruned
away.
override def references: AttributeSet = AttributeSet(keys) ++
AttributeSet(child.output.filter(_.metadata.contains(EventTimeWatermark.delayKey)))
@@ -2341,6 +2372,9 @@ case class DeduplicateWithinWatermark(keys:
Seq[Attribute], child: LogicalPlan)
override protected def withNewChildInternal(newChild: LogicalPlan):
DeduplicateWithinWatermark =
copy(child = newChild)
override def isStateful: Boolean = child.isStreaming
+ // `dedupSpec` is internal metadata used only to recompute keys on streaming
restart; keep it out
+ // of the tree string (EXPLAIN output and golden plans).
+ override protected def stringArgs: Iterator[Any] = Iterator(keys, child)
}
/**
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
index 79decd1c2e57..eed7ebd80dba 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
@@ -195,6 +195,7 @@ object TreePattern extends Enumeration {
val NAMED_STREAMING_RELATION: Value = Value
val PLAN_WITH_UNRESOLVED_IDENTIFIER: Value = Value
val UNRESOLVED_BIN_BY: Value = Value
+ val UNRESOLVED_DEDUPLICATE: Value = Value
val UNRESOLVED_EVENT_TIME_WATERMARK: Value = Value
val UNRESOLVED_HAVING: Value = Value
val UNRESOLVED_HINT: Value = Value
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index e017a9076b1d..0a94c5a5980a 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -3485,6 +3485,21 @@ object SQLConf {
.booleanConf
.createWithDefault(true)
+ val DROP_DUPLICATES_DETERMINISTIC_KEY_ORDER =
+ buildConf("spark.sql.dropDuplicates.deterministicKeyOrder.enabled")
+ .internal()
+ .doc("When true, dropDuplicates and dropDuplicatesWithinWatermark
resolve their key " +
+ "columns with a stable, first-occurrence ordering shared by Spark
Classic and Spark " +
+ "Connect. The order does not affect batch results, but stateful
streaming " +
+ "deduplication binds state-store keys by position, so for streaming
the effective " +
+ "value is pinned per query in the offset log at batch 0: newly started
queries use " +
+ "the stable order, while queries restored from a checkpoint that
predates this config " +
+ "keep their original key order. Please refer to SPARK-57489 for
details.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.SESSION)
+ .booleanConf
+ .createWithDefault(true)
+
val STATEFUL_OPERATOR_ALWAYS_NULLABLE_OUTPUT =
buildConf("spark.sql.streaming.statefulOperator.alwaysNullableOutput.enabled")
.internal()
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveDeduplicateSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveDeduplicateSuite.scala
new file mode 100644
index 000000000000..e75bd06af4b4
--- /dev/null
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveDeduplicateSuite.scala
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.analysis
+
+import org.apache.spark.sql.AnalysisException
+import org.apache.spark.sql.catalyst.dsl.expressions._
+import org.apache.spark.sql.catalyst.expressions.Attribute
+import org.apache.spark.sql.catalyst.plans.logical.{Deduplicate,
DeduplicateAllColumnsAsKey, DeduplicateKeyColumns, DeduplicateWithinWatermark,
LocalRelation, LogicalPlan}
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Unit tests for [[ResolveDeduplicate]], the analyzer rule that resolves
[[UnresolvedDeduplicate]]
+ * into a [[Deduplicate]] / [[DeduplicateWithinWatermark]]. The rule is
applied directly so the
+ * deterministic-key-order conf can be toggled per case. See SPARK-57489.
+ */
+class ResolveDeduplicateSuite extends AnalysisTest {
+
+ private val a = $"a".int
+ private val b = $"b".int
+ private val c = $"c".int
+ private val rel = LocalRelation(c, a, b)
+
+ private val confKey = SQLConf.DROP_DUPLICATES_DETERMINISTIC_KEY_ORDER.key
+
+ private def resolveKeys(
+ columnNames: Seq[String],
+ allColumnsAsKeys: Boolean = false,
+ withinWatermark: Boolean = false,
+ viaSparkClassic: Boolean = true,
+ child: LogicalPlan = rel): Seq[Attribute] = {
+ val keySpec =
+ if (allColumnsAsKeys) DeduplicateAllColumnsAsKey else
DeduplicateKeyColumns(columnNames)
+ ResolveDeduplicate(UnresolvedDeduplicate(
+ keySpec, withinWatermark, viaSparkClassic, child)) match {
+ case d: Deduplicate => d.keys
+ case d: DeduplicateWithinWatermark => d.keys
+ case other => fail(s"Expected a
(Deduplicate|DeduplicateWithinWatermark), got: $other")
+ }
+ }
+
+ test("SPARK-57489: deterministic resolution keeps first-occurrence input
order and dedups") {
+ withSQLConf(confKey -> "true") {
+ // The input order is preserved and duplicate names are collapsed at
first occurrence. Under
+ // the old `toSet.toSeq` this order would not be guaranteed; asserting
it exactly guards
+ // against anyone reintroducing Set/distinct-based ordering.
+ assert(resolveKeys(Seq("c", "a", "c", "b")).map(_.name) === Seq("c",
"a", "b"))
+ assert(resolveKeys(Seq("b", "b", "a")).map(_.name) === Seq("b", "a"))
+ }
+ }
+
+ test("SPARK-57489: deterministic resolution identical across engines
(interop)") {
+ // The deterministic path ignores the engine flag, so Classic and Connect
resolve the SAME
+ // attributes (same exprIds, same order). This is what lets a checkpoint
written by one engine
+ // restart under the other.
+ withSQLConf(confKey -> "true") {
+ val classicSubset = resolveKeys(Seq("c", "a", "c", "b"), viaSparkClassic
= true)
+ val connectSubset = resolveKeys(Seq("c", "a", "c", "b"), viaSparkClassic
= false)
+ assert(classicSubset.map(_.exprId) === connectSubset.map(_.exprId))
+ assert(classicSubset.map(_.name) === Seq("c", "a", "b"))
+
+ val classicAll = resolveKeys(Nil, allColumnsAsKeys = true,
viaSparkClassic = true)
+ val connectAll = resolveKeys(Nil, allColumnsAsKeys = true,
viaSparkClassic = false)
+ assert(classicAll.map(_.exprId) === connectAll.map(_.exprId))
+ assert(classicAll === rel.output)
+ }
+ }
+
+ test("SPARK-57489: legacy fallback - Classic dedups the subset (Set order)")
{
+ withSQLConf(confKey -> "false") {
+ val keys = resolveKeys(Seq("c", "a", "c", "b"), viaSparkClassic = true)
+ // Classic legacy used `toSet.toSeq`: duplicate names are collapsed
(order is Set-defined and
+ // intentionally not asserted here).
+ assert(keys.length === 3)
+ assert(keys.map(_.name).toSet === Set("a", "b", "c"))
+ }
+ }
+
+ test("SPARK-57489: legacy fallback - Connect keeps duplicates in input
order") {
+ withSQLConf(confKey -> "false") {
+ val keys = resolveKeys(Seq("c", "a", "c", "b"), viaSparkClassic = false)
+ // Connect legacy did not dedup the requested names.
+ assert(keys.map(_.name) === Seq("c", "a", "c", "b"))
+ }
+ }
+
+ test("SPARK-57489: allColumnsAsKeys - deterministic and Connect legacy use
child output order") {
+ // Deterministic (regardless of engine flag) keys on all child columns in
output order.
+ withSQLConf(confKey -> "true") {
+ assert(
+ resolveKeys(Nil, allColumnsAsKeys = true, viaSparkClassic = true) ===
rel.output)
+ assert(
+ resolveKeys(Nil, allColumnsAsKeys = true, viaSparkClassic = false) ===
rel.output)
+ }
+ // Legacy Spark Connect also keys on all child columns in output order.
+ withSQLConf(confKey -> "false") {
+ assert(
+ resolveKeys(Nil, allColumnsAsKeys = true, viaSparkClassic = false) ===
rel.output)
+ }
+ }
+
+ test("SPARK-57489: allColumnsAsKeys - Classic legacy fallback uses Set order
over all columns") {
+ withSQLConf(confKey -> "false") {
+ // Legacy Classic `dropDuplicates()` resolved `columns.toSet.toSeq`:
every column is present
+ // (order is Set-defined and intentionally not asserted here).
+ val keys = resolveKeys(Nil, allColumnsAsKeys = true, viaSparkClassic =
true)
+ assert(keys.map(_.exprId).toSet === rel.output.map(_.exprId).toSet)
+ }
+ }
+
+ test("SPARK-57489: withinWatermark resolves to DeduplicateWithinWatermark") {
+ val resolved = ResolveDeduplicate(
+ UnresolvedDeduplicate(DeduplicateKeyColumns(Seq("a")), withinWatermark =
true,
+ viaSparkClassic = true, rel))
+ assert(resolved.isInstanceOf[DeduplicateWithinWatermark])
+ assert(resolved.asInstanceOf[DeduplicateWithinWatermark].keys.map(_.name)
=== Seq("a"))
+ }
+
+ test("SPARK-57489: duplicate-named columns produce multiple keys (filter,
not find)") {
+ val a2 = $"a".int
+ val dupRel = LocalRelation(a, a2)
+ // A single requested name "a" must resolve to BOTH same-named columns
(filter, not find) in
+ // every mode. Compare by exprId so the test fails if we returned the same
attribute twice
+ // instead of the two distinct same-named columns.
+ Seq(
+ ("true", true), // deterministic (engine flag ignored)
+ ("true", false),
+ ("false", true), // legacy Spark Classic
+ ("false", false) // legacy Spark Connect
+ ).foreach { case (deterministic, legacy) =>
+ withSQLConf(confKey -> deterministic) {
+ assert(
+ resolveKeys(Seq("a"), viaSparkClassic = legacy, child =
dupRel).map(_.exprId)
+ === Seq(a.exprId, a2.exprId),
+ s"deterministic=$deterministic, viaSparkClassic=$legacy")
+ }
+ }
+ }
+
+ test("SPARK-57489: unknown column name fails analysis") {
+ checkError(
+ exception = intercept[AnalysisException] {
+ ResolveDeduplicate(UnresolvedDeduplicate(
+ DeduplicateKeyColumns(Seq("does_not_exist")), withinWatermark =
false,
+ viaSparkClassic = true, rel))
+ },
+ condition = "UNRESOLVED_COLUMN_AMONG_FIELD_NAMES",
+ parameters = Map("colName" -> "does_not_exist", "fieldNames" -> "c, a,
b"))
+ }
+}
diff --git
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/InvalidInputErrors.scala
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/InvalidInputErrors.scala
index cdba6c825332..604ad3860697 100644
---
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/InvalidInputErrors.scala
+++
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/InvalidInputErrors.scala
@@ -55,11 +55,6 @@ object InvalidInputErrors {
def deduplicateRequiresColumnsOrAll(): InvalidPlanInput =
InvalidPlanInput("CONNECT_INVALID_PLAN.DEDUPLICATE_REQUIRES_COLUMNS_OR_ALL",
Map.empty)
- def invalidDeduplicateColumn(colName: String, fieldNames: String):
InvalidPlanInput =
- InvalidPlanInput(
- "CONNECT_INVALID_PLAN.UNRESOLVED_COLUMN_AMONG_FIELD_NAMES",
- Map("colName" -> colName, "fieldNames" -> fieldNames))
-
def functionEvalTypeNotSupported(evalType: Int): InvalidPlanInput =
InvalidPlanInput(
"CONNECT_INVALID_PLAN.FUNCTION_EVAL_TYPE_NOT_SUPPORTED",
diff --git
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
index b067efa0579a..94e7938b7c72 100644
---
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
+++
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
@@ -43,7 +43,7 @@ import org.apache.spark.internal.LogKeys.{DATAFRAME_ID,
SESSION_ID}
import org.apache.spark.resource.{ExecutorResourceRequest, ResourceProfile,
TaskResourceProfile, TaskResourceRequest}
import org.apache.spark.sql.{AnalysisException, Column, Encoders,
ForeachWriter, Row}
import org.apache.spark.sql.catalyst.{expressions, AliasIdentifier,
FunctionIdentifier, InternalRow, QueryPlanningTracker}
-import org.apache.spark.sql.catalyst.analysis.{ChangelogContextUtils,
FunctionRegistry, GlobalTempView, LocalTempView, MultiAlias, RelationChanges,
UnresolvedAlias, UnresolvedAttribute, UnresolvedDataFrameStar,
UnresolvedDeserializer, UnresolvedExtractValue, UnresolvedFunction,
UnresolvedOrdinal, UnresolvedPlanId, UnresolvedRegex, UnresolvedRelation,
UnresolvedStar, UnresolvedStarWithColumns, UnresolvedStarWithColumnsRenames,
UnresolvedSubqueryColumnAliases, UnresolvedTableValuedFunction [...]
+import org.apache.spark.sql.catalyst.analysis.{ChangelogContextUtils,
FunctionRegistry, GlobalTempView, LocalTempView, MultiAlias, RelationChanges,
UnresolvedAlias, UnresolvedAttribute, UnresolvedDataFrameStar,
UnresolvedDeduplicate, UnresolvedDeserializer, UnresolvedExtractValue,
UnresolvedFunction, UnresolvedOrdinal, UnresolvedPlanId, UnresolvedRegex,
UnresolvedRelation, UnresolvedStar, UnresolvedStarWithColumns,
UnresolvedStarWithColumnsRenames, UnresolvedSubqueryColumnAliases, Unreso [...]
import org.apache.spark.sql.catalyst.encoders.{encoderFor, AgnosticEncoder,
ExpressionEncoder, RowEncoder}
import
org.apache.spark.sql.catalyst.encoders.AgnosticEncoders.{ProductEncoder,
RowEncoder => AgnosticRowEncoder, StringEncoder, UnboundRowEncoder}
import org.apache.spark.sql.catalyst.expressions._
@@ -51,7 +51,7 @@ import
org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
import org.apache.spark.sql.catalyst.parser.{NamedParameterContext,
ParameterContext, ParseException, ParserUtils, PositionalParameterContext}
import org.apache.spark.sql.catalyst.plans.{Cross, FullOuter, Inner, JoinType,
LeftAnti, LeftOuter, LeftSemi, RightOuter, UsingJoin}
import org.apache.spark.sql.catalyst.plans.logical
-import org.apache.spark.sql.catalyst.plans.logical.{AppendColumns, Assignment,
CoGroup, CollectMetrics, CommandResult, Deduplicate,
DeduplicateWithinWatermark, DeleteAction, DeserializeToObject, Except,
FlatMapGroupsWithState, InsertAction, InsertStarAction, Intersect, JoinWith,
LocalRelation, LogicalGroupState, LogicalPlan, MapGroups, MapPartitions,
MergeAction, Project, Sample, SerializeFromObject, Sort, SubqueryAlias,
TimeModes, TransformWithState, TypedFilter, Union, Unpivot, Unresol [...]
+import org.apache.spark.sql.catalyst.plans.logical.{AppendColumns, Assignment,
CoGroup, CollectMetrics, CommandResult, DeduplicateAllColumnsAsKey,
DeduplicateKeyColumns, DeleteAction, DeserializeToObject, Except,
FlatMapGroupsWithState, InsertAction, InsertStarAction, Intersect, JoinWith,
LocalRelation, LogicalGroupState, LogicalPlan, MapGroups, MapPartitions,
MergeAction, Project, Sample, SerializeFromObject, Sort, SubqueryAlias,
TimeModes, TransformWithState, TypedFilter, Union, Unpivo [...]
import org.apache.spark.sql.catalyst.streaming.InternalOutputModes
import org.apache.spark.sql.catalyst.trees.{CurrentOrigin, Origin, TreePattern}
import org.apache.spark.sql.catalyst.types.DataTypeUtils
@@ -1455,27 +1455,16 @@ class SparkConnectPlanner(
if (!rel.getAllColumnsAsKeys && rel.getColumnNamesCount == 0) {
throw InvalidInputErrors.deduplicateRequiresColumnsOrAll()
}
- val queryExecution = new QueryExecution(session,
transformRelation(rel.getInput))
- val resolver = session.sessionState.analyzer.resolver
- val allColumns = queryExecution.analyzed.output
- if (rel.getAllColumnsAsKeys) {
- if (rel.getWithinWatermark) DeduplicateWithinWatermark(allColumns,
queryExecution.analyzed)
- else Deduplicate(allColumns, queryExecution.analyzed)
+ val keySpec = if (rel.getAllColumnsAsKeys) {
+ DeduplicateAllColumnsAsKey
} else {
- val toGroupColumnNames = rel.getColumnNamesList.asScala.toSeq
- val groupCols = toGroupColumnNames.flatMap { (colName: String) =>
- // It is possibly there are more than one columns with the same name,
- // so we call filter instead of find.
- val cols = allColumns.filter(col => resolver(col.name, colName))
- if (cols.isEmpty) {
- val fieldNames = allColumns.map(_.name).mkString(", ")
- throw InvalidInputErrors.invalidDeduplicateColumn(colName,
fieldNames)
- }
- cols
- }
- if (rel.getWithinWatermark) DeduplicateWithinWatermark(groupCols,
queryExecution.analyzed)
- else Deduplicate(groupCols, queryExecution.analyzed)
+ DeduplicateKeyColumns(rel.getColumnNamesList.asScala.toSeq)
}
+ UnresolvedDeduplicate(
+ keySpec = keySpec,
+ withinWatermark = rel.getWithinWatermark,
+ viaSparkClassic = false,
+ child = transformRelation(rel.getInput))
}
private def transformDataType(t: proto.DataType): DataType = {
diff --git
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/StreamingDeduplicationConnectInteropSuite.scala
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/StreamingDeduplicationConnectInteropSuite.scala
new file mode 100644
index 000000000000..fba8e3dadbc1
--- /dev/null
+++
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/StreamingDeduplicationConnectInteropSuite.scala
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.connect
+
+import java.io.File
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.util.UUID
+
+import org.apache.spark.sql.streaming.Trigger
+import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
+
+/**
+ * Interop tests for dropDuplicates key resolution across Spark Classic and
Spark Connect. Both
+ * engines now resolve the dedup keys through the same analyzer rule with a
deterministic
+ * (positional) order, so a checkpoint written by one engine must be
restartable under the other:
+ * the state-store keys (bound by position) line up and previously-seen rows
are still recognized
+ * as duplicates.
+ *
+ * The in-process [[SparkConnectServerTest]] harness exposes a Classic session
(`spark`) and a
+ * Connect session (`withSession`) sharing one SparkContext / filesystem, so
both can drive the
+ * same checkpoint directory. Input is plain CSV files (no commit markers)
read by a file source
+ * with Trigger.AvailableNow for deterministic batching; the sink is a
recoverable parquet file
+ * sink. See SPARK-57489.
+ */
+class StreamingDeduplicationConnectInteropSuite extends SparkConnectServerTest
{
+
+ private val rowSchema = StructType(
+ Seq(
+ StructField("a", IntegerType),
+ StructField("b", IntegerType),
+ StructField("c", IntegerType),
+ StructField("d", IntegerType),
+ StructField("e", IntegerType)))
+
+ /**
+ * Writes one CSV file (one batch worth of input) into `inputDir` without
any commit markers.
+ */
+ private def writeInputCsv(inputDir: File, rows: Seq[(Int, Int, Int, Int,
Int)]): Unit = {
+ inputDir.mkdirs()
+ val content =
+ rows.map { case (a, b, c, d, e) => s"$a,$b,$c,$d,$e" }.mkString("",
"\n", "\n")
+ Files.write(
+ new File(inputDir, s"${UUID.randomUUID()}.csv").toPath,
+ content.getBytes(StandardCharsets.UTF_8))
+ }
+
+ /**
+ * Starts a dropDuplicates streaming query on `session` reading `inputDir`,
processes all
+ * currently-available files against `checkpoint`, appending deduplicated
rows to the parquet
+ * sink at `outputDir`.
+ */
+ // Fully qualified on purpose: this file lives in package
org.apache.spark.sql.connect, which also
+ // defines a `SparkSession`; an import of the common type would be shadowed
by it. The
+ // common base accepts both the Classic `spark` and the Connect
`withSession` session.
+ private def runDedup(
+ session: org.apache.spark.sql.SparkSession,
+ inputDir: File,
+ checkpoint: File,
+ outputDir: File): Unit = {
+ val query = session.readStream
+ .schema(rowSchema)
+ .csv(inputDir.getCanonicalPath)
+ .dropDuplicates()
+ .writeStream
+ .format("parquet")
+ .option("path", outputDir.getCanonicalPath)
+ .option("checkpointLocation", checkpoint.getCanonicalPath)
+ .trigger(Trigger.AvailableNow())
+ .start()
+ try {
+ query.processAllAvailable()
+ } finally {
+ query.stop()
+ }
+ }
+
+ /** Reads every row the sink has emitted so far (across runs), sorted for
stable comparison. */
+ private def readOutput(outputDir: File): Seq[Seq[Int]] =
+ spark.read
+ .schema(rowSchema)
+ .parquet(outputDir.getCanonicalPath)
+ .collect()
+ .map(r => (0 until 5).map(r.getInt).toList)
+ .toSeq
+ .sortBy(_.mkString(","))
+
+ test("SPARK-57489: dropDuplicates checkpoint written by Classic restarts
under Connect") {
+ withTempDir { dir =>
+ val inputDir = new File(dir, "input")
+ val outputDir = new File(dir, "output")
+ val checkpoint = new File(dir, "checkpoint")
+
+ // Run 1 on Classic seeds the dedup state with one row.
+ writeInputCsv(inputDir, Seq((1, 2, 3, 4, 5)))
+ runDedup(spark, inputDir, checkpoint, outputDir)
+
+ // Run 2 on Connect restarts the same checkpoint with a duplicate + a
new row. The duplicate
+ // must be recognized against the Classic-created state and dropped;
only the new row is
+ // emitted. A cross-engine key-order mismatch would re-emit the
duplicate.
+ writeInputCsv(inputDir, Seq((1, 2, 3, 4, 5), (6, 7, 8, 9, 10)))
+ withSession { session =>
+ runDedup(session, inputDir, checkpoint, outputDir)
+ }
+
+ assert(readOutput(outputDir) === Seq(Seq(1, 2, 3, 4, 5), Seq(6, 7, 8, 9,
10)))
+ }
+ }
+
+ test("SPARK-57489: dropDuplicates checkpoint written by Connect restarts
under Classic") {
+ withTempDir { dir =>
+ val inputDir = new File(dir, "input")
+ val outputDir = new File(dir, "output")
+ val checkpoint = new File(dir, "checkpoint")
+
+ writeInputCsv(inputDir, Seq((1, 2, 3, 4, 5)))
+ withSession { session =>
+ runDedup(session, inputDir, checkpoint, outputDir)
+ }
+
+ writeInputCsv(inputDir, Seq((1, 2, 3, 4, 5), (6, 7, 8, 9, 10)))
+ runDedup(spark, inputDir, checkpoint, outputDir)
+
+ assert(readOutput(outputDir) === Seq(Seq(1, 2, 3, 4, 5), Seq(6, 7, 8, 9,
10)))
+ }
+ }
+}
diff --git
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/planner/SparkConnectProtoSuite.scala
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/planner/SparkConnectProtoSuite.scala
index 7b734f93e595..aa0345f089fc 100644
---
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/planner/SparkConnectProtoSuite.scala
+++
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/planner/SparkConnectProtoSuite.scala
@@ -30,7 +30,7 @@ import org.apache.spark.sql.{AnalysisException, Column,
Observation, Row, SaveMo
import org.apache.spark.sql.catalyst.analysis
import org.apache.spark.sql.catalyst.expressions.{AttributeReference,
GenericInternalRow, UnsafeProjection}
import org.apache.spark.sql.catalyst.plans.{FullOuter, Inner, LeftAnti,
LeftOuter, LeftSemi, PlanTest, RightOuter}
-import org.apache.spark.sql.catalyst.plans.logical.{CollectMetrics, Distinct,
LocalRelation, LogicalPlan}
+import org.apache.spark.sql.catalyst.plans.logical.{CollectMetrics,
Deduplicate, DeduplicateWithinWatermark, Distinct, LocalRelation, LogicalPlan}
import org.apache.spark.sql.catalyst.types.DataTypeUtils
import org.apache.spark.sql.classic.ClassicConversions._
import org.apache.spark.sql.classic.DataFrame
@@ -1149,10 +1149,18 @@ class SparkConnectProtoSuite extends PlanTest with
SparkConnectPlanTest {
// Compares proto plan with LogicalPlan.
private def comparePlans(connectPlan: proto.Relation, sparkPlan:
LogicalPlan): Unit = {
- def normalizeDataframeId(plan: LogicalPlan): LogicalPlan = plan transform {
+ def normalizeForComparison(plan: LogicalPlan): LogicalPlan = plan
transform {
case cm: CollectMetrics => cm.copy(dataframeId = 0)
+ // Spark Connect and Spark Classic record different
DeduplicateSpec.viaSparkClassic; it only
+ // drives the legacy key-order fallback for streaming restarts, not the
resolved plan, so
+ // ignore it when comparing the two engines.
+ case d: Deduplicate => d.copy(dedupSpec = None)
+ case d: DeduplicateWithinWatermark => d.copy(dedupSpec = None)
}
val connectAnalyzed = analyzePlan(transform(connectPlan))
- comparePlans(normalizeDataframeId(connectAnalyzed),
normalizeDataframeId(sparkPlan), false)
+ comparePlans(
+ normalizeForComparison(connectAnalyzed),
+ normalizeForComparison(sparkPlan),
+ false)
}
}
diff --git a/sql/core/src/main/scala/org/apache/spark/sql/classic/Dataset.scala
b/sql/core/src/main/scala/org/apache/spark/sql/classic/Dataset.scala
index 3b8712fe0f31..93472d8304b4 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/classic/Dataset.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/classic/Dataset.scala
@@ -1192,8 +1192,9 @@ class Dataset[T] private[sql](
case Distinct(u: Union) =>
Distinct(flattenUnion(u, isUnionDistinct = true))
// Only handle distinct-like 'Deduplicate', where the keys == output
- case Deduplicate(keys: Seq[Attribute], u: Union) if AttributeSet(keys)
== u.outputSet =>
- Deduplicate(keys, flattenUnion(u, isUnionDistinct = true))
+ case d @ Deduplicate(keys: Seq[Attribute], u: Union, _)
+ if AttributeSet(keys) == u.outputSet =>
+ d.copy(child = flattenUnion(u, isUnionDistinct = true))
case u: Union =>
flattenUnion(u, isUnionDistinct = false)
}
@@ -1209,7 +1210,7 @@ class Dataset[T] private[sql](
changed = true
children
// Only handle distinct-like 'Deduplicate', where the keys == output
- case Deduplicate(keys: Seq[Attribute], child @ Union(children, byName,
allowMissingCol))
+ case Deduplicate(keys: Seq[Attribute], child @ Union(children, byName,
allowMissingCol), _)
if AttributeSet(keys) == child.outputSet && isUnionDistinct &&
byName == u.byName &&
allowMissingCol == u.allowMissingCol =>
changed = true
@@ -1417,41 +1418,41 @@ class Dataset[T] private[sql](
}
/** @inheritdoc */
- def dropDuplicates(): Dataset[T] = dropDuplicates(this.columns)
+ def dropDuplicates(): Dataset[T] = withSameTypedPlan {
+ UnresolvedDeduplicate(
+ keySpec = DeduplicateAllColumnsAsKey,
+ withinWatermark = false,
+ viaSparkClassic = true,
+ child = logicalPlan)
+ }
/** @inheritdoc */
def dropDuplicates(colNames: Seq[String]): Dataset[T] = withSameTypedPlan {
- val groupCols = groupColsFromDropDuplicates(colNames)
- Deduplicate(groupCols, logicalPlan)
+ UnresolvedDeduplicate(
+ keySpec = DeduplicateKeyColumns(colNames),
+ withinWatermark = false,
+ viaSparkClassic = true,
+ child = logicalPlan)
}
/** @inheritdoc */
- def dropDuplicatesWithinWatermark(): Dataset[T] = {
- dropDuplicatesWithinWatermark(this.columns)
+ def dropDuplicatesWithinWatermark(): Dataset[T] = withSameTypedPlan {
+ // UnsupportedOperationChecker will fail the query if this is called with
batch Dataset.
+ UnresolvedDeduplicate(
+ keySpec = DeduplicateAllColumnsAsKey,
+ withinWatermark = true,
+ viaSparkClassic = true,
+ child = logicalPlan)
}
/** @inheritdoc */
def dropDuplicatesWithinWatermark(colNames: Seq[String]): Dataset[T] =
withSameTypedPlan {
- val groupCols = groupColsFromDropDuplicates(colNames)
// UnsupportedOperationChecker will fail the query if this is called with
batch Dataset.
- DeduplicateWithinWatermark(groupCols, logicalPlan)
- }
-
- private def groupColsFromDropDuplicates(colNames: Seq[String]):
Seq[Attribute] = {
- val resolver = sparkSession.sessionState.analyzer.resolver
- val allColumns = queryExecution.analyzed.output
- // SPARK-31990: We must keep `toSet.toSeq` here because of the backward
compatibility issue
- // (the Streaming's state store depends on the `groupCols` order).
- colNames.toSet.toSeq.flatMap { (colName: String) =>
- // It is possibly there are more than one columns with the same name,
- // so we call filter instead of find.
- val cols = allColumns.filter(col => resolver(col.name, colName))
- if (cols.isEmpty) {
- throw
QueryCompilationErrors.cannotResolveColumnNameAmongAttributesError(
- colName, schema.fieldNames.mkString(", "))
- }
- cols
- }
+ UnresolvedDeduplicate(
+ keySpec = DeduplicateKeyColumns(colNames),
+ withinWatermark = true,
+ viaSparkClassic = true,
+ child = logicalPlan)
}
/** @inheritdoc */
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
index 6444b1ddbdc9..6e761fbe07b2 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
@@ -616,10 +616,10 @@ abstract class SparkStrategies extends
QueryPlanner[SparkPlan] {
*/
object StreamingDeduplicationStrategy extends Strategy {
override def apply(plan: LogicalPlan): Seq[SparkPlan] = plan match {
- case Deduplicate(keys, child) if child.isStreaming =>
+ case Deduplicate(keys, child, _) if child.isStreaming =>
StreamingDeduplicateExec(keys, planLater(child)) :: Nil
- case DeduplicateWithinWatermark(keys, child) if child.isStreaming =>
+ case DeduplicateWithinWatermark(keys, child, _) if child.isStreaming =>
StreamingDeduplicateWithinWatermarkExec(keys, planLater(child)) :: Nil
case _ => Nil
@@ -1038,7 +1038,7 @@ abstract class SparkStrategies extends
QueryPlanner[SparkPlan] {
case logical.ResolvedHint(child, hints) =>
throw SparkException.internalError(
"ResolvedHint operator should have been replaced by join hint in the
optimizer")
- case Deduplicate(_, child) if !child.isStreaming =>
+ case Deduplicate(_, child, _) if !child.isStreaming =>
throw SparkException.internalError(
"Deduplicate operator for non streaming data source should have been
replaced " +
"by aggregate in the optimizer")
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/checkpointing/OffsetSeq.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/checkpointing/OffsetSeq.scala
index 9ba99ac2c036..c55fda596ff9 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/checkpointing/OffsetSeq.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/checkpointing/OffsetSeq.scala
@@ -205,7 +205,8 @@ object OffsetSeqMetadata extends Logging {
PRUNE_FILTERS_CAN_PRUNE_STREAMING_SUBPLAN,
STREAMING_STATE_STORE_ENCODING_FORMAT,
STATE_STORE_ROW_CHECKSUM_ENABLED, PROTOBUF_EXTENSIONS_SUPPORT_ENABLED,
ENABLE_STREAMING_SOURCE_EVOLUTION,
- STATEFUL_OPERATOR_ALWAYS_NULLABLE_OUTPUT
+ STATEFUL_OPERATOR_ALWAYS_NULLABLE_OUTPUT,
+ DROP_DUPLICATES_DETERMINISTIC_KEY_ORDER
)
/**
@@ -256,7 +257,8 @@ object OffsetSeqMetadata extends Logging {
STATE_STORE_ROCKSDB_MERGE_OPERATOR_VERSION.key -> "1",
PROTOBUF_EXTENSIONS_SUPPORT_ENABLED.key -> "false",
ENABLE_STREAMING_SOURCE_EVOLUTION.key -> "false",
- STATEFUL_OPERATOR_ALWAYS_NULLABLE_OUTPUT.key -> "false"
+ STATEFUL_OPERATOR_ALWAYS_NULLABLE_OUTPUT.key -> "false",
+ DROP_DUPLICATES_DETERMINISTIC_KEY_ORDER.key -> "false"
)
def readValue[T](metadataLog: OffsetSeqMetadataBase, confKey:
ConfigEntry[T]): String = {
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala
index 94143799a8c4..27c4967988ec 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala
@@ -29,7 +29,7 @@ import org.apache.hadoop.fs.Path
import org.apache.spark.{SparkException, SparkIllegalArgumentException,
SparkIllegalStateException}
import org.apache.spark.internal.LogKeys
import org.apache.spark.internal.LogKeys._
-import org.apache.spark.sql.catalyst.analysis.V2TableReference
+import org.apache.spark.sql.catalyst.analysis.{ResolveDeduplicate,
V2TableReference}
import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder
import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute,
CurrentBatchTimestamp, CurrentDate, CurrentTimestamp,
FileSourceMetadataAttribute, LocalTimestamp}
import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Deduplicate,
DeduplicateWithinWatermark, Distinct, FlatMapGroupsInPandasWithState,
FlatMapGroupsWithState, GlobalLimit, Join, LeafNode, LocalRelation,
LogicalPlan, Project, StreamSourceAwareLogicalPlan, TransformWithState,
TransformWithStateInPySpark}
@@ -222,8 +222,31 @@ class MicroBatchExecution(
}
}.getOrElse(sparkSessionForStream.sessionState.conf.enableStreamingSourceEvolution)
+ // SPARK-57489: dropDuplicates / dropDuplicatesWithinWatermark resolve
their keys with the
+ // deterministic order by default, but streaming deduplication binds
state-store keys by
+ // position. A query restored from a checkpoint that predates this change
must keep its original
+ // (legacy) key order, so recompute the keys from the recipe carried on
the resolved node using
+ // the order pinned in the offset log (mirroring how `enforceNamed` is
read above). For a new
+ // query the offset log has no entry yet, so the session value is used and
pinned at batch 0.
+ val streamConf = sparkSessionForStream.sessionState.conf
+ val orderDeterministically = initialLatestOffsetSeq.flatMap { case (_,
offsetSeq) =>
+ offsetSeq.metadataOpt.flatMap { metadata =>
+ OffsetSeqMetadata.readValueOpt(metadata,
SQLConf.DROP_DUPLICATES_DETERMINISTIC_KEY_ORDER)
+ .map(_.toBoolean)
+ }
+
}.getOrElse(streamConf.getConf(SQLConf.DROP_DUPLICATES_DETERMINISTIC_KEY_ORDER))
+ val dedupResolver = sparkSessionForStream.sessionState.analyzer.resolver
+ val planWithDedupKeys = analyzedPlan.transformUp {
+ case d @ Deduplicate(_, child, Some(spec)) =>
+ d.copy(keys =
+ ResolveDeduplicate.computeKeys(child, spec, orderDeterministically,
dedupResolver))
+ case d @ DeduplicateWithinWatermark(_, child, Some(spec)) =>
+ d.copy(keys =
+ ResolveDeduplicate.computeKeys(child, spec, orderDeterministically,
dedupResolver))
+ }
+
import
org.apache.spark.sql.execution.datasources.v2.DataSourceV2Implicits._
- val _logicalPlan = analyzedPlan.transform {
+ val _logicalPlan = planWithDedupKeys.transform {
case streamingRelation @ StreamingRelation(
dataSourceV1, sourceName, output, sourceIdentifyingName) =>
toExecutionRelationMap.getOrElseUpdate(streamingRelation, {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingDeduplicationFallbackSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingDeduplicationFallbackSuite.scala
new file mode 100644
index 000000000000..f44b8c5787b1
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingDeduplicationFallbackSuite.scala
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.streaming
+
+import java.io.File
+
+import org.apache.hadoop.fs.Path
+
+import org.apache.spark.sql.catalyst.streaming.InternalOutputModes._
+import org.apache.spark.sql.execution.streaming.checkpointing.{OffsetMap,
OffsetSeq, OffsetSeqLog, OffsetSeqMetadata, OffsetSeqMetadataV2}
+import org.apache.spark.sql.execution.streaming.runtime.MemoryStream
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * End-to-end tests for the dropDuplicates key-order fallback. A streaming
query started before
+ * [[SQLConf.DROP_DUPLICATES_DETERMINISTIC_KEY_ORDER]] existed has no such
entry in its offset log;
+ * on restart it must fall back to the legacy key order so the persisted
state-store keys (bound by
+ * position) still align. The session default stays true (deterministic), so
these tests prove the
+ * fallback is driven by the offset log, not the session. See SPARK-57489.
+ */
+class StreamingDeduplicationFallbackSuite extends StateStoreMetricsTest {
+ import testImplicits._
+
+ private val confKey = SQLConf.DROP_DUPLICATES_DETERMINISTIC_KEY_ORDER.key
+
+ // A projection AFTER dropDuplicates is the case that matters: by the time
the streaming query is
+ // started the dedup keys are already resolved (with the recipe attached to
the Deduplicate node),
+ // so the offset-log fallback must recompute them from the recipe rather
than rely on an
+ // unresolved node surviving until query start.
+ private def dedupAllColumns(input: MemoryStream[(Int, Int, Int, Int, Int)]) =
+ input.toDS().toDF("a", "b", "c", "d", "e").dropDuplicates().select("a",
"b", "c", "d", "e")
+
+ /** Rewrites batch `batchId`'s offset-log metadata, applying `f` to its
persisted conf map. */
+ private def rewriteOffsetLogConf(
+ checkpoint: File,
+ batchId: Long)(f: Map[String, String] => Map[String, String]): Unit = {
+ val offsetsPath = checkpoint.getCanonicalPath + "/offsets"
+ val offsetLog = new OffsetSeqLog(spark, offsetsPath)
+ val offsetSeq = offsetLog.get(batchId).getOrElse {
+ fail(s"Missing offset log entry for batch $batchId")
+ }
+ val updatedMetadata = offsetSeq.metadataOpt.map {
+ case m: OffsetSeqMetadata => m.copy(conf = f(m.conf))
+ case m: OffsetSeqMetadataV2 => m.copy(conf = f(m.conf))
+ }
+ val updatedOffsetSeq = offsetSeq match {
+ case o: OffsetSeq =>
+ o.copy(metadataOpt =
updatedMetadata.map(_.asInstanceOf[OffsetSeqMetadata]))
+ case o: OffsetMap =>
+ // OffsetMap's metadata is required (not optional), so fall back to
the original when the
+ // rewrite produced none.
+ o.copy(metadata =
+
updatedMetadata.map(_.asInstanceOf[OffsetSeqMetadataV2]).getOrElse(o.metadata))
+ }
+ // Offset log entries are immutable once written; delete before re-adding.
+ val hadoopConf = spark.sessionState.newHadoopConf()
+ val batchFile = new Path(offsetsPath, batchId.toString)
+ batchFile.getFileSystem(hadoopConf).delete(batchFile, false)
+ assert(
+ offsetLog.add(batchId, updatedOffsetSeq),
+ s"Failed to rewrite offset log entry for batch $batchId")
+ }
+
+ private def readPinnedConf(checkpoint: File, batchId: Long): Option[String]
= {
+ val offsetLog = new OffsetSeqLog(spark, checkpoint.getCanonicalPath +
"/offsets")
+
offsetLog.get(batchId).flatMap(_.metadataOpt.map(_.conf)).flatMap(_.get(confKey))
+ }
+
+ test("SPARK-57489: existing checkpoint without the conf falls back to the
legacy key order") {
+ withTempDir { checkpoint =>
+ // The same MemoryStream is reused across both runs so source offsets
advance across the
+ // restart (a fresh stream would reset to an already-committed offset
and process nothing).
+ val input = MemoryStream[(Int, Int, Int, Int, Int)]
+
+ // Run 1 with the deterministic order disabled writes the state-store
keys in the legacy
+ // (origin-engine) order; confirm the offset log captured the legacy
value.
+ withSQLConf(confKey -> "false") {
+ testStream(dedupAllColumns(input), Append)(
+ StartStream(checkpointLocation = checkpoint.getCanonicalPath),
+ AddData(input, (1, 2, 3, 4, 5)),
+ CheckLastBatch((1, 2, 3, 4, 5)),
+ StopStream)
+ }
+ assert(readPinnedConf(checkpoint, batchId = 0).contains("false"))
+
+ // Simulate a checkpoint created before this change: drop the conf from
the offset log.
+ rewriteOffsetLogConf(checkpoint, batchId = 0)(_ - confKey)
+
+ // Restart with the deterministic session default (true). If the absent
offset-log entry were
+ // NOT pinned to the legacy order, the new run would encode keys in
schema order while the
+ // restored state holds them in Set order; the previously-seen row would
no longer be
+ // recognized as a duplicate and would be (incorrectly) emitted, failing
CheckLastBatch.
+ testStream(dedupAllColumns(input), Append)(
+ StartStream(checkpointLocation = checkpoint.getCanonicalPath),
+ AddData(input, (1, 2, 3, 4, 5), (6, 7, 8, 9, 10)),
+ CheckLastBatch((6, 7, 8, 9, 10)),
+ StopStream)
+ }
+ }
+
+ test("SPARK-57489: newly started query pins the deterministic order and
keeps it on restart") {
+ withTempDir { checkpoint =>
+ val input = MemoryStream[(Int, Int, Int, Int, Int)]
+
+ // Session default is deterministic (true); a newly started query must
pin "true" at batch 0.
+ testStream(dedupAllColumns(input), Append)(
+ StartStream(checkpointLocation = checkpoint.getCanonicalPath),
+ AddData(input, (1, 2, 3, 4, 5)),
+ CheckLastBatch((1, 2, 3, 4, 5)),
+ StopStream)
+ assert(readPinnedConf(checkpoint, batchId = 0).contains("true"))
+
+ // Restart with the session default flipped to the legacy order. The
"true" pinned in the
+ // offset log must override the session value, so the dedup state is
still honored under the
+ // deterministic order. (If the session value were used instead, the
keys would be encoded in
+ // the legacy order while the restored state holds them in the
deterministic order, so the
+ // previously-seen row would no longer be recognized as a duplicate and
would be
+ // (incorrectly) emitted, failing CheckLastBatch.)
+ withSQLConf(confKey -> "false") {
+ testStream(dedupAllColumns(input), Append)(
+ StartStream(checkpointLocation = checkpoint.getCanonicalPath),
+ AddData(input, (1, 2, 3, 4, 5), (6, 7, 8, 9, 10)),
+ CheckLastBatch((6, 7, 8, 9, 10)),
+ StopStream)
+ }
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]