skip to content

Explain RepeatStatus.FINISHED versus RepeatStatus.CONTINUABLE returned from Tasklet.execute().

level: middleimportance: must knowfreq 60%

answer

  1. FINISHED -> stop; CONTINUABLE -> loop again
  2. Each pass = new transaction, commits between
  3. null means FINISHED
  4. continueIf(boolean) helper
  5. Persist offset in ExecutionContext for restart

basics

~10 s

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

Tasklet.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
java
@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

for a junior

Should know FINISHED stops and CONTINUABLE loops again.

for a middle

Should explain the per-pass transaction commit and that null == FINISHED.

for a senior

Should design restart-safe CONTINUABLE loops with ExecutionContext state and self-limiting queries.

for a principal

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

context