Skip to content

Commit 8fba087

Browse files
committed
[JENKINS-67164] Call StepExecution.onResume in response to WorkflowRun.onLoad not FlowExecutionList.ItemListenerImpl
1 parent b912c0e commit 8fba087

4 files changed

Lines changed: 204 additions & 25 deletions

File tree

src/main/java/org/jenkinsci/plugins/workflow/flow/FlowExecution.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,4 +322,12 @@ public Iterable<BlockStartNode> iterateEnclosingBlocks(@NonNull FlowNode node) {
322322
protected void notifyShutdown() {
323323
// Default is no-op
324324
}
325+
326+
/**
327+
* Called after a restart and any attempts at {@link StepExecution#onResume} have completed.
328+
* This is a signal that it is safe to resume program execution.
329+
* By default, does nothing.
330+
*/
331+
protected void afterStepExecutionsResumed() {}
332+
325333
}

src/main/java/org/jenkinsci/plugins/workflow/flow/FlowExecutionList.java

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import hudson.Extension;
1111
import hudson.ExtensionList;
1212
import hudson.XmlFile;
13+
import hudson.init.InitMilestone;
1314
import hudson.init.Terminator;
1415
import hudson.model.listeners.ItemListener;
1516
import hudson.remoting.SingleLaneExecutorService;
@@ -31,6 +32,7 @@
3132
import java.util.logging.Logger;
3233

3334
import org.kohsuke.accmod.Restricted;
35+
import org.kohsuke.accmod.restrictions.Beta;
3436
import org.kohsuke.accmod.restrictions.DoNotUse;
3537

3638
/**
@@ -44,6 +46,8 @@ public class FlowExecutionList implements Iterable<FlowExecution> {
4446
private final SingleLaneExecutorService executor = new SingleLaneExecutorService(Timer.get());
4547
private XmlFile configFile;
4648

49+
private transient volatile boolean resumptionComplete;
50+
4751
public FlowExecutionList() {
4852
load();
4953
}
@@ -160,11 +164,17 @@ public static FlowExecutionList get() {
160164
}
161165

162166
/**
163-
* @deprecated Only exists for binary compatibility.
167+
* Returns true if all executions that were present in this {@link FlowExecutionList} have been loaded.
168+
*
169+
* <p>This takes place slightly after {@link InitMilestone#COMPLETED} is reached during Jenkins startup.
170+
*
171+
* <p>Useful to avoid resuming Pipelines in contexts that may lead to deadlock.
172+
*
173+
* <p>It is <em>not</em> guaranteed that {@link FlowExecution#afterStepExecutionsResumed} has been called at this point.
164174
*/
165-
@Deprecated
175+
@Restricted(Beta.class)
166176
public boolean isResumptionComplete() {
167-
return false;
177+
return resumptionComplete;
168178
}
169179

170180
/**
@@ -179,29 +189,8 @@ public void onLoaded() {
179189
for (final FlowExecution e : list) {
180190
// The call to FlowExecutionOwner.get in the implementation of iterator() is sufficent to load the Pipeline.
181191
LOGGER.log(Level.FINE, "Eagerly loaded {0}", e);
182-
Futures.addCallback(e.getCurrentExecutions(false), new FutureCallback<List<StepExecution>>() {
183-
@Override
184-
public void onSuccess(@NonNull List<StepExecution> result) {
185-
LOGGER.log(Level.FINE, "Will resume {0}", result);
186-
for (StepExecution se : result) {
187-
try {
188-
se.onResume();
189-
} catch (Throwable x) {
190-
se.getContext().onFailure(x);
191-
}
192-
}
193-
}
194-
195-
@Override
196-
public void onFailure(@NonNull Throwable t) {
197-
if (t instanceof CancellationException) {
198-
LOGGER.log(Level.FINE, "Cancelled load of " + e, t);
199-
} else {
200-
LOGGER.log(Level.WARNING, "Failed to load " + e, t);
201-
}
202-
}
203-
}, MoreExecutors.directExecutor());
204192
}
193+
list.resumptionComplete = true;
205194
}
206195
}
207196

@@ -256,4 +245,56 @@ public void onFailure(@NonNull Throwable t) {
256245
executor.shutdown();
257246
executor.awaitTermination(1, TimeUnit.MINUTES);
258247
}
248+
249+
/**
250+
* Whenever a Pipeline resumes, resume all incomplete steps in its {@link FlowExecution}.
251+
*
252+
* <p>Called by {@code WorkflowRun.onLoad}, so guaranteed to run if a Pipeline resumes
253+
* regardless of its presence in {@link FlowExecutionList}.
254+
*/
255+
@Extension
256+
public static class ResumeStepExecutionListener extends FlowExecutionListener {
257+
@Override
258+
public void onResumed(@NonNull FlowExecution e) {
259+
Futures.addCallback(e.getCurrentExecutions(false), new FutureCallback<List<StepExecution>>() {
260+
@Override
261+
public void onSuccess(@NonNull List<StepExecution> result) {
262+
try {
263+
if (e.isComplete()) {
264+
// WorkflowRun.onLoad will not fireResumed if the execution was already complete when loaded,
265+
// and CpsFlowExecution should not then complete until afterStepExecutionsResumed, so this is defensive.
266+
return;
267+
}
268+
FlowExecutionList list = FlowExecutionList.get();
269+
FlowExecutionOwner owner = e.getOwner();
270+
if (!list.runningTasks.contains(owner)) {
271+
LOGGER.log(Level.WARNING, "Resuming {0}, which is missing from FlowExecutionList ({1}), so registering it now.", new Object[] {owner, list.runningTasks.getView()});
272+
list.register(owner);
273+
}
274+
LOGGER.log(Level.FINE, "Will resume {0}", result);
275+
for (StepExecution se : result) {
276+
try {
277+
se.onResume();
278+
} catch (Throwable x) {
279+
se.getContext().onFailure(x);
280+
}
281+
}
282+
} finally {
283+
e.afterStepExecutionsResumed();
284+
}
285+
}
286+
287+
@Override
288+
public void onFailure(@NonNull Throwable t) {
289+
if (t instanceof CancellationException) {
290+
LOGGER.log(Level.FINE, "Cancelled load of " + e, t);
291+
} else {
292+
LOGGER.log(Level.WARNING, "Failed to load " + e, t);
293+
}
294+
e.afterStepExecutionsResumed();
295+
}
296+
297+
}, Timer.get()); // We always hold RunMap and WorkflowRun locks here, so we resume steps on a different thread to avoid potential deadlocks. See JENKINS-67351.
298+
}
299+
}
259300
}

