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,9 @@
# RiskScoringProcessor determinism, threshold behaviour
order: Order[orderId=42, customerId=777, amountCents=1234567, region=NORTH]
first.process() -> riskScore=61 highRisk=false
second.process() -> riskScore=61 highRisk=false
--- amount threshold, score held constant by a low iteration count ---
amountCents=9,499,999 (at threshold, exclusive) -> highRisk=false
amountCents=9,500,001 (over threshold) -> highRisk=true
@@ -0,0 +1,6 @@
# MultiResourcePartitioner.partition(10) with 3 resources
resources given: 3
gridSize argument passed to partition(): 10
partitions actually returned: 3
partition keys: partition2, partition1, partition0
@@ -0,0 +1,43 @@
# Spring Batch 6.0: the item/repeat infrastructure moved packages
Verified by decompiling the real jars pulled from Maven Central, not by reading prose.
--- Spring Batch 5.2.x (docs.spring.io javadoc, org.springframework.batch:spring-batch-infrastructure:5.2.6) ---
Package: org.springframework.batch.item
Class: org.springframework.batch.item.ExecutionContext
--- Spring Batch 6.0.5 (unzip -l spring-batch-infrastructure-6.0.5.jar) ---
$ unzip -l spring-batch-infrastructure-6.0.5.jar | grep -E "ExecutionContext.class|CompletionPolicy.class"
2908 2026-08-17 10:28 org/springframework/batch/infrastructure/repeat/policy/SimpleCompletionPolicy.class
3388 2026-08-17 10:28 org/springframework/batch/infrastructure/repeat/policy/CompositeCompletionPolicy.class
1179 2026-08-17 10:28 org/springframework/batch/infrastructure/repeat/policy/DefaultResultCompletionPolicy.class
2934 2026-08-17 10:28 org/springframework/batch/infrastructure/repeat/policy/CountingCompletionPolicy.class
718 2026-08-17 10:28 org/springframework/batch/infrastructure/repeat/CompletionPolicy.class
7922 2026-08-17 10:28 org/springframework/batch/infrastructure/item/ExecutionContext.class
Every class under the old org.springframework.batch.item.* and org.springframework.batch.repeat.*
now lives under org.springframework.batch.infrastructure.item.* and
org.springframework.batch.infrastructure.repeat.* -- ItemReader, ItemWriter, ItemProcessor,
ExecutionContext, RepeatStatus, CompletionPolicy, every FlatFileItemReaderBuilder and
JdbcBatchItemWriterBuilder, all of it. A 5.x guide's imports do not compile against 6.0 for this
reason alone, before any API shape has changed at all.
--- Partitioner interface, decompiled from spring-batch-core-6.0.5.jar ---
$ javap org/springframework/batch/core/partition/Partitioner.class
Compiled from "Partitioner.java"
public interface org.springframework.batch.core.partition.Partitioner {
public abstract java.util.Map<java.lang.String, org.springframework.batch.infrastructure.item.ExecutionContext> partition(int);
}
Note the return type: Map<String, org.springframework.batch.infrastructure.item.ExecutionContext>.
Any 5.x Partitioner implementation that imports org.springframework.batch.item.ExecutionContext
fails to compile against 6.0 with "cannot find symbol" on that single import line -- the fix is a
one-line import change, but the error message does not say that; it just says the type does not
exist, which sends most people straight to a search engine instead of to the correct one-line fix.
--- Two more classes that moved, found while writing this module's beans ---
org.springframework.batch.core.job.parameters.JobParametersBuilder (was org.springframework.batch.core.JobParametersBuilder)
org.springframework.batch.core.repository.explore.JobExplorer (was org.springframework.batch.core.explore.JobExplorer)
org.springframework.batch.core.scope.context.StepSynchronizationManager (NOT org.springframework.batch.core.step -- BatchConfig's
first draft guessed that package for reading the current partition's step name inside an
ItemWriter and failed to compile; see docs/04-the-writer-and-the-beanmapped-trap.md)
@@ -0,0 +1,23 @@
# Overloading a @Bean method across mutually-exclusive @Profile beans: rejected at startup
First draft of the two TaskExecutor beans in BatchConfig used the same method name,
`partitionTaskExecutor`, distinguished only by @Profile("!reject") / @Profile("reject").
Spring Framework 7's @Configuration.enforceUniqueMethods (on by default) does not know the two
profiles are mutually exclusive at class-parsing time -- it only sees one method name declared
twice -- and refuses to start:
$ java -jar target/spring-batch-partitioning-1.0.0.jar --partition.shards-dir=./data/shards-small
...
2026-09-14T09:06:49.013Z WARN 2629 --- [ main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.parsing.BeanDefinitionParsingException: Configuration problem: @Configuration class 'BatchConfig' contains overloaded @Bean methods with name 'partitionTaskExecutor'. Use unique method names for separate bean definitions (with individual conditions etc) or switch '@Configuration.enforceUniqueMethods' to 'false'.
Offending resource: class path resource [com/ankurm/batchpartition/config/BatchConfig.class]
org.springframework.beans.factory.parsing.BeanDefinitionParsingException: Configuration problem: @Configuration class 'BatchConfig' contains overloaded @Bean methods with name 'partitionTaskExecutor'. Use unique method names for separate bean definitions (with individual conditions etc) or switch '@Configuration.enforceUniqueMethods' to 'false'.
at org.springframework.beans.factory.parsing.FailFastProblemReporter.error(FailFastProblemReporter.java:71) ~[spring-beans-7.0.9.jar!/:7.0.9]
at org.springframework.context.annotation.ConfigurationClass.validate(ConfigurationClass.java:265) ~[spring-context-7.0.9.jar!/:7.0.9]
at org.springframework.context.annotation.ConfigurationClassParser.validate(ConfigurationClassParser.java:230) ~[spring-context-7.0.9.jar!/:7.0.9]
The message is accurate and the fix it suggests (unique method names) is the right one -- this
module's fixed version names the two beans partitionTaskExecutor() and
partitionTaskExecutorRejecting(int). The point worth recording: this is NOT a Spring Batch 6
change, it is a Spring Framework 7 @Configuration default that bites a pattern (profile-gated
@Bean overloads) that plenty of Spring Batch 5.x tutorials use freely.
@@ -0,0 +1,65 @@
# Happy path: 4 shard files, gridSize 4, pool size 4 -- 20,000 rows
$ java -jar target/spring-batch-partitioning-1.0.0.jar \
--partition.shards-dir=./data/shards-small --partition.grid-size=4 \
--partition.pool-core-size=4 --partition.pool-max-size=4
2026-09-14T09:29:56.758Z INFO 4937 --- [ main] c.a.b.PartitioningDemoApplication : Started PartitioningDemoApplication in 3.563 seconds (process running for 4.114)
2026-09-14T09:29:56.876Z INFO 4937 --- [ main] o.s.batch.core.step.AbstractStep : Executing step: [ordersManagerStep]
2026-09-14T09:29:56.892Z INFO 4937 --- [der-partition-2] o.s.batch.core.step.AbstractStep : Executing step: [ordersWorkerStep:partition0]
2026-09-14T09:29:56.890Z INFO 4937 --- [der-partition-1] o.s.batch.core.step.AbstractStep : Executing step: [ordersWorkerStep:partition1]
2026-09-14T09:29:56.900Z INFO 4937 --- [der-partition-4] o.s.batch.core.step.AbstractStep : Executing step: [ordersWorkerStep:partition2]
2026-09-14T09:29:56.901Z INFO 4937 --- [der-partition-3] o.s.batch.core.step.AbstractStep : Executing step: [ordersWorkerStep:partition3]
2026-09-14T09:29:57.962Z INFO 4937 --- [der-partition-2] o.s.batch.core.step.AbstractStep : Step: [ordersWorkerStep:partition0] executed in 1s70ms
2026-09-14T09:29:57.963Z INFO 4937 --- [der-partition-4] o.s.batch.core.step.AbstractStep : Step: [ordersWorkerStep:partition2] executed in 1s64ms
2026-09-14T09:29:57.990Z INFO 4937 --- [der-partition-1] o.s.batch.core.step.AbstractStep : Step: [ordersWorkerStep:partition1] executed in 1s99ms
2026-09-14T09:29:57.998Z INFO 4937 --- [der-partition-3] o.s.batch.core.step.AbstractStep : Step: [ordersWorkerStep:partition3] executed in 1s98ms
2026-09-14T09:29:58.005Z INFO 4937 --- [ main] o.s.batch.core.step.AbstractStep : Step: [ordersManagerStep] executed in 1s130ms
2026-09-14T09:29:58.011Z INFO 4937 --- [ main] o.s.batch.core.step.AbstractStep : Executing step: [reportStep]
REPORT: 20000 orders scored, 1561 flagged high-risk
JOB FINISHED: id=1 status=COMPLETED exitCode=COMPLETED
All four partitions start within a few milliseconds of each other, on four distinct named
threads (order-partition-1..4) -- this is what "partitioned" actually looks like at the OS level,
not just in configuration. 1,561 of 20,000 orders (7.8%) came back flagged high-risk, matching the
expected rate from the threshold math in docs/06-why-cpu-bound-not-io-bound.md.
--- GET /batch/partitions/1 (the diagnostic endpoint -- delete before shipping) ---
[
{
"PARTITION_NAME": "ordersWorkerStep:partition0",
"THREAD_NAME": "order-partition-2",
"READ_COUNT": 5000,
"STARTED_AT": "2026-09-14T09:29:56.892Z",
"FINISHED_AT": "2026-09-14T09:29:57.968Z",
"DURATION_MS": 1075,
"EXIT_CODE": "COMPLETED"
},
{
"PARTITION_NAME": "ordersWorkerStep:partition1",
"THREAD_NAME": "order-partition-1",
"READ_COUNT": 5000,
"STARTED_AT": "2026-09-14T09:29:56.895Z",
"FINISHED_AT": "2026-09-14T09:29:57.999Z",
"DURATION_MS": 1103,
"EXIT_CODE": "COMPLETED"
},
{
"PARTITION_NAME": "ordersWorkerStep:partition2",
"THREAD_NAME": "order-partition-4",
"READ_COUNT": 5000,
"STARTED_AT": "2026-09-14T09:29:56.900Z",
"FINISHED_AT": "2026-09-14T09:29:57.964Z",
"DURATION_MS": 1064,
"EXIT_CODE": "COMPLETED"
},
{
"PARTITION_NAME": "ordersWorkerStep:partition3",
"THREAD_NAME": "order-partition-3",
"READ_COUNT": 5000,
"STARTED_AT": "2026-09-14T09:29:56.901Z",
"FINISHED_AT": "2026-09-14T09:29:58.002Z",
"DURATION_MS": 1101,
"EXIT_CODE": "COMPLETED"
}
]
@@ -0,0 +1,33 @@
# Undersized thread pool + AbortPolicy: 3 of 4 partitions rejected, and stuck forever
$ java -jar target/spring-batch-partitioning-1.0.0.jar --spring.profiles.active=reject \
--partition.shards-dir=./data/shards-small --partition.grid-size=4 --partition.reject.pool-size=1
2026-09-14T09:11:18.181Z INFO 3074 --- [ main] c.a.b.PartitioningDemoApplication : The following 1 profile is active: "reject"
2026-09-14T09:11:21.361Z INFO 3074 --- [ main] o.s.batch.core.step.AbstractStep : Executing step: [ordersManagerStep]
2026-09-14T09:11:21.375Z INFO 3074 --- [der-partition-1] o.s.batch.core.step.AbstractStep : Executing step: [ordersWorkerStep:partition1]
2026-09-14T09:11:21.946Z INFO 3074 --- [der-partition-1] o.s.batch.core.step.AbstractStep : Step: [ordersWorkerStep:partition1] executed in 570ms
org.springframework.batch.core.job.JobExecutionException: Partition handler returned an unsuccessful step
2026-09-14T09:11:21.961Z INFO 3074 --- [ main] o.s.batch.core.step.AbstractStep : Step: [ordersManagerStep] executed in 601ms
JOB FINISHED: id=1 status=FAILED exitCode=FAILED
Only ordersWorkerStep:partition1 ever logs "Executing step" -- the other three were rejected by
the ThreadPoolTaskExecutor (corePoolSize=1, queueCapacity=0, AbortPolicy) before they could even
start, and TaskExecutorPartitionHandler swallows that TaskRejectedException into the rejected
StepExecution's failure list rather than printing it -- nothing named "TaskRejectedException" or
"Rejected" ever appears in this log. The job fails with the generic message above.
--- Querying BATCH_STEP_EXECUTION directly (org.h2.tools.Shell) shows what the log does not ---
$ java -cp h2-2.4.240.jar org.h2.tools.Shell -url jdbc:h2:file:./data/rejecttest -user sa -password "" \
-sql "SELECT STEP_EXECUTION_ID, STEP_NAME, STATUS, EXIT_CODE FROM BATCH_STEP_EXECUTION ORDER BY STEP_EXECUTION_ID;"
STEP_EXECUTION_ID | STEP_NAME | STATUS | EXIT_CODE
1 | ordersManagerStep | FAILED | FAILED
2 | ordersWorkerStep:partition3 | STARTING | EXECUTING
3 | ordersWorkerStep:partition2 | STARTING | EXECUTING
4 | ordersWorkerStep:partition1 | COMPLETED | COMPLETED
5 | ordersWorkerStep:partition0 | STARTING | EXECUTING
The manager step and the job both reach FAILED. The three rejected worker StepExecutions do not
-- they are parked at STARTING/EXECUTING permanently. Nothing in this job's lifecycle ever
transitions them again on its own.
@@ -0,0 +1,18 @@
# Restarting the stuck job: JobExecutionAlreadyRunningException, forever
$ java -jar target/spring-batch-partitioning-1.0.0.jar \
--partition.shards-dir=./data/shards-small --partition.grid-size=4 --partition.pool-core-size=4 --partition.pool-max-size=4
(same shardsDir = same identifying job parameter = same JobInstance = restart target)
Caused by: org.springframework.batch.core.launch.JobExecutionAlreadyRunningException: A job execution for this job is already running: JobExecution: id=1, version=3, startTime=2026-09-14T09:11:21.331474360, endTime=2026-09-14T09:11:21.965090481, lastUpdated=2026-09-14T09:11:21.966521177, status=FAILED, exitStatus=exitCode=FAILED;exitDescription=org.springframework.batch.core.job.JobExecutionException: Partition handler returned an unsuccessful step
at org.springframework.batch.core.partition.PartitionStep.doExecute(PartitionStep.java:134)
at org.springframework.batch.core.step.AbstractStep.execute(AbstractStep.java:251)
The JobExecution row plainly says status=FAILED right there in the exception's own message, and
SimpleJobOperator still refuses to start a new attempt against it, because its check for "is this
JobInstance already running" is not "is the JobExecution FAILED" -- it is closer to "does this
instance have any StepExecution that is not in a terminal status", and the three orphaned
STARTING/EXECUTING worker steps from docs/06-rejected-partitions-stuck.txt are exactly that. Every
subsequent `java -jar ... ` against the same shards-dir throws this same exception. The job is
not failed. It is stuck.
@@ -0,0 +1,46 @@
# Spring Batch 6.0's fix: JobOperator#recover, then a normal restart
$ java -jar target/spring-batch-partitioning-1.0.0.jar --spring.profiles.active=recover \
--recover.job-execution-id=1 --partition.shards-dir=./data/shards-small \
--partition.grid-size=4 --partition.pool-core-size=4 --partition.pool-max-size=4
RECOVER: before -> status=FAILED
RECOVER: step=ordersManagerStep status=FAILED
RECOVER: step=ordersWorkerStep:partition3 status=STARTING
RECOVER: step=ordersWorkerStep:partition2 status=STARTING
RECOVER: step=ordersWorkerStep:partition1 status=COMPLETED
RECOVER: step=ordersWorkerStep:partition0 status=STARTING
2026-09-14T09:13:31.156Z INFO 3377 --- [ main] o.s.b.c.l.s.TaskExecutorJobOperator : Recovering job execution: JobExecution: id=1, version=3, startTime=2026-09-14T09:11:21.331474360, endTime=2026-09-14T09:11:21.965090481, lastUpdated=2026-09-14T09:11:21.966521177, status=FAILED, exitStatus=exitCode=FAILED;exitDescription=org.springframework.batch.core.job.JobExecutionException: Partition handler returned an unsuccessful step
RECOVER: after -> status=FAILED
RECOVER: step=ordersManagerStep status=FAILED
RECOVER: step=ordersWorkerStep:partition3 status=FAILED
RECOVER: step=ordersWorkerStep:partition2 status=FAILED
RECOVER: step=ordersWorkerStep:partition1 status=COMPLETED
RECOVER: step=ordersWorkerStep:partition0 status=FAILED
JOB FINISHED: id=33 status=COMPLETED exitCode=COMPLETED
recover() walked the stuck JobExecution's StepExecutions and force-closed the three still at
STARTING to FAILED -- nothing else changed. RecoveryRunner runs at @Order(0); OrderIngestRunner
then runs its normal start() immediately after, in the same JVM, against the same shardsDir, and
this time it succeeds: a brand-new JobExecution (id=33) completes.
--- BATCH_STEP_EXECUTION after recovery + restart: which partitions actually reran ---
$ java -cp h2-2.4.240.jar org.h2.tools.Shell -url jdbc:h2:file:./data/rejecttest -user sa -password "" \
-sql "SELECT JOB_EXECUTION_ID, STEP_EXECUTION_ID, STEP_NAME, STATUS, READ_COUNT FROM BATCH_STEP_EXECUTION WHERE JOB_EXECUTION_ID IN (1,33) ORDER BY JOB_EXECUTION_ID, STEP_EXECUTION_ID;"
JOB_EXECUTION_ID | STEP_EXECUTION_ID | STEP_NAME | STATUS | READ_COUNT
1 | 1 | ordersManagerStep | FAILED | 5000
1 | 2 | ordersWorkerStep:partition3 | FAILED | 0
1 | 3 | ordersWorkerStep:partition2 | FAILED | 0
1 | 4 | ordersWorkerStep:partition1 | COMPLETED | 5000
1 | 5 | ordersWorkerStep:partition0 | FAILED | 0
33 | 33 | ordersManagerStep | COMPLETED | 15000
33 | 34 | ordersWorkerStep:partition3 | COMPLETED | 5000
33 | 35 | ordersWorkerStep:partition2 | COMPLETED | 5000
33 | 36 | ordersWorkerStep:partition0 | COMPLETED | 5000
33 | 37 | reportStep | COMPLETED | 0
JobExecution 33 has exactly three new worker StepExecutions -- partition3, partition2, partition0,
the ones recover() marked FAILED. There is no new StepExecution for partition1: it stayed
COMPLETED from JobExecution 1 and was correctly skipped. Restart-only-the-failed-partition is not
a promise in the reference docs here -- it is what this table shows actually happened.
@@ -0,0 +1,40 @@
# 10,000,000 rows: manager-step wall time by partition count (2 vCPUs)
Same job, same code path, same data (deterministic seed 100) -- only partition.grid-size and
the shard directory (which controls how many resources MultiResourcePartitioner sees) change.
risk.iterations=150 (the default) for all four runs. Each number is the 'Step: [ordersManagerStep]
executed in ...' line from that run's own log -- wall time for the whole partitioned step,
including every worker partition and the manager step's own bookkeeping.
grid-size manager-step rows/sec speedup-vs-1
1 70.485s 141,874 1.00x
2 49.647s 201,422 1.42x
4 53.102s 188,317 1.33x
8 55.521s 180,112 1.27x
Best result at grid-size 2 -- matching this sandbox's 2 vCPUs exactly. Beyond that, wall time gets
WORSE with every doubling: grid-size 8 is slower than grid-size 4, which is slower than grid-size
2. More partitions past the physical core count does not sit still, it actively costs time --
context-switch and scheduling overhead with no additional CPU to absorb it. The speedup at
grid-size 2 (1.42x) is also well short of the 2x a naive "twice the cores" mental model predicts;
docs/06-why-cpu-bound-not-io-bound.md and docs/10-scaling-sensitivity-to-data-size.md discuss the
two candidate reasons this article checked (H2's single-writer MVStore, and fixed per-partition
startup cost) and what evidence separates them.
--- The same sweep at a smaller scale (300,000 rows) tells a different story ---
grid-size 1: 5.127s grid-size 2: 4.677s (1.10x) grid-size 4: 5.158s (0.99x) grid-size 8: 6.812s (0.75x)
At 300K rows, grid-size 8 is not just worse than grid-size 2 -- it is worse than NOT partitioning
at all. The fixed cost of standing up a partition (opening the shard file, acquiring a JDBC
connection, thread handoff) is the same few milliseconds whether a job processes 10,000,000 rows
or 300,000; at the smaller scale there is less real work to amortize it against, so
over-partitioning is a strictly worse mistake on a smaller job than on a larger one. Whether
partitioning helps at all is a function of BOTH core count and data volume, not core count alone.
--- A CPU-heavier variant (risk.iterations=5000, 300,000 rows) narrows the gap towards 2x ---
grid-size 1: 9.184s grid-size 2: 7.277s (1.26x)
Raising the per-item CPU cost pushed the grid-size-2 speedup from 1.10x to 1.26x at the same data
volume -- evidence, not proof, that at least part of the shortfall from a clean 2x is the shared
H2 writer, not thread overhead alone: more CPU-bound work per item dilutes the fixed write-lock
cost relative to total time, and the measured speedup moved in exactly that direction.