<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: João Franchini</title>
    <description>The latest articles on DEV Community by João Franchini (@jhoni_franchini).</description>
    <link>https://dev.to/jhoni_franchini</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1444459%2F6e8fb700-1596-4ae6-b1d3-aa2ff19c7b39.jpg</url>
      <title>DEV Community: João Franchini</title>
      <link>https://dev.to/jhoni_franchini</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jhoni_franchini"/>
    <language>en</language>
    <item>
      <title>[PT] O TV Time acabou. Só tinhamos 5 dias para salvar a história de seus usuários</title>
      <dc:creator>João Franchini</dc:creator>
      <pubDate>Mon, 12 Oct 2026 17:39:14 +0000</pubDate>
      <link>https://dev.to/jhoni_franchini/pt-o-tv-time-acabou-so-tinhamos-5-dias-para-salvar-a-historia-de-seus-usuarios-2g19</link>
      <guid>https://dev.to/jhoni_franchini/pt-o-tv-time-acabou-so-tinhamos-5-dias-para-salvar-a-historia-de-seus-usuarios-2g19</guid>
      <description>&lt;p&gt;Como contornamos os desafios da nuvem para aumentarmos nossos downloads em 102% com usuários de um concorrente.&lt;/p&gt;

&lt;h2&gt;
  
  
  O prazo não era nosso
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://techcrunch.com/2026/07/02/popular-tv-tracking-app-tv-time-is-shutting-down-as-company-focuses-on-ai/" rel="noopener noreferrer"&gt;O TV Time anunciou que iria encerrar as suas atividades&lt;/a&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;cinco dias&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Spoiler: &lt;strong&gt;Funcionou!&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Resultado de negócio&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8wgzhyijmh61jijgu38n.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8wgzhyijmh61jijgu38n.jpeg" alt="Downloads do Chippu no período da migração do TV Time." width="799" height="436"&gt;&lt;/a&gt;&lt;br&gt;
Downloads do Chippu no período da migração do TV Time.&lt;/p&gt;

&lt;p&gt;Aqui, conto os limites ocultos, as ideias óbvias que descartamos e um erro que só estourou em produção.&lt;/p&gt;
&lt;h2&gt;
  
  
  O mapa: três problemas, não um
&lt;/h2&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Receber e validar o arquivo.&lt;/strong&gt; 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: &lt;strong&gt;tempo de resposta.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extrair os dados na nuvem&lt;/strong&gt;. 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: &lt;strong&gt;limites de execução&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Salvar e avisar&lt;/strong&gt;. 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: &lt;strong&gt;saber quando acabou&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;O resto do texto segue essa ordem.&lt;/p&gt;
&lt;h4&gt;
  
  
  Diagrama do fluxo completo
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnj8wr1b7bytvr6hp8ie3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnj8wr1b7bytvr6hp8ie3.png" alt="Três empecilhos encadeados. Cada um resolvido por um motivo diferente." width="800" height="369"&gt;&lt;/a&gt;&lt;br&gt;
Três empecilhos encadeados. Cada um resolvido por um motivo diferente.&lt;/p&gt;
&lt;h2&gt;
  
  
  Parte 1 - Receber e validar
&lt;/h2&gt;
&lt;h3&gt;
  
  
  A promessa que a arquitetura não podia cumprir
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;Parece razoável. Era impossível.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;antes do arquivo estar salvo no armazenamento&lt;/strong&gt;. 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. &lt;/p&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;uma requisição.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  O truque dos 212 bytes
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;ponto de lentidão dessa validação&lt;/strong&gt;?&lt;/p&gt;

&lt;p&gt;Ia. E a resposta não veio de nenhum serviço mais caro. Veio de dois detalhes que já estavam ali:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Um ZIP guarda seu índice no fim do arquivo.&lt;/strong&gt; A lista do que existe lá dentro - O Central Directory - fica nos últimos bytes, não no começo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;O protocolo HTTP sabe baixar pedaço de arquivo&lt;/strong&gt;. O cabeçalho Range permite pedir "só os bytes tais a tais”, e o armazenamento respeita isso.&lt;/p&gt;

&lt;p&gt;Juntando esses dois pontos, chegamos na conclusão: para saber o que tem dentro do ZIP, basta ler o finalzinho dele.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Range&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bytes=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pos&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;É essencialmente isso. A biblioteca faz o que sempre faz - pula para o fim do arquivo atrás do índice - e recebe só aquele fragmento. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resultado obtido: um ZIP de 39.323 bytes foi validado lendo 212 bytes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;E o ganho não é a economia pontual. O custo da validação passou a depender de &lt;strong&gt;quantos arquivos existem no índice&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;h4&gt;
  
  
  O truque dos 212 bytes
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs7iyjvoh63rp9wt349j8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs7iyjvoh63rp9wt349j8.png" alt="Para saber o que tem dentro de um ZIP, não é preciso abrir o ZIP." width="799" height="346"&gt;&lt;/a&gt;&lt;br&gt;
Para saber o que tem dentro de um ZIP, não é preciso abrir o ZIP.&lt;/p&gt;

&lt;h3&gt;
  
  
  O que não aparece no diagrama
&lt;/h3&gt;

