# Latência de envio ao BACEN SPI/ICOM — LEGADO (LegadoPIX/CRK .NET) vs cabine (Elixir)

READ-ONLY. Foco: por que o `post_ms` (POST ao BACEN) leva 1-6s por handshake mTLS frio, e o que mantém a conexão TCP/TLS quente. Objetivo do dono: liquidar PIX < 500ms.

---

## 0. Resumo executivo (o diagnóstico em uma tela)

O `post_ms` de 1-6s é a soma de **três** custos, não um só. Em ordem de impacto:

1. **Handshake mTLS frio com HSM no caminho crítico (o custo estrutural).** No nosso desenho, a chave privada PIC **nunca sai do HSM da RTM**. Cada handshake TLS *completo* (não-resumido) ao BACEN faz o sidecar Go chamar `POST /v1/kmip/{vhsm}/sign-rsa` no HSM (`main.go:138-146`) — um round-trip de rede extra ao rtmcloud DENTRO do handshake. O legado assina o CertificateVerify **localmente**, com a chave em memória (`ConfigureEnvio` usa `x509Certificate2Sync.X509`, `SPI.Core.Mensageria.Geral.Application.NetCore.decompiled.cs:224,242`) — zero round-trip. Um handshake frio nosso ≈ TCP + TLS + HSM-sign; um frio do legado ≈ TCP + TLS (ms).

2. **A conexão sidecar→BACEN esfria porque a reutilização quente é sufocada.** O `http.Transport` do sidecar NÃO define `MaxIdleConnsPerHost` (`main.go:429-450`) → default do Go = **2 conexões idle por host**. Com 6 slots de long-poll (recepção) disputando essas 2 conexões idle e mais os envios, quase todo envio abre uma conexão NOVA (fria) ao BACEN. `IdleConnTimeout: 90s` é irrelevante: o gateway RSFN fecha a conexão idle em ~30s sem o pool perceber (documentado em `application.ex:184-189` e `hsm_keep_warm.ex:13-16`).

3. **Contenção do semáforo de conexões (lado Elixir).** `ConnSemaphore` limita a 6 permits por canal/ISPB (`client.ex:475`, BACEN §2.2.2.10). Os **6 slots de long-poll do CPM** e os **envios de pacs.008** disputam os MESMOS 6 permits do CPM. Um envio pode esperar até `acquire_timeout` = 3s por um permit (`client.ex:479`) antes de virar `:conn_limited` e failover. Esse wait está DENTRO do `post_ms` (`outbound_sender.ex:663-666` mede `Client.spi_request` inteiro, incluindo `with_bacen_conn`).

Os três compõem: no melhor caso o envio pega um permit livre + conexão quente/resumida (< 100ms); no pior caso espera 3s por um permit, depois abre uma conexão fria com round-trip ao HSM (mais 1-3s) — daí a cauda de 6s.

**O legado NÃO tem um keep-warm dedicado para o envio.** Ele é rápido no frio por três razões arquiteturais: (a) chave local (sem HSM no handshake), (b) sem o hop do sidecar, (c) conexão direta SslStream ao BACEN. A recepção do legado é um long-poll contínuo de 200ms igual ao nosso, mas em pool SEPARADO do envio (também não compartilham conexão) — ou seja, "recepção quente vs envio frio" é verdade nos DOIS sistemas; o diferencial é o **custo do frio**, não a partilha.

---

## 1. Diagrama do caminho de ENVIO (nosso) e onde o handshake frio acontece

