En Slash, gestionamos miles de millones de dólares en gastos anuales. Tras haber crecido más de un 1000 % durante los últimos 12 meses, nos enfrentamos constantemente a nuevos problemas a una escala cada vez mayor. A medida que la cantidad de datos aumentaba y nuestro sistema se volvía más complejo, las suposiciones que hacíamos sobre las relaciones entre los datos a menudo se rompían. Nos encontramos con problemas de múltiples fuentes: rellenos incorrectos, errores en el código de producción y pequeños errores durante las migraciones de datos. Con el tiempo, se hizo cada vez más difícil verificar la exactitud de nuestros datos, lo que nos llevó a crear lo que ahora llamamos Health Checks, un sistema diseñado para verificar continuamente la exactitud de los datos en producción.

Las comprobaciones de estado son una herramienta que nos ayuda a verificar las invariantes en nuestro código base. Cuando creamos sistemas, siempre los diseñamos con un conjunto de invariantes implícitas y explícitas, que son reglas o supuestos que esperamos que siempre se cumplan para poder crear sistemas más complejos basados en ellos.

Un ejemplo trivial de una invariante: en el Tarjeta tabla en nuestra base de datos, donde cada fila representa una tarjeta de crédito, hay un estado columna en la que uno de los valores puede ser «cerrado» y un motivo del cierre columna que es NULLABLE. Una invariante es que la estado La columna está «cerrada» **si y solo si** el motivo del cierre El campo NO es NULL.

El ejemplo anterior no es necesariamente una condición para la que escribiríamos una comprobación de estado, pero es un ejemplo de una invariante que nos aseguramos de que se mantenga mientras trabajamos con esta área del código base.

Nuestro diseño inicial de control de salud

Nuestra primera versión de los controles de salud tenía un aspecto similar al siguiente:

/**
 * This health check ensures that for every declined AuthorizationSet, there are no transfer intents
 */
export const hcrForDeclinedAuthSets = createHealthCheckRoutineDefinition({
  programmaticName: 'chargeCard.authSets.declinedAuthSets',
  title: 'Charge card declined auth sets',
  description:
    'This health check ensures for every declined AuthorizationSet, there are no transfer intents',
  schedule: {
    type: 'interval',
    interval: 'minutes',
    frequency: 30,
  },
})
  .bulkCheckFn((deps: DefineDependencies<[HealthCheckModule.Service]>) => {
    return async (
      cursor:
        | HealthCheckRoutineCursor<{
            authorizationSetId: string;
          }>
        | undefined
    ) => {
	    // getErrorAccounts -> a query to Snowflake
      const errorAccounts = await getErrorAccounts(cursor, deps);

      return {
        data: errorAccounts.data.map((val) => ({
          key: val.authorizationSetId,
        })),
        nextCursor: errorAccounts.nextCursor,
      };
    };
  })
  .singleCheckFn(() => async (params) => {
    const singleCheckRes = await runQuery(
      sql`...`
    );

    return {
      success: singleCheckRes.length === 0,
    };
  });

Inicialmente, diseñamos las comprobaciones de estado para que se definieran mediante consultas SQL. Ejecutábamos una consulta SQL en una tabla para encontrar cualquier «error potencial». Pero como ejecutar consultas SQL en tablas grandes resulta costoso, realizábamos estas comprobaciones en una réplica de lectura eventualmente consistente (en nuestro caso, Snowflake). Para cada resultado devuelto por la consulta, realizábamos una comprobación en nuestra base de datos de producción principal para asegurarnos de que no se trataba de un falso positivo. Este diseño tenía algunos problemas:

  1. Teníamos que mantener dos consultas. La primera consulta se ejecutaría en la réplica de lectura para toda la tabla. Los mantenedores también tendrían que pensar explícitamente en la paginación de la consulta y ocuparse de ella. La segunda consulta se ejecutaría en producción para cada resultado individual que devolviera la primera consulta.
  2. Las consultas sobre toda la tabla se volverían cada vez más complejas e ilegibles/difíciles de mantener, ya que estaríamos intentando codificar un montón de lógica empresarial en una sola consulta SQL.

Segunda iteración de los controles de salud

Decidimos:

  1. Las comprobaciones de estado siempre deben realizarse en nuestra base de datos de producción principal. De lo contrario, es posible que no detectemos todos los problemas que realmente se producen.
  2. Realizar «exploraciones completas de tablas» en una consulta SQL es un gran error, pero realizar una «exploración completa de tablas» controlada iterando sobre cada fila y realizando una comprobación de estado está bien, siempre y cuando la carga sea constante, predecible y no haya picos, las cosas suelen ir bien.
  3. Realizar una iteración a lo largo del tiempo en tablas en producción (y abstraer eso para el desarrollador) simplifica enormemente nuestra experiencia de desarrollo:
    1. Ya no necesitamos mantener dos consultas SQL complejas con paginación. Solo tenemos que definir la lógica de la aplicación para comprobar el estado de un único elemento.
    2. Cada comprobación de estado se define sobre una tabla completa (en el futuro, es posible que decidamos ampliar las comprobaciones de estado para que puedan iterar específicamente sobre una definición de índice).

Una gran lección que hemos aprendido como equipo a lo largo del tiempo es que los sistemas predecibles que ejercen una carga constante son ideales. El principal culpable de los problemas de producción suele ser los cambios repentinos e impredecibles, como un gran aumento en la carga de la base de datos o un cambio repentino en el planificador de consultas interno de la base de datos.

