# Design: long-poll ICOM da cabine PIX com cap GLOBAL de 6 conexões (Redis) e desired=3

**Data:** 2026-07-21. **Mandato do dono:** garantir o teto do BACEN de 6 conexoes simultaneas por canal por ISPB de forma GLOBAL (coordenado no Redis, sem brecha, sem exceder), `desired=3` no minimo, usando o `coreproviders`/OnZ como referencia (ja opera milhoes/dia). Sem teste de carga neste momento; desenhar e resolver todos os pontos.

## 1. Problema (empirico)

- Pool Finch do canal primario (CPM 16522) hoje: `size: 6, count: 1` **por task** (`pix/backend/apps/shared/lib/shared/application.ex:95`). Com `desired=1` respeita o teto de 6. Com `desired=3` sem coordenacao = ate **18 conexoes** ao BACEN = 3x o limite -> HTTP 429 e o inbound inteiro morre (o proprio codigo documenta isso ter acontecido com `count:10`, `application.ex:49-58`).
- Regra BACEN: Manual das Interfaces v1.12 SPI/ICOM §2.2.2.10 = **6 threads long-poll simultaneas por participante (ISPB)**. O mesmo teto que o coreproviders respeita.
- Conclusao: o teto de 6 tem que ser **GLOBAL entre as 3 tasks**, autoritativo ANTES de cada request ao BACEN. Ordem obrigatoria: **cap global primeiro, desired=3 depois**.

## 2. Referencia validada: coreproviders/OnZ (blueprint portavel)

O `coreproviders` (AVIV, Elixir/OTP/Redis, opera milhoes/dia via OnZ) resolve exatamente isto com **slots distribuidos no Redis**, SEM eleicao de lider e SEM SPOF. Pecas (todas em `/Users/luizpenha/coreproviders/backend/lib/fluxiq/services/pix_providers/onz/`):

1. **Cap por slots Redis (SETNX + lease)** — `poller.ex:446-499`:
   - Chave `onz:lp:slot:{entity_id}:{slot}`, `slot ∈ 1..6`.
   - Aquisicao: `SET key pod_id NX PX lease_ttl`. Itera 1..6, primeiro `OK` vence; resto vira **standby**. Chave por-ISPB = granularidade exata do teto.
   - `max_active_pollers` default **6**, travado em `1..6` no boot (`runtime.exs:1113-1117`, fail-fast; nenhuma env sobe acima de 6).
2. **Lease + heartbeat FORA da mailbox do GenServer** (`poller.ex:580-629`): `SET key pod_id XX PX lease` renova so se ainda for dono; perder o `XX` -> standby imediato. Failover automatico por expiracao de TTL.
3. **Invariantes de seguranca validados no boot**: `receive_timeout(10s) < lease(15s)` (heartbeat nunca expira mid-poll) e `lease >= 3×heartbeat` (>=3 chances de renovar). Abortam o startup se violados.
4. **Pipelining assincrono** (`poller.ex:214-248`): o GET long-poll roda num `Task.start` separado, o GenServer fica livre durante o hold de ~5s; ao terminar, `{:lp_done, result}` agenda o proximo poll. (A versao sincrona derrubava 37/100 PIX-IN a conc=1.)
5. **1 pool Finch DEDICADO por slot** (`application.ex:301-311`): `onz_lp_pool_1..6`, cada um `size=1, count=1, http1`. Motivo: o cursor do LP e **stateful por TCP** — um pool unico sorteava TCP diferente entre o ACK e o proximo GET e quebrava a afinidade cursor↔TCP. `size=1 count=1` fixa o keep-alive.
6. **Scheduling alinhado ao relogio (NTP)** com stagger (`poller.ex:644-675`): slot N pola em multiplos de `cycle_ms` com offset `(N-1)*stagger`, ancorado no UNIX time (nao em quando o processo subiu). 6 slots escalonados cobrem o ciclo continuamente e sobrevivem a restart/handover. Floor anti-thundering-herd.
7. **Cursor avanca so apos persistencia duravel** (inbox Postgres) + **dedup por unique constraint** (`(message_id, source)`) + **recovery sweep** (`*/1min`, presos em `processing`>5min voltam) + **poison policy bounded** (N falhas -> DLQ, cursor avanca; nunca silent drop nem trava eterna) + **reconciliacao pos-deploy contra a verdade do BACEN** (MGMT CONCLUIDA vs Postgres) + **watchdog de liveness** lendo as proprias chaves de slot no Redis (`active_slots==0` por N min = CRITICAL).
8. **DELETE do stream no `terminate/2`** (`poller.ex:372-440`): drain limpo no deploy, evita re-entrega duplicada.
9. **Rate limit DICT**: token bucket distribuido via **Lua/EVAL no Redis** (`dict_bucket/redis_store.ex:19-55`), tabela A-H do BACEN, refill lazy, fail-open.
10. **Login lock** (`auth.ex:195-240`): `SET onz_login_lock:{entity} node NX EX 30` — um pod loga, os outros leem o token do Redis (evita N logins paralelos invalidando o token).

