perf(posts): eliminate N+1 on terms, author and thumbnails - #7
Open
Guajir0-code wants to merge 2 commits into
Open
perf(posts): eliminate N+1 on terms, author and thumbnails#7Guajir0-code wants to merge 2 commits into
Guajir0-code wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: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.
termsnunca é eager-loadedWpPostResource::getCategories()lê$this->termspara todo post. Nenhum dos setewith()deWpPostServiceincluía essa relação — buscando por->with(no service,termsnão aparecia em lugar nenhum. Uma query por post.2.
getThumbnail()custava duas queries por post$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 oguiddo anexo.3.
authorfaltava em três consultasgetHomePosts(),getHighlightedPosts()egetRelatedPosts()carregavam sómetadata, mas o resource lê$this->authorsempre.4. Os posts relacionados recebiam o tratamento de post completo
Este só apareceu ao inspecionar as queries restantes.
WpPostResourcedecidia seu formato assim: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.vueusa apenasid,date,slug,thumbnailetitle. 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.metadatasaiu das listagens: depois da mudança do thumbnail, nada nelas usa mais essa relação.Thumbnail como relação
Novo model
WpAttachment, sobre a mesmawp_postsmas semWpPostScope— o scope filtrapost_type = 'post'epost_status = 'publish', que excluiria todo anexo (attachment/inherit).getThumbnail()vira um acessor da relação carregada. Duas queries por post viram uma para a página inteira.Formato explícito no resource
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 emPost.Blindagem
Fora de produção, um lazy load acidental vira exceção em vez de query silenciosa.
Resultado
E, o que mais importa, a contagem parou de escalar: com 3 ou 30 posts, o mesmo número de queries.
Como validar
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
preventLazyLoadingativo, o que é a prova de que não sobrou lazy load nas sete rotas.Impacto
postcontinua idêntico. Os itens derelatedPostsdeixam de trazercontent,subtitle,tags,author_descriptioneauthor_gravatar— campos opcionais no tipo e não usados pelo componente.preventLazyLoadingfica desligado em produção.Um ponto que merece atenção no review
O
HasOneThroughjuntawp_posts.ID(bigint) comwp_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_idda coleçãometadatajá carregada e resolver osguidcom um únicowhereInpor página — mesmo número de queries, sem o join heterogêneo.Fora de escopo
EmbedProcessorServicecontinua 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.