HyukjinKwon commented on code in PR #59144:
URL: https://github.com/apache/spark/pull/59144#discussion_r4140900374


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/StateStore.scala:
##########
@@ -1625,13 +1628,69 @@ object StateStore extends Logging {
     if (version < 0) {
       throw QueryExecutionErrors.unexpectedStateStoreVersion(version)
     }
-    val (storeProvider, shouldForceSnapshotUpload) = 
getStateStoreProvider(storeProviderId,
-      keySchema, valueSchema, keyStateEncoderSpec, useColumnFamilies, 
storeConf, hadoopConf,
-      useMultipleValuesPerKey, stateSchemaBroadcast)
-    storeProvider.getStore(version, stateStoreCkptId, 
shouldForceSnapshotUpload)
+    withClosedProviderRetry(storeProviderId) { createUnpublishedProvider =>
+      getStateStoreProvider(storeProviderId,
+        keySchema, valueSchema, keyStateEncoderSpec, useColumnFamilies, 
storeConf, hadoopConf,
+        useMultipleValuesPerKey, stateSchemaBroadcast, 
createUnpublishedProvider)
+    } { (storeProvider, shouldForceSnapshotUpload) =>
+      storeProvider.getStore(version, stateStoreCkptId, 
shouldForceSnapshotUpload)
+    }
   }
   // scalastyle:on
 
+  private def withClosedProviderRetry[T](
+      storeProviderId: StateStoreProviderId)(
+      getProvider: Boolean => (StateStoreProvider, Boolean))(
+      loadStore: (StateStoreProvider, Boolean) => T): T = {
+    val (firstProvider, firstShouldForceSnapshotUpload) = getProvider(false)
+    try {
+      loadStore(firstProvider, firstShouldForceSnapshotUpload)
+    } catch {
+      case error: StateStoreInvalidStateMachineTransition
+        if isClosedProviderLoadTransition(error) =>
+        loadedProviders.synchronized {
+          if (loadedProviders.get(storeProviderId).exists(_ eq firstProvider)) 
{
+            loadedProviders.remove(storeProviderId)
+          } else {
+            None
+          }
+        }.foreach { provider =>
+          unloadedProvidersToClose.add(
+            (storeProviderId, provider, MaintenanceOpRequest.All))
+        }
+        // Create the replacement without publishing it in loadedProviders yet.
+        val (replacementProvider, replacementShouldForceSnapshotUpload) = 
getProvider(true)

Review Comment:
   **Non-blocking (P2):** This replacement is created with 
`createUnpublishedProvider = true`, so it is not in `loadedProviders` while 
`loadStore` runs. If another task on the same executor calls `StateStore.get` 
for the same `StateStoreProviderId` during that window (the Cartesian siblings 
this PR describes are exactly that case), `getOrElseUpdate` creates and 
publishes a second provider. That task then acquires its store immediately, 
because the two providers have independent `RocksDBStateMachine`s and separate 
local RocksDB directories. In the baseline, both callers would share the single 
cached provider and the second would wait in `acquireStamp`.
   
   When this caller then finds `loadedProviders.contains(storeProviderId)`, it 
queues `replacementProvider` for close but still returns the store it loaded 
from it. Two stores for the same partition and version are now acquired 
concurrently on one executor, and both can commit checkpoint files for that 
version. Maintenance also runs snapshot/cleanup against the replacement and 
blocks in `close()` until release (up to `lockAcquireTimeoutMs`).
   
   Could the retry instead go through the cache? For example, remove the 
identical stale entry under `loadedProviders.synchronized` as you do now, then 
call the normal `getStateStoreProvider` path (`getOrElseUpdate`) so the 
replacement is published immediately and concurrent callers share it. That 
would drop `createUnpublishedProvider` and the post-load publish/loser branch 
entirely.
   
   See **Shared repair plan 1** in the review body.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/RocksDBStateStoreLockHardeningSuite.scala:
##########
@@ -250,13 +286,86 @@ class RocksDBStateStoreLockHardeningSuite extends 
SparkFunSuite
     val stateMachineObj = provider invokePrivate stateMachine()
     stateMachineObj.asInstanceOf[RocksDBStateMachine].close()
 
-    // Attempting to get a new store after close should fail
-    // with StateStoreInvalidStateMachineTransition
     val exception = intercept[StateStoreInvalidStateMachineTransition] {
       provider.getStore(1)
     }
 
-    assert(exception.getMessage.contains("Old state: CLOSED"))
+    checkError(
+      exception,
+      condition = "STATE_STORE_INVALID_STATE_MACHINE_TRANSITION",
+      parameters = Map(
+        "oldState" -> "CLOSED",
+        "newState" -> "ACQUIRED",
+        "operation" -> "load",
+        "storeId" -> provider.stateStoreId.toString))
+  }
+
+  test("SPARK-59877: StateStore retries a cached closed provider") {

Review Comment:
   **Nit (P3):** This test covers `StateStore.get` with a replacement that 
loads successfully and wins the publish. None of the new tests call 
`getReadOnly` on a closed cached provider, issue a concurrent same-id 
`StateStore.get` while the replacement is blocked, or make the replacement's 
`getStore` throw. So regressions in read-only routing, the losing-publish 
branch, or the failure cleanup (which would leak a RocksDB instance) would not 
be caught. `BlockingReplacementProvider` already makes these interleavings 
deterministic, so a concurrent second caller plus a throwing variant should be 
cheap to add.
   
   See **Shared repair plan 1** in the review body.



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