**Latencia medida em PRD (coreproviders):** P50 471ms / P95 561ms / P99 737ms (1256 tx/60min). Dimensionamento: 6 workers, ~12-30 msg/s agregado; cobertura continua com `cycle=4167 stagger=694`.

**Diferenca de contexto:** coreproviders fala com o OnZ (HTTP que abstrai o RSFN); a Monetarie fala DIRETO com o BACEN (ICOM/SPI mTLS). O mecanismo (slot Redis + lease + pool-por-slot + reconciliacao) e 100% reaproveitavel; muda o transporte (o `GET /v3/lp/{cursor}` do OnZ vira o long-poll ICOM nativo do BACEN, com a mesma disciplina de cap 6, cursor stateful por TCP e ACK).

## 3. Nosso estado atual (mapeado)

- **Long-poll ICOM**: `apps/spi_service/lib/spi_service/icom/cpm/worker.ex` (gemeo CSM). Um GenServer por `(ispb, canal, slot)`. GET long-poll via `Shared.Bacen.Client` (timeout **60s**, `client.ex:57`), multipart -> `AckTracker.persist_batch` (invariante PRE-ACK) -> `NatsBridge.publish_batch` -> reagenda. 204 dorme 200ms; 404/410 reabre stream; erro -> backoff exponencial capado em 30s.
- **INBOUND JA E SEGURO para desired>1**: os workers de long-poll so rodam no LIDER (eleicao por `pg_try_advisory_lock`, singleton de cluster; `cpm/coordinator.ex:315-335`). CPM e CSM com chaves distintas. Standby abre ZERO conexao de long-poll. `ICOM_MAX_SLOTS=4` (4 long-poll, 2 de folga p/ envio) capado em 6 (`application_supervisor.ex:121-128`). Retomada por cursor `last_pull_next` (`worker.ex:161-196`) + `Readiness` (nao assume o lock antes do sidecar mTLS subir; fix 13/07 da janela surda) + `PendingRepublisher` (30s).
- **O GAP REAL = OUTBOUND (nao leader-gated)**: `OutboundSender` e um consumidor NATS `deliver_group` que roda e balanceia em TODOS os pods (`outbound_sender.ex:34-38`, `max_concurrency:50`). Cada pod envia pelo pool Finch por-pod `size:6` (`application.ex:95`). desired=3 estavel -> ate **18 conns** ao CPM; durante deploy (max 200% = 6 tasks) -> ate **36**. Reproduz o cenario de 429 em massa (`application.ex:49-58`). `OutboundBatch`, `PaymentStatusReconciler`/camt.060, `ReturnProcessor` idem.
- **Chokepoint UNICO** de todo request ao BACEN: `Shared.Bacen.Client.do_request` (`client.ex:451`) e `do_icom_request` (`client.ex:393`) — envolve `Finch.request`. E o lugar exato do `acquire`/`release` do semaforo.
- **Redis**: `Shared.Redis.Connection` (pool Redix 10), `command`/`pipeline`, ja usa **Lua `EVAL`** (`connection.ex:87-142`, token bucket atomico). Nenhum semaforo de conexao distribuido existe. `Shared.Bacen.DictBudget` = token bucket distribuido (rate, nao conexao) — modelo mais proximo.
- **Rate por-pod local** (nao serve de teto global): `Shared.Bacen.IcomTokenBudget` (ops/s, §2.2.1.5 CPM 3750/750, modo observe). O limite de OPERACOES (3750/s = 324M/dia) NAO e o gargalo; o de CONEXOES (6) e — logo a chave e usar as 6 com EFICIENCIA (recepcao em lote via multipart, keep-alive nos envios), nao desperdicar.
- **Drain**: `Shared.Infra.Drainer` cobre HTTP (so no settlement_service :4003), NAO o long-poll ICOM. **`stopTimeout` NAO esta no Terraform** (drift; default Fargate 30s < 5s+90s do drain) — consolidar antes de desired=3.
- **desired_count**: `infra/aws/greenfield/variables.tf:484` default **1**; aplicado em `ecs.tf:580` (Fargate). `deployment_minimum/maximum_percent` nao setados -> default 100/200 (deploy dobra pods).

