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
113 lines
7.4 KiB
Markdown
113 lines
7.4 KiB
Markdown
# 7. Restartability: what actually resumes, and from where
|
|
|
|
[← Previous](06-jdbc-writer-and-records.md) | [README](../README.md) | [Next: Skip vs. restart →](08-skip-vs-restart.md)
|
|
|
|
This chapter is the "run 1 fails, run 2 in a brand-new JVM finishes the job" demonstration
|
|
[the article](https://ankurm.com/) leads with. Two real, separate `java -jar` invocations,
|
|
against the same file-based H2 database, produced everything below — see
|
|
[`scripts/capture-scenarios.sh`](../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:
|
|
|
|
```console
|
|
$ 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`](output/07-restart-run1-fails.txt).
|
|
Four chunks (rows 1-40, minus the 2 filtered by the processor — see
|
|
[chapter 4](04-item-processor-as-filter.md) — = 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:
|
|
|
|
```console
|
|
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](05-launching-and-jobparameters.md)):
|
|
|
|
```console
|
|
$ 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`](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.
|
|
|
|
<figure>
|
|
<svg viewBox="0 0 740 260" role="img" aria-label="Run 1 commits chunks 1 through 4 (rows 1-40) then fails on chunk 5; run 2, a fresh JVM against the same database, resumes at row 41 and reads only 20 more rows to finish.">
|
|
<style>.h{font:600 12px sans-serif;fill:#1a1a1a}.c{font:11px sans-serif;fill:#4b5563}.m{font:11px monospace;fill:#1a1a1a}</style>
|
|
<text x="20" y="24" class="h">Run 1 (JobExecution 1)</text>
|
|
<rect x="20" y="40" width="88" height="40" rx="4" fill="#e7f4ea" stroke="#4a9d63"/><text x="34" y="64" class="m">1-10</text>
|
|
<rect x="116" y="40" width="88" height="40" rx="4" fill="#e7f4ea" stroke="#4a9d63"/><text x="130" y="64" class="m">11-20</text>
|
|
<rect x="212" y="40" width="88" height="40" rx="4" fill="#e7f4ea" stroke="#4a9d63"/><text x="226" y="64" class="m">21-30</text>
|
|
<rect x="308" y="40" width="88" height="40" rx="4" fill="#e7f4ea" stroke="#4a9d63"/><text x="322" y="64" class="m">31-40</text>
|
|
<rect x="404" y="40" width="88" height="40" rx="4" fill="#f7d9d3" stroke="#c56a54"/><text x="415" y="64" class="m">41-50 FAIL</text>
|
|
<rect x="500" y="40" width="88" height="40" rx="4" fill="#f4f5f7" stroke="#b7bec9" stroke-dasharray="3 3"/><text x="512" y="64" class="c">never read</text>
|
|
<text x="20" y="105" class="c">committed: rows 1-40 (38 written after 2 filtered) -- job FAILS at chunk 5, exit status FAILED</text>
|
|
|
|
<text x="20" y="150" class="h">Run 2 (JobExecution 2) -- fresh JVM, same database, corrected row 47</text>
|
|
<rect x="20" y="166" width="380" height="40" rx="4" fill="#f4f5f7" stroke="#b7bec9" stroke-dasharray="3 3"/><text x="150" y="190" class="c">rows 1-40: already committed, NOT re-read</text>
|
|
<rect x="404" y="166" width="88" height="40" rx="4" fill="#e7f4ea" stroke="#4a9d63"/><text x="415" y="190" class="m">41-50 OK</text>
|
|
<rect x="500" y="166" width="88" height="40" rx="4" fill="#e7f4ea" stroke="#4a9d63"/><text x="511" y="190" class="m">51-60 OK</text>
|
|
<text x="20" y="230" class="c">READ_COUNT for this execution = 20, not 60. COMPLETED. PRODUCT now has 58 rows total.</text>
|
|
</svg>
|
|
</figure>
|
|
|
|
## 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`](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
|
|
|
|
- `ItemStream` and the two-argument `open`/`update` contract:
|
|
[Spring Batch reference — readers and ItemStream](https://docs.spring.io/spring-batch/reference/readers-and-writers/item-reader.html) (`rel="nofollow"`).
|
|
- Configuring restart limits per step (`startLimit`, `allowStartIfComplete`):
|
|
[chapter 8](08-skip-vs-restart.md), and the [Spring Batch reference on restart configuration](https://docs.spring.io/spring-batch/reference/step/chunk-oriented-processing/restart.html) (`rel="nofollow"`).
|
|
- What happens if the job repository itself does not persist across the JVM restart shown above:
|
|
[chapter 10](10-resourceless-vs-jdbc.md) — this entire chapter depends on it being real.
|
|
|
|
[Next: Skip vs. restart →](08-skip-vs-restart.md)
|