Módulo 5: Hidden Partitioning And Partition Evolution
Evolucionando el `PartitionSpec` sin reescribir
Descripción
kiosko.fact_orders_at_scale quedó particionada por store_id, con diez millones de filas repartidas en tres archivos. Pero Kiosko también necesita, con frecuencia, consultas por rango de fecha —"el revenue de esta semana", "las órdenes de ayer"—, y hoy order_ts no forma parte del PartitionSpec en absoluto: cualquier filtro por fecha tiene que abrir los tres archivos completos, exactamente como viste en el ejercicio 3 de la lección anterior con product_id. Esta lección agrega esa segunda dimensión —DayTransform sobre order_ts— con una sola línea de código, y confirma, con evidencia, algo que podría sonar imposible: las diez millones de filas que ya existen no se tocan.
Conexión con el módulo. Esta lección hace, con partición, exactamente lo que el módulo 4 completo ya hizo con esquema: update_schema().add_column() nunca reescribió un archivo Parquet existente; update_spec().add_field(), el protagonista de esta lección, tampoco. Es el mismo mecanismo de fondo —Iceberg separa la definición de la tabla (metadata) de sus datos (Parquet)—, aplicado esta vez a cómo se organizan los archivos, no a qué columnas tienen.
Una analogía: el cartero agrega un segundo criterio de ordenamiento
Retomando al cartero: hasta ahora organiza sus sacos solo por destinatario. Esta lección es el momento en que decide agregar un segundo criterio —dentro de cada saco de destinatario, también separar por día—. Y fíjate en lo que el cartero no hace: no vacía los sacos ya llenos para reorganizarlos con el criterio nuevo. Esa correspondencia ya archivada se queda exactamente donde está, organizada según la regla vieja. El criterio nuevo aplica, únicamente, a la correspondencia que llegue de aquí en adelante. Eso es, con exactitud, lo que vas a comprobar en esta lección con código real.
Ejemplo trabajado: update_spec(), con evidencia de que nada se reescribe
Paso 1 — El estado antes de evolucionar
# l6_evolve_spec.py
import os
from pyiceberg.catalog import load_catalog
from pyiceberg.transforms import DayTransform
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.fact_orders_at_scale")
files_before = sorted(f["file_path"] for f in table.inspect.files().to_pylist())
snapshots_before = len(table.history())
print(f"antes de evolucionar: {len(files_before)} archivo(s) de datos, {snapshots_before} snapshot(s)")
print("spec vigente antes de evolucionar:")
print(table.spec())
Qué esperar (verificado corriendo el script real, continuando sobre la misma tabla de la lección 5):
antes de evolucionar: 3 archivo(s) de datos, 1 snapshot(s)
spec vigente antes de evolucionar:
[
1000: store_id: identity(3)
]
Tres archivos, un solo snapshot —el que creó table.append() en la lección 5—, y el spec que ya conoces. Este es el punto de partida exacto que vas a comparar contra el resultado final.
Paso 2 — Evoluciona el spec: agrega order_day
with table.update_spec() as update:
update.add_field("order_ts", DayTransform(), "order_day")
print("\nspec vigente despues de evolucionar:")
print(table.spec())
Qué esperar:
spec vigente despues de evolucionar:
[
1000: store_id: identity(3)
1001: order_day: day(7)
]
Una línea de código, y el PartitionSpec de la tabla ahora tiene dos campos: el store_id original (field_id=1000) sigue exactamente igual, y un campo nuevo, order_day (field_id=1001, como te anticipó la lección 4: el siguiente número disponible después de 1000), que trunca order_ts (field_id=7 en el esquema, de ahí el day(7)) a su fecha de calendario. Fíjate en que no pasaste source_id como número esta vez —pasaste "order_ts", el nombre de la columna—; a diferencia de la lección 5, donde el PartitionSpec inicial se construyó a mano para una tabla que todavía no existía, update_spec() opera sobre una tabla ya viva, con un esquema real al que puede preguntarle "¿cuál es el field_id de order_ts?" por ti.
Paso 3 — Confirma que nada se reescribió
files_after = sorted(f["file_path"] for f in table.inspect.files().to_pylist())
snapshots_after = len(table.history())
print(f"\narchivos de datos despues de evolucionar: {len(files_after)}")
print(f"snapshots despues de evolucionar: {snapshots_after}")
print(f"archivos identicos antes/despues (ni uno se reescribio): {files_before == files_after}")
assert files_before == files_after, "evolucionar el spec NUNCA debe tocar un archivo de datos existente"
assert snapshots_before == snapshots_after, "evolucionar el spec NUNCA crea un snapshot nuevo"
Qué esperar:
archivos de datos despues de evolucionar: 3
snapshots despues de evolucionar: 1
archivos identicos antes/despues (ni uno se reescribio): True
Los mismos tres archivos, exactamente con los mismos nombres, y todavía un solo snapshot. update_spec() no escribió ni un byte de datos nuevo — solo actualizó la definición de la tabla en un archivo de metadata nuevo, el mismo tipo de operación "instantánea" que ya viste con update_schema() en el módulo 4. Diez millones de filas, evolucionadas en su forma de organizarse, sin mover una sola de ellas.
Diagrama: qué cambió, y qué no
flowchart LR
subgraph antes["Antes de update_spec()"]
A1["3 archivos de datos\nstore_id=S01/S02/S03"]
A2["spec: solo store_id"]
A3["1 snapshot"]
end
subgraph despues["Despues de update_spec()"]
B1["LOS MISMOS 3 archivos\n(ni uno se toco)"]
B2["spec: store_id + order_day\n(spec_id nuevo)"]
B3["1 snapshot\n(sin cambios -- no es escritura de datos)"]
end
A1 -.->|"identicos"| B1
A2 -->|"update_spec().add_field()"| B2
A3 -.->|"identico"| B3
Profundización: por qué esto es seguro, y qué significa "spec nuevo"
Vale la pena ser preciso sobre qué produjo, exactamente, update_spec(). No modificó el PartitionSpec original —ese spec, con spec-id=0 y un solo campo (store_id), sigue existiendo, archivado en el árbol de metadata de la tabla, tal como lo dejó la lección 5—. Lo que produjo fue un spec nuevo —spec-id=1, con dos campos— y actualizó el puntero de "spec vigente" de la tabla hacia ese nuevo spec, para cualquier escritura futura. Es exactamente el mismo patrón que ya conoces de los snapshots: nunca se sobrescribe nada, siempre se archiva una versión nueva y se actualiza un puntero.
Esto explica, de una vez, por qué la evolución de partición es segura incluso mientras otros procesos leen o escriben la tabla al mismo tiempo: los tres archivos de datos existentes siguen asociados, en el manifest que los describe, al spec-id=0 original — nada en su forma de organizarse cambió, así que ningún lector que ya sabía cómo interpretarlos se rompe. La lección 7 va a hacer visible esta distinción con table.inspect.partitions(), mostrando el spec_id de cada grupo de archivos, lado a lado.
Errores comunes
Esperar que update_spec() reparticione los datos existentes bajo el nuevo esquema. Qué pasa: alguien, después de correr el paso 2 de esta lección, espera que table.inspect.partitions() muestre las diez millones de filas ya agrupadas también por order_day, con valores reales de fecha. Por qué pasa: si vienes de un sistema donde cambiar el particionado implica una reorganización completa de los datos (un ALTER TABLE bloqueante, por ejemplo), es natural esperar el mismo comportamiento aquí. Cómo detectarlo: si consultas table.inspect.partitions() justo después de evolucionar el spec y esperas ver valores de order_day para las filas viejas, revisa el paso 3 de esta lección — el número de archivos no cambió, así que tampoco cambió cómo esos archivos existentes se describen. Cómo corregirlo: la lección 7 muestra el resultado exacto — las filas viejas siguen apareciendo bajo el spec_id original, con order_day=None, precisamente porque nunca se reescribieron. Solo las filas que se escriban después de la evolución llevan el order_day poblado.
Llamar a update.add_field() fuera del bloque with table.update_spec() as update:. Qué pasa: alguien intenta usar el objeto update fuera del with, o lo guarda en una variable para reutilizarlo más tarde en otra parte del script, y obtiene un error o un comportamiento inesperado. Por qué pasa: el patrón with ... as update: de PyIceberg —el mismo que ya usaste con update_schema() en el módulo 4— agrupa varias operaciones en una sola transacción que se confirma (commit) automáticamente al salir del bloque; el objeto update no está pensado para vivir más allá de ese with. Cómo detectarlo: si tu código guarda una referencia a update y la usa después de que el bloque with ya terminó, revisa el ejemplo trabajado de esta lección otra vez. Cómo corregirlo: todas las llamadas a update.add_field() (o remove_field(), rename_field()) que quieras agrupar en una sola evolución de spec van dentro del mismo bloque with — exactamente como el proyecto del módulo 4 ya hizo con varias operaciones de esquema encadenadas.
Ejercicios
Ejercicio 1 — Reproduce la evolución tú mismo, y confirma los tres números. Con kiosko.fact_orders_at_scale de la lección 5 disponible, corre los tres pasos de esta lección. Confirma 3 archivos antes y después, 1 snapshot antes y después, y el spec nuevo con dos campos.
Ver solución
Si tu tabla tiene exactamente el estado que dejó la lección 5, tu salida debería coincidir número por número con la de esta lección. Si el conteo de archivos cambia entre antes y después, revisa si accidentalmente corriste algún append() adicional entre ambas lecciones — cualquier escritura de datos, incluso una pequeña, agregaría archivos nuevos y rompería la comparación limpia que este ejercicio busca.
Ejercicio 2 — Evoluciona el spec una segunda vez, agregando BucketTransform sobre franchise_id. Usando lo que aprendiste en la lección 4 sobre alta cardinalidad, agrega un tercer campo de partición: update.add_field("franchise_id", BucketTransform(8), "franchise_bucket"). Confirma que el spec ahora tiene tres campos, y que los archivos de datos siguen sin cambiar.
Ver solución
from pyiceberg.transforms import BucketTransform
files_before_second = sorted(f["file_path"] for f in table.inspect.files().to_pylist())
with table.update_spec() as update:
update.add_field("franchise_id", BucketTransform(8), "franchise_bucket")
print(table.spec())
files_after_second = sorted(f["file_path"] for f in table.inspect.files().to_pylist())
print("archivos sin cambios:", files_before_second == files_after_second)
El spec resultante tiene tres campos: store_id (identity), order_day (day), y franchise_bucket (bucket de 8 cubetas sobre franchise_id) — y los archivos de datos, otra vez, no cambian. Este ejercicio confirma que la evolución de partición no está limitada a una sola operación: puedes seguir agregando campos, cada uno con su propio field_id incremental (1002 para este tercero), sin ningún límite impuesto por los datos ya existentes.
Ejercicio 3 — Explica, en tus propias palabras, por qué esta lección nunca crea un snapshot nuevo. En 2-3 frases, conecta esta observación con lo que ya sabes del módulo 3 sobre qué operaciones crean snapshots y cuáles no.
Ver solución
Un snapshot, como estableció el módulo 3, representa el conjunto completo de archivos de datos vigente en un instante — se crea cada vez que una operación escribe o borra datos (append(), overwrite(), delete()). update_spec(), igual que update_schema() en el módulo 4, nunca toca un archivo de datos: solo cambia la definición de cómo la tabla organiza (o interpreta) esos archivos hacia adelante. Como no hay ninguna escritura de datos involucrada, no hay ningún snapshot nuevo que crear — el snapshot vigente sigue siendo exactamente el mismo antes y después de evolucionar el spec, y table.history() lo confirma con el mismo número, 1, en ambos momentos.
Resumen y siguiente paso
En esta lección evolucionaste el PartitionSpec de kiosko.fact_orders_at_scale, agregando order_day (DayTransform sobre order_ts) junto al store_id original, con una sola línea de código: update.add_field("order_ts", DayTransform(), "order_day"). Confirmaste, con assert sobre el conteo exacto de archivos y de snapshots, que las diez millones de filas ya cargadas no se tocaron en absoluto — la misma garantía que el módulo 4 ya demostró para evolución de esquema, aplicada aquí a evolución de partición.
Antes de avanzar deberías poder: explicar la diferencia entre el spec-id original y el nuevo; reproducir la evolución sobre tu propia tabla; y anticipar que las filas viejas van a mostrar order_day=None en cualquier inspección, porque nunca se reescribieron.
Tienes un spec evolucionado, pero todavía no viste, con tus propios ojos, cómo conviven los datos viejos (bajo el spec original) con los datos nuevos (bajo el spec evolucionado) dentro de la misma tabla. La lección 7 agrega una franquicia nueva —la primera que llega después de esta evolución— y usa table.inspect.partitions() para mostrar ambos esquemas, lado a lado, en la misma consulta.
Recursos
- PyIceberg — referencia de API,
table.update_spec(),update.add_field()/remove_field()/rename_field(). py.iceberg.apache.org/api. En inglés. - Apache Iceberg — documentación oficial, "Partitioning", sección "Partition Evolution" (la garantía formal de que evolucionar un spec no reescribe datos existentes). iceberg.apache.org/docs/latest/partitioning. En inglés.
- DISEÑO de esta guía — la sección del módulo 5, con la especificación exacta de la evolución de spec (
update_spec().add_field("order_ts", DayTransform(), "order_day")).src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.