Implementando Schema Evolution no CDCCore
Uma mudança estrutural na origem pode fazer o CDC continuar recebendo eventos que o destino ainda não consegue representar.
Construir um CDC para PostgreSQL parece relativamente simples quando pensamos apenas no fluxo básico. O desenho inicial ajuda a visualizar isso.
PostgreSQL
|
| WAL
v
CDC
|
v
Destino
O banco de origem registra as alterações no WAL, o consumidor lê essas alterações, interpreta os eventos e aplica INSERT, UPDATE e DELETE no banco de destino. Mas existe uma pergunta que aparece inevitavelmente quando o projeto começa a ficar mais sério:
O que acontece se o schema da tabela mudar enquanto o CDC continua replicando os dados?
Foi justamente esse problema que levou à implementação de uma camada de Schema Evolution no CDCCore.
O problema
Imagine uma tabela clientes sendo replicada. Antes de qualquer alteração, o cenário inicial é este:
ORIGEM DESTINO
clientes clientes
------------------ ------------------
id id
nome nome
email email
O CDC está funcionando normalmente nesse cenário inicial. Então alguém executa na origem:
ALTER TABLE clientes
ADD COLUMN telefone TEXT;
Depois desse DDL, a origem passa a possuir a seguinte estrutura:
clientes
id
nome
email
telefone
Enquanto isso, o destino ainda continua com a estrutura anterior:
clientes
id
nome
email
O problema aparece quando novos eventos passam a refletir a nova estrutura da relação. O CDC pode continuar recebendo eventos do PostgreSQL, mas o destino pode não possuir a estrutura necessária para armazená-los.
Portanto, schema evolution não é apenas um problema de banco de dados. É também um problema do pipeline de replicação.
O que o PostgreSQL realmente envia?
Uma das primeiras decisões importantes da implementação foi entender exatamente o que recebemos do PostgreSQL. O CDCCore utiliza replicação lógica e pgoutput para consumir o WAL.
Durante o stream aparecem mensagens relacionadas à estrutura das relações, chamadas RelationMessage. Essas mensagens descrevem a relação naquele ponto do stream, incluindo informações como:
- schema;
- nome da tabela;
- relation ID;
- colunas;
- nomes das colunas;
- tipos associados às colunas.
Isso permite que o consumidor mantenha uma espécie de snapshot da estrutura conhecida de cada tabela. Porém, existe uma diferença importante:
O
pgoutputnão entrega para o consumidor simplesmente o SQL DDL que originou a mudança.
Na prática, não recebemos um comando pronto como este:
ALTER TABLE clientes ADD COLUMN telefone TEXT;
e podemos simplesmente executar esse mesmo SQL no destino. Recebemos uma nova descrição estrutural da relação. Isso muda completamente o problema.
Precisamos responder o que mudou entre a estrutura anterior e a nova estrutura observada, e não simplesmente perguntar qual SQL o usuário executou.
Detectando mudanças de schema
O primeiro passo da implementação foi armazenar snapshots das mensagens RelationMessage. Quando uma nova descrição da mesma tabela chega, o consumidor compara a estrutura atual com a estrutura anteriormente conhecida.
Na comparação, isso permite detectar diferenças como:
- coluna adicionada;
- coluna removida;
- metadado alterado;
- relation ID alterado;
- possível rename;
- possível substituição de tabela.
Nem toda diferença pode ser identificada com segurança. Por exemplo, se uma coluna desaparece e outra aparece, não necessariamente podemos afirmar que ocorreu um RENAME COLUMN.
Poderia ter ocorrido uma sequência explícita de remoção e criação:
DROP COLUMN antigo;
ADD COLUMN novo;
Ou poderia ter sido apenas uma operação de rename:
ALTER TABLE ... RENAME COLUMN antigo TO novo;
A metadata observada pelo consumidor não necessariamente contém informação suficiente para distinguir essas situações com segurança. Essa é uma fronteira importante da implementação. Por isso o CDCCore adota uma regra importante:
Quando não conseguimos determinar com segurança o que aconteceu, tratamos a mudança como
UNKNOWN.
Essa é uma escolha deliberada: não tentamos adivinhar.
Detectar não é suficiente
A RelationMessage ajuda a identificar que a estrutura mudou, mas não contém todas as informações necessárias para decidir se uma alteração pode ser aplicada automaticamente.
Por exemplo, para aplicar uma nova coluna no destino, o DDL precisaria ser construído com cuidado:
ALTER TABLE clientes
ADD COLUMN telefone TEXT;
precisamos saber mais do que o nome e o tipo observados no stream:
telefone
TEXT
Antes de construir qualquer DDL, precisamos verificar características adicionais como estas:
- a tabela existe na origem?
- a tabela existe no destino?
- a coluna realmente não existe no destino?
- qual é o tipo real?
- a coluna aceita
NULL? - existe uma chave primária?
- a estrutura atual do destino é compatível?
Por isso adicionamos uma segunda etapa: validação contra o catálogo do PostgreSQL. A decisão deixa de depender apenas da mensagem observada no stream.
Duas fontes de informação
Na prática, a decisão de evolução de schema passa a utilizar duas fontes complementares:
pgoutput
|
v
Metadata observada
|
|
v
Detecção da mudança
|
v
Catálogo PostgreSQL
|
v
Validação
|
v
Política do CDC
O pgoutput nos informa que a estrutura observada mudou. O catálogo do PostgreSQL fornece informações adicionais para validar essa mudança.
Essa separação é importante porque evita transformar a metadata do stream em uma fonte de verdade que ela não é.
Uma política conservadora
Depois de detectar e validar uma alteração, surge outra pergunta inevitável:
Devemos executar automaticamente qualquer mudança encontrada pelo pipeline?
A resposta do CDCCore é: não. Para controlar essa decisão, foi criada uma configuração específica:
CDC_SCHEMA_EVOLUTION=disabled
CDC_SCHEMA_EVOLUTION=manual
CDC_SCHEMA_EVOLUTION=auto
Disabled
É o comportamento mais conservador. O CDC pode observar e registrar a mudança, mas não executa DDL automaticamente.
Isso é útil quando a evolução de schema deve ser controlada externamente.
Manual
Nesse modo, a alteração é identificada e registrada, mas a aplicação automática não acontece. A ideia é permitir que uma decisão externa autorize a mudança.
Auto
Nesse modo, o CDC pode aplicar automaticamente determinadas alterações consideradas seguras pela política. Mas existe uma restrição importante:
O modo automático não significa "execute qualquer DDL encontrado na origem".
Na implementação atual, o caso permitido é deliberadamente pequeno e controlado. Esse recorte é intencional.
ADD COLUMN
Esse caso só entra na automação desde que a nova coluna seja:
nullable
Essa restrição reduz significativamente o risco de uma alteração estrutural automática quebrar a replicação. O modo automático continua sendo uma política conservadora.
Por que ADD COLUMN nullable?
Considere novamente uma alteração simples e bastante comum:
ALTER TABLE clientes
ADD COLUMN telefone TEXT;
Uma coluna nullable pode ser adicionada sem exigir que os registros existentes possuam imediatamente um valor obrigatório. O destino pode passar de uma estrutura simples:
id
nome
email
Depois da alteração, o destino pode chegar a esta estrutura:
id
nome
email
telefone
Isso acontece sem precisar preencher um valor obrigatório para todas as linhas existentes.
Isso não significa que qualquer ADD COLUMN seja universalmente seguro em qualquer sistema. Significa que, dentro das premissas estabelecidas pelo CDCCore, esse é um caso que pode ser automatizado de forma controlada.
Já alterações como estas podem envolver ambiguidades, quebra de contratos ou risco de perda de dados, por isso permanecem fora da aplicação automática:
DROP COLUMN;RENAME COLUMN;ALTER TYPE;ALTER PRIMARY KEY;CREATE TABLE.
O CDC nunca executa SQL vindo da origem
Existe ainda uma preocupação de segurança. Seria perigoso simplesmente receber algum conteúdo associado à origem e tratá-lo como SQL executável no destino.
A implementação segue outra estratégia. O DDL é construído pelo próprio CDCCore a partir das informações estruturais validadas. Os identificadores de schema, tabela e coluna são tratados utilizando mecanismos apropriados de escaping.
Além disso, os tipos aceitos passam por uma allowlist. Entre os tipos inicialmente aceitos estão:
TEXT;INTEGER;BIGINT;BOOLEAN;JSONB;DATE;UUID;TIMESTAMP;TIME;NUMERIC;VARCHAR.
Tipos desconhecidos ou não permitidos são rejeitados antes da aplicação. A regra é simples:
O CDC decide qual DDL pode ser construído. A origem não fornece arbitrariamente o SQL que será executado no destino.
Schema e dados na mesma transação
Talvez essa seja uma das partes mais importantes da implementação. Imagine que uma transação da origem contenha alterações relacionadas a uma coluna recém-adicionada.
No destino, o processamento segue conceitualmente uma única unidade de trabalho:
BEGIN
|
+-- validar schema
|
+-- aplicar ADD COLUMN
|
+-- aplicar INSERT/UPDATE/DELETE
|
+-- registrar progresso
|
+-- COMMIT
Se alguma etapa falhar, a transação inteira precisa voltar:
BEGIN
|
+-- schema
|
+-- DML
|
+-- ERRO
|
v
ROLLBACK
O progresso não é confirmado antes de o destino estar consistente. Essa característica é fundamental para o modelo de processamento do CDC.
Não queremos chegar a uma situação inconsistente como esta:
Schema aplicado sim
Dados aplicados nao
LSN confirmado sim
porque nesse caso o consumidor poderia avançar no stream sem ter aplicado corretamente todos os dados. A intenção é manter a evolução como uma única unidade transacional:
schema + dados + progresso
O papel do modo genérico
O modo auto exige também uma configuração específica de aplicação:
CDC_POSTGRES_APPLY_MODE=generic
Isso acontece porque o modo genérico constrói dinamicamente os comandos de DML com base no schema conhecido no destino. Depois que uma nova coluna é adicionada, o consumidor consegue trabalhar com a nova estrutura.
Com isso, o fluxo passa a ter esta sequência de decisão:
RelationMessage
|
v
Detecta mudança
|
v
Consulta catálogo
|
v
Valida ADD COLUMN
|
v
ALTER TABLE
|
v
DML utilizando novo schema
Essa capacidade de reconstruir dinamicamente a aplicação de DML é uma parte importante para que a evolução de schema funcione dentro do próprio pipeline.
Observabilidade
Uma alteração automática de schema não deveria acontecer silenciosamente. Por isso também adicionamos eventos e métricas.
Entre os eventos registrados, aparece a observação de mudança de metadata no stream:
schema_metadata_change_observed
Também registramos o evento relacionado à tentativa de aplicação no destino:
schema_apply
As métricas complementam esses eventos e permitem acompanhar situações operacionais como:
- relation messages observadas;
- mudanças de schema detectadas;
- mudanças desconhecidas;
- tentativas de apply;
- aplicações bem-sucedidas;
- falhas;
- rejeições;
- validações de compatibilidade.
Isso permite diferenciar situações que, sem observabilidade, poderiam parecer iguais. Por exemplo, "não alterou o schema porque estava disabled" é completamente diferente de "não alterou porque a mudança foi considerada UNKNOWN", que também é diferente de "tentou aplicar, mas a validação falhou".
Para um componente de infraestrutura, essa diferença é importante durante investigação e operação.
O que a implementação não tenta resolver
Um dos princípios adotados foi evitar apresentar a funcionalidade como uma solução genérica para qualquer evolução de schema. Atualmente, o objetivo é lidar com um subconjunto controlado.
| Alteração | Aplicação automática |
|---|---|
ADD COLUMN nullable |
Permitida sob as condições definidas |
DROP COLUMN |
Não |
RENAME COLUMN |
Não |
ALTER TYPE |
Não |
| alteração de PK | Não |
| criação automática de tabela | Não |
| alteração desconhecida | Não |
Isso é proposital. Schema evolution é um problema maior do que simplesmente executar ALTER TABLE.
Uma mudança aparentemente simples pode afetar várias partes do ambiente:
- aplicações;
- queries;
- índices;
- constraints;
- consumidores;
- contratos de dados;
- relatórios;
- processos de ETL;
- outros pipelines de CDC.
Portanto, quanto maior o conjunto de alterações que um CDC aplica automaticamente, maior também é a superfície de risco.
O que mudou no CDCCore?
Antes dessa camada, o modelo podia ser resumido como um fluxo direto de DML:
PostgreSQL
|
| WAL
v
pgoutput
|
v
CDC
|
| DML
v
PostgreSQL destino
Agora existe uma camada adicional antes da aplicação dos dados:
PostgreSQL
|
| WAL
v
pgoutput
|
v
RelationMessage
|
v
Detecção de mudança
|
v
Catálogo PostgreSQL
|
v
Validação
|
v
Política de evolução
|
+--------+--------+
| |
bloqueia permite
|
v
ALTER TABLE
|
v
DML
|
v
COMMIT
O CDC deixa de ser apenas um componente que transporta dados. Ele passa a acompanhar também uma parte da evolução estrutural do dado.
Uma decisão importante: não tentar adivinhar
Talvez a principal lição dessa implementação não seja o ALTER TABLE. É a decisão de não inferir além do que as evidências permitem.
Se o consumidor sabe que uma coluna apareceu, existe uma hipótese razoável para a mudança:
ADD COLUMN
Mas se ele encontra uma combinação ambígua, a interpretação muda de figura:
coluna A desapareceu
coluna B apareceu
mesmo assim, não é seguro concluir automaticamente:
RENAME A -> B
Pode ser isso. Pode não ser. Nesse caso, o sistema prefere classificar a mudança como:
UNKNOWN
e bloqueia a automação até que exista uma decisão explícita.
Para infraestrutura de dados, essa postura é importante. Falhar de forma explícita pode ser muito melhor do que aplicar uma alteração incorreta silenciosamente.
Conclusão
A implementação de Schema Evolution no CDCCore nasceu de um problema bastante concreto: uma mudança estrutural na origem pode fazer o CDC continuar recebendo eventos que o destino ainda não consegue representar.
A solução adotada foi dividir o problema em etapas bem definidas:
Observar
|
v
Detectar
|
v
Validar
|
v
Aplicar conforme política
|
v
Replicar dados
|
v
Confirmar progresso
O pgoutput fornece as informações estruturais observadas no stream. O catálogo PostgreSQL complementa essas informações. A política do CDCCore determina o que pode ser automatizado. E a transação do sink mantém schema, dados e progresso sincronizados.
A implementação atual não pretende resolver toda a complexidade de schema evolution. Ela faz algo mais específico:
Permite detectar mudanças estruturais e automatizar somente aquelas que conseguimos validar e considerar seguras dentro das regras do sistema.
Esse limite é intencional e faz parte da segurança da abordagem.
Em sistemas de dados, especialmente em componentes responsáveis por replicação, automação segura não significa automatizar tudo. Significa saber exatamente o que pode ser automatizado e saber quando parar.