Ce qui traverse le fil : l'anatomie d'un enregistrement Zaris
L'essentiel de ce que Zaris fait bien — répliquer une écriture vers une sauvegarde, remettre à niveau un réplica en retard, faire basculer une partition vers un nouveau propriétaire — se ramène à une question discrète : quelle est, exactement, l'unité de données qui se déplace entre les nœuds ? Si cette unité est juste, la convergence est presque ennuyeuse. Si elle oublie un champ ou en porte un faux, vous obtenez une clé supprimée ressuscitée, un réplica incapable de distinguer le périmé du frais, ou la comptabilité d'un nœud qui fuit sur un autre.
Dans Zaris cette unité est un seul type — StoredRecord — et il vaut la peine de le démonter champ par champ, car chaque décision qu'il contient est porteuse. Cet article est l'anatomie : ce qu'il y a dans l'enregistrement, comment il est encadré sur le fil, pourquoi une suppression voyage comme un enregistrement au lieu d'une absence, pourquoi il y a deux numéros de version différents, et — tout aussi important — ce qui est délibérément laissé sur chaque nœud et ne traverse jamais le fil du tout.
Une charge utile, partout
Un store clé/valeur a un nombre surprenant d'endroits où « une valeur » doit être décrite précisément : la map en mémoire, le flux de réplication, le snapshot de migration, le log d'opérations. Zaris utilise la même forme d'enregistrement pour tous. La chose posée dans le dictionnaire du segment est la même chose qu'un réplica reçoit, est la même chose qu'un nouveau propriétaire reçoit pendant le cut-over. Cette uniformité est pourquoi le log d'opérations peut faire deux travaux à la fois et pourquoi un snapshot de migration et un backfill de réplica n'ont pas besoin d'encodages séparés.
Voici le type entier, réduit à sa surface de fil :
[MessagePackObject]
public class StoredRecord
{
[Key(0)] public ZarisEntry Entry { get; set; } // key + value + metadata
[Key(1)] public Revision Revision { get; set; } // segment-wide ordering
[Key(2)] public ItemVersion Version { get; set; } // per-key CAS version
[Key(3)] public DateTime TimestampUtc { get; set; }
[Key(4)] public bool IsDeleted { get; set; } = false; // tombstone flag
[IgnoreMember] public long AccountedBytes { get; set; } // node-local
[IgnoreMember] public object? MaterializedValue { get; set; } // node-local
}
Cinq champs traversent le fil. Deux autres vivent sur l'objet mais sont marqués [IgnoreMember] et ne voyagent jamais. Le reste de cet article explique pourquoi chacun est là où il est.
L'encadrement : MessagePack avec clés positionnelles
Zaris sérialise les enregistrements avec MessagePack, et les attributs [Key(N)] sont la raison pour laquelle le fil reste petit. MessagePack peut sérialiser un objet de deux façons : comme une map de nom-de-champ → valeur (auto-descriptive, mais chaque message répète chaque nom de champ en chaîne), ou comme un tableau positionnel où chaque champ est identifié par un index entier. Les attributs [Key(0)], [Key(1)], … sélectionnent la forme tableau. Sur le fil, un enregistrement est un court tableau de valeurs dans un ordre fixe — pas de noms de champs, pas de ponctuation style JSON, juste les charges utiles.
Cela compte parce que cet enregistrement est la structure de données chaude de tout le système. Il est sérialisé à chaque écriture répliquée et à chaque clé migrée ; à des centaines de milliers d'écritures par seconde, la différence entre expédier "TimestampUtc" comme une chaîne un million de fois et n'expédier que la position n'est pas une micro-optimisation, c'est le budget.
Le schéma positionnel fixe aussi la règle pour faire évoluer le format en toute sécurité : les clés entières sont en ajout seul (append-only). Un nouveau champ reçoit le prochain index inutilisé ; les index existants ne changent jamais de sens. Un nœud plus ancien qui ne connaît pas l'index 7 l'ignore simplement ; un nœud plus récent lisant un ancien enregistrement trouve l'index 7 absent et retombe sur une valeur par défaut. Nous verrons un champ — les labels à la clé de map 3 à l'intérieur des métadonnées — qui s'appuie sur exactement ce comportement « absent signifie défaut » pour ne rien coûter quand il n'est pas utilisé.
À l'intérieur de l'entry : clé, valeur et métadonnées non nulles
ZarisEntry est le champ Entry — la partie qu'un utilisateur reconnaîtrait comme « ses données » :
[MessagePackObject]
public class ZarisEntry
{
[Key(0)] public string Key { get; set; } = string.Empty;
[Key(1)] public object Value { get; set; } = default!;
[Key(2)] public EntryMetadata Metadata { get; } // getter guarantees non-null
}
Deux détails ici sont le genre de chose qu'on n'apprend qu'en se brûlant une fois. D'abord, Value est typé object, pas byte[] ni string — la forme concrète de la valeur est ce que le rédacteur y a mis, encadrée à l'intérieur de l'entry, ce qui est comment le même type d'enregistrement transporte une simple chaîne, un compteur ou une collection répliquée entière sans que l'enregistrement ait besoin de savoir laquelle.
Ensuite, Metadata est servie via un getter qui ne peut jamais renvoyer null :
private EntryMetadata? _metadata;
[Key(2)]
public EntryMetadata Metadata
{
get => _metadata ??= new EntryMetadata();
set => _metadata = value;
}
MessagePack contourne le constructeur pendant la désérialisation et appelle directement les setters, donc un simple initialiseur = new() ne survivrait pas à un aller-retour — un enregistrement arrivé sans métadonnées se désérialiserait avec une métadonnée null, et chaque site d'appel en aval aurait besoin d'une vérification de null. Le getter rend la garantie structurelle à la place : les métadonnées sont toujours là, même quand le fil n'en a porté aucune. Un seul endroit pour imposer l'invariant, zéro garde de null partout ailleurs.
Des métadonnées qui ne coûtent rien quand elles sont vides
EntryMetadata porte les attributs optionnels d'une clé — CreatedAt, Ttl, ContentType, un bail ou verrou optionnel, et des labels. Le champ des labels est une petite leçon magistrale sur l'art de ne pas payer pour ce qu'on n'utilise pas. La plupart des clés n'ont pas de labels, et un Dictionary naïf initialisé par entry est du pur gaspillage — dans un store réel, 390 000 entrées sans label détenaient chacune un dictionnaire vide de 80 octets, environ 31 Mo de rien.
La correction a deux faces. Sur le heap, les entrées sans label partagent un unique dictionnaire vide statique, de sorte que les lecteurs voient toujours une map non nulle (vide) mais n'allouent rien. Sur le fil, la surface sérialisée est null quand il n'y a pas de labels :
[Key(3)]
public Dictionary<string, LabelValue>? LabelsSerialized
{
get => _labels != null && _labels.Count > 0 ? _labels : null;
set => _labels = value != null && value.Count > 0 ? value : null;
}
Une entrée sans label écrit nil à l'index 3 et le lecteur en restaure « pas de labels ». C'est le schéma de clés append-only qui gagne sa place : le cas courant coûte un octet, et la fonctionnalité est là en entier à l'instant où une clé a réellement besoin d'un label.
Deux nombres, deux travaux : Revision et ItemVersion
Le point de confusion le plus courant à propos des enregistrements Zaris est qu'ils portent deux nombres monotones croissants. Ils ne sont pas redondants — ils répondent à des questions différentes, et les confondre casserait l'un ou l'autre travail.
ItemVersion est par clé. Il démarre à 1 et s'incrémente de un chaque fois que cette clé est écrite. C'est le nombre que le client voit et le nombre contre lequel le compare-and-swap optimiste vérifie : « ne valide que si cette clé est toujours à la version 7 ». Quand le store applique un put, la nouvelle version est simplement la version de l'ancienne clé plus un :
long newVersion = (existing?.Version.Version ?? 0) + 1;
Revision est par segment. C'est un unique compteur partagé par chaque clé de la partition, incrémenté à chaque écriture et à chaque suppression :
revision = new Revision(Interlocked.Increment(ref _storeRevision));
Cela fait de Revision un ordre total sur toutes les mutations d'un segment, ce qui est exactement ce dont la réplication et la récupération ont besoin. Un réplica se moque que la clé user:1 en soit à sa 7e version ; ce qui l'intéresse est quelles révisions à l'échelle du segment il a déjà vues afin de pouvoir demander tout ce qui suit ce point. Le log d'opérations est indexé par révision, le protocole de rattrapage demande « tout après la révision R », et la résolution de conflit entre un enregistrement entrant et un enregistrement local compare les révisions, pas les versions par clé :
// An incoming record older than what we already hold is stale — ignore it.
if (existing is not null && existing.Revision.Value >= record.Revision.Value)
return;
Donc : ItemVersion est l'unité de la concurrence optimiste visible par le client sur une clé ; Revision est l'unité de l'ordonnancement et de la convergence inter-nœuds sur tout le segment. Même enregistrement, deux horloges, chacune faisant un travail que l'autre ne peut pas.
Une suppression est un enregistrement, pas une absence
Voici le champ qui surprend le plus : IsDeleted. Quand vous supprimez une clé dans Zaris, le store ne retire pas simplement l'entrée de son dictionnaire. Il écrit un StoredRecord avec IsDeleted = true — une pierre tombale (tombstone) — l'estampille de la prochaine révision du segment, et l'ajoute au log d'opérations comme une suppression :
var revision = new Revision(Interlocked.Increment(ref _storeRevision));
_deleteRevisions[key] = revision.Value;
AppendOperationLog(revision.Value, key, ReplicationOperation.Delete, new StoredRecord
{
// ...
Revision = revision,
IsDeleted = true,
});
La raison est tout l'enjeu du format d'enregistrement. Si une suppression n'était qu'une absence — la clé disparaissant discrètement d'une map — il n'y aurait rien à répliquer. Un réplica qui a manqué le moment de la suppression continuerait de servir l'ancienne valeur à jamais, car « rien » ne voyage pas et ne peut pas être ordonné par rapport aux écritures qui l'entourent. En faisant de la suppression un enregistrement de première classe avec sa propre révision, la suppression circule par le même chemin de réplication et de rattrapage qu'une écriture, et se glisse dans l'ordre total du segment à la bonne place.
Les pierres tombales défendent aussi contre une défaillance plus subtile : une écriture périmée ressuscitante. Imaginez une suppression à la révision 50 et un put plus ancien pour la même clé, révision 48, encore en vol à travers le réseau. Sans mémoire de la suppression, le put tardif atterrirait et ramènerait la clé d'entre les morts. Zaris enregistre la révision de suppression et rejette tout ce qui lui est antérieur ou égal :
private bool HasDeleteRevisionAtOrAfter(string key, long revision)
=> _deleteRevisions.TryGetValue(key, out var deleteRevision)
&& deleteRevision >= revision;
Le revers des pierres tombales est qu'elles ne peuvent pas s'accumuler à jamais, sinon une charge à forte suppression fuirait de la mémoire. Zaris élague les révisions de suppression une fois qu'elles sont en sécurité en dessous de la plus ancienne révision dont un réplica a encore besoin — la même logique de low-water-mark qui élague le log d'opérations — de sorte que la pierre tombale fait son travail de convergence puis est récupérée. (La discipline d'élagage fait l'objet de l'article sur le log d'opérations.)
Ce qui reste à la maison : les champs [IgnoreMember]
Les deux champs que l'enregistrement porte mais ne sérialise jamais sont aussi délibérés que les cinq qu'il sérialise. Les deux sont marqués [IgnoreMember], et les deux sont node-local par conception — corrects seulement pour le nœud qui les a calculés.
AccountedBytes est la mémoire dont cet enregistrement est débité contre le budget du store, estampillée une fois au moment de l'écriture pour que le store puisse rapporter les deltas d'octets sans remesurer l'enregistrement précédent. Elle n'est explicitement pas sérialisée, car chaque nœud la calcule à partir de la clé et de la valeur de l'enregistrement sous ses propres règles de comptabilité. Si elle chevauchait le fil, un enregistrement répliqué depuis le nœud A porterait l'estimation d'octets de A sur les comptes du nœud B — la comptabilité d'un nœud faussant silencieusement celle d'un autre. La garder [IgnoreMember] signifie que chaque nœud la recalcule indépendamment, et un enregistrement arrivant par réplication ou migration est comptabilisé par le nœud qui le détient désormais.
MaterializedValue est une forme en mémoire vivante, construite paresseusement, d'une collection native (un hash, une list, un set ou un sorted set) mise en cache pour que les opérations par élément ne re-désérialisent pas toute la valeur à chaque fois. C'est de la pure accélération locale. La forme faisant autorité est toujours les octets de valeur encadrés à l'intérieur d'Entry ; un enregistrement qui arrive d'un autre nœud matérialise ce cache paresseusement au premier accès. Le sérialiser reviendrait à expédier l'état de brouillon privé d'un nœud comme s'il s'agissait de données.
La discipline ici est nette et vaut la peine d'être énoncée à voix haute : le fil porte la vérité, le nœud porte les commodités. Tout ce qu'un nœud peut recalculer depuis l'enregistrement lui-même — son coût mémoire, sa structure matérialisée — reste local et est marqué [IgnoreMember]. Tout ce qui fait partie de l'identité et de l'ordonnancement de l'enregistrement — clé, valeur, métadonnées, les deux numéros de version, le drapeau de pierre tombale — traverse le fil. La frontière des attributs est exactement la frontière vérité/commodité, et la garder nette est ce qui rend l'enregistrement sûr à copier entre nœuds sans y repenser à deux fois.
Pourquoi c'est testable, et testé
Parce que la forme de l'enregistrement est porteuse pour la convergence, son comportement sur le fil est épinglé par des tests plutôt que laissé à l'inspection. Deux invariants en particulier gagnent leurs propres assertions : le drapeau de pierre tombale doit survivre à un aller-retour (un IsDeleted perdu ressuscite une clé supprimée sur un réplica), et AccountedBytes doit revenir à zéro après un aller-retour (prouvant qu'il n'a jamais voyagé) :
back.IsDeleted.Should().BeTrue(
"a dropped tombstone flag resurrects a deleted key on the replica");
back.AccountedBytes.Should().Be(0,
"AccountedBytes is [IgnoreMember]; each node recomputes it, it must not cross the wire");
Ces assertions se lisent presque comme la thèse de tout l'article écrite sous forme d'assertions : les choses qui doivent traverser le fil sont prouvées y survivre, et celles qui ne le doivent pas sont prouvées disparaître.
L'enregistrement est le contrat
Il est facile de penser que les problèmes difficiles d'un store distribué vivent dans les parties élégantes — le protocole de basculement, la poignée de main de cut-over, le test du chaos. C'est vrai. Mais ces protocoles ne sont corrects que dans la mesure où la chose qu'ils déplacent l'est, et la chose qu'ils déplacent est ce petit enregistrement unique. Une pierre tombale au lieu d'une absence est pourquoi une suppression converge. Deux numéros de version sont pourquoi la concurrence optimiste d'une clé et l'ordonnancement de tout un segment restent démêlés. Des clés MessagePack positionnelles sont pourquoi le chemin chaud reste bon marché et le format peut quand même grandir. Et une paire de champs [IgnoreMember] sont pourquoi la réplication peut copier un enregistrement entre nœuds et avoir confiance que rien de node-local n'est venu faire le voyage.
Obtenez la bonne unité de données et les grands protocoles ont quelque chose de solide sur quoi se tenir. C'est tout l'argument pour consacrer un article de blog à cinq champs et deux qui restent à la maison.