Módulo 3: Snapshots And Time Travel
Time travel: AS OF un snapshot-id
Descripción
Todo lo anterior en este módulo construyó hacia este momento. Tienes snap_v1 capturado (lección 2), sabes por qué nunca lo hardcodeas (lección 4), y sabes que la tabla vigente muestra V2 desde la lección 3. Esta lección hace, por fin, el viaje en el tiempo: table.scan(snapshot_id=snap_v1), una sola línea de código que le pide a Iceberg "no me muestres el estado vigente — muéstrame exactamente cómo se veía esta tabla en ese snapshot específico".
Conexión con el módulo. Esta lección es la bisagra entre "tener la teoría" y "tener el resultado". Las lecciones 2 a 4 prepararon todo lo necesario; esta lección lo usa. La lección 6 va a tomar lo que recuperes aquí y lo va a conectar con el JOIN contra fact_orders para reproducir el margen correcto de P002 — el pago completo de este módulo.
Una analogía: pedirle al archivo la foto de un día específico
Vuelve, por última vez en este módulo, al encargado del archivo de fotos del supermercado. Hasta ahora, cada vez que alguien pregunta "¿cómo está el estante?", el encargado muestra la foto más reciente — es lo que hace por defecto, sin que nadie se lo pida explícitamente. Pero el archivo completo sigue ahí, foto por foto, con cada una archivada junto a su número de rollo. Esta lección es el momento de pedirle al encargado, con precisión, "no la foto de hoy — dame la foto de ese rollo específico, la de antes del último reabastecimiento". El encargado no tiene que reconstruir nada, no tiene que adivinar, no tiene que consultar ningún registro aparte de columnas de fecha diseñadas para esto — simplemente va al archivo, saca la foto con ese número, y te la entrega tal cual quedó ese día.
Ejemplo trabajado: el viaje en el tiempo, lado a lado con el presente
Paso 1 — El estado vigente, sin time travel
# time_travel.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.dim_product")
print("=== table.scan() -- SIN time travel, el estado vigente ===")
for row in table.scan().to_arrow().to_pylist():
print(f" {row['product_id']} {row['category']:<14} unit_cost={row['unit_cost']}")
Qué esperar (verificado corriendo el script real):
=== table.scan() -- SIN time travel, el estado vigente ===
P001 beverages unit_cost=0.4
P002 health-snacks unit_cost=0.68
P003 beverages unit_cost=0.35
P004 electronics unit_cost=2.1
Esto es exactamente lo que la lección 3 dejó como vigente — nada nuevo todavía.
Paso 2 — El mismo scan, AS OF snap_v1
# snap_v1 se recupera exactamente como lo capturaste en la leccion 2
# (o como lo recuperaste en la leccion 4, si lo perdiste). Aqui, para que
# este script sea autocontenido, se recupera por contenido de negocio:
history = table.history()
snap_v1 = next(
entry.snapshot_id
for entry in history
if any(
r["product_id"] == "P002" and r["category"] == "snacks"
for r in table.scan(snapshot_id=entry.snapshot_id).to_arrow().to_pylist()
)
)
print("\n=== table.scan(snapshot_id=snap_v1) -- TIME TRAVEL ===")
for row in table.scan(snapshot_id=snap_v1).to_arrow().to_pylist():
print(f" {row['product_id']} {row['category']:<14} unit_cost={row['unit_cost']}")
Qué esperar (verificado corriendo el script real; snap_v1 se muestra recuperado por contenido, no como número — ver la lección 4 de este módulo):
=== table.scan(snapshot_id=snap_v1) -- TIME TRAVEL ===
P001 beverages unit_cost=0.4
P002 snacks unit_cost=0.6
P003 beverages unit_cost=0.35
P004 electronics unit_cost=2.1
Fíjate en lo que cambió, y en lo que no. P002 volvió a category='snacks', unit_cost=0.6 — exactamente el valor V1 que la lección 2 cargó. P001, P003 y P004 son idénticos en ambos escaneos — tiene sentido: esos tres productos nunca cambiaron entre V1 y V2, así que da lo mismo mirar el snapshot de antes o el de ahora. El único parámetro que cambió entre los dos bloques de código de esta lección es snapshot_id=snap_v1 — ni una sola línea de tu esquema, tu JOIN ni tu lógica de negocio tuvo que cambiar.
Paso 3 — Confirma que el grano no cambió: cuatro filas en ambas versiones
current_count = table.scan().to_arrow().num_rows
v1_count = table.scan(snapshot_id=snap_v1).to_arrow().num_rows
print(f"\nfilas en el estado vigente: {current_count}")
print(f"filas AS OF snap_v1: {v1_count}")
Qué esperar:
filas en el estado vigente: 4
filas AS OF snap_v1: 4
Cuatro filas en las dos versiones — el time travel no perdió ni agregó ninguna fila, solo cambió cuáles valores ves para esas cuatro filas. Esto confirma algo importante: table.scan(snapshot_id=...) no es una operación parcial ni aproximada — es exactamente el mismo tipo de scan() que ya conoces, con el mismo to_arrow(), la misma capacidad de aplicar row_filter o selected_fields si los necesitas — la única diferencia es qué snapshot usa como fuente.
Diagrama: un parámetro, dos realidades
flowchart TB
T["kiosko.dim_product"]
T -->|"table.scan()\n(sin argumentos)"| C["lee el snapshot VIGENTE\nP002 = health-snacks / 0.68"]
T -->|"table.scan(snapshot_id=snap_v1)"| P["lee el snapshot snap_v1\nP002 = snacks / 0.60"]
C -.->|"current-snapshot-id\ndel metadata.json vigente"| M1["archivo de metadata actual\n(leccion 3, modulo 2)"]
P -.->|"snap_v1 explicito,\nignora current-snapshot-id"| M2["el snapshot snap_v1,\narchivado, nunca borrado"]
Profundización: por qué el time travel es siempre de solo lectura
Vale la pena ser explícito sobre algo que el ejemplo de esta lección demuestra, pero no dice en voz alta: table.scan(snapshot_id=snap_v1) no modifica nada. No mueve el puntero current-snapshot-id del catálogo, no crea un snapshot nuevo, no "restaura" la tabla a ese estado. Es, con total precisión, una lectura — la misma clase de operación que un SELECT en SQL, solo que apuntando a un punto específico del historial en vez de al más reciente. Puedes correr table.scan(snapshot_id=snap_v1) tantas veces como quieras, en cualquier orden, mezclado con lecturas del estado vigente, sin ningún efecto secundario sobre la tabla ni sobre ninguna otra consulta. Esta propiedad —que viajar en el tiempo nunca cambia el tiempo— es la que hace que el time travel sea seguro para auditorías, depuración de reportes históricos, o simplemente curiosidad, sin el riesgo de que alguien, sin querer, "revierta" la tabla a un estado viejo con una consulta de lectura.
Esto también responde una pregunta que podría surgir de la lección 4: si necesitas viajar en el tiempo por fecha en vez de por snapshot_id, PyIceberg ofrece table.snapshot_as_of_timestamp(timestamp_ms, inclusive=True), que devuelve el objeto Snapshot vigente en ese instante —tú capturas su .snapshot_id y lo pasas a table.scan(snapshot_id=...), exactamente el mismo método que usaste en esta lección—. Y para quien trabaje con Spark SQL sobre Iceberg —el módulo 6 de esta guía—, la sintaxis equivalente es SELECT * FROM local.kiosko.dim_product VERSION AS OF <snapshot_id> o TIMESTAMP AS OF <fecha>: mismo concepto, dos superficies distintas —Python y SQL— sobre el mismo mecanismo de snapshots.
Errores comunes
Esperar que table.scan(snapshot_id=snap_v1) cambie lo que devuelve table.scan() sin argumentos. Qué pasa: alguien corre el paso 2 de esta lección, ve que recupera P002=snacks, y después corre de nuevo el paso 1 —table.scan() sin argumentos—, esperando ver también snacks, porque "acabo de viajar en el tiempo". Por qué pasa: es fácil confundir "consultar un estado pasado" con "restaurar ese estado", especialmente viniendo de sistemas donde un rollback sí cambia el estado vigente. Cómo detectarlo: si después de correr un table.scan(snapshot_id=...) esperas que el comportamiento por defecto de la tabla haya cambiado, revisa la Profundización de esta lección. Cómo corregirlo: table.scan(snapshot_id=snap_v1) es de solo lectura — el snapshot vigente de la tabla sigue siendo el mismo antes y después de esa llamada. Si de verdad necesitaras revertir el estado vigente de una tabla a un snapshot anterior —una operación real, distinta de time travel de solo lectura—, esa es una operación de administración explícita (table.manage_snapshots()), no algo que ocurra como efecto secundario de una lectura.
Intentar pasar un snapshot_id de una tabla distinta. Qué pasa: alguien, trabajando con más de una tabla en el mismo script —kiosko.dim_product y kiosko.fact_orders, por ejemplo—, confunde cuál snapshot_id pertenece a cuál tabla, y pasa el de una a scan() de la otra. Por qué pasa: los snapshot_id son enteros grandes sin ningún prefijo ni indicio visual de a qué tabla pertenecen, así que es fácil mezclarlos si tienes varias variables sueltas en el mismo script. Cómo detectarlo: si table.scan(snapshot_id=algo).to_arrow() falla con un error indicando que ese snapshot no existe para esta tabla, revisa de qué tabla capturaste ese snapshot_id originalmente. Cómo corregirlo: nombra tus variables con la tabla incluida cuando trabajes con más de una a la vez —por ejemplo, dim_product_snap_v1 en vez de solo snap_v1— para que sea imposible confundirlas por accidente.
Ejercicios
Ejercicio 1 — Reproduce el viaje en el tiempo tú mismo, y confirma las cuatro filas en ambas versiones. Con el estado de las lecciones 2 y 3 disponible, corre los tres pasos de esta lección. Confirma que P002 cambia entre snacks/0.6 y health-snacks/0.68 según qué scan() uses, y que las otras tres filas son idénticas en ambos casos.
Ver solución
Tu salida debería coincidir exactamente con la de esta lección: cuatro filas en cada escaneo, con P002 como la única fila que difiere entre el estado vigente y snap_v1. Si P001, P003 o P004 también difieren entre ambos escaneos, revisa si cargaste V1 y V2 exactamente como las definieron las lecciones 2 y 3 — esos tres productos no deberían cambiar nunca en este módulo.
Ejercicio 2 — Usa table.snapshot_as_of_timestamp() en vez de un snapshot_id explícito. Usando history[0].timestamp_ms —el timestamp del primer snapshot de dim_product—, llama a table.snapshot_as_of_timestamp(history[0].timestamp_ms, inclusive=True) y confirma que el .snapshot_id que devuelve coincide con el snap_v1 que recuperaste en el paso 2 de esta lección.
Ver solución
history = table.history()
snap_by_timestamp = table.snapshot_as_of_timestamp(history[0].timestamp_ms, inclusive=True)
print("coincide con snap_v1:", snap_by_timestamp.snapshot_id == snap_v1)
El resultado debería ser True — history[0] es el primer snapshot cronológicamente, y su timestamp_ms es, por definición, el momento exacto en que se creó. Pedirle a snapshot_as_of_timestamp() el snapshot vigente en ese instante exacto (con inclusive=True, que incluye el snapshot creado justo en ese milisegundo) debería devolver ese mismo snapshot. Este ejercicio confirma que el time travel por fecha y el time travel por snapshot_id son, en el fondo, dos caminos hacia el mismo destino.
Ejercicio 3 — Predicción: ¿qué devolvería table.scan(snapshot_id=snap_v1, row_filter="product_id == 'P002'")? Sin correrlo, predice: si combinas el snapshot_id de V1 con un row_filter que solo pide P002, ¿cuántas filas y con qué valores esperas ver? Justifica tu respuesta pensando en que row_filter y snapshot_id son dos parámetros independientes del mismo scan().
Ver solución
Una sola fila: P002, category='snacks', unit_cost=0.6. snapshot_id decide de qué versión de la tabla lees; row_filter decide qué filas de esa versión te interesan — son dos filtros independientes, aplicados juntos, no uno en reemplazo del otro. Esto es exactamente el mismo scan() que ya conoces desde el módulo 1, con dos parámetros combinados en vez de uno solo — el time travel no crea una API paralela, se integra en la misma que ya usabas.
Resumen y siguiente paso
En esta lección viajaste en el tiempo de verdad: table.scan(snapshot_id=snap_v1) recuperó el estado exacto de kiosko.dim_product antes del cambio de P002 —snacks/0.6—, mientras que table.scan() sin argumentos sigue mostrando el estado vigente —health-snacks/0.68—. Confirmaste que el grano no cambia entre versiones (cuatro filas en ambas), y que el time travel es siempre una operación de solo lectura, sin ningún efecto sobre el estado vigente de la tabla.
Antes de avanzar deberías poder: usar table.scan(snapshot_id=...) para leer un estado histórico de cualquier tabla Iceberg; explicar por qué el time travel nunca modifica la tabla; y nombrar la alternativa por fecha (snapshot_as_of_timestamp) para cuando no tienes un snapshot_id a mano pero sí sabes la fecha aproximada.
Tienes el estado correcto de P002 recuperado. La lección 6 conecta ese resultado con kiosko.fact_orders para calcular, por fin, el margen correcto de P002 —el mismo 10.8 que ya viste en data-modeling y dbt— usando una tabla sin ninguna columna de historia.
Recursos
- PyIceberg — referencia de API, sintaxis exacta de
table.scan(snapshot_id=...). py.iceberg.apache.org/api. En inglés. - Apache Iceberg — documentación oficial, "Spark Queries", sintaxis equivalente
VERSION AS OF/TIMESTAMP AS OFen SQL, retomada en el módulo 6 de esta guía. iceberg.apache.org/docs/latest/spark-queries. En inglés. - DISEÑO de
data-modeling-for-analytics-guide— fuente del estadoV1exacto deP002(snacks/0.60) que este time travel recupera.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 exacta de este paso del experimento.
src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.