La base de código de Bazel

Informar un problema Ver fuente Nightly · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

En este documento, se describe la base de código y cómo se estructura Bazel. Está destinado a las personas que desean contribuir a Bazel, no a los usuarios finales.

Introducción

La base de código de Bazel es grande (alrededor de 350 KLOC de código de producción y 260 KLOC de código de prueba), y nadie conoce todo el panorama: todos conocen muy bien su valle en particular, pero pocos saben lo que hay más allá de las colinas en todas las direcciones.

Para que las personas que se encuentran a mitad del camino no se pierdan en un bosque oscuro sin un camino recto, este documento intenta brindar una descripción general de la base de código para que sea más fácil comenzar a trabajar en ella.

La versión pública del código fuente de Bazel se encuentra en GitHub en github.com/bazelbuild/bazel. Esta no es la "fuente de verdad"; se deriva de un árbol de fuentes interno de Google que contiene funciones adicionales que no son útiles fuera de Google. El objetivo a largo plazo es convertir a GitHub en la fuente de información.

Las contribuciones se aceptan a través del mecanismo habitual de solicitudes de extracción de GitHub, y un Googler las importa manualmente al árbol de origen interno y, luego, se vuelven a exportar a GitHub.

Arquitectura cliente/servidor

La mayor parte de Bazel reside en un proceso del servidor que permanece en la RAM entre compilaciones. Esto permite que Bazel mantenga el estado entre las compilaciones.

Por este motivo, la línea de comandos de Bazel tiene dos tipos de opciones: inicio y comando. En una línea de comandos como esta:

    bazel --host_jvm_args=-Xmx8G build -c opt //foo:bar

Algunas opciones (--host_jvm_args=) se encuentran antes del nombre del comando que se ejecutará y otras, después (-c opt). El primer tipo se denomina "opción de inicio" y afecta el proceso del servidor en su totalidad, mientras que el segundo tipo, la "opción de comando", solo afecta un único comando.

Cada instancia del servidor tiene un solo espacio de trabajo asociado (colección de árboles de origen conocidos como "repositorios") y cada espacio de trabajo suele tener una sola instancia del servidor activa. Esto se puede evitar especificando una base de salida personalizada (consulta la sección "Diseño de directorio" para obtener más información).

Bazel se distribuye como un solo ejecutable ELF que también es un archivo .zip válido. Cuando escribes bazel, el ejecutable ELF anterior implementado en C++ (el "cliente") toma el control. Configura un proceso de servidor adecuado con los siguientes pasos:

  1. Comprueba si ya se extrajo. Si no es así, lo hace. Aquí es donde se implementa el servidor.
  2. Verifica si hay una instancia de servidor activa que funcione: que se esté ejecutando, que tenga las opciones de inicio correctas y que use el directorio de espacio de trabajo correcto. Para encontrar el servidor en ejecución, busca en el directorio $OUTPUT_BASE/server un archivo de bloqueo con el puerto en el que el servidor está escuchando.
  3. Si es necesario, finaliza el proceso del servidor anterior.
  4. Si es necesario, inicia un nuevo proceso del servidor.

Una vez que un proceso de servidor adecuado está listo, el comando que se debe ejecutar se comunica a través de una interfaz de gRPC y, luego, la salida de Bazel se canaliza de vuelta a la terminal. Solo se puede ejecutar un comando a la vez. Esto se implementa con un mecanismo de bloqueo elaborado con partes en C++ y partes en Java. Existe cierta infraestructura para ejecutar varios comandos en paralelo, ya que la incapacidad de ejecutar bazel version en paralelo con otro comando es algo vergonzosa. El principal bloqueo es el ciclo de vida de los BlazeModules y algún estado en BlazeRuntime.

Al final de un comando, el servidor de Bazel transmite el código de salida que debe devolver el cliente. Un detalle interesante es la implementación de bazel run: la tarea de este comando es ejecutar algo que Bazel acaba de compilar, pero no puede hacerlo desde el proceso del servidor porque no tiene una terminal. En cambio, le indica al cliente qué archivo binario debe exec() y con qué argumentos.

Cuando se presiona Ctrl + C, el cliente lo traduce a una llamada de cancelación en la conexión de gRPC, que intenta finalizar el comando lo antes posible. Después del tercer Ctrl+C, el cliente envía un SIGKILL al servidor.

El código fuente del cliente se encuentra en src/main/cpp y el protocolo que se usa para comunicarse con el servidor está en src/main/protobuf/command_server.proto .

El punto de entrada principal del servidor es BlazeRuntime.main(), y GrpcServerImpl.run() controla las llamadas de gRPC del cliente.

Diseño del directorio

Bazel crea un conjunto de directorios algo complicado durante una compilación. En Diseño del directorio de salida, se encuentra una descripción completa.

El "repositorio principal" es el árbol de código fuente en el que se ejecuta Bazel. Por lo general, corresponde a algo que extrajiste del control de código fuente. La raíz de este directorio se conoce como "raíz del espacio de trabajo".

Bazel coloca todos sus datos en la "raíz del usuario de salida". Por lo general, es $HOME/.cache/bazel/_bazel_${USER}, pero se puede anular con la opción de inicio --output_user_root.

La "base de instalación" es donde se extrae Bazel. Esto se hace automáticamente, y cada versión de Bazel obtiene un subdirectorio basado en su suma de verificación en la base de instalación. Se encuentra en $OUTPUT_USER_ROOT/install de forma predeterminada y se puede cambiar con la opción de línea de comandos --install_base.

La "base de salida" es el lugar en el que escribe la instancia de Bazel adjunta a un espacio de trabajo específico. Cada base de salida tiene, como máximo, una instancia del servidor de Bazel en ejecución en cualquier momento. Por lo general, se encuentra en $OUTPUT_USER_ROOT/<checksum of the path to the workspace>. Se puede cambiar con la opción de inicio --output_base, que, entre otras cosas, es útil para evitar la limitación de que solo se puede ejecutar una instancia de Bazel en cualquier espacio de trabajo en un momento determinado.

El directorio de salida contiene, entre otras cosas, lo siguiente:

  • Son los repositorios externos recuperados en $OUTPUT_BASE/external.
  • Es la raíz de ejecución, un directorio que contiene vínculos simbólicos a todo el código fuente de la compilación actual. Se encuentra en $OUTPUT_BASE/execroot. Durante la compilación, el directorio de trabajo es $EXECROOT/<name of main repository>. Tenemos previsto cambiarlo a $EXECROOT, aunque es un plan a largo plazo porque es un cambio muy incompatible.
  • Son los archivos que se compilan durante la compilación.

El proceso de ejecución de un comando

Una vez que el servidor de Bazel toma el control y se le informa sobre un comando que debe ejecutar, se produce la siguiente secuencia de eventos:

  1. Se informa a BlazeCommandDispatcher sobre la nueva solicitud. Decide si el comando necesita un espacio de trabajo para ejecutarse (casi todos los comandos, excepto los que no tienen nada que ver con el código fuente, como version o help) y si se está ejecutando otro comando.

  2. Se encuentra el comando correcto. Cada comando debe implementar la interfaz BlazeCommand y tener la anotación @Command (esto es un poco un antipatrón; sería bueno que todos los metadatos que necesita un comando se describieran con métodos en BlazeCommand).

  3. Se analizan las opciones de la línea de comandos. Cada comando tiene diferentes opciones de línea de comandos, que se describen en la anotación @Command.

  4. Se crea un bus de eventos. El bus de eventos es un flujo de eventos que ocurren durante la compilación. Algunos de estos se exportan fuera de Bazel bajo la égida del Protocolo de eventos de compilación para informar al mundo cómo va la compilación.

  5. El comando toma el control. Los comandos más interesantes son los que ejecutan una compilación: build, test, run, coverage, etcétera. BuildTool implementa esta funcionalidad.

  6. El conjunto de patrones de destino en la línea de comandos se analiza y se resuelven los comodines, como //pkg:all y //pkg/.... Esto se implementa en AnalysisPhaseRunner.evaluateTargetPatterns() y se materializa en Skyframe como TargetPatternPhaseValue.

  7. La fase de carga y análisis se ejecuta para generar el grafo de acción (un grafo acíclico dirigido de los comandos que se deben ejecutar para la compilación).

  8. Se ejecuta la fase de ejecución. Esto significa que se ejecuta cada acción necesaria para compilar los objetivos de nivel superior solicitados.

Opciones de línea de comandos

Las opciones de la línea de comandos para una invocación de Bazel se describen en un objeto OptionsParsingResult, que, a su vez, contiene un mapa de "clases de opciones" a los valores de las opciones. Una "clase de opción" es una subclase de OptionsBase y agrupa las opciones de línea de comandos que se relacionan entre sí. Por ejemplo:

  1. Son opciones relacionadas con un lenguaje de programación (CppOptions o JavaOptions). Deben ser una subclase de FragmentOptions y, finalmente, se encapsulan en un objeto BuildOptions.
  2. Opciones relacionadas con la forma en que Bazel ejecuta acciones (ExecutionOptions)

Estas opciones están diseñadas para consumirse en la fase de análisis (ya sea a través de RuleContext.getFragment() en Java o ctx.fragments en Starlark). Algunos de ellos (por ejemplo, si se debe realizar o no el análisis de inclusión de C++) se leen en la fase de ejecución, pero eso siempre requiere una canalización explícita, ya que BuildConfiguration no está disponible en ese momento. Para obtener más información, consulta la sección "Configuraciones".

ADVERTENCIA: Nos gusta simular que las instancias de OptionsBase son inmutables y las usamos de esa manera (por ejemplo, como parte de SkyKeys). Sin embargo, no es así, y modificarlas es una muy buena manera de dañar Bazel de formas sutiles que son difíciles de depurar. Lamentablemente, hacerlos realmente inmutables es una tarea ardua. (Se permite modificar un FragmentOptions inmediatamente después de su construcción, antes de que alguien más tenga la oportunidad de conservar una referencia a él y antes de que se llame a equals() o hashCode() en él).

Bazel aprende sobre las clases de opciones de las siguientes maneras:

  1. Algunos están integrados en Bazel (CommonCommandOptions).
  2. Desde la anotación @Command en cada comando de Bazel
  3. Desde ConfiguredRuleClassProvider (estas son opciones de línea de comandos relacionadas con lenguajes de programación individuales)
  4. Las reglas de Starlark también pueden definir sus propias opciones (consulta aquí).

Cada opción (excepto las definidas por Starlark) es una variable miembro de una subclase FragmentOptions que tiene la anotación @Option, que especifica el nombre y el tipo de la opción de línea de comandos junto con un texto de ayuda.

Por lo general, el tipo de Java del valor de una opción de línea de comandos es algo simple (una cadena, un número entero, un valor booleano, una etiqueta, etcétera). Sin embargo, también admitimos opciones de tipos más complicados. En este caso, la tarea de convertir la cadena de la línea de comandos al tipo de datos recae en una implementación de com.google.devtools.common.options.Converter.

