investigación

Git a cualquier escala

VM
Vicent Martí28 min de lectura

Alojar repositorios de Git a gran escala es una pesadilla. Cuando Linus Torvalds diseñó la primera versión de el gestor de información del infierno (que, de hecho, es el eslogan de Git, compruébalo), tenía en mente un caso de uso muy concreto: el suyo. Quería sustituir BitKeeper, el sistema de control de versiones distribuido que se utilizaba para desarrollar el kernel de Linux. Por supuesto, el sustituto también tenía que ser distribuido. El kernel es un proyecto de software poco habitual: está extremadamente descentralizado y cuenta con muchos mantenedores distintos para sus numerosos subsistemas. Un sistema de control de versiones distribuido encaja de forma natural en este flujo de trabajo.

Veinte años después, Git se ha convertido en un estándar del sector, pero lo cierto es que su naturaleza distribuida es más un obstáculo que una ventaja. El proyecto medio de software de código abierto no funciona con un flujo de trabajo descentralizado. Y una compañía media, desde luego, tampoco. Aprovechan las numerosas ventajas del modelo distribuido (como poder trabajar sin conexión o retrasar los push), pero dependen en gran medida de un host centralizado. Y alojar un repositorio de Git resulta ser increíblemente difícil.

¿Qué tiene de difícil Git?

El reto de alojar repositorios de Git a escala es inherente al propio diseño de Git: al ser un sistema de control de versiones distribuido, todas las instancias de un repositorio son idénticas. No hay nada especial en el repositorio de un servidor Git que no se aplique a un repositorio en la laptop de un desarrollador. Aunque al principio pueda parecer que esto facilita el alojamiento de repositorios de Git (¡basta con poner un demonio HTTP delante de una copia del repositorio en disco y ya tienes un servidor Git funcionando!), hay numerosos retos de escalabilidad y fiabilidad que hacen que la realidad sea justo la contraria.

En un repositorio de Git normal, tu código y metadatos (archivos, commits, árboles) se comprimen y almacenan en packfiles: un sencillo formato de serialización binaria práctico para manejar en una máquina local, pero poco adecuado para gestionarlo a escala en un servidor. Los packfiles son el componente fundamental tanto del almacenamiento como de la red de Git. Cuando haces push o fetch de datos desde un repositorio, se transfieren como un packfile.

Git funciona así por diseño, pero es razonable pensar que no tendría por qué ser así. Al fin y al cabo, no controlas el cliente de Git (al menos no sin molestar a tus usuarios y añadir mucha fricción), pero dentro de tu propio servidor puedes hacer lo que quieras. Nada te obliga a usar packfiles: Linus no va a venir a comprobarlo. La única restricción es que necesitas recibir y enviar packfiles por la red para todas las operaciones de Git.

Con los años, las empresas que intentaron alojar repositorios de Git a escala comprobaron que este diseño basado en packfiles suponía una limitación importante tanto para la disponibilidad como para la escalabilidad. Los packfiles son archivos binarios grandes que deben existir en un sistema de archivos para que Git pueda acceder a ellos. El enfoque sencillo de colocar un servidor HTTP delante de un repositorio en disco tiene un límite muy bajo. Lo ideal sería que el repositorio existiera en muchos discos y máquinas (esto permite ejecutar muchas operaciones de Git en paralelo y mantener el repositorio disponible cuando falla un servidor). Pero ¿cómo hacerlo?

A grandes rasgos, hay tres enfoques posibles para lograrlo, en orden creciente de complejidad: distribuir el sistema de archivos, distribuir los packfiles o distribuir Git.

Git sin packfiles

Git es un almacén de datos direccionable por contenido. Todos los objetos de un repositorio de Git (blobs, árboles, commits, etc.) están identificados por el SHA-1 de su contenido. Esto encaja de forma muy natural con un almacén distribuido de clave-valor (la clave es el SHA-1; el valor es el objeto en sí) y podría ofrecer una forma clara de escalar el almacenamiento de un repositorio. Pero en la práctica no funciona.

El problema es el siguiente: la estructura real de un repositorio de Git es un grafo acíclico dirigido (DAG, por sus siglas en inglés). Puedes buscar cualquier objeto mediante su SHA, pero para realizar incluso la operación más trivial en el repositorio, debes recorrer el DAG paso a paso.

COMMIT DAGTREE /main → c8f3?commit · c8f3NETWORK↓ OLDER COMMITSobjects 0/54 · round-trips 0key/value storeaa42a112c8f3e816f0214b70e8c487ab19b4d5c277b22dc83f7dc43040c22d6e21aa729a0db50f627cf191fe3e81b19092d080a5f31134e06c811f6e7a196e42e147b9086a70ce192ee45d83f7a9b02ec2a70c49b8e25ac074b1a93d47dd9b519d2a8c14d431dc31ef09e205

