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


##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -35,15 +35,17 @@ import org.apache.spark.util.JsonProtocol
 /**
  * A SparkListenerBus that can be used to replay events from serialized event 
data.
  *
- * @param maxLineLength Maximum number of byes (1/2 chars) of a single event 
log line that will be
- *                      materialized during replay. Longer lines are drained, 
skipped and
- *                      logged, bounding the memory replay can use when an 
event log is
- *                      corrupt or unexpectedly large.
+ * @param maxLineLength Maximum UTF-8 byte length of a single event log line, 
excluding its line
+ *                      ending. Longer lines are drained, skipped and logged, 
bounding the
+ *                      memory replay can use when an event log is corrupt or 
unexpectedly large.
  */
 private[spark] class ReplayListenerBus(

Review Comment:
   Binary compatibility (pre-existing from SPARK-59407, but this PR is a good 
place to fix it before the maintenance releases): every released version 
(3.5.9, 4.0.4, 4.1.3, 4.2.0) declares `class ReplayListenerBus extends 
SparkListenerBus with Logging`, which has a public no-arg constructor. With 
`(maxLineLength: Int = DEFAULT_MAX_LINE_LENGTH)`, scalac only emits 
`<init>(I)V`; default arguments don't generate a no-arg constructor.
   
   The class is `private[spark]` and excluded from MiMa, but event-log tools 
already use it and would break on 3.5.10 / 4.0.5 / 4.1.4 / 4.2.1:
   - Sparklens: `Class.forName("org.apache.spark.scheduler.ReplayListenerBus")` 
+ `newInstance()` 
([EventHistoryReporter.scala#L20-L21](https://github.com/qubole/sparklens/blob/7f2ea4bac91eff4435923881c263e6d42caa0949/src/main/scala/com/qubole/sparklens/app/EventHistoryReporter.scala#L20-L21))
 -> `InstantiationException`.
   - aws-emr-advisor: `getDeclaredConstructor().newInstance()` 
([EmrSparkLogParser.scala#L75-L76](https://github.com/aws-samples/aws-emr-advisor/blob/3caea29121322c7e76b61443694355f80d66bf6c/src/main/scala/com/amazonaws/emr/spark/EmrSparkLogParser.scala#L75-L76))
 -> `NoSuchMethodException`, and `class CustomReplayListenerBus extends 
ReplayListenerBus` 
([CustomReplayListenerBus.scala#L25](https://github.com/aws-samples/aws-emr-advisor/blob/3caea29121322c7e76b61443694355f80d66bf6c/src/main/scala/org/apache/spark/utils/CustomReplayListenerBus.scala#L25))
 -> `NoSuchMethodError: 'void <init>()'` when compiled against a released 
version.
   
   The reflective cases fail even after recompiling. Could we add an auxiliary 
constructor?
   ```scala
     def this() = this(ReplayListenerBus.DEFAULT_MAX_LINE_LENGTH)
   ```
   It compiles on Scala 2.12 and 2.13 with or without the default argument, 
`new ReplayListenerBus()` is not ambiguous, and all in-repo call shapes still 
compile, including `new ReplayListenerBus(maxLineLength = ...)` and the 
anonymous subclass in `StreamingQueryListenerSuite`. If 4.2.1 ships from RC1, 
keeping the default argument as well also preserves 
`$lessinit$greater$default$1` for code compiled against 4.2.1.



##########
core/src/test/scala/org/apache/spark/scheduler/ReplayListenerSuite.scala:
##########
@@ -109,6 +112,166 @@ 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 conf = new SparkConf(false).set(History.EVENT_LOG_MAX_LINE_LENGTH.key, 
"8k")
+    val bus = new ReplayListenerBus(ReplayListenerBus.maxLineLength(conf))
+    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 raw UTF-8 boundaries and line endings") {
+    // scalastyle:off nonascii
+    val suffixes = Seq("x", "\u00e9", "\u4e2d", "\ud83d\ude00")
+    // scalastyle:on nonascii
+    for {
+      suffix <- suffixes
+      padding <- Seq(8, 8190, 8191, 8192)
+      ending <- Seq("\n", "\r\n", "")
+      delta <- -3 to 2
+    } {
+      // Put the limit inside the final code point, and split UTF-8 sequences 
across read buffers.
+      val line = "x" * padding + suffix
+      val bytes = line.getBytes(StandardCharsets.UTF_8)
+      val limit = bytes.length + delta
+      val seen = new ArrayBuffer[String]
+      val bus = new ReplayListenerBus(limit)
+      val input = new ByteArrayInputStream((line + 
ending).getBytes(StandardCharsets.UTF_8))
+      withClue(s"codePoint=${suffix.codePointAt(0)} padding=$padding " +
+          s"ending=${ending.map(_.toInt).mkString(",")} delta=$delta: ") {
+        // Observe raw lines before JSON parsing, without letting Jackson 
escape the test input.
+        assert(bus.replay(input, "utf8", eventsFilter = line => {
+          seen += line
+          false
+        }))
+        assert(seen.toSeq == (if (delta >= 0) Seq(line) else Seq.empty))
+      }
+    }
+  }
+
+  test("Replay line buffer stops retaining oversized content") {
+    for (limit <- Seq(0, 1, 8, 1024)) {
+      val buffer = new ReplayListenerBus.BoundedLineBuffer(limit)
+      for (_ <- 0 until 10000) {
+        buffer.append('x')
+        assert(buffer.length <= limit + 1)
+      }
+      assert(buffer.length == limit + 1)
+      assert(buffer.result().isEmpty)
+    }
+  }
+
+  test("Replay line limit defaults and maximum supported configuration") {
+    val conf = new SparkConf(false)
+    assert(conf.get(History.EVENT_LOG_MAX_LINE_LENGTH) == 256L * 1024 * 1024)
+    assert(ReplayListenerBus.maxLineLength(conf) == 
ReplayListenerBus.DEFAULT_MAX_LINE_LENGTH)
+    for (value <- Seq("0", "-1", "512m", "536870913", "576m", "600m", "1g",
+        "2147483647", "3g")) {
+      conf.set(History.EVENT_LOG_MAX_LINE_LENGTH.key, value)
+      assert(ReplayListenerBus.maxLineLength(conf) == 
ReplayListenerBus.MAX_LINE_LENGTH)
+    }
+  }
+
+  test("Replay constructor normalizes the effective line limit") {

Review Comment:
   Test coverage: this only reads `effectiveMaxLineLength`, and no test replays 
through a bus built with an out-of-range limit. So reverting 
`ReplayListenerBus` L114 to the raw parameter (`new 
BoundedLineBuffer(maxLineLength)`, as in the base code) passes the whole suite, 
although `new ReplayListenerBus(0)` would then skip every non-empty line, and 
`-1` / `Int.MinValue` every line. All production callers pass the 
already-normalized `maxLineLength(conf)`, so there's no user impact today, but 
the hand-ported branch-3.5 backport could regress without a failing test. Could 
we replay one event through the `0`, `-1` and `Int.MinValue` buses in the first 
loop and assert that it arrives?



##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -225,21 +235,56 @@ private[spark] class HaltReplayException extends 
RuntimeException
 
 private[spark] object ReplayListenerBus {
 
-  /**
-   * Default per-line cap during replay: far above any legitimate event line, 
so replay
-   * memory stays bounded even for corrupt logs. Matches the default of
-   * spark.history.fs.eventLog.maxLineLength.
-   */
-  val DEFAULT_MAX_LINE_LENGTH: Int = 512 * 1024 * 1024
+  /** Default UTF-8 content limit shared with the history server 
configuration. */
+  val DEFAULT_MAX_LINE_LENGTH: Int = 
History.EVENT_LOG_MAX_LINE_LENGTH.defaultValue.get.toInt
+
+  // Bound StringBuilder growth so UTF-16 inflation stays below the JVM 
array-size limit.
+  val MAX_LINE_LENGTH: Int = 512 * 1024 * 1024
 
-  /** Resolves the replay line-length cap from configuration; <= 0 disables 
the cap. */
+  /** Resolves the byte limit, using 512 MiB for non-positive or larger 
values. */
   def maxLineLength(conf: SparkConf): Int = {
-    val configured = conf.get(History.EVENT_LOG_MAX_LINE_LENGTH)
-    if (configured <= 0 || configured > Int.MaxValue) Int.MaxValue else 
configured.toInt
+    normalizeMaxLineLength(conf.get(History.EVENT_LOG_MAX_LINE_LENGTH))
+  }
+
+  private def normalizeMaxLineLength(configured: Long): Int = {
+    if (configured <= 0 || configured > MAX_LINE_LENGTH) MAX_LINE_LENGTH else 
configured.toInt
   }
 
   type ReplayEventsFilter = (String) => Boolean
 
   // utility filter that selects all event logs during replay
   val SELECT_ALL_FILTER: ReplayEventsFilter = { (eventString: String) => true }
+
+  /** Accumulates a bounded prefix of one decoded line, allowing a possible 
trailing CR. */
+  private[scheduler] class BoundedLineBuffer(maxLineLength: Int) {
+    private val buffer = new java.lang.StringBuilder()
+    private var byteLength = 0L
+    private val bufferLimit = maxLineLength.toLong + 1
+
+    def length: Int = buffer.length()
+
+    def append(c: Char): Unit = {
+      if (byteLength <= bufferLimit) {
+        // The strict decoder emits valid surrogate pairs: count two bytes per 
half.
+        byteLength += (if (c < 0x80) 1 else if (c < 0x800 || 
Character.isSurrogate(c)) 2 else 3)
+        // Reserve one extra byte for a possible CR in the line ending.
+        if (byteLength <= bufferLimit) {

Review Comment:
   Once `byteLength` exceeds `bufferLimit`, the line is certain to be skipped, 
but the retained prefix stays reachable until the `\n` is read. With the 
default `256m`, that is ~302 MB (Latin-1) or ~604 MB (UTF-16, i.e. any char >= 
U+0100 early in the line) of dead heap for the whole drain (~13 s per GB at the 
current read rate), overlapping with concurrent UI loads and compaction. In a 
`-Xmx1g` model, bus A draining U+0101 + ~868 MB of ASCII made bus B fail with 
`OutOfMemoryError` on a 200 MB line, and releasing the buffer on overflow made 
it pass.
   
   This is pre-existing from SPARK-59407, but it's a small fix:
   ```scala
           if (byteLength <= bufferLimit) {
             buffer.append(c)
           } else {
             // The line will be skipped; release the retained prefix while 
draining.
             buffer.setLength(0)
             buffer.trimToSize()
           }
   ```
   After overflow `byteLength >= maxLineLength + 2`, so `result()` still 
returns `None` regardless of a trailing CR. It doesn't lower the peak, only how 
long the memory is held. The assertion at `ReplayListenerSuite` L164 
(`buffer.length == limit + 1`) would become `== 0`.



##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -225,21 +235,56 @@ private[spark] class HaltReplayException extends 
RuntimeException
 
 private[spark] object ReplayListenerBus {
 
-  /**
-   * Default per-line cap during replay: far above any legitimate event line, 
so replay
-   * memory stays bounded even for corrupt logs. Matches the default of
-   * spark.history.fs.eventLog.maxLineLength.
-   */
-  val DEFAULT_MAX_LINE_LENGTH: Int = 512 * 1024 * 1024
+  /** Default UTF-8 content limit shared with the history server 
configuration. */
+  val DEFAULT_MAX_LINE_LENGTH: Int = 
History.EVENT_LOG_MAX_LINE_LENGTH.defaultValue.get.toInt
+
+  // Bound StringBuilder growth so UTF-16 inflation stays below the JVM 
array-size limit.
+  val MAX_LINE_LENGTH: Int = 512 * 1024 * 1024
 
-  /** Resolves the replay line-length cap from configuration; <= 0 disables 
the cap. */
+  /** Resolves the byte limit, using 512 MiB for non-positive or larger 
values. */
   def maxLineLength(conf: SparkConf): Int = {
-    val configured = conf.get(History.EVENT_LOG_MAX_LINE_LENGTH)
-    if (configured <= 0 || configured > Int.MaxValue) Int.MaxValue else 
configured.toInt
+    normalizeMaxLineLength(conf.get(History.EVENT_LOG_MAX_LINE_LENGTH))
+  }
+
+  private def normalizeMaxLineLength(configured: Long): Int = {
+    if (configured <= 0 || configured > MAX_LINE_LENGTH) MAX_LINE_LENGTH else 
configured.toInt
   }
 
   type ReplayEventsFilter = (String) => Boolean
 
   // utility filter that selects all event logs during replay
   val SELECT_ALL_FILTER: ReplayEventsFilter = { (eventString: String) => true }
+
+  /** Accumulates a bounded prefix of one decoded line, allowing a possible 
trailing CR. */
+  private[scheduler] class BoundedLineBuffer(maxLineLength: Int) {
+    private val buffer = new java.lang.StringBuilder()

Review Comment:
   FYI, because this `StringBuilder` starts at 16 and grows as `2c + 2`, the 
retained buffer is a step function of the limit rather than proportional to it. 
For ASCII-heavy lines, every limit from 150,994,942 to 301,989,885 bytes (~144m 
to ~288m) ends with the same 301,989,886-char buffer as the `256m` default, and 
~288m to 512m all end with 603,979,774 chars.
   
   So if an operator hits an OOM while a long line is drained and lowers the 
limit to `200m` or `150m`, nothing changes. With `-Xmx800m`, U+0101 followed by 
300,000,000 ASCII chars failed at the same point (length 150,994,942, when the 
302 MB UTF-16 array is copied into a 604 MB one) for `256m`, `200m` and `150m`, 
while `143m` and `128m` skipped it. Could the docs mention that only limits 
below ~144m (e.g. `128m`) reduce the buffer, or could the growth be capped at 
`bufferLimit`?



##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -202,6 +208,10 @@ private[spark] class ReplayListenerBus(
         // Just stop replay.
         false
       case _: EOFException if maybeTruncated => false
+      case jpe: JsonProcessingException =>
+        logError(log"Exception parsing Spark event log: ${MDC(PATH, 
sourceName)} " +
+          log"at line ${MDC(LINE_NUM, lineNumber)}")
+        throw jpe
       case ioe: IOException =>
         throw ioe
       case e: Exception =>

Review Comment:
   Pre-existing and optional: the new JSON branch above logs only the source 
and line, but this generic branch still logs the entire line (`Malformed line 
#...: ${MDC(LINE, currentLine)}` at L219), which can be up to 256 MiB by 
default (512 MiB max). A large line that is valid JSON but semantically wrong 
ends up here, e.g. a `JobStart` whose `Properties` contain a numeric value 
fails `extractString`'s `require` with `IllegalArgumentException`. With the 
same code in Spark 3.5.0, a 33,554,544-char line produced a single 
33,554,606-char ERROR record, and building the message temporarily needs about 
twice the line size (the `log"..."` builder doubles when the trailing `\n` is 
appended). Since this PR is about bounding replay memory, could we log a 
bounded prefix plus the line length instead? The `Malformed line #4: {}` 
assertion would still pass.



##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -67,22 +69,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(
+      logData: InputStream,
+      sourceName: String): Iterator[(String, Int)] = {
     // Fail on malformed input like Source.getLines() does instead of 
replacing it.
     val decoder = StandardCharsets.UTF_8.newDecoder()
       .onMalformedInput(CodingErrorAction.REPORT)
       .onUnmappableCharacter(CodingErrorAction.REPORT)
     val reader = new BufferedReader(new InputStreamReader(logData, decoder))

Review Comment:
   A thought for the block-read follow-up, not a blocker here: the limit is a 
UTF-8 byte count, but the reader decodes to UTF-16 first and re-derives the 
byte width per char. Since `0x0A` never appears inside a multi-byte UTF-8 
sequence, splitting lines on raw bytes into a buffer capped at `limit + 1` 
bytes, and strictly decoding only the lines that are kept, makes the unit exact 
by construction. Skipped bytes can still be streamed through the strict decoder 
to keep the "malformed UTF-8 in skipped lines" contract. It would also:
   - bound a skipped non-Latin-1 line to `limit + 1` bytes instead of a UTF-16 
builder (~604 MB plus growth at `256m`),
   - let a `MalformedInputException` be attributed to a physical line (today 
the decoder's 8 KiB read-ahead can throw before the preceding lines are 
delivered),
   - provide the raw-byte handling that the tail-reparse (partial first line) 
and truncated-UTF-8-at-EOF follow-ups need.
   
   A quick Java port matched this reader on all the grids in this PR plus 
random and malformed inputs. The reader alone ran at ~2,450 / ~310 MB/s (ASCII 
/ CJK), vs ~77 / ~91 MB/s here and ~1,140 / ~215 MB/s for a bulk `char[]` 
reader. It still needs the CR reservation, a cap (around the 1 GiB UTF-16 
`String` limit), and a heap-oriented default.



##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -104,35 +110,29 @@ private[spark] class ReplayListenerBus(
         line
       }
 
-      @tailrec private def fetchLine(): String = {
-        val sb = new java.lang.StringBuilder()
-        var overLong = false
+      @tailrec private def fetchLine(): (String, Int) = {
+        val buffer = new BoundedLineBuffer(effectiveMaxLineLength)

Review Comment:
   For the block-read follow-up: a new `BoundedLineBuffer` (and a 16-char 
`StringBuilder` regrown by doubling) per line, including skipped lines, 
allocates ~4.3 bytes per input byte on the event logs under 
`core/src/test/resources/spark-events`, vs ~1.7 with one buffer per iterator 
that is reset for each line (Jackson `readTree` alone is ~3.9). Today the 
per-char `BufferedReader.read()` lock hides the CPU cost, but with a bulk 
reader it becomes ~10% of the read loop. Most of this is inherited from 
SPARK-59407 (this PR adds ~40 B per line), so it's fine to handle it there. A 
reused buffer would need a capacity cap so that it doesn't pin a huge array for 
the rest of the file.



##########
core/src/main/scala/org/apache/spark/internal/config/History.scala:
##########
@@ -167,16 +167,19 @@ 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 " +
-        "memory replay can use when an event log is corrupt or unexpectedly 
large. Setting " +
-        "this to 0 or a negative value disables the limit. " +
+      .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. Buffer " +
+        "growth, UTF-16 storage and JSON parsing can require several times 
this limit in heap " +
+        "space. Values at or below 0, or above 536870912, use the maximum 
supported limit of " +

Review Comment:
   nit: This hard-codes `536870912` twice, duplicating 
`ReplayListenerBus.MAX_LINE_LENGTH`, similar to my earlier comment about 
`DEFAULT_MAX_LINE_LENGTH`. We could define the cap in `History` above 
`EVENT_LOG_MAX_LINE_LENGTH` (like `DEFAULT_LOG_DIR`), interpolate it with 
`.doc(s"...")`, and derive `ReplayListenerBus.MAX_LINE_LENGTH` from it. The 
other direction (referencing `ReplayListenerBus.MAX_LINE_LENGTH` from this doc) 
would create an initialization cycle, because the `ReplayListenerBus` object 
already reads `History.EVENT_LOG_MAX_LINE_LENGTH.defaultValue` during its 
initialization. `monitoring.md` would still need a manual update.



##########
core/src/main/scala/org/apache/spark/internal/config/History.scala:
##########
@@ -167,16 +167,19 @@ 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 " +
-        "memory replay can use when an event log is corrupt or unexpectedly 
large. Setting " +
-        "this to 0 or a negative value disables the limit. " +
+      .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. Buffer " +
+        "growth, UTF-16 storage and JSON parsing can require several times 
this limit in heap " +
+        "space. Values at or below 0, or above 536870912, use the maximum 
supported limit of " +
+        "536870912 bytes (512 MiB). This cap avoids JVM array-size limits but 
does not " +
+        "guarantee sufficient heap space. " +
         "Introduced in 4.3.0; also available in 3.5.10, 4.0.5, 4.1.4 and 
4.2.1; and in " +

Review Comment:
   Following up on my earlier backport comment: `v4.2.1-rc1` (cut 2026-09-28 
06:11 UTC, about an hour before the latest commit here) already contains 
SPARK-59407 with the old semantics: `.version("4.2.1")`, the `512m` default, 
the `sb.length() * 2` check, and "Setting this to 0 or a negative value 
disables the limit". `branch-4.2` has already moved to `4.2.2-SNAPSHOT` without 
this fix. My earlier comment only covered `branch-4.3`, assuming nothing had 
shipped yet.
   
   If 4.2.1 is finalized from RC1 and this PR is backported afterwards, a patch 
release (4.2.2) would change the unit, the default and the `<= 0` meaning of a 
released config:
   - Default users: the same for ASCII, but stricter for non-ASCII-heavy lines 
(e.g. 268M -> 134M chars for 2-byte text, 268M -> ~89.5M chars for 3-byte text).
   - `8k`: an `ApplicationStart` line with 3,000 x U+4E2D (3,085 chars / 9,085 
bytes) is replayed by RC1 but skipped here, so the app is missing from the 
listing.
   - `0` / negative / `3g`: RC1 never skips (`sb.length() * 2 < Int.MaxValue` 
is always true), while this PR clamps to 512 MiB, so there is no way to replay 
events above 512 MiB anymore.
   - An explicit `512m` (RC1's documented default): RC1 keeps at most 
268,435,456 chars and skips U+0101 followed by 600,000,000 ASCII chars with 
`-Xmx1g`, while this PR grows the builder to 603,979,774 chars (~1.2 GB as 
UTF-16) and fails with `OutOfMemoryError` on UI load or compaction.
   
   Could we get this into `branch-4.2` before 4.2.1 is finalized, e.g. by 
raising it on the RC1 vote so that it lands in RC2? Otherwise, we need a 
`core-migration-guide.md` entry for 4.2.1 -> 4.2.2 and should qualify "also 
available in ... 4.2.1" here and in `monitoring.md`.



##########
core/src/test/scala/org/apache/spark/scheduler/ReplayListenerSuite.scala:
##########
@@ -109,6 +112,166 @@ 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 conf = new SparkConf(false).set(History.EVENT_LOG_MAX_LINE_LENGTH.key, 
"8k")
+    val bus = new ReplayListenerBus(ReplayListenerBus.maxLineLength(conf))
+    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 raw UTF-8 boundaries and line endings") {
+    // scalastyle:off nonascii
+    val suffixes = Seq("x", "\u00e9", "\u4e2d", "\ud83d\ude00")
+    // scalastyle:on nonascii
+    for {
+      suffix <- suffixes
+      padding <- Seq(8, 8190, 8191, 8192)
+      ending <- Seq("\n", "\r\n", "")
+      delta <- -3 to 2
+    } {
+      // Put the limit inside the final code point, and split UTF-8 sequences 
across read buffers.
+      val line = "x" * padding + suffix
+      val bytes = line.getBytes(StandardCharsets.UTF_8)
+      val limit = bytes.length + delta
+      val seen = new ArrayBuffer[String]
+      val bus = new ReplayListenerBus(limit)
+      val input = new ByteArrayInputStream((line + 
ending).getBytes(StandardCharsets.UTF_8))
+      withClue(s"codePoint=${suffix.codePointAt(0)} padding=$padding " +
+          s"ending=${ending.map(_.toInt).mkString(",")} delta=$delta: ") {
+        // Observe raw lines before JSON parsing, without letting Jackson 
escape the test input.
+        assert(bus.replay(input, "utf8", eventsFilter = line => {
+          seen += line
+          false
+        }))
+        assert(seen.toSeq == (if (delta >= 0) Seq(line) else Seq.empty))
+      }
+    }
+  }
+
+  test("Replay line buffer stops retaining oversized content") {
+    for (limit <- Seq(0, 1, 8, 1024)) {
+      val buffer = new ReplayListenerBus.BoundedLineBuffer(limit)
+      for (_ <- 0 until 10000) {
+        buffer.append('x')
+        assert(buffer.length <= limit + 1)
+      }
+      assert(buffer.length == limit + 1)
+      assert(buffer.result().isEmpty)
+    }
+  }
+
+  test("Replay line limit defaults and maximum supported configuration") {
+    val conf = new SparkConf(false)
+    assert(conf.get(History.EVENT_LOG_MAX_LINE_LENGTH) == 256L * 1024 * 1024)
+    assert(ReplayListenerBus.maxLineLength(conf) == 
ReplayListenerBus.DEFAULT_MAX_LINE_LENGTH)
+    for (value <- Seq("0", "-1", "512m", "536870913", "576m", "600m", "1g",
+        "2147483647", "3g")) {
+      conf.set(History.EVENT_LOG_MAX_LINE_LENGTH.key, value)
+      assert(ReplayListenerBus.maxLineLength(conf) == 
ReplayListenerBus.MAX_LINE_LENGTH)
+    }
+  }
+
+  test("Replay constructor normalizes the effective line limit") {
+    val maximum = ReplayListenerBus.MAX_LINE_LENGTH
+    for (limit <- Seq(Int.MinValue, -1, 0, maximum, maximum + 1, 
Int.MaxValue)) {
+      assert(new ReplayListenerBus(limit).effectiveMaxLineLength == maximum)
+    }
+    for (limit <- Seq(1, 8192, ReplayListenerBus.DEFAULT_MAX_LINE_LENGTH, 
maximum - 1)) {
+      assert(new ReplayListenerBus(limit).effectiveMaxLineLength == limit)
+      val conf = new SparkConf(false).set(History.EVENT_LOG_MAX_LINE_LENGTH, 
limit.toLong)
+      assert(ReplayListenerBus.maxLineLength(conf) == limit)
+    }
+    assert(new ReplayListenerBus().effectiveMaxLineLength ==
+      ReplayListenerBus.DEFAULT_MAX_LINE_LENGTH)
+  }
+
+  test("Replay preserves physical line numbers after skipping long lines") {
+    val end = 
JsonProtocol.sparkEventToJsonString(SparkListenerApplicationEnd(1000L))
+    val appender = new LogAppender
+    val bus = new ReplayListenerBus(1024)
+    val input = new ByteArrayInputStream(
+      (end + "\n" + "x" * 2048 + "\n" + "x" * 2048 + "\n{}\n")
+        .getBytes(StandardCharsets.UTF_8))
+    withLogAppender(appender) {
+      assert(!bus.replay(input, "line-numbers"))
+    }
+    assert(appender.loggingEvents.exists(_.getMessage.getFormattedMessage
+      .contains("Malformed line #4: {}")))
+  }
+
+  test("Replay logs physical line numbers before rethrowing JSON errors") {
+    val end = 
JsonProtocol.sparkEventToJsonString(SparkListenerApplicationEnd(1000L))
+    val mappingError =
+      
"""{"Event":"org.apache.spark.util.TestListenerEvent","foo":"x","bar":[]}"""
+    for ((malformed, errorClass) <- Seq(
+        ("{bad", classOf[JsonParseException]),
+        (mappingError, classOf[MismatchedInputException]))) {
+      val appender = new LogAppender
+      val bus = new ReplayListenerBus(1024)
+      val input = new ByteArrayInputStream(
+        Seq(end, "x" * 2048, "x" * 2048, malformed, end).mkString("\n")
+          .getBytes(StandardCharsets.UTF_8))
+      withLogAppender(appender) {
+        val error = intercept[JsonProcessingException] {
+          bus.replay(input, "json-errors", maybeTruncated = true)
+        }
+        assert(errorClass.isInstance(error))
+      }
+      val diagnostics = 
appender.loggingEvents.filter(_.getMessage.getFormattedMessage
+        .contains("Exception parsing Spark event log: json-errors at line 4"))
+      assert(diagnostics.size == 1)
+      assert(diagnostics.head.getThrown == null)
+    }
+  }
+
+  test("Replay propagates stream IO errors without reporting a stale parse 
line") {

Review Comment:
   This stream throws on the very first `read()`, so no line has been parsed 
and `currentLine` / `lineNumber` are still at their initial values; the 
stale-line situation in the test name never occurs. For example, a later change 
like `case ioe: IOException => if (currentLine != null) logError(log"... at 
line ${MDC(LINE_NUM, lineNumber)}")` would still pass, while it would report 
`at line 1` for a real log whose first event parses and whose compressed stream 
then fails. Could the stream serve one valid event line (e.g. `end + "\n"`) 
before throwing, keeping the `eq error` and no-diagnostic assertions?



##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -104,35 +110,29 @@ private[spark] class ReplayListenerBus(
         line
       }
 
-      @tailrec private def fetchLine(): String = {
-        val sb = new java.lang.StringBuilder()
-        var overLong = false
+      @tailrec private def fetchLine(): (String, Int) = {
+        val buffer = new BoundedLineBuffer(effectiveMaxLineLength)
         var c = reader.read()
         if (c == -1) {
           null
         } else {
+          val index = lineIndex

Review Comment:
   nit: This hand-rolls what `zipWithIndex` already does at L151. If the reader 
emits one `Option` per physical line, both overloads number lines the same way, 
and the manual counter, the tuple-typed state and the `@tailrec` recursion go 
away:
   ```scala
       var warned = false
       Iterator.continually(reader.read()).takeWhile(_ != -1).map { first =>
         val buffer = new BoundedLineBuffer(effectiveMaxLineLength)
         var c = first
         while (c != -1 && c != '\n') {
           buffer.append(c.toChar)
           c = reader.read()
         }
         val maybeLine = buffer.result()
         if (maybeLine.isEmpty && !warned) {
           logWarning(...) // unchanged
           warned = true
         }
         maybeLine
       }.zipWithIndex.collect { case (Some(line), index) => (line, index) }
   ```
   A `NextIterator[Option[String]]` also works if relying on `takeWhile`'s 
one-element look-ahead feels too implicit. I checked that this behaves the same 
as the current iterator on Scala 2.12 and 2.13, including the look-ahead from 
`lineEntries.hasNext` in the `JsonParseException` handler and exceptions thrown 
mid-line. Feel free to skip it if you'd rather keep the diff against 
SPARK-59407 small.



##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -104,35 +110,29 @@ private[spark] class ReplayListenerBus(
         line
       }
 
-      @tailrec private def fetchLine(): String = {
-        val sb = new java.lang.StringBuilder()
-        var overLong = false
+      @tailrec private def fetchLine(): (String, Int) = {
+        val buffer = new BoundedLineBuffer(effectiveMaxLineLength)
         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
-            }
+            buffer.append(c.toChar)
             c = reader.read()
           }
-          if (overLong) {
+          val maybeLine = buffer.result()
+          if (maybeLine.isEmpty) {
             if (!warned) {
               logWarning(log"Skipped event log lines longer than " +

Review Comment:
   Since the physical `index` is available here now, could the warning include 
it (e.g. `index + 1` of the first skipped line)? Today a replay can return 
`true` after dropping several lines, and the only trace is one "Skipped event 
log lines longer than 268435456 bytes in <path>" per file, without a location 
or a count; later skips are not logged at any level. `replayEntries` already 
uses one WARN plus a per-occurrence `logDebug` for unrecognized events 
(L176-L183), which would fit here too. A count at EOF alone wouldn't be enough, 
because listing replays often stop early with `HaltReplayException`.



##########
core/src/test/scala/org/apache/spark/scheduler/ReplayListenerSuite.scala:
##########
@@ -109,6 +112,166 @@ 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:
   nit: Since this PR fixes a specific issue and adds tests to an existing 
suite, could we prefix the new test names with the JIRA ID, e.g. 
`test("SPARK-59804: Replay line limit uses UTF-8 bytes")`, as the contributing 
guide suggests? The same applies to the other new tests (L128, L157, L169, 
L180, L194, L208, L233, L248, L267). It helps to trace failures across the 
backport branches.



##########
core/src/main/scala/org/apache/spark/scheduler/ReplayListenerBus.scala:
##########
@@ -35,15 +35,17 @@ import org.apache.spark.util.JsonProtocol
 /**
  * A SparkListenerBus that can be used to replay events from serialized event 
data.
  *
- * @param maxLineLength Maximum number of byes (1/2 chars) of a single event 
log line that will be
- *                      materialized during replay. Longer lines are drained, 
skipped and
- *                      logged, bounding the memory replay can use when an 
event log is
- *                      corrupt or unexpectedly large.
+ * @param maxLineLength Maximum UTF-8 byte length of a single event log line, 
excluding its line

Review Comment:
   nit: Could the `@param` mention the normalization, e.g. "Values <= 0 or 
above `ReplayListenerBus.MAX_LINE_LENGTH` (512 MiB) use `MAX_LINE_LENGTH`"? 
Otherwise a caller passing `0` (RC1's "disables the limit" semantics) or `1 << 
30` silently gets a 512 MiB limit.



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