Como contornamos os desafios da nuvem para aumentarmos nossos downloads em 102% com usuários de um concorrente.
O prazo não era nosso
O TV Time anunciou que iria encerrar as suas atividades. Para quem usava o aplicativo há anos, era uma década de filmes e séries registrados prestes a evaporar. Milhares pessoas estavam com o mesmo problema e sem uma solução eficaz.
O TV Time ofereceu uma ajuda para seus futuros usuários orfãos. Era possível exportar toda a atividade realizada no aplicativo em um .zip antes da data final. Porém essa exportação só resolvi uma parte do problema - o usuário teria o arquivo porém nenhum lugar para colocá-lo.
Trabalho na Omelete Company, e temos o Chippu, um app que permite seus usuários a registrar seus históricos da mesma maneira que o Tv Time. O raciocínio era simples. O difícil era o prazo: recebemos sinal verde faltando cinco dias para o TV Time sair do ar. Depois disso, os usuários que não exportaram seu histórico, perderiam tudo pois não haveriam de onde exportar.
Spoiler: Funcionou! Entre 12/07 e 03/08, a funcionalidade resultou em um aumento de 102% nos downloads na lojas de aplicativo comparado ao mês anterior. Tivemos registro de pessoas que instalaram o APP especificamente para não perder o histórico do TV Time.
Resultado de negócio

Downloads do Chippu no período da migração do TV Time.
Aqui, conto os limites ocultos, as ideias óbvias que descartamos e um erro que só estourou em produção.
O mapa: três problemas, não um
Antes do código, vale um panorama geral. "Importar histórico do Tv Time” parece uma tarefa. São três, e cada um tem um empecilho diferente.
- Receber e validar o arquivo. O usuário manda um ZIP. Precisamos receber o arquivo sem travar o app, validar se é um export do TV Time e responder ao usuário o mais rápido possível. Empecilho: tempo de resposta.
- Extrair os dados na nuvem. Dezenas de megabytes de CSV para ler linha a linha, cruzar com o banco de conteúdos e transformar em registros. Isso demora, e o usuário já fechou o app. Empecilho: limites de execução.
- Salvar e avisar. Gravar tudo sem duplicar dados, manter o backend de pé para os usuários ativos e enviar um e-mail no segundo em que o processo acaba. Empecilho: saber quando acabou.
O resto do texto segue essa ordem.
Diagrama do fluxo completo

Três empecilhos encadeados. Cada um resolvido por um motivo diferente.
Parte 1 - Receber e validar
A promessa que a arquitetura não podia cumprir
O desenho inicial era o seguinte: o app pede uma URL temporária para um serviço serverless , envia o ZIP direto para o armazenamento, e isso dispara o processamento em segundo plano em outro serviço serverless . Simples e Rápido - o arquivo não precisa passar pelo nosso servidor.
Aí veio a necessidade que complicou tudo: o APP precisava que o serviço serverless confirmasse se o ZIP tem os arquivos certos antes de seguir em frente.
Parece razoável. Era impossível.
O motivo era por conta da sequência das execuções, e não a tecnologia. O serviço serverless entrega a URL temporária e responde antes do arquivo estar salvo no armazenamento. O envio acontece depois e nem passa pelo serverless. O envio acontece depois sem passar pela nossa infraestrutura. Por isso não é possível uma resposta esperar por um arquivo que ainda não foi enviado - seria como pedir ao caixa que confirmasse sua compra antes mesmo de você entrar na loja.
A saída foi parar de tentar realizar as duas coisas em uma única chamada. Criei um segundo endpoint que o app chama logo após o upload. Ele confirma a entrega, valida o arquivo e já devolve o veredito na mesma hora.
Duas chamada em vez de uma. Mas cada uma com uma definição clara sobre o que consegue garantir - e isso vale mais do que economizar uma requisição.
O truque dos 212 bytes
A pergunta que surgiu durante o desenvolvimento foi a seguinte: fazer o download de todo o arquivo só para olhar o que tem dentro não seria exatamente o ponto de lentidão dessa validação?
Ia. E a resposta não veio de nenhum serviço mais caro. Veio de dois detalhes que já estavam ali:
Um ZIP guarda seu índice no fim do arquivo. A lista do que existe lá dentro - O Central Directory - fica nos últimos bytes, não no começo.
O protocolo HTTP sabe baixar pedaço de arquivo. O cabeçalho Range permite pedir "só os bytes tais a tais”, e o armazenamento respeita isso.
Juntando esses dois pontos, chegamos na conclusão: para saber o que tem dentro do ZIP, basta ler o finalzinho dele.
Faltava convencer a biblioteca de ZIP do Python de que aquele arquivo remoto era um arquivo local. Para isso, criamos um código que finge ser um arquivo comum, mas que por baixo dos panos baixa apenas os pedaços que precisamos ler:
Range=f'bytes={self._pos}-{end}' É essencialmente isso. A biblioteca faz o que sempre faz - pula para o fim do arquivo atrás do índice - e recebe só aquele fragmento.
Resultado obtido: um ZIP de 39.323 bytes foi validado lendo 212 bytes.
E o ganho não é a economia pontual. O custo da validação passou a depender de quantos arquivos existem no índice, não do peso do conteúdo. Um ZIP de 500MB valida no mesmo tempo que um de 40KB. O empecilho não foi otimizado: ele deixou de existir.
O truque dos 212 bytes

Para saber o que tem dentro de um ZIP, não é preciso abrir o ZIP.
O que não aparece no diagrama
Duas decisões pequenas desta fase, do tipo que ninguém desenha mas todo mundo paga se esquecer.
URL temporária (Pre-signed URL) não limita tamanho. Quem assina a URL não controla o que o cliente manda por ela. O app avisa o teto de 50MB antes de o usuário gastar a banda dele, mas a validação de cliente é sugestão, não garantir. Então o servidor confere o tamanho real na hora da validação e, se passar do limite, apaga o arquivo e recusa.
ZIP reprovado também é apagado. Não é só higiene de armazenamento - o motivo real dessa linha vai aparecer mais adiante, num bug que quase custou muito caro.
Parte 2 - Extrair os dados na nuvem
O mesmo truque, agora dentro do CSV
Validar um ZIP é ler um índice. Processor todo o histórico é ler tudo, linha por linha. O empecilho muda: agora o problema é que um serviço serverless tem 15 minutos de vida, e histórico de cinéfilo não cabe nisso.
A ideia foi a mesma da validação: nunca baixar o arquivo inteiro. O processador lê blocos de 5MB a partir de uma posição, e cada bloco é uma requisição com faixa de bytes.
A posição é contada em bytes, não em carecteres. Contar depois de decodificar o texto poderia falhar logo no primeiro acento. Como no UTF-8 uma letra pode ocupar mais de um byte, a conta desanda em silêncio.
Toda quebra de bloco cai no meio de uma linha. Com certeza absoluta, sempre. Então o código procura a última quebra de linha do bloco, processa só até ali, e devolve a posição exata onde a linha incompleta começa.
Serviço serverless que se re-enfileira
Falta a peça que dissolve o limite dos 15 minutos, e ela é quase óbvia depois de vista.
Antes de ler cada bloco, o serveless pergunta quanto tempo de vida ainda tem. Se sobrar menos de 30 segundos, ele manda uma mensagem para si mesmo com a posição exata onde parou - e termina limpa, sem erros.
Alguns segundos depois, uma nova execução começa exatamente dali.
O efeito é que não existe arquivo grande demais. Existe arquivo que precisa de mais rodadas. É a mesma lógica do truque dos 212 bytes: um limite da plataforma deixou de ser um bloqueio e virou um parâmetro do algoritmo.
Nas duas vezes, a saída não veio de contratar infraestrutura maior. Vaio de entender o formato do arquivo e o protocolo que o transporta.
A função que se re-enfileira

