Skip to content

ADR-013 — Email Outbox: Trigger Direto + Cron Safety Net

CampoValor
Status✅ Aceito
Data2026-05-10
AutoresEquipe OLP
Implementaçãosupabase/functions/_shared/email-outbox.ts, supabase/functions/process-email-outbox/index.ts, pg_cron job process_email_outbox

Contexto

A fila de email (email_outbox + Resend) era drenada apenas por pg_cron rodando a cada 1 min. Resultado medido em produção (10 envios mais recentes): latência 16–35s média, p95 ~60s. Resend responde em <1s; loop de 20 emails é trivial. A janela de polling era 95% do tempo observado.

O usuário pediu avaliação de Redis e split do worker Cloudflare como caminhos de aceleração. Analisamos:

  • Redis (Upstash) como fila exige worker persistente (BLPOP/subscribe). Edge Functions Supabase não são processos persistentes. Cloudflare Worker free tem cron mínimo de 1 min; Durable Objects + alarms permitem segundos, mas é arquitetura nova.
  • QStash / fila externa: troca pg_cron por outro orquestrador, sem ganho real vs. trigger direto.
  • Reduzir cron para 10s: alivia, mas martela Postgres mesmo com fila vazia.

A causa-raiz é estrutural: enquanto o disparo for por polling, latência mínima ≈ intervalo do polling.

Decisão

Adotamos trigger direto pós-enqueue + cron como safety net:

  1. enqueueEmail() faz o INSERT na email_outbox e, em seguida, invoca process-email-outbox em background via EdgeRuntime.waitUntil(fetch(...)). A Response da função chamadora não é bloqueada.
  2. O cron de 1 min permanece intacto. Cobre: (a) retentativas de envios failed/backoff, (b) inserts via SQL fora de enqueueEmail, (c) qualquer falha do invoke direto (rede, throttle).
  3. process-email-outboxemail_outbox_batch_size de configuracoes_plataforma (cache 30s em memória) — operador ajusta sem deploy.

Decisões correlatas que NÃO foram tomadas:

  • ❌ Não migramos para Redis/Upstash como fila. Latência objetivo (<2s) já é atingida sem isso.
  • ❌ Não dividimos o Cloudflare Worker. Trabalho legítimo, mas independente — ADR próprio.
  • ❌ Não tornamos o intervalo do cron configurável via UI. Exigiria cron.alter_job dinâmico; complexidade desproporcional ao ganho.

Alternativas consideradas

AbordagemLatênciaEsforçoPor que não
A. Trigger direto (escolhido)<2sBaixo
B. Cron a cada 10s (*/10 * * * * *)0–10sTrivialPressão constante no Postgres mesmo sem fila; não resolve quando fila cresce
C. Cloudflare Worker + Upstash Redis + Durable Object alarm<1sAltoReescrita de infra crítica para resolver problema que A já resolve
D. QStash push HTTP<1sMédioDependência externa adicional sem ganho vs. A

Conformidade

Idempotência (crítica): trigger direto e cron podem cair simultaneamente sobre o mesmo outbox_id. A proteção é o lock otimista dentro de process-email-outbox:

sql
UPDATE email_outbox SET status='sending'
WHERE id IN (...) AND status='pending';

Quem perder o UPDATE recebe 0 rows e segue. Não há duplo envio. Adicionalmente, o índice único parcial uniq_email_outbox_idem em (template_slug, referencia_tipo, referencia_id) WHERE status IN ('pending','sending','sent') impede duplicação no enqueue.

Auth do invoke direto: mesmo padrão do cron — header Authorization: Bearer ${CRON_SECRET}. Edge Function valida e retorna 401 caso contrário.

Falha silenciosa do invoke é aceitável. Cron é a rede. Logamos console.warn (sem PII, conforme mem://security/pii-log-masking-standard).

Cache do batch_size (30s): alteração de UI propaga em ≤30s. Documentado na própria UI.

Como verificar

sql
-- Latência p50 das últimas 24h (esperado: <2s após este ADR)
SELECT
  percentile_cont(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (enviado_em - criado_em))) AS p50,
  percentile_cont(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (enviado_em - criado_em))) AS p95
FROM email_outbox
WHERE enviado_em > now() - interval '24 hours' AND status = 'sent';

O que viola

  • Adicionar nova chamada de INSERT INTO email_outbox que não passa por enqueueEmail() sem disparar trigger direto correspondente.
  • Trocar EdgeRuntime.waitUntil por void promise — runtime pode abortar a tarefa pós-Response (ver mem://architecture/edge-function-background-task-standard).
  • Remover o cron de 1 min sem outro mecanismo de retry para envios failed/backoff.

Consequências

Positivas:

  • Latência p50 cai de ~16s para <2s; p95 de ~60s para <3s.
  • Sem nova dependência externa. Sem custo de infra adicional.
  • Caminho rápido (trigger) e caminho lento (cron) são desacoplados — falha de um não afeta o outro.

Negativas:

  • Cada enqueueEmail adiciona 1 fetch fire-and-forget. Custo desprezível (<5ms para iniciar).
  • Possível concorrência cron+trigger sobre o mesmo lote — resolvida pelo lock otimista, mas exige que toda mudança no processador preserve essa garantia.

Trade-offs

CritérioTrigger direto + cronRedis + Worker
Latência<2s<1s
Esforço1 função + 1 seedReescrita de infra
Risco operacionalBaixo (cron é fallback)Alto (split worker afeta auth)
Custo infraZero adicionalUpstash + complexidade CI

Débito técnico

Nenhum aberto neste ADR. Se volume crescer 10x e Resend virar gargalo, reabrir avaliação de QStash/Redis com benchmark.

Referências

  • mem://features/billing/email-infrastructure-resend
  • mem://architecture/edge-function-background-task-standard
  • docs/features/billing/email-billing.md § Latência de envio
  • ADR-006 (fire-and-forget logging) — padrão de void promise rejeitado é o mesmo motivo aqui