├── .github └── workflows │ └── tests.yml ├── Classes ├── Command │ └── TaskCommandController.php ├── Domain │ ├── Model │ │ └── TaskExecution.php │ ├── Repository │ │ └── TaskExecutionRepository.php │ ├── Runner │ │ ├── PendingExecutionFinder.php │ │ └── TaskRunner.php │ ├── Scheduler │ │ └── Scheduler.php │ └── Task │ │ ├── Task.php │ │ ├── TaskCollection.php │ │ ├── TaskCollectionFactory.php │ │ ├── TaskExecutionHistory.php │ │ ├── TaskInterface.php │ │ ├── TaskStatus.php │ │ ├── Workload.php │ │ └── WorkloadInterface.php ├── Exceptions │ ├── InvalidTaskHandlerException.php │ ├── TaskExitException.php │ ├── TaskFailedException.php │ ├── TaskNotFoundException.php │ └── TaskRetryException.php └── TaskHandler │ ├── LockingTaskHandlerInterface.php │ ├── RetryTaskHandlerInterface.php │ ├── TaskHandlerFactory.php │ └── TaskHandlerInterface.php ├── Configuration ├── Objects.yaml └── Settings.yaml ├── LICENSE ├── Migrations ├── Mysql │ ├── Version20210728112626.php │ └── Version20210915083410.php ├── Postgresql │ └── Version20220719155732.php └── Sqlite │ └── Version20220415132016.php ├── Readme.md ├── Tests └── Functional │ ├── Domain │ ├── Repository │ │ └── TaskExecutionRepositoryTest.php │ ├── Runner │ │ └── PendingExecutionFinderTest.php │ └── Scheduler │ │ └── SchedulerTest.php │ ├── Fixture │ └── TestHandler.php │ └── Helper │ └── TaskCollectionConfigurationHelper.php └── composer.json /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [main, '[0-9]+.[0-9]'] 6 | pull_request: 7 | branches: [main, '[0-9]+.[0-9]'] 8 | 9 | jobs: 10 | build: 11 | env: 12 | FLOW_CONTEXT: Testing 13 | PACKAGE_FOLDER: flow-base-distribution/DistributionPackages 14 | FLOW_FOLDER: flow-base-distribution 15 | PACKAGE_NAME: 'Flowpack.Task' 16 | REPOSITORY_NAME: 'flowpack/task' 17 | 18 | runs-on: ubuntu-latest 19 | name: PHP ${{ matrix.php-versions }} | Flow ${{ matrix.flow-versions }} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | php-versions: ['7.4', '8.1'] 24 | flow-versions: ['7.3'] 25 | dependencies: ['highest'] 26 | 27 | defaults: 28 | run: 29 | working-directory: ${{ env.FLOW_FOLDER }} 30 | 31 | steps: 32 | - name: Checkout Flow development distribution 33 | uses: actions/checkout@v2 34 | with: 35 | repository: neos/flow-development-distribution 36 | ref: ${{ matrix.flow-versions }} 37 | path: ${{ env.FLOW_FOLDER }} 38 | 39 | - name: Checkout package 40 | uses: actions/checkout@v2 41 | with: 42 | path: ${{ env.PACKAGE_FOLDER}}/${{ env.PACKAGE_NAME }} 43 | 44 | - name: Setup PHP, with composer and extensions 45 | uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php 46 | with: 47 | php-version: ${{ matrix.php-versions }} 48 | extensions: mbstring, xml, ctype, iconv, intl, pdo_sqlite, mysql 49 | coverage: xdebug #optional 50 | 51 | - name: Get composer cache directory 52 | id: composer-cache 53 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 54 | - name: Cache composer dependencies 55 | uses: actions/cache@v2 56 | with: 57 | path: ${{ steps.composer-cache.outputs.dir }} 58 | key: ${{ runner.os }}-php${{ matrix.php-versions }}-composer-${{ hashFiles('**/composer.json') }} 59 | restore-keys: ${{ runner.os }}-php${{ matrix.php-versions }}-composer- 60 | 61 | - name: Install dependencies 62 | run: | 63 | ls -la ./DistributionPackages 64 | git -C ../${{ env.PACKAGE_FOLDER}}/${{ env.PACKAGE_NAME }} checkout -b build 65 | composer config minimum-stability dev 66 | composer config prefer-stable true 67 | composer require --no-update --no-interaction --no-progress ${{ env.REPOSITORY_NAME }}:"dev-build as dev-master" 68 | composer ${{ matrix.dependencies == 'locked' && 'install' || 'update' }} --no-interaction --no-progress ${{ matrix.dependencies == 'lowest' && '--prefer-lowest' || '' }} ${{ matrix.composer-arguments }} 69 | 70 | - name: Set Flow Context 71 | run: echo "FLOW_CONTEXT=${{ env.FLOW_CONTEXT }}" >> $GITHUB_ENV 72 | 73 | - name: Setup Flow configuration 74 | run: | 75 | rm -f Configuration/Routes.yaml 76 | rm -f Configuration/Testing/Settings.yaml 77 | cat <> Configuration/Testing/Settings.yaml 78 | Neos: 79 | Flow: 80 | persistence: 81 | backendOptions: 82 | driver: pdo_sqlite 83 | memory: true 84 | user: 'neos' 85 | password: 'neos' 86 | dbname: 'flow_functional_testing' 87 | mvc: 88 | routes: 89 | 'Neos.Flow': FALSE 90 | 'Neos.Welcome': FALSE 91 | EOF 92 | 93 | - name: Run Unit tests 94 | run: | 95 | bin/phpunit --colors -c Build/BuildEssentials/PhpUnit/FunctionalTests.xml Packages/Application/${PACKAGE_NAME}/Tests/Functional 96 | -------------------------------------------------------------------------------- /Classes/Command/TaskCommandController.php: -------------------------------------------------------------------------------- 1 | 'error', 54 | TaskStatus::COMPLETED => 'success', 55 | TaskStatus::RUNNING => 'em', 56 | TaskStatus::ABORTED => 'strike' 57 | ]; 58 | 59 | /** 60 | * @throws \Exception 61 | */ 62 | public function runCommand(): void 63 | { 64 | $this->scheduler->scheduleTasks(); 65 | $this->taskRunner->runTasks(); 66 | $this->taskExecutionHistory->cleanup(); 67 | } 68 | 69 | /** 70 | * Run a task directly 71 | * 72 | * @param string $taskIdentifier 73 | * @throws \Exception 74 | */ 75 | public function runSingleCommand(string $taskIdentifier): void 76 | { 77 | $task = $this->getTaskByIdentifier($taskIdentifier); 78 | $this->scheduler->scheduleTask($task); 79 | $this->taskRunner->runTasks(); 80 | $this->scheduler->scheduleTasks(); 81 | $this->taskExecutionHistory->cleanup(); 82 | } 83 | 84 | /** 85 | * Lists all defined tasks 86 | * @throws \Exception 87 | */ 88 | public function listCommand(): void 89 | { 90 | $tasks = $this->taskCollectionFactory->buildTasksFromConfiguration()->toArray(); 91 | if ($tasks === []) { 92 | $this->outputLine('No tasks configured yet'); 93 | return; 94 | } 95 | $this->scheduler->scheduleTasks(); 96 | 97 | $this->output->outputTable(array_map(function (TaskInterface $task) { 98 | /** @var TaskExecution $latestExecution */ 99 | $latestExecution = $this->taskExecutionRepository->findLatestExecution($task, 1)->getFirst(); 100 | return [ 101 | $task->getIdentifier(), 102 | $task->getLabel(), 103 | $task->getCronExpression(), 104 | $task->getHandlerClass(), 105 | $latestExecution === null || $latestExecution->getEndTime() === null ? '-' : $latestExecution->getEndTime()->format('Y-m-d H:i:s') ?? $latestExecution->getStartTime()->format('Y-m-d H:i:s'), 106 | $latestExecution === null ? '-' : sprintf('<%s>%s', $this->lastExecutionStatusMapping[$latestExecution->getStatus()], $latestExecution->getStatus(), $this->lastExecutionStatusMapping[$latestExecution->getStatus()]), 107 | $latestExecution === null || $latestExecution->getDuration() === null ? '-' : number_format($latestExecution->getDuration(), 2) . ' s', 108 | $this->getNextExecutionInfo($task), 109 | ]; 110 | }, $tasks), 111 | ['Identifier', 'Label', 'Cron Expression', 'Handler Class', 'Previous Run Date', 'Previous Run Status', 'Previous Run Duration', 'Next Run'] 112 | ); 113 | } 114 | 115 | /** 116 | * @param string $taskIdentifier 117 | * @throws \JsonException|StopCommandException 118 | */ 119 | public function showCommand(string $taskIdentifier): void 120 | { 121 | $task = $this->getTaskByIdentifier($taskIdentifier); 122 | $this->outputLine(sprintf('%s (%s)', $task->getLabel(), $taskIdentifier)); 123 | $this->outputLine(PHP_EOL . $task->getDescription() . PHP_EOL); 124 | 125 | $this->outputLine('Task Info'); 126 | $this->output->outputTable( 127 | [ 128 | ['Cron Expression', $task->getCronExpression()], 129 | ['First Execution', $task->getFirstExecution() === null ? '-' : $task->getFirstExecution()->format('Y-m-d H:i:s')], 130 | ['Last Execution', $task->getLastExecution() === null ? '-' : $task->getLastExecution()->format('Y-m-d H:i:s')], 131 | ['Handler Class', $task->getHandlerClass()], 132 | ['Workload', $task->getWorkload() !== null ? json_encode($task->getWorkload()->getData(), JSON_THROW_ON_ERROR + JSON_PRETTY_PRINT) : '-'], 133 | ['Next Run', $this->getNextExecutionInfo($task)], 134 | ] 135 | ); 136 | 137 | $this->outputLine(PHP_EOL . 'Task Executions'); 138 | $taskExecutions = $this->taskExecutionRepository->findLatestExecution($task); 139 | 140 | if ($taskExecutions->count() === 0) { 141 | $this->outputLine('This task has not yet been executed.'); 142 | return; 143 | } 144 | 145 | $this->output->outputTable( 146 | array_map(function (TaskExecution $execution) { 147 | return [ 148 | sprintf('%s', $execution->getScheduleTime()->format('Y-m-d H:i:s')), 149 | number_format($execution->getDuration(), 2) . ' s', 150 | sprintf('<%s>%s %s %s', $this->lastExecutionStatusMapping[$execution->getStatus()], $execution->getStatus(), $this->lastExecutionStatusMapping[$execution->getStatus()], $execution->getResult(), $execution->getException()), 151 | ]; 152 | }, $taskExecutions->toArray()), 153 | ['Date','Run Duration', 'Status'] 154 | ); 155 | } 156 | 157 | /** 158 | * @param TaskInterface $task 159 | * @return string 160 | */ 161 | private function getNextExecutionInfo(TaskInterface $task): string 162 | { 163 | $nextExecution = $this->taskExecutionRepository->findNextScheduled((new \DateTime())->add(new \DateInterval('P10Y')), [], $task); 164 | $nextExecutionInfo = 'Not Scheduled'; 165 | if ($nextExecution instanceof TaskExecution) { 166 | $nextExecutionDate = $nextExecution->getScheduleTime()->format('Y-m-d H:i:s'); 167 | $nextExecutionInfo = $nextExecution->getScheduleTime() < (new \DateTime()) ? sprintf('%s (delayed)', $nextExecutionDate) : $nextExecutionDate; 168 | } 169 | return $nextExecutionInfo; 170 | } 171 | 172 | /** 173 | * @param string $taskIdentifier 174 | * @return TaskInterface 175 | * @throws StopCommandException 176 | */ 177 | private function getTaskByIdentifier(string $taskIdentifier): TaskInterface 178 | { 179 | try { 180 | return $this->taskCollectionFactory->buildTasksFromConfiguration()->getTask($taskIdentifier); 181 | } catch (\InvalidArgumentException $exception) { 182 | $this->outputLine('No task with id "%s" is configured', [$taskIdentifier]); 183 | $this->quit(1); 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Classes/Domain/Model/TaskExecution.php: -------------------------------------------------------------------------------- 1 | taskIdentifier = $task->getIdentifier(); 81 | $this->workload = $task->getWorkload(); 82 | $this->handlerClass = $task->getHandlerClass(); 83 | $this->scheduleTime = $scheduleTime; 84 | } 85 | 86 | /** 87 | * @return string 88 | */ 89 | public function getStatus(): string 90 | { 91 | return $this->status; 92 | } 93 | 94 | /** 95 | * @param string $status 96 | * @return TaskExecution 97 | */ 98 | public function setStatus(string $status): TaskExecution 99 | { 100 | $this->status = $status; 101 | return $this; 102 | } 103 | 104 | /** 105 | * @return string 106 | */ 107 | public function getTaskIdentifier(): string 108 | { 109 | return $this->taskIdentifier; 110 | } 111 | 112 | /** 113 | * @return Workload|null 114 | */ 115 | public function getWorkload(): ?Workload 116 | { 117 | return $this->workload; 118 | } 119 | 120 | /** 121 | * @return string 122 | */ 123 | public function getHandlerClass(): string 124 | { 125 | return $this->handlerClass; 126 | } 127 | 128 | /** 129 | * @return \DateTime 130 | */ 131 | public function getScheduleTime(): \DateTime 132 | { 133 | return $this->scheduleTime; 134 | } 135 | 136 | /** 137 | * @return \DateTime 138 | */ 139 | public function getStartTime(): \DateTime 140 | { 141 | return $this->startTime; 142 | } 143 | 144 | /** 145 | * @return \DateTime|null 146 | */ 147 | public function getEndTime(): ?\DateTime 148 | { 149 | return $this->endTime; 150 | } 151 | 152 | /** 153 | * @return float|null 154 | */ 155 | public function getDuration(): ?float 156 | { 157 | return $this->duration; 158 | } 159 | 160 | /** 161 | * @return string|null 162 | */ 163 | public function getResult(): ?string 164 | { 165 | return $this->result; 166 | } 167 | 168 | /** 169 | * @return string|null 170 | */ 171 | public function getException(): ?string 172 | { 173 | return $this->exception; 174 | } 175 | 176 | /** 177 | * @return int 178 | */ 179 | public function getAttempts(): int 180 | { 181 | return $this->attempts; 182 | } 183 | 184 | /** 185 | * @param \DateTime $startTime 186 | * @return TaskExecution 187 | */ 188 | public function setStartTime(\DateTime $startTime): TaskExecution 189 | { 190 | $this->startTime = $startTime; 191 | return $this; 192 | } 193 | 194 | /** 195 | * @param \DateTime $endTime 196 | * @return TaskExecution 197 | */ 198 | public function setEndTime(\DateTime $endTime): TaskExecution 199 | { 200 | $this->endTime = $endTime; 201 | return $this; 202 | } 203 | 204 | /** 205 | * @param float $duration 206 | * @return TaskExecution 207 | */ 208 | public function setDuration(float $duration): TaskExecution 209 | { 210 | $this->duration = $duration; 211 | return $this; 212 | } 213 | 214 | /** 215 | * @param string $result 216 | * @return TaskExecution 217 | */ 218 | public function setResult(string $result): TaskExecution 219 | { 220 | $this->result = $result; 221 | return $this; 222 | } 223 | 224 | /** 225 | * @param string|null $exception 226 | * @return TaskExecution 227 | */ 228 | public function setException(?string $exception): TaskExecution 229 | { 230 | $this->exception = $exception; 231 | return $this; 232 | } 233 | 234 | /** 235 | * @param int $attempts 236 | * @return TaskExecution 237 | */ 238 | public function setAttempts(int $attempts): TaskExecution 239 | { 240 | $this->attempts = $attempts; 241 | return $this; 242 | } 243 | 244 | public function reset(): TaskExecution 245 | { 246 | $this->startTime = null; 247 | $this->endTime = null; 248 | $this->result = null; 249 | $this->exception = null; 250 | $this->status = TaskStatus::PLANNED; 251 | 252 | return $this; 253 | } 254 | 255 | public function incrementAttempts(): TaskExecution 256 | { 257 | $this->attempts++; 258 | return $this; 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /Classes/Domain/Repository/TaskExecutionRepository.php: -------------------------------------------------------------------------------- 1 | createQuery(); 27 | $query->matching( 28 | $query->logicalAnd( 29 | $query->equals('taskIdentifier', $task->getIdentifier()), 30 | $query->logicalOr( 31 | $query->equals('status', TaskStatus::PLANNED), 32 | $query->equals('status', TaskStatus::RUNNING), 33 | ) 34 | ) 35 | ); 36 | return $query->execute(); 37 | } 38 | 39 | public function findByTask(Task $task): QueryResultInterface 40 | { 41 | $query = $this->createQuery(); 42 | $query->matching( 43 | $query->equals('taskIdentifier', $task->getIdentifier()), 44 | ); 45 | return $query->execute(); 46 | } 47 | 48 | public function removePlannedTask(Task $task): void 49 | { 50 | $query = $this->createQuery(); 51 | $query->matching( 52 | $query->logicalAnd( 53 | $query->equals('taskIdentifier', $task->getIdentifier()), 54 | $query->equals('status', TaskStatus::PLANNED) 55 | ) 56 | ); 57 | 58 | foreach ($query->execute() as $scheduledTask) { 59 | try { 60 | $this->remove($scheduledTask); 61 | } catch (ORMException|IllegalObjectTypeException $e) { 62 | throw new \RuntimeException('Failed to remove task from execution repository', 1645610863, $e); 63 | } 64 | } 65 | } 66 | 67 | public function findLatestExecution(Task $task, int $limit = 5, int $offset = 0): QueryResultInterface 68 | { 69 | $query = $this->createQuery(); 70 | 71 | $query->matching( 72 | $query->logicalAnd( 73 | $query->equals('taskIdentifier', $task->getIdentifier()), 74 | $query->logicalNot( 75 | $query->equals('status', TaskStatus::PLANNED) 76 | ) 77 | ) 78 | ) 79 | ->setOrderings(['scheduleTime' => QueryInterface::ORDER_DESCENDING]); 80 | 81 | if ($limit > 0) { 82 | $query->setLimit($limit); 83 | } 84 | 85 | if ($offset > 0) { 86 | $query->setOffset($offset); 87 | } 88 | 89 | return $query->execute(); 90 | } 91 | 92 | public function findNextScheduled(DateTime $runTime, array $skippedExecutions = [], Task $task = null): ?TaskExecution 93 | { 94 | $queryBuilder = $this->createQueryBuilder('taskExecution'); 95 | 96 | $queryBuilder 97 | ->where($queryBuilder->expr()->lte('taskExecution.scheduleTime', ':scheduleTime')) 98 | ->andWhere($queryBuilder->expr()->eq('taskExecution.status', ':status')) 99 | ->orderBy('taskExecution.scheduleTime', QueryInterface::ORDER_DESCENDING) 100 | ->setMaxResults(1) 101 | ->setParameter('scheduleTime', $runTime, Types::DATETIME_MUTABLE) 102 | ->setParameter('status', TaskStatus::PLANNED); 103 | 104 | if (!empty($skippedExecutions)) { 105 | $queryBuilder->andWhere( 106 | $queryBuilder->expr()->not($queryBuilder->expr()->in('taskExecution.Persistence_Object_Identifier', ':skippedExecutions')) 107 | )->setParameter('skippedExecutions', $skippedExecutions); 108 | } 109 | 110 | if ($task !== null) { 111 | $queryBuilder->andWhere( 112 | $queryBuilder->expr()->eq('taskExecution.taskIdentifier', ':taskIdentifier')) 113 | ->setParameter('taskIdentifier', $task->getIdentifier()); 114 | } 115 | 116 | return $queryBuilder->getQuery()->getOneOrNullResult(AbstractQuery::HYDRATE_OBJECT); 117 | } 118 | 119 | 120 | } 121 | -------------------------------------------------------------------------------- /Classes/Domain/Runner/PendingExecutionFinder.php: -------------------------------------------------------------------------------- 1 | lockStorageConfiguration)); 46 | 47 | $skippedExecutions = []; 48 | while ($execution = $this->taskExecutionRepository->findNextScheduled($runTime, $skippedExecutions)) { 49 | $handler = $this->taskHandlerFactory->get($execution->getHandlerClass()); 50 | 51 | if (!$handler instanceof LockingTaskHandlerInterface) { 52 | yield $execution; 53 | continue; 54 | } 55 | 56 | $lock = $lockFactory->createLock($handler->getLockIdentifier($execution->getWorkload())); 57 | 58 | if (!$lock->acquire()) { 59 | $skippedExecutions[] = $execution; 60 | $this->logger->warning(sprintf('Execution "%s" is locked and skipped.', $execution->getTaskIdentifier()), LogEnvironment::fromMethodName(__METHOD__)); 61 | continue; 62 | } 63 | 64 | yield $execution; 65 | 66 | $lock->release(); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Classes/Domain/Runner/TaskRunner.php: -------------------------------------------------------------------------------- 1 | executionFinder->findNext() as $execution) { 72 | try { 73 | $this->run($execution); 74 | } catch (TaskExitException $exception) { 75 | return; 76 | } 77 | } 78 | } 79 | 80 | /** 81 | * @param TaskExecution $execution 82 | * @throws TaskExitException 83 | * @throws IllegalObjectTypeException 84 | * @throws UnknownObjectException 85 | */ 86 | private function run(TaskExecution $execution): void 87 | { 88 | $startTime = microtime(true); 89 | $execution->setStartTime(new DateTime()); 90 | $execution->setStatus(TaskStatus::RUNNING); 91 | $this->taskExecutionRepository->update($execution); 92 | 93 | try { 94 | $execution = $this->hasPassed($execution, $this->handle($execution)); 95 | } catch (TaskExitException $exception) { 96 | throw $exception; 97 | } catch (\Throwable $exception) { 98 | $execution = $this->hasFailed($execution, $exception); 99 | } finally { 100 | $this->finalize($execution, $startTime); 101 | } 102 | } 103 | 104 | /** 105 | * Handle given execution and fire before and after events. 106 | * 107 | * @throws \Throwable 108 | */ 109 | private function handle(TaskExecution $execution): string 110 | { 111 | try { 112 | return $this->execute($execution); 113 | } catch (TaskRetryException $exception) { 114 | // this find is necessary because the storage could be 115 | // invalid (clear in doctrine) after handling an execution. 116 | $execution = $this->reFetchExecution($execution); 117 | 118 | if ($execution->getAttempts() === $exception->getMaximumAttempts()) { 119 | throw $exception->getPrevious(); 120 | } 121 | 122 | $execution->reset()->incrementAttempts(); 123 | 124 | $this->taskExecutionRepository->update($execution); 125 | 126 | throw new TaskExitException(); 127 | } 128 | } 129 | 130 | /** 131 | * @throws \Throwable 132 | */ 133 | public function execute(TaskExecution $execution): string 134 | { 135 | $handler = $this->taskHandlerFactory->get($execution->getHandlerClass()); 136 | 137 | try { 138 | $this->logger->info(sprintf('Start running task %s', $execution->getTaskIdentifier()), LogEnvironment::fromMethodName(__METHOD__)); 139 | return $handler->handle($execution->getWorkload()); 140 | } catch (TaskFailedException $exception) { 141 | $this->logger->error(sprintf('Task %s failed with exception "%s"', $execution->getTaskIdentifier(), $exception->getPrevious() !== null ? $exception->getPrevious()->getMessage() : $exception->getMessage()), LogEnvironment::fromMethodName(__METHOD__)); 142 | throw $exception->getPrevious(); 143 | } catch (Exception $exception) { 144 | if (!$handler instanceof TaskRetryException) { 145 | $this->logger->error(sprintf('Task %s failed with exception "%s"', $execution->getTaskIdentifier(), $exception->getMessage()), LogEnvironment::fromMethodName(__METHOD__)); 146 | throw $exception; 147 | } 148 | 149 | $this->logger->warning(sprintf('Restarting Task %s, after failing with exception "%s"', $execution->getTaskIdentifier(), $exception->getMessage()), LogEnvironment::fromMethodName(__METHOD__)); 150 | throw new TaskRetryException($handler->getMaximumAttempts(), $exception); 151 | } 152 | } 153 | 154 | /** 155 | * The given task passed the run. 156 | */ 157 | private function hasPassed(TaskExecution $execution, string $result): TaskExecution 158 | { 159 | $execution = $this->reFetchExecution($execution); 160 | $execution->setStatus(TaskStatus::COMPLETED); 161 | $execution->setResult($result); 162 | 163 | return $execution; 164 | } 165 | 166 | private function hasFailed(TaskExecution $execution, \Throwable $throwable): TaskExecution 167 | { 168 | $execution = $this->reFetchExecution($execution); 169 | $execution->setException($throwable->__toString()); 170 | $execution->setStatus(TaskStatus::FAILED); 171 | 172 | return $execution; 173 | } 174 | 175 | private function finalize(TaskExecution $execution, float $startTime): void 176 | { 177 | $execution = $this->reFetchExecution($execution); 178 | if ($execution->getStatus() !== TaskStatus::PLANNED) { 179 | $execution->setEndTime(new DateTime()); 180 | $execution->setDuration(microtime(true) - $startTime); 181 | } 182 | 183 | $this->taskExecutionRepository->update($execution); 184 | } 185 | 186 | /** 187 | * This find is necessary because the storage could be 188 | * invalid (clear in doctrine) after handling an execution. 189 | */ 190 | private function reFetchExecution(TaskExecution $execution): TaskExecution 191 | { 192 | try { 193 | $this->persistenceManager->persistAll(); 194 | } catch (PersistenceException $e) { 195 | throw new \RuntimeException('Failed persist executions', 1645611214, $e); 196 | } 197 | /** @var TaskExecution $newExecution */ 198 | try { 199 | $newExecution = $this->taskExecutionRepository->findByIdentifier($this->persistenceManager->getIdentifierByObject($execution)); 200 | } catch (OptimisticLockException|TransactionRequiredException|ORMException|PropertyNotAccessibleException $e) { 201 | throw new \RuntimeException('Failed to re-fetch task execution', 1645611153, $e); 202 | } 203 | return $newExecution; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /Classes/Domain/Scheduler/Scheduler.php: -------------------------------------------------------------------------------- 1 | taskCollectionFactory->buildTasksFromConfiguration()->filterEndBeforeNow() as $task) { 39 | $this->scheduleTask($task); 40 | } 41 | 42 | $this->persistenceManager->persistAll(); 43 | } 44 | 45 | public function scheduleTaskForDate(string $taskIdentifier, \DateTime $runDate): void 46 | { 47 | $task = $this->taskCollectionFactory->buildTasksFromConfiguration()->getTask($taskIdentifier); 48 | $task->setCronExpression(null); 49 | $task->setFirstExecution($runDate); 50 | $this->taskExecutionRepository->removePlannedTask($task); 51 | $this->persistenceManager->persistAll(); 52 | $this->scheduleTask($task); 53 | } 54 | 55 | /** 56 | * Schedule execution for given task. 57 | * 58 | * @param Task $task 59 | * @throws \Exception 60 | */ 61 | public function scheduleTask(Task $task): void 62 | { 63 | $scheduledTasks = $this->taskExecutionRepository->findPending($task); 64 | 65 | if ($scheduledTasks->count() > 0) { 66 | return; 67 | } 68 | 69 | if ($task->getCronExpression() === null && count($this->taskExecutionRepository->findByTask($task)) > 0) { 70 | return; 71 | } 72 | 73 | $nextCronRunDate = $task->getCronExpression() ? $task->getCronExpression()->getNextRunDate() : null; 74 | 75 | if ($nextCronRunDate !== null && $nextCronRunDate > $task->getFirstExecution()) { 76 | $scheduleTime = $task->getCronExpression()->getNextRunDate(); 77 | } else { 78 | $scheduleTime = $task->getFirstExecution(); 79 | } 80 | 81 | $nextExecution = new TaskExecution($task, $scheduleTime); 82 | $this->taskExecutionRepository->add($nextExecution); 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /Classes/Domain/Task/Task.php: -------------------------------------------------------------------------------- 1 | identifier = $identifier; 38 | $this->handlerClass = $handlerClass; 39 | $this->label = $label; 40 | $this->description = $description; 41 | $this->workload = $workload; 42 | $this->cronExpression = $cronExpression; 43 | 44 | $this->firstExecution = $firstExecution; 45 | $this->lastExecution = $lastExecution; 46 | } 47 | 48 | public function getIdentifier(): string 49 | { 50 | return $this->identifier; 51 | } 52 | 53 | public function getHandlerClass(): string 54 | { 55 | return $this->handlerClass; 56 | } 57 | 58 | public function getLabel(): string 59 | { 60 | return $this->label; 61 | } 62 | 63 | public function getDescription(): string 64 | { 65 | return $this->description; 66 | } 67 | 68 | public function getCronExpression(): ?CronExpression 69 | { 70 | return $this->cronExpression; 71 | } 72 | 73 | public function getWorkload(): ?Workload 74 | { 75 | return $this->workload; 76 | } 77 | 78 | public function getFirstExecution(): ?\DateTime 79 | { 80 | return $this->firstExecution; 81 | } 82 | 83 | public function getLastExecution(): ?\DateTime 84 | { 85 | return $this->lastExecution; 86 | } 87 | 88 | /** 89 | * @param CronExpression|null $cronExpression 90 | * @return Task 91 | */ 92 | public function setCronExpression(?CronExpression $cronExpression): Task 93 | { 94 | $this->cronExpression = $cronExpression; 95 | return $this; 96 | } 97 | 98 | /** 99 | * @param \DateTime|null $firstExecution 100 | * @return Task 101 | */ 102 | public function setFirstExecution(?\DateTime $firstExecution): Task 103 | { 104 | $this->firstExecution = $firstExecution; 105 | return $this; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Classes/Domain/Task/TaskCollection.php: -------------------------------------------------------------------------------- 1 | get($taskIdentifier); 13 | if ($task === null) { 14 | throw new \InvalidArgumentException(sprintf('Task "%s" does not exist in this collection', $taskIdentifier), 1645610446); 15 | } 16 | return $task; 17 | } 18 | 19 | public function filterEndBeforeNow(): ArrayCollection 20 | { 21 | return $this->filter(static function (Task $task) { 22 | return $task->getLastExecution() === null || $task->getLastExecution() > new \DateTime(); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Classes/Domain/Task/TaskCollectionFactory.php: -------------------------------------------------------------------------------- 1 | taskCollection instanceof TaskCollection) { 25 | return $this->taskCollection; 26 | } 27 | 28 | $this->taskCollection = new TaskCollection(); 29 | 30 | foreach ($this->taskConfigurations as $taskIdentifier => $taskConfiguration) { 31 | 32 | $cronExpressionPattern = $taskConfiguration['cronExpression'] ?? ''; 33 | $cronExpression = $cronExpressionPattern !== '' ? new CronExpression($cronExpressionPattern) : null; 34 | 35 | $this->taskCollection->set($taskIdentifier, new Task( 36 | $taskIdentifier, 37 | $cronExpression, 38 | $taskConfiguration['handlerClass'], 39 | $taskConfiguration['label'] ?? $taskIdentifier, 40 | $taskConfiguration['description'] ?? '', 41 | new Workload($taskConfiguration['workload'] ?? []), 42 | new \DateTime($taskConfiguration['firstExecution'] ?? 'now'), 43 | ($taskConfiguration['lastExecution'] ?? null) === null ? null : new \DateTime($taskConfiguration['lastExecution']) 44 | )); 45 | } 46 | 47 | return $this->taskCollection; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Classes/Domain/Task/TaskExecutionHistory.php: -------------------------------------------------------------------------------- 1 | taskCollectionFactory->buildTasksFromConfiguration() as $task) { 55 | /** @var TaskExecution $taskExecution */ 56 | foreach ($this->taskExecutionRepository->findLatestExecution($task, 0, $this->keepTaskExecutionHistory) as $taskExecution) { 57 | $this->taskExecutionRepository->remove($taskExecution); 58 | $removedTaskExecutions++; 59 | } 60 | } 61 | 62 | if ($removedTaskExecutions > 0) { 63 | $this->logger->info(sprintf('Removed %s completed task executions', $removedTaskExecutions), LogEnvironment::fromMethodName(__METHOD__)); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Classes/Domain/Task/TaskInterface.php: -------------------------------------------------------------------------------- 1 | data = $data; 16 | } 17 | 18 | public function getData(): array 19 | { 20 | return $this->data; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Classes/Domain/Task/WorkloadInterface.php: -------------------------------------------------------------------------------- 1 | getMessage(), $previous->getCode(), $previous); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Classes/Exceptions/TaskNotFoundException.php: -------------------------------------------------------------------------------- 1 | getMessage(), $previous->getCode(), $previous); 16 | 17 | $this->maximumAttempts = $maximumAttempts; 18 | } 19 | 20 | public function getMaximumAttempts(): int 21 | { 22 | return $this->maximumAttempts; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Classes/TaskHandler/LockingTaskHandlerInterface.php: -------------------------------------------------------------------------------- 1 | objectManager->get($taskHandlerClassName); 41 | 42 | if (!$taskHandler instanceof TaskHandlerInterface) { 43 | throw new InvalidTaskHandlerException(sprintf('The taskHandler class "%s" is not of type "%s"', $taskHandlerClassName, TaskHandlerInterface::class), 1627477053); 44 | } 45 | 46 | return $taskHandler; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Classes/TaskHandler/TaskHandlerInterface.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); 24 | 25 | $this->addSql('CREATE TABLE flowpack_task_domain_model_taskexecution (persistence_object_identifier VARCHAR(40) NOT NULL, taskidentifier VARCHAR(255) NOT NULL, workload LONGTEXT NOT NULL COMMENT \'(DC2Type:object)\', handlerclass VARCHAR(255) NOT NULL, scheduletime DATETIME NOT NULL, starttime DATETIME DEFAULT NULL, endtime DATETIME DEFAULT NULL, duration DOUBLE PRECISION DEFAULT NULL, status VARCHAR(255) NOT NULL, result VARCHAR(255) DEFAULT NULL, exception VARCHAR(255) DEFAULT NULL, attempts INT NOT NULL, PRIMARY KEY(persistence_object_identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 26 | 27 | } 28 | 29 | public function down(Schema $schema): void 30 | { 31 | // this down() migration is auto-generated, please modify it to your needs 32 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); 33 | $this->addSql('DROP TABLE flowpack_task_domain_model_taskexecution'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20210915083410.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); 19 | $this->addSql('ALTER TABLE flowpack_task_domain_model_taskexecution CHANGE exception exception LONGTEXT DEFAULT NULL'); 20 | } 21 | 22 | public function down(Schema $schema): void 23 | { 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Migrations/Postgresql/Version20220719155732.php: -------------------------------------------------------------------------------- 1 | abortIf( 20 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSqlPlatform, 21 | "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\PostgreSqlPlatform'." 22 | ); 23 | 24 | $this->addSql('CREATE TABLE flowpack_task_domain_model_taskexecution (persistence_object_identifier VARCHAR(40) NOT NULL, taskidentifier VARCHAR(255) NOT NULL, workload TEXT NOT NULL, handlerclass VARCHAR(255) NOT NULL, scheduletime TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, starttime TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, endtime TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, duration DOUBLE PRECISION DEFAULT NULL, status VARCHAR(255) NOT NULL, result VARCHAR(255) DEFAULT NULL, exception TEXT DEFAULT NULL, attempts INT NOT NULL, PRIMARY KEY(persistence_object_identifier))'); 25 | $this->addSql('COMMENT ON COLUMN flowpack_task_domain_model_taskexecution.workload IS \'(DC2Type:object)\''); 26 | } 27 | 28 | public function down(Schema $schema): void 29 | { 30 | $this->abortIf( 31 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSqlPlatform, 32 | "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\PostgreSqlPlatform'." 33 | ); 34 | 35 | $this->addSql('DROP TABLE flowpack_task_domain_model_taskexecution'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Migrations/Sqlite/Version20220415132016.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'sqlite', 'Migration can only be executed safely on \'sqlite\'.'); 24 | 25 | $this->addSql('CREATE TABLE flowpack_task_domain_model_taskexecution (persistence_object_identifier VARCHAR(40) NOT NULL, taskidentifier VARCHAR(255) NOT NULL, workload CLOB NOT NULL --(DC2Type:object) 26 | , handlerclass VARCHAR(255) NOT NULL, scheduletime DATETIME NOT NULL, starttime DATETIME DEFAULT NULL, endtime DATETIME DEFAULT NULL, duration DOUBLE PRECISION DEFAULT NULL, status VARCHAR(255) NOT NULL, result VARCHAR(255) DEFAULT NULL, exception CLOB DEFAULT NULL, attempts INTEGER NOT NULL, PRIMARY KEY(persistence_object_identifier))'); 27 | } 28 | 29 | public function down(Schema $schema): void 30 | { 31 | // this down() migration is auto-generated, please modify it to your needs 32 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'sqlite', 'Migration can only be executed safely on \'sqlite\'.'); 33 | 34 | $this->addSql('DROP TABLE flowpack_task_domain_model_taskexecution'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Flow Framework Task Scheduler 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/flowpack/task/v/stable)](https://packagist.org/packages/flowpack/task) [![Total Downloads](https://poser.pugx.org/flowpack/task/downloads)](https://packagist.org/packages/flowpack/task) [![License](https://poser.pugx.org/flowpack/task/license)](https://packagist.org/packages/flowpack/task) 4 | 5 | This package provides a simple to use task scheduler for Neos Flow. Tasks are configured via settings, recurring tasks can be configured using cron syntax. Detailed options configure the first and last executions as well as options for the class handling the task. 6 | 7 | Scheduling and running tasks are decoupled: The `Scheduler` schedules tasks whcih the are executed by the `TaskRunner`. This architecture allows receiving and displaying metrics of already executed tasks. 8 | 9 | Most of the architectural ideas behind the package are taken from [php-task](https://github.com/php-task/php-task), and reimplemented for Neos Flow. 10 | 11 | ## Installation 12 | 13 | ```bash 14 | composer require 'flowpack/task' 15 | ``` 16 | 17 | ## Configuration 18 | 19 | ### Defining A Task 20 | 21 | ```yaml 22 | Flowpack: 23 | Task: 24 | tasks: 25 | 'a-unique-identifier': 26 | label: The label of this task 27 | description: Some detailed description of this task 28 | # A class, implementing the TaskHandlerInterface 29 | handlerClass: 'Vendor\Package\TaskHandler\TaskHandlerClass' 30 | cronExpression: '*/5 * * * *' 31 | # A workload, eg. some configuration, given to the taskHandler 32 | workload: 33 | interval: PT5M 34 | ``` 35 | 36 | ### General Options 37 | 38 | * `lockStorage`: Configuration string for the lock storage used for taskHandler implementing `LockingTaskHandlerInterface`. See https://symfony.com/doc/current/components/lock.html#available-stores for more options 39 | 40 | * `keepTaskExecutionHistory`: Number of task executions to keep in the database. (default: 3) 41 | 42 | ## Implementing A Task Handler 43 | 44 | A task handler contains the code executed for a specific task. Your command handler has to implement one of the following interfaces: 45 | 46 | `Flowpack\Task\TaskHandler\TaskHandlerInterface` 47 | 48 | A basic task. The interface requires the method `handle(WorkloadInterface $workload): string` to be implemented. The return value serves as information for successfully executed tasks. 49 | 50 | `Flowpack\Task\TaskHandler\RetryTaskHandlerInterface` 51 | 52 | Also requires `getMaximumAttempts(): int` to be implemented. Allowing the tasks to be retried on failure. 53 | 54 | `Flowpack\Task\TaskHandler\LockingTaskHandlerInterface` 55 | 56 | Also requires `getLockIdentifier(WorkloadInterface $workload): string` to be implemented. The return value specifies a lock to be acquired. When such a task is running, other tasks requiring the same lock will be skipped. 57 | 58 | ## Available Commands 59 | 60 | Schedule and run due tasks 61 | 62 | ```bash 63 | ./flow task:run 64 | ``` 65 | Schedule and run a single task 66 | 67 | ```bash 68 | ./flow task:runSingle 69 | ``` 70 | 71 | Show a list of all defined and scheduled tasks: 72 | 73 | ```bash 74 | ./flow task:list 75 | ``` 76 | 77 | Show details about a specific task: 78 | 79 | ```bash 80 | ./flow task:show 81 | ``` 82 | -------------------------------------------------------------------------------- /Tests/Functional/Domain/Repository/TaskExecutionRepositoryTest.php: -------------------------------------------------------------------------------- 1 | taskCollectionFactory = $this->objectManager->get(TaskCollectionFactory::class); 29 | $this->taskExecutionRepository = $this->objectManager->get(TaskExecutionRepository::class); 30 | $this->scheduler = $this->objectManager->get(Scheduler::class); 31 | } 32 | 33 | /** 34 | * @test 35 | */ 36 | public function findNext(): void 37 | { 38 | TaskCollectionConfigurationHelper::prepareTaskCollectionFactoryWithConfiguration( 39 | $this->taskCollectionFactory, 40 | [ 41 | 'taskOne' => [ 42 | 'handlerClass' => TestHandler::class, 43 | 'cronExpression' => '* * * * *', 44 | ], 45 | 'taskTwo' => [ 46 | 'handlerClass' => TestHandler::class, 47 | 'cronExpression' => '5 * * * *', 48 | ], 49 | 'taskThree' => [ 50 | 'handlerClass' => TestHandler::class, 51 | 'cronExpression' => '* * * * *', 52 | 'firstExecution' => '2100-01-01 00:00:00', 53 | ] 54 | ] 55 | ); 56 | 57 | $this->scheduler->scheduleTasks(); 58 | $this->persistenceManager->persistAll(); 59 | 60 | $runTime = (new \DateTime())->add(new \DateInterval('PT8H')); 61 | 62 | $nextExecution = $this->taskExecutionRepository->findNextScheduled($runTime); 63 | self::assertNotNull($nextExecution, 'No execution found'); 64 | 65 | self::assertEquals('taskTwo', $nextExecution->getTaskIdentifier(), 'First one should be taskTwo'); 66 | 67 | $nextExecutionExcluded = $this->taskExecutionRepository->findNextScheduled($runTime, [$nextExecution]); 68 | self::assertNotNull($nextExecutionExcluded, 'No execution found'); 69 | 70 | self::assertEquals('taskOne', $nextExecutionExcluded->getTaskIdentifier()); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /Tests/Functional/Domain/Runner/PendingExecutionFinderTest.php: -------------------------------------------------------------------------------- 1 | taskCollectionFactory = $this->objectManager->get(TaskCollectionFactory::class); 32 | $this->taskExecutionRepository = $this->objectManager->get(TaskExecutionRepository::class); 33 | $this->pendingExecutionFinder = $this->objectManager->get(PendingExecutionFinder::class); 34 | $this->scheduler = $this->objectManager->get(Scheduler::class); 35 | } 36 | 37 | /** 38 | * @test 39 | */ 40 | public function findNext(): void 41 | { 42 | TaskCollectionConfigurationHelper::prepareTaskCollectionFactoryWithConfiguration( 43 | $this->taskCollectionFactory, 44 | [ 45 | 'taskOne' => [ 46 | 'handlerClass' => TestHandler::class, 47 | 'cronExpression' => '0 0 * * *', 48 | 49 | ], 50 | 'taskTwo' => [ 51 | 'handlerClass' => TestHandler::class, 52 | 'cronExpression' => '* * * * *', 53 | 54 | ], 55 | 'taskThree' => [ 56 | 'handlerClass' => TestHandler::class, 57 | 'cronExpression' => '* * * * *', 58 | 'firstExecution' => '2100-01-01 00:00:00', 59 | ] 60 | ] 61 | ); 62 | 63 | $this->scheduler->scheduleTasks(); 64 | $this->persistenceManager->persistAll(); 65 | 66 | $nextExecution = $this->pendingExecutionFinder->findNext(); 67 | 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/Functional/Domain/Scheduler/SchedulerTest.php: -------------------------------------------------------------------------------- 1 | taskCollectionFactory = $this->objectManager->get(TaskCollectionFactory::class); 32 | $this->scheduler = $this->objectManager->get(Scheduler::class); 33 | $this->taskExecutionRepository = $this->objectManager->get(TaskExecutionRepository::class); 34 | } 35 | 36 | /** 37 | * @test 38 | * @throws Exception 39 | */ 40 | public function taskGetsScheduledOnce(): void 41 | { 42 | TaskCollectionConfigurationHelper::prepareTaskCollectionFactoryWithConfiguration( 43 | $this->taskCollectionFactory, 44 | [ 45 | 'singleTask' => [ 46 | 'handlerClass' => TestHandler::class, 47 | ] 48 | ] 49 | ); 50 | 51 | $this->scheduler->scheduleTasks(); 52 | $this->persistenceManager->persistAll(); 53 | $this->scheduler->scheduleTasks(); 54 | $this->persistenceManager->persistAll(); 55 | 56 | $taskExecutions = $this->taskExecutionRepository->findAll(); 57 | self::assertEquals(1, $taskExecutions->count()); 58 | } 59 | 60 | /** 61 | * @test 62 | */ 63 | public function taskIsNotScheduledIfLastExecutionIsInThePast(): void 64 | { 65 | TaskCollectionConfigurationHelper::prepareTaskCollectionFactoryWithConfiguration( 66 | $this->taskCollectionFactory, 67 | [ 68 | 'taskInThePast' => [ 69 | 'handlerClass' => TestHandler::class, 70 | 'lastExecution' => '2021-01-01', 71 | ] 72 | ] 73 | ); 74 | 75 | $this->scheduler->scheduleTasks(); 76 | $this->persistenceManager->persistAll(); 77 | 78 | $taskExecutions = $this->taskExecutionRepository->findAll(); 79 | self::assertEquals(0, $taskExecutions->count()); 80 | } 81 | 82 | /** 83 | * @test 84 | */ 85 | public function cronExpressionIsInterpreted(): void 86 | { 87 | TaskCollectionConfigurationHelper::prepareTaskCollectionFactoryWithConfiguration( 88 | $this->taskCollectionFactory, 89 | [ 90 | 'taskInThePast' => [ 91 | 'handlerClass' => TestHandler::class, 92 | 'cronExpression' => '0 0 * * *', 93 | ] 94 | ] 95 | ); 96 | 97 | $this->scheduler->scheduleTasks(); 98 | $this->persistenceManager->persistAll(); 99 | 100 | $taskExecutions = $this->taskExecutionRepository->findAll(); 101 | self::assertEquals(1, $taskExecutions->count()); 102 | 103 | /** @var TaskExecution $taskExecution */ 104 | $taskExecution = $taskExecutions->getFirst(); 105 | self::assertEquals($taskExecution->getScheduleTime(), (new DateTime())->modify('+1 day')->setTime(0, 0, 0)); 106 | } 107 | 108 | /** 109 | * @test 110 | */ 111 | public function firstExecutionOverridesCron(): void 112 | { 113 | $effectiveFirstExecution = (new DateTime('now'))->setTime(8, 0, 0)->add(new \DateInterval('P1D')); 114 | 115 | TaskCollectionConfigurationHelper::prepareTaskCollectionFactoryWithConfiguration( 116 | $this->taskCollectionFactory, 117 | [ 118 | 'taskInThePast' => [ 119 | 'handlerClass' => TestHandler::class, 120 | 'cronExpression' => '* * * * *', 121 | 'firstExecution' => $effectiveFirstExecution->format('Y-m-d H:i:0') 122 | ] 123 | ] 124 | ); 125 | 126 | $this->scheduler->scheduleTasks(); 127 | $this->persistenceManager->persistAll(); 128 | 129 | $taskExecutions = $this->taskExecutionRepository->findAll(); 130 | self::assertEquals(1, $taskExecutions->count()); 131 | 132 | /** @var TaskExecution $taskExecution */ 133 | $taskExecution = $taskExecutions->getFirst(); 134 | self::assertEquals($taskExecution->getScheduleTime(), $effectiveFirstExecution); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Tests/Functional/Fixture/TestHandler.php: -------------------------------------------------------------------------------- 1 | jsonSerialize(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/Functional/Helper/TaskCollectionConfigurationHelper.php: -------------------------------------------------------------------------------- 1 | =7.4", 8 | "neos/flow": "*", 9 | "symfony/lock": "^5.3", 10 | "dragonmantank/cron-expression": "^3.1" 11 | }, 12 | "autoload": { 13 | "psr-4": { 14 | "Flowpack\\Task\\": "Classes/" 15 | } 16 | }, 17 | "extra": { 18 | "neos": { 19 | "package-key": "Flowpack.Task" 20 | } 21 | } 22 | } 23 | --------------------------------------------------------------------------------