Vista generada de dec-0106: ExtentMap: disponibilidad parcial por rangos, y FETCH de objetos publicados por el browser
docs/decisions/dec-0106-extentmap-disponibilidad-parcial.mdVista generada desde
docs/decisions/dec-0106-extentmap-disponibilidad-parcial.md. No se edita a mano:bun run docs:genla regenera ybun run docs:checkfalla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.
| Campo | Valor |
|---|---|
| Estado | PROPOSED |
| Fecha | 2026-09-28 |
| Fichero | docs/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 (
LocalFileSourcefijasize_bytes = stat.sizeal 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)
Leído de docs/decisions/dec-0106-extentmap-disponibilidad-parcial.md, el fichero canónico.
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.br-design, rama w2/br-design).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).docs/track/byte-runtime/plans/m7-extentmap-fetch-browser-published.plan.md.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).
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.
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.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).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.| # | Consumidor | Hoy | Con ExtentMap |
|---|---|---|---|
| C1 | FETCH 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. |
| C2 | Serve WT de un fichero creciente: quiczig_transport.zig:803-804 y :1123 | truncamiento silencioso con FIN | end 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. |
| C3 | SeekableMediaSource.availability() (TS, packages/source-sdk/src/source.ts:139) | 'online' / 'unknown' grueso | Fuera 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.
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.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.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.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:
HandleSlab por su cuenta ni fuera de ese mutex.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}.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.
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.
| 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 crecen | C — 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 torrent | A medias: sólo bytes en disco |
pending frente a missing con razón (dec-0023, C7) | Sí | No | No: 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 error | No: inotify no avisa por rangos |
| Encaja con los tiers L1/L2 (r12) | Sí: retained_object ↔ file_extent | En parte: sólo RAM | No: todo objeto pasa por disco |
| Portabilidad | Código propio | Código propio | Semántica que depende del fs (tmpfs, ext4, xfs y APFS difieren) |
| Coste | Medio: un tipo nuevo, dos productores, dos consumidores | Bajo | Bajo 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.
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.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.GlobalByteScheduler de r26.