Apéndice B — Metadatado y catálogo

Terminada la ingesta tenemos una capa de aterrizaje llena de tablas. Y aparece, inevitablemente, la pregunta incómoda: ¿quién sabe qué hay ahí dentro?

Al principio no es un problema, porque quien cargó los datos es quien los consulta y lo tiene todo en la cabeza. El problema llega cuando hay cuarenta tablas, tres personas y dos de ellas se incorporaron el mes pasado. Entonces empiezan las preguntas que ninguna consulta SQL responde: qué significa exactamente esta columna, de dónde sale esta tabla, a quién pregunto si el número me parece raro, puedo fiarme de este dato para el informe del comité.

Los datos que responden a esas preguntas son los metadatos: datos sobre nuestros datos.

B.2 Qué guarda un catálogo de gobierno

Merece la pena separar las familias de metadatos, porque cada una se obtiene de forma distinta y tiene un coste de mantenimiento muy diferente:

Familias de metadatos y su origen
Familia Ejemplos De dónde sale
Técnicos Esquemas, tipos, tamaño, particiones Automáticamente, del sistema
Operativos Última actualización, frecuencia, volumen por carga Automáticamente, de los procesos
De uso Consultas más frecuentes, quién lee qué Automáticamente, de los registros
De linaje Qué tabla alimenta a cuál, a nivel de columna Semiautomático, del SQL y los procesos
De calidad Tests, perfiles, incidencias Se declaran una vez, se ejecutan siempre
De negocio Glosario, definiciones, dominios A mano, por personas
De gobierno Propietarios, clasificación, políticas, contratos A mano, por personas

Las dos últimas filas son las que dan valor real y las únicas que no se pueden automatizar. Es un patrón que conviene tener presente antes de elegir herramienta: la parte que una herramienta resuelve sola es la parte fácil.

B.3 OpenMetadata

OpenMetadata es hoy uno de los dos grandes proyectos abiertos de esta categoría, bajo licencia Apache 2.0. En 2026 ha reposicionado su discurso hacia lo que llaman la capa de contexto abierta, con el argumento de que un asistente o un agente que consulta datos necesita exactamente lo mismo que necesita una persona nueva en el equipo: saber qué significa cada cosa y si puede fiarse.

Su base es un modelo de entidades definido con más de setecientos esquemas JSON, publicado como estándar abierto en openmetadatastandards.org. Ese detalle importa más de lo que parece: el modelo está especificado con independencia del producto, con lo que los metadatos no quedan cautivos de la herramienta.

Sobre ese modelo se articulan las piezas habituales:

  • Activos de datos: bases de datos, esquemas, tablas y columnas, pero también procesos, topics de mensajería, cuadros de mando, modelos de aprendizaje automático y APIs.
  • Semántica: glosarios, términos de negocio, métricas y clasificaciones, además de dominios y productos de datos para quien organice el trabajo al estilo data mesh que ya mencionamos en los nuevos paradigmas.
  • Linaje a nivel de tabla y de columna, que es la diferencia entre saber que un informe depende de una tabla y saber que la métrica de facturación depende de esa columna concreta.
  • Calidad: casos de prueba, perfilado, comprobaciones de frescura e incidencias.
  • Gobierno: propietarios, equipos, roles, políticas y certificaciones.

B.3.1 Levantarlo

Para trastear en local, la vía corta. Conviene saber de antemano que pide unos 6 GiB de memoria para Docker, porque no es una pieza ligera:

mkdir openmetadata-docker && cd openmetadata-docker

# Descargar el docker-compose.yml de la última release desde
# https://github.com/open-metadata/OpenMetadata/releases/latest

docker compose -f docker-compose.yml up --detach

La interfaz queda en http://localhost:8585, con admin@open-metadata.org y contraseña admin. Para desmontarlo todo, docker compose down --volumes.

Frente a DataHub, que necesita Kafka, una base de datos de grafos, Elasticsearch y una relacional, OpenMetadata se conforma con dos o tres piezas. Es la razón principal por la que suele ganar cuando el equipo es pequeño.

B.3.2 Alimentarlo

Hay cuatro vías, y en un despliegue real acaban usándose todas:

  • Conectores, más de ciento treinta, que se conectan al sistema y extraen esquemas, perfiles y estadísticas de uso de forma programada.
  • OpenLineage, el estándar abierto de linaje. OpenMetadata puede consumir eventos OpenLineage desde Kafka o Kinesis y traducirlos a su propio grafo, que es la forma de que el linaje lo reporten los propios procesos en lugar de deducirse.
  • API REST y SDKs de Python y TypeScript, para lo que no cubra ningún conector.
  • Servidor MCP, para que un asistente pueda consultar el catálogo como contexto. Es la novedad de esta generación de herramientas y encaja con la idea de que el catálogo deja de ser una web que nadie visita para convertirse en algo que se pregunta.

B.3.3 Contratos de datos

Cuando hablábamos del contrato con el origen decíamos que era lo único que evita de verdad que una ingesta se rompa por sorpresa. Ese acuerdo tiene desde hace poco un formato estándar: ODCS (Open Data Contract Standard), mantenido por el proyecto Bitol bajo la Linux Foundation AI & Data, y que en su versión 3.1 añade relaciones entre propiedades, validación más estricta y acuerdos de nivel de servicio ejecutables.

OpenMetadata soporta ODCS de forma nativa, lo que permite que el contrato deje de ser un documento en una carpeta compartida y pase a ser un artefacto verificable: se declara qué campos son obligatorios, qué reglas de calidad deben cumplirse y qué frescura se garantiza, y el propio catálogo comprueba si se está cumpliendo.

B.4 Encajarlo con nuestro stack

Aquí toca ser honesto, porque es donde las guías de arquitectura suelen pintar una flecha y seguir adelante.

AdvertenciaDuckDB y DuckLake no tienen conector nativo

A día de hoy OpenMetadata no incluye un conector oficial para DuckDB ni para DuckLake. Existe algún conector comunitario y está documentada la vía de los conectores personalizados, que consiste en extender la clase Source del marco de ingesta, implementar el método _iter que va emitiendo peticiones de creación de entidades, empaquetarlo en la imagen de ingesta y darlo de alta como servicio de tipo Custom.

Es perfectamente viable y es trabajo real. Conviene contarlo antes de dibujar la flecha.

Tampoco es cierto, aunque se lea con frecuencia, que dlt publique automáticamente su linaje en OpenMetadata. Lo que existe es lo contrario: una fuente de dlt que lee metadatos de OpenMetadata para llevarlos a una base de datos. Para reportar linaje desde los procesos, la vía es OpenLineage o la API.

Podríamos cerrar aquí con un “pues no encaja del todo” y dejarlo para más adelante. Pero sería pasar por alto lo importante: las dos herramientas que ya usamos generan, cada una por su lado, casi todos los metadatos que un catálogo pediría. Lo que falta no es información, es juntarla. Y eso sí está a nuestro alcance.

B.5 Trazabilidad de principio a fin con dlt y dbt

El recorrido del dato en las partes anteriores tiene dos mitades y una costura:

  • De origen a aterrizaje manda dlt, que sabe de qué sistema extrajo, qué esquema se encontró, cuándo cargó y con qué identificador de carga entró cada fila.
  • De aterrizaje a consumo manda dbt, que sabe qué modelo depende de cuál, cómo se materializa cada uno, qué pruebas pasa y qué dice su documentación.
  • La costura es la declaración de source() en dbt. Es literalmente el punto donde una herramienta deja de saber y empieza la otra.

Ninguna de las dos ve la mitad de la otra, y esa es toda la brecha. Un catálogo comercial la cierra conectándose a ambas; vamos a cerrarla nosotros con los artefactos que ya se escriben en cada ejecución, sin instalar nada.

Partimos del mismo ejemplo de la secretaría académica, esta vez con la ingesta real por delante: dlt extrae de la base de datos origen y aterriza en DuckLake, y dbt construye encima el vault y la capa de consumo.

import dlt
from dlt.sources.sql_database import sql_database
from dlt.destinations import ducklake
from dlt.destinations.impl.ducklake.configuration import DuckLakeCredentials

