Módulo 7: Catalogs Maintenance And Delta Lake By Contrast

Expirando snapshots viejos, con seguridad

Descripción

Esta es la lección donde kiosko.dim_product finalmente se poda. Partiendo de los trece snapshots que dejó la lección 3 —tres del módulo 3, diez de cinco noches redundantes—, vas a correr table.maintenance.expire_snapshots() de verdad, sin ninguna reserva: PyIceberg 0.11.1 puro Python, sin Spark, sin JVM. Vas a proteger explícitamente snap_v1 —el único snapshot que hace posible recuperar el estado original de P002— y vas a confirmar, con assert, que el time travel sigue funcionando exactamente igual después de la poda. Y vas a descubrir algo que la documentación general de Iceberg no aclara para esta versión específica: expire_snapshots() en PyIceberg 0.11.1 no borra ningún archivo físico — solo la metadata.

Conexión con el módulo. Esta es la única operación de mantenimiento de este módulo que corre de verdad, verificada contra el código fuente instalado de PyIceberg 0.11.1. Las lecciones 4 y 6 documentan operaciones representativas; esta ejecuta.

Una analogía: podar el álbum, con la foto de la abuela marcada de antemano

La lección 1 de este módulo prometió una regla de poda con dos partes: tirar los duplicados, nunca arrancar la página que la abuela quiere ver. Esta lección aplica esa regla de forma literal, no metafórica. Antes de tocar una sola página del álbum de kiosko.dim_product, vas a hacer una lista explícita de qué se poda y qué se protege — nunca vas a confiar en que el sistema "adivine" cuál es la foto importante. Esa lista, en código, es exactamente lo que ya conoces desde el módulo 3: snap_v1, capturado en variable, nunca hardcodeado.

Ejemplo trabajado: expire_snapshots(), con snap_v1 protegido

Paso 1 — Parte del estado de la lección 3: trece snapshots, snap_v1 capturado

Este script asume que ya corriste el de la lección 3 en el mismo directorio de trabajo —kiosko.dim_product con trece snapshots archivados—. Si necesitas el snap_v1 de esa corrida, vuelve a capturarlo desde table.history(): es, siempre, el primero de la lista.

# expire_dim_product.py -- poda segura de kiosko.dim_product
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()
snap_v1 = history[0].snapshot_id                    # el primero -- V1, antes del cambio de P002
current_snapshot_id = table.current_snapshot().snapshot_id  # el ultimo -- el vigente hoy

print(f"ANTES: {len(history)} snapshots")
print("snap_v1 (proteger, necesario para time travel):", snap_v1)
print("vigente (proteger, es el estado actual):", current_snapshot_id)

Qué esperar (verificado corriendo el script real, sobre el estado que dejó la lección 3; los snapshot_id son de tu propia corrida, distintos cada vez):

ANTES: 13 snapshots
snap_v1 (proteger, necesario para time travel): <snapshot-id asignado en tu corrida>
vigente (proteger, es el estado actual): <snapshot-id asignado en tu corrida>

Paso 2 — Construye la lista de poda: todo, salvo lo protegido

all_ids_in_order = [entry.snapshot_id for entry in history]
to_expire = [sid for sid in all_ids_in_order if sid != snap_v1 and sid != current_snapshot_id]

print(f"\nsnapshots seleccionados para expirar: {len(to_expire)} de {len(all_ids_in_order)}")

Qué esperar:

snapshots seleccionados para expirar: 11 de 13

Once de trece: los diez que produjeron las cinco noches redundantes, más el snapshot intermedio delete que el overwrite() del cambio real de P002 ya había dejado atrás desde el módulo 3. Ninguno de los once aporta nada que snap_v1 (el "antes") o el vigente (el "ahora") no cubran ya — exactamente el criterio de poda de esta lección: se necesitan los dos extremos de la historia de negocio, no cada parada intermedia.

Paso 3 — Corre expire_snapshots() de verdad

table.maintenance.expire_snapshots().by_ids(to_expire).commit()
table.refresh()

history_after = table.history()
print(f"\nDESPUES: {len(history_after)} snapshots")
for row in table.inspect.snapshots().select(["snapshot_id", "operation"]).to_pylist():
    print(" ", row["operation"], row["snapshot_id"])

Qué esperar (verificado corriendo el script real):

DESPUES: 2 snapshots
  append <snap_v1>
  append <vigente>