&lt;p&gt;Duas decisões pequenas desta fase, do tipo que ninguém desenha mas todo mundo paga se esquecer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL temporária (Pre-signed URL) não limita tamanho&lt;/strong&gt;. 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, &lt;strong&gt;apaga o arquivo&lt;/strong&gt; e recusa.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ZIP reprovado também é apagado&lt;/strong&gt;. Não é só higiene de armazenamento - o motivo real dessa linha vai aparecer mais adiante, num bug que quase custou muito caro.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parte 2 - Extrair os dados na nuvem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  O mesmo truque, agora dentro do CSV
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;15 minutos&lt;/strong&gt; de vida, e histórico de cinéfilo não cabe nisso.&lt;/p&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A posição é contada em bytes, não em carecteres&lt;/strong&gt;. 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. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Toda quebra de bloco cai no meio de uma linha&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Serviço serverless que se re-enfileira
&lt;/h3&gt;

&lt;p&gt;Falta a peça que dissolve o limite dos 15 minutos, e ela é quase óbvia depois de vista. &lt;/p&gt;

&lt;p&gt;Antes de ler cada bloco, o serveless  pergunta quanto tempo de vida ainda tem. Se sobrar menos de 30 segundos, &lt;strong&gt;ele manda uma mensagem para si mesmo&lt;/strong&gt; com a posição exata onde parou - e termina limpa, sem erros. &lt;/p&gt;

&lt;p&gt;Alguns segundos depois, uma nova execução começa exatamente dali.&lt;/p&gt;

&lt;p&gt;O efeito é que &lt;strong&gt;não existe arquivo grande demais&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h4&gt;
  
  
  A função que se re-enfileira
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuqnqeyt9q3s7igscnu3r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuqnqeyt9q3s7igscnu3r.png" alt="A função desiste antes de morrer, e deixa um bilhete para a próxima." width="800" height="398"&gt;&lt;/a&gt;&lt;br&gt;
A função desiste antes de morrer, e deixa um bilhete para a próxima.&lt;/p&gt;

&lt;h3&gt;
  
  
  Seis segundos entre cada chamada - e por que o motivo importa mais que o número
&lt;/h3&gt;

&lt;p&gt;Aqui entra a restrição mais interessante do projeto, e ela não é técnica: é de convivência. &lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Então a decisão foi deliberadamente contraintuitiva: &lt;strong&gt;deixamos nossa própria funcionalidade mais lenta de propósito&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;E aqui foi preciso olhar a documentação com calma: &lt;strong&gt;não é possível configurar uma fila para entregar uma mensagem a cada seis segundos&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Para contornar esse empecilho, foi implementado uma pausa dentro da própria função, antes de cada escrita.&lt;/p&gt;

&lt;p&gt;Só que, sozinha, &lt;strong&gt;essa pausa não serve para nada&lt;/strong&gt;. Cinquenta execuções aguardando seis segundos em paralelo é a mesma coisa que cinquenta execuções sem pausa alguma. &lt;/p&gt;

&lt;p&gt;Ela só consegue controlar de verdade quando combinada com uma trava: &lt;strong&gt;no máximo uma execução da função por vez&lt;/strong&gt;. Aí a pausa é aplicada para o sistema inteiro.&lt;/p&gt;

&lt;p&gt;Duas configurações completam a arquitetura, e elas existem por motivo de recuperação e não de desempenho:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Um item por vez&lt;/strong&gt;: Se o tempo estourar, só ele volta para a fila. Usar lotes faria o sistema reprocessar coisas que já tinham dado certo. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tempo de invisibilidade folgado&lt;/strong&gt;: O tempo que a mensagem fica invisível na fila precisa ser vem maior que a duração da função&lt;/p&gt;

&lt;h3&gt;
  
  
  Quando cada chamada custa seis segundo, cortar chamada vira decisão de produto
&lt;/h3&gt;

&lt;p&gt;Um espaçamento de seis segundos muda a forma do sistema funcionar: &lt;strong&gt;cada chamada evitada é seis segundos a menos de espera para finalizar a importação&lt;/strong&gt;. "Reduzir requisições” não era mais uma otimização mas sim um requisito!&lt;/p&gt;

&lt;p&gt;Duas decisões foram definidas a partir disto:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporada completa vira uma chamada só&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Onde a API permitia, enviamos em lotes de 20&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;A regra que ficou: &lt;strong&gt;se é padrão, vai em lote; se tem detalhes, vai sozinho&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h4&gt;
  
  
  Uma temporada, dois caminhos
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzqrklqo351hc8slkzxmx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzqrklqo351hc8slkzxmx.png" alt="Quando cada requisição custa 6 segundos, agrupar deixa de ser elegância e vira requisito" width="799" height="398"&gt;&lt;/a&gt;&lt;br&gt;
Quando cada requisição custa 6 segundos, agrupar deixa de ser elegância e vira requisito&lt;/p&gt;

&lt;h3&gt;
  
  
  Não duplicar sem banco de dados
&lt;/h3&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;conjuntos.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;Conjunto é &lt;strong&gt;consistente&lt;/strong&gt; por definição: adicionar o mesmo episódio duas vezes não duplica nada. Reprocessar um bloco depois de uma falha é seguro &lt;strong&gt;por construção, não por controle.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  O dicionário que já existia na casa
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;"Breaking Bad” não é um dado. É um texto. E texto sozinho não casa com banco de dados.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Foi exatamente o que resolveu a migração de histórico.&lt;/p&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;h3&gt;
  
  
  O cache por conteúdo
&lt;/h3&gt;