pipeline = dlt.pipeline(
    pipeline_name="secretaria",
    destination=ducklake(
        credentials=DuckLakeCredentials(
            ducklake_name="lago",
            catalog=f"sqlite:///{utilidades.CATALOGO}",
            storage=utilidades.DATOS,
        )
    ),
    dataset_name="staging",
    pipelines_dir=os.path.join(utilidades.ESPACIO, "_estado_dlt"),
)

info = pipeline.run(
    sql_database(
        credentials=f"sqlite:///{ORIGEN}",
        table_names=["alumnos", "asignaturas", "cursa"],
    ),
    write_disposition="replace",
)

B.5.1 Lo que ya sabe dlt

Junto a las tablas de datos, dlt deja en el propio destino tres tablas técnicas. Es un detalle que se pasa por alto y que aquí resulta decisivo: esos metadatos viajan dentro del mismo lakehouse, no en un servidor aparte que alguien pueda apagar.

La primera es el registro de cargas.

utilidades.consultar("""
    SELECT load_id, schema_name, status, inserted_at
    FROM staging._dlt_loads
    ORDER BY inserted_at
""")
load_id schema_name status inserted_at
0 1786723682.7462032 sql_database 0 2026-08-14 16:08:04.431556+00:00

Y ese identificador de carga viene estampado en cada fila, con lo que la pregunta “¿de qué ejecución salió este dato?” tiene respuesta exacta sin salir de SQL.

utilidades.consultar("""
    SELECT a.id_alumno, a.email, a._origen, a._dlt_load_id, c.inserted_at
    FROM staging.alumnos a
    JOIN staging._dlt_loads c ON a._dlt_load_id = c.load_id
    ORDER BY a.id_alumno
""")
id_alumno email _origen _dlt_load_id inserted_at
0 1 iraitz@ejemplo.eus secretaria 1786723682.7462032 2026-08-14 16:08:04.431556+00:00
1 2 javier@ejemplo.eus secretaria 1786723682.7462032 2026-08-14 16:08:04.431556+00:00
2 3 miguel@ejemplo.eus secretaria 1786723682.7462032 2026-08-14 16:08:04.431556+00:00

La segunda tabla, _dlt_version, guarda como JSON el esquema completo tal y como dlt lo dedujo, con tipos, pistas y hasta el historial de versiones anteriores del esquema. Es exactamente el inventario que un conector de catálogo se dedicaría a extraer.

esquema = json.loads(
    utilidades.consultar("""
        SELECT schema FROM staging._dlt_version
        ORDER BY version DESC LIMIT 1
    """).iloc[0, 0]
)

pd.DataFrame(
    [
        (tabla, columna, propiedades["data_type"])
        for tabla, definicion in esquema["tables"].items()
        if not tabla.startswith("_dlt")
        for columna, propiedades in definicion["columns"].items()
    ],
    columns=["tabla", "columna", "tipo"],
).head(10)
tabla columna tipo
0 alumnos id_alumno bigint
1 alumnos nombre text
2 alumnos apellido text
3 alumnos email text
4 alumnos _origen text
5 alumnos _cargado_en text
6 alumnos _dlt_load_id text
7 alumnos _dlt_id text
8 asignaturas id_asignatura bigint
9 asignaturas nombre text

La tercera, _dlt_pipeline_state, guarda el estado incremental, que es lo que hace que una segunda ejecución no vuelva a traerse lo que ya está.

TipPropagar el identificador de carga hasta el final

Hay una mejora pequeña con un efecto desproporcionado. Data Vault ya reserva un campo para esto, record_source, y en los capítulos de transformación lo rellenábamos con el nombre del sistema origen. Si en la capa de preparación arrastramos también el identificador de carga de dlt:

-- models/prep/stg_alumnos.sql
select
    ...
    _cargado_en                       as load_date,
    _origen || '/' || _dlt_load_id    as record_source
from {{ source('staging', 'alumnos') }}

entonces cada fila del almacén, hasta la capa de consumo, sabe de qué ejecución de ingesta salió. Ante un número raro en un cuadro de mando, el camino de vuelta hasta la carga concreta y su registro de errores es una consulta, no una investigación.

B.5.2 La costura: los sources de dbt

La declaración de source() es donde las dos mitades se dan la mano, y merece algo más de cariño del que se le suele dar. Con dos líneas más en el YAML, dbt pasa a vigilar también lo que hace la ingesta:

