peter-toth commented on code in PR #57815:
URL: https://github.com/apache/spark/pull/57815#discussion_r3755095094
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/WindowResolution.scala:
##########
@@ -163,6 +163,26 @@ object WindowResolution {
errorClass = "INVALID_WINDOW_SPEC_FOR_AGGREGATION_FUNC",
messageParameters = Map("aggFunc" ->
toSQLExpr(agg.aggregateFunction))
)
+ case AggregateExpression(_: PythonUDAF, _, true, _, _) =>
+ windowExpression.failAnalysis(
+ errorClass = "DISTINCT_WINDOW_FUNCTION_UNSUPPORTED",
+ messageParameters = Map("windowExpr" -> toSQLExpr(windowExpression))
+ )
+ case AggregateExpression(function, _, true, _, _)
+ if (windowExpression.windowSpec.frameSpecification match {
+ case SpecifiedWindowFrame(_, UnboundedPreceding, _) =>
+ val distinctExpressions =
function.children.filterNot(_.foldable).map {
+ case sortOrder: SortOrder => sortOrder.child
+ case expression => expression
+ }
+ RowOrdering.isOrderable(distinctExpressions)
+ case _ => false
+ }) => ()
+ case AggregateExpression(_, _, true, _, _) =>
+ windowExpression.failAnalysis(
+ errorClass = "DISTINCT_WINDOW_FUNCTION_UNSUPPORTED",
Review Comment:
**Finding 2.** The message behind this condition is now false.
`common/utils/src/main/resources/error/error-conditions.json:2309` still reads
"Distinct window functions are not supported: `<windowExpr>`.", but after this
PR they are supported whenever the frame's lower bound is `UNBOUNDED PRECEDING`
and the inputs are orderable. Someone who writes `count(DISTINCT x) OVER (ORDER
BY id ROWS BETWEEN 1 PRECEDING AND CURRENT ROW)` is told the feature doesn't
exist, with no hint that widening the frame makes it work.
Three different reasons also land on this one message and can't be told
apart: unsupported frame (this branch), unorderable DISTINCT input (the
`RowOrdering.isOrderable` guard at `:178`), and Python UDAF (`:166`).
Minimum ask: reword the message to name the supported shape, e.g. "DISTINCT
is not supported for this window function: `<windowExpr>`. DISTINCT aggregates
are supported only when the window frame's lower bound is UNBOUNDED PRECEDING
and the DISTINCT inputs are orderable."
Better, and the idiomatic option here, is sub-conditions so each rejection
says which rule it hit:
```json
"DISTINCT_WINDOW_FUNCTION_UNSUPPORTED" : {
"message" : [ "Distinct window functions are not supported: <windowExpr>."
],
"subClass" : {
"FRAME_LOWER_BOUND_NOT_UNBOUNDED_PRECEDING" : {
"message" : [ "DISTINCT aggregates are supported only when the window
frame's lower bound is UNBOUNDED PRECEDING." ]
},
"INPUT_NOT_ORDERABLE" : {
"message" : [ "The DISTINCT input <dataType> is not orderable." ]
},
"PYTHON_UDAF" : {
"message" : [ "DISTINCT is not supported for Python user-defined
aggregate functions." ]
}
},
"sqlState" : "0A000"
}
```
That does mean updating the callers that assert on the bare condition name
(`AnalysisErrorSuite`, `SegmentTreeWindowFunctionSuite:415,419`,
`UnboundedFollowingSegmentTreeSuite:384,388`, and the `listagg.sql` golden
files), so if you'd rather keep the change small the reworded single message is
fine by me.
##########
core/src/main/java/org/apache/spark/util/collection/unsafe/sort/UnsafeExternalSorter.java:
##########
@@ -547,6 +577,8 @@ public void insertKVRecord(Object keyBase, long keyOffset,
int keyLen,
Object valueBase, long valueOffset, int valueLen, long prefix, boolean
prefixIsNull)
throws IOException {
+ spillIfThresholdReached();
Review Comment:
**Finding 3.** `insertKVRecord` did not consult the force-spill thresholds
before this PR; now it does. The new frame needs that (it relies on the sorter
honoring `spark.sql.windowExec.buffer.spill.threshold`), but `insertKVRecord`
is shared, and two aggregation paths reach it through
`UnsafeKVExternalSorter.insertKV`:
- `ObjectAggregationIterator.createExternalSorterForInput`
(`sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/ObjectAggregationIterator.scala:328`)
- `ObjectAggregationMap.dumpToExternalSorter`
(`sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/ObjectAggregationMap.scala:75`)
Both construct the sorter with
`spark.shuffle.spill.numElementsForceSpillThreshold` /
`spark.shuffle.spill.maxSizeInBytesForSpillThreshold` and then insert row by
row, so `ObjectHashAggregateExec` now force-spills where it previously didn't.
Both configs default to "never" (`Integer.MAX_VALUE` / `Long.MAX_VALUE`), so
nothing changes out of the box — but anyone who lowered either to fight OOM
gets a different number of spill files.
I'm not asking you to revert it; the thresholds arguably always meant to
apply here, and the two new `UnsafeKVExternalSorterSuite` cases cover the
mechanism. The ask is that it stops being invisible: mention it under "Does
this PR introduce _any_ user-facing change?", or split it into its own JIRA so
it can be reasoned about (and reverted or backported) independently of the
window feature.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/AnalysisErrorSuite.scala:
##########
@@ -200,12 +200,12 @@ class AnalysisErrorSuite extends AnalysisTest with
DataTypeErrorsBase {
WindowSpecDefinition(
UnresolvedAttribute("a") :: Nil,
SortOrder(UnresolvedAttribute("b"), Ascending) :: Nil,
- UnspecifiedFrame)).as("window")),
+ SpecifiedWindowFrame(RowFrame, CurrentRow,
CurrentRow))).as("window")),
Review Comment:
**Finding 6.** Moving this case to `ROWS BETWEEN CURRENT ROW AND CURRENT
ROW` is the right call — with the default frame it would now pass analysis. But
it leaves this suite covering only one of the three rejection paths
(`WindowResolution.scala:181`, bounded lower bound). Neither branch the PR
*adds* has a test:
- the `RowOrdering.isOrderable` guard at `WindowResolution.scala:178` — e.g.
`count(DISTINCT map_col) OVER (ORDER BY id)` must still fail, and nothing pins
that. This gate is what keeps unorderable keys out of
`UnsafeKVExternalSorter`'s `GenerateOrdering`, so it's worth a regression test;
- the `PythonUDAF` branch at `WindowResolution.scala:166` — without it a
distinct pandas UDAF reaches `AggregateProcessor` and dies with
`INTERNAL_ERROR: Unsupported aggregate function` instead of a user-facing
error. That one probably belongs in a PySpark test rather than here.
Both are cheap and they're the kind of gate that silently rots when someone
widens the frame check later.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/WindowResolution.scala:
##########
@@ -109,11 +112,13 @@ object WindowResolution {
* - Disallows [[FrameLessOffsetWindowFunction]] (e.g. [[Lag]]) without
defined ordering or
* one with a frame which is defined as something other than an offset
frame (e.g.
* `ROWS BETWEEN` is logically incompatible with offset functions).
- * - Disallows distinct aggregate expressions in window functions.
+ * - Allows supported distinct aggregate expressions with orderable inputs
and an unbounded
Review Comment:
**Finding 5.** The rule is documented here in the scaladoc, but the
capability isn't documented anywhere a user will look.
`docs/sql-ref-syntax-qry-select-window.md` describes `window_function` with a
"Ranking / Analytic / Aggregate Functions" breakdown (around `:47`) and never
mentions `DISTINCT`. After this PR `COUNT(DISTINCT x) OVER (...)` works for
some frames and fails for others, and the only way to find out which is to run
the query and read the error.
Two or three lines under the "Aggregate Functions" bullet would cover it:
`DISTINCT` is supported when the frame's lower bound is `UNBOUNDED PRECEDING` —
which includes both the default frame and `OVER ()` — unsupported for bounded
and sliding lower bounds, and the DISTINCT inputs must be orderable.
I don't think a `docs/sql-migration-guide.md` entry is needed, since no
previously-working query changes behavior; shout if you disagree.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/window/WindowEvaluatorFactoryBase.scala:
##########
@@ -141,21 +142,34 @@ trait WindowEvaluatorFactoryBase {
* [[WindowExpression]]s and factory function for the
[[WindowFunctionFrame]].
*/
protected lazy val windowFrameExpressionFactoryPairs = {
- type FrameKey = (String, FrameType, Expression, Expression,
Seq[Expression])
+ type FrameKey =
+ (String, FrameType, Expression, Expression, Seq[Expression],
Option[Expression])
type ExpressionBuffer = mutable.Buffer[Expression]
val framedFunctions = mutable.Map.empty[FrameKey, (ExpressionBuffer,
ExpressionBuffer)]
+ def distinctChildren(ae: AggregateExpression): Seq[Expression] = {
+ ae.aggregateFunction.children.filterNot(_.foldable).map {
Review Comment:
**Finding 7.** `distinctChildren` doesn't dedupe by `canonicalized`, so an
aggregate that mentions the same child twice gets one key column per occurrence.
The realistic case is `listagg(DISTINCT col) WITHIN GROUP (ORDER BY col)
OVER ()`, which this PR newly allows: the `ListAgg` guard at
`WindowResolution.scala:149-153` only fires when `orderSpec.nonEmpty` or the
frame differs from `SpecifiedWindowFrame(RowFrame, UnboundedPreceding,
UnboundedFollowing)`, and `OVER ()` resolves to exactly that frame with an
empty order spec. `ListAgg.children` is `child +: delimiter +:
orderExpressions` (`collect.scala:808`) and `SortOrder` isn't foldable, so
`distinctChildren` returns `[col, col]`. Consequences:
- `distinctKeySchema` becomes `(key0: col, key1: col)`, doubling the dedup
key in the `BytesToBytesMap` and in every sorter record;
- `Utils.toMap` keeps only the last pair for a duplicate key, so
`distinctColumnAttributeLookup` maps `col -> key1` and the rewritten `ListAgg`
reads `key1` only. `key0` is projected and written everywhere and read nowhere.
Deduping by `canonicalized` before `distinctInputAttributes` is built fixes
it, and stays consistent with the frame key since that groups on the same list:
```scala
def distinctChildren(ae: AggregateExpression): Seq[Expression] = {
ae.aggregateFunction.children.filterNot(_.foldable).map {
case sortOrder: SortOrder => sortOrder.child
case expression => expression
}.distinctBy(_.canonicalized)
}
```
While you're here: that `listagg(...) WITHIN GROUP (...) OVER ()` shape is
newly supported and has no test. Worth adding, because `ListAgg`'s eval-time
sort makes it the one full-partition DISTINCT aggregate whose output order is
deterministic — a useful contrast to the `collect_list(DISTINCT ...) OVER ()`
case where it isn't.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/window/DistinctWindowFunctionFrame.scala:
##########
@@ -0,0 +1,548 @@
+/*
+ * 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.execution.window
+
+import org.apache.spark.{SparkEnv, TaskContext}
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.util.UnsafeRowUtils
+import org.apache.spark.sql.execution.{ExternalAppendOnlyUnsafeRowArray,
UnsafeKVExternalSorter}
+import org.apache.spark.sql.execution.metric.SQLMetric
+import org.apache.spark.sql.types.{DataType, IntegerType, StructField,
StructType}
+import org.apache.spark.unsafe.map.BytesToBytesMap
+
+/**
+ * Computes one or more equivalent DISTINCT aggregate expressions for a frame
whose lower bound is
+ * UNBOUNDED PRECEDING.
+ *
+ * Such a frame never removes rows: it either covers the entire partition or
only grows as output
+ * advances. For each qualifying input row this frame finds the first output
row whose upper bound
+ * contains it. A BytesToBytesMap removes duplicates while it remains below
configurable key-count
+ * and memory-size soft limits. When another new key arrives after either
limit has been reached, or
+ * an append fails, the frame permanently falls back to an external sorter for
the rest of the
+ * window partition. For a growing frame, that sorter finds the earliest event
for every key, then
+ * a second external sorter orders those events by (firstVisibleIndex,
inputIndex). The frame feeds
+ * each unique, normalized DISTINCT input to the aggregate processor when its
event becomes visible.
+ * A frame covering the entire partition consumes unique inputs directly
because their order is
+ * undefined.
+ */
+private[window] abstract class DistinctWindowFunctionFrame(
+ target: InternalRow,
+ processor: AggregateProcessor,
+ distinctExpressions: Seq[Expression],
+ filter: Option[Expression],
+ inputSchema: Seq[Attribute],
+ spillSize: SQLMetric,
+ hashFallbackThreshold: Int,
+ spillThreshold: Int,
+ spillSizeThreshold: Long)
+ extends WindowFunctionFrame with AutoCloseable {
+
+ private val distinctFields = distinctExpressions.zipWithIndex.map { case
(expression, index) =>
+ StructField(s"key$index", expression.dataType, expression.nullable)
+ }
+ private val distinctKeySchema = StructType(distinctFields)
+ private val distinctTypes = distinctKeySchema.map(_.dataType)
+ private val positionSchema = StructType(Seq(
+ StructField("firstVisibleIndex", IntegerType, nullable = false),
+ StructField("inputIndex", IntegerType, nullable = false)))
+
+ private val distinctProjection =
UnsafeProjection.create(distinctExpressions, inputSchema)
+ private val distinctOrdering =
RowOrdering.createNaturalAscendingOrdering(distinctTypes)
+ private val canUseHashDedup =
distinctTypes.forall(UnsafeRowUtils.isBinaryStable)
+ private val boundFilter = filter.map(Predicate.create(_, inputSchema))
+ private val positionInput =
+ new SpecificInternalRow(Seq(IntegerType, IntegerType))
+ private val positionProjection =
+ UnsafeProjection.create(Array[DataType](IntegerType, IntegerType))
+
+ private var eventSorter: UnsafeKVExternalSorter = _
+ private var eventIterator: UnsafeKVExternalSorter#KVSorterIterator = _
+ private var nextEventIndex = Int.MaxValue
+
+ Option(TaskContext.get()).foreach(_.addTaskCompletionListener[Unit](_ =>
close()))
+
+ override final def prepare(rows: ExternalAppendOnlyUnsafeRowArray): Unit = {
+ closeEventResources()
+ nextEventIndex = Int.MaxValue
+ processor.initialize(rows.length)
+ val partitionIndex =
Option(TaskContext.get()).map(_.partitionId()).getOrElse(0)
+ boundFilter.foreach(_.initialize(partitionIndex))
+
+ val firstVisibleRows = new FirstVisibleRowsBuilder
+ try {
+ populateFirstVisibleRows(rows, firstVisibleRows)
+ processDistinctRows(firstVisibleRows)
+ spillSize.add(firstVisibleRows.getSpillSize)
+ } finally {
+ firstVisibleRows.close()
+ }
+ prepareFrame(rows)
+ }
+
+ protected def populateFirstVisibleRows(
+ rows: ExternalAppendOnlyUnsafeRowArray,
+ firstVisibleRows: FirstVisibleRowsBuilder): Unit
+
+ protected def processDistinctRows(firstVisibleRows:
FirstVisibleRowsBuilder): Unit
+
+ protected def prepareFrame(rows: ExternalAppendOnlyUnsafeRowArray): Unit
+
+ protected final def prepareOrderedEvents(firstVisibleRows:
FirstVisibleRowsBuilder): Unit = {
+ var newEventSorter: UnsafeKVExternalSorter = null
+ try {
+ newEventSorter = firstVisibleRows.buildEvents()
+ eventSorter = newEventSorter
+ newEventSorter = null
+ eventIterator = eventSorter.sortedIterator()
+ loadNextEvent()
+ } finally {
+ if (newEventSorter != null) {
+ newEventSorter.cleanupResources()
+ }
+ }
+ }
+
+ protected final def addCandidate(
+ row: InternalRow,
+ firstVisibleIndex: Int,
+ inputIndex: Int,
+ firstVisibleRows: FirstVisibleRowsBuilder): Unit = {
+ if (boundFilter.forall(_.eval(row))) {
+ positionInput.setInt(0, firstVisibleIndex)
+ positionInput.setInt(1, inputIndex)
+ val distinctRow = distinctProjection(row)
+ firstVisibleRows.add(distinctRow, positionProjection(positionInput))
+ }
+ }
+
+ /**
+ * Uses a bounded BytesToBytesMap to remove duplicates before they reach the
first external
+ * sorter. The map contains only the earliest row for each distinct key
because candidates arrive
+ * in non-decreasing (firstVisibleIndex, inputIndex) order.
+ *
+ * BytesToBytesMap compares raw UnsafeRow bytes, so binary-unstable keys use
the sorter directly.
+ * Once the map reaches either its key-count or memory-size soft limit and
another new key
+ * arrives, or it cannot append a record, all map entries are transferred to
one external sorter.
+ * The rest of this window partition goes directly to that sorter and never
returns to hash-based
+ * deduplication.
+ */
+ protected final class FirstVisibleRowsBuilder extends AutoCloseable {
+ private val map = if (canUseHashDedup) {
+ val taskMemoryManager = TaskContext.get().taskMemoryManager()
+ new BytesToBytesMap(taskMemoryManager, 64,
taskMemoryManager.pageSizeBytes())
+ } else {
+ null
+ }
+ private var usingMap = map != null
+ private var sorter: UnsafeKVExternalSorter = _
+
+ def add(key: UnsafeRow, value: UnsafeRow): Unit = {
+ if (!usingMap) {
+ insertIntoSorter(key, value)
+ return
+ }
+
+ val location = map.lookup(
+ key.getBaseObject,
+ key.getBaseOffset,
+ key.getSizeInBytes)
+ if (location.isDefined) {
+ return
+ }
+
+ if (map.numKeys() >= hashFallbackThreshold ||
+ map.getTotalMemoryConsumption >= spillSizeThreshold) {
+ switchToSorter()
+ insertIntoSorter(key, value)
+ } else if (!location.append(
+ key.getBaseObject,
+ key.getBaseOffset,
+ key.getSizeInBytes,
+ value.getBaseObject,
+ value.getBaseOffset,
+ value.getSizeInBytes)) {
+ switchToSorter()
+ insertIntoSorter(key, value)
+ }
+ }
+
+ def buildEvents(): UnsafeKVExternalSorter = {
+ var events: UnsafeKVExternalSorter = null
+ try {
+ if (usingMap) {
+ // Make the map spillable before allocating the event sorter. The
destructive iterator
+ // releases the hash array immediately and lets memory pressure
spill remaining pages.
+ val iterator = destructiveMapIterator()
+ events = newSorter(positionSchema, distinctKeySchema)
+ drainMap(iterator, (key, position) => emitEvent(events, position,
key))
+ } else {
+ events = newSorter(positionSchema, distinctKeySchema)
+ if (sorter != null) {
+ consumeDistinctRows(
+ sorter, (key, position) => emitEvent(events, position, key))
+ }
+ }
+ val result = events
+ events = null
+ result
+ } finally {
+ if (events != null) {
+ events.cleanupResources()
+ }
+ }
+ }
+
+ def foreachDistinctRow(consume: (UnsafeRow, UnsafeRow) => Unit): Unit = {
+ if (usingMap) {
+ drainMap(destructiveMapIterator(), consume)
+ } else if (sorter != null) {
+ consumeDistinctRows(sorter, consume)
+ }
+ }
+
+ /**
+ * Returns bytes spilled by the first, distinct-key sorter. The frame
accounts for the second,
+ * event-order sorter separately when closing its event resources. Spill
files created while a
+ * destructive BytesToBytesMap iterator releases the map's data pages are
not included in the
+ * window spill metric.
+ */
+ def getSpillSize: Long = if (sorter == null) 0L else sorter.getSpillSize
+
+ private def insertIntoSorter(key: UnsafeRow, value: UnsafeRow): Unit = {
+ if (sorter == null) {
+ sorter = newSorter(distinctKeySchema, positionSchema)
+ }
+ sorter.insertKV(key, value)
+ }
+
+ private def switchToSorter(): Unit = {
+ assert(sorter == null)
+ val iterator = destructiveMapIterator()
+ sorter = newSorter(distinctKeySchema, positionSchema)
+ drainMap(iterator, (key, position) => sorter.insertKV(key, position))
+ }
+
+ private def destructiveMapIterator(): BytesToBytesMap#MapIterator = {
+ assert(usingMap)
+ val iterator = map.destructiveIterator()
+ usingMap = false
+ iterator
+ }
+
+ private def drainMap(
+ iterator: BytesToBytesMap#MapIterator,
+ consume: (UnsafeRow, UnsafeRow) => Unit): Unit = {
+ val key = new UnsafeRow(distinctKeySchema.length)
+ val position = new UnsafeRow(positionSchema.length)
+ while (iterator.hasNext) {
+ val location = iterator.next()
+ key.pointTo(
+ location.getKeyBase,
+ location.getKeyOffset,
+ location.getKeyLength)
+ position.pointTo(
+ location.getValueBase,
+ location.getValueOffset,
+ location.getValueLength)
+ consume(key, position)
+ }
+ }
+
+ override def close(): Unit = {
+ if (sorter != null) {
+ sorter.cleanupResources()
+ sorter = null
+ }
+ if (map != null) {
+ map.free()
+ usingMap = false
+ }
+ }
+ }
+
+ private def consumeDistinctRows(
+ firstSorter: UnsafeKVExternalSorter,
+ consume: (UnsafeRow, UnsafeRow) => Unit): Unit = {
+ val iterator = firstSorter.sortedIterator()
+ var groupKey: UnsafeRow = null
+ var selectedKey: UnsafeRow = null
+ var selectedPosition: UnsafeRow = null
+ try {
+ while (iterator.next()) {
+ val key = iterator.getKey
+ val position = iterator.getValue
+ if (groupKey == null) {
+ groupKey = key.copy()
+ selectedKey = groupKey
+ selectedPosition = position.copy()
+ } else if (distinctOrdering.compare(groupKey, key) == 0) {
+ if (isEarlier(position, selectedPosition)) {
+ selectedKey = key.copy()
+ selectedPosition = position.copy()
+ }
+ } else {
+ consume(selectedKey, selectedPosition)
+ groupKey = key.copy()
+ selectedKey = groupKey
+ selectedPosition = position.copy()
+ }
+ }
+ if (groupKey != null) {
+ consume(selectedKey, selectedPosition)
+ }
+ } finally {
+ iterator.close()
+ }
+ }
+
+ private def isEarlier(left: InternalRow, right: InternalRow): Boolean = {
+ val leftVisibleIndex = left.getInt(0)
+ val rightVisibleIndex = right.getInt(0)
+ leftVisibleIndex < rightVisibleIndex ||
+ leftVisibleIndex == rightVisibleIndex && left.getInt(1) < right.getInt(1)
+ }
+
+ private def emitEvent(
+ events: UnsafeKVExternalSorter,
+ position: UnsafeRow,
+ distinctValue: UnsafeRow): Unit = {
+ events.insertKV(position, distinctValue)
+ }
+
+ private def newSorter(
+ keySchema: StructType,
+ valueSchema: StructType): UnsafeKVExternalSorter = {
+ val taskContext = TaskContext.get()
+ // The frame owns all of its sorters and registers one task completion
listener at construction.
+ UnsafeKVExternalSorter.createWithCallerOwnedLifecycle(
+ keySchema,
+ valueSchema,
+ SparkEnv.get.blockManager,
+ SparkEnv.get.serializerManager,
+ taskContext.taskMemoryManager().pageSizeBytes,
+ spillThreshold,
+ spillSizeThreshold)
+ }
+
+ private def loadNextEvent(): Unit = {
+ if (eventIterator != null && eventIterator.next()) {
+ nextEventIndex = eventIterator.getKey.getInt(0)
+ } else {
+ nextEventIndex = Int.MaxValue
+ }
+ }
+
+ protected final def updateProcessor(index: Int): Unit = {
+ var bufferUpdated = index == 0
+ while (nextEventIndex <= index) {
+ processor.update(eventIterator.getValue)
Review Comment:
**Finding 1.** The frame hands the `AggregateProcessor` rows that point into
memory it then frees, and `processor.evaluate(target)` runs after the free.
`MutableProjection` does not copy non-primitive values —
`CodeGenerator.setColumn` falls through to `row.update(ordinal, value)` — and
`UnsafeRow.getUTF8String` / `getArray` / `getStruct` return views over the
row's backing memory. So any `DeclarativeAggregate` that keeps an input-derived
value in its buffer (`Max`, `Min`, `First`, `Last` on
`STRING`/`ARRAY`/`STRUCT`) retains a pointer into whichever page the row it was
updated with lives in, and so does the shared `windowFunctionResult` once
`evaluate` copies the reference across.
Two sites free that memory too early:
1. Here. On the iteration that consumes the last event, `loadNextEvent()`
calls `eventIterator.next()`, which returns `false` and — see
`KVSorterIterator.next()` / `close()` in
`sql/core/src/main/java/org/apache/spark/sql/execution/UnsafeKVExternalSorter.java`
— calls `cleanupResources()`, freeing the sorter's pages and deleting its
spill files. `processor.evaluate(target)` then runs on `:359`, and
`WindowEvaluatorFactory.next()` re-reads `target` through
`createResultProjection` for every remaining row of the partition. It's worse
once the event sorter has spilled: `UnsafeSorterSpillReader.loadNext()` reads
into a single reusable array, so consuming two events in one `updateProcessor`
call overwrites the first one's bytes before `evaluate`.
2. `:423`. `foreachDistinctRow` on the hash path routes through `drainMap`
over a **destructive** `BytesToBytesMap` iterator, which frees each data page
as it advances (`BytesToBytesMap.MapIterator.advanceToNextPage`), and
`prepare`'s `finally { firstVisibleRows.close() }` -> `map.free()` frees
whatever is left — both strictly before `prepareFrame` calls
`processor.evaluate(target)` at `:428`. That one is unconditional, not
memory-pressure dependent.
The sorter branch of the same method is already safe precisely because
`consumeDistinctRows` copies (`selectedKey = key.copy()`, `:291`). The map
branch being the odd one out is itself a hint that the copy is load-bearing.
On-heap the freed page goes back into `HeapMemoryAllocator`'s size pool, so
the bytes usually survive and the tests pass — they stop surviving as soon as
another consumer in the task reuses that page. Off-heap,
`TaskMemoryManager.freePage` -> `Platform.freeMemory` really releases it, and
`-Dspark.memory.debugFill=true` overwrites it with `0xa5` right away.
A copy at both sites fixes it:
```suggestion
processor.update(eventIterator.getValue.copy())
```
and at `:423`:
```scala
firstVisibleRows.foreachDistinctRow((key, _) => processor.update(key.copy()))
```
Coverage note: every new test uses `count`/`sum`/`avg` (primitive buffers)
or `collect_list`/`listagg` (`TypedImperativeAggregate`s that copy via
`InternalRow.copyValue`), so nothing exercises the vulnerable shape.
`max(DISTINCT strCol)` and `first(DISTINCT strCol)` over both a growing frame
and `OVER ()` would cover it, and running those with off-heap memory or
`spark.memory.debugFill=true` makes the failure deterministic rather than
luck-dependent.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/window/DistinctWindowFunctionFrame.scala:
##########
@@ -0,0 +1,548 @@
+/*
+ * 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.execution.window
+
+import org.apache.spark.{SparkEnv, TaskContext}
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.util.UnsafeRowUtils
+import org.apache.spark.sql.execution.{ExternalAppendOnlyUnsafeRowArray,
UnsafeKVExternalSorter}
+import org.apache.spark.sql.execution.metric.SQLMetric
+import org.apache.spark.sql.types.{DataType, IntegerType, StructField,
StructType}
+import org.apache.spark.unsafe.map.BytesToBytesMap
+
+/**
+ * Computes one or more equivalent DISTINCT aggregate expressions for a frame
whose lower bound is
+ * UNBOUNDED PRECEDING.
+ *
+ * Such a frame never removes rows: it either covers the entire partition or
only grows as output
+ * advances. For each qualifying input row this frame finds the first output
row whose upper bound
+ * contains it. A BytesToBytesMap removes duplicates while it remains below
configurable key-count
+ * and memory-size soft limits. When another new key arrives after either
limit has been reached, or
+ * an append fails, the frame permanently falls back to an external sorter for
the rest of the
+ * window partition. For a growing frame, that sorter finds the earliest event
for every key, then
+ * a second external sorter orders those events by (firstVisibleIndex,
inputIndex). The frame feeds
+ * each unique, normalized DISTINCT input to the aggregate processor when its
event becomes visible.
+ * A frame covering the entire partition consumes unique inputs directly
because their order is
+ * undefined.
+ */
+private[window] abstract class DistinctWindowFunctionFrame(
+ target: InternalRow,
+ processor: AggregateProcessor,
+ distinctExpressions: Seq[Expression],
+ filter: Option[Expression],
+ inputSchema: Seq[Attribute],
+ spillSize: SQLMetric,
+ hashFallbackThreshold: Int,
+ spillThreshold: Int,
+ spillSizeThreshold: Long)
+ extends WindowFunctionFrame with AutoCloseable {
+
+ private val distinctFields = distinctExpressions.zipWithIndex.map { case
(expression, index) =>
+ StructField(s"key$index", expression.dataType, expression.nullable)
+ }
+ private val distinctKeySchema = StructType(distinctFields)
+ private val distinctTypes = distinctKeySchema.map(_.dataType)
+ private val positionSchema = StructType(Seq(
+ StructField("firstVisibleIndex", IntegerType, nullable = false),
+ StructField("inputIndex", IntegerType, nullable = false)))
+
+ private val distinctProjection =
UnsafeProjection.create(distinctExpressions, inputSchema)
+ private val distinctOrdering =
RowOrdering.createNaturalAscendingOrdering(distinctTypes)
+ private val canUseHashDedup =
distinctTypes.forall(UnsafeRowUtils.isBinaryStable)
+ private val boundFilter = filter.map(Predicate.create(_, inputSchema))
+ private val positionInput =
+ new SpecificInternalRow(Seq(IntegerType, IntegerType))
+ private val positionProjection =
+ UnsafeProjection.create(Array[DataType](IntegerType, IntegerType))
+
+ private var eventSorter: UnsafeKVExternalSorter = _
+ private var eventIterator: UnsafeKVExternalSorter#KVSorterIterator = _
+ private var nextEventIndex = Int.MaxValue
+
+ Option(TaskContext.get()).foreach(_.addTaskCompletionListener[Unit](_ =>
close()))
+
+ override final def prepare(rows: ExternalAppendOnlyUnsafeRowArray): Unit = {
+ closeEventResources()
+ nextEventIndex = Int.MaxValue
+ processor.initialize(rows.length)
+ val partitionIndex =
Option(TaskContext.get()).map(_.partitionId()).getOrElse(0)
+ boundFilter.foreach(_.initialize(partitionIndex))
+
+ val firstVisibleRows = new FirstVisibleRowsBuilder
+ try {
+ populateFirstVisibleRows(rows, firstVisibleRows)
+ processDistinctRows(firstVisibleRows)
+ spillSize.add(firstVisibleRows.getSpillSize)
+ } finally {
+ firstVisibleRows.close()
+ }
+ prepareFrame(rows)
+ }
+
+ protected def populateFirstVisibleRows(
+ rows: ExternalAppendOnlyUnsafeRowArray,
+ firstVisibleRows: FirstVisibleRowsBuilder): Unit
+
+ protected def processDistinctRows(firstVisibleRows:
FirstVisibleRowsBuilder): Unit
+
+ protected def prepareFrame(rows: ExternalAppendOnlyUnsafeRowArray): Unit
+
+ protected final def prepareOrderedEvents(firstVisibleRows:
FirstVisibleRowsBuilder): Unit = {
+ var newEventSorter: UnsafeKVExternalSorter = null
+ try {
+ newEventSorter = firstVisibleRows.buildEvents()
+ eventSorter = newEventSorter
+ newEventSorter = null
+ eventIterator = eventSorter.sortedIterator()
+ loadNextEvent()
+ } finally {
+ if (newEventSorter != null) {
+ newEventSorter.cleanupResources()
+ }
+ }
+ }
+
+ protected final def addCandidate(
+ row: InternalRow,
+ firstVisibleIndex: Int,
+ inputIndex: Int,
+ firstVisibleRows: FirstVisibleRowsBuilder): Unit = {
+ if (boundFilter.forall(_.eval(row))) {
+ positionInput.setInt(0, firstVisibleIndex)
+ positionInput.setInt(1, inputIndex)
+ val distinctRow = distinctProjection(row)
+ firstVisibleRows.add(distinctRow, positionProjection(positionInput))
+ }
+ }
+
+ /**
+ * Uses a bounded BytesToBytesMap to remove duplicates before they reach the
first external
+ * sorter. The map contains only the earliest row for each distinct key
because candidates arrive
+ * in non-decreasing (firstVisibleIndex, inputIndex) order.
+ *
+ * BytesToBytesMap compares raw UnsafeRow bytes, so binary-unstable keys use
the sorter directly.
+ * Once the map reaches either its key-count or memory-size soft limit and
another new key
+ * arrives, or it cannot append a record, all map entries are transferred to
one external sorter.
+ * The rest of this window partition goes directly to that sorter and never
returns to hash-based
+ * deduplication.
+ */
+ protected final class FirstVisibleRowsBuilder extends AutoCloseable {
+ private val map = if (canUseHashDedup) {
+ val taskMemoryManager = TaskContext.get().taskMemoryManager()
+ new BytesToBytesMap(taskMemoryManager, 64,
taskMemoryManager.pageSizeBytes())
+ } else {
+ null
+ }
+ private var usingMap = map != null
+ private var sorter: UnsafeKVExternalSorter = _
+
+ def add(key: UnsafeRow, value: UnsafeRow): Unit = {
+ if (!usingMap) {
+ insertIntoSorter(key, value)
+ return
+ }
+
+ val location = map.lookup(
+ key.getBaseObject,
+ key.getBaseOffset,
+ key.getSizeInBytes)
+ if (location.isDefined) {
+ return
+ }
+
+ if (map.numKeys() >= hashFallbackThreshold ||
+ map.getTotalMemoryConsumption >= spillSizeThreshold) {
+ switchToSorter()
+ insertIntoSorter(key, value)
+ } else if (!location.append(
+ key.getBaseObject,
+ key.getBaseOffset,
+ key.getSizeInBytes,
+ value.getBaseObject,
+ value.getBaseOffset,
+ value.getSizeInBytes)) {
+ switchToSorter()
+ insertIntoSorter(key, value)
+ }
+ }
+
+ def buildEvents(): UnsafeKVExternalSorter = {
+ var events: UnsafeKVExternalSorter = null
+ try {
+ if (usingMap) {
+ // Make the map spillable before allocating the event sorter. The
destructive iterator
+ // releases the hash array immediately and lets memory pressure
spill remaining pages.
+ val iterator = destructiveMapIterator()
+ events = newSorter(positionSchema, distinctKeySchema)
+ drainMap(iterator, (key, position) => emitEvent(events, position,
key))
+ } else {
+ events = newSorter(positionSchema, distinctKeySchema)
+ if (sorter != null) {
+ consumeDistinctRows(
+ sorter, (key, position) => emitEvent(events, position, key))
+ }
+ }
+ val result = events
+ events = null
+ result
+ } finally {
+ if (events != null) {
+ events.cleanupResources()
+ }
+ }
+ }
+
+ def foreachDistinctRow(consume: (UnsafeRow, UnsafeRow) => Unit): Unit = {
+ if (usingMap) {
+ drainMap(destructiveMapIterator(), consume)
+ } else if (sorter != null) {
+ consumeDistinctRows(sorter, consume)
+ }
+ }
+
+ /**
+ * Returns bytes spilled by the first, distinct-key sorter. The frame
accounts for the second,
+ * event-order sorter separately when closing its event resources. Spill
files created while a
+ * destructive BytesToBytesMap iterator releases the map's data pages are
not included in the
+ * window spill metric.
+ */
+ def getSpillSize: Long = if (sorter == null) 0L else sorter.getSpillSize
+
+ private def insertIntoSorter(key: UnsafeRow, value: UnsafeRow): Unit = {
+ if (sorter == null) {
+ sorter = newSorter(distinctKeySchema, positionSchema)
+ }
+ sorter.insertKV(key, value)
+ }
+
+ private def switchToSorter(): Unit = {
+ assert(sorter == null)
+ val iterator = destructiveMapIterator()
+ sorter = newSorter(distinctKeySchema, positionSchema)
+ drainMap(iterator, (key, position) => sorter.insertKV(key, position))
+ }
+
+ private def destructiveMapIterator(): BytesToBytesMap#MapIterator = {
+ assert(usingMap)
+ val iterator = map.destructiveIterator()
+ usingMap = false
+ iterator
+ }
+
+ private def drainMap(
+ iterator: BytesToBytesMap#MapIterator,
+ consume: (UnsafeRow, UnsafeRow) => Unit): Unit = {
+ val key = new UnsafeRow(distinctKeySchema.length)
+ val position = new UnsafeRow(positionSchema.length)
+ while (iterator.hasNext) {
+ val location = iterator.next()
+ key.pointTo(
+ location.getKeyBase,
+ location.getKeyOffset,
+ location.getKeyLength)
+ position.pointTo(
+ location.getValueBase,
+ location.getValueOffset,
+ location.getValueLength)
+ consume(key, position)
+ }
+ }
+
+ override def close(): Unit = {
+ if (sorter != null) {
+ sorter.cleanupResources()
+ sorter = null
+ }
+ if (map != null) {
+ map.free()
+ usingMap = false
+ }
+ }
+ }
+
+ private def consumeDistinctRows(
+ firstSorter: UnsafeKVExternalSorter,
+ consume: (UnsafeRow, UnsafeRow) => Unit): Unit = {
+ val iterator = firstSorter.sortedIterator()
+ var groupKey: UnsafeRow = null
+ var selectedKey: UnsafeRow = null
+ var selectedPosition: UnsafeRow = null
+ try {
+ while (iterator.next()) {
+ val key = iterator.getKey
+ val position = iterator.getValue
+ if (groupKey == null) {
+ groupKey = key.copy()
+ selectedKey = groupKey
+ selectedPosition = position.copy()
+ } else if (distinctOrdering.compare(groupKey, key) == 0) {
+ if (isEarlier(position, selectedPosition)) {
+ selectedKey = key.copy()
+ selectedPosition = position.copy()
+ }
+ } else {
+ consume(selectedKey, selectedPosition)
+ groupKey = key.copy()
+ selectedKey = groupKey
+ selectedPosition = position.copy()
+ }
+ }
+ if (groupKey != null) {
+ consume(selectedKey, selectedPosition)
+ }
+ } finally {
+ iterator.close()
+ }
+ }
+
+ private def isEarlier(left: InternalRow, right: InternalRow): Boolean = {
+ val leftVisibleIndex = left.getInt(0)
+ val rightVisibleIndex = right.getInt(0)
+ leftVisibleIndex < rightVisibleIndex ||
+ leftVisibleIndex == rightVisibleIndex && left.getInt(1) < right.getInt(1)
+ }
+
+ private def emitEvent(
+ events: UnsafeKVExternalSorter,
+ position: UnsafeRow,
+ distinctValue: UnsafeRow): Unit = {
+ events.insertKV(position, distinctValue)
+ }
+
+ private def newSorter(
+ keySchema: StructType,
+ valueSchema: StructType): UnsafeKVExternalSorter = {
+ val taskContext = TaskContext.get()
+ // The frame owns all of its sorters and registers one task completion
listener at construction.
+ UnsafeKVExternalSorter.createWithCallerOwnedLifecycle(
+ keySchema,
+ valueSchema,
+ SparkEnv.get.blockManager,
+ SparkEnv.get.serializerManager,
+ taskContext.taskMemoryManager().pageSizeBytes,
+ spillThreshold,
+ spillSizeThreshold)
+ }
+
+ private def loadNextEvent(): Unit = {
+ if (eventIterator != null && eventIterator.next()) {
+ nextEventIndex = eventIterator.getKey.getInt(0)
+ } else {
+ nextEventIndex = Int.MaxValue
+ }
+ }
+
+ protected final def updateProcessor(index: Int): Unit = {
+ var bufferUpdated = index == 0
+ while (nextEventIndex <= index) {
+ processor.update(eventIterator.getValue)
+ bufferUpdated = true
+ loadNextEvent()
+ }
+ if (bufferUpdated) {
+ processor.evaluate(target)
+ }
+ }
+
+ override final def currentLowerBound(): Int = 0
+
+ private def closeEventResources(): Unit = {
+ if (eventSorter != null) {
+ spillSize.add(eventSorter.getSpillSize)
+ }
+ if (eventIterator != null) {
+ eventIterator.close()
+ eventIterator = null
+ }
+ if (eventSorter != null) {
+ eventSorter.cleanupResources()
+ eventSorter = null
+ }
+ }
+
+ override final def close(): Unit = closeEventResources()
+}
+
+/**
+ * Computes DISTINCT aggregates over the entire window partition. Since every
output row has the
+ * same set of unique inputs, their consumption order is undefined and is not
restored after hash
+ * deduplication or sorting by the DISTINCT key.
+ */
+private[window] final class UnboundedDistinctWindowFunctionFrame(
+ target: InternalRow,
+ processor: AggregateProcessor,
+ distinctExpressions: Seq[Expression],
+ filter: Option[Expression],
+ inputSchema: Seq[Attribute],
+ spillSize: SQLMetric,
+ hashFallbackThreshold: Int,
+ spillThreshold: Int,
+ spillSizeThreshold: Long)
+ extends DistinctWindowFunctionFrame(
+ target,
+ processor,
+ distinctExpressions,
+ filter,
+ inputSchema,
+ spillSize,
+ hashFallbackThreshold,
+ spillThreshold,
+ spillSizeThreshold) {
+
+ private var partitionSize = 0
+
+ override protected def populateFirstVisibleRows(
+ rows: ExternalAppendOnlyUnsafeRowArray,
+ firstVisibleRows: FirstVisibleRowsBuilder): Unit = {
+ val iterator = rows.generateIterator()
+ var inputIndex = 0
+ while (iterator.hasNext) {
+ addCandidate(iterator.next(), 0, inputIndex, firstVisibleRows)
+ inputIndex += 1
+ }
+ }
+
+ override protected def processDistinctRows(
+ firstVisibleRows: FirstVisibleRowsBuilder): Unit = {
+ firstVisibleRows.foreachDistinctRow((key, _) => processor.update(key))
+ }
+
+ override protected def prepareFrame(rows: ExternalAppendOnlyUnsafeRowArray):
Unit = {
+ partitionSize = rows.length
+ processor.evaluate(target)
+ }
+
+ override def write(index: Int, current: InternalRow): Unit = {}
+
+ override def currentUpperBound(): Int = partitionSize
+}
+
+/**
+ * Computes DISTINCT aggregates for a growing frame with an UNBOUNDED
PRECEDING lower bound.
+ */
+private[window] final class UnboundedPrecedingDistinctWindowFunctionFrame(
+ target: InternalRow,
+ processor: AggregateProcessor,
+ distinctExpressions: Seq[Expression],
+ filter: Option[Expression],
+ inputSchema: Seq[Attribute],
+ upperBound: BoundOrdering,
+ spillSize: SQLMetric,
+ hashFallbackThreshold: Int,
+ spillThreshold: Int,
+ spillSizeThreshold: Long)
+ extends DistinctWindowFunctionFrame(
+ target,
+ processor,
+ distinctExpressions,
+ filter,
+ inputSchema,
+ spillSize,
+ hashFallbackThreshold,
+ spillThreshold,
+ spillSizeThreshold) {
+
+ private val rowOffset = upperBound match {
+ case RowBoundOrdering(offset) => Some(offset)
+ case _ => None
+ }
+
+ private var partitionSize = 0
+ private var boundaryIterator: Iterator[UnsafeRow] = Iterator.empty
+ private var nextBoundaryRow: UnsafeRow = _
+ private var boundaryInputIndex = 0
+
+ override protected def populateFirstVisibleRows(
+ rows: ExternalAppendOnlyUnsafeRowArray,
+ firstVisibleRows: FirstVisibleRowsBuilder): Unit = {
+ rowOffset match {
+ case Some(offset) =>
+ // A ROWS bound depends only on row indexes. Calculate the first
visible output row
+ // directly instead of scanning the partition again as output rows.
+ val inputIterator = rows.generateIterator()
+ var inputIndex = 0
+ while (inputIterator.hasNext) {
+ val input = inputIterator.next()
+ val firstVisibleIndex = inputIndex.toLong - offset.toLong
+ if (firstVisibleIndex < rows.length) {
+ addCandidate(
+ input,
+ math.max(firstVisibleIndex, 0L).toInt,
+ inputIndex,
+ firstVisibleRows)
+ }
+ inputIndex += 1
+ }
+
+ case None =>
+ val inputIterator = rows.generateIterator()
+ val outputIterator = rows.generateIterator()
+ var nextInput = WindowFunctionFrame.getNextOrNull(inputIterator)
+ var inputIndex = 0
+ var outputIndex = 0
+
+ while (outputIterator.hasNext && nextInput != null) {
+ val currentOutput = outputIterator.next()
+ while (nextInput != null &&
+ upperBound.compare(nextInput, inputIndex, currentOutput,
outputIndex) <= 0) {
+ addCandidate(nextInput, outputIndex, inputIndex, firstVisibleRows)
+ inputIndex += 1
+ nextInput = WindowFunctionFrame.getNextOrNull(inputIterator)
+ }
+ outputIndex += 1
+ }
+ }
+ }
+
+ override protected def processDistinctRows(
+ firstVisibleRows: FirstVisibleRowsBuilder): Unit = {
+ prepareOrderedEvents(firstVisibleRows)
+ }
+
+ override protected def prepareFrame(rows: ExternalAppendOnlyUnsafeRowArray):
Unit = {
+ partitionSize = rows.length
+ boundaryInputIndex = 0
+ if (rowOffset.isEmpty) {
+ boundaryIterator = rows.generateIterator()
+ nextBoundaryRow = WindowFunctionFrame.getNextOrNull(boundaryIterator)
+ } else {
+ boundaryIterator = Iterator.empty
+ nextBoundaryRow = null
+ }
+ }
+
+ override def write(index: Int, current: InternalRow): Unit = {
+ updateProcessor(index)
+ rowOffset match {
+ case Some(offset) =>
+ // The upper bound is exclusive, hence the extra one after applying
the ROWS offset.
+ val upperBoundIndex = index.toLong + offset.toLong + 1L
+ boundaryInputIndex = math.max(0L, math.min(upperBoundIndex,
partitionSize.toLong)).toInt
+
+ case None =>
+ while (nextBoundaryRow != null &&
Review Comment:
**Finding 4.** This boundary tracking is pure overhead on the RANGE path.
`currentUpperBound()` / `currentLowerBound()` are read in exactly one place in
the tree —
`sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonEvaluatorFactory.scala:363-365`
— and a distinct frame can never reach it:
- `SparkStrategies.Window` (`:786-796`) routes a `Window` node to either
`WindowExec` or `ArrowWindowPythonExec` by `WindowFunctionType`, so SQL and
Python window functions are never in the same physical node;
- a distinct `PythonUDAF` is rejected in
`WindowResolution.checkWindowFunction:166`.
So `boundaryIterator` / `nextBoundaryRow` (`:522-523`) exist only to compute
a value nothing reads, and they cost a third live iterator over the partition
plus one extra full pass over `rows` per window partition — re-read from disk
whenever the input buffer spilled. The ROWS branch's arithmetic is free, so
this is specifically the RANGE cost.
Suggest computing it on demand instead, so the scan only happens if someone
actually asks:
```scala
override def currentUpperBound(): Int = {
// Advance lazily: nothing in the SQL window path reads this.
...
}
```
If you'd rather keep the eager version, a one-line comment saying the bound
is maintained only to satisfy the `WindowFunctionFrame` contract would at least
stop the next reader from assuming it's needed.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]