Notícias do setor

Segurança em alterações de esquema e migrações de dados numa plataforma de iGaming regulamentada

O banco de dados de uma plataforma não é um documento que se reescreve. É uma estrutura que está sustentando peso enquanto há pessoas em cima dela, e as tabelas que carregam saldos de jogadores, histórico de rodadas e transações liquidadas são as paredes-mestras. Alterar a forma delas é, portanto, uma questão sobre o que a plataforma pode prometer nos minutos em que a alteração está em andamento, e não apenas sobre qual deve ser a nova forma.

É por isso que o trabalho de esquema em uma plataforma regulamentada é diferente do trabalho de esquema em um site. Espera-se que a plataforma liquide rodadas, cumpra limites e responda a perguntas sobre uma transação passada enquanto uma coluna está sendo adicionada, uma restrição está sendo aplicada e milhões de linhas estão sendo reescritas no local. A disciplina de engenharia a seguir trata de fazer isso de forma deliberada: saber qual forma de instrução adquire qual bloqueio, escalonar a alteração de modo que nenhum passo isolado seja ao mesmo tempo grande e irreversível, e manter evidências de que os dados do outro lado são os mesmos dados.

Uma parede de pedra escura de blocos idênticos aparelhados permanece intacta com um único encaixe aberto e vazio, enquanto uma figura de casaco verde-azulado e preto guia com as duas mãos um bloco idêntico para a abertura e uma criatura mecânica em forma de aranha vermelha e creme transporta outro bloco idêntico por um trilho de latão ao pé da parede; o terço esquerdo da cena é pedra escura vazia e silenciosa
Uma estrutura de pé, reparada um bloco de cada vez: o bloco de reposição espera em um berço ao lado do encaixe aberto, em vez de a parede ser derrubada para trocá-lo. A ilustração é arte conceitual gerada para este artigo e não afirma qualquer quantidade, duração ou resultado.

O bloqueio é a alteração, não a instrução

Toda operação de esquema é, na realidade, uma decisão de bloqueio com uma sintaxe anexada, e a disponibilidade da plataforma é decidida pelo bloqueio, e não pelo tempo que a instrução leva para ser planejada.

O PostgreSQL enuncia o padrão sem rodeios em sua documentação de ALTER TABLE: “Um bloqueio ACCESS EXCLUSIVE é adquirido, salvo indicação explícita em contrário.” A mesma página acrescenta a frase que pega as equipes de surpresa, porque ela se aplica a uma instrução que parece uma alteração e se comporta como várias: “Quando várias subinstruções são fornecidas, o bloqueio adquirido será o mais estrito exigido por qualquer subinstrução.” Combinar uma alteração de metadados barata com uma dispendiosa em uma única instrução não faz a média do custo; herda o pior bloqueio da lista e o mantém por todo o comando.

A documentação também registra os casos em que um bloqueio mais leve é suficiente. Adicionar uma chave estrangeira é um deles: ADD FOREIGN KEY “exige apenas um bloqueio SHARE ROW EXCLUSIVE”, embora também adquira um na tabela referenciada. Validar uma restrição que já foi adicionada como NOT VALID é outro, e é a razão pela qual a abordagem por etapas descrita abaixo funciona.

Assim, a primeira regra prática é um hábito de leitura, e não uma técnica: antes de executar qualquer coisa, procure o nível de bloqueio da forma que está sendo usada e divida uma instrução que misture uma subinstrução rápida com uma lenta.

Que alterações reescrevem a tabela e quais apenas tocam os metadados

A segunda questão é se o motor consegue alterar a definição sem reescrever as linhas armazenadas. A documentação de DDL online do MySQL torna a distinção explícita e digna de ser copiada para o plano de alteração, porque a diferença se mede em segundos versus horas em uma tabela grande.

