Transformação operacional e resolução sem conflitos para aplicações de colaboração em tempo real
Nesta página
Estou em processo seletivo com várias empresas no Reino Unido, e isso me dá a chance de descobrir os desafios de cada negócio. Um deles é a colaboração em tempo real dentro do aplicativo da empresa.
Isso despertou meu interesse em saber como as tecnologias de colaboração em tempo real são implementadas.
Várias pessoas editando o mesmo documento é algo bem comum. Tão comum que Google Docs, Microsoft Word, Canva, Figma, Miro, Notion, Linear e muitos outros já vêm com esse suporte de fábrica. É tão, tão comum que estranho é ver um aplicativo sem isso.
Agora eu só quero saber como funciona.
Técnicas
Pesquisando, encontrei duas técnicas bem comuns que ajudam.
Transformação operacional (OT)
Esse algoritmo é o mais usado em colaboração em tempo real. Ele processa as mudanças no estado atual do documento e aplica no servidor de um jeito em que a ordem das operações não afeta a consistência.
Tipos de dados replicados sem conflito (CRDTs)
Como a OT, os CRDTs garantem que as cópias dos dados acabem convergindo, seja qual for a ordem das mudanças. As ações em CRDTs são projetadas para serem comutativas, idempotentes e associativas, o que simplifica o processo de mesclagem. CRDTs são ótimos porque não dependem necessariamente de um servidor e funcionam ponto a ponto. Isso significa que os usuários podem continuar trabalhando offline e sincronizar sem problemas quando voltarem a se conectar, sem medo de que as mudanças causem inconsistências.
Pontos de atenção
Há vários outros pontos que também precisam entrar na conta, mas não vou me aprofundar muito aqui.
Latência de rede e desconexões
Tratar bem a latência de rede e as desconexões no cliente é fundamental. Implementar estratégias para expirar mudanças de estado antigas pode ajudar a reduzir inconsistências na interface e conflitos.
Atualizações em tempo real
Em vez de fazer polling, tecnologias como WebSockets ou gRPC conseguem gerenciar com eficiência as atualizações de estado em tempo real vindas do servidor.
UX da resolução de conflitos
A interface deve oferecer mecanismos intuitivos para o usuário entender e resolver os conflitos quando eles acontecem.
Cronologia e idade de uma mudança de estado
Enquanto os CRDTs não dependem da ordem das mudanças, a OT depende do estado anterior e do próximo. A distância cronológica entre as mudanças pode complicar a resolução de conflitos. Além disso, a idade de uma mudança de estado pode afetar mudanças futuras se o cliente não a descartar depois de um TTL razoável. Em outras palavras, não envie mudanças de estado velhíssimas de quando a conexão caiu. Envie só a mais recente. (A não ser que você guarde a cronologia e use isso depois na resolução de conflitos no servidor.)
Implementações
Transformação operacional
Resumindo, a OT existe para garantir que, aconteça o que acontecer, o estado final seja consistente, seja qual for a ordem das operações. Isso é crucial porque, no mundo real, você nunca sabe em que ordem as mudanças dos usuários vão chegar ao servidor. Dá para coletar os timestamps dos dispositivos, mas sincronizá-los entre muitos clientes é quase impossível (mas, como falei acima, pode ser muito útil na resolução de conflitos se você guardar um histórico das mudanças).
Conceitos
- Idempotência: é o super-herói dos conceitos na edição em tempo real. Idempotência significa que, não importa quantas vezes uma operação seja executada, o resultado é o mesmo que se ela tivesse sido executada uma vez só. Pense se apertar o botão de publicar no Facebook reenviasse seus últimos pensamentos sem parar. Você ficaria sem amigos! Em aplicativos colaborativos, a idempotência garante que atualizações repetidas não afetem o estado final além da primeira aplicação. É crucial para manter a sanidade nas sessões colaborativas.
- Associatividade: pense nela como a batedeira. Ela significa que, não importa como você agrupe as operações, o resultado final vai ser o mesmo. Veja o exemplo
(a * b) * c = a * (b * c). Isso é muito útil quando você tem um monte de mudanças para mesclar e não quer perder tempo desenrolando o que depende de quê. - Comutatividade: esse conceito garante que a ordem das operações não importa:
a * b = b * a. Então, tanto faz se as mudanças do usuário A são aplicadas antes das do usuário B ou o contrário, o resultado final é o mesmo. Isso vale ouro em ambientes onde as mudanças chegam de todos os lados ou até são feitas offline.
Exemplo básico de implementação
Este é um exemplo bem básico, usando uma única string como estado. Para mudar o estado, a gente simplesmente pega um índice e insere ou apaga texto.
Por exemplo:
interface Operation {
id: string;
index: number;
length: number;
text: string;
type: 'insert' | 'delete';
timestamp: number;
}
function applyOperation(currentState: string, operation: Operation): string {
switch (operation.type) {
case 'insert':
return (
currentState.slice(0, operation.index) +
operation.text +
currentState.slice(operation.index)
);
case 'delete':
return (
currentState.slice(0, operation.index) +
currentState.slice(operation.index + operation.length)
);
default:
return currentState;
}
}
function transformOperation(
baseOperation: Operation,
newOperation: Operation
): Operation {
if (newOperation.index > baseOperation.index) {
switch (baseOperation.type) {
case 'insert':
newOperation.index += baseOperation.text.length;
break;
case 'delete':
newOperation.index -= baseOperation.length;
break;
}
}
return newOperation;
}
function compareAndApplyOT(
operations: Operation[],
currentState: string
): string {
let appliedOperations = new Map<string, Operation>();
operations.sort((a, b) => a.timestamp - b.timestamp);
for (const operation of operations) {
if (!appliedOperations.has(operation.id)) {
let transformedOperation = operation;
appliedOperations.forEach((appliedOp) => {
transformedOperation = transformOperation(
appliedOp,
transformedOperation
);
});
currentState = applyOperation(currentState, transformedOperation);
appliedOperations.set(operation.id, transformedOperation);
}
}
return currentState;
}
Tipos de dados replicados sem conflito
Os CRDTs são outra técnica para gerenciar estado em aplicativos colaborativos em tempo real. Eles seguem outro caminho: garantem que cada mudança possa convergir de forma independente para o mesmo resultado final, seja qual for a ordem em que são aplicadas. Isso deixa os CRDTs especialmente robustos em ambientes com muita instabilidade de rede.
Os CRDTs brilham quando a latência é bem mais que um incômodo pequeno. Pense em trabalhar num trem com internet instável. Cada ação ou edição feita num sistema CRDT é projetada para ser mesclada de forma independente, sem precisar consultar um servidor central na hora. Isso significa que os usuários podem continuar trabalhando offline e sincronizar sem problemas quando voltarem a se conectar, sem medo de que as mudanças causem inconsistências.
Conceitos
Exemplo básico de implementação
interface CRDTOperation {
id: string;
index: number;
text: string;
type: 'insert' | 'delete';
}
function applyOperation(currentState: string, operation: Operation): string {
switch (operation.type) {
case 'insert':
return (
currentState.slice(0, operation.index) +
operation.text +
currentState.slice(operation.index)
);
case 'delete':
return (
currentState.slice(0, operation.index) +
currentState.slice(operation.index + operation.length)
);
default:
return currentState;
}
}
function compareAndApplyCRDT(
operations: CRDTOperation[],
currentState: string
): string {
let operationMap = new Map<string, CRDTOperation>();
operations.forEach((op) => operationMap.set(op.id, op)); // Ensures idempotency by overwriting duplicates
let sortedOperations = Array.from(operationMap.values());
// Sort by ID for consistency, but order does not change the result
sortedOperations.sort((a, b) => a.id.localeCompare(b.id));
for (const operation of sortedOperations) {
currentState = applyOperation(currentState, operation); // Re-using the applyOperation function from OT
}
return currentState;
}
Conclusão
Gostei muito de explorar como esses conceitos funcionam. Hoje estamos todos superconectados, mas ainda é essencial que os aplicativos lidem bem com esse tipo de complexidade para melhorar a experiência do usuário.
Claro que, num cenário real, eu não usaria um único campo de string para gerenciar o estado, mas queria mostrar como isso poderia funcionar na prática.
Além disso, eu provavelmente tentaria evitar usar a camada de aplicação para processar as mudanças de estado. Dá para fazer, mas não sei que impacto isso teria na conformidade ACID do banco de dados quando as atualizações chegam com frequência de vários clientes. Talvez seja algo que uma fila resolva.
Brinquei com algumas ferramentas que me ajudaram a entender como isso funciona. Espero que sejam úteis para você.
Agradecimentos
Um agradecimento enorme ao Tuhin Banerjee, no Medium, cujo post me ajudou a começar a minha própria implementação.