7 pontos por GN⁺ 2023-10-19 | 1 comentários | Compartilhar no WhatsApp
  • Reflect é um framework lançado para criar rapidamente apps colaborativos como Figma, Notion e Google Sheets, adicionando um servidor totalmente gerenciado ao mecanismo de sincronização no estilo de jogos do Replicache
  • Como interfaces colaborativas precisam mostrar mudanças locais imediatamente, sem esperar resposta do servidor, a forma de lidar com conflitos ao editar os mesmos dados ao mesmo tempo define a experiência do produto
  • Em vez de CRDT, o Reflect adota Transactional Conflict Resolution, em que cliente e servidor executam o mesmo histórico de chamadas de mutator e o servidor cria o estado autoritativo na ordem de chegada
  • CRDTs de sequência como o Yjs são fortes para texto, listas e mapas, mas em casos que exigem regras de mesclagem próprias, como contadores, incrementos podem se perder; o Reflect lida com aritmética, manipulação de listas e invariantes de nível superior reexecutando mutators
  • Como o servidor recebe apenas o nome da mutation e seus argumentos para recalcular o resultado, ele não confia no resultado calculado pelo cliente e fica mais fácil incorporar verificação de permissões, validação de esquema e migrações ao design

A experiência de desenvolvimento apresentada pelo Reflect

  • Reflect é uma nova forma de criar apps web multiplayer como Figma, Notion e Google Sheets
  • É uma evolução do framework de sincronização no lado do cliente Replicache e usa o mesmo mecanismo de sincronização no estilo de jogos
  • Diferente do Replicache, inclui um servidor totalmente gerenciado e tem como objetivo permitir a criação de apps multiplayer de alta qualidade em poucos minutos
  • O Reflect está sendo disponibilizado publicamente pela primeira vez, e é possível ver a apresentação em reflect.net e começar em hello.reflect.net

Como surgem conflitos na edição colaborativa

  • Na edição colaborativa, conflitos são inevitáveis
  • Para criar uma UI com resposta imediata, não dá para esperar o servidor, então as mudanças precisam acontecer primeiro no cliente local
  • Como vários usuários podem editar o mesmo item ao mesmo tempo, é necessário sincronizar os conflitos e resolvê-los de forma natural para que todos vejam o mesmo resultado
  • O mecanismo de sincronização influencia a experiência de desenvolvimento, a experiência do usuário, o desempenho possível e até os tipos de app que podem ser criados

CRDT e o exemplo de contador no Yjs

  • No ecossistema web, CRDT é amplamente usado como forma de sincronizar dados
  • CRDT é uma estrutura de dados que converge para o mesmo valor quando todas as mudanças entre colaboradores são trocadas, e Yjs e Automerge são bibliotecas CRDT open source representativas
  • O Reflect não é um CRDT; ele usa Transactional Conflict Resolution, uma adaptação de Server Reconciliation, técnica usada há muito tempo na indústria de videogames
  • Por que um contador simples quebra no Yjs

    • Armazenar um valor count em um Yjs Map e regravar prev + 1 pode perder incrementos em cenários de concorrência
    • O exemplo correto de contador na documentação do Yjs adiciona números a um array e calcula a soma
    • O Yjs é um CRDT de sequência, então é forte para listas, fragmentos de texto e mapas, mas tem dificuldade para modelar contadores de forma natural
    • O algoritmo de mesclagem de Yjs Map é last-write wins por chave, então se dois usuários incrementarem ao mesmo tempo, uma das mudanças pode desaparecer
    • CRDT funciona muito bem para certos problemas, mas tem a limitação de ser difícil de estender quando o problema é outro