src/main/java/org/jenkinsci/plugins/workflow/flow/FlowExecutionListener.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public void onCompleted(@NonNull FlowExecution execution) {
6262
* Fires the {@link #onCreated(FlowExecution)} event.
6363
*/
6464
public static void fireCreated(@NonNull FlowExecution execution) {
65+
// TODO Jenkins 2.325+ use Listeners.notify
6566
for (FlowExecutionListener listener : ExtensionList.lookup(FlowExecutionListener.class)) {
6667
listener.onCreated(execution);
6768
}

src/test/java/org/jenkinsci/plugins/workflow/flow/FlowExecutionListTest.java

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,44 @@
2424

2525
package org.jenkinsci.plugins.workflow.flow;
2626

27+
import static org.hamcrest.MatcherAssert.assertThat;
28+
import static org.hamcrest.Matchers.containsString;
29+
import static org.hamcrest.Matchers.hasItem;
2730
import static org.junit.Assert.assertNotNull;
2831

32+
import hudson.AbortException;
2933
import hudson.model.ParametersAction;
3034
import hudson.model.ParametersDefinitionProperty;
35+
import hudson.model.Result;
3136
import hudson.model.StringParameterDefinition;
3237
import hudson.model.StringParameterValue;
38+
import hudson.model.TaskListener;
3339
import hudson.model.queue.QueueTaskFuture;
40+
import java.io.Serializable;
41+
import java.time.Duration;
42+
import java.time.Instant;
43+
import java.util.Collections;
44+
import java.util.Set;
45+
import java.util.function.Supplier;
3446
import java.util.logging.Level;
47+
import org.hamcrest.Matcher;
3548
import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
3649
import org.jenkinsci.plugins.workflow.job.WorkflowJob;
3750
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
51+
import org.jenkinsci.plugins.workflow.steps.Step;
52+
import org.jenkinsci.plugins.workflow.steps.StepContext;
53+
import org.jenkinsci.plugins.workflow.steps.StepDescriptor;
54+
import org.jenkinsci.plugins.workflow.steps.StepExecution;
55+
import org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep;
3856
import org.junit.ClassRule;
3957
import org.junit.Test;
4058
import org.junit.Rule;
4159
import org.jvnet.hudson.test.BuildWatcher;
4260
import org.jvnet.hudson.test.Issue;
4361
import org.jvnet.hudson.test.LoggerRule;
4462
import org.jvnet.hudson.test.JenkinsSessionRule;
63+
import org.jvnet.hudson.test.TestExtension;
64+
import org.kohsuke.stapler.DataBoundConstructor;
4565

4666
public class FlowExecutionListTest {
4767

@@ -79,4 +99,113 @@ public class FlowExecutionListTest {
7999
});
80100
}
81101

102+
@Test public void forceLoadRunningExecutionsAfterRestart() throws Throwable {
103+
logging.capture(50);
104+
sessions.then(r -> {
105+
WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "p");
106+
p.setDefinition(new CpsFlowDefinition("semaphore('wait')", true));
107+
WorkflowRun b = p.scheduleBuild2(0).waitForStart();
108+
SemaphoreStep.waitForStart("wait/1", b);
109+
});
110+
sessions.then(r -> {
111+
/*
112+
Make sure that the build gets loaded automatically by FlowExecutionList$ItemListenerImpl before we load it explictly.
113+
Expected call stack for resuming a Pipelines and its steps:
114+
at org.jenkinsci.plugins.workflow.flow.FlowExecutionList$ResumeStepExecutionListener$1.onSuccess(FlowExecutionList.java:250)
115+
at org.jenkinsci.plugins.workflow.flow.FlowExecutionList$ResumeStepExecutionListener$1.onSuccess(FlowExecutionList.java:247)
116+
at com.google.common.util.concurrent.Futures$6.run(Futures.java:975)
117+
at org.jenkinsci.plugins.workflow.flow.DirectExecutor.execute(DirectExecutor.java:33)
118+
... Guava Futures API internals ...
119+
at com.google.common.util.concurrent.Futures.addCallback(Futures.java:985)
120+
at org.jenkinsci.plugins.workflow.flow.FlowExecutionList$ResumeStepExecutionListener.onResumed(FlowExecutionList.java:247)
121+
at org.jenkinsci.plugins.workflow.flow.FlowExecutionListener.fireResumed(FlowExecutionListener.java:84)
122+
at org.jenkinsci.plugins.workflow.job.WorkflowRun.onLoad(WorkflowRun.java:528)
123+
at hudson.model.RunMap.retrieve(RunMap.java:225)
124+
... RunMap internals ...
125+
at hudson.model.RunMap.getById(RunMap.java:205)
126+
at org.jenkinsci.plugins.workflow.job.WorkflowRun$Owner.run(WorkflowRun.java:937)
127+
at org.jenkinsci.plugins.workflow.job.WorkflowRun$Owner.get(WorkflowRun.java:948)
128+
at org.jenkinsci.plugins.workflow.flow.FlowExecutionList$1.computeNext(FlowExecutionList.java:65)
129+
at org.jenkinsci.plugins.workflow.flow.FlowExecutionList$1.computeNext(FlowExecutionList.java:57)
130+
at com.google.common.collect.AbstractIterator.tryToComputeNext(AbstractIterator.java:143)
131+
at com.google.common.collect.AbstractIterator.hasNext(AbstractIterator.java:138)
132+
at org.jenkinsci.plugins.workflow.flow.FlowExecutionList$ItemListenerImpl.onLoaded(FlowExecutionList.java:175)
133+
at jenkins.model.Jenkins.<init>(Jenkins.java:1019)
134+
*/
135+
waitFor(logging::getMessages, hasItem(containsString("Will resume [org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep")));
136+
WorkflowJob p = r.jenkins.getItemByFullName("p", WorkflowJob.class);
137+
SemaphoreStep.success("wait/1", null);
138+
WorkflowRun b = p.getBuildByNumber(1);
139+
r.waitForCompletion(b);
140+
r.assertBuildStatus(Result.SUCCESS, b);
141+
});
142+
}
143+
144+
@Issue("JENKINS-67164")
145+
@Test public void resumeStepExecutions() throws Throwable {
146+
sessions.then(r -> {
147+
WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "p");
148+
p.setDefinition(new CpsFlowDefinition("noResume()", true));
149+
WorkflowRun b = p.scheduleBuild2(0).waitForStart();
150+
r.waitForMessage("Starting non-resumable step", b);
151+
// TODO: Unclear how this might happen in practice.
152+
FlowExecutionList.get().unregister(b.asFlowExecutionOwner());
153+
});
154+
sessions.then(r -> {
155+
WorkflowJob p = r.jenkins.getItemByFullName("p", WorkflowJob.class);
156+
WorkflowRun b = p.getBuildByNumber(1);
157+
r.waitForCompletion(b);
158+
r.assertBuildStatus(Result.FAILURE, b);
159+
r.assertLogContains("Unable to resume NonResumableStep", b);
160+
});
161+
}
162+
163+
public static class NonResumableStep extends Step implements Serializable {
164+
public static final long serialVersionUID = 1L;
165+
@DataBoundConstructor
166+
public NonResumableStep() { }
167+
@Override
168+
public StepExecution start(StepContext sc) {
169+
return new ExecutionImpl(sc);
170+
}
171+
172+
private static class ExecutionImpl extends StepExecution implements Serializable {
173+
public static final long serialVersionUID = 1L;
174+
private ExecutionImpl(StepContext sc) {
175+
super(sc);
176+
}
177+
@Override
178+
public boolean start() throws Exception {
179+
getContext().get(TaskListener.class).getLogger().println("Starting non-resumable step");
180+
return false;
181+
}
182+
@Override
183+
public void onResume() {
184+
getContext().onFailure(new AbortException("Unable to resume NonResumableStep"));
185+
}
186+
}
187+
188+
@TestExtension public static class DescriptorImpl extends StepDescriptor {
189+
@Override
190+
public Set<? extends Class<?>> getRequiredContext() {
191+
return Collections.singleton(TaskListener.class);
192+
}
193+
@Override
194+
public String getFunctionName() {
195+
return "noResume";
196+
}
197+
}
198+
}
199+
200+
/**
201+
* Wait up to 5 seconds for the given supplier to return a matching value.
202+
*/
203+
private static <T> void waitFor(Supplier<T> valueSupplier, Matcher<T> matcher) throws InterruptedException {
204+
Instant end = Instant.now().plus(Duration.ofSeconds(5));
205+
while (!matcher.matches(valueSupplier.get()) && Instant.now().isBefore(end)) {
206+
Thread.sleep(100L);
207+
}
208+
assertThat("Matcher should have matched after 5s", valueSupplier.get(), matcher);
209+
}
210+
82211
}

0 commit comments

Comments
 (0)