A documentação dele descreve três formas pelas quais uma operação pode se comportar. Uma operação instantânea “apenas modifica os metadados no dicionário de dados”, com um possível bloqueio exclusivo de metadados breve durante a execução e DML concorrente permitido. Uma operação in-place altera a tabela sem uma cópia completa, mas ainda pode precisar reorganizar os dados. Uma operação de cópia reescreve a tabela.

Alteração Como o motor a classifica O que ela custa à plataforma
Adicionar um índice secundário In place, sem reconstrução da tabela, DML concorrente permitido O tempo de construção do índice, mais o caminho de escrita com o qual ele compete
Adicionar o primeiro índice FULLTEXT In place, mas o DML concorrente não é permitido Um passo que bloqueia a escrita, e não um passo em segundo plano
Adicionar uma chave primária In place, reconstrói a tabela; o manual considera a reorganização dos dados substancial e dispendiosa Uma reescrita de grande volume com DML concorrente permitido
Remover uma chave primária Reconstrói a tabela e não permite DML concorrente Uma interrupção das escritas por toda a duração
Alterar o tipo de um índice Instantâneo possível, apenas metadados Praticamente sem custo, por isso faça-o cedo e em separado

Dois detalhes na mesma documentação são fáceis de passar despercebidos, e ambos importam para um plano de alteração. O primeiro é que uma cláusula LOCK é uma afirmação, não uma preferência: se ela pedir um nível menos restritivo “do que o permitido para uma determinada operação DDL, a instrução falha com um erro”. Essa falha é o resultado útil, porque chega antes da alteração, e não durante ela. O segundo diz respeito a quando um índice é realmente utilizável: um índice secundário recém-criado “contém apenas os dados confirmados na tabela no momento em que a instrução CREATE INDEX … termina de ser executada”, e é por isso que um índice pertence ao fim da sequência, e não ao começo.

O hábito equivalente no PostgreSQL se abre com o mesmo raciocínio, mas com vocabulário diferente. Uma forma que reescreve a tabela sob um bloqueio ACCESS EXCLUSIVE é agendada como uma migração; uma forma que adquire SHARE UPDATE EXCLUSIVE é uma alteração de rotina. O plano de alteração deve dizer por escrito qual das duas é, porque as duas recebem janelas diferentes, aprovações diferentes e histórias de reversão diferentes.

Decompor a alteração: expandir, backfill, contrair

A forma de evitar um único passo grande e irreversível é dividir a alteração em três passos individualmente pequenos, que é o formato que a maioria das equipes experientes já usa, mesmo quando não lhe dá nome.

Expandir. Adicione a nova coluna, tabela ou índice sem pedir ao banco de dados que prove qualquer coisa sobre as linhas existentes. A documentação de ALTER TABLE do PostgreSQL explica o mecanismo de forma direta: uma restrição adicionada provoca normalmente uma varredura da tabela para verificar que todas as linhas existentes a satisfazem, mas “se for usada a opção NOT VALID, essa varredura potencialmente longa é ignorada”. A restrição continua a se aplicar a tudo o que for escrito depois, de modo que a plataforma deixa imediatamente de acumular novas violações, enquanto as linhas históricas permanecem não comprovadas.

Backfill. Mova os dados em lotes, em um processo separado, a um ritmo escolhido pela plataforma. A seção abaixo trata isso como a carga de trabalho que ele é.

Contrair. Quando as linhas históricas estiverem comprovadamente válidas e o caminho de leitura tiver migrado, valide a restrição e depois remova o que não é mais necessário — e remova em uma alteração posterior, não na mesma.

O passo de validação é o que torna a sequência acessível. A mesma documentação afirma que a validação “não precisa bloquear atualizações concorrentes, já que sabe que outras transações estarão aplicando a restrição às linhas que inserem ou atualizam; apenas as linhas preexistentes precisam ser verificadas. Portanto, a validação adquire apenas um bloqueio SHARE UPDATE EXCLUSIVE na tabela que está sendo alterada.” Uma chave estrangeira adiciona um bloqueio ROW SHARE na tabela referenciada.

