- Reflect es un framework para crear rápidamente apps colaborativas como Figma, Notion o Google Sheets, y se lanzó públicamente al agregar un servidor totalmente administrado al motor de sincronización estilo videojuegos de Replicache
- Las interfaces colaborativas deben mostrar los cambios locales de inmediato sin esperar la respuesta del servidor, por lo que la forma de resolver conflictos cuando varias personas editan los mismos datos al mismo tiempo define la experiencia del producto
- En lugar de CRDT, Reflect elige Transactional Conflict Resolution: el cliente y el servidor ejecutan el mismo historial de llamadas a mutators, y el servidor crea el estado autoritativo según el orden de llegada
- Los CRDT de secuencia como Yjs son fuertes para texto, listas y mapas, pero en casos que requieren reglas de fusión aparte, como los contadores, los incrementos pueden perderse; Reflect maneja aritmética, operaciones de listas e invariantes de nivel superior volviendo a ejecutar mutators
- Como el servidor recibe solo el nombre de la mutation y sus argumentos para volver a calcular el resultado, no confía en el resultado calculado por el cliente y resulta más fácil incorporar controles de permisos, validación de esquema y migraciones en el diseño
La experiencia de desarrollo que presentó Reflect
- Reflect propone una nueva forma de crear apps web multijugador como Figma, Notion o Google Sheets
- Es una evolución de Replicache, un framework de sincronización del lado del cliente, y usa el mismo motor de sincronización estilo videojuegos
- A diferencia de Replicache, incluye un servidor totalmente administrado y busca permitir crear apps multijugador de alta calidad en cuestión de minutos
- Reflect se ofrece públicamente por primera vez; puedes conocerlo en reflect.net y comenzar en hello.reflect.net
Por qué surgen conflictos en la edición colaborativa
- En la edición colaborativa, los conflictos son inevitables
- Para crear una UI con respuesta inmediata, no se puede esperar al servidor y los cambios deben ocurrir primero de forma local en el cliente
- Como varios usuarios pueden editar el mismo elemento al mismo tiempo, hay que sincronizar los conflictos y resolverlos de manera natural para que todos vean el mismo resultado
- El motor de sincronización influye en la experiencia del desarrollador, la experiencia del usuario, el rendimiento posible e incluso en el tipo de apps que se pueden crear
CRDT y el ejemplo del contador en Yjs
- En el ecosistema web, los CRDT se usan ampliamente como forma de sincronizar datos
- Un CRDT es una estructura de datos que converge al mismo valor cuando todos los cambios entre colaboradores se intercambian, y Yjs y Automerge son bibliotecas CRDT open source representativas
- Reflect no es un CRDT; usa Transactional Conflict Resolution, una variante de Server Reconciliation usada durante mucho tiempo en la industria de los videojuegos
-
Por qué se rompe un contador simple en Yjs
- Guardar un valor
counten un Yjs Map y volver a escribirprev + 1puede perder incrementos en situaciones de concurrencia - El ejemplo correcto de contador en la documentación de Yjs consiste en agregar números a un arreglo y calcular la suma
- Yjs es un CRDT de secuencia, así que es fuerte para listas, fragmentos de texto y mapas, pero no resulta natural para modelar contadores
- El algoritmo de fusión de Yjs Map funciona por clave con last-write wins, así que si dos usuarios incrementan al mismo tiempo, uno de los cambios puede desaparecer
- Los CRDT encajan bien en ciertos problemas, pero fuera de ellos tienen la limitación de que son difíciles de extender
- Guardar un valor
Cómo funciona Transactional Conflict Resolution
- En Reflect, los cambios se implementan como funciones especiales de JavaScript llamadas mutators
- Existe una copia de cada mutator en todos los clientes y en el servidor
- Cuando un usuario genera un cambio, Reflect crea una mutation, es decir, un registro de llamada a un mutator
- Una mutation solo contiene el nombre del mutator y sus argumentos, como
increment(delta: 1) - El contenido del cambio resultante no se incluye en la mutation
- Una mutation solo contiene el nombre del mutator y sus argumentos, como
- Reflect aplica la mutation de inmediato en local para actualizar la UI, así que el usuario puede ver su cambio al instante
-
Linealización y reejecución en el servidor
- Cada cliente sigue agregando mutations sin esperar al servidor
- Las mutations se transmiten al servidor, y el servidor las linealiza según el orden de llegada para generar el siguiente estado autoritativo
- Por ejemplo, si
increment(1)del cliente 1 eincrement(2)del cliente 2 ocurren al mismo tiempo, el conteo final en el servidor se construye según el orden en que lleguen - El servidor fusiona conflictos linealizando el historial de ejecución, sin necesitar conocimiento aparte sobre qué hace
incremento cómo debe fusionarse - El estado autoritativo más reciente se sigue transmitiendo a cada cliente
- Cuando un cliente sabe que sus mutations pendientes ya fueron aplicadas al estado autoritativo, las elimina de la cola local
- Las mutations pendientes restantes se vuelven a ejecutar sobre el estado autoritativo más reciente para hacer rebase
- Todo este ciclo puede ocurrir hasta 120 veces por segundo por cliente
Coste de implementación y ventajas que se generalizan
- Implementar este enfoque requiere un datastore rápido capaz de rebobinar, bifurcar y crear ramas
- Del lado del servidor también se necesita un almacenamiento rápido para procesar las mutations entrantes
- Hace falta una forma de sincronizar mutators y manejar la recuperación cuando hay conflictos durante la sincronización en el cliente o en el servidor
- A cambio, la linealización de funciones arbitrarias funciona como una estrategia de sincronización bastante general
-
Ejemplos que se resuelven sin código de sincronización aparte
- Las operaciones aritméticas se manejan de forma natural
setHighScoreguarda el valor mayor entrehigh-scorey la puntuación candidata- La mayoría de las operaciones sobre listas también funcionan
appendagrega un elemento al final de una lista de comprasinsertAtinserta un elemento en la posición indicada, ysplice()ajusta la posiciónremovedebe recibir como argumento el elemento o un ID estable, ya que el índice puede cambiar- También se pueden hacer cumplir invariantes de nivel superior
addChildactualiza en conjuntochildIDsdel padre yparentIDdel hijo para que siempre sean consistentes- Estos ejemplos se fusionan razonablemente incluso sin código separado pensado específicamente para sincronización
Autoridad del servidor y control de permisos
- En Reflect, el servidor es la autoridad
- Lo que el cliente crea sobre el resultado de un cambio no se comparte con el servidor ni con otros clientes
- Al servidor solo se envían el nombre de la mutation y sus argumentos, y el servidor vuelve a calcular por sí mismo el resultado de la mutation
- El servidor ni siquiera necesita ejecutar exactamente el mismo código que el cliente; también puede consultar servicios externos o usar números aleatorios
-
Permisos granulares
- Este diseño permite incorporar de forma natural controles de permisos granulares
- Un ejemplo es un programa de diseño colaborativo donde a un invitado se le permiten comentarios y resaltados, pero no cambios reales en el diseño
- En los CRDT es difícil implementarlo porque no hay un lugar claro donde poner la lógica para rechazar cambios sin permiso
- En Reflect, el mutator que corre en el servidor puede revisar algo como
tx.user.canEdity lanzar un errorunauthorizedsi no hay permiso - No hay problema si el mutator ejecuta código distinto en el servidor y en el cliente, porque el servidor toma la decisión final
Validación de esquema y guía de uso
- En el enfoque de Reflect, la validación de esquema y las migraciones también pueden integrarse de manera natural en el diseño
- Elegir una estrategia de sincronización es el núcleo de un sistema multijugador, y Reflect considera que Transactional Conflict Resolution, aprendido de la industria de los videojuegos, es una forma simple, flexible y potente
- Si estás creando una app multijugador, puedes probarlo desde la página de inicio de Reflect
- Si quieres hablar con el equipo de desarrollo, puedes usar discord.reflect.net o @hello_reflect
1 comentarios
Opiniones de Hacker News
La demo en la parte superior de la página principal (https://reflect.net/) es bastante divertida.
Al verla, cada vez que se completa el rompecabezas, la gente celebra moviendo el cursor, como diciendo “¡lo logramos!”.
Con las otras letras no se puede hacer igual porque el contorno sigue viéndose.
Cuando tomas una pieza, parece que esa pieza queda bloqueada para ese usuario, así que casi no hay conflictos que resolver; como mucho queda decidir dársela al usuario que la tomó primero cuando dos personas la agarran al mismo tiempo.
Me gustaría ver una demo de ejemplo mejor donde realmente ocurra resolución de conflictos.
Aquí hay un video con la escena descrita: https://streamable.com/asu261
Quizá recuerden que esto ya apareció una o dos veces antes como Replicache.
Reflect es una versión que agrega un servidor de sincronización totalmente administrado y muy rápido.
El área local-first/tiempo real está bastante concurrida últimamente, pero Replicache/Reflect vale la pena revisarlo porque su modelo de datos y su modelo de programación son bellamente simples.
Comparado con los CRDT, su ventaja es que permite manejar conflictos directamente con código secuencial simple; creo que agregar resolución de conflictos específica de la aplicación a un CRDT puede ser complejo cuando las reglas incorporadas no encajan.
PowerSync también eligió una arquitectura de reconciliación en servidor en lugar de CRDT, y creo que la simplicidad de esta estructura es muy atractiva para aplicaciones con un servidor central.
Quiero darle crédito a Aaron por dar a conocer y difundir el concepto de reconciliación en servidor: https://www.gabrielgambetta.com/client-side-prediction-serve...
A JavaScript framework to build offline-first but collaborative webapp - https://news.ycombinator.com/item?id=33269440 - octubre de 2022
Linear clone, with realtime sync and instant UI - built with Replicache - https://news.ycombinator.com/item?id=31331660 - mayo de 2022
Replicache: Easy Offline-First for Existing Applications - https://news.ycombinator.com/item?id=22173500 - enero de 2020
Hace unos 15 años profundicé bastante en este tema para evitar aburrirme en la casa de mi abuela, e incluso hice una implementación en JavaScript.
Eso de que “las operaciones aritméticas simplemente funcionan” obviamente es muy fácil de convertir en operaciones idempotentes, pero me cuesta estar de acuerdo con que “las operaciones de lista también simplemente funcionan”.
Por ejemplo, supongamos que en un arreglo como [1, 2, 3, 4, 5], A elimina el rango 2~4, B elimina el rango 3~5 y C inserta algo entre 3 y 4. Cuando las tres actualizaciones llegan al servidor al mismo tiempo, la solución es ambigua.
Si te apoyas en timestamps o en Last Write Wins (la última escritura gana), se rompe el modelo transaccional; y si eliges una como ganadora, también se vuelve un problema qué verán los otros usuarios y cómo comunicarlo.
Si la respuesta es “reenviar todo el arreglo”, entonces también se rompe el modelo transaccional.
En realidad, la formulación pretendida era más bien “muchas operaciones de lista simplemente funcionan”.
En Reflect no se pueden usar índices de lista como identificadores de edición/eliminación, porque los índices no son estables; por eso se recomienda usar el propio elemento si es atómico y, por lo general, un ID estable: https://i.imgur.com/IKzmf0q.png
También existe el problema de que la inserción de C pueda eliminarse, pero en el contexto de colaboración en tiempo real ningún protocolo puede resolverlo por completo.
Desde el punto de vista de C, puede ser triste que lo que acaba de escribir desaparezca, y esto ocurre porque las intenciones de las personas que trabajan simultáneamente en el mismo espacio difieren; mecanismos sociales como deshacer y mostrar quién está trabajando actualmente ayudan.
Mientras tanto, cada cliente puede haber aplicado localmente su propia inserción: A ve [“a”], B ve [“b”] y C ve [“c”]. Si el servidor envía el estado [“c”, “b”, “a”], parece que los clientes descartarían los cambios pendientes y tomarían el estado del servidor como la verdad del mundo.
Pero si cada inserción produce un efecto como “si mi cambio se aplica primero, gano”, me pregunto si durante los 300 ms de espera por la actualización del servidor todos verán “you win”.
Así se puede insertar de forma segura después o entre elementos eliminados.
Los tres usuarios verán el resultado, se darán cuenta de que tocaron los mismos datos al mismo tiempo y de que el estado quedó enredado, y luego lo corregirán.
Piensa en la edición de documentos.
Si no puedes ver que otros están trabajando, puede sorprenderte, pero al final puedes corregirlo hasta llegar al estado deseado.
Si no revisas el resultado o no puedes revisarlo, el estado no será correcto, pero en apps interactivas como edición colaborativa o juegos normalmente no fluye así.
Soy una de las personas que participó en este proyecto, y puedo responder preguntas si las hay.
También hice una biblioteca “redux-pubsub” con rebase y autoridad del servidor, y por lo que entiendo es parecida a TCR.
Hay muchas cosas que me gustan de este modelo, y el artículo enlazado también es muy claro.
Dijeron que “la validación de esquemas y las migraciones surgen naturalmente del diseño, casi gratis”, y me da curiosidad qué enfoque les funcionó realmente bien para las migraciones.
Además, para casos de uso que manejan una cantidad considerable de edición de texto compartida en un sistema TCR, normalmente pensaría primero en Yjs y Tiptap/ProseMirror; me pregunto si lo mejor es mantener un documento CRDT y un documento TCR en paralelo.
Quisiera saber si la mayor parte de la base de código del lado del cliente se comparte y podemos seguir esperando actualizaciones, o si es más probable que Reflect pase a ser el foco principal.
La terminología me confunde un poco.
En los juegos hay “jugadores”, así que tiene sentido llamar “multijugador” al sistema de sincronización, pero en el software general hay “usuarios”, por lo que parece más correcto llamarlo multiusuario.
En la página mezclan “usuarios” y “multijugador”, y se lee raro.
HN es un servicio multiusuario, pero sería extraño llamar a HN multijugador.
La interacción simultánea en tiempo real tiene algo más fuerte que multiusuario, y multijugador, en el sentido de que varios usuarios actúan e interactúan, me parece un uso razonable.
En cambio, todo software web es multiusuario, así que esa palabra por sí sola no aporta ninguna información.
Multijugador significa multiusuario en vivo, donde se visualiza lo que otros usuarios hacen en cada momento.
Llevo unos 2 años mirando los CRDT por encima y siempre me pregunté cómo funciona la autorización.
Este artículo parece sugerir que, solo con bibliotecas CRDT como Y.js, es difícil manejar bien aplicaciones donde los cambios deben pasar controles de permisos.
Sería porque no hay una autoridad central, es decir, un servidor; y Reflect parece asumir que el servidor media las interacciones de los clientes. Me pregunto si esta interpretación es correcta.
Con esfuerzo, también se puede manejar autorización en CRDT; por ejemplo, poniendo un servidor entre todo y haciendo que el servidor revierta los cambios no autorizados que vea.
Pero a medida que la aplicación crece, requiere más mantenimiento, se vuelve frágil y, de entrada, también se pierden algunas ventajas de los CRDT.
Si el servidor ya está en el medio, es mucho más simple usar desde el principio un protocolo en el que el servidor pueda rechazar mensajes.
Por ejemplo, el servidor puede encargarse de autenticar la conexión.
Si alguien se conecta por P2P y afirma tener ciertos permisos, se puede verificar esa afirmación con el servidor.
Además, CRDT no implica necesariamente P2P; se puede reenviar mensajes mediante un servidor central y aun así mantener un modelo CRDT en el que tanto servidor como clientes resuelven el estado actual.
Este enfoque permite que los CRDT también funcionen en contextos P2P.
Pero si el servidor es la autoridad, basta con rechazar los mensajes de clientes no autorizados.
Me pregunto cómo manejan las actualizaciones de mutators.
Si un cliente está ejecutando código antiguo, sus operaciones diferirán de las del servidor; la respuesta obvia sería versionarlas de forma independiente, como
increment_v1,increment_v2, pero me pregunto si hay una forma mejor.Como todavía no activamos la persistencia, actualmente ese período es bastante corto.
Felicitaciones por el lanzamiento.
Me pregunto si está en una línea parecida a https://partykit.io/.
La diferencia clave es qué tan opinado es el diseño de cada uno.
PartyKit es muy poco prescriptivo, más cercano a un servidor JavaScript liviano que permite empezar rápido y escala automáticamente.
Parece que la mayoría corre yjs en PartyKit, pero también se puede correr automerge o Replicache.
Reflect está completamente enfocado en ofrecer la mejor experiencia multijugador posible, y el plan es integrar con fuerza muchas decisiones en todo el stack para que el multijugador simplemente funcione y puedas concentrarte en implementar la aplicación.
Como referencia, en desarrollo de juegos esta estrategia se conoce como lockstep determinista, y se usa con especial frecuencia en juegos con muchas entidades cuyo estado debe sincronizarse.
Un ejemplo representativo son los juegos de estrategia en tiempo real, que casi todos usan este enfoque.
Hay una excelente charla de GDC que explica en profundidad las implementaciones de Mortal Kombat e Injustice 2: https://youtu.be/7jb0FOcImdg
El lockstep determinista es un algoritmo para juegos P2P en el que cada participante espera las entradas de todos los demás jugadores antes de avanzar la simulación del juego.
Es “determinista” porque se comparten las entradas, no el resultado de la simulación, y la simulación es determinista para las mismas entradas; y es “lockstep” porque todos los clientes avanzan a un ritmo coordinado.
La serie Age of Empires usa este enfoque, por eso las unidades no se mueven de inmediato al hacer clic; StarCraft también lo usa, pero tiene trucos para que la sensación de juego sea más fluida.
Reflect se parece más a una simulación con autoridad del servidor que a P2P.
El cliente envía entradas al servidor, pero no espera el resultado y predice localmente; el servidor retrocede en el tiempo y reproduce las entradas para compensar la latencia de cada cliente.
Luego, cuando el cliente recibe el resultado del servidor que incluye sus propias entradas, corrige su simulación local.
Las palabras clave de este algoritmo son autoridad del servidor, predicción, compensación de latencia y reconciliación de predicción.
No sé si Reflect lo incluye, pero en los juegos FPS también es común la interpolación del lado del cliente, en la que, al recibir actualizaciones del mundo, se interpolan las entidades hacia su nueva posición y rotación durante cierto tiempo.
Como el único ente con autoridad es el servidor, el determinismo no es tan crucial, pero sí ayuda a reducir predicciones incorrectas en la predicción del cliente.
Las predicciones incorrectas ocurren cuando las entradas de otros clientes cambian mucho el estado del mundo, o cuando la simulación no es determinista, por ejemplo porque la generación de números aleatorios no está sincronizada.
Counter Strike también es un ejemplo de no sincronizar la aleatoriedad de la dispersión de las balas para impedir trampas de tipo “nospread”.
https://www.gabrielgambetta.com/client-side-prediction-live-...
https://developer.valvesoftware.com/wiki/Latency_Compensatin...
https://developer.valvesoftware.com/wiki/Source_Multiplayer_...
Reflect es excelente.
Actualmente estamos usando la versión alfa en producción, y estamos muy satisfechos no solo con el sistema, sino también con Aaron y el equipo.
Si tienen preguntas desde el punto de vista de un cliente, puedo responderlas.