Módulo 3: Snapshots And Time Travel

Cada escritura es un snapshot nuevo

Descripción

Esta lección crea la segunda tabla de Kiosko en Iceberg: kiosko.dim_product, con un esquema de cuatro columnas —product_id, product_name, category, unit_cost— y ninguna columna de historia. La carga, por primera vez, con los valores V1: los cuatro productos de Kiosko, con P002 Energy Bar todavía en category='snacks', unit_cost=0.60. Al final de esta lección vas a tener la primera mitad del experimento completo de este módulo: una tabla con exactamente un snapshot, la foto "antes" del cambio que la lección 3 va a provocar.

Conexión con el módulo. La lección 1 prometió dos escrituras reales sobre kiosko.dim_product. Esta lección hace la primera. El mecanismo —table.append() crea un snapshot nuevo— ya lo viste, exactamente igual, en la lección 6 del módulo 1, sobre kiosko.fact_orders. Esta lección no descubre ningún método nuevo de PyIceberg; lo que aporta es el caso —una tabla diseñada específicamente para el cambio de P002— sobre el que las lecciones 3 a 7 de este módulo van a construir el viaje en el tiempo completo.

Una analogía: instalar el estante, antes de la primera foto

Retomando la analogía de la lección 1: antes de que el encargado del supermercado pueda tomar la primera foto de un estante, tiene que existir el estante en sí —vacío, con sus repisas marcadas, listo para recibir mercadería—. Esta lección hace exactamente eso: crea kiosko.dim_product con su esquema declarado —cuatro repisas, ninguna más— y después la llena, por primera vez, con la mercadería V1. El momento en que la mercadería llega al estante es, con precisión, el momento en que se toma la primera foto: el primer snapshot de esta tabla.

Ejemplo trabajado: la tabla, y su primer snapshot

Paso 1 — Crea kiosko.dim_product, sin ninguna columna de historia

Con el catálogo kiosko ya cargado —el mismo SqlCatalog respaldado por SQLite que instalaste en el módulo 1—, declara el esquema de la nueva tabla:

# create_dim_product.py
import os

from pyiceberg.catalog import load_catalog
from pyiceberg.schema import Schema
from pyiceberg.types import DoubleType, NestedField, StringType

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}",
)

dim_product_schema = Schema(
    NestedField(field_id=1, name="product_id", field_type=StringType(), required=True),
    NestedField(field_id=2, name="product_name", field_type=StringType(), required=True),
    NestedField(field_id=3, name="category", field_type=StringType(), required=True),
    NestedField(field_id=4, name="unit_cost", field_type=DoubleType(), required=True),
)

table = catalog.create_table("kiosko.dim_product", schema=dim_product_schema)

print("Tabla creada:", table.name())
print()
print(table.schema())
print()
print("Tablas en el namespace kiosko:", catalog.list_tables("kiosko"))

Qué esperar (verificado corriendo el script real, con kiosko.fact_orders del módulo 1 ya cargada en el mismo catálogo):

Tabla creada: ('kiosko', 'dim_product')

table {
  1: product_id: required string
  2: product_name: required string
  3: category: required string
  4: unit_cost: required double
}

Tablas en el namespace kiosko: [('kiosko', 'dim_product'), ('kiosko', 'fact_orders')]

Fíjate en las cuatro columnas, y en lo que no está ahí: ningún valid_from, ningún valid_to, ningún is_current, ningún dbt_scd_id. Esto no es un descuido — es, con precisión, el punto central de todo este módulo. catalog.list_tables("kiosko") confirma que el namespace kiosko ahora contiene dos tablas: la fact_orders del módulo 1, y esta dim_product recién creada, todavía vacía.

Paso 2 — Los valores V1: P002 todavía es snacks/0.60

# load_v1.py -- kiosko.dim_product, primera carga, P002 = snacks / 0.60
import os

import pyarrow as pa
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.dim_product")

