W1 · Reparar los sort orders huérfanos — approach de terreno
Fecha: 2026-08-10. Sub-fase de
w-write-path-approach.md.
Existe como documento aparte porque W1 dejó de ser «unALTER» y pasó a ser una
operación de mantenimiento con cirugía de metadata — otra clase de riesgo y otro
principal.Nada de lo que sigue se ha ejecutado. Lo que sí está medido lleva su comando.
🏁 0 · RESUELTO (2026-08-10) — y el camino salió a la CUARTA
Las 10 tablas reparadas. Barrido final: 0 huérfanos en 119 tablas. Historial intacto.
tablas inspeccionadas : 119 · CON SORT ORDER HUÉRFANO: 0
animal_events 12 snapshots · 11 ANTERIORES a hoy (los originales) 🏁
caretakers 4 snapshots · 3 ANTERIORES a hoy 🏁
(el +1 de cada una es el snapshot vacío que commitea el propio INSERT … WHERE 1=0
del gate — no es una pérdida, es el gate dejando huella)
⭐ El procedimiento final: DOS pasos, sin ventana de riesgo
① escribir en R2 una metadata SIN el sort order huérfano (fichero suelto)
② POST …/register { name, metadata-location, overwrite: true } ← atómico
scripts/governance/w1-repair-cycle.ts. No hay drop, no hay rename, no hay
papelera, y no hay rollback que ejecutar — porque no existe un instante en el que
la tabla no exista.
Las tres hipótesis que hubo que falsar para llegar aquí
| Intento | Por qué NO |
|---|---|
ALTER TABLE … WRITE UNORDERED | SUCCEEDED y no hizo nada: las tablas ya estaban en default-sort-order-id: 0. El por defecto nunca fue el problema |
| registrar la copia con nombre temporal | 409 — Lakekeeper rechaza registrar sobre una location ya referenciada, y lo reporta como «same name». Sólo un control con nombre imposible de colisionar lo distingue de una colisión real |
drop (purge=false) + register | 409 otra vez — Lakekeeper hace SOFT-DELETE: la borrada sigue ocupando nombre y location desde la papelera. Y el rollback por register tampoco funcionaba. Costó tener una tabla fuera del catálogo unos minutos; la salvó undrop, que no estaba en el plan |
⭐⭐ Las tres lecciones, y las tres son del mismo tipo
- Un rollback que no se ha EJECUTADO no es un rollback: es una hipótesis. El de la 3ª vía estaba escrito y razonado —«tras el drop la location queda libre»— y era falso: liberaba la location, no el nombre. Se supo al necesitarlo.
- Un comando que no falla no ha hecho necesariamente nada.
WRITE UNORDEREDdevolvióSUCCEEDEDsobre una operación vacía. - ⭐ El gate midió en el momento equivocado y dio CUATRO rojos falsos. La sesión
del puente es caliente y cachea la metadata: tras el
register, el catálogo ya apuntaba al fichero nuevo y la sesión seguía con el viejo. Cuatro tablas correctamente reparadas se dieron por fallidas. ⇒ El gate haceREFRESH TABLEantes de medir. Es la tercera vez en la misma jornada que el momento de la sonda decide el veredicto.
⚠️ Lo que queda abierto de W1
- La causa sigue viva:
drop-legacy-identity-columns.tsvolvería a producirlo. Hay que corregirlo para que primero cambie el sort order y después suelte la columna — la secuencia que Iceberg sí admite. - Los otros 7 inquilinos no se han barrido (el censo fue del workspace con datos).
- 10 snapshots vacíos nuevos, uno por tabla, cortesía del gate. Los limpia B7.
1 · El problema, en una línea y con su prueba
Diez tablas no admiten escritura de Spark porque su metadata conserva un sort order que apunta a una columna que ya no existe:
INSERT INTO main.test.diets … WHERE 1=0
→ ValidationException: Cannot find source column for sort field: identity(1) ASC NULLS LAST
Residuo de scripts/duckdb/drop-legacy-identity-columns.ts. Es el defecto upstream
apache/iceberg#8614, cerrado como
«not planned».
⭐ Por qué llevaba meses invisible
La lectura funciona. Spark sólo vincula los sort orders al serializar la tabla
para los executors (SerializableTable → SortOrderParser.fromJson →
UnboundSortOrder.bind), y eso sólo ocurre escribiendo. Por eso el error aparece en el
executor y no en el driver, y por eso ningún gate de lectura podía verlo.
⛔ Y por qué NO es el sort order por defecto
Ésta fue la primera hipótesis, y era falsa. Leyendo la metadata:
censo_poblacional default-sort-order-id: 0 sort-order 0: VACÍO sort-order 1: [(1,'identity')]
diets default-sort-order-id: 0 sort-order 0: VACÍO sort-order 1: [(1,'identity')]
Ya estaban en 0 (unsorted). Por eso ALTER TABLE … WRITE UNORDERED devolvió
SUCCEEDED y no cambió nada: no había nada que cambiar.
⇒ Lo que rompe es que el sort-order 1 sigue en la lista, y la lista se parsea entera.
2 · Por qué no hay salida por el protocolo
Los tipos de update de la spec Iceberg REST son:
add-schema · set-current-schema · add-spec · set-default-spec · add-sort-order ·
set-default-sort-order · set-location · set-properties · remove-properties ·
add-encryption-key · remove-encryption-key
⛔ No existe remove-sort-order. sort-orders es append-only: se puede añadir
uno nuevo y cambiar el por defecto, pero no borrar uno histórico. Ni con SQL, ni por
updateTable, ni con ningún procedimiento estándar de Iceberg.
⇒ Para quitarlo hay que reescribir el metadata.json y re-apuntar el catálogo.
3 · El terreno, medido
| tabla | snapshots | qué se pierde si se recrea |
|---|---|---|
main.test.animal_events | 11 | ⚠️ historial real |
main.default.caretakers | 3 | ⚠️ historial |
| las otras 8 | 1 | nada — el único snapshot es el actual |
| 22 total |
Todas son format-version 2, con 4 schemas y 2 sort orders (el 0 vacío y el 1 roto).
Y la pieza que lo hace posible — register existe en Lakekeeper:
POST …/v1/{prefix}/namespaces/main␟test/register con body {}
→ 422 "missing field `name`" ⇒ la ruta EXISTE y valida
⚠️ Pero la cara NO lo gobierna: routeCatalogRequest sólo conoce
namespaces/…/tables. ⇒ W1 va directo a Lakekeeper con lakekeeper-operator, igual
que el warehouse-writer. Eso la convierte en una operación de mantenimiento, no de
producto — y es correcto que lo sea.
3·bis · ⛔⛔ W1·0 FALSÓ EL PROCEDIMIENTO (2026-08-10) — y sin romper nada
El ensayo sobre censo_poblacional hizo ①-③ y se detuvo. Lo que sigue sustituye a §4.
✅ Lo que SÍ funcionó (incógnita nº1, cerrada)
La credencial vendida basta. loadTable con x-iceberg-access-delegation devuelve
una credencial STS acotada a esa tabla, y con ella se leyó el metadata.json de R2 y
se escribió la versión reparada:
leído el original de R2 (1135 bytes)
escrita la metadata reparada (1077 bytes) ← 58 bytes menos: el sort-order huérfano fuera
⇒ No hace falta ninguna llave de administración. La deuda de
carbon-r2-vending-spike (Admin R&W sobre todos los buckets) no se toca.
⛔ Lo que NO funcionó (incógnita nº2: la respuesta es NO)
register "ds_ce4901bb…__w1" → 409 "Tabular with the same name already exists"
register "zz_w1_probe_unico_0810" → 409 "Tabular with the same name already exists"
↑ nombre garantizado inexistente. MISMO error.
⭐⭐ El mensaje de Lakekeeper MIENTE. No es una colisión de nombre: es que la
location ya está referenciada por otra tabla, y lo reporta como «same name». Sin el
control con un nombre imposible de colisionar, esto habría mandado a buscar durante horas
una colisión de nombres que no existe.
⇒ Lakekeeper NO admite registrar una tabla cuyos ficheros ya referencia otra. El paso ③ —«registrar la copia reparada ANTES de tocar la original»— no se puede hacer.
⛔⛔⛔ Y EL DROP-PRIMERO TAMPOCO FUNCIONA — medido, con susto
Se ejecutó sobre censo_poblacional. Resultado:
③ DROP purgeRequested=false → 204 ✅
④ REGISTER de la reparada → 409 "same name already exists"
🔄 ROLLBACK · register la ORIGINAL → 409 "same name already exists" ⛔ TAMPOCO
Durante unos minutos la tabla no estuvo en el catálogo. Los datos nunca corrieron
peligro (purge=false), pero el rollback que estaba preparado no sirvió.
La causa: Lakekeeper hace SOFT-DELETE. Una tabla borrada va a una papelera y sigue ocupando su nombre:
GET /management/v1/warehouse/{wid}/deleted-tabulars
→ ds_ce4901bbfbd642febaa14a4a43b04311 id 019f33ac-… (ahí estaba)
⇒ Tras un drop, el nombre no queda libre, así que register es imposible — ni de
la reparada ni de la original. El razonamiento de §3·bis («tras el drop la location está
libre, así que el rollback está probado por construcción») era falso: liberaba la
location, pero no el nombre.
Lo que salvó la situación no estaba en el plan: el undrop de Lakekeeper.
POST /management/v1/warehouse/{wid}/deleted-tabulars/undrop
{"targets":[{"type":"table","id":"019f33ac-…"}]} → 204 ✅
SELECT count(*) FROM main.test.censo_poblacional → 4 (intacta)
⭐ La lección, y es cara: un rollback que no se ha EJECUTADO no es un rollback, es una hipótesis. Éste estaba escrito, razonado y era falso — y sólo se supo al necesitarlo.
⇒ El procedimiento CORREGIDO (③ pasa a ser un RENAME)
El nombre se libera renombrando, no borrando: así nunca entra en la papelera.
① leer metadata → ② escribir la reparada [ya hecho]
③ RENAME original → ds_<uuid>__w1old ← el nombre bueno queda libre
④ DROP de __w1old con purgeRequested=FALSE ← libera la LOCATION; el nombre
que va a la papelera es el feo
⑤ REGISTER de la reparada con el nombre ORIGINAL
⑥ GATE: INSERT … WHERE 1=0 · count(*) idéntico
Y el rollback ahora sí es ejecutable en cada punto:
| Falla en | Cómo se vuelve |
|---|---|
| ③ | nada que hacer, no se tocó |
| ④ | RENAME de vuelta |
| ⑤ | undrop de __w1old + RENAME de vuelta — las dos operaciones ya probadas hoy |
⚠️ Antes de ejecutarlo hay que probar que rename existe y funciona, con el mismo
control que destapó lo del register. No se da por bueno porque esté en la spec.
El procedimiento revisado: drop-primero, con la ventana (FALSADO arriba)
① leer metadata → ② escribir la reparada [HECHO, y no toca nada]
③ DROP de la original con purgeRequested=FALSE ← ⛔ la ventana empieza aquí
④ REGISTER de la reparada con el nombre original ← la location ya está libre
⑤ GATE: INSERT … WHERE 1=0 + count(*) idéntico
⚠️ La ventana de riesgo pasa a ser ③-④ y ya no se puede verificar antes. A cambio,
el rollback está probado por construcción: el 409 demuestra que register funciona
sobre una location libre, y tras el drop lo está — así que re-registrar la
original (cuyo metadata sigue en R2, intacto) es una operación que sabemos que pasa.
⚠️ Y purgeRequested=false sigue siendo lo único que separa esto de una pérdida de
datos: con true, el drop borraría los Parquet que la reparada necesita.
Estado tras W1·0
Nada roto. Los dos register fallaron, así que el catálogo está como estaba:
SELECT count(*) FROM main.test.censo_poblacional → 4 (intacta)
Sólo queda un fichero suelto en R2 (w1-reparada-…gz.metadata.json) que no referencia
nadie — inofensivo, y es el insumo del paso ④.
4 · La decisión (SUSTITUIDA por §3·bis) · registrar una COPIA REPARADA antes de tocar la original
Se descartaron las dos alternativas obvias:
| Por qué no | |
|---|---|
| Recrear con CTAS + swap | Pierde los 22 snapshots, cambia el ds_<uuid> (⇒ hay que tocar Index) y copia todos los ficheros. Para 8 tablas daría igual; para animal_events no |
drop y luego register | Deja una ventana en la que la tabla no existe. Si el register falla, la tabla se ha perdido del catálogo con los datos huérfanos en R2 |
⇒ El procedimiento invierte el orden: se verifica lo reparado ANTES de tocar lo bueno.
① leer el metadata.json vigente desde R2
② escribir uno NUEVO sin el sort-order huérfano (fichero nuevo; el viejo no se toca)
③ REGISTER con un nombre temporal ds_<uuid>__w1 ← la reparada ya existe y es verificable
④ GATE: INSERT … WHERE 1=0 sobre la temporal ← si falla aquí, no se ha roto nada
⑤ DROP de la original con purgeRequested=FALSE ← ⛔ el paso crítico
⑥ RENAME de la temporal al nombre original
⑦ GATE: INSERT … WHERE 1=0 sobre el nombre bueno + SELECT count(*) igual que antes
⭐ La ventana de riesgo queda entre ⑤ y ⑥, y ahí ya se sabe que la reparada funciona. Antes de ⑤ no se ha tocado nada reversible.
⛔⛔ El paso ⑤ es el que puede destruir datos
Las dos tablas apuntan a LOS MISMOS ficheros de datos. Un DROP con
purgeRequested=true borraría los Parquet — y con ellos, también los de la copia
reparada. purgeRequested=false no es una optimización: es lo único que separa esto
de una pérdida de datos.
⇒ El script debe fallar si el parámetro no viaja, no confiar en el valor por defecto del cliente. Se comprueba en el gate previo, no después.
5 · Las fases, con su gate
| Paso | Gate | |
|---|---|---|
| W1·0 | Ensayo sobre censo_poblacional (4 filas, 1 snapshot) | el ciclo ①-⑦ completo · INSERT … WHERE 1=0 pasa · count(*) idéntico antes y después |
| W1·1 | Las otras 7 de un snapshot | ídem, tabla a tabla · nunca en lote |
| W1·2 | ⚠️ caretakers (3) y animal_events (11) | ídem + el historial sobrevive: SELECT count(*) FROM …snapshots da 3 y 11 |
| W1·3 | Barrido final | las 119 tablas dan 0 huérfanos · y un INSERT pasa en las 10 |
⭐ Tabla a tabla y con verificación entre medias. El CAS del catálogo es un punto de fallo por operación; en lote, un fallo a mitad deja un estado que nadie sabe describir.
6 · Las incógnitas — dichas antes, no después
- ⭐ ¿Quién escribe el
metadata.jsonnuevo en R2? El perímetro de escritura eswarehouse-writer(RW). Hay que decidir si W1 usa su credencial o pide un vending acotado. No usar la llave compartidacarbon-r2-vending-spike, que tiene Admin R&W sobre todos los buckets y ya está en la lista de deuda. - ¿Lakekeeper permite
registerde una tabla cuyos ficheros ya referencia otra? Es el supuesto del paso ③ y no está probado. Si lo rechaza, el plan cambia a drop-primero y hay que aceptar la ventana. - ¿El
renameexiste y es atómico en Lakekeeper? La spec lo tiene (POST /v1/{prefix}/tables/rename); nuestra cara tampoco lo gobierna. - Index no se entera. El
ds_<uuid>no cambia con este procedimiento —a diferencia del CTAS—, así quedatasetsno necesita tocarse. Verificarlo es parte del gate, no una suposición.
7 · Rollback
Cada paso deja el anterior intacto:
| Falla en | Estado | Cómo se vuelve |
|---|---|---|
| ②-④ | la original no se ha tocado | borrar el metadata nuevo y la temporal |
| ⑤ | la original ya no está en el catálogo, los ficheros siguen (purge=false) | register de la original con su metadata ORIGINAL, que sigue en R2 |
| ⑥ | la temporal existe con el nombre feo | rename otra vez |
⭐ Por eso el metadata viejo no se borra nunca: es el rollback.
8 · Lo que NO hace este plan
- No arregla la causa:
drop-legacy-identity-columns.tsvolvería a producirlo. Hay que corregir ese script —o el que retire columnas— para que primero cambie el sort order y después suelte la columna, que es la secuencia que Iceberg sí admite. - No toca las 32 tablas físicas sin dataset (W5).
- No cubre otros inquilinos: el barrido midió el workspace con datos. Los otros 7 hay que barrerlos antes de dar W1 por cerrada.