Bug when calculating a scheduled task next run time

Discussion in 'PHP' started by vincentgagnoncoll, Jun 13, 2009.

  1. #1
    Hi I need some help debugging this script. It works fine when trying to compute a cron like this one '* * * * *' .

    My bug occurs when trying to compute this format '1 2 * * * *'
    This cron must run every day of the month at 2:01 am

    If the local datetime is Jun 13, 13:01 2009 and I compute the next run time it output Jun 15 2:01 2009 instead of June 14 2:01 2009, it adds 2 days but I cannot pinpoint the issue.

    Can someone help me debug this script ?

    Here is my class, feel free to use afterward.

    class Gtw_CronParser {
     private
        $bits = array(), //exploded String like 0 1 * * *
       	$now = array(),	//Array of cron-style entries for time()
       	$next_run, 		//Timestamp of next run time.
      	$year, //computed year
      	$month, //computed month
      	$day, //computed day
      	$hour, //computed hour
      	$minute, //computed minute
      	$equal, //indicate whether or not the computed time is equal to current time - if yes add minute and process new calcul
      	$count_invalid_day_range, //for invalid cron range (eg: 30 february), there's a counter that avoid infinitate loop
      	$minutes_arr = array(),	//minutes array based on cron string
      	$days_arr = array(), //days array based on cron string
      	$hours_arr = array(),	//hours array based on cron string
      	$months_arr = array();	//months array based on cron string
    
    	public function getNextRun() {
    		return explode(",", strftime("%M,%H,%d,%m,%w,%Y", $this->next_run)); //Get the values for now in a format we can use
    	}
    
    	public function getNextRunUnix() {
    		return $this->next_run;
    	}
    
    	/**
    	 *  Calculate the last due time before this moment
    	 */
    	public function calcNextRun($string) {
    
     		$tstart = microtime();
    		$this->count_invalid_day_range = 0;
    		$this->equal = true;
    
    		$string = preg_replace('/[\s]{2,}/', ' ', $string);
    
    		if (preg_match('/[^-,* \\d]/', $string) !== 0) {
    			throw new Exception("Cron String contains invalid character");
    			return false;
    		}
    
     		$this->bits = @explode(" ", $string);
    
    		if (count($this->bits) != 5) {
    			throw new Exception("Cron string is invalid. Too many or too little sections after explode");
    			return false;
    		}
    
    		//put the current time into an array
    		$t = strftime("%M,%H,%d,%m,%w,%Y", time());
    		$this->now = explode(",", $t);
    
    		$this->year = $this->now[5];
    		$this->initMonthsCronArray();
    		//l'init day dépand de l'année et du mois, on le calcul donc à la demande
    		//$this->initDaysCronArray();
    		$this->initHoursCronArray();
    		$this->initMinutesCronArray();
    
    		$this->calcNextMonth();
    		if ($this->equal) {
    		  $this->addMinute();
    		}
    
    		$this->next_run = mktime($this->hour, $this->minute, 0, $this->month, $this->day, $this->year);
    		return true;
    	}
    
    	/**
    	 * Assumes that value is not *, and creates an array of valid numbers that
    	 * the string represents.  Returns an array.
    	 */
    	public static function expandRanges($str) {
    		if (strstr($str,  ",")) {
    			$arParts = explode(',', $str);
    			foreach ($arParts AS $part) {
    				if (strstr($part, '-')) {
    					$arRange = explode('-', $part);
    					for ($i = $arRange[0]; $i <= $arRange[1]; $i++) {
    						$ret[] = $i;
    					}
    				} else {
    					$ret[] = $part;
    				}
    			}
    
    		}	elseif (strstr($str,  '-')) {
    			$arRange = explode('-', $str);
    			for ($i = $arRange[0]; $i <= $arRange[1]; $i++) {
    				$ret[] = $i;
    			}
    
    		} else {
    			$ret[] = $str;
    		}
    
    		$ret = array_unique($ret);
    		sort($ret);
    		return $ret;
    	}
    
    	//remove the out of range array elements. $arr should be sorted already and does not contain duplicates
    	public static function sanitize ($arr, $low, $high) {
    		$count = count($arr);
    		for ($i = 0; $i <= ($count - 1); $i++) {
    			if ($arr[$i] < $low) {
    				unset($arr[$i]);
    			} else {
    				break;
    			}
    		}
    
    		for ($i = ($count - 1); $i >= 0; $i--) {
    			if ($arr[$i] > $high) {
    				unset ($arr[$i]);
    			} else {
    				break;
    			}
    		}
    
    		//re-assign keys
    		sort($arr);
    		return $arr;
    	}
    
    
    
    	private static function getNbDaysInMonth($month, $year) {
    		return date('t', mktime(0, 0, 0, $month, 1, $year));
    	}
    
    
    
    	private function addMonth() {
    	  $this->equal = false;
    	  if ($this->now[3] == max($this->months_arr)) {
    	    $this->resetMonth();
    	  } else {
    	    $this->now[3]++;
    	  }
    	  $this->calcNextMonth();
    	}
    
    
    	private function resetMonth() {
    	  $this->equal = false;
    	  $this->year++;
    	  $this->now[3] = min($this->months_arr);
    	}
    
    
    	private function calcNextMonth() {
    	  foreach ($this->months_arr as $month) {
    
    	    if ($this->now[3] <= $month) {
    	      if ($this->now[3] < $month) {
    	        $this->equal = false;
    	      }
    
    	      $this->month = $month;
    	      $this->calcNextDay();
    	      return ;
    	    }
    	  }
    
    	  //le prochain mois n'est pas dans le range en cours
    	  //on incrémente mois, on réinitialise le mois à 1 et on recalcule
    	  $this->resetMonth();
        $this->calcNextMonth();
    	}
    
    
    	private function resetDay() {
    	  $this->equal = false;
    	  $this->now[2] = 1;
    	  $this->addMonth();
    	}
    
    
    	private function addDay() {
    	  $this->equal = false;
        if ($this->now[2] == max($this->days_arr)) {
          $this->resetDay();
          //ne pas lancer le calcNextDay car resetDay lance addMonth qui appel calcNextMonth qui appel calcNextDay
        } else {
          $this->now[2]++;
          $this->calcNextDay();
        }
    	}
    
    
      private function calcNextDay() {
    	  $this->initDaysCronArray();
    	  foreach ($this->days_arr as $day) {
          if ($this->now[2] <= $day) {
            if ($this->now[2] < $day) {
              $this->equal = false;
            }
    
    	      $this->day = $day;
    	      $this->calcNextHour();
    
            return ;
          }
    	  }
    
    	  $this->resetDay();
    	}
    
    
    	private function resetHour() {
    	  $this->equal = false;
    	  $this->now[1] = min($this->hours_arr);
    	  $this->addDay();
    	}
    
    	private function addHour() {
        $this->equal = false;
    
        if ($this->hour == max($this->hours_arr)) {
          $this->resetHour();
        } else {
          $this->now[1]++;
          $this->calcNextHour();
        }
    	}
    
    
    	private function calcNextHour() {
    	  foreach ($this->hours_arr as $hour) {
    	    if ($this->now[1] <= $hour) {
    	      if ($this->now[1] < $hour) {
    	        $this->equal = false;
    	      }
            $this->hour = $hour;
            $this->calcNextMinute();
    	      return ;
    	    }
    	  }
    
    	  $this->resetHour();
    	}
    
    
    	private function resetMinute() {
    	  $this->equal = false;
    	  $this->now[0] = min($this->minutes_arr);
    	  $this->addHour();
    	}
    
    	private function addMinute() {
        $this->equal = false;
    
        if ($this->minute == max($this->minutes_arr)) {
          $this->resetMinute();
        } else {
          $this->now[0]++;
          $this->calcNextMinute();
        }
    	}
    
    
    	private function calcNextMinute() {
    	  foreach ($this->minutes_arr as $min) {
    	    if ($this->now[0] <= $min) {
    	      if ($this->now[0] < $min) {
    	        $this->equal = false;
    	      }
            $this->minute = $min;
    	      return ;
    	    }
    	  }
    
    	  $this->resetMinute();
    	}
    
    
    	private function initMonthsCronArray() {
    		if (empty($this->months_arr)) {
    			$months = array();
    			if ($this->bits[3] == '*') {
    				for ($i = 1; $i <= 12; $i++) {
    					$months[] = $i;
    				}
    			} else {
    				$months = self::expandRanges($this->bits[3]);
    				$months = self::sanitize($months, 1, 12);
    			}
    
    			if (!count($months)) {
    			  throw new Exception('Month range is not valid');
    			}
    
    			$this->months_arr = $months;
    		}
    	}
    
    	private function initHoursCronArray() {
    		$hours = array();
    
    		if ($this->bits[1] == '*') {
    			for ($i = 0; $i <= 23; $i++) {
    				$hours[] = $i;
    			}
    		} else {
    			$hours = self::expandRanges($this->bits[1]);
    			$hours = self::sanitize($hours, 0, 23);
    		}
    
    		if (!count($hours)) {
    		  throw new Exception('Hour range is not valid');
    		}
    		$this->hours_arr = $hours;
    	}
    
    	private function initMinutesCronArray() {
    		$minutes = array();
    
    		if ($this->bits[0] == '*') {
    			for ($i = 0; $i <= 60; $i++) {
    				$minutes[] = $i;
    			}
    		} else {
    			$minutes = self::expandRanges($this->bits[0]);
    			$minutes = self::sanitize($minutes, 0, 59);
    		}
    
    		if (!count($minutes)) {
    		  throw new Exception('Minute range is not valid');
    		}
    		$this->minutes_arr = $minutes;
    	}
    
    	//given a month/year, return an array containing all the days in that month
    	private static function getMonthDaysArray($month, $year) {
    		$days = array();
    		$daysinmonth = self::getNbDaysInMonth($month, $year);
    		for ($i = 1; $i <= $daysinmonth; $i++) {
    			$days[] = $i;
    		}
    		return $days;
    	}
    
    
      //given a month/year, list all the days within that month fell into the week days list.
    	private function initDaysCronArray() {
    		$days = array();
    		$month = $this->month; $year = $this->year;
    
    		//return everyday of the month if both bit[2] and bit[4] are '*'
    		if ($this->bits[2] == '*' AND $this->bits[4] == '*') {
    			$days = self::getMonthDaysArray($month, $year);
    		} else {
    			//create an array for the weekdays
    			if ($this->bits[4] == '*') {
    				for ($i = 0; $i <= 6; $i++) {
    					$arWeekdays[] = $i;
    				}
    			} else {
    				$arWeekdays = self::expandRanges($this->bits[4]);
    				$arWeekdays = self::sanitize($arWeekdays, 0, 7);
    
    				//map 7 to 0, both represents Sunday. Array is sorted already!
    				if (in_array(7, $arWeekdays)) {
    					if (in_array(0, $arWeekdays)) {
    						array_pop($arWeekdays);
    					} else {
    						$tmp[] = 0;
    						array_pop($arWeekdays);
    						$arWeekdays = array_merge($tmp, $arWeekdays);
    					}
    				}
    			}
    
    			if ($this->bits[2] == '*') {
    				$daysmonth = $this->getMonthDaysArray($month, $year);
    			} else {
    				$daysmonth = self::expandRanges($this->bits[2]);
    				// so that we do not end up with 31 of Feb
    				$daysinmonth = self::getNbDaysInMonth($month, $year);
    				$daysmonth = self::sanitize($daysmonth, 1, $daysinmonth);
    			}
    
    			//Now match these days with weekdays
    			foreach ($daysmonth AS $day) {
    				$wkday = date('w', mktime(0, 0, 0, $month, $day, $year));
    				if (in_array($wkday, $arWeekdays)) {
    					$days[] = $day;
    				}
    			}
    		}
    		//self::sanitize($days, 1, self::getNbDaysInMonth($month, $year));
    
    		if(!count($days)) {
    		  $this->count_invalid_day_range++;
    		}
    
    		if ($this->count_invalid_day_range == 12) {
    		  throw new Exception('Day range is not valid');
    		}
    		$this->days_arr = $days;
    	}
    
    }
    
    $oCron = new Gtw_CronParser();
    $sFormat = "1 2 * * *";
    $oCron->calcNextRun($sFormat);
    echo "<pre>";
    var_dump($oCron->getNextRun());
    echo "</pre>";
    
    ?>
    PHP:
     

    Attached Files:

    vincentgagnoncoll, Jun 13, 2009 IP