La base de código de Bazel

Informar un problema Ver fuente

Este documento es una descripción de la base de código y cómo se estructura Bazel. Está dirigido a personas que estén dispuestas a contribuir con Bazel, no a los usuarios finales.

Introducción

La base de código de Bazel es grande (código de producción ~350 KLOC y ~260 códigos de prueba KLOC) y nadie está familiarizado con el panorama completo: todos conocen muy bien su valle particular, pero pocos saben lo que se encuentra sobre las colinas en cada dirección.

Para que las personas que están en su recorrido no se encuentren en un bosque oscuro donde se pierde el camino directo, en este documento se 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 está en GitHub, en github.com/bazelbuild/bazel. Esta no es la "fuente de confianza", sino que 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 que GitHub sea la fuente de información.

Las contribuciones se aceptan a través del mecanismo normal de solicitud de extracción de GitHub, y un empleado de Google las importa de forma manual al árbol de fuentes interno, y luego se vuelven a exportar a GitHub.

Arquitectura cliente/servidor

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

Por eso 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=) están antes del nombre del comando que se ejecutará y otras son después de (-c opt). El primer tipo se llama "opción de inicio" y afecta al proceso del servidor en su conjunto, mientras que el último tipo, la "opción de comando", solo afecta a un solo comando.

Cada instancia de servidor tiene un solo lugar de trabajo asociado (colección de árboles de fuentes conocidos como “repositorios”) y cada lugar de trabajo suele tener una sola instancia de servidor activa. Para eludir esto, especifica 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 del servidor adecuado mediante los siguientes pasos:

  1. Comprueba si ya se extrajo a sí mismo. Si no es así, lo hace. Aquí es de donde proviene la implementación del servidor.
  2. Comprueba si hay una instancia de servidor activa que funcione: esté en ejecución, tenga las opciones de inicio correctas y use el directorio de lugar de trabajo correcto. Encuentra el servidor en ejecución mirando el directorio $OUTPUT_BASE/server, en el que hay un archivo de bloqueo con el puerto en el que escucha el servidor.
  3. Si es necesario, cierra 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, se le comunica el comando que se debe ejecutar a través de una interfaz de gRPC y, luego, el resultado de Bazel se canaliza a la terminal. Solo se puede ejecutar un comando a la vez. Esto se implementa mediante un mecanismo de bloqueo elaborado con partes en C++ y partes en Java. Hay cierta infraestructura para ejecutar varios comandos en paralelo, ya que la imposibilidad de ejecutar bazel version en paralelo con otro comando es un poco vergonzosa. El bloqueador principal es el ciclo de vida de los elementos BlazeModule y algunos estados de BlazeRuntime.

Al final de un comando, el servidor de Bazel transmite el código de salida que el cliente debe mostrar. Una arruga interesante es la implementación de bazel run: el trabajo de este comando es ejecutar algo que Bazel acaba de compilar, pero no puede hacer eso desde el proceso del servidor porque no tiene una terminal. Por lo tanto, le indica al cliente qué objeto binario debe ujexec() y con qué argumentos.

Cuando se presiona Ctrl + C, el cliente lo traduce en una llamada Cancel en la conexión de gRPC, que intenta finalizar el comando lo antes posible. Después de la tercera tecla Ctrl-C, el cliente envía una 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 un tanto complicado durante una compilación. Puedes encontrar una descripción completa en Diseño del directorio de salida.

El "repositorio principal" es el árbol de fuentes en el que se ejecuta Bazel. Por lo general, corresponde a algo que revisaste desde el control de código fuente. La raíz de este directorio se conoce como “raíz del lugar 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 el lugar en el que 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 instalaciones. De forma predeterminada, se encuentra en $OUTPUT_USER_ROOT/install 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 conectada a un lugar de trabajo específico. Cada base de salida tiene, como máximo, una instancia de servidor de Bazel en ejecución a la vez. Por lo general, es a la(s) $OUTPUT_USER_ROOT/<checksum of the path to the workspace>. Se puede cambiar con la opción de inicio --output_base, que es, entre otras cosas, útil para evitar la limitación de que solo una instancia de Bazel puede ejecutarse en cualquier lugar de trabajo en cualquier momento.

El directorio de salida contiene, entre otros, los siguientes elementos:

  • Los repositorios externos recuperados en $OUTPUT_BASE/external.
  • La raíz de ejecución, un directorio que contiene symlinks 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>. Planeamos cambiar esto a $EXECROOT, aunque es un plan a largo plazo porque es un cambio muy incompatible.
  • Archivos compilados durante la compilación.

El proceso de ejecutar un comando

Una vez que el servidor de Bazel obtiene el control y se le informa sobre un comando que necesita ejecutar, ocurre la siguiente secuencia de eventos:

  1. Se informó a BlazeCommandDispatcher sobre la nueva solicitud. Decide si el comando necesita un lugar de trabajo en el cual ejecutarse (casi todos los comandos, excepto los que no tienen nada que ver con el código fuente, como la versión o la ayuda) y si se está ejecutando otro comando.

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

  3. Se analizan las opciones de 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 una transmisión para los eventos que ocurren durante la compilación. Algunos de estos se exportan fuera de Bazel bajo los aegis del protocolo de eventos de compilación para indicarle al mundo cómo va la compilación.

  5. El comando obtiene el control. Los comandos más interesantes son los que ejecutan una compilación (compilar, probar, ejecutar, cobertura, etc.): BuildTool implementa esta funcionalidad.

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

  7. La fase de carga o análisis se ejecuta a fin de producir el grafo de acción (un grafo acíclico dirigido de comandos que deben ejecutarse para la compilación).

  8. Se ejecuta la fase de ejecución. Esto significa que se deben ejecutar todas las acciones necesarias para compilar los destinos de nivel superior que se solicitan.

Opciones de línea de comandos

Las opciones de 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 están relacionadas entre sí. Por ejemplo:

  1. Opciones relacionadas con un lenguaje de programación (CppOptions o JavaOptions). Deben ser una subclase de FragmentOptions y, finalmente, se unirán a 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). Algunas de ellas (por ejemplo, si debe incluir escaneo de C++ o no) se leen en la fase de ejecución, pero eso siempre requiere de fontanería 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 suponer que las instancias de OptionsBase son inmutables y usarlas de esa manera (como una parte de SkyKeys). Este no es el caso y modificarlas es una muy buena manera de romper Bazel de maneras sutiles que son difíciles de depurar. Por desgracia, hacerlas inmutables es una tarea grande. (Modificar un FragmentOptions inmediatamente después de la construcción antes de que cualquier otra persona pueda mantener una referencia a él y antes de que se llame a equals() o hashCode())

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

  1. Algunos están conectados con cable a Bazel (CommonCommandOptions)
  2. Desde la anotación @Command en cada comando de Bazel
  3. A partir de 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 opciones definidas por Starlark) es una variable de 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 poco de texto de ayuda.

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

