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
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.
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
ItemStreamand the two-argumentopen/updatecontract: Spring Batch reference — readers and ItemStream (rel="nofollow").- Configuring restart limits per step (
startLimit,allowStartIfComplete): chapter 8, and the Spring Batch reference on restart configuration (rel="nofollow"). - What happens if the job repository itself does not persist across the JVM restart shown above: chapter 10 — this entire chapter depends on it being real.