dec-0106

Vista generada de dec-0106: ExtentMap: disponibilidad parcial por rangos, y FETCH de objetos publicados por el browser

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0106-extentmap-disponibilidad-parcial.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0106-extentmap-disponibilidad-parcial.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoPROPOSED
Fecha2026-09-28
Ficherodocs/decisions/dec-0106-extentmap-disponibilidad-parcial.md

Por qué importa (del frontmatter del ADR):

Fija el CONTRATO del ExtentMap que dec-0021/dec-0023 (r26 §2) declararon y que tiene 0 definiciones en código. Gobierna dos huecos abiertos del track/byte-runtime que son la misma pieza: R-13 (LocalFileSource fija size_bytes = stat.size al abrir y trata todo offset posterior como EOF) y el deferral (b) de R-02 (los objetos MoQT publicados por el browser no se indexan, así que un FETCH tardío recibe FETCH_ERROR). Sin este ADR, cada consumidor inventaría su propio modelo de disponibilidad.

Nodos del roadmap que lo citan en refs: ninguno.

Páginas de la documentación que lo citan: Transferencias (especificado)

Texto del ADR

Leído de docs/decisions/dec-0106-extentmap-disponibilidad-parcial.md, el fichero canónico.

dec-0106 — ExtentMap: disponibilidad parcial por rangos, y FETCH de objetos publicados por el browser

  • Fecha: 2026-09-28
  • Estado: PROPOSED. No es un lock: requiere AskUserQuestion a waxin. Qué se decide ya y qué no: la existencia de ExtentMap/AvailabilityMap ya está lockeada por dec-0023 (r26-L2c) y la de ByteSource con size/availability separados por dec-0021 (r26-L2a). Este ADR propone el contrato: estados, API, productores, consumidores e interacción con cancel, cache y zkit.
  • Propone: builder-dataplane (lane br-design, rama w2/br-design).
  • Cita: r04/r20 §3.6 (cancel(requestId) por request), r12/r45 (cache por tiers, eviction por coste de regeneración), r16 (frontier), r26 §2 (Byte Runtime), r28 (consumer real), r39 (perfiles VOD/live), C7 (BackingState con razón), dec-0103 (zkit), dec-0104 (VOD sin pérdida).
  • Plan de ejecución propuesto: docs/track/byte-runtime/plans/m7-extentmap-fetch-browser-published.plan.md.

Contexto: dos síntomas, una pieza

Citas en 03ea232 (M4 integrada). La migración a quic-zig upstream (w2/quiczig-migration) reescribe media-daemon/moqt/**. Donde cambia la línea se cita también la rama.

R-13: tamaño = disponibilidad, y el fichero es finito. LocalFileSource.open guarda size_bytes = stat.size una vez (media-core/source/local_file.zig:530), y readAt responde EOF a cualquier offset >= size_bytes (:577-584). El serve WT recorta el rango a ese tamaño (transport/quiczig_transport.zig:803-804) y termina con FIN limpio (:1123). Con un fichero que sigue creciendo (una grabación, un spool de publish, un torrent progresivo), el cliente recibe un cuerpo truncado con FIN de éxito: truncamiento silencioso, no error. El mismo recorte está en serveRangeDirect (:1134-1135) y en H3 (h3z_transport.zig:295).

R-02 (b): los objetos publicados por el browser no se pueden pedir con FETCH. El publish entrante copia cada objeto a un ZeroCopyBuffer (moqt/moqt_server.zig:1198-1200) para el fan-out en vivo y no lo indexa (:1214-1222, deferral declarado en m4#01). Así un FETCH tardío resuelve .empty y recibe FETCH_ERROR (moqt/fetch_runtime.zig:122, test del deferral en :499). Es honesto (no sirve bytes equivocados), pero la capacidad no existe: el relay sólo sabe leer de una LocalFileSource (moqt/moqt_relay.zig:90,182), y un objeto del browser no tiene offset en ese fichero.

Lo que hay que reutilizar ya existe. BackingState (C7, media-core/cache/types.zig:149) distingue file_extent{source_handle, offset, length}, retained_object{buffer_handle, committed_len} y unavailable{reason: BackingReason}, donde las razones son evicted, never_retained, source_retired, integrity_failure y policy_refusal. TieredCache guarda los ZeroCopyBuffer en un zkit.HandleSlab (tiered_cache.zig:47) con identidad slot+generación (C4). Defecto encontrado de paso: BackingState.makeFileExtent rechaza offset == 0 (types.zig:180). Un extent real al principio del fichero es irrepresentable. La prohibición del offset fabricado de R-02 se implementó sobre el valor en vez de sobre la procedencia. Este ADR lo corrige (§4).

Qué se propone

1. El tipo

Un ExtentMap por espacio de direcciones de bytes vive en media-core/source/extent_map.zig. Es un mapa de intervalos ordenado, que fusiona vecinos contiguos del mismo estado, sobre [0, ∞):

pub const ExtentState = union(enum) {
    available: cache.BackingState,          // dónde están los bytes (file_extent | retained_object)
    pending: struct { producer: ProducerId, expected_by_ns: ?u64 },  // alguien los está produciendo
    missing: cache.BackingReason,           // no están y se sabe por qué (dec-0023: 'missing' explícito)
};
// Sin cobertura = `unknown`: nadie ha anunciado ese rango.

pub const ExtentMap = struct {
    known_length: ?u64,   // null = no acotado (live, grabación en curso)
    sealed: bool,         // el productor declaró el final; sólo entonces offset >= known_length es EOF
    ...
};

size y availability quedan separados, como pide dec-0021. known_length es el tamaño cuando se conoce, y la disponibilidad se consulta por rango.

Ubicación. Va en media-core, como indica la tabla de crates de r26 §4 (media-core: "ByteSource, LocalFileSource, ExtentMap (crece desde el actual)"). El borrador de descomposición (plans/byte-runtime-decompose.plan.md §M5) proponía un crate byte-core/ aparte. Ese M5 no llegó al model: el m5 real fue la arista a zkit. Si waxin prefiere el crate, cambia la ruta, no el contrato.

2. La API

  • query(range) -> Availability, donde Availability es available (el rango entero, con la lista de BackingState que lo cubre), partial{available_prefix, then: ExtentState}, pending{producer, expected_by_ns}, missing{reason} o beyond_end (sólo si sealed).
  • commit(range, backing): un productor declara bytes disponibles. Despierta a los que esperan.
  • announce(range, producer, expected_by_ns): un productor declara que va a producir ese rango.
  • retire(range, reason): el rango pasa a missing (eviction, fuente retirada, integridad).
  • seal(length): fija el final.
  • waitFor(range, request_id, deadline_ns) -> Waiter: se registra una espera por requestId. Se resuelve con available, con missing, al vencer el plazo o cancelada: cancel(requestId) quita la espera y la resuelve como cancelled (r04/r20 §3.6: por request, nunca global). El wake reutiliza el mecanismo de media-core/sync/backpressure_waker.zig.

3. Productores (quién escribe)

  • LocalFileSource: al abrir, commit([0, stat.size), file_extent) y seal(stat.size) salvo que la fuente sea creciente. Una fuente creciente sólo existe cuando haya un productor real que la marque (grabación, spool, torrent: r28, sin capacidad especulativa).
  • Publish MoQT del browser: cada track publicado tiene su propio espacio de direcciones, un spool append-only. Cada objeto hace commit([spool_off, spool_off+len), retained_object) con el BufferHandle del ZeroCopyBuffer que hoy ya se crea en moqt_server.zig:1198. La entrada del índice de FETCH pasa a llevar spool_off real. El offset fabricado desaparece por construcción: el offset es real en ese espacio, que sí contiene esos bytes.
  • Futuros (con consumer propio, no en el primer milestone): piezas de torrent, rellenos de cache de orígenes HTTP/S3 (r26).

4. Consumidores reales

#ConsumidorHoyCon ExtentMap
C1FETCH tardío de un track publicado por el browser: moqt_relay.zig:410 (handleFetch) → fetch_runtime.resolveRange (fetch_runtime.zig:122) → lectura. En la rama de migración, FetchServe.pump (fetch_runtime.zig:141, lectura en :171).FETCH_ERROR (deferral R-02 b)El resolve consulta el ExtentMap del track. Los retained_object se sirven desde el ZeroCopyBuffer, resuelto y retenido a través de TieredCache (§6), los file_extent con pread. missing{reason} se traduce a FETCH_ERROR con la razón y pending a espera cancelable.
C2Serve WT de un fichero creciente: quiczig_transport.zig:803-804 y :1123truncamiento silencioso con FINend se recorta a known_length sólo si está sealed. Un rango pending espera (cancelable). Un missing termina en RESET con código de incompleto, nunca FIN.
C3SeekableMediaSource.availability() (TS, packages/source-sdk/src/source.ts:139)'online' / 'unknown' gruesoFuera del primer milestone. Exponer disponibilidad por rango a Bun sólo cuando la capa de decisión tenga un consumidor que la use (por ejemplo, arrancar la reproducción con N segundos disponibles).

makeFileExtent: la validación pasa del valor a la procedencia. El constructor recibe el SourceHandle emitido por la propia fuente (C4) y el rango se valida contra el ExtentMap de esa fuente, no contra offset != 0. offset = 0 vuelve a ser representable. El test de m4#01 que prohíbe resolver un offset fabricado sigue siendo el control negativo.

5. Cache por tiers y retención (r12/r45, dec-0104)

  • Los objetos del browser nacen en L1 como retained_object. Bajo presión de L1 bajan a L2 (NVMe) y el extent pasa a file_extent sobre el fichero de spool del track. Si L2 también los expulsa, pasan a missing{evicted}, con contador.
  • Esos bytes no tienen fuente de la que regenerarse. Hoy el enum de coste va de raw_relegible a transcoded (types.zig:92-98) y ningún valor dice "irrecuperable". Se propone la clase origin_only (coste máximo, se expulsa la última). Añade un valor y no cambia la política de r45.
  • Perfil VOD (r39, profile.zig: "Objects do not expire", sin MAX_CACHE_DURATION): un track VOD publicado por el browser no se expulsa en silencio. Si hay que expulsar, es missing{evicted} con contador y con FETCH_ERROR que da la razón. Es la misma regla que dec-0104 aplicó a las colas: sin pérdida silenciosa. Los tracks .live expulsan por antigüedad sin más.

6. Concurrencia

El publish del browser escribe en el hilo del loop MoQT y los serves leen desde los loops WT y MoQT. Un mutex por mapa (el patrón pthread_mutex_t de tiered_cache.zig:111). Los lectores copian el ExtentState bajo ese lock. Lo que sale de la sección crítica es un valor: un handle con su generación, nunca un puntero.

Resolver un retained_object es una operación de TieredCache, no del slab. zkit.HandleSlab no es thread-safe (zkit/src/handle.zig:36, "Callers must synchronize externally"; BR2 #2 en br2-conditions-reeval-2026-09-28.md), y en styx su única sincronización es el mutex de TieredCache (tiered_cache.zig:100-111: todo acceso al slab ocurre bajo self.mutex). Por eso:

  1. El serve nunca busca el handle en el HandleSlab por su cuenta ni fuera de ese mutex.
  2. Pide el buffer a TieredCache (un método del estilo acquireRetained(handle) ?*ZeroCopyBuffer, que hoy no existe y entra con m7). Dentro de self.mutex, ese método comprueba la generación del handle (C4) y hace acquire() sobre el ZeroCopyBuffer antes de soltar el lock. Si la generación no coincide, devuelve null y el serve vuelve a consultar el ExtentMap, que ya dirá file_extent (demovido a L2) o missing{evicted}.
  3. El serve retiene esa referencia durante todo el envío, desde el primer byte hasta la última completion del stream, se entregue o se descarte (cancel, RESET, cierre de sesión). Sólo entonces llama a release(), en todos los caminos, también en el de error.

Sin la regla 3, la democión L1→L2 de §5 (o una expulsión de L1) puede liberar el buffer mientras un FETCH lo está enviando: el slot del slab se recicla, el ZeroCopyBuffer baja a refcount 0 y el envío lee memoria liberada. Con ella, la democión sólo suelta la referencia de L1 y el buffer vive hasta el último release(). Hay que probarlo con TSAN (lane test:zig:tsan), con un test que demueva el objeto a L2 mientras un FETCH lo está enviando.

7. zkit (dec-0103)

zkit en el pin 09fd8cf exporta SubscriberQueue, ReorderBuffer, HungWorkerWatchdog, TrackingAllocator y HandleSlab. Ninguno es un mapa de intervalos ni un modelo de disponibilidad. ReorderBuffer ordena completions por secuencia, que es otra cosa. Así que ExtentMap es código nuevo de styx y no re-implementa nada de zkit. HandleSlab se consume (vía TieredCache) para los retained_object. Si aparece un segundo consumidor fuera de styx, ExtentMap es candidato a extraerse a zkit, con styx como origen igual que con candidate-h.

Alternativas

A — ExtentMap por espacio de direcciones con estados explícitos y esperas cancelables (recomendada)B — Mínimo: se mantiene tamaño = disponibilidad; almacén aparte (group,object) → ZeroCopyBuffer para FETCH; re-stat por lectura para ficheros que crecenC — Fichero sparse más SEEK_DATA/SEEK_HOLE (el kernel hace de ExtentMap)
Un modelo para local, spool, torrent y remoto (r26, dec-0023)SíNo: dos modelos, y un tercero para torrentA medias: sólo bytes en disco
pending frente a missing con razón (dec-0023, C7)SíNoNo: un hueco no dice por qué
Esperar a bytes que están llegando, cancelable por requestId (r04)SíNo: FETCH de un objeto en curso da errorNo: inotify no avisa por rangos
Encaja con los tiers L1/L2 (r12)Sí: retained_object ↔ file_extentEn parte: sólo RAMNo: todo objeto pasa por disco
PortabilidadCódigo propioCódigo propioSemántica que depende del fs (tmpfs, ext4, xfs y APFS difieren)
CosteMedio: un tipo nuevo, dos productores, dos consumidoresBajoBajo al principio, alto después

Recomendación: A. Por r16 es la frontier: un solo modelo de disponibilidad, que es justo lo que dec-0021/dec-0023 ya lockearon que existiría. B no tiene blocker, pero deja deuda estructural: tres modelos de disponibilidad en cuanto llegue torrent. C sirve como detalle de persistencia de L2 y no como modelo. Por r28, A entra con dos consumidores reales (C1 y C2) y dos productores reales (LocalFileSource y el publish del browser), sin capa vacía.

Consecuencias si se lockea A

  • Nodo: se propone track/byte-runtime/m7 ("ExtentMap + FETCH de objetos publicados por el browser"). Plan: docs/track/byte-runtime/plans/m7-extentmap-fetch-browser-published.plan.md. Crear el milestone está en la cola de decisiones de waxin ([WAXIN #1] del plan de la sesión 2026-09-28: "se crea solo con OK de waxin"), y no consta ese OK. Si lo da, lo escribe gobernanza en styx.model.yml, no esta lane.
  • Dependencias: track/byte-runtime/m4 (R-09 y R-12 cambian el serve de FETCH) y la integración de la migración a quic-zig upstream (reescribe moqt/**, donde vive C1). dec-0108 (R-08, proposed) es independiente, pero comparte la ServeTask por ventanas: si se lockean los dos, C2 se implementa sobre la ServeTask de dec-0108 y no sobre el bucle síncrono actual.
  • Qué NO decide este ADR: el formato en disco del spool L2, la política de retención del perfil live (más allá de "por antigüedad"), la superficie IPC hacia Bun (C3) ni la unificación con el GlobalByteScheduler de r26.