Skip to content
Open
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
5 changes: 1 addition & 4 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,9 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest]
php: [8.4, 8.3, 8.2]
php: [8.5, 8.4]
laravel: [13.*, 12.*, 11.*]
stability: [prefer-stable]
exclude:
- laravel: 13.*
php: 8.2
include:
- laravel: 13.*
testbench: 11.*
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
}
],
"require": {
"php": "^8.2|^8.3|^8.4",
"php": "^8.2|^8.3|^8.4|^8.5",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"illuminate/queue": "^10.0|^11.0|^12.0|^13.0",
"illuminate/events": "^10.0|^11.0|^12.0|^13.0",
Expand Down
2,529 changes: 1,363 additions & 1,166 deletions composer.lock

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions config/vantage.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,19 @@
*/
'database_connection' => env('VANTAGE_DATABASE_CONNECTION', null),

/*
|--------------------------------------------------------------------------
| Jobs List Filter Options Cache
|--------------------------------------------------------------------------
|
| The jobs list page builds its filter dropdowns (queues, job classes, tags)
| from queries that scan the whole table and rarely change. They are cached
| on the application's default cache store for this many seconds. Set to 0
| to disable caching and recompute them on every request.
|
*/
'filter_options_cache_ttl' => (int) env('VANTAGE_FILTER_OPTIONS_CACHE_TTL', 300),

/*
|--------------------------------------------------------------------------
| Authentication
Expand Down
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ parameters:
-
message: '#^Called ''env'' outside of the config directory which returns null when the config is cached, use ''config''\.$#'
identifier: larastan.noEnvCallsOutsideOfConfig
count: 16
count: 17
path: config/vantage.php

-
Expand Down
2 changes: 1 addition & 1 deletion resources/views/jobs.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<p class="mt-1 text-sm text-gray-500">Strategic monitoring and filtering of queue jobs</p>
</div>
<div class="flex items-center space-x-3">
<span class="text-sm text-gray-500">{{ $jobs->total() }} total jobs</span>
<span class="text-sm text-gray-500">Showing {{ $jobs->firstItem() ?? 0 }}&ndash;{{ $jobs->lastItem() ?? 0 }}</span>
<button onclick="location.reload()" class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
Expand Down
61 changes: 46 additions & 15 deletions src/Http/Controllers/QueueMonitorController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Queue\Jobs\Job;
use Illuminate\Routing\Controller;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Storvia\Vantage\Models\VantageJob;
Expand Down Expand Up @@ -330,30 +331,39 @@ public function jobs(Request $request)
}

// Get jobs
// simplePaginate avoids a COUNT(*) over the full filtered set on every load,
// which is the dominant cost on large tables. Trade-off: no total/numbered pages.
$jobs = $query->latest('id')
->paginate(50)
->simplePaginate(50)
->withQueryString();

$retryChildCounts = $this->retryChildCountsForJobIds($jobs->pluck('id')->all());
$attemptBounds = $this->attemptBoundsForUuids($jobs->pluck('uuid')->filter(fn ($u) => filled($u))->unique()->all());

// Get filter options
// Only show queues that actually have jobs in vantage_jobs table
// This ensures filtering by a queue will return results
$queues = VantageJob::distinct()
->whereNotNull('queue')
->where('queue', '!=', '')
->pluck('queue')
->filter()
->sort()
->values();

$jobClasses = VantageJob::distinct()->pluck('job_class')->map(fn ($c) => class_basename($c))->filter();
// Get filter options. These scan the whole table (or aggregate JSON over 30 days)
// and rarely change, so they are cached to avoid recomputing on every page load.
// A ttl of 0 disables caching and runs each query live.
$queues = $this->rememberFilterOptions('queues', function () {
// Only show queues that actually have jobs in vantage_jobs table
// This ensures filtering by a queue will return results
return VantageJob::distinct()
->whereNotNull('queue')
->where('queue', '!=', '')
->pluck('queue')
->filter()
->sort()
->values();
});

$jobClasses = $this->rememberFilterOptions('job_classes', function () {
return VantageJob::distinct()->pluck('job_class')->map(fn ($c) => class_basename($c))->filter();
});

// Get all available tags with counts - use optimized queries
// Only look at last 30 days to limit data size
$tagAggregator = new TagAggregator;
$allTags = $tagAggregator->getTopTags(now()->subDays(30), 50);
$allTags = $this->rememberFilterOptions('tags', function () {
return (new TagAggregator)->getTopTags(now()->subDays(30), 50);
});

return view('vantage::jobs', compact('jobs', 'retryChildCounts', 'attemptBounds', 'queues', 'jobClasses', 'allTags'));
}
Expand Down Expand Up @@ -492,6 +502,27 @@ public function retry($id)
* @param array<int|string|null> $vantageJobIds
* @return Collection<int|string, int>
*/
/**
* Cache a jobs-page filter option set. These queries scan the whole table and rarely
* change, so we cache them on the app's default store. A ttl of 0 (or less) bypasses
* the cache and runs the closure live, preserving the previous always-fresh behavior.
*
* @template TValue
*
* @param callable(): TValue $callback
* @return TValue
*/
protected function rememberFilterOptions(string $key, callable $callback)
{
$ttl = (int) config('vantage.filter_options_cache_ttl', 300);

if ($ttl <= 0) {
return $callback();
}

return Cache::remember('vantage:jobs:filter:'.$key, $ttl, $callback);
}

protected function retryChildCountsForJobIds(array $vantageJobIds): Collection
{
$ids = array_values(array_unique(array_map('intval', array_filter($vantageJobIds, fn ($id) => $id !== null && $id !== ''))));
Expand Down
93 changes: 91 additions & 2 deletions tests/Feature/QueueMonitorControllerTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Storvia\Vantage\Models\VantageJob;
use Storvia\Vantage\Support\TagAggregator;
Expand All @@ -10,18 +11,20 @@
});

