Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config/app.example.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@
// - string: Uses specified layout
'adminLayout' => null,

// Maximum number of pending and scheduled job rows the admin
// dashboard materialises and renders. Aggregate tile counts on the
// dashboard are still computed via DB count() and reflect the
// unbounded totals; only the visible row list is capped. Raise this
// for more rows at once, lower it if the page is sluggish on a
// large backlog. The cap also keeps DebugKit's Variables panel
// from OOM-ing on huge queues.
'adminDetailsLimit' => 200,

// Back-to-App link in the admin header (opt-in). When set, an outline
// button appears in the top navbar so admins can escape the
// plugin-isolated layout. Accepts anything Router::url() takes — Cake
Expand Down
49 changes: 39 additions & 10 deletions src/Controller/Admin/QueueController.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,31 @@ public function index() {
$status = $QueueProcesses->status();

$current = $this->QueuedJobs->getLength();
$pendingDetails = $this->QueuedJobs->getPendingStats()->toArray();
$new = 0;
foreach ($pendingDetails as $pendingDetail) {
if ($pendingDetail['fetched'] || $pendingDetail['attempts']) {
continue;
}
$new++;

// Cap how many pending/scheduled rows we materialise and pass to the
// view. Without a cap a backlog of thousands of pending jobs makes
// the dashboard explode: heavy per-row HTML in the response, and
// DebugKit's Variables panel serialising every entity for its
// snapshot can OOM the request. Aggregate tile counts on the
// dashboard keep using DB-side count() queries so they reflect the
// true totals regardless of the visible-list cap.
$detailsLimit = (int)Configure::read('Queue.adminDetailsLimit', 200);

// +1 row past the cap so we can detect truncation without a second
// count query in the small-backlog case.
$pendingDetails = $this->QueuedJobs->getPendingStats()->limit($detailsLimit + 1)->toArray();
$pendingDetailsTruncated = count($pendingDetails) > $detailsLimit;
if ($pendingDetailsTruncated) {
array_pop($pendingDetails);
}

$scheduledDetails = $this->QueuedJobs->getScheduledStats()->toArray();
$new = $this->QueuedJobs->getNewCount();

$scheduledDetails = $this->QueuedJobs->getScheduledStats()->limit($detailsLimit + 1)->toArray();
$scheduledDetailsTruncated = count($scheduledDetails) > $detailsLimit;
if ($scheduledDetailsTruncated) {
array_pop($scheduledDetails);
}

$data = $this->QueuedJobs->getStats();

Expand All @@ -74,7 +89,17 @@ public function index() {
$servers = $QueueProcesses->serverList();
$workers = $status ? $status['workers'] : 0;

$scheduledJobs = count($scheduledDetails);
// True totals from DB. When the visible list is truncated we need
// these for the "showing N of M" hint; when it isn't, they also
// happen to match $pendingDetails count (small extra query that
// avoids one branch in the derivation below).
$totalPending = $pendingDetailsTruncated
? $this->QueuedJobs->getPendingCount()
: count($pendingDetails);
$scheduledJobs = $scheduledDetailsTruncated
? $this->QueuedJobs->getScheduledCount()
: count($scheduledDetails);

$runningJobs = $this->QueuedJobs->find()
->where([
'completed IS' => null,
Expand All @@ -89,7 +114,7 @@ public function index() {
])
->count();
// Pending = total pending minus running and failed (to avoid double counting)
$pendingJobs = max(0, count($pendingDetails) - $runningJobs - $failedJobs);
$pendingJobs = max(0, $totalPending - $runningJobs - $failedJobs);

$configurations = (array)Configure::read('Queue');

Expand All @@ -99,6 +124,10 @@ public function index() {
'data',
'pendingDetails',
'scheduledDetails',
'pendingDetailsTruncated',
'scheduledDetailsTruncated',
'detailsLimit',
'totalPending',
'status',
'tasks',
'addableTasks',
Expand Down
57 changes: 57 additions & 0 deletions src/Model/Table/QueuedJobsTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,46 @@ public function getPendingStats(): SelectQuery {
return $this->find('all', ...$findCond);
}

/**
* Count of pending jobs (matches getPendingStats() conditions).
*
* Companion to getPendingStats() for callers that LIMIT the materialised
* details list and need the unbounded total to render "showing N of M".
*
* @return int
*/
public function getPendingCount(): int {
return $this->find()
->where([
'completed IS' => null,
'OR' => [
'notbefore <=' => new DateTime(),
'notbefore IS' => null,
],
])
->count();
}

/**
* Count of pending jobs that have never been fetched or attempted
* (the "new" tile on the admin dashboard).
*
* @return int
*/
public function getNewCount(): int {
return $this->find()
->where([
'completed IS' => null,
'fetched IS' => null,
'attempts' => 0,
'OR' => [
'notbefore <=' => new DateTime(),
'notbefore IS' => null,
],
])
->count();
}

/**
* @return \Cake\ORM\Query\SelectQuery
*/
Expand Down Expand Up @@ -1068,6 +1108,23 @@ public function getScheduledStats(): SelectQuery {
return $this->find('all', ...$findCond);
}

/**
* Count of scheduled jobs (matches getScheduledStats() conditions).
*
* Companion to getScheduledStats() for callers that LIMIT the
* materialised details list and need the unbounded total.
*
* @return int
*/
public function getScheduledCount(): int {
return $this->find()
->where([
'completed IS' => null,
'notbefore >' => new DateTime(),
])
->count();
}

/**
* Cleanup/Delete Completed Jobs.
*
Expand Down
44 changes: 41 additions & 3 deletions templates/Admin/Queue/index.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
<?php
/**
* @var \App\View\AppView $this
* @var \Queue\Model\Entity\QueuedJob[] $pendingDetails
* @var \Queue\Model\Entity\QueuedJob[] $scheduledDetails
* @var \Queue\Model\Entity\QueuedJob[] $pendingDetails Capped at $detailsLimit rows.
* @var \Queue\Model\Entity\QueuedJob[] $scheduledDetails Capped at $detailsLimit rows.
* @var bool $pendingDetailsTruncated True when the pending list was capped.
* @var bool $scheduledDetailsTruncated True when the scheduled list was capped.
* @var int $detailsLimit Configured cap for the visible pending/scheduled rows.
* @var int $totalPending True (uncapped) pending-jobs count.
* @var string[] $tasks
* @var string[] $addableTasks
* @var array<string, string|null> $taskDescriptions
Expand Down Expand Up @@ -124,6 +128,23 @@
) ?>
</div>
<div class="card-body p-0">
<?php if ($pendingDetailsTruncated): ?>
<div class="alert alert-info mb-0 small rounded-0 border-0 border-bottom">
<i class="fas fa-info-circle me-1"></i>
<?= __d(
'queue',
'Showing {0} most recent of {1} pending jobs. {2} for the full list.',
[
count($pendingDetails),
$totalPending,
$this->Html->link(
__d('queue', 'See QueuedJobs admin'),
['controller' => 'QueuedJobs', 'action' => 'index', '?' => ['status' => 'in_progress']],
),
],
) ?>
</div>
<?php endif; ?>
<?php if ($pendingDetails): ?>
<div class="table-responsive">
<table class="table table-hover mb-0">
Expand Down Expand Up @@ -248,9 +269,26 @@
<?php if ($scheduledDetails): ?>
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-calendar me-2"></i><?= __d('queue', 'Scheduled Jobs') ?> (<?= count($scheduledDetails) ?>)
<i class="fas fa-calendar me-2"></i><?= __d('queue', 'Scheduled Jobs') ?> (<?= $scheduledJobs ?>)
</div>
<div class="card-body p-0">
<?php if ($scheduledDetailsTruncated): ?>
<div class="alert alert-info mb-0 small rounded-0 border-0 border-bottom">
<i class="fas fa-info-circle me-1"></i>
<?= __d(
'queue',
'Showing {0} most recent of {1} scheduled jobs. {2} for the full list.',
[
count($scheduledDetails),
$scheduledJobs,
$this->Html->link(
__d('queue', 'See QueuedJobs admin'),
['controller' => 'QueuedJobs', 'action' => 'index', '?' => ['status' => 'scheduled']],
),
],
) ?>
</div>
<?php endif; ?>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
Expand Down
46 changes: 46 additions & 0 deletions tests/TestCase/Controller/Admin/QueueControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,52 @@ public function testIndex() {
$this->assertResponseCode(200);
}

/**
* The pending/scheduled detail lists must be capped to
* Queue.adminDetailsLimit so the dashboard renders a bounded amount of
* markup and DebugKit's Variables panel doesn't OOM on large backlogs.
*
* @return void
*/
public function testIndexTruncatesPendingDetailsAtDetailsLimit() {
Configure::write('Queue.adminDetailsLimit', 3);

$QueuedJobs = $this->fetchTable('Queue.QueuedJobs');
for ($i = 0; $i < 5; $i++) {
$QueuedJobs->createJob('Queue.Example', ['n' => $i]);
}

$this->get(['prefix' => 'Admin', 'plugin' => 'Queue', 'controller' => 'Queue', 'action' => 'index']);

$this->assertResponseCode(200);
$pendingDetails = $this->viewVariable('pendingDetails');
$this->assertCount(3, $pendingDetails);
$this->assertTrue($this->viewVariable('pendingDetailsTruncated'));
$this->assertSame(5, $this->viewVariable('totalPending'));
$this->assertSame(3, $this->viewVariable('detailsLimit'));
}

/**
* The truncation flag must stay false when the backlog fits inside the
* cap — otherwise the "Showing N of M" hint would render unnecessarily
* and the controller would issue an extra count() it doesn't need.
*
* @return void
*/
public function testIndexNotTruncatedWhenWithinLimit() {
Configure::write('Queue.adminDetailsLimit', 200);

$QueuedJobs = $this->fetchTable('Queue.QueuedJobs');
$QueuedJobs->createJob('Queue.Example');

$this->get(['prefix' => 'Admin', 'plugin' => 'Queue', 'controller' => 'Queue', 'action' => 'index']);

$this->assertResponseCode(200);
$this->assertFalse($this->viewVariable('pendingDetailsTruncated'));
$this->assertFalse($this->viewVariable('scheduledDetailsTruncated'));
$this->assertSame(1, $this->viewVariable('totalPending'));
}

/**
* @return void
*/
Expand Down
Loading