Vista generada de dec-0104: MOQT: política de colas VOD sin pérdida, live conserva drop-oldest
docs/decisions/dec-0104-moqt-colas-vod-sin-perdida.mdVista generada desde
docs/decisions/dec-0104-moqt-colas-vod-sin-perdida.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 | LOCKED |
| Fecha | 2026-09-27 |
| Fichero | docs/decisions/dec-0104-moqt-colas-vod-sin-perdida.md |
Enmendado o sustituido por: dec-0122
Por qué importa (del frontmatter del ADR):
Gobierna la política de colas del daemon MOQT (
native/zig/media-daemon/moqt/moqt_pending_delivery.zig,moqt_relay.zig) para el track/byte-runtime/m4#06 (R-10). Sin este ADR, un executor futuro reintroduciría drop-oldest silencioso para VOD leyendo solo los comentarios pre-existentes dePENDING_QUEUE_CAP/FETCH_INDEX_CAP, que documentaban la política antigua (uniforme, ambos perfiles) como si fuera la única posible.
Nodos del roadmap que lo citan en refs: track/byte-runtime/m4
Páginas de la documentación que lo citan: Byte runtime (implementado)
Leído de docs/decisions/dec-0104-moqt-colas-vod-sin-perdida.md, el fichero canónico.
ENMENDADO 2026-10-01 por
dec-0122(lock de waxin víaAskUserQuestion, hallazgo AppSec ZT2-P2-02): en VOD la contrapresión es por suscriptor. Cada suscriptor tiene un backlog acotado; al que no sigue el ritmo se le corta la suscripción con EXCESSIVE_LOAD y retoma con FETCH. Los que siguen el ritmo no pierden nada, y ni la pista ni la cola compartida esperan al lento. Ver «Enmienda dec-0122» al final; el resto del texto se conserva como registro de su época.
AskUserQuestion (track/byte-runtime/m4#06, R-10) — "VOD sin
pérdida; live mantiene drop-oldest; contadores visibles".r39 (MOQT scope dual-policy VOD/live), r40 (mecanismos scheduler/cache del
harvest MoQ, incl. el PendingDeliveryQueue original de M2.E/M2.F).r39 estableció el dual-policy (.vod reliable / .live lossy) en el TIPO
(profile.zig's Profile enum), pero nada en el runtime lo leía: PendingDeliveryQueue
(M2.F remediation D2-01/D4-3) y Relay.fetch_index (FETCH_INDEX_CAP) aplicaban
drop-oldest FIFO incondicional — la misma política para .vod y para .live, porque M1
sólo implementa .vod en producción y nadie había vuelto a cerrar el círculo.
Consecuencia verificada por el repro (ver ticket 06.md, docs/track/byte-runtime/m4/evidence/):
bajo backpressure sostenido (PENDING_QUEUE_CAP = 256 objetos en cola), el perfil VOD —cuyo
propio profile.zig declara "Objects do not expire" y "cache unbounded"— perdía objetos
en silencio exactamente igual que .live, sin contador que lo distinguiera de una eviction
normal. Esto contradice la garantía "reliable, ordered, complete" que r39 §1 atribuye al
perfil VOD.
PendingDeliveryQueue bifurca en PENDING_QUEUE_CAP por self.profile.live: sin cambios — drop-oldest FIFO (comportamiento pre-existente, M2.F remediation
D2-01/D4-3). Contado en Counters.drops (nuevo, por track)..vod: nunca evita silenciosamente. Dos variantes del método push, según el hilo
que la llame — la razón de la bifurcación es la invariante ya lockeada de que el hilo del
event loop no puede bloquear jamás (Relay.deliverObject's comentario M2.F remediation
D4-2, mismo riesgo de deadlock):
push (NO bloqueante) — para el hilo del event loop (flushStaged del PUBLISH entrante
del browser, y el re-enqueue de onPollComplete). Al llenarse, rechaza explícito
(error.QueueFull), contado en Counters.rejections. Nunca espera.pushBlocking (bloqueante, acotado a VOD_BACKPRESSURE_MAX_WAIT_NS = 2s) — para el hilo
del auto-publisher (moqt_publisher.publishRange), que posee su propio std.Thread y
puede permitirse esperar a que onPollComplete (hilo del event loop, cada ciclo de poll)
libere espacio. Cuenta Counters.backpressure_events al entrar en espera y
Counters.rejections si excede el bound sin liberar espacio.Esto es la aplicación literal del pick de waxin: "aplica backpressure al productor (auto-publisher/publisher) o rechaza con error explícito al publicador" — backpressure real donde el productor puede esperar (auto-publisher), rechazo explícito y contado donde no puede (el publish entrante, que corre en el hilo que no puede bloquearse).
FETCH_INDEX_CAP sigue acotado (drop-oldest de METADATA), pero resolveRange deja de mentirFETCH_INDEX_CAP (el índice objeto→offset que FETCH escanea) sigue siendo un FIFO
acotado con drop-oldest, en AMBOS perfiles — no se vuelve ilimitado. Esto NO es una
contradicción con "VOD sin pérdida": el índice es metadata de a dónde re-servir un objeto ya
entregado en vivo por deliverObject, no el objeto en sí. Los 8192 entries del cap ya cubren
horas de historial (ver comentario original en moqt_relay.zig), y volverlo ilimitado
reintroduciría exactamente el RSS-growth-locus que FETCH_INDEX_CAP existe para cerrar
(forensics §4.5, mismo linaje que el PendingDeliveryQueue original).
Lo que SÍ cambia: fetch_runtime.resolveRange ahora distingue, para el FETCH que pide un
rango cuyo inicio antecede a la entrada más antigua aún retenida para ese track, un nuevo
resultado — .out_of_window — de .empty. Antes, un rango legítimamente publicado pero
ya podado por FETCH_INDEX_CAP resolvía .empty, indistinguible de "esto nunca se publicó".
Ahora resuelve .out_of_window, mapeado a FETCH_ERROR con motivo "out of window" (mismo
ERR_MALFORMED_TRACK que .empty/.out_of_bounds — quic-zig no tiene un ERR_* dedicado,
gap ya documentado en fetch_runtime.zig's cabecera; el cliente distingue por el string de
motivo, misma postura que las otras dos ramas).
Esto es la lectura elegida para "índice acotado por ventana declarada al cliente con error
explícito fuera de ventana" (texto del ticket): la ventana es implícita (el contenido actual
del índice, no un número declarado en el wire — extenderlo a un MAX_CACHE_DURATION/ventana
explícita en el protocolo es semántica de wire nueva, fuera de scope de este ADR) y el error es
explícito (.out_of_window vs .empty, contado por separado en RangeHist/otel).
PendingDeliveryQueue.Counters { drops, rejections, backpressure_events }, mapa por
TrackAlias, expuestos vía PendingDeliveryQueue.counters(track_alias). RangeHist gana
out_of_window_p4 (mismo patrón atómico que empty_p4/out_of_bounds_p4), exportado por
otel/metrics_exporter.zig y logueado en main.zig's [RANGE_HIST] line. Ninguno de los dos
son agregados globales opacos — el pick de waxin pedía "contadores visibles", no sólo "no
perder".
push (no bloqueante)
rechaza en el path de PUBLISH entrante del browser. El daemon cuenta + loguea el rechazo
(Counters.rejections), pero el publicador remoto no recibe hoy un mensaje de protocolo
distinguible ("tu objeto fue rechazado por backpressure") — inventar esa semántica de wire
(nuevo código de error MOQT, o usar session.stopSending sobre el stream del publisher) es
un cambio de protocolo/transporte que el ticket no pedía y este ADR no lockea. Cobertura sin
ello: el rechazo queda contado y logueado server-side; el publicador simplemente no ve su
objeto confirmado (el wire MOQT de M1 no tiene ack por objeto de todos modos). Reabsorbible
en un ticket propio si telemetría de campo muestra que hace falta.MAX_FETCHABLE_RANGE o
similar anunciado al cliente). .out_of_window es honesto sobre el estado actual del índice
del servidor; comunicar proactivamente esa ventana al cliente (para que evite pedir fuera de
ella) es una mejora de UX de protocolo, no lo que R-10 pedía cerrar (que era "no bytes
equivocados / no pérdida silenciosa", ya cerrado por R-02 y este ADR respectivamente).FETCH_INDEX_CAP ni PENDING_QUEUE_CAP como valores (siguen en 8192 y 256)..live (Datagram/lossy) — sigue post-M1 (r39 §3). Este ADR
sólo asegura que, EL DÍA que exista, .live seguirá teniendo la vía drop-oldest ya
verificada por test, sin que el código VOD-lossless la contamine.native/zig/media-daemon/moqt/moqt_pending_delivery.zig: Profile field + Counters +
push/pushBlocking bifurcados.native/zig/media-daemon/moqt/moqt_publisher.zig: publishRange usa pushBlocking (corre
en el hilo propio del auto-publisher).native/zig/media-daemon/moqt/moqt_server.zig: MoqtHandler.init construye la cola con
.vod (único perfil con runtime en M1); flushStaged/onPollComplete siguen usando push
no bloqueante (corren en el hilo del event loop).native/zig/media-daemon/moqt/moqt_types.zig: RangeResolution gana .out_of_window.native/zig/media-daemon/moqt/fetch_runtime.zig: resolveRange calcula el suelo (min_loc)
del track además del techo (max_loc) ya existente, y resuelve .out_of_window antes del
fallback .empty.native/zig/media-daemon/moqt/moqt_relay.zig: handleFetch mapea .out_of_window a
FETCH_ERROR con motivo distinto.native/zig/media-daemon/metrics/range_hist.zig + otel/metrics_exporter.zig + main.zig:
out_of_window_p4 counter.docs/track/byte-runtime/m4/tickets/06.md: DoD + evidencia RED/GREEN.Lo que cambia de §1: el relay ya no retiene la entrega de una pista mientras algún suscriptor
tenga su backlog de envío lleno. Esa retención hacía del suscriptor más lento el reloj de todos:
un cliente con una SCT de lectura que no enviaba ACK congelaba al resto de espectadores de la
pista y llenaba PendingDeliveryQueue, hasta rechazar objetos de otras pistas (ZT2-P2-02).
Desde c7d7277:
StreamSink escribe lo que le deja el ledger de envío y guarda el resto en su propio
backlog acotado (SINK_BACKLOG_BYTES = 8 MiB, SINK_BACKLOG_ITEMS = 64). Si lo desborda, su
suscripción termina con EXCESSIVE_LOAD y el relay la quita.push gana una cuota por pista (PER_TRACK_SHARE = 64 de los 256 sitios).Siguen igual: .live con drop-oldest, pushBlocking acotado para el auto-publisher, push no
bloqueante con rechazo contado, §2 y §3.