From ae2541cd2e9fa9f5d0ff225432a541c4f34ca213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Barto=C5=A1?= Date: Fri, 26 Jan 2024 22:24:54 +0100 Subject: [PATCH] orisai/scheduler:^2.0.0 compatibility --- CHANGELOG.md | 10 + composer.json | 3 +- docs/README.md | 293 +++++++++++++++++- src/DI/LazyJobManager.php | 126 +++++--- src/DI/LazyJobManagerV1.php | 128 ++++++++ src/DI/SchedulerExtension.php | 215 ++++++++++--- ...entHandler.php => TestJobEventHandler.php} | 2 +- tests/Doubles/TestLockedJobEventHandler.php | 34 ++ tests/Doubles/TestRunEventHandler.php | 36 +++ tests/Unit/DI/LazyJobManager.empty.neon | 3 +- tests/Unit/DI/LazyJobManager.invalidType.neon | 12 +- tests/Unit/DI/LazyJobManager.neon | 28 +- tests/Unit/DI/LazyJobManagerTest.php | 89 +++--- tests/Unit/DI/LazyJobManagerV1.empty.neon | 5 + .../Unit/DI/LazyJobManagerV1.invalidType.neon | 11 + tests/Unit/DI/LazyJobManagerV1.neon | 17 + tests/Unit/DI/LazyJobManagerV1Test.php | 147 +++++++++ .../SchedulerExtension.invalidTimeZone.neon | 12 + ...neon => SchedulerExtension.jobEvents.neon} | 10 +- .../DI/SchedulerExtension.jobSchedules.neon | 22 ++ .../SchedulerExtension.lockedJobEvents.neon | 22 ++ .../Unit/DI/SchedulerExtension.runEvents.neon | 26 ++ tests/Unit/DI/SchedulerExtensionTest.php | 151 ++++++++- tools/phpstan.baseline.neon | 37 ++- tools/phpstan.neon | 4 + 25 files changed, 1245 insertions(+), 198 deletions(-) create mode 100644 src/DI/LazyJobManagerV1.php rename tests/Doubles/{TestEventHandler.php => TestJobEventHandler.php} (94%) create mode 100644 tests/Doubles/TestLockedJobEventHandler.php create mode 100644 tests/Doubles/TestRunEventHandler.php create mode 100644 tests/Unit/DI/LazyJobManagerV1.empty.neon create mode 100644 tests/Unit/DI/LazyJobManagerV1.invalidType.neon create mode 100644 tests/Unit/DI/LazyJobManagerV1.neon create mode 100644 tests/Unit/DI/LazyJobManagerV1Test.php create mode 100644 tests/Unit/DI/SchedulerExtension.invalidTimeZone.neon rename tests/Unit/DI/{SchedulerExtension.events.neon => SchedulerExtension.jobEvents.neon} (54%) create mode 100644 tests/Unit/DI/SchedulerExtension.jobSchedules.neon create mode 100644 tests/Unit/DI/SchedulerExtension.lockedJobEvents.neon create mode 100644 tests/Unit/DI/SchedulerExtension.runEvents.neon diff --git a/CHANGELOG.md b/CHANGELOG.md index c98cc50..46c8477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased](https://github.com/orisai/nette-scheduler/compare/1.0.2...HEAD) +### Added + +- compatibility with orisai/scheduler:^2.0.0 +- planning jobs by seconds +- timezones support +- locked job, before run and after run events +- job results are shown in console immediately +- stderr handling in subprocesses - causes an exception +- stdout handling in subprocesses - causes a notice, instead of an exception + ## [1.0.2](https://github.com/orisai/nette-scheduler/compare/1.0.1...1.0.2) - 2023-10-05 ### Added diff --git a/composer.json b/composer.json index cc93b51..8aa752e 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ "nette/schema": "^1.1.0", "orisai/exceptions": "^1.0.0", "orisai/nette-di": "^1.0.7", - "orisai/scheduler": "^1.0.0" + "orisai/scheduler": "^1.0.0|^2.0.0" }, "require-dev": { "brianium/paratest": "^6.3.0", @@ -45,6 +45,7 @@ "phpstan/extension-installer": "^1.0.0", "phpstan/phpstan": "^1.0.0", "phpstan/phpstan-deprecation-rules": "^1.0.0", + "phpstan/phpstan-nette": "^1.2.0", "phpstan/phpstan-phpunit": "^1.0.0", "phpstan/phpstan-strict-rules": "^1.0.0", "phpunit/phpunit": "^9.5.0", diff --git a/docs/README.md b/docs/README.md index 3dcc58d..4043f8e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,15 @@ - [Why do you need it?](#why-do-you-need-it) - [Quick start](#quick-start) - [Execution time](#execution-time) + - [Cron expression - minutes and above](#cron-expression---minutes-and-above) + - [Seconds](#seconds) + - [Timezones](#timezones) - [Events](#events) + - [Before job event](#before-job-event) + - [After job event](#after-job-event) + - [Locked job event](#locked-job-event) + - [Before run event](#before-run-event) + - [After run event](#after-run-event) - [Handling errors](#handling-errors) - [Locks and job overlapping](#locks-and-job-overlapping) - [Parallelization and process isolation](#parallelization-and-process-isolation) @@ -48,7 +56,9 @@ Orisai Scheduler solves all of these problems. On top of that you get: - [locking](#locks-and-job-overlapping) - each job should run only once at a time, without overlapping -- [before/after job events](#events) for accessing job status +- [per-second scheduling](#seconds) - run jobs multiple times in a minute +- [timezones](#timezones) - interpret job schedule within specified timezone +- [events](#events) for accessing job status - [overview of all jobs](#list-command), including estimated time of next run - running jobs either [once](#run-command) or [periodically](#worker-command) during development - running just a [single](#run-single-job) job, either ignoring or respecting due times @@ -119,9 +129,24 @@ Good to go! ## Execution time -Cron execution time is expressed via `expression`, using crontab syntax +Execution time is determined by [cron expression](#cron-expression---minutes-and-above) which allows you to schedule +jobs from anywhere between once a year and once every minute and [seconds], allowing you tu run job several times in a +minute. -```php +In ideal situation, jobs are executed just in time, but it may not be always the case. Crontab can execute jobs several +seconds late, serial jobs execution may take way over a minute and long jobs may overlap. To prevent any issues, we +implement multiple measures: + +- jobs [repeated after seconds](#seconds) take in account crontab may run late and delay each execution accordingly to + minimize unwanted gaps between executions (e.g. if crontab starts 10 seconds late, all jobs also run 10 seconds late) +- [parallel execution](#parallelization-and-process-isolation) can be used instead of the serial +- [locks](#locks-and-job-overlapping) should be used to prevent overlapping of long-running jobs + +### Cron expression - minutes and above + +Main job execution time is expressed via `CronExpression`, using crontab syntax + +```neon orisai.scheduler: jobs: - @@ -159,9 +184,67 @@ You can also use macro instead of an expression: - `@daily`, `@midnight` - Run once a day, midnight - `0 0 * * *` - `@hourly` - Run once an hour, first minute - `0 * * * *` +### Seconds + +Run a job every n seconds within a minute. + +```neon +orisai.scheduler: + jobs: + - + expression: # ... + callback: # ... + repeatAfterSeconds: 1 # every second, 60 times a minute +``` + +```neon +orisai.scheduler: + jobs: + - + expression: # ... + callback: # ... + repeatAfterSeconds: 30 # every 30 seconds, 2 times a minute +``` + +With default, synchronous job executor, all jobs scheduled for current second are executed and just after it is +finished, jobs for the next second are executed. With [parallel](#parallelization-and-process-isolation) executor it is +different - all jobs are executed as soon as it is their time. Therefore, it is strongly recommended to +use [locking](#locks-and-job-overlapping) to prevent overlapping. + +### Timezones + +All jobs run within timezone used by your application. You may specify that your job execution time should be +interpreted within different timezone, e.g. every midnight in Europe/Prague. + +```neon +orisai.scheduler: + jobs: + - + expression: 0 0 * * * + callback: # ... + timeZone: Europe/Prague +``` + +Some timezones use daylight savings time. When daylight saving time changes occur, scheduled job may run twice or even +not run at all during that period. Make sure you run your tasks often enough and that running them more often gives you +expected results. + +If you want job to run at specific time (e.g. midnight) in timezone of each user, run it every 15 minutes and implement +timezone checking logic yourself. Several time zones have deviations of either 30 or 45 minutes. For instance, UTC-03:30 +is the standard time in Newfoundland, while Nepal's standard time is UTC+05:45. Indian Standard Time is UTC+05:30, and +Myanmar Standard Time is UTC+06:30. + ## Events -Run callbacks before and after job to collect statistics, etc. +Run callbacks to collect statistics, etc. + +### Before job event + +Executes before job start + +- has [JobInfo](#job-info-and-result) available as a parameter +- does not execute if job is [locked](#locks-and-job-overlapping), see [locked job event](#locked-job-event) +- check [callback job](#callback-job) `callback` syntax for event callable syntax, it is the same ```neon orisai.scheduler: @@ -169,16 +252,181 @@ orisai.scheduler: # list beforeJob: # same as jobs > [job] > callback, any valid callable - - @handler + - [@handler, 'beforeJob'] + +services: + handler: Example\SchedulerEventHandler +``` + +```php +namespace Example; + +use Orisai\Scheduler\Status\JobInfo; + +final class SchedulerEventHandler +{ + + public function beforeJob(JobInfo $info): void + { + // Executes before job start + } + +} +``` + +### After job event + +Executes after job finish + +- has [JobInfo and JobResult](#job-info-and-result) available as a parameter +- executes even if job failed with an exception +- check [callback job](#callback-job) `callback` syntax for event callable syntax, it is the same + +```neon +orisai.scheduler: + events: # list afterJob: # same as jobs > [job] > callback, any valid callable - - @handler + - [@handler, 'afterJob'] + +services: + handler: Example\SchedulerEventHandler ``` -Check [job info and result](#job-info-and-result) for available status info +```php +namespace Example; -And check [callback job](#callback-job) `callback` syntax for more examples, events can use all shown variants too +use Orisai\Scheduler\Status\JobInfo; +use Orisai\Scheduler\Status\JobResult; + +final class SchedulerEventHandler +{ + + public function afterJob(JobInfo $info, JobResult $result): void + { + // Executes after job finish + } + +} +``` + +### Locked job event + +Executes when [lock](#locks-and-job-overlapping) for given job is acquired by another process and therefore job does not +execute + +- has [JobInfo and JobResult](#job-info-and-result) available as a parameter +- check [callback job](#callback-job) `callback` syntax for event callable syntax, it is the same + +```neon +orisai.scheduler: + events: + # list + lockedJob: + # same as jobs > [job] > callback, any valid callable + - [@handler, 'lockedJob'] + +services: + handler: Example\SchedulerEventHandler +``` + +```php +namespace Example; + +use Orisai\Scheduler\Status\JobInfo; +use Orisai\Scheduler\Status\JobResult; + +final class SchedulerEventHandler +{ + + public function lockedJob(JobInfo $info, JobResult $result): void + { + // Executes when lock for given job is acquired by another process + } + +} +``` + +### Before run event + +Executes before every run (every minute), even if no jobs will be executed + +- has RunInfo available as a parameter +- check [callback job](#callback-job) `callback` syntax for event callable syntax, it is the same + +```neon +orisai.scheduler: + events: + # list + beforeRun: + # same as jobs > [job] > callback, any valid callable + - [@handler, 'beforeRun'] + +services: + handler: Example\SchedulerEventHandler +``` + +```php +namespace Example; + +use Orisai\Scheduler\Status\RunInfo; + +final class SchedulerEventHandler +{ + + public function beforeRun(RunInfo $info): void + { + $info->getStart(); // DateTimeImmutable + + foreach ($info->getJobInfos() as $jobInfo) { + $jobInfo->getId(); // int|string + $jobInfo->getName(); // string + $jobInfo->getExpression(); // string, e.g. * * * * * + $jobInfo->getExtendedExpression(); // string, e.g. * * * * * / 30 + $jobInfo->getRepeatAfterSeconds(); // int<0, 30> + $jobInfo->getRunsCountPerMinute(); // int<1, max> + $jobInfo->getEstimatedStartTimes(); // list + } + } + +} +``` + +### After run event + +Executes after every run (every minute), even if no jobs were executed + +- has [RunSummary](#run-summary) available as a parameter +- check [callback job](#callback-job) `callback` syntax for event callable syntax, it is the same + +```neon +orisai.scheduler: + events: + # list + afterRun: + # same as jobs > [job] > callback, any valid callable + - [@handler, 'afterRun'] + +services: + handler: Example\SchedulerEventHandler +``` + +```php +namespace Example; + +use Orisai\Scheduler\Status\RunSummary; + +final class SchedulerEventHandler +{ + + public function afterRun(RunSummary $summary): void + { + // Executes after every run (every minute), even if no jobs were executed + } + +} +``` ## Handling errors @@ -210,7 +458,8 @@ class SchedulerLogger $this->logger->error("Job {$info->getName()} failed", [ 'exception' => $throwable, 'name' => $info->getName(), - 'expression' => $info->getExpression(), + 'expression' => $info->getExtendedExpression(), + 'runSecond' => $info->getRunSecond(), 'start' => $info->getStart()->format(DateTimeInterface::ATOM), 'end' => $result->getEnd()->format(DateTimeInterface::ATOM), ]); @@ -229,7 +478,7 @@ services: ## Locks and job overlapping -Crontab jobs are time-based and simply run at specified intervals. If they take too long, they may overlap and run +Jobs are time-based and simply run at specified intervals. If they take too long, they may overlap and run simultaneously. This may cause issues if the jobs access the same resources, such as files or databases, leading to conflicts or data corruption. @@ -271,6 +520,17 @@ class ExampleJobService } ``` +To make sure locks are correctly used during deployments, specify constant id for every added job, lock identifiers rely +on that fact. Otherwise, your job id will change when new jobs are added before it and acquired lock will be ignored. + +```neon +orisai.scheduler: + jobs: + job-id: + expression: # ... + callback: # ... +``` + ## Parallelization and process isolation It is important for crontab scheduler tasks to be executed asynchronously and in separate processes because this @@ -390,6 +650,9 @@ Info: $id = $info->getId(); // string|int $name = $info->getName(); // string $expression = $info->getExpression(); // string, e.g. '* * * * *' +$repeatAfterSeconds = $info->getRepeatAfterSeconds(); // int<0, 30> +$extendedExpression = $info->getExtendedExpression(); // string, e.g. '* * * * * / 30' +$runSecond = $info->getRunSecond(); // int $start = $info->getStart(); // DateTimeImmutable ``` @@ -414,7 +677,7 @@ $summary = $scheduler->run(); // RunSummary $summary->getStart(); // DateTimeImmutable $summary->getEnd(); // DateTimeImmutable -foreach ($summary->getJobs() as $jobSummary) { +foreach ($summary->getJobSummaries() as $jobSummary) { $jobSummary->getInfo(); // JobInfo $jobSummary->getResult(); // JobResult } @@ -489,13 +752,15 @@ Run single job, ignoring scheduled time ### List command -List all scheduled jobs (in `expression [id] name... next-due` format) +List all scheduled jobs (in `expression / second (timezone) [id] name... next-due` format) `bin/console scheduler:list` -- use `--next` to sort jobs by their next execution time -- `--next=N` lists only *N* next jobs (e.g. `--next=3` prints maximally 3) +- use `--next` (or `-n`) to sort jobs by their next execution time + - `--next=N` lists only *N* next jobs (e.g. `--next=3` prints maximally 3) - use `-v` to display absolute times +- use `--timezone` (or `-tz`) to display times in specified timezone instead of one used by application + - e.g. `--tz=UTC` ### Worker command diff --git a/src/DI/LazyJobManager.php b/src/DI/LazyJobManager.php index 8feb182..26aea84 100644 --- a/src/DI/LazyJobManager.php +++ b/src/DI/LazyJobManager.php @@ -3,10 +3,13 @@ namespace OriNette\Scheduler\DI; use Cron\CronExpression; +use DateTimeZone; use Nette\DI\Container; use Orisai\Exceptions\Logic\InvalidArgument; +use Orisai\Exceptions\Logic\ShouldNotHappen; use Orisai\Exceptions\Message; use Orisai\Scheduler\Job\Job; +use Orisai\Scheduler\Job\JobSchedule; use Orisai\Scheduler\Manager\JobManager; use Orisai\Utils\Reflection\Classes; use function get_class; @@ -19,75 +22,81 @@ final class LazyJobManager implements JobManager private Container $container; - /** @var array */ - private array $jobs; + /** + * @var array, + * timeZone: string|null, + * }> + */ + private array $jobSchedules; - /** @var array */ - private array $expressions; + /** @var array */ + private array $resolvedJobSchedules = []; /** - * @param array $jobs - * @param array $expressions + * @param array, + * timeZone: string|null, + * }> $jobSchedules */ - public function __construct(Container $container, array $jobs, array $expressions) + public function __construct(Container $container, array $jobSchedules) { + $this->jobSchedules = $jobSchedules; $this->container = $container; - $this->jobs = $jobs; - $this->expressions = $expressions; } - public function getPair($id): ?array + public function getJobSchedule($id): ?JobSchedule { - $job = $this->jobs[$id] ?? null; - - if ($job === null) { - return null; - } - - $jobInst = $this->container->getService($job); - if (!$jobInst instanceof Job) { - $this->throwInvalidServiceType($job, Job::class, $jobInst); + $schedule = $this->resolvedJobSchedules[$id] ?? null; + if ($schedule !== null) { + return $schedule; } - return [ - $jobInst, - new CronExpression($this->expressions[$id]), - ]; - } - - public function getPairs(): array - { - $pairs = []; - foreach ($this->jobs as $id => $job) { - $jobInst = $this->container->getService($job); - if (!$jobInst instanceof Job) { - $this->throwInvalidServiceType($job, Job::class, $jobInst); - } - - $pairs[$id] = [ - $jobInst, - new CronExpression($this->expressions[$id]), - ]; + $rawSchedule = $this->jobSchedules[$id] ?? null; + if ($rawSchedule === null) { + return null; } - return $pairs; + $jobName = $rawSchedule['job']; + $timeZone = $rawSchedule['timeZone']; + $schedule = JobSchedule::createLazy( + function () use ($jobName): Job { + $job = $this->container->getService($jobName); + if (!$job instanceof Job) { + self::throwInvalidServiceType($jobName, Job::class, $job); + } + + return $job; + }, + new CronExpression($rawSchedule['expression']), + $rawSchedule['repeatAfterSeconds'], + $timeZone !== null ? new DateTimeZone($timeZone) : null, + ); + + unset($this->jobSchedules[$id]); + + return $this->resolvedJobSchedules[$id] = $schedule; } - public function getExpressions(): array + public function getJobSchedules(): array { - $expressions = []; - foreach ($this->expressions as $id => $expression) { - $expressions[$id] = new CronExpression($expression); + // Triggers schedules initialization + foreach ($this->jobSchedules as $id => $jobSchedule) { + $this->getJobSchedule($id); } - return $expressions; + return $this->resolvedJobSchedules; } /** * @param class-string $expectedType * @return never */ - private function throwInvalidServiceType(string $serviceName, string $expectedType, object $service): void + private static function throwInvalidServiceType(string $serviceName, string $expectedType, object $service): void { $serviceClass = get_class($service); $selfClass = self::class; @@ -102,4 +111,31 @@ private function throwInvalidServiceType(string $serviceName, string $expectedTy ->withMessage($message); } + /** + * @codeCoverageIgnore + */ + public function getPair($id): ?array + { + throw ShouldNotHappen::create() + ->withMessage('This method is here just to make tooling happy'); + } + + /** + * @codeCoverageIgnore + */ + public function getPairs(): array + { + throw ShouldNotHappen::create() + ->withMessage('This method is here just to make tooling happy'); + } + + /** + * @codeCoverageIgnore + */ + public function getExpressions(): array + { + throw ShouldNotHappen::create() + ->withMessage('This method is here just to make tooling happy'); + } + } diff --git a/src/DI/LazyJobManagerV1.php b/src/DI/LazyJobManagerV1.php new file mode 100644 index 0000000..fed224b --- /dev/null +++ b/src/DI/LazyJobManagerV1.php @@ -0,0 +1,128 @@ + */ + private array $jobs; + + /** @var array */ + private array $expressions; + + /** + * @param array $jobs + * @param array $expressions + */ + public function __construct(Container $container, array $jobs, array $expressions) + { + $this->container = $container; + $this->jobs = $jobs; + $this->expressions = $expressions; + } + + public function getPair($id): ?array + { + $job = $this->jobs[$id] ?? null; + + if ($job === null) { + return null; + } + + $jobInst = $this->container->getService($job); + if (!$jobInst instanceof Job) { + $this->throwInvalidServiceType($job, Job::class, $jobInst); + } + + return [ + $jobInst, + new CronExpression($this->expressions[$id]), + ]; + } + + public function getPairs(): array + { + $pairs = []; + foreach ($this->jobs as $id => $job) { + $jobInst = $this->container->getService($job); + if (!$jobInst instanceof Job) { + $this->throwInvalidServiceType($job, Job::class, $jobInst); + } + + $pairs[$id] = [ + $jobInst, + new CronExpression($this->expressions[$id]), + ]; + } + + return $pairs; + } + + public function getExpressions(): array + { + $expressions = []; + foreach ($this->expressions as $id => $expression) { + $expressions[$id] = new CronExpression($expression); + } + + return $expressions; + } + + /** + * @param class-string $expectedType + * @return never + */ + private function throwInvalidServiceType(string $serviceName, string $expectedType, object $service): void + { + $serviceClass = get_class($service); + $selfClass = self::class; + $className = Classes::getShortName($selfClass); + + $message = Message::create() + ->withContext("Service '$serviceName' returns instance of $serviceClass.") + ->withProblem("$selfClass supports only instances of $expectedType.") + ->withSolution("Remove service from $className or make the service return supported object type."); + + throw InvalidArgument::create() + ->withMessage($message); + } + + /** + * @codeCoverageIgnore + */ + public function getJobSchedule($id): ?JobSchedule + { + throw ShouldNotHappen::create() + ->withMessage('This method is here just to make tooling happy'); + } + + /** + * @codeCoverageIgnore + */ + public function getJobSchedules(): array + { + throw ShouldNotHappen::create() + ->withMessage('This method is here just to make tooling happy'); + } + +} diff --git a/src/DI/SchedulerExtension.php b/src/DI/SchedulerExtension.php index 2bac5a8..792acc5 100644 --- a/src/DI/SchedulerExtension.php +++ b/src/DI/SchedulerExtension.php @@ -12,6 +12,7 @@ use Nette\Schema\Schema; use OriNette\DI\Definitions\DefinitionsLoader; use OriNette\Scheduler\Tracy\SchedulerTracyLogger; +use Orisai\Exceptions\Logic\InvalidArgument; use Orisai\Scheduler\Command\ListCommand; use Orisai\Scheduler\Command\RunCommand; use Orisai\Scheduler\Command\RunJobCommand; @@ -19,10 +20,14 @@ use Orisai\Scheduler\Executor\ProcessJobExecutor; use Orisai\Scheduler\Job\CallbackJob; use Orisai\Scheduler\ManagedScheduler; +use Orisai\Scheduler\Manager\JobManager; use Orisai\Scheduler\Scheduler; use stdClass; use function function_exists; +use function in_array; use function is_array; +use function method_exists; +use function timezone_identifiers_list; /** * @property-read stdClass $config @@ -46,6 +51,30 @@ public function getConfigSchema(): Schema 'runJobCommand' => Expect::string()->default('scheduler:run-job'), ]), 'events' => Expect::structure([ + 'beforeRun' => Expect::listOf( + Expect::anyOf( + Expect::string(), + /* @infection-ignore-all */ + Expect::array()->min(2)->max(2), + Expect::type(Statement::class), + ), + ), + 'afterRun' => Expect::listOf( + Expect::anyOf( + Expect::string(), + /* @infection-ignore-all */ + Expect::array()->min(2)->max(2), + Expect::type(Statement::class), + ), + ), + 'lockedJob' => Expect::listOf( + Expect::anyOf( + Expect::string(), + /* @infection-ignore-all */ + Expect::array()->min(2)->max(2), + Expect::type(Statement::class), + ), + ), 'beforeJob' => Expect::listOf( Expect::anyOf( Expect::string(), @@ -77,6 +106,19 @@ public function getConfigSchema(): Schema Expect::type(Statement::class), )->default(null), 'job' => DefinitionsLoader::schema()->default(null), + 'repeatAfterSeconds' => Expect::int(0) + ->min(0) + ->max(30), + 'timeZone' => Expect::anyOf( + Expect::string(), + Expect::null(), + )->assert(static function (?string $timeZone): bool { + if ($timeZone === null) { + return true; + } + + return in_array($timeZone, timezone_identifiers_list(), true); + }, 'Valid timezone'), ])->assert(static function (stdClass $values): bool { if ($values->callback !== null && $values->job !== null) { return false; @@ -110,34 +152,39 @@ private function registerScheduler(ContainerBuilder $builder, stdClass $config): $events = $config->events; - foreach ($events->beforeJob as $event) { - $schedulerDefinition->addSetup( - 'addBeforeJobCallback', - [ - new Statement([ - Closure::class, - 'fromCallable', - ], [ - $event, - ]), - ], + // Compat - orisai/scheduler v1 + if (method_exists(Scheduler::class, 'getJobSchedules')) { + $this->addEventsToScheduler( + $schedulerDefinition, + 'addBeforeRunCallback', + $events->beforeRun, ); - } - foreach ($events->afterJob as $event) { - $schedulerDefinition->addSetup( - 'addAfterJobCallback', - [ - new Statement([ - Closure::class, - 'fromCallable', - ], [ - $event, - ]), - ], + $this->addEventsToScheduler( + $schedulerDefinition, + 'addAfterRunCallback', + $events->afterRun, + ); + + $this->addEventsToScheduler( + $schedulerDefinition, + 'addLockedJobCallback', + $events->lockedJob, ); } + $this->addEventsToScheduler( + $schedulerDefinition, + 'addBeforeJobCallback', + $events->beforeJob, + ); + + $this->addEventsToScheduler( + $schedulerDefinition, + 'addAfterJobCallback', + $events->afterJob, + ); + return $schedulerDefinition; } @@ -145,44 +192,86 @@ private function registerJobManager(ContainerBuilder $builder, stdClass $config) { $loader = new DefinitionsLoader($this->compiler); - $jobs = []; - $expressions = []; - foreach ($config->jobs as $id => $job) { - $expressions[$id] = $job->expression; - - $jobDefinitionName = $this->prefix("job.$id"); - if ($job->callback !== null) { - $builder->addDefinition($jobDefinitionName) - ->setFactory(new Statement( - CallbackJob::class, - [ - new Statement([ - Closure::class, - 'fromCallable', - ], [ - $job->callback, - ]), - ], - )) - ->setAutowired(false); - } else { - $loader->loadDefinitionFromConfig( - $job->job, - $jobDefinitionName, - ); + // Compat - orisai/scheduler v1 + /** @infection-ignore-all */ + if (method_exists(JobManager::class, 'getPairs')) { + $jobs = []; + $expressions = []; + foreach ($config->jobs as $id => $job) { + /** @codeCoverageIgnore */ + if ($job->repeatAfterSeconds !== 0) { + throw InvalidArgument::create() + ->withMessage( + "Option `$this->name > jobs > $id > repeatAfterSeconds` requires orisai/scheduler >= 2.0.0", + ); + } + + /** @codeCoverageIgnore */ + if ($job->timeZone !== null) { + throw InvalidArgument::create() + ->withMessage( + "Option `$this->name > jobs > $id > timeZone` requires orisai/scheduler >= 2.0.0", + ); + } + + $expressions[$id] = $job->expression; + $jobDefinitionName = $this->registerJob($id, $job, $builder, $loader); + $jobs[$id] = $jobDefinitionName; } - $jobs[$id] = $jobDefinitionName; + return $builder->addDefinition($this->prefix('jobManager')) + ->setFactory(LazyJobManagerV1::class, [ + 'jobs' => $jobs, + 'expressions' => $expressions, + ]) + ->setAutowired(false); + } + + $jobSchedules = []; + foreach ($config->jobs as $id => $job) { + $jobDefinitionName = $this->registerJob($id, $job, $builder, $loader); + $jobSchedules[$id] = [ + 'job' => $jobDefinitionName, + 'expression' => $job->expression, + 'repeatAfterSeconds' => $job->repeatAfterSeconds, + 'timeZone' => $job->timeZone, + ]; } return $builder->addDefinition($this->prefix('jobManager')) ->setFactory(LazyJobManager::class, [ - 'jobs' => $jobs, - 'expressions' => $expressions, + 'jobSchedules' => $jobSchedules, ]) ->setAutowired(false); } + /** + * @param int|string $id + */ + private function registerJob($id, stdClass $job, ContainerBuilder $builder, DefinitionsLoader $loader): string + { + $jobDefinitionName = $this->prefix("job.$id"); + if ($job->callback !== null) { + $builder->addDefinition($jobDefinitionName) + ->setFactory(new Statement( + CallbackJob::class, + [ + new Statement([ + Closure::class, + 'fromCallable', + ], [ + $job->callback, + ]), + ], + )) + ->setAutowired(false); + } else { + $loader->loadDefinitionFromConfig($job->job, $jobDefinitionName); + } + + return $jobDefinitionName; + } + private function registerErrorHandler(stdClass $config): ?Statement { if ($config->errorHandler === 'tracy') { @@ -225,6 +314,30 @@ private function registerExecutor(ContainerBuilder $builder, stdClass $config): return null; } + /** + * @param array $events + */ + private function addEventsToScheduler( + ServiceDefinition $schedulerDefinition, + string $method, + array $events + ): void + { + foreach ($events as $event) { + $schedulerDefinition->addSetup( + $method, + [ + new Statement([ + Closure::class, + 'fromCallable', + ], [ + $event, + ]), + ], + ); + } + } + private function registerCommands( ContainerBuilder $builder, stdClass $config, diff --git a/tests/Doubles/TestEventHandler.php b/tests/Doubles/TestJobEventHandler.php similarity index 94% rename from tests/Doubles/TestEventHandler.php rename to tests/Doubles/TestJobEventHandler.php index 161e797..74bae9f 100644 --- a/tests/Doubles/TestEventHandler.php +++ b/tests/Doubles/TestJobEventHandler.php @@ -5,7 +5,7 @@ use Orisai\Scheduler\Status\JobInfo; use Orisai\Scheduler\Status\JobResult; -final class TestEventHandler +final class TestJobEventHandler { private TestEventRecorder $recorder; diff --git a/tests/Doubles/TestLockedJobEventHandler.php b/tests/Doubles/TestLockedJobEventHandler.php new file mode 100644 index 0000000..e91424c --- /dev/null +++ b/tests/Doubles/TestLockedJobEventHandler.php @@ -0,0 +1,34 @@ +recorder = $recorder; + } + + public function handle(JobInfo $info, JobResult $result): void + { + if ($result->getState() !== JobResultState::lock()) { + throw new Exception('This handler is for locked jobs only'); + } + + $this->recorder->records[] = 'locked job'; + } + + public function __invoke(JobInfo $info, JobResult $result): void + { + $this->handle($info, $result); + } + +} diff --git a/tests/Doubles/TestRunEventHandler.php b/tests/Doubles/TestRunEventHandler.php new file mode 100644 index 0000000..7f7f675 --- /dev/null +++ b/tests/Doubles/TestRunEventHandler.php @@ -0,0 +1,36 @@ +recorder = $recorder; + } + + /** + * @param RunInfo|RunSummary $info + */ + public function handle($info): void + { + $this->recorder->records[] = $info instanceof RunInfo + ? 'before run' + : 'after run'; + } + + /** + * @param RunInfo|RunSummary $info + */ + public function __invoke($info): void + { + $this->handle($info); + } + +} diff --git a/tests/Unit/DI/LazyJobManager.empty.neon b/tests/Unit/DI/LazyJobManager.empty.neon index c34f9a0..268320f 100644 --- a/tests/Unit/DI/LazyJobManager.empty.neon +++ b/tests/Unit/DI/LazyJobManager.empty.neon @@ -1,5 +1,4 @@ services: orisai.scheduler.jobManager: OriNette\Scheduler\DI\LazyJobManager( - jobs: [], - expressions: [] + jobSchedules: [], ) diff --git a/tests/Unit/DI/LazyJobManager.invalidType.neon b/tests/Unit/DI/LazyJobManager.invalidType.neon index 1302a08..abb7e93 100644 --- a/tests/Unit/DI/LazyJobManager.invalidType.neon +++ b/tests/Unit/DI/LazyJobManager.invalidType.neon @@ -1,11 +1,13 @@ services: orisai.scheduler.jobManager: OriNette\Scheduler\DI\LazyJobManager( - jobs: [ - job1: app.job1, + jobSchedules: [ + job1: [ + job: app.job1, + expression: '1 * * * *', + repeatAfterSeconds: 0, + timeZone: null, + ], ], - expressions: [ - job1: '1 * * * *', - ] ) app.job1: \stdClass diff --git a/tests/Unit/DI/LazyJobManager.neon b/tests/Unit/DI/LazyJobManager.neon index 1fc46fe..d568f57 100644 --- a/tests/Unit/DI/LazyJobManager.neon +++ b/tests/Unit/DI/LazyJobManager.neon @@ -1,15 +1,25 @@ services: orisai.scheduler.jobManager: OriNette\Scheduler\DI\LazyJobManager( - jobs: [ - job1: app.job1, - job2: app.job2, - 3: app.job3, + jobSchedules: [ + job1: [ + job: app.job1, + expression: '1 * * * *', + repeatAfterSeconds: 0, + timeZone: null, + ], + job2: [ + job: app.job2, + expression: '2 * * * *', + repeatAfterSeconds: 10, + timeZone: 'Europe/Prague', + ], + 3: [ + job: app.job3, + expression: '3 * * * *', + repeatAfterSeconds: 30, + timeZone: 'UTC', + ], ], - expressions: [ - job1: '1 * * * *', - job2: '2 * * * *', - 3: '3 * * * *', - ] ) app.job1: Tests\OriNette\Scheduler\Doubles\TestJob('job1') diff --git a/tests/Unit/DI/LazyJobManagerTest.php b/tests/Unit/DI/LazyJobManagerTest.php index df51f3b..fb888fb 100644 --- a/tests/Unit/DI/LazyJobManagerTest.php +++ b/tests/Unit/DI/LazyJobManagerTest.php @@ -3,12 +3,16 @@ namespace Tests\OriNette\Scheduler\Unit\DI; use Cron\CronExpression; +use DateTimeZone; use OriNette\DI\Boot\ManualConfigurator; use OriNette\Scheduler\DI\LazyJobManager; use Orisai\Exceptions\Logic\InvalidArgument; +use Orisai\Scheduler\Job\JobSchedule; +use Orisai\Scheduler\Manager\JobManager; use PHPUnit\Framework\TestCase; use Tests\OriNette\Scheduler\Doubles\TestJob; use function dirname; +use function method_exists; use function mkdir; use const PHP_VERSION_ID; @@ -25,6 +29,11 @@ protected function setUp(): void if (PHP_VERSION_ID < 8_01_00) { @mkdir("$this->rootDir/var/build"); } + + // Compat - orisai/scheduler v1 + if (method_exists(JobManager::class, 'getPairs')) { + self::markTestSkipped('This test is for orisai/scheduler v2'); + } } public function test(): void @@ -38,36 +47,41 @@ public function test(): void $manager = $container->getService('orisai.scheduler.jobManager'); self::assertInstanceOf(LazyJobManager::class, $manager); - self::assertEquals( - [ - 'job1' => new CronExpression('1 * * * *'), - 'job2' => new CronExpression('2 * * * *'), - 3 => new CronExpression('3 * * * *'), - ], - $manager->getExpressions(), - ); + self::assertSame($manager->getJobSchedules(), $manager->getJobSchedules()); + + // Trigger internal initialization + foreach ($manager->getJobSchedules() as $jobSchedule) { + $jobSchedule->getJob(); + } self::assertEquals( [ - 'job1' => [ + 'job1' => JobSchedule::create( new TestJob('job1'), new CronExpression('1 * * * *'), - ], - 'job2' => [ + 0, + null, + ), + 'job2' => JobSchedule::create( new TestJob('job2'), new CronExpression('2 * * * *'), - ], - 3 => [ + 10, + new DateTimeZone('Europe/Prague'), + ), + 3 => JobSchedule::create( new TestJob('job3'), new CronExpression('3 * * * *'), - ], + 30, + new DateTimeZone('UTC'), + ), ], - $manager->getPairs(), + $manager->getJobSchedules(), ); - self::assertNull($manager->getPair(42)); - foreach ($manager->getPairs() as $id => $pair) { - self::assertEquals($pair, $manager->getPair($id)); + self::assertNull($manager->getJobSchedule(42)); + foreach ($manager->getJobSchedules() as $id => $schedule) { + self::assertEquals($schedule, $manager->getJobSchedule($id)); + self::assertSame($manager->getJobSchedule($id), $manager->getJobSchedule($id)); } } @@ -82,14 +96,13 @@ public function testEmpty(): void $manager = $container->getService('orisai.scheduler.jobManager'); self::assertInstanceOf(LazyJobManager::class, $manager); - self::assertSame([], $manager->getExpressions()); - self::assertSame([], $manager->getPairs()); - self::assertNull($manager->getPair(0)); - self::assertNull($manager->getPair('id')); - self::assertNull($manager->getPair(42)); + self::assertSame([], $manager->getJobSchedules()); + self::assertNull($manager->getJobSchedule(0)); + self::assertNull($manager->getJobSchedule('id')); + self::assertNull($manager->getJobSchedule(42)); } - public function testInvalidPair(): void + public function testInvalidJobType(): void { $configurator = new ManualConfigurator($this->rootDir); $configurator->setForceReloadContainer(); @@ -100,29 +113,8 @@ public function testInvalidPair(): void $manager = $container->getService('orisai.scheduler.jobManager'); self::assertInstanceOf(LazyJobManager::class, $manager); - $this->expectException(InvalidArgument::class); - $this->expectExceptionMessage( - <<<'MSG' -Context: Service 'app.job1' returns instance of stdClass. -Problem: OriNette\Scheduler\DI\LazyJobManager supports only instances of - Orisai\Scheduler\Job\Job. -Solution: Remove service from LazyJobManager or make the service return - supported object type. -MSG, - ); - $manager->getPair('job1'); - } - - public function testInvalidPairs(): void - { - $configurator = new ManualConfigurator($this->rootDir); - $configurator->setForceReloadContainer(); - $configurator->addConfig(__DIR__ . '/LazyJobManager.invalidType.neon'); - - $container = $configurator->createContainer(); - - $manager = $container->getService('orisai.scheduler.jobManager'); - self::assertInstanceOf(LazyJobManager::class, $manager); + $schedule = $manager->getJobSchedule('job1'); + self::assertNotNull($schedule); $this->expectException(InvalidArgument::class); $this->expectExceptionMessage( @@ -134,7 +126,8 @@ public function testInvalidPairs(): void supported object type. MSG, ); - $manager->getPairs(); + + $schedule->getJob(); } } diff --git a/tests/Unit/DI/LazyJobManagerV1.empty.neon b/tests/Unit/DI/LazyJobManagerV1.empty.neon new file mode 100644 index 0000000..678b26b --- /dev/null +++ b/tests/Unit/DI/LazyJobManagerV1.empty.neon @@ -0,0 +1,5 @@ +services: + orisai.scheduler.jobManager: OriNette\Scheduler\DI\LazyJobManagerV1( + jobs: [], + expressions: [] + ) diff --git a/tests/Unit/DI/LazyJobManagerV1.invalidType.neon b/tests/Unit/DI/LazyJobManagerV1.invalidType.neon new file mode 100644 index 0000000..61207e1 --- /dev/null +++ b/tests/Unit/DI/LazyJobManagerV1.invalidType.neon @@ -0,0 +1,11 @@ +services: + orisai.scheduler.jobManager: OriNette\Scheduler\DI\LazyJobManagerV1( + jobs: [ + job1: app.job1, + ], + expressions: [ + job1: '1 * * * *', + ] + ) + + app.job1: \stdClass diff --git a/tests/Unit/DI/LazyJobManagerV1.neon b/tests/Unit/DI/LazyJobManagerV1.neon new file mode 100644 index 0000000..5f9c6e7 --- /dev/null +++ b/tests/Unit/DI/LazyJobManagerV1.neon @@ -0,0 +1,17 @@ +services: + orisai.scheduler.jobManager: OriNette\Scheduler\DI\LazyJobManagerV1( + jobs: [ + job1: app.job1, + job2: app.job2, + 3: app.job3, + ], + expressions: [ + job1: '1 * * * *', + job2: '2 * * * *', + 3: '3 * * * *', + ] + ) + + app.job1: Tests\OriNette\Scheduler\Doubles\TestJob('job1') + app.job2: Tests\OriNette\Scheduler\Doubles\TestJob('job2') + app.job3: Tests\OriNette\Scheduler\Doubles\TestJob('job3') diff --git a/tests/Unit/DI/LazyJobManagerV1Test.php b/tests/Unit/DI/LazyJobManagerV1Test.php new file mode 100644 index 0000000..a1c51a1 --- /dev/null +++ b/tests/Unit/DI/LazyJobManagerV1Test.php @@ -0,0 +1,147 @@ +rootDir = dirname(__DIR__, 3); + if (PHP_VERSION_ID < 8_01_00) { + @mkdir("$this->rootDir/var/build"); + } + + // Compat - orisai/scheduler v1 + if (!method_exists(JobManager::class, 'getPairs')) { + self::markTestSkipped('This test is for orisai/scheduler v1'); + } + } + + public function test(): void + { + $configurator = new ManualConfigurator($this->rootDir); + $configurator->setForceReloadContainer(); + $configurator->addConfig(__DIR__ . '/LazyJobManagerV1.neon'); + + $container = $configurator->createContainer(); + + $manager = $container->getService('orisai.scheduler.jobManager'); + self::assertInstanceOf(LazyJobManagerV1::class, $manager); + + self::assertEquals( + [ + 'job1' => new CronExpression('1 * * * *'), + 'job2' => new CronExpression('2 * * * *'), + 3 => new CronExpression('3 * * * *'), + ], + $manager->getExpressions(), + ); + + self::assertEquals( + [ + 'job1' => [ + new TestJob('job1'), + new CronExpression('1 * * * *'), + ], + 'job2' => [ + new TestJob('job2'), + new CronExpression('2 * * * *'), + ], + 3 => [ + new TestJob('job3'), + new CronExpression('3 * * * *'), + ], + ], + $manager->getPairs(), + ); + + self::assertNull($manager->getPair(42)); + foreach ($manager->getPairs() as $id => $pair) { + self::assertEquals($pair, $manager->getPair($id)); + } + } + + public function testEmpty(): void + { + $configurator = new ManualConfigurator($this->rootDir); + $configurator->setForceReloadContainer(); + $configurator->addConfig(__DIR__ . '/LazyJobManagerV1.empty.neon'); + + $container = $configurator->createContainer(); + + $manager = $container->getService('orisai.scheduler.jobManager'); + self::assertInstanceOf(LazyJobManagerV1::class, $manager); + + self::assertSame([], $manager->getExpressions()); + self::assertSame([], $manager->getPairs()); + self::assertNull($manager->getPair(0)); + self::assertNull($manager->getPair('id')); + self::assertNull($manager->getPair(42)); + } + + public function testInvalidPair(): void + { + $configurator = new ManualConfigurator($this->rootDir); + $configurator->setForceReloadContainer(); + $configurator->addConfig(__DIR__ . '/LazyJobManagerV1.invalidType.neon'); + + $container = $configurator->createContainer(); + + $manager = $container->getService('orisai.scheduler.jobManager'); + self::assertInstanceOf(LazyJobManagerV1::class, $manager); + + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage( + <<<'MSG' +Context: Service 'app.job1' returns instance of stdClass. +Problem: OriNette\Scheduler\DI\LazyJobManagerV1 supports only instances of + Orisai\Scheduler\Job\Job. +Solution: Remove service from LazyJobManagerV1 or make the service return + supported object type. +MSG, + ); + $manager->getPair('job1'); + } + + public function testInvalidPairs(): void + { + $configurator = new ManualConfigurator($this->rootDir); + $configurator->setForceReloadContainer(); + $configurator->addConfig(__DIR__ . '/LazyJobManagerV1.invalidType.neon'); + + $container = $configurator->createContainer(); + + $manager = $container->getService('orisai.scheduler.jobManager'); + self::assertInstanceOf(LazyJobManagerV1::class, $manager); + + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage( + <<<'MSG' +Context: Service 'app.job1' returns instance of stdClass. +Problem: OriNette\Scheduler\DI\LazyJobManagerV1 supports only instances of + Orisai\Scheduler\Job\Job. +Solution: Remove service from LazyJobManagerV1 or make the service return + supported object type. +MSG, + ); + $manager->getPairs(); + } + +} diff --git a/tests/Unit/DI/SchedulerExtension.invalidTimeZone.neon b/tests/Unit/DI/SchedulerExtension.invalidTimeZone.neon new file mode 100644 index 0000000..c108ccb --- /dev/null +++ b/tests/Unit/DI/SchedulerExtension.invalidTimeZone.neon @@ -0,0 +1,12 @@ +extensions: + orisai.scheduler: OriNette\Scheduler\DI\SchedulerExtension + +orisai.scheduler: + jobs: + 0: + expression: * * * * * + job: Tests\OriNette\Scheduler\Doubles\TestJob('0') + timeZone: invalid + +services: + - Orisai\Clock\FrozenClock(1) diff --git a/tests/Unit/DI/SchedulerExtension.events.neon b/tests/Unit/DI/SchedulerExtension.jobEvents.neon similarity index 54% rename from tests/Unit/DI/SchedulerExtension.events.neon rename to tests/Unit/DI/SchedulerExtension.jobEvents.neon index b57d13f..a46da8e 100644 --- a/tests/Unit/DI/SchedulerExtension.events.neon +++ b/tests/Unit/DI/SchedulerExtension.jobEvents.neon @@ -13,14 +13,14 @@ orisai.scheduler: beforeJob: - @handler - [@handler, 'handle'] - - Tests\OriNette\Scheduler\Doubles\TestEventHandler() - - [Tests\OriNette\Scheduler\Doubles\TestEventHandler(), 'handle'] + - Tests\OriNette\Scheduler\Doubles\TestJobEventHandler() + - [Tests\OriNette\Scheduler\Doubles\TestJobEventHandler(), 'handle'] afterJob: - @handler - [@handler, 'handle'] - - Tests\OriNette\Scheduler\Doubles\TestEventHandler() - - [Tests\OriNette\Scheduler\Doubles\TestEventHandler(), 'handle'] + - Tests\OriNette\Scheduler\Doubles\TestJobEventHandler() + - [Tests\OriNette\Scheduler\Doubles\TestJobEventHandler(), 'handle'] services: recorder: Tests\OriNette\Scheduler\Doubles\TestEventRecorder - handler: Tests\OriNette\Scheduler\Doubles\TestEventHandler + handler: Tests\OriNette\Scheduler\Doubles\TestJobEventHandler diff --git a/tests/Unit/DI/SchedulerExtension.jobSchedules.neon b/tests/Unit/DI/SchedulerExtension.jobSchedules.neon new file mode 100644 index 0000000..096de33 --- /dev/null +++ b/tests/Unit/DI/SchedulerExtension.jobSchedules.neon @@ -0,0 +1,22 @@ +extensions: + orisai.scheduler: OriNette\Scheduler\DI\SchedulerExtension + +orisai.scheduler: + jobs: + 0: + expression: * * * * * + job: Tests\OriNette\Scheduler\Doubles\TestJob('0') + repeatAfterSeconds: 0 + 1: + expression: 0 * * * * + job: Tests\OriNette\Scheduler\Doubles\TestJob('1') + repeatAfterSeconds: 1 + timeZone: Europe/Prague + 2: + expression: 1 * * * * + job: Tests\OriNette\Scheduler\Doubles\TestJob('2') + repeatAfterSeconds: 30 + timeZone: UTC + +services: + - Orisai\Clock\FrozenClock(1) diff --git a/tests/Unit/DI/SchedulerExtension.lockedJobEvents.neon b/tests/Unit/DI/SchedulerExtension.lockedJobEvents.neon new file mode 100644 index 0000000..5dd07fa --- /dev/null +++ b/tests/Unit/DI/SchedulerExtension.lockedJobEvents.neon @@ -0,0 +1,22 @@ +extensions: + orisai.scheduler: OriNette\Scheduler\DI\SchedulerExtension + +orisai.scheduler: + executor: basic + + jobs: + jobName: + expression: * * * * * + callback: Tests\OriNette\Scheduler\Doubles\TestService() + + events: + lockedJob: + - @handler + - [@handler, 'handle'] + - Tests\OriNette\Scheduler\Doubles\TestLockedJobEventHandler() + - [Tests\OriNette\Scheduler\Doubles\TestLockedJobEventHandler(), 'handle'] + +services: + recorder: Tests\OriNette\Scheduler\Doubles\TestEventRecorder + handler: Tests\OriNette\Scheduler\Doubles\TestLockedJobEventHandler + lock: Symfony\Component\Lock\LockFactory(Symfony\Component\Lock\Store\SemaphoreStore()) diff --git a/tests/Unit/DI/SchedulerExtension.runEvents.neon b/tests/Unit/DI/SchedulerExtension.runEvents.neon new file mode 100644 index 0000000..67a5fc4 --- /dev/null +++ b/tests/Unit/DI/SchedulerExtension.runEvents.neon @@ -0,0 +1,26 @@ +extensions: + orisai.scheduler: OriNette\Scheduler\DI\SchedulerExtension + +orisai.scheduler: + executor: basic + + jobs: + - + expression: * * * * * + callback: Tests\OriNette\Scheduler\Doubles\TestService() + + events: + beforeRun: + - @handler + - [@handler, 'handle'] + - Tests\OriNette\Scheduler\Doubles\TestRunEventHandler() + - [Tests\OriNette\Scheduler\Doubles\TestRunEventHandler(), 'handle'] + afterRun: + - @handler + - [@handler, 'handle'] + - Tests\OriNette\Scheduler\Doubles\TestRunEventHandler() + - [Tests\OriNette\Scheduler\Doubles\TestRunEventHandler(), 'handle'] + +services: + recorder: Tests\OriNette\Scheduler\Doubles\TestEventRecorder + handler: Tests\OriNette\Scheduler\Doubles\TestRunEventHandler diff --git a/tests/Unit/DI/SchedulerExtensionTest.php b/tests/Unit/DI/SchedulerExtensionTest.php index fbfc419..2705553 100644 --- a/tests/Unit/DI/SchedulerExtensionTest.php +++ b/tests/Unit/DI/SchedulerExtensionTest.php @@ -2,11 +2,14 @@ namespace Tests\OriNette\Scheduler\Unit\DI; +use Cron\CronExpression; +use DateTimeZone; use Exception; use Generator; use Nette\DI\InvalidConfigurationException; use OriNette\DI\Boot\ManualConfigurator; use OriNette\Scheduler\DI\LazyJobManager; +use OriNette\Scheduler\DI\LazyJobManagerV1; use Orisai\Scheduler\Command\ListCommand; use Orisai\Scheduler\Command\RunCommand; use Orisai\Scheduler\Command\RunJobCommand; @@ -14,8 +17,10 @@ use Orisai\Scheduler\Executor\ProcessJobExecutor; use Orisai\Scheduler\Job\CallbackJob; use Orisai\Scheduler\ManagedScheduler; +use Orisai\Scheduler\Manager\JobManager; use Orisai\Scheduler\Scheduler; use PHPUnit\Framework\TestCase; +use Symfony\Component\Lock\LockFactory; use Tests\OriNette\Scheduler\Doubles\TestEventRecorder; use Tests\OriNette\Scheduler\Doubles\TestJob; use Tests\OriNette\Scheduler\Doubles\TestLogger; @@ -24,6 +29,7 @@ use Tracy\Debugger; use function dirname; use function function_exists; +use function method_exists; use function mkdir; use const PHP_VERSION_ID; @@ -58,8 +64,14 @@ public function testMinimal(): void self::assertSame($scheduler, $container->getByType(Scheduler::class)); $manager = $container->getService('orisai.scheduler.jobManager'); - self::assertInstanceOf(LazyJobManager::class, $manager); - self::assertNull($container->getByType(LazyJobManager::class, false)); + // Compat - orisai/scheduler v1 + if (method_exists(JobManager::class, 'getPairs')) { + self::assertInstanceOf(LazyJobManagerV1::class, $manager); + self::assertNull($container->getByType(LazyJobManagerV1::class, false)); + } else { + self::assertInstanceOf(LazyJobManager::class, $manager); + self::assertNull($container->getByType(LazyJobManager::class, false)); + } if (function_exists('proc_open')) { $executor = $container->getService('orisai.scheduler.executor'); @@ -86,6 +98,54 @@ public function testMinimal(): void self::assertNull($container->getByType(WorkerCommand::class, false)); } + public function testJobSchedules(): void + { + // Compat - orisai/scheduler v1 + if (!method_exists(Scheduler::class, 'getJobSchedules')) { + self::markTestSkipped('Schedules are available since v2'); + } + + $configurator = new ManualConfigurator($this->rootDir); + $configurator->setForceReloadContainer(); + $configurator->addConfig(__DIR__ . '/SchedulerExtension.jobSchedules.neon'); + + $container = $configurator->createContainer(); + + $scheduler = $container->getByType(Scheduler::class); + $schedules = $scheduler->getJobSchedules(); + + self::assertCount(3, $schedules); + + $schedule = $schedules[0]; + self::assertEquals(new CronExpression('* * * * *'), $schedule->getExpression()); + self::assertSame(0, $schedule->getRepeatAfterSeconds()); + self::assertNull($schedule->getTimeZone()); + + $schedule = $schedules[1]; + self::assertEquals(new CronExpression('0 * * * *'), $schedule->getExpression()); + self::assertSame(1, $schedule->getRepeatAfterSeconds()); + self::assertEquals(new DateTimeZone('Europe/Prague'), $schedule->getTimeZone()); + + $schedule = $schedules[2]; + self::assertEquals(new CronExpression('1 * * * *'), $schedule->getExpression()); + self::assertSame(30, $schedule->getRepeatAfterSeconds()); + self::assertEquals(new DateTimeZone('UTC'), $schedule->getTimeZone()); + } + + public function testInvalidTimeZone(): void + { + $configurator = new ManualConfigurator($this->rootDir); + $configurator->setForceReloadContainer(); + $configurator->addConfig(__DIR__ . '/SchedulerExtension.invalidTimeZone.neon'); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage( + "Failed assertion 'Valid timezone' for item 'orisai.scheduler › jobs › 0 › timeZone' with value 'invalid'.", + ); + + $configurator->createContainer(); + } + public function testExecutorBasic(): void { $configurator = new ManualConfigurator($this->rootDir); @@ -112,7 +172,12 @@ public function testExecutorBasic(): void $result = $scheduler->run(); - self::assertCount(4, $result->getJobs()); + // Compat - orisai/scheduler v1 + if (method_exists($result, 'getJobs')) { + self::assertCount(4, $result->getJobs()); + } else { + self::assertCount(4, $result->getJobSummaries()); + } self::assertSame(2, $service->executions); self::assertSame(1, $job1->executions); @@ -139,20 +204,94 @@ public function testExecutorProcess(): void $result = $scheduler->run(); // Can't test the same way as basic executor, we are in different process - self::assertCount(2, $result->getJobs()); + // Compat - orisai/scheduler v1 + if (method_exists($result, 'getJobs')) { + self::assertCount(2, $result->getJobs()); + } else { + self::assertCount(2, $result->getJobSummaries()); + } + } + + public function testRunEvents(): void + { + // Compat - orisai/scheduler v1 + if (!method_exists(Scheduler::class, 'getJobSchedules')) { + self::markTestSkipped('Run events are available since v2'); + } + + $configurator = new ManualConfigurator($this->rootDir); + $configurator->setForceReloadContainer(); + $configurator->addConfig(__DIR__ . '/SchedulerExtension.runEvents.neon'); + + $container = $configurator->createContainer(); + + $scheduler = $container->getByType(Scheduler::class); + $recorder = $container->getByType(TestEventRecorder::class); + + self::assertSame([], $recorder->records); + + $scheduler->run(); + self::assertSame( + [ + 'before run', + 'before run', + 'before run', + 'before run', + 'after run', + 'after run', + 'after run', + 'after run', + ], + $recorder->records, + ); } - public function testEvents(): void + public function testLockedJobEvents(): void { + // Compat - orisai/scheduler v1 + if (!method_exists(Scheduler::class, 'getJobSchedules')) { + self::markTestSkipped('Locked job events are available since v2'); + } + $configurator = new ManualConfigurator($this->rootDir); $configurator->setForceReloadContainer(); - $configurator->addConfig(__DIR__ . '/SchedulerExtension.events.neon'); + $configurator->addConfig(__DIR__ . '/SchedulerExtension.lockedJobEvents.neon'); $container = $configurator->createContainer(); $scheduler = $container->getByType(Scheduler::class); + $lockFactory = $container->getByType(LockFactory::class); + $recorder = $container->getByType(TestEventRecorder::class); + + $scheduler->run(); + self::assertSame([], $recorder->records); + + $lock = $lockFactory->createLock('Orisai.Scheduler.Job/jobName'); + self::assertTrue($lock->acquire()); + $scheduler->run(); + self::assertSame( + [ + 'locked job', + 'locked job', + 'locked job', + 'locked job', + ], + $recorder->records, + ); + } + + public function testJobEvents(): void + { + $configurator = new ManualConfigurator($this->rootDir); + $configurator->setForceReloadContainer(); + $configurator->addConfig(__DIR__ . '/SchedulerExtension.jobEvents.neon'); + + $container = $configurator->createContainer(); + + $scheduler = $container->getByType(Scheduler::class); $recorder = $container->getByType(TestEventRecorder::class); + self::assertSame([], $recorder->records); $scheduler->run(); diff --git a/tools/phpstan.baseline.neon b/tools/phpstan.baseline.neon index f4b04af..4b0d516 100644 --- a/tools/phpstan.baseline.neon +++ b/tools/phpstan.baseline.neon @@ -1,21 +1,36 @@ parameters: ignoreErrors: - - message: "#^Cannot access property \\$records on Tests\\\\OriNette\\\\Scheduler\\\\Doubles\\\\TestEventRecorder\\|null\\.$#" - count: 2 - path: ../tests/Unit/DI/SchedulerExtensionTest.php + message: "#^Method OriNette\\\\Scheduler\\\\DI\\\\LazyJobManager\\:\\:getExpressions\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: ../src/DI/LazyJobManager.php - - message: "#^Cannot access property \\$records on Tests\\\\OriNette\\\\Scheduler\\\\Doubles\\\\TestSchedulerLogger\\|null\\.$#" - count: 2 - path: ../tests/Unit/DI/SchedulerExtensionTest.php + message: "#^Method OriNette\\\\Scheduler\\\\DI\\\\LazyJobManager\\:\\:getPair\\(\\) has parameter \\$id with no type specified\\.$#" + count: 1 + path: ../src/DI/LazyJobManager.php - - message: "#^Cannot call method run\\(\\) on Orisai\\\\Scheduler\\\\Scheduler\\|null\\.$#" - count: 5 - path: ../tests/Unit/DI/SchedulerExtensionTest.php + message: "#^Method OriNette\\\\Scheduler\\\\DI\\\\LazyJobManager\\:\\:getPair\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: ../src/DI/LazyJobManager.php + + - + message: "#^Method OriNette\\\\Scheduler\\\\DI\\\\LazyJobManager\\:\\:getPairs\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: ../src/DI/LazyJobManager.php - - message: "#^Cannot call method run\\(\\) on Symfony\\\\Component\\\\Console\\\\Application\\|null\\.$#" + message: "#^Call to function method_exists\\(\\) with 'Orisai\\\\\\\\Scheduler\\\\\\\\Scheduler' and 'getJobSchedules' will always evaluate to true\\.$#" count: 1 - path: ../tests/Unit/DI/schedulerExtensionProcessExecutable.php + path: ../src/DI/SchedulerExtension.php + + - + message: "#^Call to function method_exists\\(\\) with 'Orisai\\\\\\\\Scheduler\\\\\\\\Scheduler' and 'getJobSchedules' will always evaluate to true\\.$#" + count: 3 + path: ../tests/Unit/DI/SchedulerExtensionTest.php + + - + message: "#^Call to function method_exists\\(\\) with Orisai\\\\Scheduler\\\\Status\\\\RunSummary and 'getJobs' will always evaluate to false\\.$#" + count: 2 + path: ../tests/Unit/DI/SchedulerExtensionTest.php diff --git a/tools/phpstan.neon b/tools/phpstan.neon index 8437767..5861f7c 100644 --- a/tools/phpstan.neon +++ b/tools/phpstan.neon @@ -19,3 +19,7 @@ parameters: tooWideThrowType: true checkedExceptionClasses: - Orisai\Exceptions\Check\CheckedException + + excludePaths: + # Compat - orisai/scheduler v1 + - ../src/DI/LazyJobManagerV1.php