A função desiste antes de morrer, e deixa um bilhete para a próxima.
Seis segundos entre cada chamada - e por que o motivo importa mais que o número
Aqui entra a restrição mais interessante do projeto, e ela não é técnica: é de convivência.
A infraestrutura do Chippu não me permitia acesso direto ao banco, então a gravação do histórico do TV Time tinha que passar pela exata mesma API que atendia o público. O peso disso? O import não gastava limite de uma ferramenta externa, mas concorria por recursos direto com os próprios usuários. Tudo isso rodando 24 horas por dia para esvaziar uma fila enorme contra o relógio.
Uma rajada de milhares de requisições ali não geraria só um erro de excesso de chamadas. Tornaria o app lento para os usuários que não estavam realizando importação alguma, não sabiam que existiam uma migração acontecendo e só queriam avaliar um filme antes de dormir.
Então a decisão foi deliberadamente contraintuitiva: deixamos nossa própria funcionalidade mais lenta de propósito. Uma escrita a cada seis segundos. Ninguém pediu esse número; ele saiu de escolher um ritmo que causasse um ruído irrelevante ao tráfego normal do app.
E aqui foi preciso olhar a documentação com calma: não é possível configurar uma fila para entregar uma mensagem a cada seis segundos.
Para contornar esse empecilho, foi implementado uma pausa dentro da própria função, antes de cada escrita.
Só que, sozinha, essa pausa não serve para nada. Cinquenta execuções aguardando seis segundos em paralelo é a mesma coisa que cinquenta execuções sem pausa alguma.
Ela só consegue controlar de verdade quando combinada com uma trava: no máximo uma execução da função por vez. Aí a pausa é aplicada para o sistema inteiro.
Duas configurações completam a arquitetura, e elas existem por motivo de recuperação e não de desempenho:
Um item por vez: Se o tempo estourar, só ele volta para a fila. Usar lotes faria o sistema reprocessar coisas que já tinham dado certo.
Tempo de invisibilidade folgado: O tempo que a mensagem fica invisível na fila precisa ser vem maior que a duração da função
Quando cada chamada custa seis segundo, cortar chamada vira decisão de produto
Um espaçamento de seis segundos muda a forma do sistema funcionar: cada chamada evitada é seis segundos a menos de espera para finalizar a importação. "Reduzir requisições” não era mais uma otimização mas sim um requisito!
Duas decisões foram definidas a partir disto:
Temporada completa vira uma chamada só. O backend do Chippu possibilitava marcar como assistido um título, uma temporada ou um episódio. Quando resolvemos o título, recebemos junto quantos episódios cada temporada tem. Se a pessoa assistiu a todos, emitimos uma marcação de temporada. Se assistiu a parte, uma por episódio.
Uma temporada de 24 episódios integralmente assistida caiu de 24 requisições para uma. Cronometrado no relógio: de 2 minutos e 24 segundos para 6 segundos.
Onde a API permitia, enviamos em lotes de 20: favoritos, 'assistir mais tarde' e marcações simples viajaram juntos. Já a marcação detalhada continuou individual. Como cada item tem sua própria data, temporada e plataforma, agrupar significaria perder dados.
A regra que ficou: se é padrão, vai em lote; se tem detalhes, vai sozinho.
Somadas, as duas tiram um usuário com milhares de episódios da cada dos milhares de requisições para a das centenas. Sem mexer na infraestrutura.
Uma temporada, dois caminhos

