Aller au contenu principal

Les tentatives que vous n'écrivez pas : survivre au changement de propriété dans le client Zaris

· 11 minutes de lecture
Clustron Team
Ingénierie des systèmes distribués

Nouvelle tentative et redirection transparentes lors d'un changement de propriété

Voici un fait concernant tout store clé-valeur partitionné que les schémas léchés ont tendance à passer sous silence : le nœud qui possède votre clé va se déplacer. Un nœud tombe en panne et ses partitions basculent vers leurs réplicas. Vous montez en charge et les partitions migrent vers le nouveau membre. Un primaire préféré revient et la propriété lui est rendue. Chacun de ces événements signifie que le nœud auquel votre client parlait tranquillement il y a une milliseconde est, à l'instant présent, le mauvais nœud pour un certain ensemble de clés.

L'expérience client naïve de ce moment est désagréable : une connexion réinitialisée, une « erreur serveur » générique ou — pire — une écriture qui atterrit sur un nœud qui n'est plus le propriétaire actif et qui, discrètement, ne mène nulle part. Le client Zaris .NET est conçu pour que rien de tout cela n'atteigne votre code. Un changement de propriété devient un statut typé, le client redirige vers le nouveau propriétaire, et la nouvelle tentative est bornée à la fois par un budget de tentatives et par une échéance en temps réel. Dans le cas courant, votre PutAsync prend simplement quelques millisecondes de plus et renvoie Ok. Cet article traite de la mécanique qui rend cela vrai et — tout aussi important — des cas qu'elle choisit délibérément de ne pas masquer.

Le serveur dit la vérité avec un statut, pas une exception​

Lorsqu'une requête atteint un nœud qui ne peut pas la servir, le nœud ne raccroche pas et ne lève pas d'erreur vague. Il répond avec un KvStatus précis qui indique exactement pourquoi il est au mauvais endroit :

  • Moved — ce nœud ne possède pas du tout le segment. Votre table de routage est périmée ; la clé vit ailleurs.
  • OwnershipChanging — ce nœud n'est pas le propriétaire actif en ce moment, mais la propriété de ce segment est activement en transition (un retour vers un primaire préféré, par exemple). Ce n'est pas un bug de routage établi ; c'est un transfert qui se produit sous vos pieds.
  • ReplicaWriteRejected — ce nœud possède le segment mais en est le réplica, pas le primaire actif. Votre propriétaire actif mis en cache est périmé après un basculement, et une écriture ici ne doit pas être acceptée.
  • Migrating — le segment est en cours de migration vers un nouveau propriétaire ; les écritures sont brièvement retenues.
  • Unavailable — la partition n'a aucun propriétaire joignable à cet instant.

Ceux-ci ne sont pas inventés pour le bénéfice du client — ils sont produits sur le chemin de données. La décision propriétaire-contre-non-propriétaire, par exemple, est une unique branche sur le serveur : une requête non-propriétaire pendant un transfert actif obtient OwnershipChanging, tandis qu'un accès ordinaire à un routage périmé obtient 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

Garder ces deux cas distincts a son importance : un transfert transitoire ne devrait pas ressembler à un bug de routage dans vos journaux, et le client peut réagir de façon appropriée à chacun. Tout l'intérêt de ce vocabulaire de statuts est que « mauvais nœud » est une réponse de première classe, lisible par machine, et non une erreur que vous devez extraire par filtrage d'un message d'exception.

Le cerveau des nouvelles tentatives : ce qui est transitoire et ce qui est une vraie réponse​

Toute cette intelligence de routage réside à un seul endroit sur le client — ClientRetryHelper — que la documentation dans le code appelle, à juste titre, « le cerveau de nouvelle tentative/backoff du client ». Sa décision la plus importante n'est pas comment réessayer mais s'il faut réessayer du tout. Deux prédicats tracent cette ligne :

// 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;

Observez ce qui est absent de ShouldRetry. NotFound n'est pas réessayable — la clé n'est véritablement pas là, et redemander ne la fera pas apparaître. Conflict n'est pas réessayable non plus, et c'est là la distinction cruciale. Comme nous l'avons vu dans l'article sur le CAS, un Conflict issu d'une écriture IfMatchVersion est une vraie réponse : quelqu'un d'autre a écrit en premier, et votre valeur était périmée. Le client doit vous le renvoyer directement pour que votre boucle de comparaison-et-échange puisse relire et recalculer. Si le cerveau des nouvelles tentatives réessayait « serviablement » un Conflict, il mettrait silencieusement en échec la concurrence optimiste.

La règle que le client applique est donc nette : réessayer les échecs transitoires de routage et de disponibilité ; ne jamais réessayer un résultat significatif. Un basculement est le problème du store à dissimuler. Un conflit de version ou une clé manquante est l'affaire de votre application à trancher.

Réessayer ne suffit pas — il faut rediriger​

Réessayer un Moved contre le même nœud périmé ne produirait qu'un autre Moved, à l'infini. C'est pourquoi les trois statuts « mauvais nœud » déclenchent aussi un rafraîchissement de la carte du cluster via un rappel onMoved avant la tentative suivante. La boucle de nouvelle tentative, réduite à sa colonne vertébrale, se lit :

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

Le même chemin d'exception existe pour les échecs au niveau du transport que le changement de propriété génère également — PartitionUnavailableException, NodeUnavailableException, TransportException, SocketException, TimeoutException. Chacun d'eux déclenche aussi le rafraîchissement de la carte avant de réessayer, car une erreur de socket vers un nœud qui vient de mourir est elle-même un signal que le routage a changé. Une exception véritablement non réessayable, en revanche, est relancée immédiatement — la boucle n'avale jamais ce qu'elle ne comprend pas.

L'effet est que la seconde tentative ne retourne pas au nœud qui a dit « pas moi » — elle va vers celui que la carte rafraîchie désigne désormais comme propriétaire actif. La nouvelle tentative plus la redirection sont ce qui fait effectivement réussir l'opération ; la nouvelle tentative seule ne ferait que tourner en boucle.

Deux bornes, pas une — la tempête de rafraîchissements que nous avons dû corriger​

Une boucle de nouvelle tentative a besoin d'une condition d'arrêt, et le bug subtil consiste à n'en choisir qu'une. Le client Zaris borne chaque opération à la fois par un budget de tentatives et par une échéance en temps réel, et la condition de boucle échoue dès que l'une des deux se déclenche :

for (int attempt = 1;
attempt <= maxAttempts && (!deadline.HasValue || DateTime.UtcNow < deadline.Value);
attempt++)

Ce && est là à cause d'un incident réel. Une version antérieure utilisait l'échéance à la place de maxAttempts chaque fois qu'un timeout était défini. Ainsi, un appelant demandant maxAttempts: 3 avec le timeout d'opération par défaut de 60 secondes ne réessayait pas trois fois — il réessayait pendant les 60 secondes entières, déclenchant un rafraîchissement de la carte du cluster à chaque tentative. Sous un changement de nœuds, où de nombreuses opérations échouent et se rafraîchissent en même temps, cela transformait une seule opération en échec en des dizaines de rafraîchissements, tous sérialisés derrière le verrou de rafraîchissement. Le chemin d'envoi était affamé et le débit s'effondrait vers zéro. Le correctif a consisté à honorer les deux bornes : quelques tentatives et une échéance ferme, selon ce qui arrive en premier.

Les valeurs par défaut sont délibérément modestes — maxAttempts: 3, backoffMillis: 100 — avec l'échéance d'opération résolue à partir de ZARIS_REQUEST_TIMEOUT (60 secondes par défaut ; réglez-la sur infinite pour la désactiver). Le budget de tentatives maintient un incident transitoire peu coûteux ; l'échéance empêche une panne réelle de suspendre votre appel pour toujours.

Un backoff qui respecte l'échéance — et qui sait que les migrations sont lentes​

