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 Truehistory[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 P002snacks/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 OF en 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 estado V1 exacto de P002 (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.