Módulo 2: Anatomy Of An Iceberg Table

El archivo de metadata: esquema, partición, snapshots

Descripción

Esta lección abre el archivo al que metadata_location apunta —la carátula del expediente, en la analogía de este módulo—: el archivo de metadata de kiosko.fact_orders, con extensión .metadata.json. A diferencia de los manifest files y manifest lists (lección 4), este archivo es JSON plano, legible con cualquier editor de texto o con json.load() de Python sin ninguna dependencia extra. Vas a leerlo directamente, campo por campo, y vas a reconocer en él tres cosas que ya conoces: el esquema exacto que declaraste en la lección 5 del módulo 1, la ausencia de partición (todavía), y un único snapshot, el que creó table.append() en la lección 6 de ese módulo.

Conexión con el módulo. La lección 2 confirmó que el catálogo solo guarda una dirección. Esta lección abre lo que hay en esa dirección — la carátula completa del expediente, con toda la información que el catálogo, a propósito, no guarda directamente.

Una analogía: leyendo la carátula completa

La carátula de un expediente judicial, a diferencia del mostrador que solo te dio una dirección, sí contiene información sustancial: el número de caso, la fecha de la audiencia más reciente, un resumen de qué se decidió en cada audiencia anterior, y una referencia exacta a dónde está el índice de pruebas de la audiencia vigente. No contiene las pruebas en sí —eso está un nivel más adentro—, pero sí contiene todo lo que hace falta para saber qué forma tiene el caso: sus partes, su historial de audiencias, su estado actual. Eso es, con precisión, lo que un archivo de metadata de Iceberg contiene: el esquema completo de la tabla (qué columnas existen, con qué tipos), el esquema de partición (cómo están organizados los datos, si es que lo están), y la lista completa de snapshots —cada versión de la tabla que existió alguna vez, con su fecha y su resumen—.

Ejemplo trabajado: abriendo la carátula completa

Paso 1 — Encuentra la ruta exacta vía el catálogo, y ábrela con json.load()

# inspect_metadata_json.py
import json
import os
import sqlite3
from urllib.parse import urlparse

catalog_db_path = os.path.abspath("kiosko_catalog.db")
conn = sqlite3.connect(catalog_db_path)
cur = conn.cursor()
cur.execute("SELECT metadata_location FROM iceberg_tables WHERE table_name = 'fact_orders';")
metadata_location = cur.fetchone()[0]
conn.close()

# metadata_location es un URI file://; urlparse lo convierte en una ruta normal de sistema
metadata_path = urlparse(metadata_location).path
with open(metadata_path) as f:
    metadata = json.load(f)

print("Archivo abierto directamente con json.load() -- es JSON plano, legible:")
print(" ", os.path.basename(metadata_path))

Qué esperar:

Archivo abierto directamente con json.load() -- es JSON plano, legible:
  00001-<uuid>.metadata.json

Ni una librería de Iceberg de por medio — json.load() de la librería estándar de Python abre el archivo sin ningún error, exactamente como abrirías cualquier archivo .json. Esa es, en la práctica, la diferencia central que separa este archivo de los manifest de la lección 4.

Paso 2 — Los punteros principales: versión, esquema vigente, snapshot vigente

print("=== format-version y punteros principales ===")
print("format-version:", metadata["format-version"])
print("table-uuid:", metadata["table-uuid"], " <- unico por tabla, no por corrida")
print("location:", metadata["location"])
print("current-schema-id:", metadata["current-schema-id"])
print("current-snapshot-id:", metadata["current-snapshot-id"])

Qué esperar (table-uuid y current-snapshot-id son valores asignados en tu propia corrida —distintos de los mostrados aquí—; el resto es idéntico en cualquier corrida que reproduzca exactamente el módulo 1):

=== format-version y punteros principales ===
format-version: 2
table-uuid: <uuid asignado a tu tabla, distinto del de cualquier otra corrida>
location: file:///.../kiosko_warehouse/kiosko/fact_orders
current-schema-id: 0
current-snapshot-id: <snapshot-id asignado en tu corrida, distinto cada vez>

format-version: 2 confirma que esta tabla usa la versión 2 de la especificación de Iceberg —la vigente al escribir esta guía, con soporte completo de borrados a nivel de fila y sequence-number por snapshot—. table-uuid es un identificador único, generado una sola vez cuando catalog.create_table() creó la tabla en la lección 5 del módulo 1 — no cambia nunca, ni siquiera entre snapshots; distínguelo con cuidado de current-snapshot-id, que sí cambia con cada escritura.

