Tasklets: Running Non-Chunk Operations in Batch Jobs
Introduction
Not every step in a batch job reads-processes-writes a stream of items. Sometimes you need to:
- Delete yesterday’s temp files before reading today’s data
- Truncate a staging table before loading new data
- Call a stored procedure to aggregate results
- Send an email or Slack notification after processing
- Execute a DDL statement to add an index
These operations do not have items — they are single units of work. Spring Batch handles them with the Tasklet interface.
The Tasklet Interface
public interface Tasklet {
RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception;
}
Return RepeatStatus.FINISHED when the work is complete. Return RepeatStatus.CONTINUABLE to have Spring Batch call execute() again in a loop (useful for polling).
If execute() throws, the step fails. The transaction wrapping the tasklet call rolls back.
Simple Tasklet Examples
File cleanup
@Component
public class DeleteTempFilesTasklet implements Tasklet {
private static final Logger log = LoggerFactory.getLogger(DeleteTempFilesTasklet.class);
@Value("${batch.temp.dir:/tmp/batch}")
private String tempDir;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext)
throws Exception {
Path dir = Paths.get(tempDir);
if (!Files.exists(dir)) {
log.info("Temp directory does not exist, nothing to clean");
return RepeatStatus.FINISHED;
}
try (Stream<Path> files = Files.list(dir)) {
files.filter(p -> p.toString().endsWith(".csv") || p.toString().endsWith(".tmp"))
.forEach(p -> {
try {
Files.deleteIfExists(p);
log.info("Deleted temp file: {}", p);
contribution.incrementWriteCount(1);
} catch (IOException e) {
log.warn("Could not delete {}: {}", p, e.getMessage());
}
});
}
return RepeatStatus.FINISHED;
}
}
Register as a step:
@Bean
public Step cleanupStep(DeleteTempFilesTasklet tasklet) {
return new StepBuilder("cleanupStep", jobRepository)
.tasklet(tasklet, tx)
.allowStartIfComplete(true) // always re-run cleanup on restart
.build();
}
Truncate staging table
@Component
@RequiredArgsConstructor
public class TruncateStagingTableTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
jdbcTemplate.execute("TRUNCATE TABLE staging_orders");
log.info("Staging table truncated");
return RepeatStatus.FINISHED;
}
}
Call a MySQL stored procedure
@Component
@RequiredArgsConstructor
public class AggregateOrdersTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
String runDate = (String) chunkContext.getStepContext()
.getJobParameters().get("runDate");
jdbcTemplate.update("CALL aggregate_daily_orders(?)", runDate);
log.info("Aggregation stored procedure executed for {}", runDate);
return RepeatStatus.FINISHED;
}
}
Send notification
@Component
@RequiredArgsConstructor
public class NotifyCompletionTasklet implements Tasklet {
private final RestClient slackClient;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
JobExecution jobExecution = chunkContext.getStepContext()
.getStepExecution().getJobExecution();
long importedCount = jobExecution.getExecutionContext()
.getLong("importedOrderCount", 0L);
long skippedCount = jobExecution.getExecutionContext()
.getLong("skippedOrderCount", 0L);
String message = String.format(
"✅ Order import complete. Imported: %d, Skipped: %d",
importedCount, skippedCount);
slackClient.post()
.uri("/webhooks/notify")
.body(Map.of("text", message))
.retrieve()
.toBodilessEntity();
return RepeatStatus.FINISHED;
}
}
Polling Tasklet with RepeatStatus.CONTINUABLE
Use CONTINUABLE for steps that must wait for an external condition before proceeding — an S3 file to appear, an upstream job to complete, a lock to be released.
@Component
@RequiredArgsConstructor
public class WaitForFileTasklet implements Tasklet {
private static final Logger log = LoggerFactory.getLogger(WaitForFileTasklet.class);
private static final int MAX_ATTEMPTS = 60;
private int attemptCount = 0;
@Value("${batch.input.file}")
private String filePath;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext)
throws Exception {
if (Files.exists(Paths.get(filePath))) {
log.info("File found after {} attempts: {}", attemptCount, filePath);
return RepeatStatus.FINISHED;
}
attemptCount++;
if (attemptCount >= MAX_ATTEMPTS) {
throw new FileNotFoundException(
"File not found after " + MAX_ATTEMPTS + " attempts: " + filePath);
}
log.info("File not yet available (attempt {}), retrying in 30s...", attemptCount);
Thread.sleep(30_000);
return RepeatStatus.CONTINUABLE; // Spring Batch calls execute() again
}
}
Warning: CONTINUABLE holds the step’s transaction open during the sleep. For long waits, use CONTINUABLE only with short sleep intervals and a reasonable MAX_ATTEMPTS limit.
MethodInvokingTaskletAdapter
If you have an existing service method that should become a step, use MethodInvokingTaskletAdapter to avoid writing a Tasklet wrapper class:
@Service
public class ArchiveService {
public void archiveCompletedOrders(String runDate) {
// move completed orders to archive table
}
}
@Bean
@StepScope
public MethodInvokingTaskletAdapter archiveTasklet(
ArchiveService archiveService,
@Value("#{jobParameters['runDate']}") String runDate) {
MethodInvokingTaskletAdapter adapter = new MethodInvokingTaskletAdapter();
adapter.setTargetObject(archiveService);
adapter.setTargetMethod("archiveCompletedOrders");
adapter.setArguments(new Object[]{runDate});
return adapter;
}
@Bean
public Step archiveOrdersStep() {
return new StepBuilder("archiveOrdersStep", jobRepository)
.tasklet(archiveTasklet(null, null), tx)
.build();
}
@StepScope is required here because the tasklet reads jobParameters.
SystemCommandTasklet
Run a shell command as a batch step:
@Bean
public SystemCommandTasklet generateReportTasklet() {
SystemCommandTasklet tasklet = new SystemCommandTasklet();
tasklet.setCommand("python3 /scripts/generate_report.py --date 2026-05-03");
tasklet.setTimeout(300_000); // 5-minute timeout
tasklet.setInterruptOnCancel(true);
return tasklet;
}
Tasklets in a Job
Tasklets compose naturally with chunk-based steps:
@Bean
public Job importOrdersJob(JobRepository jobRepository,
Step truncateStagingStep,
Step importToCsvToStagingStep, // chunk step
Step validateAndMergeStep, // chunk step
Step aggregateResultsStep,
Step notifyCompletionStep,
Step cleanupTempFilesStep) {
return new JobBuilder("importOrdersJob", jobRepository)
.start(truncateStagingStep) // tasklet
.next(importToCsvToStagingStep) // chunk
.next(validateAndMergeStep) // chunk
.next(aggregateResultsStep) // tasklet (stored proc)
.next(notifyCompletionStep) // tasklet
.on("*").to(cleanupTempFilesStep) // tasklet — always run cleanup
.end()
.build();
}
Key Takeaways
Taskletis for single-operation steps: file operations, DDL, stored procedures, notifications, shell commands.- Return
RepeatStatus.FINISHEDwhen done. ReturnRepeatStatus.CONTINUABLEfor polling loops. MethodInvokingTaskletAdapterwraps an existing service method without writing aTaskletclass.- Mark idempotent tasklets (cleanup, truncate) with
allowStartIfComplete(true)so they re-run on restart. - Tasklets are transactional — if
execute()throws, the transaction rolls back and the step fails.
What’s Next
Part 5 (Job and Step Configuration) is complete. Article 16 starts Part 6 — Listeners. You will learn how to hook into job, step, chunk, read, process, and write lifecycle events to log metrics, send alerts, and collect telemetry.