it('counts processed and released jobs separately on the dashboard', function () {
// Seed within the dashboard's default period window (1h) so the counts are exercised
// regardless of that default.
VantageJob::create([
'uuid' => Str::uuid(),
'job_class' => 'App\\Jobs\\ProcessedJob',
'status' => 'processed',
'created_at' => now()->subDay(),
'created_at' => now()->subMinutes(30),
]);

VantageJob::create([
'uuid' => Str::uuid(),
'job_class' => 'App\\Jobs\\ReleasedJob',
'status' => 'released',
'created_at' => now()->subDay(),
'created_at' => now()->subMinutes(30),
]);

$this->get('/vantage')
Expand Down Expand Up @@ -379,3 +382,89 @@
->assertSee('Failed', false)
->assertSee('Test error', false);
});

it('paginates the jobs list without a total count query', function () {
foreach (range(1, 60) as $i) {
VantageJob::create([
'uuid' => Str::uuid(),
'job_class' => 'App\\Jobs\\BulkJob',
'status' => 'processed',
'queue' => 'default',
]);
}

$response = $this->get('/vantage/jobs');

$response->assertOk()
// simplePaginate exposes a "Next" link when more pages exist
->assertSee('rel="next"', false)
// it must NOT attempt to render a total count (would throw on simple paginator)
->assertDontSee('total jobs', false);

// second page is reachable
$this->get('/vantage/jobs?page=2')->assertOk();
});

it('caches jobs filter options across requests', function () {
config()->set('vantage.filter_options_cache_ttl', 300);
Cache::flush();

VantageJob::create([
'uuid' => Str::uuid(),
'job_class' => 'App\\Jobs\\OriginalJob',
'status' => 'processed',
'queue' => 'alpha',
]);

// First request populates the cache
$this->get('/vantage/jobs')
->assertOk()
->assertSee('OriginalJob', false)
->assertSee('alpha', false);

// A new class/queue inserted after caching must NOT appear until the cache expires
VantageJob::create([
'uuid' => Str::uuid(),
'job_class' => 'App\\Jobs\\LaterJob',
'status' => 'processed',
'queue' => 'bravo',
]);

$response = $this->get('/vantage/jobs');
$response->assertOk()
->assertSee('OriginalJob', false);

// The filter dropdown options come from cache, so the new ones are absent there.
$queues = $response->viewData('queues');
$jobClasses = $response->viewData('jobClasses');
expect($queues->all())->toBe(['alpha']);
expect($jobClasses->values()->all())->toBe(['OriginalJob']);
});

it('bypasses the filter options cache when ttl is zero', function () {
config()->set('vantage.filter_options_cache_ttl', 0);
Cache::flush();

VantageJob::create([
'uuid' => Str::uuid(),
'job_class' => 'App\\Jobs\\FirstJob',
'status' => 'processed',
'queue' => 'alpha',
]);

$this->get('/vantage/jobs')->assertOk();

VantageJob::create([
'uuid' => Str::uuid(),
'job_class' => 'App\\Jobs\\SecondJob',
'status' => 'processed',
'queue' => 'bravo',
]);

$response = $this->get('/vantage/jobs')->assertOk();

$queues = $response->viewData('queues');
$jobClasses = $response->viewData('jobClasses');
expect($queues->all())->toBe(['alpha', 'bravo']);
expect($jobClasses->values()->all())->toContain('FirstJob', 'SecondJob');
});