dongjoon-hyun commented on code in PR #59328:
URL: https://github.com/apache/spark/pull/59328#discussion_r4235443272
##########
core/src/main/scala/org/apache/spark/deploy/history/EventLogFileCompactor.scala:
##########
@@ -111,21 +125,29 @@ class EventLogFileCompactor(
/**
* Loads all available EventFilterBuilders in classloader via ServiceLoader,
and initializes
* them via replaying events in given files.
+ *
+ * Returns None if replay skips any line over the line-length limit. The
builders have not seen
+ * the skipped events (e.g. the start of a live job), so filters created
from them could reject
+ * events that are still needed, and those events would be lost once the
original files are
+ * removed.
*/
- private def initializeBuilders(fs: FileSystem, files: Seq[Path]):
Seq[EventFilterBuilder] = {
+ private def initializeBuilders(
+ fs: FileSystem,
+ files: Seq[Path]): Option[Seq[EventFilterBuilder]] = {
val bus = new ReplayListenerBus(ReplayListenerBus.maxLineLength(sparkConf))
val builders = ServiceLoader.load(classOf[EventFilterBuilder],
Utils.getContextOrSparkClassLoader).asScala.toSeq
builders.foreach(bus.addListener)
- files.foreach { log =>
+ // Stop replaying once a line is skipped, as compaction will not proceed.
+ files.iterator.takeWhile(_ => bus.numSkippedLines == 0).foreach { log =>
Utils.tryWithResource(EventLogFileReader.openEventLog(log, fs)) { in =>
bus.replay(in, log.getName)
Review Comment:
The return value of `bus.replay(...)` is ignored here. `replayEntries`
catches generic exceptions and returns `false` without replaying the rest of
the file. For example, a line that is valid JSON but fails in
`JsonProtocol.sparkEventFromJson` because a field is missing can cause this. If
a live job's `SparkListenerJobStart` comes after that line in the same file,
the builders never see it. `numSkippedLines` is still `0`, so compaction goes
ahead: `rewrite` rejects the live job's events, and the original files are
deleted. That is the same data loss this PR fixes for over-long lines.
This was already the case before this PR, but should the check cover any
incomplete replay instead of only skipped lines? For example:
```scala
val complete = files.forall { log =>
Utils.tryWithResource(EventLogFileReader.openEventLog(log, fs)) { in =>
bus.replay(in, log.getName)
} && bus.numSkippedLines == 0
}
if (complete) Some(builders) else None
```
This would also stop at the first incomplete file, and `forall` would
replace `takeWhile`.
##########
core/src/main/scala/org/apache/spark/deploy/history/EventLogFileCompactor.scala:
##########
@@ -84,18 +87,29 @@ class EventLogFileCompactor(
if (filesToCompact.isEmpty) {
CompactionResult(CompactionResultCode.NOT_ENOUGH_FILES, None)
} else {
- val builders = initializeBuilders(fs, filesToCompact.map(_.getPath))
-
- val filters = builders.map(_.createFilter())
- val minScore = filters.flatMap(_.statistics()).map(calculateScore).min
-
- if (minScore < compactionThresholdScore) {
- CompactionResult(CompactionResultCode.LOW_SCORE_FOR_COMPACTION, None)
- } else {
- rewrite(filters, filesToCompact)
- cleanupCompactedFiles(filesToCompact)
- CompactionResult(CompactionResultCode.SUCCESS, Some(
-
RollingEventLogFilesWriter.getEventLogFileIndex(filesToCompact.last.getPath.getName)))
+ initializeBuilders(fs, filesToCompact.map(_.getPath)) match {
+ case None =>
Review Comment:
Once an over-long line is in the range to compact, compaction of this
application never runs again. The range always starts from the first (or
compact) file, so the over-long line stays in it. For a long-running streaming
application (the main use case of rolling event logs and compaction), this
means:
- `spark.history.fs.eventLog.rolling.maxFilesToRetain` is silently not
enforced, and the rolled files grow without bound. The application UI also has
to replay all of them.
- Each new rolled file replays the same files again and logs two WARN
messages (one from `ReplayListenerBus` and this one).
- `FsHistoryProvider.compact` ignores the result code, so the listing does
not show that the application is in this state.
This might be an acceptable trade-off, but could we at least say in the
description and docs that it is permanent for the application? Another option
is to compact the files before the first file with a skipped line, so that the
files before it are still compacted.
##########
core/src/main/scala/org/apache/spark/deploy/history/EventLogFileCompactor.scala:
##########
@@ -84,18 +87,29 @@ class EventLogFileCompactor(
if (filesToCompact.isEmpty) {
CompactionResult(CompactionResultCode.NOT_ENOUGH_FILES, None)
} else {
- val builders = initializeBuilders(fs, filesToCompact.map(_.getPath))
-
- val filters = builders.map(_.createFilter())
- val minScore = filters.flatMap(_.statistics()).map(calculateScore).min
-
- if (minScore < compactionThresholdScore) {
- CompactionResult(CompactionResultCode.LOW_SCORE_FOR_COMPACTION, None)
- } else {
- rewrite(filters, filesToCompact)
- cleanupCompactedFiles(filesToCompact)
- CompactionResult(CompactionResultCode.SUCCESS, Some(
-
RollingEventLogFilesWriter.getEventLogFileIndex(filesToCompact.last.getPath.getName)))
+ initializeBuilders(fs, filesToCompact.map(_.getPath)) match {
+ case None =>
+ logWarning(log"Skipping compaction of event log files in " +
+ log"${MDC(LogKeys.PATH, filesToCompact.head.getPath.getParent)}:
some lines exceed " +
+ log"${MDC(LogKeys.CONFIG, History.EVENT_LOG_MAX_LINE_LENGTH.key)}
" +
+ log"(${MDC(LogKeys.MAX_SIZE,
ReplayListenerBus.maxLineLength(sparkConf))} bytes). " +
+ log"Increase the limit to compact these files.")
+ CompactionResult(CompactionResultCode.LINES_OVER_LENGTH_LIMIT, None)
Review Comment:
The PR description says that no released version is affected. However,
SPARK-59407 is already in the `v4.0.5-rc1`, `v4.1.4-rc2` and `v4.2.1-rc1` tags,
and the config doc also lists 3.5.10. If those RCs are released without this
fix, the data loss will be in released versions. Could you backport this to
`branch-4.2`, `branch-4.1`, `branch-4.0` and `branch-3.5` (and
`branch-4.x`/`branch-4.3`) before the RCs are released?
##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -50,6 +50,11 @@ private[spark] class ReplayListenerBus(
private[scheduler] val effectiveMaxLineLength =
ReplayListenerBus.normalizeMaxLineLength(maxLineLength)
+ private var skippedLines = 0L
+
+ /** Number of over-long lines skipped so far by [[replay()]] calls on an
[[InputStream]]. */
+ private[spark] def numSkippedLines: Long = skippedLines
Review Comment:
nit. `numSkippedLines` only counts lines skipped by `replay(InputStream,
...)`. The public `replay(Iterator[String], ...)` overload neither applies the
limit nor updates the counter. So a caller that replays through that overload
and checks `numSkippedLines == 0`, as the compactor does, cannot tell "nothing
skipped" from "not tracked". The scaladoc mentions this, but it is easy to
miss. Should the doc say clearly that the value is always `0` for the
`Iterator[String]` overload?
##########
core/src/main/scala/org/apache/spark/deploy/history/EventLogFileCompactor.scala:
##########
@@ -68,6 +69,8 @@ class EventLogFileCompactor(
* This method skips compaction for some circumstances described below:
* - not enough files on the range of compaction
* - score is lower than the threshold of compaction (meaning compaction
won't help much)
+ * - some lines exceed the event log line-length limit, so replay skips them
and the filters
Review Comment:
This is a user-facing change, but `docs/monitoring.md` is not updated.
Neither the "Applying compaction on rolling event log files" section nor the
`spark.history.fs.eventLog.maxLineLength` entry mentions that a line over the
limit stops compaction of that application. Could we add a sentence to both?
Operators who lower `maxLineLength` to bound History Server memory would need
to know about this.
##########
core/src/main/scala/org/apache/spark/deploy/history/EventLogFileCompactor.scala:
##########
@@ -111,21 +125,29 @@ class EventLogFileCompactor(
/**
* Loads all available EventFilterBuilders in classloader via ServiceLoader,
and initializes
* them via replaying events in given files.
+ *
+ * Returns None if replay skips any line over the line-length limit. The
builders have not seen
+ * the skipped events (e.g. the start of a live job), so filters created
from them could reject
+ * events that are still needed, and those events would be lost once the
original files are
+ * removed.
*/
- private def initializeBuilders(fs: FileSystem, files: Seq[Path]):
Seq[EventFilterBuilder] = {
+ private def initializeBuilders(
+ fs: FileSystem,
+ files: Seq[Path]): Option[Seq[EventFilterBuilder]] = {
val bus = new ReplayListenerBus(ReplayListenerBus.maxLineLength(sparkConf))
val builders = ServiceLoader.load(classOf[EventFilterBuilder],
Utils.getContextOrSparkClassLoader).asScala.toSeq
builders.foreach(bus.addListener)
- files.foreach { log =>
+ // Stop replaying once a line is skipped, as compaction will not proceed.
+ files.iterator.takeWhile(_ => bus.numSkippedLines == 0).foreach { log =>
Review Comment:
nit. `takeWhile` checks the counter only between files. The rest of the file
with the skipped line is still decompressed, parsed and posted to every
builder, and the result is then discarded. This is repeated on every new rolled
file (see the comment on L91). Could we stop inside the file as well, or at
least say in the comment that the stop happens per file?
##########
core/src/test/scala/org/apache/spark/deploy/history/EventLogFileCompactorSuite.scala:
##########
@@ -202,6 +205,57 @@ class EventLogFileCompactorSuite extends SparkFunSuite {
}
}
+ test("SPARK-60110: Don't compact files if some lines exceed the line-length
limit") {
+ withTempDir { dir =>
+ val fs = new Path(dir.getAbsolutePath).getFileSystem(hadoopConf)
+
+ // job 1 is finished
+ val stage1 = createStage(1, createRddsWithId(1 to 2), Nil)
+ val tasks1 = createTasks(4, Array("exec1"),
0L).map(createTaskStartEvent(_, 1, 0))
+
+ // job 2 is still live, and its job start line exceeds the line-length
limit
+ val stage2 = createStage(2, createRddsWithId(3 to 4), Nil)
+ val tasks2 = createTasks(4, Array("exec1"),
4L).map(createTaskStartEvent(_, 2, 0))
+ val maxLineLength = 8 * 1024
+ val props = new Properties()
+ props.setProperty("large", "x" * maxLineLength)
+ val jobStart2 = SparkListenerJobStart(2, 0, Seq(stage2), props)
+ val stageSubmitted2 = SparkListenerStageSubmitted(stage2)
+
+ // 1~3 are candidates to compact, 4~6 are dummies to ensure max files to
retain
+ val fileStatuses = writeEventsToRollingWriter(fs, "app", dir, sparkConf,
hadoopConf,
+ Seq(SparkListenerJobStart(1, 0, Seq(stage1)),
SparkListenerStageSubmitted(stage1)) ++
+ tasks1,
+ Seq(jobStart2, stageSubmitted2) ++ tasks2,
+ Seq(SparkListenerJobEnd(1, 0, JobSucceeded)),
+ testEvent,
+ testEvent,
+ testEvent)
+
+ // Replay skips job 2's start, so the filters would treat job 2 as
finished and drop
+ // all of its events. Compaction must not proceed.
+ val limitedConf = sparkConf.clone()
+ .set(History.EVENT_LOG_MAX_LINE_LENGTH, maxLineLength.toLong)
+ val limitedCompactor = new EventLogFileCompactor(limitedConf,
hadoopConf, fs,
+ TEST_ROLLING_MAX_FILES_TO_RETAIN, TEST_COMPACTION_SCORE_THRESHOLD)
+ assertNoCompaction(fs, fileStatuses,
limitedCompactor.compact(fileStatuses),
Review Comment:
This checks the result code and that the files are unchanged. It doesn't
check that replay stops after the file with the skipped line. If the
`takeWhile` is removed, the result is still `LINES_OVER_LENGTH_LIMIT` and this
test still passes. The live `SparkListenerSQLExecutionStart` case from the PR
description is also not covered (for example in `sql/core`, next to the
existing SQL event filter tests). Could we add tests for these?
--
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]