From 1e9087a5e5945a419cd4ee79da5cb27429f8d10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Wed, 21 Oct 2026 19:26:06 +0100
Subject: [PATCH] feat: Report queues that have no live worker
The dashboard alert can ask the manager which queues are unworked, using the same heartbeat rule as before.
---
README.md | 5 ++
src/Admin/Dashboard/Alert/NoWorkers.php | 38 +-----------
src/Service/Manager.php | 77 +++++++++++++++++++++++++
tests/Service/ManagerTest.php | 77 +++++++++++++++++++++++++
tests/Stub/SideQueue.php | 17 ++++++
5 files changed, 177 insertions(+), 37 deletions(-)
create mode 100644 tests/Stub/SideQueue.php
diff --git a/README.md b/README.md
index 15cafe1..5dcf7d1 100644
--- a/README.md
+++ b/README.md
@@ -152,11 +152,16 @@ Manager::unregisterWorker(Resource\Worker $worker): void
Manager::deleteStaleWorkers(): Resource\Worker[]
Manager::resetStuckJobs(): Job[]
Manager::rotateOldJobs(): Job[]
+
+// Same liveness check as the admin dashboard alert
+Manager::unworkedQueues(): Queue[]
+Manager::isProcessed(Queue|string $queue): bool
```
Notes:
- Passing `null` or `'default'` as the queue argument uses the default queue alias; you can also pass a fully-qualified queue class name (FQCN) or an instance.
- FIFO is approximated by selecting the smallest available `id` where `available_at <= NOW()`.
+- `unworkedQueues()` is the list shown by the admin dashboard alert. A worker counts when its heartbeat is newer than `QUEUE_WORKER_HEARTBEAT_STALE` (300 seconds by default) and it was started for that queue class. `isProcessed()` is true when a live worker lists the queue. For a discovered queue, that is the opposite of appearing in `unworkedQueues()`.
## Creating user-land queues and jobs
diff --git a/src/Admin/Dashboard/Alert/NoWorkers.php b/src/Admin/Dashboard/Alert/NoWorkers.php
index 2f71553..c81884a 100644
--- a/src/Admin/Dashboard/Alert/NoWorkers.php
+++ b/src/Admin/Dashboard/Alert/NoWorkers.php
@@ -8,8 +8,6 @@
use Nails\Factory;
use Nails\Queue\Constants;
use Nails\Queue\Interface\Queue;
-use Nails\Queue\Model;
-use Nails\Queue\Resource;
use Nails\Queue\Service;
/**
@@ -86,41 +84,7 @@ private function getUnworkedQueues(): array
/** @var Service\Manager $manager */
$manager = Factory::service('Manager', Constants::MODULE_SLUG);
- /** @var Model\Worker $workerModel */
- $workerModel = Factory::model('Worker', Constants::MODULE_SLUG);
- $queues = $manager->getQueues();
- if (empty($queues)) {
- // No queues, no need to worry if there are workers
- return static::$unworkedQueues = [];
- }
-
- /** @var Resource\Worker[] $workers */
- $workers = $workerModel->getAll();
- $workers = array_filter($workers, fn($worker) => !$worker->isStale());
-
- if (empty($workers)) {
- // No active workers at all, all queues are unworked
- return static::$unworkedQueues = $queues;
- }
-
- // Check each queue has at least one worker
- $map = [];
- foreach ($queues as $queue) {
- $map[$queue::class] = (object) ['queue' => $queue, 'workerCount' => 0];
- foreach ($workers as $worker) {
- foreach ($worker->queues as $workerQueue) {
- if ($workerQueue === $queue::class) {
- $map[$queue::class]->workerCount++;
- }
- }
- }
- }
-
- $map = array_filter($map, fn($map) => $map->workerCount === 0);
- $map = array_map(fn($map) => $map->queue, $map);
- $map = array_values($map);
-
- return static::$unworkedQueues = $map;
+ return static::$unworkedQueues = $manager->unworkedQueues();
}
}
diff --git a/src/Service/Manager.php b/src/Service/Manager.php
index adb356d..632b5f3 100644
--- a/src/Service/Manager.php
+++ b/src/Service/Manager.php
@@ -118,6 +118,83 @@ public function getQueues(): array
return $this->queues;
}
+ /**
+ * Discovered queues with no live worker.
+ *
+ * This is the check behind the admin dashboard alert. No discovered queues
+ * means nothing to report. A worker counts when its heartbeat is still
+ * inside QUEUE_WORKER_HEARTBEAT_STALE and its registered queues include
+ * the queue class.
+ *
+ * @return Queue[]
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function unworkedQueues(): array
+ {
+ $queues = $this->getQueues();
+ if ($queues === []) {
+ return [];
+ }
+
+ $covered = $this->coveredQueueClasses();
+ if ($covered === []) {
+ return $queues;
+ }
+
+ $unworked = [];
+
+ foreach ($queues as $queue) {
+ if (!isset($covered[$queue::class])) {
+ $unworked[$queue::class] = $queue;
+ }
+ }
+
+ return array_values($unworked);
+ }
+
+ /**
+ * Whether a live worker is registered for this queue.
+ *
+ * A worker counts when its heartbeat is still inside QUEUE_WORKER_HEARTBEAT_STALE
+ * and it was started for this queue class. A discovered queue is processed
+ * exactly when {@see unworkedQueues()} leaves it out.
+ *
+ * @throws FactoryException
+ * @throws InvalidArgumentException
+ * @throws ModelException
+ */
+ public function isProcessed(Queue|string $queue): bool
+ {
+ return isset($this->coveredQueueClasses()[$this->resolveQueue($queue)::class]);
+ }
+
+ /**
+ * Class names listed by a worker whose heartbeat is still fresh.
+ *
+ * @return array