/* * The MIT License * * Copyright (c) 2004-2009, Sun Microsystems, Inc., Kohsuke Kawaguchi, InfraDNA, Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ package hudson.scheduler; import antlr.ANTLRException; import java.io.StringReader; import java.util.Calendar; import java.util.TimeZone; import java.util.GregorianCalendar; import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; import static java.util.Calendar.*; import javax.annotation.CheckForNull; /** * Table for driving scheduled tasks. * * @author Kohsuke Kawaguchi */ public final class CronTab { /** * bits[0]: minutes * bits[1]: hours * bits[2]: days * bits[3]: months * * false:not scheduled <-> true scheduled */ final long[] bits = new long[4]; int dayOfWeek; /** * Textual representation. */ private String spec; /** * Optional timezone string for calendar */ private @CheckForNull String specTimezone; public CronTab(String format) throws ANTLRException { this(format,null); } public CronTab(String format, Hash hash) throws ANTLRException { this(format,1,hash); } /** * @deprecated as of 1.448 * Use {@link #CronTab(String, int, Hash)} */ @Deprecated public CronTab(String format, int line) throws ANTLRException { set(format, line, null); } /** * @param hash * Used to spread out token like "@daily". Null to preserve the legacy behaviour * of not spreading it out at all. */ public CronTab(String format, int line, Hash hash) throws ANTLRException { this(format, line, hash, null); } /** * @param timezone * Used to schedule cron in a different timezone. Null to use the default system * timezone * @since 1.615 */ public CronTab(String format, int line, Hash hash, @CheckForNull String timezone) throws ANTLRException { set(format, line, hash, timezone); } private void set(String format, int line, Hash hash) throws ANTLRException { set(format, line, hash, null); } /** * @since 1.615 */ private void set(String format, int line, Hash hash, String timezone) throws ANTLRException { CrontabLexer lexer = new CrontabLexer(new StringReader(format)); lexer.setLine(line); CrontabParser parser = new CrontabParser(lexer); parser.setHash(hash); spec = format; specTimezone = timezone; parser.startRule(this); if((dayOfWeek&(1<<7))!=0) { dayOfWeek |= 1; // copy bit 7 over to bit 0 dayOfWeek &= ~(1<<7); // clear bit 7 or CalendarField#ceil will return an invalid value 7 } } /** * Returns true if the given calendar matches */ boolean check(Calendar cal) { Calendar checkCal = cal; if(specTimezone != null && !specTimezone.isEmpty()) { Calendar tzCal = Calendar.getInstance(TimeZone.getTimeZone(specTimezone)); tzCal.setTime(cal.getTime()); checkCal = tzCal; } if(!checkBits(bits[0],checkCal.get(MINUTE))) return false; if(!checkBits(bits[1],checkCal.get(HOUR_OF_DAY))) return false; if(!checkBits(bits[2],checkCal.get(DAY_OF_MONTH))) return false; if(!checkBits(bits[3],checkCal.get(MONTH)+1)) return false; if(!checkBits(dayOfWeek,checkCal.get(Calendar.DAY_OF_WEEK)-1)) return false; return true; } private static abstract class CalendarField { /** * {@link Calendar} field ID. */ final int field; /** * Lower field is a calendar field whose value needs to be reset when we change the value in this field. * For example, if we modify the value in HOUR, MINUTES must be reset. */ final CalendarField lowerField; /** * Whether this field is 0-origin or 1-origin differs between Crontab and {@link Calendar}, * so this field adjusts that. If crontab is 1 origin and calendar is 0 origin, this field is 1 * that is the value is {@code (cronOrigin-calendarOrigin)} */ final int offset; /** * When we reset this field, we set the field to this value. * For example, resetting {@link Calendar#DAY_OF_MONTH} means setting it to 1. */ final int min; /** * If this calendar field has other aliases such that a change in this field * modifies other field values, then true. */ final boolean redoAdjustmentIfModified; /** * What is this field? Useful for debugging */ @SuppressWarnings("unused") private final String displayName; private CalendarField(String displayName, int field, int min, int offset, boolean redoAdjustmentIfModified, CalendarField lowerField) { this.displayName = displayName; this.field = field; this.min = min; this.redoAdjustmentIfModified= redoAdjustmentIfModified; this.lowerField = lowerField; this.offset = offset; } /** * Gets the current value of this field in the given calendar. */ int valueOf(Calendar c) { return c.get(field)+offset; } void addTo(Calendar c, int i) { c.add(field,i); } void setTo(Calendar c, int i) { c.set(field,i-offset); } void clear(Calendar c) { setTo(c, min); } /** * Given the value 'n' (which represents the current value), finds the smallest x such that: * 1) x matches the specified {@link CronTab} (as far as this field is concerned.) * 2) x>=n (inclusive) * * If there's no such bit, return -1. Note that if 'n' already matches the crontab, the same n will be returned. */ private int ceil(CronTab c, int n) { long bits = bits(c); while ((bits|(1L<<n))!=bits) { if (n>60) return -1; n++; } return n; } /** * Given a bit mask, finds the first bit that's on, and return its index. */ private int first(CronTab c) { return ceil(c,0); } private int floor(CronTab c, int n) { long bits = bits(c); while ((bits|(1L<<n))!=bits) { if (n==0) return -1; n--; } return n; } private int last(CronTab c) { return floor(c,63); } /** * Extracts the bit masks from the given {@link CronTab} that matches this field. */ abstract long bits(CronTab c); /** * Increment the next field. */ abstract void rollUp(Calendar cal, int i); private static final CalendarField MINUTE = new CalendarField("minute", Calendar.MINUTE, 0, 0, false, null) { long bits(CronTab c) { return c.bits[0]; } void rollUp(Calendar cal, int i) { cal.add(Calendar.HOUR_OF_DAY,i); } }; private static final CalendarField HOUR = new CalendarField("hour", Calendar.HOUR_OF_DAY, 0, 0, false, MINUTE) { long bits(CronTab c) { return c.bits[1]; } void rollUp(Calendar cal, int i) { cal.add(Calendar.DAY_OF_MONTH,i); } }; private static final CalendarField DAY_OF_MONTH = new CalendarField("day", Calendar.DAY_OF_MONTH, 1, 0, true, HOUR) { long bits(CronTab c) { return c.bits[2]; } void rollUp(Calendar cal, int i) { cal.add(Calendar.MONTH,i); } }; private static final CalendarField MONTH = new CalendarField("month", Calendar.MONTH, 1, 1, false, DAY_OF_MONTH) { long bits(CronTab c) { return c.bits[3]; } void rollUp(Calendar cal, int i) { cal.add(Calendar.YEAR,i); } }; private static final CalendarField DAY_OF_WEEK = new CalendarField("dow", Calendar.DAY_OF_WEEK, 1,-1, true, HOUR) { long bits(CronTab c) { return c.dayOfWeek; } void rollUp(Calendar cal, int i) { cal.add(Calendar.DAY_OF_WEEK, 7 * i); } @Override void setTo(Calendar c, int i) { int v = i-offset; int was = c.get(field); c.set(field,v); final int firstDayOfWeek = c.getFirstDayOfWeek(); if (v < firstDayOfWeek && was >= firstDayOfWeek) { // in crontab, the first DoW is always Sunday, but in Java, it can be Monday or in theory arbitrary other days. // When first DoW is 1/2 Monday, calendar points to 1/2 Monday, setting the DoW to Sunday makes // the calendar moves forward to 1/8 Sunday, instead of 1/1 Sunday. So we need to compensate that effect here. addTo(c,-7); } else if (was < firstDayOfWeek && firstDayOfWeek <= v) { // If we wrap the other way around, we need to adjust in the opposite direction of above. addTo(c, 7); } } }; private static final CalendarField[] ADJUST_ORDER = { MONTH, DAY_OF_MONTH, DAY_OF_WEEK, HOUR, MINUTE }; } /** * Computes the nearest future timestamp that matches this cron tab. * <p> * More precisely, given the time 't', computes another smallest time x such that: * * <ul> * <li>x >= t (inclusive) * <li>x matches this crontab * </ul> * * <p> * Note that if t already matches this cron, it's returned as is. */ public Calendar ceil(long t) { Calendar cal = new GregorianCalendar(Locale.US); cal.setTimeInMillis(t); return ceil(cal); } /** * See {@link #ceil(long)}. * * This method modifies the given calendar and returns the same object. */ public Calendar ceil(Calendar cal) { OUTER: while (true) { for (CalendarField f : CalendarField.ADJUST_ORDER) { int cur = f.valueOf(cal); int next = f.ceil(this,cur); if (cur==next) continue; // this field is already in a good shape. move on to next // we are modifying this field, so clear all the lower level fields for (CalendarField l=f.lowerField; l!=null; l=l.lowerField) l.clear(cal); if (next<0) { // we need to roll over to the next field. f.rollUp(cal, 1); f.setTo(cal,f.first(this)); // since higher order field is affected by this, we need to restart from all over continue OUTER; } else { f.setTo(cal,next); if (f.redoAdjustmentIfModified) continue OUTER; // when we modify DAY_OF_MONTH and DAY_OF_WEEK, do it all over from the top } } return cal; // all fields adjusted } } /** * Computes the nearest past timestamp that matched this cron tab. * <p> * More precisely, given the time 't', computes another smallest time x such that: * * <ul> * <li>x <= t (inclusive) * <li>x matches this crontab * </ul> * * <p> * Note that if t already matches this cron, it's returned as is. */ public Calendar floor(long t) { Calendar cal = new GregorianCalendar(Locale.US); cal.setTimeInMillis(t); return floor(cal); } /** * See {@link #floor(long)} * * This method modifies the given calendar and returns the same object. */ public Calendar floor(Calendar cal) { OUTER: while (true) { for (CalendarField f : CalendarField.ADJUST_ORDER) { int cur = f.valueOf(cal); int next = f.floor(this,cur); if (cur==next) continue; // this field is already in a good shape. move on to next // we are modifying this field, so clear all the lower level fields for (CalendarField l=f.lowerField; l!=null; l=l.lowerField) l.clear(cal); if (next<0) { // we need to borrow from the next field. f.rollUp(cal,-1); // the problem here, in contrast with the ceil method, is that // the maximum value of the field is not always a fixed value (that is, day of month) // so we zero-clear all the lower fields, set the desired value +1, f.setTo(cal,f.last(this)); f.addTo(cal,1); // then subtract a minute to achieve maximum values on all the lower fields, // with the desired value in 'f' CalendarField.MINUTE.addTo(cal,-1); // since higher order field is affected by this, we need to restart from all over continue OUTER; } else { f.setTo(cal,next); f.addTo(cal,1); CalendarField.MINUTE.addTo(cal,-1); if (f.redoAdjustmentIfModified) continue OUTER; // when we modify DAY_OF_MONTH and DAY_OF_WEEK, do it all over from the top } } return cal; // all fields adjusted } } void set(String format, Hash hash) throws ANTLRException { set(format,1,hash); } /** * Returns true if n-th bit is on. */ private boolean checkBits(long bitMask, int n) { return (bitMask|(1L<<n))==bitMask; } public String toString() { return super.toString()+"["+ toString("minute",bits[0])+','+ toString("hour",bits[1])+','+ toString("dayOfMonth",bits[2])+','+ toString("month",bits[3])+','+ toString("dayOfWeek",dayOfWeek)+']'; } private String toString(String key, long bit) { return key+'='+Long.toHexString(bit); } /** * Checks if this crontab entry looks reasonable, * and if not, return an warning message. * * <p> * The point of this method is to catch syntactically correct * but semantically suspicious combinations, like * "* 0 * * *" */ public @CheckForNull String checkSanity() { OUTER: for (int i = 0; i < 5; i++) { long bitMask = (i<4)?bits[i]:(long)dayOfWeek; for( int j=BaseParser.LOWER_BOUNDS[i]; j<=BaseParser.UPPER_BOUNDS[i]; j++ ) { if(!checkBits(bitMask,j)) { // this rank has a sparse entry. // if we have a sparse rank, one of them better be the left-most. if(i>0) return Messages.CronTab_do_you_really_mean_every_minute_when_you(spec, "H " + spec.substring(spec.indexOf(' ') + 1)); // once we find a sparse rank, upper ranks don't matter break OUTER; } } } int daysOfMonth = 0; for (int i = 1; i < 31; i++) { if (checkBits(bits[2], i)) { daysOfMonth++; } } if (daysOfMonth > 5 && daysOfMonth < 28) { // a bit arbitrary return Messages.CronTab_short_cycles_in_the_day_of_month_field_w(); } String hashified = hashify(spec); if (hashified != null) { return Messages.CronTab_spread_load_evenly_by_using_rather_than_(hashified, spec); } return null; } /** * Checks a prospective crontab specification to see if it could benefit from balanced hashes. * @param spec a (legal) spec * @return a similar spec that uses a hash, if such a transformation is necessary; null if it is OK as is * @since 1.510 */ public static @CheckForNull String hashify(String spec) { if (spec.contains("H")) { // if someone is already using H, presumably he knows what it is, so a warning is likely false positive return null; } else if (spec.startsWith("*/")) {// "*/15 ...." (every N minutes) to hash return "H" + spec.substring(1); } else if (spec.matches("\\d+ .+")) {// "0 ..." (certain minute) to hash return "H " + spec.substring(spec.indexOf(' ') + 1); } else { Matcher m = Pattern.compile("0(,(\\d+)(,\\d+)*)( .+)").matcher(spec); if (m.matches()) { // 0,15,30,45 to H/15 int period = Integer.parseInt(m.group(2)); if (period > 0) { StringBuilder b = new StringBuilder(); for (int i = period; i < 60; i += period) { b.append(',').append(i); } if (b.toString().equals(m.group(1))) { return "H/" + period + m.group(4); } } } return null; } } }