## 4. Solucao proposta (porte do blueprint, precisa)

1. **`Shared.Bacen.ConnSemaphore`** (novo): semaforo distribuido de N=6 permits por `(canal, ispb)` no Redis via **Lua `EVAL` atomico**, com **lease TTL** (crash de pod libera o permit sozinho). Modelo coreproviders: `acquire` itera slot 1..6 com `SET slot NX PX lease`; primeiro OK vence; `release` = `DEL`; heartbeat opcional `SET XX PX` p/ permits de vida longa (long-poll). Chave `sem:icom:{canal}:{ispb}:{slot}`.
2. **Wire no chokepoint** `Shared.Bacen.Client`: TODO request ICOM (long-poll GET + envios) faz `acquire` antes do `Finch.request` e `release` depois. **fail-CLOSED** sob contencao (recusa/re-tenta com backoff curto; fail-open reabriria o risco de 429 — decisao consciente, diferente do DictBudget que e rate e fail-open).
3. **Reserva 4 long-poll + 2 envio GLOBAL** (nao por-pod): os 4 workers do lider seguram 4 permits de vida-longa (com heartbeat); os envios de qualquer pod contendem pelos 2 restantes com keep-alive (envio ~300ms). Tunavel; o `ICOM_MAX_SLOTS=4` atual vira o split do orcamento GLOBAL.
4. **Cap travado em 1..6 no boot** (fail-fast) + invariante lease > timeout do long-poll (60s) para permit de long-poll nao expirar mid-poll — ou permits de long-poll SEM lease-expiry, renovados por heartbeat curto (modelo coreproviders: `receive_timeout(10s) < lease(15s)`; nosso long-poll e 60s, entao ou lease > 60s ou reduzir o timeout do long-poll ICOM para ~10-15s e reagendar — decisao de tuning no §5).
5. **desired=3** SO depois de (1)-(4) provados com desired=1/2 (Redis nunca mostra >6 permits). Consolidar `stopTimeout` no TF e capar `deployment_maximum_percent` (ou confiar que o semaforo global ja cobre o transiente de 6 tasks — cobre, pois o cap e global).
6. **Drain**: `terminate` libera permits + fecha sessao (ja existe `graceful_close`); reconciliador PixInOrphan em modo ATIVO como rede.
7. **Metricas**: permits em uso por canal (gauge lendo o Redis), rejeicoes de acquire, latencia, lag; watchdog de liveness (permits==0 anormal).

## 5. Ordem de execucao (fail-safe)

1. `Shared.Bacen.ConnSemaphore` (TDD, Redis Lua, lease TTL, cap 1..6 boot-guard).
2. Wire no `Shared.Bacen.Client` (acquire/release em `do_icom_request`/`do_request`), com metrica. Deploy `desired=1` — prova ZERO regressao (o cap de 6 num pod so e transparente) e o Redis mostrando os permits.
3. Tuning do split long-poll/envio + timeout do long-poll vs lease (decidir 60s->lease>60s OU reduzir para ~15s estilo coreproviders).
4. Consolidar infra: `stopTimeout` no TF, `deployment_maximum_percent`, drain do long-poll no terminate, reconciliador ATIVO.
5. `desired=2` -> observar Redis (permits <= 6 sempre) -> `desired=3`.
6. Alertas + watchdog.

