Skip to content

transport-quic-broker: under global backlog byte-pressure, publish evicts from the currently-publishing room — discards the active stream's freshest replay records while stale quiet-room data survives #899

Description

@erskingardner

Severity: LOW (replay-only; inert under the default replay_ttl = 0)
Component: crates/transport-quic-broker

Summary

BrokerState::publish enforces the global backlog byte budget (total_backlog_bytes > max_backlog_bytes) by popping from the current room's backlog only. When one room has already filled the global budget and then goes quiet, a different now-active room's first records are immediately evicted — so replay preferentially discards the freshest data of the active stream while retaining staler data from quiet rooms, inverting the replay feature's intent.

Location

crates/transport-quic-broker/src/state.rs:228-236 (BrokerState::publish):

room.backlog.push_back(BacklogRecord { record: Arc::clone(&record), bytes: record_bytes, appended_at: Instant::now() });
room.backlog_bytes += record_bytes;
total_backlog_bytes += record_bytes;
while room.backlog.len() > self.max_backlog
    || total_backlog_bytes > self.max_backlog_bytes      // GLOBAL budget…
{
    let Some(dropped) = room.backlog.pop_front() else { break; };  // …enforced by popping THIS room only
    room.backlog_bytes = room.backlog_bytes.saturating_sub(dropped.bytes);
    total_backlog_bytes = total_backlog_bytes.saturating_sub(dropped.bytes);
}

Concrete failure scenario (with replay_ttl > 0)

  1. Room A is the sole active stream and fills its backlog until total_backlog_bytes ≈ max_backlog_bytes (each A-publish self-evicts A's front, so A alone reaches the global budget).
  2. Room A goes quiet but its backlog persists (up to FINISHED_ROOM_TTL/UNFINISHED_ROOM_TTL, ~60s).
  3. Room B (a different, now-active preview) publishes its first record. total_backlog_bytes is ≈ budget (from A), so adding B's record trips total > max_backlog_bytes.
  4. The while loop pops from B (the current room), discarding the record B just appended. B empties (pop_front → None → break) and retains nothing.
  5. A late subscriber joining B during A's TTL window gets an empty backlog snapshot, while A's stale records survive.

Impact

Silent replay starvation of the active room under global byte pressure — the opposite of what replay is for. No error, no metric. Inert with the default replay_ttl = 0 (no backlog retained); only reachable when replay is enabled. The broker tests exercise single-room eviction only (src/tests.rs:204-236, 1008-1011), never the multi-room global-budget interaction.

Suggested fix

When enforcing the global byte budget, evict from the globally-largest / oldest room (or bound per-room bytes), not from whichever room is currently publishing.

Dedup

Distinct from #797 (performance of the purge_expired_rooms O(max_rooms) sweep + recompute) — this is an eviction-target-selection correctness/fairness defect, orthogonal to sweep cost. Related to but distinct from the closed #372 (wait_for_subscriber reset not decrementing total_backlog_bytes — an accounting bug on finished-room reuse); this is the publish eviction path choosing the wrong room.


Filed from a meticulous automated code-review pass; verified against source and deduplicated against the existing open/closed issue set.

Metadata

Metadata

Assignees

No one assigned

    Labels

    LOWbugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions