Pular para o conteúdo

Uma mudança de schema, um alarm de 30 segundos e uma noite de um bilhão de linhas

Como uma migração de Proof of Play em Cloudflare Durable Objects virou um loop de armazenamento sem limite — e o radar que construímos para que o próximo não se esconda.

  • ~625 milhões

    linhas escritas faturáveis

    Writes SQLite, não dinheiro nem registros únicos

  • ~30 s

    repetição do alarm

  • Telas tocando

    reprodução nas telas preservada

Foto de Paulo R.

por16 min de leitura

Ilustração de um nó de schema, pulsos de alarm e uma tempestade de dados em âmbar sobre fundo escuro.

Parte 1O incidente

A versão curta

Entre 2 e 3 de setembro de 2026, a produção da Lucimark entrou em uma tempestade de armazenamento SQLite em Durable Object.

Acabávamos de enviar uma mudança de schema de Proof of Play: guardar a localização do player como uma trilha de geohash nos rollups de 15 minutos, em vez de embutir a geografia na chave única de cada linha. A mudança de modelo era sólida. A estratégia de migração não foi dimensionada para o nosso maior tenant — centenas de milhares de linhas de rollup dentro de um único TenantDO.

Dois modos de falha, em sequência.

Dia um — writes. Uma reconstrução síncrona no startup do Durable Object produziu cerca de 625 milhões de writes SQLite faturáveis em poucas horas. Nos preços de lista da Cloudflare para SQLite em Durable Objects (documentação consultada em 25 de agosto de 2026), writes no plano Workers Paid custam US$ 1,00 / milhão de linhas e reads US$ 0,001 / milhão — cerca de 1.000×. Isso é equivalente bruto de lista, antes das franquias incluídas, não o valor efetivamente faturado.

Noite e manhã seguinte — reads. Movemos o restante para um backfill assíncrono acionado por alarm — um replay em lotes do trabalho que o boot não conseguiu terminar. Isso parou as falhas de startup, mas, com o índice UNIQUE fora, a máquina de estados repetia SQL O(n) — tempo proporcional ao número de linhas da tabela — a cada ~30 segundos. Os picos horários de reads chegaram a aproximadamente 0,3–1,4 bilhão de linhas faturáveis. Os reads eram bem mais baratos por linha. O perigo era outro: a migração tinha virado um loop sem limite. Nada na plataforma a interromperia naturalmente.

Writes causaram o choque imediato de custo. Reads eram cerca de 1.000× mais baratos por linha no preço de lista, mas revelaram um loop sem limite. Equivalentes brutos de lista, antes das franquias incluídas; não é a fatura.

Não foi artefato de GraphQL, glitch de billing nem ingest de Proof of Play descontrolado. O ingest daquele dia foi só milhares de inserts de rollup. O tráfego HTTP dos Workers aumentou de forma modesta.

Quando entendemos o loop, a contenção foi direta: estacionar o housekeeping acionado por alarm, publicar cooldown e um índice helper, parar de rearmar estados pesados a cada 30 segundos, remover rollups históricos descartáveis de Proof of Play do tenant afetado e marcar a migração como completa. Os reads caíram de dezenas de milhões por hora para perto do baseline na mesma manhã.

A tempestade estava no data plane — custo e saturação, não uma interrupção de playback na frota.

Por que essa arquitetura tornou o incidente possível

A Lucimark é uma plataforma DOOH para operar redes de telas pelo navegador.

Nossa API roda em Cloudflare Workers. Cada tenant de cliente é representado por um Durable Object — um TenantDO — com seu próprio banco SQLite.

Os rollups de Proof of Play também vivem aí: resumos de 15 minutos e diários descrevendo o que tocou, onde e quando.

O incidente ficou isolado no SQLite de Proof of Play daquele TenantDO. A frota de telas não era o blast radius.

Essa arquitetura nos dá propriedades úteis:

  • isolamento por tenant;
  • localidade dos dados;
  • um blast radius claro;
  • nenhum banco relacional compartilhado virando gargalo de todos os clientes.

Mas também cria uma superfície de faturamento.

O SQLite de Durable Object cobra por linhas lidas e escritas. Writes são dramaticamente mais caros por linha do que reads, mas qualquer um dos dois pode virar problema operacional quando um algoritmo toca uma tabela grande inteira de forma repetida.

Um tenant de alto volume nessa arquitetura não é simplesmente “mais HTTP”.

É mais linhas dentro de um SQLite, potencialmente revisitadas por uma máquina de estados cada vez que aquele Durable Object acorda.

Os tenants medianos atravessaram essa migração sem incidente.

Uma tabela grande de rollup não.

A Cloudflare oferece notificações de budget, mas elas não interrompem o consumo em runtime. O próprio sistema ainda precisa de limites de engenharia.

O que estávamos tentando enviar

Nossas linhas de Proof of Play de 15 minutos originalmente carregavam geografia dentro da natural key — a chave de negócio que identifica o rollup, distinta da chave primária interna.

Isso fazia da geografia, na prática, parte da identidade do registro: mudar informação de localização podia bifurcar o que deveria representar o mesmo rollup lógico.

O novo modelo guardava uma trilha de geohash em separado e removia a geografia da chave única.

A geografia saiu da identidade do rollup e passou para a coluna geohash_trail. Identificadores técnicos iguais aos do schema.

Antes

rollup

natural_key
<event + geo + …>
metrics

UNIQUE (natural_key)

Geo na identidade

Depois

rollup

natural_key
<event + …>
metrics
geohash_trail
trilha de localização

UNIQUE (natural_key)

Geo como dado

Isso exigia que a migração:

  • introduzisse a nova representação;
  • atualizasse as linhas de rollup existentes;
  • mudasse a natural key;
  • deduplicasse colisões;
  • reconstruísse a unicidade.

Para um tenant pequeno, isso é uma migração.

Para o maior tenant afetado — cerca de 540k rollups de 15 minutos mais 280k linhas diárias — era, na prática, reescrever uma das tabelas históricas mais quentes do objeto.

Subestimamos essa tabela.

Linha do tempo

Horários em BRT (UTC−3), o fuso do post-mortem operacional.

Do deploy à recuperação, em duas janelas: pico de writes em 2 de setembro, depois read storm acionado pelos alarms. Visão geral; a tabela abaixo é a fonte legível.
  1. 2 set ~14:16 BRT

    Deploy da migração geohash-trail

    Trabalho pesado no startup do Durable Object.

  2. 2 set 14:00–15:00 BRT

    Pico de writes

    Dois buckets horários consecutivos, ~333M e ~290M de linhas escritas faturáveis/h — sem atribuir qual hora a qual total. 503s e resets no tenant grande.

  3. 2 set fim da tarde

    Migração sai do boot

    Backfill assíncrono por alarm. Os 503s param; o amplificador só muda de lugar.

  4. 2 set noite

    Read storm

    Writes normalizam. Reads faturáveis ~0,3–1,4B/h enquanto o UNIQUE está fora.

  5. 3 set manhã

    Alarms estacionados (~09:20) e hotfix

    O loop de scan para. Residual ainda ~57M–74M reads/h.

  6. 3 set ~12:01 BRT

    PoP histórico removido

    Dataset operacional não autoritativo; ingest forward segue. Migração marcada done.

Parte 2A recuperação

A curva horária de reads depois disso está na seção de recuperação — não aqui.

Causa raiz: quatro coisas que ficaram perigosas juntas

1. Trabalho pesado em uma tabela grande

A migração exigia operações equivalentes a:

  • remover temporariamente a unicidade;
  • atualizar linhas existentes;
  • deduplicar;
  • recriar o índice UNIQUE.

Enquanto a estrutura UNIQUE estava indisponível, operações como deduplicação e reconstrução de índice precisavam tocar grandes partes da tabela.

Em centenas de milhares de linhas, isso importa.

