fix(perf): eliminate N+1 queries from InstanceSettings and Server lookups (#7966)
This commit is contained in:
commit
e0f8ac4159
9 changed files with 203 additions and 35 deletions
|
|
@ -33,6 +33,10 @@ class Index extends Component
|
|||
|
||||
public Collection $services;
|
||||
|
||||
public Collection $allProjects;
|
||||
|
||||
public Collection $allEnvironments;
|
||||
|
||||
public array $parameters;
|
||||
|
||||
public function mount()
|
||||
|
|
@ -50,6 +54,33 @@ public function mount()
|
|||
->firstOrFail();
|
||||
|
||||
$this->project = $project;
|
||||
|
||||
// Load projects and environments for breadcrumb navigation (avoids inline queries in view)
|
||||
$this->allProjects = Project::ownedByCurrentTeamCached();
|
||||
$this->allEnvironments = $project->environments()
|
||||
->with([
|
||||
'applications.additional_servers',
|
||||
'applications.destination.server',
|
||||
'services',
|
||||
'services.destination.server',
|
||||
'postgresqls',
|
||||
'postgresqls.destination.server',
|
||||
'redis',
|
||||
'redis.destination.server',
|
||||
'mongodbs',
|
||||
'mongodbs.destination.server',
|
||||
'mysqls',
|
||||
'mysqls.destination.server',
|
||||
'mariadbs',
|
||||
'mariadbs.destination.server',
|
||||
'keydbs',
|
||||
'keydbs.destination.server',
|
||||
'dragonflies',
|
||||
'dragonflies.destination.server',
|
||||
'clickhouses',
|
||||
'clickhouses.destination.server',
|
||||
])->get();
|
||||
|
||||
$this->environment = $environment->loadCount([
|
||||
'applications',
|
||||
'redis',
|
||||
|
|
@ -71,11 +102,13 @@ public function mount()
|
|||
'destination.server.settings',
|
||||
'settings',
|
||||
])->get()->sortBy('name');
|
||||
$this->applications = $this->applications->map(function ($application) {
|
||||
$projectUuid = $this->project->uuid;
|
||||
$environmentUuid = $this->environment->uuid;
|
||||
$this->applications = $this->applications->map(function ($application) use ($projectUuid, $environmentUuid) {
|
||||
$application->hrefLink = route('project.application.configuration', [
|
||||
'project_uuid' => data_get($application, 'environment.project.uuid'),
|
||||
'environment_uuid' => data_get($application, 'environment.uuid'),
|
||||
'application_uuid' => data_get($application, 'uuid'),
|
||||
'project_uuid' => $projectUuid,
|
||||
'environment_uuid' => $environmentUuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
]);
|
||||
|
||||
return $application;
|
||||
|
|
@ -98,11 +131,11 @@ public function mount()
|
|||
'tags',
|
||||
'destination.server.settings',
|
||||
])->get()->sortBy('name');
|
||||
$this->{$property} = $this->{$property}->map(function ($db) {
|
||||
$this->{$property} = $this->{$property}->map(function ($db) use ($projectUuid, $environmentUuid) {
|
||||
$db->hrefLink = route('project.database.configuration', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'project_uuid' => $projectUuid,
|
||||
'database_uuid' => $db->uuid,
|
||||
'environment_uuid' => data_get($this->environment, 'uuid'),
|
||||
'environment_uuid' => $environmentUuid,
|
||||
]);
|
||||
|
||||
return $db;
|
||||
|
|
@ -114,11 +147,11 @@ public function mount()
|
|||
'tags',
|
||||
'destination.server.settings',
|
||||
])->get()->sortBy('name');
|
||||
$this->services = $this->services->map(function ($service) {
|
||||
$this->services = $this->services->map(function ($service) use ($projectUuid, $environmentUuid) {
|
||||
$service->hrefLink = route('project.service.configuration', [
|
||||
'project_uuid' => data_get($service, 'environment.project.uuid'),
|
||||
'environment_uuid' => data_get($service, 'environment.uuid'),
|
||||
'service_uuid' => data_get($service, 'uuid'),
|
||||
'project_uuid' => $projectUuid,
|
||||
'environment_uuid' => $environmentUuid,
|
||||
'service_uuid' => $service->uuid,
|
||||
]);
|
||||
|
||||
return $service;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Once;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class InstanceSettings extends Model
|
||||
|
|
@ -35,6 +36,9 @@ class InstanceSettings extends Model
|
|||
protected static function booted(): void
|
||||
{
|
||||
static::updated(function ($settings) {
|
||||
// Clear once() cache so subsequent calls get fresh data
|
||||
Once::flush();
|
||||
|
||||
// Clear trusted hosts cache when FQDN changes
|
||||
if ($settings->wasChanged('fqdn')) {
|
||||
\Cache::forget('instance_settings_fqdn_host');
|
||||
|
|
@ -82,7 +86,7 @@ public function autoUpdateFrequency(): Attribute
|
|||
|
||||
public static function get()
|
||||
{
|
||||
return InstanceSettings::findOrFail(0);
|
||||
return once(fn () => InstanceSettings::findOrFail(0));
|
||||
}
|
||||
|
||||
// public function getRecipients($notification)
|
||||
|
|
|
|||
|
|
@ -108,6 +108,12 @@ class Server extends BaseModel
|
|||
|
||||
public static $batch_counter = 0;
|
||||
|
||||
/**
|
||||
* Identity map cache for request-scoped Server lookups.
|
||||
* Prevents N+1 queries when the same Server is accessed multiple times.
|
||||
*/
|
||||
private static ?array $identityMapCache = null;
|
||||
|
||||
protected $appends = ['is_coolify_host'];
|
||||
|
||||
protected static function booted()
|
||||
|
|
@ -186,6 +192,40 @@ protected static function booted()
|
|||
$server->settings()->delete();
|
||||
$server->sslCertificates()->delete();
|
||||
});
|
||||
|
||||
static::updated(function () {
|
||||
static::flushIdentityMap();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a Server by ID using the identity map cache.
|
||||
* This prevents N+1 queries when the same Server is accessed multiple times.
|
||||
*/
|
||||
public static function findCached($id): ?static
|
||||
{
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (static::$identityMapCache === null) {
|
||||
static::$identityMapCache = [];
|
||||
}
|
||||
|
||||
if (! isset(static::$identityMapCache[$id])) {
|
||||
static::$identityMapCache[$id] = static::query()->find($id);
|
||||
}
|
||||
|
||||
return static::$identityMapCache[$id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the identity map cache.
|
||||
* Called automatically on update, and should be called in tests.
|
||||
*/
|
||||
public static function flushIdentityMap(): void
|
||||
{
|
||||
static::$identityMapCache = null;
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
|
|
|
|||
|
|
@ -73,6 +73,28 @@ public function server()
|
|||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server attribute using identity map caching.
|
||||
* This intercepts lazy-loading to use cached Server lookups.
|
||||
*/
|
||||
public function getServerAttribute(): ?Server
|
||||
{
|
||||
// Use eager loaded data if available
|
||||
if ($this->relationLoaded('server')) {
|
||||
return $this->getRelation('server');
|
||||
}
|
||||
|
||||
// Use identity map for lazy loading
|
||||
$server = Server::findCached($this->server_id);
|
||||
|
||||
// Cache in relation for future access on this instance
|
||||
if ($server) {
|
||||
$this->setRelation('server', $server);
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
public function services()
|
||||
{
|
||||
return $this->morphMany(Service::class, 'destination');
|
||||
|
|
|
|||
|
|
@ -56,6 +56,28 @@ public function server()
|
|||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server attribute using identity map caching.
|
||||
* This intercepts lazy-loading to use cached Server lookups.
|
||||
*/
|
||||
public function getServerAttribute(): ?Server
|
||||
{
|
||||
// Use eager loaded data if available
|
||||
if ($this->relationLoaded('server')) {
|
||||
return $this->getRelation('server');
|
||||
}
|
||||
|
||||
// Use identity map for lazy loading
|
||||
$server = Server::findCached($this->server_id);
|
||||
|
||||
// Cache in relation for future access on this instance
|
||||
if ($server) {
|
||||
$this->setRelation('server', $server);
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
public function services()
|
||||
{
|
||||
return $this->morphMany(Service::class, 'destination');
|
||||
|
|
|
|||
|
|
@ -2,12 +2,28 @@
|
|||
'lastDeploymentInfo' => null,
|
||||
'lastDeploymentLink' => null,
|
||||
'resource' => null,
|
||||
'projects' => null,
|
||||
'environments' => null,
|
||||
])
|
||||
@php
|
||||
$projects = auth()->user()->currentTeam()->projects()->get();
|
||||
$environments = $resource->environment->project
|
||||
use App\Models\Project;
|
||||
|
||||
// Use passed props if available, otherwise query (backwards compatible)
|
||||
$projects = $projects ?? Project::ownedByCurrentTeamCached();
|
||||
$environments = $environments ?? $resource->environment->project
|
||||
->environments()
|
||||
->with(['applications', 'services'])
|
||||
->with([
|
||||
'applications',
|
||||
'services',
|
||||
'postgresqls',
|
||||
'redis',
|
||||
'mongodbs',
|
||||
'mysqls',
|
||||
'mariadbs',
|
||||
'keydbs',
|
||||
'dragonflies',
|
||||
'clickhouses',
|
||||
])
|
||||
->get();
|
||||
$currentProjectUuid = data_get($resource, 'environment.project.uuid');
|
||||
$currentEnvironmentUuid = data_get($resource, 'environment.uuid');
|
||||
|
|
@ -74,6 +90,16 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
|
|||
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
|
||||
@foreach ($environments as $environment)
|
||||
@php
|
||||
// Use pre-loaded relations instead of databases() method to avoid N+1 queries
|
||||
$envDatabases = collect()
|
||||
->merge($environment->postgresqls ?? collect())
|
||||
->merge($environment->redis ?? collect())
|
||||
->merge($environment->mongodbs ?? collect())
|
||||
->merge($environment->mysqls ?? collect())
|
||||
->merge($environment->mariadbs ?? collect())
|
||||
->merge($environment->keydbs ?? collect())
|
||||
->merge($environment->dragonflies ?? collect())
|
||||
->merge($environment->clickhouses ?? collect());
|
||||
$envResources = collect()
|
||||
->merge(
|
||||
$environment->applications->map(
|
||||
|
|
@ -81,9 +107,7 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor
|
|||
),
|
||||
)
|
||||
->merge(
|
||||
$environment
|
||||
->databases()
|
||||
->map(fn($db) => ['type' => 'database', 'resource' => $db]),
|
||||
$envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]),
|
||||
)
|
||||
->merge(
|
||||
$environment->services->map(
|
||||
|
|
@ -173,7 +197,9 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor
|
|||
]),
|
||||
};
|
||||
$isCurrentResource = $res->uuid === $currentResourceUuid;
|
||||
$resHasMultipleServers = $resType === 'application' && method_exists($res, 'additional_servers') && $res->additional_servers()->count() > 0;
|
||||
// Use loaded relation count if available, otherwise check additional_servers_count attribute
|
||||
$resHasMultipleServers = $resType === 'application' && method_exists($res, 'additional_servers') &&
|
||||
($res->relationLoaded('additional_servers') ? $res->additional_servers->count() > 0 : ($res->additional_servers_count ?? 0) > 0);
|
||||
$resServerName = $resHasMultipleServers ? null : data_get($res, 'destination.server.name');
|
||||
@endphp
|
||||
<div @mouseenter="openRes('{{ $environment->uuid }}-{{ $res->uuid }}'); resPositions['{{ $environment->uuid }}-{{ $res->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
|
||||
|
|
@ -405,7 +431,9 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
|
|||
$isApplication = $resourceType === 'App\Models\Application';
|
||||
$isService = $resourceType === 'App\Models\Service';
|
||||
$isDatabase = str_contains($resourceType, 'Database') || str_contains($resourceType, 'Standalone');
|
||||
$hasMultipleServers = $isApplication && method_exists($resource, 'additional_servers') && $resource->additional_servers()->count() > 0;
|
||||
// Use loaded relation count if available, otherwise check additional_servers_count attribute
|
||||
$hasMultipleServers = $isApplication && method_exists($resource, 'additional_servers') &&
|
||||
($resource->relationLoaded('additional_servers') ? $resource->additional_servers->count() > 0 : ($resource->additional_servers_count ?? 0) > 0);
|
||||
$serverName = $hasMultipleServers ? null : data_get($resource, 'destination.server.name');
|
||||
$routeParams = [
|
||||
'project_uuid' => $currentProjectUuid,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 -mt-1">
|
||||
@foreach ($projects as $project)
|
||||
<div class="relative gap-2 cursor-pointer coolbox group">
|
||||
<a href="{{ $project->navigateTo() }}" class="absolute inset-0"></a>
|
||||
<a href="{{ $project->navigateTo() }}" {{ wireNavigate() }} class="absolute inset-0"></a>
|
||||
<div class="flex flex-1 mx-6">
|
||||
<div class="flex flex-col justify-center flex-1">
|
||||
<div class="box-title">{{ $project->name }}</div>
|
||||
|
|
|
|||
|
|
@ -29,9 +29,6 @@
|
|||
<livewire:project.delete-environment :disabled="!$environment->isEmpty()" :environment_id="$environment->id" />
|
||||
@endcan
|
||||
</div>
|
||||
@php
|
||||
$projects = auth()->user()->currentTeam()->projects()->get();
|
||||
@endphp
|
||||
<nav class="flex pt-2 pb-6">
|
||||
<ol class="flex items-center">
|
||||
<li class="inline-flex items-center" x-data="{ projectOpen: false, toggle() { this.projectOpen = !this.projectOpen }, open() { this.projectOpen = true }, close() { this.projectOpen = false } }">
|
||||
|
|
@ -53,7 +50,7 @@
|
|||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute z-20 top-full mt-1 w-56 -ml-2 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
|
||||
@foreach ($projects as $proj)
|
||||
@foreach ($allProjects as $proj)
|
||||
<a href="{{ route('project.show', ['project_uuid' => $proj->uuid]) }}"
|
||||
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $proj->uuid === $project->uuid ? 'dark:text-warning font-semibold' : '' }}"
|
||||
title="{{ $proj->name }}">
|
||||
|
|
@ -63,12 +60,6 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
|
|||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@php
|
||||
$allEnvironments = $project
|
||||
->environments()
|
||||
->with(['applications', 'services'])
|
||||
->get();
|
||||
@endphp
|
||||
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, envPositions: {}, activeRes: null, resPositions: {}, activeMenuEnv: null, menuPositions: {}, closeTimeout: null, envTimeout: null, resTimeout: null, menuTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null;
|
||||
this.activeRes = null;
|
||||
this.activeMenuEnv = null; } }, open() { clearTimeout(this.closeTimeout);
|
||||
|
|
@ -162,6 +153,16 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover
|
|||
<!-- Resources Sub-dropdown (2nd level) -->
|
||||
@foreach ($allEnvironments as $env)
|
||||
@php
|
||||
// Use pre-loaded relations instead of databases() method to avoid N+1 queries
|
||||
$envDatabases = collect()
|
||||
->merge($env->postgresqls ?? collect())
|
||||
->merge($env->redis ?? collect())
|
||||
->merge($env->mongodbs ?? collect())
|
||||
->merge($env->mysqls ?? collect())
|
||||
->merge($env->mariadbs ?? collect())
|
||||
->merge($env->keydbs ?? collect())
|
||||
->merge($env->dragonflies ?? collect())
|
||||
->merge($env->clickhouses ?? collect());
|
||||
$envResources = collect()
|
||||
->merge(
|
||||
$env->applications->map(
|
||||
|
|
@ -169,9 +170,7 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover
|
|||
),
|
||||
)
|
||||
->merge(
|
||||
$env
|
||||
->databases()
|
||||
->map(fn($db) => ['type' => 'database', 'resource' => $db]),
|
||||
$envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]),
|
||||
)
|
||||
->merge(
|
||||
$env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]),
|
||||
|
|
@ -208,10 +207,11 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor
|
|||
'database_uuid' => $res->uuid,
|
||||
]),
|
||||
};
|
||||
// Use loaded relation to check additional_servers (avoids N+1 query)
|
||||
$resHasMultipleServers =
|
||||
$resType === 'application' &&
|
||||
method_exists($res, 'additional_servers') &&
|
||||
$res->additional_servers()->count() > 0;
|
||||
($res->relationLoaded('additional_servers') ? $res->additional_servers->count() > 0 : false);
|
||||
$resServerName = $resHasMultipleServers
|
||||
? null
|
||||
: data_get($res, 'destination.server.name');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test Case
|
||||
|
|
@ -12,6 +15,22 @@
|
|||
*/
|
||||
uses(Tests\TestCase::class)->in('Feature');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test Hooks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Global hooks that run before/after each test.
|
||||
|
|
||||
*/
|
||||
beforeEach(function () {
|
||||
// Flush the Once memoization cache to ensure tests get fresh data
|
||||
Once::flush();
|
||||
|
||||
// Flush the Server identity map cache to ensure tests get fresh data
|
||||
Server::flushIdentityMap();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expectations
|
||||
|
|
|
|||
Loading…
Reference in a new issue