Saltar al contenido principal

Migrar una partición con cero pérdida de datos: el protocolo de cut-over

· 14 min de lectura
Clustron Team
Ingeniería de Sistemas Distribuidos

El protocolo de cut-over de particiones de Zaris

Un failover es lo que ocurre cuando un propietario muere —el clúster tiene segundos para elegir a un superviviente y el mapa de particiones nunca se mueve. La migración es la prima más tranquila y más peligrosa: el propietario está perfectamente sano, y quieres mover una partición fuera de él a propósito —para hacer crecer el clúster, para rebalancear, para drenar un nodo que estás a punto de retirar. Nada ha fallado, que es exactamente por qué el listón está más alto. A un failover se le permiten unas pocas escrituras de cola honestamente perdidas en el peor caso; a una migración planificada no. Si mover una partición del nodo A al nodo B puede perder una sola escritura confirmada, la migración no es una característica, es un pasivo.

Este artículo recorre el protocolo de cut-over que Zaris realmente ejecuta, en orden, y dedica la mayor parte de su tiempo al único paso que todos resuelven mal: el momento en que la fuente finalmente queda "libre" para descartar su copia. Ese paso es un arma cargada, y la ingeniería interesante es el seguro que lleva.

La forma del problema​

Una partición es un conjunto de segmentos, y un segmento es un saco de registros clave-valor, cada uno con una revisión monótona. La migración mueve los datos de un segmento de un nodo fuente a un nodo destino y luego gira la propiedad para que los clientes enruten al destino. Todo ello ocurre mientras el segmento está vivo: las lecturas y escrituras siguen llegando todo el tiempo.

Esa vitalidad es el problema. Si haces una instantánea de la fuente, envías los bytes y giras la propiedad, cada escritura que llegó durante el envío está en la fuente y no en el destino —y en el instante en que giras, esas escrituras se desvanecen de la vista enrutada. Las confirmaste; al cliente se le dijo "confirmado"; ahora se han ido. La migración ingenua pierde exactamente las escrituras que ocurrieron mientras estaba ocupada siendo cuidadosa con las que ocurrieron antes.

Así que el protocolo tiene que hacer dos cosas que están en tensión: seguir sirviendo escrituras en la fuente justo hasta el giro, y garantizar que el destino tenga cada una de esas escrituras antes de que el giro surta efecto. La forma en que Zaris reconcilia esa tensión es una secuencia de cinco pasos, cada uno de los cuales se niega a avanzar hasta que el anterior esté demostrablemente completo.

Paso 1 — una instantánea anclada a una revisión, con el piso de recorte fijado​

El destino arranca pidiéndole a la fuente los datos del segmento (SegmentMigrationRequest). La fuente no serializa simplemente su diccionario actual; toma una instantánea anclada a una revisión. Registra la revisión actual del segmento —llámala R— y captura los registros vivos tal como están en R.

Dos sutilezas viven en este único paso. Primero, la fuente arma su log de operaciones antes de capturar el ancla. Zaris puede omitir el log de operaciones para un segmento de primario único (RF1) como optimización de la ruta de escritura; armarlo primero significa que cada escritura desde este punto en adelante se registra, así que una escritura que antes se omitía del log aún tiene una revisión en o por debajo de R y la lleva la propia instantánea. El delta que computamos después —"ops después de R"— es entonces demostrablemente sin agujeros.

Segundo, la fuente fija el piso de recorte de su log de operaciones en R:

var (snapshotRevision, snapshotEntries) = _store.CreateSegmentSnapshot(segmentId);
// Pin BEFORE streaming so any op the source acks between the snapshot and the
// new owner's final-delta request is still replayable — the floor cannot advance past R.
_store.PinSegmentTrim(segmentId, snapshotRevision.Value);

Normalmente el log de operaciones recorta entradas viejas para recuperar memoria. Durante una migración eso sería un desastre: si el piso avanzara más allá de R mientras la transferencia masiva está en vuelo, las escrituras entre R y el giro serían no reproducibles, y el delta final sería silenciosamente parcial. Fijar el piso en R garantiza que esas escrituras sobrevivan hasta que el destino las tenga. La fuente entonces transmite las entradas de la instantánea —y sigue sirviendo lecturas y escrituras todo el tiempo. Sigue siendo el propietario enrutado. Nada se ha movido todavía.

Paso 2 — el destino espera, deliberadamente​

El destino aplica la instantánea masiva y luego hace algo que parece inacción pero es el corazón del diseño: mueve el segmento a un estado de espera-de-cut-over y se detiene. No acusa recibo a la fuente. No reclama propiedad. No le dice al líder que ha terminado. El comentario en el código es contundente sobre por qué:

NO Ack to the source (it is still the routed owner), NO ownership claim, and NO completion notify yet — the completion is withheld until the fenced final delta confirms this node holds everything the source acked.

Este es el contrato "old-owner-authoritative" (propietario-antiguo-autoritativo). Hasta el giro, exactamente un nodo responde por el segmento, y es la fuente. Un destino que se adelantara —empezara a servir desde una instantánea que ya está obsoleta por unos cientos de escrituras— sería un split brain. Así que el destino se mantiene, y pide lo único que le permitirá probar que está al día: el delta final.

Paso 3 — el delta final con escrituras bloqueadas y la prueba por digest​

El destino envía un SegmentMigrationFinalDeltaRequest anclado en R. Aquí es donde la fuente hace la parte cuidadosa.

// Fence FIRST, then capture a revision after the fence — so no acked write can exist beyond it.
var fenceEpoch = _topologyManager.EngageMigrationWriteFence(seg, request.CreationMapVersion);
var fenceRevision = _localStore.GetSegmentCurrentRevision(seg);

Engancha un bloqueo de escritura (write fence) en el segmento y luego lee la revisión actual. El orden importa: bloquear primero, medir después, para que no haya ventana en la que una escritura se cuele más allá de la medición. Luego lee cada entrada del log de operaciones después de R —el delta de todo lo que llegó durante la transferencia masiva— y computa un digest de contenido del estado vivo del segmento. Ese digest es la clave de todo el protocolo, así que la fuente lo registra como la prueba de cut-over:

response.Operations = finalOps.ToList();
response.SourceDigest = _localStore.ComputeSegmentLiveDigest(seg);
// Record this served digest as the cut-over PROOF: the post-cut-over orphan drop will refuse to
// destroy this segment's data unless its live digest still equals this exact value.
_localStore.RecordCutoverProofDigest(seg, response.SourceDigest);

Hay un modo de fallo honesto conectado justo aquí. Si el piso de recorte sí avanzara de algún modo más allá de R —una fijación perdida o retrocedida— entonces "ops después de R" estaría incompleto, y enviar ese delta descartaría silenciosamente escrituras confirmadas. La fuente se niega:

if (!_localStore.TryGetSegmentOperationsAfter(seg, request.SnapshotRevision, out var finalOps))
{
response.Aborted = true;
response.AbortReason = "FinalDeltaBelowTrimFloor";
// release the fence, re-snapshot, retry — a loud abort, never a silent partial cut-over.
}

Un aborto por-debajo-del-piso no es una pérdida; derriba y re-instantánea desde un ancla fresca. La regla que el código sostiene es aborto ruidoso antes que parcial silencioso —la migración prefiere reiniciar a girar sobre un delta incompleto.

Paso 4 — la compuerta: el destino prueba que domina​

El destino aplica el delta, luego recomputa su propio digest de contenido del segmento y lo compara con el de la fuente:

var localDigest = _localStore.ComputeSegmentLiveDigest(seg);
var matchesSource = string.Equals(localDigest, response.SourceDigest, StringComparison.Ordinal);

Si los dos digests son iguales, el destino tiene byte a byte lo que la fuente tenía en el bloqueo —el estado del destino es un superconjunto de cada escritura que la fuente confirmó. Si difieren, casi siempre es porque la instantánea transmitida todavía se está aplicando cuando se computa el primer delta (la señal de completación puede adelantarse a los lotes de datos encolados), así que el destino se mantiene pendiente y deja que un reintento espaciado re-solicite el delta en lugar de girar en vano. Un desajuste nunca gira. El giro está condicionado a una prueba positiva de igualdad, no a la ausencia de un error.

Hay una cosa más que el destino hace antes de declarar la victoria, y es una garantía que la mayoría de las implementaciones de migración omiten. La fuente no es la única copia que podría ir por delante —una réplica del segmento podría tener una escritura que la fuente bloqueada se perdió. Así que antes de completar, el destino pliega la cola de solo lectura de cada réplica viva:

The flip may fire only when the new owner's digest dominates the source and every live replica of the segment.

El plegado preserva las revisiones: los registros más viejos de una réplica rezagada son ignorados por la compuerta de revisión, y una réplica adelantada aporta exactamente la escritura que la fuente no tenía. Solo cuando el destino domina a la fuente y a cada réplica viva avanza. Este es el invariante "durabilidad antes que propiedad" —la propiedad es una recompensa por la posesión demostrable, no una precondición para ella.

Paso 5 — el giro, y el descarte que casi pierde tus datos​

Ahora el destino le dice al líder que ha terminado (SegmentMigrationCompletionNotify). El líder valida la completación contra el override de migración vivo y arma una regeneración atómica de cut-over del mapa de particiones —un único cambio de mapa versionado que gira el propietario del segmento al destino y elimina el override de migración. Después de que ese mapa se aplica, el destino es el propietario enrutado. Los clientes enrutan a él. El movimiento está, en lo que respecta al clúster, hecho.

Y la fuente, ahora sosteniendo un segmento para el que ya no posee un rol, ejecuta un barrido de limpieza para descartar la copia huérfana y recuperar la memoria. Esta es el arma cargada.