# models/prep/_sources.yml
version: 2

sources:
  - name: staging
    description: >
      Capa de aterrizaje que dejó la parte de ingesta. Refleja el sistema
      origen sin interpretar y no se modifica desde aquí.
    schema: staging
    loaded_at_field: "cast(_cargado_en as timestamp)"
    freshness:
      warn_after: {count: 24, period: hour}
      error_after: {count: 72, period: hour}
    tables:
      - name: alumnos
        description: Alumnos que constan en secretaría, sin interpretar.
      - name: asignaturas
        description: Catálogo de asignaturas ofertadas.
      - name: cursa
        description: Qué alumno cursa qué asignatura, la relación en crudo.

loaded_at_field apunta a la marca de tiempo que dejó la ingesta y convierte a dbt en vigilante de la frescura del origen:

print(utilidades.resumen(
    utilidades.dbt("source", "freshness"),
    ("PASS freshness", "WARN freshness", "ERROR STALE", "Finished running"),
))
1 of 3 PASS freshness of staging.alumnos ....................................... [PASS in 0.03s]
2 of 3 PASS freshness of staging.asignaturas ................................... [PASS in 0.02s]
3 of 3 PASS freshness of staging.cursa ......................................... [PASS in 0.02s]
Finished running 3 sources in 0 hours 0 minutes and 0.44 seconds (0.44s).

Esto es más importante de lo que parece. La mayoría de los incidentes de datos no son transformaciones mal escritas, son cargas que dejaron de ejecutarse y nadie se enteró. El cuadro de mando sigue funcionando, sigue mostrando números y los números son de hace tres semanas. Con freshness declarado, el propio dbt levanta la mano antes que el usuario.

Construimos ahora el almacén completo encima.

print("\n".join(utilidades.resumen(utilidades.dbt("build")).splitlines()[-2:]))
Completed successfully
Done. PASS=27 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=27

B.5.3 Lo que ya sabe dbt

Cada ejecución de dbt escribe en target/ un puñado de ficheros JSON que son, sin exagerar, el mejor catálogo técnico gratuito que hay:

Artefactos que deja cada ejecución de dbt
Artefacto Qué contiene
manifest.json El proyecto entero: modelos, sources, pruebas, descripciones y el grafo de dependencias
run_results.json Qué se ejecutó en la última pasada, con qué estado y cuánto tardó
sources.json El resultado de la comprobación de frescura
catalog.json Columnas y tipos reales en el destino, tras dbt docs generate

De ahí sale el inventario de activos sin preguntarle nada a la base de datos.

def artefacto(nombre):
    with open(os.path.join(utilidades.ESPACIO, "target", nombre), encoding="utf-8") as f:
        return json.load(f)

manifest = artefacto("manifest.json")

nombres, activos = {}, []

for uid, fuente in manifest["sources"].items():
    nombres[uid] = f"{fuente['schema']}.{fuente['name']}"
    activos.append(("aterrizaje", nombres[uid], "dlt", "tabla", fuente["description"]))

for uid, nodo in manifest["nodes"].items():
    if nodo["resource_type"] != "model":
        continue
    nombres[uid] = f"{nodo['schema']}.{nodo['name']}"
    activos.append((nodo["fqn"][1], nombres[uid], "dbt",
                    nodo["config"]["materialized"], nodo["description"]))

df_activos = pd.DataFrame(
    activos, columns=["capa", "activo", "proceso", "forma", "descripcion"]
)
df_activos
capa activo proceso forma descripcion
0 aterrizaje staging.alumnos dlt tabla Alumnos que constan en secretaría, sin interpr...
1 aterrizaje staging.asignaturas dlt tabla Catálogo de asignaturas ofertadas.
2 aterrizaje staging.cursa dlt tabla Qué alumno cursa qué asignatura, la relación e...
3 prep main_prep.stg_asignaturas dbt view
4 prep main_prep.stg_matriculas dbt view
5 prep main_prep.stg_alumnos dbt view
6 raw_vault main_raw_vault.hub_alumno dbt incremental Claves de negocio de los alumnos conocidos por...
7 raw_vault main_raw_vault.link_matricula dbt incremental Relación entre un alumno y una asignatura que ...
8 raw_vault main_raw_vault.sat_alumno dbt incremental Atributos descriptivos del alumno, con una ver...
9 raw_vault main_raw_vault.hub_asignatura dbt incremental
10 marts main_marts.fct_matriculas dbt table
11 marts main_marts.dim_alumno dbt table
12 marts main_marts.dim_asignatura dbt table
13 business_vault main_business_vault.pit_alumno dbt table
14 business_vault main_business_vault.sat_alumno_bv dbt table

