Skip to content

Commit f184c25

Browse files
committed
[SPARK-56361][SS] Provide better error with logging on NPE in stream-stream join
### What changes were proposed in this pull request? This PR proposes to provide better error with additional logging on NPE in stream-stream join. We have captured several places which could throw NPE - this PR provides the better error for users which is less cryptic and gives a quick mitigation if they are willing to tolerate losing some data to keep the query running. For devs, this PR leaves the context to the log, so that when users come to devs with the error, devs can at least know where to start looking into. ### Why are the changes needed? Throwing NPE does not help anything for users and devs. For users, NPE is mostly cryptic error and they know nothing what to do to mitigate the issue. There is no information around context, so debugging is almost impossible for devs when this happens. ### Does this PR introduce _any_ user-facing change? Yes, users would no longer get NPE from stream-stream join for known places on potential NPE, and will get better exception with the guidance for quick mitigation (toleration of data loss). ### How was this patch tested? Modified test ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude 4.6 Opus Closes #55214 from HeartSaVioR/SPARK-56361. Authored-by: Jungtaek Lim <kabhwan.opensource@gmail.com> Signed-off-by: Jungtaek Lim <kabhwan.opensource@gmail.com>
1 parent ae7f6e3 commit f184c25

5 files changed

Lines changed: 98 additions & 19 deletions

File tree

common/utils-java/src/main/java/org/apache/spark/internal/LogKeys.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ public enum LogKeys implements LogKey {
319319
JOB_IDS,
320320
JOIN_CONDITION,
321321
JOIN_CONDITION_SUB_EXPR,
322+
JOIN_SIDE,
322323
JOIN_TYPE,
323324
K8S_CONTEXT,
324325
KEY,
@@ -537,6 +538,7 @@ public enum LogKeys implements LogKey {
537538
NUM_TASK_CPUS,
538539
NUM_TRAIN_WORD,
539540
NUM_UNFINISHED_DECOMMISSIONED,
541+
NUM_VALUES,
540542
NUM_VERSIONS_RETAIN,
541543
NUM_WEIGHTED_EXAMPLES,
542544
NUM_WORKERS,

common/utils/src/main/resources/error/error-conditions.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6769,6 +6769,21 @@
67696769
],
67706770
"sqlState" : "XXKST"
67716771
},
6772+
"STREAM_STREAM_JOIN_INCONSISTENT_STATE" : {
6773+
"message" : [
6774+
"Detected inconsistency in stream-stream join state."
6775+
],
6776+
"subClass" : {
6777+
"NULL_VALUE" : {
6778+
"message" : [
6779+
"Value at index <valueIndex> is null while numValues is <numValues>.",
6780+
"joinSide=<joinSide>, storeVersion=<storeVersion>, partitionId=<partitionId>.",
6781+
"Enable <configKey> as a workaround to skip null values."
6782+
]
6783+
}
6784+
},
6785+
"sqlState" : "XXKST"
6786+
},
67726787
"STRUCT_ARRAY_LENGTH_MISMATCH" : {
67736788
"message" : [
67746789
"Input row doesn't have expected number of values required by the schema. <expected> fields are required while <actual> values are provided."

sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/join/SymmetricHashJoinStateManager.scala

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import org.apache.hadoop.conf.Configuration
2525

2626
import org.apache.spark.TaskContext
2727
import org.apache.spark.internal.Logging
28-
import org.apache.spark.internal.LogKeys.{END_INDEX, START_INDEX, STATE_STORE_ID}
28+
import org.apache.spark.internal.LogKeys.{END_INDEX, INDEX, JOIN_SIDE, KEY, NUM_VALUES, PARTITION_ID, START_INDEX, STATE_STORE_ID, STATE_STORE_VERSION}
2929
import org.apache.spark.sql.catalyst.InternalRow
3030
import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference, Expression, JoinedRow, Literal, SafeProjection, SpecificInternalRow, UnsafeProjection, UnsafeRow}
3131
import org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes
@@ -1025,7 +1025,9 @@ abstract class SymmetricHashJoinStateManagerBase(
10251025
override def getNext(): JoinedRow = {
10261026
while (index < numValues) {
10271027
val valuePair = keyWithIndexToValue.get(key, index)
1028-
if (valuePair == null && storeConf.skipNullsForStreamStreamJoins) {
1028+
if (valuePair == null) {
1029+
handleNullValuePair(key, index, numValues)
1030+
skippedNullValueCount.foreach(_ += 1L)
10291031
index += 1
10301032
} else if (valuePair.matched) {
10311033
// See the NOTE in the method doc about rationale.
@@ -1265,8 +1267,10 @@ abstract class SymmetricHashJoinStateManagerBase(
12651267
/**
12661268
* Find the next value satisfying the condition, updating `currentKey` and `numValues` if
12671269
* needed. Returns null when no value can be found.
1268-
* Note that we will skip nulls explicitly if config setting for the same is
1269-
* set to true via STATE_STORE_SKIP_NULLS_FOR_STREAM_STREAM_JOINS.
1270+
*
1271+
* Null values from the state store are handled by [[handleNullValuePair]]:
1272+
* skipped if STATE_STORE_SKIP_NULLS_FOR_STREAM_STREAM_JOINS is enabled,
1273+
* otherwise an exception is thrown with diagnostic context.
12701274
*/
12711275
private def findNextValueForIndex(): ValueAndMatchPair = {
12721276
// Loop across all values for the current key, and then all other keys, until we find a
@@ -1277,7 +1281,9 @@ abstract class SymmetricHashJoinStateManagerBase(
12771281
if (hasMoreValuesForCurrentKey) {
12781282
// First search the values for the current key.
12791283
val valuePair = keyWithIndexToValue.get(currentKey, index)
1280-
if (valuePair == null && storeConf.skipNullsForStreamStreamJoins) {
1284+
if (valuePair == null) {
1285+
handleNullValuePair(currentKey, index, numValues)
1286+
skippedNullValueCount.foreach(_ += 1L)
12811287
index += 1
12821288
} else if (removalCondition(valuePair.value)) {
12831289
return valuePair
@@ -1328,6 +1334,40 @@ abstract class SymmetricHashJoinStateManagerBase(
13281334
/** Projects the key of unsafe row to internal row for printable log message. */
13291335
def getInternalRowOfKeyWithIndex(currentKey: UnsafeRow): InternalRow = keyProjection(currentKey)
13301336

1337+
@volatile private var nullValueWarningLogged = false
1338+
1339+
/**
1340+
* Called when keyWithIndexToValue.get(key, index) returns null even though
1341+
* keyToNumValues claims there should be a value at that index.
1342+
*
1343+
* Logs a warning (once per instance) with diagnostic context, then either
1344+
* returns normally (if skipNullsForStreamStreamJoins is enabled, caller skips)
1345+
* or throws a [[StreamStreamJoinInconsistentStateNullValue]].
1346+
*/
1347+
protected def handleNullValuePair(
1348+
key: UnsafeRow,
1349+
nullIndex: Long,
1350+
numValues: Long): Unit = {
1351+
if (!nullValueWarningLogged) {
1352+
nullValueWarningLogged = true
1353+
logWarning(log"Null value detected in stream-stream join state: " +
1354+
log"joinSide=${MDC(JOIN_SIDE, joinSide)}, key=${MDC(KEY, keyProjection(key))}, " +
1355+
log"index=${MDC(INDEX, nullIndex)}, numValues=${MDC(NUM_VALUES, numValues)}, " +
1356+
log"storeVersion=${MDC(STATE_STORE_VERSION, stateInfo.get.storeVersion)}, " +
1357+
log"partitionId=${MDC(PARTITION_ID, partitionId)}")
1358+
}
1359+
1360+
if (!storeConf.skipNullsForStreamStreamJoins) {
1361+
throw StateStoreErrors.streamStreamJoinNullValue(
1362+
valueIndex = nullIndex,
1363+
numValues = numValues,
1364+
joinSide = joinSide.toString,
1365+
storeVersion = stateInfo.get.storeVersion,
1366+
partitionId = partitionId,
1367+
configKey = SQLConf.STATE_STORE_SKIP_NULLS_FOR_STREAM_STREAM_JOINS.key)
1368+
}
1369+
}
1370+
13311371
/** Commit all the changes to all the state stores */
13321372
def commit(): Unit
13331373

@@ -1609,8 +1649,6 @@ abstract class SymmetricHashJoinStateManagerBase(
16091649
/**
16101650
* Get all values and indices for the provided key.
16111651
* Should not return null.
1612-
* Note that we will skip nulls explicitly if config setting for the same is
1613-
* set to true via STATE_STORE_SKIP_NULLS_FOR_STREAM_STREAM_JOINS.
16141652
*/
16151653
def getAll(key: UnsafeRow, numValues: Long): Iterator[KeyWithIndexAndValue] = {
16161654
new NextIterator[KeyWithIndexAndValue] {
@@ -1623,7 +1661,8 @@ abstract class SymmetricHashJoinStateManagerBase(
16231661
val keyWithIndex = keyWithIndexRow(key, index)
16241662
val valuePair =
16251663
valueRowConverter.convertValue(stateStore.get(keyWithIndex, colFamilyName))
1626-
if (valuePair == null && storeConf.skipNullsForStreamStreamJoins) {
1664+
if (valuePair == null) {
1665+
handleNullValuePair(key, index, numValues)
16271666
skippedNullValueCount.foreach(_ += 1L)
16281667
index += 1
16291668
} else {

sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/StateStoreErrors.scala

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,17 @@ object StateStoreErrors {
306306
StateStoreUnknownInternalColumnFamily = {
307307
new StateStoreUnknownInternalColumnFamily(colFamilyName)
308308
}
309+
310+
def streamStreamJoinNullValue(
311+
valueIndex: Long,
312+
numValues: Long,
313+
joinSide: String,
314+
storeVersion: Long,
315+
partitionId: Int,
316+
configKey: String): StreamStreamJoinInconsistentStateNullValue = {
317+
new StreamStreamJoinInconsistentStateNullValue(
318+
valueIndex, numValues, joinSide, storeVersion, partitionId, configKey)
319+
}
309320
}
310321

311322
trait ConvertableToCannotLoadStoreError {
@@ -674,3 +685,20 @@ class StateStoreBaseCheckpointIdMismatch(
674685
"actualBaseId" -> actualBaseId
675686
)
676687
)
688+
689+
class StreamStreamJoinInconsistentStateNullValue(
690+
valueIndex: Long,
691+
numValues: Long,
692+
joinSide: String,
693+
storeVersion: Long,
694+
partitionId: Int,
695+
configKey: String)
696+
extends SparkRuntimeException(
697+
errorClass = "STREAM_STREAM_JOIN_INCONSISTENT_STATE.NULL_VALUE",
698+
messageParameters = Map(
699+
"valueIndex" -> valueIndex.toString,
700+
"numValues" -> numValues.toString,
701+
"joinSide" -> joinSide,
702+
"storeVersion" -> storeVersion.toString,
703+
"partitionId" -> partitionId.toString,
704+
"configKey" -> configKey))

sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/SymmetricHashJoinStateManagerSuite.scala

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import java.util.UUID
2424
import org.apache.hadoop.conf.Configuration
2525
import org.scalatest.BeforeAndAfter
2626

27+
import org.apache.spark.{SparkRuntimeException, SparkThrowable}
2728
import org.apache.spark.sql.SparkSession
2829
import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference, BoundReference, Expression, GenericInternalRow, JoinedRow, LessThanOrEqual, Literal, UnsafeProjection, UnsafeRow}
2930
import org.apache.spark.sql.catalyst.expressions.codegen.GeneratePredicate
@@ -329,8 +330,8 @@ class SymmetricHashJoinStateManagerSuite extends SymmetricHashJoinStateManagerBa
329330

330331
/* Test removeByValue with nulls in middle simulated by updating numValues on the state manager */
331332
private def testAllOperationsWithNullsInMiddle(stateFormatVersion: Int): Unit = {
332-
// Test with skipNullsForStreamStreamJoins set to false which would throw a
333-
// NullPointerException while iterating and also return null values as part of get
333+
// Test with skipNullsForStreamStreamJoins set to false which would throw
334+
// STREAM_STREAM_JOIN_INCONSISTENT_STATE.NULL_VALUE while iterating
334335
withJoinStateManager(inputValueAttributes, joinKeyExpressions, stateFormatVersion) { manager =>
335336
implicit val mgr = manager
336337

@@ -342,15 +343,9 @@ class SymmetricHashJoinStateManagerSuite extends SymmetricHashJoinStateManagerBa
342343
updateNumValues(40, 7) // create nulls in between and end
343344
removeByValue(50)
344345
}
345-
assert(ex.isInstanceOf[NullPointerException])
346-
assert(getNumValues(40) === 7) // we should get 7 with no nulls skipped
347-
348-
removeByValue(300)
349-
assert(getNumValues(40) === 1) // only 400 should remain
350-
assert(get(40) === Seq(400))
351-
removeByValue(400)
352-
assert(get(40) === Seq.empty)
353-
assertNumRows(stateFormatVersion, 0) // ensure all elements removed
346+
assert(ex.isInstanceOf[SparkRuntimeException])
347+
assert(ex.asInstanceOf[SparkThrowable].getCondition ==
348+
"STREAM_STREAM_JOIN_INCONSISTENT_STATE.NULL_VALUE")
354349
}
355350

356351
// Test with skipNullsForStreamStreamJoins set to true which would skip nulls

0 commit comments

Comments
 (0)