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}