El Calendarizador

Cómo la calendarización se convierte en infraestructura: la frescura del dato como contrato, no como esperanza.


Abrís una llave y sale agua, aburrida, confiable, como describió el Post 1. Pero el Post 1 dejó una pregunta abierta: ¿qué tan vieja está esa agua?

Una llave que cuando se abre entrega agua vieja o no trae agua es equivalente al pipeline que entrega datos rancios. Un analista que consulta Sales_Opportunities a las 9:00 am y obtiene datos de hace dos días no tiene un problema de pipeline. Tiene un problema de frescura. Una tabla llamada Sales_Opportunities no es un artefacto estático. Es una snapshot de un momento que ya pasó. Cada consulta es una respuesta a una pregunta implícita: ¿qué tan viejos son estos datos? ¿Podés prometer, a un analista, a un pipeline downstream, a un proceso de negocio que depende de él, que los datos tienen menos de cuatro horas? ¿O seis? ¿Lo sabés, o solo esperás?

La esperanza no es una calendarización.

Este post es sobre el componente de una capa Bronze dirigida por metadatos del que nadie habla hasta que algo se rompe a las 3:00 am: el Calendarizador (Scheduler en inglés). No porque sea complejo (no lo es), sino porque es la parte que convierte la frescura del dato de una propiedad que descubrís después del hecho en una ventana de frescura que comprometés de antemano: no un timestamp exacto, sino una promesa acotada.

La restricción que fuerza el diseño

Empecemos con una realidad simple. En Microsoft Fabric, podés adjuntar una programación nativa a un pipeline (hasta 20 por pipeline; límite documentado de la plataforma). Para un Lakehouse con un puñado de fuentes en una cadencia común, 20 alcanza y sobra. La restricción aparece cuando la lógica del cuándo se mueve a un archivo YAML: un pipeline maestro único necesita evaluar cuáles de las 350 cargas están pendientes en cada momento dado, aplicar calendarizaciones por carga, y respetar ventanas y offsets por carga. Una programación nativa puede decirle a un pipeline «corré a las 04:00 los martes», pero no puede decirle «corré la carga de Sales Salesforce a las 04:00 los martes pero saltá Finance SAP porque está pausada por migración». Esa lógica condicional pertenece al YAML y al orquestador que lo lee, no a la interfaz de calendarización de la plataforma.

En un Lakehouse real (múltiples fuentes, múltiples dominios, múltiples países) tenés 350+ cargas. No estás construyendo 350 pipelines calendarizados individualmente. Ese camino lleva al mismo problema de proliferación con el que abrió el Post 1: 200 pipelines, cada uno con su propia programación alambrada en el portal, ninguno visible en Git, ninguno comparable en un diff, ninguno auditable. No podés responder «¿qué cambió y cuándo?» ni «¿qué corre a las 4:00 am y por qué?» sin hacer clic en cada pipeline manualmente.

La solución dirigida por metadatos es el mismo patrón aplicado acá: un pipeline maestro que evalúa cada pocos minutos cuáles cargas están pendientes, y un único archivo YAML que declara la calendarización de todas ellas. La complejidad se mueve de la plataforma a un archivo donde pertenece: versionable, revisable y editable en cualquier editor de texto.

Dos contratos, dos archivos

La decisión de diseño más importante de este post: el YAML del Calendarizador nunca es el mismo archivo que el YAML de ingesta.

El Post 1 introdujo el YAML de ingesta: Sales_Salesforce.yml, que declara qué ingerir y cómo. El YAML del Calendarizador (llamémoslo _Calendarizador.yml) declara cuándo. Son dos archivos separados porque responden dos preguntas distintas, evolucionan a dos ritmos distintos y pertenecen a dos audiencias distintas.

El YAML de ingesta cambia cuando cambia el modelo de datos: una nueva entidad, una actualización de schema, un nuevo tag de seguridad. Es un artefacto de ingeniería de datos.

El YAML del Calendarizador cambia cuando cambia el contrato operacional: una fuente se cae, una carga necesita correr más seguido, un nuevo proceso de negocio requiere datos intradía. Es un artefacto operacional. Los equipos de Ops que no tienen ningún interés en los detalles del schema igual tienen opiniones sobre ventanas horarias y políticas de retry.