Aquí está la trampa, y es lo bastante sutil como para haber sobrevivido a una corrida de certificación antes de ser atrapada (Missing=8629 claves confirmadas desaparecidas). Un bloqueo de escritura reduce la confirmación de escrituras pero, en un segmento de copia única bajo carga, no lo aquieta perfectamente a cero de forma instantánea. Así que esta secuencia es posible:

  1. La fuente sirve el digest del delta final como la prueba. El destino lo iguala y gira.
  2. En la rendija entre servir la prueba y el aterrizaje del giro, se confirma una escritura más en la fuente.
  3. El destino nunca recibió esa escritura —la prueba que igualó era de antes de ella.
  4. La fuente, ahora huérfana, descarta su copia para recuperar espacio.
  5. Esa última escritura existía solo en la fuente. Se ha ido. A un cliente se le dijo que se confirmó.

El arreglo es hacer el descarte condicional a que la prueba siga siendo verdadera. La fuente no destruye ciegamente un segmento huérfano. Compara el digest vivo actual del segmento con el digest que registró como la prueba de cut-over, y solo destruye los datos si siguen coincidiendo:

public static bool ShouldRetainAfterCutover(bool hasCutoverProof, string? currentLiveDigest, string? proofDigest)
{
if (!hasCutoverProof || string.IsNullOrEmpty(proofDigest))
return false; // not a proof-carrying cut-over segment -> this policy does not intervene
return !string.Equals(currentLiveDigest, proofDigest, System.StringComparison.Ordinal);
}

Si una escritura llegó después de la prueba, el digest actual difiere del digest de la prueba, ShouldRetainAfterCutover devuelve true, y la fuente conserva su copia:

if (CutoverDropSafetyPolicy.ShouldRetainAfterCutover(hasProof, currentDigest, proofDigest))
{
Log.CutoverDropRetainedUnprovenTail(_logger, store.Count, segmentId);
continue; // keep the store — a post-proof tail exists that the new owner has not received
}

La asimetría es todo el punto. La retención siempre es segura: la fuente ya no es el propietario enrutado, así que una copia conservada es un huérfano redundante y no servido —nunca puede causar un split brain, solo cuesta un poco de memoria hasta que un re-servicio re-entrega la cola al nuevo propietario y un barrido posterior, al encontrar que los digests coinciden de nuevo, la descarta limpiamente. La destrucción sobre una prueba obsoleta nunca es segura: es la única operación que puede borrar permanentemente una escritura confirmada. Así que la política es deliberadamente estrecha —solo anula un descarte que de otro modo ocurriría, y solo para un segmento que lleva una prueba registrada cuyo digest ya no coincide. Los segmentos sin prueba (una expulsión, un giro inmediato, un segmento nunca migrado) conservan su comportamiento existente intacto. La compuerta añade una retención; nunca añade un descarte.

¿Por qué el digest, y no un número de revisión?​

Podrías preguntar por qué la prueba es un digest de contenido y no simplemente "la revisión más alta que el destino confirmó". La respuesta es que un número de revisión te dice qué tan lejos llegó el destino, pero un digest te dice si lo que obtuvo es lo que tú tenías. Los dos pueden divergir en las costuras —un plegado de réplica, un reintento re-anclado, una instantánea aún drenándose— y la única pregunta que mantiene los datos seguros es la pregunta de contenido: ¿tiene el nuevo propietario exactamente esto? Un digest responde eso directamente. Una marca de agua solo responde un proxy de ello, y los proxies son donde las escrituras confirmadas van a morir.

Qué compra esto, honestamente​

Enunciado sin rodeos, la garantía es: una migración planificada de partición no pierde una escritura confirmada. Cada escritura que la fuente confirmó antes del bloqueo está en la instantánea o el delta; cada escritura que confirmó después de la prueba mantiene viva la copia de la fuente hasta que el nuevo propietario también la tenga. El protocolo abortará y reiniciará ruidosamente en lugar de girar sobre un delta incompleto, y pliega las réplicas vivas para que el nuevo propietario domine cada copia, no solo la fuente.

Lo que no es, es gratis. El delta final bloqueado es un viaje de ida y vuelta síncrono que brevemente bloquea las escrituras en el segmento que se está traspasando —una pausa corta y acotada en un segmento, no la partición y no el clúster. Una migración que no puede converger (un piso de recorte que sigue avanzando, una cola de réplica que no puede expresarse desde su log) re-instantánea y reintenta en lugar de forzar el progreso, lo que bajo churn patológico significa que una migración tarda más en lugar de tomar un atajo a través de tus datos. Esos son los compromisos correctos para una operación planificada: un movimiento que elegiste hacer nunca debería ser la razón por la que una escritura confirmada desaparece.

Esta es la misma columna vertebral que la certificación de caos de doce horas ejercita desde el exterior —detén un nodo a mitad de migración, agita el mapa, y observa cómo las escrituras confirmadas sobreviven. Este artículo es la vista interna de una de las costuras que esa corrida demuestra cerradas: el momento en que una partición cambia de manos, y el seguro del gatillo que evita que el traspaso se coma las mismísimas escrituras que fue construido para proteger. Se apoya en otras dos piezas que vale la pena leer por su cuenta —el log de operaciones reproducible que hace de "ops después de la revisión R" una consulta real y sin agujeros, y el escalado elástico que es la razón más común por la que una partición necesita moverse siquiera.