diff --git a/src/main/java/org/jenkinsci/plugins/workflow/steps/RetryStep.java b/src/main/java/org/jenkinsci/plugins/workflow/steps/RetryStep.java index b26aa998..9068e1dd 100644 --- a/src/main/java/org/jenkinsci/plugins/workflow/steps/RetryStep.java +++ b/src/main/java/org/jenkinsci/plugins/workflow/steps/RetryStep.java @@ -26,28 +26,64 @@ import hudson.Extension; import hudson.model.TaskListener; +import hudson.util.ListBoxModel; + +import java.io.Serializable; import java.util.Collections; import java.util.Set; +import java.util.concurrent.TimeUnit; + import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; /** * Executes the body up to N times. * * @author Kohsuke Kawaguchi */ -public class RetryStep extends Step { +public class RetryStep extends Step implements Serializable { private final int count; + private int timeDelay; + private TimeUnit unit = TimeUnit.SECONDS; + private boolean useTimeDelay = false; + + public int left; @DataBoundConstructor public RetryStep(int count) { this.count = count; + this.left = count; } public int getCount() { return count; } + @DataBoundSetter public void setUseTimeDelay(boolean useTimeDelay) { + this.useTimeDelay = useTimeDelay; + } + + public boolean isUseTimeDelay() { + return useTimeDelay; + } + + @DataBoundSetter public void setTimeDelay(int timeDelay) { + this.timeDelay = timeDelay; + } + + public int getTimeDelay() { + return timeDelay; + } + + @DataBoundSetter public void setUnit(TimeUnit unit) { + this.unit = unit; + } + + public TimeUnit getUnit() { + return unit; + } + @Override public DescriptorImpl getDescriptor() { return (DescriptorImpl)super.getDescriptor(); @@ -55,7 +91,7 @@ public DescriptorImpl getDescriptor() { @Override public StepExecution start(StepContext context) throws Exception { - return new RetryStepExecution(count, context); + return new RetryStepExecution(this, context); } @Extension @@ -76,6 +112,14 @@ public String getDisplayName() { return "Retry the body up to N times"; } + public ListBoxModel doFillUnitItems() { + ListBoxModel r = new ListBoxModel(); + for (TimeUnit unit : TimeUnit.values()) { + r.add(unit.name()); + } + return r; + } + @Override public Set> getRequiredContext() { return Collections.singleton(TaskListener.class); @@ -83,4 +127,5 @@ public Set> getRequiredContext() { } + private static final long serialVersionUID = 1L; } diff --git a/src/main/java/org/jenkinsci/plugins/workflow/steps/RetryStepExecution.java b/src/main/java/org/jenkinsci/plugins/workflow/steps/RetryStepExecution.java index a7825599..bde546ff 100644 --- a/src/main/java/org/jenkinsci/plugins/workflow/steps/RetryStepExecution.java +++ b/src/main/java/org/jenkinsci/plugins/workflow/steps/RetryStepExecution.java @@ -1,51 +1,153 @@ package org.jenkinsci.plugins.workflow.steps; +import com.google.common.base.Function; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.AbortException; import hudson.Functions; +import hudson.Util; import hudson.model.TaskListener; +import java.util.UUID; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nonnull; + +import jenkins.util.Timer; + /** * @author Kohsuke Kawaguchi */ public class RetryStepExecution extends AbstractStepExecutionImpl { - - @SuppressFBWarnings(value="SE_TRANSIENT_FIELD_NOT_RESTORED", justification="Only used when starting.") + + @SuppressFBWarnings(value = "SE_TRANSIENT_FIELD_NOT_RESTORED", justification = "Only used when starting.") + private transient final RetryStep step; + @SuppressFBWarnings(value = "SE_TRANSIENT_FIELD_NOT_RESTORED", justification = "Only used when starting.") private transient final int count; + private transient volatile ScheduledFuture task; + /** Used to track whether this is timing out on inactivity without needing to reference {@link #step}. */ + private boolean executing = false; + /** Token for {@link #executing} callbacks. */ + private final String id = UUID.randomUUID().toString(); + + + @Deprecated RetryStepExecution(int count, StepContext context) { super(context); - this.count = count; + this.count =count; + this.step = null; + } + + RetryStepExecution(@Nonnull RetryStep step, StepContext context) { + super(context); + this.step = step; + this.count = step.getCount(); } - @Override - public boolean start() throws Exception { + @Override public boolean start() throws Exception { StepContext context = getContext(); - context.newBodyInvoker() - .withCallback(new Callback(count)) - .start(); + if(step == null) { + context.newBodyInvoker() + .withCallback(new Callback(count)) + .start(); + } else { + executing = true; + context.newBodyInvoker() + .withCallback(new Callback(id,step)) + .start(); + } return false; // execution is asynchronous } + + @Override public void stop(Throwable cause) throws Exception { + if (task != null) { + task.cancel(false); + } + super.stop(cause); + } + + @Override public void onResume() { + if (!executing && step != null) { + // Restarted while waiting for the timer to go off. Rerun now. + getContext().newBodyInvoker().withCallback(new Callback(id, step)).start(); + executing = true; + } // otherwise we are in the middle of the body already, so let it run + } + + private static void retry(final String id, final StepContext context) { + StepExecution.applyAll(RetryStepExecution.class, new Function() { + @Override public Void apply(@Nonnull RetryStepExecution execution) { + if (execution.id.equals(id)) { + execution.retry(context); + } + return null; + } + }); + } + + private void retry(StepContext perBodyContext) { + executing = false; + getContext().saveState(); + + try { + TaskListener l = getContext().get(TaskListener.class); + if(step.left>0) { + long delay = step.getUnit().toMillis(step.getTimeDelay()); + l.getLogger().println( + "Will try again after " + + Util.getTimeSpanString(delay)); + task = Timer.get().schedule(new Runnable() { + @Override public void run() { + task = null; + try { + l.getLogger().println("Retrying"); + } catch (Exception x) { + getContext().onFailure(x); + return; + } + getContext().newBodyInvoker().withCallback(new Callback(id,step)).start(); + executing = true; + } + }, delay, TimeUnit.MILLISECONDS); + } + } catch (Throwable p) { + getContext().onFailure(p); + } + } - @Override public void onResume() {} + @Override public String getStatus() { + if (executing) { + return "running body"; + } else if (task == null) { + return "no body, no task, not sure what happened"; + } else if (task.isDone()) { + return "scheduled task is done, but no body"; + } else if (task.isCancelled()) { + return "scheduled task was cancelled"; + } else { + return "waiting to rerun; next recurrence period: " + + step.getUnit().toMillis(step.getTimeDelay()) + "ms"; + } + } private static class Callback extends BodyExecutionCallback { + private final RetryStep step; private int left; + private final String id; + @Deprecated Callback(int count) { left = count; + this.step = null; + this.id = "-1"; } - /* Could be added, but seems unnecessary, given the message already printed in onFailure: - @Override public void onStart(StepContext context) { - try { - context.get(TaskListener.class).getLogger().println(left + " tries left"); - } catch (Exception x) { - context.onFailure(x); - } + Callback(String id, RetryStep step) { + this.id = id; + this.step = step; + left = step.getCount(); } - */ @Override public void onSuccess(StepContext context, Object result) { @@ -59,16 +161,27 @@ public void onFailure(StepContext context, Throwable t) { context.onFailure(t); return; } - left--; - if (left>0) { + int remaining = 0; + if(step != null) { + step.left--; + remaining = step.left; + } else { + left--; + remaining = left; + } + if (remaining>0) { TaskListener l = context.get(TaskListener.class); if (t instanceof AbortException) { l.error(t.getMessage()); } else { Functions.printStackTrace(t, l.error("Execution failed")); } - l.getLogger().println("Retrying"); - context.newBodyInvoker().withCallback(this).start(); + if(step != null && !step.isUseTimeDelay()) { + l.getLogger().println("Retrying"); + context.newBodyInvoker().withCallback(this).start(); + } else { + RetryStepExecution.retry(id, context); + } } else { // No need to print anything in this case, since it will be thrown up anyway. context.onFailure(t); diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/config.jelly b/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/config.jelly index a734bff4..92cd587b 100644 --- a/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/config.jelly +++ b/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/config.jelly @@ -28,4 +28,13 @@ THE SOFTWARE. + + + + + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-count.html b/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-count.html new file mode 100644 index 00000000..c6b0eca4 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-count.html @@ -0,0 +1,3 @@ +
+ The number of times to retry if an exception happens dury its body of execution. +
diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-timeDelay.html b/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-timeDelay.html new file mode 100644 index 00000000..d5bb6757 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-timeDelay.html @@ -0,0 +1,3 @@ +
+ The time delay that will be used in between retries. The default unit is 'SECONDS'. +
diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-unit.html b/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-unit.html new file mode 100644 index 00000000..7501d2e5 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-unit.html @@ -0,0 +1,3 @@ +
+ The unit of time to be applied to the time delay. +
diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-useTimeDelay.html b/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-useTimeDelay.html new file mode 100644 index 00000000..4ed1dac9 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/workflow/steps/RetryStep/help-useTimeDelay.html @@ -0,0 +1,3 @@ +
+ Use a time delay in between retries. +
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/steps/RetryStepTest.java b/src/test/java/org/jenkinsci/plugins/workflow/steps/RetryStepTest.java index c10daf23..4826c886 100644 --- a/src/test/java/org/jenkinsci/plugins/workflow/steps/RetryStepTest.java +++ b/src/test/java/org/jenkinsci/plugins/workflow/steps/RetryStepTest.java @@ -19,6 +19,7 @@ import org.jvnet.hudson.test.BuildWatcher; import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; +import java.util.concurrent.TimeUnit; import static org.junit.Assert.*; @@ -161,4 +162,87 @@ public void stackTraceOnError() throws Exception { r.assertLogContains("Try #2", run); r.assertLogContains("Done!", run); } -} + + @Test + public void retryTimeout() throws Exception { + WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition( + "int i = 0;\n" + + "retry(count: 3, timeDelay: 10, unit: 'SECONDS', useTimeDelay: true) {\n" + + " println 'Trying!'\n" + + " if (i++ < 2) error('oops');\n" + + " println 'Done!'\n" + + "}\n" + + "println 'Over!'" + , true)); + + long before = System.currentTimeMillis(); + QueueTaskFuture f = p.scheduleBuild2(0); + long after = System.currentTimeMillis(); + long difference = after - before; + long timeInSeconds = TimeUnit.MILLISECONDS.convert(difference, TimeUnit.SECONDS); + assertTrue(timeInSeconds > 20); + WorkflowRun b = r.assertBuildStatusSuccess(f); + + String log = JenkinsRule.getLog(b); + r.assertLogNotContains("\tat ", b); + + int idx = 0; + for (String msg : new String[] { + "Trying!", + "oops", + "Will try again", + "Retrying", + "Trying!", + "oops", + "Will try again", + "Retrying", + "Trying!", + "Done!", + "Over!", + }) { + idx = log.indexOf(msg, idx + 1); + assertTrue(msg + " not found", idx != -1); + } + + idx = 0; + for (String msg : new String[] { + "[Pipeline] retry", + "[Pipeline] {", + "[Pipeline] }", + "[Pipeline] {", + "[Pipeline] }", + "[Pipeline] {", + "[Pipeline] }", + "[Pipeline] // retry", + }) { + idx = log.indexOf(msg, idx + 1); + assertTrue(msg + " not found", idx != -1); + } + } + + @Test + public void stackTraceOnErrorWithTimeout() throws Exception { + WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition( + new CpsFlowDefinition( + "def count = 0\n" + + "retry(count: 2, timeDelay: 10, unit: 'SECONDS', useTimeDelay: true) {\n" + + " count += 1\n" + + " echo 'Try #' + count\n" + + " if (count == 1) {\n" + + " throw new Exception('foo')\n" + + " }\n" + + " echo 'Done!'\n" + + "}\n", + true)); + + WorkflowRun run = r.buildAndAssertSuccess(p); + r.assertLogContains("Try #1", run); + r.assertLogContains("ERROR: Execution failed", run); + r.assertLogContains("java.lang.Exception: foo", run); + r.assertLogContains("\tat ", run); + r.assertLogContains("Try #2", run); + r.assertLogContains("Done!", run); + } +} \ No newline at end of file diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/steps/RetryStepTest/pipelineOptionsRetryWithTimeout.groovy b/src/test/resources/org/jenkinsci/plugins/workflow/steps/RetryStepTest/pipelineOptionsRetryWithTimeout.groovy new file mode 100644 index 00000000..c86ba44c --- /dev/null +++ b/src/test/resources/org/jenkinsci/plugins/workflow/steps/RetryStepTest/pipelineOptionsRetryWithTimeout.groovy @@ -0,0 +1,19 @@ +pipeline { + agent any + options { + retry(count: 3, timeDelay: 10, unit: 'SECONDS', useTimeDelay: true) + } + stages { + stage('x') { + steps { + echo 'Trying!' + error('oops') + } + } + } + post { + always { + echo 'Done!' + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/steps/RetryStepTest/pipelineRetryStepWithTimeout.groovy b/src/test/resources/org/jenkinsci/plugins/workflow/steps/RetryStepTest/pipelineRetryStepWithTimeout.groovy new file mode 100644 index 00000000..ac3edefa --- /dev/null +++ b/src/test/resources/org/jenkinsci/plugins/workflow/steps/RetryStepTest/pipelineRetryStepWithTimeout.groovy @@ -0,0 +1,18 @@ +pipeline { + agent any + stages { + stage('x') { + steps { + retry(count: 3, timeDelay: 10, unit: 'SECONDS', useTimeDelay: true) { + echo 'Trying!' + error('oops') + } + } + } + } + post { + always { + echo 'Done!' + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/steps/RetryStepTest/stageOptionsRetryWithTimeout copy.groovy b/src/test/resources/org/jenkinsci/plugins/workflow/steps/RetryStepTest/stageOptionsRetryWithTimeout copy.groovy new file mode 100644 index 00000000..e351c00a --- /dev/null +++ b/src/test/resources/org/jenkinsci/plugins/workflow/steps/RetryStepTest/stageOptionsRetryWithTimeout copy.groovy @@ -0,0 +1,19 @@ +pipeline { + agent any + stages { + stage('x') { + options { + retry(count: 3, timeDelay: 10, unit: 'SECONDS', useTimeDelay: true) + } + steps { + echo 'Trying!' + error('oops') + } + } + } + post { + always { + echo 'Done!' + } + } +} \ No newline at end of file