This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new b65ef3e18fbe [SPARK-58107][CORE][FOLLOWUP] Keep the local-checkpoint
sealed checksum as a tombstone past last-replica loss
b65ef3e18fbe is described below
commit b65ef3e18fbe2fd499e1c48edd212cae79d377ba
Author: Juliusz Sompolski <[email protected]>
AuthorDate: Fri Jul 24 23:49:44 2026 +0800
[SPARK-58107][CORE][FOLLOWUP] Keep the local-checkpoint sealed checksum as
a tombstone past last-replica loss
### What changes were proposed in this pull request?
Follow-up to
[SPARK-58107](https://issues.apache.org/jira/browse/SPARK-58107) (detect and
repair
non-deterministic local checkpoints via RDD-block content checksums). Makes
the master's per-block
sealed checksum (`sealedChecksums` in `BlockManagerMasterEndpoint`) a
tombstone that survives the
loss of the block's last replica and is reclaimed only when the RDD is
removed.
Previously `sealedChecksums(blockId)` was dropped alongside
`blockLocations` when a block's last
replica went away (the empty-locations branches of `updateBlockInfo` and
`removeBlockManager`).
- `updateBlockInfo` / `removeBlockManager`: on empty locations, keep
`sealedChecksums` (still drop
`blockLocations` / `blockChecksums`). A late replica matching the seal is
admitted, a divergent one
rejected by `checksumSealRejectsUpdate`.
- `removeRdd`: reclaim the RDD's tombstones via a new `sealedBlocksByRdd`
index (rddId -> its sealed
block ids, populated in `sealRddChecksums`), so reclaim is O(the RDD's
sealed blocks) rather than a
scan of the whole `sealedChecksums` map on the single-threaded endpoint.
- Only `sealedChecksums` (a `Long` per sealed partition, plus its
`RDDBlockId` in the index) is
tombstoned; `blockChecksums` is still dropped at empty-locations.
### Why are the changes needed?
After a checkpoint is sealed and its lineage is cut, losing the winning
replica erased the seal. A
late divergent copy of that block -- e.g. from a zombie task of a
superseded stage attempt that was
still running from before the seal -- then had no seal to reject it, so
`updateBlockInfo` admitted it
and later readers could silently observe a different version than earlier
readers. Losing every
replica of a finalized checkpoint must mean data loss, not a silent version
switch. The tombstone
preserves the seal so the divergent late copy is still rejected; retention
is bounded by RDD lifetime
(reclaimed in `removeRdd`).
### Does this PR introduce _any_ user-facing change?
No. `spark.checkpoint.local.verifyChecksum.enabled` gates the seal path;
behavior only differs when it
is enabled, and only in the (previously silently wrong) last-replica-loss +
divergent-late-copy case.
### How was this patch tested?
New `BlockManagerSuite` tests drive the master RPC sequence the race
produces (no scheduler needed):
after a sealed block loses its last replica -- via an invalid-level
`updateBlockInfo` and via
`removeExecutor` -- the seal survives as a tombstone, a divergent late
registration is rejected and a
matching one admitted; and `removeRdd` reclaims a zero-replica tombstone.
The existing "sealed
checksums are cleared when the RDD is removed" test still passes.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)
Closes #57499 from
juliuszsompolski/julek_data/checksum-seal-verify-tombstone.
Authored-by: Juliusz Sompolski <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../spark/storage/BlockManagerMasterEndpoint.scala | 18 +++++--
.../apache/spark/storage/BlockManagerSuite.scala | 57 ++++++++++++++++++++++
2 files changed, 71 insertions(+), 4 deletions(-)
diff --git
a/core/src/main/scala/org/apache/spark/storage/BlockManagerMasterEndpoint.scala
b/core/src/main/scala/org/apache/spark/storage/BlockManagerMasterEndpoint.scala
index 2807ab839bf4..08d802740618 100644
---
a/core/src/main/scala/org/apache/spark/storage/BlockManagerMasterEndpoint.scala
+++
b/core/src/main/scala/org/apache/spark/storage/BlockManagerMasterEndpoint.scala
@@ -87,9 +87,15 @@ class BlockManagerMasterEndpoint(
// The authoritative sealed checksum per finalized block. Once present, only
a copy with this
// checksum is admitted to `blockLocations`; divergent copies are rejected,
and reads self-check
- // against it. Kept in lock-step with `blockLocations` on block/rdd/BM
removal.
+ // against it. Unlike `blockChecksums`/`blockLocations`, this is a
tombstone: it survives the loss
+ // of the block's last replica (so a late divergent copy of a finalized
block is still rejected)
+ // and is reclaimed only with the RDD, in `removeRdd`.
private val sealedChecksums = new JHashMap[BlockId, Long]
+ // rddId -> its sealed block ids, letting `removeRdd` reclaim the
`sealedChecksums` tombstones by
+ // rddId without scanning the whole map.
+ private val sealedBlocksByRdd = new mutable.HashMap[Int,
mutable.HashSet[RDDBlockId]]
+
// Mapping from task id to the set of rdd blocks which are generated from
the task.
private val tidToRddBlockIds = new mutable.HashMap[Long,
mutable.HashSet[RDDBlockId]]
// Record the RDD blocks which are not visible yet, a block will be removed
from this collection
@@ -376,7 +382,6 @@ class BlockManagerMasterEndpoint(
blocks.foreach { blockId =>
val bms: mutable.HashSet[BlockManagerId] = blockLocations.remove(blockId)
blockChecksums.remove(blockId)
- sealedChecksums.remove(blockId)
if (trackingCacheVisibility) {
invisibleRDDBlocks.remove(blockId)
}
@@ -401,6 +406,10 @@ class BlockManagerMasterEndpoint(
}
}
}
+
+ // Reclaim this RDD's sealed checksums via the index.
+ sealedBlocksByRdd.remove(rddId).foreach(_.foreach(sealedChecksums.remove))
+
val removeRddFromExecutorsFutures = blockManagerInfo.values.map { bmInfo =>
bmInfo.storageEndpoint.ask[Int](removeMsg).recover {
// use 0 as default value means no blocks were removed
@@ -542,7 +551,7 @@ class BlockManagerMasterEndpoint(
if (locations.isEmpty) {
blockLocations.remove(blockId)
blockChecksums.remove(blockId)
- sealedChecksums.remove(blockId)
+ // `sealedChecksums` deliberately kept (tombstone; see its def).
logWarning(log"No more replicas available for ${MDC(BLOCK_ID,
blockId)}!")
} else if (proactivelyReplicate && (blockId.isRDD ||
blockId.isInstanceOf[TestBlockId])) {
// As a heuristic, assume single executor failure to find out the
number of replicas that
@@ -907,7 +916,7 @@ class BlockManagerMasterEndpoint(
if (locations.isEmpty) {
blockLocations.remove(blockId)
blockChecksums.remove(blockId)
- sealedChecksums.remove(blockId)
+ // `sealedChecksums` deliberately kept (tombstone; see its def).
}
true
}
@@ -959,6 +968,7 @@ class BlockManagerMasterEndpoint(
// because a lost local-checkpoint block cannot be recomputed.
val winner = perReplica.values.groupBy(identity).maxBy(_._2.size)._1
sealedChecksums.put(blockId, winner)
+ sealedBlocksByRdd.getOrElseUpdate(rddId, new
mutable.HashSet[RDDBlockId]) += blockId
val losers = perReplica.collect { case (bmId, c) if c != winner =>
bmId }.toSeq
losers.foreach { bmId =>
perReplica.remove(bmId)
diff --git
a/core/src/test/scala/org/apache/spark/storage/BlockManagerSuite.scala
b/core/src/test/scala/org/apache/spark/storage/BlockManagerSuite.scala
index 2241e3ee5a92..e2e2552270f7 100644
--- a/core/src/test/scala/org/apache/spark/storage/BlockManagerSuite.scala
+++ b/core/src/test/scala/org/apache/spark/storage/BlockManagerSuite.scala
@@ -2580,6 +2580,63 @@ class BlockManagerSuite extends SparkFunSuite with
Matchers with PrivateMethodTe
assert(master.getSealedChecksum(blockId).isEmpty)
}
+ test("sealed checksum tombstone rejects a divergent late copy after the last
replica is lost") {
+ val store1 = makeBlockManager(20000, "exec1")
+ val store2 = makeBlockManager(20000, "exec2")
+ val blockId = RDDBlockId(20, 0)
+ // store1 materializes and the block is sealed to checksum 42.
+ master.updateBlockInfo(
+ store1.blockManagerId, blockId, StorageLevel.DISK_ONLY, 0, 100,
Some(42L))
+ assert(master.sealRddChecksums(20) === 0)
+ assert(master.getSealedChecksum(blockId) === Some(42L))
+ // store1's only replica is lost (invalid storage level empties its
locations).
+ master.updateBlockInfo(store1.blockManagerId, blockId, StorageLevel.NONE,
0, 0, Some(42L))
+ assert(master.getLocations(blockId).isEmpty)
+ // The seal is a tombstone: it survives the loss of the last replica.
+ assert(master.getSealedChecksum(blockId) === Some(42L))
+ // A late divergent copy (e.g. a zombie task) is still rejected by the
tombstone.
+ master.updateBlockInfo(
+ store2.blockManagerId, blockId, StorageLevel.DISK_ONLY, 0, 100,
Some(99L))
+ assert(!master.getLocations(blockId).contains(store2.blockManagerId))
+ assert(master.getSealedChecksum(blockId) === Some(42L))
+ // A late copy matching the seal is admitted.
+ master.updateBlockInfo(
+ store2.blockManagerId, blockId, StorageLevel.DISK_ONLY, 0, 100,
Some(42L))
+ assert(master.getLocations(blockId).contains(store2.blockManagerId))
+ }
+
+ test("sealed checksum tombstone survives losing the last replica via
executor removal") {
+ val store1 = makeBlockManager(20000, "exec1")
+ val store2 = makeBlockManager(20000, "exec2")
+ val blockId = RDDBlockId(21, 0)
+ master.updateBlockInfo(
+ store1.blockManagerId, blockId, StorageLevel.DISK_ONLY, 0, 100,
Some(42L))
+ assert(master.sealRddChecksums(21) === 0)
+ // The only replica's executor dies (removeBlockManager empties the
block's locations).
+ master.removeExecutor(store1.blockManagerId.executorId)
+ assert(master.getLocations(blockId).isEmpty)
+ assert(master.getSealedChecksum(blockId) === Some(42L))
+ // A divergent late copy is still rejected.
+ master.updateBlockInfo(
+ store2.blockManagerId, blockId, StorageLevel.DISK_ONLY, 0, 100,
Some(99L))
+ assert(!master.getLocations(blockId).contains(store2.blockManagerId))
+ }
+
+ test("removeRdd reclaims a sealed checksum tombstone whose last replica was
lost") {
+ val store = makeBlockManager(20000, "exec1")
+ val blockId = RDDBlockId(22, 0)
+ master.updateBlockInfo(
+ store.blockManagerId, blockId, StorageLevel.DISK_ONLY, 0, 100, Some(42L))
+ assert(master.sealRddChecksums(22) === 0)
+ // Lose the last replica; the tombstone remains even though the block has
no location.
+ master.updateBlockInfo(store.blockManagerId, blockId, StorageLevel.NONE,
0, 0, Some(42L))
+ assert(master.getLocations(blockId).isEmpty)
+ assert(master.getSealedChecksum(blockId) === Some(42L))
+ // removeRdd reclaims the tombstone via the sealedBlocksByRdd index (not
blockLocations).
+ master.removeRdd(22, blocking = true)
+ assert(master.getSealedChecksum(blockId).isEmpty)
+ }
+
test("local-checkpoint verification: agreeing disk and _SER replicas survive
the seal") {
val diskStore = makeBlockManager(20000, "exec1")
val serStore = makeBlockManager(20000, "exec2")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]