Published

🪨 PIEZA · DATA-STORAGE — el plano de bytes

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

🪨 PIEZA · DATA-STORAGE — el plano de bytes

Versiónv1.2 — ⚠️ corregida dos veces: el censo de v1.0 recorría un solo warehouse, y v1.1 daba por hecho que el catálogo no borra. Ver el historial
Estado🏁 Vivo y en uso · bucket limpio · el aislamiento por inquilino funcionó en test/ mientras el plano de control apunta a prod/
Última medición2026-08-12npx tsx scripts/storage/censo-plano-bytes.ts

Qué es

Un solo bucket de objetos en Cloudflare R2. Es el único sitio de la
plataforma donde el dato del cliente ocupa espacio: todo lo demás —el catálogo,
Index, el grafo— guarda punteros a esto.

Y es la pieza más tonta del stack, deliberadamente: sabe de claves y de bytes, y de nada más. No sabe qué es una tabla, ni una fila, ni un inquilino.

Qué NO es

  • No es el catálogo, y ahí vive la propiedad que lo sostiene todo. Iceberg no logra ACID en el storage: lo logra en el catálogo, con un compare-and-swap sobre un puntero. El bucket no participa en ningún commit — por eso un commit de 1 TB y uno de 1 KB le cuestan lo mismo. Ver ICEBERG-FACE y INDEX-CATALOG.
  • No es el formato. Iceberg decide qué ficheros hay y qué significan; el storage sólo los guarda. Y no decide su propio borrado: quién quita bytes de aquí lo manda el catálogo (§«Quién borra un byte, y quién no»).
  • No es Index. Index dice quién puede pedir la llave; el bucket no sabe quién entra — de hecho no puede saberlo: R2 devuelve el accessKeyId del token padre en todos los accesos, así que la atribución sólo existe en access_events.
  • No es la única superficie de objetos de la plataforma, y esta pieza no cubre la otra. El plano de ML vive en Supabase Storageml-snapshots, ml-artifacts, ml-models, más avatars— con otras llaves, otras rutas y otro ciclo de vida (lib/ml/storage/buckets.ts). Son dos planos de bytes distintos que nadie ha unificado ni ha decidido no unificar.

Su naturaleza — la forma física

Cloudflare R2 · cuenta c1da8061…
  └── bucket «lakehouse»          creado 2026-06-30 · location WEUR · jurisdiction default
        │                          ⚠️ location es una PISTA; jurisdiction es la GARANTÍA
        ├── warehouse/            ← el prefijo COMPARTIDO   2.732 obj · 18,67 MB
        ├── test/w_<workspace>/   ← prefijo por inquilino      68 obj ·  0,11 MB
        ├── _probe-gravitino      ← 1 objeto, de una sonda
        └── edc-test              ← 1 objeto, de un ciclo EDC
                └── <uuid de la tabla>/        ← lo asigna el CATÁLOGO, no nosotros
                      ├── metadata/
                      │     00003-<uuid>.gz.metadata.json   el puntero de la tabla
                      │     <uuid>-m0.avro                  manifiestos
                      └── data/
                            <uuid32>.parquet               ← lo escribe nuestro writer
                            00000-0-<uuid>.parquet         ← lo escribe pyiceberg / Spark

Tres decisiones definen esta forma, y ninguna es nuestra:

Un bucket, prefijo por inquilinoAWS lo llama pool, y documenta el silo —un bucket por cliente— como anti-patrón a escala. La pieza que lo permite es de Lakekeeper: «Buckets can be re-used for multiple Warehouses as long as the key-prefix is different»la frontera del inquilino es un objeto del catálogo, no un recurso de la nube
El nombre del directorio lo pone el catálogoEs el uuid de la tabla, no el del dataset. Saber a qué dataset pertenece un byte exige preguntar — al catálogo o a Index. El bucket, solo, no lo dice
El prefijo lleva el entorno delante (test/, prod/)Porque dev y prod comparten el mismo bucket y el mismo Lakekeeper. Sin ese token, el despliegue de test acuñaría el mismo prefijo que producción y escribiría dentro de él sin que nada fallara (lib/storage/tenant-warehouse.ts)

Lo medido (2026-08-12)

npx tsx scripts/storage/censo-plano-bytes.ts
Bucket2.802 objetos · 18,77 MB · 192 directorios de tabla
Tablas vivas en el catálogo188 (en 13 warehouses), todas format-version 2 · +6 en borrado blando
Parquet513 ficheros · 8,85 MB · media 17,7 KB
Metadata2.286 objetos · 9,93 MB (1.271 manifiestos .avro + 1.015 .json)
Particionadas0 · con sort order 41 · con __row_index en el esquema 127 de 188
Snapshots619 · media 3,3 · p50 = 1 · p90 = 4 · máx 195 · 15 tablas sin ninguno

⭐ El hecho que reencuadra el tamaño: la metadata pesa más que el dato

2.286 objetos de metadata frente a 513 de dato, y 9,93 MB frente a 8,85 MB. Cuatro objetos y medio de contabilidad por cada objeto de contenido.

No es una anomalía: es lo que le pasa a un warehouse ancho y diminuto —174 tablas, mediana de 1 snapshot, fichero medio de 17,7 KB—. Y tiene dos consecuencias que se pagan en sitios distintos:

  • Medir «cuánto pesa el warehouse» y decidir con ese número lleva a sobredimensionar. Un motor escanea Parquet: son 8,85 MB, no 18,77.
  • Planificar una consulta cuesta leer manifiestos, y hay 1.271. Sin server-side scan planning (…/plan no existe en este catálogo), eso lo paga el cliente en cada consulta.

⭐ Y el layout está dimensionado para otro warehouse

services/ml-runner/app/lakehouse/writer.py declara en cada tabla que crea:

TARGET_FILE_SIZE_BYTES = 256 MiB      # write.target-file-size-bytes
ROW_GROUP_SIZE_BYTES   = 128 MiB      # write.parquet.row-group-size-bytes

El fichero medio real es de 17,7 KB: el objetivo declarado es ~15.000 veces mayor que lo que de verdad se escribe. No está mal configurado —está configurado para el warehouse que queremos tener— pero conviene decirlo, porque es una propiedad que hoy no hace nada y se lee como si estuviera actuando.

⚠️ Y sólo 83 de las 188 tablas declaran esas propiedades: son las nacidas por _create_native_table. Las otras 105 nacieron por otras vías y no llevan layout declarado de ninguna clase. Lo mismo con la retención: 33 de 188 declaran política, y el job de retención avisa de ello en cada ciclo.


El régimen de llaves — quién puede tocar los bytes

La regla: el storage tiene un perímetro; sólo el cómputo entra. Todo lo demás pide por la puerta. No es invento nuestro: ni Databricks ni Snowflake delegan credenciales al motor del cliente — tienen el cómputo del lado del servicio, que es la misma respuesta.

Medido hoy, servicio por servicio:

npx @railway/cli variables -s warehouse-writer --kv | grep -E '^LAKEHOUSE_S3_|^LAKEHOUSE_REST_VENDING'
npx @railway/cli variables -s Duck             --kv | grep -E '^LAKEHOUSE_S3_|^DUCK_S3_SCOPE'
npx @railway/cli variables -s ml-runner        --kv | grep -E '^LAKEHOUSE_S3_'
QuiénQué llave lleva hoyAlcance
warehouse-writer — el único escritorestática, read-writetodo el bucket · LAKEHOUSE_REST_VENDING=0no pide credencial a la puerta
⚠️ duck-serverla de sólo lectura (verificado: su huella difiere de la RW) … y además sigue llevando la RW en el entornoDUCK_S3_SCOPE=s3://lakehouse/ = el bucket entero
ml-runnerninguna
⚠️ Lakekeeperla suya propia, guardada como storage-credentialel 4º sitio, y el que se olvida: rotar sin actualizarla rompe toda escritura, y el síntoma engaña (LIST tables → 200 mientras cualquier commit da Unauthorized)
la puerta (Vercel)el token padre de R2, para acuñarnunca sale de ahí

⚠️ La llave del warehouse-writer y la llave RW de duck-server son la misma (misma huella). Y no es la que documenta .env.local: esa está muerta desde el 2026-08-06 (401 en un simple LIST) — lo cual cierra en positivo la duda que quedaba abierta: la ruta de escritura de producción no depende de ella.

⭐ La máquina de acuñar existe, funciona, y no la usa ningún escritor

lib/governance/credential-vending.ts + lib/governance/r2-local-sign.ts: credencial temporal acotada por prefijo, con TTL, firmada en proceso en ~1 ms —sin llamada a Cloudflare, sin consumir su cuota de 1.200 req/5 min por cuenta—. El privilegio decide el alcance (TABLE_READ_DATAobject-read-only, TABLE_WRITE_DATAobject-read-write), y queda asiento en access_events.

Hoy su único cliente es loadTable a través de la cara Iceberg — o sea, Spark leyendo. Los dos servicios que sostienen la ingesta y el serving siguen con llave estática de bucket entero.

⭐⭐ Dos límites medidos que conviene no volver a descubrir:
(1) R2 rechaza actions en el firmado local pese a documentarlo —cinco
variantes, cinco InvalidArgument— así que la granularidad real es el preset
scope, no la lista de operaciones.
(2) El accessKeyId acuñado es el del token padre. No es una fuga (el
secreto es otro, y sin sessionToken da SignatureDoesNotMatch), pero
significa que en los logs de R2 todos los inquilinos se ven iguales.


⛔⛔ El aislamiento por inquilino: funcionó en test/, y el plano de control mira a prod/

Éste es el hallazgo de la pieza, y sólo se ve mirando los tres planos a la vez.

PlanoQué dice
Index (tenant_warehouses)8 organizaciones con espacio propio
Lakekeeper12 warehouses de inquilino + el compartido
El bucket174 de 188 tablas vivas en el prefijo compartido · 14 en el de su inquilino
  dónde viven de verdad los bytes de las tablas vivas
     174 tablas → s3://lakehouse/warehouse/
      11 tablas → s3://lakehouse/test/w_cccc70a526ff412aba9299970b49c341/
       3 tablas → s3://lakehouse/test/w_7b500f4dcd7b44be8850f44eaae192d7/

El mecanismo funciona: hay 14 tablas vivas en el espacio de su organización. Lo que no encaja es dónde: las 14 están bajo el token de entorno test/, en los dos únicos warehouses que Index no registra — mientras las filas de Index para esas mismas organizaciones apuntan a warehouses prod/….

El aislamiento no está sin estrenar: está estrenado en un sitio y contabilizado
en otro
. Y las tablas que sí lo ejercen viven en warehouses que el plano de
control no conoce, así que ningún inventario suyo las ve.

Por qué no ha explotado, y qué lo haría explotar

El conmutador es una columna: tenant_warehouses.dedicated_storage. Sin ella —vía isDedicated()— la puerta devuelve null y todo cae al prefijo compartido.

  cccc70a5   aplica=true    0 datasets
  ec14e3cc   aplica=true    0 datasets
  0936227f   aplica=true    0 datasets
  7b500f4d   aplica=FALSE   123 datasets   ← la única organización con datos
  (+4 más, aplica=true, 0 datasets)

⭐⭐⭐ El aislamiento está ARMADO en siete organizaciones vacías y DESARMADO
justo en la única que tiene datos.
Por eso nada se ha roto.

Y si alguien pone esa columna a true sin mover los bytes antes, el cinturón de seguridad —locationWithinTenant, que existe para impedir que una llave se acuñe fuera del inquilino— negará todas las lecturas de sus 123 tablas con out-of-tenant, porque sus bytes están en warehouse/. El cinturón funcionaría exactamente como está escrito; el problema es que el estado que protege no es el estado real.

⛔ Y los dos planos ya no dicen lo mismo

En 4 de las 8 filas, Index y Lakekeeper discrepan sobre dónde va ese inquilino — y el warehouse_id de la fila apunta al warehouse de Lakekeeper que dice lo contrario:

  cccc70a5  index=test/w_cccc70a5…   lakekeeper=prod/w_cccc70a5…   ⛔ DISCREPA  aplica=true
  7b500f4d  index=test/w_7b500f4d…   lakekeeper=prod/w_7b500f4d…   ⛔ DISCREPA  aplica=false
  ec14e3cc  index=test/w_ec14e3cc…   lakekeeper=prod/w_ec14e3cc…   ⛔ DISCREPA  aplica=true
  0936227f  index=test/w_0936227f…   lakekeeper=prod/w_0936227f…   ⛔ DISCREPA  aplica=true

Una reprovisión bajo LAKEHOUSE_ENV=prod actualizó el nombre y el id de la fila y no su key_prefix. La consecuencia es concreta: la tabla nacería en prod/w_… (lo decide Lakekeeper) y el cinturón la compararía contra test/w_… (lo decide Index) ⇒ denegada por su propia protección.

⛔⛔ Y es peor: esos cuatro warehouses no tienen ninguna concesión. (Medido en la capa 4 de CATALOG-STORE.) Su única asignación es operator: ownership — el writer, la cara y duck reciben 404 al asomarse. Si dedicated_storage se activara, la ingesta fallaría con «no existe», que es el mensaje que más tiempo cuesta diagnosticar.

⇒ Además quedan 4 warehouses carbon-test-w-* vivos en Lakekeeper que Index no registra: nadie los alcanza y nadie los va a limpiar. (El quinto no registrado es lakehouse, el compartido, y ése es a propósito.)


Quién borra un byte, y quién no

Las vías por las que el almacenamiento podría liberarse, y su estado real:

VíaEstado
DROP TABLE con purgaSÍ borra los objetos. (Corregido el 2026-08-12 por la capa 3 de CATALOG-STORE: 14 de 14 tablas expiradas se quedaron sin un solo objeto, con control positivo.) push-s3-delete-disabled: true no significa «no borra» — y w1-sort-order-repair.ts ya lo advertía en el repo
DROP TABLE sin purgaBorrado blando de 7 días en el registro del catálogo. ⛔ Y ese colchón hoy está vacío: las 6 tablas dentro de la ventana no tienen bytes
Expiración de snapshotsMetadata-only. El job existe (lib/workers/lakehouse/retention-scheduler.ts, diario, opt-in, arranca en dry-run), pero en pyiceberg 0.11.1 expirar quita snapshots de la lista y no borra un fichero. Acota la ventana recuperable y el tamaño del metadata.json, no el bucket
Compactación / limpieza de huérfanosNo existe el mecanismo. Existe el vocabulario: compaction y orphan_cleanup son tipos de política con su esquema en lib/governance/policy.ts — se pueden declarar y no los ejecuta nadie
Ciclo de vida del bucketUna sola regla, el aborto de multipart a 7 días. Ninguna de expiración
# la regla del bucket, tal cual
curl -sH "authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/r2/buckets/lakehouse/lifecycle"

⭐⭐ Hay exactamente UNA vía que libera bytes: DROP con purga. Todo lo demás
—expirar snapshots, compactar, recoger huérfanos, el ciclo de vida del bucket—
no quita nada. Y como el catálogo no puede listar lo que ya no le
pertenece
, el único censo que encuentra lo suelto es bucket ∖ locations
vivas
— el ② del script de esta pieza.

⚠️ El corolario incómodo: la única vía que borra es la irreversible, y la que
debería ser reversible —el borrado blando de 7 días— hoy no protege nada.

Huérfanos hoy: 4 objetos sueltos · 0,00 MB. Ni uno es una tabla: son un marcador de directorio y tres restos de sonda (_probe-gravitino/, edc-test/). El bucket está limpio. Eran el 16 % el 2026-08-06; la purga manual funcionó.

⛔⛔ Y el colchón de recuperación no existe. El catálogo tiene 6 tablas en borrado blando que expiran el 2026-08-13 y se compromete a poder restaurarlas hasta entonces: ninguna de las seis conserva un byte. Un undrop devolvería el registro y no el dato. Ver CATALOG-STORE §capa 3·③.


⛔ El trinquete del perímetro está en rojo

npm run check:storage-perimeter     # ✗ 17 ficheros

De los 17, 10 existen ya en main (verificado sobre los blobs de main), y el check corre en CI (.github/workflows/ci.yml:61).

Y la causa no es una fuga: es que la definición del perímetro caducó. El script declara que sólo services/ml-runner/app/lakehouse/ y services/duck-server/app/ pueden nombrar credenciales de storage — y desde entonces (a) la puerta se convirtió en la máquina de acuñar, así que lib/governance/storage-vending.ts y lib/storage/tenant-warehouse.ts las nombran con toda razón, y (b) esos dos servicios crecieron tests, que caen fuera del app/.

Un trinquete que todo el mundo sabe que está rojo deja de ser un trinquete.
Arreglarlo es ampliar el perímetro a propósito —tests incluidos, la puerta
incluida— y volver a cero, para que el siguiente rojo signifique algo.


Lo que falta — con su gate

QuéGate
⛔⛔Reconciliar las 4 filas que discrepan (key_prefix de Index ← el key-prefix real de Lakekeeper)El ③ del censo: 8 de 8 casa
⛔⛔Decidir el aislamiento físico: hay 14 tablas aisladas en test/…, en warehouses que Index no registra, mientras sus filas apuntan a prod/…. O se adoptan esos warehouses o se rehace el alta en los prod/Una tabla viva bajo s3://lakehouse/prod/w_…/, leída por la puerta, con asiento en access_events
⛔⛔Que el warehouse-writer pida la llave a la puerta (LAKEHOUSE_REST_VENDING=1) y se le retire la estáticaUna ingesta que escriba con credencial acuñada, y la llave estática revocada sin que la ingesta falle
Quitar la llave RW del entorno de duck-server y acotar DUCK_S3_SCOPE por debajo del bucketduck-ro-credential-gate con control negativo: escritura rechazada
Alguien que recoja los ficheros que ya no referencia nadie — el único hueco donde el almacenamiento crece sin techoUn barrido que reduzca el % de huérfanos y no toque una sola location viva
Reparar el trinquete del perímetronpm run check:storage-perimeter verde en main
🟡Residencia: el bucket es location: WEUR pero jurisdiction: default. La pista está; la garantía no — y la jurisdicción no se puede cambiar despuésUn bucket con jurisdiction: eu para quien lo pida (lib/storage/tenant-bucket.ts, hoy con cero instancias: la cuenta tiene un solo bucket)
🟡Formato v3deletion vectors no existen en v2, y las 174 tablas son v2Decidirlo antes de tener volumen

Historial

VersiónFecha
v1.22026-08-12⚠️ Segunda corrección, desde la capa 3 de CATALOG-STORE. «Nada borra un byte» era falso: DROP con purga SÍ borra los objetos —14 de 14 medidas, con control positivo— y push-s3-delete-disabled: true no significa lo que asumí. La advertencia ya estaba escrita en scripts/governance/w1-sort-order-repair.ts: contradije una nota del propio repo por no buscarla. La imagen correcta: una sola vía libera bytes, y es la irreversible; mientras, el borrado blando de 7 días no protege nada porque las 6 tablas en su ventana ya no tienen bytes
v1.12026-08-12⚠️ Corrección medida, el mismo día. El censo de v1.0 pedía el prefix de un warehouse y recorría sólo ése ⇒ vio 174 de 188 tablas, y las 14 que faltaban eran justo las que sí viven en el prefijo de su inquilino. De ahí salió una conclusión falsa —«ninguna tabla está en el espacio de su organización»— y 18 falsos huérfanos que eran tablas vivas. Es la trampa del namespace equivocado, un nivel más arriba: medir un warehouse y llamarlo el catálogo. Corregido el script (recorre los 13) y las cifras: huérfanos reales 4 objetos, 0,00 MB; el hallazgo pasa a ser el aislamiento se ejerció en test/ mientras el plano de control apunta a prod/
v1.02026-08-12Primera imagen trazada y medida, con censo reproducible (scripts/storage/censo-plano-bytes.ts). Tres hallazgos: la metadata pesa más que el dato (2.286 objetos vs 513); ni una de las 174 tablas vivas está en el prefijo de su inquilino, y el aislamiento está armado en 7 organizaciones vacías y desarmado en la única con datos; y el trinquete del perímetro lleva rojo en main porque su definición caducó, no porque haya una fuga