Mezclar ambas responsabilidades en un único archivo crea un merge conflict esperando suceder. Dos archivos separados significan dos PRs separados, dos revisores separados, dos cadencias separadas. El contrato que gobierna qué dato se ingiere queda desacoplado del contrato que gobierna cuándo.

Un lente RACI hace concreta la separación. En el YAML de ingesta, el Data Owner (un rol de negocio) es el Responsable: define qué entidades se ingieren, qué tags de seguridad aplican, qué cuenta como la fuente de registro. Operaciones es el Informado. En el YAML del Calendarizador, la dirección se invierte: Operaciones es el Responsable (gestiona ventanas horarias, políticas de retry y secuenciado de cargas) y el Negocio es el Informado. El mismo nombre de campo, enabled, lleva distinta responsabilidad según qué archivo estés leyendo. En el YAML de ingesta, enabled: false es una decisión de negocio comunicada hacia abajo a Ops. En el YAML del Calendarizador, enabled: false es una decisión operacional comunicada hacia arriba al Negocio. El efecto operacional es el mismo en ambos (la carga no corre), pero el dueño es diferente, y también lo es la cadena de aprobación. Cada archivo, una dirección de responsabilidad.

DimensiónYAML de ingestaYAML del Calendarizador
DeclaraQué ingerir, cómo clasificarCuándo correr, política de retry
ResponsableData OwnerOperaciones
InformadoOperacionesNegocio
Cambia cuandoCambia el modelo de datosCambia el contrato operacional
Revisor del PRIngeniería de datosOperaciones

Anatomía del YAML del Calendarizador

Así se ve un YAML de Calendarizador completo con claves en inglés:

# File: _Calendarizador.yml
# Controls WHEN each load runs -- separate from WHAT and HOW

timezone: "America/Mexico_City"
execution_window:
  interval_minutes: 10          # Master pipeline polls every 10 min

retry_policy:
  max_attempts: 2
  backoff_minutes: 20

loads:
  - domain: Sales
    source: Salesforce
    enabled: true
    schedule:
      type: weekly
      day_of_week: "Tuesday"
      local_time: "04:00"

  - domain: Finance
    source: SAP
    enabled: false
    disable_reason: "SAP S/4 migration in progress -- re-enable after cutover"
    schedule:
      type: daily
      local_time: "02:00"

  - domain: Operations
    source: ERP
    enabled: true
    schedule:
      type: intraday
      interval_hours: 4
      window_start: "06:00"
      window_end: "22:00"

Recorramos las secciones:

timezone: Todos los horarios del archivo son locales. El pipeline maestro convierte a UTC internamente. Cada equipo que lea este archivo debería poder razonar sobre «04:00» sin hacer cálculos de zona horaria mentalmente. Usá una única zona horaria para todo el archivo.

execution_window.interval_minutes: El pipeline maestro corre con una programación nativa de Fabric, cada 10 minutos en este caso. Se despierta, lee el YAML, evalúa qué cargas están pendientes basándose en la calendarización y el último tiempo de ejecución, y las dispara. El intervalo es el piso de precisión de tus garantías de frescura: si una carga está programada para las 06:00 y el pipeline corre a las 05:58 y a las 06:08, se dispara a las 06:08. Ese desfase de 8 minutos es aceptable para cargas diarias. Importa más para las intradía, a las que volveremos.

retry_policy: Dos intentos de retry, 20 minutos de backoff. Definido una vez, aplica a todas las cargas. La política está centralizada porque el comportamiento de retry es una responsabilidad transversal: la próxima sección explica por qué debe vivir acá y no en los notebooks individuales.

loads: Cada entrada mapea a un YAML de ingesta: domain y source juntos forman la clave de lookup. El orquestador deriva el filename como {Domain}_{Source}.yml, la misma convención establecida en el Post 1. Sin tabla de configuración, sin archivo de mapeo. El filename es el contrato. Los tipos de calendarización van de weekly a daily a intraday. El par enabled/disable_reason merece su propia sección abajo.

El retry pertenece acá, no en el notebook

