├── LICENSE ├── composer.json └── src ├── CallbackJob.php ├── Command ├── ForceRunCommand.php ├── HelpCommand.php ├── ListCommand.php └── RunCommand.php ├── DI └── SchedulerExtension.php ├── Exceptions ├── LogicalException.php └── RuntimeException.php ├── ExpressionJob.php ├── Helpers └── Debugger.php ├── IJob.php ├── IScheduler.php ├── LockingScheduler.php └── Scheduler.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Contributte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contributte/scheduler", 3 | "description": "PHP job scheduler (cron) with locking", 4 | "keywords": [ 5 | "nette", 6 | "cron", 7 | "contributte", 8 | "scheduler" 9 | ], 10 | "license": "MIT", 11 | "type": "library", 12 | "homepage": "https://github.com/contributte/scheduler", 13 | "authors": [ 14 | { 15 | "name": "Milan Felix Šulc", 16 | "homepage": "https://f3l1x.io" 17 | }, 18 | { 19 | "name": "Josef Benjac", 20 | "homepage": "http://josefbenjac.com" 21 | } 22 | ], 23 | "require": { 24 | "php": ">=8.1", 25 | "nette/di": "^3.1.8", 26 | "dragonmantank/cron-expression": "^3.3.3", 27 | "symfony/console": "^6.4.1 || ^7.0.1" 28 | }, 29 | "require-dev": { 30 | "mockery/mockery": "^1.6.7", 31 | "contributte/qa": "^0.4", 32 | "contributte/tester": "^0.4", 33 | "contributte/phpstan": "^0.1", 34 | "tracy/tracy": "^2.10.5" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "Contributte\\Scheduler\\": "src/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Tests\\": "tests" 44 | } 45 | }, 46 | "minimum-stability": "dev", 47 | "prefer-stable": true, 48 | "config": { 49 | "sort-packages": true, 50 | "allow-plugins": { 51 | "dealerdirect/phpcodesniffer-composer-installer": true 52 | } 53 | }, 54 | "extra": { 55 | "branch-alias": { 56 | "dev-master": "0.9.x-dev" 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/CallbackJob.php: -------------------------------------------------------------------------------- 1 | callback = $callback; 16 | } 17 | 18 | public function run(): void 19 | { 20 | call_user_func($this->callback); 21 | } 22 | 23 | public function getCallback(): callable 24 | { 25 | return $this->callback; 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Command/ForceRunCommand.php: -------------------------------------------------------------------------------- 1 | scheduler = $scheduler; 26 | } 27 | 28 | protected function configure(): void 29 | { 30 | $this->addArgument('key', InputArgument::REQUIRED, 'Job key'); 31 | } 32 | 33 | protected function execute(InputInterface $input, OutputInterface $output): int 34 | { 35 | $key = $input->getArgument('key'); 36 | 37 | if (!is_string($key) && !is_int($key)) { 38 | return Command::FAILURE; 39 | } 40 | 41 | $job = $this->scheduler->get($key); 42 | 43 | if ($job === null) { 44 | return Command::FAILURE; 45 | } 46 | 47 | $job->run(); 48 | 49 | return Command::SUCCESS; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/Command/HelpCommand.php: -------------------------------------------------------------------------------- 1 | writeln('Cron syntax: '); 20 | $output->writeln(' 21 | * * * * * 22 | - - - - - 23 | | | | | | 24 | | | | | | 25 | | | | | +----- day of week (0 - 7) (Sunday=0 or 7) 26 | | | | +---------- month (1 - 12) 27 | | | +--------------- day of month (1 - 31) 28 | | +-------------------- hour (0 - 23) 29 | +------------------------- min (0 - 59)'); 30 | $output->writeln(''); 31 | 32 | return Command::SUCCESS; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Command/ListCommand.php: -------------------------------------------------------------------------------- 1 | scheduler = $scheduler; 32 | } 33 | 34 | protected function execute(InputInterface $input, OutputInterface $output): int 35 | { 36 | $jobs = $this->scheduler->getAll(); 37 | $table = new Table($output); 38 | $table->setHeaders(['Key', 'Type', 'Is due', 'Cron', 'Callback']); 39 | $dateTime = new DateTime(); 40 | 41 | foreach ($jobs as $key => $job) { 42 | $table->addRow(self::formatRow(is_string($key) ? $key : '', $job, $dateTime)); 43 | } 44 | 45 | $table->render(); 46 | 47 | return Command::SUCCESS; 48 | } 49 | 50 | /** 51 | * @return string[]|callable[]|CronExpression[] 52 | */ 53 | private static function formatRow(string $key, IJob $job, DateTime $dateTime): array 54 | { 55 | // Common 56 | $row = [ 57 | $key, 58 | $job::class, 59 | $job->isDue($dateTime) ? 'TRUE' : 'FALSE', 60 | ]; 61 | 62 | // Expression 63 | $row[] = $job instanceof ExpressionJob ? $job->getExpression() : 'Dynamic'; 64 | 65 | // Callback 66 | if ($job instanceof CallbackJob) { 67 | $callback = $job->getCallback(); 68 | if (is_string($callback)) { 69 | $row[] = $callback; 70 | } elseif (is_array($callback)) { 71 | $class = $callback[0]; 72 | $callback = $callback[1]; 73 | $row[] = $class::class . '->' . $callback . '()'; 74 | } else { 75 | throw new LogicalException('Unknown callback'); 76 | } 77 | } else { 78 | $row[] = 'Dynamic'; 79 | } 80 | 81 | return $row; 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/Command/RunCommand.php: -------------------------------------------------------------------------------- 1 | scheduler = $scheduler; 25 | } 26 | 27 | protected function execute(InputInterface $input, OutputInterface $output): int 28 | { 29 | $this->scheduler->run(); 30 | 31 | return Command::SUCCESS; 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/DI/SchedulerExtension.php: -------------------------------------------------------------------------------- 1 | Expect::string()->nullable(), 30 | 'jobs' => Expect::arrayOf( 31 | Expect::anyOf(Expect::string(), Expect::array(), Expect::type(Statement::class)) 32 | ), 33 | ]); 34 | } 35 | 36 | public function loadConfiguration(): void 37 | { 38 | $builder = $this->getContainerBuilder(); 39 | $config = $this->config; 40 | 41 | // Scheduler 42 | $schedulerDefinition = $builder->addDefinition($this->prefix('scheduler')) 43 | ->setType(IScheduler::class); 44 | if ($config->path !== null) { 45 | $schedulerDefinition->setFactory(LockingScheduler::class, [$config->path]); 46 | } else { 47 | $schedulerDefinition->setFactory(Scheduler::class); 48 | } 49 | 50 | // Commands 51 | $builder->addDefinition($this->prefix('runCommand')) 52 | ->setFactory(RunCommand::class) 53 | ->setAutowired(false); 54 | $builder->addDefinition($this->prefix('forceRunCommand')) 55 | ->setFactory(ForceRunCommand::class) 56 | ->setAutowired(false); 57 | $builder->addDefinition($this->prefix('listCommand')) 58 | ->setFactory(ListCommand::class) 59 | ->setAutowired(false); 60 | $builder->addDefinition($this->prefix('helpCommand')) 61 | ->setFactory(HelpCommand::class) 62 | ->setAutowired(false); 63 | 64 | // Jobs 65 | foreach ($config->jobs as $jobName => $jobConfig) { 66 | if (is_array($jobConfig) && (isset($jobConfig['cron']) || isset($jobConfig['callback']))) { 67 | if (!isset($jobConfig['cron'], $jobConfig['callback'])) { 68 | throw new InvalidArgumentException(sprintf('Both options "callback" and "cron" of %s > jobs > %s must be configured', $this->name, $jobName)); 69 | } 70 | 71 | $jobDefinition = new Statement(CallbackJob::class, [$jobConfig['cron'], $jobConfig['callback']]); 72 | } else { 73 | $jobDefinition = $builder->addDefinition($this->prefix('job.' . $jobName)) 74 | ->setFactory($jobConfig) 75 | ->setAutowired(false); 76 | } 77 | 78 | $schedulerDefinition->addSetup('add', [$jobDefinition, $jobName]); 79 | } 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/Exceptions/LogicalException.php: -------------------------------------------------------------------------------- 1 | expression = new CronExpression($cron); 16 | } 17 | 18 | public function isDue(DateTime $dateTime): bool 19 | { 20 | return $this->expression->isDue($dateTime); 21 | } 22 | 23 | public function getExpression(): CronExpression 24 | { 25 | return $this->expression; 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Helpers/Debugger.php: -------------------------------------------------------------------------------- 1 | path = $path; 18 | } 19 | 20 | public function run(): void 21 | { 22 | if (!file_exists($this->path) && !mkdir($this->path, 0777, true) && !is_dir($this->path)) { 23 | throw new RuntimeException(sprintf('Directory `%s` was not created', $this->path)); 24 | } 25 | 26 | $dateTime = new DateTime(); 27 | $jobs = $this->jobs; 28 | 29 | foreach ($jobs as $id => $job) { 30 | if (!$job->isDue($dateTime)) { 31 | continue; 32 | } 33 | 34 | // Create lock 35 | $fp = fopen($this->path . '/' . $id . '.lock', 'w+'); 36 | 37 | if ($fp === false) { 38 | throw new RuntimeException('Cannot acquire lock'); 39 | } 40 | 41 | if (!flock($fp, LOCK_EX | LOCK_NB)) { // acquire an exclusive lock 42 | fclose($fp); 43 | 44 | continue; 45 | } 46 | 47 | try { 48 | // Run job 49 | $job->run(); 50 | } catch (Throwable $e) { 51 | Debugger::log($e); 52 | } finally { 53 | // Unlock 54 | flock($fp, LOCK_UN); 55 | fclose($fp); 56 | unlink($this->path . '/' . $id . '.lock'); 57 | } 58 | } 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/Scheduler.php: -------------------------------------------------------------------------------- 1 | jobs; 19 | foreach ($jobs as $job) { 20 | if (!$job->isDue($dateTime)) { 21 | continue; 22 | } 23 | 24 | try { 25 | $job->run(); 26 | } catch (Throwable $e) { 27 | Debugger::log($e); 28 | } 29 | } 30 | } 31 | 32 | public function add(IJob $job, string|int|null $key = null): void 33 | { 34 | if ($key !== null) { 35 | $this->jobs[$key] = $job; 36 | 37 | return; 38 | } 39 | 40 | $this->jobs[] = $job; 41 | } 42 | 43 | public function get(string|int $key): ?IJob 44 | { 45 | return $this->jobs[$key] ?? null; 46 | } 47 | 48 | /** 49 | * @return IJob[] 50 | */ 51 | public function getAll(): array 52 | { 53 | return $this->jobs; 54 | } 55 | 56 | public function remove(string|int $key): void 57 | { 58 | unset($this->jobs[$key]); 59 | } 60 | 61 | public function removeAll(): void 62 | { 63 | $this->jobs = []; 64 | } 65 | 66 | } 67 | --------------------------------------------------------------------------------