This is an automated email from the ASF dual-hosted git repository.

dongjoon-hyun pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new b3a476d39967 [SPARK-60581][CORE] Delete LogInfo of deleted attempts in 
FsHistoryProvider.deleteAttemptLogs
b3a476d39967 is described below

commit b3a476d399678831d990d59d467067624a4ff3d4
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Tue Oct 27 11:05:22 2026 -0700

    [SPARK-60581][CORE] Delete LogInfo of deleted attempts in 
FsHistoryProvider.deleteAttemptLogs
    
    ### What changes were proposed in this pull request?
    
    This PR aims to delete the `LogInfo` listing entries of the deleted 
attempts in `FsHistoryProvider.deleteAttemptLogs` again. They were removed 
accidentally by SPARK-55793.
    
    The key is the qualified path because `LogInfo` is keyed by 
`reader.rootPath`, which is the qualified path returned by 
`FileSystem.listStatus`, while `resolveLogPath` returns a path built from the 
configured log directory string.
    
    ```scala
           val (logFs, logPath) = resolveLogPath(attempt.logPath, 
attempt.logSourceFullPath)
    +      // LogInfo is keyed by the qualified path returned by 
FileSystem.listStatus.
    +      listing.synchronized {
    +        listing.delete(classOf[LogInfo], 
logFs.makeQualified(logPath).toString())
    +      }
           cleanAppData(app.id, attempt.info.attemptId, logPath.toString())
    ```
    
    ### Why are the changes needed?
    
    Since SPARK-55793 
