Skip to content

Transaction is not rolled back before chunk scanning in ChunkOrientedStep with skip policy #5210

@fxpaquette

Description

@fxpaquette

Bug description
When using ChunkOrientedStep (fault-tolerant) with a given skip policy, the current transaction is not rolled back before chunk scanning starts. As a result, the persistence context is not cleared. This causes any Hibernate entities created and added to the persistence context during the writing of the full chunk to remain in the persistence context. Consequently, this can lead to a false belief that an entity existed before the chunk writing began, potentially causing logical errors inside the writer during chunk scanning.

This is a regression in spring-batch 6.x, the issue was not present in spring-batch 5.x

Environment

  • Spring Batch version: 6.0.1
  • Hibernate 7+
  • Java version: 17+
  • Database: H2, PostgreSQL, MySQL (issue is not database-specific)

Steps to reproduce

  1. Configure a ChunkOrientedStep with fault tolerance enabled and a skip policy.
  2. Write a chunk that creates Hibernate-managed entities.
  3. Trigger a scenario where chunk scanning starts (e.g., an exception occurs during chunk processing).
  4. Observe that the transaction is not rolled back and the persistence context is not cleared before the chunk scanning starts.

Expected behavior
When chunk scanning starts, the current transaction should be rolled back, and the persistence context should be cleared. This ensures that no Hibernate-managed entities from the chunk writing remain in the persistence context, avoiding logical errors in the writer.

Minimal Complete Reproducible example
Below is a minimal example to reproduce the issue. In this example, we demonstrate that item 1 (created during initial chunk writing) is still present in the persistence context after chunk scanning has started (errors are thrown on writing of items 2 and 3).

Full code: spring-batch-mcve-issue.zip

@Bean
public ListItemReader<String> reader() {
    return new ListItemReader<>(Arrays.asList("1", "2", "3"));
}

@Bean
public ItemWriter<String> writer(EntityManager em) {
    return items -> {
        System.out.println(); System.out.println("Writing chunk of items..." + items); System.out.println();
        for (String number : items) {
            System.out.println("Writing item: " + number);
            writeItem(em, number);
            System.out.println();
        }
    };
}

private void writeItem(EntityManager em, String number) {
    if (number.equals("2") || number.equals("3")) {
        System.out.println("Simulating error on item: " + number);
        throw new RuntimeException("Simulated write error for item: " + number);
    } else {
        Delivery existingDelivery = em.find(Delivery.class, number);
        if (existingDelivery != null) {
            System.out.println("Delivery with number " + number + " already exists but shouldn't: " + existingDelivery);
        } else {
            System.out.println("Delivery with number " + number + " does not exist.");
            em.persist(new Delivery(number));
        }
    }
}

@Bean
public Step step(JobRepository jobRepository, PlatformTransactionManager tm, ListItemReader<String> reader, ItemWriter<String> writer) {
    return new StepBuilder("step", jobRepository)
            .<String, String>chunk(3)
            .transactionManager(tm)
            .reader(reader)
            .writer(writer)
            .faultTolerant()
            .skip(Exception.class)
            .skipLimit(1)
            .build();
}

@Entity
public class Delivery {

    @Id
    private String number;

    public Delivery() {
    }

    public Delivery(String number) {
        this.number = number;
    }

    public String getNumber() {
        return number;
    }

    public void setNumber(String number) {
        this.number = number;
    }

    @Override
    public String toString() {
        return "Delivery{" +
                "number='" + number + '\'' +
                '}';
    }
}

Output and indication of where rollback is expected

Writing chunk of items...[items=[1, 2, 3], skips=[]]

Writing item: 1
Delivery with number 1 does not exist.

Writing item: 2
Simulating error on item: 2
<-- Transaction is NOT rolled back here, persistence context is NOT cleared -->
Writing chunk of items...[items=[1], skips=[]]

Writing item: 1
Delivery with number 1 already exists but shouldn't: Delivery{number='1'}

Writing chunk of items...[items=[2], skips=[]]

Writing item: 2
Simulating error on item: 2

Writing chunk of items...[items=[3], skips=[]]

Writing item: 3
Simulating error on item: 3

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions