Stopping, restart & recovery

This section documents the current Spring Batch line — 6.0.x, on Spring Framework 7 and Spring Boot 4.1.x, with a Java 17+ baseline — as published at the Spring Batch reference documentation. No specific patch version is pinned. Some surfaces (Spring Cloud Task and Spring Cloud Data Flow orchestration, the deployer-based partition handler, and JSR-352) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

Batch jobs are long-running, so they get interrupted: an operator stops one, a pod is evicted, a database goes away. What separates a batch framework from a loop is what happens next.

Natural completion vs. a programmatic stop

A job ends naturally when its last step completes — BatchStatus.COMPLETED, ExitStatus.COMPLETED — or when a step fails and no transition handles the failure, giving FAILED.

A stop is different: it is a request, honoured at the next safe checkpoint, that leaves the execution restartable.

Stopping from outside

JobOperator.stop(jobExecution) sets the execution to STOPPING. The running step notices at its next chunk boundary, finishes the in-flight transaction, and the execution becomes STOPPED:

@Service
public class BatchControlService {

    private final JobOperator jobOperator;
    private final JobRepository jobRepository;

    public BatchControlService(JobOperator jobOperator, JobRepository jobRepository) {
        this.jobOperator = jobOperator;
        this.jobRepository = jobRepository;
    }

    public void stopAll(String jobName) throws Exception {
        for (JobExecution execution : jobRepository.findRunningJobExecutions(jobName)) {
            jobOperator.stop(execution);
        }
    }
}

The stop is cooperative, not immediate: the current chunk is allowed to commit, so no work is lost and no partial chunk is written.

In Spring Batch 6.0 this works for every step type. Previously only chunk-oriented steps polled for a stop request between chunks, so a long-running tasklet ignored stop() entirely. StoppableStep (which extends Step, and whose callback is stop(StepExecution)) is the contract on the step side; a tasklet opts into the same behaviour by implementing StoppableTasklet, whose callback is a no-argument stop():

public class LongRunningTasklet implements StoppableTasklet {

    private volatile boolean stopping;

    @Override
    public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
        while (!stopping && workRemains()) {
            processOneUnit();
        }
        // CONTINUABLE when stopped with work left, so the step is not recorded as
        // COMPLETED and the remaining work is picked up on restart
        return stopping && workRemains() ? RepeatStatus.CONTINUABLE : RepeatStatus.FINISHED;
    }

    @Override
    public void stop() {
        this.stopping = true;   // called by the framework when a stop is requested
    }
}

Stopping from inside

Business logic can stop the job itself by flagging the current step execution. The step finishes its chunk and the job stops:

@Bean
public Step guardedStep(JobRepository jobRepository, PlatformTransactionManager tx) {
    return new StepBuilder("guardedStep", jobRepository)
            .tasklet((contribution, chunkContext) -> {
                if (marketDataService.isStale()) {
                    chunkContext.getStepContext().getStepExecution().setTerminateOnly();
                }
                return RepeatStatus.FINISHED;
            }, tx)
            .build();
}

setTerminateOnly() results in a JobInterruptedException and a STOPPED execution — which, unlike a failure, reads as "deliberately halted" in the metadata.

A flow can also be told to stop declaratively, with .stopAndRestart(step) naming where a restart should pick up — see Step flow & listeners.

Failure and ExitStatus

When a step throws an exception that is not skipped, not retried and not caught by a transition, the step is FAILED and, by default, so is the job. The failing throwables are recorded on the execution:

JobExecution execution = jobRepository.getJobExecution(executionId);
if (execution.getStatus() == BatchStatus.FAILED) {
    execution.getAllFailureExceptions().forEach(throwable -> log.error("job failed", throwable));
    log.error("exit code {} / {}", execution.getExitStatus().getExitCode(),
                                   execution.getExitStatus().getExitDescription());
}

ExitStatus is the outward-facing outcome: COMPLETED, FAILED, STOPPED, NOOP, UNKNOWN, or any custom code a listener sets. Callers — a shell script’s exit code, a flow transition, a monitoring probe — key on that string.

Restart

Restarting means launching the same JobInstance again, which the framework recognises by the identifying parameters (Jobs, instances & parameters). Spring Batch then:

  1. finds the previous JobExecution for that instance;

  2. skips steps that already ended COMPLETED — unless allowStartIfComplete(true) says otherwise;

  3. resumes the first incomplete step, restoring its step-scoped ExecutionContext so a restartable reader continues where it stopped.

// restart the instance whose previous execution is this one
JobExecution failedExecution = jobRepository.getJobExecution(failedExecutionId);
JobExecution restarted = jobOperator.restart(failedExecution);

Three switches shape this behaviour:

Setting Effect

.preventRestart() on the job

The instance may never be restarted; a second launch throws JobRestartException.

.startLimit(n) on a step

The step may be started at most n times across all executions of the instance; exceeding it fails the job with StartLimitExceededException.

.allowStartIfComplete(true) on a step

The step runs again on restart even though it completed — for validation, staging or clean-up steps that must always execute.

Rerunning a completed step

A step that already succeeded is normally skipped on restart. allowStartIfComplete(true) is the supported way to force it:

@Bean
public Step stageInputStep(JobRepository jobRepository, PlatformTransactionManager tx) {
    return new StepBuilder("stageInputStep", jobRepository)
            .tasklet(new CopyInputFileTasklet(), tx)
            .allowStartIfComplete(true)
            .build();
}

Note the constraint this places on the step: it will run more than once for one instance, so it must be idempotent.

A completed job instance cannot be rerun at all. If the same logical work must run twice, give it different identifying parameters — an incrementer is the tidy way (Configuring a job).

Recovering stranded executions

If the JVM dies mid-run, nobody updates the metadata: the execution stays STARTED (or STOPPING) forever, and because Spring Batch believes it is still running, a restart is refused. Historically this was fixed by hand with an UPDATE statement.

Spring Batch 6.0 adds JobOperator.recover(), which finds such executions and marks them appropriately so they can be restarted:

@Component
public class StartupRecovery implements ApplicationRunner {

    private final JobOperator jobOperator;
    private final JobRepository jobRepository;

    public StartupRecovery(JobOperator jobOperator, JobRepository jobRepository) {
        this.jobOperator = jobOperator;
        this.jobRepository = jobRepository;
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        for (JobExecution stranded : jobRepository.findRunningJobExecutions("endOfDayJob")) {
            // nothing can genuinely be running: this process has just started
            jobOperator.recover(stranded);
        }
    }
}

abandon(executionId) remains available for the opposite decision: mark a stopped or crashed execution ABANDONED so that restart logic ignores it entirely and the instance is considered closed.

Failing a step that found no work

An empty input is usually a bug in the upstream feed, not a success. NoWorkFoundStepExecutionListener turns "read nothing" into a failure:

@Bean
public Step loadTradesStep(JobRepository jobRepository, PlatformTransactionManager tx,
                           ItemReader<Trade> reader, ItemWriter<Trade> writer) {
    return new StepBuilder("loadTradesStep", jobRepository)
            .<Trade, Trade>chunk(500, tx)
            .reader(reader)
            .writer(writer)
            .listener(new NoWorkFoundStepExecutionListener())
            .build();
}

The listener inspects readCount in afterStep and returns ExitStatus.FAILED when it is zero. When an empty input is legitimate, route on it instead — return a custom NO_DATA exit code from a listener and add a .on("NO_DATA").to(…​) transition, as in Step flow & listeners. This and related recipes are in Common batch patterns.

Further reading