Módulo 2: Anatomy Of An Iceberg Table
Leyendo snapshots e historia con PyIceberg
Descripción
Las lecciones 2 a 6 abrieron cada eslabón de la cadena por separado — a veces con SQL directo, a veces con json.load(), a veces desde la terminal. Esta lección junta el kit de herramientas que vas a usar, sin pausa, durante el resto de esta guía: los cuatro métodos de inspección de PyIceberg — table.history(), table.inspect.snapshots(), table.inspect.manifests(), table.inspect.files() — corridos uno al lado del otro, sobre la misma tabla, para que veas cómo se complementan.
Conexión con el módulo. Esta lección no descubre ningún archivo nuevo — todo lo que vas a ver aquí ya lo viste, disperso, en las lecciones 2 a 6. Lo que esta lección aporta es la vista consolidada: la misma información, pero accesible con cuatro llamadas de una sola línea, sin tener que abrir SQLite ni parsear JSON a mano.
Una analogía: el sistema de consulta rápida del archivo judicial
Después de haber caminado el pasillo del archivo en persona (lección 6), esta lección es el equivalente a que la corte finalmente te dé acceso a su sistema de consulta digital: en vez de caminar hasta el estante físico cada vez, escribes una consulta y el sistema te devuelve, en segundos, exactamente la misma información que habrías encontrado caminando —el historial completo de audiencias, el resumen de cada carpeta de evidencia, el inventario de cada foto—. El sistema no sabe nada que tú no pudieras haber encontrado a mano; solo lo hace más rápido y menos propenso a errores de conteo.
Ejemplo trabajado: los cuatro métodos, uno al lado del otro
Paso 1 — table.history(): la lista simple de snapshots, en orden
# snapshots_and_history.py
import os
from pyiceberg.catalog import load_catalog
warehouse_path = os.path.abspath("kiosko_warehouse")
catalog_db_path = os.path.abspath("kiosko_catalog.db")
catalog = load_catalog(
"kiosko", type="sql",
uri=f"sqlite:///{catalog_db_path}", warehouse=f"file://{warehouse_path}",
)
table = catalog.load_table("kiosko.fact_orders")
print("=== table.history() ===")
history = table.history()
print(f"cantidad de entradas: {len(history)}")
for entry in history:
print(f" snapshot_id={entry.snapshot_id} timestamp_ms={entry.timestamp_ms}")
Qué esperar (snapshot_id y timestamp_ms son valores asignados en tu propia corrida; la estructura y la cantidad de entradas son deterministas para el estado que dejó el módulo 1):
=== table.history() ===
cantidad de entradas: 1
snapshot_id=<snapshot-id asignado en tu corrida, distinto cada vez> timestamp_ms=<timestamp asignado en tu corrida>
table.history() devuelve la lista más simple de las cuatro: una entrada por cada snapshot que existió alguna vez, con su snapshot_id y el momento exacto (timestamp_ms, milisegundos desde epoch) en que se creó. Con un único append() en el módulo 1, esta lista tiene exactamente una entrada — el mismo snapshot_id que capturaste en la variable snap_id en la lección 6 de ese módulo.
Paso 2 — table.inspect.snapshots(): la misma lista, con más detalle de negocio
print("\n=== table.inspect.snapshots() ===")
snaps = table.inspect.snapshots().select(
["committed_at", "snapshot_id", "parent_id", "operation", "summary"]
)
print(f"cantidad de filas: {snaps.num_rows}")
for row in snaps.to_pylist():
summary = dict(row["summary"])
print(f" committed_at: {row['committed_at']}")
print(f" snapshot_id: {row['snapshot_id']}")
print(f" parent_id: {row['parent_id']}")
print(f" operation: {row['operation']}")
print(f" summary.added-records: {summary['added-records']}")
print(f" summary.total-records: {summary['total-records']}")
Qué esperar (snapshot_id y committed_at son de tu propia corrida; parent_id: None, operation: append y los conteos son deterministas):
=== table.inspect.snapshots() ===
cantidad de filas: 1
committed_at: <fecha y hora asignada en tu corrida>
snapshot_id: <snapshot-id asignado en tu corrida, distinto cada vez>
parent_id: None
operation: append
summary.added-records: 40
summary.total-records: 40
table.inspect.snapshots() devuelve un pyarrow.Table —el mismo tipo de resultado que table.inspect.manifests() y table.inspect.files(), ya usados en las lecciones 4 y 5—, con más columnas que table.history(): parent_id (el snapshot del que "desciende" este —None porque este es el primero de la tabla, sin ningún ancestro—), operation (el tipo exacto de escritura: append en este caso), y summary, un mapa con las mismas claves que ya viste dentro de metadata["snapshots"][0]["summary"] en la lección 3 —added-records, total-records, y las demás que ya reconoces—. Fíjate en parent_id: None: esta columna es la que va a cobrar sentido en el módulo 3, cuando un segundo snapshot tenga como parent_id el snapshot_id de este primero — la cadena de ancestría que hace posible reconstruir, en orden, cómo llegó la tabla a su estado actual.
Paso 3 — table.inspect.manifests() y table.inspect.files(): el resto de la cadena, en una línea cada uno
print("\n=== table.inspect.manifests() -- cantidad de manifest files ===")
print(table.inspect.manifests().num_rows)
print("\n=== table.inspect.files() -- cantidad de archivos de datos ===")
print(table.inspect.files().num_rows)
Qué esperar:
=== table.inspect.manifests() -- cantidad de manifest files ===
1
=== table.inspect.files() -- cantidad de archivos de datos ===
1
Los mismos números que ya obtuviste con detalle completo en las lecciones 4 y 5 — aquí solo el conteo, para dejar clara la simetría: un snapshot, un manifest file, un archivo de datos. Esta es exactamente la cadena "uno a uno" del diagrama de la lección 4, ahora confirmada con los cuatro métodos juntos.
Paso 4 — Confirma que current_snapshot() y history()[-1] son la misma verdad, vista desde dos ángulos
print("\n=== table.current_snapshot().snapshot_id == history[-1].snapshot_id ===")
print(table.current_snapshot().snapshot_id == history[-1].snapshot_id)
Qué esperar:
=== table.current_snapshot().snapshot_id == history[-1].snapshot_id ===
True
table.current_snapshot() —el método que ya usaste en las lecciones 5 y 6 del módulo 1 para capturar snap_id— y la última entrada de table.history() apuntan, siempre, al mismo snapshot: el vigente. Esto no es una coincidencia de esta tabla en particular —es una garantía estructural: current_snapshot() consulta directamente current-snapshot-id del archivo de metadata (lección 3), y history() construye su lista siguiendo el campo snapshot-log de ese mismo archivo, cuya última entrada, por definición, siempre coincide con el snapshot vigente actual.
Diagrama: los cuatro métodos, y qué eslabón de la cadena responde cada uno
flowchart TB
T["table (kiosko.fact_orders)"]
T --> H["table.history()\nlista simple: snapshot_id + timestamp"]
T --> S["table.inspect.snapshots()\nsnapshot_id + parent_id + operation + summary"]
T --> M["table.inspect.manifests()\nuna fila por manifest file"]
T --> F["table.inspect.files()\nuna fila por archivo de datos"]
H -.->|"responde: 'que snapshots existieron, en orden'"| Q1["metadata.json: snapshots[]\n(leccion 3)"]
S -.->|"responde: 'que paso en cada snapshot'"| Q1
M -.->|"responde: 'que manifest files tiene el vigente'"| Q2["manifest list + manifest files\n(leccion 4)"]
F -.->|"responde: 'que archivos de datos tiene el vigente'"| Q3["archivos de datos\n(leccion 5)"]
Profundización: por qué existen cuatro métodos, y no uno solo "que lo diga todo"
Podría parecer más simple que PyIceberg ofreciera un único método, algo como table.inspect.everything(), que devolviera toda la cadena de una sola vez. La razón de que no exista es la misma razón por la que la cadena tiene varios eslabones en primer lugar: cada pregunta tiene un costo distinto de responder, y cada método está diseñado para el costo mínimo de la pregunta que responde. table.history() solo necesita leer el archivo de metadata vigente —barato, siempre—. table.inspect.snapshots() también, con un poco más de detalle por fila. Pero table.inspect.manifests() necesita abrir el manifest list (y, según la implementación, potencialmente cada manifest file) para construir su resultado — más caro, proporcional a cuántos manifests existan. Y table.inspect.files() es el más caro de los cuatro: necesita abrir cada manifest file para enumerar cada archivo de datos individual —en una tabla con millones de archivos, como podría llegar a ser kiosko.fact_orders_at_scale en el módulo 5, este método hace mucho más trabajo que table.history()—. Separar estos cuatro métodos, en vez de uno solo que siempre haga el trabajo más caro, es una decisión de diseño que respeta la misma cadena de indirección que este módulo completo enseñó: no pagas el costo de abrir manifest files si solo necesitas saber cuántos snapshots existen.
Errores comunes
Llamar a table.inspect.files() repetidamente dentro de un bucle, sin guardar el resultado. Qué pasa: alguien, necesitando el conteo de archivos varias veces en un mismo script, llama a table.inspect.files() cada vez que lo necesita, en vez de guardar el resultado una sola vez en una variable. Por qué pasa: en un script corto, con una tabla tan pequeña como kiosko.fact_orders, el costo extra es imperceptible, así que el hábito no se corrige a tiempo. Cómo detectarlo: si tu script llama a table.inspect.files() (o .manifests()) más de una vez sin que la tabla haya cambiado entre llamadas, revisa la Profundización de esta lección sobre el costo relativo de cada método. Cómo corregirlo: guarda el resultado en una variable la primera vez —exactamente como hizo files = table.inspect.files() en la lección 5— y reutilízala; en una tabla grande, con muchos manifest files, este hábito evita trabajo repetido e innecesario.
Confundir table.history() (lista de Python) con table.inspect.snapshots() (pyarrow.Table), y usar la sintaxis incorrecta sobre cada uno. Qué pasa: alguien intenta usar .select([...]) sobre el resultado de table.history(), o itera con un for row in ...to_pylist() sobre table.history() como si fuera un pyarrow.Table, y obtiene un error de atributo. Por qué pasa: ambos métodos responden preguntas relacionadas (qué snapshots existen), así que es fácil asumir que tienen la misma forma de resultado. Cómo detectarlo: si tu código falla con AttributeError: 'list' object has no attribute 'select' (o el error inverso, 'Table' object is not iterable de forma directa), revisa cuál de los dos métodos estás usando. Cómo corregirlo: table.history() devuelve una lista normal de Python de objetos SnapshotLogEntry —se itera con un for simple, como en el paso 1 de esta lección—; los cuatro métodos bajo table.inspect.* devuelven pyarrow.Table —se filtran con .select([...]), se cuentan con .num_rows, se listan como diccionarios con .to_pylist(), como en los pasos 2 y 3—.
Esperar que table.inspect.manifests() y table.inspect.files() devuelvan siempre el mismo número de filas. Qué pasa: alguien, después de ver que en kiosko.fact_orders ambos métodos devuelven 1, asume que esto es una regla general —que siempre hay tantos manifest files como archivos de datos—. Por qué pasa: en el estado actual de esta tabla, con una sola escritura pequeña, la cadena "uno a uno" hace que coincidan por casualidad. Cómo detectarlo: si en una tabla con varias escrituras acumuladas (algo que vas a ver a partir del módulo 3) esperas que ambos números sigan coincidiendo, revisa el diagrama de la lección 4 de este módulo. Cómo corregirlo: un solo manifest file puede enumerar varios archivos de datos —por ejemplo, si una escritura grande se divide en varios archivos Parquet por tamaño—, así que table.inspect.files().num_rows va a ser, en general, mayor o igual que table.inspect.manifests().num_rows, nunca necesariamente igual.
Ejercicios
Ejercicio 1 — Reproduce los cuatro métodos tú mismo, y verifica la igualdad final. En tu propia máquina, con el estado del módulo 1 disponible, corre el script completo de esta lección. Confirma que el paso 4 imprime True.
Ver solución
Si tu tabla tiene el único snapshot que dejó el módulo 1, deberías ver cantidad de entradas: 1 en el paso 1, cantidad de filas: 1 con operation: append en el paso 2, 1 y 1 en el paso 3, y True en el paso 4. Si el paso 4 imprime False, algo inusual pasó con tu catálogo — revisa que no tengas más de un snapshot o alguna inconsistencia entre el archivo de metadata y el registro del catálogo.
Ejercicio 2 — Calcula, tú mismo, cuántos milisegundos pasaron entre committed_at y ahora. Usando el timestamp_ms que obtuviste en el paso 1 de esta lección, escribe una línea de código que calcule cuántos segundos han pasado desde que se creó ese snapshot hasta el momento en que corres el cálculo. (Pista: usa time.time() únicamente para esta comparación de una sola vez —nunca para generar datos de negocio de Kiosko, que es la regla dura de esta guía—.)
Ver solución
import time
elapsed_seconds = time.time() - (history[0].timestamp_ms / 1000)
print(f"Segundos desde el commit: {elapsed_seconds:.1f}")
El resultado depende, por completo, de cuánto tiempo pasó entre que corriste el módulo 1 y este ejercicio — no hay un valor "correcto" que memorizar. Este ejercicio usa time.time() de forma explícita y consciente solo para medir un intervalo relativo a un timestamp ya capturado —nunca para producir un dato de negocio de Kiosko ni un valor que después se guarde como si fuera determinista—, así que no viola la regla dura de esta guía contra time.time()/datetime.now()/random en código que alimenta un bloque "Qué esperar".
Ejercicio 3 — Predicción: si llamaras a estos cuatro métodos sobre una tabla recién creada, sin ningún append() todavía, ¿qué esperas que devuelva cada uno? Recordando la lección 5 del módulo 1 (table.current_snapshot() devolvía None antes de la primera carga), predice qué devolvería cada uno de los cuatro métodos de esta lección sobre esa misma tabla vacía.
Ver solución
table.history() devolvería una lista vacía ([]) — no hay ningún snapshot registrado todavía. table.inspect.snapshots() devolvería un pyarrow.Table con num_rows == 0 — la estructura de columnas existe, pero sin ninguna fila. table.inspect.manifests() y table.inspect.files() devolverían, cada uno, también num_rows == 0 — sin ningún snapshot, no hay ningún manifest list del cual partir, así que no hay nada que enumerar en ninguno de los dos. Los cuatro métodos son consistentes entre sí en el caso vacío, exactamente como lo fueron en el caso de un solo snapshot: todos derivan, en última instancia, del mismo archivo de metadata (o de su ausencia de snapshots).
Resumen y siguiente paso
En esta lección corriste los cuatro métodos de inspección de PyIceberg —table.history(), table.inspect.snapshots(), table.inspect.manifests(), table.inspect.files()— uno al lado del otro, sobre kiosko.fact_orders, y confirmaste que todos describen consistentemente el mismo estado: un snapshot (operation: append, 40 registros), un manifest file, un archivo de datos. Confirmaste también que table.current_snapshot() y la última entrada de table.history() son, siempre, la misma verdad vista desde dos ángulos distintos.
Antes de avanzar deberías poder: elegir cuál de los cuatro métodos usar según qué pregunta necesitas responder; explicar por qué existen cuatro métodos separados en vez de uno solo; y reproducir los cuatro sobre tu propia tabla.
Con la cadena completa recorrida —de tres formas distintas: código puro (lecciones 2-5), terminal (lección 6), y la API de inspección dedicada (esta lección)— la lección 8 cierra el módulo con un solo proyecto que junta las siete anteriores en un mapa único de la anatomía completa de kiosko.fact_orders.
Recursos
- PyIceberg — referencia de API, la sección completa de
table.inspect, con la lista de todos los métodos disponibles (snapshots,manifests,files,entries,partitions, y otros que módulos posteriores de esta guía van a usar). py.iceberg.apache.org/api. En inglés. - PyIceberg — referencia de API,
Table.history()y la claseSnapshotLogEntry. py.iceberg.apache.org/api. En inglés. - Apache Iceberg — documentación oficial, "Table Spec", sección de "Snapshots", la definición formal de
parent-snapshot-idque hace posible la cadena de ancestría entre snapshots. iceberg.apache.org/spec. En inglés. - DISEÑO de esta guía — la lista exacta de los cuatro métodos de inspección que este módulo debía ejecutar sobre
kiosko.fact_orders.src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.