diff --git a/includes/kohana/modules/cron/README.markdown b/includes/kohana/modules/cron/README.markdown new file mode 100644 index 00000000..b08b42f3 --- /dev/null +++ b/includes/kohana/modules/cron/README.markdown @@ -0,0 +1,86 @@ +# Kohana-Cron + +This module provides a way to schedule tasks (jobs) within your Kohana application. + + +## Installation + +Step 1: Download the module into your modules subdirectory. + +Step 2: Enable the module in your bootstrap file: + + /** + * Enable modules. Modules are referenced by a relative or absolute path. + */ + Kohana::modules(array( + 'cron' => MODPATH.'cron', + // 'auth' => MODPATH.'auth', // Basic authentication + // 'codebench' => MODPATH.'codebench', // Benchmarking tool + // 'database' => MODPATH.'database', // Database access + // 'image' => MODPATH.'image', // Image manipulation + // 'orm' => MODPATH.'orm', // Object Relationship Mapping + // 'pagination' => MODPATH.'pagination', // Paging of results + // 'userguide' => MODPATH.'userguide', // User guide and API documentation + )); + + +Step 3: Make sure the settings in `config/cron.php` are correct for your environment. +If not, copy the file to `application/config/cron.php` and change the values accordingly. + + +## Usage + +In its simplest form, a task is a [PHP callback][1] and times at which it should run. +To configure a task call `Cron::set($name, array($frequency, $callback))` where +`$frequency` is a string of date and time fields identical to those found in [crontab][2]. +For example, + + Cron::set('reindex_catalog', array('@daily', 'Catalog::regenerate_index')); + Cron::set('calendar_notifications', array('*/5 * * * *', 'Calendar::send_emails')); + +Configured tasks are run with their appropriate frequency by calling `Cron::run()`. Call +this method in your bootstrap file, and you're done! + + +## Advanced Usage + +A task can also be an instance of `Cron` that extends `next()` and/or `execute()` as +needed. Such a task is configured by calling `Cron::set($name, $instance)`. + +If you have access to the system crontab, you can run Cron less (or more) than once +every request. You will need to modify the lines where the request is handled in your +bootstrap file to prevent extraneous output. The default is: + + /** + * Execute the main request. A source of the URI can be passed, eg: $_SERVER['PATH_INFO']. + * If no source is specified, the URI will be automatically detected. + */ + echo Request::instance() + ->execute() + ->send_headers() + ->response; + +Change it to: + + if ( ! defined('SUPPRESS_REQUEST')) + { + /** + * Execute the main request. A source of the URI can be passed, eg: $_SERVER['PATH_INFO']. + * If no source is specified, the URI will be automatically detected. + */ + echo Request::instance() + ->execute() + ->send_headers() + ->response; + } + +Then set up a system cron job to run your application's Cron once a minute: + + * * * * * /usr/bin/php -f /path/to/kohana/modules/cron/run.php + +The included `run.php` should work for most cases, but you are free to call `Cron::run()` +in any way you see fit. + + + [1]: http://php.net/manual/language.pseudo-types.php#language.types.callback + [2]: http://linux.die.net/man/5/crontab diff --git a/includes/kohana/modules/cron/classes/cron.php b/includes/kohana/modules/cron/classes/cron.php new file mode 100644 index 00000000..7f3d2757 --- /dev/null +++ b/includes/kohana/modules/cron/classes/cron.php @@ -0,0 +1,10 @@ +lock) AND ($stat = @stat($config->lock)) AND time() - $config->window < $stat['mtime']) + { + // Lock exists and has not expired + return $result; + } + + $fh = fopen($config->lock, 'a'); + + if (flock($fh, LOCK_EX)) + { + fseek($fh, 0, SEEK_END); + + if (ftell($fh) === (empty($stat) ? 0 : $stat['size'])) + { + // Current size matches expected size + // Claim the file by changing the size + fwrite($fh, '.'); + + $result = TRUE; + } + + // else, Another process acquired during flock() + } + + fclose($fh); + + return $result; + } + + /** + * Store the timestamps of when jobs should run next + */ + protected static function _save() + { + Kohana::cache("Cron::run()", Cron::$_times, Kohana::config('cron')->window * 2); + } + + /** + * Release the Cron mutex + */ + protected static function _unlock() + { + return @unlink(Kohana::config('cron')->lock); + } + + /** + * @return boolean FALSE when another instance is running + */ + public static function run() + { + if (empty(Cron::$_jobs)) + return TRUE; + + if ( ! Cron::_lock()) + return FALSE; + + try + { + Cron::_load(); + + $now = time(); + $threshold = $now - Kohana::config('cron')->window; + + foreach (Cron::$_jobs as $name => $job) + { + if (empty(Cron::$_times[$name]) OR Cron::$_times[$name] < $threshold) + { + // Expired + + Cron::$_times[$name] = $job->next($now); + + if ($job->next($threshold) < $now) + { + // Within the window + + $job->execute(); + } + } + elseif (Cron::$_times[$name] < $now) + { + // Within the window + + Cron::$_times[$name] = $job->next($now); + + $job->execute(); + } + } + } + catch (Exception $e) {} + + Cron::_save(); + Cron::_unlock(); + + if (isset($e)) + throw $e; + + return TRUE; + } + + protected $_callback; + protected $_period; + + public function __construct($period, $callback) + { + $this->_period = $period; + $this->_callback = $callback; + } + + /** + * Execute this job + */ + public function execute() + { + call_user_func($this->_callback); + } + + /** + * Calculates the next timestamp in this period + * + * @param integer Timestamp from which to calculate + * @return integer Next timestamp in this period + */ + public function next($from) + { + // PHP >= 5.3.0 + //if ($this->_period instanceof DatePeriod) { return; } + //if (is_string($this->_period) AND preg_match('/^P[\dDHMSTWY]+$/', $period)) { $this->_period = new DateInterval($this->_period); } + //if ($this->_period instanceof DateInterval) { return; } + + return $this->_next_crontab($from); + } + + /** + * Calculates the next timestamp of this crontab period + * + * @param integer Timestamp from which to calculate + * @return integer Next timestamp in this period + */ + protected function _next_crontab($from) + { + if (is_string($this->_period)) + { + // Convert string to lists of valid values + + if ($this->_period[0] === '@') + { + switch (substr($this->_period, 1)) + { + case 'annually': + case 'yearly': + // '0 0 1 1 *' + $this->_period = array('minutes' => array(0), 'hours' => array(0), 'monthdays' => array(1), 'months' => array(1), 'weekdays' => range(0,6)); + break; + + case 'daily': + case 'midnight': + // '0 0 * * *' + $this->_period = array('minutes' => array(0), 'hours' => array(0), 'monthdays' => range(1,31), 'months' => range(1,12), 'weekdays' => range(0,6)); + break; + + case 'hourly': + // '0 * * * *' + $this->_period = array('minutes' => array(0), 'hours' => range(0,23), 'monthdays' => range(1,31), 'months' => range(1,12), 'weekdays' => range(0,6)); + break; + + case 'monthly': + // '0 0 1 * *' + $this->_period = array('minutes' => array(0), 'hours' => array(0), 'monthdays' => array(1), 'months' => range(1,12), 'weekdays' => range(0,6)); + break; + + case 'weekly': + // '0 0 * * 0' + $this->_period = array('minutes' => array(0), 'hours' => array(0), 'monthdays' => range(1,31), 'months' => range(1,12), 'weekdays' => array(0)); + break; + } + } + else + { + list($minutes, $hours, $monthdays, $months, $weekdays) = explode(' ', $this->_period); + + $months = strtr(strtolower($months), array( + 'jan' => 1, + 'feb' => 2, + 'mar' => 3, + 'apr' => 4, + 'may' => 5, + 'jun' => 6, + 'jul' => 7, + 'aug' => 8, + 'sep' => 9, + 'oct' => 10, + 'nov' => 11, + 'dec' => 12, + )); + + $weekdays = strtr(strtolower($weekdays), array( + 'sun' => 0, + 'mon' => 1, + 'tue' => 2, + 'wed' => 3, + 'thu' => 4, + 'fri' => 5, + 'sat' => 6, + )); + + $this->_period = array( + 'minutes' => $this->_parse_crontab_field($minutes, 0, 59), + 'hours' => $this->_parse_crontab_field($hours, 0, 23), + 'monthdays' => $this->_parse_crontab_field($monthdays, 1, 31), + 'months' => $this->_parse_crontab_field($months, 1, 12), + 'weekdays' => $this->_parse_crontab_field($weekdays, 0, 7) + ); + + // Ensure Sunday is zero + if (end($this->_period['weekdays']) === 7) + { + array_pop($this->_period['weekdays']); + + if (reset($this->_period['weekdays']) !== 0) + { + array_unshift($this->_period['weekdays'], 0); + } + } + } + } + + $from = getdate($from); + + if ( ! in_array($from['mon'], $this->_period['months'])) + return $this->_next_crontab_month($from); + + if (count($this->_period['weekdays']) === 7) + { + // Day of Week is unrestricted, defer to Day of Month + if ( ! in_array($from['mday'], $this->_period['monthdays'])) + return $this->_next_crontab_monthday($from); + } + elseif (count($this->_period['monthdays']) === 31) + { + // Day of Month is unrestricted, use Day of Week + if ( ! in_array($from['wday'], $this->_period['weekdays'])) + return $this->_next_crontab_weekday($from); + } + else + { + // Both Day of Week and Day of Month are restricted + if ( ! in_array($from['mday'], $this->_period['monthdays']) AND ! in_array($from['wday'], $this->_period['weekdays'])) + return $this->_next_crontab_day($from); + } + + if ( ! in_array($from['hours'], $this->_period['hours'])) + return $this->_next_crontab_hour($from); + + return $this->_next_crontab_minute($from); + } + + /** + * Calculates the first timestamp in the next day of this period when both + * Day of Week and Day of Month are restricted + * + * @uses _next_crontab_month() + * + * @param array Date array from getdate() + * @return integer Timestamp of next restricted Day + */ + protected function _next_crontab_day(array $from) + { + // Calculate effective Day of Month for next Day of Week + + if ($from['wday'] >= end($this->_period['weekdays'])) + { + $next = reset($this->_period['weekdays']) + 7; + } + else + { + foreach ($this->_period['weekdays'] as $next) + { + if ($from['wday'] < $next) + break; + } + } + + $monthday = $from['mday'] + $next - $from['wday']; + + if ($monthday <= (int) date('t', mktime(0, 0, 0, $from['mon'], 1, $from['year']))) + { + // Next Day of Week is in this Month + + if ($from['mday'] >= end($this->_period['monthdays'])) + { + // No next Day of Month, use next Day of Week + $from['mday'] = $monthday; + } + else + { + // Calculate next Day of Month + foreach ($this->_period['monthdays'] as $next) + { + if ($from['mday'] < $next) + break; + } + + // Use earliest day + $from['mday'] = min($monthday, $next); + } + } + else + { + if ($from['mday'] >= end($this->_period['monthdays'])) + { + // No next Day of Month, use next Month + return $this->_next_crontab_month($from); + } + + // Calculate next Day of Month + foreach ($this->_period['monthdays'] as $next) + { + if ($from['mday'] < $next) + break; + } + + // Use next Day of Month + $from['mday'] = $next; + } + + // Use first Hour and first Minute + return mktime(reset($this->_period['hours']), reset($this->_period['minutes']), 0, $from['mon'], $from['mday'], $from['year']); + } + + /** + * Calculates the first timestamp in the next hour of this period + * + * @uses _next_crontab_day() + * @uses _next_crontab_monthday() + * @uses _next_crontab_weekday() + * + * @param array Date array from getdate() + * @return integer Timestamp of next Hour + */ + protected function _next_crontab_hour(array $from) + { + if ($from['hours'] >= end($this->_period['hours'])) + { + // No next Hour + + if (count($this->_period['weekdays']) === 7) + { + // Day of Week is unrestricted, defer to Day of Month + return $this->_next_crontab_monthday($from); + } + + if (count($this->_period['monthdays']) === 31) + { + // Day of Month is unrestricted, use Day of Week + return $this->_next_crontab_weekday($from); + } + + // Both Day of Week and Day of Month are restricted + return $this->_next_crontab_day($from); + } + + // Calculate next Hour + foreach ($this->_period['hours'] as $next) + { + if ($from['hours'] < $next) + break; + } + + // Use next Hour and first Minute + return mktime($next, reset($this->_period['minutes']), 0, $from['mon'], $from['mday'], $from['year']); + } + + /** + * Calculates the timestamp of the next minute in this period + * + * @uses _next_crontab_hour() + * + * @param array Date array from getdate() + * @return integer Timestamp of next Minute + */ + protected function _next_crontab_minute(array $from) + { + if ($from['minutes'] >= end($this->_period['minutes'])) + { + // No next Minute, use next Hour + return $this->_next_crontab_hour($from); + } + + // Calculate next Minute + foreach ($this->_period['minutes'] as $next) + { + if ($from['minutes'] < $next) + break; + } + + // Use next Minute + return mktime($from['hours'], $next, 0, $from['mon'], $from['mday'], $from['year']); + } + + /** + * Calculates the first timestamp in the next month of this period + * + * @param array Date array from getdate() + * @return integer Timestamp of next Month + */ + protected function _next_crontab_month(array $from) + { + if ($from['mon'] >= end($this->_period['months'])) + { + // No next Month, increment Year and use first Month + ++$from['year']; + $from['mon'] = reset($this->_period['months']); + } + else + { + // Calculate next Month + foreach ($this->_period['months'] as $next) + { + if ($from['mon'] < $next) + break; + } + + // Use next Month + $from['mon'] = $next; + } + + if (count($this->_period['weekdays']) === 7) + { + // Day of Week is unrestricted, use first Day of Month + $from['mday'] = reset($this->_period['monthdays']); + } + else + { + // Calculate Day of Month for the first Day of Week + $indices = array_flip($this->_period['weekdays']); + + $monthday = 1; + $weekday = (int) date('w', mktime(0, 0, 0, $from['mon'], 1, $from['year'])); + + while ( ! isset($indices[$weekday % 7]) AND $monthday < 7) + { + ++$monthday; + ++$weekday; + } + + if (count($this->_period['monthdays']) === 31) + { + // Day of Month is unrestricted, use first Day of Week + $from['mday'] = $monthday; + } + else + { + // Both Day of Month and Day of Week are restricted, use earliest one + $from['mday'] = min($monthday, reset($this->_period['monthdays'])); + } + } + + // Use first Hour and first Minute + return mktime(reset($this->_period['hours']), reset($this->_period['minutes']), 0, $from['mon'], $from['mday'], $from['year']); + } + + /** + * Calculates the first timestamp in the next day of this period when only + * Day of Month is restricted + * + * @uses _next_crontab_month() + * + * @param array Date array from getdate() + * @return integer Timestamp of next Day of Month + */ + protected function _next_crontab_monthday(array $from) + { + if ($from['mday'] >= end($this->_period['monthdays'])) + { + // No next Day of Month, use next Month + return $this->_next_crontab_month($from); + } + + // Calculate next Day of Month + foreach ($this->_period['monthdays'] as $next) + { + if ($from['mday'] < $next) + break; + } + + // Use next Day of Month, first Hour, and first Minute + return mktime(reset($this->_period['hours']), reset($this->_period['minutes']), 0, $from['mon'], $next, $from['year']); + } + + /** + * Calculates the first timestamp in the next day of this period when only + * Day of Week is restricted + * + * @uses _next_crontab_month() + * + * @param array Date array from getdate() + * @return integer Timestamp of next Day of Week + */ + protected function _next_crontab_weekday(array $from) + { + // Calculate effective Day of Month for next Day of Week + + if ($from['wday'] >= end($this->_period['weekdays'])) + { + $next = reset($this->_period['weekdays']) + 7; + } + else + { + foreach ($this->_period['weekdays'] as $next) + { + if ($from['wday'] < $next) + break; + } + } + + $monthday = $from['mday'] + $next - $from['wday']; + + if ($monthday > (int) date('t', mktime(0, 0, 0, $from['mon'], 1, $from['year']))) + { + // Next Day of Week is not in this Month, use next Month + return $this->_next_crontab_month($from); + } + + // Use next Day of Week, first Hour, and first Minute + return mktime(reset($this->_period['hours']), reset($this->_period['minutes']), 0, $from['mon'], $monthday, $from['year']); + } + + /** + * Returns a sorted array of all the values indicated in a Crontab field + * @link http://linux.die.net/man/5/crontab + * + * @param string Crontab field + * @param integer Minimum value for this field + * @param integer Maximum value for this field + * @return array + */ + protected function _parse_crontab_field($value, $min, $max) + { + $result = array(); + + foreach (explode(',', $value) as $value) + { + if ($slash = strrpos($value, '/')) + { + $step = (int) substr($value, $slash + 1); + $value = substr($value, 0, $slash); + } + + if ($value === '*') + { + $result = array_merge($result, range($min, $max, $slash ? $step : 1)); + } + elseif ($dash = strpos($value, '-')) + { + $result = array_merge($result, range(max($min, (int) substr($value, 0, $dash)), min($max, (int) substr($value, $dash + 1)), $slash ? $step : 1)); + } + else + { + $value = (int) $value; + + if ($min <= $value AND $value <= $max) + { + $result[] = $value; + } + } + } + + sort($result); + + return array_unique($result); + } + +} diff --git a/includes/kohana/modules/cron/config/cron.php b/includes/kohana/modules/cron/config/cron.php new file mode 100644 index 00000000..80cd3d3e --- /dev/null +++ b/includes/kohana/modules/cron/config/cron.php @@ -0,0 +1,28 @@ + Kohana::$cache_dir.DIRECTORY_SEPARATOR.'cron.lck', + + /** + * Cron does not run EXACTLY when tasks are scheduled. + * A task can be executed up to this many seconds AFTER its scheduled time. + * + * For example, Cron is run at 10:48 and a task was scheduled to execute at + * 10:45, 180 seconds ago. If window is greater than 180, the task will be + * executed. + * + * This value should always be larger than the time it takes to run all + * your tasks. + */ + 'window' => 300, +); diff --git a/includes/kohana/modules/cron/run.php b/includes/kohana/modules/cron/run.php new file mode 100644 index 00000000..6cafdbf8 --- /dev/null +++ b/includes/kohana/modules/cron/run.php @@ -0,0 +1,22 @@ +next($from); + + $this->assertSame($expected_result, $result); + } + + public function provider_next() + { + return array + ( + array('@annually', mktime(8, 45, 0, 11, 19, 2009), mktime(0, 0, 0, 1, 1, 2010)), + array('@monthly', mktime(8, 45, 0, 11, 19, 2009), mktime(0, 0, 0, 12, 1, 2009)), + array('@weekly', mktime(8, 45, 0, 11, 19, 2009), mktime(0, 0, 0, 11, 22, 2009)), + array('@daily', mktime(8, 45, 0, 11, 19, 2009), mktime(0, 0, 0, 11, 20, 2009)), + array('@hourly', mktime(8, 45, 0, 11, 19, 2009), mktime(9, 0, 0, 11, 19, 2009)), + + array('* * * * *', mktime(8, 45, 0, 11, 19, 2009), mktime(8, 46, 0, 11, 19, 2009)), + + array( + '* * * * 0', // Sundays + mktime(0, 0, 0, 11, 30, 2009), // Monday, Nov 30, 2009 + mktime(0, 0, 0, 12, 6, 2009) // Sunday, Dec 6, 2009 + ), + + array( + '* * 15 * 6', // 15th and Saturdays + mktime(0, 0, 0, 11, 29, 2009), // Sunday, Nov 29, 2009 + mktime(0, 0, 0, 12, 5, 2009) // Saturday, Dec 5, 2009 + ), + + array( + '* * * * 1,5', // Mondays and Fridays + mktime(0, 0, 0, 11, 24, 2009), // Tuesday, Nov 24, 2009 + mktime(0, 0, 0, 11, 27, 2009) // Friday, Nov 27, 2009 + ), + + array( + '* * 15 * 6-7', // 15th, Saturdays, and Sundays + mktime(0, 0, 0, 11, 23, 2009), // Monday, Nov 23, 2009 + mktime(0, 0, 0, 11, 28, 2009) // Saturday, Nov 28, 2009 + ), + + array( + '* * 15,30 * 2', // 15th, 30th, and Tuesdays + mktime(0, 0, 0, 11, 29, 2009), // Sunday, Nov 29, 2009 + mktime(0, 0, 0, 11, 30, 2009) // Monday, Nov 30, 2009 + ), + + array( + '0 0 * * 4', // Midnight on Thursdays + mktime(1, 0, 0, 11, 19, 2009), // 01:00 Thursday, Nov 19, 2009 + mktime(0, 0, 0, 11, 26, 2009) // 00:00 Thursday, Nov 26, 2009 + ), + + array( + '0 0 */2 * 4', // Midnight on odd days and Thursdays + mktime(1, 0, 0, 11, 19, 2009), // 01:00 Thursday, Nov 19, 2009 + mktime(0, 0, 0, 11, 21, 2009) // 00:00 Saturday, Nov 21, 2009 + ), + ); + } +}