Anatomía de una conmutación por error: qué le pasa a una escritura en vuelo cuando el dueño muere
Una conmutación por error es el momento en que un almacén distribuido es más honesto sobre lo que realmente es. En estado estable todos los almacenes se ven iguales: una escritura llega a un dueño, se confirma, y sigues adelante. El diseño solo se muestra cuando el dueño desaparece a mitad de frase: un nodo tiene la copia activa de una de tus particiones, le están llegando escrituras ahora mismo, y de pronto ya no está. Eliminado en seco. Particionado por la red. Muerto por OOM. El clúster tiene unos segundos para darse cuenta, acordar un nuevo dueño y hacer que el tráfico vuelva a fluir; y que lo haga correctamente decide si tu escritura confirmada sobrevive o se evapora silenciosamente.
Esta entrada sigue esa secuencia de principio a fin, en el orden en que Zaris realmente la ejecuta, y nombra los casos en los que la respuesta es genuinamente "la escritura se pierde", porque una entrada sobre conmutación por error que solo describe el camino feliz no vale la pena leer.
El único invariante que hace tratable el resto
Antes de la línea de tiempo, la única decisión de diseño de la que cuelga todo lo demás: Zaris separa el mapa de particiones del runtime de particiones.
El mapa de particiones es la asignación lenta y deliberada —qué nodos son responsables de la partición P: un primario preferido y sus réplicas—. Cambia solo ante eventos de topología reales (un nodo se une o se va permanentemente, un escalado horizontal, un rebalanceo) y cambia mediante una regeneración planificada y versionada.
El runtime es la respuesta rápida y en vivo a una pregunta distinta —qué nodo asignado es el dueño activo que sirve P ahora mismo, y con qué salud—. Durante una conmutación por error el mapa no se mueve en absoluto. Solo se mueve el activo del runtime. Ese es el invariante declarado en el propio servicio de conmutación por error: una única señal independiente de fallo abre la compuerta de conmutación del runtime, "PartitionMap remains stable and only PartitionRuntimeMap.Active is allowed to move during failover."
Por qué importa: regenerar un mapa de particiones es caro y peligroso; puede reordenar particiones sanas y disparar migración de datos. Que un nodo muera no debería disparar eso. Debería disparar una decisión de runtime barata y local: elegir una réplica superviviente de la misma partición y hacerla el activo. El mapa sigue diciendo "P es propiedad de n1, n2, n3"; el runtime cambia de "activo = n1" a "activo = n2". Mantener estos dos planos separados es lo que permite que la conmutación sea rápida sin ser imprudente.
Paso 1 — detectar, pero no creerse el primer rumor
La conmutación por error está coordinada por el líder. Un nodo del clúster es el líder electo, y solo el líder promueve: un no-líder que oye un evento de nodo-salido simplemente lo ignora. Eso evita que dos nodos elijan independientemente dos nuevos dueños distintos para la misma partición.
Cuando el líder recibe una señal NodeLeft para un nodo, no promueve inmediatamente réplicas para cada partición que ese nodo poseía. La promoción se señala en el código como "an irreversible, high-impact action", y está protegida:
if (string.Equals(activeOwner, nodeId, StringComparison.Ordinal))
{
// A NodeLeft message can race ahead of the peer-table update.
// If the "dead" owner is in fact still reachable, do NOT fail it over.
if (IsNodeAlive(nodeId))
{
Log.NodeUnavailableRejectedReachable(/* ... */);
continue;
}
await PromoteOrMarkUnavailableAsync(partition, nodeId, runtime, ct);
}
La protección existe porque el mensaje de fallo y la tabla de pares de la capa de membresía se actualizan de forma asíncrona. Un NodeLeft puede llegar un latido antes de que la tabla de pares lo refleje, o estar rotundamente equivocado. Así que antes de la acción de alto impacto, el líder vuelve a comprobar la vitalidad directamente. Un dueño "muerto" todavía alcanzable se deja exactamente donde está.
Hay un segundo camino, más lento, para el caso que el primero no puede cubrir: un dueño activo que se ha quedado en silencio sin un NodeLeft limpio. Un barrido periódico de reconciliación (HandleUnavailableActiveOwnersAsync) recorre cada partición, y para un dueño que no está vivo pero no ha sido confirmado como caído, arranca un temporizador de sospecha en lugar de actuar:
var suspectedSince = _suspectedUnavailableSinceUtc.GetOrAdd(activeOwner, _ => DateTime.UtcNow);
if (DateTime.UtcNow - suspectedSince < _suspectEscalationTimeout)
{
Log.UnconfirmedActiveOwnerFailure(/* ... */);
continue; // not yet — give it until the escalation timeout
}
ConfirmNodeUnavailable(activeOwner, "RuntimeOwnershipSuspectEscalationTimeout");
Solo después de que el dueño haya estado inalcanzable durante toda la ventana de escalada (diez segundos por defecto) el líder lo confirma como caído y procede. El diseño es deliberadamente asimétrico: marcar una partición como degradada por pérdida de réplica es barato y reversible, así que se dispara de inmediato; promover un nuevo activo es irreversible, así que espera a tener pruebas.
Paso 2 — elegir un superviviente, y subir la época
Una vez confirmado que el dueño está caído, el líder elige una réplica para promover. La selección es primero la disponibilidad: entre las réplicas asignadas de la partición, prefiere la que ha sincronizado más segmentos, el nodo con más probabilidades de poder servir pronto.
La promoción en sí es una única difusión que avanza una época por partición:
var nextEpoch = GenerateNextEpoch(partition.PartitionId, runtime, "ReplicaPromotion");
// ...
await _runtimeOwnership.BroadcastAsync(
partition.PartitionId, promotedNodeId, nextEpoch.Value, promotionState, ct);
La época es el token de delimitación (fencing token), y es el héroe silencioso de todo el mecanismo. Es monótona por partición, y el generador se niega a retroceder: una época propuesta que no sea estrictamente mayor que la más alta ya observada se rechaza de plano:
var nextEpoch = baseEpoch + 1;
if (nextEpoch <= highestObservedEpoch)
{
Log.StaleEpochGenerationRejected(/* ... */);
return null; // never regress ownership
}
Así que tras la promoción, el activo de la partición es "n2 en la época 8". Si el antiguo dueño n1 estaba meramente particionado (no muerto) y vuelve creyendo que sigue siendo el activo en la época 7, su época obsoleta pierde toda comparación. No puede reclamar la propiedad, y cualquier escritura que intente aceptar como "activo" queda delimitada fuera (fenced). Así es como Zaris evita el split-brain de dos primarios que de otro modo dejaría que un antiguo dueño sanado-pero-confundido aceptara escrituras que nadie volverá a ver jamás.
Paso 3 — la prueba de posesión: ¿servir o sincronizar primero?
Aquí está la parte sutil, y la parte que requirió depuración real para hacerla bien. Habiendo elegido la réplica más disponible, ¿puede realmente empezar a servir lecturas de inmediato?
No necesariamente, porque "más segmentos sincronizados" no es lo mismo que "tiene todas las claves confirmadas". Una réplica puede estar al día en el recuento de segmentos y aun así ir por detrás de los datos verdaderos en los segmentos que tiene. Promueve tal nodo directamente a un estado de servicio y se convierte en un activo incompleto: responde NotFound a claves que fueron confirmadas de forma durable en el dueño muerto y que sí existen en un hermano más completo. Eso es pérdida silenciosa de datos disfrazada de lectura exitosa.
Así que antes de permitir que el nodo promovido sirva, Zaris aplica una prueba de posesión. El líder mantiene una tabla fresca y emparejada por término (term-matched) de cuántas claves reporta tener cada nodo para la partición. La regla es una comparación pura:
public static PartitionHealthState ResolvePromotionState(
long candidateHeldKeys, long bestOtherLiveHolderKeys)
=> bestOtherLiveHolderKeys > candidateHeldKeys
? PartitionHealthState.Recovering // a more-complete live peer exists: sync first, do NOT serve
: PartitionHealthState.Degraded; // candidate is the authority: serve now
Dos resultados:
- No existe ningún poseedor vivo estrictamente más completo — el candidato es el nodo más completo, o un genuino único superviviente sin nada de lo que sincronizar. Se promueve a Degraded: un estado de servicio. No tiene nada mejor por lo que esperar, así que acuñarlo como no-servicio solo haría la partición innecesariamente indisponible. Sirve de inmediato.
- Sí existe un poseedor vivo estrictamente más completo — el candidato se promueve a Recovering: un estado sin servicio. Sincroniza primero desde esa fuente mejor y solo empieza a servir una vez que se demuestra completo. A los clientes se les dice que la partición todavía está arrancando, en lugar de entregarles un nodo que les mentiría.
La distinción entre Degraded y Recovering es exactamente la distinción entre "la redundancia está reducida pero se está sirviendo la verdad" y "no confíes todavía en este dueño para lecturas". Lograr que un único superviviente entre correctamente en Degraded importa tanto como mantener un nodo incompleto en Recovering: una única copia restante nunca debe quedar acuñada como no-servicio para siempre solo porque es la única copia que queda.
Una nota rápida sobre honestidad: esta prueba está basada en recuentos, extraída de los reportes de claves-poseídas por nodo con una marca de agua de frescura (un reporte de un nodo que se ha quedado en silencio no cuenta como fuente viva). Es una protección fuerte y barata contra promover a servicio un nodo obviamente rezagado; no es una atestación criptográfica de que dos nodos tienen datos byte a byte idénticos. La convergencia que hace las copias realmente idénticas es el camino de sincronización de réplicas, cubierto más abajo.
Paso 4 — el cliente se redirecciona, y tu reintento aterriza
Mientras todo esto ocurre en el servidor, ¿qué ve el cliente que estaba a mitad de escritura?
Ve la verdad, como un estado tipado. Una solicitud que llega al antiguo dueño (ahora delimitado, o simplemente ido) no se cuelga ni lanza un error vago: vuelve como Moved u OwnershipChanging. El cliente de Zaris los trata como señales de redireccionamiento reintentables: refresca su mapa de propiedad, descubre el nuevo activo y reproduce la operación ahí. En el caso común tu PutAsync simplemente tarda unos milisegundos más y devuelve Ok, contra el dueño recién promovido, en la nueva época. El código de la aplicación nunca se entera de que el dueño se movió.
Esa maquinaria del cliente —la taxonomía de estados, el reintento con presupuesto-de-intentos-y-plazo, el refresco del mapa— es una historia en sí misma, contada en Reintentos que no escribes. Para esta entrada el punto es solo que el aumento de época del servidor y el redireccionamiento del cliente son dos mitades de un solo traspaso: el servidor hace al antiguo dueño no-autoritativo, y el cliente deja de hablarle.
Paso 5 — converger, luego sanar de vuelta a Healthy
La promoción restaura la disponibilidad; no restaura la redundancia. Justo después de una conmutación por error la partición corre en Degraded con una copia menos de su objetivo. Entonces ocurren dos cosas en segundo plano.
Primero, el nodo promovido (si arrancó en Recovering) y cualquier réplica rezagada o que retorna se ponen al día reproduciendo el registro de operaciones por segmento desde una revisión conocida, el mismo sustrato reproducible que impulsa la replicación ordinaria. Un nodo que acaba de heredar el rol activo y una réplica que simplemente se quedó atrás convergen a través del mecanismo idéntico; ese es el tema de Un registro, dos trabajos.
Segundo, una vez que cada participante asignado está vivo de nuevo y sincronizado, el barrido de recuperación del líder transiciona la partición de vuelta a Healthy, pero solo mediante la misma difusión que avanza la época, y solo tras un breve enfriamiento para que un nodo oscilante no pueda arrastrar el runtime a través de una tormenta de transiciones. La máquina de estados de salud es por tanto un bucle pequeño y cerrado: Healthy → Degraded/Recovering → Healthy, cada arista un aumento de época, cada promoción delimitada.
Si no sobrevive ninguna réplica elegible en absoluto —todos los nodos asignados a la partición están caídos—, el líder hace lo único honesto: marca la partición como Unavailable (sin dueño, sin falsa promoción) e inicia una cuenta regresiva de desalojo que, si nadie retorna a tiempo, deja que una regeneración planificada del mapa reubique la partición. Una partición Unavailable falla lecturas y escrituras ruidosamente en lugar de servir una mentira.
Dónde se pierde genuinamente una escritura — dicho con claridad
Un mecanismo de conmutación por error gana confianza siendo preciso sobre sus bordes, no insinuando que no los hay.
- Factor de replicación 1. Una partición con una sola copia y sin réplica no tiene nada que promover. Cuando ese nodo muere la partición queda Unavailable hasta que retorne; los datos no se pierden (están en la memoria de ese nodo) pero están indisponibles, y si el nodo nunca retorna, se han ido. RF1 es no-durable por diseño: es una elección deliberada para datos que te puedes permitir perder, no una garantía que el camino de conmutación pueda rescatar.
- Confirmación asíncrona. Zaris permite que una escritura se confirme en el dueño activo antes de que haya llegado a una réplica, por latencia. Ese es el camino rápido, y su coste honesto es una ventana: si el activo muere en el hueco entre confirmarle al cliente y que la operación llegue a cualquier poseedor superviviente, esa escritura confirmada se pierde. La prueba de posesión te protege de promover a servicio un nodo conocido como rezagado; no puede conjurar una operación que nunca salió del nodo muerto. Si necesitas que una escritura sobreviva a la pérdida de su dueño, debe confirmarse solo después de que sea durable en una réplica; el camino síncrono cambia un poco de latencia por exactamente esa garantía. Elige por carga de trabajo, con los ojos abiertos.
- La ventana de confirmación misma. Durante el tiempo de escalada tras que un dueño se queda en silencio, las escrituras de la partición están en el limbo: reintentadas por el cliente, aún sin aterrizar en un nuevo dueño. La corrección se preserva (nada se promueve erróneamente), pero la disponibilidad cae durante esa ventana. Apretar el tiempo de espera cambia una conmutación más rápida por más falsas promociones bajo parpadeos transitorios; es un dial, no un almuerzo gratis.
Ninguno de estos son errores que el camino de conmutación pueda diseñar para eliminar: son las genuinas disyuntivas de disponibilidad frente a durabilidad frente a latencia, y el trabajo de Zaris es hacer que la elección segura sea la predeterminada y la elección rápida sea explícita, nunca fingir que la ventana no existe.
La forma de todo esto
Reduce la conmutación por error a lo esencial y son cuatro movimientos sobre un invariante. El invariante: el mapa se queda quieto; solo se mueve el activo del runtime. Los movimientos: confirmar la muerte antes de actuar sobre ella, promover a un superviviente detrás de una época monótona que delimita al antiguo dueño, probar la posesión para que el nuevo dueño sirva la verdad o sincronice antes de servir siquiera, y redireccionar al cliente para que su reintento aterrice en el nuevo dueño. Luego converger las copias de vuelta y sanar a Healthy.
Que se sostenga no es una cuestión de argumento: está medido. El mismo camino de promoción, posesión y convergencia corre continuamente bajo caos deliberado de eliminación de nodos durante horas seguidas, con el libro mayor revisado al final en busca de una sola escritura confirmada perdida. Esa certificación es su propia historia: Ninguna escritura confirmada se queda atrás.