De forma repetida, vira o incidente.

2. Mover trabalho sync para async não o tornou seguro

Nossa primeira implementação fazia demais durante o startup do Durable Object.

Isso causou resets e 503s.

Mover o trabalho para uma máquina de estados acionada por alarm foi a resposta correta ao problema de startup.

Async ≠ seguro.

A versão síncrona tinha um limite natural de falha: o startup podia estourar timeout ou o objeto podia resetar.

A versão assíncrona podia continuar acordando indefinidamente.

Tirar trabalho caro do caminho de request removeu uma restrição operacional sem adicionar outra.

3. A cadência do alarm virou multiplicador de custo

Durante a migração, os estados eram aproximadamente:

pending → updating → deduping → indexing

Os updates em lote eram gerenciáveis.

Os estados perigosos eram os que podiam exigir trabalho O(n) — varrer ou reconstruir a tabela inteira.

O backfill podia se rearmar em cadência de cerca de 30 segundos enquanto incompleto.

Em uma tabela com mais de meio milhão de linhas de rollup, isso significava que uma operação teoricamente cara podia ser revisitada cerca de 120 vezes por hora.

É aí que uma migração lenta vira runaway.

Um alarm de ~30 s transformou operações O(n) da migração em um amplificador recorrente. O ×120/h segue da cadência, não de uma medição à parte.
  1. 1Alarm ~30srearma o tick
  2. 2Updatemarca como migrando
  3. 3DedupeGROUP BY · O(n)
  4. 4Create unique indexO(n)
  5. 5Failduplicatas permanecem

×120 por hora

A cadência do alarm é um multiplicador de custo.

Frequência de agendamento não pode ser tratada como detalhe de implementação quando a operação agendada escala com o tamanho da tabela.

4. A criação falha de UNIQUE não tinha breaker

Duplicatas permaneceram.

O rebuild do índice UNIQUE falhou.

O erro foi logado.

Então a máquina de estados tentou de novo.

E de novo.

Encontramos cerca de 2.800 falhas de tick de migração, todas associadas ao mesmo tenant grande.

O pior formato possível é direto:

scan → falha → espera 30 segundos → scan de novo

Uma operação DDL que falha dentro de uma máquina de estados recorrente não é só uma mensagem de erro.

É um loop.

E loops precisam de breakers.

O que descartamos

Várias explicações plausíveis não sobreviveram aos dados.

Artefato de GraphQL ou billing

Métricas de Durable Object com escopo de namespace bateram com o comportamento dos logs e com a recuperação abrupta depois de remover o dataset histórico.

Explosão de ingest de Proof of Play

O volume de ingest foi só milhares de inserts de rollup naquele dia.

Isso não explicava centenas de milhões ou bilhões de linhas sendo tocadas.

Tráfego de Workers em toda a conta

O tráfego HTTP aumentou cerca de 2–3×.

O movimento no SQLite foi ordens de magnitude maior.

Todo tenant falhando

Outros tenants de produção não mostraram a mesma assinatura de falha de backfill.

O problema se concentrou em uma tabela grande.

Outage de frontend

Os 503s no dia um vieram de resets de migração do TenantDO — wakes que precisavam do objeto durante a reconstrução síncrona. Depois que a migração passou a async, o problema dominante virou churn de housekeeping, não tela em branco na frota.

Impacto de produto

No tenant afetado, durante a migração síncrona, algumas operações que precisavam acordar o TenantDO — inclusive partes da experiência de Telas — podiam falhar com 503. Outros tenants não mostraram a mesma assinatura.

Para aquele tenant de alto volume, removemos de propósito os rollups históricos de Proof of Play envolvidos na migração. Foi uma decisão operacional específica daquele dataset: aqueles rollups não eram evidência contratual autoritativa e o ingest forward continuou normalmente.

Apagar dados não é uma estratégia genérica de migração. Neste incidente, continuar uma reescrita sem limite de histórico substituível era pior do que reconstruir o histórico para frente a partir de um estado limpo.