O mesmo escalonamento se aplica a uma coluna NOT NULL, cujo comportamento é documentado com mais detalhes do que uma equipe costuma supor. SET NOT NULL é “normalmente verificado durante o ALTER TABLE por meio de uma varredura de toda a tabela, a menos que NOT VALID seja especificado”; a varredura é totalmente ignorada “se existir uma restrição CHECK válida (e ela não for removida no mesmo comando) que prove que nenhum NULL pode existir”; e se uma restrição not-null já tiver sido adicionada como not valid, SET NOT NULL a valida em vez de verificar o mundo inteiro de novo. Na prática, isso significa: adicione a verificação como NOT VALID, valide-a e só então defina a coluna como not null — três passos pequenos cujo custo total é uma varredura em um nível que permite escritas.

Construir o índice sem parar as escritas — e precificar a concorrência

Índices são o ponto em que uma alteração de esquema mais frequentemente se transforma em uma indisponibilidade, porque uma construção de índice comum exclui as escritas por toda a duração, e uma tabela grande pode levar horas. Todo motor importante hoje oferece uma forma de evitar isso, e cada uma delas troca disponibilidade por trabalho total.

A documentação de CREATE INDEX do PostgreSQL descreve a opção e o seu custo em conjunto: CONCURRENTLY constrói o índice “sem adquirir nenhum bloqueio que impeça inserções, atualizações ou exclusões concorrentes na tabela; ao passo que uma construção de índice padrão exclui as escritas (mas não as leituras) na tabela até terminar”. Em seguida, ela diz o que a plataforma compra com o seu orçamento de tempo: a construção concorrente realiza duas varreduras, “precisa esperar que todas as transações existentes que poderiam modificar ou usar o índice terminem” e, portanto, “exige mais trabalho total do que uma construção de índice padrão e leva significativamente mais tempo para ser concluída”.

Três comportamentos documentados devem entrar no runbook, porque são os que surpreendem as pessoas:

  • Uma construção concorrente pode falhar e deixar algo para trás. O índice é inicialmente registrado como um índice “inválido”; se surgir um problema, como um deadlock, durante a varredura, o comando “falhará, mas deixará para trás um índice ‘inválido’”. Esse índice é ignorado nas consultas porque pode estar incompleto, mas “ainda assim consumirá sobrecarga de atualização” em cada escrita. A recuperação documentada é removê-lo e executar a construção concorrente novamente.
  • Um índice único começa a ser aplicado antes de ser utilizável. A unicidade é imposta em relação a outras transações a partir da segunda varredura, então uma violação “pode ser reportada em outras consultas antes de o índice ficar disponível para uso, ou até mesmo em casos em que a construção do índice acaba falhando”, e um índice único inválido continua sendo aplicado depois disso. Implantar a restrição e o código que a satisfaz na ordem errada transforma isso em erros visíveis no tráfego em tempo real.
  • Uma construção concorrente não admite composição. Apenas uma construção concorrente de índice pode rodar em uma tabela por vez, o esquema da tabela não pode ser modificado enquanto a construção está em execução, e CREATE INDEX CONCURRENTLY não pode rodar dentro de um bloco de transação. Em uma tabela particionada, construções concorrentes não são suportadas diretamente; a alternativa documentada é construir o índice de forma concorrente em cada partição e depois anexar o índice particionado, que é uma operação apenas de metadados.

A última restrição é a que normalmente força uma implantação coordenada de uma versão posterior: se o índice não pode ser criado na mesma transação de migração que o resto do esquema, a construção passa a ser um passo operacional separado, com monitoramento próprio.

Um backfill é uma carga de trabalho, não um script