([v4.2.0](https://github.com/apache/spark/releases/tag/v4.2.0), 2026-07-11), 
`deleteAttemptLogs` deletes the event log files and `ApplicationInfoWrapper`, 
but keeps their `LogInfo` entries until the next `checkForLogs` removes them as 
stale. Since `cleanLogs` counts `LogInfo` entries for 
`spark.history.fs.cleaner.maxNum` right after the max age cleanup in the same 
run, the event logs deleted by the max age policy are counted again, and the 
cleaner deletes more comple [...]
    
    For example, with `spark.history.fs.cleaner.maxNum=2` and three completed 
applications `app1`, `app2`, and `app3` where only `app1` exceeds 
`spark.history.fs.cleaner.maxAge`, the cleaner deletes `app1` by the max age 
policy, and then deletes `app2` by the max number policy although only two 
event logs remain.
    
    In addition, when an event log cannot be deleted, its `LogInfo` entry is 
kept and refreshed by every scan. So the application disappears from the 
listing permanently and its deletion is not retried.
    
    Note that the original key, `new Path(logDir, attempt.logPath).toString()`, 
only matched when `spark.history.fs.logDirectory` was fully-qualified. For a 
log directory without a scheme or authority, such as `/path` or `hdfs:///path` 
(`new Path("hdfs:///path", "app1")` is `hdfs:/path/app1` while `listStatus` 
returns `hdfs://<namenode>/path/app1`), the same over-deletion has existed 
since SPARK-28294 added `spark.history.fs.cleaner.maxNum` in 
[v3.0.0](https://github.com/apache/spark/rele [...]
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. The SHS cleaner keeps `spark.history.fs.cleaner.maxNum` event logs 
when the max age policy also deletes event logs in the same run.
    
    - For a fully-qualified `spark.history.fs.logDirectory`, this fixes a 
regression in Apache Spark 4.2.0 (SPARK-55793).
    - For a log directory without a scheme or authority, such as `/path` or 
`hdfs:///path`, this fixes a bug that has existed since Apache Spark 3.0.0 
(SPARK-28294).
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added test case.
    
    ```
    $ build/sbt 'core/testOnly *RocksDBBackendFsHistoryProviderSuite -- -z 
"SPARK-60581"'
    ```
    
    **BEFORE**
    
    ```
    [info] - SPARK-60581: log cleaner with both the max age and the maximum 
number of log files *** FAILED *** (708 milliseconds)
    [info]   List("app3") did not contain the same elements as List("app2", 
"app3") (FsHistoryProviderSuite.scala:1594)
    ```
    
    **AFTER**
    
    ```
    [info] - SPARK-60581: log cleaner with both the max age and the maximum 
number of log files (736 milliseconds)
    [info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
    ```
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5.5
    
    Closes #59833 from dongjoon-hyun/SPARK-60581.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
    (cherry picked from commit 418bf547488db34c7eedbc96694515b8d1038916)
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../spark/deploy/history/FsHistoryProvider.scala   |  4 +++
 .../deploy/history/FsHistoryProviderSuite.scala    | 37 ++++++++++++++++++++++
 2 files changed, 41 insertions(+)

diff --git 
a/core/src/main/scala/org/apache/spark/deploy/history/FsHistoryProvider.scala 
b/core/src/main/scala/org/apache/spark/deploy/history/FsHistoryProvider.scala
index 8df2d3e9d724..1c31f49b6378 100644
--- 
a/core/src/main/scala/org/apache/spark/deploy/history/FsHistoryProvider.scala
+++ 
b/core/src/main/scala/org/apache/spark/deploy/history/FsHistoryProvider.scala
@@ -1331,6 +1331,10 @@ private[history] class FsHistoryProvider(conf: 
SparkConf, clock: Clock)
     toDelete.foreach { attempt =>
       logInfo(log"Deleting expired event log for ${MDC(PATH, 
attempt.logPath)}")
       val (logFs, logPath) = resolveLogPath(attempt.logPath, 
attempt.logSourceFullPath)
+      // LogInfo is keyed by the qualified path returned by 
FileSystem.listStatus.
+      listing.synchronized {
+        listing.delete(classOf[LogInfo], 
logFs.makeQualified(logPath).toString())
+      }
       cleanAppData(app.id, attempt.info.attemptId, logPath.toString())
       if (deleteLog(logFs, logPath)) {
         countDeleted += 1
diff --git 
a/core/src/test/scala/org/apache/spark/deploy/history/FsHistoryProviderSuite.scala
 
b/core/src/test/scala/org/apache/spark/deploy/history/FsHistoryProviderSuite.scala
index 0adbae83d144..8fb26e3f1013 100644
--- 
a/core/src/test/scala/org/apache/spark/deploy/history/FsHistoryProviderSuite.scala
+++ 
b/core/src/test/scala/org/apache/spark/deploy/history/FsHistoryProviderSuite.scala
@@ -1485,6 +1485,43 @@ abstract class FsHistoryProviderSuite extends 
SparkFunSuite with Matchers with P
     }
   }
 
+  test("SPARK-60581: log cleaner with both the max age and the maximum number 
of log files") {
+    val maxAge = TimeUnit.SECONDS.toMillis(10)
+    val clock = new ManualClock(maxAge)
+    val conf = createTestConf()
+      .set(CLEANER_ENABLED, true)
+      .set(MAX_LOG_AGE_S.key, s"${maxAge}ms")
+      .set(MAX_LOG_NUM, 2)
+    val provider = new FsHistoryProvider(conf, clock)
+
+    def newCompletedLogFile(appId: String, lastModified: Long): File = {
+      val log = newLogFile(appId, None, inProgress = false)
+      writeFile(log, None,
+        SparkListenerApplicationStart(appId, Some(appId), 1L, "test", None),
+        SparkListenerApplicationEnd(2L))
+      log.setLastModified(lastModified)
+      log
+    }
+    val log1 = newCompletedLogFile("app1", 1000L)
+    val log2 = newCompletedLogFile("app2", 5000L)
+    val log3 = newCompletedLogFile("app3", 6000L)
+
+    // Load all logs while they are within the max age, before the cleaner 
runs.
+    provider.checkForLogs()
+    provider.getListing().size should be (3)
+
+    // Only app1 exceeds the max age. Removing it leaves exactly the maximum 
number of logs,
+    // so the max number policy should not remove anything more.
+    clock.setTime(maxAge + 2000L)
+    updateAndCheck(provider) { list =>
+      list.map(_.id) should contain theSameElementsAs Seq("app2", "app3")
+    }
+    assert(!log1.exists())
+    assert(log2.exists())
+    assert(log3.exists())
+    assert(provider.listing.count(classOf[LogInfo]) === 2)
+  }
+
   test("backwards compatibility with LogInfo from Spark 2.4") {
     case class LogInfoV24(
          logPath: String,


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to