Aller au contenu principal

Un propriétaire qui redémarre n'a aucun droit à sa propre vacuité

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

Reconstruire un propriétaire redémarré depuis ses réplicas

Un basculement couvre le moment où un propriétaire meurt : le cluster choisit un survivant et continue de servir. Une migration planifiée couvre le déplacement à dessein de la partition d'un propriétaire sain. Cet article porte sur le troisième acte, celui qui se joue après que les sirènes se taisent — le processus propriétaire d'origine redémarre, rejoint le cluster, et veut récupérer sa partition. Son store rien-sur-disque, tout-en-mémoire est revenu vide. Les données dont il est responsable sont toujours vivantes, mais elles sont vivantes sur les réplicas qui ont continué de servir pendant qu'il était à terre.

Tout le problème de correction tient en une phrase : le propriétaire redémarré ne peut pas être autorisé à faire confiance à sa propre vacuité. Un store vide ressemble exactement à un store que quelqu'un a délibérément vidé. Si le nœud suppose « je suis le propriétaire et je n'ai pas de données, donc cette partition est vide », il servira joyeusement NotFound pour des millions de clés vivantes et, pire, laissera son état vide refluer sur les réplicas et les supprimer. Cet article parcourt les cinq petites politiques pures que Zaris utilise pour s'assurer que cela n'arrive jamais.

Pourquoi la vacuité est indécidable de l'intérieur​

Commençons par le piège, car chaque règle ci-dessous en est façonnée. Un nœud qui démarre en détenant zéro enregistrement pour un segment qu'il possède est dans l'un de deux états, et il ne peut pas dire lequel à partir de la seule information locale :

  1. Il est vide parce qu'il vient de redémarrer et n'a jamais retiré ses données. Il doit les retirer.
  2. Il est vide parce qu'il a autoritairement vidé ou expiré ces clés, et un réplica qui était brièvement à terre détient encore les copies périmées. S'il retire, il ressuscite des données supprimées.

Ces deux cas sont localement indiscernables — le store est vide dans les deux cas. Une version antérieure de Zaris a tenté de résoudre cela avec un signal d'anomalie EmptyActive et il a été retiré précisément parce que le signal est une supposition. Vous ne pouvez pas demander « suis-je vide pour une bonne raison ? » et obtenir une réponse digne de confiance du nœud vide.

Donc Zaris ne demande rien à la vacuité. Il route la décision à travers un autre fait : un drapeau collant, au niveau de la partition, appelé certified, qui signifie exactement « ma synchronisation de démarrage s'est terminée et mon store fait maintenant autorité ». Un nœud ne le fixe qu'après avoir prouvé la possession. Un propriétaire fraîchement redémarré est, par définition, non certifié — et un propriétaire non certifié n'a aucune autorité à être vide. Il doit retirer les données de quiconque les détient. Un propriétaire certifié fait autorité et est laissé strictement tranquille, de sorte qu'un vidage légitime n'est jamais ressuscité.

Ce seul recadrage — de la vacuité (indécidable) à la certification (un fait prouvé et collant) — est ce qui rend le reste de la récupération sûr.

Étape 1 — le propriétaire redémarré retire, au lieu de supposer​

Quand le nœud rejoint, la map l'assigne toujours comme propriétaire de son ancienne partition. Le déclencheur de reverse-sync décide si chaque segment possédé doit être retiré d'un réplica vivant plutôt que traité comme localement autoritaire :

public static bool ShouldReverseSyncOwnedSegment(
bool partitionCertified,
bool isMapPreferredPrimary,
bool liveNonSelfReplicaExists)
=> !partitionCertified && liveNonSelfReplicaExists;

Trois choses à propos de ce petit prédicat sont porteuses, et chacune est un bug qui a atteint une exécution de certification avant que la règle ne soit écrite.