Reescrever linhas históricas é a parte de uma alteração de esquema que de fato consome a capacidade da plataforma, e a tentação é tratá-la como um script pontual que roda até terminar. Em uma plataforma de apostas em operação, ela é uma carga de trabalho concorrente e tem caminho de volta.

O padrão open source que mais equipes pegam emprestado vale a pena ser lido pelas suas decisões de projeto, e não pela sua linha de comando. O gh-ost do GitHub descreve o formato de uma alteração online: ele cria uma ghost table à semelhança da original, migra essa tabela enquanto vazia, copia dados da original para ela “lentamente e de forma incremental”, propaga as alterações em andamento ao mesmo tempo e, então, “no momento certo”, substitui a original pela ghost table. Duas das suas decisões de projeto são as transferíveis. Ele evita deliberadamente os triggers e lê o binary log, porque, nas palavras dos seus autores, os triggers são a fonte de “muitas limitações e riscos” em um caminho de escrita movimentado. E o seu throttle é uma pausa real, e não um ritmo mais lento: quando ele faz throttle, “ele realmente interrompe as escritas no master: nenhuma cópia de linha e nenhum processamento de eventos em andamento”, devolvendo o banco de dados à sua carga de trabalho original.

Essa última propriedade é o que um backfill precisa em uma plataforma regulamentada: um botão de desligar que funcione imediatamente, porque o gatilho para usá-lo costuma ser um sinal vindo de fora do backfill — latência de liquidação subindo, lag de replicação aumentando ou uma fila de suporte enchendo de apostas com falha. Um backfill que só pode ser desacelerado reiniciando-o não é um backfill que um operador possa deixar rodando com segurança em horário de pico.

As propriedades que vale a pena escrever no plano:

  • Lotes limitados com um marcador de progresso confirmado. O job deve conseguir retomar de onde parou sem reescrever linhas que já processou, e deve conseguir parar entre lotes, não apenas entre execuções.
  • Escritas idempotentes. Um backfill que roda duas vezes deve deixar o mesmo resultado, porque a falha mais provável não é um valor errado, mas um passo repetido depois de um passo interrompido.
  • Throttling guiado pelo próprio sinal de saúde da plataforma, e não por um sleep fixo. Lag de replicação, latência de escrita e profundidade da fila de liquidação são os sinais que dizem se a carga extra está gratuita neste momento.
  • Uma condição de conclusão verificável, como uma contagem de linhas que ainda aguardam o novo valor, em vez de uma linha de log dizendo que o script terminou.
  • Uma ordem deliberada entre o backfill e o cut-over. Se a nova coluna se tornar a fonte da verdade enquanto o backfill ainda está rodando, a plataforma fica com dois escritores e um problema de reconciliação.

Os limites de tempo pertencem à alteração

Uma instrução de esquema que espera é pior do que uma que falha, porque uma alteração à espera de um bloqueio está segurando recursos e continua sendo um passo inacabado em uma sequência que ninguém mais está acompanhando. Os timeouts são a forma de a plataforma decidir isso com antecedência.

O PostgreSQL documenta lock_timeout como uma configuração de sessão ou de instrução que aborta “qualquer instrução que espere mais tempo do que o período especificado ao tentar adquirir um bloqueio em uma tabela, índice, linha ou outro objeto do banco de dados”, e observa que o limite “se aplica separadamente a cada tentativa de aquisição de bloqueio”. Ele vem desativado por padrão, e a documentação aconselha explicitamente a não configurá-lo no arquivo de configuração do servidor “porque isso afetaria todas as sessões” — e é exatamente por isso que ele pertence às ferramentas de alteração, restrito à conexão que está executando a migração. Ela também registra a armadilha de ordenação: se statement_timeout estiver definido com o mesmo valor ou com um valor menor, ele sempre disparará primeiro, então um lock timeout escolhido para proteger a plataforma pode ser mascarado por um statement timeout escolhido com outro propósito.

