dongjoon-hyun commented on code in PR #59074:
URL: https://github.com/apache/spark/pull/59074#discussion_r4117057071


##########
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:
   Test coverage: because L148 now re-decides from the full `byteLength`, this 
cap is no longer observable by any test. A mutant that always appends (never 
sets `overLong`), or one that uses `2 * maxLineLength + 1`, produces the same 
lines, indices, warnings and exceptions for every input in the suite; only the 
buffered size of skipped lines changes (e.g. 8193 -> 9216 chars in "Over-long 
event log lines are skipped instead of materialized"). Before this PR, removing 
the cap made that test fail. A test that observes the buffer bound would guard 
SPARK-59407's main guarantee, e.g. by extracting the reader into a helper and 
asserting the max buffered length, or by streaming a huge line from a lazy 
`InputStream` with a small limit.



##########
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:
   This changes the per-line memory bound, not only the unit. Previously the 
builder stopped growing at `maxLineLength / 2` chars; now it can hold 
`maxLineLength + 1` chars, and a single char >= U+0100 inflates it to UTF-16 (2 
bytes per char). With the unchanged default of `512m`:
   
   - Default SHS heap (`-Xmx1g`), one line of U+0101 followed by 600,000,000 
ASCII chars: the current code stops at capacity 301,989,886 chars and skips the 
line. With this PR the builder grows to capacity 603,979,774 chars (a ~1.2 GB 
array) and fails with `OutOfMemoryError: Java heap space` before the line is 
rejected. `case e: Exception` does not catch it.
   - For configured limits in [576m, 1152m), a Latin-1 builder that has grown 
past 603,979,774 chars has capacity 1,207,959,550. 
`AbstractStringBuilder.inflate()` sizes the UTF-16 array from the capacity, so 
the first char >= U+0100 fails with `UTF16 String size is 1207959550, should be 
less than 1073741823` regardless of the heap size.
   - On JDK 8 (branch-3.5) there are no compact strings, so even pure-ASCII 
lines need about twice the heap of the current code.
   
   The docs still say the setting bounds "the memory replay can use". Could we 
default to `256m` (keeps the current bound for ASCII lines), keep a separate 
heap-oriented cap for the buffer, or document that heap usage can reach about 
2-4x the limit?



##########
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:
   Performance (pre-existing since SPARK-59407, kept by this rewrite): 
`BufferedReader.read()` takes a lock on every call (on JDK 21 an 
`InternalLock`/`ReentrantLock`, whose CAS survives C2 inlining). On ~100 MB of 
TaskEnd-like ASCII lines this loop splits at ~73 MB/s (13.6 s/GB) vs ~1,039 
MB/s (0.96 s/GB) for the previous `Source.getLines()`; together with Jackson 
`readTree` it is 60.9 MB/s vs 293 MB/s. That is roughly +12.6 s of CPU per GB 
for UI rebuilds and compaction, and ~8 s to drain a 600 MiB line. The UTF-8 
width branching added here is not measurable. Keeping the same per-char logic 
but reading into a local `char[]` via `InputStreamReader.read(char[], ...)` is 
~5x faster, and bulk-scanning the buffer for `\n` with slice appends is ~15x 
faster. Maybe a follow-up?



##########
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:
   nit: `overLong ||` is redundant. `overLong` is only set when `byteLength >= 
maxLineLength + 2`, `byteLength` no longer changes afterwards, and the CR strip 
subtracts at most 1, so `byteLength > maxLineLength` already holds (also for 
`Int.MaxValue`, 0 and negative limits; checked exhaustively for small limits 
plus random inputs). Either use `if (byteLength > maxLineLength)`, or drop the 
flag and keep only `if (byteLength <= maxLineLength + 1L) sb.append(c.toChar)` 
in the loop, which removes one nesting level.



##########
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:
   This overload is also what the end-event reparse in `FsHistoryProvider` uses 
(FsHistoryProvider.scala L1109-L1127), fed by an unbounded 
`Source.fromInputStream(in)(Codec.UTF8).getLines()`. So on that path there is 
no `maxLineLength` and line numbers are relative to the skip point. With the 
defaults (compressed event logs, `endEventReparseChunkSize=1m`), completed apps 
always take this path, and because `target = lastFile.getLen - 
reparseChunkSize` is based on the compressed length while `skip` runs on the 
decompressed stream, most of the log is read there. In a local repro (156.6 MB 
decompressed / 16.4 MB compressed), 141 MB went through `getLines()`, and a 
12.6 MB line over a 4 MiB limit was fully materialized. This predates the PR, 
but since the PR defines the limit and line-number semantics, it may be worth 
reusing the bounded reader there (skip, drop the partial first line, then 
replay through `boundedLines`) or filing a follow-up.



##########
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:
   nit: since the strict decoder only emits complete surrogate pairs, counting 
2 bytes per surrogate half gives identical results (the running total matches 
at every code point boundary), so this could be `if (c < 0x80) 1 else if (c < 
0x800 || Character.isSurrogate(c.toChar)) 2 else 3`, and the comment above 
becomes simpler.



##########
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:
   Since `boundedLines` is private, compaction reads the same files with two 
different readers: `EventLogFileCompactor.initializeBuilders` replays through 
this bounded reader, while `EventFilter.applyFilterToFile` (EventFilter.scala 
L79) rewrites them with an unbounded `Source.getLines()`. If a skipped 
over-limit line is the start event of a still-live entity (e.g. 
`StageSubmitted` of a running stage, `JobStart` of a running job, 
`SQLExecutionStart` of a running execution without jobs), the builders never 
mark it live, the rewrite materializes the line and rejects it together with 
its dependent task events, and `cleanupCompactedFiles` deletes the originals, 
so raising the limit later cannot recover them. A port of the builder/filter 
logic lost 23 events with a 1 MiB limit and 2 MiB lines. The inconsistency came 
with SPARK-59407 and this PR only moves the threshold (e.g. CJK-heavy lines 
between limit/3 and limit/2 chars are newly skipped), but sharing one bounded 
reader for both passe
 s, and passing over-long lines through unchanged in the rewrite, would make 
them consistent.



##########
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:
   With the new `Long` arithmetic, "disables the limit" is no longer strictly 
true. `ReplayListenerBus.maxLineLength` maps `<= 0` (and values above 
`Int.MaxValue`) to `Int.MaxValue`, and lines with more than 2,147,483,647 UTF-8 
bytes are now skipped with a warning (e.g. 2^30 x U+00E9 is 2,147,483,648 bytes 
but only a 1 GiB Latin-1 builder), whereas the previous `sb.length() * 2 < 
Int.MaxValue` overflowed and never skipped. Spark 3.4+ writers cannot produce 
such a line (one `ByteArrayOutputStream` per event), so this is mostly 
theoretical, but skipping the check when the limit is disabled, or documenting 
the 2^31-1 byte cap, would keep the doc accurate.



##########
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") {

Review Comment:
   minor, about the PR description: it says "Three new regression tests fail 
against the original implementation", but four of the five new tests fail on 
the current code (this one, the boundary grid, the physical-line-number test 
and the truncated/filtered test); only "Replay still rejects malformed UTF-8 in 
skipped lines" passes there. Since the description becomes the commit message, 
it may be worth correcting.



##########
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:
   The physical line number only surfaces for non-IOException failures. 
`JsonParseException` (and `JsonMappingException`s such as 
`MismatchedInputException`) extend `IOException`, so for a malformed JSON line 
in the middle of a file, `throw jpe` (L219) is caught by `case ioe: IOException 
=> throw ioe` (L233) before the `Malformed line #N` log (L237). For example, 
`[end, 2048 x's, 2048 x's, {bad, end]` with a 1024 limit throws without any 
line number, and `FsHistoryProvider` only logs Jackson's `line: 1, column: N`, 
which is relative to the single-line string. `{}` in the new test goes through 
the NPE path. The case ordering is old (SPARK-2261), but logging 
`sourceName`/`lineNumber` before rethrowing would make this fix effective for 
the most common corruption.



##########
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:
   The U+1F600 case does not exercise the 4-byte path. `JsonProtocol` 
serializes through Jackson's `UTF8JsonGenerator`, which writes supplementary 
characters as the ASCII escape `\ud83d\ude00` 
(`COMBINE_UNICODE_SURROGATES_IN_UTF8` is off by default in 2.22.2). So that 
JSON line is pure ASCII, the surrogate branches (ReplayListenerBus.scala 
L128-L131) are never taken, and mutants that count 3+3, 3+0 or 0+0 bytes for a 
pair all pass. Also, the boundary always falls on the trailing ASCII 
`","Timestamp":125,"User":"user"}`, so no multi-byte character straddles the 
limit, and since the name starts at byte offset 110, the 8192-byte decoder 
boundary never splits a U+00E9/U+4E2D sequence (8192 - 110 = 8082 is divisible 
by 2 and 3). Could we build raw UTF-8 input directly (e.g. lines whose last 
code point is 2, 3 or 4 bytes) and put the limit at -1/0/+1/+2 bytes around it?



##########
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")
+    // scalastyle:on nonascii
+    for {
+      name <- names
+      repetitions <- Seq(100, 4097)
+      ending <- Seq("\n", "\r\n", "")
+      excess <- Seq(0, 1)
+    } {
+      val start = SparkListenerApplicationStart(name * repetitions, None, 
125L, "user", None)
+      val json = JsonProtocol.sparkEventToJsonString(start)
+      val limit = json.getBytes(StandardCharsets.UTF_8).length - excess
+      val listener = new EventBufferingListener
+      val bus = new ReplayListenerBus(limit)
+      bus.addListener(listener)
+      // Put a short event first, including when the final line has no 
terminator.
+      val input = new ByteArrayInputStream((end + "\n" + json + ending)
+        .getBytes(StandardCharsets.UTF_8))
+      assert(bus.replay(input, "utf8"))
+      val expected = if (excess == 0) Seq(end, json) else Seq(end)
+      assert(listener.loggedEvents.toSeq == expected, s"name=$name 
ending=$ending excess=$excess")

Review Comment:
   nit: (1) The clue omits `repetitions` and prints `ending` raw, so `\n` and 
`\r\n` failures look identical, e.g. `s"name=$name repetitions=$repetitions 
ending=${ending.map(_.toInt).mkString(",")} excess=$excess"`; the 
`assert(bus.replay(input, "utf8"))` above has no clue at all. (2) `repetitions` 
100 vs 4097 gives the same outcome for every (name, ending, excess) 
combination, so it only doubles the grid (see the decoder-boundary note on 
L127). (3) "Replay line limit uses UTF-8 bytes" is subsumed by the `name = 
"x"`, `excess = 0` iterations here.



-- 
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]

Reply via email to