Como mitigamos

Primeiro: parar o multiplicador

A migração era conduzida por alarms do TenantDO.

Então a primeira alavanca útil não foi desligar o ingest de Proof of Play.

Foi estacionar o housekeeping acionado por alarm.

Isso parou o loop de scan repetido.

Essa distinção agora faz parte do nosso modelo operacional: o kill switch correto precisa corresponder ao amplificador que causa o incidente.

Depois: corrigir a máquina de estados

O hotfix de produção introduziu:

  • cooldowns de aproximadamente 15 minutos em torno de estados pesados de dedupe/index;
  • um índice helper não-unique enquanto a unicidade está temporariamente indisponível;
  • short-circuit quando o UNIQUE desejado já está presente;
  • sem retry imediato de UNIQUE enquanto ainda existem duplicatas;
  • falha de criação de índice voltando para dedupe em vez de repetir a mesma operação full-table;
  • progresso da migração desacoplado da cadência ativa normal de 30 segundos.

A regra-chave é simples:

Nunca trate SQL O(n) como um heartbeat.

Depois: eliminar trabalho de migração desnecessário

Mesmo com cooldown e índice helper, reconstruir o dataset histórico in-place ainda teria sido caro.

A correção durável para esse tenant foi eliminar a necessidade de migrar aquelas linhas históricas descartáveis.

Quando os rollups históricos foram removidos e o estado da migração marcado como completo, a superfície de scan desapareceu.

Por fim: verificar a recuperação

Não demos o incidente por encerrado porque um deploy teve sucesso.

Procuramos os sinais reais:

  • rows read por hora caíram de forma abrupta;
  • falhas de tick de migração foram a zero;
  • o comportamento do TenantDO voltou ao baseline;
  • o housekeeping normal pôde ser restaurado.

A curva de recuperação importou mais do que o status do deploy.

Recuperação observada

  1. 74M
  2. 2,9M
  3. 836 mil
  4. 424 mil

linhas lidas por hora

Quatro buckets horários consecutivos depois da remoção do Proof of Play histórico — pontos observados, não uma curva interpolada.

Eixo Y logarítmico, linhas lidas por hora. Pontos observados em buckets horários (3 set ~11h–14h BRT) + faixa overnight de 2 set. Não é uma interpolação contínua.

Parte 3O que mudou

O storm radar

Já tínhamos o Cost Sense, um job de produção a cada 15 minutos que estima o gasto Cloudflare e compara com baselines recentes.

Antes deste incidente, ele era pensado principalmente para pegar drift mais lento de custo no nível da conta.

Isso não bastava.

Essa falha não parecia um outage clássico de aplicação.

Parecia um Durable Object quieto lendo repetidamente o próprio banco.

Um alerta genérico dizendo:

"O gasto Cloudflare parece alto"

teria sido útil na direção certa.

Mas não diria ao operador o que fazer a seguir.

Então adicionamos um storm radar de TenantDO.

O objetivo é simples:

Detectar cedo um padrão de armazenamento sem limite e identificar a alavanca que pode limitá-lo.

Cost Sense transforma telemetria de armazenamento do TenantDO em classificação (read storm ou write storm) e num caminho explícito de mitigação.
  1. MetricsReads · writes · active time
  2. DetectPace · rate · piso horário
  3. ClassifyRead storm ou write storm
  4. AlertSlack
  5. MitigateEstacionar alarms · pausar ingest

O que ele observa

Cada tick de produção agora amostra o namespace Lucimark TenantDO para:

  • rows read do SQLite;
  • rows written do SQLite;
  • active time do Durable Object;
  • buckets horários recentes de armazenamento;
  • volume de requests de Worker e Durable Object como sinais de apoio;
  • comparação com baselines recentes de produção.

A amostra anterior fica armazenada para o monitor calcular deltas em vez de depender só dos totais day-to-date.

Também protegemos contra amostras ausentes depois de deploy. Tratar um valor anterior ausente como zero poderia fabricar um pico falso de um bilhão de linhas.

