peter-toth commented on code in PR #59009:
URL: https://github.com/apache/spark/pull/59009#discussion_r4090886566
##########
core/src/main/java/org/apache/spark/unsafe/map/BytesToBytesMap.java:
##########
@@ -587,6 +599,29 @@ public Location lookup(Object keyBase, long keyOffset, int
keyLength, int hash)
return loc;
}
+ /**
+ * Restores the hash array after a {@link #reset()} freed it but its eager
reallocation failed
+ * with an OOM, leaving {@code longArray == null}. A reset() empties the
map, so re-allocating at
+ * {@code initialCapacity} here is correct. Without this, a subsequent
lookup dereferences the
+ * null array in {@code safeLookup} (or an assertion error with -ea). If
memory is still
+ * unavailable this throws a SparkOutOfMemoryError instead.
+ */
+ private void restoreArrayAfterFailedReset() {
+ if (!freed && longArrayRecoveryRequired) {
+ // Only the failed-reset state is recoverable here: the map is empty, so
a fresh
+ // initial-capacity array is correct. `destructiveIterator != null`
means destructive
+ // iteration has begun, after which map operations are illegal. Do not
re-allocate the
+ // array in that terminal state; leave the illegal lookup to fail loudly.
+ synchronized (this) {
+ if (!freed && longArray == null && destructiveIterator == null &&
Review Comment:
**Finding 2.** This guard has no test. I removed `destructiveIterator ==
null` from the condition and re-ran both suites on your head commit: all 25
tests in each stayed green, so nothing currently pins it.
This closes it, alongside the existing `lookupAfterFreeDoesNotRestoreArray`:
```java
@Test
public void lookupAfterDestructiveIterationDoesNotRestoreArray() {
memoryManager.limit(5000);
BytesToBytesMap map =
new BytesToBytesMap(taskMemoryManager, blockManager,
serializerManager, 256, 0.5, 4000);
final long[] key = new long[]{1L};
try {
memoryManager.markExecutionAsOutOfMemoryOnce();
assertThrows(SparkOutOfMemoryError.class, map::reset);
map.destructiveIterator();
memoryManager.limit(PAGE_SIZE_BYTES);
assertThrows(
AssertionError.class,
() -> map.lookup(key, Platform.LONG_ARRAY_OFFSET, 8));
assertEquals(0L, taskMemoryManager.getMemoryConsumptionForThisTask());
} finally {
map.free();
}
}
```
I ran it both ways. On your head it passes. With `destructiveIterator ==
null` deleted it fails with `Expected java.lang.AssertionError to be thrown,
but nothing was thrown`, and the memory assertion would catch the stray array
as well.
##########
core/src/main/java/org/apache/spark/unsafe/map/BytesToBytesMap.java:
##########
@@ -615,6 +651,7 @@ public void safeLookup(Object keyBase, long keyOffset, int
keyLength, Location l
* and the map must not be modified concurrently.
*/
public void safeLookup(Object keyBase, long keyOffset, int keyLength,
Location loc, int hash) {
+ restoreArrayAfterFailedReset();
Review Comment:
**Finding 3.** Two javadoc gaps on public methods, both about behaviour this
line introduces.
`safeLookup`'s doc right above says "The map must not be modified
concurrently", but this call makes the lookup itself modify the map. It
allocates a `LongArray`, registers it with the `TaskMemoryManager`, and
rewrites `mask` and `growthThreshold`. It can also throw
`SparkOutOfMemoryError`, which nobody expects from a read method.
`UnsafeHashedRelation.get` and `getValue` reach `safeLookup` on the
broadcast-join probe path
(`sql/core/src/main/scala/org/apache/spark/sql/execution/joins/HashedRelation.scala:235`),
so it is worth stating even though those maps are never reset.
`reset()`'s doc at `:1160` gained the `free()` sentence but not the one that
matters most here. A failed reallocation now leaves the map empty and the next
lookup restores it. That contract currently lives only on the private
`restoreArrayAfterFailedReset`, where a caller will not look for it.
##########
core/src/main/java/org/apache/spark/unsafe/map/BytesToBytesMap.java:
##########
@@ -587,6 +599,29 @@ public Location lookup(Object keyBase, long keyOffset, int
keyLength, int hash)
return loc;
}
+ /**
+ * Restores the hash array after a {@link #reset()} freed it but its eager
reallocation failed
+ * with an OOM, leaving {@code longArray == null}. A reset() empties the
map, so re-allocating at
+ * {@code initialCapacity} here is correct. Without this, a subsequent
lookup dereferences the
+ * null array in {@code safeLookup} (or an assertion error with -ea). If
memory is still
+ * unavailable this throws a SparkOutOfMemoryError instead.
+ */
+ private void restoreArrayAfterFailedReset() {
Review Comment:
**Finding 5.** The recovery only runs on the `safeLookup` paths, so the
failed-reset state still reaches two other public methods with `longArray ==
null`.
- **`getArray()`**: `assert(longArray != null)` is a no-op without `-ea`, so
it returns null. `UnsafeKVExternalSorter` calls it first thing
(`sql/core/src/main/java/org/apache/spark/sql/execution/UnsafeKVExternalSorter.java:109`)
and then `pointerArray.size()`, which is the NPE this PR is fixing on a
different entry point.
- **`maxNumKeysIndex()`**: dereferences `longArray` directly.
`iterator()`, `destructiveIterator()` and `iteratorWithKeyIndex()` are safe,
because `numValues == 0` after the reset keeps them from touching the array.
Calling `restoreArrayAfterFailedReset()` at the top of `getArray()` and
`maxNumKeysIndex()` covers those two. The alternative is to state in the
javadoc that only lookups self-heal, so a caller knows to do a lookup first.
##########
core/src/test/java/org/apache/spark/unsafe/map/AbstractBytesToBytesMapSuite.java:
##########
@@ -875,4 +879,308 @@ public void freeAfterFailedReset() {
}
}
+ @Test
+ public void lookupAfterFreeDoesNotRestoreArray() {
+ memoryManager.limit(5000);
+ BytesToBytesMap map =
+ new BytesToBytesMap(taskMemoryManager, blockManager, serializerManager,
256, 0.5, 4000);
+ final long[] key = new long[]{1L};
+ try {
+ memoryManager.markExecutionAsOutOfMemoryOnce();
+ assertThrows(SparkOutOfMemoryError.class, map::reset);
+ map.free();
+ assertEquals(0L, taskMemoryManager.getMemoryConsumptionForThisTask());
+
+ map.reset();
+ assertEquals(0, map.getNumDataPages());
+ assertEquals(0L, taskMemoryManager.getMemoryConsumptionForThisTask());
+
+ assertThrows(
+ AssertionError.class,
Review Comment:
**Finding 7.** This pins a JVM assertion, so it only describes the `-ea`
build. Production runs without `-ea`, where the same lookup falls through to
`longArray.get(pos * 2)` and throws a bare NPE. That is the failure mode the PR
set out to remove.
Now that `freed` is tracked, making it explicit costs nothing.
`restoreArrayAfterFailedReset` already reads `freed` on the fast path, so it
can just be:
```java
if (freed) {
throw new IllegalStateException("BytesToBytesMap has already been
freed");
}
if (longArrayRecoveryRequired) {
...
}
```
and the test asserts `IllegalStateException` instead. If you would rather
not add a throw on the lookup path, a comment saying the `AssertionError` only
happens under `-ea` would at least record what production does.
##########
core/src/main/java/org/apache/spark/unsafe/map/BytesToBytesMap.java:
##########
@@ -587,6 +599,29 @@ public Location lookup(Object keyBase, long keyOffset, int
keyLength, int hash)
return loc;
}
+ /**
+ * Restores the hash array after a {@link #reset()} freed it but its eager
reallocation failed
+ * with an OOM, leaving {@code longArray == null}. A reset() empties the
map, so re-allocating at
+ * {@code initialCapacity} here is correct. Without this, a subsequent
lookup dereferences the
+ * null array in {@code safeLookup} (or an assertion error with -ea). If
memory is still
+ * unavailable this throws a SparkOutOfMemoryError instead.
+ */
+ private void restoreArrayAfterFailedReset() {
+ if (!freed && longArrayRecoveryRequired) {
+ // Only the failed-reset state is recoverable here: the map is empty, so
a fresh
+ // initial-capacity array is correct. `destructiveIterator != null`
means destructive
+ // iteration has begun, after which map operations are illegal. Do not
re-allocate the
+ // array in that terminal state; leave the illegal lookup to fail loudly.
+ synchronized (this) {
Review Comment:
**Finding 4.** This block holds the map's monitor across `allocate()`, which
goes through `TaskMemoryManager.allocatePage` and can spill other consumers to
disk. That establishes the lock order BytesToBytesMap -> TaskMemoryManager.
`TaskMemoryManager.spillConsumer` uses the opposite order, calling
`consumerToSpill.spill(...)` while holding the TaskMemoryManager monitor
(`core/src/main/java/org/apache/spark/memory/TaskMemoryManager.java:313`).
It does not deadlock today, and I checked why. `BytesToBytesMap.spill` is
not synchronized, and `MapIterator.spill` takes the iterator's monitor rather
than the map's, so nothing reachable from `spillConsumer` ever waits on this
monitor. `avoidDeadlock` still passes with `reset()` and `free()` synchronized.
That is the same hazard `advanceToNextPage` spells out for `MapIterator`
under SPARK-26265, and three new things now depend on it quietly: the
`synchronized` here, on `reset()`, and on `free()`. A comment beside this
`synchronized` recording the invariant ("nothing invoked under the
TaskMemoryManager monitor may take this monitor") would stop the next change
from breaking it silently.
##########
core/src/main/java/org/apache/spark/unsafe/map/BytesToBytesMap.java:
##########
@@ -1117,22 +1157,34 @@ public LongArray getArray() {
}
/**
- * Reset this map to initialized state.
+ * Reset this map to initialized state. If {@link #free()} has been called,
the map cannot be
+ * reinitialized and this method is a no-op.
*/
- public void reset() {
+ public synchronized void reset() {
+ if (freed) {
+ return;
+ }
updatePeakMemoryUsed();
+ // Put the map into its empty state up front so that if the allocate()
below fails with an OOM,
+ // the map is left consistently empty (longArray == null, no stale
currentPage) rather than
+ // half-reset. A subsequent lookup then restores the array lazily
(restoreArrayAfterFailedReset)
+ // instead of dereferencing a null longArray or writing into a freed
currentPage.
numKeys = 0;
numValues = 0;
- freeArray(longArray);
+ canGrowArray = true;
+ currentPage = null;
+ pageCursor = 0;
+ if (longArray != null) {
+ freeArray(longArray);
+ }
longArray = null;
while (dataPages.size() > 0) {
MemoryBlock dataPage = dataPages.removeLast();
freePage(dataPage);
}
+ longArrayRecoveryRequired = true;
Review Comment:
**Finding 6.** The invariant the whole recovery rests on is "`longArray ==
null` means recovery is required". It breaks at `:1180` and is only
re-established here, five statements later. If anything throws in between (a
`freeArray` or `freePage` failure), the map is left with a null array and the
flag still `false`, which disarms the recovery for good.
Arming it before the array is dropped removes the window:
```java
longArrayRecoveryRequired = true;
if (longArray != null) {
freeArray(longArray);
}
longArray = null;
while (dataPages.size() > 0) {
MemoryBlock dataPage = dataPages.removeLast();
freePage(dataPage);
}
allocate(initialCapacity);
longArrayRecoveryRequired = false;
```
--
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]