micheal-o commented on PR #59144:
URL: https://github.com/apache/spark/pull/59144#issuecomment-6030266239

   **This is an AI assisted review. This is an initial review and doesn't mean 
I'm fine with this change or approving it.**
   
   ## Review Summary
   This PR does two things: (1) `StateStore.get`/`getReadOnly` retry once on a 
CLOSED->ACQUIRED load failure, using an unpublished replacement provider, and 
(2) Cartesian locality prefers the stateful left side. Retrying once is enough, 
since nobody else can see or close the unpublished replacement while it loads, 
and first-writer-wins holds. I agree with HyukjinKwon that two providers for 
the same id coexisting is already part of today's lifecycle. I don't agree that 
`close()` blocks until the store is released, though: it waits at most 
`lockAcquireTimeoutMs` (120s by default), so the losing replacement can leak. 
The new locality walk also re-implements 
`DAGScheduler.getPreferredLocsInternal` without its safeguards.
   
   ## Required Changes
   1. **StateStore.scala:1671-1681 - the losing replacement is queued for close 
while its store is still acquired.** Walk-through:
      - T1 retries and loads its store from replacement R.
      - T2 publishes Q in the meantime.
      - T1 sees `contains`, enqueues R, and returns R's store. That store stays 
acquired for the rest of the task, because `UnsafeCartesianRDD.compute` buffers 
rdd2 and then reads rdd1 lazily.
      - Maintenance runs snapshot, then cleanup, then `closeProvider`. 
`RocksDBStateMachine.close()` blocks a maintenance thread for up to 
`lockAcquireTimeoutMs` and then throws `unreleasedThreadError`.
      - The `finally` in `closeProvider` (1805) calls `setUnloaded()` anyway. 
The NonFatal handler's second `closeProvider` then sees the provider as 
unloaded and returns, so `rocksDB.close()` never runs. R's native memory and 
local dir leak for the life of the executor.
   
      The ownership-move path can hit this by chance today; this branch hits it 
deterministically for any task that runs longer than the timeout.
   
      Fix options:
      - Abort R's store, enqueue R (it is now RELEASED and closes cleanly), and 
load through the published Q with `getProvider(false)`.
      - Or enqueue R from a task-completion listener instead of right away.
   
      The test at RocksDBStateStoreLockHardeningSuite.scala:430-475 asserts the 
current behavior and will need updating.
   2. **StateStore.scala:1662 / 1717-1745 - the unpublished provider leaks if 
`reportActiveStoreInstance` throws.**
      - With `createUnpublishedProvider = true`, `createAndInit` opens RocksDB 
eagerly (a native handle plus a local dir).
      - `getStateStoreProvider` then makes the `askSync` RPC at 1745, which can 
time out.
      - If it throws, nothing references the provider: it isn't in 
`loadedProviders`, and the handler at 1664-1669 only covers `loadStore`.
   
      Fix: create the unpublished provider in a separate helper (see Code 
Design), and close it before rethrowing on any failure after creation.
   3. **CartesianRDD.scala:76-107 - drop 
`preferredLocationsThroughNarrowDependencies`.**
      - It duplicates `DAGScheduler.getPreferredLocsInternal`, but without the 
`visited` set (SPARK-695), so a diamond-shaped narrow lineage is explored 
exponentially, and it ignores `getCacheLocs`.
      - It runs n1*n2 times per stage, and each run makes a coordinator 
`askSync` from the DAGScheduler event loop. That is about 40k RPCs per batch at 
200x200.
      - `CartesianRDD.getDependencies` already lists rdd1 first. So when the 
flag is set, return just `rdd1.preferredLocations(s1)`, even if it is `Nil`, 
and the scheduler walks the left chain and then falls back to rdd2 by itself.
      - Tests can assert through `sc.dagScheduler.getPreferredLocs`.
   4. **RocksDBStateStoreLockHardeningSuite.scala:352/410/445/493 - the retry 
tests don't reproduce the race as it happens in production.**
      - Every test calls `close()` on a provider that is still cached, which no 
production path does. In production the provider is first removed by an 
ownership move, then closed by maintenance while the task waits in 
`acquireStamp`; that is the `else None` branch at 1652.
      - Pause instance 1 before `acquireStamp`, then remove and close it via 
`StateStore.closeProvider` from the test thread.
      - Also add tests for:
        - the losing replacement held past `lockAcquireTimeoutMs` (the suite 
already uses 2000ms);
        - `getProvider(true)` throwing;
        - a replacement that fails inside `rocksDB.load`. The current 
failing-replacement test throws before `super.getStore`.
   
   ## Suggestions
   - **StateStore.scala:1650-1662 - the retry always builds a fresh provider, 
even when `loadedProviders` already holds a different, healthy one.**
     - Interleaving: P is closed, T3 publishes P2, then T1 creates P3. P3 does 
a full DFS load and is guaranteed to lose (item 1).
     - If the cached instance isn't `firstProvider`, retry through the cache 
with `getProvider(false)`, and create an unpublished provider only when the 
cache is empty or still holds the stale instance.
   - **StateStore.scala:1651-1656 - can the identity-removal branch run in 
production?** I couldn't find a production path that leaves a CLOSED provider 
in `loadedProviders`: every close path removes it under the lock first. When 
the branch does fire, enqueueing an already-closed provider makes 
`verifyForMaintenance` throw and logs a spurious "Closing provider due to 
error" warning. Please confirm it is reachable, or skip the enqueue when the 
provider is already closed.
   - **StateStore.scala:1664-1669 - a replacement that failed to load has no 
stamp and was never published.** Close it directly on the task thread instead 
of queuing it for `MaintenanceOpRequest.All`, which runs snapshot plus DFS 
cleanup on an instance that never loaded.
   - **StateStore.scala:1658/1667/1679 - none of these enqueues call 
