Files
tuliprox/backend/hls/src/cache.rs
T
knylbyteandGitHub 5ae547f5e5 FIX: stabilize shared HLS playback, recovery and account handovers (#925)
* fix (hls): preserve playback reserve across cache eviction

keep a stable session media clock and completed segment timings so cache
eviction and origin rollovers cannot reset playback reserve.

update segment fixtures and cover reserve stability after collection.

* fix (hls): prevent playback stalls during origin manifest retries

mark the origin as degraded on the first retryable manifest failure, cap
retry-after delays, and reevaluate reserve independently of client polling.

use a bounded state commit after an expired splice deadline instead of
leaving the lease live when terminal media cannot be published safely.

* fix (hls): serve custom responses after the live splice deadline

publish a finite lease-bound channel_unavailable playlist on the existing
manifest route when a safe live splice is no longer possible.

renew lease and session activity on successful custom-segment requests
and retain concrete preparation failure diagnostics.

* fix(hls): persist origin qualification for short playlists

Share qualification and retry receipts across leases, preserve them while media publication is pending, and allow controlled recovery after expired acceptance episodes. Prefer eligible regular candidates before short fallbacks.

Add startup, revalidation, replay, cookie and endpoint regression coverage, and document short-window behavior.

Validation during implementation: Linux make test (7009 passed, 23 ignored), focused HLS and endpoint suites, format checks, Clippy and Markdown lint. Offline production library and backend checks passed after the import fix.

* fix(hls): reconcile cache write budgets and revision reclamation

Transfer reserved bytes atomically through staging and publication, preserve cleanup ownership across cancellation, and retire unpinned revisions during local and global reclamation.

Version temp registrations during inventory scans, remove production panic calls, and isolate debug diagnostics from admission results.

Validation: 1,162 HLS tests and 7,038 workspace tests passed; fmt-check, lint, test, markdown-lint and isolated production Clippy passed with dev-slim.

* feat (ai): add local codex files

* fix (hls): replace invalid origin accounts in shared HLS sessions

* fix (hls): synchronize origin account handovers across WebUI viewers

* fix (hls): preserve same-account resume work and backpressure

* fix (hls): resume reduced retries without crossing generations
Allow eligible exhausted acceptance episodes to select reduced retry candidates
while preserving generation, candidate, workload and timing guards. Keep
recovery chains bound to their generation, including changes between selection
and commit.

Add regressions for repaired and persistent resource conflicts, generation
isolation and media delivery through the app's local HTTP origin. Keep shared
manifest fixtures under test/fixtures/hls/reduced-retry.
2026-10-19 17:42:50 +02:00

257 lines
8.4 KiB
Rust

use super::{safe_proxy_session_id, ProxyMapId, ProxySessionId, TransientResourceId};
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
sync::{atomic::AtomicU64, Arc, Mutex as StdMutex, RwLock as StdRwLock, Weak},
};
use tokio::sync::{Mutex as AsyncMutex, Notify, RwLock, Semaphore};
pub const DEFAULT_HLS_CACHE_PATH: &str = "/tmp/tuliprox/cache/hls";
const TEMP_CREATE_ATTEMPTS: usize = 8;
const MAX_CONCURRENT_OWNED_CACHE_OPERATIONS: usize = 64;
pub(crate) const MAX_TEMP_FILE_CLEANUP_CANDIDATES_PER_RUN: usize = 128;
/// Stable cache key for one proxy-visible HLS segment.
#[derive(Clone, Eq, PartialEq, Hash)]
pub struct SegmentCacheKey {
session_id: ProxySessionId,
seq: u64,
file_ext: String,
}
/// Stable cache key for one proxy-visible HLS EXT-X-MAP object.
#[derive(Clone, Eq, PartialEq, Hash)]
pub struct MapCacheKey {
session_id: ProxySessionId,
map_id: ProxyMapId,
file_ext: String,
}
/// Stable cache key for one demand-cached transient passthrough object.
///
/// This key is not a provider-source identity. It keys the proxy-visible object for a concrete transient resource.
/// The concrete origin fetch URI is kept in `TransientResourceRef`; callers must not reconstruct it from this key or
/// force host-neutral cache hits across redirect/CDN contexts without a separate safe resource identity.
#[derive(Clone, Eq, PartialEq, Hash)]
pub struct TransientObjectCacheKey {
session_id: ProxySessionId,
resource_id: TransientResourceId,
file_ext: String,
}
#[derive(Clone)]
pub struct StagedCacheObject {
pub path: PathBuf,
pub size: u64,
cache_path_generation: Arc<CachePathGeneration>,
_registration: Arc<ActiveTempFileRegistration>,
pub(crate) write_budget: Option<Arc<HlsRevisionDiskReservation>>,
cleanup: Arc<StagedFileCleanup>,
}
/// Typed source used to distinguish a decoded-object size violation from a filesystem failure.
#[derive(Debug, thiserror::Error)]
#[error("hls cache object exceeds configured size limit {limit}")]
pub struct HlsCacheObjectLimitError {
limit: u64,
}
/// Revision of cache accounting or of the protected working set.
///
/// Deferred segment work waits for this opaque token to become stale before it is
/// requeued. This gives `LocalCacheCapacity` a concrete retry contract without a
/// timer-driven origin-download loop.
#[derive(Clone)]
pub struct HlsCacheCapacityRevision(Arc<CapacityRevision>);
/// Typed local budget deferral kept distinct from filesystem capacity failures.
#[derive(Debug, Clone, thiserror::Error)]
#[error(
"hls cache capacity unavailable (required_session_bytes={required_session_bytes}, required_global_bytes={required_global_bytes})"
)]
pub struct HlsCacheCapacityError {
configured_session_bytes: u64,
configured_global_bytes: u64,
current_session_bytes: u64,
current_global_bytes: u64,
staged_bytes: u64,
required_session_bytes: u64,
required_global_bytes: u64,
protected_working_set_bytes: u64,
reclaimable_bytes: u64,
revision: HlsCacheCapacityRevision,
}
/// File-backed cache for committed HLS segment objects.
///
/// Lock order: the capacity-admission gate serializes authoritative admission, projected reclamation, and the following
/// reservation. It is acquired before the cache-path read lease because cache-root handoff orders GC before the
/// exclusive cache-path gate. The read lease is released before calling the GC reclaimer and reacquired for generation
/// validation and reservation; after projected accounting is reserved, the pressure gate is released and the read lease
/// spans the atomic rename.
/// The `temp_files`, `capacity`, path, reclaimer, and marker standard-library locks are never nested with one another
/// and are always released before filesystem I/O or `.await`.
pub struct HlsSegmentCache {
cache_path: StdRwLock<CachePathState>,
cache_path_commit_gate: Arc<RwLock<()>>,
temp_files: Arc<StdMutex<TempFileState>>,
max_object_bytes: AtomicU64,
max_cache_bytes: AtomicU64,
max_session_bytes: AtomicU64,
marker_path: StdRwLock<Option<PathBuf>>,
// Projected totals and per-object mutation ownership are updated atomically under this short synchronous lock.
capacity: Arc<StdMutex<CacheCapacityState>>,
capacity_changed: Arc<Notify>,
capacity_reclaimer: StdRwLock<Option<Weak<dyn HlsCacheCapacityReclaimer>>>,
capacity_admission_gate: AsyncMutex<()>,
owned_operation_permits: Arc<Semaphore>,
#[cfg(test)]
startup_test_checkpoint: StdMutex<Option<test_support::StartupWriteCheckpoint>>,
#[cfg(test)]
delete_test_failures: StdMutex<HashMap<PathBuf, usize>>,
}
#[derive(Clone)]
struct CachePathState {
path: PathBuf,
generation: Arc<CachePathGeneration>,
}
struct CachePathGeneration;
#[derive(Default)]
struct CacheCapacityState {
cache_path: PathBuf,
initialized: bool,
total_bytes: u64,
session_bytes: HashMap<String, u64>,
revision: Arc<CapacityRevision>,
active_mutations: HashSet<PathBuf>,
revision_reservations: HashMap<u64, (String, u64)>,
next_write_id: u64,
write_owners: HashMap<PathBuf, u64>,
abandoned_writes: HashMap<PathBuf, (u64, String)>,
reserved_spools: HashMap<PathBuf, u64>,
/// Current revision bytes, reconciled by scans and successful file mutations.
revision_bytes: u64,
revision_metrics: Option<Weak<crate::HlsCacheMetrics>>,
}
#[derive(Default)]
struct CapacityRevision;
#[derive(Default)]
struct TempFileState {
active_files: HashSet<PathBuf>,
revision: Arc<CapacityRevision>,
deletion_reservations: HashSet<PathBuf>,
}
struct ActiveTempFileRegistration {
path: PathBuf,
state: Arc<StdMutex<TempFileState>>,
changed: Arc<Notify>,
}
impl Drop for ActiveTempFileRegistration {
fn drop(&mut self) {
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if state.active_files.remove(&self.path) {
state.revision = Arc::new(CapacityRevision);
drop(state);
self.changed.notify_waiters();
}
}
}
struct CachePathDeletionReservation {
path: PathBuf,
state: Arc<StdMutex<TempFileState>>,
}
struct CapacityInvalidationGuard {
capacity: Arc<StdMutex<CacheCapacityState>>,
changed: Arc<Notify>,
}
impl Drop for CachePathDeletionReservation {
fn drop(&mut self) {
self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).deletion_reservations.remove(&self.path);
}
}
struct CapacityMutationReservation {
path: PathBuf,
cache_path: PathBuf,
session_component: String,
replacement: Option<(u64, u64)>,
filesystem_mutation_started: bool,
write_budget: Option<Arc<HlsRevisionDiskReservation>>,
transferred_bytes: u64,
capacity: Arc<StdMutex<CacheCapacityState>>,
changed: Arc<Notify>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
#[allow(clippy::struct_field_names)]
struct CacheCapacityPressure {
configured_session_bytes: u64,
configured_global_bytes: u64,
current_session_bytes: u64,
current_global_bytes: u64,
staged_bytes: u64,
required_session_bytes: u64,
required_global_bytes: u64,
}
impl Default for HlsSegmentCache {
fn default() -> Self { Self::new() }
}
/// A peak reservation includes raw data, cache staging, repair output and immutable revisions.
pub struct HlsRevisionDiskReservation {
id: u64,
capacity: Arc<StdMutex<CacheCapacityState>>,
changed: Arc<Notify>,
/// Set when bytes were written under this reservation outside the staged commit accounting.
unaccounted_writes: bool,
generation: Arc<CachePathGeneration>,
cache_path: PathBuf,
}
pub struct HlsRevisionFilePin {
_registration: Arc<ActiveTempFileRegistration>,
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod revision_capacity_tests;
mod capacity;
mod error;
mod keys;
mod lifecycle;
mod revisions;
mod staging;
#[cfg(test)]
mod test_support;
mod write_budget;
#[cfg(test)]
use capacity::CapacityReservationError;
#[allow(unused_imports, reason = "Retains the existing module interface in production and test builds.")]
pub use capacity::{
CacheInvalidationOutcome, HlsCacheCapacityReclaimOutcome, HlsCacheCapacityReclaimRequest,
HlsCacheCapacityReclaimer, HlsCacheCapacityUsage,
};
pub use error::{hls_cache_capacity_from_io, hls_cache_object_limit_from_io};
pub use keys::HlsCacheObjectKey;
pub use staging::CachedSegmentMetadata;
#[cfg(test)]
pub(crate) use test_support::StartupWritePhase;
use write_budget::StagedFileCleanup;