dongjoon-hyun commented on code in PR #58965:
URL: https://github.com/apache/spark/pull/58965#discussion_r4459563140
##########
core/src/main/scala/org/apache/spark/deploy/history/HistoryServer.scala:
##########
@@ -163,6 +165,38 @@ class HistoryServer(
attachHandler(contextHandler)
}
+ /**
+ * Registers the static resources declared by every available plugin. A
plugin that cannot be
+ * loaded, or whose declared resources cannot be registered, is skipped with
a warning so that
+ * it does not prevent the History Server from starting. Throwable is caught
around both
+ * hasNext and next() because the ServiceLoader iterator can throw
ServiceConfigurationError
+ * or LinkageError for a provider whose class is missing or cannot be linked.
+ */
+ private[history] def setupPluginStaticResources(
+ plugins: Iterable[AppHistoryServerPlugin]): Unit = {
+ val iterator = plugins.iterator
+ var keepLoading = true
+ while (keepLoading) {
+ try {
+ if (iterator.hasNext) {
+ val plugin = iterator.next()
+ try {
+ plugin.staticResources.foreach { case (dir, path) =>
addStaticHandler(dir, path) }
+ } catch {
+ case t: Throwable =>
+ logWarning(log"Failed to set up static resources for " +
Review Comment:
**[11] When one declared entry fails, the earlier entries stay, the later
ones are skipped, and the warning names none of them.**
One `try` covers the whole `foreach` at L184. Say a plugin declares three
entries and the directory of the second is missing. The first entry stays
registered, the third is never tried, and this warning says only that the
plugin's static resources failed, without a directory or a path. The pages that
need the third directory then get 404s, and nothing in the log names that
directory. Could the `try` go inside the `foreach`, with the warning naming the
`(dir, path)` pair? `SQLHistoryServerPlugin` declares only one entry, so this
is only about the contract.
##########
core/src/main/scala/org/apache/spark/deploy/history/HistoryServer.scala:
##########
@@ -163,6 +165,38 @@ class HistoryServer(
attachHandler(contextHandler)
}
+ /**
+ * Registers the static resources declared by every available plugin. A
plugin that cannot be
+ * loaded, or whose declared resources cannot be registered, is skipped with
a warning so that
+ * it does not prevent the History Server from starting. Throwable is caught
around both
+ * hasNext and next() because the ServiceLoader iterator can throw
ServiceConfigurationError
+ * or LinkageError for a provider whose class is missing or cannot be linked.
+ */
+ private[history] def setupPluginStaticResources(
+ plugins: Iterable[AppHistoryServerPlugin]): Unit = {
+ val iterator = plugins.iterator
+ var keepLoading = true
+ while (keepLoading) {
+ try {
+ if (iterator.hasNext) {
+ val plugin = iterator.next()
+ try {
+ plugin.staticResources.foreach { case (dir, path) =>
addStaticHandler(dir, path) }
Review Comment:
**[10] The declared directory is looked up with Spark's class loader, not
the plugin's.**
`loadPlugins()` finds plugins through `Utils.getContextOrSparkClassLoader`
(AppHistoryServerPlugin.scala:63). `addStaticHandler`, however, resolves `dir`
in `JettyUtils.createStaticHandler`, which uses
`Utils.getSparkClassLoader.getResource` (JettyUtils.scala:247). If only the
context class loader can see a plugin, `ServiceLoader` finds the plugin, but
`createStaticHandler` throws "Could not find resource path for Web UI". L186
then logs it, and the files are not served. The new doc
(AppHistoryServerPlugin.scala:45) describes them as "JS/CSS bundled with the
plugin", which suggests the plugin's own class loader.
This does not affect `SQLHistoryServerPlugin`, whose directory is on Spark's
class path. Could the doc say that the directory must be on Spark's class path?
Alternatively, the lookup could use `plugin.getClass.getClassLoader`.
##########
core/src/main/scala/org/apache/spark/deploy/history/HistoryServer.scala:
##########
@@ -155,6 +156,7 @@ class HistoryServer(
attachHandler(ApiRootResource.getServletHandler(this))
addStaticHandler(SparkUI.STATIC_RESOURCE_DIR)
+ setupPluginStaticResources(AppHistoryServerPlugin.loadPlugins())
Review Comment:
**[12] A declared path can take over the History Server's own routes.**
Plugin handlers are attached here, before the `/history` loader handler
(L162-165), and their paths are not checked. Jetty picks the longest matching
context path. Of two handlers with the same path, it picks the one attached
first, which I checked with Jetty 12.1.13. So:
- a plugin that declares `/history` answers every application URL instead of
`loaderServlet`;
- one that declares `/history/x` or `/api/v1/x` takes those requests;
- one that declares `/static` is ignored without any message.
Plugins are trusted code, so this is about catching a mistake early. Could
`setupPluginStaticResources` reject a path that is not under `/static/`, with
the same warning?
##########
sql/core/src/test/scala/org/apache/spark/deploy/history/SQLHistoryServerStaticResourceSuite.scala:
##########
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.deploy.history
+
+import java.net.URI
+
+import org.apache.spark.{SecurityManager, SparkConf, SparkFunSuite}
+import org.apache.spark.internal.config.History.HISTORY_LOG_DIR
+import org.apache.spark.util.Utils
+
+/**
+ * Verifies that the SQL plugin's static resources are served by the History
Server itself, so
+ * they are available before any application UI has been loaded (and stay
available after an
Review Comment:
**[14] The test covers neither the eviction this doc mentions nor the
precedence the fix relies on.**
The doc says the files "stay available after an application UI is evicted
from the cache". The test, however, never loads or evicts an application; it
checks only an empty log directory. The fix and the ACL note in the description
also rely on the History Server's own handler answering before the
`/static/sql` handlers that `SQLTab` and `StreamingQueryTab` attach for each
loaded application. That holds today. With Jetty 12.1.13, of two context
handlers with the same path, the one attached first answers, even after the
other is removed and a new one is added. But this is Jetty's tie-break, and
nothing here would notice if it changed.
Could a case write an event log with one SQL execution, load its UI, detach
it, and then request `/static/sql/spark-sql-viz.js` again? To skip the cache,
it could call `server.attachSparkUI` and `server.detachSparkUI` with the UI
from `provider.getAppUI`. A request made while the UI is attached would also
show which handler answers. If that is too much for this PR, could the doc
describe only what the test checks?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/ui/SQLTab.scala:
##########
@@ -34,11 +34,20 @@ class SQLTab(val sqlStore: SQLAppStatusStore, sparkUI:
SparkUI)
attachPage(new ExecutionPage(this))
parent.attachTab(this)
- parent.addStaticHandler(SQLTab.STATIC_RESOURCE_DIR, "/static/sql")
+ SQLTab.addStaticHandler(parent)
Review Comment:
**[18] Nit: in the History Server, the per-application `/static/sql`
handlers can no longer receive a request.**
The History Server's own handler is attached first and always answers, so in
the History Server the handler that this line adds for each loaded application
is now unreachable. The same goes for the one at StreamingQueryTab.scala:35.
Still, each load creates that handler and adds its filters, plus the
cache-check filter for an incomplete application
(ApplicationCache.scala:193-196). It also wraps and starts the handler, and
rebuilds the root handler collection on attach and again on detach
(HistoryServer.scala:218-220 and 226). That cost is small, but it grows with
the cache churn this PR is about. The live UI still needs this line, so one
option is for `HistoryServer.attachSparkUI` to skip any handler whose context
path the server already serves. This is optional.
##########
core/src/main/scala/org/apache/spark/deploy/history/HistoryServer.scala:
##########
@@ -163,6 +165,38 @@ class HistoryServer(
attachHandler(contextHandler)
}
+ /**
+ * Registers the static resources declared by every available plugin. A
plugin that cannot be
+ * loaded, or whose declared resources cannot be registered, is skipped with
a warning so that
+ * it does not prevent the History Server from starting. Throwable is caught
around both
+ * hasNext and next() because the ServiceLoader iterator can throw
ServiceConfigurationError
+ * or LinkageError for a provider whose class is missing or cannot be linked.
+ */
+ private[history] def setupPluginStaticResources(
+ plugins: Iterable[AppHistoryServerPlugin]): Unit = {
+ val iterator = plugins.iterator
+ var keepLoading = true
+ while (keepLoading) {
+ try {
+ if (iterator.hasNext) {
Review Comment:
**[9] The loop never ends when the lookup fails without moving on.**
After any failure, the loop calls `hasNext` again and relies on the iterator
having moved past the bad entry. The JDK `ServiceLoader` does not always move.
In `LazyClassPathLookupIterator.nextProviderClass`, if the class loader's
`getResources` for
`META-INF/services/org.apache.spark.status.AppHistoryServerPlugin` throws an
`IOException`, `configs` stays `null`. `hasNext` keeps the error and returns
`true`, and `next()` throws it and clears it. The next `hasNext` calls
`getResources` again with the same result. So `new HistoryServer(...)` never
returns, and L195 logs the same warning in a tight loop. I ran this loop
against a `ServiceLoader` whose class loader throws from `getResources`, on JDK
21.0.13 and 27.0.1, and it was still looping after 1000 failures. The test
cannot catch this, because `PluginIterator` always moves on.
`HadoopDelegationTokenManager.loadProviders`
(HadoopDelegationTokenManager.scala:339-352), which I pointed to in item 1, has
the same loop, so this comes f
rom my suggestion as much as from the PR.
Could the loop give up after a few failures in a row with no successful
`next()`, and log that the remaining plugins were skipped? For example:
```scala
var failuresInARow = 0
while (keepLoading && failuresInARow < 10) {
try {
if (iterator.hasNext) {
val plugin = iterator.next()
failuresInARow = 0
// ... register plugin.staticResources as now ...
} else {
keepLoading = false
}
} catch {
case t: Throwable =>
failuresInARow += 1
logWarning("Skipping an AppHistoryServerPlugin that failed to
load", t)
}
}
```
While this catch is being changed: `Throwable` also catches
`OutOfMemoryError` and `StackOverflowError`, and reports them as a plugin that
failed to load. Item 1 suggested `Throwable` because `loadProviders` uses it.
`case t if NonFatal(t) || t.isInstanceOf[LinkageError]` would still cover
`ServiceConfigurationError` and `LinkageError`, and would let a fatal error
stop the startup. The same applies to the inner catch at L186. Either choice is
fine with me.
##########
sql/core/src/main/scala/org/apache/spark/sql/streaming/ui/StreamingQueryTab.scala:
##########
@@ -32,11 +32,7 @@ private[sql] class StreamingQueryTab(
attachPage(new StreamingQueryStatisticsPage(this))
parent.attachTab(this)
- parent.addStaticHandler(StreamingQueryTab.STATIC_RESOURCE_DIR, "/static/sql")
+ SQLTab.addStaticHandler(parent)
Review Comment:
**[16] No Structured Streaming page loads anything from `/static/sql`.**
The pages of this tab load only core files: `/static/d3.min.js`,
`/static/streaming-page.css`, `/static/streaming-page.js` and
`/static/structured-streaming-page.js`
(StreamingQueryStatisticsPage.scala:52-55). The directory behind `/static/sql`
holds only SQL files, such as `spark-sql-viz.js` and `executionpage.js`. This
registration has been unused since SPARK-29543 added it. So the description's
"The live driver UI never calls the new hook, so it still needs them" holds for
`SQLTab`, but not for this tab. Keeping the line is fine for this PR. Could the
description say that only `SQLTab` needs it, or note that this one is unused?
##########
core/src/main/scala/org/apache/spark/status/AppHistoryServerPlugin.scala:
##########
@@ -36,8 +41,25 @@ private[spark] trait AppHistoryServerPlugin {
*/
def setupUI(ui: SparkUI): Unit
+ /**
+ * Static resources (JS/CSS bundled with the plugin) to serve from the
History Server's own UI,
+ * as (classpath resource directory, URL path) pairs. They are registered
once at startup.
+ * Unlike a handler attached in [[setupUI]], which is torn down when the
application cache
+ * evicts that application, these live as long as the History Server.
+ *
+ * This does not replace the registration in [[setupUI]] or in the tabs: the
live driver UI
+ * never calls this hook, so tabs must still register their own handlers.
+ */
+ def staticResources: Seq[(String, String)] = Nil
+
/**
* The position of a plugin tab relative to the other plugin tabs in the
history UI.
*/
def displayOrder: Int = Integer.MAX_VALUE
}
+
+private[spark] object AppHistoryServerPlugin {
+ def loadPlugins(): Iterable[AppHistoryServerPlugin] = {
Review Comment:
**[13] Could `loadPlugins()` skip a broken plugin, so that every caller
handles it the same way?**
The new failure handling covers only the static resources.
`FsHistoryProvider.getAppUI` (FsHistoryProvider.scala:450) and
`rebuildAppStore` (FsHistoryProvider.scala:1441-1444) still iterate the raw
`ServiceLoader` view returned here. So a plugin that
HistoryServer.scala:193-195 skips with a warning at startup still throws
`ServiceConfigurationError` out of every application UI load. The description
says this. Now that the loop exists, though, moving it here would let one place
decide.
For example, `loadPlugins()` could use the loop from
`setupPluginStaticResources` and return a `Seq` of the plugins that loaded.
`setupPluginStaticResources` would then be a plain `foreach`, and the test
could check `loadPlugins` instead of a hand-written iterator.
`HadoopDelegationTokenManager.loadProviders`
(HadoopDelegationTokenManager.scala:339-352) has the same loop, so a small
shared helper could serve both.
One caveat: skipping a plugin in `rebuildAppStore` may not be wanted. A
store kept on disk without that plugin's listeners would stay incomplete after
the plugin is fixed. If so, the helper could still serve `HistoryServer` and
`getAppUI`, and `rebuildAppStore` could keep failing fast.
##########
sql/core/src/test/scala/org/apache/spark/deploy/history/SQLHistoryServerStaticResourceSuite.scala:
##########
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.deploy.history
+
+import java.net.URI
+
+import org.apache.spark.{SecurityManager, SparkConf, SparkFunSuite}
+import org.apache.spark.internal.config.History.HISTORY_LOG_DIR
+import org.apache.spark.util.Utils
+
+/**
+ * Verifies that the SQL plugin's static resources are served by the History
Server itself, so
+ * they are available before any application UI has been loaded (and stay
available after an
+ * application UI is evicted from the cache).
+ */
+class SQLHistoryServerStaticResourceSuite extends SparkFunSuite {
+
+ test("/static/sql resources are served before any application is loaded") {
+ withTempDir { logDir =>
+ val conf = new SparkConf().set(HISTORY_LOG_DIR, logDir.getAbsolutePath)
+ val provider = new FsHistoryProvider(conf)
+ val server = new HistoryServer(conf, provider, new
SecurityManager(conf), 0)
+ try {
+ server.bind()
+ provider.start()
+ val host = Utils.localHostNameForURI()
+ val url = new
URI(s"http://$host:${server.boundPort}/static/sql/spark-sql-viz.js").toURL
+ val (code, body, _) = HistoryServerSuite.getContentAndCode(url)
+ assert(code === 200)
+ assert(body.exists(_.nonEmpty))
+ } finally {
+ server.stop()
+ provider.stop()
Review Comment:
**[15] Nit: `provider.stop()` runs twice.**
`HistoryServer.stop()` already calls `provider.stop()`
(HistoryServer.scala:208), so this line stops the provider a second time: its
executors are shut down again and `listing` is closed again. That is harmless
with the in-memory store. Still, the existing suites call only `server.stop()`
(HistoryServerPageSuite.scala:64 and RealBrowserUIHistoryServerSuite.scala:98).
Could this line and HistoryServerPluginSuite.scala:105 go?
--
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]