Un notebook puede hacer retry. Podés envolver el loop de ingesta en un try/except, atrapar errores transitorios, esperar y reintentar. Muchos equipos lo hacen.

El problema: cuando cada notebook gestiona su propio retry, el comportamiento es inconsistente. Diferentes notebooks tienen diferentes conteos de reintentos, diferentes duraciones de backoff, diferentes categorías de errores que consideran retryable. Uno tiene 3 intentos con 10 minutos de backoff. Otro tiene 5 intentos sin backoff. Un tercero no tiene retry en absoluto. Cuando algo falla a las 3:00 am, no estás mirando una política de retry; estás mirando lo que cada quien que escribió ese notebook decidió ese día.

El Calendarizador centraliza la política. Una declaración gobierna todas las cargas. Si la política cambia (el negocio decide que 3 intentos es el nuevo estándar, o una fuente se vuelve inestable y necesita backoff extendido), editás un YAML y un PR. No cincuenta notebooks.

El principio más profundo: el retry no es específico a una fuente de datos. Es una responsabilidad transversal sobre la confiabilidad del sistema. Las responsabilidades de confiabilidad pertenecen a la capa de orquestación, no dispersas entre unidades de ejecución individuales. Es el mismo argumento que Infrastructure as Code hace sobre el aprovisionamiento de servidores: la configuración que aplica en todas partes debería vivir en un lugar, no repetida con variaciones en cada manifiesto de despliegue.

Enabled/DisableReason: audit trail, no solo un toggle

La entrada de Finance SAP arriba tiene enabled: false. Sin contexto, eso es ruido.

Con disable_reason: "SAP S/4 migration in progress; re-enable after cutover", es memoria institucional. Seis meses después, un ingeniero nuevo abrirá este archivo a las 2:00 am porque alguien se quejó de que los datos de Finance no se actualizan desde febrero. La respuesta ya está ahí. Nadie tiene que recordarlo. Nadie tiene que escarbar en el historial de Slack ni preguntarle a la persona que tomó la decisión.

Esto importa más por quién es el dueño de este archivo. Operaciones establece enabled: false, pero el Negocio es el Informado. Dependen de que el dato esté disponible; cuando no lo está, necesitan una explicación que no requiera un hilo de Slack, un ticket o una escalación. disable_reason es esa explicación, embebida en el contrato mismo. El ingeniero a las 3:00 am puede leerla. También el analista que esperaba datos frescos a las 8:00 am.

La regla: enabled: false sin disable_reason debería fallar tu validación pre-merge. Yamale maneja verificaciones de campos estáticos de forma nativa; hacer cumplir esta regla condicional («si enabled es false, disable_reason es requerido») necesita un pequeño validador personalizado junto a él: una función Python, llamada en el mismo paso de CI. Si estás deliberadamente deteniendo una carga de producción, le debés al próximo ingeniero (y a la versión de vos mismo a las 3:00 am) una explicación. La razón no es opcional. Es parte del contrato.

Qué pertenece en un disable_reason: suficiente contexto para tomar una decisión sin escalar. «Disabled for testing» no supera esa barra. «SAP S/4 migration in progress; re-enable after cutover, coordinate with finance@» la supera.

Cargas intradía: cuando el Calendarizador se convierte en infraestructura crítica

Las cargas diarias son convenientes. Las cargas de alta cadencia (las intradía) son donde el Calendarizador pasa de nice-to-have a infraestructura crítica.

La mayoría de las cargas Bronze en un Lakehouse típico corren una vez al día o una vez a la semana. El Calendarizador es útil ahí, pero las apuestas son bajas: un trigger perdido significa datos rancios por horas, que es recuperable. Ahora agregá una carga que corre cada cuatro horas. O cada 15 minutos.

  - domain: Inventory
    source: WMS
    enabled: true
    schedule:
      type: intraday
      interval_hours: 4
      window_start: "06:00"
      window_end: "22:00"

Con interval_hours: 4 y una ventana de 6:00 am-10:00 pm, esta carga se dispara a las 06:00, 10:00, 14:00, 18:00 y 22:00 (cinco veces al día). Cada corrida debe completarse, registrar su resultado y liberar su lock antes de que la siguiente esté programada. El sistema tolera un desfase de 10 minutos cuando se espera un dato una vez a la semana. No lo tolera cuando un proceso de reabastecimiento de almacén espera una snapshot cada cuatro horas.