Si quieres realizar una operación como listar los cambios recientes de un repositorio, debes procesar sus commits. Al procesar un commit, obtienes un puntero a la raíz de su árbol. De ese árbol, obtienes punteros a cada archivo y subárbol. Del commit original, obtienes un puntero a su padre (el que lo precede en el historial). Lo crucial es que, en cada paso de este recorrido, no conoces el valor del siguiente puntero hasta obtener el anterior. Si cada obtención requiere un viaje de ida y vuelta a un almacén distribuido, el coste aumenta muy rápido.

Este enfoque de distribuir Git a nivel de objeto se ha intentado muchas veces antes y suele fallar a escala. La implementación más prometedora la intentó mi antiguo mentor, Shawn Pearce, mientras trabajaba en el equipo de sistemas de control de versiones de Google. Su enfoque consistía en almacenar los objetos en una tabla hash distribuida. Esto solo fue posible gracias a JGit, una implementación personalizada de Git en Java. Como toda buena biblioteca Java de la vieja escuela, JGit proporciona suficientes interfaces, fábricas y fábricas de interfaces para abstraer todos los detalles de un repositorio de Git normal, incluido sustituir sus packfiles en disco por una DHT. Aunque el sistema funcionaba y los resultados eran suficientemente buenos para las operaciones habituales de Git, las limitaciones del protocolo de Git (que, una vez más, exige enviar packfiles por la red independientemente de cómo se almacenen los datos en el servidor) hicieron que el rendimiento de git clone fuera lo bastante malo como para descartar el diseño por completo.

GitHub y sistemas de archivos

Un par de años después de que Git comenzara a salir de su burbuja del kernel de Linux, nació una startup aguerrida en San Francisco. GitHub se fundó en 2008 como una plataforma social para programar, con un eslogan muy premonitorio: "Alojamiento de repositorios de Git: ya no es un dolor de muelas". No bromeo, compruébalo. Ya en 2008 existía un amplio consenso en que, a pesar de —o quizá debido a— el diseño distribuido de Git, necesitabas una forma centralizada de alojar repositorios de Git para que resultaran fáciles de usar, y hacerlo era muy complicado. GitHub se propuso cambiarlo.

Su plataforma empezó como un monolito de Rails —y, en gran medida, sigue siéndolo—. Las primeras versiones se ejecutaban en una única máquina, aunque potente, con un servidor Ruby y copias de los repositorios en disco junto a él. Escalar una aplicación de Rails es fácil: despliegas más instancias. Pero en este caso concreto, dado que interviene Git, pronto se toparon con la pregunta recurrente que intentamos resolver aquí: si la aplicación de Rails necesita acceder a los repositorios de Git en disco, ¿cómo despliegas más copias de ellos?

Como grupo de inadaptados ahorrativos, los primeros ingenieros de sistemas de GitHub probaron el enfoque más sencillo que pudiera resolver sus problemas de escalabilidad. El razonamiento era que, si se centraban en distribuir el sistema de archivos (en lugar de los packfiles o de Git en sí), podrían mantener intacta la aplicación de Rails y dedicar su tiempo a lanzar más funcionalidades para una base de usuarios en constante crecimiento, en vez de hacer cosas extrañas con Git. Muy pragmático. No funcionó.

El equipo probó muchos enfoques para crear un sistema de archivos distribuido para los datos de Git: el más obvio, usar NFS para almacenar todos los repositorios en un servidor centralizado, se descartó rápidamente. La implementación predeterminada de Git hace muchas suposiciones sobre la semántica del sistema de archivos (bloqueo, fragmentación, lectura, sincronización...) que garantizan un rendimiento aceptable en el sistema de archivos local del lento portátil de un desarrollador, pero no tienen en cuenta cómo se comportan en un sistema de archivos en red. Era lento y propenso a errores.

Se hicieron otros intentos con tecnologías —francamente, y visto en retrospectiva, horribles— que replicaban el sistema de archivos a nivel de bloque. Un despliegue de corta duración con GFS. Otro más duradero basado en DRBD. Todos llegaron a un punto muerto. Eran terribles de operar en el día a día y no lo compensaban con un buen rendimiento. Todo se reduce al diseño de los packfiles en disco.

Ya hemos visto cómo las estructuras de datos en forma de grafo de Git hacen que los viajes de ida y vuelta resulten prohibitivamente costosos. Por desgracia, un principio muy similar también se aplica a los datos subyacentes en disco. No existe correlación entre la disposición de los objetos en el DAG y la forma en que se ubican en un packfile. La heurística principal al generar packfiles es minimizar su tamaño; los objetos se distribuyen aleatoriamente por todo el pack, se comprimen y, lo más importante, rara vez se almacenan completos. La mayoría de los objetos se almacenan como un delta sobre otro objeto del mismo packfile. Leer un objeto individual, después de recorrer los numerosos saltos lógicos de la estructura de datos de grafo, también implica recorrer saltos físicos en el formato en disco.

