Módulo 7: Catalogs Maintenance And Delta Lake By Contrast
Por qué los snapshots acumulan costo
Descripción
Esta lección reconstruye kiosko.dim_product con el estado exacto que dejó el módulo 3 —V1 cargada con append(), P002 cambiado a health-snacks/0.68 con overwrite(), tres snapshots en total— y le agrega lo que ningún módulo anterior necesitó: el paso del tiempo. Vas a simular cinco noches más de un pipeline operativo real, que vuelve a cargar el catálogo completo de productos cada noche sin verificar si algo cambió. Vas a medir, con table.history() ejecutado de verdad, exactamente cuánto cuesta esa falta de verificación.
Conexión con el módulo. La lección 1 prometió evidencia, no una afirmación abstracta de que "los snapshots acumulan costo". Esta lección es esa evidencia: código ejecutado, números reales, antes y después.
Una analogía: el fotógrafo que no revisa el rollo antes de repetir la toma
Vuelve al estante del supermercado del módulo 3: cada reposición completa produce una foto nueva, archivada para siempre. Ahora imagina un empleado que, cada noche, sin falta, vuelve a fotografiar el estante completo — incluso las noches en que nadie tocó un solo producto. Al cabo de una semana, el archivo tiene ocho fotos casi idénticas del mismo estante sin cambios, mezcladas con las dos fotos que sí importan: la de antes del reabastecimiento real, y la de después. Nadie planeó ese desperdicio — simplemente nadie le dijo al empleado "primero revisa si algo cambió, y solo entonces toma la foto".
Ejemplo trabajado: reconstruyendo el estado, y agregando cinco noches redundantes
Paso 1 — El estado exacto del módulo 3: V1 cargada, P002 cambiado
# accumulated_history.py -- reconstruye el estado del modulo 3 y agrega 5 noches redundantes
import os
import pyarrow as pa
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")
os.makedirs(warehouse_path, exist_ok=True)
catalog = load_catalog(
"kiosko", type="sql",
uri=f"sqlite:///{catalog_db_path}", warehouse=f"file://{warehouse_path}",
)
catalog.create_namespace("kiosko")
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)
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),
])
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},
]
DIM_PRODUCT_V2 = [
{"product_id": "P001", "product_name": "Bottled Water 600ml", "category": "beverages", "unit_cost": 0.40},
{"product_id": "P002", "product_name": "Energy Bar", "category": "health-snacks", "unit_cost": 0.68},
{"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},
]
# A -- modulo 3, leccion 2: V1 cargada con append()
table.append(pa.Table.from_pylist(DIM_PRODUCT_V1, schema=pa_schema))
snap_v1 = table.current_snapshot().snapshot_id
print("A) append(V1). snap_v1 capturado.")
# B -- modulo 3, leccion 3: el cambio REAL de P002, con overwrite()
table.overwrite(pa.Table.from_pylist(DIM_PRODUCT_V2, schema=pa_schema))
print("B) overwrite(V2) -- el cambio real del 2026-08-15.")
print("\nDespues de A+B (estado del modulo 3):", len(table.history()), "snapshots")
Qué esperar (verificado corriendo el script real; el snapshot_id de snap_v1 es de tu propia corrida, distinto cada vez):
A) append(V1). snap_v1 capturado.
B) overwrite(V2) -- el cambio real del 2026-08-15.
Despues de A+B (estado del modulo 3): 3 snapshots
Exactamente el mismo número que confirmó el módulo 3, lección 3: append, delete, append — tres snapshots para dos escrituras, porque overwrite() sin filtro se resuelve internamente como un retiro completo seguido de una carga completa.
Paso 2 — Cinco noches, mismo pipeline, ningún cambio real
# el pipeline nocturno de kiosko vuelve a cargar el catalogo completo cada noche,
# sin verificar si el sistema de origen realmente cambio algo -- overwrite() nunca compara.
nights = ["2026-08-16", "2026-08-17", "2026-08-18", "2026-08-19", "2026-08-20"]
for night in nights:
table.overwrite(pa.Table.from_pylist(DIM_PRODUCT_V2, schema=pa_schema))
print("Despues de 5 noches redundantes:", len(table.history()), "snapshots")
Qué esperar (verificado corriendo el script real):
Despues de 5 noches redundantes: 13 snapshots
Cinco noches, cada una con table.overwrite(DIM_PRODUCT_V2) — el mismo contenido, sin ningún dato de negocio distinto—, y el historial pasó de 3 a 13 snapshots: diez nuevos, ninguno con información que no estuviera ya en el snapshot anterior. Fíjate en el número: no son cinco snapshots nuevos, uno por noche — son diez, porque cada overwrite() sin filtro sigue resolviéndose como delete + append, la misma lección del módulo 3 aplicada diez veces más.
Paso 3 — El costo, medido en archivos, no solo en snapshots
all_files = table.inspect.all_data_files()
live_files = table.inspect.files()
print("all_data_files().num_rows (todo archivo de datos que algun snapshot aun rastrea):", all_files.num_rows)
print("files().num_rows (archivos que el snapshot VIGENTE necesita):", live_files.num_rows)
current = table.scan().to_arrow().to_pylist()
p002_current = next(r for r in current if r["product_id"] == "P002")
v1_rows = table.scan(snapshot_id=snap_v1).to_arrow().to_pylist()
p002_v1 = next(r for r in v1_rows if r["product_id"] == "P002")
print("current P002:", p002_current["category"], p002_current["unit_cost"])
print("AS OF snap_v1 P002:", p002_v1["category"], p002_v1["unit_cost"])
Qué esperar (verificado corriendo el script real):
all_data_files().num_rows (todo archivo de datos que algun snapshot aun rastrea): 7
files().num_rows (archivos que el snapshot VIGENTE necesita): 1
current P002: health-snacks 0.68
AS OF snap_v1 P002: snacks 0.6
Este es el número que resume todo el problema. kiosko.dim_product sigue teniendo, hoy, exactamente cuatro filas — ni una fila de negocio de más—, y para responder cualquier consulta sobre su estado vigente, Iceberg solo necesita abrir un archivo de datos (files().num_rows == 1). Pero siete archivos de datos siguen existiendo y siguen siendo rastreados por algún snapshot todavía no expirado (all_data_files().num_rows == 7) — seis de ellos contienen, cada uno, una copia completa y redundante de los mismos cuatro productos. Y el time travel sigue funcionando exactamente igual que en el módulo 3: snap_v1 todavía recupera snacks/0.60, sin que las diez escrituras de en medio lo hayan afectado en absoluto.
Por qué esto pasa: tres mecanismos, ninguno nuevo
Nada de lo que acabas de ver depende de un comportamiento oculto o inesperado — son tres reglas que esta guía ya enseñó, una por módulo, ahora combinadas:
- Los archivos de datos son inmutables (módulo 2). Ningún
overwrite()modifica un archivo Parquet existente — siempre escribe uno nuevo. Diez escrituras redundantes producen, como mínimo, diez oportunidades de escribir un archivo nuevo (en este caso, cinco de las diez operaciones fuerondelete, que no escriben archivo — de ahí que sean siete archivos, no diez). - Un snapshot no desaparece solo (módulo 3). Cada uno de los trece snapshots de esta lección sigue archivado, con su propio
snapshot_id, disponible para quien lo pida explícitamente — exactamente el mecanismo que hace posible recuperarsnap_v1diez escrituras después. table.overwrite()nunca compara, siempre reemplaza (módulo 6, lección 6, por contraste contable.upsert()). El Ejercicio 2 de esa lección ya demostró queupsert()con datos idénticos a los ya archivados produceUpsertResult(rows_updated=0, rows_inserted=0)— cero snapshots nuevos. El pipeline nocturno de esta lección usóoverwrite(), la herramienta equivocada para "reconfirmar sin saber si cambió algo": por diseño, no tiene forma de darse cuenta de que no hacía falta escribir nada.
La combinación de los tres explica el costo completo: archivos que nunca se modifican en el lugar, snapshots que nunca expiran solos, y una herramienta de escritura que nunca pregunta "¿hace falta esto?". Ninguno de los tres es, por sí solo, un error de diseño de Iceberg — son, al contrario, exactamente las garantías que hicieron posible el time travel del módulo 3. El costo es el precio de esa garantía cuando nadie la administra activamente.
Diagrama: trece fotos del mismo estante, dos de ellas importan
flowchart LR
S1["snap_v1\nappend, V1\nsnacks/0.60"] --> SD["delete\n(modulo 3)"]
SD --> S3["append, V2\nhealth-snacks/0.68"]
S3 --> N1["noche 1: delete+append\n(identico a S3)"]
N1 --> N2["noche 2: delete+append"]
N2 --> N3["noche 3: delete+append"]
N3 --> N4["noche 4: delete+append"]
N4 --> N5["noche 5: delete+append\n= snapshot VIGENTE"]
S1 -.->|"protegido -- time travel"| KEEP1["se necesita"]
N5 -.->|"vigente -- se necesita"| KEEP2["se necesita"]
SD -.->|"ruido"| WASTE["candidato a expirar"]
N1 -.->|"ruido"| WASTE
N2 -.->|"ruido"| WASTE
N3 -.->|"ruido"| WASTE
N4 -.->|"ruido"| WASTE
Errores comunes
Asumir que table.overwrite() con datos idénticos es un "no-op" porque el resultado de negocio no cambia. Qué pasa: alguien, familiarizado con sistemas donde "guardar lo mismo que ya había" no hace nada, asume que las cinco noches de esta lección no deberían haber costado nada. Por qué pasa: en muchas bases de datos relacionales tradicionales, un UPDATE que no cambia ningún valor puede, según el motor, no generar ningún registro de transacción nuevo. Cómo detectarlo: si tu conteo de snapshots después de un overwrite() "sin cambios" no es exactamente cero adicional, revisa qué operación usaste — la Profundización de esta lección ya lo explica: overwrite() nunca compara. Cómo corregirlo: si tu pipeline necesita "reconfirmar sin saber si cambió algo", usa table.upsert() (módulo 6) — es la herramienta diseñada exactamente para ese caso, con el costo cero que confirma el Ejercicio 2 de la lección 6 del módulo 6.
Confundir "el archivo sigue en disco" con "el archivo sigue siendo parte de la tabla vigente". Qué pasa: alguien ve all_data_files().num_rows == 7 y concluye que table.scan() va a leer los siete archivos en cada consulta, haciendo la tabla siete veces más lenta de lo esperado. Por qué pasa: es fácil no distinguir entre "todo archivo que algún snapshot archivado todavía rastrea" (all_data_files()) y "los archivos que el snapshot vigente necesita para responder una consulta normal" (files(), sin argumentos). Cómo detectarlo: compara los dos números, como hizo el Paso 3 de esta lección — si files() (vigente) es mucho menor que all_data_files() (histórico completo), tu tabla está sana en cuanto a rendimiento de lectura normal, aunque tenga trabajo de limpieza pendiente. Cómo corregirlo: el costo de los seis archivos redundantes no es de lectura — es de almacenamiento (bytes en disco que nadie necesita) y de metadata (manifests que crecen sin aportar). La lección 5 de este módulo resuelve exactamente ese costo, sin tocar el rendimiento de una consulta normal, que ya era correcto desde antes.
Ejercicios
Ejercicio 1 — Reproduce el experimento completo tú mismo, y confirma los tres números. En un directorio nuevo, corre los tres pasos de esta lección. Confirma 3 snapshots después de A+B, 13 después de las cinco noches, 7 archivos totales rastreados y 1 archivo vigente.
Ver solución
Si tu entorno tiene PyIceberg 0.11.1 instalado, tu salida debería coincidir exactamente con los cuatro números de esta lección. Los snapshot_id van a ser distintos de los mostrados aquí — eso es exactamente lo esperado, y es, precisamente, la razón por la que snap_v1 se captura en variable en el Paso 1 en vez de citarse como literal.
Ejercicio 2 — Calcula cuántos de los trece snapshots corresponden a operaciones delete y cuántos a append. Usa table.inspect.snapshots().select(["operation"]) sobre la tabla que dejó esta lección, y cuenta cada tipo.
Ver solución
ops = [row["operation"] for row in table.inspect.snapshots().select(["operation"]).to_pylist()]
from collections import Counter
print(Counter(ops))
Counter({'append': 7, 'delete': 6}). Un append inicial (V1, Paso 1) y seis overwrite() posteriores (el cambio real de P002 más las cinco noches redundantes), cada uno resuelto como delete + append — seis delete y seis append adicionales, más el primero: 7 append en total, 6 delete en total, suma 13. Cada delete no escribe ningún archivo nuevo —solo deja de referenciar el archivo del snapshot anterior—, razón por la que el conteo de archivos físicos (7) es menor que el conteo de operaciones append que sí escribieron algo.
Ejercicio 3 — Predicción: si el pipeline nocturno hubiera corrido durante un año completo (365 noches) en vez de cinco, ¿qué le pasaría al tiempo que tarda table.inspect.files() en responder? Piensa en la Profundización de la lección 7 del módulo 2, sobre el costo relativo de los métodos de inspección.
Ver solución
table.inspect.files() (sin argumentos, solo el snapshot vigente) seguiría respondiendo casi instantáneo — sigue siendo, siempre, un archivo, sin importar cuántas noches redundantes hayan pasado, porque el snapshot vigente nunca cambió de contenido. Lo que sí crecería, de forma proporcional al número de noches, es el tiempo de table.inspect.all_data_files() y table.inspect.snapshots() — cada uno tiene que recorrer una lista de manifests que crece con cada commit, aunque ninguno de esos commits haya agregado una sola fila de valor de negocio. La lección 7 del módulo 2 ya explicó esto: cada método de inspección paga el costo proporcional a lo que tiene que recorrer, no a lo que la tabla "realmente contiene" en términos de negocio — trescientas sesenta y cinco noches redundantes inflarían ese costo sin que ninguna consulta normal lo notara.
Resumen y siguiente paso
En esta lección mediste, con evidencia ejecutada, exactamente cómo se acumula el costo en una tabla Iceberg: cinco noches de un pipeline que reconfirma sin verificar convirtieron 3 snapshots en 13, y dejaron seis archivos de datos redundantes junto al único que la tabla vigente necesita. Identificaste los tres mecanismos que lo explican —archivos inmutables, snapshots que no expiran solos, overwrite() que nunca compara— y confirmaste que, a pesar de todo ese ruido, el time travel de snap_v1 siguió funcionando sin ninguna degradación.
Antes de avanzar deberías poder: explicar por qué all_data_files() y files() (vigente) pueden diferir tanto; y calcular, para cualquier secuencia de escrituras, cuántos snapshots va a producir un overwrite() sin filtro.
La lección 4 cambia de eje: en vez de snapshots viejos que ya nadie necesita, mira el problema de los archivos pequeños — incluso dentro de un único snapshot vigente, muchas escrituras chicas pueden fragmentar la tabla en más archivos de los que hacen falta.
Recursos
- Apache Iceberg — documentación oficial, "Maintenance", sección "Expire Snapshots": "Snapshots accumulate until they are expired", la frase que resume esta lección completa. iceberg.apache.org/docs/latest/maintenance. En inglés.
- PyIceberg — referencia de API,
table.inspect.all_data_files()vs.table.inspect.files(), la distinción central del Paso 3 de esta lección. py.iceberg.apache.org/api. En inglés. - Esta misma guía, módulo 6, lección 6 — fuente del Ejercicio 2 que demuestra que
table.upsert()con datos idénticos produce cero snapshots nuevos, el contraste central de la Profundización de esta lección.06-pyicebergs-upsert-the-python-native-alternative.md. En español. - DISEÑO de esta guía — la sección del módulo 7, "por qué los snapshots acumulan costo con el tiempo".
src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.