De trece a dos. table.maintenance.expire_snapshots() —el builder que devuelve table.maintenance— acepta la lista completa con .by_ids(...), y .commit() aplica el cambio en una sola transacción, con la misma actualización atómica condicional del catálogo que ya conoces desde la lección 2. Fíjate en un detalle que confirma que la poda fue quirúrgica: los dos snapshots que sobreviven son, los dos, de tipo append — el delete intermedio del cambio real de P002 desapareció junto con los diez de las noches redundantes, porque ninguno de los dos snapshots protegidos lo necesita para reconstruirse a sí mismo.

Paso 4 — Verifica que el time travel sigue intacto

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("\nAS OF snap_v1 (post-poda) P002:", p002_v1["category"], p002_v1["unit_cost"])

current_rows = table.scan().to_arrow().to_pylist()
p002_current = next(r for r in current_rows if r["product_id"] == "P002")
print("vigente (post-poda) P002:", p002_current["category"], p002_current["unit_cost"])

assert len(history_after) == 2
assert p002_v1["category"] == "snacks" and p002_v1["unit_cost"] == 0.60
assert p002_current["category"] == "health-snacks" and p002_current["unit_cost"] == 0.68
print("\nasserts OK -- time travel intacto, estado vigente intacto")

Qué esperar (verificado corriendo el script real):

AS OF snap_v1 (post-poda) P002: snacks 0.6
vigente (post-poda) P002: health-snacks 0.68

asserts OK -- time travel intacto, estado vigente intacto

Esta es la promesa central de esta lección, cumplida con evidencia: podaste once snapshots de trece —un 85% del historial archivado—, y el time travel hacia snap_v1 sigue devolviendo, byte por byte, el mismo resultado que devolvía antes de la poda. Nada de lo que el negocio todavía necesita se perdió.

Descubrimiento: expire_snapshots() de PyIceberg no borra archivos — solo metadata

La documentación general de Apache Iceberg, en su página de "Maintenance", describe la expiración de snapshots así: "Data files are not deleted until they are no longer referenced by a snapshot that may be used for time travel or rollback. Regularly expiring snapshots deletes unused data files." Esa frase es cierta para la implementación de Java y para la acción de Spark. No es cierta, verificado leyendo su propio código fuente, para table.maintenance.expire_snapshots() de PyIceberg 0.11.1.

Compruébalo tú mismo, contando archivos físicos en disco antes y después de la poda:

import os

data_dir = os.path.join(warehouse_path, "kiosko", "dim_product", "data")
on_disk = [f for f in os.listdir(data_dir) if f.endswith(".parquet")]
print("\narchivos .parquet fisicos en disco, DESPUES de expire_snapshots():", len(on_disk))

tracked = table.inspect.all_data_files().num_rows
print("archivos rastreados por algun snapshot vigente:", tracked)

Qué esperar (verificado corriendo el script real, en el mismo directorio de la lección 3, que tenía siete archivos físicos antes de esta lección):

archivos .parquet fisicos en disco, DESPUES de expire_snapshots(): 7
archivos rastreados por algun snapshot vigente: 2

Siete archivos siguen en disco. Dos están rastreados. La metadata que expire_snapshots() reescribió ya no menciona a los otros cinco — pero nadie los borró del filesystem. Esto no es un error de esta guía ni un bug de PyIceberg: es exactamente lo que hace su implementación, verificado leyendo el código fuente de ExpireSnapshots._commit() (en pyiceberg/table/update/snapshot.py): construye un único RemoveSnapshotsUpdate —una instrucción que le dice a la metadata "deja de listar estos snapshot_id"— y lo aplica sobre el modelo de metadata en memoria. En ningún punto de esa clase, ni de la función que aplica la actualización (pyiceberg/table/update/__init__.py), hay una sola llamada a borrar un archivo. expire_snapshots() en esta versión es, con precisión, una operación de metadata pura: rápida, segura, reversible en el sentido de que nunca destruye bytes — pero no libera un solo byte de almacenamiento por sí sola.

Una alternativa real, verificada por separado: older_than(dt)

.by_ids([...]) fue la técnica principal de esta lección, alineada con la disciplina de esta guía de nunca depender de datetime.now(). Pero table.maintenance.expire_snapshots() también ofrece .older_than(dt) — expira todo snapshot no protegido con timestamp anterior a un valor dado —, verificado en una demo aislada, con un timestamp capturado de un snapshot real, nunca del reloj del sistema:

# demo aislada -- NO toca kiosko.dim_product, solo confirma que older_than() funciona
snaps = table_demo.inspect.snapshots().select(["snapshot_id", "committed_at"]).to_pylist()
threshold = snaps[1]["committed_at"]      # committed_at capturado del 2o snapshot, no datetime.now()
table_demo.maintenance.expire_snapshots().older_than(threshold).commit()