A configuração relacionada é a que protege contra a migração que trava sem falhar. idle_in_transaction_session_timeout encerra qualquer sessão deixada ociosa dentro de uma transação aberta, e a documentação apresenta a razão operacional: ele pode ser usado para garantir que sessões ociosas “não mantenham bloqueios por um período de tempo irrazoável”. Um processo de alteração que abre uma transação, executa um passo e depois fica esperando outra coisa está segurando tudo o que bloqueou, e em uma plataforma o custo é pago pelos jogadores cuja rodada não pode ser liquidada.

O plano de alteração deve, portanto, declarar, por passo: sob quais configurações de sessão ele roda, o que ele faz quando sofre timeout e se a falha deixa o banco de dados em um estado do qual o passo seguinte consegue retomar. Um timeout que deixa um índice inválido ou uma restrição não validada é um checkpoint, não um desastre, desde que o plano diga isso antes de acontecer.

A verificação é uma comparação, não um check verde

Uma alteração de esquema está concluída quando os dados provam que está concluída. A evidência é uma comparação entre dois estados da mesma pergunta, tomados em dois momentos, e ela precisa ser reproduzível por alguém que não participou da alteração.

O que é comparado Por que é a comparação que importa
Contagens de linhas por tabela e por partição Barato, e detecta o lote truncado e a inserção duplicada
Somas e contagens sobre as colunas de valores monetários, agrupadas da mesma forma que o livro-razão as agrupa A comparação que um revisor de finanças ou de compliance vai pedir, e a que detecta um backfill que contou errado
Um checksum sobre um conjunto de chaves definido, antes e depois Transforma “acreditamos que rodou” em um valor que pode ser recalculado
Rodadas abertas e transações não liquidadas no limite O estado com maior probabilidade de estar meio migrado, porque estava em andamento quando a alteração rodou
O conjunto de linhas que a nova restrição rejeita Uma contagem de violações é, ao mesmo tempo, uma medida de progresso e uma medida de risco
Leituras pelo novo caminho versus pelo caminho antigo nas mesmas linhas Detecta o erro de mapeamento que deixa os dois caminhos individualmente válidos

Dois hábitos tornam isso acessível. O primeiro é a janela de reconciliação: a alteração é seguida por um período no qual o livro-razão é comparado da forma como é comparado depois de qualquer outro evento de liquidação, que é onde a disciplina de reconciliação de carteiras já se aplica. O segundo é o caminho antigo permanecer legível até a comparação passar, que é o conteúdo técnico de “expandir e contrair” e a razão pela qual a remoção de uma coluna é agendada em uma alteração posterior.

Nenhuma dessas comparações prova que uma rodada ainda possa ser interpretada da forma como foi interpretada quando foi jogada. Essa propriedade merece ser declarada separadamente, porque uma alteração de esquema pode quebrá-la silenciosamente: a disciplina de replay determinístico existe precisamente porque uma rodada passada às vezes tem de ser reconstruída a partir dos dados como eles eram, e uma alteração que adiciona, renomeia ou reinterpreta uma coluna é uma alteração desse registro.

Decidir a reversão antes de a primeira instrução rodar

Todo plano de alteração tem um ponto a partir do qual reverter é mais caro do que seguir em frente. Nomear esse ponto com antecedência é a diferença entre uma decisão e uma improvisação às três da manhã.

A parte reversível é normalmente maior do que as equipes supõem, por causa de como as restrições escalonadas se comportam. Uma restrição NOT VALID adicionada pode ser removida. Um backfill pode ser interrompido e reiniciado. Um índice recém-construído pode ser removido — e remover um índice, ao contrário de construí-lo, é uma alteração de metadados que permite DML concorrente. A parte irreversível é estreita e identificável: remover uma coluna ou uma tabela, e qualquer alteração que já tenha sido lida e escrita pelo novo caminho de código.