dim_product_pa_schema = pa.schema([
    pa.field("product_id", pa.string(), nullable=False),
    pa.field("product_name", pa.string(), nullable=False),
    pa.field("category", pa.string(), nullable=False),
    pa.field("unit_cost", pa.float64(), nullable=False),
])

# V1 -- el estado de dim_product ANTES del cambio de P002, vigente hasta 2026-08-15
DIM_PRODUCT_V1 = [
    {"product_id": "P001", "product_name": "Bottled Water 600ml", "category": "beverages", "unit_cost": 0.40},
    {"product_id": "P002", "product_name": "Energy Bar", "category": "snacks", "unit_cost": 0.60},
    {"product_id": "P003", "product_name": "Instant Coffee Sachet", "category": "beverages", "unit_cost": 0.35},
    {"product_id": "P004", "product_name": "Phone Charger Cable", "category": "electronics", "unit_cost": 2.10},
]

pa_table_v1 = pa.Table.from_pylist(DIM_PRODUCT_V1, schema=dim_product_pa_schema)
table.append(pa_table_v1)

print("table.scan().to_arrow() tras la primera carga:")
for row in table.scan().to_arrow().to_pylist():
    print(f"  {row['product_id']}  {row['product_name']:<22} {row['category']:<10} unit_cost={row['unit_cost']}")

Qué esperar (verificado corriendo el script real):

table.scan().to_arrow() tras la primera carga:
  P001  Bottled Water 600ml    beverages  unit_cost=0.4
  P002  Energy Bar             snacks     unit_cost=0.6
  P003  Instant Coffee Sachet  beverages  unit_cost=0.35
  P004  Phone Charger Cable    electronics unit_cost=2.1

Cuatro filas, una por producto — el mismo DIM_PRODUCT que ya conoces de las seis guías anteriores, con P002 todavía en su estado original. Nada de esto es nuevo mecánicamente: es exactamente el mismo table.append() que ya usaste en la lección 6 del módulo 1.

Paso 3 — Confirma el primer snapshot, capturado en variable

snap_v1 = table.current_snapshot().snapshot_id

print("Primer snapshot de dim_product, capturado en snap_v1")
print("type(snap_v1):", type(snap_v1).__name__)
print("table.history() tiene", len(table.history()), "entrada(s)")

Qué esperar (snap_v1 es un entero asignado por Iceberg en el momento del commit, distinto en cada corrida — nunca se hardcodea; ver la lección 4 de este módulo para la regla completa):

Primer snapshot de dim_product, capturado en snap_v1
type(snap_v1): int
table.history() tiene 1 entrada(s)

Fíjate en algo importante para el resto de este módulo: capturaste snap_v1 en una variable inmediatamente después de la escritura, no después. Esta disciplina —capturar el snapshot_id en el mismo bloque de código que lo generó, nunca "después, cuando lo necesite"— es la que la lección 4 va a convertir en regla explícita. Por ahora, guarda snap_v1: es la foto "antes" que vas a usar para viajar en el tiempo en la lección 5.

Diagrama: la primera foto de kiosko.dim_product

flowchart LR
    A["catalog.create_table\nkiosko.dim_product\n(4 columnas, 0 filas)"] --> B["table.append(V1)\nP002 = snacks / 0.60"]
    B --> C["snapshot snap_v1\noperation: append\nadded-records: 4"]
    C -.->|"capturado de inmediato"| D["snap_v1 = table.current_snapshot().snapshot_id"]
    D -.->|"leccion 5"| E["table.scan(snapshot_id=snap_v1)\nrecupera esta foto, mas adelante"]

En disco, después de esta lección, kiosko_warehouse/kiosko/dim_product/ tiene la misma forma que ya viste para fact_orders en el módulo 1: un archivo de datos Parquet con las cuatro filas, un manifest file que lo lista, un manifest list que apunta a ese manifest file, y dos archivos de metadata —uno de la tabla vacía (paso 1), otro del primer snapshot (paso 3)—.

Profundización: qué cuenta como "una escritura" para Iceberg