&lt;p&gt;Duas requisições por título é a operação mais cara do pipeline inteiro. E a chave do cache &lt;strong&gt;não inclui o usuário&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A consequência é direta: o primeiro que importar &lt;em&gt;Breaking Bad&lt;/em&gt; paga as duas requisições. &lt;strong&gt;Todos os outros pagam zero —&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Dois detalhes que fazem o cache render mais ainda: &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;O fracasso também é cacheado&lt;/strong&gt;. Título sem correspondência é registrado como "não encontrado” e nunca mais é perguntado. Assim evitamos uma busca fracassada por pessoa.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A chave é o título normalizado&lt;/strong&gt;. Antes de virar chave, o nome perde acentos, vira minúsculo e tem espaços colapsados. &lt;em&gt;Amélie&lt;/em&gt;, A*melie* e &lt;em&gt;AMELIE&lt;/em&gt; se tornam amelie para facilitar a busca no cache salvo.&lt;/p&gt;

&lt;h4&gt;
  
  
  Cache por conteúdo, não por usuário
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fci4kag0kaga6iw0i6xgu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fci4kag0kaga6iw0i6xgu.png" alt="Numa migração em massa, os catálogos se sobrepõem muito mais do que se imagina." width="800" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Numa migração em massa, os catálogos se sobrepõem muito mais do que se imagina.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parte 3 &lt;strong&gt;— Salvar e avisar&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  O sistema assíncrono não sabe que terminou
&lt;/h3&gt;

&lt;p&gt;Com tudo desacoplado por filas, apareceu um problema que não estava no plano: &lt;strong&gt;nenhum componente sabe que o trabalho de um usuário acabou&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;E precisávamos desse instante, porque é ele que dispara o e-mail para uma pessoa que fechou o app há horas.&lt;/p&gt;

&lt;p&gt;A resposta foi um componente novo, que não processa nada: só &lt;strong&gt;observa o sistema de fora&lt;/strong&gt; e decide quando avançar. Três decisões dele merecem registro.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fila de espera respeita a ordem de chegada.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;O sinal de conclusão é o silêncio das filas — incluindo o que está em execução&lt;/strong&gt;. 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. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Um upload por vez&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pipoca pronta
&lt;/h3&gt;

&lt;p&gt;Quando o backlog daquele usuário drena e as filas silenciam, sai o e-mail: "&lt;em&gt;Pipoca pronta! Seu histórico do TVTime foi importado com sucesso.&lt;/em&gt;”&lt;/p&gt;

&lt;p&gt;O envio é &lt;strong&gt;best-effort&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  O bug que nenhum teste local encontra
&lt;/h3&gt;

&lt;p&gt;Esta é uma parte interessante, porque não tem nada a ver com nuvem.&lt;/p&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;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?”.&lt;/p&gt;

&lt;p&gt;No instante em que a limpeza entrou no ar, &lt;strong&gt;todo usuário já processado passou a ser reportado como se nunca tivesse enviado nada&lt;/strong&gt;. O APP pediria o arquivo de novo e correria o risco dos dados serão duplicados no Chippu.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;"Se o arquivo foi extraído, então o usuário fez upload” parecia prático. Era uma armadilha com gatilho de tempo.&lt;/p&gt;

&lt;h4&gt;
  
  
  O sinal errado
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F31ocn5yviv1fnl5snyxy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F31ocn5yviv1fnl5snyxy.png" alt="Um bug invisível: dois serviços serverless, escritas com dias de diferença, discordando sobre o que significa um arquivo existir. " width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
Um bug invisível: dois serviços serverless, escritas com dias de diferença, discordando sobre o que significa um arquivo existir. &lt;/p&gt;

&lt;h3&gt;
  
  
  Guardar o ZIP possibilitou mudar de ideia
&lt;/h3&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;Quando passamos a entender melhor o formato do TVTime, a lista de arquivos úteis foi crescendo e &lt;strong&gt;os uploads já recebido não precisariam ser pedidos novamente&lt;/strong&gt;. Um endpoint reaplica a interpretação atual sobre os ZIPs que já estão guardados.&lt;/p&gt;

&lt;p&gt;Um detalhe importa muito aqui: a reinterpretação, por padrão, &lt;strong&gt;só relê — não reexecuta as ações&lt;/strong&gt;. Refazer as gravações exige pedir explicitamente.&lt;/p&gt;

&lt;p&gt;Separar '&lt;strong&gt;reler o arquivo&lt;/strong&gt;' de &lt;strong&gt;'executar as ações'&lt;/strong&gt; é o que garante a segurança de rodar tudo isso em produção &lt;strong&gt;sem afetar quem está usando o app&lt;/strong&gt;. As duas coisas parecem uma só até o dia em que você precisa fazer a primeira sem a segunda.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Como eu sabia que funcionava antes do deploy
&lt;/h3&gt;

&lt;p&gt;Prazo curto costuma virar desculpa para testar em produção. Fizemos o oposto, com um ambiente local &lt;strong&gt;híbrido —&lt;/strong&gt; nem tudo falso, nem tudo real:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Camada&lt;/th&gt;
&lt;th&gt;No teste local&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Armazenamento e banco de conteúdos&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Reais&lt;/strong&gt;, só leitura, com CSVs de um upload verdadeiro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Banco e filas&lt;/td&gt;
&lt;td&gt;Versões locais, em Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leitura do backend do Chippu&lt;/td&gt;
&lt;td&gt;Simulação que repassa para o backend real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Escrita&lt;/strong&gt; no backend do Chippu&lt;/td&gt;
&lt;td&gt;Simulação que só &lt;strong&gt;registra o payload exato&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  O botão de pânico precisa ser mais rápido que um deploy
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;O &lt;strong&gt;estrutural&lt;/strong&gt; 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: &lt;strong&gt;os consumidores sobem desligados por padrão&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;O &lt;strong&gt;operacional&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  O que ficou
&lt;/h3&gt;

