Skip to content

Commit 8cb25b5

Browse files
authored
Another case for in progress stages (#1256)
1 parent f5bec27 commit 8cb25b5

2 files changed

Lines changed: 84 additions & 7 deletions

File tree

src/main/java/io/jenkins/plugins/pipelinegraphview/treescanner/PipelineNodeTreeScanner.java

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -556,15 +556,16 @@ private void assignParent(@NonNull FlowNodeWrapper wrappedNode, @CheckForNull Fl
556556
} else {
557557
FlowNode start = relationship.getStart();
558558
FlowNode end = relationship.getEnd();
559-
// Timing and status for a closed block are stable once the BlockEndNode
560-
// exists AND the block is no longer active; memoise so subsequent requests
561-
// don't re-walk actions per block.
562-
// We must not cache while the start node is still active: the status can
563-
// still change (e.g. IN_PROGRESS → SUCCESS), and an incorrectly cached
564-
// value would be returned for the remainder of the run.
559+
// Only memoise once the block is fully past — neither start nor end may be
560+
// in the active set. Excluding {@code end} matters for the brief window
561+
// where a BlockEndNode is itself the current head (between this stage
562+
// closing and the next one opening): {@code computeChunkStatus2} treats
563+
// {@code isCurrentHead(end)} as a last-chunk signal and returns IN_PROGRESS,
564+
// which must not be cached as the block's resolved status.
565565
boolean blockClosed = start != end
566566
&& end instanceof BlockEndNode<?>
567-
&& (activeNodeIds == null || !activeNodeIds.contains(start.getId()));
567+
&& (activeNodeIds == null
568+
|| (!activeNodeIds.contains(start.getId()) && !activeNodeIds.contains(end.getId())));
568569
BlockResolutionCache cache =
569570
blockClosed ? LiveGraphRegistry.get().blockResolutionCache(execution) : null;
570571
if (cache != null) {

src/test/java/io/jenkins/plugins/pipelinegraphview/livestate/LiveGraphLifecycleTest.java

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,21 @@
99
import static org.hamcrest.Matchers.sameInstance;
1010

1111
import hudson.model.Result;
12+
import io.jenkins.plugins.pipelinegraphview.treescanner.PipelineNodeGraphAdapter;
13+
import io.jenkins.plugins.pipelinegraphview.utils.BlueRun;
14+
import io.jenkins.plugins.pipelinegraphview.utils.NodeRunStatus;
1215
import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraph;
1316
import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraphApi;
1417
import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraphViewCache;
1518
import io.jenkins.plugins.pipelinegraphview.utils.PipelineStepApi;
1619
import io.jenkins.plugins.pipelinegraphview.utils.PipelineStepList;
1720
import io.jenkins.plugins.pipelinegraphview.utils.TestUtils;
1821
import java.io.File;
22+
import java.util.Set;
23+
import org.jenkinsci.plugins.workflow.actions.LabelAction;
1924
import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
25+
import org.jenkinsci.plugins.workflow.graph.BlockEndNode;
26+
import org.jenkinsci.plugins.workflow.graph.FlowNode;
2027
import org.jenkinsci.plugins.workflow.job.WorkflowJob;
2128
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
2229
import org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep;
@@ -122,6 +129,75 @@ void repeatCallsReturnCachedDtoWhenNoNewNodes() throws Exception {
122129
}
123130
}
124131

132+
@Test
133+
void wrapWithBlockEndInActiveSetDoesNotPopulateCache() throws Exception {
134+
// Regression for #1252. When a block's BlockEndNode is itself a current head (the
135+
// brief transitional moment between one stage closing and the next opening), the
136+
// BlockResolutionCache must not store a status for that block: computeChunkStatus2
137+
// would return IN_PROGRESS in that window and persist as state="running" in the
138+
// seeded JSON for the rest of the run's life.
139+
WorkflowJob job = j.createProject(WorkflowJob.class, "block-end-active");
140+
job.setDefinition(new CpsFlowDefinition(
141+
"stage('one') { echo 'in stage one' }\n" + "stage('two') { semaphore 'wait' }\n", true));
142+
WorkflowRun run = job.scheduleBuild2(0).waitForStart();
143+
try {
144+
SemaphoreStep.waitForStart("wait/1", run);
145+
146+
LiveGraphSnapshot snapshot = LiveGraphRegistry.get().snapshot(run);
147+
assertThat(snapshot, is(notNullValue()));
148+
149+
FlowNode stage1Start = null;
150+
FlowNode stage1End = null;
151+
for (FlowNode n : snapshot.nodes()) {
152+
if (n instanceof BlockEndNode<?> blockEnd) {
153+
FlowNode start = blockEnd.getStartNode();
154+
LabelAction la = start.getAction(LabelAction.class);
155+
if (la != null && "one".equals(la.getDisplayName())) {
156+
stage1Start = start;
157+
stage1End = n;
158+
break;
159+
}
160+
}
161+
}
162+
assertThat("stage 'one' BlockStartNode resolved", stage1Start, is(notNullValue()));
163+
assertThat("stage 'one' BlockEndNode resolved", stage1End, is(notNullValue()));
164+
165+
// Simulate the transitional snapshot: activeNodeIds contains only the
166+
// BlockEndNode of the just-closed stage. (A BlockEndNode's enclosing chain
167+
// does NOT include its own BlockStartNode, so a snapshot taken while the
168+
// BlockEndNode is the head genuinely produces this set.)
169+
new PipelineNodeGraphAdapter(
170+
run, snapshot.nodes(), snapshot.enclosingIdsByNodeId(), Set.of(stage1End.getId()));
171+
172+
// After the wrap pass, the cache must have NO entry for stage 1's
173+
// (start,end) pair. We detect this by passing a sentinel supplier to
174+
// getOrComputeStatus: it only runs on cache miss.
175+
BlockResolutionCache cache = LiveGraphRegistry.get().blockResolutionCache(run.getExecution());
176+
assertThat(cache, is(notNullValue()));
177+
boolean[] supplierRan = {false};
178+
NodeRunStatus value = cache.getOrComputeStatus(stage1Start.getId(), stage1End.getId(), () -> {
179+
supplierRan[0] = true;
180+
return new NodeRunStatus(BlueRun.BlueRunResult.SUCCESS, BlueRun.BlueRunState.FINISHED);
181+
});
182+
assertThat("wrap pass must not populate the cache when end is in activeNodeIds", supplierRan[0], is(true));
183+
assertThat(value.getState(), is(BlueRun.BlueRunState.FINISHED));
184+
} finally {
185+
SemaphoreStep.success("wait/1", null);
186+
j.waitForCompletion(run);
187+
}
188+
j.assertBuildStatus(Result.SUCCESS, run);
189+
190+
// Belt and braces: the seeded post-completion JSON must never report a stage as
191+
// still 'running'. A poisoned cache entry from any earlier wrap would surface here.
192+
File treeFile = new File(run.getRootDir(), PipelineGraphViewCache.TREE_FILE_NAME);
193+
assertThat(treeFile.exists(), is(true));
194+
String content = java.nio.file.Files.readString(treeFile.toPath());
195+
assertThat(
196+
"completed run never persists a stage as 'running'",
197+
content.contains("\"state\":\"running\""),
198+
is(false));
199+
}
200+
125201
@Test
126202
void newNodeInvalidatesOutputCache() throws Exception {
127203
WorkflowJob job = j.createProject(WorkflowJob.class, "cache-miss");

0 commit comments

Comments
 (0)