Skip to content

Usage guide

This walks through wiring a partitioned job onto the cluster. It assumes you have already added the dependency, enabled a JDBC job repository, and set spring.batch.cluster.enabled=true — see Installation.

Your job and step definitions stay standard Spring Batch. Two things are cluster-specific: a ClusterAwarePartitioner (how work is split and assigned), and wiring the manager step to the library's ClusterAwarePartitionHandler and ClusterAwareAggregator.

1. Write a ClusterAwarePartitioner

Extend the abstract ClusterAwarePartitioner and implement three methods. The key one, createDistributedPartitions, receives the live node count so you can size the split to the cluster that actually exists right now.

The partitioner must be a Spring bean — don't new it

ClusterAwarePartitioner has the cluster service injected into it by the framework, so it only works as a Spring-managed bean. A plain new RangeSumPartitioner(...) is never injected and fails at runtime with a NullPointerException. Annotate it (@Component below, or declare it as a @Bean), and use @StepScope if it reads job parameters (as here).

import io.github.jchejarla.springbatch.clustering.api.ClusterAwarePartitioner;
import io.github.jchejarla.springbatch.clustering.api.PartitionStrategy;
import io.github.jchejarla.springbatch.clustering.partition.PartitioningMode;
import io.github.jchejarla.springbatch.clustering.partition.PartitionTransferableProp;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.infrastructure.item.ExecutionContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
@StepScope   // a new instance per job run, so it can read this run's job parameters
public class RangeSumPartitioner extends ClusterAwarePartitioner {

    private final long from, to;

    public RangeSumPartitioner(@Value("#{jobParameters['from']}") long from,
                               @Value("#{jobParameters['to']}") long to) {
        this.from = from;
        this.to = to;
    }

    @Override
    public List<ExecutionContext> createDistributedPartitions(int availableNodeCount) {
        // Size the split to the live cluster; each ExecutionContext is one partition's input.
        int partitions = availableNodeCount * 2;
        long span = (to - from + 1) / partitions;
        List<ExecutionContext> result = new ArrayList<>(partitions);
        long start = from;
        for (int i = 0; i < partitions; i++) {
            long end = (i == partitions - 1) ? to : start + span - 1;
            ExecutionContext ctx = new ExecutionContext();
            ctx.putLong("start", start);
            ctx.putLong("end", end);
            result.add(ctx);
            start = end + 1;
        }
        return result;
    }

    @Override
    public PartitionTransferableProp arePartitionsTransferableWhenNodeFailed() {
        // YES: if a node dies, its incomplete partitions are reassigned to healthy nodes.
        // NO: partitions are node-local / non-idempotent — on node loss they are failed, not moved.
        return PartitionTransferableProp.YES;
    }

    @Override
    public PartitionStrategy buildPartitionStrategy() {
        return PartitionStrategy.builder()
                .partitioningMode(PartitioningMode.ROUND_ROBIN)
                .build();
    }
}

Assignment strategies

buildPartitionStrategy() chooses how partitions map onto nodes:

Mode Behaviour
ROUND_ROBIN Even distribution across all live nodes.
FIXED_NODE_COUNT Restrict to N nodes — PartitionStrategy.builder().partitioningMode(FIXED_NODE_COUNT).fixedNodeCount(3).build().
LEAST_LOADED Load-aware — assigns each partition to the node with the lowest live load, steering work away from nodes already busy with other jobs.

Transferability

arePartitionsTransferableWhenNodeFailed() is a correctness contract, not a hint. YES means a lost node's incomplete partitions are safe to re-run elsewhere (idempotent work). NO means they must never be re-executed on another node — when the owning node is lost they are failed so the job fails cleanly, rather than risking a double side effect. Choose deliberately.

2. Wire the partitioned step

The manager step uses your partitioner plus the library-provided ClusterAwarePartitionHandler; the worker step is an ordinary Spring Batch step that each node runs for its assigned partitions.

@Configuration
public class RangeSumJobConfig {