El árbol fuente, tal como lo ve Bazel

Bazel se dedica a compilar software, lo que se logra leyendo e interpretando el código fuente. La totalidad del código fuente con el que opera Bazel se denomina "el espacio de trabajo" y se estructura en repositorios, paquetes y reglas.

Repositorios

Un "repositorio" es un árbol de fuentes en el que trabaja un desarrollador y, por lo general, representa un solo proyecto. Blaze, el antecesor de Bazel, operaba en un monorepo, es decir, un solo árbol de origen que contiene todo el código fuente que se usa para ejecutar la compilación. En cambio, Bazel admite proyectos cuyo código fuente abarca varios repositorios. El repositorio desde el que se invoca Bazel se denomina "repositorio principal", y los demás se denominan "repositorios externos".

Un repositorio se marca con un archivo de límite de repo (MODULE.bazel, REPO.bazel o, en contextos heredados, WORKSPACE o WORKSPACE.bazel) en su directorio raíz. El repo principal es el árbol de origen desde el que invocas Bazel. Los repos externos se definen de varias maneras. Consulta la descripción general de las dependencias externas para obtener más información.

El código de los repositorios externos se vincula simbólicamente o se descarga en $OUTPUT_BASE/external.

Cuando se ejecuta la compilación, se debe unir todo el árbol de origen. Esto lo hace SymlinkForest, que crea vínculos simbólicos de cada paquete del repositorio principal a $EXECROOT y de cada repositorio externo a $EXECROOT/external o $EXECROOT/...

Paquetes

Cada repositorio se compone de paquetes, una colección de archivos relacionados y una especificación de las dependencias. Estos se especifican en un archivo llamado BUILD o BUILD.bazel. Si ambos existen, Bazel prefiere BUILD.bazel. El motivo por el que aún se aceptan los archivos BUILD es que el antecesor de Bazel, Blaze, usaba este nombre de archivo. Sin embargo, resultó ser un segmento de ruta de acceso de uso común, especialmente en Windows, donde los nombres de archivos no distinguen entre mayúsculas y minúsculas.

Los paquetes son independientes entre sí: los cambios en el archivo BUILD de un paquete no pueden provocar que cambien otros paquetes. La adición o eliminación de archivos BUILD _puede_ cambiar otros paquetes, ya que los globs recursivos se detienen en los límites del paquete y, por lo tanto, la presencia de un archivo BUILD detiene la recursión.

La evaluación de un archivo BUILD se denomina "carga de paquete". Se implementa en la clase PackageFactory, funciona llamando al intérprete de Starlark y requiere conocimiento del conjunto de clases de reglas disponibles. El resultado de la carga del paquete es un objeto Package. En su mayoría, es un mapa de una cadena (el nombre de un destino) al destino en sí.

Una gran parte de la complejidad durante la carga de paquetes es la expansión de comodines: Bazel no requiere que cada archivo fuente se incluya de forma explícita y, en cambio, puede ejecutar expansiones de comodines (como glob(["**/*.java"])). A diferencia de la shell, admite expansiones de comodines recursivas que descienden a subdirectorios (pero no a subpaquetes). Esto requiere acceso al sistema de archivos y, como puede ser lento, implementamos todo tipo de trucos para que se ejecute en paralelo y de la manera más eficiente posible.

La expansión con comodines se implementa en las siguientes clases:

  • LegacyGlobber, un globber rápido y felizmente inconsciente de Skyframe
  • SkyframeHybridGlobber, una versión que usa Skyframe y vuelve al globber heredado para evitar los "reinicio de Skyframe" (que se describen a continuación)

La clase Package en sí contiene algunos miembros que se usan exclusivamente para analizar el paquete "externo" (relacionado con las dependencias externas) y que no tienen sentido para los paquetes reales. Esto es un defecto de diseño, ya que los objetos que describen paquetes regulares no deberían contener campos que describan otra cosa. Estos incluyen los siguientes:

  • Las asignaciones del repositorio
  • Las cadenas de herramientas registradas
  • Las plataformas de ejecución registradas

Lo ideal sería que hubiera una mayor separación entre el análisis del paquete "externo" y el análisis de los paquetes normales, de modo que Package no tenga que satisfacer las necesidades de ambos. Lamentablemente, esto es difícil de hacer porque ambos están muy entrelazados.

Etiquetas, objetivos y reglas

Los paquetes se componen de destinos, que tienen los siguientes tipos:

  1. Archivos: Son elementos que son la entrada o la salida de la compilación. En el lenguaje de Bazel, los llamamos artefactos (se analizan en otro lugar). No todos los archivos creados durante la compilación son destinos; es común que un resultado de Bazel no tenga una etiqueta asociada.
  2. Reglas: Describen los pasos para derivar sus resultados a partir de sus entradas. Por lo general, se asocian con un lenguaje de programación (como cc_library, java_library o py_library), pero hay algunos que son independientes del lenguaje (como genrule o filegroup).
  3. Grupos de paquetes: Se describen en la sección Visibilidad.

El nombre de un destino se denomina etiqueta. La sintaxis de las etiquetas es @repo//pac/kage:name, donde repo es el nombre del repositorio en el que se encuentra la etiqueta, pac/kage es el directorio en el que se encuentra su archivo BUILD y name es la ruta de acceso del archivo (si la etiqueta hace referencia a un archivo fuente) en relación con el directorio del paquete. Cuando se hace referencia a un destino en la línea de comandos, se pueden omitir algunas partes de la etiqueta:

  1. Si se omite el repositorio, se considera que la etiqueta está en el repositorio principal.
  2. Si se omite la parte del paquete (como name o :name), se considera que la etiqueta está en el paquete del directorio de trabajo actual (no se permiten rutas relativas que contengan referencias de nivel superior (..)).

Un tipo de regla (como "biblioteca de C++") se denomina "clase de regla". Las clases de reglas se pueden implementar en Starlark (la función rule()) o en Java (las llamadas "reglas nativas", de tipo RuleClass). A largo plazo, todas las reglas específicas del lenguaje se implementarán en Starlark, pero algunas familias de reglas heredadas (como Java o C++) aún están en Java por el momento.

Las clases de reglas de Starlark deben importarse al principio de los archivos BUILD con la instrucción load(), mientras que Bazel "conoce" de forma innata las clases de reglas de Java, ya que están registradas en ConfiguredRuleClassProvider.

Las clases de reglas contienen información como la siguiente:

  1. Sus atributos (como srcs, deps): sus tipos, valores predeterminados, restricciones, etcétera
  2. Las transiciones y los aspectos de configuración adjuntos a cada atributo, si los hay
  3. La implementación de la regla
  4. Los proveedores de información transitivos que la regla "generalmente" crea

Nota sobre la terminología: En la base de código, a menudo usamos "Rule" para referirnos al destino creado por una clase de regla. Sin embargo, en Starlark y en la documentación para el usuario, se debe usar "regla" exclusivamente para referirse a la clase de regla en sí; el destino es solo un "destino". También ten en cuenta que, a pesar de que RuleClass tiene "clase" en su nombre, no hay una relación de herencia de Java entre una clase de regla y los destinos de ese tipo.

Skyframe

El framework de evaluación subyacente de Bazel se llama Skyframe. Su modelo indica que todo lo que se debe compilar durante una compilación se organiza en un gráfico acíclico dirigido con bordes que apuntan desde cualquier fragmento de datos a sus dependencias, es decir, otros fragmentos de datos que se deben conocer para construirlo.

Los nodos del gráfico se denominan SkyValues y sus nombres se denominan SkyKeys. Ambos son profundamente inmutables; solo se debe poder acceder a objetos inmutables desde ellos. Esta invariante casi siempre se cumple y, en caso de que no se cumpla (como en el caso de las clases de opciones individuales BuildOptions, que es miembro de BuildConfigurationValue y su SkyKey), nos esforzamos mucho por no cambiarlas o por cambiarlas solo de formas que no se puedan observar desde el exterior. De esto se deduce que todo lo que se calcula dentro de Skyframe (como los destinos configurados) también debe ser inmutable.

La forma más conveniente de observar el gráfico de Skyframe es ejecutar bazel dump --skyframe=deps, que vuelca el gráfico, un SkyValue por línea. Es mejor hacerlo para compilaciones pequeñas, ya que puede volverse bastante grande.

Skyframe se encuentra en el paquete com.google.devtools.build.skyframe. El paquete con un nombre similar com.google.devtools.build.lib.skyframe contiene la implementación de Bazel sobre Skyframe. Puedes encontrar más información sobre Skyframe aquí.

Para evaluar un SkyKey determinado en un SkyValue, Skyframe invocará el SkyFunction correspondiente al tipo de la clave. Durante la evaluación de la función, es posible que solicite otras dependencias de Skyframe llamando a las distintas sobrecargas de SkyFunction.Environment.getValue(). Esto tiene el efecto secundario de registrar esas dependencias en el gráfico interno de Skyframe, de modo que Skyframe sepa que debe volver a evaluar la función cuando cambie alguna de sus dependencias. En otras palabras, el almacenamiento en caché y el cálculo incremental de Skyframe funcionan con la granularidad de los SkyFunction y los SkyValue.

Cada vez que un SkyFunction solicita una dependencia que no está disponible, getValue() devolverá nulo. Luego, la función debe ceder el control a Skyframe por sí misma devolviendo nulo. En algún momento posterior, Skyframe evaluará la dependencia no disponible y, luego, reiniciará la función desde el principio. Solo que, esta vez, la llamada a getValue() se realizará correctamente con un resultado no nulo.

Una consecuencia de esto es que cualquier cálculo realizado dentro de SkyFunction antes del reinicio debe repetirse. Sin embargo, esto no incluye el trabajo realizado para evaluar la dependencia SkyValues, que se almacena en caché. Por lo tanto, solemos solucionar este problema de las siguientes maneras:

  1. Declarar dependencias en lotes (con getValuesAndExceptions()) para limitar la cantidad de reinicios
  2. Divide un SkyValue en partes separadas que calculan diferentes SkyFunctions, de modo que se puedan calcular y almacenar en caché de forma independiente. Esto debe hacerse de forma estratégica, ya que tiene el potencial de aumentar el uso de memoria.
  3. Almacenar el estado entre reinicios, ya sea con SkyFunction.Environment.getState() o mantener una caché estática ad hoc "detrás de Skyframe". Con las SkyFunctions complejas, la administración del estado entre reinicios puede ser difícil, por lo que se introdujeron los StateMachines para un enfoque estructurado de la simultaneidad lógica, incluidos los hooks para suspender y reanudar los cálculos jerárquicos dentro de un SkyFunction. Ejemplo: DependencyResolver#computeDependencies usa un StateMachine con getState() para calcular el conjunto potencialmente enorme de dependencias directas de un destino configurado, lo que, de lo contrario, puede generar reinicios costosos.

