Skip to content

feat(net): move wall-clock LRU expiry to the cache pool - #3169

Open
kixelated wants to merge 1 commit into
devfrom
claude/lru-wall-clock-pool-expiration-ae670a
Open

feat(net): move wall-clock LRU expiry to the cache pool#3169
kixelated wants to merge 1 commit into
devfrom
claude/lru-wall-clock-pool-expiration-ae670a

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Progress on #3161, scoped to where the LRU window is configured.

What changed

track::Info::max_age used to drive two different clocks. It is the media-timestamp staleness budget (is_stale, post-#3099), but evict_expired also used it as a wall-clock idle window: a group unaccessed for max_age of real time was aborted. That conflation means a 5s media window also nukes the cache after a 5s congestion stall, and there was no way to say "5s of media retention, but keep idle content around for 30s in case someone fetches it".

The wall-clock half now lives on cache::Pool:

  • Pool::with_expiry(Option<Duration>) / Pool::expiry(): the LRU window, defaulting to a new cache::DEFAULT_EXPIRY (30s). None disables idle reclamation entirely, leaving only the byte budget.
  • TrackState::evict_expired reads the window from the pool instead of taking the track's max_age. max_age is now purely a media-timestamp promise: a stall (no accesses, no timestamp progress) inside the LRU window can never age content out, so tracks are immune to congestion bursts.
  • The frame-write settlement path (cache::Track::settle) now runs the expiry scan alongside debt payment, so a track that only appends frames to open groups still ages its idle siblings out instead of needing a new group insertion.
  • The prefetch reader's re-stamp cadence (refresh_if_stale) keys off half the pool window instead of half max_age, and stays bounded even when expiry is disabled (the stamp also protects against byte-budget eviction).
  • origin::Info::cache_duration keeps only its media-time role: clamping each track's advertised max_age (what subscribers may rely on).
  • The relay's cache-duration knob now also sets the pool expiry, which is what its documentation already promised ("retained since it was last written or served from cache by a FETCH"). Unset leaves the 30s default in force, so a long-running relay no longer accumulates unbounded idle history when no knobs are set.

Root cause

Issue #3161: after #3099 made subscription expiry timestamp-only, wall-clock reclamation was left keyed to a per-track media property, so it moved with the wrong knob (a long max_age meant unbounded idle RAM; a short one meant congestion stalls emptied the cache). The window is a cache policy about RAM, so it belongs to the pool that owns the RAM budget.

Tests

  • pool_expiry_controls_eviction, disabled_pool_expiry_never_reclaims, expiry_config cover the new knob.
  • Regression tests encoding the split: max_age_does_not_drive_wall_eviction (a 1s max_age track survives a 10s wall stall) and origin_cache_duration_does_not_wall_evict.
  • Existing expiry tests re-keyed from DEFAULT_MAX_AGE to cache::DEFAULT_EXPIRY.
  • just check and just test pass (3365 tests); rustdoc with -D warnings is clean.

Cross-package sync

Still open on #3161

Expiry is still evaluated only when a track writes. A fully idle publisher keeps its cache pinned until it resumes or disconnects, and a parked reader on an abandoned open group still waits forever; fixing that needs a wakeup, which the model layer deliberately does not own (model/clock.rs), so it belongs to a timer-owning layer.

🤖 Generated with Claude Code

(Written by Claude Fable 5)

Track max_age used to drive two different clocks: the media-timestamp
staleness budget and the wall-clock idle scan in evict_expired. The
wall-clock half now lives on cache::Pool as an expiry window (default
30s, None to disable), so a track can promise a 5s media window while
the pool reclaims anything unaccessed for 30s of real time. Media
timestamps stand still during a congestion stall, so a short max_age can
no longer age content out of the cache mid-stall; the LRU window is
purely about not pinning RAM on content nobody reads.

The origin's cache_duration keeps only its media-time role: clamping
each track's advertised max_age. The relay's cache-duration knob now
also sets the pool expiry, which is what its docs already promised
(retained since last written or served from cache). Frame-write
settlement now runs the expiry scan too, so a track appending frames to
open groups without inserting new ones still ages idle siblings out.

Progress on #3161 (the Rust reclamation half). Still open there: idle
reclamation with zero writes needs a wakeup the model layer deliberately
does not own, and the JS retention prune still keys off maxAge and
retains open groups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T17:07:43.420513Z 3afea3d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3afea3d1b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let Some(state) = self.state.upgrade() else { return };
if let Ok(mut state) = state.write() {
state.charge_debt();
state.evict_expired();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Trigger expiry below the byte threshold

For a track that keeps adding frames to an existing open group but writes less than 256 KiB after the previous settlement, settle returns before this new eviction scan runs. Consequently, a configured 30-second expiry can retain idle sibling groups for minutes on low-bitrate tracks, and the four-entry scan can make a large backlog take much longer to clear, despite the documented claim that expiry is evaluated when a track writes. Drive expiry from a separate time-based gate rather than the byte-debt threshold, and cover a sub-threshold writer with a regression test.

AGENTS.md reference: AGENTS.md:L141-L145

Useful? React with 👍 / 👎.

Self {
used: AtomicU64::new(0),
capacity: AtomicU64::new(u64::MAX),
expiry: AtomicU64::new(DEFAULT_EXPIRY.as_millis() as u64),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the unbounded pool contract

Initializing every pool with a 30-second expiry means Pool::unbounded() now aborts idle non-latest groups, while that public method still promises to create a pool that never evicts. A caller choosing unbounded() specifically to preserve cached history will unexpectedly receive Error::Old unless it discovers and adds with_expiry(None). Either preserve the documented behavior or explicitly redefine the method as byte-unbounded and direct callers to the expiry opt-out.

AGENTS.md reference: AGENTS.md:L115-L117

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant