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


##########
core/src/main/scala/org/apache/spark/scheduler/local/LocalSchedulerBackend.scala:
##########
@@ -137,6 +143,48 @@ private[spark] class LocalSchedulerBackend(
     SparkHadoopUtil.get.addDelegationTokens(tokens, conf)
   }
 
+  /**
+   * Start the UserCredentialManager if OIDC credential propagation is 
enabled, mirroring
+   * CoarseGrainedSchedulerBackend. Runs independently of 
Kerberos/HadoopDelegationTokenManager.
+   *
+   * In local mode the driver and the single executor share this JVM and the 
same
+   * `SparkEnv.get.userCredentials`, so the propagation callback simply 
updates that reference
+   * (there is no remote executor to message); the in-JVM Executor picks up 
credentials from the
+   * same store via TaskDescription. Driver-side filesystem access uses the 
provider wiring that
+   * the selection phase (UserCredentialManager.applyProviderProperties) 
already applied to the
+   * driver's Hadoop Configuration.
+   */
+  private def setupUserCredentialManager(): Unit = {
+    // Capture this backend's SparkEnv once, rather than looking up the global 
SparkEnv.get on
+    // every callback. stop() only waits a bounded time for the renewal 
thread, so a renewal that
+    // outlives this SparkContext must not write into a *different* 
SparkContext's credential
+    // store (e.g. a new context created in the same JVM by a notebook, test, 
or Spark Connect
+    // session). Binding to this env ensures a late renewal updates only this 
application's store,
+    // which is harmless once this env is stopped. 
(CoarseGrainedSchedulerBackend avoids the issue
+    // differently, by routing updates through its own already-stopped 
driverEndpoint.)
+    val env = SparkEnv.get
+    // Reuse the loader from SparkContext's selection phase (Some when OIDC is 
enabled, None
+    // otherwise). Passing the Option straight through keeps SparkContext as 
the single owner of
+    // the loader: create() enforces that an enabled configuration has a 
loader rather than
+    // silently allocating one here that no one would close.
+    userCredentialManager = UserCredentialManager.create(conf, { (version, 
credentials) =>

Review Comment:
   `conf` here is `sc.getConf`, i.e. a clone 
(`SparkContext.createTaskScheduler` passes `sc.getConf` to 
`LocalSchedulerBackend`), while `CoarseGrainedSchedulerBackend` binds the 
manager to `scheduler.sc.conf`. `UserCredentialManager.start()` runs 
`resolveCredentials(ctx, applyProperties = true)`, whose fallback writes 
provider-declared `spark.*` keys into `sparkConf`. In local mode those writes 
land in the clone and never reach `sc.conf`, so the fallback silently does 
nothing here while it works in cluster mode. Shall we pass `scheduler.sc.conf` 
for parity?



##########
core/src/main/scala/org/apache/spark/scheduler/local/LocalSchedulerBackend.scala:
##########
@@ -137,6 +143,48 @@ private[spark] class LocalSchedulerBackend(
     SparkHadoopUtil.get.addDelegationTokens(tokens, conf)
   }
 
+  /**
+   * Start the UserCredentialManager if OIDC credential propagation is 
enabled, mirroring
+   * CoarseGrainedSchedulerBackend. Runs independently of 
Kerberos/HadoopDelegationTokenManager.
+   *
+   * In local mode the driver and the single executor share this JVM and the 
same
+   * `SparkEnv.get.userCredentials`, so the propagation callback simply 
updates that reference
+   * (there is no remote executor to message); the in-JVM Executor picks up 
credentials from the
+   * same store via TaskDescription. Driver-side filesystem access uses the 
provider wiring that
+   * the selection phase (UserCredentialManager.applyProviderProperties) 
already applied to the
+   * driver's Hadoop Configuration.
+   */
+  private def setupUserCredentialManager(): Unit = {
+    // Capture this backend's SparkEnv once, rather than looking up the global 
SparkEnv.get on
+    // every callback. stop() only waits a bounded time for the renewal 
thread, so a renewal that
+    // outlives this SparkContext must not write into a *different* 
SparkContext's credential
+    // store (e.g. a new context created in the same JVM by a notebook, test, 
or Spark Connect
+    // session). Binding to this env ensures a late renewal updates only this 
application's store,
+    // which is harmless once this env is stopped. 
(CoarseGrainedSchedulerBackend avoids the issue
+    // differently, by routing updates through its own already-stopped 
driverEndpoint.)
+    val env = SparkEnv.get

Review Comment:
   Since the goal is to bind to this backend's own env, `scheduler.sc.env` 
expresses that directly instead of depending on what the global `SparkEnv.get` 
points at when `start()` runs. Also, the callback only needs the store: 
capturing `val store = scheduler.sc.env.userCredentials` rather than the whole 
`SparkEnv` avoids pinning the stopped context's 
BlockManager/MemoryManager/RpcEnv if a renewal outlives the 10s 
`awaitTermination` in `stop()`.
   
   Minor: CGSB stops the manager before `StopDriver`, so a late renewal there 
still reaches a live `DriverEndpoint` (which reads the global `SparkEnv.get`). 
The comment's claim that CGSB avoids the issue that way is not quite accurate.



##########
core/src/test/scala/org/apache/spark/deploy/security/UserCredentialManagerSuite.scala:
##########
@@ -742,13 +743,80 @@ class UserCredentialManagerSuite extends SparkFunSuite {
     assert(conf.get("spark.fake.credentials.enabled") === "true")
   }
 
+  test("selection then resolution reuse one loader and initialize the provider 
exactly once") {
+    // Ordering invariant behind the driver-side fix: the selection phase
+    // (applyProviderProperties) selects the provider WITHOUT init() and 
applies its declared
+    // properties, then the resolution phase (start(), via 
UserCredentialManager.create) reuses
+    // the SAME loader so the provider is initialized exactly once. This 
mirrors what
+    // SparkContext (selection) and the scheduler backend (resolution) do at 
runtime -- including
+    // LocalSchedulerBackend now that local mode runs a resolution phase.
+    val conf = createSparkConf()
+    conf.set("spark.security.oidc.provider.fake",
+      "org.apache.spark.security.FakeCredentialProvider")
+
+    // Selection phase: applies properties and returns the loader to reuse.
+    val loaderOpt = UserCredentialManager.applyProviderProperties(conf)
+    assert(loaderOpt.isDefined)
+    val loader = loaderOpt.get
+    assert(conf.get("spark.hadoop.fs.fake.credentials.provider") ===
+      "org.apache.spark.security.FakeExecutorCredentialProvider")
+
+    // Selection must NOT have initialized the provider 
(selectProviderForProperties skips init).
+    val confMap = conf.getAll
+      .filter { case (k, _) => k.startsWith("spark.security.oidc.") }
+      .toMap.asJava
+    // Observe the SAME provider instance the loader caches, without 
initializing it, and assert
+    // the selection phase left it uninitialized.
+    val provider = loader.selectProviderForProperties("fake", confMap).get()
+      .asInstanceOf[FakeCredentialProvider]
+    assert(provider.getInitCount === 0,
+      "the selection phase must not initialize the provider")
+    // The first providerFor() call (resolution path) performs the single 
init().
+    val resolved = loader.providerFor("fake", confMap).get()

Review Comment:
   This `providerFor` call initializes the provider before `manager.start()`, 
so the `getInitCount === 1` assertion after `start()` only checks that 
`CredentialProviderLoader` is idempotent, not that `create()`/`start()` reused 
the selection-phase loader. `initCount` is per instance: if `create()` 
regressed to allocating a fresh loader, `start()` would initialize a different 
`FakeCredentialProvider` and this test would still pass. Dropping this call 
(keeping `selectProviderForProperties` + `=== 0`) and asserting `=== 1` only 
after `start()` makes the test prove the invariant in its name. Also consider 
`loader.closeAll()` in `finally` so the initialized provider is closed.



##########
core/src/main/scala/org/apache/spark/scheduler/local/LocalSchedulerBackend.scala:
##########
@@ -137,6 +143,48 @@ private[spark] class LocalSchedulerBackend(
     SparkHadoopUtil.get.addDelegationTokens(tokens, conf)
   }
 
+  /**
+   * Start the UserCredentialManager if OIDC credential propagation is 
enabled, mirroring
+   * CoarseGrainedSchedulerBackend. Runs independently of 
Kerberos/HadoopDelegationTokenManager.
+   *
+   * In local mode the driver and the single executor share this JVM and the 
same
+   * `SparkEnv.get.userCredentials`, so the propagation callback simply 
updates that reference
+   * (there is no remote executor to message); the in-JVM Executor picks up 
credentials from the
+   * same store via TaskDescription. Driver-side filesystem access uses the 
provider wiring that
+   * the selection phase (UserCredentialManager.applyProviderProperties) 
already applied to the
+   * driver's Hadoop Configuration.
+   */
+  private def setupUserCredentialManager(): Unit = {

Review Comment:
   This is a near-verbatim copy of 
`CoarseGrainedSchedulerBackend.setupUserCredentialManager`/`stopUserCredentialManager`,
 and the two already differ (captured env vs. global `SparkEnv.get`). Since the 
sibling `HadoopDelegationTokenManager` lifecycle lives once in 
`SupportsDelegationToken` for both backends, how about hoisting 
`userCredentialManager`, `setupUserCredentialManager()` and 
`stopUserCredentialManager()` there with a single `protected def 
propagateUserCredentials(version: Long, credentials: Array[Byte]): Unit` hook? 
CGSB would implement it via `driverEndpoint.send(UpdateUserCredentials(...))` 
and this backend via the direct store write.



##########
core/src/main/scala/org/apache/spark/scheduler/local/LocalSchedulerBackend.scala:
##########
@@ -197,8 +246,20 @@ private[spark] class LocalSchedulerBackend(
   }
 
   private def stop(finalState: SparkAppHandle.State): Unit = {
-    localEndpoint.ask(StopExecutor)
-    stopTokenManager()
+    // Ensure both managers are always stopped, even if stopping the executor 
endpoint throws.
+    // The UserCredentialManager renewal thread must be shut down before 
SparkContext.stop()
+    // closes the shared CredentialProviderLoader, otherwise a renewal task 
could race against an
+    // already-closed loader. Each step is isolated so that a failure in one 
does not skip the
+    // others (mirrors CoarseGrainedSchedulerBackend.stop, which stops the 
managers in a finally).
+    Utils.tryLogNonFatalError {
+      localEndpoint.ask(StopExecutor)

Review Comment:
   `ask` is non-blocking and turns failures into a failed `Future`, so the only 
synchronous exception this wrapper can catch is the NPE when `localEndpoint` is 
still null, i.e. a launcher stop request arriving before `start()` 
(`launcherBackend.connect()` runs in the constructor and `onStopRequest` fires 
on its own thread). A null guard like CGSB's `if (driverEndpoint != null)` plus 
`try { ... } finally { stopTokenManager(); stopUserCredentialManager() }` would 
state the invariant instead of logging an NPE.
   
   Relatedly, if `stop(KILLED)` lands before `manager.start()`, 
`stopUserCredentialManager()` is a no-op and `start()` still brings up the 
renewal thread on a killed app until `SparkContext.stop()`. A `stopped` flag 
checked in `start()` would close that. The shape is pre-existing for the token 
manager, but it now involves a thread doing network I/O.



##########
core/src/main/scala/org/apache/spark/scheduler/local/LocalSchedulerBackend.scala:
##########
@@ -137,6 +143,48 @@ private[spark] class LocalSchedulerBackend(
     SparkHadoopUtil.get.addDelegationTokens(tokens, conf)
   }
 
+  /**
+   * Start the UserCredentialManager if OIDC credential propagation is 
enabled, mirroring
+   * CoarseGrainedSchedulerBackend. Runs independently of 
Kerberos/HadoopDelegationTokenManager.
+   *
+   * In local mode the driver and the single executor share this JVM and the 
same
+   * `SparkEnv.get.userCredentials`, so the propagation callback simply 
updates that reference
+   * (there is no remote executor to message); the in-JVM Executor picks up 
credentials from the
+   * same store via TaskDescription. Driver-side filesystem access uses the 
provider wiring that
+   * the selection phase (UserCredentialManager.applyProviderProperties) 
already applied to the
+   * driver's Hadoop Configuration.
+   */
+  private def setupUserCredentialManager(): Unit = {
+    // Capture this backend's SparkEnv once, rather than looking up the global 
SparkEnv.get on
+    // every callback. stop() only waits a bounded time for the renewal 
thread, so a renewal that
+    // outlives this SparkContext must not write into a *different* 
SparkContext's credential
+    // store (e.g. a new context created in the same JVM by a notebook, test, 
or Spark Connect
+    // session). Binding to this env ensures a late renewal updates only this 
application's store,
+    // which is harmless once this env is stopped. 
(CoarseGrainedSchedulerBackend avoids the issue
+    // differently, by routing updates through its own already-stopped 
driverEndpoint.)
+    val env = SparkEnv.get
+    // Reuse the loader from SparkContext's selection phase (Some when OIDC is 
enabled, None
+    // otherwise). Passing the Option straight through keeps SparkContext as 
the single owner of
+    // the loader: create() enforces that an enabled configuration has a 
loader rather than
+    // silently allocating one here that no one would close.
+    userCredentialManager = UserCredentialManager.create(conf, { (version, 
credentials) =>
+      // No remote executors in local mode; update the shared credential store 
directly so that
+      // subsequently dispatched tasks (and driver-side access) observe the 
new credentials.
+      VersionedCredentials.updateIfNewer(env.userCredentials, version, 
credentials)
+    }, scheduler.sc.userCredentialProviderLoader)
+    userCredentialManager.foreach { manager =>
+      val (version, initialCredentials) = manager.start()
+      // Store initial credentials synchronously so they are available for 
TaskDescription
+      // (task dispatch) immediately. The onCredentialsUpdate callback above 
also runs the same
+      // updateIfNewer, so this is idempotent.
+      VersionedCredentials.updateIfNewer(env.userCredentials, version, 
initialCredentials)

Review Comment:
   `UserCredentialManager.start()` invokes `onCredentialsUpdate` synchronously 
before returning, and the callback above is itself `updateIfNewer` on the same 
store, so this second `updateIfNewer` is always a no-op here. The rationale in 
CGSB applies only because its callback goes through an async 
`driverEndpoint.send`. `userCredentialManager.foreach(_.start())` would be 
enough, with a note that the callback performs the synchronous store.



##########
core/src/test/scala/org/apache/spark/scheduler/local/LocalSchedulerBackendSuite.scala:
##########
@@ -0,0 +1,128 @@
+/*
+ * 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.scheduler.local
+
+import java.io.File
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.time.Instant
+import java.util.Base64
+
+import org.apache.spark.{LocalSparkContext, SparkConf, SparkContext, SparkEnv, 
SparkFunSuite}
+import org.apache.spark.deploy.security.UserCredentialManager
+import org.apache.spark.internal.config._
+
+/**
+ * Tests that [[LocalSchedulerBackend]] starts a `UserCredentialManager` in 
local mode when OIDC
+ * credential propagation is enabled, for parity with 
`HadoopDelegationTokenManager` (which
+ * `LocalSchedulerBackend` already runs via `createTokenManager()`). This is 
the SPARK-59296
+ * follow-up: before it, OIDC was a no-op in local mode.
+ */
+class LocalSchedulerBackendSuite extends SparkFunSuite with LocalSparkContext {
+
+  private var tokenFile: File = _
+
+  override def beforeEach(): Unit = {
+    super.beforeEach()
+    tokenFile = File.createTempFile("oidc-token-", ".jwt")
+    tokenFile.deleteOnExit()
+    // A real (unsigned) JWT with the claims FileTokenIngestor requires: sub + 
iss (+ exp).
+    Files.write(tokenFile.toPath, makeJwt().getBytes(StandardCharsets.UTF_8))
+  }
+
+  override def afterEach(): Unit = {
+    try {
+      if (tokenFile != null) tokenFile.delete()
+    } finally {
+      super.afterEach()
+    }
+  }
+
+  /** Build a minimal unsigned JWT (header.payload) that FileTokenIngestor can 
parse. */
+  private def makeJwt(): String = {

Review Comment:
   The same unsigned-JWT builder is added inline in 
`UserCredentialManagerSuite` too, and `FileTokenIngestorSuite` already builds 
these with jjwt 
(`Jwts.builder().subject(..).issuer(..).expiration(..).compact()`), which is on 
core's test classpath. One shared helper (or `Jwts.builder()` inline) would 
avoid a third hand-rolled copy. Likewise `withTempDir` from `SparkFunSuite` 
would replace the `createTempFile`/`deleteOnExit`/`afterEach` plumbing. For the 
new `UserCredentialManagerSuite` test, the 4-arg constructor with 
`createIngestor(createUserContext())` needs no token file at all.



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