Básicamente, Bazel necesita estos tipos de soluciones alternativas porque es común que haya cientos de miles de nodos de Skyframe en proceso, y la compatibilidad de Java con subprocesos ligeros no supera la implementación de StateMachine en 2023.

Starlark

Starlark es el lenguaje específico del dominio que las personas usan para configurar y extender Bazel. Se concibe como un subconjunto restringido de Python que tiene muchos menos tipos, más restricciones en el flujo de control y, lo que es más importante, garantías de inmutabilidad sólidas para habilitar lecturas simultáneas. No es Turing-completo, lo que disuade a algunos (pero no a todos) los usuarios de intentar realizar tareas de programación generales dentro del lenguaje.

Starlark se implementa en el paquete net.starlark.java. También tiene una implementación independiente en Go aquí. Actualmente, la implementación de Java que se usa en Bazel es un intérprete.

Starlark se usa en varios contextos, incluidos los siguientes:

  1. Archivos BUILD Aquí se definen los nuevos destinos de compilación. El código de Starlark que se ejecuta en este contexto solo tiene acceso al contenido del archivo BUILD y a los archivos .bzl que carga.
  2. El archivo MODULE.bazel Aquí es donde se definen las dependencias externas. El código de Starlark que se ejecuta en este contexto solo tiene acceso muy limitado a algunas directivas predefinidas.
  3. Archivos .bzl Aquí se definen las nuevas reglas de compilación, las reglas del repositorio y las extensiones de módulos. El código de Starlark aquí puede definir funciones nuevas y cargar desde otros archivos .bzl.

Los dialectos disponibles para los archivos BUILD y .bzl son ligeramente diferentes porque expresan cosas distintas. Puedes consultar una lista de las diferencias aquí.

Puedes encontrar más información sobre Starlark aquí.

La fase de carga y análisis

En la fase de carga y análisis, Bazel determina qué acciones se necesitan para compilar una regla en particular. Su unidad básica es un “destino configurado”, que, de manera bastante lógica, es un par (destino, configuración).

Se denomina "fase de carga y análisis" porque se puede dividir en dos partes distintas, que antes se serializaban, pero que ahora se pueden superponer en el tiempo:

  1. Cargar paquetes, es decir, convertir archivos BUILD en los objetos Package que los representan
  2. Analizar los destinos configurados, es decir, ejecutar la implementación de las reglas para producir el gráfico de acciones

Cada destino configurado en el cierre transitivo de los destinos configurados solicitados en la línea de comandos debe analizarse de forma ascendente, es decir, primero los nodos hoja y, luego, hasta los que se encuentran en la línea de comandos. Las entradas para el análisis de un solo destino configurado son las siguientes:

  1. Es la configuración. ("cómo" compilar esa regla; por ejemplo, la plataforma de destino, pero también aspectos como las opciones de línea de comandos que el usuario desea pasar al compilador de C++)
  2. Las dependencias directas. Sus proveedores de información transitiva están disponibles para la regla que se analiza. Se llaman así porque proporcionan un "resumen" de la información en el cierre transitivo del destino configurado, como todos los archivos .jar en la ruta de clase o todos los archivos .o que deben vincularse en un archivo binario de C++.
  3. El objetivo en sí Este es el resultado de cargar el paquete en el que se encuentra el destino. En el caso de las reglas, esto incluye sus atributos, que suelen ser lo más importante.
  4. Es la implementación del destino configurado. En el caso de las reglas, puede ser en Starlark o en Java. Todos los destinos configurados que no son de regla se implementan en Java.

El resultado del análisis de un destino configurado es el siguiente:

  1. Los proveedores de información transitiva que configuraron destinos que dependen de él pueden acceder a
  2. Los artefactos que puede crear y las acciones que los producen.

La API que se ofrece a las reglas de Java es RuleContext, que es el equivalente del argumento ctx de las reglas de Starlark. Su API es más potente, pero, al mismo tiempo, es más fácil hacer cosas malas™, por ejemplo, escribir código cuya complejidad de tiempo o espacio sea cuadrática (o peor), hacer que el servidor de Bazel falle con una excepción de Java o incumplir invariantes (por ejemplo, modificando inadvertidamente una instancia de Options o haciendo que un destino configurado sea mutable).

El algoritmo que determina las dependencias directas de un destino configurado se encuentra en DependencyResolver.dependentNodeMap().

Configuraciones

Las configuraciones son el "cómo" de compilar un destino: para qué plataforma, con qué opciones de línea de comandos, etcétera.

El mismo destino se puede compilar para varios parámetros de configuración en la misma compilación. Esto es útil, por ejemplo, cuando se usa el mismo código para una herramienta que se ejecuta durante la compilación y para el código de destino, y estamos realizando una compilación cruzada o cuando estamos compilando una app para Android gruesa (una que contiene código nativo para varias arquitecturas de CPU).

Conceptualmente, la configuración es una instancia de BuildOptions. Sin embargo, en la práctica, BuildOptions se encapsula en BuildConfiguration, que proporciona otras funciones diversas adicionales. Se propaga desde la parte superior del gráfico de dependencias hasta la parte inferior. Si cambia, se debe volver a analizar la compilación.

Esto genera anomalías, como tener que volver a analizar toda la compilación si, por ejemplo, cambia la cantidad de ejecuciones de prueba solicitadas, aunque eso solo afecte a los destinos de prueba (tenemos planes para "recortar" las configuraciones de modo que esto no suceda, pero aún no está listo).

Cuando una implementación de regla necesita parte de la configuración, debe declararla en su definición con RuleClass.Builder.requiresConfigurationFragments(). Esto se hace para evitar errores (como reglas de Python que usan el fragmento de Java) y para facilitar el recorte de la configuración, de modo que, si cambian las opciones de Python, no sea necesario volver a analizar los destinos de C++.

La configuración de una regla no es necesariamente la misma que la de su regla "principal". El proceso de cambiar la configuración en un borde de dependencia se denomina "transición de configuración". Puede ocurrir en dos lugares:

  1. En un borde de dependencia. Estas transiciones se especifican en Attribute.Builder.cfg() y son funciones de un Rule (donde ocurre la transición) y un BuildOptions (la configuración original) a uno o más BuildOptions (la configuración de salida).
  2. En cualquier arista entrante a un destino configurado. Estos se especifican en RuleClass.Builder.cfg().

Las clases relevantes son TransitionFactory y ConfigurationTransition.

Las transiciones de configuración se usan, por ejemplo, en los siguientes casos:

  1. Declarar que una dependencia en particular se usa durante la compilación y, por lo tanto, se debe compilar en la arquitectura de ejecución
  2. Para declarar que una dependencia en particular se debe compilar para varias arquitecturas (como para el código nativo en APKs de Android fat)

Si una transición de configuración genera varias configuraciones, se denomina transición de división.

Las transiciones de configuración también se pueden implementar en Starlark (documentación aquí).

Proveedores de información de tránsito

Los proveedores de información transitiva son una forma (y la _única_ forma) para que los destinos configurados aprendan sobre otros destinos configurados de los que dependen, y la única forma de informar sobre sí mismos a otros destinos configurados que dependen de ellos. El motivo por el que se incluye la palabra "transitivo" en su nombre es que, por lo general, se trata de algún tipo de resumen del cierre transitivo de un destino configurado.

En general, hay una correspondencia 1:1 entre los proveedores de información transitiva de Java y los de Starlark (la excepción es DefaultInfo, que es una combinación de FileProvider, FilesToRunProvider y RunfilesProvider porque se consideró que esa API era más similar a Starlark que una transliteración directa de la de Java). Su clave es una de las siguientes opciones:

  1. Es un objeto de clase Java. Esta opción solo está disponible para los proveedores a los que no se puede acceder desde Starlark. Estos proveedores son una subclase de TransitiveInfoProvider.
  2. Una string. Este es un método heredado y muy desaconsejado, ya que es susceptible a conflictos de nombres. Estos proveedores de información transitiva son subclases directas de build.lib.packages.Info .
  3. Símbolo de proveedor. Se puede crear desde Starlark con la función provider() y es la forma recomendada de crear proveedores nuevos. El símbolo se representa con una instancia de Provider.Key en Java.

Los proveedores nuevos implementados en Java deben implementarse con BuiltinProvider. NativeProvider está en desuso (aún no tuvimos tiempo de quitarlo) y no se puede acceder a las subclases de TransitiveInfoProvider desde Starlark.

Objetivos configurados

Los destinos configurados se implementan como RuleConfiguredTargetFactory. Hay una subclase para cada clase de regla implementada en Java. Los destinos configurados de Starlark se crean a través de StarlarkRuleConfiguredTargetUtil.buildRule() .

Las fábricas de destinos configuradas deben usar RuleConfiguredTargetBuilder para construir su valor de devolución. Consta de los siguientes elementos:

  1. Su filesToBuild, el concepto impreciso de "el conjunto de archivos que representa esta regla". Estos son los archivos que se compilan cuando el destino configurado está en la línea de comandos o en los srcs de una genrule.
  2. Sus archivos ejecutables, tanto regulares como de datos
  3. Son los grupos de salida. Estos son varios "otros conjuntos de archivos" que la regla puede compilar. Se puede acceder a ellos con el atributo output_group de la regla filegroup en BUILD y con el proveedor OutputGroupInfo en Java.

Archivos de ejecución

Algunos archivos binarios necesitan archivos de datos para ejecutarse. Un ejemplo destacado son las pruebas que necesitan archivos de entrada. En Bazel, esto se representa con el concepto de "runfiles". Un "árbol de runfiles" es un árbol de directorios de los archivos de datos de un archivo binario en particular. Se crea en el sistema de archivos como un árbol de vínculos simbólicos con vínculos simbólicos individuales que apuntan a los archivos de los árboles de origen o de salida.

Un conjunto de archivos ejecutables se representa como una instancia de Runfiles. Conceptualmente, es un mapa de la ruta de acceso de un archivo en el árbol de archivos de ejecución a la instancia de Artifact que lo representa. Es un poco más complicado que un solo Map por dos motivos:

  • La mayoría de las veces, la ruta de acceso de los archivos de ejecución de un archivo es la misma que su ruta de acceso de ejecución. Usamos esto para ahorrar algo de RAM.
  • También hay varios tipos heredados de entradas en los árboles de runfiles, que también deben representarse.

Los archivos ejecutables se recopilan con RunfilesProvider: Una instancia de esta clase representa los archivos ejecutables que necesita un destino configurado (como una biblioteca) y su cierre transitivo, y se recopilan como un conjunto anidado (de hecho, se implementan con conjuntos anidados de forma interna): Cada destino une los archivos ejecutables de sus dependencias, agrega algunos propios y, luego, envía el conjunto resultante hacia arriba en el gráfico de dependencias. Una instancia de RunfilesProvider contiene dos instancias de Runfiles, una para cuando se depende de la regla a través del atributo "data" y otra para cualquier otro tipo de dependencia entrante. Esto se debe a que, a veces, un destino presenta diferentes archivos ejecutables cuando se depende de él a través de un atributo de datos que de otra manera. Este es un comportamiento heredado no deseado que aún no hemos podido quitar.