Quando cada requisição custa 6 segundos, agrupar deixa de ser elegância e vira requisito
Não duplicar sem banco de dados
O serverless que despacha as ações não tem memória. Não consulta tabela de controle, não registra o que já fez. Toda a garantia de não duplicar vem da fila — e isso foi escolha, não economia.
Cada mensagem possuía um hash calculado a partir dos campos que definem a ação — usuário, tipo, título, temporada, episódio. Reprocessar o mesmo upload gera exatamente as mesmas mensagens e a fila descarta as repetições sozinha.
A agregação intermediária segue a mesma filosofia. Em vez de acumular episódios na memória da função — que evapora entre execuções — o acúmulo acontece dentro do banco, usando conjuntos.
Conjunto é consistente por definição: adicionar o mesmo episódio duas vezes não duplica nada. Reprocessar um bloco depois de uma falha é seguro por construção, não por controle.
O dicionário que já existia na casa
Aqui aparece o problema mais chato de toda essa migração: os CSVs do TVTime não tinham nenhum identificador universal de conteúdo. So havia o nome do título como aparecia no TVTime, e mais nada.
"Breaking Bad” não é um dado. É um texto. E texto sozinho não casa com banco de dados.
A sorte é que a peça que faltava já existia dentro de casa, construída para outra finalidade. O Omelete possuí um banco de dados de conteúdos de filmes e séries. Ninguém construiu aquilo pensando em migração de histórico.
Foi exatamente o que resolveu a migração de histórico.
A resolução ficou em duas requisições: o nome cru do TVTime vai ao banco de conteúdos do Omelete e volta com um identificador público; esse identificador vai ao backend do Chippu e volta com o ID interno, mais o mapa de temporadas, episódios e plataformas — que é justamente o que alimenta a regra da temporada completa da seção anterior.
O cache por conteúdo
Duas requisições por título é a operação mais cara do pipeline inteiro. E a chave do cache não inclui o usuário.
A consequência é direta: o primeiro que importar Breaking Bad paga as duas requisições. Todos os outros pagam zero — inclusive quem importar meses depois. Isso não é somente uma otimização, mas sim a diferença entre o sistema drenar em horas ou dias.
Dois detalhes que fazem o cache render mais ainda:
O fracasso também é cacheado. Título sem correspondência é registrado como "não encontrado” e nunca mais é perguntado. Assim evitamos uma busca fracassada por pessoa.
A chave é o título normalizado. Antes de virar chave, o nome perde acentos, vira minúsculo e tem espaços colapsados. Amélie, A*melie* e AMELIE se tornam amelie para facilitar a busca no cache salvo.
Cache por conteúdo, não por usuário
Numa migração em massa, os catálogos se sobrepõem muito mais do que se imagina.
Parte 3 — Salvar e avisar
O sistema assíncrono não sabe que terminou
Com tudo desacoplado por filas, apareceu um problema que não estava no plano: nenhum componente sabe que o trabalho de um usuário acabou.
O processador sabe que publicou as ações, mas não que foram executadas. O executor sabe que fez uma ação, mas não que era a última. A fila sabe quantas mensagens tem, mas não de quem são.
E precisávamos desse instante, porque é ele que dispara o e-mail para uma pessoa que fechou o app há horas.
A resposta foi um componente novo, que não processa nada: só observa o sistema de fora e decide quando avançar. Três decisões dele merecem registro.
A fila de espera respeita a ordem de chegada. Cada upload entra num backlog ordenado pela data do arquivo original. Com prazo curto e volume concentrado, a regra definida for: quem enviou primeiro é atendido primeiro.
O sinal de conclusão é o silêncio das filas — incluindo o que está em execução. A cada dois minutos, ele soma as mensagens disponíveis e as que estão sendo processadas neste instante. Só as disponíveis não bastam: uma fila pode reportar zero justamente porque tudo está em execução. Esse é o falso positivo clássico, e somar as duas categorias é o que evita. Por cima ainda existe uma espera de 90 segundos antes de declarar fim, dando janela para uma mensagem em execucao se materializar.
Um upload por vez. O próximo só começa quando o anterior drenou por completo. Somado à trava de uma execução simultânea, isso torna o sistema inteiramente previsível: em qualquer instante existe um usuário sendo processado e no máximo uma requisição em execucao para o backend do Chippu — que, lembre, é o mesmo backend que está atendendo o app ao vivo.
Pipoca pronta
Quando o backlog daquele usuário drena e as filas silenciam, sai o e-mail: "Pipoca pronta! Seu histórico do TVTime foi importado com sucesso.”
O envio é best-effort e não trava a fila. Se o e-mail falhar, o trabalho é marcado como concluído mesmo assim, com o resultado do envio registrado ao lado. Travar o backlog inteiro de usuários porque o serviço de e-mail caiu seria trocar um problema pequeno por um grande.
O bug que nenhum teste local encontra
Esta é uma parte interessante, porque não tem nada a ver com nuvem.
Ao concluir um upload, o sistema passou a apagar os CSVs já extraídos. A motivação era boa: cumpriram o papel, ocupam espaço, e a ausência deles funciona como trava física contra reprocessamento acidental. A exclusão é conservadora — só acontece se o ZIP original ainda existir, garantindo que dá para regenerar tudo.
O que eu não percebi é que outra funçãp, escrita dias antes, usava a existência daqueles arquivos. O endpoint que o APP consulta para saber se a pessoa já tinha importado respondia com base em "existe arquivo extraído?”.
No instante em que a limpeza entrou no ar, todo usuário já processado passou a ser reportado como se nunca tivesse enviado nada. O APP pediria o arquivo de novo e correria o risco dos dados serão duplicados no Chippu.
A correção foi trocar a lógica: o indicador correto é o ZIP original, que é permanente. Por isso o ZIP reprovado é apagado, lá na Parte 1 — a presença do arquivo virou o sinal, e deixar lixo inválido para trás corromperia esse sinal.
"Se o arquivo foi extraído, então o usuário fez upload” parecia prático. Era uma armadilha com gatilho de tempo.
O sinal errado

