Módulo 3: Snapshots And Time Travel
Recuperando la historia de P002 con cero columnas extra
Descripción
Esta es la lección de pago de todo el módulo. Vas a unir kiosko.fact_orders —las cuarenta órdenes reales de Kiosko, sin cambios desde el módulo 1— contra kiosko.dim_product, leída con time travel a snap_v1, y vas a calcular el margen por categoría de las seis guías anteriores. El número que estás buscando es 10.8 para snacks —el margen correcto de P002—, el mismo que ya calculaste en data-modeling-for-analytics-guide con un JOIN punto-en-el-tiempo sobre columnas valid_from/valid_to, y el mismo que dbt-analytics-engineering-guide reprodujo con dbt snapshot. La diferencia, esta vez, es que kiosko.dim_product tiene exactamente cuatro columnas.
Conexión con el módulo. Las lecciones 2 a 5 construyeron cada pieza por separado: la tabla, las dos escrituras, la disciplina de captura, el scan() con snapshot_id. Esta lección las junta en el cálculo real que le da sentido a todo el módulo — no "puedes viajar en el tiempo" en abstracto, sino "puedes viajar en el tiempo, y el número que obtienes es el correcto, verificado contra dos guías anteriores que llegaron al mismo resultado por caminos distintos".
Una analogía: el balance del contador, con y sin la foto correcta
Un contador que calcula el margen de una venta necesita dos números: cuánto se cobró, y cuánto costó lo que se vendió. Si usa el precio de costo de hoy para calcular el margen de una venta de la semana pasada, el balance queda mal — no porque haya un error de aritmética, sino porque usó el dato equivocado para el momento equivocado. Esta lección es, con precisión, ese balance hecho dos veces: una con el precio de costo de hoy (el error), y otra con el precio de costo vigente el día de la venta —recuperado con la foto correcta del archivo, no con una columna que alguien tuvo que mantener a mano—.
Ejemplo trabajado: el mismo margen, dos formas de calcularlo
Paso 1 — El margen ROTO: fact_orders contra el estado vigente de dim_product
# margin_broken_vs_correct.py
import os
from collections import defaultdict
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}",
)
dim_product = catalog.load_table("kiosko.dim_product")
fact_orders = catalog.load_table("kiosko.fact_orders")
fact_rows = fact_orders.scan().to_arrow().to_pylist()
def margin_by_category(dim_rows, fact_rows):
dim_by_id = {r["product_id"]: r for r in dim_rows}
revenue, margin = defaultdict(float), defaultdict(float)
for f in fact_rows:
d = dim_by_id[f["product_id"]]
revenue[d["category"]] += f["revenue"]
margin[d["category"]] += f["revenue"] - f["quantity"] * d["unit_cost"]
return revenue, margin
current_rows = dim_product.scan().to_arrow().to_pylist()
revenue_broken, margin_broken = margin_by_category(current_rows, fact_rows)
print("=== ROTO -- dim_product SIN time travel (P002 = health-snacks / 0.68) ===")
for cat in sorted(revenue_broken):
print(f" {cat:14} revenue={round(revenue_broken[cat], 2):>6} margin={round(margin_broken[cat], 2):>6}")
Qué esperar (verificado corriendo el script real):
=== ROTO -- dim_product SIN time travel (P002 = health-snacks / 0.68) ===
beverages revenue= 44.05 margin= 14.75
electronics revenue= 40.5 margin= 21.6
health-snacks revenue= 21.6 margin= 9.36
health-snacks, margen 9.36. Este es exactamente el resultado roto que ya viste en data-modeling (módulo 5) y en dbt (módulo 5): el JOIN aplicó el unit_cost de hoy (0.68) a diez órdenes que ocurrieron antes de que ese costo entrara en vigencia.
Paso 2 — El margen CORRECTO: fact_orders contra dim_product con time travel a snap_v1
history = dim_product.history()
snap_v1 = next(
entry.snapshot_id
for entry in history
if any(
r["product_id"] == "P002" and r["category"] == "snacks"
for r in dim_product.scan(snapshot_id=entry.snapshot_id).to_arrow().to_pylist()
)
)
v1_rows = dim_product.scan(snapshot_id=snap_v1).to_arrow().to_pylist()
revenue_correct, margin_correct = margin_by_category(v1_rows, fact_rows)
print("\n=== CORRECTO -- dim_product CON time travel (P002 = snacks / 0.60) ===")
for cat in sorted(revenue_correct):
print(f" {cat:14} revenue={round(revenue_correct[cat], 2):>6} margin={round(margin_correct[cat], 2):>6}")
total_revenue = round(sum(f["revenue"] for f in fact_rows), 2)
print(f"\nRevenue total (identico en ambos calculos): {total_revenue}")
Qué esperar:
=== CORRECTO -- dim_product CON time travel (P002 = snacks / 0.60) ===
beverages revenue= 44.05 margin= 14.75
electronics revenue= 40.5 margin= 21.6
snacks revenue= 21.6 margin= 10.8
Revenue total (identico en ambos calculos): 106.15
snacks, margen 10.8. Fíjate en lo que cambia y en lo que no, comparando los dos bloques: beverages y electronics son idénticos en ambos cálculos —P001, P003 y P004 nunca cambiaron, así que da lo mismo qué snapshot de dim_product uses—; la diferencia está toda en la categoría de P002, que pasa de health-snacks/9.36 a snacks/10.8 — una diferencia de 1.44 en el margen, exactamente la que produce aplicar unit_cost=0.68 en vez de unit_cost=0.60 a las 18 unidades vendidas de P002. Y el revenue total, 106.15, es idéntico en los dos cálculos — el error nunca aparece en el número que la mayoría de la gente mira primero.
Los mismos dos números, tres técnicas distintas
| Técnica | Guía | Cómo recupera el estado correcto de P002 | Columnas de historia necesarias |
|---|---|---|---|
JOIN punto-en-el-tiempo | data-modeling-for-analytics-guide (M5) | f.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, '9999-12-31') | valid_from, valid_to, is_current |
dbt snapshot | dbt-analytics-engineering-guide (M5) | Automatiza la misma técnica; el modelo de reporte hace el mismo JOIN punto-en-el-tiempo contra la tabla de snapshot | dbt_valid_from, dbt_valid_to, dbt_scd_id |
| Time travel | Esta guía (M3) | table.scan(snapshot_id=snap_v1) — la tabla completa, leída en un instante anterior | Ninguna |
Los tres llegan al mismo par de números —9.36 roto, 10.8 correcto—, con el mismo revenue total de 106.15 en cualquier variante. Las dos primeras técnicas resuelven el problema agregando información a la tabla —columnas que alguien tiene que declarar, poblar y mantener en cada MERGE—. La tercera lo resuelve sin agregar nada — la información ya estaba disponible, archivada en el propio mecanismo de snapshots, sin que nadie tuviera que pedírselo a Iceberg de antemano.
Diagrama: el JOIN, con la foto correcta de fondo
flowchart LR
F["kiosko.fact_orders\n40 filas, sin cambios desde M1"] --> J["JOIN por product_id"]
D1["dim_product.scan()\nP002 = health-snacks/0.68"] -.->|"JOIN roto"| J
D2["dim_product.scan(snapshot_id=snap_v1)\nP002 = snacks/0.60"] -.->|"JOIN correcto"| J
J --> M["margin por categoria"]
M --> R1["health-snacks: 9.36 (ROTO)"]
M --> R2["snacks: 10.8 (CORRECTO)"]
Profundización: por qué esta técnica funciona tan limpiamente aquí
Vale la pena adelantar, antes de la lección 7, por qué este cálculo salió tan limpio: las cuarenta órdenes de Kiosko ocurren todas entre el 3 y el 9 de agosto de 2026, y el cambio de P002 entra en vigencia el 15 de agosto — una fecha posterior a todas las órdenes, sin ninguna excepción. Eso significa que existe un único snapshot —snap_v1— que es correcto para las cuarenta filas de fact_orders a la vez, sin ninguna excepción caso por caso. No tuviste que preguntarte "¿esta orden específica es de antes o de después del cambio?" fila por fila —la respuesta fue la misma para las cuarenta—, así que un solo scan(snapshot_id=snap_v1) bastó para todas. Esta condición —todos los hechos relevantes caen del mismo lado de un único cambio de dimensión— es la que hace que el time travel, en este caso concreto, sea un reemplazo perfecto del JOIN punto-en-el-tiempo. La lección 7 examina, con evidencia, qué pasa cuando esa condición deja de cumplirse.
Errores comunes
Olvidar que el JOIN en Python de esta lección no es la única forma de hacerlo, ni la más eficiente a escala. Qué pasa: alguien, viendo el dict de dim_by_id y el bucle for f in fact_rows de esta lección, asume que así es como se hace un JOIN "de verdad" contra una tabla Iceberg. Por qué pasa: esta guía, hasta este punto, no introdujo ningún motor SQL —DuckDB, Spark— capaz de ejecutar un JOIN declarativo directamente sobre los resultados de table.scan(). Cómo detectarlo: si te preguntas cómo harías este mismo cálculo con miles de productos o millones de órdenes, sospecha que el bucle en Python puro de este ejemplo no es la respuesta de producción. Cómo corregirlo: en un caso real, tomarías el resultado de dim_product.scan(snapshot_id=snap_v1).to_arrow() y fact_orders.scan().to_arrow() —ambos ya son pyarrow.Table— y los unirías con pyarrow.Table.join(), o los cargarías en DuckDB (que puede leer un pyarrow.Table directamente) para escribir el JOIN en SQL declarativo, exactamente como ya hiciste en data-modeling. Esta lección usa un bucle en Python puro únicamente para mantener el ejemplo autocontenido, sin agregar una dependencia nueva a esta guía solo para un cálculo de cuatro categorías.
Pensar que el time travel "reemplaza" el concepto de JOIN punto-en-el-tiempo, en vez de resolverlo de otra forma en este caso particular. Qué pasa: alguien concluye, después de ver los mismos números recuperados con menos código, que el JOIN BETWEEN valid_from AND valid_to de data-modeling ya no tiene ningún valor. Por qué pasa: el resultado de esta lección es, en efecto, más simple de escribir que el JOIN punto-en-el-tiempo completo. Cómo detectarlo: si no puedes explicar por qué la Profundización de esta lección menciona "todas las órdenes caen del mismo lado de un único cambio" como una condición favorable, todavía no viste el límite completo. Cómo corregirlo: la lección 7, inmediatamente después de esta, existe exactamente para cerrar esta idea — léela antes de sacar una conclusión general sobre cuándo usar cada técnica.
Ejercicios
Ejercicio 1 — Reproduce los dos cálculos tú mismo, y confirma los cuatro números. Con el estado completo de las lecciones 2, 3 y 5 disponible, corre el script completo de esta lección. Confirma que obtienes health-snacks/9.36 en el cálculo roto y snacks/10.8 en el correcto, con 106.15 de revenue total en ambos.
Ver solución
Tu salida debería coincidir exactamente con la de esta lección, número por número — a diferencia de un snapshot_id, estos son datos de negocio de Kiosko, deterministas, y deberían ser idénticos sin importar cuándo corras el script. Si obtienes un revenue total distinto de 106.15, revisa primero que kiosko.fact_orders tenga las cuarenta filas exactas del módulo 1, sin ninguna carga adicional accidental.
Ejercicio 2 — Calcula el margen total de Kiosko (las tres categorías sumadas), en ambas versiones. Usando los resultados de margin_broken y margin_correct del script de esta lección, suma el margen de las tres categorías en cada versión. ¿Cuál es la diferencia entre los dos totales, y coincide con la diferencia que ya calculaste en P002 individualmente?
Ver solución
total_margin_broken = round(sum(margin_broken.values()), 2)
total_margin_correct = round(sum(margin_correct.values()), 2)
print(total_margin_broken, total_margin_correct, round(total_margin_correct - total_margin_broken, 2))
El margen total roto es 14.75 + 21.6 + 9.36 = 45.71; el correcto es 14.75 + 21.6 + 10.8 = 47.15. La diferencia es 1.44 — exactamente la misma diferencia que ya viste entre 9.36 y 10.8 de P002 individualmente, porque beverages y electronics no cambian entre ambos cálculos. Esto confirma que todo el error del cálculo roto está concentrado en P002, sin filtrarse a ninguna otra categoría — la misma conclusión, con la misma evidencia, que data-modeling ya mostró con su JOIN punto-en-el-tiempo.
Ejercicio 3 — Explica por qué revenue nunca cambia entre el cálculo roto y el correcto, pero margin sí. En 2-3 frases, explica por qué el revenue de P002 es 21.6 en ambos cálculos, mientras que el margen cambia de 9.36 a 10.8.
Ver solución
revenue viene enteramente de fact_orders —quantity * unit_price, calculado en el momento de cada venta y guardado en la orden misma—, y fact_orders no cambió entre los dos cálculos: son las mismas cuarenta filas en ambos casos. margin, en cambio, se calcula como revenue - quantity * unit_cost, y unit_cost viene de dim_product —la tabla que sí cambió entre los dos cálculos, según qué snapshot uses—. El revenue depende únicamente de lo que ya pasó (el hecho histórico); el margen depende también de un dato de la dimensión que puede cambiar después de que el hecho ya ocurrió — exactamente la razón por la que esta guía completa insiste en que "el revenue total nunca delata el error": solo una métrica que depende de la dimensión lo revela.
Resumen y siguiente paso
En esta lección calculaste el margen de las tres categorías de Kiosko dos veces: una con el estado vigente de dim_product (health-snacks, margen 9.36, roto), y otra con time travel a snap_v1 (snacks, margen 10.8, correcto) — los mismos dos números que ya confirmaron data-modeling-for-analytics-guide y dbt-analytics-engineering-guide, ahora con una tabla de exactamente cuatro columnas, ninguna de historia. Viste la tabla comparativa de las tres técnicas —JOIN punto-en-el-tiempo, dbt snapshot, time travel—, y una primera pista de por qué esta técnica funcionó tan limpiamente en este caso particular.
Antes de avanzar deberías poder: unir fact_orders contra una versión histórica de dim_product recuperada con time travel; explicar la tabla comparativa de las tres técnicas; y explicar, en tus propias palabras, la condición que hizo que el time travel bastara en este caso —todas las órdenes caen del mismo lado de un único cambio—.
Esa condición no siempre se cumple. La lección 7 —la más importante de este módulo— muestra, con un ejemplo ejecutado, exactamente qué pasa cuando deja de cumplirse.
Recursos
- PyIceberg — referencia de API,
table.scan(snapshot_id=...).to_arrow(), la base delJOINde esta lección. py.iceberg.apache.org/api. En inglés. - DISEÑO de
data-modeling-for-analytics-guide— fuente delJOINpunto-en-el-tiempo original (f.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, '9999-12-31')) y los números canónicos9.36/10.8.src/guides/data-modeling-for-analytics-guide/DISENO.md. En español. - DISEÑO de
dbt-analytics-engineering-guide— fuente dedbt snapshotydbt_valid_from/dbt_valid_to, la segunda técnica de la tabla comparativa de esta lección.src/guides/dbt-analytics-engineering-guide/DISENO.md. En español. - DISEÑO de esta guía — la sección "Snapshots y time travel" (M3), fuente del resultado exacto que esta lección verifica.
src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.