├── .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 | [](http://paypal.me/nloges)
4 |
5 | [](https://packagist.org/packages/shapecode/cron-bundle)
6 | [](https://packagist.org/packages/shapecode/cron-bundle)
7 | [](https://packagist.org/packages/shapecode/cron-bundle)
8 | [](https://packagist.org/packages/shapecode/cron-bundle)
9 | [](https://packagist.org/packages/shapecode/cron-bundle)
10 | [](https://packagist.org/packages/shapecode/cron-bundle)
11 | [](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 |
--------------------------------------------------------------------------------