El árbol fuente, como lo ve Bazel

Bazel se dedica a compilar software, que se realiza mediante la lectura y la interpretación del código fuente. La totalidad del código fuente en el que opera Bazel se denomina “el lugar de trabajo” y se estructura en repositorios, paquetes y reglas.

Repositorios

Un "repositorio" es un árbol de fuentes en el que trabaja un desarrollador. Por lo general, representa un solo proyecto. El principal de Bazel, Blaze, operaba en un monorepo, es decir, un árbol fuente único que contiene todo el código fuente que se usa para ejecutar la compilación. Por el contrario, Bazel admite proyectos cuyo código fuente abarca varios repositorios. El repositorio desde el que se invoca a Bazel se denomina "repositorio principal", los otros se denominan "repositorios externos".

Un repositorio está marcado por un archivo de límites de repositorio (MODULE.bazel, REPO.bazel o, en contextos heredados, WORKSPACE o WORKSPACE.bazel) en su directorio raíz. El repositorio principal es el árbol de fuentes desde el que invocas a Bazel. Los repositorios 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 repositorios externos se vincula o se descarga en $OUTPUT_BASE/external.

Cuando se ejecuta la compilación, se debe unir todo el árbol de fuentes; esto lo hace SymlinkForest, que vincula cada paquete del repositorio principal a $EXECROOT y 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 mediante un archivo llamado BUILD o BUILD.bazel. Si existen ambos, Bazel prefiere BUILD.bazel. El motivo por el que aún se aceptan archivos BUILD es que el principal de Bazel, Blaze, usó este nombre de archivo. Sin embargo, resultó ser un segmento de ruta de acceso de uso general, especialmente en Windows, en el que los nombres de los archivos no distinguen mayúsculas de minúsculas.

Los paquetes son independientes entre sí: los cambios en el archivo BUILD de un paquete no pueden provocar que otros paquetes cambien. Agregar o quitar archivos BUILD puede cambiar otros paquetes, ya que los globs recursivos se detienen en los límites de los paquetes 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 paquetes”. 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. Se trata, principalmente, de un mapa desde una cadena (el nombre de un destino) hasta el objetivo en sí.

Una gran parte de la complejidad durante la carga de paquetes es global: Bazel no requiere que se enumeren de forma explícita todos los archivos de origen y, en su lugar, puede ejecutar globs (como glob(["**/*.java"])). A diferencia del shell, admite globs recurrentes 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.

Globbing se implementa en las siguientes clases:

  • LegacyGlobber, un globo terráqueo rápido y felizmente ignorado de Skyframe
  • SkyframeHybridGlobber, una versión que usa Skyframe y se revierte al globber heredado para evitar los "reinicios de Skyframe" (como se describe a continuación)

La clase Package en sí misma contiene algunos miembros que se usan exclusivamente para analizar el paquete "externo" (relacionado con dependencias externas) y que no tienen sentido para los paquetes reales. Esta es una falla de diseño porque los objetos que describen paquetes regulares no deberían contener campos que describan algo más. 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 más separación entre el análisis del paquete "externo" del análisis de los paquetes normales, de modo que Package no necesite satisfacer las necesidades de ambos. Lamentablemente, esto es difícil de hacer porque ambos están entrelazados bastante profundamente.

Etiquetas, objetivos y reglas

Los paquetes se componen de objetivos que tienen los siguientes tipos:

  1. Archivos: elementos que son la entrada o la salida de la compilación. En términos de Bazel, los llamamos artefactos (que se analizan en otra sección). 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, están asociados con un lenguaje de programación (como cc_library, java_library o py_library), pero hay algunos que no dependen del lenguaje (como genrule o filegroup).
  3. Grupos de paquetes: Se analizan en la sección Visibilidad.

El nombre de un destino se denomina Etiqueta. La sintaxis de las etiquetas es @repo//pac/kage:name, en el que 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 de origen) 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 las rutas relativas que contienen referencias de nivel superior (..).

Un tipo de regla (como "biblioteca 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", tipo RuleClass). A largo plazo, cada regla específica para cada lenguaje se implementará en Starlark, pero, por el momento, algunas familias de reglas heredadas (como Java o C++) siguen estando en Java.

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

Las clases de reglas contienen la siguiente información:

  1. Sus atributos (como srcs o deps): sus tipos, valores predeterminados, restricciones, etcétera.
  2. Las transiciones de configuración y los aspectos adjuntos a cada atributo, si corresponde
  3. La implementación de la regla
  4. La información transitiva que proporciona la regla "por lo general"

Nota sobre la terminología: En la base de código, a menudo usamos "Rule" para referirnos al objetivo creado por una clase de regla. Sin embargo, en Starlark y en la documentación orientada al usuario, "Rule" debe usarse exclusivamente para referirse a la clase de regla en sí. El objetivo es solo un "objetivo". Además, ten en cuenta que, a pesar de que RuleClass tiene "clase" en su nombre, no existe una relación de herencia de Java entre una clase de regla y objetivos de ese tipo.

Skyframe

El framework de evaluación subyacente de Bazel se llama Skyframe. Su modelo consiste en que todo lo que se debe compilar durante una compilación se organiza en un grafo acíclico dirigido con bordes que apuntan desde cualquier dato hacia sus dependencias, es decir, otros datos que se deben conocer para construirlos.

Los nodos del gráfico se llaman SkyValue y sus nombres se llaman SkyKey. Ambos son profundamente inmutables; solo se debe poder acceder a los objetos inmutables. Esta invariante casi siempre se mantiene y, en caso de que no sea así (como en el caso de las clases de opciones individuales BuildOptions, que es miembro de BuildConfigurationValue y su SkyKey), nos esforzamos mucho para no cambiarlas o para hacerlo solo de formas que no sean observables desde el exterior. A partir de esto, se desprende que todo lo que se procesa 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. Lo mejor es hacerlo para construcciones pequeñas, ya que puede volverse bastante grande.

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

Para evaluar un SkyKey determinado en un SkyValue, Skyframe invocará el SkyFunction correspondiente al tipo de clave. Durante la evaluación de la función, es posible que se soliciten otras dependencias de Skyframe llamando a las diversas 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 cambia cualquiera de sus dependencias. En otras palabras, el almacenamiento en caché y el procesamiento incremental de Skyframe funcionan con el nivel de detalle de los SkyFunction y SkyValue.

Cada vez que un SkyFunction solicite una dependencia que no está disponible, getValue() mostrará un valor nulo. Luego, la función debería devolver el control a Skyframe y mostrar un valor nulo. Más adelante, Skyframe evaluará la dependencia no disponible y, luego, reiniciará la función desde el principio. Solo esta vez la llamada a getValue() tendrá éxito y un resultado no nulo.

