P·1 — La paginación pasa a ser del RESULTADO
El keystone de legacy-plane-removal.md. Mientras la página sea una propiedad de la fila (
__row_indexestampado en el escritor), esa columna no se puede quitar y la matriz de verbos sigue cerrada en 4/10. Este documento fija qué la sustituye, que el entregable de dimensionado no fijaba.
1 · Los dos hallazgos que deciden el diseño
No hace falta elegir a ciegas: el propio código ya dice qué quiere el producto y qué cuesta hoy el keyset.
1.1 · Las cuatro superficies YA emulan paginación posicional sobre el keyset
Las cuatro pantallas que paginan mantienen, cada una por su cuenta, un array de historial de cursores para poder ofrecer «página 3»:
| Superficie | La emulación |
|---|---|
components/workspace/tabs/datasets/DatasetTab.tsx:838 | cursors[i] = el afterIndex de la página i |
components/sources/explore/actions/DatasetDetailView.tsx:183 | idem, con nav hacia atrás O(1) |
components/sources/explore/ExploreSource.tsx:241 | idem (previewCursors) |
components/workspace/tabs/dashboards/builder/DashboardBuilder.tsx:2062 | idem (history) |
Y la propia ruta ya traduce offset → keyset cuando el cliente no trae cursor (app/api/datasets/route.ts:216):
afterRowIndex: page * pageSize - 1 // "exacto con row_index denso"
Ese comentario es la confesión: la aproximación ya está aceptada en producción. El producto pide una posición; el keyset es la capa que la finge.
⇒ Migrar a posicional no añade una emulación: retira cuatro.
1.2 · Sobre Iceberg el keyset NO es O(log n) — es un scan completo con sort en memoria
services/ml-runner/app/lakehouse/read_service.py:163-173, rama ordenada:
arrow = table.scan(selected_fields=selected, row_filter=row_filter).to_arrow()
arrow = _apply_contains(arrow, contains)
arrow = arrow.sort_by([(ROW_INDEX_COL, "ascending")])
if limit is not None:
arrow = arrow.slice(0, limit)
El limit no se puede empujar al scan cuando hay orden (el comentario del propio fichero lo explica): se materializa todo, se ordena todo, y se corta. Lo único que el keyset aporta de verdad es el file-pruning por min/max de __row_index en el row_filter.
⇒ El argumento «keyset = O(log n), offset = O(n)» no aplica aquí: hoy ya se paga O(n) por página. Cambiar a slice(offset, limit) sobre el mismo scan es coste idéntico menos la exigencia de una columna sintética.
2 · El diseño: una página es una VENTANA de un RESULTADO ORDENADO
La invariante nueva: la paginación no es un atributo de la fila, es un atributo de la consulta. Una fila no sabe en qué página cae; un resultado sí sabe cuál es su fila número k.
Tres piezas, y ninguna necesita que el escritor estampe nada:
① La ventana — offset + limit. Lo que el producto pide y las cuatro superficies emulan. Nativo en PG (.range), nativo en DuckDB (LIMIT/OFFSET), y en el brazo pyiceberg es arrow.slice(offset, limit) sobre el scan que ya se materializa.
② El orden — orderBy, declarado por la consulta. Sin ORDER BY la paginación es indefinida — eso no es una limitación nuestra, es lo que dicen Snowflake, Databricks y BigQuery. Así que el orden se declara, y la puerta lo rellena con la mejor opción disponible:
orderBy explícito del llamante → se usa tal cual
si no, y el descriptor tiene PK → ORDER BY <columnas PK> ← determinismo real
si no → orden de scan (best-effort) ← anclado por ③
La segunda rama es gratis y llega hoy: isPrimaryKey ya vive en datasets.schema y el DatasetHandle ya lo transporta (DatasetDescriptor.schema). 67 de 106 datasets ganan orden determinista sin esperar a P·2.
③ El ancla — snapshotId. La página se sirve contra un snapshot concreto, y el resultado lo devuelve para que la página siguiente pida el mismo. Es lo que hace que hojear sea coherente aunque alguien escriba entre página y página — con el aislamiento por snapshot que Iceberg ya da, en vez del contador monótono que construimos a mano.
Éste es el punto entero de §0.2·4 del entregable: dejamos de emular una propiedad de Postgres y empezamos a usar la capacidad del formato que ya pagamos.
La forma nueva del contrato
export interface OrderBy { column: string; desc?: boolean }
export interface PageQuery {
columns?: string[];
filters?: IcebergFilter[];
/** Posición de inicio DENTRO DEL RESULTADO ordenado (0-based). */
offset?: number;
limit?: number;
/** Orden del resultado. Ausente ⇒ la puerta lo deriva (PK, si la hay). */
orderBy?: OrderBy[];
/** Ancla a un snapshot para que hojear sea coherente (lo devuelve `Page`). */
snapshotId?: number | null;
includeIdentity?: boolean;
signal?: AbortSignal;
}
export interface Page {
rows: Row[];
/** Offset de la página siguiente, o null si no hay más. */
nextOffset: number | null;
/** Snapshot contra el que se sirvió — devolverlo ancla la paginación. */
snapshotId?: number | null;
/** Orden efectivamente aplicado (auto-descripción: el llamante sabe si tuvo
* determinismo o sólo orden de scan). */
orderBy?: OrderBy[];
}
Desaparecen: PageQuery.afterRowIndex, PageQuery.orderByRowIndex, Page.nextCursor.
2·bis · ⚠️ EL ORDEN DE DESPLIEGUE NO ES OPCIONAL
ml-runner tiene que ir ANTES que Next. Al revés rompe la paginación EN SILENCIO.
El modelo de petición de ml-runner es un BaseModel de Pydantic, que ignora los campos que no conoce. Un Node ya desplegado enviando offset/order_by contra un ml-runner viejo no da error: el servidor no ve cursor ni orden, hace un scan sin ordenar con limit = pageSize, y devuelve la primera página otra vez para todas las páginas. Sin 500, sin log, sin nada.
1º services/ml-runner (entiende offset/order_by/snapshot_id)
2º la app Next (deja de mandar after_row_index)
Al revés no hace falta compatibilidad: un ml-runner nuevo con un Node viejo recibe after_row_index, lo ignora igual, y da el mismo fallo. No hay ventana segura — es un despliegue ordenado, no un rollout gradual.
3 · El reparto, fichero a fichero
P·1a — la puerta y el motor
| Fichero | Cambio |
|---|---|
lib/compute/contract.ts | la forma de arriba; ContractVersion → v1.3 |
lib/compute/junction.ts | pgReadPage siempre .range; brazo iceberg pasa offset nativo (muere la emulación de :930-941); deriva orderBy de la PK del descriptor |
lib/lakehouse/read-client.ts | readDatasetPage → offset/orderBy/snapshotId; el cursor deja de leerse de la fila |
services/ml-runner/app/lakehouse/read_service.py | ⚠️ quitar el raise de :134-138; offset/order_by/snapshot_id; build_row_filter pierde after_row_index |
services/ml-runner/app/routers/lakehouse.py | el modelo de petición |
P·1b — los puertos (5 rutas)
app/api/datasets/route.ts · graphs/explorer/data · graphs/explorer/search · model/objects/sync.ts · dataspace/item/sample.
Los tres afterRowIndex: -1 son literalmente offset: 0. El sync.ts pagina un stream completo → offset += BATCH_SIZE.
P·1c — la superficie HTTP + las UI
?afterIndex= y nextCursor se borran del contrato HTTP (no se dejan como alias: suprime el path legacy, no lo dejes como fallback). ?page= y ?pageSize= ya bastan.
Fueron CUATRO superficies, no tres — y una de las que parecían serlo no lo era:
| Superficie | Veredicto |
|---|---|
DatasetTab · DatasetDetailView · DashboardBuilder | pierden el array de cursores |
components/workspace/tabs/mediaset/DataAssetView.tsx | no estaba en el inventario — también paginaba con afterIndex |
components/sources/explore/ExploreSource.tsx | ⚠️ NO es de P·1: su cursors[] lleva los tokens OPACOS de servicios externos (LastEvaluatedKey de DynamoDB, x-ms-continuation de Azure) vía /api/integrations/…/preview. Nada que ver con __row_index. Se queda |
P·1d — el trinquete
check:row-index-seam, con la forma de check:dataset-rows-seam: cuenta las ocurrencias de __row_index por fichero, arranca en lo medido y sólo puede subir a propósito. Verificado que caza: al añadir los tests de la nueva ventana falló señalando el fichero nuevo.
4 · Lo que este paquete NO resuelve (y a quién le toca)
- Direccionar «esta fila» (editar, borrar, el puntero de la ontología,
nodeId = label:row:N) sigue colgando de__row_index/__row_id. Es P·2, y necesita la decisión de producto sobre los 25 datasets sin PK. lib/graphs/kuzu-engine.tsusa__row_indexcomoPRIMARY KEYdel grafo: no lo toca P·1.- Orden determinista para los 25 sin PK: queda en best-effort anclado a snapshot hasta que P·2 decida.
5 · El marcador
__row_index deja de tener lectores de paginación. Con eso, P·3 (el protocolo de reserva de rangos, 8 ficheros) pasa a ser borrado puro, y la matriz de verbos de warehouse-writes-parity.md §1 debería moverse de 4/10 a 8/10 — todo menos el DDL.
6 · Estado — HECHO, y qué queda verificado y qué no
Implementado (2026-07-30): P·1a + P·1b + P·1c + P·1d completos. Contrato de la puerta v1.2 → v1.3.
Verde:
tsc --noEmitlimpio en todo el repo.- 1.319 tests de Node (
lib/compute·lib/warehouse·lib/pipelines·lib/lakehouse), incluidos 9 nuevos sobre la ventana (readDatasetPage: que mandaoffset/order_by/snapshot_idy ningún cursor de fila; quenextOffsetavanza con ventana llena y esnullcon ventana corta; que devuelve el snapshot servido; y que una página son filas de NEGOCIO sin forzarincludeIdentity). - Los tres trinquetes:
dataset-rows-seam,dataset-birthsy el nuevorow-index-seam(48 ficheros · 189 ocurrencias). - Python: compila; los tests con sólo pyarrow pasan.
NO verificado localmente — y hay que decirlo:
- Los tests nuevos de pyiceberg (
test_paged_read_needs_no_identity_column, el paseo poroffset,order_by desc, el ancla de snapshot) se saltan en local: el repo corre Python 3.14 y no hay wheels de pyiceberg. Corren de verdad enml-runner-tests.yml(Python 3.12). El más importante es el primero: es la regresión que prueba que elraisederead_service.py:134ya no existe. - Nada se ha ejecutado contra el ml-runner desplegado, que todavía habla el protocolo viejo — ver §2·bis.
- Latencia: sin medir, ni de lo viejo ni de lo nuevo. §1.2 argumenta que el coste es el mismo porque la rama ordenada ya materializaba y ordenaba el scan entero; eso es lectura de código, no un benchmark.
Cruza con: legacy-plane-removal.md (el entregable medido) · warehouse-writes-parity.md (el marcador) · junction.md (la puerta).