**Invariante que garante o mandato:** permits globais no Redis <= 6 por canal SEMPRE, mesmo com 3 (ou 6, no deploy) pods, porque o semaforo e distribuido e autoritativo antes de cada `Finch.request`. Sem brecha, sem exceder o BACEN.

## 6. PROVA VIVA em PRD (21/07) e ACHADO que BLOQUEIA desired=3

**Deployado e provado (pix-api PRD:69, imagem `prod-f3511fae-connsem`):**
- Semaforo ATIVO no Redis de PRD (nao fail-open): in_use cpm=4/csm=2 (os workers de long-poll segurando permit); acquire->1->release->0 sem vazamento.
- **Canais INDEPENDENTES provados ao vivo**: CPM enche em 6/6 (7o recusado) enquanto o CSM fica em 0 e depois enche nos SEUS proprios 6. Total 6 CPM + 6 CSM (6 por canal, correto). NAO mistura, NAO dobra. (Refuta a hipotese do dono de que o desenho nao distinguia canais — o desenho ANTIGO Finch size:6 por-pod e que dobrava com desired>1; o semaforo conserta.)
- **desired=2 e desired=3 provados**: permits globais <= 6 por canal com 3 pods (cpm=4/csm=2), ZERO 429, ZERO conn_limited. O cap global holda.
- O 429 no handover deste deploy foi UNICO (task velha SEM semaforo + task nova, sobrepostas); cessou 100% quando a velha saiu.

**ACHADO que BLOQUEIA desired=3 (nao e o semaforo — e outros workers):** com desired>1, `SettlementService.Workers.Scheduler` e `FileImporter` emitem `JetStream request failed: :timeout` PERSISTENTE (0 a desired=1, dezenas/min a desired=3). Eles NAO sao HA-safe (rodam em todos os pods sem leader-gate, ao contrario do ICOM Coordinator que tem advisory lock). Rollback para **desired=1** (estado provado limpo).

**Pendencia para fechar desired=3 (proximo passo):** leader-gatear (ou tornar idempotentes/HA-safe) TODOS os workers periodicos do SettlementService que hoje rodam em todos os pods — auditar Scheduler, FileImporter, e os demais nao-ICOM. So depois disso desired=3 e seguro. O semaforo (o cap de 6) esta pronto e provado; o bloqueio e a HA-safety desses workers.

## 7. SOLUCAO do bloqueio de desired=3: leader-gate dos singletons de liquidacao (21/07, fail-proof)

O bloqueio do §6 NAO era o semaforo (o cap de 6 estava provado). Era que 3 workers
singleton por natureza rodavam nos 3 pods.

**Baseline empirico (PRD pix-api:70, desired=3, janela de 6h, read-only CloudWatch):**
- `Push consumer creation failed, retrying`: **386** ocorrencias.
- `JetStream request failed: :timeout`: **386** ocorrencias.
- Discriminado por worker: **193 `SettlementService.Workers.Scheduler` + 193 `FileImporter`**.
- ZERO vindos de `CoreEventProcessor`/`SettlementObligationWorker` — ou seja, o
  check-first do `581ec393` JA resolveu os distribuidos; o residual era exatamente
  estes dois singletons criando o MESMO consumer no `MONETARIE_SETTLEMENT` nos 3
  pods (contencao de `$JS.API.CONSUMER.DURABLE.CREATE`).

**Fix (`Shared.ClusterSingleton`, commit `7952b8ac`, TDD 5/0 + 3/0):**
- Roda um conjunto de children em EXATAMENTE 1 pod (lider), via lock de lider no
  Redis com lease (mesmo padrao do `ConnSemaphore`: `SET NX PX` + heartbeat
  `SET XX PX`). Morte do pod libera por TTL (30s) e a lideranca reassume sozinha
  (HA sem SPOF). Redis fora = fica follower e loga alto (nao silencioso).
- `Workers.Supervisor`:
  - DISTRIBUIDOS em todos os pods (queue-group = HA + vazao p/ 10M/dia):
    `SettlementObligationWorker` (settled->obrigacao) e `CoreEventProcessor`
    (payment_request). Continuam iguais.
  - SINGLETONS sob um `ClusterSingleton` (1 pod): `Scheduler`, `CycleScheduler`,
    `FileImporter`. Os consumers do `MONETARIE_SETTLEMENT` passam a ser criados por
    1 pod so -> zero contencao -> zero timeout.
