Un propietario que reinicia no tiene derecho a su propia vacuidad
Un failover cubre el momento en que un propietario muere: el clúster elige un superviviente y sigue sirviendo. Una migración planificada cubre mover la partición de un propietario sano a propósito. Este artículo trata del tercer acto, el que corre después de que las sirenas se apagan —el proceso propietario original reinicia, se reúne al clúster, y quiere su partición de vuelta. Su almacén de nada-en-disco, todo-en-memoria volvió vacío. Los datos de los que es responsable siguen vivos, pero están vivos en las réplicas que siguieron sirviendo mientras estaba caído.
Todo el problema de corrección está contenido en una frase: al propietario reiniciado no se le puede permitir confiar en su propia vacuidad. Un almacén vacío se ve exactamente como un almacén que alguien limpió deliberadamente. Si el nodo asume "soy el propietario y no tengo datos, por lo tanto esta partición está vacía", servirá alegremente NotFound para millones de claves vivas y, peor, dejará que su estado vacío fluya de vuelta a las réplicas y las elimine. Este artículo recorre las cinco políticas pequeñas y puras que Zaris usa para asegurar que eso nunca ocurra.
Por qué la vacuidad es indecidible desde dentro
Empieza con la trampa, porque cada regla de abajo está moldeada por ella. Un nodo que arranca sosteniendo cero registros para un segmento que posee está en uno de dos estados, y no puede saber cuál solo con información local:
- Está vacío porque acaba de reiniciar y nunca extrajo sus datos de vuelta. Debe extraer.
- Está vacío porque autoritativamente limpió o expiró esas claves, y una réplica que estuvo brevemente caída todavía sostiene las copias obsoletas. Si extrae, resucita datos eliminados.
Estos dos son localmente indistinguibles —el almacén está vacío de cualquier modo. Una versión anterior de Zaris intentó resolver esto con una señal de anomalía EmptyActive y se eliminó precisamente porque la señal es una conjetura. No puedes preguntar "¿estoy vacío por una buena razón?" y obtener una respuesta confiable del nodo vacío.
Así que Zaris no le pregunta nada a la vacuidad. Enruta la decisión a través de un hecho distinto: un flag pegajoso a nivel de partición llamado certified (certificado), que significa exactamente "mi sincronización de arranque se completó y mi almacén es ahora autoritativo". Un nodo lo establece solo después de haber demostrado la posesión. Un propietario recién reiniciado está, por definición, sin certificar— y un propietario sin certificar no tiene autoridad para estar vacío. Debe extraer de quien sea que sostenga los datos. Un propietario certificado es autoritativo y se deja estrictamente en paz, así que una limpieza legítima nunca se resucita.
Ese único reencuadre —de vacuidad (indecidible) a certificación (un hecho demostrado y pegajoso)— es lo que hace segura el resto de la recuperación.
Paso 1 — el propietario reiniciado extrae, en lugar de asumir
Cuando el nodo se reúne, el mapa todavía lo asigna como propietario de su partición vieja. El disparador de sincronización inversa decide si cada segmento poseído debería ser extraído de una réplica viva en lugar de tratarse como localmente autoritativo:
public static bool ShouldReverseSyncOwnedSegment(
bool partitionCertified,
bool isMapPreferredPrimary,
bool liveNonSelfReplicaExists)
=> !partitionCertified && liveNonSelfReplicaExists;
Tres cosas sobre este diminuto predicado son portantes, y cada una es un bug que alcanzó una corrida de certificación antes de que la regla se escribiera.
Ignora isMapPreferredPrimary. Ese parámetro todavía se pasa —como contexto-de-decisión documentado— pero la decisión deliberadamente no lo lee. Una versión anterior solo disparaba la sincronización inversa cuando el nodo era también el primario preferido del mapa. Un nodo que es el activo en tiempo de ejecución pero no el primario del mapa —el estado exacto en el que aterriza un propietario re-ubicado o promovido-tras-recuperación— tenía sus segmentos poseídos omitidos por completo. Nunca solicitaba sus datos y quedaba varado vacío para siempre mientras una réplica viva sostenía cada registro (una corrida de certificación: activo sosteniendo 0 vs réplica sosteniendo 4600 en quince segmentos, nunca convergió a lo largo de ocho minutos). La propiedad de la extracción de datos no puede estar condicionada a ser el primario preferido; tiene que dispararse para cualquier activo sin certificar.
La señal es !partitionCertified, no la vacuidad. Este es el arreglo de la indecidibilidad de la sección anterior, en código. Un activo sin certificar nunca ha demostrado posesión, así que extrae; uno certificado es autoritativo y se omite.
liveNonSelfReplicaExists es una guarda, no una cortesía. Si la réplica configurada está caída, no hay nada de lo que sincronizar. Impulsar la recuperación contra una fuente muerta gira para siempre (RECOVERY STUCK source=self synced=0/128). Así que cuando no existe una réplica viva, el activo es la única copia superviviente —es la autoridad, y certifica de inmediato en lugar de esperar a una fuente que nunca responderá. La extracción en sí está bloqueada por revisión (revision-fenced) y acotada por marca de agua (watermark-bounded), lo que la hace un no-op inofensivo cuando la réplica resulta no sostener nada.
Paso 2 — no sirvas una clave que todavía no has alcanzado
Extraer no es instantáneo, y la ventana mientras corre es donde las lecturas confirmadas se convierten silenciosamente en NotFound. Una compuerta ingenua es un único booleano, IsSynced, que significa "una pasada de sincronización terminó". Ese booleano no es suficiente, y tres fallos de certificación separados lo probaron: un activo que reclamaba promovió 34 claves corto; un traspaso perdió una clave confirmada; un activo que se reunía sirvió NotFound para 57 claves durante el churn. En cada caso una pasada de sincronización había terminado —pero la fuente (el activo temporal que siguió sirviendo) había desde entonces aceptado más escrituras que el nodo en recuperación no había extraído. IsSynced era verdadero y el nodo seguía atrasado.
El arreglo es condicionar sobre una posición, no un flag. La revisión del log de operaciones es un número replicado y comparable entre nodos: el activo la asigna y la envía, así que una réplica al día converge a la misma cabeza. La compuerta de servicio compara cabezas:
public static SegmentServeDecision Decide(bool isSynced, long localHeadRevision, long sourceHeadRevision)
{
if (!isSynced)
return SegmentServeDecision.HoldUnsynced;
// A watermark of 0 carries no information — do not block on it; trust the completed sync pass.
if (sourceHeadRevision > 0 && localHeadRevision < sourceHeadRevision)
return SegmentServeDecision.HoldBehindSource;
return SegmentServeDecision.Serve;
}
HoldBehindSource es el nuevo estado que importa: una pasada de sincronización se completó, pero la cabeza de la fuente (capturada en esa sincronización) va por delante de la nuestra, así que la fuente aceptó escrituras que no hemos extraído. Servir ahora devolvería NotFound para exactamente esas claves. El nodo se mantiene hasta que su cabeza local alcanza la marca de agua. Nota la degradación deliberada: una marca de agua registrada de 0 significa "sin información de marca de agua", y la compuerta recae en el comportamiento solo-IsSynced en lugar de bloquear para siempre sobre datos faltantes. La regla nunca bloquea sobre la ausencia de un hecho —solo sobre un hecho que dice vas atrasado.
Paso 3 — extrae, pero no pisotees
Ahora la dirección peligrosa: aplicar la instantánea que la réplica envía de vuelta. El instinto es limpiar el almacén local y copiar la instantánea al por mayor. Ese instinto es correcto exactamente la mitad de las veces, y catastróficamente erróneo la otra mitad. La pregunta decisiva es de quién son las revisiones comparables con las de quién:
public static ResyncApplyMode Decide(
bool digestMatches,
bool isPassiveSubordinateReplica,
bool priorReconcileShortfall = false)
=> digestMatches
? ResyncApplyMode.Skip
: (isPassiveSubordinateReplica && !priorReconcileShortfall)
? ResyncApplyMode.Reconcile
: ResyncApplyMode.Wipe;
Tres resultados:
Skip— los digests de contenido ya coinciden. Nada que transferir, nada que aplicar. (Ver el protocolo de cut-over para por qué un digest de contenido, no un conteo de revisiones, es lo que vale la pena comparar.)Reconcile(no destructivo) — el nodo local es un subordinado pasivo puro del único activo vivo. Sus revisiones por clave todas vinieron de ese único activo, así que son directamente comparables. La instantánea se fusiona bloqueada-por-revisión: unPutRawconserva el registro localmente-más-nuevo y rellena las claves faltantes; las tombstones se aplican víaDeleteRaw. Crucialmente, esto preserva las escrituras que la réplica acumuló sobre el canal vivo durante un arranque lento en lugar de tirarlas limpiando primero.Wipe(limpieza destructiva + copia) — el nodo local es o fue él mismo un activo para esta partición. Dos nodos independientemente-activos tienen revisiones por clave incomparables: el mismo número de revisión en cada uno se refiere a escrituras distintas. Fusionarlas intercalaría silenciosamente dos historias. Así que el único movimiento seguro es descartar el estado local y adoptar la instantánea autoritativa al por mayor.
El valor por defecto es la copia destructiva segura; Reconcile se desbloquea solo al demostrar que el nodo es un subordinado puramente pasivo. Este es el "wipe-guard": previene el merge peligroso entre dos ex-activos, y simétricamente previene que una limpieza al por mayor descarte buenas escrituras del canal vivo en una réplica simple.
Hay un matiz más codificado en priorReconcileShortfall, y sale directamente de un aborto de certificación de doce horas. Una reconciliación bloqueada-por-revisión puede bloquear fuera registros que debería haber aplicado y completar mientras sigue atrasada respecto a la instantánea autoritativa —un segmento atascado en 12643/15435 a lo largo de 232 sesiones de sincronización, reconciliando los mismos registros fuera de la ventana cada vez, girando para siempre. El flag dice: si una reconciliación anterior se completó pero nos dejó cortos, deja de reconciliar y escala a una limpieza para que la instantánea congelada se adopte entera y realmente converjamos. Solo se establece tras un déficit medido —una réplica adelantada-o-igual nunca lo dispara, así que las escrituras del canal vivo nunca se descartan para perseguir un fantasma.
Paso 4 — el otro lado: un propietario sin certificar aún puede ser una buena fuente
La recuperación no es solo sobre el nodo que va atrasado. Considera el caso espejo: el nodo A reinicia, y el nodo B —una réplica que ha estado caída— ahora quiere sincronizar desde A. ¿Debería A servir la solicitud mientras A mismo todavía está sin certificar?
La guarda ingenua dice que no: "sin certificar significa que mi almacén está vacío, así que servir sobrescribiría los datos válidos de B con mi hueco". Pero esa premisa es falsa en el momento en que A realmente sostiene la copia autoritativa —lo que ocurre de dos formas: A era un primario preferido que verificó la puesta al día antes de la promoción pero cuya ruta de promoción nunca llamó a CertifyPartition (así que el flag pegajoso quedó atascado en falso mientras los datos están todos ahí), o A tomó la propiedad mediante una recuperación de fallo-total-de-partición y sostiene los datos completos como el activo sin ser nunca estampado IsSynced. En ambos, la copia de A es la fuente de verdad, y rechazar varó la réplica en cero (un volcado: 1.792 solicitudes rechazadas; otro: los 128 segmentos rechazados para toda una partición).
Así que la guarda del lado de la fuente se hace consciente-de-datos:
public static bool ShouldRefuse(
bool selfIsRuntimeOwnerMapPrimary,
bool partitionCertified,
bool segmentSynced,
bool selfHoldsSegmentData)
=> selfIsRuntimeOwnerMapPrimary && !partitionCertified && !segmentSynced && !selfHoldsSegmentData;
Niégate a servir solo cuando el nodo es el propietario, está sin certificar, y el segmento está a la vez sin sincronizar y no sostiene datos —el único caso genuino de "a mitad de recuperación y realmente vacío" donde servir un hueco podría dejar que el solicitante certifique contra él. Como el receptor aplica cada chunk bloqueado-por-revisión, servir datos reales siempre es seguro; la guarda estrecha el rechazo al único estado donde no hay nada seguro que servir.
Paso 5 — declarar la victoria, exactamente una vez
Finalmente el activo en recuperación ha extraído cada segmento poseído y ha puesto al día cada uno hasta la cabeza de su fuente. Es autoritativo de nuevo, y necesita decirlo —girar el flag certified pegajoso— para que el disparador de sincronización inversa del Paso 1 deje de re-entregar sus segmentos y el clúster registre la partición como convergida.
public static bool ShouldCertify(
bool selfIsRuntimeActive,
bool partitionCertified,
int ownedSegmentCount,
int syncedSegmentCount)
=> selfIsRuntimeActive
&& !partitionCertified
&& ownedSegmentCount > 0
&& syncedSegmentCount >= ownedSegmentCount;
La certificación es la completación simétrica del disparador: el disparador encola los segmentos de un activo sin certificar para extraer; esto gira el flag una vez que cada segmento poseído está sincronizado, y la partición se asienta. La ruta de certificación preexistente solo corría para el primario preferido del mapa, así que un activo re-ubicado no-primario extraía todos sus datos correctamente y luego nunca certificaba —re-encolaba la sincronización inversa para siempre, y el clúster nunca se registraba como convergido (una corrida: 30 checkpoints agotando el tiempo ~106 segundos después del inicio, mientras Missing=0 y los conteos nunca divergían —los datos estaban bien, solo faltaba el asentamiento).
Y nota el único atajo que esta política deliberadamente rechaza: no hay ruta de "sin réplica viva, así que certifica mi vacuidad como autoridad" para un activo no-primario. Un activo no-primario no es la autoridad del mapa; certificarlo vacío mientras una réplica temporalmente-caída todavía sostiene los datos dejaría que esa réplica después sincronizara desde este nodo vacío y perdiera todo. El atajo de autoridad-para-estar-vacío pertenece solo al primario preferido del mapa, en su propia ruta de reconciliación. En todos los demás lugares, la vacuidad no gana nada.
La forma del todo
Da un paso atrás y las cinco políticas son una idea aplicada en cinco lugares: la posesión se demuestra, nunca se asume.
- Un propietario reiniciado está sin certificar, así que extrae en lugar de confiar en su almacén vacío (Paso 1).
- No servirá un segmento hasta que la cabeza de su log de operaciones alcance la de la fuente —una pasada de sincronización terminada no es lo mismo que estar al día (Paso 2).
- Fusiona en lugar de pisotear cuando las revisiones son comparables, y pisotea en lugar de intercalar cuando no lo son (Paso 3).
- Como fuente, sirve siempre que genuinamente sostenga los datos, y rechaza solo cuando está verdaderamente vacío a mitad de recuperación (Paso 4).
- Certifica —gira el flag autoritativo pegajoso— solo ante la prueba de que cada segmento poseído está sincronizado, sin atajo de vacuidad-como-autoridad para un no-primario (Paso 5).
Cada una de estas reglas es un predicado puro y sin E/S —un puñado de booleanos y dos números de revisión de entrada, una decisión de salida— que es por lo que son unit-testables de forma aislada y por lo que cada una pudo fijarse al fallo de certificación exacto que la motivó. Son la vista interna del invariante que la certificación de caos de doce horas demuestra desde fuera: mata un propietario en pleno vuelo, deja que sus réplicas lleven la carga, tráelo de vuelta, y observa cómo Missing=0 se sostiene a lo largo de millones de operaciones confirmadas. La razón por la que el reinicio no pierde una escritura es que al propietario que regresa nunca se le permite ni una sola vez creer en su propia vacuidad. Tiene que ganarse sus datos de vuelta —de las réplicas que los mantuvieron seguros.
Si quieres las piezas en las que esto se apoya: el log de operaciones reproducible es lo que hace de "pon al día mi cabeza con la cabeza de la fuente" una operación real y sin agujeros, y leer tus propias escrituras es la promesa de cara al cliente que la compuerta de servicio del Paso 2 existe para mantener.