Skip to content

perf(posts): eliminate N+1 on terms, author and thumbnails - #7

Open
Guajir0-code wants to merge 2 commits into
sourcevortex:mainfrom
Guajir0-code:perf/eliminate-n-plus-one
Open

perf(posts): eliminate N+1 on terms, author and thumbnails#7
Guajir0-code wants to merge 2 commits into
sourcevortex:mainfrom
Guajir0-code:perf/eliminate-n-plus-one

Conversation

@Guajir0-code

Copy link
Copy Markdown

Problema

O número de queries por página cresce com a quantidade de posts renderizados. Uma home com dez posts dispara 45 consultas.

Evidência

Medido com DB::enableQueryLog() sobre as rotas reais, antes da correção:

Página Queries
Home, 3 posts 17
Home, 30 posts 45
Categoria, 10 posts 36
Post individual 21

O salto de 17 para 45 apenas por haver mais posts é a assinatura de N+1.

Causa

Quatro problemas independentes, todos no caminho de render.

1. terms nunca é eager-loaded

WpPostResource::getCategories()$this->terms para todo post. Nenhum dos sete with() de WpPostService incluía essa relação — buscando por ->with( no service, terms não aparecia em lugar nenhum. Uma query por post.

2. getThumbnail() custava duas queries por post

if (! $postMeta = $this->metadata()->thumbnailId()->first()) {

$this->metadata() chama o método da relação, não a propriedade. Isso constrói uma query nova e ignora a coleção já carregada pelo eager load. Depois, uma segunda query resolve o guid do anexo.

3. author faltava em três consultas

getHomePosts(), getHighlightedPosts() e getRelatedPosts() carregavam só metadata, mas o resource lê $this->author sempre.

4. Os posts relacionados recebiam o tratamento de post completo

Este só apareceu ao inspecionar as queries restantes. WpPostResource decidia seu formato assim:

if ($request->routeIs('post')) {
    $postData['content'] = EmbedProcessorService::processContent($this->post_content);
    $postData['subtitle'] = ...;
    $postData['author_description'] = ...;
    $postData['author_gravatar'] = ...;
    $postData['tags'] = $this->getTags();
}

routeIs() é propriedade da requisição, não do recurso. Numa página de post, os relacionados passam pelo mesmo resource, na mesma requisição — então cada um deles também ganhava conteúdo processado, subtítulo, biografia do autor, gravatar e tags.

PostContentRelated.vue usa apenas id, date, slug, thumbnail e title. Todo o resto era descartado.

O custo maior nem é em queries: EmbedProcessorService::processContent() rodava quatro vezes por página de post — uma para o post, três para os relacionados.

Solução

Eager loading correto

Listagens carregam ['terms', 'author', 'thumbnail']. A página de post carrega ['terms', 'thumbnail', 'metadata', 'author.metadata'], que é o que ela de fato consome.

metadata saiu das listagens: depois da mudança do thumbnail, nada nelas usa mais essa relação.

Thumbnail como relação

Novo model WpAttachment, sobre a mesma wp_posts mas sem WpPostScope — o scope filtra post_type = 'post' e post_status = 'publish', que excluiria todo anexo (attachment / inherit).

public function thumbnail(): HasOneThrough
{
    return $this->hasOneThrough(
        WpAttachment::class,
        WpPostMeta::class,
        'post_id', 'ID', 'ID', 'meta_value',
    )
        ->where('wp_postmeta.meta_key', '_thumbnail_id')
        ->where('wp_posts.post_type', 'attachment');
}

getThumbnail() vira um acessor da relação carregada. Duas queries por post viram uma para a página inteira.

Formato explícito no resource

// PostController::show()
'post' => WpPostResource::make($post)->detailed(),

O opt-in substitui a inferência por rota. Os relacionados usam o formato de listagem.

Conferi antes que isso não quebra o front: em resources/js/types/index.d.ts, todos os campos afetados (content, subtitle, author_description, author_gravatar, tags) já são opcionais em Post.

Blindagem

Model::preventLazyLoading(! $this->app->isProduction());

Fora de produção, um lazy load acidental vira exceção em vez de query silenciosa.

Resultado

Página Antes Depois
Home, 10 posts 45 7
Categoria, 10 posts 36 6
Post individual 21 11

E, o que mais importa, a contagem parou de escalar: com 3 ou 30 posts, o mesmo número de queries.

Como validar

php vendor/bin/pest --filter=QueryCountTest
✓ it does not scale queries with the number of posts on the home page
✓ it keeps the home page within a query budget
✓ it keeps the single post page within a query budget
✓ it keeps the category listing within a query budget

O primeiro teste é o que guarda a propriedade real: mede a home com 3 posts e com 30, e exige que os números sejam iguais. Os demais fixam orçamentos com folga de duas queries sobre o medido, para pegar reintrodução de N+1 sem quebrar por uma query pontual a mais.

A suíte completa (16 testes) passa com preventLazyLoading ativo, o que é a prova de que não sobrou lazy load nas sete rotas.

Impacto

  • Comportamento: o payload de post continua idêntico. Os itens de relatedPosts deixam de trazer content, subtitle, tags, author_description e author_gravatar — campos opcionais no tipo e não usados pelo componente.
  • Schema: nenhuma alteração de banco. Nenhum índice novo é exigido.
  • Compatibilidade: nenhuma variável de ambiente nova. preventLazyLoading fica desligado em produção.
  • Rollback: reverter o commit.

Um ponto que merece atenção no review

O HasOneThrough junta wp_posts.ID (bigint) com wp_postmeta.meta_value (longtext). Funciona, e o MySQL converte implicitamente, mas o plano de execução merece uma olhada com o volume real de dados. Se o custo não for aceitável, a alternativa é ler _thumbnail_id da coleção metadata já carregada e resolver os guid com um único whereIn por página — mesmo número de queries, sem o join heterogêneo.

Fora de escopo

  • EmbedProcessorService continua fazendo chamadas HTTP durante a requisição. Este PR reduz de quatro execuções para uma por página de post; remover as chamadas é tratado no PR de embeds.
  • Estratégia de cache. Os TTLs de um dia e a ausência de invalidação continuam como estão.
  • Índices no banco do WordPress. Nada aqui exige mudança de schema.

Guajir0-code and others added 2 commits August 5, 2026 10:10
The app points Eloquent at an existing WordPress database, so no migration
creates wp_posts, wp_users, wp_terms and friends. Any test that touched the
database therefore could not run, and the single feature test in the repo
(GET / asserting 200) failed on a missing database.

Add a schema builder for the wp_* tables the app reads, small row builders,
and smoke coverage for all seven routes in routes/web.php, including the
highlighted-post exclusion, search, and the published/scheduled filtering the
global scope is responsible for.

withoutVite() keeps the suite independent of `npm run build`.
ExampleTest is dropped: RoutesTest covers GET / properly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three separate per-record queries were issued while rendering any listing:

* terms was never eager loaded anywhere, yet WpPostResource::getCategories()
  reads it for every post
* getThumbnail() called $this->metadata() (the relation method, bypassing the
  eager loaded collection) and then looked up the attachment guid, costing two
  queries per post
* author was missing from the home, highlights and related-posts queries

WpPostResource also chose its shape from $request->routeIs('post'), so the
related posts rendered on a post page received the full detail treatment,
including running EmbedProcessorService over content the "Veja tambem" cards
never display. Replaced with an explicit ->detailed() opt-in from the
controller.

Featured images are now a HasOneThrough relation via WpAttachment, a model on
wp_posts without WpPostScope, so they can be eager loaded in one query.

preventLazyLoading is enabled outside production so this cannot silently
regress.

Measured with 10 posts:

  home       45 -> 7 queries
  category   36 -> 6 queries
  post       21 -> 11 queries

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant