Un journal, deux missions : comment un réplica et un nouveau propriétaire se mettent à jour de la même façon
Deux événements d'apparence très différente dans un store distribué s'avèrent être le même problème en dessous. Le premier : un réplica prend du retard sur son primaire — une pause de ramasse-miettes, un disque lent, une brève partition réseau — et détient désormais une copie périmée d'une partition qu'il doit finir par faire correspondre. Le second : une partition est réassignée à un nœud qui ne l'a jamais détenue, et ce nœud doit acquérir l'ensemble complet des données auprès du propriétaire actuel. L'un est un retard de réplication ; l'autre est une migration. Ils ont des déclencheurs différents, des opérateurs différents, des histoires d'échec différentes.
Mais la question à laquelle chacun doit répondre est identique : comment converger vers la vérité courante de cette partition sans geler les écritures pendant que je le fais ? Zaris répond aux deux avec un seul mécanisme — un journal d'opérations ordonné par révision, tenu par segment, rejoué depuis un point connu. Cet article porte sur ce journal : comment il est construit sur le chemin d'écriture, comment il est borné pour ne pas pouvoir grossir sans limite, comment il est rejoué, et pourquoi exactement le même substrat conduit à la convergence tant un réplica en retard qu'un propriétaire flambant neuf.
La révision est l'épine dorsale
Tout commence avec un unique compteur. Chaque segment — l'unité de stockage partitionné de Zaris — possède une révision monotone dans son SegmentStore :
private long _storeRevision = 0;
public Revision CurrentRevision => new(_storeRevision);
Chaque mutation l'incrémente. Un Put, un Delete, un changement de TTL — chacun prend la révision suivante via Interlocked.Increment(ref _storeRevision) tout en détenant le verrou de bande de cette clé, et dans la même section critique enregistre l'opération :
revision = new Revision(Interlocked.Increment(ref _storeRevision));
// ... apply to the live map ...
AppendOperationLog(revision, key, ReplicationOperation.Put, record);
La révision n'est donc pas un horodatage ni un numéro de version que vous gérez — c'est un numéro de séquence possédé par le store, strictement croissant, estampillé sur chaque changement dans l'ordre de commit. Cet ordonnancement est toute l'astuce. Si vous connaissez la révision que la copie a vue en dernier, vous savez exactement ce qui lui manque : tout ce qui est estampillé plus haut.
L'entrée de journal elle-même est délibérément petite et plate :
[MessagePackObject]
public sealed class SegmentOperationLogEntry
{
[Key(0)] public long Revision { get; set; }
[Key(1)] public string Key { get; set; }
[Key(2)] public ReplicationOperation Operation { get; set; } // Put | Delete
[Key(3)] public StoredRecord Record { get; set; }
}
Le journal est un ConcurrentDictionary<long, SegmentOperationLogEntry> indexé par révision — un write-ahead log en ajout seul, indexé par révision. Une suppression est une entrée de première classe, pas une absence ; cela compte, car « la clé X a été supprimée à la révision 1202 » est une information qu'une copie périmée doit rejouer, et vous ne pouvez pas représenter une suppression en ne vous contentant pas d'envoyer quelque chose.
Snapshot, puis queue
Vous ne pouvez pas rejouer un journal depuis le début des temps — il serait non borné, et la majeure partie est depuis longtemps écrasée. La mise à jour est donc toujours en deux parties : un snapshot de l'ensemble vivant à une certaine révision S, suivi de la queue du journal, chaque opération estampillée après S.
var (snapshotRevision, snapshotEntries) = _store.CreateSegmentSnapshot(segmentId);
// ... later, the delta ...
var operations = _store.GetSegmentOperationsAfter(segmentId, snapshotRevision.Value);
Le snapshot est le gros des données ; la queue est la poignée d'écritures qui ont atterri pendant que le snapshot était construit et expédié. Le consommateur applique d'abord le snapshot, puis rejoue la queue dans l'ordre des révisions, et il est arrivé à la révision S + n.
Il y a ici une subtilité qui sépare une implémentation correcte d'une implémentation pleine d'espoir. Entre la capture du snapshot à S et la fin du flux, de nouvelles écritures continuent d'atterrir. Le côté service gère cela avec une boucle de drainage bornée — il ré-interroge la queue après la dernière révision qu'il a déjà envoyée, et répète jusqu'à ce qu'une passe revienne vide, de sorte que le transfert reflète le véritable état courant de la source plutôt qu'un point gelé il y a quelques secondes. Toute écriture qui arrive après la passe de drainage finale emprunte le chemin ordinaire de réplication en direct, et le message d'achèvement porte un filigrane AckedHighRevision pour que la source puisse réémettre tout ce qui est au-dessus. La déchirure entre « snapshot » et « direct » est refermée délibérément, non souhaitée au loin.
Le journal est borné — et cela change le contrat
Un WAL indexé par révision qui grossirait indéfiniment serait une fuite mémoire avec des étapes supplémentaires. Le journal d'opérations de Zaris est borné : une cible de rétention souple de 512 entrées et un plafond dur de 200 000, taillé opportunément une fois que le décompte vivant dépasse deux fois la cible souple (hystérésis, pour que la taille ne soit pas sur chaque écriture). La taille retire la plage de révisions contiguës la plus ancienne et publie un plancher de taille (trim floor) — le filigrane sous lequel les opérations sont perdues pour de bon :
public long OpLogTrimFloor => Volatile.Read(ref _opLogTrimWatermark);
Borner le journal crée un nouveau mode d'échec, et la conception honnête réside dans la façon dont il est traité plutôt que caché. Si un consommateur demande la queue après la révision S, mais que S est désormais en dessous du plancher de taille, les opérations dont il a besoin n'existent plus — la plage (S, floor] a été taillée. Une lecture naïve renverrait silencieusement une queue partielle et le consommateur convergerait vers une copie corrompue, criblée de trous. Zaris refuse de faire cela. La lecture gardée ne renvoie la queue que si elle peut la servir sans trou :
// returns false if `after` < trim floor — the delta would have a hole
if (!_localStore.TryGetSegmentOperationsAfter(seg, request.SnapshotRevision, out var finalOps))
{
response.Aborted = true;
response.AbortReason = "FinalDeltaBelowTrimFloor";
// ... release fence, unpin ...
}
Un consommateur qui reçoit cet abandon ne boitille pas — il jette son ancrage périmé et redémarre à partir d'un snapshot frais, dont la révision est courante par définition. « Sous le plancher de taille » est un signal bruyant et récupérable, jamais un trou silencieux. C'est la garantie du delta sans trou, et c'est ce qui permet au journal de rester petit sans jamais mentir sur ce qu'il contient.
Pour empêcher qu'une mise à jour en cours ne soit taillée sous ses propres pieds, le côté service épingle le plancher à la révision du snapshot pour la durée de vie du transfert :
_store.PinSegmentTrim(segmentId, snapshot.Revision.Value); // released in finally
Une épingle maintient le plancher de taille au niveau ou en dessous du point de snapshot afin que la queue dont un consommateur a encore besoin soit conservée. La taille ne franchit jamais l'épingle active la plus basse. La borne et l'épingle ensemble font que le journal est petit en régime permanent mais garanti-complet exactement pour les transferts qui en dépendent.
Mise à jour d'un réplica : le chemin direct
Lorsqu'un réplica a besoin de se resynchroniser, ReplicaSyncRequestHandler exécute directement le modèle snapshot-puis-queue. L'ordre des premières lignes est tout l'argument d'exactitude :
ArmSegmentOpLog(segmentId)— garantir que le journal enregistre avant que l'ancrage soit pris (plus sur le pourquoi ci-dessous).CreateSegmentSnapshot(segmentId)— capturer l'ensemble vivant à la révisionS.PinSegmentTrim(segmentId, S)— épingler le plancher pour que la queue survive au flux.- Diffuser le snapshot en morceaux de
ReplicaSyncChunkKind.Snapshot, puis la queue en morceaux deReplicaSyncChunkKind.Replay. - Exécuter la boucle de drainage bornée jusqu'à ce qu'une passe de queue revienne vide.
Du côté application, chaque morceau est routé vers un processeur par segment qui applique les entrées du snapshot, puis rejoue les opérations strictement dans l'ordre des révisions — une suppression appelle le chemin de suppression du store local, tout le reste écrit l'enregistrement :
foreach (var op in chunk.Operations.OrderBy(o => o.Revision))
{
if (op.Operation == ReplicationOperation.Delete)
_localStore.DeleteRecord(op.Key, op.Record);
else
_localStore.SaveRecord(op.Key, op.Record);
}
L'ordre compte car la même clé peut apparaître plusieurs fois dans une queue — écrite, supprimée, réécrite. Rejouer dans l'ordre des révisions reproduit la séquence exacte du primaire ; rejouer dans le désordre pourrait ressusciter une clé supprimée ou revenir en arrière sur une écriture plus récente.
Migration : les mêmes rails, avec une barrière
Un propriétaire flambant neuf n'obtient pas un moteur différent — il obtient le même, plus un portail de bascule. La migration a deux phases. La phase de masse (SegmentMigrationRequestHandler) arme le journal, prend un snapshot, épingle le plancher de taille et diffuse les enregistrements — des coups d'ouverture identiques à la synchronisation de réplica. Puis, une fois que le nouveau propriétaire a appliqué l'ensemble de masse, il demande le delta final ancré à la révision de snapshot qui lui a été donnée :
public class SegmentMigrationFinalDeltaRequest
{
public ushort SegmentId { get; set; }
public long SnapshotRevision { get; set; } // the anchor
// ...
}
La réponse de la source à cette requête est là où la migration ajoute la seule chose dont la réplication n'a pas besoin. Elle engage d'abord une barrière d'écriture (write fence), capture la révision de barrière, puis construit le delta via la même lecture de queue gardée — de sorte que chaque opération du delta final est au niveau ou en dessous de la barrière, car les écritures sont désormais retenues :
EngageMigrationWriteFence(seg);
var fenceRevision = GetSegmentCurrentRevision(seg);
if (!_localStore.TryGetSegmentOperationsAfter(seg, request.SnapshotRevision, out var finalOps))
{
// below trim floor -> abort, owner re-drives from a fresh snapshot
}
response.Operations = finalOps.ToList();
response.SourceDigest = ComputeSegmentLiveDigest(seg);
La réponse porte aussi un SourceDigest. Le nouveau propriétaire rejoue les opérations du delta final — de nouveau OrderBy(o => o.Revision), de nouveau en routant séparément suppressions et puts — et n'achève la bascule que si son propre condensé recalculé est égal à celui de la source. Si les contenus ne correspondent pas bit pour bit, la bascule n'a pas lieu ; le propriétaire relance. La barrière rend le delta final fini et quiescent ; le condensé rend le transfert vérifiable.
La réplication et la migration diffèrent donc en exactement deux endroits, et sont par ailleurs une seule base de code : la migration ajoute une barrière d'écriture et un portail de bascule à égalité de condensé ; la réplication lit la queue sous une épingle avec une boucle de drainage. Le journal, le snapshot, l'ordonnancement par révision, le contrat de plancher de taille — tout est partagé.
Pourquoi le rejeu utilise le chemin d'écriture « brut »
Un détail qui vaut la peine d'être souligné : la mise à jour ne rejoue pas à travers le Put/Delete normal qu'un client utiliserait. Elle utilise PutRaw / DeleteRaw, qui préservent la révision et la version d'élément de la source au lieu de les ré-estampiller. Un Put normal assignerait une version fraîche comme si l'écriture avait pris naissance localement ; cela ferait diverger les versions du réplica de celles du primaire et casserait la comparaison de condensés sur laquelle la migration repose. Le chemin brut fait avancer le compteur de révision local vers la valeur entrante (Interlocked.Exchange(ref _storeRevision, Math.Max(_storeRevision, record.Revision.Value))) plutôt que d'en inventer une nouvelle. Le rejeu est une reproduction fidèle, pas une re-origination — la copie finit non seulement avec les mêmes données mais avec la même lignée de versions, ce qui rend « nos condensés correspondent-ils ? » une question pertinente.
L'optimisation honnête : sauter le journal quand personne n'écoute
Ajouter au journal d'opérations n'est pas gratuit — c'est une allocation et une seconde insertion de dictionnaire sur chaque écriture, touchant des lignes de cache partagées sur le chemin chaud. Si un segment n'a aucun réplica et que ce nœud est son unique primaire, rien ne rejouera jamais ce journal, donc Zaris peut sauter l'ajout. Cela est livré, et c'est protégé avec soin.
L'obligation est un drapeau par segment, sûr-par-défaut activé :
private long _logObligation = 1; // DEFAULT ON — safe by default
Il est dérivé de la topologie, pas d'un fichier de configuration. À chaque application de carte des partitions, RefreshOpLogObligations() le recalcule par segment : obligation = !(noReplicas && selfIsPrimary). Le journal n'est sauté que lorsque la partition n'a prouvablement aucun réplica et que ce nœud est son unique primaire ; une entrée manquante ou ambiguë se replie sur activé. Il n'existe aucun interrupteur d'opérateur capable de le désactiver incorrectement — il suit la forme réelle du cluster.
Le saut est une ligne en haut de l'ajout :
if (Volatile.Read(ref _logObligation) == 0 && _trimPins.IsEmpty) return;
Notez la seconde clause : même si le drapeau dit « aucune obligation », une épingle de taille détenue force la journalisation activée. Si un transfert est en vol, son épingle garde le journal en enregistrement, quelle que soit la topologie.
La part qui rend cela prouvablement sûr est la barrière arm-avant-snapshot. Chaque consommateur arme le journal immédiatement avant de capturer son ancrage de snapshot :
public void ArmOpLog()
{
Interlocked.Exchange(ref _logObligation, 1);
foreach (var stripe in _keyLocks) lock (stripe) { } // happens-before barrier
}
Positionner le drapeau puis balayer tous les verrous de bande de clés établit une arête happens-before : toute écriture qui assigne une révision après l'armement observera l'obligation et journalisera. Donc toute écriture qui a sauté le journal s'est nécessairement committée avant l'armement, ce qui signifie que sa révision est au niveau ou en dessous de la révision de snapshot S — et est donc portée par le snapshot, pas par la queue. Les écritures sautées ne sont pas perdues ; elles sont simplement livrées dans l'ensemble de masse plutôt que dans le delta. Désactiver l'obligation est refusé tant qu'une épingle est détenue, fermant l'autre côté de la fenêtre.
C'est le genre d'optimisation qui ne vaut la peine d'être livrée que si vous pouvez énoncer exactement pourquoi elle est sûre. Ici l'argument est un invariant unique — armer avant d'ancrer — et tant les chemins de service de synchronisation de réplica que de migration l'obéissent sur les deux lignes qui précèdent leur snapshot.
Un substrat, mérité
L'attrait d'un substrat partagé n'est pas l'élégance pour elle-même. C'est que le raisonnement difficile — l'ordonnancement par révision, la déchirure snapshot/queue, le contrat de delta sans trou face à un journal borné, le rejeu fidèle préservant les versions — est fait une fois puis est ensuite de confiance pour chaque consommateur. Un réplica en retard et un propriétaire fraîchement assigné ressemblent à des événements d'exploitation différents, mais ils posent la même question et obtiennent la même réponse du même code. La migration superpose une barrière et une vérification de condensé par-dessus ; la réplication superpose une épingle et une boucle de drainage. Aucune ne réinvente le journal, et aucune ne peut dériver de l'exactitude de l'autre.
Le journal reste petit parce qu'il est borné et épinglé seulement au besoin ; il reste honnête parce qu'un delta sous le plancher de taille abandonne vers un snapshot frais au lieu de renvoyer un trou ; et il reste bon marché parce que la seule écriture que personne ne rejouera jamais est la seule écriture qu'il est autorisé à sauter — prouvablement, sous une barrière, et activée par défaut partout ailleurs.