Isso dá a um plano de alteração um formato, e não uma lista de verificação:

  1. Expandir e fazer backfill são reversíveis e podem ser abandonados em qualquer ponto, deixando colunas extras e um índice extra que ocupam espaço mas não mudam nada.
  2. O cut-over é o passo reversível com esforço: o código pode ser revertido, mas apenas enquanto o caminho antigo ainda estiver sendo mantido.
  3. Contrair é o ponto sem retorno e pertence a uma alteração separada e posterior, com aprovação própria, janela própria e verificação própria — depois do período de observação que o plano define, e não depois que a janela de alteração termina.

A mesma disciplina determina como um incidente se parece. Uma alteração falhada com um esquema não revertido é um incidente diferente de uma alteração falhada que foi abandonada de forma limpa, e o plano de resposta a incidentes é onde a diferença entre os dois está escrita, junto com quem tem autorização para chamar a reversão.

Ensaiar contra uma cópia do tamanho real

Uma alteração de esquema ensaiada em uma tabela vazia quase não prova nada, porque o que está sendo testado é como a operação se comporta diante do formato real dos dados.

O ensaio precisa de três propriedades. Tamanho: uma cópia da tabela com o volume de produção, porque a diferença entre uma alteração instantânea de metadados e uma que reescreve só aparece quando há linhas para reescrever. Concorrência: escritas acontecendo enquanto a alteração roda, porque um bloqueio invisível em um banco de dados ocioso é o problema inteiro em um banco movimentado. Injeção: uma falha induzida deliberadamente — uma instrução interrompida no meio do backfill, uma restrição que deveria rejeitar uma linha, uma construção de índice que falha — porque o caminho de recuperação é a parte do plano que ninguém testou.

É para isso que serve o ambiente de não produção, e os requisitos de ambiente de não produção são o lugar certo para isso. Um ensaio também pode pegar emprestado o caminho de restauração: uma alteração ensaiada contra uma cópia restaurada do banco de produção testa tanto a alteração quanto a capacidade de restauração do backup de onde ela veio, que é a mesma evidência que a disciplina de verificação de backup e restauração exige.

Para uma alteração em uma tabela que pertence a outra equipe — uma tabela de integração de fornecedor, um extrato de relatórios, uma tabela que um parceiro lê —, o ensaio é também o momento de confirmar que a alteração é compatível com as leituras dessas pessoas. O guia de migração e cutover de plataformas cobre a versão mais ampla desse problema: uma migração de plataforma é uma sequência de alterações coordenadas, e o esquema é uma das coisas que estão sendo coordenadas, e não um detalhe de implementação da migração.

Transformar isso em evidências de aceitação

O pacote que um revisor, um auditor ou um comprador deve conseguir ler é curto, e cada item dele é verificável, e não apenas afirmado:

  1. O plano de alteração, nomeando para cada passo a forma da instrução, o nível de bloqueio que o motor documenta para ela e se a operação reescreve a tabela.
  2. A decomposição, mostrando que nenhum passo isolado ao mesmo tempo reescreve uma tabela grande e é irreversível.
  3. A especificação do backfill: tamanho do lote, marcador de progresso, comportamento de retomada, sinal de throttle e condição de conclusão.
  4. As configurações de sessão sob as quais cada passo roda, incluindo os timeouts de bloqueio e de transação ociosa e o que acontece quando eles disparam.
  5. As evidências de comparação: as contagens, somas e checksums obtidos antes e depois, com as consultas que os produziram.
  6. O registro do ensaio, incluindo a falha injetada e a recuperação observada.
  7. O ponto de reversão nomeado, os passos que continuam reversíveis e quem está autorizado a chamar a reversão.
  8. O cronograma do passo de contração, mantido separado da alteração que o tornou possível.

