S5 · Approach — escritura por Spark y mantenimiento del lakehouse
2026-08-09. Estado de partida medido en
sustrato-02.md§5·bis: S0-S4 cerradas. Spark arranca, lee el
warehouse por nuestra cara con credencial vendida, y la autorización vive en el
catálogo con control negativo.Este documento dice qué falta entre eso y escribir, y por qué S5 es la fase
que justifica la absorción entera. Ordenado por lo que BLOQUEA.
0 · Por qué S5 es la fase que paga la absorción
Todo lo anterior fue infraestructura. S5 es lo primero que tapa un agujero que hoy está abierto y sangrando:
144 tablas Iceberg sin compaction, sin expiración de snapshots y sin limpieza
de huérfanos. Ningún componente de la plataforma las mantiene.
Una tabla Iceberg sin mantenimiento no se queda igual: acumula ficheros pequeños,
metadata fragmentada e historial que nadie va a leer. Y el coste no es lineal —
se paga en cada planFiles de cada query.
⚠️ Y no es un problema que llegue con la escala: llega con el tiempo. A 75 MB ya está pasando; a los cientos de petabytes que se van a ingestar, lo que hoy es lentitud será imposibilidad.
1 · ⭐ La oportunidad que nadie ha pedido: Spark cierra el agujero de ratify
lib/compute/ratify.ts tiene cuatro veredictos, y uno existe por una
limitación del escritor:
· unverifiable → el motor no pudo marcar la operación
(los merges de pyiceberg no aceptan `snapshot_properties`)
Y withDmlControlPlane estampa, literal:
// Ver `SnapshotAttribution`: el motor NO puede marcar el snapshot — medido,
// no supuesto. Sigue siendo false aunque la observación sea `exclusive`.
attributable: false,
⇒ Hoy el control-plane abre una transacción, el motor commitea, y nadie puede demostrar que ese snapshot fue de esa transacción. El ledger dice «pasó algo» y el catálogo dice «hay un snapshot nuevo», y unirlos es una inferencia, no un hecho.
Spark SÍ escribe snapshot_properties. Es una capacidad de primera clase de
su integración con Iceberg.
⇒ Con Spark, attributable puede pasar a true, unverifiable desaparece, y
ratify deja de ser un veredicto de consuelo para convertirse en una
verificación real. Eso es una mejora estricta de la gobernanza de escritura, y
llega gratis con el motor.
⭐ Es el argumento más fuerte de S5, y no aparece en ningún doc previo: no
se cambia de escritor por escala — se cambia porque el escritor nuevo puede
firmar lo que escribe.
2 · Las dos escrituras NO son la misma cosa
Meterlas en el mismo saco es el error que hay que evitar desde el principio:
| ETL / producto | Mantenimiento | |
|---|---|---|
| Qué hace | cambia el DATO lógico | reescribe FICHEROS, el dato lógico es idéntico |
| Quién lo pide | un pipeline, un usuario | un reloj |
| Riesgo | corrupción de datos de cliente | borrado de ficheros aún referenciados |
| Frescura | debe mover el oráculo | no debe moverlo |
| Ledger | APPEND/UPDATE/DELETE | tipo propio |
⚠️ El punto que se escapa: compaction crea un snapshot nuevo con
operation=replace. Si se estampa como una transacción de datos, el oráculo de
frescura concluirá que la tabla cambió cuando no ha cambiado nada, y todo lo que
dependa de frescura (caches, materializaciones, la ontología) se invalidará sin
motivo.
⇒ El mantenimiento tiene que ser legible COMO mantenimiento, en el ledger y en
las snapshot_properties. No es cosmética: es la diferencia entre un lakehouse
que se mantiene solo y uno que se invalida solo.
3 · Las cuatro operaciones, y su orden — que es contraintuitivo
Iceberg trae cuatro: rewrite_data_files (compactar), expire_snapshots
(historial), remove_orphan_files (basura no referenciada) y rewrite_manifests
(metadata).
El orden que recomienda la industria NO es el intuitivo:
① expire_snapshots → ② remove_orphan_files → ③ rewrite_data_files → ④ rewrite_manifests
Por qué ① va antes que ③, que es lo que sorprende: la expiración desreferencia ficheros que ya no hacen falta. Si se compacta primero, el motor lee y reescribe ficheros que la expiración iba a tirar acto seguido — se paga cómputo por datos que estaban a punto de ser basura.
⚠️ Y aquí hay que ser honesto: hay tensión en las fuentes. Buena parte de la literatura recomienda el orden inverso (compactar primero, expirar después) porque la compaction genera snapshots obsoletos que conviene limpiar. Los dos razonamientos son válidos y optimizan cosas distintas:
- expirar primero optimiza cómputo (no compactas lo que vas a tirar);
- compactar primero optimiza almacenamiento en la siguiente pasada.
⇒ No se adopta por fe: se mide. El gate de S5·3 corre las dos secuencias sobre una tabla canario y compara ficheros resultantes y tiempo. Es exactamente el mismo criterio que nos ha salvado tres veces esta sesión.
⛔ remove_orphan_files es la peligrosa
Es la única que borra ficheros que el catálogo no conoce. Y un fichero que está siendo escrito por un commit en vuelo es indistinguible de un huérfano.
- La práctica de la industria: esperar 24-48 h antes de retirar huérfanos.
- ⚠️ Y en nuestro caso hay una interacción que no está en ningún manual:
Lakekeeper tiene soft-delete de 7 días (medido, los 9 warehouses). Un
fichero de una tabla soft-borrada sigue en R2 y no está en ningún snapshot
vivo. Hay que verificar que
remove_orphan_filesno lo cuente como huérfano y se cargue la papelera del catálogo.
Cadencia
Streaming: cada 1-3 h. Batch: diaria. Nuestro caso hoy es batch.
4 · Lo que bloquea, en orden
① lakekeeper-spark es reader
Medido en principal_grants: rol reader — SCHEMA_LIST/READ,
TABLE_LIST/READ. No puede escribir, y eso es correcto: S2/S4 sólo pedían
leer.
Decisión (recomendada): tres principals, no uno.
| Principal | Rol | Por qué separado |
|---|---|---|
lakekeeper-spark | reader | consultas y notebooks — lo que ya existe |
lakekeeper-spark-etl | writer | escribe dato de producto |
lakekeeper-spark-maintenance | rol nuevo | reescribe ficheros, no cambia dato lógico |
⚠️ El tercero necesita un rol que hoy no existe en la plantilla. Y la pregunta
incómoda: ¿qué privilegio es «compactar»? En el vocabulario actual es
TABLE_WRITE_DATA, el mismo que borrar filas. Un rol de mantenimiento con
permiso de borrado es un rol que puede vaciar una tabla. Merece su propio
privilegio o su propia frontera.
② El ledger no lo puede abrir un job de Spark
withDmlControlPlane es código de Node: abre dataset_transactions, llama al
motor, y cierra. Un job de Spark commitea por su cuenta, sin pasar por ahí.
Tres opciones, y la tercera es la buena:
- A · el job llama a Carbon para abrir/cerrar la txn → mete a la plataforma en el camino crítico del job y falla feo si Carbon no está;
- B · Node abre la txn, lanza el job, cierra → sirve para lo que Node dispara (mantenimiento por reloj), no para lo que dispare un usuario;
- ⭐ C · el job estampa
snapshot_propertiesyratifyreconcilia → el job es autónomo, el ledger se cierra por observación del catálogo, yattributablepasa atrue. Es exactamente para lo queratifyfue escrito, y hoy no puede hacerlo porque ningún escritor firma.
⇒ C, con B como camino de arranque para el mantenimiento (que ya lo dispara un reloj nuestro).
③ No hay dónde colgar el reloj
SparkAppTemplate no tiene campo de schedule (verificado con
kubectl explain --recursive). Es una plantilla, no un cron.
Opciones: CronJob de k8s que crea SparkApplications, o nuestro propio worker
(lib/workers/, que ya tiene barridos). El worker propio gana, porque el
mantenimiento tiene que consultar Index para saber qué tablas tocar y con qué
política — un CronJob ciego no sabe de inquilinos.
④ iceberg.unique-table-location
Lakekeeper lo documenta como necesario con soft-delete activo, para que una
tabla recreada no choque con la que espera su expiración. Nuestro soft-delete
está activo (7 días). Sin esto, un DROP + CREATE de la misma tabla rompe — y
el fallo aparece días después, cuando expira la vieja.
5 · Las fases, y su gate
| Fase | Gate | |
|---|---|---|
| S5·0 | 🏁 principals + rol de mantenimiento | s5-0-principals-gate.ts — ver §5·bis |
| S5·1 | una escritura que firma | Spark hace INSERT en una tabla canario con snapshot_properties, y ratify devuelve committed — no unverifiable ⭐ |
| S5·2 | mantenimiento en seco | las 4 operaciones sobre el canario, midiendo ficheros/metadata antes y después |
| S5·3 | el orden, medido | las dos secuencias (①③ vs ③①) sobre el mismo canario: ficheros resultantes y tiempo. Se adopta la que gane, no la que diga el blog |
| S5·4 | huérfanos con red | remove_orphan_files con ventana ≥48 h, y el negativo: que NO toque la papelera de soft-delete de Lakekeeper |
| S5·5 | el reloj | worker que consulta Index, respeta la tenencia y no mueve el oráculo de frescura |
⚠️ Todos los gates sobre una tabla canario, nunca sobre las 144. Y con control negativo, porque una operación de mantenimiento que no hace nada es indistinguible de una que funciona si sólo se mira que no lance.
5·bis · S5·0 — HECHO, y lo que se midió
lib/governance/tenant-template.ts + scripts/governance/s5-0-principals-gate.ts
· 2026-08-09
lakekeeper-spark 7 declarados → 7 efectivos reader
lakekeeper-spark-etl 1 declarado → 21 efectivos writer
lakekeeper-spark-maintenance 7 declarados → 7 efectivos maintenance
etl puede TABLE_DROP: SI
maintenance puede TABLE_DROP: no <- la frontera que SI existe
Los tres, con cobertura 8/8 inquilinos. Y los negativos son el gate:
lakekeeper-spark no puede escribir —si pudiera, S2 y S4 habrían medido otra
cosa— y maintenance no puede crear ni soltar tablas.
⚠️ La trampa: declarado ≠ efectivo
principal_grants guarda el privilegio tal y como se concedió. Y los roles no
se declaran igual:
| Rol | Cómo se declara |
|---|---|
writer | un agregado, CATALOG_MANAGE_CONTENT, que implica 21 |
reader · maintenance | hoja por hoja |
⇒ La primera versión del gate leyó la tabla a secas, vio que etl tenía un solo
privilegio y concluyó «los dos roles de escritura no se distinguen — la
separación es decorativa». Rojo falso. El autorizador decide sobre el
conjunto EFECTIVO (expandPrivilege, cierre transitivo), así que el gate también
tiene que hacerlo.
Es la tercera vez en la sesión que un gate pregunta lo que es fácil de consultar en vez de lo que decide el sistema.
La limitación que NO se disfraza
maintenance lleva TABLE_WRITE_DATA, así que puede vaciar una tabla. No
existe un privilegio menor que sirva para compactar — ni en nuestro vocabulario,
ni en Iceberg, ni en Lakekeeper: mecánicamente, compactar y reescribir datos son
la misma capacidad.
Lo que sí se gana, y no es poco: no puede soltarla. Deja el objeto y su historial de snapshots — o sea, deja rastro y deja algo que recuperar.
⇒ La frontera real del mantenimiento no vive en el privilegio: vive en la
identidad (lakekeeper-spark-maintenance, auditable por separado), en el tipo
de asiento del ledger y en las snapshot_properties. Tres capas que existen, en
vez de una que no.
6 · Lo que este approach NO cubre
- La política de retención. Cuántos días de time-travel es una decisión de
producto, y
expire_snapshotsla ejecuta sin opinar. - El coste. Compactar cientos de petabytes no es gratis; dimensionarlo exige medir primero una tabla real.
- La migración del escritor actual.
warehouse-writer(pyiceberg) sigue escribiendo la ingesta. S5 añade un escritor, no lo sustituye — y hay que decidir si acaba sustituyéndolo. - Los
deletesa nivel de fila. Merge-on-read genera delete files que tienen su propia compaction (rewrite_position_delete_files), y su ausencia se nota antes que la de la compaction normal.
7 · Riesgos, por orden
- ⛔
remove_orphan_filescontra la papelera de Lakekeeper. Es el único riesgo de pérdida de datos del plan. Gate S5·4 con negativo explícito. - ⚠️ Un rol de mantenimiento con
TABLE_WRITE_DATApuede vaciar una tabla. - ⚠️ La frescura invalidada por compaction — si el mantenimiento no es legible como tal, todo lo que dependa de frescura se recalcula sin motivo.
- ⚠️
unique-table-locationausente — rompe enDROP+CREATE, y el fallo llega días después.