views:

1932

answers:

4

I have a cron "time definition"

1 * * * * (every hour at xx:01)
2 5 * * * (every day at 05:02)
0 4 3 * * (every third day of the month at 04:00)
* 2 * * 5 (every minute between 02:00 and 02:59 on fridays)

And I have an unix timestamp.

Is there an obvious way to find (calculate) the next time (after that given timestamp) the job is due to be executed?

I'm using PHP, but the problem should be fairly language-agnostic.

[Update]

The class "PHP Cron Parser" (suggested by Ray) calculates the LAST time the CRON job was supposed to be executed, not the next time.

To make it easier: In my case the cron time parameters are only absolute, single numbers or "*". There are no time-ranges and no "*/5" intervals.

+3  A: 

Check this out:

It can calculate the next time a scheduled job is supposed to be run based on the given cron definitions.
Ray
Actually that class calculates the last time the job WAS due. I need to find the next time the job IS GOING to be due :(
BlaM
+3  A: 

This is basically doing the reverse of checking if the current time fits the conditions. so something like:

//Totaly made up language
next = getTimeNow();
next.addMinutes(1) //so that next is never now
done = false;
while (!done) {
  if (cron.minute != '*' && next.minute != cron.minute) {
    if (next.minute > cron.minute) {
      next.addHours(1);
    }
    next.minute = cron.minute;
  }
  if (cron.hour != '*' && next.hour != cron.hour) {
    if (next.hour > cron.hour) {
      next.hour = cron.hour;
      next.addDays(1);
      next.minute = 0;
      continue;
    }
    next.hour = cron.hour;
    next.minute = 0;
    continue;
  }
  if (cron.weekday != '*' && next.weekday != cron.weekday) {
    deltaDays = cron.weekday - next.weekday //assume weekday is 0=sun, 1 ... 6=sat
    if (deltaDays < 0) { deltaDays+=7; }
    next.addDays(deltaDays);
    next.hour = 0;
    next.minute = 0;
    continue;
  }
  if (cron.day != '*' && next.day != cron.day) {
    if (next.day > cron.day || !next.month.hasDay(cron.day)) {
      next.addMonths(1);
      next.day = 1; //assume days 1..31
      next.hour = 0;
      next.minute = 0;
      continue;
    }
    next.day = cron.day
    next.hour = 0;
    next.minute = 0;
    continue;
  }
  if (cron.month != '*' && next.month != cron.month) {
    if (next.month > cron.month) {
      next.addMonths(12-next.month+cron.month)
      next.day = 1; //assume days 1..31
      next.hour = 0;
      next.minute = 0;
      continue;
    }
    next.month = cron.month;
    next.day = 1;
    next.hour = 0;
    next.minute = 0;
    continue;
  }
  done = true;
}

I might have written that a bit backwards. Also it can be a lot shorter if in every main if instead of doing the greater than check you merely increment the current time grade by one and set the lesser time grades to 0 then continue; however then you'll be looping a lot more. Like so:

//Shorter more loopy version
next = getTimeNow().addMinutes(1);
while (true) {
  if (cron.month != '*' && next.month != cron.month) {
    next.addMonths(1);
    next.day = 1;
    next.hour = 0;
    next.minute = 0;
    continue;
  }
  if (cron.day != '*' && next.day != cron.day) {
    next.addDays(1);
    next.hour = 0;
    next.minute = 0;
    continue;
  }
  if (cron.weekday != '*' && next.weekday != cron.weekday) {
    next.addDays(1);
    next.hour = 0;
    next.minute = 0;
    continue;
  }
  if (cron.hour != '*' && next.hour != cron.hour) {
    next.addHours(1);
    next.minute = 0;
    continue;
  }
  if (cron.minute != '*' && next.minute != cron.minute) {
    next.addMinutes(1);
    continue;
  }
  break;
}
dlamblin
+3  A: 

For anyone interested, here's my final PHP implementation, which pretty much equals dlamblin pseudo code:

class myMiniDate {
 var $myTimestamp;
 static private $dateComponent = array(
         'second' => 's',
         'minute' => 'i',
         'hour' => 'G',
         'day' => 'j',
         'month' => 'n',
         'year' => 'Y',
         'dow' => 'w',
         'timestamp' => 'U'
          );
 static private $weekday = array(
        1 => 'monday',
        2 => 'tuesday',
        3 => 'wednesday',
        4 => 'thursday',
        5 => 'friday',
        6 => 'saturday',
        0 => 'sunday'
         );

 function __construct($ts = NULL) { $this->myTimestamp = is_null($ts)?time():$ts; }

 function __set($var, $value) {
  list($c['second'], $c['minute'], $c['hour'], $c['day'], $c['month'], $c['year'], $c['dow']) = explode(' ', date('s i G j n Y w', $this->myTimestamp));
  switch ($var) {
   case 'dow':
    $this->myTimestamp = strtotime(self::$weekday[$value], $this->myTimestamp);
    break;

   case 'timestamp':
    $this->myTimestamp = $value;
    break;

   default:
    $c[$var] = $value;
    $this->myTimestamp = mktime($c['hour'], $c['minute'], $c['second'], $c['month'], $c['day'], $c['year']);
  }
 }


 function __get($var) {
  return date(self::$dateComponent[$var], $this->myTimestamp);
 }

 function modify($how) { return $this->myTimestamp = strtotime($how, $this->myTimestamp); }
}


$cron = new myMiniDate(time() + 60);
$cron->second = 0;
$done = 0;

echo date('Y-m-d H:i:s') . '<hr>' . date('Y-m-d H:i:s', $cron->timestamp) . '<hr>';

$Job = array(
   'Minute' => 5,
   'Hour' => 3,
   'Day' => 13,
   'Month' => null,
   'DOW' => 5,
    );

while ($done < 100) {
 if (!is_null($Job['Minute']) && ($cron->minute != $Job['Minute'])) {
  if ($cron->minute > $Job['Minute']) {
   $cron->modify('+1 hour');
  }
  $cron->minute = $Job['Minute'];
 }
 if (!is_null($Job['Hour']) && ($cron->hour != $Job['Hour'])) {
  if ($cron->hour > $Job['Hour']) {
   $cron->modify('+1 day');
  }
  $cron->hour = $Job['Hour'];
  $cron->minute = 0;
 }
 if (!is_null($Job['DOW']) && ($cron->dow != $Job['DOW'])) {
  $cron->dow = $Job['DOW'];
  $cron->hour = 0;
  $cron->minute = 0;
 }
 if (!is_null($Job['Day']) && ($cron->day != $Job['Day'])) {
  if ($cron->day > $Job['Day']) {
   $cron->modify('+1 month');
  }
  $cron->day = $Job['Day'];
  $cron->hour = 0;
  $cron->minute = 0;
 }
 if (!is_null($Job['Month']) && ($cron->month != $Job['Month'])) {
  if ($cron->month > $Job['Month']) {
   $cron->modify('+1 year');
  }
  $cron->month = $Job['Month'];
  $cron->day = 1;
  $cron->hour = 0;
  $cron->minute = 0;
 }

 $done = (is_null($Job['Minute']) || $Job['Minute'] == $cron->minute) &&
   (is_null($Job['Hour']) || $Job['Hour'] == $cron->hour) &&
   (is_null($Job['Day']) || $Job['Day'] == $cron->day) &&
   (is_null($Job['Month']) || $Job['Month'] == $cron->month) &&
   (is_null($Job['DOW']) || $Job['DOW'] == $cron->dow)?100:($done+1);
}

echo date('Y-m-d H:i:s', $cron->timestamp) . '<hr>';
BlaM
Should be noted that this only works with crons that aren't complex ie. simple - 30 8 5 7 1, complex - * 2-4,8,10 * 7-8 *
buggedcom
+1  A: 

I'll throw my hat in the ring... Here's a PHP class that is based on dlamblin's psuedo code. Adds support for ranges (X-X) and intervals (*/X). Requires PHP 5.3+.

Usage:

<?php

$cron = new \Cron\Parser('15 2 */15 1 2-5');

// getNextRunDate() returns a \DateTime object
$nextRunDate = $cron->getNextRunDate()->format('Y-m-d H:i:s');

// Usually you know the last time the cron ran, so pass that value to the isDue() method
$isDue = $cron->isDue($lastRunDate);

Code:

<?php

namespace Cron;

/**
 * Cron schedule parser 
 */
class Parser
{
    /**
     * @var array Cron parts
     */
    private $_cronParts;

    /**
     * Constructor
     *
     * @param string $schedule Cron schedule string (e.g. '8 * * * *').  The 
     *      schedule can handle ranges (10-12) and intervals
     *      (*\/10 [remove the backslash]).  Schedule parts should map to
     *      minute [0-59], hour [0-23], day of month, month [1-12], day of week [1-7]
     *
     * @throws InvalidArgumentException if $schedule is not a valid cron schedule
     */
    public function __construct($schedule)
    {
        $this->_cronParts = explode(' ', $schedule);
        if (count($this->_cronParts) != 5) {
            throw new \InvalidArgumentException($schedule . ' is not a valid cron schedule string');
        }
    }

    /**
     * Check if a date/time unit value satisfies a crontab unit
     *
     * @param DateTime $nextRun Current next run date
     * @param string $unit Date/time unit type (e.g. Y, m, d, H, i)
     * @param string $schedule Cron schedule variable
     *
     * @return bool Returns TRUE if the unit satisfies the constraint
     */
    public function unitSatisfiesCron(\DateTime $nextRun, $unit, $schedule)
    {
        $unitValue = (int)$nextRun->format($unit);

        if ($schedule == '*') {
            return true;
        } if (strpos($schedule, '-')) {
            list($first, $last) = explode('-', $schedule);
            return $unitValue >= $first && $unitValue <= $last;
        } else if (strpos($schedule, '*/') !== false) {
            list($delimiter, $interval) = explode('*/', $schedule);
            return $unitValue % (int)$interval == 0;
        } else {
            return $unitValue == (int)$schedule;
        }
    }

    /**
     * Get the date in which the cron will run next
     *
     * @param string|DateTime (optional) $fromTime Set the relative start time
     * @param string $currentTime (optional) Optionally set the current date
     *      time for testing purposes
     *
     * @return DateTime
     */
    public function getNextRunDate($fromTime = 'now', $currentTime = 'now')
    {
        $nextRun = ($fromTime instanceof \DateTime) ? $fromTime : new \DateTime($fromTime ?: 'now');
        $nextRun->setTime($nextRun->format('H'), $nextRun->format('i'), 0);
        $currentDate = ($currentTime instanceof \DateTime) ? $currentTime : new \DateTime($currentTime ?: 'now');
        $i = 0;

        // Set a hard limit to bail on an impossible date
        while (++$i && $i < 100000) {

            // Adjust the month until it matches.  Reset day to 1 and reset time.
            if (!$this->unitSatisfiesCron($nextRun, 'm', $this->getSchedule('month'))) {
                $nextRun->add(new \DateInterval('P1M'));
                $nextRun->setDate($nextRun->format('Y'), $nextRun->format('m'), 1);
                $nextRun->setTime(0, 0, 0);
                continue;
            }

            // Adjust the day of the month by incrementing the day until it matches. Reset time.
            if (!$this->unitSatisfiesCron($nextRun, 'd', $this->getSchedule('day_of_month'))) {
                $nextRun->add(new \DateInterval('P1D'));
                $nextRun->setTime(0, 0, 0);
                continue;
            }

            // Adjust the day of week by incrementing the day until it matches.  Resest time.
            if (!$this->unitSatisfiesCron($nextRun, 'N', $this->getSchedule('day_of_week'))) {
                $nextRun->add(new \DateInterval('P1D'));
                $nextRun->setTime(0, 0, 0);
                continue;
            }

            // Adjust the hour until it matches the set hour.  Set seconds and minutes to 0
            if (!$this->unitSatisfiesCron($nextRun, 'H', $this->getSchedule('hour'))) {
                $nextRun->add(new \DateInterval('PT1H'));
                $nextRun->setTime($nextRun->format('H'), 0, 0);
                continue;
            }

            // Adjust the minutes until it matches a set minute
            if (!$this->unitSatisfiesCron($nextRun, 'i', $this->getSchedule('minute'))) {
                $nextRun->add(new \DateInterval('PT1M'));
                continue;
            }

            // If the suggested next run time is not after the current time, then keep iterating
            if (is_string($fromTime) && $currentDate >= $nextRun) {
                $nextRun->add(new \DateInterval('PT1M'));
                continue;
            }

            break;
        }

        return $nextRun;
    }

    /**
     * Get all or part of the cron schedule string
     *
     * @param string $part Specify the part to retrieve or NULL to get the full
     *      cron schedule string.  $part can be the PHP date() part of a date
     *      formatted string or one of the following values:
     *      NULL, 'minute', 'hour', 'month', 'day_of_week', 'day_of_month'
     *
     * @return string
     */
    public function getSchedule($part = null)
    {
        switch ($part) {
            case 'minute': case 'i':
                return $this->_cronParts[0];
            case 'hour': case 'H':
                return $this->_cronParts[1];
            case 'day_of_month': case 'd':
                return $this->_cronParts[2];
            case 'month': case 'm':
                return $this->_cronParts[3];
            case 'day_of_week': case 'N':
                return $this->_cronParts[4];
            default:
                return implode(' ', $this->_cronParts);
        }
    }

    /**
     * Deterime if the cron is due to run based on the current time, last run
     * time, and the next run time.
     * 
     * If the relative next run time based on the last run time is not equal to 
     * the next suggested run time based on the current time, then the cron 
     * needs to run.
     *
     * @param string|DateTime $lastRun (optional) Date the cron was last run.
     * @param string|DateTime $currentTime (optional) Set the current time for testing
     *
     * @return bool Returns TRUE if the cron is due to run or FALSE if not
     */
    public function isDue($lastRun = 'now', $currentTime = 'now')
    {
        return ($this->getNextRunDate($lastRun, $currentTime) != $this->getNextRunDate('now', $currentTime));
    }
}

Unit tests:

<?php

use Cron\Parser;

/**
 * Cron parser test
 */
class ParserTest extends \PHPUnit_Framework_TestCase
{
    /**
     * @covers Cron\Parser::__construct
     * @covers Cron\Parser::getSchedule
     */
    public function test__construct()
    {
        $cron = new Parser('1 2-4 * 4 */3', '2010-09-10 12:00:00');
        $this->assertEquals('1', $cron->getSchedule('minute'));
        $this->assertEquals('2-4', $cron->getSchedule('hour'));
        $this->assertEquals('*', $cron->getSchedule('day_of_month'));
        $this->assertEquals('4', $cron->getSchedule('month'));
        $this->assertEquals('*/3', $cron->getSchedule('day_of_week'));
        $this->assertEquals('1 2-4 * 4 */3', $cron->getSchedule());
    }

    /**
     * @covers Cron\Parser::__construct
     * @expectedException InvalidArgumentException
     */
    public function test__constructException()
    {
        // Only four values
        $cron = new Parser('* * * 1');
    }

    /**
     * Data provider for cron schedule
     *
     * @return array
     */
    public function scheduleProvider()
    {
        return array(
            array('*/2 */2 * * *', '2010-08-10 21:47:27', '2010-08-10 15:30:00', '2010-08-10 22:00:00', true),
            array('* * * * *', '2010-08-10 21:50:37', '2010-08-10 21:00:00', '2010-08-10 21:51:00', true),
            array('7-9 * */9 * *', '2010-08-10 22:02:33', '2010-08-10 22:01:33', '2010-08-18 00:07:00', false),
            // Minutes 12-19, every 3 hours, every 5 days, in June, on Sunday
            array('12-19 */3 */5 6 7', '2010-08-10 22:05:51', '2010-08-10 22:04:51', '2011-06-05 00:12:00', false),
            // 15th minute, of the second hour, every 15 days, in January, every Friday
            array('15 2 */15 1 */5', '2010-08-10 22:10:19', '2010-08-10 22:09:19', '2015-01-30 02:15:00', false),
            // 15th minute, of the second hour, every 15 days, in January, Tuesday-Friday
            array('15 2 */15 1 2-5', '2010-08-10 22:10:19', '2010-08-10 22:09:19', '2013-01-15 02:15:00', false)
        );
    }

    /**
     * @covers Cron\Parser::isDue
     * @covers Cron\Parser::getNextRunDate
     * @dataProvider scheduleProvider
     */
    public function testIsDueNextRun($schedule, $relativeTime, $lastRun, $nextRun, $isDue)
    {
        $cron = new Parser($schedule);
        $this->assertEquals($cron->isDue(new \DateTime($relativeTime), new \DateTime($lastRun)), $isDue);
        $this->assertEquals(new \DateTime($nextRun), $cron->getNextRunDate($lastRun, $relativeTime));
    }
}
Michael
hey dude. what in here is 5.3 dependent? no chance for 5.2.10 support?
onassar
This could be ported to 5.2.x, but you'd need to remove the namespaces, DateTime::add() calls, and the DateInterval references.
Michael