Published

🧭 Warehouse · W1.5 — Re-enraizado FÍSICO del namespace Iceberg (Approach)

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

🧭 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 en main.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.* a catalog.schema.* en Lakekeeper.

Estrategia elegida (2026-07-04): Born-in-place + barrido. El resolver deriva catalog.schema ya → todo write nativo NACE en su namespace correcto; barrido metadata-only del puñado de tablas datasets.* 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

HallazgoEvidencia
El move es metadata-only — cero reescritura de Parquetregister_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/writefallback 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 cerradoscope = 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)

  1. pyiceberg 0.8/0.9 NO tiene rename_table (requirements-lakehouse.txt:18 pyiceberg>=0.8,<1.0) → el move es register + drop (adopción), no rename.
  2. 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) en register_table (hoy pasa 2-tuple: register_service.py:69).
  3. Naming identifier-safe (PREREQUISITO): hoy catalogs.name/schemas.name llevan el nombre humano (espacios/mayúsculas/sufijos). Un namespace Iceberg necesita un slug.
  4. La coordinación, no los datos, es el riesgo: cutover por-dataset + iceberg_sync_log.iceberg_table guarda 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). name sigue 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 aceptan namespace arbitrario. Nada más cambia.
  • 2d ya escribe nativo HOY: sync.full/incremental/append, cdc.append, manual.table = defaultMode=iceberg_native (write-flags.ts). Solo pipeline.output/ingest.api = pg. → ya hay tablas en datasets.*; el barrido (W1.5-c) no está vacío.
  • Dos writers NO pasan por la facade: lib/workers/iceberg/sync-worker.ts:27 y lib/dataspaces/lakehouse-landing.ts:18 usan un NAMESPACE constante → 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):

PuntoArchivo:líneaCambio
Resolver (read+write)lib/lakehouse/item-consumption.ts:122 (SELECT) + :102Añ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-placelib/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-facadesync-worker.ts:27, lakehouse-landing.ts:18Reemplazar el NAMESPACE constante por el resolver/born-in-place por-dataset.
ml-runnerwriter.py:519_ensure_namespace parent-first + helper _ns_tuple(). Resto agnóstico.
(verificar)checksum-client.ts:49confirmar que se llama vía facade; si standalone, cablear.

Orden / canary:

  1. Resolver puro — read-only, no-op mientras iceberg_namespace sea NULL en todos. Seguro de mergear solo.
  2. ml-runner _ensure_namespace parent-first.
  3. Born-in-place en manual.table primero (pequeño/estable) → verificar que una tabla nueva NACE en catalog.schema → luego sync.full/cdc.
  4. Cablear los dos non-facade. pipeline.output sigue 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):

  1. Quiescepg_advisory_xact_lock(hashtext(dataset_id)) (el candado del control plane) para no colisionar con un write en vuelo.
  2. Computar to = {catalog.slug}.{schema.slug} desde schema_id.
  3. POST /lakehouse/move-namespace { dataset_id, from_namespace:'datasets', to_namespace: to }.
  4. 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'.
  5. 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:

  1. --dataset <id>un canary → verificar (leer por la facade; row_count idéntico; tabla visible en catalog.schema en Lakekeeper).
  2. --all --limit N — lotes.
  3. 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

RiesgoMitigación
Write en vuelo durante el move de un datasetadvisory lock (quiesce) por dataset en el barrido
Read/write divergen de namespace a mitad de migracióndatasets.iceberg_namespace persistido = fuente única; el flip es 1 UPDATE
create_namespace multi-parte falla en Lakekeepercrear parent-first; idempotente; SOAK antes del flip
iceberg_sync_log.iceberg_table queda stalese 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 registeraceptado (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_namespace seteado.
  • Barrido canary: un dataset existente datasets.ds_x → tras el script, legible por REST en el nuevo ns, iceberg_namespace actualizado, 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_log count 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 slug separada (elegido) o forzar el name a identifier-safe y añadir display_name? La columna slug preserva los nombres humanos ya sembrados.

8. Registro de decisiones

FechaDecisión
2026-07-04Recon 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-04Estrategia Born-in-place + barrido. Persistir datasets.iceberg_namespace para cutover por-dataset.
2026-07-04Namespace 2-nivel catalog.schema confirmado (no aplanado).
2026-07-04W1.5-a aplicada en Supabase (20261233).
2026-07-04W1.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-04W1.5-b IMPLEMENTADA (typecheck verde): resolver puro (item-consumption.ts); born-in-place en 1 puntowithNativeControlPlane 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-04W1.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).