001/*
002 * Stallion Core: A Modern Web Framework
003 *
004 * Copyright (C) 2015 - 2016 Stallion Software LLC.
005 *
006 * This program is free software: you can redistribute it and/or modify it under the terms of the
007 * GNU General Public License as published by the Free Software Foundation, either version 2 of
008 * the License, or (at your option) any later version. This program is distributed in the hope that
009 * it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
010 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public
011 * License for more details. You should have received a copy of the GNU General Public License
012 * along with this program.  If not, see <http://www.gnu.org/licenses/gpl-2.0.html>.
013 *
014 *
015 *
016 */
017
018package io.stallion.jobs;
019
020import io.stallion.Context;
021import io.stallion.exceptions.AppException;
022import io.stallion.exceptions.ConfigException;
023import io.stallion.users.IUser;
024import io.stallion.users.UserController;
025import io.stallion.utils.DateUtils;
026import org.apache.commons.lang3.StringUtils;
027
028import java.time.*;
029import java.util.Random;
030import java.util.concurrent.ThreadLocalRandom;
031
032/**
033 * A data structure representing the schedule for a recurring task,
034 * with helper methods to find the next recurring time.
035 */
036public class Schedule {
037    public static final int MONDAY = 1;
038    public static final int TUESDAY = 2;
039    public static final int WEDNESDAY = 3;
040    public static final int THURSDAY = 4;
041    public static final int FRIDAY = 5;
042    public static final int SATURDAY = 6;
043    public static final int SUNDAY = 7;
044
045    private String timeZoneId = "";
046    private Long timeZoneForUserId;
047
048    private Minutes _minutes = new Minutes();
049    private Hours _hours = new Hours();
050    private Days _days = new Days();
051    private Months _months = new Months();
052
053    /**
054     * Gets the next datetime matching the schedule, in the Users timezone
055     *
056     * @return
057     */
058    public ZonedDateTime nextAt() {
059        ZoneId zoneId = null;
060        if (!StringUtils.isEmpty(timeZoneId)) {
061            zoneId = ZoneId.of(timeZoneId);
062        } else if (timeZoneForUserId != null) {
063            IUser user = UserController.instance().forId(timeZoneForUserId);
064            if (user != null && !StringUtils.isEmpty(user.getTimeZoneId())) {
065                zoneId = ZoneId.of(user.getTimeZoneId());
066            }
067        }
068
069        if (zoneId == null) {
070            zoneId = ZoneId.of("UTC");
071        }
072        ZonedDateTime dt = ZonedDateTime.now(zoneId);
073        return nextAt(dt);
074    }
075
076    public ZoneId getZoneId() {
077        ZoneId zoneId = null;
078        if (!StringUtils.isEmpty(timeZoneId)) {
079            zoneId = ZoneId.of(timeZoneId);
080        } else if (timeZoneForUserId != null) {
081            IUser user = UserController.instance().forId(timeZoneForUserId);
082            if (user != null && !StringUtils.isEmpty(user.getTimeZoneId())) {
083                zoneId = ZoneId.of(user.getTimeZoneId());
084            }
085        }
086        if (zoneId == null) {
087            zoneId = ZoneId.of("UTC");
088        }
089        return zoneId;
090    }
091
092    /**
093     * Gets the next datetime matching the schedule, in UTC
094     *
095     * @return
096     */
097    public ZonedDateTime utcNextAt() {
098        return nextAt(DateUtils.utcNow());
099    }
100
101    /**
102     * Gets the next datetime matching the schedule, in the application timezone, as defined in the settings
103     *
104     * @return
105     */
106    public ZonedDateTime serverLocalNextAt() {
107        ZoneId zoneId = null;
108        if (zoneId == null) {
109            zoneId = Context.getSettings().getTimeZoneId();
110        }
111        if (zoneId == null) {
112            zoneId = ZoneId.of("UTC");
113        }
114        ZonedDateTime dt = ZonedDateTime.now(zoneId);
115        return nextAt(dt);
116    }
117
118    /**
119     * Gets the next datetime matching the schedule, passing in the current
120     * date from which to look. Used for testing.
121     *
122     * @param startingFrom
123     * @return
124     */
125    public ZonedDateTime nextAt(ZonedDateTime startingFrom) {
126        if (!startingFrom.getZone().equals(getZoneId())) {
127            startingFrom = startingFrom.withZoneSameInstant(getZoneId());
128        }
129        ZonedDateTime dt = new NextDateTimeFinder(startingFrom).find();
130        return dt.withZoneSameInstant(ZoneId.of("UTC"));
131    }
132
133    /**
134     * Returns true if this schedule matches the passed in datetime
135     * @param dt
136     * @return
137     */
138    public boolean matchesDateTime(ZonedDateTime dt) {
139        ZonedDateTime now = dt.withSecond(0).withNano(0);
140        ZonedDateTime nextRun = nextAt(now).withSecond(0).withNano(0);
141        if (now.isEqual(nextRun)) {
142            return true;
143        }
144        return false;
145    }
146
147    /**
148     * Get a schedule instance that will run every night, at some random minute
149     * during the 5AM hour
150     * @return
151     */
152    public static Schedule daily() {
153        return new Schedule()
154                .randomMinute()
155                .hours(5)
156                .everyDay()
157                .everyMonth()
158                .verify();
159    }
160
161    /**
162     * Run at the top of every hour
163     * @return
164     */
165    public static Schedule hourly() {
166        return new Schedule()
167                .minutes(0)
168                .everyHour()
169                .everyDay()
170                .everyMonth()
171                .verify();
172    }
173
174    /** Get a schedule instance that will run on at 5AM UTC on the second and fourth Friday, every month
175     *
176     */
177    public static Schedule paydays() {
178        return new Schedule()
179                .minutes(0)
180                .hours(5)
181                // Second and fourth Friday of the month
182                .daysOfWeekMonth(DayOfWeek.FRIDAY, 2, 4)
183                .everyMonth()
184                .verify();
185
186    }
187
188
189
190    /**
191     * Set which minutes of the hour the task will run at
192     *
193     * @param minutes
194     * @return
195     */
196    public Schedule minutes(Integer ...minutes) {
197        this._minutes.verifyAndUpdateUnset();
198        for (Integer i : minutes) {
199            this._minutes.add(i);
200        }
201        return this;
202    }
203
204    /**
205     * Set the schedule to run every minute
206     * @return
207     */
208    public Schedule everyMinute() {
209        this._minutes.verifyAndUpdateUnset();
210        this._minutes.setEvery(true);
211        return this;
212    }
213
214    /**
215     * Set the schedule to run on a random minute every hour
216     * @return
217     */
218    public Schedule randomMinute() {
219        this._minutes.verifyAndUpdateUnset();
220        this._minutes.setRandom(true);
221        return this;
222    }
223
224    /**
225     * Set which hours of the day the task should run at
226     * @param hours
227     * @return
228     */
229    public Schedule hours(Integer ...hours) {
230        this._hours.verifyAndUpdateUnset();
231        for(Integer hour: hours) {
232            this._hours.add(hour);
233        }
234        return this;
235    }
236
237    /**
238     * Set the task to run each hour of the day
239     * @return
240     */
241    public Schedule everyHour() {
242        this._hours.verifyAndUpdateUnset();
243        this._hours.setEvery(true);
244        return this;
245    }
246
247    /**
248     * Set the task to run every single day
249     * @return
250     */
251    public Schedule everyDay() {
252        this._days.verifyAndUpdateUnset();
253        this._days.setEvery(true);
254        return this;
255    }
256
257    /**
258     * Set the days of the month on which the schedule is to run.
259     *
260     * @param daysOfMonth
261     * @return
262     */
263    public Schedule daysOfMonth(Integer ...daysOfMonth) {
264        this._days.verifyAndUpdateUnset();
265        this._days.setIntervalType(Days.IntervalType.DAY_OF_MONTH);
266        for(Integer day: daysOfMonth) {
267            this._days.add(day);
268        }
269        return this;
270    }
271
272    /**
273     * Set the days of the week on which the task is to run.
274     *
275     * @param days
276     * @return
277     */
278    public Schedule daysOfWeek(DayOfWeek ...days) {
279        this._days.verifyAndUpdateUnset();
280        this._days.setIntervalType(Days.IntervalType.DAY_OF_WEEK);
281        for(DayOfWeek day: days) {
282            this._days.add(day.getValue());
283        }
284        return this;
285    }
286
287
288    /**
289     * Set the day of the week and which weeks of the month combination
290     * the schedule is to match.
291     *
292     * @param dayOfWeek
293     * @param weeksOfMonth
294     * @return
295     */
296    public Schedule daysOfWeekMonth(DayOfWeek dayOfWeek, Integer ...weeksOfMonth) {
297        this._days.verifyAndUpdateUnset();
298        this._days.setIntervalType(Days.IntervalType.DAY_AND_WEEKS_OF_MONTH);
299        this._days.setDayOfWeek(dayOfWeek);
300        for(Integer weekOfMonth: weeksOfMonth) {
301            this._days.add(weekOfMonth);
302        }
303        return this;
304    }
305
306    /**
307     * Runs the given days of the week, every other week.
308     *
309     * @param startingAt
310     * @param days
311     * @return
312     */
313    public Schedule daysBiweekly(Long startingAt, DayOfWeek ...days) {
314        this._days.verifyAndUpdateUnset();
315        this._days.setIntervalType(Days.IntervalType.BIWEEKLY_DAY_OF_WEEK);
316        for(DayOfWeek day: days) {
317            this._days.add(day.getValue());
318        }
319        ZonedDateTime startingWeek = ZonedDateTime.ofInstant(Instant.ofEpochMilli(startingAt), ZoneId.of("UTC"));
320        // Get the Monday 12PM of that week
321        startingWeek = startingWeek.minusDays(startingWeek.getDayOfWeek().getValue()-1).withSecond(0).withHour(12).withMinute(0).withNano(0);
322        this._days.setStartingDate(startingWeek);
323        return this;
324    }
325
326    /**
327     * Runs every month
328     * @return
329     */
330    public Schedule everyMonth() {
331        this._months.verifyAndUpdateUnset();
332        this._months.setEvery(true);
333        return this;
334    }
335
336    /**
337     * Set to runs on the given month(s)
338     *
339     * @param months
340     * @return
341     */
342    public Schedule months(Integer ...months) {
343        this._months.verifyAndUpdateUnset();
344        for(Integer month: months) {
345            this._months.add(month);
346        }
347        return this;
348    }
349
350    /**
351     * Verify that this is a valid schedule.
352     *
353     * @return
354     * @throws ConfigException
355     */
356    public Schedule verify() throws ConfigException {
357        for (BaseTimings timing: new BaseTimings[]{_minutes, _hours, _days, _months}) {
358            if (timing.isUnset()) {
359                throw new ConfigException("You did not configure the schedule field: " + timing.getClass().getSimpleName());
360            }
361            if (timing.size() == 0 && !timing.isEvery() && !timing.isRandom()) {
362                throw new ConfigException("Timings are not set, nor is every interval set for schedule field: " + timing.getClass().getSimpleName());
363            }
364        }
365        return this;
366    }
367
368    public Schedule timezone(String timeZoneId){
369        this.timeZoneId = timeZoneId;
370        return this;
371    }
372
373    /**
374     * Helper class to actually do the work of finding the next run time.
375     */
376    public class NextDateTimeFinder {
377        private ZonedDateTime startingFrom;
378
379        public NextDateTimeFinder(ZonedDateTime startingFrom) {
380            this.startingFrom = startingFrom;
381        }
382
383
384        public ZonedDateTime find() {
385
386            // Verify the schedule is valid
387            verify();
388
389            ZonedDateTime dt = startingFrom;
390            dt = dt.withSecond(0).withNano(0);
391
392            if (_minutes.isRandom()) {
393                dt.withMinute(ThreadLocalRandom.current().nextInt(0, 60));
394            }
395
396
397            for(int brake=0; brake<2000; brake++) { // fake while-loop with emergency brake pattern. Worst case(s): It is February 1, task runs on January 31st, we loop 11 plus 30 times
398                Mismatched mismatched = checkMismatch(dt);
399                if (mismatched.equals(Mismatched.NONE)) {
400                    return dt;
401                } else if (mismatched.equals(Mismatched.MONTH)) {
402                    // Month doesn't match the schedule, skip to first day of the next month
403                    dt = dt.plusMonths(1).withDayOfMonth(1).withHour(0).withMinute(0);
404                } else if (mismatched.equals(Mismatched.DAY)) {
405                    /// Day doesn't match the schedule, skip to the first hour and minute of the next day
406                    dt = dt.plusDays(1).withHour(0).withMinute(0);
407                } else if (mismatched.equals(Mismatched.HOUR)) {
408                    // Hour doesn't match the schedule, skip to the first minute of the next hour
409                    dt = dt.plusHours(hoursToAdd(dt)).withMinute(0);
410                } else if (mismatched.equals(Mismatched.MINUTE)) {
411                    // Minute doesn't match the schedule, find the next viable minute
412                    dt = dt.plusMinutes(minutesToAdd(dt));
413                } else {
414                    throw new AppException("This should never happen but it did. Invalid DealBreaker value: " + mismatched);
415                }
416            }
417            throw new ConfigException("This should never happen. A schedule was created from which a next date could not be found.");
418        }
419
420        private Mismatched checkMismatch(ZonedDateTime dt) {
421            if (!_months.isEvery() && !_months.contains(dt.getMonthValue())) {
422                return Mismatched.MONTH;
423            }
424            if (!_days.isEvery()) {
425                if (Days.IntervalType.DAY_OF_MONTH.equals(_days.getIntervalType())) {
426                    if (!_days.contains(dt.getDayOfMonth())) {
427                        return Mismatched.DAY;
428                    }
429                } else if (Days.IntervalType.DAY_OF_WEEK.equals(_days.getIntervalType())) {
430                    if (!_days.contains(dt.getDayOfWeek().getValue())) {
431                        return Mismatched.DAY;
432                    }
433                } else if (Days.IntervalType.DAY_AND_WEEKS_OF_MONTH.equals(_days.getIntervalType())) {
434                    if (!_days.getDayOfWeek().equals(dt.getDayOfWeek())) {
435                        return Mismatched.DAY;
436                    }
437                    int weekOfMonth = 1 + (dt.getDayOfMonth() / 7);
438                    if (!_days.contains(weekOfMonth)) {
439                        return Mismatched.DAY;
440                    }
441                } else if (Days.IntervalType.BIWEEKLY_DAY_OF_WEEK.equals(_days.getIntervalType())) {
442                    if (!_days.contains(dt.getDayOfWeek().getValue())) {
443                        return Mismatched.DAY;
444                    }
445                    // Calculate the elapsed weeks between the starting date and now
446                    // if there is an odd number of weeks, then the week doesn't match based on a biweekly schedule
447                    ZonedDateTime thisWeek = dt.minusDays(dt.getDayOfWeek().getValue()-1).withSecond(0).withHour(12).withMinute(0).withNano(0);
448                    Period period = Period.between(thisWeek.toLocalDate(), _days.getStartingDate().toLocalDate());
449                    int weekDif = period.getDays() / 7;
450                    if (weekDif %2 != 0) {
451                        return Mismatched.DAY;
452                    }
453                } else {
454                    // This should never happen
455                    throw new AppException("This should never happen but it did. Invalid intervalTye: " + _days.getIntervalType());
456                }
457            }
458            if (!_hours.isEvery() && !_hours.contains(dt.getHour())) {
459                return Mismatched.HOUR;
460            }
461
462            if (!_minutes.isRandom() && !_minutes.isEvery() && !_minutes.contains(dt.getMinute())) {
463                return Mismatched.MINUTE;
464            }
465
466            return Mismatched.NONE;
467        }
468
469
470        public int hoursToAdd(ZonedDateTime dt) {
471            if (_hours.isEvery()) {
472                return 1;
473            }
474            for (Integer hour: _hours) {
475                if (hour > dt.getHour()) {
476                    return hour - dt.getHour();
477                }
478            }
479            // Wrap around to the next day
480            return 24 + _hours.get(0) - dt.getHour();
481        }
482
483        public int minutesToAdd(ZonedDateTime dt) {
484            if (_minutes.isEvery()) {
485                return 1;
486            }
487            if (_minutes.isRandom()) {
488                return 0;
489            }
490            for (Integer minute: _minutes) {
491                if (minute > dt.getMinute()) {
492                    return minute - dt.getMinute();
493                }
494            }
495            // We wrap around to the next hour
496            return 60 + _minutes.get(0) - dt.getMinute();
497        }
498
499    }
500
501
502    enum Mismatched {
503        NONE,
504        MONTH,
505        DAY,
506        HOUR,
507        MINUTE
508    }
509
510
511}