* SPDX-License-Identifier: AGPL-3.0-only ************************************/ // crmv@42264 require_once('include/BaseClasses.php'); require_once('include/utils/LogUtils.php'); class CronUtils extends SDKExtendableUniqueClass { public function insertCronJob($cb) { $cb->write($cb->getId()); } public function editCronJob($cb) { // TODO } public function deleteCronJob($cronid) { $cronjob = new CronJob($cronid); $cronjob->delete(); } public function suspendCronJob($cronid, $suspend = true) { $cronjob = new CronJob($cronid); $cronjob->activate(!$suspend); } public function suspendAll() { global $adb, $table_prefix; $adb->query("update {$table_prefix}_cronjobs set active = 0"); } // crmv@106069 public static function cleanLogs() { $CM = new CronManager(); $CM->cleanLogs(); } // crmv@106069e // crmv@181161 /** * Freeze all active jobs */ public function freezeAllActive() { global $adb, $table_prefix; $adb->query("UPDATE {$table_prefix}_cronjobs SET active = 2 WHERE active = 1"); } /** * Unfreeze all frozen jobs */ public function unfreezeAll() { global $adb, $table_prefix; $adb->query("UPDATE {$table_prefix}_cronjobs SET active = 1 WHERE active = 2"); } /** * Wait until all crons finish processing or when reaching the timeout * There might be false positives: cron in processing status, but crashed * @param int $timeout A timeout in seconds * @return bool true = ok, false = timeout reached */ public function waitForAllCron($timeout = 300, $skipids = array()) { global $adb, $table_prefix; $sql = "SELECT COUNT(*) as count FROM {$table_prefix}_cronjobs WHERE active = 1 AND status = ?"; $params = array(CronJob::$STATUS_PROCESSING); if (count($skipids) > 0) { $sql .= " AND cronid NOT IN (".generateQuestionMarks($skipids).")"; $params = array_merge($params, $skipids); } $time = microtime(true); $res = $adb->pquery($sql, $params); $count = $adb->query_result_no_html($res, 0, 'count'); while ($count > 0) { $t = microtime(true); if ($t-$time > $timeout) return false; sleep(3); $res = $adb->pquery($sql, $params); $count = $adb->query_result_no_html($res, 0, 'count'); } return true; } // crmv@181161e } class CronManager { public $logDir = 'logs/cron/'; public $logMaxSize = 5242880; // 5MB per logfile (more or less) // crmv@106069 public $logPurgeAge = 90; // delete logs (both raw and compressed) after this amount of days (only if there is more than 1 log, 0 = disable) public $logCompressAge = 30; // compress logs after they are older than this amount of days and there is more than 1 file (0 = disable) // crmv@106069e public $logToStdout = false; public $request = array(); static $current_processing_jobid = null; // crmv@65455 static $termination_requested = false; // crmv@194059 function __construct($request='') { if (!empty($request)) { $this->request = $request; } } // crmv@194059 static function requestCronTermination() { self::$termination_requested = true; } // crmv@194059e // this method may be called while another instance is running public function run($idlist = array(), $force = false) { // crmv@181265 global $adb, $table_prefix; if (!is_array($idlist)) $idlist = array($idlist); $idlist = array_filter($idlist); $now = date('Y-m-d H:i:s'); // first check for dead jobs (marked as running in timeout, but not terminated or not running due to reboot) $this->checkOprhanedJobs(); // then check running jobs for timeout $this->checkTimeoutJobs(); // crmv@181265 if ($force && count($idlist) == 1) { $cronjob = new CronJob($idlist[0]); if ($cronjob) { $_REQUEST = $this->request; $this->runJob($cronjob); } return; } // crmv@181265e // now get available jobs $runnableStatuses = array(CronJob::$STATUS_ERROR, CronJob::$STATUS_TIMEOUT_END, CronJob::$STATUS_EMPTY); $params = array_merge($runnableStatuses, array($now, $now), $idlist); //crmv@58330 crmv@112251 if($adb->isMssql() || $adb->isOracle()){ $res = $adb->pquery( "SELECT cronid FROM {$table_prefix}_cronjobs WHERE active = 1 AND (status IS NULL OR status IN (".generateQuestionMarks($runnableStatuses).")) AND (starttime IS NULL OR starttime = '1900-01-01 00:00:00' OR starttime <= ?) AND (endtime IS NULL OR endtime = '1900-01-01 00:00:00' OR endtime > ?) AND (max_attempts IS NULL OR max_attempts = 0 OR attempts IS NULL OR attempts < max_attempts) ". (count($idlist) > 0 ? ' AND cronid in ('.generateQuestionMarks($idlist).')' : '')." ORDER BY lastrun ASC", $params ); } else{ //crmv@58330e crmv@112251e $res = $adb->pquery( "SELECT cronid FROM {$table_prefix}_cronjobs WHERE active = 1 AND (status IS NULL OR status IN (".generateQuestionMarks($runnableStatuses).")) AND (starttime IS NULL OR starttime = '0000-00-00 00:00:00' OR starttime <= ?) AND (endtime IS NULL OR endtime = '0000-00-00 00:00:00' OR endtime > ?) AND (max_attempts IS NULL OR max_attempts = 0 OR attempts IS NULL OR attempts < max_attempts) ". (count($idlist) > 0 ? ' AND cronid in ('.generateQuestionMarks($idlist).')' : '')." ORDER BY lastrun ASC", $params ); } //crmv@58330 if ($res) { $runnableJobs = array(); while ($row = $adb->FetchByAssoc($res, -1, false)) { $cronid = $row['cronid']; $cronjob = new CronJob($cronid); // just store the id because running a job might be long and the time calculations can go wrong if ($cronjob->shouldRunNow()) $runnableJobs[] = $cronjob; } foreach ($runnableJobs as $rjob) { // regenerate _REQUEST every script job if ($this->checkRunnable($rjob)) { $_REQUEST = $this->request; $this->runJob($rjob); if (self::$termination_requested) break; // crmv@194059 } } } } // crmv@181281 // crmv@201562 function checkRunnable($job) { global $adb, $table_prefix; $runnableStatuses = array(CronJob::$STATUS_ERROR, CronJob::$STATUS_TIMEOUT_END, CronJob::$STATUS_EMPTY); $res = $adb->pquery( "UPDATE {$table_prefix}_cronjobs SET status = ? WHERE cronid = ? AND (status IS NULL OR status in (".generateQuestionMarks($runnableStatuses)."))", array(CronJob::$STATUS_PROCESSING, $job->getId(), $runnableStatuses) ); if ($res && $adb->getAffectedRowCount($res) > 0) { return true; } // echo "SKIPCRON ".date('Y-m-d H:i:s')." {$job->name}\n"; return false; } // crmv@181281e // crmv@201562e // check for running jobs which are already in timeout, and mark them // so their calling process can mark them as timeout (if they'll ever terminate) protected function checkTimeoutJobs() { global $adb, $table_prefix; $now = date('Y-m-d H:i:s'); if ($adb->isMysql()) { $diffExpr = "TIMESTAMPDIFF(SECOND, lastrun, ?)"; } elseif ($adb->isOracle()) { $diffExpr = "(lastrun - to_date(?, 'YYYY-MM-DD HH24:MI:SS'))*24*60*60"; } elseif ($adb->isMssql()) { $diffExpr = "DATEDIFF(SECOND, lastrun, ?)"; } else { throw new Exception('Unknown database type'); } $res = $adb->pquery( "SELECT cronid FROM {$table_prefix}_cronjobs WHERE active = 1 AND status = ? AND lastrun IS NOT NULL AND timeout IS NOT NULL AND timeout > 0 AND $diffExpr > timeout", array(CronJob::$STATUS_PROCESSING, $now) ); if ($res && $adb->num_rows($res) > 0) { // there are jobs still running, already in timeout while ($row = $adb->FetchByAssoc($res, -1, false)) { $cronjob = new CronJob($row['cronid']); $cronjob->setStatus(CronJob::$STATUS_TIMEOUT); //TODO reset flag if MessageCron } } } // check for jobs who are signed as timeout, but probably their controlling process doesn't exist anymore (or will never terminate) protected function checkOprhanedJobs() { global $adb, $table_prefix; $now = date('Y-m-d H:i:s'); if ($adb->isMysql()) { $diffExpr = "TIMESTAMPDIFF(SECOND, lastrun, ?)"; } elseif ($adb->isOracle()) { $diffExpr = "(lastrun - to_date(?, 'YYYY-MM-DD HH24:MI:SS'))*24*60*60"; } elseif ($adb->isMssql()) { $diffExpr = "DATEDIFF(SECOND, lastrun, ?)"; } else { throw new Exception('Unknown database type'); } $res = $adb->pquery( "SELECT cronid, pid FROM {$table_prefix}_cronjobs WHERE active = 1 AND status = ? AND lastrun IS NOT NULL AND timeout IS NOT NULL AND timeout > 0 AND $diffExpr > 4*timeout", // check for 4 times the timeout array(CronJob::$STATUS_TIMEOUT, $now) ); if ($res && $adb->num_rows($res) > 0) { // there are jobs still running, already in timeout while ($row = $adb->FetchByAssoc($res, -1, false)) { $cronjob = new CronJob($row['cronid']); $pid = $row['pid']; // kill the process (warning: might kill something else!!) if ($pid > 0 && PHP_OS == 'Linux') { $cronjob->kill(); } $cronjob->setStatus(CronJob::$STATUS_EMPTY); $cronjob->clearPid(); //TODO reset flag if MessageCron } } } // catches only fatal errors static function errorHandler($job, $logfile = null) { // crmv@146653 global $adb, $table_prefix; $error = error_get_last(); $catchTypes = array(E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR); if ($job && $job->getId() == self::$current_processing_jobid && $error !== null && in_array($error['type'], $catchTypes)) { // crmv@65455 $job->setStatus(CronJob::$STATUS_ERROR); $now = date('Y-m-d H:i:s'); if ($logfile) file_put_contents($logfile, "[$now] FATAL ERROR DURING JOB {$job->name}: {$error['message']} at {$error['file']}:{$error['line']}\n\n", FILE_APPEND); } } // beware! this method may need a lot of time public function runJob(&$job) { global $adb, $table_prefix; $now = date('Y-m-d H:i:s'); $job->setStatus(CronJob::$STATUS_PROCESSING, $now, 0); // crmv@201562 $nowts0 = time(); $error = false; $job->setPid(); // initialize log file $logfile = $this->decideLogFile($job); if ($logfile) file_put_contents($logfile, "[$now] STARTING JOB {$job->name}\n\n", FILE_APPEND); // set exit handler to catch fatal errors register_shutdown_function(array($this, 'errorHandler'), $job, $logfile); // crmv@65455 // global var to avoid multiple call to the error handler self::$current_processing_jobid = $job->getId(); // crmv@65455e // capture output if (!$this->logToStdout) { ob_start(); ob_clean(); } $errorMsg = ''; $error = false; try { if (file_exists($job->fileName)) { $job->incrementAttempts(); //crmv@49395 if (!empty($job->params)) { foreach($job->params as $k => $v) { $_REQUEST[$k] = $v; } } //crmv@49395e /* ---------- SCRIPT EXECUTION ---------- */ require $job->fileName; /* ---------------- END ----------------- */ //crmv@49395 if (!empty($job->params)) { foreach($job->params as $k => $v) { unset($_REQUEST[$k]); } } //crmv@49395e } else { $error = true; $errorMsg = "\nFILE NOT READABLE: {$job->fileName}\n"; } } catch (Exception $e) { $error = true; $errorMsg = "\nEXCEPTION IN {$e->getFile()} AT LINE {$e->getLine()} : {$e->getMessage()}\n"; $errorMsg .= "TRACE: {$e->getTraceAsString()}\n"; } // get the output if ($this->logToStdout) { $output = $errorMsg; } else { $output = ob_get_clean() . $errorMsg; ob_end_clean(); } // crmv@65455 // global var to avoid multiple call to the error handler self::$current_processing_jobid = null; // crmv@65455e // write log file if ($logfile) file_put_contents($logfile, $output, FILE_APPEND); $job->clearPid(); // check timeout $nowts1 = time(); $deltats = $nowts1 - $nowts0; if ($error) { $jobEndStatus = CronJob::$STATUS_ERROR; } elseif ($job->timeout > 0 && $deltats > $job->timeout) { $jobEndStatus = CronJob::$STATUS_TIMEOUT_END; } else { $jobEndStatus = CronJob::$STATUS_EMPTY; $job->resetAttempts(); } $job->setStatus($jobEndStatus, null, $deltats); // crmv@102956 // final row of log file if ($logfile) { $nowEnd = date('Y-m-d H:i:s'); if ($jobEndStatus === CronJob::$STATUS_EMPTY) $jobEndStatus = 'OK'; file_put_contents($logfile, "\n[$nowEnd] JOB FINISHED ({$job->name}) WITH STATUS $jobEndStatus\n\n", FILE_APPEND); } } public function decideLogFile(&$job) { if ($this->logToStdout) return 'php://stdout'; if (!is_dir($this->logDir)) { mkdir($this->logDir, 0755); } $logbasename = $this->logDir.'cron_'.$job->name; // find a free name for ($i=1; $i<1000; ++$i) { $curname = $logbasename.'_'.str_pad(strval($i), 2, '0', STR_PAD_LEFT).'.log'; if (!file_exists($curname) || filesize($curname) < $this->logMaxSize) return $curname; } return false; } public function sendAlert() { } // crmv@106069 public function cleanLogs() { if (!is_dir($this->logDir)) return; $now = time(); $purgeTime = $now - $this->logPurgeAge*3600*24; $compressTime = $now - $this->logCompressAge*3600*24; // first purge old compressed logs if ($this->logPurgeAge > 0) { $list = glob($this->logDir.'*.gz', GLOB_NOSORT); foreach ($list as $zlog) { if (filemtime($zlog) < $purgeTime) { $this->deleteLog($zlog);; } } } // then get all log files, grouped by cron name $logfiles = array(); $list = glob($this->logDir.'*.log', GLOB_NOSORT); foreach ($list as $file) { $pieces = explode('_', basename($file)); $logfiles[$pieces[1]][] = $file; } foreach ($logfiles as $logname => $filelist) { // only if more than 1 log is present if (count($filelist) > 1) { // get the times $times = array_map('filemtime', $filelist); $filetimes = array_combine($filelist, $times); asort($filetimes); $i = 0; $count = count($filetimes); // keep the last one, and delete/compress the others foreach ($filetimes as $file=>$logtime) { if ($this->logPurgeAge > 0 && $logtime < $purgeTime) { $this->deleteLog($file); } elseif ($this->logCompressAge > 0 && $logtime < $compressTime) { $this->compressLog($file); } if (++$i == $count - 1) break; } } } } protected function deleteLog($logfile) { if (is_writable($logfile)) { $r = @unlink($logfile); if ($r) { echo "Deleted logfile $logfile\n"; } } } protected function compressLog($logfile) { if (is_readable($logfile) && is_writable($logfile)) { $r = LogUtils::gzCompressFile($logfile); if ($r) { @unlink($logfile); echo "Compressed logfile $logfile\n"; } } } // crmv@106069e } class CronJob { protected $id; // id of the job public $name; // name of the job public $active; // if false, job is not executed public $singleRun; // if true, executes only one time public $fileName; // name of the php script to run (relative to VTE root path, or absolute) public $params = array(); //crmv@49395 : simulate $_REQUEST parameters public $start; // datetime since the job is executed (leave empty to ignore) public $end; // datetime when the job stops from being executed (leave empty to ignore) public $timeout; // max duration allowed for a job (seconds) public $repeat; // repeat the job every X seconds (can be 0 or null if using the next param, > 60) public $runHours; // array of hours::minute of the day to start the job (they must be ordered) // (example: 09:00, 10:00, 14:30, 15:30) public $maxAttempts; // number of failed attempts to run (because of error or timeout) before suspending the job // parameters about last execution public $status; // status of the job public $lastRun; // last datetime the job has been executed (started) public $attempts; // number of failed consecutive run attempts protected $pid; // pid of the PHP process that is running this job // status constants static $STATUS_EMPTY = ''; // ready to run static $STATUS_PROCESSING = 'PROCESSING'; // running static $STATUS_TIMEOUT_END = 'TIMEOUT_END'; // job terminated, timeout static $STATUS_ERROR = 'ERROR'; // job terminated, with some other error static $STATUS_TIMEOUT = 'TIMEOUT'; // job still running, already in timeout static $STATUS_SUSPENDED = 'SUSPENDED'; // job automatically suspended due to errors or timeout // constructor, sets some defaults public function __construct($id = null) { $this->active = true; $this->runHours = array(); $this->singleRun = false; $this->timeout = 600; $this->maxAttempts = 5; // if and id is provided, read it if ($id > 0) $this->read($id); } static public function getByName($name) { global $adb, $table_prefix; $res = $adb->pquery("select cronid from {$table_prefix}_cronjobs where cronname= ?", array($name)); if ($res && $adb->num_rows($res) > 0) { $id = $adb->query_result($res, 0, 'cronid'); return new CronJob($id); } return null; } // read an existing cronjob public function read($id) { global $adb, $table_prefix; $res = $adb->pquery("select * from {$table_prefix}_cronjobs where cronid = ?", array($id)); if ($res && $adb->num_rows($res) > 0) { $row = $adb->FetchByAssoc($res, -1, false); $this->id = $id; $this->name = $row['cronname']; //crmv@49395 if (strpos($row['filename'],'?') !== false) { list($file,$params) = explode('?',$row['filename']); $this->fileName = $file; $params = explode('&',$params); foreach($params as $p) { list($k,$v) = explode('=',$p); if (!empty($k)) { $this->params[$k] = $v; } } } else { $this->fileName = $row['filename']; } //crmv@49395e $this->active = ($row['active'] == 1); $this->singleRun = ($row['singlerun'] == 1); $this->status = $row['status']; $this->lastRun = $row['lastrun']; $this->attempts = intval($row['attempts']); $this->start = $row['starttime']; $this->end = $row['endtime']; $this->timeout = intval($row['timeout']); $this->repeat = intval($row['repeat_sec']); $this->runHours = array_map('trim',explode(',',$row['run_hours'])); $this->maxAttempts = intval($row['max_attempts']); $this->pid = intval($row['pid']); } } public function getId() { return $this->id; } // crmv@65455 public function getPid() { return $this->pid; } // crmv@65455e // insert (or update) a job public function write($id = null) { if (empty($id)) return $this->insert(); else return $this->update(); } // insert as a new job public function insert() { global $adb, $table_prefix; $id = $adb->getUniqueId($table_prefix.'_cronjobs'); $columns = array( 'cronid' => $id, 'cronname' => $this->name, 'active' => ($this->active ? 1 : 0), 'singlerun' => ($this->singleRun ? 1 : 0), 'status' => self::$STATUS_EMPTY, 'filename' => $this->fileName, 'starttime' => (empty($this->start) ? '' : $this->start), 'endtime' => (empty($this->end) ? '' : $this->end), 'timeout' => intval($this->timeout), 'repeat_sec' => intval($this->repeat), 'run_hours' => implode(',', $this->runHours), 'max_attempts' => intval($this->maxAttempts), //'pid' => '', ); $query = "insert into {$table_prefix}_cronjobs (".implode(',',array_keys($columns)).") values (".generateQuestionMarks($columns).")"; $res = $adb->pquery($query, $columns); $this->id = $id; } // update a job public function update() { global $adb, $table_prefix; $id = $this->id; $columns = array( //'cronname' => $this->name, 'active' => ($this->active ? 1 : 0), 'singlerun' => ($this->singleRun ? 1 : 0), //'status' => self::$STATUS_EMPTY, 'filename' => $this->fileName, 'starttime' => $this->start, 'endtime' => $this->end, 'timeout' => intval($this->timeout), 'repeat_sec' => intval($this->repeat), 'run_hours' => implode(',', $this->runHours), 'max_attempts' => intval($this->maxAttempts), ); $sets = $params = array(); foreach ($columns as $k=>$v) { $params[] = $v; $sets[] = "$k = ?"; } $sets = implode(',', $sets); $params[] = $id; $query = "update {$table_prefix}_cronjobs set $sets where cronid = ?"; $res = $adb->pquery($query, $params); } public function activate($active = true) { global $adb, $table_prefix; $adb->pquery("update {$table_prefix}_cronjobs set active = ? where cronid = ?", array(($active ? 1 : 0), $this->id)); $this->active = $active; } public function deactivate() { return $this->activate(false); } // crmv@181161 public function freeze($freeze = true) { global $adb, $table_prefix; $adb->pquery("update {$table_prefix}_cronjobs set active = ? where cronid = ?", array(($freeze ? 2 : 1), $this->id)); $this->active = !$freeze; } public function unfreeze() { return $this->freeze(false); } // crmv@181161e public function delete() { global $adb, $table_prefix; $adb->pquery("delete from {$table_prefix}_cronjobs where cronid = ?", array($this->id)); $this->id = null; } // this function works only in linux public function kill() { global $adb, $table_prefix; $pid = $this->pid; if ($pid > 0) { exec("kill -s TERM $pid && sleep 5 && kill -s KILL $pid"); sleep(3); // at this point it should be killed, so the next one should fail and print the message exec("kill -s KILL $pid || echo 'NOTKILLED'", $output, $ret); if (is_array($output)) $output = implode("\n", $output); if (strpos($output, 'NOTKILLED') !== false) { $adb->pquery("update {$table_prefix}_cronjobs set pid = NULL where cronid = ?", array($this->id)); $this->pid = null; return true; } } return false; } // return false if above or equal max number of attempts public function incrementAttempts() { global $adb, $table_prefix; $adb->pquery("update {$table_prefix}_cronjobs set attempts = attempts + 1 where cronid = ?", array($this->id)); ++$this->attempts; return ($this->attempts < $this->maxAttempts); } public function resetAttempts($what = 0) { global $adb, $table_prefix; $adb->pquery("update {$table_prefix}_cronjobs set attempts = ? where cronid = ?", array($what, $this->id)); $this->attempts = $what; } // set status for execution // crmv@102956 public function setStatus($status, $lastrun = null, $duration = null) { global $adb, $table_prefix; $this->status = $status; $query = "update {$table_prefix}_cronjobs set status = ?"; $params = array($status); if (!empty($lastrun)) { $query .= ", lastrun = ?"; $params[] = $lastrun; } if (!is_null($duration)) { $query .= ", last_duration = ?"; $params[] = $duration; } $query .= " where cronid = ?"; $params[] = $this->id; return $adb->pquery($query, $params); //crmv@181281 } // crmv@102956e public function setPid($pid = 0) { global $adb, $table_prefix; if ($pid === 0) $pid = getmypid(); $adb->pquery("update {$table_prefix}_cronjobs set pid = ? where cronid = ?", array($pid, $this->id)); $this->pid = $pid; } public function clearPid() { $this->setPid(null); } // check wether this job should run now or not // $cronInterval is usually 1 minute public function shouldRunNow($cronInterval = 300) { // crmv@152744 - 5min range /* * 1. check start time and end time * 2. if repeat_sec >0 * [YES] 2a. check lastrun + repeat_sec < now ? -> TRUE else FALSE * [NO] 2b. check run hours: * find maximum hour which is < now * if now - hour < cronInterval -> TRUE else FALSE */ // check global start and end $now = new DateTime(); if (!empty($this->start) && $this->start != '0000-00-00 00:00:00' && $this->start != '1900-01-01 00:00:00') { // crmv@112251 $start = DateTime::createFromFormat('Y-m-d H:i:s', $this->start); if ($start !== false && $start > $now) return false; } if (!empty($this->end) && $this->end != '0000-00-00 00:00:00' && $this->end != '1900-01-01 00:00:00') { // crmv@112251 $end = DateTime::createFromFormat('Y-m-d H:i:s', $this->end); if ($end !== false && $end <= $now) return false; } // now check with repetitions if ($this->repeat > 0) { // if never run before, then run! if (empty($this->lastRun) || $this->lastRun == '0000-00-00 00:00:00' || $this->lastRun == '1970-01-01 01:00:00') return true; // crmv@112251 $lastrun = DateTime::createFromFormat('Y-m-d H:i:s', $this->lastRun); if ($lastrun !== false) { $lastrun->setTimestamp($lastrun->getTimestamp() + $this->repeat); return ($lastrun <= $now); } else { return true; } // and now with hours } elseif (!empty($this->runHours)) { // build array of hours $hoursArrays = array(); //crmv@60443 $today = clone $now; $yesterday = clone $now; $tomorrow = clone $now; $yesterday->sub(new DateInterval('P1D')); $tomorrow->add(new DateInterval('P1D')); foreach ($this->runHours as $rh) { // sanity check and format validation if (preg_match('/\d{1,2}:\d{1,2}(:\d{1,2})?/', $rh)) { list($h, $m, $s) = explode(':', $rh); // build array for yesterday, today and tomorrow // this trick is to get around the changing day problem // crmv@152744 $yesterday_tmp = clone $yesterday; $today_tmp = clone $today; $tomorrow_tmp = clone $tomorrow; $hoursArrays[0][] = $yesterday_tmp->setTime($h, $m, $s); $hoursArrays[1][] = $today_tmp->setTime($h, $m, $s); $hoursArrays[2][] = $tomorrow_tmp->setTime($h, $m, $s); // crmv@152744e } } // now find the greatest hour < now $hoursArrays = array_merge($hoursArrays[0], $hoursArrays[1], $hoursArrays[2]); $lasth = $hoursArrays[0]; // this one is for sure < now (it's yesterday) for ($i=1; $igetTimeStamp() - $lasth->getTimestamp()); // crmv@152744 // avoid multiple execution inside the same cronInterval range if (!empty($this->lastRun) && !in_array($this->lastRun, array('0000-00-00 00:00:00', '1970-01-01 01:00:00'))) { $lastrun = DateTime::createFromFormat('Y-m-d H:i:s', $this->lastRun); if ($lastrun !== false) { $diff_lastrun = abs($now->getTimeStamp() - $lastrun->getTimestamp()); if ($diff_lastrun < $cronInterval){ return false; } } } // crmv@152744e return ( $diff < $cronInterval); } // otherwise return false return false; } // extimated the next execution time for this job public function getNextExecution() { } }