Paso 3 — El esquema, campo por campo

print("\n=== schemas[] -- el esquema declarado en la leccion 5 de M1 ===")
for field in metadata["schemas"][0]["fields"]:
    print(f"  id={field['id']:<2} {field['name']:<12} {field['type']:<10} required={field['required']}")

Qué esperar:

=== schemas[] -- el esquema declarado en la leccion 5 de M1 ===
  id=1  order_id     string     required=True
  id=2  store_id     string     required=True
  id=3  product_id   string     required=True
  id=4  quantity     int        required=True
  id=5  unit_price   double     required=True
  id=6  revenue      double     required=True
  id=7  order_ts     timestamp  required=True

Reconoces estas siete líneas: son, campo por campo, el Schema que declaraste con NestedField en la lección 5 del módulo 1 — mismos field_id (id aquí), mismos nombres, mismos tipos, misma obligatoriedad. El archivo de metadata no reinterpreta el esquema — lo guarda tal cual, como la fuente de verdad que cualquier lector debe consultar antes de abrir un solo archivo Parquet.

Paso 4 — Partición: vacía, todavía

print("\n=== partition-specs[] -- sin particion (M1-M4), M5 evoluciona esto ===")
print(metadata["partition-specs"])

Qué esperar:

=== partition-specs[] -- sin particion (M1-M4), M5 evoluciona esto ===
[{'spec-id': 0, 'fields': []}]

Un PartitionSpec con spec-id: 0 y una lista de fields vacíakiosko.fact_orders no está particionada, ni por carpetas ni de ninguna otra forma, exactamente como la creaste en la lección 5 del módulo 1. El módulo 5 de esta guía introduce el primer esquema de partición real, sobre una tabla distinta (kiosko.fact_orders_at_scale); esta tabla se mantiene sin particionar durante toda la guía.

Paso 5 — La lista de snapshots, y la referencia main

print("\n=== snapshots[] -- UN solo snapshot tras la carga de M1 ===")
print("cantidad de snapshots:", len(metadata["snapshots"]))
snap = metadata["snapshots"][0]
print("  snapshot-id:", snap["snapshot-id"], " <- no reproducible, distinto en tu corrida")
print("  sequence-number:", snap["sequence-number"])
print("  operation:", snap["summary"]["operation"])
print("  manifest-list:", os.path.basename(urlparse(snap["manifest-list"]).path))
print("  summary.added-data-files:", snap["summary"]["added-data-files"])
print("  summary.added-records:", snap["summary"]["added-records"])
print("  summary.total-records:", snap["summary"]["total-records"])

print("\n=== refs -- el branch 'main' apunta al snapshot vigente ===")
print(metadata["refs"])

Qué esperar (snapshot-id no es reproducible; el resto de la estructura y los valores de negocio sí lo son):

=== snapshots[] -- UN solo snapshot tras la carga de M1 ===
cantidad de snapshots: 1
  snapshot-id: <snapshot-id asignado en tu corrida, distinto cada vez>
  sequence-number: 1
  operation: append
  manifest-list: snap-<snapshot-id>-0-<uuid>.avro
  summary.added-data-files: 1
  summary.added-records: 40
  summary.total-records: 40

=== refs -- el branch 'main' apunta al snapshot vigente ===
{'main': {'snapshot-id': <snapshot-id asignado en tu corrida, distinto cada vez>, 'type': 'branch'}}

Este es el corazón de la lección: metadata["snapshots"] tiene exactamente una entrada, con sequence-number: 1 —el primer commit que existió alguna vez sobre esta tabla—, operation: "append" —la misma operación que ejecutaste en la lección 6 del módulo 1—, y summary.added-records: 40/total-records: 40 —el mismo conteo que ya verificaste. Y fíjate en el campo manifest-list: es la referencia exacta al siguiente eslabón de la cadena, el que la lección 4 abre. refs["main"] confirma, una vez más, cuál es el snapshot vigente —el mismo current-snapshot-id del paso 2—, usando el mecanismo de "ramas" (branch) que Iceberg soporta desde la versión 2 de su especificación, aunque esta guía nunca usa una rama distinta de main.

