Files
spring-boot-demo/spring-batch/docs/07-restartability.md
T
Claude b81af72bc3 Add spring-batch: jobs, steps, chunk processing and restartability on Boot 4.1
Companion code for "Spring Batch on Boot 4.1: Jobs, Steps, Chunk Processing and
Restartability". A productImportJob configured three ways by profile against a
poisoned CSV row, run as real java -jar processes (not just JUnit) so the
restart story is genuine: a chunk fails and rolls back, the process exits, a
brand-new JVM against the same file-based H2 database resumes at the exact
next unread row (READ_COUNT 20, not 60) and completes.

Findings the build pins:
- StepBuilder.chunk(int, PlatformTransactionManager) still compiles in Batch
  6.0.5 but returns the legacy SimpleStepBuilder; chunk(int) returns the new
  ChunkOrientedStepBuilder, and only the latter is used here.
- Two different ExecutionContext classes now exist in two different packages
  (infrastructure.item vs core.repository.persistence) with different shapes.
- spring-boot-starter-batch alone gives a resourceless JobRepository that
  forgets every JobInstance the moment the JVM exits; spring-boot-starter-
  batch-jdbc is what makes the restart demo possible at all, demonstrated by
  excluding BatchJdbcAutoConfiguration and watching a "restart" collide with
  the previous run's own data instead of resuming it.
- A migration-guide summary claiming CommandLineJobRunner was removed in 6.0
  is wrong -- javap against the real jar shows @Deprecated(forRemoval=true),
  not removed.
- RepeatStatus moved from core.repeat to infrastructure.repeat, caught by the
  compiler rather than by reading docs.

11 documentation chapters, 10 captured transcripts (unit tests, javap output,
and real two-JVM scenario runs), all regenerated by scripts/run-all.sh.

Fixed after push: three dead docs.spring.io links in the doc chapters
(readersAndWriters/* and chunk-oriented-processing/*.html paths moved when
Spring Batch 6 reorganized its reference docs; corrected to the current
readers-and-writers/*, processor.html and chunk-oriented-processing.html
paths, verified 200 via curl before committing).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019DXsJ1zpikbA1MQJN6RqFA
2026-09-13 06:37:46 +00:00

7.4 KiB

7. Restartability: what actually resumes, and from where

← Previous | README | Next: Skip vs. restart →

This chapter is the "run 1 fails, run 2 in a brand-new JVM finishes the job" demonstration the article leads with. Two real, separate java -jar invocations, against the same file-based H2 database, produced everything below — see scripts/capture-scenarios.sh for the exact commands.

Run 1: the poisoned duplicate fails a whole chunk

products-poison.csv is identical to the clean fixture except row 47, where the SKU is changed to ABC-0005 — a duplicate of row 5. Row 47 falls in chunk 5 (rows 41-50 at chunk size 10). The broken profile's step has no fault tolerance configured, so the UNIQUE constraint violation on that insert fails the batch statement, which fails the chunk, which fails the step, which fails the job:

$ SELECT status, read_count, filter_count, write_count, commit_count, rollback_count FROM BATCH_STEP_EXECUTION;
STATUS | READ_COUNT | FILTER_COUNT | WRITE_COUNT | COMMIT_COUNT | ROLLBACK_COUNT
FAILED | 50         | 2            | 38          | 4            | 1

Full transcript: docs/output/07-restart-run1-fails.txt. Four chunks (rows 1-40, minus the 2 filtered by the processor — see chapter 4 — = 38 written) committed successfully before chunk 5 rolled back entirely. The reader had read through row 50 by the time the writer failed (READ_COUNT 50), but none of chunk 5's rows are in the PRODUCT table — the transaction that would have written them rolled back along with everything else in that chunk.

The fix, and the restart

Someone fixes the source data — here, changing row 47's SKU to something that is not a duplicate, in place, same line, same file:

sed -i '48s/ABC-0005/ABC-0999/' scenario-data/restart-demo/input.csv

Then the exact same command that produced run 1 runs again, in a completely new JVM process, using the exact same identifying job parameter (batch.run=demo, see chapter 5):

$ SELECT step_execution_id, job_execution_id, status, read_count, write_count, commit_count, rollback_count FROM BATCH_STEP_EXECUTION ORDER BY step_execution_id;
STEP_EXECUTION_ID | JOB_EXECUTION_ID | STATUS    | READ_COUNT | WRITE_COUNT | COMMIT_COUNT | ROLLBACK_COUNT
1                 | 1                | FAILED    | 50         | 38          | 4            | 1
2                 | 2                | COMPLETED | 20         | 20          | 2            | 0

$ SELECT COUNT(*) FROM PRODUCT;
COUNT(*)
58

Full transcript: docs/output/08-restart-run2-resumes.txt. Read that second row carefully: READ_COUNT is 20, not 60. The new execution did not start the CSV from row 1. It read only rows 41 through 60 — the two chunks that had not committed yet — wrote all 20 of them (the earlier two filtered rows were both in the first 40, so nothing left to filter here), and the job status flips to COMPLETED. PRODUCT ends up with 58 rows: the 38 that survived run 1, plus these 20.

<style>.h{font:600 12px sans-serif;fill:#1a1a1a}.c{font:11px sans-serif;fill:#4b5563}.m{font:11px monospace;fill:#1a1a1a}</style> Run 1 (JobExecution 1) 1-10 11-20 21-30 31-40 41-50 FAIL never read committed: rows 1-40 (38 written after 2 filtered) -- job FAILS at chunk 5, exit status FAILED

Run 2 (JobExecution 2) -- fresh JVM, same database, corrected row 47 rows 1-40: already committed, NOT re-read 41-50 OK 51-60 OK READ_COUNT for this execution = 20, not 60. COMPLETED. PRODUCT now has 58 rows total.

Where the resume position actually lives

The reader's position is not something Spring Batch infers after the fact — it is saved, by the reader itself, into the step's ExecutionContext at every chunk commit, in the same transaction as the chunk's writes. FlatFileItemReader implements ItemStream (org.springframework.batch.infrastructure.item.ItemStream, see docs/output/04-two-executioncontext-classes.txt for its shape) and calls update(ExecutionContext) on every commit, storing the current line count. Because that update happens inside the chunk's transaction:

  • If the chunk commits, the new line count is persisted right alongside the rows it wrote.
  • If the chunk rolls back (chunk 5, run 1), the line-count update rolls back too — the persisted position stays at the end of chunk 4, not partway through chunk 5.

That is the entire mechanism. There is no separate "resume point" concept to configure: it falls out of transactional chunk commits plus a reader that participates in ItemStream. Writing a custom reader that does not save its position to the ExecutionContext — a plain ItemReader with no ItemStream — means restarts always start that reader from scratch, which is sometimes exactly what you want (an idempotent reader) and sometimes a bug you will not notice until the first real failure in production.

Going deeper

Next: Skip vs. restart →