Reintentos que no escribes: sobrevivir a la rotación de propiedad en el cliente de Zaris
Aquí hay un hecho sobre cualquier almacén clave-valor particionado que los diagramas pulidos tienden a omitir: el nodo que posee tu clave se moverá. Un nodo falla y sus particiones conmutan por error a sus réplicas. Escalas horizontalmente y las particiones migran al nuevo miembro. Un primario preferido vuelve y la propiedad conmuta de vuelta a él. Cada uno de esos eventos significa que el nodo con el que tu cliente hablaba felizmente hace un milisegundo es, en este momento, el nodo equivocado para algún conjunto de claves.
La experiencia ingenua del cliente en ese momento es fea: un reinicio de conexión, un genérico "error de servidor" o —peor— una escritura que aterriza en un nodo que ya no es el dueño activo y silenciosamente no va a ninguna parte. El cliente .NET de Zaris está construido para que nada de eso llegue a tu código. Un cambio de propiedad se convierte en un estado tipado, el cliente reencamina al nuevo dueño, y el reintento está acotado tanto por un presupuesto de intentos como por una fecha límite de reloj de pared. En el caso común, tu PutAsync simplemente tarda unos milisegundos más y devuelve Ok. Esta publicación trata de la maquinaria que hace que eso sea cierto y —no menos importante— de los casos que deliberadamente no disimula.
El servidor dice la verdad con un estado, no con una excepción
Cuando una solicitud llega a un nodo que no puede atenderla, el nodo no cuelga ni lanza un error vago. Responde con un KvStatus específico que dice exactamente por qué es el lugar equivocado:
Moved— este nodo no posee el segmento en absoluto. Tu tabla de enrutamiento está obsoleta; la clave vive en otro lugar.OwnershipChanging— este nodo no es el dueño activo ahora mismo, pero la propiedad de ese segmento está activamente en transición (una conmutación de vuelta a un primario preferido, por ejemplo). No es un error de enrutamiento asentado; es un traspaso que ocurre bajo tus pies.ReplicaWriteRejected— este nodo posee el segmento pero es la réplica, no el primario activo. Tu dueño activo en caché está obsoleto tras una conmutación por error, y una escritura aquí no debe aceptarse.Migrating— el segmento está a mitad de migración hacia un nuevo dueño; las escrituras se retienen brevemente.Unavailable— la partición no tiene un dueño alcanzable en este instante.
Estos no se inventan para beneficio del cliente: se producen en el camino de datos. La decisión dueño-vs-no-dueño, por ejemplo, es una única bifurcación en el servidor: una solicitud de no-dueño durante un traspaso activo obtiene OwnershipChanging, mientras que un acierto ordinario de enrutamiento obsoleto obtiene Moved.
// server-side, Clustron.Zaris.Core.ExternalStoreService
private KvStatus OwnershipFailureStatus(ushort segmentId)
=> _topology is not null && _topology.IsSegmentOwnershipTransitioning(segmentId)
? KvStatus.OwnershipChanging // handoff in progress: refresh and retry
: KvStatus.Moved; // settled, stale routing entry
Mantener esos dos distintos importa: un traspaso transitorio no debe parecer un error de enrutamiento en tus registros, y el cliente puede reaccionar ante cada uno de forma apropiada. El propósito de todo el vocabulario de estados es que "nodo equivocado" sea una respuesta de primera clase, legible por máquina, no un error que tengas que extraer por coincidencia de patrones del mensaje de una excepción.
El cerebro de reintentos: qué es transitorio y qué es una respuesta real
Toda esa inteligencia de enrutamiento vive en un solo lugar del cliente —ClientRetryHelper— que la documentación en el código llama, con razón, "el cerebro de reintento/backoff del cliente". Su decisión más importante no es cómo reintentar sino si hacerlo en absoluto. Dos predicados trazan esa línea:
// Retry only these — they mean "transient; the same request can succeed shortly."
private static bool ShouldRetry(KvStatus status) => status switch
{
KvStatus.Moved => true,
KvStatus.ReplicaWriteRejected => true,
KvStatus.Unavailable => true,
KvStatus.Migrating => true,
KvStatus.OwnershipChanging => true,
_ => false
};
// Of those, these three also mean "you reached the wrong node — refresh routing first."
private static bool RequiresMapRefresh(KvStatus status)
=> status == KvStatus.Moved
|| status == KvStatus.ReplicaWriteRejected
|| status == KvStatus.OwnershipChanging;
Fíjate en lo que está ausente de ShouldRetry. NotFound no es reintentable: la clave genuinamente no está ahí, y volver a preguntar no la hará aparecer. Conflict tampoco es reintentable, y esa es la distinción crucial. Como cubrimos en la publicación sobre CAS, un Conflict de una escritura IfMatchVersion es una respuesta real: alguien más escribió primero, y tu valor estaba obsoleto. El cliente debe devolvértelo directamente para que tu bucle de compare-and-swap pueda volver a leer y recalcular. Si el cerebro de reintentos reintentara "servicialmente" un Conflict, derrotaría silenciosamente la concurrencia optimista.
Así que la regla que el cliente impone es afilada: reintenta los fallos transitorios de enrutamiento y disponibilidad; nunca reintentes un resultado significativo. Una conmutación por error es problema del almacén que debe ocultar. Un conflicto de versión o una clave ausente es asunto de tu aplicación que debe decidir.
El reintento no basta: tienes que reencaminar
Reintentar un Moved contra el mismo nodo obsoleto solo produciría otro Moved para siempre. Por eso los tres estados de "nodo equivocado" también disparan una actualización del mapa del clúster a través de una retrollamada onMoved antes del siguiente intento. El bucle de reintento, reducido a su espina dorsal, se lee así:
result = await operation().ConfigureAwait(false);
if (result.IsSuccess || !ShouldRetry(result.Status))
return result; // success, or a real answer — hand it back
if (RequiresMapRefresh(result.Status) && onMoved != null)
await onMoved(cancellationToken); // re-fetch ownership, THEN retry elsewhere
El mismo camino de excepciones existe para los fallos a nivel de transporte que la rotación de propiedad también desprende: PartitionUnavailableException, NodeUnavailableException, TransportException, SocketException, TimeoutException. Cada uno de ellos también dispara la actualización del mapa antes de reintentar, porque un error de socket hacia un nodo que acaba de morir es en sí mismo una señal de que el enrutamiento ha cambiado. Una excepción genuinamente no reintentable, en cambio, se relanza de inmediato: el bucle nunca se traga lo que no entiende.
El efecto es que el segundo intento no vuelve al nodo que dijo "yo no": va a quienquiera que el mapa actualizado nombre ahora como el dueño activo. Reintento más reencaminamiento es lo que realmente hace que la operación tenga éxito; el reintento por sí solo simplemente giraría en vacío.
Dos límites, no uno — la tormenta de actualizaciones que tuvimos que arreglar
Un bucle de reintento necesita una condición de parada, y el error sutil es elegir solo una. El cliente de Zaris acota cada operación mediante ambos: un presupuesto de intentos y una fecha límite de reloj de pared, y la condición del bucle falla cuando se dispara el que llegue primero:
for (int attempt = 1;
attempt <= maxAttempts && (!deadline.HasValue || DateTime.UtcNow < deadline.Value);
attempt++)
Ese && está ahí por culpa de un incidente real. Una versión anterior usaba la fecha límite en lugar de maxAttempts siempre que se establecía un tiempo de espera. Así que un llamador que pedía maxAttempts: 3 con el tiempo de espera de operación predeterminado de 60 segundos no reintentaba tres veces: reintentaba durante los 60 segundos enteros, disparando una actualización del mapa del clúster en cada intento. Bajo rotación de nodos, donde muchas operaciones fallan y se actualizan a la vez, eso convertía una sola operación fallida en docenas de actualizaciones, todas serializadas detrás del bloqueo de actualización. El camino de envío se quedó sin recursos y el rendimiento se desplomó hacia cero. La solución fue respetar ambos límites: unos pocos intentos y una fecha límite estricta, lo que llegue primero.
Los valores predeterminados son deliberadamente modestos —maxAttempts: 3, backoffMillis: 100— con la fecha límite de operación resuelta a partir de ZARIS_REQUEST_TIMEOUT (60 segundos por defecto; establécelo en infinite para desactivarla). El presupuesto de intentos mantiene barato un parpadeo transitorio; la fecha límite evita que una interrupción genuina cuelgue tu llamada para siempre.
Backoff que respeta la fecha límite, y que sabe que las migraciones son lentas
Entre intentos el cliente aplica un backoff lineal (backoffMillis * attempt), pero dos refinamientos lo hacen de grado de producción:
private static TimeSpan GetDelay(int attempt, int backoffMillis, KvStatus? status, DateTime? deadline)
{
var delayMs = status == KvStatus.Migrating
? backoffMillis * attempt * 8 // a migration takes real time; don't hammer it
: backoffMillis * attempt;
var delay = TimeSpan.FromMilliseconds(Math.Min(delayMs, 2_000)); // hard cap
if (deadline.HasValue)
{
var remaining = deadline.Value - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero) return TimeSpan.Zero;
if (delay > remaining) delay = remaining; // never overshoot
}
return delay;
}
Un estado Migrating obtiene un retraso 8× más largo, porque una migración de segmento es un evento a escala de segundos y reintentar cada 100 ms solo añade ruido a un nodo que ya está ocupado moviendo datos. Cada retraso está limitado a dos segundos para que el backoff no se dispare, y —el detalle que mantiene honestos a los dos límites juntos— el retraso está sujeto al tiempo restante antes de la fecha límite, de modo que el cliente nunca duerme más allá de su propio corte.
Una actualización, no una estampida
Aquí está la parte que solo importa a escala. Bajo rotación, muchas operaciones fallan en el mismo instante y cada una de ellas quiere actualizar el mapa del clúster. Si cada una disparara su propia actualización, tendrías una estampida golpeando a los nodos supervivientes justo cuando son más frágiles. El cliente las fusiona en una única actualización compartida:
internal async Task TryRefreshClusterMapSafe(CancellationToken ct = default)
{
Task? existing = _activeRefreshTask;
if (existing != null && !existing.IsCompleted)
{
// A refresh is already in flight — ride it instead of starting another.
await WaitForSharedTaskAsync(existing, ct);
return;
}
// ... under a lock, double-check and start exactly one, storing it in _activeRefreshTask
}
Un puñado de guardas adicionales hacen esto robusto: la actualización misma hace debounce (no hace nada si el mapa se actualizó en los últimos 500 ms), un llamador espera como mucho un presupuesto acotado por la actualización compartida (15 segundos por defecto, ZARIS_CLIENT_MAP_REFRESH_WAIT_MS) antes de renunciar a esperar sin cancelar la actualización que otros todavía necesitan, y el espacio compartido se limpia con un Interlocked.CompareExchange en lugar de un bloqueo, porque una limpieza anterior basada en bloqueo ejecutándose dentro de una continuación síncrona podía poner en convoy todo el camino de actualización y atascar las lecturas sanas. La lección que se repite una y otra vez: el código de resiliencia tiene que ser al menos tan cuidadoso con su propia contención como el sistema que protege.
Dónde se detiene, y por qué eso es correcto
Los reintentos no son magia, y el borde honesto es la idempotencia. El bucle de reintento te da semántica de envío al-menos-una-vez: si el acuse de recibo de una escritura se pierde en el cable después de que el almacén la confirmó, un reintento la enviará de nuevo. Para un PutAsync simple de último-escritor-gana con un valor fijo, repetir es inofensivo. Para cualquier cosa que no sea naturalmente idempotente, la solución no es desactivar los reintentos, sino hacer que la operación sea segura de repetir, usando las mismas primitivas que Zaris ya te da:
- Protege una mutación con
IfMatchVersionpara que una escritura repetida contra una versión ya movida vuelva comoConflict, que, como se estableció arriba, el cliente te devuelve directamente en lugar de reintentarla. - Usa
IfAbsentpara hacer que una creación única sea genuinamente única; una repetición veConflicty la tratas como "ya hecho".
En otras palabras, el reintento a nivel de transporte y el compare-and-swap a nivel de aplicación están diseñados para componerse: el cliente oculta la rotación, y las precondiciones hacen seguros los reintentos ocultos. Si necesitas desactivar los reintentos por completo —en una prueba que quiera observar estados en bruto, por ejemplo— ZARIS_DISABLE_RETRIES=true colapsa el presupuesto a un único intento.
El punto más profundo es una división del trabajo. Un almacén distribuido que mueve la propiedad le entregará a tu cliente fallos transitorios; eso no es un defecto, es el costo de poder conmutar por error y reequilibrar en absoluto. Lo que un buen cliente te debe es hacer de esos fallos un problema del almacén —tipados, reencaminados, acotados— mientras es escrupulosamente cuidadoso de nunca ocultar los fallos que son en realidad tu respuesta. Zaris traza esa línea en exactamente un lugar: reintenta la rotación, haz aflorar la verdad.