El interval_minutes del Calendarizador (la cadencia de polling del pipeline maestro) ahora importa. A 10 minutos, una carga pendiente se dispara dentro de los 10 minutos de su horario objetivo. Si eso es aceptable depende del SLO downstream. Si no lo es, la cadencia va a 5 minutos (un cambio de configuración, no una rearquitectura de la plataforma).

Lo que no es problema del Calendarizador: cargas por debajo de aproximadamente 15 minutos. A esa frecuencia, ya no estás en territorio batch. El overhead del patrón evaluar-y-disparar empieza a dominar, y el motor Bronze dirigido por metadatos no está diseñado para eso. Los requerimientos en tiempo real o casi real pertenecen a infraestructura de streaming; en Fabric, eso es Eventstream. El límite es aproximadamente: si necesitás datos más frescos de 15 minutos, diseñá para streaming; si podés aceptar 15 minutos o más, la calendarización batch funciona y es más simple.

El Calendarizador como palanca de balanceo de carga

El Calendarizador responde «cuándo», pero «cuándo» no es puramente una pregunta de negocio. También es una pregunta de infraestructura.

200 cargas disparándose simultáneamente a las 02:00 porque cada equipo quiere sus datos «lo primero de la mañana» no es una calendarización. Es una estampida. Satura el On-Premises Data Gateway, martilla los sistemas fuente y quema capacidad de Fabric en un pico en lugar de distribuirla a lo largo de la noche.

El Calendarizador es la perilla que previene esto. Escalonar cargas a lo largo de la noche no cuesta nada y reduce cada pico: gateway, base de datos fuente y capacidad de Fabric simultáneamente.

# Antes: estampida a las 02:00
loads:
  - domain: Sales
    source: Salesforce
    schedule:
      type: daily
      local_time: "02:00"
  - domain: Marketing
    source: Salesforce
    schedule:
      type: daily
      local_time: "02:00"
  - domain: Finance
    source: SAP
    schedule:
      type: daily
      local_time: "02:00"

# Después: escalonado a lo largo de la ventana
loads:
  - domain: Sales
    source: Salesforce
    schedule:
      type: daily
      local_time: "02:00"
  - domain: Marketing
    source: Salesforce
    schedule:
      type: daily
      local_time: "02:30"
  - domain: Finance
    source: SAP
    schedule:
      type: daily
      local_time: "03:00"

Nada de campos nuevos, ninguna configuración de plataforma: un ajuste horario en YAML es todo lo que se necesita.

Cuando aparecen problemas de desempeño en la práctica, la secuencia de diagnóstico importa. Empezá con Fabric:

  • Si el CU de Fabric está por debajo del ~70% de capacidad, Fabric no es el cuello de botella. Buscar una corrección en Fabric es mirar en el lugar equivocado.
  • Si el CU de Fabric está cerca o por encima de la capacidad, las opciones incluyen mover workspaces entre capacidades o habilitar facturación de autoscale para jobs de Spark.

Movete afuera hacia el gateway si Fabric está limpio:

  • Un On-Premises Gateway tipicamente maneja 5-10 conexiones simultáneas en hardware estándar (el techo exacto varía por máquina). Por encima de eso, las conexiones se encolan. Señales de saturación: CPU por encima del 75%, throughput de red cerca de su techo, o consultas de latencia simple más lentas de lo que deberían ser. La guía de performance de gateway de Microsoft cubre las palancas de tuning en detalle.

Movete afuera nuevamente a los sistemas fuente:

  • Para fuentes relacionales, la pregunta es CPU, disk I/O, memoria y red durante la ventana de carga Bronze. Una base de datos fuente que maneja la carga transaccional normal sin problema puede igual ceder cuando el ETL de Bronze la golpea a las 3:00 am, especialmente si es hardware on-premises sin capacidad elástica. Correlacionar las métricas del fuente con los horarios de carga es una investigación directa: si el fuente muestra picos de CPU que coinciden con tus ventanas de ingesta, la corrección es escalonar (mover dos cargas simultáneas a un offset de 30 minutos, por ejemplo) antes de agregar capacidad.