Como funciona o Transactional Conflict Resolution

  • No Reflect, mudanças são implementadas como funções JavaScript especiais chamadas mutators
  • Uma cópia de cada mutator existe em todos os clientes e no servidor
  • Quando o usuário faz uma mudança, o Reflect cria uma mutation, que é o registro da chamada do mutator
    • A mutation contém apenas o nome do mutator e seus argumentos, como increment(delta: 1)
    • O conteúdo da mudança produzida não é incluído na mutation
  • O Reflect aplica a mutation imediatamente no ambiente local para atualizar a UI, então o usuário vê sua própria alteração na hora
  • Linearização no servidor e reexecução

    • Cada cliente continua adicionando mutations sem esperar o servidor
    • As mutations são transmitidas em streaming para o servidor, e o servidor as lineariza pela ordem de chegada para gerar o próximo estado autoritativo
    • Por exemplo, se increment(1) do cliente 1 e increment(2) do cliente 2 acontecerem ao mesmo tempo, a contagem final no servidor será criada conforme a ordem de chegada
    • O servidor mescla conflitos linearizando o histórico de execução, sem precisar de conhecimento separado sobre o que increment faz ou como ele deve ser mesclado
    • O estado autoritativo mais recente continua sendo transmitido em streaming para cada cliente
    • Quando o cliente sabe que suas mutations pendentes já foram aplicadas ao estado autoritativo, ele as remove da fila local
    • As mutations pendentes restantes são rebaseadas sobre o estado autoritativo mais recente, reexecutando o código do mutator
    • Todo esse ciclo pode acontecer até 120 vezes por segundo por cliente

Custo de implementação e vantagens generalizáveis

  • Para implementar essa abordagem, é preciso um datastore rápido capaz de voltar no tempo, fazer fork e criar branches
  • No lado do servidor, também é necessário um armazenamento rápido para processar as mutations recebidas
  • Também são necessários mecanismos para sincronizar mutators e para recuperar o sistema quando cliente ou servidor entram em conflito durante a sincronização
  • Em troca, a linearização de funções arbitrárias funciona como uma estratégia de sincronização bastante genérica
  • Exemplos que funcionam sem código de sincronização separado

    • Operações aritméticas funcionam naturalmente
    • setHighScore grava o maior valor entre o high-score existente e a pontuação candidata
    • A maioria das manipulações de lista também funciona
    • append adiciona um item ao fim de uma lista de compras
    • insertAt insere um item na posição especificada, e splice() ajusta a posição
    • remove precisa receber o item ou um ID estável como argumento, porque o índice pode mudar
    • Também é possível impor invariantes de nível mais alto
    • addChild atualiza junto o childIDs do pai e o parentID do filho para que permaneçam sempre consistentes
    • Esses exemplos são mesclados de forma razoável mesmo sem código separado pensado especificamente para sincronização

Autoridade do servidor e verificação de permissões

  • No Reflect, o servidor é a autoridade
  • O que o cliente acha que foi o resultado de uma mudança não é compartilhado com o servidor nem com outros clientes
  • Apenas o nome da mutation e seus argumentos são enviados ao servidor, e o servidor recalcula diretamente o resultado da mutation
  • O servidor nem precisa executar exatamente o mesmo código do cliente; ele pode consultar serviços externos ou usar números aleatórios
  • Autorização detalhada

    • Nesse design, é natural inserir uma verificação detalhada de permissões
    • Um exemplo é um programa colaborativo de design em que convidados podem comentar e destacar trechos, mas não podem alterar o design em si
    • Em CRDT, é difícil implementar isso porque não há um lugar adequado para colocar a lógica que rejeita mudanças sem permissão
    • No Reflect, o mutator executado no servidor pode verificar um valor como tx.user.canEdit e lançar um erro unauthorized se não houver permissão
    • Não há problema se o mutator executar código diferente no servidor e no cliente, porque o servidor toma a decisão final

Validação de esquema e orientação de uso

  • Na abordagem do Reflect, validação de esquema e migrações também podem ser incorporadas naturalmente ao design
  • Escolher a estratégia de sincronização é central em sistemas multiplayer, e o Reflect vê o Transactional Conflict Resolution, aprendido com a indústria de jogos, como uma abordagem simples, flexível e poderosa
  • Se você está criando um app multiplayer, pode testar em página inicial do Reflect
  • Para conversar com a equipe de desenvolvimento, use discord.reflect.net ou @hello_reflect

1 comentários

 
GN⁺ 2023-10-19
Opiniões no Hacker News
  • A demo no topo da página inicial (https://reflect.net/) é bem divertida
    Ao assistir, sempre que o quebra-cabeça é concluído, as pessoas parecem comemorar balançando o cursor, como se dissessem “conseguimos!”

    • Começaram a formar a palavra FUCK com as peças e, quando outras pessoas perceberam e passaram a participar também, acabou virando uma experiência surpreendentemente calorosa
    • Há um bug visual que permite esconder uma peça “e” atrás da parte e já concluída
      Com as outras letras, o contorno continua aparecendo, então não dá para fazer do mesmo jeito
    • Não acho que essa demo seja um exemplo que mostre bem os recursos da biblioteca
      Quando você pega uma peça, ela parece ficar bloqueada para aquele usuário, então quase não há conflitos a resolver; sobra basicamente o caso de duas pessoas pegarem ao mesmo tempo e a peça ficar com quem pegou primeiro
      Gostaria de ver uma demo melhor em que a resolução de conflitos de fato aconteça
    • É uma experiência realmente divertida
      O vídeo com a cena descrita está aqui: https://streamable.com/asu261
  • Talvez você se lembre de isso já ter aparecido uma ou duas vezes antes como Replicache
    O Reflect é uma versão que adiciona um servidor de sincronização totalmente gerenciado e muito rápido
    A área de local-first/tempo real está bem movimentada hoje em dia, e vale a pena conferir Replicache/Reflect porque o modelo de dados e o modelo de programação são belamente simples
    Em comparação com CRDTs, a vantagem é que você pode tratar conflitos diretamente como código sequencial simples; acho que pode ser complexo adicionar resolução de conflitos específica da aplicação a CRDTs quando as regras embutidas não servem

  • Cerca de 15 anos atrás, na casa da minha avó, mergulhei bastante nesse assunto para fugir do tédio e até fiz uma implementação em JavaScript
    É claro que “operações aritméticas simplesmente funcionam” é algo muito fácil de tornar idempotente, mas tenho dificuldade em concordar com a afirmação de que “operações de lista também simplesmente funcionam”
    Por exemplo, imagine um array como [1, 2, 3, 4, 5], em que A remove o intervalo de 2 a 4, B remove o intervalo de 3 a 5, e C insere algo entre 3 e 4; quando as três atualizações chegam ao servidor ao mesmo tempo, a solução fica ambígua
    Se você se apoia em timestamps ou em Last Write Wins, o modelo transacional quebra; se escolhe uma operação como vencedora, também surge o problema do que os outros usuários verão e de como comunicar isso
    Se a resposta for “reenviar o array inteiro”, o modelo transacional também quebra

    • A observação é válida
      Na verdade, a intenção era mais próxima de “muitas operações de lista simplesmente funcionam”
      No Reflect, você não pode usar índices de lista como identificadores de edição/exclusão, porque índices não são estáveis; é por isso que a recomendação é usar o próprio item quando ele for atômico e, normalmente, um ID estável: https://i.imgur.com/IKzmf0q.png
      Também existe o problema de a inserção de C poder ser apagada, mas, no contexto de colaboração em tempo real, nenhum protocolo consegue resolver isso completamente
      Do ponto de vista de C, pode ser triste ver o que acabou de escrever desaparecer; esse tipo de coisa acontece porque humanos trabalhando simultaneamente no mesmo espaço têm intenções diferentes, e mecanismos sociais como desfazer e indicação de quem está trabalhando agora ajudam
    • Mesmo sem envolver exclusões, se “ao mesmo tempo” A adiciona “a” à lista L, B adiciona “b” e C adiciona “c”, o servidor aplica as adições em uma ordem arbitrária e depois envia o resultado aos clientes
      Enquanto isso, cada cliente pode ter aplicado localmente a própria adição, então A vê [“a”], B vê [“b”] e C vê [“c”]; quando o servidor envia o estado [“c”, “b”, “a”], imagino que os clientes descartem as mudanças pendentes e passem a tratar o estado do servidor como a verdade do mundo
      Mas, se cada adição tiver um efeito como “se a minha mudança for aplicada primeiro, eu venço”, fico curioso se, durante os 300 ms de espera pela atualização do servidor, todo mundo verá “you win”
    • A maioria desses sistemas trata exclusões como tombstones
      Assim, é possível inserir com segurança depois ou entre itens excluídos
    • Em uma aplicação colaborativa, o servidor pode resolver as três operações em qualquer ordem
      Os três usuários verão o resultado, perceberão que mexeram nos mesmos dados ao mesmo tempo e que o estado ficou bagunçado, e então corrigirão
      Pense em edição de documentos
      Se eles não conseguem ver que estão trabalhando ao mesmo tempo, pode ser surpreendente, mas no fim conseguem corrigir para chegar ao estado desejado
      Se não verificarem o resultado, ou não puderem verificá-lo, o estado não estará correto; mas, em apps interativos como edição colaborativa ou jogos, normalmente não é assim que as coisas acontecem
  • Sou uma das pessoas que participou deste projeto e posso responder se houver perguntas

    • Aaron é modesto, então vou dizer por ele: ele criou o Greasemonkey e esteve envolvido em várias inovações revolucionárias em navegadores ao longo dos anos
    • No passado, criei vários sistemas de sincronização multiplayer em codebases privadas e venho acompanhando essa área
      Também criei uma biblioteca “redux-pubsub” com rebase e autoridade do servidor, que, pelo que entendi, é parecida com TCR
      Há muitas partes desse modelo de que gosto, e o artigo linkado também é muito claro
      Vocês disseram que “validação de esquema e migrações vêm naturalmente, quase de graça, do design”, e fiquei curioso sobre quais abordagens realmente funcionaram bem para migrações
      Também me pergunto se, em casos de uso que lidam com uma quantidade considerável de edição de texto compartilhada em um sistema TCR, o padrão normalmente seria pensar primeiro em Yjs e Tiptap/ProseMirror; nesse caso, deixar um documento CRDT e um documento TCR em paralelo seria a melhor opção?
    • Fiquei curioso sobre como fica o desenvolvimento do Replicache daqui para frente
      Gostaria de saber se a maior parte da codebase do lado do cliente é compartilhada e podemos continuar esperando atualizações, ou se é mais provável que o Reflect substitua isso como foco principal
    • Gostaria de saber o que acham do ElectricSQL
    • Gostaria de saber qual é o número máximo de usuários que podem entrar simultaneamente em uma sala
  • A terminologia é meio confusa
    Em jogos há “jogadores”, então faz sentido chamar o sistema de sincronização de “multiplayer”, mas em software comum há “usuários”, então parece mais correto chamar de multiusuário
    Na página, misturar “usuário” e “multiplayer” soa estranho

    • Pode ser, mas “usuário” soa como consumidor
      O HN é um serviço multiusuário, mas seria estranho chamar o HN de multiplayer
      Interação simultânea em tempo real tem algo mais forte do que multiusuário, e multiplayer, no sentido de várias pessoas agindo e interagindo, parece um uso aceitável
    • Essa lógica é baseada na lógica usada há décadas em jogos multiplayer
      Por outro lado, todo software web é multiusuário, então essa palavra sozinha não informa nada
    • Hoje em dia a terminologia simplesmente se consolidou assim
      Multiplayer significa multiusuário ao vivo, em que se visualiza o que outros usuários estão fazendo a cada momento
  • Passei uns 2 anos olhando CRDTs por alto e sempre me perguntei como fica a autorização
    Este texto parece sugerir que apenas bibliotecas CRDT como Y.js têm dificuldade para lidar bem com aplicações em que alterações precisam passar por verificação de permissão
    Isso seria porque não há uma autoridade central, isto é, um servidor, e o Reflect parece assumir que o servidor media as interações dos clientes; queria saber se esse entendimento está correto

    • Dizer que “não dá” é forte
      Com esforço, é possível tratar autorização em CRDTs; por exemplo, colocando um servidor entre tudo e fazendo o servidor reverter alterações sem permissão que ele vê
      Mas, à medida que a aplicação cresce, isso exige mais manutenção, fica frágil e também elimina parte das vantagens do CRDT desde o início
      Se o servidor já está no meio, é muito mais simples usar um protocolo em que o servidor possa rejeitar mensagens desde o começo
    • CRDTs também podem incluir um servidor; o servidor apenas não precisa necessariamente estar no centro de tudo
      Por exemplo, a autenticação de conexão pode ficar a cargo do servidor
      Se alguém se conectar via P2P e reivindicar determinada permissão, é possível verificar essa reivindicação com o servidor
      Além disso, CRDT não significa necessariamente P2P; é possível encaminhar mensagens por um servidor central e ainda manter um modelo CRDT em que tanto servidor quanto clientes resolvem o estado atual
    • É possível tratar permissões assinando cada operação ou mensagem e verificando se ela corresponde à chave pública da lista de controle de acesso
      Essa abordagem permite que CRDTs funcionem também em contexto P2P
      Mas, se o servidor for a autoridade, basta rejeitar as mensagens de clientes sem permissão
  • Fiquei curioso sobre como tratam upgrades de mutators
    Se um cliente estiver executando código antigo, a operação vai divergir do servidor; a resposta óbvia seria versionar de forma independente, como increment_v1, increment_v2, mas queria saber se há uma forma melhor

    • Por enquanto, não há resposta melhor do que não remover o mutator antigo até saber que não há mais clientes com alterações pendentes
      Como ainda não ativamos persistência, atualmente esse período é bem curto
  • Parabéns pelo lançamento
    Fiquei curioso se é algo da mesma família que https://partykit.io/

    • Está no mesmo espaço
      A principal diferença é o quanto cada um tem uma arquitetura opinativa
      O PartyKit é bem não prescritivo, mais próximo de um servidor JavaScript leve, com início rápido e escalabilidade automática
      Parece que a maioria roda yjs no PartyKit, mas também é possível rodar automerge ou Replicache
      O Reflect é totalmente focado em oferecer a melhor experiência multiplayer possível e planeja integrar fortemente muitas escolhas em toda a stack para fazer o multiplayer simplesmente funcionar e permitir que você se concentre na implementação da aplicação
  • Para referência, essa estratégia é chamada de lockstep determinístico no desenvolvimento de jogos, e é usada com bastante frequência em jogos com muitas entidades cujo estado precisa ser sincronizado
    Exemplos representativos são os jogos de estratégia em tempo real, que quase todos usam esse método

    • Mais precisamente, como o cliente não espera a resposta do servidor antes de exibir alterações locais, isso se aproxima de rollback determinístico
      Há uma ótima palestra da GDC que explica em profundidade as implementações de Mortal Kombat e Injustice 2: https://youtu.be/7jb0FOcImdg
    • Isso não é lockstep determinístico
      Lockstep determinístico é um algoritmo para jogos P2P, em que cada participante espera as entradas de todos os outros jogadores antes de avançar a simulação do jogo
      Ele é “determinístico” porque o que se compartilha não é o resultado da simulação, mas as entradas, e a simulação é determinística para as mesmas entradas; e é “lockstep” porque todos os clientes avançam em um ritmo coordenado
      Como a série Age of Empires usa esse método, as unidades não se movem imediatamente quando você clica; StarCraft também o usa, mas tem truques para suavizar a sensação de jogo
      Reflect está mais perto de uma simulação autoritativa no servidor do que de P2P
      O cliente envia entradas ao servidor, mas não espera o resultado e faz predição localmente; o servidor volta no tempo e reproduz as entradas para compensar a latência de cada cliente
      Depois, quando o cliente recebe o resultado do servidor que inclui sua própria entrada, ele corrige a simulação local
      As palavras-chave desse algoritmo são autoridade do servidor, predição, compensação de latência e reconciliação de predição
      Não sei se isso existe no Reflect, mas em jogos FPS também é comum a interpolação do lado do cliente, em que, ao receber uma atualização do mundo, as entidades são interpoladas para a nova posição e rotação ao longo de certo período
      Como a única autoridade é o servidor, o determinismo não é tão importante, mas é útil para reduzir erros de predição na predição do cliente
      Erros de predição ocorrem quando a entrada de outros clientes altera muito o estado do mundo, ou quando a simulação não é determinística, por exemplo por a geração de números aleatórios não estar sincronizada
      Counter Strike também é um exemplo de não sincronizar a aleatoriedade da dispersão dos tiros para impedir cheats de “nospread”
      https://www.gabrielgambetta.com/client-side-prediction-live-...
      https://developer.valvesoftware.com/wiki/Latency_Compensatin...
      https://developer.valvesoftware.com/wiki/Source_Multiplayer_...
  • O Reflect é excelente
    Estamos usando a versão alfa em produção atualmente, e estamos muito satisfeitos não só com o sistema, mas também com Aaron e a equipe
    Se alguém tiver perguntas do ponto de vista de cliente, posso responder