diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dfc6956e..7e260f81d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rector as dev dependency for automated code refactoring - Additional PHP extensions required in CI: `bcmath`, `gd`, `zip` - PHPUnit strict testing flags: `--fail-on-warning`, `--fail-on-risky` +- **Cron Scheduler**: New CLI command `php qt cron:run` for running scheduled tasks + - Task definition via PHP files in `cron/` directory + - Cron expression parsing using `dragonmantank/cron-expression` library + - File-based task locking to prevent concurrent execution + - Comprehensive logging of task execution and errors + - Support for force mode and specific task execution + - Automatic cleanup of stale locks (older than 24 hours) + - Full documentation in `docs/cron-scheduler.md` ### Removed - Support for PHP 7.3 and earlier versions diff --git a/composer.json b/composer.json index 2cf4e4be9..9eaef8f08 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,8 @@ "league/commonmark": "^2.0", "ezyang/htmlpurifier": "^4.18", "povils/figlet": "^0.1.0", - "ramsey/uuid": "^4.2" + "ramsey/uuid": "^4.2", + "dragonmantank/cron-expression": "^3.0" }, "require-dev": { "phpunit/phpunit": "^9.0", diff --git a/src/Console/Commands/CronRunCommand.php b/src/Console/Commands/CronRunCommand.php new file mode 100644 index 000000000..307170969 --- /dev/null +++ b/src/Console/Commands/CronRunCommand.php @@ -0,0 +1,140 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Console\Commands; + +use Quantum\Libraries\Cron\CronManager; +use Quantum\Libraries\Cron\Exceptions\CronException; +use Quantum\Console\QtCommand; + +/** + * Class CronRunCommand + * @package Quantum\Console + */ +class CronRunCommand extends QtCommand +{ + /** + * The console command name. + * @var string + */ + protected $name = 'cron:run'; + + /** + * The console command description. + * @var string + */ + protected $description = 'Run scheduled cron tasks'; + + /** + * Command help text. + * @var string + */ + protected $help = 'Executes scheduled tasks defined in the cron directory. Use --task to run a single task or --force to bypass locks.'; + + /** + * Command options + * @var array> + */ + protected $options = [ + ['force', 'f', 'none', 'Force run tasks ignoring locks'], + ['task', 't', 'optional', 'Run a specific task by name'], + ['path', 'p', 'optional', 'Custom cron directory path'], + ]; + + /** + * Executes the command + */ + public function exec() + { + $force = (bool) $this->getOption('force'); + $taskName = $this->getOption('task'); + $cronPath = $this->getOption('path') ?: getenv('QT_CRON_PATH') ?: null; + + try { + $manager = new CronManager($cronPath); + + if ($taskName) { + $this->runSpecificTask($manager, $taskName, $force); + } else { + $this->runAllDueTasks($manager, $force); + } + } catch (CronException $e) { + $this->error($e->getMessage()); + } catch (\Throwable $e) { + $this->error('Unexpected error: ' . $e->getMessage()); + } + } + + /** + * Run all due tasks + * @param CronManager $manager + * @param bool $force + * @return void + */ + private function runAllDueTasks(CronManager $manager, bool $force): void + { + $this->info('Running scheduled tasks...'); + + $stats = $manager->runDueTasks($force); + + $this->output(''); + $this->info('Execution Summary:'); + $this->output(" Total tasks: {$stats['total']}"); + $this->output(" Executed: {$stats['executed']}"); + $this->output(" Skipped: {$stats['skipped']}"); + + if ($stats['locked'] > 0) { + $this->output(" Locked: {$stats['locked']}"); + } + + if ($stats['failed'] > 0) { + $this->output(" Failed: {$stats['failed']}"); + } + + $this->output(''); + + if ($stats['executed'] > 0) { + $this->info('✓ Tasks completed successfully'); + } elseif ($stats['total'] === 0) { + $this->comment('No tasks found in cron directory'); + } else { + $this->comment('No tasks were due to run'); + } + } + + /** + * Run a specific task + * @param CronManager $manager + * @param string $taskName + * @param bool $force + * @return void + * @throws CronException + */ + private function runSpecificTask(CronManager $manager, string $taskName, bool $force): void + { + $this->info("Running task: {$taskName}"); + + $manager->runTaskByName($taskName, $force); + + $stats = $manager->getStats(); + + if ($stats['executed'] > 0) { + $this->info("✓ Task '{$taskName}' completed successfully"); + } elseif ($stats['failed'] > 0) { + $this->error("✗ Task '{$taskName}' failed"); + } elseif ($stats['locked'] > 0) { + $this->comment("⚠ Task '{$taskName}' is locked"); + } + } +} diff --git a/src/Libraries/Cron/Contracts/CronTaskInterface.php b/src/Libraries/Cron/Contracts/CronTaskInterface.php new file mode 100644 index 000000000..9944c3e4d --- /dev/null +++ b/src/Libraries/Cron/Contracts/CronTaskInterface.php @@ -0,0 +1,46 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron\Contracts; + +/** + * Interface CronTaskInterface + * @package Quantum\Libraries\Cron + */ +interface CronTaskInterface +{ + /** + * Get the cron expression + * @return string + */ + public function getExpression(): string; + + /** + * Get the task name + * @return string + */ + public function getName(): string; + + /** + * Check if the task should run at the current time + * @return bool + */ + public function shouldRun(): bool; + + /** + * Execute the task + * @return void + */ + public function handle(): void; +} diff --git a/src/Libraries/Cron/CronLock.php b/src/Libraries/Cron/CronLock.php new file mode 100644 index 000000000..058187c9f --- /dev/null +++ b/src/Libraries/Cron/CronLock.php @@ -0,0 +1,207 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron; + +use Quantum\Libraries\Cron\Exceptions\CronException; + +/** + * Class CronLock + * @package Quantum\Libraries\Cron + */ +class CronLock +{ + /** + * Lock directory path + * @var string + */ + private $lockDirectory; + + /** + * Task name + * @var string + */ + private $taskName; + + /** + * Lock file path + * @var string|null + */ + private $lockFile = null; + + /** + * Lock file handle + * @var resource|null + */ + private $lockHandle = null; + + /** + * Maximum lock age in seconds (24 hours) + */ + private const MAX_LOCK_AGE = 86400; + + /** + * CronLock constructor + * @param string $taskName + * @param string|null $lockDirectory + * @throws CronException + */ + public function __construct(string $taskName, ?string $lockDirectory = null) + { + $this->taskName = $taskName; + $this->lockDirectory = $lockDirectory ?? $this->getDefaultLockDirectory(); + $this->lockFile = $this->lockDirectory . DIRECTORY_SEPARATOR . $this->taskName . '.lock'; + + $this->ensureLockDirectoryExists(); + $this->cleanupStaleLocks(); + } + + /** + * Acquire lock for the task + * @return bool + */ + public function acquire(): bool + { + if ($this->isLocked()) { + return false; + } + + $this->lockHandle = fopen($this->lockFile, 'w'); + + if ($this->lockHandle === false) { + return false; + } + + if (!flock($this->lockHandle, LOCK_EX | LOCK_NB)) { + fclose($this->lockHandle); + $this->lockHandle = null; + return false; + } + + fwrite($this->lockHandle, json_encode([ + 'task' => $this->taskName, + 'started_at' => time(), + 'pid' => getmypid() + ])); + + fflush($this->lockHandle); + + return true; + } + + /** + * Release the lock + * @return bool + */ + public function release(): bool + { + if ($this->lockHandle !== null) { + flock($this->lockHandle, LOCK_UN); + fclose($this->lockHandle); + $this->lockHandle = null; + } + + if (file_exists($this->lockFile)) { + return unlink($this->lockFile); + } + + return true; + } + + /** + * Check if task is locked + * @return bool + */ + public function isLocked(): bool + { + if (!file_exists($this->lockFile)) { + return false; + } + + // Check if lock is stale + if (time() - filemtime($this->lockFile) > self::MAX_LOCK_AGE) { + unlink($this->lockFile); + return false; + } + + // Try to open the file to check if it's actually locked + $handle = @fopen($this->lockFile, 'r'); + if ($handle === false) { + return true; + } + + $locked = !flock($handle, LOCK_EX | LOCK_NB); + + if (!$locked) { + flock($handle, LOCK_UN); + } + + fclose($handle); + + return $locked; + } + + /** + * Get default lock directory + * @return string + */ + private function getDefaultLockDirectory(): string + { + $baseDir = base_dir() . DIRECTORY_SEPARATOR . 'runtime'; + return $baseDir . DIRECTORY_SEPARATOR . 'cron' . DIRECTORY_SEPARATOR . 'locks'; + } + + /** + * Ensure lock directory exists + * @throws CronException + */ + private function ensureLockDirectoryExists(): void + { + if (!is_dir($this->lockDirectory)) { + if (!mkdir($this->lockDirectory, 0755, true)) { + throw CronException::lockDirectoryNotWritable($this->lockDirectory); + } + } + + if (!is_writable($this->lockDirectory)) { + throw CronException::lockDirectoryNotWritable($this->lockDirectory); + } + } + + /** + * Cleanup stale locks + */ + private function cleanupStaleLocks(): void + { + if (!is_dir($this->lockDirectory)) { + return; + } + + $files = glob($this->lockDirectory . DIRECTORY_SEPARATOR . '*.lock'); + + foreach ($files as $file) { + if (time() - filemtime($file) > self::MAX_LOCK_AGE) { + @unlink($file); + } + } + } + + /** + * Destructor - ensure lock is released + */ + public function __destruct() + { + $this->release(); + } +} diff --git a/src/Libraries/Cron/CronManager.php b/src/Libraries/Cron/CronManager.php new file mode 100644 index 000000000..6d111fafc --- /dev/null +++ b/src/Libraries/Cron/CronManager.php @@ -0,0 +1,251 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron; + +use Quantum\Libraries\Cron\Contracts\CronTaskInterface; +use Quantum\Libraries\Cron\Exceptions\CronException; +use Quantum\Libraries\Logger\Factories\LoggerFactory; + +/** + * Class CronManager + * @package Quantum\Libraries\Cron + */ +class CronManager +{ + /** + * Loaded tasks + * @var array + */ + private $tasks = []; + + /** + * Cron directory path + * @var string + */ + private $cronDirectory; + + /** + * Execution statistics + * @var array + */ + private $stats = [ + 'total' => 0, + 'executed' => 0, + 'skipped' => 0, + 'failed' => 0, + 'locked' => 0 + ]; + + /** + * CronManager constructor + * @param string|null $cronDirectory + */ + public function __construct(?string $cronDirectory = null) + { + $this->cronDirectory = $cronDirectory ?? $this->getDefaultCronDirectory(); + } + + /** + * Load tasks from cron directory + * @return void + * @throws CronException + */ + public function loadTasks(): void + { + if (!is_dir($this->cronDirectory)) { + return; + } + + $files = scandir($this->cronDirectory); + + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + + if (pathinfo($file, PATHINFO_EXTENSION) === 'php') { + $this->loadTaskFromFile($this->cronDirectory . DIRECTORY_SEPARATOR . $file); + } + } + + $this->stats['total'] = count($this->tasks); + } + + /** + * Load a single task from file + * @param string $file + * @return void + * @throws CronException + */ + private function loadTaskFromFile(string $file): void + { + $task = require $file; + + if (is_array($task)) { + $task = $this->createTaskFromArray($task); + } + + if (!$task instanceof CronTaskInterface) { + throw CronException::invalidTaskFile($file); + } + + $this->tasks[$task->getName()] = $task; + } + + /** + * Create task from array definition + * @param array $definition + * @return CronTask + * @throws CronException + */ + private function createTaskFromArray(array $definition): CronTask + { + if (!isset($definition['name'], $definition['expression'], $definition['callback'])) { + throw new CronException('Task definition must contain name, expression, and callback'); + } + + return new CronTask( + $definition['name'], + $definition['expression'], + $definition['callback'] + ); + } + + /** + * Run all due tasks + * @param bool $force Ignore locks + * @return array Statistics + */ + public function runDueTasks(bool $force = false): array + { + $this->loadTasks(); + + foreach ($this->tasks as $task) { + if ($task->shouldRun()) { + $this->runTask($task, $force); + } else { + $this->stats['skipped']++; + } + } + + return $this->stats; + } + + /** + * Run a specific task by name + * @param string $taskName + * @param bool $force Ignore locks + * @return void + * @throws CronException + */ + public function runTaskByName(string $taskName, bool $force = false): void + { + $this->loadTasks(); + + if (!isset($this->tasks[$taskName])) { + throw CronException::taskNotFound($taskName); + } + + $this->runTask($this->tasks[$taskName], $force); + } + + /** + * Run a single task + * @param CronTaskInterface $task + * @param bool $force Ignore locks + * @return void + */ + private function runTask(CronTaskInterface $task, bool $force = false): void + { + $lock = new CronLock($task->getName()); + + if (!$force && $lock->isLocked()) { + $this->stats['locked']++; + $this->log('warning', "Task \"{$task->getName()}\" skipped: locked"); + return; + } + + if (!$force && !$lock->acquire()) { + $this->stats['locked']++; + $this->log('warning', "Task \"{$task->getName()}\" skipped: failed to acquire lock"); + return; + } + + $startTime = microtime(true); + $this->log('info', "Task \"{$task->getName()}\" started"); + + try { + $task->handle(); + $duration = round(microtime(true) - $startTime, 2); + $this->stats['executed']++; + $this->log('info', "Task \"{$task->getName()}\" completed in {$duration}s"); + } catch (\Throwable $e) { + $this->stats['failed']++; + $this->log('error', "Task \"{$task->getName()}\" failed: " . $e->getMessage(), [ + 'exception' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + } finally { + if (!$force) { + $lock->release(); + } + } + } + + /** + * Get all loaded tasks + * @return array + */ + public function getTasks(): array + { + return $this->tasks; + } + + /** + * Get execution statistics + * @return array + */ + public function getStats(): array + { + return $this->stats; + } + + /** + * Get default cron directory + * @return string + */ + private function getDefaultCronDirectory(): string + { + return base_dir() . DIRECTORY_SEPARATOR . 'cron'; + } + + /** + * Log a message + * @param string $level + * @param string $message + * @param array $context + * @return void + */ + private function log(string $level, string $message, array $context = []): void + { + try { + $logger = LoggerFactory::get(); + $logger->log($level, "[CRON] " . $message, $context); + } catch (\Throwable $exception) { + error_log(sprintf('[CRON] [%s] %s', strtoupper($level), $message)); + } + } +} diff --git a/src/Libraries/Cron/CronTask.php b/src/Libraries/Cron/CronTask.php new file mode 100644 index 000000000..b1a80f14a --- /dev/null +++ b/src/Libraries/Cron/CronTask.php @@ -0,0 +1,113 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron; + +use Quantum\Libraries\Cron\Contracts\CronTaskInterface; +use Quantum\Libraries\Cron\Exceptions\CronException; +use Cron\CronExpression; + +/** + * Class CronTask + * @package Quantum\Libraries\Cron + */ +class CronTask implements CronTaskInterface +{ + /** + * Cron expression instance + * @var CronExpression + */ + private $cronExpression; + + /** + * Task name + * @var string + */ + private $name; + + /** + * Task callback + * @var callable + */ + private $callback; + + /** + * CronTask constructor + * @param string $name + * @param string $expression + * @param callable $callback + * @throws CronException + */ + public function __construct(string $name, string $expression, callable $callback) + { + $this->name = $name; + $this->callback = $callback; + + try { + $this->cronExpression = new CronExpression($expression); + } catch (\Exception $e) { + throw CronException::invalidExpression($expression); + } + } + + /** + * @inheritDoc + */ + public function getExpression(): string + { + return $this->cronExpression->getExpression(); + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return $this->name; + } + + /** + * @inheritDoc + */ + public function shouldRun(): bool + { + return $this->cronExpression->isDue(); + } + + /** + * @inheritDoc + */ + public function handle(): void + { + call_user_func($this->callback); + } + + /** + * Get the next run date + * @return \DateTime + */ + public function getNextRunDate(): \DateTime + { + return $this->cronExpression->getNextRunDate(); + } + + /** + * Get the previous run date + * @return \DateTime + */ + public function getPreviousRunDate(): \DateTime + { + return $this->cronExpression->getPreviousRunDate(); + } +} diff --git a/src/Libraries/Cron/Enums/ExceptionMessages.php b/src/Libraries/Cron/Enums/ExceptionMessages.php new file mode 100644 index 000000000..160673220 --- /dev/null +++ b/src/Libraries/Cron/Enums/ExceptionMessages.php @@ -0,0 +1,30 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron\Enums; + +/** + * Enum ExceptionMessages + * @package Quantum\Libraries\Cron + */ +enum ExceptionMessages: string +{ + case TASK_NOT_FOUND = 'Cron task "%s" not found'; + case INVALID_EXPRESSION = 'Invalid cron expression: %s'; + case LOCK_ACQUIRE_FAILED = 'Failed to acquire lock for task "%s"'; + case TASK_EXECUTION_FAILED = 'Task "%s" execution failed: %s'; + case INVALID_TASK_FILE = 'Invalid task file "%s": must return array or CronTask instance'; + case CRON_DIRECTORY_NOT_FOUND = 'Cron directory not found: %s'; + case LOCK_DIRECTORY_NOT_WRITABLE = 'Lock directory is not writable: %s'; +} diff --git a/src/Libraries/Cron/Exceptions/CronException.php b/src/Libraries/Cron/Exceptions/CronException.php new file mode 100644 index 000000000..5b872dab7 --- /dev/null +++ b/src/Libraries/Cron/Exceptions/CronException.php @@ -0,0 +1,95 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron\Exceptions; + +use Quantum\Libraries\Cron\Enums\ExceptionMessages; + +/** + * Class CronException + * @package Quantum\Libraries\Cron + */ +class CronException extends \Exception +{ + /** + * Task not found exception + * @param string $taskName + * @return CronException + */ + public static function taskNotFound(string $taskName): CronException + { + return new self(sprintf(ExceptionMessages::TASK_NOT_FOUND->value, $taskName)); + } + + /** + * Invalid cron expression exception + * @param string $expression + * @return CronException + */ + public static function invalidExpression(string $expression): CronException + { + return new self(sprintf(ExceptionMessages::INVALID_EXPRESSION->value, $expression)); + } + + /** + * Lock acquire failed exception + * @param string $taskName + * @return CronException + */ + public static function lockAcquireFailed(string $taskName): CronException + { + return new self(sprintf(ExceptionMessages::LOCK_ACQUIRE_FAILED->value, $taskName)); + } + + /** + * Task execution failed exception + * @param string $taskName + * @param string $error + * @return CronException + */ + public static function taskExecutionFailed(string $taskName, string $error): CronException + { + return new self(sprintf(ExceptionMessages::TASK_EXECUTION_FAILED->value, $taskName, $error)); + } + + /** + * Invalid task file exception + * @param string $file + * @return CronException + */ + public static function invalidTaskFile(string $file): CronException + { + return new self(sprintf(ExceptionMessages::INVALID_TASK_FILE->value, $file)); + } + + /** + * Cron directory not found exception + * @param string $directory + * @return CronException + */ + public static function cronDirectoryNotFound(string $directory): CronException + { + return new self(sprintf(ExceptionMessages::CRON_DIRECTORY_NOT_FOUND->value, $directory)); + } + + /** + * Lock directory not writable exception + * @param string $directory + * @return CronException + */ + public static function lockDirectoryNotWritable(string $directory): CronException + { + return new self(sprintf(ExceptionMessages::LOCK_DIRECTORY_NOT_WRITABLE->value, $directory)); + } +} diff --git a/src/Libraries/Cron/Helpers/cron.php b/src/Libraries/Cron/Helpers/cron.php new file mode 100644 index 000000000..87ea7b41e --- /dev/null +++ b/src/Libraries/Cron/Helpers/cron.php @@ -0,0 +1,56 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +use Quantum\Libraries\Cron\CronManager; +use Quantum\Libraries\Cron\CronTask; +use Quantum\Libraries\Cron\Schedule; + +if (!function_exists('cron_manager')) { + /** + * Get CronManager instance + * @param string|null $cronDirectory + * @return CronManager + */ + function cron_manager(?string $cronDirectory = null): CronManager + { + return new CronManager($cronDirectory); + } +} + +if (!function_exists('cron_task')) { + /** + * Create a new cron task + * @param string $name + * @param string $expression + * @param callable $callback + * @return CronTask + * @throws \Quantum\Libraries\Cron\Exceptions\CronException + */ + function cron_task(string $name, string $expression, callable $callback): CronTask + { + return new CronTask($name, $expression, $callback); + } +} + +if (!function_exists('schedule')) { + /** + * Create a new schedule with fluent API + * @param string $name + * @return Schedule + */ + function schedule(string $name): Schedule + { + return new Schedule($name); + } +} diff --git a/src/Libraries/Cron/Schedule.php b/src/Libraries/Cron/Schedule.php new file mode 100644 index 000000000..d1e19ca64 --- /dev/null +++ b/src/Libraries/Cron/Schedule.php @@ -0,0 +1,439 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron; + +use Quantum\Libraries\Cron\Exceptions\CronException; + +/** + * Class Schedule + * Fluent API for creating cron schedules + * @package Quantum\Libraries\Cron + */ +class Schedule +{ + /** + * Task name + * @var string + */ + private $name; + + /** + * Cron expression + * @var string + */ + private $expression; + + /** + * Task callback + * @var callable|null + */ + private $callback = null; + + /** + * Schedule constructor + * @param string $name + */ + public function __construct(string $name) + { + $this->name = $name; + } + + /** + * Run the task every minute + * @return self + */ + public function everyMinute(): self + { + $this->expression = '* * * * *'; + return $this; + } + + /** + * Run the task every five minutes + * @return self + */ + public function everyFiveMinutes(): self + { + $this->expression = '*/5 * * * *'; + return $this; + } + + /** + * Run the task every ten minutes + * @return self + */ + public function everyTenMinutes(): self + { + $this->expression = '*/10 * * * *'; + return $this; + } + + /** + * Run the task every fifteen minutes + * @return self + */ + public function everyFifteenMinutes(): self + { + $this->expression = '*/15 * * * *'; + return $this; + } + + /** + * Run the task every thirty minutes + * @return self + */ + public function everyThirtyMinutes(): self + { + $this->expression = '*/30 * * * *'; + return $this; + } + + /** + * Run the task hourly + * @return self + */ + public function hourly(): self + { + $this->expression = '0 * * * *'; + return $this; + } + + /** + * Run the task hourly at a specific minute + * @param int $minute + * @return self + */ + public function hourlyAt(int $minute): self + { + $this->expression = "{$minute} * * * *"; + return $this; + } + + /** + * Run the task every two hours + * @return self + */ + public function everyTwoHours(): self + { + $this->expression = '0 */2 * * *'; + return $this; + } + + /** + * Run the task every three hours + * @return self + */ + public function everyThreeHours(): self + { + $this->expression = '0 */3 * * *'; + return $this; + } + + /** + * Run the task every four hours + * @return self + */ + public function everyFourHours(): self + { + $this->expression = '0 */4 * * *'; + return $this; + } + + /** + * Run the task every six hours + * @return self + */ + public function everySixHours(): self + { + $this->expression = '0 */6 * * *'; + return $this; + } + + /** + * Run the task daily + * @return self + */ + public function daily(): self + { + $this->expression = '0 0 * * *'; + return $this; + } + + /** + * Run the task daily at a specific time + * @param string $time Format: "HH:MM" + * @return self + */ + public function dailyAt(string $time): self + { + [$hour, $minute] = explode(':', $time); + $this->expression = "{$minute} {$hour} * * *"; + return $this; + } + + /** + * Run the task twice daily + * @param int $firstHour + * @param int $secondHour + * @return self + */ + public function twiceDaily(int $firstHour = 1, int $secondHour = 13): self + { + $this->expression = "0 {$firstHour},{$secondHour} * * *"; + return $this; + } + + /** + * Run the task weekly + * @return self + */ + public function weekly(): self + { + $this->expression = '0 0 * * 0'; + return $this; + } + + /** + * Run the task weekly on a specific day and time + * @param int $dayOfWeek 0-6 (Sunday = 0) + * @param string $time Format: "HH:MM" + * @return self + */ + public function weeklyOn(int $dayOfWeek, string $time = '0:00'): self + { + [$hour, $minute] = explode(':', $time); + $this->expression = "{$minute} {$hour} * * {$dayOfWeek}"; + return $this; + } + + /** + * Run the task monthly + * @return self + */ + public function monthly(): self + { + $this->expression = '0 0 1 * *'; + return $this; + } + + /** + * Run the task monthly on a specific day and time + * @param int $dayOfMonth + * @param string $time Format: "HH:MM" + * @return self + */ + public function monthlyOn(int $dayOfMonth = 1, string $time = '0:00'): self + { + [$hour, $minute] = explode(':', $time); + $this->expression = "{$minute} {$hour} {$dayOfMonth} * *"; + return $this; + } + + /** + * Run the task twice monthly + * @param int $firstDay + * @param int $secondDay + * @param string $time + * @return self + */ + public function twiceMonthly(int $firstDay = 1, int $secondDay = 16, string $time = '0:00'): self + { + [$hour, $minute] = explode(':', $time); + $this->expression = "{$minute} {$hour} {$firstDay},{$secondDay} * *"; + return $this; + } + + /** + * Run the task quarterly + * @return self + */ + public function quarterly(): self + { + $this->expression = '0 0 1 1-12/3 *'; + return $this; + } + + /** + * Run the task yearly + * @return self + */ + public function yearly(): self + { + $this->expression = '0 0 1 1 *'; + return $this; + } + + /** + * Run the task on weekdays + * @return self + */ + public function weekdays(): self + { + $this->expression = '0 0 * * 1-5'; + return $this; + } + + /** + * Run the task on weekends + * @return self + */ + public function weekends(): self + { + $this->expression = '0 0 * * 0,6'; + return $this; + } + + /** + * Run the task on Mondays + * @return self + */ + public function mondays(): self + { + return $this->days(1); + } + + /** + * Run the task on Tuesdays + * @return self + */ + public function tuesdays(): self + { + return $this->days(2); + } + + /** + * Run the task on Wednesdays + * @return self + */ + public function wednesdays(): self + { + return $this->days(3); + } + + /** + * Run the task on Thursdays + * @return self + */ + public function thursdays(): self + { + return $this->days(4); + } + + /** + * Run the task on Fridays + * @return self + */ + public function fridays(): self + { + return $this->days(5); + } + + /** + * Run the task on Saturdays + * @return self + */ + public function saturdays(): self + { + return $this->days(6); + } + + /** + * Run the task on Sundays + * @return self + */ + public function sundays(): self + { + return $this->days(0); + } + + /** + * Run the task on specific days + * @param int|array $days + * @return self + */ + public function days($days): self + { + $days = is_array($days) ? implode(',', $days) : $days; + $this->expression = "0 0 * * {$days}"; + return $this; + } + + /** + * Set the time for the task + * @param string $time Format: "HH:MM" + * @return self + */ + public function at(string $time): self + { + [$hour, $minute] = explode(':', $time); + + // Replace hour and minute in existing expression + $parts = explode(' ', $this->expression); + $parts[0] = $minute; + $parts[1] = $hour; + $this->expression = implode(' ', $parts); + + return $this; + } + + /** + * Set custom cron expression + * @param string $expression + * @return self + */ + public function cron(string $expression): self + { + $this->expression = $expression; + return $this; + } + + /** + * Set the callback for the task + * @param callable $callback + * @return self + */ + public function call(callable $callback): self + { + $this->callback = $callback; + return $this; + } + + /** + * Build and return the CronTask + * @return CronTask + * @throws CronException + */ + public function build(): CronTask + { + if ($this->callback === null) { + throw new CronException("Task '{$this->name}' must have a callback. Use call() method."); + } + + if ($this->expression === null) { + throw new CronException("Task '{$this->name}' must have a schedule. Use methods like daily(), hourly(), etc."); + } + + return new CronTask($this->name, $this->expression, $this->callback); + } + + /** + * Get the cron expression + * @return string|null + */ + public function getExpression(): ?string + { + return $this->expression; + } +} diff --git a/tests/Unit/Console/Commands/CronRunCommandTest.php b/tests/Unit/Console/Commands/CronRunCommandTest.php new file mode 100644 index 000000000..65f684a15 --- /dev/null +++ b/tests/Unit/Console/Commands/CronRunCommandTest.php @@ -0,0 +1,209 @@ +vfsRoot = vfsStream::setup('project'); + $this->cronDirectory = vfsStream::url('project/cron'); + mkdir($this->cronDirectory); + + // Setup logging config to avoid Loader dependency + if (!config()->has('logging')) { + config()->set('logging', [ + 'default' => 'single', + 'single' => [ + 'driver' => 'single', + 'path' => base_dir() . '/logs', + 'level' => 'debug', + ], + ]); + } + } + + public function testCommandExecutesSuccessfully() + { + $this->createTaskFile('test-task.php', [ + 'name' => 'test-task', + 'expression' => '* * * * *', + 'callback' => function () {} + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute(['--path' => $this->cronDirectory]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('Running scheduled tasks', $output); + $this->assertStringContainsString('Execution Summary', $output); + } + + public function testCommandWithNoTasks() + { + $emptyDir = vfsStream::url('project/cron-empty'); + mkdir($emptyDir); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute(['--path' => $emptyDir]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('No tasks found', $output); + } + + public function testCommandWithSpecificTask() + { + $this->createTaskFile('specific-task.php', [ + 'name' => 'specific-task', + 'expression' => '* * * * *', + 'callback' => function () {} + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute([ + '--path' => $this->cronDirectory, + '--task' => 'specific-task' + ]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('Running task: specific-task', $output); + } + + public function testCommandWithForceOption() + { + $this->createTaskFile('force-task.php', [ + 'name' => 'force-task', + 'expression' => '* * * * *', + 'callback' => function () {} + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute([ + '--path' => $this->cronDirectory, + '--force' => true + ]); + + $this->assertEquals(0, $tester->getStatusCode()); + } + + public function testCommandWithNonExistentTask() + { + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute([ + '--path' => $this->cronDirectory, + '--task' => 'non-existent' + ]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('not found', $output); + } + + public function testCommandDisplaysStatistics() + { + $this->createTaskFile('task1.php', [ + 'name' => 'task-1', + 'expression' => '* * * * *', + 'callback' => function () {} + ]); + + $this->createTaskFile('task2.php', [ + 'name' => 'task-2', + 'expression' => '0 0 1 1 *', + 'callback' => function () {} + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute(['--path' => $this->cronDirectory]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('Total tasks:', $output); + $this->assertStringContainsString('Executed:', $output); + $this->assertStringContainsString('Skipped:', $output); + } + + public function testCommandHandlesTaskFailure() + { + $this->createTaskFile('failing-task.php', [ + 'name' => 'failing-task', + 'expression' => '* * * * *', + 'body' => "throw new \\Exception('Task failed');" + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute(['--path' => $this->cronDirectory]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('Failed:', $output); + } + + public function testCommandShortOptions() + { + $this->createTaskFile('short-option-task.php', [ + 'name' => 'short-option-task', + 'expression' => '* * * * *', + 'callback' => function () {} + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute([ + '--path' => $this->cronDirectory, + '-t' => 'short-option-task' + ]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('short-option-task', $output); + } + + private function createTaskFile(string $filename, array $definition): void + { + $body = $definition['body'] ?? "echo 'Test task executed';"; + + $content = " '{$definition['name']}',\n"; + $content .= " 'expression' => '{$definition['expression']}',\n"; + $content .= " 'callback' => function () {\n"; + $content .= " {$body}\n"; + $content .= " }\n"; + $content .= "];\n"; + + file_put_contents($this->cronDirectory . '/' . $filename, $content); + } +} diff --git a/tests/Unit/Libraries/Cron/CronLockTest.php b/tests/Unit/Libraries/Cron/CronLockTest.php new file mode 100644 index 000000000..aca16d506 --- /dev/null +++ b/tests/Unit/Libraries/Cron/CronLockTest.php @@ -0,0 +1,151 @@ +vfsRoot = vfsStream::setup('runtime'); + $this->lockDirectory = vfsStream::url('runtime/cron/locks'); + } + + protected function tearDown(): void + { + parent::tearDown(); + + // Cleanup any real lock files if they exist + $realLockDir = base_dir() . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'cron' . DIRECTORY_SEPARATOR . 'locks'; + if (is_dir($realLockDir)) { + $files = glob($realLockDir . '/*.lock'); + foreach ($files as $file) { + @unlink($file); + } + } + } + + public function testConstructorCreatesLockDirectory() + { + $lock = new CronLock('test-task', $this->lockDirectory); + + $this->assertTrue(is_dir($this->lockDirectory)); + } + + public function testAcquireLock() + { + $lock = new CronLock('test-task', $this->lockDirectory); + + $this->assertTrue($lock->acquire()); + } + + public function testCannotAcquireLockedTask() + { + $lock1 = new CronLock('test-task', $this->lockDirectory); + $lock1->acquire(); + + $lock2 = new CronLock('test-task', $this->lockDirectory); + + $this->assertFalse($lock2->acquire()); + + $lock1->release(); + } + + public function testReleaseLock() + { + $lock = new CronLock('test-task', $this->lockDirectory); + $lock->acquire(); + + $this->assertTrue($lock->release()); + $this->assertFalse($lock->isLocked()); + } + + public function testIsLocked() + { + $lock1 = new CronLock('test-task', $this->lockDirectory); + + $this->assertFalse($lock1->isLocked()); + + $lock1->acquire(); + + $lock2 = new CronLock('test-task', $this->lockDirectory); + $this->assertTrue($lock2->isLocked()); + + $lock1->release(); + + $this->assertFalse($lock2->isLocked()); + } + + public function testMultipleTasksCanHaveSeparateLocks() + { + $lock1 = new CronLock('task-1', $this->lockDirectory); + $lock2 = new CronLock('task-2', $this->lockDirectory); + + $this->assertTrue($lock1->acquire()); + $this->assertTrue($lock2->acquire()); + + $lock1->release(); + $lock2->release(); + } + + public function testLockFileContainsMetadata() + { + $lock = new CronLock('test-task', $this->lockDirectory); + $lock->acquire(); + + $lockFile = $this->lockDirectory . '/test-task.lock'; + $this->assertTrue(file_exists($lockFile)); + + $content = file_get_contents($lockFile); + $data = json_decode($content, true); + + $this->assertArrayHasKey('task', $data); + $this->assertArrayHasKey('started_at', $data); + $this->assertArrayHasKey('pid', $data); + $this->assertEquals('test-task', $data['task']); + + $lock->release(); + } + + public function testDestructorReleasesLock() + { + $lockFile = $this->lockDirectory . '/test-task.lock'; + + $lock = new CronLock('test-task', $this->lockDirectory); + $lock->acquire(); + + $this->assertTrue(file_exists($lockFile)); + + unset($lock); + + // Lock should be released after destructor + $newLock = new CronLock('test-task', $this->lockDirectory); + $this->assertFalse($newLock->isLocked()); + } + + public function testThrowsExceptionWhenDirectoryNotWritable() + { + $this->expectException(CronException::class); + $this->expectExceptionMessage('not writable'); + + // Create a read-only directory + $readOnlyDir = vfsStream::url('runtime/readonly'); + mkdir($readOnlyDir, 0444); + + new CronLock('test-task', $readOnlyDir); + } +} diff --git a/tests/Unit/Libraries/Cron/CronManagerTest.php b/tests/Unit/Libraries/Cron/CronManagerTest.php new file mode 100644 index 000000000..cc41fbe7e --- /dev/null +++ b/tests/Unit/Libraries/Cron/CronManagerTest.php @@ -0,0 +1,233 @@ +vfsRoot = vfsStream::setup('project'); + $this->cronDirectory = vfsStream::url('project/cron'); + mkdir($this->cronDirectory); + self::$executedTasks = []; + + // Setup logging config to avoid Loader dependency + if (!config()->has('logging')) { + config()->set('logging', [ + 'default' => 'single', + 'single' => [ + 'driver' => 'single', + 'path' => base_dir() . '/logs', + 'level' => 'debug', + ], + ]); + } + } + + public function testLoadTasksFromDirectory() + { + $this->createTaskFile('task1.php', [ + 'name' => 'task-1', + 'expression' => '* * * * *', + ]); + + $this->createTaskFile('task2.php', [ + 'name' => 'task-2', + 'expression' => '0 * * * *', + ]); + + $manager = new CronManager($this->cronDirectory); + $manager->loadTasks(); + + $tasks = $manager->getTasks(); + + $this->assertCount(2, $tasks); + $this->assertArrayHasKey('task-1', $tasks); + $this->assertArrayHasKey('task-2', $tasks); + } + + public function testLoadTasksWithObjectFormat() + { + $taskContent = 'cronDirectory . '/object-task.php', $taskContent); + + $manager = new CronManager($this->cronDirectory); + $manager->loadTasks(); + + $tasks = $manager->getTasks(); + + $this->assertCount(1, $tasks); + $this->assertArrayHasKey('object-task', $tasks); + } + + public function testLoadTasksWithEmptyDirectory() + { + $manager = new CronManager($this->cronDirectory); + $manager->loadTasks(); + + $this->assertCount(0, $manager->getTasks()); + } + + public function testLoadTasksWithNonExistentDirectory() + { + $manager = new CronManager(vfsStream::url('project/nonexistent')); + $manager->loadTasks(); + + $this->assertCount(0, $manager->getTasks()); + } + + public function testRunDueTasksExecutesOnlyDueTasks() + { + $this->createTaskFile('due-task.php', [ + 'name' => 'due-task', + 'expression' => '* * * * *', // Always due + 'body' => "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('due-task');" + ]); + + $this->createTaskFile('not-due-task.php', [ + 'name' => 'not-due-task', + 'expression' => '0 0 1 1 *', // Once a year (Jan 1st) + 'body' => "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('not-due-task');" + ]); + + $manager = new CronManager($this->cronDirectory); + $stats = $manager->runDueTasks(); + + $this->assertContains('due-task', self::$executedTasks); + $this->assertNotContains('not-due-task', self::$executedTasks); + $this->assertEquals(1, $stats['executed']); + $this->assertEquals(1, $stats['skipped']); + } + + public function testRunTaskByName() + { + $this->createTaskFile('specific-task.php', [ + 'name' => 'specific-task', + 'expression' => '* * * * *', + 'body' => "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('specific-task');" + ]); + + $manager = new CronManager($this->cronDirectory); + $manager->runTaskByName('specific-task'); + + $this->assertContains('specific-task', self::$executedTasks); + } + + public function testRunTaskByNameThrowsExceptionForNonExistentTask() + { + $this->expectException(CronException::class); + $this->expectExceptionMessage('not found'); + + $manager = new CronManager($this->cronDirectory); + $manager->runTaskByName('non-existent-task'); + } + + public function testRunDueTasksWithForceIgnoresLocks() + { + $this->createTaskFile('locked-task.php', [ + 'name' => 'locked-task', + 'expression' => '* * * * *', + 'body' => "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('locked-task');" + ]); + + $manager = new CronManager($this->cronDirectory); + + // Run twice with force - should execute both times + $manager->runDueTasks(true); + $manager->runDueTasks(true); + + $occurrences = array_filter(self::$executedTasks, function ($task) { + return $task === 'locked-task'; + }); + $this->assertCount(2, $occurrences); + } + + public function testTaskExecutionFailureIsHandled() + { + $this->createTaskFile('failing-task.php', [ + 'name' => 'failing-task', + 'expression' => '* * * * *', + 'body' => "throw new \\Exception('Task failed');" + ]); + + $manager = new CronManager($this->cronDirectory); + $stats = $manager->runDueTasks(); + + $this->assertEquals(0, $stats['executed']); + $this->assertEquals(1, $stats['failed']); + } + + public function testGetStatsReturnsCorrectStatistics() + { + $this->createTaskFile('task1.php', [ + 'name' => 'task-1', + 'expression' => '* * * * *', + ]); + + $this->createTaskFile('task2.php', [ + 'name' => 'task-2', + 'expression' => '0 0 1 1 *', + ]); + + $manager = new CronManager($this->cronDirectory); + $stats = $manager->runDueTasks(); + + $this->assertEquals(2, $stats['total']); + $this->assertEquals(1, $stats['executed']); + $this->assertEquals(1, $stats['skipped']); + $this->assertEquals(0, $stats['failed']); + $this->assertEquals(0, $stats['locked']); + } + + public function testInvalidTaskFileThrowsException() + { + $this->expectException(CronException::class); + $this->expectExceptionMessage('Invalid task file'); + + file_put_contents($this->cronDirectory . '/invalid.php', 'cronDirectory); + $manager->loadTasks(); + } + + private function createTaskFile(string $filename, array $definition): void + { + $body = $definition['body'] ?? "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('{$definition['name']}');"; + + $content = " '{$definition['name']}',\n"; + $content .= " 'expression' => '{$definition['expression']}',\n"; + $content .= " 'callback' => function () {\n"; + $content .= " {$body}\n"; + $content .= " }\n"; + $content .= "];\n"; + + file_put_contents($this->cronDirectory . '/' . $filename, $content); + } + + public static function recordExecution(string $taskName): void + { + self::$executedTasks[] = $taskName; + } +} diff --git a/tests/Unit/Libraries/Cron/CronTaskTest.php b/tests/Unit/Libraries/Cron/CronTaskTest.php new file mode 100644 index 000000000..69485e3c5 --- /dev/null +++ b/tests/Unit/Libraries/Cron/CronTaskTest.php @@ -0,0 +1,107 @@ +assertEquals('test-task', $task->getName()); + $this->assertEquals('* * * * *', $task->getExpression()); + } + + public function testConstructorWithInvalidExpression() + { + $this->expectException(CronException::class); + $this->expectExceptionMessage('Invalid cron expression'); + + new CronTask('test-task', 'invalid', function () {}); + } + + public function testShouldRunEveryMinute() + { + $task = new CronTask('test-task', '* * * * *', function () {}); + + $this->assertTrue($task->shouldRun()); + } + + public function testShouldNotRunFutureTask() + { + // Task scheduled for next year + $task = new CronTask('test-task', "0 0 1 1 *", function () {}); + + $this->assertFalse($task->shouldRun()); + } + + public function testHandleExecutesCallback() + { + $executed = false; + + $task = new CronTask('test-task', '* * * * *', function () use (&$executed) { + $executed = true; + }); + + $task->handle(); + + $this->assertTrue($executed); + } + + public function testHandleWithCallbackArguments() + { + $result = null; + + $task = new CronTask('test-task', '* * * * *', function () use (&$result) { + $result = 'executed'; + }); + + $task->handle(); + + $this->assertEquals('executed', $result); + } + + public function testGetNextRunDate() + { + $task = new CronTask('test-task', '0 0 * * *', function () {}); + + $nextRun = $task->getNextRunDate(); + + $this->assertInstanceOf(\DateTime::class, $nextRun); + $this->assertGreaterThan(new \DateTime(), $nextRun); + } + + public function testGetPreviousRunDate() + { + $task = new CronTask('test-task', '0 0 * * *', function () {}); + + $previousRun = $task->getPreviousRunDate(); + + $this->assertInstanceOf(\DateTime::class, $previousRun); + $this->assertLessThan(new \DateTime(), $previousRun); + } + + public function testComplexCronExpression() + { + // Every 5 minutes + $task = new CronTask('test-task', '*/5 * * * *', function () {}); + + $this->assertEquals('*/5 * * * *', $task->getExpression()); + } + + public function testWeeklyCronExpression() + { + // Every Monday at 9 AM + $task = new CronTask('test-task', '0 9 * * 1', function () {}); + + $this->assertEquals('0 9 * * 1', $task->getExpression()); + } +}