# TOI Shortcut — Arquitetura de Evolucao para Producao

> **Autor:** Oscar (Arquitetura de Sistemas)
> **Data:** 2026-04-03
> **Escopo:** Evolucao do MVP (SPEC.md) para sistema production-grade preparado para 10M redirects/dia
> **Premissa:** Este documento NAO repete o conteudo do SPEC.md. Apenas evolui, corrige e complementa.

---

## 1. GAP ANALYSIS

Analise critica do SPEC.md atual, item a item.

### Gaps Criticos (Severidade: CRITICAL)

| # | Gap | Problema | Impacto |
|---|-----|----------|---------|
| G01 | **Analytics via BackgroundTasks** | `background_tasks.add_task(record_click, ...)` roda na mesma event loop do Uvicorn. Se o INSERT no PG levar >50ms (sob carga), acumula coroutines, consome memoria e eventualmente degrada o redirect. Nao ha backpressure. | Redirect degrada sob carga. OOM possivel. |
| G02 | **Sem circuit breaker para Redis** | Se Redis ficar lento (nao down, mas com latencia de 200ms+), TODAS as requests pagam essa latencia antes do fallback PG. Nao ha timeout agressivo nem circuit breaker. | p99 do redirect explode. Cascading failure. |
| G03 | **click_count UPDATE atomico mas nao bufferizado** | Cada redirect gera um `UPDATE short_links SET click_count = click_count + 1`. Com 1000 redirects/s para o mesmo slug, sao 1000 UPDATEs/s na mesma row = contention de lock no PG. | Hot slug causa lock contention, afeta todo o PG. |
| G04 | **Sem particionamento de click_events** | Tabela unica `click_events` sem particao. Com 10M cliques/dia = 300M rows/mes. Queries de agregacao ficam inviáveis em <6 meses. VACUUM se torna problematico. | Queries lentas, VACUUM blocante, storage descontrolado. |
| G05 | **JWT sem revogacao** | Token JWT de 24h sem blacklist. Se um token vaza, nao ha como invalida-lo ate expirar. Logout e client-side (remove localStorage). | Token comprometido = acesso irrevogavel por 24h. |

### Gaps Altos (Severidade: HIGH)

| # | Gap | Problema | Impacto |
|---|-----|----------|---------|
| G06 | **Single instance, sem stateless design** | Nenhuma mencao a como escalar horizontalmente. Cache local, estado em memoria, sem shared-nothing. | Impossivel escalar sem rewrite. |
| G07 | **Sem observabilidade** | Nenhum log estruturado, metricas, tracing ou alertas definidos. "structlog" mencionado en passant na secao de audit. | Operacao cega. Incidentes demoram a ser detectados. |
| G08 | **Sem CI/CD pipeline** | Docker Compose para deploy. Nenhum pipeline de lint, test, build, deploy. Nenhuma estrategia de rollback. | Deploys manuais, propensos a erro, sem rollback. |
| G09 | **Sem health check profundo** | `/api/health` mencionado mas sem spec. Nao verifica Redis, PG, disk. | Load balancer nao detecta instancia degradada. |
| G10 | **Connection pooling nao especificado** | SQLAlchemy async engine sem configuracao de pool. Sem PgBouncer. Redis sem pool explicito. | Exaustao de conexoes sob carga. |
| G11 | **Sem cache stampede prevention** | Cache com TTL de 1h. Quando expira, N requests simultaneas vao todas ao PG para o mesmo slug. | Thundering herd no PG. |
| G12 | **Open redirect nao mitigado** | "Validacao de URL no cadastro; apenas admin autenticado cria links" nao e mitigacao real. Admin pode cadastrar URL de phishing. | Dominio toi.shinp.ai usado para phishing. Reputacao destruida. |
| G13 | **Slug auto-gerado com apenas 6 chars** | `secrets.token_urlsafe(4)` truncado para 6 chars. Espaco de 62^6 = ~56 bilhoes, mas com apenas 6 chars a probabilidade de colisao cresce com o volume. Mais critico: 6 chars e facilmente brute-forceable para enumerar links. | Enumeracao de links ativos por forca bruta. |

### Gaps Medios (Severidade: MEDIUM)

| # | Gap | Problema | Impacto |
|---|-----|----------|---------|
| G14 | **Sem data retention policy** | click_events cresce indefinidamente. Nenhuma politica de expurgo ou rollup. | Storage infinito, queries progressivamente mais lentas. |
| G15 | **Sem rate limiting distribuido robusto** | slowapi com backend Redis, mas sem especificar algoritmo (sliding window? token bucket?) nem como funciona com multiplas instancias. | Rate limiting inconsistente em multi-instance. |
| G16 | **GeoIP como best-effort sem spec** | "MaxMind GeoLite2" mencionado no roadmap V2, sem definir como integrar (database file? API?), atualizacao, fallback. | Feature incompleta, potencial ponto de falha. |
| G17 | **Sem backup strategy detalhada** | "Backup diario PG" sem especificar: pg_dump? WAL archiving? Retention? Recovery time objective? | Recovery lento ou impossivel. |
| G18 | **CORS apenas para proprio dominio** | Ok para MVP, mas impede integracao futura com API keys de terceiros. | Bloqueio de evolucao do produto. |
| G19 | **Sem graceful shutdown** | Nenhuma mencao a SIGTERM handling, drain de connections, finalizacao de background tasks em andamento. | Requests perdidas durante deploy, dados de analytics perdidos. |

### Gaps Baixos (Severidade: LOW)

| # | Gap | Problema | Impacto |
|---|-----|----------|---------|
| G20 | **Frontend vanilla sem minificacao** | JS/CSS servidos sem minificacao, concatenacao ou cache busting. | Tempo de carregamento do admin panel nao otimizado. |
| G21 | **Sem ETag/Last-Modified em static assets** | Assets sem versionamento. | Cache do browser nao e eficiente. |
| G22 | **Sem robots.txt/sitemap estrategico** | Slug `robots.txt` e reservado, mas nao ha o arquivo real sendo servido. | Crawlers nao sabem o que indexar. |

---

## 2. ARQUITETURA EVOLUIDA

### 2.1 Monolito vs. Microservicos

**Decisao: Manter monolito ate 50K req/s.** Razoes:

1. **Latencia**: Chamadas internas em-processo sao ~0ns vs. network hop de ~0.5ms entre servicos
2. **Complexidade operacional**: Um deploy, um container, um log stream
3. **Overhead cognitivo**: Equipe pequena nao precisa de service mesh, distributed tracing obrigatorio, etc.

**Ponto de split definido — quando dividir:**

| Sinal | Acao |
|-------|------|
| Redirect precisa de deploy independente do admin | Extrair redirect como servico stateless separado |
| Analytics consome >30% CPU do processo principal | Extrair analytics worker como servico dedicado |
| Equipe cresce para >5 devs trabalhando simultaneamente | Considerar domain-based split |
| Latencia de redirect impactada por admin queries | Separar read path (redirect) do write path (admin) |

### 2.2 Design Stateless para Escalonamento Horizontal

Principio: **qualquer instancia pode atender qualquer request**.

```
Requisitos para stateless:
1. Zero estado em memoria de processo (exceto cache local efemero com TTL curto)
2. Sessao via JWT (ja e stateless por natureza)
3. Rate limiting via Redis (compartilhado entre instancias)
4. Cache de slug via Redis (compartilhado) + local LRU (efemero, 60s TTL)
5. Background tasks substituidas por Redis Streams (consumidas por qualquer instancia)
6. Arquivos estaticos servidos por CDN ou nginx (nao pelo FastAPI)
```

### 2.3 De 1 Instancia para N Instancias

```
ETAPA 1 (MVP): 1 instancia
┌──────────────────┐
│  Nginx (proxy)   │
└────────┬─────────┘
         │
┌────────▼─────────┐     ┌─────────┐     ┌──────────┐
│  FastAPI (1)     ├─────┤  Redis  ├─────┤ Postgres │
└──────────────────┘     └─────────┘     └──────────┘

ETAPA 2 (Scale): N instancias
┌──────────────────────────────────────────────────┐
│                   CDN (Cloudflare)                │
│          (static assets + DDoS protection)       │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│              Load Balancer (Nginx/HAProxy)        │
│         (round-robin, health check /api/health)  │
└──┬──────────┬──────────┬──────────┬──────────────┘
   │          │          │          │
┌──▼──┐   ┌──▼──┐   ┌──▼──┐   ┌──▼──┐
│ App │   │ App │   │ App │   │ App │  ← Stateless, identicas
│  1  │   │  2  │   │  3  │   │  N  │
└──┬──┘   └──┬──┘   └──┬──┘   └──┬──┘
   │          │          │          │
   └──────────┴─────┬────┴──────────┘
                    │
        ┌───────────┼───────────┐
        │           │           │
   ┌────▼────┐ ┌───▼────┐ ┌───▼──────────┐
   │  Redis  │ │  PG    │ │ PG Read      │
   │ Primary │ │Primary │ │ Replicas     │
   │ +Sentinel│ │        │ │ (analytics)  │
   └─────────┘ └────────┘ └──────────────┘
```

### 2.4 Load Balancing Strategy

```nginx
# nginx.conf para producao
upstream toi_backend {
    least_conn;  # Melhor que round-robin para requests com latencia variavel

    server app1:8000 max_fails=3 fail_timeout=30s;
    server app2:8000 max_fails=3 fail_timeout=30s;
    server app3:8000 max_fails=3 fail_timeout=30s;

    keepalive 64;  # Pool de conexoes persistentes ao upstream
}

server {
    listen 443 ssl http2;
    server_name toi.shinp.ai;

    # Static files direto pelo nginx (nao passa pelo FastAPI)
    location /static/ {
        alias /var/www/toi/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Redirect endpoint — prioridade maxima
    location ~ ^/[a-zA-Z0-9_-]+$ {
        proxy_pass http://toi_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";  # Habilita keepalive com upstream
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # Timeout agressivo — redirect deve ser <50ms
        proxy_connect_timeout 2s;
        proxy_read_timeout 5s;
    }

    # API e admin
    location / {
        proxy_pass http://toi_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_read_timeout 30s;
    }
}
```

### 2.5 Diagrama de Fluxo de Dados Completo

```
                         VISITANTE
                            │
                            ▼
                    ┌───────────────┐
                    │  Cloudflare   │── DDoS filter, SSL termination
                    └───────┬───────┘
                            │
                    ┌───────▼───────┐
                    │    Nginx LB   │── Health check, static files
                    └───────┬───────┘
                            │
              ┌─────────────▼─────────────┐
              │      FastAPI Instance      │
              │                            │
              │  1. Local LRU check        │── HIT? → 302 (sub-1ms)
              │       │ MISS               │
              │  2. Redis GET              │── HIT? → populate LRU → 302 (~1ms)
              │       │ MISS               │
              │  3. PostgreSQL SELECT      │── HIT? → populate Redis+LRU → 302 (~3ms)
              │       │ MISS               │
              │  4. Return 404             │
              │                            │
              │  APOS 302:                 │
              │  5. LPUSH click_event      │── Redis Stream (fire-and-forget, <0.1ms)
              │     para Redis Stream      │
              └────────────────────────────┘
                            │
              ┌─────────────▼─────────────┐
              │    Analytics Worker(s)     │── Processo separado ou thread dedicada
              │                            │
              │  1. XREADGROUP do Stream   │
              │  2. Buffer em memoria      │
              │     (ate 500 events ou 5s) │
              │  3. Batch INSERT events    │
              │  4. Batch UPDATE counters  │
              │     (INCR agrupado por     │
              │      slug, 1 UPDATE/slug)  │
              │  5. XACK                   │
              └────────────────────────────┘

              ┌─────────────────────────────┐
              │      ADMIN (Dashboard)      │
              │                            │
              │  Queries de leitura vao    │
              │  para PG Read Replica      │
              │  (quando disponivel)       │
              │                            │
              │  Materialized Views para   │
              │  agregacoes pesadas        │
              └─────────────────────────────┘
```

---

## 3. REDIRECT ENGINE (PRODUCTION LEVEL)

### 3.1 Fluxo Otimizado para sub-5ms p99

O redirect e o hot path. Cada microsegundo importa.

```python
# app/services/redirect_service.py (evolucao)
import time
from cachetools import TTLCache
from app.core.circuit_breaker import CircuitBreaker

# Cache local: 1000 slugs mais acessados, TTL 60s
# POR QUE 60s: curto o suficiente para invalidacao convergir,
# longo o suficiente para absorver picos
_local_cache = TTLCache(maxsize=1000, ttl=60)

# Circuit breaker para Redis
_redis_cb = CircuitBreaker(
    failure_threshold=5,      # 5 falhas consecutivas
    recovery_timeout=30,      # tenta reconectar apos 30s
    expected_exception=Exception
)

async def resolve_slug(slug: str, redis, db_pool) -> ResolveResult | None:
    # CAMADA 1: Local LRU (~0.001ms)
    cached = _local_cache.get(slug)
    if cached is not None:
        return cached

    # CAMADA 2: Redis (~0.3-1ms)
    if _redis_cb.is_closed:
        try:
            raw = await redis.get(f"s:{slug}")  # Key curta = menos bytes
            if raw:
                result = ResolveResult.from_msgpack(raw)  # msgpack, nao JSON
                _local_cache[slug] = result
                return result
        except Exception as e:
            _redis_cb.record_failure(e)

    # CAMADA 3: PostgreSQL (~2-5ms)
    # Query otimizada: SELECT APENAS os campos necessarios
    row = await db_pool.fetchrow(
        "SELECT destination_url, is_active FROM short_links WHERE slug = $1",
        slug
    )
    if row is None:
        # Cache negativo: evita queries repetidas para slugs inexistentes
        _local_cache[slug] = _NEGATIVE_SENTINEL
        return None

    result = ResolveResult(url=row["destination_url"], active=row["is_active"])

    # Populate caches (fire-and-forget, nao bloqueia response)
    _local_cache[slug] = result
    if _redis_cb.is_closed:
        # Nao usa await — fire and forget
        asyncio.create_task(_safe_redis_set(redis, slug, result))

    return result
```

### 3.2 Cache Multi-Camada — Configuracao

```
┌─────────────────────────────────────────────────────────────┐
│ CAMADA 1: Process-Local LRU                                 │
│ Implementacao: cachetools.TTLCache                          │
│ Capacidade: 1000 entries (estimativa: ~200KB de memoria)    │
│ TTL: 60 segundos                                            │
│ Latencia: ~0.001ms (acesso a dicionario Python)             │
│ Invalidacao: TTL-based (consistencia eventual, max 60s)     │
│ POR QUE: Elimina round-trip ao Redis para os top slugs.     │
│ Em um sistema com distribuicao Zipf (80/20), os top 1000    │
│ slugs cobrem ~80% dos redirects.                            │
└──────────────────────────┬──────────────────────────────────┘
                           │ MISS
┌──────────────────────────▼──────────────────────────────────┐
│ CAMADA 2: Redis                                             │
│ Formato de valor: msgpack (30-50% menor que JSON)           │
│ Key pattern: s:{slug} (key curta para economizar memoria)   │
│ TTL: 3600 segundos (1 hora)                                 │
│ Latencia: ~0.3-1ms (rede local)                             │
│ Invalidacao: Explicita (write-through) + TTL como safety    │
│ POR QUE: Compartilhado entre instancias. Source of truth    │
│ do cache. Suporta invalidacao via pub/sub.                  │
└──────────────────────────┬──────────────────────────────────┘
                           │ MISS
┌──────────────────────────▼──────────────────────────────────┐
│ CAMADA 3: PostgreSQL                                        │
│ Query: SELECT destination_url, is_active                    │
│        FROM short_links WHERE slug = $1                     │
│ Indice: idx_short_links_slug (btree, unique, lower)         │
│ Latencia: ~2-5ms                                            │
│ POR QUE: Source of truth absoluta. Fallback quando Redis    │
│ esta down ou cache expirou.                                 │
└─────────────────────────────────────────────────────────────┘
```

### 3.3 Cache Warming em Cold Start

Quando uma instancia nova inicia (deploy, scale-up, restart), o cache local esta vazio. Sem warming, as primeiras 1000 requests vao todas ao Redis/PG.

```python
# app/core/cache_warmer.py
async def warm_cache_on_startup(redis, db_pool, local_cache):
    """
    Executa no evento startup do FastAPI.
    Carrega os top 1000 slugs mais acessados no cache local.
    POR QUE: Evita thundering herd no Redis apos restart.
    """
    # Estrategia 1: Buscar do Redis (rapido, ~50ms para 1000 keys)
    # Os slugs mais acessados provavelmente ja estao no Redis

    # Buscar top slugs do PG (ordenado por click_count)
    rows = await db_pool.fetch(
        """SELECT slug, destination_url, is_active
           FROM short_links
           WHERE is_active = true
           ORDER BY click_count DESC
           LIMIT 1000"""
    )

    pipe = redis.pipeline()
    for row in rows:
        slug = row["slug"]
        result = ResolveResult(url=row["destination_url"], active=row["is_active"])
        local_cache[slug] = result
        # Tambem garante que Redis tem esses slugs
        pipe.set(f"s:{slug}", result.to_msgpack(), ex=3600)

    await pipe.execute()
    logger.info("cache_warmed", count=len(rows))
```

### 3.4 Lua Scripts no Redis para Operacoes Atomicas

```lua
-- scripts/resolve_and_count.lua
-- Resolve slug E incrementa contador atomicamente no Redis
-- POR QUE: Uma unica roundtrip ao Redis em vez de GET + INCR separados
-- Reduz latencia de ~0.6ms (2 ops) para ~0.3ms (1 op)

local slug_key = KEYS[1]          -- s:{slug}
local counter_key = KEYS[2]       -- c:{slug}

local data = redis.call('GET', slug_key)
if data then
    redis.call('INCR', counter_key)
    return data
end
return nil
```

```lua
-- scripts/cache_set_with_lock.lua
-- SET com lock para prevenir stampede
-- POR QUE: Apenas uma request popula o cache, as outras esperam
-- Previne N queries identicas ao PG quando cache expira

local slug_key = KEYS[1]
local lock_key = KEYS[2]          -- lock:s:{slug}
local value = ARGV[1]
local ttl = tonumber(ARGV[2])

-- Tenta adquirir lock (NX = apenas se nao existe, EX = com expiracao)
local acquired = redis.call('SET', lock_key, '1', 'NX', 'EX', 5)
if acquired then
    redis.call('SET', slug_key, value, 'EX', ttl)
    redis.call('DEL', lock_key)
    return 1
end
return 0
```

### 3.5 Connection Pooling

```python
# app/config.py — configuracoes de pool

class Settings(BaseSettings):
    # PostgreSQL
    pg_pool_min_size: int = 5        # Conexoes minimas mantidas abertas
    pg_pool_max_size: int = 20       # Maximo de conexoes simultaneas
    pg_command_timeout: float = 5.0  # Timeout por query (segundos)
    pg_statement_cache_size: int = 100  # Prepared statements em cache

    # Redis
    redis_pool_max_connections: int = 50    # Pool de conexoes Redis
    redis_socket_timeout: float = 1.0      # Timeout de socket
    redis_socket_connect_timeout: float = 0.5  # Timeout de conexao
    redis_retry_on_timeout: bool = True
    redis_health_check_interval: int = 30  # Segundos entre health checks

# app/database.py — pool otimizado
import asyncpg

async def create_pg_pool(settings: Settings) -> asyncpg.Pool:
    """
    POR QUE asyncpg direto em vez de SQLAlchemy para redirect:
    SQLAlchemy adiciona ~0.5-1ms de overhead (ORM mapping, session management).
    Para o hot path do redirect, usamos asyncpg direto.
    SQLAlchemy continua sendo usado para CRUD admin (onde o overhead e aceitavel).
    """
    return await asyncpg.create_pool(
        dsn=settings.database_url,
        min_size=settings.pg_pool_min_size,
        max_size=settings.pg_pool_max_size,
        command_timeout=settings.pg_command_timeout,
        statement_cache_size=settings.pg_statement_cache_size,
    )

# app/redis.py — pool Redis
from redis.asyncio import ConnectionPool, Redis

def create_redis_pool(settings: Settings) -> Redis:
    pool = ConnectionPool.from_url(
        settings.redis_url,
        max_connections=settings.redis_pool_max_connections,
        socket_timeout=settings.redis_socket_timeout,
        socket_connect_timeout=settings.redis_socket_connect_timeout,
        retry_on_timeout=settings.redis_retry_on_timeout,
        health_check_interval=settings.redis_health_check_interval,
        decode_responses=False,  # msgpack = bytes
    )
    return Redis(connection_pool=pool)
```

### 3.6 Circuit Breaker Pattern

```python
# app/core/circuit_breaker.py
import time
from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"        # Normal — requests passam
    OPEN = "open"            # Falha — requests bloqueadas, vai direto ao fallback
    HALF_OPEN = "half_open"  # Testando — permite 1 request de teste

class CircuitBreaker:
    """
    POR QUE: Redis lento e PIOR que Redis down. Quando Redis esta down,
    o timeout e rapido (~0.5s). Quando esta lento (rede saturada, swap),
    cada request espera o timeout cheio. Circuit breaker curto-circuita
    apos N falhas e vai direto ao PG, preservando latencia.
    """

    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout: float = 30.0,
        half_open_max_calls: int = 3,
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.half_open_max_calls = half_open_max_calls
        self._state = CircuitState.CLOSED
        self._failure_count = 0
        self._last_failure_time = 0.0
        self._half_open_calls = 0

    @property
    def is_closed(self) -> bool:
        if self._state == CircuitState.CLOSED:
            return True
        if self._state == CircuitState.OPEN:
            if time.monotonic() - self._last_failure_time > self.recovery_timeout:
                self._state = CircuitState.HALF_OPEN
                self._half_open_calls = 0
                return True
            return False
        # HALF_OPEN
        return self._half_open_calls < self.half_open_max_calls

    def record_success(self):
        if self._state == CircuitState.HALF_OPEN:
            self._half_open_calls += 1
            if self._half_open_calls >= self.half_open_max_calls:
                self._state = CircuitState.CLOSED
                self._failure_count = 0
        elif self._state == CircuitState.CLOSED:
            self._failure_count = 0

    def record_failure(self, exc: Exception):
        self._failure_count += 1
        self._last_failure_time = time.monotonic()
        if self._failure_count >= self.failure_threshold:
            self._state = CircuitState.OPEN
```

### 3.7 Graceful Degradation — Redis Down

```
Cenario: Redis completamente indisponivel.

Fluxo de redirect:
1. Local LRU → HIT? Serve normalmente (60s de sobrevivencia sem Redis)
2. Local LRU → MISS? Circuit breaker OPEN → skip Redis → vai direto ao PG
3. PG responde → popula local LRU (NAO tenta popular Redis)
4. Analytics event → Redis Stream indisponivel → fallback para buffer local em memoria
   → flush para PG diretamente em batch quando PG estiver acessivel

Impactos aceitos:
- Latencia de redirect: ~3-5ms em vez de ~1ms (PG direct)
- Cache invalidation: nao funciona via pub/sub (aceito: TTL de 60s no local LRU)
- Analytics: pode perder eventos se o buffer local encher (configurar max 10K eventos)
- Rate limiting: nao funciona via Redis → fallback para limitacao in-process (menos preciso)

Alerta: metric redis_circuit_breaker_state == OPEN → PagerDuty
```

### 3.8 Graceful Degradation — PostgreSQL Down

```
Cenario: PostgreSQL completamente indisponivel.

Fluxo de redirect:
1. Local LRU → HIT? Serve normalmente
2. Redis → HIT? Serve normalmente
3. Ambos MISS? → HTTP 503 com Retry-After: 30
   → NAO retornar 404 (o link pode existir, nos que nao sabemos)

Fluxo de admin:
- Todas as operacoes de escrita → HTTP 503
- Dashboard → servir dados do Redis se disponiveis (cache das ultimas agregacoes)

Analytics:
- Eventos bufferizados em Redis Stream (sobrevive a PG down)
- Quando PG retorna, worker consome o backlog

Alerta: metric pg_health_check_failed → PagerDuty (CRITICAL)
```

### 3.9 Hot Slug Handling

Um link viral pode receber milhoes de hits em minutos. Estrategia:

```python
# Deteccao de hot slug via Redis sorted set
# POR QUE: Precisamos saber QUAIS slugs estao quentes para tratamento especial

# A cada redirect, incrementar no sorted set (via Lua script atomico)
# ZINCRBY hot:slugs 1 {slug}
# O sorted set e resetado a cada 60 segundos por um worker

# Se um slug tem >1000 hits/minuto, ele e promovido:
# 1. TTL do cache local estendido para 300s (em vez de 60s)
# 2. TTL do Redis estendido para 7200s (em vez de 3600s)
# 3. click_count atualizado via INCR no Redis, flush para PG a cada 60s
#    (em vez de UPDATE no PG a cada click)

HOT_SLUG_THRESHOLD = 1000  # hits por minuto
HOT_SLUG_LOCAL_TTL = 300   # segundos
HOT_SLUG_REDIS_TTL = 7200  # segundos
```

### 3.10 HTTP Response Optimization

```python
# Redirect response otimizada

# Headers MINIMOS para redirect (cada byte conta)
REDIRECT_HEADERS = {
    "Location": destination_url,       # Obrigatorio para 302
    "Cache-Control": "private, no-store",  # Mais curto que "no-cache, no-store, must-revalidate"
    "Content-Length": "0",             # Indica body vazio
}
# NAO enviar: Server, X-Powered-By, Date (se possivel via nginx)
# POR QUE: Menos headers = menos bytes = menos tempo de serialização

# Usar HTTP 302 (nao 301) — ja justificado no SPEC
# Usar HTTP/2 via nginx — multiplexing, header compression (HPACK)
# Habilitar TCP keepalive no nginx:
#   keepalive_timeout 65;
#   keepalive_requests 1000;

# Uvicorn config para producao
# POR QUE --no-access-log: cada log de redirect e I/O. Em 10K req/s, sao 10K writes/s.
# Usamos structlog com sampling em vez disso.
CMD = [
    "uvicorn", "app.main:app",
    "--host", "0.0.0.0",
    "--port", "8000",
    "--workers", "4",           # 1 worker por CPU core
    "--loop", "uvloop",         # uvloop: 2-4x mais rapido que asyncio
    "--http", "httptools",      # httptools: parser HTTP em C
    "--no-access-log",          # Logs via structlog, nao access log
    "--timeout-keep-alive", "65",
]
```

---

## 4. ANALYTICS PIPELINE (PRODUCTION LEVEL)

### 4.1 Problema com BackgroundTasks

O SPEC atual usa `background_tasks.add_task(record_click, ...)`. Problemas:

1. **Sem backpressure**: Se o PG estiver lento, tarefas acumulam na memoria do processo
2. **Perda em restart**: Tarefas em andamento sao perdidas no SIGTERM
3. **Sem retry**: Se o INSERT falhar, o evento e perdido
4. **Compartilha event loop**: CPU gasta em analytics rouba ciclos do redirect

### 4.2 Pipeline via Redis Streams

```
REDIRECT PATH (ultra-rapido):              ANALYTICS PATH (async):

  Request                                  ┌─────────────────────────┐
    │                                      │   Analytics Worker(s)   │
    ▼                                      │   (processo separado)   │
  Resolve slug                             │                         │
    │                                      │   XREADGROUP            │
    ▼                                      │     ↓                   │
  302 Response ──────┐                     │   Buffer (500 ou 5s)    │
                     │                     │     ↓                   │
  XADD (fire&forget) │                     │   Batch INSERT events   │
    │                │                     │     ↓                   │
    ▼                │                     │   Batch UPDATE counters │
  Redis Stream       │                     │     ↓                   │
  "clicks"           │                     │   XACK                  │
                     │                     └─────────────────────────┘
                     │
                     └──→ Latencia adicionada ao redirect: <0.1ms
                          (XADD e O(1), ~0.05ms)
```

```python
# Publicacao do evento (no redirect handler)
async def publish_click_event(redis, slug: str, request: Request):
    """
    Fire-and-forget. NAO usa await no caller.
    POR QUE Redis Streams em vez de lista:
    - Consumer groups: multiplos workers podem consumir sem duplicar
    - Acknowledgment: eventos nao sao perdidos se worker morrer
    - Backpressure: MAXLEN limita o tamanho do stream
    """
    event = {
        "slug": slug,
        "ts": str(time.time()),
        "ref": (request.headers.get("referer") or "")[:500],
        "ua": (request.headers.get("user-agent") or "")[:256],
        "ip": anonymize_ip(get_client_ip(request)),
    }
    # MAXLEN ~100000: se o worker parar, mantemos ate 100K eventos
    # Apos isso, os mais antigos sao descartados (protecao de memoria)
    await redis.xadd("stream:clicks", event, maxlen=100000, approximate=True)
```

```python
# Worker de consumo (processo separado)
# app/workers/analytics_worker.py

import asyncio
import asyncpg
from redis.asyncio import Redis

BATCH_SIZE = 500
FLUSH_INTERVAL = 5  # segundos
CONSUMER_GROUP = "analytics-workers"
CONSUMER_NAME = f"worker-{os.getpid()}"

async def run_analytics_worker():
    redis = create_redis_pool(settings)
    pg_pool = await asyncpg.create_pool(settings.database_url, min_size=2, max_size=5)

    # Criar consumer group (idempotente)
    try:
        await redis.xgroup_create("stream:clicks", CONSUMER_GROUP, id="0", mkstream=True)
    except Exception:
        pass  # Grupo ja existe

    buffer = []
    last_flush = time.monotonic()

    while True:
        # Ler ate BATCH_SIZE eventos, bloquear por ate FLUSH_INTERVAL ms
        entries = await redis.xreadgroup(
            groupname=CONSUMER_GROUP,
            consumername=CONSUMER_NAME,
            streams={"stream:clicks": ">"},
            count=BATCH_SIZE,
            block=FLUSH_INTERVAL * 1000,
        )

        if entries:
            for stream_name, messages in entries:
                for msg_id, data in messages:
                    buffer.append((msg_id, data))

        # Flush se buffer cheio OU timeout atingido
        if len(buffer) >= BATCH_SIZE or (
            buffer and time.monotonic() - last_flush >= FLUSH_INTERVAL
        ):
            await flush_buffer(pg_pool, redis, buffer)
            buffer = []
            last_flush = time.monotonic()

async def flush_buffer(pg_pool, redis, buffer):
    """
    Batch insert de eventos + batch update de contadores.
    POR QUE batch: 500 INSERTs individuais ~500ms. 1 COPY ~5ms.
    """
    if not buffer:
        return

    msg_ids = []
    events = []
    counter_map = {}  # slug -> count

    for msg_id, data in buffer:
        msg_ids.append(msg_id)
        slug = data[b"slug"].decode()
        events.append((
            slug,
            float(data[b"ts"]),
            data.get(b"ref", b"").decode(),
            data.get(b"ua", b"").decode(),
            data.get(b"ip", b"").decode(),
        ))
        counter_map[slug] = counter_map.get(slug, 0) + 1

    async with pg_pool.acquire() as conn:
        async with conn.transaction():
            # Batch INSERT via COPY (ordens de magnitude mais rapido)
            await conn.copy_records_to_table(
                "click_events",
                records=[(slug, ts, ref, ua, ip) for slug, ts, ref, ua, ip in events],
                columns=["slug", "clicked_at", "referer", "user_agent", "ip_hash"],
            )

            # Batch UPDATE contadores (1 UPDATE por slug, nao por click)
            # POR QUE: Se "cogna" teve 300 clicks no batch, 1 UPDATE em vez de 300
            for slug, count in counter_map.items():
                await conn.execute(
                    "UPDATE short_links SET click_count = click_count + $1 WHERE slug = $2",
                    count, slug,
                )

    # ACK todos os eventos processados
    pipe = redis.pipeline()
    for msg_id in msg_ids:
        pipe.xack("stream:clicks", CONSUMER_GROUP, msg_id)
    await pipe.execute()
```

### 4.3 Separacao Write Path vs. Read Path

```
WRITE PATH (rapido, append-only):
  click_events (tabela particionada)
    → INSERT-only, nunca UPDATE ou DELETE em operacao normal
    → Particionamento por mes (ver secao 5)
    → Indices minimos (apenas o necessario para JOIN com short_links)

READ PATH (agregado, materializado):
  daily_stats (materialized view ou tabela)
    → Pre-computado por cronjob a cada 15 minutos
    → Dashboard le APENAS desta tabela
    → Queries simples, sem GROUP BY pesado em runtime

  monthly_stats (rollup mensal)
    → Computado uma vez por dia
    → Usado para views de longo prazo
```

### 4.4 Schema de Rollup Tables

```sql
-- Tabela de agregacao diaria
-- POR QUE tabela e nao materialized view: permite INSERT incremental
-- sem REFRESH CONCURRENTLY (que faz full scan)
CREATE TABLE daily_stats (
    id              BIGSERIAL PRIMARY KEY,
    short_link_id   UUID NOT NULL REFERENCES short_links(id) ON DELETE CASCADE,
    stat_date       DATE NOT NULL,
    click_count     INTEGER NOT NULL DEFAULT 0,
    unique_visitors INTEGER NOT NULL DEFAULT 0,  -- baseado em ip_hash distinct
    top_referer     VARCHAR(2048),
    top_country     VARCHAR(2),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),

    CONSTRAINT uq_daily_stats UNIQUE (short_link_id, stat_date)
);

-- Indice para queries do dashboard
-- POR QUE: Dashboard pede "cliques por dia dos ultimos 30 dias" frequentemente
CREATE INDEX idx_daily_stats_date ON daily_stats (stat_date DESC);
CREATE INDEX idx_daily_stats_link_date ON daily_stats (short_link_id, stat_date DESC);

-- Tabela de agregacao mensal
CREATE TABLE monthly_stats (
    id              BIGSERIAL PRIMARY KEY,
    short_link_id   UUID NOT NULL REFERENCES short_links(id) ON DELETE CASCADE,
    stat_month      DATE NOT NULL,  -- Primeiro dia do mes (ex: 2026-04-01)
    click_count     INTEGER NOT NULL DEFAULT 0,
    unique_visitors INTEGER NOT NULL DEFAULT 0,

    CONSTRAINT uq_monthly_stats UNIQUE (short_link_id, stat_month)
);

CREATE INDEX idx_monthly_stats_link ON monthly_stats (short_link_id, stat_month DESC);
```

```python
# Cronjob de agregacao (roda a cada 15 minutos)
# app/workers/aggregation_worker.py

AGGREGATE_DAILY_SQL = """
    INSERT INTO daily_stats (short_link_id, stat_date, click_count, unique_visitors, top_referer, top_country)
    SELECT
        sl.id,
        DATE(ce.clicked_at),
        COUNT(*),
        COUNT(DISTINCT ce.ip_hash),
        MODE() WITHIN GROUP (ORDER BY ce.referer),
        MODE() WITHIN GROUP (ORDER BY ce.country)
    FROM click_events ce
    JOIN short_links sl ON sl.slug = ce.slug
    WHERE ce.clicked_at >= $1 AND ce.clicked_at < $2
    GROUP BY sl.id, DATE(ce.clicked_at)
    ON CONFLICT (short_link_id, stat_date) DO UPDATE SET
        click_count = EXCLUDED.click_count,
        unique_visitors = EXCLUDED.unique_visitors,
        top_referer = EXCLUDED.top_referer,
        top_country = EXCLUDED.top_country,
        updated_at = now();
"""
```

### 4.5 Data Retention Policy

```
POLITICA DE RETENCAO (configuravel via env):

Tier 1 — Raw events (click_events):
  Retencao: 90 dias
  POR QUE: Dados granulares consomem ~150 bytes/evento.
           10M/dia × 150B × 90 dias = ~135GB
  Acao: DROP PARTITION apos 90 dias (instantaneo, sem VACUUM)

Tier 2 — Daily aggregates (daily_stats):
  Retencao: 2 anos
  POR QUE: ~50 bytes/registro. 10K links × 365 dias × 50B = ~180MB/ano
  Acao: DELETE WHERE stat_date < now() - interval '2 years' (mensal)

Tier 3 — Monthly aggregates (monthly_stats):
  Retencao: Indefinida
  POR QUE: ~30 bytes/registro. 10K links × 12 meses × 30B = ~3.6MB/ano
  Acao: Nunca expurgar

Implementacao: pg_cron ou cronjob externo rodando mensalmente.
```

### 4.6 Estimativa de Storage

```
Cenario: 10M redirects/dia

click_events:
  ~150 bytes/row (UUID + timestamp + varchar fields + indices)
  10M × 150B = 1.5GB/dia
  90 dias retention = ~135GB
  Com particao mensal: max 3 particoes ativas (~45GB cada)

daily_stats:
  ~100 bytes/row
  Assumindo 10K links ativos: 10K × 365 = 3.65M rows/ano = ~365MB/ano

monthly_stats:
  ~60 bytes/row
  10K × 12 = 120K rows/ano = ~7.2MB/ano

Redis:
  s:{slug} → ~200 bytes/entry (key + msgpack value)
  10K slugs × 200B = ~2MB (negligivel)
  Stream buffer: ~100K mensagens × 200B = ~20MB

Total em regime (90 dias):
  PG: ~140GB (dominado por click_events)
  Redis: ~25MB

Mitigacao se crescer demais:
  1. Reduzir retention de click_events para 30 dias
  2. Comprimir particoes antigas (pg_compress ou tablespace em ZFS)
  3. Migrar click_events para TimescaleDB (ver secao 5)
```

---

## 5. DATABASE EVOLUTION

### 5.1 Schema Refinado

Mudancas em relacao ao SPEC.md original:

```sql
-- MUDANCA 1: click_events particionada por mes
-- POR QUE: DROP PARTITION e instantaneo vs DELETE de milhoes de rows
-- POR QUE range por mes: granularidade ideal para retention de 90 dias
CREATE TABLE click_events (
    id              BIGSERIAL,      -- MUDANCA: BIGSERIAL em vez de UUID
                                    -- POR QUE: 8 bytes vs 16 bytes,
                                    -- indices menores, INSERTs mais rapidos
                                    -- UUID nao e necessario aqui (nao e referenciado externamente)
    short_link_id   UUID NOT NULL,  -- SEM FK em tabela particionada
                                    -- POR QUE: PG nao suporta FK em partitioned tables
                                    -- Integridade garantida pelo application layer
    clicked_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    referer         VARCHAR(500),   -- MUDANCA: 500 em vez de 2048
                                    -- POR QUE: Referers >500 chars sao raros e nao uteis
    user_agent      VARCHAR(256),   -- MUDANCA: 256 em vez de 512
    ip_hash         VARCHAR(64) NOT NULL,
    country         CHAR(2),        -- MUDANCA: CHAR(2) em vez de VARCHAR(2)
                                    -- POR QUE: fixo, sem overhead de length prefix
    PRIMARY KEY (id, clicked_at)    -- POR QUE: PK deve incluir partition key
) PARTITION BY RANGE (clicked_at);

-- Criar particoes automaticamente (via pg_partman ou script)
CREATE TABLE click_events_2026_04 PARTITION OF click_events
    FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE click_events_2026_05 PARTITION OF click_events
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
-- ... criadas automaticamente por cronjob 1 mes adiantado

-- MUDANCA 2: Indice em click_events simplificado
-- POR QUE: BRIN em vez de btree para clicked_at em tabela append-only
-- BRIN e ~100x menor que btree para dados ordenados cronologicamente
CREATE INDEX idx_click_events_clicked_at ON click_events
    USING BRIN (clicked_at) WITH (pages_per_range = 32);

-- Indice para queries por link + data (btree necessario para GROUP BY)
CREATE INDEX idx_click_events_link_date ON click_events (short_link_id, clicked_at DESC);

-- MUDANCA 3: short_links — adicionar campos para evolucao
ALTER TABLE short_links ADD COLUMN expires_at TIMESTAMPTZ;      -- Link com expiracao
ALTER TABLE short_links ADD COLUMN tenant_id UUID;               -- Multi-tenant futuro
ALTER TABLE short_links ADD COLUMN metadata JSONB DEFAULT '{}';  -- Extensivel sem migration

-- Indice parcial para links ativos com expiracao
-- POR QUE: Query de redirect precisa checar expiracao apenas em links que TEM expiracao
CREATE INDEX idx_short_links_expires ON short_links (expires_at)
    WHERE expires_at IS NOT NULL AND is_active = true;
```

### 5.2 Estrategia de Indices — Justificativas

| Indice | Tipo | Justificativa Detalhada |
|--------|------|------------------------|
| `idx_short_links_slug` | UNIQUE btree, LOWER(slug) | **Hot path do redirect.** Lookup por slug e a query mais executada do sistema. UNIQUE garante O(log n). LOWER() permite case-insensitive sem full scan. |
| `idx_short_links_created_at` | btree DESC | **Listagem admin.** ORDER BY created_at DESC e o sort padrao. DESC evita backward scan. |
| `idx_short_links_click_count` | btree DESC | **Top links.** Dashboard pede "top 10 por clicks". Sem indice, seria full scan + sort. |
| `idx_short_links_is_active` | partial btree WHERE is_active = true | **Filtro admin.** ~95% dos links sao ativos, mas o indice parcial cobre apenas esses, sendo menor. |
| `idx_click_events_clicked_at` | BRIN | **Retention/particao.** BRIN ocupa ~100KB vs ~100MB de btree para 10M rows. Ideal para dados append-only ordenados. |
| `idx_click_events_link_date` | btree composto | **Analytics por link.** Query "cliques do link X nos ultimos 30 dias" precisa de (link_id, date) composto para index-only scan. |
| `idx_daily_stats_date` | btree DESC | **Dashboard.** "Ultimos 30 dias" e o query principal. |

### 5.3 Read Replicas

```
QUANDO adicionar:
- Quando queries de analytics (dashboard, aggregation worker) consumirem >30%
  de CPU/IO do primary
- Estimativa: isso acontece em ~5M redirects/dia com o schema atual

COMO configurar:
- PG streaming replication (built-in, sincrono)
- 1 replica dedicada para analytics reads
- Application-level routing (nao proxy):

# app/database.py
class DatabaseManager:
    def __init__(self):
        self.write_pool = None   # Primary — redirects + writes
        self.read_pool = None    # Replica — analytics queries

    async def get_read_pool(self):
        """Retorna replica se disponivel, senao primary."""
        if self.read_pool and await self._check_pool_health(self.read_pool):
            return self.read_pool
        return self.write_pool  # Fallback para primary
```

### 5.4 PgBouncer

```ini
; pgbouncer.ini
; POR QUE PgBouncer: cada conexao PG consome ~10MB de RAM.
; 4 instancias FastAPI × 20 conexoes = 80 conexoes diretas.
; PgBouncer multipleca 80 conexoes aparentes em 20 reais.

[databases]
toi = host=postgres port=5432 dbname=toi

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5

; Pool mode: transaction (melhor para queries curtas como redirect)
; POR QUE nao session: session prende a conexao. Transaction libera entre queries.
pool_mode = transaction

; Conexoes
default_pool_size = 20       ; Conexoes reais ao PG por database
min_pool_size = 5            ; Manter abertas mesmo sem uso
reserve_pool_size = 5        ; Extra para picos
reserve_pool_timeout = 3     ; Segundos antes de usar reserva

; Timeouts
server_idle_timeout = 300    ; Fechar conexao idle apos 5min
client_idle_timeout = 60     ; Fechar client idle apos 1min
query_timeout = 10           ; Kill query apos 10s (safety net)

; Stats
stats_period = 60            ; Emitir stats a cada 60s (para Prometheus)
```

### 5.5 VACUUM e Manutencao

```
PROBLEMA: click_events com alto volume de INSERT gera bloat.
Com particionamento, o problema e mitigado (DROP PARTITION = sem bloat).

Configuracao de autovacuum para short_links:
  POR QUE personalizado: click_count e atualizado frequentemente,
  mas a tabela e pequena (<100K rows). Autovacuum padrao pode ser lento demais.

ALTER TABLE short_links SET (
    autovacuum_vacuum_threshold = 100,         -- Roda apos 100 dead tuples (padrao: 50)
    autovacuum_vacuum_scale_factor = 0.05,     -- Ou 5% da tabela (padrao: 20%)
    autovacuum_analyze_threshold = 50,
    autovacuum_analyze_scale_factor = 0.02
);

Para click_events (particionada):
  Cada particao e relativamente pequena (~30 dias de dados).
  Autovacuum padrao e suficiente.
  Particoes antigas sao dropadas, nao precisam de vacuum.

Manutencao semanal (via pg_cron):
  -- REINDEX CONCURRENTLY (nao bloqueia reads)
  REINDEX INDEX CONCURRENTLY idx_short_links_slug;

  -- Atualizar estatisticas (planner depende disso)
  ANALYZE short_links;
  ANALYZE daily_stats;
```

### 5.6 Quando Considerar TimescaleDB

```
CRITERIOS para migracao:
1. click_events > 500GB (com particao nativa)
2. Queries temporais complexas (moving averages, time buckets) se tornam frequentes
3. Necessidade de compressao nativa (TimescaleDB comprime ~90% para dados temporais)
4. Precisa de continuous aggregates (materialized views automaticas e incrementais)

COMO migrar:
1. TimescaleDB e extensao PG, nao e banco separado
2. CREATE EXTENSION timescaledb;
3. SELECT create_hypertable('click_events', 'clicked_at', migrate_data => true);
4. Indices e queries existentes continuam funcionando
5. Adicionar compression policy:
   SELECT add_compression_policy('click_events', INTERVAL '7 days');
6. Adicionar retention:
   SELECT add_retention_policy('click_events', INTERVAL '90 days');

ESTIMATIVA: Nao necessario antes de 50M redirects/dia ou 1TB de dados.
```

### 5.7 Migration Strategy (Alembic)

```python
# alembic/env.py — boas praticas

# 1. SEMPRE usar revision com mensagem descritiva
# alembic revision --autogenerate -m "add_click_events_partitioning"

# 2. NUNCA usar autogenerate em producao sem revisar
# Autogenerate nao detecta: mudancas de indice parcial, particoes, triggers

# 3. Migrations devem ser IDEMPOTENTES quando possivel
# Usar IF NOT EXISTS, IF EXISTS

# 4. Separar DDL de DML
# Uma migration para ALTER TABLE, outra para backfill de dados
# POR QUE: DDL em PG adquire ACCESS EXCLUSIVE lock (bloqueia reads)
# DML (UPDATE) adquire ROW EXCLUSIVE (nao bloqueia reads)

# 5. Para tabelas grandes, usar batched migrations:
def upgrade():
    # NAO: UPDATE click_events SET new_column = compute(old_column);
    # SIM: Batch de 10K rows por vez com COMMIT entre batches
    op.execute("""
        DO $$
        DECLARE
            batch_size INT := 10000;
            affected INT := 1;
        BEGIN
            WHILE affected > 0 LOOP
                UPDATE click_events
                SET new_column = compute(old_column)
                WHERE new_column IS NULL
                LIMIT batch_size;
                GET DIAGNOSTICS affected = ROW_COUNT;
                COMMIT;
            END LOOP;
        END $$;
    """)

# 6. Lock timeout para DDL
# POR QUE: Se uma migration nao conseguir lock em 5s, e melhor falhar
# do que ficar esperando e bloqueando requests
def upgrade():
    op.execute("SET lock_timeout = '5s';")
    op.add_column('short_links', sa.Column('expires_at', sa.DateTime(timezone=True)))
```

---

## 6. ADVANCED CACHE LAYER

### 6.1 Cache Stampede Prevention

```
PROBLEMA: Cache de slug "cogna" expira. 1000 requests simultaneas para "cogna"
detectam cache miss ao mesmo tempo. Todas vao ao PG. PG recebe 1000 queries identicas.

SOLUCAO 1: Locking (via Redis)
  - Primeira request adquire lock e vai ao PG
  - Demais requests esperam lock (com timeout de 50ms)
  - Se timeout, vao direto ao PG (fallback)

SOLUCAO 2: Probabilistic Early Recomputation (PER) — PREFERIDA
  POR QUE: Sem lock, sem coordenacao, sem waiting.
  Conceito: recomputar ANTES do TTL expirar, com probabilidade crescente.
```

```python
# Implementacao de Probabilistic Early Recomputation
import random
import time

def should_recompute(ttl_remaining: float, delta: float = 1.0, beta: float = 1.0) -> bool:
    """
    Decide probabilisticamente se deve recomputar o cache.

    Parametros:
    - ttl_remaining: segundos ate expirar
    - delta: tempo estimado para recomputar (query PG ~0.005s)
    - beta: fator de agressividade (1.0 = padrao, >1.0 = mais cedo)

    POR QUE funciona: Quando TTL esta longe, probabilidade e ~0%.
    Conforme TTL se aproxima de 0, probabilidade aumenta.
    Resultado: exatamente 1 request (em media) recomputa antes de expirar.
    """
    if ttl_remaining <= 0:
        return True
    return random.random() < delta * beta * (-1 * math.log(random.random())) / ttl_remaining

# No resolve_slug:
async def resolve_from_redis(redis, slug):
    pipe = redis.pipeline()
    pipe.get(f"s:{slug}")
    pipe.ttl(f"s:{slug}")
    data, ttl = await pipe.execute()

    if data and ttl > 0:
        if should_recompute(ttl, delta=0.005):
            # Recomputar em background (nao bloqueia esta request)
            asyncio.create_task(recompute_cache(redis, slug))
        return ResolveResult.from_msgpack(data)
    return None
```

### 6.2 Cache Invalidation Protocol (Multi-Instance)