&lt;p&gt;No fim das contas, a nuvem não resolve problemas sozinha. Montar essa arquitetura não foi sobre conectar serviços, mas sobre &lt;strong&gt;lidar com restrições rígidas da plataforma usando atalhos que ninguém documenta&lt;/strong&gt;. Foi entender que escalar infraestrutura nem sempre é a resposta e, sobretudo, aceitar que a decisão mais lógica costuma ser a pior.&lt;/p&gt;

&lt;p&gt;Três ideias renderam mais que todas as outras:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;O tempo limite da plataforma virou parâmetro do algoritmo em dois momentos&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache por conteúdo, não por usuário&lt;/strong&gt;. 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. &lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Cinco dias. Cinco funções, duas filas e uma pilha de decisões que só pareceram óbvias depois.&lt;/p&gt;

&lt;p&gt;O TVTime saiu do ar no prazo. O histórico dos usuários, não.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>infrastructure</category>
      <category>microservices</category>
    </item>
    <item>
      <title>[EN] TV Time shut down. We only had 5 days to save its users' history</title>
      <dc:creator>João Franchini</dc:creator>
      <pubDate>Mon, 12 Oct 2026 17:38:52 +0000</pubDate>
      <link>https://dev.to/jhoni_franchini/en-tv-time-shut-down-we-only-had-5-days-to-save-its-users-history-1gkf</link>
      <guid>https://dev.to/jhoni_franchini/en-tv-time-shut-down-we-only-had-5-days-to-save-its-users-history-1gkf</guid>
      <description>&lt;p&gt;How we bypassed cloud challenges to increase our downloads by 102% with users from a competitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deadline wasn't ours
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.google.com/search?q=https://techcrunch.com/2026/07/02/popular-tv-tracking-app-tv-time-is-shutting-down-as-company-focuses-on-ai/" rel="noopener noreferrer"&gt;TV Time announced it was shutting down its operations&lt;/a&gt;. For those who had used the app for years, it was a decade of logged movies and series about to evaporate. Thousands of people faced the same problem with no effective solution.&lt;/p&gt;

&lt;p&gt;TV Time offered some help to its soon-to-be orphaned users. It was possible to export all activity tracked in the app into a .zip file before the final date. However, this export only solved part of the problem - the user would have the file but nowhere to put it.&lt;/p&gt;

&lt;p&gt;I work at Omelete Company, and we have Chippu, an app that allows its users to track their history in the same way as TV Time. The reasoning was simple. The hard part was the deadline: we got the green light with only &lt;strong&gt;five days&lt;/strong&gt; left before TV Time went offline. After that, users who hadn't exported their history would lose everything because there would be nowhere left to export from.&lt;/p&gt;

&lt;p&gt;Spoiler: &lt;strong&gt;It worked!&lt;/strong&gt; Between July 12th and August 3rd, the feature resulted in a 102% increase in app store downloads compared to the previous month. We had records of people installing the APP specifically so they wouldn't lose their TV Time history.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Business result&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflcoyj2pbccrepacsbr7.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflcoyj2pbccrepacsbr7.jpeg" alt="Chippu downloads during the TV Time migration period." width="799" height="436"&gt;&lt;/a&gt;&lt;br&gt;
Chippu downloads during the TV Time migration period.&lt;/p&gt;

&lt;p&gt;Here, I will share the hidden limits, the obvious ideas we discarded, and a bug that only blew up in production.&lt;/p&gt;
&lt;h2&gt;
  
  
  The map: three problems, not one
&lt;/h2&gt;

&lt;p&gt;Before getting to the code, an overview is worthwhile. "Import TV Time history" sounds like a single task. It's actually three, and each has a different bottleneck.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Receive and validate the file.&lt;/strong&gt; The user sends a ZIP. We need to receive the file without freezing the app, validate if it is a TV Time export, and reply to the user as fast as possible. Bottleneck: &lt;strong&gt;response time.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extract the data in the cloud&lt;/strong&gt;. Tens of megabytes of CSV to read line by line, cross-reference with the content database, and transform into records. This takes time, and the user has already closed the app. Bottleneck: &lt;strong&gt;execution limits.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save and notify&lt;/strong&gt;. Save everything without duplicating data, keep the backend up and running for active users, and send an email the second the process finishes. Bottleneck: &lt;strong&gt;knowing when it finished.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rest of the text follows this order.&lt;/p&gt;
&lt;h4&gt;
  
  
  Full flow diagram
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3hvgiz1cgctkyml3sr3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3hvgiz1cgctkyml3sr3.jpg" alt="Three chained bottlenecks. Each solved for a different reason." width="800" height="369"&gt;&lt;/a&gt;&lt;br&gt;
Three chained bottlenecks. Each solved for a different reason.&lt;/p&gt;
&lt;h2&gt;
  
  
  Part 1 - Receive and validate
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The promise the architecture couldn't keep
&lt;/h3&gt;

&lt;p&gt;The initial design was as follows: the app requests a temporary URL from a serverless service, sends the ZIP directly to storage, and this triggers background processing in another serverless service. Simple and Fast - the file doesn't need to pass through our server.&lt;/p&gt;