```
OutboundSender.process_message                         [outbound_sender.ex:62]
  └─ do_dispatch_send  (gate → sign(HSM) → xsd → claim) [outbound_sender.ex:453-482]
       └─ send_with_budget → send_phase(:post_ms, …)    [outbound_sender.ex:657-667]  ← post_ms começa aqui
            └─ Client.spi_request(:post, path, xml)      [client.ex:202-252]
                 └─ do_request                            [client.ex:512-589]
                      └─ with_bacen_conn(gated=true,:cpm) [client.ex:439-460]  ← (A) SEMÁFORO: até 3s de espera
                           └─ ConnSemaphore.with_slot (Redis, cap 6/canal)     [client.ex:455]
                                └─ Finch.request(req, Shared.Finch)             [client.ex:633-638]
                                     │  finch_opts: receive_timeout=800ms(BACEN_TIMEOUT), pool_timeout=3000ms
                                     ▼
   ┌─────────────────────────── FINCH POOL (Elixir) ───────────────────────────┐
   │ URL do request = ICOM_PRIMARY_URL (env) = http://localhost:9103  (sidecar) │
   │ → NÃO casa a chave do pool mTLS (https://icom.pi.rsfn.net.br:16422)         │
   │ → cai no pool :default (size 25, count 2) — loopback, SEM TLS. Barato.      │
   └────────────────────────────────────────────────────────────────────────────┘
                                     │  HTTP plano sobre loopback
                                     ▼
   ┌───────────────────── SIDECAR Go (bacen_mtls_sidecar) ─────────────────────┐
   │ upstream icom=9103 → icom.pi.rsfn.net.br:16422           [main.go:540-542]  │
   │ http.Transport:  MaxIdleConns=32,  IdleConnTimeout=90s   [main.go:429-450]  │
   │                  (MaxIdleConnsPerHost NÃO setado → 2)     ← (B) POUCA QUENTE │
   │ TLS 1.2 pin, ClientSessionCache LRU (resumption ON)      [main.go:440-449]  │
   │ handshake COMPLETO → hsmSigner.Sign → POST sign-rsa ao HSM [main.go:138-146]│  ← (C) HSM NO HANDSHAKE FRIO
   └────────────────────────────────────────────────────────────────────────────┘
                                     │  mTLS ICP-Brasil
                                     ▼
                         BACEN ICOM/SPI  (gateway RSFN fecha idle ~30s)
```

**Em qual hop o handshake frio acontece: hop (C), sidecar → BACEN.** O hop Elixir→sidecar é loopback sem TLS (barato). A latência do `post_ms` mora em: (A) espera por permit no semáforo + (B) o sidecar não ter conexão quente reutilizável + (C) o custo do handshake completo incluir o round-trip ao HSM.

**Por que esfria** — três causas somadas, não uma:
- **(B) `MaxIdleConnsPerHost` default = 2** no sidecar: mesmo com o long-poll de recepção batendo no MESMO host icom a cada ~200ms, o Go só GUARDA 2 conexões idle por host. Os 6 slots de recepção, ao devolverem conexão ao pool, estouram o limite de 2 e as excedentes são FECHADAS — o próximo GET reabre frio. Sobra pouquíssima conexão quente para o envio pegar.
- **gateway RSFN fecha idle em ~30s** (`application.ex:184-189`): o `IdleConnTimeout:90s` do sidecar é maior que os 30s do gateway, então o sidecar acha que tem conexão viva mas está morta; reusar devolve `TransportError:closed` e força reconexão fria.
- **(A) semáforo de 6 permits** compartilhado entre 6 long-polls de recepção e os envios.

> Nota de verificação: confirmar na task-def PRD que `ICOM_PRIMARY_URL` aponta para o sidecar (`http://localhost:9103`). O `hsm_keep_warm.ex:13-16` afirma explicitamente que o `post_ms` frio é "pool ICOM, idle 30s fechado pelo gateway RSFN" e exige "tráfego real ao BACEN" via EchoProbeWorker — o que confirma que o mTLS de envio termina num pool que esfria (o do sidecar).

---

## 2. Como o LEGADO lida com o handshake frio (arquivo:linha)

Resposta honesta: **o legado NÃO tem keep-warm dedicado do envio.** Ele só paga um frio muito mais barato, e reusa conexão quando os envios são frequentes.

**2.1. Cliente HTTP do legado — pools SEPARADOS para envio e recepção, via IHttpClientFactory:**
- `AddHttpClientEnvio(...).ConfigurePrimaryHttpMessageHandler(ConfigureEnvio())` — cliente nomeado do ENVIO. `SPI.Core.Mensageria.Geral.Application.NetCore.decompiled.cs:374-380`.
- `AddHttpClientRetorno(...).ConfigurePrimaryHttpMessageHandler(ConfigureRetorno())` — cliente nomeado da RECEPÇÃO. `NetCore:390-395`.
- São handlers DISTINTOS → pools de conexão TCP DISTINTOS. Envio e recepção **não** compartilham conexão no legado tampouco.

**2.2. Handler do ENVIO — `ConfigureEnvio()`, ramo `SSLSocketHttpHandler` (`NetCore:229-259`):**
```csharp
new SocketsHttpHandler {
    ConnectTimeout = 90s,
    Expect100ContinueTimeout = Zero,
    SslOptions { ApplicationProtocols = Http11, EnabledSslProtocols = Tls12,
                 ClientCertificates = { x509Certificate2Sync.X509 } }   // ← CHAVE LOCAL, em memória
}
```
- **NÃO define `PooledConnectionIdleTimeout` nem `PooledConnectionLifetime`** → usa os defaults do .NET: **idle 1 min**, lifetime infinito, `MaxConnectionsPerServer` ilimitado. Ou seja: reusa uma conexão quente por até 1 min de ociosidade; sem cap por servidor.
- **A chave privada está em memória** (`x509Certificate2Sync.X509`, resolvida em `NetCore:224`) — o CertificateVerify do handshake é assinado LOCALMENTE pelo SslStream. **Nenhum round-trip a HSM.** Esta é a diferença estrutural com o nosso sidecar.
- **Sem hop de sidecar**: o SslStream do .NET conecta direto ao BACEN.

**2.3. Handler da RECEPÇÃO — `ConfigureRetorno()` (`NetCore:290-316`):** idêntico, mais `AutomaticDecompression = GZip|Deflate`. Mesmos defaults de pool.

**2.4. Políticas (`AddHttpClientBuilderPolicies`, `NetCore:422-440`):** Polly retry (`NumeroTentativasOnErrorHttp`, intervalo `IntervaloTentativasOnErrorHttp`=2s) + fallback que, se `ReiniciarAposErroHttp`, chama `Environment.Exit(0)` — reinicia o processo em falha persistente (mantém tudo "fresco" via restart, não via keep-warm). **Não** chama `SetHandlerLifetime`, então o handler roda com lifetime default de 2 min: o pool é reconstruído a cada 2 min (custa 1 frio a cada 2 min — desprezível para um loop contínuo).

**2.5. Conclusão sobre o legado:** ele NÃO evita o frio com pool persistente especial nem heartbeat < 30s no envio. O envio (`EnvioApiBacen`, pausa de fila 2000ms — `SPI.Core.Worker.EnvioApiBacen.Application.decompiled.cs:75`) esfria igual à nossa quando os envios ficam esparsos (> 30s → gateway RSFN mata). A diferença é que **o frio do legado é barato** (chave local, sem sidecar). Existe um TESTE_CONECTIVIDADE / pibr.001 (`SPI.Core.WinForms.TesteConectividade.Lib` + `EnvioAutomatico` trata `CdMsg=="PIBR.001"`, `SPI.Core.Worker.TarefasAutomaticas.Application.decompiled.cs:239,562`), mas é dirigido por config de automação (`ParametroAutomacaoMensagem`), não um keep-warm garantido < 30s do canal de envio. Foi dele que portamos o nosso `EchoProbeWorker`.

---

## 3. Diferenças de RECEBIMENTO: legado vs nosso long-poll ICOM

| Aspecto | LEGADO (.NET) | NOSSO (Elixir) |
|---|---|---|
| Mecanismo | `ThreadConsumoMultipartApiBacen` — loop contínuo `_receiveService.Receive(ispbIF, nextAnt)` com cursor `nextAnt` (multipart). `SPI.Core.Worker.RetornoApiBacen.Infrastructure.decompiled.cs:1412-1450` | `Icom.Cpm.Worker` / `Csm.Worker` — long-poll `stream/start` + `pull_next` (cursor `PI-Pull-Next`), multipart. `icom/cpm/worker.ex` |
| Cadência entre polls | pausa **200ms** (`RetornoApiBacen.Application.decompiled.cs:88`, `_milissegundosPausaThread=200`) | pausa **200ms** no 204 (`worker.ex:78` `@cpm_idle_sleep_ms 200`); tick imediato quando há dados |
| Paralelismo | thread(s) de consumo | **6 slots** por canal (`icom/cpm/coordinator.ex:93` `@max_slots 6`), líder único |
| Pool de conexão | cliente RETORNO separado do ENVIO | **compartilha o sidecar** (mesmo host icom:16422 → upstream 9103) MAS o Elixir gateia recepção e envio no MESMO semáforo de 6 permits/canal (`client.ex:439-460`) |
| Timeout do poll | ConnectTimeout 90s; poll segura conexão até resposta | `@icom_default_timeout` **15s** (`client.ex:64`, reduzido de 60s "para liberar o permit para os envios") |

**A hipótese-chave do dono, respondida:** o recebimento nosso mantém conexão quente ao host icom (long-poll a cada ~200ms). O envio de pacs.008 vai ao MESMO host icom → MESMO upstream do sidecar (9103) → então **em princípio o envio PODE reusar a conexão quente da recepção**. Mas dois estrangulamentos impedem:
1. **Sidecar `MaxIdleConnsPerHost=2`**: só 2 conexões quentes idle são guardadas; 6 slots de recepção as consomem/fecham, sobra ~0 para o envio → o envio abre frio.
2. **Semáforo de 6 permits/canal compartilhado**: quando os 6 long-polls seguram os 6 permits do CPM (o `client.ex:59-63` assume que um poll segura o permit por até 15s), o envio espera até 3s por um permit e faz failover. Não há permit dedicado ao envio.

Ou seja: **não é "pool de envio separado e frio"** — é pool COMPARTILHADO no sidecar, mas com reuso quente sufocado (2 idle/host) e com gate de concorrência disputado (6 permits divididos com 6 leitores). E o `ICOM_MAX_SLOTS=6` (subido de 4→6 em 21/07) removeu a folga de conexão para envios que o próprio comentário do pool exige: `application.ex:57-59` "keep slots < size to leave headroom for sends within the 6-conn limit" — hoje slots(6) == size(6) == cap(6), folga = ZERO.

---

## 4. Mudanças priorizadas para o post_ms cair de 1-6s para < 100ms

| # | Mudança | Onde (arquivo:linha) | Impacto | Risco |
|---|---|---|---|---|
| 1 | **Sidecar: `MaxIdleConnsPerHost = 10` (e `MaxConnsPerHost = 8`)** no `http.Transport`. Guarda conexões quentes suficientes para os 6 slots de recepção E deixa idle quente para o envio pegar. Hoje default = 2. | `main.go:429-450` (adicionar `MaxIdleConnsPerHost: 10`) | **ALTO** | Baixo — respeita o cap 6/canal via semáforo; só muda quantas conexões quentes o sidecar guarda. |
| 2 | **Ligar EchoProbeWorker com intervalo < 30s** (`ECHO_PROBE_ENABLED=true`, `ECHO_PROBE_INTERVAL_MS=20000`). O echo pibr.001 vai pela ROTA DE ENVIO (spi_request POST) → mantém uma conexão do host icom quente e reutilizável pelo pacs.008, independente da disputa com os slots de recepção. É o que o `hsm_keep_warm.ex:13-16` recomenda para o "irmão do post_ms frio". | `echo_probe_worker.ex:66` (flag) e `:52` (intervalo); env na task-def | **ALTO** | Baixo — gera tráfego real ao BACEN (custo de token ICOM por op); decisão operacional. Fail-safe já embutido. |
| 3 | **Devolver folga de conexão ao envio**: reduzir slots de recepção do CPM de 6 → 3-4 (`ICOM_CPM_MAX_SLOTS`) OU dar orçamento de permit dedicado ao envio no `ConnSemaphore`. Alinha com a regra documentada "slots < size". | `coordinator.ex:93`/env `ICOM_CPM_MAX_SLOTS`; cap em `client.ex:475`; semáforo `conn_semaphore.ex` | **MÉDIO-ALTO** | Médio — troca vazão de recepção por latência de envio. Apresentar como tradeoff; a subida 4→6 de 21/07 provavelmente PIOROU o envio. |
| 4 | **Pré-aquecer no sidecar (keep-warm próprio)**: goroutine que faz um GET barato (ou reusa o SELFTEST) por upstream a cada ~20s, mantendo a conexão quente sem depender do lado Elixir. Complementa/substitui o echo probe se não quiser gerar tráfego BACEN de negócio. | `main.go` (novo goroutine ao lado do health em `:606-625`) | **MÉDIO** | Baixo-Médio — precisa de um endpoint BACEN aceitável para GET frequente (o long-poll de recepção já é isso, então o ganho principal é para o CSM/idle). |
| 5 | **Confirmar/forçar TLS session resumption**: com resumption ON e conexões quentes (fix 1), a maioria dos envios reusa conexão ESTABELECIDA (zero handshake) ou faz handshake RESUMIDO (sem HSM). Verificar `resumed>0` nos contadores `[tls icom]` (`SIDECAR_TLS_STATS_EVERY`). Se o gateway RSFN não emitir tickets, resumption é no-op e só o fix 1 salva. | `main.go:440-449` (já ON); observabilidade `main.go:349-368` | **MÉDIO** | Nenhum (só medição) — mas depende do gateway RSFN emitir tickets. |
| 6 | **HTTP/2 multiplexing — investigar, provavelmente indisponível.** Se o ICOM aceitasse HTTP/2, uma conexão quente carregaria sends + long-polls sem head-of-line. Hoje o sidecar força HTTP/1.1 (`ForceAttemptHTTP2:false`) e o legado fixa Http11 — indício forte de que o BACEN só fala HTTP/1.1. Registrar como beco sem saída até prova contrária. | `main.go:431` | — | — |
| 7 | **conn_max_idle_time no pool Elixir→sidecar**: NÃO é o gargalo (loopback sem TLS). Não mexer. | — | Nenhum | — |

**Sequência recomendada (fail-safe, incremental):** #1 (sidecar MaxIdleConnsPerHost) → #5 (medir resumed) → #2 (echo probe <30s) → #3 (folga de slots, com o dono, medindo antes/depois). #1+#2 já devem levar o caso quente para < 100ms; #3 remove a cauda do semáforo.

**O que NÃO resolve sozinho:** trocar timeouts (BACEN_TIMEOUT já é 800ms), mexer no pool localhost, ou só ligar o echo com intervalo de 5min (default atual — inútil, > 30s). E: enquanto a chave PIC ficar no HSM (decisão de segurança correta), o handshake COMPLETO sempre custará o round-trip ao HSM — por isso a estratégia é **nunca fazer handshake completo no caminho crítico**: manter a conexão sempre quente (1,2,4) e resumida (5).

---

## 5. Números de referência coletados

- Nosso: `sign_ms` 36-760ms (HSM frio p/ assinar XML, mitigado por `HsmKeepWarm` 20s), `xsd_ms` 4-5ms, **`post_ms` 1017-5775ms** (o gargalo). `BACEN_TIMEOUT=800ms` (`runtime.exs:694`), pool_timeout 3s, semáforo acquire 3s / lease 30s / cap 6 (`client.ex:475-479`).
- Legado: RETORNO pausa 200ms (`RetornoApiBacen.Application:88`); ENVIO pausa de fila 2000ms (`EnvioApiBacen.Application:75`); ConsultaOperacao pausa 60000ms (`EnvioConsultaOperacao.Application:377`); pool idle default 1 min; chave em memória (sem HSM); Polly retry 2s + Environment.Exit em falha persistente.