`maintenanceTask.triggerNow()`.** The ownership-move path does (1767), so these 
providers wait for the next periodic tick.
   - **StateStore.scala:1686-1692 - `isClosedProviderLoadTransition` matches on 
message-parameter strings.** Renaming a state or an operation would silently 
disable the retry. The `newState == "ACQUIRED"` check is also always true for 
LOAD. Prefer a typed signal, e.g. a dedicated subclass raised from 
`RocksDBStateMachine.acquireStamp`.
   - **StateStore.scala:1666 - use `NonFatal` instead of `case error: 
Throwable`.** It also shadows the outer `error` from 1649.
   - **CartesianProductExec.scala:97 - 
`left.exists(_.isInstanceOf[StateStoreWriter])` also matches a writer below an 
Exchange.** In that case there is no state store in this stage, yet right-side 
locality is still dropped. Stop the traversal at exchanges.
   - **CartesianProductExec.scala:97 - only the left side is handled.** For 
`static.join(statefulStream)` the stateful input is on the right, its siblings 
still scatter, and the query relies on the retry alone. Either handle that side 
too or state the limitation.
   - **StateStore.scala:1662 - `getProvider(true)` still calls 
`reportActiveStoreInstance` for a provider that isn't published yet.** If 
another task's report removes this id in the meantime, the provider is 
published anyway and stays a non-owner until maintenance evicts it.
   - nit: `preferFirstParentLocations` (CartesianRDD.scala:54) and 
`preferLeftLocations` (CartesianProductExec.scala:42) name the same thing 
differently. Pick one.
   - nit: CartesianRDD.scala:91 - this comment in core RDD code refers to SQL's 
StateStoreRDD and codegen wrappers. It goes away with item 3.
   - nit: CartesianProductExec.scala:96 - the comment just restates the code. 
Please remove it.
   
   ## Performance
   - With the flag on, all `numRight` siblings of a left partition prefer the 
same executor and serialize in `acquireStamp` on one provider.
     - Each sibling recomputes the stateful partition, and siblings can occupy 
executor slots just to wait.
     - Any sibling that holds the store longer than `lockAcquireTimeoutMs` 
makes the next one fail with `unreleasedThreadError`. Spreading siblings across 
executors hides this today.
     - Please benchmark with `numRight` at or above the executor's core count, 
and report task wait times.
   - Each retry does a full RocksDB load from DFS into a new local dir while P 
may still hold memory. That is fine for a rare path, but the description should 
say so.
   - The walk's n1*n2 coordinator RPCs and its exponential behavior on diamond 
lineages are covered by item 3.
   
   ## Code Design
   - The root cause is that the Cartesian join recomputes the stateful left 
subtree once per right partition, and each copy commits the same state version. 
Locality hints and the retry only reduce the symptoms. A planner-level fix 
would remove the cause: broadcast or materialize the non-stateful side, or 
don't plan `CartesianProductExec` over a `StateStoreWriter`. Worth discussing 
in the JIRA.
   - Have you checked this with checkpoint v2 
(`enableStateStoreCheckpointIds`)? There, n2 sibling commits of one partition 
each report a different unique id, and the first-writer-wins argument depends 
on how that resolves.
   - StateStore.scala:1710 - the `createUnpublishedProvider` flag turns 
`getStateStoreProvider` into "create, don't cache", and the `Boolean => 
(StateStoreProvider, Boolean)` callback in `withClosedProviderRetry` is hard to 
follow. Split out a dedicated helper that creates an unpublished provider, and 
keep `getStateStoreProvider` for the cached path. That also makes item 2 easy 
to fix.
   - This retry only applies to RocksDB, but it lives in the provider-agnostic 
`StateStore` object.
   
   ## Testing
   - StreamingCartesianStateStorePlacementSuite.scala:98-113
     - `stateStoreLocations` copies the production walk, so the assertion is 
circular. Compare against the location the coordinator reports for the provider.
     - It checks a hand-built `cartesian.copy(...)`, not the executed plan.
     - `local[2]` has a single host, so real placement is never exercised, and 
the test name ("independently scheduled tasks") claims more than the test 
checks.
   - RocksDBStateStoreLockHardeningSuite.scala:465/504 - these assert on 
`unloadedProvidersToClose` while `SharedSparkSession`'s maintenance thread can 
drain it, which is timing-dependent. Stop maintenance for these tests or assert 
through a hook.
   - RocksDBStateStoreLockHardeningSuite.scala:42-46, 70
     - `instance == 2` depends on a global creation counter, so any extra 
`createAndInit` shifts the numbering.
     - `loadStarted`/`continueLoad` are plain `var`s read from other threads 
without `@volatile`.
   - RDDSuite.scala:921-961 - no cases for:
     - a `ShuffleDependency` boundary;
     - several narrow deps where only the second has locations;
     - a diamond lineage.
   - No test that a non-stateful `CartesianProductExec` keeps the existing 
union behavior.
   
   ## PR Hygiene
   - The description says the retry "handles the remaining race safely". Please 
qualify that and state:
     - after a lost publish race, two acquired stores for one partition can 
coexist on one executor;
     - the retry only applies to RocksDB;
     - the right-side stateful case isn't covered;
     - the locality flag only changes behavior when the right side has 
locations. When it has none (e.g. `spark.range`), the DAGScheduler walk already 
lands on the left state-store location.
   - These are two independent fixes: a core scheduling hint, and an SS 
provider retry. Consider splitting them into separate PRs so core reviewers can 
own the CartesianRDD part and each fix can be reverted on its own.
   - There is no internal conf kill switch for either behavior change.
   - nit: `UnsafeCartesianRDD`'s new constructor parameter has no default. A 
`false` default keeps source compatibility for callers.


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