From 08da4b377032870ae4ccb294667c462867e46ed1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 9 Sep 2026 23:50:04 -0700 Subject: [PATCH 1/4] chore: claim quest/m1/cluster-construction From 0a8d3fc52784e7a197679387f75cec6e67dde741 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 10 Sep 2026 00:59:18 -0700 Subject: [PATCH 2/4] feat(relay)!: construct the cluster origin once Put cache settings into ClusterOptions so the origin and node view are built once. Cluster::with_cache rebuilt the origin and detached any handle taken earlier. Co-Authored-By: grok-4.6 --- doc/bin/relay/config.md | 2 +- quest/m1/README.md | 1 - quest/m1/cluster-construction.md | 38 ------ rs/moq-relay/CHANGELOG.md | 8 ++ rs/moq-relay/src/cache.rs | 12 +- rs/moq-relay/src/cluster.rs | 183 ++++++++++++++++++++------- rs/moq-relay/src/nodes.rs | 5 - rs/moq-relay/src/relay.rs | 11 +- rs/moq-relay/src/web.rs | 2 +- rs/moq-relay/tests/auth_lifetime.rs | 6 +- rs/moq-relay/tests/goaway_cluster.rs | 14 +- rs/moq-relay/tests/smoke.rs | 6 +- 12 files changed, 174 insertions(+), 114 deletions(-) delete mode 100644 quest/m1/cluster-construction.md diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 6986c0959f..806f93b9d5 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -193,7 +193,7 @@ waiting forever). seconds and resizes the pool. Embedders calling `CacheConfig::init` directly should know that the task is owned by the `cache::Pool` it resizes, not by the `Cache` struct or the `Relay`: it stops on its next tick once the last `Pool` -clone drops. Handing the `Cache` to `Cluster::with_cache` therefore moves the +clone drops. Handing the `Cache` to `Cluster::new` therefore moves the task's lifetime onto the cluster, and keeping a `Pool` clone of your own keeps the task running for as long as you hold it. diff --git a/quest/m1/README.md b/quest/m1/README.md index ebbeee7a59..e14c1afca0 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -36,7 +36,6 @@ quest here and merged main into dev. - [HLS 404](/quest/m1/hls-cache-miss-codes.md) - a relay miss and a disconnected publisher answer 404 over moq-lite; IETF upstreams stay 500 - [HLS sibling restart](/quest/m1/hls-sibling-epoch-identity.md) - a replaced sibling publisher restarts its rendition instead of serving stale rows - [A/V clock](/quest/m1/plan-av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles -- [Cluster construction](/quest/m1/cluster-construction.md) - construct one stable origin after its cache settings are known, deleting the rebuilding builder - [LAN discovery app id](/quest/m1/lan-app.md) - every advertisement names an application as a DNS-SD subtype bound into the proofs, so unrelated apps on one network never meet - [One LAN mesh](/quest/m1/lan-mesh.md) - moq-cli drives the relay's Cluster, LAN peers authenticate by mDNS credential, and the two binaries mesh with each other - [Native Go context](/quest/m1/go-native-context.md) - the Go generator emits context.Context itself, retiring the hand-rolled cancellation token diff --git a/quest/m1/cluster-construction.md b/quest/m1/cluster-construction.md deleted file mode 100644 index c33e147bd0..0000000000 --- a/quest/m1/cluster-construction.md +++ /dev/null @@ -1,38 +0,0 @@ -# [M] Construct the cluster origin once - -## Goal - -A cluster exposes one stable origin from construction onward. Applying cache -settings cannot silently detach previously derived origin handles or stats -publishers. Callers do not need to know the order of origin-rebuilding methods. - -## Plan - -`rs/moq-relay/src/cluster.rs` constructs an origin in `Cluster::new` (:661), -exposes it through the public `origin` field, then constructs another in -`with_cache` (:700-709). It retains `info` alongside the live origin to -support that replacement and rebinds `nodes` afterward. A caller can clone -`cluster.origin` or attach stats before calling `with_cache`; those handles -keep the old origin. Consuming `self` in the builder does not prevent this -because the origin is cloneable. The two call sites, `Relay::load` -(rs/moq-relay/src/relay.rs:217) and the cache test helper -(rs/moq-relay/src/cache.rs:269), order the calls correctly, but the public API -only documents the prerequisite that the origin must still be pristine. - -- Put origin-defining settings into construction, using one options struct - with defaults. Construct the origin and its node view once, after the cache - pool, retention ceiling, and identity are known. `linger` is not one of - them: it is a deprecated, hidden no-op that only logs a warning - (cluster.rs:485-494, :670-675), so drop it rather than carry it into the - options. -- Delete the origin-rebuilding `with_cache` path and any stored construction - state that has no remaining purpose. Keep builders that only attach - independent services if they do not invalidate existing handles. -- Migrate both call sites and audit external embedder usage before removing - the published method. This is a `dev` change. Do not add a compatibility - shim that retains the same origin replacement hazard. -- Update the docs that name `with_cache`: the governor paragraph in - doc/bin/relay/config.md:193 and the `Cache` doc comment at cache.rs:74. -- Verify that configured cache settings reach the same origin used by serving, - node discovery, and stats. Cover the API shape at compile time where possible - and exercise publish/consume through a retained origin handle. diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index e292c9b0ce..63c7e99b2d 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- *(relay)* `ClusterOptions` so the origin is constructed with its cache settings + +### Removed + +- *(relay)* `Cluster::with_cache`; pass the cache to `Cluster::new` via `ClusterOptions` + ## [0.14.16](https://github.com/moq-dev/moq/compare/moq-relay-v0.14.15...moq-relay-v0.14.16) - 2026-09-09 ### Fixed diff --git a/rs/moq-relay/src/cache.rs b/rs/moq-relay/src/cache.rs index d1e83b5835..91ac34f334 100644 --- a/rs/moq-relay/src/cache.rs +++ b/rs/moq-relay/src/cache.rs @@ -71,11 +71,11 @@ pub struct CacheConfig { /// The headroom governor, when configured, is owned by [`Self::pool`] rather than /// by this struct: it holds only a [`cache::PoolWeak`] and stops on its next tick /// once every [`cache::Pool`] clone has dropped. Handing this to -/// [`Cluster::with_cache`](crate::Cluster::with_cache) therefore moves the -/// governor's lifetime onto the cluster, and dropping this struct afterwards keeps -/// it running. Conversely, holding a clone of [`Self::pool`] past the relay keeps -/// the governor running too, deliberately: as long as anything can still cache into -/// the budget, resizing it is still the right thing to do. +/// [`Cluster::new`](crate::Cluster::new) therefore moves the governor's lifetime +/// onto the cluster, and dropping this struct afterwards keeps it running. +/// Conversely, holding a clone of [`Self::pool`] past the relay keeps the governor +/// running too, deliberately: as long as anything can still cache into the budget, +/// resizing it is still the right thing to do. pub struct Cache { /// The shared pool every session's groups register with: the byte budget plus /// the wall-clock LRU window that reclaims idle groups. @@ -266,7 +266,7 @@ mod tests { /// hand it to a cluster whose construction can still fail. fn attach(cache: &CacheConfig, cluster: crate::ClusterConfig) -> anyhow::Result { let cache = cache.init()?; - Ok(crate::Cluster::new(cluster)?.with_cache(cache)) + crate::Cluster::new(crate::ClusterOptions::new(cluster).with_cache(cache)) } #[tokio::test(start_paused = true)] diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index 42244d14c9..bf9ce67701 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -562,6 +562,40 @@ pub struct LanConfig { pub secret: Option, } +/// Construction settings for a [`Cluster`]: identity, discovery, and the origin cache. +/// +/// The origin is built once from these, so cache settings cannot detach a handle +/// taken after construction. Independent services ([`Cluster::with_client`], +/// [`Cluster::with_stats`]) attach afterwards without rebuilding it. +#[derive(Default)] +#[non_exhaustive] +pub struct ClusterOptions { + /// Cluster identity, peers, and discovery. + pub config: ClusterConfig, + + /// Shared group pool and per-track retention ceiling. + /// + /// `None` uses an unbounded pool with the standard LRU window and no + /// media-timestamp ceiling. + pub cache: Option, +} + +impl ClusterOptions { + /// Construct from cluster config, leaving the origin cache at its defaults. + pub fn new(config: ClusterConfig) -> Self { + Self { + config, + ..Default::default() + } + } + + /// Use this resolved cache when constructing the origin. + pub fn with_cache(mut self, cache: crate::Cache) -> Self { + self.cache = Some(cache); + self + } +} + /// A [`Cluster`] whose config is validated and whose resources are bound, /// produced by [`Cluster::start`] and run with [`run`](Self::run). /// @@ -621,8 +655,8 @@ struct Work { /// /// Construct with [`Cluster::new`], then attach a QUIC client and (optionally) /// a [`stats::Registry`](moq_net::stats::Registry) with the `with_*` builder -/// methods. A cluster without a client can serve local sessions but cannot -/// dial remote peers. +/// methods. Those builders do not rebuild the origin. A cluster without a +/// client can serve local sessions but cannot dial remote peers. #[derive(Clone)] pub struct Cluster { config: ClusterConfig, @@ -634,11 +668,6 @@ pub struct Cluster { /// view always points at the same session in the logs. connection_ids: Arc, - /// The origin's construction config (identity, cache pool). Kept so the - /// `with_*` builders can rebuild the origin without losing each other's - /// settings. - info: origin::Info, - /// Client TLS config used to build the `--cluster-connect-api` HTTP client, so /// peer-list fetches present the same cluster cert the QUIC dials do. `Arc` so /// cloning a `Cluster` per connection stays cheap. @@ -664,11 +693,13 @@ pub struct Cluster { } impl Cluster { - /// Creates a new cluster with a fresh origin and no peers, client, or stats. + /// Creates a cluster with one origin, using [`ClusterOptions`] for identity + /// and cache. /// /// Use [`with_client`](Self::with_client) to enable dialing remote peers /// (required when `config.connect` is non-empty), and - /// [`with_stats`](Self::with_stats) to enable metrics publishing. + /// [`with_stats`](Self::with_stats) to enable metrics publishing. Those + /// builders do not rebuild the origin. /// /// Must be called within a tokio runtime: the origin's lifecycle driver is /// spawned here, so the origin serves sessions whether or not @@ -676,7 +707,8 @@ impl Cluster { /// /// Errors if `config.id` is set but invalid: it must be non-zero and below /// 2^62 (the wire varint limit). An unset id picks a fresh random origin. - pub fn new(config: ClusterConfig) -> anyhow::Result { + pub fn new(options: ClusterOptions) -> anyhow::Result { + let ClusterOptions { config, cache } = options; let id = match config.id { Some(0) => anyhow::bail!("--cluster-id must be non-zero"), Some(id) if id >= 1 << 62 => { @@ -691,8 +723,11 @@ impl Cluster { "cluster linger is deprecated and ignored; a broadcast closes as soon as its last publisher is lost" ); } - let info = origin::Info::new(id); - let origin = moq_tokio::origin::spawn(info.clone()); + let mut info = origin::Info::new(id); + if let Some(cache) = cache { + info = info.with_pool(cache.pool).with_cache_duration(cache.duration); + } + let origin = moq_tokio::origin::spawn(info); let nodes = crate::nodes::Nodes::new(origin.clone()); tracing::info!(hop_id = %origin.id(), configured = config.id.is_some(), "cluster initialized"); Ok(Cluster { @@ -701,31 +736,12 @@ impl Cluster { nodes, connection_ids: Arc::default(), client_tls: None, - info, origin, stats: moq_net::stats::Registry::disabled(), _stats_publisher: None, }) } - /// Attach the resolved [`Cache`](crate::Cache) (the shared group pool and the - /// per-track retention ceiling) so every session's broadcasts cache into one - /// memory budget bounded by both bytes and age. Call before deriving any origin - /// handles (e.g. [`with_stats`](Self::with_stats)) so they inherit the settings. - /// - /// Rebuilds the origin with the cache: safe because the cluster's origin is - /// still pristine here (no broadcasts published, no scopes derived). - pub fn with_cache(mut self, cache: crate::Cache) -> Self { - self.info = self - .info - .clone() - .with_pool(cache.pool) - .with_cache_duration(cache.duration); - self.origin = moq_tokio::origin::spawn(self.info.clone()); - self.nodes = self.nodes.with_origin(self.origin.clone()); - self - } - /// Attach a QUIC client used to dial cluster peers. /// /// Required when `config.connect` is non-empty; [`start`](Self::start) returns @@ -1641,6 +1657,10 @@ mod tests { use super::*; use crate::Config; + fn new_cluster(config: ClusterConfig) -> anyhow::Result { + Cluster::new(ClusterOptions::new(config)) + } + /// The publish task holds only a `Weak` to its producer, so it stops when the /// last `moq_stats::Producer` clone drops. Attaching one must therefore hand /// its lifetime to the cluster: an embedder driving its own loop takes the @@ -1660,7 +1680,7 @@ mod tests { ..Default::default() }; - let cluster = Cluster::new(ClusterConfig::default()).expect("cluster"); + let cluster = new_cluster(ClusterConfig::default()).expect("cluster"); let stats = config.build(cluster.origin.clone()); let cluster = cluster.with_stats(stats); @@ -1709,10 +1729,10 @@ mod tests { #[tokio::test] async fn cluster_tier_defaults_to_unprefixed() { - let cluster = Cluster::new(ClusterConfig::default()).expect("cluster"); + let cluster = new_cluster(ClusterConfig::default()).expect("cluster"); assert_eq!(cluster.cluster_tier(), Tier::default()); - let cluster = Cluster::new(ClusterConfig { + let cluster = new_cluster(ClusterConfig { tier: Some("region/sjc".to_string()), ..Default::default() }) @@ -2045,7 +2065,7 @@ mod tests { /// tear down or reconfigure the last-known-good dial set. #[tokio::test] async fn malformed_peer_list_preserves_current_dial() { - let cluster = Cluster::new(ClusterConfig::default()).expect("cluster"); + let cluster = new_cluster(ClusterConfig::default()).expect("cluster"); let dialed = DialMap::default(); let current = DialTarget::parse("https://peer.example/?cost=1").unwrap(); let task = tokio::spawn(std::future::pending::<()>()); @@ -2112,7 +2132,7 @@ mod tests { mesh: Some("true".to_string()), ..Default::default() }; - let err = Cluster::new(config).unwrap().start().await.expect_err("should error"); + let err = new_cluster(config).unwrap().start().await.expect_err("should error"); let msg = format!("{err}"); assert!(msg.contains("--cluster-node"), "missing --cluster-node in: {msg}"); assert!(msg.contains("--cluster-mesh"), "missing --cluster-mesh in: {msg}"); @@ -2122,7 +2142,7 @@ mod tests { /// node a stable identity across restarts. #[tokio::test] async fn cluster_id_sets_origin() { - let cluster = Cluster::new(ClusterConfig { + let cluster = new_cluster(ClusterConfig { id: Some(42), ..Default::default() }) @@ -2130,12 +2150,89 @@ mod tests { assert_eq!(cluster.origin.id(), 42); } + /// Cache settings land on the one origin serving, node discovery, and stats + /// share. A handle cloned at construction stays on that origin. + #[tokio::test] + async fn constructed_origin_keeps_cache_and_handles() { + let duration = Duration::from_secs(5); + let cache = crate::CacheConfig { + duration: Some(duration.into()), + ..Default::default() + } + .init() + .expect("cache"); + let pool = cache.pool.clone(); + + let cluster = Cluster::new( + ClusterOptions::new(ClusterConfig { + id: Some(42), + ..Default::default() + }) + .with_cache(cache), + ) + .expect("cluster"); + + let origin = cluster.origin.clone(); + assert_eq!(origin.id(), 42); + assert_eq!(origin.info().cache_duration, duration); + assert_eq!(origin.info().pool.expiry(), Some(duration)); + + let stats = crate::StatsConfig { + enabled: Some(true), + node: Some("test".to_string()), + ..Default::default() + } + .build(origin.clone()); + let cluster = cluster.with_stats(stats); + + assert_eq!(cluster.origin.id(), origin.id()); + assert_eq!(cluster.origin.info().cache_duration, duration); + assert_eq!(cluster.origin.info().pool.expiry(), Some(duration)); + + let mut broadcast = origin.create_broadcast("cam").expect("create"); + broadcast.announce(Default::default()).expect("announce"); + let mut track = broadcast.create_track("data", None).expect("track"); + track.write_frame(moq_net::Timestamp::ZERO, b"hello").expect("write"); + assert!(pool.used() > 0, "writes charge the constructed cache pool"); + + let consumer = cluster.origin.consume(); + tokio::time::timeout(Duration::from_secs(2), consumer.request_broadcast("cam")) + .await + .expect("broadcast resolves") + .expect("broadcast present"); + + let path = Path::new(MESH_PREFIX).join("https://peer.example/"); + let mut announced = consumer + .clone() + .with_root(MESH_PREFIX) + .expect("mesh prefix") + .announced(); + let registration = origin.create_broadcast(&path).expect("node advertise"); + registration.announce(Default::default()).expect("announce node"); + let update = tokio::time::timeout(Duration::from_secs(2), announced.next()) + .await + .expect("node advertised") + .expect("announce"); + assert!(update.active); + let snapshot = cluster.nodes.snapshot(); + assert!( + snapshot.nodes.iter().any(|node| node.node.contains("peer.example")), + "node discovery reads the constructed origin: {snapshot:?}" + ); + + let stats_path = Path::new(".stats").join("node").join("test"); + tokio::time::timeout(Duration::from_secs(5), consumer.routed(&stats_path)) + .await + .expect("stats announced") + .expect("stats present"); + } + /// A reserved (0) or out-of-range (>= 2^62) `cluster.id` is rejected rather /// than producing an unencodable hop id. #[test] fn cluster_id_out_of_range_errors() { for bad in [0, 1u64 << 62] { - let err = Cluster::new(ClusterConfig { + let err = new_cluster(ClusterConfig { id: Some(bad), ..Default::default() }) @@ -2151,7 +2248,7 @@ mod tests { /// (i.e. not exit and drop the broadcast). #[tokio::test(start_paused = true)] async fn passive_rendezvous_runs_without_client_and_advertises_self() { - let cluster = Cluster::new(ClusterConfig { + let cluster = new_cluster(ClusterConfig { node: Some("rendezvous.example.com:4443".to_string()), mesh: Some("true".to_string()), ..Default::default() @@ -2215,7 +2312,7 @@ mod tests { /// backwards compatibility: it enables gossip and supplies the node URL. #[tokio::test] async fn legacy_mesh_url_enables_gossip_as_node() { - let cluster = Cluster::new(ClusterConfig { + let cluster = new_cluster(ClusterConfig { mesh: Some("rendezvous.example.com:4443".to_string()), ..Default::default() }) @@ -2392,7 +2489,7 @@ mod tests { /// conflict, not a silent pick. #[tokio::test] async fn legacy_mesh_url_conflicting_with_node_errors() { - let cluster = Cluster::new(ClusterConfig { + let cluster = new_cluster(ClusterConfig { mesh: Some("a.example.com:4443".to_string()), node: Some("b.example.com:4443".to_string()), ..Default::default() @@ -2458,7 +2555,7 @@ mod tests { }, ..Default::default() }; - let err = Cluster::new(config) + let err = new_cluster(config) .unwrap() .start() .await @@ -2483,7 +2580,7 @@ mod tests { }, ..Default::default() }; - let err = Cluster::new(config) + let err = new_cluster(config) .unwrap() .start() .await diff --git a/rs/moq-relay/src/nodes.rs b/rs/moq-relay/src/nodes.rs index 4bb779a7ae..6f704c9331 100644 --- a/rs/moq-relay/src/nodes.rs +++ b/rs/moq-relay/src/nodes.rs @@ -123,11 +123,6 @@ impl Nodes { } } - pub(crate) fn with_origin(mut self, origin: origin::Producer) -> Self { - self.origin = origin; - self - } - /// Record a dial this relay initiated, keyed by the URL it dialed. /// /// `id` is the session's `conn` id from diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index f5a416f974..fcf4b28e9b 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -17,14 +17,10 @@ //! nothing errors, and the feature reports as configured while doing nothing. //! Calling `load` means a new step arrives with the update, and a reshaped API //! is a compile error. -//! -//! Ordering constraints live here for the same reason. [`Cluster::with_cache`] -//! rebuilds the origin, so it runs before anything derives a handle from it (the -//! stats producer, and every session after). use anyhow::Context; -use crate::{Auth, Cluster, Config, Connection, Internal, Shutdown, ShutdownTrigger, Web}; +use crate::{Auth, Cluster, ClusterOptions, Config, Connection, Internal, Shutdown, ShutdownTrigger, Web}; /// A fully assembled relay: the listeners and the shared cluster behind them. /// @@ -210,11 +206,8 @@ impl Relay { config.auth.init(&config.connect.tls).await? }; - // Before any origin handle is derived: `with_cache` rebuilds the origin, so a - // handle taken earlier would keep charging groups into the unbounded pool. let cache = config.cache.init()?; - let cluster = Cluster::new(config.cluster)? - .with_cache(cache) + let cluster = Cluster::new(ClusterOptions::new(config.cluster).with_cache(cache))? .with_client(client.clone()) .with_client_tls(config.connect.tls.build()?); let stats = config.stats.build(cluster.origin.clone()); diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index c0c55b596d..b4a3a67d83 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -1273,7 +1273,7 @@ mod tests { ..Default::default() })); let auth = Auth::new(auth_config).await.unwrap(); - let cluster = Cluster::new(crate::ClusterConfig::default()).unwrap(); + let cluster = Cluster::new(crate::ClusterOptions::default()).unwrap(); let certificates = moq_tokio::tls::Certificates::from_pem(&std::fs::read(&cert).unwrap()).unwrap(); let web = Web::new(auth, cluster, certificates, config); diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 8d1aaad73a..49293d1ebf 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -7,7 +7,7 @@ use std::{net::TcpListener, time::Duration}; -use moq_relay::{AuthConfig, Cluster, ClusterConfig, Connection, Web, WebConfig}; +use moq_relay::{AuthConfig, Cluster, ClusterOptions, Connection, Web, WebConfig}; use moq_token::{Algorithm, Key, KeyId}; use moq_tokio::moq_net::{self, Hop}; use wiremock::matchers::{method, path as path_matcher, query_param}; @@ -80,7 +80,7 @@ async fn spawn_relay(auth: moq_relay::Auth) -> (u16, tokio::task::JoinHandle<()> config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); let server = config.init(Default::default()).expect("server init"); let mut server = server.listen().await.expect("listen"); - let cluster = Cluster::new(ClusterConfig::default()).expect("cluster init"); + let cluster = Cluster::new(ClusterOptions::default()).expect("cluster init"); let handle = tokio::spawn(async move { let mut id = 0; @@ -107,7 +107,7 @@ async fn spawn_ws_relay(auth: moq_relay::Auth) -> (u16, tokio::task::JoinHandle< let port = probe.local_addr().expect("local addr").port(); drop(probe); - let cluster = Cluster::new(ClusterConfig::default()).expect("cluster init"); + let cluster = Cluster::new(ClusterOptions::default()).expect("cluster init"); // Stream listeners bind lazily, so this server never opens a socket; only // its certificate handle is used. diff --git a/rs/moq-relay/tests/goaway_cluster.rs b/rs/moq-relay/tests/goaway_cluster.rs index c29610e4d3..5879b39182 100644 --- a/rs/moq-relay/tests/goaway_cluster.rs +++ b/rs/moq-relay/tests/goaway_cluster.rs @@ -11,7 +11,7 @@ use std::net::TcpListener; use std::time::Duration; use moq_net::Hop; -use moq_relay::{AuthConfig, Cluster, ClusterConfig, Connection, PublicConfig}; +use moq_relay::{AuthConfig, Cluster, ClusterConfig, ClusterOptions, Connection, PublicConfig}; use url::Url; const TEST_TIMEOUT: Duration = Duration::from_secs(15); @@ -203,7 +203,9 @@ async fn cluster_migrates_on_upstream_goaway_inner() { let mut cluster_config = ClusterConfig::default(); cluster_config.connect = vec![format!("tcp://127.0.0.1:{port_a}/")]; - let cluster = Cluster::new(cluster_config).expect("cluster init").with_client(client); + let cluster = Cluster::new(ClusterOptions::new(cluster_config)) + .expect("cluster init") + .with_client(client); let started = cluster.clone().start().await.expect("cluster start"); let cluster_run = tokio::spawn(started.run()); @@ -325,7 +327,9 @@ async fn spawn_relay_with_upstream( client_config.goaway.handover = Duration::from_secs(2).into(); let client = client_config.init(Default::default()).expect("client init"); - let cluster = Cluster::new(cluster_config).expect("cluster init").with_client(client); + let cluster = Cluster::new(ClusterOptions::new(cluster_config)) + .expect("cluster init") + .with_client(client); let started = cluster.clone().start().await.expect("cluster start"); let handle = tokio::spawn(async move { @@ -668,7 +672,9 @@ async fn cluster_reconnects_on_empty_uri_goaway_inner() { let mut cluster_config = ClusterConfig::default(); cluster_config.connect = vec![format!("tcp://127.0.0.1:{port}/")]; - let cluster = Cluster::new(cluster_config).expect("cluster init").with_client(client); + let cluster = Cluster::new(ClusterOptions::new(cluster_config)) + .expect("cluster init") + .with_client(client); let started = cluster.clone().start().await.expect("cluster start"); let cluster_run = tokio::spawn(started.run()); diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index f9bf8c9a79..b198722381 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -9,7 +9,7 @@ use std::{net::TcpListener, time::Duration}; -use moq_relay::{AuthConfig, Cluster, ClusterConfig, Config, Connection, PublicConfig, Relay, Web, WebConfig}; +use moq_relay::{AuthConfig, Cluster, ClusterOptions, Config, Connection, PublicConfig, Relay, Web, WebConfig}; use moq_tokio::moq_net::{self, Hop}; const TIMEOUT: Duration = Duration::from_secs(10); @@ -54,7 +54,7 @@ async fn build_web_with(web_config: WebConfig) -> Web { .await .expect("auth init"); - let cluster = Cluster::new(ClusterConfig::default()).expect("cluster init"); + let cluster = Cluster::new(ClusterOptions::default()).expect("cluster init"); // moq_tokio::Server is needed for `certificates`, even though we never // expose HTTPS or QUIC in this test. Binding QUIC to `[::]:0` picks an @@ -538,7 +538,7 @@ async fn spawn_accept_relay( .await .expect("auth init"); - let cluster = Cluster::new(ClusterConfig::default()).expect("cluster init"); + let cluster = Cluster::new(ClusterOptions::default()).expect("cluster init"); let mut server = server.listen().await.expect("listen"); let handle = tokio::spawn(async move { From 97965544bbfe38784e06061d42f5cf48f3f5fb99 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 11 Sep 2026 22:45:56 -0700 Subject: [PATCH 3/4] docs(quest): drop cluster-construction links from lan-mesh The cluster-construction quest file is deleted when the origin is built once, so remaining links would fail the quest checker. Co-Authored-By: grok-4.6 --- quest/m1/lan-mesh.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/quest/m1/lan-mesh.md b/quest/m1/lan-mesh.md index b025b18fab..0d646c50e3 100644 --- a/quest/m1/lan-mesh.md +++ b/quest/m1/lan-mesh.md @@ -25,9 +25,8 @@ One implementation: the relay's `Cluster`. - moq-cli depends on the moq-relay library; its `cluster-lan` feature becomes `moq-relay/cluster-lan`. `MoqSide.cluster` nests `moq_relay::ClusterConfig`, so the CLI's `--cluster-*` flags are the relay's, LAN and WAN alike, and the - stages publish and subscribe on the cluster's origin (the one - [cluster construction](/quest/m1/cluster-construction.md) builds once). - Delete `rs/moq-cli/src/cluster.rs`. + stages publish and subscribe on the cluster's origin (built once at + construction). Delete `rs/moq-cli/src/cluster.rs`. - Advertise: the listener's fingerprint whenever its certificate was generated, the node URL when one is configured, and at least one of them (the `UnboundSecret` rule). A relay without `--cluster-node` advertises its @@ -63,5 +62,4 @@ Branch from `dev`. ## Related -- [Cluster construction](/quest/m1/cluster-construction.md) - the origin the CLI's stages attach to - [`moq relay`](/quest/m2/moq-relay-subcommand.md) - the second place the CLI hosts the relay library From ac840c12e7cdcf835a3138f00a58fc07c4c8c2a6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 11 Sep 2026 23:09:32 -0700 Subject: [PATCH 4/4] fix(relay): match StatsConfig.enabled as a bool #3587 made stats.enabled a bool, so the construction-time cache test cannot wrap it in Some. Co-Authored-By: grok-4.6 --- rs/moq-relay/src/cluster.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index bf9ce67701..f8a38341b6 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -2178,7 +2178,7 @@ mod tests { assert_eq!(origin.info().pool.expiry(), Some(duration)); let stats = crate::StatsConfig { - enabled: Some(true), + enabled: true, node: Some("test".to_string()), ..Default::default() }