Y el linaje sale de parent_map, que es el grafo de dependencias que dbt ya deduce de cada ref() y cada source(). Le añadimos por delante el tramo que conoce dlt, leyendo del esquema que dejó en el lago qué tablas trajo y de dónde:

aristas = [
    (f"secretaria.{tabla}", f"staging.{tabla}", "dlt")
    for tabla in esquema["tables"]
    if not tabla.startswith("_dlt")
]

for uid, padres in manifest["parent_map"].items():
    if uid in nombres:
        aristas += [(nombres[p], nombres[uid], "dbt") for p in padres if p in nombres]

df_linaje = pd.DataFrame(aristas, columns=["origen", "destino", "proceso"])
df_linaje.head(8)
origen destino proceso
0 secretaria.alumnos staging.alumnos dlt
1 secretaria.asignaturas staging.asignaturas dlt
2 secretaria.cursa staging.cursa dlt
3 staging.asignaturas main_prep.stg_asignaturas dbt
4 staging.cursa main_prep.stg_matriculas dbt
5 staging.alumnos main_prep.stg_alumnos dbt
6 main_prep.stg_alumnos main_raw_vault.hub_alumno dbt
7 main_prep.stg_matriculas main_raw_vault.link_matricula dbt

B.5.4 El grafo completo

Con esas dos piezas ya se puede dibujar el recorrido de principio a fin, desde la tabla del sistema origen hasta la dimensión que consume el cuadro de mando.

PALETA = {
    "origen":         "fill:#ececf0,stroke:#868d9c,stroke-width:1.5px,color:#2b2f3a",
    "carga":          "fill:#fae3c8,stroke:#bf7f28,stroke-width:1.5px,color:#573809",
    "transformacion": "fill:#d7eddc,stroke:#4a9463,stroke-width:1.5px,color:#1c4a2e",
    "explotacion":    "fill:#e6ddf5,stroke:#7457bd,stroke-width:1.5px,color:#332757",
}

def nodo_id(nombre):
    return nombre.replace(".", "_")

def etiqueta(nombre):
    return nombre.replace("main_", "")

def bloque(nombre):
    """Cada activo se colorea por la parte del libro a la que pertenece."""
    esquema = nombre.split(".")[0].replace("main_", "")
    if esquema == "staging":
        return "carga"
    if esquema in ("prep", "raw_vault", "business_vault"):
        return "transformacion"
    if esquema == "marts":
        return "explotacion"
    return "origen"

print("```{mermaid}")
print("flowchart LR")
for arista in df_linaje.itertuples():
    print(f"    {nodo_id(arista.origen)}[\"{etiqueta(arista.origen)}\"]"
          f" --> {nodo_id(arista.destino)}[\"{etiqueta(arista.destino)}\"]")

for clase, estilo in PALETA.items():
    print(f"    classDef {clase} {estilo}")
for activo in sorted(set(df_linaje.origen) | set(df_linaje.destino)):
    print(f"    class {nodo_id(activo)} {bloque(activo)}")
print("```")

