wandres.dev
CASOS DE ESTUDIO · qué hacen los que lo hacen

El patrón del gestor de incidencias: un modelo relacional pequeño que cabe entero

Cómo se monta la sensación de latencia cero en las aplicaciones de productividad: modelo en memoria, orden total asignado por el servidor y un alcance de sincronización deliberadamente acotado.

⏱ 19 min

Hay una familia de aplicaciones —gestores de incidencias, clientes de correo, herramientas de planificación— cuya seña de identidad comercial es que responden antes de que sueltes la tecla. La tentación es atribuirlo a una red rápida o a un servidor cercano, y es exactamente al revés: responden así porque en el momento de pulsar no hay red de por medio en absoluto, y porque alguien decidió que el modelo de datos completo del espacio de trabajo cupiese en la memoria del navegador. Este nivel abre una serie de casos de estudio y conviene empezarla con una advertencia metodológica: de estos productos sabemos lo que sus equipos han contado en charlas y artículos, más lo que terceros han reconstruido leyendo el código que se descarga en el navegador. Distinguir esas dos fuentes no es pedantería académica, es lo que separa aprender de un caso real de repetir folclore de ingeniería.

🎯 Al terminar esta lección sabrás
  • Separar lo documentado por el propio equipo de lo reconstruido por terceros al estudiar un producto.
  • Reconocer la arquitectura de reserva en memoria con hidratación diferida y base local de respaldo.
  • Entender por qué un contador global de orden total produce latencia percibida cero sin ser un CRDT.
  • Identificar el alcance de sincronización como la decisión de producto que sostiene todo lo demás.

Qué se sabe de verdad y por qué fuente

El caso mejor estudiado de esta familia es el motor de sincronización de Linear. Del propio producto hay dos fuentes primarias: una charla en React Helsinki y una segunda charla, Scaling the Linear Sync Engine, publicada en el sitio de la empresa en junio de 2023 y firmada por Tuomas Artman. Ambas son material de la casa y describen el problema en sus propios términos: un motor de sincronización en tiempo real, los apuros de escalarlo y cómo la API pública del producto acabó tomando la forma que tomó por culpa de esos apuros. Eso es lo que la empresa afirma de sí misma, y es poco más que un esqueleto.

La segunda fuente es de naturaleza distinta y hay que tratarla como tal. Existe un estudio abierto de ingeniería inversa del motor, publicado por un tercero, que describe con enorme detalle la maquinaria interna: nombres de clases minificadas, tablas de la base local, estrategias de carga. Su propia presentación afirma contar con el visto bueno del director técnico de la empresa. Lo verificable es que esa afirmación está ahí; el respaldo en sí no lo he comprobado de forma independiente. La postura razonable es usar ese material como hipótesis muy bien fundamentada sobre una implementación concreta en un momento concreto, no como especificación. El código de una aplicación web cambia sin avisar y sin número de versión.

⚠️
Cómo leer el resto de la lección

Marco con claridad los tres registros. Documentado significa que el equipo lo ha dicho en un artículo o una charla suya. Reconstruido significa que procede del estudio de ingeniería inversa y describe una implementación observada, no un compromiso del producto. Inferencia significa que lo deduzco yo del resto y puede estar equivocado. Si una afirmación te resulta útil para tomar una decisión de arquitectura, mira antes en qué registro está.

La reserva en memoria y la hidratación diferida

Lo reconstruido describe una arquitectura de tres capas que conviene entender como una sola idea. Las entidades del dominio —incidencia, equipo, comentario, organización— se declaran como modelos con metadatos asociados a cada propiedad, y esos metadatos son los que gobiernan todo el comportamiento posterior: si la propiedad se persiste, cómo se serializa, si es una referencia a otro modelo, si se indexa. Los modelos viven en una reserva de objetos, un mapa grande indexado por identificador único, y las vistas se suscriben a ellos mediante un sistema de observabilidad reactiva. Cualquier consulta de la interfaz interroga primero a esa reserva.

Debajo hay una base local en el navegador que sirve de respaldo entre sesiones, y la pieza que hace el conjunto viable es la estrategia de carga declarada por modelo. El estudio identifica cinco: cargar en el arranque, cargar entera pero solo cuando haga falta, cargar por subconjuntos bajo demanda, cargar únicamente si alguien lo pide de forma explícita, y no salir nunca del cliente. Las entidades voluminosas —el contenido de los documentos, el historial— no están en la primera categoría. Ese reparto es la diferencia entre un arranque de un segundo y uno de treinta.

