dongjoon-hyun commented on code in PR #59327:
URL: https://github.com/apache/spark/pull/59327#discussion_r4235445164
##########
core/src/main/scala/org/apache/spark/deploy/history/FsHistoryProvider.scala:
##########
@@ -1107,24 +1106,23 @@ private[history] class FsHistoryProvider(conf:
SparkConf, clock: Clock)
Utils.tryWithResource(EventLogFileReader.openEventLog(lastFile.getPath,
fsForPath(lastFile.getPath))) { in =>
val target = lastFile.getLen - reparseChunkSize
- if (target > 0) {
+ val sourceName = if (target > 0) {
logInfo(log"Looking for end event; skipping ${MDC(NUM_BYTES,
target)} bytes" +
log" from ${MDC(PATH, logPath)}...")
var skipped = 0L
while (skipped < target) {
skipped += in.skip(target - skipped)
Review Comment:
**[1] This loop never terminates when the stream ends before `target`.**
This is pre-existing, but it is in the block this PR rewrites, and it
contradicts the PR description ("If the skip reaches the end of the stream, the
replay now sees an empty input"). `target` comes from the compressed `getLen`,
while the skip runs on the decompressed stream. If the decompressed stream is
shorter than `target`, `skip` returns 0 at EOF (e.g. `LZ4BlockInputStream.skip`
returns 0 once `finished`, and `BufferedInputStream` / `InputStream.skip`
behave the same way), so `skipped` stops growing and the scan thread spins
forever.
Examples: a small `endEventReparseChunkSize` with a tiny lz4/zstd log whose
framing overhead makes the compressed file larger than its content, or a log
replaced by a shorter file after listing. Could we stop when `skip` returns 0
and confirm EOF with `in.read() == -1`? Then the "empty input" behavior holds
in every case.
##########
core/src/main/scala/org/apache/spark/deploy/history/FsHistoryProvider.scala:
##########
@@ -1107,24 +1106,23 @@ private[history] class FsHistoryProvider(conf:
SparkConf, clock: Clock)
Utils.tryWithResource(EventLogFileReader.openEventLog(lastFile.getPath,
fsForPath(lastFile.getPath))) { in =>
val target = lastFile.getLen - reparseChunkSize
- if (target > 0) {
+ val sourceName = if (target > 0) {
logInfo(log"Looking for end event; skipping ${MDC(NUM_BYTES,
target)} bytes" +
log" from ${MDC(PATH, logPath)}...")
var skipped = 0L
while (skipped < target) {
skipped += in.skip(target - skipped)
}
+ // Because skipping may leave the stream in the middle of a line, or
even in the
+ // middle of a multi-byte UTF-8 character, discard the rest of that
line before any
+ // decoding. Line numbers reported by the replay are then relative
to this offset.
+ val offset = target + skipPartialLine(in)
Review Comment:
**[2] A line starting exactly at `target` is always discarded.**
`skipPartialLine` always throws away bytes up to the next `\n`. When
`target` lands exactly on a line start (byte `target - 1` is `\n`), it discards
a complete line, which can be the `SparkListenerApplicationEnd` line itself.
The app then misses its end event and falls back to the slower path. This is
pre-existing, but now that the code works on raw bytes it is cheap to avoid:
skip `target - 1` bytes, read one byte, and call `skipPartialLine` only if that
byte is not `\n`.
##########
core/src/main/scala/org/apache/spark/deploy/history/FsHistoryProvider.scala:
##########
@@ -1107,24 +1106,23 @@ private[history] class FsHistoryProvider(conf:
SparkConf, clock: Clock)
Utils.tryWithResource(EventLogFileReader.openEventLog(lastFile.getPath,
fsForPath(lastFile.getPath))) { in =>
val target = lastFile.getLen - reparseChunkSize
- if (target > 0) {
+ val sourceName = if (target > 0) {
logInfo(log"Looking for end event; skipping ${MDC(NUM_BYTES,
target)} bytes" +
log" from ${MDC(PATH, logPath)}...")
var skipped = 0L
while (skipped < target) {
skipped += in.skip(target - skipped)
}
+ // Because skipping may leave the stream in the middle of a line, or
even in the
+ // middle of a multi-byte UTF-8 character, discard the rest of that
line before any
+ // decoding. Line numbers reported by the replay are then relative
to this offset.
+ val offset = target + skipPartialLine(in)
+ s"${lastFile.getPath} (lines counted from uncompressed byte offset
$offset)"
Review Comment:
**[4] The offset label overloads `sourceName`.**
`sourceName` is logged under `MDC(PATH, ...)` and `MDC(FILE_NAME, ...)` in
`ReplayListenerBus`. With this change, structured logs on this path get values
like `hdfs://.../app-1 (lines counted from uncompressed byte offset 123456)` in
the path field, which no longer matches the file. Tooling that groups or links
logs by that key sees a different "path" for each offset. Could we pass the
offset to `replay` separately (or log it under its own key) and keep the path
field clean?
##########
core/src/main/scala/org/apache/spark/deploy/history/FsHistoryProvider.scala:
##########
@@ -1107,24 +1106,23 @@ private[history] class FsHistoryProvider(conf:
SparkConf, clock: Clock)
Utils.tryWithResource(EventLogFileReader.openEventLog(lastFile.getPath,
fsForPath(lastFile.getPath))) { in =>
val target = lastFile.getLen - reparseChunkSize
- if (target > 0) {
+ val sourceName = if (target > 0) {
logInfo(log"Looking for end event; skipping ${MDC(NUM_BYTES,
target)} bytes" +
log" from ${MDC(PATH, logPath)}...")
var skipped = 0L
while (skipped < target) {
skipped += in.skip(target - skipped)
}
+ // Because skipping may leave the stream in the middle of a line, or
even in the
+ // middle of a multi-byte UTF-8 character, discard the rest of that
line before any
+ // decoding. Line numbers reported by the replay are then relative
to this offset.
+ val offset = target + skipPartialLine(in)
+ s"${lastFile.getPath} (lines counted from uncompressed byte offset
$offset)"
+ } else {
+ lastFile.getPath.toString
}
- val source = Source.fromInputStream(in)(Codec.UTF8).getLines()
-
- // Because skipping may leave the stream in the middle of a line, read
the next line
- // before replaying.
- if (target > 0) {
- source.next()
- }
-
- bus.replay(source, lastFile.getPath.toString, !appCompleted,
eventsFilter)
+ bus.replay(in, sourceName, !appCompleted, eventsFilter)
Review Comment:
**[3] UTF-8 safety is fixed only at the start of the tail.**
`replay(InputStream, ...)` uses the strict decoder, and a
`MalformedInputException` there is an `IOException`, which `replayEntries`
rethrows regardless of `maybeTruncated`. With
`spark.history.fs.inProgressOptimization.enabled=false`, an in-progress log
takes this path while the writer may have flushed only the first bytes of a
multi-byte character at EOF. The listing for that app then fails on this scan
even though `maybeTruncated = true` is meant to tolerate a partial last line.
Should a malformed sequence at EOF be handled like a truncated last line when
`maybeTruncated` is set? A separate JIRA is also fine, since the same applies
to the main replay path.
##########
core/src/test/scala/org/apache/spark/deploy/history/FsHistoryProviderSuite.scala:
##########
@@ -1248,6 +1248,61 @@ abstract class FsHistoryProviderSuite extends
SparkFunSuite with Matchers with P
}
}
+ test("end event reparse when the skip offset lands inside a multi-byte
character") {
+ val log = newLogFile("end-event-utf8", None, inProgress = false)
+ val bytes = writeRawLog(log, endEventTestLines("end-event-utf8",
+ jobStartLine(1, 0x4e2d.toChar.toString * 200)))
+ // The job description is written as raw UTF-8: skip to the second byte of
a character.
+ val leadByte = bytes.indexWhere(b => (b & 0xF0) == 0xE0)
+ assert(leadByte > 0)
+ val conf = createTestConf().set(END_EVENT_REPARSE_CHUNK_SIZE,
(bytes.length - leadByte - 1L))
+ updateAndCheck(new FsHistoryProvider(conf)) { list =>
+ assert(list.size === 1)
+ assert(list(0).attempts.head.completed)
+ }
+ }
+
+ test("end event reparse applies the line length limit and reports relative
line numbers") {
+ val log = newLogFile("end-event-limit", None, inProgress = false)
+ val tail = Seq(jobStartLine(2, "x" * 2048), jobStartLine(3, "y"))
+ val lines = endEventTestLines("end-event-limit", tail: _*)
+ val bytes = writeRawLog(log, lines)
+ // Skip into the middle of the line before the tail. The next line starts
at `offset` and
+ // the over-long line is the first line after it.
+ val offset = lines.takeWhile(_ != tail.head).map(_.length + 1).sum
+ val conf = createTestConf()
+ .set(END_EVENT_REPARSE_CHUNK_SIZE, bytes.length - offset + 10L)
+ .set(EVENT_LOG_MAX_LINE_LENGTH.key, "1k")
+ val appender = new LogAppender("end event reparse")
+ withLogAppender(appender) {
+ updateAndCheck(new FsHistoryProvider(conf)) { list =>
+ assert(list.size === 1)
+ assert(list(0).attempts.head.completed)
+ }
+ }
+ val sourceName = s"${log.getName} (lines counted from uncompressed byte
offset $offset)"
+ val messages = appender.loggingEvents.map(_.getMessage.getFormattedMessage)
+ assert(messages.exists(m => m.contains("Skipped event log lines longer
than 1024 bytes") &&
+ m.contains(s"$sourceName; first skipped line: 1")), messages)
+ }
+
+ test("end event reparse labels JSON errors with the skip offset") {
+ val log = newLogFile("end-event-malformed", None, inProgress = false)
+ val malformed = "{\"Event\":\"SparkListenerApplicationEnd\",\"Timestamp\":"
+ val tail = Seq(jobStartLine(2, "x"), malformed, jobStartLine(3, "y"))
+ val lines = endEventTestLines("end-event-malformed", tail: _*)
+ val bytes = writeRawLog(log, lines)
+ val offset = lines.takeWhile(_ != tail.head).map(_.length + 1).sum
+ val conf = createTestConf().set(END_EVENT_REPARSE_CHUNK_SIZE, bytes.length
- offset + 10L)
+ val appender = new LogAppender("end event reparse")
+ withLogAppender(appender) {
+ new FsHistoryProvider(conf).checkForLogs()
Review Comment:
**[8] Only the log message is asserted here.**
This test checks the log text, but not what happens to the application
listing after the `JsonParseException` propagates. A change that silently
swallowed the malformed end event (listing the app as incomplete, or dropping
it) would still pass. Could we also assert the expected listing state, e.g. via
`provider.getListing()`?
##########
core/src/main/scala/org/apache/spark/deploy/history/FsHistoryProvider.scala:
##########
@@ -1772,6 +1770,22 @@ private[history] class FsHistoryProvider(conf:
SparkConf, clock: Clock)
private[spark] object FsHistoryProvider {
+ /**
+ * Discards bytes up to and including the next '\n', returning the number of
bytes discarded.
+ * This works on raw bytes so that it is safe to call at an arbitrary byte
offset: '\n' never
+ * occurs inside a multi-byte UTF-8 sequence. The bytes are read one at a
time so that the
+ * stream is left exactly at the start of the next line.
+ */
+ private[history] def skipPartialLine(in: InputStream): Long = {
+ var discarded = 0L
+ var b = in.read()
+ while (b != -1 && b != '\n') {
Review Comment:
**[5] One `read()` call per byte, with no bound.**
`in` is a `BufferedInputStream` (or a codec stream), so each `read()` is a
synchronized method call. If the skip lands at the start of a very long line (a
large SQL plan, or a corrupted line far above `maxLineLength`), this makes one
call per byte, and it repeats on every rescan of that log. A bulk read into a
byte array that scans for `\n` and passes the leftover bytes back in front of
the stream (e.g. `new SequenceInputStream(new ByteArrayInputStream(rest), in)`)
would avoid that while keeping the alignment exact.
##########
core/src/test/scala/org/apache/spark/deploy/history/FsHistoryProviderSuite.scala:
##########
@@ -1248,6 +1248,61 @@ abstract class FsHistoryProviderSuite extends
SparkFunSuite with Matchers with P
}
}
+ test("end event reparse when the skip offset lands inside a multi-byte
character") {
+ val log = newLogFile("end-event-utf8", None, inProgress = false)
+ val bytes = writeRawLog(log, endEventTestLines("end-event-utf8",
+ jobStartLine(1, 0x4e2d.toChar.toString * 200)))
+ // The job description is written as raw UTF-8: skip to the second byte of
a character.
+ val leadByte = bytes.indexWhere(b => (b & 0xF0) == 0xE0)
+ assert(leadByte > 0)
+ val conf = createTestConf().set(END_EVENT_REPARSE_CHUNK_SIZE,
(bytes.length - leadByte - 1L))
+ updateAndCheck(new FsHistoryProvider(conf)) { list =>
+ assert(list.size === 1)
+ assert(list(0).attempts.head.completed)
+ }
+ }
+
+ test("end event reparse applies the line length limit and reports relative
line numbers") {
+ val log = newLogFile("end-event-limit", None, inProgress = false)
+ val tail = Seq(jobStartLine(2, "x" * 2048), jobStartLine(3, "y"))
+ val lines = endEventTestLines("end-event-limit", tail: _*)
+ val bytes = writeRawLog(log, lines)
+ // Skip into the middle of the line before the tail. The next line starts
at `offset` and
+ // the over-long line is the first line after it.
+ val offset = lines.takeWhile(_ != tail.head).map(_.length + 1).sum
Review Comment:
**[6] The expected offset is computed in UTF-16 chars, not UTF-8 bytes.**
`_.length + 1` counts chars, while the production offset counts UTF-8 bytes.
This matches today only because every line before the tail is ASCII. If a line
in `endEventTestLines` ever contains non-ASCII text (for example, reusing the
CJK description from the first test), the expected offset would diverge. Using
`_.getBytes(StandardCharsets.UTF_8).length + 1` here and at L1295 would match
what is being verified.
##########
core/src/test/scala/org/apache/spark/deploy/history/FsHistoryProviderSuite.scala:
##########
@@ -1248,6 +1248,61 @@ abstract class FsHistoryProviderSuite extends
SparkFunSuite with Matchers with P
}
}
+ test("end event reparse when the skip offset lands inside a multi-byte
character") {
Review Comment:
**[7] Coverage gaps.**
All three new tests use uncompressed logs. According to the PR description,
most completed apps take the compressed path (the target comes from the
compressed length and the skip runs on the decompressed stream), but none of
these tests exercise it, so the "uncompressed byte offset" label is not
verified there. The claimed empty-input behavior when the skip reaches EOF is
also untested (see [1]), and so are the `skipPartialLine` edge cases: no
trailing `\n`, `\r\n`, and `target` exactly at a line start. A small direct
test of `skipPartialLine` plus one compressed-codec variant would cover most of
this.
--
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]