diff --git a/administrator/components/com_scheduler/src/Scheduler/Scheduler.php b/administrator/components/com_scheduler/src/Scheduler/Scheduler.php index a0688d560eaf7..22fd589a13ebe 100644 --- a/administrator/components/com_scheduler/src/Scheduler/Scheduler.php +++ b/administrator/components/com_scheduler/src/Scheduler/Scheduler.php @@ -39,10 +39,11 @@ class Scheduler { private const LOG_TEXT = [ - Status::OK => 'COM_SCHEDULER_SCHEDULER_TASK_COMPLETE', - Status::NO_LOCK => 'COM_SCHEDULER_SCHEDULER_TASK_LOCKED', - Status::NO_RUN => 'COM_SCHEDULER_SCHEDULER_TASK_UNLOCKED', - Status::NO_ROUTINE => 'COM_SCHEDULER_SCHEDULER_TASK_ROUTINE_NA', + Status::OK => 'COM_SCHEDULER_SCHEDULER_TASK_COMPLETE', + Status::WILL_RESUME => 'COM_SCHEDULER_SCHEDULER_TASK_WILL_RESUME', + Status::NO_LOCK => 'COM_SCHEDULER_SCHEDULER_TASK_LOCKED', + Status::NO_RUN => 'COM_SCHEDULER_SCHEDULER_TASK_UNLOCKED', + Status::NO_ROUTINE => 'COM_SCHEDULER_SCHEDULER_TASK_ROUTINE_NA', ]; /** @@ -158,7 +159,7 @@ public function runTask(array $options): ?Task if (\array_key_exists($exitCode, self::LOG_TEXT)) { - $level = $exitCode === Status::OK ? 'info' : 'warning'; + $level = in_array($exitCode, [Status::OK, Status::WILL_RESUME]) ? 'info' : 'warning'; $task->log(Text::sprintf(self::LOG_TEXT[$exitCode], $taskId, $duration, $netDuration), $level); return $task; diff --git a/administrator/components/com_scheduler/src/Task/Status.php b/administrator/components/com_scheduler/src/Task/Status.php index 09dddc4493793..50f4673577deb 100644 --- a/administrator/components/com_scheduler/src/Task/Status.php +++ b/administrator/components/com_scheduler/src/Task/Status.php @@ -67,6 +67,19 @@ abstract class Status */ public const KNOCKOUT = 5; + /** + * Exit code used when a task needs to resume (reschedule it to run a.s.a.p.). + * + * Use this for long running tasks, e.g. batch processing of hundreds or thousands of files, + * sending newsletters with thousands of subscribers etc. These are tasks which might run out of + * memory and/or hit a time limit when lazy scheduling or web triggering of tasks is being used. + * Split them into smaller batches which return Status::WILL_RESUME. When the last batch is + * executed return Status::OK. + * + * @since 4.1.0 + */ + public const WILL_RESUME = 123; + /** * Exit code used when a task times out. * diff --git a/administrator/components/com_scheduler/src/Task/Task.php b/administrator/components/com_scheduler/src/Task/Task.php index df94e24d8c97c..fdc0b3e987201 100644 --- a/administrator/components/com_scheduler/src/Task/Task.php +++ b/administrator/components/com_scheduler/src/Task/Task.php @@ -116,9 +116,10 @@ class Task implements LoggerAwareInterface * @since 4.1.0 */ protected const EVENTS_MAP = [ - Status::OK => 'onTaskExecuteSuccess', - Status::NO_ROUTINE => 'onTaskRoutineNotFound', - 'NA' => 'onTaskExecuteFailure', + Status::OK => 'onTaskExecuteSuccess', + Status::NO_ROUTINE => 'onTaskRoutineNotFound', + Status::WILL_RESUME => 'onTaskRoutineWillResume', + 'NA' => 'onTaskExecuteFailure', ]; /** @@ -246,11 +247,29 @@ public function run(): bool // @todo make the ExecRuleHelper usage less ugly, perhaps it should be composed into Task // Update object state. $this->set('last_execution', Factory::getDate('@' . (int) $this->snapshot['taskStart'])->toSql()); - $this->set('next_execution', (new ExecRuleHelper($this->taskRegistry->toObject()))->nextExec()); $this->set('last_exit_code', $this->snapshot['status']); - $this->set('times_executed', $this->get('times_executed') + 1); - if ($this->snapshot['status'] !== Status::OK) + if ($this->snapshot['status'] !== Status::WILL_RESUME) + { + $this->set('next_execution', (new ExecRuleHelper($this->taskRegistry->toObject()))->nextExec()); + $this->set('times_executed', $this->get('times_executed') + 1); + } + else + { + /** + * Resumable tasks need special handling. + * + * They are rescheduled as soon as possible to let their next step to be executed without + * a very large temporal gap to the previous step. + * + * Moreover, the times executed does NOT increase for each step. It will increase once, + * after the last step, when they return Status::OK. + */ + $this->set('next_execution', Factory::getDate('now', 'UTC')->sub(new \DateInterval('PT1M'))->toSql()); + } + + // The only acceptable "successful" statuses are either clean exit or resuming execution. + if (!in_array($this->snapshot['status'], [Status::WILL_RESUME, Status::OK])) { $this->set('times_failed', $this->get('times_failed') + 1); } @@ -391,7 +410,7 @@ public function releaseLock(bool $update = true): bool ->bind(':times_executed', $timesExecuted) ->bind(':times_failed', $timesFailed); - if ($exitCode !== Status::OK) + if (!in_array($exitCode, [Status::OK, Status::WILL_RESUME])) { $query->set('times_failed = t.times_failed + 1'); } @@ -495,7 +514,7 @@ protected function dispatchExitEvent(): void */ public function isSuccess(): bool { - return ($this->snapshot['status'] ?? null) === Status::OK; + return in_array(($this->snapshot['status'] ?? null), [Status::OK, Status::WILL_RESUME]); } /** diff --git a/administrator/language/en-GB/com_scheduler.ini b/administrator/language/en-GB/com_scheduler.ini index 6c31fa2ce9cf1..efd4818f58825 100644 --- a/administrator/language/en-GB/com_scheduler.ini +++ b/administrator/language/en-GB/com_scheduler.ini @@ -109,6 +109,7 @@ COM_SCHEDULER_SCHEDULER_TASK_ROUTINE_NA="Task#%1$02d has no corresponding plugin COM_SCHEDULER_SCHEDULER_TASK_START="Running task#%1$02d '%2$s'." COM_SCHEDULER_SCHEDULER_TASK_UNKNOWN_EXIT="Task#%1$02d exited with code %4$d in %2$.2f (net %3$.2f) seconds." COM_SCHEDULER_SCHEDULER_TASK_UNLOCKED="Task#%1$02d was unlocked." +COM_SCHEDULER_SCHEDULER_TASK_WILL_RESUME="Task#%1$02d needs to perform more work." COM_SCHEDULER_SELECT_INTERVAL_MINUTES="- Select interval in Minutes -" COM_SCHEDULER_SELECT_TASK_TYPE="Select task, %s" COM_SCHEDULER_SELECT_TYPE="- Task Type -" diff --git a/administrator/language/en-GB/plg_task_demotasks.ini b/administrator/language/en-GB/plg_task_demotasks.ini index 056a9de80e62e..695c0d3048956 100644 --- a/administrator/language/en-GB/plg_task_demotasks.ini +++ b/administrator/language/en-GB/plg_task_demotasks.ini @@ -9,6 +9,10 @@ PLG_TASK_DEMO_TASKS_STRESS_MEMORY_DESC="What happens to a task when the PHP memo PLG_TASK_DEMO_TASKS_STRESS_MEMORY_OVERRIDE_DESC="What happens to a task when the system memory is exhausted?" PLG_TASK_DEMO_TASKS_STRESS_MEMORY_OVERRIDE_TITLE="Stress Memory, Override Limit" PLG_TASK_DEMO_TASKS_STRESS_MEMORY_TITLE="Stress Memory" +PLG_TASK_DEMO_TASKS_RESUMABLE_TITLE="Resumable task" +PLG_TASK_DEMO_TASKS_RESUMABLE_DESC="A simple task to demonstrate resumable task behaviour." +PLG_TASK_DEMO_TASKS_RESUMABLE_STEPS_LABEL="Total number of steps" +PLG_TASK_DEMO_TASKS_RESUMABLE_TIMEOUT_LABEL="Delay per step (seconds)" PLG_TASK_DEMO_TASKS_TASK_SLEEP_DESC="Sleep, do nothing for x seconds." PLG_TASK_DEMO_TASKS_TASK_SLEEP_ROUTINE_END_LOG_MESSAGE="TestTask1 return code is: %1$d. Processing Time: %2$.2f seconds" PLG_TASK_DEMO_TASKS_TASK_SLEEP_TITLE="Demo Task - Sleep" diff --git a/libraries/src/Console/TasksRunCommand.php b/libraries/src/Console/TasksRunCommand.php index 37ddfb1fbb543..f13993457db90 100644 --- a/libraries/src/Console/TasksRunCommand.php +++ b/libraries/src/Console/TasksRunCommand.php @@ -58,10 +58,11 @@ protected function doExecute(InputInterface $input, OutputInterface $output): in * load the namespace when it's time to do that (why?) */ static $outTextMap = [ - Status::OK => 'Task#%1$02d \'%2$s\' processed in %3$.2f seconds.', - Status::NO_RUN => 'Task#%1$02d \'%2$s\' failed to run. Is it already running?', - Status::NO_ROUTINE => 'Task#%1$02d \'%2$s\' is orphaned! Visit the backend to resolve.', - 'N/A' => 'Task#%1$02d \'%2$s\' exited with code %4$d in %3$.2f seconds.', + Status::OK => 'Task#%1$02d \'%2$s\' processed in %3$.2f seconds.', + Status::WILL_RESUME => 'Task#%1$02d \'%2$s\' ran for %3$.2f seconds, will resume next time.', + Status::NO_RUN => 'Task#%1$02d \'%2$s\' failed to run. Is it already running?', + Status::NO_ROUTINE => 'Task#%1$02d \'%2$s\' is orphaned! Visit the backend to resolve.', + 'N/A' => 'Task#%1$02d \'%2$s\' exited with code %4$d in %3$.2f seconds.', ]; $this->configureIo($input, $output); diff --git a/plugins/task/demotasks/demotasks.php b/plugins/task/demotasks/demotasks.php index 1cc862bf72319..bea812cf49e53 100644 --- a/plugins/task/demotasks/demotasks.php +++ b/plugins/task/demotasks/demotasks.php @@ -13,6 +13,7 @@ use Joomla\CMS\Plugin\CMSPlugin; use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; use Joomla\Component\Scheduler\Administrator\Task\Status; +use Joomla\Component\Scheduler\Administrator\Task\Task; use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; use Joomla\Event\SubscriberInterface; @@ -44,6 +45,11 @@ class PlgTaskDemotasks extends CMSPlugin implements SubscriberInterface 'langConstPrefix' => 'PLG_TASK_DEMO_TASKS_STRESS_MEMORY_OVERRIDE', 'method' => 'stressMemoryRemoveLimit', ], + 'demoTask_r4.resumable' => [ + 'langConstPrefix' => 'PLG_TASK_DEMO_TASKS_RESUMABLE', + 'method' => 'resumable', + 'form' => 'testTaskForm', + ], ]; /** @@ -68,6 +74,60 @@ public static function getSubscribedEvents(): array ]; } + /** + * Sample resumable task. + * + * Whether the task will resume is random. There's a 40% chance of finishing every time it runs. + * + * You can use this as a template to create long running tasks which can detect an impending + * timeout condition, return Status::WILL_RESUME and resume execution next time they are called. + * + * @param ExecuteTaskEvent $event The event we are handling + * + * @return integer + * + * @since __DEPLOY_VERSION__ + * @throws \Exception + */ + private function resumable(ExecuteTaskEvent $event): int + { + /** @var Task $task */ + $task = $event->getArgument('subject'); + $timeout = (int) $event->getArgument('params')->timeout ?? 1; + + $lastStatus = $task->get('last_exit_code', Status::OK); + + // This is how you detect if you are resuming a task or starting it afresh + if ($lastStatus === Status::WILL_RESUME) + { + $this->logTask(sprintf('Resuming task %d', $task->get('id'))); + } + else + { + $this->logTask(sprintf('Starting new task %d', $task->get('id'))); + } + + // Sample task body; we are simply sleeping for some time. + $this->logTask(sprintf('Starting %ds timeout', $timeout)); + sleep($timeout); + $this->logTask(sprintf('%ds timeout over!', $timeout)); + + // Should I resume the task in the next step (randomly decided)? + $willResume = random_int(0, 5) < 4; + + // Log our intention to resume or not and return the appropriate exit code. + if ($willResume) + { + $this->logTask(sprintf('Task %d will resume', $task->get('id'))); + } + else + { + $this->logTask(sprintf('Task %d is now complete', $task->get('id'))); + } + + return $willResume ? Status::WILL_RESUME : Status::OK; + } + /** * @param ExecuteTaskEvent $event The `onExecuteTask` event. *