Una consecuencia de esto es que debe repetirse cualquier cálculo realizado dentro del SkyFunction antes del reinicio. Sin embargo, esto no incluye el trabajo realizado para evaluar la dependencia SkyValues, que se almacena en caché. Por lo tanto, comúnmente solucionamos este problema de la siguiente manera:

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

En esencia, Bazel necesita estos tipos de soluciones alternativas porque cientos de miles de nodos de Skyframe en tránsito es común y la compatibilidad de Java con subprocesos livianos no supera la implementación de StateMachine a partir de 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 con 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 permitir lecturas simultáneas. No es el modelo de Turing completo, lo que desalienta a algunos usuarios (pero no a todos) a intentar realizar tareas de programación generales en el lenguaje.

Starlark se implementa en el paquete net.starlark.java. También tiene una implementación independiente de 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 objetivos 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í 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 nuevas reglas de compilación, reglas de repositorio y extensiones de módulos. Aquí, el código de Starlark puede definir funciones nuevas y realizar cargas desde otros archivos .bzl.

Los dialectos disponibles para los archivos BUILD y .bzl son ligeramente diferentes porque expresan diferentes cosas. Aquí encontrarás una lista de las diferencias.

Obtén más información sobre Starlark aquí.

Fase de carga/análisis

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

Se denomina “fase de carga/análisis” porque se puede dividir en dos partes distintas, que solían ser serializadas, pero ahora pueden superponerse en el tiempo:

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

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

  1. La configuración. ("cómo" compilar esa regla; por ejemplo, la plataforma de destino, pero también elementos como las opciones de la línea de comandos que el usuario quiere que se pase 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 una "lista completa" de la información en el cierre transitivo del destino configurado, como todos los archivos .jar de la ruta de clase o todos los archivos .o que deben vincularse a un objeto binario C++).
  3. El destino 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, por lo general, son los más importantes.
  4. La implementación del destino configurado. En cuanto a las reglas, esto puede estar en Starlark o en Java. Todos los destinos configurados sin reglas se implementan en Java.

El resultado de analizar un destino configurado es el siguiente:

  1. Los proveedores de información transitiva que configuraron objetivos que dependen de ella pueden acceder
  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 realizar Bad ThingsTM, por ejemplo, escribir código cuya complejidad de tiempo o espacio sea cuadrática (o peor), para hacer que el servidor de Bazel falle con una excepción de Java o para infringir invariantes (por ejemplo, modificando involuntariamente 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().

Parámetros de configuración

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

Se puede compilar el mismo destino para varias configuraciones 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 realizamos compilaciones de forma cruzada, o bien cuando compilamos una app para Android extensa (una que contiene código nativo para varias arquitecturas de CPU).

De forma conceptual, la configuración es una instancia de BuildOptions. Sin embargo, en la práctica, BuildOptions se une con BuildConfiguration, lo que proporciona funciones adicionales. Se propaga desde la parte superior del gráfico de dependencia hasta la parte inferior. Si cambia, la compilación debe volver a analizarse.

Esto genera anomalías, como tener que volver a analizar toda la compilación si, por ejemplo, cambia la cantidad de ejecuciones de pruebas solicitadas, aunque eso solo afecte a los objetivos de prueba (planeamos "cortar" las configuraciones para que este no sea el caso, pero aún no está listo).

Cuando la implementación de una regla necesita parte de la configuración, debe declararla en su definición mediante RuleClass.Builder.requiresConfigurationFragments(). Esto permite evitar errores (como las reglas de Python que usan el fragmento de Java) y facilitar el recorte de configuración, de modo que, por ejemplo, si cambian las opciones de Python, no es necesario volver a analizar los objetivos 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 perímetro de dependencia se denomina "transición de configuración". Esto puede ocurrir en dos lugares:

  1. En un perímetro de dependencia Estas transiciones se especifican en Attribute.Builder.cfg() y son funciones de una Rule (en la que se produce la transición) y una BuildOptions (la configuración original) a una o más BuildOptions (la configuración de salida).
  2. En cualquier perímetro entrante a un destino configurado. Estos se especifican en RuleClass.Builder.cfg().

Las clases relevantes son TransitionFactory y ConfigurationTransition.

Se usan transiciones de configuración, por ejemplo:

  1. Para declarar que una dependencia específica se usa durante la compilación y, por lo tanto, debe compilarse en la arquitectura de ejecución
  2. Declarar que se debe compilar una dependencia específica para varias arquitecturas (por ejemplo, para código nativo en APKs grandes de Android)

Si una transición de configuración genera varias opciones de configuración, se denomina transición dividida.

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

Proveedores de información transitiva

Los proveedores de información transitiva son una forma (y la _única_ruta) para los objetivos configurados de proporcionar información sobre otros destinos configurados que dependen de ellos. El motivo por el que "transitivo" está en su nombre es que, por lo general, es una clase de datos integrados del cierre transitivo de un destino configurado.

Por lo 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 amalgamación de FileProvider, FilesToRunProvider y RunfilesProvider porque se consideró que esa API era más estilo Starlark que una transliteración directa de Java). Su clave es uno de los siguientes aspectos:

  1. Es un objeto de clase Java. Solo está disponible para proveedores a los que no se puede acceder desde Starlark. Estos proveedores son una subclase de TransitiveInfoProvider.
  2. Una string. Esta acción es heredada y no se recomienda, ya que es susceptible a conflictos de nombres. Esos proveedores de información transitiva son subclases directas de build.lib.packages.Info .
  3. Un símbolo de proveedor. Esto se puede crear a partir de Starlark con la función provider() y es la forma recomendada de crear proveedores nuevos. El símbolo está representado por una instancia de Provider.Key en Java.

Los proveedores nuevos implementados en Java se deben implementar con BuiltinProvider. NativeProvider dejó de estar disponible (aún no tuvimos tiempo de quitarlo) y no se puede acceder a las subclases TransitiveInfoProvider desde Starlark.

Destinos configurados

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

Las fábricas de destino configuradas deben usar RuleConfiguredTargetBuilder para construir el valor que se muestra. Consta de los siguientes elementos:

  1. Su filesToBuild, el concepto confuso 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 el archivo srcs de una genrule.
  2. Sus archivos de ejecución, regular y datos.
  3. Sus 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 de grupo de archivos en BUILD y usar el proveedor OutputGroupInfo en Java.

Archivos de ejecución

Algunos objetos binarios necesitan archivos de datos para ejecutarse. Un ejemplo destacado son las pruebas que necesitan archivos de entrada. En Bazel, se representa con el concepto de “archivos de ejecución”. Un “árbol de archivos de ejecución” es un árbol de directorios de los archivos de datos para un objeto binario en particular. Se crea en el sistema de archivos como un árbol de symlinks con symlinks individuales que apuntan a los archivos en la fuente de los árboles de salida.

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

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