Il ignore isMapPreferredPrimary. Ce paramètre est toujours passé — comme contexte de décision documenté — mais la décision ne le lit délibérément pas. Une version antérieure ne déclenchait le reverse-sync que lorsque le nœud était aussi le primaire préféré de la map. Un nœud qui est l'actif à l'exécution mais pas le primaire de la map — l'état exact dans lequel atterrit un propriétaire re-domicilié ou promu-après-récupération — voyait ses segments possédés entièrement sautés. Il ne demandait jamais ses données et restait vide à jamais pendant qu'un réplica vivant détenait chaque enregistrement (une exécution de cert : actif détenant 0 contre réplica détenant 4600 sur quinze segments, jamais convergé en huit minutes). La possession du retrait de données ne peut pas être conditionnée au fait d'être le primaire préféré ; elle doit se déclencher pour tout actif non certifié.

Le signal est !partitionCertified, pas la vacuité. C'est la correction d'indécidabilité de la section précédente, en code. Un actif non certifié n'a jamais prouvé la possession, donc il retire ; un certifié fait autorité et est sauté.

liveNonSelfReplicaExists est une garde, pas une gentillesse. Si le réplica configuré est à terre, il n'y a rien à partir de quoi synchroniser. Piloter la récupération contre une source morte tourne à jamais (RECOVERY STUCK source=self synced=0/128). Donc quand aucun réplica vivant n'existe, l'actif est la seule copie survivante — il est l'autorité, et il certifie immédiatement plutôt que d'attendre une source qui ne répondra jamais. Le retrait lui-même est borné par révision et par filigrane (watermark), ce qui en fait un no-op inoffensif quand le réplica ne détient rien.

Étape 2 — ne servez pas une clé que vous n'avez pas encore rattrapée​

Le retrait n'est pas instantané, et la fenêtre pendant qu'il s'exécute est là où les lectures acquittées se transforment discrètement en NotFound. Une barrière naïve est un unique booléen, IsSynced, signifiant « une passe de synchronisation s'est terminée ». Ce booléen ne suffit pas, et trois échecs de cert distincts l'ont prouvé : un actif en train de reprendre a promu avec 34 clés en moins ; une transmission a perdu une clé acquittée ; un actif qui rejoignait a servi NotFound pour 57 clés pendant le churn. Dans chaque cas une passe de synchronisation s'était terminée — mais la source (l'actif temporaire qui a continué de servir) avait depuis accepté davantage d'écritures que le nœud en récupération n'avait pas retirées. IsSynced était vrai et le nœud était toujours en retard.

La correction est de conditionner à une position, pas à un drapeau. La révision de l'op-log est un nombre répliqué et comparable entre nœuds : l'actif l'assigne et l'expédie, de sorte qu'un réplica rattrapé converge vers la même tête. La barrière de service compare les têtes :

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 est le nouvel état qui compte : une passe de synchronisation s'est terminée, mais la tête de la source (capturée à cette synchronisation) est en avance sur la nôtre, donc la source a accepté des écritures que nous n'avons pas retirées. Servir maintenant renverrait NotFound pour exactement ces clés. Le nœud patiente jusqu'à ce que sa tête locale atteigne le filigrane. Notez la dégradation délibérée : un filigrane enregistré de 0 signifie « pas d'information de filigrane », et la barrière retombe sur le comportement basé sur le seul IsSynced plutôt que de bloquer à jamais sur une donnée manquante. La règle ne bloque jamais sur l'absence d'un fait — seulement sur un fait qui dit vous êtes en retard.

Étape 3 — retirez, mais n'écrasez pas​

Maintenant la direction dangereuse : appliquer le snapshot que le réplica renvoie. L'instinct est de vider le store local et de copier le snapshot en bloc. Cet instinct est correct exactement la moitié du temps, et catastrophiquement faux l'autre moitié. La question décisive est dont les révisions sont comparables à celles de qui :

public static ResyncApplyMode Decide(
bool digestMatches,
bool isPassiveSubordinateReplica,
bool priorReconcileShortfall = false)
=> digestMatches
? ResyncApplyMode.Skip
: (isPassiveSubordinateReplica && !priorReconcileShortfall)
? ResyncApplyMode.Reconcile
: ResyncApplyMode.Wipe;

Trois issues :

  • Skip — les digests de contenu correspondent déjà. Rien à transférer, rien à appliquer. (Voir le protocole de cut-over pour savoir pourquoi un digest de contenu, pas un décompte de révisions, est la chose qui vaut d'être comparée.)
  • Reconcile (non destructif) — le nœud local est un pur subordonné passif de l'unique actif vivant. Ses révisions par clé viennent toutes de ce seul actif, donc elles sont directement comparables. Le snapshot est fusionné borné par révision : un PutRaw garde l'enregistrement localement plus récent et comble les clés manquantes ; les pierres tombales s'appliquent via DeleteRaw. Crucialement, cela préserve les écritures que le réplica a accumulées par le canal vivant pendant un bootstrap lent au lieu de les jeter en vidant d'abord.
  • Wipe (vidage destructif + copie) — le nœud local est ou a été lui-même un actif pour cette partition. Deux nœuds indépendamment actifs ont des révisions par clé incomparables : le même numéro de révision sur chacun se réfère à des écritures différentes. Les fusionner entrelacerait silencieusement deux historiques. Donc la seule manœuvre sûre est de déposer l'état local et d'adopter le snapshot faisant autorité en bloc.

Le défaut est la copie destructive sûre ; Reconcile n'est débloqué que en prouvant que le nœud est un subordonné purement passif. C'est la « wipe-guard » : elle empêche la fusion dangereuse entre deux anciens actifs, et symétriquement elle empêche un vidage en bloc de jeter de bonnes écritures du canal vivant sur un simple réplica.

Il y a une ride de plus encodée dans priorReconcileShortfall, et elle vient directement d'un abandon de certification sur douze heures. Une réconciliation bornée par révision peut clôturer dehors (fence out) des enregistrements qu'elle aurait dû appliquer et se terminer tout en restant derrière le snapshot faisant autorité — un segment bloqué à 12643/15435 sur 232 sessions de synchronisation, réconciliant les mêmes enregistrements hors de la fenêtre à chaque fois, bouclant à jamais. Le drapeau dit : si une réconciliation précédente s'est terminée mais nous a laissés en déficit, cesser de réconcilier et escalader vers un wipe pour que le snapshot gelé soit adopté en entier et que nous convergions réellement. Il n'est jamais fixé qu'après un déficit mesuré — un réplica en avance ou à égalité ne le déclenche jamais, donc les écritures du canal vivant ne sont jamais jetées pour poursuivre un fantôme.

Étape 4 — l'autre côté : un propriétaire non certifié peut quand même être une bonne source​

La récupération ne concerne pas seulement le nœud qui est en retard. Considérez le cas miroir : le nœud A redémarre, et le nœud B — un réplica qui était à terre — veut maintenant synchroniser depuis A. A devrait-il servir la requête alors que A est lui-même encore non certifié ?

La garde naïve dit non : « non certifié signifie que mon store est vide, donc servir écraserait les données valides de B avec mon trou ». Mais cette prémisse est fausse à l'instant où A détient réellement la copie faisant autorité — ce qui arrive de deux façons : A était un primaire préféré qui a vérifié le rattrapage avant la promotion mais dont le chemin de promotion n'a jamais appelé CertifyPartition (donc le drapeau collant reste coincé à faux alors que les données sont toutes là), ou A a pris la possession via une récupération de défaillance totale de partition et détient les données complètes comme actif sans jamais avoir été estampillé IsSynced. Dans les deux cas, la copie de A est la source de vérité, et refuser a laissé le réplica bloqué à zéro (un relevé : 1 792 requêtes refusées ; un autre : les 128 segments refusés pour toute une partition).

Donc la garde côté source est rendue consciente des données :

public static bool ShouldRefuse(
bool selfIsRuntimeOwnerMapPrimary,
bool partitionCertified,
bool segmentSynced,
bool selfHoldsSegmentData)
=> selfIsRuntimeOwnerMapPrimary && !partitionCertified && !segmentSynced && !selfHoldsSegmentData;

Refuser de servir seulement quand le nœud est le propriétaire, est non certifié, et que le segment est à la fois non synchronisé et ne détient aucune donnée — le seul cas véritable « en pleine récupération et réellement vide » où servir un trou pourrait laisser le demandeur se certifier contre lui. Parce que le récepteur applique chaque chunk borné par révision, servir des données réelles est toujours sûr ; la garde réduit le refus au seul état où il n'y a rien de sûr à servir.

Étape 5 — déclarer victoire, exactement une fois​

Enfin l'actif en récupération a retiré chaque segment possédé et a rattrapé chacun jusqu'à la tête de sa source. Il fait à nouveau autorité, et il a besoin de le dire — basculer le drapeau certifié collant — pour que le déclencheur de reverse-sync de l'Étape 1 cesse de re-céder ses segments et que le cluster enregistre la partition comme convergée.

public static bool ShouldCertify(
bool selfIsRuntimeActive,
bool partitionCertified,
int ownedSegmentCount,
int syncedSegmentCount)
=> selfIsRuntimeActive
&& !partitionCertified
&& ownedSegmentCount > 0
&& syncedSegmentCount >= ownedSegmentCount;

La certification est le pendant symétrique du déclencheur : le déclencheur met en file les segments d'un actif non certifié à retirer ; celui-ci bascule le drapeau une fois que chaque segment possédé est synchronisé, et la partition se stabilise. Le chemin de certification préexistant ne s'exécutait que pour le primaire préféré de la map, donc un actif re-domicilié non-primaire retirait toutes ses données correctement puis ne certifiait jamais — il re-mettait en file le reverse-sync à jamais, et le cluster ne s'enregistrait jamais comme convergé (une exécution : 30 points de contrôle expirant ~106 secondes après le début, alors que Missing=0 et que les décomptes ne divergeaient jamais — les données étaient justes, seule la stabilisation manquait).

Et notez le seul raccourci que cette politique refuse délibérément : il n'y a pas de chemin « pas de réplica vivant, donc certifie ma vacuité comme autorité » pour un actif non-primaire. Un actif non-primaire n'est pas l'autorité de la map ; le certifier vide alors qu'un réplica temporairement à terre détient encore les données laisserait ce réplica plus tard synchroniser depuis ce nœud vide et tout perdre. Le raccourci de l'autorité-à-être-vide n'appartient qu'au primaire préféré de la map, dans son propre chemin de réconciliation. Partout ailleurs, la vacuité ne gagne rien.

La forme de l'ensemble​

Prenez du recul et les cinq politiques sont une seule idée appliquée en cinq endroits : la possession est prouvée, jamais supposée.

  • Un propriétaire redémarré est non certifié, donc il retire au lieu de faire confiance à son store vide (Étape 1).
  • Il ne servira pas un segment tant que la tête de son op-log n'aura pas atteint celle de la source — une passe de synchronisation terminée n'est pas la même chose qu'être rattrapé (Étape 2).
  • Il fusionne plutôt que d'écraser quand les révisions sont comparables, et écrase plutôt qu'entrelace quand elles ne le sont pas (Étape 3).
  • En tant que source, il sert chaque fois qu'il détient véritablement les données, et ne refuse que lorsqu'il est réellement vide en pleine récupération (Étape 4).
  • Il certifie — bascule le drapeau collant d'autorité — seulement sur preuve que chaque segment possédé est synchronisé, sans raccourci de vacuité-comme-autorité pour un non-primaire (Étape 5).

Chacune de ces règles est un prédicat pur et sans E/S — une poignée de booléens et deux numéros de révision en entrée, une décision en sortie — ce qui est pourquoi elles sont testables unitairement en isolation et pourquoi chacune a pu être épinglée à l'échec de certification exact qui l'a motivée. Elles sont la vue de l'intérieur de l'invariant que la certification du chaos sur douze heures prouve depuis l'extérieur : tuer un propriétaire en plein vol, laisser ses réplicas porter la charge, le ramener, et regarder Missing=0 tenir sur des millions d'opérations acquittées. La raison pour laquelle le redémarrage ne perd pas une écriture est que le propriétaire de retour n'est jamais une seule fois autorisé à croire sa propre vacuité. Il doit regagner ses données — des réplicas qui les ont gardées en sécurité.

Si vous voulez les pièces sur lesquelles ceci s'appuie : l'op-log rejouable est ce qui fait de « rattraper ma tête jusqu'à la tête de la source » une opération réelle et sans trou, et lire vos propres écritures est la promesse côté client que la barrière de service de l'Étape 2 existe pour tenir.