Vale la pena ser precisos sobre qué operaciones crean un snapshot nuevo, porque el resto de este módulo —y buena parte de los siguientes— depende de esta distinción. table.append(), table.overwrite() (lección 3), table.delete() y table.upsert() (módulo 6) son todas operaciones que modifican los datos de la tabla, y cada una de ellas produce, como mínimo, un snapshot nuevo —la lección 3 va a mostrar que overwrite() puede producir más de uno—. Lo que no cuenta como una escritura de datos, en este sentido preciso, es una evolución de esquema —table.update_schema(), que vas a usar recién en el módulo 4—: agregar o borrar una columna cambia el archivo de metadata, pero no toca ningún archivo Parquet ni agrega una fila nueva al historial de snapshots de datos. Esta distinción —cambios de datos producen snapshots; cambios de esquema son otro tipo de operación, sobre la misma cadena de metadata— es exactamente la que el módulo 4 completo va a desarrollar a fondo. Por ahora, lo que necesitas retener es más simple: cada vez que le pides a Iceberg que agregue, reemplace o borre filas, obtienes una foto nueva, archivada junto a todas las anteriores.

Errores comunes

Crear kiosko.dim_product sin haber cargado antes kiosko.fact_orders del módulo 1, y sorprenderse de que el namespace ya exista. Qué pasa: alguien, empezando este módulo en un directorio nuevo sin el estado del módulo 1, corre el paso 1 de esta lección y obtiene un error porque el namespace kiosko no está registrado. Por qué pasa: esta lección asume, a propósito, que estás continuando desde el estado que dejaron los módulos 1 y 2 —el mismo kiosko_catalog.db y kiosko_warehouse/ de siempre—, no empezando desde cero. Cómo detectarlo: si catalog.create_table("kiosko.dim_product", ...) falla con NoSuchNamespaceError, tu catálogo no tiene el namespace kiosko creado todavía. Cómo corregirlo: corre primero catalog.create_namespace("kiosko") —el mismo paso de la lección 5 del módulo 1—, o, si quieres partir de cero para este módulo específicamente, usa el proyecto de la lección 8, que reconstruye todo el estado necesario en un solo script.

Asumir que table.append() sobre dim_product reemplaza filas existentes con el mismo product_id. Qué pasa: alguien, planeando ya el cambio de P002 de la lección 3, intenta resolverlo con un segundo table.append() que solo incluye la fila actualizada de P002, esperando que reemplace la fila vieja. Por qué pasa: en muchas bases de datos, un UPSERT implícito por clave primaria es un comportamiento común, y es fácil asumir que append() se comporta así. Cómo detectarlo: si después de "actualizar" P002 ves dos filas con product_id='P002' en table.scan().to_arrow(), en vez de una sola actualizada, ese es el síntoma exacto de este error. Cómo corregirlo: table.append() siempre agrega, nunca reemplaza — para reemplazar el contenido completo de una tabla de una fila por clave, como dim_product, la operación correcta es table.overwrite(), exactamente la que usa la lección 3 de este módulo. (El módulo 6 de esta guía enseña una tercera alternativa, table.upsert(), diseñada específicamente para actualizar por clave sin reemplazar toda la tabla.)

Ejercicios

Ejercicio 1 — Reproduce la creación y la primera carga tú mismo. Con el catálogo kiosko del módulo 1 disponible, corre los tres pasos de esta lección en tu propia máquina. Confirma que ves las cuatro filas de V1 con P002 en category='snacks', unit_cost=0.6, y que table.history() reporta exactamente una entrada.

Ver solución

Si tu catálogo ya tenía el namespace kiosko (heredado del módulo 1), tu salida debería coincidir exactamente con la de esta lección: la tabla creada con cuatro columnas, las cuatro filas de V1 en el mismo orden, y table.history() con una sola entrada. Tu snap_v1 va a ser un entero distinto al de esta lección — eso es exactamente lo esperado, no un error.

