fix(dashboard): bound recent deployments query per application

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-28 23:58:59 +01:00
co-authored by Claude Opus 5.5
parent 3d956c110c
commit a12f62ed4f
4 changed files with 213 additions and 17 deletions
+50 -13
View File
@@ -6,6 +6,7 @@ use App\Enums\ApplicationDeploymentStatus;
use App\Models\ApplicationDeploymentQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -28,6 +29,18 @@ class ActiveDeployments extends Component
'finished_at',
];
private const ACTIVE_STATUSES = [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
];
/**
* Not dehydrated, so it is loaded once per request and shared by both lists.
*
* @var Collection<int, string>|null
*/
private ?Collection $teamApplicationIds = null;
/**
* Polling target. The lists are loaded again on every render.
*/
@@ -37,10 +50,8 @@ class ActiveDeployments extends Component
public function activeDeployments(): Collection
{
return $this->deploymentsQuery()
->whereIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
])
->whereIn('application_id', $this->teamApplicationIds())
->whereIn('status', self::ACTIVE_STATUSES)
->orderBy('status')
->orderBy('id')
->limit(5)
@@ -50,21 +61,47 @@ class ActiveDeployments extends Component
#[Computed]
public function recentDeployments(): Collection
{
return $this->deploymentsQuery()
->whereNotIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
])
if (DB::connection()->getDriverName() !== 'pgsql') {
return $this->deploymentsQuery()
->whereIn('application_id', $this->teamApplicationIds())
->whereNotIn('status', self::ACTIVE_STATUSES)
->orderByDesc('id')
->limit(5)
->get(self::COLUMNS);
}
// With "application_id IN (...) ORDER BY id DESC" PostgreSQL scans the primary key backwards and can
// read most of the table for a team without recent deployments. Take the five newest of each
// application from idx_deployment_queues_app_id instead; the five newest of the team are among them.
$newestPerApplication = ApplicationDeploymentQueue::query()
->select(self::COLUMNS)
->whereColumn('application_id', 'team_applications.id')
->whereNotIn('status', self::ACTIVE_STATUSES)
->orderByDesc('id')
->limit(5);
return $this->deploymentsQuery()
->fromRaw('unnest(?::varchar[]) as team_applications(id)', ['{'.$this->teamApplicationIds()->implode(',').'}'])
->joinLateral($newestPerApplication, 'application_deployment_queues')
->orderByDesc('application_deployment_queues.id')
->limit(5)
->get(self::COLUMNS);
->get(array_map(fn (string $column) => "application_deployment_queues.{$column}", self::COLUMNS));
}
private function deploymentsQuery(): Builder
{
return ApplicationDeploymentQueue::query()
->with(['application.environment.project'])
->ownedByTeam(currentTeam()->id);
return ApplicationDeploymentQueue::query()->with([
'application' => fn ($query) => $query->withoutGlobalScope('withRelations')->select(['id', 'environment_id']),
'application.environment.project',
]);
}
/**
* @return Collection<int, string>
*/
private function teamApplicationIds(): Collection
{
return $this->teamApplicationIds ??= ApplicationDeploymentQueue::applicationIdsOwnedByTeam(currentTeam()->id);
}
public function render()
+15 -4
View File
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use OpenApi\Attributes as OA;
@@ -166,13 +167,23 @@ class ApplicationDeploymentQueue extends Model
*/
public function scopeOwnedByTeam(Builder $query, int $teamId): Builder
{
// application_id is varchar; whereHas joins to applications.id (bigint) and breaks on PostgreSQL.
$applicationIds = Application::withTrashed()
return $query->whereIn('application_id', self::applicationIdsOwnedByTeam($teamId));
}
/**
* IDs of the team's applications, deleted ones included, as strings to match application_id.
*
* application_id is varchar; whereHas joins to applications.id (bigint) and breaks on PostgreSQL.
*
* @return Collection<int, string>
*/
public static function applicationIdsOwnedByTeam(int $teamId): Collection
{
return Application::withTrashed()
->withoutGlobalScope('withRelations')
->whereRelation('environment.project.team', 'id', $teamId)
->pluck('id')
->map(fn ($id) => (string) $id);
return $query->whereIn('application_id', $applicationIds);
}
public function server(): Attribute
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public $withinTransaction = false;
public function up(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deployment_queues_app_id ON application_deployment_queues (application_id, id)');
return;
}
if (! Schema::hasIndex('application_deployment_queues', 'idx_deployment_queues_app_id')) {
Schema::table('application_deployment_queues', function (Blueprint $table) {
$table->index(['application_id', 'id'], 'idx_deployment_queues_app_id');
});
}
}
public function down(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_deployment_queues_app_id');
return;
}
if (Schema::hasIndex('application_deployment_queues', 'idx_deployment_queues_app_id')) {
Schema::table('application_deployment_queues', function (Blueprint $table) {
$table->dropIndex('idx_deployment_queues_app_id');
});
}
}
};
+107
View File
@@ -212,3 +212,110 @@ it('does not load deployment payload columns when the dashboard list polls', fun
->not->toContain('configuration_diff');
}
});
function createDashboardApplication(array $overrides = []): Application
{
return Application::factory()->create(array_merge([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
], $overrides));
}
it('lists the five newest eligible deployments of the team across its applications', function () {
$secondApplication = createDashboardApplication();
$thirdApplication = createDashboardApplication();
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherApplication = createDashboardApplication([
'environment_id' => Environment::factory()->create(['project_id' => $otherProject->id])->id,
]);
$expected = [];
foreach (range(1, 4) as $round) {
foreach ([$this->application, $secondApplication, $thirdApplication] as $application) {
$expected[] = createDashboardDeployment([
'application_id' => $application->id,
'status' => $round % 2 === 0
? ApplicationDeploymentStatus::FAILED->value
: ApplicationDeploymentStatus::FINISHED->value,
])->id;
}
}
createDashboardDeployment(['application_id' => $otherApplication->id]);
createDashboardDeployment(['application_id' => $secondApplication->id, 'status' => ApplicationDeploymentStatus::QUEUED->value]);
createDashboardDeployment(['application_id' => $thirdApplication->id, 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
$component = Livewire::test(ActiveDeployments::class);
expect($component->get('recentDeployments')->pluck('id')->all())
->toBe(array_slice(array_reverse($expected), 0, 5));
});
it('lists fewer than five recent deployments when fewer are eligible', function () {
$finished = createDashboardDeployment()->id;
$failed = createDashboardDeployment(['status' => ApplicationDeploymentStatus::FAILED->value])->id;
createDashboardDeployment(['status' => ApplicationDeploymentStatus::QUEUED->value]);
expect(Livewire::test(ActiveDeployments::class)->get('recentDeployments')->pluck('id')->all())
->toBe([$failed, $finished]);
});
it('keeps deployments of deleted applications and skips deployments without an application', function () {
$deletedApplication = createDashboardApplication();
$deployment = createDashboardDeployment([
'application_id' => $deletedApplication->id,
'application_name' => 'Deleted App Deployment',
]);
$deletedApplication->delete();
createDashboardDeployment([
'application_id' => '999999',
'application_name' => 'Orphan Deployment',
]);
$component = Livewire::test(ActiveDeployments::class);
expect($component->get('recentDeployments')->pluck('id')->all())->toBe([$deployment->id])
->and($component->get('recentDeployments')->first()->application)->toBeNull();
$component->assertSee('Deleted App Deployment')->assertDontSee('Orphan Deployment');
});
it('moves a deployment from the active to the recent list when its status changes', function () {
$deployment = createDashboardDeployment([
'application_name' => 'Status Change App',
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
$component = Livewire::test(ActiveDeployments::class);
expect($component->get('activeDeployments')->pluck('id')->all())->toBe([$deployment->id])
->and($component->get('recentDeployments'))->toBeEmpty();
$deployment->setStatus(ApplicationDeploymentStatus::FINISHED->value);
$component->call('refreshDeployments');
expect($component->get('activeDeployments'))->toBeEmpty()
->and($component->get('recentDeployments')->pluck('id')->all())->toBe([$deployment->id]);
$component->assertDontSee('Running or queued right now')->assertSee('Recent deployments');
});
it('looks up the team applications once per request without loading full applications', function () {
createDashboardDeployment();
createDashboardDeployment(['status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
$applicationQueries = [];
DB::listen(function ($query) use (&$applicationQueries) {
if (str_contains($query->sql, 'from "applications"')) {
$applicationQueries[] = $query->sql;
}
});
Livewire::test(ActiveDeployments::class)->assertSee('Dashboard App');
$teamLookups = array_filter($applicationQueries, fn ($sql) => str_contains($sql, 'exists'));
expect($teamLookups)->toHaveCount(1);
foreach ($applicationQueries as $sql) {
expect($sql)->not->toMatch('/^select\s+("?\w+"?\.)?\*/i')
->not->toContain('additional_servers_count')
->not->toContain('additional_networks_count');
}
});