diff --git a/composer.json b/composer.json index fb7d800..0405b04 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,13 @@ "name": "avsdev/sprinkle-scheduler", "type": "userfrosting-sprinkle", "description": "A sprinkle to add scheduled events.", + "require": { + "dragonmantank/cron-expression": "*" + }, "autoload": { + "files" : [ + "defines.php" + ], "psr-4": { "UserFrosting\\Sprinkle\\Scheduler\\": "src/" } diff --git a/defines.php b/defines.php new file mode 100644 index 0000000..f2cfd18 --- /dev/null +++ b/defines.php @@ -0,0 +1,13 @@ +setName('schedule') + ->setDescription('Run the schedule') + ->setHelp('This command runs the schedule, checking if any tasks are due and executing them.'); + #->addOption('quiet', null, InputOption::VALUE_NONE, 'Do not output any status messages.'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + // Get options + $quiet = $input->getOption('quiet'); + + if (!$quiet) { + $this->io->title("UserFrosting's Scheduler"); + } + + // Prepare task locator + $scheduler = $this->ci->scheduler; + + + if (!$quiet) { + $this->io->writeln('Checking for tasks to run...'); + } + + $tasks = []; + + // Start by getting tasks + foreach ($scheduler->getTasks() as $task) { + if ($task['instance']->isDue()) { + // Add task class to list + $tasks[] = $task; + } + } + + if (count($tasks) == 0) { + if (!$quiet) { + $this->io->success('Nothing to do'); + } + return self::SUCCESS; + } + + if (!$quiet) { + $this->io->writeln('Found ' . count($tasks) . ' task(s) to run'); + $this->io->writeln(''); + $this->io->writeln('Running tasks...'); + } + + $hasFailure = false; + + // Run tasks + foreach ($tasks as $task) { + // Display the class we are going to use as info + $this->io->write('Running task `' . $task['class'] . '`...'); + $start = hrtime(true); + + try { + + if (!($taskSuccess = $task['instance']->run())) { + throw new Exception('Task returned failure'); + } + } catch (\Exception $e) { + $taskSuccess = false; + $hasFailure = true; + if ($quiet) { + $this->io->write('Running task `' . $task['class'] . '`...'); + } + $this->io->error(' [ERROR] ' . $e->getMessage() . ' '); + } + + if (!$quiet && $taskSuccess) { + $end = hrtime(true); + + $tdiff = round(($end-$start) / 1e+6); + $tunit = 'ms'; + if ($tdiff > 1000) { + $tdiff = round($tdiff / 1000); + $tunit = 's'; + } + + $this->io->writeln($tdiff . $tunit); + } + } + + // Success + if (!$hasFailure) { + if (!$quiet) { + $this->io->success('Schedule success !'); + } + return self::SUCCESS; + } else { + if (!$quiet) { + $this->io->error('Schedule failed !'); + } + return self::FAILURE; + } + + } +} diff --git a/src/Bakery/ScheduleListCommand.php b/src/Bakery/ScheduleListCommand.php new file mode 100644 index 0000000..8616521 --- /dev/null +++ b/src/Bakery/ScheduleListCommand.php @@ -0,0 +1,49 @@ +setName('schedule:list') + ->setDescription('List all scheduled tasks available') + ->setHelp('This command returns a list of scheduled tasks that can be called using the `schedule:run` command.'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->io->title('Scheduled Tasks List'); + $tasks = $this->ci->scheduler->getTasks(); + $tasks = array_map(function($task) { + unset($task['instance']); + return $task; + }, $tasks); + $this->io->table(['Schedule', 'Sprinkle', 'Name', 'Namespace'], $tasks); + + return self::SUCCESS; + } +} diff --git a/src/Bakery/ScheduleRunCommand.php b/src/Bakery/ScheduleRunCommand.php new file mode 100644 index 0000000..acd687f --- /dev/null +++ b/src/Bakery/ScheduleRunCommand.php @@ -0,0 +1,92 @@ +setName('schedule:run') + ->setDescription('Run a scheduled task now') + ->setHelp('This command runs a task immediately, ignoring all schedule constraints.') + ->addArgument('name', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'The name of the task(s). Separate multiple tasks with a space.'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->io->title('Running Task(s)'); + + $scheduler = $this->ci->scheduler; + + // Get options + $names = $input->getArgument('name'); + + $hasFailure = false; + + // Start by getting seeds + foreach ($names as $taskName) { + + // Get task class and + try { + $taskClass = $scheduler->getTaskClass($taskName); + } catch (\Exception $e) { + $this->io->error($e->getMessage()); + exit(1); + } + + // Display the class we are going to use as info + $this->io->write('Running task `' . get_class($taskClass) . '`...'); + + $start = hrtime(true); + try { + $scheduler->runTask($taskClass); + } catch (\Exception $e) { + $this->io->writeln('failed'); + $this->io->error($e->getMessage()); + $hasFailure = true; + } + $end = hrtime(true); + + $tdiff = round(($end-$start) / 1e+6); + $tunit = 'ms'; + if ($tdiff > 1000) { + $tdiff = round($tdiff / 1000); + $tunit = 's'; + } + + $this->io->writeln($tdiff . $tunit); + } + + if (!$hasFailure) { + $this->io->success('Run task success !'); + } else { + $this->io->error('Run task failed !'); + } + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/src/Scheduler.php b/src/Scheduler.php index 4d02487..eda96eb 100644 --- a/src/Scheduler.php +++ b/src/Scheduler.php @@ -18,4 +18,23 @@ use UserFrosting\System\Sprinkle\Sprinkle; */ class Scheduler extends Sprinkle { + /** + * Set static references to DI container in necessary classes. + */ + public function onSprinklesInitialized() + { + $this->registerStreams(); + } + + /** + * Register Scheduler sprinkle locator streams. + */ + protected function registerStreams() + { + /** @var \UserFrosting\UniformResourceLocator\ResourceLocator $locator */ + $locator = $this->ci->locator; + + // Register scheduler sprinkle class streams + $locator->registerStream('tasks', '', \UserFrosting\SCHEDULED_TASKS_DIR); + } } diff --git a/src/Scheduler/BaseTask.php b/src/Scheduler/BaseTask.php new file mode 100644 index 0000000..9528d82 --- /dev/null +++ b/src/Scheduler/BaseTask.php @@ -0,0 +1,111 @@ +ci = $ci; + $this->schedule(); + } + + /** + * Function used to retrieve the cron expression for the task. + */ + public function cronExpression() + { + return $this->expression; + } + + /** + * Determine if the given event should run based on the Cron expression. + * + * @return bool + */ + public function isDue() + { + $date = Date::now(); + + if ($this->timezone) { + $date = $date->setTimezone($this->timezone); + } + + return (new CronExpression($this->expression))->isDue($date->toDateTimeString()); + } + + /** + * Determine the next due date for an event. + * + * @param \DateTimeInterface|string $currentTime + * @param int $nth + * @param bool $allowCurrentDate + * @return \Illuminate\Support\Carbon + */ + public function nextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false) + { + return Date::instance((new CronExpression($this->getExpression())) + ->getNextRunDate($currentTime, $nth, $allowCurrentDate, $this->timezone)); + } + + /** + * Function used to specify the schedule for the task. + */ + abstract public function schedule(); + + /** + * Function used to specify what the task does. + */ + abstract public function run(); +} diff --git a/src/Scheduler/Scheduler.php b/src/Scheduler/Scheduler.php new file mode 100644 index 0000000..b16af55 --- /dev/null +++ b/src/Scheduler/Scheduler.php @@ -0,0 +1,215 @@ +ci = $ci; + } + + /** + * Loop all the available sprinkles and return a list of their tasks. + * + * @return array An array of all the task classes found for every sprinkle + */ + public function getTasks() + { + $tasks = $this->ci->locator->listResources($this->scheme, false, false); + + return $this->loadTasks($tasks); + } + + /** + * Get a single task info. + * + * @param string $name The task name + * + * @throws \Exception If task not found + * + * @return array The details about a task file [name, class, sprinkle] + */ + public function getTask($name) + { + // Get task resource + $taskResource = $this->ci->locator->getResource($this->scheme . $name . '.php'); + + // Make sure we found something + if (!$taskResource) { + throw new \Exception("Task $name not found"); + } + + // Return the task info + return $this->getTaskDetails($taskResource); + } + + /** + * Return the class instance of a task. + * + * @param string $name The task name + * + * @throws \Exception If class doesn't exist or is not right interface + * + * @return TaskInterface The task class instance + */ + public function getTaskClass($name) + { + // Try to get task info + $task = $this->getTask($name); + + // Make sure class exist + $classPath = $task['class']; + if (!class_exists($classPath)) { + throw new \Exception("Task class `$classPath` not found. Make sure the class has the correct namespace."); + } + + // Create a new class instance + $taskClass = new $classPath($this->ci); + + // Class must be an instance of `TaskerInterface` + if (!$taskClass instanceof TaskInterface) { + throw new \Exception('Task class must be an instance of `TaskerInterface`'); + } + + return $taskClass; + } + + /** + * Run a task class. + * + * @param TaskInterface $task The task to run + */ + public function runTask(TaskInterface $task) + { + $task->run(); + } + + /** + * Run a task based on it's name. + * + * @param string $taskName + */ + public function run($taskName) + { + $task = $this->getTaskClass($taskName); + $this->runTask($task); + } + + /** + * Return a list of tasks due to be run + */ + public function dueTasks() + { + $tasks = $this->getTasks(); + + return array_filter($tasks, function($task) { + // Get the task's class + $classPath = $task['class']; + + // Create a new class instance + $taskClass = new $classPath($this->ci); + + // Class must be an instance of `TaskerInterface` + if (!$taskClass instanceof TaskInterface) { + throw new \Exception('Task class must be an instance of `TaskerInterface`'); + } + + return $taskClass->isDue(); + }); + } + + /** + * Process tasks Resource into info. + * + * @param array $taskFiles List of tasks files + * + * @return array + */ + protected function loadTasks(array $taskFiles) + { + $tasks = []; + + foreach ($taskFiles as $taskFile) { + $tasks[] = $this->getTaskDetails($taskFile); + } + + return $tasks; + } + + /** + * Return an array of task details including the class name and the sprinkle name. + * + * @param ResourceInstance $file The task file + * + * @return array The details about a task file [name, class, sprinkle] + */ + protected function getTaskDetails(ResourceInstance $file) + { + // Format the sprinkle name for the namespace + $sprinkleName = $file->getLocation()->getName(); + $sprinkleName = Str::studly($sprinkleName); + + // Getting base path, name and class name + $basePath = str_replace($file->getBasename(), '', $file->getBasePath()); + $name = $basePath . $file->getFilename(); + $className = str_replace('/', '\\', $basePath) . $file->getFilename(); + $classPath = "\\UserFrosting\\Sprinkle\\$sprinkleName\\Scheduler\\Tasks\\$className"; + + if (!class_exists($classPath)) { + throw new \Exception("Task class `$className` not found. Make sure the class has the correct namespace."); + } + + // Create a new class instance + $taskClass = new $classPath($this->ci); + + // Class must be an instance of `TaskerInterface` + if (!$taskClass instanceof TaskInterface) { + throw new \Exception('Task class must be an instance of `TaskerInterface`'); + } + + $schedule = $taskClass->cronExpression(); + + // Build the class name and namespace + return [ + 'schedule' => $schedule, + 'sprinkle' => $sprinkleName, + 'name' => $name, + 'class' => $classPath, + 'instance' => $taskClass, + ]; + } +} diff --git a/src/Scheduler/TaskInterface.php b/src/Scheduler/TaskInterface.php new file mode 100644 index 0000000..ad9b937 --- /dev/null +++ b/src/Scheduler/TaskInterface.php @@ -0,0 +1,51 @@ +