posts.

Una ontología para la capa semántica

Las capas semánticas se venden como el sitio donde poner las definiciones de métricas. Escribe revenue una vez, exponlo en todas partes, deja de discutir en reuniones.

Y entonces dos equipos reportan cifras de ingresos distintas, ambas técnicamente correctas, y la discusión se muda del panel al archivo de definiciones.

Las definiciones nunca fueron la parte difícil.

Las métricas son la capa visible, no la que sostiene el peso

Una métrica es una agregación sobre un conjunto de cosas. Cambia qué cuenta como una de esas cosas y el número cambia, sin que la fórmula se toque.

sum(amount) no es ambiguo. Qué importes es donde vive el desacuerdo:

  • ¿Un pedido cancelado sigue siendo un pedido?
  • ¿Un cliente que firmó dos contratos es un cliente o dos?
  • ¿Un pedido pertenece a la fecha en que se hizo, se pagó o se envió?
  • ¿Una devolución es un pedido negativo, o es otra cosa distinta?

Ninguna de estas es una pregunta sobre métricas. Son preguntas sobre qué es un pedido y qué es un cliente, y sobre si dos registros se refieren a la misma cosa real. Una capa semántica que solo responde la pregunta de la métrica hereda todos estos desacuerdos y no les da ningún sitio donde vivir.

Esa parte que falta es la ontología: no las fórmulas, sino las entidades sobre las que las fórmulas cuantifican.

A qué se compromete una ontología

A tres cosas, y cada una es una decisión que alguien tiene que tomar de forma explícita.

Identidad. Qué hace que dos registros sean la misma cosa. Es la que más se omite, y la que provoca dobles conteos durante años.

Granularidad. Qué significa una fila. “Una fila por pedido” y “una fila por línea de pedido” son mundos distintos, y las métricas se rompen en silencio cuando un modelo pasa de uno a otro.

Relaciones. Cómo se conectan las entidades y, sobre todo, con qué cardinalidad. Un join que abre en abanico sin que nadie lo declarara es la causa más común de un total inflado.

