//! The DVR store: a window of a room's tracks, with their receipts, kept on disk and served //! to FETCH below the live edge (WAI `live-recording.md` §8). //! //! **What it keeps.** Every track whose namespace a `[[dvr.window]]` rule matches (the rule //! with the longest prefix): media tracks, their receipts tracks, events tracks, catalogs //! and takes alike. A group is stored once the cache holds it whole (`crate::cache`): its //! objects as they arrived, with every field a FETCH carries (Subgroup ID or datagram, //! publisher priority, properties, status, payload). Group *g* of a receipts track is the //! receipts of media group *g* (WAI `moq-streaming-format.md` §4.1), so a viewer that //! fetches both checks a recorded object exactly as it checks a live one. //! //! **The window.** Each rule has an age (`max_age_s`, at most 24 hours) and a byte budget //! (`max_bytes`) over its tracks together. Past either, the oldest group goes first, whole, //! and group *g* of a media track goes with group *g* of its receipts track. A group at or //! below a track's eviction mark is no longer known to the relay: a FETCH that reaches it //! is told so with an End of Unknown Range (MOQT §3.2.2, §11.4.1.2), since an evicted //! object existed, and an unmarked gap would say it did not. //! //! **On disk.** One directory per track under `tracks/`, named by the first 16 bytes of the //! SHA-256 of its rendered name (MOQT §8.8) in hexadecimal, holding `TRACK` (its namespace, //! name and properties, with a SHA-256 check) and its **segments**, `s.wds`: //! files a group record is appended to as each group is stored, a new one begun every ten //! seconds or 16 MiB. A segment is a sequence of frames, `len(u32) state(u8) record`, the //! state 1 for a record that stands and 0 for one a later record replaced; a record is //! //! ```text //! header "WDG1" 1 track(32) group(u64) arrived_us(u64) count(u32) erased(u32) check(32) //! object id(u64) subgroup(u64, 2^64-1 for a datagram) priority(u8) status(u8) //! props_len(u32) payload_len(u32) digest(32) check(32) props payload //! erased id(u64) digest(32) why(u8: 1 redacted, 2 did not verify, 3 taken down) //! ``` //! //! Integers are little-endian. `track` is the SHA-256 of the rendered track; a header's //! `check` is SHA-256 over the header's bytes before it; an object's `digest` is the content //! hash its receipt names it by (WAI `jwp-receipts.md` §2.1), and its `check` SHA-256 over //! its fixed fields before `check`, its properties and its payload. A record that replaces //! another (a group written again: an object erased, or a subgroup that came late) is //! appended, and the old frame's state set to 0 and its record's bytes overwritten with //! zeros. A segment is deleted once no group's standing record is in it. //! //! **Durability.** The writer syncs every segment it wrote, and the directories it made //! files in, at least once a second and before it answers a flush or stops; a shutdown //! flushes. A crash loses at most the last second's groups. On start the store reads every //! segment again: a frame a crash cut short, at a segment's end, is cut off and counted //! (`torn_writes`); a record whose header does not check is dropped; an object whose //! `check` fails, or, on a track with a receipts track, whose payload does not verify //! against group *g*'s receipts (the group receipt's signature under the door's trust for //! the namespace, the object's receipt at its position, its Merkle proof to the signed //! root), or, on an events track, a record of which does not verify under its principal, is //! dropped and counted, and its group written again with the object among the erased. It is //! never served: a FETCH that reaches it is told End of Unknown Range. Each object's digest //! is checked again as it is read. //! //! **Moderation.** The relay reads the `decision` events (WAI `live-events.md` §4.11) on the //! events tracks it already forwards. A decision is honoured when its record verifies, its //! author owns the track it rides and is a moderator the relay knows: a room key it is //! configured with (`dvr.room_keys`, the keys of `auth.authorities`), or the principal of a //! grant with the moderate power its publishing session proved. The last honoured decision //! on a target stands (WAI `live-moderation.md` §4.5): deny and redact withhold the target //! from every DVR read, and from every FETCH the cache answers; redact also erases its bytes //! from the store (an event record from the object that carries it, the object's other //! records kept). Allow restores what deny withheld. The honoured decisions are kept in //! `decisions.wdl` and survive a restart. //! //! **A room whose objects are sealed** (WAI `confidentiality.md`: an end-to-end encrypted //! room) is never kept, since such a room is never recorded and a relay keeps nothing of its //! tracks beyond delivery (§9). The store knows one by its first sealed object: an object //! whose properties carry the secure-objects Key ID (§4.3), which a relay reads without a key. //! A group holding one is never written. From then on the store writes nothing of the room, //! deletes what it already held (the room's events and receipts tracks, in the clear, can be //! whole before its first media group is), and drops the room's decisions from //! `decisions.wdl`; it still honours them for what the cache serves. The room is recorded in //! `sealed.wdl`, `room(32) check(32)` per room with `check` the SHA-256 of the room, so a //! restart keeps refusing it. //! //! **A room in which a minor publishes** (WAI `age-assurance.md` §8.4) is never kept either. //! The relay knows one by a minor's publishing grant: a session whose SETUP proved a grant of //! an authority it trusts whose `publish` is not 0 and whose attribute `minor` is `true` //! (WAI `live-rooms.md` §4.4). From then on the store treats the room as it treats a sealed //! one: it writes nothing of it, deletes what it held, keeps none of its decisions, and //! records it in `sealed.wdl`, which so holds every room the store never keeps. //! //! **Takedowns** (WAI `takedown.md` §10.1). A signed takedown (`crate::takedown`) is honoured //! when its signer is a key the store trusts (`dvr.takedown_keys`, and the keys of //! `auth.authorities`) and its signature verifies; it rides as an object of the track //! `[key, takedown]`, `0`, whose key is the signer's, or is given to [`Dvr::takedown`]. The //! store then forgets every covered object, as it forgets a sealed room, whatever its room: an //! object whose content hash its `exact` set or its `matched` set names, and every object of a //! track its `exact` set names (by the track's digest, WAI live-rooms §6.2) from the group it //! names on. Each is erased from every group that holds it (`why` 3), never stored again (a //! group that becomes whole later stores it erased), and never served, to a moderator's //! session either. The takedowns are kept in `takedowns.wdl`, `len(u32) record check(32)` per //! takedown, and verified again on start, which makes again any erasure a crash left undone. //! Once an erasure is on disk the store signs a purge with the relay's key and writes it to //! `purges/.json`, the takedown's link hash in hexadecimal, once. use crate::auth::pattern_matches; use crate::cache::CachedObject; use crate::config::DvrConfig; use crate::events::{self, Event, REDACT}; use crate::relay::TrackKey; use bytes::Bytes; use ed25519_dalek::SigningKey; use joule_moq::carriage::ReceiptBundle; use joule_moq::draft22::data::ObjectStatus; use joule_moq::draft22::kvp::{prop, Properties}; use joule_moq::draft22::names::render; use joule_moq::draft22::Location; use joule_moq::receipt::{ObjectReceipt, ReceiptVerifier}; use joule_moq::secure_objects::{self, Refusal}; use joule_moq::{GroupId, ObjectId, TrackName, TrackNamespace}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::io::Write; use std::os::unix::fs::FileExt; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::time::{Duration, Instant}; const GROUP_MAGIC: &[u8; 4] = b"WDG1"; const TRACK_MAGIC: &[u8; 4] = b"WDT1"; const VERSION: u8 = 1; const HEADER_LEN: usize = 4 + 1 + 32 + 8 + 8 + 4 + 4 + 32; const OBJECT_FIXED: usize = 8 + 8 + 1 + 1 + 4 + 4 + 32 + 32; /// A frame's length and state, before its record. const FRAME: u64 = 5; const DATAGRAM: u64 = u64::MAX; /// How long a segment takes groups, and how large it grows, before another is begun. const SEGMENT_SPAN_US: u64 = 10_000_000; const SEGMENT_MAX: u64 = 16 << 20; /// How often the writer syncs what it wrote. const SYNC_EVERY: Duration = Duration::from_secs(1); /// Why an object is among a group's erased. pub const ERASED_REDACTED: u8 = 1; pub const ERASED_UNVERIFIED: u8 = 2; pub const ERASED_TAKEN_DOWN: u8 = 3; /// The resolution of the store's metering series. const POINT_US: u64 = 1_000_000; /// How long the metering series keeps its points. const SERIES_KEEP_US: u64 = 48 * 3_600 * 1_000_000; fn sha256(parts: &[&[u8]]) -> [u8; 32] { let mut c = ring::digest::Context::new(&ring::digest::SHA256); for p in parts { c.update(p); } c.finish().as_ref().try_into().expect("32 bytes") } pub fn now_us() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_micros() as u64) } /// The room a namespace is a track of (WAI live-rooms §6.1): its first field, 64 lowercase /// hexadecimal digits. pub fn room_of(ns: &TrackNamespace) -> Option<[u8; 32]> { let f = ns.0.first()?; if f.len() != 64 || !f.iter().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b)) { return None; } hex::decode(f).ok()?.try_into().ok() } /// A key a namespace field names, 64 lowercase hexadecimal digits. fn key_field(ns: &TrackNamespace, i: usize) -> Option<[u8; 32]> { let f = ns.0.get(i)?; if f.len() != 64 { return None; } hex::decode(f).ok()?.try_into().ok() } /// Whether a track is a room's events track (live-events §8.1): `[R, P, events]`, `0`. pub fn is_events_track(key: &TrackKey) -> bool { key.0 .0.len() == 3 && key.0 .0[2] == b"events" && key.1 .0 == b"0" && room_of(&key.0).is_some() } fn track_digest(key: &TrackKey) -> [u8; 32] { sha256(&[render(&key.0, Some(&key.1)).as_bytes()]) } /// A track's digest as a takedown names it (WAI live-rooms §6.2): SHA-256 over the MOQT §8.8 /// rendering of its namespace, one zero byte, and its name. pub fn room_track_digest(key: &TrackKey) -> [u8; 32] { sha256(&[render(&key.0, None).as_bytes(), &[0], &key.1 .0]) } /// Whether a track is one takedowns ride (WAI takedown §10.1): `[K, takedown]`, `0`, with `K` /// a key in 64 lowercase hexadecimal digits. The key, if so. pub fn takedown_track_key(key: &TrackKey) -> Option<[u8; 32]> { (key.0 .0.len() == 2 && key.0 .0[1] == crate::takedown::TRACK_FIELD && key.1 .0 == b"0").then(|| room_of(&key.0)).flatten() } // ── The index ────────────────────────────────────────────────────────────── #[derive(Clone, Debug)] struct DObject { id: u64, subgroup: Option, priority: u8, status: ObjectStatus, /// Where its properties begin in its segment; its payload follows them. at: u64, props_len: u32, payload_len: u32, digest: [u8; 32], /// The digests of its event records, on an events track. records: Vec<[u8; 32]>, } #[derive(Clone, Debug)] struct DGroup { arrived_us: u64, /// Payload bytes of its objects. bytes: u64, objects: Vec, /// Objects no longer held, by Object ID, with their digests. erased: Vec<(u64, [u8; 32], u8)>, /// Its standing record: the segment, and the frame's place and record length. seg: u64, frame_at: u64, record_len: u32, } #[derive(Debug)] struct DTrack { dir: PathBuf, window: usize, room: Option<[u8; 32]>, events: bool, props: Properties, groups: BTreeMap, /// Groups known not to exist (a Prior Group ID Gap, MOQT §10.8), `lo ..= hi` by `lo`. absent: BTreeMap, /// Groups at or below this are no longer known to the store. evicted_through: Option, end_of_track: Option, /// Each segment, with the groups whose standing record is in it. segs: BTreeMap, } impl DTrack { fn largest(&self) -> Option { let (g, grp) = self.groups.iter().next_back()?; let o = grp.objects.iter().map(|o| o.id).chain(grp.erased.iter().map(|e| e.0)).max()?; Some(Location::new(*g, o)) } } /// The store's metering of one room (WAI live-metering §3.9): what it wrote, what it holds /// over time, what it dropped. Points are kept each second a change falls in. #[derive(Clone, Debug, Default)] struct Series { t_last: u64, held: u64, acc: u128, stored_objects: u64, stored_bytes: u64, dropped: u64, points: Vec<(u64, u128, u64, u64, u64, u64)>, } impl Series { fn change(&mut self, now: u64, held: i128, objects: u64, bytes: u64, dropped: u64) { if self.t_last != 0 && now > self.t_last { self.acc += self.held as u128 * (now - self.t_last) as u128; } self.t_last = self.t_last.max(now); self.held = (self.held as i128 + held).max(0) as u64; self.stored_objects += objects; self.stored_bytes += bytes; self.dropped += dropped; let p = (self.t_last, self.acc, self.held, self.stored_objects, self.stored_bytes, self.dropped); match self.points.last_mut() { Some(last) if last.0 / POINT_US == p.0 / POINT_US => *last = p, _ => self.points.push(p), } if self.points.first().is_some_and(|f| f.0 + SERIES_KEEP_US < now) { self.points.retain(|x| x.0 + SERIES_KEEP_US >= now); } } /// (byte-microseconds held, objects stored, bytes stored, dropped) up to `x`. fn at(&self, x: u64) -> (u128, u64, u64, u64) { if self.t_last != 0 && x >= self.t_last { return (self.acc + self.held as u128 * (x - self.t_last) as u128, self.stored_objects, self.stored_bytes, self.dropped); } match self.points.partition_point(|p| p.0 <= x) { 0 => (0, 0, 0, 0), i => { let p = self.points[i - 1]; (p.1 + p.2 as u128 * (x - p.0) as u128, p.3, p.4, p.5) } } } } /// What the store did for one room over a span (WAI live-metering §3.9). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct RoomFigures { pub objects_stored: u64, pub bytes_stored: u64, /// Payload bytes held, integrated over the span, in byte-seconds, rounded down. pub byte_s: u64, /// Objects dropped because they did not verify against their receipts. pub dropped: u64, } #[derive(Default)] struct Index { tracks: HashMap, window_bytes: Vec, /// Where each digest is held: (track, group, Object ID). by_digest: HashMap<[u8; 32], Vec<(TrackKey, u64, u64)>>, /// The standing decision on each (room, target): its verdict. standing: HashMap<([u8; 32], [u8; 32]), u8>, decided: HashSet<[u8; 32]>, series: HashMap<[u8; 32], Series>, /// Rooms whose objects are sealed: nothing of them is kept. sealed: HashSet<[u8; 32]>, /// The takedowns honoured, by link hash; the objects they cover, by content hash; and /// the tracks they cover, by digest, each from a group on. takedowns: HashSet<[u8; 32]>, taken: HashSet<[u8; 32]>, taken_tracks: Vec<([u8; 32], u64)>, } impl Index { fn withheld(&self, room: Option<[u8; 32]>, digest: &[u8; 32]) -> bool { room.is_some_and(|r| self.standing.get(&(r, *digest)).is_some_and(|v| *v != events::ALLOW)) } /// Whether a takedown covers group `group` of `key`, by its track. fn track_gone(&self, key: &TrackKey, group: u64) -> bool { if self.taken_tracks.is_empty() { return false; } let d = room_track_digest(key); self.taken_tracks.iter().any(|(t, from)| *t == d && group >= *from) } /// Record a takedown honoured. False when it was already. fn take(&mut self, t: &crate::takedown::Takedown) -> bool { if !self.takedowns.insert(t.link) { return false; } self.taken.extend(t.objects.iter().copied()); for x in &t.tracks { if !self.taken_tracks.contains(x) { self.taken_tracks.push(*x); } } true } fn unindex(&mut self, key: &TrackKey, g: u64, o: &DObject) { for d in std::iter::once(&o.digest).chain(o.records.iter()) { if let Some(v) = self.by_digest.get_mut(d) { v.retain(|x| !(x.0 == *key && x.1 == g && x.2 == o.id)); if v.is_empty() { self.by_digest.remove(d); } } } } fn reindex(&mut self, key: &TrackKey, g: u64, o: &DObject) { for d in std::iter::once(&o.digest).chain(o.records.iter()) { let e = self.by_digest.entry(*d).or_default(); if !e.iter().any(|x| x.0 == *key && x.1 == g && x.2 == o.id) { e.push((key.clone(), g, o.id)); } } } } /// Counters across the store. #[derive(Default)] pub struct DvrStats { pub groups_written: AtomicU64, pub objects_written: AtomicU64, pub groups_evicted: AtomicU64, /// Groups that became whole after the window had moved past them (their receipts or /// media group already evicted): not stored. pub groups_late: AtomicU64, /// Objects dropped because their check or their receipt did not verify. pub objects_dropped: AtomicU64, /// Frames a crash cut short at a segment's end, cut off at start. pub torn_writes: AtomicU64, /// Objects erased by a redaction. pub objects_redacted: AtomicU64, pub decisions: AtomicU64, /// Segments synced, and the syncs that wrote them out. pub syncs: AtomicU64, /// Takedowns honoured, objects they erased, and purges signed. pub takedowns: AtomicU64, pub objects_taken_down: AtomicU64, pub purges_signed: AtomicU64, } /// A snapshot of [`DvrStats`]. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct DvrCounts { pub groups_written: u64, pub objects_written: u64, pub groups_evicted: u64, pub groups_late: u64, pub objects_dropped: u64, pub torn_writes: u64, pub objects_redacted: u64, pub decisions: u64, pub syncs: u64, pub groups_held: u64, pub bytes_held: u64, pub segments: u64, /// Rooms whose objects are sealed, of which the store keeps nothing. pub rooms_sealed: u64, pub takedowns: u64, pub objects_taken_down: u64, pub purges_signed: u64, } /// One object read from the store, or a place it can no longer serve. #[derive(Clone, Debug)] pub enum Slot { Object(StoredObject), /// No longer held, or withheld: its Object ID. Unknown(u64), } #[derive(Clone, Debug)] pub struct StoredObject { pub id: u64, pub subgroup: Option, pub priority: u8, pub status: ObjectStatus, pub properties: Properties, pub payload: Bytes, } /// What the store holds of a track over a span of groups, for a FETCH's plan. #[derive(Clone, Debug, Default)] pub struct Snapshot { pub held: BTreeSet, pub absent: Vec<(u64, u64)>, pub evicted_through: Option, pub largest: Option, pub end_of_track: Option, pub props: Properties, } impl Snapshot { /// Whether group `g` is known not to exist, and the last group of its run if so. pub fn absent_through(&self, g: u64) -> Option { self.absent.iter().filter(|(lo, hi)| *lo <= g && g <= *hi).map(|(_, hi)| *hi).max() } } enum Job { Seal { key: TrackKey, props: Properties, group: u64, arrived_us: u64, objects: Vec<((u64, u64), CachedObject)> }, Erase { room: [u8; 32], digest: [u8; 32] }, Log(Vec), Forget([u8; 32]), TakeDown { t: crate::takedown::Takedown, rec: Vec }, Flush(mpsc::Sender<()>), } struct Window { prefix: TrackNamespace, max_age_us: u64, max_bytes: u64, } /// The segment a track appends to. struct OpenSeg { seq: u64, file: std::fs::File, started_us: u64, len: u64, } /// What the writer alone holds: each track's open segment, and what it has to sync. #[derive(Default)] struct Writer { open: HashMap, next: HashMap, dirty: HashSet, dirs: HashSet, last_sync: Option, } pub struct Dvr { root: PathBuf, windows: Vec, suffix: Vec, room_keys: Vec<[u8; 32]>, /// The keys whose takedowns the store honours. takedown_keys: Vec<[u8; 32]>, /// The key the store signs its purges with: the relay's. purge_key: Option, index: Mutex, tx: Mutex>>, worker: Mutex>>, pub stats: DvrStats, } fn put_short_field(o: &mut Vec, b: &[u8]) { o.extend_from_slice(&(b.len() as u16).to_le_bytes()); o.extend_from_slice(b); } fn track_dir_name(key: &TrackKey) -> String { hex::encode(&track_digest(key)[..16]) } fn seg_path(dir: &Path, seq: u64) -> PathBuf { dir.join(format!("s{seq:020}.wds")) } fn seg_seq(path: &Path) -> Option { let n = path.file_name()?.to_str()?; n.strip_prefix('s')?.strip_suffix(".wds")?.parse().ok() } fn sync_dir(dir: &Path) { if let Ok(f) = std::fs::File::open(dir) { let _ = f.sync_all(); } } /// Write `bytes` to `path` durably: a temporary file, synced, renamed into place, and the /// directory synced. fn write_durably(path: &Path, bytes: &[u8]) -> std::io::Result<()> { let tmp = path.with_extension("tmp"); { let mut f = std::fs::File::create(&tmp)?; f.write_all(bytes)?; f.sync_all()?; } std::fs::rename(&tmp, path)?; if let Some(d) = path.parent() { sync_dir(d); } Ok(()) } fn status_code(s: ObjectStatus) -> u8 { s.code() as u8 } /// The bytes of a group record (module documentation), and its objects' index, their /// places counted from the record's start. fn encode_group(track_digest: &[u8; 32], group: u64, arrived_us: u64, objects: &[(DObject, Bytes, Bytes)], erased: &[(u64, [u8; 32], u8)]) -> (Vec, Vec) { let mut o = Vec::new(); o.extend_from_slice(GROUP_MAGIC); o.push(VERSION); o.extend_from_slice(track_digest); o.extend_from_slice(&group.to_le_bytes()); o.extend_from_slice(&arrived_us.to_le_bytes()); o.extend_from_slice(&(objects.len() as u32).to_le_bytes()); o.extend_from_slice(&(erased.len() as u32).to_le_bytes()); let check = sha256(&[&o]); o.extend_from_slice(&check); let mut index = Vec::with_capacity(objects.len()); for (d, props, payload) in objects { let mut fixed = Vec::with_capacity(OBJECT_FIXED); fixed.extend_from_slice(&d.id.to_le_bytes()); fixed.extend_from_slice(&d.subgroup.unwrap_or(DATAGRAM).to_le_bytes()); fixed.push(d.priority); fixed.push(status_code(d.status)); fixed.extend_from_slice(&(props.len() as u32).to_le_bytes()); fixed.extend_from_slice(&(payload.len() as u32).to_le_bytes()); fixed.extend_from_slice(&d.digest); let check = sha256(&[&fixed, props, payload]); o.extend_from_slice(&fixed); o.extend_from_slice(&check); let at = o.len() as u64; o.extend_from_slice(props); o.extend_from_slice(payload); index.push(DObject { at, props_len: props.len() as u32, payload_len: payload.len() as u32, ..d.clone() }); } for (id, digest, why) in erased { o.extend_from_slice(&id.to_le_bytes()); o.extend_from_slice(digest); o.push(*why); } (o, index) } /// A group record that read: its header facts, each object with its properties and payload /// and whether its `check` held (places counted from the record's start), and its erased /// entries. struct Decoded { group: u64, arrived_us: u64, count: u32, objects: Vec<(DObject, Bytes, Bytes, bool)>, erased: Vec<(u64, [u8; 32], u8)>, } /// Read a group record. `None` when its header does not check; objects past a truncation /// are left out, and with them the erased entries. fn decode_group(b: &[u8], track_digest: &[u8; 32]) -> Option { if b.len() < HEADER_LEN || &b[..4] != GROUP_MAGIC || b[4] != VERSION || &b[5..37] != track_digest { return None; } if sha256(&[&b[..HEADER_LEN - 32]])[..] != b[HEADER_LEN - 32..HEADER_LEN] { return None; } let u64_at = |i: usize| u64::from_le_bytes(b[i..i + 8].try_into().expect("8")); let u32_at = |i: usize| u32::from_le_bytes(b[i..i + 4].try_into().expect("4")); let (group, arrived_us, count, erased_n) = (u64_at(37), u64_at(45), u32_at(53), u32_at(57) as usize); let mut p = HEADER_LEN; let mut objects = Vec::new(); for _ in 0..count { if b.len() < p + OBJECT_FIXED { break; } let f = &b[p..p + OBJECT_FIXED]; let id = u64::from_le_bytes(f[..8].try_into().expect("8")); let sg = u64::from_le_bytes(f[8..16].try_into().expect("8")); let priority = f[16]; let status = ObjectStatus::from_code(f[17] as u64).unwrap_or(ObjectStatus::Normal); let props_len = u32::from_le_bytes(f[18..22].try_into().expect("4")); let payload_len = u32::from_le_bytes(f[22..26].try_into().expect("4")); let digest: [u8; 32] = f[26..58].try_into().expect("32"); let at = p + OBJECT_FIXED; let end = at + props_len as usize + payload_len as usize; if b.len() < end { break; } let props = Bytes::copy_from_slice(&b[at..at + props_len as usize]); let payload = Bytes::copy_from_slice(&b[at + props_len as usize..end]); let ok = sha256(&[&f[..58], &props, &payload])[..] == f[58..90]; objects.push((DObject { id, subgroup: (sg != DATAGRAM).then_some(sg), priority, status, at: at as u64, props_len, payload_len, digest, records: Vec::new() }, props, payload, ok)); p = end; } let mut erased = Vec::new(); if objects.len() == count as usize { for _ in 0..erased_n { if b.len() < p + 41 { break; } erased.push((u64::from_le_bytes(b[p..p + 8].try_into().expect("8")), b[p + 8..p + 40].try_into().expect("32"), b[p + 40])); p += 41; } } Some(Decoded { group, arrived_us, count, objects, erased }) } fn encode_track(key: &TrackKey, props: &Properties) -> Vec { let mut o = TRACK_MAGIC.to_vec(); o.push(VERSION); o.push(key.0 .0.len() as u8); for f in &key.0 .0 { put_short_field(&mut o, f); } put_short_field(&mut o, &key.1 .0); let p = props.to_bytes().unwrap_or_default(); o.extend_from_slice(&(p.len() as u32).to_le_bytes()); o.extend_from_slice(&p); let check = sha256(&[&o]); o.extend_from_slice(&check); o } fn decode_track(b: &[u8]) -> Option<(TrackKey, Properties)> { if b.len() < 4 + 1 + 1 + 32 || &b[..4] != TRACK_MAGIC || b[4] != VERSION { return None; } let (body, check) = b.split_at(b.len() - 32); if sha256(&[body])[..] != *check { return None; } let mut p = 6; let mut field = || -> Option> { let n = u16::from_le_bytes(body.get(p..p + 2)?.try_into().ok()?) as usize; let v = body.get(p + 2..p + 2 + n)?.to_vec(); p += 2 + n; Some(v) }; let mut fields = Vec::new(); for _ in 0..body[5] { fields.push(field()?); } let name = field()?; let n = u32::from_le_bytes(body.get(p..p + 4)?.try_into().ok()?) as usize; let props = Properties::decode(body.get(p + 4..p + 4 + n)?, "dvr track properties").ok()?; if p + 4 + n != body.len() { return None; } Some(((TrackNamespace(fields), TrackName(name)), props)) } /// The Prior Group ID Gap an object's properties carry (MOQT §10.8): the groups before it /// that do not exist. /// Whether an object may be sealed (WAI confidentiality §4.3): its properties carry a Key /// ID, or do not read (Immutable Properties that do not decode could hide one). Only an /// object whose properties read and carry none is in the clear. pub fn is_sealed(props: &Properties) -> bool { !matches!(secure_objects::key_id_of(props), Err(Refusal::KeyIdMissing)) } fn prior_groups(props: &Properties, group: u64) -> Option<(u64, u64)> { let n = props.find(prop::PRIOR_GROUP_ID_GAP).ok().flatten().and_then(|k| k.as_varint())?; (n > 0 && n <= group).then(|| (group - n, group - 1)) } fn read_at(path: &Path, at: u64, len: usize) -> std::io::Result> { let f = std::fs::File::open(path)?; let mut b = vec![0u8; len]; f.read_exact_at(&mut b, at)?; Ok(b) } /// A group's record held in memory to be written again: each object with its properties /// and payload. type Rows = Vec<(DObject, Bytes, Bytes)>; impl Dvr { /// Open the store at `cfg.path`: read every track and segment in it, check every object /// again, and start its writer. `verifier` gives the receipts verifier of a namespace; /// `authorities` are the keys of `auth.authorities`, room keys besides `cfg.room_keys` and /// takedown keys besides `cfg.takedown_keys`; `purge_key` signs the store's purges (none /// are signed without it). pub fn open(cfg: &DvrConfig, suffix: &str, authorities: Vec<[u8; 32]>, purge_key: Option, verifier: impl Fn(&TrackNamespace) -> ReceiptVerifier) -> Result, String> { cfg.check()?; let root = cfg.path.clone(); std::fs::create_dir_all(root.join("tracks")).map_err(|e| format!("dvr: {}: {e}", root.display()))?; let mut room_keys: Vec<[u8; 32]> = cfg.room_keys.iter().filter_map(|k| hex::decode(k).ok()?.try_into().ok()).collect(); let mut takedown_keys: Vec<[u8; 32]> = cfg.takedown_keys.iter().filter_map(|k| hex::decode(k).ok()?.try_into().ok()).collect(); takedown_keys.extend(authorities.iter().copied()); room_keys.extend(authorities); let windows = cfg .window .iter() .map(|w| Window { prefix: TrackNamespace::from_strs(w.prefix.iter().map(String::as_str)), max_age_us: w.max_age_s * 1_000_000, max_bytes: w.max_bytes }) .collect::>(); let dvr = Arc::new(Dvr { root, index: Mutex::new(Index { window_bytes: vec![0; windows.len()], ..Index::default() }), windows, suffix: suffix.as_bytes().to_vec(), room_keys, takedown_keys, purge_key, tx: Mutex::new(None), worker: Mutex::new(None), stats: DvrStats::default(), }); let mut w = Writer::default(); dvr.load(&mut w, &verifier)?; let (tx, rx) = mpsc::channel(); *dvr.tx.lock().unwrap() = Some(tx); let me = dvr.clone(); let worker = std::thread::Builder::new().name("joule-moq-relay-dvr".into()).spawn(move || me.run(w, rx)).map_err(|e| e.to_string())?; *dvr.worker.lock().unwrap() = Some(worker); Ok(dvr) } /// The window that keeps `ns`: the rule with the longest prefix that matches it. pub fn window_of(&self, ns: &TrackNamespace) -> Option { self.windows.iter().enumerate().filter(|(_, w)| pattern_matches(&w.prefix, ns)).max_by_key(|(_, w)| w.prefix.0.len()).map(|(i, _)| i) } /// Whether a window rule matches `ns`. Moderation reads the decisions of every events /// track it matches, a sealed room's among them. pub fn keeps(&self, ns: &TrackNamespace) -> bool { self.window_of(ns).is_some() } /// Whether the store writes the tracks of `ns`: a rule matches, and it is no room whose /// objects are sealed and no room in which a minor publishes. pub fn stores(&self, ns: &TrackNamespace) -> bool { self.keeps(ns) && !room_of(ns).is_some_and(|r| self.index.lock().unwrap().sealed.contains(&r)) } /// Room `room`'s objects are sealed: from now the store keeps nothing of it, and the /// writer deletes what it held. fn seal_room(&self, room: [u8; 32]) { if self.index.lock().unwrap().sealed.insert(room) { tracing::info!(target: "joule_moq_relay::dvr", room = %hex::encode(room), "a room's objects are sealed: the store keeps nothing of it"); self.send(Job::Forget(room)); } } /// A minor publishes in room `room` (WAI age-assurance §8.4): from now the store keeps /// nothing of it, as of a room whose objects are sealed, and the writer deletes what it held. pub fn minors_room(&self, room: [u8; 32]) { if self.index.lock().unwrap().sealed.insert(room) { tracing::info!(target: "joule_moq_relay::dvr", room = %hex::encode(room), "a minor publishes in a room: the store keeps nothing of it"); self.send(Job::Forget(room)); } } fn send(&self, j: Job) { if let Some(tx) = self.tx.lock().unwrap().as_ref() { let _ = tx.send(j); } } /// Wait until every write asked for so far is on disk, synced. pub fn flush(&self) { let (tx, rx) = mpsc::channel(); self.send(Job::Flush(tx)); let _ = rx.recv_timeout(Duration::from_secs(60)); } /// Flush, then stop the writer. The store is closed; a new one can open its path. pub fn close(&self) { self.flush(); self.tx.lock().unwrap().take(); if let Some(w) = self.worker.lock().unwrap().take() { let _ = w.join(); } } /// Group `group` of `key` is whole in the cache: store it, unless it is stored with as /// many objects already. Cheap: the write is the writer's. pub fn seal(&self, key: &TrackKey, props: &Properties, group: u64, arrived_us: u64, objects: Vec<((u64, u64), CachedObject)>) { if objects.is_empty() || !self.stores(&key.0) { return; } // A sealed object is never written, and its room is never kept again // (confidentiality §9). if objects.iter().any(|(_, o)| is_sealed(&o.properties)) { if let Some(room) = room_of(&key.0) { self.seal_room(room); } return; } { let ix = self.index.lock().unwrap(); if let Some(t) = ix.tracks.get(key) { if t.evicted_through.is_some_and(|e| group <= e) { self.stats.groups_late.fetch_add(1, Ordering::Relaxed); return; } if t.groups.get(&group).is_some_and(|g| g.objects.len() + g.erased.len() >= objects.len()) { return; } } } self.send(Job::Seal { key: key.clone(), props: props.clone(), group, arrived_us, objects }); } /// What the store holds of `key` from group `lo` to `hi`. pub fn snapshot(&self, key: &TrackKey, lo: u64, hi: u64) -> Option { let ix = self.index.lock().unwrap(); let t = ix.tracks.get(key)?; Some(Snapshot { held: t.groups.range(lo..=hi).map(|(g, _)| *g).collect(), absent: t.absent.iter().filter(|(l, h)| **h >= lo && **l <= hi).map(|(l, h)| (*l, *h)).collect(), evicted_through: t.evicted_through, largest: t.largest(), end_of_track: t.end_of_track, props: t.props.clone(), }) } /// The Largest Object the store holds of `key`. pub fn largest(&self, key: &TrackKey) -> Option { self.index.lock().unwrap().tracks.get(key).and_then(DTrack::largest) } /// The track's properties as the store recorded them. pub fn props(&self, key: &TrackKey) -> Option { self.index.lock().unwrap().tracks.get(key).map(|t| t.props.clone()) } /// Whether any decision withholds something in `room`. pub fn withholds_in(&self, room: Option<[u8; 32]>) -> bool { room.is_some_and(|r| self.index.lock().unwrap().standing.iter().any(|((rr, _), v)| *rr == r && *v != events::ALLOW)) } /// Whether the standing decision on `digest` in `room` withholds it. pub fn withheld(&self, room: Option<[u8; 32]>, digest: &[u8; 32]) -> bool { self.index.lock().unwrap().withheld(room, digest) } /// The objects of group `group` of `key` from `from` to `to` (Object IDs, inclusive), /// read from disk, in Object ID order, with what is no longer held or withheld marked. /// An events-track object some of whose records are withheld comes back without them /// (`crate::events::withhold`). With `withhold` false (a moderator's session, WAI /// live-moderation §6.3) nothing is withheld; what is erased stays unknown. `None` when /// the store no longer holds the group. pub fn read_group(&self, key: &TrackKey, group: u64, from: u64, to: u64, withhold: bool) -> Option> { // A group written again between the index and the read (an erasure) is read again. for attempt in 0..2 { let (path, place, objs, erased, room, is_events) = { let ix = self.index.lock().unwrap(); let t = ix.tracks.get(key)?; let g = t.groups.get(&group)?; (seg_path(&t.dir, g.seg), (g.seg, g.frame_at), g.objects.clone(), g.erased.clone(), t.room.filter(|_| withhold), t.events) }; let Ok(f) = std::fs::File::open(&path) else { if attempt == 0 { continue; } return None; }; let mut out: Vec<(u64, Slot)> = erased.iter().filter(|e| e.0 >= from && e.0 <= to).map(|e| (e.0, Slot::Unknown(e.0))).collect(); let mut moved = false; // What a takedown covers is never served, to any session (WAI takedown §10.1). let gone = self.index.lock().unwrap().track_gone(key, group); for o in objs.iter().filter(|o| o.id >= from && o.id <= to) { if gone || self.index.lock().unwrap().taken.contains(&o.digest) { out.push((o.id, Slot::Unknown(o.id))); continue; } let mut b = vec![0u8; o.props_len as usize + o.payload_len as usize]; let payload = match f.read_exact_at(&mut b, o.at) { Ok(()) => Bytes::from(b.split_off(o.props_len as usize)), Err(_) => Bytes::new(), }; if ObjectReceipt::hash_payload(&payload) != o.digest { moved = true; out.push((o.id, Slot::Unknown(o.id))); continue; } let ix = self.index.lock().unwrap(); if ix.withheld(room, &o.digest) { out.push((o.id, Slot::Unknown(o.id))); continue; } let payload = if is_events && o.records.iter().any(|d| ix.withheld(room, d)) { match events::withhold(&payload, |d| ix.withheld(room, d)) { Some(p) => Bytes::from(p.into_owned()), None => { out.push((o.id, Slot::Unknown(o.id))); continue; } } } else { payload }; drop(ix); let properties = if b.is_empty() { Properties::new() } else { Properties::decode(&b, "dvr object properties").unwrap_or_default() }; out.push((o.id, Slot::Object(StoredObject { id: o.id, subgroup: o.subgroup, priority: o.priority, status: o.status, properties, payload }))); } if moved { // Written again since the index was read: read it where it stands now. let now = self.index.lock().unwrap().tracks.get(key).and_then(|t| t.groups.get(&group)).map(|g| (g.seg, g.frame_at)); if attempt == 0 && now.is_some() && now != Some(place) { continue; } tracing::warn!(target: "joule_moq_relay::dvr", file = %path.display(), group, "a stored object whose bytes no longer hold its digest: not served"); } out.sort_by_key(|(id, _)| *id); return Some(out.into_iter().map(|(_, s)| s).collect()); } None } /// What the store did for `room` between two instants (WAI live-metering §3.9). pub fn figures(&self, room: [u8; 32], from_us: u64, to_us: u64) -> RoomFigures { let ix = self.index.lock().unwrap(); let Some(s) = ix.series.get(&room) else { return RoomFigures::default() }; let (a0, o0, b0, d0) = s.at(from_us); let (a1, o1, b1, d1) = s.at(to_us.max(from_us)); RoomFigures { objects_stored: o1 - o0, bytes_stored: b1 - b0, byte_s: ((a1 - a0) / 1_000_000) as u64, dropped: d1 - d0 } } pub fn counts(&self) -> DvrCounts { let s = &self.stats; let ix = self.index.lock().unwrap(); DvrCounts { groups_written: s.groups_written.load(Ordering::Relaxed), objects_written: s.objects_written.load(Ordering::Relaxed), groups_evicted: s.groups_evicted.load(Ordering::Relaxed), groups_late: s.groups_late.load(Ordering::Relaxed), objects_dropped: s.objects_dropped.load(Ordering::Relaxed), torn_writes: s.torn_writes.load(Ordering::Relaxed), objects_redacted: s.objects_redacted.load(Ordering::Relaxed), decisions: s.decisions.load(Ordering::Relaxed), syncs: s.syncs.load(Ordering::Relaxed), groups_held: ix.tracks.values().map(|t| t.groups.len() as u64).sum(), bytes_held: ix.window_bytes.iter().sum(), segments: ix.tracks.values().map(|t| t.segs.len() as u64).sum(), rooms_sealed: ix.sealed.len() as u64, takedowns: s.takedowns.load(Ordering::Relaxed), objects_taken_down: s.objects_taken_down.load(Ordering::Relaxed), purges_signed: s.purges_signed.load(Ordering::Relaxed), } } /// The groups the store holds of `key`, ascending. pub fn groups(&self, key: &TrackKey) -> Vec { self.index.lock().unwrap().tracks.get(key).map(|t| t.groups.keys().copied().collect()).unwrap_or_default() } /// Where group `group` of `key` stands on disk: its segment and the place of its /// frame, for tests and tools. pub fn group_location(&self, key: &TrackKey, group: u64) -> Option<(PathBuf, u64)> { let ix = self.index.lock().unwrap(); let t = ix.tracks.get(key)?; let g = t.groups.get(&group)?; Some((seg_path(&t.dir, g.seg), g.frame_at)) } /// The directory of `key`'s files, for tests and tools. pub fn track_dir(&self, key: &TrackKey) -> Option { self.index.lock().unwrap().tracks.get(key).map(|t| t.dir.clone()) } // ── Takedowns ─────────────────────────────────────────────────────────── /// Whether any takedown is in force. pub fn takes_down(&self) -> bool { !self.index.lock().unwrap().takedowns.is_empty() } /// Whether a takedown covers the object of content hash `digest`, or group `group` of /// `key` by its track. pub fn taken_down(&self, key: &TrackKey, group: u64, digest: &[u8; 32]) -> bool { let ix = self.index.lock().unwrap(); ix.taken.contains(digest) || ix.track_gone(key, group) } /// Honour a signed takedown (WAI takedown §10.1): verify it under the keys the store /// trusts, record what it covers, and have the writer erase it, log it and sign the /// purge. Its link hash, or why it is not honoured. A takedown honoured before is not /// applied again. pub fn takedown(&self, rec: &[u8]) -> Result<[u8; 32], String> { self.takedown_under(rec, &self.takedown_keys) } fn takedown_under(&self, rec: &[u8], trusted: &[[u8; 32]]) -> Result<[u8; 32], String> { let t = crate::takedown::read(rec, trusted)?; if !self.index.lock().unwrap().take(&t) { return Ok(t.link); } self.stats.takedowns.fetch_add(1, Ordering::Relaxed); tracing::info!(target: "joule_moq_relay::dvr", takedown = %hex::encode(t.link), objects = t.objects.len(), tracks = t.tracks.len(), "a takedown is honoured"); let link = t.link; self.send(Job::TakeDown { t, rec: rec.to_vec() }); Ok(link) } /// One object of a takedown track `[K, takedown]` the relay forwards: a takedown signed by /// `K`, honoured when `K` is a key the store trusts. pub fn takedown_object(&self, ns: &TrackNamespace, payload: &[u8]) { let Some(k) = room_of(ns).filter(|_| ns.0.len() == 2 && ns.0[1] == crate::takedown::TRACK_FIELD) else { return }; if !self.takedown_keys.contains(&k) { return; } if let Err(e) = self.takedown_under(payload, &[k]) { tracing::warn!(target: "joule_moq_relay::dvr", error = %e, "a takedown is not honoured"); } } /// Where the purge of the takedown `link` is written. pub fn purge_path(&self, link: &[u8; 32]) -> PathBuf { self.root.join("purges").join(format!("{}.json", hex::encode(link))) } /// The writer's part of a takedown: log it, erase what it covers, sync, sign the purge. fn apply_takedown(&self, w: &mut Writer, t: &crate::takedown::Takedown, rec: Option<&[u8]>) { if let Some(rec) = rec { let mut b = (rec.len() as u32).to_le_bytes().to_vec(); b.extend_from_slice(rec); b.extend_from_slice(&sha256(&[rec])); let r = std::fs::OpenOptions::new().create(true).append(true).open(self.root.join("takedowns.wdl")).and_then(|mut f| { f.write_all(&b)?; f.sync_all() }); if let Err(e) = r { tracing::error!(target: "joule_moq_relay::dvr", error = %e, "the takedowns log could not be written"); } } for d in &t.objects { self.erase(w, None, *d, ERASED_TAKEN_DOWN); } for (track, from) in &t.tracks { self.erase_track(w, track, *from); } self.sync(w); self.write_purge(t); } /// Sign and write the purge of `t`, once. fn write_purge(&self, t: &crate::takedown::Takedown) { let Some(key) = &self.purge_key else { return }; let path = self.purge_path(&t.link); if path.exists() { return; } let body = crate::takedown::purge(t, now_us() / 1_000_000, key); let r = std::fs::create_dir_all(self.root.join("purges")).and_then(|_| write_durably(&path, body.as_bytes())); match r { Ok(()) => { self.stats.purges_signed.fetch_add(1, Ordering::Relaxed); tracing::info!(target: "joule_moq_relay::dvr", takedown = %hex::encode(t.link), path = %path.display(), "a purge is signed"); } Err(e) => tracing::error!(target: "joule_moq_relay::dvr", error = %e, "a purge could not be written"), } } /// Erase every object of each group, from `from` on, of every track whose digest is /// `track` (WAI live-rooms §6.2). fn erase_track(&self, w: &mut Writer, track: &[u8; 32], from: u64) { let groups: Vec<(TrackKey, PathBuf, u64)> = { let ix = self.index.lock().unwrap(); ix.tracks .iter() .filter(|(k, _)| room_track_digest(k) == *track) .flat_map(|(k, t)| t.groups.range(from..).map(move |(g, _)| (k.clone(), t.dir.clone(), *g))) .collect() }; for (key, dir, g) in groups { let Some(grp) = self.index.lock().unwrap().tracks.get(&key).and_then(|t| t.groups.get(&g)).cloned() else { continue }; if grp.objects.is_empty() { continue; } let mut erased = grp.erased.clone(); erased.extend(grp.objects.iter().map(|o| (o.id, o.digest, ERASED_TAKEN_DOWN))); if self.rewrite(w, &key, &dir, g, grp.arrived_us, &Vec::new(), &erased, (grp.seg, grp.frame_at, grp.record_len)).is_none() { continue; } let room = { let mut ix = self.index.lock().unwrap(); for o in &grp.objects { ix.unindex(&key, g, o); } let (window, room) = ix.tracks.get(&key).map_or((None, None), |t| (Some(t.window), t.room)); if let Some(gr) = ix.tracks.get_mut(&key).and_then(|t| t.groups.get_mut(&g)) { gr.bytes = 0; } if let Some(wi) = window { ix.window_bytes[wi] = ix.window_bytes[wi].saturating_sub(grp.bytes); } room }; self.series_change(room, now_us(), -(grp.bytes as i128), 0, 0, 0); self.stats.objects_taken_down.fetch_add(grp.objects.len() as u64, Ordering::Relaxed); tracing::info!(target: "joule_moq_relay::dvr", dir = %dir.display(), group = g, objects = grp.objects.len(), "a takedown erased a group of a track"); } } // ── Moderation ────────────────────────────────────────────────────────── /// One object of an events track `ns` the relay forwards: each `decision` record of it /// is honoured when it verifies, its author owns the track, and the author is a /// moderator (`moderators`: the principals of grants with the moderate power the /// publishing session proved, in this room). pub fn events_object(&self, ns: &TrackNamespace, payload: &[u8], moderators: &[[u8; 32]]) { let (Some(room), Some(owner)) = (room_of(ns), key_field(ns, 1)) else { return }; if !(ns.0.len() == 3 && ns.0[2] == b"events") { return; } let Some(recs) = events::records(payload) else { return }; for (_, rec) in recs { let Some(e) = Event::read(&rec) else { continue }; if e.decision().is_none() || e.room != room || e.principal != owner { continue; } if self.index.lock().unwrap().decided.contains(&e.digest) { continue; } if !(self.room_keys.contains(&owner) || moderators.contains(&owner)) || !e.verifies(&rec) { continue; } self.apply_decision(&e); if !self.index.lock().unwrap().sealed.contains(&room) { self.send(Job::Log(rec)); } } } /// Returns the target to erase, for a redaction. fn apply_decision(&self, e: &Event) -> Option<[u8; 32]> { let (verdict, target) = e.decision()?; let mut ix = self.index.lock().unwrap(); if !ix.decided.insert(e.digest) { return None; } ix.standing.insert((e.room, target), verdict); drop(ix); self.stats.decisions.fetch_add(1, Ordering::Relaxed); tracing::info!(target: "joule_moq_relay::dvr", room = %hex::encode(e.room), target_digest = %hex::encode(target), verdict, "a decision is honoured"); if verdict == REDACT { self.send(Job::Erase { room: e.room, digest: target }); return Some(target); } None } // ── Segments ──────────────────────────────────────────────────────────── /// Append `record` to `key`'s open segment (beginning another past its span or size), /// returning the segment and the frame's place. fn append(&self, w: &mut Writer, key: &TrackKey, dir: &Path, record: &[u8]) -> std::io::Result<(u64, u64)> { let now = now_us(); let stale = w.open.get(key).is_none_or(|s| now.saturating_sub(s.started_us) >= SEGMENT_SPAN_US || s.len >= SEGMENT_MAX); if stale { if let Some(old) = w.open.remove(key) { self.retire(key, old.seq, None); } let seq = *w.next.get(key).unwrap_or(&0); w.next.insert(key.clone(), seq + 1); let path = seg_path(dir, seq); let file = std::fs::OpenOptions::new().create(true).truncate(true).read(true).write(true).open(&path)?; w.dirs.insert(dir.to_path_buf()); w.open.insert(key.clone(), OpenSeg { seq, file, started_us: now, len: 0 }); if let Some(t) = self.index.lock().unwrap().tracks.get_mut(key) { t.segs.entry(seq).or_insert(0); } } let s = w.open.get_mut(key).expect("opened above"); let mut frame = Vec::with_capacity(FRAME as usize + record.len()); frame.extend_from_slice(&(record.len() as u32).to_le_bytes()); frame.push(1); frame.extend_from_slice(record); s.file.write_all_at(&frame, s.len)?; let at = s.len; s.len += frame.len() as u64; w.dirty.insert(seg_path(dir, s.seq)); Ok((s.seq, at)) } /// Mark a record replaced: its frame's state 0 and its bytes zeros. fn kill(&self, w: &mut Writer, dir: &Path, seq: u64, frame_at: u64, record_len: u32) { let path = seg_path(dir, seq); let r = std::fs::OpenOptions::new().write(true).open(&path).and_then(|f| { f.write_all_at(&[0], frame_at + 4)?; f.write_all_at(&vec![0u8; record_len as usize], frame_at + FRAME) }); if r.is_ok() { w.dirty.insert(path); } } /// Delete segment `seq` of `key` when no group's standing record is in it and it is not /// the one being appended to (`open`, the writer's). fn retire(&self, key: &TrackKey, seq: u64, open: Option) { if open == Some(seq) { return; } let path = { let mut ix = self.index.lock().unwrap(); let Some(t) = ix.tracks.get_mut(key) else { return }; if t.segs.get(&seq).is_some_and(|n| *n > 0) { return; } t.segs.remove(&seq); seg_path(&t.dir, seq) }; let _ = std::fs::remove_file(&path); } /// Sync every segment written since the last sync, and the directories files were made /// in. fn sync(&self, w: &mut Writer) { for p in w.dirty.drain() { if let Ok(f) = std::fs::OpenOptions::new().write(true).open(&p) { let _ = f.sync_data(); } self.stats.syncs.fetch_add(1, Ordering::Relaxed); } for d in w.dirs.drain() { sync_dir(&d); } w.last_sync = Some(Instant::now()); } /// Write group `g` of `key` again from `rows` and `erased`: append the new record, kill /// the old, and update the index. Returns the group's new objects. #[allow(clippy::too_many_arguments)] fn rewrite(&self, w: &mut Writer, key: &TrackKey, dir: &Path, g: u64, arrived_us: u64, rows: &Rows, erased: &[(u64, [u8; 32], u8)], old: (u64, u64, u32)) -> Option> { let (bytes, mut index) = encode_group(&track_digest(key), g, arrived_us, rows, erased); let (seq, at) = match self.append(w, key, dir, &bytes) { Ok(x) => x, Err(e) => { tracing::error!(target: "joule_moq_relay::dvr", error = %e, "a group could not be written again"); return None; } }; for (o, r) in index.iter_mut().zip(rows) { o.at += at + FRAME; o.records = r.0.records.clone(); } { let mut ix = self.index.lock().unwrap(); if let Some(t) = ix.tracks.get_mut(key) { *t.segs.entry(seq).or_insert(0) += 1; if let Some(n) = t.segs.get_mut(&old.0) { *n = n.saturating_sub(1); } if let Some(gr) = t.groups.get_mut(&g) { gr.objects = index.clone(); gr.erased = erased.to_vec(); gr.seg = seq; gr.frame_at = at; gr.record_len = bytes.len() as u32; } } } self.kill(w, dir, old.0, old.1, old.2); let open = w.open.get(key).map(|s| s.seq); self.retire(key, old.0, open); Some(index) } // ── Loading ───────────────────────────────────────────────────────────── fn load(&self, w: &mut Writer, verifier: &dyn Fn(&TrackNamespace) -> ReceiptVerifier) -> Result<(), String> { let now = now_us(); // The rooms found sealed before. Nothing of them is loaded: their decisions and the // tracks a crash left of them are deleted. let path = self.root.join("sealed.wdl"); if let Ok(b) = std::fs::read(&path) { let mut good = 0; for c in b.as_chunks::<64>().0 { if sha256(&[&c[..32]])[..] != c[32..] { break; } self.index.lock().unwrap().sealed.insert(c[..32].try_into().expect("32")); good += 64; } if good != b.len() { let _ = std::fs::OpenOptions::new().write(true).open(&path).and_then(|f| f.set_len(good as u64)); } } let sealed = self.index.lock().unwrap().sealed.clone(); if !sealed.is_empty() { self.rewrite_decisions(|room| !sealed.contains(room)); } // The decisions honoured before, each verified again. let log = self.root.join("decisions.wdl"); if let Ok(b) = std::fs::read(&log) { let mut p = 0; let mut good = 0; while b.len() >= p + 4 { let n = u32::from_le_bytes(b[p..p + 4].try_into().expect("4")) as usize; let Some(rec) = b.get(p + 4..p + 4 + n) else { break }; let Some(check) = b.get(p + 4 + n..p + 4 + n + 32) else { break }; if sha256(&[rec])[..] != *check { break; } if let Some(e) = Event::read(rec).filter(|e| e.verifies(rec)) { // Erasures are made below, once the groups are read. let (verdict, target) = e.decision().unwrap_or((0, [0; 32])); let mut ix = self.index.lock().unwrap(); if verdict != 0 && ix.decided.insert(e.digest) { ix.standing.insert((e.room, target), verdict); self.stats.decisions.fetch_add(1, Ordering::Relaxed); } } p += 4 + n + 32; good = p; } if good != b.len() { // A record a crash cut short: the log keeps what precedes it. let _ = std::fs::OpenOptions::new().write(true).open(&log).and_then(|f| f.set_len(good as u64)); } } // The takedowns honoured before, each verified again (WAI takedown §10.1). let mut takedowns: Vec = Vec::new(); let tlog = self.root.join("takedowns.wdl"); if let Ok(b) = std::fs::read(&tlog) { let (mut p, mut good) = (0, 0); while b.len() >= p + 4 { let n = u32::from_le_bytes(b[p..p + 4].try_into().expect("4")) as usize; let Some(rec) = b.get(p + 4..p + 4 + n) else { break }; let Some(check) = b.get(p + 4 + n..p + 4 + n + 32) else { break }; if sha256(&[rec])[..] != *check { break; } match crate::takedown::read(rec, &self.takedown_keys) { Ok(t) => { if self.index.lock().unwrap().take(&t) { self.stats.takedowns.fetch_add(1, Ordering::Relaxed); takedowns.push(t); } } Err(e) => tracing::warn!(target: "joule_moq_relay::dvr", error = %e, "a logged takedown no longer verifies: not honoured"), } p += 4 + n + 32; good = p; } if good != b.len() { let _ = std::fs::OpenOptions::new().write(true).open(&tlog).and_then(|f| f.set_len(good as u64)); } } let tracks_dir = self.root.join("tracks"); let mut loaded: Vec<(TrackKey, DTrack, HashMap)> = Vec::new(); // Groups whose standing record no longer says what the index holds: written again. let mut changed: HashSet<(TrackKey, u64)> = HashSet::new(); for d in std::fs::read_dir(&tracks_dir).map_err(|e| e.to_string())?.flatten() { let dir = d.path(); let Some((key, props)) = std::fs::read(dir.join("TRACK")).ok().and_then(|b| decode_track(&b)) else { tracing::warn!(target: "joule_moq_relay::dvr", dir = %dir.display(), "a track directory whose TRACK does not read: left alone"); continue; }; if room_of(&key.0).is_some_and(|r| sealed.contains(&r)) { let _ = std::fs::remove_dir_all(&dir); continue; } let Some(window) = self.window_of(&key.0) else { continue }; let digest = track_digest(&key); let mut t = DTrack { dir: dir.clone(), window, room: room_of(&key.0), events: is_events_track(&key), props, groups: BTreeMap::new(), absent: BTreeMap::new(), evicted_through: None, end_of_track: None, segs: BTreeMap::new() }; let mut segs: Vec<(u64, PathBuf)> = std::fs::read_dir(&dir).map_err(|e| e.to_string())?.flatten().filter_map(|e| seg_seq(&e.path()).map(|s| (s, e.path()))).collect(); segs.sort(); // Each group's standing record: the last that reads; the earlier ones killed. let mut rows: HashMap = HashMap::new(); let mut replaced: Vec<(u64, u64, u32)> = Vec::new(); for (seq, path) in &segs { let Ok(b) = std::fs::read(path) else { continue }; t.segs.insert(*seq, 0); let mut p = 0usize; while p < b.len() { let whole = b.len() >= p + FRAME as usize && { let n = u32::from_le_bytes(b[p..p + 4].try_into().expect("4")) as usize; b.len() >= p + FRAME as usize + n }; if !whole { // A frame a crash cut short: cut off. self.stats.torn_writes.fetch_add(1, Ordering::Relaxed); let _ = std::fs::OpenOptions::new().write(true).open(path).and_then(|f| f.set_len(p as u64)); break; } let n = u32::from_le_bytes(b[p..p + 4].try_into().expect("4")) as usize; let state = b[p + 4]; let rec = &b[p + FRAME as usize..p + FRAME as usize + n]; let frame_at = p as u64; p += FRAME as usize + n; if state != 1 { continue; } let Some(dg) = decode_group(rec, &digest) else { self.stats.objects_dropped.fetch_add(1, Ordering::Relaxed); self.series_change(t.room, now, 0, 0, 0, 1); tracing::warn!(target: "joule_moq_relay::dvr", file = %path.display(), at = frame_at, "a group record whose header does not check: dropped"); replaced.push((*seq, frame_at, n as u32)); continue; }; if let Some(prev) = t.groups.remove(&dg.group) { replaced.push((prev.seg, prev.frame_at, prev.record_len)); if let Some(c) = t.segs.get_mut(&prev.seg) { *c = c.saturating_sub(1); } } let base = frame_at + FRAME; let short = dg.count as usize - dg.objects.len(); let mut grp = DGroup { arrived_us: dg.arrived_us, bytes: 0, objects: Vec::new(), erased: dg.erased, seg: *seq, frame_at, record_len: n as u32 }; let mut kept: Rows = Vec::new(); if short > 0 { changed.insert((key.clone(), dg.group)); } for (mut o, props, payload, ok) in dg.objects { o.at += base; if !ok || ObjectReceipt::hash_payload(&payload) != o.digest { self.dropped(&t, dg.group, &o, &mut grp, "its check does not hold"); changed.insert((key.clone(), dg.group)); continue; } if t.events { o.records = events::digests(&payload); } kept.push((o, props, payload)); } if short > 0 { self.stats.objects_dropped.fetch_add(short as u64, Ordering::Relaxed); self.series_change(t.room, now, 0, 0, 0, short as u64); } for (o, props, payload) in &kept { if let Some(r) = prior_groups(&Properties::decode(props, "dvr").unwrap_or_default(), dg.group) { t.absent.insert(r.0, r.1); } if o.status == ObjectStatus::EndOfTrack { t.end_of_track = Some(Location::new(dg.group, o.id)); } grp.bytes += payload.len() as u64; grp.objects.push(o.clone()); } *t.segs.entry(*seq).or_insert(0) += 1; t.groups.insert(dg.group, grp); rows.insert(dg.group, kept); } } for (seq, at, len) in replaced { self.kill(w, &dir, seq, at, len); } w.next.insert(key.clone(), segs.last().map_or(0, |(s, _)| s + 1)); loaded.push((key, t, rows)); } // Every object of a track with a receipts track, against its group's receipts; every // record of an events track, under its principal. let held: HashSet = loaded.iter().map(|(k, _, _)| k.clone()).collect(); let receipts: HashMap> = loaded .iter() .filter(|(k, _, _)| self.media_of(&k.1).is_some()) .map(|(k, _, rows)| (k.clone(), rows.iter().filter_map(|(g, r)| r.first().map(|(_, _, p)| (*g, p.clone()))).collect())) .collect(); for (k, t, rows) in &mut loaded { let rkey = (k.0.clone(), self.receipts_of(&k.1)); let check_receipts = self.media_of(&k.1).is_none() && held.contains(&rkey); if !(check_receipts || t.events) { continue; } let v = verifier(&k.0); let groups: Vec = t.groups.keys().copied().collect(); for g in groups { let bundle = if check_receipts { receipts.get(&rkey).and_then(|m| m.get(&g)).and_then(|b| ReceiptBundle::from_payload(b).ok()) } else { None }; let vg = bundle.as_ref().and_then(|b| (b.group.group_id == GroupId(g)).then(|| v.verify_group(&b.group).ok()).flatten()); let Some(r) = rows.get_mut(&g) else { continue }; let mut bad = Vec::new(); for (o, _, payload) in r.iter() { let ok = if check_receipts { match (&bundle, &vg) { (Some(b), Some(vg)) => b.objects.iter().find(|x| x.object_id == ObjectId(o.id)).is_some_and(|x| v.verify_payload(payload, x, vg).is_ok()), _ => o.status != ObjectStatus::Normal, } } else { match events::records(payload) { Some(rs) => rs.iter().all(|(_, x)| Event::read(x).is_some_and(|e| e.verifies(x))), None => true, } }; if !ok { bad.push(o.clone()); } } let mut grp = t.groups.remove(&g).expect("held"); if !bad.is_empty() { changed.insert((k.clone(), g)); } for o in &bad { self.dropped(t, g, o, &mut grp, if check_receipts { "it does not verify against its receipt" } else { "a record of it does not verify" }); r.retain(|x| x.0.id != o.id); } t.groups.insert(g, grp); } } // Into the index; then every group whose record no longer says what the index holds // (an object dropped, or a redaction the log asks for that a crash left undone) is // written again. let mut again: Vec<(TrackKey, PathBuf, u64)> = Vec::new(); { let mut ix = self.index.lock().unwrap(); for (k, mut t, _) in std::mem::take(&mut loaded) { for (g, grp) in t.groups.iter_mut() { // What a takedown covers, erased (a crash can leave it held). let gone = ix.track_gone(&k, *g); let taken: Vec = grp.objects.iter().filter(|o| gone || ix.taken.contains(&o.digest)).cloned().collect(); if !taken.is_empty() { changed.insert((k.clone(), *g)); } for o in &taken { grp.objects.retain(|x| x.id != o.id); grp.bytes = grp.bytes.saturating_sub(o.payload_len as u64); grp.erased.push((o.id, o.digest, ERASED_TAKEN_DOWN)); self.stats.objects_taken_down.fetch_add(1, Ordering::Relaxed); } let redacted: Vec = grp.objects.iter().filter(|o| t.room.is_some_and(|r| ix.standing.get(&(r, o.digest)) == Some(&REDACT))).cloned().collect(); if !redacted.is_empty() { changed.insert((k.clone(), *g)); } for o in &redacted { grp.objects.retain(|x| x.id != o.id); grp.bytes = grp.bytes.saturating_sub(o.payload_len as u64); grp.erased.push((o.id, o.digest, ERASED_REDACTED)); self.stats.objects_redacted.fetch_add(1, Ordering::Relaxed); } ix.window_bytes[t.window] += grp.bytes; if let Some(room) = t.room { ix.series.entry(room).or_default().change(now, grp.bytes as i128, 0, 0, 0); } for o in &grp.objects { ix.reindex(&k, *g, o); } if changed.contains(&(k.clone(), *g)) { again.push((k.clone(), t.dir.clone(), *g)); } } // What is below the first group held is no longer known to the store. t.evicted_through = t.groups.keys().next().and_then(|g| g.checked_sub(1)); ix.tracks.insert(k, t); } } for (k, dir, g) in again { self.write_again_if_changed(w, &k, &dir, g); } // Event records a redaction the log holds targets, erased from the objects that // carry them. let redactions: Vec<([u8; 32], [u8; 32])> = self.index.lock().unwrap().standing.iter().filter(|(_, v)| **v == REDACT).map(|(k, _)| *k).collect(); for (room, target) in redactions { self.erase(w, Some(room), target, ERASED_REDACTED); } // The takedowns the log holds: what a crash left of their erasures is made now, and a // purge a crash left unsigned is signed. for t in &takedowns { self.apply_takedown(w, t, None); } self.sync(w); let n: usize = self.index.lock().unwrap().tracks.values().map(|t| t.groups.len()).sum(); tracing::info!(target: "joule_moq_relay::dvr", path = %self.root.display(), groups = n, dropped = self.stats.objects_dropped.load(Ordering::Relaxed), torn = self.stats.torn_writes.load(Ordering::Relaxed), "the DVR store is open"); Ok(()) } /// Write group `g` again when its standing record holds other objects than the index /// does (some dropped, or erased). fn write_again_if_changed(&self, w: &mut Writer, key: &TrackKey, dir: &Path, g: u64) { let Some(grp) = self.index.lock().unwrap().tracks.get(key).and_then(|t| t.groups.get(&g)).cloned() else { return }; let path = seg_path(dir, grp.seg); let Ok(rec) = read_at(&path, grp.frame_at + FRAME, grp.record_len as usize) else { return }; let Some(dg) = decode_group(&rec, &track_digest(key)) else { return }; if dg.count as usize == grp.objects.len() && dg.erased.len() == grp.erased.len() { return; } let rows: Rows = grp.objects.iter().filter_map(|o| dg.objects.iter().find(|x| x.0.id == o.id).map(|(_, props, payload, _)| (o.clone(), props.clone(), payload.clone()))).collect(); self.rewrite(w, key, dir, g, grp.arrived_us, &rows, &grp.erased, (grp.seg, grp.frame_at, grp.record_len)); } /// An object that did not verify at load: out of the group, among its erased, counted. fn dropped(&self, t: &DTrack, g: u64, o: &DObject, grp: &mut DGroup, why: &str) { self.stats.objects_dropped.fetch_add(1, Ordering::Relaxed); self.series_change(t.room, now_us(), 0, 0, 0, 1); tracing::warn!(target: "joule_moq_relay::dvr", dir = %t.dir.display(), group = g, object = o.id, reason = why, "a stored object dropped: never served"); if grp.objects.iter().any(|x| x.id == o.id) { grp.bytes = grp.bytes.saturating_sub(o.payload_len as u64); } grp.objects.retain(|x| x.id != o.id); if !grp.erased.iter().any(|e| e.0 == o.id) { grp.erased.push((o.id, o.digest, ERASED_UNVERIFIED)); } } fn series_change(&self, room: Option<[u8; 32]>, now: u64, held: i128, objects: u64, bytes: u64, dropped: u64) { if let Some(r) = room { self.index.lock().unwrap().series.entry(r).or_default().change(now, held, objects, bytes, dropped); } } fn media_of(&self, name: &TrackName) -> Option { (!self.suffix.is_empty() && name.0.len() > self.suffix.len() && name.0.ends_with(&self.suffix)).then(|| TrackName(name.0[..name.0.len() - self.suffix.len()].to_vec())) } fn receipts_of(&self, name: &TrackName) -> TrackName { let mut v = name.0.clone(); v.extend_from_slice(&self.suffix); TrackName(v) } // ── The writer ────────────────────────────────────────────────────────── fn run(self: Arc, mut w: Writer, rx: mpsc::Receiver) { loop { let job = rx.recv_timeout(SYNC_EVERY); match job { Ok(Job::Seal { key, props, group, arrived_us, objects }) => self.write_seal(&mut w, key, props, group, arrived_us, objects), Ok(Job::Erase { room, digest }) => self.erase(&mut w, Some(room), digest, ERASED_REDACTED), Ok(Job::TakeDown { t, rec }) => self.apply_takedown(&mut w, &t, Some(&rec)), Ok(Job::Log(rec)) => self.log(&rec), Ok(Job::Forget(room)) => self.forget(&mut w, room), Ok(Job::Flush(done)) => { self.evict_by_age(&mut w, now_us()); self.sync(&mut w); let _ = done.send(()); } Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Disconnected) => { self.sync(&mut w); return; } } if w.last_sync.is_none_or(|t| t.elapsed() >= SYNC_EVERY) { self.evict_by_age(&mut w, now_us()); self.sync(&mut w); } } } fn log(&self, rec: &[u8]) { // A room found sealed after the record was queued: its decisions are not kept. if Event::read(rec).is_some_and(|e| self.index.lock().unwrap().sealed.contains(&e.room)) { return; } let path = self.root.join("decisions.wdl"); let mut b = (rec.len() as u32).to_le_bytes().to_vec(); b.extend_from_slice(rec); b.extend_from_slice(&sha256(&[rec])); let r = std::fs::OpenOptions::new().create(true).append(true).open(&path).and_then(|mut f| { f.write_all(&b)?; f.sync_all() }); if let Err(e) = r { tracing::error!(target: "joule_moq_relay::dvr", error = %e, "the decisions log could not be written"); } } /// Forget room `room`, whose objects are sealed: record it in `sealed.wdl`, delete every /// track of it the store holds, and drop its decisions from `decisions.wdl`. fn forget(&self, w: &mut Writer, room: [u8; 32]) { let mut rec = room.to_vec(); rec.extend_from_slice(&sha256(&[&room])); let r = std::fs::OpenOptions::new().create(true).append(true).open(self.root.join("sealed.wdl")).and_then(|mut f| { f.write_all(&rec)?; f.sync_all() }); if let Err(e) = r { tracing::error!(target: "joule_moq_relay::dvr", error = %e, "a sealed room could not be recorded"); } sync_dir(&self.root); let keys: Vec = self.index.lock().unwrap().tracks.iter().filter(|(_, t)| t.room == Some(room)).map(|(k, _)| k.clone()).collect(); for k in &keys { self.drop_track(w, k); } self.rewrite_decisions(|r| *r != room); tracing::info!(target: "joule_moq_relay::dvr", room = %hex::encode(room), tracks = keys.len(), "a sealed room is forgotten"); } /// Delete track `key` from the store: its groups, its directory, its writer state. fn drop_track(&self, w: &mut Writer, key: &TrackKey) { let t = { let mut ix = self.index.lock().unwrap(); let Some(t) = ix.tracks.remove(key) else { return }; let held: u64 = t.groups.values().map(|g| g.bytes).sum(); ix.window_bytes[t.window] = ix.window_bytes[t.window].saturating_sub(held); for (g, grp) in &t.groups { for o in &grp.objects { ix.unindex(key, *g, o); } } if let Some(r) = t.room { ix.series.entry(r).or_default().change(now_us(), -(held as i128), 0, 0, 0); } t }; w.open.remove(key); w.next.remove(key); w.dirty.retain(|p| !p.starts_with(&t.dir)); if let Err(e) = std::fs::remove_dir_all(&t.dir) { tracing::error!(target: "joule_moq_relay::dvr", error = %e, dir = %t.dir.display(), "a sealed room's track could not be deleted"); } w.dirs.insert(self.root.join("tracks")); } /// Write `decisions.wdl` again with only the records whose room `keep` keeps; a record /// that does not read as an event stays. Nothing is written when every record stays. fn rewrite_decisions(&self, keep: impl Fn(&[u8; 32]) -> bool) { let path = self.root.join("decisions.wdl"); let Ok(b) = std::fs::read(&path) else { return }; let (mut out, mut p, mut dropped) = (Vec::with_capacity(b.len()), 0, false); while b.len() >= p + 4 { let n = u32::from_le_bytes(b[p..p + 4].try_into().expect("4")) as usize; let Some(frame) = b.get(p..p + 4 + n + 32) else { break }; if Event::read(&frame[4..4 + n]).is_some_and(|e| !keep(&e.room)) { dropped = true; } else { out.extend_from_slice(frame); } p += 4 + n + 32; } if dropped && let Err(e) = write_durably(&path, &out) { tracing::error!(target: "joule_moq_relay::dvr", error = %e, "the decisions log could not be written again"); } } fn write_seal(&self, w: &mut Writer, key: TrackKey, props: Properties, group: u64, arrived_us: u64, objects: Vec<((u64, u64), CachedObject)>) { let Some(window) = self.window_of(&key.0) else { return }; let room = room_of(&key.0); // A seal queued before its room was found sealed. The room is marked before its // forgetting is queued, and this thread takes jobs in order: a seal it takes after the // mark is refused here, and one it took before is deleted by the forgetting. if room.is_some_and(|r| self.index.lock().unwrap().sealed.contains(&r)) { return; } let events = is_events_track(&key); let dir = self.root.join("tracks").join(track_dir_name(&key)); let known = self.index.lock().unwrap().tracks.contains_key(&key); if !known { if let Err(e) = std::fs::create_dir_all(&dir).and_then(|_| write_durably(&dir.join("TRACK"), &encode_track(&key, &props))) { tracing::error!(target: "joule_moq_relay::dvr", error = %e, "a track could not be stored"); return; } w.dirs.insert(self.root.join("tracks")); let mut ix = self.index.lock().unwrap(); ix.tracks.entry(key.clone()).or_insert_with(|| DTrack { dir: dir.clone(), window, room, events, props: props.clone(), groups: BTreeMap::new(), absent: BTreeMap::new(), evicted_through: None, end_of_track: None, segs: BTreeMap::new() }); } // A seal checked against the window when it was queued can reach the writer after // the window has moved past its group: it is late here too, and never written below // the evicted mark (where it would leave the window two runs). if self.index.lock().unwrap().tracks.get(&key).is_some_and(|t| t.evicted_through.is_some_and(|e| group <= e)) { self.stats.groups_late.fetch_add(1, Ordering::Relaxed); return; } let mut rows: Rows = Vec::with_capacity(objects.len()); let mut erased = Vec::new(); { let ix = self.index.lock().unwrap(); let gone = ix.track_gone(&key, group); for ((_, oid), o) in &objects { let d = ObjectReceipt::hash_payload(&o.payload); // A takedown covers it: it is never stored (WAI takedown §10.1). if gone || ix.taken.contains(&d) { erased.push((*oid, d, ERASED_TAKEN_DOWN)); self.stats.objects_taken_down.fetch_add(1, Ordering::Relaxed); continue; } if room.is_some_and(|r| ix.standing.get(&(r, d)) == Some(&REDACT)) { erased.push((*oid, d, ERASED_REDACTED)); continue; } let records = if events { events::digests(&o.payload) } else { Vec::new() }; let row = DObject { id: *oid, subgroup: o.subgroup, priority: o.publisher_priority, status: o.status, at: 0, props_len: 0, payload_len: 0, digest: d, records }; rows.push((row, Bytes::from(o.properties.to_bytes().unwrap_or_default()), o.payload.clone())); } } let (bytes, mut index) = encode_group(&track_digest(&key), group, arrived_us, &rows, &erased); let (seq, at) = match self.append(w, &key, &dir, &bytes) { Ok(x) => x, Err(e) => { tracing::error!(target: "joule_moq_relay::dvr", error = %e, "a group could not be stored"); return; } }; for o in &mut index { o.at += at + FRAME; } let payload_bytes: u64 = index.iter().map(|o| o.payload_len as u64).sum(); let mut ix = self.index.lock().unwrap(); let Some(t) = ix.tracks.get_mut(&key) else { return }; if t.evicted_through.is_some_and(|e| group <= e) { drop(ix); self.kill(w, &dir, seq, at, bytes.len() as u32); self.stats.groups_late.fetch_add(1, Ordering::Relaxed); return; } for (_, o) in &objects { if let Some(r) = prior_groups(&o.properties, group) { t.absent.insert(r.0, r.1); } } if let Some(((g, oo), _)) = objects.iter().find(|(_, x)| x.status == ObjectStatus::EndOfTrack) { t.end_of_track = Some(Location::new(*g, *oo)); } *t.segs.entry(seq).or_insert(0) += 1; let old = t.groups.insert(group, DGroup { arrived_us, bytes: payload_bytes, objects: index.clone(), erased, seg: seq, frame_at: at, record_len: bytes.len() as u32 }); if let Some(o) = &old && let Some(n) = t.segs.get_mut(&o.seg) { *n = n.saturating_sub(1); } let (old_bytes, old_n) = old.as_ref().map_or((0, 0), |g| (g.bytes, g.objects.len())); ix.window_bytes[window] = (ix.window_bytes[window] + payload_bytes).saturating_sub(old_bytes); if let Some(o) = &old { for x in &o.objects { ix.unindex(&key, group, x); } } for o in &index { ix.reindex(&key, group, o); } let new_objects = index.len().saturating_sub(old_n) as u64; if let Some(r) = room { ix.series.entry(r).or_default().change(now_us(), payload_bytes as i128 - old_bytes as i128, new_objects, payload_bytes.saturating_sub(old_bytes), 0); } drop(ix); if let Some(o) = old { self.kill(w, &dir, o.seg, o.frame_at, o.record_len); let open = w.open.get(&key).map(|s| s.seq); self.retire(&key, o.seg, open); } self.stats.groups_written.fetch_add(1, Ordering::Relaxed); self.stats.objects_written.fetch_add(new_objects, Ordering::Relaxed); self.evict_by_bytes(w, window); } /// Erase what a redaction targets from every group that holds it: the group is written /// again with the object among its erased, and its old record's bytes are overwritten. /// An event record is erased from the events-track object that carries it, the object's /// other records kept byte for byte; an object left with none is erased whole. fn erase(&self, w: &mut Writer, room: Option<[u8; 32]>, digest: [u8; 32], why: u8) { let places: Vec<(TrackKey, u64, u64)> = self.index.lock().unwrap().by_digest.get(&digest).cloned().unwrap_or_default(); for (key, g, oid) in places { let (dir, grp, track_room) = { let ix = self.index.lock().unwrap(); let Some(t) = ix.tracks.get(&key).filter(|t| room.is_none_or(|r| t.room == Some(r))) else { continue }; let Some(grp) = t.groups.get(&g).cloned() else { continue }; (t.dir.clone(), grp, t.room) }; let Some(o) = grp.objects.iter().find(|o| o.id == oid).cloned() else { continue }; let Ok(rec) = read_at(&seg_path(&dir, grp.seg), grp.frame_at + FRAME, grp.record_len as usize) else { continue }; let Some(dg) = decode_group(&rec, &track_digest(&key)) else { continue }; let mut rows: Rows = grp.objects.iter().filter_map(|x| dg.objects.iter().find(|y| y.0.id == x.id).map(|(_, props, payload, _)| (x.clone(), props.clone(), payload.clone()))).collect(); let mut erased = grp.erased.clone(); // The object's new payload, when a record is taken out of it and others stay. let replaced: Option = if o.digest == digest { None } else { let Some((_, _, payload)) = rows.iter().find(|x| x.0.id == oid) else { continue }; events::withhold(payload, |d| *d == digest).map(|p| Bytes::from(p.into_owned())) }; let delta: i128 = match &replaced { None => { rows.retain(|x| x.0.id != oid); erased.push((oid, o.digest, why)); -(o.payload_len as i128) } Some(p) => { for x in rows.iter_mut().filter(|x| x.0.id == oid) { x.0.digest = ObjectReceipt::hash_payload(p); x.0.records = events::digests(p); x.2 = p.clone(); } p.len() as i128 - o.payload_len as i128 } }; let Some(index) = self.rewrite(w, &key, &dir, g, grp.arrived_us, &rows, &erased, (grp.seg, grp.frame_at, grp.record_len)) else { continue }; { let mut ix = self.index.lock().unwrap(); ix.unindex(&key, g, &o); if let Some(x) = index.iter().find(|x| x.id == oid) { ix.reindex(&key, g, x); } if let Some(t) = ix.tracks.get_mut(&key) { let window = t.window; if let Some(gr) = t.groups.get_mut(&g) { gr.bytes = (gr.bytes as i128 + delta).max(0) as u64; } ix.window_bytes[window] = (ix.window_bytes[window] as i128 + delta).max(0) as u64; } if let Some(r) = track_room { ix.series.entry(r).or_default().change(now_us(), delta, 0, 0, 0); } } if why == ERASED_TAKEN_DOWN { self.stats.objects_taken_down.fetch_add(1, Ordering::Relaxed); } else { self.stats.objects_redacted.fetch_add(1, Ordering::Relaxed); } tracing::info!(target: "joule_moq_relay::dvr", dir = %dir.display(), group = g, object = oid, whole = replaced.is_none(), why, "an object erased from the store"); } } fn evict_by_bytes(&self, w: &mut Writer, window: usize) { loop { let victim = { let ix = self.index.lock().unwrap(); if ix.window_bytes[window] <= self.windows[window].max_bytes { return; } oldest(&ix, |t| t.window == window) }; match victim { Some((k, g)) => self.evict(w, &k, g), None => return, } } } fn evict_by_age(&self, w: &mut Writer, now: u64) { for (i, win) in self.windows.iter().enumerate() { let floor = now.saturating_sub(win.max_age_us); loop { let victim = { let ix = self.index.lock().unwrap(); oldest(&ix, |t| t.window == i).filter(|(k, g)| ix.tracks[k].groups[g].arrived_us < floor) }; match victim { Some((k, g)) => self.evict(w, &k, g), None => break, } } } } /// Evict group `g` of `key`, and group `g` of its receipts or media track with it. A /// segment no group stands in any longer is deleted. fn evict(&self, w: &mut Writer, key: &TrackKey, g: u64) { let partner = match self.media_of(&key.1) { Some(m) => (key.0.clone(), m), None => (key.0.clone(), self.receipts_of(&key.1)), }; for k in [key.clone(), partner] { // The mark moves to `g`, and every group at or below it goes: none is ever held // below a track's evicted mark. Groups are sealed out of order under load and a // group's arrival time can be later than a higher group's, so evicting `g` alone // could leave a lower group held while higher ones went: a gap in the window. let victims: Vec = { let mut ix = self.index.lock().unwrap(); let Some(t) = ix.tracks.get_mut(&k) else { continue }; let through = t.evicted_through.map_or(g, |e| e.max(g)); t.evicted_through = Some(through); t.absent.retain(|_, hi| *hi > through); t.groups.range(..=through).map(|(gg, _)| *gg).collect() }; for v in victims { let seg = { let mut ix = self.index.lock().unwrap(); let Some(t) = ix.tracks.get_mut(&k) else { break }; let Some(grp) = t.groups.remove(&v) else { continue }; if let Some(n) = t.segs.get_mut(&grp.seg) { *n = n.saturating_sub(1); } let (window, room) = (t.window, t.room); ix.window_bytes[window] = ix.window_bytes[window].saturating_sub(grp.bytes); for o in &grp.objects { ix.unindex(&k, v, o); } if let Some(r) = room { ix.series.entry(r).or_default().change(now_us(), -(grp.bytes as i128), 0, 0, 0); } grp.seg }; self.stats.groups_evicted.fetch_add(1, Ordering::Relaxed); let open = w.open.get(&k).map(|s| s.seq); self.retire(&k, seg, open); } } } } /// The oldest group held among the tracks `f` selects: by arrival, then track and group. fn oldest(ix: &Index, f: impl Fn(&DTrack) -> bool) -> Option<(TrackKey, u64)> { // Within a track, the lowest group goes first, so a track's window stays one run of // groups: groups finish, and are sealed, out of order under load, and evicting by // arrival left gaps below a late group. Across tracks, the track whose lowest group // arrived first. ix.tracks .iter() .filter(|(_, t)| f(t)) .filter_map(|(k, t)| t.groups.iter().next().map(|(g, grp)| (grp.arrived_us, k.clone(), *g))) .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.2.cmp(&b.2))) .map(|(_, k, g)| (k, g)) } #[cfg(test)] mod tests { use super::*; #[test] fn a_group_record_reads_back_and_a_flipped_byte_is_found() { let td = [7u8; 32]; let o = |id: u64| DObject { id, subgroup: Some(0), priority: 3, status: ObjectStatus::Normal, at: 0, props_len: 0, payload_len: 0, digest: ObjectReceipt::hash_payload(format!("p{id}").as_bytes()), records: vec![] }; let rows: Rows = (0..3).map(|i| (o(i), Bytes::new(), Bytes::from(format!("p{i}")))).collect(); let (mut b, index) = encode_group(&td, 5, 99, &rows, &[(3, [1; 32], ERASED_REDACTED)]); let dg = decode_group(&b, &td).unwrap(); assert_eq!((dg.group, dg.arrived_us, dg.count, dg.erased.len()), (5, 99, 3, 1)); assert!(dg.objects.iter().all(|x| x.3)); assert_eq!(index[1].at, dg.objects[1].0.at); // A flipped payload byte fails that object's check alone. let at = index[1].at as usize; b[at] ^= 0x40; let dg = decode_group(&b, &td).unwrap(); assert_eq!(dg.objects.iter().map(|x| x.3).collect::>(), vec![true, false, true]); // A flipped header byte drops the group; so does another track's record. b[40] ^= 1; assert!(decode_group(&b, &td).is_none()); let (b2, _) = encode_group(&td, 5, 99, &rows, &[]); assert!(decode_group(&b2, &[8; 32]).is_none()); } #[test] fn a_track_record_reads_back() { let key = (TrackNamespace::from_strs(["r", "p", "video"]), TrackName::from_str("0")); let mut p = Properties::new(); p.push(joule_moq::draft22::kvp::KeyValuePair::varint(prop::DEFAULT_PUBLISHER_PRIORITY, 7)); let b = encode_track(&key, &p); assert_eq!(decode_track(&b), Some((key, p))); let mut bad = b.clone(); bad[7] ^= 1; assert!(decode_track(&bad).is_none()); } #[test] fn the_series_integrates_what_is_held() { let mut s = Series::default(); s.change(1_000_000, 100, 1, 100, 0); s.change(3_000_000, 50, 1, 50, 0); // 100 bytes for 2 s, then 150. assert_eq!(s.at(3_000_000).0, 100 * 2_000_000); assert_eq!(s.at(4_000_000).0, 100 * 2_000_000 + 150 * 1_000_000); assert_eq!(s.at(0), (0, 0, 0, 0)); assert_eq!((s.at(5_000_000).1, s.at(5_000_000).2), (2, 150)); } #[test] fn segment_names_read_back() { assert_eq!(seg_seq(&seg_path(Path::new("/x"), 42)), Some(42)); assert_eq!(seg_seq(Path::new("/x/TRACK")), None); } }