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 @@
+
+
=htmlspecialchars($queueWarning)?>
+
Search saved content, then replace matches from the results screen. A search and its rows are kept or deleted together.
=form_open(null, 'id="find-replace-form"')?>
@@ -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;
+ }
+}