Lo que cruza el cable: la anatomía de un registro de Zaris
La mayor parte de lo que Zaris hace bien —replicar una escritura a un backup, poner al día a una réplica rezagada, traspasar una partición a un nuevo propietario— se reduce a una pregunta silenciosa: ¿cuál es, exactamente, la unidad de datos que se mueve entre nodos? Si esa unidad está bien, la convergencia es casi aburrida. Si deja caer un campo o lleva el equivocado, obtienes una clave eliminada resucitada, una réplica que no puede distinguir lo obsoleto de lo fresco, o la contabilidad de un nodo filtrándose en otro.
En Zaris esa unidad es un solo tipo —StoredRecord— y vale la pena desmontarlo campo por campo, porque cada decisión en él es portante. Este artículo es la anatomía: qué contiene el registro, cómo se enmarca en el cable, por qué una eliminación viaja como un registro en lugar de como una ausencia, por qué hay dos números de versión distintos, y —igual de importante— qué deliberadamente se deja atrás en cada nodo y nunca cruza el cable en absoluto.
Una carga útil, en todas partes
Un almacén clave/valor tiene un número sorprendente de lugares donde "un valor" tiene que describirse con precisión: el mapa en memoria, el stream de replicación, la instantánea de migración, el log de operaciones. Zaris usa la misma forma de registro para todos ellos. La cosa que está en el diccionario del segmento es la misma cosa que recibe una réplica, es la misma cosa que recibe un nuevo propietario durante el cut-over. Esa uniformidad es por qué el log de operaciones puede hacer dos trabajos a la vez y por qué una instantánea de migración y un backfill de réplica no necesitan codificaciones separadas.
Aquí está todo el tipo, despojado a su superficie de cable:
[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
}
Cinco campos cruzan el cable. Dos más viven en el objeto pero están marcados [IgnoreMember] y nunca viajan. El resto de este artículo es por qué cada uno está donde está.
El enmarcado: MessagePack con claves posicionales
Zaris serializa los registros con MessagePack, y los atributos [Key(N)] son la razón por la que el cable se mantiene pequeño. MessagePack puede serializar un objeto de dos formas: como un mapa de nombre-de-campo → valor (autodescriptivo, pero cada mensaje repite cada nombre de campo como una cadena), o como un arreglo posicional donde cada campo se identifica por un índice entero. Los atributos [Key(0)], [Key(1)], … seleccionan la forma de arreglo. En el cable un registro es un arreglo corto de valores en un orden fijo —sin nombres de campo, sin puntuación estilo JSON, solo las cargas útiles.
Eso importa porque este registro es la estructura de datos caliente de todo el sistema. Se serializa en cada escritura replicada y cada clave migrada; a cientos de miles de escrituras por segundo la diferencia entre enviar "TimestampUtc" como una cadena un millón de veces y enviar nada más que la posición no es una micro-optimización, es el presupuesto.
El esquema posicional también fija la regla para evolucionar el formato de forma segura: las claves enteras son solo-de-adición (append-only). Un nuevo campo recibe el siguiente índice sin usar; los índices existentes nunca cambian de significado. Un nodo más viejo que no conoce el índice 7 simplemente lo ignora; un nodo más nuevo leyendo un registro viejo encuentra el índice 7 ausente y recurre a un valor por defecto. Veremos un campo —las etiquetas (labels) en la clave de mapa 3 dentro de los metadatos— que se apoya exactamente en este comportamiento de "ausente significa por defecto" para no costar nada cuando no se usa.
Dentro de la entrada: clave, valor y metadatos no nulos
ZarisEntry es el campo Entry —la parte que un usuario reconocería como "sus datos":
[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
}
Dos detalles aquí son del tipo que solo aprendes quemándote una vez. Primero, Value está tipado object, no byte[] o string —la forma concreta del valor es lo que sea que el escritor puso ahí, enmarcado dentro de la entrada, que es cómo el mismo tipo de registro lleva una cadena simple, un contador, o toda una colección replicada sin que el registro necesite saber cuál.
Segundo, Metadata se sirve a través de un getter que nunca puede devolver null:
private EntryMetadata? _metadata;
[Key(2)]
public EntryMetadata Metadata
{
get => _metadata ??= new EntryMetadata();
set => _metadata = value;
}
MessagePack omite el constructor durante la deserialización y llama a los setters directamente, así que un inicializador simple = new() no sobreviviría a un viaje de ida y vuelta —un registro que llegara sin metadatos deserializaría con unos nulos, y cada sitio de llamada downstream necesitaría una comprobación de null. El getter hace la garantía estructural en su lugar: los metadatos están siempre ahí, incluso cuando el cable no llevó ninguno. Un lugar donde imponer el invariante, cero guardas de null en todos los demás.
Metadatos que no cuestan nada cuando están vacíos
EntryMetadata lleva los atributos opcionales de una clave —CreatedAt, Ttl, ContentType, un handle opcional de arrendamiento o lock, y etiquetas. El campo de etiquetas es una pequeña clase magistral sobre no pagar por lo que no usas. La mayoría de las claves no tienen etiquetas, y un Dictionary ingenuo inicializado por entrada es puro desperdicio —en un almacén real, 390K entradas sin etiquetas sostenían cada una un diccionario vacío de 80 bytes, unos 31 MB de nada.
El arreglo es de dos lados. En el heap, las entradas sin etiquetas comparten un único diccionario vacío estático, así que los lectores aún ven un mapa no nulo (vacío) pero no asignan nada. En el cable, la superficie serializada es null cuando no hay etiquetas:
[Key(3)]
public Dictionary<string, LabelValue>? LabelsSerialized
{
get => _labels != null && _labels.Count > 0 ? _labels : null;
set => _labels = value != null && value.Count > 0 ? value : null;
}
Una entrada sin etiquetas escribe nil en el índice 3 y el lector restaura "sin etiquetas" a partir de ello. Este es el esquema de claves solo-de-adición ganándose el sustento: el caso común cuesta un byte, y la característica está ahí en pleno en el momento en que una clave realmente necesita una etiqueta.
Dos números, dos trabajos: Revision e ItemVersion
El punto de confusión más común sobre los registros de Zaris es que llevan dos números que aumentan monótonamente. No son redundantes —responden preguntas distintas, y confundirlos rompería uno de los dos trabajos.
ItemVersion es por clave. Comienza en 1 e incrementa en uno cada vez que esa clave se escribe. Es el número que el cliente ve y el número contra el que las comprobaciones de compare-and-swap optimistas verifican: "confirma solo si esta clave sigue en la versión 7". Cuando el almacén aplica un put, la nueva versión es simplemente la versión de la clave vieja más uno:
long newVersion = (existing?.Version.Version ?? 0) + 1;
Revision es por segmento. Es un único contador compartido por cada clave en la partición, incrementado en cada escritura y cada eliminación:
revision = new Revision(Interlocked.Increment(ref _storeRevision));
Eso hace de Revision un orden total sobre todas las mutaciones en un segmento, que es exactamente lo que la replicación y la recuperación necesitan. A una réplica no le importa que la clave user:1 esté en su 7.ª versión; le importa qué revisiones de todo el segmento ya ha visto para poder pedir todo lo posterior a ese punto. El log de operaciones está indexado por revisión, el protocolo de puesta al día solicita "todo lo posterior a la revisión R", y la resolución de conflictos entre un registro entrante y uno local compara revisiones, no versiones por clave:
// 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;
Así que: ItemVersion es la unidad de concurrencia optimista visible al cliente sobre una clave; Revision es la unidad de orden y convergencia entre nodos a lo largo de todo el segmento. El mismo registro, dos relojes, cada uno haciendo un trabajo que el otro no puede.
Una eliminación es un registro, no una ausencia
Aquí está el campo que más sorprende a la gente: IsDeleted. Cuando eliminas una clave en Zaris, el almacén no simplemente quita la entrada de su diccionario. Escribe un StoredRecord con IsDeleted = true —una tombstone— lo estampa con la siguiente revisión del segmento, y lo añade al log de operaciones como una eliminación:
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 razón es todo el punto del formato de registro. Si una eliminación fuera meramente una ausencia —la clave desvaneciéndose silenciosamente de un mapa— no habría nada que replicar. Una réplica que se perdiera el momento de la eliminación seguiría sirviendo el valor viejo para siempre, porque "nada" no viaja y no puede ordenarse contra las escrituras a su alrededor. Al hacer de la eliminación un registro de primera clase con su propia revisión, la eliminación fluye por la misma ruta exacta de replicación y puesta al día que una escritura, y encaja en el orden total del segmento en el lugar correcto.
Las tombstones también defienden contra un fallo más sutil: una escritura obsoleta resucitante. Imagina una eliminación en la revisión 50 y un put más viejo para la misma clave, revisión 48, todavía en vuelo a través de la red. Sin una memoria de la eliminación, el put tardío aterrizaría y traería la clave de vuelta de entre los muertos. Zaris registra la revisión de eliminación y rechaza cualquier cosa en o antes de ella:
private bool HasDeleteRevisionAtOrAfter(string key, long revision)
=> _deleteRevisions.TryGetValue(key, out var deleteRevision)
&& deleteRevision >= revision;
El reverso de las tombstones es que no pueden acumularse para siempre, o una carga de trabajo con muchas eliminaciones filtraría memoria. Zaris recorta las revisiones de eliminación una vez que están con seguridad por debajo de la revisión más vieja que cualquier réplica todavía necesita —la misma lógica de marca de agua baja (low-water-mark) que recorta el log de operaciones— así que la tombstone hace su trabajo de convergencia y luego se recupera. (La disciplina de recorte es el tema del artículo sobre el log de operaciones.)
Lo que se queda en casa: los campos [IgnoreMember]
Los dos campos que el registro lleva pero nunca serializa son tan deliberados como los cinco que sí. Ambos están marcados [IgnoreMember], y ambos son locales al nodo por diseño —correctos solo para el nodo que los computó.
AccountedBytes es la memoria contra la que se cobra este registro en el presupuesto del almacén, estampada una vez en el momento de escritura para que el almacén pueda reportar deltas de bytes sin re-medir el registro anterior. Explícitamente no se serializa, porque cada nodo lo computa a partir de la propia clave y valor del registro bajo sus propias reglas de contabilidad. Si viajara por el cable, un registro replicado desde el nodo A llevaría la estimación de bytes de A a los libros del nodo B —la contabilidad de un nodo distorsionando silenciosamente la de otro. Mantenerlo [IgnoreMember] significa que cada nodo lo recomputa independientemente, y un registro que llega vía replicación o migración lo contabiliza el nodo que ahora lo sostiene.
MaterializedValue es una forma en memoria viva, construida perezosamente, de una colección nativa (un hash, lista, set o sorted set) cacheada para que las operaciones por elemento no re-deserialicen todo el valor cada vez. Es pura aceleración local. La forma autoritativa es siempre los bytes del valor enmarcado dentro de Entry; un registro que llega de otro nodo materializa esta caché perezosamente en el primer acceso. Serializarla sería enviar el estado de borrador privado de un nodo como si fuera datos.
La disciplina aquí es limpia y vale la pena enunciarla en voz alta: el cable lleva la verdad, el nodo lleva las conveniencias. Cualquier cosa que un nodo pueda recomputar a partir del registro en sí —su costo de memoria, su estructura materializada— se queda local y se marca [IgnoreMember]. Cualquier cosa que sea parte de la identidad y el orden del registro —clave, valor, metadatos, los dos números de versión, el flag de tombstone— cruza el cable. La frontera del atributo es exactamente la frontera verdad/conveniencia, y mantenerla afilada es lo que hace seguro copiar el registro entre nodos sin pensarlo dos veces.
Por qué esto es comprobable, y comprobado
Como la forma del registro es portante para la convergencia, su comportamiento en el cable está fijado por pruebas en lugar de dejado a inspección. Dos invariantes en particular ganan sus propias aserciones: el flag de tombstone debe sobrevivir a un viaje de ida y vuelta (un IsDeleted dejado caer resucita una clave eliminada en una réplica), y AccountedBytes debe volver como cero tras un viaje de ida y vuelta (probando que nunca viajó):
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");
Estas se leen casi como la tesis de todo el artículo escrita como aserciones: las cosas que deben cruzar el cable se demuestran que sobreviven a él, y las cosas que no deben se demuestran que se desvanecen.
El registro es el contrato
Es fácil pensar en los problemas difíciles de un almacén distribuido como si vivieran en las partes vistosas —el protocolo de failover, el handshake de cut-over, las pruebas de caos. Lo hacen. Pero esos protocolos son solo tan correctos como la cosa que mueven, y la cosa que mueven es este único registro pequeño. Una tombstone en lugar de una ausencia es por qué una eliminación converge. Dos números de versión son por qué la concurrencia optimista de una clave y el orden de todo un segmento se mantienen desenredados. Las claves posicionales de MessagePack son por qué la ruta caliente se mantiene barata y el formato aún puede crecer. Y un par de campos [IgnoreMember] son por qué la replicación puede copiar un registro entre nodos y confiar en que nada local al nodo vino de polizón.
Acierta la unidad de datos y los grandes protocolos tienen algo sólido sobre lo que apoyarse. Ese es todo el caso para dedicar un artículo de blog a cinco campos y dos que se quedan en casa.