Migrer une partition sans perte de données : le protocole de cut-over
Un basculement, c'est ce qui arrive quand un propriétaire meurt — le cluster a quelques secondes pour choisir un survivant et la partition map ne bouge jamais. La migration est le cousin plus calme et plus dangereux : le propriétaire est parfaitement sain, et vous voulez déplacer une partition hors de lui à dessein — pour faire grandir le cluster, pour rééquilibrer, pour drainer un nœud que vous êtes sur le point de retirer. Rien n'a échoué, ce qui est exactement pourquoi la barre est plus haute. Un basculement a droit à quelques écritures de queue honnêtement perdues dans le pire des cas ; une migration planifiée, non. Si déplacer une partition du nœud A au nœud B peut perdre une seule écriture acquittée, la migration n'est pas une fonctionnalité, c'est un passif.
Cet article parcourt le protocole de cut-over que Zaris exécute réellement, dans l'ordre, et passe l'essentiel de son temps sur l'étape que tout le monde rate : le moment où la source est enfin « libre » de déposer sa copie. Cette étape est un pistolet armé, et l'ingénierie intéressante est la sécurité dessus.
La forme du problème
Une partition est un ensemble de segments, et un segment est un sac d'enregistrements clé-valeur, chacun portant une révision monotone. La migration déplace les données d'un segment d'un nœud source vers un nœud cible puis bascule la possession pour que les clients routent vers la cible. Le tout se produit pendant que le segment est vivant : lectures et écritures continuent d'atterrir tout du long.
Cette vivacité est le problème. Si vous prenez un snapshot de la source, expédiez les octets, et basculez la possession, chaque écriture qui a atterri pendant l'expédition est sur la source et pas sur la cible — et à l'instant où vous basculez, ces écritures disparaissent de la vue routée. Vous les avez acquittées ; on a dit au client « validé » ; maintenant elles sont perdues. La migration naïve perd exactement les écritures survenues pendant qu'elle était occupée à être prudente à propos de celles survenues avant.
Le protocole doit donc faire deux choses en tension : continuer de servir les écritures sur la source jusqu'au basculement, et garantir que la cible détient chacune de ces écritures avant que le basculement ne prenne effet. La façon dont Zaris réconcilie cette tension est une séquence de cinq étapes, dont chacune refuse d'avancer tant que la précédente n'est pas prouvée complète.
Étape 1 — un snapshot ancré sur une révision, avec le plancher de trim épinglé
La cible démarre en demandant à la source les données du segment (SegmentMigrationRequest). La source ne sérialise pas simplement son dictionnaire courant ; elle prend un snapshot ancré sur une révision. Elle enregistre la révision courante du segment — appelons-la R — et capture les enregistrements vivants à hauteur de R.
Deux subtilités vivent dans cette seule étape. D'abord, la source arme son op-log avant de capturer l'ancre. Zaris peut sauter l'op-log pour un segment à primaire unique (RF1) comme optimisation du chemin d'écriture ; l'armer d'abord signifie que chaque écriture à partir de ce point est loguée, de sorte qu'une écriture précédemment non loguée (skip-logged) a toujours une révision à ou en dessous de R et est portée par le snapshot lui-même. Le delta que nous calculons plus tard — « les ops après R » — est alors prouvé sans trou.
Ensuite, la source épingle le plancher de trim de son op-log à 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);
Normalement l'op-log élague les anciennes entrées pour récupérer de la mémoire. Pendant une migration ce serait un désastre : si le plancher avançait au-delà de R pendant que le transfert en masse est en vol, les écritures entre R et le basculement seraient non-rejouables, et le delta final serait silencieusement partiel. Épingler le plancher à R garantit que ces écritures survivent jusqu'à ce que la cible les ait. La source diffuse ensuite les entrées du snapshot — et continue de servir lectures et écritures tout du long. Elle est toujours le propriétaire routé. Rien n'a encore bougé.
Étape 2 — la cible attend, délibérément
La cible applique le snapshot en masse puis fait quelque chose qui ressemble à de l'inaction mais qui est le cœur de la conception : elle fait passer le segment dans un état en attente de cut-over et s'arrête. Elle n'accuse pas réception à la source. Elle ne revendique pas la possession. Elle ne dit pas au leader qu'elle a terminé. Le commentaire dans le code est brutal sur le pourquoi :
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.
C'est le contrat « ancien-propriétaire-autoritaire ». Jusqu'au basculement, exactement un nœud répond pour le segment, et c'est la source. Une cible qui aurait brûlé l'étape — commencé à servir à partir d'un snapshot déjà périmé de quelques centaines d'écritures — serait un split-brain. La cible patiente donc, et demande la seule chose qui lui permettra de prouver qu'elle est à jour : le delta final.
Étape 3 — le delta final sous barrière d'écriture et la preuve par digest
La cible envoie un SegmentMigrationFinalDeltaRequest ancré à R. C'est là que la source fait la partie délicate.
// 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);
Elle engage une barrière d'écriture sur le segment puis lit la révision courante. L'ordre compte : barrière d'abord, mesure ensuite, pour qu'il n'y ait aucune fenêtre où une écriture se glisse après la mesure. Puis elle lit chaque entrée d'op-log après R — le delta de tout ce qui a atterri pendant le transfert en masse — et calcule un digest de contenu de l'état vivant du segment. Ce digest est le pivot de tout le protocole, donc la source l'enregistre comme la preuve 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);
Un mode de défaillance honnête est câblé juste ici. Si le plancher de trim avait d'une manière ou d'une autre avancé au-delà de R — un épinglage perdu ou régressé — alors « les ops après R » seraient incomplètes, et expédier ce delta perdrait silencieusement des écritures acquittées. La source refuse :
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 abandon sous le plancher n'est pas une perte ; il démantèle et re-snapshote depuis une ancre fraîche. La règle que le code respecte est un abandon bruyant plutôt qu'un partiel silencieux — la migration préfère recommencer que basculer sur un delta incomplet.
Étape 4 — la barrière : la cible prouve qu'elle domine
La cible applique le delta, puis recalcule son propre digest de contenu du segment et le compare à celui de la source :
var localDigest = _localStore.ComputeSegmentLiveDigest(seg);
var matchesSource = string.Equals(localDigest, response.SourceDigest, StringComparison.Ordinal);
Si les deux digests sont égaux, la cible détient octet pour octet ce que la source détenait à la barrière — l'état de la cible est un sur-ensemble de chaque écriture que la source a acquittée. S'ils diffèrent, c'est presque toujours parce que le snapshot diffusé est encore en cours d'application quand le premier delta est calculé (le signal de complétion peut dépasser des lots de données en file), donc la cible reste en attente et laisse un réessai espacé redemander le delta plutôt que de tourner à vide. Une divergence ne bascule jamais. Le basculement est conditionné par une preuve positive d'égalité, pas par l'absence d'une erreur.
Il y a une chose de plus que la cible fait avant de crier victoire, et c'est une garantie que la plupart des implémentations de migration sautent. La source n'est pas la seule copie qui pourrait être en avance — un réplica du segment pourrait détenir une écriture que la source sous barrière a manquée. Donc avant de terminer, la cible intègre la queue en lecture seule de chaque réplica vivant :
The flip may fire only when the new owner's digest dominates the source and every live replica of the segment.
L'intégration préserve les révisions : les enregistrements plus anciens d'un réplica en retard sont ignorés par la garde de révision, et un réplica en avance contribue exactement l'écriture que la source n'avait pas. Ce n'est que lorsque la cible domine la source et chaque réplica vivant qu'elle poursuit. C'est l'invariant « durabilité avant possession » — la possession est une récompense pour une possession prouvable, pas une condition préalable à celle-ci.
Étape 5 — le basculement, et le drop qui perd presque vos données
Maintenant la cible dit au leader qu'elle a terminé (SegmentMigrationCompletionNotify). Le leader valide la complétion contre l'override de migration vivant et arme une régénération atomique de cut-over de la partition map — un unique changement de map versionné qui bascule le propriétaire du segment vers la cible et retire l'override de migration. Après que cette map s'applique, la cible est le propriétaire routé. Les clients routent vers elle. Le déplacement est, du point de vue du cluster, terminé.
Et la source, qui détient désormais un segment dont elle ne possède plus de rôle, exécute un balayage de ménage pour déposer la copie orpheline et récupérer la mémoire. C'est le pistolet armé.
Voici le piège, et il est assez subtil pour avoir survécu jusque dans une exécution de certification avant d'être attrapé (Missing=8629 clés acquittées disparues). Une barrière d'écriture réduit l'acquittement mais, sur un segment à copie unique sous charge, ne le met pas parfaitement et instantanément à zéro. Cette séquence est donc possible :
- La source sert le digest du delta final comme preuve. La cible le fait correspondre et bascule.
- Dans l'infime intervalle entre le service de la preuve et l'atterrissage du basculement, une écriture de plus est acquittée sur la source.
- La cible n'a jamais reçu cette écriture — la preuve qu'elle a fait correspondre date d'avant elle.
- La source, désormais orpheline, dépose sa copie pour récupérer de l'espace.
- Cette dernière écriture n'existait que sur la source. Elle est perdue. On a dit à un client qu'elle était validée.
La correction consiste à rendre le drop conditionnel au fait que la preuve soit toujours vraie. La source ne détruit pas aveuglément un segment orphelin. Elle compare le digest vivant courant du segment au digest qu'elle a enregistré comme preuve de cut-over, et ne détruit les données que s'ils correspondent toujours :
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 une écriture a atterri après la preuve, le digest courant diffère du digest de preuve, ShouldRetainAfterCutover renvoie true, et la source conserve sa copie :
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
}
L'asymétrie est tout l'enjeu. La conservation est toujours sûre : la source n'est plus le propriétaire routé, donc une copie conservée est un orphelin redondant et non servi — elle ne peut jamais causer de split-brain, elle coûte juste un peu de mémoire jusqu'à ce qu'un re-service redélivre la queue au nouveau propriétaire et qu'un balayage ultérieur, constatant que les digests concordent à nouveau, la dépose proprement. La destruction sur une preuve périmée n'est jamais sûre : c'est la seule opération qui peut supprimer définitivement une écriture acquittée. La politique est donc délibérément étroite — elle ne fait jamais qu'outrepasser un drop qui aurait autrement lieu, et seulement pour un segment portant une preuve enregistrée dont le digest ne correspond plus. Les segments sans preuve (une éviction, un basculement immédiat, un segment jamais migré) conservent leur comportement existant intact. La barrière ajoute une conservation ; elle n'ajoute jamais un drop.
Pourquoi le digest, et pas un numéro de révision ?
Vous pourriez demander pourquoi la preuve est un digest de contenu et pas simplement « la révision la plus haute que la cible a acquittée ». La réponse est qu'un numéro de révision vous dit jusqu'où la cible est allée, mais un digest vous dit si ce qu'elle a obtenu est ce que vous aviez. Les deux peuvent diverger dans les coutures — une intégration de réplica, un réessai ré-ancré, un snapshot encore en train de se vider — et la seule question qui garde les données en sécurité est la question de contenu : le nouveau propriétaire détient-il exactement ceci ? Un digest y répond directement. Un filigrane (watermark) ne répond qu'à un proxy de cette question, et les proxys sont là où les écritures acquittées vont mourir.
Ce que cela achète, honnêtement
Dit simplement, la garantie est : une migration de partition planifiée ne perd pas d'écriture acquittée. Chaque écriture que la source a acquittée avant la barrière est dans le snapshot ou le delta ; chaque écriture qu'elle a acquittée après la preuve garde la copie de la source en vie jusqu'à ce que le nouveau propriétaire l'ait aussi. Le protocole abandonnera et recommencera bruyamment plutôt que de basculer sur un delta incomplet, et il intègre les réplicas vivants pour que le nouveau propriétaire domine chaque copie, pas seulement la source.
Ce qu'il n'est pas, c'est gratuit. Le delta final sous barrière est un aller-retour synchrone qui met brièvement les écritures sous barrière sur le segment en cours de cut-over — une pause courte et bornée sur un segment, pas sur la partition ni sur le cluster. Une migration qui ne peut pas converger (un plancher de trim qui n'arrête pas d'avancer, une queue de réplica qui ne peut pas être exprimée depuis son log) re-snapshote et réessaie plutôt que de forcer le progrès, ce qui, sous un churn pathologique, signifie qu'une migration prend plus de temps plutôt que de prendre un raccourci à travers vos données. Ce sont les bons compromis pour une opération planifiée : un déplacement que vous avez choisi de faire ne devrait jamais être la raison pour laquelle une écriture validée disparaît.
C'est la même colonne vertébrale que la certification du chaos sur douze heures exerce depuis l'extérieur — arrêter un nœud en pleine migration, brasser la map, et regarder les écritures acquittées survivre. Cet article est la vue de l'intérieur de l'une des coutures que cette exécution prouve fermée : le moment où une partition change de main, et la sécurité sur la détente qui empêche la transmission de dévorer les écritures mêmes qu'elle a été bâtie pour protéger. Il s'appuie sur deux autres pièces qui valent la lecture à elles seules — l'op-log rejouable qui fait de « les ops après la révision R » une requête réelle et sans trou, et la mise à l'échelle élastique qui est la raison la plus courante pour laquelle une partition a besoin de se déplacer un jour.