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,63 @@
|
||||
package com.ankurm.batch;
|
||||
|
||||
import com.ankurm.batch.domain.Product;
|
||||
import com.ankurm.batch.processing.ProductValidatingProcessor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Chapter 4 ({@code docs/04-item-processor-as-filter.md}): a null return from
|
||||
* {@link org.springframework.batch.infrastructure.item.ItemProcessor#process} is a filter, not
|
||||
* a skip. These assertions pin exactly which rows the pipeline's processor removes and which it
|
||||
* lets through, independent of Spring Batch or the database.
|
||||
*/
|
||||
class ProductValidatingProcessorTest {
|
||||
|
||||
private final ProductValidatingProcessor processor = new ProductValidatingProcessor();
|
||||
|
||||
@Test
|
||||
void passesAWellFormedRow() {
|
||||
try (Transcript t = new Transcript("01-processor-filter.txt",
|
||||
"The processor as a filter: null means skip, not fail")) {
|
||||
|
||||
Product ok = new Product("ABC-0001", "Widget 1", 1001);
|
||||
Product result = processor.process(ok);
|
||||
t.line("input : %s", ok);
|
||||
t.line("result : %s", result);
|
||||
assertThat(result).isEqualTo(ok);
|
||||
|
||||
t.blank();
|
||||
Product badSku = new Product("abc-0012", "Widget 12", 1012);
|
||||
t.line("input : %s <- lowercase sku, fails ^[A-Z]{3}-\\d{4}$", badSku);
|
||||
t.line("result : %s", processor.process(badSku));
|
||||
assertThat(processor.process(badSku)).isNull();
|
||||
|
||||
t.blank();
|
||||
Product zeroPrice = new Product("ABC-0033", "Widget 33", 0);
|
||||
t.line("input : %s <- priceCents is zero", zeroPrice);
|
||||
t.line("result : %s", processor.process(zeroPrice));
|
||||
assertThat(processor.process(zeroPrice)).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateKeyExceptionIsADataIntegrityViolationException() {
|
||||
try (Transcript t = new Transcript("02-exception-hierarchy.txt",
|
||||
"Why skip(DataIntegrityViolationException.class) also catches the duplicate-key case")) {
|
||||
|
||||
DuplicateKeyException dup = new DuplicateKeyException("Unique index violation");
|
||||
t.line("thrown type : %s", dup.getClass().getName());
|
||||
t.line("is a DataIntegrityViolationException? %b", dup instanceof DataIntegrityViolationException);
|
||||
t.line("");
|
||||
t.line("H2's JdbcBatchUpdateException on a UNIQUE-constraint violation is translated by");
|
||||
t.line("Spring's SQLExceptionSubclassTranslator into DuplicateKeyException, which extends");
|
||||
t.line("DataIntegrityViolationException. A skip policy configured against the parent class");
|
||||
t.line("catches the subclass too -- see docs/07-restartability.md and docs/08-skip-vs-restart.md.");
|
||||
|
||||
assertThat(dup).isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.batch;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code docs/output/} and echoes it to the console.
|
||||
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = Path.of("docs", "output", fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
out.println();
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String heading) {
|
||||
out.println();
|
||||
out.println("--- " + heading + " ---");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, buffer.toString());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(buffer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user