Três formas de uma tempestade de armazenamento disparar

Pace

Se o consumo day-to-date já está bem à frente do que o baseline histórico sugere, abrir um finding.

Isso pega queimas mais lentas cedo no dia.

Rate

Extrapolamos o delta da janela curta mais recente.

Se a inclinação atual produziria um dia inteiro anormal, abrir um finding mesmo que o total acumulado ainda pareça inofensivo.

Isso pega acelerações súbitas.

Pisos horários absolutos

Comparações relativas não bastam para precipícios.

Por isso o radar tem pisos absolutos calibrados neste incidente.

Read storm

≥ 10 milhões de rows read faturáveis/hora, sustentados em dois buckets horários consecutivos.

A atividade saudável de TenantDO fica normalmente na casa das centenas de milhares até cerca de um milhão de reads por hora.

O incidente produziu 50–70M/hora mesmo na fase mais quieta da manhã e até 0,3–1,4B/hora durante a noite.

O ponto do 10M é alertar bem abaixo do nível que vivemos.

Write storm

≥ 2 milhões de rows written faturáveis/hora, também sustentados.

Writes são o medidor caro.

Um dia saudável da Lucimark produz só uma fração desse número no total. Milhões de writes concentrados em horas individuais merecem atenção imediata mesmo antes de um baseline relativo acompanhar.

A detecção deve nomear a alavanca

Quando o radar dispara, o alerta é feito para alguém que não passou as doze horas anteriores lendo código de migração.

Ele contém:

  • métricas atuais de armazenamento do Durable Object;
  • o finding anormal;
  • informação direcional de custo;
  • baseline recente;
  • o controle de primeira resposta adequado.

Um read storm aponta primeiro para o kill switch de alarm/housekeeping.

Um write storm aponta primeiro para os controles de ingest/amplificação de write.

Essa é a diferença entre observabilidade e mitigação.

"O gasto está alto" é uma observação. "Estacione os alarms" é um runbook.

Num loop que não para sozinho, o segundo é o que limita o incidente.

Backstops

Um monitor de produção a cada 15 minutos é útil, mas nenhum caminho de monitoramento deve ser a única linha de defesa.

Adicionamos dois mecanismos extras.

Checagem diária noturna

O snapshot operacional diário verifica de forma independente taxas anormais de read em Durable Object e pode emitir uma notificação separada.

O objetivo é pegar a classe de problema mais propensa a ganhar vantagem enquanto ninguém está olhando.

Inventário de estado de backfill

Métricas de namespace dizem que a frota TenantDO está anormal.

Elas não identificam necessariamente qual tenant é o responsável.

Por isso o snapshot também inspeciona o estado da migração de Proof of Play nos tenants ativos e reporta migrações incompletas ou desconhecidas.

Os estados aparecem do pior para o melhor:

indexing → deduping → updating → pending → done

Timeouts são classificados como unknown, não como healthy.

Esse é o inventário que não tínhamos em 2 de setembro.

Na época, milhares de linhas de erro idênticas tiveram de ser rastreadas manualmente até um único tenant.

Depois de cada deploy

Deploys de produção de API, app e player agora armam uma janela temporária de Cost Sense com sensibilidade maior.

Durante essa janela:

  • os limiares de pace apertam;
  • os limiares de rate apertam;
  • comportamento anormal de Durable Object fica mais fácil de disparar;
  • qualquer alerta inclui contexto do deploy.

Essa migração entrou em produção como um deploy rotineiro de API.

Uma regressão futura de armazenamento não deveria ganhar uma noite de vantagem só porque as taxas de erro HTTP parecem aceitáveis.

O que mudou nas nossas regras de engenharia

O que já vale na plataforma, em resumo:

  • boot do TenantDO só com schema barato — sem DML em tabela grande nem rebuild caro de unicidade;
  • backfill pesado assíncrono, em lote, resumível, marcado done e seguro contra reexecução depois de reset;
  • cooldown em estados O(n);
  • soak da cauda: testar o maior dataset real, não a mediana;
  • storm radar no Cost Sense, kill switch de alarm, janela pós-deploy e inventário de backfill.

