Módulo 7: Catalogs Maintenance And Delta Lake By Contrast
Eliminando archivos huérfanos
Descripción
La lección 5 dejó un problema explícito, sin resolver a propósito: siete archivos Parquet físicos en disco, pero solo dos rastreados por algún snapshot vigente de kiosko.dim_product. Esta lección le pone nombre exacto a esos cinco archivos —huérfanos— y los identifica con código real, contando bytes exactos. Y documenta, con la misma honestidad que la lección 4 (compactación) del resto de este módulo, por qué la operación que de verdad los borraría —remove_orphan_files— no corre en PyIceberg 0.11.1 puro Python.
Conexión con el módulo. Esta lección es la continuación directa de la lección 5, no un tema nuevo. expire_snapshots() reescribió la metadata; esta lección mide, con precisión, qué quedó atrás en el filesystem por esa reescritura.
Qué es exactamente un archivo huérfano
Un archivo es huérfano cuando cumple dos condiciones a la vez: existe físicamente en el warehouse de la tabla, y ningún snapshot que la metadata vigente todavía rastrea lo referencia desde ningún manifest file. No es lo mismo que "un archivo viejo" —un archivo puede ser viejo y seguir perfectamente vivo, si algún snapshot no expirado todavía lo necesita—, y no es lo mismo que "un archivo corrupto" —un huérfano suele ser un Parquet perfectamente válido, solo que ya nadie sabe que existe—. La causa más común, la que produjo los cinco archivos de esta lección: expirar los snapshots que los referenciaban, sin que la operación de expiración también se encargara de borrarlos del disco — exactamente lo que confirmó la lección 5 sobre PyIceberg 0.11.1.
Una analogía: las copias del brindis, todavía en el cajón del laboratorio de revelado
Volviendo al álbum de bodas: expirar los snapshots redundantes de la lección 5 es como tachar, en el índice del álbum, las diecinueve referencias a las copias duplicadas del brindis — el índice ahora dice, con toda claridad, que solo existe una foto de ese momento. Pero las diecinueve copias físicas siguen exactamente donde el laboratorio de revelado las dejó: en un cajón, sin ningún número de índice que las señale. Nadie las va a encontrar buscando en el álbum —el índice ya no las menciona—, pero siguen ahí, ocupando espacio, hasta que alguien revise el cajón directamente y las deseche.
Ejemplo trabajado: contando los huérfanos con evidencia real
Paso 1 — Compara lo rastreado contra lo físico
# find_orphans.py -- identifica archivos huerfanos de kiosko.dim_product (SOLO LECTURA)
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")
tracked = {row["file_path"] for row in table.inspect.all_data_files().select(["file_path"]).to_pylist()}
print("archivos rastreados por algun snapshot vigente:", len(tracked))
data_dir = os.path.join(warehouse_path, "kiosko", "dim_product", "data")
on_disk = {"file://" + os.path.join(data_dir, fn) for fn in os.listdir(data_dir) if fn.endswith(".parquet")}
print("archivos .parquet fisicos en disco:", len(on_disk))
orphans = on_disk - tracked
print("\narchivos huerfanos (en disco, cero snapshots vigentes los referencian):", len(orphans))
total_bytes = 0
for f in sorted(orphans):
local_path = f.replace("file://", "")
size = os.path.getsize(local_path)
total_bytes += size
print(" ", f.split("/")[-1], f"{size} bytes")
print(f"\ntotal de bytes huerfanos: {total_bytes}")
Qué esperar (verificado corriendo el script real, sobre el estado que dejó la lección 5; los nombres de archivo son de tu propia corrida, distintos cada vez — la cantidad y el tamaño total son deterministas):
archivos rastreados por algun snapshot vigente: 2
archivos .parquet fisicos en disco: 7
archivos huerfanos (en disco, cero snapshots vigentes los referencian): 5
<archivo-1>.parquet 1837 bytes
<archivo-2>.parquet 1837 bytes
<archivo-3>.parquet 1837 bytes
<archivo-4>.parquet 1837 bytes
<archivo-5>.parquet 1837 bytes
total de bytes huerfanos: 9185
Cinco archivos, 9.185 bytes en total —menos de 9 KB, en esta tabla de laboratorio con cuatro filas—, pero el mecanismo es idéntico, sea cual sea la escala: cada snapshot que expiraste en la lección 5 dejó atrás, exactamente, el archivo de datos que había escrito en su momento, y ninguno de esos cinco archivos fue tocado por la operación de expiración.
Paso 2 — Por qué este script es diagnóstico, nunca una herramienta de borrado
Fíjate en algo deliberado: el Paso 1 no borra nada. Calcula, compara, reporta — la misma disciplina de solo lectura que esta guía aplicó en el módulo 2 al explorar el warehouse/ en disco. Borrar un archivo "que parece huérfano" con os.remove(), a mano, fuera de una operación oficial de mantenimiento, es peligroso por una razón concreta: la comparación de este script se hizo contra la metadata en el instante exacto en que corrió — si otro proceso está, en ese mismo momento, escribiendo un snapshot nuevo que todavía no confirmó su commit, sus archivos temporales podrían aparecer, transitoriamente, como "no rastreados todavía" sin ser huérfanos de verdad. Un archivo verdaderamente huérfano necesita confirmarse contra toda la metadata vigente, incluida la de cualquier escritura en curso — exactamente el trabajo que hace la operación oficial remove_orphan_files, y exactamente la razón por la que existe como una operación de motor, con una ventana de retención configurable, en vez de como un simple os.remove().
La operación real: remove_orphan_files — no disponible en PyIceberg 0.11.1 puro Python
Se verificó, igual que en la lección 4, contra el código fuente instalado de PyIceberg 0.11.1 y contra su referencia de API oficial: no existe ningún método remove_orphan_files en Table ni en table.maintenance. Es, como la compactación, una operación de motor distribuido — en el ecosistema Iceberg de 2026, corre sobre Spark, a través del procedimiento SQL remove_orphan_files. El siguiente bloque está marcado como representativo:
-- (representativo) -- sintaxis verificada contra la documentacion oficial de Iceberg,
-- NO ejecutada en este entorno: PyIceberg 0.11.1 no implementa remove_orphan_files.
-- primero, SIEMPRE: un dry run -- lista los candidatos, no borra nada todavia
CALL local.system.remove_orphan_files(table => 'kiosko.dim_product', dry_run => true);
-- despues de revisar la lista, la version que si borra
CALL local.system.remove_orphan_files(table => 'kiosko.dim_product');
Qué esperar (representativo): el dry_run => true devolvería una fila por cada archivo candidato a huérfano —los mismos cinco que identificó el Paso 1 de esta lección, en un warehouse real— sin borrar ni un byte todavía. Corrido sin dry_run, el procedimiento borra físicamente los archivos confirmados como huérfanos, y devuelve un reporte (orphan_file_location, una fila por archivo eliminado) — el mismo patrón de verificación explícita que ya viste en rewrite_data_files (lección 4): nunca confiar en que la operación hizo lo esperado sin revisar su reporte de salida.
La ventana de retención: por qué "más rápido" no es "más seguro"
La documentación oficial de Iceberg incluye una advertencia explícita sobre esta operación, con una frase que vale la pena citar completa: "It is dangerous to remove orphan files with a retention interval shorter than the time expected for any write to complete because it might corrupt the table if in-progress files are considered orphaned and are deleted. The default interval is 3 days." Esto conecta directamente con lo que el Paso 2 de esta lección ya advirtió con código: un archivo que parece huérfano en el instante de la comparación podría ser, en realidad, un archivo que otra escritura todavía está construyendo, sin haber confirmado su commit todavía. La ventana de retención por defecto —tres días— existe exactamente para cubrir ese margen: ninguna escritura razonable de Iceberg debería tardar más de tres días en completar su commit, así que cualquier archivo sin referencia que además tenga más de tres días de antigüedad es, con altísima confianza, huérfano de verdad y no una escritura en curso.
Esta es la razón concreta por la que remove_orphan_files es una operación de motor y no un script casero: necesita cruzar, con exactitud, la lista completa de archivos físicos contra la metadata de todos los commits en curso en el sistema, respetando una ventana de tiempo de seguridad — trabajo que un simple os.walk() + comparación de conjuntos, como el Paso 1 de esta lección, deliberadamente no intenta resolver de forma segura para producción.
Diagrama: de la metadata reescrita a los bytes finalmente liberados
flowchart LR
L5["Leccion 5:\nexpire_snapshots()\n13 -> 2 snapshots"] --> M["Metadata: 2 archivos rastreados\nDisco: 7 archivos fisicos"]
M --> P1["Paso 1 de esta leccion:\nos.walk() vs all_data_files()\n5 huerfanos, 9185 bytes"]
P1 -.->|"(representativo) -- Spark"| RM["remove_orphan_files\ndry_run primero,\nretention >= 3 dias"]
RM -.-> F["Disco final: 2 archivos\n(nunca ejecutado en este entorno)"]
Errores comunes
Borrar "a mano" un archivo que el Paso 1 de esta lección marcó como huérfano. Qué pasa: alguien, con la lista de cinco archivos huérfanos ya identificada, corre os.remove() directamente sobre cada uno, pensando que ya hizo el trabajo equivalente a remove_orphan_files. Por qué pasa: el resultado final —los archivos desaparecen— parece idéntico. Cómo detectarlo: si tu "limpieza" se basó en una comparación tomada en un único instante, sin ninguna ventana de retención ni verificación contra escrituras en curso, corriste el riesgo exacto que la advertencia oficial describe — borrar un archivo que otra escritura todavía necesitaba. Cómo corregirlo: usa el Paso 1 de esta lección únicamente para diagnosticar y entender el problema —exactamente su propósito declarado—, nunca como reemplazo de la operación oficial. En un entorno de producción real, remove_orphan_files (Spark) es la única forma segura de completar esta limpieza, con su ventana de retención de tres días por defecto respetada.
Pensar que remove_orphan_files es la misma operación que expire_snapshots, solo con otro nombre. Qué pasa: alguien, después de la lección 5, asume que correr expire_snapshots() de nuevo, o correrlo con parámetros distintos, terminaría de limpiar los archivos físicos. Por qué pasa: ambas operaciones tienen "limpieza" como objetivo declarado, así que es fácil asumir que son intercambiables. Cómo detectarlo: revisa qué rastrea cada una — expire_snapshots opera sobre la lista de snapshots en la metadata; remove_orphan_files opera sobre la lista de archivos físicos en el filesystem, comparada contra la metadata vigente. Cómo corregirlo: son dos pasos de un mismo flujo de mantenimiento, en orden: primero expire_snapshots (lección 5, real en PyIceberg) deja de referenciar lo que ya no hace falta; después remove_orphan_files (esta lección, representativo en este entorno) borra, con seguridad, lo que quedó sin referencia — nunca al revés, porque remove_orphan_files sin haber expirado antes no encontraría ningún candidato: todo seguiría estando "rastreado".
Ejercicios
Ejercicio 1 — Reproduce el Paso 1 de esta lección tú mismo, y confirma los dos números. Con el estado de la lección 5 disponible, corre el script de diagnóstico. Confirma 5 archivos huérfanos y 9185 bytes totales.
Ver solución
Si tu tabla partió del estado exacto de la lección 5, tu salida debería coincidir en ambos números —5 archivos, 9185 bytes— con esta lección. Los nombres de archivo van a ser distintos (cada uno incluye un UUID generado en su momento de escritura), pero el tamaño de cada uno (1837 bytes) debería coincidir, porque las cuatro filas de dim_product con este esquema producen, siempre, el mismo tamaño de archivo Parquet.
Ejercicio 2 — Calcula qué fracción del total de bytes que alguna vez escribió esta tabla sigue siendo huérfana. Suma el tamaño de los dos archivos rastreados (usa table.inspect.all_data_files().select(["file_size_in_bytes"])) y compáralo contra los 9185 bytes huérfanos.
Ver solución
Los dos archivos rastreados —el de snap_v1 (V1, cuatro filas) y el vigente (V2, cuatro filas)— pesan, cada uno, aproximadamente lo mismo que cada uno de los cinco huérfanos (mismo esquema, mismo número de filas): alrededor de 1837 bytes cada uno, 3674 bytes en total rastreado. Contra los 9185 bytes huérfanos, eso significa que, en este momento, más del 70% de todos los bytes que esta tabla alguna vez escribió corresponden a archivos que ya nadie necesita — un número que, en una tabla de producción con miles de escrituras redundantes en vez de cinco, se traduce directamente en costo de almacenamiento real, el tema que profundiza cost-optimization-caching-guide.
Ejercicio 3 — Explica, en tus propias palabras, por qué la ventana de retención por defecto de remove_orphan_files es de tres días y no de tres segundos. Piensa en cuánto puede tardar, en el peor caso, una escritura distribuida real en confirmar su commit.
Ver solución
Una escritura de Iceberg sobre una tabla de producción real —a diferencia de los scripts de esta guía, que corren en menos de un segundo— puede involucrar un trabajo distribuido de Spark procesando terabytes de datos, con múltiples tareas escribiendo archivos Parquet en paralelo durante minutos u horas, antes de que el commit final se confirme contra el catálogo. Durante todo ese tiempo, los archivos que esa escritura va generando existen físicamente en el filesystem, pero todavía no están referenciados por ningún snapshot —el snapshot que los va a referenciar recién se crea al final, cuando el commit se confirma—. Si la ventana de retención de remove_orphan_files fuera de tres segundos, correr esa operación mientras una escritura larga está en curso borraría archivos que esa escritura todavía necesita, corrompiendo el resultado final. Tres días es un margen deliberadamente generoso, pensado para cubrir incluso trabajos de escritura excepcionalmente largos — el costo de ser demasiado conservador (archivos huérfanos que tardan un poco más en limpiarse) es mucho menor que el costo de ser demasiado agresivo (una tabla corrupta).
Resumen y siguiente paso
En esta lección identificaste, con código real y sin ambigüedad, los cinco archivos huérfanos que dejó la expiración de snapshots de la lección 5 —9185 bytes, verificados comparando el filesystem contra table.inspect.all_data_files()—. Confirmaste que remove_orphan_files, igual que la compactación de la lección 4, no está disponible en PyIceberg 0.11.1 puro Python, y documentaste su sintaxis representativa contra la documentación oficial de Iceberg, incluida la advertencia real sobre la ventana de retención de tres días y por qué borrar "a mano" es peligroso.
Antes de avanzar deberías poder: explicar la diferencia entre expire_snapshots (metadata) y remove_orphan_files (filesystem); y justificar por qué un os.remove() manual nunca reemplaza, con seguridad, a la operación oficial.
La lección 7 cierra el círculo de mantenimiento de este módulo con la única mención de Delta Lake de toda esta guía: mismo problema —snapshots que acumulan costo, archivos que hay que compactar y limpiar—, mecanismo de metadata completamente distinto.
Recursos
- Apache Iceberg — documentación oficial, "Maintenance", sección "Delete orphan files", fuente de la advertencia textual sobre la ventana de retención de tres días. iceberg.apache.org/docs/latest/maintenance. En inglés.
- Apache Iceberg — documentación oficial, "Spark Procedures", sección
remove_orphan_files, fuente exacta de la sintaxisCALLrepresentativa de esta lección, incluidodry_run. iceberg.apache.org/docs/latest/spark-procedures. En inglés. - PyIceberg — referencia de API, la sección
table.maintenance, confirmando otra vez que soloexpire_snapshotsestá documentado en esta versión. py.iceberg.apache.org/api. En inglés. - Esta misma guía, módulo 2, lección 6 — fuente de la disciplina de explorar el
warehouse/en disco de forma puramente diagnóstica, sin modificar nada.06-inspecting-kioskos-table-on-disk.md. En español. - DISEÑO de esta guía — la sección del módulo 7, "eliminando archivos huérfanos sin perder el time travel que sí se necesita".
src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.