Ejercicio 2 — Explica por qué esta lección no muestra el valor literal de snap_v1. En 1-2 frases, explica por qué el bloque "Qué esperar" del paso 3 de esta lección no imprime el número real del snapshot_id, a diferencia de, por ejemplo, type(snap_v1), que sí es un valor fijo y reproducible.

Ver solución

El snapshot_id es un identificador que Iceberg asigna en el momento exacto del commit —no es un valor que dependa de los datos de negocio de Kiosko, sino del propio mecanismo interno de generación de identificadores de Iceberg—, así que va a ser distinto en cada corrida, incluso corriendo el mismo script dos veces desde cero. Mostrar un número específico en "Qué esperar" implicaría, incorrectamente, que ese número es el resultado "correcto" que deberías obtener, cuando en realidad la única verificación válida es que el valor exista (no sea None) y sea del tipo correcto (int). type(snap_v1), en cambio, sí es reproducible —siempre va a ser int, sin importar cuándo corras el script—, así que sí se muestra como un valor literal.

Ejercicio 3 — Predicción: ¿qué pasaría si corrieras load_v1.py una segunda vez, sin haber creado una tabla nueva? Sin correrlo todavía, predice: si ejecutas el paso 2 de esta lección dos veces seguidas sobre la misma tabla kiosko.dim_product, ¿cuántas filas esperas ver en table.scan().to_arrow() después de la segunda corrida? Justifica tu respuesta con lo que ya sabes de table.append() desde el módulo 1.

Ver solución

Ocho filas: dos copias de cada uno de los cuatro productos. table.append() agrega filas, nunca reemplaza el contenido existente —exactamente el mismo comportamiento que ya viste en el módulo 1 cuando correr load_into_iceberg.py dos veces sobre kiosko.fact_orders daba 80 filas en vez de 40—. Para una tabla de dimensión como dim_product, donde el grano correcto es "una fila por producto", correr append() dos veces con los mismos datos rompe ese grano — es exactamente el error que la sección "Errores comunes" de esta lección advierte, y la razón por la que la lección 3 usa table.overwrite() en vez de un segundo append() para el cambio de P002.

Resumen y siguiente paso

En esta lección creaste kiosko.dim_product, con cuatro columnas y ninguna de historia, y la cargaste por primera vez con los valores V1P002 todavía snacks/0.60—. Confirmaste que esa primera escritura creó exactamente un snapshot, lo capturaste de inmediato en la variable snap_v1, y viste, en la Profundización, la distinción precisa entre qué operaciones crean un snapshot de datos y cuáles no.

Antes de avanzar deberías poder: crear una tabla Iceberg de dimensión sin columnas de historia; cargarla con table.append(); y explicar por qué capturar snap_v1 inmediatamente después de la escritura, en vez de "después, cuando haga falta", es una disciplina y no una formalidad.

kiosko.dim_product tiene ahora su primera foto archivada. La lección 3 provoca el cambio real: sobrescribe la tabla completa con los valores V2, donde P002 pasa a category='health-snacks', unit_cost=0.68 — el mismo cambio que ya resolviste, con dos técnicas distintas, en data-modeling y dbt.

Recursos

  • PyIceberg — referencia de API, table.append() y table.current_snapshot(), ya usados en el módulo 1 y reutilizados aquí sobre una tabla nueva. py.iceberg.apache.org/api. En inglés.
  • Apache Iceberg — documentación oficial, "Table Spec", sección de "Snapshots", la definición formal de qué constituye un snapshot de datos. iceberg.apache.org/spec. En inglés.
  • DISEÑO de data-modeling-for-analytics-guide — fuente de los valores V1 exactos de DIM_PRODUCT (P001-P004, con P002 en snacks/0.60) que esta lección carga sin cambios. src/guides/data-modeling-for-analytics-guide/DISENO.md. En español.
  • DISEÑO de esta guía — la sección "Snapshots y time travel" (M3), fuente de la secuencia exacta V1snap_v1V2 que este módulo ejecuta. src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.