Entre les tentatives, le client applique un backoff linéaire (backoffMillis * attempt), mais deux raffinements le rendent de qualité production :

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 statut Migrating reçoit un délai 8× plus long, car une migration de segment est un événement de l'ordre de la seconde et réessayer toutes les 100 ms ne fait qu'ajouter du bruit à un nœud déjà occupé à déplacer des données. Chaque délai est plafonné à deux secondes pour que le backoff ne puisse pas enfler et — le détail qui maintient les deux bornes honnêtes ensemble — le délai est borné au temps restant avant l'échéance, de sorte que le client ne dort jamais au-delà de son propre point d'arrêt.

Un seul rafraîchissement, pas une ruée​

Voici la partie qui ne compte qu'à grande échelle. Lors d'un changement de propriété, beaucoup d'opérations échouent au même instant et chacune d'elles veut rafraîchir la carte du cluster. Si chacune déclenchait son propre rafraîchissement, vous obtiendriez une ruée martelant les nœuds survivants exactement au moment où ils sont les plus fragiles. Le client les fusionne en un unique rafraîchissement partagé :

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
}

Une poignée de garde-fous supplémentaires rendent cela robuste : le rafraîchissement lui-même applique un anti-rebond (il ne fait rien si la carte a été rafraîchie dans les 500 dernières ms), un appelant attend au plus un budget borné pour le rafraîchissement partagé (15 secondes par défaut, ZARIS_CLIENT_MAP_REFRESH_WAIT_MS) avant d'abandonner l'attente sans annuler le rafraîchissement dont les autres ont encore besoin, et l'emplacement partagé est libéré avec un Interlocked.CompareExchange plutôt qu'avec un verrou — car une libération antérieure basée sur un verrou s'exécutant dans une continuation synchrone pouvait embouteiller tout le chemin de rafraîchissement et bloquer les lectures saines. La leçon qui revient sans cesse : le code de résilience doit être au moins aussi prudent quant à sa propre contention que le système qu'il protège.

Là où cela s'arrête — et pourquoi c'est correct​

Les nouvelles tentatives ne sont pas magiques, et la limite honnête est l'idempotence. La boucle de nouvelle tentative vous donne une sémantique d'envoi au moins une fois : si l'accusé de réception d'une écriture est perdu sur le fil après que le store l'a validée, une nouvelle tentative la renverra. Pour un simple PutAsync « dernier écrivain gagne » d'une valeur fixe, rejouer est inoffensif. Pour tout ce qui n'est pas naturellement idempotent, le correctif n'est pas de désactiver les nouvelles tentatives — c'est de rendre l'opération sûre à répéter, en utilisant les primitives mêmes que Zaris vous offre déjà :

  • Protégez une mutation avec IfMatchVersion pour qu'une écriture rejouée contre une version désormais déplacée revienne en Conflict — ce que, comme établi plus haut, le client vous renvoie directement plutôt que de réessayer.
  • Utilisez IfAbsent pour rendre une création-unique véritablement unique ; une relecture voit Conflict et vous le traitez comme « déjà fait ».

Autrement dit, la nouvelle tentative au niveau du transport et la comparaison-et-échange au niveau de l'application sont conçues pour se composer : le client cache le changement, et les préconditions rendent les tentatives cachées sûres. Si vous devez désactiver entièrement les nouvelles tentatives — dans un test qui veut observer les statuts bruts, par exemple — ZARIS_DISABLE_RETRIES=true réduit le budget à une seule tentative.

Le point plus profond est une division du travail. Un store distribué qui déplace la propriété va remettre à votre client des échecs transitoires ; ce n'est pas un défaut, c'est le prix de pouvoir basculer et rééquilibrer tout court. Ce qu'un bon client vous doit, c'est de faire de ces échecs le problème du store — typé, redirigé, borné — tout en étant scrupuleusement attentif à ne jamais cacher les échecs qui sont en réalité votre réponse. Zaris trace cette ligne à exactement un endroit : réessayer le changement, révéler la vérité.