Implementado

  • Política de boot barato no TenantDO
  • Cooldown e índice helper na máquina de estados
  • Cadência de migração desacoplada dos 30 s
  • Storm radar (pace, rate, pisos horários)
  • Kill switch de alarm / housekeeping
  • Janela Cost Sense pós-deploy
  • Checagem noturna e inventário de backfill

Ainda falta

  • Circuit breaker após falhas repetidas de UNIQUE
  • Dedupe incremental em vez de GROUP BY full-table
  • Soak em staging com ≥500k linhas no release gate de PoP
  • Pausar a migração de um tenant sem estacionar o housekeeping inteiro
  • Rebuild-from-scratch quando o grain torna irracional migrar histórico
  • Atribuição de custo SQLite por tenant
  • Tooling mais seguro para saídas destrutivas

Um circuit breaker, aqui, é um limite que para o retry depois de N falhas — pausa, alerta e exige mudança de estado — em vez de deixar o tick repetir para sempre. Ainda não está no caminho de UNIQUE; está na coluna da direita.

O radar não torna uma migração ruim barata. Ele reduz quanto tempo ela pode permanecer sem limite.

O que diríamos a outro time rodando SQLite em Durable Objects

A maior parte já está nas regras acima. O que não cabe num quadro:

Separe observabilidade pela superfície de falha. Gráficos de HTTP não dizem que o SQLite está se varrendo. Observe rows read / rows written diretamente.

DDL que falha é transição de máquina de estados. Se CREATE UNIQUE INDEX pode falhar dentro de um worker recorrente, desenhe essa transição antes de publicar.

O alerta útil nomeia a alavanca. Não “o gasto está alto”. Sim: “esse padrão casa com um loop de scan acionado por alarm — estacione os alarms.”

Esperança não é circuit breaker.

Fechamento

Enviamos uma mudança de modelo de dados de Proof of Play cuja estratégia de migração subestimou um TenantDO grande.

O primeiro modo de falha foram writes caros.

O segundo foi mais revelador: um alarm de 30 segundos tinha transformado trabalho O(n) de migração num loop sem condição natural de parada.

Paramos o multiplicador, corrigimos a máquina de estados, removemos dados históricos que não justificavam uma reescrita sem limite e restauramos o sistema à faixa normal.

Depois mudamos a plataforma para que uma falha semelhante fique visível bem mais cedo.

A lição mais importante não foi o número de linhas.

Foi o formato da falha.

Uma operação recorrente cujo custo escala com o tamanho da tabela precisa de um limite antes de chegar à produção.

Para a Lucimark, isso agora significa mecânica de migração mais rigorosa, cooldowns, kill switches, testes realistas de alto volume e um radar que faz mais do que reportar que o custo está subindo.

Ele nos diz onde está o amplificador — e qual alavanca o interrompe.

O trabalho da Lucimark é deixar operadores rodarem redes de telas com confiança.

Isso inclui as partes que ninguém vê na parede: migrações, rollups, comportamento de armazenamento e o custo de estar errado sobre um loop.

Comece com uma tela, grátis

Comece no plano grátis com 1 tela. Faça upgrade quando a rede crescer.

Compartilhar

Foto de Paulo R.

Paulo R.

Fundador e CTO

Fundador da Lucimark. Constrói a plataforma de ponta a ponta — do player Android na tela até os Workers que servem a API.

Ver perfil completo de Paulo R.

Receba os próximos artigos por e-mail

Sem spam — só quando publicarmos algo novo sobre DOOH, produto ou engenharia.

Ao inscrever-se, você concorda em receber e-mails da Lucimark. Pode cancelar quando quiser.

Usamos cookies analíticos para entender quais artigos são lidos e como melhorar o blog. Você pode aceitar, rejeitar os não necessários ou escolher suas preferências.