COMMIT DAGTREE /HEAD·mergecommit · c8f3root /tree · f021server.tsblob · f7a9pack.tsblob · a112README.mdblob · c430Cargo.tomlblob · ef09main~1commit · 9d2aroot /tree · e8c4server.tsblob · d431pack.tsblob · b8e2README.mdblob · 21aaCargo.tomlblob · 92d0featurecommit · 74b1root /tree · 7a19server.tsblob · b02epack.tsblob · 7cf1README.mdblob · 5d83Cargo.tomlblob · e147merge basecommit · 5ac0root /tree · 2d6eserver.tsblob · 8c14pack.tsblob · 0f62README.mdblob · a93dCargo.tomlblob · 3e81refactorcommit · 3f7droot /tree · 6c81server.tsblob · e205pack.tsblob · 19b4README.mdblob · 6a70Cargo.tomlblob · d5c2parsercommit · aa42root /tree · 91feserver.tsblob · 4b70pack.tsblob · f311README.mdblob · 2dc8Cargo.tomlblob · 80a5docscommit · ce19root /tree · 0db5server.tsblob · 729apack.tsblob · 6e42README.mdblob · b190Cargo.tomlblob · 47ddbootstrapcommit · 87abroot /tree · 40c2server.tsblob · 1f6epack.tsblob · c2a7README.mdblob · 9b51Cargo.tomlblob · 34e0initialcommit · 2ee4root /tree · b908server.tsblob · dc31pack.tsblob · 77b2README.mdblob · e816Cargo.tomlblob · 0c49↓ OLDER COMMITSpack-7d9a.pack00005041434B00000002000000369667706B001087CB98F791BBB1A90DFB95AEA39C9096002008C2A7180F97ACF200DBBC9098D5BBD10030B5A94CB2E61004EC96B098E17C9BE5B700404A19E5C49FA44FE62B3225C446E744410050C5EB8990E84A607C5C5A3D9996324D5F0060633898968A2B4DF3066395013955B4960070A15A6898EA976BE046C3ACDE98A86F5E00803BE84A97E68450D47936985069F5B25F00902FBDE5D4C28ECFE63DB350B871E7F33A00A0BAF109D0E8F7FF556E74DAB7E6E2E71E00B058DD984BFA29344429F2E52F415382E600C0C71AD04782E703FECCE51940E862FA0600D004826A0596C94054F246984BD218C3AC00E04B2C9506ED4A02967A5071F17D9793A000F01B73670B98B79D58807A0B14E6DC5E5301002FEF9814BBB9A6E8617EE5D4B43D5AE601108478CF63F0E7F3D8BA541492E8E8FCF701205929E078E6629739693698F03127F80B0130295BE516B79BE8E68A9D1BCD23E744210140B1A5543CE88D7CD27941A14CE6C614EB0150F0E1982CE621266DD61CE5CECCF7DEE60160212997678DE7FD3C7B66EFF2E85F579E0170C2D51BAEE51554918799BF9B3F89732901808F7ED201FA87C2FD

Este tipo de recorrido aleatorio por gigabytes de datos, necesario para cada operación de Git realizada en un repositorio, simplemente no se lleva bien con un sistema de archivos en red, ya replique a nivel de archivo o de bloque. La única forma de que funcione sin volverse extremadamente lento es poder almacenar todo el archivo en caché de forma local. Pero, con cientos de miles de repositorios en el mismo sistema de archivos, el almacenamiento en caché no es una opción.

Finalmente, los ingenieros de sistemas de GitHub se resignaron y abandonaron la distribución del sistema de archivos. Empezaron a desarrollar un sistema RPC para que los repositorios pudieran alojarse en servidores de archivos dedicados y actualizaron la aplicación de Rails para realizar todas las operaciones de forma remota. Esto aportó una buena dosis de escalabilidad horizontal, pero no resolvió la disponibilidad ni el rendimiento de los repositorios con más actividad. Después de todo, cada repositorio seguía almacenado en una única máquina.

Spokes y consistencia

Spokes se desarrolló originalmente en GitHub hacia 2013 y desde entonces se ha convertido en un estándar del sector. La mayoría de los servicios de alojamiento de Git utilizan una variante del enfoque de Spokes (replicación a nivel de aplicación para repositorios de Git) en su arquitectura. La principal razón por la que Spokes ha funcionado bien durante tantos años es que tomó tres decisiones fundamentales que, con el tiempo, han demostrado ser óptimas:

  1. No distribuye Git en sí, sino que funciona a nivel de packfile.
  2. Almacena todos los datos como repositorios de Git reales en discos NVMe locales.
  3. Replica los datos de Git, pero mantiene todas las copias sincronizadas de forma coherente.

Debido a los patrones de lectura aleatoria en los packfiles que acabamos de analizar, almacenar repositorios de Git sin modificar en unidades NVMe es prácticamente un requisito para garantizar que todas las operaciones básicas de Git sigan siendo rápidas. También permite que los clones sean eficientes, porque no es necesario transformar los datos al formato que espera el cliente de Git. Además, te permite centrarte en crear un producto sobre Git, en lugar de mantener por tu cuenta una bifurcación de Git capaz de operar con tus repositorios poco convencionales.

Mantener todas las copias de los datos sincronizadas de forma coherente también es crucial. Esto se aprende por las malas, pero el cliente de Git realmente no se lleva bien con la consistencia eventual. Si tu cliente local de Git hace push de un commit y luego no puede leerlo inmediatamente después de un fetch, son malas noticias. Git lo encuentra muy confuso. Si ejecutas tu pipeline de CI en cien runners y tres de ellos no encuentran el commit que deben probar después de clonar tu repositorio, son malas noticias. Además, la experiencia de usuario es muy deficiente.

Trabajar con una vista con consistencia eventual de un repositorio de Git presenta muchas dificultades, tanto en el cliente como en el backend. Por ello, Spokes asume un coste de complejidad muy alto para garantizar que el sistema sea siempre totalmente consistente. Veamos exactamente qué significa esto.

Spokes es un sistema distribuido basado en consenso. Funciona almacenando varias copias de tu repositorio de Git en distintos servidores. Cada vez que haces push de datos nuevos, un orquestador distribuye ese push para que cada instancia de tu repositorio reciba una copia. Esta «distribución» se sincroniza mediante un algoritmo de consenso clásico llamado 3PC (commit de tres fases), de modo que un push solo se acepta si una mayoría de los nodos lo confirma.

QUORUM · 4/5tx #42RESTORE ALLCOORDINATORcollecting votesPARTICIPANT 1waitingPARTICIPANT 2waitingPARTICIPANT 3offlinePARTICIPANT 4waitingPARTICIPANT 5waiting
1 · VOTING2 · PRE-COMMIT3 · DO COMMIT

El commit de tres fases no está relacionado con Git en absoluto. Es un algoritmo de consenso que garantiza que todos los nodos de un sistema acuerden confirmar o revertir una transacción; lo hace en tres viajes de ida y vuelta. Es muy similar a un commit de dos fases, pero introduce una fase adicional de «precommit» para que el sistema pueda recuperarse si el coordinador se desconecta en medio de una transacción.

Antes de profundizar en cómo Spokes utiliza 3PC, debemos entender cómo funciona un push de Git. Un push de Git tiene dos componentes: un packfile y una transacción de referencias. El packfile, del que ya hemos hablado, contiene los objetos que envías al repositorio (blobs, árboles y commits con tus cambios). La transacción es lo que realmente publica tus cambios en el repositorio, al actualizar una o más referencias (por ejemplo, la rama en la que estás trabajando) para que apunten a los commits que acabas de enviar.

Esta separación resulta muy conveniente, porque un commit enviado no es visible («alcanzable», en la terminología de Git) hasta que se actualiza la referencia que apunta a él. Esto significa que podemos implementar el consenso para nuestros pushes distribuyendo los packfiles a todos los hosts simultáneamente (aquí no es necesario sincronizarlos) y después realizando un commit de tres fases con la transacción de referencias, que es mucho más pequeña y rápida de sincronizar que el packfile. Git admite la preparación de transacciones de referencias: puede adquirir un bloqueo sobre la referencia, verificar que el valor existente sea el esperado y mantener el bloqueo hasta recibir un comando de commit o de cancelación para la transacción.

Playback speed0.010x
Replicas5
One-way latency20ms
elapsed 0msSPOKEScoordinatorneeds 5 / 5 acks

Spokes distribuye los paquetes y luego realiza un commit de tres fases para la transacción de cada push. Puedes aumentar el número de réplicas y la latencia en este simulador para ver cómo afecta al rendimiento de los commits.

Con este diseño, garantizamos que cada push se sincronice por completo en todas las réplicas. Las lecturas (fetches y clones) pueden dirigirse de forma segura a cualquier réplica individual, porque cada réplica está siempre actualizada.

Así es, en esencia, como funciona Spokes, y ha funcionado bastante bien durante los últimos 13 años. Por supuesto, Spokes no es perfecto; ningún sistema lo es. En 2026, la forma en que se usan los repositorios de Git ha cambiado drásticamente, y por el camino hemos aprendido muchas lecciones importantes sobre la creación de sistemas distribuidos. El tiempo y la experiencia han demostrado cuáles de las decisiones de Spokes resultaron óptimas y cuáles no.