```
PROBLEMA: Instancia A atualiza um link (admin edita URL). Redis e atualizado.
Mas Instancia B tem o valor antigo no cache local (TTL 60s).
Pior caso: redirects vao para URL antiga por ate 60 segundos.

SOLUCAO: Redis Pub/Sub para invalidacao de cache local.
```

```python
# app/core/cache_invalidation.py

INVALIDATION_CHANNEL = "cache:invalidate"

class CacheInvalidationManager:
    """
    Cada instancia se inscreve no canal de invalidacao.
    Quando um link e atualizado/deletado, a instancia que fez a mudanca
    publica no canal. Todas as instancias (incluindo ela mesma) removem
    do cache local.

    POR QUE pub/sub e nao polling:
    - Latencia de invalidacao: ~1ms (pub/sub) vs ~60s (TTL expiry)
    - Sem overhead de polling (pub/sub e push-based)
    """

    def __init__(self, redis, local_cache):
        self.redis = redis
        self.local_cache = local_cache
        self._pubsub = None

    async def start(self):
        """Iniciar listener de invalidacao (chamado no startup)."""
        self._pubsub = self.redis.pubsub()
        await self._pubsub.subscribe(INVALIDATION_CHANNEL)
        asyncio.create_task(self._listen())

    async def _listen(self):
        async for message in self._pubsub.listen():
            if message["type"] == "message":
                slug = message["data"].decode()
                self.local_cache.pop(slug, None)

    async def invalidate(self, slug: str):
        """Publicar invalidacao (chamado quando link e atualizado/deletado)."""
        await self.redis.publish(INVALIDATION_CHANNEL, slug)

# Uso no link_service.py:
async def update_link(link_id, data, cache_invalidation):
    # ... update no PG ...
    # ... update no Redis ...
    await cache_invalidation.invalidate(old_slug)
    if data.slug != old_slug:
        await cache_invalidation.invalidate(data.slug)
```

### 6.3 Redis Cluster vs. Sentinel

```
DECISAO: Redis Sentinel para HA (ate 100K req/s).
Redis Cluster quando precisar de sharding (>100K req/s ou >25GB de dados).

POR QUE Sentinel primeiro:
1. Simples: 1 primary + 2 replicas + 3 sentinels
2. Failover automatico: se primary cai, sentinel promove replica em ~10s
3. Sem sharding: todos os dados em todos os nos (simplicidade)
4. Dados do TOI cabem em 1 instancia (<1GB estimado)

QUANDO migrar para Cluster:
- Redis memory > 25GB (improvavel para URL shortener)
- Throughput > 100K ops/s em single node
- Precisa de multi-region (Cluster permite write em qualquer shard)

Configuracao Sentinel:
  # docker-compose.prod.yml
  redis-primary:
    image: redis:7-alpine
    command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru

  redis-replica-1:
    image: redis:7-alpine
    command: redis-server --replicaof redis-primary 6379

  redis-replica-2:
    image: redis:7-alpine
    command: redis-server --replicaof redis-primary 6379

  redis-sentinel-1:
    image: redis:7-alpine
    command: redis-sentinel /etc/sentinel.conf
    # sentinel.conf:
    # sentinel monitor toi-primary redis-primary 6379 2
    # sentinel down-after-milliseconds toi-primary 5000
    # sentinel failover-timeout toi-primary 10000
```

### 6.4 Cache Key Namespacing e Versionamento

```
Schema de keys:

s:{slug}                    → Dados do redirect (msgpack)
c:{slug}                    → Contador de clicks (Redis INCR)
lock:s:{slug}               → Lock para cache stampede prevention
hot:slugs                   → Sorted set para deteccao de hot slugs
stream:clicks               → Stream de eventos de click
rl:{ip}:{endpoint}          → Rate limiting counters
sess:{admin_id}             → Blacklist de tokens revogados

VERSIONAMENTO:
Se o formato do valor mudar (ex: adicionar campo), prefixar com versao:

  Key: s:{slug}
  Valor (v1): msgpack({url, active})
  Valor (v2): msgpack({url, active, expires_at})

  Como migrar sem downtime:
  1. Deploy codigo que LE v1 e v2, ESCREVE v2
  2. Esperar TTL expirar (1h) — todos os valores viram v2
  3. Remover codigo de leitura v1

  Alternativa: prefixar key com versao (v2:s:{slug})
  Problema: invalida todo o cache de uma vez. NAO recomendado.
```

### 6.5 Memory Budget

```
ESTIMATIVA para 10K links ativos:

Cache local (por instancia):
  1000 entries × ~200 bytes = ~200KB
  Overhead do TTLCache: ~50KB
  Total: ~250KB por instancia (negligivel)

Redis:
  Slug data:     10K × 200B = 2MB
  Counters:      10K × 50B = 500KB
  Rate limiting: ~50K counters × 30B = 1.5MB
  Stream buffer: 100K msgs × 200B = 20MB
  Hot slugs set: ~1K entries × 50B = 50KB
  Overhead Redis: ~2MB (data structures, buffers)

  Total Redis: ~26MB
  Recomendacao: maxmemory 512MB (margem ampla)

Politica de evicao: allkeys-lru
POR QUE allkeys (nao volatile): Se memoria encher, melhor evictar qualquer key
(inclusive slug data) do que OOM. Slugs sao reconstruiveis do PG.
```

---

## 7. PRODUCAO — SEGURANCA

### 7.1 Secrets Management

```
EVOLUCAO:
MVP:     .env file (aceitavel para dev)
Staging: Environment variables via Docker secrets
Prod:    HashiCorp Vault ou AWS Secrets Manager

Segredos que DEVEM sair do .env:
- JWT_SECRET_KEY        → Vault path: secret/toi/jwt_secret
- DATABASE_URL          → Vault path: secret/toi/database
- REDIS_URL             → Vault path: secret/toi/redis
- ADMIN_INITIAL_PASSWORD → Vault path: secret/toi/admin_seed (usar uma vez, revogar)
- GEOIP_LICENSE_KEY     → Vault path: secret/toi/geoip

Integracao com Vault (padrao):
# app/config.py
import hvac

class Settings(BaseSettings):
    vault_addr: str = ""
    vault_token: str = ""

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        if self.vault_addr:
            client = hvac.Client(url=self.vault_addr, token=self.vault_token)
            secrets = client.secrets.kv.v2.read_secret_version(path="toi")["data"]["data"]
            self.jwt_secret_key = secrets["jwt_secret"]
            self.database_url = secrets["database_url"]
            # ...

Rotacao de secrets:
- JWT_SECRET_KEY: Rotacionar a cada 90 dias
  Suportar 2 keys simultaneamente (current + previous) para nao invalidar tokens ativos
- DATABASE_URL password: Rotacionar a cada 30 dias via Vault dynamic secrets
```

### 7.2 Open Redirect Abuse Prevention

```
PROBLEMA: Admin cadastra https://evil-phishing.com como destino.
Qualquer pessoa que acessar toi.shinp.ai/x e redirecionada para phishing.
Dominio toi.shinp.ai perde reputacao em email filters e safe browsing.

MITIGACOES (camadas):

1. Google Safe Browsing API check no momento da criacao do link:
   POST https://safebrowsing.googleapis.com/v4/threatMatches:find
   Se URL for classificada como phishing/malware → rejeitar criacao
   POR QUE: Previne o cadastro de URLs ja conhecidas como maliciosas

2. Verificacao periodica (diaria) de todos os links ativos:
   Worker que verifica destination_url de cada link ativo contra Safe Browsing
   Se URL mudar de classificacao → desativar link + notificar admin
   POR QUE: Uma URL pode se tornar maliciosa apos o cadastro

3. Pagina intermediaria para links com >1000 clicks/dia de IPs unicos:
   Exibir "Voce esta sendo redirecionado para {domain}. Continuar?"
   POR QUE: Links virais tem maior superficie de ataque
   TRADEOFF: Adiciona 1 click a mais. Configuravel por link.

4. Header Referrer-Policy: no-referrer nos redirects:
   POR QUE: Nao propagar reputacao do toi.shinp.ai para destinos desconhecidos
```

### 7.3 Bot/Spam Protection

```
PROBLEMA: Bots inflam contagens de click, desperdicam recursos.

Deteccao em camadas:

1. User-Agent filtering (basico):
   Lista de UA conhecidos de bots (Googlebot, Bingbot, etc.)
   NAO contar como click, mas registrar como bot_visit separadamente
   POR QUE: ~30% do trafego web e bot. Inflar analytics e inutil.

2. Heuristica de velocidade:
   >10 requests do mesmo ip_hash em 10 segundos → provavelmente bot
   Registrar mas nao contar no click_count

3. Challenge page (CAPTCHA) — apenas quando threshold e atingido:
   Se ip_hash > 50 requests em 1 minuto para o MESMO slug → servir CAPTCHA
   Usar hCaptcha (gratis para <1M verifications/mes)
   POR QUE: NAO colocar CAPTCHA em todo redirect (destruiria UX)
   Apenas em caso de abuso detectado.

4. Fingerprinting basico (sem JavaScript no redirect):
   TLS fingerprint (JA3) — identifica tipo de client sem JS
   Bots geralmente tem JA3 de curl/python-requests, nao de browser
```

### 7.4 DDoS Mitigation

```
CAMADAS:

1. Cloudflare (obrigatorio para producao):
   - DDoS protection automatica (Layer 3/4/7)
   - Rate limiting por IP (configuravel no dashboard)
   - Bot management
   - Cache de paginas 404/410 no edge
   POR QUE: Redirect endpoint e publico, sem auth. Superficie de ataque maxima.

2. Nginx rate limiting (segunda camada):
   limit_req_zone $binary_remote_addr zone=redirect:10m rate=50r/s;
   location ~ ^/[a-zA-Z0-9_-]+$ {
       limit_req zone=redirect burst=100 nodelay;
   }
   POR QUE: Se Cloudflare falhar ou for bypassed (IP exposto).

3. Application-level (terceira camada):
   slowapi com Redis backend (ja no SPEC)
   Ajuste: 100r/s por IP e generoso demais para DDoS.
   Mudar para 30r/s por IP com burst de 50.
```

### 7.5 JWT Evolution

```python
# PROBLEMA: JWT de 24h sem revogacao e inseguro.
# SOLUCAO: Access token curto + Refresh token + Blacklist

# Novo fluxo:
# 1. Login → access_token (15 min) + refresh_token (7 dias, httponly cookie)
# 2. Access token expira → frontend usa refresh_token para obter novo access_token
# 3. Logout → refresh_token adicionado a blacklist no Redis

# Token config:
ACCESS_TOKEN_EXPIRE = 900          # 15 minutos
REFRESH_TOKEN_EXPIRE = 604800      # 7 dias

# Blacklist via Redis:
# POR QUE Redis: O(1) lookup, com TTL automatico (auto-limpa)
async def revoke_token(redis, jti: str, exp: int):
    """Adicionar token a blacklist."""
    ttl = exp - int(time.time())
    if ttl > 0:
        await redis.set(f"bl:{jti}", "1", ex=ttl)

async def is_token_revoked(redis, jti: str) -> bool:
    """Verificar se token foi revogado."""
    return await redis.exists(f"bl:{jti}") > 0

# Refresh token:
# Armazenado como httponly cookie (nao acessivel via JS → protege contra XSS)
# SameSite=Strict (protege contra CSRF)
# Secure=True (apenas HTTPS)
response.set_cookie(
    key="refresh_token",
    value=refresh_token,
    httponly=True,
    secure=True,
    samesite="strict",
    max_age=REFRESH_TOKEN_EXPIRE,
    path="/api/auth/refresh",  # Apenas enviado para esta rota
)
```

### 7.6 CSP Headers para Admin Panel

```python
# POR QUE CSP: Previne XSS mesmo se innerHTML for usado acidentalmente.
# O browser bloqueia scripts nao autorizados.

CSP_POLICY = "; ".join([
    "default-src 'self'",
    "script-src 'self' https://cdn.jsdelivr.net",  # Chart.js CDN
    "style-src 'self' 'unsafe-inline'",  # CSS inline necessario para componentes
    "font-src 'self' https://fonts.gstatic.com",  # Google Fonts (Inter, JetBrains Mono)
    "img-src 'self' data:",  # data: para SVG inline
    "connect-src 'self'",  # Apenas API do proprio dominio
    "frame-ancestors 'none'",  # Equivalente a X-Frame-Options DENY
    "base-uri 'self'",
    "form-action 'self'",
])

# Aplicar apenas nas rotas do admin (nao no redirect)
@app.middleware("http")
async def add_csp_header(request, call_next):
    response = await call_next(request)
    if request.url.path.startswith("/admin") or request.url.path.startswith("/api"):
        response.headers["Content-Security-Policy"] = CSP_POLICY
    return response
```

### 7.7 Dependency Scanning

```yaml
# .github/workflows/security.yml
name: Security Scan
on:
  schedule:
    - cron: "0 6 * * 1"  # Toda segunda as 6h
  push:
    branches: [main]
    paths: ["requirements*.txt", "Dockerfile"]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # pip-audit (substituto do safety, mais mantido)
      - name: pip-audit
        run: |
          pip install pip-audit
          pip-audit -r requirements.txt --strict

      # Trivy para scan de vulnerabilidades na imagem Docker
      - name: Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: toi-shortcut:latest
          severity: CRITICAL,HIGH
          exit-code: 1

      # Bandit para analise estatica de seguranca em Python
      - name: Bandit
        run: |
          pip install bandit
          bandit -r app/ -ll  # Reportar apenas medium+ severity
```

---

## 8. OBSERVABILITY STACK

### 8.1 Structured Logging

```python
# app/core/logging.py
import structlog

def setup_logging():
    """
    POR QUE structlog: Logs estruturados (JSON) sao parseáveis por
    Elasticsearch, Loki, CloudWatch. Logs de texto sao para humanos,
    nao para maquinas.
    """
    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,
            structlog.stdlib.add_log_level,
            structlog.stdlib.add_logger_name,
            structlog.processors.TimeStamper(fmt="iso"),
            structlog.processors.StackInfoRenderer(),
            structlog.processors.format_exc_info,
            # Em producao: JSON. Em dev: console colorido.
            structlog.processors.JSONRenderer() if PRODUCTION else
            structlog.dev.ConsoleRenderer(),
        ],
        wrapper_class=structlog.stdlib.BoundLogger,
        context_class=dict,
        logger_factory=structlog.stdlib.LoggerFactory(),
    )

# Uso no redirect:
logger = structlog.get_logger()

async def redirect(slug, request):
    log = logger.bind(
        slug=slug,
        client_ip_hash=anonymize_ip(get_client_ip(request)),
        cache_layer=None,
    )

    result = await resolve_slug(slug)

    # Log apenas para requests nao-cache-hit-local (sampling)
    # POR QUE: Em 10K req/s, logar tudo = 10K logs/s = custo alto.
    # Cache hits locais sao o caso feliz — nao precisam de log.
    if cache_layer != "local":
        log.info("redirect",
            cache_layer=cache_layer,  # "redis", "pg", "miss"
            status=302 if result else 404,
            latency_ms=elapsed_ms,
        )
```

Output (JSON):
```json
{
  "event": "redirect",
  "slug": "cogna",
  "cache_layer": "redis",
  "status": 302,
  "latency_ms": 0.8,
  "timestamp": "2026-04-03T14:22:01.123Z",
  "level": "info"
}
```

### 8.2 Metricas Prometheus

```python
# app/core/metrics.py
from prometheus_client import Counter, Histogram, Gauge, Info

# POR QUE estas metricas especificamente:
# Cada uma mapeia para um alerta ou decisao operacional.

# REDIRECT PERFORMANCE
redirect_latency = Histogram(
    "toi_redirect_latency_seconds",
    "Latencia do redirect end-to-end",
    buckets=[0.001, 0.002, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
    labelnames=["cache_layer"],  # local, redis, pg, miss
)
# POR QUE: p50/p95/p99 de latencia e o SLI principal. Buckets otimizados para sub-5ms target.

redirect_total = Counter(
    "toi_redirect_total",
    "Total de redirects",
    labelnames=["status"],  # 302, 404, 410, 503
)
# POR QUE: Taxa de redirect e erro. Alerta se 5xx > 1%.

# CACHE
cache_hit_ratio = Gauge(
    "toi_cache_hit_ratio",
    "Razao de cache hits (0.0-1.0)",
    labelnames=["layer"],  # local, redis
)
# POR QUE: Se cache hit ratio cair, latencia media sobe. Alerta se < 90%.

# LINKS
active_links_total = Gauge(
    "toi_active_links_total",
    "Total de links ativos",
)
# POR QUE: Capacidade planning. Usado para projecao de storage e cache sizing.

# ERRORS
error_total = Counter(
    "toi_error_total",
    "Total de erros",
    labelnames=["type"],  # pg_error, redis_error, validation_error
)
# POR QUE: Deteccao rapida de falhas de infraestrutura.

# CIRCUIT BREAKER
circuit_breaker_state = Gauge(
    "toi_circuit_breaker_state",
    "Estado do circuit breaker (0=closed, 1=open, 2=half_open)",
    labelnames=["service"],  # redis, pg
)
# POR QUE: Alerta imediato quando fallback esta ativo.

# ANALYTICS PIPELINE
analytics_stream_length = Gauge(
    "toi_analytics_stream_length",
    "Tamanho do Redis Stream de analytics",
)
# POR QUE: Se crescer, worker esta atrasado. Alerta se > 50K.

analytics_batch_duration = Histogram(
    "toi_analytics_batch_duration_seconds",
    "Duracao do flush de batch de analytics",
    buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
)

# Endpoint /metrics
from prometheus_client import make_asgi_app
metrics_app = make_asgi_app()
app.mount("/metrics", metrics_app)
# PROTEGER: /metrics nao deve ser publico. Filtrar no nginx ou via auth.
```

### 8.3 Tracing com OpenTelemetry

```python
# app/core/tracing.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.asyncpg import AsyncPGInstrumentor

def setup_tracing(app):
    """
    POR QUE OpenTelemetry: Padrao aberto, suportado por todos os vendors
    (Jaeger, Tempo, Datadog, etc). Instrumentação automatica para FastAPI,
    Redis e asyncpg mostra spans individuais por camada.

    Exemplo de trace de redirect:
    [GET /{slug}] ─── 2.1ms total
      ├── [local_cache_lookup] ─── 0.01ms (MISS)
      ├── [redis.GET s:cogna] ─── 0.3ms (HIT)
      └── [xadd stream:clicks] ─── 0.05ms
    """
    provider = TracerProvider()
    processor = BatchSpanProcessor(OTLPSpanExporter(
        endpoint="http://otel-collector:4317",
    ))
    provider.add_span_processor(processor)
    trace.set_tracer_provider(provider)

    # Instrumentacao automatica
    FastAPIInstrumentor.instrument_app(app)
    RedisInstrumentor().instrument()
    AsyncPGInstrumentor().instrument()
```

### 8.4 Sentry Integration

```python
# app/core/sentry.py
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration

def setup_sentry(dsn: str):
    sentry_sdk.init(
        dsn=dsn,
        integrations=[
            FastApiIntegration(transaction_style="endpoint"),
            SqlalchemyIntegration(),
        ],
        traces_sample_rate=0.1,  # 10% das requests para performance monitoring
        profiles_sample_rate=0.01,  # 1% para profiling

        # POR QUE filtrar: Redirect 404 nao e erro do sistema, e slug inexistente.
        before_send=lambda event, hint: (
            None if event.get("level") == "warning"
            and "404" in str(event.get("message", ""))
            else event
        ),
    )
```

### 8.5 Alerting Rules

```yaml
# alerting-rules.yml (Prometheus Alertmanager)

groups:
  - name: toi-critical
    rules:
      # p99 de redirect acima de 50ms por 5 minutos
      # POR QUE 50ms: 10x o target de 5ms. Algo esta muito errado.
      - alert: RedirectLatencyHigh
        expr: histogram_quantile(0.99, rate(toi_redirect_latency_seconds_bucket[5m])) > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "p99 de redirect > 50ms"

      # Error rate acima de 1% por 3 minutos
      - alert: HighErrorRate
        expr: rate(toi_redirect_total{status=~"5.."}[3m]) / rate(toi_redirect_total[3m]) > 0.01
        for: 3m
        labels:
          severity: critical
        annotations:
          summary: "Taxa de erro > 1%"

      # Circuit breaker do Redis aberto
      - alert: RedisCircuitBreakerOpen
        expr: toi_circuit_breaker_state{service="redis"} == 1
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Redis circuit breaker OPEN"

      # PG health check falhando
      - alert: PostgresDown
        expr: up{job="postgres"} == 0
        for: 30s
        labels:
          severity: critical

  - name: toi-warning
    rules:
      # Cache hit ratio abaixo de 80%
      - alert: LowCacheHitRatio
        expr: toi_cache_hit_ratio{layer="redis"} < 0.8
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Cache hit ratio < 80%"

      # Analytics stream crescendo (worker lento)
      - alert: AnalyticsBacklog
        expr: toi_analytics_stream_length > 50000
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Analytics stream > 50K mensagens"

      # Disco PG acima de 80%
      - alert: DiskSpaceHigh
        expr: (node_filesystem_avail_bytes{mountpoint="/var/lib/postgresql"} / node_filesystem_size_bytes{mountpoint="/var/lib/postgresql"}) < 0.2
        for: 10m
        labels:
          severity: warning
```

### 8.6 Health Check Evolution

```python
# app/routers/health.py

@router.get("/api/health")
async def health_check(redis, db_pool):
    """
    Health check PROFUNDO. Nao retorna 200 se dependencias estao down.
    POR QUE: Load balancer usa este endpoint para decidir se a instancia
    pode receber trafego. 200 com PG down = requests falhando.
    """
    checks = {}
    overall_healthy = True

    # Check Redis (timeout 1s)
    try:
        start = time.monotonic()
        await asyncio.wait_for(redis.ping(), timeout=1.0)
        checks["redis"] = {
            "status": "healthy",
            "latency_ms": round((time.monotonic() - start) * 1000, 2),
        }
    except Exception as e:
        checks["redis"] = {"status": "unhealthy", "error": str(e)}
        overall_healthy = False

    # Check PostgreSQL (timeout 2s)
    try:
        start = time.monotonic()
        await asyncio.wait_for(
            db_pool.fetchval("SELECT 1"), timeout=2.0
        )
        checks["postgres"] = {
            "status": "healthy",
            "latency_ms": round((time.monotonic() - start) * 1000, 2),
            "pool_size": db_pool.get_size(),
            "pool_free": db_pool.get_idle_size(),
        }
    except Exception as e:
        checks["postgres"] = {"status": "unhealthy", "error": str(e)}
        overall_healthy = False

    status_code = 200 if overall_healthy else 503
    return JSONResponse(
        status_code=status_code,
        content={
            "status": "healthy" if overall_healthy else "degraded",
            "checks": checks,
            "version": APP_VERSION,
            "uptime_seconds": int(time.monotonic() - START_TIME),
        },
    )

# Endpoint separado para liveness (Kubernetes)
@router.get("/api/health/live")
async def liveness():
    """Apenas verifica se o processo esta respondendo."""
    return {"status": "alive"}

# Endpoint separado para readiness (Kubernetes)
@router.get("/api/health/ready")
async def readiness(redis, db_pool):
    """Igual ao health check profundo. Usado pelo K8s para readiness probe."""
    return await health_check(redis, db_pool)
```

### 8.7 Dashboards Grafana (Recomendacoes)

```
Dashboard 1: "TOI Redirect Performance" (SRE)
  Row 1: Request Rate (req/s) | Error Rate (%) | p50/p95/p99 Latency
  Row 2: Cache Hit Ratio por camada | Circuit Breaker States
  Row 3: Active Connections (PG pool) | Redis Memory | Redis Ops/s
  Refresh: 10s

Dashboard 2: "TOI Analytics Pipeline" (SRE)
  Row 1: Stream Length | Events/s processados | Batch Duration
  Row 2: Clicks/min (total) | Top 10 slugs (tempo real)
  Row 3: PG Insert Rate | PG WAL Size | Replication Lag
  Refresh: 30s

Dashboard 3: "TOI Business Metrics" (Product)
  Row 1: Total Links | Total Clicks (today) | Unique Visitors (today)
  Row 2: Clicks over time (7d/30d/90d) | Top Links
  Row 3: Top Countries | Top Referers
  Refresh: 5min
```

---

## 9. DEPLOY STRATEGY

### 9.1 Docker Multi-Stage Build

```dockerfile
# Dockerfile (producao)

# Stage 1: Dependencies
FROM python:3.11-slim as deps
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Production image
FROM python:3.11-slim as production
# POR QUE slim: ~150MB vs ~900MB de python:3.11. Menos superficie de ataque.

# Security: nao rodar como root
RUN groupadd -r toi && useradd -r -g toi toi

WORKDIR /app

# Copiar dependencias do stage 1
COPY --from=deps /install /usr/local

# Copiar aplicacao
COPY app/ ./app/
COPY alembic/ ./alembic/
COPY alembic.ini .

# Frontend (static files)
COPY frontend/ ./frontend/

# Ownership
RUN chown -R toi:toi /app

USER toi

EXPOSE 8000

# Health check do Docker
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health/live')"

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
     "--workers", "4", "--loop", "uvloop", "--http", "httptools", \
     "--no-access-log", "--timeout-keep-alive", "65"]
```

```
Tamanho estimado da imagem:
  python:3.11-slim base:     ~150MB
  Dependencias Python:       ~80MB
  Codigo da aplicacao:       ~5MB
  Frontend (static):         ~2MB
  Total:                     ~237MB

vs. sem multi-stage (python:3.11 + build tools): ~1.1GB
Reducao: ~78%
```

### 9.2 CI/CD Pipeline (GitHub Actions)

```yaml
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.11" }
      - run: pip install ruff mypy
      - run: ruff check app/
      - run: ruff format --check app/
      - run: mypy app/ --ignore-missing-imports

  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: toi_test
          POSTGRES_USER: toi
          POSTGRES_PASSWORD: test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7-alpine
        ports: ["6379:6379"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.11" }
      - run: pip install -r requirements.txt -r requirements-test.txt
      - run: pytest tests/ -v --cov=app --cov-report=xml
        env:
          DATABASE_URL: postgresql+asyncpg://toi:test@localhost:5432/toi_test
          REDIS_URL: redis://localhost:6379/0
      - uses: codecov/codecov-action@v3

  build:
    needs: [lint, test]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: [build]
    runs-on: ubuntu-latest
    environment: production
    steps:
      # 1. Database migration ANTES do deploy
      # POR QUE antes: new code pode depender de novo schema.
      # Migrations devem ser backward-compatible (old code funciona com new schema).
      - name: Run migrations
        run: |
          ssh deploy@${{ secrets.SERVER_IP }} \
            "docker run --rm --network toi-net \
             -e DATABASE_URL=${{ secrets.DATABASE_URL }} \
             ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
             alembic upgrade head"

      # 2. Blue-green deploy
      - name: Deploy new version
        run: |
          ssh deploy@${{ secrets.SERVER_IP }} << 'EOF'
            # Pull new image
            docker pull $IMAGE:$SHA

            # Start new containers (green)
            docker compose -f docker-compose.prod.yml up -d --scale app=4 --no-recreate

            # Wait for health checks
            for i in $(seq 1 30); do
              if curl -sf http://localhost:8000/api/health/live; then
                break
              fi
              sleep 1
            done

            # Switch nginx to green
            # Drain old containers (30s grace period)
            docker compose -f docker-compose.prod.yml up -d --remove-orphans
          EOF
```

### 9.3 Rollback Strategy

```
ROLLBACK DE APLICACAO:
  1. Docker image anterior esta no registry (tagged por SHA)
  2. Rollback = mudar tag no docker-compose e redeploy
  3. Tempo de rollback: <2 minutos

ROLLBACK DE DATABASE:
  POR QUE e complexo: `alembic downgrade` pode perder dados (DROP COLUMN).

  Regra de ouro: NUNCA escrever migrations que nao sao backward-compatible.

  Exemplos:
  ✅ ADD COLUMN (nullable ou com default) → Old code ignora a coluna
  ✅ ADD INDEX CONCURRENTLY → Old code nao afetado
  ❌ DROP COLUMN → Old code quebra
  ❌ RENAME COLUMN → Old code quebra

  Para mudancas breaking:
  1. Deploy 1: Add new column, write to both old and new
  2. Deploy 2: Migrate reads to new column
  3. Deploy 3: Stop writing to old column
  4. Deploy 4: Drop old column

  Isso se chama "expand and contract" pattern.
```

### 9.4 Zero-Downtime Deploy

```
ESTRATEGIA: Rolling update com drain.

1. N instancias rodando (ex: 4)
2. Deploy atualiza 1 instancia por vez:
   a. Remover instancia do load balancer (nginx upstream remove)
   b. Enviar SIGTERM para o container
   c. Container para de aceitar novas conexoes
   d. Container espera requests em andamento finalizarem (graceful shutdown, max 30s)
   e. Container encerra
   f. Novo container inicia com nova imagem
   g. Health check passa → adicionar ao load balancer
   h. Repetir para proxima instancia

CONFIGURACAO UVICORN:
  --timeout-graceful-shutdown 30  # Espera ate 30s para requests finalizarem

CONFIGURACAO NGINX:
  health check a cada 5s, 2 falhas = remove do upstream
```

### 9.5 Infrastructure as Code

```
RECOMENDACAO PROGRESSIVA:

Fase 1 (MVP): docker-compose.yml manual
Fase 2: docker-compose.prod.yml com profiles (dev/staging/prod)
Fase 3: Terraform para infra cloud (VPC, VM, managed PG, managed Redis)
Fase 4: Kubernetes (se necessario >10 instancias)

POR QUE nao K8s de inicio: Overhead operacional para equipe pequena.
Docker Compose + 1 VM atende ate ~50K req/s.
```

---

## 10. SCALE PROJECTIONS

### 10.1 Limites da Arquitetura Atual (MVP)

```
CONFIGURACAO: 1 VM (4 vCPU, 8GB RAM), 1 PG, 1 Redis, 4 Uvicorn workers

Redirect throughput estimado:
  Cache hit local:  ~15,000 req/s (limitado por Python GIL + event loop)
  Cache hit Redis:  ~8,000 req/s  (limitado por roundtrip Redis ~0.3ms)
  Cache miss (PG):  ~2,000 req/s  (limitado por PG query ~3ms + pool)

  Assumindo 90% hit local, 8% Redis, 2% PG:
  Throughput misto: ~12,000 req/s

  Em redirects/dia: ~12,000 × 86,400 = ~1 bilhao (teorico, peak != sustained)
  Sustained: ~500M redirects/dia com headroom

LIMITES REAIS:
  - CPU: 4 workers saturados em ~15K req/s
  - Memory: 8GB suporta ~1M concurrent connections (Uvicorn overhead ~8KB/conn)
  - PG connections: Pool de 20, com PgBouncer multipleca para 80 aparentes
  - Redis: Single instance suporta ~100K ops/s (nao sera gargalo)
  - Network: 1Gbps = ~125K responses/s de 1KB (nao sera gargalo)
```

### 10.2 Bottleneck Analysis

```
1K redirects/dia (~0.01 req/s):
  Gargalo: NENHUM
  Acao: MVP e suficiente
  Custo: ~$20/mes (1 VM pequena)

10K redirects/dia (~0.1 req/s):
  Gargalo: NENHUM
  Acao: MVP e suficiente
  Custo: ~$20/mes

100K redirects/dia (~1.2 req/s):
  Gargalo: NENHUM
  Acao: Adicionar observabilidade (metricas, logs estruturados)
  Custo: ~$30/mes

1M redirects/dia (~12 req/s):
  Gargalo: click_events storage (~150MB/dia), analytics queries ficam lentas
  Acao:
    - Particionar click_events
    - Adicionar daily_stats rollup
    - Separar analytics worker
  Custo: ~$80/mes (VM maior para PG storage)

10M redirects/dia (~115 req/s):
  Gargalo: Single PG instance para writes (contadores + eventos)
  Acao:
    - Redis Stream para analytics (desacoplamento)
    - PG read replica para analytics queries
    - Cache warming no startup
    - Batch writes para click_events
  Custo: ~$250/mes (2 VMs, PG maior)

100M redirects/dia (~1,150 req/s):
  Gargalo: Single FastAPI instance, PG write capacity
  Acao:
    - Multiple FastAPI instances (2-4) atras de load balancer
    - Redis Sentinel para HA
    - PgBouncer
    - CDN para paginas 404/410 (cacheaveis)
  Custo: ~$800/mes

1B redirects/dia (~11,500 req/s):
  Gargalo: Tudo. Python/FastAPI no limite.
  Acao:
    - 8-16 FastAPI instances
    - Redis Cluster
    - PG particionamento + multiple read replicas
    - CDN para redirect de links estáticos (Cloudflare Workers)
    - Considerar rewrite do redirect engine em Go/Rust
  Custo: ~$3,000/mes
```

### 10.3 Quando Adicionar Cada Componente

| Componente | Trigger | Estimativa |
|------------|---------|------------|
| Read replica PG | Analytics queries >30% CPU | ~5M redirects/dia |
| Redis Sentinel | Precisa de HA (nao pode ter downtime de Redis) | Producao com SLA |
| Redis Cluster | Redis memory >25GB ou >100K ops/s | ~500M redirects/dia |
| CDN para redirects | Latencia geografica importa | Usuarios globais |
| Multiple app instances | CPU >70% sustained | ~50M redirects/dia |
| PgBouncer | >50 conexoes diretas ao PG | >4 app instances |
| TimescaleDB | click_events >500GB | ~100M redirects/dia mantidos por 90 dias |

### 10.4 CDN para Redirects (Cloudflare Workers)

```javascript
// POR QUE: Para links com >100K hits/dia, redirect no edge elimina
// round-trip ao origin server. Latencia: ~2ms global vs ~50-200ms do Brasil para EUA.

// Cloudflare Worker (edge redirect)
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const url = new URL(request.url)
  const slug = url.pathname.slice(1) // Remove leading /

  if (!slug || slug.includes('/')) {
    // Nao e um slug — pass through para origin
    return fetch(request)
  }

  // Check KV (Cloudflare Workers KV — edge storage)
  const destination = await TOI_REDIRECTS.get(slug)
  if (destination) {
    // Analytics: enviar beacon para origin (fire-and-forget)
    const analyticsUrl = `${url.origin}/api/internal/click?slug=${slug}`
    // waitUntil permite fazer fetch apos retornar a response
    event.waitUntil(fetch(analyticsUrl, { method: 'POST', headers: request.headers }))

    return new Response(null, {
      status: 302,
      headers: { 'Location': destination },
    })
  }

  // Slug nao encontrado no KV — pass through para origin
  return fetch(request)
}

// Sincronizacao: Quando link e criado/atualizado no origin,
// API call para Cloudflare KV para atualizar o edge cache.
// Propagacao: ~60 segundos para todos os edge nodes.
```

### 10.5 Distribuicao Geografica

```
FASE 1: Origin unico (Sao Paulo)
  Latencia BR: ~10-30ms
  Latencia US: ~150-200ms
  Latencia EU: ~250-300ms

FASE 2: CDN (Cloudflare) com KV
  Latencia global: ~5-15ms (edge redirect)
  Origin em SP apenas para admin + analytics

FASE 3: Multi-region (se necessario)
  Origin SP + Origin US-East
  PG com logical replication cross-region
  Redis por regiao (nao replicado — cache e reconstruivel)

  POR QUE nao antes: Complexidade operacional de multi-region e enorme.
  CDN resolve 95% do problema de latencia geografica.
```

### 10.6 Estimativa de Custo

```
TIER 1: Hobby (ate 100K redirects/dia)
  1× VM (2 vCPU, 4GB): $20/mes
  Cloudflare Free tier: $0
  Total: ~$20/mes

TIER 2: Startup (ate 10M redirects/dia)
  1× VM (4 vCPU, 8GB): $40/mes
  Managed PG (2 vCPU, 50GB): $50/mes
  Managed Redis (1GB): $25/mes
  Cloudflare Pro: $20/mes
  Monitoring (Grafana Cloud free tier): $0
  Total: ~$135/mes

TIER 3: Growth (ate 100M redirects/dia)
  4× VM app (4 vCPU, 8GB): $160/mes
  Managed PG Primary (4 vCPU, 200GB): $150/mes
  Managed PG Replica: $100/mes
  Managed Redis (HA, 2GB): $80/mes
  Cloudflare Business: $200/mes
  Monitoring stack: $50/mes
  Total: ~$740/mes

TIER 4: Scale (ate 1B redirects/dia)
  16× VM app: $640/mes
  PG Cluster (primary + 3 replicas): $600/mes
  Redis Cluster (3 nodes): $300/mes
  Cloudflare Enterprise: ~$2000/mes (negociavel)
  Full monitoring stack: $200/mes
  Total: ~$3,740/mes
```

---

## 11. PRODUCT EVOLUTION

### 11.1 Multi-Tenant Architecture

