From 89525dfeae57f4e354aac1ede3ad94be30d54cdb Mon Sep 17 00:00:00 2001 From: Artak Date: Thu, 15 Jan 2026 12:25:12 +0400 Subject: [PATCH 1/7] Add native cron task runner command to Quantum CLI (qt cron:run) #291 --- CHANGELOG.md | 8 + composer.json | 3 +- src/Console/Commands/CronRunCommand.php | 140 ++++++ .../Cron/Contracts/CronTaskInterface.php | 46 ++ src/Libraries/Cron/CronLock.php | 207 +++++++++ src/Libraries/Cron/CronManager.php | 251 ++++++++++ src/Libraries/Cron/CronTask.php | 113 +++++ .../Cron/Enums/ExceptionMessages.php | 30 ++ .../Cron/Exceptions/CronException.php | 95 ++++ src/Libraries/Cron/Helpers/cron.php | 56 +++ src/Libraries/Cron/Schedule.php | 439 ++++++++++++++++++ .../Console/Commands/CronRunCommandTest.php | 209 +++++++++ tests/Unit/Libraries/Cron/CronLockTest.php | 151 ++++++ tests/Unit/Libraries/Cron/CronManagerTest.php | 233 ++++++++++ tests/Unit/Libraries/Cron/CronTaskTest.php | 107 +++++ 15 files changed, 2087 insertions(+), 1 deletion(-) create mode 100644 src/Console/Commands/CronRunCommand.php create mode 100644 src/Libraries/Cron/Contracts/CronTaskInterface.php create mode 100644 src/Libraries/Cron/CronLock.php create mode 100644 src/Libraries/Cron/CronManager.php create mode 100644 src/Libraries/Cron/CronTask.php create mode 100644 src/Libraries/Cron/Enums/ExceptionMessages.php create mode 100644 src/Libraries/Cron/Exceptions/CronException.php create mode 100644 src/Libraries/Cron/Helpers/cron.php create mode 100644 src/Libraries/Cron/Schedule.php create mode 100644 tests/Unit/Console/Commands/CronRunCommandTest.php create mode 100644 tests/Unit/Libraries/Cron/CronLockTest.php create mode 100644 tests/Unit/Libraries/Cron/CronManagerTest.php create mode 100644 tests/Unit/Libraries/Cron/CronTaskTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dfc6956..7e260f81 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 2cf4e4be..9eaef8f0 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 00000000..30717096 --- /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 00000000..9944c3e4 --- /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 00000000..058187c9 --- /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 00000000..6d111faf --- /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 00000000..b1a80f14 --- /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 00000000..16067322 --- /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 00000000..5b872dab --- /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 00000000..87ea7b41 --- /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 00000000..d1e19ca6 --- /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 00000000..65f684a1 --- /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 00000000..aca16d50 --- /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 00000000..cc41fbe7 --- /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 00000000..69485e3c --- /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()); + } +} From 71b3beeb0a395236487e3bd1a20610d1568e4cd6 Mon Sep 17 00:00:00 2001 From: Artak Date: Thu, 15 Jan 2026 12:52:41 +0400 Subject: [PATCH 2/7] Add native cron task runner command to Quantum CLI (qt cron:run) #291 --- src/Console/Commands/CronRunCommand.php | 252 ++--- .../Cron/Contracts/CronTaskInterface.php | 92 +- src/Libraries/Cron/CronLock.php | 414 ++++----- src/Libraries/Cron/CronManager.php | 484 +++++----- src/Libraries/Cron/CronTask.php | 226 ++--- .../Cron/Enums/ExceptionMessages.php | 60 +- .../Cron/Exceptions/CronException.php | 190 ++-- src/Libraries/Cron/Helpers/cron.php | 112 +-- src/Libraries/Cron/Schedule.php | 878 +++++++++--------- .../Console/Commands/CronRunCommandTest.php | 346 +++---- tests/Unit/Libraries/Cron/CronLockTest.php | 302 +++--- tests/Unit/Libraries/Cron/CronManagerTest.php | 309 +++--- tests/Unit/Libraries/Cron/CronTaskTest.php | 214 ++--- 13 files changed, 1939 insertions(+), 1940 deletions(-) diff --git a/src/Console/Commands/CronRunCommand.php b/src/Console/Commands/CronRunCommand.php index 30717096..bf02ade2 100644 --- a/src/Console/Commands/CronRunCommand.php +++ b/src/Console/Commands/CronRunCommand.php @@ -1,35 +1,35 @@ - - * @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'; - + + * @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 @@ -41,100 +41,100 @@ class CronRunCommand extends QtCommand * @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() - { + + /** + * 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"); - } - } -} + + 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 index 9944c3e4..90c991cc 100644 --- a/src/Libraries/Cron/Contracts/CronTaskInterface.php +++ b/src/Libraries/Cron/Contracts/CronTaskInterface.php @@ -1,46 +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; -} + + * @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 index 058187c9..ef746d69 100644 --- a/src/Libraries/Cron/CronLock.php +++ b/src/Libraries/Cron/CronLock.php @@ -1,207 +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(); - } -} + + * @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 index 6d111faf..cbdafac5 100644 --- a/src/Libraries/Cron/CronManager.php +++ b/src/Libraries/Cron/CronManager.php @@ -1,249 +1,249 @@ - - * @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 - */ + + * @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); + $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 index b1a80f14..8cf99711 100644 --- a/src/Libraries/Cron/CronTask.php +++ b/src/Libraries/Cron/CronTask.php @@ -1,113 +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(); - } -} + + * @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 index 16067322..3ab3556a 100644 --- a/src/Libraries/Cron/Enums/ExceptionMessages.php +++ b/src/Libraries/Cron/Enums/ExceptionMessages.php @@ -1,30 +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'; -} + + * @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 + */ +final class ExceptionMessages +{ + public const TASK_NOT_FOUND = 'Cron task "%s" not found'; + public const INVALID_EXPRESSION = 'Invalid cron expression: %s'; + public const LOCK_ACQUIRE_FAILED = 'Failed to acquire lock for task "%s"'; + public const TASK_EXECUTION_FAILED = 'Task "%s" execution failed: %s'; + public const INVALID_TASK_FILE = 'Invalid task file "%s": must return array or CronTask instance'; + public const CRON_DIRECTORY_NOT_FOUND = 'Cron directory not found: %s'; + public const 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 index 5b872dab..2e5b9eac 100644 --- a/src/Libraries/Cron/Exceptions/CronException.php +++ b/src/Libraries/Cron/Exceptions/CronException.php @@ -1,95 +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)); - } -} + + * @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, $taskName)); + } + + /** + * Invalid cron expression exception + * @param string $expression + * @return CronException + */ + public static function invalidExpression(string $expression): CronException + { + return new self(sprintf(ExceptionMessages::INVALID_EXPRESSION, $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, $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, $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, $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, $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, $directory)); + } +} diff --git a/src/Libraries/Cron/Helpers/cron.php b/src/Libraries/Cron/Helpers/cron.php index 87ea7b41..b1ec9403 100644 --- a/src/Libraries/Cron/Helpers/cron.php +++ b/src/Libraries/Cron/Helpers/cron.php @@ -1,56 +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); - } -} + + * @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 index d1e19ca6..3cc70984 100644 --- a/src/Libraries/Cron/Schedule.php +++ b/src/Libraries/Cron/Schedule.php @@ -1,439 +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; - } -} + + * @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 index 65f684a1..883ee6d3 100644 --- a/tests/Unit/Console/Commands/CronRunCommandTest.php +++ b/tests/Unit/Console/Commands/CronRunCommandTest.php @@ -1,62 +1,62 @@ -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); - } - +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'); @@ -66,52 +66,52 @@ public function testCommandWithNoTasks() $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()); - } - + + $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(); @@ -119,79 +119,79 @@ public function testCommandWithNonExistentTask() $tester->execute([ '--path' => $this->cronDirectory, - '--task' => 'non-existent' + '--task' => 'non-existent', + ]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('not found', $output); + } + + public function testCommandDisplaysStatistics() + { + $this->createTaskFile('task1.php', [ + 'name' => 'task-1', + 'expression' => '* * * * *', + 'callback' => function () {}, ]); - - $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('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');" + '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); - } - + + $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';"; diff --git a/tests/Unit/Libraries/Cron/CronLockTest.php b/tests/Unit/Libraries/Cron/CronLockTest.php index aca16d50..f96ab223 100644 --- a/tests/Unit/Libraries/Cron/CronLockTest.php +++ b/tests/Unit/Libraries/Cron/CronLockTest.php @@ -1,151 +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); - } -} +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 index cc41fbe7..6f93659e 100644 --- a/tests/Unit/Libraries/Cron/CronManagerTest.php +++ b/tests/Unit/Libraries/Cron/CronManagerTest.php @@ -1,48 +1,47 @@ -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() - { + + // 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' => '* * * * *', @@ -52,134 +51,134 @@ public function testLoadTasksFromDirectory() '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() - { + + $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() - { + '; + + file_put_contents($this->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');" + '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');" + 'body' => "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('not-due-task');", ]); - - $manager = new CronManager($this->cronDirectory); - $stats = $manager->runDueTasks(); - + + $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->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');" + 'body' => "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('specific-task');", ]); - - $manager = new CronManager($this->cronDirectory); - $manager->runTaskByName('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() - { + } + + 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');" + '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); - + + $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() - { + } + + public function testTaskExecutionFailureIsHandled() + { $this->createTaskFile('failing-task.php', [ 'name' => 'failing-task', 'expression' => '* * * * *', - 'body' => "throw new \\Exception('Task failed');" + '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() - { + + $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' => '* * * * *', @@ -189,28 +188,28 @@ public function testGetStatsReturnsCorrectStatistics() '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(); - } - + + $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']}');"; diff --git a/tests/Unit/Libraries/Cron/CronTaskTest.php b/tests/Unit/Libraries/Cron/CronTaskTest.php index 69485e3c..8975d3f8 100644 --- a/tests/Unit/Libraries/Cron/CronTaskTest.php +++ b/tests/Unit/Libraries/Cron/CronTaskTest.php @@ -1,107 +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()); - } -} +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()); + } +} From 3a55887c3b275e8af05a5d556c85c3955eb9048d Mon Sep 17 00:00:00 2001 From: Artak Date: Mon, 19 Jan 2026 13:45:34 +0400 Subject: [PATCH 3/7] Fix:Add native cron task runner command to Quantum CLI (qt cron:run) #291 --- src/Console/Commands/CronRunCommand.php | 4 +- src/Libraries/Cron/CronLock.php | 255 ++++++++++-------- src/Libraries/Cron/CronManager.php | 32 +-- src/Libraries/Cron/Helpers/cron.php | 28 ++ src/Libraries/Cron/Schedule.php | 14 +- .../Console/Commands/CronRunCommandTest.php | 117 +++++++- .../Unit/Libraries/Cron/CronExceptionTest.php | 51 ++++ tests/Unit/Libraries/Cron/CronHelperTest.php | 48 ++++ tests/Unit/Libraries/Cron/CronLockTest.php | 206 +++++++++++--- tests/Unit/Libraries/Cron/CronManagerTest.php | 113 ++++++-- tests/Unit/Libraries/Cron/CronTaskTest.php | 4 +- tests/Unit/Libraries/Cron/ScheduleTest.php | 241 +++++++++++++++++ tests/_root/shared/config/cron.php | 7 + 13 files changed, 905 insertions(+), 215 deletions(-) create mode 100644 tests/Unit/Libraries/Cron/CronExceptionTest.php create mode 100644 tests/Unit/Libraries/Cron/CronHelperTest.php create mode 100644 tests/Unit/Libraries/Cron/ScheduleTest.php create mode 100644 tests/_root/shared/config/cron.php diff --git a/src/Console/Commands/CronRunCommand.php b/src/Console/Commands/CronRunCommand.php index bf02ade2..43d7693c 100644 --- a/src/Console/Commands/CronRunCommand.php +++ b/src/Console/Commands/CronRunCommand.php @@ -14,8 +14,8 @@ namespace Quantum\Console\Commands; -use Quantum\Libraries\Cron\CronManager; use Quantum\Libraries\Cron\Exceptions\CronException; +use Quantum\Libraries\Cron\CronManager; use Quantum\Console\QtCommand; /** @@ -59,7 +59,7 @@ public function exec() { $force = (bool) $this->getOption('force'); $taskName = $this->getOption('task'); - $cronPath = $this->getOption('path') ?: getenv('QT_CRON_PATH') ?: null; + $cronPath = $this->getOption('path') ?: cron_config('path'); try { $manager = new CronManager($cronPath); diff --git a/src/Libraries/Cron/CronLock.php b/src/Libraries/Cron/CronLock.php index ef746d69..dec18785 100644 --- a/src/Libraries/Cron/CronLock.php +++ b/src/Libraries/Cron/CronLock.php @@ -1,207 +1,232 @@ - * @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 + * CronLock - file based lock with flock() + * + * Changes vs original: + * - taskName sanitized for safe filename + * - release() removes lock file + * - stale cleanup reads timestamp from locked handle (no extra fs()->get) + * - isLocked() is non-destructive (does NOT delete files); cleanup handles stale deletion + * - optional refresh() to update timestamp while running */ class CronLock { - /** - * Lock directory path - * @var string - */ - private $lockDirectory; - - /** - * Task name - * @var string - */ - private $taskName; + private string $lockDirectory; + private string $taskName; + private string $lockFile; - /** - * Lock file path - * @var string|null - */ - private $lockFile = null; - - /** - * Lock file handle - * @var resource|null - */ + /** @var resource|null */ private $lockHandle = null; - /** - * Maximum lock age in seconds (24 hours) - */ - private const MAX_LOCK_AGE = 86400; + private bool $ownsLock = false; + private int $maxLockAge; - /** - * CronLock constructor - * @param string $taskName - * @param string|null $lockDirectory - * @throws CronException - */ - public function __construct(string $taskName, ?string $lockDirectory = null) + private const DEFAULT_MAX_LOCK_AGE = 86400; + + public function __construct(string $taskName, ?string $lockDirectory = null, ?int $maxLockAge = null) { - $this->taskName = $taskName; - $this->lockDirectory = $lockDirectory ?? $this->getDefaultLockDirectory(); - $this->lockFile = $this->lockDirectory . DIRECTORY_SEPARATOR . $this->taskName . '.lock'; + $this->taskName = $this->sanitizeTaskName($taskName); + $this->lockDirectory = $this->resolveLockDirectory($lockDirectory); + $this->lockFile = $this->lockDirectory . DS . $this->taskName . '.lock'; + $this->maxLockAge = $maxLockAge ?? (int) cron_config('max_lock_age', self::DEFAULT_MAX_LOCK_AGE); $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'); - + $this->lockHandle = @fopen($this->lockFile, 'c+'); if ($this->lockHandle === false) { + $this->lockHandle = null; + $this->ownsLock = false; return false; } if (!flock($this->lockHandle, LOCK_EX | LOCK_NB)) { fclose($this->lockHandle); $this->lockHandle = null; + $this->ownsLock = false; return false; } - fwrite($this->lockHandle, json_encode([ - 'task' => $this->taskName, - 'started_at' => time(), - 'pid' => getmypid(), - ])); - - fflush($this->lockHandle); + $this->writeTimestampToHandle($this->lockHandle); + $this->ownsLock = true; return true; } /** - * Release the lock - * @return bool + * Update lock timestamp (useful for long-running jobs) */ - public function release(): bool + public function refresh(): bool { - if ($this->lockHandle !== null) { - flock($this->lockHandle, LOCK_UN); - fclose($this->lockHandle); - $this->lockHandle = null; + if (!$this->ownsLock || $this->lockHandle === null) { + return false; } - if (file_exists($this->lockFile)) { - return unlink($this->lockFile); + $this->writeTimestampToHandle($this->lockHandle); + return true; + } + + public function release(): bool + { + if (!$this->ownsLock || $this->lockHandle === null) { + return true; } + flock($this->lockHandle, LOCK_UN); + fclose($this->lockHandle); + + $this->lockHandle = null; + $this->ownsLock = false; + + @fs()->remove($this->lockFile); + return true; } /** - * Check if task is locked - * @return bool + * Check if another process currently holds the lock. */ public function isLocked(): bool { - if (!file_exists($this->lockFile)) { + if (!fs()->exists($this->lockFile)) { return false; } - // Check if lock is stale - if (time() - filemtime($this->lockFile) > self::MAX_LOCK_AGE) { - unlink($this->lockFile); - return false; + $handle = @fopen($this->lockFile, 'c+'); + if ($handle === false) { + return true; } - // Try to open the file to check if it's actually locked - $handle = @fopen($this->lockFile, 'r'); - if ($handle === false) { + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); return true; } - $locked = !flock($handle, LOCK_EX | LOCK_NB); + flock($handle, LOCK_UN); + fclose($handle); - if (!$locked) { - flock($handle, LOCK_UN); + return false; + } + + private function sanitizeTaskName(string $taskName): string + { + $taskName = trim($taskName); + if ($taskName === '') { + return 'default'; } - fclose($handle); + // Keep safe filename chars only + $taskName = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $taskName) ?? 'default'; + $taskName = trim($taskName, '._-'); - return $locked; + return $taskName !== '' ? $taskName : 'default'; + } + + private function resolveLockDirectory(?string $lockDirectory): string + { + $path = $lockDirectory ?? cron_config('lock_path'); + return $path === null ? $this->getDefaultLockDirectory() : $path; } - /** - * Get default lock directory - * @return string - */ private function getDefaultLockDirectory(): string { - $baseDir = base_dir() . DIRECTORY_SEPARATOR . 'runtime'; - return $baseDir . DIRECTORY_SEPARATOR . 'cron' . DIRECTORY_SEPARATOR . 'locks'; + return base_dir() . DS . 'runtime' . DS . 'cron' . DS . '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 ($this->lockDirectory === '') { + throw CronException::lockDirectoryNotWritable(''); } - if (!is_writable($this->lockDirectory)) { + $this->createDirectory($this->lockDirectory); + + if (!fs()->isWritable($this->lockDirectory)) { throw CronException::lockDirectoryNotWritable($this->lockDirectory); } } + private function createDirectory(string $directory): void + { + if (fs()->isDirectory($directory)) { + return; + } + + $parent = dirname($directory); + if ($parent && $parent !== $directory) { + $this->createDirectory($parent); + } + + // @phpstan-ignore-next-line + if (!fs()->makeDirectory($directory) && !fs()->isDirectory($directory)) { + throw CronException::lockDirectoryNotWritable($directory); + } + } + /** - * Cleanup stale locks + * Removes stale lock files that are NOT currently locked by any process. + * Safe because we take LOCK_EX before removing. */ private function cleanupStaleLocks(): void { - if (!is_dir($this->lockDirectory)) { + if (!fs()->isDirectory($this->lockDirectory)) { return; } - $files = glob($this->lockDirectory . DIRECTORY_SEPARATOR . '*.lock'); + $files = fs()->glob($this->lockDirectory . DS . '*.lock') ?: []; + $now = time(); foreach ($files as $file) { - if (time() - filemtime($file) > self::MAX_LOCK_AGE) { - @unlink($file); + $handle = @fopen($file, 'c+'); + if ($handle === false) { + continue; + } + + // If someone holds it, skip + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + continue; + } + + $timestamp = $this->readTimestampFromHandle($handle); + + if ($timestamp !== null && ($now - $timestamp) > $this->maxLockAge) { + @flock($handle, LOCK_UN); + @fclose($handle); + @fs()->remove($file); + continue; } + + @flock($handle, LOCK_UN); + @fclose($handle); } } - /** - * Destructor - ensure lock is released - */ - public function __destruct() + private function writeTimestampToHandle($handle): void { - $this->release(); + @ftruncate($handle, 0); + @rewind($handle); + @fwrite($handle, (string) time()); + @fflush($handle); + } + + private function readTimestampFromHandle($handle): ?int + { + @rewind($handle); + $content = stream_get_contents($handle); + if ($content === false) { + return null; + } + + $timestamp = (int) trim((string) $content); + return $timestamp > 0 ? $timestamp : null; } } diff --git a/src/Libraries/Cron/CronManager.php b/src/Libraries/Cron/CronManager.php index cbdafac5..4a35add4 100644 --- a/src/Libraries/Cron/CronManager.php +++ b/src/Libraries/Cron/CronManager.php @@ -15,8 +15,8 @@ namespace Quantum\Libraries\Cron; use Quantum\Libraries\Cron\Contracts\CronTaskInterface; -use Quantum\Libraries\Cron\Exceptions\CronException; use Quantum\Libraries\Logger\Factories\LoggerFactory; +use Quantum\Libraries\Cron\Exceptions\CronException; /** * Class CronManager @@ -54,7 +54,8 @@ class CronManager */ public function __construct(?string $cronDirectory = null) { - $this->cronDirectory = $cronDirectory ?? $this->getDefaultCronDirectory(); + $configuredPath = $cronDirectory ?? cron_config('path'); + $this->cronDirectory = $configuredPath ?: $this->getDefaultCronDirectory(); } /** @@ -64,20 +65,17 @@ public function __construct(?string $cronDirectory = null) */ public function loadTasks(): void { - if (!is_dir($this->cronDirectory)) { + if (!fs()->isDirectory($this->cronDirectory)) { + if ($this->cronDirectory !== $this->getDefaultCronDirectory()) { + throw CronException::cronDirectoryNotFound($this->cronDirectory); + } return; } - $files = scandir($this->cronDirectory); + $files = fs()->glob($this->cronDirectory . DS . '*.php') ?: []; foreach ($files as $file) { - if ($file === '.' || $file === '..') { - continue; - } - - if (pathinfo($file, PATHINFO_EXTENSION) === 'php') { - $this->loadTaskFromFile($this->cronDirectory . DIRECTORY_SEPARATOR . $file); - } + $this->loadTaskFromFile($file); } $this->stats['total'] = count($this->tasks); @@ -91,7 +89,7 @@ public function loadTasks(): void */ private function loadTaskFromFile(string $file): void { - $task = require $file; + $task = fs()->require($file); if (is_array($task)) { $task = $this->createTaskFromArray($task); @@ -171,15 +169,9 @@ 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"); + $this->log('warning', "Task \"{$task->getName()}\" skipped: locked"); return; } @@ -229,7 +221,7 @@ public function getStats(): array */ private function getDefaultCronDirectory(): string { - return base_dir() . DIRECTORY_SEPARATOR . 'cron'; + return base_dir() . DS . 'cron'; } /** diff --git a/src/Libraries/Cron/Helpers/cron.php b/src/Libraries/Cron/Helpers/cron.php index b1ec9403..3816eefc 100644 --- a/src/Libraries/Cron/Helpers/cron.php +++ b/src/Libraries/Cron/Helpers/cron.php @@ -15,6 +15,34 @@ use Quantum\Libraries\Cron\CronManager; use Quantum\Libraries\Cron\CronTask; use Quantum\Libraries\Cron\Schedule; +use Quantum\Loader\Setup; + +if (!function_exists('cron_config')) { + /** + * Resolve cron configuration value + * @param string $key + * @param mixed $default + * @return mixed|null + */ + function cron_config(string $key, $default = null) + { + static $configLoaded = false; + + if (!$configLoaded) { + try { + if (!config()->has('cron')) { + config()->import(new Setup('config', 'cron')); + } + } catch (\Throwable $exception) { + // Ignore missing cron config file and rely on defaults + } + + $configLoaded = true; + } + + return config()->get('cron.' . $key, $default); + } +} if (!function_exists('cron_manager')) { /** diff --git a/src/Libraries/Cron/Schedule.php b/src/Libraries/Cron/Schedule.php index 3cc70984..e19d1081 100644 --- a/src/Libraries/Cron/Schedule.php +++ b/src/Libraries/Cron/Schedule.php @@ -179,6 +179,8 @@ public function daily(): self public function dailyAt(string $time): self { [$hour, $minute] = explode(':', $time); + $hour = (int) $hour; + $minute = (int) $minute; $this->expression = "{$minute} {$hour} * * *"; return $this; } @@ -214,6 +216,8 @@ public function weekly(): self public function weeklyOn(int $dayOfWeek, string $time = '0:00'): self { [$hour, $minute] = explode(':', $time); + $hour = (int) $hour; + $minute = (int) $minute; $this->expression = "{$minute} {$hour} * * {$dayOfWeek}"; return $this; } @@ -237,6 +241,8 @@ public function monthly(): self public function monthlyOn(int $dayOfMonth = 1, string $time = '0:00'): self { [$hour, $minute] = explode(':', $time); + $hour = (int) $hour; + $minute = (int) $minute; $this->expression = "{$minute} {$hour} {$dayOfMonth} * *"; return $this; } @@ -251,6 +257,8 @@ public function monthlyOn(int $dayOfMonth = 1, string $time = '0:00'): self public function twiceMonthly(int $firstDay = 1, int $secondDay = 16, string $time = '0:00'): self { [$hour, $minute] = explode(':', $time); + $hour = (int) $hour; + $minute = (int) $minute; $this->expression = "{$minute} {$hour} {$firstDay},{$secondDay} * *"; return $this; } @@ -378,11 +386,13 @@ public function days($days): self public function at(string $time): self { [$hour, $minute] = explode(':', $time); + $hour = (int) $hour; + $minute = (int) $minute; // Replace hour and minute in existing expression $parts = explode(' ', $this->expression); - $parts[0] = $minute; - $parts[1] = $hour; + $parts[0] = (string) $minute; + $parts[1] = (string) $hour; $this->expression = implode(' ', $parts); return $this; diff --git a/tests/Unit/Console/Commands/CronRunCommandTest.php b/tests/Unit/Console/Commands/CronRunCommandTest.php index 883ee6d3..76f48c6a 100644 --- a/tests/Unit/Console/Commands/CronRunCommandTest.php +++ b/tests/Unit/Console/Commands/CronRunCommandTest.php @@ -2,28 +2,25 @@ namespace Quantum\Tests\Unit\Console\Commands; +use Quantum\Tests\Unit\AppTestCase; use Quantum\Console\Commands\CronRunCommand; use Symfony\Component\Console\Tester\CommandTester; -use PHPUnit\Framework\TestCase; -use org\bovigo\vfs\vfsStream; /** * Class CronRunCommandTest * @package Quantum\Tests\Unit\Console\Commands */ -class CronRunCommandTest extends TestCase +class CronRunCommandTest extends AppTestCase { - private $vfsRoot; private $cronDirectory; - protected function setUp(): void + public function setUp(): void { parent::setUp(); - // Create virtual filesystem - $this->vfsRoot = vfsStream::setup('project'); - $this->cronDirectory = vfsStream::url('project/cron'); - mkdir($this->cronDirectory); + $this->cronDirectory = base_dir() . DS . 'cron-command-tests'; + $this->cleanupDirectory($this->cronDirectory); + mkdir($this->cronDirectory, 0777, true); // Setup logging config to avoid Loader dependency if (!config()->has('logging')) { @@ -36,6 +33,18 @@ protected function setUp(): void ], ]); } + config()->set('cron', [ + 'path' => null, + 'lock_path' => null, + 'max_lock_age' => 86400, + ]); + } + + public function tearDown(): void + { + parent::tearDown(); + + $this->cleanupDirectory($this->cronDirectory); } public function testCommandExecutesSuccessfully() @@ -59,8 +68,9 @@ public function testCommandExecutesSuccessfully() public function testCommandWithNoTasks() { - $emptyDir = vfsStream::url('project/cron-empty'); - mkdir($emptyDir); + $emptyDir = $this->cronDirectory . '-empty'; + $this->cleanupDirectory($emptyDir); + mkdir($emptyDir, 0777, true); $command = new CronRunCommand(); $tester = new CommandTester($command); @@ -168,7 +178,7 @@ public function testCommandHandlesTaskFailure() $output = $tester->getDisplay(); - $this->assertStringContainsString('Failed:', $output); + $this->assertStringContainsString('Failed: 1', $output); } public function testCommandShortOptions() @@ -192,6 +202,62 @@ public function testCommandShortOptions() $this->assertStringContainsString('short-option-task', $output); } + public function testCommandUsesConfiguredPath() + { + $this->createTaskFile('config-task.php', [ + 'name' => 'config-task', + 'expression' => '* * * * *', + 'callback' => function () {}, + ]); + + config()->set('cron', [ + 'path' => $this->cronDirectory, + 'lock_path' => null, + 'max_lock_age' => 86400, + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute([]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('Execution Summary', $output); + } + + public function testCommandReportsLockedTasks() + { + $this->createTaskFile('locked-task.php', [ + 'name' => 'locked-task', + 'expression' => '* * * * *', + ]); + + $lock = new \Quantum\Libraries\Cron\CronLock('locked-task', $this->runtimeDirectory . DS . 'locks'); + $lock->acquire(); // Hold the lock + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute(['--path' => $this->cronDirectory]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('Locked: 1', $output); + + $lock->release(); + } + + public function testCommandHandlesUnexpectedError() + { + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute(['--path' => []]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('Unexpected error', $output); + } + private function createTaskFile(string $filename, array $definition): void { $body = $definition['body'] ?? "echo 'Test task executed';"; @@ -204,6 +270,31 @@ private function createTaskFile(string $filename, array $definition): void $content .= " }\n"; $content .= "];\n"; - file_put_contents($this->cronDirectory . '/' . $filename, $content); + file_put_contents($this->cronDirectory . DS . $filename, $content); + } + + private function cleanupDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $items = scandir($directory); + + foreach ($items as $item) { + if (in_array($item, ['.', '..'], true)) { + continue; + } + + $path = $directory . DS . $item; + + if (is_dir($path)) { + $this->cleanupDirectory($path); + } elseif (file_exists($path)) { + @unlink($path); + } + } + + @rmdir($directory); } } diff --git a/tests/Unit/Libraries/Cron/CronExceptionTest.php b/tests/Unit/Libraries/Cron/CronExceptionTest.php new file mode 100644 index 00000000..af789013 --- /dev/null +++ b/tests/Unit/Libraries/Cron/CronExceptionTest.php @@ -0,0 +1,51 @@ +assertEquals('Cron task "my-task" not found', $exception->getMessage()); + } + + public function testInvalidExpression() + { + $exception = CronException::invalidExpression('invalid-expr'); + $this->assertEquals('Invalid cron expression: invalid-expr', $exception->getMessage()); + } + + public function testLockAcquireFailed() + { + $exception = CronException::lockAcquireFailed('my-task'); + $this->assertEquals('Failed to acquire lock for task "my-task"', $exception->getMessage()); + } + + public function testTaskExecutionFailed() + { + $exception = CronException::taskExecutionFailed('my-task', 'Connection timeout'); + $this->assertEquals('Task "my-task" execution failed: Connection timeout', $exception->getMessage()); + } + + public function testInvalidTaskFile() + { + $exception = CronException::invalidTaskFile('invalid-file.php'); + $this->assertEquals('Invalid task file "invalid-file.php": must return array or CronTask instance', $exception->getMessage()); + } + + public function testCronDirectoryNotFound() + { + $exception = CronException::cronDirectoryNotFound('/path/to/cron'); + $this->assertEquals('Cron directory not found: /path/to/cron', $exception->getMessage()); + } + + public function testLockDirectoryNotWritable() + { + $exception = CronException::lockDirectoryNotWritable('/path/to/lock'); + $this->assertEquals('Lock directory is not writable: /path/to/lock', $exception->getMessage()); + } +} diff --git a/tests/Unit/Libraries/Cron/CronHelperTest.php b/tests/Unit/Libraries/Cron/CronHelperTest.php new file mode 100644 index 00000000..0d2865f2 --- /dev/null +++ b/tests/Unit/Libraries/Cron/CronHelperTest.php @@ -0,0 +1,48 @@ +has('cron')) { + config()->set('cron', [ + 'path' => '/default/path', + 'lock_path' => '/default/lock', + ]); + } + } + + public function testCronConfig() + { + $this->assertEquals('/default/path', cron_config('path')); + $this->assertEquals('default-val', cron_config('non-existent', 'default-val')); + } + + public function testCronManagerHelper() + { + $manager = cron_manager('/custom/path'); + $this->assertInstanceOf(CronManager::class, $manager); + } + + public function testCronTaskHelper() + { + $task = cron_task('my-task', '* * * * *', function () {}); + $this->assertInstanceOf(CronTask::class, $task); + $this->assertEquals('my-task', $task->getName()); + } + + public function testScheduleHelper() + { + $schedule = schedule('my-task'); + $this->assertInstanceOf(Schedule::class, $schedule); + } +} diff --git a/tests/Unit/Libraries/Cron/CronLockTest.php b/tests/Unit/Libraries/Cron/CronLockTest.php index f96ab223..e577e4fb 100644 --- a/tests/Unit/Libraries/Cron/CronLockTest.php +++ b/tests/Unit/Libraries/Cron/CronLockTest.php @@ -2,41 +2,37 @@ namespace Quantum\Tests\Unit\Libraries\Cron; +use Quantum\Tests\Unit\AppTestCase; use Quantum\Libraries\Cron\CronLock; use Quantum\Libraries\Cron\Exceptions\CronException; -use PHPUnit\Framework\TestCase; -use org\bovigo\vfs\vfsStream; /** * Class CronLockTest * @package Quantum\Tests\Unit\Libraries\Cron */ -class CronLockTest extends TestCase +class CronLockTest extends AppTestCase { - private $vfsRoot; private $lockDirectory; - protected function setUp(): void + public function setUp(): void { parent::setUp(); - // Create virtual filesystem - $this->vfsRoot = vfsStream::setup('runtime'); - $this->lockDirectory = vfsStream::url('runtime/cron/locks'); + $this->lockDirectory = base_dir() . DS . 'runtime' . DS . 'cron-lock-tests'; + $this->cleanupDirectory($this->lockDirectory); + + config()->set('cron', [ + 'path' => null, + 'lock_path' => null, + 'max_lock_age' => 86400, + ]); } - protected function tearDown(): void + public 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); - } - } + $this->cleanupDirectory($this->lockDirectory); } public function testConstructorCreatesLockDirectory() @@ -44,6 +40,7 @@ public function testConstructorCreatesLockDirectory() $lock = new CronLock('test-task', $this->lockDirectory); $this->assertTrue(is_dir($this->lockDirectory)); + $lock->release(); } public function testAcquireLock() @@ -51,6 +48,7 @@ public function testAcquireLock() $lock = new CronLock('test-task', $this->lockDirectory); $this->assertTrue($lock->acquire()); + $lock->release(); } public function testCannotAcquireLockedTask() @@ -74,6 +72,19 @@ public function testReleaseLock() $this->assertFalse($lock->isLocked()); } + public function testReleaseWithoutAcquireDoesNotDeleteForeignLock() + { + $lockPath = $this->lockDirectory . DS . 'foreign-task.lock'; + mkdir($this->lockDirectory, 0777, true); + file_put_contents($lockPath, 'foreign'); + + $lock = new CronLock('foreign-task', $this->lockDirectory); + $lock->release(); + + $this->assertFileExists($lockPath); + @unlink($lockPath); + } + public function testIsLocked() { $lock1 = new CronLock('test-task', $this->lockDirectory); @@ -102,50 +113,167 @@ public function testMultipleTasksCanHaveSeparateLocks() $lock2->release(); } - public function testLockFileContainsMetadata() + public function testRefreshUpdatesTimestamp() { - $lock = new CronLock('test-task', $this->lockDirectory); + $lock = new CronLock('refresh-task', $this->lockDirectory); $lock->acquire(); - $lockFile = $this->lockDirectory . '/test-task.lock'; - $this->assertTrue(file_exists($lockFile)); + $lockFile = $this->lockDirectory . DS . 'refresh-task.lock'; + $initial = (int) file_get_contents($lockFile); - $content = file_get_contents($lockFile); - $data = json_decode($content, true); + sleep(1); + $this->assertTrue($lock->refresh()); + $updated = (int) file_get_contents($lockFile); - $this->assertArrayHasKey('task', $data); - $this->assertArrayHasKey('started_at', $data); - $this->assertArrayHasKey('pid', $data); - $this->assertEquals('test-task', $data['task']); + $this->assertGreaterThan($initial, $updated); $lock->release(); } - public function testDestructorReleasesLock() + public function testTaskNameIsSanitized() { - $lockFile = $this->lockDirectory . '/test-task.lock'; + $lock = new CronLock('../bad name', $this->lockDirectory); + $this->assertTrue($lock->acquire()); - $lock = new CronLock('test-task', $this->lockDirectory); - $lock->acquire(); + $expectedPath = $this->lockDirectory . DS . 'bad_name.lock'; + $this->assertFileExists($expectedPath); + + $lock->release(); + @unlink($expectedPath); + } - $this->assertTrue(file_exists($lockFile)); + public function testStaleLocksAreCleanedUp() + { + $lockPath = $this->lockDirectory . DS . 'stale-task.lock'; + $this->cleanupDirectory($this->lockDirectory); + mkdir($this->lockDirectory, 0777, true); + file_put_contents($lockPath, (string) (time() - 90000)); + + new CronLock('stale-task', $this->lockDirectory, 10); + + $this->assertFalse(file_exists($lockPath)); + } + + public function testCleanupSkipsActiveLocks() + { + $lockPath = $this->lockDirectory . DS . 'active.lock'; + $this->cleanupDirectory($this->lockDirectory); + mkdir($this->lockDirectory, 0777, true); + file_put_contents($lockPath, (string) (time() - 90000)); + + $handle = fopen($lockPath, 'c+'); + flock($handle, LOCK_EX); + touch($lockPath, time() - 90000); + + new CronLock('dummy', $this->lockDirectory, 10); + + $this->assertFileExists($lockPath); - unset($lock); + flock($handle, LOCK_UN); + fclose($handle); - // Lock should be released after destructor - $newLock = new CronLock('test-task', $this->lockDirectory); - $this->assertFalse($newLock->isLocked()); + new CronLock('dummy', $this->lockDirectory, 10); + + $this->assertFileDoesNotExist($lockPath); + } + + public function testConfigurableLockDirectoryIsUsed() + { + $customDirectory = base_dir() . DS . 'runtime' . DS . 'cron-custom-locks'; + $this->cleanupDirectory($customDirectory); + + config()->set('cron', [ + 'path' => null, + 'lock_path' => $customDirectory, + 'max_lock_age' => 86400, + ]); + + $lock = new CronLock('custom-task'); + $lock->acquire(); + $lock->release(); + + $this->assertTrue(is_dir($customDirectory)); + + $this->cleanupDirectory($customDirectory); } public function testThrowsExceptionWhenDirectoryNotWritable() { + if (function_exists('posix_getuid') && posix_getuid() === 0) { + $this->markTestSkipped('Skipping non-writable directory test as root.'); + } + $this->expectException(CronException::class); $this->expectExceptionMessage('not writable'); - // Create a read-only directory - $readOnlyDir = vfsStream::url('runtime/readonly'); + $readOnlyDir = $this->lockDirectory . DS . 'readonly'; + mkdir($this->lockDirectory, 0777, true); mkdir($readOnlyDir, 0444); - new CronLock('test-task', $readOnlyDir); + try { + new CronLock('test-task', $readOnlyDir); + } finally { + chmod($readOnlyDir, 0755); + } + } + + public function testCronLockRefresh() + { + $lock = new CronLock('refresh-task', $this->lockDirectory); + $this->assertFalse($lock->refresh()); // Not owned yet + + $lock->acquire(); + $this->assertTrue($lock->refresh()); + $lock->release(); + } + + public function testCronLockSanitization() + { + $lock = new CronLock(' Space Task / \\ ', $this->lockDirectory); + $this->assertStringContainsString('Space_Task', $this->getPrivateProperty($lock, 'lockFile')); + + $lock2 = new CronLock('', $this->lockDirectory); + $this->assertStringContainsString('default.lock', $this->getPrivateProperty($lock2, 'lockFile')); + } + + public function testCronLockDirectoryRecursion() + { + $nestedDir = $this->lockDirectory . DS . 'a' . DS . 'b' . DS . 'c'; + $lock = new CronLock('nested-task', $nestedDir); + $this->assertTrue(fs()->isDirectory($nestedDir)); + $lock->acquire(); + $this->assertTrue(fs()->exists($nestedDir . DS . 'nested-task.lock')); + $lock->release(); + } + + public function testCronLockEmptyDirectoryThrowsException() + { + $this->expectException(CronException::class); + new CronLock('task', ''); + } + + private function cleanupDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $items = scandir($directory); + + foreach ($items as $item) { + if (in_array($item, ['.', '..'], true)) { + continue; + } + + $path = $directory . DS . $item; + + if (is_dir($path)) { + $this->cleanupDirectory($path); + } elseif (file_exists($path)) { + @unlink($path); + } + } + + @rmdir($directory); } } diff --git a/tests/Unit/Libraries/Cron/CronManagerTest.php b/tests/Unit/Libraries/Cron/CronManagerTest.php index 6f93659e..c72ac318 100644 --- a/tests/Unit/Libraries/Cron/CronManagerTest.php +++ b/tests/Unit/Libraries/Cron/CronManagerTest.php @@ -2,29 +2,26 @@ namespace Quantum\Tests\Unit\Libraries\Cron; +use Quantum\Tests\Unit\AppTestCase; use Quantum\Libraries\Cron\CronManager; use Quantum\Libraries\Cron\Exceptions\CronException; -use PHPUnit\Framework\TestCase; -use org\bovigo\vfs\vfsStream; /** * Class CronManagerTest * @package Quantum\Tests\Unit\Libraries\Cron */ -class CronManagerTest extends TestCase +class CronManagerTest extends AppTestCase { - private $vfsRoot; private $cronDirectory; private static $executedTasks = []; - protected function setUp(): void + public function setUp(): void { parent::setUp(); - // Create virtual filesystem - $this->vfsRoot = vfsStream::setup('project'); - $this->cronDirectory = vfsStream::url('project/cron'); - mkdir($this->cronDirectory); + $this->cronDirectory = base_dir() . DS . 'cron-tests'; + $this->cleanupDirectory($this->cronDirectory); + mkdir($this->cronDirectory, 0777, true); self::$executedTasks = []; // Setup logging config to avoid Loader dependency @@ -38,6 +35,12 @@ protected function setUp(): void ], ]); } + + config()->set('cron', [ + 'path' => null, + 'lock_path' => null, + 'max_lock_age' => 86400, + ]); } public function testLoadTasksFromDirectory() @@ -64,9 +67,9 @@ public function testLoadTasksFromDirectory() public function testLoadTasksWithObjectFormat() { - $taskContent = 'cronDirectory . '/object-task.php', $taskContent); @@ -88,14 +91,6 @@ public function testLoadTasksWithEmptyDirectory() $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', [ @@ -210,8 +205,57 @@ public function testInvalidTaskFileThrowsException() $manager->loadTasks(); } - private function createTaskFile(string $filename, array $definition): void + public function testCronDirectoryCanBeConfigured() + { + $configuredDir = base_dir() . DS . 'cron-configured'; + $this->cleanupDirectory($configuredDir); + mkdir($configuredDir, 0777, true); + + $this->createTaskFile('configured-task.php', [ + 'name' => 'configured-task', + 'expression' => '* * * * *', + ], $configuredDir); + + config()->set('cron', [ + 'path' => $configuredDir, + 'lock_path' => null, + 'max_lock_age' => 86400, + ]); + + $manager = new CronManager(); + $manager->loadTasks(); + + $this->assertArrayHasKey('configured-task', $manager->getTasks()); + + $this->cleanupDirectory($configuredDir); + } + + public function testCronDirectoryNotFoundThrowsException() { + $this->expectException(CronException::class); + $this->expectExceptionMessage('not found'); + + $manager = new CronManager($this->cronDirectory . '/non-existent'); + $manager->loadTasks(); + } + + public function testTaskExecutionFailureIsRecorded() + { + $this->createTaskFile('failing-task.php', [ + 'name' => 'failing-task', + 'expression' => '* * * * *', + 'body' => 'throw new \Exception("Execution failed");', + ]); + + $manager = new CronManager($this->cronDirectory); + $stats = $manager->runDueTasks(true); + + $this->assertEquals(1, $stats['failed']); + } + + private function createTaskFile(string $filename, array $definition, ?string $directory = null): void + { + $directory = $directory ?? $this->cronDirectory; $body = $definition['body'] ?? "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('{$definition['name']}');"; $content = "cronDirectory . '/' . $filename, $content); + file_put_contents($directory . DS . $filename, $content); } public static function recordExecution(string $taskName): void { self::$executedTasks[] = $taskName; } + + private function cleanupDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $items = scandir($directory); + + foreach ($items as $item) { + if (in_array($item, ['.', '..'], true)) { + continue; + } + + $path = $directory . DS . $item; + + if (is_dir($path)) { + $this->cleanupDirectory($path); + } elseif (file_exists($path)) { + @unlink($path); + } + } + + @rmdir($directory); + } } diff --git a/tests/Unit/Libraries/Cron/CronTaskTest.php b/tests/Unit/Libraries/Cron/CronTaskTest.php index 8975d3f8..c03bb4ec 100644 --- a/tests/Unit/Libraries/Cron/CronTaskTest.php +++ b/tests/Unit/Libraries/Cron/CronTaskTest.php @@ -2,15 +2,15 @@ namespace Quantum\Tests\Unit\Libraries\Cron; +use Quantum\Tests\Unit\AppTestCase; use Quantum\Libraries\Cron\CronTask; use Quantum\Libraries\Cron\Exceptions\CronException; -use PHPUnit\Framework\TestCase; /** * Class CronTaskTest * @package Quantum\Tests\Unit\Libraries\Cron */ -class CronTaskTest extends TestCase +class CronTaskTest extends AppTestCase { public function testConstructorWithValidExpression() { diff --git a/tests/Unit/Libraries/Cron/ScheduleTest.php b/tests/Unit/Libraries/Cron/ScheduleTest.php new file mode 100644 index 00000000..e2d92309 --- /dev/null +++ b/tests/Unit/Libraries/Cron/ScheduleTest.php @@ -0,0 +1,241 @@ +schedule = new Schedule('test-task'); + } + + public function testEveryMinute() + { + $this->schedule->everyMinute(); + $this->assertEquals('* * * * *', $this->schedule->getExpression()); + } + + public function testEveryFiveMinutes() + { + $this->schedule->everyFiveMinutes(); + $this->assertEquals('*/5 * * * *', $this->schedule->getExpression()); + } + + public function testEveryTenMinutes() + { + $this->schedule->everyTenMinutes(); + $this->assertEquals('*/10 * * * *', $this->schedule->getExpression()); + } + + public function testEveryFifteenMinutes() + { + $this->schedule->everyFifteenMinutes(); + $this->assertEquals('*/15 * * * *', $this->schedule->getExpression()); + } + + public function testEveryThirtyMinutes() + { + $this->schedule->everyThirtyMinutes(); + $this->assertEquals('*/30 * * * *', $this->schedule->getExpression()); + } + + public function testHourly() + { + $this->schedule->hourly(); + $this->assertEquals('0 * * * *', $this->schedule->getExpression()); + } + + public function testHourlyAt() + { + $this->schedule->hourlyAt(15); + $this->assertEquals('15 * * * *', $this->schedule->getExpression()); + } + + public function testEveryTwoHours() + { + $this->schedule->everyTwoHours(); + $this->assertEquals('0 */2 * * *', $this->schedule->getExpression()); + } + + public function testEveryThreeHours() + { + $this->schedule->everyThreeHours(); + $this->assertEquals('0 */3 * * *', $this->schedule->getExpression()); + } + + public function testEveryFourHours() + { + $this->schedule->everyFourHours(); + $this->assertEquals('0 */4 * * *', $this->schedule->getExpression()); + } + + public function testEverySixHours() + { + $this->schedule->everySixHours(); + $this->assertEquals('0 */6 * * *', $this->schedule->getExpression()); + } + + public function testDaily() + { + $this->schedule->daily(); + $this->assertEquals('0 0 * * *', $this->schedule->getExpression()); + } + + public function testDailyAt() + { + $this->schedule->dailyAt('13:30'); + $this->assertEquals('30 13 * * *', $this->schedule->getExpression()); + } + + public function testTwiceDaily() + { + $this->schedule->twiceDaily(4, 16); + $this->assertEquals('0 4,16 * * *', $this->schedule->getExpression()); + } + + public function testWeekly() + { + $this->schedule->weekly(); + $this->assertEquals('0 0 * * 0', $this->schedule->getExpression()); + } + + public function testWeeklyOn() + { + $this->schedule->weeklyOn(1, '15:45'); + $this->assertEquals('45 15 * * 1', $this->schedule->getExpression()); + } + + public function testMonthly() + { + $this->schedule->monthly(); + $this->assertEquals('0 0 1 * *', $this->schedule->getExpression()); + } + + public function testMonthlyOn() + { + $this->schedule->monthlyOn(15, '10:00'); + $this->assertEquals('0 10 15 * *', $this->schedule->getExpression()); + } + + public function testTwiceMonthly() + { + $this->schedule->twiceMonthly(1, 15, '12:00'); + $this->assertEquals('0 12 1,15 * *', $this->schedule->getExpression()); + } + + public function testQuarterly() + { + $this->schedule->quarterly(); + $this->assertEquals('0 0 1 1-12/3 *', $this->schedule->getExpression()); + } + + public function testYearly() + { + $this->schedule->yearly(); + $this->assertEquals('0 0 1 1 *', $this->schedule->getExpression()); + } + + public function testWeekdays() + { + $this->schedule->weekdays(); + $this->assertEquals('0 0 * * 1-5', $this->schedule->getExpression()); + } + + public function testWeekends() + { + $this->schedule->weekends(); + $this->assertEquals('0 0 * * 0,6', $this->schedule->getExpression()); + } + + public function testMondays() + { + $this->schedule->mondays(); + $this->assertEquals('0 0 * * 1', $this->schedule->getExpression()); + } + + public function testTuesdays() + { + $this->schedule->tuesdays(); + $this->assertEquals('0 0 * * 2', $this->schedule->getExpression()); + } + + public function testWednesdays() + { + $this->schedule->wednesdays(); + $this->assertEquals('0 0 * * 3', $this->schedule->getExpression()); + } + + public function testThursdays() + { + $this->schedule->thursdays(); + $this->assertEquals('0 0 * * 4', $this->schedule->getExpression()); + } + + public function testFridays() + { + $this->schedule->fridays(); + $this->assertEquals('0 0 * * 5', $this->schedule->getExpression()); + } + + public function testSaturdays() + { + $this->schedule->saturdays(); + $this->assertEquals('0 0 * * 6', $this->schedule->getExpression()); + } + + public function testSundays() + { + $this->schedule->sundays(); + $this->assertEquals('0 0 * * 0', $this->schedule->getExpression()); + } + + public function testDaysWithArray() + { + $this->schedule->days([1, 3, 5]); + $this->assertEquals('0 0 * * 1,3,5', $this->schedule->getExpression()); + } + + public function testAtOverridesTime() + { + $this->schedule->weeklyOn(1)->at('14:30'); + $this->assertEquals('30 14 * * 1', $this->schedule->getExpression()); + } + + public function testCronSchedulesCustomExpression() + { + $this->schedule->cron('1 2 3 4 5'); + $this->assertEquals('1 2 3 4 5', $this->schedule->getExpression()); + } + + public function testBuildSetsTask() + { + $callback = function () {}; + $task = $this->schedule->everyMinute()->call($callback)->build(); + + $this->assertInstanceOf(CronTask::class, $task); + $this->assertEquals('test-task', $task->getName()); + $this->assertEquals('* * * * *', $task->getExpression()); + } + + public function testBuildThrowsExceptionWhenCallbackMissing() + { + $this->expectException(CronException::class); + $this->expectExceptionMessage("Task 'test-task' must have a callback. Use call() method."); + $this->schedule->everyMinute()->build(); + } + + public function testBuildThrowsExceptionWhenScheduleMissing() + { + $callback = function () {}; + $this->expectException(CronException::class); + $this->expectExceptionMessage("Task 'test-task' must have a schedule. Use methods like daily(), hourly(), etc."); + $this->schedule->call($callback)->build(); + } +} diff --git a/tests/_root/shared/config/cron.php b/tests/_root/shared/config/cron.php new file mode 100644 index 00000000..7d837613 --- /dev/null +++ b/tests/_root/shared/config/cron.php @@ -0,0 +1,7 @@ + env('CRON_PATH'), + 'lock_path' => env('CRON_LOCK_PATH'), + 'max_lock_age' => (int)env('CRON_MAX_LOCK_AGE', 86400), +]; From 837e6c074747d51901e0988e1859383c63d12106 Mon Sep 17 00:00:00 2001 From: Artak Date: Mon, 19 Jan 2026 13:57:36 +0400 Subject: [PATCH 4/7] Fix:Add native cron task runner command to Quantum CLI (qt cron:run) #291 --- .../Console/Commands/CronRunCommandTest.php | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/tests/Unit/Console/Commands/CronRunCommandTest.php b/tests/Unit/Console/Commands/CronRunCommandTest.php index 76f48c6a..9adf5854 100644 --- a/tests/Unit/Console/Commands/CronRunCommandTest.php +++ b/tests/Unit/Console/Commands/CronRunCommandTest.php @@ -13,29 +13,31 @@ class CronRunCommandTest extends AppTestCase { private $cronDirectory; + private $lockDirectory; public function setUp(): void { parent::setUp(); $this->cronDirectory = base_dir() . DS . 'cron-command-tests'; + $this->lockDirectory = base_dir() . DS . 'cron-command-locks'; $this->cleanupDirectory($this->cronDirectory); + $this->cleanupDirectory($this->lockDirectory); mkdir($this->cronDirectory, 0777, true); + mkdir($this->lockDirectory, 0777, true); + + config()->set('logging', [ + 'default' => 'single', + 'single' => [ + 'driver' => 'single', + 'path' => base_dir() . '/logs', + 'level' => 'debug', + ], + ]); - // 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', - ], - ]); - } config()->set('cron', [ 'path' => null, - 'lock_path' => null, + 'lock_path' => $this->lockDirectory, 'max_lock_age' => 86400, ]); } @@ -45,6 +47,7 @@ public function tearDown(): void parent::tearDown(); $this->cleanupDirectory($this->cronDirectory); + $this->cleanupDirectory($this->lockDirectory); } public function testCommandExecutesSuccessfully() @@ -231,10 +234,11 @@ public function testCommandReportsLockedTasks() $this->createTaskFile('locked-task.php', [ 'name' => 'locked-task', 'expression' => '* * * * *', + 'callback' => function () {}, ]); - $lock = new \Quantum\Libraries\Cron\CronLock('locked-task', $this->runtimeDirectory . DS . 'locks'); - $lock->acquire(); // Hold the lock + $lock = new \Quantum\Libraries\Cron\CronLock('locked-task', $this->lockDirectory); + $lock->acquire(); $command = new CronRunCommand(); $tester = new CommandTester($command); @@ -249,10 +253,13 @@ public function testCommandReportsLockedTasks() public function testCommandHandlesUnexpectedError() { + $invalidTask = $this->cronDirectory . DS . 'invalid-task.php'; + file_put_contents($invalidTask, "execute(['--path' => []]); + $tester->execute(['--path' => $this->cronDirectory]); $output = $tester->getDisplay(); $this->assertStringContainsString('Unexpected error', $output); From 4fc7eb8b31ef0e846d9087d26c15ccfe9b23c860 Mon Sep 17 00:00:00 2001 From: Artak Date: Mon, 19 Jan 2026 14:15:05 +0400 Subject: [PATCH 5/7] Fix:Add native cron task runner command to Quantum CLI (qt cron:run) #291 --- src/Libraries/Cron/CronManager.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Cron/CronManager.php b/src/Libraries/Cron/CronManager.php index 4a35add4..75025853 100644 --- a/src/Libraries/Cron/CronManager.php +++ b/src/Libraries/Cron/CronManager.php @@ -17,6 +17,7 @@ use Quantum\Libraries\Cron\Contracts\CronTaskInterface; use Quantum\Libraries\Logger\Factories\LoggerFactory; use Quantum\Libraries\Cron\Exceptions\CronException; +use Quantum\Libraries\Logger\Logger; /** * Class CronManager @@ -234,7 +235,8 @@ private function getDefaultCronDirectory(): string private function log(string $level, string $message, array $context = []): void { try { - $logger = LoggerFactory::get(); + $defaultAdapter = config()->get('logging.default', Logger::SINGLE); + $logger = LoggerFactory::get($defaultAdapter); $logger->log($level, '[CRON] ' . $message, $context); } catch (\Throwable $exception) { error_log(sprintf('[CRON] [%s] %s', strtoupper($level), $message)); From d4a4f3a390252f71db32ec01517723d5a89e6e7c Mon Sep 17 00:00:00 2001 From: Artak Date: Mon, 19 Jan 2026 15:04:15 +0400 Subject: [PATCH 6/7] Fix:nativ-cron-runner(Unit AppTestCase reset messages) --- src/Libraries/Cron/CronManager.php | 3 +-- tests/Unit/AppTestCase.php | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Cron/CronManager.php b/src/Libraries/Cron/CronManager.php index 75025853..b5abe5ba 100644 --- a/src/Libraries/Cron/CronManager.php +++ b/src/Libraries/Cron/CronManager.php @@ -235,8 +235,7 @@ private function getDefaultCronDirectory(): string private function log(string $level, string $message, array $context = []): void { try { - $defaultAdapter = config()->get('logging.default', Logger::SINGLE); - $logger = LoggerFactory::get($defaultAdapter); + $logger = LoggerFactory::get(Logger::SINGLE); $logger->log($level, '[CRON] ' . $message, $context); } catch (\Throwable $exception) { error_log(sprintf('[CRON] [%s] %s', strtoupper($level), $message)); diff --git a/tests/Unit/AppTestCase.php b/tests/Unit/AppTestCase.php index deaa1303..cc6e15db 100644 --- a/tests/Unit/AppTestCase.php +++ b/tests/Unit/AppTestCase.php @@ -7,6 +7,7 @@ use Quantum\Environment\Environment; use Quantum\Router\RouteController; use PHPUnit\Framework\TestCase; +use Quantum\Debugger\Debugger; use Quantum\Http\Request; use Quantum\Loader\Setup; use ReflectionClass; @@ -32,6 +33,7 @@ public function tearDown(): void { AppFactory::destroy(App::WEB); config()->flush(); + Debugger::getInstance()->resetStore(); Di::reset(); } From 8365d46a00eacb392dd24e638c67e5ba860f156e Mon Sep 17 00:00:00 2001 From: Artak Date: Mon, 19 Jan 2026 18:04:56 +0400 Subject: [PATCH 7/7] Fix:nativ-cron-runner(fix scr scrutinizer issues) --- src/Libraries/Cron/CronLock.php | 62 ++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/src/Libraries/Cron/CronLock.php b/src/Libraries/Cron/CronLock.php index dec18785..e5b0d68d 100644 --- a/src/Libraries/Cron/CronLock.php +++ b/src/Libraries/Cron/CronLock.php @@ -41,7 +41,7 @@ public function __construct(string $taskName, ?string $lockDirectory = null, ?in public function acquire(): bool { - $this->lockHandle = @fopen($this->lockFile, 'c+'); + $this->lockHandle = fopen($this->lockFile, 'c+'); if ($this->lockHandle === false) { $this->lockHandle = null; $this->ownsLock = false; @@ -55,7 +55,13 @@ public function acquire(): bool return false; } - $this->writeTimestampToHandle($this->lockHandle); + if (!$this->writeTimestampToHandle($this->lockHandle)) { + flock($this->lockHandle, LOCK_UN); + fclose($this->lockHandle); + $this->lockHandle = null; + $this->ownsLock = false; + return false; + } $this->ownsLock = true; return true; @@ -70,8 +76,7 @@ public function refresh(): bool return false; } - $this->writeTimestampToHandle($this->lockHandle); - return true; + return $this->writeTimestampToHandle($this->lockHandle); } public function release(): bool @@ -80,15 +85,18 @@ public function release(): bool return true; } - flock($this->lockHandle, LOCK_UN); - fclose($this->lockHandle); + $unlocked = flock($this->lockHandle, LOCK_UN); + $closed = fclose($this->lockHandle); $this->lockHandle = null; $this->ownsLock = false; - @fs()->remove($this->lockFile); + $removed = true; + if (fs()->exists($this->lockFile)) { + $removed = fs()->remove($this->lockFile); + } - return true; + return $unlocked && $closed && $removed; } /** @@ -100,7 +108,7 @@ public function isLocked(): bool return false; } - $handle = @fopen($this->lockFile, 'c+'); + $handle = fopen($this->lockFile, 'c+'); if ($handle === false) { return true; } @@ -185,7 +193,7 @@ private function cleanupStaleLocks(): void $now = time(); foreach ($files as $file) { - $handle = @fopen($file, 'c+'); + $handle = fopen($file, 'c+'); if ($handle === false) { continue; } @@ -199,28 +207,40 @@ private function cleanupStaleLocks(): void $timestamp = $this->readTimestampFromHandle($handle); if ($timestamp !== null && ($now - $timestamp) > $this->maxLockAge) { - @flock($handle, LOCK_UN); - @fclose($handle); - @fs()->remove($file); + flock($handle, LOCK_UN); + fclose($handle); + fs()->remove($file); continue; } - @flock($handle, LOCK_UN); - @fclose($handle); + flock($handle, LOCK_UN); + fclose($handle); } } - private function writeTimestampToHandle($handle): void + private function writeTimestampToHandle($handle): bool { - @ftruncate($handle, 0); - @rewind($handle); - @fwrite($handle, (string) time()); - @fflush($handle); + if (ftruncate($handle, 0) === false) { + return false; + } + if (rewind($handle) === false) { + return false; + } + if (fwrite($handle, (string) time()) === false) { + return false; + } + if (fflush($handle) === false) { + return false; + } + + return true; } private function readTimestampFromHandle($handle): ?int { - @rewind($handle); + if (rewind($handle) === false) { + return null; + } $content = stream_get_contents($handle); if ($content === false) { return null;