&lt;p&gt;Then came the requirement that complicated everything: the APP needed the serverless service to confirm if the ZIP had the correct files before moving forward.&lt;/p&gt;

&lt;p&gt;Seems reasonable. It was impossible.&lt;/p&gt;

&lt;p&gt;The reason was due to the sequence of executions, not the technology. The serverless service delivers the temporary URL and responds &lt;strong&gt;before the file is saved in storage&lt;/strong&gt;. The upload happens later and doesn't even pass through our infrastructure. Therefore, it's not possible for a response to wait for a file that hasn't been sent yet - it would be like asking the cashier to confirm your purchase before you even enter the store.&lt;/p&gt;

&lt;p&gt;The way out was to stop trying to do both things in a single call. I created a second endpoint that the app calls right after the upload. It confirms delivery, validates the file, and returns the verdict right away.&lt;/p&gt;

&lt;p&gt;Two calls instead of one. But each has a clear definition of what it can guarantee - and that is worth more than saving &lt;strong&gt;a single request.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The 212-byte trick
&lt;/h3&gt;

&lt;p&gt;The question that arose during development was this: wouldn't downloading the entire file just to look inside it be exactly the &lt;strong&gt;slowdown point of this validation&lt;/strong&gt;?&lt;/p&gt;

&lt;p&gt;It would. And the answer didn't come from any more expensive service. It came from two details that were already there:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A ZIP stores its index at the end of the file.&lt;/strong&gt; The list of what's inside - the Central Directory - is in the last few bytes, not at the beginning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The HTTP protocol knows how to download a piece of a file.&lt;/strong&gt; The Range header allows you to request "only bytes from X to Y", and the storage respects that.&lt;/p&gt;

&lt;p&gt;Putting these two points together, we reached a conclusion: to know what's inside the ZIP, you just need to read its very end.&lt;/p&gt;

&lt;p&gt;We just had to convince Python's ZIP library that this remote file was a local file. To do this, we created code that pretends to be a regular file, but under the hood, it only downloads the pieces we need to read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Range&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bytes=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pos&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's essentially it. The library does what it always does - jumps to the end of the file looking for the index - and receives only that fragment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result achieved: a 39,323-byte ZIP was validated by reading 212 bytes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And the gain isn't just the punctual savings. The cost of validation now depends on &lt;strong&gt;how many files are in the index&lt;/strong&gt;, not the weight of the content. A 500MB ZIP validates in the same time as a 40KB one. The bottleneck wasn't optimized: it ceased to exist.&lt;/p&gt;

&lt;h4&gt;
  
  
  The 212-byte trick
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7oqa8ja10grvu5vq427l.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7oqa8ja10grvu5vq427l.jpg" alt="To know what's inside a ZIP, you don't need to open the ZIP." width="800" height="346"&gt;&lt;/a&gt;&lt;br&gt;
To know what's inside a ZIP, you don't need to open the ZIP.&lt;/p&gt;

&lt;h3&gt;
  
  
  What doesn't appear in the diagram
&lt;/h3&gt;

&lt;p&gt;Two small decisions in this phase, the kind nobody draws but everybody pays for if forgotten.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Pre-signed URL doesn't limit size&lt;/strong&gt;. Whoever signs the URL doesn't control what the client sends through it. The app warns about the 50MB ceiling before the user spends their bandwidth, but client-side validation is a suggestion, not a guarantee. So the server checks the real size at validation time and, if it exceeds the limit, &lt;strong&gt;deletes the file&lt;/strong&gt; and rejects it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A rejected ZIP is also deleted&lt;/strong&gt;. This isn't just storage hygiene - the real reason for this line will appear later, in a bug that almost cost us dearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2 - Extract the data in the cloud
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The same trick, now inside the CSV
&lt;/h3&gt;

&lt;p&gt;Validating a ZIP is reading an index. Processing the entire history is reading everything, line by line. The bottleneck changes: now the problem is that a serverless service has a &lt;strong&gt;15-minute&lt;/strong&gt; lifespan, and a cinephile's history doesn't fit in that.&lt;/p&gt;

&lt;p&gt;The idea was the same as the validation: never download the whole file. The processor reads 5MB blocks starting from a position, and each block is a byte-range request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The position is counted in bytes, not in characters&lt;/strong&gt;. Counting after decoding the text could fail right at the first accent. Since a letter in UTF-8 can take up more than one byte, the math silently breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every block break falls in the middle of a line&lt;/strong&gt;. With absolute certainty, always. So the code looks for the last line break in the block, processes only up to there, and returns the exact position where the incomplete line begins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Serverless service that requeues itself
&lt;/h3&gt;

&lt;p&gt;The piece that dissolves the 15-minute limit is missing, and it is almost obvious once seen.&lt;/p&gt;

&lt;p&gt;Before reading each block, the serverless function asks how much lifespan it has left. If less than 30 seconds remain, &lt;strong&gt;it sends a message to itself&lt;/strong&gt; with the exact position where it stopped - and finishes cleanly, without errors.&lt;/p&gt;

&lt;p&gt;A few seconds later, a new execution starts exactly from there.&lt;/p&gt;

&lt;p&gt;The effect is that &lt;strong&gt;there is no such thing as a file that is too big&lt;/strong&gt;. There is a file that needs more rounds. It is the same logic as the 212-byte trick: a platform limit ceased to be a blocker and became a parameter of the algorithm.&lt;/p&gt;