Los archivos de ejecución se recopilan con RunfilesProvider: una instancia de esta clase representa los archivos de ejecución de un destino configurado (como una biblioteca) y sus necesidades de cierre transitivo, y se recopilan como un conjunto anidado (de hecho, se implementan usando conjuntos anidados en la portada): cada destino une los archivos de ejecución de sus dependencias, agrega algunos propios y, luego, envía la configuración 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 cada otro tipo de dependencia entrante. Esto se debe a que, a veces, un objetivo presenta diferentes archivos de ejecución cuando se depende de ellos mediante un atributo de datos. Este es un comportamiento heredado no deseado que aún no pudimos quitar.

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

  • El manifiesto de runfiles de entrada. Esta es una descripción serializada del árbol de runfiles. Se usa como proxy para el contenido del árbol de archivos de ejecución, y Bazel supone que este árbol solo cambia si cambia el contenido del manifiesto.
  • El manifiesto de los archivos runfiles de salida. Las bibliotecas en tiempo de ejecución que controlan los árboles de archivos de ejecución, en particular en Windows, que a veces no admite vínculos simbólicos no admiten esta función.
  • El intermediario de los archivos de ejecución. Para que exista un árbol de archivos de ejecución, se debe compilar el árbol de symlinks y el artefacto al que apuntan los symlinks. Para disminuir la cantidad de bordes de dependencia, se puede usar el intermediario de archivos de ejecución a fin de representarlos.
  • Argumentos de la línea de comandos para ejecutar el objeto binario cuyos archivos de ejecución representa el objeto RunfilesSupport

Aspectos

Los aspectos son una forma de “propagar el procesamiento por el gráfico de la dependencia”. Se describen para los usuarios de Bazel aquí. Un buen ejemplo motivador son los búferes de protocolo: una regla proto_library no debería conocer ningún lenguaje en particular, pero compilar 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 vincularse a la regla proto_library para que, si dos objetivos en el mismo idioma 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 su forma de crearlos es muy similar a la compilación de 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 destino configuradas, también conoce el destino configurado al que se adjunta y sus proveedores.

El conjunto de aspectos propagados por el gráfico de dependencia se especifica para cada atributo mediante 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 similar a RuleConfiguredTargetFactory.
  2. AspectDefinition es la definición del aspecto. Incluye a los proveedores que requiere y a los que proporciona. Además, contiene una referencia a su implementación, como la instancia de AspectClass adecuada. Es análogo a RuleClass.
  3. AspectParameters es una forma de parametrizar un aspecto que se propaga hacia abajo en el gráfico de dependencia. Actualmente es un mapa de cadenas a cadenas. Los búferes de protocolo son un buen ejemplo de por qué son útiles: si un lenguaje tiene varias APIs, la información sobre la API para la que se deben compilar los búferes de protocolo debe propagarse en 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 la dependencia. Consiste en 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 IDE se deberá adjuntar al par (regla proto_library + aspecto proto de Java).

La complejidad de los aspectos sobre los aspectos se registra 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 acciones de compilación y varias para las que se compila el código. Estas arquitecturas se denominan plataformas en Bazel (la documentación completa aquí)

Una plataforma se describe mediante una asignación de par clave-valor desde la configuración de restricciones (como el concepto de "arquitectura de la CPU") hasta los valores de restricción (como una CPU en particular, como x86_64). Tenemos un "diccionario" de los valores y la configuración de restricciones más utilizados en el repositorio @platforms.

El concepto de cadena de herramientas proviene del hecho de que, según las plataformas en las que se ejecute la compilación y las plataformas a las que esté orientado, es posible que se necesiten usar compiladores diferentes. Por ejemplo, una cadena de herramientas de C++ particular podría ejecutarse en un SO específico y poder orientarse a otros SO. Bazel debe determinar el compilador de C++ que se usa en función de la ejecución establecida y la plataforma de destino (consulta la documentación sobre cadenas de herramientas aquí).