flowchart LR
    secretaria_alumnos["secretaria.alumnos"] --> staging_alumnos["staging.alumnos"]
    secretaria_asignaturas["secretaria.asignaturas"] --> staging_asignaturas["staging.asignaturas"]
    secretaria_cursa["secretaria.cursa"] --> staging_cursa["staging.cursa"]
    staging_asignaturas["staging.asignaturas"] --> main_prep_stg_asignaturas["prep.stg_asignaturas"]
    staging_cursa["staging.cursa"] --> main_prep_stg_matriculas["prep.stg_matriculas"]
    staging_alumnos["staging.alumnos"] --> main_prep_stg_alumnos["prep.stg_alumnos"]
    main_prep_stg_alumnos["prep.stg_alumnos"] --> main_raw_vault_hub_alumno["raw_vault.hub_alumno"]
    main_prep_stg_matriculas["prep.stg_matriculas"] --> main_raw_vault_link_matricula["raw_vault.link_matricula"]
    main_prep_stg_alumnos["prep.stg_alumnos"] --> main_raw_vault_sat_alumno["raw_vault.sat_alumno"]
    main_prep_stg_asignaturas["prep.stg_asignaturas"] --> main_raw_vault_hub_asignatura["raw_vault.hub_asignatura"]
    main_raw_vault_hub_alumno["raw_vault.hub_alumno"] --> main_marts_fct_matriculas["marts.fct_matriculas"]
    main_raw_vault_hub_asignatura["raw_vault.hub_asignatura"] --> main_marts_fct_matriculas["marts.fct_matriculas"]
    main_raw_vault_link_matricula["raw_vault.link_matricula"] --> main_marts_fct_matriculas["marts.fct_matriculas"]
    main_raw_vault_hub_alumno["raw_vault.hub_alumno"] --> main_marts_dim_alumno["marts.dim_alumno"]
    main_business_vault_sat_alumno_bv["business_vault.sat_alumno_bv"] --> main_marts_dim_alumno["marts.dim_alumno"]
    main_raw_vault_hub_asignatura["raw_vault.hub_asignatura"] --> main_marts_dim_asignatura["marts.dim_asignatura"]
    main_prep_stg_asignaturas["prep.stg_asignaturas"] --> main_marts_dim_asignatura["marts.dim_asignatura"]
    main_raw_vault_sat_alumno["raw_vault.sat_alumno"] --> main_business_vault_pit_alumno["business_vault.pit_alumno"]
    main_raw_vault_sat_alumno["raw_vault.sat_alumno"] --> main_business_vault_sat_alumno_bv["business_vault.sat_alumno_bv"]
    classDef origen fill:#ececf0,stroke:#868d9c,stroke-width:1.5px,color:#2b2f3a
    classDef carga fill:#fae3c8,stroke:#bf7f28,stroke-width:1.5px,color:#573809
    classDef transformacion fill:#d7eddc,stroke:#4a9463,stroke-width:1.5px,color:#1c4a2e
    classDef explotacion fill:#e6ddf5,stroke:#7457bd,stroke-width:1.5px,color:#332757
    class main_business_vault_pit_alumno transformacion
    class main_business_vault_sat_alumno_bv transformacion
    class main_marts_dim_alumno explotacion
    class main_marts_dim_asignatura explotacion
    class main_marts_fct_matriculas explotacion
    class main_prep_stg_alumnos transformacion
    class main_prep_stg_asignaturas transformacion
    class main_prep_stg_matriculas transformacion
    class main_raw_vault_hub_alumno transformacion
    class main_raw_vault_hub_asignatura transformacion
    class main_raw_vault_link_matricula transformacion
    class main_raw_vault_sat_alumno transformacion
    class secretaria_alumnos origen
    class secretaria_asignaturas origen
    class secretaria_cursa origen
    class staging_alumnos carga
    class staging_asignaturas carga
    class staging_cursa carga

Nadie ha dibujado ese diagrama a mano y nadie tendrá que actualizarlo: se regenera con cada ejecución, porque sale de los mismos artefactos que produce el trabajo. Es la propiedad que distingue un linaje útil de un documento de Visio.

B.5.5 Guardarlo donde vive el dato

Un grafo en memoria sirve de poco. Como el destino es un lakehouse y escribir en él es trivial, el sitio natural para estos metadatos es el propio lago, en un esquema aparte:

con = duckdb.connect()
con.sql("INSTALL ducklake; LOAD ducklake;")
con.sql(f"ATTACH 'ducklake:sqlite:{utilidades.CATALOGO}' AS lago (DATA_PATH '{utilidades.DATOS}')")
con.sql("USE lago")

resultados = artefacto("run_results.json")
df_ejecuciones = pd.DataFrame(
    [(r["unique_id"].split(".")[-1], r["status"], round(r["execution_time"], 3))
     for r in resultados["results"]],
    columns=["nodo", "estado", "segundos"],
)