&lt;p&gt;Both times, the way out didn't come from hiring larger infrastructure. It came from understanding the file format and the protocol that transports it.&lt;/p&gt;

&lt;h4&gt;
  
  
  The function that requeues itself
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuzupna03hdnpdq1ryt48.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuzupna03hdnpdq1ryt48.jpg" alt="The function gives up before it dies, and leaves a note for the next one." width="800" height="398"&gt;&lt;/a&gt;&lt;br&gt;
The function gives up before it dies, and leaves a note for the next one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Six seconds between each call - and why the reason matters more than the number
&lt;/h3&gt;

&lt;p&gt;Here comes the most interesting restriction of the project, and it's not technical: it's about coexistence.&lt;/p&gt;

&lt;p&gt;Chippu's infrastructure didn't allow me direct access to the database, so the recording of the TV Time history had to pass through the exact same API that served the public. The weight of this? The import didn't consume limits of an external tool, but it competed for resources directly with our own users. All of this running 24 hours a day to empty a huge queue against the clock.&lt;/p&gt;

&lt;p&gt;A burst of thousands of requests there wouldn't just generate a rate-limiting error. It would make the app slow for users who weren't doing any importing, didn't know a migration was happening, and just wanted to rate a movie before going to sleep.&lt;/p&gt;

&lt;p&gt;So the decision was deliberately counterintuitive: &lt;strong&gt;we intentionally made our own feature slower&lt;/strong&gt;. One write every six seconds. Nobody asked for this number; it came from choosing a pace that would cause irrelevant noise to the app's normal traffic.&lt;/p&gt;

&lt;p&gt;And here it was necessary to look at the documentation carefully: &lt;strong&gt;you cannot configure a queue to deliver one message every six seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;To bypass this bottleneck, a pause was implemented inside the function itself, before each write.&lt;/p&gt;

&lt;p&gt;Except that, by itself, &lt;strong&gt;this pause is useless&lt;/strong&gt;. Fifty executions waiting six seconds in parallel is the same thing as fifty executions with no pause at all.&lt;/p&gt;

&lt;p&gt;It can only truly control the flow when combined with a lock: &lt;strong&gt;a maximum of one function execution at a time&lt;/strong&gt;. Then the pause applies to the whole system.&lt;/p&gt;

&lt;p&gt;Two settings complete the architecture, and they exist for recovery reasons, not performance:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One item at a time&lt;/strong&gt;: If the time runs out, only that item returns to the queue. Using batches would make the system reprocess things that had already succeeded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generous invisibility timeout&lt;/strong&gt;: The time the message remains invisible in the queue needs to be much greater than the function's duration.&lt;/p&gt;

&lt;h3&gt;
  
  
  When each call costs six seconds, cutting calls becomes a product decision
&lt;/h3&gt;

&lt;p&gt;A six-second spacing changes how the system works: &lt;strong&gt;every avoided call is six seconds less waiting to finish the import&lt;/strong&gt;. "Reducing requests" was no longer an optimization but a requirement!&lt;/p&gt;

&lt;p&gt;Two decisions were defined based on this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A full season becomes a single call&lt;/strong&gt;. Chippu's backend allowed marking a title, a season, or an episode as watched. When we resolve the title, we also get how many episodes each season has. If the person watched all of them, we issue a season mark. If they watched a part, one per episode.&lt;/p&gt;

&lt;p&gt;A fully watched 24-episode season dropped from 24 requests to one. Timed on the clock: from 2 minutes and 24 seconds to 6 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the API allowed, we sent in batches of 20&lt;/strong&gt;: favorites, 'watch later', and simple marks traveled together. The detailed mark, however, remained individual. Since each item has its own date, season, and platform, grouping would mean losing data.&lt;/p&gt;

&lt;p&gt;The rule that stuck: &lt;strong&gt;if it's standard, it goes in a batch; if it has details, it goes alone&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Combined, both drop a user with thousands of episodes from the thousands of requests tier to the hundreds. Without touching the infrastructure.&lt;/p&gt;

&lt;h4&gt;
  
  
  One season, two paths
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpht7vrd2puxu6ccu1qyj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpht7vrd2puxu6ccu1qyj.jpg" alt="When every request costs 6 seconds, grouping stops being an elegance and becomes a requirement." width="800" height="398"&gt;&lt;/a&gt;&lt;br&gt;
When every request costs 6 seconds, grouping stops being an elegance and becomes a requirement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not duplicating without a database
&lt;/h3&gt;

&lt;p&gt;The serverless function that dispatches the actions has no memory. It doesn't query a control table, it doesn't record what it has already done. The entire guarantee of not duplicating comes from the queue — and this was a choice, not an economy.&lt;/p&gt;

&lt;p&gt;Each message had a hash calculated from the fields that define the action — user, type, title, season, episode. Reprocessing the same upload generates exactly the same messages, and the queue discards repetitions on its own.&lt;/p&gt;

&lt;p&gt;The intermediate aggregation follows the same philosophy. Instead of accumulating episodes in the function's memory — which evaporates between executions — the accumulation happens inside the database, using &lt;strong&gt;sets.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A set is &lt;strong&gt;consistent&lt;/strong&gt; by definition: adding the same episode twice doesn't duplicate anything. Reprocessing a block after a failure is safe &lt;strong&gt;by construction, not by control.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The dictionary that already existed in-house
&lt;/h3&gt;

