Add spring-batch-partitioning: manager/worker partitioning, gridSize semantics, rejected-partition recovery, and a real 10M-row scaling sweep
This commit is contained in:
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+257
@@ -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 — 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 — 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 — {@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} — 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} — 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 — 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) {
|
||||
}
|
||||
+5
@@ -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) {
|
||||
}
|
||||
+53
@@ -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 — 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();
|
||||
}
|
||||
}
|
||||
+37
@@ -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 — 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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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} — 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()));
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
* — 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
|
||||
);
|
||||
+50
@@ -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 — 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}
|
||||
* — 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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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 — 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user