Explain RepeatStatus.FINISHED versus RepeatStatus.CONTINUABLE returned from Tasklet.execute().
answer
- FINISHED -> stop; CONTINUABLE -> loop again
- Each pass = new transaction, commits between
- null means FINISHED
- continueIf(boolean) helper
- Persist offset in ExecutionContext for restart
basics
~10 sFINISHED means the Tasklet is done and won't be called again. CONTINUABLE means there's more work, so Spring calls execute() again in a new transaction. Returning null counts as FINISHED.
solid answer
~40 sTasklet.execute() returns a RepeatStatus that tells the framework whether to loop. RepeatStatus.FINISHED ends the step — execute() won't be invoked again. RepeatStatus.CONTINUABLE tells Spring there's more to do, so it commits the current transaction and re-invokes execute() in a brand-new transaction. Returning null is treated as FINISHED. This lets you process large work in transactional slices without the chunk model: each CONTINUABLE pass does a bounded amount, commits, and returns; you track progress yourself, typically in the StepExecution's ExecutionContext so a restart resumes correctly. The danger: an infinite loop if you never return FINISHED, or a broken restart if you don't persist how far you got. Each pass is its own transaction, so partial commits become visible between passes.
code
java · 11 lines@Bean
Step purgeStep(JobRepository repo, PlatformTransactionManager tx, JdbcTemplate jdbc) {
return new StepBuilder("purgeOldRows", repo)
.tasklet((contribution, ctx) -> {
int n = jdbc.update(
"DELETE FROM audit_log WHERE created < now() - interval '90 days' LIMIT 50000");
contribution.incrementWriteCount(n);
return RepeatStatus.continueIf(n > 0); // CONTINUABLE while rows remain
}, tx)
.build();
}go deeper
Should know FINISHED stops and CONTINUABLE loops again.
Should explain the per-pass transaction commit and that null == FINISHED.
Should design restart-safe CONTINUABLE loops with ExecutionContext state and self-limiting queries.
Should weigh partial-commit visibility, lock scope, throughput, and consistency trade-offs of decomposing work via CONTINUABLE vs one FINISHED transaction vs the chunk model.
## The return contract `Tasklet.execute()` returns an `org.springframework.batch.repeat.RepeatStatus` enum with two values: - **`RepeatStatus.FINISHED`** — the Tasklet has completed all its work. The framework will **not** call `execute()` again; the step moves toward completion. - **`RepeatStatus.CONTINUABLE`** — there is more work to do. The framework **commits the current transaction** and **calls `execute()` again inside a new transaction**. Returning **`null` is treated as `FINISHED`** (the framework normalizes it). `RepeatStatus` also has a helper: `RepeatStatus.continueIf(boolean)` returns CONTINUABLE when true, FINISHED when false — handy to express the loop condition inline. ## Why CONTINUABLE exists Each `execute()` call runs in its own transaction (via the `PlatformTransactionManager` passed to `StepBuilder.tasklet`). CONTINUABLE lets you **break a big operation into multiple committed transactions** without adopting the full chunk (reader/processor/writer) model. For example, deleting 10 million rows in batches of 50,000: each pass deletes one batch, returns CONTINUABLE, and the framework commits and comes back — keeping transaction size and lock scope bounded. ```java Tasklet purge = (contribution, chunkContext) -> { int deleted = jdbc.update("DELETE FROM audit WHERE created < ? LIMIT 50000", cutoff); contribution.incrementWriteCount(deleted); return RepeatStatus.continueIf(deleted > 0); // FINISHED when nothing left }; ``` ## Restart correctness Because each pass commits, a failure mid-way leaves earlier passes committed. On restart the step re-runs `execute()` from the top. If your logic is naturally self-limiting (e.g. `DELETE ... WHERE created < cutoff LIMIT n` — already-deleted rows won't match again) it resumes cleanly. Otherwise you must **persist progress** in the `ExecutionContext`: ```java ExecutionContext ctx = chunkContext.getStepContext().getStepExecution().getExecutionContext(); long offset = ctx.getLong("offset", 0); // ... do work from offset ... ctx.putLong("offset", offset + batch); ``` The `ExecutionContext` is persisted by the `JobRepository` at commit, so a restarted step reads back the last committed offset. ## Gotchas - **Infinite loop**: if a bug makes you always return CONTINUABLE, the step never ends. Always have a termination condition that eventually yields FINISHED. - **Visibility of partial results**: because passes commit independently, other transactions can see partially-completed work between passes. That's a feature for throughput but a hazard for consistency-sensitive jobs — use FINISHED (one transaction) if you need all-or-nothing. - **Not the same as chunk commit-interval**: CONTINUABLE is you controlling the loop manually; the chunk model does this automatically per commit-interval (sibling topic). - **null == FINISHED**: forgetting to return anything meaningful (returning null) silently ends the step — usually what you want, but be deliberate.
- What happens if execute() returns null?It's treated as RepeatStatus.FINISHED — the step will not re-invoke the Tasklet.
- How do you make a CONTINUABLE Tasklet restart correctly after a crash?Persist your progress marker (e.g. an offset) in the StepExecution's ExecutionContext each pass; the JobRepository saves it on commit, so on restart you read back the last committed position instead of starting over. Or make the query naturally self-limiting.
- Is each CONTINUABLE pass a separate transaction?Yes — the framework commits after each execute() returning CONTINUABLE and starts a fresh transaction for the next call.
saying these in an interview costs you the question
- Thinking CONTINUABLE keeps the same open transaction across passes
- Believing null throws or is an error (it's FINISHED)
- Assuming a CONTINUABLE step is automatically restart-safe without saving state
- Confusing manual CONTINUABLE looping with the chunk commit-interval