O trabalho que isso descreve é, em grande parte, preparação, e é por isso que ele é tão frequentemente comprimido para dentro da própria janela de alteração, onde é ao mesmo tempo mais caro e mais visível para os jogadores. O hábito ao qual ele pertence — especificar o comportamento antes de construí-lo e manter a especificação atualizada quando o sistema muda — é o mesmo que está por trás da disciplina de desenvolvimento de plataformas que este site aplica em outros lugares. Para um banco de dados, a especificação é incomum em um aspecto: ela precisa permanecer verdadeira enquanto milhões de linhas estão sendo reescritas por baixo dela, e essa é a única razão pela qual vale a pena escrever a sequência antes de executá-la.

Perguntas que uma equipe de plataforma faz

Podemos simplesmente agendar a migração para um horário tranquilo?

Você pode, e ainda assim vale a pena decompor a instrução. Um horário tranquilo reduz a chance de alguém estar no meio de uma rodada quando o bloqueio é adquirido; ele não muda quanto tempo uma reescrita leva e, em uma tabela grande o bastante para importar, a reescrita é mais longa do que qualquer horário tranquilo que a plataforma tenha. As duas mitigações são separadas: a janela reduz o número de pessoas afetadas, e a alteração escalonada reduz aquilo que as afeta. Uma alteração que depende apenas da janela falha na primeira vez que a tabela cresce além dela.

É seguro executar CREATE INDEX CONCURRENTLY a qualquer momento?

Ele é projetado para não bloquear inserções, atualizações ou exclusões concorrentes, e ainda assim é uma operação mais pesada do que uma construção comum, porque realiza duas varreduras e espera por transações que poderiam tocar o índice. Dois dos seus comportamentos documentados decidem se ele é seguro em determinada hora: ele pode falhar e deixar um índice inválido que consome sobrecarga de atualização até ser removido, e um índice único impõe a unicidade a partir da sua segunda varredura, o que significa que ele pode expor violações no tráfego em tempo real antes de ser utilizável. Então a resposta é uma sequência, e não um sim: construa-o enquanto os dados são sabidamente bons, fique atento ao estado inválido e trate o índice como um passo operacional separado do resto da alteração.

O que realmente precisa ser verdade antes de removermos a coluna antiga?

Três coisas, em ordem. O novo caminho precisa ser o único escritor. A comparação sobre os dados de valores monetários e de rodadas precisa ter passado ao longo de um ciclo completo de liquidação, e não apenas em uma verificação pontual. E um período de observação definido precisa ter decorrido sem nenhum consumidor ainda lendo a coluna antiga — incluindo o extrato de relatórios, o feed de parceiros e aquele painel de que ninguém se lembrou. Até que as três condições se mantenham, a coluna antiga é um seguro barato: ela ocupa espaço e também é a única cópia restante dos dados no formato que o código antigo entendia.

Como sabemos que o backfill realmente terminou?

Não use a ausência de erros nem uma linha de log. Defina uma condição de conclusão que o banco de dados consiga responder, como a contagem de linhas que ainda mantêm o valor anterior ou que ainda falham na nova restrição, e exija que essa contagem chegue a zero e permaneça aí ao longo de um ciclo completo de escrita depois do cut-over. Depois mantenha essa mesma contagem como monitor por um período definido, porque um caminho de código que escreve o valor antigo pode ser reintroduzido pela reversão de uma versão não relacionada, e um backfill silencioso não é a mesma coisa que um backfill que permanece concluído.

Qual é o menor primeiro passo útil se nada disso existe ainda?

Escreva, para a próxima alteração que você tiver de fazer de qualquer forma, os quatro fatos que a documentação já lhe diz: a forma da instrução, o bloqueio que ela adquire, se ela reescreve a tabela e o ponto a partir do qual a alteração deixa de ser reversível. Isso é uma tarde de leitura e muda o plano imediatamente, porque as duas decisões que causam a maior parte dos danos — combinar uma subinstrução rápida com uma lenta em uma única instrução, e descobrir o ponto de reversão depois do fato — são tomadas no momento em que o plano é escrito.