Published

S5 · Approach — escritura por Spark y mantenimiento del lakehouse

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...

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 / productoMantenimiento
Qué hacecambia el DATO lógicoreescribe FICHEROS, el dato lógico es idéntico
Quién lo pideun pipeline, un usuarioun reloj
Riesgocorrupción de datos de clienteborrado de ficheros aún referenciados
Frescuradebe mover el oráculono debe moverlo
LedgerAPPEND/UPDATE/DELETEtipo 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_files no 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 readerSCHEMA_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.

PrincipalRolPor qué separado
lakekeeper-sparkreaderconsultas y notebooks — lo que ya existe
lakekeeper-spark-etlwriterescribe dato de producto
lakekeeper-spark-maintenancerol nuevoreescribe 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_properties y ratify reconcilia → el job es autónomo, el ledger se cierra por observación del catálogo, y attributable pasa a true. Es exactamente para lo que ratify fue 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

FaseGate
S5·0🏁 principals + rol de mantenimientos5-0-principals-gate.ts — ver §5·bis
S5·1una escritura que firmaSpark hace INSERT en una tabla canario con snapshot_properties, y ratify devuelve committedno unverifiable
S5·2mantenimiento en secolas 4 operaciones sobre el canario, midiendo ficheros/metadata antes y después
S5·3el orden, medidolas dos secuencias (①③ vs ③①) sobre el mismo canario: ficheros resultantes y tiempo. Se adopta la que gane, no la que diga el blog
S5·4huérfanos con redremove_orphan_files con ventana ≥48 h, y el negativo: que NO toque la papelera de soft-delete de Lakekeeper
S5·5el relojworker 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:

RolCómo se declara
writerun agregado, CATALOG_MANAGE_CONTENT, que implica 21
reader · maintenancehoja 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_snapshots la 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 deletes a 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

  1. remove_orphan_files contra la papelera de Lakekeeper. Es el único riesgo de pérdida de datos del plan. Gate S5·4 con negativo explícito.
  2. ⚠️ Un rol de mantenimiento con TABLE_WRITE_DATA puede vaciar una tabla.
  3. ⚠️ La frescura invalidada por compaction — si el mantenimiento no es legible como tal, todo lo que dependa de frescura se recalcula sin motivo.
  4. ⚠️ unique-table-location ausente — rompe en DROP+CREATE, y el fallo llega días después.

8 · Fuentes