Módulo 3: Snapshots And Time Travel
Capturando el snapshot-id, nunca hardcodeándolo
Descripción
Las lecciones 2 y 3 ya siguieron esta regla sin explicarla del todo: cada vez que este módulo necesitó un snapshot_id, lo capturó en una variable —snap_v1— inmediatamente después de la escritura que lo creó. Esta lección se detiene en esa disciplina, la convierte en regla explícita, y muestra qué se rompe si no la sigues. También resuelve un problema práctico: ¿qué haces si no capturaste el snapshot_id a tiempo, y necesitas encontrarlo después?
Conexión con el módulo. Sin esta lección, la lección 5 —el viaje en el tiempo en sí— dependería de un número que nadie te explicó por qué es tan frágil. Esta lección es el "por qué" antes del "cómo": entender que un snapshot_id no es un dato de negocio de Kiosko, sino un identificador que el propio motor genera en el instante del commit, es la base de por qué el resto de esta guía —y de todo el ecosistema— nunca lo escribe como literal en ningún lado.
Una analogía: el número de rollo de una cámara, no la fecha de la foto
Piensa de nuevo en el archivo de fotos del supermercado. Cada foto tiene una fecha —eso es un dato de negocio, previsible, el mismo para cualquiera que pregunte "¿qué foto es del 15 de agosto?"—. Pero el archivo también le asigna a cada foto un número de rollo interno, generado por la cámara en el momento exacto en que se toma la foto, sin ninguna relación con la fecha ni con el contenido. Ese número de rollo es exactamente lo que es un snapshot_id: un identificador que existe porque el sistema necesita distinguir una foto de otra, generado en el instante de la captura, y que va a ser distinto si la misma foto se hubiera tomado un segundo antes o después. Nadie memoriza el número de rollo de una foto para encontrarla después — se busca por fecha, por evento, por lo que la foto contiene. El snapshot_id funciona igual: no es lo que buscas, es lo que usas una vez que ya sabes cuál es la foto correcta.
Ejemplo trabajado: la prueba de que no es reproducible, y cómo recuperarlo si lo perdiste
Paso 1 — Evidencia real: dos corridas idénticas, dos snapshot_id completamente distintos
Esto no es una advertencia teórica. Corrí el script completo de las lecciones 2 y 3 de este módulo —crear kiosko.dim_product, cargar V1, sobrescribir con V2— dos veces, en dos directorios de trabajo distintos, desde cero cada vez. El código fue exactamente el mismo. Esto es lo que capturó cada corrida:
Corrida A -- snap_v1: 2791049306460028584 snap_v2: 8316902092849711638
Corrida B -- snap_v1: 5083159773583532361 snap_v2: 6033084327179618444
Ni un solo dígito en común entre snap_v1 de la Corrida A y snap_v1 de la Corrida B — más allá de que ambos son enteros de la misma magnitud, no hay ninguna relación predecible entre ellos. Si el código de la lección 5 hubiera escrito table.scan(snapshot_id=2791049306460028584) como un literal —copiado de una corrida anterior, o de esta misma lección—, habría funcionado en la Corrida A, y habría fallado (o, peor, habría apuntado a un snapshot que no existe, o a uno equivocado) en cualquier otra corrida, incluida la tuya, ahora mismo, en tu propia máquina.
Paso 2 — La forma correcta: capturar en el mismo bloque que escribe
# el patron correcto, ya usado en las lecciones 2 y 3 de este modulo
table.append(pa_table_v1)
snap_v1 = table.current_snapshot().snapshot_id # <- capturado DE INMEDIATO, en la siguiente linea
# ... mas codigo, mas adelante, en otra lección o en otro momento del script ...
table.overwrite(pa_table_v2)
snap_v2 = table.current_snapshot().snapshot_id # <- capturado DE INMEDIATO, otra vez
table.current_snapshot() siempre devuelve el snapshot vigente en este momento — así que si lo llamas inmediatamente después de la escritura que te interesa, antes de cualquier otra operación, tienes la garantía de que estás capturando exactamente esa escritura, y no una posterior que alguien más (u otro proceso) pudiera haber hecho mientras tanto. Capturar tarde —"ya voy a necesitar el snapshot_id de la carga de V1, lo busco cuando llegue el momento"— es exactamente el hábito que rompe esta garantía.
Paso 3 — Si de verdad lo perdiste: recuperarlo por contenido, no por posición
Supón que, por la razón que sea, no capturaste snap_v1 en su momento, y ahora kiosko.dim_product ya tiene los tres snapshots de la lección 3 —append, delete, append—. ¿Cómo encuentras cuál de los tres es "la foto de P002 antes del cambio"?
# recuperar_snap_v1.py -- si no lo capturaste en su momento
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")
history = table.history()
print(f"table.history() tiene {len(history)} entradas\n")
recovered_snap_v1 = None
for i, entry in enumerate(history):
rows = table.scan(snapshot_id=entry.snapshot_id).to_arrow().to_pylist()
p002 = next((r for r in rows if r["product_id"] == "P002"), None)
category = p002["category"] if p002 else "(0 filas -- snapshot intermedio de delete)"
print(f" history()[{i}]: P002.category = {category}")
if p002 is not None and p002["category"] == "snacks":
recovered_snap_v1 = entry.snapshot_id
print(f"\nsnap_v1 recuperado (ultimo snapshot con P002 en 'snacks'): encontrado = {recovered_snap_v1 is not None}")
Qué esperar (verificado corriendo el script real; los snapshot_id internos no se muestran, solo la posición y el contenido de negocio, que sí son deterministas):
table.history() tiene 3 entradas
history()[0]: P002.category = snacks
history()[1]: P002.category = (0 filas -- snapshot intermedio de delete)
history()[2]: P002.category = health-snacks
snap_v1 recuperado (ultimo snapshot con P002 en 'snacks'): encontrado = True
Fíjate en el método: no asumiste que "el primer snapshot" (history()[0]) es siempre snap_v1 por estar en la posición cero — en esta tabla, con solo dos escrituras, dio la casualidad de que sí lo es, pero esa posición no está garantizada en general (una tabla con más historia acumulada podría tener snap_v1 en cualquier posición). Lo que hiciste fue verificar el contenido real de cada candidato con table.scan(snapshot_id=...), y quedarte con el último snapshot donde P002 todavía tenía category='snacks' — la definición precisa de "el estado antes del cambio". Esta técnica —recorrer table.history(), escanear cada candidato, y decidir por el contenido de negocio, no por la posición— es la forma robusta de recuperar un snapshot_id que no capturaste a tiempo.
Diagrama: capturar en el momento, contra reconstruir después
flowchart TB
subgraph correcto["Forma correcta -- capturar de inmediato"]
W1["table.append(V1)"] --> C1["snap_v1 = table.current_snapshot().snapshot_id\n(en la misma linea de codigo)"]
end
subgraph tardio["Si se te paso -- reconstruir por contenido"]
H["table.history()"] --> L["recorre cada entrada"]
L --> SC["table.scan(snapshot_id=candidato)\nrevisa el contenido de negocio"]
SC --> D["te quedas con el ultimo\nque cumple la condicion que buscas"]
end
Profundización: por qué el timestamp_ms tampoco se hardcodea
La misma regla aplica al otro dato que cada entrada de table.history() trae consigo: timestamp_ms, el momento exacto del commit en milisegundos desde epoch. Es tentador pensar que un timestamp, a diferencia de un snapshot_id, sí es "predecible" —después de todo, sabes aproximadamente cuándo corriste tu script—, pero en la práctica es igual de frágil como literal: depende del reloj de la máquina donde corriste el commit, de cuánto tardó la escritura, y de cualquier reintento que haya ocurrido por dentro. Esta guía completa —igual que dbt-analytics-engineering-guide con product_updated_at y spark-and-distributed-processing-guide al medir tiempos de ejecución— sigue la misma disciplina: cualquier timestamp de commit se captura en variable inmediatamente después de generarse, nunca se escribe como literal en el código ni se predice de antemano. table.snapshot_as_of_timestamp(timestamp_ms, inclusive=True) —un método real de PyIceberg, verificado contra la API 0.11.1— te permite viajar en el tiempo por fecha en vez de por snapshot_id, pero el timestamp_ms que le pases tiene que venir siempre de un valor capturado (por ejemplo, history[0].timestamp_ms), nunca de un número que escribiste a mano pensando "esto fue más o menos a esa hora".
Errores comunes
Copiar un snapshot_id de la documentación de esta guía, o de una corrida anterior, y pegarlo como literal en código nuevo. Qué pasa: alguien, leyendo los ejemplos de esta guía, ve un snapshot_id de muestra en algún lado —de una lección, de un mensaje de error, de una corrida propia de ayer— y lo copia directamente en un script nuevo, en vez de capturarlo con table.current_snapshot() o recuperarlo con la técnica del paso 3. Por qué pasa: un entero grande se ve como un dato normal, del mismo tipo que un product_id o un store_id, que sí son estables entre corridas. Cómo detectarlo: si tu código tiene un snapshot_id=<un numero de 18-20 digitos escrito directamente> en vez de una variable, revisa de dónde salió ese número. Cómo corregirlo: cualquier snapshot_id en tu código debe venir de una llamada a table.current_snapshot(), table.history(), table.snapshots() o table.snapshot_by_id() — nunca de un literal copiado, ni siquiera "solo para probar".
Confundir "el snapshot más viejo" con "el snapshot que necesito". Qué pasa: alguien, con una tabla que acumuló muchas escrituras a lo largo de varios módulos —algo que vas a ver más adelante en esta guía—, asume que table.history()[0] siempre corresponde al estado que está buscando, sin verificar el contenido. Por qué pasa: en el ejemplo de esta lección, con solo dos escrituras, history()[0] sí resultó ser snap_v1 — y es fácil generalizar ese resultado particular a una regla general que no lo es. Cómo detectarlo: si tu tabla tiene más de dos o tres snapshots acumulados y confías en una posición fija del historial sin haber verificado su contenido, estás en riesgo de este error. Cómo corregirlo: usa siempre la técnica del paso 3 de esta lección —recorrer los candidatos y verificar con table.scan(snapshot_id=...) qué contienen de verdad— en vez de asumir una posición fija en table.history().
Ejercicios
Ejercicio 1 — Reproduce la comparación de dos corridas tú mismo. Corre las lecciones 2 y 3 de este módulo dos veces, en dos directorios de trabajo completamente distintos (dos kiosko_warehouse//kiosko_catalog.db separados). Anota el snap_v1 de cada corrida. Confirma que son distintos.
Ver solución
Deberías obtener dos enteros grandes, sin ninguna relación predecible entre sí —ni el mismo valor, ni una diferencia constante, ni ningún patrón—, exactamente como las Corridas A y B de esta lección. Esto confirma, con tu propia evidencia, que un snapshot_id no es un dato reproducible entre corridas, incluso cuando el código y los datos de negocio son idénticos.
Ejercicio 2 — Implementa la recuperación por contenido para encontrar snap_v2 (no snap_v1). Adapta el script del paso 3 de esta lección para encontrar, en cambio, el snapshot_id del último snapshot donde P002 tiene category='health-snacks' — es decir, snap_v2, sin haberlo capturado en la lección 3.
Ver solución
recovered_snap_v2 = None
for entry in table.history():
rows = table.scan(snapshot_id=entry.snapshot_id).to_arrow().to_pylist()
p002 = next((r for r in rows if r["product_id"] == "P002"), None)
if p002 is not None and p002["category"] == "health-snacks":
recovered_snap_v2 = entry.snapshot_id
print("snap_v2 recuperado:", recovered_snap_v2 == table.current_snapshot().snapshot_id)
El resultado debería confirmar que el snapshot_id recuperado por este método coincide exactamente con table.current_snapshot().snapshot_id — la forma más directa y simple de obtener el snapshot vigente, que no necesita ningún recorrido de historial en absoluto. Este ejercicio existe para mostrar que la técnica de recuperación por contenido es general —sirve para encontrar cualquier estado pasado, no solo snap_v1—, aunque para el snapshot vigente siempre hay una forma más directa.
Ejercicio 3 — Explica, con tus propias palabras, la diferencia entre un product_id y un snapshot_id. En 2-3 frases, contrasta por qué "P002" sí es seguro escribir como literal en el código de esta guía —de hecho, lo hace todo el tiempo—, mientras que un snapshot_id nunca lo es.
Ver solución
"P002" es un dato de negocio: un identificador de catálogo que Kiosko definió, estable a través de cualquier corrida, cualquier motor y cualquier módulo de esta guía completa —es, por diseño, el mismo valor sin importar cuándo o cómo se ejecute el código—. Un snapshot_id es un identificador técnico, generado por el motor de Iceberg en el instante exacto de un commit, sin ninguna relación con el significado de negocio de los datos que contiene ese snapshot. Escribir "P002" como literal es seguro porque ese valor es, por definición, siempre el mismo; escribir un snapshot_id como literal es peligroso porque ese valor es, por definición, distinto cada vez que el código que lo generó se vuelve a ejecutar.
Resumen y siguiente paso
En esta lección confirmaste, con evidencia de dos corridas reales, que un snapshot_id nunca es reproducible entre ejecuciones —ni siquiera con el mismo código y los mismos datos de negocio—. Viste la forma correcta de capturarlo (inmediatamente después de la escritura que lo genera) y la forma de recuperarlo si no lo hiciste a tiempo (recorriendo table.history() y verificando el contenido de cada candidato, nunca confiando en su posición). Y viste que la misma regla aplica al timestamp_ms de cada commit.
Antes de avanzar deberías poder: explicar por qué un snapshot_id nunca se escribe como literal en código; capturar un snapshot_id inmediatamente después de una escritura; y recuperar un snapshot_id perdido verificando el contenido de cada snapshot candidato, en vez de asumir una posición fija en el historial.
Con snap_v1 capturado de forma correcta —el de la lección 2, o el que acabas de recuperar en esta lección—, la lección 5 lo usa por primera vez para viajar en el tiempo de verdad: table.scan(snapshot_id=snap_v1).
Recursos
- PyIceberg — referencia de API,
table.current_snapshot(),table.history(),table.snapshots()ytable.snapshot_by_id(), los cuatro puntos de entrada para obtener unsnapshot_idsin hardcodearlo. py.iceberg.apache.org/api. En inglés. - PyIceberg — referencia de API,
table.snapshot_as_of_timestamp(timestamp_ms, inclusive=True), el método equivalente para buscar un snapshot por fecha en vez de por identificador. py.iceberg.apache.org/api. En inglés. - DISEÑO de
dbt-analytics-engineering-guide— fuente de la misma disciplina aplicada aproduct_updated_at, nuncaCURRENT_TIMESTAMP, el precedente directo de esta regla en el ecosistema.src/guides/dbt-analytics-engineering-guide/DISENO.md. En español. - DISEÑO de esta guía — la regla dura completa sobre nunca hardcodear un
snapshot-ido timestamp de commit, con la justificación de por qué no son determinables de antemano.src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.