diff --git a/Cargo.lock b/Cargo.lock index 2c1e5dee84f..491abc29458 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5608,6 +5608,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "tokio", + "uuid", "workspace-hack", "zeroize", ] @@ -5821,7 +5822,6 @@ dependencies = [ "tokio", "tracing", "url", - "uuid", "workspace-hack", ] @@ -6178,6 +6178,7 @@ dependencies = [ "thiserror 2.0.21", "tokio", "tracing", + "uuid", "workspace-hack", ] diff --git a/bun.lock b/bun.lock index 91a99485f37..eb0a8ba7b6e 100644 --- a/bun.lock +++ b/bun.lock @@ -168,6 +168,7 @@ "version": "0.0.0", "dependencies": { "@moq/net": "workspace:^", + "uuid": "^14.0.3", }, "devDependencies": { "@moq/clock": "workspace:^", @@ -287,7 +288,6 @@ "async-mutex": "^0.5.0", "pako": "^3.0.2", "sorted-btree": "^2.1.0", - "uuid": "^14.0.3", }, "devDependencies": { "@types/bun": "^1.4.4", diff --git a/c/moq/MIGRATE.md b/c/moq/MIGRATE.md index 8b30e7adfd4..e2a9d73e5cd 100644 --- a/c/moq/MIGRATE.md +++ b/c/moq/MIGRATE.md @@ -86,7 +86,7 @@ Publish a replacement under a new name, and expect a finished track's demand ter ### A claim answers by publishing (#5678) `moq_origin_dynamic` takes no route, and `moq_origin_dynamic_update` is gone. -`moq_broadcast_request_accept` is replaced by `moq_broadcast_request_prepare`, which returns a prepared answer under the epoch you pass: fill its broadcast, then `moq_broadcast_prepared_publish` announces it at the exact path under that epoch. +`moq_broadcast_request_accept` is replaced by `moq_broadcast_request_prepare`, which returns a prepared answer: fill its broadcast, then `moq_broadcast_prepared_publish` announces it at the exact path under a fresh instance of its own. `moq_broadcast_prepared_free` refuses an answer you will not publish. ### `moq_track_info` gains `compress` (#5928) diff --git a/cpp/moq/README.md b/cpp/moq/README.md index dd1255cb823..19aeab1e9b8 100644 --- a/cpp/moq/README.md +++ b/cpp/moq/README.md @@ -1,6 +1,6 @@ # moq C++ package -C++17 bindings for [rs/moq-ffi](../../rs/moq-ffi): the sources `uniffi-bindgen-cpp` generates, plus `include/moq/moq.hpp`, a thin layer that adds the executor, shutdown, and `co_await` glue. `uniffi.toml` strips moq-ffi's `Moq` type prefix and `moq_` function prefix, so `MoqClient` is generated as `moq::Client` and `moq_mint_epoch` as `moq::mint_epoch`. Nothing here re-implements a method; a shape problem is fixed in moq-ffi, where every binding benefits. User docs: [doc/lib/cpp](../../doc/lib/cpp/index.md). +C++17 bindings for [rs/moq-ffi](../../rs/moq-ffi): the sources `uniffi-bindgen-cpp` generates, plus `include/moq/moq.hpp`, a thin layer that adds the executor, shutdown, and `co_await` glue. `uniffi.toml` strips moq-ffi's `Moq` type prefix and `moq_` function prefix, so `MoqClient` is generated as `moq::Client` and `moq_log_level` as `moq::log_level`. Nothing here re-implements a method; a shape problem is fixed in moq-ffi, where every binding benefits. User docs: [doc/lib/cpp](../../doc/lib/cpp/index.md). ## Layout diff --git a/cpp/obs/src/moq-dock.cpp b/cpp/obs/src/moq-dock.cpp index 1fa0c448d2c..21699589dd2 100644 --- a/cpp/obs/src/moq-dock.cpp +++ b/cpp/obs/src/moq-dock.cpp @@ -1124,11 +1124,6 @@ void MoQDock::StartStream() return; } - if (auto *moq = static_cast(obs_obj_get_data(output))) { - const auto epoch = moq->GetEpoch(); - if (!epoch.empty()) - publishSummary += QStringLiteral("\nEpoch ") + QString::fromStdString(epoch); - } pollTimer->start(); SetRunning(true); diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index b6791870781..a10a03ca551 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -167,7 +167,8 @@ bool MoQOutput::Start() } // Create the broadcast on an origin of our own, then announce it so subscribers - // can discover it. Stop() finishes it, so each Start creates a fresh one. Only the + // can discover it. Stop() finishes it, so each Start creates a fresh one, under a + // fresh instance the broadcast mints and keeps across reconnects. Only the // broadcast is kept: the route outlives the publication until the broadcast closes. LOG_INFO("Publishing broadcast: %s", path.c_str()); auto next_origin = moq::OriginProducer::init(moq::OriginConfig{}); @@ -176,9 +177,7 @@ bool MoQOutput::Start() LOG_ERROR("Failed to create broadcast: %s", publication.error().to_string().c_str()); return false; } - moq::AnnounceRoute route; - route.epoch = moq::mint_epoch(); - if (auto announced = (*publication)->announce(route); !announced) { + if (auto announced = (*publication)->announce({}); !announced) { LOG_ERROR("Failed to announce broadcast: %s", announced.error().to_string().c_str()); return false; } @@ -229,7 +228,6 @@ bool MoQOutput::Start() { std::lock_guard lock(mutex); url = server; - epoch = *route.epoch; } { std::lock_guard lock(media_mutex); @@ -371,7 +369,6 @@ void MoQOutput::Reset() connected = false; live = false; url.clear(); - epoch.clear(); last_failure_code = MoQError::None; last_failure_reason.clear(); token_refusal.clear(); @@ -428,12 +425,6 @@ void MoQOutput::Reset() SetBitrate(encoder, bitrate); } -std::string MoQOutput::GetEpoch() -{ - std::lock_guard lock(mutex); - return epoch; -} - bool MoQOutput::TryGetConnectionStats(ConnectionStats *out) { if (!out) diff --git a/cpp/obs/src/moq-output.h b/cpp/obs/src/moq-output.h index 00ed4ea892c..f77ac44e0a3 100644 --- a/cpp/obs/src/moq-output.h +++ b/cpp/obs/src/moq-output.h @@ -60,9 +60,6 @@ class MoQOutput { // Prefer this over obs_output_get_connect_time_ms, which stays 0 for sub-ms connects. bool IsLiveSession(); - // Publisher epoch of this Start(), retained through session reconnects; empty when stopped. - std::string GetEpoch(); - // Most recent connect/reconnect failure for this Start(), or empty when none. // Thread-safe; the dock polls this while connecting, while reconnecting, and on stop. void CopyLastFailure(int *code, std::string *reason); @@ -174,7 +171,6 @@ class MoQOutput { bool connected = false; bool live = false; std::string url; - std::string epoch; int last_failure_code = 0; std::string last_failure_reason; std::string token_refusal; diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp index fad23a832b9..626f96e3b6e 100644 --- a/cpp/obs/src/moq-source.cpp +++ b/cpp/obs/src/moq-source.cpp @@ -940,7 +940,7 @@ static bool moq_source_same_video(const Track *track, const std::string &id, con const bool same_coded = old.coded.has_value() == config.coded.has_value() && (!old.coded || (old.coded->width == config.coded->width && old.coded->height == config.coded->height)); - return old.broadcast == config.broadcast && old.epoch == config.epoch && old.codec == config.codec && + return old.broadcast == config.broadcast && old.instance == config.instance && old.codec == config.codec && old.description == config.description && same_coded && moq_source_same_container(old.container, config.container); } @@ -953,7 +953,7 @@ static bool moq_source_same_audio(const Track *track, const std::string &id, con // The same track under a new catalog ID is the same rendition. if (old.track.value_or(track->id) != config.track.value_or(id)) return false; - return old.broadcast == config.broadcast && old.epoch == config.epoch && old.codec == config.codec && + return old.broadcast == config.broadcast && old.instance == config.instance && old.codec == config.codec && old.description == config.description && old.sample_rate == config.sample_rate && old.channel_count == config.channel_count && moq_source_same_container(old.container, config.container); } diff --git a/cpp/obs/test/moq-audio-encoder-test.cpp b/cpp/obs/test/moq-audio-encoder-test.cpp index d8f3dc6c5dc..f7f19fd9b15 100644 --- a/cpp/obs/test/moq-audio-encoder-test.cpp +++ b/cpp/obs/test/moq-audio-encoder-test.cpp @@ -205,9 +205,7 @@ void CheckSubscriberDecodes(const char *preset, size_t channels) auto origin = moq::OriginProducer::init(moq::OriginConfig{}); auto publication = TestOk(origin->create_broadcast("room"), "create_broadcast"); auto broadcast = publication->broadcast(); - moq::AnnounceRoute route; - route.epoch = moq::mint_epoch(); - TestOk(publication->announce(route), "announce"); + TestOk(publication->announce({}), "announce"); moq::AudioInit init{}; init.format = moq::AudioFormat::kOpus; diff --git a/cpp/obs/test/moq-output-test.cpp b/cpp/obs/test/moq-output-test.cpp index 49cd110f8b3..975f97b7cc9 100644 --- a/cpp/obs/test/moq-output-test.cpp +++ b/cpp/obs/test/moq-output-test.cpp @@ -366,7 +366,7 @@ std::optional relayCatalog(const std::shared_ptr Epoch(MoQOutput &output) + static std::optional Instance(MoQOutput &output) { std::shared_ptr origin; { @@ -381,7 +381,9 @@ struct MoQOutputStatsTest { if (!event) return std::nullopt; const auto *start = std::get_if(&event->get_variant()); - return start ? start->announce.route.epoch : std::nullopt; + if (!start) + return std::nullopt; + return start->announce.route.instance; } static std::shared_ptr Session(MoQOutput &output) @@ -457,26 +459,22 @@ int main() reset(relay.Url()); MoQOutput output(nullptr, OUTPUT); CHECK(output.Start()); - const auto first = MoQOutputStatsTest::Epoch(output); - CHECK(first.has_value()); - CHECK(first == output.GetEpoch()); + const auto first = MoQOutputStatsTest::Instance(output); + CHECK(first.has_value() && !first->empty()); CHECK(WaitFor([&] { return output.IsLiveSession() && relay.Accepted() == 1; })); // The plugin names itself ahead of the C++ package, so a relay can tell it apart. CHECK(relay.Advertised("moq-obs/dev moq-cpp/")); relay.DropSessions(); CHECK(WaitFor([&] { return output.GetReconnectCount() == 1 && output.IsLiveSession(); })); - CHECK(MoQOutputStatsTest::Epoch(output) == first); - CHECK(first == output.GetEpoch()); + CHECK(MoQOutputStatsTest::Instance(output) == first); output.Stop(); - CHECK(output.GetEpoch().empty()); CHECK(output.Start()); - const auto second = MoQOutputStatsTest::Epoch(output); - CHECK(second.has_value()); + const auto second = MoQOutputStatsTest::Instance(output); + CHECK(second.has_value() && !second->empty()); CHECK(second != first); - CHECK(second == output.GetEpoch()); output.Stop(); } - printf("each start announces a fresh epoch, reconnect preserves it: ok\n"); + printf("each start announces a fresh instance, reconnect preserves it: ok\n"); { TestRelay relay; diff --git a/cpp/obs/test/moq-source-test.cpp b/cpp/obs/test/moq-source-test.cpp index 85ce5d8943a..425b6b1a124 100644 --- a/cpp/obs/test/moq-source-test.cpp +++ b/cpp/obs/test/moq-source-test.cpp @@ -1351,7 +1351,7 @@ int main() } report("all-disabled section holds the playing rendition"); - // A rendition's publisher epoch is part of its identity, for the active and the pending + // A rendition's stream instance is part of what names it, for the active and the pending // track alike: a sibling publisher restarting behind a stable catalog resubscribes. { TestRelay relay; @@ -1364,20 +1364,20 @@ int main() { std::lock_guard lock(ctx->mutex); const auto &conn = ctx->connection; - snapshot.video.begin()->second.epoch = "01900000-0000-7000-8000-000000000001"; - snapshot.audio.begin()->second.epoch = "01900000-0000-7000-8000-000000000001"; + snapshot.video.begin()->second.instance = "01900000-0000-7000-8000-000000000001"; + snapshot.audio.begin()->second.instance = "01900000-0000-7000-8000-000000000001"; moq_source_subscribe_video(ctx, conn, snapshot); moq_source_subscribe_audio(ctx, conn, snapshot); CHECK(conn->pending_video && conn->pending_audio); const auto pending_video = conn->pending_video; const auto pending_audio = conn->pending_audio; - // The same epoch keeps the pending track. + // The same instance keeps the pending track. moq_source_subscribe_video(ctx, conn, snapshot); moq_source_subscribe_audio(ctx, conn, snapshot); CHECK(conn->pending_video == pending_video && conn->pending_audio == pending_audio); - // Another epoch replaces it. - snapshot.video.begin()->second.epoch = "01900000-0000-7000-8000-000000000002"; - snapshot.audio.begin()->second.epoch = "01900000-0000-7000-8000-000000000002"; + // Another instance replaces it. + snapshot.video.begin()->second.instance = "01900000-0000-7000-8000-000000000002"; + snapshot.audio.begin()->second.instance = "01900000-0000-7000-8000-000000000002"; moq_source_subscribe_video(ctx, conn, snapshot); moq_source_subscribe_audio(ctx, conn, snapshot); CHECK(conn->pending_video && conn->pending_video != pending_video); @@ -1385,7 +1385,7 @@ int main() } destroySource(source); } - report("epoch change resubscribes"); + report("instance change resubscribes"); // A new rendition that has not delivered a frame cannot stop the old track. { @@ -1607,7 +1607,7 @@ int main() if (sibling) { hidden = TestOk(relay.origin->create_broadcast("obs/hidden"), "unannounced sibling"); config.broadcast = "./hidden"; - config.epoch = "01900000-0000-7000-8000-000000000001"; + config.instance = "obs-test-pinned"; } snapshot->video.clear(); snapshot->video.emplace("!unavailable", config); @@ -1627,9 +1627,9 @@ int main() // An unannounced sibling stays pending, without interrupting current playback. const int frames = g_output_frames; CHECK(publisher.WriteUntil([&] { return g_output_frames >= frames + 3; })); - // A different publisher instance is a real resolution failure. + // A different stream is a real resolution failure. moq::AnnounceRoute route; - route.epoch = "01900000-0000-7000-8000-000000000002"; + route.instance = "obs-test-other"; TestOk(hidden->announce(route), "announce mismatched sibling"); } CHECK(WaitFor([] { diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index 5348c1d3d25..38f4d5f1f5b 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -62,8 +62,8 @@ the matching generated bindings and native library. ### `announce` takes an `AnnounceRoute` -`BroadcastProducer.announce(route:)` takes `AnnounceRoute(cost:, epoch:)` instead of a `MoqRoute`: the origin builds the hop chain, so `hops` is no longer an input. -`MoqRoute` is only what announcements report. To re-announce an observed route, copy its `cost` and `epoch`. +`BroadcastProducer.announce(route:)` takes `AnnounceRoute(cost:, instance:, epoch:)` instead of a `MoqRoute`: the origin builds the hop chain, so `hops` is no longer an input. +`MoqRoute` is only what announcements report. To re-announce an observed route, copy its `cost`, and its `instance` only when forwarding that same stream; leave `instance` unset to keep the one the broadcast minted. ### Cancelling a stream cancels its pending read @@ -117,11 +117,11 @@ moq-transport and moq-lite-03/04 sessions are unchanged. A container import with `ContainerFormat.fmp4` now publishes one Legacy frame per sample instead of one CMAF frame per fragment. Pass the new `ContainerFormat.cmaf` to keep publishing CMAF. -### Catalog references carry their epoch +### Catalog references carry their instance -`Video` and `Audio` renditions gain `epoch`, the publisher instance their `broadcast` reference names. -`BroadcastConsumer.resolve(reference:)` becomes `resolve(rendition:)`, taking the catalog `Rendition` and reading its `broadcast` and `epoch` from it. -Resolving, and the rendition-based consumers, throw for a rendition whose broadcast is proven to serve another epoch. +`Video` and `Audio` renditions gain `instance`, the publisher instance their `broadcast` reference names. +`BroadcastConsumer.resolve(reference:)` becomes `resolve(rendition:)`, taking the catalog `Rendition` and reading its `broadcast` and `instance` from it. +Resolving, and the rendition-based consumers, throw for a rendition whose broadcast is proven to serve another instance. ### Timestamps stop at 2^61 - 1 microseconds (#6168) @@ -137,7 +137,7 @@ Publish a replacement under a new name, and expect a finished track's demand wat ### A claim answers by publishing (#5678) `origin.dynamic_(prefix:)` takes no route, and `update` is gone. -Replace `request.accept(broadcast:)` with `request.prepare(epoch: mintEpoch())`, which returns a `BroadcastPrepared`: fill its `broadcast()`, then `publish()` announces it at the exact path under that epoch and returns its producer. +Replace `request.accept(broadcast:)` with `request.prepare()`, which returns a `BroadcastPrepared`: fill its `broadcast()`, then `publish()` announces it at the exact path under a fresh instance of its own and returns its producer. ### Anchors can name a time (#6074) diff --git a/dart/moq/lib/moq.dart b/dart/moq/lib/moq.dart index 25bbc4f11f9..d637d3884b9 100644 --- a/dart/moq/lib/moq.dart +++ b/dart/moq/lib/moq.dart @@ -5,6 +5,5 @@ export 'package:moq_ffi/moq_ffi.dart'; export 'src/aliases.dart'; export 'src/client.dart'; -export 'src/epoch.dart'; export 'src/server.dart'; export 'src/timestamp.dart'; diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 3bb32a24f04..6d446d10505 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -64,7 +64,7 @@ typedef AnnounceConfig = MoqAnnounceConfig; /// Follows a specific broadcast path across the publisher instances that serve it. typedef Following = MoqFollowing; -/// How a broadcast advertises its exact path: its static production cost and publisher epoch. +/// How a broadcast advertises its exact path: its static production cost and stream instance. typedef AnnounceRoute = MoqAnnounceRoute; /// A route over a prefix: its origin-relative path, required wildcard captures (empty without wildcards), and route metadata. diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index b998ee6353f..267834ddf62 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -230,15 +230,15 @@ final class Moq { }) => session.consume().announced(config: options._ffi); /// Follow the broadcast at exactly [path]: each `available()` resolves the - /// next publisher instance serving it, such as a restarted publisher. Pass an - /// [epoch] (UUIDv7 text) to follow only that instance. - Following follow(String path, {String? epoch}) => - session.consume().follow(path: path, epoch: epoch); + /// next instance serving it, such as a restarted publisher. Pass an + /// [instance] to follow only that stream. + Following follow(String path, {String? instance}) => + session.consume().follow(path: path, instance: instance); /// Resolve one publisher instance of the broadcast at [path]. Pass an - /// [epoch] (UUIDv7 text) to refuse any other instance. - Future request(String path, {String? epoch}) => - session.consume().request(path: path, epoch: epoch); + /// [instance] to refuse any other stream. + Future request(String path, {String? instance}) => + session.consume().request(path: path, instance: instance); /// The session's bandwidth allocator. /// diff --git a/dart/moq/lib/src/epoch.dart b/dart/moq/lib/src/epoch.dart deleted file mode 100644 index c0db608e414..00000000000 --- a/dart/moq/lib/src/epoch.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:moq_ffi/moq_ffi.dart'; - -/// Mint a fresh publisher epoch from the wall clock and secure randomness, -/// ordered newest last. Mint one per publisher run and announce it in -/// `AnnounceRoute.epoch`, so viewers see a restart as a new broadcast instead of a -/// stalled one. -String mintEpoch() => moqMintEpoch(); diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index d13f5c72f20..e2cafbde1e4 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -516,24 +516,22 @@ void main() { replacement.broadcast().close(); }); - test('a new epoch restarts announce consumers', () async { + test('a new instance restarts announce consumers', () async { final origin = MoqOriginProducer(config: MoqOriginConfig()); final announced = origin.consume().announced(config: MoqAnnounceConfig()); - final publication = origin.createBroadcast(path: 'epoch'); + final publication = origin.createBroadcast(path: 'instance'); - final first = mintEpoch(); - publication.announce(route: AnnounceRoute(epoch: first)); + publication.announce(route: AnnounceRoute()); final start = await nextRoute(announced) as AnnounceEventStart; - expect(start.announce.route.epoch, first); + expect(start.announce.route.instance, isNotEmpty); - final second = mintEpoch(); - publication.announce(route: AnnounceRoute(epoch: second)); + publication.announce(route: AnnounceRoute(instance: 'camera', epoch: 1)); final restart = await nextRoute(announced) as AnnounceEventRestart; - expect(restart.announce.route.epoch, second); + expect(restart.announce.route.instance, 'camera'); expect( - () => publication.announce(route: AnnounceRoute(epoch: 'not-an-epoch')), - throwsA(isA()), + () => publication.announce(route: AnnounceRoute(instance: '')), + throwsA(isA()), ); announced.cancel(); announced.dispose(); @@ -558,7 +556,7 @@ void main() { final pending = origin.consume().request(path: 'live/cam'); final request = await dynamic.requestedBroadcast().timeout(timeout); expect(request.path(), 'live/cam'); - final prepared = request.prepare(epoch: mintEpoch()); + final prepared = request.prepare(); final served = prepared.publish(); await pending.timeout(timeout); // The publication controls the prepared fill: closing that ends its announcement. diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index f1cddc8aa09..6fa18a6839c 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -1163,7 +1163,7 @@ class FfiConverterMoqJsonWindowTimed { class MoqAudio { final String? label; final String? broadcast; - final String? epoch; + final String? instance; final String? track; final String codec; final Uint8List? description; @@ -1176,7 +1176,7 @@ class MoqAudio { MoqAudio({ this.label = null, this.broadcast = null, - this.epoch = null, + this.instance = null, this.track = null, required this.codec, this.description, @@ -1206,11 +1206,11 @@ class FfiConverterMoqAudio { ); final broadcast = broadcast_lifted.value; new_offset += broadcast_lifted.bytesRead; - final epoch_lifted = FfiConverterOptionalString.read( + final instance_lifted = FfiConverterOptionalString.read( Uint8List.view(buf.buffer, new_offset), ); - final epoch = epoch_lifted.value; - new_offset += epoch_lifted.bytesRead; + final instance = instance_lifted.value; + new_offset += instance_lifted.bytesRead; final track_lifted = FfiConverterOptionalString.read( Uint8List.view(buf.buffer, new_offset), ); @@ -1260,7 +1260,7 @@ class FfiConverterMoqAudio { MoqAudio( label: label, broadcast: broadcast, - epoch: epoch, + instance: instance, track: track, codec: codec, description: description, @@ -1279,7 +1279,7 @@ class FfiConverterMoqAudio { final total_length = FfiConverterOptionalString.allocationSize(value.label) + FfiConverterOptionalString.allocationSize(value.broadcast) + - FfiConverterOptionalString.allocationSize(value.epoch) + + FfiConverterOptionalString.allocationSize(value.instance) + FfiConverterOptionalString.allocationSize(value.track) + FfiConverterString.allocationSize(value.codec) + FfiConverterOptionalUint8List.allocationSize(value.description) + @@ -1306,7 +1306,7 @@ class FfiConverterMoqAudio { Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterOptionalString.write( - value.epoch, + value.instance, Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterOptionalString.write( @@ -1351,7 +1351,7 @@ class FfiConverterMoqAudio { static int allocationSize(MoqAudio value) { return FfiConverterOptionalString.allocationSize(value.label) + FfiConverterOptionalString.allocationSize(value.broadcast) + - FfiConverterOptionalString.allocationSize(value.epoch) + + FfiConverterOptionalString.allocationSize(value.instance) + FfiConverterOptionalString.allocationSize(value.track) + FfiConverterString.allocationSize(value.codec) + FfiConverterOptionalUint8List.allocationSize(value.description) + @@ -2159,7 +2159,7 @@ class FfiConverterMoqTimestamp { class MoqVideo { final String? label; final String? broadcast; - final String? epoch; + final String? instance; final String? track; final String codec; final Uint8List? description; @@ -2173,7 +2173,7 @@ class MoqVideo { MoqVideo({ this.label = null, this.broadcast = null, - this.epoch = null, + this.instance = null, this.track = null, required this.codec, this.description, @@ -2204,11 +2204,11 @@ class FfiConverterMoqVideo { ); final broadcast = broadcast_lifted.value; new_offset += broadcast_lifted.bytesRead; - final epoch_lifted = FfiConverterOptionalString.read( + final instance_lifted = FfiConverterOptionalString.read( Uint8List.view(buf.buffer, new_offset), ); - final epoch = epoch_lifted.value; - new_offset += epoch_lifted.bytesRead; + final instance = instance_lifted.value; + new_offset += instance_lifted.bytesRead; final track_lifted = FfiConverterOptionalString.read( Uint8List.view(buf.buffer, new_offset), ); @@ -2263,7 +2263,7 @@ class FfiConverterMoqVideo { MoqVideo( label: label, broadcast: broadcast, - epoch: epoch, + instance: instance, track: track, codec: codec, description: description, @@ -2283,7 +2283,7 @@ class FfiConverterMoqVideo { final total_length = FfiConverterOptionalString.allocationSize(value.label) + FfiConverterOptionalString.allocationSize(value.broadcast) + - FfiConverterOptionalString.allocationSize(value.epoch) + + FfiConverterOptionalString.allocationSize(value.instance) + FfiConverterOptionalString.allocationSize(value.track) + FfiConverterString.allocationSize(value.codec) + FfiConverterOptionalUint8List.allocationSize(value.description) + @@ -2311,7 +2311,7 @@ class FfiConverterMoqVideo { Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterOptionalString.write( - value.epoch, + value.instance, Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterOptionalString.write( @@ -2360,7 +2360,7 @@ class FfiConverterMoqVideo { static int allocationSize(MoqVideo value) { return FfiConverterOptionalString.allocationSize(value.label) + FfiConverterOptionalString.allocationSize(value.broadcast) + - FfiConverterOptionalString.allocationSize(value.epoch) + + FfiConverterOptionalString.allocationSize(value.instance) + FfiConverterOptionalString.allocationSize(value.track) + FfiConverterString.allocationSize(value.codec) + FfiConverterOptionalUint8List.allocationSize(value.description) + @@ -2797,8 +2797,9 @@ class FfiConverterMoqAnnounceConfig { class MoqAnnounceRoute { final int cost; - final String? epoch; - MoqAnnounceRoute({this.cost = 0, this.epoch = null}); + final String? instance; + final int epoch; + MoqAnnounceRoute({this.cost = 0, this.instance = null, this.epoch = 0}); } class FfiConverterMoqAnnounceRoute { @@ -2813,13 +2814,18 @@ class FfiConverterMoqAnnounceRoute { ); final cost = cost_lifted.value; new_offset += cost_lifted.bytesRead; - final epoch_lifted = FfiConverterOptionalString.read( + final instance_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final instance = instance_lifted.value; + new_offset += instance_lifted.bytesRead; + final epoch_lifted = FfiConverterUInt64.read( Uint8List.view(buf.buffer, new_offset), ); final epoch = epoch_lifted.value; new_offset += epoch_lifted.bytesRead; return LiftRetVal( - MoqAnnounceRoute(cost: cost, epoch: epoch), + MoqAnnounceRoute(cost: cost, instance: instance, epoch: epoch), new_offset - buf.offsetInBytes, ); } @@ -2827,7 +2833,8 @@ class FfiConverterMoqAnnounceRoute { static RustBuffer lower(MoqAnnounceRoute value) { final total_length = FfiConverterUInt64.allocationSize(value.cost) + - FfiConverterOptionalString.allocationSize(value.epoch) + + FfiConverterOptionalString.allocationSize(value.instance) + + FfiConverterUInt64.allocationSize(value.epoch) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -2841,6 +2848,10 @@ class FfiConverterMoqAnnounceRoute { Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterOptionalString.write( + value.instance, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterUInt64.write( value.epoch, Uint8List.view(buf.buffer, new_offset), ); @@ -2849,7 +2860,8 @@ class FfiConverterMoqAnnounceRoute { static int allocationSize(MoqAnnounceRoute value) { return FfiConverterUInt64.allocationSize(value.cost) + - FfiConverterOptionalString.allocationSize(value.epoch) + + FfiConverterOptionalString.allocationSize(value.instance) + + FfiConverterUInt64.allocationSize(value.epoch) + 0; } } @@ -2904,12 +2916,12 @@ class MoqRoute { final List hops; final int cost; final bool anonymous; - final String? epoch; + final String instance; MoqRoute({ required this.hops, required this.cost, required this.anonymous, - this.epoch, + required this.instance, }); } @@ -2935,13 +2947,18 @@ class FfiConverterMoqRoute { ); final anonymous = anonymous_lifted.value; new_offset += anonymous_lifted.bytesRead; - final epoch_lifted = FfiConverterOptionalString.read( + final instance_lifted = FfiConverterString.read( Uint8List.view(buf.buffer, new_offset), ); - final epoch = epoch_lifted.value; - new_offset += epoch_lifted.bytesRead; + final instance = instance_lifted.value; + new_offset += instance_lifted.bytesRead; return LiftRetVal( - MoqRoute(hops: hops, cost: cost, anonymous: anonymous, epoch: epoch), + MoqRoute( + hops: hops, + cost: cost, + anonymous: anonymous, + instance: instance, + ), new_offset - buf.offsetInBytes, ); } @@ -2951,7 +2968,7 @@ class FfiConverterMoqRoute { FfiConverterSequenceUInt64.allocationSize(value.hops) + FfiConverterUInt64.allocationSize(value.cost) + FfiConverterBool.allocationSize(value.anonymous) + - FfiConverterOptionalString.allocationSize(value.epoch) + + FfiConverterString.allocationSize(value.instance) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -2972,8 +2989,8 @@ class FfiConverterMoqRoute { value.anonymous, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterOptionalString.write( - value.epoch, + new_offset += FfiConverterString.write( + value.instance, Uint8List.view(buf.buffer, new_offset), ); return new_offset - buf.offsetInBytes; @@ -2983,7 +3000,7 @@ class FfiConverterMoqRoute { return FfiConverterSequenceUInt64.allocationSize(value.hops) + FfiConverterUInt64.allocationSize(value.cost) + FfiConverterBool.allocationSize(value.anonymous) + - FfiConverterOptionalString.allocationSize(value.epoch) + + FfiConverterString.allocationSize(value.instance) + 0; } } @@ -5525,7 +5542,7 @@ class FfiConverterMoqException { lifted.bytesRead - subview.offsetInBytes + 4, ); case 29: - final lifted = InvalidEpochMoqException.read(subview); + final lifted = InvalidInstanceMoqException.read(subview); return LiftRetVal( lifted.value, lifted.bytesRead - subview.offsetInBytes + 4, @@ -6529,18 +6546,18 @@ class InvalidPatternMoqException extends MoqException { } } -class InvalidEpochMoqException extends MoqException { +class InvalidInstanceMoqException extends MoqException { final String reason; - InvalidEpochMoqException(String this.reason); - InvalidEpochMoqException._(String this.reason); - static LiftRetVal read(Uint8List buf) { + InvalidInstanceMoqException(String this.reason); + InvalidInstanceMoqException._(String this.reason); + static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; final reason_lifted = FfiConverterString.read( Uint8List.view(buf.buffer, new_offset), ); final reason = reason_lifted.value; new_offset += reason_lifted.bytesRead; - return LiftRetVal(InvalidEpochMoqException._(reason), new_offset); + return LiftRetVal(InvalidInstanceMoqException._(reason), new_offset); } @override @@ -11988,7 +12005,7 @@ class FfiConverterMoqBroadcastPrepared { abstract class MoqBroadcastRequestInterface { String path(); - MoqBroadcastPrepared prepare({required String epoch}); + MoqBroadcastPrepared prepare(); void reject({required int errorCode}); } @@ -12036,11 +12053,10 @@ class MoqBroadcastRequest implements MoqBroadcastRequestInterface { ); } - MoqBroadcastPrepared prepare({required String epoch}) { + MoqBroadcastPrepared prepare() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqbroadcastrequest_prepare( uniffiClonePointer(), - FfiConverterString.lower(epoch), status, ), FfiConverterMoqBroadcastPrepared.lift, @@ -12173,10 +12189,10 @@ class FfiConverterMoqFollowing { abstract class MoqOriginConsumerInterface { MoqAnnounceConsumer announced({required MoqAnnounceConfig config}); - MoqFollowing follow({required String path, String? epoch = null}); + MoqFollowing follow({required String path, String? instance = null}); UniffiRustFuture request({ required String path, - String? epoch = null, + String? instance = null, }); } @@ -12225,12 +12241,12 @@ class MoqOriginConsumer implements MoqOriginConsumerInterface { ); } - MoqFollowing follow({required String path, String? epoch = null}) { + MoqFollowing follow({required String path, String? instance = null}) { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqoriginconsumer_follow( uniffiClonePointer(), FfiConverterString.lower(path), - FfiConverterOptionalString.lower(epoch), + FfiConverterOptionalString.lower(instance), status, ), FfiConverterMoqFollowing.lift, @@ -12240,13 +12256,13 @@ class MoqOriginConsumer implements MoqOriginConsumerInterface { UniffiRustFuture request({ required String path, - String? epoch = null, + String? instance = null, }) { return uniffiRustCallAsync( () => uniffi_moq_ffi_fn_method_moqoriginconsumer_request( uniffiClonePointer(), FfiConverterString.lower(path), - FfiConverterOptionalString.lower(epoch), + FfiConverterOptionalString.lower(instance), ), ffi_moq_ffi_rust_future_poll_u64, ffi_moq_ffi_rust_future_complete_u64, @@ -17395,14 +17411,6 @@ void moqLogLevel({required String level}) { }, moqExceptionErrorHandler); } -String moqMintEpoch() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_func_moq_mint_epoch(status), - FfiConverterString.lift, - null, - ); -} - MoqClientConfig moqClientDefaults() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_func_moq_client_defaults(status), @@ -18777,12 +18785,11 @@ external RustBuffer uniffi_moq_ffi_fn_method_moqbroadcastrequest_path( Pointer uniffiStatus, ); -@Native< - Pointer Function(Pointer, RustBuffer, Pointer) ->(assetId: _uniffiAssetId) +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) external Pointer uniffi_moq_ffi_fn_method_moqbroadcastrequest_prepare( Pointer ptr, - RustBuffer epoch, Pointer uniffiStatus, ); @@ -18860,7 +18867,7 @@ external Pointer uniffi_moq_ffi_fn_method_moqoriginconsumer_announced( external Pointer uniffi_moq_ffi_fn_method_moqoriginconsumer_follow( Pointer ptr, RustBuffer path, - RustBuffer epoch, + RustBuffer instance, Pointer uniffiStatus, ); @@ -18870,7 +18877,7 @@ external Pointer uniffi_moq_ffi_fn_method_moqoriginconsumer_follow( external Pointer uniffi_moq_ffi_fn_method_moqoriginconsumer_request( Pointer ptr, RustBuffer path, - RustBuffer epoch, + RustBuffer instance, ); @Native Function(Pointer, Pointer)>( @@ -20030,11 +20037,6 @@ external void uniffi_moq_ffi_fn_func_moq_log_level( Pointer uniffiStatus, ); -@Native)>(assetId: _uniffiAssetId) -external RustBuffer uniffi_moq_ffi_fn_func_moq_mint_epoch( - Pointer uniffiStatus, -); - @Native)>(assetId: _uniffiAssetId) external RustBuffer uniffi_moq_ffi_fn_func_moq_client_defaults( Pointer uniffiStatus, @@ -20380,9 +20382,6 @@ external int uniffi_moq_ffi_checksum_func_moq_subscription_defaults(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_func_moq_log_level(); -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_func_moq_mint_epoch(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_func_moq_client_defaults(); @@ -21081,9 +21080,6 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_func_moq_log_level() != 24625) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_func_moq_mint_epoch() != 34550) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_func_moq_client_defaults() != 15294) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -21167,7 +21163,7 @@ void _checkApiChecksums() { 18633) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_resolve() != 56822) { + if (uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_resolve() != 972) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_track() != @@ -21377,7 +21373,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_path() != 6534) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_prepare() != 14120) { + if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_prepare() != 45877) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_reject() != 9727) { @@ -21392,10 +21388,10 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_announced() != 35661) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_follow() != 6409) { + if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_follow() != 34533) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_request() != 26056) { + if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_request() != 40910) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqoriginproducer_consume() != 52357) { @@ -21758,7 +21754,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_constructor_moqmediatrackconsumer_subscribe() != - 55638) { + 21132) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_constructor_moqmediatrackproducer_audio() != diff --git a/demo/carla/README.md b/demo/carla/README.md index c026fcac61d..07567b95cc6 100644 --- a/demo/carla/README.md +++ b/demo/carla/README.md @@ -106,8 +106,8 @@ The worker's gate (`sim/moq_carla_sim/drive.py`) owns every truck's controls: - **One driver at a time.** The first driver to send a command holds the lease until its broadcast ends or its 1 s watchdog expires; the player heartbeats every 250 ms. The lease is bound to the driver's path and the - epoch it announced with, so a driver that reconnects at the same path is a - new claimant rather than a continuation. + route instance it announced with, so a driver that reconnects at the same + path is a new claimant rather than a continuation. - **E-stop latches** until an explicit reset, from any viewer: the player latches and resets it through a short-lived safety broadcast without taking the lease. diff --git a/demo/carla/sim/moq_carla_sim/drive.py b/demo/carla/sim/moq_carla_sim/drive.py index e764c1219d5..34487dd81fe 100644 --- a/demo/carla/sim/moq_carla_sim/drive.py +++ b/demo/carla/sim/moq_carla_sim/drive.py @@ -94,15 +94,15 @@ class Command: `drive`, `estop`, `reset_estop`, and `respawn` arrive on the wire; `driver_left` and `watchdog_expired` are synthesized locally. - `driver_id` is the driver's path segment, which `status` echoes. `epoch` is - the route epoch its broadcast was announced under: the same id announced - under another epoch is another run of the driver, so it is a different + `driver_id` is the driver's path segment, which `status` echoes. `instance` is + the route instance its broadcast was announced under: the same id announced + under another instance is another run of the driver, so it is a different driver for the lease. """ kind: CommandKind driver_id: str = "" - epoch: str | None = None + instance: str | None = None sequence: int = 0 state: DriveState | None = None timestamps: tuple[TimestampEntry, ...] = () @@ -114,18 +114,18 @@ class Drive: The first driver to send a command owns the truck until it disconnects or its command lease expires. Other drivers cannot silently steal control. - The lease binds to the driver's broadcast path and the epoch it was + The lease binds to the driver's broadcast path and the instance it was announced under, so a driver whose broadcast ends or restarts loses it. It is still cooperative, not enforced: the holder's id is echoed on the public `status` track, so anyone allowed to publish there can announce at - that path under a newer epoch, which ends the holder's broadcast and frees + that path under another instance, which ends the holder's broadcast and frees the lease for the newcomer. This stops two honest drivers colliding; it does not stop a deliberate takeover. """ def __init__(self) -> None: self._driver: str | None = None - self._epoch: str | None = None + self._instance: str | None = None self._sequence: int | None = None self._state = DriveState.stopped() self._estopped = False @@ -153,7 +153,7 @@ def estop_by(self) -> str | None: def holds(self, command: Command) -> bool: """Whether `command` comes from the run of the driver holding the lease.""" - return self._driver is not None and (self._driver, self._epoch) == (command.driver_id, command.epoch) + return self._driver is not None and (self._driver, self._instance) == (command.driver_id, command.instance) def accepted(self) -> Accepted: return Accepted(state=self._state, estopped=self._estopped) @@ -172,7 +172,7 @@ def apply(self, command: Command) -> bool: return False claiming = self._driver is None self._driver = command.driver_id - self._epoch = command.epoch + self._instance = command.instance self._sequence = command.sequence state = (command.state or DriveState.stopped()).sanitized() if ( @@ -240,6 +240,6 @@ def _can_initialize_takeover(current: DriveState, requested: DriveState) -> bool def _clear_driver(self) -> None: self._driver = None - self._epoch = None + self._instance = None self._sequence = None self._state = DriveState.stopped() diff --git a/demo/carla/sim/moq_carla_sim/main.py b/demo/carla/sim/moq_carla_sim/main.py index e38796829ff..91e2ce38fa1 100644 --- a/demo/carla/sim/moq_carla_sim/main.py +++ b/demo/carla/sim/moq_carla_sim/main.py @@ -240,15 +240,12 @@ async def run(args: argparse.Namespace) -> None: try: async with moq.connect(args.url) as client: try: - # One epoch for the run: a restarted worker announces every - # truck under a new one, which viewers see as a restart. - route = moq.AnnounceRoute(epoch=moq.mint_epoch()) for truck in world.trucks: publication = client.create_broadcast(broadcast_path(truck.name, args.camera)) video = await bridge.open( bridge_stream(truck.name, args.camera), renditions(camera), publication.broadcast ) - session = TruckSession(truck, publication, video, args.location, map_name, world.clock, route) + session = TruckSession(truck, publication, video, args.location, map_name, world.clock) sessions.append(session) log.info( "publishing %s, consuming %s/*", diff --git a/demo/carla/sim/moq_carla_sim/publish.py b/demo/carla/sim/moq_carla_sim/publish.py index d0ec5b7b3bd..2b2088dfedf 100644 --- a/demo/carla/sim/moq_carla_sim/publish.py +++ b/demo/carla/sim/moq_carla_sim/publish.py @@ -231,7 +231,7 @@ def _parse_timestamps(raw: Any) -> tuple[TimestampEntry, ...]: return tuple(entries.values()) -def parse_command(driver_id: str, message: Any, epoch: str | None = None) -> Command | None: +def parse_command(driver_id: str, message: Any, instance: str | None = None) -> Command | None: """Turn one `control` track message from a driver run into a Command. Returns None for anything unrecognized. A driver is an untrusted publisher, @@ -258,7 +258,7 @@ def parse_command(driver_id: str, message: Any, epoch: str | None = None) -> Com return Command( kind="drive", driver_id=driver_id, - epoch=epoch, + instance=instance, sequence=sequence, state=DriveState( steering=_number(message.get("steering")), @@ -270,7 +270,7 @@ def parse_command(driver_id: str, message: Any, epoch: str | None = None) -> Com ) if kind in ("estop", "reset_estop", "respawn"): - return Command(kind=kind, driver_id=driver_id, epoch=epoch, sequence=sequence, timestamps=timestamps) + return Command(kind=kind, driver_id=driver_id, instance=instance, sequence=sequence, timestamps=timestamps) return None @@ -298,12 +298,12 @@ async def watch_drivers( ) -> None: """Discover driver broadcasts at `{prefix}/{driverId}` and fan their commands in. - Runs until cancelled. Each run of a driver, its path plus the route epoch it + Runs until cancelled. Each run of a driver, its path plus the route instance it announced under, gets its own task, so one slow or stuck driver cannot hold - up the others. A broadcast that ends, or restarts under another epoch, ends + up the others. A broadcast that ends, or restarts under another instance, ends its task, and with it the lease of that run; a restart starts a new task. """ - drivers: dict[str, tuple[str | None, asyncio.Task[None]]] = {} + drivers: dict[str, tuple[str, asyncio.Task[None]]] = {} def forget(driver_id: str, task: asyncio.Task[None]) -> None: # Only this run's entry: a restart has already put its successor there. @@ -322,7 +322,7 @@ def forget(driver_id: str, task: asyncio.Task[None]) -> None: # is a route that only overlaps it, not a driver's broadcast. if driver_id == path or not driver_id or "/" in driver_id: continue - epoch = announce.route.epoch + instance = announce.route.instance current = drivers.get(driver_id) if isinstance(event, moq.AnnounceEventEnd): if current is not None: @@ -331,14 +331,14 @@ def forget(driver_id: str, task: asyncio.Task[None]) -> None: continue if current is not None: # Same run: the route only changed hops or cost. - if current[0] == epoch and not isinstance(event, moq.AnnounceEventRestart): + if current[0] == instance and not isinstance(event, moq.AnnounceEventRestart): continue current[1].cancel() task = asyncio.create_task( - _pump_driver(driver_id, epoch, client, path, commands), + _pump_driver(driver_id, instance, client, path, commands), name=f"driver:{driver_id}", ) - drivers[driver_id] = (epoch, task) + drivers[driver_id] = (instance, task) task.add_done_callback(lambda task, driver_id=driver_id: forget(driver_id, task)) finally: for _, task in drivers.values(): @@ -347,7 +347,7 @@ def forget(driver_id: str, task: asyncio.Task[None]) -> None: async def _pump_driver( driver_id: str, - epoch: str | None, + instance: str, client: moq.Client, path: str, commands: asyncio.Queue[Command], @@ -357,22 +357,22 @@ async def _pump_driver( Always emits `driver_left` on the way out, however it leaves, so the gate releases a lease even when the track ends abnormally. """ - log.info("driver %s connected (epoch %s)", driver_id, epoch) + log.info("driver %s connected (instance %s)", driver_id, instance) try: # Pinned to this run, so a newer one that took over before its own # announcement cancels this task is never read under this run's lease. - broadcast = await client.request(path, epoch=epoch) + broadcast = await client.request(path, instance=instance) control = moq.json.SnapshotConsumer(await broadcast.subscribe_track("control")) async for message in control: - command = parse_command(driver_id, message.value, epoch) + command = parse_command(driver_id, message.value, instance) if command is not None: await commands.put(command) # uniffi's stubs declare the error a plain class; at runtime it is an Exception. except moq.Error as err: # pyright: ignore[reportGeneralTypeIssues] log.info("driver %s track ended: %s", driver_id, err) finally: - log.info("driver %s disconnected (epoch %s)", driver_id, epoch) - await commands.put(Command(kind="driver_left", driver_id=driver_id, epoch=epoch)) + log.info("driver %s disconnected (instance %s)", driver_id, instance) + await commands.put(Command(kind="driver_left", driver_id=driver_id, instance=instance)) __all__ = [ diff --git a/demo/carla/sim/moq_carla_sim/runtime.py b/demo/carla/sim/moq_carla_sim/runtime.py index d7cea896541..149d46ba746 100644 --- a/demo/carla/sim/moq_carla_sim/runtime.py +++ b/demo/carla/sim/moq_carla_sim/runtime.py @@ -98,7 +98,6 @@ def __init__( location: str | None, map_name: str | None, clock: Clock, - route: moq.AnnounceRoute, ) -> None: self._truck = truck self._video = video @@ -117,9 +116,9 @@ def __init__( # A broadcast starts unadvertised; the player discovers trucks by # announcement, so advertise once the status and video tracks exist. # The stream's are there already: opening it advertised them. The - # route carries the run's epoch, so viewers see a restarted worker as - # a restart rather than a stalled broadcast. - publication.announce(route) + # broadcast mints a fresh instance, so viewers see a restarted worker + # as a restart rather than a stalled broadcast. + publication.announce() def stop_video(self) -> None: """End the video tracks cleanly and stop the bridge encoding them.""" diff --git a/demo/carla/sim/tests/conftest.py b/demo/carla/sim/tests/conftest.py index db43ae77ac6..bd3eef5702c 100644 --- a/demo/carla/sim/tests/conftest.py +++ b/demo/carla/sim/tests/conftest.py @@ -1,7 +1,6 @@ """Lightweight stand-ins for runtime-only modules used during unit collection.""" import sys -import uuid from types import ModuleType, SimpleNamespace import pytest @@ -65,8 +64,6 @@ async def __aiter__(self): moq.AnnounceEventUpdate = AnnounceEventUpdate moq.AnnounceEventEnd = AnnounceEventEnd moq.AnnounceEventRestart = AnnounceEventRestart -moq.AnnounceRoute = lambda **kwargs: SimpleNamespace(**kwargs) -moq.mint_epoch = lambda: str(uuid.uuid4()) moq.Timestamp = lambda value, scale: SimpleNamespace(value=value, scale=scale) moq.TrackInfo = lambda **kwargs: SimpleNamespace(**kwargs) diff --git a/demo/carla/sim/tests/test_drive.py b/demo/carla/sim/tests/test_drive.py index 8a2b96a9142..0f0cc72a92e 100644 --- a/demo/carla/sim/tests/test_drive.py +++ b/demo/carla/sim/tests/test_drive.py @@ -29,19 +29,23 @@ def test_first_driver_keeps_control() -> None: def test_the_lease_belongs_to_one_run_of_a_driver() -> None: - """A driver id announced under another epoch is another driver: it cannot + """A driver id announced under another instance is another driver: it cannot steer while the first run holds the lease, nor release it.""" state = Drive() - first = Command(kind="drive", driver_id="abc", epoch="e1", sequence=1, state=DriveState(throttle=0.5, brake=0.0)) - second = Command(kind="drive", driver_id="abc", epoch="e2", sequence=1, state=DriveState(throttle=1.0, brake=0.0)) + first = Command( + kind="drive", driver_id="abc", instance="run-1", sequence=1, state=DriveState(throttle=0.5, brake=0.0) + ) + second = Command( + kind="drive", driver_id="abc", instance="run-2", sequence=1, state=DriveState(throttle=1.0, brake=0.0) + ) assert state.apply(first) assert not state.apply(second) assert state.state.throttle == 0.5 - assert not state.apply(Command(kind="driver_left", driver_id="abc", epoch="e2")) + assert not state.apply(Command(kind="driver_left", driver_id="abc", instance="run-2")) assert state.driver == "abc" # The first run leaving frees the lease for the second. - assert state.apply(Command(kind="driver_left", driver_id="abc", epoch="e1")) + assert state.apply(Command(kind="driver_left", driver_id="abc", instance="run-1")) assert state.driver is None assert state.apply(second) assert state.holds(second) diff --git a/demo/carla/sim/tests/test_publish.py b/demo/carla/sim/tests/test_publish.py index c69668caae7..6cd1881faf3 100644 --- a/demo/carla/sim/tests/test_publish.py +++ b/demo/carla/sim/tests/test_publish.py @@ -199,9 +199,9 @@ async def __anext__(self): self._idle.clear() return event - async def send(self, kind: type, path: str, epoch: str | None) -> None: + async def send(self, kind: type, path: str, instance: str) -> None: self._idle.clear() - self._events.put_nowait(kind(SimpleNamespace(prefix=path, route=SimpleNamespace(epoch=epoch)))) + self._events.put_nowait(kind(SimpleNamespace(prefix=path, route=SimpleNamespace(instance=instance)))) await self._idle.wait() @@ -217,8 +217,8 @@ def announced(self, prefix: str, *, filter: str): assert (prefix, filter) == ("viewer/carla/truck1", "*") return self.announcements - async def request(self, path: str, *, epoch: str | None = None): - self.requested.append((path, epoch)) + async def request(self, path: str, *, instance: str | None = None): + self.requested.append((path, instance)) sequence = len(self.requested) async def control(): @@ -255,56 +255,59 @@ def test_a_driver_run_keeps_the_lease_across_route_updates() -> None: """An update is the same run re-priced: no second subscription, no release.""" async def scenario(relay: Drivers, commands: asyncio.Queue, gate: Drive) -> None: - await relay.announcements.send(moq.AnnounceEventStart, "viewer/carla/truck1/abc", "e1") + await relay.announcements.send(moq.AnnounceEventStart, "viewer/carla/truck1/abc", "run-1") command = await next_command(commands, gate) - assert (command.kind, command.driver_id, command.epoch) == ("drive", "abc", "e1") + assert (command.kind, command.driver_id, command.instance) == ("drive", "abc", "run-1") assert gate.driver == "abc" - await relay.announcements.send(moq.AnnounceEventUpdate, "viewer/carla/truck1/abc", "e1") + await relay.announcements.send(moq.AnnounceEventUpdate, "viewer/carla/truck1/abc", "run-1") assert commands.empty() assert gate.driver == "abc" - assert relay.requested == [("viewer/carla/truck1/abc", "e1")] + assert relay.requested == [("viewer/carla/truck1/abc", "run-1")] asyncio.run(watching(scenario)) def test_a_driver_whose_broadcast_ends_loses_the_lease() -> None: async def scenario(relay: Drivers, commands: asyncio.Queue, gate: Drive) -> None: - await relay.announcements.send(moq.AnnounceEventStart, "viewer/carla/truck1/abc", "e1") + await relay.announcements.send(moq.AnnounceEventStart, "viewer/carla/truck1/abc", "run-1") await next_command(commands, gate) assert gate.driver == "abc" - await relay.announcements.send(moq.AnnounceEventEnd, "viewer/carla/truck1/abc", "e1") + await relay.announcements.send(moq.AnnounceEventEnd, "viewer/carla/truck1/abc", "run-1") left = await next_command(commands, gate) - assert (left.kind, left.epoch) == ("driver_left", "e1") + assert (left.kind, left.instance) == ("driver_left", "run-1") assert gate.driver is None asyncio.run(watching(scenario)) @pytest.mark.parametrize("kind", ["restart", "update"]) -def test_a_driver_that_restarts_under_a_new_epoch_is_a_new_driver(kind: str) -> None: +def test_a_driver_that_restarts_under_a_new_instance_is_a_new_driver(kind: str) -> None: """The old run's lease ends with it, even though the path and id are the same. - A new epoch is a restart whether the relay says so or reports it as an update. + A new instance is a restart whether the relay says so or reports it as an update. """ event = moq.AnnounceEventRestart if kind == "restart" else moq.AnnounceEventUpdate async def scenario(relay: Drivers, commands: asyncio.Queue, gate: Drive) -> None: - await relay.announcements.send(moq.AnnounceEventStart, "viewer/carla/truck1/abc", "e1") + await relay.announcements.send(moq.AnnounceEventStart, "viewer/carla/truck1/abc", "run-1") await next_command(commands, gate) - await relay.announcements.send(event, "viewer/carla/truck1/abc", "e2") + await relay.announcements.send(event, "viewer/carla/truck1/abc", "run-2") received = [await next_command(commands, gate) for _ in range(2)] - assert {(command.kind, command.epoch) for command in received} == {("driver_left", "e1"), ("drive", "e2")} - assert relay.requested == [("viewer/carla/truck1/abc", "e1"), ("viewer/carla/truck1/abc", "e2")] + assert {(command.kind, command.instance) for command in received} == { + ("driver_left", "run-1"), + ("drive", "run-2"), + } + assert relay.requested == [("viewer/carla/truck1/abc", "run-1"), ("viewer/carla/truck1/abc", "run-2")] # Whichever order those two arrived in, the new run holds the lease # by its next command, and the old run can no longer release it. - assert not gate.holds(Command(kind="drive", driver_id="abc", epoch="e1")) - gate.apply(Command(kind="drive", driver_id="abc", epoch="e2", sequence=3, state=DriveState())) - assert gate.holds(Command(kind="drive", driver_id="abc", epoch="e2")) - assert not gate.apply(Command(kind="driver_left", driver_id="abc", epoch="e1")) + assert not gate.holds(Command(kind="drive", driver_id="abc", instance="run-1")) + gate.apply(Command(kind="drive", driver_id="abc", instance="run-2", sequence=3, state=DriveState())) + assert gate.holds(Command(kind="drive", driver_id="abc", instance="run-2")) + assert not gate.apply(Command(kind="driver_left", driver_id="abc", instance="run-1")) assert gate.driver == "abc" asyncio.run(watching(scenario)) diff --git a/demo/carla/sim/tests/test_runtime.py b/demo/carla/sim/tests/test_runtime.py index 61698dc7509..1cb2d4bd707 100644 --- a/demo/carla/sim/tests/test_runtime.py +++ b/demo/carla/sim/tests/test_runtime.py @@ -365,23 +365,21 @@ async def sleep(delay: float) -> None: asyncio.run(scenario()) -def test_session_announces_with_the_run_epoch_after_populating_tracks() -> None: +def test_session_announces_after_populating_tracks() -> None: """The player lists trucks from announcements, and a broadcast starts - unadvertised, so a session that never announces is invisible. The route - carries the run's epoch so a restarted worker reads as a restart.""" + unadvertised, so a session that never announces is invisible.""" calls: list[object] = [] publication = SimpleNamespace( broadcast=SimpleNamespace( publish_track=lambda name, info=None: calls.append(name) or SimpleNamespace(demand=SimpleNamespace) ), - announce=lambda route: calls.append(route), + announce=lambda: calls.append("announce"), ) - route = SimpleNamespace(epoch="0190b2a4-0000-7000-8000-000000000000") # The video tracks were advertised when the bridge stream opened. - runtime.TruckSession(SimpleNamespace(name="truck1"), publication, SimpleNamespace(), None, None, FakeClock(), route) + runtime.TruckSession(SimpleNamespace(name="truck1"), publication, SimpleNamespace(), None, None, FakeClock()) - assert calls == ["status", route] + assert calls == ["status", "announce"] def test_a_driver_keeps_the_world_stepping_with_nobody_watching() -> None: diff --git a/demo/carla/web/src/truck.ts b/demo/carla/web/src/truck.ts index a7572fc066f..cfa29b3d4c7 100644 --- a/demo/carla/web/src/truck.ts +++ b/demo/carla/web/src/truck.ts @@ -428,8 +428,9 @@ export class Truck { const producer = new Json.Snapshot.Producer>({ track }); effect.cleanup(() => producer.finish()); effect.run(this.#runControlTrack.bind(this, track, producer)); - // A fresh epoch per lease: the truck binds its single driver to this path and epoch. - driverBroadcastPublication.announce({ epoch: Moq.Epoch.mint() }); + // A fresh instance per lease, which each publication mints: the truck binds its single + // driver to this path and instance. + driverBroadcastPublication.announce(); } #runSafetyControl(connection: Moq.Connection, effect: Moq.Signals.Effect) { @@ -441,7 +442,7 @@ export class Truck { const broadcastPublication = conn.createBroadcast(driverPath(this.name, driverId)); const broadcast = broadcastPublication.broadcast; const safety = new SafetyCommandBroadcast(command, broadcast); - broadcastPublication.announce({ epoch: Moq.Epoch.mint() }); + broadcastPublication.announce(); effect.cleanup(() => safety.close()); } diff --git a/demo/web/src/stats.ts b/demo/web/src/stats.ts index 40a89063812..95f981a762f 100644 --- a/demo/web/src/stats.ts +++ b/demo/web/src/stats.ts @@ -5,7 +5,7 @@ * `.stats/node/` carrying JSON tracks that snapshot current activity. We * auto-discover all of those nodes (announcements under `.stats/node`), so this * works for a single relay and for a cluster alike, then aggregate each node and - * let you drill into one. A restarted relay announces a new epoch at the same + * let you drill into one. A restarted relay announces a new instance at the same * path, which arrives as an end then a start, so its history starts over. * * Per node we read `group.json`, the whole node's group at the relay's default diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 98c50cb09c0..253b994f458 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -96,9 +96,9 @@ an error; run it again to publish anew. `import ts` and `import srt` republish instead when an MPEG-TS clock steps onto another timeline: the PCR and every stream stepping back, flagged or not, or more than 10 s forward without a `discontinuity_indicator`. The broadcast finishes there, and the rest of the -input publishes as a new instance under a new -[epoch](/concept/moq-lite#publisher-epochs), on the same SRT connection, which -`export ts --stitch --follow` carries on into. The new instance keeps the mux +input publishes as a new stream under a new +[instance](/concept/moq-lite#publisher-instances), on the same SRT connection, which +`export ts --stitch --follow` carries on into. The new stream keeps the mux rate the old one measured, so its catalog goes out with its first media rather than after a fresh two-second measurement. An unflagged step usually means redundant encoders behind a changeover switch: time-lock their clocks, or have @@ -260,7 +260,7 @@ closes. Playback starts when the name is announced. A [restarted publisher](#publisher-runs) starts it over on the new run, with a fresh catalog, decoders, and clock. When the announcement ends, what is playing finishes and the player waits for the name to return. The log names each run's -epoch. A refused broadcast request or a catalog with nothing this build can +instance. A refused broadcast request or a catalog with nothing this build can play ends the player. Playback is behind the `play` feature, since it pulls in windowing and @@ -380,25 +380,25 @@ for how, and for storage and serving limits. moq --connect https://relay.example.com/anon --connect-version moq-lite-07-wip --broadcast event export archive s3://recordings # Replay one recorded broadcast under another name -moq --connect https://relay.example.com/anon --broadcast event-replay.hang import archive s3://recordings/event/@ +moq --connect https://relay.example.com/anon --broadcast event-replay.hang import archive s3://recordings/event/@ ``` `export archive` records every broadcast announced at or under `--broadcast` -with [moq-archive](https://docs.rs/moq-archive), each publisher instance under -`/@` in the store, until the connection ends. Like +with [moq-archive](https://docs.rs/moq-archive), each stream under +`/@` in the store, until the connection ends. Like `announced`, it leaves out a broadcast whose name starts with `.` unless `--broadcast` names it. A broadcast path segment that starts with `@` is stored as `%40`, so no recording lands inside another. Every track is stored as its own objects, cut about every 2 s of media (or wall clock for an [untimed](/concept/moq-lite#subscriptions) track) and at least every 4 s, splitting a long group between frames. A broadcast restarted under a new -[epoch](/concept/moq-lite#publisher-epochs) records under its own key; a route -without an epoch gets a fresh one each time it announces, so it never resumes -or stitches into an older recording. Any recording that fails ends the export. +[instance](/concept/moq-lite#publisher-instances) records under its own key; a route +over an older wire gets a fresh instance each time it announces, so it never +resumes or stitches into an older recording. Any recording that fails ends the export. `--retention 1h` keeps only the last hour, a DVR, deleting older objects `--retention-grace` (default 30 s) after they leave the window. -`import archive` republishes one recorded broadcast, the `/@` -prefix `export archive` wrote, as an ordinary broadcast under a new epoch: every +`import archive` republishes one recorded broadcast, the `/@` +prefix `export archive` wrote, as an ordinary broadcast under a new instance: every track, the catalog included, with its original groups, frames, timescale, and priority. Each track is live from its newest stored group and serves older groups from the store on request, with no max age, so the whole recording stays @@ -428,20 +428,25 @@ here needs no network track discovery. ## Publisher runs -Each run of `moq` announces a fresh -[publisher epoch](/concept/moq-lite#publisher-epochs), kept across reconnects. +Each run of `moq` announces under a fresh random +[instance](/concept/moq-lite#publisher-instances), kept across reconnects. The RTMP, SRT, and WHIP ingests mint one per connection instead, `import rtsp` one per RTSP session, and `import ts --program all` one per program. +No importer continues its group sequences across a restart, so `moq` has no +flag to set a stable instance and refuses one. A publisher that does continue +them sets it on its route in a library, such as `Route::with_instance` in Rust +or `announce({ instance, epoch })` in JavaScript; see +[identity and recovery](/concept/identity). A restarted process takes the name at once: viewers see the broadcast restart, and their next subscribe reaches the new run instead of waiting for its group -numbers to catch up. Epochs cross a connection only on moq-lite 07, which is -opt-in, and order by the host's clock there, so a host whose clock runs behind the old run's waits for it to close. -Other versions carry no epoch, so a restart from another session is the newest -route at an equal cost, or a better one. +numbers to catch up. A relay ranks the route that entered it for exactly the +path newest first at an equal cost, so the restart wins over the old run's +lingering session. Instances cross a connection only on moq-lite 07, which is +opt-in; over other versions the receiving session mints one per announcement. -Each independent importer publishes its own epoch, even when two importers read -one stream and publish one path. See [Redundant ingest](/setup/redundancy) for +Each independent importer publishes its own instance, even when two importers +read one stream and publish one path. See [Redundant ingest](/setup/redundancy) for same-path switching, subscriber-controlled selection on separate paths, and several routes to one publisher. @@ -501,8 +506,8 @@ as a player that read the final catalog after the end, still gets the tail; import does the same at a clean end: an RTMP unpublish (`deleteStream`) or completed play, the end of an SRT stream, a WHIP `DELETE`, or an HLS playlist ending with `EXT-X-ENDLIST`. A dropped connection or an error closes the -broadcast at once. A reconnect publishes a newer -[epoch](/concept/moq-lite#publisher-epochs) at the same path, which takes over +broadcast at once. A reconnect publishes a new +[instance](/concept/moq-lite#publisher-instances) at the same path, which takes over the path while the old broadcast finishes its linger. `import rtsp` redials after every session, so it closes each run at once and refuses `--linger`, as do `import archive`, `import capture`, and a WHEP pull (`import rtc --connect`). @@ -542,7 +547,7 @@ on with the same stream. A return that refuses its catalog keeps it waiting for the next announcement; if the follow runs out first, it exits 1 with that refusal. One that goes before serving, such as one still routed through a dead session, is no return. A replacement (a restarted publisher, under a new -[epoch](/concept/moq-lite#publisher-epochs)) exits 1 unless `--stitch` follows +[instance](/concept/moq-lite#publisher-instances)) exits 1 unless `--stitch` follows it as a full program switch: a new PMT from its catalog, with every PID flagging the break. A stitch waits for the replacement's first catalog snapshot until it arrives or the replacement goes, keeping the old publisher playing meanwhile, @@ -554,10 +559,9 @@ congested link. The marked switch drops the old instance's buffered tail, so the replacement plays at `--delay` rather than behind it. An unimpaired switch usable within that window adds no gap. Congestion can starve the old instance sooner or leave the replacement acquiring past the window, causing a gap. -Timestamps across epochs are not aligned. Without `--stitch` the export stays -on the old publisher while it is up. Only lite-07 and moq-transport sessions -carry epochs, so on the -default version every return is a replacement. A failure other than the +Timestamps across instances are not aligned. Without `--stitch` the export +stays on the old publisher while it is up. Only lite-07 sessions carry +instances, so on the default version every return is a replacement. A failure other than the publisher leaving, such as a codec TS cannot carry, exits 1 at once. Only `ts` can mark a break, so the other formats refuse `--linger`, the released spelling of `--follow`. diff --git a/doc/bin/demo.md b/doc/bin/demo.md index a2ae9ef6bc5..1a621bf0a2b 100644 --- a/doc/bin/demo.md +++ b/doc/bin/demo.md @@ -66,7 +66,7 @@ just carla # relay + trucks + player What it demonstrates: - **GPU to wire.** Camera images never leave the GPU: `moq-video` encodes them on the device that rendered them, and a camera renders only while someone subscribes. -- **Control with a lease.** A driver publishes a JSON `control` track under `viewer/carla/{truck}/`; the truck accepts one driver at a time, bound to the epoch it announced with, and returns to autonomy when the lease lapses. +- **Control with a lease.** A driver publishes a JSON `control` track under `viewer/carla/{truck}/`; the truck accepts one driver at a time, bound to the instance it announced with, and returns to autonomy when the lease lapses. - **Measured feedback.** Each truck's `status` track carries what the gate accepted and what the vehicle did, so the player draws requested against measured steering. Code: [`demo/carla`](https://github.com/moq-dev/moq/tree/main/demo/carla). diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index fb079c9b183..9e38ffe305e 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -96,9 +96,9 @@ comma-separated list, matching the CLI's repeatable `--connect-version`; unknown versions fail startup. QUIC durations are milliseconds. Zero disables the local idle timeout; a zero or unset keep-alive derives from the idle timeout and reads back as zero. Each run from `READY` publishes under a fresh -[publisher epoch](/concept/moq-lite#publisher-epochs), so on moq-lite 07 (opt-in) -or moq-transport, viewers switch to a restarted pipeline at once instead of stalling on the old -one; reconnects within a run keep it. The first catalog waits for a timed buffer +[instance](/concept/moq-lite#publisher-instances), so viewers switch to a +restarted pipeline at once instead of stalling on the old one; reconnects +within a run keep it. The first catalog waits for a timed buffer to place the shared wall clock and for every requested pad to negotiate its rendition, including any codec config carried in its first buffers. A linked pad that never negotiates holds back the whole catalog until diff --git a/doc/bin/hls.md b/doc/bin/hls.md index d7972a49c8c..bb564de8f58 100644 --- a/doc/bin/hls.md +++ b/doc/bin/hls.md @@ -56,8 +56,8 @@ What that means for you: - **Segments are timed by their records.** A segment is listed once the record after it arrives, and its duration, its DASH `t` and `d`, and its last sample's duration all come from its own record and the next, so a segment's bytes never change with a catalog estimate. A stop record (a break or the end of the content) is listed as a zero-length gap, and what follows it starts a discontinuity. - **The target duration is declared.** Every media playlist keeps one `EXT-X-TARGETDURATION` while its rendition stays in the catalog, even across a reconfigure: the longest segment duration the broadcast's first renditions declare, rounded up. A rendition added later that declares longer segments gets a larger target of its own, while playlists already served keep theirs. A rendition removed and re-added gets the target its declared duration gives, so keep a rendition's declared duration fixed for the run. A GOP longer than the target, once rounded, is still listed rather than dropped or split, against RFC 8216's rule: hls.js plays it, but Apple's `mediastreamvalidator` flags it, so keep keyframe intervals within the declared duration. - **Disabled renditions are hidden.** A rendition the catalog marks `enabled: false` leaves the multivariant playlist and DASH manifest until it re-enables. Its media playlist keeps serving, so a player already on it is not ended with `EXT-X-ENDLIST`. -- **URLs name the publisher instance.** Every URL the multivariant playlist and DASH manifest list sits under `@`, the publisher's epoch, so every edge and every reload derive the same URLs and a CDN in front of export caches them. A restarted publisher gets URLs of its own, whose numbering starts over. The multivariant playlist and manifest switch to it at once, while the old instance's playlists end at the segments they listed before the switch, even if its publisher keeps sending; a player already on it plays those out and then has to reload the multivariant playlist. An instance without an epoch (one that reached export over moq-lite-06 or older) gets an id each export mints, so only that export serves its URLs. -- **Only a clean finish ends a playlist.** `EXT-X-ENDLIST` and a `static` DASH manifest mean the records finished, or, on a replaced instance's media playlists, that another publisher took the broadcast over. A rendition whose records fail instead, such as a publisher dropping its session, answers HTTP 500 with `Cache-Control: no-store`, so a truncated recording never looks complete, and leaves the multivariant playlist and DASH manifest so players switch to a sibling. With none left, the DASH manifest answers the error too. +- **URLs name the stream.** Every URL the multivariant playlist and DASH manifest list sits under `@`, the stream's instance, so every edge and every reload derive the same URLs and a CDN in front of export caches them. A restarted publisher gets URLs of its own, whose numbering starts over. The multivariant playlist and manifest switch to it at once, while the old stream's playlists end at the segments they listed before the switch, even if its publisher keeps sending; a player already on it plays those out and then has to reload the multivariant playlist. A stream that reached export over moq-lite-06 or older, or moq-transport, has an instance its session minted, so only that export serves its URLs. +- **Only a clean finish ends a playlist.** `EXT-X-ENDLIST` and a `static` DASH manifest mean the records finished, or, on a replaced stream's media playlists, that another publisher took the broadcast over. A rendition whose records fail instead, such as a publisher dropping its session, answers HTTP 500 with `Cache-Control: no-store`, so a truncated recording never looks complete, and leaves the multivariant playlist and DASH manifest so players switch to a sibling. With none left, the DASH manifest answers the error too. A [broadcast archive](/concept/archives) is served the same way, with no second stored copy: record a broadcast, republish it, and export @@ -67,7 +67,7 @@ recording needs moq-lite 07 on every network hop for track discovery; see ```bash moq --connect https://relay.example.com/anon --connect-version moq-lite-07-wip --broadcast event export archive s3://recordings -moq --connect https://relay.example.com/anon --broadcast event-replay.hang import archive s3://recordings/event/@ +moq --connect https://relay.example.com/anon --broadcast event-replay.hang import archive s3://recordings/event/@ moq --connect https://relay.example.com/anon --broadcast event-replay.hang export hls --listen '[::]:8089' ``` diff --git a/doc/bin/obs.md b/doc/bin/obs.md index f47b467be5b..65080543b6e 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -12,10 +12,10 @@ OBS Studio install. - **Subscribe**: add a "MoQ Source", enter the relay URL and broadcast path, and the stream appears in the scene. - **Dock**: a **MoQ** dock with Go Live, encoding that uses the OBS Output settings or custom settings for this stream only, and live stats (protocol, RTT, bandwidth, loss), and the reason while a stream connects or reconnects. -Each Start Streaming publishes a fresh epoch, shown in the dock's Stats tab. -The client's automatic reconnects keep the same epoch. A stop/start, or an OBS -auto-reconnect after the client gives up, republishes the broadcast under a new -epoch, which peers using an epoch-capable protocol see as a new publisher run. +Each Start Streaming publishes under a fresh instance. The client's automatic +reconnects keep the same instance. A +stop/start, or an OBS auto-reconnect after the client gives up, republishes +the broadcast under a new instance, which viewers see as a new stream. MoQ Source plays the best enabled audio and video rendition it can decode and place. It follows catalog changes while keeping the current audio and video playing until a replacement produces decoded output. A failed replacement leaves the current rendition playing. Disabling every rendition of a media kind keeps the one already playing; removing the media kind from the catalog clears it. It also follows a restarted or replaced publisher, including a sibling broadcast the catalog points at, playing the new one from its first keyframe. diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index f0bccd5574e..a748e166bc5 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -29,25 +29,25 @@ requests follow the new best route immediately. For 2 s after a relay retracts a broadcast, it holds back a worse path to the same publisher that a peer still hunting offers, instead of announcing the broadcast again. -Routes carrying the same [publisher epoch](/concept/moq-lite#publisher-epochs) -serve one broadcast. When the route serving it dies, withdraws, or is beaten by -a cheaper one with that epoch, each subscription continues on the new route -from the first frame its readers lack, so they see every frame once. A -publisher whose groups restart, such as an encoder restarting from group 0, -publishes under a new epoch, which replaces the old broadcast instead of -resuming it; the RTMP, SRT, and WHIP ingests mint one per connection. Epochs -order by creation time, so a reconnect to a different gateway assumes the two -gateways' clocks roughly agree. Seamless failover needs a publisher that -announces an epoch and, on every link the route crosses, moq-lite 07. A route -without an epoch, including every route on moq-lite 06 and older or -moq-transport, keeps its subscriptions until it goes or a track fails -upstream, and any other route winning it is announced as a restart. Viewers -re-request on a restart, and a relay drops its copy of the old instance, -including every path beneath a prefix claim when one of them moves to another -worker. +Routes carrying the same [instance](/concept/moq-lite#publisher-instances) +serve one stream. When the route serving it dies, withdraws, or is beaten by +a cheaper one of that instance, or a higher epoch of it, each subscription +continues on the new route from the first frame its readers lack, so they see +every frame once. A publisher whose groups restart, such as an encoder +restarting from group 0, publishes under a new instance, which replaces the +old broadcast instead of resuming it; the RTMP, SRT, and WHIP ingests mint one +per connection. Among instances every relay ranks routes in [one +order](/concept/identity#choosing-a-route), ending in a hash of the instance +and the path, so the relays of a mesh agree on each path's stream and a pool's +paths spread over its hosts. Seamless failover needs moq-lite 07 on every link +the route crosses. Over moq-lite 06 and older, or moq-transport, the receiving +relay mints an instance per announcement, so failover onto such a route is +announced as a restart. Viewers re-request on a restart, and a relay drops its +copy of the old stream, including every path beneath a prefix claim when one +of them moves to another worker. Failover routes must carry copies of one publisher's broadcast. Independent -importers use distinct epochs and switch as a restart, even when fed one stream. +importers use distinct instances and switch as a restart, even when fed one stream. See [Redundant ingest](/setup/redundancy) to choose between publisher switching, subscriber selection, and several routes to one publisher. A source whose track differs in any property, such as timescale, retention, @@ -56,10 +56,10 @@ moq-transport link records its draft in the track's properties and carries refresh only as on or off, so a route crossing one does not fail over to or from a route that does not. -A route whose original publisher (its first hop) changes under the same epoch is -updated in place on both wire protocols, so the broadcast never briefly vanishes -downstream, and subscriptions in flight carry on through it. Without an epoch -that is another source, so it restarts. +A route whose original publisher (its first hop) changes under the same instance +is updated in place, so the broadcast never briefly vanishes downstream, and +subscriptions in flight carry on through it. Under another instance that is +another stream, so it restarts. ## Topology diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 6dd24cc0ebb..a0022ec7046 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -363,7 +363,7 @@ tls.key = "relay.key" goaway.redirect = "same-host" # How far to trust a draining peer's redirect URI. goaway.handover = "10s" # Cap on how long the drained upstream keeps serving. extensions.auth = false # Decline MoQ Auth on outbound moq-transport sessions. -extensions.solicit = false # Decline MoQ Solicit. +extensions.solicit = false # Decline MoQ Solicit. All default to on, as in [listen.extensions]. ``` A draining upstream may name a replacement URI. `same-host` follows it only @@ -415,11 +415,11 @@ linger = "5m" # Keep an empty group's track listed this l Each node publishes its traffic and session counters as MoQ tracks, split by a **tier** label from the auth server's grant (`--cluster-tier` for links this relay dials and LAN peers it admits), which is what makes billing per customer or per region possible. -Each run announces under a fresh [epoch](/concept/moq-lite#publisher-epochs), +Each run announces under a fresh [instance](/concept/moq-lite#publisher-instances), so a restart is a new broadcast at the same path. Each group is a [track](/concept/stats#group-tracks) of that broadcast, and one returning after -its linger restarts its counters under a new epoch without an announce. The -node's totals count everything since its epoch began, so billing reads those; +its linger restarts its counters under a new run id without an announce. The +node's totals count everything since its run began, so billing reads those; a group's per-path traffic and per-label sessions and HTTP requests are for dashboards. Session counters report how close sessions come to the per-session limits, past which one is closed with `TOO_MANY_REQUESTS`. diff --git a/doc/bin/rtc.md b/doc/bin/rtc.md index e9c8b81375b..2eefff03d92 100644 --- a/doc/bin/rtc.md +++ b/doc/bin/rtc.md @@ -34,7 +34,7 @@ opens the endpoint to browsers on other origins, and `--udp-bind` plus put a TLS-terminating proxy in front for WHIP clients that require HTTPS. A fresh WHEP peer joins at the current group, so it starts at a keyframe without waiting for the next one. `DELETE` on the resource URL tears a session down per the RFC. Each WHIP session publishes -under a fresh [epoch](/concept/moq-lite#publisher-epochs), so a publisher that +under a fresh [instance](/concept/moq-lite#publisher-instances), so a publisher that reconnects while its stale session lingers replaces it at once. An export stays on the publisher it started with. A replacement publisher never diff --git a/doc/bin/rtmp.md b/doc/bin/rtmp.md index 1769b7163b4..76d5f705136 100644 --- a/doc/bin/rtmp.md +++ b/doc/bin/rtmp.md @@ -30,7 +30,7 @@ publish or play request to accept, map to a path, or reject. The CLI listener is unauthenticated; firewall it. Each push or pull is its own broadcast, under a fresh -[epoch](/concept/moq-lite#publisher-epochs): an encoder that reconnects while +[instance](/concept/moq-lite#publisher-instances): an encoder that reconnects while its stale connection is still open replaces it at once. Viewers see the broadcast restart and their next subscribe reaches the new push, while what they already read from the stale push stays until they drop it or it closes. Import publishes the encoder's own timestamps diff --git a/doc/bin/srt.md b/doc/bin/srt.md index 2abd0c38681..42884ba65fd 100644 --- a/doc/bin/srt.md +++ b/doc/bin/srt.md @@ -25,7 +25,7 @@ moq --connect https://relay.example.com/anon --broadcast event.hang import srt - ``` Each connection publishes under a fresh -[epoch](/concept/moq-lite#publisher-epochs), so an encoder that reconnects +[instance](/concept/moq-lite#publisher-instances), so an encoder that reconnects while its stale connection is still open replaces it at once: viewers see the broadcast restart, and their next subscribe reaches the new feed. An `export srt` stream ends with its broadcast; `--follow` and `--stitch` follow a diff --git a/doc/concept/archives.md b/doc/concept/archives.md index f645fbf2a4b..5f1b9259c8d 100644 --- a/doc/concept/archives.md +++ b/doc/concept/archives.md @@ -53,7 +53,7 @@ bounds the backlog: content further behind the live edge is skipped, even when the publisher still caches it. Pick a distinct store prefix for each publisher instance, such as its broadcast -path and [epoch](/concept/identity). Restarting a recorder on the same instance +path and [instance](/concept/identity). Restarting a recorder on the same instance and prefix resumes after each track's last stored frame, within that max delay: an interruption longer than it leaves a gap. A prefix already marked complete records nothing. Never append a new publisher run that restarts its @@ -90,7 +90,7 @@ which can differ from an installed package; use the reference matching your vers Create an output broadcast, call `archive::Consumer::open` with the stored prefix and that broadcast's producer, then announce its publication and run the consumer. Opening creates the stored tracks before the host announces them. -The host chooses the output path and a fresh epoch, as +The host chooses the output path and a fresh instance, as [`moq import archive`](/bin/cli#archive) does. A relay routes that publication like any other broadcast; it does not look in object storage on a cache miss. diff --git a/doc/concept/hang.md b/doc/concept/hang.md index 0790ea6d0b9..547dc5d9b54 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -12,8 +12,8 @@ browser can decode it directly. The spec is [draft-lcurley-moq-hang](/draft/moq-hang). Plaintext broadcast names end in `.hang` so a player knows which catalog to expect. End-to-end encrypted broadcasts live at ``, derived from the credential and a semantic name -such as `foo.hang`, and the [epoch](/concept/moq-lite#publisher-epochs) on -their route identifies each publisher run. The path exposes no format or protection marker; the +such as `foo.hang`, and the [instance](/concept/moq-lite#publisher-instances) +on their route, the run's moq-e2ee epoch, identifies each publisher run. The path exposes no format or protection marker; the payloads follow [moq-e2ee](/draft/moq-e2ee). See [Encryption](#encryption). ## Catalog @@ -56,14 +56,14 @@ rate, and channel count. Everything else, such as the coded size, `bitrate`, `framerate`, and `enabled`, updates in place; the picture resizes in band at a keyframe unless a `description` pins it. A publisher that needs a new identity publishes a new track name, or restarts the broadcast under a new -[epoch](/concept/moq-lite#publisher-epochs); the Rust and `@moq/publish` +[instance](/concept/moq-lite#publisher-instances); the Rust and `@moq/publish` publishers take a new name. A player can configure its decoder once per track. A few things the catalog can express beyond decoder config: - **Labels.** Any rendition may carry a human-readable `label` for a track picker. The map key stays the rendition's ID, so labels need not be unique and renaming one doesn't rename the track. -- **Renditions in another broadcast.** A rendition may point at a relative broadcast path, so a transcoder can publish a ladder that adds low rungs and references the source's original rendition without re-publishing its bytes. The path resolves against where the consumer found the catalog, so a reference that escapes above the root names nothing and the catalog is rejected. `@moq/watch` and `moqsrc` follow a referenced path's announcements like their own, restarting the rendition on each instance that serves it; every other reader, such as `moq play`, the exporters, and the bindings, waits for the first one announced and keeps it, and an exporter ends the rendition with it. An optional `epoch` beside the path names the [publisher instance](/concept/moq-lite#publisher-epochs) it expects: a route proving another instance ends the rendition instead of falling back to it, while a route without an epoch (an older version) cannot prove one, so there it is best effort. Omit `epoch` for a broadcast produced on demand, which has no instance until a worker answers; see [identity and recovery](/concept/identity). -- **Whole contributions.** A root `contributions` map names whole broadcasts, such as each guest feed in a composed room, by the same relative path and optional `epoch` as a rendition's reference. Listing one opens nothing, so a contribution produced on demand starts only once a viewer selects it, which `@moq/watch` does with `broadcast.contribution(effect, id)`. Its rendition IDs stay its own. +- **Renditions in another broadcast.** A rendition may point at a relative broadcast path, so a transcoder can publish a ladder that adds low rungs and references the source's original rendition without re-publishing its bytes. The path resolves against where the consumer found the catalog, so a reference that escapes above the root names nothing and the catalog is rejected. `@moq/watch` and `moqsrc` follow a referenced path's announcements like their own, restarting the rendition on each instance that serves it; every other reader, such as `moq play`, the exporters, and the bindings, waits for the first one announced and keeps it, and an exporter ends the rendition with it. An optional `instance` beside the path names the [stream](/concept/moq-lite#publisher-instances) it expects: a route of another instance ends the rendition instead of falling back to it, while a route whose instance a session minted (one that crossed an older version) cannot prove one, so there it is best effort. Omit `instance` for a broadcast produced on demand, which has none until a worker answers; see [identity and recovery](/concept/identity). +- **Whole contributions.** A root `contributions` map names whole broadcasts, such as each guest feed in a composed room, by the same relative path and optional `instance` as a rendition's reference. Listing one opens nothing, so a contribution produced on demand starts only once a viewer selects it, which `@moq/watch` does with `broadcast.contribution(effect, id)`. Its rendition IDs stay its own. - **Track names.** The map key is a rendition's ID and, by default, its track name. A rendition sets `track` only when the two must differ, such as two renditions of same-named tracks in different broadcasts, or a rendition whose media changed and so needs a new name; readers subscribe to `track` when present. An ID can't name both a video and an audio rendition. - **Jitter.** A rendition can say how much media it writes behind a frame's timestamp, in whole milliseconds rounded up: the reorder delay of B-frames and the span of a fragment or batch written together. It is measured from media timestamps alone, never the time a frame was written, so neither a constant encoder delay nor a capture clock counts. It describes the publisher, never the network, only grows over the life of a stream, and a player sizes its buffer to at least this much. A `0` is read as absent. - **Delay.** A rendition can also say how far its frames' timestamps trail those another rendition already wrote, so a video encoder running 200 ms behind audio advertises `delay: 200` on video. A rendition alone measures none. It follows the same rules as jitter. A player holds the largest `delay + jitter` among the renditions it subscribes to, and never subtracts one rendition's `delay` from another's. @@ -229,8 +229,9 @@ size, even when the parameter sets ride in the samples. [moq-e2ee](/draft/moq-e2ee) encrypts groups, datagrams, and track names so a relay forwards ciphertext and never sees a content key. The application distributes the credential on its own channel. Each publisher run binds that -credential to its [epoch](/concept/moq-lite#publisher-epochs), which derives -the track names and keys for that run alone. A restart under a fresh epoch +credential to a fresh moq-e2ee epoch, announced as its route's +[instance](/concept/moq-lite#publisher-instances), which derives the track names +and keys for that run alone. A restart under a fresh epoch shares no names or keys with the previous run. Never share or reuse an epoch under one credential, whether for a replica or a later run: two instances on one epoch derive the same keys from the same group sequences and repeat diff --git a/doc/concept/identity.md b/doc/concept/identity.md index 3b97e804373..750aac06c9c 100644 --- a/doc/concept/identity.md +++ b/doc/concept/identity.md @@ -1,6 +1,6 @@ --- title: Identity and Recovery -description: Choose broadcast identities so restarts, failover, and on-demand work never mix content from different publisher instances +description: Choose stream instances so restarts, failover, and on-demand work never mix content from different streams --- # Identity and recovery @@ -12,128 +12,166 @@ source of a name serves the same bytes. This page is the contract publishers, players, and relays share to keep that true across restarts, failover, and work started on demand. Wire details live in the [moq-lite](/draft/moq-lite) draft. -## Identity is a path and an epoch - -A broadcast is its path plus a [publisher epoch](/concept/moq-lite#publisher-epochs): -a UUIDv7 that a publisher instance mints when it starts producing. - -- **An epoch names a run, not a configuration.** Two encoders with identical - settings fed the same source still number and cut their groups - independently, so each mints its own. Never derive an epoch from a hash of - settings, or restore one saved by an earlier run. -- **Replicas share it.** One instance's broadcast carried through several - relays or CDNs keeps its epoch on every route, which is what lets a - subscription move between them. -- **A new run takes a new one.** A restarted process, or anything that starts - its group numbers over, mints a fresh epoch. Keep it across reconnects of the - same run, since the numbering continues. Replaying a recording is a new run - too: [`moq import archive`](/bin/cli#archive) republishes under a new epoch. -- **Reusing a name is a bug.** Publishing different content under a path and - epoch, a track name, or a group sequence that already named something else +## Identity is a path and an instance + +A broadcast's identity is its path plus its **instance**. Every route in an +origin carries an instance: any non-empty text naming one continuous run of +group numbers at its path. Within one instance, group sequences never restart +and never name two different groups, so a reader stitches across every route +and incarnation that shares it. Two instances are two independent streams, +never stitched together. An instance names a run, not who publishes it: it is +no credential, and auth decides who may publish the path. + +- **A new stream takes a new instance.** Every broadcast mints a random one + when it is created and keeps it while it is withdrawn and announced again or + reconnects, since its numbering continues. A restarted process, or anything + that starts its group numbers over, is a new broadcast with a new instance. + Replaying a recording is a new stream too: [`moq import archive`](/bin/cli#archive) + republishes under a new instance. +- **Replicas share it.** One stream carried through several relays or CDNs + keeps its instance on every route, which is what lets a subscription move + between them. +- **A stable instance is a promise.** A publisher may name its own instance + (`Route::with_instance(instance, epoch)` in Rust, `announce({ instance, epoch })` + in JavaScript) only if it continues the sequence space across restarts and + announces a higher **epoch** each time, for example a counter persisted on + disk. Breaking the promise splices unrelated groups into every reader's + tracks. Two encoders with identical settings fed the same source still + number and cut their groups independently, so never derive an instance from + a hash of settings. +- **Reusing a name is a bug.** Publishing different content under an instance, + a track name, or a group sequence that already named something else corrupts every cache that holds the old content, and MoQ has no ETag-style - invalidation. Mint a new epoch for a whole new run, a new track name for one - rendition whose decoder configuration changes (see + invalidation. Use a new instance for a whole new stream, a new track name for + one rendition whose decoder configuration changes (see [hang](/concept/hang#catalog)), and continue past the old sequence when a - track is replaced within one run. + track is replaced within one stream. -Rust and JavaScript publishers announce with `Route::default().with_epoch(Epoch::mint())` -and `publication.announce({ epoch: Epoch.mint() })`. `moq`, `@moq/publish`, and -the ingest gateways do it for you; see [publisher runs](/bin/cli#publisher-runs). +Think of a cloud instance ID that survives a reboot: the instance stays the +same, and the epoch counts the boots. The epoch is a counter within one +instance, never compared across instances and never shown to readers. It only +picks the live incarnation of a stable instance while a stale route lingers: +within one instance, only its highest epoch is eligible, however cheap an older +one is, so it fences out an older run. Nothing assumes it is a clock. -| Situation | Epoch | What readers see | +| Situation | Instance | What readers see | | --- | --- | --- | -| One publisher run, reconnecting | Mint once per run | The same broadcast; subscriptions resume | -| One run carried by several relays or CDNs | Shared by every route | Failover resumes from the first missing frame | -| A restarted or replaced publisher | A fresh one | A restart, then the new broadcast from its start | -| Independent publishers of one source | One each | The newest wins the path as a restart; see [redundant ingest](/setup/redundancy) | -| A worker producing paths on demand | None on the claim, a fresh one per job | See [work on demand](#work-on-demand) | - -## Routes with and without an epoch - -An epoch reaches readers only over hops that carry it: moq-lite 07 -(`moq-lite-07-wip`, opt-in). Older moq-lite versions and moq-transport drop -it, so use moq-lite 07 on every hop where continuity matters. The -[moq-lite draft](/draft/moq-lite) says how each version signals another -instance taking the path. - -A relay forwards an epoch unchanged and never invents one. Without one, no -route can prove it serves the same bytes as another, so any other route -winning the path is a restart, even a reconnect of the same run, and a -subscription stays on its route until that route goes. Do not rely on -continuity through such a hop. +| One broadcast, reconnecting | The one it minted | The same stream; subscriptions resume | +| One stream carried by several relays or CDNs | Shared by every route | Failover resumes from the first missing frame | +| A restarted or replaced publisher | A fresh one | A restart, then the new stream from its start | +| A publisher that continues its sequences across restarts | Stable, with a higher epoch each run | The same stream; readers continue without a seam | +| Independent publishers of one source | One each | Two streams; see [redundant ingest](/setup/redundancy) | +| A pool of hosts producing paths on demand | One per host and one per job | See [work on demand](#work-on-demand) | + +## Choosing a route + +A route resolving a path is chosen in this order: + +1. The most specific prefix wins. +2. Within one instance, only its highest epoch is eligible, even over a + cheaper route. Readers continue across an epoch change without a seam, + because the sequence space continues. +3. Among different instances: the preferred link, then an identified chain + over an anonymous one, then the lowest cost, then a broadcast published on + this origin, then the shortest hop chain, then a hash of the instance and + the full requested path. Every relay agrees on the hash, and it spreads a + pool's paths over its hosts. A route that entered a node for exactly the + requested path is not hashed: the newest one wins, so a restarted publisher + takes over from its lingering session at once. + +`Consumer::request` in Rust and `origin.request` in JavaScript resolve by this +order, and so does every relay deciding what to advertise. + +## Routes over older wires + +Instances and epochs travel only over moq-lite 07 (`moq-lite-07-wip`, +opt-in). Older moq-lite versions and moq-transport carry neither, so the +receiving session mints an instance for each announcement it receives, with +epoch 0. A minted instance starts with `~`, so a publisher naming a stable +instance should not start it with `~`: nothing refuses one, since a relay +forwarding a route keeps the instance it received, but every node then treats +it as minted. A minted instance names the announcement, not the publisher's +stream, so: + +- An announcement ending and starting again, such as a job re-announced on one + session, is a new stream, never spliced to its predecessor. +- Continuity does not survive such a hop: a reconnect of the same publisher + through it reads as another stream. Do not rely on seamless failover through + an older hop. +- A catalog reference naming an instance cannot be checked against a minted + one, so there it is best effort. +- A pool reached through an older-lite relay hop can pick different winners + per relay, since each relay mints its own instances. Use moq-lite 07 + between relays. An end followed by a start is not a cache flush. A third-party moq-transport -cache that ignores them may keep the old run's objects under the same names, so -only a fresh epoch, carried end to end, keeps a restart from serving stale -groups. Relays built on `moq-net` or `@moq/net` drop their copy of a replaced -instance, so a new subscriber never sees its cached groups. +cache that ignores them may keep the old run's objects under the same names. +Relays built on `moq-net` or `@moq/net` drop their copy of a replaced stream, +so a new subscriber never sees its cached groups. ## Readers: follow or hold -A resolved broadcast handle is sticky: it stays on the instance it resolved. -A route with the same epoch replacing its route is invisible to it. Another -instance winning the path never substitutes content into it: subscriptions -already open stay on the old instance until its last route goes, then end with -an error, and without an epoch the handle opens no new ones once the restart -arrives. New requests resolve the winner on a fresh broadcast. -Choose between two kinds of reader. - -**Follow announcements** when the reader should outlive any one publisher -run, as a player does. Start on each instance, drop all decoder and buffer -state on a restart, and wait on an end. Rust uses -`consumer.follow(path, None)` (or `moq_mux::follow::Instances`), and -JavaScript `origin.follow(path)`. `@moq/watch`, `moq play`, -and `moqsrc` follow this way, and `moq export ts --stitch` can splice a -replacement as a marked program switch. A request failing is never the signal -to restart; the announcement is. - -**Hold one instance** when the output must not splice two runs, as an exporter -or recorder does. Rust and JavaScript -`request(path)` and `moq_mux::Source` keep the first broadcast they -resolve for their whole life, then end when it does. Request the path again -for the next run. +`request(path)` resolves one instance and returns its broadcast. Another route +of the same instance replacing its route, including a higher epoch, is +invisible to it: the broadcast moves there and subscriptions continue from the +first frame they lack. Another instance winning the path never substitutes +content into it: subscriptions already open stay on the old stream until its +last route goes, then end with an error. New requests resolve the winner on a +fresh broadcast. Choose between two kinds of reader. + +**Follow announcements** when the reader should outlive any one stream, as a +player does. Start on each broadcast, drop all decoder and buffer state on a +restart, and wait on an end. Rust uses `consumer.follow(path, None)` (or +`moq_mux::follow::Instances`), and JavaScript `origin.follow(path)`. +`@moq/watch`, `moq play`, and `moqsrc` follow this way, and +`moq export ts --stitch` can splice a replacement as a marked program switch. +A request failing is never the signal to restart; the announcement is. + +**Hold one stream** when the output must not splice two, as an exporter or +recorder does. Rust and JavaScript `request(path)` and `moq_mux::Source` keep +the first broadcast they resolve for their whole life, then end when it does. +Request the path again for the next stream. A catalog can reference renditions in a sibling broadcast. Followers restart -that rendition on each instance of the sibling, and holders pin the first one +that rendition on each broadcast of the sibling, and holders pin the first one they resolve. See [renditions in another broadcast](/concept/hang#catalog). -### Expecting a particular instance +### Expecting a particular stream -| Expectation | Over a route with an epoch | Over a route without one | +| Expectation | Over moq-lite 07 | Over an older wire | | --- | --- | --- | -| Request pinned to an epoch (`request(path, Some(epoch))`, `request(path, { epoch })`) | Only that instance; another is refused as unroutable | Refused: nothing proves the instance | -| Catalog reference with an `epoch` | Only that instance; another ends the rendition | Best effort: reads whatever resolved | -| No expectation | Whichever instance wins | Whichever route wins | - -The epoch also travels on requests: where the route carries one, SUBSCRIBE -and FETCH name the instance their route announced, -pinned or not, and a publisher or relay that now serves another refuses them -rather than answer with the wrong one. A catalog's `epoch` is weaker. It never -proves a subscription may resume across routes, and over an older wire it -cannot be checked at all. -Set it only when you know the instance; omit it for a broadcast produced on +| Request pinned to an instance (`request(path, Some(instance))`, `request(path, { instance })`) | Only that instance, over any of its routes; another is refused as unroutable | Only the instance this session minted | +| Catalog reference with an `instance` | Only that instance; another ends the rendition | Best effort: reads whatever resolved | +| No expectation | Whichever instance wins | Whichever announcement wins | + +The instance also travels on requests: on moq-lite 07, TRACK, TRACKS, +SUBSCRIBE and FETCH name the instance their route announced, and a publisher +or relay now serving another refuses them rather than answer with the wrong +stream. A catalog's `instance` is weaker. It never proves a subscription may +resume across routes, and against a minted instance it cannot be checked at +all. Set it only when you know the stream; omit it for a broadcast produced on demand, which has none until a worker answers. ## Work on demand A worker can advertise a prefix it serves on demand, such as a transcoder -claiming `transcode`. A claim promises capability, not content, so it carries -no epoch. Each requested path is a separate job, announced at its exact path -under its own fresh epoch. +claiming `transcode`. A claim promises capability, not content. Each requested +path is a separate job, announced at its exact path under a fresh instance of +its own. ```rust let claim = origin.requests("transcode")?; while let Ok(request) = claim.requested_broadcast().await { - // A fresh epoch for this job, never one shared with another path or run. - let prepared = request.prepare(Epoch::mint())?; + // A fresh instance for this job, never one shared with another path or run. + let prepared = request.prepare()?; let video = prepared.broadcast().create_track("video", info.clone())?; - // Announces the exact path under that epoch and answers the waiting request. + // Announces the exact path under that instance and answers the waiting request. let output = prepared.publish()?; spawn(run_job(video, output)); } ``` -JavaScript mirrors this with `request.prepare(epoch)` and `prepared.publish()`; +JavaScript mirrors this with `request.prepare()` and `prepared.publish()`; see [`@moq/net`](/lib/js/net) and [`moq-net`](/lib/rs/moq-net). A prepared answer is unannounced until published, and dropping it unpublished refuses the request. @@ -142,36 +180,48 @@ A lazy job's lifecycle, as a viewer of `transcode/alice` sees it through a relay 1. **Request.** The relay resolves the path through the claim and forwards the request. The worker starts job A and publishes `transcode/alice` under - epoch A. + instance A. 2. **Takeover.** Publishing the answer through the claim proves it was job A, so job A's route takes over the claim: what the viewer already resolved - through the claim beneath `transcode/alice` keeps playing, now as epoch A. - A Rust follower sees a takeover rather than a restart (JavaScript still - reports a start or restart). A different worker, or a - job restarted under the same claim, never inherits it. Only moq-lite 07 - carries the takeover across relays. Older wires, and paths where a relay - cannot vouch for it, send an ordinary announcement instead, so the first - subscription through the claim is reset there: a follower sees a restart - and requests again, and a holder's track ends with an error, and it - requests again. Either way the second request reaches job A. -3. **Steady state.** Requests resolve epoch A by its exact route. Routes - carrying epoch A through other relays fail over without a seam. + through the claim beneath `transcode/alice` keeps playing, now as instance + A. A Rust follower sees a takeover rather than a restart (JavaScript still + reports a start or restart). A different worker, or a job restarted under + the same claim, never inherits it. Only moq-lite 07 carries the takeover + across relays. Older wires, and paths where a relay cannot vouch for it, + send an ordinary announcement instead, so the first subscription through + the claim is reset there: a follower sees a restart and requests again, and + a holder's track ends with an error, and it requests again. Either way the + second request reaches job A. +3. **Steady state.** Requests resolve instance A by its exact route. Routes + carrying instance A through other relays fail over without a seam. 4. **Job restart.** The worker closes job A's publication. The path falls back to the claim, the next request asks the worker again, and job B publishes - under a fresh epoch. Followers see each change as a restart; relays drop job - A's cached groups, so a viewer returning within the cache window never gets - them. Other paths under the claim, such as `transcode/bob`, are untouched. + under a fresh instance. Followers see each change as a restart; relays drop + job A's cached groups, so a viewer returning within the cache window never + gets them. Other paths under the claim, such as `transcode/bob`, are + untouched. 5. **Done.** Closing the last job leaves only the claim, which serves the next request as in step 1. +### Pools + +Several hosts can claim one prefix, such as a set of transcoders or control +nodes. Each claim has its own instance, so step 3 of [choosing a +route](#choosing-a-route) spreads the paths under the prefix over the hosts, +and every relay picks the same host for a path. A host that is full withdraws +its claim, like a leaky bucket: the paths it served through the claim move to +another host's instance, so their broadcasts end and `follow` yields the other +host's, while paths another host won stay where they are. A crashed host's +claim goes the same way when its session closes or drains. + ## Recovering content -Within one instance, recovery is about which groups are still worth having. +Within one stream, recovery is about which groups are still worth having. - **Subscriptions and fetches.** A subscription follows the live track and, - after a same-epoch route change, continues from the first frame it lacks. A - fetch asks for one group by sequence, which a relay fills upstream when its - cache misses. Both are bound to the instance they resolved. + after a route change within its instance, continues from the first frame it + lacks. A fetch asks for one group by sequence, which a relay fills upstream + when its cache misses. Both are bound to the instance they resolved. - **Publisher max age bounds retention; reader max delay bounds interest.** A track's `max_age` (`maxAge` in JavaScript, `--max-age` on `moq import`) is the longest any hop keeps a non-latest group for late joiners, fetches, and @@ -203,13 +253,14 @@ or max delay. Support differs by library; see A relay that keeps this contract: -- Ranks the newest epoch first among routes for one path, ahead of cost, and - an epochless route last. Epochs order by creation time, so a host with a - skewed clock can lose a restart until the old instance retracts. -- Moves a subscription only between routes with the same epoch, never between - epochless routes or onto another instance. +- Chooses routes in the [order above](#choosing-a-route), so every relay + resolving a path agrees on its stream. +- Moves a subscription only between routes of the same instance, never onto + another stream. - Refuses a request naming another instance than the route it would serve from. -- Drops its copy of an instance once another replaces it at the path. +- Mints an instance for every announcement it receives without one, so it + holds no route without an instance. +- Drops its copy of a stream once another replaces it at the path. See [clustering](/bin/relay/cluster) for how `moq-relay` applies this across a mesh, and [redundant ingest](/setup/redundancy) for choosing between publisher diff --git a/doc/concept/index.md b/doc/concept/index.md index 21363414d20..6f129d15cd8 100644 --- a/doc/concept/index.md +++ b/doc/concept/index.md @@ -35,7 +35,7 @@ watched at 100 ms by one viewer and 10 s by another. - [Transport](/concept/transport): why QUIC, and the WebTransport, WebSocket, raw QUIC, and iroh paths onto it. - [moq-lite](/concept/moq-lite): the pub/sub protocol, discovery, path patterns, subscriptions, and congestion behavior. -- [Identity and recovery](/concept/identity): choosing publisher epochs, following restarts, and answering work on demand without mixing instances. +- [Identity and recovery](/concept/identity): choosing stream instances, following restarts, and answering work on demand without mixing streams. - [hang](/concept/hang): the media catalog, containers, encryption, and how to extend both. - [Archives](/concept/archives): record listed tracks into object storage and serve them back from a host. - [Audio jitter](/concept/audio-jitter): how a receiver sizes its audio playout target from arrival timing. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 27cbea25374..441db611b4b 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -15,7 +15,7 @@ implement in an afternoon. The wire spec is Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts 18 through 22. Clients offer `moq-lite-06` first. moq-lite 07 is still in progress (`moq-lite-07-wip`, and only when both sides enable it). It is the version that -carries [publisher epochs](#publisher-epochs). +carries [publisher instances](#publisher-instances). ## Terminology @@ -73,8 +73,8 @@ tracks follow. Each route carries the chain of relay identities it passed through, which is how forwarding loops are caught, and a cost, which is how a subscriber picks -among several routes to the same broadcast. It may also carry a -[publisher epoch](#publisher-epochs), which says which routes serve the same +among several routes to the same broadcast. On moq-lite 07 it also carries a +[publisher instance](#publisher-instances), which says which routes serve the same bytes. A hop of 0 is the anonymous mark and travels the chain unchanged. A route that passed through an anonymous hop at any depth ranks below every fully identified route, whatever the costs say. @@ -100,75 +100,77 @@ whose upstream session has since gone. How long a finished broadcast stays announced for late readers is the application's retention policy, such as the `--linger` of [`moq import`](/bin/cli), not a protocol state. -### Publisher epochs - -A route may carry an epoch: a UUIDv7 naming the publisher instance behind it. -A path and an epoch name one broadcast. The path never changes, so -authorization and hidden names work exactly as without one. - -- **One instance, one epoch.** A publisher mints an epoch per run. Replicas of - the same content announce that same epoch. A prefix claim names no instance: - its worker answers each requested path by announcing that exact path under - the output's own epoch. Restarting one output is then a new epoch every hop - sees, so a viewer returning within a relay's cache window never gets the old - output's groups. On moq-lite 07 that announcement is a takeover of the claim: - what a viewer already resolved through the claim keeps playing from the - output rather than being reset to the new route. A different worker, or a - replacement output, never inherits those viewers. -- **Newest wins.** Among routes at the same prefix, the newest epoch ranks - first, ahead of cost, and a route without an epoch ranks last. Newest means - the latest UUIDv7 timestamp, so hosts with skewed clocks can lose a restart - until the old instance retracts. -- **Same epoch resumes.** A subscription moves between routes with the same - epoch without a seam, continuing from the first frame it lacks. That is a +### Publisher instances + +Every route carries an instance: any non-empty text naming one continuous run +of group numbers at its path, plus an epoch, a counter within that instance, +like a cloud instance ID that survives a reboot and a count of its boots. A +path and an instance name one stream. The path never changes, so authorization and +hidden names work exactly as before. + +- **One stream, one instance.** Every broadcast mints a random instance and + keeps it across reconnects. Replicas of the same content announce that same + instance. A prefix claim is its own stream: its worker answers each requested + path by announcing that exact path under the output's own instance. + Restarting one output is then a new instance every hop sees, so a viewer + returning within a relay's cache window never gets the old output's groups. + On moq-lite 07 that announcement is a takeover of the claim: what a viewer + already resolved through the claim keeps playing from the output rather than + being reset to the new route. A different worker, or a replacement output, + never inherits those viewers. +- **Most specific, then highest epoch, then rank.** The most specific prefix + wins. Within one instance only its highest epoch is eligible, ahead of cost. + Among instances the order is link preference, cost, a broadcast published + locally, hop length, then a hash of the instance and the path, so relays + agree and a pool's paths spread over its hosts. See + [choosing a route](/concept/identity#choosing-a-route). +- **Same instance resumes.** A subscription moves between routes with the same + instance without a seam, continuing from the first frame it lacks. That is a floor, not a promise: groups the outage held back still arrive only while the subscriber's max age covers them, so a route that dies silently, noticed - only at the idle timeout, costs whatever aged out meanwhile. A route - without an epoch keeps its subscriptions until that route goes, and a track - that fails ends instead of resuming, so request the path again. -- **Another instance restarts.** A newer epoch, or, without one, another route - winning the path, is announced as a restart. Subscriptions already answered - stay on the old instance until dropped or none of its routes remain, which - ends them with an error, while new requests get the new one, so a player - follows the restart by requesting the path again. Without an epoch, a request - still unanswered when another instance takes the path, even after a - withdrawal, ends the same way, since that instance may be the one answering - it. A withdrawal alone leaves it to its answer. - Held handles can still read their old cached content while a subscription - on the old instance stays open, but cannot open new epochless requests once - the restart is delivered. Request the path again for - new content. - That is how a restarted encoder, whose group numbers start over, takes the - name instead of stalling viewers on the old sequence. A relay drops its copy - of the old instance, so a new subscriber never sees its cached groups. - -The epoch and the restart and takeover messages travel on moq-lite 07, which is -opt-in. Older versions carry a takeover as an ordinary announcement, so the -first subscription through a claim is reset once the output's route arrives. -moq-transport and older moq-lite versions carry no epoch. Without an epoch, -any other route winning is a restart, even a reconnect over the same path, since nothing says it serves the same bytes. A peer that -ignores the end and start may keep cached objects of the old run under the same -names, so an end and a start is not a cache flush. - -`moq` and `moqsink` mint a fresh epoch per run, kept across reconnects, and the -RTMP, SRT, and WHIP ingests mint one per connection. Independent importers use -distinct epochs, even when fed one stream. Only replicas of one publisher's -bytes share its epoch, such as one publisher announced through multiple relays. See [Clustering](/bin/relay/cluster) for + only at the idle timeout, costs whatever aged out meanwhile. +- **Another instance restarts.** Another instance winning the path is + announced as a restart. Subscriptions already answered stay on the old + stream until dropped or none of its routes remain, which ends them with an + error, while new requests get the new one, so a player follows the restart + by requesting the path again. That is how a restarted encoder, whose group + numbers start over, takes the name instead of stalling viewers on the old + sequence. A relay drops its copy of the old stream, so a new subscriber never + sees its cached groups. + +The instance, the epoch, and the restart and takeover messages travel on +moq-lite 07, which is opt-in. `ANNOUNCE_START`, `ANNOUNCE_RESTART` and +`ANNOUNCE_TAKEOVER` carry the instance and epoch, and `TRACK`, `TRACKS`, +`SUBSCRIBE` and `FETCH` name the instance they pin. A restart onto another +instance, or a higher epoch of the same one, is advertised before the old one +is withdrawn, so the path is never absent. Older versions carry a takeover as +an ordinary announcement, so the first subscription through a claim is reset +once the output's route arrives. Older moq-lite versions and moq-transport +carry no instance, so the receiving session mints one for each announcement, +marked with a leading `~`: a reconnect or a re-announcement through such a hop +is another stream. A peer that ignores the end and start may keep cached +objects of the old run under the same names, so an end and a start is not a +cache flush. + +Independent importers use distinct instances, even when fed one stream. Only +replicas of one publisher's bytes share its instance, such as one publisher +announced through multiple relays. See [Clustering](/bin/relay/cluster) for how a relay uses this, and [identity and recovery](/concept/identity) for -choosing epochs, following restarts, and answering work on demand. +choosing instances, following restarts, and answering work on demand. ### Multiple CDNs An endpoint can hold sessions to several CDNs at once, each feeding one shared origin, and rank them: moq.pro as primary, another CDN as secondary. The rank is -a per-link preference the endpoint sets (higher wins, 0 is neutral, negative demotes), never sent on the wire. It decides after -the newest epoch and before cost, so metrics from different operators are never +a per-link preference the endpoint sets (higher wins, 0 is neutral, negative demotes), never sent on the wire. It decides +among instances before cost, so metrics from different operators are never compared. A subscription uses the preferred link that has a route and moves to the next when that route goes; a publisher pushes the same broadcast to every CDN. -The move resumes without a seam only between routes with the same epoch, which -needs moq-lite 07 on both links. A CDN link that carries no epoch makes moving onto it, or back -off it, a restart, and the player requests the path again. Both links must +The move resumes without a seam only between routes with the same instance, +which needs moq-lite 07 on both links. A CDN link that carries no instance +makes moving onto it, or back off it, a restart, and the player requests the +path again. Both links must also speak the same protocol and draft: a copy's track info records the moq-transport draft it crossed, and a copy whose track info differs is refused, ending the track. diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 15dd7bf38e0..98fdcca2542 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -54,8 +54,9 @@ its `peek()` or `changed()` methods; session statistics do not request PROBE. Both libraries offer solicit and auth unless a side's `extensions` setting declines one, which connects it as a peer that does not speak that extension. The pooled JavaScript `Connection` has no such setting yet. -moq-transport carries no [publisher epochs](/concept/moq-lite#publisher-epochs); -the [epoch draft](/draft/moq-epoch) remains an unimplemented proposal. +moq-transport carries no [publisher instance](/concept/moq-lite#publisher-instances), +so the receiving session mints one per announcement; the +[epoch draft](/draft/moq-epoch) remains an unimplemented proposal. [ietf](/draft/moq-ietf) goes the other way: it extends moq-lite-07 to carry this object state between relays. moq-lite-07 is still a work in progress (`moq-lite-07-wip`), negotiated only when both sides enable it, so a peer that @@ -76,8 +77,9 @@ choices. They follow the product model. `SUBSCRIBE` goes to one route, not to every publisher whose namespace matches. [Draft 16 §8.5](https://www.ietf.org/archive/id/draft-ietf-moq-transport-16.html#section-8.5) requires the relay to send that `SUBSCRIBE` to all matching publishers. See - [publisher epochs](/concept/moq-lite#publisher-epochs) for how that one route - is chosen. + [choosing a route](/concept/identity#choosing-a-route) for how that one + route is chosen. moq-transport carries no stream instance, so a relay mints + one for each namespace it receives. - **Plain readers leave object and track properties out.** [Draft 18 §2.5](https://www.ietf.org/archive/id/draft-ietf-moq-transport-18.html#section-2.5) diff --git a/doc/concept/stats.md b/doc/concept/stats.md index 8f9dbe55733..fe92c756be6 100644 --- a/doc/concept/stats.md +++ b/doc/concept/stats.md @@ -113,7 +113,7 @@ source's. `export ts --passthrough` reports it without `mpegts`. `hang::health` and `Hang.Health` in `@moq/hang` grade a client from two consecutive reports: `publisher` takes a pair of `stats` snapshots, `viewer` a pair of `.echo` snapshots. Each report names the broadcast it was read from -(its path and epoch), which observed every number in it; the caller states the +(its path and instance), which observed every number in it; the caller states the interval between the two and how long ago the newer one arrived. Both languages compute the same result from the shared fixtures in `rs/hang/fixtures/health.json`. @@ -127,7 +127,7 @@ listed as `unknown`, and a verdict with nothing graded is `unknown`: a browser publisher that knows only its PROBE RTT grades on RTT alone. A report older than the stale limit is `unknown` throughout, as is a transport sample whose `age` plus the report's exceeds it, and a client with no report has no sample at all. A changed -path or epoch (`replaced`) or a decreasing counter (`decrease`) is a reset: +path or instance (`replaced`) or a decreasing counter (`decrease`) is a reset: the deltas start over, so only gauges are graded until the next pair. Container sections such as `mpegts` are not read. @@ -170,15 +170,13 @@ generate more stats. prefix, so a consumer skips any path without `node` where it expects one. The broadcast stays announced for the producer's life. Each announcement -carries a fresh [publisher epoch](/concept/moq-lite#publisher-epochs) on its +carries a fresh [instance](/concept/moq-lite#publisher-instances) on its route, so a restarted node is a new broadcast at the same path: it replaces the -old one, and no relay serves it groups cached under the old epoch. A reader -treats each epoch as its own set of counters. The epoch rides only moq-lite 07 -announcements; over moq-transport and older moq-lite versions a reader sees the same -change as an end and a start at the path. -An aggregating reader pins each node's subscription to its epoch, and when one -publisher is reached through both kinds of link (with and without an epoch), it -carries only that path's outgoing counters across the change. +old one, and no relay serves it groups cached under the old instance. A reader +treats each instance as its own set of counters. The instance rides moq-lite 07 +announcements; over anything else the receiving session mints one per +announcement, so a reader sees the same change as an end and a start at the path. +An aggregating reader pins each node's subscription to its instance. Every group shares the node's broadcast, so a token scoped to the stats prefix reads every group's tracks; scope by group with an aggregating reader instead. @@ -262,11 +260,11 @@ earlier instance wrote. } ``` -Each tier that recorded anything in the epoch, with its egress (`publisher`) +Each tier that recorded anything in the node's run, with its egress (`publisher`) and ingress (`subscriber`) [Traffic](#traffic) and its [Presence](#presence), summed over every broadcast and stats label. Unlike a group's paths and labels, an entry that ends stays counted, so the next frame always carries everything -since the epoch began: a reader that misses frames loses nothing while the +since the run began: a reader that misses frames loses nothing while the node stays announced. Billing reads this track. ### Group @@ -285,7 +283,7 @@ node stays announced. Billing reads this track. ``` `epoch` is a UUIDv7 minted for each instance of the group's track, and a -reader keeps each epoch's counters apart, as it does for a broadcast's epochs. +reader keeps each epoch's counters apart, as it does for a broadcast's instances. `tiers` maps each tier the group recorded on since then. Its top-level `publisher`, `subscriber`, and `sessions` are the group's [totals](#totals), so a totals reader parses them unchanged, and like the node's totals they keep @@ -313,7 +311,7 @@ anyone holds the track. A track held again starts over. Wire bytes start at zero on every hold, including the first. Other presence fields seed from the root's current live-session counters; they do not recover -closed roots from before the hold. Node totals keep the full epoch's +closed roots from before the hold. Node totals keep the full run's history. Each recreated track starts a fresh snapshot group numbered by its publish interval, past any group of an earlier hold, so relay caches can distinguish the two. @@ -471,13 +469,13 @@ Every counter is a cumulative, monotonic unsigned integer. A rate is the difference between two frames divided by the time between them, and a live count is started minus ended. A frame never shows ended above started. -A new epoch starts every counter from zero, so a reader summing a node over -time adds each epoch's counters rather than diffing across them. Within one -epoch the totals never go down. A group's path or label counter going **down** +A new instance starts every counter from zero, so a reader summing a node over +time adds each instance's counters rather than diffing across them. Within one +instance the totals never go down. A group's path or label counter going **down** means the entry was dropped and re-created, and a presence or prefix counter going down means the track was held again. Treat it as the start of a fresh segment -rather than a negative rate. On a route without an epoch (an older relay, or a session older -than moq-lite 07), a counter going down also means the node restarted. +rather than a negative rate. On a route whose instance a session minted (an older relay, or +a session older than moq-lite 07), a counter going down also means the node restarted. A reader ignores unknown fields, so a newer relay can add counters, and defaults a missing field to zero, so it can read an older relay. diff --git a/doc/concept/use-case/contribution.md b/doc/concept/use-case/contribution.md index e3fa31e7814..3094dc679c5 100644 --- a/doc/concept/use-case/contribution.md +++ b/doc/concept/use-case/contribution.md @@ -31,13 +31,13 @@ captions on, or a transcoder's extra renditions. ## Failover and restarts A broadcast can be announced over several connections. Seamless failover -between them needs a [publisher epoch](/concept/moq-lite#publisher-epochs) -(moq-lite 07, opt-in), which names the instance behind a broadcast: replicas -sharing one fail over mid-group, and a restart is a newer epoch that viewers -switch to instead of stalling on the old group numbers, given hosts whose -clocks roughly agree. The RTMP, SRT, and -WHIP ingests mint one per connection. Without an epoch, a subscription stays on -the route it first resolved through until that route goes. +between them needs a [publisher instance](/concept/moq-lite#publisher-instances) +carried on every hop (moq-lite 07, opt-in), which names the stream behind a +broadcast: replicas sharing one fail over mid-group, and a restart is a new +instance that viewers switch to instead of stalling on the old group numbers. +The RTMP, SRT, and WHIP ingests mint one per connection. Over an older wire the +receiving relay mints an instance per announcement, so failover onto such a +route is a restart. ## One protocol both ways diff --git a/doc/concept/use-case/index.md b/doc/concept/use-case/index.md index 41847181822..aacba7eaac8 100644 --- a/doc/concept/use-case/index.md +++ b/doc/concept/use-case/index.md @@ -12,7 +12,7 @@ of scale come from. | Use case | Replaces | Why MoQ fits | | --- | --- | --- | | [Distribution](/concept/use-case/distribution) | HLS, DASH | Sub-second latency at CDN scale, with the viewer choosing its buffer. | -| [Contribution](/concept/use-case/contribution) | RTMP, SRT | Pull-based delivery, optional on-demand encoding, and epoch-based failover. | +| [Contribution](/concept/use-case/contribution) | RTMP, SRT | Pull-based delivery, optional on-demand encoding, and instance-based failover. | | [Conferencing](/concept/use-case/conferencing) | WebRTC | Bidirectional on one session, browser control of the pipeline, no SDP or TURN. | | [AI](/concept/use-case/ai) | WebRTC, WebSockets | Adjustable reliability, on-demand inference, media and prompts on one connection. | | [Teleoperation](/concept/use-case/teleoperation) | VPN plus video and control flows | Fresh media, replaceable state, and ordered commands share one session. | diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 38e6c40bf6b..624ee44d88d 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -71,19 +71,19 @@ if (publication < 0) return fail(moq_error()); // Fill tracks on broadcast here; announce on the publication. -char epoch[MOQ_EPOCH_LEN]; -moq_mint_epoch(epoch, sizeof(epoch)); // a fresh epoch per run moq_announce_route route; -memset(&route, 0, sizeof(route)); // zeroed means cost 0 and no epoch -route.epoch = epoch; -route.epoch_len = sizeof(epoch); +memset(&route, 0, sizeof(route)); // zeroed means cost 0 and the minted instance if (moq_publication_announce((uint32_t)publication, &route) < 0) return fail(moq_error()); ``` -A route carries only a cost and an epoch; relays add the hops. Mint a fresh epoch each -time the publisher starts, so viewers see a restart (`MOQ_ANNOUNCE_KIND_RESTART`) instead of -a stalled broadcast. Redundant publishers of the same bytes share one. +A route carries only a cost and the stream's instance; relays add the hops. Each +publication mints a fresh instance and keeps it across re-announcing, so a restarted +publisher is a new stream viewers restart onto (`MOQ_ANNOUNCE_KIND_RESTART`) instead of a +stalled broadcast. Set `instance` only for a publisher that continues its group sequences +across restarts, and raise `epoch` on each: readers stitch every route of one instance +together, so restarting the sequences under an old instance splices unrelated groups into +their tracks. Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Publication track names stay separate from codec settings. For simulcast, give each rendition the next one down as the `lower` handle in its `moq_video_encode_options`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3. diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index 670487570ad..4bc25d74446 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -142,9 +142,7 @@ moq::VideoEncoderOutput output{moq::VideoCodec::kH264, std::nullopt, std::nullop auto catalog = ok(moq::MediaCatalogProducer::init(broadcast)); auto video = ok(moq::VideoProducer::init(catalog, {{moq::VideoPixelFormat::kRgba, 1280, 720, {30000, 1001}}, output, "camera", nullptr})); ok(video->write({{0, 30'000}, rgba})); -moq::AnnounceRoute route; -route.epoch = moq::mint_epoch(); // a fresh epoch per run -ok(publication->announce(route)); +ok(publication->announce({})); // each broadcast mints a fresh instance ok(broadcast->close()); // keep the producer alive while publishing, then close explicitly ``` diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index 0dfcbe1bfeb..2fafb238017 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -99,7 +99,7 @@ final encoder = video.Producer( ), ); encoder.write(frame: video.Frame(timestamp: pts, data: rgba)); -camera.announce(route: AnnounceRoute(epoch: mintEpoch())); +camera.announce(route: AnnounceRoute()); encoder.finish(); ``` @@ -132,7 +132,7 @@ final track = mine.publishTrack(name: 'video', info: null); final group = track.appendGroup(); group.writeFrame(frame: Frame(payload: bytes, timestamp: null)); group.finish(); -publication.announce(route: AnnounceRoute(epoch: mintEpoch())); // a fresh epoch per run +publication.announce(route: AnnounceRoute()); // the broadcast mints its own instance track.finish(); mine.close(); diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 3439a2b8424..ff64d45c78e 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -113,8 +113,7 @@ video, _ := moqvideo.NewProducer(catalogProducer, moqvideo.ProducerOptions{ Track: &track, }) _ = video.Write(moqvideo.Frame{Timestamp: moq.Timestamp{Value: pts, Scale: 90_000}, Data: rgba}) -epoch := moq.MintEpoch() // a fresh epoch per run -_ = publication.Announce(moq.AnnounceRoute{Epoch: &epoch}) +_ = publication.Announce(moq.AnnounceRoute{}) // each broadcast mints a fresh instance _ = audio.Finish() _ = video.Finish() broadcast.Close() // keep the producer reachable while publishing, then close explicitly diff --git a/doc/lib/js/index.md b/doc/lib/js/index.md index 5f6f76d6082..79b7f0aa322 100644 --- a/doc/lib/js/index.md +++ b/doc/lib/js/index.md @@ -31,7 +31,7 @@ small examples. The [API reference](/lib/js/api/) covers every package; source l | [@moq/clock](/lib/js/clock) | Logical monotonic time, wall time, and deterministic timers. | | [@moq/signals](/lib/js/signals) | The reactive primitives every package exposes its state through. | | [@moq/json](/lib/js/json) | JSON over tracks: snapshots with merge-patch deltas, or append logs. | -| [@moq/e2ee](https://github.com/moq-dev/moq/tree/main/js/e2ee) | End-to-end encryption of groups, datagrams, and track names, scoped to a publisher epoch. Workspace-only until the browser components use it. | +| [@moq/e2ee](https://github.com/moq-dev/moq/tree/main/js/e2ee) | End-to-end encryption of groups, datagrams, and track names, scoped to a publisher run's epoch. Workspace-only until the browser components use it. | | [@moq/sign](https://github.com/moq-dev/moq/tree/main/js/sign) | Per-frame Ed25519 signatures, so relays forward content they cannot forge, retime, or relocate. Workspace-only until the mesh uses it. | | [@moq/loc](/lib/js/api/loc), [@moq/msf](/lib/js/api/msf) | The IETF LOC container and MSF catalog. | | [@moq/boy](/lib/js/api/boy) | The [MoQ Boy](/bin/demo) player element. | diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index db96a2cfc21..35dcdc8a56d 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -39,13 +39,13 @@ publication.announce(); ``` - **The origin holds the broadcasts, not the connection.** Closing a session unannounces them and leaves them created for the next one. A publication controls announcements; its `broadcast` fills tracks. The broadcast is invisible, locally and remotely, until the publication calls `announce()`. -- **Answer claimed paths with `using`.** `requests(prefix)` claims a prefix and yields each requested path. Use `using prepared = request.prepare(epoch)`, fill its broadcast, then `prepared.publish()` to answer with a new publisher instance and keep the `Publication` it returns. Leaving the scope without publishing, including on a throw, refuses the request as unroutable. Explicit `prepared.close()` or closing its unpublished broadcast does the same; disposal after publishing leaves the returned publication and its fill broadcast live. Reject before preparing with `request.reject(err)`. A retained, unclosed prepared answer keeps the request pending; JavaScript garbage collection provides no prompt cleanup guarantee. +- **Answer claimed paths with `using`.** `requests(prefix)` claims a prefix and yields each requested path. Use `using prepared = request.prepare()`, fill its broadcast, then `prepared.publish()` to answer with a new stream under a fresh instance and keep the `Publication` it returns. Leaving the scope without publishing, including on a throw, refuses the request as unroutable. Explicit `prepared.close()` or closing its unpublished broadcast does the same; disposal after publishing leaves the returned publication and its fill broadcast live. Reject before preparing with `request.reject(err)`. A retained, unclosed prepared answer keeps the request pending; JavaScript garbage collection provides no prompt cleanup guarantee. - **End content by finishing tracks.** A broadcast has no finished state. When the input ends, `finish()` each track, then `close()` it; closing a track without finishing aborts it. The broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide. -- **Requests can pin an epoch.** `consumer.request(path, { epoch })` resolves only through a route announcing that [publisher epoch](/concept/moq-lite#publisher-epochs). The resolved broadcast's `epoch` names the route it came through, pinned or not. -- **Follow restarts.** `origin.follow(path)` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close it when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same epoch taking over (epochs travel only on `moq-lite-07-wip`) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends it. Pass `{ epoch }` to pin one instance, which it waits for again after an end. `request(path)` instead keeps its accepted instance and ends once that instance stops serving; while pending, it fails `Unroutable` once discovery of its path has synced without a route for it. To list what is live, iterate `announced(scope)`. See [identity and recovery](/concept/identity) to choose between following and holding one instance. +- **Requests can pin an instance.** `consumer.request(path, { instance })` resolves only through a route announcing that [publisher instance](/concept/moq-lite#publisher-instances). The resolved broadcast's `instance` names the stream it came through, pinned or not. A publication mints a random instance and keeps it across `announce()`; pass `announce({ instance, epoch })` only for a publisher that continues its group sequences across restarts and raises the epoch each time. +- **Follow restarts.** `origin.follow(path)` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close it when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same instance taking over (instances travel only on `moq-lite-07-wip`; older wires mint one per announcement) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends it. Pass `{ instance }` to pin one stream, which it waits for again after an end. `request(path)` instead keeps its accepted instance and ends once that instance stops serving; while pending, it fails `Unroutable` once discovery of its path has synced without a route for it. To list what is live, iterate `announced(scope)`. See [identity and recovery](/concept/identity) to choose between following and holding one instance. - **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)`, `request(path)`, or `follow(path)`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. Every request and follower watches its path from creation until closed, so it outlives the listing that found it. A relay that refuses an announce request downgrades that session: requests subscribe blind through it, and followers keep waiting for a session that can discover. - **Connections share by URL and versions.** `new Connection({ url })` pools and reconnects with backoff, which the elements use. An ordered `versions` list, spelled like `moq-lite-06` as in Rust, applies to both WebTransport and WebSocket; equivalent lists share, while a different order or an omitted list gets its own pool entry. Supplying your own transport options or origin selects a private loop. Receive estimates are on demand: observe `connection.probe` reactively or subscribe while you need them; `peek()` alone opens no PROBE stream. -- **GOAWAY moves the session.** The connection dials the replacement at once while the old session keeps serving its groups, up to `goaway.handover` (default 10s, or the relay's deadline when that is sooner). New requests keep opening on the old session until the replacement's route outranks it, which is a restart without an epoch: an unpinned announced request follows the replacement, while a blind or epoch-pinned request stays on its accepted instance. With an epoch, which travels only when `moq-lite-07-wip` is offered, the replacement serves the same broadcast, so open subscriptions move across without missing a group. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`, which also presents the tokens on the connection's `auth` handle to the new host. A one-shot session from `Connection.connect` never moves on its own; its `draining` settles with the GOAWAY, so you can connect again and close it. Its URI is the peer's to choose and `goaway.redirect` does not apply, so validate it before dialing it or presenting tokens to it. +- **GOAWAY moves the session.** The connection dials the replacement at once while the old session keeps serving its groups, up to `goaway.handover` (default 10s, or the relay's deadline when that is sooner). New requests keep opening on the old session until the replacement's route outranks it, which is a restart when the instance did not cross: an unpinned announced request follows the replacement, while a blind or instance-pinned request stays on its accepted stream. When `moq-lite-07-wip` is offered the instance crosses, the replacement serves the same stream, and open subscriptions move across without missing a group. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`, which also presents the tokens on the connection's `auth` handle to the new host. A one-shot session from `Connection.connect` never moves on its own; its `draining` settles with the GOAWAY, so you can connect again and close it. Its URI is the peer's to choose and `goaway.redirect` does not apply, so validate it before dialing it or presenting tokens to it. - **One send estimate per connection.** `Bandwidth.Allocator` divides it by track priority, max-min fair within a tier. An idle track claims nothing, though its reservation still reads the share it would get. Publishers reserve against it so their targets sum to the estimate. - **List a broadcast's tracks.** `for await (const event of broadcast.tracks())` reports each track the broadcast lists (`added`, with its `Track.Info`), one `synced`, then `added` and `removed` as tracks come and go, until the broadcast ends; leaving the loop stops listing. Over a session it needs `moq-lite-07-wip`, syncs at once with what this side holds, and the peer's tracks follow as `added`. A track served only on request is never listed. See [discovery](/concept/moq-lite#discovery). - **One track per name.** Concurrent subscriptions share one request and producer. `createTrack` on a name with a request still queued answers it. The application owns sequence numbers: the broadcast keeps nothing of an ended track, so a track that replaces it, created or requested, starts at 0. A name always means the same content, so the replacement continues past its predecessor's groups by writing its first group with an explicit sequence. diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 5ed786a2ac3..d781b00f704 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -85,7 +85,7 @@ Moq.connect("https://relay.example.com").use { moq -> output = VideoEncoderOutput(codec = VideoCodec.H264, bitrate = null, gop = null, kind = autoEncoder), track = "camera"), ) video.write(VideoFrame(timestamp = pts, data = rgba)) - publication.announce(AnnounceRoute(epoch = mintEpoch())) // a fresh epoch per run + publication.announce(AnnounceRoute()) // the broadcast mints its own instance audio.finish() video.finish() broadcast.end() diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 1795c2b7620..c21b9a5d9c7 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -99,7 +99,7 @@ async def main(): status = moq.json.SnapshotProducer(broadcast, status_track) status.update({"state": "live", "viewers": 42}) - publication.announce(moq.AnnounceRoute(epoch=moq.mint_epoch())) # a fresh epoch per run + publication.announce() # each broadcast mints a fresh instance audio.finish() video.finish() events.finish() diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 3e6a681cfa4..ddf1e7bf83b 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -35,7 +35,7 @@ see [Applications](/bin/). [Examples](/setup/examples) links runnable consumers; | [moq-auth](/lib/rs/moq-auth) | Relay authorization: JWT signing and verification, and the auth server contract. | | [moq-room](/lib/rs/moq-room) | Workspace-only native rooms; use the published JS room API for applications. | | [moq-json](/lib/rs/moq-json) | JSON over tracks: snapshots with merge-patch deltas, or append logs. | -| [moq-e2ee](https://github.com/moq-dev/moq/tree/main/rs/moq-e2ee) | Workspace-only end-to-end encryption of groups, datagrams, and track names, scoped to a publisher epoch. | +| [moq-e2ee](https://github.com/moq-dev/moq/tree/main/rs/moq-e2ee) | Workspace-only end-to-end encryption of groups, datagrams, and track names, scoped to a publisher run's epoch. | | [moq-sign](https://github.com/moq-dev/moq/tree/main/rs/moq-sign) | Workspace-only per-frame Ed25519 signatures, so relays forward content they cannot forge, retime, or relocate. | | [moq-loc](https://docs.rs/moq-loc), [moq-msf](https://docs.rs/moq-msf) | The IETF LOC container and MSF catalog. | | [moq-stats](https://docs.rs/moq-stats) | Publish and consume relay traffic counters as tracks. | @@ -65,7 +65,7 @@ let mut announced = consumer.announced(); while let Some(event) = announced.next().await { // Skip updates and retractions. let moq_net::announce::Event::Start(update) = event else { continue }; - let broadcast = consumer.request(&update.prefix, update.route.epoch).await?; + let broadcast = consumer.request(&update.prefix, update.route.instance).await?; let catalog = broadcast .track(hang::Catalog::DEFAULT_NAME)? .subscribe(hang::Catalog::default_subscription()) @@ -80,7 +80,7 @@ let mut broadcast = origin.publish("my-stream.hang", Default::default())?; // moq-mux (from a container) or moq-video / moq-audio (from a device) fill it. // The route retracts on `unannounce()` or when the broadcast ends. To serve a whole // subtree on demand instead, `origin.requests("room")?` yields each requested path: -// `prepare(Epoch::mint())`, fill its broadcast, then `publish()`, or reject it. +// `prepare()`, fill its broadcast, then `publish()`, or reject it. ``` The connection and origin helpers spawn their drivers. Watch diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 5f4fab3b08e..54dfc2d713e 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -16,11 +16,11 @@ moq-transport at setup and presents one API either way. Media is a layer above What you use it for, beyond what the concept page already describes: - **An origin outlives the session.** Creating a broadcast returns an origin publication that controls its announcement and exposes the broadcast to fill. Standalone and Prepared fill producers cannot announce; only publishing the Prepared answer admits its publication. A reconnect announces existing publications again, and closing the session does not delete them. -- **Requests can pin an epoch.** `consumer.request(path, Some(epoch))` resolves only through a route announcing that [publisher epoch](/concept/moq-lite#publisher-epochs); `None` takes whichever route wins. The resolved consumer names its epoch in `info().epoch`, pinned or not. +- **Requests can pin an instance.** `consumer.request(path, Some(instance))` resolves only through a route announcing that [publisher instance](/concept/moq-lite#publisher-instances); `None` takes whichever route wins. The resolved consumer names its instance in `info().instance`, pinned or not. A broadcast announced with `Route::default()` serves a random instance it keeps across re-announcing; set one with `Route::with_instance(instance, epoch)` only for a publisher that continues its group sequences across restarts and raises the epoch each time. - **Rank several CDNs.** Dial each with its own subscribe handle, `origin.clone().with_preference(origin::Preference::new(rank))` (higher wins; 0 is neutral and a negative rank demotes), all on one origin. Requests use the preferred link that has a route and fail over to the next; see [multiple CDNs](/concept/moq-lite#multiple-cdns). - **Request one instance, or follow them all.** `consumer.request(path, None)` resolves the instance serving a path now, and fails `Unroutable` only once discovery has synced with no route for it. `consumer.follow(path, None)` hands out each publisher instance serving a path from `available()`: the first once announced, then each one that takes over or returns after an end, like JS `follow(path)`. A broadcast already resolved stays on the old instance until dropped or its route goes. See [identity and recovery](/concept/identity) to choose between following and holding one instance. - **Discovery follows what you watch.** A client session asks the relay for announcements only under the prefixes its subscribe origin's readers watch, with `announced()`, `request`, or `follow`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. A broadcast resolved through a discovered route keeps its path watched until dropped. `request` owns discovery of its path, so no separate watch is needed. A server session, or a client fed into an origin handle marked `with_link(origin::Link::Peer)` or `Link::Upstream`, asks for everything it may, so a relay discovers everything. An HTTP gateway (the moq-hls `Server`, the moq-rtc WHEP router) watches its whole scope while it lives, so the paths its callers name never open a request each. moq-lite-03/04 sessions, whose announcements mark no initial set, still ask for everything. -- **One track per name.** Concurrent subscriptions share one request and producer. The application owns sequence numbers: the broadcast keeps nothing of an ended track, so a producer that replaces it starts at 0. A name always means the same content, so the replacement continues past its predecessor's groups with `create_group` at an explicit sequence. Readers of an aborted track resume onto the replacement only when the broadcast announces an epoch; otherwise their subscription ends with the error. +- **One track per name.** Concurrent subscriptions share one request and producer. The application owns sequence numbers: the broadcast keeps nothing of an ended track, so a producer that replaces it starts at 0. A name always means the same content, so the replacement continues past its predecessor's groups with `create_group` at an explicit sequence. Readers of an aborted track resume onto the replacement when they read through an origin request, whose route always names an instance; otherwise their subscription ends with the error. - **End content by finishing tracks.** A broadcast has no finished state. When the input ends, finish each track; the broadcast stays announced and keeps serving them until you close it or drop its last producer. How long to keep it up for late readers is yours to decide: `moq_mux::Linger` holds a hang broadcast for a fixed window. - **Demand says whether anyone is watching.** `demand()` on a track, group, or broadcast says whether a subscriber is attached. First-party capture does not wait for it: video encodes while enabled and audio keeps raw PCM to encode when someone listens, so a late subscription's `max_delay` or a FETCH finds the recent past. Demand gates bandwidth reservations, transcode rungs, and on-demand video where a publisher opts in, which saves the encode but leaves nothing from before the first subscriber. Holding a broadcast or track consumer is not demand: a track consumer requests nothing until it queries, subscribes, or fetches, and those count until dropped. A shared fetch stays up until its last reader leaves. Demand rolls up: a frame being read keeps its group wanted, a group a subscription or fetch handed out keeps its track wanted until read to its end, and any wanted track keeps its broadcast wanted. - **Use the model without a runtime.** Disable default features on `moq-net` to use its `poll_*` APIs and origin driver with caller-supplied time. The default `async` feature adds async helpers, `time::run`, and both wire sessions. diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index d2260f67809..05f6eeac60d 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -86,7 +86,7 @@ let video = try Video.Producer( track: "camera" ) try video.write(VideoFrame(timestamp: pts, data: rgba)) -try publication.announce(route: AnnounceRoute(epoch: mintEpoch())) // a fresh epoch per run +try publication.announce(route: AnnounceRoute()) // the broadcast mints its own instance try audio.finish() try video.finish() try broadcast.close() diff --git a/doc/setup/redundancy.md b/doc/setup/redundancy.md index 95a81efaea5..83e415b068c 100644 --- a/doc/setup/redundancy.md +++ b/doc/setup/redundancy.md @@ -16,11 +16,11 @@ publisher's broadcast can resume the same stream. | Independent publishers, separate paths | Leaves selection to the subscriber. | | One publisher, several routes | Resumes the same broadcast on another route. | -A [path and publisher epoch](/concept/moq-lite#publisher-epochs) identify one -broadcast. Every independent publisher mints its own epoch, even if both read -the same source. Sharing an epoch would claim that their group and frame -numbers name the same content, which independently running importers cannot -promise. See [identity and recovery](/concept/identity) for the whole contract. +A path and an [instance](/concept/moq-lite#publisher-instances) name one +stream, so a redundant pair is two instances. Every independent publisher +mints its own instance, even if both read the same source. Sharing one would +claim that their group and frame numbers name the same content, which +independently running importers cannot promise. See [identity and recovery](/concept/identity) for the whole contract. ## Independent publishers on one path @@ -28,22 +28,18 @@ promise. See [identity and recovery](/concept/identity) for the whole contract. relays into a [cluster](/bin/relay/cluster) so the viewer's relay can reach both. Give both publishers permission to announce the same full path. 2. Publish the same broadcast name under the same auth path. Each publisher - keeps its own epoch; `moq` creates one automatically. -3. Use moq-lite 07, which is opt-in, on every hop to carry epochs. Keep the publisher hosts' clocks synchronized, since epochs order - by their UUIDv7 creation time. + keeps its own instance; `moq` creates one automatically. For example, two HLS importers can publish `event.hang` through different relays. Run each command on its publisher host, with its own source URL: ```bash # Publisher A -moq --connect https://ingest-a.example.com/anon \ - --connect-version moq-transport-18 --broadcast event.hang \ +moq --connect https://ingest-a.example.com/anon --broadcast event.hang \ import hls https://source-a.example.com/live/index.m3u8 # Publisher B -moq --connect https://ingest-b.example.com/anon \ - --connect-version moq-transport-18 --broadcast event.hang \ +moq --connect https://ingest-b.example.com/anon --broadcast event.hang \ import hls https://source-b.example.com/live/index.m3u8 ``` @@ -52,11 +48,14 @@ two disconnected relays does not make one a fallback for the other. The same setup applies to other publishers, including browser capture and ingest gateways. See [Production](/setup/prod) for TLS and authentication. -At the same path, the newest epoch wins new requests, ahead of route cost. -Starting B after A therefore switches followers to B even while A is healthy. -If B's route goes away and A remains live and reachable, A wins again as a new -broadcast. This is not a fixed primary/standby priority: restarting either -publisher can make it the newest. +At the same path, the two instances rank like any others: the cheaper route, +then the shorter hop chain, then a hash of the instance and the path, which +every relay agrees on. One of them wins new requests while both are healthy. +When its route goes, followers switch to the other as a new broadcast: the +old one ends and `follow` yields the next. When it returns, it wins again, +again as a new broadcast. This is not a fixed primary/standby priority unless +the routes' costs make it one, and a restarted publisher takes a fresh +instance, so it may rank either way. The switch is announced as a `Restart`. Players that follow announcements request the path again and begin the replacement broadcast. Existing @@ -79,7 +78,7 @@ priority so the replacement can acquire on a congested link. The switch drops the old instance's buffered tail, so the new program plays at `--delay` instead of carrying that tail as added latency. An unimpaired replacement usable within `--follow` adds no gap. Congestion can starve the old instance sooner or leave -the replacement acquiring past the window, causing a gap. The epochs' timestamps +the replacement acquiring past the window, causing a gap. The two streams' timestamps are not aligned, so this is still a marked program discontinuity, not hitless failover. `--follow 10s` also bounds waiting for a publisher after an end; choose that @@ -88,9 +87,10 @@ instance while it is up, then fails on a replacement. Other stdout export formats end with their broadcast; they do not follow a replacement. TS passthrough also cannot stitch. See [CLI retention and latency](/bin/cli#retention-and-latency). -A connection without epochs cannot prove continuity or apply the newest-epoch -rule: routing chooses among its epochless routes instead, and another route -winning is a restart. Do not rely on same-path continuity through such a hop. +A connection that carries no instance (moq-lite 06 and older, or +moq-transport) cannot prove continuity: the receiving relay mints an instance +per announcement, so even a reconnect of one publisher through it is another +stream. Do not rely on same-path continuity through such a hop. ## Independent publishers on separate paths @@ -100,14 +100,14 @@ switch between them, combine them, or keep them separate. This is useful when selection should depend on the media or operator policy rather than which publisher started last. -Each publisher produces its own stream with its own epoch. The relay routes +Each publisher produces its own stream with its own instance. The relay routes the two paths independently; it does not align their frames or stitch their output. The subscriber owns any transition between them. ## One publisher through several routes Keep a single publisher instance and distribute copies of its broadcast -through multiple relays or CDNs. All copies retain its path, epoch, group +through multiple relays or CDNs. All copies retain its path, instance, group numbers, and frame content. Connect the publisher's relay to multiple peers, then make those routes @@ -115,9 +115,9 @@ reachable from the subscriber's relay. An application can also hold sessions to [multiple CDNs](/concept/moq-lite#multiple-cdns) feeding one shared origin and rank its links. Do not start a second importer to create the second copy. -When the serving route fails or another route with the same epoch wins, the +When the serving route fails or another route with the same instance wins, the subscription resumes from the first frame it lacks. This needs an explicit -matching epoch across every hop, routes whose copies report identical track +matching instance across every hop (moq-lite 07 on every link), routes whose copies report identical track info (so the same protocol and draft on each), and a surviving route that can still serve those frames within the broadcast's retention. It preserves broadcast continuity, but a late recovery can still miss playback deadlines. Losing the diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index 5a4877034eb..bcaae6ad55c 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -66,14 +66,15 @@ These land with the next breaking release. Neither side reads the other's shape, so upgrade HLS exporters and their publishers together. - **Announce restarts.** moq-lite 07 adds `ANNOUNCE_RESTART` (0x3): another - publisher instance now serves the prefix. lite-06's `ANNOUNCE_RESTART` is - named `ANNOUNCE_UPDATE`, as it always meant. moq-transport carries no epoch; + instance, or a higher epoch of the same one, now serves the prefix. lite-06's + `ANNOUNCE_RESTART` is named `ANNOUNCE_UPDATE`, as it always meant. Elsewhere, a restart is still an end then a start. -- **Publisher epochs ride the route** (#4942). moq-lite 07 carries the epoch - after the path in `ANNOUNCE_START`, `TRACK`, `SUBSCRIBE`, and `FETCH`. - Routes without an epoch no longer resume across routes, so default lite-06 - sessions, including cluster GOAWAY redials, lose seamless cross-route - failover until lite-07 is the default. +- **Publisher instances ride the route.** moq-lite 07 carries the instance and + epoch after the path in `ANNOUNCE_START` and `ANNOUNCE_RESTART`, and the + pinned instance in `TRACK`, `TRACKS`, `SUBSCRIBE`, and `FETCH`. Older wires + carry none, so the receiving session mints an instance per announcement: + default lite-06 sessions, including cluster GOAWAY redials, lose seamless + cross-route failover until lite-07 is the default. - **Route cost is one value** (#4661). Upgraded nodes write lite-06's cold cost at 2^62-1, so an older relay that still ranks on `(warm, cold)` breaks equal-warm ties against routes that crossed an upgraded node. It is only a diff --git a/drafts/draft-lcurley-moq-e2ee.md b/drafts/draft-lcurley-moq-e2ee.md index 82d3da0f574..aacf9cf94d5 100644 --- a/drafts/draft-lcurley-moq-e2ee.md +++ b/drafts/draft-lcurley-moq-e2ee.md @@ -137,12 +137,12 @@ Clones of a producing generation MUST share permanent track claims so reopening Its leading 48-bit timestamp makes epochs sort by creation time and its random bits make collisions negligible. A protected broadcast is published at ``, the 22-character base64url segment derived from the credential and the application's semantic broadcast name according to {{derive}}. -Each instance announces its epoch as the route's moq-lite Epoch; the HKDF `bytes(epoch)` input is the UUID text. +Each instance announces its epoch text as the route's moq-lite Instance; the HKDF `bytes(epoch)` input is the UUID text. The opaque derivation does not include the epoch, so every instance of the same semantic broadcast shares one path. The path carries no format or protection marker; for example, the semantic name `meeting.hang` appears only as an input to the opaque derivation. A plaintext consumer that opens the protected broadcast fails because it cannot find the plaintext catalog it expects, not because of a path naming rule. -Subscribers resolve `` and take the epoch from its route, where relays rank the newest first. -On a wire that cannot carry the route's epoch, the application supplies it over its own authenticated channel. +Subscribers resolve `` and take the epoch from its route's Instance, which relays rank like any other Instance rather than newest first. +On a wire that cannot carry the route's Instance, where a relay mints one of its own, the application supplies the epoch over its own authenticated channel. The epoch and the path are not secret and are not authenticated. A relay that presents a wrong epoch causes authentication failure; a relay that withholds a newer instance denies service. @@ -152,7 +152,7 @@ A restart or replacement of a publisher is a new instance and mints a new epoch. So is a cloned process image, such as a `fork` without `exec`, a restored VM snapshot or process checkpoint, or a producing generation prepared once and copied to two processes: each copy MUST mint its own generation before it encrypts. A clone copies the epoch and the per-track counters verbatim, so two copies that both encrypt would protect different plaintext under the same key and nonce, and nothing on the wire lets a receiver or relay detect it. Transport sequence numbers therefore restart freely without any coordination between instances. -An ended instance stays readable only for as long as relays or archives retain it under its epoch; once a newer epoch wins the shared path, subscribers resolve the newer one. +An ended instance stays readable only for as long as relays or archives retain it under its epoch; once another instance wins the shared path, subscribers resolve that one. # Canonical Encoding {#encoding} @@ -387,7 +387,7 @@ This document requests no registrations. {:numbered="false"} - Require internally minted producing epochs and consume-only bindings for discovered epochs. -- Require canonical UUIDv7 epochs, carried on the route rather than in the path, leaving HKDF UUID inputs unchanged. +- Require canonical UUIDv7 epochs, carried as the route's Instance rather than in the path, leaving HKDF UUID inputs unchanged. Relays no longer rank the newest epoch first. - Keep semantic track names in the decrypted catalog; subscribers derive physical names from them. - Forbid applications from relying on group completeness, which is unauthenticated. - Derive physical track names by AES-128-GCM encryption of the padded semantic name under an HMAC-SHA-256 nonce, so a publisher recovers any requested name, and bind AEAD keys to the semantic name. diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 09036411fef..ba84e772963 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -35,14 +35,6 @@ normative: date: false informative: - moqepoch: - title: "MoQ Epoch Extension" - target: https://datatracker.ietf.org/doc/draft-lcurley-moq-epoch/ - author: - - - ins: L. Curley - name: Luke Curley - date: false h273: title: "Coding-independent code points for video signal type identification" target: https://www.itu.int/rec/T-REC-H.273 @@ -166,7 +158,7 @@ type Catalog = { "binary": BinaryTracks | undefined, "contributions": Record | undefined, // ... any custom fields ... } @@ -221,12 +213,12 @@ A discontinuity marker is a delivery event, not a new epoch, and a system-clock ## Contributions {#contributions} The optional root `contributions` map names whole broadcasts, such as each guest feed in a composed room, whose own catalogs describe their renditions. Each key is a contribution ID. -Each entry MUST contain a string `broadcast`, resolved against the catalog's broadcast as in {{field-broadcast}}, and MAY contain an `epoch` as in {{field-epoch}}. +Each entry MUST contain a string `broadcast`, resolved against the catalog's broadcast as in {{field-broadcast}}, and MAY contain an `instance` as in {{field-instance}}. A consumer MUST reject a catalog with a malformed entry or a reference that escapes above the root. Listing an entry MUST NOT subscribe to the contribution: a consumer opens its catalog only when it selects the contribution or one of its renditions, so a contribution produced on demand starts only for a viewer that wants it. -A selected contribution resolves like a rendition's reference, under the same rules for `epoch` and restarts ({{field-epoch}}). -A contribution produced on demand has no instance until it resolves, so a publisher omits its `epoch` until then. +A selected contribution resolves like a rendition's reference, under the same rules for `instance` and restarts ({{field-instance}}). +A contribution produced on demand has no stream until it resolves, so a publisher omits its `instance` until then. A contribution's rendition IDs belong to its own catalog and MUST NOT replace IDs in the listing catalog, so the same ID MAY name a contribution, a rendition in the listing catalog, and a rendition in the contribution. An unavailable contribution does not invalidate the listing catalog. @@ -239,7 +231,7 @@ A publisher MUST NOT change them for a track once a catalog lists it. Every other rendition field is live state that MAY change in any update within its own rules (`jitter` and `delay` never fall), as MAY the video section's `display`, `rotation`, and `flip`. The picture size MAY change at any keyframe, which a `description` carrying parameter sets rules out, and `codec` names a profile and level that covers every picture the track carries. -A publisher that changes an identity field MUST publish the media under a new track name, or as a new broadcast under a new Epoch {{moql}}. +A publisher that changes an identity field MUST publish the media under a new track name, or as a new broadcast under a new Instance {{moql}}. A rendition MAY keep its ID and name the new track with `track` ({{field-track}}), so a consumer follows the new name rather than reusing a decoder configured for the ID. A track name removed from the catalog MUST NOT return with a different identity. A consumer MAY configure a decoder once per track from the catalog. @@ -665,7 +657,7 @@ That is the intended behaviour, since a partial log presented as a whole one is A publisher SHOULD therefore keep a `stream` track's log within what its groups retain, and split anything unbounded across successive tracks; a consumer that needs the whole log SHOULD subscribe before the publisher exceeds that. ### broadcast and track {#data-shared} -The `broadcast`, `epoch`, and `track` fields carry the same meaning here as they do for a media rendition ({{field-broadcast}}, {{field-epoch}}, {{field-track}}). +The `broadcast`, `instance`, and `track` fields carry the same meaning here as they do for a media rendition ({{field-broadcast}}, {{field-instance}}, {{field-track}}). ### bitrate, jitter, and delay {#data-estimates} The optional `bitrate` field is the track's maximum bitrate in bits per second. @@ -689,7 +681,7 @@ Audio, video, and text renditions share the following fields, extending the WebC ~~~ type CommonExtensions = { "broadcast": string | undefined, - "epoch": string | undefined, + "instance": string | undefined, "track": string | undefined, "label": string | undefined, "container": Container, @@ -722,26 +714,26 @@ An absent MSF `namespace` inherits the catalog's broadcast. Since each endpoint sees paths under its own connection root, a `namespace` equal to the catalog broadcast's path, or matching it on whole trailing segments, also names the catalog's broadcast; a gateway MUST NOT emit a different broadcast's `namespace` that matches so. A gateway MUST preserve the namespace and track-name pair or refuse the rendition; it MUST NOT substitute the catalog's broadcast for an unrepresentable reference. -### epoch {#field-epoch} -The optional `epoch` field names the publisher instance of the broadcast a rendition references ({{field-broadcast}}), as the canonical text of its Epoch {{moql}}: a lowercase hyphenated UUIDv7. -Without `broadcast`, it names the instance of the catalog's own broadcast. -A consumer MUST reject a catalog containing any other `epoch` value. +### instance {#field-instance} +The optional `instance` field names the stream of the broadcast a rendition references ({{field-broadcast}}), as the text of its Instance {{moql}}. +Without `broadcast`, it names the stream of the catalog's own broadcast. +A consumer MUST reject a catalog containing an empty `instance`. A consumer reads each path a catalog instance references from the first broadcast it resolves there, so the catalog and its renditions never resolve one path independently across a replacement. -An absent `epoch` accepts that first broadcast, whichever instance it is. -A consumer that follows a referenced broadcast's announcements MAY move a rendition without `epoch` onto a restarted instance. +An absent `instance` accepts that first broadcast, whichever stream it is. +A consumer that follows a referenced broadcast's announcements MAY move a rendition without `instance` onto a restarted stream. -When `epoch` is present and the route that resolved the referenced broadcast carries an Epoch, the consumer MUST read the rendition only from that instance. -It MUST fail the rendition rather than fall back to another instance, including one that replaced it before or after resolution, and MUST NOT follow a restart onto another instance for it. -A later catalog update naming another Epoch for an already-resolved path MUST NOT replace the resolved broadcast; the renditions naming the other Epoch fail instead. +When `instance` is present, the consumer MUST read the rendition only from a broadcast of that Instance. +It MUST fail the rendition rather than fall back to another stream, including one that replaced it before or after resolution, and MUST NOT follow a restart onto another stream for it. +A later catalog update naming another Instance for an already-resolved path MUST NOT replace the resolved broadcast; the renditions naming the other Instance fail instead. A new catalog instance resolves its references afresh. -A route without an Epoch, such as one over an older moq-lite version or MoQ Transport without the epoch extension {{moqepoch}}, cannot prove which instance it serves, so there `epoch` is best effort: the consumer reads the broadcast it resolved. -A consumer MUST NOT treat a catalog's `epoch` as evidence that a subscription may resume across routes. +A route that crossed an older moq-lite version or MoQ Transport carries an Instance its receiver minted, starting with `~` {{moql}}, which names the announcement rather than the publisher's stream, so there `instance` is best effort: the consumer reads the broadcast it resolved. +A consumer MUST NOT treat a catalog's `instance` as evidence that a subscription may resume across routes. A consumer that predates the field ignores it and does not gain its guarantee. -A publisher SHOULD set `epoch` only when it knows the instance it references, and MUST omit it for a broadcast produced on demand, which has no instance until a worker accepts the request. -MSF {{moqmsf}} has no field for an instance, so a gateway converting to MSF drops `epoch` and SHOULD report that it did; its readers accept the first instance they resolve. +A publisher SHOULD set `instance` only when it knows the stream it references, and MUST omit it for a broadcast produced on demand, which has no stream until a worker accepts the request. +MSF {{moqmsf}} has no field for a stream instance, so a gateway converting to MSF drops `instance` and SHOULD report that it did; its readers accept the first stream they resolve. ### track {#field-track} A rendition's key in its map is its ID, and by default also the name of its track. @@ -1175,12 +1167,12 @@ This document has no IANA actions. - Removed the Recording section: the store layout is a moq-archive format, documented with that crate, not part of this draft. - Added the optional video `preference` field: a consumer selects automatically among the highest preference it can decode. - Added the optional video `reorder` field: the most frames that can precede one in decode order and follow it in presentation, fixed for the track once declared. A consumer that authors decode times derives them from it and the current group alone, refusing deeper reordering; without it, a consumer infers them from timestamps. -- Added an optional `epoch` beside every rendition and data track `broadcast` reference ({{field-epoch}}). A consumer reads each referenced path from the first broadcast it resolves for a catalog instance; a present `epoch` fails a rendition proven to resolve another instance, and is best effort over a route without an Epoch. -- Added the optional root `contributions` map ({{contributions}}): whole broadcasts named by a relative `broadcast` and an optional `epoch`, opened only on selection, whose rendition IDs stay their own. +- Added an optional `instance` beside every rendition and data track `broadcast` reference ({{field-instance}}). A consumer reads each referenced path from the first broadcast it resolves for a catalog instance; a present `instance` fails a rendition that resolves another stream, and is best effort against an Instance a receiver minted. +- Added the optional root `contributions` map ({{contributions}}): whole broadcasts named by a relative `broadcast` and an optional `instance`, opened only on selection, whose rendition IDs stay their own. - Added optional `stats` and `echo` catalog sections, publisher/viewer snapshots keyed by rendition ID, and the `.echo` feedback broadcast convention. - An Opus frame timestamp counts the OpusHead pre-skip: a consumer presents each decoded sample the pre-skip earlier than its frame timestamp places it. -- Fixed a track's identity for its name ({{identity}}): codec, description, container, display aspect, sample rate, and channel count never change, while every other field, including size, bitrate, and framerate, updates in place. A change of identity takes a new track name, which a rendition may name with `track` while keeping its ID, or a new Epoch. +- Fixed a track's identity for its name ({{identity}}): codec, description, container, display aspect, sample rate, and channel count never change, while every other field, including size, bitrate, and framerate, updates in place. A change of identity takes a new track name, which a rendition may name with `track` while keeping its ID, or a new stream Instance. - Specified MSF text metadata preservation and refusal of unrepresentable text packaging during gateway conversion. - Specified conversion between relative broadcast references and MSF track namespaces without changing track identity, reading a namespace aligned with the catalog path's trailing segments as the catalog itself. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 507af45e2de..563690ed7eb 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -32,7 +32,6 @@ normative: RFC8831: RFC9000: RFC9002: - RFC9562: informative: I-D.lcurley-moq-cluster: @@ -132,9 +131,11 @@ A Broadcast is a collection of Tracks named by a path. This corresponds to a MoqTransport's "track namespace". A path is a sequence of non-empty segments joined by `/` (0x2F), so it never starts or ends with `/` or contains two in a row; the empty path has no segments and prefixes every path. A `\` (0x5C) escapes the `\` or `/` that follows it, as a MoqTransport namespace field holding either is spelled; a path with any other `\` is a PROTOCOL_VIOLATION. -A publisher instance is identified by an Epoch, carried on its routes (see [ANNOUNCE_START](#announce-start)): a path and an Epoch name one Broadcast, and a publisher MUST NOT serve different content under the same pair. -A restarted publisher mints a new Epoch rather than reusing the path's old one, while replicas of the same content MAY share one. -A route without an Epoch names no instance: nothing says another route serves the same bytes. +A continuous stream is named by an Instance, carried on its routes (see [ANNOUNCE_START](#announce-start)): a path and an Instance name one Broadcast, and a publisher MUST NOT serve different content under the same pair. +Within one Instance, group sequences never restart and never name two different groups, so routes sharing it serve the same bytes; two Instances are independent streams, never stitched together. +A publisher that starts its group sequences over, such as a restarted process, uses a new Instance, while replicas of the same content share one. +A publisher MAY keep an Instance across restarts only if it continues the sequence space and announces a higher Epoch each time. +The Epoch is a counter within one Instance that picks its live incarnation while a stale route lingers; it is never compared across Instances. A publisher advertises what it can serve via ANNOUNCE_START messages, each carrying a path prefix: a route covering every broadcast path beneath it. A route is the only shape an advertisement takes: a publisher that serves only some of the paths beneath a prefix, such as a transcoder for any broadcast's derivative, advertises the covering prefix and refuses the requests it will not serve (see [Resolution](#resolution)); no message narrows a route. @@ -395,7 +396,7 @@ The publisher replies with a single ANNOUNCE_OK message followed by announcement - ANNOUNCE_START: a matching route over a path prefix is available. - ANNOUNCE_END: a previously started route is no longer advertised. - ANNOUNCE_UPDATE: a previously started advertisement was atomically updated (new hops or cost). -- ANNOUNCE_RESTART: another publisher instance now serves a previously started advertisement. +- ANNOUNCE_RESTART: a previously started advertisement now serves another Instance, or a higher Epoch of the same one. - ANNOUNCE_TAKEOVER: a matching route is available and continues what a previously started advertisement served beneath it. - SYNCED: the initial set is complete. @@ -452,27 +453,30 @@ A publisher MUST NOT advertise a path whose entries contain the Hop ID the subsc The receiver can only discard it, and acting on it would form a loop, so sending one is never useful. Of the paths that remain a publisher SHOULD advertise the best, and nothing when every known path contains that Hop ID. Selection is per subscriber, so a subscriber that the serving path flows through still receives the best standby path, which is what lets it fail over if its own copy dies. -The per-subscriber winner changing travels as an ANNOUNCE_UPDATE when the new winner serves the same Broadcast, and as an ANNOUNCE_RESTART when it does not: another Epoch, or, between routes without one, another route; the last qualifying path appearing or disappearing travels as an ANNOUNCE_START or ANNOUNCE_END. -A relay whose resolution of a path beneath a prefix moves to another route without an Epoch while the prefix's own winner stays (see the Spread Hash below) sends an ANNOUNCE_RESTART of that prefix. +The per-subscriber winner changing travels as an ANNOUNCE_UPDATE when the new winner serves the same Instance at the same Epoch, and as an ANNOUNCE_RESTART when it serves another Instance or a higher Epoch of the same one; the last qualifying path appearing or disappearing travels as an ANNOUNCE_START or ANNOUNCE_END. A route continuing what the subscriber was sent for its predecessor travels as an ANNOUNCE_TAKEOVER (see [ANNOUNCE_TAKEOVER](#announce-takeover)); where continuity does not hold, it travels as an ANNOUNCE_START, or an ANNOUNCE_RESTART at the predecessor's own prefix. When serving a subscription, a publisher MUST select the source by that same exclusion; if only excluded sources remain, the subscription is unroutable. Applying one rule to both advertisement and dispatch keeps advertised paths truthful, which is what prevents subscription cycles of any length. -When resolving a path covered by several routes (across any number of streams), the subscriber SHOULD prefer the most specific covering route (see [Resolution](#resolution)), then the newest Epoch, ranking a route without one last, then a path that contains no 0 Hop ID over one that does, then the lowest Route Cost after adding each arriving link's cost (see [Cost Parameter](#cost-parameter)), breaking ties toward the shortest path, then toward the lowest Spread Hash, and then toward the most recently received, so a reconnecting publisher is not outranked by the stale session it replaced. +When resolving a path covered by several routes (across any number of streams), the subscriber SHOULD prefer the most specific covering route (see [Resolution](#resolution)). +Among those, a route is ineligible while another route of the same prefix carries its Instance at a higher Epoch, however cheap it is. +Among the eligible routes, the subscriber SHOULD prefer a path that contains no 0 Hop ID over one that does, then the lowest Route Cost after adding each arriving link's cost (see [Cost Parameter](#cost-parameter)), then a broadcast the receiver publishes itself, breaking ties toward the shortest path, then toward the lowest Instance Hash, then, among routes of one Instance, toward the lowest Chain Hash, and then toward the most recently received, so a reconnecting publisher is not outranked by the stale session it replaced. -The Spread Hash is the 64-bit FNV-1a hash, with offset basis `0x420C0DECB00B` and the standard FNV-64 prime, of the requested path's UTF-8 bytes followed by each Hop ID of the route's path, oldest first, as 8 little-endian bytes. -It is keyed on the requested path rather than the route's prefix, so equal-cost advertisers of one prefix share its paths instead of the first one taking them all, while one path resolves to the same advertiser on every relay that holds the same routes. +Both hashes are the 64-bit FNV-1a hash, with offset basis `0x420C0DECB00B` and the standard FNV-64 prime, of the requested path's UTF-8 bytes followed by more bytes: for the Instance Hash, the route's Instance; for the Chain Hash, each Hop ID of the route's path, oldest first, as 8 little-endian bytes. +They are keyed on the requested path rather than the route's prefix, so equal-cost advertisers of one prefix, each under its own Instance, share its paths instead of the first one taking them all. +The Instance reads the same on every relay, so one path resolves to the same stream on every relay that holds the same routes. When choosing which route to advertise for a prefix, the requested path is the prefix itself. -A relay skips the Spread Hash for a route a publisher announced to it directly at the requested path, which no other relay ranks, so a publisher reconnecting under a new Hop ID wins at once. +A relay skips both hashes for a route a publisher announced to it directly at the requested path, which no other relay ranks, so a restarted publisher under a new Instance wins at once over its lingering session. -Routes with the same Epoch serve the same Broadcast, so a relay MAY move a live subscription between them, continuing from the first frame the subscriber lacks, and a route change (a reconnect, a cheaper path, a draining session) is invisible to the subscriber. -A relay MUST NOT move a subscription to a route with a different Epoch, or between routes without one: it stays on its route and ends with it. +Routes with the same Instance serve the same Broadcast, so a relay MAY move a live subscription between them, including onto a higher Epoch, continuing from the first frame the subscriber lacks, and a route change (a reconnect, a cheaper path, a draining session) is invisible to the subscriber. +A relay MUST NOT move a subscription to a route with a different Instance: it stays on its Instance's routes and ends with the last of them. When another Broadcast wins the path, subscriptions already open stay on theirs until the subscriber drops them or the route goes; new requests resolve the winner, and the ANNOUNCE_RESTART tells subscribers to request again. A receiver of an ANNOUNCE_RESTART (or of the ANNOUNCE_END and ANNOUNCE_START standing in for one) MUST NOT serve a new request under its prefix from anything resolved through the replaced advertisement. -An ANNOUNCE_TAKEOVER is the one exception to staying on a route: what was resolved through its predecessor beneath its prefix is the Broadcast the new route serves, so those subscriptions continue on the new route and new requests MAY join them, whatever either route's Epoch. -Epochs compare as their 16 bytes, which for a UUIDv7 orders them by creation time. -Ordering Epochs minted on different hosts therefore trusts their clocks: a restart on a host whose clock runs behind loses to the older instance until that one is retracted. +An ANNOUNCE_TAKEOVER is the one exception to staying on an Instance: what was resolved through its predecessor beneath its prefix is the Broadcast the new route serves, so those subscriptions continue on the new route and new requests MAY join them, whatever either route's Instance. +A version before this one carries no Instance, so a receiver of such a session mints one for each ANNOUNCE_START it receives, with Epoch 0, and forwards it as the route's Instance. +A minted Instance SHOULD start with `~` (0x7E), so a reader can tell it names an announcement rather than the publisher's stream; a publisher SHOULD NOT name its own Instance with that prefix. +A minted Instance is new for every announcement, so a publisher reconnecting or re-announcing through such a hop is another stream. #### Resolution {#resolution} A SUBSCRIBE, FETCH, or TRACK request names a path, and the receiver resolves it against the routes covering that path, after the per-subscriber exclusion above. @@ -542,7 +546,7 @@ Because a subscriber cannot parse buffered group frames until TRACK_INFO arrives A subscriber opens a Tracks Stream (0x8) to list the Tracks of one Broadcast. A listing is discovery, not delivery: the subscriber SUBSCRIBEs to each Track it wants as usual, and a Track's groups reach a late SUBSCRIBE from the publisher's cache while they are retained. -The subscriber sends a [TRACKS](#tracks) message naming the broadcast path and Epoch. +The subscriber sends a [TRACKS](#tracks) message naming the broadcast path and Instance. The publisher replies with listing events, then FINs the stream once the Broadcast ends, or resets it on error: - [TRACKS_ADD](#tracks-add): a Track is listed, with its TRACK_INFO. @@ -1046,7 +1050,8 @@ ANNOUNCE_START Message { Path Base (i), Path Keep (i), Route Prefix Suffix (s), - Epoch (b), + Instance (b), + Epoch (i), Hops (..), Route Cost (i), } @@ -1069,11 +1074,16 @@ The suffix begins with the first `Path Keep` segments of the suffix advertised b The remaining segments of the suffix, which is combined with the requested prefix to form the route's full prefix. An empty suffix advertises the requested prefix itself, which is how a route covering more than the request presents (see [Announce](#announce)). +**Instance**: +The stream the route serves (see [Broadcast](#broadcast)): non-empty UTF-8 text. +An empty or non-UTF-8 Instance is a PROTOCOL_VIOLATION. +A relay forwards the Instance unchanged. + **Epoch**: -The publisher instance the route serves: 16 bytes holding a UUIDv7 [RFC9562], or empty when unknown. -Any other length, or a UUID of another version or variant, is a PROTOCOL_VIOLATION. -A relay forwards the Epoch unchanged and MUST NOT invent one for a route that arrived without one. -Only an ANNOUNCE_RESTART or ANNOUNCE_TAKEOVER changes it; ANNOUNCE_UPDATE never does. +Which incarnation of the Instance the route serves: a counter the publisher raises within one Instance, 0 unless it keeps an Instance across restarts. +A relay forwards the Epoch unchanged. + +Only an ANNOUNCE_RESTART or ANNOUNCE_TAKEOVER changes the Instance or the Epoch; ANNOUNCE_UPDATE never does. **Hop Base** and **Hop Keep**: The Hop ID list ends with the last `Hop Keep` entries of the list advertised by the base that `Hop Base` names (see [Compression](#announce-compression)), following the literal `Hop ID` entries. @@ -1156,16 +1166,18 @@ An update whose only change is a Route Cost is valid: it is how a relay re-price ## ANNOUNCE_RESTART {#announce-restart} -A publisher sends an ANNOUNCE_RESTART message when another publisher instance replaces a previously started advertisement (see [Routing](#routing)), referencing its Announce ID. +A publisher sends an ANNOUNCE_RESTART message when a previously started advertisement now serves another Instance, or a higher Epoch of the same one (see [Routing](#routing)), referencing its Announce ID. The route's prefix is unchanged and the id stays live. -Whatever the subscriber resolved under the prefix through the advertisement is the old instance: in-flight subscriptions on it are undisturbed, and a new request resolves afresh. +Under another Instance, whatever the subscriber resolved under the prefix through the advertisement is the old stream: in-flight subscriptions on it are undisturbed, and a new request resolves afresh. +Under a higher Epoch of the same Instance the stream continues, and so do its subscriptions. ~~~ ANNOUNCE_RESTART Message { Type (i) = 0x3 Message Length (i) Announce ID (i), - Epoch (b), + Instance (b), + Epoch (i), Hops (..), Route Cost (i), } @@ -1177,13 +1189,13 @@ Set to 0x3 to indicate an ANNOUNCE_RESTART message. **Announce ID**: As defined for [ANNOUNCE_UPDATE](#announce-update). -**Epoch**, **Hops**, and **Route Cost**: +**Instance**, **Epoch**, **Hops**, and **Route Cost**: As defined for [ANNOUNCE_START](#announce-start), replacing the advertisement's. `Hop Base` may name this advertisement itself, which resolves against the list being replaced. ## ANNOUNCE_TAKEOVER {#announce-takeover} A publisher sends an ANNOUNCE_TAKEOVER message to advertise a route that continues what a live advertisement, its predecessor, served beneath the new route's prefix. -A worker advertising a prefix without an Epoch answers a request under it by publishing that path under a fresh Epoch; the ANNOUNCE_TAKEOVER keeps subscriptions already resolved through the prefix rather than resetting them to the more specific route. +A worker advertising a prefix under its own Instance answers a request under it by publishing that path under a fresh Instance; the ANNOUNCE_TAKEOVER keeps subscriptions already resolved through the prefix rather than resetting them to the more specific route. It is an ANNOUNCE_START in every other respect and assigns the next Announce ID. ~~~ @@ -1194,7 +1206,8 @@ ANNOUNCE_TAKEOVER Message { Path Base (i), Path Keep (i), Route Prefix Suffix (s), - Epoch (b), + Instance (b), + Epoch (i), Hops (..), Route Cost (i), } @@ -1208,14 +1221,14 @@ The Announce ID of a live advertisement whose prefix covers the new route's pref One at the predecessor's own prefix retires the predecessor's id, which this advertisement replaces; otherwise the predecessor stays live. The receiver MUST close the session with a PROTOCOL_VIOLATION if the predecessor was never assigned, is already retired, or does not cover the new prefix. -**Path Base**, **Path Keep**, **Route Prefix Suffix**, **Epoch**, **Hops**, and **Route Cost**: +**Path Base**, **Path Keep**, **Route Prefix Suffix**, **Instance**, **Epoch**, **Hops**, and **Route Cost**: As defined for [ANNOUNCE_START](#announce-start), resolved before the predecessor retires. The receiver treats whatever it resolved through the predecessor beneath the new prefix as served by the new route: subscriptions on it continue, and a new request for those paths MAY join them. Only what the predecessor's current advertisement resolved continues; anything resolved before an earlier ANNOUNCE_RESTART of it does not. -A publisher MUST send an ANNOUNCE_TAKEOVER only when the new route serves exactly what the subscriber resolved through the predecessor beneath its prefix: the instance that answered requests through the predecessor now advertises their paths. -A relay forwards one only when the subscriber was last sent that predecessor, and every route the relay presented there serves the same instance; otherwise it sends an ANNOUNCE_START, or an ANNOUNCE_RESTART at the predecessor's own prefix. +A publisher MUST send an ANNOUNCE_TAKEOVER only when the new route serves exactly what the subscriber resolved through the predecessor beneath its prefix: the publisher that answered requests through the predecessor now advertises their paths. +A relay forwards one only when the subscriber was last sent that predecessor, and every route the relay presented there serves the same Instance; otherwise it sends an ANNOUNCE_START, or an ANNOUNCE_RESTART at the predecessor's own prefix. Before moq-lite-07, a publisher sends an ANNOUNCE_START instead, or an ANNOUNCE_END and then an ANNOUNCE_START at the predecessor's own prefix. @@ -1227,7 +1240,7 @@ SUBSCRIBE Message { Message Length (i) Subscribe ID (i) Broadcast Path (s) - Epoch (b) + Instance (b) Track Name (s) Subscriber Priority (8) Group Order (8) @@ -1338,7 +1351,7 @@ It is the first message on a Track Stream (0x6). TRACK Message { Message Length (i) Broadcast Path (s) - Epoch (b) + Instance (b) Track Name (s) } ~~~ @@ -1346,10 +1359,10 @@ TRACK Message { **Broadcast Path**: The broadcast path of the track. -**Epoch**: -The publisher instance the subscriber expects, encoded as in [ANNOUNCE_START](#announce-start), or empty for whichever wins the path. -A publisher MUST refuse a request with UNROUTABLE when the route it would serve from has another Epoch, so a request is never answered by a different instance than the one it named. -A relay fills in the Epoch of the route it resolved when forwarding a request upstream; SUBSCRIBE and FETCH carry it the same way. +**Instance**: +The stream the subscriber expects, encoded as in [ANNOUNCE_START](#announce-start), or empty for whichever wins the path. +A publisher MUST refuse a request with UNROUTABLE when no route of that Instance serves the path, so a request is never answered by a different stream than the one it named. +A relay fills in the Instance of the route it resolved when forwarding a request upstream; TRACKS, SUBSCRIBE and FETCH carry it the same way. **Track Name**: The name of the track. @@ -1415,16 +1428,16 @@ It is the first message on a Tracks Stream (0x8). TRACKS Message { Message Length (i) Broadcast Path (s) - Epoch (b) + Instance (b) } ~~~ **Broadcast Path**: The path of the Broadcast to list. -**Epoch**: -The publisher instance the subscriber expects, as in [TRACK](#track). -The listing is pinned to the instance that answers it and ends with that instance. +**Instance**: +The stream the subscriber expects, as in [TRACK](#track). +The listing is pinned to the stream that answers it and ends with that stream. ## TRACKS_ADD {#tracks-add} A publisher sends TRACKS_ADD on a Tracks Stream when a Track is listed. @@ -1562,7 +1575,7 @@ FETCH is sent by a subscriber to request a single group from a track. FETCH Message { Message Length (i) Broadcast Path (s) - Epoch (b) + Instance (b) Track Name (s) Subscriber Priority (8) Subscriber Max Age (i) @@ -1843,13 +1856,14 @@ The `Message Length` describes the payload size on the wire. - A `\` in a path escapes only `\` or `/`; any other `\` is a PROTOCOL_VIOLATION rather than a second spelling of the same namespace field. - Hid routes with a `.`-prefixed segment below the requested prefix from announce discovery, and added the ANNOUNCE_REQUEST `Hidden` field to opt in. - Replaced ANNOUNCE_OK Active Count with an ordered, empty SYNCED event (0x4), marking the initial set without retaining routes that end before delivery. -- Added `Epoch` to ANNOUNCE_START, SUBSCRIBE, TRACK, and FETCH: a UUIDv7 naming the publisher instance, or empty. A path and an Epoch name one Broadcast. A relay MAY move a subscription between routes with the same Epoch, continuing from the first frame the subscriber lacks instead of at a group boundary, and never between routes with different Epochs or none. The newest Epoch wins a path for new requests, while subscriptions to the older one stay until their route goes. Replaces the first-hop identity. -- Added ANNOUNCE_RESTART (0x3): another publisher instance replaces a live advertisement, under a new Epoch or, without one, through another route, including a path beneath a prefix moving to another route. +- Added `Instance` and `Epoch` to ANNOUNCE_START and ANNOUNCE_RESTART, and `Instance` to TRACK, TRACKS, SUBSCRIBE, and FETCH. The Instance is non-empty UTF-8 text naming one continuous stream, and a path and an Instance name one Broadcast; the Epoch is a counter within one Instance. A relay MAY move a subscription between routes with the same Instance, continuing from the first frame the subscriber lacks instead of at a group boundary, and never between routes with different Instances. Within one Instance only its highest Epoch is eligible. A request's Instance is empty when it pins none. Replaces the first-hop identity. +- Added ANNOUNCE_RESTART (0x3): a live advertisement now serves another Instance, or a higher Epoch of the same one. - Added ANNOUNCE_TAKEOVER (0x5): a route that continues what a live advertisement served beneath it, such as a job answering a request through a worker's prefix at its exact path, so subscriptions resolved through the prefix are not reset. +- A receiver mints an Instance, starting with `~`, for each announcement a session of an earlier version delivers. - Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE_START and ANNOUNCE_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list. - Hop IDs span 64 bits. - Replaced Warm and Cold Route Cost with one static Route Cost. -- Added the Spread Hash tie-break after the shortest path: a hash of the requested path and the route's Hop IDs, so equal-cost advertisers of one prefix share its paths. It is skipped for a route a publisher announced directly at the requested path. +- Added the Instance Hash and Chain Hash tie-breaks after the shortest path: hashes of the requested path with the route's Instance, then with its Hop IDs, so equal-cost advertisers of one prefix share its paths. Both are skipped for a route a publisher announced directly at the requested path. - Removed the standby-ordering deployment requirements on Route Cost seeds and bounds. ### Tracks {#changelog-07-tracks} diff --git a/drafts/draft-lcurley-moq-sign.md b/drafts/draft-lcurley-moq-sign.md index 59cb3c65a3d..5430d457fb2 100644 --- a/drafts/draft-lcurley-moq-sign.md +++ b/drafts/draft-lcurley-moq-sign.md @@ -39,7 +39,7 @@ informative: --- abstract This document specifies moq-sign-00, a versioned profile that authenticates MoQ application payloads with a per-frame Ed25519 signature. -A publisher signs each grouped frame with its endpoint key, binding the broadcast path, the publisher epoch, the track, the group, the frame index, and the frame's timestamp. +A publisher signs each grouped frame with its endpoint key, binding the broadcast path, the publisher's instance, the track, the group, the frame index, and the frame's timestamp. A subscriber that expects that key delivers a frame only after its signature verifies, so a relay can forward and cache content without forging it, retiming it, or moving it to another position. --- note_Note_to_Readers @@ -91,7 +91,7 @@ Integers are unsigned big-endian of the stated width, and `bytes(x)` is `u64(len ~~~ message = "moq-sign-00" || bytes(path) - || bytes(epoch) + || bytes(instance) || bytes(track) || u64(group) || u64(frame) @@ -104,10 +104,10 @@ message = "moq-sign-00" The UTF-8 broadcast path both ends name the broadcast by. A relay may mount a broadcast under another prefix, so this is the application's path, not the one a transport hop reports. -**epoch**: -The UUID text of the publisher instance's epoch, carried on the broadcast's route ({{moql}}). -A publisher instance MUST sign under an epoch it minted and no other instance has used. -The epoch names one sequence space: a publisher that restarts its group sequences signs under a new one. +**instance**: +The UTF-8 instance carried on the broadcast's route ({{moql}}), naming one continuous stream. +A publisher MUST sign under an instance no other publisher uses. +The instance names one sequence space: a publisher that restarts its group sequences signs under a new one. **track**: The UTF-8 track name. @@ -152,9 +152,9 @@ A subscriber MUST NOT deliver any byte of a frame before its signature verifies. A `signature` failure ends that group for the subscriber: it MUST deliver no later frame of the group. Other groups of the track continue, since each verifies on its own. -A subscriber that cannot learn the broadcast's epoch from its route MUST refuse to read it (`epoch`). +A subscriber that cannot learn the publisher's instance from the broadcast's route MUST refuse to read it (`instance`), including when a relay minted the instance for a hop that carries none. -Every hop between the publisher and the subscriber must carry the route's epoch and each frame's timestamp and timescale unchanged; in {{moql}} that is moq-lite-07. +Every hop between the publisher and the subscriber must carry the route's instance and each frame's timestamp and timescale unchanged; in {{moql}} that is moq-lite-07. A hop that cannot, such as a moq-lite-05 or -06 hop that stamps an untimed frame with its send time, causes `signature` failures rather than a frame delivered with the wrong time. Datagrams are not covered by this profile; a subscriber MUST NOT deliver datagrams from a track it reads under it. @@ -170,22 +170,22 @@ The file covers key derivation from a seed, including {{RFC8032}} Section 7.1 TE Relays, caches, and recorders are untrusted for content. The publisher's secret key is trusted; anyone holding it can sign as that endpoint. -The signature binds every field a relay could otherwise change to move or retime an authentic frame: the path, the epoch, the track, the group, the frame index, and the timestamp. -Binding the epoch keeps a frame from an earlier instance of the same broadcast, which restarted its sequences, from verifying in a later one. +The signature binds every field a relay could otherwise change to move or retime an authentic frame: the path, the instance, the track, the group, the frame index, and the timestamp. +Binding the instance keeps a frame from an earlier instance of the same broadcast, which restarted its sequences, from verifying in a later one. Within an instance, a signed position names one frame only because the publisher never signs a group sequence twice; a relay holding two groups signed at one sequence could splice their frames into a group that was never published. Binding the timestamp keeps a relay from changing when a frame presents. Any subscriber can verify but none can sign, so subscribers that share a broadcast cannot forge frames to each other, as they could with a shared symmetric key. A relay can still delay, drop, or reorder groups. -A relay that presents a wrong epoch only causes `signature` failures. +A relay that presents a wrong instance only causes `signature` failures. ## Freshness {#freshness} The profile authenticates content, not liveness. -The subscriber learns the epoch from the route, which a relay presents, so a relay that recorded an instance can later announce that epoch again and replay the instance's frames, whole or in part, after the publisher has gone or restarted. +The subscriber learns the instance from the route, which a relay presents, so a relay that recorded an instance can later announce that instance again and replay the instance's frames, whole or in part, after the publisher has gone or restarted. Every replayed frame verifies, because the publisher did sign it at that position. -Binding the epoch only keeps frames of different instances from being spliced into one. -An application that needs freshness learns the expected epoch from a channel it trusts, or checks the signed timestamps against a clock it shares with the publisher. +Binding the instance only keeps frames of different streams from being spliced into one. +An application that needs freshness learns the expected instance from a channel it trusts, or checks the signed timestamps against a clock it shares with the publisher. ## Group End {#group-end} Nothing authenticates where a group ends: a relay can end a group after any frame, and the subscriber sees an ordinary end of group. @@ -193,7 +193,7 @@ MoQ already lets a relay drop the tail of a group, for example when it falls beh An application MUST NOT treat a group's end as proof that it received every frame the publisher wrote. One that needs a complete group, such as a byte stream reading the end as a clean close, signs its own end marker as the group's last frame. -A relay can observe everything a signature does not hide: paths, epochs, track names, payloads, sizes, and timing. +A relay can observe everything a signature does not hide: paths, instances, track names, payloads, sizes, and timing. Ed25519 signing is deterministic, so no per-signature randomness can leak the key. @@ -210,4 +210,4 @@ This document requests no registrations. ## draft-lcurley-moq-sign-00 {:numbered="false"} -- Initial `moq-sign-00` profile: Ed25519 endpoint keys with a z-base-32 ID, a message binding path, epoch, track, group, frame index, and timestamp, a payload-then-signature frame body, group-ending failures, an unauthenticated group end, no freshness guarantee, and shared vectors. +- Initial `moq-sign-00` profile: Ed25519 endpoint keys with a z-base-32 ID, a message binding path, instance, track, group, frame index, and timestamp, a payload-then-signature frame body, group-ending failures, an unauthenticated group end, no freshness guarantee, and shared vectors. diff --git a/drafts/moq-sign-00.json b/drafts/moq-sign-00.json index 6f854a0d3ac..1399107c087 100644 --- a/drafts/moq-sign-00.json +++ b/drafts/moq-sign-00.json @@ -36,7 +36,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -51,7 +51,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 0, @@ -66,7 +66,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 1099511627781, "frame": 0, @@ -81,7 +81,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 4294967296, @@ -96,7 +96,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 9007199254740991, "frame": 1, @@ -111,7 +111,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "café", "group": 3, "frame": 1, @@ -126,7 +126,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "video", "group": 3, "frame": 1, @@ -141,7 +141,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "video", "group": 3, "frame": 1, @@ -156,7 +156,7 @@ "seed": "6d6f712d7369676e2d3030206f74686572207365656421212121212121212121", "endpoint": "wfc7hxboduwytxyrr35ct7oj77o6jmsidyq9teynw9obene7g7cy", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -172,7 +172,7 @@ "id": "relocated-path", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/http", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -182,10 +182,10 @@ "error": "signature" }, { - "id": "relocated-epoch", + "id": "relocated-instance", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7c", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7c", "track": "stream", "group": 3, "frame": 1, @@ -198,7 +198,7 @@ "id": "relocated-track", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream2", "group": 3, "frame": 1, @@ -211,7 +211,7 @@ "id": "relocated-group", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 4, "frame": 1, @@ -224,7 +224,7 @@ "id": "relocated-frame", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 0, @@ -237,7 +237,7 @@ "id": "shifted-boundary", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/sshs", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "tream", "group": 3, "frame": 1, @@ -250,7 +250,7 @@ "id": "relocated-timestamp", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "video", "group": 3, "frame": 1, @@ -263,7 +263,7 @@ "id": "rescaled-timestamp", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "video", "group": 3, "frame": 1, @@ -276,7 +276,7 @@ "id": "timestamp-dropped", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "video", "group": 3, "frame": 1, @@ -289,7 +289,7 @@ "id": "timestamp-added", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -302,7 +302,7 @@ "id": "wrong-key", "endpoint": "wfc7hxboduwytxyrr35ct7oj77o6jmsidyq9teynw9obene7g7cy", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -315,7 +315,7 @@ "id": "tampered-payload", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -328,7 +328,7 @@ "id": "tampered-signature", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -341,7 +341,7 @@ "id": "appended-byte", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -354,7 +354,7 @@ "id": "truncated", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -367,7 +367,7 @@ "id": "empty", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "instance": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, diff --git a/drafts/moq-sign-00.ts b/drafts/moq-sign-00.ts index 9a23a4c219f..2dd55a04f56 100644 --- a/drafts/moq-sign-00.ts +++ b/drafts/moq-sign-00.ts @@ -33,7 +33,7 @@ export class ProfileError extends Error { /** One signed frame's identity. An untimed frame has timescale and timestamp 0. */ export type Identity = { path: string; - epoch: string; + instance: string; track: string; group: bigint; frame: bigint; @@ -99,7 +99,7 @@ export function message(identity: Identity, payload: Uint8Array): Uint8Array