&lt;p&gt;Here is the most annoying problem of this whole migration: TV Time CSVs had no universal content identifier. There was only the title name as it appeared on TV Time, and nothing else.&lt;/p&gt;

&lt;p&gt;"Breaking Bad" is not data. It is text. And text alone doesn't match with a database.&lt;/p&gt;

&lt;p&gt;Fortunately, the missing piece already existed in-house, built for another purpose. Omelete has a movie and series content database. No one built it thinking about history migration.&lt;/p&gt;

&lt;p&gt;That was exactly what solved the history migration.&lt;/p&gt;

&lt;p&gt;The resolution took two requests: the raw TV Time name goes to the Omelete content database and returns with a public identifier; this identifier goes to the Chippu backend and returns with the internal ID, plus the map of seasons, episodes, and platforms — which is precisely what feeds the full season rule from the previous section.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cache per content
&lt;/h3&gt;

&lt;p&gt;Two requests per title is the most expensive operation in the entire pipeline. And the cache key &lt;strong&gt;does not include the user&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The consequence is direct: the first one to import &lt;em&gt;Breaking Bad&lt;/em&gt; pays for the two requests. &lt;strong&gt;Everyone else pays zero —&lt;/strong&gt; including those who import months later. This is not just an optimization, but the difference between the system draining in hours or days.&lt;/p&gt;

&lt;p&gt;Two details that make the cache yield even more:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure is also cached&lt;/strong&gt;. A title with no match is registered as "not found" and is never queried again. This way we avoid one failed search per person.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key is the normalized title&lt;/strong&gt;. Before becoming a key, the name loses accents, turns lowercase, and has spaces collapsed. &lt;em&gt;Amélie&lt;/em&gt;, &lt;em&gt;Amelie&lt;/em&gt;, and &lt;em&gt;AMELIE&lt;/em&gt; become 'amelie' to facilitate the lookup in the saved cache.&lt;/p&gt;

&lt;h4&gt;
  
  
  Cache per content, not per user
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyqc8petgzyg84nz2pqd9.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyqc8petgzyg84nz2pqd9.jpg" alt="In a mass migration, catalogs overlap much more than one imagines." width="799" height="395"&gt;&lt;/a&gt;&lt;br&gt;
In a mass migration, catalogs overlap much more than one imagines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3 &lt;strong&gt;— Save and notify&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The asynchronous system doesn't know it finished
&lt;/h3&gt;

&lt;p&gt;With everything decoupled by queues, a problem appeared that wasn't in the plan: &lt;strong&gt;no component knows that a user's work has finished&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The processor knows it published the actions, but not that they were executed. The executor knows it performed an action, but not that it was the last one. The queue knows how many messages it has, but not whose they are.&lt;/p&gt;

&lt;p&gt;And we needed this instant, because it's what triggers the email to a person who closed the app hours ago.&lt;/p&gt;

&lt;p&gt;The answer was a new component, which processes nothing: it just &lt;strong&gt;observes the system from the outside&lt;/strong&gt; and decides when to advance. Three of its decisions are worth noting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The waiting queue respects the order of arrival.&lt;/strong&gt; Each upload enters a backlog ordered by the original file's date. With a short deadline and concentrated volume, the defined rule was: whoever sent first is served first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The completion signal is the silence of the queues — including what is currently executing.&lt;/strong&gt; Every two minutes, it sums the available messages and the ones being processed right now. Just the available ones isn't enough: a queue might report zero precisely because everything is in execution. This is the classic false positive, and summing both categories is what prevents it. On top of that, there's a 90-second wait before declaring completion, providing a window for an executing message to materialize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One upload at a time&lt;/strong&gt;. The next one only starts when the previous one has completely drained. Combined with the lock of one simultaneous execution, this makes the system entirely predictable: at any moment there is one user being processed and a maximum of one request executing against the Chippu backend — which, remember, is the same backend serving the live app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Popcorn is ready
&lt;/h3&gt;

&lt;p&gt;When that user's backlog drains and the queues fall silent, the email goes out: "&lt;em&gt;Popcorn is ready! Your TV Time history was successfully imported.&lt;/em&gt;"&lt;/p&gt;

&lt;p&gt;The delivery is &lt;strong&gt;best-effort&lt;/strong&gt; and doesn't block the queue. If the email fails, the job is marked as completed anyway, with the delivery result recorded alongside. Blocking the entire backlog of users because the email service went down would be trading a small problem for a big one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bug that no local test catches
&lt;/h3&gt;

&lt;p&gt;This is an interesting part, because it has nothing to do with the cloud.&lt;/p&gt;

&lt;p&gt;Upon completing an upload, the system began to delete the already extracted CSVs. The motivation was good: they served their purpose, take up space, and their absence acts as a physical lock against accidental reprocessing. The deletion is conservative — it only happens if the original ZIP still exists, guaranteeing that everything can be regenerated.&lt;/p&gt;

&lt;p&gt;What I didn't realize is that another function, written days before, relied on the existence of those files. The endpoint the APP queries to know if the person had already imported responded based on "does the extracted file exist?".&lt;/p&gt;

&lt;p&gt;The instant the cleanup went live, &lt;strong&gt;every already processed user started being reported as if they had never uploaded anything&lt;/strong&gt;. The APP would request the file again and run the risk of the data being duplicated in Chippu.&lt;/p&gt;

