Skip to content

perf(search): normalise terms, cap cache growth and rate limit - #9

Open
Guajir0-code wants to merge 2 commits into
sourcevortex:mainfrom
Guajir0-code:perf/search-guardrails
Open

perf(search): normalise terms, cap cache growth and rate limit#9
Guajir0-code wants to merge 2 commits into
sourcevortex:mainfrom
Guajir0-code:perf/search-guardrails

Conversation

@Guajir0-code

Copy link
Copy Markdown

Problema

Cada valor distinto de ?s= cria uma entrada de cache própria, guardada por um dia, respaldada por um LIKE '%termo%' sobre post_title e post_content. Nada limita quantas dessas um visitante consegue criar.

Causa

$searchTermHash = $searchTerm ? md5($searchTerm) : null;
$cacheKey = $searchTerm ? "search_{$searchTermHash}_posts_page_{$page}" : "home_posts_page_{$page}";
$cacheTTL = now()->addDay();

O termo vai direto do ?s= para o md5(), sem normalização nem validação. Disso decorrem três coisas:

Entradas duplicadas. Games, games e games produzem três hashes, três entradas de cache e três varreduras completas — para um único conjunto de resultados.

Crescimento sem limite. Um script (ou um crawler mal comportado) pedindo ?s=<aleatório> em laço gera uma entrada nova por requisição, cada uma viva por 24 horas. Com CACHE_STORE=database, que é o padrão do .env.example, isso cresce numa tabela dentro do próprio banco do WordPress.

Cada miss custa uma varredura. LIKE '%termo%' não usa índice; o % inicial obriga a percorrer a tabela. Com post_content incluído, cada consulta lê o corpo de todos os posts.

Não há rate limit em nenhuma rota do projeto.

Solução

Normalização única, no controller

$searchTerm = WpPostService::normaliseSearchTerm($request->get('s'));
public static function normaliseSearchTerm(?string $term): ?string
{
    $term = trim(preg_replace('/\s+/u', ' ', (string) $term));

    if (mb_strlen($term) < self::MIN_SEARCH_LENGTH) {
        return null;
    }

    return mb_strtolower(mb_substr($term, 0, self::MAX_SEARCH_LENGTH));
}

Feito no controller, e não dentro do service, para que o valor usado na consulta seja o mesmo devolvido para a página. Se o service normalizasse por dentro, uma busca por AB mostraria o termo na tela enquanto a listagem ignorava o filtro.

  • termos com menos de 3 caracteres são descartados: casam com quase tudo e não ajudam ninguém
  • termos acima de 60 caracteres são cortados
  • espaços colapsados e caixa unificada, então as variações compartilham uma entrada

TTL curto para busca

$cacheTTL = $searchTerm ? now()->addMinutes(5) : now()->addDay();

Resultado de busca é indexado por entrada arbitrária de visitante: são muitos, e cada um só interessa a quem digitou aquele termo exato. Cinco minutos absorvem repetição sem acumular.

Rate limit apenas na busca

RateLimiter::for('search', function (Request $request) {
    return $request->filled('s')
        ? Limit::perMinute(20)->by($request->ip())
        : Limit::none();
});

Aplicado com ->middleware('throttle:search') na rota da home. Tráfego normal, incluindo paginação, não é afetado — só requisições que carregam ?s=.

Como validar

php vendor/bin/pest --filter=SearchTest
Tests: 14 passed (97 assertions)

A cobertura inclui a tabela de normalização (caixa, espaços, nulo, vazio, curto demais), o corte de comprimento, busca funcionando, insensibilidade a caixa, termo curto sendo tratado como "sem busca", e o rate limit — este último confirmando também que 25 requisições à home sem ?s= passam sem serem limitadas.

Verificado nos dois sentidos: contra o código anterior, 13 dos 14 falham.

Impacto

  • Comportamento: buscas com menos de 3 caracteres passam a se comportar como se nenhuma busca tivesse sido feita — a home normal, sem o termo ecoado. Buscas válidas seguem idênticas, exceto pelo termo aparecer em minúsculas na tela.
  • Cache: entradas de busca vivem 5 minutos em vez de 24 horas.
  • Rate limit: 20 buscas por minuto por IP; acima disso, 429.
  • Schema: nenhuma alteração de banco.
  • Rollback: reverter o commit.

Um teste existente em RoutesTest foi ajustado: ele esperava searchTerm de volta na caixa original. A normalização é intencional, então a expectativa passou a ser o termo normalizado.

Fora de escopo: o índice FULLTEXT

A causa raiz do custo por consulta é o LIKE '%termo%', e a correção adequada seria um índice FULLTEXT sobre post_title e post_content com MATCH ... AGAINST.

Deixei de fora deliberadamente, por dois motivos:

  1. Exige ALTER TABLE wp_posts no banco de produção do WordPress. É a única mudança de schema que o trabalho de auditoria identificou, e precisa de janela de baixo tráfego e decisão de quem opera o banco.
  2. Não tenho como testá-la. A suíte roda em sqlite, que não implementa FULLTEXT do MySQL. Entregar um caminho de código exercitado apenas em produção seria pior do que não entregá-lo.

O comando, para quando houver decisão:

ALTER TABLE wp_posts
    ADD FULLTEXT INDEX ft_post_search (post_title, post_content)
    ALGORITHM=INPLACE, LOCK=NONE;

É aditivo e o WordPress ignora índices que não conhece. O rollback é DROP INDEX ft_post_search ON wp_posts.

As proteções deste PR reduzem bastante a frequência com que a varredura acontece, mas não mudam o custo de cada uma.

Também fora de escopo

  • Chaves de cache de categoria, tag, autor e arquivo continuam usando valores crus da URL. O vetor foi estreitado pelas restrições de rota do PR de rotas, mas a normalização em si não foi generalizada aqui.
  • Feedback de "termo muito curto" na interface. Hoje o termo é simplesmente ignorado; mostrar uma mensagem é decisão de produto.

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>
Every distinct ?s= value produced its own cache entry, kept for a day, each one
backed by a LIKE '%term%' scan over post_title and post_content. Nothing capped
how many of those a visitor could create, and "Games", "games" and " games "
were three entries for one result set.

* normalise the term once, in the controller, so the query, the cache key and
  the value echoed back to the page all agree
* ignore terms below 3 characters, which match most of the table and return
  nothing useful
* cap terms at 60 characters
* keep search results for 5 minutes instead of a day
* rate limit to 20/minute per IP, applied only to requests carrying ?s= so
  ordinary home page traffic and pagination are untouched

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