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

  • Tasklet is for single-operation steps: file operations, DDL, stored procedures, notifications, shell commands.
  • Return RepeatStatus.FINISHED when done. Return RepeatStatus.CONTINUABLE for polling loops.
  • MethodInvokingTaskletAdapter wraps an existing service method without writing a Tasklet class.
  • 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.