Diagrama: lo que vive dentro del archivo de metadata

flowchart TB
    META["metadata.json"]
    META --> A["format-version, table-uuid, location"]
    META --> B["schemas[] + current-schema-id\n(el Schema completo, con field_id)"]
    META --> C["partition-specs[] + default-spec-id\n(vacio: sin particion todavia)"]
    META --> D["snapshots[] + current-snapshot-id\n(1 snapshot: append, 40 registros)"]
    META --> E["refs.main -> snapshot vigente"]
    D -->|"snapshot.manifest-list apunta a"| NEXT["Proximo eslabon:\nmanifest list (leccion 4)"]

Profundización: por qué snapshots[] es una lista, no un solo objeto

Vale la pena notar algo que esta lección no explota todavía, pero que el módulo 3 completo va a convertir en la garantía central de esta guía: metadata["snapshots"] es una lista, no un único objeto — el formato de Iceberg está diseñado, desde su especificación, para acumular más de un snapshot en el mismo archivo de metadata. En este punto de la guía tiene exactamente un elemento porque solo hubo una escritura, pero si en el módulo 3 haces un segundo overwrite() sobre otra tabla, vas a ver esa lista crecer a dos elementos, con current-snapshot-id apuntando siempre al más reciente —sin que el primero desaparezca—. Esta es, con precisión, la estructura de datos que hace posible el time travel: no es magia, es una lista de snapshots que Iceberg nunca poda por sí sola, con un puntero (current-snapshot-id) que dice cuál es "el vigente ahora", exactamente igual que el catálogo (lección 2) dice cuál archivo de metadata es "el vigente ahora" — el mismo patrón, repetido en dos niveles distintos de la cadena.

Errores comunes

Buscar los valores reales de las filas (order_id, revenue, etc.) dentro del archivo de metadata. Qué pasa: alguien, al ver cuánta información contiene este archivo, espera encontrar ahí mismo, en algún campo, las cuarenta órdenes de Kiosko. Por qué pasa: el archivo de metadata es sorprendentemente rico en detalle —esquema, historial, resúmenes— así que es fácil sobreestimar cuánto contiene. Cómo detectarlo: si tu búsqueda de "ORD-1001" (o cualquier order_id real) dentro del JSON no encuentra nada, confirma que estás en el archivo correcto — la ausencia es esperada, no un error. Cómo corregirlo: el archivo de metadata contiene resúmenes (summary.added-records: 40), nunca las filas en sí — esas viven, varios eslabones más adelante, en los archivos Parquet (lección 5).

Confundir current-schema-id con field_id. Qué pasa: alguien ve "current-schema-id": 0 y, por separado, "id": 1 dentro de cada campo del esquema, y asume que son el mismo tipo de número con roles intercambiables. Por qué pasa: ambos son enteros pequeños, y "schema" y "field" suenan cercanos. Cómo detectarlo: si tu código intenta usar current-schema-id para identificar una columna específica, revisa qué representa cada uno en el ejemplo trabajado de esta lección. Cómo corregirlo: current-schema-id identifica una versión completa del esquema (útil cuando el módulo 4 evolucione el esquema y aparezca un schema-id: 1); el id dentro de cada campo (field_id, en la terminología de la lección 5 del módulo 1) identifica una columna individual, estable incluso cuando el esquema evoluciona. Son dos niveles distintos de identificación, cada uno con su propio propósito.

Editar el archivo de metadata a mano "para corregir algo rápido". Qué pasa: alguien, viendo que el archivo es JSON legible y editable, lo abre en un editor de texto y modifica un valor directamente, pensando que es una forma válida de corregir un error. Por qué pasa: la legibilidad del archivo invita, engañosamente, a tratarlo como un archivo de configuración cualquiera. Cómo detectarlo: si después de editar el archivo a mano, catalog.load_table("kiosko.fact_orders") empieza a fallar, o la tabla se comporta de forma inconsistente con lo que el catálogo espera, revisa si el archivo sigue siendo exactamente el que Iceberg escribió. Cómo corregirlo: nunca edites un archivo de metadata a mano — cualquier cambio real a una tabla Iceberg debe pasar por una operación de la API (table.append(), table.overwrite(), table.update_schema(), etc.), que es la única forma de que el archivo de metadata nuevo resultante sea consistente con los manifest y los datos a los que apunta.