Para ello, las cadenas de herramientas se anotan con el conjunto de restricciones de ejecución y 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 objetivo que admite una cadena de herramientas y que indica qué tipo de cadena de herramientas (como C++ o Java) es (la última 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, ya que necesitamos conocer las restricciones de cada cadena de herramientas para poder resolver el problema, y las reglas de *_toolchain() específicas del lenguaje contienen mucha más información, 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 .

PlatformOptions.computeTargetPlatform() determina la plataforma de destino para un destino configurado . Es una lista de plataformas porque, en última instancia, queremos admitir varias plataformas de destino, pero aún no se implementó.

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

  • El conjunto de cadenas de herramientas registradas (en el archivo MODULE.bazel y en la configuración)
  • Las plataformas de ejecución y destino deseadas (en la configuración)
  • El conjunto de tipos de cadenas de herramientas que requiere el destino configurado (en UnloadedToolchainContextKey)
  • 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

Su resultado es una 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 realmente con ResolvedToolchainContext.load() y se usan en 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 las configuraciones de destino que se representan con varias marcas de configuración, como --cpu . Estamos realizando la transición gradual al sistema anterior. Para manejar 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 estilo nuevo de la plataforma. Su código está en PlatformMappingFunction y usa un "pequeño lenguaje" que no es de Starlark.

Restricciones

A veces uno quiere designar un objetivo como compatible solo con algunas plataformas. Lamentablemente, Bazel cuenta con varios mecanismos para lograr este fin:

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

Las restricciones específicas de reglas se usan principalmente dentro de las reglas de Google para Java. Están en camino y no están disponibles en Bazel, pero el código fuente puede contener referencias a él. El atributo que lo rige se denomina constraints= .

entorno_grupo() y entorno()

Estas reglas son un mecanismo heredado y no se usan mucho.

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

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

  1. Mediante el atributo restricted_to=. Esta es la forma de especificación más directa; declara el conjunto exacto de entornos que la regla admite para este grupo.
  2. Mediante el atributo compatible_with=. De esta manera, se declaran los entornos que admite una regla además de los entornos “estándar” que son compatibles de forma predeterminada.
  3. Mediante los atributos a nivel del paquete default_restricted_to= y default_compatible_with=
  4. Mediante especificaciones predeterminadas en las reglas environment_group() Cada entorno pertenece a un grupo de pares temáticos (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= y environment() no lo especifican de otra manera. Una regla sin esos atributos hereda todos los valores predeterminados.
  5. Mediante una configuración predeterminada de 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 hacer que todas las reglas *_test se puedan probar sin tener que declarar explícitamente esta función en cada instancia.

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

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

La implementación de la verificación de restricciones está en RuleContextConstraintSemantics y TopLevelConstraintSemantics.

Restricciones de la plataforma

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

Visibilidad

Si trabajas en una base de código grande con muchos desarrolladores (como en Google), asegúrate de evitar que los demás dependan de manera arbitraria de tu código. De lo contrario, según la ley de Hyrum, las personas volverán a depender de los comportamientos que consideraste como detalles de implementación.

Bazel admite esto mediante el mecanismo llamado visibilidad: puedes declarar que solo se puede depender de un destino en particular mediante el uso del atributo visibilidad. Este atributo es un poco especial porque, aunque contiene una lista de etiquetas, estas pueden codificar un patrón sobre los nombres de paquetes en lugar de un puntero a cualquier objetivo en particular. (Sí, se trata de 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 privada) o una lista de etiquetas.
  • Las etiquetas pueden hacer referencia a grupos de paquetes (lista predefinida de paquetes), directamente a paquetes (//pkg:__pkg__) o subárboles de paquetes (//pkg:__subpackages__). Esto es diferente de la sintaxis de línea de comandos, que usa //pkg:* o //pkg/....
  • Los grupos de paquetes se implementan como su propio destino (PackageGroup) y su propio destino configurado (PackageGroupConfiguredTarget). Probablemente podríamos reemplazarlos por 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 al atributo packages de un solo elemento package_group; y PackageSpecificationProvider, que se agrega en un package_group y su includes transitivo.
  • La conversión de las listas de etiquetas de visibilidad a dependencias se realiza en DependencyResolver.visitTargetVisibility y en otros lugares misceláneos.
  • 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 une el conjunto agregado en un proveedor de información transitiva 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
  • El conjunto de archivos .jar que debe estar en la ruta de clase para que se compile o ejecute una regla de Java
  • El conjunto de archivos de Python en el cierre transitivo de una regla de Python

Si hiciéramos esto de la manera simple y usamos, por ejemplo, List o Set, obtendríamos un uso cuadrático de memoria: 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, se nos ocurrió el concepto de NestedSet. Es una estructura de datos compuesta por otras instancias de NestedSet y algunos miembros propios, lo que forma un grafo acíclico dirigido de conjuntos. Son inmutables y sus miembros se pueden iterar. Definimos el orden de iteración múltiple (NestedSet.Order): preorder, postorder, topology (un nodo siempre viene después de sus principales) y “no importa, pero debería ser igual cada vez”.

La misma estructura de datos se denomina depset en Starlark.

Artefactos y acciones

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

Los artefactos pueden ser de dos tipos: de origen (que están disponibles antes de que Bazel comience a ejecutarse) y artefactos derivados (que se deben compilar). Los artefactos derivados pueden ser de varios tipos:

  1. **Artefactos habituales. **Para comprobar su estado de actualización, se calcula su suma de comprobación, con mtime como un atajo; no se realiza la suma de comprobación del archivo si su ctime no ha cambiado.
  2. Artefactos de symlink sin resolver. Para comprobar su actualización, se llama a readlink(). A diferencia de los artefactos normales, pueden ser vínculos simbólicos colgantes. Por lo general, se usa en los casos en que se incluyen algunos archivos en algún tipo de archivo.
  3. Artefactos de árboles. No son archivos individuales, sino árboles de directorios. Se comprueba si están actualizados. Para ello, se revisa el conjunto de archivos que contiene y su contenido. Se representan como TreeArtifact.
  4. Artefactos de metadatos constantes. Los cambios en estos artefactos no activan una recompilación. Se usa exclusivamente para información del sello de compilación: no queremos volver a compilar solo porque cambió la hora actual.

No hay una razón fundamental por la que los artefactos de origen no pueden ser artefactos de árbol o artefactos de symlink sin resolver. Es solo que todavía no lo hemos implementado (aunque deberíamos hacerlo: hacer referencia a un directorio de código fuente en un archivo BUILD es uno de los pocos problemas recurrentes de errores conocidos con Bazel. Tenemos una implementación de este tipo que funciona, habilitada por la propiedad de JVM BAZEL_TRACK_SOURCE_DIRECTORIES=1).

Un tipo notable de Artifact son los intermediarios. Se indican mediante instancias de Artifact que son las salidas de MiddlemanAction. Se utilizan para dar casos especiales a algunas cosas:

  • Los intermediarios de agregación se usan para agrupar artefactos. Por lo tanto, si muchas acciones usan el mismo conjunto grande de entradas, no tengamos bordes de dependencia N*M, solo N+M (se reemplazan por conjuntos anidados).
  • La programación de intermediarios de dependencias garantiza que una acción se ejecute antes que otra. Se usan principalmente para el análisis con lint, pero también para la compilación de C++ (consulta CcCompilationContext.createMiddleman() para obtener una explicación).
  • Los intermediarios de archivos de ejecución se usan para garantizar la presencia de un árbol de archivos de ejecución, de modo que no sea necesario depender por separado del manifiesto de salida y de cada artefacto único al que hace referencia el árbol de archivos de ejecución.

Las acciones se entienden mejor como un comando que debe ejecutarse, el entorno que necesita y el conjunto de resultados que produce. Los siguientes 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 configurar
  • Anotaciones que describen el entorno (como la plataforma) que necesita para ejecutarse en \

También hay otros casos especiales, como escribir un archivo cuyo contenido es conocido por Bazel. 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).

Con el tiempo, queremos mover todo a SpawnAction. JavaCompileAction es bastante parecido, pero C++ es un caso especial debido al análisis de archivos .d y su inclusión.

La mayoría de los gráficos de acción están "incorporados" en el gráfico de Skyframe: en términos conceptuales, la ejecución de una acción se representa como una invocación de ActionExecutionFunction. La asignación de un perímetro de dependencia de 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 baja la cantidad de bordes de Skyframe:

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

Acciones compartidas

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

Esto se considera un error de función, pero deshacerse de él es muy difícil porque produce ahorros significativos en el tiempo de ejecución cuando, por ejemplo, un archivo de origen debe procesarse de alguna manera y se hace referencia a ese archivo mediante varias reglas (onda manual). Esto tiene el costo de una parte de la 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: deben tener las mismas entradas y 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 mediante la observación de cada acción. Se implementa en SkyframeActionExecutor.findAndStoreArtifactConflicts() y es uno de los pocos lugares de Bazel que requiere una vista “global” de la compilación.

La fase de ejecución

Aquí es cuando Bazel comienza a ejecutar acciones de compilación, como comandos que generan 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 está codificada 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 en el sistema de archivos (--package_path), debe proporcionar acciones ejecutadas de forma local con un árbol de fuentes completo. La clase SymlinkForest se encarga de esto, y funciona tomando nota de cada destino utilizado en la fase de análisis y creando un solo árbol de directorios que vincule cada paquete con un destino utilizado desde su ubicación real. Una alternativa sería pasar las rutas de acceso correctas a los comandos (teniendo en cuenta --package_path). Esto es un problema no deseado por los siguientes motivos:

  • Cambia las líneas de comandos de acción cuando un paquete se mueve de una entrada de ruta de paquete a otra (antes era un caso común).
  • Si una acción se ejecuta de forma remota, se generan diferentes líneas de comandos que si se ejecutara de forma local.
  • Requiere una transformación de línea de comandos específica para la herramienta en uso (ten en cuenta la diferencia entre, como las rutas de clase de Java y las de inclusión de C++).
  • Cambiar la línea de comandos de una acción invalida su entrada de caché de acciones
  • --package_path se dará de baja de forma lenta y progresiva

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

Dado que ejecutar una acción es costoso, tenemos algunas capas de almacenamiento en caché que se pueden encontrar 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
  • Los sistemas de ejecución remota también suelen contener su propia caché.

Caché de la acción local

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

Se verifica la caché en busca de hits con el método ActionCacheChecker.getTokenIfNeedToExecute() .

Al contrario de su nombre, es un mapa desde la ruta de un artefacto derivado hasta 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 comprobació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 captura la suma de verificación de los archivos de entrada (por ejemplo, para FileWriteAction, es la suma de verificación de los datos escritos).

También hay una "caché de acción vertical" altamente experimental que aún está en desarrollo y usa hashes transitivos para evitar acceder a la caché tantas veces.

Descubrimiento y reducción de entradas

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

  • Una acción puede descubrir entradas nuevas antes de su ejecución o decidir que algunas de sus entradas no son realmente necesarias. El ejemplo canónico es C++, en el que es mejor hacer una suposición fundamentada sobre los archivos de encabezado que usa un archivo de C++ a partir de su cierre transitivo para no prestar atención a enviar todos los archivos a ejecutores remotos; por lo tanto, tenemos la opción de no registrar cada archivo de encabezado como una “entrada”, pero el archivo de origen para los archivos de encabezado que usa un archivo de C++, y solo debe marcar esos archivos de encabezado como entradas transitivas en los que se utilizan un procesador en exceso “#include” es una opción de análisis con cable en el procesamiento previo.
  • Es posible que una acción detecte que algunos archivos no se usaron durante su ejecución. En C++, esto se llama "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 usa este hecho. Esto ofrece una mejor estimación que el escáner de inclusión porque se basa en el compilador.

Se implementan con métodos en Action:

  1. Se llama a Action.discoverInputs(). Debería mostrar un conjunto anidado de artefactos que se determine que son 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 necesitaban todas sus entradas. Esto puede generar compilaciones incrementales incorrectas si se informa que una entrada usada no se usa.

Cuando una caché de acciones muestra un hit en una instancia de acción nueva (como la que se creó después de un reinicio del servidor), Bazel llama a updateInputs() para que el conjunto de entradas refleje el resultado del descubrimiento y la reducción de entradas realizados antes.

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

Diversas formas de ejecutar acciones: estrategias/ActionContexts

Algunas acciones se pueden ejecutar de diferentes maneras. Por ejemplo, una línea de comandos se puede ejecutar de forma local, local, pero en varios tipos de zonas de pruebas o de forma remota. El concepto que representa esto se llama ActionContext (o Strategy, ya que solo llegamos a la mitad del camino con un 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 a través de una instancia Class de Java que hace referencia a una subinterfaz de ActionContext y que debe implementar el contexto de acción.
  2. Se selecciona el contexto de acción adecuado de los disponibles y se reenvía a ActionExecutionContext y BlazeExecutor .
  3. Las acciones solicitan contextos con ActionExecutionContext.getContext() y BlazeExecutor.getStrategy() (solo debería haber una manera de hacerlo...)

Las estrategias son libres de llamar a otras estrategias para que hagan su trabajo. Esto se usa, por ejemplo, en la estrategia dinámica que inicia acciones de forma local y remota y luego utiliza lo que finalice primero.

Una estrategia notable es la que implementa procesos trabajadores persistentes (WorkerSpawnStrategy). La idea es que algunas herramientas tienen un tiempo de inicio prolongado y, por lo tanto, deben reutilizarse 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 trabajador de que no lleva un estado observable entre solicitudes individuales).

Si la herramienta cambia, se debe reiniciar el proceso del trabajador. Para determinar si un trabajador se puede volver a usar, 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 de ejecución de Spawn se cuentan como partes de la herramienta.

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

  • La información sobre varias estrategias para ejecutar acciones está disponible aquí.
  • Aquí encontrarás información sobre la estrategia dinámica, en la que ejecutamos una acción de forma local y remota para ver qué finaliza primero.
  • La información sobre las complejidades de la ejecución de acciones de forma local está disponible aquí.

El administrador de recursos local

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

Esto se implementa en la clase ResourceManager: cada acción debe anotarse con una estimación de los recursos locales que requiere en forma de una instancia ResourceSet (CPU y RAM). Por lo tanto, cuando los contextos de acción realizan una acción que requiere recursos locales, llaman a ResourceManager.acquireResources() y se bloquean hasta que los recursos necesarios están disponibles.

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

La estructura del directorio de salida

Cada acción requiere un lugar independiente en el directorio de salida en el que se ubican 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 con una configuración en particular? Existen dos propiedades deseables en conflicto:

  1. Si pueden ocurrir dos configuraciones en la misma compilación, deben tener directorios diferentes para que ambos puedan tener su propia versión de la misma acción. De lo contrario, si las dos configuraciones no están de acuerdo, 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 "más o menos" lo mismo, deberían tener el mismo nombre para que las acciones ejecutadas en una puedan reutilizarse para la otra si las líneas de comandos coinciden (por ejemplo, los cambios en las opciones de línea de comandos del compilador de Java no deben hacer que se vuelvan a ejecutar las acciones de compilación de C++).

Hasta ahora, no hemos creado una forma basada en principios para resolver este problema, que tiene similitudes con el problema de recorte de configuración. Un análisis más detallado de las opciones está disponible aquí. Las principales áreas problemáticas son las reglas de Starlark (cuyos autores no suelen estar 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 consiste en que el segmento de ruta de la configuración sea <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 agregue su propia parte al nombre del directorio de salida.

Pruebas

Bazel es muy compatible para ejecutar pruebas. Es compatible con:

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

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

  • Los artefactos cuya compilación genera la ejecución de la prueba. Este es un archivo de “estado de la caché” que contiene un mensaje TestResultData serializado.
  • La cantidad de veces que se debe ejecutar la prueba
  • La 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 complejo.

En primer lugar, durante el análisis del patrón de destino, los paquetes de pruebas se expanden de manera recursiva. La expansión se implementa en TestsForTargetPatternFunction. Un problema un tanto sorprendente es que si un conjunto de pruebas no declara ninguna prueba, hace referencia a todas las pruebas del paquete. Se implementa en Package.beforeBuild() agregando un atributo implícito llamado $implicit_tests para probar las reglas del paquete.

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

Luego, esto se procesa más a fondo 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 transparencia a este proceso elaborado, el operador de consulta tests() (implementado en TestsFunction) está disponible para determinar qué pruebas se ejecutan cuando se especifica un destino en particular en la línea de comandos. Lamentablemente, se trata de una reimplementación, por lo que es probable que se desvíe de lo anterior de varias maneras sutiles.

Cómo ejecutar pruebas

Se ejecutan las pruebas mediante la solicitud de artefactos de estado de 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 de acuerdo con un protocolo elaborado que usa variables de entorno para indicarles a las pruebas lo que se espera de ellas. Aquí encontrarás una descripción detallada de lo que Bazel espera de las pruebas y lo que pueden esperar de Bazel. En el caso más simple, un código de salida de 0 significa éxito, todo lo demás significa falla.

Además del archivo de estado de la caché, cada proceso de prueba emite varios archivos adicionales. Se colocan en el "directorio de registro de prueba", 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 el fragmento de prueba
  • test.log es el resultado de la consola de la prueba. stdout y stderr no están separados.
  • test.outputs, el "directorio de salidas no declarados", se usa en las pruebas que desean generar archivos además de los que imprimen en la terminal.

Hay dos cosas que pueden ocurrir durante la ejecución de prueba y que no durante la compilación de objetivos regulares: ejecución de prueba exclusiva y transmisión de resultados.

Algunas pruebas deben ejecutarse en modo exclusivo; por ejemplo, no en paralelo con otras pruebas. Para ello, se puede agregar tags=["exclusive"] a la regla de prueba o ejecutar la prueba con --test_strategy=exclusive . Cada prueba exclusiva se ejecuta mediante una invocación de Skyframe separada 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, cuyos resultados de terminal se vuelcan 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 mediante la opción de línea de comandos --test_output=streamed e implica la ejecución de pruebas exclusivas, de modo que los resultados de diferentes pruebas no se intercalan.

Esto se implementa en la clase StreamedTestOutput con el nombre apropiado y funciona sondeando los cambios en el archivo test.log de la prueba en cuestión y volcando bytes nuevos en la terminal donde se ejecutan las reglas de Bazel.

Los resultados de las pruebas ejecutadas están disponibles en el bus de eventos mediante la observación de varios eventos (como TestAttempt, TestResult o TestingCompleteEvent). Se vuelcan en el protocolo de eventos de compilación y AggregatingTestListener los emite a la consola.

Recopilación de cobertura

La cobertura se informa en las pruebas en formato LCOV en los archivos bazel-testlogs/$PACKAGE/$TARGET/coverage.dat .

Para recopilar cobertura, cada ejecución de prueba se une a una secuencia de comandos llamada collect_coverage.sh .

Esta secuencia de comandos configura el entorno de prueba para habilitar la recopilación de cobertura y determinar el lugar en el que los entornos 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 entornos de ejecución de recopilación de cobertura independientes). La secuencia de comandos del 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 mediante las estrategias de prueba y requiere que collect_coverage.sh esté 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 instrumentación sin conexión, lo que significa que la instrumentación de cobertura se agrega en el tiempo de compilación (como C++) y otros realizan 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 objeto binario o una prueba si no se ejecutó ningún código. El problema que resuelve es que si deseas calcular la cobertura de la prueba de 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 objeto binario que contenga solo los archivos para los que recopilamos cobertura, sin líneas cubiertas. El archivo de cobertura del modelo de referencia de un objetivo se encuentra en bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat . También se genera para objetos binarios y bibliotecas, además de pruebas si pasas la marca --nobuild_tests_only a Bazel.

La cobertura del modelo de referencia no funciona en este momento.

Hacemos 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 durante el tiempo de ejecución para decidir qué archivos 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 necesita una prueba para generar los archivos LCOV que Bazel requiere. En la práctica, esto consiste en archivos específicos del entorno de ejecución; por ejemplo, gcc emite archivos .gcno durante la compilación. Estas se agregan al conjunto de entradas de acciones de prueba si el modo de cobertura está habilitado.

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

Los archivos de compatibilidad de cobertura se dependen de las etiquetas mediante una dependencia implícita para que la política de invocación pueda anularlos, lo que permite que difieran entre las diferentes versiones de Bazel. Idealmente, se quitarían estas diferencias y, luego, estandarizamos 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 lo controla y se llama desde BuildView.createResult() . Para obtener acceso a las herramientas que necesita, observa el atributo :coverage_report_generator de la primera prueba que se ejecuta.

El motor de consultas

Bazel usa poco lenguaje para preguntar varias cosas sobre varios gráficos. Se proporcionan los siguientes tipos de consultas:

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

Cada una de ellas se implementa dividiendo en subclases AbstractBlazeQueryEnvironment. Se pueden realizar funciones de consulta adicionales mediante la subclasificación de QueryFunction. Para permitir la transmisión de resultados de consultas, en lugar de recopilarlos en una estructura de datos, se pasa un query2.engine.Callback a QueryFunction, que lo llama para obtener los resultados que desea mostrar.

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 consulta (proto, definitivamente) es que Bazel necesita emitir _toda_la información que proporciona la carga del paquete para que se pueda diferenciar el resultado y determinar si un objetivo en particular cambió. Como consecuencia, los valores de los atributos deben ser serializables, razón por la cual hay muy pocos tipos de atributos sin ningún atributo que tenga valores complejos de Starlark. La solución 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, sería bueno eliminar 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 denominaba Blaze) y obtener información sobre varios eventos durante la ejecución de un comando.

En general, se usan para implementar varias partes de la funcionalidad "no principal" que solo necesitan algunas versiones de Bazel (como la que usamos en Google):

  • Interfaces con sistemas de ejecución remota
  • Comandos nuevos

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

El bus de eventos

La forma principal 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 ella 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 que se compilarán (TargetParsingCompleteEvent).
  • Se determinaron las opciones de configuración de nivel superior (BuildConfigurationEvent).
  • Se compiló un destino, pero no se compiló correctamente (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 BuildEvent). De esta manera, no solo se permiten objetos BlazeModule, sino también elementos fuera del proceso de Bazel, que observen la compilación. Se puede acceder a ellas como un archivo que contiene mensajes de protocolo, o bien como Bazel puede conectarse a un servidor (llamado servicio de eventos de compilación) para transmitir eventos.

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

Repositorios externos

Mientras que Bazel se diseñó originalmente para usarse en un monorepo (un único árbol de fuentes que contiene todo lo que se necesita compilar), Bazel vive en un mundo en el que esto no es necesariamente cierto. Los “repositorios externos” son una abstracción que se usa para unir estos dos mundos: representan código que es necesario para la compilación, pero que no está en el árbol fuente principal.

El archivo WORKSPACE

El conjunto de repositorios externos se determina mediante el análisis del archivo WORKSPACE. Por ejemplo, puedes usar una declaración como la siguiente:

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

Hace que el repositorio llamado @foo esté disponible. Esto se complica porque se pueden definir nuevas reglas de repositorio en archivos de Starlark, que luego se pueden usar para cargar código nuevo de Starlark, que se puede usar para definir nuevas reglas de repositorio y así sucesivamente...

Para manejar este caso, el análisis del archivo WORKSPACE (en WorkspaceFileFunction) se divide en fragmentos delimitados por declaraciones load(). El índice del fragmento se indica con WorkspaceFileKey.getIndex() y calcular WorkspaceFileFunction hasta que el índice X implica evaluarlo hasta la Xth load().

Recuperando repositorios

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

Para recuperar el repositorio, sigue estos pasos:

  1. PackageLookupFunction detecta que necesita un repositorio y crea un RepositoryName como SkyKey, que invoca a RepositoryLoaderFunction
  2. RepositoryLoaderFunction reenvía la solicitud a RepositoryDelegatorFunction por motivos poco claros (el código indica que se debe evitar tener que volver a descargar elementos en caso de que se reinicie Skyframe, pero no es un razonamiento muy sólido).
  3. RepositoryDelegatorFunction descubre la regla de repositorio que se le solicita recuperar mediante la iteración sobre los fragmentos del archivo WORKSPACE hasta que se encuentre el repositorio solicitado.
  4. Se encontró el RepositoryFunction adecuado que implementa la recuperación del repositorio, ya sea la implementación de Starlark del repositorio o un mapa hard-coded para los repositorios que se implementan en Java.

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

  1. Hay una caché para los archivos descargados que tiene como clave su suma de verificación (RepositoryCache). Esto requiere que la suma de verificación esté disponible en el archivo WORKSPACE, pero, de todos modos, es bueno para la hermeticidad. Esto se comparte por cada instancia del servidor de Bazel en la misma estación de trabajo, sin importar en qué lugar de trabajo o base de salida 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ó en la recuperación. Si el servidor de Bazel se reinicia, 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 los artefactos que se descargarán. Esto es útil en entornos empresariales en los que Bazel no debería recuperar elementos aleatorios de Internet. DownloadManager lo implementa .

Una vez que se descarga un repositorio, sus artefactos se tratan como artefactos de origen. Esto plantea un problema porque Bazel suele verificar la actualización de los artefactos de origen llamando a stat(), y estos artefactos también se invalidan cuando cambia la definición del repositorio. Por lo tanto, los objetos FileStateValue 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 versiones diferentes (esta es una instancia del "problema de dependencia de diamantes"). Por ejemplo, si dos objetos binarios en repositorios separados de la compilación quieren depender de Guava, es probable que ambos hagan referencia a Guava con etiquetas que comiencen con @guava// y esperen que eso signifique diferentes versiones.

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

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

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

  • Package.Builder.repositoryMapping, que se usa para transformar los atributos con valor de etiqueta de las reglas en el paquete mediante 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 declaraciones load()

Bits de JNI

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

El código de C++ se encuentra en src/main/native, y las clases de 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 de la consola parece algo simple, pero la confluencia de ejecutar varios procesos (a veces de forma remota), el almacenamiento en caché detallado, el deseo de tener una salida de terminal atractiva y colorida y tener un servidor de larga duración lo hace no trivial.

Justo después de que la llamada RPC llega 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, estas transmisiones se entregan a BlazeCommandDispatcher.execExclusively().

La salida se imprime de forma predeterminada con secuencias de escape ANSI. Cuando estos no son deseados (--color=no), se quitan por un AnsiStrippingOutputStream. Además, System.out y System.err se redireccionan a estas transmisiones de salida. Esto es para que la información de depuración se pueda imprimir con System.err.println() y aun así terminar en la salida de la terminal del cliente (que es diferente de la del servidor). Se debe tener en cuenta que, si un proceso produce resultados binarios (como bazel query --output=proto), no se lleve a cabo una administración de stdout.

Los mensajes cortos (errores, advertencias y elementos similares) se expresan a través de la interfaz EventHandler. En particular, estas son diferentes de las que se publican en EventBus (esto es confuso). Cada Event tiene un EventKind (error, advertencia, información y algunos otros) y puede tener un Location (el lugar del código fuente que causó el evento).

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

Algunos objetos EventHandler también permiten publicar eventos que finalmente llegan al bus de eventos (los Event normales _no _aparecen allí). Estas son implementaciones de ExtendedEventHandler y su uso principal es volver a reproducir eventos EventBus almacenados en caché. Todos estos eventos EventBus implementan Postable, pero no todo lo que se publica en EventBus implementa necesariamente esta interfaz. Solo aquellos que están almacenados en caché por un ExtendedEventHandler (sería agradable y la mayoría de las cosas sí; sin embargo, no se aplica de manera forzosa).

El resultado de la terminal se emite en su mayoría a través de UiEventHandler, que es responsable de todo el formato de salida sofisticado y de los informes de progreso que hace Bazel. Tiene dos entradas:

  • El bus de eventos
  • La transmisión del evento canalizada a través de Reporter

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

Cómo generar perfiles de Bazel

Bazel es rápido. Bazel también es lento, porque 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 correctamente Profiler. Está activada 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 registre todo lo que puede.

Emite un perfil en el formato del generador de perfiles de Chrome; 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 bien anidadas entre sí. Cada subproceso de Java obtiene su propia pila de tareas. TODO: ¿Cómo funciona esto con las acciones y el estilo de pasar de continuación?

El generador de perfiles se inicia y se detiene en BlazeRuntime.initProfiler() y BlazeRuntime.afterCommand() respectivamente e intenta estar activo el mayor tiempo posible para que podamos generar perfiles de todo. Para agregar algo al perfil, llama a Profiler.instance().profile(). Muestra una Closeable, cuyo cierre representa el final de la tarea. Es ideal para usar declaraciones probar con recursos.

También realizamos perfiles rudimentarios de memoria en MemoryProfiler. También está siempre activada y principalmente registra los tamaños máximos de montón y el comportamiento de la recolección de elementos no utilizados.

Cómo probar Bazel

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

De las pruebas de integración, tenemos los siguientes dos tipos:

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

BuildIntegrationTestCase es el framework de pruebas de integración preferido, ya que está bien equipado para la mayoría de las situaciones de prueba. Como es un framework de Java, proporciona depuración e integración continua 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. Existe un sistema de archivos desde cero que puedes usar para escribir archivos BUILD. Luego, varios métodos auxiliares pueden solicitar destinos configurados, cambiar la configuración y declarar varios aspectos sobre el resultado del análisis.