// Reconstruccion ilustrativa del patron, no del codigo real
const reserva = new Map(); // uuid -> modelo observable

function leerIncidencia(uuid) {
  const m = reserva.get(uuid);
  if (!m) return null;
  if (!m.hidratado) hidratarDesdeBaseLocal(m); // las propiedades pesadas llegan tarde
  return m;
}

La consecuencia de diseño que importa es que la latencia percibida no la produce la red sino la ausencia de red en el camino crítico de lectura. Cuando pulsas sobre una incidencia, no hay petición: hay un acceso a un mapa en memoria. Y esa arquitectura solo es posible si alguien ha aceptado antes una restricción incómoda: el modelo relacional que se sincroniza tiene que ser pequeño. No pequeño en número de filas, sino pequeño en variedad de entidades y en tamaño medio de cada una. Un gestor de incidencias cabe. Un almacén de ficheros adjuntos, no.

Transacciones, orden total y paquetes de deltas

Aquí es donde el patrón se separa con nitidez de la familia de los CRDT, y donde más conviene ser preciso. Según lo reconstruido, una escritura local se aplica de inmediato al modelo en memoria y se empaqueta como una transacción que se encola, se guarda en la base local por si la conexión cae y se envía al servidor por lotes. El servidor la ejecuta —en exclusiva, es él quien manda—, incrementa un contador global monótono y devuelve a todos los clientes conectados, incluido el que la originó, un paquete de deltas con las acciones resultantes y el número de secuencia correspondiente.

sequenceDiagram
participant UI as Interfaz
participant M as Reserva en memoria
participant Q as Cola de transacciones
participant S as Servidor
UI->>M: escribir titulo
M-->>UI: repintado inmediato
M->>Q: transaccion pendiente
Q->>S: envio por lotes
S->>S: ejecutar y aumentar el contador global
S-->>M: paquete de deltas con numero de secuencia
M->>M: aplicar y resolver la transaccion pendiente

Ese contador es la pieza conceptual central: funciona como número de versión de toda la base de datos y establece un orden total de las escrituras del sistema. Los CRDT que ocupan la mitad de este track solo necesitan un orden parcial y por eso pueden prescindir del árbitro; aquí el árbitro es imprescindible y la reconstrucción lo dice sin rodeos, señalando que el modelo se parece más a la transformación operacional que a un tipo replicado sin conflictos. La resolución de conflictos que describe es gana la última escritura, con la particularidad de que no hace falta reloj alguno, porque el orden lo fija quien recibe.

ℹ️
Lo que el cliente puede y no puede hacer sin servidor

Con transacciones en cola y modelo en memoria, un cliente desconectado lee todo lo que ya tenía, escribe con normalidad y conserva sus escrituras. Lo que no puede hacer es cerrar nada: no sabe si su transacción sobrevivirá, no puede darle un número de secuencia y no puede reconstruir el estado global si pierde la base local. Es una réplica optimista con recibo pendiente, no una fuente de verdad. La distinción parece sutil y decide si el producto entra o no en la definición estricta de local-first del nivel 1.

El alcance: grupos de sincronización y el techo del modelo

Queda la decisión que sostiene el edificio entero y que casi nunca aparece en las presentaciones. Si todos los clientes comparten un contador global, ¿qué impide que te lleguen los deltas de espacios de trabajo ajenos? La respuesta reconstruida son los grupos de sincronización: cada cliente declara a qué conjunto de identificadores está suscrito —su usuario, sus equipos, ciertos roles— y el servidor filtra los deltas contra esa suscripción. El control de acceso no es una capa sobre la sincronización; es el mecanismo de enrutado de la sincronización.

// El filtro de permisos y el filtro de entrega son el mismo filtro
function entregarDelta(delta, cliente) {
  const visible = delta.acciones.filter((a) =>
    cliente.gruposSuscritos.includes(a.grupo)
  );
  if (visible.length) enviar(cliente, { acciones: visible, seq: delta.seq });
}

De ahí se deduce, y esto ya es inferencia mía, el techo real del patrón: funciona mientras el subconjunto que un usuario tiene derecho a ver quepa en su dispositivo. Cuando una organización crece lo bastante, o cuando alguien tiene acceso a todo, la premisa se rompe y hay que empezar a cargar por subconjuntos, que es precisamente lo que las estrategias de carga parciales están haciendo. La arquitectura no escala indefinidamente: se degrada de forma controlada hacia el modelo cliente-servidor clásico, entidad por entidad, a medida que crece el volumen.

