GH-5308: Serialize step execution updates with concurrent stop requests - #5448
GH-5308: Serialize step execution updates with concurrent stop requests#5448kyungrae wants to merge 1 commit into
Conversation
When a JobOperator stops a running job, the stopping thread persisted the step execution's stopped state on its own thread, concurrently with the worker thread still committing chunks for the same step execution. Both issued optimistic-locking UPDATEs against the same BATCH_STEP_EXECUTION row, so the stopping thread could fail with OptimisticLockingFailureException. Guard every update to a step execution's metadata with a per-execution lock, held across the surrounding transaction's commit, and share it between the worker and the stopping thread. Re-enables GracefulShutdownFunctionalTests. Issue spring-projects#5308 Signed-off-by: Kyungrae Kim <rlarudfo93@gmail.com>
nikhiln64
left a comment
There was a problem hiding this comment.
The per execution semaphore moved onto AbstractStep and shared with the stopping thread is a clean way to keep a chunk commit from racing the stop update inside one JVM, and the new AbstractStepTests case shows callUnderLock serialising concurrent callers on the same execution. My concern is the change that rides along with it in SimpleJobRepository.update, where stepExecutionDao.synchronizeStatus(stepExecution) is removed from the isStopped or isStopping branch.
That call was doing more than status reconciliation. It reloads VERSION from the row and, when the in memory version is stale, resets stepExecution version to the database value, which is exactly what keeps the next optimistic locking update from throwing OptimisticLockingFailureException when two holders of the same StepExecution both write. The new lock replaces that protection only for writers in the same JVM, since callUnderLock runs the action unlocked when getStepExecutionLock returns null and the comment there names the case, not executing in this JVM. So for a remote partitioned or remote chunking step, the worker updates the row in its own JVM while the operator on the manager stops the same execution through the null lock path with synchronizeStatus now gone, and the reconciliation that used to absorb that race is no longer there.
The added test exercises callUnderLock within a single execute() on one JVM, so it stays green even if the remote path regresses. Could you add a case that stops a step whose execution is not registered in the stopping thread's map, standing in for the remote worker, and asserts the stop update still succeeds against a concurrently bumped version. If that path does still need synchronizeStatus, keeping it only for the null lock branch would restore the old behaviour without giving up the new in JVM lock.
Two smaller things. The semaphore is put into the map at the top of execute() and removed in its finally, so a stop arriving before execute() populates the map, or after it clears, takes the unlocked branch, and for a stop that lands in that window against a still running worker the serialisation is silently skipped. And setting the execution straight to STOPPED instead of STOPPING drops the STOPPING observation entirely, so anything polling for the intermediate state, or restart logic that distinguishes the two, now only ever sees STOPPED. Both may be intended but are worth a line in the description.
|
Thank you for the detailed review — it pushed me to study how 1. On restoring
|
Problem
When
JobOperator.stop(jobExecution)is called while a step is running, the stopping thread persists the step execution's stopped state (jobRepository.update(stepExecution)) on its own thread — concurrently with the worker thread still committing chunks for the same BATCH_STEP_EXECUTION row. Both issue optimistic-locking updates:so one matches 0 rows and fails with
OptimisticLockingFailureException. It is timing- and vendor-sensitive: frequent on MySQL (REPEATABLE READ), occasional on PostgreSQL (READ COMMITTED), almost never on in-memory HSQLDB — which is why CI rarely catches it andGracefulShutdownFunctionalTestswas@Disabled.A contributing factor: SimpleJobRepository.update(StepExecution) re-read the row version via stepExecutionDao.synchronizeStatus(stepExecution) while the job was stopping. Under MySQL REPEATABLE READ that read returns the stale snapshot version, overwriting the correct in-memory version and guaranteeing the stopping thread's update loses.
Solution
Guard every update to a step execution's metadata with a per-execution lock, held across the surrounding transaction's commit, shared between the worker and the stopping thread:
AbstractStepkeeps oneSemaphoreper running step execution and exposesStoppableStep.callUnderLock(StepExecution, Runnable). The worker's start/chunk/final updates and the operator's stop update all run under it, so they serialize and neither observes a stale version.TaskletStepandChunkOrientedSteptake this shared lock around their chunk transactions.SimpleJobRepository.update(StepExecution)no longer re-reads the version while stopping — under the lock the shared in-memory execution already holds the current version (the stale re-read was the root failure on MySQL).STOPPEDdirectly instead ofSTOPPING(which update(JobExecution) upgraded to STOPPED anyway).GracefulShutdownFunctionalTests(disabled under GracefulShutdownFunctionalTests.testStopJob fails intermittently due to a race condition #5308).Validation
JobOperatorFunctionalTests and GracefulShutdownFunctionalTests, 100 runs per vendor (MySQL 8 / PostgreSQL 16 in Docker, HSQLDB in-memory), after the fix:
GracefulShutdownFunctionalTestsGracefulShutdownFunctionalTestsGracefulShutdownFunctionalTestsJobOperatorFunctionalTestsJobOperatorFunctionalTestsJobOperatorFunctionalTests(0 failures / 600 runs. Before-fix numbers: see #5442.) Plus a new AbstractStepTests unit test asserting t updates to one step execution; full spring-batch-core suite passes.
Resolves #5308