viirya commented on code in PR #59074:
URL: https://github.com/apache/spark/pull/59074#discussion_r4117451556
##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -104,35 +107,54 @@ private[spark] class ReplayListenerBus(
line
}
- @tailrec private def fetchLine(): String = {
+ @tailrec private def fetchLine(): (String, Int) = {
val sb = new java.lang.StringBuilder()
var overLong = false
+ var byteLength = 0L
var c = reader.read()
if (c == -1) {
null
} else {
+ val index = lineIndex
+ lineIndex += 1
while (c != -1 && c != '\n') {
- if (sb.length() * 2 < maxLineLength) {
- sb.append(c.toChar)
- } else {
- overLong = true
+ if (!overLong) {
+ // The strict decoder guarantees valid surrogate pairs. Count
their four UTF-8
+ // bytes on the high surrogate and zero on the low surrogate.
+ byteLength += (if (c < 0x80) {
+ 1
+ } else if (c < 0x800) {
+ 2
+ } else if (Character.isHighSurrogate(c.toChar)) {
+ 4
+ } else if (Character.isLowSurrogate(c.toChar)) {
+ 0
+ } else {
+ 3
+ })
+ // Allow one extra byte until we know whether the line ends in
CRLF.
+ if (byteLength <= maxLineLength.toLong + 1) {
Review Comment:
Confirmed. I reproduced the OOM in a separate `-Xmx1g` JVM using a lazy
stream containing U+0101 followed by 600,000,000 ASCII characters. The 512 MiB
limit fails during builder growth; the revised implementation with a 256 MiB
limit successfully drains and skips the same input.
Changed both defaults to `256m` in ab7c85da8f6 to keep the maximum buffered
character count close to the previous effective bound. The configuration docs
now describe the additional heap cost of buffer growth, UTF-16 storage and JSON
parsing. This does not guarantee that every accepted 256 MiB event fits in a 1
GiB heap.
##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -104,35 +107,54 @@ private[spark] class ReplayListenerBus(
line
}
- @tailrec private def fetchLine(): String = {
+ @tailrec private def fetchLine(): (String, Int) = {
val sb = new java.lang.StringBuilder()
var overLong = false
+ var byteLength = 0L
var c = reader.read()
if (c == -1) {
null
} else {
+ val index = lineIndex
+ lineIndex += 1
while (c != -1 && c != '\n') {
- if (sb.length() * 2 < maxLineLength) {
- sb.append(c.toChar)
- } else {
- overLong = true
+ if (!overLong) {
+ // The strict decoder guarantees valid surrogate pairs. Count
their four UTF-8
+ // bytes on the high surrogate and zero on the low surrogate.
+ byteLength += (if (c < 0x80) {
+ 1
+ } else if (c < 0x800) {
+ 2
+ } else if (Character.isHighSurrogate(c.toChar)) {
+ 4
+ } else if (Character.isLowSurrogate(c.toChar)) {
+ 0
+ } else {
+ 3
+ })
+ // Allow one extra byte until we know whether the line ends in
CRLF.
+ if (byteLength <= maxLineLength.toLong + 1) {
+ sb.append(c.toChar)
+ } else {
+ overLong = true
Review Comment:
Addressed in ab7c85da8f6. The reader now uses a `BoundedLineBuffer`, and the
new test checks its retained character count after every append while feeding
content well beyond the limit. It covers zero and small limits and allows only
the one extra character reserved for a possible trailing CR.
I also tested mutations that remove the append cap or double it. Both cause
this test to fail, even though the oversized line would still be rejected at
the end.
##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -104,35 +107,54 @@ private[spark] class ReplayListenerBus(
line
}
- @tailrec private def fetchLine(): String = {
+ @tailrec private def fetchLine(): (String, Int) = {
val sb = new java.lang.StringBuilder()
var overLong = false
+ var byteLength = 0L
var c = reader.read()
if (c == -1) {
null
} else {
+ val index = lineIndex
+ lineIndex += 1
while (c != -1 && c != '\n') {
- if (sb.length() * 2 < maxLineLength) {
- sb.append(c.toChar)
- } else {
- overLong = true
+ if (!overLong) {
+ // The strict decoder guarantees valid surrogate pairs. Count
their four UTF-8
+ // bytes on the high surrogate and zero on the low surrogate.
+ byteLength += (if (c < 0x80) {
+ 1
+ } else if (c < 0x800) {
+ 2
+ } else if (Character.isHighSurrogate(c.toChar)) {
+ 4
+ } else if (Character.isLowSurrogate(c.toChar)) {
+ 0
+ } else {
+ 3
+ })
+ // Allow one extra byte until we know whether the line ends in
CRLF.
+ if (byteLength <= maxLineLength.toLong + 1) {
+ sb.append(c.toChar)
+ } else {
+ overLong = true
+ }
}
c = reader.read()
Review Comment:
Agreed that this deserves a separate follow-up. We will address
block-oriented reading and validate it with an SHS replay benchmark there. This
PR keeps the existing read strategy and focuses on the byte-limit and
diagnostic fixes; I have not independently rerun the throughput measurements
quoted here.
##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -104,35 +107,54 @@ private[spark] class ReplayListenerBus(
line
}
- @tailrec private def fetchLine(): String = {
+ @tailrec private def fetchLine(): (String, Int) = {
val sb = new java.lang.StringBuilder()
var overLong = false
+ var byteLength = 0L
var c = reader.read()
if (c == -1) {
null
} else {
+ val index = lineIndex
+ lineIndex += 1
while (c != -1 && c != '\n') {
- if (sb.length() * 2 < maxLineLength) {
- sb.append(c.toChar)
- } else {
- overLong = true
+ if (!overLong) {
+ // The strict decoder guarantees valid surrogate pairs. Count
their four UTF-8
+ // bytes on the high surrogate and zero on the low surrogate.
+ byteLength += (if (c < 0x80) {
+ 1
+ } else if (c < 0x800) {
+ 2
+ } else if (Character.isHighSurrogate(c.toChar)) {
+ 4
+ } else if (Character.isLowSurrogate(c.toChar)) {
+ 0
+ } else {
+ 3
+ })
+ // Allow one extra byte until we know whether the line ends in
CRLF.
+ if (byteLength <= maxLineLength.toLong + 1) {
+ sb.append(c.toChar)
+ } else {
+ overLong = true
+ }
}
c = reader.read()
}
- if (overLong) {
+ if (sb.length() > 0 && sb.charAt(sb.length() - 1) == '\r') {
+ sb.setLength(sb.length() - 1)
+ byteLength -= 1
+ }
+ if (overLong || byteLength > maxLineLength) {
Review Comment:
Removed `overLong` in ab7c85da8f6. The bounded accumulator stops updating
once the byte count exceeds the buffer allowance, and the final content-byte
check determines whether to skip the line. This also avoids continuing to count
the rest of an arbitrarily long discarded line.
##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -104,35 +107,54 @@ private[spark] class ReplayListenerBus(
line
}
- @tailrec private def fetchLine(): String = {
+ @tailrec private def fetchLine(): (String, Int) = {
val sb = new java.lang.StringBuilder()
var overLong = false
+ var byteLength = 0L
var c = reader.read()
if (c == -1) {
null
} else {
+ val index = lineIndex
+ lineIndex += 1
while (c != -1 && c != '\n') {
- if (sb.length() * 2 < maxLineLength) {
- sb.append(c.toChar)
- } else {
- overLong = true
+ if (!overLong) {
+ // The strict decoder guarantees valid surrogate pairs. Count
their four UTF-8
+ // bytes on the high surrogate and zero on the low surrogate.
+ byteLength += (if (c < 0x80) {
Review Comment:
Applied in ab7c85da8f6: each surrogate half contributes two bytes, with a
comment explaining the strict decoder's valid-pair guarantee. The new raw UTF-8
tests exercise supplementary code points and limits inside those code points. A
mutation counting three bytes per half fails those tests.
##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -148,15 +170,21 @@ private[spark] class ReplayListenerBus(
sourceName: String,
maybeTruncated: Boolean,
eventsFilter: ReplayEventsFilter): Boolean = {
+ replayEntries(lines.zipWithIndex, sourceName, maybeTruncated, eventsFilter)
Review Comment:
We will handle this in a separate follow-up. The tail-reparse reader and its
offset/partial-line semantics are independent of the two issues addressed here.
This PR leaves that path unchanged; the follow-up will address the bypass of
the bounded reader and clarify line-number semantics after skipping into a file.
##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -67,22 +66,26 @@ private[spark] class ReplayListenerBus(
maybeTruncated: Boolean = false,
eventsFilter: ReplayEventsFilter = SELECT_ALL_FILTER): Boolean = {
val lines = boundedLines(logData, sourceName)
- replay(lines, sourceName, maybeTruncated, eventsFilter)
+ replayEntries(lines, sourceName, maybeTruncated, eventsFilter)
}
/**
- * Reads '\n'-terminated lines like Source.getLines(), but never
materializes more than
- * `maxLineLength` bytes of a single line. An over-long line is drained and
skipped
- * with a warning instead of being turned into a String.
+ * Reads '\n'-terminated lines and retains their original zero-based
indices. The limit
+ * measures UTF-8 content bytes, excluding the line ending, rather than JVM
heap usage.
+ * The character buffer is bounded by the limit plus one possible trailing
CR. An over-long
+ * line is drained and skipped with a warning instead of being turned into a
String.
*/
- private def boundedLines(logData: InputStream, sourceName: String):
Iterator[String] = {
+ private def boundedLines(
Review Comment:
We will address compaction reader and retention consistency in a separate
follow-up, including reproducing the possible loss of live-entity events. That
needs its own retention tests and a decision about handling oversized start
events across both passes. This PR does not change the compaction rewrite path.
##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -148,15 +170,21 @@ private[spark] class ReplayListenerBus(
sourceName: String,
maybeTruncated: Boolean,
eventsFilter: ReplayEventsFilter): Boolean = {
+ replayEntries(lines.zipWithIndex, sourceName, maybeTruncated, eventsFilter)
+ }
+
+ private def replayEntries(
Review Comment:
Confirmed and fixed in ab7c85da8f6. `JsonProcessingException` now logs the
source and physical line number before the same exception is rethrown. Tests
cover both malformed JSON and a mapping error on physical line 4 after two
skipped lines.
Truncated-last-line handling and ordinary stream IOException propagation are
preserved. A separate test checks that a stream read failure does not receive a
stale JSON parse-line diagnostic. Removing the new JSON diagnostic makes its
regression test fail.
##########
core/src/main/scala/org/apache/spark/internal/config/History.scala:
##########
@@ -167,8 +167,8 @@ private[spark] object History {
val EVENT_LOG_MAX_LINE_LENGTH =
ConfigBuilder("spark.history.fs.eventLog.maxLineLength")
- .doc("Maximum length of a single event log line during replay. Lines
longer than " +
- "this are skipped with a warning instead of being read into memory,
bounding the " +
+ .doc("Maximum UTF-8 byte length of a single event log line during
replay, excluding " +
+ "the line ending. Longer lines are skipped with a warning, bounding
the " +
"memory replay can use when an event log is corrupt or unexpectedly
large. Setting " +
"this to 0 or a negative value disables the limit. " +
Review Comment:
Corrected the configuration docs and helper comment in ab7c85da8f6. They now
state that non-positive values and values above `Int.MaxValue` use the maximum
supported limit of 2,147,483,647 bytes. The existing configuration
normalization is unchanged. Added tests for zero, negative, exact-maximum and
larger values.
##########
core/src/test/scala/org/apache/spark/scheduler/ReplayListenerSuite.scala:
##########
@@ -109,6 +109,85 @@ class ReplayListenerSuite extends SparkFunSuite with
BeforeAndAfter with LocalSp
assert(eventMonster.loggedEvents(1) ===
JsonProtocol.sparkEventToJsonString(applicationEnd))
}
+ test("Replay line limit uses UTF-8 bytes") {
+ val start = SparkListenerApplicationStart("x" * 6000, None, 125L, "user",
None)
+ val json = JsonProtocol.sparkEventToJsonString(start)
+ assert(json.getBytes(StandardCharsets.UTF_8).length < 8 * 1024)
+ val listener = new EventBufferingListener
+ val bus = new ReplayListenerBus(8 * 1024)
+ bus.addListener(listener)
+ val input = new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))
+ assert(bus.replay(input, "ascii"))
+ assert(listener.loggedEvents.toSeq == Seq(json))
+ }
+
+ test("Replay line limit handles UTF-8 boundaries and line endings") {
+ val end =
JsonProtocol.sparkEventToJsonString(SparkListenerApplicationEnd(1000L))
+ // scalastyle:off nonascii
+ val names = Seq("x", "\u00e9", "\u4e2d", "\ud83d\ude00")
Review Comment:
Confirmed: Jackson escapes U+1F600 in the original test. Replaced that grid
in ab7c85da8f6 with raw UTF-8 input observed through the replay filter before
JSON parsing.
The final code point is 1–4 bytes wide and starts at offsets 8, 8190, 8191
or 8192. Limits range from three bytes below to two bytes above the content
length, covering positions inside multibyte code points and sequences split
across the reader's byte buffer. LF, CRLF and unterminated final lines are
included. A mutant counting supplementary characters as 3+3 bytes fails the new
grid.
--
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]