diff --git a/CronCommand.php b/CronCommand.php new file mode 100644 index 0000000..33f27bc --- /dev/null +++ b/CronCommand.php @@ -0,0 +1,105 @@ +> /dev/null 2>&1 + * + * Run action create CronProcess and runs task, serialized in it. + * + * Default index action prints in console current list of tasks with detailed information: cron schedule, task route + * with params, last start and stop time and current status. + * + * @author Vadym Stepanov + * @date 18.01.2016 + */ +class CronCommand extends CConsoleCommand +{ + /** @var CronService */ + private $_service; + + /** + * @inheritdoc + */ + public function beforeAction($action, $params) + { + $this->_service = Yii::app()->cron; + + return parent::beforeAction($action, $params); + } + + /** + * Daemon action to run every minute (need to be manually added to the server's cron schedule) + * and check if any of the specified tasks by user can be executed. + * For each task get CronProcess instance with information of it's current state. Check run conditions (time and + * date, uniqueness) and run wrapper command if all conditions are met. + */ + public function actionDaemon() + { + $this->_service->loadTasks(); + + /** @var CronTask $task */ + foreach ($this->_service->getActiveTasks() as $task) { + if (!$task->canRun()) { + continue; + } + + if ($task->getProcessInfo()->isRunning() && $task->isUnique()) { + CronService::log( + "Cannot run task '{$task->getName()}': it is still running and does not allow overlapping (is unique)", + CLogger::LEVEL_WARNING + ); + } else { + $task->getProcessInfo()->runWrapper(); + } + } + } + + /** + * Wrapper to handle execution process of the task + * @param string $id unique identifier of the task to be executed + */ + public function actionRun($id) + { + CronProcess::createById($id, $this->_service)->run(); + } + + /** + * Action to display detailed status of current available tasks + */ + public function actionIndex() + { + $this->_service->loadTasks(); + + /** @var CronTask $task */ + foreach ($this->_service->getActiveTasks() as $task) { + $process = $task->getProcessInfo(); + echo "Task '{$task->getName()}':\n"; + $output = $task->getOutputFile() ? " > {$task->getOutputFile()}" : ''; + $params = array(); + foreach ($task->getParams() as $key => $value) { + $params[] = "--{$key}={$value}"; + } + echo "{$task->getCron()} {$task->getCommand()} {$task->getCommandAction()} "; + echo implode(' ', $params) . "{$output}\n"; + echo "Last start: {$process->lastStart} Last finish: {$process->lastStop} "; + echo 'Status: '; + switch ($process->status) { + case CronProcess::STATUS_NEW: + echo 'NEW (not started yet)'; + break; + case CronProcess::STATUS_RUNNING: + echo "RUNNING (PID: {$process->pid})"; + break; + case CronProcess::STATUS_FINISHED: + echo 'FINISHED'; + break; + case CronProcess::STATUS_FAILED: + echo 'FAILED'; + break; + } + echo "\n\n"; + } + } +} \ No newline at end of file diff --git a/CronProcess.php b/CronProcess.php new file mode 100644 index 0000000..2458ca1 --- /dev/null +++ b/CronProcess.php @@ -0,0 +1,310 @@ + + * @date 18.01.2016 + */ +class CronProcess +{ + const STATUS_NEW = 0; + const STATUS_RUNNING = 1; + const STATUS_FINISHED = 2; + const STATUS_FAILED = 3; + + /** + * @var int status of the task execution at the moment + */ + public $status = self::STATUS_NEW; + + /** + * @var string last date and time of task start + */ + public $lastStart; + + /** + * @var string last date and time of task stop + */ + public $lastStop; + + /** + * @var int PID of running task instance + */ + public $pid; + + /** + * @var string unique hash from CronTask instance + */ + private $id; + + /** + * @var string name from CronTask instance + */ + private $name; + + /** + * @var string console command from CronTask instance + */ + private $command; + + /** + * @var string console command action from CronTask instance + */ + private $action; + + /** + * @var array list of params from CronTask instance + */ + private $params; + + /** + * @var bool uniqueness flag from CronTask instance + */ + private $unique; + + /** + * @var string shell command to run task wrapper + */ + private $_wrapperCommand; + + /** + * @var CronService application component instance + */ + private $_service; + + /** + * Static method to create new instance and get information about last execution. Used in console daemon action. + * @param CronTask $task configured task instance + * @param CronService $service application service component + * @return CronProcess + */ + public static function createByTask(CronTask $task, CronService $service) + { + $process = new self($service, $task->getId()); + $process->readInfoFile(); + $process->unique = $task->isUnique(); + $process->name = $task->getName(); + $process->command = $task->getCommand(); + $process->action = $task->getCommandAction(); + + $params = array(); + foreach ($task->getParams() as $param => $value) { + $params[] = "--{$param}={$value}"; + } + $process->params = $params; + + $app = Yii::app()->getBasePath() . DIRECTORY_SEPARATOR . 'yiic'; + $output = $task->getOutputFile() ? "> {$task->getOutputFile()}" : '>> /dev/null'; + $process->_wrapperCommand = "{$app} cron run --id={$task->getId()} {$output} 2>&1 & echo $!"; + + return $process; + } + + /** + * Static method to create process instance by task identifier. Used in special wrapper command to run specified + * task and log it's execution. + * @param string $id + * @param CronService $service application service component + * @return self + */ + public static function createById($id, CronService $service) + { + $process = new self($service, $id); + $process->readInfoFile(true); + + return $process; + } + + /** + * Get if task process is running at the moment + * @return bool + */ + public function isRunning() + { + return ($this->status === self::STATUS_RUNNING); + } + + /** + * Save info file and task wrapper + */ + public function runWrapper() + { + $this->saveInfoFile(); + exec($this->_wrapperCommand); + } + + /** + * Run console command saved in the process. Handle normal and abnormal termination (save status to lock file and + * log message) + */ + public function run() + { + $this->checkIsCLI(); + + if ($this->unique && $this->isRunning()) { + CronService::log( + "Cannot run task '{$this->name}': it is still running and does not allow overlapping (unique)", + CLogger::LEVEL_WARNING + ); + return; + } + + $this->pid = getmypid(); + $this->status = self::STATUS_RUNNING; + $this->lastStart = date('Y-m-d H:i:s'); + $this->saveInfoFile(); + + CronService::log("Task '{$this->name}' started (PID: {$this->pid})"); + + // to log task failure if error or exception occurred + register_shutdown_function(array($this, 'shutdown')); + + /** @var CConsoleCommand $command */ + $command = Yii::app()->getCommandRunner()->createCommand($this->command); + $command->init(); + + $params = $this->params; + $action = $this->action ?: $command->defaultAction; + array_unshift($params, $action); + $command->run($params); + + // normal end of the task process + $this->status = self::STATUS_FINISHED; + CronService::log("Task '{$this->name}' successfully finished"); + } + + /** + * Called by PHP on shutdown process. Checks if task was successfully finished. + * Allowed only in CLI mode. + * @throws CException + */ + public function shutdown() + { + $this->checkIsCLI(); + + $this->pid = null; + $this->lastStop = date('Y-m-d H:i:s'); + + // not finished in usual way (exception or another error) + if ($this->status === self::STATUS_RUNNING) { + $this->status = self::STATUS_FAILED; + } + + $this->saveInfoFile(); + + if ($this->status === self::STATUS_FAILED) { + CronService::log( + "Task '{$this->name}' unexpectedly finished. Check logs and console command", + CLogger::LEVEL_ERROR + ); + + // force flush application logs + Yii::getLogger()->flush(true); + } + } + + /** + * Private constructor to prevent manual instantiating outside of the special static methods + * @param CronService $service + * @param string $id + */ + private function __construct(CronService $service, $id) + { + $this->_service = $service; + $this->id = $id; + } + + /** + * Load file with information about process (JSON content). Decode data and set attributes of current instance. + * Identifier attribute should be set before calling this method. + * @param bool|false $exceptionNoFile + * @throws RuntimeException + */ + private function readInfoFile($exceptionNoFile = false) + { + $file = $this->getInfoFileName(); + + if (file_exists($file) && is_readable($file)) { + $data = json_decode(file_get_contents($file), true); + + if (!empty($data)) { + foreach ($data as $key => $value) { + $this->$key = $value; + } + + $this->checkProcessAvailability(); + } + } else { + if ($exceptionNoFile) { + throw new RuntimeException('Process info file is not available. Wrong hash?'); + } + } + } + + /** + * Check if task process really active and running + */ + private function checkProcessAvailability() + { + if ($this->pid !== null && $this->status === self::STATUS_RUNNING) { + exec("ps -p {$this->pid} -o pid", $output); + if (count($output) != 2) { + $this->pid = null; + $this->status = self::STATUS_FAILED; + CronService::log( + "Task '{$this->name}' unexpectedly finished. Check logs and console command", + CLogger::LEVEL_ERROR + ); + } + } + } + + /** + * Serialize current instance attributes to the file. + * Allowed only in CLI mode. + */ + private function saveInfoFile() + { + $this->checkIsCLI(); + $data = get_object_vars($this); + unset($data['_service'], $data['_wrapperCommand']); + + file_put_contents($this->getInfoFileName(), json_encode($data), LOCK_EX); + } + + /** + * Generate name of the file with process information + * @return string + */ + private function getInfoFileName() + { + return $this->_service->getRuntimePath() . DIRECTORY_SEPARATOR . $this->id . '.json'; + } + + /** + * Check current PHP_SAPI constant value. If not 'cli' (console mode) - throw runtime exception. + * @throws RuntimeException + */ + private function checkIsCLI() + { + if (PHP_SAPI !== 'cli') { + throw new RuntimeException('You cannot run cron process in non CLI mode'); + } + } +} \ No newline at end of file diff --git a/CronService.php b/CronService.php new file mode 100644 index 0000000..633eef9 --- /dev/null +++ b/CronService.php @@ -0,0 +1,191 @@ + array( + * 'cron' => array( + * 'tasksCallback' => array( + * array('class' => 'application.models.AppCronTasks'), + * 'getTasks' + * ) + * ) + * ) + * ... + * + * In protected/models/AppCronTasks.php: + * name('Send invites via mail') + * ->minute('9/2') + * ->setOutputFile(Yii::app()->getRuntimePath() . '/console-mail-invites.txt'); + * // call console command 'import' with action 'products' every day at 00:00 and save output + * $task2 = new CronTask('import', 'products'); + * $tasks[] = $task2 + * ->name('Import products (daily)') + * ->daily() + * ->setOutputFile(Yii::app()->getRuntimePath() . '/product-import.txt'); + * + * return $tasks; + * } + * } + * ?> + * + * @author Vadym Stepanov + * @date 18.01.2016 + */ +class CronService extends CApplicationComponent +{ + /** + * @var array valid PHP callback definition. First argument can be array with class definition to create instance + * by Yii::createComponent() + */ + public $tasksCallback; + + /** + * @var string alias path to directory to store CronProcess logs + */ + public $runtimePathAlias; + + /** + * @var CronTask[] detailed information about specified cron tasks + */ + private $activeTasks = array(); + + /** + * @var string working runtime path + */ + private $runtimePath; + + /** + * Log message with predefined category. It allows to separate logs on application level. Example of config: + * 'components' => array( + * ... + * 'log' => array( + * ... + * 'routes' => array( + * ... + * array( + * 'class' => 'CFileLogRoute', + * 'levels' => 'error, warning, info', + * 'categories' => 'cron-tasks', + * 'logFile' => 'cron.log' + * ) + * ) + * ) + * ... + * ) + * + * @param string $message log message + * @param string $level level of the message (see CLogger) + */ + public static function log($message, $level = CLogger::LEVEL_INFO) + { + Yii::log($message, $level, 'cron-tasks'); + } + + /** + * Initialize component. Import extension classes. Check attributes. + * @throws InvalidArgumentException + */ + public function init() + { + parent::init(); + + if (empty($this->tasksCallback)) { + throw new InvalidArgumentException( + 'You should specify callback with tasks definitions.' + ); + } + + if (!is_array($this->tasksCallback) || count($this->tasksCallback) !== 2) { + throw new InvalidArgumentException( + 'Callback must be a array with valid PHP callback description.' + ); + } + + if ($this->runtimePathAlias === null) { + $this->runtimePath = Yii::app()->getRuntimePath() . DIRECTORY_SEPARATOR . 'cron'; + } else { + $this->runtimePath = Yii::getPathOfAlias($this->runtimePathAlias); + } + + if (!file_exists($this->runtimePath)) { + mkdir($this->runtimePath); + } + } + + /** + * Runs specified callback to get available cron tasks and store them in this component. + */ + public function loadTasks() + { + $this->activeTasks = array(); + $tasks = $this->runCallback(); + + if (!is_array($tasks)) { + throw new RuntimeException('Callback must return array of CronTask instances'); + } + + /** @var CronTask $task */ + foreach ($tasks as $task) { + if (!is_object($task) || !($task instanceof CronTask)) { + throw new RuntimeException('One of the callback results is not a CronTask instance'); + } + + $this->activeTasks[$task->getId()] = $task; + } + } + + /** + * Get previously loaded task instances. + * @return CronTask[] + */ + public function getActiveTasks() + { + return $this->activeTasks; + } + + /** + * Get working directory for info files + * @return string + */ + public function getRuntimePath() + { + return $this->runtimePath; + } + + /** + * Check specified callback and run it returning result of execution. + * @return CronTask[] + * @throws CException + */ + private function runCallback() + { + $callback = $this->tasksCallback; + if (is_array($callback[0]) && array_key_exists('class', $callback[0])) { + $callback[0] = Yii::createComponent($callback[0]); + } + + return call_user_func($callback); + } +} \ No newline at end of file diff --git a/CronTask.php b/CronTask.php new file mode 100644 index 0000000..83e4bd1 --- /dev/null +++ b/CronTask.php @@ -0,0 +1,605 @@ + 1)); + * $task->name('Import products (daily@18:00)')->daily()->hour(18); + * + * How to add and run task see CronService class description. + * + * @author Vadym Stepanov + * @date 18.01.2016 + */ +class CronTask +{ + const MONTH_JANUARY = 1; + const MONTH_FEBRUARY = 2; + const MONTH_MARCH = 3; + const MONTH_APRIL = 4; + const MONTH_MAY = 5; + const MONTH_JUNE = 6; + const MONTH_JULY = 7; + const MONTH_AUGUST = 8; + const MONTH_SEPTEMBER = 9; + const MONTH_OCTOBER = 10; + const MONTH_NOVEMBER = 11; + const MONTH_DECEMBER = 12; + + const WEEK_MONDAY = 0; + const WEEK_TUESDAY = 1; + const WEEK_WEDNESDAY = 2; + const WEEK_THURSDAY = 3; + const WEEK_FRIDAY = 4; + const WEEK_SATURDAY = 5; + const WEEK_SUNDAY = 6; + + /** + * @var string console controller of command to execute. Required + */ + private $command; + + /** + * @var string action of console controller of command to execute. Can be omitted + */ + private $action; + + /** + * @var array key-valued list of params for specified command + */ + private $params = array(); + + /** + * @var string optional name of the task (used in displaying of the task statuses and logs). Optional. + */ + private $name; + + /** + * @var bool the flag to set uniqueness of the task (to disallow overlapping) + */ + private $unique = false; + + /** + * @var string the name of the output file with full path to output task process + */ + private $outputFile; + + /** + * @var string minute value for cron schedule. Valid range: 0-59. Default: '*', means 'every minute'. + */ + private $minuteValue = '*'; + + /** + * @var string hour value for cron schedule. Valid range: 0-23. Default: '*', means 'every hour'. + */ + private $hourValue = '*'; + + /** + * @var string day of month value for cron schedule. Valid range: 1-31. Default: '*', means 'every day'. + */ + private $dayValue = '*'; + + /** + * @var string month value for cron schedule. Valid range: 1-12. Default: '*', means 'every month'. + */ + private $monthValue = '*'; + + /** + * @var string day of week value for cron schedule. Valid range: 0-6, 0 for Sunday, 6 for Saturday. Default: '*', + * means 'any day of week'. + */ + private $dayOfWeekValue = '*'; + + /** + * @var CronProcess information about status and last start/stop time + */ + private $_process; + + /** + * @var array calculated values of allowed minutes, hours, days, etc. + */ + private $_allowedValues = array(); + + /** + * Create default task, check command and params + * @param string $command + * @param string|null $action + * @param array $params + * @throws InvalidArgumentException + */ + public function __construct($command, $action = null, array $params = array()) + { + if (empty($command)) { + throw new InvalidArgumentException( + 'Command cannot be empty. You should specify name of the application console command' + ); + } + + if (!preg_match('/^[a-z0-9]+$/i', $command)) { + throw new InvalidArgumentException( + 'Specified command value is not valid. Valid examples: myConsole, import' + ); + } + + if (!empty($action) && !preg_match('/^[a-z0-9]+$/i', $action)) { + throw new InvalidArgumentException( + 'Specified action value is not valid. Valid examples: run, index, start' + ); + } + + $this->command = $command; + $this->action = $action; + + $clearedParams = array(); + foreach ($params as $key => $value) { + $key = escapeshellcmd($key); + $value = escapeshellcmd($value); + if (!preg_match('/^[a-z]+\w+$/i', $key)) { + throw new InvalidArgumentException( + "Bad param name: '{$key}'. It must contain alphanumeric values and/or underscore." + ); + } + $clearedParams[$key] = $value; + } + + $this->params = $clearedParams; + } + + /** + * Get console command + * @return string + */ + public function getCommand() + { + return $this->command; + } + + /** + * Get console command action + * @return string + */ + public function getCommandAction() + { + return $this->action; + } + + /** + * Get list of params (already cleared and escaped) + * @return array + */ + public function getParams() + { + return $this->params; + } + + /** + * Get unique identifier for current task + * @param string $hashFunc the name of the PHP hash function + * @return string + */ + public function getId($hashFunc = 'sha1') + { + if (!function_exists($hashFunc)) { + throw new RuntimeException( + "Your server does not have hash function '{$hashFunc}'. Use another one: md5, sha1, crc32" + ); + } + + return $hashFunc(json_encode(array( + $this->command, + $this->action, + $this->params, + ))); + } + + /** + * Get name of the task. If not specified, identifier will be returned. + * @return string + */ + public function getName() + { + return $this->name ?: $this->getId(); + } + + /** + * Get output file + * @return string + */ + public function getOutputFile() + { + return $this->outputFile; + } + + /** + * Get cron schedule + * @return string + */ + public function getCron() + { + return "{$this->minuteValue} {$this->hourValue} {$this->dayValue} {$this->monthValue} {$this->dayOfWeekValue}"; + } + + /** + * Check if task instances cannot be overlapped + * @return bool + */ + public function isUnique() + { + return $this->unique; + } + + /** + * Set task to be unique to prevent overlapping + * @return $this + */ + public function unique() + { + $this->unique = true; + + return $this; + } + + /** + * Set name of the task + * @param string $value + * @return $this + */ + public function name($value) + { + $this->name = trim($value); + + return $this; + } + + /** + * Set hour schedule of the task. + * @param string $value + * @return $this + */ + public function hour($value) + { + $this->hourValue = $this->parseValue('hour', (string)$value); + + return $this; + } + + /** + * Set minute schedule of the task + * @param string $value + * @return $this + */ + public function minute($value) + { + $this->minuteValue = $this->parseValue('minute', (string)$value); + + return $this; + } + + /** + * Set day of month schedule of the task + * @param string $value + * @return $this + */ + public function day($value) + { + $this->dayValue = $this->parseValue('day', (string)$value); + + return $this; + } + + /** + * Set month schedule of the task + * @param string $value + * @return $this + */ + public function month($value) + { + $this->monthValue = $this->parseValue('month', (string)$value); + + return $this; + } + + /** + * Set day of week schedule of the task + * @param string $value + * @return $this + */ + public function dayOfWeek($value) + { + $this->dayOfWeekValue = $this->parseValue('dayOfWeek', (string)$value); + + return $this; + } + + /** + * Macro method to run task at the beginning of the each hour (at 10:00, 11:00, ...) + * @return $this + */ + public function hourly() + { + return $this->cron('0 * * * *'); + } + + /** + * Macro method to run task at the beginning of the each day + * @return $this + */ + public function daily() + { + return $this->cron('0 0 * * *'); + } + + /** + * Macro method to run task on the 1st day of each month + * @return $this + */ + public function monthly() + { + return $this->cron('0 0 1 * *'); + } + + /** + * Macro method to run task on the 1st day of each year + * @return $this + */ + public function yearly() + { + return $this->cron('0 0 1 1 *'); + } + + /** + * Macro method to run task on each Sunday at 00:00 + * @return $this + */ + public function weekly() + { + return $this->cron('0 0 * * 0'); + } + + /** + * Set schedule by cron value + * @param string $value + * @return $this + * @throws InvalidArgumentException + */ + public function cron($value) + { + $parts = array('minute', 'hour', 'day', 'month', 'dayOfWeek'); + $partPattern = '[\d\/\-\,\*]+'; + foreach ($parts as &$part) { + $part = "(?P<{$part}>{$partPattern})"; + } + unset($part); + + $regexp = '/^' . implode('\s', $parts) . '$/'; + preg_match($regexp, $value, $matches); + + if (count($matches) === 0) { + throw new InvalidArgumentException("Bad cron expression: {$value}"); + } + + $this + ->minute($matches['minute']) + ->hour($matches['hour']) + ->day($matches['day']) + ->month($matches['month']) + ->dayOfWeek($matches['dayOfWeek']); + + return $this; + } + + /** + * Set output file to store any output from task console command + * @param string $filePath + * @return $this + * @throws InvalidArgumentException + */ + public function setOutputFile($filePath) + { + if (is_dir($filePath)) { + throw new InvalidArgumentException('Wrong output path - this is the directory!'); + } + + $dirName = dirname($filePath); + + if (!is_dir($dirName)) { + throw new InvalidArgumentException('Wrong output path - target directory for output file does not exist!'); + } + + if (!is_writable($dirName)) { + throw new InvalidArgumentException('Wrong output path - target directory for output file does not writable!'); + } + + $this->outputFile = $filePath; + + return $this; + } + + /** + * Check if task can be executed now. + * @return bool + */ + public function canRun() + { + $date = getdate(); + $dateValues = array( + 'minute' => $date['minutes'], + 'hour' => $date['hours'], + 'day' => $date['mday'], + 'month' => $date['mon'], + 'dayOfWeek' => $date['wday'] - 1, + ); + + $canRun = array(); + + foreach ($dateValues as $field => $value) { + if (!array_key_exists($field, $this->_allowedValues)) { + $fieldName = $field . 'Value'; + $this->parseValue($field, $this->$fieldName); + } + + $canRun[$field] = in_array($value, $this->_allowedValues[$field], true); + } + + return $canRun['minute'] && $canRun['hour'] && $canRun['month'] && ($canRun['day'] || $canRun['dayOfWeek']); + } + + /** + * Create process instance with additional information + * @return CronProcess + */ + public function getProcessInfo() + { + if ($this->_process === null) { + $this->_process = CronProcess::createByTask($this, Yii::app()->cron); + } + + return $this->_process; + } + + /** + * Parse value of the specified field in cron schedule and get allowed date/time values (minutes, hours, ...) + * @param string $field name of the field of cron schedule: 'hour', 'minute', ... + * @param string $value value for field + * @return string + * @throws InvalidArgumentException + */ + private function parseValue($field, $value) + { + $regexp = '(\,|^)(?P\*|%DIGIT%)(\-(?P%DIGIT%))?(\/(?P%DIGIT%))?'; + + $digitRegexp = ''; + switch ($field) { + case 'hour': + $digitRegexp = '[0-2]?[0-9]+'; + break; + case 'minute': + $digitRegexp = '[0-5]?[0-9]+'; + break; + case 'day': + $digitRegexp = '[1-3]?[0-9]+'; + break; + case 'month': + $digitRegexp = '1?[0-9]+'; + break; + case 'dayOfWeek': + $digitRegexp = '[0-6]+'; + break; + } + + $regexp = str_replace('%DIGIT%', $digitRegexp, $regexp); + preg_match_all("/{$regexp}/", $value, $matches); + + $num = count($matches['arg1']); + if ($num === 0) { + throw new InvalidArgumentException("Bad syntax for '{$field}' (value: '{$value}')"); + } + + $analyze = array(); + for ($i = 0; $i < $num; $i++) { + $analyze[] = array( + 'arg1' => $matches['arg1'][$i], + 'arg2' => $matches['arg2'][$i], + 'step' => $matches['step'][$i], + ); + } + + $this->getAllowedValues($field, $analyze); + + return $value; + } + + /** + * Get all allowed values for parsed field conditions + * @param string $field name of the field of cron schedule + * @param array $matches parsed range conditions from entered value + * @throws InvalidArgumentException + */ + private function getAllowedValues($field, $matches) + { + $sequence = array(); + foreach ($matches as $match) { + + if ($match['arg1'] === '*' && $match['arg2'] !== '') { + throw new InvalidArgumentException( + "Bad syntax for '{$field}' (part of value: '{$match['arg1']}-{$match['arg2']}')" + ); + } + + try { + $sequence = array_merge( + $sequence, + $this->generateSequence($field, $match['arg1'], $match['arg2'], $match['step']) + ); + } catch (InvalidArgumentException $e) { + throw new InvalidArgumentException( + "Bad syntax for '{$field}' (part of value: '{$match['arg1']}-{$match['arg2']}'). Error: {$e->getMessage()}" + ); + } + } + + $this->_allowedValues[$field] = $sequence; + } + + /** + * Generate sequence of the allowed values for specified range with some step + * @param string $field name of the field of cron schedule + * @param mixed $start start of the range or '*' (means, all available values) + * @param mixed $end end of the range (can be omitted) + * @param mixed $step step of value increasing in range + * @return array + * @throws InvalidArgumentException + */ + private function generateSequence($field, $start, $end, $step) + { + $fieldLimits = array( + 'hour' => array('start' => 0, 'end' => 23), + 'minute' => array('start' => 0, 'end' => 59), + 'day' => array('start' => 1, 'end' => 31), + 'month' => array('start' => 1, 'end' => 12), + 'dayOfWeek' => array('start' => 0, 'end' => 6), + ); + + $step = $step ? (int)$step : 1; + + if ($start === '*') { + $start = $fieldLimits[$field]['start']; + $end = $fieldLimits[$field]['end']; + } else { + $start = (int)$start; + if ($start < $fieldLimits[$field]['start']) { + throw new InvalidArgumentException("Wrong beginning of the range: '{$start}'"); + } + + if (empty($end)) { + $end = $start; + if ($step > 1) { + $end = $fieldLimits[$field]['end']; + } + } else { + $end = (int)$end; + } + if ($end > $fieldLimits[$field]['end']) { + throw new InvalidArgumentException("Wrong ending of the range: '{$end}'"); + } + } + + $values = array(); + for ($i = $start; $i <= $end; $i += $step) { + $values[] = $i; + } + + return $values; + } +} \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..ab40e2e --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,32 @@ +The yii-cron-tasks is free software. It is released under the terms of +the following BSD License. + +Copyright (c) 2015 by Stevad. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of Stevad nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..24904ec --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +yii-cron-tasks +================= + +Simple extension to create and execute scheduled console commands in Yii Framework 1.x application. + +It allows to create tasks with powerful cron syntax. Each task can be unique to disallow overlapping (with +logging warning message if this happens). Also you can set output file for each task and get information about +last start and stop time and current status of execution (with PID). + +For license information check the [LICENSE](LICENSE.md) file. + +Tested on Yii Framework v1.1.16. + +Installation +------------- + +This extension is available at packagist.org and can be installed via composer by following command: + +`composer require --dev stevad/yii-cron-tasks`. + +If you want to install this extension manually - copy sources to `/protected/extensions` directory. + +Example of default configuration (with using of `ext.yii-cron-tasks` alias, meaning that extension files are located here: `/protected/extensions/yii-cron-tasks`): + +```php +return array( + // import classes + 'import' => array( + 'ext.yii-cron-tasks.*' + ), + 'components' => array( + 'cron' => array( + 'class' => 'ext.yii-cron-tasks.CronService', + // next option must be a valid PHP callback, this is example + 'tasksCallback' => array( + array('class' => 'application.models.AppCronTasks'), + 'getList' + ), + ), + ), + 'commandMap' => array( + 'cron' => array( + 'class' => 'ext.yii-cron-tasks.CronCommand' + ), + ), +); +``` + +For component option `tasksCallback` you must specify valid PHP callback. First argument can be an array with object +definition to create instance by `Yii::createComponent()` method. + +In configuration example it was mentioned callback with Yii class definition `application.models.AppCronTasks` and +`getList` action. Here is the example of class content with cron tasks definitions: + +File: `protected/models/AppCronTasks.php` + +```php +class AppCronTasks +{ + public function getList() + { + $tasks = array(); + + // call console command 'mail' with action 'sendInvites' every hour each 2 minutes starting from 9th + // and save output to protected/runtime/console-mail-invites.txt + $task1 = new CronTask('mail', 'sendInvites'); + $tasks[] = $task1 + ->name('Send invites via mail') + ->minute('9/2') + ->setOutputFile(Yii::app()->getRuntimePath() . '/console-mail-invites.txt'); + + // call console command 'import' with action 'products' every day at 00:00 and save output + $task2 = new CronTask('import', 'products', array('removeOld' => 1)); + $tasks[] = $task2 + ->name('Import products (daily)') + ->daily() + ->setOutputFile(Yii::app()->getRuntimePath() . '/product-import.txt'); + + return $tasks; + } +} +``` + +In this class we have method witch returns two configured console tasks. To run them we need to make last step: manually +add special console command to the server's crontab: + +`* * * * * php /path/to/yiic cron daemon >> /dev/null 2>&1` + +And now server will run our own cron daemon console command each minute and check if some of the specified tasks +need to be executed. + + +CronTask class +------------- + +By default each task instance is pre-configured to be executed each minute (cron schedule: `* * * * *`). To create own +task you need to create `CronTask` instance and pass command, action names and optional params for it. + +Command is the name of the available application console command. Action name can be omitted (will run default +action: `index` or another by configuration options). + +Params are represented as the key-valued list where key is the name of param (without `--` at the beginning). + +Available methods in `CronTask` class: + +- `name('Task name')` - sets name for task (for logs and task statuses) +- `unique()` - sets task to disallow running of another instance of the same task if previous is still running +- `setOutputFile('/full/path/to/file.txt')` - sets the file to which will be redirected console output +- `canRun()` - check if task can be executed now +- `getProcessInfo()` - create `CronProcess` instance with detailed process information (last start and stop time, PID, + status of execution) + +Methods to control schedule: + +- `hour(12)` - sets hour part of cron schedule +- `minute('*/5')` - sets minute part of cron schedule (in example: each five minutes, starting with 0) +- `day('1-10,15/2')` - sets day of month part of cron schedule (in example: 1-10 and then each 2 days from 15th by the +end of the month) +- `month('4')` - sets month part of cron schedule (in example: each April) +- `dayOfWeek('6')` - sets day of week part of cron schedule (in example: each Saturday) +- `cron('30 12 * * *')` - set schedule directly by cron + +Each method support all features of cron syntax. Check this site for more information: [crontab.guru](http://crontab.guru/) + +Also there is available some predefined "macro" methods: + +- `hourly()` - run task at the beginning of each hour, equals to cron: `0 * * * *` +- `daily()` - run task each day at 00:00, equals to cron: `0 0 * * *` +- `monthly()` - run task each month at 1st day at 00:00, equals to cron: `0 0 1 * *` +- `yearly()` - run task each year at 1st January at 00:00, equals to cron: `0 0 1 1 *` +- `weekly()` - run task each Sunday at 00:00, equals to cron: `0 0 * * 0` + +You can combine methods in any way. For example, to set task to be executed at 18:00 every day you can use next code: + +```php +$task = new CronTask('command/action'); +$task->daily()->hour(18); +``` + +Author +------------- + +Copyright (c) 2015 by Stevad. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c417bc3 --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "stevad/yii-cron-tasks", + "description": "Yii extension to execute console commands by cron schedule in your application", + "keywords": ["yii", "extension", "cron", "schedule"], + "homepage": "https://github.com/stevad/yii-cron-tasks", + "type": "yii-extension", + "license": "BSD-3-Clause", + "require": { + "php": ">=5.3.0", + "yiisoft/yii": ">=1.1.16" + }, + "authors": [ + { + "name": "Vadym Stepanov", + "email": "vadim.stepanov.ua@gmail.com" + } + ] +}