openvibers/openvibe.media Commits

docs/media-fabric.md: the accepted design for Media's storage and delivery (owner, 2026-09-29). Two fabrics (online: B2 canonical, R2 Standard hot, Bunny/CDN, the Media host's NVMe edge; archive/backup separately, deep archive never online); provider classes with capability probes and live prices in media.cost_tiers; segment-native CMAF/HLS video with a growing DVR playlist, write-behind segment durability, packing after finalize, keyframes forced on the source's boundaries and one timeline index; clips that reference the source; derivatives as cost-managed computed caches (AV1 for viral VODs); one placement engine per object x region x segment bucket under budgets with two-phase moves, dry-run and a simulator; sticky, canaried multi-CDN routing with a single-flight origin shield and tokenized private media; one telemetry schema, Valkey rollups, player QoE and seek SLOs, cascaded control loops and provider-bill calibration. Build order F1-F6.

OP OpenVibers committed 29 Sept 2026 · 12c6811b72 · 1 file, +189 −0


Changes

ddiff --git a/docs/media-fabric.md b/docs/media-fabric.md
nnew file mode 100644
iindex 0000000..9709adc
---- /dev/null
++++ b/docs/media-fabric.md
@@@ -0,0 +1,189 @@
++# The Media Fabric
++
++**Status:** accepted design, 2026-09-29 (owner direction: "R2 is for faster storage than B2 and it should auto detect
++and load balance automatically while maximizing cost effectiveness and not wasting", plus the Media Fabric write-up of
++the same day). Supersedes the VOD-only R2 popularity sweep and the separate native-object tiering. Build order at the
++end; each phase ships alone.
++
++Media is the authority for every stored byte on the network: objects, timelines, variants, manifests, clips,
++thumbnails, captions, placement, storage and delivery. Products (Live, Video, Pics, Download, Community, Games) are
++presentation; they ask Media for an object and a delivery URL and never know where the bytes live.
++
++## 1. Two fabrics, never mixed
++
++**Online (the Media Fabric).** Every object a viewer can ask for has an immediately retrievable copy:
++
++```
++viewer → media.openvibe.network → delivery router
++ ├─ OpenVibe edge (the Media host's NVMe cache; later edge nodes) capacity-bounded, prepaid bandwidth
++ ├─ global CDN (Bunny in front of B2; Cloudflare only in front of R2)
++ └─ hot object tier: R2 Standard
++ └─ origin shield (nginx slice + cache lock) → B2: the canonical online copy
++```
++
++**Archive/backup (separate).** PostgreSQL (pgBackRest), host snapshots, configs and optional long-term archives go to
++B2 (warm) and, optionally, a deep-archive provider (Glacier Deep Archive, GCS Archive). Nothing online ever fails over
++to deep archive; a restore from it is an operator action, drilled like every other restore.
++
++## 2. Provider classes, not provider names
++
++Business logic asks for a class; providers declare capabilities; the fabric picks.
++
++| Class | Today | Capabilities that make a provider eligible |
++|---|---|---|
++| `online-canonical` | B2 | durable (≥ 11 nines), no minimum duration, free ops, range + 206 + ETag |
++| `online-hot` | R2 Standard | low TTFB, zero egress, range + 206; **not** R2 Infrequent Access for anything seekable (retrieval fee, 30-day minimum) |
++| `global-cdn` | Bunny (origin B2, free Alliance egress); Cloudflare with R2 as origin | range from cache or cache slicing, request coalescing, origin shield, token auth, purge API |
++| `regional-cache` | Media host NVMe (nginx) | prepaid bandwidth up to the port; single-flight per object+range |
++| `bulk-download` | B2/R2 + CDN mirrors | range/resume, checksums |
++| `warm-backup`, `deep-archive` | B2; Glacier/GCS Archive | backup fabric only |
++
++Each adapter passes a capability probe before it becomes eligible for a class: `supports_range`, `supports_206`,
++`supports_if_range`, `supports_etag`, `supports_cache_range`, `token_auth`, `purge`. The probe runs at boot and every
++hour; a failed capability removes the provider from that class until it passes again.
++
++**Economic facts the engine is built on (official price pages, fetched 2026-09-29; kept in the revisioned
++`media.cost_tiers` config, never in code):** R2 Standard $0.015/GB-month, Class A $4.50/M, Class B $0.36/M, zero egress;
++R2 IA $0.01/GB-month + $0.01/GB retrieval + 30-day minimum; B2 ≈ $6–6.95/TB-month, API calls free, egress free up to 3×
++stored data then $0.01/GB, and **unlimited free egress to Alliance CDNs (Bunny, Cloudflare, Fastly, …)**. Consequences:
++
++- Behind a CDN, B2 egress costs nothing. R2 earns its place through **latency** (cold B2 TTFB is hundreds of ms) and by
++ **shielding B2's request ceiling** (≈ 500 requests/s per account by default), not through egress savings.
++- For small objects (segments, thumbnails) **R2 request charges dominate**, not storage: promote by requests saved per
++ dollar, cache presigned URLs, and let the CDN absorb repeat reads.
++- Writes go to B2 first (free PUTs); R2 PUTs (Class A) are spent only on promotion.
++- **Cloudflare's terms restrict video through its CDN to its paid media products (R2, Stream, Images):** video cached by
++ Cloudflare must come from an R2 origin (custom domain); B2-origin video goes through Bunny. Re-verify the terms before
++ enabling a Cloudflare video path.
++- The Media host's bandwidth is prepaid: the router fills the OpenVibe edge first, up to a capacity ceiling (70 % of the
++ port by default), then spills to CDN/R2.
++
++## 3. Video is segment-native (CMAF under HLS)
++
++A video object is a timeline of fragmented-MP4 (CMAF) segments, not a file:
++
++```
++med_xyz/master.m3u8
++med_xyz/source/{init.mp4, 000001.m4s, …} canonical, durable
++med_xyz/{720p,480p,360p,av1-…}/… derivatives, rebuildable
++med_xyz/{captions/*.vtt, storyboard.webp+.vtt, waveform, metadata}
++```
++
++- **Live → DVR → VOD without a conversion step.** OpenRe segments continuously; the playlist grows while live (seek
++ anywhere from the start to the live edge) and gets `#EXT-X-ENDLIST` at the end. Live latency modes: WebRTC (sub-second,
++ SFU), LL-HLS (partial segments, ~2–4 s), HLS DVR. When the SFU nears capacity, new viewers move to LL-HLS.
++- **Write-behind durability for live.** A segment lands on the ingest node's NVMe first (served from RAM/NVMe for the
++ first minutes: `segment_age` is a routing input), then uploads to B2 asynchronously. A segment is *durable* once B2
++ confirms it; the timeline records that. A crash loses only the not-yet-uploaded tail, bounded by the upload lag,
++ which is a metric with an alert.
++- **Packing after finalize.** Two-second segments are perfect while live but produce thousands of objects per hour.
++ After finalize, segments are packed into ~60-second chunk objects (byte-range playlists, `EXT-X-BYTERANGE`): ~30×
++ fewer objects, PUTs, HEADs and metadata rows, while placement stays at minute granularity.
++- **Keyframes.** Browser (WHIP) sources have irregular keyframes, so the source representation segments on the source's
++ own keyframes (variable segment duration is legal HLS). Every derived rendition forces keyframes at exactly the
++ source's segment boundaries (`-force_key_frames` from the timeline), so ABR switches and clips line up everywhere.
++- **The timeline index** (`media_timeline`: object, rendition, segment number, start/end time, keyframe time, packed
++ object + byte offset/length, sha256, durability) answers "the bytes for 01:47:12.350" without opening a file, and is
++ the shared axis for DVR, seek, clips, sprites, captions, transcripts, chapters, AI moments and moderation markers.
++- **MP4 stays for downloads and old clients:** a remuxed faststart MP4 (lazy, cached like any derivative) with plain
++ HTTP ranges.
++
++## 4. Clips reuse the source
++
++- **Virtual clip** (default): `{source, start, end}` → a clipped manifest over the source's segments. No bytes copied.
++- **Materialized clip** (shared externally, downloaded, edited, popular): middle GOPs are **referenced**, not copied
++ (the new manifest points at the source's packed chunks); only the two boundary segments are re-encoded for
++ frame-accurate cuts. Segments and chunks are content-addressed (sha256) with reference counts, so deleting a VOD
++ that a clip still references keeps the referenced chunks (and holds still freeze everything).
++
++## 5. Derivatives are cost-managed computed caches
++
++Canonical source media is durable. Renditions, codecs (AV1/HEVC), storyboards and sprites, waveforms, image sizes and
++formats (AVIF/WebP), preview clips and AI thumbnails are `rebuildable = true`:
++
++- generated **on demand** (the first request enqueues the job and is served from the source meanwhile) or **ahead of
++ demand** when the class predicts it (a live stream with a big audience gets 480p/720p while live; a trending VOD gets
++ its ladder prewarmed);
++- **kept only while cheaper than regenerating**: keep if `storage_cost(horizon) + delivery_cost < p(regenerate) ×
++ regeneration_cost`, else drop and rebuild on the next request;
++- **codec economics:** for a viral VOD, an AV1 rendition is produced when the egress/CDN bytes it saves exceed its encode
++ cost; for a cold VOD, never;
++- compute is placed by the Compute fabric (cheapest available CPU/GPU, `openvibe-sdk/placement`).
++
++## 6. Placement: one engine, every class, down to the segment bucket
++
++- **Hotness is per object × region × segment bucket** (5-minute buckets for video: beginnings are hotter than middles).
++ A two-hour VOD can have its first 10 minutes on R2 + CDN + edge, minutes 10–40 on R2, the rest only on B2, and
++ promote minute 60 when viewers start landing there.
++- **Decision rule:** value-per-dollar greedy under per-class budgets (the knapsack is near-optimal when costs are
++ monotone): expected requests and bytes saved × latency value, against storage + requests + retrieval + minimum
++ residency, with hysteresis (promote above P, demote below D < P), minimum residency, and rate limits on moves.
++- **Intents** set class defaults: `video` (segments, seek-sensitive), `image` (tiny, cache hard), `download`
++ (throughput + cost, mirrors, resume), `game-asset` (small hot assets global; large packs mirrored), `attachment`,
++ `backup` (archive fabric only).
++- **Every move is two-phase:** verify the new copy (size + sha256), commit the location in one transaction, then
++ delete old bytes after commit, with a retrying cleanup job and an alert after three failures. The row never points
++ at bytes that do not exist.
++- **Dry-run and simulator:** every policy can run in dry-run mode (logs what it would do and what it would cost), and a
++ simulator replays recorded telemetry against a candidate policy before it is enabled.
++
++## 7. Delivery: sticky, measured, canaried
++
++- **Route epochs:** a playback session gets a route (primary + fallbacks, TTL ~30 s) and keeps it until a health
++ failure, a significant latency change, a capacity or cost guard, or TTL; changes happen at segment boundaries.
++- **Canary shifts:** moving traffic between routes goes 1 % → 5 % → 25 % → 50 % → 100 %, watching errors, TTFB,
++ throughput, seek failures, player stalls and cost, and reverts automatically.
++- **Origin protection:** the OpenVibe edge collapses concurrent misses (nginx `proxy_cache_lock` + `slice` = one upstream
++ fetch per object+range per shield); Bunny's origin shield and request coalescing in front of B2; presigned-URL LRU in
++ Media for provider request costs.
++- **Access control never leaks to caches:** private and unlisted media use tokenized URLs (Bunny token auth, signed R2
++ URLs); the cache key excludes the token; private bytes are never pushed to a public cache.
++- **Deletion reaches everything:** a delete, DMCA takedown or account erasure (ADR-033) removes every replica and
++ derivative and purges every CDN path, and is verified.
++
++## 8. The closed loop
++
++```
++request/playback → telemetry → Events → {Hotness, Health, Cost} engines → Placement engine → route/replica change → Delivery
++```
++
++- **One telemetry sample schema** (`openvibe.telemetry.sample@1`: service, project, resource, provider, node, region,
++ operation, bytes, duration, ttfb, throughput, status, cache status, cost estimate, route epoch, trace id), shared by
++ Media, OpenRe, Host, AI, Events and Compute.
++- **Aggregation, not rows per request:** nginx JSON access logs and player beacons are folded into rolling Valkey
++ counters (EWMA + p50/p95/p99 over 10 s, 1 min, 5 min, 1 h, 24 h), HyperLogLog for unique viewers, sorted sets for the
++ top-N hot objects and buckets; PostgreSQL gets 5-minute rollups per object × region × bucket; raw samples go to
++ object storage in batches.
++- **Player QoE counts, not only HTTP:** startup time, rebuffers, fatal errors, seek latency, quality switches, bitrate,
++ live-edge delay (privacy-bounded, a random session id, no user id). **SLOs:** cold seek p95 < 500 ms to the first
++ playable segment, hot seek < 200 ms, live rewind < 150 ms, startup p95 < 1.5 s.
++- **Loops cooperate by time constant and precedence:** player ABR (seconds) ⊂ delivery routing (30 s–5 min) ⊂ placement
++ (minutes–hours) ⊂ cost (hours–days). Each outer loop only constrains the inner (budgets bound placement; placement
++ bounds routes); every loop uses hysteresis and rate limits.
++- **Events emitted:** `media.object.hot`, `media.replica.requested|ready|draining|evicted`, `media.variant.requested|ready`,
++ `media.region.hot`, `media.delivery.surge|degraded`, `provider.health.degraded`, `provider.capacity.warning`,
++ `provider.cost.threshold`.
++
++## 9. Cost truth
++
++`media.cost_tiers` holds list prices; estimates drive decisions; **provider bills calibrate them**: R2 analytics
++(GraphQL), B2 usage reports, Bunny statistics API are imported daily, compared with the estimate, and the gap is a
++metric. Budgets per class and provider with a forecast; `provider.cost.threshold` at 90 % shifts non-critical traffic.
++
++## 10. Build order
++
++- **F1 foundation (now):** provider registry + capability probes; `media.cost_tiers` + per-class budgets in a revisioned
++ `media.storage_policy` (retiring `media.storage_tier`/`media.object_tier`); the read router (EWMA latency, error
++ circuit breaker, continuous health probe, fastest healthy copy) in `server/placement/`; presigned-URL LRU; two-phase
++ moves with a cleanup job; per-class hysteresis bands; `media_storage_alerts_total`, provider latency histograms; the
++ corrected price table; the nginx shield (slice + cache lock) on the Media host.
++- **F2 one placement engine:** every class through one sweep, value-per-dollar under budgets, dry-run + simulator,
++ decision log with class and reason, the events above, Valkey rollups of demand.
++- **F3 segment-native video:** CMAF/HLS recording with the growing DVR playlist, the timeline index, packing after
++ finalize, virtual clips, sprites and captions on the timeline, MP4 fallback; Live's player moves to HLS.
++- **F4 reactive derivatives:** on-demand renditions and image variants, keep-vs-regenerate economics, AV1 for viral
++ VODs, compute placement.
++- **F5 multi-CDN delivery:** Bunny path (B2 origin), R2 custom-domain path, OpenVibe edge first up to capacity, route
++ epochs, canary shifts with auto-revert, player QoE beacons, seek SLOs.
++- **F6 cost truth:** bill imports, forecasts, budget guards, the owner dashboard.
++- **Archive fabric:** backups and snapshots to B2; optional deep archive with restore drills; never online.