con.sql("CREATE SCHEMA IF NOT EXISTS meta")
con.sql("CREATE OR REPLACE TABLE meta.activos AS SELECT * FROM df_activos")
con.sql("CREATE OR REPLACE TABLE meta.linaje AS SELECT * FROM df_linaje")
con.sql("CREATE OR REPLACE TABLE meta.ejecuciones AS SELECT * FROM df_ejecuciones")
con.close()

utilidades.consultar("""
    SELECT capa, count(*) AS activos
    FROM meta.activos GROUP BY capa ORDER BY activos DESC
""")
capa activos
0 raw_vault 4
1 aterrizaje 3
2 prep 3
3 marts 3
4 business_vault 2

A partir de aquí el catálogo se consulta con la misma herramienta con la que se consulta todo lo demás. La pregunta que de verdad se hace a diario, qué se rompe si toco esta tabla, es un WITH RECURSIVE:

utilidades.consultar("""
    WITH RECURSIVE aguas_abajo(activo, salto) AS (
        SELECT destino, 1 FROM meta.linaje WHERE origen = 'staging.alumnos'
        UNION ALL
        SELECT l.destino, a.salto + 1
        FROM meta.linaje l JOIN aguas_abajo a ON l.origen = a.activo
    )
    SELECT salto, activo FROM aguas_abajo
    ORDER BY salto, activo
""")
salto activo
0 1 main_prep.stg_alumnos
1 2 main_raw_vault.hub_alumno
2 2 main_raw_vault.sat_alumno
3 3 main_business_vault.pit_alumno
4 3 main_business_vault.sat_alumno_bv
5 3 main_marts.dim_alumno
6 3 main_marts.fct_matriculas
7 4 main_marts.dim_alumno

Y como vive en el lago, se puede exponer en Rill igual que cualquier otro dato, con un panel que enseñe qué cargas han fallado, qué tablas llevan más tiempo sin refrescarse y cuántos activos hay por capa. Un catálogo que no obliga a levantar ningún servicio.

AdvertenciaLo que esto no es

Conviene ser preciso con lo que acabamos de construir, porque es fácil venderlo por más de lo que es.

Sí es: un inventario de activos con su capa y su forma, el linaje a nivel de tabla de principio a fin, el registro de cada ejecución y de cada carga, y la frescura de los orígenes. Todo derivado, sin nada escrito a mano y sin nada que se pueda desactualizar en silencio.

No es: linaje a nivel de columna, ni un glosario de negocio, ni control de permisos, ni un buscador para quien no sabe SQL, ni un sitio donde discutir sobre un dato. Todo eso es lo que justifica un catálogo de gobierno de verdad, y llegado el momento habrá que instalarlo.

Lo que sí resuelve es la trampa habitual: montar el catálogo antes de tener los metadatos, y acabar con una herramienta cara enseñando fichas vacías.

B.6 Alternativas que cubren este stack

Cuando lo anterior se quede corto, y se quedará en cuanto haya que escribir un glosario, estas son las opciones que encajan con DuckLake, dlt y dbt sin pelearse con ellas.

Opciones ordenadas por lo que cuesta empezar
Opción Cubre Coste de entrada
dbt docs Modelos, descripciones y grafo, en una web estática Un comando
Metadatos en el lago Inventario, linaje de tabla y ejecuciones, consultable en SQL Un cuaderno como el de arriba
OpenLineage y Marquez Linaje de ejecución de ambas mitades, en un servicio aparte Un contenedor y un envoltorio
DataHub por artefactos Catálogo completo, alimentado desde manifest.json Varias piezas de infraestructura
OpenMetadata Catálogo completo y contratos ODCS Conector personalizado por escribir

B.6.1 dbt docs

La opción que casi nadie usa y que sale gratis. dbt docs generate produce catalog.json con las columnas reales del destino y dbt docs serve levanta una web navegable con el grafo, las descripciones y el SQL compilado de cada modelo. Es un catálogo de verdad para la mitad de dbt, se publica como sitio estático en cualquier sitio y se actualiza en cada integración continua.

Su límite es evidente: empieza en el source(). No sabe nada de la ingesta ni de los sistemas origen.

