From c4f740ff42a275fdb5b13eebbe3ccd4d75db989c Mon Sep 17 00:00:00 2001 From: Seonwoo Jung Date: Sat, 13 Jun 2026 12:19:07 +0900 Subject: [PATCH] Propagate step exit description from FlowJob to JobExecution Issue: gh-3740 When a step inside a FlowJob fails, the StepExecution's exit status already contains the failure description (typically the stack trace recorded by AbstractStep). However, JobFlowExecutor.executeStep only returned the exit code, and the final JobFlowExecutor.updateJobExecutionStatus combined the executor's tracked ExitStatus (which had no description) with an ExitStatus built from the FlowExecutionStatus name. As a result the step's exit description was discarded and the JobExecution ended with an empty exit description, in contrast to SimpleJob which assigns stepExecution.getExitStatus() directly. Accumulate the step's exit description into the executor's tracked ExitStatus in executeStep so it survives the final updateJobExecutionStatus call. ExitStatus.addExitDescription already deduplicates equal descriptions and concatenates distinct ones with a semicolon, so multiple step failures along a flow path are preserved without duplication. Signed-off-by: Seonwoo Jung --- .../batch/core/job/flow/JobFlowExecutor.java | 6 ++++ .../core/job/flow/FlowJobFailureTests.java | 32 ++++++++++++++++++- .../step/StepGatewayIntegrationTests.java | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/job/flow/JobFlowExecutor.java b/spring-batch-core/src/main/java/org/springframework/batch/core/job/flow/JobFlowExecutor.java index 89ace2f924..593a9bdb7d 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/job/flow/JobFlowExecutor.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/job/flow/JobFlowExecutor.java @@ -82,6 +82,12 @@ public String executeStep(Step step) stepExecution.getExecutionContext().put("batch.restart", true); } + // Preserve the step's exit description (e.g., the stack trace recorded by + // AbstractStep when a step fails) so it survives the flow's final + // updateJobExecutionStatus() call, which only carries the FlowExecutionStatus + // name and would otherwise discard it. + exitStatus = exitStatus.addExitDescription(stepExecution.getExitStatus().getExitDescription()); + return stepExecution.getExitStatus().getExitCode(); } diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/job/flow/FlowJobFailureTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/job/flow/FlowJobFailureTests.java index a025c1cb69..81a43067fa 100644 --- a/spring-batch-core/src/test/java/org/springframework/batch/core/job/flow/FlowJobFailureTests.java +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/job/flow/FlowJobFailureTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2010-2023 the original author or authors. + * Copyright 2010-2026 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.core.job.flow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; import java.util.List; @@ -91,6 +92,35 @@ void testStepFailure() throws Exception { assertEquals(BatchStatus.FAILED, execution.getStatus()); } + @Test // gh-3740 + void testStepFailureExitDescriptionPropagatedToJobExecution() throws Exception { + final String failureDescription = "boom: simulated step failure"; + SimpleFlow flow = new SimpleFlow("job"); + List transitions = new ArrayList<>(); + StepState step = new StepState(new StepSupport("step") { + @Override + public void execute(StepExecution stepExecution) + throws JobInterruptedException, UnexpectedJobExecutionException { + stepExecution.setExitStatus(ExitStatus.FAILED.addExitDescription(failureDescription)); + stepExecution.setStatus(BatchStatus.FAILED); + } + }); + transitions.add(StateTransition.createStateTransition(step, ExitStatus.FAILED.getExitCode(), "end0")); + transitions.add(StateTransition.createStateTransition(step, ExitStatus.COMPLETED.getExitCode(), "end1")); + transitions.add(StateTransition.createEndStateTransition(new EndState(FlowExecutionStatus.FAILED, "end0"))); + transitions.add(StateTransition.createEndStateTransition(new EndState(FlowExecutionStatus.COMPLETED, "end1"))); + flow.setStateTransitions(transitions); + job.setFlow(flow); + job.afterPropertiesSet(); + job.execute(execution); + + assertEquals(BatchStatus.FAILED, execution.getStatus()); + assertEquals(ExitStatus.FAILED.getExitCode(), execution.getExitStatus().getExitCode()); + assertTrue(execution.getExitStatus().getExitDescription().contains(failureDescription), + "Expected JobExecution exit description to contain step failure description but was: '" + + execution.getExitStatus().getExitDescription() + "'"); + } + @Test void testStepStatusUnknown() throws Exception { SimpleFlow flow = new SimpleFlow("job"); diff --git a/spring-batch-integration/src/test/java/org/springframework/batch/integration/step/StepGatewayIntegrationTests.java b/spring-batch-integration/src/test/java/org/springframework/batch/integration/step/StepGatewayIntegrationTests.java index 2f9b8e76c1..2c5adddaa6 100644 --- a/spring-batch-integration/src/test/java/org/springframework/batch/integration/step/StepGatewayIntegrationTests.java +++ b/spring-batch-integration/src/test/java/org/springframework/batch/integration/step/StepGatewayIntegrationTests.java @@ -65,7 +65,7 @@ void testLaunchFailedJob() throws Exception { JobExecution jobExecution = jobOperator.start(job, new JobParametersBuilder().addLong("run.id", 2L).toJobParameters()); assertEquals(BatchStatus.FAILED, jobExecution.getStatus()); - assertEquals(ExitStatus.FAILED, jobExecution.getExitStatus()); + assertEquals(ExitStatus.FAILED.getExitCode(), jobExecution.getExitStatus().getExitCode()); } }