Los archivos ejecutables de los archivos binarios se representan como una instancia de RunfilesSupport. Esto es diferente de Runfiles porque RunfilesSupport tiene la capacidad de compilarse (a diferencia de Runfiles, que es solo una asignación). Esto requiere los siguientes componentes adicionales:

  • Es el manifiesto de archivos ejecutables de entrada. Es una descripción serializada del árbol de archivos de ejecución. Se usa como proxy para el contenido del árbol de runfiles, y Bazel supone que el árbol de runfiles cambia si y solo si cambia el contenido del manifiesto.
  • Es el manifiesto de los archivos ejecutables de salida. Las bibliotecas de tiempo de ejecución que controlan los árboles de archivos ejecutables usan este valor, en especial en Windows, que a veces no admite vínculos simbólicos.
  • Argumentos de la línea de comandos para ejecutar el objeto binario cuyos archivos ejecutables representa el objeto RunfilesSupport.

Aspectos

Los aspectos son una forma de "propagar el procesamiento por el gráfico de dependencias". Se describen para los usuarios de Bazel aquí. Un buen ejemplo motivador son los búferes de protocolo: una regla proto_library no debe conocer ningún lenguaje en particular, pero la compilación de la implementación de un mensaje de búfer de protocolo (la "unidad básica" de los búferes de protocolo) en cualquier lenguaje de programación debe estar vinculada a la regla proto_library para que, si dos destinos en el mismo lenguaje dependen del mismo búfer de protocolo, se compile solo una vez.

Al igual que los destinos configurados, se representan en Skyframe como un SkyValue, y la forma en que se construyen es muy similar a la forma en que se compilan los destinos configurados: tienen una clase de fábrica llamada ConfiguredAspectFactory que tiene acceso a un RuleContext, pero, a diferencia de las fábricas de destinos configurados, también conoce el destino configurado al que está adjunta y sus proveedores.

El conjunto de aspectos propagados hacia abajo en el gráfico de dependencias se especifica para cada atributo con la función Attribute.Builder.aspects(). Hay algunas clases con nombres confusos que participan en el proceso:

  1. AspectClass es la implementación del aspecto. Puede estar en Java (en cuyo caso es una subclase) o en Starlark (en cuyo caso es una instancia de StarlarkAspectClass). Es análogo a RuleConfiguredTargetFactory.
  2. AspectDefinition es la definición del aspecto. Incluye los proveedores que requiere y los que proporciona, y contiene una referencia a su implementación, como la instancia AspectClass adecuada. Es análogo a RuleClass.
  3. AspectParameters es una forma de parametrizar un aspecto que se propaga por el gráfico de dependencias. Actualmente, es un mapa de cadena a cadena. Un buen ejemplo de por qué es útil son los búferes de protocolo: si un lenguaje tiene varias APIs, la información sobre para qué API se deben compilar los búferes de protocolo se debe propagar por el gráfico de dependencias.
  4. Aspect representa todos los datos necesarios para calcular un aspecto que se propaga hacia abajo en el gráfico de dependencias. Consta de la clase de aspecto, su definición y sus parámetros.
  5. RuleAspect es la función que determina qué aspectos debe propagar una regla en particular. Es una función Rule -> Aspect.

Una complicación algo inesperada es que los aspectos se pueden adjuntar a otros aspectos. Por ejemplo, un aspecto que recopila la ruta de clase para un IDE de Java probablemente querrá conocer todos los archivos .jar en la ruta de clase, pero algunos de ellos son búferes de protocolo. En ese caso, el aspecto del IDE querrá adjuntarse al par (regla proto_library + aspecto de Java proto).

La complejidad de los aspectos sobre aspectos se captura en la clase AspectCollection.

Plataformas y cadenas de herramientas

Bazel admite compilaciones multiplataforma, es decir, compilaciones en las que puede haber varias arquitecturas en las que se ejecutan las acciones de compilación y varias arquitecturas para las que se compila el código. En Bazel, estas arquitecturas se denominan plataformas (aquí se encuentra la documentación completa).

Una plataforma se describe mediante una asignación de clave-valor de configuración de restricciones (como el concepto de "arquitectura de CPU") a valores de restricciones (como una CPU en particular, como x86_64). Tenemos un "diccionario" de los parámetros de configuración y los valores de restricción que se usan con mayor frecuencia en el repositorio de @platforms.

El concepto de cadena de herramientas proviene del hecho de que, según las plataformas en las que se ejecuta la compilación y las plataformas a las que se dirige, es posible que se deban usar diferentes compiladores. Por ejemplo, una cadena de herramientas de C++ en particular puede ejecutarse en un SO específico y ser capaz de dirigirse a otros SO. Bazel debe determinar el compilador de C++ que se usa según la plataforma de destino y ejecución establecida (aquí se encuentra la documentación de las cadenas de herramientas).

Para ello, las cadenas de herramientas se anotan con el conjunto de restricciones de ejecución y de plataforma de destino que admiten. Para ello, la definición de una cadena de herramientas se divide en dos partes:

  1. Una regla toolchain() que describe el conjunto de restricciones de ejecución y de destino que admite una cadena de herramientas y que indica qué tipo (como C++ o Java) de cadena de herramientas es (esto último se representa con la regla toolchain_type())
  2. Una regla específica del lenguaje que describe la cadena de herramientas real (como cc_toolchain())

Esto se hace de esta manera porque necesitamos conocer las restricciones de cada cadena de herramientas para poder resolver la cadena de herramientas, y las reglas *_toolchain() específicas del idioma contienen mucha más información que eso, por lo que tardan más en cargarse.

Las plataformas de ejecución se especifican de una de las siguientes maneras:

  1. En el archivo MODULE.bazel con la función register_execution_platforms()
  2. En la línea de comandos, con la opción de línea de comandos --extra_execution_platforms

El conjunto de plataformas de ejecución disponibles se calcula en RegisteredExecutionPlatformsFunction .

La plataforma de destino para un destino configurado se determina con PlatformOptions.computeTargetPlatform() . Es una lista de plataformas porque, con el tiempo, queremos admitir varias plataformas de destino, pero aún no se implementó.

El conjunto de cadenas de herramientas que se usarán para un destino configurado se determina con ToolchainResolutionFunction. Es una función de lo siguiente:

  • El conjunto de cadenas de herramientas registradas (en el archivo MODULE.bazel y la configuración)
  • Las plataformas de ejecución y de destino deseadas (en la configuración)
  • Es el conjunto de tipos de cadenas de herramientas que requiere el destino configurado (en UnloadedToolchainContextKey)
  • Es el conjunto de restricciones de la plataforma de ejecución del destino configurado (el atributo exec_compatible_with) y la configuración (--experimental_add_exec_constraints_to_targets), en UnloadedToolchainContextKey.

El resultado es un UnloadedToolchainContext, que es básicamente un mapa del tipo de cadena de herramientas (representado como una instancia de ToolchainTypeInfo) a la etiqueta de la cadena de herramientas seleccionada. Se llama "descargado" porque no contiene las cadenas de herramientas en sí, solo sus etiquetas.

Luego, las cadenas de herramientas se cargan con ResolvedToolchainContext.load() y las usa la implementación del destino configurado que las solicitó.

También tenemos un sistema heredado que se basa en que haya una sola configuración de "host" y que las configuraciones de destino se representen con varias marcas de configuración, como --cpu . Estamos realizando una transición gradual al sistema anterior. Para controlar los casos en los que las personas dependen de los valores de configuración heredados, implementamos asignaciones de plataformas para traducir entre las marcas heredadas y las restricciones de plataformas de estilo nuevo. Su código está en PlatformMappingFunction y usa un "lenguaje pequeño" que no es de Starlark.

Limitaciones

A veces, se desea designar un destino como compatible solo con algunas plataformas. Lamentablemente, Bazel tiene varios mecanismos para lograr este objetivo:

  • Restricciones específicas de la regla
  • environment_group()/environment()
  • Restricciones de la plataforma

Las restricciones específicas de la regla se usan principalmente dentro de Google para las reglas de Java. Están en proceso de desuso y no están disponibles en Bazel, pero el código fuente puede contener referencias a ellas. El atributo que rige esto se llama constraints= .

environment_group() y environment()

Estas reglas son un mecanismo heredado y no se usan de forma generalizada.

Todas las reglas de compilación pueden declarar para qué "entornos" se pueden compilar, donde un "entorno" es una instancia de la regla environment().

Existen varias formas de especificar los entornos admitidos para una regla:

  1. A través del atributo restricted_to= Esta es la forma más directa de especificación, ya que declara el conjunto exacto de entornos que admite la regla.
  2. A través del atributo compatible_with= Esto declara los entornos que admite una regla, además de los entornos "estándar" que se admiten de forma predeterminada.
  3. A través de los atributos a nivel del paquete default_restricted_to= y default_compatible_with=.
  4. A través de especificaciones predeterminadas en las reglas de environment_group() Cada entorno pertenece a un grupo de elementos comparables relacionados temáticamente (como "arquitecturas de CPU", "versiones de JDK" o "sistemas operativos para dispositivos móviles"). La definición de un grupo de entornos incluye cuáles de estos entornos deben ser compatibles de forma "predeterminada" si los atributos restricted_to= o environment() no especifican lo contrario. Una regla sin esos atributos hereda todos los valores predeterminados.
  5. A través de un valor predeterminado de la clase de regla Esto anula los valores predeterminados globales para todas las instancias de la clase de regla determinada. Esto se puede usar, por ejemplo, para que todas las reglas de *_test se puedan probar sin que cada instancia tenga que declarar explícitamente esta capacidad.

environment() se implementa como una regla normal, mientras que environment_group() es tanto una subclase de Target, pero no de Rule (EnvironmentGroup), como una función que está disponible de forma predeterminada en Starlark (StarlarkLibrary.environmentGroup()) y que, finalmente, crea un destino epónimo. Esto se hace para evitar una dependencia cíclica que surgiría porque cada entorno debe declarar el grupo de entornos al que pertenece y cada grupo de entornos debe declarar sus entornos predeterminados.

Una compilación se puede restringir a un entorno determinado con la opción de línea de comandos --target_environment.

La implementación de la verificación de restricciones se encuentra en RuleContextConstraintSemantics y TopLevelConstraintSemantics.

Restricciones de la plataforma

La forma "oficial" actual de describir con qué plataformas es compatible un destino es usar las mismas restricciones que se usan para describir las cadenas de herramientas y las plataformas. Se implementó en la solicitud de extracción #10945.

