diff --git a/docs/changelog/2026-09-25-instagram-cdn-ipv4-instavel.md b/docs/changelog/2026-09-25-instagram-cdn-ipv4-instavel.md new file mode 100644 index 00000000..d5a5343e --- /dev/null +++ b/docs/changelog/2026-09-25-instagram-cdn-ipv4-instavel.md @@ -0,0 +1 @@ +**Instagram na Central: retry no download + rede IPv6 no stack (2026-09-25):** o sintoma do C220-FOLLOWUP persistiu em produção porque a causa real era **rede**: o container de produção é IPv4-only e a rota IPv4 até o CDN do Instagram (`instagram.fssa2-1.fna.fbcdn.net`) está instável nesta rede (host e container com ~3/8 de sucesso; `ETIMEDOUT`), enquanto o IPv6 funciona 100% — o resolver baixava com **uma** tentativa e virava `indisponivel`; o mesmo caminho quebrava as capas IG da home (otimizador `/_next/image` → 500) e algumas chamadas do feed. Correção de infra (fora do repo, no `~/stack/docker-compose.yml` do homeserver): rede `teqo-ipv6` (`enable_ipv6`, ULA `fd00:1313:1313::/64`) anexada a `teqo-1313`/`teqo-staging` (backup `docker-compose.yml.pre-ipv6-`; rollback documentado no post-mortem) — verificado em prod: 10/10 downloads do CDN dentro do container e `/_next/image` de capa IG em `200` (era 500). Correção de código no mesmo PR: `downloadContentPieceMedia` (src/utilities/content/contentPieceLink.ts) faz até 6 tentativas com teto de 15 s na fase de conexão/cabeçalhos (o corpo conectado mantém o orçamento de 3 min), sem repetir resposta não-2xx; tentativas esgotadas seguem `indisponivel`. Verificação: int "retries a stranded media download and still extracts the piece" com RED provado sem o fix (1 tentativa) e verde com (4), int contentPiece 39/39, `pnpm gate:fast` verde (unit full 4669). Runbook de deploy ganha a nota da rede; post-mortem em `docs/postmortems/2026-09-25-instagram-cdn-ipv4-instavel.md`. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 29012e10..189aa89d 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -305,28 +305,29 @@ a correção se re-mergeia e o próximo deploy completo publica. ## Falhas conhecidas -| Sintoma | Causa | Tratamento | -| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Prod regride para um SHA antigo após re-dispatch fora de ordem | O `flock` serializa mas não ordena; o "already deployed" não pega revision diferente | Evitar re-dispatchar SHA antigo (o requeue da OPS104 sempre despacha o head atual); se acontecer, re-dispatchar o `main` atual (rollback manual disponível na seção Rollback). Revisitar se uma regressão fora-de-ordem acontecer mesmo com o requeue | -| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: run duplicado da mesma HEAD — `workflow_dispatch`, ou push + requeue da OPS104) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: localhost:5000/teqo-1313:-rebuild`) e `docker compose up -d`, ou espere um commit novo | -| Merge durante aprovação pendente de produção: staging do run novo ficava `pending` (resolvido) | O job `waiting` de approval **segurava o grupo** `deploy-homeserver` (a avaliação de concurrency acontece antes do approval — comportamento do GitHub) | **Resolvido na OPS107:** o `concurrency` vive só no `deploy-staging` (que nunca espera approval); o `deploy-production` não ocupa lane, então runs novos fazem staging e ficam aprováveis. Aprove só o run escolhido e rejeite os demais — runs não aprovados não publicam; multi-approve = o último deploy executado vence (rollback manual se um SHA indesejado publicar) | -| Merge durante `deploy-production` `in_progress` não gera requeue | O push nasce, o `preflight` vê o deploy ativo e pula; o requeue daquele run já rodou (logo após o staging) | Nada nasce até o próximo merge ou `workflow_dispatch`. Revisitar na primeira ocorrência real → 2º requeue após produção verde, ou refinar o guard | -| Job de deploy nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só os deploys | -| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado | -| `No such image` ao recriar o container fora de deploy (ex.: `compose up -d` pós-reboot/migração) | O compose referenciava uma tag local bare (`teqo-1313:`) que desapareceu sem recriação (achado INF8/INF13, 24/08: só restava a ref do registry) | Corrigido no INF13: o compose passa a referenciar a tag **qualificada** (`localhost:5000/teqo-1313:`) a partir do primeiro deploy pós-fix — até lá, produção ainda roda o compose bare (recrie o alias via `docker tag` se necessário). Recovery geral: `docker pull localhost:5000/teqo-1313:` (+ `docker tag` se o compose em uso tiver tag bare) e `docker compose up -d` | -| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy | -| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat do ambiente (prod `teqo-1313-build-proxy` em 127.0.0.1:5433; staging `teqo-staging-build-proxy` em 127.0.0.1:5434, na `stack_default`, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback | -| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel | -| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:` e re-dispatch do workflow | -| Workspace clone sem o SHA novo | O workspace antigo apontava para o Forgejo local (pré-OPS71) e o fetch não achou o SHA | O script agora re-aponta `origin` para `TEQO_REPO_URL` (idempotente) antes do fetch — se ainda falhar, `git -C ~/teqo-deploy remote -v` e corrigir manualmente | -| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear | -| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar | -| `deploy-staging` falha cedo (`teqo-staging.env`/serviço ausente) | Bootstrap do staging incompleto (DB, env file, serviços no compose ou environment `staging`) | Rodar o bootstrap da seção Staging; produção nunca é tocada (staging vermelho fail-closa o job `deploy-production`) | -| `verify` vermelho | Regressão real na suíte full (ou flake — #882/#906) | O `auto-unblock.yml` dispara o agente sozinho (OPS106): acompanhe a Issue `auto-unblock` (token) e o PR `fix/*`. Sem PR, o token fica `blocked` — inspecione o log em `~/teqo-unblock/logs/`. Correção manual segue possível (re-merge ou `workflow_dispatch`) | -| Agente de desbloqueio não disparou (token inexistente) | Bootstrap do homeserver incompleto — opencode sem auth, Node/pnpm fora do PATH do runner, PAT ausente/sem push, Docker/porta 5432, label ausente | Rodar `pnpm unblock:check` no homeserver (com `GITHUB_TOKEN` e `AUTOMERGE_PAT` exportados) e seguir a seção Auto-unblock (OPS106) abaixo | -| Launcher `auto-unblock` morre em `fetch failed` (nenhum token criado) | Node fetch ignora HTTP(S)\_PROXY sem NODE_USE_ENV_PROXY (Node ≥24); no homeserver o GitHub só sai pelo proxy CONNECT do runner | OPS126: `scripts/auto-unblock.sh` liga o `NODE_USE_ENV_PROXY` quando há proxy no env; manter `HTTPS_PROXY/HTTP_PROXY` no `~/actions-runner/.env`. Re-dispatch ou aguarde a próxima falha do `verify` | -| Agente morre com exit 127 (`opencode`: No such file or directory) | Serviço do runner não carrega o profile → `~/.opencode/bin` fora do PATH; ou opencode nem instalado | OPS126: `resolveOpenCodeBinary` (`OPENCODE_BIN` → `$HOME/.opencode/bin` → PATH); o launcher falha fechado (token `blocked`) se faltar. Instale/autentique via `pnpm unblock:check` | -| Token `auto-unblock` preso (agente morto, sem PR) | Wrapper morreu (OOM/timeout/erro) — lock livre, token < TTL se torna `skip_comment` na próxima falha | O TTL de 3h reclaima o token automaticamente (comenta + fecha + novo agente). Antes disso, inspecione o log e a branch `fix/*`; para liberar na hora, feche a Issue token com um comentário | +| Sintoma | Causa | Tratamento | +| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Prod regride para um SHA antigo após re-dispatch fora de ordem | O `flock` serializa mas não ordena; o "already deployed" não pega revision diferente | Evitar re-dispatchar SHA antigo (o requeue da OPS104 sempre despacha o head atual); se acontecer, re-dispatchar o `main` atual (rollback manual disponível na seção Rollback). Revisitar se uma regressão fora-de-ordem acontecer mesmo com o requeue | +| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: run duplicado da mesma HEAD — `workflow_dispatch`, ou push + requeue da OPS104) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: localhost:5000/teqo-1313:-rebuild`) e `docker compose up -d`, ou espere um commit novo | +| Merge durante aprovação pendente de produção: staging do run novo ficava `pending` (resolvido) | O job `waiting` de approval **segurava o grupo** `deploy-homeserver` (a avaliação de concurrency acontece antes do approval — comportamento do GitHub) | **Resolvido na OPS107:** o `concurrency` vive só no `deploy-staging` (que nunca espera approval); o `deploy-production` não ocupa lane, então runs novos fazem staging e ficam aprováveis. Aprove só o run escolhido e rejeite os demais — runs não aprovados não publicam; multi-approve = o último deploy executado vence (rollback manual se um SHA indesejado publicar) | +| Merge durante `deploy-production` `in_progress` não gera requeue | O push nasce, o `preflight` vê o deploy ativo e pula; o requeue daquele run já rodou (logo após o staging) | Nada nasce até o próximo merge ou `workflow_dispatch`. Revisitar na primeira ocorrência real → 2º requeue após produção verde, ou refinar o guard | +| Job de deploy nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só os deploys | +| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado | +| `No such image` ao recriar o container fora de deploy (ex.: `compose up -d` pós-reboot/migração) | O compose referenciava uma tag local bare (`teqo-1313:`) que desapareceu sem recriação (achado INF8/INF13, 24/08: só restava a ref do registry) | Corrigido no INF13: o compose passa a referenciar a tag **qualificada** (`localhost:5000/teqo-1313:`) a partir do primeiro deploy pós-fix — até lá, produção ainda roda o compose bare (recrie o alias via `docker tag` se necessário). Recovery geral: `docker pull localhost:5000/teqo-1313:` (+ `docker tag` se o compose em uso tiver tag bare) e `docker compose up -d` | +| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy | +| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat do ambiente (prod `teqo-1313-build-proxy` em 127.0.0.1:5433; staging `teqo-staging-build-proxy` em 127.0.0.1:5434, na `stack_default`, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback | +| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel | +| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:` e re-dispatch do workflow | +| Workspace clone sem o SHA novo | O workspace antigo apontava para o Forgejo local (pré-OPS71) e o fetch não achou o SHA | O script agora re-aponta `origin` para `TEQO_REPO_URL` (idempotente) antes do fetch — se ainda falhar, `git -C ~/teqo-deploy remote -v` e corrigir manualmente | +| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear | +| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar | +| `deploy-staging` falha cedo (`teqo-staging.env`/serviço ausente) | Bootstrap do staging incompleto (DB, env file, serviços no compose ou environment `staging`) | Rodar o bootstrap da seção Staging; produção nunca é tocada (staging vermelho fail-closa o job `deploy-production`) | +| `verify` vermelho | Regressão real na suíte full (ou flake — #882/#906) | O `auto-unblock.yml` dispara o agente sozinho (OPS106): acompanhe a Issue `auto-unblock` (token) e o PR `fix/*`. Sem PR, o token fica `blocked` — inspecione o log em `~/teqo-unblock/logs/`. Correção manual segue possível (re-merge ou `workflow_dispatch`) | +| Agente de desbloqueio não disparou (token inexistente) | Bootstrap do homeserver incompleto — opencode sem auth, Node/pnpm fora do PATH do runner, PAT ausente/sem push, Docker/porta 5432, label ausente | Rodar `pnpm unblock:check` no homeserver (com `GITHUB_TOKEN` e `AUTOMERGE_PAT` exportados) e seguir a seção Auto-unblock (OPS106) abaixo | +| Launcher `auto-unblock` morre em `fetch failed` (nenhum token criado) | Node fetch ignora HTTP(S)\_PROXY sem NODE_USE_ENV_PROXY (Node ≥24); no homeserver o GitHub só sai pelo proxy CONNECT do runner | OPS126: `scripts/auto-unblock.sh` liga o `NODE_USE_ENV_PROXY` quando há proxy no env; manter `HTTPS_PROXY/HTTP_PROXY` no `~/actions-runner/.env`. Re-dispatch ou aguarde a próxima falha do `verify` | +| Agente morre com exit 127 (`opencode`: No such file or directory) | Serviço do runner não carrega o profile → `~/.opencode/bin` fora do PATH; ou opencode nem instalado | OPS126: `resolveOpenCodeBinary` (`OPENCODE_BIN` → `$HOME/.opencode/bin` → PATH); o launcher falha fechado (token `blocked`) se faltar. Instale/autentique via `pnpm unblock:check` | +| Token `auto-unblock` preso (agente morto, sem PR) | Wrapper morreu (OOM/timeout/erro) — lock livre, token < TTL se torna `skip_comment` na próxima falha | O TTL de 3h reclaima o token automaticamente (comenta + fecha + novo agente). Antes disso, inspecione o log e a branch `fix/*`; para liberar na hora, feche a Issue token com um comentário | +| Capa do Instagram na home em `500` / link do próprio Instagram na Central sempre "Instagram indisponível no momento" | O container é IPv4-only e a rota IPv4 até o CDN do Instagram (`instagram.fssa2-1.fna.fbcdn.net`) é instável nesta rede; a API (`graph.instagram.com`) funciona, mas o arquivo não chega (`media_url` presente e download estourando) | A rede `teqo-ipv6` (`enable_ipv6`, ULA `fd00:1313:1313::/64`) está anexada a `teqo-1313`/`teqo-staging` no compose (backup `docker-compose.yml.pre-ipv6-*`). Se voltar: confirme IPv6 no container (`/proc/net/if_inet6`) e o fetch do CDN de dentro dele; o resolver agora faz retry com teto de conexão. Post-mortem: `docs/postmortems/2026-09-25-instagram-cdn-ipv4-instavel.md` | ## Auto-unblock (OPS106) diff --git a/docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md b/docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md index aff513d9..03ae7520 100644 --- a/docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md +++ b/docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md @@ -15,14 +15,14 @@ ## Timeline -| Momento | Data/hora | Evento | -| ------------------ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Início provável | 2026-09-24 ~03:19 BRT | Commit `8f311831` "fix(C220): link do proprio Instagram baixa e cataloga a peca" (PR #1314) introduz a janela de 500 sem early-stop | -| C220 em produção | 2026-09-24 12:56–13:07 UTC | Deploy run 35966535233 (SHA `8f311831`), job "deploy production" | -| Detecção | 2026-09-24 ~22:09 BRT | Relato do humano na sessão `/bug-fix` | -| Correção mergeada | pendente | Em andamento no PR #1332 (merge pendente do CI/auto-merge) | -| Deploy | pendente | Merge em `main` dispara deploy; staging automático; produção só com approve humano no environment `production` (não feito ainda) | -| Verificado em prod | pendente | Aguardando confirmação do humano (nunca declarar corrigido em prod antes disso) | +| Momento | Data/hora | Evento | +| ------------------ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Início provável | 2026-09-24 ~03:19 BRT | Commit `8f311831` "fix(C220): link do proprio Instagram baixa e cataloga a peca" (PR #1314) introduz a janela de 500 sem early-stop | +| C220 em produção | 2026-09-24 12:56–13:07 UTC | Deploy run 35966535233 (SHA `8f311831`), job "deploy production" | +| Detecção | 2026-09-24 ~22:09 BRT | Relato do humano na sessão `/bug-fix` | +| Correção mergeada | 2026-09-25 ~02:00 UTC | PR #1332 (merge `9f43cbb2`); em produção no deploy do SHA `3cdd1cd8` (~03:34 UTC) | +| Deploy | 2026-09-25 ~03:34 UTC | Deploy run 36087193017 (SHA `3cdd1cd8`), job "deploy production" | +| Verificado em prod | 2026-09-25 | O sintoma **persistiu** após o merge: a causa residual era o caminho de rede até o CDN do Instagram — ver `2026-09-25-instagram-cdn-ipv4-instavel.md` | ## O bug @@ -42,7 +42,7 @@ Resultado: o caso típico (post recém-publicado, na página 1) ficava refém de **Por que os testes não pegaram:** o unit do feed usava cursor fabricado; os testes int do resolver injetavam `loadFeed` fake (nunca o dono real); o e2e do motivo gravava `linkFailureReason` direto no banco e não esperava o job. Nenhum teste exercia o `loadInstagramFeed` real através do resolver. -**Observação honesta:** a API real com a credencial de produção não foi acessada (proibido); a forma exata da paginação da edge segue **não apurada**. A hipótese H2 (API omitir `media_url` em Reels com áudio licenciado, documentada no C212) permanece **não apurada** e pode afetar reels específicos — nesse caso o motivo honesto `indisponivel` continua correto, mas não há via oficial de baixar o arquivo. +**Observação honesta:** na ocasião, a API real com a credencial de produção não foi acessada; no diagnóstico seguinte (autorizado pelo dono, read-only) ela foi consultada e a hipótese H2 foi **descartada** para os reels testados (`media_url` presente, sem copyright). A causa residual era de rede — ver `2026-09-25-instagram-cdn-ipv4-instavel.md`. A forma da paginação da edge além da 1ª página segue **não apurada**. ## Correção diff --git a/docs/postmortems/2026-09-25-instagram-cdn-ipv4-instavel.md b/docs/postmortems/2026-09-25-instagram-cdn-ipv4-instavel.md new file mode 100644 index 00000000..32127e19 --- /dev/null +++ b/docs/postmortems/2026-09-25-instagram-cdn-ipv4-instavel.md @@ -0,0 +1,85 @@ +# Post-mortem: CDN do Instagram inalcançável do container — IPv4 instável e caminho único sem retry + +> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente. + +## Registro + +| Campo | Valor | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Data do post-mortem | 2026-09-25 | +| Severidade | alta (fluxo editorial da Central e capas públicas do Instagram na home) | +| Ambiente | prod | +| Issue(s) | sem Issue — continuação do relato do worktree `fix/10` (post-mortem anterior: `2026-09-24-instagram-link-sem-early-stop.md`) | +| PR do fix | a preencher (o retry no resolver); a rede foi corrigida direto no stack do homeserver (fora do repo) | +| Detectado por | humano (testes em prod: reels com `Instagram indisponível no momento`) | + +## Timeline + +| Momento | Data/hora | Evento | +| ----------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Início provável | não apurado | A rota IPv4 da rede até o CDN do Instagram degradar; o container (IPv4-only) depende dela desde sempre | +| C220-FOLLOWUP em prod | 2026-09-25 ~03:34 UTC | Deploy do SHA `3cdd1cd8` (inclui o early-stop do PR #1332); o sintoma persistiu | +| Detecção | 2026-09-24 ~22:09 BRT | Relato do humano; a causa residual só foi isolada com o diagnóstico read-only de 2026-09-25 | +| Diagnóstico (read-only) | 2026-09-25 ~08:30–09:30 BRT | API devolve `media_url` presente; otimizador de imagem de prod → 500; host e container com ~3/8 de sucesso no IPv4 e 100% no IPv6; container sem rota IPv6 | +| Correção de infra | 2026-09-25 ~09:35 BRT | Rede `teqo-ipv6` (enable_ipv6, ULA) anexada a `teqo-1313`/`teqo-staging` no compose do homeserver + recreate; verificação 10/10 e otimizador 200 | +| Correção de código (PR) | pendente | Retry com teto de conexão no download do resolver (`contentPieceLink.ts`) — a preencher número | +| Deploy (código) | pendente | Merge em `main` dispara deploy; produção só com approve humano no environment `production` | +| Verificado em prod | pendente | Aguardando o reteste do humano na Central (a rede já está corrigida em prod) | + +## O bug + +Colocar um link de publicação do próprio Instagram na Central de Conteúdos não baixava a mídia nem catalogava a peça: a ficha terminava com "Instagram indisponível no momento" (peça-link). O mesmo caminho de rede também quebrava as capas do Instagram na home pública (otimizador de imagem respondendo `500`) e podia derrubar chamadas do feed. + +## Causa-raiz + +O container de produção é **IPv4-only** (Docker sem `ipv6`; `/proc/net/if_inet6` só loopback). A rota IPv4 da rede do homeserver até o CDN do Instagram (`instagram.fssa2-1.fna.fbcdn.net`, edge Vivo `191.251.196.160`) está **muito instável**: medições repetidas deram ~3/8 de sucesso no host e ~3/8 no container, com `ETIMEDOUT` de conexão; o **IPv6 funciona 100%** (`curl -6` 206; fetch no container com a rede IPv6: 10/10). O resolver baixava com **uma única tentativa** (`contentPieceLink.ts`), então quase sempre devolvia `indisponivel` — sem qualquer sinal de que o problema era a rede, não a API. + +5-whys: + +1. **Por que a peça não baixava?** O `fetch` do `media_url` estourava por timeout de conexão no CDN. +2. **Por que estourava?** O container só tem IPv4 e o caminho IPv4 até o edge do CDN é instável nesta rede (o IPv6, estável, não está disponível no container). +3. **Por que o container não usa IPv6?** O Docker do homeserver não tem IPv6 habilitado e o serviço não estava anexado a nenhuma rede IPv6. +4. **Por que isso não aparecia?** O board da home **degrada em silêncio** para o snapshot (fail-closed) e o painel de sync dizia "Sincronizado" (a chamada da API funciona); a ficha mostrava um motivo genérico ("Instagram indisponível no momento") que aponta para a plataforma, não para a rede. +5. **Por que os testes não pegavam?** Todo o caminho de download é mockado nos testes (unit/int) — nenhum teste toca o CDN real; e a verificação viva do C220 nunca foi feita em produção. + +**Evidência:** + +- API com token de produção (read-only): `media_url` **presente** para os reels recentes; `media_audio_type` nulo; nenhum `copyright_check_information`. +- `DdsAC82NXsC` é um reel **colaborativo** de `@joaobahiaprefeito` e **não** aparece no edge `/media` da própria conta — um link de terceiro/colab corretamente não casa (seria `nao-encontrado` com o feed saudável). +- Host `curl -4` no `media_url`: 3/8; `curl -6`: 100% (rápido). Container: 3/8 no IPv4, `ENETUNREACH` no IPv6. +- `https://jorgesolla1313.com.br/_next/image?url=` → `500` antes da correção; `200 image/jpeg` depois. +- Dentro do container de produção, após a rede IPv6: 10/10 downloads do `media_url`. + +## Correção + +- **Infra (fora do repo):** rede `teqo-ipv6` (`enable_ipv6: true`, subnet ULA `fd00:1313:1313::/64`) no `~/stack/docker-compose.yml` do homeserver, anexada a `teqo-1313` e `teqo-staging` (mantendo a `default`). Backup do compose em `docker-compose.yml.pre-ipv6-`; containers recriados (`docker compose up -d teqo-1313 teqo-staging`). Rollback: restaurar o backup e `docker compose up -d teqo-1313 teqo-staging`. +- **Código (este PR):** `downloadContentPieceMedia` em `src/utilities/content/contentPieceLink.ts` — até 6 tentativas com teto de 15 s para a fase de conexão/cabeçalhos (o corpo de uma resposta conectada mantém o orçamento de 3 min). Um `fetch` perdido deixa de perder a peça; tentativas esgotadas continuam virando `indisponivel`, e resposta não-2xx não é repetida. + +## Verificação + +- Teste de regressão: `tests/int/contentPiece.int.spec.ts` — "retries a stranded media download and still extracts the piece" — falha sem o fix (1 tentativa → `indisponivel`) e passa com (4 tentativas → mídia criada) +- Suíte: int contentPiece 39/39; `pnpm gate:fast` verde (lint/typecheck/unit full 4669) +- Infra em prod: 10/10 downloads do CDN dentro do container `teqo-1313`; `/_next/image` de capa IG → `200` (3/3) +- CI: pendente no PR; Prod: pendente do reteste do humano na Central + +## Prevenção + +| Estratégia | Custo | Estado | +| -------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------ | +| Retry com teto de conexão no dono do download (resolver da peça) | barata | implementada agora (este PR) | +| Registrar a rede `teqo-ipv6` e o sintoma no runbook de deploy | barata | implementada agora | +| Observabilidade do caminho do CDN (alerta quando o otimizador/sync falha por rede, não só status da API) | cara | documentada — o painel de sync diz "Sincronizado" mesmo com o CDN inalcançável | +| Re-host das capas do Instagram no sync (S3/Garage) para não depender do CDN em tempo de request | cara | já documentada no post-mortem de 2026-09-11 — segue candidata | +| Healthcheck ativo do stack para egress IPv6/IPv4 aos CDNs externos | cara | documentada | + +**Estratégia implementada:** retry no resolver + nota no runbook (com a rede IPv6 documentada). + +**Estratégia documentada (cara):** observabilidade do caminho, re-host das capas e healthcheck de egress. + +## Lições + +- **Fetch remoto de tentativa única + rede de caminho único = falha silenciosa.** O motivo "Instagram indisponível" escondia uma falha de rota local; um retry curto teria mascarado por tempo, mas a rede era a causa. +- **Fail-closed do board mascara a indisponibilidade:** o snapshot manteve a home de pé e o painel de sync dizia "Sincronizado" (a API chama IPv4 que funcionava para `graph.instagram.com`) enquanto o CDN de mídia estava inalcançável. Status de API não é status de mídia. +- **Diagnóstico de rede precisa dos dois lados:** medir host × container e IPv4 × IPv6 com repetição revelou o padrão que uma única tentativa não mostra. +- **"A API entrega o arquivo" não significa "o arquivo chega":** `media_url` presente, `copyright` ausente e o download falhando por rota. +- **O CDN muda de host/edge sem aviso:** o post-mortem de 2026-09-11 já tinha visto `cdninstagram` → `fbcdn`; desta vez o edge IPv4 novo é que não é alcançável de forma confiável. diff --git a/src/utilities/content/contentPieceLink.ts b/src/utilities/content/contentPieceLink.ts index 14d77fa0..2d6093cd 100644 --- a/src/utilities/content/contentPieceLink.ts +++ b/src/utilities/content/contentPieceLink.ts @@ -81,9 +81,56 @@ const matchesInstagramShortcode = /** A hung Graph API must not hold the piece `processando` until the 1 h reaper. */ const CONTENT_PIECE_LINK_FEED_TIMEOUT_MS = 30_000 -/** Same deadline for the media CDN (the speech pipeline precedent is 180 s). */ +/** Overall budget of one download attempt: a connected body may stream this long. */ const CONTENT_PIECE_LINK_DOWNLOAD_TIMEOUT_MS = 3 * 60_000 +/** + * Connect/headers ceiling per attempt. The production path to the Instagram CDN + * is not stable from every origin (host IPv4 edges can strand a connect while + * the IPv6 route works); a lost connect is retried, never fatal. + */ +const CONTENT_PIECE_LINK_DOWNLOAD_CONNECT_TIMEOUT_MS = 15_000 + +/** Bounded attempts: 6 × 15 s worst case stays inside the old single-attempt budget. */ +const CONTENT_PIECE_LINK_DOWNLOAD_ATTEMPTS = 6 + +type ContentPieceFetch = (input: string, init?: RequestInit) => Promise + +/** + * One media download with a bounded retry: each attempt caps the + * connect/headers phase (a stranded connect aborts and the next attempt + * follows), while the body of a connected response keeps the full budget. An + * exhausted retry returns `null` and the caller keeps the honest + * `indisponivel`; a non-2xx answer is definitive and never retried. + */ +const downloadContentPieceMedia = async ( + fetchImpl: ContentPieceFetch, + url: string, +): Promise => { + for (let attempt = 0; attempt < CONTENT_PIECE_LINK_DOWNLOAD_ATTEMPTS; attempt += 1) { + const connect = new AbortController() + const connectTimer = setTimeout( + () => connect.abort(), + CONTENT_PIECE_LINK_DOWNLOAD_CONNECT_TIMEOUT_MS, + ) + try { + const response = await fetchImpl(url, { + signal: AbortSignal.any([ + AbortSignal.timeout(CONTENT_PIECE_LINK_DOWNLOAD_TIMEOUT_MS), + connect.signal, + ]), + }) + if (!response.ok) return null + return response + } catch { + // Transport failure: another attempt follows while attempts remain. + } finally { + clearTimeout(connectTimer) + } + } + return null +} + /** * Our own ceiling, not the platform's: it must stay distinguishable from a * transport failure so the resolver rethrows it (`falhou`) instead of dressing @@ -227,15 +274,8 @@ export const resolveContentPieceSource = async ({ return linkOnly('indisponivel', matched.caption) } - let response: Response - try { - response = await fetchImpl(matched.mediaUrl, { - signal: AbortSignal.timeout(CONTENT_PIECE_LINK_DOWNLOAD_TIMEOUT_MS), - }) - } catch { - return linkOnly('indisponivel', matched.caption) - } - if (!response.ok) { + const response = await downloadContentPieceMedia(fetchImpl, matched.mediaUrl) + if (!response) { return linkOnly('indisponivel', matched.caption) } diff --git a/tests/int/contentPiece.int.spec.ts b/tests/int/contentPiece.int.spec.ts index 2953e9d4..25bf5ba3 100644 --- a/tests/int/contentPiece.int.spec.ts +++ b/tests/int/contentPiece.int.spec.ts @@ -1233,6 +1233,44 @@ describe('content pieces (C211)', () => { expect(calls.some((call) => call.includes('after='))).toBe(false) }) + it('retries a stranded media download and still extracts the piece', async () => { + await setInstagramSettings(true) + const { piece } = await createPiece({ + title: 'Reel com rede instável', + origin: 'instagram', + withMedia: false, + sourceUrl: 'https://www.instagram.com/reel/INSTAVEL1/', + }) + let downloadAttempts = 0 + + const resolution = await withTempDir((tempDir) => + resolveContentPieceSource({ + payload, + piece: pieceInput(piece), + tempDir, + loadFeed: async () => ({ + username: 'depjorgesolla', + posts: [instagramPost({ permalink: 'https://www.instagram.com/reel/INSTAVEL1/' })], + }), + fetchImpl: async (input) => { + if (input.includes('cdn.example')) { + downloadAttempts += 1 + // The stranded-connect shape: undici throws before any response. + if (downloadAttempts <= 3) throw new TypeError('fetch failed') + return new Response(Buffer.from('reel-bytes'), { status: 200 }) + } + return new Response(null, { status: 200 }) + }, + }), + ) + + // The lost connects are retried; the fourth attempt extracts the piece. + expect(downloadAttempts).toBe(4) + expect(resolution.linkFailureReason).toBeNull() + expect(resolution.media).toBeTruthy() + if (resolution.media) createdMediaIds.add(resolution.media.id) + }) + it('reports nao-encontrado when the post is not in the window', async () => { await setInstagramSettings(true) const { piece } = await createPiece({