El aspecto contraintuitivo de aplicar una carga constante a los sistemas, especialmente en lo que respecta a las bases de datos y las colas, es que puede parecer un desperdicio. Solía creer que era mejor no aplicar ninguna carga a los sistemas de forma predeterminada y solo trabajar las pocas veces al día que fuera necesario. Sin embargo, esto puede dar lugar a picos de carga de trabajo que, en ocasiones, degradarían nuestro sistema de forma inesperada. En realidad, hemos descubierto que suele ser mejor que haya una pequeña carga constante en la base de datos durante un largo periodo de tiempo, incluso si esa carga constante contribuye con algo así como un 1-5 % del uso total de la CPU las 24 horas del día, los 7 días de la semana. Cuando la carga es predecible, es fácil supervisar las tasas de uso en todos los ámbitos. Esta previsibilidad nos permite escalar horizontalmente con confianza y planificar con antelación posibles problemas de rendimiento futuros.

Hay un artículo muy interesante sobre este tema escrito por el equipo de AWS: Fiabilidad, trabajo constante y una buena taza de café.

La segunda versión de los controles de salud ahora tiene el siguiente aspecto:

export const savingsAccount = createHealthCheckGroup({
  over: SlashAccountGroup,
  name: 'savings_account_interest_entities',
  schedules: {
    full_scan: {
      type: 'full_scan',
      config: {},
      props: {
        numberOfItemsToPaginate: 2000,
        minimumIntervalSinceStartOfLastRun: 3 * 60 * 60 * 1000,
      },
    },
  },
  checks: {
    interest_limit_rule_should_exist: {
      async check(group, ctx) {
        if (group.groupType === 'savings') {
          const activeInterestLimits = await from(SlashAccountGroupLimitRule)
            .leftJoin(LimitRule, {
              on: (_) => _.lr.id.equals(_.slr.limitRuleId),
            })
            .where((_) =>
              _.slr.slashAccountGroupId
                .equals(group.id)
                .and(_.slr.isActive.equals(true))
                .and(_.lr.type.equals('interest'))
            );

          ctx.shouldHaveLengthOf(activeInterestLimits, 1, {
            message:
              'Savings accounts should have exactly one active interest limit',
          });
        }
      },
    },
    // other checks
    ...
  }
});

Con este diseño, resulta muy fácil añadir nuevas comprobaciones de estado a una sola entidad, ya que cada comprobación de estado se define como una función única asíncrona. Podríamos hacer que estas comprobaciones de estado se repitieran varias veces al día en tablas completas sin tener que hacer concesiones en cuanto a la frecuencia, ya que la carga es constante. Para la comprobación de estado anterior, cada entidad realiza una búsqueda rápida en la tabla `LimitRule`. Estas comprobaciones se ejecutan de forma continua, lo que supone una carga mínima pero constante en la base de datos en cualquier momento dado.

El funcionamiento interno de este sistema puede simplificarse aproximadamente al siguiente diseño:

  1. Realizamos nuestras comprobaciones sobre Temporal para garantizar su ejecución final. Utilizamos el producto «schedules» de Temporal para asegurarnos de que nuestro cron se ejecuta cada minuto.
  2. Cada minuto, revisamos todas las comprobaciones que deben programarse AHORA MISMO y las ejecutamos en una cola de tareas. Regulamos nuestra cola de tareas en consecuencia para que nunca se envíe más de un determinado RPS a nuestra base de datos.
  3. Cada actividad que se ejecuta realiza algún tipo de trabajo «constante». No hay O(N) ni otra complejidad temporal que crezca en relación con el número total de entidades de la base de datos. Esto garantiza que las actividades se realicen rápidamente, sean fáciles de predecir y no bloqueen la propia cola de tareas.

¿Qué sigue?

Las comprobaciones de estado constituyen hoy en día una base importante en nuestro proceso de pruebas. Son el fundamento de nuestras pruebas de verificación de producción continuas. A medida que hemos ido creciendo, se han ido requiriendo cada vez más pruebas generales para mantener nuestro producto estable y funcional. Nos ha llevado varios intentos conseguirlo. Durante los últimos tres años, hemos querido implementar algún tipo de prueba de contabilidad o verificación de datos en la producción. Sin embargo, no fue hasta hace año y medio cuando finalmente conseguimos crear algo. En general, algunas lecciones importantes que hemos aprendido son:

  1. Envía algo, cualquier cosa, y repítelo con el tiempo. No podríamos haber llegado a la segunda versión de nuestros chequeos médicos sin haber enviado la primera.
  2. Mantén la sencillez: queremos facilitar al máximo a los ingenieros la creación y el mantenimiento de comprobaciones de estado. No es nada divertido trabajar con una lógica empresarial compleja integrada en múltiples consultas SQL.
  3. Ejecuta nuestras comprobaciones directamente en producción: esa es nuestra fuente de verdad, y si ejecutamos las cosas contra otra cosa, será inherentemente más complejo y más susceptible a falsos positivos/negativos.

Hoy en día, las comprobaciones de estado son el primer paso que hemos dado para mejorar las pruebas y la verificación de nuestros sistemas más allá de las formas más tradicionales, como las pruebas y la observabilidad. A medida que sigamos creciendo, iremos reflexionando y descubriendo nuevas formas de mantener la estabilidad de nuestro producto.

Read more from us