Qué esperar (verificado corriendo esta demo real, en una tabla aislada de 3 snapshots): el snapshot con committed_at estrictamente anterior al umbral capturado se expira; los otros dos —incluido el que definió el umbral— sobreviven, porque older_than compara con <, estricto. table.history() pasa de 3 a 2 entradas. Esta forma es la que más se parece a un plan de mantenimiento recurrente en producción —"expira todo lo anterior a hace N días"—, pero necesita, en producción real, un datetime calculado a partir del reloj del sistema en el momento de correr el mantenimiento — algo perfectamente razonable para un job de mantenimiento programado (no para generar datos de negocio de Kiosko), y por eso esta guía la demuestra por separado, sin mezclarla con el flujo principal de kiosko.dim_product.

Y si intentas expirar el snapshot vigente, a propósito

try:
    table.maintenance.expire_snapshots().by_id(current_snapshot_id).commit()
except Exception as e:
    print(f"{type(e).__name__}: {e}")

Qué esperar (verificado corriendo el script real):

ValueError: Snapshot with ID <id> is protected and cannot be expired.

PyIceberg protege, siempre, al snapshot vigente (el HEAD de la referencia principal de la tabla) y a cualquier snapshot marcado como tag o branch — el método _get_protected_snapshot_ids(), visible en el mismo código fuente citado arriba, calcula esa lista antes de aplicar cualquier expiración, y by_id() la consulta explícitamente antes de aceptar tu pedido. No hace falta que tú mismo excluyas el snapshot vigente de tu lista por seguridad extra — pero esta lección lo hizo de todas formas, en el Paso 2, porque una poda explícita y verificada es siempre preferible a depender de una protección implícita del sistema.

Diagrama: de trece a dos, con los dos extremos intactos

flowchart TB
    subgraph antes["ANTES -- 13 snapshots"]
        A1["snap_v1\nprotegido"] --> A2["...11 candidatos a podar..."] --> A3["vigente\nprotegido"]
    end
    antes -->|"expire_snapshots().by_ids(11 ids)\n.commit()"| despues
    subgraph despues["DESPUES -- 2 snapshots"]
        B1["snap_v1\nsnacks/0.60"] -.->|"metadata reescrita,\nsin conexion directa"| B2["vigente\nhealth-snacks/0.68"]
    end

Errores comunes

Asumir que expire_snapshots() liberó espacio en disco de inmediato. Qué pasa: alguien corre expire_snapshots(), ve que table.history() bajó de trece a dos, y reporta que "la tabla ahora pesa mucho menos" sin haber medido el tamaño real en disco. Por qué pasa: el nombre de la operación —"expirar", "podar"— sugiere una limpieza completa, y la documentación general del proyecto Iceberg (escrita pensando en Java/Spark) refuerza esa expectativa. Cómo detectarlo: mide el tamaño real del directorio data/ antes y después, como hizo esta lección — si no cambió, tu cliente no está borrando archivos físicamente. Cómo corregirlo: en PyIceberg 0.11.1, trata expire_snapshots() como lo que es —una operación de metadata que hace que los archivos dejen de estar rastreados, no que se borren—, y sigue con la lección 6 (remove_orphan_files, representativo en este entorno) para completar la limpieza física.

Elegir qué snapshots proteger "a ojo", en vez de construir la lista explícitamente desde table.history(). Qué pasa: alguien, con una tabla real de cientos de snapshots, intenta recordar de memoria cuáles son "los importantes" y arma la lista de protección a mano, arriesgándose a olvidar uno. Por qué pasa: con pocos snapshots —como los trece de esta lección— parece manejable hacerlo de memoria; con cientos, ya no lo es, y el hábito descuidado persiste. Cómo detectarlo: si tu criterio de protección no se puede expresar como código que cualquiera pueda auditar y re-ejecutar, corres el riesgo de expirar, por accidente, un snapshot que sí hacía falta. Cómo corregirlo: expresa siempre el criterio de protección como código explícito sobre table.history() o table.inspect.snapshots() —como hizo el Paso 2 de esta lección con una comprensión de lista simple—, nunca como una lista de IDs copiada a mano desde una consulta anterior.

Ejercicios

Ejercicio 1 — Reproduce el experimento completo tú mismo, y confirma los cuatro números. Con el estado de la lección 3 disponible, corre los cuatro pasos de esta lección. Confirma 13 antes, 11 seleccionados, 2 después, y que los dos assert finales del Paso 4 pasan.

Ver solución

Si tu tabla partió del estado exacto de la lección 3, tu salida debería coincidir en los cuatro números con esta lección. Los snapshot_id van a ser distintos de los mostrados aquí — eso es exactamente lo esperado, y es la razón por la que ni snap_v1 ni to_expire se hardcodean en ningún punto de este script.