Ejercicios

Ejercicio 1 — Reproduce las cinco partes tú mismo. En tu propia máquina, con el estado del módulo 1 disponible, corre las cinco partes del ejemplo trabajado de esta lección. Confirma que ves exactamente una entrada en snapshots[], con operation: "append" y summary.added-records: 40.

Ver solución

Si tu tabla tiene el mismo único snapshot que dejó el módulo 1, tu salida debería coincidir estructuralmente con la mostrada aquí en todo, excepto table-uuid, current-snapshot-id, el snapshot-id dentro de snapshots[0], y el nombre exacto del archivo de manifest list —todos esos son valores que tu propia corrida asignó, distintos de cualquier otra corrida—. Si len(metadata["snapshots"]) no es 1, revisa si corriste table.append() más de una vez en la lección 6 del módulo 1.

Ejercicio 2 — Localiza el manifest-list del snapshot, y anota su nombre. Usando el script de esta lección, extrae el valor de snap["manifest-list"] y anota únicamente el nombre del archivo (sin la ruta completa). ¿Qué patrón reconoces en ese nombre, comparado con el snapshot-id que ya anotaste?

Ver solución

El nombre del archivo sigue el patrón snap-<snapshot-id>-0-<uuid>.avro — el propio snapshot-id (el mismo entero grande que viste en current-snapshot-id) aparece, literal, como parte del nombre del archivo de manifest list. Este patrón de nombrado no es una coincidencia: hace que, con solo mirar el nombre de un archivo snap-*.avro en la carpeta metadata/, sepas de inmediato a qué snapshot pertenece, sin tener que abrirlo — algo que la lección 6 de este módulo aprovecha al recorrer el directorio completo desde la terminal.

Ejercicio 3 — Predicción: ¿qué cambiaría en este archivo si el módulo 3 hiciera un segundo overwrite()? Sin adelantarte al módulo 3 todavía, predice: si más adelante en esta guía se ejecutara una segunda escritura sobre una tabla (un overwrite(), por ejemplo), ¿qué campos de este mismo archivo de metadata esperas que cambien, y cuáles esperas que se mantengan exactamente igual?

Ver solución

current-snapshot-id cambiaría, apuntando al snapshot nuevo; snapshots[] crecería a dos elementos, con el snapshot viejo todavía presente en la lista (no reemplazado); refs["main"]["snapshot-id"] también cambiaría, junto con current-snapshot-id. En cambio, table-uuid se mantendría exactamente igual —es el identificador permanente de la tabla, no de una versión—, y schemas[]/current-schema-id se mantendrían igual también, a menos que la escritura incluyera además una evolución de esquema (módulo 4), que es una operación distinta. La Profundización de esta lección adelantó exactamente esta idea: snapshots[] está diseñado para crecer, nunca para reemplazar su contenido anterior.

Resumen y siguiente paso

En esta lección abriste el archivo de metadata de kiosko.fact_orders directamente con json.load() —sin ninguna dependencia de Iceberg— y confirmaste, campo por campo, que contiene el esquema completo (siete columnas, mismos field_id de la lección 5 del módulo 1), un partition-specs vacío (sin partición todavía), y una lista de snapshots con exactamente una entrada: la que creó table.append() en la lección 6 de ese módulo, con 40 registros agregados.

Antes de avanzar deberías poder: nombrar las cuatro secciones principales de un archivo de metadata (schemas, partition-specs, snapshots, refs); y explicar por qué snapshots[] es una lista que crece, nunca un solo objeto que se sobrescribe.

La lección 4 sigue la flecha una vez más: abre el campo manifest-list del snapshot que acabas de leer — y ahí la lectura directa con json.load() deja de funcionar, porque el siguiente eslabón ya no es JSON.

Recursos

  • Apache Iceberg — documentación oficial, "Table Spec", sección de metadata de tabla (TableMetadata), la definición formal de cada campo que esta lección leyó. iceberg.apache.org/spec. En inglés.
  • PyIceberg — referencia de API, Table.metadata y la clase TableMetadata, la representación tipada del mismo archivo que esta lección leyó como JSON crudo. py.iceberg.apache.org/api. En inglés.
  • DISEÑO de esta guía — el formato exacto de nombre del archivo de metadata y la advertencia de nunca hardcodear un snapshot-id, aplicada de nuevo en esta lección. src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.