diff --git a/.github/dco.yml b/.github/dco.yml new file mode 100644 index 0000000000..0c4b142e9a --- /dev/null +++ b/.github/dco.yml @@ -0,0 +1,2 @@ +require: + members: false diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 37dc5a6925..9171cc9021 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -1,9 +1,6 @@ name: CI/CD build -on: - workflow_dispatch: - push: - branches: [ "main" ] +on: [push, pull_request, workflow_dispatch] jobs: build: @@ -20,7 +17,12 @@ jobs: distribution: 'temurin' cache: 'maven' + - name: Build with Maven + if: ${{ github.repository != 'spring-projects/spring-batch' || github.ref_name != 'main' }} + 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' }} env: ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} @@ -37,6 +39,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' }} env: DOCS_SSH_KEY: ${{ secrets.DOCS_SSH_KEY }} DOCS_SSH_HOST_KEY: ${{ secrets.DOCS_SSH_HOST_KEY }} @@ -47,6 +50,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' }} env: DOCS_HOST: ${{ secrets.DOCS_HOST }} DOCS_PATH: ${{ secrets.DOCS_PATH }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 779b711d58..c6ad7d3a70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,9 +26,11 @@ about how to report issues. Not sure what a *pull request* is, or how to submit one? Take a look at the excellent [GitHub help documentation][] first. Please create a new issue *before* submitting a pull request unless the change is truly trivial, e.g. typo fixes, removing compiler warnings, etc. -### Sign the contributor license agreement +### Sign-off commits according to the Developer Certificate of Origin -If you have not previously done so, please fill out and submit the [Contributor License Agreement](https://cla.pivotal.io/sign/spring). +All commits must include a Signed-off-by trailer at the end of each commit message to indicate that the contributor agrees to the [Developer Certificate of Origin](https://developercertificate.org). + +For additional details, please refer to the blog post [Hello DCO, Goodbye CLA: Simplifying Contributions to Spring](https://spring.io/blog/2025/01/06/hello-dco-goodbye-cla-simplifying-contributions-to-spring). ### Fork the Repository diff --git a/README.md b/README.md index 7110ad6524..5cc775db30 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # Latest news +* 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) -* October 25, 2024: [Spring Batch 5.2.0-RC1 is out!](https://spring.io/blog/2024/10/25/spring-batch-5-2-0-rc1-is-out) -* October 11, 2024: [Spring Batch 5.2.0-M2 is available now!](https://spring.io/blog/2024/10/11/spring-batch-5-2-0-m2-is-available-now) -* September 18, 2024: [Spring Batch 5.2.0-M1 is out!](https://spring.io/blog/2024/09/18/spring-batch-5-2-0-m1-is-out) +* 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 2cef3d05b8..9ad73b8107 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.1 + 5.2.2 pom https://projects.spring.io/spring-batch @@ -61,55 +61,55 @@ 17 - 6.2.1 + 6.2.4 2.0.11 - 6.4.1 - 1.14.2 + 6.4.3 + 1.14.5 - 3.4.1 - 3.4.1 - 3.4.1 - 4.4.1 - 3.3.1 - 3.2.1 - 3.2.9 + 3.4.4 + 3.4.4 + 3.4.4 + 4.4.4 + 3.3.4 + 3.2.4 + 3.2.11 - 2.18.2 + 2.18.3 1.12.0 - 2.11.0 - 6.6.3.Final + 2.12.1 + 6.6.11.Final 3.0.0 2.1.3 3.1.0 - 3.1.0 - 3.1.0 - 4.0.13 - 5.2.1 - 5.11.4 + 3.1.1 + 3.1.0 + 4.0.16 + 5.3.1 + 5.11.4 3.0.2 - 1.4.1 + 1.4.4 - 1.4.20 + 1.4.21 4.13.2 ${junit-jupiter.version} 3.0 - 3.26.3 - 5.14.2 + 3.27.3 + 5.16.1 2.10.0 2.18.0 2.13.0 - 2.0.16 + 2.0.17 2.7.4 2.3.232 - 3.47.1.0 + 3.49.1.0 10.16.1.1 - 2.21.11 - 2.38.0 + 2.24.6 + 2.40.0 4.0.5 2.24.3 8.0.2.Final @@ -119,20 +119,24 @@ 4.0.2 2.0.3 7.1.0 - 1.9.22.1 - 9.1.0 - 3.5.1 - 42.7.4 - 11.5.9.0 - 19.24.0.0 + 1.9.23 + 9.2.0 + 3.5.2 + 42.7.5 + 12.1.0.0 + 19.26.0.0 11.2.3.jre17 1.3.1 - 1.20.4 + 1.20.6 1.5.3 + 4.0.26 + 15.6 + 2.0b6 + 9.4.12.0 ${spring-amqp.version} - 2.3.2 + 2.5.0 0.16.0 3.0.22 @@ -140,13 +144,13 @@ 0.0.4 - 3.13.0 - 3.5.0 - 3.5.0 - 3.10.0 + 3.14.0 + 3.5.2 + 3.5.2 + 3.11.2 3.3.1 - 1.6.0 - 3.1.3 + 1.7.0 + 3.1.4 3.7.1 3.4.2 0.0.39 diff --git a/spring-batch-bom/pom.xml b/spring-batch-bom/pom.xml index d5a013c25d..35c27a533f 100644 --- a/spring-batch-bom/pom.xml +++ b/spring-batch-bom/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.1 + 5.2.2 spring-batch-bom pom diff --git a/spring-batch-core/pom.xml b/spring-batch-core/pom.xml index 447807636d..783b995348 100644 --- a/spring-batch-core/pom.xml +++ b/spring-batch-core/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.1 + 5.2.2 spring-batch-core jar diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/aot/CoreRuntimeHints.java b/spring-batch-core/src/main/java/org/springframework/batch/core/aot/CoreRuntimeHints.java index 84a3c6e885..5c818578c8 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/aot/CoreRuntimeHints.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/aot/CoreRuntimeHints.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 the original author or authors. + * Copyright 2022-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. @@ -98,6 +98,27 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) { // proxy hints hints.proxies() + .registerJdkProxy(builder -> builder + .proxiedInterfaces(TypeReference.of("org.springframework.batch.core.StepExecutionListener")) + .proxiedInterfaces(SpringProxy.class, Advised.class, DecoratingProxy.class)) + .registerJdkProxy(builder -> builder + .proxiedInterfaces(TypeReference.of("org.springframework.batch.core.ItemReadListener")) + .proxiedInterfaces(SpringProxy.class, Advised.class, DecoratingProxy.class)) + .registerJdkProxy(builder -> builder + .proxiedInterfaces(TypeReference.of("org.springframework.batch.core.ItemProcessListener")) + .proxiedInterfaces(SpringProxy.class, Advised.class, DecoratingProxy.class)) + .registerJdkProxy(builder -> builder + .proxiedInterfaces(TypeReference.of("org.springframework.batch.core.ItemWriteListener")) + .proxiedInterfaces(SpringProxy.class, Advised.class, DecoratingProxy.class)) + .registerJdkProxy(builder -> builder + .proxiedInterfaces(TypeReference.of("org.springframework.batch.core.ChunkListener")) + .proxiedInterfaces(SpringProxy.class, Advised.class, DecoratingProxy.class)) + .registerJdkProxy(builder -> builder + .proxiedInterfaces(TypeReference.of("org.springframework.batch.core.SkipListener")) + .proxiedInterfaces(SpringProxy.class, Advised.class, DecoratingProxy.class)) + .registerJdkProxy(builder -> builder + .proxiedInterfaces(TypeReference.of("org.springframework.batch.core.JobExecutionListener")) + .proxiedInterfaces(SpringProxy.class, Advised.class, DecoratingProxy.class)) .registerJdkProxy(builder -> builder .proxiedInterfaces(TypeReference.of("org.springframework.batch.core.repository.JobRepository")) .proxiedInterfaces(SpringProxy.class, Advised.class, DecoratingProxy.class)) diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/annotation/BatchRegistrar.java b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/annotation/BatchRegistrar.java index d261384ef0..3d23f6bcf7 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/annotation/BatchRegistrar.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/annotation/BatchRegistrar.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * Copyright 2022-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. @@ -51,6 +51,16 @@ class BatchRegistrar implements ImportBeanDefinitionRegistrar { private static final String MISSING_ANNOTATION_ERROR_MESSAGE = "EnableBatchProcessing is not present on importing class '%s' as expected"; + private static final String JOB_REPOSITORY = "jobRepository"; + + private static final String JOB_EXPLORER = "jobExplorer"; + + private static final String JOB_LAUNCHER = "jobLauncher"; + + private static final String JOB_REGISTRY = "jobRegistry"; + + private static final String JOB_LOADER = "jobLoader"; + @Override public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { StopWatch watch = new StopWatch(); @@ -80,7 +90,7 @@ private void validateState(AnnotationMetadata importingClassMetadata) { } private void registerJobRepository(BeanDefinitionRegistry registry, EnableBatchProcessing batchAnnotation) { - if (registry.containsBeanDefinition("jobRepository")) { + if (registry.containsBeanDefinition(JOB_REPOSITORY)) { LOGGER.info("Bean jobRepository already defined in the application context, skipping" + " the registration of a jobRepository"); return; @@ -143,11 +153,11 @@ private void registerJobRepository(BeanDefinitionRegistry registry, EnableBatchP beanDefinitionBuilder.addPropertyValue("maxVarCharLength", batchAnnotation.maxVarCharLength()); beanDefinitionBuilder.addPropertyValue("clobType", batchAnnotation.clobType()); - registry.registerBeanDefinition("jobRepository", beanDefinitionBuilder.getBeanDefinition()); + registry.registerBeanDefinition(JOB_REPOSITORY, beanDefinitionBuilder.getBeanDefinition()); } private void registerJobExplorer(BeanDefinitionRegistry registry, EnableBatchProcessing batchAnnotation) { - if (registry.containsBeanDefinition("jobExplorer")) { + if (registry.containsBeanDefinition(JOB_EXPLORER)) { LOGGER.info("Bean jobExplorer already defined in the application context, skipping" + " the registration of a jobExplorer"); return; @@ -192,11 +202,11 @@ private void registerJobExplorer(BeanDefinitionRegistry registry, EnableBatchPro if (tablePrefix != null) { beanDefinitionBuilder.addPropertyValue("tablePrefix", tablePrefix); } - registry.registerBeanDefinition("jobExplorer", beanDefinitionBuilder.getBeanDefinition()); + registry.registerBeanDefinition(JOB_EXPLORER, beanDefinitionBuilder.getBeanDefinition()); } private void registerJobLauncher(BeanDefinitionRegistry registry, EnableBatchProcessing batchAnnotation) { - if (registry.containsBeanDefinition("jobLauncher")) { + if (registry.containsBeanDefinition(JOB_LAUNCHER)) { LOGGER.info("Bean jobLauncher already defined in the application context, skipping" + " the registration of a jobLauncher"); return; @@ -204,25 +214,25 @@ private void registerJobLauncher(BeanDefinitionRegistry registry, EnableBatchPro BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder .genericBeanDefinition(TaskExecutorJobLauncher.class); // set mandatory properties - beanDefinitionBuilder.addPropertyReference("jobRepository", "jobRepository"); + beanDefinitionBuilder.addPropertyReference(JOB_REPOSITORY, JOB_REPOSITORY); // set optional properties String taskExecutorRef = batchAnnotation.taskExecutorRef(); if (registry.containsBeanDefinition(taskExecutorRef)) { beanDefinitionBuilder.addPropertyReference("taskExecutor", taskExecutorRef); } - registry.registerBeanDefinition("jobLauncher", beanDefinitionBuilder.getBeanDefinition()); + registry.registerBeanDefinition(JOB_LAUNCHER, beanDefinitionBuilder.getBeanDefinition()); } private void registerJobRegistry(BeanDefinitionRegistry registry) { - if (registry.containsBeanDefinition("jobRegistry")) { + if (registry.containsBeanDefinition(JOB_REGISTRY)) { LOGGER.info("Bean jobRegistry already defined in the application context, skipping" + " the registration of a jobRegistry"); return; } BeanDefinition beanDefinition = BeanDefinitionBuilder.genericBeanDefinition(MapJobRegistry.class) .getBeanDefinition(); - registry.registerBeanDefinition("jobRegistry", beanDefinition); + registry.registerBeanDefinition(JOB_REGISTRY, beanDefinition); } private void registerJobRegistrySmartInitializingSingleton(BeanDefinitionRegistry registry) { @@ -234,7 +244,7 @@ private void registerJobRegistrySmartInitializingSingleton(BeanDefinitionRegistr } BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder .genericBeanDefinition(JobRegistrySmartInitializingSingleton.class); - beanDefinitionBuilder.addPropertyReference("jobRegistry", "jobRegistry"); + beanDefinitionBuilder.addPropertyReference(JOB_REGISTRY, JOB_REGISTRY); registry.registerBeanDefinition("jobRegistrySmartInitializingSingleton", beanDefinitionBuilder.getBeanDefinition()); @@ -252,10 +262,10 @@ private void registerJobOperator(BeanDefinitionRegistry registry, EnableBatchPro String transactionManagerRef = batchAnnotation.transactionManagerRef(); beanDefinitionBuilder.addPropertyReference("transactionManager", transactionManagerRef); - beanDefinitionBuilder.addPropertyReference("jobRepository", "jobRepository"); - beanDefinitionBuilder.addPropertyReference("jobLauncher", "jobLauncher"); - beanDefinitionBuilder.addPropertyReference("jobExplorer", "jobExplorer"); - beanDefinitionBuilder.addPropertyReference("jobRegistry", "jobRegistry"); + beanDefinitionBuilder.addPropertyReference(JOB_REPOSITORY, JOB_REPOSITORY); + beanDefinitionBuilder.addPropertyReference(JOB_LAUNCHER, JOB_LAUNCHER); + beanDefinitionBuilder.addPropertyReference(JOB_EXPLORER, JOB_EXPLORER); + beanDefinitionBuilder.addPropertyReference(JOB_REGISTRY, JOB_REGISTRY); // set optional properties String jobParametersConverterRef = batchAnnotation.jobParametersConverterRef(); @@ -276,12 +286,12 @@ private void registerAutomaticJobRegistrar(BeanDefinitionRegistry registry, Enab return; } BeanDefinition jobLoaderBeanDefinition = BeanDefinitionBuilder.genericBeanDefinition(DefaultJobLoader.class) - .addPropertyReference("jobRegistry", "jobRegistry") + .addPropertyReference(JOB_REGISTRY, JOB_REGISTRY) .getBeanDefinition(); - registry.registerBeanDefinition("jobLoader", jobLoaderBeanDefinition); + registry.registerBeanDefinition(JOB_LOADER, jobLoaderBeanDefinition); BeanDefinition jobRegistrarBeanDefinition = BeanDefinitionBuilder .genericBeanDefinition(AutomaticJobRegistrar.class) - .addPropertyReference("jobLoader", "jobLoader") + .addPropertyReference(JOB_LOADER, JOB_LOADER) .getBeanDefinition(); registry.registerBeanDefinition("jobRegistrar", jobRegistrarBeanDefinition); } diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/partition/support/SimplePartitioner.java b/spring-batch-core/src/main/java/org/springframework/batch/core/partition/support/SimplePartitioner.java index 9e3ebbaa10..de0b44f7b4 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/partition/support/SimplePartitioner.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/partition/support/SimplePartitioner.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2013 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. @@ -25,7 +25,7 @@ * Simplest possible implementation of {@link Partitioner}. Just creates a set of empty * {@link ExecutionContext} instances, and labels them as * {partition0, partition1, ..., partitionN}, where N is the - * grid size. + * grid size - 1. * * @author Dave Syer * @since 2.0 diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/MongoJobExecutionDao.java b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/MongoJobExecutionDao.java index 90d3326a9a..da1d81ff78 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/MongoJobExecutionDao.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/MongoJobExecutionDao.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. @@ -26,7 +26,6 @@ import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoOperations; import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; import org.springframework.jdbc.support.incrementer.DataFieldMaxValueIncrementer; import static org.springframework.data.mongodb.core.query.Criteria.where; @@ -143,14 +142,13 @@ public JobExecution getJobExecution(Long executionId) { @Override public void synchronizeStatus(JobExecution jobExecution) { - Query query = query(where("jobExecutionId").is(jobExecution.getId())); - Update update = Update.update("status", jobExecution.getStatus()); + JobExecution currentJobExecution = getJobExecution(jobExecution.getId()); + if (currentJobExecution != null && currentJobExecution.getStatus().isGreaterThan(jobExecution.getStatus())) { + jobExecution.upgradeStatus(currentJobExecution.getStatus()); + } // TODO the contract mentions to update the version as well. Double check if this // is needed as the version is not used in the tests following the call sites of // synchronizeStatus - this.mongoOperations.updateFirst(query, update, - org.springframework.batch.core.repository.persistence.JobExecution.class, - JOB_EXECUTIONS_COLLECTION_NAME); } } diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/step/item/ChunkProcessor.java b/spring-batch-core/src/main/java/org/springframework/batch/core/step/item/ChunkProcessor.java index 3bab818b81..51034c867e 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/step/item/ChunkProcessor.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/step/item/ChunkProcessor.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. @@ -22,8 +22,10 @@ /** * Interface defined for processing {@link org.springframework.batch.item.Chunk}s. * + * @author Kyeonghoon Lee (Add FunctionalInterface annotation) * @since 2.0 */ +@FunctionalInterface public interface ChunkProcessor { void process(StepContribution contribution, Chunk chunk) throws Exception; diff --git a/spring-batch-core/src/main/resources/org/springframework/batch/core/schema-mongodb.js b/spring-batch-core/src/main/resources/org/springframework/batch/core/schema-mongodb.js index e3a971ad8a..eb10033e8c 100644 --- a/spring-batch-core/src/main/resources/org/springframework/batch/core/schema-mongodb.js +++ b/spring-batch-core/src/main/resources/org/springframework/batch/core/schema-mongodb.js @@ -10,9 +10,9 @@ db.getCollection("BATCH_SEQUENCES").insertOne({_id: "BATCH_JOB_EXECUTION_SEQ", c db.getCollection("BATCH_SEQUENCES").insertOne({_id: "BATCH_STEP_EXECUTION_SEQ", count: Long(0)}); // INDICES -db.getCollection("BATCH_JOB_INSTANCE").createIndex("job_name_idx", {"jobName": 1}, {}); -db.getCollection("BATCH_JOB_INSTANCE").createIndex("job_name_key_idx", {"jobName": 1, "jobKey": 1}, {}); -db.getCollection("BATCH_JOB_INSTANCE").createIndex("job_instance_idx", {"jobInstanceId": -1}, {}); -db.getCollection("BATCH_JOB_EXECUTION").createIndex("job_instance_idx", {"jobInstanceId": 1}, {}); -db.getCollection("BATCH_JOB_EXECUTION").createIndex("job_instance_idx", {"jobInstanceId": 1, "status": 1}, {}); -db.getCollection("BATCH_STEP_EXECUTION").createIndex("step_execution_idx", {"stepExecutionId": 1}, {}); +db.getCollection("BATCH_JOB_INSTANCE").createIndex( {"jobName": 1}, {"name": "job_name_idx"}); +db.getCollection("BATCH_JOB_INSTANCE").createIndex( {"jobName": 1, "jobKey": 1}, {"name": "job_name_key_idx"}); +db.getCollection("BATCH_JOB_INSTANCE").createIndex( {"jobInstanceId": -1}, {"name": "job_instance_idx"}); +db.getCollection("BATCH_JOB_EXECUTION").createIndex( {"jobInstanceId": 1}, {"name": "job_instance_idx"}); +db.getCollection("BATCH_JOB_EXECUTION").createIndex( {"jobInstanceId": 1, "status": 1}, {"name": "job_instance_status_idx"}); +db.getCollection("BATCH_STEP_EXECUTION").createIndex( {"stepExecutionId": 1}, {"name": "step_execution_idx"}); diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/DefaultJobKeyGeneratorTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/DefaultJobKeyGeneratorTests.java index 74e280f1ef..f5e9983011 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/DefaultJobKeyGeneratorTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/DefaultJobKeyGeneratorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2022 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. @@ -65,4 +65,22 @@ void testCreateJobKeyOrdering() { assertEquals(key1, key2); } + @Test + public void testCreateJobKeyForEmptyParameters() { + JobParameters jobParameters1 = new JobParameters(); + JobParameters jobParameters2 = new JobParameters(); + String key1 = jobKeyGenerator.generateKey(jobParameters1); + String key2 = jobKeyGenerator.generateKey(jobParameters2); + assertEquals(key1, key2); + } + + @Test + public void testCreateJobKeyForEmptyParametersAndNonIdentifying() { + JobParameters jobParameters1 = new JobParameters(); + JobParameters jobParameters2 = new JobParametersBuilder().addString("name", "foo", false).toJobParameters(); + String key1 = jobKeyGenerator.generateKey(jobParameters1); + String key2 = jobKeyGenerator.generateKey(jobParameters2); + assertEquals(key1, key2); + } + } diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBIntegrationTestConfiguration.java b/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBIntegrationTestConfiguration.java index 015a90e034..31ea7439dd 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBIntegrationTestConfiguration.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBIntegrationTestConfiguration.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. @@ -15,8 +15,6 @@ */ package org.springframework.batch.core.repository.support; -import com.mongodb.client.MongoClient; -import com.mongodb.client.MongoClients; import org.springframework.batch.core.Job; import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; import org.springframework.batch.core.explore.JobExplorer; @@ -63,8 +61,7 @@ public JobExplorer jobExplorer(MongoTemplate mongoTemplate, MongoTransactionMana @Bean public MongoDatabaseFactory mongoDatabaseFactory(@Value("${mongo.connectionString}") String connectionString) { - MongoClient mongoClient = MongoClients.create(connectionString); - return new SimpleMongoClientDatabaseFactory(mongoClient, "test"); + return new SimpleMongoClientDatabaseFactory(connectionString + "/test"); } @Bean diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBJobExplorerIntegrationTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBJobExplorerIntegrationTests.java index a6ed1c9bb9..f47c731990 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBJobExplorerIntegrationTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBJobExplorerIntegrationTests.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. @@ -30,6 +30,7 @@ import org.springframework.batch.core.launch.JobLauncher; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -45,6 +46,7 @@ /** * @author Henning Pöttker */ +@DirtiesContext @Testcontainers(disabledWithoutDocker = true) @SpringJUnitConfig(MongoDBIntegrationTestConfiguration.class) public class MongoDBJobExplorerIntegrationTests { diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBJobRepositoryIntegrationTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBJobRepositoryIntegrationTests.java index b45aa7bd19..b70b80281c 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBJobRepositoryIntegrationTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoDBJobRepositoryIntegrationTests.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. @@ -23,6 +23,9 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.index.Index; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -43,6 +46,7 @@ /** * @author Mahmoud Ben Hassine */ +@DirtiesContext @Testcontainers(disabledWithoutDocker = true) @SpringJUnitConfig(MongoDBIntegrationTestConfiguration.class) public class MongoDBJobRepositoryIntegrationTests { @@ -62,9 +66,11 @@ static void setMongoDbConnectionString(DynamicPropertyRegistry registry) { @BeforeEach public void setUp() { + // collections mongoTemplate.createCollection("BATCH_JOB_INSTANCE"); mongoTemplate.createCollection("BATCH_JOB_EXECUTION"); mongoTemplate.createCollection("BATCH_STEP_EXECUTION"); + // sequences mongoTemplate.createCollection("BATCH_SEQUENCES"); mongoTemplate.getCollection("BATCH_SEQUENCES") .insertOne(new Document(Map.of("_id", "BATCH_JOB_INSTANCE_SEQ", "count", 0L))); @@ -72,6 +78,23 @@ public void setUp() { .insertOne(new Document(Map.of("_id", "BATCH_JOB_EXECUTION_SEQ", "count", 0L))); mongoTemplate.getCollection("BATCH_SEQUENCES") .insertOne(new Document(Map.of("_id", "BATCH_STEP_EXECUTION_SEQ", "count", 0L))); + // indices + mongoTemplate.indexOps("BATCH_JOB_INSTANCE") + .ensureIndex(new Index().on("jobName", Sort.Direction.ASC).named("job_name_idx")); + mongoTemplate.indexOps("BATCH_JOB_INSTANCE") + .ensureIndex(new Index().on("jobName", Sort.Direction.ASC) + .on("jobKey", Sort.Direction.ASC) + .named("job_name_key_idx")); + mongoTemplate.indexOps("BATCH_JOB_INSTANCE") + .ensureIndex(new Index().on("jobInstanceId", Sort.Direction.DESC).named("job_instance_idx")); + mongoTemplate.indexOps("BATCH_JOB_EXECUTION") + .ensureIndex(new Index().on("jobInstanceId", Sort.Direction.ASC).named("job_instance_idx")); + mongoTemplate.indexOps("BATCH_JOB_EXECUTION") + .ensureIndex(new Index().on("jobInstanceId", Sort.Direction.ASC) + .on("status", Sort.Direction.ASC) + .named("job_instance_status_idx")); + mongoTemplate.indexOps("BATCH_STEP_EXECUTION") + .ensureIndex(new Index().on("stepExecutionId", Sort.Direction.ASC).named("step_execution_idx")); } @Test diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoExecutionContextDaoIntegrationTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoExecutionContextDaoIntegrationTests.java index 7b71ca8505..a04795928f 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoExecutionContextDaoIntegrationTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/repository/support/MongoExecutionContextDaoIntegrationTests.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. @@ -36,6 +36,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.mongodb.core.MongoOperations; import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -51,6 +52,7 @@ /** * @author Henning Pöttker */ +@DirtiesContext @Testcontainers(disabledWithoutDocker = true) @SpringJUnitConfig({ MongoDBIntegrationTestConfiguration.class, ExecutionContextDaoConfiguration.class }) public class MongoExecutionContextDaoIntegrationTests { diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/step/item/TaskletStepExceptionTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/step/item/TaskletStepExceptionTests.java index 5bc1fc695f..d4a01e8fc9 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/step/item/TaskletStepExceptionTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/step/item/TaskletStepExceptionTests.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. @@ -63,6 +63,7 @@ * @author David Turanski * @author Mahmoud Ben Hassine * @author Parikshit Dutta + * @author Elimelec Burghelea */ class TaskletStepExceptionTests { @@ -212,8 +213,8 @@ public void close() throws ItemStreamException { taskletStep.execute(stepExecution); assertEquals(FAILED, stepExecution.getStatus()); - assertTrue(stepExecution.getFailureExceptions().contains(taskletException)); - assertTrue(stepExecution.getFailureExceptions().contains(exception)); + assertEquals(stepExecution.getFailureExceptions().get(0), taskletException); + assertEquals(stepExecution.getFailureExceptions().get(1).getSuppressed()[0], exception); assertEquals(2, jobRepository.getUpdateCount()); } diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/step/tasklet/TaskletStepTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/step/tasklet/TaskletStepTests.java index ef6e917b70..e429a30e42 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/step/tasklet/TaskletStepTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/step/tasklet/TaskletStepTests.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. @@ -761,7 +761,7 @@ public void close() throws ItemStreamException { Throwable ex = stepExecution.getFailureExceptions().get(0); // The original rollback was caused by this one: - assertEquals("Bar", ex.getMessage()); + assertEquals("Bar", ex.getSuppressed()[0].getMessage()); } @Test @@ -791,7 +791,7 @@ public void close() throws ItemStreamException { assertEquals("", msg); Throwable ex = stepExecution.getFailureExceptions().get(0); // The original rollback was caused by this one: - assertEquals("Bar", ex.getMessage()); + assertEquals("Bar", ex.getSuppressed()[0].getMessage()); } /** diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/test/repository/Db2JobRepositoryIntegrationTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/test/repository/Db2JobRepositoryIntegrationTests.java index 22b6d109bb..4f7e9041c1 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/test/repository/Db2JobRepositoryIntegrationTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/test/repository/Db2JobRepositoryIntegrationTests.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. @@ -57,7 +57,7 @@ class Db2JobRepositoryIntegrationTests { // TODO find the best way to externalize and manage image versions - private static final DockerImageName DB2_IMAGE = DockerImageName.parse("ibmcom/db2:11.5.5.1"); + private static final DockerImageName DB2_IMAGE = DockerImageName.parse("icr.io/db2_community/db2:11.5.9.0"); @Container public static Db2Container db2 = new Db2Container(DB2_IMAGE).acceptLicense(); diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/test/step/FaultTolerantStepFactoryBeanIntegrationTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/test/step/FaultTolerantStepFactoryBeanIntegrationTests.java index 67d24fcefa..704c3fc22c 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/test/step/FaultTolerantStepFactoryBeanIntegrationTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/test/step/FaultTolerantStepFactoryBeanIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2010-2024 the original author or authors. + * Copyright 2010-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,17 +16,15 @@ package org.springframework.batch.core.test.step; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.List; -import javax.sql.DataSource; - import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.springframework.batch.core.BatchStatus; import org.springframework.batch.core.JobExecution; @@ -39,8 +37,8 @@ import org.springframework.batch.item.ItemProcessor; import org.springframework.batch.item.ItemReader; import org.springframework.batch.item.ItemWriter; -import org.springframework.batch.item.ParseException; -import org.springframework.batch.item.UnexpectedInputException; +import org.springframework.batch.item.support.ListItemReader; +import org.springframework.batch.item.support.SynchronizedItemReader; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.lang.Nullable; @@ -48,9 +46,9 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.jdbc.JdbcTestUtils; import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.util.Assert; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Timeout.ThreadMode.SEPARATE_THREAD; /** * Tests for {@link FaultTolerantStepFactoryBean}. @@ -69,12 +67,8 @@ class FaultTolerantStepFactoryBeanIntegrationTests { private SkipWriterStub writer; - private JobExecution jobExecution; - - private StepExecution stepExecution; - @Autowired - private DataSource dataSource; + private JdbcTemplate jdbcTemplate; @Autowired private JobRepository repository; @@ -85,8 +79,8 @@ class FaultTolerantStepFactoryBeanIntegrationTests { @BeforeEach void setUp() { - writer = new SkipWriterStub(dataSource); - processor = new SkipProcessorStub(dataSource); + writer = new SkipWriterStub(jdbcTemplate); + processor = new SkipProcessorStub(jdbcTemplate); factory = new FaultTolerantStepFactoryBean<>(); @@ -101,14 +95,12 @@ void setUp() { taskExecutor.afterPropertiesSet(); factory.setTaskExecutor(taskExecutor); - JdbcTestUtils.deleteFromTables(new JdbcTemplate(dataSource), "ERROR_LOG"); + JdbcTestUtils.deleteFromTables(jdbcTemplate, "ERROR_LOG"); } @Test - void testUpdatesNoRollback() throws Exception { - - JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + void testUpdatesNoRollback() { writer.write(Chunk.of("foo", "bar")); processor.process("spam"); @@ -121,17 +113,15 @@ void testUpdatesNoRollback() throws Exception { } @Test + @Timeout(value = 30, threadMode = SEPARATE_THREAD) void testMultithreadedSunnyDay() throws Throwable { - jobExecution = repository.createJobExecution("vanillaJob", new JobParameters()); + JobExecution jobExecution = repository.createJobExecution("vanillaJob", new JobParameters()); for (int i = 0; i < MAX_COUNT; i++) { - JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); - - SkipReaderStub reader = new SkipReaderStub(); - reader.clear(); - reader.setItems("1", "2", "3", "4", "5"); + ItemReader reader = new SynchronizedItemReader<>( + new ListItemReader<>(List.of("1", "2", "3", "4", "5"))); factory.setItemReader(reader); writer.clear(); factory.setItemWriter(writer); @@ -144,7 +134,7 @@ void testMultithreadedSunnyDay() throws Throwable { Step step = factory.getObject(); - stepExecution = jobExecution.createStepExecution(factory.getName()); + StepExecution stepExecution = jobExecution.createStepExecution(factory.getName()); repository.add(stepExecution); step.execute(stepExecution); assertEquals(BatchStatus.COMPLETED, stepExecution.getStatus()); @@ -167,48 +157,12 @@ void testMultithreadedSunnyDay() throws Throwable { } - private static class SkipReaderStub implements ItemReader { - - private String[] items; - - private int counter = -1; - - public SkipReaderStub() throws Exception { - super(); - } - - public void setItems(String... items) { - Assert.isTrue(counter < 0, "Items cannot be set once reading has started"); - this.items = items; - } - - public void clear() { - counter = -1; - } - - @Nullable - @Override - public synchronized String read() throws Exception, UnexpectedInputException, ParseException { - counter++; - if (counter >= items.length) { - return null; - } - String item = items[counter]; - return item; - } - - } - private static class SkipWriterStub implements ItemWriter { - private final List written = new ArrayList<>(); - - private final Collection failures = Collections.emptySet(); - private final JdbcTemplate jdbcTemplate; - public SkipWriterStub(DataSource dataSource) { - jdbcTemplate = new JdbcTemplate(dataSource); + public SkipWriterStub(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; } public List getCommitted() { @@ -217,22 +171,13 @@ public List getCommitted() { } public void clear() { - written.clear(); JdbcTestUtils.deleteFromTableWhere(jdbcTemplate, "ERROR_LOG", "STEP_NAME='written'"); } @Override - public void write(Chunk items) throws Exception { + public void write(Chunk items) { for (String item : items) { - written.add(item); jdbcTemplate.update("INSERT INTO ERROR_LOG (MESSAGE, STEP_NAME) VALUES (?, ?)", item, "written"); - checkFailure(item); - } - } - - private void checkFailure(String item) { - if (failures.contains(item)) { - throw new RuntimeException("Planned failure"); } } @@ -242,12 +187,10 @@ private static class SkipProcessorStub implements ItemProcessor private final Log logger = LogFactory.getLog(getClass()); - private final List processed = new ArrayList<>(); - private final JdbcTemplate jdbcTemplate; - public SkipProcessorStub(DataSource dataSource) { - jdbcTemplate = new JdbcTemplate(dataSource); + public SkipProcessorStub(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; } public List getCommitted() { @@ -256,14 +199,12 @@ public List getCommitted() { } public void clear() { - processed.clear(); JdbcTestUtils.deleteFromTableWhere(jdbcTemplate, "ERROR_LOG", "STEP_NAME='processed'"); } @Nullable @Override - public String process(String item) throws Exception { - processed.add(item); + public String process(String item) { logger.debug("Processed item: " + item); jdbcTemplate.update("INSERT INTO ERROR_LOG (MESSAGE, STEP_NAME) VALUES (?, ?)", item, "processed"); return item; diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/test/step/FaultTolerantStepFactoryBeanRollbackIntegrationTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/test/step/FaultTolerantStepFactoryBeanRollbackIntegrationTests.java index 6eb416fa06..e17332dac9 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/test/step/FaultTolerantStepFactoryBeanRollbackIntegrationTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/test/step/FaultTolerantStepFactoryBeanRollbackIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2010-2023 the original author or authors. + * Copyright 2010-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. @@ -19,16 +19,15 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; -import javax.sql.DataSource; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.springframework.batch.core.BatchStatus; import org.springframework.batch.core.JobExecution; @@ -41,8 +40,8 @@ import org.springframework.batch.item.ItemProcessor; import org.springframework.batch.item.ItemReader; import org.springframework.batch.item.ItemWriter; -import org.springframework.batch.item.ParseException; -import org.springframework.batch.item.UnexpectedInputException; +import org.springframework.batch.item.support.ListItemReader; +import org.springframework.batch.item.support.SynchronizedItemReader; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.lang.Nullable; @@ -50,7 +49,6 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.jdbc.JdbcTestUtils; import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.util.Assert; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -70,12 +68,8 @@ class FaultTolerantStepFactoryBeanRollbackIntegrationTests { private SkipWriterStub writer; - private JobExecution jobExecution; - - private StepExecution stepExecution; - @Autowired - private DataSource dataSource; + private JdbcTemplate jdbcTemplate; @Autowired private JobRepository repository; @@ -86,8 +80,8 @@ class FaultTolerantStepFactoryBeanRollbackIntegrationTests { @BeforeEach void setUp() { - writer = new SkipWriterStub(dataSource); - processor = new SkipProcessorStub(dataSource); + writer = new SkipWriterStub(jdbcTemplate, "1", "2", "3", "4", "5"); + processor = new SkipProcessorStub(jdbcTemplate); factory = new FaultTolerantStepFactoryBean<>(); @@ -97,14 +91,12 @@ void setUp() { factory.setCommitInterval(3); factory.setSkipLimit(10); - JdbcTestUtils.deleteFromTables(new JdbcTemplate(dataSource), "ERROR_LOG"); + JdbcTestUtils.deleteFromTables(jdbcTemplate, "ERROR_LOG"); } @Test - void testUpdatesNoRollback() throws Exception { - - JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + void testUpdatesNoRollback() { writer.write(Chunk.of("foo", "bar")); processor.process("spam"); @@ -117,6 +109,7 @@ void testUpdatesNoRollback() throws Exception { } @Test + @Timeout(value = 30) void testMultithreadedSkipInWriter() throws Throwable { ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor(); @@ -126,11 +119,9 @@ void testMultithreadedSkipInWriter() throws Throwable { taskExecutor.afterPropertiesSet(); factory.setTaskExecutor(taskExecutor); - @SuppressWarnings("unchecked") - Map, Boolean> skippable = getExceptionMap(Exception.class); - factory.setSkippableExceptionClasses(skippable); + factory.setSkippableExceptionClasses(Map.of(Exception.class, true)); - jobExecution = repository.createJobExecution("skipJob", new JobParameters()); + JobExecution jobExecution = repository.createJobExecution("skipJob", new JobParameters()); for (int i = 0; i < MAX_COUNT; i++) { @@ -138,25 +129,21 @@ void testMultithreadedSkipInWriter() throws Throwable { logger.info("Starting step: " + i); } - JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); assertEquals(0, JdbcTestUtils.countRowsInTable(jdbcTemplate, "ERROR_LOG")); try { - SkipReaderStub reader = new SkipReaderStub(); - reader.clear(); - reader.setItems("1", "2", "3", "4", "5"); + ItemReader reader = new SynchronizedItemReader<>( + new ListItemReader<>(List.of("1", "2", "3", "4", "5"))); factory.setItemReader(reader); writer.clear(); factory.setItemWriter(writer); processor.clear(); factory.setItemProcessor(processor); - writer.setFailures("1", "2", "3", "4", "5"); - Step step = factory.getObject(); - stepExecution = jobExecution.createStepExecution(factory.getName()); + StepExecution stepExecution = jobExecution.createStepExecution(factory.getName()); repository.add(stepExecution); step.execute(stepExecution); assertEquals(BatchStatus.COMPLETED, stepExecution.getStatus()); @@ -178,61 +165,15 @@ void testMultithreadedSkipInWriter() throws Throwable { } - @SuppressWarnings("unchecked") - private Map, Boolean> getExceptionMap(Class... args) { - Map, Boolean> map = new HashMap<>(); - for (Class arg : args) { - map.put(arg, true); - } - return map; - } - - private static class SkipReaderStub implements ItemReader { - - private String[] items; - - private int counter = -1; - - public SkipReaderStub() throws Exception { - super(); - } - - public void setItems(String... items) { - Assert.isTrue(counter < 0, "Items cannot be set once reading has started"); - this.items = items; - } - - public void clear() { - counter = -1; - } - - @Nullable - @Override - public synchronized String read() throws Exception, UnexpectedInputException, ParseException { - counter++; - if (counter >= items.length) { - return null; - } - String item = items[counter]; - return item; - } - - } - private static class SkipWriterStub implements ItemWriter { - private final List written = new CopyOnWriteArrayList<>(); - - private Collection failures = Collections.emptySet(); + private final Collection failures; private final JdbcTemplate jdbcTemplate; - public SkipWriterStub(DataSource dataSource) { - jdbcTemplate = new JdbcTemplate(dataSource); - } - - public void setFailures(String... failures) { + public SkipWriterStub(JdbcTemplate jdbcTemplate, String... failures) { this.failures = Arrays.asList(failures); + this.jdbcTemplate = jdbcTemplate; } public List getCommitted() { @@ -241,14 +182,12 @@ public List getCommitted() { } public void clear() { - written.clear(); JdbcTestUtils.deleteFromTableWhere(jdbcTemplate, "ERROR_LOG", "STEP_NAME='written'"); } @Override - public void write(Chunk items) throws Exception { + public void write(Chunk items) { for (String item : items) { - written.add(item); jdbcTemplate.update("INSERT INTO ERROR_LOG (MESSAGE, STEP_NAME) VALUES (?, ?)", item, "written"); checkFailure(item); } @@ -270,8 +209,8 @@ private static class SkipProcessorStub implements ItemProcessor private final JdbcTemplate jdbcTemplate; - public SkipProcessorStub(DataSource dataSource) { - jdbcTemplate = new JdbcTemplate(dataSource); + public SkipProcessorStub(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; } /** @@ -293,7 +232,7 @@ public void clear() { @Nullable @Override - public String process(String item) throws Exception { + public String process(String item) { processed.add(item); logger.debug("Processed item: " + item); jdbcTemplate.update("INSERT INTO ERROR_LOG (MESSAGE, STEP_NAME) VALUES (?, ?)", item, "processed"); diff --git a/spring-batch-docs/modules/ROOT/pages/common-patterns.adoc b/spring-batch-docs/modules/ROOT/pages/common-patterns.adoc index d5442d1ddb..5e25cddbb7 100644 --- a/spring-batch-docs/modules/ROOT/pages/common-patterns.adoc +++ b/spring-batch-docs/modules/ROOT/pages/common-patterns.adoc @@ -686,7 +686,7 @@ the class definition for `NoWorkFoundStepExecutionListener`: [source, java] ---- -public class NoWorkFoundStepExecutionListener extends StepExecutionListenerSupport { +public class NoWorkFoundStepExecutionListener implements StepExecutionListener { public ExitStatus afterStep(StepExecution stepExecution) { if (stepExecution.getReadCount() == 0) { diff --git a/spring-batch-docs/modules/ROOT/pages/step/chunk-oriented-processing/intercepting-execution.adoc b/spring-batch-docs/modules/ROOT/pages/step/chunk-oriented-processing/intercepting-execution.adoc index 023dcaee4f..d07884516a 100644 --- a/spring-batch-docs/modules/ROOT/pages/step/chunk-oriented-processing/intercepting-execution.adoc +++ b/spring-batch-docs/modules/ROOT/pages/step/chunk-oriented-processing/intercepting-execution.adoc @@ -89,7 +89,7 @@ public interface StepExecutionListener extends StepListener { } ---- -`ExitStatus` has a return type of `afterStep`, to give listeners the chance to +`afterStep` has a return type of `ExitStatus`, to give listeners the chance to modify the exit code that is returned upon completion of a `Step`. The annotations corresponding to this interface are: diff --git a/spring-batch-docs/modules/ROOT/pages/testing.adoc b/spring-batch-docs/modules/ROOT/pages/testing.adoc index f6b3d7e523..7030be5005 100644 --- a/spring-batch-docs/modules/ROOT/pages/testing.adoc +++ b/spring-batch-docs/modules/ROOT/pages/testing.adoc @@ -274,26 +274,6 @@ int count = StepScopeTestUtils.doInStepScope(stepExecution, }); ---- -[[validatingOutputFiles]] -== Validating Output Files - -When a batch job writes to the database, it is easy to query the database to verify that -the output is as expected. However, if the batch job writes to a file, it is equally -important that the output be verified. Spring Batch provides a class called `AssertFile` -to facilitate the verification of output files. The method called `assertFileEquals` takes -two `File` objects (or two `Resource` objects) and asserts, line by line, that the two -files have the same content. Therefore, it is possible to create a file with the expected -output and to compare it to the actual result, as the following example shows: - -[source, java] ----- -private static final String EXPECTED_FILE = "src/main/resources/data/input.txt"; -private static final String OUTPUT_FILE = "target/test-outputs/output.txt"; - -AssertFile.assertFileEquals(new FileSystemResource(EXPECTED_FILE), - new FileSystemResource(OUTPUT_FILE)); ----- - [[mockingDomainObjects]] == Mocking Domain Objects @@ -303,7 +283,7 @@ the following code snippet shows: [source, java] ---- -public class NoWorkFoundStepExecutionListener extends StepExecutionListenerSupport { +public class NoWorkFoundStepExecutionListener implements StepExecutionListener { public ExitStatus afterStep(StepExecution stepExecution) { if (stepExecution.getReadCount() == 0) { diff --git a/spring-batch-docs/pom.xml b/spring-batch-docs/pom.xml index ccaeb1ae84..30afe8f50b 100644 --- a/spring-batch-docs/pom.xml +++ b/spring-batch-docs/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.1 + 5.2.2 spring-batch-docs Spring Batch Docs diff --git a/spring-batch-infrastructure/pom.xml b/spring-batch-infrastructure/pom.xml index fc411fe428..3abe16ba46 100644 --- a/spring-batch-infrastructure/pom.xml +++ b/spring-batch-infrastructure/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.1 + 5.2.2 spring-batch-infrastructure jar @@ -529,6 +529,30 @@ ${angus-mail.version} test + + org.apache.groovy + groovy-jsr223 + ${groovy-jsr223.version} + test + + + org.openjdk.nashorn + nashorn-core + ${nashorn.version} + test + + + org.apache-extras.beanshell + bsh + ${beanshell.version} + test + + + org.jruby + jruby + ${jruby.version} + test + diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/infrastructure/aot/InfrastructureRuntimeHints.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/infrastructure/aot/InfrastructureRuntimeHints.java new file mode 100644 index 0000000000..8d67cefb0f --- /dev/null +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/infrastructure/aot/InfrastructureRuntimeHints.java @@ -0,0 +1,104 @@ +/* + * 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.infrastructure.aot; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.batch.item.ItemStreamSupport; +import org.springframework.batch.item.amqp.AmqpItemReader; +import org.springframework.batch.item.amqp.AmqpItemWriter; +import org.springframework.batch.item.amqp.builder.AmqpItemReaderBuilder; +import org.springframework.batch.item.amqp.builder.AmqpItemWriterBuilder; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.JpaCursorItemReader; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.batch.item.file.FlatFileItemReader; +import org.springframework.batch.item.file.FlatFileItemWriter; +import org.springframework.batch.item.file.builder.FlatFileItemReaderBuilder; +import org.springframework.batch.item.file.builder.FlatFileItemWriterBuilder; +import org.springframework.batch.item.jms.JmsItemReader; +import org.springframework.batch.item.jms.JmsItemWriter; +import org.springframework.batch.item.jms.builder.JmsItemReaderBuilder; +import org.springframework.batch.item.jms.builder.JmsItemWriterBuilder; +import org.springframework.batch.item.json.JsonFileItemWriter; +import org.springframework.batch.item.json.JsonItemReader; +import org.springframework.batch.item.json.builder.JsonFileItemWriterBuilder; +import org.springframework.batch.item.json.builder.JsonItemReaderBuilder; +import org.springframework.batch.item.queue.BlockingQueueItemReader; +import org.springframework.batch.item.queue.BlockingQueueItemWriter; +import org.springframework.batch.item.queue.builder.BlockingQueueItemReaderBuilder; +import org.springframework.batch.item.queue.builder.BlockingQueueItemWriterBuilder; +import org.springframework.batch.item.support.AbstractFileItemWriter; +import org.springframework.batch.item.support.AbstractItemCountingItemStreamItemReader; +import org.springframework.batch.item.support.AbstractItemStreamItemReader; +import org.springframework.batch.item.support.AbstractItemStreamItemWriter; +import org.springframework.batch.item.xml.StaxEventItemReader; +import org.springframework.batch.item.xml.StaxEventItemWriter; +import org.springframework.batch.item.xml.builder.StaxEventItemReaderBuilder; +import org.springframework.batch.item.xml.builder.StaxEventItemWriterBuilder; + +import java.util.Set; + +/** + * {@link RuntimeHintsRegistrar} for Spring Batch infrastructure module. + * + * @author Mahmoud Ben Hassine + * @since 5.2.2 + */ +public class InfrastructureRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + // reflection hints + Set> classes = Set.of( + // File IO APIs + FlatFileItemReader.class, FlatFileItemReaderBuilder.class, FlatFileItemWriter.class, + FlatFileItemWriterBuilder.class, JsonItemReader.class, JsonItemReaderBuilder.class, + JsonFileItemWriter.class, JsonFileItemWriterBuilder.class, StaxEventItemReader.class, + StaxEventItemReaderBuilder.class, StaxEventItemWriter.class, StaxEventItemWriterBuilder.class, + + // Database IO APIs + JdbcCursorItemReader.class, JdbcCursorItemReaderBuilder.class, JdbcPagingItemReader.class, + JdbcPagingItemReaderBuilder.class, JdbcBatchItemWriter.class, JdbcBatchItemWriterBuilder.class, + JpaCursorItemReader.class, JpaCursorItemReaderBuilder.class, JpaPagingItemReader.class, + JpaPagingItemReaderBuilder.class, JpaItemWriter.class, JpaItemWriterBuilder.class, + + // Queue IO APIs + BlockingQueueItemReader.class, BlockingQueueItemReaderBuilder.class, BlockingQueueItemWriter.class, + BlockingQueueItemWriterBuilder.class, JmsItemReader.class, JmsItemReaderBuilder.class, + JmsItemWriter.class, JmsItemWriterBuilder.class, AmqpItemReader.class, AmqpItemReaderBuilder.class, + AmqpItemWriter.class, AmqpItemWriterBuilder.class, + + // Support classes + AbstractFileItemWriter.class, AbstractItemStreamItemWriter.class, + AbstractItemCountingItemStreamItemReader.class, AbstractItemStreamItemReader.class, + ItemStreamSupport.class); + for (Class type : classes) { + hints.reflection().registerType(type, MemberCategory.values()); + } + } + +} diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/ExecutionContext.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/ExecutionContext.java index 71e56ce4d5..8f000c4656 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/ExecutionContext.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/ExecutionContext.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. @@ -37,6 +37,7 @@ * @author Lucas Ward * @author Douglas Kaminsky * @author Mahmoud Ben Hassine + * @author Seokmun Heo */ public class ExecutionContext implements Serializable { @@ -124,19 +125,21 @@ public void putDouble(String key, double value) { public void put(String key, @Nullable Object value) { if (value != null) { Object result = this.map.put(key, value); - this.dirty = result == null || !result.equals(value); + this.dirty = this.dirty || result == null || !result.equals(value); } else { Object result = this.map.remove(key); - this.dirty = result != null; + this.dirty = this.dirty || result != null; } } /** * Indicates if context has been changed with a "put" operation since the dirty flag * was last cleared. Note that the last time the flag was cleared might correspond to - * creation of the context. - * @return True if "put" operation has occurred since flag was last cleared + * creation of the context. A context is only dirty if a new value is put or an old + * one is removed. + * @return True if a new value was put or an old one was removed since the last time + * the flag was cleared */ public boolean isDirty() { return this.dirty; diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/MultiResourceItemWriter.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/MultiResourceItemWriter.java index 835abb3527..d07cbda99d 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/MultiResourceItemWriter.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/MultiResourceItemWriter.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. @@ -34,9 +34,6 @@ * {@link #setItemCountLimitPerResource(int)}. Suffix creation can be customized with * {@link #setResourceSuffixCreator(ResourceSuffixCreator)}. *

