Módulo 4: Schema Evolution Without Rewriting
Qué garantiza "ACID" en este contexto exacto
Descripción
Esta lección da un paso atrás. Las lecciones 2 a 6 de este módulo demostraron, una y otra vez, que las escrituras de Iceberg son atómicas y que sus lecturas nunca ven un estado a medias. Es tentador, con esa evidencia acumulada, decir "Iceberg es ACID" y dejarlo ahí — pero esa frase, sin precisión, puede sugerir una garantía que Iceberg no da: la de un motor transaccional relacional completo, con transacciones multi-tabla y ROLLBACK explícito. Esta lección construye, con código real, el escenario exacto donde la garantía de Iceberg entra en juego —dos escritores compitiendo por el mismo commit— y traza, con la misma precisión, la línea de hasta dónde llega esa garantía y dónde termina.
Conexión con el módulo. La lección 2 demostró la mitad de esta historia: que ningún lector externo ve un estado intermedio durante una escritura. Esta lección demuestra la otra mitad: qué pasa cuando dos escritores —no un escritor y un lector— intentan modificar la misma tabla al mismo tiempo. Es el mismo mecanismo de "stage, luego commit" que la lección 2 ya introdujo, visto ahora desde el ángulo de la concurrencia entre escrituras, no entre escritura y lectura.
Una analogía: el mostrador, con dos personas pidiendo el mismo turno
Vuelve al mostrador de trámites de la lección 2. Ahora imagina que dos personas llegan, casi al mismo tiempo, a actualizar el mismo registro —cada una preparó, por su cuenta, su propio documento nuevo, sin saber que la otra estaba haciendo lo mismo—. El mostrador no puede aceptar los dos cambios a la vez, porque cada uno partió de una versión distinta del registro original, y aplicar ambos sin coordinación produciría un resultado que nadie pidió. La solución del mostrador es simple y estricta: la primera persona que llega con su documento completo gana el turno, su cambio se aplica; la segunda, cuando presenta el suyo, descubre que el registro ya cambió desde que empezó a prepararlo, y el mostrador le dice, sin ambigüedad, "esto ya no es válido — vuelve a prepararlo con el registro actualizado". Nadie pierde su trabajo en silencio. Nadie termina con un registro mezclado, mitad de un cambio y mitad del otro. El mostrador rechaza, con toda claridad, al que llegó tarde a la carrera.
Ejemplo trabajado: dos escritores, uno gana, el otro es rechazado con evidencia
Paso 1 — Dos procesos cargan la misma tabla, al mismo tiempo
Esta ilustración usa un catálogo y una tabla propios, descartables, aislados de kiosko.dim_store — el objetivo es mostrar el mecanismo puro de la concurrencia, no mezclar el experimento con el estado real de Kiosko.
# concurrent_write_conflict.py -- ilustracion aislada, NO parte del modelo de Kiosko
import os
import shutil
from pyiceberg.catalog import load_catalog
from pyiceberg.exceptions import CommitFailedException
from pyiceberg.schema import Schema
from pyiceberg.types import NestedField, StringType
demo_warehouse = os.path.abspath("acid_demo_warehouse")
demo_db = os.path.abspath("acid_demo_catalog.db")
os.makedirs(demo_warehouse, exist_ok=True)
demo_catalog = load_catalog(
"acid_demo", type="sql",
uri=f"sqlite:///{demo_db}", warehouse=f"file://{demo_warehouse}",
)
demo_catalog.create_namespace("acid_demo")
schema = Schema(NestedField(field_id=1, name="ticket_id", field_type=StringType(), required=True))
demo_catalog.create_table("acid_demo.race_table", schema=schema)
# dos "procesos" distintos cargan el MISMO table handle en el instante inicial --
# cada uno tiene su propia copia en memoria del metadata vigente ahora mismo
table_process_a = demo_catalog.load_table("acid_demo.race_table")
table_process_b = demo_catalog.load_table("acid_demo.race_table")
print("schema_id que ambos procesos ven al cargar la tabla:",
table_process_a.schema().schema_id, table_process_b.schema().schema_id)
Qué esperar (verificado corriendo el script real):
schema_id que ambos procesos ven al cargar la tabla: 0 0
Los dos procesos parten exactamente del mismo punto — el mismo schema_id=0, la tabla recién creada, sin ninguna diferencia entre lo que cada uno ve. Ninguno sabe, todavía, que el otro existe.
Paso 2 — El proceso A confirma primero; el proceso B llega tarde
with table_process_a.update_schema() as update:
update.add_column("status", StringType())
print("\nProceso A confirma primero -- agrega la columna 'status'. OK.")
print("schema_id vigente tras el commit de A:", table_process_a.schema().schema_id)
try:
with table_process_b.update_schema() as update:
update.add_column("priority", StringType())
print("Proceso B confirmo sin error (esto NO deberia pasar)")
except CommitFailedException as e:
print(f"\nProceso B falla con CommitFailedException:\n {e}")
Qué esperar (verificado corriendo el script real; el texto exacto de la excepción es de PyIceberg 0.11.1, y es completamente reproducible: los números de schema_id en este experimento aislado son deterministas, a diferencia de un snapshot_id):
Proceso A confirma primero -- agrega la columna 'status'. OK.
schema_id vigente tras el commit de A: 1
Proceso B falla con CommitFailedException:
Requirement failed: current schema id has changed: expected 0, found 1
Ahí está la garantía, con evidencia literal, no con una promesa de documentación. El proceso A, que partió del schema_id=0, confirmó primero, y la tabla avanzó al schema_id=1. El proceso B, que también partió del schema_id=0 pero llegó segundo a confirmar, fue rechazado — no con datos corruptos, no con un resultado mezclado de status y priority a medias, sino con una excepción clara que dice, con precisión, qué esperaba y qué encontró. El registro nunca terminó con un esquema Frankenstein de "algo de A, algo de B" — o ganó A por completo, o (si B hubiera llegado primero) habría ganado B por completo. Nunca los dos parcialmente.
Diagrama: la carrera, y el árbitro
sequenceDiagram
participant A as Proceso A
participant Cat as Catalogo (kiosko)
participant B as Proceso B
A->>Cat: load_table() -- ve schema_id=0
B->>Cat: load_table() -- ve schema_id=0
A->>A: prepara metadata nuevo (schema_id=1)
A->>Cat: UPDATE ... WHERE schema_id_esperado=0
Cat-->>A: OK -- vigente ahora es schema_id=1
B->>B: prepara metadata nuevo (basado en schema_id=0, ya viejo)
B->>Cat: UPDATE ... WHERE schema_id_esperado=0
Cat-->>B: RECHAZADO -- vigente ya es schema_id=1, no 0
B->>B: CommitFailedException
Profundización: las tres letras que sí aplican, y la que no aplica sin matices
Vale la pena precisar, letra por letra, qué de "ACID" —Atomicidad, Consistencia, Aislamiento, Durabilidad— demostró este módulo, y con qué alcance exacto:
- Atomicidad — demostrada en la lección 2: una escritura de Iceberg se aplica completa o no se aplica en absoluto, nunca a medias. Confirmado con 490 lecturas concurrentes sin capturar ningún estado intermedio.
- Aislamiento — demostrado en esta lección: dos escritores compitiendo por el mismo commit nunca producen un resultado mezclado; uno gana, el otro es rechazado con una excepción clara (
CommitFailedException), y puede reintentar sobre el estado nuevo. La documentación oficial de Apache Iceberg lo llama, con ese nombre exacto, "serializable isolation": "Commits replace the path of the current table metadata file using an atomic operation. This ensures that all updates to table data and metadata are atomic, and is the basis for serializable isolation." - Durabilidad — heredada del almacenamiento subyacente: una vez que el
UPDATEdel catálogo confirma, el archivo de metadata y los archivos de datos que referencia ya están escritos, completos, en disco (o en el object store, en un despliegue real) — no dependen de que ningún proceso siga corriendo para persistir. - Consistencia — aquí es donde hay que ser más preciso. Iceberg garantiza que una tabla nunca queda en un estado inconsistente consigo misma —su metadata siempre describe correctamente sus propios archivos—. Lo que Iceberg no garantiza es consistencia entre tablas distintas: si un script escribe primero en
kiosko.dim_storey después falla al escribir enkiosko.fact_orders, la primera escritura queda confirmada, completa, sin ningún mecanismo automático que la deshaga. No hay unROLLBACKque abarque ambas tablas a la vez, porque cada tabla Iceberg tiene su propio catálogo de commits, independiente del de cualquier otra.
Por eso la afirmación precisa no es "Iceberg es un motor transaccional ACID completo" — es, con las palabras exactas de esta lección: Iceberg garantiza atomicidad y aislamiento serializable para los cambios de una sola tabla, con concurrencia optimista para resolver conflictos entre escritores. Eso es exactamente lo que necesitabas para confiar en que table.overwrite() (módulo 3) y table.update_schema() (este módulo) nunca dejan a kiosko.dim_store a medio camino — y es, también, exactamente donde termina la garantía, sin estirarla más de lo que la documentación oficial respalda.
Errores comunes
Diseñar un pipeline que dependa de un ROLLBACK automático entre dos tablas Iceberg distintas. Qué pasa: alguien escribe un script que actualiza kiosko.dim_store y luego kiosko.fact_orders, y asume que si la segunda escritura falla, la primera se deshace sola. Por qué pasa: en una base de datos relacional con transacciones multi-tabla, ese es exactamente el comportamiento esperado, y es natural asumir lo mismo aquí. Cómo detectarlo: si tu script no maneja explícitamente qué hacer cuando la segunda de dos escrituras Iceberg falla después de que la primera ya confirmó, tienes este riesgo. Cómo corregirlo: diseña cada escritura de tabla como una unidad independiente, y maneja los errores de forma explícita —por ejemplo, verificando el resultado de la primera escritura antes de decidir si la segunda debe correr, o construyendo tu propio mecanismo de compensación si de verdad necesitas revertir un cambio ya confirmado—. Este es, con precisión, uno de los problemas que un orquestador real (airflow-and-declarative-orchestration-guide, nombrado sin implementarse en esta guía) está diseñado para manejar con reintentos y dependencias explícitas entre pasos.
Pensar que CommitFailedException significa que los datos se corrompieron. Qué pasa: alguien, al ver la excepción del paso 2 de esta lección por primera vez, entra en pánico pensando que algo salió mal con la tabla. Por qué pasa: el nombre de la excepción, y el hecho de que interrumpe la ejecución del script, puede sonar a una falla grave. Cómo detectarlo: si tu reacción a un CommitFailedException es revisar la integridad de la tabla, revisa primero de qué está hecha la excepción: un rechazo limpio, sin ningún cambio aplicado a medias. Cómo corregirlo: CommitFailedException es, exactamente, el mecanismo de seguridad funcionando como debe — significa que Iceberg detectó una carrera y la resolvió sin corromper nada, rechazando al que llegó tarde. La respuesta correcta no es preocuparse por la tabla: es recargarla (catalog.load_table(...) de nuevo, para obtener el estado vigente) y reintentar la operación sobre esa base actualizada, si el cambio sigue siendo necesario.
Ejercicios
Ejercicio 1 — Reproduce la carrera tú mismo, y confirma el mensaje exacto de la excepción. Corre el script completo de esta lección. Confirma que ves schema_id que ambos procesos ven al cargar la tabla: 0 0, y que el proceso B falla con un mensaje que mencione "expected 0, found 1".
Ver solución
A diferencia de un snapshot_id, los números de schema_id en este experimento aislado sí son reproducibles —porque dependen únicamente de la secuencia de operaciones de esquema, no de ningún reloj ni de ningún identificador aleatorio—, así que tu salida debería coincidir exactamente con la de esta lección, incluido el texto literal "Requirement failed: current schema id has changed: expected 0, found 1".
Ejercicio 2 — Modifica el script para que el proceso B reintente con éxito. Después de capturar el CommitFailedException, agrega código que recargue la tabla (demo_catalog.load_table("acid_demo.race_table")) y vuelva a intentar add_column("priority", ...) sobre esa versión fresca. Confirma que el segundo intento sí tiene éxito.
Ver solución
try:
with table_process_b.update_schema() as update:
update.add_column("priority", StringType())
except CommitFailedException:
table_process_b_retry = demo_catalog.load_table("acid_demo.race_table")
with table_process_b_retry.update_schema() as update:
update.add_column("priority", StringType())
print("Reintento exitoso. Esquema final:")
print(table_process_b_retry.schema())
El segundo intento tiene éxito porque table_process_b_retry parte del schema_id=1 —el que dejó el proceso A—, no del schema_id=0 viejo. El esquema final de la tabla incluye tanto status (de A) como priority (de B, en su segundo intento) — ningún cambio se perdió, solo se aplicaron en el orden correcto, uno después del otro, nunca simultáneamente.
Ejercicio 3 — Explica, en tus propias palabras, por qué esta lección dice "Iceberg garantiza atomicidad y aislamiento serializable para una sola tabla", en vez de simplemente "Iceberg es ACID". En 2-3 frases, justifica por qué la frase completa es más precisa, usando el ejemplo de kiosko.dim_store y kiosko.fact_orders como dos tablas distintas.
Ver solución
"Iceberg es ACID", sin más contexto, sugiere una garantía equivalente a la de un motor relacional transaccional completo, que incluye transacciones que abarcan múltiples tablas con ROLLBACK conjunto. Iceberg no ofrece eso: cada tabla —kiosko.dim_store, kiosko.fact_orders— tiene su propio catálogo de commits, independiente del de cualquier otra, así que un fallo al escribir la segunda de dos tablas no deshace automáticamente lo que ya se confirmó en la primera. La frase precisa —atomicidad y aislamiento serializable por tabla— describe exactamente lo que este módulo demostró con evidencia (una tabla nunca queda a medias, dos escritores nunca producen un resultado mezclado), sin prometer una garantía multi-tabla que la propia documentación oficial de Iceberg no ofrece.
Resumen y siguiente paso
En esta lección construiste, con código real, el escenario exacto donde la garantía de aislamiento de Iceberg entra en juego: dos procesos compitiendo por el mismo commit, uno gana, el otro es rechazado con una excepción clara y reproducible —CommitFailedException: Requirement failed: current schema id has changed: expected 0, found 1—. Uniste esa evidencia con la de la lección 2 (atomicidad) para trazar, letra por letra, qué garantiza "ACID" en el contexto de una sola tabla Iceberg, y dónde termina esa garantía —en el límite de una tabla, no en transacciones multi-tabla al estilo de un motor relacional completo—.
Antes de avanzar deberías poder: explicar, con tus propias palabras, las cuatro letras de ACID en el contexto exacto de una tabla Iceberg; reproducir un conflicto de escritura real y su mensaje de error; y explicar por qué Iceberg no ofrece transacciones multi-tabla, con un ejemplo concreto de kiosko.
Con el mecanismo completo demostrado —atomicidad, evolución de esquema segura, lectura del pasado, y los límites precisos de "ACID"—, la lección 8 junta las siete piezas de este módulo en un solo proyecto: kiosko.dim_store evolucionada de punta a punta, con assert automáticos que confirman cada afirmación.
Recursos
- Apache Iceberg — documentación oficial, "Reliability", secciones "Serializable Isolation" y "Concurrent write operations", la fuente formal de las dos citas de esta lección sobre atomicidad y concurrencia optimista. iceberg.apache.org/docs/latest/reliability. En inglés.
- PyIceberg — referencia de API,
pyiceberg.exceptions.CommitFailedException, la excepción real que esta lección captura y explica. py.iceberg.apache.org/api. En inglés. - DISEÑO de esta guía — la sección "Evolución de esquema" (M4), fuente exacta de la pregunta que esta lección responde: "qué garantiza 'ACID' en este contexto (aislamiento y atomicidad de la escritura, no un motor transaccional completo)".
src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.