diff --git a/.github/workflows/artifactory-milestone-release.yml b/.github/workflows/artifactory-milestone-release.yml deleted file mode 100644 index 4c368a39a4..0000000000 --- a/.github/workflows/artifactory-milestone-release.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Artifactory Milestone Release - -on: - workflow_dispatch: - inputs: - releaseVersion: - description: "Milestone release version" - required: true - -jobs: - build: - name: Release milestone to Artifactory - runs-on: ubuntu-latest - steps: - - name: Checkout source code - uses: actions/checkout@v3 - - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - cache: 'maven' - - - name: Capture release version - run: echo RELEASE_VERSION=${{ github.event.inputs.releaseVersion }} >> $GITHUB_ENV - - - name: Update release version - run: mvn versions:set -DgenerateBackupPoms=false -DnewVersion=$RELEASE_VERSION - - - name: Enforce release rules - run: mvn org.apache.maven.plugins:maven-enforcer-plugin:enforce -Drules=requireReleaseDeps - - - name: Build with Maven and deploy to Artifactory's milestone repository - env: - ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} - ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} - run: mvn -P artifactory-milestone -s settings.xml --batch-mode -Dmaven.test.skip=true deploy diff --git a/.github/workflows/artifactory-staging.yml b/.github/workflows/artifactory-staging.yml index ae8a04859c..f538d8d66b 100644 --- a/.github/workflows/artifactory-staging.yml +++ b/.github/workflows/artifactory-staging.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: releaseVersion: - description: "Release version" + description: "Release version (5.2.x)" required: true jobs: @@ -13,26 +13,34 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout source code - uses: actions/checkout@v3 + uses: actions/checkout@v4.2.2 + with: + ref: '5.2.x' - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4.7.1 with: java-version: '17' distribution: 'temurin' cache: 'maven' - - name: Capture release version - run: echo RELEASE_VERSION=${{ github.event.inputs.releaseVersion }} >> $GITHUB_ENV - - name: Update release version - run: mvn versions:set -DgenerateBackupPoms=false -DnewVersion=$RELEASE_VERSION + run: mvn versions:set -DgenerateBackupPoms=false -DnewVersion=${{ github.event.inputs.releaseVersion }} - name: Enforce release rules run: mvn org.apache.maven.plugins:maven-enforcer-plugin:enforce -Drules=requireReleaseDeps - - name: Build with Maven and deploy to Artifactory staging repository - env: - ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} - ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} - run: mvn -P artifactory-staging -s settings.xml --batch-mode -Dmaven.test.skip=true deploy + - name: Build with Maven + run: mvn -DaltDeploymentRepository=local::file:deployment-repository --no-transfer-progress --batch-mode -Dmaven.test.skip=true deploy + + - name: Deploy to Artifactory + uses: spring-io/artifactory-deploy-action@v0.0.2 + with: + uri: 'https://repo.spring.io' + username: ${{ secrets.ARTIFACTORY_USERNAME }} + password: ${{ secrets.ARTIFACTORY_PASSWORD }} + build-name: 'spring-batch-${{ github.event.inputs.releaseVersion }}' + repository: 'libs-staging-local' + folder: 'deployment-repository' + signing-key: ${{ secrets.GPG_PRIVATE_KEY }} + signing-passphrase: ${{ secrets.GPG_PASSPHRASE }} diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration-52x.yml similarity index 88% rename from .github/workflows/continuous-integration.yml rename to .github/workflows/continuous-integration-52x.yml index 9171cc9021..1f5744f64d 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration-52x.yml @@ -1,14 +1,18 @@ -name: CI/CD build +name: CI/CD build for 5.2.x -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: [ "5.2.x" ] jobs: build: - name: Build branch + name: Build 5.2.x branch runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v3 + with: + ref: '5.2.x' - name: Set up JDK 17 uses: actions/setup-java@v3 @@ -18,11 +22,11 @@ jobs: cache: 'maven' - name: Build with Maven - if: ${{ github.repository != 'spring-projects/spring-batch' || github.ref_name != 'main' }} + if: ${{ github.repository != 'spring-projects/spring-batch' || github.ref_name != '5.2.x' }} run: mvn -s settings.xml --batch-mode --update-snapshots verify - name: Build with Maven and deploy to Artifactory - if: ${{ github.repository == 'spring-projects/spring-batch' && github.ref_name == 'main' }} + if: ${{ github.repository == 'spring-projects/spring-batch' && github.ref_name == '5.2.x' }} env: ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} @@ -39,7 +43,7 @@ jobs: run: echo PROJECT_VERSION=$(mvn help:evaluate -Dexpression=project.version --quiet -DforceStdout) >> $GITHUB_ENV - name: Setup SSH key - if: ${{ github.repository == 'spring-projects/spring-batch' && github.ref_name == 'main' }} + if: ${{ github.repository == 'spring-projects/spring-batch' && github.ref_name == '5.2.x' }} env: DOCS_SSH_KEY: ${{ secrets.DOCS_SSH_KEY }} DOCS_SSH_HOST_KEY: ${{ secrets.DOCS_SSH_HOST_KEY }} @@ -50,7 +54,7 @@ jobs: echo "$DOCS_SSH_HOST_KEY" > "$HOME/.ssh/known_hosts" - name: Deploy Java docs - if: ${{ github.repository == 'spring-projects/spring-batch' && github.ref_name == 'main' }} + if: ${{ github.repository == 'spring-projects/spring-batch' && github.ref_name == '5.2.x' }} env: DOCS_HOST: ${{ secrets.DOCS_HOST }} DOCS_PATH: ${{ secrets.DOCS_PATH }} diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index 4af2314b75..0000000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Deploy Docs -on: - push: - branches-ignore: [ gh-pages ] - tags: '**' - repository_dispatch: - types: request-build-reference # legacy - workflow_dispatch: -permissions: - actions: write -jobs: - build: - runs-on: ubuntu-latest - if: github.repository_owner == 'spring-projects' - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - ref: docs-build - fetch-depth: 1 - - name: Dispatch (partial build) - if: github.ref_type == 'branch' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh workflow run deploy-docs.yml -r $(git rev-parse --abbrev-ref HEAD) -f build-refname=${{ github.ref_name }} - - name: Dispatch (full build) - if: github.ref_type == 'tag' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh workflow run deploy-docs.yml -r $(git rev-parse --abbrev-ref HEAD) diff --git a/.github/workflows/documentation-upload.yml b/.github/workflows/documentation-upload.yml index bf3f725cd7..2c25469f1b 100644 --- a/.github/workflows/documentation-upload.yml +++ b/.github/workflows/documentation-upload.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: releaseVersion: - description: "Release version" + description: "Release version (5.2.x)" required: true jobs: @@ -17,6 +17,8 @@ jobs: - name: Checkout source code uses: actions/checkout@v3 + with: + ref: '5.2.x' - name: Set up JDK 17 uses: actions/setup-java@v3 diff --git a/.github/workflows/extension-build.yml b/.github/workflows/extension-build.yml deleted file mode 100644 index 6b6b033894..0000000000 --- a/.github/workflows/extension-build.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Spring Batch Extension Build - -on: - workflow_dispatch: - inputs: - extension: - description: "Extension name" - required: true - type: choice - options: - - spring-batch-bigquery - - spring-batch-excel - - spring-batch-elasticsearch - - spring-batch-geode - - spring-batch-neo4j - -jobs: - build: - name: Build an extension - runs-on: ubuntu-latest - steps: - - name: Checkout source code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - with: - repository: 'spring-projects/spring-batch-extensions' - ref: 'main' - - - name: Set up JDK 17 - uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0 - with: - java-version: '17' - distribution: 'temurin' - - - name: Build extension with Maven - run: mvn -B package --file pom.xml - working-directory: ${{ github.event.inputs.extension }} diff --git a/.github/workflows/maven-central-release.yml b/.github/workflows/maven-central-release.yml deleted file mode 100644 index bce60ac9ea..0000000000 --- a/.github/workflows/maven-central-release.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Maven Central Release - -on: - workflow_dispatch: - inputs: - releaseVersion: - description: "Release version" - required: true - -jobs: - build: - runs-on: ubuntu-latest - steps: - - - name: Capture release version - run: echo RELEASE_VERSION=${{ github.event.inputs.releaseVersion }} >> $GITHUB_ENV - - - name: Prepare directory structure - run: | - mkdir -p nexus/org/springframework/batch/spring-batch-bom/$RELEASE_VERSION - mkdir -p nexus/org/springframework/batch/spring-batch-infrastructure/$RELEASE_VERSION - mkdir -p nexus/org/springframework/batch/spring-batch-core/$RELEASE_VERSION - mkdir -p nexus/org/springframework/batch/spring-batch-test/$RELEASE_VERSION - mkdir -p nexus/org/springframework/batch/spring-batch-integration/$RELEASE_VERSION - - - name: Download release files from Artifactory - env: - ARTIFACTORY_URL: "https://repo.spring.io/libs-staging-local/org/springframework/batch" - ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} - ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} - run: | - echo "Downloading BOM artifacts" - cd nexus/org/springframework/batch/spring-batch-bom/$RELEASE_VERSION - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-bom/$RELEASE_VERSION/spring-batch-bom-$RELEASE_VERSION.pom - - echo "Downloading infrastructure artifacts" - cd ../../../../../.. - cd nexus/org/springframework/batch/spring-batch-infrastructure/$RELEASE_VERSION - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-infrastructure/$RELEASE_VERSION/spring-batch-infrastructure-$RELEASE_VERSION.pom - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-infrastructure/$RELEASE_VERSION/spring-batch-infrastructure-$RELEASE_VERSION.jar - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-infrastructure/$RELEASE_VERSION/spring-batch-infrastructure-$RELEASE_VERSION-javadoc.jar - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-infrastructure/$RELEASE_VERSION/spring-batch-infrastructure-$RELEASE_VERSION-sources.jar - - echo "Downloading core artifacts" - cd ../../../../../.. - cd nexus/org/springframework/batch/spring-batch-core/$RELEASE_VERSION - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-core/$RELEASE_VERSION/spring-batch-core-$RELEASE_VERSION.pom - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-core/$RELEASE_VERSION/spring-batch-core-$RELEASE_VERSION.jar - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-core/$RELEASE_VERSION/spring-batch-core-$RELEASE_VERSION-javadoc.jar - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-core/$RELEASE_VERSION/spring-batch-core-$RELEASE_VERSION-sources.jar - - echo "Downloading test artifacts" - cd ../../../../../.. - cd nexus/org/springframework/batch/spring-batch-test/$RELEASE_VERSION - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-test/$RELEASE_VERSION/spring-batch-test-$RELEASE_VERSION.pom - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-test/$RELEASE_VERSION/spring-batch-test-$RELEASE_VERSION.jar - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-test/$RELEASE_VERSION/spring-batch-test-$RELEASE_VERSION-javadoc.jar - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-test/$RELEASE_VERSION/spring-batch-test-$RELEASE_VERSION-sources.jar - - echo "Downloading integration artifacts" - cd ../../../../../.. - cd nexus/org/springframework/batch/spring-batch-integration/$RELEASE_VERSION - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-integration/$RELEASE_VERSION/spring-batch-integration-$RELEASE_VERSION.pom - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-integration/$RELEASE_VERSION/spring-batch-integration-$RELEASE_VERSION.jar - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-integration/$RELEASE_VERSION/spring-batch-integration-$RELEASE_VERSION-javadoc.jar - wget --user="$ARTIFACTORY_USERNAME" --password="$ARTIFACTORY_PASSWORD" $ARTIFACTORY_URL/spring-batch-integration/$RELEASE_VERSION/spring-batch-integration-$RELEASE_VERSION-sources.jar - - - name: Sign artifacts and release them to Maven Central - uses: jvalkeal/nexus-sync@v0 - id: nexus - with: - url: ${{ secrets.OSSRH_URL }} - username: ${{ secrets.OSSRH_S01_TOKEN_USERNAME }} - password: ${{ secrets.OSSRH_S01_TOKEN_PASSWORD }} - staging-profile-name: ${{ secrets.OSSRH_STAGING_PROFILE_NAME }} - create: true - upload: true - close: true - release: true - generate-checksums: true - pgp-sign: true - pgp-sign-passphrase: ${{ secrets.GPG_PASSPHRASE }} - pgp-sign-private-key: ${{ secrets.GPG_PRIVATE_KEY }} diff --git a/.github/workflows/release-notes-generation.yml b/.github/workflows/release-notes-generation.yml deleted file mode 100644 index fa601a05fa..0000000000 --- a/.github/workflows/release-notes-generation.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Generate Release notes - -on: - workflow_dispatch: - inputs: - milestoneNumber: - description: "Milestone title" - required: true - generatorVersion: - description: "Changelog Generator version" - required: true - -jobs: - build: - name: Generate release notes - runs-on: ubuntu-latest - steps: - - name: Capture milestone number and generator version - run: | - echo MILESTONE_NUMBER=${{ github.event.inputs.milestoneNumber }} >> $GITHUB_ENV - echo GENERATOR_VERSION=${{ github.event.inputs.generatorVersion }} >> $GITHUB_ENV - - - name: Download changelog generator - run: wget https://github.com/spring-io/github-changelog-generator/releases/download/v$GENERATOR_VERSION/github-changelog-generator.jar - - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - - name: Prepare configuration file - run: | - cat << EOF > application.yml - changelog: - repository: spring-projects/spring-batch - sections: - - title: ":star: New features" - labels: [ "type: feature" ] - - title: ":rocket: Enhancements" - labels: [ "type: enhancement" ] - - title: ":lady_beetle: Bug fixes" - labels: [ "type: bug" ] - - title: ":notebook_with_decorative_cover: Documentation" - labels: [ "in: documentation" ] - - title: ":hammer: Tasks" - labels: [ "type: task" ] - EOF - - - name: Generate release notes - run: java -jar github-changelog-generator.jar $MILESTONE_NUMBER release-notes.md - - - name: Print release notes - run: cat release-notes.md diff --git a/README.md b/README.md index 5cc775db30..81533825dd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Latest news +* March 19, 2025: [Spring Batch 5.2.2 available now](https://spring.io/blog/2025/03/19/spring-batch-5-2-2-available-now) * December 18, 2024: [Spring Batch 5.1.3 and 5.2.1 available now](https://spring.io/blog/2024/12/18/spring-batch-5-1-3-and-5-2-1-available-now) * November 24, 2024: [Bootiful Spring Boot 3.4: Spring Batch](https://spring.io/blog/2024/11/24/bootiful-34-batch) * November 20, 2024: [Spring Batch 5.2.0 goes GA!](https://spring.io/blog/2024/11/20/spring-batch-5-2-0-goes-ga) diff --git a/pom.xml b/pom.xml index 9ad73b8107..6055c7037d 100644 --- a/pom.xml +++ b/pom.xml @@ -8,7 +8,7 @@ designed to enable the development of robust batch applications vital for the daily operations of enterprise systems. Spring Batch is part of the Spring Portfolio. - 5.2.2 + 5.2.3 pom https://projects.spring.io/spring-batch @@ -61,30 +61,30 @@ 17 - 6.2.4 - 2.0.11 - 6.4.3 - 1.14.5 + 6.2.11 + 2.0.12 + 6.4.7 + 1.14.11 - 3.4.4 - 3.4.4 - 3.4.4 - 4.4.4 - 3.3.4 - 3.2.4 - 3.2.11 + 3.4.10 + 3.4.10 + 3.4.10 + 4.4.10 + 3.3.10 + 3.2.7 + 3.2.14 - 2.18.3 + 2.18.4 1.12.0 2.12.1 - 6.6.11.Final + 6.6.29.Final 3.0.0 2.1.3 3.1.0 3.1.1 3.1.0 - 4.0.16 + 4.0.20 5.3.1 5.11.4 @@ -92,13 +92,13 @@ 3.0.2 - 1.4.4 + 1.4.10 1.4.21 4.13.2 ${junit-jupiter.version} 3.0 - 3.27.3 + 3.27.4 5.16.1 2.10.0 2.18.0 @@ -133,6 +133,9 @@ 15.6 2.0b6 9.4.12.0 + 6.7.1.RELEASE + 6.0.0 + 2.2.4 ${spring-amqp.version} @@ -145,11 +148,11 @@ 3.14.0 - 3.5.2 - 3.5.2 - 3.11.2 + 3.5.4 + 3.5.4 + 3.11.3 3.3.1 - 1.7.0 + 1.7.2 3.1.4 3.7.1 3.4.2 diff --git a/spring-batch-bom/pom.xml b/spring-batch-bom/pom.xml index 35c27a533f..3e1573dbe5 100644 --- a/spring-batch-bom/pom.xml +++ b/spring-batch-bom/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.2 + 5.2.3 spring-batch-bom pom diff --git a/spring-batch-core/pom.xml b/spring-batch-core/pom.xml index 783b995348..b0292f861c 100644 --- a/spring-batch-core/pom.xml +++ b/spring-batch-core/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.2 + 5.2.3 spring-batch-core jar diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/ExitStatus.java b/spring-batch-core/src/main/java/org/springframework/batch/core/ExitStatus.java index edaa4986eb..0a247b433a 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/ExitStatus.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/ExitStatus.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2023 the original author or authors. + * Copyright 2006-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ */ package org.springframework.batch.core; +import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; import java.io.PrintWriter; @@ -28,6 +29,7 @@ * * @author Dave Syer * @author Mahmoud Ben Hassine + * @author JiWon Seo * */ public class ExitStatus implements Serializable, Comparable { @@ -198,7 +200,7 @@ public String toString() { * @see java.lang.Object#equals(java.lang.Object) */ @Override - public boolean equals(Object obj) { + public boolean equals(@Nullable Object obj) { if (obj == null) { return false; } @@ -230,7 +232,7 @@ public ExitStatus replaceExitCode(String code) { * @return {@code true} if the exit code is {@code EXECUTING} or {@code UNKNOWN}. */ public boolean isRunning() { - return "EXECUTING".equals(this.exitCode) || "UNKNOWN".equals(this.exitCode); + return EXECUTING.exitCode.equals(this.exitCode) || UNKNOWN.exitCode.equals(this.exitCode); } /** diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/DefaultJobLoader.java b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/DefaultJobLoader.java index 4dde8ea152..e9e7ffeac9 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/DefaultJobLoader.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/DefaultJobLoader.java @@ -173,7 +173,7 @@ private Collection doLoad(ApplicationContextFactory factory, boolean unregi if (!autoRegistrationDetected) { - Job job = (Job) context.getBean(name); + Job job = context.getBean(name, Job.class); String jobName = job.getName(); // On reload try to unregister first diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/xml/CoreNamespacePostProcessor.java b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/xml/CoreNamespacePostProcessor.java index 7f250d389c..d1b742810e 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/xml/CoreNamespacePostProcessor.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/xml/CoreNamespacePostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2023 the original author or authors. + * Copyright 2006-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -119,19 +119,19 @@ private Object injectDefaults(Object bean) { JobParserJobFactoryBean fb = (JobParserJobFactoryBean) bean; JobRepository jobRepository = fb.getJobRepository(); if (jobRepository == null) { - fb.setJobRepository((JobRepository) applicationContext.getBean(DEFAULT_JOB_REPOSITORY_NAME)); + fb.setJobRepository(applicationContext.getBean(DEFAULT_JOB_REPOSITORY_NAME, JobRepository.class)); } } else if (bean instanceof StepParserStepFactoryBean) { StepParserStepFactoryBean fb = (StepParserStepFactoryBean) bean; JobRepository jobRepository = fb.getJobRepository(); if (jobRepository == null) { - fb.setJobRepository((JobRepository) applicationContext.getBean(DEFAULT_JOB_REPOSITORY_NAME)); + fb.setJobRepository(applicationContext.getBean(DEFAULT_JOB_REPOSITORY_NAME, JobRepository.class)); } PlatformTransactionManager transactionManager = fb.getTransactionManager(); if (transactionManager == null && fb.requiresTransactionManager()) { fb.setTransactionManager( - (PlatformTransactionManager) applicationContext.getBean(DEFAULT_TRANSACTION_MANAGER_NAME)); + applicationContext.getBean(DEFAULT_TRANSACTION_MANAGER_NAME, PlatformTransactionManager.class)); } } return bean; diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/launch/support/CommandLineJobRunner.java b/spring-batch-core/src/main/java/org/springframework/batch/core/launch/support/CommandLineJobRunner.java index 34bdf928b0..d5344c4797 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/launch/support/CommandLineJobRunner.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/launch/support/CommandLineJobRunner.java @@ -356,7 +356,7 @@ int start(String jobPath, String jobIdentifier, String[] parameters, Set } } if (job == null) { - job = (Job) context.getBean(jobName); + job = context.getBean(jobName, Job.class); } if (opts.contains("-next")) { diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcExecutionContextDao.java b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcExecutionContextDao.java index 07915965c0..169bb91d01 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcExecutionContextDao.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcExecutionContextDao.java @@ -27,11 +27,11 @@ import java.util.Collection; import java.util.HashMap; import java.util.Iterator; -import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Stream; import org.springframework.batch.core.JobExecution; import org.springframework.batch.core.StepExecution; @@ -57,6 +57,7 @@ * @author Michael Minella * @author David Turanski * @author Mahmoud Ben Hassine + * @author Yanming Zhou */ public class JdbcExecutionContextDao extends AbstractJdbcBatchMetadataDao implements ExecutionContextDao { @@ -154,13 +155,9 @@ public ExecutionContext getExecutionContext(JobExecution jobExecution) { Long executionId = jobExecution.getId(); Assert.notNull(executionId, "ExecutionId must not be null."); - List results = getJdbcTemplate().query(getQuery(FIND_JOB_EXECUTION_CONTEXT), - new ExecutionContextRowMapper(), executionId); - if (!results.isEmpty()) { - return results.get(0); - } - else { - return new ExecutionContext(); + try (Stream stream = getJdbcTemplate().queryForStream(getQuery(FIND_JOB_EXECUTION_CONTEXT), + new ExecutionContextRowMapper(), executionId)) { + return stream.findFirst().orElseGet(ExecutionContext::new); } } @@ -169,13 +166,9 @@ public ExecutionContext getExecutionContext(StepExecution stepExecution) { Long executionId = stepExecution.getId(); Assert.notNull(executionId, "ExecutionId must not be null."); - List results = getJdbcTemplate().query(getQuery(FIND_STEP_EXECUTION_CONTEXT), - new ExecutionContextRowMapper(), executionId); - if (results.size() > 0) { - return results.get(0); - } - else { - return new ExecutionContext(); + try (Stream stream = getJdbcTemplate().queryForStream(getQuery(FIND_STEP_EXECUTION_CONTEXT), + new ExecutionContextRowMapper(), executionId)) { + return stream.findFirst().orElseGet(ExecutionContext::new); } } diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcJobExecutionDao.java b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcJobExecutionDao.java index 9ec0a9e2d8..6fbbdb9310 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcJobExecutionDao.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcJobExecutionDao.java @@ -28,6 +28,7 @@ import java.util.Set; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Stream; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -74,6 +75,7 @@ * @author Dimitrios Liapis * @author Philippe Marschall * @author Jinwoo Bae + * @author Yanming Zhou */ public class JdbcJobExecutionDao extends AbstractJdbcBatchMetadataDao implements JobExecutionDao, InitializingBean { @@ -98,28 +100,22 @@ SELECT COUNT(*) private static final String UPDATE_JOB_EXECUTION = """ UPDATE %PREFIX%JOB_EXECUTION - SET START_TIME = ?, END_TIME = ?, STATUS = ?, EXIT_CODE = ?, EXIT_MESSAGE = ?, VERSION = ?, CREATE_TIME = ?, LAST_UPDATED = ? + SET START_TIME = ?, END_TIME = ?, STATUS = ?, EXIT_CODE = ?, EXIT_MESSAGE = ?, VERSION = VERSION + 1, CREATE_TIME = ?, LAST_UPDATED = ? WHERE JOB_EXECUTION_ID = ? AND VERSION = ? """; - private static final String FIND_JOB_EXECUTIONS = """ + private static final String GET_JOB_EXECUTIONS = """ SELECT JOB_EXECUTION_ID, START_TIME, END_TIME, STATUS, EXIT_CODE, EXIT_MESSAGE, CREATE_TIME, LAST_UPDATED, VERSION FROM %PREFIX%JOB_EXECUTION - WHERE JOB_INSTANCE_ID = ? - ORDER BY JOB_EXECUTION_ID DESC """; - private static final String GET_LAST_EXECUTION = """ - SELECT JOB_EXECUTION_ID, START_TIME, END_TIME, STATUS, EXIT_CODE, EXIT_MESSAGE, CREATE_TIME, LAST_UPDATED, VERSION - FROM %PREFIX%JOB_EXECUTION E - WHERE JOB_INSTANCE_ID = ? AND JOB_EXECUTION_ID IN (SELECT MAX(JOB_EXECUTION_ID) FROM %PREFIX%JOB_EXECUTION E2 WHERE E2.JOB_INSTANCE_ID = ?) - """; + private static final String FIND_JOB_EXECUTIONS = GET_JOB_EXECUTIONS + + " WHERE JOB_INSTANCE_ID = ? ORDER BY JOB_EXECUTION_ID DESC"; - private static final String GET_EXECUTION_BY_ID = """ - SELECT JOB_EXECUTION_ID, START_TIME, END_TIME, STATUS, EXIT_CODE, EXIT_MESSAGE, CREATE_TIME, LAST_UPDATED, VERSION - FROM %PREFIX%JOB_EXECUTION - WHERE JOB_EXECUTION_ID = ? - """; + private static final String GET_LAST_EXECUTION = GET_JOB_EXECUTIONS + + " WHERE JOB_INSTANCE_ID = ? AND JOB_EXECUTION_ID IN (SELECT MAX(JOB_EXECUTION_ID) FROM %PREFIX%JOB_EXECUTION E2 WHERE E2.JOB_INSTANCE_ID = ?)"; + + private static final String GET_EXECUTION_BY_ID = GET_JOB_EXECUTIONS + " WHERE JOB_EXECUTION_ID = ?"; private static final String GET_RUNNING_EXECUTIONS = """ SELECT E.JOB_EXECUTION_ID, E.START_TIME, E.END_TIME, E.STATUS, E.EXIT_CODE, E.EXIT_MESSAGE, E.CREATE_TIME, E.LAST_UPDATED, E.VERSION, E.JOB_INSTANCE_ID @@ -146,7 +142,7 @@ SELECT COUNT(*) private static final String DELETE_JOB_EXECUTION = """ DELETE FROM %PREFIX%JOB_EXECUTION - WHERE JOB_EXECUTION_ID = ? + WHERE JOB_EXECUTION_ID = ? AND VERSION = ? """; private static final String DELETE_JOB_EXECUTION_PARAMETERS = """ @@ -284,7 +280,6 @@ public void updateJobExecution(JobExecution jobExecution) { this.lock.lock(); try { - Integer version = jobExecution.getVersion() + 1; String exitDescription = jobExecution.getExitStatus().getExitDescription(); if (exitDescription != null && exitDescription.length() > exitMessageLength) { @@ -301,7 +296,7 @@ public void updateJobExecution(JobExecution jobExecution) { Timestamp lastUpdated = jobExecution.getLastUpdated() == null ? null : Timestamp.valueOf(jobExecution.getLastUpdated()); Object[] parameters = new Object[] { startTime, endTime, jobExecution.getStatus().toString(), - jobExecution.getExitStatus().getExitCode(), exitDescription, version, createTime, lastUpdated, + jobExecution.getExitStatus().getExitCode(), exitDescription, createTime, lastUpdated, jobExecution.getId(), jobExecution.getVersion() }; // Check if given JobExecution's Id already exists, if none is found @@ -315,7 +310,7 @@ public void updateJobExecution(JobExecution jobExecution) { int count = getJdbcTemplate().update(getQuery(UPDATE_JOB_EXECUTION), parameters, new int[] { Types.TIMESTAMP, Types.TIMESTAMP, Types.VARCHAR, Types.VARCHAR, Types.VARCHAR, - Types.INTEGER, Types.TIMESTAMP, Types.TIMESTAMP, Types.BIGINT, Types.INTEGER }); + Types.TIMESTAMP, Types.TIMESTAMP, Types.BIGINT, Types.INTEGER }); // Avoid concurrent modifications... if (count == 0) { @@ -339,16 +334,9 @@ public JobExecution getLastJobExecution(JobInstance jobInstance) { Long id = jobInstance.getId(); - List executions = getJdbcTemplate().query(getQuery(GET_LAST_EXECUTION), - new JobExecutionRowMapper(jobInstance), id, id); - - Assert.state(executions.size() <= 1, "There must be at most one latest job execution"); - - if (executions.isEmpty()) { - return null; - } - else { - return executions.get(0); + try (Stream stream = getJdbcTemplate().queryForStream(getQuery(GET_LAST_EXECUTION), + new JobExecutionRowMapper(jobInstance), id, id)) { + return stream.findFirst().orElse(null); } } @@ -395,7 +383,13 @@ public void synchronizeStatus(JobExecution jobExecution) { */ @Override public void deleteJobExecution(JobExecution jobExecution) { - getJdbcTemplate().update(getQuery(DELETE_JOB_EXECUTION), jobExecution.getId()); + int count = getJdbcTemplate().update(getQuery(DELETE_JOB_EXECUTION), jobExecution.getId(), + jobExecution.getVersion()); + + if (count == 0) { + throw new OptimisticLockingFailureException("Attempt to delete job execution id=" + jobExecution.getId() + + " with wrong version (" + jobExecution.getVersion() + ")"); + } } /** diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcJobInstanceDao.java b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcJobInstanceDao.java index 27dcc8b7a2..748125922c 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcJobInstanceDao.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcJobInstanceDao.java @@ -21,6 +21,7 @@ import java.sql.Types; import java.util.ArrayList; import java.util.List; +import java.util.stream.Stream; import org.springframework.batch.core.DefaultJobKeyGenerator; import org.springframework.batch.core.JobExecution; @@ -31,6 +32,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.dao.DataAccessException; import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.support.incrementer.DataFieldMaxValueIncrementer; @@ -53,6 +55,7 @@ * @author Will Schipp * @author Mahmoud Ben Hassine * @author Parikshit Dutta + * @author Yanming Zhou */ public class JdbcJobInstanceDao extends AbstractJdbcBatchMetadataDao implements JobInstanceDao, InitializingBean { @@ -71,7 +74,7 @@ public class JdbcJobInstanceDao extends AbstractJdbcBatchMetadataDao implements WHERE JOB_NAME = ? """; - private static final String FIND_JOBS_WITH_KEY = FIND_JOBS_WITH_NAME + " and JOB_KEY = ?"; + private static final String FIND_JOBS_WITH_KEY = FIND_JOBS_WITH_NAME + " AND JOB_KEY = ?"; private static final String COUNT_JOBS_WITH_NAME = """ SELECT COUNT(*) @@ -79,11 +82,8 @@ SELECT COUNT(*) WHERE JOB_NAME = ? """; - private static final String FIND_JOBS_WITH_EMPTY_KEY = """ - SELECT JOB_INSTANCE_ID, JOB_NAME - FROM %PREFIX%JOB_INSTANCE - WHERE JOB_NAME = ? AND (JOB_KEY = ? OR JOB_KEY IS NULL) - """; + private static final String FIND_JOBS_WITH_EMPTY_KEY = FIND_JOBS_WITH_NAME + + " AND (JOB_KEY = ? OR JOB_KEY IS NULL)"; private static final String GET_JOB_FROM_ID = """ SELECT JOB_INSTANCE_ID, JOB_NAME, JOB_KEY, VERSION @@ -124,7 +124,7 @@ SELECT COUNT(*) private static final String DELETE_JOB_INSTANCE = """ DELETE FROM %PREFIX%JOB_INSTANCE - WHERE JOB_INSTANCE_ID = ? + WHERE JOB_INSTANCE_ID = ? AND VERSION = ? """; private DataFieldMaxValueIncrementer jobInstanceIncrementer; @@ -178,21 +178,12 @@ public JobInstance getJobInstance(final String jobName, final JobParameters jobP RowMapper rowMapper = new JobInstanceRowMapper(); - List instances; - if (StringUtils.hasLength(jobKey)) { - instances = getJdbcTemplate().query(getQuery(FIND_JOBS_WITH_KEY), rowMapper, jobName, jobKey); - } - else { - instances = getJdbcTemplate().query(getQuery(FIND_JOBS_WITH_EMPTY_KEY), rowMapper, jobName, jobKey); + try (Stream stream = getJdbcTemplate().queryForStream( + getQuery(StringUtils.hasLength(jobKey) ? FIND_JOBS_WITH_KEY : FIND_JOBS_WITH_EMPTY_KEY), rowMapper, + jobName, jobKey)) { + return stream.findFirst().orElse(null); } - if (instances.isEmpty()) { - return null; - } - else { - Assert.state(instances.size() == 1, "instance count must be 1 but was " + instances.size()); - return instances.get(0); - } } @Override @@ -281,7 +272,13 @@ public long getJobInstanceCount(@Nullable String jobName) throws NoSuchJobExcept */ @Override public void deleteJobInstance(JobInstance jobInstance) { - getJdbcTemplate().update(getQuery(DELETE_JOB_INSTANCE), jobInstance.getId()); + int count = getJdbcTemplate().update(getQuery(DELETE_JOB_INSTANCE), jobInstance.getId(), + jobInstance.getVersion()); + + if (count == 0) { + throw new OptimisticLockingFailureException("Attempt to delete job instance id=" + jobInstance.getId() + + " with wrong version (" + jobInstance.getVersion() + ")"); + } } /** diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcStepExecutionDao.java b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcStepExecutionDao.java index b1e46e0c23..67a825c6cd 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcStepExecutionDao.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/JdbcStepExecutionDao.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Stream; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -66,6 +67,7 @@ * @author Mahmoud Ben Hassine * @author Baris Cubukcuoglu * @author Minsoo Kim + * @author Yanming Zhou * @see StepExecutionDao */ public class JdbcStepExecutionDao extends AbstractJdbcBatchMetadataDao implements StepExecutionDao, InitializingBean { @@ -79,19 +81,19 @@ public class JdbcStepExecutionDao extends AbstractJdbcBatchMetadataDao implement private static final String UPDATE_STEP_EXECUTION = """ UPDATE %PREFIX%STEP_EXECUTION - SET START_TIME = ?, END_TIME = ?, STATUS = ?, COMMIT_COUNT = ?, READ_COUNT = ?, FILTER_COUNT = ?, WRITE_COUNT = ?, EXIT_CODE = ?, EXIT_MESSAGE = ?, VERSION = ?, READ_SKIP_COUNT = ?, PROCESS_SKIP_COUNT = ?, WRITE_SKIP_COUNT = ?, ROLLBACK_COUNT = ?, LAST_UPDATED = ? + SET START_TIME = ?, END_TIME = ?, STATUS = ?, COMMIT_COUNT = ?, READ_COUNT = ?, FILTER_COUNT = ?, WRITE_COUNT = ?, EXIT_CODE = ?, EXIT_MESSAGE = ?, VERSION = VERSION + 1, READ_SKIP_COUNT = ?, PROCESS_SKIP_COUNT = ?, WRITE_SKIP_COUNT = ?, ROLLBACK_COUNT = ?, LAST_UPDATED = ? WHERE STEP_EXECUTION_ID = ? AND VERSION = ? """; private static final String GET_RAW_STEP_EXECUTIONS = """ SELECT STEP_EXECUTION_ID, STEP_NAME, START_TIME, END_TIME, STATUS, COMMIT_COUNT, READ_COUNT, FILTER_COUNT, WRITE_COUNT, EXIT_CODE, EXIT_MESSAGE, READ_SKIP_COUNT, WRITE_SKIP_COUNT, PROCESS_SKIP_COUNT, ROLLBACK_COUNT, LAST_UPDATED, VERSION, CREATE_TIME FROM %PREFIX%STEP_EXECUTION - WHERE JOB_EXECUTION_ID = ? """; - private static final String GET_STEP_EXECUTIONS = GET_RAW_STEP_EXECUTIONS + " ORDER BY STEP_EXECUTION_ID"; + private static final String GET_STEP_EXECUTIONS = GET_RAW_STEP_EXECUTIONS + + " WHERE JOB_EXECUTION_ID = ? ORDER BY STEP_EXECUTION_ID"; - private static final String GET_STEP_EXECUTION = GET_RAW_STEP_EXECUTIONS + " AND STEP_EXECUTION_ID = ?"; + private static final String GET_STEP_EXECUTION = GET_RAW_STEP_EXECUTIONS + " WHERE STEP_EXECUTION_ID = ?"; private static final String GET_LAST_STEP_EXECUTION = """ SELECT SE.STEP_EXECUTION_ID, SE.STEP_NAME, SE.START_TIME, SE.END_TIME, SE.STATUS, SE.COMMIT_COUNT, SE.READ_COUNT, SE.FILTER_COUNT, SE.WRITE_COUNT, SE.EXIT_CODE, SE.EXIT_MESSAGE, SE.READ_SKIP_COUNT, SE.WRITE_SKIP_COUNT, SE.PROCESS_SKIP_COUNT, SE.ROLLBACK_COUNT, SE.LAST_UPDATED, SE.VERSION, SE.CREATE_TIME, JE.JOB_EXECUTION_ID, JE.START_TIME, JE.END_TIME, JE.STATUS, JE.EXIT_CODE, JE.EXIT_MESSAGE, JE.CREATE_TIME, JE.LAST_UPDATED, JE.VERSION @@ -114,7 +116,7 @@ SELECT COUNT(*) private static final String DELETE_STEP_EXECUTION = """ DELETE FROM %PREFIX%STEP_EXECUTION - WHERE STEP_EXECUTION_ID = ? + WHERE STEP_EXECUTION_ID = ? and VERSION = ? """; private static final Comparator BY_CREATE_TIME_DESC_ID_DESC = Comparator @@ -267,7 +269,6 @@ public void updateStepExecution(StepExecution stepExecution) { this.lock.lock(); try { - Integer version = stepExecution.getVersion() + 1; Timestamp startTime = stepExecution.getStartTime() == null ? null : Timestamp.valueOf(stepExecution.getStartTime()); Timestamp endTime = stepExecution.getEndTime() == null ? null @@ -277,13 +278,13 @@ public void updateStepExecution(StepExecution stepExecution) { Object[] parameters = new Object[] { startTime, endTime, stepExecution.getStatus().toString(), stepExecution.getCommitCount(), stepExecution.getReadCount(), stepExecution.getFilterCount(), stepExecution.getWriteCount(), stepExecution.getExitStatus().getExitCode(), exitDescription, - version, stepExecution.getReadSkipCount(), stepExecution.getProcessSkipCount(), + stepExecution.getReadSkipCount(), stepExecution.getProcessSkipCount(), stepExecution.getWriteSkipCount(), stepExecution.getRollbackCount(), lastUpdated, stepExecution.getId(), stepExecution.getVersion() }; int count = getJdbcTemplate().update(getQuery(UPDATE_STEP_EXECUTION), parameters, new int[] { Types.TIMESTAMP, Types.TIMESTAMP, Types.VARCHAR, Types.BIGINT, Types.BIGINT, - Types.BIGINT, Types.BIGINT, Types.VARCHAR, Types.VARCHAR, Types.INTEGER, Types.BIGINT, - Types.BIGINT, Types.BIGINT, Types.BIGINT, Types.TIMESTAMP, Types.BIGINT, Types.INTEGER }); + Types.BIGINT, Types.BIGINT, Types.VARCHAR, Types.VARCHAR, Types.BIGINT, Types.BIGINT, + Types.BIGINT, Types.BIGINT, Types.TIMESTAMP, Types.BIGINT, Types.INTEGER }); // Avoid concurrent modifications... if (count == 0) { @@ -324,16 +325,9 @@ private String truncateExitDescription(String description) { @Override @Nullable public StepExecution getStepExecution(JobExecution jobExecution, Long stepExecutionId) { - List executions = getJdbcTemplate().query(getQuery(GET_STEP_EXECUTION), - new StepExecutionRowMapper(jobExecution), jobExecution.getId(), stepExecutionId); - - Assert.state(executions.size() <= 1, - "There can be at most one step execution with given name for single job execution"); - if (executions.isEmpty()) { - return null; - } - else { - return executions.get(0); + try (Stream stream = getJdbcTemplate().queryForStream(getQuery(GET_STEP_EXECUTION), + new StepExecutionRowMapper(jobExecution), stepExecutionId)) { + return stream.findFirst().orElse(null); } } @@ -378,7 +372,13 @@ public long countStepExecutions(JobInstance jobInstance, String stepName) { */ @Override public void deleteStepExecution(StepExecution stepExecution) { - getJdbcTemplate().update(getQuery(DELETE_STEP_EXECUTION), stepExecution.getId()); + int count = getJdbcTemplate().update(getQuery(DELETE_STEP_EXECUTION), stepExecution.getId(), + stepExecution.getVersion()); + + if (count == 0) { + throw new OptimisticLockingFailureException("Attempt to delete step execution id=" + stepExecution.getId() + + " with wrong version (" + stepExecution.getVersion() + ")"); + } } private static class StepExecutionRowMapper implements RowMapper { diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/support/ResourcelessJobRepository.java b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/support/ResourcelessJobRepository.java index 9fb6b33dd8..b41974c8b7 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/support/ResourcelessJobRepository.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/support/ResourcelessJobRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 the original author or authors. + * Copyright 2024-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,6 +49,8 @@ public class ResourcelessJobRepository implements JobRepository { private JobExecution jobExecution; + private long stepExecutionIdIncrementer = 0L; + @Override public boolean isJobInstanceExists(String jobName, JobParameters jobParameters) { return false; @@ -77,12 +79,14 @@ public void update(JobExecution jobExecution) { @Override public void add(StepExecution stepExecution) { - this.addAll(Collections.singletonList(stepExecution)); + stepExecution.setId(this.stepExecutionIdIncrementer++); } @Override public void addAll(Collection stepExecutions) { - this.jobExecution.addStepExecutions(new ArrayList<>(stepExecutions)); + for (StepExecution stepExecution : stepExecutions) { + this.add(stepExecution); + } } @Override @@ -105,6 +109,9 @@ public void updateExecutionContext(JobExecution jobExecution) { @Override public StepExecution getLastStepExecution(JobInstance jobInstance, String stepName) { + if (this.jobExecution == null || !this.jobExecution.getJobInstance().getId().equals(jobInstance.getId())) { + return null; + } return this.jobExecution.getStepExecutions() .stream() .filter(stepExecution -> stepExecution.getStepName().equals(stepName)) diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/scope/context/SynchronizationManagerSupport.java b/spring-batch-core/src/main/java/org/springframework/batch/core/scope/context/SynchronizationManagerSupport.java index 1891f55883..f76a48b55a 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/scope/context/SynchronizationManagerSupport.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/scope/context/SynchronizationManagerSupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2021 the original author or authors. + * Copyright 2013-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ * @author Dave Syer * @author Jimmy Praet * @author Mahmoud Ben Hassine + * @author Yanming Zhou * @since 3.0 */ public abstract class SynchronizationManagerSupport { @@ -87,11 +88,7 @@ public C register(@Nullable E execution) { getCurrent().push(execution); C context; synchronized (contexts) { - context = contexts.get(execution); - if (context == null) { - context = createNewContext(execution); - contexts.put(execution, context); - } + context = contexts.computeIfAbsent(execution, this::createNewContext); } increment(); return context; @@ -131,11 +128,7 @@ public void increment() { if (current != null) { AtomicInteger count; synchronized (counts) { - count = counts.get(current); - if (count == null) { - count = new AtomicInteger(); - counts.put(current, count); - } + count = counts.computeIfAbsent(current, k -> new AtomicInteger()); } count.incrementAndGet(); } diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/ExitStatusTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/ExitStatusTests.java index 907ea62ff8..17a736d1db 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/ExitStatusTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/ExitStatusTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2024 the original author or authors. + * Copyright 2006-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,6 +33,7 @@ /** * @author Dave Syer * @author Mahmoud Ben Hassine + * @author JiWon Seo * */ class ExitStatusTests { @@ -153,7 +154,7 @@ void testAddExitDescription() { } @Test - void testAddExitDescriptionWIthStacktrace() { + void testAddExitDescriptionWithStacktrace() { ExitStatus status = ExitStatus.EXECUTING.addExitDescription(new RuntimeException("Foo")); assertNotSame(ExitStatus.EXECUTING, status); String description = status.getExitDescription(); @@ -182,8 +183,15 @@ void testAddExitCodeWithDescription() { } @Test - void testUnknownIsRunning() { + void testIsRunning() { + // running statuses + assertTrue(ExitStatus.EXECUTING.isRunning()); assertTrue(ExitStatus.UNKNOWN.isRunning()); + // non running statuses + assertFalse(ExitStatus.COMPLETED.isRunning()); + assertFalse(ExitStatus.FAILED.isRunning()); + assertFalse(ExitStatus.STOPPED.isRunning()); + assertFalse(ExitStatus.NOOP.isRunning()); } @Test diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/SpringBeanJobTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/SpringBeanJobTests.java index c47fd72c0c..a4a38c2c2a 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/SpringBeanJobTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/SpringBeanJobTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2022 the original author or authors. + * Copyright 2006-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,7 +50,7 @@ void testBeanNameWithBeanDefinition() { context.registerBeanDefinition("bean", new RootBeanDefinition(JobSupport.class, args, null)); context.refresh(); - JobSupport configuration = (JobSupport) context.getBean("bean"); + JobSupport configuration = context.getBean("bean", JobSupport.class); assertNotNull(configuration.getName()); assertEquals("foo", configuration.getName()); configuration.setBeanName("bar"); @@ -66,7 +66,7 @@ void testBeanNameWithParentBeanDefinition() { context.registerBeanDefinition("parent", new RootBeanDefinition(JobSupport.class, args, null)); context.registerBeanDefinition("bean", new ChildBeanDefinition("parent")); context.refresh(); - JobSupport configuration = (JobSupport) context.getBean("bean"); + JobSupport configuration = context.getBean("bean", JobSupport.class); assertNotNull(configuration.getName()); assertEquals("bar", configuration.getName()); configuration.setBeanName("foo"); diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/annotation/JobScopeConfigurationTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/annotation/JobScopeConfigurationTests.java index af0b5fc1a6..b2820ad838 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/annotation/JobScopeConfigurationTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/annotation/JobScopeConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2023 the original author or authors. + * Copyright 2006-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -82,7 +82,7 @@ void testXmlJobScopeWithInheritance() throws Exception { context = new ClassPathXmlApplicationContext( "org/springframework/batch/core/configuration/annotation/JobScopeConfigurationTestsInheritance-context.xml"); JobSynchronizationManager.register(jobExecution); - SimpleHolder value = (SimpleHolder) context.getBean("child"); + SimpleHolder value = context.getBean("child", SimpleHolder.class); assertEquals("JOB", value.call()); } @@ -97,9 +97,9 @@ void testJobScopeWithProxyTargetClass() throws Exception { void testStepScopeXmlImportUsingNamespace() throws Exception { init(JobScopeConfigurationXmlImportUsingNamespace.class); - SimpleHolder value = (SimpleHolder) context.getBean("xmlValue"); + SimpleHolder value = context.getBean("xmlValue", SimpleHolder.class); assertEquals("JOB", value.call()); - value = (SimpleHolder) context.getBean("javaValue"); + value = context.getBean("javaValue", SimpleHolder.class); assertEquals("JOB", value.call()); } diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/annotation/StepScopeConfigurationTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/annotation/StepScopeConfigurationTests.java index 5774e00314..e3b7cd1cfc 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/annotation/StepScopeConfigurationTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/annotation/StepScopeConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2023 the original author or authors. + * Copyright 2006-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -81,7 +81,7 @@ void testXmlStepScopeWithInheritance() throws Exception { context = new ClassPathXmlApplicationContext( "org/springframework/batch/core/configuration/annotation/StepScopeConfigurationTestsInheritance-context.xml"); StepSynchronizationManager.register(stepExecution); - SimpleHolder value = (SimpleHolder) context.getBean("child"); + SimpleHolder value = context.getBean("child", SimpleHolder.class); assertEquals("STEP", value.call()); } @@ -96,9 +96,9 @@ void testStepScopeWithProxyTargetClass() throws Exception { void testStepScopeXmlImportUsingNamespace() throws Exception { init(StepScopeConfigurationXmlImportUsingNamespace.class); - SimpleHolder value = (SimpleHolder) context.getBean("xmlValue"); + SimpleHolder value = context.getBean("xmlValue", SimpleHolder.class); assertEquals("STEP", value.call()); - value = (SimpleHolder) context.getBean("javaValue"); + value = context.getBean("javaValue", SimpleHolder.class); assertEquals("STEP", value.call()); } @@ -109,9 +109,9 @@ void testStepScopeXmlImportUsingNamespace() throws Exception { public void testStepScopeUsingNamespaceAutoregisterBeans() throws Exception { init(StepScopeConfigurationTestsUsingNamespaceAutoregisterBeans.class); - ISimpleHolder value = (ISimpleHolder) context.getBean("xmlValue"); + ISimpleHolder value = context.getBean("xmlValue", ISimpleHolder.class); assertEquals("STEP", value.call()); - value = (ISimpleHolder) context.getBean("javaValue"); + value = context.getBean("javaValue", ISimpleHolder.class); assertEquals("STEP", value.call()); } diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/PartitionStepParserTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/PartitionStepParserTests.java index 263a59b701..34adb784e7 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/PartitionStepParserTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/PartitionStepParserTests.java @@ -145,7 +145,7 @@ void testNestedPartitionStepStepReference() throws Throwable { String stepExecutionName = se.getStepName(); // the partitioned step if (stepExecutionName.equalsIgnoreCase("j3s1")) { - PartitionStep partitionStep = (PartitionStep) this.applicationContext.getBean(stepExecutionName); + PartitionStep partitionStep = this.applicationContext.getBean(stepExecutionName, PartitionStep.class); // prove that the reference in the {@link // TaskExecutorPartitionHandler} is the step configured inline TaskExecutorPartitionHandler taskExecutorPartitionHandler = accessPrivateField(partitionStep, @@ -184,7 +184,7 @@ void testNestedPartitionStep() throws Throwable { String stepExecutionName = se.getStepName(); if (stepExecutionName.equalsIgnoreCase("j4s1")) { // the partitioned // step - PartitionStep partitionStep = (PartitionStep) this.applicationContext.getBean(stepExecutionName); + PartitionStep partitionStep = this.applicationContext.getBean(stepExecutionName, PartitionStep.class); // prove that the reference in the {@link // TaskExecutorPartitionHandler} is the step configured inline diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepListenerInStepParserTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepListenerInStepParserTests.java index caf6f2f99c..6536da633c 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepListenerInStepParserTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepListenerInStepParserTests.java @@ -45,7 +45,7 @@ class StepListenerInStepParserTests { @Test void testListenersAtStepLevel() throws Exception { - Step step = (Step) beanFactory.getBean("s1"); + Step step = beanFactory.getBean("s1", Step.class); List list = getListeners(step); assertEquals(1, list.size()); assertTrue(list.get(0) instanceof DummyStepExecutionListener); @@ -54,7 +54,7 @@ void testListenersAtStepLevel() throws Exception { @Test // TODO: BATCH-1689 (expected=BeanCreationException.class) void testListenersAtStepLevelWrongType() throws Exception { - Step step = (Step) beanFactory.getBean("s2"); + Step step = beanFactory.getBean("s2", Step.class); List list = getListeners(step); assertEquals(1, list.size()); assertTrue(list.get(0) instanceof DummyChunkListener); @@ -62,7 +62,7 @@ void testListenersAtStepLevelWrongType() throws Exception { @Test void testListenersAtTaskletAndStepLevels() throws Exception { - Step step = (Step) beanFactory.getBean("s3"); + Step step = beanFactory.getBean("s3", Step.class); List list = getListeners(step); assertEquals(2, list.size()); assertTrue(list.get(0) instanceof DummyStepExecutionListener); @@ -71,7 +71,7 @@ void testListenersAtTaskletAndStepLevels() throws Exception { @Test void testListenersAtChunkAndStepLevels() throws Exception { - Step step = (Step) beanFactory.getBean("s4"); + Step step = beanFactory.getBean("s4", Step.class); List list = getListeners(step); assertEquals(2, list.size()); assertTrue(list.get(0) instanceof DummyStepExecutionListener); diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepNameTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepNameTests.java index 6932f31c12..a482c3022a 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepNameTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepNameTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2022 the original author or authors. + * Copyright 2006-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,7 +61,7 @@ void testStepNames(Resource resource) throws Exception { for (String name : stepLocators.keySet()) { StepLocator stepLocator = stepLocators.get(name); Collection stepNames = stepLocator.getStepNames(); - Job job = (Job) context.getBean(name); + Job job = context.getBean(name, Job.class); String jobName = job.getName(); assertFalse(stepNames.isEmpty(), "Job has no steps: " + jobName); for (String registeredName : stepNames) { diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepParserTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepParserTests.java index 541e402264..f183cfd343 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepParserTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/StepParserTests.java @@ -98,7 +98,7 @@ void testStepParserBeanName() { "org/springframework/batch/core/configuration/xml/StepParserBeanNameTests-context.xml"); Map beans = ctx.getBeansOfType(Step.class); assertTrue(beans.containsKey("s1"), "'s1' bean not found"); - Step s1 = (Step) ctx.getBean("s1"); + Step s1 = ctx.getBean("s1", Step.class); assertEquals("s1", s1.getName(), "wrong name"); } @@ -114,7 +114,7 @@ void testStepParserCommitInterval() throws Exception { "org/springframework/batch/core/configuration/xml/StepParserCommitIntervalTests-context.xml"); Map beans = ctx.getBeansOfType(Step.class); assertTrue(beans.containsKey("s1"), "'s1' bean not found"); - Step s1 = (Step) ctx.getBean("s1"); + Step s1 = ctx.getBean("s1", Step.class); CompletionPolicy completionPolicy = getCompletionPolicy(s1); assertTrue(completionPolicy instanceof SimpleCompletionPolicy); assertEquals(25, ReflectionTestUtils.getField(completionPolicy, "chunkSize")); @@ -126,7 +126,7 @@ void testStepParserCompletionPolicy() throws Exception { "org/springframework/batch/core/configuration/xml/StepParserCompletionPolicyTests-context.xml"); Map beans = ctx.getBeansOfType(Step.class); assertTrue(beans.containsKey("s1"), "'s1' bean not found"); - Step s1 = (Step) ctx.getBean("s1"); + Step s1 = ctx.getBean("s1", Step.class); CompletionPolicy completionPolicy = getCompletionPolicy(s1); assertTrue(completionPolicy instanceof DummyCompletionPolicy); } @@ -212,7 +212,7 @@ private void validateTransactionAttributesInherited(String stepName, Application @SuppressWarnings("unchecked") private List getListeners(String stepName, ApplicationContext ctx) throws Exception { assertTrue(ctx.containsBean(stepName)); - Step step = (Step) ctx.getBean(stepName); + Step step = ctx.getBean(stepName, Step.class); assertTrue(step instanceof TaskletStep); Object compositeListener = ReflectionTestUtils.getField(step, "stepExecutionListener"); Object composite = ReflectionTestUtils.getField(compositeListener, "list"); @@ -236,7 +236,7 @@ private StepExecutionListener getListener(String stepName, ApplicationContext ct private DefaultTransactionAttribute getTransactionAttribute(ApplicationContext ctx, String stepName) { assertTrue(ctx.containsBean(stepName)); - Step step = (Step) ctx.getBean(stepName); + Step step = ctx.getBean(stepName, Step.class); assertTrue(step instanceof TaskletStep); Object transactionAttribute = ReflectionTestUtils.getField(step, "transactionAttribute"); return (DefaultTransactionAttribute) transactionAttribute; @@ -252,7 +252,7 @@ void testInheritFromBean() { private Tasklet getTasklet(String stepName, ApplicationContext ctx) { assertTrue(ctx.containsBean(stepName)); - Step step = (Step) ctx.getBean(stepName); + Step step = ctx.getBean(stepName, Step.class); assertTrue(step instanceof TaskletStep); Object tasklet = ReflectionTestUtils.getField(step, "tasklet"); assertTrue(tasklet instanceof Tasklet); diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/TaskletStepAllowStartIfCompleteTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/TaskletStepAllowStartIfCompleteTests.java index 11823defd7..f561e58ef0 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/TaskletStepAllowStartIfCompleteTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/TaskletStepAllowStartIfCompleteTests.java @@ -46,7 +46,7 @@ class TaskletStepAllowStartIfCompleteTests { @Test void test() throws Exception { // retrieve the step from the context and see that it's allow is set - AbstractStep abstractStep = (AbstractStep) context.getBean("simpleJob.step1"); + AbstractStep abstractStep = context.getBean("simpleJob.step1", AbstractStep.class); assertTrue(abstractStep.isAllowStartIfComplete()); } diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/repository/dao/JdbcStepExecutionDaoTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/repository/dao/JdbcStepExecutionDaoTests.java index f4651a2479..1bed93ab41 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/repository/dao/JdbcStepExecutionDaoTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/repository/dao/JdbcStepExecutionDaoTests.java @@ -31,14 +31,14 @@ class JdbcStepExecutionDaoTests extends AbstractStepExecutionDaoTests { @Override protected StepExecutionDao getStepExecutionDao() { - return (StepExecutionDao) applicationContext.getBean("stepExecutionDao"); + return applicationContext.getBean("stepExecutionDao", StepExecutionDao.class); } @Override protected JobRepository getJobRepository() { deleteFromTables("BATCH_JOB_EXECUTION_CONTEXT", "BATCH_STEP_EXECUTION_CONTEXT", "BATCH_STEP_EXECUTION", "BATCH_JOB_EXECUTION_PARAMS", "BATCH_JOB_EXECUTION", "BATCH_JOB_INSTANCE"); - return (JobRepository) applicationContext.getBean("jobRepository"); + return applicationContext.getBean("jobRepository", JobRepository.class); } /** diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/step/item/FaultTolerantExceptionClassesTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/step/item/FaultTolerantExceptionClassesTests.java index ec486a7636..7b9640d16b 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/step/item/FaultTolerantExceptionClassesTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/step/item/FaultTolerantExceptionClassesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2009-2022 the original author or authors. + * Copyright 2009-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -339,7 +339,7 @@ private StepExecution launchStep(String stepName) throws Exception { job.setJobRepository(jobRepository); List stepsToExecute = new ArrayList<>(); - stepsToExecute.add((Step) applicationContext.getBean(stepName)); + stepsToExecute.add(applicationContext.getBean(stepName, Step.class)); job.setSteps(stepsToExecute); JobExecution jobExecution = jobLauncher.run(job, diff --git a/spring-batch-docs/modules/ROOT/pages/whatsnew.adoc b/spring-batch-docs/modules/ROOT/pages/whatsnew.adoc index 22635de973..2162223f15 100644 --- a/spring-batch-docs/modules/ROOT/pages/whatsnew.adoc +++ b/spring-batch-docs/modules/ROOT/pages/whatsnew.adoc @@ -41,7 +41,8 @@ This implementation requires MongoDB version 4 or later and is based on Spring D In order to use this job repository, all you need to do is define a `MongoTemplate` and a `MongoTransactionManager` which are required by the newly added `MongoJobRepositoryFactoryBean`: -``` +[source, java] +---- @Bean public JobRepository jobRepository(MongoTemplate mongoTemplate, MongoTransactionManager transactionManager) throws Exception { MongoJobRepositoryFactoryBean jobRepositoryFactoryBean = new MongoJobRepositoryFactoryBean(); @@ -50,7 +51,7 @@ public JobRepository jobRepository(MongoTemplate mongoTemplate, MongoTransaction jobRepositoryFactoryBean.afterPropertiesSet(); return jobRepositoryFactoryBean.getObject(); } -``` +---- Once the MongoDB job repository defined, you can inject it in any job or step as a regular job repository. You can find a complete example in the https://github.com/spring-projects/spring-batch/blob/main/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBJobRepositoryIntegrationTests.java[MongoDBJobRepositoryIntegrationTests]. @@ -85,7 +86,8 @@ over different resources and writing a custom reader is not an option. A `CompositeItemReader` works like other composite artifacts, by delegating the reading operation to regular item readers in order. Here is a quick example showing a composite reader that reads persons data from a flat file then from a database table: -``` +[source, java] +---- @Bean public FlatFileItemReader itemReader1() { return new FlatFileItemReaderBuilder() @@ -112,12 +114,12 @@ public JdbcCursorItemReader itemReader2() { public CompositeItemReader itemReader() { return new CompositeItemReader<>(Arrays.asList(itemReader1(), itemReader2())); } -``` +---- [[new-adapters-for-java-util-function-apis]] == New adapters for java.util.function APIs -Similar to `FucntionItemProcessor` that adapts a `java.util.function.Function` to an item processor, this release +Similar to `FunctionItemProcessor` that adapts a `java.util.function.Function` to an item processor, this release introduces several new adapters for other `java.util.function` interfaces like `Supplier`, `Consumer` and `Predicate`. The newly added adapters are: `SupplierItemReader`, `ConsumerItemWriter` and `PredicateFilteringItemProcessor`. diff --git a/spring-batch-docs/pom.xml b/spring-batch-docs/pom.xml index 30afe8f50b..0f6286769e 100644 --- a/spring-batch-docs/pom.xml +++ b/spring-batch-docs/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.2 + 5.2.3 spring-batch-docs Spring Batch Docs diff --git a/spring-batch-infrastructure/pom.xml b/spring-batch-infrastructure/pom.xml index 3abe16ba46..6f46cc7a58 100644 --- a/spring-batch-infrastructure/pom.xml +++ b/spring-batch-infrastructure/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.2 + 5.2.3 spring-batch-infrastructure jar @@ -553,6 +553,24 @@ ${jruby.version} test + + io.lettuce + lettuce-core + ${lettuce.version} + test + + + redis.clients + jedis + ${jedis.version} + test + + + com.redis + testcontainers-redis + ${testcontainers-redis.version} + test + diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/data/MongoItemWriter.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/data/MongoItemWriter.java index b7aa27f375..3937005099 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/data/MongoItemWriter.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/data/MongoItemWriter.java @@ -16,6 +16,10 @@ package org.springframework.batch.item.data; +import static java.util.stream.Collectors.toList; + +import java.util.List; + import org.bson.Document; import org.bson.types.ObjectId; @@ -92,6 +96,8 @@ public enum Mode { private Mode mode = Mode.UPSERT; + private List primaryKeys = List.of(ID_KEY); + public MongoItemWriter() { super(); this.bufferKey = new Object(); @@ -163,6 +169,27 @@ public String getCollection() { return collection; } + /** + * Set the primary keys to associate with the document being written. These fields + * should uniquely identify a single object. + * @param primaryKeys The primary keys to use. + * @since 5.2.3 + */ + public void setPrimaryKeys(List primaryKeys) { + Assert.notEmpty(primaryKeys, "The primaryKeys list must have one or more keys."); + + this.primaryKeys = primaryKeys; + } + + /** + * Get the list of primary keys associated with the document being written. + * @return the list of primary keys + * @since 5.2.3 + */ + public List getPrimaryKeys() { + return primaryKeys; + } + /** * If a transaction is active, buffer items to be written just before commit. * Otherwise write items using the provided template. @@ -213,9 +240,14 @@ private void remove(Chunk chunk) { for (Object item : chunk) { Document document = new Document(); mongoConverter.write(item, document); - Object objectId = document.get(ID_KEY); - if (objectId != null) { - Query query = new Query().addCriteria(Criteria.where(ID_KEY).is(objectId)); + + List criteriaList = primaryKeys.stream() + .filter(document::containsKey) + .map(key -> Criteria.where(key).is(document.get(key))) + .collect(toList()); + if (!criteriaList.isEmpty()) { + Query query = new Query(); + criteriaList.forEach(query::addCriteria); bulkOperations.remove(query); } } @@ -229,8 +261,21 @@ private void upsert(Chunk chunk) { for (Object item : chunk) { Document document = new Document(); mongoConverter.write(item, document); - Object objectId = document.get(ID_KEY) != null ? document.get(ID_KEY) : new ObjectId(); - Query query = new Query().addCriteria(Criteria.where(ID_KEY).is(objectId)); + + Query query = new Query(); + List criteriaList = primaryKeys.stream() + .filter(document::containsKey) + .map(key -> Criteria.where(key).is(document.get(key))) + .collect(toList()); + + if (criteriaList.isEmpty()) { + Object objectId = document.get(ID_KEY) != null ? document.get(ID_KEY) : new ObjectId(); + query.addCriteria(Criteria.where(ID_KEY).is(objectId)); + } + else { + criteriaList.forEach(query::addCriteria); + } + bulkOperations.replaceOne(query, document, upsert); } bulkOperations.execute(); diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/data/builder/MongoItemWriterBuilder.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/data/builder/MongoItemWriterBuilder.java index 4df60a7d4c..0b1fcc5aee 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/data/builder/MongoItemWriterBuilder.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/data/builder/MongoItemWriterBuilder.java @@ -16,6 +16,8 @@ package org.springframework.batch.item.data.builder; +import java.util.List; + import org.springframework.batch.item.data.MongoItemWriter; import org.springframework.batch.item.data.MongoItemWriter.Mode; import org.springframework.data.mongodb.core.MongoOperations; @@ -37,6 +39,8 @@ public class MongoItemWriterBuilder { private Mode mode = Mode.UPSERT; + private List primaryKeys = List.of(); + /** * Indicates if the items being passed to the writer are to be saved or removed from * the data store. If set to false (default), the items will be saved. If set to true, @@ -93,6 +97,32 @@ public MongoItemWriterBuilder collection(String collection) { return this; } + /** + * Set the primary keys to associate with the document being written. These fields + * should uniquely identify a single object. + * @param primaryKeys The keys to use. + * @see MongoItemWriter#setPrimaryKeys(List) + * @since 5.2.3 + */ + public MongoItemWriterBuilder primaryKeys(List primaryKeys) { + this.primaryKeys = List.copyOf(primaryKeys); + + return this; + } + + /** + * Set the primary keys to associate with the document being written. These fields + * should uniquely identify a single object. + * @param primaryKeys The keys to use. + * @see MongoItemWriter#setPrimaryKeys(List) + * @since 5.2.3 + */ + public MongoItemWriterBuilder primaryKeys(String... primaryKeys) { + this.primaryKeys = List.of(primaryKeys); + + return this; + } + /** * Validates and builds a {@link MongoItemWriter}. * @return a {@link MongoItemWriter} @@ -105,6 +135,10 @@ public MongoItemWriter build() { writer.setMode(this.mode); writer.setCollection(this.collection); + if (!this.primaryKeys.isEmpty()) { + writer.setPrimaryKeys(this.primaryKeys); + } + return writer; } diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/database/builder/JdbcCursorItemReaderBuilder.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/database/builder/JdbcCursorItemReaderBuilder.java index a4014536d5..48bb8b91b8 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/database/builder/JdbcCursorItemReaderBuilder.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/database/builder/JdbcCursorItemReaderBuilder.java @@ -164,7 +164,7 @@ public JdbcCursorItemReaderBuilder maxRows(int maxRows) { } /** - * The time in milliseconds for the query to timeout + * The time in seconds for the query to timeout * @param queryTimeout timeout * @return this instance for method chaining * @see JdbcCursorItemReader#setQueryTimeout(int) diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemReaderBuilder.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemReaderBuilder.java index ab8601b18c..e52d4dbde9 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemReaderBuilder.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemReaderBuilder.java @@ -58,6 +58,8 @@ * @author Glenn Renfro * @author Mahmoud Ben Hassine * @author Drummond Dawson + * @author Patrick Baumgartner + * @author François Martin * @since 4.0 * @see FlatFileItemReader */ @@ -459,6 +461,9 @@ else if (this.delimitedBuilder != null) { throw new IllegalStateException("No LineTokenizer implementation was provided."); } + Assert.state(this.targetType == null || this.fieldSetMapper == null, + "Either a TargetType or FieldSetMapper can be set, can't be both."); + if (this.targetType != null || StringUtils.hasText(this.prototypeBeanName)) { if (this.targetType != null && this.targetType.isRecord()) { RecordFieldSetMapper mapper = new RecordFieldSetMapper<>(this.targetType); diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/mapping/RecordFieldSetMapper.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/mapping/RecordFieldSetMapper.java index a86079cc0f..0eb449dab4 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/mapping/RecordFieldSetMapper.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/mapping/RecordFieldSetMapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ * * @param type of mapped items * @author Mahmoud Ben Hassine + * @author Seungyong Hong * @since 4.3 */ public class RecordFieldSetMapper implements FieldSetMapper { @@ -63,6 +64,10 @@ public RecordFieldSetMapper(Class targetType, ConversionService conversionSer this.constructorParameterNames = BeanUtils.getParameterNames(this.mappedConstructor); this.constructorParameterTypes = this.mappedConstructor.getParameterTypes(); } + else { + this.constructorParameterNames = new String[0]; + this.constructorParameterTypes = new Class[0]; + } } @Override diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/AbstractDatabaseItemStreamItemReaderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/AbstractDatabaseItemStreamItemReaderTests.java index 43b9ff289e..cd2b0de1a3 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/AbstractDatabaseItemStreamItemReaderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/AbstractDatabaseItemStreamItemReaderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2009-2023 the original author or authors. + * Copyright 2009-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -70,7 +70,7 @@ void testReadToExhaustion() throws Exception { } protected DataSource getDataSource() { - return (DataSource) ctx.getBean("dataSource"); + return ctx.getBean("dataSource", DataSource.class); } } diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcBatchItemWriterBuilderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcBatchItemWriterBuilderTests.java index cdc7c5fc76..76ffb25b76 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcBatchItemWriterBuilderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcBatchItemWriterBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2023 the original author or authors. + * Copyright 2016-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -60,7 +60,7 @@ class JdbcBatchItemWriterBuilderTests { @BeforeEach void setUp() { this.context = new AnnotationConfigApplicationContext(TestDataSourceConfiguration.class); - this.dataSource = (DataSource) context.getBean("dataSource"); + this.dataSource = context.getBean("dataSource", DataSource.class); } @AfterEach diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcCursorItemReaderBuilderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcCursorItemReaderBuilderTests.java index 16e33c6107..fa66612886 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcCursorItemReaderBuilderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcCursorItemReaderBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * Copyright 2016-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -58,7 +58,7 @@ class JdbcCursorItemReaderBuilderTests { @BeforeEach void setUp() { this.context = new AnnotationConfigApplicationContext(TestDataSourceConfiguration.class); - this.dataSource = (DataSource) context.getBean("dataSource"); + this.dataSource = context.getBean("dataSource", DataSource.class); } @AfterEach diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcPagingItemReaderBuilderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcPagingItemReaderBuilderTests.java index a6220cbeb1..850067929d 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcPagingItemReaderBuilderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JdbcPagingItemReaderBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * Copyright 2017-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -58,7 +58,7 @@ class JdbcPagingItemReaderBuilderTests { @BeforeEach void setUp() { this.context = new AnnotationConfigApplicationContext(TestDataSourceConfiguration.class); - this.dataSource = (DataSource) context.getBean("dataSource"); + this.dataSource = context.getBean("dataSource", DataSource.class); } @AfterEach diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JpaCursorItemReaderBuilderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JpaCursorItemReaderBuilderTests.java index d24fca1a7a..1affc62d81 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JpaCursorItemReaderBuilderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JpaCursorItemReaderBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2023 the original author or authors. + * Copyright 2020-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,7 +61,7 @@ class JpaCursorItemReaderBuilderTests { void setUp() { this.context = new AnnotationConfigApplicationContext( JpaCursorItemReaderBuilderTests.TestDataSourceConfiguration.class); - this.entityManagerFactory = (EntityManagerFactory) context.getBean("entityManagerFactory"); + this.entityManagerFactory = context.getBean("entityManagerFactory", EntityManagerFactory.class); } @AfterEach diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JpaPagingItemReaderBuilderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JpaPagingItemReaderBuilderTests.java index 8fce0376be..e0f1f67e04 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JpaPagingItemReaderBuilderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/builder/JpaPagingItemReaderBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2023 the original author or authors. + * Copyright 2017-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -64,7 +64,7 @@ class JpaPagingItemReaderBuilderTests { void setUp() { this.context = new AnnotationConfigApplicationContext( JpaPagingItemReaderBuilderTests.TestDataSourceConfiguration.class); - this.entityManagerFactory = (EntityManagerFactory) context.getBean("entityManagerFactory"); + this.entityManagerFactory = context.getBean("entityManagerFactory", EntityManagerFactory.class); } @AfterEach diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemReaderBuilderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemReaderBuilderTests.java index e6c6f6c2de..736297641a 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemReaderBuilderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemReaderBuilderTests.java @@ -55,6 +55,8 @@ * @author Mahmoud Ben Hassine * @author Drummond Dawson * @author Glenn Renfro + * @author Patrick Baumgartner + * @author François Martin */ class FlatFileItemReaderBuilderTests { @@ -562,6 +564,17 @@ void testErrorMessageWhenNoLineTokenizerWasProvided() { } } + @Test + void testErrorWhenTargetTypeAndFieldSetMapperIsProvided() { + var builder = new FlatFileItemReaderBuilder().name("fooReader") + .resource(getResource("1;2;3")) + .lineTokenizer(line -> new DefaultFieldSet(line.split(";"))) + .targetType(Foo.class) + .fieldSetMapper(fieldSet -> new Foo()); + var exception = assertThrows(IllegalStateException.class, builder::build); + assertEquals("Either a TargetType or FieldSetMapper can be set, can't be both.", exception.getMessage()); + } + @Test void testSetupWithRecordTargetType() { // given diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/mapping/RecordFieldSetMapperTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/mapping/RecordFieldSetMapperTests.java index a3fc68ff44..3a1dde5eef 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/mapping/RecordFieldSetMapperTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/mapping/RecordFieldSetMapperTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,6 @@ package org.springframework.batch.item.file.mapping; import org.junit.jupiter.api.Test; - import org.springframework.batch.item.file.transform.DefaultFieldSet; import org.springframework.batch.item.file.transform.FieldSet; @@ -26,6 +25,7 @@ /** * @author Mahmoud Ben Hassine + * @author Seungyong Hong */ class RecordFieldSetMapperTests { @@ -68,7 +68,23 @@ void testMapFieldSetWhenFieldNamesAreNotSpecified() { assertEquals("Field names must be specified", exception.getMessage()); } + @Test + void testMapFieldSetWhenEmptyRecord() { + // given + RecordFieldSetMapper mapper = new RecordFieldSetMapper<>(EmptyRecord.class); + FieldSet fieldSet = new DefaultFieldSet(new String[0], new String[0]); + + // when + EmptyRecord empty = mapper.mapFieldSet(fieldSet); + + // then + assertNotNull(empty); + } + record Person(int id, String name) { } + record EmptyRecord() { + } + } diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/redis/RedisItemReaderIntegrationTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/redis/RedisItemReaderIntegrationTests.java new file mode 100644 index 0000000000..66e733fcfb --- /dev/null +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/redis/RedisItemReaderIntegrationTests.java @@ -0,0 +1,126 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.batch.item.redis; + +import com.redis.testcontainers.RedisContainer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.redis.example.Person; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; + +/** + * @author Hyunwoo Jung + */ +@Testcontainers(disabledWithoutDocker = true) +@ExtendWith(SpringExtension.class) +class RedisItemReaderIntegrationTests { + + private static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:8.0.3"); + + @Container + public static RedisContainer redis = new RedisContainer(REDIS_IMAGE); + + private RedisItemReader reader; + + private RedisTemplate template; + + @BeforeEach + void setUp() { + this.template = setUpRedisTemplate(lettuceConnectionFactory()); + } + + @AfterEach + void tearDown() { + this.template.getConnectionFactory().getConnection().serverCommands().flushAll(); + } + + @ParameterizedTest + @MethodSource("connectionFactories") + void testRead(RedisConnectionFactory connectionFactory) throws Exception { + this.template.opsForValue().set("person:1", new Person(1, "foo")); + this.template.opsForValue().set("person:2", new Person(2, "bar")); + this.template.opsForValue().set("person:3", new Person(3, "baz")); + this.template.opsForValue().set("person:4", new Person(4, "qux")); + this.template.opsForValue().set("person:5", new Person(5, "quux")); + + RedisTemplate redisTemplate = setUpRedisTemplate(connectionFactory); + ScanOptions scanOptions = ScanOptions.scanOptions().match("person:*").count(10).build(); + this.reader = new RedisItemReader<>(redisTemplate, scanOptions); + + this.reader.open(new ExecutionContext()); + + List items = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + items.add(this.reader.read()); + } + + assertThat(items, containsInAnyOrder(new Person(1, "foo"), new Person(2, "bar"), new Person(3, "baz"), + new Person(4, "qux"), new Person(5, "quux"))); + } + + private RedisTemplate setUpRedisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer()); + redisTemplate.afterPropertiesSet(); + + return redisTemplate; + } + + private static Stream connectionFactories() { + return Stream.of(Arguments.of(lettuceConnectionFactory()), Arguments.of(jedisConnectionFactory())); + } + + private static RedisConnectionFactory lettuceConnectionFactory() { + LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory( + new RedisStandaloneConfiguration(redis.getRedisHost(), redis.getRedisPort())); + lettuceConnectionFactory.afterPropertiesSet(); + return lettuceConnectionFactory; + } + + private static JedisConnectionFactory jedisConnectionFactory() { + JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory( + new RedisStandaloneConfiguration(redis.getRedisHost(), redis.getRedisPort())); + jedisConnectionFactory.afterPropertiesSet(); + return jedisConnectionFactory; + } + +} diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/redis/RedisItemWriterIntegrationTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/redis/RedisItemWriterIntegrationTests.java new file mode 100644 index 0000000000..f6d7edcac6 --- /dev/null +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/redis/RedisItemWriterIntegrationTests.java @@ -0,0 +1,145 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.batch.item.redis; + +import com.redis.testcontainers.RedisContainer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.redis.example.Person; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Hyunwoo Jung + */ +@Testcontainers(disabledWithoutDocker = true) +@ExtendWith(SpringExtension.class) +class RedisItemWriterIntegrationTests { + + private static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:8.0.3"); + + @Container + public static RedisContainer redis = new RedisContainer(REDIS_IMAGE); + + private RedisItemWriter writer; + + private RedisTemplate template; + + @BeforeEach + void setUp() { + this.template = setUpRedisTemplate(lettuceConnectionFactory()); + } + + @AfterEach + void tearDown() { + this.template.getConnectionFactory().getConnection().serverCommands().flushAll(); + } + + @ParameterizedTest + @MethodSource("connectionFactories") + void testWriteWithLettuce(RedisConnectionFactory connectionFactory) throws Exception { + RedisTemplate redisTemplate = setUpRedisTemplate(connectionFactory); + this.writer = new RedisItemWriter<>(); + this.writer.setRedisTemplate(redisTemplate); + this.writer.setItemKeyMapper(p -> "person:" + p.getId()); + this.writer.setDelete(false); + + Chunk items = new Chunk<>(new Person(1, "foo"), new Person(2, "bar"), new Person(3, "baz"), + new Person(4, "qux"), new Person(5, "quux")); + this.writer.write(items); + + assertEquals(new Person(1, "foo"), this.template.opsForValue().get("person:1")); + assertEquals(new Person(2, "bar"), this.template.opsForValue().get("person:2")); + assertEquals(new Person(3, "baz"), this.template.opsForValue().get("person:3")); + assertEquals(new Person(4, "qux"), this.template.opsForValue().get("person:4")); + assertEquals(new Person(5, "quux"), this.template.opsForValue().get("person:5")); + } + + @ParameterizedTest + @MethodSource("connectionFactories") + void testDelete(RedisConnectionFactory connectionFactory) throws Exception { + this.template.opsForValue().set("person:1", new Person(1, "foo")); + this.template.opsForValue().set("person:2", new Person(2, "bar")); + this.template.opsForValue().set("person:3", new Person(3, "baz")); + this.template.opsForValue().set("person:4", new Person(4, "qux")); + this.template.opsForValue().set("person:5", new Person(5, "quux")); + + RedisTemplate redisTemplate = setUpRedisTemplate(connectionFactory); + this.writer = new RedisItemWriter<>(); + this.writer.setRedisTemplate(redisTemplate); + this.writer.setItemKeyMapper(p -> "person:" + p.getId()); + this.writer.setDelete(true); + + Chunk items = new Chunk<>(new Person(1, "foo"), new Person(2, "bar"), new Person(3, "baz"), + new Person(4, "qux"), new Person(5, "quux")); + this.writer.write(items); + + assertFalse(this.template.hasKey("person:1")); + assertFalse(this.template.hasKey("person:2")); + assertFalse(this.template.hasKey("person:3")); + assertFalse(this.template.hasKey("person:4")); + assertFalse(this.template.hasKey("person:5")); + } + + private RedisTemplate setUpRedisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer()); + redisTemplate.afterPropertiesSet(); + + return redisTemplate; + } + + private static Stream connectionFactories() { + return Stream.of(Arguments.of(lettuceConnectionFactory()), Arguments.of(jedisConnectionFactory())); + } + + private static RedisConnectionFactory lettuceConnectionFactory() { + LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory( + new RedisStandaloneConfiguration(redis.getRedisHost(), redis.getRedisPort())); + lettuceConnectionFactory.afterPropertiesSet(); + return lettuceConnectionFactory; + } + + private static JedisConnectionFactory jedisConnectionFactory() { + JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory( + new RedisStandaloneConfiguration(redis.getRedisHost(), redis.getRedisPort())); + jedisConnectionFactory.afterPropertiesSet(); + return jedisConnectionFactory; + } + +} diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/redis/example/Person.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/redis/example/Person.java new file mode 100644 index 0000000000..2cc819ab99 --- /dev/null +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/redis/example/Person.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.batch.item.redis.example; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +/** + * @author Hyunwoo Jung + */ +public class Person implements Serializable { + + @Serial + private static final long serialVersionUID = 2396556853218591048L; + + private long id; + + private String name; + + public Person(long id, String name) { + this.id = id; + this.name = name; + } + + public long getId() { + return id; + } + + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + Person person = (Person) o; + return id == person.id && Objects.equals(name, person.name); + } + + @Override + public int hashCode() { + return Objects.hash(id, name); + } + + @Override + public String toString() { + return "Person{id=" + id + ", name=" + name + "}"; + } + +} diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/repeat/jms/SynchronousTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/repeat/jms/SynchronousTests.java index bded5597a9..365ce8a5ed 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/repeat/jms/SynchronousTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/repeat/jms/SynchronousTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2023 the original author or authors. + * Copyright 2006-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -155,7 +155,7 @@ void JpaNativeQueryProviderIntegrationTeststestPartialRollback() { // The JmsTemplate is used elsewhere outside a transaction, so // we need to use one here that is transaction aware. final JmsTemplate txJmsTemplate = new JmsTemplate( - (ConnectionFactory) applicationContext.getBean("txAwareConnectionFactory")); + applicationContext.getBean("txAwareConnectionFactory", ConnectionFactory.class)); txJmsTemplate.setReceiveTimeout(100L); txJmsTemplate.setSessionTransacted(true); diff --git a/spring-batch-integration/pom.xml b/spring-batch-integration/pom.xml index debc4e8e64..8bc95f393a 100644 --- a/spring-batch-integration/pom.xml +++ b/spring-batch-integration/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.2 + 5.2.3 spring-batch-integration Spring Batch Integration diff --git a/spring-batch-integration/src/main/java/org/springframework/batch/integration/chunk/RemoteChunkHandlerFactoryBean.java b/spring-batch-integration/src/main/java/org/springframework/batch/integration/chunk/RemoteChunkHandlerFactoryBean.java index 9def1ae7c3..26ffef4ff8 100644 --- a/spring-batch-integration/src/main/java/org/springframework/batch/integration/chunk/RemoteChunkHandlerFactoryBean.java +++ b/spring-batch-integration/src/main/java/org/springframework/batch/integration/chunk/RemoteChunkHandlerFactoryBean.java @@ -122,12 +122,12 @@ public ChunkHandler getObject() throws Exception { stepContributionSource = (StepContributionSource) chunkWriter; } - Assert.state(step instanceof TaskletStep, "Step [" + step.getName() + "] must be a TaskletStep"); + Assert.state(step != null, "A TaskletStep must be provided"); if (logger.isDebugEnabled()) { logger.debug("Converting TaskletStep with name=" + step.getName()); } - Tasklet tasklet = getTasklet(step); + Tasklet tasklet = step.getTasklet(); Assert.state(tasklet instanceof ChunkOrientedTasklet, "Tasklet must be ChunkOrientedTasklet in step=" + step.getName()); @@ -227,15 +227,6 @@ private ChunkProcessor getChunkProcessor(ChunkOrientedTasklet tasklet) { return (ChunkProcessor) getField(tasklet, "chunkProcessor"); } - /** - * Pull a Tasklet out of a step. - * @param step a TaskletStep - * @return the Tasklet - */ - private Tasklet getTasklet(TaskletStep step) { - return (Tasklet) getField(step, "tasklet"); - } - private static Object getField(Object target, String name) { Assert.notNull(target, "Target object must not be null"); Field field = ReflectionUtils.findField(target.getClass(), name); diff --git a/spring-batch-samples/pom.xml b/spring-batch-samples/pom.xml index 646a150321..72fd8a5a67 100644 --- a/spring-batch-samples/pom.xml +++ b/spring-batch-samples/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.2 + 5.2.3 spring-batch-samples jar diff --git a/spring-batch-samples/src/test/java/org/springframework/batch/samples/chunking/RemoteChunkingJobFunctionalTests.java b/spring-batch-samples/src/test/java/org/springframework/batch/samples/chunking/RemoteChunkingJobFunctionalTests.java index 07d5f93da5..b800ddc027 100644 --- a/spring-batch-samples/src/test/java/org/springframework/batch/samples/chunking/RemoteChunkingJobFunctionalTests.java +++ b/spring-batch-samples/src/test/java/org/springframework/batch/samples/chunking/RemoteChunkingJobFunctionalTests.java @@ -77,10 +77,9 @@ void testRemoteChunkingJob(@Autowired Job job) throws Exception { JobExecution jobExecution = this.jobLauncher.run(job, new JobParameters()); // then + // the manager sent 2 chunks ({1, 2, 3} and {4, 5, 6}) to workers assertEquals(ExitStatus.COMPLETED.getExitCode(), jobExecution.getExitStatus().getExitCode()); - assertEquals("Waited for 2 results.", // the manager sent 2 chunks ({1, 2, - // 3} and {4, 5, 6}) to workers - jobExecution.getExitStatus().getExitDescription()); + assertEquals("Waited for 2 results.", jobExecution.getExitStatus().getExitDescription()); } } diff --git a/spring-batch-samples/src/test/java/org/springframework/batch/samples/helloworld/HelloWorldJobFunctionalTests.java b/spring-batch-samples/src/test/java/org/springframework/batch/samples/helloworld/HelloWorldJobFunctionalTests.java index 9dfcc75f44..71caa2868f 100644 --- a/spring-batch-samples/src/test/java/org/springframework/batch/samples/helloworld/HelloWorldJobFunctionalTests.java +++ b/spring-batch-samples/src/test/java/org/springframework/batch/samples/helloworld/HelloWorldJobFunctionalTests.java @@ -42,6 +42,8 @@ public void testLaunchJob() throws Exception { // then assertEquals(BatchStatus.COMPLETED, jobExecution.getStatus()); + assertEquals(1, jobExecution.getStepExecutions().size()); + assertEquals(BatchStatus.COMPLETED, jobExecution.getStepExecutions().iterator().next().getStatus()); } } diff --git a/spring-batch-test/pom.xml b/spring-batch-test/pom.xml index c9f92b858f..55f14ac626 100644 --- a/spring-batch-test/pom.xml +++ b/spring-batch-test/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.2 + 5.2.3 spring-batch-test Spring Batch Test diff --git a/spring-batch-test/src/main/java/org/springframework/batch/test/JobRepositoryTestUtils.java b/spring-batch-test/src/main/java/org/springframework/batch/test/JobRepositoryTestUtils.java index d6a7b07327..15e1d2dae5 100644 --- a/spring-batch-test/src/main/java/org/springframework/batch/test/JobRepositoryTestUtils.java +++ b/spring-batch-test/src/main/java/org/springframework/batch/test/JobRepositoryTestUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2023 the original author or authors. + * Copyright 2006-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,7 @@ import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException; import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.repository.JobRestartException; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.lang.Nullable; /** @@ -39,6 +40,7 @@ * * @author Dave Syer * @author Mahmoud Ben Hassine + * @author Yanming Zhou */ public class JobRepositoryTestUtils { @@ -136,7 +138,12 @@ public void removeJobExecutions(Collection jobExecutions) { removeJobExecution(jobExecution); } for (JobExecution jobExecution : jobExecutions) { - this.jobRepository.deleteJobInstance(jobExecution.getJobInstance()); + try { + this.jobRepository.deleteJobInstance(jobExecution.getJobInstance()); + } + catch (OptimisticLockingFailureException ignore) { + // same job instance may be already deleted + } } } diff --git a/spring-batch-test/src/test/java/org/springframework/batch/test/SampleStepTests.java b/spring-batch-test/src/test/java/org/springframework/batch/test/SampleStepTests.java index e16e1410a7..a52cfd7620 100755 --- a/spring-batch-test/src/test/java/org/springframework/batch/test/SampleStepTests.java +++ b/spring-batch-test/src/test/java/org/springframework/batch/test/SampleStepTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2022 the original author or authors. + * Copyright 2008-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,7 +61,7 @@ void tearDown() { @Test void testTasklet() { - Step step = (Step) context.getBean("s2"); + Step step = context.getBean("s2", Step.class); assertEquals(BatchStatus.COMPLETED, stepRunner.launchStep(step).getStatus()); assertEquals(2, jdbcTemplate.queryForObject("SELECT ID from TESTS where NAME = 'SampleTasklet2'", Integer.class) .intValue());