erDiagram
    CLIENTE ||--o{ PEDIDO : realiza
    PEDIDO  ||--|{ LINEA_PEDIDO : contiene
    PEDIDO  ||--o{ DEVOLUCION : "puede devolverse en"
    PRODUCTO ||--o{ LINEA_PEDIDO : "aparece en"
    CLIENTE {
        string cliente_id PK
        string cuenta_id "identidad: una por entidad legal"
    }
    PEDIDO {
        string pedido_id PK
        string cliente_id FK
        date   fecha_pedido
        date   fecha_reconocimiento "grano: fecha de ingreso"
        string estado
    }
    LINEA_PEDIDO {
        string linea_id PK
        string pedido_id FK
        numeric importe
    }
theory of small decisions

Lee el diagrama como un conjunto de compromisos, no como un dibujo. Dice que un pedido tiene muchas líneas, así que cualquier métrica que sume importe al grano de pedido está mal. Dice que un cliente se mapea a una cuenta, así que “número de clientes” necesita declarar a cuál de las dos se refiere. Dice que el ingreso tiene su propia fecha, distinta de cuándo se hizo el pedido.

Cada una de esas es una frase que alguien pudo discutir en una reunión. Escritas, dejan de relitigarse cada trimestre.

Hacerla ejecutable

Una ontología que vive en un diagrama se pudre. La versión que sobrevive es la que lee el motor de consultas, porque entonces no puede desviarse de lo que los números hacen de verdad.

entities:
  - name: pedido
    primary_key: pedido_id
    grain: "una fila por pedido"
    # La identidad es una decisión, no un tecnicismo: dos filas con el mismo
    # id de origen desde sistemas distintos son el mismo pedido solo después
    # de que esta regla lo diga.
    identity:
      resolve_by: [sistema_origen, id_origen]
    relationships:
      - to: cliente
        type: many_to_one
        via: cliente_id
      - to: linea_pedido
        type: one_to_many
        via: pedido_id

  - name: linea_pedido
    primary_key: linea_id
    grain: "una fila por línea dentro de un pedido"

metrics:
  - name: ingresos
    description: >-
      Neto de devoluciones, reconocido en fecha_reconocimiento y no en
      fecha_pedido, excluyendo pedidos cancelados.
    entity: linea_pedido
    agg: sum
    expr: importe
    filters:
      - "pedido.estado != 'cancelado'"
    time_dimension: pedido.fecha_reconocimiento

La línea entity: linea_pedido hace un trabajo silencioso e importante. Declara el grano al que corre la suma, que es lo que impide al motor abrir en abanico un join a nivel de pedido y contar el mismo dinero dos veces.

Cómo se resuelve una pregunta

El valor de escribir esto es que una pregunta deja de responderla quien componga el SQL, y pasa a responderla el modelo.

flowchart LR
    Q["ingresos por cliente,<br/>último trimestre"] --> M[métrica: ingresos]
    M --> G[grano: linea_pedido]
    M --> F["filtro: estado != cancelado"]
    M --> T[tiempo: fecha_reconocimiento]
    G --> J{ruta de join}
    J --> O[pedido]
    O --> C[cliente]
    C --> ID["identidad: cuenta_id"]
    ID --> R[una fila por cuenta]
theory of small decisions

Dos analistas que hagan la misma pregunta recorren ahora el mismo camino. No porque se hayan puesto de acuerdo, sino porque el desacuerdo se zanjó una vez, aguas arriba, y se codificó.

Dónde debe vivir la ontología

La parte difícil no es modelar. Es la propiedad: una ontología mantenida por un equipo central se desvía de los dominios que describe, porque la gente que sabe qué es un contrato no trabaja ahí.

Este es el argumento para empujar las definiciones hacia los dominios dueños del dato, y dejar la capa semántica como el contrato entre ellos en lugar del sitio donde se inventa el significado.

No hace falta adoptar el modelo organizativo entero para quedarse con la parte útil: la definición pertenece a donde está el conocimiento, y el trabajo de la capa es hacerla legible en todas partes.

Modos de fallo que conviene nombrar

SíntomaSuele significar
Dos equipos, dos cifras de ingresos, ambas defendiblesSin identidad acordada para la entidad
Los totales se inflan tras un join nuevoCardinalidad no declarada, abanico silencioso
Una métrica se rompe al refactorizar un modeloEl grano nunca se declaró, solo se supuso
“¿Qué fecha?” preguntado cada trimestreSemántica temporal dejada a quien escribe la consulta
Definiciones correctas pero sin usarOntología gobernada lejos del dominio

El patrón en los cinco: la fórmula estaba bien, y lo que había debajo nunca se escribió.

Por dónde empezar

No por un catálogo de métricas. Empieza por las cinco o seis entidades sobre las que el negocio más discute, y por cada una escribe las tres frases: qué hace que dos sean la misma, qué significa una fila, y cómo se conecta con las demás.

Es poco lucido, y lleva una tarde por entidad. Pero esas frases son de lo que depende en silencio cada métrica, y dejarlas sin escribir es como una capa semántica acaba siendo un sitio más donde se almacena el mismo desacuerdo.

Capa semántica - dbt - Modelado de datos - YAML

Batch o tiempo real: un tradeoff que casi siempre se plantea mal

La pregunta llega como una pregunta técnica. ¿Esta integración debería ser batch o streaming? Y se responde igual, comparando motores, latencias y cifras de rendimiento.

Ese planteamiento es el problema. La latencia no es el requisito. Es una propiedad que se compra, y lo que merece discusión es cuánta necesitas de verdad.

Empieza por la decisión, no por el pipeline

Los datos existen para que alguien, o algo, actúe. Así que la pregunta útil no es “¿cuán fresco podemos tenerlo?” sino "¿con qué rapidez ocurre realmente la acción que dispara?"

Toda frescura por encima de la cadencia de la decisión a la que alimenta es desperdicio. Cuesta dinero y complejidad y no compra nada, porque el número se queda ahí sin leer hasta la revisión semanal, el proceso nocturno de riesgo, o el momento en que una persona abre el panel.

De ahí sale una regla que conviene aplicar antes de elegir motor:

flowchart TD
    A[Integración nueva] --> B{¿Algo actúa sobre esto<br/>automáticamente?}
    B -- no --> C{¿Con qué frecuencia<br/>lo mira una persona?}
    C -- diaria o menos --> D[Batch]
    C -- continuamente --> E[Micro-batch]
    B -- sí --> F{¿La acción es reversible<br/>si el dato llega tarde?}
    F -- sí --> E
    F -- no --> G[Streaming]
theory of small decisions

La mayoría de integraciones caen en la mitad izquierda del diagrama. Las que de verdad necesitan streaming comparten una forma: algo actúa sin intervención humana, y actuar tarde es materialmente peor que no actuar.

Rechazos por fraude. Inventario que puede sobrevenderse. Decisiones de enrutamiento. Cualquier cosa donde la ventana para responder se cierra sola.

El coste que se ve

La diferencia obvia de coste es de forma, no de tamaño. Batch es a ráfagas: nada, luego un pico, luego nada. Streaming es un suelo que pagas haya o no actividad.

BatchStreaming
Perfil de cómputoCon picos, planificableSuelo continuo
Radio de un falloUna ejecuciónLa ruta en vivo
RecuperaciónRelanzarloReplay, reproceso, conciliación
Cambio de esquemaEn la próxima ejecuciónCoordinado, con eventos en vuelo
Quién puede depurarloCasi todo el equipoQuien conozca el motor de streaming

Esa última fila es la que suele quedar fuera de la estimación, y a menudo es la que decide si el sistema sobrevive a su segundo año.

El coste que aparece después

La parte cara del tiempo real no es la infraestructura. Es que la corrección deja de ser evidente.

En batch, “los pedidos de ayer” es un conjunto cerrado. Sabes que está completo porque el día terminó. En un stream no existe esa frontera: tú decides una, y cada evento que llega después te obliga a tomar una decisión que ahora tienes que hacer explícita.

sequenceDiagram
    participant S as Origen
    participant P as Pipeline
    participant C as Consumidor
    S->>P: evento t=10:00
    S->>P: evento t=10:02
    P->>C: ventana 10:00-10:05 cerrada
    Note over P,C: el consumidor actúa sobre el total
    S-->>P: evento t=10:01 (llega 10:07)
    Note over P: tarde — ¿reexpresar, descartar<br/>o emitir una corrección?
theory of small decisions

No hay respuesta gratis. Descartarlo deja el número silenciosamente mal. Reexpresarlo significa que cada consumidor aguas abajo tiene que manejar un total que cambia después de los hechos. Emitir una corrección significa que tienen que entender qué es una corrección.

En batch, ese mismo evento simplemente entra en la siguiente ejecución.

Cómo se ve esto en la práctica

La versión batch de un acumulado es una consulta con una frontera que puedes señalar:

-- Modelo incremental: la frontera es explícita e inspeccionable.
select
    customer_id,
    date_trunc('day', ordered_at) as day,
    sum(amount)                   as daily_amount,
    count(*)                      as order_count
from {{ ref('orders') }}
where ordered_at >= (select coalesce(max(day), '1900-01-01') from {{ this }})
group by 1, 2

Si está mal, lo relanzas. El modo de fallo es un número desactualizado, y eso es una condición que puedes detectar y alertar.

La versión streaming tiene que declarar qué cree sobre el tiempo:

# El ventaneo es la parte fácil. El watermark es la decisión de verdad:
# declara cuánto espera el pipeline a los rezagados antes de dar una
# ventana por cerrada, y por tanto qué eventos pasan a ser "tardíos".
orders = (
    env.from_source(kafka_source, watermark_strategy, "orders")
       .key_by(lambda o: o.customer_id)
       .window(TumblingEventTimeWindows.of(Time.days(1)))
       .allowed_lateness(Time.hours(2))
       .aggregate(SumAmount())
)

allowed_lateness(2 hours) no es un parámetro de ajuste. Es una afirmación de negocio: pasadas dos horas preferimos equivocarnos antes que esperar. Alguien debería aceptarlo de forma explícita, y casi nunca se le pregunta.

La trampa del híbrido

El instinto cuando hacen falta los dos es montar ambos: una ruta rápida para la frescura, una ruta batch para la corrección, y conciliar aguas abajo. Es un diseño que suena razonable y que suele envejecer mal, porque la misma lógica de negocio existe ahora dos veces y se desincroniza.

Jay Kreps formuló bien el argumento contrario: si el log es reproducible, puedes reprocesar el histórico con el mismo código que atiende el stream en vivo, y la segunda implementación deja de ser necesaria.

El argumento merece tomarse en serio, y merece leerse por lo que es: una defensa de no duplicar lógica, no una defensa de convertirlo todo a streaming.

Lo que preguntaría antes de elegir

  • ¿Qué actúa sobre esto, y con qué rapidez actúa de verdad?
  • Si el número tuviera dos horas de retraso, ¿qué saldría mal, concretamente?
  • ¿Quién está de guardia a las 3 de la mañana, y conoce este motor?
  • Cuando un valor se reexpresa después, ¿qué hace el consumidor?
  • Si elegimos batch ahora y necesitamos streaming en un año, ¿cuánto cuesta esa migración?

La última importa más de lo que parece. De batch a streaming suele ser una reescritura. De streaming a batch suele ser una simplificación. Cuando la respuesta no está clara, esa asimetría aconseja empezar por lo simple.

Ninguna de estas es una decisión grande. Cada una es una frase en un documento de diseño, tomada en una tarde, y cada una fija en silencio lo que se le podrá pedir al sistema dos años después.

Kafka - Flink - dbt - SQL