Ejercicio 2 — Intenta expirar un snapshot_id que no existe, y observa el error. Llama a table.maintenance.expire_snapshots().by_id(999999999999999999).commit() sobre la tabla que dejó esta lección.

Ver solución
try:
    table.maintenance.expire_snapshots().by_id(999999999999999999).commit()
except Exception as e:
    print(f"{type(e).__name__}: {e}")

Salida esperada: ValueError: Snapshot with ID 999999999999999999 does not exist. — verificado directamente en el código fuente de ExpireSnapshots.by_id(): antes de aceptar cualquier ID, el método consulta self._transaction.table_metadata.snapshot_by_id(snapshot_id), y si el resultado es None, lanza la excepción de inmediato, antes de siquiera intentar el commit. Este comportamiento —fallar rápido, con un mensaje explícito, antes de tocar la metadata— es el mismo principio de seguridad que ya viste con el snapshot protegido: PyIceberg prefiere rechazar un pedido ambiguo o inválido antes que aplicar un cambio parcial o silencioso.

Ejercicio 3 — Explica, en tus propias palabras, por qué esta lección construye to_expire como "todo menos lo protegido" en vez de "una lista fija de IDs que sé que quiero borrar". Piensa en qué pasaría si, entre la lección 3 y esta lección, alguien hubiera agregado una sexta noche redundante sin que tú lo supieras.

Ver solución

Construir to_expire como [sid for sid in all_ids_in_order if sid not in {snap_v1, current_snapshot_id}] es una lista derivada del estado real de la tabla en el momento de correr el script — si alguien hubiera agregado una sexta noche redundante antes de que corrieras esta lección, to_expire la habría incluido automáticamente, sin que tuvieras que actualizar ninguna lista a mano. Una lista fija de IDs, copiada de una corrida anterior, se volvería obsoleta o incompleta en cuanto el estado real de la tabla cambiara — y peor, si alguno de esos IDs ya no existiera (por ejemplo, si ya se hubiera expirado antes), el by_id() fallaría con el error de "no existe" del Ejercicio 2. El patrón de "protege esto, poda todo lo demás" es más robusto porque se adapta al estado real de la tabla en el momento exacto en que se ejecuta, en vez de congelar una decisión tomada en un momento distinto.

Resumen y siguiente paso

En esta lección corriste table.maintenance.expire_snapshots() de verdad: de trece snapshots a dos, con snap_v1 explícitamente protegido y el time travel verificado, con assert, intacto después de la poda. Descubriste, con evidencia del propio código fuente y una medición directa del filesystem, que esta operación en PyIceberg 0.11.1 reescribe la metadata pero no borra archivos físicos — siete archivos siguen en disco, aunque solo dos estén rastreados. Verificaste también la protección automática del snapshot vigente, y una alternativa real (older_than) para cuando el criterio de poda es "todo lo anterior a una fecha", no una lista explícita.

Antes de avanzar deberías poder: construir una lista de snapshots a expirar a partir de table.history(), protegiendo explícitamente lo que el negocio todavía necesita; y explicar por qué expire_snapshots() de PyIceberg no libera espacio en disco por sí solo.

La lección 6 completa la limpieza que esta lección dejó pendiente: los cinco archivos huérfanos, todavía en disco, que ningún snapshot vigente rastrea ya.

Recursos

  • PyIceberg — referencia de API, table.maintenance y ExpireSnapshots (by_id, by_ids, older_than, commit), la base completa de esta lección. py.iceberg.apache.org/api. En inglés.
  • Apache Iceberg — documentación oficial, "Maintenance", sección "Expire Snapshots", fuente de la cita sobre el borrado de archivos que esta lección contrasta con el comportamiento real verificado de PyIceberg 0.11.1. iceberg.apache.org/docs/latest/maintenance. En inglés.
  • PyIceberg — código fuente, pyiceberg/table/update/snapshot.py (clase ExpireSnapshots) y pyiceberg/table/update/__init__.py (aplicación de RemoveSnapshotsUpdate), la evidencia directa de que esta versión no borra archivos físicos. Instalado localmente con pip install "pyiceberg[sql-sqlite,pyarrow]". En inglés.
  • Esta misma guía, módulo 3, lección 4 — fuente de la disciplina de nunca hardcodear un snapshot_id, aplicada en esta lección a la lista de poda. 04-capturing-the-snapshot-id-never-hardcoding-it.md. En español.
  • DISEÑO de esta guía — la sección del módulo 7, "expirando snapshots viejos de forma segura". src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.