LuciferYang commented on code in PR #58167:
URL: https://github.com/apache/spark/pull/58167#discussion_r4237061116
##########
core/src/main/scala/org/apache/spark/rdd/ReliableCheckpointRDD.scala:
##########
@@ -170,6 +219,7 @@ private[spark] object ReliableCheckpointRDD extends Logging
{
if (originalRDD.partitioner.nonEmpty) {
writePartitionerToCheckpointDir(sc, originalRDD.partitioner.get,
checkpointDirPath)
}
+ writePartitionCountToCheckpointDir(sc, originalRDD.partitions.length,
checkpointDirPath)
Review Comment:
The count written at `:222` is `originalRDD.partitions.length`, and the
read-back at `:230` runs the new check in `getPartitions` first against that
same number. So on the write path the new `MISSING_FILES` pre-empts
`CHECKPOINT_RDD_PARTITION_COUNT_MISMATCH` at `:231`, which is now reachable
only when `_num_partitions` was not written or not read back, or the check is
disabled. The usual way to hit this is a cluster-mode checkpoint dir on local
disk. That case used to get the SPARK-58770 hint that the directory is not on
storage both the driver and the executors can reach; now it says trailing files
may have been lost and suggests turning the flag off. Doing so only brings back
the old error at `:231`, and leaves detection off.
Moving `:222` after the check at `:230` fixes this: record N only once the
directory is known to hold N part files, so the read-back finds no
`_num_partitions` and skips the new check. A `LocalFileSystem` subclass whose
`listStatus` omits the last part file can test it by asserting the old error
condition.
##########
core/src/main/scala/org/apache/spark/rdd/ReliableCheckpointRDD.scala:
##########
@@ -83,6 +89,21 @@ private[spark] class ReliableCheckpointRDD[T: ClassTag](
throw SparkCoreErrors.invalidCheckpointDirectoryError(path,
expectedFileName)
}
}
+ // If a partition-count metadata file is present and the check is enabled,
verify the
+ // recorded count against the files found: fewer means trailing files were
lost, more means
+ // files were added that the checkpoint did not write. A missing file
(older Spark version
+ // or failed best-effort write) is tolerated. Set
+ // spark.checkpoint.verifyPartitionCount.enabled=false to skip only this
comparison when
+ // the directory must be loaded as-is. See SPARK-58883.
+ if (context.conf.get(CHECKPOINT_VERIFY_PARTITION_COUNT_ENABLED)) {
Review Comment:
The new flag does not take effect on DStream recovery, which is the only
production caller of `checkpointFile`. On `getOrCreate`, the SparkContext is
built from `Checkpoint.createSparkConf()`, which starts from the conf saved in
the checkpoint and re-reads only a fixed set of keys, `propertiesToReload`,
from the current environment. I checked this in spark-shell on 4.1.1 (that code
is unchanged since): restarting with `--conf
spark.checkpoint.verifyPartitionCount.enabled=false` leaves the key unset in
the recovered conf, while `spark.master`, which is on that list, picks up the
new value. So an operator who follows the error message and restarts with the
flag off gets the same error again.
Adding `CHECKPOINT_VERIFY_PARTITION_COUNT_ENABLED.key` to
`propertiesToReload` in `Checkpoint.scala` would fix it; `UI_FILTERS.key` in
that list already references a core config the same way. The streaming
`CheckpointSuite` test "recovery of conf through checkpoints" already drives
`createSparkConf()` through system properties, so one more case there would pin
it.
##########
core/src/test/scala/org/apache/spark/CheckpointSuite.scala:
##########
@@ -771,6 +775,313 @@ 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.
+ val recoveredRDD = sc.checkpointFile[Int](rdd.getCheckpointFile.get)
+ checkError(
+ exception = intercept[SparkException](recoveredRDD.partitions),
+ condition =
"CHECKPOINT_DIRECTORY_PARTITION_COUNT_MISMATCH.MISSING_FILES",
+ sqlState = Some("58030"),
+ parameters = Map(
+ "path" -> rdd.getCheckpointFile.get,
+ "expected" -> "4",
+ "found" -> "3"))
+
+ // With the config disabled, the same truncated directory should load
silently.
+ sc.conf.set(CHECKPOINT_VERIFY_PARTITION_COUNT_ENABLED, false)
+ try {
+ val suppressedRDD = sc.checkpointFile[Int](rdd.getCheckpointFile.get)
+ assert(suppressedRDD.partitions.length === 3)
+ } finally {
+ sc.conf.set(CHECKPOINT_VERIFY_PARTITION_COUNT_ENABLED, true)
+ }
+ }
+ }
+
+ test("SPARK-58883: reading a checkpoint directory with extra partition files
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)
+
+ // Add a contiguous part-00004 that the checkpoint never wrote. The
contiguity check
+ // still passes, so only the recorded count can reject it, and the
mismatch must be
+ // reported as extra files rather than as truncation. A regression from
!= to < in the
+ // comparison would leave this directory accepted with 5 partitions.
+ val extraPartFile = new Path(checkpointPath, "part-00004")
+ assert(!fs.exists(extraPartFile), "expected part-00004 not to exist
before the copy")
+ writeAllBytes(fs, extraPartFile, readAllBytes(fs, new
Path(checkpointPath, "part-00003")))
+
+ val recoveredRDD = sc.checkpointFile[Int](rdd.getCheckpointFile.get)
+ checkError(
+ exception = intercept[SparkException](recoveredRDD.partitions),
+ condition =
"CHECKPOINT_DIRECTORY_PARTITION_COUNT_MISMATCH.UNEXPECTED_FILES",
+ sqlState = Some("58030"),
+ parameters = Map(
+ "path" -> rdd.getCheckpointFile.get,
+ "expected" -> "4",
+ "found" -> "5"))
+
+ // With the config disabled, all 5 part files load as-is.
+ sc.conf.set(CHECKPOINT_VERIFY_PARTITION_COUNT_ENABLED, false)
+ try {
+ val suppressedRDD = sc.checkpointFile[Int](rdd.getCheckpointFile.get)
+ assert(suppressedRDD.partitions.length === 5)
+ } finally {
+ sc.conf.set(CHECKPOINT_VERIFY_PARTITION_COUNT_ENABLED, true)
+ }
+ }
+ }
+
+ 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")
+ assert(fs.exists(countFile), "expected _num_partitions to exist before
deletion")
+ fs.delete(countFile, false)
+
+ // Must recover normally with all 4 partitions and correct data.
+ val recovered = sc.checkpointFile[Int](rdd.getCheckpointFile.get)
+ assert(recovered.partitions.length === 4)
Review Comment:
`:862` is the backward-compatibility test for directories without the file,
but it only checks the partition count and the data. If the
`FileNotFoundException` case at `ReliableCheckpointRDD.scala:445` is removed,
the exception falls into the `NonFatal` case at `:449`, the reader still
returns `None`, and the test stays green, while every read of a pre-upgrade
directory logs a "truncation detection will be inactive" WARN with a stack
trace.
Wrapping the read in `withLogAppender` and asserting that WARN is absent
would pin it.
##########
core/src/main/scala/org/apache/spark/rdd/ReliableCheckpointRDD.scala:
##########
@@ -280,6 +330,128 @@ 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.
+ * The file is written atomically (temp path then rename) so a torn write
leaves no partial
+ * file, and the temp file is removed if publication fails at any point
after it is created;
+ * the payload layout is documented at [[PARTITION_COUNT_FORMAT_VERSION]].
+ * 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 tmpFilePath = new Path(
+ checkpointDirPath, s".${checkpointPartitionCountFileName()}-tmp")
+ val bufferSize = sc.conf.get(BUFFER_SIZE)
+ val fs = countFilePath.getFileSystem(sc.hadoopConfiguration)
+ // A cleanup failure must not mask the publication failure being
handled, so it is only
+ // logged, whether reported by a false return value or by an exception.
+ def deleteTempFile(): Unit = {
+ try {
+ if (!fs.delete(tmpFilePath, false)) {
+ logWarning(log"Failed to delete ${MDC(TEMP_OUTPUT_PATH,
tmpFilePath)}, " +
+ log"it may be left behind in the checkpoint directory")
+ }
+ } catch {
+ case NonFatal(e) =>
+ logWarning(log"Failed to delete ${MDC(TEMP_OUTPUT_PATH,
tmpFilePath)}, " +
+ log"it may be left behind in the checkpoint directory", e)
+ }
+ }
+ // Write to a temp path first so readers never see a partial file.
+ val fileOutputStream = fs.create(tmpFilePath, true, bufferSize)
+ // From here on the temp file exists and must not be left behind on any
failure.
+ val renamed = try {
+ val dos = new DataOutputStream(fileOutputStream)
+ Utils.tryWithSafeFinally {
+ val header = partitionCountHeader(partitionCount)
+ dos.write(header)
+ dos.writeLong(partitionCountChecksum(header))
+ } {
+ dos.close()
+ }
+ fs.rename(tmpFilePath, countFilePath)
Review Comment:
The reader accepts exactly 13 bytes, so any prefix left by a torn write
fails in `readFully` or `readLong` with `EOFException` and ends up where a
missing file does: detection is off for that directory. Temp-then-rename only
changes whether that read logs a WARN or a debug line. I asked for it in the
first round together with the fixed length, but the length alone already
exposes a torn write.
Since you asked whether to trim things back, this is the part I would trim:
write `_num_partitions` directly and keep the current WARN on failure, which
removes `deleteTempFile`, the inner try, the `renamed` branch, and the three
rename/cleanup test cases with their FileSystem subclasses. The two remaining
injection filesystems would then match `_num_partitions` instead of the temp
name, and the write-failure case would assert that the reader rejects the
partial file. Not blocking.
--
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]