Add spring-batch-partitioning: manager/worker partitioning, gridSize semantics, rejected-partition recovery, and a real 10M-row scaling sweep

This commit is contained in:
2026-09-14 09:36:35 +00:00
parent b81af72bc3
commit 34e9f5b243
40 changed files with 2071 additions and 0 deletions
@@ -0,0 +1,11 @@
package com.ankurm.batchpartition;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PartitioningDemoApplication {
public static void main(String[] args) {
SpringApplication.run(PartitioningDemoApplication.class, args);
}
}
@@ -0,0 +1,257 @@
package com.ankurm.batchpartition.config;
import com.ankurm.batchpartition.domain.Order;
import com.ankurm.batchpartition.domain.RiskScoredOrder;
import com.ankurm.batchpartition.partition.PartitionStatsListener;
import com.ankurm.batchpartition.processing.RiskScoringProcessor;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.partition.Partitioner;
import org.springframework.batch.core.partition.support.MultiResourcePartitioner;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.Step;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.infrastructure.item.ItemProcessor;
import org.springframework.batch.infrastructure.item.ItemWriter;
import org.springframework.batch.infrastructure.item.database.builder.JdbcBatchItemWriterBuilder;
import org.springframework.batch.infrastructure.item.file.FlatFileItemReader;
import org.springframework.batch.infrastructure.item.file.builder.FlatFileItemReaderBuilder;
import org.springframework.batch.infrastructure.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.task.TaskExecutor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.transaction.PlatformTransactionManager;
import java.io.IOException;
import java.util.concurrent.ThreadPoolExecutor;
/**
* One job, {@code orderRiskJob}: partition a directory of pre-sharded order CSVs across worker
* threads, score every order for risk, write the result to {@code ORDER_RISK_SUMMARY}.
*
* <p>The manager/worker split follows {@code docs/02-anatomy-of-a-partitioned-step.md}. The
* single most load-bearing design choice in this module: there is no separately-coded
* "single-threaded baseline". Running with {@code --partition.grid-size=1} against one shard
* file drives the exact same {@link #partitioner}/{@link #workerStep}/{@link #managerStep} code
* path as running with grid size 8 against eight shards &mdash; so the numbers in the article
* differ by exactly one variable (thread count), not by two (thread count AND a different code
* path). See {@code docs/01-the-problem-and-mental-model.md}.
*/
@Configuration
public class BatchConfig {
@Value("${partition.shards-dir}")
private String shardsDir;
@Value("${partition.grid-size:4}")
private int gridSize;
@Value("${partition.pool-core-size:4}")
private int poolCoreSize;
@Value("${partition.pool-max-size:4}")
private int poolMaxSize;
@Value("${partition.pool-queue-capacity:0}")
private int poolQueueCapacity;
@Value("${risk.iterations:150}")
private int riskIterations;
@Value("${risk.high-risk-amount-cents:5000000}")
private long highRiskAmountCents;
// ---- partitioning: one ExecutionContext per shard file ---------------------------------
/**
* {@link MultiResourcePartitioner#partition(int)} ignores the {@code gridSize} argument it is
* handed &mdash; verified by decompiling {@code spring-batch-core-6.0.5.jar}: the method body
* loops over the configured {@code resources} array and never reads its {@code int} parameter
* at all. The number of partitions this job runs is the number of shard files in
* {@code partition.shards-dir}, full stop. {@code docs/03-what-gridsize-actually-controls.md}
* has the decompiled bytecode and the run that proves it (three shard files, gridSize 10,
* three partitions).
*/
@Bean
public Partitioner partitioner() throws IOException {
var resolver = new PathMatchingResourcePatternResolver();
Resource[] resources = resolver.getResources("file:" + shardsDir + "/*.csv");
java.util.Arrays.sort(resources, java.util.Comparator.comparing(r -> {
try {
return r.getFilename();
} catch (Exception e) {
return "";
}
}));
var partitioner = new MultiResourcePartitioner();
partitioner.setResources(resources);
partitioner.setKeyName("fileName");
return partitioner;
}
// ---- worker step: reads ONE shard file, scores it, writes it ---------------------------
@Bean
@StepScope
public FlatFileItemReader<Order> shardReader(@Value("#{stepExecutionContext['fileName']}") Resource shardFile) {
return new FlatFileItemReaderBuilder<Order>()
.name("shardReader")
.resource(shardFile)
.linesToSkip(1)
.delimited().delimiter(",").names("orderId", "customerId", "amountCents", "region")
.fieldSetMapper(fs -> new Order(
fs.readLong("orderId"),
fs.readLong("customerId"),
fs.readLong("amountCents"),
fs.readString("region")))
.build();
}
@Bean
public ItemProcessor<Order, RiskScoredOrder> riskProcessor() {
return new RiskScoringProcessor(riskIterations, highRiskAmountCents);
}
/**
* Not {@code beanMapped()}, same reasoning {@code spring-batch/} (the earlier module) already
* found: {@code BeanPropertySqlParameterSource} looks for JavaBean getters, and a record's
* accessors are {@code orderId()} not {@code getOrderId()}.
*
* <p>{@code partitionName} is late-bound from {@code #{stepExecution.stepName}}. The first
* draft of this bean reached for {@code StepSynchronizationManager} to read the current step
* name instead &mdash; {@code javap} on the real 6.0.5 jar showed that class does not exist at
* {@code org.springframework.batch.core.step.StepSynchronizationManager}; it is at
* {@code org.springframework.batch.core.scope.context.StepSynchronizationManager}, one of
* several classes this article's research moved out of the {@code core.step} package. Late
* binding sidesteps the question entirely and is the idiomatic way to reach step identity from
* a step-scoped bean anyway.
*/
@Bean
@StepScope
public ItemWriter<RiskScoredOrder> riskWriter(JdbcTemplate jdbcTemplate,
@Value("#{stepExecution.stepName}") String partitionName) {
return new JdbcBatchItemWriterBuilder<RiskScoredOrder>()
.dataSource(jdbcTemplate.getDataSource())
.sql("MERGE INTO ORDER_RISK_SUMMARY (order_id, customer_id, amount_cents, region, risk_score, " +
"high_risk, partition_name) KEY(order_id) VALUES (?, ?, ?, ?, ?, ?, ?)")
.itemPreparedStatementSetter((item, ps) -> {
ps.setLong(1, item.order().orderId());
ps.setLong(2, item.order().customerId());
ps.setLong(3, item.order().amountCents());
ps.setString(4, item.order().region());
ps.setInt(5, item.riskScore());
ps.setBoolean(6, item.highRisk());
ps.setString(7, partitionName);
})
.assertUpdates(true)
.build();
}
@Bean
public PartitionStatsListener partitionStatsListener(JdbcTemplate jdbcTemplate) {
return new PartitionStatsListener(jdbcTemplate);
}
@Bean
public Step workerStep(JobRepository jobRepository, PlatformTransactionManager transactionManager,
FlatFileItemReader<Order> shardReader, ItemProcessor<Order, RiskScoredOrder> riskProcessor,
ItemWriter<RiskScoredOrder> riskWriter, PartitionStatsListener partitionStatsListener) {
return new StepBuilder("ordersWorkerStep", jobRepository)
.<Order, RiskScoredOrder>chunk(1000)
.transactionManager(transactionManager)
.reader(shardReader)
.processor(riskProcessor)
.writer(riskWriter)
.listener(partitionStatsListener)
.build();
}
// ---- the two task executors: the working one, and the one that demonstrates rejection --
@Bean
@Profile("!reject")
public TaskExecutor partitionTaskExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(poolCoreSize);
executor.setMaxPoolSize(poolMaxSize);
executor.setQueueCapacity(poolQueueCapacity);
executor.setThreadNamePrefix("order-partition-");
executor.initialize();
return executor;
}
/**
* {@code docs/07-the-rejectedexecutionexception.md}: a pool sized smaller than the number of
* shard files, a zero-capacity queue, and {@link ThreadPoolExecutor.AbortPolicy} &mdash; the
* combination {@link org.springframework.batch.core.partition.support.TaskExecutorPartitionHandler}
* needs to actually throw {@code TaskRejectedException} instead of silently serialising the
* "extra" partitions onto the caller thread (which is what
* {@link ThreadPoolExecutor.CallerRunsPolicy}, Spring's default rejection handler, does).
*
* <p>This bean was originally an overload of {@link #partitionTaskExecutor()} distinguished
* only by {@code @Profile}. Spring Framework 7's {@code @Configuration.enforceUniqueMethods}
* (on by default) rejects that at startup with {@code BeanDefinitionParsingException:
* contains overloaded @Bean methods} &mdash; it does not know the two profiles are mutually
* exclusive at parse time, only that the method name collides. The fix is a distinct method
* name, not a workaround; see {@code docs/07-the-rejectedexecutionexception.md} for the exact
* exception text this produced.
*/
@Bean
@Profile("reject")
public TaskExecutor partitionTaskExecutorRejecting(@Value("${partition.reject.pool-size:1}") int smallPoolSize) {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(smallPoolSize);
executor.setMaxPoolSize(smallPoolSize);
executor.setQueueCapacity(0);
executor.setThreadNamePrefix("order-partition-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.initialize();
return executor;
}
// ---- manager step + job -----------------------------------------------------------------
@Bean
public Step managerStep(JobRepository jobRepository, Partitioner partitioner, Step workerStep,
TaskExecutor partitionTaskExecutor) {
return new StepBuilder("ordersManagerStep", jobRepository)
.partitioner("ordersWorkerStep", partitioner)
.step(workerStep)
.gridSize(gridSize)
.taskExecutor(partitionTaskExecutor)
.build();
}
@Bean
public Tasklet reportTasklet(JdbcTemplate jdbcTemplate) {
return (contribution, chunkContext) -> {
int total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM ORDER_RISK_SUMMARY", Integer.class);
int highRisk = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM ORDER_RISK_SUMMARY WHERE high_risk = TRUE", Integer.class);
System.out.println("REPORT: " + total + " orders scored, " + highRisk + " flagged high-risk");
return RepeatStatus.FINISHED;
};
}
@Bean
public Step reportStep(JobRepository jobRepository, PlatformTransactionManager transactionManager,
Tasklet reportTasklet) {
return new StepBuilder("reportStep", jobRepository)
.tasklet(reportTasklet, transactionManager)
.allowStartIfComplete(true)
.build();
}
@Bean
public Job orderRiskJob(JobRepository jobRepository, Step managerStep, Step reportStep) {
return new JobBuilder("orderRiskJob", jobRepository).start(managerStep).next(reportStep).build();
}
}
@@ -0,0 +1,9 @@
package com.ankurm.batchpartition.domain;
/**
* One row of the input CSV. A record, deliberately &mdash; see
* {@code docs/04-the-writer-and-the-beanmapped-trap.md} for why {@link #writer} in
* {@link com.ankurm.batchpartition.config.BatchConfig} does not use {@code beanMapped()}.
*/
public record Order(long orderId, long customerId, long amountCents, String region) {
}
@@ -0,0 +1,5 @@
package com.ankurm.batchpartition.domain;
/** {@link Order} plus what {@link com.ankurm.batchpartition.processing.RiskScoringProcessor} computed. */
public record RiskScoredOrder(Order order, int riskScore, boolean highRisk) {
}
@@ -0,0 +1,53 @@
package com.ankurm.batchpartition.partition;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.listener.StepExecutionListener;
import org.springframework.batch.core.step.StepExecution;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* Records which thread ran which worker partition, and how long it took, into
* {@code PARTITION_STATS}. This is the hidden runtime state {@code /batch/partitions} in
* {@link com.ankurm.batchpartition.web.PartitionInsightController} exposes &mdash; without it,
* "did partitioning actually run four threads or one?" is a question you can only answer by
* trusting the configuration, not by looking at what happened. Delete this listener (and the
* controller) before shipping a real job; it exists here to make the mechanism visible, not
* because production batch jobs should query their own thread names.
*/
public class PartitionStatsListener implements StepExecutionListener {
private final JdbcTemplate jdbcTemplate;
private final ThreadLocal<LocalDateTime> startedAt = new ThreadLocal<>();
public PartitionStatsListener(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void beforeStep(StepExecution stepExecution) {
startedAt.set(LocalDateTime.now());
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
LocalDateTime start = startedAt.get();
LocalDateTime end = LocalDateTime.now();
long durationMs = start == null ? -1 : Duration.between(start, end).toMillis();
jdbcTemplate.update(
"INSERT INTO PARTITION_STATS (job_execution_id, partition_name, thread_name, read_count, " +
"started_at, finished_at, duration_ms, exit_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
stepExecution.getJobExecutionId(),
stepExecution.getStepName(),
Thread.currentThread().getName(),
stepExecution.getReadCount(),
start,
end,
durationMs,
stepExecution.getExitStatus().getExitCode());
startedAt.remove();
return stepExecution.getExitStatus();
}
}
@@ -0,0 +1,37 @@
package com.ankurm.batchpartition.processing;
import com.ankurm.batchpartition.domain.Order;
import com.ankurm.batchpartition.domain.RiskScoredOrder;
import org.springframework.batch.infrastructure.item.ItemProcessor;
/**
* Deliberately CPU-bound, not I/O-bound &mdash; this is what makes the partitioning numbers in
* this article mean something on a 2-core sandbox. A pure I/O-bound step (a network call per
* item) would show partitioning "working" even with a single core free, because the threads
* spend their time blocked, not computing. Here every item does {@code risk.iterations} real
* multiplications, so the speedup this module measures is bounded by actual core count, not by
* how many threads are merely alive. See {@code docs/06-why-cpu-bound-not-io-bound.md}.
*/
public class RiskScoringProcessor implements ItemProcessor<Order, RiskScoredOrder> {
private final int iterations;
private final long highRiskAmountCents;
public RiskScoringProcessor(int iterations, long highRiskAmountCents) {
this.iterations = iterations;
this.highRiskAmountCents = highRiskAmountCents;
}
@Override
public RiskScoredOrder process(Order order) {
long acc = order.orderId() * 2654435761L + order.customerId();
for (int i = 0; i < iterations; i++) {
acc = (acc ^ (acc >>> 13)) * 2246822519L;
acc = (acc ^ (acc >>> 15)) * 3266489917L;
acc = acc ^ (acc >>> 16);
}
int score = (int) Math.floorMod(acc, 1000);
boolean highRisk = order.amountCents() > highRiskAmountCents || score > 970;
return new RiskScoredOrder(order, score, highRisk);
}
}
@@ -0,0 +1,47 @@
package com.ankurm.batchpartition.runner;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.parameters.JobParametersBuilder;
import org.springframework.batch.core.launch.JobInstanceAlreadyCompleteException;
import org.springframework.batch.core.launch.JobOperator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
* {@code partition.shards-dir} is the identifying job parameter: two runs against the same
* directory are the same {@code JobInstance}, so a run that failed partway through resumes
* (only the failed partitions re-execute) rather than starting over. Point
* {@code partition.shards-dir} at a different directory to force a fresh instance. See
* {@code docs/07-the-rejectedexecutionexception.md} and
* {@code docs/08-restart-reruns-only-the-failed-partition.md}.
*/
@Component
public class OrderIngestRunner implements ApplicationRunner {
private final JobOperator jobOperator;
private final Job job;
@Value("${partition.shards-dir}")
private String shardsDir;
public OrderIngestRunner(JobOperator jobOperator, Job job) {
this.jobOperator = jobOperator;
this.job = job;
}
@Override
public void run(ApplicationArguments args) throws Exception {
var params = new JobParametersBuilder()
.addString("shardsDir", shardsDir) // identifying: same dir = same JobInstance = restart target
.toJobParameters();
try {
var execution = jobOperator.start(job, params);
System.out.println("JOB FINISHED: id=" + execution.getId() + " status=" + execution.getStatus()
+ " exitCode=" + execution.getExitStatus().getExitCode());
} catch (JobInstanceAlreadyCompleteException e) {
System.out.println("JOB ALREADY COMPLETE: " + e.getMessage());
}
}
}
@@ -0,0 +1,58 @@
package com.ankurm.batchpartition.runner;
import org.springframework.batch.core.job.JobExecution;
import org.springframework.batch.core.launch.JobOperator;
import org.springframework.batch.core.repository.explore.JobExplorer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* Spring Batch 6.0's new {@link JobOperator#recover(JobExecution)} (see
* {@code docs/09-jobexecutionalreadyrunning-and-recover.md}): the fix for the stuck job this
* module's {@code reject} profile produces. A rejected partition submission leaves its
* {@code StepExecution} parked at {@code STARTING}/{@code EXECUTING} forever even though the
* manager step and the {@code JobExecution} both reach {@code FAILED} &mdash; and the orphaned
* child rows are exactly what makes {@code JobOperator#start} on the same identifying parameters
* throw {@code JobExecutionAlreadyRunningException} on every subsequent attempt. {@code recover}
* walks the execution's steps and force-closes anything still marked running, after which a
* normal restart proceeds.
*
* <p>Runs before {@link OrderIngestRunner} ({@code @Order(0)} vs. the default) when the
* {@code recover} profile is active, so a single JVM invocation both recovers and restarts.
*/
@Component
@Profile("recover")
@Order(0)
public class RecoveryRunner implements ApplicationRunner {
private final JobOperator jobOperator;
private final JobExplorer jobExplorer;
@Value("${recover.job-execution-id}")
private long jobExecutionId;
public RecoveryRunner(JobOperator jobOperator, JobExplorer jobExplorer) {
this.jobOperator = jobOperator;
this.jobExplorer = jobExplorer;
}
@Override
public void run(ApplicationArguments args) {
JobExecution execution = jobExplorer.getJobExecution(jobExecutionId);
if (execution == null) {
System.out.println("RECOVER: no JobExecution with id=" + jobExecutionId);
return;
}
System.out.println("RECOVER: before -> status=" + execution.getStatus());
execution.getStepExecutions().forEach(se ->
System.out.println("RECOVER: step=" + se.getStepName() + " status=" + se.getStatus()));
JobExecution recovered = jobOperator.recover(execution);
System.out.println("RECOVER: after -> status=" + recovered.getStatus());
recovered.getStepExecutions().forEach(se ->
System.out.println("RECOVER: step=" + se.getStepName() + " status=" + se.getStatus()));
}
}
@@ -0,0 +1,41 @@
package com.ankurm.batchpartition.web;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* Prints the real thing instead of the remembered thing: which OS thread executed which
* partition of a given job execution, in what order, and for how long. Delete before shipping
* &mdash; see the Javadoc on {@link com.ankurm.batchpartition.partition.PartitionStatsListener}.
*/
@RestController
public class PartitionInsightController {
private final JdbcTemplate jdbcTemplate;
public PartitionInsightController(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@GetMapping("/batch/partitions/{jobExecutionId}")
public List<Map<String, Object>> partitions(@PathVariable long jobExecutionId) {
return jdbcTemplate.queryForList(
"SELECT partition_name, thread_name, read_count, started_at, finished_at, duration_ms, exit_code " +
"FROM PARTITION_STATS WHERE job_execution_id = ? ORDER BY started_at",
jobExecutionId);
}
@GetMapping("/batch/partitions/latest")
public List<Map<String, Object>> latest() {
return jdbcTemplate.queryForList(
"SELECT job_execution_id, partition_name, thread_name, read_count, started_at, finished_at, " +
"duration_ms, exit_code FROM PARTITION_STATS " +
"WHERE job_execution_id = (SELECT MAX(job_execution_id) FROM PARTITION_STATS) " +
"ORDER BY started_at");
}
}
@@ -0,0 +1,36 @@
spring:
batch:
job:
# Same reasoning as the spring-batch module: ImportRunner-style explicit launch, not the
# auto-configured JobLauncherApplicationRunner, which would launch every Job bean with
# parameterless defaults and run it twice on every startup.
enabled: false
jdbc:
initialize-schema: always
datasource:
url: jdbc:h2:file:./data/batchdb;AUTO_SERVER=TRUE
username: sa
password: ""
driver-class-name: org.h2.Driver
sql:
init:
mode: always
schema-locations: classpath:schema.sql
server:
port: 8081
partition:
shards-dir: ./data/shards
grid-size: 4
pool-core-size: 4
pool-max-size: 4
pool-queue-capacity: 0
risk:
iterations: 150
high-risk-amount-cents: 9500000
logging:
level:
org.springframework.batch: INFO
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS ORDER_RISK_SUMMARY (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
amount_cents BIGINT NOT NULL,
region VARCHAR(16) NOT NULL,
risk_score INT NOT NULL,
high_risk BOOLEAN NOT NULL,
partition_name VARCHAR(64) NOT NULL
);
-- Hidden runtime state this module makes visible: which thread actually executed which
-- partition, how many rows it read, and how long it took. See
-- web/PartitionInsightController and docs/05-the-diagnostic-endpoint.md.
CREATE TABLE IF NOT EXISTS PARTITION_STATS (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
job_execution_id BIGINT NOT NULL,
partition_name VARCHAR(64) NOT NULL,
thread_name VARCHAR(128) NOT NULL,
read_count BIGINT NOT NULL,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP NOT NULL,
duration_ms BIGINT NOT NULL,
exit_code VARCHAR(32) NOT NULL
);
@@ -0,0 +1,50 @@
package com.ankurm.batchpartition;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.partition.support.MultiResourcePartitioner;
import org.springframework.batch.infrastructure.item.ExecutionContext;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Pins the claim in {@code docs/03-what-gridsize-actually-controls.md}: decompiling
* {@code MultiResourcePartitioner.partition(int)} in {@code spring-batch-core-6.0.5.jar} shows
* the method never reads its {@code int gridSize} argument &mdash; it loops over the configured
* {@code resources} array (it does call {@code resource.getURL()} on each one, though, which is
* why this test uses real temp files rather than {@code ByteArrayResource}: the first draft did,
* and failed with {@code FileNotFoundException: Byte array resource cannot be resolved to URL}
* &mdash; itself a small, real, verified fact about what this method requires of its resources).
* If a future Spring Batch release changes the gridSize behaviour, this test is what breaks
* first, before an article claim goes stale silently.
*/
class PartitionerGridSizeTest {
@Test
void partitionCountFollowsResourceCountNotGridSize() throws IOException {
var partitioner = new MultiResourcePartitioner();
Resource[] resources = {
new FileSystemResource(Files.createTempFile("shard-one-", ".csv")),
new FileSystemResource(Files.createTempFile("shard-two-", ".csv")),
new FileSystemResource(Files.createTempFile("shard-three-", ".csv")),
};
partitioner.setResources(resources);
Map<String, ExecutionContext> partitions = partitioner.partition(10);
try (var t = new Transcript("02-gridsize-ignored.txt",
"MultiResourcePartitioner.partition(10) with 3 resources")) {
t.line("resources given: %d", resources.length);
t.line("gridSize argument passed to partition(): 10");
t.line("partitions actually returned: %d", partitions.size());
t.line("partition keys: %s", String.join(", ", partitions.keySet()));
}
assertThat(partitions).hasSize(3);
}
}
@@ -0,0 +1,52 @@
package com.ankurm.batchpartition;
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);
}
}
@@ -0,0 +1,39 @@
package com.ankurm.batchpartition.processing;
import com.ankurm.batchpartition.Transcript;
import com.ankurm.batchpartition.domain.Order;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link RiskScoringProcessor} is pure and deterministic on purpose: {@code docs/06-why-cpu-bound-not-io-bound.md}
* leans on that determinism to prove two separate JVM runs of the same shard produce byte-identical
* {@code ORDER_RISK_SUMMARY} rows &mdash; a claim this test pins at the processor level before the
* full job ever runs.
*/
class RiskScoringProcessorTest {
@Test
void sameInputAlwaysProducesSameScore() {
var processor = new RiskScoringProcessor(150, 9_500_000L);
var order = new Order(42L, 777L, 1_234_567L, "NORTH");
var first = processor.process(order);
var second = processor.process(order);
try (var t = new Transcript("01-processor-determinism.txt", "RiskScoringProcessor determinism, threshold behaviour")) {
t.line("order: %s", order);
t.line("first.process() -> riskScore=%d highRisk=%b", first.riskScore(), first.highRisk());
t.line("second.process() -> riskScore=%d highRisk=%b", second.riskScore(), second.highRisk());
t.section("amount threshold, score held constant by a low iteration count");
var below = processor.process(new Order(1L, 1L, 9_499_999L, "EAST"));
var above = processor.process(new Order(1L, 1L, 9_500_001L, "EAST"));
t.line("amountCents=9,499,999 (at threshold, exclusive) -> highRisk=%b", below.highRisk());
t.line("amountCents=9,500,001 (over threshold) -> highRisk=%b", above.highRisk());
}
assertThat(first.riskScore()).isEqualTo(second.riskScore());
assertThat(first.highRisk()).isEqualTo(second.highRisk());
}
}