From 43a3148862f5649af924fa8968d5d43ca9fb3cb5 Mon Sep 17 00:00:00 2001 From: Priyadi Iman Nurcahyo <1102197+priyadi@users.noreply.github.com> Date: Thu, 18 Jul 2024 01:14:39 +0700 Subject: [PATCH] feat(batch): show remaining time if the count is known (#129) * feat: batch remaining time * cs * changelog * fix --- CHANGELOG.md | 1 + .../src/Batch/Event/AfterPageEvent.php | 9 ++ .../src/Batch/Event/BeforePageEvent.php | 9 ++ .../src/Batch/BatchCommand.php | 1 + .../CommandBatchProcessorDecorator.php | 119 +++++++++++++----- .../src/App/Command/AppSimpleBatchCommand.php | 11 +- 6 files changed, 121 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4696b0..45dd743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ # 0.13.3 * fix: batch size CLI option +* feat(batch): show remaining time if the count is known # 0.13.2 diff --git a/packages/rekapager-core/src/Batch/Event/AfterPageEvent.php b/packages/rekapager-core/src/Batch/Event/AfterPageEvent.php index bad0d37..414c9d1 100644 --- a/packages/rekapager-core/src/Batch/Event/AfterPageEvent.php +++ b/packages/rekapager-core/src/Batch/Event/AfterPageEvent.php @@ -13,6 +13,7 @@ namespace Rekalogika\Rekapager\Batch\Event; +use Rekalogika\Contracts\Rekapager\PageableInterface; use Rekalogika\Contracts\Rekapager\PageInterface; /** @@ -29,6 +30,14 @@ public function __construct( ) { } + /** + * @return PageableInterface + */ + public function getPageable(): PageableInterface + { + return $this->beforePageEvent->getPageable(); + } + /** * @return PageInterface */ diff --git a/packages/rekapager-core/src/Batch/Event/BeforePageEvent.php b/packages/rekapager-core/src/Batch/Event/BeforePageEvent.php index 06143bd..6e9311b 100644 --- a/packages/rekapager-core/src/Batch/Event/BeforePageEvent.php +++ b/packages/rekapager-core/src/Batch/Event/BeforePageEvent.php @@ -13,6 +13,7 @@ namespace Rekalogika\Rekapager\Batch\Event; +use Rekalogika\Contracts\Rekapager\PageableInterface; use Rekalogika\Contracts\Rekapager\PageInterface; /** @@ -38,6 +39,14 @@ public function getPage(): PageInterface return $this->page; } + /** + * @return PageableInterface + */ + public function getPageable(): PageableInterface + { + return $this->page->getPageable(); + } + public function getEncodedPageIdentifier(): string { return $this->encodedPageIdentifier; diff --git a/packages/rekapager-symfony-bridge/src/Batch/BatchCommand.php b/packages/rekapager-symfony-bridge/src/Batch/BatchCommand.php index 374c0e3..ce86037 100644 --- a/packages/rekapager-symfony-bridge/src/Batch/BatchCommand.php +++ b/packages/rekapager-symfony-bridge/src/Batch/BatchCommand.php @@ -127,6 +127,7 @@ final protected function execute(InputInterface $input, OutputInterface $output) $this->io = new SymfonyStyle($input, $output); $batchProcessor = new CommandBatchProcessorDecorator( + description: $this->getDescription(), decorated: $this->getBatchProcessor(), io: $this->io, progressFile: $progressFile, diff --git a/packages/rekapager-symfony-bridge/src/Batch/Internal/CommandBatchProcessorDecorator.php b/packages/rekapager-symfony-bridge/src/Batch/Internal/CommandBatchProcessorDecorator.php index 31a6c14..30ef1fd 100644 --- a/packages/rekapager-symfony-bridge/src/Batch/Internal/CommandBatchProcessorDecorator.php +++ b/packages/rekapager-symfony-bridge/src/Batch/Internal/CommandBatchProcessorDecorator.php @@ -44,10 +44,19 @@ class CommandBatchProcessorDecorator extends BatchProcessorDecorator private readonly ProgressIndicator $progressIndicator; + private ?string $startPageIdentifier = null; + + private ?int $totalPages = null; + + private ?int $totalItems = null; + + private int $itemsPerPage = 0; + /** * @param BatchProcessorInterface $decorated */ public function __construct( + private readonly string $description, private readonly BatchProcessorInterface $decorated, private readonly SymfonyStyle $io, private readonly ?string $progressFile, @@ -63,22 +72,29 @@ private function formatTime(\DateTimeInterface $time): string return $time->format('Y-m-d H:i:s T'); } + private function getStartTime(): \DateTimeInterface + { + if ($this->startTime === null) { + throw new \LogicException('Start time is not set'); + } + + return $this->startTime; + } + public function beforeProcess(BeforeProcessEvent $event): void { $this->startTime = new \DateTimeImmutable(); $this->timer->start(BatchTimer::TIMER_DISPLAY); $this->timer->start(BatchTimer::TIMER_PROCESS); + $this->startPageIdentifier = $event->getStartPageIdentifier(); + $this->totalPages = $event->getPageable()->getTotalPages(); + $this->totalItems = $event->getPageable()->getTotalItems(); + $this->itemsPerPage = $event->getPageable()->getItemsPerPage(); + $this->io->success('Starting batch process'); - $this->io->definitionList( - ['Start time' => $this->formatTime($this->startTime)], - ['Start page' => $event->getStartPageIdentifier() ?? '(first page)'], - ['Progress file' => $this->progressFile ?? '(not used)'], - ['Items per page' => $event->getPageable()->getItemsPerPage()], - ['Total pages' => $event->getPageable()->getTotalPages() ?? '(unknown)'], - ['Total items' => $event->getPageable()->getTotalItems() ?? '(unknown)'], - ); + $this->showStats($event); $this->decorated->beforeProcess($event); } @@ -95,6 +111,27 @@ public function afterProcess(AfterProcessEvent $event): void $this->showStats($event); } + /** + * @param BeforePageEvent|AfterPageEvent $event + */ + private function getProgressString(BeforePageEvent|AfterPageEvent $event): string + { + if ($this->totalPages === null) { + return sprintf( + 'Page %s, identifier %s', + $event->getPage()->getPageNumber() ?? '?', + $event->getEncodedPageIdentifier(), + ); + } + return sprintf( + 'Page %s/%s, identifier %s', + $event->getPage()->getPageNumber() ?? '?', + $this->totalPages, + $event->getEncodedPageIdentifier(), + ); + + } + public function beforePage(BeforePageEvent $event): void { $this->pageNumber++; @@ -104,12 +141,7 @@ public function beforePage(BeforePageEvent $event): void file_put_contents($this->progressFile, $event->getEncodedPageIdentifier()); } - $this->progressIndicator->start(sprintf( - 'Page %s, identifier %s', - $event->getPage()->getPageNumber() ?? '(unknown)', - $event->getEncodedPageIdentifier(), - )); - + $this->progressIndicator->start($this->getProgressString($event)); $this->decorated->beforePage($event); } @@ -118,11 +150,7 @@ public function afterPage(AfterPageEvent $event): void $this->decorated->afterPage($event); // $pageDuration = $this->timer->stop(BatchTimer::TIMER_PAGE); - $this->progressIndicator->finish(sprintf( - 'Page %s, identifier %s', - $event->getPage()->getPageNumber() ?? '(unknown)', - $event->getEncodedPageIdentifier(), - )); + $this->progressIndicator->finish($this->getProgressString($event)); $displayDuration = $this->timer->getDuration(BatchTimer::TIMER_DISPLAY); @@ -200,9 +228,9 @@ public function onTimeLimit(TimeLimitEvent $event): void } /** - * @param AfterPageEvent|AfterProcessEvent|InterruptEvent|TimeLimitEvent $event + * @param BeforeProcessEvent|AfterPageEvent|AfterProcessEvent|InterruptEvent|TimeLimitEvent $event */ - private function showStats(AfterPageEvent|AfterProcessEvent|InterruptEvent|TimeLimitEvent $event): void + private function showStats(BeforeProcessEvent|AfterPageEvent|AfterProcessEvent|InterruptEvent|TimeLimitEvent $event): void { if ($event instanceof AfterPageEvent) { $this->io->writeln(''); @@ -210,20 +238,55 @@ private function showStats(AfterPageEvent|AfterProcessEvent|InterruptEvent|TimeL $processDuration = $this->timer->getDuration(BatchTimer::TIMER_PROCESS); - $stats = []; - - if ($this->startTime !== null) { - $stats[] = ['Start time' => $this->formatTime($this->startTime)]; + if ($processDuration !== null) { + $pagesPerSecond = $this->pageNumber / $processDuration; + $itemsPerSecond = $this->itemNumber / $processDuration; + } else { + $pagesPerSecond = 0; + $itemsPerSecond = 0; } + $estimatedEnd = null; + $eta = null; + + $stats = [ + ['Description' => $this->description], + ['Start page' => $this->startPageIdentifier ?? '(first page)'], + ['Progress file' => $this->progressFile ?? '(not used)'], + ['Items per page' => $this->itemsPerPage], + ['Total pages' => $this->totalPages ?? '(unknown)'], + ['Total items' => $this->totalItems ?? '(unknown)'], + ]; + + $stats[] = ['Start time' => $this->formatTime($this->getStartTime())]; + if ($event instanceof AfterPageEvent) { $stats[] = ['Current time' => $this->formatTime(new \DateTimeImmutable())]; - } else { + + if ($this->totalItems !== null) { + $remainingItems = $this->totalItems - $this->itemNumber; + if ($remainingItems < 0) { + $remainingItems = 0; + } + + $eta = $remainingItems / $itemsPerSecond; + $estimatedEnd = time() + $eta; + $stats[] = ['Estimated end time' => $this->formatTime((new \DateTimeImmutable('@' . $estimatedEnd))->setTimezone(new \DateTimeZone(date_default_timezone_get())))]; + } + } elseif ( + $event instanceof AfterProcessEvent + || $event instanceof InterruptEvent + || $event instanceof TimeLimitEvent + ) { $stats[] = ['End time' => $this->formatTime(new \DateTimeImmutable())]; } if ($processDuration !== null) { $stats[] = ['Time elapsed' => Helper::formatTime($processDuration)]; + + if ($eta !== null && $event instanceof AfterPageEvent) { + $stats[] = ['Estimated time remaining' => Helper::formatTime($eta)]; + } } $stats = [ @@ -234,8 +297,8 @@ private function showStats(AfterPageEvent|AfterProcessEvent|InterruptEvent|TimeL ]; if ($processDuration !== null) { - $stats[] = ['Pages/minute' => round($this->pageNumber / $processDuration * 60, 2)]; - $stats[] = ['Items/minute' => round($this->itemNumber / $processDuration * 60, 2)]; + $stats[] = ['Pages/minute' => round($pagesPerSecond * 60, 2)]; + $stats[] = ['Items/minute' => round($itemsPerSecond * 60, 2)]; } $this->io->definitionList(...$stats); diff --git a/tests/src/App/Command/AppSimpleBatchCommand.php b/tests/src/App/Command/AppSimpleBatchCommand.php index 864b46f..bafbe5c 100644 --- a/tests/src/App/Command/AppSimpleBatchCommand.php +++ b/tests/src/App/Command/AppSimpleBatchCommand.php @@ -24,6 +24,7 @@ use Rekalogika\Rekapager\Tests\App\Repository\PostRepository; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; /** @@ -42,13 +43,21 @@ public function __construct( parent::__construct(); } + protected function configure(): void + { + $this->addOption('count', null, InputOption::VALUE_NONE, 'Count the total items'); + } + protected function getPageable( InputInterface $input, OutputInterface $output ): PageableInterface { + /** @psalm-suppress RedundantCast */ + $count = (bool) $input->getOption('count'); + $adapter = new SelectableAdapter($this->postRepository); - return new KeysetPageable($adapter); + return new KeysetPageable($adapter, count: $count); } public function processItem(ItemEvent $itemEvent): void