🪨 PIEZA · DATA-STORAGE — el plano de bytes
| Versión | v1.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ón | 2026-08-12 — npx 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
accessKeyIddel token padre en todos los accesos, así que la atribución sólo existe enaccess_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 Storage —
ml-snapshots,ml-artifacts,ml-models, másavatars— 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 inquilino | AWS 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álogo | Es 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
| Bucket | 2.802 objetos · 18,77 MB · 192 directorios de tabla |
| Tablas vivas en el catálogo | 188 (en 13 warehouses), todas format-version 2 · +6 en borrado blando |
| Parquet | 513 ficheros · 8,85 MB · media 17,7 KB |
| Metadata | 2.286 objetos · 9,93 MB (1.271 manifiestos .avro + 1.015 .json) |
| Particionadas | 0 · con sort order 41 · con __row_index en el esquema 127 de 188 |
| Snapshots | 619 · 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 (
…/planno 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én | Qué llave lleva hoy | Alcance |
|---|---|---|
| ⛔ warehouse-writer — el único escritor | estática, read-write | todo el bucket · LAKEHOUSE_REST_VENDING=0 ⇒ no pide credencial a la puerta |
| ⚠️ duck-server | la de sólo lectura (verificado: su huella difiere de la RW) … y además sigue llevando la RW en el entorno | DUCK_S3_SCOPE=s3://lakehouse/ = el bucket entero |
| ✅ ml-runner | ninguna | — |
| ⚠️ Lakekeeper | la suya propia, guardada como storage-credential | el 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ñar | nunca 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_DATA → object-read-only,
TABLE_WRITE_DATA → object-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 rechazaactionsen el firmado local pese a documentarlo —cinco
variantes, cincoInvalidArgument— así que la granularidad real es el preset
scope, no la lista de operaciones.
(2) ElaccessKeyIdacuñado es el del token padre. No es una fuga (el
secreto es otro, y sinsessionTokendaSignatureDoesNotMatch), 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.
| Plano | Qué dice |
|---|---|
Index (tenant_warehouses) | 8 organizaciones con espacio propio |
| Lakekeeper | 12 warehouses de inquilino + el compartido |
| El bucket | 174 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ía | Estado |
|---|---|
DROP TABLE con purga | ✅ SÍ 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 purga | Borrado 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 snapshots | ⛔ Metadata-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érfanos | ⛔ No 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 bucket | ⛔ Una 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:
DROPcon 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ática | Una 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 bucket | duck-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 techo | Un barrido que reduzca el % de huérfanos y no toque una sola location viva |
| ⛔ | Reparar el trinquete del perímetro | npm 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és | Un bucket con jurisdiction: eu para quien lo pida (lib/storage/tenant-bucket.ts, hoy con cero instancias: la cuenta tiene un solo bucket) |
| 🟡 | Formato v3 — deletion vectors no existen en v2, y las 174 tablas son v2 | Decidirlo antes de tener volumen |
Historial
| Versión | Fecha | |
|---|---|---|
| v1.2 | 2026-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.1 | 2026-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.0 | 2026-08-12 | Primera 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 |