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
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# The processor as a filter: null means skip, not fail
|
||||
|
||||
input : Product[sku=ABC-0001, name=Widget 1, priceCents=1001]
|
||||
result : Product[sku=ABC-0001, name=Widget 1, priceCents=1001]
|
||||
|
||||
input : Product[sku=abc-0012, name=Widget 12, priceCents=1012] <- lowercase sku, fails ^[A-Z]{3}-\d{4}$
|
||||
result : null
|
||||
|
||||
input : Product[sku=ABC-0033, name=Widget 33, priceCents=0] <- priceCents is zero
|
||||
result : null
|
||||
@@ -0,0 +1,9 @@
|
||||
# Why skip(DataIntegrityViolationException.class) also catches the duplicate-key case
|
||||
|
||||
thrown type : org.springframework.dao.DuplicateKeyException
|
||||
is a DataIntegrityViolationException? true
|
||||
|
||||
H2's JdbcBatchUpdateException on a UNIQUE-constraint violation is translated by
|
||||
Spring's SQLExceptionSubclassTranslator into DuplicateKeyException, which extends
|
||||
DataIntegrityViolationException. A skip policy configured against the parent class
|
||||
catches the subclass too -- see docs/07-restartability.md and docs/08-skip-vs-restart.md.
|
||||
@@ -0,0 +1,19 @@
|
||||
# javap org.springframework.batch.core.step.builder.StepBuilder (spring-batch-core 6.0.5)
|
||||
|
||||
$ javap -cp spring-batch-core-6.0.5.jar org.springframework.batch.core.step.builder.StepBuilder
|
||||
Compiled from "StepBuilder.java"
|
||||
public class org.springframework.batch.core.step.builder.StepBuilder extends org.springframework.batch.core.step.builder.StepBuilderHelper<org.springframework.batch.core.step.builder.StepBuilder> {
|
||||
public org.springframework.batch.core.step.builder.StepBuilder(org.springframework.batch.core.repository.JobRepository);
|
||||
public org.springframework.batch.core.step.builder.StepBuilder(java.lang.String, org.springframework.batch.core.repository.JobRepository);
|
||||
public org.springframework.batch.core.step.builder.TaskletStepBuilder tasklet(org.springframework.batch.core.step.tasklet.Tasklet, org.springframework.transaction.PlatformTransactionManager);
|
||||
public org.springframework.batch.core.step.builder.TaskletStepBuilder tasklet(org.springframework.batch.core.step.tasklet.Tasklet);
|
||||
public <I, O> org.springframework.batch.core.step.builder.SimpleStepBuilder<I, O> chunk(int, org.springframework.transaction.PlatformTransactionManager);
|
||||
public <I, O> org.springframework.batch.core.step.builder.ChunkOrientedStepBuilder<I, O> chunk(int);
|
||||
public <I, O> org.springframework.batch.core.step.builder.SimpleStepBuilder<I, O> chunk(org.springframework.batch.infrastructure.repeat.CompletionPolicy, org.springframework.transaction.PlatformTransactionManager);
|
||||
public org.springframework.batch.core.step.builder.PartitionStepBuilder partitioner(java.lang.String, org.springframework.batch.core.partition.Partitioner);
|
||||
public org.springframework.batch.core.step.builder.PartitionStepBuilder partitioner(org.springframework.batch.core.step.Step);
|
||||
public org.springframework.batch.core.step.builder.JobStepBuilder job(org.springframework.batch.core.job.Job);
|
||||
public org.springframework.batch.core.step.builder.FlowStepBuilder flow(org.springframework.batch.core.job.flow.Flow);
|
||||
protected org.springframework.batch.core.step.builder.StepBuilder self();
|
||||
protected org.springframework.batch.core.step.builder.StepBuilderHelper self();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# javap: two ExecutionContext classes in two different packages
|
||||
|
||||
$ javap -cp spring-batch-infrastructure-6.0.5.jar org.springframework.batch.infrastructure.item.ExecutionContext
|
||||
Compiled from "ExecutionContext.java"
|
||||
public class org.springframework.batch.infrastructure.item.ExecutionContext implements java.io.Serializable {
|
||||
public org.springframework.batch.infrastructure.item.ExecutionContext();
|
||||
public org.springframework.batch.infrastructure.item.ExecutionContext(java.util.Map<java.lang.String, java.lang.Object>);
|
||||
public org.springframework.batch.infrastructure.item.ExecutionContext(org.springframework.batch.infrastructure.item.ExecutionContext);
|
||||
public void putString(java.lang.String, java.lang.String);
|
||||
public void putLong(java.lang.String, long);
|
||||
public void putInt(java.lang.String, int);
|
||||
public void putDouble(java.lang.String, double);
|
||||
public void put(java.lang.String, java.lang.Object);
|
||||
public boolean isDirty();
|
||||
public java.lang.String getString(java.lang.String);
|
||||
public java.lang.String getString(java.lang.String, java.lang.String);
|
||||
public long getLong(java.lang.String);
|
||||
public long getLong(java.lang.String, long);
|
||||
public int getInt(java.lang.String);
|
||||
public int getInt(java.lang.String, int);
|
||||
public double getDouble(java.lang.String);
|
||||
public double getDouble(java.lang.String, double);
|
||||
public java.lang.Object get(java.lang.String);
|
||||
public <V> V get(java.lang.String, java.lang.Class<V>);
|
||||
public <V> V get(java.lang.String, java.lang.Class<V>, V);
|
||||
public boolean isEmpty();
|
||||
public void clearDirtyFlag();
|
||||
public java.util.Set<java.util.Map$Entry<java.lang.String, java.lang.Object>> entrySet();
|
||||
public java.util.Map<java.lang.String, java.lang.Object> toMap();
|
||||
public boolean containsKey(java.lang.String);
|
||||
public java.lang.Object remove(java.lang.String);
|
||||
public boolean containsValue(java.lang.Object);
|
||||
public boolean equals(java.lang.Object);
|
||||
public int hashCode();
|
||||
public java.lang.String toString();
|
||||
public int size();
|
||||
}
|
||||
|
||||
$ javap -cp spring-batch-core-6.0.5.jar org.springframework.batch.core.repository.persistence.ExecutionContext
|
||||
Compiled from "ExecutionContext.java"
|
||||
public final class org.springframework.batch.core.repository.persistence.ExecutionContext extends java.lang.Record {
|
||||
public org.springframework.batch.core.repository.persistence.ExecutionContext(java.util.Map<java.lang.String, java.lang.Object>, boolean);
|
||||
public final java.lang.String toString();
|
||||
public final int hashCode();
|
||||
public final boolean equals(java.lang.Object);
|
||||
public java.util.Map<java.lang.String, java.lang.Object> map();
|
||||
public boolean dirty();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# The happy path: 60 rows in, 58 products out
|
||||
|
||||
$ java -jar target/spring-batch-1.0.0.jar --spring.profiles.active=clean \
|
||||
--import.file=file:scenario-data/clean-demo/input.csv \
|
||||
--spring.datasource.url=jdbc:h2:file:.../scenario-data/clean-demo/db
|
||||
|
||||
2026-09-13T06:23:05.509Z INFO 5242 --- [ main] o.s.batch.core.step.AbstractStep : Executing step: [importStep]
|
||||
2026-09-13T06:23:05.530Z WARN 5242 --- [ main] c.a.b.p.ProductValidatingProcessor : filtering abc-0012: does not match ^[A-Z]{3}-\d{4}$
|
||||
2026-09-13T06:23:05.543Z WARN 5242 --- [ main] c.a.b.p.ProductValidatingProcessor : filtering ABC-0033: priceCents must be positive, was 0
|
||||
2026-09-13T06:23:05.570Z INFO 5242 --- [ main] o.s.batch.core.step.AbstractStep : Step: [importStep] executed in 61ms
|
||||
2026-09-13T06:23:05.590Z INFO 5242 --- [ main] o.s.batch.core.step.AbstractStep : Executing step: [reportStep]
|
||||
REPORT: 58 products now in the PRODUCT table
|
||||
2026-09-13T06:23:05.600Z INFO 5242 --- [ main] o.s.batch.core.step.AbstractStep : Step: [reportStep] executed in 13ms
|
||||
JOB FINISHED: status=COMPLETED exitCode=COMPLETED
|
||||
|
||||
$ SELECT read_count, filter_count, write_count, commit_count FROM BATCH_STEP_EXECUTION;
|
||||
READ_COUNT | FILTER_COUNT | WRITE_COUNT | COMMIT_COUNT
|
||||
60 | 2 | 58 | 6
|
||||
(1 row, 21 ms)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Run 1: the poisoned duplicate at row 47 fails the whole chunk
|
||||
|
||||
$ java -jar target/spring-batch-1.0.0.jar --spring.profiles.active=broken \
|
||||
--import.file=file:scenario-data/restart-demo/input.csv \
|
||||
--spring.datasource.url=jdbc:h2:file:.../scenario-data/restart-demo/db
|
||||
|
||||
2026-09-13T06:23:09.122Z INFO 5302 --- [ main] o.s.batch.core.step.AbstractStep : Executing step: [importStep]
|
||||
2026-09-13T06:23:09.162Z WARN 5302 --- [ main] c.a.b.p.ProductValidatingProcessor : filtering abc-0012: does not match ^[A-Z]{3}-\d{4}$
|
||||
2026-09-13T06:23:09.176Z WARN 5302 --- [ main] c.a.b.p.ProductValidatingProcessor : filtering ABC-0033: priceCents must be positive, was 0
|
||||
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO PRODUCT (sku, name, price_cents) VALUES (?, ?, ?)]; Unique index or primary key violation: "PUBLIC.CONSTRAINT_18 INDEX PUBLIC.CONSTRAINT_INDEX_1 ON PUBLIC.PRODUCT(SKU NULLS FIRST) VALUES ( /* 5 */ 'ABC-0005' )"; SQL statement:
|
||||
Caused by: org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO PRODUCT (sku, name, price_cents) VALUES (?, ?, ?)]; Unique index or primary key violation: "PUBLIC.CONSTRAINT_18 INDEX PUBLIC.CONSTRAINT_INDEX_1 ON PUBLIC.PRODUCT(SKU NULLS FIRST) VALUES ( /* 5 */ 'ABC-0005' )"; SQL statement:
|
||||
2026-09-13T06:23:09.205Z INFO 5302 --- [ main] o.s.batch.core.step.AbstractStep : Step: [importStep] executed in 85ms
|
||||
JOB FINISHED: status=FAILED exitCode=FAILED
|
||||
|
||||
$ 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
|
||||
(1 row, 21 ms)
|
||||
|
||||
$ SELECT COUNT(*) FROM PRODUCT;
|
||||
COUNT(*)
|
||||
38
|
||||
(1 row, 20 ms)
|
||||
@@ -0,0 +1,25 @@
|
||||
# Run 2: a brand-new JVM, the SAME job parameters, the corrected file
|
||||
|
||||
sed -i '48s/ABC-0005/ABC-0999/' scenario-data/restart-demo/input.csv # the only change
|
||||
|
||||
$ java -jar target/spring-batch-1.0.0.jar --spring.profiles.active=broken \
|
||||
--import.file=file:scenario-data/restart-demo/input.csv \
|
||||
--spring.datasource.url=jdbc:h2:file:.../scenario-data/restart-demo/db # same db file
|
||||
|
||||
2026-09-13T06:23:13.205Z INFO 5386 --- [ main] o.s.batch.core.step.AbstractStep : Executing step: [importStep]
|
||||
2026-09-13T06:23:13.232Z INFO 5386 --- [ main] o.s.batch.core.step.AbstractStep : Step: [importStep] executed in 28ms
|
||||
2026-09-13T06:23:13.240Z INFO 5386 --- [ main] o.s.batch.core.step.AbstractStep : Executing step: [reportStep]
|
||||
REPORT: 58 products now in the PRODUCT table
|
||||
2026-09-13T06:23:13.246Z INFO 5386 --- [ main] o.s.batch.core.step.AbstractStep : Step: [reportStep] executed in 6ms
|
||||
JOB FINISHED: status=COMPLETED exitCode=COMPLETED
|
||||
|
||||
$ 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
|
||||
(2 rows, 22 ms)
|
||||
|
||||
$ SELECT COUNT(*) FROM PRODUCT;
|
||||
COUNT(*)
|
||||
58
|
||||
(1 row, 19 ms)
|
||||
@@ -0,0 +1,23 @@
|
||||
# javap: CommandLineJobRunner is deprecated (forRemoval), not removed, in 6.0.5
|
||||
|
||||
$ javap -verbose -cp spring-batch-core-6.0.5.jar org.springframework.batch.core.launch.support.CommandLineJobRunner | grep -A3 "^public class\|Deprecated"
|
||||
public class org.springframework.batch.core.launch.support.CommandLineJobRunner
|
||||
minor version: 0
|
||||
major version: 61
|
||||
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
|
||||
--
|
||||
#503 = Utf8 Deprecated
|
||||
#504 = Utf8 RuntimeVisibleAnnotations
|
||||
#505 = Utf8 Ljava/lang/Deprecated;
|
||||
#506 = Utf8 since
|
||||
#507 = Utf8 6.0
|
||||
#508 = Utf8 forRemoval
|
||||
--
|
||||
Deprecated: true
|
||||
RuntimeVisibleAnnotations:
|
||||
0: #505(#506=s#507,#508=Z#509)
|
||||
java.lang.Deprecated(
|
||||
since="6.0"
|
||||
forRemoval=true
|
||||
)
|
||||
--
|
||||
@@ -0,0 +1,27 @@
|
||||
# faultTolerant().skip(DataIntegrityViolationException.class): one bad row, job still COMPLETES
|
||||
|
||||
$ java -jar target/spring-batch-1.0.0.jar --spring.profiles.active=skip \
|
||||
--import.file=file:scenario-data/skip-demo/input.csv \
|
||||
--spring.datasource.url=jdbc:h2:file:.../scenario-data/skip-demo/db
|
||||
|
||||
2026-09-13T06:23:17.111Z INFO 5469 --- [ main] o.s.batch.core.step.AbstractStep : Executing step: [importStep]
|
||||
2026-09-13T06:23:17.139Z WARN 5469 --- [ main] c.a.b.p.ProductValidatingProcessor : filtering abc-0012: does not match ^[A-Z]{3}-\d{4}$
|
||||
2026-09-13T06:23:17.150Z WARN 5469 --- [ main] c.a.b.p.ProductValidatingProcessor : filtering ABC-0033: priceCents must be positive, was 0
|
||||
Caused by: org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO PRODUCT (sku, name, price_cents) VALUES (?, ?, ?)]; Unique index or primary key violation: "PUBLIC.CONSTRAINT_18 INDEX PUBLIC.CONSTRAINT_INDEX_1 ON PUBLIC.PRODUCT(SKU NULLS FIRST) VALUES ( /* 5 */ 'ABC-0005' )"; SQL statement:
|
||||
Caused by: org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO PRODUCT (sku, name, price_cents) VALUES (?, ?, ?)]; Unique index or primary key violation: "PUBLIC.CONSTRAINT_18 INDEX PUBLIC.CONSTRAINT_INDEX_1 ON PUBLIC.PRODUCT(SKU NULLS FIRST) VALUES ( /* 5 */ 'ABC-0005' )"; SQL statement:
|
||||
SKIPPED on write: ABC-0005 (DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO PRODUCT (sku, name, price_cents) VALUES (?, ?, ?)]; Unique index or primary key violation: "PUBLIC.CONSTRAINT_18 INDEX PUBLIC.CONSTRAINT_INDEX_1 ON PUBLIC.PRODUCT(SKU NULLS FIRST) VALUES ( /* 5 */ 'ABC-0005' )"; SQL statement:
|
||||
2026-09-13T06:23:17.235Z INFO 5469 --- [ main] o.s.batch.core.step.AbstractStep : Step: [importStep] executed in 124ms
|
||||
2026-09-13T06:23:17.247Z INFO 5469 --- [ main] o.s.batch.core.step.AbstractStep : Executing step: [reportStep]
|
||||
REPORT: 57 products now in the PRODUCT table
|
||||
2026-09-13T06:23:17.256Z INFO 5469 --- [ main] o.s.batch.core.step.AbstractStep : Step: [reportStep] executed in 9ms
|
||||
JOB FINISHED: status=COMPLETED exitCode=COMPLETED
|
||||
|
||||
$ SELECT status, read_count, filter_count, write_count, write_skip_count, commit_count, rollback_count FROM BATCH_STEP_EXECUTION;
|
||||
STATUS | READ_COUNT | FILTER_COUNT | WRITE_COUNT | WRITE_SKIP_COUNT | COMMIT_COUNT | ROLLBACK_COUNT
|
||||
COMPLETED | 60 | 2 | 57 | 1 | 6 | 2
|
||||
(1 row, 22 ms)
|
||||
|
||||
$ SELECT COUNT(*) FROM PRODUCT;
|
||||
COUNT(*)
|
||||
57
|
||||
(1 row, 19 ms)
|
||||
@@ -0,0 +1,21 @@
|
||||
# --spring.autoconfigure.exclude=...BatchJdbcAutoConfiguration: same job, same params, no memory
|
||||
|
||||
$ java -jar target/spring-batch-1.0.0.jar --spring.profiles.active=clean \
|
||||
--spring.autoconfigure.exclude=org.springframework.boot.batch.jdbc.autoconfigure.BatchJdbcAutoConfiguration \
|
||||
--import.file=file:scenario-data/resourceless-demo/input.csv \
|
||||
--spring.datasource.url=jdbc:h2:file:.../scenario-data/resourceless-demo/db
|
||||
|
||||
-- run 1 --
|
||||
REPORT: 58 products now in the PRODUCT table
|
||||
JOB FINISHED: status=COMPLETED exitCode=COMPLETED
|
||||
|
||||
-- run 2: a second, completely fresh JVM, same jar, same job parameters, same PRODUCT table --
|
||||
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO PRODUCT (sku, name, price_cents) VALUES (?, ?, ?)]; Unique index or primary key violation: "PUBLIC.CONSTRAINT_18 INDEX PUBLIC.CONSTRAINT_INDEX_1 ON PUBLIC.PRODUCT(SKU NULLS FIRST) VALUES ( /* 1 */ 'ABC-0001' )"; SQL statement:
|
||||
Caused by: org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO PRODUCT (sku, name, price_cents) VALUES (?, ?, ?)]; Unique index or primary key violation: "PUBLIC.CONSTRAINT_18 INDEX PUBLIC.CONSTRAINT_INDEX_1 ON PUBLIC.PRODUCT(SKU NULLS FIRST) VALUES ( /* 1 */ 'ABC-0001' )"; SQL statement:
|
||||
JOB FINISHED: status=FAILED exitCode=FAILED
|
||||
|
||||
No JobInstanceAlreadyCompleteException on run 2 -- the resourceless JobRepository has no idea
|
||||
run 1 ever happened, so it tries the whole job again and collides with what run 1 already
|
||||
wrote to the PRODUCT table. With spring-boot-starter-batch-jdbc (the default configuration
|
||||
used everywhere else in this module) run 2 throws JobInstanceAlreadyCompleteException instead,
|
||||
which ImportRunner treats as "nothing to do" -- see docs/07-restartability.md.
|
||||
Reference in New Issue
Block a user