Um bug invisível: dois serviços serverless, escritas com dias de diferença, discordando sobre o que significa um arquivo existir.
Guardar o ZIP possibilitou mudar de ideia
O ZIP original nunca é apagado. Nem depois de processado, nem depois do e-mail. Foi guardado por instinto de “não jogue fora a fonte” — e acabou destravando algo que não estava no plano.
Quando passamos a entender melhor o formato do TVTime, a lista de arquivos úteis foi crescendo e os uploads já recebido não precisariam ser pedidos novamente. Um endpoint reaplica a interpretação atual sobre os ZIPs que já estão guardados.
Um detalhe importa muito aqui: a reinterpretação, por padrão, só relê — não reexecuta as ações. Refazer as gravações exige pedir explicitamente.
Separar 'reler o arquivo' de 'executar as ações' é o que garante a segurança de rodar tudo isso em produção sem afetar quem está usando o app. As duas coisas parecem uma só até o dia em que você precisa fazer a primeira sem a segunda.
O princípio: enquanto o dado bruto estiver disponível, toda decisão de interpretação é reversível. Guardar o ZIP custa centavos e possibilita mudar de ideia sobre o processamento dos arquivo — exatamente o que este projeto exigiu.
Como eu sabia que funcionava antes do deploy
Prazo curto costuma virar desculpa para testar em produção. Fizemos o oposto, com um ambiente local híbrido — nem tudo falso, nem tudo real:
| Camada | No teste local |
|---|---|
| Armazenamento e banco de conteúdos | Reais, só leitura, com CSVs de um upload verdadeiro |
| Banco e filas | Versões locais, em Docker |
| Leitura do backend do Chippu | Simulação que repassa para o backend real |
| Escrita no backend do Chippu | Simulação que só registra o payload exato |
O valor está na assimetria. Leitura é real, então o teste enfrenta os dados verdadeiros com toda a sujeira que dado de terceiro tem. Escrita é falsa, então o ambiente é fisicamente incapaz de tocar produção. E dado que a escrita ia bater no backend que atende os usuários reais, essa garantia era requisito.
Como a simulação registra o payload literal de cada ação, foi ali que validei o formato exato esperado pelo backend antes de qualquer deploy.
O botão de pânico precisa ser mais rápido que um deploy
Como uma janela de dias, "reverter” precisa ser mais rápido que publicar código. Por isso o sistema tem dois níveis de controle.
O estrutural muda no deploy, com rastro: ligar ou desligar os consumidores das filas, ajustar a concorrência, mudar o nível de log. Um detalhe deliberado: os consumidores sobem desligados por padrão. Publicar código e ativar consumo são atos separados — dá para subir uma correção sem que ela imediatamente comece a drenar o backlog acumulado.
O operacional muda em segundos, sem deploy. Pausar o pipeline inteiro é um comando; religar, outro. Isso exise por um motivo bem concreto: se o APP começasse a dar sinal de lentidão, eu precisava conseguir desligar a importação o quanto antes.
O que ficou
No fim das contas, a nuvem não resolve problemas sozinha. Montar essa arquitetura não foi sobre conectar serviços, mas sobre lidar com restrições rígidas da plataforma usando atalhos que ninguém documenta. Foi entender que escalar infraestrutura nem sempre é a resposta e, sobretudo, aceitar que a decisão mais lógica costuma ser a pior.
Três ideias renderam mais que todas as outras:
O tempo limite da plataforma virou parâmetro do algoritmo em dois momentos. Vencemos o primeiro gargalo com uma leitura parcial do ZIP, e superamos o teto de 15 minutos do serverless com uma função que recria a si mesma na fila. Nas duas vezes, a saída veio de entender os protocolos — não de pagar por mais infraestrutura.
Cache por conteúdo, não por usuário. A mesma tabela que acelera um import acelera todos os próximos, de qualquer pessoa. Numa migração em massa, essa única escolha define se o sistema drena em horas ou dias.
A funcionalidade mais lenta de propósito foi a decisão mais madura do projeto. Dava para importar tudo muito mais rápido. Também dava para deixar o APP arrastado para milhares de pessoas que não tinham nada a ver com a migração. Escolher o ritmo lento não foi limitação técnica: foi decidir quem tinha prioridade.
Cinco dias. Cinco funções, duas filas e uma pilha de decisões que só pareceram óbvias depois.
O TVTime saiu do ar no prazo. O histórico dos usuários, não.