El Calendarizador es la herramienta más barata del stack para balanceo de carga. Un ajuste horario es un commit de YAML y un PR. Agregar capacidad es una conversación de presupuesto.

El lock: una consecuencia natural

Hay una cosa más que el Calendarizador crea y que el Post 4 tratará directamente: locks.

Cuando el pipeline maestro dispara una carga, adquiere un lock en una tabla de control: una fila que dice «esta carga está corriendo actualmente». El lock evita que el siguiente ciclo de evaluación dispare la misma carga de nuevo antes de que la primera corrida haya terminado. Dos corridas concurrentes de la misma carga producirían datos duplicados o sobreescribirían las particiones del otro. El lock no es opcional.

Lo que el lock no maneja es una corrida que crashea sin liberarlo. El pipeline falla. El lock queda adquirido. La próxima corrida programada queda bloqueada, y la que sigue. Un único notebook que falla a las 2:00 am, a mitad de corrida, sin liberar su lock, puede congelar la ingesta de todo un dominio hasta que alguien nota que los datos no se actualizaron a las 9:00 am. Siete horas de datos rancios silenciosos por una excepción no manejada. Eso es un lock zombie. Y los locks zombies se merecen su propio post.

El Calendarizador es lo que hace que la llave sea predecible. Una llave que abre al azar no es infraestructura. Es una fuga.

Compensaciones honestas

El pipeline maestro es en sí mismo un único punto de falla. Si el pipeline que evalúa el YAML del Calendarizador crashea o se pausa, ninguna carga se dispara: ni una, ni dos, todas ellas. Monitoreá el pipeline maestro con las mismas alertas que aplicarías a cualquier componente de infraestructura crítica.

La gestión de zonas horarias es genuinamente molesta. Una única zona horaria declarada al principio del archivo es el default correcto. Implementaciones multi-país donde diferentes fuentes «pertenecen» naturalmente a diferentes zonas horarias empujan hacia campos de zona horaria por carga. Resistí esto hasta que lo necesités: un archivo de Calendarizador donde cada entrada vive en una zona horaria diferente te va a costar a las 3:00 am.

El Calendarizador declara el contrato pero no la alarma. El orquestador usa fire-and-forget: dispara cada carga y sigue sin esperar su completación. LogEnd (el marcador de fin de corrida del pipeline) corre cuando el último trigger se disparó, no cuando la última carga terminó. El orquestador no conoce los resultados, y eso es por diseño. Eso significa que el monitoreo del SLO de frescura no puede vivir en el orquestador; requiere un Monitor Pipeline separado que lee los logs de ejecución de forma independiente. Ese pipeline también necesita su propia programación nativa de Fabric, porque es el único componente que puede detectar que el pipeline maestro mismo se ha caído.

Qué viene

Post 4. Cicatrices de producción: El YAML se veía perfecto en el PR. Después Spark 3.x rechazó fechas de 1753, un lock sobrevivió a su corrida y bloqueó un dominio entero por siete horas, y el YAML en Git resultó no ser el que corría en el Lakehouse. Lecciones operacionales y cicatrices de producción, y los patrones que contienen el daño.

Post 5. Runbooks como infraestructura: Cuando la llave se rompe. Por qué las instrucciones para un humano a las 3:00 am son ingeniería, no documentación, y por qué viven en Git junto al código que describen.

Post 6. Metadato vivo: Capturamos metadatos con cada ingesta. Pero metadato que nadie lee es metadato muerto. Cómo los logs se vuelven reportes, los reportes se vuelven decisiones, y tres audiencias distintas terminan consumiendo los mismos datos subyacentes para tres propósitos diferentes.


Anterior: Metadatos hasta el fondo | → Siguiente: Cicatrices de producción


Este es el tercer post de la serie «Ingesta dirigida por metadatos en YAML». Los patrones descritos evolucionaron a través de varias implementaciones de Lakehouse empresariales y son agnósticos a la plataforma, aunque nuestra plataforma de referencia es Microsoft Fabric.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *