🧭 Warehouse · W1.5 — Re-enraizado FÍSICO del namespace Iceberg (Approach)
⚠️ PARCIALMENTE OBSOLETO (predata 2026-07-09). Estado real y vigente:
docs/INFRA.md. El namespace físico ya está mayormente en canónico (78 tablas enmain.default). Correcciones: catálogo activo = Lakekeeper (type=rest); SqlCatalog RETIRADO (P2.5). Queda como registro de diseño.
Approach de la Fase W1.5. Padre: warehouse-reorg.md. Sigue a W1 (re-enraizado lógico, hecho). Aquí movemos el namespace físico de las tablas Iceberg de
datasets.*acatalog.schema.*en Lakekeeper.Estrategia elegida (2026-07-04): Born-in-place + barrido. El resolver deriva
catalog.schemaya → todo write nativo NACE en su namespace correcto; barrido metadata-only del puñado de tablasdatasets.*existentes. No se mueve dos veces (coordina con el write nativo de 2d, casi por estrenar).Grounded en reconocimiento de campo (2 scouts, 2026-07-04) — el detalle con file:line abajo.
1. La buena noticia del recon: es mucho menos invasiva de lo que suena
| Hallazgo | Evidencia |
|---|---|
| El move es metadata-only — cero reescritura de Parquet | register_table((ns, name), metadata_location) reusa el metadata.json en R2 → services/ml-runner/app/lakehouse/register_service.py:57,69,84-90 |
| Namespace NO persistido — derivado on-the-fly, simétrico read/write | fallback caller → LAKEHOUSE_NAMESPACE → 'datasets': lib/lakehouse/read-client.ts:89, ingest-client.ts:59, item-consumption.ts:102 |
| La identidad ya está parametrizada — 2 funciones, namespace es param | {namespace}.ds_{id} en services/ml-runner/app/lakehouse/writer.py:416 + read_service.py:36; los endpoints /lakehouse/* aceptan namespace |
| Footprint físico pequeño / canary — 2d aún no cerrado | scope = SELECT dataset_id, iceberg_table FROM iceberg_sync_log WHERE status='succeeded' AND iceberg_table IS NOT NULL (20261223_iceberg_dual_write.sql:89) |
ItemRef.namespace ya existe (opcional) | lib/lakehouse/consumption-contract.ts:69 |
2. Los cuatro riesgos reales (que este approach resuelve)
- pyiceberg 0.8/0.9 NO tiene
rename_table(requirements-lakehouse.txt:18pyiceberg>=0.8,<1.0) → el move esregister + drop(adopción), no rename. - Namespace multi-nivel (
catalog.schema) solo en Lakekeeper/RestCatalog, no SqlCatalog; hay que crearlo explícito (_ensure_namespace,writer.py:519) y construir el tuple(*partes, tabla)enregister_table(hoy pasa 2-tuple:register_service.py:69). - Naming identifier-safe (PREREQUISITO): hoy
catalogs.name/schemas.namellevan el nombre humano (espacios/mayúsculas/sufijos). Un namespace Iceberg necesita un slug. - La coordinación, no los datos, es el riesgo: cutover por-dataset +
iceberg_sync_log.iceberg_tableguarda el identificador viejo.
3. Decisión de diseño clave: persistir el namespace por dataset
El recon dice que hoy el namespace se deriva on-the-fly. Para un cutover por-dataset atómico y sin divergencia read/write durante el barrido, lo invertimos: persistimos el namespace físico real de cada dataset en una columna nueva.
datasets.iceberg_namespace text NULL -- NULL = legacy 'datasets'
- Resolver (único, read+write):
namespace = datasets.iceberg_namespace ?? 'datasets'. Siempre apunta a donde la tabla vive de verdad → cero divergencia. - Born-in-place: al crear una tabla nativa, se setea
iceberg_namespace = slug(catalog).slug(schema). - Cutover del barrido: register en el nuevo ns → UPDATE de una fila (
iceberg_namespace = nuevo) = el flip atómico → drop del viejo.
Esto convierte "no persistido + derivar en dos sitios" en "un choke point + un flip de una fila". Trade-off deliberado para la migración.
4. Fases
W1.5-a — Prerequisito: naming + columna (migración 20261233) ✅ (escrita, pendiente aplicar)
catalogs.slug/schemas.slug— identifier-safe (^[a-z0-9][a-z0-9_]*$),UNIQUE(workspace_id, slug)/UNIQUE(catalog_id, slug). Backfill = slugify(name) con desambiguación (mismo patrón que W0).namesigue siendo el humano (display).datasets.iceberg_namespace text NULL.- Trigger update (extiende T1/T2 de W1): al crear catalog/schema, generar slug.
- Aditiva/inerte. No cambia rutas ni escritura.
W1.5-b — Resolver + born-in-place ✅ (implementada — typecheck verde)
Hallazgos del recon que ajustan el plan:
- ml-runner = 1 función.
_ensure_namespace(services/ml-runner/app/lakehouse/writer.py:519) → parent-first + helper_ns_tuple(). pyiceberg acepta el identificador dotted (main.ventas.ds_x) en load/create/drop/register/list; los endpoints/lakehouse/*ya aceptannamespacearbitrario. Nada más cambia. - 2d ya escribe nativo HOY:
sync.full/incremental/append,cdc.append,manual.table=defaultMode=iceberg_native(write-flags.ts). Solopipeline.output/ingest.api=pg. → ya hay tablas endatasets.*; el barrido (W1.5-c) no está vacío. - Dos writers NO pasan por la facade:
lib/workers/iceberg/sync-worker.ts:27ylib/dataspaces/lakehouse-landing.ts:18usan unNAMESPACEconstante → cablearlos al resolver también.
Corrección de diseño (seguridad) — resolver PURO: namespace = datasets.iceberg_namespace ?? 'datasets', SIN derivar de schema_id. Derivar en lectura rompería las tablas existentes (apuntaría a un catalog.schema inexistente físicamente). La derivación schema_id → {catalog.slug}.{schema.slug} ocurre solo al nacer y se persiste. iceberg_namespace es la única fuente de verdad.
Born-in-place — SOLO en el primer snapshot (tabla que aún no existe). Si el dataset YA tiene tabla física en datasets.* (sync/cdc/manual vivos), NO se toca iceberg_namespace → sigue en datasets hasta que el barrido lo mueva. Señal de "primer write": el ml-runner reporta created (create vs open); fallback TS = ausencia de iceberg_sync_log succeeded previo.
Superficie de cambio (medida):
| Punto | Archivo:línea | Cambio |
|---|---|---|
| Resolver (read+write) | lib/lakehouse/item-consumption.ts:122 (SELECT) + :102 | Añadir iceberg_namespace al SELECT del dataset; namespace = ref.namespace ?? opts.namespace ?? ds.iceberg_namespace ?? 'datasets'. Fluye al descriptor → todos los clients, read y write. |
| Born-in-place | lib/lakehouse/iceberg-native-write.ts (withNativeControlPlane, UPDATE stats ~:189) | Si primer snapshot: computar {catalog.slug}.{schema.slug} desde schema_id, pasarlo como namespace al ingest, y persistir en datasets.iceberg_namespace atómico con el commit (idempotente WHERE iceberg_namespace IS NULL). |
| Non-facade | sync-worker.ts:27, lakehouse-landing.ts:18 | Reemplazar el NAMESPACE constante por el resolver/born-in-place por-dataset. |
| ml-runner | writer.py:519 | _ensure_namespace parent-first + helper _ns_tuple(). Resto agnóstico. |
| (verificar) | checksum-client.ts:49 | confirmar que se llama vía facade; si standalone, cablear. |
Orden / canary:
- Resolver puro — read-only, no-op mientras
iceberg_namespacesea NULL en todos. Seguro de mergear solo. - ml-runner
_ensure_namespaceparent-first. - Born-in-place en
manual.tableprimero (pequeño/estable) → verificar que una tabla nueva NACE encatalog.schema→ luegosync.full/cdc. - Cablear los dos non-facade.
pipeline.outputsigue su canary de 2d aparte.
Riesgo residual a verificar en el primer canary: register_table((dotted_ns, name)) — el scout dice que pyiceberg parsea el dotted; confirmarlo ANTES del barrido masivo (W1.5-c).
W1.5-c — Barrido metadata-only (script scripts/warehouse-namespace-sweep.ts, canary-first) 📐 (redactado 2026-07-04)
Prerequisito: W1.5-b desplegado (resolver + born-in-place + _ns_tuple/parent-first) y ≥1 canary de born-in-place verificado.
Scope (enumeración) — solo tablas físicas en el namespace legacy aún sin mover:
SELECT DISTINCT l.dataset_id, l.iceberg_table, d.schema_id
FROM iceberg_sync_log l
JOIN datasets d ON d.id = l.dataset_id
WHERE l.status = 'succeeded'
AND l.iceberg_table LIKE 'datasets.%'
AND d.iceberg_namespace IS NULL -- aún no born/movido
AND d.schema_id IS NOT NULL; -- tiene destino catalog.schema
(Los born-in-place ya tienen iceberg_namespace ≠ NULL → excluidos. Los shadow/PG sin schema_id → excluidos.)
Endpoint nuevo del ml-runner POST /lakehouse/move-namespace (modelado sobre register_service.py), metadata-only:
body: { dataset_id, from_namespace, to_namespace }
1. src = catalog.load_table(f"{from_namespace}.ds_{id}") → metadata_location
2. _ensure_namespace(catalog, to_namespace) # parent-first (W1.5-b)
3. catalog.register_table(_ns_tuple(to_namespace) + (f"ds_{id}",), metadata_location)
4. SOAK: catalog.load_table(f"{to_namespace}.ds_{id}").scan(limit=1).to_arrow()
5. catalog.drop_table(f"{from_namespace}.ds_{id}") # solo el puntero; Parquet intacto
→ { moved: true, identifier: f"{to_namespace}.ds_{id}", rows_soak }
Verificar en el PRIMER canary que
register_table((dotted_ns, name))traga el namespace dotted (el scout lo afirma; confirmarlo antes del lote).
El script (Node, idempotente/resumable, por dataset):
- Quiesce —
pg_advisory_xact_lock(hashtext(dataset_id))(el candado del control plane) para no colisionar con un write en vuelo. - Computar
to = {catalog.slug}.{schema.slug}desdeschema_id. POST /lakehouse/move-namespace { dataset_id, from_namespace:'datasets', to_namespace: to }.- Flip (una tx):
UPDATE datasets SET iceberg_namespace = :to WHERE id = :id AND iceberg_namespace IS NULL+UPDATE iceberg_sync_log SET iceberg_table = replace(iceberg_table, 'datasets.', :to || '.') WHERE dataset_id = :id AND status='succeeded'. - Falla de un dataset → se registra y se salta (no aborta el lote).
Idempotencia/resumabilidad: el filtro iceberg_namespace IS NULL hace que un re-run salte lo ya movido. El endpoint es idempotente (register sobre already-registered = no-fatal; si el viejo ya está dropeado → load(from) falla → saltar directo al flip).
Orden de ejecución:
--dataset <id>— un canary → verificar (leer por la facade;row_countidéntico; tabla visible encatalog.schemaen Lakekeeper).--all --limit N— lotes.- Verificación final:
SELECT count(*) FROM iceberg_sync_log WHERE iceberg_table LIKE 'datasets.%' AND status='succeeded'→ 0.
Seguridad: metadata-only (Parquet intacto); candado evita carreras; el flip solo sobre iceberg_namespace IS NULL; el drop es solo el puntero de catálogo. Si algo falla a mitad, el dataset se queda en datasets (iceberg_namespace NULL) → el resolver sigue apuntando bien → re-run seguro.
Coordinación con 2d: W1.5-b ya aterrizó en el path nativo compartido (withNativeControlPlane) → el canary de 2d nace en catalog.schema sin mover dos veces.
5. Riesgos y mitigaciones
| Riesgo | Mitigación |
|---|---|
| Write en vuelo durante el move de un dataset | advisory lock (quiesce) por dataset en el barrido |
| Read/write divergen de namespace a mitad de migración | datasets.iceberg_namespace persistido = fuente única; el flip es 1 UPDATE |
create_namespace multi-parte falla en Lakekeeper | crear parent-first; idempotente; SOAK antes del flip |
iceberg_sync_log.iceberg_table queda stale | se actualiza en el flip (paso 5) |
| Slug colisiona (dos schemas "ventas") | desambiguación en el backfill (sufijo id), UNIQUE(catalog_id, slug) |
| Data files quedan bajo el path R2 viejo tras register | aceptado (metadata-only por diseño; la location vive en el metadata.json) |
| pyiceberg sube a 1.0 (rename real) | el approach no depende de rename; register+drop sigue válido |
6. Verificación
- Born-in-place: crear un dataset nativo nuevo → su tabla aparece en
catalog.schema.*en Lakekeeper;datasets.iceberg_namespaceseteado. - Barrido canary: un dataset existente
datasets.ds_x→ tras el script, legible por REST en el nuevo ns,iceberg_namespaceactualizado, viejo dropeado, row_count idéntico (metadata-only, sin pérdida). - Read/write E2E sobre un dataset migrado (facade
.read()+.write()) → resuelven el nuevo namespace. - Enumerar antes/después:
iceberg_sync_logcount por namespace.
7. Decisiones abiertas (W1.5)
- ¿Namespace 2-nivel (
catalog.schema) o aplanado (catalog__schema)? Multi-nivel es más limpio y Lakekeeper lo soporta; aplanado evita el split-a-tuple en el ml-runner. Recomendación: 2-nivel (fiel a UC), asumiendo el ajuste del tuple. - ¿El barrido ahora o esperar a que 2d cierre? Como el footprint es pequeño, el barrido es barato en cualquier momento; pero born-in-place (W1.5-b) debería aterrizar CON 2d.
- Slug vs enforce identifier-safe en el name: ¿columna
slugseparada (elegido) o forzar elnamea identifier-safe y añadirdisplay_name? La columna slug preserva los nombres humanos ya sembrados.
8. Registro de decisiones
| Fecha | Decisión |
|---|---|
| 2026-07-04 | Recon W1.5 (2 scouts): move = metadata-only (register_table); namespace no persistido (2 fns ml-runner); pyiceberg 0.8 sin rename; footprint pequeño via iceberg_sync_log. |
| 2026-07-04 | Estrategia Born-in-place + barrido. Persistir datasets.iceberg_namespace para cutover por-dataset. |
| 2026-07-04 | Namespace 2-nivel catalog.schema confirmado (no aplanado). |
| 2026-07-04 | W1.5-a aplicada en Supabase (20261233). |
| 2026-07-04 | W1.5-b recon + redactado (2 scouts): ml-runner=1 fn (_ensure_namespace parent-first); 2d ya nativo (sync/cdc/manual); resolver PURO (iceberg_namespace ?? 'datasets', sin derivar en lectura); born-in-place solo en primer snapshot. |
| 2026-07-04 | W1.5-b IMPLEMENTADA (typecheck verde): resolver puro (item-consumption.ts); born-in-place en 1 punto — withNativeControlPlane muta opts.namespace (las closures lo leen al invocar) + persiste iceberg_namespace en el commit → cubre snapshot/fanout/parquet, facade y EDC-landing; ml-runner _ensure_namespace parent-first + _ns_tuple. sync-worker (shadow dual-write) NO tocado a propósito. |
| 2026-07-04 | W1.5-c REDACTADA: barrido metadata-only + endpoint /lakehouse/move-namespace; enumeración por iceberg_sync_log; idempotente/resumable; canary-first. Sin implementar (es la migración de datos → canary del usuario). |