&lt;p&gt;The fix was to change the logic: the correct indicator is the original ZIP, which is permanent. That is why the rejected ZIP is deleted, back in Part 1 — the file's presence became the signal, and leaving invalid garbage behind would corrupt that signal.&lt;/p&gt;

&lt;p&gt;"If the file was extracted, then the user uploaded it" seemed practical. It was a time-bomb trap.&lt;/p&gt;

&lt;h4&gt;
  
  
  The wrong signal
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh7u08zqco9auh8fvfdbk.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh7u08zqco9auh8fvfdbk.jpg" alt="An invisible bug: two serverless services, written days apart, disagreeing on what a file's existence means." width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
An invisible bug: two serverless services, written days apart, disagreeing on what a file's existence means.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping the ZIP allowed us to change our minds
&lt;/h3&gt;

&lt;p&gt;The original ZIP is never deleted. Not after being processed, not after the email. It was kept out of a "don't throw away the source" instinct — and it ended up unlocking something that wasn't in the plan.&lt;/p&gt;

&lt;p&gt;As we started to better understand the TV Time format, the list of useful files grew and &lt;strong&gt;the already received uploads wouldn't need to be requested again&lt;/strong&gt;. An endpoint reapplies the current interpretation onto the ZIPs that are already stored.&lt;/p&gt;

&lt;p&gt;A detail matters a lot here: the reinterpretation, by default, &lt;strong&gt;only re-reads — it does not re-execute the actions&lt;/strong&gt;. Redoing the writes requires asking explicitly.&lt;/p&gt;

&lt;p&gt;Separating '&lt;strong&gt;re-read the file&lt;/strong&gt;' from '&lt;strong&gt;execute the actions&lt;/strong&gt;' is what guarantees the safety of running all this in production &lt;strong&gt;without affecting those using the app&lt;/strong&gt;. The two things seem like one until the day you need to do the first without the second.&lt;/p&gt;

&lt;p&gt;The principle: as long as the raw data is available, any interpretation decision is reversible. Keeping the ZIP costs pennies and allows us to change our minds about the file processing — exactly what this project demanded.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I knew it worked before the deploy
&lt;/h3&gt;

&lt;p&gt;A short deadline usually becomes an excuse to test in production. We did the opposite, with a &lt;strong&gt;hybrid&lt;/strong&gt; local environment — not entirely fake, not entirely real:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;In the local test&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Storage and content database&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Real&lt;/strong&gt;, read-only, with CSVs from a true upload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database and queues&lt;/td&gt;
&lt;td&gt;Local versions, in Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chippu backend reading&lt;/td&gt;
&lt;td&gt;Simulation that forwards to the real backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Writing&lt;/strong&gt; to the Chippu backend&lt;/td&gt;
&lt;td&gt;Simulation that only &lt;strong&gt;logs the exact payload&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The value is in the asymmetry. Reading is real, so the test faces the true data with all the dirtiness that third-party data has. Writing is fake, so the environment is physically incapable of touching production. And given that the writing would hit the backend serving real users, this guarantee was a requirement.&lt;/p&gt;

&lt;p&gt;Since the simulation logs the literal payload of each action, that was where I validated the exact format expected by the backend before any deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The panic button needs to be faster than a deploy
&lt;/h3&gt;

&lt;p&gt;With a window of days, "reverting" needs to be faster than publishing code. That is why the system has two levels of control.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;structural&lt;/strong&gt; level changes on deploy, leaving a trail: turning queue consumers on or off, adjusting concurrency, changing the log level. A deliberate detail: &lt;strong&gt;the consumers boot up turned off by default&lt;/strong&gt;. Publishing code and activating consumption are separate acts — you can deploy a fix without it immediately starting to drain the accumulated backlog.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;operational&lt;/strong&gt; level changes in seconds, without a deploy. Pausing the entire pipeline is one command; resuming it, another. This exists for a very concrete reason: if the APP started showing signs of slowness, I needed to be able to shut down the import as soon as possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  What remained
&lt;/h3&gt;

&lt;p&gt;In the end, the cloud doesn't solve problems by itself. Building this architecture wasn't about connecting services, but about &lt;strong&gt;dealing with rigid platform restrictions using shortcuts that nobody documents&lt;/strong&gt;. It was understanding that scaling infrastructure isn't always the answer and, above all, accepting that the most logical decision is usually the worst one.&lt;/p&gt;

&lt;p&gt;Three ideas yielded more than all the others:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The platform's timeout limit became a parameter of the algorithm twice&lt;/strong&gt;. We beat the first bottleneck with a partial read of the ZIP, and overcame the serverless 15-minute ceiling with a function that recreates itself in the queue. Both times, the way out came from understanding the protocols — not from paying for more infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache by content, not by user&lt;/strong&gt;. The same table that accelerates one import accelerates all the following ones, from anyone. In a mass migration, this single choice defines whether the system drains in hours or days.&lt;/p&gt;

&lt;p&gt;The intentionally slower feature was the most mature decision of the project. We could have imported everything much faster. We could also have left the APP crawling for thousands of people who had nothing to do with the migration. Choosing the slow pace wasn't a technical limitation: it was deciding who had priority.&lt;/p&gt;

&lt;p&gt;Five days. Five functions, two queues, and a stack of decisions that only seemed obvious afterward.&lt;/p&gt;

&lt;p&gt;TV Time went offline on schedule. The users' history did not.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>infrastructure</category>
      <category>microservices</category>
    </item>
  </channel>
</rss>