Visibilidad

Si trabajas en una base de código grande con muchos desarrolladores (como en Google), debes tener cuidado para evitar que todos los demás dependan arbitrariamente de tu código. De lo contrario, según la ley de Hyrum, las personas confiarán en los comportamientos que consideraste detalles de implementación.

Bazel admite esto a través del mecanismo llamado visibilidad: puedes limitar qué destinos pueden depender de un destino en particular con el atributo visibilidad. Este atributo es un poco especial porque, si bien contiene una lista de etiquetas, estas pueden codificar un patrón sobre los nombres de los paquetes en lugar de un puntero a un destino en particular. (Sí, este es un defecto de diseño).

Esto se implementa en los siguientes lugares:

  • La interfaz RuleVisibility representa una declaración de visibilidad. Puede ser una constante (completamente pública o completamente privada) o una lista de etiquetas.
  • Las etiquetas pueden hacer referencia a grupos de paquetes (lista predefinida de paquetes), a paquetes directamente (//pkg:__pkg__) o a subárboles de paquetes (//pkg:__subpackages__). Esto es diferente de la sintaxis de la línea de comandos, que usa //pkg:* o //pkg/....
  • Los grupos de paquetes se implementan como su propio destino (PackageGroup) y destino configurado (PackageGroupConfiguredTarget). Probablemente podríamos reemplazar estos con reglas simples si quisiéramos. Su lógica se implementa con la ayuda de PackageSpecification, que corresponde a un solo patrón como //pkg/...; PackageGroupContents, que corresponde a un solo atributo packages de package_group; y PackageSpecificationProvider, que agrega un package_group y su includes transitivo.
  • La conversión de listas de etiquetas de visibilidad a dependencias se realiza en DependencyResolver.visitTargetVisibility y en algunos otros lugares diversos.
  • La verificación real se realiza en CommonPrerequisiteValidator.validateDirectPrerequisiteVisibility().

Conjuntos anidados

A menudo, un destino configurado agrega un conjunto de archivos de sus dependencias, agrega los suyos propios y encapsula el conjunto agregado en un proveedor de información transitivo para que los destinos configurados que dependen de él puedan hacer lo mismo. Ejemplos:

  • Los archivos de encabezado de C++ que se usan para una compilación
  • Los archivos de objeto que representan el cierre transitivo de un cc_library
  • Es el conjunto de archivos .jar que deben estar en la ruta de acceso a las clases para que se compile o ejecute una regla de Java.
  • Es el conjunto de archivos de Python en el cierre transitivo de una regla de Python.

Si lo hiciéramos de forma ingenua, por ejemplo, con List o Set, terminaríamos con un uso de memoria cuadrático: si hay una cadena de N reglas y cada regla agrega un archivo, tendríamos 1 + 2 + … + N miembros de la colección.

Para solucionar este problema, creamos el concepto de un NestedSet. Es una estructura de datos compuesta por otras instancias de NestedSet y algunos miembros propios, lo que forma un gráfico acíclico dirigido de conjuntos. Son inmutables y sus miembros se pueden iterar. Definimos varios órdenes de iteración (NestedSet.Order): preorden, posorden, topológico (un nodo siempre aparece después de sus ancestros) y "no importa, pero debería ser el mismo cada vez".

La misma estructura de datos se llama depset en Starlark.

Artefactos y acciones

La compilación real consta de un conjunto de comandos que se deben ejecutar para producir el resultado que desea el usuario. Los comandos se representan como instancias de la clase Action y los archivos se representan como instancias de la clase Artifact. Se organizan en un grafo acíclico, dirigido y bipartito llamado "grafo de acción".

Los artefactos son de dos tipos: artefactos de origen (los que están disponibles antes de que Bazel comience la ejecución) y artefactos derivados (los que se deben compilar). Los artefactos derivados pueden ser de varios tipos:

  1. Artefactos regulares: Para verificar que estén actualizados, se calcula su suma de verificación, con mtime como un atajo. No calculamos la suma de verificación del archivo si no cambió su ctime.
  2. Artefactos de vínculos simbólicos sin resolver. Se verifica que estén actualizados llamando a readlink(). A diferencia de los artefactos normales, estos pueden ser vínculos simbólicos colgantes. Se suele usar en casos en los que se empaquetan algunos archivos en un archivo de algún tipo.
  3. Artefactos de árbol. No son archivos individuales, sino árboles de directorios. Para verificar si están actualizados, se revisa el conjunto de archivos que contienen y su contenido. Se representan como un TreeArtifact.
  4. Artefactos de metadatos constantes. Los cambios en estos artefactos no activan una recompilación. Esto se usa exclusivamente para la información de la marca de compilación: no queremos volver a compilar solo porque cambió la hora actual.

No hay ninguna razón fundamental por la que los artefactos fuente no puedan ser artefactos de árbol o artefactos de vínculos simbólicos sin resolver. Simplemente, aún no lo implementamos (aunque deberíamos hacerlo: hacer referencia a un directorio fuente en un archivo BUILD es uno de los pocos problemas de incorrección conocidos y de larga data con Bazel; tenemos una implementación que funciona de alguna manera y que se habilita con la propiedad de JVM BAZEL_TRACK_SOURCE_DIRECTORIES=1).

Las acciones se comprenden mejor como un comando que se debe ejecutar, el entorno que necesita y el conjunto de resultados que produce. Los siguientes elementos son los componentes principales de la descripción de una acción:

  • La línea de comandos que se debe ejecutar
  • Los artefactos de entrada que necesita
  • Las variables de entorno que se deben establecer
  • Anotaciones que describen el entorno (como la plataforma) en el que debe ejecutarse \

También hay algunos otros casos especiales, como escribir un archivo cuyo contenido Bazel conoce. Son una subclase de AbstractAction. La mayoría de las acciones son SpawnAction o StarlarkAction (lo mismo, podría decirse que no deberían ser clases separadas), aunque Java y C++ tienen sus propios tipos de acciones (JavaCompileAction, CppCompileAction y CppLinkAction).

Eventualmente, queremos migrar todo a SpawnAction. JavaCompileAction está bastante cerca, pero C++ es un caso especial debido al análisis de archivos .d y el análisis de inclusión.

El gráfico de acción se encuentra, en su mayor parte, "incorporado" en el gráfico de Skyframe: conceptualmente, la ejecución de una acción se representa como una invocación de ActionExecutionFunction. La asignación de un borde de dependencia del gráfico de acción a un borde de dependencia de Skyframe se describe en ActionExecutionFunction.getInputDeps() y Artifact.key(), y tiene algunas optimizaciones para mantener bajo el número de bordes de Skyframe:

  • Los artefactos derivados no tienen sus propios SkyValue. En su lugar, se usa Artifact.getGeneratingActionKey() para averiguar la clave de la acción que la genera.
  • Los conjuntos anidados tienen su propia clave de Skyframe.

Acciones compartidas

Algunas acciones se generan a partir de varios destinos configurados. Las reglas de Starlark son más limitadas, ya que solo pueden colocar sus acciones derivadas en un directorio determinado por su configuración y su paquete (pero, aun así, las reglas del mismo paquete pueden entrar en conflicto), pero las reglas implementadas en Java pueden colocar artefactos derivados en cualquier lugar.

Esto se considera una función defectuosa, pero deshacerse de ella es muy difícil porque produce ahorros significativos en el tiempo de ejecución cuando, por ejemplo, un archivo fuente debe procesarse de alguna manera y varias reglas hacen referencia a ese archivo (explicación vaga). Esto tiene un costo de RAM: cada instancia de una acción compartida debe almacenarse en la memoria por separado.

Si dos acciones generan el mismo archivo de salida, deben ser exactamente iguales: tener las mismas entradas, las mismas salidas y ejecutar la misma línea de comandos. Esta relación de equivalencia se implementa en Actions.canBeShared() y se verifica entre las fases de análisis y ejecución observando cada acción. Esto se implementa en SkyframeActionExecutor.findAndStoreArtifactConflicts() y es uno de los pocos lugares en Bazel que requiere una vista "global" de la compilación.

La fase de ejecución

Es cuando Bazel comienza a ejecutar acciones de compilación, como comandos que producen resultados.

Lo primero que hace Bazel después de la fase de análisis es determinar qué artefactos se deben compilar. La lógica para esto se codifica en TopLevelArtifactHelper; en términos generales, es el filesToBuild de los destinos configurados en la línea de comandos y el contenido de un grupo de salida especial con el propósito explícito de expresar "si este destino está en la línea de comandos, compila estos artefactos".

El siguiente paso es crear la raíz de ejecución. Dado que Bazel tiene la opción de leer paquetes de origen desde diferentes ubicaciones del sistema de archivos (--package_path), debe proporcionar acciones ejecutadas de forma local con un árbol de origen completo. La clase SymlinkForest controla esto y funciona registrando cada destino utilizado en la fase de análisis y creando un solo árbol de directorios que vincula simbólicamente cada paquete con un destino utilizado desde su ubicación real. Una alternativa sería pasar las rutas correctas a los comandos (teniendo en cuenta --package_path). Esto no es conveniente por los siguientes motivos:

  • Cambia las líneas de comandos de acción cuando un paquete se mueve de una entrada de ruta de acceso del paquete a otra (solía ser un evento común).
  • Se generan diferentes líneas de comandos si una acción se ejecuta de forma remota que si se ejecuta de forma local.
  • Requiere una transformación de línea de comandos específica para la herramienta en uso (considera la diferencia entre las rutas de acceso de clases de Java y las rutas de acceso de inclusión de C++)
  • Cambiar la línea de comandos de una acción invalida su entrada de caché de acción
  • --package_path se está dejando de usar de forma lenta y constante

Luego, Bazel comienza a recorrer el gráfico de acciones (el gráfico bipartito y dirigido compuesto por acciones y sus artefactos de entrada y salida) y a ejecutar acciones. La ejecución de cada acción se representa con una instancia de la clase SkyValue ActionExecutionValue.

Dado que ejecutar una acción es costoso, tenemos algunas capas de almacenamiento en caché que se pueden alcanzar detrás de Skyframe:

  • ActionExecutionFunction.stateMap contiene datos para que los reinicios de Skyframe de ActionExecutionFunction sean económicos.
  • La caché de acciones locales contiene datos sobre el estado del sistema de archivos
  • Por lo general, los sistemas de ejecución remota también contienen su propia caché.

La caché de acciones locales

Esta caché es otra capa que se encuentra detrás de Skyframe. Incluso si una acción se vuelve a ejecutar en Skyframe, puede seguir siendo un acierto en la caché de acciones local. Representa el estado del sistema de archivos local y se serializa en el disco, lo que significa que, cuando se inicia un nuevo servidor de Bazel, se pueden obtener aciertos de la caché de acciones local, aunque el gráfico de Skyframe esté vacío.

Se verifica si hay aciertos en esta caché con el método ActionCacheChecker.getTokenIfNeedToExecute() .

A diferencia de su nombre, es un mapa de la ruta de acceso de un artefacto derivado a la acción que lo emitió. La acción se describe de la siguiente manera:

  1. El conjunto de sus archivos de entrada y salida, y su suma de verificación
  2. Su "clave de acción", que suele ser la línea de comandos que se ejecutó, pero, en general, representa todo lo que no se captura con la suma de verificación de los archivos de entrada (por ejemplo, para FileWriteAction, es la suma de verificación de los datos que se escriben)

También hay una "caché de acciones descendente" altamente experimental que aún está en desarrollo y que usa hashes transitivos para evitar ir a la caché tantas veces.

Descubrimiento y poda de entradas

Algunas acciones son más complicadas que solo tener un conjunto de entradas. Los cambios en el conjunto de entradas de una acción se presentan de dos formas:

  • Una acción puede descubrir nuevas entradas antes de su ejecución o decidir que algunas de sus entradas no son necesarias. El ejemplo canónico es C++, en el que es mejor hacer una suposición fundamentada sobre qué archivos de encabezado usa un archivo de C++ a partir de su cierre transitivo para no tener que enviar cada archivo a ejecutores remotos. Por lo tanto, tenemos la opción de no registrar cada archivo de encabezado como una "entrada", sino analizar el archivo fuente en busca de encabezados incluidos de forma transitiva y marcar solo aquellos archivos de encabezado como entradas que se mencionan en las instrucciones #include (sobreestimamos para no tener que implementar un preprocesador de C completo). Actualmente, esta opción está codificada como "false" en Bazel y solo se usa en Google.
  • Una acción puede darse cuenta de que no se usaron algunos archivos durante su ejecución. En C++, se denominan "archivos .d": el compilador indica qué archivos de encabezado se usaron después del hecho y, para evitar la vergüenza de tener una incrementalidad peor que Make, Bazel aprovecha este hecho. Esto ofrece una mejor estimación que el analizador de inclusión, ya que se basa en el compilador.

Estos se implementan con métodos en Action:

  1. Se llama a Action.discoverInputs(). Debe devolver un conjunto anidado de artefactos que se determinan como obligatorios. Estos deben ser artefactos de origen para que no haya bordes de dependencia en el gráfico de acción que no tengan un equivalente en el gráfico de destino configurado.
  2. La acción se ejecuta llamando a Action.execute().
  3. Al final de Action.execute(), la acción puede llamar a Action.updateInputs() para indicarle a Bazel que no se necesitaron todas sus entradas. Esto puede generar compilaciones incrementales incorrectas si una entrada utilizada se informa como no utilizada.

Cuando una caché de acciones devuelve un acierto en una instancia de Action nueva (como la que se crea después de reiniciar el servidor), Bazel llama a updateInputs() por sí mismo para que el conjunto de entradas refleje el resultado de la detección y la poda de entradas que se realizaron antes.

Las acciones de Starlark pueden usar la función para declarar algunas entradas como no utilizadas con el argumento unused_inputs_list= de ctx.actions.run().

Varias formas de ejecutar acciones: Strategies/ActionContexts

Algunas acciones se pueden ejecutar de diferentes maneras. Por ejemplo, una línea de comandos se puede ejecutar de forma local, de forma local pero en varios tipos de sandbox o de forma remota. El concepto que encarna esto se llama ActionContext (o Strategy, ya que solo llegamos a la mitad con el cambio de nombre...).

El ciclo de vida de un contexto de acción es el siguiente:

  1. Cuando se inicia la fase de ejecución, se les pregunta a las instancias de BlazeModule qué contextos de acción tienen. Esto sucede en el constructor de ExecutionTool. Los tipos de contexto de acción se identifican con una instancia de Class de Java que hace referencia a una subinterfaz de ActionContext y a la interfaz que debe implementar el contexto de acción.
  2. El contexto de acción adecuado se selecciona entre los disponibles y se reenvía a ActionExecutionContext y BlazeExecutor .
  3. Contextos de solicitudes de acciones con ActionExecutionContext.getContext() y BlazeExecutor.getStrategy() (en realidad, debería haber una sola forma de hacerlo…)

Las estrategias pueden llamar a otras estrategias para que realicen sus trabajos. Esto se usa, por ejemplo, en la estrategia dinámica que inicia acciones de forma local y remota, y luego usa la que finaliza primero.

Una estrategia destacada es la que implementa procesos de trabajadores persistentes (WorkerSpawnStrategy). La idea es que algunas herramientas tienen un tiempo de inicio prolongado y, por lo tanto, se deben reutilizar entre acciones en lugar de iniciar una nueva para cada acción (esto representa un posible problema de corrección, ya que Bazel se basa en la promesa del proceso de trabajador de que no lleva un estado observable entre solicitudes individuales).

Si la herramienta cambia, se debe reiniciar el proceso de trabajo. Para determinar si se puede volver a usar un trabajador, se calcula una suma de verificación para la herramienta que se usa con WorkerFilesHash. Se basa en saber qué entradas de la acción representan parte de la herramienta y cuáles representan entradas. Esto lo determina el creador de la acción: Spawn.getToolFiles() y los archivos ejecutables de Spawn se consideran partes de la herramienta.

Más información sobre las estrategias (o los contextos de acción):

  • Puedes encontrar información sobre varias estrategias para ejecutar acciones aquí.
  • Aquí puedes encontrar información sobre la estrategia dinámica, en la que ejecutamos una acción de forma local y remota para ver cuál finaliza primero.
  • Puedes encontrar información sobre las complejidades de ejecutar acciones de forma local aquí.

El administrador de recursos locales

Bazel puede ejecutar muchas acciones en paralelo. La cantidad de acciones locales que deben ejecutarse en paralelo difiere de una acción a otra: cuanta más cantidad de recursos requiera una acción, menos instancias deberían ejecutarse al mismo tiempo para evitar sobrecargar la máquina local.

Esto se implementa en la clase ResourceManager: cada acción se debe anotar con una estimación de los recursos locales que requiere en forma de una instancia de ResourceSet (CPU y RAM). Luego, cuando los contextos de acción hacen algo que requiere recursos locales, llaman a ResourceManager.acquireResources() y se bloquean hasta que los recursos requeridos estén disponibles.

Aquí puedes encontrar una descripción más detallada de la administración de recursos locales.

Estructura del directorio de salida

Cada acción requiere un lugar separado en el directorio de salida donde coloca sus resultados. Por lo general, la ubicación de los artefactos derivados es la siguiente:

$EXECROOT/bazel-out/<configuration>/bin/<package>/<artifact name>

¿Cómo se determina el nombre del directorio asociado a una configuración en particular? Hay dos propiedades deseables en conflicto:

  1. Si dos configuraciones pueden ocurrir en la misma compilación, deben tener directorios diferentes para que ambas puedan tener su propia versión de la misma acción. De lo contrario, si las dos configuraciones no coinciden en aspectos como la línea de comandos de una acción que produce el mismo archivo de salida, Bazel no sabe qué acción elegir (un "conflicto de acción").
  2. Si dos configuraciones representan "aproximadamente" lo mismo, deben tener el mismo nombre para que las acciones ejecutadas en una se puedan reutilizar en la otra si las líneas de comandos coinciden. Por ejemplo, los cambios en las opciones de la línea de comandos del compilador de Java no deben provocar que se vuelvan a ejecutar las acciones de compilación de C++.

Hasta el momento, no hemos encontrado una forma fundamentada de resolver este problema, que tiene similitudes con el problema de la reducción de la configuración. Puedes encontrar una explicación más detallada de las opciones aquí. Las principales áreas problemáticas son las reglas de Starlark (cuyos autores no suelen estar muy familiarizados con Bazel) y los aspectos, que agregan otra dimensión al espacio de elementos que pueden producir el "mismo" archivo de salida.

El enfoque actual es que el segmento de ruta de acceso para la configuración es <CPU>-<compilation mode> con varios sufijos agregados para que las transiciones de configuración implementadas en Java no generen conflictos de acciones. Además, se agrega una suma de verificación del conjunto de transiciones de configuración de Starlark para que los usuarios no puedan causar conflictos de acciones. Está lejos de ser perfecto. Esto se implementa en OutputDirectories.buildMnemonic() y se basa en que cada fragmento de configuración agrega su propia parte al nombre del directorio de salida.

Pruebas

Bazel ofrece una gran compatibilidad para ejecutar pruebas. Es compatible con:

  • Ejecutar pruebas de forma remota (si hay un backend de ejecución remota disponible)
  • Ejecutar pruebas varias veces en paralelo (para corregir la inestabilidad o recopilar datos de sincronización)
  • Fragmentación de pruebas (división de casos de prueba en la misma prueba en varios procesos para aumentar la velocidad)
  • Cómo volver a ejecutar pruebas inestables
  • Cómo agrupar pruebas en paquetes de pruebas

Las pruebas son destinos configurados normales que tienen un TestProvider, que describe cómo se debe ejecutar la prueba:

  • Son los artefactos cuya compilación genera la ejecución de la prueba. Este es un archivo de "estado de caché" que contiene un mensaje TestResultData serializado.
  • Cantidad de veces que se debe ejecutar la prueba
  • Cantidad de fragmentos en los que se debe dividir la prueba
  • Algunos parámetros sobre cómo se debe ejecutar la prueba (como el tiempo de espera de la prueba)

Cómo determinar qué pruebas ejecutar

Determinar qué pruebas se ejecutan es un proceso elaborado.

Primero, durante el análisis del patrón de destino, los conjuntos de pruebas se expanden de forma recursiva. La expansión se implementa en TestsForTargetPatternFunction. Un detalle algo sorprendente es que, si un paquete de pruebas no declara ninguna prueba, se refiere a todas las pruebas de su paquete. Esto se implementa en Package.beforeBuild() agregando un atributo implícito llamado $implicit_tests a las reglas del conjunto de pruebas.

Luego, las pruebas se filtran por tamaño, etiquetas, tiempo de espera y lenguaje según las opciones de la línea de comandos. Esto se implementa en TestFilter y se llama desde TargetPatternPhaseFunction.determineTests() durante el análisis del destino. El resultado se coloca en TargetPatternPhaseValue.getTestsToRunLabels(). El motivo por el que no se pueden configurar los atributos de regla por los que se puede filtrar es que esto sucede antes de la fase de análisis, por lo que la configuración no está disponible.

Luego, se procesa aún más en BuildView.createResult(): se filtran los destinos cuyo análisis falló y las pruebas se dividen en pruebas exclusivas y no exclusivas. Luego, se coloca en AnalysisResult, que es la forma en que ExecutionTool sabe qué pruebas ejecutar.

Para brindar cierta transparencia a este proceso elaborado, el operador de consultas tests() (implementado en TestsFunction) está disponible para indicar qué pruebas se ejecutan cuando se especifica un destino en particular en la línea de comandos. Lamentablemente, es una reimplementación, por lo que probablemente se desvíe de lo anterior de varias maneras sutiles.

Cómo ejecutar pruebas

Las pruebas se ejecutan solicitando artefactos de estado de la caché. Esto genera la ejecución de un TestRunnerAction, que, finalmente, llama al TestActionContext elegido por la opción de línea de comandos --test_strategy que ejecuta la prueba de la manera solicitada.

Las pruebas se ejecutan según un protocolo elaborado que usa variables de entorno para indicarles a las pruebas lo que se espera de ellas. Aquí puedes encontrar una descripción detallada de lo que Bazel espera de las pruebas y lo que las pruebas pueden esperar de Bazel. En el caso más simple, un código de salida de 0 significa éxito y cualquier otro valor significa error.

Además del archivo de estado de la caché, cada proceso de prueba emite varios archivos más. Se colocan en el "directorio de registro de pruebas", que es el subdirectorio llamado testlogs del directorio de salida de la configuración de destino:

  • test.xml, un archivo en formato XML de estilo JUnit que detalla los casos de prueba individuales en la partición de prueba
  • test.log, la salida de la consola de la prueba. stdout y stderr no están separados.
  • test.outputs, el "directorio de resultados no declarados"; lo usan las pruebas que desean generar archivos además de lo que imprimen en la terminal.

Hay dos cosas que pueden suceder durante la ejecución de pruebas que no pueden suceder durante la compilación de destinos regulares: la ejecución exclusiva de pruebas y la transmisión de resultados.

Algunas pruebas deben ejecutarse en modo exclusivo, por ejemplo, no en paralelo con otras pruebas. Esto se puede obtener agregando tags=["exclusive"] a la regla de prueba o ejecutando la prueba con --test_strategy=exclusive . Cada prueba exclusiva se ejecuta con una invocación de Skyframe independiente que solicita la ejecución de la prueba después de la compilación "principal". Esto se implementa en SkyframeExecutor.runExclusiveTest().

A diferencia de las acciones normales, cuyo resultado terminal se vuelca cuando finaliza la acción, el usuario puede solicitar que se transmita el resultado de las pruebas para que se le informe sobre el progreso de una prueba de larga duración. Esto se especifica con la opción de línea de comandos --test_output=streamed y supone una ejecución de prueba exclusiva para que no se intercalen los resultados de diferentes pruebas.

Esto se implementa en la clase StreamedTestOutput, cuyo nombre es apropiado, y funciona sondeo los cambios en el archivo test.log de la prueba en cuestión y volcando nuevos bytes en la terminal donde rigen las reglas de Bazel.

Los resultados de las pruebas ejecutadas están disponibles en el bus de eventos observando varios eventos (como TestAttempt, TestResult o TestingCompleteEvent). Se vuelcan en el Build Event Protocol y AggregatingTestListener los emite en la consola.

Recopilación de cobertura

Las pruebas informan la cobertura en formato LCOV en los archivos bazel-testlogs/$PACKAGE/$TARGET/coverage.dat .

Para recopilar la cobertura, cada ejecución de prueba se incluye en un wrapper en una secuencia de comandos llamada collect_coverage.sh .

Esta secuencia de comandos configura el entorno de la prueba para habilitar la recopilación de cobertura y determinar dónde los tiempos de ejecución de cobertura escriben los archivos de cobertura. Luego, ejecuta la prueba. Una prueba puede ejecutar varios subprocesos y constar de partes escritas en varios lenguajes de programación diferentes (con tiempos de ejecución de recopilación de cobertura separados). La secuencia de comandos de wrapper es responsable de convertir los archivos resultantes al formato LCOV si es necesario y de combinarlos en un solo archivo.

La interposición de collect_coverage.sh se realiza a través de las estrategias de prueba y requiere que collect_coverage.sh se encuentre en las entradas de la prueba. Esto se logra con el atributo implícito :coverage_support, que se resuelve en el valor de la marca de configuración --coverage_support (consulta TestConfiguration.TestOptions.coverageSupport).

Algunos lenguajes realizan la instrumentación sin conexión, lo que significa que la instrumentación de cobertura se agrega en el momento de la compilación (como C++), y otros realizan la instrumentación en línea, lo que significa que la instrumentación de cobertura se agrega en el momento de la ejecución.

Otro concepto central es la cobertura de referencia. Es la cobertura de una biblioteca, un archivo binario o una prueba si no se ejecutó ningún código en ellos. El problema que resuelve es que, si deseas calcular la cobertura de pruebas para un objeto binario, no es suficiente con combinar la cobertura de todas las pruebas, ya que puede haber código en el objeto binario que no esté vinculado a ninguna prueba. Por lo tanto, lo que hacemos es emitir un archivo de cobertura para cada archivo binario que contiene solo los archivos para los que recopilamos la cobertura sin líneas cubiertas. El archivo de cobertura de referencia predeterminado para un destino se encuentra en bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat, pero se recomienda que las reglas generen sus propios archivos de cobertura de referencia con contenido más significativo que solo los nombres de los archivos fuente.

Realizamos un seguimiento de dos grupos de archivos para la recopilación de cobertura de cada regla: el conjunto de archivos instrumentados y el conjunto de archivos de metadatos de instrumentación.

El conjunto de archivos instrumentados es solo eso, un conjunto de archivos para instrumentar. En el caso de los tiempos de ejecución de cobertura en línea, se puede usar en el tiempo de ejecución para decidir qué archivos se deben instrumentar. También se usa para implementar la cobertura de referencia.

El conjunto de archivos de metadatos de instrumentación es el conjunto de archivos adicionales que una prueba necesita para generar los archivos LCOV que Bazel requiere de ella. En la práctica, esto consiste en archivos específicos del tiempo de ejecución; por ejemplo, gcc emite archivos .gcno durante la compilación. Se agregan al conjunto de entradas de las acciones de prueba si el modo de cobertura está habilitado.

Si se recopila o no la cobertura, se almacena en BuildConfiguration. Esto es útil porque es una forma sencilla de cambiar la acción de prueba y el gráfico de acción según este bit, pero también significa que, si se cambia este bit, todos los destinos deben volver a analizarse (algunos lenguajes, como C++, requieren diferentes opciones de compilador para emitir código que pueda recopilar la cobertura, lo que mitiga este problema en cierta medida, ya que, de todos modos, se necesita un nuevo análisis).

Los archivos de compatibilidad con la cobertura dependen de etiquetas en una dependencia implícita, de modo que la política de invocación pueda anularlos, lo que permite que difieran entre las diferentes versiones de Bazel. Lo ideal sería que se quitaran estas diferencias y que se estandarizara una de ellas.

También generamos un "informe de cobertura" que combina la cobertura recopilada para cada prueba en una invocación de Bazel. CoverageReportActionFactory controla esto y se llama desde BuildView.createResult() . Obtiene acceso a las herramientas que necesita consultando el atributo :coverage_report_generator de la primera prueba que se ejecuta.

El motor de consultas

Bazel tiene un lenguaje pequeño que se usa para preguntarle varias cosas sobre varios gráficos. Se proporcionan los siguientes tipos de consultas:

  • bazel query se usa para investigar el gráfico objetivo
  • bazel cquery se usa para investigar el grafo de destino configurado.
  • bazel aquery se usa para investigar el gráfico de acción.

Cada uno de estos se implementa creando una subclase de AbstractBlazeQueryEnvironment. Se pueden realizar funciones de consulta adicionales adicionales creando una subclase de QueryFunction . Para permitir la transmisión de los resultados de la consulta, en lugar de recopilarlos en alguna estructura de datos, se pasa un query2.engine.Callback a QueryFunction, que lo llama para los resultados que desea devolver.

El resultado de una consulta se puede emitir de varias maneras: etiquetas, etiquetas y clases de reglas, XML, Protobuf, etcétera. Se implementan como subclases de OutputFormatter.

Un requisito sutil de algunos formatos de salida de consultas (proto, sin duda) es que Bazel debe emitir _toda_ la información que proporciona la carga de paquetes para que se pueda comparar la salida y determinar si un destino en particular cambió. Como consecuencia, los valores de los atributos deben ser serializables, por lo que solo hay unos pocos tipos de atributos sin que ninguno tenga valores complejos de Starlark. La solución alternativa habitual es usar una etiqueta y adjuntar la información compleja a la regla con esa etiqueta. No es una solución alternativa muy satisfactoria, y sería muy bueno levantar este requisito.

El sistema de módulos

Bazel se puede extender agregándole módulos. Cada módulo debe crear una subclase de BlazeModule (el nombre es una reliquia de la historia de Bazel, cuando se llamaba Blaze) y obtiene información sobre varios eventos durante la ejecución de un comando.

Se usan principalmente para implementar varias partes de la funcionalidad "no central" que solo necesitan algunas versiones de Bazel (como la que usamos en Google):

  • Interfaces para sistemas de ejecución remota
  • Nuevos comandos

El conjunto de puntos de extensión que ofrece BlazeModule es algo aleatorio. No lo uses como ejemplo de buenos principios de diseño.

El bus de eventos

La principal forma en que los BlazeModules se comunican con el resto de Bazel es a través de un bus de eventos (EventBus): se crea una instancia nueva para cada compilación, varias partes de Bazel pueden publicar eventos en él y los módulos pueden registrar objetos de escucha para los eventos que les interesan. Por ejemplo, los siguientes elementos se representan como eventos:

  • Se determinó la lista de destinos de compilación (TargetParsingCompleteEvent).
  • Se determinaron los parámetros de configuración de nivel superior (BuildConfigurationEvent).
  • Se compiló un destino, ya sea correctamente o no (TargetCompleteEvent).
  • Se ejecutó una prueba (TestAttempt, TestSummary).

Algunos de estos eventos se representan fuera de Bazel en el Protocolo de eventos de compilación (son BuildEvents). Esto permite que no solo los BlazeModules, sino también los elementos fuera del proceso de Bazel observen la compilación. Se puede acceder a ellos como un archivo que contiene mensajes de protocolo o Bazel puede conectarse a un servidor (llamado Build Event Service) para transmitir eventos.

Esto se implementa en los paquetes build.lib.buildeventservice y build.lib.buildeventstream de Java.

Repositorios externos

Si bien Bazel se diseñó originalmente para usarse en un monorepo (un solo árbol de fuentes que contiene todo lo que se necesita para compilar), Bazel existe en un mundo en el que esto no siempre es cierto. Los "repositorios externos" son una abstracción que se usa para unir estos dos mundos: representan el código que es necesario para la compilación, pero que no está en el árbol de origen principal.

El archivo WORKSPACE

El conjunto de repositorios externos se determina analizando el archivo WORKSPACE. Por ejemplo, una declaración como esta:

    local_repository(name="foo", path="/foo/bar")

Los resultados están disponibles en el repositorio llamado @foo. La complejidad radica en que se pueden definir reglas de repositorio nuevas en archivos de Starlark, que luego se pueden usar para cargar código de Starlark nuevo, que se puede usar para definir reglas de repositorio nuevas, y así sucesivamente…

Para controlar este caso, el análisis del archivo WORKSPACE (en WorkspaceFileFunction) se divide en fragmentos delimitados por instrucciones load(). El índice de fragmento se indica con WorkspaceFileKey.getIndex(), y calcular WorkspaceFileFunction hasta el índice X significa evaluarlo hasta la instrucción load() número X.

Recuperando repositorios

Antes de que el código del repositorio esté disponible para Bazel, se debe recuperar. Esto hace que Bazel cree un directorio en $OUTPUT_BASE/external/<repository name>.

La recuperación del repositorio se realiza en los siguientes pasos:

  1. PackageLookupFunction se da cuenta de que necesita un repositorio y crea un RepositoryName como un SkyKey, que invoca RepositoryLoaderFunction
  2. RepositoryLoaderFunction reenvía la solicitud a RepositoryDelegatorFunction por motivos poco claros (el código dice que es para evitar volver a descargar elementos en caso de reinicios de Skyframe, pero no es un razonamiento muy sólido).
  3. RepositoryDelegatorFunction itera sobre los fragmentos del archivo WORKSPACE hasta que encuentra el repositorio solicitado para averiguar la regla del repositorio que se le pide que recupere.
  4. Se encuentra el RepositoryFunction adecuado que implementa la recuperación del repositorio; es la implementación de Starlark del repositorio o un mapa codificado de forma rígida para los repositorios que se implementan en Java.

Existen varias capas de almacenamiento en caché, ya que recuperar un repositorio puede ser muy costoso:

  1. Existe una caché para los archivos descargados que se indexa por su suma de verificación (RepositoryCache). Esto requiere que la suma de verificación esté disponible en el archivo WORKSPACE, pero eso es bueno para la hermeticidad de todos modos. Todas las instancias del servidor de Bazel en la misma estación de trabajo comparten este directorio, independientemente del espacio de trabajo o de la base de salida en la que se ejecuten.
  2. Se escribe un "archivo de marcador" para cada repositorio en $OUTPUT_BASE/external que contiene una suma de verificación de la regla que se usó para recuperarlo. Si se reinicia el servidor de Bazel, pero la suma de verificación no cambia, no se vuelve a recuperar. Esto se implementa en RepositoryDelegatorFunction.DigestWriter .
  3. La opción de línea de comandos --distdir designa otra caché que se usa para buscar artefactos que se descargarán. Esto es útil en la configuración empresarial, en la que Bazel no debe recuperar elementos aleatorios de Internet. DownloadManager implementa esta función .

Una vez que se descarga un repositorio, los artefactos que contiene se tratan como artefactos de origen. Esto plantea un problema porque Bazel suele verificar la actualización de los artefactos fuente llamando a stat() en ellos, y estos artefactos también se invalidan cuando cambia la definición del repositorio en el que se encuentran. Por lo tanto, los FileStateValues de un artefacto en un repositorio externo deben depender de su repositorio externo. ExternalFilesHelper se encarga de esto.

Asignaciones de repositorios

Puede suceder que varios repositorios quieran depender del mismo repositorio, pero en diferentes versiones (este es un caso del "problema de dependencia de diamante"). Por ejemplo, si dos archivos binarios en repositorios separados de la compilación quieren depender de Guava, presumiblemente ambos harán referencia a Guava con etiquetas que comiencen con @guava// y esperarán que eso signifique diferentes versiones de ella.

Por lo tanto, Bazel permite reasignar etiquetas de repositorios externos para que la cadena @guava// pueda hacer referencia a un repositorio de Guava (como @guava1//) en el repositorio de un archivo binario y a otro repositorio de Guava (como @guava2//) en el repositorio del otro.

Como alternativa, también se puede usar para unir diamantes. Si un repositorio depende de @guava1// y otro depende de @guava2//, la asignación de repositorios permite reasignar ambos repositorios para usar un repositorio canónico @guava//.

La asignación se especifica en el archivo WORKSPACE como el atributo repo_mapping de las definiciones de repositorios individuales. Luego, aparece en Skyframe como miembro de WorkspaceFileValue, donde se conecta a lo siguiente:

  • Package.Builder.repositoryMapping, que se usa para transformar los atributos con valores de etiqueta de las reglas del paquete con RuleClass.populateRuleAttributeValues()
  • Package.repositoryMapping, que se usa en la fase de análisis (para resolver elementos como $(location), que no se analizan en la fase de carga)
  • BzlLoadFunction para resolver etiquetas en instrucciones load()

Bits de JNI

El servidor de Bazel está escrito principalmente en Java. La excepción son las partes que Java no puede hacer por sí mismo o no podía hacer por sí mismo cuando lo implementamos. Esto se limita principalmente a la interacción con el sistema de archivos, el control de procesos y otras tareas de bajo nivel.

El código C++ se encuentra en src/main/native, y las clases Java con métodos nativos son las siguientes:

  • NativePosixFiles y NativePosixFileSystem
  • ProcessUtils
  • WindowsFileOperations y WindowsFileProcesses
  • com.google.devtools.build.lib.platform

Resultado de la consola

Emitir resultados en la consola parece algo simple, pero la combinación de ejecutar varios procesos (a veces de forma remota), el almacenamiento en caché detallado, el deseo de tener un resultado de terminal agradable y colorido, y tener un servidor de ejecución prolongada lo hacen no trivial.

Inmediatamente después de que llega la llamada a RPC del cliente, se crean dos instancias de RpcOutputStream (para stdout y stderr) que reenvían los datos impresos en ellas al cliente. Luego, se unen en un OutErr (un par (stdout, stderr)). Todo lo que se debe imprimir en la consola pasa por estos flujos. Luego, estos flujos se entregan a BlazeCommandDispatcher.execExclusively().

De forma predeterminada, la salida se imprime con secuencias de escape ANSI. Cuando no se desean (--color=no), un AnsiStrippingOutputStream los quita. Además, System.out y System.err se redireccionan a estos flujos de salida. Esto permite que la información de depuración se imprima con System.err.println() y, aun así, termine en el resultado de la terminal del cliente (que es diferente del del servidor). Se tiene cuidado de que, si un proceso produce una salida binaria (como bazel query --output=proto), no se realice ninguna manipulación de stdout.

Los mensajes cortos (errores, advertencias y similares) se expresan a través de la interfaz EventHandler. Cabe destacar que son diferentes de lo que se publica en EventBus (esto es confuso). Cada Event tiene un EventKind (error, advertencia, información y algunos otros) y puede tener un Location (el lugar en el código fuente que provocó el evento).

Algunas implementaciones de EventHandler almacenan los eventos que recibieron. Se usa para reproducir información en la IU causada por varios tipos de procesamiento almacenado en caché, por ejemplo, las advertencias que emite un destino configurado almacenado en caché.

Algunos EventHandlers también permiten publicar eventos que, con el tiempo, llegan al bus de eventos (los Events normales _no_ aparecen allí). Estas son implementaciones de ExtendedEventHandler y su uso principal es reproducir eventos EventBus almacenados en caché. Todos estos eventos EventBus implementan Postable, pero no todo lo que se publica en EventBus necesariamente implementa esta interfaz; solo aquellos que se almacenan en caché mediante un ExtendedEventHandler (sería bueno y la mayoría de las cosas lo hacen, aunque no se aplica).

La salida de la terminal se emite principalmente a través de UiEventHandler, que es responsable de todo el formato de salida sofisticado y los informes de progreso que realiza Bazel. Tiene dos entradas:

  • El bus de eventos
  • El flujo de eventos canalizado a través de Reporter

La única conexión directa que la maquinaria de ejecución de comandos (por ejemplo, el resto de Bazel) tiene con el flujo de RPC al cliente es a través de Reporter.getOutErr(), que permite el acceso directo a estos flujos. Solo se usa cuando un comando necesita volcar grandes cantidades de datos binarios posibles (como bazel query).

Cómo crear perfiles de Bazel

Bazel es rápido. Bazel también es lento, ya que las compilaciones tienden a crecer hasta el límite de lo tolerable. Por este motivo, Bazel incluye un generador de perfiles que se puede usar para generar perfiles de compilaciones y de Bazel. Se implementa en una clase que se denomina Profiler de forma adecuada. Está activado de forma predeterminada, aunque solo registra datos abreviados para que su sobrecarga sea tolerable. La línea de comandos --record_full_profiler_data hace que se registre todo lo que se pueda.

Emite un perfil en el formato del generador de perfiles de Chrome, que se ve mejor en Chrome. Su modelo de datos es el de pilas de tareas: se pueden iniciar y finalizar tareas, y se supone que están anidadas de forma ordenada entre sí. Cada subproceso de Java obtiene su propia pila de tareas. TODO: ¿Cómo funciona esto con las acciones y el estilo de paso de continuación?

El generador de perfiles se inicia y se detiene en BlazeRuntime.initProfiler() y BlazeRuntime.afterCommand(), respectivamente, y trata de estar activo el mayor tiempo posible para que podamos generar perfiles de todo. Para agregar algo al perfil, llama a Profiler.instance().profile(). Devuelve un Closeable, cuyo cierre representa el final de la tarea. Se recomienda usarlo con instrucciones try-with-resources.

También realizamos una generación de perfiles de memoria rudimentaria en MemoryProfiler. También está siempre activado y, en su mayoría, registra los tamaños máximos del montón y el comportamiento del GC.

Prueba Bazel

Bazel tiene dos tipos principales de pruebas: las que observan Bazel como una "caja negra" y las que solo ejecutan la fase de análisis. A las primeras las llamamos "pruebas de integración" y a las segundas, "pruebas de unidades", aunque se parecen más a pruebas de integración que son, bueno, menos integradas. También tenemos algunas pruebas de unidades reales, en las que son necesarias.

En cuanto a las pruebas de integración, tenemos dos tipos:

  1. Las que se implementan con un framework de prueba de Bash muy elaborado en src/test/shell
  2. Los que se implementan en Java. Se implementan como subclases de BuildIntegrationTestCase.

BuildIntegrationTestCase es el marco de trabajo de pruebas de integración preferido, ya que está bien equipado para la mayoría de los casos de prueba. Como es un framework de Java, proporciona capacidad de depuración y una integración perfecta con muchas herramientas de desarrollo comunes. Hay muchos ejemplos de clases BuildIntegrationTestCase en el repositorio de Bazel.

Las pruebas de análisis se implementan como subclases de BuildViewTestCase. Hay un sistema de archivos temporales que puedes usar para escribir archivos BUILD. Luego, varios métodos auxiliares pueden solicitar destinos configurados, cambiar la configuración y confirmar varias cosas sobre el resultado del análisis.