    @Autowired ClusterAwarePartitionHandler partitionHandler;   // provided by auto-configuration
    @Autowired RangeSumPartitioner rangeSumPartitioner;         // the @Component bean from step 1

    @Bean
    public Job rangeSumJob(JobRepository jobRepository, PlatformTransactionManager txnManager,
                           StepExecutionAggregator aggregator) {
        return new JobBuilder("rangeSumJob", jobRepository)
                .incrementer(new RunIdIncrementer())
                .start(managerStep(jobRepository, txnManager, aggregator))
                .build();
    }

    public Step managerStep(JobRepository jobRepository, PlatformTransactionManager txnManager,
                            StepExecutionAggregator aggregator) {
        return new StepBuilder("rangeSumStep.manager", jobRepository)
                // inject the partitioner BEAN — never `new RangeSumPartitioner(...)` (see step 1).
                .partitioner("rangeSumStep", rangeSumPartitioner)
                .partitionHandler(partitionHandler)     // ← distributes via the database
                .step(rangeSumStep(jobRepository, txnManager))
                .aggregator(aggregator)
                .build();
    }

    // The worker step. Its BEAN NAME must equal the step name and the name passed to
    // .partitioner("rangeSumStep", ...) above — worker nodes look the step up by that name in the
    // application context. Here the @Bean method name, the StepBuilder name, and the partitioner name
    // are all "rangeSumStep"; if they diverge, workers fail with NoSuchBeanDefinitionException.
    @Bean
    public Step rangeSumStep(JobRepository jobRepository, PlatformTransactionManager txnManager) {
        return new StepBuilder("rangeSumStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                    ExecutionContext ctx = chunkContext.getStepContext()
                            .getStepExecution().getExecutionContext();
                    long start = ctx.getLong("start"), end = ctx.getLong("end");
                    long sum = 0;
                    for (long i = start; i <= end; i++) sum += i;
                    ctx.putLong("result", sum);
                    return RepeatStatus.FINISHED;
                }, txnManager)
                .build();
    }
}

Nothing about the worker step is cluster-aware — it just reads its partition's ExecutionContext and does the work. Distribution, claiming, and failover happen underneath, in ClusterAwarePartitionHandler. For a chunk-oriented ETL worker (a @StepScope reader/processor/writer reading start/end from #{stepExecutionContext['start']}), see the CSV→XML job in Examples.

Spring Batch 6 import

RunIdIncrementer moved packages in Batch 6 — import it from org.springframework.batch.core.job.parameters.RunIdIncrementer (it was in …launch.support in Batch 5, and the old path no longer compiles).

3. Aggregate the results

ClusterAwareAggregator collects the completed partitions on the master and invokes your callback. Provide a ClusterAwareAggregatorCallback for the success/failure hooks:

@Bean
public StepExecutionAggregator aggregator(JobRepository jobRepository) {
    return new ClusterAwareAggregator(new ClusterAwareAggregatorCallback() {
        @Override
        public void onSuccess(Collection<StepExecution> executions) {
            long total = executions.stream()
                    .map(se -> se.getExecutionContext().get("result", Long.class))
                    .filter(Objects::nonNull).mapToLong(Long::longValue).sum();
            log.info("Job complete — total = {}", total);
        }

        @Override
        public void onFailure(Collection<StepExecution> executions) {
            log.error("Job failed — some partitions did not complete");
        }
    }, jobRepository);
}

4. Run more than one instance

Start two or more instances of your Spring Boot application (different hosts, or different ports on one machine). Each node registers itself and generates a unique id automatically — no per-instance config. Launch the job on any node: that node becomes the master for that execution and the rest act as workers. Two jobs launched on two nodes have two independent masters, concurrently.

Don't let the job auto-run at startup

If you have a Job bean and launch jobs on demand (REST, a scheduler, …), set spring.batch.job.enabled=false. Otherwise Spring Boot's JobLauncherApplicationRunner runs the job at application startup — before the cluster has formed — and it fails. Trigger jobs yourself once the app is up.

Watch it run through the actuator endpoints, and see a complete, runnable multi-node project — including a failover walkthrough — in Examples.