Un defecto que ha resultado crítico es la limitada escalabilidad horizontal de 3PC. Cuando se lanzó Spokes, tres réplicas por repositorio eran el punto óptimo. Podías atender un repositorio promedio desde tres copias con capacidad de sobra y redundancia suficiente para seguir aceptando pushes incluso si una máquina fallaba.

En 2026, la situación es muy distinta. El repositorio promedio de una compañía empresarial es ahora un monorepo enorme. Tres réplicas no bastan para atender el tráfico de estos repositorios, especialmente en CI. Por supuesto, nada impide que Spokes se ejecute con más de tres réplicas, salvo la temida cola a escala. El commit de tres fases se adapta con mucha elegancia al modelo de transacciones de Git, pero, como algoritmo de consenso, tiene limitaciones fundamentales: la latencia de cada paso viene determinada por el servidor más lento del clúster. Cuantas más réplicas añadas a un clúster, peor será el rendimiento de los pushes.

Esta limitación de escalabilidad también se aplica en sentido inverso. Cuando los agentes trabajan con repositorios de Git a escala, suelen operar fuera de un monorepo creando grandes cantidades de repositorios pequeños, muchos de ellos desechables y la mayoría apenas utilizados. Spokes tiene dificultades en este caso porque sigue requiriendo tres réplicas para cada uno de estos repositorios. Tres réplicas casi inactivas que no se pueden reducir, porque entonces el sistema dejaría de ser totalmente consistente y podría producirse una pérdida de datos. Con el commit de tres fases, el mínimo siempre es demasiado alto y el máximo demasiado bajo.

Otro defecto, imposible de prever, pero dolorosamente evidente después de sufrirlo, es que Spokes puede ser complicado de operar a escala. Como los repositorios en disco son siempre la fuente de verdad del consenso, cada copia de cada repositorio es muy importante. Debes tratar los repositorios como mascotas, no ganado.

Esto significa, para empezar, que debes saber exactamente dónde está cada repositorio. Esto añade una dependencia —y un posible problema de disponibilidad— de una base de datos externa que debe mantener una tabla de enrutamiento muy grande que asigne cada repositorio a cada máquina en la que esté replicado. Además, debe calcularse la suma de comprobación de cada repositorio y actualizarse constantemente en esa tabla para garantizar que el repositorio siga siendo válido en disco. En cuanto le ocurra algo malo a un repositorio (y créeme, las cosas malas ocurren todo el tiempo; Git puede ser muy quisquilloso en la práctica), debes detectarlo y programar una tarea de reparación para devolverlo a un estado saludable. ¡Y debes hacerlo muy rápido! Porque, de nuevo, los repositorios en disco son la fuente de verdad. Una copia corrupta es tan mala como una que falta. Si dos de las tres copias están corruptas, el sistema ya no puede aceptar pushes: no hay cuórum.

Continuity

Continuity (Cnt para abreviar) es el sistema de almacenamiento de Git que hemos desarrollado en Cursor, con un enfoque muy claro: aprender de todo lo que Spokes hizo bien y solucionar los aspectos que, tras muchos años, ahora sabemos que son problemáticos.

Cnt es un sistema sencillo (un sistema no puede ser fácil de operar si no es sencillo). Su componente base es un registro de escritura anticipada, que almacenamos en almacenamiento de objetos compatible con S3. En producción, se ejecuta directamente sobre S3, pero lo diseñamos para que pueda desplegarse en cualquier cloud.

Cuando un repositorio recibe un push, lo almacenamos como una entrada de WAL en S3. Nunca confirmamos un push hasta que se haya persistido por completo. Cada push se almacena como un objeto independiente; escribimos el packfile enviado en disco y lo subimos a S3 simultáneamente. Sin embargo, subir una entrada de WAL no la publica. Un push solo es visible cuando preparamos correctamente su transacción de referencias en una copia local del repositorio y registramos un puntero a la entrada de WAL en el archivo de índice WAL, que es un objeto independiente en el almacenamiento. Esto garantiza que todos los pushes sean linealizables.

S3 · OBJECT STORE#1.wal#2.wal#19e37.wal#23c6e.walgitwal.pbetag e2GIT CLIENTgit pushWALGITreceivingBARE REPOidle

Intentamos no realizar una única escritura en S3 por push porque, en repositorios con mucha actividad, esto impone un límite estricto al rendimiento de los pushes en función de la latencia de la operación PUT de S3. Con una implementación de procesamiento por lotes cuidadosamente optimizada y el único requisito de sincronizar la transacción de referencias con un único repositorio local en lugar de con un cuórum de réplicas, contamos con un sistema capaz de procesar pushes tan rápido como lo permita nuestro disco.