🏢

Lo documentado por la empresa

Existe un motor de sincronización en tiempo real, escalarlo fue difícil y la API pública se moldeó por esa dificultad. Dos charlas propias, poco detalle interno.

🔍

Lo reconstruido por terceros

Reserva de objetos, hidratación diferida, transacciones en cola, contador global, paquetes de deltas y grupos de sincronización. Detalladísimo y sin garantía de vigencia.

🧭

Lo que es inferencia

Que el techo del patrón es el tamaño del subconjunto visible por usuario, y que la degradación hacia cliente-servidor es gradual y por entidad.

🚫

Lo que nadie ha afirmado

Que el cliente pueda operar indefinidamente sin el servidor, ni que el dato local sea la fuente de verdad. Ninguna fuente lo sostiene.

La latencia cero no se compra con velocidad: se compra renunciando a preguntar

Conviene detenerse en la inversión conceptual que hace funcionar a toda esta familia, porque es más profunda de lo que sugiere el eslogan comercial y porque, una vez vista, reorganiza el resto del nivel. La forma habitual de perseguir la instantaneidad es hacer la pregunta más rápido: acercar el servidor, comprimir la carga útil, abrir la conexión antes, cachear la respuesta. Todo eso mejora los números y ninguna de esas técnicas cruza el umbral perceptivo, porque el coste irreducible de preguntar a otra máquina y esperar la respuesta está gobernado por la física y no por la ingeniería, como ya establecimos en el nivel 3. Lo que hace este patrón es distinto en naturaleza: elimina la pregunta del camino crítico. La interfaz nunca consulta al servidor para pintar; consulta a un mapa en memoria que ya contiene la respuesta. El servidor sigue ahí, sigue siendo el árbitro y sigue teniendo la última palabra sobre el orden de las escrituras, pero se ha desplazado a un carril paralelo donde su latencia ya no la percibe nadie. Ahora bien, ese desplazamiento tiene un precio y el precio es exactamente el tema de esta lección: para que la respuesta esté ya en memoria, todo lo que el usuario pueda llegar a preguntar tiene que haberse traído antes, lo cual solo es viable si alguien ha acotado con severidad qué es ese todo. De ahí que el patrón aparezca una y otra vez en gestores de incidencias, correos y calendarios, y prácticamente nunca en almacenes de archivos, catálogos de comercio o cuadros de mando analíticos. No es que en esos dominios la técnica no funcione; es que en esos dominios el conjunto que habría que precargar no tiene cota superior conocida. La lección de arquitectura, entonces, no es usa un motor de sincronización, sino algo bastante más severo: antes de prometer latencia cero, demuestra que existe un subconjunto acotado que cubre el noventa y nueve por ciento de las preguntas del usuario. Si ese subconjunto existe y cabe, el resto de la maquinaria es trabajo duro pero conocido. Si no existe, ninguna cantidad de ingeniería de sincronización te salvará, y lo que construirás será un caché grande con vocabulario de local-first. Fíjate además en que esta es la razón por la que los grupos de sincronización y las estrategias de carga no son detalles de implementación sino decisiones de producto disfrazadas de decisiones técnicas: definir el subconjunto es definir qué puede hacer el usuario cuando el mundo se cae, y esa pregunta no la contesta un ingeniero solo.

⚔️ Audita el subconjunto de tu propia aplicación
  1. Enumera las entidades de tu modelo y estima, con datos reales de producción, el tamaño medio y el número de filas que un usuario típico tiene derecho a ver.
  2. Marca cada entidad con una de las cinco estrategias de carga descritas y justifica en una línea por qué no puede estar en la anterior.
  3. Calcula el peso total de las entidades marcadas para cargar en el arranque y compáralo con lo que tu presupuesto de arranque tolera.
  4. Identifica el percentil noventa y nueve de tu base de usuarios y repite el cálculo: es ahí donde la arquitectura se rompe, no en la media.
  5. Escribe qué operaciones quedan disponibles sin conexión y cuáles no, y comprueba si esa lista coincide con lo que tu producto promete.
  6. Localiza dónde vive hoy tu filtro de permisos y determina si podría usarse también como filtro de entrega, o si son dos verdades que pueden divergir.