B.6.2 OpenLineage y Marquez

Si lo que se busca es el linaje de las ejecuciones y no solo el de las definiciones, OpenLineage es el estándar que ya mencionábamos, y Marquez su implementación de referencia, bastante más ligera que un catálogo completo.

Para dbt la integración está hecha: el paquete openlineage-dbt trae el envoltorio dbt-ol, que se usa en lugar de dbt y emite los eventos leyendo los mismos artefactos que hemos abierto antes.

pip install openlineage-dbt
dbt-ol build --project-dir academia

Para dlt no hay envoltorio equivalente, así que la emisión se escribe a mano desde el propio proceso, usando el cliente de Python de OpenLineage y la información que devuelve pipeline.run(). Es un rato de trabajo, no un proyecto, y a cambio ambas mitades acaban reportando al mismo sitio y en un formato que cualquier catálogo posterior sabe leer.

Es, con diferencia, la vía con mejor relación entre esfuerzo y opciones futuras: si mañana se instala OpenMetadata o DataHub, los dos consumen OpenLineage y el trabajo hecho se aprovecha entero.

B.6.3 DataHub y OpenMetadata por artefactos

Aquí está el matiz que resuelve buena parte del problema del conector ausente. Ambos catálogos saben ingerir un proyecto de dbt leyendo manifest.json y catalog.json de un fichero o un bucket, sin conectarse a la base de datos. Eso significa que todo el almacén construido con dbt, con su linaje y su documentación, entra en el catálogo aunque el motor de debajo no tenga conector.

La diferencia entre los dos importa en este punto. DataHub trata esa ingesta como una fuente independiente, con lo que un proyecto de dbt se cataloga por sí solo. OpenMetadata la plantea como un enriquecimiento de un servicio de base de datos que debe existir antes, con lo que se vuelve a necesitar el conector personalizado del que avisábamos. No es un impedimento, pero conviene saberlo antes de elegir por comodidad.

Queda fuera en ambos casos la mitad de la ingesta, que es donde entra OpenLineage o la API.

B.6.4 Y si el catálogo técnico fuera otro

Merece la pena apuntar una salida distinta para cuando el problema deje de ser doméstico. DuckLake es una elección excelente para empezar y para equipos pequeños, pero su ecosistema de integraciones es todavía joven. El día que el catálogo de gobierno sea un requisito duro y el equipo haya crecido, cambiar el catálogo técnico por Apache Polaris o Unity Catalog sobre Iceberg abre la puerta a los conectores que ya existen para todo.

Y ese cambio es asumible precisamente por lo que veíamos en los formatos abiertos: los datos siguen siendo Parquet y los modelos de dbt no se enteran, porque lo único que cambia es la cadena de conexión del profiles.yml.

B.7 El panorama

Herramientas de catálogo y su capa
Herramienta Capa Nota
OpenMetadata Gobierno Arquitectura ligera, catálogo de conectores muy amplio, soporte de contratos ODCS
DataHub Gobierno Mayor comunidad y más funcionalidad de empresa, a costa de cuatro o cinco piezas de infraestructura
Amundsen Descubrimiento Pionero en Lyft, hoy prácticamente sin mantenimiento
Marquez Linaje Implementación de referencia de OpenLineage, sin pretensión de catálogo
Unity Catalog Técnico y gobierno Abierto por Databricks; cubre también activos no estructurados
Apache Polaris Técnico Catálogo REST, solo Iceberg
Apache Gravitino Técnico Federa varios catálogos y formatos

B.8 Lo que un catálogo no arregla

Para cerrar, la advertencia que ahorra proyectos enteros. Un catálogo de datos es un espejo de la organización: si nadie asume la propiedad de las tablas, mostrará tablas sin propietario; si nadie escribe el glosario, tendrá un glosario vacío; y si se instala como proyecto técnico sin que nadie tenga la responsabilidad de mantenerlo, en seis meses será un inventario desactualizado en el que nadie confía, que es peor que no tener ninguno.

Vuelve a valer aquí lo que decía Conway sobre las organizaciones y sus sistemas. La herramienta hace fácil lo que ya se quiere hacer; no hace que se quiera hacer.