La copia local del repositorio es, por supuesto, un repositorio de Git normal almacenado en una unidad NVMe muy rápida. Hacemos lo mismo que Spokes porque creo que Spokes acertó plenamente en ese aspecto. Esto nos permite reutilizar todo el extraordinario trabajo de código abierto de la comunidad de Git, incluido el cliente de Git upstream y sus numerosas optimizaciones de rendimiento. Podemos centrarnos en lanzar nuevas funcionalidades, en lugar de hacer cosas extrañas con Git.

Consenso

Hemos visto que una de las dificultades de operar un clúster de Spokes es que es muy importante llevar un seguimiento de la ubicación de cada repositorio en cada servidor. Cnt lo hace de una forma muy distinta. ¿Dónde se encuentra cada repositorio? La respuesta es «en cualquier lugar». ¡No importa! Tratamos los repositorios como una caché caliente en disco, pero la fuente de verdad siempre es el registro de escritura anticipada en S3. El sistema no tiene estado y no hay tablas de enrutamiento (ni una base de datos relacional que administrar — hashtag bendecidos). Si falta un repositorio en el disco local al acceder a él desde un host, simplemente lo materializamos a partir del WAL. Podemos hacerlo de forma muy eficiente, pero, por supuesto, no queremos hacerlo todo el tiempo, porque sería un desperdicio. En producción, usamos hashing de rendezvous para asignar un ID de repositorio a la lista de nodos donde esperamos encontrarlo. Todo el estado que necesitamos para enrutar repositorios es el ID del repositorio y el conjunto actual de nodos saludables de un clúster. Pero si este estado se desincroniza (por ejemplo, si un nodo deja de estar saludable), tampoco pasa nada. Simplemente materializaremos el repositorio en el siguiente nodo.

¿Y el consenso? ¿Las elecciones? ¿Qué servidor es el primario de un repositorio determinado? ¡Tampoco importa! Aquí no hay estado ni consenso. Cualquier servidor puede ser el primario. Todas las actualizaciones del registro de escritura anticipada se sincronizan mediante una operación atómica de comparación e intercambio (CAS) en S3, por lo que siempre es seguro que cualquier instancia de un repositorio reciba un push. De nuevo, al igual que con el enrutamiento, permitir que un servidor arbitrario actúe como primario no es lo más eficiente (provoca reintentos de CAS, que pueden retrasar los pushes), así que, en la práctica, siempre elegimos el mismo servidor como primario: el primero de la lista ordenada mediante hashing de rendezvous. Pero en los casos límite —cuando hay un deploy, una conmutación por error o una breve interrupción de red— simplemente no nos importa exactamente qué servidor es el primario. El sistema está diseñado para ser siempre correcto cuando está degradado y siempre rápido cuando está saludable.

S3 · OBJECT STORE4b1e#414b1e.walgitwal.pbetag e0WALGIT Aupload pack#414b1e.wal#429f4d.walgitwal.pbWALGIT Bupload pack#414b1e.wal#42b7e3.walgitwal.pb

Replicación

Tener un registro de escritura anticipada en S3 abre un mundo de posibilidades en términos de escalabilidad. Podemos tener literalmente cualquier número de réplicas, porque la escalabilidad de S3 no tiene comparación y todas las réplicas se sincronizan directamente desde allí. Realizamos una replicación optimista enviando paquetes UDP de gossip por todo el clúster. Los paquetes contienen todos los metadatos necesarios para que cada réplica se sincronice directamente desde S3 después de cada push. «Eso es una locura», te oigo murmurar desde detrás de la pantalla, a través del tiempo y el espacio. «UDP no es un protocolo de transporte fiable». Claro que no. ¡Nada es fiable en un sistema distribuido! La red no es fiable, el enrutamiento no es fiable y la topología tampoco lo es. Pero no pasa nada: da igual. Cada réplica conoce el ETag de la última versión del índice WAL con la que se ha sincronizado. Cuando realizas una operación de lectura en una réplica, hacemos un GET condicional a S3 con el ETag esperado. Una respuesta 304 sin cuerpo (una operación casi instantánea —menos de 10 ms de media, ya que S3 solo consulta metadatos—) significa que estamos actualizados y podemos atender el fetch o el clone de inmediato. Una respuesta 200 incluye la versión más reciente del índice WAL, que usamos para sincronizarnos antes de atender la lectura.

S3 · OBJECT STORE#40.wal#41.wal#40b8ab.wal#4156e2.walgitwal.pbetag e1GIT CLIENTgit pushGIT CLIENTgit fetchWALGIT · PRIMARYreceive-packWALGIT · REPLICAcurrent · e1#40b8ab.wal#4156e2.walgitwal.pbetag e1

Da igual si se pierde el paquete UDP de replicación o si llega al servidor equivocado porque la topología ha cambiado. Todas las lecturas de todas las réplicas son totalmente consistentes, porque se verifican con la fuente de verdad, que es S3. El sistema está diseñado para ser siempre correcto cuando está degradado y siempre rápido cuando está saludable.

Esto tiene dos implicaciones. En primer lugar, como el sistema siempre es consistente, crear infraestructura sobre él es trivial. Nosotros (nuestros agentes, nuestra interfaz web y nuestros clientes) siempre vemos una vista globalmente consistente del repositorio. Además, como el sistema escala en ambas direcciones, cada repositorio tiene justo el número de réplicas que necesita. Un monorepo grande puede desplegarse en cientos de réplicas para atender toda la carga de sus trabajos de CI. Millones de repositorios pequeños creados por agentes pueden atenderse con una réplica cada uno; no necesitamos más de una para garantizar la disponibilidad, porque S3 es la fuente de verdad. De hecho, un repositorio inactivo ni siquiera necesita una: cuando una réplica no recibe tráfico durante un tiempo, la eliminamos del disco del nodo y la volvemos a materializar desde el WAL la próxima vez que llega un fetch.

S3 · OBJECT STORE

Compactación

Los registros de escritura anticipada requieren una compactación periódica. No se puede permitir que el registro crezca sin límite: una restauración completa reproduce cada entrada, por lo que, cuantas más entradas haya, más costosa será.

Por cierto, un repositorio de Git normal también requiere una compactación periódica, aunque Git no se basa en un WAL. Hemos visto que la unidad fundamental de almacenamiento en un repositorio de Git es el packfile. Cada vez que haces push a una copia remota de un repositorio o fetch en tu copia local, creas un nuevo packfile. Esto no escala indefinidamente: cada packfile tiene su propio índice asociado, que permite a Git buscar eficientemente los objetos que contiene, pero esta búsqueda solo es eficiente para cada packfile individual. Si buscas un objeto específico y tu repositorio tiene 100 packfiles, tendrás que abrir el índice de cada uno y buscar el objeto hasta encontrarlo en uno de ellos. Una operación eficiente deja de serlo si debe realizarse cientos o miles de veces.

Git moderno ha mejorado mucho a la hora de sortear este problema; ahora admite índices multipack y compactación geométrica incremental. Pero, tarde o temprano, hay que asumirlo y reempaquetar el repositorio de Git en disco. Históricamente, esto ha supuesto un problema constante de disponibilidad para sistemas como Spokes, porque el reempaquetado consume mucha CPU, incluso cuando se realiza de forma incremental, y debe ejecutarse en todas las réplicas del sistema. Activar accidentalmente una operación de mantenimiento en dos o más nodos de Spokes para el mismo repositorio hará fácilmente que el repositorio conmute por error.

Aquí amortizamos el costo de la compactación. Solo el primario realiza las compactaciones, y el resultado se aplica tanto al repositorio en disco como al WAL. Como todas las réplicas siguen el WAL, también siguen los eventos de compactación. Las réplicas no reempaquetan; simplemente descargan de S3 los packs ya compactados, intercambiando ancho de banda por CPU.

WALGIT · LOCAL PACKSgeometric compaction0ec8f61e8f28979dbe0e6db3COMPACTION FRONTIERPACKS ARRIVES3 · SOURCE OF TRUTH5 packs#126db3.wal#40be0e.wal#970ec8.wal#98f61e.wal#998f28.walgitwal.pb

Escalabilidad

La replicación y la compactación son los dos factores clave que determinan el comportamiento de un sistema de almacenamiento de Git bajo carga. Como acabamos de ver, están intrínsecamente vinculadas: cuantos más pushes por segundo recibe un repositorio, más se degrada el rendimiento de lectura, ya que los packfiles de cada push deben compactarse para que las operaciones Git sigan siendo eficientes. Si replicas estos pushes, la compactación debe replicarse o realizarse de forma independiente en cada réplica.

El diseño de Continuity basado en WAL ofrece escalabilidad horizontal totalmente consistente: puedes desplegar un número arbitrario de réplicas y el rendimiento de las operaciones Git de solo lectura aumenta linealmente con ellas. Como todas las réplicas del clúster son totalmente consistentes, esto nos permite escalar el protocolo de Git (clones, fetches) y todas las operaciones RPC que Origin realiza sobre los repositorios (interacciones con la interfaz web, la API REST, todas nuestras interfaces con agentes, etc.).

Hemos realizado pruebas de estrés sintéticas con hasta 100 réplicas y hemos observado un escalado lineal y consistente de las lecturas, sin regresiones en el rendimiento de los pushes.