```sql
-- COMO adicionar sem rewrite:
-- 1. Adicionar tenant_id a short_links (ja sugerido no schema evoluido)
-- 2. Row-level security no PG

-- Tabela de tenants
CREATE TABLE tenants (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        VARCHAR(255) NOT NULL,
    slug        VARCHAR(50) NOT NULL UNIQUE,  -- subdomain: {slug}.toi.shinp.ai
    plan        VARCHAR(20) NOT NULL DEFAULT 'free',  -- free, pro, enterprise
    is_active   BOOLEAN NOT NULL DEFAULT true,
    settings    JSONB NOT NULL DEFAULT '{}',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Associar admin a tenant
ALTER TABLE admins ADD COLUMN tenant_id UUID REFERENCES tenants(id);

-- Associar links a tenant
-- short_links.tenant_id ja existe no schema evoluido
ALTER TABLE short_links ADD CONSTRAINT fk_tenant
    FOREIGN KEY (tenant_id) REFERENCES tenants(id);

-- Row-Level Security
-- POR QUE: Garante isolamento mesmo se o codigo tiver bug.
-- Queries sem filtro de tenant retornam apenas dados do tenant da sessao.
ALTER TABLE short_links ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON short_links
    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- No application layer:
# Middleware que seta o tenant baseado no JWT
async def set_tenant_context(conn, tenant_id):
    await conn.execute(f"SET app.current_tenant_id = '{tenant_id}'")
```

### 11.2 API Keys para Integracoes Externas

```sql
CREATE TABLE api_keys (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    key_hash    VARCHAR(64) NOT NULL,  -- SHA-256 da key (nunca armazenar plaintext)
    key_prefix  VARCHAR(8) NOT NULL,   -- Primeiros 8 chars para identificacao (toi_k_...)
    name        VARCHAR(255) NOT NULL,
    scopes      VARCHAR[] NOT NULL DEFAULT '{"links:read","links:write"}',
    rate_limit  INTEGER NOT NULL DEFAULT 100,  -- requests/minuto
    is_active   BOOLEAN NOT NULL DEFAULT true,
    last_used_at TIMESTAMPTZ,
    expires_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_api_keys_prefix ON api_keys (key_prefix) WHERE is_active = true;
```

```python
# Formato da API key: toi_k_{32 chars random}
# Exemplo: toi_k_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6

# Autenticacao via header: Authorization: Bearer toi_k_...
# OU via query param: ?api_key=toi_k_... (para webhooks)

# Lookup otimizado:
# 1. Extrair prefix (primeiros 8 chars apos "toi_k_")
# 2. Buscar por prefix no PG (indice, poucas rows)
# 3. SHA-256 da key completa e comparar com key_hash
# POR QUE prefix: Evita full table scan. Index no hash nao funciona bem
# porque hash e uniformemente distribuido.
```

### 11.3 Rate Limiting por Tenant

```python
# Rate limits por plano:
RATE_LIMITS = {
    "free":       {"redirects": 10_000, "api_calls": 100, "links": 50},      # por dia
    "pro":        {"redirects": 1_000_000, "api_calls": 10_000, "links": 5000},
    "enterprise": {"redirects": None, "api_calls": 100_000, "links": None},   # None = unlimited
}

# Implementacao via Redis com sliding window:
# Key: rl:{tenant_id}:{resource}:{window}
# Exemplo: rl:uuid:redirects:2026-04-03
# INCR atomico, EXPIRE ao final do dia
```

### 11.4 Custom Domains

```
COMO funciona:
1. Tenant configura custom domain no painel (ex: go.acme.com)
2. Tenant aponta CNAME de go.acme.com → toi.shinp.ai
3. Sistema verifica DNS e emite certificado SSL via Let's Encrypt (ACME challenge)
4. Nginx recebe requests para go.acme.com e roteia para o tenant correto

Implementacao:
- Tabela custom_domains (domain, tenant_id, ssl_status, verified_at)
- Nginx com SNI-based routing (ou Caddy que faz auto-SSL)
- Wildcard nao funciona para custom domains — cada domain precisa de seu proprio certificado
- Caddy e recomendado: auto-HTTPS built-in, configuracao dinamica via API

# Caddyfile gerado dinamicamente:
go.acme.com {
    reverse_proxy toi_backend:8000
    header X-Tenant-Domain go.acme.com
}

# No application layer, resolver tenant pelo domain:
# 1. Request chega com Host: go.acme.com
# 2. Lookup custom_domains WHERE domain = 'go.acme.com'
# 3. Setar tenant_id no contexto
# 4. Redirect normalmente
```

### 11.5 Billing Integration Points

```
PONTOS DE INTEGRACAO (preparacao, nao implementacao):

1. Metering: Contar redirects/dia por tenant (ja temos via Redis counter)
   Key: meter:{tenant_id}:redirects:{YYYY-MM-DD}

2. Usage API: GET /api/billing/usage → retorna consumo do periodo
   Dados vem de daily_stats agregados por tenant

3. Webhook para billing system:
   Quando tenant atinge 80% do limite → webhook para billing system
   Quando tenant atinge 100% → soft-block (302 → pagina de upgrade)

4. Stripe integration points:
   - Checkout session ao fazer upgrade
   - Webhook receiver para payment events (invoice.paid, invoice.failed)
   - Customer portal link para gerenciar assinatura
```

### 11.6 Webhook System

```sql
CREATE TABLE webhooks (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    url         VARCHAR(2048) NOT NULL,
    secret      VARCHAR(64) NOT NULL,  -- Para HMAC signature
    events      VARCHAR[] NOT NULL,     -- Ex: {"link.clicked", "link.created"}
    is_active   BOOLEAN NOT NULL DEFAULT true,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Tabela de delivery log (para retry e debug)
CREATE TABLE webhook_deliveries (
    id              BIGSERIAL PRIMARY KEY,
    webhook_id      UUID NOT NULL REFERENCES webhooks(id),
    event_type      VARCHAR(50) NOT NULL,
    payload         JSONB NOT NULL,
    status_code     INTEGER,
    response_body   TEXT,
    attempts        INTEGER NOT NULL DEFAULT 0,
    next_retry_at   TIMESTAMPTZ,
    delivered_at    TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

```python
# Delivery com retry exponencial:
# Tentativa 1: imediata
# Tentativa 2: 1 minuto
# Tentativa 3: 5 minutos
# Tentativa 4: 30 minutos
# Tentativa 5: 2 horas
# Apos 5 falhas: desativar webhook, notificar admin

# Payload de webhook:
{
    "event": "link.clicked",
    "timestamp": "2026-04-03T14:22:01Z",
    "data": {
        "slug": "cogna",
        "destination_url": "https://cogna.com.br",
        "click_count": 1548,
        "visitor": {
            "country": "BR",
            "referer": "https://google.com"
        }
    }
}

# Assinatura HMAC (header X-TOI-Signature):
# HMAC-SHA256(webhook.secret, request_body)
# POR QUE: Receptor pode verificar que o payload veio do TOI e nao foi adulterado.
```

---

## 12. PRODUCTION ROADMAP

### Phase 1: Hardening (Antes de ir para producao)

**Duracao estimada: 1-2 semanas**
**Dependencia: MVP completo**

| Item | POR QUE | Esforco |
|------|---------|---------|
| Circuit breaker para Redis | Redis lento derruba tudo (G02) | 4h |
| Graceful shutdown (SIGTERM handling) | Perda de dados em deploy (G19) | 2h |
| Connection pooling configurado (PG + Redis) | Exaustao de conexoes sob carga (G10) | 3h |
| Health check profundo (Redis + PG) | LB nao detecta instancia degradada (G09) | 2h |
| Cache negativo (slugs inexistentes) | Scan de slugs gera queries desnecessárias | 1h |
| JWT com refresh token + blacklist | Token vazado = acesso por 24h (G05) | 6h |
| Structured logging (structlog + JSON) | Operacao cega (G07) | 3h |
| Docker multi-stage build | Imagem 4x menor, mais segura | 2h |
| Rate limiting ajustado (30r/s redirect) | DDoS protection basica | 1h |
| Graceful degradation Redis down | Sistema cai quando Redis cai | 3h |

**Total estimado: ~27 horas de desenvolvimento**

### Phase 2: Observability + CI/CD

**Duracao estimada: 1-2 semanas**
**Dependencia: Phase 1**

| Item | POR QUE | Esforco |
|------|---------|---------|
| Prometheus metrics (/metrics) | Nao se pode melhorar o que nao se mede | 4h |
| GitHub Actions pipeline (lint → test → build) | Deploys manuais sao propensos a erro (G08) | 6h |
| Sentry integration | Erros em producao precisam de contexto rico | 2h |
| Alerting rules (Prometheus/Alertmanager) | Deteccao proativa de problemas | 3h |
| Grafana dashboards (redirect perf + business) | Visibilidade operacional e de produto | 4h |
| OpenTelemetry basic (FastAPI + Redis spans) | Entender onde tempo e gasto | 3h |
| Zero-downtime deploy (rolling update) | Deploy sem afetar usuarios | 4h |
| Database migration no CI (Alembic) | Migrations esquecidas = downtime | 2h |

**Total estimado: ~28 horas**

### Phase 3: Analytics Pipeline

**Duracao estimada: 2-3 semanas**
**Dependencia: Phase 2 (observability para monitorar a pipeline)**

| Item | POR QUE | Esforco |
|------|---------|---------|
| Redis Streams para click events | BackgroundTasks nao escala, perde dados (G01) | 8h |
| Analytics worker (processo separado) | Desacoplar write path de read path | 6h |
| Batch INSERT via COPY | 500 INSERTs → 1 COPY = 100x mais rapido | 3h |
| Batch UPDATE contadores por slug | 1000 UPDATEs → 1 UPDATE/slug = sem lock contention (G03) | 3h |
| click_events particionada por mes | Storage infinito, queries lentas (G04) | 4h |
| daily_stats rollup table + cronjob | Dashboard sem queries pesadas em click_events | 6h |
| Data retention (DROP PARTITION >90 dias) | Storage controlado (G14) | 2h |
| Bot filtering (UA-based) | Analytics precisos | 3h |

**Total estimado: ~35 horas**

### Phase 4: Scale Preparation

**Duracao estimada: 2-3 semanas**
**Dependencia: Phase 3**

| Item | POR QUE | Esforco |
|------|---------|---------|
| Cache multi-camada (local LRU + Redis) | Reduzir roundtrips ao Redis | 4h |
| Cache stampede prevention (PER) | Thundering herd no PG (G11) | 3h |
| Cache invalidation via pub/sub | Consistencia em multi-instance | 4h |
| Cache warming no startup | Cold start sem thundering herd | 2h |
| PgBouncer | Connection multiplexing para multi-instance | 3h |
| Redis Sentinel setup | HA para Redis (failover automatico) | 4h |
| Nginx load balancer config | Multi-instance ready | 3h |
| Stateless verification (audit) | Garantir que nada depende de estado local | 2h |
| asyncpg direto para hot path | Eliminar overhead SQLAlchemy no redirect | 4h |
| Load testing (locust) | Validar projecoes de throughput | 4h |

**Total estimado: ~33 horas**

### Phase 5: Product Features

**Duracao estimada: 4-6 semanas**
**Dependencia: Phase 4 (infraestrutura pronta para multi-tenant)**

| Item | POR QUE | Esforco |
|------|---------|---------|
| Multi-tenant (tenant_id + RLS) | Monetizacao, isolamento | 16h |
| API Keys | Integracoes externas | 8h |
| Rate limiting por tenant | Fair use, planos | 4h |
| Custom domains (Caddy + ACME) | Feature premium | 16h |
| Webhook system | Integracoes real-time | 12h |
| Link expiration | Feature solicitada | 3h |
| UTM parameter tracking | Analytics avancado | 4h |
| Billing integration (Stripe) | Monetizacao | 16h |
| CDN para redirects (Cloudflare Workers) | Latencia global | 8h |

**Total estimado: ~87 horas**

---

### Diagrama de Dependencias

```
Phase 1 (Hardening)
    │
    ├──→ Phase 2 (Observability + CI/CD)
    │        │
    │        ├──→ Phase 3 (Analytics Pipeline)
    │        │        │
    │        │        └──→ Phase 4 (Scale Preparation)
    │        │                 │
    │        │                 └──→ Phase 5 (Product Features)
    │        │
    │        └──→ Phase 5 pode comecar features independentes
    │             (API keys, link expiration) em paralelo com Phase 3-4
    │
    └──→ Security items da Phase 1 sao PRE-REQUISITO para producao
```

---

> **Documento elaborado por Oscar**
> Arquitetura de Sistemas — Squad AI TOI
> 2026-04-03
>
> Este documento deve ser revisado a cada 3 meses ou quando o throughput
> atingir um novo tier de escala.
