From 95091e918ffd1623abb9f177ae6f73e36b30a3f0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:51:26 +0100 Subject: [PATCH] fix: optimize queries and caching for projects and environments --- app/Livewire/Project/Resource/Index.php | 55 +++++++++++++++---- app/Models/InstanceSettings.php | 6 +- app/Models/Server.php | 40 ++++++++++++++ app/Models/StandaloneDocker.php | 22 ++++++++ app/Models/SwarmDocker.php | 22 ++++++++ .../resources/breadcrumbs.blade.php | 44 ++++++++++++--- .../views/livewire/project/index.blade.php | 2 +- .../livewire/project/resource/index.blade.php | 28 +++++----- tests/Pest.php | 19 +++++++ 9 files changed, 203 insertions(+), 35 deletions(-) diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index 2b199dcfd..be6e3e98f 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -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; diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 376242ca0..ccc361d67 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -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) diff --git a/app/Models/Server.php b/app/Models/Server.php index 2319e0303..d693aea6d 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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 = [ diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 9f5f0b33e..62ef68434 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -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'); diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php index e0fe349c7..08be81970 100644 --- a/app/Models/SwarmDocker.php +++ b/app/Models/SwarmDocker.php @@ -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'); diff --git a/resources/views/components/resources/breadcrumbs.blade.php b/resources/views/components/resources/breadcrumbs.blade.php index 380c3270a..135cad3a7 100644 --- a/resources/views/components/resources/breadcrumbs.blade.php +++ b/resources/views/components/resources/breadcrumbs.blade.php @@ -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