diff --git a/README.md b/README.md index 57b8004..2ab3591 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,6 @@ Install with Composer: composer require nails/module-find-replace ``` +Admin searches are queued. `FIND_REPLACE_QUEUE` chooses the queue; when it is unset the search uses `default`. The Find & Replace index warns when that queue has no live worker, using the same check as the queue dashboard alert. + See [the documentation](https://docs.nailsapp.co.uk/modules/find-replace). diff --git a/admin/views/FindReplace/index.php b/admin/views/FindReplace/index.php index fd0ce76..bde1177 100644 --- a/admin/views/FindReplace/index.php +++ b/admin/views/FindReplace/index.php @@ -1,9 +1,20 @@
+ +
+

Search saved content, then replace matches from the results screen. A search and its rows are kept or deleted together.

@@ -51,11 +62,11 @@ Term - Status - Progress - Results - Last run - Actions + Status + Progress + Results + Last run + Actions diff --git a/assets/css/admin.min.css b/assets/css/admin.min.css index bff385e..0d8cbad 100644 --- a/assets/css/admin.min.css +++ b/assets/css/admin.min.css @@ -1 +1 @@ -.find-replace-results mark,.find-replace-snippet mark,.find-replace-preview mark{padding:0 .15em}.find-replace-snippet mark{background-color:#c8f7c5}.find-replace-preview mark{background-color:#c5ddf7}.find-replace-results td.actions .btn{margin-bottom:.25rem}[data-find-replace-status]{font-variant-numeric:tabular-nums}.find-replace-searches{margin-top:2.5rem}.find-replace-detail__term{font-size:1.25rem}.find-replace-detail__arrow{margin:0 .35rem;color:#6c757d}.find-replace-detail__meta{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;gap:.5rem .75rem}.find-replace-detail__facts{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;gap:0 1.5rem}.find-replace-detail__fact{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;gap:.15rem}.find-replace-detail__fact+.find-replace-detail__fact{padding-left:1.5rem;border-left:1px solid #dee2e6}.find-replace-detail__fact-label{color:#6c757d;font-size:.75rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase}.find-replace-detail__fact-value{font-size:1rem;font-variant-numeric:tabular-nums}.find-replace-detail__scope{color:#6c757d}.find-replace-meter-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;gap:.75rem}.find-replace-meter{-ms-flex:1;flex:1;height:.5rem;overflow:hidden;border-radius:1rem;background:#e9ecef}.find-replace-meter>span{display:block;height:100%;background:#337ab7}.find-replace-progress{font-variant-numeric:tabular-nums;white-space:nowrap}.find-replace-error{margin-top:.35rem;color:#a94442} +.find-replace-results mark,.find-replace-snippet mark,.find-replace-preview mark{padding:0 .15em}.find-replace-snippet mark{background-color:#c8f7c5}.find-replace-preview mark{background-color:#c5ddf7}.find-replace-results td.actions .btn{margin-bottom:.25rem}[data-find-replace-status]{font-variant-numeric:tabular-nums}.find-replace-searches{margin-top:2.5rem}.find-replace-searches .find-replace-status,.find-replace-searches .find-replace-progress,.find-replace-searches .find-replace-results,.find-replace-searches .find-replace-last-run,.find-replace-searches .actions{width:250px}.find-replace-detail__term{font-size:1.25rem}.find-replace-detail__arrow{margin:0 .35rem;color:#6c757d}.find-replace-detail__meta{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;gap:.5rem .75rem}.find-replace-detail__facts{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;gap:0 1.5rem}.find-replace-detail__fact{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;gap:.15rem}.find-replace-detail__fact+.find-replace-detail__fact{padding-left:1.5rem;border-left:1px solid #dee2e6}.find-replace-detail__fact-label{color:#6c757d;font-size:.75rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase}.find-replace-detail__fact-value{font-size:1rem;font-variant-numeric:tabular-nums}.find-replace-detail__scope{color:#6c757d}.find-replace-meter-row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;gap:.75rem}.find-replace-meter{-ms-flex:1;flex:1;height:.5rem;overflow:hidden;border-radius:1rem;background:#e9ecef}.find-replace-meter>span{display:block;height:100%;background:#337ab7}.find-replace-progress{font-variant-numeric:tabular-nums;white-space:nowrap}.find-replace-error{margin-top:.35rem;color:#a94442} diff --git a/assets/sass/admin.scss b/assets/sass/admin.scss index a2b2bc1..d6b351e 100644 --- a/assets/sass/admin.scss +++ b/assets/sass/admin.scss @@ -26,6 +26,14 @@ .find-replace-searches { margin-top: 2.5rem; + + .find-replace-status, + .find-replace-progress, + .find-replace-results, + .find-replace-last-run, + .actions { + width: 250px; + } } .find-replace-detail__term { diff --git a/services/services.php b/services/services.php index d320422..1f88813 100644 --- a/services/services.php +++ b/services/services.php @@ -11,6 +11,7 @@ use Nails\FindReplace\Resource; use Nails\FindReplace\Service\Access; use Nails\FindReplace\Service\Engine; +use Nails\FindReplace\Service\Queue; use Nails\FindReplace\Service\Registry; use Nails\FindReplace\Service\Schema; @@ -56,6 +57,13 @@ return new Access($searches, $results, $changes, $database); }, + 'Queue' => function (): Queue { + if (class_exists('\App\FindReplace\Service\Queue')) { + return new \App\FindReplace\Service\Queue(); + } + + return new Queue(); + }, ], 'models' => [ 'Search' => function (): Model\Search { diff --git a/src/Admin/Controller/FindReplace.php b/src/Admin/Controller/FindReplace.php index 8638b59..7f16a4d 100644 --- a/src/Admin/Controller/FindReplace.php +++ b/src/Admin/Controller/FindReplace.php @@ -20,12 +20,11 @@ use Nails\FindReplace\Model\Result; use Nails\FindReplace\Model\Search; use Nails\FindReplace\Output\Csv; -use Nails\FindReplace\Queue\Task\Search as SearchTask; use Nails\FindReplace\Resource; use Nails\FindReplace\Service\Access; use Nails\FindReplace\Service\Engine; +use Nails\FindReplace\Service\Queue; use Nails\FindReplace\Service\Registry; -use Nails\Queue\Interface\Data; class FindReplace extends Base { @@ -52,6 +51,7 @@ public function index(): void $this ->addBreadcrumb('Utilities') ->addBreadcrumb('Find & Replace') + ->setData('queueWarning', $this->searchQueue()->warning()) ->loadView('index'); } @@ -67,6 +67,8 @@ public function create(): void try { $include = $this->tableEntries($input, 'include_tables'); $exclude = $this->tableEntries($input, 'exclude_tables'); + $queue = $this->searchQueue(); + $queue->assertAvailable(); $id = $this->searches()->open( $this->userId(), $this->requiredTerm($input), @@ -75,7 +77,7 @@ public function create(): void $include, $exclude, ); - $this->queue($id); + $queue->push($id); } catch (\InvalidArgumentException $exception) { $this->oUserFeedback->error($exception->getMessage()); redirect(self::url()); @@ -257,9 +259,20 @@ public function rerun(): void { $this->requireSearchPermission(); $search = $this->requireOwned(); + $queue = $this->searchQueue(); + + try { + $queue->assertAvailable(); + } catch (\InvalidArgumentException $exception) { + $this->oUserFeedback->error($exception->getMessage()); + redirect(self::url('results') . '?id=' . (int) $search->id); + + return; + } + $this->searches()->markPending((int) $search->id); $this->resultModel()->clear((int) $search->id); - $this->queue((int) $search->id); + $queue->push((int) $search->id); $this->oUserFeedback->success('Search queued again.'); redirect(self::url('results') . '?id=' . (int) $search->id); } @@ -410,16 +423,15 @@ private function userId(): int return (int) $id; } - private function queue(int $id): void + private function searchQueue(): Queue { - $manager = Factory::service('Manager', \Nails\Queue\Constants::MODULE_SLUG); - $data = Factory::factory('Data', \Nails\Queue\Constants::MODULE_SLUG, $id); + $queue = Factory::service('Queue', Constants::MODULE_SLUG); - if (!method_exists($manager, 'push') || !$data instanceof Data) { - throw new \RuntimeException('Queue is not available'); + if (!$queue instanceof Queue) { + throw new \RuntimeException('Find-replace queue is not available'); } - $manager->push(new SearchTask(), $data); + return $queue; } /** diff --git a/src/Api/Controller/Search.php b/src/Api/Controller/Search.php index 4371aaf..d5389f6 100644 --- a/src/Api/Controller/Search.php +++ b/src/Api/Controller/Search.php @@ -14,9 +14,8 @@ use Nails\FindReplace\Exclusion\Pattern; use Nails\FindReplace\Model\Result; use Nails\FindReplace\Model\Search as SearchModel; -use Nails\FindReplace\Queue\Task\Search as SearchTask; use Nails\FindReplace\Resource; -use Nails\Queue\Interface\Data; +use Nails\FindReplace\Service\Queue; class Search extends \Nails\Api\Controller\Base { @@ -51,6 +50,8 @@ public function postIndex(): ApiResponse $input = $this->input(); try { + $queue = $this->searchQueue(); + $queue->assertAvailable(); $id = $this->searches()->open( $this->userId(), $this->requiredTerm($input), @@ -59,7 +60,7 @@ public function postIndex(): ApiResponse $this->tableEntries($input['include_tables'] ?? null), $this->tableEntries($input['exclude_tables'] ?? null), ); - $this->queue($id); + $queue->push($id); } catch (\InvalidArgumentException $exception) { throw new ValidationException($exception->getMessage()); } @@ -70,9 +71,17 @@ public function postIndex(): ApiResponse public function postRerun(): ApiResponse { $id = (int) $this->owned($this->requestId($this->input()))->id; + $queue = $this->searchQueue(); + + try { + $queue->assertAvailable(); + } catch (\InvalidArgumentException $exception) { + throw new ValidationException($exception->getMessage()); + } + $this->searches()->markPending($id); $this->results()->clear($id); - $this->queue($id); + $queue->push($id); return $this->respond($this->payload($this->owned($id))); } @@ -249,16 +258,15 @@ private function userId(): int return (int) $id; } - private function queue(int $id): void + private function searchQueue(): Queue { - $manager = Factory::service('Manager', \Nails\Queue\Constants::MODULE_SLUG); - $data = Factory::factory('Data', \Nails\Queue\Constants::MODULE_SLUG, $id); + $queue = Factory::service('Queue', Constants::MODULE_SLUG); - if (!method_exists($manager, 'push') || !$data instanceof Data) { - throw new \RuntimeException('Queue is not available'); + if (!$queue instanceof Queue) { + throw new \RuntimeException('Find-replace queue is not available'); } - $manager->push(new SearchTask(), $data); + return $queue; } /** diff --git a/src/Service/Queue.php b/src/Service/Queue.php new file mode 100644 index 0000000..c0f79ac --- /dev/null +++ b/src/Service/Queue.php @@ -0,0 +1,110 @@ +manager()->resolveQueue($this->name()); + } catch (\InvalidArgumentException | \TypeError $exception) { + throw new \InvalidArgumentException( + sprintf('Find & Replace is set to use the queue "%s", which is not available.', $this->name()), + 0, + $exception, + ); + } + } + + public function push(int $id): void + { + $this->assertAvailable(); + $data = Factory::factory('Data', \Nails\Queue\Constants::MODULE_SLUG, $id); + + if (!$data instanceof Data) { + throw new \RuntimeException('Queue is not available'); + } + + $this->manager()->push(new SearchTask(), $data, $this->name()); + } + + /** + * Warning for the index when this queue has no live worker, or is not a queue. + * Liveness is Manager::isProcessed(), the same check as the queue dashboard alert. + */ + public function warning(): ?string + { + $manager = $this->manager(); + + try { + $queue = $manager->resolveQueue($this->name()); + } catch (\InvalidArgumentException | \TypeError) { + return sprintf( + 'Find & Replace is set to use the queue "%s", which is not available. Searches cannot be queued.', + $this->name(), + ); + } + + if ($manager->isProcessed($queue)) { + return null; + } + + $name = $this->name(); + $class = $queue::class; + $label = $name === $class ? $class : $name . ' (' . $class . ')'; + + return sprintf( + 'There are no queue workers running for %s. Searches will stay pending until a worker is processing that queue.', + $label, + ); + } + + private function manager(): Manager + { + if ($this->manager instanceof Manager) { + return $this->manager; + } + + $manager = Factory::service('Manager', \Nails\Queue\Constants::MODULE_SLUG); + + if (!$manager instanceof Manager) { + throw new \RuntimeException('Queue is not available'); + } + + return $this->manager = $manager; + } +} diff --git a/tests/Service/QueueTest.php b/tests/Service/QueueTest.php new file mode 100644 index 0000000..b09c7c5 --- /dev/null +++ b/tests/Service/QueueTest.php @@ -0,0 +1,145 @@ +name()); + } + + public function test_blank_or_non_string_config_uses_default(): void + { + Config::set(Queue::CONFIG_KEY, ' '); + self::assertSame('default', (new Queue())->name()); + + Config::set(Queue::CONFIG_KEY, ['priority']); + self::assertSame('default', (new Queue())->name()); + } + + public function test_push_uses_the_configured_queue(): void + { + Config::set(Queue::CONFIG_KEY, 'priority'); + $manager = new ManagerDouble(queue: new PriorityQueue(), job: $this->createStub(Job::class)); + + (new Queue($manager))->push(17); + + self::assertSame('priority', $manager->pushedQueue); + self::assertInstanceOf(Data::class, $manager->pushedData); + self::assertSame(17, $manager->pushedData->get()); + } + + public function test_warning_names_an_unworked_queue(): void + { + Config::set(Queue::CONFIG_KEY, 'priority'); + $manager = new ManagerDouble(queue: new PriorityQueue(), processed: false); + + $warning = (new Queue($manager))->warning(); + + self::assertIsString($warning); + self::assertStringContainsString('priority', $warning); + self::assertStringContainsString(PriorityQueue::class, $warning); + self::assertStringContainsString('no queue workers', $warning); + } + + public function test_warning_is_absent_when_the_queue_is_processed(): void + { + Config::set(Queue::CONFIG_KEY, 'default'); + $manager = new ManagerDouble(queue: new DefaultQueue(), processed: true); + + self::assertNull((new Queue($manager))->warning()); + } + + public function test_warning_when_the_queue_is_not_available(): void + { + Config::set(Queue::CONFIG_KEY, 'missing'); + $manager = new ManagerDouble(throwOnResolve: true); + + $warning = (new Queue($manager))->warning(); + + self::assertIsString($warning); + self::assertStringContainsString('missing', $warning); + self::assertStringContainsString('not available', $warning); + } + + public function test_assert_available_rejects_an_unknown_queue(): void + { + Config::set(Queue::CONFIG_KEY, 'missing'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('missing'); + + (new Queue(new ManagerDouble(throwOnResolve: true)))->assertAvailable(); + } +} + +class ManagerDouble extends Manager +{ + public ?string $pushedQueue = null; + + public ?Data $pushedData = null; + + public function __construct( + private readonly ?QueueContract $queue = null, + private readonly bool $processed = false, + private readonly bool $throwOnResolve = false, + private readonly ?Job $job = null, + ) { + } + + public function resolveQueue(string|QueueContract $alias): QueueContract + { + if ($this->throwOnResolve || !$this->queue instanceof QueueContract) { + $name = is_string($alias) ? $alias : $alias::class; + + throw new \InvalidArgumentException(sprintf('Invalid queue "%s"', $name)); + } + + return $this->queue; + } + + public function isProcessed(string|QueueContract $queue): bool + { + return $this->processed; + } + + public function push( + Task $task, + Data $data, + QueueContract|string|null $queue = null, + ?DateTimeInterface $availableAt = null, + ): Job { + $this->pushedQueue = is_string($queue) ? $queue : null; + $this->pushedData = $data; + + if (!$this->job instanceof Job) { + throw new \RuntimeException('Queue job double is not available'); + } + + return $this->job; + } +}