- * Note that new resources are created only at chunk boundaries i.e. the number of items - * written into one resource is between the limit set by - *

* This writer will create an output file only when there are items to write, which means * there would be no empty file created if no items are passed (for example when all items * are filtered or skipped during the processing phase). @@ -45,6 +42,7 @@ * @param item type * @author Robert Kasanicky * @author Mahmoud Ben Hassine + * @author Henning Pöttker */ public class MultiResourceItemWriter extends AbstractItemStreamItemWriter { @@ -74,22 +72,30 @@ public MultiResourceItemWriter() { @Override public void write(Chunk items) throws Exception { - if (!opened) { - File file = setResourceToDelegate(); - // create only if write is called - file.createNewFile(); - Assert.state(file.canWrite(), "Output resource " + file.getAbsolutePath() + " must be writable"); - delegate.open(new ExecutionContext()); - opened = true; - } - delegate.write(items); - currentResourceItemCount += items.size(); - if (currentResourceItemCount >= itemCountLimitPerResource) { - delegate.close(); - resourceIndex++; - currentResourceItemCount = 0; - setResourceToDelegate(); - opened = false; + int writtenItems = 0; + while (writtenItems < items.size()) { + if (!opened) { + File file = setResourceToDelegate(); + // create only if write is called + file.createNewFile(); + Assert.state(file.canWrite(), "Output resource " + file.getAbsolutePath() + " must be writable"); + delegate.open(new ExecutionContext()); + opened = true; + } + + int itemsToWrite = Math.min(itemCountLimitPerResource - currentResourceItemCount, + items.size() - writtenItems); + delegate.write(new Chunk(items.getItems().subList(writtenItems, writtenItems + itemsToWrite))); + currentResourceItemCount += itemsToWrite; + writtenItems += itemsToWrite; + + if (currentResourceItemCount >= itemCountLimitPerResource) { + delegate.close(); + resourceIndex++; + currentResourceItemCount = 0; + setResourceToDelegate(); + opened = false; + } } } diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilder.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilder.java index 30233fd89c..7de7de5301 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilder.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilder.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. @@ -97,7 +97,7 @@ public FlatFileItemWriterBuilder saveState(boolean saveState) { * The name used to calculate the key within the * {@link org.springframework.batch.item.ExecutionContext}. Required if * {@link #saveState(boolean)} is set to true. - * @param name name of the reader instance + * @param name name of the writer instance * @return The current instance of the builder. * @see org.springframework.batch.item.ItemStreamSupport#setName(String) */ diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/AbstractFileItemWriter.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/AbstractFileItemWriter.java index 0396ca8cc7..c3d3a00bbb 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/AbstractFileItemWriter.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/AbstractFileItemWriter.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. @@ -25,6 +25,7 @@ import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.nio.charset.UnsupportedCharsetException; +import java.nio.file.Files; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -61,6 +62,7 @@ * @author Mahmoud Ben Hassine * @author Glenn Renfro * @author Remi Kaeffer + * @author Elimelec Burghelea * @since 4.1 */ public abstract class AbstractFileItemWriter extends AbstractItemStreamItemWriter @@ -268,11 +270,9 @@ public void close() { state.close(); if (state.linesWritten == 0 && shouldDeleteIfEmpty) { try { - if (!resource.getFile().delete()) { - throw new ItemStreamException("Failed to delete empty file on close"); - } + Files.delete(resource.getFile().toPath()); } - catch (IOException e) { + catch (IOException | SecurityException e) { throw new ItemStreamException("Failed to delete empty file on close", e); } } diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemReader.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemReader.java index 06148a346c..73a92aa57a 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemReader.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemReader.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. @@ -15,6 +15,7 @@ */ package org.springframework.batch.item.support; +import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -27,6 +28,7 @@ * implementation is not thread-safe. * * @author Mahmoud Ben Hassine + * @author Elimelec Burghelea * @param type of objects to read * @since 5.2 */ @@ -77,10 +79,30 @@ public void update(ExecutionContext executionContext) throws ItemStreamException } } + /** + * Close all delegates. + * @throws ItemStreamException thrown if one of the delegates fails to close. Original + * exceptions thrown by delegates are added as suppressed exceptions into this one, in + * the same order as delegates were registered. + */ @Override public void close() throws ItemStreamException { + List exceptions = new ArrayList<>(); + for (ItemStreamReader delegate : delegates) { - delegate.close(); + try { + delegate.close(); + } + catch (Exception e) { + exceptions.add(e); + } + } + + if (!exceptions.isEmpty()) { + String message = String.format("Failed to close %d delegate(s) due to exceptions", exceptions.size()); + ItemStreamException holder = new ItemStreamException(message); + exceptions.forEach(holder::addSuppressed); + throw holder; } } diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemStream.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemStream.java index e773bf8616..82f55750e8 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemStream.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemStream.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. @@ -28,7 +28,7 @@ * * @author Dave Syer * @author Mahmoud Ben Hassine - * + * @author Elimelec Burghelea */ public class CompositeItemStream implements ItemStream { @@ -102,13 +102,27 @@ public void update(ExecutionContext executionContext) { /** * Broadcast the call to close. * @throws ItemStreamException thrown if one of the {@link ItemStream}s in the list - * fails to close. This is a sequential operation so all itemStreams in the list after - * the one that failed to close will remain open. + * fails to close. Original exceptions thrown by delegates are added as suppressed + * exceptions into this one, in the same order as delegates were registered. */ @Override public void close() throws ItemStreamException { + List exceptions = new ArrayList<>(); + for (ItemStream itemStream : streams) { - itemStream.close(); + try { + itemStream.close(); + } + catch (Exception e) { + exceptions.add(e); + } + } + + if (!exceptions.isEmpty()) { + String message = String.format("Failed to close %d delegate(s) due to exceptions", exceptions.size()); + ItemStreamException holder = new ItemStreamException(message); + exceptions.forEach(holder::addSuppressed); + throw holder; } } diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemWriter.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemWriter.java index d76d5c33e0..730213c965 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemWriter.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/support/CompositeItemWriter.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. @@ -25,6 +25,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.util.Assert; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -37,6 +38,7 @@ * @author Robert Kasanicky * @author Dave Syer * @author Mahmoud Ben Hassine + * @author Elimelec Burghelea */ public class CompositeItemWriter implements ItemStreamWriter, InitializingBean { @@ -103,13 +105,33 @@ public void setDelegates(List> delegates) { this.delegates = delegates; } + /** + * Close all delegates. + * @throws ItemStreamException thrown if one of the delegates fails to close. Original + * exceptions thrown by delegates are added as suppressed exceptions into this one, in + * the same order as delegates were registered. + */ @Override public void close() throws ItemStreamException { + List exceptions = new ArrayList<>(); + for (ItemWriter writer : delegates) { if (!ignoreItemStream && (writer instanceof ItemStream)) { - ((ItemStream) writer).close(); + try { + ((ItemStream) writer).close(); + } + catch (Exception e) { + exceptions.add(e); + } } } + + if (!exceptions.isEmpty()) { + String message = String.format("Failed to close %d delegate(s) due to exceptions", exceptions.size()); + ItemStreamException holder = new ItemStreamException(message); + exceptions.forEach(holder::addSuppressed); + throw holder; + } } @Override diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/util/FileUtils.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/util/FileUtils.java index 1b82ae1634..c14d9470b3 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/util/FileUtils.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/util/FileUtils.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. @@ -18,6 +18,7 @@ import java.io.File; import java.io.IOException; +import java.nio.file.Files; import org.springframework.batch.item.ItemStreamException; import org.springframework.util.Assert; @@ -28,6 +29,7 @@ * @author Peter Zozom * @author Mahmoud Ben Hassine * @author Taeik Lim + * @author Elimelec Burghelea */ public abstract class FileUtils { @@ -57,8 +59,11 @@ public static void setUpOutputFile(File file, boolean restarted, boolean append, if (!overwriteOutputFile) { throw new ItemStreamException("File already exists: [" + file.getAbsolutePath() + "]"); } - if (!file.delete()) { - throw new IOException("Could not delete file: " + file); + try { + Files.delete(file.toPath()); + } + catch (IOException | SecurityException e) { + throw new IOException("Could not delete file: " + file, e); } } diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/xml/StaxEventItemWriter.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/xml/StaxEventItemWriter.java index fef239f809..2c6e803773 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/xml/StaxEventItemWriter.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/xml/StaxEventItemWriter.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. @@ -24,6 +24,7 @@ import java.io.UnsupportedEncodingException; import java.io.Writer; import java.nio.channels.FileChannel; +import java.nio.file.Files; import java.util.Collections; import java.util.List; import java.util.Map; @@ -75,6 +76,7 @@ * @author Michael Minella * @author Parikshit Dutta * @author Mahmoud Ben Hassine + * @author Elimelec Burghelea */ public class StaxEventItemWriter extends AbstractItemStreamItemWriter implements ResourceAwareItemWriterItemStream, InitializingBean { @@ -726,11 +728,9 @@ public void close() { } if (currentRecordCount == 0 && shouldDeleteIfEmpty) { try { - if (!resource.getFile().delete()) { - throw new ItemStreamException("Failed to delete empty file on close"); - } + Files.delete(resource.getFile().toPath()); } - catch (IOException e) { + catch (IOException | SecurityException e) { throw new ItemStreamException("Failed to delete empty file on close", e); } } diff --git a/spring-batch-infrastructure/src/main/resources/META-INF/spring/aot.factories b/spring-batch-infrastructure/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 0000000000..efa2f70c11 --- /dev/null +++ b/spring-batch-infrastructure/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=org.springframework.batch.infrastructure.aot.InfrastructureRuntimeHints diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/ExecutionContextTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/ExecutionContextTests.java index 96e19dfc43..581369b822 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/ExecutionContextTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/ExecutionContextTests.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. @@ -36,7 +36,7 @@ /** * @author Lucas Ward * @author Mahmoud Ben Hassine - * + * @author Seokmun Heo */ class ExecutionContextTests { @@ -94,11 +94,13 @@ void testNotDirtyWithDuplicate() { } @Test - void testNotDirtyWithRemoveMissing() { + void testDirtyWithRemoveMissing() { context.putString("1", "test"); assertTrue(context.isDirty()); context.putString("1", null); // remove an item that was present assertTrue(context.isDirty()); + + context.clearDirtyFlag(); context.putString("1", null); // remove a non-existent item assertFalse(context.isDirty()); } @@ -167,6 +169,15 @@ void testCopyConstructorNullInput() { assertTrue(context.isEmpty()); } + @Test + void testDirtyWithDuplicate() { + ExecutionContext context = new ExecutionContext(); + context.put("1", "testString1"); + assertTrue(context.isDirty()); + context.put("1", "testString1"); // put the same value + assertTrue(context.isDirty()); + } + /** * Value object for testing serialization */ diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/support/Db2PagingQueryProviderIntegrationTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/support/Db2PagingQueryProviderIntegrationTests.java index 19d876b9d1..18353345b6 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/support/Db2PagingQueryProviderIntegrationTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/database/support/Db2PagingQueryProviderIntegrationTests.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. @@ -32,6 +32,7 @@ /** * @author Henning Pöttker + * @author Mahmoud Ben Hassine */ @Testcontainers(disabledWithoutDocker = true) @SpringJUnitConfig @@ -39,7 +40,7 @@ class Db2PagingQueryProviderIntegrationTests extends AbstractPagingQueryProviderIntegrationTests { // TODO find the best way to externalize and manage image versions - private static final DockerImageName DB2_IMAGE = DockerImageName.parse("ibmcom/db2:11.5.5.1"); + private static final DockerImageName DB2_IMAGE = DockerImageName.parse("icr.io/db2_community/db2:11.5.9.0"); @Container public static Db2Container db2 = new Db2Container(DB2_IMAGE).acceptLicense(); diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/MultiResourceItemWriterFlatFileTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/MultiResourceItemWriterFlatFileTests.java index ffe91317e9..ab23affa63 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/MultiResourceItemWriterFlatFileTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/MultiResourceItemWriterFlatFileTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2023 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. @@ -78,22 +78,22 @@ void testBasicMultiResourceWriteScenario() throws Exception { tested.write(Chunk.of("1", "2", "3")); - File part1 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(1)); - assertTrue(part1.exists()); - assertEquals("123", readFile(part1)); + assertFileExistsAndContains(1, "12"); + assertFileExistsAndContains(2, "3"); tested.write(Chunk.of("4")); - File part2 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(2)); - assertTrue(part2.exists()); - assertEquals("4", readFile(part2)); + + assertFileExistsAndContains(2, "34"); tested.write(Chunk.of("5")); - assertEquals("45", readFile(part2)); + + assertFileExistsAndContains(3, "5"); tested.write(Chunk.of("6", "7", "8", "9")); - File part3 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(3)); - assertTrue(part3.exists()); - assertEquals("6789", readFile(part3)); + + assertFileExistsAndContains(3, "56"); + assertFileExistsAndContains(4, "78"); + assertFileExistsAndContains(5, "9"); } @Test @@ -107,7 +107,7 @@ void testUpdateAfterDelegateClose() throws Exception { assertEquals(1, executionContext.getInt(tested.getExecutionContextKey("resource.index"))); tested.write(Chunk.of("1", "2", "3")); tested.update(executionContext); - assertEquals(0, executionContext.getInt(tested.getExecutionContextKey("resource.item.count"))); + assertEquals(1, executionContext.getInt(tested.getExecutionContextKey("resource.item.count"))); assertEquals(2, executionContext.getInt(tested.getExecutionContextKey("resource.index"))); } @@ -121,17 +121,22 @@ void testMultiResourceWriteScenarioWithFooter() throws Exception { tested.write(Chunk.of("1", "2", "3")); - File part1 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(1)); - assertTrue(part1.exists()); + assertFileExistsAndContains(1, "12f"); + assertFileExistsAndContains(2, "3"); tested.write(Chunk.of("4")); - File part2 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(2)); - assertTrue(part2.exists()); + + assertFileExistsAndContains(2, "34f"); + + tested.write(Chunk.of("5")); + + assertFileExistsAndContains(3, "5"); tested.close(); - assertEquals("123f", readFile(part1)); - assertEquals("4f", readFile(part2)); + assertFileExistsAndContains(1, "12f"); + assertFileExistsAndContains(2, "34f"); + assertFileExistsAndContains(3, "5f"); } @@ -144,19 +149,18 @@ void testTransactionalMultiResourceWriteScenarioWithFooter() throws Exception { ResourcelessTransactionManager transactionManager = new ResourcelessTransactionManager(); - new TransactionTemplate(transactionManager).execute(new WriterCallback(Chunk.of("1", "2", "3"))); + new TransactionTemplate(transactionManager).execute(new WriterCallback(Chunk.of("1", "2"))); - File part1 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(1)); - assertTrue(part1.exists()); + assertFileExistsAndContains(1, "12f"); - new TransactionTemplate(transactionManager).execute(new WriterCallback(Chunk.of("4"))); - File part2 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(2)); - assertTrue(part2.exists()); + new TransactionTemplate(transactionManager).execute(new WriterCallback(Chunk.of("3"))); + + assertFileExistsAndContains(2, "3"); tested.close(); - assertEquals("123f", readFile(part1)); - assertEquals("4f", readFile(part2)); + assertFileExistsAndContains(1, "12f"); + assertFileExistsAndContains(2, "3f"); } @@ -168,27 +172,23 @@ void testRestart() throws Exception { tested.write(Chunk.of("1", "2", "3")); - File part1 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(1)); - assertTrue(part1.exists()); - assertEquals("123", readFile(part1)); - - tested.write(Chunk.of("4")); - File part2 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(2)); - assertTrue(part2.exists()); - assertEquals("4", readFile(part2)); + assertFileExistsAndContains(1, "12"); + assertFileExistsAndContains(2, "3"); tested.update(executionContext); tested.close(); tested.open(executionContext); - tested.write(Chunk.of("5")); - assertEquals("45", readFile(part2)); + tested.write(Chunk.of("4")); - tested.write(Chunk.of("6", "7", "8", "9")); - File part3 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(3)); - assertTrue(part3.exists()); - assertEquals("6789", readFile(part3)); + assertFileExistsAndContains(2, "34"); + + tested.write(Chunk.of("5", "6", "7", "8", "9")); + + assertFileExistsAndContains(3, "56"); + assertFileExistsAndContains(4, "78"); + assertFileExistsAndContains(5, "9"); } @Test @@ -201,27 +201,24 @@ void testRestartWithFooter() throws Exception { tested.write(Chunk.of("1", "2", "3")); - File part1 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(1)); - assertTrue(part1.exists()); - assertEquals("123f", readFile(part1)); - - tested.write(Chunk.of("4")); - File part2 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(2)); - assertTrue(part2.exists()); - assertEquals("4", readFile(part2)); + assertFileExistsAndContains(1, "12f"); + assertFileExistsAndContains(2, "3"); tested.update(executionContext); tested.close(); tested.open(executionContext); - tested.write(Chunk.of("5")); - assertEquals("45f", readFile(part2)); + tested.write(Chunk.of("4")); - tested.write(Chunk.of("6", "7", "8", "9")); - File part3 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(3)); - assertTrue(part3.exists()); - assertEquals("6789f", readFile(part3)); + assertFileExistsAndContains(2, "34f"); + + tested.write(Chunk.of("5", "6", "7", "8", "9")); + tested.close(); + + assertFileExistsAndContains(3, "56f"); + assertFileExistsAndContains(4, "78f"); + assertFileExistsAndContains(5, "9f"); } @Test @@ -233,24 +230,28 @@ void testTransactionalRestartWithFooter() throws Exception { ResourcelessTransactionManager transactionManager = new ResourcelessTransactionManager(); - new TransactionTemplate(transactionManager).execute(new WriterCallback(Chunk.of("1", "2", "3"))); + new TransactionTemplate(transactionManager).execute(new WriterCallback(Chunk.of("1", "2"))); - File part1 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(1)); - assertTrue(part1.exists()); - assertEquals("123f", readFile(part1)); + assertFileExistsAndContains(1, "12f"); - new TransactionTemplate(transactionManager).execute(new WriterCallback(Chunk.of("4"))); - File part2 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(2)); - assertTrue(part2.exists()); - assertEquals("4", readFile(part2)); + new TransactionTemplate(transactionManager).execute(new WriterCallback(Chunk.of("3"))); + + assertFileExistsAndContains(2, "3"); tested.update(executionContext); tested.close(); tested.open(executionContext); - new TransactionTemplate(transactionManager).execute(new WriterCallback(Chunk.of("5"))); - assertEquals("45f", readFile(part2)); + new TransactionTemplate(transactionManager).execute(new WriterCallback(Chunk.of("4"))); + + assertFileExistsAndContains(2, "34f"); + } + + private void assertFileExistsAndContains(int index, String expected) throws Exception { + File part = new File(this.file.getAbsolutePath() + this.suffixCreator.getSuffix(index)); + assertTrue(part.exists()); + assertEquals(expected, readFile(part)); } } diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/MultiResourceItemWriterXmlTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/MultiResourceItemWriterXmlTests.java index 38760361c6..f6485adb80 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/MultiResourceItemWriterXmlTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/MultiResourceItemWriterXmlTests.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. @@ -108,21 +108,26 @@ void multiResourceWritingWithRestart() throws Exception { tested.update(executionContext); tested.close(); - assertEquals(xmlDocStart + "" + xmlDocEnd, readFile(part2)); - assertEquals(xmlDocStart + "" + xmlDocEnd, readFile(part1)); + assertEquals(xmlDocStart + "" + xmlDocEnd, readFile(part2)); + assertEquals(xmlDocStart + "" + xmlDocEnd, readFile(part1)); tested.open(executionContext); tested.write(Chunk.of("5")); - - tested.write(Chunk.of("6", "7", "8", "9")); File part3 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(3)); assertTrue(part3.exists()); + tested.write(Chunk.of("6", "7", "8", "9")); + File part4 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(4)); + assertTrue(part4.exists()); + File part5 = new File(file.getAbsolutePath() + suffixCreator.getSuffix(5)); + assertTrue(part5.exists()); + tested.close(); - assertEquals(xmlDocStart + "" + xmlDocEnd, readFile(part2)); - assertEquals(xmlDocStart + "" + xmlDocEnd, readFile(part3)); + assertEquals(xmlDocStart + "" + xmlDocEnd, readFile(part3)); + assertEquals(xmlDocStart + "" + xmlDocEnd, readFile(part4)); + assertEquals(xmlDocStart + "" + xmlDocEnd, readFile(part5)); } } diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/MultiResourceItemWriterBuilderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/MultiResourceItemWriterBuilderTests.java index 6eb75b9ed8..ce1ec6b8f4 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/MultiResourceItemWriterBuilderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/MultiResourceItemWriterBuilderTests.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. @@ -84,22 +84,22 @@ void testBasicMultiResourceWriteScenario() throws Exception { this.writer.write(Chunk.of("1", "2", "3")); - File part1 = new File(this.file.getAbsolutePath() + this.suffixCreator.getSuffix(1)); - assertTrue(part1.exists()); - assertEquals("123", readFile(part1)); + assertFileExistsAndContains(1, "12"); + assertFileExistsAndContains(2, "3"); this.writer.write(Chunk.of("4")); - File part2 = new File(this.file.getAbsolutePath() + this.suffixCreator.getSuffix(2)); - assertTrue(part2.exists()); - assertEquals("4", readFile(part2)); + + assertFileExistsAndContains(2, "34"); this.writer.write(Chunk.of("5")); - assertEquals("45", readFile(part2)); + + assertFileExistsAndContains(3, "5"); this.writer.write(Chunk.of("6", "7", "8", "9")); - File part3 = new File(this.file.getAbsolutePath() + this.suffixCreator.getSuffix(3)); - assertTrue(part3.exists()); - assertEquals("6789", readFile(part3)); + + assertFileExistsAndContains(3, "56"); + assertFileExistsAndContains(4, "78"); + assertFileExistsAndContains(5, "9"); } @Test @@ -117,14 +117,12 @@ void testBasicDefaultSuffixCreator() throws Exception { this.writer.write(Chunk.of("1", "2", "3")); - File part1 = new File(this.file.getAbsolutePath() + simpleResourceSuffixCreator.getSuffix(1)); - assertTrue(part1.exists()); - assertEquals("123", readFile(part1)); + assertFileExistsAndContains(1, "12", simpleResourceSuffixCreator); + assertFileExistsAndContains(2, "3", simpleResourceSuffixCreator); this.writer.write(Chunk.of("4")); - File part2 = new File(this.file.getAbsolutePath() + simpleResourceSuffixCreator.getSuffix(2)); - assertTrue(part2.exists()); - assertEquals("4", readFile(part2)); + + assertFileExistsAndContains(2, "34", simpleResourceSuffixCreator); } @Test @@ -143,7 +141,7 @@ void testUpdateAfterDelegateClose() throws Exception { assertEquals(1, this.executionContext.getInt(this.writer.getExecutionContextKey("resource.index"))); this.writer.write(Chunk.of("1", "2", "3")); this.writer.update(this.executionContext); - assertEquals(0, this.executionContext.getInt(this.writer.getExecutionContextKey("resource.item.count"))); + assertEquals(1, this.executionContext.getInt(this.writer.getExecutionContextKey("resource.item.count"))); assertEquals(2, this.executionContext.getInt(this.writer.getExecutionContextKey("resource.index"))); } @@ -160,26 +158,21 @@ void testRestart() throws Exception { this.writer.write(Chunk.of("1", "2", "3")); - File part1 = new File(this.file.getAbsolutePath() + this.suffixCreator.getSuffix(1)); - assertTrue(part1.exists()); - assertEquals("123", readFile(part1)); - - this.writer.write(Chunk.of("4")); - File part2 = new File(this.file.getAbsolutePath() + this.suffixCreator.getSuffix(2)); - assertTrue(part2.exists()); - assertEquals("4", readFile(part2)); + assertFileExistsAndContains(1, "12"); + assertFileExistsAndContains(2, "3"); this.writer.update(this.executionContext); this.writer.close(); this.writer.open(this.executionContext); - this.writer.write(Chunk.of("5")); - assertEquals("45", readFile(part2)); + this.writer.write(Chunk.of("4")); - this.writer.write(Chunk.of("6", "7", "8", "9")); - File part3 = new File(this.file.getAbsolutePath() + this.suffixCreator.getSuffix(3)); - assertTrue(part3.exists()); - assertEquals("6789", readFile(part3)); + assertFileExistsAndContains(2, "34"); + + this.writer.write(Chunk.of("5", "6", "7", "8")); + + assertFileExistsAndContains(3, "56"); + assertFileExistsAndContains(4, "78"); } @Test @@ -195,26 +188,23 @@ void testRestartNoSaveState() throws Exception { this.writer.write(Chunk.of("1", "2", "3")); - File part1 = new File(this.file.getAbsolutePath() + this.suffixCreator.getSuffix(1)); - assertTrue(part1.exists()); - assertEquals("123", readFile(part1)); - - this.writer.write(Chunk.of("4")); - File part2 = new File(this.file.getAbsolutePath() + this.suffixCreator.getSuffix(2)); - assertTrue(part2.exists()); - assertEquals("4", readFile(part2)); + assertFileExistsAndContains(1, "12"); + assertFileExistsAndContains(2, "3"); this.writer.update(this.executionContext); this.writer.close(); this.writer.open(this.executionContext); - this.writer.write(Chunk.of("5")); - assertEquals("4", readFile(part2)); + this.writer.write(Chunk.of("4")); - this.writer.write(Chunk.of("6", "7", "8", "9")); - File part3 = new File(this.file.getAbsolutePath() + this.suffixCreator.getSuffix(1)); - assertTrue(part3.exists()); - assertEquals("56789", readFile(part3)); + assertFileExistsAndContains(2, "3"); + assertFileExistsAndContains(1, "4"); + + this.writer.write(Chunk.of("5", "6", "7", "8")); + + assertFileExistsAndContains(1, "45"); + assertFileExistsAndContains(2, "67"); + assertFileExistsAndContains(3, "8"); } @Test @@ -265,4 +255,15 @@ private String readFile(File f) throws Exception { return result.toString(); } + private void assertFileExistsAndContains(int index, String expected) throws Exception { + assertFileExistsAndContains(index, expected, this.suffixCreator); + } + + private void assertFileExistsAndContains(int index, String expected, ResourceSuffixCreator suffixCreator) + throws Exception { + File part = new File(this.file.getAbsolutePath() + suffixCreator.getSuffix(index)); + assertTrue(part.exists()); + assertEquals(expected, readFile(part)); + } + } diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/AbstractFileItemWriterTest.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/AbstractFileItemWriterTest.java new file mode 100644 index 0000000000..aacc67e716 --- /dev/null +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/AbstractFileItemWriterTest.java @@ -0,0 +1,75 @@ +/* + * 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.support; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import java.io.File; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamException; +import org.springframework.core.io.FileSystemResource; + +/** + * Tests for common methods from {@link AbstractFileItemWriter}. + * + * @author Elimelec Burghelea + */ +class AbstractFileItemWriterTests { + + @Test + void testFailedFileDeletionThrowsException() { + File outputFile = new File("target/data/output.tmp"); + File mocked = Mockito.spy(outputFile); + + TestFileItemWriter writer = new TestFileItemWriter(); + + writer.setResource(new FileSystemResource(mocked)); + writer.setShouldDeleteIfEmpty(true); + writer.setName(writer.getClass().getSimpleName()); + writer.open(new ExecutionContext()); + + when(mocked.delete()).thenReturn(false); + + ItemStreamException exception = assertThrows(ItemStreamException.class, writer::close, + "Expected exception when file deletion fails"); + + assertEquals("Failed to delete empty file on close", exception.getMessage(), "Wrong exception message"); + assertNotNull(exception.getCause(), "Exception should have a cause"); + } + + private static class TestFileItemWriter extends AbstractFileItemWriter { + + @Override + protected String doWrite(Chunk items) { + return String.join("\n", items); + } + + @Override + public void afterPropertiesSet() { + + } + + } + +} \ No newline at end of file diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemReaderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemReaderTests.java index 3775c4299c..70091a0afc 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemReaderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemReaderTests.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. @@ -17,11 +17,14 @@ import java.util.Arrays; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamException; import org.springframework.batch.item.ItemStreamReader; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -32,6 +35,7 @@ * Test class for {@link CompositeItemReader}. * * @author Mahmoud Ben Hassine + * @author Elimelec Burghelea */ public class CompositeItemReaderTests { @@ -107,4 +111,27 @@ void testCompositeItemReaderClose() { verify(reader2).close(); } + @Test + void testCompositeItemReaderCloseWithDelegateThatThrowsException() { + // given + ItemStreamReader reader1 = mock(); + ItemStreamReader reader2 = mock(); + CompositeItemReader compositeItemReader = new CompositeItemReader<>(Arrays.asList(reader1, reader2)); + + doThrow(new ItemStreamException("A failure")).when(reader1).close(); + + // when + try { + compositeItemReader.close(); + Assertions.fail("Expected an ItemStreamException"); + } + catch (ItemStreamException ignored) { + + } + + // then + verify(reader1).close(); + verify(reader2).close(); + } + } \ No newline at end of file diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemStreamTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemStreamTests.java index 5f1be03821..3861ca0f8d 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemStreamTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemStreamTests.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. @@ -15,19 +15,25 @@ */ package org.springframework.batch.item.support; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + import java.util.ArrayList; import java.util.List; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.springframework.batch.item.ExecutionContext; import org.springframework.batch.item.ItemStream; +import org.springframework.batch.item.ItemStreamException; import org.springframework.batch.item.ItemStreamSupport; -import static org.junit.jupiter.api.Assertions.assertEquals; - /** * @author Dave Syer - * + * @author Elimelec Burghelea */ class CompositeItemStreamTests { @@ -90,6 +96,40 @@ public void close() { assertEquals(1, list.size()); } + @Test + void testClose2Delegates() { + ItemStream reader1 = Mockito.mock(ItemStream.class); + ItemStream reader2 = Mockito.mock(ItemStream.class); + manager.register(reader1); + manager.register(reader2); + + manager.close(); + + verify(reader1, times(1)).close(); + verify(reader2, times(1)).close(); + } + + @Test + void testClose2DelegatesThatThrowsException() { + ItemStream reader1 = Mockito.mock(ItemStream.class); + ItemStream reader2 = Mockito.mock(ItemStream.class); + manager.register(reader1); + manager.register(reader2); + + doThrow(new ItemStreamException("A failure")).when(reader1).close(); + + try { + manager.close(); + Assertions.fail("Expected an ItemStreamException"); + } + catch (ItemStreamException ignored) { + + } + + verify(reader1, times(1)).close(); + verify(reader2, times(1)).close(); + } + @Test void testCloseDoesNotUnregister() { manager.setStreams(new ItemStream[] { new ItemStreamSupport() { diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemWriterTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemWriterTests.java index 8d5d3f7b62..89db324007 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemWriterTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/CompositeItemWriterTests.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. @@ -18,14 +18,18 @@ import java.util.ArrayList; import java.util.List; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamException; import org.springframework.batch.item.ItemStreamWriter; import org.springframework.batch.item.ItemWriter; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; /** * Tests for {@link CompositeItemWriter} @@ -33,6 +37,7 @@ * @author Robert Kasanicky * @author Will Schipp * @author Mahmoud Ben Hassine + * @author Elimelec Burghelea */ class CompositeItemWriterTests { @@ -94,4 +99,36 @@ private void doTestItemStream(boolean expectOpen) throws Exception { itemWriter.write(data); } + @Test + void testCloseWithMultipleDelegate() { + AbstractFileItemWriter delegate1 = mock(); + AbstractFileItemWriter delegate2 = mock(); + CompositeItemWriter itemWriter = new CompositeItemWriter<>(List.of(delegate1, delegate2)); + + itemWriter.close(); + + verify(delegate1).close(); + verify(delegate2).close(); + } + + @Test + void testCloseWithMultipleDelegatesThatThrow() { + AbstractFileItemWriter delegate1 = mock(); + AbstractFileItemWriter delegate2 = mock(); + CompositeItemWriter itemWriter = new CompositeItemWriter<>(List.of(delegate1, delegate2)); + + doThrow(new ItemStreamException("A failure")).when(delegate1).close(); + + try { + itemWriter.close(); + Assertions.fail("Expected an ItemStreamException"); + } + catch (ItemStreamException ignored) { + + } + + verify(delegate1).close(); + verify(delegate2).close(); + } + } diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/ScriptItemProcessorTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/ScriptItemProcessorTests.java index bdbb6205c4..5370d7b74f 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/ScriptItemProcessorTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/support/ScriptItemProcessorTests.java @@ -82,7 +82,7 @@ void testJRubyScriptSourceSimple() throws Exception { assumeTrue(languageExists("jruby")); ScriptItemProcessor scriptItemProcessor = new ScriptItemProcessor<>(); - scriptItemProcessor.setScriptSource("$item.upcase", "jruby"); + scriptItemProcessor.setScriptSource("item.upcase", "jruby"); scriptItemProcessor.afterPropertiesSet(); assertEquals("SS", scriptItemProcessor.process("ss"), "Incorrect transformed value"); @@ -93,7 +93,7 @@ void testJRubyScriptSourceMethod() throws Exception { assumeTrue(languageExists("jruby")); ScriptItemProcessor scriptItemProcessor = new ScriptItemProcessor<>(); - scriptItemProcessor.setScriptSource("def process(item) $item.upcase end \n process($item)", "jruby"); + scriptItemProcessor.setScriptSource("def process(item) item.upcase end \n process(item)", "jruby"); scriptItemProcessor.afterPropertiesSet(); assertEquals("SS", scriptItemProcessor.process("ss"), "Incorrect transformed value"); diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/util/FileUtilsTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/util/FileUtilsTests.java index 311ef986ba..6faae21e61 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/util/FileUtilsTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/util/FileUtilsTests.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. @@ -28,6 +28,7 @@ import org.springframework.util.Assert; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -36,6 +37,7 @@ * Tests for {@link FileUtils} * * @author Robert Kasanicky + * @author Elimelec Burghelea */ class FileUtilsTests { @@ -178,6 +180,43 @@ public boolean exists() { } } + @Test + void testCannotDeleteFile() { + + File file = new File("new file") { + + @Override + public boolean createNewFile() { + return true; + } + + @Override + public boolean exists() { + return true; + } + + @Override + public boolean delete() { + return false; + } + + }; + try { + FileUtils.setUpOutputFile(file, false, false, true); + fail("Expected ItemStreamException because file cannot be deleted"); + } + catch (ItemStreamException ex) { + String message = ex.getMessage(); + assertTrue(message.startsWith("Unable to create file"), "Wrong message: " + message); + assertTrue(ex.getCause() instanceof IOException); + assertTrue(ex.getCause().getMessage().startsWith("Could not delete file"), "Wrong message: " + message); + assertNotNull(ex.getCause().getCause(), "Exception should have a cause"); + } + finally { + file.delete(); + } + } + @BeforeEach void setUp() { file.delete(); diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/xml/StaxEventItemWriterTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/xml/StaxEventItemWriterTests.java index f904c59441..08fada774e 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/xml/StaxEventItemWriterTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/xml/StaxEventItemWriterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2023 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. @@ -30,6 +30,7 @@ import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamException; import org.springframework.batch.item.UnexpectedInputException; import org.springframework.batch.item.WriterNotOpenException; import org.springframework.batch.support.transaction.ResourcelessTransactionManager; @@ -47,9 +48,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; /** @@ -57,6 +60,7 @@ * * @author Parikshit Dutta * @author Mahmoud Ben Hassine + * @author Elimelec Burghelea */ class StaxEventItemWriterTests { @@ -831,6 +835,26 @@ void testOpenAndCloseTagsInComplexCallbacksRestart() throws Exception { + "", content, "Wrong content: " + content); } + /** + * Tests that if file.delete() returns false, an appropriate exception is thrown to + * indicate the deletion attempt failed. + */ + @Test + void testFailedFileDeletionThrowsException() throws IOException { + File mockedFile = spy(resource.getFile()); + writer.setResource(new FileSystemResource(mockedFile)); + writer.setShouldDeleteIfEmpty(true); + writer.open(executionContext); + + when(mockedFile.delete()).thenReturn(false); + + ItemStreamException exception = assertThrows(ItemStreamException.class, () -> writer.close(), + "Expected exception when file deletion fails"); + + assertEquals("Failed to delete empty file on close", exception.getMessage(), "Wrong exception message"); + assertNotNull(exception.getCause(), "Exception should have a cause"); + } + private void initWriterForSimpleCallbackTests() throws Exception { writer = createItemWriter(); writer.setHeaderCallback(writer -> { diff --git a/spring-batch-integration/pom.xml b/spring-batch-integration/pom.xml index 05649454c0..debc4e8e64 100644 --- a/spring-batch-integration/pom.xml +++ b/spring-batch-integration/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.1 + 5.2.2 spring-batch-integration Spring Batch Integration diff --git a/spring-batch-integration/src/main/java/org/springframework/batch/integration/partition/RemotePartitioningManagerStepBuilderFactory.java b/spring-batch-integration/src/main/java/org/springframework/batch/integration/partition/RemotePartitioningManagerStepBuilderFactory.java index 60a1f8d019..8a3c4995b0 100644 --- a/spring-batch-integration/src/main/java/org/springframework/batch/integration/partition/RemotePartitioningManagerStepBuilderFactory.java +++ b/spring-batch-integration/src/main/java/org/springframework/batch/integration/partition/RemotePartitioningManagerStepBuilderFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2022 the original author or authors. + * Copyright 2019-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. @@ -21,12 +21,10 @@ import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; -import org.springframework.transaction.PlatformTransactionManager; /** * Convenient factory for a {@link RemotePartitioningManagerStepBuilder} which sets the - * {@link JobRepository}, {@link JobExplorer}, {@link BeanFactory} and - * {@link PlatformTransactionManager} automatically. + * {@link JobRepository}, {@link JobExplorer} and {@link BeanFactory} automatically. * * @since 4.2 * @author Mahmoud Ben Hassine diff --git a/spring-batch-integration/src/main/java/org/springframework/batch/integration/partition/RemotePartitioningWorkerStepBuilderFactory.java b/spring-batch-integration/src/main/java/org/springframework/batch/integration/partition/RemotePartitioningWorkerStepBuilderFactory.java index b3c13a1f72..7246b0d259 100644 --- a/spring-batch-integration/src/main/java/org/springframework/batch/integration/partition/RemotePartitioningWorkerStepBuilderFactory.java +++ b/spring-batch-integration/src/main/java/org/springframework/batch/integration/partition/RemotePartitioningWorkerStepBuilderFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2022 the original author or authors. + * Copyright 2018-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. @@ -21,12 +21,10 @@ import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; -import org.springframework.transaction.PlatformTransactionManager; /** * Convenient factory for a {@link RemotePartitioningWorkerStepBuilder} which sets the - * {@link JobRepository}, {@link JobExplorer}, {@link BeanFactory} and - * {@link PlatformTransactionManager} automatically. + * {@link JobRepository}, {@link JobExplorer} and {@link BeanFactory} automatically. * * @since 4.1 * @author Mahmoud Ben Hassine diff --git a/spring-batch-samples/README.md b/spring-batch-samples/README.md index eb26857fc5..770c7fd938 100644 --- a/spring-batch-samples/README.md +++ b/spring-batch-samples/README.md @@ -61,6 +61,7 @@ The IO Sample Job has a number of special instances that show different IO featu | [multiResource Sample](#multiresource-input-output-job) | x | | | | | | | x | | x | | x | | [XML Input Output Sample](#xml-input-output) | | | x | | | | | | | | | | | [MongoDB sample](#mongodb-sample) | | | | | x | | | | x | | | | +| [PetClinic sample](#petclinic-sample) | | | | | x | x | | | | | | | ### Common Sample Source Structures @@ -615,6 +616,16 @@ $>docker run --name mongodb --rm -d -p 27017:27017 mongo Once MongoDB is up and running, run the `org.springframework.batch.samples.mongodb.MongoDBSampleApp` class without any argument to start the sample. +### PetClinic sample + +This sample uses the [PetClinic Spring application](https://github.com/spring-projects/spring-petclinic) to show how to use +Spring Batch to export data from a relational database table to a flat file. + +The job in this sample is a single-step job that exports data from the `owners` table +to a flat file named `owners.csv`. + +[PetClinic Sample](src/main/java/org/springframework/batch/samples/petclinic/README.md) + ### Adhoc Loop and JMX Sample This job is simply an infinite loop. It runs forever so it is diff --git a/spring-batch-samples/pom.xml b/spring-batch-samples/pom.xml index 3fefe28a8c..646a150321 100644 --- a/spring-batch-samples/pom.xml +++ b/spring-batch-samples/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.1 + 5.2.2 spring-batch-samples jar diff --git a/spring-batch-samples/src/main/java/org/springframework/batch/samples/petclinic/Owner.java b/spring-batch-samples/src/main/java/org/springframework/batch/samples/petclinic/Owner.java new file mode 100644 index 0000000000..7a66d7d296 --- /dev/null +++ b/spring-batch-samples/src/main/java/org/springframework/batch/samples/petclinic/Owner.java @@ -0,0 +1,19 @@ +/* + * 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.samples.petclinic; + +public record Owner(int id, String firstname, String lastname, String address, String city, String telephone) { +} diff --git a/spring-batch-samples/src/main/java/org/springframework/batch/samples/petclinic/OwnersExportJobConfiguration.java b/spring-batch-samples/src/main/java/org/springframework/batch/samples/petclinic/OwnersExportJobConfiguration.java new file mode 100644 index 0000000000..4a27ffb23f --- /dev/null +++ b/spring-batch-samples/src/main/java/org/springframework/batch/samples/petclinic/OwnersExportJobConfiguration.java @@ -0,0 +1,71 @@ +/* + * 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.samples.petclinic; + +import javax.sql.DataSource; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.item.file.FlatFileItemWriter; +import org.springframework.batch.item.file.builder.FlatFileItemWriterBuilder; +import org.springframework.batch.samples.common.DataSourceConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.io.FileSystemResource; +import org.springframework.jdbc.core.DataClassRowMapper; +import org.springframework.jdbc.support.JdbcTransactionManager; + +@Configuration +@EnableBatchProcessing +@Import(DataSourceConfiguration.class) +public class OwnersExportJobConfiguration { + + @Bean + public JdbcCursorItemReader ownersReader(DataSource dataSource) { + return new JdbcCursorItemReaderBuilder().name("ownersReader") + .sql("SELECT * FROM OWNERS") + .dataSource(dataSource) + .rowMapper(new DataClassRowMapper<>(Owner.class)) + .build(); + } + + @Bean + public FlatFileItemWriter ownersWriter() { + return new FlatFileItemWriterBuilder().name("ownersWriter") + .resource(new FileSystemResource("owners.csv")) + .delimited() + .names("id", "firstname", "lastname", "address", "city", "telephone") + .build(); + } + + @Bean + public Job job(JobRepository jobRepository, JdbcTransactionManager transactionManager, + JdbcCursorItemReader ownersReader, FlatFileItemWriter ownersWriter) { + return new JobBuilder("ownersExportJob", jobRepository) + .start(new StepBuilder("ownersExportStep", jobRepository).chunk(5, transactionManager) + .reader(ownersReader) + .writer(ownersWriter) + .build()) + .build(); + } + +} diff --git a/spring-batch-samples/src/main/java/org/springframework/batch/samples/petclinic/README.md b/spring-batch-samples/src/main/java/org/springframework/batch/samples/petclinic/README.md new file mode 100644 index 0000000000..12be08e09b --- /dev/null +++ b/spring-batch-samples/src/main/java/org/springframework/batch/samples/petclinic/README.md @@ -0,0 +1,21 @@ +# PetClinic Job + +## About the sample + +This sample uses the [PetClinic Spring application](https://github.com/spring-projects/spring-petclinic) to show how to use +Spring Batch to export data from a relational database table to a flat file. + +The job in this sample is a single-step job that exports data from the `owners` table +to a flat file named `owners.csv`. + +## Run the sample + +You can run the sample from the command line as following: + +``` +$>cd spring-batch-samples +# Launch the sample using the XML configuration +$>../mvnw -Dtest=PetClinicJobFunctionalTests#testLaunchJobWithXmlConfiguration test +# Launch the sample using the Java configuration +$>../mvnw -Dtest=PetClinicJobFunctionalTests#testLaunchJobWithJavaConfiguration test +``` \ No newline at end of file diff --git a/spring-batch-samples/src/main/resources/org/springframework/batch/samples/common/business-schema-hsqldb.sql b/spring-batch-samples/src/main/resources/org/springframework/batch/samples/common/business-schema-hsqldb.sql index b02b0b89a5..52a8c890f0 100644 --- a/spring-batch-samples/src/main/resources/org/springframework/batch/samples/common/business-schema-hsqldb.sql +++ b/spring-batch-samples/src/main/resources/org/springframework/batch/samples/common/business-schema-hsqldb.sql @@ -9,6 +9,7 @@ DROP TABLE PLAYERS IF EXISTS; DROP TABLE GAMES IF EXISTS; DROP TABLE PLAYER_SUMMARY IF EXISTS; DROP TABLE ERROR_LOG IF EXISTS; +DROP TABLE OWNERS IF EXISTS; -- Autogenerated: do not edit this file @@ -100,3 +101,26 @@ CREATE TABLE ERROR_LOG ( STEP_NAME CHAR(20) , MESSAGE VARCHAR(300) NOT NULL ) ; + +-- PetClinic sample tables + +CREATE TABLE OWNERS ( + ID INTEGER IDENTITY PRIMARY KEY, + FIRSTNAME VARCHAR(30), + LASTNAME VARCHAR(30), + ADDRESS VARCHAR(255), + CITY VARCHAR(80), + TELEPHONE VARCHAR(20) +); + +INSERT INTO OWNERS VALUES (1, 'George', 'Franklin', '110 W. Liberty St.', 'Madison', '6085551023'); +INSERT INTO OWNERS VALUES (2, 'Betty', 'Davis', '638 Cardinal Ave.', 'Sun Prairie', '6085551749'); +INSERT INTO OWNERS VALUES (3, 'Eduardo', 'Rodriquez', '2693 Commerce St.', 'McFarland', '6085558763'); +INSERT INTO OWNERS VALUES (4, 'Harold', 'Davis', '563 Friendly St.', 'Windsor', '6085553198'); +INSERT INTO OWNERS VALUES (5, 'Peter', 'McTavish', '2387 S. Fair Way', 'Madison', '6085552765'); +INSERT INTO OWNERS VALUES (6, 'Jean', 'Coleman', '105 N. Lake St.', 'Monona', '6085552654'); +INSERT INTO OWNERS VALUES (7, 'Jeff', 'Black', '1450 Oak Blvd.', 'Monona', '6085555387'); +INSERT INTO OWNERS VALUES (8, 'Maria', 'Escobito', '345 Maple St.', 'Madison', '6085557683'); +INSERT INTO OWNERS VALUES (9, 'David', 'Schroeder', '2749 Blackhawk Trail', 'Madison', '6085559435'); +INSERT INTO OWNERS VALUES (10, 'Carlos', 'Estaban', '2335 Independence La.', 'Waunakee', '6085555487'); + diff --git a/spring-batch-samples/src/main/resources/org/springframework/batch/samples/petclinic/job/ownersExportJob.xml b/spring-batch-samples/src/main/resources/org/springframework/batch/samples/petclinic/job/ownersExportJob.xml new file mode 100644 index 0000000000..0247f5511f --- /dev/null +++ b/spring-batch-samples/src/main/resources/org/springframework/batch/samples/petclinic/job/ownersExportJob.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spring-batch-samples/src/test/java/org/springframework/batch/samples/compositereader/CompositeItemWriterSampleFunctionalTests.java b/spring-batch-samples/src/test/java/org/springframework/batch/samples/compositereader/CompositeItemReaderSampleFunctionalTests.java similarity index 99% rename from spring-batch-samples/src/test/java/org/springframework/batch/samples/compositereader/CompositeItemWriterSampleFunctionalTests.java rename to spring-batch-samples/src/test/java/org/springframework/batch/samples/compositereader/CompositeItemReaderSampleFunctionalTests.java index 8c90257b6e..03db277a99 100644 --- a/spring-batch-samples/src/test/java/org/springframework/batch/samples/compositereader/CompositeItemWriterSampleFunctionalTests.java +++ b/spring-batch-samples/src/test/java/org/springframework/batch/samples/compositereader/CompositeItemReaderSampleFunctionalTests.java @@ -50,7 +50,7 @@ import org.springframework.jdbc.support.JdbcTransactionManager; import org.springframework.test.jdbc.JdbcTestUtils; -public class CompositeItemWriterSampleFunctionalTests { +public class CompositeItemReaderSampleFunctionalTests { record Person(int id, String name) { } diff --git a/spring-batch-samples/src/test/java/org/springframework/batch/samples/file/multiresource/MultiResourceFunctionalTests.java b/spring-batch-samples/src/test/java/org/springframework/batch/samples/file/multiresource/MultiResourceFunctionalTests.java index 209ac5ce39..b7522968b5 100644 --- a/spring-batch-samples/src/test/java/org/springframework/batch/samples/file/multiresource/MultiResourceFunctionalTests.java +++ b/spring-batch-samples/src/test/java/org/springframework/batch/samples/file/multiresource/MultiResourceFunctionalTests.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. @@ -16,6 +16,7 @@ package org.springframework.batch.samples.file.multiresource; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.batch.core.BatchStatus; @@ -38,6 +39,7 @@ * @author Mahmoud Ben Hassine * @since 2.0 */ +@Disabled("Failing on the CI platform but not locally") @SpringJUnitConfig(locations = { "/org/springframework/batch/samples/file/multiresource/job/multiResource.xml", "/simple-job-launcher-context.xml" }) class MultiResourceFunctionalTests { diff --git a/spring-batch-samples/src/test/java/org/springframework/batch/samples/petclinic/PetClinicJobFunctionalTests.java b/spring-batch-samples/src/test/java/org/springframework/batch/samples/petclinic/PetClinicJobFunctionalTests.java new file mode 100644 index 0000000000..dc8bfce26b --- /dev/null +++ b/spring-batch-samples/src/test/java/org/springframework/batch/samples/petclinic/PetClinicJobFunctionalTests.java @@ -0,0 +1,75 @@ +/* + * 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.samples.petclinic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringJUnitConfig(locations = { "/simple-job-launcher-context.xml", + "/org/springframework/batch/samples/petclinic/job/ownersExportJob.xml" }) +class PetClinicJobFunctionalTests { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @BeforeEach + @AfterEach + public void deleteOwnersFile() throws IOException { + Files.deleteIfExists(Paths.get("owners.csv")); + } + + @Test + void testLaunchJobWithXmlConfiguration() throws Exception { + // when + JobExecution jobExecution = jobLauncherTestUtils.launchJob(); + + // then + assertEquals(BatchStatus.COMPLETED, jobExecution.getStatus()); + } + + @Test + void testLaunchJobWithJavaConfiguration() throws Exception { + // given + ApplicationContext context = new AnnotationConfigApplicationContext(OwnersExportJobConfiguration.class); + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + Job job = context.getBean(Job.class); + + // when + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + // then + assertEquals(BatchStatus.COMPLETED, jobExecution.getStatus()); + } + +} diff --git a/spring-batch-test/pom.xml b/spring-batch-test/pom.xml index cf8b468ccd..c9f92b858f 100644 --- a/spring-batch-test/pom.xml +++ b/spring-batch-test/pom.xml @@ -4,7 +4,7 @@ org.springframework.batch spring-batch - 5.2.1 + 5.2.2 spring-batch-test Spring Batch Test diff --git a/spring-batch-test/src/main/java/org/springframework/batch/test/context/BatchTestContextCustomizerFactory.java b/spring-batch-test/src/main/java/org/springframework/batch/test/context/BatchTestContextCustomizerFactory.java index 12625b0dea..3c4888d7a1 100644 --- a/spring-batch-test/src/main/java/org/springframework/batch/test/context/BatchTestContextCustomizerFactory.java +++ b/spring-batch-test/src/main/java/org/springframework/batch/test/context/BatchTestContextCustomizerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 the original author or authors. + * Copyright 2018-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. @@ -17,23 +17,25 @@ import java.util.List; -import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.lang.Nullable; import org.springframework.test.context.ContextConfigurationAttributes; import org.springframework.test.context.ContextCustomizer; import org.springframework.test.context.ContextCustomizerFactory; +import org.springframework.test.context.TestContextAnnotationUtils; /** * Factory for {@link BatchTestContextCustomizer}. * * @author Mahmoud Ben Hassine + * @author Stefano Cordio * @since 4.1 */ public class BatchTestContextCustomizerFactory implements ContextCustomizerFactory { @Override - public ContextCustomizer createContextCustomizer(Class testClass, + public @Nullable ContextCustomizer createContextCustomizer(Class testClass, List configAttributes) { - if (AnnotatedElementUtils.hasAnnotation(testClass, SpringBatchTest.class)) { + if (TestContextAnnotationUtils.hasAnnotation(testClass, SpringBatchTest.class)) { return new BatchTestContextCustomizer(); } return null; diff --git a/spring-batch-test/src/test/java/org/springframework/batch/test/context/BatchTestContextCustomizerFactoryTests.java b/spring-batch-test/src/test/java/org/springframework/batch/test/context/BatchTestContextCustomizerFactoryTests.java index 4c693d05ec..7d393fde47 100644 --- a/spring-batch-test/src/test/java/org/springframework/batch/test/context/BatchTestContextCustomizerFactoryTests.java +++ b/spring-batch-test/src/test/java/org/springframework/batch/test/context/BatchTestContextCustomizerFactoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2022 the original author or authors. + * Copyright 2018-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. @@ -18,38 +18,42 @@ import java.util.Collections; import java.util.List; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.test.context.ContextConfigurationAttributes; import org.springframework.test.context.ContextCustomizer; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; /** * @author Mahmoud Ben Hassine + * @author Stefano Cordio */ class BatchTestContextCustomizerFactoryTests { private final BatchTestContextCustomizerFactory factory = new BatchTestContextCustomizerFactory(); - @Test - void testCreateContextCustomizer_whenAnnotationIsPresent() { + @ParameterizedTest + @ValueSource(classes = { MyJobTest.class, MyJobTest.MyNestedTest.class }) + void testCreateContextCustomizer_whenAnnotationIsPresent(Class testClass) { // given - Class testClass = MyJobTest.class; List configAttributes = Collections.emptyList(); // when ContextCustomizer contextCustomizer = this.factory.createContextCustomizer(testClass, configAttributes); // then - assertNotNull(contextCustomizer); + assertInstanceOf(BatchTestContextCustomizer.class, contextCustomizer); } @Test void testCreateContextCustomizer_whenAnnotationIsAbsent() { // given - Class testClass = MyOtherJobTest.class; + Class testClass = MyOtherJobTest.class; List configAttributes = Collections.emptyList(); // when @@ -62,6 +66,11 @@ void testCreateContextCustomizer_whenAnnotationIsAbsent() { @SpringBatchTest private static class MyJobTest { + @Nested + class MyNestedTest { + + } + } private static class MyOtherJobTest { diff --git a/spring-batch-test/src/test/java/org/springframework/batch/test/context/SpringBatchTestIntegrationTests.java b/spring-batch-test/src/test/java/org/springframework/batch/test/context/SpringBatchTestIntegrationTests.java new file mode 100644 index 0000000000..b48c39214d --- /dev/null +++ b/spring-batch-test/src/test/java/org/springframework/batch/test/context/SpringBatchTestIntegrationTests.java @@ -0,0 +1,70 @@ +/* + * 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.test.context; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.JobRepositoryTestUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * @author Stefano Cordio + */ +@SpringJUnitConfig +@SpringBatchTest +class SpringBatchTestIntegrationTests { + + @Autowired + ApplicationContext context; + + @Nested + class InnerWithoutSpringBatchTest { + + @Autowired + ApplicationContext context; + + @Test + void test() { + assertSame(SpringBatchTestIntegrationTests.this.context, context); + assertNotNull(context.getBean(JobLauncherTestUtils.class)); + assertNotNull(context.getBean(JobRepositoryTestUtils.class)); + } + + } + + @Nested + @SpringBatchTest + class InnerWithSpringBatchTest { + + @Autowired + ApplicationContext context; + + @Test + void test() { + assertSame(SpringBatchTestIntegrationTests.this.context, context); + assertNotNull(context.getBean(JobLauncherTestUtils.class)); + assertNotNull(context.getBean(JobRepositoryTestUtils.class)); + } + + } + +}