├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── php.yml ├── .gitignore ├── LICENSE ├── UPGRADE-6.0.md ├── composer-dependency-analyser.php ├── composer.json ├── composer.lock ├── docs ├── README.md └── _config.yml ├── phpcs.xml ├── phpstan-baseline.php ├── phpstan.php ├── phpunit.xml ├── src ├── Attribute │ └── AsCronJob.php ├── Collection │ ├── CronJobCollection.php │ ├── CronJobMetadataCollection.php │ └── CronJobRunningCollection.php ├── Command │ ├── CronJobDisableCommand.php │ ├── CronJobEnableCommand.php │ ├── CronProcessCommand.php │ ├── CronRunCommand.php │ ├── CronScanCommand.php │ └── CronStatusCommand.php ├── Console │ └── Style │ │ └── CronStyle.php ├── Controller │ └── CronJobController.php ├── CronJob │ ├── CommandHelper.php │ └── CronJobManager.php ├── DependencyInjection │ ├── Compiler │ │ └── CronJobCompilerPass.php │ ├── Configuration.php │ └── ShapecodeCronExtension.php ├── Domain │ ├── CronJobCounter.php │ ├── CronJobMetadata.php │ ├── CronJobResultStatus.php │ └── CronJobRunning.php ├── Entity │ ├── AbstractEntity.php │ ├── CronJob.php │ └── CronJobResult.php ├── Event │ └── LoadJobsEvent.php ├── EventListener │ ├── EntitySubscriber.php │ └── ServiceJobLoaderListener.php ├── Repository │ ├── CronJobRepository.php │ └── CronJobResultRepository.php ├── Resources │ └── config │ │ ├── routing.yml │ │ └── services.yml └── ShapecodeCronBundle.php └── tests ├── Command └── CronRunCommandTest.php ├── CronJob └── CronJobManagerTest.php ├── Entity ├── CronJobResultTest.php └── CronJobTest.php ├── EventListener └── EntitySubscriberTest.php ├── Fixtures └── bin │ └── console └── Service └── CommandHelperTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | insert_final_newline = false 14 | 15 | [*.{yaml,yml}] 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: nicklog 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: nicklog # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['http://paypal.me/nloges'] 13 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - 'releases/**' 8 | - 'feature/**' 9 | pull_request: 10 | branches: 11 | - master 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | php-versions: [ '8.3', '8.4' ] 20 | dependencies: [ 'locked', 'latest' ] 21 | 22 | steps: 23 | - name: Checkout Repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Install PHP 27 | uses: shivammathur/setup-php@v2 28 | with: 29 | php-version: ${{ matrix.php-versions }} 30 | 31 | - name: Validate composer.json and composer.lock 32 | run: composer validate --strict -n 33 | 34 | - name: Install Composer dependencies 35 | uses: ramsey/composer-install@v3 36 | with: 37 | dependency-versions: ${{ matrix.dependencies }} 38 | composer-options: --prefer-dist --no-scripts 39 | 40 | - name: Composer Dependency Analyser 41 | run: composer cda 42 | 43 | - name: CS Check 44 | run: composer cs-check 45 | 46 | - name: PHPStan 47 | run: composer phpstan 48 | 49 | - name: PHPUnit 50 | run: composer phpunit 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | vendor/ 3 | .phpunit.result.cache 4 | .phpcs.cache 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2019 Shapecode, Nikita Loges 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /UPGRADE-6.0.md: -------------------------------------------------------------------------------- 1 | UPGRADE FROM 5.x to 6.0 2 | ======================= 3 | 4 | Genereal 5 | ----- 6 | 7 | * Many internal changes and use of Symfony 5.4 features. 8 | * Use of PHP8.1 feature set 9 | 10 | Annotations/Attributes 11 | ----- 12 | 13 | * Removed `CronJob` Annotation, use `AsCronJob` Attribute instead. 14 | Before: 15 | ```php 16 | addPathToScan(__DIR__ . '/tests', true); 10 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shapecode/cron-bundle", 3 | "description": "This bundle provides scheduled execution of Symfony commands", 4 | "keywords": [ 5 | "cron", 6 | "cronjob", 7 | "command", 8 | "execute", 9 | "interval", 10 | "schedule", 11 | "shapecode", 12 | "symfony", 13 | "time", 14 | "bundle" 15 | ], 16 | "type": "symfony-bundle", 17 | "license": "MIT", 18 | "homepage": "https://github.com/shapecode/cron-bundle", 19 | "support": { 20 | "email": "support@shapeocode.de", 21 | "issues": "https://github.com/shapecode/cron-bundle/issues", 22 | "source": "https://github.com/shapecode/cron-bundle/releases", 23 | "wiki": "https://github.com/shapecode/cron-bundle/wiki" 24 | }, 25 | "authors": [ 26 | { 27 | "name": "Nikita Loges", 28 | "homepage": "https://loges.one", 29 | "email": "dev@loges.one" 30 | }, 31 | { 32 | "name": "Contributors", 33 | "homepage": "https://github.com/shapecode/cron-bundle/graphs/contributors" 34 | } 35 | ], 36 | "require": { 37 | "php": "^8.3", 38 | 39 | "symfony/framework-bundle": "^6.4 || ^7.0", 40 | "symfony/dependency-injection": "^6.4 || ^7.0", 41 | "symfony/http-kernel": "^6.4 || ^7.0", 42 | "symfony/config": "^6.4 || ^7.0", 43 | "symfony/console": "^6.4 || ^7.0", 44 | "symfony/http-foundation": "^6.4 || ^7.0", 45 | "symfony/process": "^6.4 || ^7.0", 46 | "symfony/stopwatch": "^6.4 || ^7.0", 47 | "symfony/event-dispatcher": "^6.4 || ^7.0", 48 | "symfony/event-dispatcher-contracts": "^3.5", 49 | 50 | "doctrine/doctrine-bundle": "^2.13", 51 | "doctrine/collections": "^2.2", 52 | "doctrine/persistence": "^3.4 || ^4.0", 53 | "doctrine/orm": "^2.20 || ^3.3", 54 | "doctrine/dbal": "^3.9 || ^4.2", 55 | 56 | "psr/clock": "^1.0", 57 | 58 | "dragonmantank/cron-expression": "^3.4", 59 | "ramsey/collection": "^2.0" 60 | }, 61 | "require-dev":{ 62 | "shipmonk/composer-dependency-analyser": "^1.8", 63 | "doctrine/coding-standard": "^12.0", 64 | "roave/security-advisories": "dev-latest", 65 | "squizlabs/php_codesniffer": "^3.11", 66 | "phpstan/phpstan": "^2.1", 67 | "phpstan/phpstan-deprecation-rules": "^2.0", 68 | "phpstan/phpstan-phpunit": "^2.0", 69 | "phpstan/phpstan-strict-rules": "^2.0", 70 | "phpstan/phpstan-symfony": "^2.0", 71 | "phpstan/phpstan-doctrine": "^2.0", 72 | "phpunit/phpunit": "^12.0", 73 | "symfony/var-dumper": "^7.2", 74 | "symfony/clock": "^7.2" 75 | }, 76 | "autoload": { 77 | "psr-4": { 78 | "Shapecode\\Bundle\\CronBundle\\": "src/" 79 | } 80 | }, 81 | "autoload-dev": { 82 | "psr-4": { 83 | "Shapecode\\Bundle\\CronBundle\\Tests\\": "tests/" 84 | } 85 | }, 86 | "scripts": { 87 | "check": [ 88 | "@cda", 89 | "@cs-check", 90 | "@phpstan", 91 | "@phpunit" 92 | ], 93 | "cda": "vendor/bin/composer-dependency-analyser --config=./composer-dependency-analyser.php", 94 | "phpstan": "phpstan analyse --configuration=./phpstan.php --memory-limit=-1 --ansi", 95 | "phpstan-update-baseline": "@phpstan --generate-baseline phpstan-baseline.php --allow-empty-baseline", 96 | "phpunit": "phpunit --colors=always", 97 | "cs-check": "phpcs -s", 98 | "cs-fix": "phpcbf" 99 | }, 100 | "extra": { 101 | "branch-alias": { 102 | "dev-master": "8.0-dev" 103 | } 104 | }, 105 | "minimum-stability": "dev", 106 | "prefer-stable": true, 107 | "config": { 108 | "preferred-install": "dist", 109 | "allow-plugins": { 110 | "dealerdirect/phpcodesniffer-composer-installer": true, 111 | "icanhazstring/composer-unused": true 112 | } 113 | }, 114 | "funding": [ 115 | { 116 | "type": "github", 117 | "url": "http://github.com/sponsors/nicklog" 118 | }, 119 | { 120 | "type": "paypal", 121 | "url": "http://paypal.me/nloges" 122 | }, 123 | { 124 | "type": "liberapay", 125 | "url": "https://liberapay.com/nicklog" 126 | } 127 | ] 128 | } 129 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Shapecode - Cron Bundle 2 | 3 | [![paypal](https://img.shields.io/badge/Donate-Paypal-blue.svg)](http://paypal.me/nloges) 4 | 5 | [![PHP Version](https://img.shields.io/packagist/php-v/shapecode/cron-bundle.svg)](https://packagist.org/packages/shapecode/cron-bundle) 6 | [![Latest Stable Version](https://img.shields.io/packagist/v/shapecode/cron-bundle.svg?label=stable)](https://packagist.org/packages/shapecode/cron-bundle) 7 | [![Latest Unstable Version](https://img.shields.io/packagist/vpre/shapecode/cron-bundle.svg?label=unstable)](https://packagist.org/packages/shapecode/cron-bundle) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/shapecode/cron-bundle.svg)](https://packagist.org/packages/shapecode/cron-bundle) 9 | [![Monthly Downloads](https://img.shields.io/packagist/dm/shapecode/cron-bundle.svg)](https://packagist.org/packages/shapecode/cron-bundle) 10 | [![Daily Downloads](https://img.shields.io/packagist/dd/shapecode/cron-bundle.svg)](https://packagist.org/packages/shapecode/cron-bundle) 11 | [![License](https://img.shields.io/packagist/l/shapecode/cron-bundle.svg)](https://packagist.org/packages/shapecode/cron-bundle) 12 | 13 | 14 | This bundle provides a simple interface for registering repeated scheduled 15 | tasks within your application. 16 | 17 | ## Install instructions 18 | 19 | Installing this bundle can be done through these simple steps: 20 | 21 | Add the bundle to your project through composer: 22 | ```bash 23 | composer require shapecode/cron-bundle 24 | ``` 25 | 26 | Add the bundle to your config if it flex did not do it for you: 27 | ```php 28 | disable a CronJob, run: `bin/console shapecode:cron:disable your:cron:job`, where `your:cron:job` is the name of the CronJob in your project you would like to disable. 103 | 104 | Running the above will disable your CronJob until you manually enable it again. Please note that even though the `next_run` field on the `cron_job` table will still hold a datetime value, your disabled cronjob will not be run. 105 | 106 | To enable a cron job, run: `bin/console shapecode:cron:enable your:cron:job`, where `your:cron:job` is the name of the CronJob in your project you would like to enable. 107 | 108 | ## Config 109 | 110 | By default, all cronjobs run until they are finished (or exceed the [default timeout of 60s set by the Process component](https://symfony.com/doc/current/components/process.html#process-timeout). When running cronjob from a controller, a timeout for running cronjobs 111 | can be useful as the HTTP request might get killed by PHP due to a maximum execution limit. By specifying a timeout, 112 | all jobs get killed automatically, and the correct job result (which would not indicate any success) will be persisted 113 | (see [#26](https://github.com/shapecode/cron-bundle/issues/26#issuecomment-731738093)). A default value of `null` lets the Process component use its default timeout, otherwise the specified timeout in seconds (as `float`) is applied (see [Process component docs](https://symfony.com/doc/current/components/process.html#process-timeout)). 114 | **Important:** The timeout is applied to every cronjob, regardless from where (controller or CLI) it is executed. 115 | 116 | ```yaml 117 | shapecode_cron: 118 | timeout: null # default. A number (of type float) can be specified 119 | ``` 120 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | src 13 | tests 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /phpstan-baseline.php: -------------------------------------------------------------------------------- 1 | [ 7 | __DIR__ . '/vendor/phpstan/phpstan/conf/bleedingEdge.neon', 8 | __DIR__ . '/vendor/phpstan/phpstan-strict-rules/rules.neon', 9 | __DIR__ . '/vendor/phpstan/phpstan-deprecation-rules/rules.neon', 10 | __DIR__ . '/vendor/phpstan/phpstan-phpunit/extension.neon', 11 | __DIR__ . '/vendor/phpstan/phpstan-phpunit/rules.neon', 12 | __DIR__ . '/vendor/phpstan/phpstan-symfony/extension.neon', 13 | __DIR__ . '/vendor/phpstan/phpstan-symfony/rules.neon', 14 | __DIR__ . '/vendor/phpstan/phpstan-doctrine/extension.neon', 15 | __DIR__ . '/vendor/phpstan/phpstan-doctrine/rules.neon', 16 | __DIR__ . '/phpstan-baseline.php', 17 | ], 18 | 'parameters' => [ 19 | 'level' => 'max', 20 | 'paths' => [ 21 | 'src', 22 | 'tests', 23 | ], 24 | ], 25 | ]; 26 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | tests 7 | 8 | 9 | 10 | 11 | src 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Attribute/AsCronJob.php: -------------------------------------------------------------------------------- 1 | */ 12 | final class CronJobCollection extends Collection 13 | { 14 | public function __construct( 15 | CronJob ...$cronJob, 16 | ) { 17 | parent::__construct(CronJob::class, $cronJob); 18 | } 19 | 20 | /** @return CollectionInterface */ 21 | public function mapToCommand(): CollectionInterface 22 | { 23 | return $this->map(static fn (CronJob $o): string => $o->getCommand()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Collection/CronJobMetadataCollection.php: -------------------------------------------------------------------------------- 1 | */ 11 | final class CronJobMetadataCollection extends Collection 12 | { 13 | public function __construct( 14 | CronJobMetadata ...$metadata, 15 | ) { 16 | parent::__construct(CronJobMetadata::class, $metadata); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Collection/CronJobRunningCollection.php: -------------------------------------------------------------------------------- 1 | */ 11 | final class CronJobRunningCollection extends Collection 12 | { 13 | public function __construct( 14 | CronJobRunning ...$runnings, 15 | ) { 16 | parent::__construct(CronJobRunning::class, $runnings); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Command/CronJobDisableCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('job', InputArgument::REQUIRED, 'Name or id of the job to disable'); 37 | } 38 | 39 | protected function execute( 40 | InputInterface $input, 41 | OutputInterface $output, 42 | ): int { 43 | $io = new CronStyle($input, $output); 44 | 45 | $nameOrId = $input->getArgument('job'); 46 | assert(is_string($nameOrId)); 47 | 48 | $jobs = $this->cronJobRepository->findByCommandOrId($nameOrId); 49 | 50 | if ($jobs->isEmpty()) { 51 | $io->error(sprintf('Couldn\'t find a job by the name or id of %s', $nameOrId)); 52 | 53 | return Command::FAILURE; 54 | } 55 | 56 | foreach ($jobs as $job) { 57 | $job->disable(); 58 | $this->entityManager->persist($job); 59 | } 60 | 61 | $this->entityManager->flush(); 62 | 63 | $io->success('cron disabled'); 64 | 65 | return Command::SUCCESS; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Command/CronJobEnableCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('job', InputArgument::REQUIRED, 'Name or id of the job to disable'); 37 | } 38 | 39 | protected function execute( 40 | InputInterface $input, 41 | OutputInterface $output, 42 | ): int { 43 | $io = new CronStyle($input, $output); 44 | 45 | $nameOrId = $input->getArgument('job'); 46 | assert(is_string($nameOrId)); 47 | 48 | $jobs = $this->cronJobRepository->findByCommandOrId($nameOrId); 49 | 50 | if ($jobs->isEmpty()) { 51 | $io->error(sprintf('Couldn\'t find a job by the name or id of %s', $nameOrId)); 52 | 53 | return Command::FAILURE; 54 | } 55 | 56 | foreach ($jobs as $job) { 57 | $job->enable(); 58 | $this->entityManager->persist($job); 59 | } 60 | 61 | $this->entityManager->flush(); 62 | 63 | $io->success('cron enabled'); 64 | 65 | return Command::SUCCESS; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Command/CronProcessCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('cron', InputArgument::REQUIRED); 49 | } 50 | 51 | protected function execute( 52 | InputInterface $input, 53 | OutputInterface $output, 54 | ): int { 55 | $io = new CronStyle($input, $output); 56 | 57 | $job = $this->cronJobRepository->find($input->getArgument('cron')); 58 | 59 | if ($job === null) { 60 | $io->error('No job found'); 61 | 62 | return Command::SUCCESS; 63 | } 64 | 65 | $command = sprintf('%s -n', $job->getFullCommand()); 66 | $watch = sprintf('job-%s', str_replace(' ', '-', $command)); 67 | 68 | $io->title(sprintf('Running %s', $command)); 69 | 70 | $jobInput = new StringInput($command); 71 | $jobOutput = new BufferedOutput(); 72 | 73 | if ( 74 | $jobInput->hasParameterOption([ 75 | '--quiet', 76 | '-q', 77 | ], true) === true 78 | ) { 79 | $jobOutput->setVerbosity(OutputInterface::VERBOSITY_QUIET); 80 | } 81 | 82 | $this->stopwatch->start($watch); 83 | 84 | if ($job->getRunningInstances() > $job->getMaxInstances()) { 85 | $statusCode = Command::INVALID; 86 | } else { 87 | try { 88 | $application = $this->getApplication(); 89 | 90 | if ($application === null) { 91 | throw new RuntimeException('application can not be bull', 1653426731910); 92 | } 93 | 94 | $statusCode = $application->doRun($jobInput, $jobOutput); 95 | } catch (Throwable $ex) { 96 | $statusCode = Command::FAILURE; 97 | $io->error(sprintf('Job execution failed with exception %s: %s', $ex::class, $ex->getMessage())); 98 | } 99 | } 100 | 101 | $this->stopwatch->stop($watch); 102 | 103 | $status = CronJobResultStatus::fromCommandStatus($statusCode); 104 | $duration = $this->stopwatch->getEvent($watch)->getDuration(); 105 | 106 | $seconds = $duration > 0 ? number_format($duration / 1000, 4) : 0; 107 | $message = sprintf('%s in %s seconds', $status->getStatusMessage(), $seconds); 108 | 109 | $callback = [$io, $status->getBlockName()]; 110 | if (is_callable($callback)) { 111 | $callback($message); 112 | } 113 | 114 | // reload job entity - it might be detached from current entity manager by the command 115 | $job = $this->cronJobRepository->find($job->getId()); 116 | if ($job === null) { 117 | throw new RuntimeException('job not found', 1653421395730); 118 | } 119 | 120 | // Record the result 121 | $this->recordJobResult($job, $duration, $jobOutput, $statusCode); 122 | 123 | return $statusCode; 124 | } 125 | 126 | private function recordJobResult( 127 | CronJob $job, 128 | float $timeTaken, 129 | BufferedOutput $output, 130 | int $statusCode, 131 | ): void { 132 | $buffer = $output->isQuiet() ? null : $output->fetch(); 133 | 134 | $result = new CronJobResult( 135 | $job, 136 | $timeTaken, 137 | $statusCode, 138 | $buffer, 139 | $this->clock->now(), 140 | ); 141 | 142 | $this->entityManager->persist($result); 143 | $this->entityManager->flush(); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Command/CronRunCommand.php: -------------------------------------------------------------------------------- 1 | clock->now()); 50 | 51 | $jobsToRun = $this->cronJobRepository->findAll(); 52 | 53 | $jobCount = count($jobsToRun); 54 | $style->comment(sprintf('Cron jobs started at %s', $now->format('r'))); 55 | 56 | $style->title('Execute cron jobs'); 57 | $style->info(sprintf('Found %d jobs', $jobCount)); 58 | 59 | $processes = new CronJobRunningCollection(); 60 | 61 | foreach ($jobsToRun as $job) { 62 | $style->section(sprintf('Running "%s"', $job->getFullCommand())); 63 | 64 | if (! $job->isEnable()) { 65 | $style->notice('cronjob is disabled'); 66 | 67 | continue; 68 | } 69 | 70 | if ($job->getNextRun() > $now) { 71 | $style->notice(sprintf('cronjob will not be executed. Next run is: %s', $job->getNextRun()->format('r'))); 72 | 73 | continue; 74 | } 75 | 76 | if ($job->getRunningInstances() >= $job->getMaxInstances()) { 77 | $style->notice('cronjob will not be executed. The number of maximum instances has been exceeded.'); 78 | continue; 79 | } 80 | 81 | $job->increaseRunningInstances(); 82 | $process = $this->runJob($job); 83 | 84 | $job->setLastUse($now); 85 | 86 | $this->entityManager->persist($job); 87 | $this->entityManager->flush(); 88 | 89 | $processes->add(new CronJobRunning($job, $process)); 90 | 91 | $style->success('cronjob started successfully and is running in background'); 92 | } 93 | 94 | $style->section('Summary'); 95 | 96 | if ($processes->isEmpty()) { 97 | $style->info('No jobs were executed.'); 98 | 99 | return Command::SUCCESS; 100 | } 101 | 102 | $style->text('waiting for all running jobs ...'); 103 | 104 | $this->waitProcesses($processes); 105 | 106 | $style->success('All jobs are finished.'); 107 | 108 | return Command::SUCCESS; 109 | } 110 | 111 | private function waitProcesses(CronJobRunningCollection $processes): void 112 | { 113 | while (count($processes) > 0) { 114 | foreach ($processes as $running) { 115 | try { 116 | $running->process->checkTimeout(); 117 | 118 | if ($running->process->isRunning() === true) { 119 | continue; 120 | } 121 | } catch (ProcessTimedOutException) { 122 | } 123 | 124 | $job = $running->cronJob; 125 | $this->entityManager->refresh($job); 126 | $job->decreaseRunningInstances(); 127 | 128 | if ($job->getRunningInstances() === 0) { 129 | $job->calculateNextRun(); 130 | } 131 | 132 | $this->entityManager->persist($job); 133 | $this->entityManager->flush(); 134 | 135 | $processes->remove($running); 136 | } 137 | 138 | sleep(1); 139 | } 140 | } 141 | 142 | private function runJob(CronJob $job): Process 143 | { 144 | $command = [ 145 | $this->commandHelper->getPhpExecutable(), 146 | $this->commandHelper->getConsoleBin(), 147 | CronProcessCommand::NAME, 148 | $job->getId(), 149 | ]; 150 | 151 | $process = new Process($command); 152 | $process->disableOutput(); 153 | 154 | $timeout = $this->commandHelper->getTimeout(); 155 | if ($timeout > 0) { 156 | $process->setTimeout($timeout); 157 | } 158 | 159 | $process->start(); 160 | 161 | return $process; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Command/CronScanCommand.php: -------------------------------------------------------------------------------- 1 | addOption('keep-deleted', 'k', InputOption::VALUE_NONE, 'If set, deleted cron jobs will not be removed') 42 | ->addOption('default-disabled', 'd', InputOption::VALUE_NONE, 'If set, new jobs will be disabled by default'); 43 | } 44 | 45 | protected function execute( 46 | InputInterface $input, 47 | OutputInterface $output, 48 | ): int { 49 | $io = new CronStyle($input, $output); 50 | $io->comment(sprintf('Scan for cron jobs started at %s', $this->clock->now()->format('r'))); 51 | $io->title('scanning ...'); 52 | 53 | $keepDeleted = (bool) $input->getOption('keep-deleted'); 54 | $defaultDisabled = (bool) $input->getOption('default-disabled'); 55 | 56 | // Enumerate the known jobs 57 | $cronJobs = $this->cronJobRepository->findAllCollection(); 58 | $knownJobs = $cronJobs->mapToCommand(); 59 | 60 | $counter = new CronJobCounter(); 61 | foreach ($this->cronJobManager->getJobs() as $jobMetadata) { 62 | $command = $jobMetadata->command; 63 | 64 | $io->section($command); 65 | 66 | $counter->increase($jobMetadata); 67 | 68 | if ($knownJobs->contains($command)) { 69 | // Clear it from the known jobs so that we don't try to delete it 70 | $knownJobs->remove($command); 71 | 72 | // Update the job if necessary 73 | $currentJob = $this->cronJobRepository->findOneByCommand($command, $counter->value($jobMetadata)); 74 | 75 | if ($currentJob === null) { 76 | continue; 77 | } 78 | 79 | $currentJob->setDescription($jobMetadata->description); 80 | $currentJob->setArguments($jobMetadata->arguments); 81 | 82 | $io->text(sprintf('command: %s', $jobMetadata->command)); 83 | $io->text(sprintf('arguments: %s', $jobMetadata->arguments)); 84 | $io->text(sprintf('expression: %s', $jobMetadata->expression)); 85 | $io->text(sprintf('instances: %s', $jobMetadata->maxInstances)); 86 | 87 | if ( 88 | $currentJob->getPeriod() !== $jobMetadata->expression || 89 | $currentJob->getMaxInstances() !== $jobMetadata->maxInstances || 90 | $currentJob->getArguments() !== $jobMetadata->arguments 91 | ) { 92 | $currentJob->setPeriod($jobMetadata->expression); 93 | $currentJob->setArguments($jobMetadata->arguments); 94 | $currentJob->setMaxInstances($jobMetadata->maxInstances); 95 | 96 | $currentJob->calculateNextRun(); 97 | $io->notice('cronjob updated'); 98 | } 99 | } else { 100 | $this->newJobFound($io, $jobMetadata, $defaultDisabled, $counter->value($jobMetadata)); 101 | } 102 | } 103 | 104 | $io->success('Finished scanning for cron jobs'); 105 | 106 | // Clear any jobs that weren't found 107 | if ($keepDeleted === false) { 108 | $io->title('remove cron jobs'); 109 | 110 | if (! $knownJobs->isEmpty()) { 111 | foreach ($knownJobs as $deletedJob) { 112 | $io->notice(sprintf('Deleting job: %s', $deletedJob)); 113 | $jobsToDelete = $this->cronJobRepository->findByCommandOrId($deletedJob); 114 | foreach ($jobsToDelete as $jobToDelete) { 115 | $this->entityManager->remove($jobToDelete); 116 | } 117 | } 118 | } else { 119 | $io->info('No cronjob has to be removed.'); 120 | } 121 | } 122 | 123 | $this->entityManager->flush(); 124 | 125 | return Command::SUCCESS; 126 | } 127 | 128 | private function newJobFound( 129 | CronStyle $io, 130 | CronJobMetadata $metadata, 131 | bool $defaultDisabled, 132 | int $counter, 133 | ): void { 134 | $newJob = 135 | CronJob::create( 136 | $metadata->command, 137 | $metadata->expression, 138 | ) 139 | ->setArguments($metadata->arguments) 140 | ->setDescription($metadata->description) 141 | ->setEnable(! $defaultDisabled) 142 | ->setNumber($counter) 143 | ->calculateNextRun(); 144 | 145 | $io->success(sprintf( 146 | 'Found new job: "%s" with period %s', 147 | $newJob->getFullCommand(), 148 | $newJob->getPeriod(), 149 | )); 150 | 151 | $this->entityManager->persist($newJob); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Command/CronStatusCommand.php: -------------------------------------------------------------------------------- 1 | title('Cron job status'); 36 | 37 | $tableContent = array_map( 38 | static fn (CronJob $cronJob): array => [ 39 | $cronJob->getId(), 40 | $cronJob->getFullCommand(), 41 | $cronJob->isEnable() ? $cronJob->getNextRun()->format('r') : 'Not scheduled', 42 | $cronJob->getLastUse()?->format('r') ?? 'This job has not yet been run', 43 | $cronJob->isEnable() ? 'Enabled' : 'Disabled', 44 | ], 45 | $this->cronJobRepository->findAll(), 46 | ); 47 | 48 | $io->table( 49 | [ 50 | 'ID', 51 | 'Command', 52 | 'Next Schedule', 53 | 'Last run', 54 | 'Enabled', 55 | ], 56 | $tableContent, 57 | ); 58 | 59 | return Command::SUCCESS; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Console/Style/CronStyle.php: -------------------------------------------------------------------------------- 1 | $message */ 12 | // phpcs:ignore SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint 13 | public function info($message): void 14 | { 15 | $this->block($message, 'Info', 'fg=white;bg=blue', ' ', true); 16 | } 17 | 18 | /** @param string|list $message */ 19 | public function notice(string|array $message): void 20 | { 21 | $this->block($message, 'Note', 'fg=black;bg=yellow', ' ', true); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Controller/CronJobController.php: -------------------------------------------------------------------------------- 1 | kernel); 24 | $application->setAutoExit(false); 25 | 26 | $input = new StringInput(CronRunCommand::NAME); 27 | $output = new BufferedOutput(); 28 | 29 | $application->run($input, $output); 30 | 31 | return new Response($output->fetch()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/CronJob/CommandHelper.php: -------------------------------------------------------------------------------- 1 | consoleBin === null) { 29 | $projectDir = $this->kernel->getProjectDir(); 30 | 31 | $consolePath = sprintf('%s/bin/console', $projectDir); 32 | 33 | if (! file_exists($consolePath)) { 34 | throw new RuntimeException('Missing console binary', 1653426744265); 35 | } 36 | 37 | $this->consoleBin = $consolePath; 38 | } 39 | 40 | return $this->consoleBin; 41 | } 42 | 43 | public function getPhpExecutable(): string 44 | { 45 | if ($this->phpExecutable === null) { 46 | $php = (new PhpExecutableFinder())->find(); 47 | 48 | if ($php === false) { 49 | throw new RuntimeException('Unable to find the PHP executable.', 1653426749950); 50 | } 51 | 52 | $this->phpExecutable = $php; 53 | } 54 | 55 | return $this->phpExecutable; 56 | } 57 | 58 | public function getTimeout(): float|null 59 | { 60 | return $this->timeout; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/CronJob/CronJobManager.php: -------------------------------------------------------------------------------- 1 | metadataCollection = new CronJobMetadataCollection(); 19 | } 20 | 21 | public function getJobs(): CronJobMetadataCollection 22 | { 23 | if ($this->metadataCollection->isEmpty()) { 24 | $this->eventDispatcher->dispatch(new LoadJobsEvent($this->metadataCollection)); 25 | } 26 | 27 | return $this->metadataCollection; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/CronJobCompilerPass.php: -------------------------------------------------------------------------------- 1 | findDefinition(ServiceJobLoaderListener::class); 22 | 23 | $tagged = $container->findTaggedServiceIds(self::CRON_JOB_TAG_ID); 24 | 25 | foreach ($tagged as $id => $configs) { 26 | foreach ($configs as $config) { 27 | if (! is_array($config)) { 28 | throw new RuntimeException('config must be an array', 1740941125172); 29 | } 30 | 31 | if (! isset($config['expression'])) { 32 | throw new RuntimeException('missing expression config', 1653426737628); 33 | } 34 | 35 | $expression = $config['expression']; 36 | $arguments = $config['arguments'] ?? null; 37 | $maxInstances = $config['maxInstances'] ?? 1; 38 | 39 | $definition->addMethodCall('addCommand', [ 40 | $expression, 41 | new Reference($id), 42 | $arguments, 43 | $maxInstances, 44 | ]); 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode() 17 | ->children() 18 | ->floatNode('timeout') 19 | ->defaultNull() 20 | ->end() 21 | ->end(); 22 | 23 | return $treeBuilder; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/DependencyInjection/ShapecodeCronExtension.php: -------------------------------------------------------------------------------- 1 | $mergedConfig */ 20 | protected function loadInternal( 21 | array $mergedConfig, 22 | ContainerBuilder $container, 23 | ): void { 24 | $locator = new FileLocator(__DIR__ . '/../Resources/config'); 25 | $loader = new Loader\YamlFileLoader($container, $locator); 26 | $loader->load('services.yml'); 27 | 28 | $definition = $container->getDefinition(CommandHelper::class); 29 | $definition->setArgument('$timeout', $mergedConfig['timeout']); 30 | 31 | $container->registerAttributeForAutoconfiguration( 32 | AsCronJob::class, 33 | static function (ChildDefinition $definition, AsCronJob $attribute, Reflector $reflector): void { 34 | $definition->addTag(CronJobCompilerPass::CRON_JOB_TAG_ID, [ 35 | 'expression' => $attribute->schedule, 36 | 'arguments' => $attribute->arguments, 37 | 'maxInstances' => $attribute->maxInstances, 38 | ]); 39 | }, 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Domain/CronJobCounter.php: -------------------------------------------------------------------------------- 1 | */ 12 | private array $counter = []; 13 | 14 | public function increase(CronJobMetadata $metadata): void 15 | { 16 | if (! array_key_exists($metadata->command, $this->counter)) { 17 | $this->counter[$metadata->command] = 0; 18 | } 19 | 20 | $this->counter[$metadata->command]++; 21 | } 22 | 23 | public function value(CronJobMetadata $metadata): int 24 | { 25 | if (! array_key_exists($metadata->command, $this->counter)) { 26 | return 0; 27 | } 28 | 29 | return $this->counter[$metadata->command]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Domain/CronJobMetadata.php: -------------------------------------------------------------------------------- 1 | getName(); 30 | 31 | if ($commandName === null) { 32 | throw new RuntimeException('command has to have a name provided', 1653426725688); 33 | } 34 | 35 | return new self( 36 | str_replace('\\', '', $expression), 37 | $commandName, 38 | $arguments, 39 | $maxInstances, 40 | $command->getDescription(), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Domain/CronJobResultStatus.php: -------------------------------------------------------------------------------- 1 | self::SUCCEEDED, 18 | 2 => self::SKIPPED, 19 | default => self::FAILED, 20 | }; 21 | } 22 | 23 | public function getStatusMessage(): string 24 | { 25 | return match ($this) { 26 | self::SUCCEEDED => 'succeeded', 27 | self::FAILED => 'failed', 28 | self::SKIPPED => 'skipped', 29 | }; 30 | } 31 | 32 | public function getBlockName(): string 33 | { 34 | return match ($this) { 35 | self::SUCCEEDED => 'success', 36 | self::FAILED => 'error', 37 | self::SKIPPED => 'info', 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Domain/CronJobRunning.php: -------------------------------------------------------------------------------- 1 | true])] 14 | #[ORM\Id] 15 | #[ORM\GeneratedValue(strategy: 'AUTO')] 16 | protected int|null $id = null; 17 | 18 | #[ORM\Column(type: Types::DATETIME_MUTABLE)] 19 | protected DateTimeInterface|null $createdAt = null; 20 | 21 | #[ORM\Column(type: Types::DATETIME_MUTABLE)] 22 | protected DateTimeInterface|null $updatedAt = null; 23 | 24 | public function getId(): int|null 25 | { 26 | return $this->id; 27 | } 28 | 29 | public function setCreatedAt(DateTimeInterface $createdAt): static 30 | { 31 | $this->createdAt = $createdAt; 32 | 33 | return $this; 34 | } 35 | 36 | public function getCreatedAt(): DateTimeInterface|null 37 | { 38 | return $this->createdAt; 39 | } 40 | 41 | public function setUpdatedAt(DateTimeInterface $updatedAt): static 42 | { 43 | $this->updatedAt = $updatedAt; 44 | 45 | return $this; 46 | } 47 | 48 | public function getUpdatedAt(): DateTimeInterface|null 49 | { 50 | return $this->updatedAt; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Entity/CronJob.php: -------------------------------------------------------------------------------- 1 | true, 'default' => 0])] 29 | private int $runningInstances = 0; 30 | 31 | #[ORM\Column(type: Types::INTEGER, options: ['unsigned' => true, 'default' => 1])] 32 | private int $maxInstances = 1; 33 | 34 | #[ORM\Column(type: Types::INTEGER, options: ['unsigned' => true, 'default' => 1])] 35 | private int $number = 1; 36 | 37 | #[ORM\Column(type: Types::STRING)] 38 | private string $period; 39 | 40 | #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)] 41 | private DateTimeInterface|null $lastUse = null; 42 | 43 | #[ORM\Column(type: Types::DATETIME_MUTABLE)] 44 | private DateTimeInterface $nextRun; 45 | 46 | /** @var Collection*/ 47 | #[ORM\OneToMany(targetEntity: CronJobResult::class, mappedBy: 'cronJob', cascade: ['persist', 'remove'], orphanRemoval: true)] 48 | private Collection $results; 49 | 50 | #[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])] 51 | private bool $enable = true; 52 | 53 | public function __construct( 54 | string $command, 55 | string $period, 56 | ) { 57 | $this->command = $command; 58 | $this->period = $period; 59 | $this->results = new ArrayCollection(); 60 | 61 | $this->calculateNextRun(); 62 | } 63 | 64 | public static function create( 65 | string $command, 66 | string $period, 67 | ): self { 68 | return new self($command, $period); 69 | } 70 | 71 | public function getCommand(): string 72 | { 73 | return $this->command; 74 | } 75 | 76 | public function getFullCommand(): string 77 | { 78 | $arguments = ''; 79 | 80 | if ($this->getArguments() !== null) { 81 | $arguments = ' ' . $this->getArguments(); 82 | } 83 | 84 | return $this->getCommand() . $arguments; 85 | } 86 | 87 | public function getArguments(): string|null 88 | { 89 | return $this->arguments; 90 | } 91 | 92 | public function setArguments(string|null $arguments): self 93 | { 94 | $this->arguments = $arguments; 95 | 96 | return $this; 97 | } 98 | 99 | public function getDescription(): string|null 100 | { 101 | return $this->description; 102 | } 103 | 104 | public function setDescription(string|null $description): self 105 | { 106 | $this->description = $description; 107 | 108 | return $this; 109 | } 110 | 111 | public function getRunningInstances(): int 112 | { 113 | return $this->runningInstances; 114 | } 115 | 116 | public function increaseRunningInstances(): self 117 | { 118 | ++$this->runningInstances; 119 | 120 | return $this; 121 | } 122 | 123 | public function decreaseRunningInstances(): self 124 | { 125 | --$this->runningInstances; 126 | 127 | return $this; 128 | } 129 | 130 | public function getMaxInstances(): int 131 | { 132 | return $this->maxInstances; 133 | } 134 | 135 | public function setMaxInstances(int $maxInstances): self 136 | { 137 | $this->maxInstances = $maxInstances; 138 | 139 | return $this; 140 | } 141 | 142 | public function getNumber(): int 143 | { 144 | return $this->number; 145 | } 146 | 147 | public function setNumber(int $number): self 148 | { 149 | $this->number = $number; 150 | 151 | return $this; 152 | } 153 | 154 | public function getPeriod(): string 155 | { 156 | return $this->period; 157 | } 158 | 159 | public function setPeriod(string $period): self 160 | { 161 | $this->period = $period; 162 | 163 | return $this; 164 | } 165 | 166 | public function getLastUse(): DateTimeInterface|null 167 | { 168 | return $this->lastUse; 169 | } 170 | 171 | public function setLastUse(DateTimeInterface $lastUse): self 172 | { 173 | $this->lastUse = DateTime::createFromInterface($lastUse); 174 | 175 | return $this; 176 | } 177 | 178 | public function setNextRun(DateTimeInterface $nextRun): self 179 | { 180 | $this->nextRun = DateTime::createFromInterface($nextRun); 181 | 182 | return $this; 183 | } 184 | 185 | public function getNextRun(): DateTimeInterface 186 | { 187 | return $this->nextRun; 188 | } 189 | 190 | /** @return Collection */ 191 | public function getResults(): Collection 192 | { 193 | return $this->results; 194 | } 195 | 196 | public function setEnable(bool $enable): self 197 | { 198 | $this->enable = $enable; 199 | 200 | return $this; 201 | } 202 | 203 | public function isEnable(): bool 204 | { 205 | return $this->enable; 206 | } 207 | 208 | public function enable(): self 209 | { 210 | return $this->setEnable(true); 211 | } 212 | 213 | public function disable(): self 214 | { 215 | return $this->setEnable(false); 216 | } 217 | 218 | public function calculateNextRun(): self 219 | { 220 | $cron = new CronExpression($this->getPeriod()); 221 | $this->setNextRun($cron->getNextRunDate()); 222 | 223 | return $this; 224 | } 225 | 226 | public function __toString(): string 227 | { 228 | return $this->getCommand(); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/Entity/CronJobResult.php: -------------------------------------------------------------------------------- 1 | runTime = $runTime; 42 | $this->statusCode = $statusCode; 43 | $this->output = $output; 44 | $this->cronJob = $cronJob; 45 | $this->runAt = DateTime::createFromInterface($runAt); 46 | } 47 | 48 | public function getRunAt(): DateTimeInterface 49 | { 50 | return $this->runAt; 51 | } 52 | 53 | public function getRunTime(): float 54 | { 55 | return $this->runTime; 56 | } 57 | 58 | public function getStatusCode(): int 59 | { 60 | return $this->statusCode; 61 | } 62 | 63 | public function getOutput(): string|null 64 | { 65 | return $this->output; 66 | } 67 | 68 | public function getCronJob(): CronJob 69 | { 70 | return $this->cronJob; 71 | } 72 | 73 | public function __toString(): string 74 | { 75 | return sprintf( 76 | '%s - %s', 77 | $this->getCronJob()->getCommand(), 78 | $this->getRunAt()->format('d.m.Y H:i P'), 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Event/LoadJobsEvent.php: -------------------------------------------------------------------------------- 1 | metadataCollection->add($cronJobMetadata); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/EventListener/EntitySubscriber.php: -------------------------------------------------------------------------------- 1 | $args */ 25 | public function prePersist(LifecycleEventArgs $args): void 26 | { 27 | $this->setDates($args); 28 | } 29 | 30 | /** @param LifecycleEventArgs $args */ 31 | public function preUpdate(LifecycleEventArgs $args): void 32 | { 33 | $this->setDates($args); 34 | } 35 | 36 | /** @param LifecycleEventArgs $args */ 37 | private function setDates(LifecycleEventArgs $args): void 38 | { 39 | $entity = $args->getObject(); 40 | 41 | if (! $entity instanceof AbstractEntity) { 42 | return; 43 | } 44 | 45 | $now = DateTime::createFromImmutable($this->clock->now()); 46 | 47 | if ($entity->getCreatedAt() === null) { 48 | $entity->setCreatedAt($now); 49 | } 50 | 51 | $entity->setUpdatedAt($now); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/EventListener/ServiceJobLoaderListener.php: -------------------------------------------------------------------------------- 1 | metadataCollection = new CronJobMetadataCollection(); 21 | } 22 | 23 | public function __invoke(LoadJobsEvent $event): void 24 | { 25 | foreach ($this->metadataCollection as $job) { 26 | $event->addJob($job); 27 | } 28 | } 29 | 30 | public function addCommand( 31 | string $expression, 32 | Command $command, 33 | string|null $arguments = null, 34 | int $maxInstances = 1, 35 | ): void { 36 | $this->metadataCollection->add( 37 | CronJobMetadata::createByCommand($expression, $command, $arguments, $maxInstances), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Repository/CronJobRepository.php: -------------------------------------------------------------------------------- 1 | */ 14 | class CronJobRepository extends ServiceEntityRepository 15 | { 16 | public function __construct(ManagerRegistry $registry) 17 | { 18 | parent::__construct($registry, CronJob::class); 19 | } 20 | 21 | public function findOneByCommand( 22 | string $command, 23 | int $number = 1, 24 | ): CronJob|null { 25 | return $this->findOneBy([ 26 | 'command' => $command, 27 | 'number' => $number, 28 | ]); 29 | } 30 | 31 | public function findAllCollection(): CronJobCollection 32 | { 33 | return new CronJobCollection(...$this->findAll()); 34 | } 35 | 36 | public function findByCommandOrId(string $commandOrId): CronJobCollection 37 | { 38 | $qb = $this->createQueryBuilder('p'); 39 | 40 | /** @var list $result */ 41 | $result = $qb 42 | ->andWhere( 43 | $qb->expr()->orX( 44 | 'p.command = :command', 45 | 'p.id = :commandInt', 46 | ), 47 | ) 48 | ->setParameter('command', $commandOrId, Types::STRING) 49 | ->setParameter('commandInt', (int) $commandOrId, Types::INTEGER) 50 | ->getQuery() 51 | ->getResult(); 52 | 53 | return new CronJobCollection(...$result); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Repository/CronJobResultRepository.php: -------------------------------------------------------------------------------- 1 | */ 13 | class CronJobResultRepository extends ServiceEntityRepository 14 | { 15 | public function __construct(ManagerRegistry $registry) 16 | { 17 | parent::__construct($registry, CronJobResult::class); 18 | } 19 | 20 | public function deleteOldLogs(DateTimeInterface $time): void 21 | { 22 | $this->getEntityManager()->createQuery( 23 | <<<'DQL' 24 | DELETE FROM Shapecode\Bundle\CronBundle\Entity\CronJobResult d 25 | WHERE d.createdAt <= :createdAt 26 | DQL, 27 | ) 28 | ->setParameter('createdAt', $time) 29 | ->execute(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Resources/config/routing.yml: -------------------------------------------------------------------------------- 1 | cronjob_run: 2 | path: /run 3 | controller: Shapecode\Bundle\CronBundle\Controller\CronJobController 4 | methods: GET 5 | -------------------------------------------------------------------------------- /src/Resources/config/services.yml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: false 6 | 7 | Shapecode\Bundle\CronBundle\: 8 | resource: '../../*' 9 | exclude: 10 | - '../../Attribute' 11 | - '../../Collection' 12 | - '../../Console' 13 | - '../../DependencyInjection' 14 | - '../../Domain' 15 | - '../../Entity' 16 | - '../../Event' 17 | - '../../Resources' 18 | - '../../ShapecodeCronBundle.php' 19 | -------------------------------------------------------------------------------- /src/ShapecodeCronBundle.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new CronJobCompilerPass(), PassConfig::TYPE_AFTER_REMOVING); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Command/CronRunCommandTest.php: -------------------------------------------------------------------------------- 1 | kernel = $this->createMock(Kernel::class); 43 | $this->manager = $this->createMock(EntityManagerInterface::class); 44 | $this->commandHelper = $this->createMock(CommandHelper::class); 45 | $this->cronJobRepo = $this->createMock(CronJobRepository::class); 46 | $this->input = $this->createMock(InputInterface::class); 47 | $this->output = new BufferedOutput(); 48 | 49 | $this->clock = new MockClock(); 50 | 51 | $this->command = new CronRunCommand( 52 | $this->manager, 53 | $this->cronJobRepo, 54 | $this->commandHelper, 55 | $this->clock, 56 | ); 57 | } 58 | 59 | public function testRun(): void 60 | { 61 | $this->kernel->method('getProjectDir')->willReturn(__DIR__); 62 | 63 | $this->commandHelper->method('getConsoleBin')->willReturn('/bin/console'); 64 | $this->commandHelper->method('getPhpExecutable')->willReturn('php'); 65 | $this->commandHelper->method('getTimeout')->willReturn(null); 66 | 67 | $job = CronJob::create('pwd', '* * * * *'); 68 | $job->setNextRun(new DateTime()); 69 | 70 | $this->cronJobRepo->method('findAll')->willReturn([ 71 | $job, 72 | ]); 73 | 74 | $this->command->run($this->input, $this->output); 75 | 76 | self::assertSame('shapecode:cron:run', $this->command->getName()); 77 | } 78 | 79 | public function testRunWithTimeout(): void 80 | { 81 | $this->kernel->method('getProjectDir')->willReturn(__DIR__); 82 | 83 | $this->commandHelper->method('getConsoleBin')->willReturn('/bin/console'); 84 | $this->commandHelper->method('getPhpExecutable')->willReturn('php'); 85 | $this->commandHelper->method('getTimeout')->willReturn(30.0); 86 | 87 | $this->manager = $this->createMock(EntityManagerInterface::class); 88 | 89 | $job = CronJob::create('pwd', '* * * * *'); 90 | $job->setNextRun(new DateTime()); 91 | 92 | $this->cronJobRepo->method('findAll')->willReturn([ 93 | $job, 94 | ]); 95 | 96 | $this->command->run($this->input, $this->output); 97 | 98 | self::assertSame('shapecode:cron:run', $this->command->getName()); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/CronJob/CronJobManagerTest.php: -------------------------------------------------------------------------------- 1 | createMock(Command::class); 22 | $command->method('getName')->willReturn($commandName); 23 | 24 | $eventDispatcher = $this->createMock(EventDispatcherInterface::class); 25 | $eventDispatcher 26 | ->expects(self::once()) 27 | ->method('dispatch') 28 | ->willReturnCallback( 29 | static function (LoadJobsEvent $event) use ($expression, $command): LoadJobsEvent { 30 | $event->addJob( 31 | CronJobMetadata::createByCommand($expression, $command), 32 | ); 33 | 34 | return $event; 35 | }, 36 | ); 37 | 38 | $cronJobManager = new CronJobManager($eventDispatcher); 39 | 40 | $jobs = $cronJobManager->getJobs(); 41 | 42 | self::assertCount(1, $jobs); 43 | self::assertSame($commandName, $jobs->first()->command); 44 | self::assertSame($expression, $jobs->first()->expression); 45 | 46 | // Run second time to assert the same result. 47 | $jobs = $cronJobManager->getJobs(); 48 | 49 | self::assertCount(1, $jobs); 50 | self::assertSame($commandName, $jobs->first()->command); 51 | self::assertSame($expression, $jobs->first()->expression); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Entity/CronJobResultTest.php: -------------------------------------------------------------------------------- 1 | __toString()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Entity/CronJobTest.php: -------------------------------------------------------------------------------- 1 | setLastUse(new DatePoint()); 19 | $job->calculateNextRun(); 20 | 21 | self::assertSame('command', $job->getCommand()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/EventListener/EntitySubscriberTest.php: -------------------------------------------------------------------------------- 1 | clock = $this->createMock(ClockInterface::class); 27 | $this->subscriber = new EntitySubscriber($this->clock); 28 | } 29 | 30 | public function testPrePersistSetsCreatedAtAndUpdatedAtWhenNull(): void 31 | { 32 | $now = new DateTimeImmutable('2024-10-10 12:00:00'); 33 | $this->clock->method('now')->willReturn($now); 34 | 35 | $entity = $this->createMock(AbstractEntity::class); 36 | 37 | $entity->expects(self::once()) 38 | ->method('setCreatedAt') 39 | ->with(self::isInstanceOf(DateTime::class)); 40 | 41 | $entity->expects(self::once()) 42 | ->method('setUpdatedAt') 43 | ->with(self::isInstanceOf(DateTime::class)); 44 | 45 | $entity->method('getCreatedAt')->willReturn(null); 46 | 47 | $entityManager = $this->createMock(EntityManagerInterface::class); 48 | $args = new LifecycleEventArgs($entity, $entityManager); 49 | 50 | $this->subscriber->prePersist($args); 51 | } 52 | 53 | public function testPreUpdateSetsUpdatedAt(): void 54 | { 55 | $now = new DateTimeImmutable('2024-10-10 12:00:00'); 56 | $this->clock->method('now')->willReturn($now); 57 | 58 | $entity = $this->createMock(AbstractEntity::class); 59 | 60 | $entity->expects(self::never()) 61 | ->method('setCreatedAt'); 62 | 63 | $entity->expects(self::once()) 64 | ->method('setUpdatedAt') 65 | ->with(self::isInstanceOf(DateTime::class)); 66 | 67 | $entity->method('getCreatedAt')->willReturn(new DateTime()); 68 | 69 | $entityManager = $this->createMock(EntityManagerInterface::class); 70 | $args = new LifecycleEventArgs($entity, $entityManager); 71 | 72 | $this->subscriber->preUpdate($args); 73 | } 74 | 75 | public function testEntityNotInstanceOfAbstractEntity(): void 76 | { 77 | $nonEntity = new stdClass(); 78 | 79 | $entityManager = $this->createMock(EntityManagerInterface::class); 80 | $args = new LifecycleEventArgs($nonEntity, $entityManager); 81 | 82 | $this->subscriber->prePersist($args); 83 | $this->subscriber->preUpdate($args); 84 | 85 | $this->expectNotToPerformAssertions(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/Fixtures/bin/console: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapecode/cron-bundle/58558b9c4280532f213b40d5a9d395d4e80e65ca/tests/Fixtures/bin/console -------------------------------------------------------------------------------- /tests/Service/CommandHelperTest.php: -------------------------------------------------------------------------------- 1 | createMock(Kernel::class); 23 | $kernel->method('getProjectDir')->willReturn($path); 24 | 25 | $helper = new CommandHelper($kernel); 26 | 27 | self::assertEquals( 28 | sprintf('%s/bin/console', $path), 29 | $helper->getConsoleBin(), 30 | ); 31 | } 32 | 33 | public function testGetPhpExecutable(): void 34 | { 35 | $kernel = $this->createMock(Kernel::class); 36 | $kernel->method('getProjectDir')->willReturn(__DIR__); 37 | 38 | $helper = new CommandHelper($kernel); 39 | 40 | self::assertEquals( 41 | PHP_BINARY, 42 | $helper->getPhpExecutable(), 43 | ); 44 | } 45 | } 46 | --------------------------------------------------------------------------------