- Money-safety: as obrigacoes de liquidacao seguem gravadas pelos workers
  distribuidos (nao dependem do lock); o netting e protegido por constraint de
  banco `(cycle_date, cycle_type)`. Split-brain curto (blip de Redis) nao duplica
  dinheiro.

**PROVA VIVA em HML (pix-api:200, imagem `homolog-7952b8ac-failproof`, desired=3):**
- Baseline: 386 timeouts em 6h (193 Scheduler + 193 FileImporter) no PRD pre-fix.
- Depois (HML, janela steady 130s E janela cheia 15min incluindo boot+handover):
  **0** `Push consumer creation failed`, **0** `JetStream request failed`.
- Eleicao de lider ESTAVEL: 1 unico `assumiu lideranca` (pod 5ca7c9a9), 0 `PERDEU`.
- rpc por pod: LIDER (5ca7c9a9) `scheduler=vivo`; FOLLOWERS (020117f8, 334a303a)
  `scheduler=nil`, `cycle=nil`, `coreEvent=true` (money-path distribuido roda em
  todos). => exatamente 1 pod roda os singletons; os 2 outros nao.
- Semaforo GLOBAL consistente: os 3 pods leem `cpm=4 csm=2` (<=6) do Redis; 0 429
  real; 0 conn_limited. TG 3/3 healthy.
- Os 3 streams que apareciam para `settlement-scheduler` na janela de 15min eram o
  image ANTIGO (:199, 0 logs de ClusterSingleton) drenando no handover — nao flap
  de lideranca.


## 8. SATURACAO 6+6: ICOM_MAX_SLOTS estava travado em 4 (achado + fix, 21/07)

Ao provar o cap de 6, o dono cobrou "6 ATIVOS no cpm e csm agora". Medi a verdade:
o `in_use` reportado (cpm=4 csm=2) era snapshot de permits (workers mid-GET), MAS
o banco confirmou o defeito real — so **4 sessoes abertas por canal**, nao 6:
- `icom_sessions` status=open: CPM=4, CSM=4 (slots 1..4); pod lider com 4+4 workers.
- Causa-raiz (config, nao inferencia): o env da task-def travava
  `ICOM_MAX_SLOTS=4` (HML tinha CPM/CSM/geral=4; PRD tinha geral=4). O codigo
  default e 6 (`@max_slots 6` nos coordinators + `configured_max_slots` default "6"),
  mas o env sobrepunha para 4. Cada coordinator e cluster-singleton (advisory lock
  PG) e sobe `1..max_slots` workers no pod lider.

**Fix:** `ICOM_CPM_MAX_SLOTS=6`, `ICOM_CSM_MAX_SLOTS=6`, `ICOM_MAX_SLOTS=6` nas
task-defs (HML :201, PRD :72; sidecar pix-mtls preservado no PRD; leader-gate junto).

**PROVA VIVA pos-deploy (HML :201 e PRD :72, desired=3):**
- ENV_ICOM_MAX_SLOTS=6 nos dois; `icom_sessions` open **CPM=6 CSM=6** nos dois.
- Pod lider ICOM com **6 workers CPM + 6 CSM vivos**; os outros 2 pods com 0
  (coordinator singleton — nao duplica entre pods).
- PRD in_use CPM SATURA em 6 (`[{6,4},...]`, max cpm=6); CSM 6 sessoes abertas
  (snapshot 4/6 por ter menos trafego). Zero slots em "error".
- Semaforo continua capando em 6 por canal (7o recusado, §7) — 6 workers <-> no
  maximo 6 permits, nunca o 7o. Handover protegido pelo cap global.
- Leader-gate da liquidacao intacto no :72: 0 timeouts JetStream, 1 lider.

**Estado final:** 6 CPM + 6 CSM sessoes ativas por ambiente, capadas em 6 pelo Redis,
com desired=3 e zero timeout JetStream. Mandato do dono atendido e provado.
GOTCHA de ops: o `ICOM_*_MAX_SLOTS=6` vive na task-def; deploy futuro que parta de
template antigo (=4) reverte — sempre basear na revisao viva (HML>=201, PRD>=72).
