Skip to content

ChunkOrientedStep does not throw exception when skipPolicy.shouldSkip() returns false #5079

@KILL9-NO-MERCY

Description

@KILL9-NO-MERCY

Hi Spring Batch team,

I think, I’ve discovered a bug in ChunkOrientedStep where failed items are silently discarded when the skip policy rejects skipping.

Bug description

When retry is exhausted in fault-tolerant mode, ChunkOrientedStep calls skipPolicy.shouldSkip() to determine whether the failed item should be skipped. However, when skipPolicy.shouldSkip() returns false (meaning the item should NOT be skipped), the code does not throw an exception. This causes the failed item to be silently lost, and the job continues as if nothing happened.

This affects three methods in ChunkOrientedStep:

  • doSkipInRead() (line 528)
  • doSkipInProcess() (line 656)
  • scan() (line 736)

Environment

  • Spring Batch version: 6.0.0-RC2

Steps to reproduce

  1. Configure a fault-tolerant step with a skip policy that always returns false (never skip)
  2. Configure retry with a limited number of attempts (e.g., retryLimit = 2)
  3. Process items where one item consistently fails
  4. After retry exhaustion, observe that the failed item is silently discarded instead of causing the job to fail

Expected behavior

When skipPolicy.shouldSkip() returns false, the exception should be re-thrown to:

  • Roll back the transaction
  • Mark the step as FAILED
  • Prevent silent data loss

The job should fail with a clear error indicating that the skip limit was exceeded or skip policy rejected skipping.

Minimal Complete Reproducible example

@Slf4j
@Configuration
public class IssueReproductionJobConfiguration {
    @Bean
    public Job issueReproductionJob(JobRepository jobRepository, Step issueReproductionStep) {
        return new JobBuilder(jobRepository)
                .start(issueReproductionStep)
                .build();
    }

    @Bean
    public Step issueReproductionStep(JobRepository jobRepository) {
        return new StepBuilder(jobRepository)
                .<String, String>chunk(3)
                .reader(issueReproductionReader())
                .processor(issueReproductionProcessor())
                .writer(issueReproductionWriter())
                .faultTolerant()
                .retryLimit(2)
                .skipPolicy(new NeverSkipItemSkipPolicy())  
                .build();
    }

    @Bean
    public ItemReader<String> issueReproductionReader() {
        return new ListItemReader<>(List.of("Item_1", "Item_2", "Item_3"));
    }

    @Bean
    public ItemProcessor<String, String> issueReproductionProcessor() {
        return item -> {
            if ("Item_3".equals(item)) {
                log.error("Exception thrown for: {}", item);
                throw new ProcessingException("Processing failed for " + item);
            }

            log.info("Successfully processed: {}", item);
            return item;
        };
    }

    @Bean
    public ItemWriter<String> issueReproductionWriter() {
        return items -> {
            log.info("Writing items: {}", items.getItems());
            items.getItems().forEach(item -> log.info("Written: {}", item));
        };
    }

    public static class ProcessingException extends RuntimeException {
        public ProcessingException(String message) {
            super(message);
        }
    }
}

Actual output:
Step COMPLETED

Job: [SimpleJob: [name=issueReproductionJob]] launched with the following parameters: [{}]
Executing step: [issueReproductionStep]
Successfully processed: Item_1
Successfully processed: Item_2
Exception thrown for: Item_3
Exception thrown for: Item_3
Exception thrown for: Item_3
Writing items: [Item_1, Item_2]
Written: Item_1
Written: Item_2
Step: [issueReproductionStep] executed in 2s18ms
Job: [SimpleJob: [name=issueReproductionJob]] completed with the following parameters: [{}] and the following status: [COMPLETED] in 2s20ms

As you can see, Item_3 failed 3 times (initial attempt + 2 retries), but was silently discarded. The job completed successfully with status COMPLETED, even though NeverSkipItemSkipPolicy should have rejected skipping.

Expected output:
The job should fail with status FAILED because the skip policy does not allow skipping the failed item.


Proposed fix:

The three affected methods should throw an exception when skipPolicy.shouldSkip() returns false:

private void doSkipInRead(RetryException retryException, StepContribution contribution) {
    Throwable cause = retryException.getCause();
    if (this.skipPolicy.shouldSkip(cause, contribution.getStepSkipCount())) {
        this.compositeSkipListener.onSkipInRead(cause);
        contribution.incrementReadSkipCount();
    } else {
        throw new NonSkippableReadException("Skip policy rejected skipping", cause);
    }
}

Similar changes should be applied to doSkipInProcess() and the catch block in scan().

Thank you for your attention to this issue!

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions