Skip to content

Commit c5beea0

Browse files
committed
Schedule builds via a timer
The approach to scheduler the build by adding it to the queue with a delay has some disadvantes. When some triggers a build the regular way that can lead to the scheduled execution to be lost. This change adds another way of scheduling that will add the execution internally to a list. A periodic background work will run every minute and start the build accordingly. If the job should run not a 0 seconds, the remaining time is added as delay, so a job stays in the queue for not more then 60 seconds. This approach also allows the define a dedicated cause and the parameters of the job can be queried in the same UI where the start time is entered.
1 parent d2f9fc8 commit c5beea0

14 files changed

Lines changed: 505 additions & 18 deletions

File tree

pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@
6262
<groupId>io.jenkins.plugins</groupId>
6363
<artifactId>ionicons-api</artifactId>
6464
</dependency>
65+
<dependency>
66+
<groupId>org.jenkins-ci.plugins.workflow</groupId>
67+
<artifactId>workflow-job</artifactId>
68+
</dependency>
6569
<!-- JCasC test dependency -->
6670
<dependency>
6771
<groupId>io.jenkins.configuration-as-code</groupId>

src/main/java/org/jenkinsci/plugins/schedulebuild/ScheduleBuildAction.java

Lines changed: 156 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,25 @@
44
import hudson.model.Descriptor.FormException;
55
import hudson.model.Item;
66
import hudson.model.Job;
7+
import hudson.model.ParameterDefinition;
8+
import hudson.model.ParameterValue;
79
import hudson.model.ParametersDefinitionProperty;
810
import hudson.util.FormValidation;
911
import java.time.LocalDateTime;
1012
import java.time.ZonedDateTime;
1113
import java.time.format.DateTimeFormatter;
1214
import java.time.format.DateTimeParseException;
1315
import java.time.temporal.ChronoUnit;
16+
import java.util.ArrayList;
17+
import java.util.List;
1418
import java.util.Locale;
19+
import java.util.UUID;
1520
import java.util.logging.Level;
1621
import java.util.logging.Logger;
22+
import jenkins.management.Badge;
23+
import jenkins.model.ParameterizedJobMixIn;
24+
import jenkins.util.TimeDuration;
25+
import net.sf.json.JSONArray;
1726
import net.sf.json.JSONObject;
1827
import org.jenkins.ui.icon.IconSpec;
1928
import org.kohsuke.accmod.Restricted;
@@ -24,7 +33,9 @@
2433
import org.kohsuke.stapler.QueryParameter;
2534
import org.kohsuke.stapler.StaplerProxy;
2635
import org.kohsuke.stapler.StaplerRequest2;
36+
import org.kohsuke.stapler.StaplerResponse2;
2737
import org.kohsuke.stapler.interceptor.RequirePOST;
38+
import org.kohsuke.stapler.verb.POST;
2839

2940
public class ScheduleBuildAction implements Action, StaplerProxy, IconSpec {
3041

@@ -130,6 +141,150 @@ public long getQuietPeriodInSeconds() {
130141
return quietperiod;
131142
}
132143

144+
public Badge getBadge() {
145+
List<ScheduledRun> plannedBuilds = getPlannedBuilds();
146+
if (plannedBuilds.isEmpty()) {
147+
return null;
148+
}
149+
return new Badge(
150+
Integer.toString(plannedBuilds.size()),
151+
Messages.ScheduleBuildAction_BadgeTooltip(plannedBuilds.size()),
152+
Badge.Severity.INFO);
153+
}
154+
155+
@POST
156+
public void doCancelBuild(@QueryParameter String id, StaplerResponse2 rsp) {
157+
target.checkPermission(Item.CANCEL);
158+
try {
159+
ScheduledRun toRemove = null;
160+
for (ScheduledRun sr : ScheduleBuildGlobalConfiguration.get().getScheduledRuns()) {
161+
if (sr.getId().equals(id) && sr.getJob().equals(target.getFullName())) {
162+
toRemove = sr;
163+
break;
164+
}
165+
}
166+
if (toRemove != null) {
167+
ScheduleBuildGlobalConfiguration.get().getScheduledRuns().remove(toRemove);
168+
ScheduleBuildGlobalConfiguration.get().save();
169+
}
170+
rsp.sendRedirect2("./planned");
171+
} catch (Exception e) {
172+
throw new RuntimeException(e);
173+
}
174+
}
175+
176+
@POST
177+
public void doBuild(StaplerRequest2 req, StaplerResponse2 rsp) {
178+
target.checkPermission(Item.BUILD);
179+
try {
180+
JSONObject formData = req.getSubmittedForm();
181+
String date = formData.getString("date");
182+
boolean scheduleViaCron = formData.has("scheduleViaCron");
183+
184+
ZonedDateTime startDateTime, now = ZonedDateTime.now();
185+
186+
final String time = date.trim();
187+
try {
188+
startDateTime = parseDateTime(time)
189+
.atZone(ScheduleBuildGlobalConfiguration.get().getZoneId());
190+
} catch (DateTimeParseException ex) {
191+
LOGGER.log(Level.INFO, ex, () -> "Error parsing " + time);
192+
rsp.sendRedirect2("error");
193+
return;
194+
}
195+
196+
long delay = ChronoUnit.SECONDS.between(now, startDateTime);
197+
198+
if (delay + ScheduleBuildAction.SECURITY_MARGIN < 0) { // 120 sec security margin
199+
LOGGER.log(Level.INFO, () -> "Error security margin " + delay);
200+
rsp.sendRedirect2("error");
201+
return;
202+
}
203+
204+
if (!scheduleViaCron) {
205+
TimeDuration quietPeriod = new TimeDuration(Math.max(0, delay) * 1000);
206+
new ParameterizedJobMixIn() {
207+
@Override
208+
protected Job<?, ?> asJob() {
209+
return target;
210+
}
211+
}.doBuild(req, rsp, quietPeriod);
212+
return;
213+
}
214+
String id = UUID.randomUUID().toString();
215+
List<ParameterValue> values = new ArrayList<>();
216+
JSONObject scheduleViaCronObject = formData.getJSONObject("scheduleViaCron");
217+
boolean triggerOnMissed = scheduleViaCronObject.getBoolean("triggerOnMissed");
218+
219+
if (target instanceof ParameterizedJobMixIn.ParameterizedJob<?, ?> pj) {
220+
if (pj.isParameterized()) {
221+
222+
Object parameter = formData.get("parameter");
223+
224+
if (parameter != null) {
225+
ParametersDefinitionProperty prop = target.getProperty(ParametersDefinitionProperty.class);
226+
JSONArray a = JSONArray.fromObject(parameter);
227+
228+
for (Object o : a) {
229+
JSONObject jo = (JSONObject) o;
230+
String name = jo.getString("name");
231+
232+
ParameterDefinition d = prop.getParameterDefinition(name);
233+
if (d == null) throw new IllegalArgumentException("No such parameter definition: " + name);
234+
ParameterValue parameterValue = d.createValue(req, jo);
235+
if (parameterValue != null) {
236+
values.add(parameterValue);
237+
} else {
238+
throw new IllegalArgumentException("Cannot retrieve the parameter value: " + name);
239+
}
240+
}
241+
}
242+
}
243+
}
244+
245+
ScheduledRun sr = new ScheduledRun(
246+
id, target.getFullName(), startDateTime, values, triggerOnMissed, new ScheduledBuildCause());
247+
248+
ScheduleBuildGlobalConfiguration.get().getScheduledRuns().add(sr);
249+
ScheduleBuildGlobalConfiguration.get().save();
250+
251+
rsp.sendRedirect2("..");
252+
} catch (Exception e) {
253+
throw new RuntimeException(e);
254+
}
255+
}
256+
257+
public boolean hasPlannedBuilds() {
258+
for (ScheduledRun sr : ScheduleBuildGlobalConfiguration.get().getScheduledRuns()) {
259+
if (sr.getJob().equals(target.getFullName())) {
260+
return true;
261+
}
262+
}
263+
return false;
264+
}
265+
266+
public List<ScheduledRun> getPlannedBuilds() {
267+
return ScheduleBuildGlobalConfiguration.get().getScheduledRuns().stream()
268+
.filter(r -> r.getJob().equals(target.getFullName()))
269+
.toList();
270+
}
271+
272+
public boolean isParameterized() {
273+
if (target instanceof ParameterizedJobMixIn.ParameterizedJob<?, ?> pj) {
274+
return pj.isParameterized();
275+
}
276+
return false;
277+
}
278+
279+
public List<ParameterDefinition> getParameterDefinitions() {
280+
if (target instanceof ParameterizedJobMixIn.ParameterizedJob<?, ?> pj) {
281+
if (pj.isParameterized()) {
282+
return target.getProperty(ParametersDefinitionProperty.class).getParameterDefinitions();
283+
}
284+
}
285+
return List.of();
286+
}
287+
133288
@RequirePOST
134289
public HttpResponse doNext(@QueryParameter String date, @AncestorInPath Item item) {
135290
if (item == null) {
@@ -150,7 +305,7 @@ public HttpResponse doNext(@QueryParameter String date, @AncestorInPath Item ite
150305
quietperiod = ChronoUnit.SECONDS.between(now, ddate);
151306
LOGGER.log(Level.FINER, () -> "Quietperiod: " + quietperiod);
152307
if (quietperiod + ScheduleBuildAction.SECURITY_MARGIN < 0) { // 120 sec security margin
153-
LOGGER.log(Level.INFO, () -> "Error security margin" + quietperiod);
308+
LOGGER.log(Level.INFO, () -> "Error security margin " + quietperiod);
154309
return HttpResponses.redirectTo("error");
155310
}
156311
return HttpResponses.forwardToView(this, "redirect");
@@ -169,13 +324,6 @@ private LocalDateTime parseDateTime(String time) {
169324
throw exception;
170325
}
171326

172-
public boolean isJobParameterized() {
173-
ParametersDefinitionProperty paramDefinitions = target.getProperty(ParametersDefinitionProperty.class);
174-
return paramDefinitions != null
175-
&& paramDefinitions.getParameterDefinitions() != null
176-
&& paramDefinitions.getParameterDefinitions().size() > 0;
177-
}
178-
179327
@Restricted(NoExternalUse.class)
180328
public String getDateTimeFormatting() {
181329
return DATE_TIME_PATTERN;

src/main/java/org/jenkinsci/plugins/schedulebuild/ScheduleBuildGlobalConfiguration.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
import java.time.ZonedDateTime;
1414
import java.time.format.DateTimeFormatter;
1515
import java.time.format.DateTimeParseException;
16+
import java.util.Collections;
1617
import java.util.Date;
1718
import java.util.Locale;
1819
import java.util.Set;
20+
import java.util.SortedSet;
1921
import java.util.TimeZone;
2022
import java.util.TreeSet;
2123
import java.util.logging.Level;
@@ -32,6 +34,8 @@
3234
@Symbol("scheduleBuild")
3335
public class ScheduleBuildGlobalConfiguration extends GlobalConfiguration {
3436

37+
private SortedSet<ScheduledRun> scheduledRuns = null;
38+
3539
public static ScheduleBuildGlobalConfiguration get() {
3640
final ScheduleBuildGlobalConfiguration configuration =
3741
GlobalConfiguration.all().get(ScheduleBuildGlobalConfiguration.class);
@@ -67,6 +71,13 @@ public ScheduleBuildGlobalConfiguration() {
6771
defaultScheduleLocalTime = LocalTime.parse(defaultStartTime, getTimeFormatter());
6872
}
6973

74+
public SortedSet<ScheduledRun> getScheduledRuns() {
75+
if (scheduledRuns == null) {
76+
scheduledRuns = Collections.synchronizedSortedSet(new TreeSet<>());
77+
}
78+
return scheduledRuns;
79+
}
80+
7081
@Override
7182
@SuppressFBWarnings(value = "UWF_UNWRITTEN_FIELD", justification = "Written by xstream")
7283
public void load() {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package org.jenkinsci.plugins.schedulebuild;
2+
3+
import hudson.model.Cause;
4+
5+
public class ScheduledBuildCause extends Cause.UserIdCause {}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package org.jenkinsci.plugins.schedulebuild;
2+
3+
import edu.umd.cs.findbugs.annotations.NonNull;
4+
import hudson.model.Action;
5+
import hudson.model.Cause;
6+
import hudson.model.CauseAction;
7+
import hudson.model.Job;
8+
import hudson.model.ParameterValue;
9+
import hudson.model.ParametersAction;
10+
import hudson.model.ParametersDefinitionProperty;
11+
import hudson.model.TaskListener;
12+
import java.io.Serializable;
13+
import java.time.ZonedDateTime;
14+
import java.time.format.DateTimeFormatter;
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
import jenkins.model.Jenkins;
18+
import jenkins.model.ParameterizedJobMixIn;
19+
import org.kohsuke.accmod.Restricted;
20+
import org.kohsuke.accmod.restrictions.NoExternalUse;
21+
22+
@Restricted(NoExternalUse.class)
23+
public class ScheduledRun implements Serializable, Comparable<ScheduledRun> {
24+
25+
private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss v";
26+
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);
27+
28+
private final String id;
29+
private final String job;
30+
private final ZonedDateTime time;
31+
private final List<ParameterValue> values;
32+
private final boolean triggerOnMissed;
33+
private final Cause cause;
34+
35+
public ScheduledRun(
36+
String id,
37+
String job,
38+
ZonedDateTime time,
39+
@NonNull List<ParameterValue> values,
40+
boolean triggerOnMissed,
41+
Cause cause) {
42+
this.id = id;
43+
this.time = time;
44+
this.values = values;
45+
this.triggerOnMissed = triggerOnMissed;
46+
this.job = job;
47+
this.cause = cause;
48+
}
49+
50+
public String getId() {
51+
return id;
52+
}
53+
54+
public ZonedDateTime getTime() {
55+
return time;
56+
}
57+
58+
public String getFormattedTime() {
59+
return DATE_TIME_FORMATTER.format(time);
60+
}
61+
62+
public boolean isTriggerOnMissed() {
63+
return triggerOnMissed;
64+
}
65+
66+
public List<ParameterValue> getValues() {
67+
return values;
68+
}
69+
70+
public Cause getCause() {
71+
return cause;
72+
}
73+
74+
public String getJob() {
75+
return job;
76+
}
77+
78+
public void run(TaskListener listener, int delay) {
79+
Job<?, ?> j = Jenkins.get().getItemByFullName(job, Job.class);
80+
if (j != null) {
81+
run(j, delay);
82+
} else {
83+
if (listener != null) {
84+
listener.error("Job " + job + " not found, cannot schedule build");
85+
}
86+
}
87+
}
88+
89+
public String getParametersTooltip() {
90+
StringBuilder sb = new StringBuilder();
91+
sb.append("Build Parameters:");
92+
for (ParameterValue v : values) {
93+
sb.append("\n");
94+
sb.append(v.getName()).append("=").append(v.getValue());
95+
}
96+
return sb.toString();
97+
}
98+
99+
public void run(Job<?, ?> job, int delay) {
100+
ParametersDefinitionProperty pp = job.getProperty(ParametersDefinitionProperty.class);
101+
List<Action> actions = new ArrayList<>();
102+
if (cause != null) {
103+
actions.add(new CauseAction(cause));
104+
}
105+
if (pp != null) {
106+
actions.add(new ParametersAction(values));
107+
}
108+
// Calculate the delay in seconds
109+
// The worker runs every minute at the 0 seconds, but we might have scheduled a job at 30 seconds past the
110+
// minute
111+
// So we need to calculate the delay in seconds, for which the job stays in the queue so it starts at the right
112+
// time
113+
ParameterizedJobMixIn.scheduleBuild2(job, delay, actions.toArray(new Action[0]));
114+
}
115+
116+
@Override
117+
public int compareTo(ScheduledRun o) {
118+
int c = time.compareTo(o.time);
119+
if (c != 0) {
120+
return c;
121+
}
122+
return id.compareTo(o.id);
123+
}
124+
}

0 commit comments

Comments
 (0)