' * topics, where determines time-periods of publishing those topics. * * Currently, model handles the following frequencies: * * hourly - 'p4cms.cron.hourly' topic is published (at the most) once per hour * daily - 'p4cms.cron.daily' topic is published (at the most) once per day * weekly - 'p4cms.cron.weekly' topic is published (at the most) once per week * monthly - 'p4cms.cron.monthly' topic is published (at the most) once per month * * Pub-sub topics are published by calling Cron_Model_Cron::run() method, that * publishes only topics there were previously published sufficiently long ago * according to the frequency. This method also returns a report, to check * which topics were actually published, in the form of associative array with * frequencies as keys and statuses as values, where status is one of the following: * * executed - if topic was successfully published, * skipped - if topic was not published (ie. previously executed) * failed - if there were errors during publishing the topic (however it doesn't * necessarily mean that topic was published) * * Every time the topic is published, 2 new revisions of the cron file (for each * frequency) are created. One just before and one just after publishing the topic. * At the first update, execution timestamp is saved and 'completed' attribute is * set to false. At the second update, 'completed' attribute is set to true and data * collected from subscribers are stored in the 'data' attribute. * * Whereas this module guarantees that particular topics are not published too often * (i.e. not more than once per hour for p4cms.cron.hourly topic, not more than once * per day for p4cms.cron.daily topic etc.), it does not guarantee that * 'p4cms.cron.hourly' topic will be published every hour and similarly for other * frequencies, as it depends on calling Cron_Model_Cron::run() method sufficiently * often. * * Typically, this can be ensured by setting up a cron job on a server with a frequency * of at least once per hour. For example: * * 0 * * * * wget -O - -q -t 1 http://example.com/cron * * The above line executes wget once every hour (at the 'top' of the hour). The '-O -' * arguments tell wget to write output to standard out, '-q' runs quietly and '-t 1' * limits retries to one. If you do not have wget, try curl with comparable options. * * @copyright 2011 Perforce Software. All rights reserved. * @license Please see LICENSE.txt in top-level folder of this distribution. * @version / */ class Cron_Model_Cron extends P4Cms_Record { const CRON_HOURLY = 'hourly'; const CRON_DAILY = 'daily'; const CRON_WEEKLY = 'weekly'; const CRON_MONTHLY = 'monthly'; const RESULT_EXECUTED = 'executed'; const RESULT_SKIPPED = 'skipped'; const RESULT_FAILED = 'failed'; protected static $_storageSubPath = 'cron'; protected static $_fields = array( 'runTime' => array( 'accessor' => 'getRunTime', 'mutator' => 'setRunTime' ), 'completed' => array( 'accessor' => 'getCompleted', 'mutator' => 'setCompleted' ), 'data' => array( 'accessor' => 'setData', 'mutator' => 'getData' ) ); /** * Get cron runTime (timestamp of the most recent cron execution). * * @return int runTime timestamp */ public function getRunTime() { return (int) $this->_getValue('runTime'); } /** * Set runTime timestamp. * * @param int $timestamp unix timestamp * @return Cron_Model_Cron provides fluent interface */ public function setRunTime($timestamp) { if (!is_int($timestamp) || $timestamp < 0) { throw new InvalidArgumentException("Timestamp must be a non-negative integer"); } $this->_setValue('runTime', $timestamp); return $this; } /** * Get info whether last cron execution was completed. * * @return boolean true if last cron execution was completed, false otherwise */ public function getCompleted() { return (boolean) $this->_getValue('completed'); } /** * Set whether last cron execution was completed or not. * * @param boolean $completed set true if last cron execution was completed, * false otherwise * @return Cron_Model_Cron provides fluent interface */ public function setCompleted($completed) { $this->_setValue('completed', $completed ? '1' : '0'); return $this; } /** * Set additional data about the cron execution. * * @param array $data data to record with cron execution * @return Cron_Model_Cron provides fluent interface */ public function setData(array $data) { $this->_setValue('data', $data); return $this; } /** * Get cron data. * * @return array list of data about cron execution. */ public function getData() { return $this->_getValue('data'); } /** * Run cron (if needed) for given frequencies specified in the optional parameter * (all by default). Every cron run (for a given frequency) updates cron/ * record, where is one of hourly|daily|weekly|monthly. * * @param array $frequencies (optional) list of frequencies to run cron for, * defaults to all * @param int $timestamp (optional) UNIX timestamp to use for * determining if cron needs to be run * (useful mostly for testing); * if not provided, then current timestamp * will be used * @param P4Cms_Record_Adapter $adapter optional - storage adapter to use. * @return array list with run report for each frequency, report is one of * executed|skipped|failed. */ public static function run( array $frequencies = null, $timestamp = null, P4Cms_Record_Adapter $adapter = null) { // if no adapter given, use default. $adapter = $adapter ?: static::getDefaultAdapter(); $all = array( static::CRON_HOURLY, static::CRON_DAILY, static::CRON_WEEKLY, static::CRON_MONTHLY ); // filter for only valid frequencies, fall back to all frequency types if none passed $frequencies = array_intersect($frequencies ?: $all, $all); // if no timestamp is provided, set to current if (!$timestamp) { $timestamp = time(); } // get list of frequencies to run cron for $needRun = static::_needRun($frequencies, $timestamp, $adapter); // iterate over all frequencies and run cron if needed $report = array(); foreach ($frequencies as $frequency) { if (!in_array($frequency, $needRun)) { $report[$frequency] = static::RESULT_SKIPPED; continue; } // cron jobs should run as a special type of anonymous // user that can be granted additional privileges. $activeUser = P4Cms_User::hasActive() ? P4Cms_User::fetchActive() : null; P4Cms_User::setActive(new Cron_Model_User); try { static::_run($frequency, $timestamp, $adapter); $result = static::RESULT_EXECUTED; } catch (Exception $e) { P4Cms_Log::logException("Cron run failed [frequency: $frequency].", $e); $result = static::RESULT_FAILED; } // after each cron job, restore original active user. if ($activeUser) { P4Cms_User::setActive($activeUser); } else { P4Cms_User::clearActive(); } $report[$frequency] = $result; } return $report; } /** * Execute cron for a given frequency. Publishes 'p4cms.cron.' topic * to let subscribers executing their cron tasks. Subscribers can optionally * return list of messages that will be saved in the cron log file. * * @param string $frequency frequency to run cron for (it should be one * of hourly|daily|weekly|monthly) * @param string $timestamp unix timestamp to run cron at (useful for * testing, in real case this should refer to * the current time) * @param P4Cms_Record_Adapter $adapter optional - storage adapter to use. * * @publishes p4cms.cron.hourly * Perform periodic operations intended to execute once per hour. Any returned text * will be saved in the cron log. * (requires additional settings on a server, or activation of the Easy_Cron * module. Please see ) * * @publishes p4cms.cron.daily * Perform periodic operations intended to execute once per day. Any returned text * will be saved in the cron log. * (requires additional settings on a server, or activation of the Easy_Cron * module. Please see ) * * @publishes p4cms.cron.weekly * Perform periodic operations intended to execute once per week. Any returned text * will be saved in the cron log. * (requires additional settings on a server, or activation of the Easy_Cron * module. Please see ) * * @publishes p4cms.cron.monthly * Perform periodic operations intended to execute once per month. Any returned * text will be saved in the cron log. * (requires additional settings on a server, or activation of the Easy_Cron * module. Please see ) */ protected static function _run( $frequency, $timestamp, P4Cms_Record_Adapter $adapter = null) { // if no adapter given, use default. $adapter = $adapter ?: static::getDefaultAdapter(); // prepare messages to use later $cronStarted = "Cron $frequency started."; $cronCompleted = "Cron $frequency completed."; // update cron file immediately, so if another cron check is started // before this run is done, it won't be executed again $cron = new static; $cron->setAdapter($adapter) ->setId($frequency) ->setRunTime($timestamp) ->setCompleted(false); // set filetype to ctext to improve performance as there may be a huge // number of revisions in the future $file = $cron->_getP4File(); $file->touchLocalFile() ->open(null, 'text+C'); $cron->save($cronStarted); // publish cron topic to allow subscribers to execute their cron tasks // collect messages that subscribers can optionally return P4Cms_Log::log($cronStarted, P4Cms_Log::NOTICE); $topic = 'p4cms.cron.' . $frequency; $messages = P4Cms_PubSub::publish($topic); // add collected messages and save cron as completed $cron->setData($messages) ->setCompleted(true) ->save($cronCompleted); P4Cms_Log::log($cronCompleted, P4Cms_Log::NOTICE); } /** * Check if cron needs to be run for given frequencies. Returns list of * only those frequencies the cron needs to be run for. * * @param array $frequencies list of frequencies to check for * @param string $timestamp unix timestamp to run cron at * @param P4Cms_Record_Adapter $adapter optional - storage adapter to use. * @return array list of frequencies from $frequencies the cron needs * to be run for. */ protected static function _needRun( array $frequencies, $timestamp, P4Cms_Record_Adapter $adapter = null) { // if no adapter given, use default. $adapter = $adapter ?: static::getDefaultAdapter(); // fetch existing cron files for given frequencies $records = static::fetchAll(array('ids' => $frequencies)); // assume cron needs to be run for all frequencies that don't have cron // record yet $needRun = array_diff($frequencies, $records->invoke('getId')); // iterate over existing cron files for given frequencies and determine // which of them need to be updated by comparing truncated timestamp // of the last cron run with truncated $timestamp provided foreach ($records as $cron) { $runTime = $cron->getRunTime(); $frequency = $cron->getId(); $truncatedRunTime = static::_truncateTimestamp($runTime, $frequency); $truncatedTimestamp = static::_truncateTimestamp($timestamp, $frequency); // cron needs to be updated if truncated timestamps are different if ($truncatedRunTime !== $truncatedTimestamp) { $needRun[] = $frequency; } } return $needRun; } /** * Return timestamp truncated with respect to the given frequency. * * Following parts from the timestamp are truncated for the given frequencies: * * hourly: minutes + seconds * daily: hours + minutes + seconds * weekly: days in the week (since Monday) + hours + minutes + seconds * monthly: days in the month + hours + minutes + seconds * * Example: * January 2nd 2011 15:45:27 will be truncated to: * * January 2nd 2011 15:00:00 for the hourly frequency, * January 2nd 2011 00:00:00 for the daily frequency, * December 26th 2010 00:00:00 for the weekly frequency and * January 1st 2011 00:00:00 for the monthly frequency * * @param int $timestamp unix timestamp to truncate * @param string $frequency frequency to truncate $timestamp according to * @return int|null unix timestamp of truncated $timestamp with respect to * $frequency or null if not able to truncate */ protected static function _truncateTimestamp($timestamp, $frequency) { // get formatted string representing datetime of the truncated timestamp switch ($frequency) { case static::CRON_HOURLY: $datetime = date('Y-m-d H:00', $timestamp); break; case static::CRON_DAILY: $datetime = date('Y-m-d', $timestamp); break; case static::CRON_WEEKLY: $datetime = date('Y-\WW', $timestamp); break; case static::CRON_MONTHLY: $datetime = date('Y-m', $timestamp); break; } // convert datetime to the timestamp and return return isset($datetime) ? strtotime($datetime) : null; } }