El rendimiento de los pushes de un clúster depende de la latencia con la que podemos actualizar nuestro WAL en S3. Con S3 Standard, podemos mantener hasta 120 pushes/s mientras compactamos y replicamos los datos compactados en todos los demás nodos. También hemos desplegado clústeres de alto rendimiento en S3 Express One Zone, que ofrece una latencia mucho menor para las operaciones PUT. En este caso, podemos recibir más de 300 pushes/s y, en la práctica, estamos limitados por la velocidad a la que Git puede compactar los datos en disco. Estamos trabajando en formas innovadoras de organizar estos datos en disco para reducir el impacto de la compactación: nuestro objetivo es seguir optimizando la velocidad a la que un repositorio de Git puede recibir código sin relajar nuestras estrictas garantías de durabilidad y consistencia.

  • S3 Standard
  • S3 Express One Zone

Rendimiento de push/clone para everysphere, el monorepo de Cursor.
Todos los pushes son linealizables y se conservan en almacenamiento externo antes de confirmarse.
Todos los clones son totalmente consistentes.

WAL como fuente de verdad

S3 es una tecnología excelente. Todo el concepto de almacenamiento de blobs, impulsado inicialmente por la API de S3, ha demostrado ser un componente fundamental muy potente para sistemas de almacenamiento de grandes volúmenes de datos, y esto también se aplica, sin duda, al alojamiento de repositorios de Git. El diseño que se presenta aquí es novedoso en muchos aspectos, pero no es el primero en almacenar packfiles como blobs. Azure DevOps (el competidor de GitHub de Microsoft) cuenta con un sistema de almacenamiento de Git muy exitoso que guarda los packfiles en almacenamiento de blobs y las referencias en una base de datos relacional (MS SQL Server). Un sistema como este implica muchas concesiones. Una base de datos relacional escala bien con grandes transacciones de referencias. Pero también hay que operar una base de datos relacional. Creemos firmemente que la consistencia de los datos de Git es más importante que cualquier otra consideración. Esto fue lo que nos llevó a diseñar un sistema basado en WAL que no depende de bases de datos externas.

Hay muchas cosas que pueden salir mal con un repositorio de Git en producción: corrupción de datos en reposo, errores durante el reempaquetado, condiciones de carrera durante los push. Es una gran colección de casos límite. La mayoría se han resuelto en el código fuente principal de Git, pero no todos. Ningún sistema está libre de errores, ni siquiera los que son OSS y se implementan ampliamente. Nuestro modelo de consistencia garantiza que llevemos un seguimiento de cada operación fundamental que se realiza en un repositorio. Nunca confirmamos un push hasta que se haya persistido por completo en el WAL. Linealizamos todos los push. Cada vista de cada repositorio al que accedemos es siempre totalmente consistente. Como cada push está en el WAL, podemos consultar todos los estados por los que ha pasado un repositorio. Disponemos de datos completos de procedencia para todos los push y todos los reempaquetados. Podemos retroceder o avanzar rápidamente cualquier réplica. No tenemos que sincronizar ningún estado con ninguna base de datos externa, ya sea una base de datos que solo almacena referencias o una que almacena todos los datos de objetos. Cuando (no si) nos encontremos con un error en Git, podremos determinar exactamente qué ocurrió y revertirlo. Además de los errores que ya existen en Git, introducimos muy pocos nuevos, porque, durante todo este proceso, todas las operaciones de Git se realizan en un repositorio de Git normal en disco, con herramientas estándar.

Origin

Somos muy conscientes de la importancia de alojar el código fuente de otras personas. Creo que todos los que leen y entienden esta publicación del blog son igual de conscientes de ello. Una compañía puede quedar paralizada si sus desarrolladores no pueden hacer push o pull en sus repositorios de Git. El coste en productividad de cinco minutos de inactividad en su sistema de CI es difícil de cuantificar en dólares, pero, se mida como se mida, es enorme.

Los agentes han cambiado radicalmente la forma en que trabajamos con el software y, en muchos sentidos, han empeorado esta situación. Más código, más PR, más ejecuciones de CI. El control de versiones es la base de todo esto y, posiblemente, sea lo más difícil de cambiar de la noche a la mañana.

Llevamos muchos meses afrontando estas dificultades internamente en Cursor y hemos puesto mucho empeño y cuidado en crear una plataforma que las resuelva para nosotros y que, esperamos, también pueda resolverlas para nuestros clientes. Nuestro objetivo ahora es ofrecer la transición más fluida posible hacia una mayor fiabilidad, rendimiento y escalabilidad, y hacer que la migración sea lo menos dolorosa posible.

Origin no es un experimento; es el resultado de muchas décadas de experiencia creando estos mismos sistemas, por parte de personas que comprenden a fondo la magnitud de los desafíos que implican. Contamos con una filosofía de ingeniería y operaciones que ha demostrado funcionar, y con un firme compromiso de seguir desarrollándola a medida que evoluciona el panorama del control de versiones.

Esperamos que depositen su confianza en nosotros y en nuestra plataforma.