Published

W1 · Reparar los sort orders huérfanos — approach de terreno

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

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 «un ALTER» 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í

IntentoPor qué NO
ALTER TABLE … WRITE UNORDEREDSUCCEEDED 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 temporal409 — 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) + register409 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

  1. 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.
  2. Un comando que no falla no ha hecho necesariamente nada. WRITE UNORDERED devolvió SUCCEEDED sobre una operación vacía.
  3. 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 hace REFRESH TABLE antes 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.ts volverí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 (SerializableTableSortOrderParser.fromJsonUnboundSortOrder.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

tablasnapshotsqué se pierde si se recrea
main.test.animal_events11⚠️ historial real
main.default.caretakers3⚠️ historial
las otras 81nada — 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 posibleregister 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 enCó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 + swapPierde 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 registerDeja 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

PasoGate
W1·0Ensayo sobre censo_poblacional (4 filas, 1 snapshot)el ciclo ①-⑦ completo · INSERT … WHERE 1=0 pasa · count(*) idéntico antes y después
W1·1Las 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·3Barrido finallas 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

  1. ¿Quién escribe el metadata.json nuevo en R2? El perímetro de escritura es warehouse-writer (RW). Hay que decidir si W1 usa su credencial o pide un vending acotado. No usar la llave compartida carbon-r2-vending-spike, que tiene Admin R&W sobre todos los buckets y ya está en la lista de deuda.
  2. ¿Lakekeeper permite register de 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.
  3. ¿El rename existe y es atómico en Lakekeeper? La spec lo tiene (POST /v1/{prefix}/tables/rename); nuestra cara tampoco lo gobierna.
  4. Index no se entera. El ds_<uuid> no cambia con este procedimiento —a diferencia del CTAS—, así que datasets no necesita tocarse. Verificarlo es parte del gate, no una suposición.

7 · Rollback

Cada paso deja el anterior intacto:

Falla enEstadoCómo se vuelve
②-④la original no se ha tocadoborrar 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 feorename 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.ts volverí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.