LuciferYang commented on code in PR #58167:
URL: https://github.com/apache/spark/pull/58167#discussion_r3860940006
##########
core/src/main/scala/org/apache/spark/rdd/ReliableCheckpointRDD.scala:
##########
@@ -83,6 +85,16 @@ private[spark] class ReliableCheckpointRDD[T: ClassTag](
throw SparkCoreErrors.invalidCheckpointDirectoryError(path,
expectedFileName)
}
}
+ // If a partition-count metadata file is present, verify no trailing files
are missing.
+ // Directories written by earlier Spark versions have no such file; a
missing file is
+ // silently tolerated for backward compatibility. See SPARK-58883.
+ ReliableCheckpointRDD.readPartitionCountFromCheckpointDir(context,
checkpointPath)
+ .foreach { expected =>
+ if (inputFiles.length != expected) {
Review Comment:
Writing and reading `_num_partitions` both tolerate any failure (`:312`,
`:346`), but acting on it (`:93`) is fatal and consults no config, and the diff
does not touch `internal/config`. The analogous local-checkpoint integrity
feature in this same release ships behind
`spark.checkpoint.local.verifyChecksum.enabled`, an `.internal()` flag
defaulting to true.
There is no API-level way around the throw either; neither version of
`checkpointFile` is public. What is left is deleting `_num_partitions` from
storage, and since the error names no directory, that means deleting it from
every `rdd-*`, which turns the check off wholesale. Until someone does that, a
streaming app fails on every restart. Gating this comparison the same way would
cover it.
##########
core/src/main/scala/org/apache/spark/rdd/ReliableCheckpointRDD.scala:
##########
@@ -268,6 +285,70 @@ private[spark] object ReliableCheckpointRDD extends
Logging {
}
}
+ /**
+ * Write the partition count of the checkpointed RDD to the checkpoint
directory so that
+ * a later read via [[SparkContext.checkpointFile]] can detect a truncated
directory.
+ * This is done on a best-effort basis; any exception is caught, logged and
ignored so that
+ * an inability to write the file does not prevent checkpointing. See
SPARK-58883.
+ */
+ private def writePartitionCountToCheckpointDir(
+ sc: SparkContext, partitionCount: Int, checkpointDirPath: Path): Unit = {
+ try {
+ val countFilePath = new Path(checkpointDirPath,
checkpointPartitionCountFileName())
+ val bufferSize = sc.conf.get(BUFFER_SIZE)
+ val fs = countFilePath.getFileSystem(sc.hadoopConfiguration)
+ // overwrite = false: matches _partitioner's write helper; a second
checkpoint to the
+ // same directory would fail here (caught and logged below), which is
acceptable.
+ val fileOutputStream = fs.create(countFilePath, false, bufferSize)
+ val serializer = SparkEnv.get.serializer.newInstance()
+ val serializeStream = serializer.serializeStream(fileOutputStream)
+ Utils.tryWithSafeFinally {
+ serializeStream.writeObject(partitionCount)
+ } {
+ serializeStream.close()
+ }
+ logDebug(s"Written partition count $partitionCount to $countFilePath")
+ } catch {
+ case NonFatal(e) =>
+ logWarning(log"Error writing partition count to ${MDC(PATH,
checkpointDirPath)}")
+ }
+ }
+
+ /**
+ * Read the expected partition count from the checkpoint directory metadata
file, if present.
+ * Returns [[None]] when the file is absent (checkpoint written by an older
Spark version)
+ * or unreadable, so callers must tolerate a missing value. See SPARK-58883.
+ */
+ private def readPartitionCountFromCheckpointDir(
+ sc: SparkContext, checkpointDirPath: String): Option[Int] = {
+ try {
+ val bufferSize = sc.conf.get(BUFFER_SIZE)
+ val countFilePath = new Path(checkpointDirPath,
checkpointPartitionCountFileName())
+ val fs = countFilePath.getFileSystem(sc.hadoopConfiguration)
+ val fileInputStream = fs.open(countFilePath, bufferSize)
+ val serializer = SparkEnv.get.serializer.newInstance()
+ val count = Utils.tryWithSafeFinally {
+ val deserializeStream = serializer.deserializeStream(fileInputStream)
+ Utils.tryWithSafeFinally {
+ deserializeStream.readObject[Int]()
+ } {
+ deserializeStream.close()
+ }
+ } {
+ fileInputStream.close()
+ }
+ logDebug(s"Read partition count $count from $countFilePath")
+ Some(count)
+ } catch {
+ case _: FileNotFoundException =>
+ logDebug(s"No partition count file in $checkpointDirPath (older
checkpoint)")
+ None
+ case NonFatal(e) =>
Review Comment:
`_num_partitions` is written with no atomic replace: part files go to
`.part-NNNNN-attempt-K` and are renamed (`:217`, `:246`), while this one, like
`_partitioner`, is a plain `fs.create` (`:302`, `:272`). The difference is the
consequence: losing the partitioner costs a shuffle, losing this file turns off
the check the PR just added. If the driver dies mid-write, or the storage
truncates it, the read at `:346` swallows the `NonFatal` into `None` and one
WARN is all that is left. That is the same storage this PR assumes can drop a
trailing file.
Routing the Int through `DataOutputStream.writeInt` plus a format version,
written to a temp path and renamed, makes it a fixed 8 bytes whose length shows
a torn write.
##########
core/src/main/scala/org/apache/spark/rdd/ReliableCheckpointRDD.scala:
##########
@@ -268,6 +285,70 @@ private[spark] object ReliableCheckpointRDD extends
Logging {
}
}
+ /**
+ * Write the partition count of the checkpointed RDD to the checkpoint
directory so that
+ * a later read via [[SparkContext.checkpointFile]] can detect a truncated
directory.
+ * This is done on a best-effort basis; any exception is caught, logged and
ignored so that
+ * an inability to write the file does not prevent checkpointing. See
SPARK-58883.
+ */
+ private def writePartitionCountToCheckpointDir(
+ sc: SparkContext, partitionCount: Int, checkpointDirPath: Path): Unit = {
+ try {
+ val countFilePath = new Path(checkpointDirPath,
checkpointPartitionCountFileName())
+ val bufferSize = sc.conf.get(BUFFER_SIZE)
+ val fs = countFilePath.getFileSystem(sc.hadoopConfiguration)
+ // overwrite = false: matches _partitioner's write helper; a second
checkpoint to the
+ // same directory would fail here (caught and logged below), which is
acceptable.
+ val fileOutputStream = fs.create(countFilePath, false, bufferSize)
+ val serializer = SparkEnv.get.serializer.newInstance()
+ val serializeStream = serializer.serializeStream(fileOutputStream)
+ Utils.tryWithSafeFinally {
+ serializeStream.writeObject(partitionCount)
+ } {
+ serializeStream.close()
+ }
+ logDebug(s"Written partition count $partitionCount to $countFilePath")
+ } catch {
+ case NonFatal(e) =>
+ logWarning(log"Error writing partition count to ${MDC(PATH,
checkpointDirPath)}")
Review Comment:
The catch in `writePartitionCountToCheckpointDir` never uses `e` (`:312`),
so a failed write leaves one WARN with no cause. The read side treats a missing
`_num_partitions` as a directory written by an older Spark (`:343`), so once
the file fails to appear, truncation detection stays off for that directory
while `rdd.checkpoint()` still returns success.
The minimum is to pass `e` to `logWarning`, add the part-file count found in
the directory, and say in the scaladoc that a failed write leaves the check
inactive. Letting the write throw is the stronger option, but it is not free:
`doCheckpoint()` runs after `dagScheduler.runJob` has returned, so throwing
fails an action whose result is already computed, and `cpState` stays at
`CheckpointingInProgress`.
##########
core/src/main/scala/org/apache/spark/rdd/ReliableCheckpointRDD.scala:
##########
@@ -83,6 +85,16 @@ private[spark] class ReliableCheckpointRDD[T: ClassTag](
throw SparkCoreErrors.invalidCheckpointDirectoryError(path,
expectedFileName)
}
}
+ // If a partition-count metadata file is present, verify no trailing files
are missing.
+ // Directories written by earlier Spark versions have no such file; a
missing file is
+ // silently tolerated for backward compatibility. See SPARK-58883.
+ ReliableCheckpointRDD.readPartitionCountFromCheckpointDir(context,
checkpointPath)
+ .foreach { expected =>
+ if (inputFiles.length != expected) {
+ throw
SparkCoreErrors.checkpointRDDHasDifferentNumberOfPartitionsFromOriginalRDDError(
+ id, expected, id, inputFiles.length)
Review Comment:
`:95` passes the same `id` into both RDD-id placeholders of the template, so
the message reads `The checkpoint of RDD 4 ... The checkpoint RDD is 4` and the
original RDD's id is gone, while `:199` passes `originalRDD.id` and
`newRDD.id`. The second message line still carries the diagnosis hint, so it is
not misleading, but neither variant names the checkpoint directory.
The write path goes through the new check too: `:93` and `:197` test the
same condition, so the new one throws first and the old one is only reachable
when `_num_partitions` could not be written or read back. This mismatch should
get its own error condition carrying the directory path and the two counts; the
neighboring `INVALID_CHECKPOINT_DIRECTORY` (`:85`) already carries `path`.
##########
core/src/main/scala/org/apache/spark/rdd/ReliableCheckpointRDD.scala:
##########
@@ -268,6 +285,70 @@ private[spark] object ReliableCheckpointRDD extends
Logging {
}
}
+ /**
+ * Write the partition count of the checkpointed RDD to the checkpoint
directory so that
+ * a later read via [[SparkContext.checkpointFile]] can detect a truncated
directory.
+ * This is done on a best-effort basis; any exception is caught, logged and
ignored so that
+ * an inability to write the file does not prevent checkpointing. See
SPARK-58883.
+ */
+ private def writePartitionCountToCheckpointDir(
+ sc: SparkContext, partitionCount: Int, checkpointDirPath: Path): Unit = {
+ try {
+ val countFilePath = new Path(checkpointDirPath,
checkpointPartitionCountFileName())
+ val bufferSize = sc.conf.get(BUFFER_SIZE)
+ val fs = countFilePath.getFileSystem(sc.hadoopConfiguration)
+ // overwrite = false: matches _partitioner's write helper; a second
checkpoint to the
+ // same directory would fail here (caught and logged below), which is
acceptable.
+ val fileOutputStream = fs.create(countFilePath, false, bufferSize)
Review Comment:
The two comment lines above `:302` justify `overwrite = false` with "a
second checkpoint to the same directory would fail here", but that case is not
reachable. The directory is `<dir>/<UUID>/rdd-<rddId>`, the UUID changes on
every `setCheckpointDir`, rddId is unique within a SparkContext,
`writeRDDToCheckpointDirectory` has exactly one call site
(`ReliableRDDCheckpointData.scala:61`), and `RDDCheckpointData.checkpoint()`
gates on `cpState` so it runs once.
If it were reachable the conclusion would invert too: `overwrite = false`
keeps the first value, so every later read compares a stale count against a new
directory and throws a mismatch as soon as the two disagree. "Keeps this
consistent with `_partitioner`" is all the comment needs to say.
##########
core/src/test/scala/org/apache/spark/CheckpointSuite.scala:
##########
@@ -739,6 +739,92 @@ class CheckpointStorageSuite extends SparkFunSuite with
LocalSparkContext {
parameters = Map("path" -> rddPath.toString))
}
}
+
+ // SPARK-58883: truncated checkpoint directory (trailing part-* file
deleted) should be
+ // detected when reading back via SparkContext.checkpointFile.
+ test("SPARK-58883: reading a truncated checkpoint directory throws an
error") {
+ withTempDir { checkpointDir =>
+ val conf = new SparkConf().set(UI_ENABLED.key, "false")
+ sc = new SparkContext("local", "test", conf)
+ sc.setCheckpointDir(checkpointDir.toString)
+ val rdd = sc.makeRDD(1 to 20, numSlices = 4)
+ rdd.checkpoint()
+ rdd.collect()
+
+ val checkpointPath = new Path(rdd.getCheckpointFile.get)
+ val fs = checkpointPath.getFileSystem(sc.hadoopConfiguration)
+
+ // Delete the last partition file; the remaining files are still
contiguous so the
+ // old contiguity check would pass silently and return a 3-partition RDD.
+ val lastPartFile = new Path(checkpointPath, "part-00003")
+ assert(fs.exists(lastPartFile), "expected part-00003 to exist before
deletion")
+ fs.delete(lastPartFile, false)
+
+ // Reading back should now throw because _num_partitions records the
original count.
+ // The ReliableCheckpointRDD has no separate "original" RDD on the read
path, so
+ // its own id is used for both RDD id fields.
+ val recoveredRDD = sc.checkpointFile[Int](rdd.getCheckpointFile.get)
+ checkError(
+ exception = intercept[SparkException](recoveredRDD.partitions),
+ condition = "CHECKPOINT_RDD_PARTITION_COUNT_MISMATCH",
+ sqlState = Some("58030"),
+ parameters = Map(
+ "originalRDDId" -> recoveredRDD.id.toString,
+ "originalRDDLength" -> "4",
+ "newRDDId" -> recoveredRDD.id.toString,
+ "newRDDLength" -> "3"))
+ }
+ }
+
+ test("SPARK-58883: checkpoint directory without _num_partitions is read
without error") {
+ // Backward compatibility: a checkpoint written before SPARK-58883 has no
_num_partitions
+ // file. Removing it must not prevent the RDD from being read.
+ withTempDir { checkpointDir =>
+ val conf = new SparkConf().set(UI_ENABLED.key, "false")
+ sc = new SparkContext("local", "test", conf)
+ sc.setCheckpointDir(checkpointDir.toString)
+ val rdd = sc.makeRDD(1 to 20, numSlices = 4)
+ rdd.checkpoint()
+ rdd.collect()
+
+ val checkpointPath = new Path(rdd.getCheckpointFile.get)
+ val fs = checkpointPath.getFileSystem(sc.hadoopConfiguration)
+
+ // Remove the metadata file to simulate a pre-SPARK-58883 checkpoint.
+ val countFile = new Path(checkpointPath, "_num_partitions")
+ fs.delete(countFile, false)
Review Comment:
Delete the write at `ReliableCheckpointRDD.scala:189` and only the first of
the three tests goes red. Test 2 deletes `_num_partitions` (`:795`) and test 3
overwrites it (`:818`), neither asserting the file exists first, and
`fs.delete` returning false is ignored while `fs.create(path, true)` on a
missing path is silent. So those two show that reading works, not the backward
compatibility and corruption tolerance their comments claim.
Test 1 does guard its precondition (`:760`), and so does an older test in
the same file (`:707`). One `assert(fs.exists(countFile))` in each of tests 2
and 3 covers it, and asserting the WARN in test 3 with `withLogAppender` is
what separates "corruption tolerated" from "the check never ran".
--
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]