open-standards/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs
Transaction Science 8f8ba256a8 joule-moq-relay, joule-moq-remote: build on Windows
The release's Windows leg failed to compile joule-moq-relay: the DVR read
and wrote its segments through std::os::unix::fs::FileExt. On Windows the
same two calls now run over seek_read and seek_write. wai-remote-host would
have failed next on its Unix file modes; they now apply on Unix only, as the
room's keys already do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:38:14 -04:00

2132 lines
98 KiB
Rust

//! 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<n, 20 digits>.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/<link>.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;
#[cfg(unix)]
use std::os::unix::fs::FileExt;
#[cfg(windows)]
use windows_at::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<u64>,
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<DObject>,
/// 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<u64, DGroup>,
/// Groups known not to exist (a Prior Group ID Gap, MOQT §10.8), `lo ..= hi` by `lo`.
absent: BTreeMap<u64, u64>,
/// Groups at or below this are no longer known to the store.
evicted_through: Option<u64>,
end_of_track: Option<Location>,
/// Each segment, with the groups whose standing record is in it.
segs: BTreeMap<u64, usize>,
}
impl DTrack {
fn largest(&self) -> Option<Location> {
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<TrackKey, DTrack>,
window_bytes: Vec<u64>,
/// 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<u64>,
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<u64>,
pub absent: Vec<(u64, u64)>,
pub evicted_through: Option<u64>,
pub largest: Option<Location>,
pub end_of_track: Option<Location>,
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<u64> {
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<u8>),
Forget([u8; 32]),
TakeDown { t: crate::takedown::Takedown, rec: Vec<u8> },
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<TrackKey, OpenSeg>,
next: HashMap<TrackKey, u64>,
dirty: HashSet<PathBuf>,
dirs: HashSet<PathBuf>,
last_sync: Option<Instant>,
}
pub struct Dvr {
root: PathBuf,
windows: Vec<Window>,
suffix: Vec<u8>,
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<SigningKey>,
index: Mutex<Index>,
tx: Mutex<Option<mpsc::Sender<Job>>>,
worker: Mutex<Option<std::thread::JoinHandle<()>>>,
pub stats: DvrStats,
}
fn put_short_field(o: &mut Vec<u8>, 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<u64> {
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<u8>, Vec<DObject>) {
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<Decoded> {
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<u8> {
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<Vec<u8>> {
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<Vec<u8>> {
let f = std::fs::File::open(path)?;
let mut b = vec![0u8; len];
f.read_exact_at(&mut b, at)?;
Ok(b)
}
/// Unix's `read_exact_at` and `write_all_at`, over Windows' `seek_read` and `seek_write`.
/// These move the file's cursor, which nothing here reads: every segment write is positional.
#[cfg(windows)]
mod windows_at {
use std::io::{Error, ErrorKind, Result};
use std::os::windows::fs::FileExt as _;
pub trait FileExt {
fn read_exact_at(&self, buf: &mut [u8], at: u64) -> Result<()>;
fn write_all_at(&self, buf: &[u8], at: u64) -> Result<()>;
}
impl FileExt for std::fs::File {
fn read_exact_at(&self, mut buf: &mut [u8], mut at: u64) -> Result<()> {
while !buf.is_empty() {
match self.seek_read(buf, at) {
Ok(0) => return Err(Error::from(ErrorKind::UnexpectedEof)),
Ok(n) => {
buf = &mut buf[n..];
at += n as u64;
}
Err(e) if e.kind() == ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(())
}
fn write_all_at(&self, mut buf: &[u8], mut at: u64) -> Result<()> {
while !buf.is_empty() {
match self.seek_write(buf, at) {
Ok(0) => return Err(Error::from(ErrorKind::WriteZero)),
Ok(n) => {
buf = &buf[n..];
at += n as u64;
}
Err(e) if e.kind() == ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(())
}
}
}
/// 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<SigningKey>, verifier: impl Fn(&TrackNamespace) -> ReceiptVerifier) -> Result<Arc<Dvr>, 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::<Vec<_>>();
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<usize> {
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<Snapshot> {
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<Location> {
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<Properties> {
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<Vec<Slot>> {
// 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<u64> {
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<PathBuf> {
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<u64>) {
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<Vec<DObject>> {
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<crate::takedown::Takedown> = 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<u64, Rows>)> = 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<u64, Rows> = 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<TrackKey> = loaded.iter().map(|(k, _, _)| k.clone()).collect();
let receipts: HashMap<TrackKey, HashMap<u64, Bytes>> = 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<u64> = 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<DObject> = 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<DObject> = 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<TrackName> {
(!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<Self>, mut w: Writer, rx: mpsc::Receiver<Job>) {
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<TrackKey> = 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<Bytes> = 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<u64> = {
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<_>>(), 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);
}
}