dec-0104

Vista generada de dec-0104: MOQT: política de colas VOD sin pérdida, live conserva drop-oldest

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0104-moqt-colas-vod-sin-perdida.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0104-moqt-colas-vod-sin-perdida.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
EstadoLOCKED
Fecha2026-09-27
Ficherodocs/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 de PENDING_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)

Texto del ADR

Leído de docs/decisions/dec-0104-moqt-colas-vod-sin-perdida.md, el fichero canónico.

dec-0104 — MOQT: política de colas VOD sin pérdida, live conserva drop-oldest

ENMENDADO 2026-10-01 por dec-0122 (lock de waxin vía AskUserQuestion, 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.

  • Fecha: 2026-09-27
  • Estado: LOCKED
  • Decisor: waxin, vía AskUserQuestion (track/byte-runtime/m4#06, R-10) — "VOD sin pérdida; live mantiene drop-oldest; contadores visibles".
  • Cita: r39 (MOQT scope dual-policy VOD/live), r40 (mecanismos scheduler/cache del harvest MoQ, incl. el PendingDeliveryQueue original de M2.E/M2.F).

Contexto — la tensión que r39/r40 dejaron sin resolver

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.

Qué se decide

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

2. FETCH_INDEX_CAP sigue acotado (drop-oldest de METADATA), pero resolveRange deja de mentir

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

3. Contadores visibles, por track

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

Lo que este ADR NO decide (deferral explícito)

  • No se envía una señal a nivel de wire MOQT al publicador cuando 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.
  • No se declara una ventana FETCH explícita en el wire (un 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).
  • No se toca FETCH_INDEX_CAP ni PENDING_QUEUE_CAP como valores (siguen en 8192 y 256).
  • No se construye el runtime .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.

Efecto sobre los artefactos

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

Enmienda dec-0122 (2026-10-01)

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:

  • Cada 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.
  • Los suscriptores que siguen el ritmo reciben la pista completa y en orden, como fija este ADR.
  • El suscriptor cortado retoma con FETCH (§2; fuera de la ventana del índice, «out of window»).
  • 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.