ADR-013 — Email Outbox: Trigger Direto + Cron Safety Net
| Campo | Valor |
|---|---|
| Status | ✅ Aceito |
| Data | 2026-05-10 |
| Autores | Equipe OLP |
| Implementação | supabase/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:
enqueueEmail()faz oINSERTnaemail_outboxe, em seguida, invocaprocess-email-outboxem background viaEdgeRuntime.waitUntil(fetch(...)). A Response da função chamadora não é bloqueada.- O cron de 1 min permanece intacto. Cobre: (a) retentativas de envios
failed/backoff, (b) inserts via SQL fora deenqueueEmail, (c) qualquer falha do invoke direto (rede, throttle). process-email-outboxlêemail_outbox_batch_sizedeconfiguracoes_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_jobdinâmico; complexidade desproporcional ao ganho.
Alternativas consideradas
| Abordagem | Latência | Esforço | Por que não |
|---|---|---|---|
| A. Trigger direto (escolhido) | <2s | Baixo | — |
B. Cron a cada 10s (*/10 * * * * *) | 0–10s | Trivial | Pressão constante no Postgres mesmo sem fila; não resolve quando fila cresce |
| C. Cloudflare Worker + Upstash Redis + Durable Object alarm | <1s | Alto | Reescrita de infra crítica para resolver problema que A já resolve |
| D. QStash push HTTP | <1s | Médio | Dependê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:
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
-- 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_outboxque não passa porenqueueEmail()sem disparar trigger direto correspondente. - Trocar
EdgeRuntime.waitUntilporvoid promise— runtime pode abortar a tarefa pós-Response (vermem://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
enqueueEmailadiciona 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ério | Trigger direto + cron | Redis + Worker |
|---|---|---|
| Latência | <2s | <1s |
| Esforço | 1 função + 1 seed | Reescrita de infra |
| Risco operacional | Baixo (cron é fallback) | Alto (split worker afeta auth) |
| Custo infra | Zero adicional | Upstash + 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-resendmem://architecture/edge-function-background-task-standarddocs/features/billing/email-billing.md§ Latência de envio- ADR-006 (fire-and-forget logging) — padrão de
void promiserejeitado é o mesmo motivo aqui