|
9 | 9 | import static org.hamcrest.Matchers.sameInstance; |
10 | 10 |
|
11 | 11 | 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; |
12 | 15 | import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraph; |
13 | 16 | import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraphApi; |
14 | 17 | import io.jenkins.plugins.pipelinegraphview.utils.PipelineGraphViewCache; |
15 | 18 | import io.jenkins.plugins.pipelinegraphview.utils.PipelineStepApi; |
16 | 19 | import io.jenkins.plugins.pipelinegraphview.utils.PipelineStepList; |
17 | 20 | import io.jenkins.plugins.pipelinegraphview.utils.TestUtils; |
18 | 21 | import java.io.File; |
| 22 | +import java.util.Set; |
| 23 | +import org.jenkinsci.plugins.workflow.actions.LabelAction; |
19 | 24 | import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; |
| 25 | +import org.jenkinsci.plugins.workflow.graph.BlockEndNode; |
| 26 | +import org.jenkinsci.plugins.workflow.graph.FlowNode; |
20 | 27 | import org.jenkinsci.plugins.workflow.job.WorkflowJob; |
21 | 28 | import org.jenkinsci.plugins.workflow.job.WorkflowRun; |
22 | 29 | import org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep; |
@@ -122,6 +129,75 @@ void repeatCallsReturnCachedDtoWhenNoNewNodes() throws Exception { |
122 | 129 | } |
123 | 130 | } |
124 | 131 |
|
| 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 | + |
125 | 201 | @Test |
126 | 202 | void newNodeInvalidatesOutputCache() throws Exception { |
127 | 203 | WorkflowJob job = j.createProject(WorkflowJob.class, "cache-miss"); |
|
0 commit comments