diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 0e6d525cf5b..5623422e6a5 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -691,9 +691,10 @@ void main() { final pending = origin.consume().request(path: 'live/cam'); final request = (await dynamic.requested().timeout(timeout))!; expect(request.path(), 'live/cam'); - // A hidden job route answers nobody until it announces. + // A hidden job route answers nobody until it announces, under the instance its + // prefix route lends. final served = request.route(options: RouteOptions(hidden: true)); - expect(served.instanceId(), isNot(dynamic.instanceId())); + expect(served.instanceId(), dynamic.instanceId()); served.announce(); await pending.timeout(timeout); // Closing the job's broadcast ends its route. diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 290b21c71fd..d52e3abe28e 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -24509,10 +24509,10 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_reject() != 9727) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_route() != 16046) { + if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_route() != 8265) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_serve() != 2580) { + if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_serve() != 10832) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqfollowing_available() != 26337) { @@ -24548,7 +24548,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqroute_cancel() != 41411) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqroute_instance_id() != 18728) { + if (uniffi_moq_ffi_checksum_method_moqroute_instance_id() != 57871) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqroute_requested() != 49558) { diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index a08f00c2984..6810a5879fa 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -49,9 +49,11 @@ 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 +order](/concept/identity#choosing-a-route), ending in a hash of the path with +the route's origin node on moq-lite 07, or with the instance on older wires, so +the relays of a mesh agree on each path's stream and a pool's paths spread over +its hosts. On moq-lite 07 a path therefore stays on its host when the instance +changes. 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 diff --git a/doc/concept/identity.md b/doc/concept/identity.md index 5b2ac5021cb..49df5551284 100644 --- a/doc/concept/identity.md +++ b/doc/concept/identity.md @@ -61,7 +61,7 @@ one is, so it fences out an older run. Nothing assumes it is a clock. | 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) | +| A pool of hosts producing paths on demand | One per host, carried by its jobs; a fresh one per restart | See [work on demand](#work-on-demand) | ## Choosing a route @@ -73,9 +73,11 @@ A route resolving a path is chosen in this order: 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 + this origin, then the shortest hop chain, then a hash of the full requested + path with the route's origin node, which moq-lite 07 carries, or else with + the instance. Relays agree on the hash only where routes carry a consistent + origin node, which takes moq-lite 07 on every hop, 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. @@ -94,7 +96,7 @@ 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. + session, is a new stream, never spliced to the earlier one. - 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. @@ -161,7 +163,7 @@ 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. +demand, whose instance changes each time its job restarts. ## Work on demand @@ -175,12 +177,12 @@ the prefix route without a restart. A job that may restart independently, such as a transcoder claiming `transcode`, instead creates each requested path's own exact route. 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. +announced at its exact path. ```rust let claim = origin.route_prefix("transcode", RouteOptions::default())?; while let Some(request) = claim.requested().await { - // A fresh instance for this job, never one shared with another path or run. + // Takes the claim's instance, so readers resolved through the claim continue. // Hidden until its tracks exist. let job = request.route(RouteOptions::hidden())?; let video = job.broadcast().create_track("video", info.clone())?; @@ -193,33 +195,36 @@ while let Some(request) = claim.requested().await { JavaScript mirrors this with `request.route({ hidden: true })` and `job.announce()`; see [`@moq/net`](/lib/js/net) and [`moq-net`](/lib/rs/moq-net). A hidden job route is unannounced until it announces, and dropping it before -then refuses the request. Ending a job's route withdraws only its path: the +then refuses the request. It takes the claim's instance when it announces, or +earlier when its instance is first read, as a signer built from it does. Read +first, the job announces under it or not at all: if another job under the claim +ends in between, moving the claim to a fresh instance, `announce` fails with +`Unroutable` and the request reaches the worker again. Ending a job's route withdraws its path, and the prefix route keeps answering later requests. 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 - 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 instance - A. A Rust follower is handed nothing new (a JavaScript follower still moves - to a new broadcast). 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 is handed the new broadcast, 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 drops job A's route. The path falls back - to the claim, the next request asks the worker again, and job B publishes - under a fresh instance. Followers are handed each new broadcast; 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. +1. **Request.** The relay resolves the path through the claim, under the + claim's instance, and forwards the request. The worker starts a job, whose + route takes the claim's instance and publishes `transcode/alice` under it. +2. **Continue.** The job's exact route carries the instance the viewer + resolved, so on moq-lite 07 every relay keeps the viewer on it: it keeps + playing, and a Rust or JavaScript follower is handed nothing new. Older + wires mint an instance per announcement, so there the first subscription + through the claim is reset: a follower is handed the new broadcast, and a + holder's track ends with an error, and it requests again. The same happens + on any wire under a claim that names a stable instance, whose jobs each take + a fresh one, since a restarted job could not keep that promise. +3. **Steady state.** Requests resolve the job by its exact route. Routes + carrying its instance through other relays fail over without a seam. +4. **Job restart.** The worker drops the job's route, and the claim moves to a + fresh instance at once, so readers see a restart. The next request starts a + new job under the claim's new instance, a new stream; a request pinned to + the old instance is refused as unroutable. Relays drop the old job's cached + groups, so a viewer returning within the cache window never gets them. + Other running jobs, such as `transcode/bob`, keep their instance, while + paths the claim answered with a held broadcast resolve afresh under its new + one. 5. **Done.** Closing the last job leaves only the claim, which serves the next request as in step 1. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index a926879de9a..9ca3a321f26 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -111,13 +111,13 @@ 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. + path by announcing that exact path under the claim's instance, so on + moq-lite 07 a viewer resolved through the claim keeps playing (a claim that + names a stable instance gives each output a fresh one instead). When that + output ends, the claim takes a fresh instance, so a restarted output is a + new stream every hop sees, and a viewer returning within a relay's cache + window never gets the old output's groups. A different worker has its own + instance, so it 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 @@ -138,19 +138,17 @@ hidden names work exactly as before. 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. +The instance, the epoch, and the restart message travel on moq-lite 07, which +is opt-in. `ANNOUNCE_START` and `ANNOUNCE_RESTART` 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 +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, and the first +subscription through a claim is reset once the output's route arrives. 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 diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index fc6690ca6c5..af9df1869d8 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -82,9 +82,9 @@ if (moq_route_announce((uint32_t)route) < 0) The route keeps the broadcast announced: `moq_route_free()` retracts it, and `moq_publish_close()` ends both. A route carries only a cost and the stream's instance; -relays add the hops. Each route mints a fresh instance (`moq_route_instance()`) and keeps it -across `moq_route_withdraw()` and `moq_route_announce()`, so a restarted publisher, being a -new route, is a new stream that `moq_origin_follow` hands viewers instead of a stalled +relays add the hops. `moq_origin_route()` mints a fresh instance (`moq_route_instance()`) and +keeps it across `moq_route_withdraw()` and `moq_route_announce()`, so a restarted publisher, +being a new route, is a new stream that `moq_origin_follow` hands viewers instead of a stalled broadcast; the path stays `MOQ_ANNOUNCE_KIND_PRESENT` throughout. The follow callback reports each instance as `MOQ_FOLLOW_EVENT_ONLINE` with its handle, `MOQ_FOLLOW_EVENT_OFFLINE` when the path goes offline, and one `MOQ_FOLLOW_EVENT_END`. Set `instance` only for a publisher that continues its group sequences @@ -92,7 +92,11 @@ across restarts, and raise `epoch` on each: readers stitch every route of one in together, so restarting the sequences under an old instance splices unrelated groups into their tracks. To serve paths on demand, `moq_origin_route_prefix()` delivers each request to answer with `moq_broadcast_request_serve()`, `moq_broadcast_request_route()`, or -`moq_broadcast_request_reject()`. +`moq_broadcast_request_reject()`. Unless one is named, a prefix route moves to a fresh +instance each time a job holding its current one ends, and a job from +`moq_broadcast_request_route()` takes the prefix route's, so on moq-lite 07 a reader resolved through the +prefix route does not restart; older wires mint another instance for the job's route, restarting it. The string `moq_route_instance()` writes is borrowed until the +next `moq_route_instance()` on that route or `moq_route_free()`. 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. For pre-encoded video, publish from the lowest rendition up and set `moq_video_init.lower` to the next lower media handle. Reserve each track with `moq_allocator_reserve_media`, then bind its reservation with `moq_rate_control_create` and call `moq_rate_control_update` on every frame. Apply changed targets to your encoder and `moq_publish_media_set_enabled`, resuming video on a keyframe; close the control and reservation separately when done. diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index abfc1fe159c..9a122a3a087 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -149,7 +149,7 @@ one to one; the API reference has the names, and [Layers](/lib/layers) shows which module holds each. - **Names.** Types drop the `Moq` prefix (`Session`, `BroadcastProducer`, `RouteOptions`) as aliases, so the generated names still work. `MoqContainer`, `MoqRoute`, and the exceptions keep it, since Flutter owns those names. -- **Routes.** `route(path)` publishes a broadcast and `routePrefix(prefix)` answers requests beneath a prefix, each returning a `MoqRoute`. Hold it: cancelling or disposing it retracts the route, while a broadcast handle alone does not keep it announced. It keeps its instance across `withdraw()` and `announce()`, so a new run is a new route. +- **Routes.** `route(path)` publishes a broadcast and `routePrefix(prefix)` answers requests beneath a prefix, each returning a `MoqRoute`. Hold it: cancelling or disposing it retracts the route, while a broadcast handle alone does not keep it announced. A `route(path)` keeps its instance across `withdraw()` and `announce()`, so a new run is a new route. Unless one is named, a request's own route (`request.route(options: ...)`) takes its prefix route's instance (on moq-lite 07; older wires mint another, restarting readers resolved through the prefix route), and the prefix route moves to a fresh one each time such a route holding its current one ends. - **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `cut()`: after every frame, or less often to batch frames into fewer groups. - **Timeline breaks.** On a seek or pause, call `discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. - **Media reads.** `media.TrackConsumer`, `audio.Consumer`, and `video.Consumer` return a `Read` in order: a `ReadFrame`, a `ReadGap` where media was lost, or a `ReadEnd` where the publisher stopped its encoder. A gap breaks continuity. An encoder end is not stream EOF: following frames before its endpoint are the old encoder's drain; the first at or past it starts fresh. A decoder reads everything the track still holds unless a live player sets `start: media.DecodeStart.latest` in its decoder output, which joins at the newest group and skips the cached backlog. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 6bcd7032472..faf1519921b 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -39,9 +39,9 @@ route.announce(); ``` - **The origin holds the routes, not the connection.** `origin.route(path)` creates a route that owns a new broadcast; fill its tracks through `route.broadcast`. Closing a session withdraws its announcements and leaves the routes for the next one. A route is visible at once unless created `{ hidden: true }`, which keeps it invisible, locally and remotely, until `announce()`. `withdraw()` hides it again and `announce()` brings the same stream back. `close()` retracts it and closes its broadcast, as closing the broadcast does. -- **Answer claimed paths with a route.** `origin.routePrefix(prefix)` claims a prefix, and `route.requested()` yields each requested path. Answer with `request.route()`, a new exact route under a fresh instance, or `request.route({ hidden: true })` to fill its broadcast before `announce()` answers the requesters; closing it or its broadcast before then refuses them as unroutable, so `using job = request.route({ hidden: true })` refuses on a throw. Serve a broadcast you already hold with `request.serve(broadcast)`, or refuse with `request.reject(err)`. +- **Answer claimed paths with a route.** `origin.routePrefix(prefix)` claims a prefix, and `route.requested()` yields each requested path. Answer with `request.route()`, a new exact route under the claim's instance (a fresh one if the claim names its own), so on `moq-lite-07-wip` readers resolved through the claim continue (older wires mint another instance for it, restarting their subscription), or `request.route({ hidden: true })` to fill its broadcast before `announce()` answers the requesters; closing it or its broadcast before then refuses them as unroutable, so `using job = request.route({ hidden: true })` refuses on a throw. Serve a broadcast you already hold with `request.serve(broadcast)`, or refuse with `request.reject(err)`. - **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 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 route mints a random instance when it is created and keeps it across `withdraw()` and `announce()`; pass `origin.route(path, { instance, epoch })` only for a publisher that continues its group sequences across restarts and raises the epoch each time. +- **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. `origin.route(path)` mints a random instance when it is created and keeps it across `withdraw()` and `announce()`. Unless one is named, a claim moves to a fresh instance each time a job holding its current one ends, and a job takes the claim's. Pass `origin.route(path, { 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. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index ed1976326d3..2af4f414756 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -17,7 +17,7 @@ What you use it for, beyond what the concept page already describes: - **An origin outlives the session.** `origin.route(path, options)` returns a `Route` that owns the broadcast to fill (`route.broadcast()`) and controls its announcement: `announce()`, `withdraw()`, `set_cost()`, and dropping the last clone retracts it. `origin.route_prefix(prefix, options)` serves requests beneath a prefix instead, each answered with `request.serve(held)`, `request.route(options)` (the path's own exact route) or `reject`. A standalone broadcast cannot announce; publish one only through a prefix route's `serve`. A reconnect announces existing routes again, and closing the session does not delete them. - **Routes report what the mesh filled in.** An announcement's `route` gives its `instance()`, `cost()`, the hop ids it crossed (`hops()`, oldest first, 0 for an anonymous hop) and `is_anonymous()`; an application sets only `RouteOptions`. An origin's own hop id is `origin::Config::hop`, random unless a relay names one. -- **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 route serves a random instance minted when it is created and kept across `withdraw()` and `announce()`; set one with `RouteOptions::with_instance(instance, epoch)` only for a publisher that continues its group sequences across restarts and raises the epoch each time. `route.instance()` names it. +- **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. `origin.route(path, options)` serves a random instance minted when it is created and kept across `withdraw()` and `announce()`. Unless one is named, a prefix route moves to a fresh instance each time a job holding its current one ends, and a job takes the prefix route's. Set one with `RouteOptions::with_instance(instance, epoch)` only for a publisher that continues its group sequences across restarts and raises the epoch each time. `route.instance()` names it. - **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, `None` once the path goes offline, and whatever serves it next, like JS `follow(path)`. `consumer.announced()` lists paths only as present or absent. 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-webrtc 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. diff --git a/drafts/draft-lcurley-moq-cluster.md b/drafts/draft-lcurley-moq-cluster.md index ee1790eaf8a..89f35ca8d3e 100644 --- a/drafts/draft-lcurley-moq-cluster.md +++ b/drafts/draft-lcurley-moq-cluster.md @@ -277,7 +277,7 @@ Its edge relay then advertises the covering namespace as it would forward that P SUBSCRIBE_TRACKS reaches it through that advertisement. The request stays open ({{moqt}} Section 3.6), so a namespace advertised later is covered when it is advertised. -A receiver identifies a track by its namespace, its name, and, when MoQ Epoch is negotiated, its EPOCH. +A receiver identifies a track by its namespace, its name, and, when MoQ Epoch is negotiated, its instance. It serves each demand for the track's objects, such as a downstream subscription, from the source Path Selection prefers among those eligible for that demand ({{selection}}), and resumes that source by sending REQUEST_UPDATE with Forward set to 1 on its subscription ({{moqt}} Section 9.5); once a source serves no demand, the receiver pauses it again with REQUEST_UPDATE setting Forward to 0. Demands usually share one source, but a downstream peer that the preferred path runs through is served from another, so several sources can be active at once. A later PUBLISH for the track that Path Selection prefers takes over the same way, but only where {{publishers}} allows moving a subscription between the two: the receiver resumes it for the demand it now serves and pauses a source left serving none. @@ -291,8 +291,7 @@ Under this extension an advertisement is a path, so a session advertises a names A namespace names one broadcast whichever publisher serves it, so several publishers of it are redundant copies: their groups are one sequence, and a subscription moves between them without a gap. A publisher MUST NOT reuse a namespace for different content, and publishes a new instance under a new namespace instead. -A session that negotiated MoQ Epoch names the instance instead: the namespace and EPOCH together name the content, so only advertisements with the same EPOCH are redundant copies, and a publisher MAY reuse the namespace for a new instance under a new EPOCH. -A receiver MUST NOT move a subscription between advertisements with different EPOCHs, or between advertisements without one. +A session that negotiated MoQ Epoch names the instance instead, so only advertisements of the same instance are redundant copies, and a publisher MAY reuse the namespace for a new instance (see draft-lcurley-moq-epoch). A refusal never moves to another publisher ({{selection}}). A relay that moves to another publisher MUST update its advertisement to the new path ({{updating}}). @@ -344,7 +343,7 @@ The Key-Value-Pair parity is load-bearing: HOP_PATH is odd, so its value is a le ## moq-cluster-02 - Hop IDs span 64 bits; a link whose integer encoding cannot carry one sends 0 in its place. -- Exactly one NAMESPACE parameter block is appended when any extension that uses it is negotiated, holding the parameters of all of them. Under MoQ Epoch, only advertisements with the same EPOCH are redundant copies, and an update never changes the instance. +- Exactly one NAMESPACE parameter block is appended when any extension that uses it is negotiated, holding the parameters of all of them. Under MoQ Epoch, only advertisements of the same instance are redundant copies, and an update never changes the instance. - A namespace names one broadcast whichever publisher serves it, and a publisher MUST NOT reuse one for different content. A receiver MAY fail a subscription over between any of its advertisements, continuing from the first object the subscriber lacks instead of at a group boundary. Replaces the first-Hop-ID publisher identity. - Defined request resolution against the longest covering prefix; every refusal is terminal, including between several publishers of one namespace, and capacity is signaled only by withdrawing or re-pricing the advertisement. - A relay does not advertise a namespace because it resolved it; the publisher advertises the concrete namespace once producing. diff --git a/drafts/draft-lcurley-moq-epoch.md b/drafts/draft-lcurley-moq-epoch.md index e33d6364316..afcc0b1b484 100644 --- a/drafts/draft-lcurley-moq-epoch.md +++ b/drafts/draft-lcurley-moq-epoch.md @@ -27,7 +27,7 @@ informative: --- abstract This document defines an extension for MoQ Transport {{moqt}} that names the publisher instance behind a namespace. -Peers that negotiate it carry an EPOCH, a UUIDv7 the publisher mints when it starts, on namespace advertisements and on the requests that resolve through them. +Peers that negotiate it carry an EPOCH, a UUIDv7 the publisher mints for each instance, on namespace advertisements and on the requests that resolve through them. A relay can then move a subscription between redundant paths of one instance, and refuse to serve a request from a replacement instance that reused the same names. --- note_Note_to_Readers @@ -42,7 +42,7 @@ Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github. An endpoint **advertises** a namespace by sending PUBLISH_NAMESPACE, or NAMESPACE in response to a SUBSCRIBE_NAMESPACE. -An **instance** is one run of a publisher of a namespace: the content it produces between starting and stopping. +An **instance** is one continuous sequence space of a namespace: its group sequences never restart and never name two different groups. A namespace and an EPOCH name one instance. @@ -51,7 +51,8 @@ A namespace and an EPOCH name one instance. A publisher that restarts and counts its groups from zero again reuses names for different content, and a relay holding both runs cannot tell them apart. A relay with several paths to one namespace also cannot tell whether they serve the same bytes, so it cannot move a subscription between them without risking a gap or a mix of two runs. -This extension carries the instance identity that {{moql}} carries in its Epoch field, without changing any name. +This extension names the instance as {{moql}}'s Instance does, without changing any name. +{{moql}}'s Epoch, which orders the incarnations of one Instance, has no counterpart: a publisher here never keeps an instance across a restart. The namespace a subscriber asks for is the same with or without it. @@ -97,11 +98,11 @@ Epochs compare as their 16 bytes, which for a UUIDv7 orders them by creation tim # Identity {#identity} -A publisher mints a new Epoch each time it starts an instance, and MUST NOT serve different content under one namespace and Epoch. -Publishers serving identical content, such as replicas of one encoder, MAY share an Epoch. +A publisher mints a new EPOCH each time it starts an instance, such as a restart that counts its groups from zero again, and MUST NOT serve different content under one namespace and EPOCH. +Publishers serving identical content, such as replicas of one encoder, MAY share an EPOCH. Two advertisements of a namespace with the same EPOCH serve the same instance: a receiver MAY move a subscription between them, continuing from the first object the subscriber lacks. -A receiver MUST NOT move a subscription between advertisements with different EPOCHs, or between advertisements without one: it stays on its path and ends with it. +A receiver MUST NOT move a subscription between advertisements of different instances, or between advertisements without EPOCH: it stays on its path and ends with it. An advertisement without EPOCH names no instance, so nothing says another advertisement serves the same bytes. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 5d9fbb922e4..fb445513146 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -413,7 +413,6 @@ The publisher replies with a single ANNOUNCE_OK message followed by the matching - ANNOUNCE_END: the origin withdrew a claim. - ANNOUNCE_UPDATE: a claim's cost changed. - ANNOUNCE_RESTART: a claim now serves another Instance, or a higher Epoch of the same one. -- ANNOUNCE_TAKEOVER: a matching claim continues what a live claim served beneath it. - ANNOUNCE_FLOOR: a batch of a route's claims is complete. - SYNCED: the initial set is complete. @@ -425,16 +424,16 @@ The initial set is relative to this hop: a relay answers with what it holds with The publisher MAY coalesce changes to a claim it has not yet sent, and sends no more claims before SYNCED than the initial set holds. A retraction of a claim or route already sent MAY precede SYNCED. -Each ROUTE_START implicitly assigns the next Route ID on the stream, and each ANNOUNCE_START or ANNOUNCE_TAKEOVER the next Announce ID: two counters starting at 0 that increment by 1 per assignment. +Each ROUTE_START implicitly assigns the next Route ID on the stream, and each ANNOUNCE_START the next Announce ID: two counters starting at 0 that increment by 1 per assignment. Neither id appears on the wire where it is assigned; both endpoints derive it from the message order on the (reliable, ordered) stream, and other messages reference it instead of repeating the origin node or the claim's prefix. Ids are never reused within a stream. A ROUTE_END retires its Route ID and the Announce ID of every claim under the route; an ANNOUNCE_END retires one Announce ID. A stream has at most one live route per origin node, and each route at most one live claim per prefix. Another origin claiming the same prefix is another claim, under its own route. -An ANNOUNCE_UPDATE or ANNOUNCE_RESTART atomically replaces a claim while keeping its id live, and an ANNOUNCE_TAKEOVER at its predecessor's own prefix replaces it under a new id; a claim never moves to another route. +An ANNOUNCE_UPDATE or ANNOUNCE_RESTART atomically replaces a claim while keeping its id live; a claim never moves to another route. -The subscriber MUST close the session with a PROTOCOL_VIOLATION if it receives a message referencing a Route ID or Announce ID that was never assigned or already retired, a ROUTE_START for an origin node that already has a live route on the stream, an ANNOUNCE_START or ANNOUNCE_TAKEOVER for a prefix its route already claims other than as its predecessor, or any message before ANNOUNCE_OK. +The subscriber MUST close the session with a PROTOCOL_VIOLATION if it receives a message referencing a Route ID or Announce ID that was never assigned or already retired, a ROUTE_START for an origin node that already has a live route on the stream, an ANNOUNCE_START for a prefix its route already claims, or any message before ANNOUNCE_OK. When the stream is closed, the subscriber MUST assume that every route and claim on it is gone. A broadcast path has at most 32 segments and at most 4,096 bytes; a receiver MUST close the session with a PROTOCOL_VIOLATION on a longer one, including one resolved by [Compression](#announce-compression). @@ -459,9 +458,9 @@ A floor that certifies nothing proves no claim absent: the stream's claims stay While a receiver has a usable route to the origin and every peer whose last advertised route to it is feasible here has answered the current request, it lets go of a claim none of those peers certifies or carries: it goes offline, which is not an ANNOUNCE_END. #### Compression {#announce-compression} -An ANNOUNCE_START or ANNOUNCE_TAKEOVER MAY copy the head of its path from a live claim on the same stream, its base. -`Path Base` names it by distance: the base's Announce ID is the next unassigned Announce ID minus `Path Base`, so 1 names the latest ANNOUNCE_START or ANNOUNCE_TAKEOVER, and 0 names no base. -The receiver resolves the base against the claims live when the message arrives, and the result is identical to the literal encoding: a base ending later changes nothing, including a predecessor that the ANNOUNCE_TAKEOVER naming it as a base retires. +An ANNOUNCE_START MAY copy the head of its path from a live claim on the same stream, its base. +`Path Base` names it by distance: the base's Announce ID is the next unassigned Announce ID minus `Path Base`, so 1 names the latest ANNOUNCE_START, and 0 names no base. +The receiver resolves the base against the claims live when the message arrives, and the result is identical to the literal encoding: a base ending later changes nothing. The base need not share the new claim's route. A publisher MAY always send a `Path Base` of 0. @@ -524,7 +523,6 @@ Claims with the same Instance serve the same Broadcast, so a relay MAY move a li A relay MUST NOT move a subscription to a claim with a different Instance: it stays on its Instance's claims 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 claim 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 claim. -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 claim serves, so those subscriptions continue on the new claim and new requests MAY join them, whatever either claim'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 claim'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. @@ -545,6 +543,7 @@ When choosing which claim to advertise for a prefix, the requested path is the p A claim resolved this way serves the request like any other, and the claim is not consumed by it. A relay MUST NOT announce a path merely because it resolved it: the covering claim stays the only advertisement until the advertiser announces the concrete path, which it SHOULD do once it is producing, so a later request finds the running broadcast by its exact path instead of resolving a second producer. +The advertiser MAY announce the path under the claim's Instance, so subscriptions resolved through the claim continue on it (see [Routing](#routing)); when that announcement ends, a claim still serving its Instance restarts under a new one, so a restarted answer is another Broadcast. An advertiser that will not serve a resolved request resets the request stream with a typed code (see [Error Codes](#error-codes)). Every refusal is terminal and propagates without re-resolution, so probing unserved paths costs one round trip per path. @@ -1116,7 +1115,7 @@ An Announce Stream ending before SYNCED is a PROTOCOL_VIOLATION; a Tracks Stream ## ROUTE_START {#route-start} A publisher sends a ROUTE_START message when it reaches an origin node (see [Routing](#routing)). -Each ROUTE_START implicitly assigns the next Route ID on the stream, later referenced by ROUTE_UPDATE, ROUTE_HOLD, ROUTE_END, ANNOUNCE_FLOOR, ANNOUNCE_START, and ANNOUNCE_TAKEOVER (see [Announce](#announce)). +Each ROUTE_START implicitly assigns the next Route ID on the stream, later referenced by ROUTE_UPDATE, ROUTE_HOLD, ROUTE_END, ANNOUNCE_FLOOR, and ANNOUNCE_START (see [Announce](#announce)). ~~~ ROUTE_START Message { @@ -1244,7 +1243,7 @@ A publisher MUST close the session with a PROTOCOL_VIOLATION if it receives any ## ANNOUNCE_START {#announce-start} A publisher sends an ANNOUNCE_START message to advertise a claim of a route's origin node: that paths under a prefix can be served. -Each ANNOUNCE_START implicitly assigns the next Announce ID on the stream, later referenced by ANNOUNCE_END, ANNOUNCE_UPDATE, ANNOUNCE_RESTART, and ANNOUNCE_TAKEOVER (see [Announce](#announce)). +Each ANNOUNCE_START implicitly assigns the next Announce ID on the stream, later referenced by ANNOUNCE_END, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART (see [Announce](#announce)). Only the suffix is encoded on the wire, as the full claim prefix can be constructed by prepending the requested prefix. @@ -1285,7 +1284,7 @@ A relay forwards the Instance unchanged. Which incarnation of the Instance the claim 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. +Only an ANNOUNCE_RESTART changes the Instance or the Epoch; ANNOUNCE_UPDATE never does. **Announce Cost**: The origin's price for this prefix, in the units of the [Cost Parameter](#cost-parameter): 0 for content it already produces, larger for a standby that starts work on demand. @@ -1311,7 +1310,7 @@ ANNOUNCE_END Message { Set to 0x1 to indicate an ANNOUNCE_END message. **Announce ID**: -The ordinal implicitly assigned by a prior ANNOUNCE_START or ANNOUNCE_TAKEOVER on this stream. +The ordinal implicitly assigned by a prior ANNOUNCE_START on this stream. A claim that is announced again after an ANNOUNCE_END gets a fresh id from its next ANNOUNCE_START. @@ -1365,44 +1364,6 @@ As defined for [ANNOUNCE_END](#announce-end). **Instance**, **Epoch**, and **Announce Cost**: As defined for [ANNOUNCE_START](#announce-start), replacing the claim's. -## ANNOUNCE_TAKEOVER {#announce-takeover} -A publisher sends an ANNOUNCE_TAKEOVER message to advertise a claim that continues what a live claim, its predecessor, served beneath the new claim's prefix. -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 claim. -It is an ANNOUNCE_START in every other respect and assigns the next Announce ID. - -~~~ -ANNOUNCE_TAKEOVER Message { - Type (i) = 0x5 - Message Length (i) - Predecessor (i), - Path Base (i), - Path Keep (i), - Claim Prefix Suffix (s), - Route ID (i), - Instance (b), - Epoch (i), - Announce Cost (i), -} -~~~ - -**Type**: -Set to 0x5 to indicate an ANNOUNCE_TAKEOVER message. - -**Predecessor**: -The Announce ID of a live claim under the same route whose prefix covers the new claim's prefix. -One at the predecessor's own prefix retires the predecessor's id, which this claim 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, is under another route, or does not cover the new prefix. - -**Path Base**, **Path Keep**, **Claim Prefix Suffix**, **Route ID**, **Instance**, **Epoch**, and **Announce 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 claim: subscriptions on it continue, and a new request for those paths MAY join them. -Only what the predecessor's current claim resolved continues; anything resolved before an earlier ANNOUNCE_RESTART of it does not. - -A publisher MUST send an ANNOUNCE_TAKEOVER only when the new claim 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 claim the relay sent at its prefix serves the same Instance; otherwise it sends an ANNOUNCE_START, or an ANNOUNCE_RESTART at the predecessor's own prefix. - - ## ANNOUNCE_FLOOR {#announce-floor} A publisher sends an ANNOUNCE_FLOOR message to close a batch of a route's claims (see [Floors](#floors)). @@ -2225,10 +2186,10 @@ The `Message Length` describes the payload size on the wire. - Replaced ANNOUNCE_OK Active Count with an ordered, empty SYNCED event (0x4), marking the initial set without retaining claims that end before delivery. - 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 claims with the same Instance, continuing from the first frame the subscriber lacks instead of at a group boundary, and never between claims 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 claim that continues what a live claim 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. +- An advertiser MAY announce a path it answers through a claim under the claim's Instance, so subscriptions resolved through the claim continue, and a claim still serving that Instance when the announcement ends restarts under a new one. - A receiver mints an Instance, starting with `~`, for each announcement a session of an earlier version delivers. -- Added announce compression: ANNOUNCE_START and ANNOUNCE_TAKEOVER gain `Path Base` and `Path Keep` to copy the head of a live claim's suffix. -- Split routing into two layers on the Announce Stream. ROUTE_START (0x6), ROUTE_UPDATE (0x7), and ROUTE_END (0x8) advertise how the publisher reaches an origin node, named by a Node ID, with a seqno, an additive metric, and a down-only bit, and loops are prevented by Babel's feasibility condition. Every ANNOUNCE_START and ANNOUNCE_TAKEOVER names its route's Route ID in place of the Hop ID list, so a link change sends one ROUTE_UPDATE per origin rather than an update per claim. Removed the Hop ID list, ANNOUNCE_OK's Hop ID, and the SETUP Hop parameter (0x5). +- Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live claim's suffix. +- Split routing into two layers on the Announce Stream. ROUTE_START (0x6), ROUTE_UPDATE (0x7), and ROUTE_END (0x8) advertise how the publisher reaches an origin node, named by a Node ID, with a seqno, an additive metric, and a down-only bit, and loops are prevented by Babel's feasibility condition. Every ANNOUNCE_START names its route's Route ID in place of the Hop ID list, so a link change sends one ROUTE_UPDATE per origin rather than an update per claim. Removed the Hop ID list, ANNOUNCE_OK's Hop ID, and the SETUP Hop parameter (0x5). - Added ROUTE_HOLD (0xB), which holds a route while it recovers; ROUTE_END lets it go. Neither withdraws the origin's claims, and the subscriber echoes every ROUTE_END back. - Added ROUTE_REQUEST (0xA), sent by the subscriber on the Announce Stream to ask for a route with a newer seqno when every route it has is infeasible. - Added ANNOUNCE_FLOOR (0x9), closing each batch of a route's claims with the origin Revision at which the stream's claims across the requested prefix are certified plus one, or 0 to certify nothing, so a stale copy never revives a claim whose withdrawal a newer one delivered. A floor certifies only the claims its own request shows. diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index 99063943ee5..6bbe426013c 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -98,8 +98,8 @@ func TestDynamicBroadcastRequest(t *testing.T) { t.Fatal(err) } defer runtime.KeepAlive(served) - if served.Instance() == dynamic.Instance() { - t.Fatal("a job's route serves a fresh instance") + if served.Instance() != dynamic.Instance() { + t.Fatal("a job's route continues the instance its prefix route lends") } broadcast, err := served.Broadcast() if err != nil { diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index c3d65e0954b..55021fc10a0 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -95,8 +95,12 @@ func (r *Route) Broadcast() (*BroadcastProducer, error) { return r.broadcast, nil } -// Instance returns the stream this route serves, minted at creation unless -// [RouteOptions] named one. +// Instance returns the stream this route serves now. A named instance never +// changes. Unnamed, a prefix route moves to a fresh one each time a job holding +// its current one ends. A job's route takes its prefix route's current one when +// it first announces or when this first reads it. Read first, the job announces +// under it, and [Route.Announce] fails with Unroutable once the prefix route has +// moved past it, so a signer built from it stays valid. func (r *Route) Instance() string { return r.inner.InstanceId() } @@ -162,8 +166,12 @@ func (r *BroadcastRequest) Path() (string, error) { // Route answers with the path's own exact route, owning a new broadcast. // -// It serves a fresh instance unless options names one. Created visible, it -// announces now and answers the waiting requesters; created hidden, they wait +// Unless options names an instance, it takes the one its prefix route lends +// when it first announces or [Route.Instance] first reads it, so readers that resolved the path through the prefix +// route continue on it; its end then moves the prefix route to a fresh instance, +// so a restarted job is a new stream. A prefix route that names its instance +// lends none, and the route mints its own. Created visible, it announces now and answers +// the waiting requesters; created hidden, they wait // until [Route.Announce], so the job can fill its catalog first. func (r *BroadcastRequest) Route(options RouteOptions) (*Route, error) { inner, err := r.inner.Route(options) diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 3985a65e4ed..5c37972039c 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -2698,7 +2698,8 @@ test.each([Lite.ALPN_06, Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_2 }, ); -test.each([Lite.ALPN_06, Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_22])( +// Without an instance on the wire nothing says the job's route continues the claim's stream. +test.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_22])( "publishing a job still resets its first subscribe on %s", async (protocol) => { const pair = createMockTransportPair(protocol); @@ -2735,6 +2736,58 @@ test.each([Lite.ALPN_06, Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_2 }, ); +// On lite-07 a job's exact route continues its claim's minted instance, so a reader resolved +// through the claim keeps its broadcast and its subscription once that route lands. +test.each(["request", "follow"] as const)( + "a job's route continues a reader resolved through its claim (%s)", + async (method) => { + const pair = createMockTransportPair(Lite.ALPN_07_WIP); + const origin = new OriginProducer(); + const consumer = new OriginProducer(); + const path = Path.from("live/cam"); + const handle = origin.routePrefix(Path.empty()); + const serving = (async () => { + const requests = handle.requested(); + const request = (await requests.next()).value; + if (!request) throw new Error("missing job request"); + const job = request.route({ hidden: true }); + const chat = job.broadcast.createTrack("chat", { timescale: Timescale.MILLI }); + chat.writeFrame(textFrame("job")); + job.announce(); + return { job, chat }; + })(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client, consume: consumer }), + accept(pair.server, url, { publish: origin.consume() }), + ]); + const listing = consumer.announced(Path.Pattern.literal(path)); + const requested = method === "request" ? consumer.request(path) : consumer.follow(path); + const remote = await waitFor(requested.active, (broadcast) => broadcast !== undefined); + if (!remote) throw new Error("request did not resolve"); + const track = remote.track("chat").subscribe().ordered(); + expect(await track.readString()).toBe("job"); + + // Written once the job's route is listed at the reader: the same subscription carries it. + const { job, chat } = await serving; + expect(job.instance).toBe(handle.instance); + expect(await listing.next()).toMatchObject({ kind: "present", prefix: path }); + chat.writeFrame(textFrame("later")); + expect(await track.readString()).toBe("later"); + expect(requested.active.peek()).toBe(remote); + expect(requested.closed.peek()).toBeUndefined(); + + track.close(); + listing.close(); + requested.close(); + handle.close(); + job.close(); + client.abort(); + server.abort(); + origin.close(); + consumer.close(); + }, +); + // The peer's close code a killed session carries. const DEATH = SessionCode(71); diff --git a/js/net/src/lite/announce.test.ts b/js/net/src/lite/announce.test.ts index 90256dbf0d8..82e45f79ab1 100644 --- a/js/net/src/lite/announce.test.ts +++ b/js/net/src/lite/announce.test.ts @@ -100,35 +100,6 @@ test("AnnounceBroadcast carries a restart only on draft-07", async () => { expect(await decodeAnnounceBroadcast(reader, Version.DRAFT_06)).toEqual({ status: "skipped" }); }); -test("AnnounceBroadcast carries a takeover only on draft-07", async () => { - const hops = [HopSchema.parse(7n)]; - const msg = { - status: "takeover" as const, - predecessor: 2n, - suffix: Path.from("p"), - pathBase: { distance: 1n, keep: 1 }, - instance: Instance.random(), - epoch: 3n, - hops, - hopBase: undefined, - cost: 12n, - }; - expect(await roundTrip(msg, Version.DRAFT_07)).toEqual(msg); - - // Older versions send a start instead. - await expect( - bytes( - (w) => - encodeAnnounceBroadcast( - w, - { status: "takeover", predecessor: 0n, suffix: Path.from("p"), hops: [] }, - Version.DRAFT_06, - ), - Version.DRAFT_06, - ), - ).rejects.toThrow(); -}); - test("AnnounceBroadcast skips an unknown type on draft-06", async () => { const encoded = await bytes(async (w) => { await w.u53(4); @@ -139,6 +110,18 @@ test("AnnounceBroadcast skips an unknown type on draft-06", async () => { expect(await decodeAnnounceBroadcast(reader, Version.DRAFT_06)).toEqual({ status: "skipped" }); }); +// Type 5 was a lite-07 message once; the version is unpublished, so it is now unknown there too. +test("AnnounceBroadcast skips type 5 as unknown on draft-07", async () => { + const encoded = await bytes(async (w) => { + await w.u53(5); + await w.u53(2); + await w.u8(0); + await w.u8(0); + }, Version.DRAFT_07); + const reader = new Reader(undefined, encoded, Version.DRAFT_07); + expect(await decodeAnnounceBroadcast(reader, Version.DRAFT_07)).toEqual({ status: "skipped" }); +}); + test("AnnounceBroadcast drops the route cost before draft-06", async () => { // Pre-lite-06 has no room for a cost on the wire, so one set locally is // simply not sent, keeping mixed-version meshes ranking on hop count. diff --git a/js/net/src/lite/announce.ts b/js/net/src/lite/announce.ts index c9fad00621c..516f63e2bb2 100644 --- a/js/net/src/lite/announce.ts +++ b/js/net/src/lite/announce.ts @@ -10,7 +10,6 @@ import { hasAnnounceId, hasAnnounceOk, hasAnnounceRestart, - hasAnnounceTakeover, hasExcludeHop, hasHidden, hasRouteCost, @@ -33,8 +32,6 @@ const ANNOUNCE_UPDATE = 2; // lite-07: another publisher instance replaces the advertisement. const ANNOUNCE_RESTART = 3; const SYNCED = 4; -// lite-07: a new advertisement continues what a live one served beneath its path. -const ANNOUNCE_TAKEOVER = 5; /** * Lite-07: copy `keep` path segments (from the head) or hops (from the tail) of the @@ -47,10 +44,9 @@ export type Base = { distance: bigint; keep: number }; * An announcement on the Announce Stream, advertising or retracting a broadcast. * * On lite-06+ these are independently-typed messages (`ANNOUNCE_START`, `ANNOUNCE_END`, - * `ANNOUNCE_UPDATE`, and on lite-07 `ANNOUNCE_RESTART` and `ANNOUNCE_TAKEOVER`), each - * framed as `Type | Length | Body` like the subscribe stream's responses. Each `active` - * (ANNOUNCE_START) or `takeover` implicitly assigns the next announce id (a per-stream ordinal starting - * at 0); the others reference that id instead of repeating the path. Older versions send + * `ANNOUNCE_UPDATE`, and on lite-07 `ANNOUNCE_RESTART`), each framed as `Type | Length | Body` + * like the subscribe stream's responses. Each `active` (ANNOUNCE_START) implicitly assigns the + * next announce id (a per-stream ordinal starting at 0); the others reference that id instead of repeating the path. Older versions send * a single ANNOUNCE_BROADCAST message that retracts by path (`ended`). */ export type AnnounceBroadcast = @@ -89,20 +85,6 @@ export type AnnounceBroadcast = cost?: Cost; hopBase?: Base; } - /** Lite07+: an `active` that continues what the live announcement `predecessor` served - * beneath this one's path. It assigns the next id; one at the predecessor's own path - * retires the predecessor. */ - | { - status: "takeover"; - predecessor: bigint; - suffix: Path.Valid; - instance?: Instance.Valid; - epoch?: bigint; - hops: Hop[]; - cost?: Cost; - pathBase?: Base; - hopBase?: Base; - } /** An unknown lite-06+ announce type, skipped by length. Does not assign an id. */ | { status: "skipped" } /** Lite07+: the initial set has been delivered. */ @@ -264,13 +246,6 @@ async function encodeAnnounce06Body(w: Writer, msg: AnnounceBroadcast, version: await encodeHopsBlock(w, version, msg.hops, msg.hopBase); await encodeRouteCost(w, version, msg.cost); break; - case "takeover": - await w.u62(msg.predecessor); - await encodePath(w, version, msg.suffix, msg.pathBase); - await encodeRoute(w, version, msg.instance, msg.epoch); - await encodeHopsBlock(w, version, msg.hops, msg.hopBase); - await encodeRouteCost(w, version, msg.cost); - break; case "synced": break; case "ended": @@ -294,10 +269,6 @@ function announce06Type(msg: AnnounceBroadcast, version: Version): number { // Older versions send an end and a start instead. if (!hasAnnounceRestart(version)) throw new Error("announce restart not supported for this version"); return ANNOUNCE_RESTART; - case "takeover": - // Older versions send a start instead. - if (!hasAnnounceTakeover(version)) throw new Error("announce takeover not supported for this version"); - return ANNOUNCE_TAKEOVER; case "synced": if (!hasSynced(version)) throw new Error("synced not supported for this version"); return SYNCED; @@ -334,22 +305,6 @@ async function decodeAnnounce06Body(r: Reader, typ: number, version: Version): P const hops = await decodeHopsBlock(r, version); return { status: "restart", id, ...served, ...hops, cost: await decodeRouteCost(r, version) }; } - case ANNOUNCE_TAKEOVER: { - // Unknown before lite-07: skipped below like any other. - if (!hasAnnounceTakeover(version)) break; - const predecessor = await r.u62(); - const path = await decodePath(r, version); - const served = await decodeRoute(r, version); - const hops = await decodeHopsBlock(r, version); - return { - status: "takeover", - predecessor, - ...path, - ...served, - ...hops, - cost: await decodeRouteCost(r, version), - }; - } } // Skip the length-prefixed body so an earlier Lite06 build negotiating // the same ALPN does not kill the announce stream. @@ -375,7 +330,6 @@ async function encodeLegacyBody(w: Writer, msg: AnnounceBroadcast, version: Vers case "endedId": case "update": case "restart": - case "takeover": case "skipped": // The id-referencing forms only exist on lite-06+. throw new Error("announce ids not supported for this version"); @@ -476,23 +430,6 @@ export class AnnounceHistory { return { suffix, hops }; } - /** An ANNOUNCE_TAKEOVER: resolve it like a start against the live `predecessor`, - * returning the predecessor's suffix too. One at the predecessor's own suffix retires the - * predecessor, since a suffix holds one advertisement. */ - takeover(msg: { - predecessor: bigint; - suffix: Path.Valid; - hops: Hop[]; - pathBase?: Base; - hopBase?: Base; - }): Advertised & { previous: Path.Valid } { - const live = this.#live.get(msg.predecessor); - if (!live) throw new ProtocolViolation(`unknown announce id: ${msg.predecessor}`); - const resolved = this.start(msg); - if (resolved.suffix === live.suffix) this.#live.delete(msg.predecessor); - return { ...resolved, previous: live.suffix }; - } - /** An ANNOUNCE_UPDATE: resolve its chain before replacing the old one, which it may be based on. */ update(msg: { id: bigint; hops: Hop[]; hopBase?: Base }): Advertised { const hops = this.#hops(msg.hops, msg.hopBase); diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index 993d7db4921..1ba82234abc 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -391,90 +391,6 @@ test("a lite-07 restart replaces the instance, and the next consume subscribes f subscriber.close(); }); -test("a lite-07 takeover surfaces as a start beneath its predecessor, or a restart of it", async () => { - const { subscriber, send, settle } = announceHarness(Version.DRAFT_07); - const announced = subscriber.routes(); - await settle(); - const pool = Path.from("pool"); - const job = Path.from("pool/job"); - - await send((w) => new AnnounceOk(PEER, 0).encode(w, Version.DRAFT_07)); - await send((w) => - encodeAnnounceBroadcast( - w, - { status: "active", suffix: pool, instance: Instance.random(), hops: [PUBLISHER_A] }, - Version.DRAFT_07, - ), - ); - expect(await announced.next()).toMatchObject({ prefix: pool, kind: "start" }); - - // Beneath the claim: a start with the next id (1), and the claim stays live. - const instance = Instance.random(); - await send((w) => - encodeAnnounceBroadcast( - w, - { status: "takeover", predecessor: 0n, suffix: job, instance, hops: [PUBLISHER_A] }, - Version.DRAFT_07, - ), - ); - expect(await announced.next()).toMatchObject({ prefix: job, kind: "start", route: { instance } }); - - // At the job's own path: a restart that retires id 1 and assigns id 2. - const next = Instance.random(); - await send((w) => - encodeAnnounceBroadcast( - w, - { status: "takeover", predecessor: 1n, suffix: job, instance: next, hops: [PUBLISHER_A] }, - Version.DRAFT_07, - ), - ); - expect(await announced.next()).toMatchObject({ prefix: job, kind: "restart", route: { instance: next } }); - - await send((w) => encodeAnnounceBroadcast(w, { status: "endedId", id: 2n }, Version.DRAFT_07)); - expect(await announced.next()).toMatchObject({ prefix: job, kind: "end" }); - await send((w) => encodeAnnounceBroadcast(w, { status: "endedId", id: 0n }, Version.DRAFT_07)); - expect(await announced.next()).toMatchObject({ prefix: pool, kind: "end" }); - - announced.close(); - subscriber.close(); -}); - -// A takeover must name a live predecessor whose path covers its own. -test.each([ - ["a retired predecessor", 1n, "pool/job"], - ["a predecessor not covering it", 0n, "other"], -] as const)("a lite-07 takeover of %s ends the stream", async (_, predecessor, suffix) => { - const { subscriber, send, settle } = announceHarness(Version.DRAFT_07); - const announced = subscriber.routes(); - await settle(); - - await send((w) => new AnnounceOk(PEER, 0).encode(w, Version.DRAFT_07)); - await send((w) => - encodeAnnounceBroadcast( - w, - { status: "active", suffix: Path.from("pool"), instance: Instance.random(), hops: [PUBLISHER_A] }, - Version.DRAFT_07, - ), - ); - expect(await announced.next()).toMatchObject({ kind: "start" }); - await send((w) => - encodeAnnounceBroadcast( - w, - { - status: "takeover", - predecessor, - suffix: Path.from(suffix), - instance: Instance.random(), - hops: [PUBLISHER_A], - }, - Version.DRAFT_07, - ), - ); - await expect(announced.next()).rejects.toThrow(); - - subscriber.close(); -}); - // A replaced prefix served everything consumed beneath it, so those paths subscribe fresh too, // whether the replacement arrives as a lite-07 restart or as an end then a start. test.each([ @@ -2000,7 +1916,7 @@ test.each([ ["answered on lite-06 stays", Version.DRAFT_06, "answered"], ["unanswered on lite-05 finishes", Version.DRAFT_05, "withdrawn"], ["unanswered on lite-06 finishes", Version.DRAFT_06, "withdrawn"], -] as const)("an unpinned SUBSCRIBE %s at a takeover or withdrawal", async (_, version, outcome) => { +] as const)("an unpinned SUBSCRIBE %s at a replacement or withdrawal", async (_, version, outcome) => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const { quic, streams, opened } = fakeSession(); const subscriber = new Subscriber(quic, version, HopSchema.parse(1n)); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 051672e0dee..cb8adcd6661 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -576,8 +576,8 @@ export class Subscriber { stopDrain = this.#goaway?.changed(() => drainAdvertised()); // Lite06+: announce ids. Each received `active` implicitly assigns the next - // per-stream ordinal, as does each `takeover`; `endedId`/`update`/`restart` reference - // it, and lite-07 bases copy from it. Tracked even for announces we skip as reflected, since the sender + // per-stream ordinal; `endedId`/`update`/`restart` reference it, and lite-07 bases + // copy from it. Tracked even for announces we skip as reflected, since the sender // doesn't know we skipped. const history = new AnnounceHistory(); @@ -664,23 +664,6 @@ export class Subscriber { restart = true; break; } - case "takeover": { - // Continuity across the takeover is not kept here: a takeover at a new path - // surfaces as a start, and one at its predecessor's own path as a restart, - // as an older wire carries it. - const resolved = history.takeover(announce); - if (!Path.hasPrefix(resolved.previous, resolved.suffix)) { - throw new ProtocolViolation(`takeover of ${resolved.previous} at ${resolved.suffix}`); - } - path = Path.join(prefix, resolved.suffix); - active = true; - hops = resolved.hops; - cost = announce.cost; - instance = announce.instance; - epoch = announce.epoch; - restart = resolved.suffix === resolved.previous; - break; - } case "skipped": continue; } @@ -702,8 +685,7 @@ export class Subscriber { // a duplicate means the same thing on both sides of it. Mirrors the branch the // Rust announce loop takes before `start_announce`. const duplicateIsUpdate = updateSupported(this.version) && !hasAnnounceId(this.version); - const starts = announce.status === "active" || (announce.status === "takeover" && !restart); - if (starts && !duplicateIsUpdate && advertised.has(path)) { + if (announce.status === "active" && !duplicateIsUpdate && advertised.has(path)) { throw new ProtocolViolation(`duplicate announce for ${path}`); } diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index d5a9e089fe3..f8f03106635 100644 --- a/js/net/src/lite/version.ts +++ b/js/net/src/lite/version.ts @@ -435,12 +435,6 @@ export function hasAnnounceRestart(version: Version): boolean { } } -/** Whether the announce stream has ANNOUNCE_TAKEOVER: a new advertisement continuing what a - * live one served beneath its path. Added in lite-07. Older versions send an ANNOUNCE_START. */ -export function hasAnnounceTakeover(version: Version): boolean { - return hasAnnounceRestart(version); -} - /** Whether announcements carry their instance and epoch, and TRACK, TRACKS, SUBSCRIBE, and FETCH * the instance they pin. Added in lite-07. Older versions carry nothing, so the receiving session * mints an instance for each announcement. */ diff --git a/js/net/src/origin-request.test.ts b/js/net/src/origin-request.test.ts index 874a5f38b7e..01b77ae4888 100644 --- a/js/net/src/origin-request.test.ts +++ b/js/net/src/origin-request.test.ts @@ -345,16 +345,19 @@ test("a visible job route answers its waiting requesters at once", async () => { const pending = (await requests.next()).value; if (!pending) throw new Error("expected a request"); const shared = origin.request(path); + const minted = claim.instance; const job = pending.route(); expect(job.announced).toBe(true); - expect(job.instance).not.toBe(claim.instance); + expect(job.instance).toBe(minted); await settle(); for (const request of [first, shared]) expect(request.active.peek()?.instance).toBe(job.instance); // The request is answered: routing it again is refused. expect(() => pending.route()).toThrow("request is closed"); - // Ending the job withdraws only its path: the claim keeps answering later requests. + // Ending the job withdraws only its path: the claim keeps answering later requests, under a + // fresh instance. job.close(); + expect(claim.instance).not.toBe(minted); await settle(); const later = origin.request(path); const next = (await requests.next()).value; @@ -898,3 +901,281 @@ test("a peer's pin is served by a local broadcast that loses the path", async () publication.broadcast.close(); origin.close(); }); + +// A peer pinned to the claim's instance is served by its job's route, even when a sibling job +// ending moved the claim on before the peer's demand looked again. +test("a peer's pin reaches its job after a sibling moved the claim on", async () => { + const origin = new Producer(); + const wire = wireOf(origin.consume()); + const claim = origin.routePrefix(Path.from("room")); + const minted = claim.instance; + const requests = claim.requested(); + const reader = origin.request(Path.from("room/alice")); + const asked = (await requests.next()).value; + if (!asked) throw new Error("expected a request"); + const alice = asked.route(); + await settle(); + + const bob = Path.from("room/bob"); + const demanded = wire.demand(bob, minted); + const next = (await requests.next()).value; + if (!next) throw new Error("expected a request"); + // Bob answers under the claim's instance, then Alice ends before the demand resumes. + const job = next.route(); + alice.close(); + expect(claim.instance).not.toBe(minted); + expect(job.instance).toBe(minted); + expect(await demanded).toBeDefined(); + + reader.close(); + job.close(); + claim.close(); + origin.close(); +}); + +// A job naming no instance continues its claim's minted one: a reader resolved through the claim +// is handed the job's broadcast under the instance it resolved, once, and keeps reading it. +test.each(["request", "follow"] as const)("a %s through a claim continues on its job's route", async (method) => { + const origin = new Producer(); + const path = Path.from("room/alice"); + const claim = origin.routePrefix(Path.from("room")); + const minted = claim.instance; + const requests = claim.requested(); + const reader = method === "request" ? origin.request(path) : origin.follow(path); + const handed: unknown[] = []; + const stop = reader.active.subscribe((active) => { + if (active) handed.push(active); + }); + const pending = (await requests.next()).value; + if (!pending) throw new Error("expected a request"); + const job = pending.route({ hidden: true }); + const data = job.broadcast.createTrack("data"); + data.writeFrame({ payload: new TextEncoder().encode("first") }); + expect(job.instance).toBe(minted); + job.announce(); + await settle(); + expect(job.instance).toBe(minted); + expect(claim.instance).toBe(minted); + const active = reader.active.peek(); + expect(active?.instance).toBe(minted); + const track = active?.track("data").subscribe().ordered(); + expect(await track?.readString()).toBe("first"); + data.writeFrame({ payload: new TextEncoder().encode("second") }); + expect(await track?.readString()).toBe("second"); + expect(handed).toEqual([active]); + expect(reader.closed.peek()).toBeUndefined(); + stop(); + track?.close(); + reader.close(); + job.close(); + claim.close(); + origin.close(); +}); + +// Ending a job moves its claim to a fresh instance at once: nothing answers the ended stream's +// instance again, and the restarted job is a new stream. +test("a restarted job gets a fresh instance and its old one is refused", async () => { + const origin = new Producer(); + const wire = wireOf(origin.consume()); + const path = Path.from("room/alice"); + const claim = origin.routePrefix(Path.from("room")); + const minted = claim.instance; + const requests = claim.requested(); + const first = origin.request(path); + const pending = (await requests.next()).value; + if (!pending) throw new Error("expected a request"); + const job = pending.route(); + await settle(); + const pinned = origin.request(path, { instance: minted }); + expect(pinned.active.peek()?.instance).toBe(minted); + + job.close(); + expect(claim.instance).not.toBe(minted); + for (const request of [first, pinned]) { + expect((request.closed.peek() as StreamError).code).toBe(StreamCode.Unroutable); + } + // A peer naming the ended instance is refused rather than handed the next job. + await expect(wire.demand(path, minted)).rejects.toMatchObject({ code: StreamCode.Unroutable }); + + const later = origin.request(path); + const next = (await requests.next()).value; + if (!next) throw new Error("expected another request"); + const restarted = next.route(); + await settle(); + expect(restarted.instance).toBe(claim.instance); + expect(restarted.instance).not.toBe(minted); + expect(later.active.peek()?.instance).toBe(restarted.instance); + later.close(); + restarted.close(); + claim.close(); + origin.close(); +}); + +// A job that never read its instance binds its claim's when it announces, so one still hidden +// when an unrelated job ends keeps its request and answers it under the claim's new instance. +test("a hidden job announces under the instance its claim moved to", async () => { + const origin = new Producer(); + const claim = origin.routePrefix(Path.from("room")); + const minted = claim.instance; + const requests = claim.requested(); + const alice = origin.request(Path.from("room/alice")); + const first = (await requests.next()).value; + if (!first) throw new Error("expected a request"); + const ended = first.route(); + const bob = origin.request(Path.from("room/bob")); + const second = (await requests.next()).value; + if (!second) throw new Error("expected a request"); + const hidden = second.route({ hidden: true }); + + ended.close(); + await settle(); + const moved = claim.instance; + expect(moved).not.toBe(minted); + expect(bob.closed.peek()).toBeUndefined(); + hidden.announce(); + await settle(); + expect(hidden.instance).toBe(moved); + expect(bob.active.peek()?.instance).toBe(moved); + for (const request of [alice, bob]) request.close(); + hidden.close(); + claim.close(); + origin.close(); +}); + +// A hidden job that read its instance, as a signer does, announces under it or not at all: once an +// unrelated job's end moves the claim past it, the announce is refused and its request goes back to +// the worker, which prepares the job under the claim's new instance. Every requester waits on. +test("a job that read its instance is refused once its claim moves", async () => { + const origin = new Producer(); + const claim = origin.routePrefix(Path.from("room")); + const minted = claim.instance; + const requests = claim.requested(); + const alice = origin.request(Path.from("room/alice")); + const first = (await requests.next()).value; + if (!first) throw new Error("expected a request"); + const ended = first.route(); + const bob = origin.request(Path.from("room/bob")); + const second = (await requests.next()).value; + if (!second) throw new Error("expected a request"); + const hidden = second.route({ hidden: true }); + expect(hidden.instance).toBe(minted); + + ended.close(); + await settle(); + const moved = claim.instance; + expect(moved).not.toBe(minted); + expect(hidden.instance).toBe(minted); + const late = origin.request(Path.from("room/bob")); + expect(() => hidden.announce()).toThrow(expect.objectContaining({ code: StreamCode.Unroutable })); + await settle(); + for (const request of [bob, late]) expect(request.closed.peek()).toBeUndefined(); + + const third = (await requests.next()).value; + if (!third) throw new Error("expected the request again"); + expect(third.path).toBe(Path.from("room/bob")); + const again = third.route({ hidden: true }); + expect(again.instance).toBe(moved); + again.announce(); + await settle(); + for (const request of [bob, late]) expect(request.active.peek()?.instance).toBe(moved); + for (const request of [alice, bob, late]) request.close(); + again.close(); + claim.close(); + origin.close(); +}); + +// Each live job keeps the instance it bound; only one carrying the claim's current instance moves +// the claim when it ends. +test("one job ending leaves another job's instance alone", async () => { + const origin = new Producer(); + const claim = origin.routePrefix(Path.from("room")); + const minted = claim.instance; + const requests = claim.requested(); + const jobs = []; + for (const name of ["alice", "bob"]) { + const request = origin.request(Path.from(`room/${name}`)); + const pending = (await requests.next()).value; + if (!pending) throw new Error("expected a request"); + jobs.push({ request, job: pending.route() }); + } + const [alice, bob] = jobs; + await settle(); + expect(bob.job.instance).toBe(minted); + + alice.job.close(); + const moved = claim.instance; + expect(moved).not.toBe(minted); + await settle(); + expect(bob.job.instance).toBe(minted); + expect(bob.request.closed.peek()).toBeUndefined(); + expect(bob.request.active.peek()?.instance).toBe(minted); + const again = origin.request(Path.from("room/bob")); + expect(again.active.peek()?.instance).toBe(minted); + + bob.job.close(); + expect(claim.instance).toBe(moved); + for (const { request } of jobs) request.close(); + again.close(); + claim.close(); + origin.close(); +}); + +// A claim naming a stable instance keeps it for answers it serves; its jobs mint their own. +test("a claim naming its instance gives each job a fresh one", async () => { + const origin = new Producer(); + const stable = Instance.parse("stable"); + const claim = origin.routePrefix(Path.from("room"), { instance: stable, epoch: 1n }); + const requests = claim.requested(); + const request = origin.request(Path.from("room/alice")); + const pending = (await requests.next()).value; + if (!pending) throw new Error("expected a request"); + const job = pending.route(); + await settle(); + expect(job.instance).not.toBe(stable); + expect(request.active.peek()?.instance).toBe(job.instance); + job.close(); + expect(claim.instance).toBe(stable); + request.close(); + claim.close(); + origin.close(); +}); + +// A claim moving on after a job ends keeps the answers it served from held broadcasts: a later +// request for a held path is answered again without asking the handler, under the new instance. +test("a claim moving on keeps its held answers", async () => { + const origin = new Producer(); + const claim = origin.routePrefix(Path.from("room")); + const minted = claim.instance; + const requests = claim.requested(); + const held = new BroadcastProducer(); + held.createTrack("data").writeFrame({ payload: new TextEncoder().encode("held") }); + const reader = origin.request(Path.from("room/held")); + const asked = (await requests.next()).value; + if (!asked) throw new Error("expected a request"); + asked.serve(held.consume()); + const alice = origin.request(Path.from("room/alice")); + const pending = (await requests.next()).value; + if (!pending) throw new Error("expected a request"); + const job = pending.route(); + await settle(); + expect(reader.active.peek()?.instance).toBe(minted); + + job.close(); + expect(claim.instance).not.toBe(minted); + await settle(); + // The reader resolved under the old instance keeps its broadcast. + expect(reader.closed.peek()).toBeUndefined(); + const track = reader.active.peek()?.track("data").subscribe().ordered(); + expect(await track?.readString()).toBe("held"); + const later = origin.request(Path.from("room/held")); + expect(later.active.peek()?.instance).toBe(claim.instance); + const again = later.active.peek()?.track("data").subscribe().ordered(); + expect(await again?.readString()).toBe("held"); + + track?.close(); + again?.close(); + for (const request of [reader, alice, later]) request.close(); + claim.close(); + held.close(); + origin.close(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index a54395e7076..dfdfba82a1a 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -378,6 +378,8 @@ class ServeState { settled = new Signal(0); /** Counts {@link reset}s, so a waiter can tell its request was released rather than answered. */ resets = 0; + /** Set on a claim serving an instance it minted, which it hands to its jobs' exact routes. */ + handoff?: Handoff; onChange: (path: Path.Valid) => void = () => {}; onReject: (path: Path.Valid, err: Error) => void = () => {}; @@ -478,6 +480,19 @@ class ServeState { } } +/** + * What a claim that minted its instance shares with its jobs. A job naming no instance + * continues the claim's, so a reader resolved through the claim continues on the job's exact + * route; once that job stops being advertised the claim moves to a fresh instance, so the next + * job for the path is a new stream and nothing answers the ended one's instance again. + */ +interface Handoff { + /** The instance and epoch the claim serves now, which a job binds when it first announces or reads its instance. */ + current(): { instance: Instance.Valid; epoch: bigint }; + /** A job serving `instance` stopped being advertised; the claim moves on if it still serves it. */ + advance(instance: Instance.Valid): void; +} + interface Presented extends Advertised { readonly captures: Path.Pattern[] | undefined; } @@ -499,10 +514,12 @@ function publishPath(path: Path.Valid): void { } } -// A broadcast created through the origin: its producer, and the handle the origin routes. +// A broadcast created through the origin: its producer, the handle the origin routes, and +// how its route stops being advertised. interface Created { producer: broadcast.Producer; front: broadcast.Consumer; + retract: () => void; } // End a created broadcast the origin no longer routes. Consumer handles never end a produced @@ -1249,7 +1266,8 @@ export interface Table extends Reader { * credential, and, rarely, a stable instance. * * The default announces at once, at cost 0, under the session's scope, serving a fresh random - * instance minted when the route is created. + * instance minted when the route is created. A job's route ({@link Request.route}) instead + * continues its prefix route's instance when that one was minted; see {@link Route.instance}. */ export interface RouteOptions { /** @@ -1441,14 +1459,27 @@ export class Producer implements Table { auth: options.auth, }); + // A job naming no instance continues its claim's, bound when it first announces or when its + // instance is read, whichever comes first. One that never read it is not invalidated by the + // claim moving on while it is prepared; one that read it announces under it or not at all. + const claim = request && options.instance === undefined ? requestHandoff(request) : undefined; + let bound = false; + const producer = new broadcast.Producer(); hooks.stampPath(producer, path); const front = producer.consume(); - const entry = { producer, front }; + const announced = () => this.#state.local.peek()?.get(path) === front; + // The claim moves on before the retraction reroutes anything, so no request resolving the + // claim in this step is answered under the ended stream. + const retract = () => { + if (bound && announced()) claim?.advance(info.instance as Instance.Valid); + this.#retractExact(path, front); + }; + const entry = { producer, front, retract }; const previous = created.get(path); created.set(path, entry); if (previous) { - this.#retractExact(path, previous.front); + previous.retract(); retire(previous); } @@ -1459,7 +1490,7 @@ export class Producer implements Table { // a later announce. A job's unanswered request can never be answered now. const release = this.#hold(() => { stop(); - this.#retractExact(path, front); + retract(); if (created.get(path) === entry) created.delete(path); retire(entry); const job = pending; @@ -1469,7 +1500,6 @@ export class Producer implements Table { // Synchronous, so a request made right after the broadcast closes finds it gone. const stop = hooks.onClosed(producer, release); - const announced = () => this.#state.local.peek()?.get(path) === front; const announce = () => { if (producer.closed.peek() !== undefined) throw new Error("broadcast is closed"); const job = pending; @@ -1481,6 +1511,17 @@ export class Producer implements Table { try { // A request released meanwhile can never be answered, so this route never announces. if (!jobPending(job)) throw unroutable(); + if (claim) { + const current = claim.current(); + // A job that read its instance announces under it or not at all. Its requesters ask + // the claim again, so the worker prepares the job under the claim's new instance. + if (bound && current.instance !== info.instance) { + requeueJob(job); + throw unroutable(); + } + info = { ...info, ...current }; + bound = true; + } this.#advertiseExact(path, front, info); } catch (err) { release(); @@ -1498,9 +1539,15 @@ export class Producer implements Table { const route = makeRoute({ broadcast: producer, - instance: () => info.instance as Instance.Valid, + instance: () => { + if (claim && !bound && pending) { + info = { ...info, ...claim.current() }; + bound = true; + } + return info.instance as Instance.Valid; + }, announce, - withdraw: () => this.#retractExact(path, front), + withdraw: retract, announced, change: (update) => change(WireRoute.normalize({ ...info, ...update })), requested: async function* () {}, @@ -1554,9 +1601,12 @@ export class Producer implements Table { * A consumer resolving a path under it that no announced local broadcast wins is handed to * {@link Route.requested} as a {@link Request}: answer it by serving a broadcast already held * ({@link Request.serve}), under this route's instance, or by creating the path's own exact - * route ({@link Request.route}), under a fresh instance, so restarting one job never splices - * into a copy of the last. A publisher that knows its broadcasts creates each one's exact - * {@link route} instead, so subscribers can enumerate them. + * route ({@link Request.route}), a job. Without {@link RouteOptions.instance} this route + * serves a minted instance that its jobs continue, so a reader resolved through it keeps + * reading when the job's route lands; once a job stops being advertised, this route moves to + * a fresh instance, so restarting the job never splices into a copy of the last. With a named + * instance, each job mints its own. A publisher that knows its broadcasts creates each one's + * exact {@link route} instead, so subscribers can enumerate them. */ routePrefix(prefix: Path.Valid, options: RouteOptions = {}): Route { const info = WireRoute.normalize({ @@ -1575,8 +1625,9 @@ export class Producer implements Table { #serving(prefix: Path.Valid, route: WireRoute, originated: boolean, publishes: boolean, visible: boolean): Route { prefix = this.#scope.prefix(prefix); - // A route that names no instance serves one of its own. - if (route.instance === undefined) route = { ...route, instance: Instance.random(), epoch: 0n }; + // A route that names no instance serves one of its own, and a claim hands that one to its jobs. + const minted = route.instance === undefined; + if (minted) route = { ...route, instance: Instance.random(), epoch: 0n }; publishPath(prefix); const server = new ServeState(this.#scope.root, prefix); server.onChange = (path) => this.#state.refresh(path); @@ -1644,19 +1695,39 @@ export class Producer implements Table { if (visible) insert(); } - const update = (next: WireRoute) => { + // An `advance` to a fresh instance of the claim's own keeps the requests its handler holds, + // which a job binding at announce answers under the new instance, and the held answers it + // served; only readers restart. + const update = (next: WireRoute, advance = false) => { open(); const previous = entry.route.peek().instance; const restart = inserted && previous !== next.instance; if (restart) this.#state.restart(entry); entry.route.set(next); - if (previous !== next.instance) server.reset(); + if (previous !== next.instance) { + // Wakes a peer's demand pinned to the old instance, which then refuses it. + if (advance) server.settled.update((n) => n + 1); + else server.reset(); + } if (!inserted) return; this.#state.rebuildOriginated(); this.#state.refreshPrefix(prefix); this.#state.routes.mutate(() => {}); }; + if (publishes && minted) { + server.handoff = { + current: () => { + const { instance, epoch } = entry.route.peek(); + return { instance: instance as Instance.Valid, epoch: epoch ?? 0n }; + }, + advance: (instance) => { + if (ended || !this.#state.routes.peek() || entry.route.peek().instance !== instance) return; + update({ ...entry.route.peek(), instance: Instance.random(), epoch: 0n }, true); + }, + }; + } + const publisher = publishes ? this : undefined; return makeRoute({ instance: () => entry.route.peek().instance as Instance.Valid, @@ -1889,6 +1960,8 @@ let setRequestPublisher: (request: Request, publisher: Producer | undefined) => let jobPending: (request: Request) => boolean; let answerJob: (request: Request) => void; let rejectJob: (request: Request, err: Error) => void; +let requeueJob: (request: Request) => void; +let requestHandoff: (request: Request) => Handoff | undefined; /** * An owned request for one publisher instance at a broadcast path; see {@link Consumer.request}. @@ -2600,8 +2673,14 @@ export class Consumer implements Reader { try { for (;;) { signal?.throwIfAborted(); - // The route may name another instance by the time its handler answers. - if (instance !== undefined && entry.route.peek().instance !== instance) throw unroutable(); + // The route may name another instance by the time its handler answers, as when a + // sibling job ending moves a claim on. A job's route still carrying the requested + // instance serves it. + if (instance !== undefined && entry.route.peek().instance !== instance) { + const job = this.#local(path, instance); + if (job) return job; + throw unroutable(); + } const served = server.served.get(path); if (served && served.closed.peek() === undefined) return served; const rejected = server.rejected.get(path); @@ -2615,7 +2694,15 @@ export class Consumer implements Reader { } // Released by an instance change, which also dropped this pass's count: ask again. if (server.resets !== resets) break; - if (!server.pending.has(path)) return undefined; + if (!server.pending.has(path)) { + // Answered by a job's exact route. A peer naming no instance cannot tell + // that route's stream from the claim's, so it is refused and subscribes + // again once the route's announcement reaches it. + if (instance === undefined) return undefined; + const job = this.#local(path, instance); + if (job) return job; + throw unroutable(); + } await race([Signal.race(server.settled, server.closed), cancelled]); } } finally { @@ -2717,7 +2804,18 @@ export class Route { return producer; } - /** The stream this route serves, minted when it was created unless {@link RouteOptions.instance} named one. */ + /** + * The stream this route serves, minted when it was created unless {@link RouteOptions.instance} + * named one. + * + * A prefix route that minted its instance moves to a fresh one, as a restart, the moment a + * job's route carrying its instance stops being advertised. A job's route + * ({@link Request.route}) naming no instance takes its prefix route's current one when it + * first announces or when this first reads it. Read first, the job announces under it, and + * {@link announce} throws {@link StreamCode | StreamCode.Unroutable} once the prefix route has + * moved past it, so a signer built from it stays valid. A prefix route naming its instance + * gives each job a fresh one instead. + */ get instance(): Instance.Valid { return this.#control.instance(); } @@ -2728,7 +2826,9 @@ export class Route { * Visible to local consumers before it returns. A job's route ({@link Request.route}) also * answers the requesters waiting on its path. Throws once the route or its broadcast closed, * and, announcing nothing, with {@link StreamCode | StreamCode.Unroutable} when a job's request - * was released or its prefix route retracted meanwhile: that route can never announce. + * was released or its prefix route retracted meanwhile, or when the job read its + * {@link instance} and its prefix route has moved past it since: that route can never + * announce. In that last case its requesters ask the prefix route again. */ announce(): void { this.#control.announce(); @@ -2792,8 +2892,8 @@ export class Route { * * Yielded by {@link Route.requested}. Answer it one of three ways: {@link serve} a broadcast * already held, under the prefix route's instance; create the path's own exact route with - * {@link route}, under a fresh instance; or {@link reject} it with an error. Advancing the - * iterator or closing it without answering rejects the request. + * {@link route}, a job; or {@link reject} it with an error. Advancing the iterator or closing it + * without answering rejects the request. * * @public */ @@ -2824,6 +2924,12 @@ export class Request { request.#server.settled.update((n) => n + 1); }; rejectJob = (request, err) => request.#server.reject(request, err); + requeueJob = (request) => { + const path = Path.join(request.#server.root, request.path); + request.#server.forget(path, request); + request.#server.enqueue(path); + }; + requestHandoff = (request) => request.#server.handoff; finishRequest = (request, err) => { request.#done = true; request.#withdrawn = true; @@ -2834,9 +2940,13 @@ export class Request { /** * Answer with the path's own exact route, owning a new broadcast. * - * The route serves a fresh instance unless `options` names one, so a job that ends and - * restarts beneath the same prefix is a new stream for its restarted sequences. Closing the - * route withdraws only this path: the prefix route keeps answering later requests. Created + * Unless `options` names an instance, the route continues the prefix route's minted + * instance, bound when it first announces or {@link Route.instance} first reads it, so readers that resolved the path through the + * prefix route keep reading the same stream. Once this route stops being advertised the + * prefix route moves to a fresh instance, so a job that restarts beneath it is a new stream + * for its restarted sequences, and a request naming the ended instance is refused. Under a + * prefix route naming its instance, the route mints a fresh one. Closing the route withdraws + * only this path: the prefix route keeps answering later requests. Created * visible, the route announces now and answers the requesters waiting on its path. Created * {@link RouteOptions.hidden | hidden}, they wait until {@link Route.announce}, so the job can * fill its catalog first; closing the route or its broadcast before then refuses them. diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 32e8ea4676a..11d27d4d592 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -628,6 +628,33 @@ describe("restart", () => { await settle(); }); + it("plays a claim's job on the instance it resolved", async () => { + const owner = new Origin.Producer(); + const name = Path.from("pool/job.hang"); + const pool = owner.routePrefix(Path.from("pool")); + const answers = serve(pool); + const source = new Broadcast({ origin: owner, name, enabled: true, follow: true, catalogFormat: "manual" }); + const handed: unknown[] = []; + const stop = source.out.active.subscribe((active) => { + if (active) handed.push(active); + }); + await settle(); + const active = source.out.active.peek(); + expect(active?.instance).toBe(pool.instance); + + // The job's exact route carries the pool's instance, so the player never restarts. + for (let i = 0; i < 5; i++) await settle(); + expect(handed).toEqual([active]); + expect(source.out.error.peek()).toBeUndefined(); + + stop(); + for (const answer of answers) answer.close(); + pool.close(); + source.close(); + owner.close(); + await settle(); + }); + it("resumes on a covering prefix after a gap", async () => { const owner = new Origin.Producer(); const name = Path.from("pool/job.hang"); diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index bdb48cef8c3..e4a7597e87e 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -30,7 +30,6 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs -import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -921,7 +920,7 @@ class SmokeTest { val request = assertNotNull(dynamic.requested()) assertEquals("live/cam", request.path()) request.route(RouteOptions(hidden = true)).use { job -> - assertNotEquals(dynamic.instance(), job.instance()) + assertEquals(dynamic.instance(), job.instance()) job.broadcast().use { fill -> // Only announcing answers the requester. job.announce() diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 14c62fbff2d..79a5a88735f 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -288,9 +288,9 @@ moq-transport on every relay hop; this API does not add pre-07 capability detect - `.route_prefix(prefix, *, hidden=False, cost=0, instance=None, epoch=0) → Route`. A prefix route that yields the requests beneath it. - **`Route`**. A route on an origin. Hold it: dropping or cancelling it retracts it, and so does closing its broadcast. - `.broadcast → BroadcastProducer`. The broadcast an exact route owns; raises `Internal` on a prefix route. - - `.instance`. The stream it serves, kept across `withdraw()` and `announce()`. + - `.instance`. The stream it serves now, kept across `withdraw()` and `announce()`. Unless one is named, a prefix route moves to a fresh one each time a job holding it ends, and a job takes its prefix route's when it first announces or when `.instance` first reads it. Read first, the job announces under it, and `announce()` fails with `Unroutable` once the prefix route has moved past it. - `.announce()`, `.withdraw()`, `.set_cost(cost)`. Show, hide, or re-price it in place; `announce` and `set_cost` raise `Closed` once the broadcast closed. - - `await .requested() → BroadcastRequest | None`, or async iteration over a prefix route. Call `.serve(consumer)` to answer with held content under the prefix route's continuous sequence space, `.route(hidden=..., ...)` for the path's own route under a fresh instance, as an independently restarting job, or `.reject(code)` to fail the requester. + - `await .requested() → BroadcastRequest | None`, or async iteration over a prefix route. Call `.serve(consumer)` to answer with held content under the prefix route's continuous sequence space, `.route(hidden=..., ...)` for the path's own route, which continues the prefix route's instance until the job ends and then moves the prefix route to a fresh one (unless either names an instance: a named prefix route lends none, and a named job keeps its own), or `.reject(code)` to fail the requester. - **`OriginConsumer`**. Discover broadcasts. - `.announced(prefix, filter=None) → AnnounceConsumer` (async iterator of `AnnounceEvent`: `AnnounceEventPresent` or `AnnounceEventAbsent`, each carrying an `Announce` whose `.route.instance` names the stream; another instance taking a path over keeps it present, so `follow` the path to see that); `filter` is a pattern relative to the literal prefix, while each `Announce.prefix` stays origin-relative and `.captures` is a required list of wildcard matches, empty without wildcards - `.follow(path, instance=None) → Following` (awaitable, waits until something serves the path, then returns each stream that takes over, or `None` once the path goes offline; an `instance` follows only that stream) diff --git a/py/moq-rs/moq/origin.py b/py/moq-rs/moq/origin.py index fac2485f231..1128922623f 100644 --- a/py/moq-rs/moq/origin.py +++ b/py/moq-rs/moq/origin.py @@ -119,11 +119,14 @@ def path(self) -> str: def route(self, *, hidden: bool = False, cost: int = 0, instance: str | None = None, epoch: int = 0) -> Route: """Answer with the path's own exact :class:`Route`, owning a new broadcast. - The route serves a fresh instance unless ``instance`` names one, so a job that - restarts is a new stream. Created visible, it announces now and answers the waiting - requesters; created ``hidden``, they wait until :meth:`Route.announce`, so the job can - fill its catalog first, and cancelling the route before then refuses them. Ending the - route withdraws only this path. + Unless ``instance`` names one, the route takes the instance its prefix route lends when + it first announces or :attr:`Route.instance` first reads it, so readers that resolved + the path through the prefix route continue on it; its end then moves the prefix route to + a fresh instance, so a job that restarts is a new stream. A prefix route that names its + instance lends none, and the route mints its own. Created visible, it announces now and + answers the waiting requesters; created ``hidden``, they wait until + :meth:`Route.announce`, so the job can fill its catalog first, and cancelling the route + before then refuses them. Ending the route withdraws only this path. """ return Route(self._inner.route(_options(hidden=hidden, cost=cost, instance=instance, epoch=epoch))) @@ -163,8 +166,13 @@ def broadcast(self) -> BroadcastProducer: @property def instance(self) -> str: - """The stream this route serves, minted at creation unless one was named, and kept - across :meth:`withdraw` and :meth:`announce`.""" + """The stream this route serves now. + + A named instance never changes. Unnamed, a prefix route moves to a fresh one each time + a job holding its current one ends. A job's route takes its prefix route's current one + when it first announces or when this first reads it. Read first, the job announces + under it, and :meth:`announce` fails with ``Unroutable`` once the prefix route has moved + past it, so a signer built from it stays valid. Withdrawing and announcing keep it.""" return self._inner.instance_id() def announce(self) -> None: diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index fbe1b655e77..4d94a03a30c 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -839,9 +839,9 @@ async def test_dynamic_broadcast_request(): assert request is not None assert request.path == "dynamic/broadcast" - # A hidden job route answers once it announces, under its own instance. + # A hidden job route answers once it announces, under the instance its prefix route lends. served = request.route(hidden=True) - assert served.instance != dynamic.instance + assert served.instance == dynamic.instance track = served.broadcast.publish_track("status", moq.TrackInfo(timescale=1_000_000)) served.announce() with pytest.raises(Exception): diff --git a/quest/m2/README.md b/quest/m2/README.md index 062bef3c7a3..cee976a51c0 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -183,7 +183,6 @@ quests that require them. - [Subscribe ranges](/quest/m2/subscribe-ranges/README.md) - a lite-07 SUBSCRIBE asks for past and live ranges with absolute or relative ends, in either order, and replaces FETCH for multi-group reads (single-group FETCH stays); merged ranges never starve a subscriber; relays fill misses by range, including over moq-transport - [Address groups by timestamp](/quest/m2/time-addressing/README.md) - group start timestamps replace sequence numbers in the moq-net API and lite-07 wire, while old subscribers, old publishers, and published JS clients keep working - [Cluster routing](/quest/m2/cluster-routing/README.md) - prove shared routing contracts, implement per-origin routes, and connect outbound local clusters -- [A job's exact route continues its claim's readers](/quest/m2/announce-takeover.md) - a job's route inherits its claim's instance, replacing the ANNOUNCE_TAKEOVER predecessor design - [A route's role is its type](/quest/m2/route-roles.md) - no route method panics, throws, or errors because the other constructor made the route; prototypes of a single prefix route and an exact/prefix split decide the shape - [A group a subscriber gives up on is always reported](/quest/m2/group-drops.md) - the wire names every abandoned group and the drop signal is public, so readers count losses instead of stalling - [Stream credit by priority](/quest/m1/stream-credit/README.md) - every stream opens through one priority entry point, and a higher-priority group takes over or resets a lower one when credit runs out (held in m1 by an open claim) diff --git a/quest/m2/announce-takeover.md b/quest/m2/announce-takeover.md deleted file mode 100644 index aab0fa6580a..00000000000 --- a/quest/m2/announce-takeover.md +++ /dev/null @@ -1,159 +0,0 @@ -# [XL] A job's exact route continues its claim's readers - -## Goal - -A worker advertises a prefix route to accept lazy work. When a request -starts a job, the job's exact route carries the prefix route's instance, so -a reader first served through the claim continues on the exact route with -no cold-start reset, across relays, on lite-07. A different worker, or a -restarted job, never continues those readers: each is another instance. -Readers holding `follow` or `request` simply continue, and see a new -broadcast only when the instance changes. Rust and JS players keep playing -through a claim. - -The ANNOUNCE_TAKEOVER work that landed in -[moq-dev/moq#6994](https://github.com/moq-dev/moq/pull/6994) is removed: the -lite-07 ANNOUNCE_TAKEOVER message (0x5), the origin, front and relay -handling of predecessors, the `Takeover { announce, predecessor }` event, the -predecessor fields in moq-ffi and the C ABI, and the takeover text in the -lite draft and the concept docs. No wire message or public event replaces it. - -Non-goals: moq-transport, which keeps the cold-start reset -(moq-transport carries no epoch (decided 2026-10-28, removed in -[moq-dev/moq#7239](https://github.com/moq-dev/moq/pull/7239)), and an -edge mints a new instance for each announcement, -[Instance and epoch](https://github.com/moq-dev/moq/pull/7489)). - -## Plan - -Re-planned 2026-10-30 (maintainer, the [route epic](https://github.com/moq-dev/moq/blob/1a6ac473d75fcbfd5d23bdbdfd64eb4750b077ad/quest/m1/api-audit/route/README.md)): -continuity is the instance promise, not an `ANNOUNCE_TAKEOVER` predecessor -ID. An instance is one continuous sequence space at a path, so the exact -route and the claim's answer are the same consumer route, -origin → route(path, instance) → broadcast. The 2026-10-10 plan (TAKEOVER as -START plus a predecessor Announce ID, with per-stream ID translation in -relays) is dropped. Its safety requirements carry over, as follows. - -Decided 2026-10-31 (maintainer, merging main into -[moq-dev/moq#7420](https://github.com/moq-dev/moq/pull/7420)): instance -wins over the predecessor design that landed in -[moq-dev/moq#6994](https://github.com/moq-dev/moq/pull/6994) meanwhile. -Keeping both would give two continuity rules, one by instance and one by -predecessor prefix, that every origin, relay and binding must agree on. The -instance rule already covers the claim case and needs no wire message, so -this quest removes the predecessor machinery and grows to [XL]. - -- **Removing ANNOUNCE_TAKEOVER.** Delete the 0x5 message and its codec in - `rs/moq-net/src/lite/announce.rs`, `compress.rs`, `publisher/routed.rs`, - `subscriber/routed.rs` and `replay.rs`; the route layer's claim lineage - (`Claim::continues` in `rs/moq-net/src/route/node.rs`, fed by `OwnClaims` in - `origin.rs`); the predecessor tracking in - `rs/moq-net/src/model/origin.rs` and `front.rs` (the `seen` stamps in - `OriginConsumerState` and `PendingFront::via`); relay forwarding in - `rs/moq-relay`; and `rs/moq-net/tests/integration/announce_takeover.rs`. - Remove `RouteEvent::Takeover` from the relay-level `routes()` stream - (`rs/moq-net/src/model/origin.rs`, and its folds in `follow.rs`, - `announced.rs` and the session publishers); the app listing, `MoqAnnounceEvent` - and `moq_announce_event` dropped it already in - [moq-dev/moq#7678](https://github.com/moq-dev/moq/pull/7678). In `js/net`, drop the ANNOUNCE_TAKEOVER decoding in - `lite/announce.ts` and `lite/subscriber.ts`. Remove the takeover sections - from `drafts/draft-lcurley-moq-lite.md`, `doc/concept/identity.md` and - `doc/concept/moq-lite.md`. -- **Inheriting the instance.** Decided 2026-11-03 (maintainer, on - [moq-dev/moq#7770](https://github.com/moq-dev/moq/pull/7770)): - `request.route(options)`, from - [One Route handle](https://github.com/moq-dev/moq/pull/7806), mints a fresh - instance by default, and ending a job leaves its prefix route open, with - no history of ended paths. Carrying the prefix route's instance onto the - path's exact route, so that the answer the waiting request gets is that - route's broadcast, is this quest's change, and it must keep those - properties: a restarted job still gets a fresh instance, and job - lifetimes stay independent without per-path history of ended jobs. - A job that serves a held broadcast through `Request::serve` - never announces an exact route, so it needs none of this. -- **One stream per path and instance.** A claim may answer a path under - its instance only while that stream is live. Once the job's route ends, - the next request for that path gets a fresh instance, because its - sequences restart. A request pinned to the old instance is refused as - ended, never answered with new bytes. This is the old rule "a worker - restarting a job under an unchanged claim cannot relabel the previous - job's cached content", now enforced in the origin. Prove it in the - model, including retained generations and pending answers, before - relying on it. -- **Relays.** An aggregated prefix can be served by several workers, but - each worker has its own instance. A relay can therefore only continue a - reader on routes carrying the instance it resolved, and readers of A - never move to B. A relay that cannot keep the instance, such as an - older-lite hop, mints a new one, so its readers see a new broadcast - rather than a splice. -- **What a continued reader keeps.** Handles, group positions, metadata, - object and negative caches, catalog pins, and outstanding - TRACK/SUBSCRIBE/FETCH requests. Unrelated sibling paths are unchanged. A - continued stream does not renew a composed catalog. -- **JS players.** `js/net`'s lite subscriber continues a consumer resolved - through the claim on the exact route with the same instance instead of - invalidating it, in `BroadcastCache` and `connection/forward.ts`, so a - `@moq/watch` reader through a claim keeps playing. -- **Epoch-bound requests through a continued route.** A front or selection - that continues on the exact route keeps the upstream SUBSCRIBE it already - sent under the claim's route; a later request naming the exact route's - epoch joins it without restamping. This is accepted while the answering - worker is the same instance by construction. Decide whether that join - needs the upstream's answer to confirm the epoch. -- **Docs.** Update the lazy-job lifecycle in `doc/concept/identity.md`: - the cold-start step no longer resets on lite-07. Also update the lite - draft's announce section where it mentions claims. - -Tests with controlled time cover: -- the first reader before and after the exact route lands, with no reader - at all, and with two workers at one prefix; -- a job restarting on one worker, and a request pinned to an ended stream; -- pending TRACK/SUBSCRIBE/FETCH at the front between its resolve and the - exact route landing, and late groups across the transition; -- conflicting epochs within one instance; -- independently ending prefix and exact routes (a claim ending before or - after its child), and late snapshots; -- multi-hop and diamond relays, and mixed versions with an older-lite hop; -- held catalog and media pins through a real player rather than route-table - checks, and the composer. - -Remove the cold-start warm-up reads (`claimed` in -`rs/moq-net-tests/tests/integration/restart.rs` and the -`rs/moq-net/tests/integration/drained_claim.rs` helper) for lite-07 and -assert that the first subscription succeeds. Keep them for moq-transport. -Run `just check`, `just test interop --all` and `just drafts check`. - -### Once the instance replaces ANNOUNCE_TAKEOVER - -Decided 2026-10-31 (maintainer, the /quest-complete requeue session). Kept -separate from the identity rewrite of this Plan, which landed with -[moq-dev/moq#7420](https://github.com/moq-dev/moq/pull/7420). -"We're replacing EPOCH with IDENTITY soon" (maintainer, 2026-10-31). The -broadcast "identity" was renamed `instance` on 2026-11-01 (maintainer, in -[the route API audit](https://github.com/moq-dev/moq/blob/1a6ac473d75fcbfd5d23bdbdfd64eb4750b077ad/quest/m1/api-audit/route/README.md), landed in -[moq-dev/moq#7489](https://github.com/moq-dev/moq/pull/7489)), so that -IDENTITY is now the instance. - -- Reword "takeover" as an event or operation, in terms of instance - continuity, in [moq-bench pins the instances it measures](/quest/m4/bench-epoch-pin.md) - (Goal and Plan) and [the HLS server follows each path once](/quest/m4/hls-follow-once.md) - (Goal and Tests). -- Rewrite the EPOCH references in `drafts/draft-lcurley-moq-cluster.md` and - `drafts/draft-lcurley-moq-epoch.md` in terms of the instance. -- Add a regression test where a publisher restart's new announcement - arrives before the old one's reset, on `solicit = false` moq-transport - sessions. [moq-dev/moq#7239](https://github.com/moq-dev/moq/pull/7239) - found that the subscriber (`rs/moq-net/src/ietf/subscriber.rs`, `attach`) - folds such a PUBLISH_NAMESPACE into the same epochless route as an update - instead of End then Start; per-announcement instances resolve it, and - the test proves they do. - -Public API: breaking; removes `Takeover` and the predecessor fields in every -language. Wire: removes lite-07's ANNOUNCE_TAKEOVER (0x5), adding nothing -beyond [Instance and epoch](https://github.com/moq-dev/moq/pull/7489)'s fields. - -## Related - -- [Claim answer epochs](https://github.com/moq-dev/moq/pull/5678) - why a restarted job must never be stitched to its predecessor -- [Retired requests](https://github.com/moq-dev/moq/pull/5294) - replacements never substitute content into held handles -- [Routes and announces](/quest/m2/cluster-routing/routes/README.md) - the route layer whose ANNOUNCE_START layout carries the instance diff --git a/quest/m2/cluster-routing/routes/README.md b/quest/m2/cluster-routing/routes/README.md index 05743abd669..e9ed07a3e65 100644 --- a/quest/m2/cluster-routing/routes/README.md +++ b/quest/m2/cluster-routing/routes/README.md @@ -20,7 +20,7 @@ claim whose withdrawal is already known. 5. ✅ Decided 2026-11-05 (maintainer, /quest-plan): **Floor regions have no order, and a repeated region is a violation.** A sorted order would need Rust's `Path` ordering reproduced exactly in JS. Decoding into a map rejects duplicates at the same cost. Superseded the same day by item 10, which removes regions. 6. ✅ Decided 2026-11-05 (maintainer, /quest-plan): **The Hop setup parameter and ANNOUNCE_OK's Hop ID are dropped from lite-07.** Their only roles were path-vector exclusion and the implicit trailing hop. ANNOUNCE_OK reserves no body for a route-key proof: [moq-dev/moq#7937](https://github.com/moq-dev/moq/pull/7937), accepted the same day, proves the route key once per session in SETUP. The live hop-based codec still switches in session integration, as choice 1 requires. 7. ✅ Decided 2026-11-05 (maintainer, /quest-plan): **An echo naming a Route ID that was never ended, or was already echoed, is a violation at the publisher.** The publisher can check this from its live and unacknowledged ids alone, with no growing state. -8. ✅ Decided 2026-11-05 (maintainer, /quest-plan): **ANNOUNCE_TAKEOVER keeps its shape, with a Route ID in place of hops, and its predecessor must be under the same route.** [quest/m2/announce-takeover.md](https://github.com/moq-dev/moq/blob/main/quest/m2/announce-takeover.md) deletes the message, so this keeps the change minimal. Decided 2026-11-06 (maintainer, /quest-complete on [moq-dev/moq#8023](https://github.com/moq-dev/moq/pull/8023)), asked whether session integration ports the message or lets lite-07 answer a claim as a restart or start until announce-takeover lands: ✅ **port it**, so a job answering a claim stays a takeover across relays. Rejected: accepting the restart and start. +8. ✅ Decided 2026-11-05 (maintainer, /quest-plan): **ANNOUNCE_TAKEOVER keeps its shape, with a Route ID in place of hops, and its predecessor must be under the same route.** [moq-dev/moq#8063](https://github.com/moq-dev/moq/pull/8063) deletes the message, so this keeps the change minimal. Decided 2026-11-06 (maintainer, /quest-complete on [moq-dev/moq#8023](https://github.com/moq-dev/moq/pull/8023)), asked whether session integration ports the message or lets lite-07 answer a claim as a restart or start until announce-takeover lands: ✅ **port it**, so a job answering a claim stays a takeover across relays. Rejected: accepting the restart and start. 9. ✅ Decided 2026-11-05 (maintainer, /quest-plan): **Unknown announce types stay skipped by length on lite-06+**, as before. An unknown reply type is refused. 10. ✅ Decided 2026-11-05 (maintainer, /quest-plan): **ANNOUNCE_FLOOR is a marker, not a region list:** `{Route ID, Revision}`. It closes a batch and certifies the stream's claims for that route across the whole requested prefix as the origin's at that Revision. A publisher that cannot vouch for the whole requested prefix at one Revision still closes the batch, with a floor that certifies nothing, encoded per item 13. Regions, the replacing list, and the 65,535-byte overflow go away. Accepted cost: a relay with partial knowledge of an origin certifies nothing for that route, so a receiver lets its stale claims go only on ROUTE_END. Rejected: one region per message; a multipart atomic list. 11. ✅ Decided 2026-11-05 (maintainer, /quest-plan): **Clamped claims collapse to the best one.** When several of a route's claims cover a request's prefix (`live` at cost 10 and `live/a` at cost 1, request `live/a/x`), the publisher presents only the most specific, and when it is withdrawn moves the presented claim to the next covering one with ANNOUNCE_UPDATE or ANNOUNCE_RESTART instead of ending it. No wire change; one live claim per prefix per route holds, and a receiver merges claims at the prefix each stream presents them. Rejected: carrying each claim's ancestor depth. diff --git a/quest/m2/lite07-finalize.md b/quest/m2/lite07-finalize.md index ed267863216..13d2589191c 100644 --- a/quest/m2/lite07-finalize.md +++ b/quest/m2/lite07-finalize.md @@ -117,7 +117,6 @@ timestamp bound in [#6168](https://github.com/moq-dev/moq/pull/6168). - [Lite-07 FETCH is single-group only](/quest/m2/subscribe-ranges/lite07-fetch.md) - lite-07 FETCH reads one group or a slice of one, and SUBSCRIBE ranges of whole groups - [Lite-07 by start](/quest/m2/time-addressing/lite07.md) - lite-07 carries group starts, previous-start links, GROUP_END, and time bounds instead of sequence numbers - [@moq/net by start](/quest/m2/time-addressing/js.md) - JS speaks the same lite-07 wire -- [A job's exact route continues its claim's readers](/quest/m2/announce-takeover.md) - the job's route inherits the claim's instance, with no new wire message - [Takeover](/quest/m1/stream-credit/takeover.md) - lite-07 lets a group stream take over for another group ## Related diff --git a/quest/m2/route-roles.md b/quest/m2/route-roles.md index b856f5b4562..a8c67fa0e86 100644 --- a/quest/m2/route-roles.md +++ b/quest/m2/route-roles.md @@ -37,7 +37,3 @@ put the comparison in the PR under `## Waiting` with a recommendation. After the maintainer picks, land the winner in JS, moq-ffi, moq-c, and the wrappers in the same PR. The route instance accessor's five spellings across bindings stay with [the exporter](/quest/m3/ffi-exporter.md). - -## Required - -- [A job's exact route continues its claim's readers](/quest/m2/announce-takeover.md) - changes how `Request::route` lends a claim's instance, which both shapes must keep diff --git a/quest/m3/release/runner-client-bump.md b/quest/m3/release/runner-client-bump.md index 16faea6b5d0..f2afee1b942 100644 --- a/quest/m3/release/runner-client-bump.md +++ b/quest/m3/release/runner-client-bump.md @@ -27,9 +27,9 @@ refuses timestamped frames, `Subscription` takes `max_delay` instead of a max age, announcement events are `Start`, `Update`, `Restart`, and `End`, and `track::Producer::used` is gone. Through moq-relay, the first subscription under `data-subgroup-basic`'s dynamic claim then fails -`unroutable`: the accepted cold-start reset that -[announce takeover](/quest/m2/announce-takeover.md) removes, so check -whether that has landed before calling it a client bug. The JS client's +`unroutable`: the accepted cold-start reset, which moq-transport keeps +([moq-dev/moq#8063](https://github.com/moq-dev/moq/pull/8063) removes it on +lite-07 only), so it is no client bug. The JS client's announcement check also moves from `"retracted"` to `"end"` when it takes the cut. Its `data-subgroup-basic` subscriber also stalls there: on `main`, `follow(path)` asks SUBSCRIBE_NAMESPACE for the diff --git a/quest/m4/README.md b/quest/m4/README.md index 1c6226e9c91..46f2592a7c0 100644 --- a/quest/m4/README.md +++ b/quest/m4/README.md @@ -69,7 +69,7 @@ deleted. - [The API diff reports GStreamer element properties](/quest/m4/api-diff-gst.md) - `just api-diff` lists added, removed, and changed moq-gst properties - [Hand-written wrappers reach every FFI item](/quest/m4/binding-export-coverage.md) - Python, Go, and Swift fail a test when an FFI item has no wrapper path, as Kotlin does - [The HLS server follows each path once](/quest/m4/hls-follow-once.md) - `Following` reports synced absence, so moq-hls resolves with one follow -- [moq-bench pins the instances it measures](/quest/m4/bench-epoch-pin.md) - pooled requests pin their announced epoch, so a takeover is not counted as the same broadcast +- [moq-bench pins the instances it measures](/quest/m4/bench-epoch-pin.md) - pooled requests pin their announced instance, so another instance at the path is not counted as the same broadcast - [moq-bench reports sub-millisecond latency](/quest/m4/bench-latency-resolution.md) - steady-window latency resolves finer than 1 ms - [Benchmark series stay meaningful](/quest/m4/bench-hygiene.md) - a warm `burst` variant, stale series pruned, and slow-group `bytes` kept out of series names - [Re-run the SPS fuzzer on h264-reader](/quest/m4/sps-fuzz-h264-reader.md) - the H.264 SPS overflow is rechecked on the new parser diff --git a/quest/m4/bench-epoch-pin.md b/quest/m4/bench-epoch-pin.md index c7ac12d3219..26c4c324cfc 100644 --- a/quest/m4/bench-epoch-pin.md +++ b/quest/m4/bench-epoch-pin.md @@ -2,15 +2,15 @@ ## Goal -moq-bench requests each pooled broadcast pinned to the epoch it saw -announced, so a takeover between gathering and requesting is not counted as -the same broadcast. +moq-bench requests each pooled broadcast pinned to the instance it saw +announced, so another instance taking the path between gathering and +requesting is not counted as the same broadcast. ## Plan Decided 2026-10-29 (maintainer), a follow-up of [moq-dev/moq#7192](https://github.com/moq-dev/moq/pull/7192). Pass the -announced epoch to `request`. A pinned request that finds another instance is -refused; count that as a takeover, not a failure. +announced instance to `request`. A pinned request that finds another instance is +refused; count that as an instance change, not a failure. Public API: none. Wire: none. diff --git a/quest/m4/hls-follow-once.md b/quest/m4/hls-follow-once.md index 88992d2b0f5..6f9952b3ae6 100644 --- a/quest/m4/hls-follow-once.md +++ b/quest/m4/hls-follow-once.md @@ -3,8 +3,8 @@ ## Goal The moq-hls server resolves a path once, with one `follow`, instead of a -`request` for the first answer and a separate follower for takeovers. It -still reports a path nothing serves once discovery has synced. +`request` for the first answer and a separate follower for each new instance +at the path. It still reports a path nothing serves once discovery has synced. ## Plan @@ -15,8 +15,8 @@ nothing serves this path". Add that report to `Following`, the synced absence `request` already answers with `Unroutable`, without making a follower give up. Then move the server onto it. After the cut, since the double resolve works. -Tests: an unserved path is reported once discovery syncs, a takeover is -followed without a second resolution, and the hand-written resolve timeout -stays only a limit on a handler that never answers. +Tests: an unserved path is reported once discovery syncs, a new instance at +the path is followed without a second resolution, and the hand-written +resolve timeout stays only a limit on a handler that never answers. Public API: additive in moq-net. Wire: none. diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index a5b6fa89451..63c142389b0 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -2124,10 +2124,15 @@ pub extern "C" fn moq_route_broadcast(route: u32) -> i32 { }) } -/// The stream a route serves, minted at creation unless its options named one. +/// The stream a route serves now. /// -/// Kept across [moq_route_withdraw] and [moq_route_announce]. The destination borrows the -/// route's storage until [moq_route_free]. +/// Named options keep one instance. Unnamed, a prefix route moves to a fresh one each time a +/// job holding its current one ends. A route from [moq_broadcast_request_route] takes its +/// prefix route's current one when it first announces or when this first reads it. Read first, +/// the job announces under it, and [moq_route_announce] fails with an unroutable error once the +/// prefix route has moved past it, so a signer built from it stays valid. +/// Withdrawing and announcing again keep it. The destination borrows the route's storage +/// until the next [moq_route_instance] on the route or [moq_route_free]. /// /// Returns zero on success, or a negative code on failure. /// @@ -2226,7 +2231,8 @@ pub unsafe extern "C" fn moq_broadcast_request_path(request: u32, dst: *mut moq_ /// Serve a held consumer under the prefix route's instance without announcing an exact route. /// /// Every answer at this path must continue the same sequences and bytes. Start a new -/// prefix route for different content, or route a job with its own instance. +/// prefix route for different content, or route a job, whose end moves the prefix route to +/// another instance. /// Consumes the request handle; the consumer remains owned by the caller. /// Returns zero on success, or a negative error. #[unsafe(no_mangle)] @@ -2247,8 +2253,11 @@ pub extern "C" fn moq_broadcast_request_serve(request: u32, broadcast: u32) -> i /// Answer a broadcast request with the path's own exact route, owning a new broadcast. /// /// Consumes the request handle and returns a positive route handle, or a negative error. -/// The route serves a fresh instance unless `options` names one, so a job that restarts is -/// a new stream. Created visible, it announces now and answers the waiting requesters; +/// Unless `options` names an instance, the route takes the one its prefix route lends when +/// it first announces or [moq_route_instance] first reads it, so readers that resolved the path through the prefix route continue +/// on it; its end then moves the prefix route to a fresh instance, so a job that restarts is +/// a new stream. A prefix route that names its instance lends none, and the route mints its +/// own. Created visible, it announces now and answers the waiting requesters; /// created hidden, they wait until [moq_route_announce], so the job can fill its catalog /// first, and freeing the route before then refuses them. Ending the route withdraws only /// this path. Unreadable `options` fail without consuming the request. diff --git a/rs/moq-c/src/origin.rs b/rs/moq-c/src/origin.rs index 481ca1d27f2..4555090edd6 100644 --- a/rs/moq-c/src/origin.rs +++ b/rs/moq-c/src/origin.rs @@ -150,7 +150,7 @@ impl State { struct RouteEntry { inner: moq_net::origin::Route, driver: crate::ffi::Background, - /// Minted at creation and kept for the route's lifetime, borrowed by [Origin::route_instance]. + /// The instance last read, borrowed by [Origin::route_instance]. instance: String, /// An exact route's broadcast fill handle, or a prefix route's request delivery task. kind: RouteKind, @@ -409,8 +409,9 @@ impl Origin { driver: crate::ffi::Background, broadcast: Id, ) -> Result { + // Read only on request: reading a hidden job's instance binds it. self.routes.insert(RouteEntry { - instance: route.instance().to_string(), + instance: String::new(), inner: route, driver, kind: RouteKind::Exact { broadcast }, @@ -519,8 +520,10 @@ impl Origin { } } - pub fn route_instance(&self, route: Id, dst: &mut moq_string) -> Result<(), Error> { - let entry = self.route_entry(route)?; + pub fn route_instance(&mut self, route: Id, dst: &mut moq_string) -> Result<(), Error> { + let entry = self.routes.get_mut(route).ok_or(Error::NotFound)?; + entry.driver.check()?; + entry.instance = entry.inner.instance().to_string(); *dst = moq_string { data: entry.instance.as_ptr().cast::(), len: entry.instance.len(), diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 69276b8603f..278f626e63b 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -4140,19 +4140,22 @@ fn dynamic_serves_a_request_under_a_prefix() { crate::error::MOQ_ERROR_UTF8 ); - // A hidden job route answers nobody until it announces, under its own fresh instance. + // A hidden job route answers nobody until it announces, under the instance its prefix + // route lends. let job = id(unsafe { moq_broadcast_request_route(request, &hidden()) }); assert!( unsafe { moq_broadcast_request_route(request, std::ptr::null()) } < 0, "request is consumed" ); - assert_ne!(instance_of(job), instance_of(dynamic)); + let lent = instance_of(dynamic); + assert_eq!(instance_of(job), lent); let served = id(moq_route_broadcast(job)); assert_eq!(moq_route_announce(job), 0); assert!(req_cb.recv() > 0); req_cb.recv_terminal(); - // The answer's route is the job's to withdraw. + // The answer's route is the job's to withdraw, which moves the prefix route on. assert_eq!(moq_route_withdraw(job), 0); + assert_ne!(instance_of(dynamic), lent); assert_eq!(moq_route_free(job), 0); assert_eq!(moq_route_free(dynamic), 0); diff --git a/rs/moq-cli/tests/integration/export.rs b/rs/moq-cli/tests/integration/export.rs index 968257e22dd..fb1cdc45559 100644 --- a/rs/moq-cli/tests/integration/export.rs +++ b/rs/moq-cli/tests/integration/export.rs @@ -1066,9 +1066,7 @@ async fn next_instance(announced: &mut moq_net::origin::Routes) -> moq_net::Inst kio::clock::timeout(TIMEOUT, async { loop { match announced.next().await.expect("the relay stays up") { - moq_net::origin::RouteEvent::Start(announce) - | moq_net::origin::RouteEvent::Restart(announce) - | moq_net::origin::RouteEvent::Takeover { announce, .. } => { + moq_net::origin::RouteEvent::Start(announce) | moq_net::origin::RouteEvent::Restart(announce) => { return announce.route.instance().clone(); } _ => continue, diff --git a/rs/moq-cli/tests/integration/instances.rs b/rs/moq-cli/tests/integration/instances.rs index 90c15ddb30e..029df508218 100644 --- a/rs/moq-cli/tests/integration/instances.rs +++ b/rs/moq-cli/tests/integration/instances.rs @@ -283,3 +283,148 @@ versions! { lite06: "moq-lite-06", ietf22: "moq-transport-22", } + +/// A worker claiming `live`, answering each request with a job's exact route: the catalog, +/// which names `./media` under the job's instance, and the media it references. +struct Worker { + claim: origin::Route, + jobs: std::sync::Arc>>, +} + +/// One answered request, held so its route stays advertised. +struct Job { + path: String, + route: origin::Route, + _catalog: Option, + media: Option, +} + +impl Worker { + fn new(origin: &origin::Producer) -> Self { + let claim = origin.route_prefix("live", origin::RouteOptions::default()).unwrap(); + let jobs = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let (handler, answered) = (claim.clone(), jobs.clone()); + tokio::spawn(async move { + while let Some(request) = handler.requested().await { + let path = request.path().to_string(); + let route = request.route(origin::RouteOptions::hidden()).unwrap(); + // Hidden, the job reports the instance it will announce under: the claim's. + let instance = route.instance(); + let (catalog, media) = match path.as_str() { + "live/catalog.hang" => { + let mut catalog = + moq_mux::catalog::Producer::new(&mut route.broadcast().clone(), Default::default()) + .unwrap(); + catalog + .mutate(|catalog| { + let mut audio = + hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); + audio.broadcast = Some(moq_net::path::Relative::new("./media").unwrap()); + audio.instance = Some(instance); + catalog.audio.renditions.insert("audio".into(), audio); + }) + .unwrap(); + (Some(catalog), None) + } + _ => { + let media = route + .broadcast() + .create_track( + "audio", + track::Info::default().with_timescale(moq_net::Timescale::MILLI), + ) + .unwrap(); + write(&media, "job:0"); + (None, Some(media)) + } + }; + route.announce().unwrap(); + answered.lock().unwrap().push(Job { + path, + route, + _catalog: catalog, + media, + }); + } + }); + Self { claim, jobs } + } + + fn answered(&self) -> Vec { + self.jobs.lock().unwrap().iter().map(|job| job.path.clone()).collect() + } +} + +/// A player's first catalog read through a worker's claim continues on the job's exact route +/// over a real lite-07 relay, and the catalog's pinned media resolves through the claim to the +/// media job under the same instance. Once that media job ends the claim moves on, so the +/// held pin is refused rather than answered by a restarted job. +#[tokio::test] +async fn a_player_reads_through_a_claim_on_its_first_request() { + let relay = relay().await; + let worker_origin = moq_tokio::origin::spawn(); + let worker = Worker::new(&worker_origin); + let lent = worker.claim.instance(); + let _worker_connection = client(LITE_07) + .with_publisher(&worker_origin) + .connect(relay.url.clone()) + .established() + .await + .unwrap(); + let viewer_origin = moq_tokio::origin::spawn(); + let viewer = viewer_origin.consume(); + let mut routes = viewer.routes(); + let _viewer_connection = client(LITE_07) + .with_subscriber(viewer_origin) + .connect(relay.url.clone()) + .established() + .await + .unwrap(); + assert!(matches!(routes.next().await, Some(origin::RouteEvent::Start(_)))); + + let held = viewer.request("live/catalog.hang", None).await.unwrap(); + let source = moq_mux::Source::new(viewer.clone(), held); + let mut catalog = source + .catalog::<()>(moq_mux::catalog::CatalogFormat::Hang) + .await + .unwrap(); + let snapshot = catalog.next().await.unwrap().unwrap(); + let reference = &snapshot.audio.renditions["audio"]; + assert_eq!( + reference.instance.as_ref(), + Some(&lent), + "the job serves the claim's instance" + ); + let pinned = source + .resolve(reference.broadcast.as_ref(), reference.instance.as_ref()) + .await + .unwrap(); + let mut subscription = pinned.track("audio").unwrap().subscribe(None).await.unwrap(); + assert_eq!(read(&mut subscription).await, "job:0"); + assert_eq!(worker.answered(), ["live/catalog.hang", "live/media"]); + + // The media job ends: the claim moves on, and nothing answers the old pin again. + let media = { + let mut jobs = worker.jobs.lock().unwrap(); + let index = jobs.iter().position(|job| job.media.is_some()).unwrap(); + jobs.remove(index) + }; + media.route.broadcast().close(); + drop(media); + assert_ne!(worker.claim.instance(), lent); + loop { + match routes.next().await.unwrap() { + origin::RouteEvent::End(end) if end.prefix.as_str() == "live/media" => break, + _ => {} + } + } + assert!( + viewer.request("live/media", lent).await.is_err(), + "a request pinned to the ended job resolved" + ); + assert_eq!( + worker.answered(), + ["live/catalog.hang"], + "a pinned request woke the worker" + ); +} diff --git a/rs/moq-cli/tests/integration/rtsp.rs b/rs/moq-cli/tests/integration/rtsp.rs index 685939d52f4..f7dcdd41d5f 100644 --- a/rs/moq-cli/tests/integration/rtsp.rs +++ b/rs/moq-cli/tests/integration/rtsp.rs @@ -65,9 +65,7 @@ async fn next_broadcast( .await .expect("origin closed") { - moq_net::origin::RouteEvent::Start(_) - | moq_net::origin::RouteEvent::Restart(_) - | moq_net::origin::RouteEvent::Takeover { .. } => break, + moq_net::origin::RouteEvent::Start(_) | moq_net::origin::RouteEvent::Restart(_) => break, moq_net::origin::RouteEvent::End(_) | moq_net::origin::RouteEvent::Update(_) => {} } } diff --git a/rs/moq-ffi/src/origin.rs b/rs/moq-ffi/src/origin.rs index 36b262da812..d1b252fc084 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -152,8 +152,8 @@ pub struct MoqRoute { reads: Polled, /// An exact route's broadcast, wrapped once so every caller shares its catalog. broadcast: Option>, - /// Minted at creation and kept for the route's lifetime. - instance: String, + /// The instance last read, reported once the route is cancelled. + instance: std::sync::Mutex, driver: Background, } @@ -440,8 +440,15 @@ impl MoqRoute { } fn new(route: moq_net::origin::Route, broadcast: Option>, driver: Background) -> Self { + // Reading a hidden job's instance binds it, so one is cached only once it announces or + // is read. + let instance = if route.is_announced() || broadcast.is_none() { + route.instance().to_string() + } else { + String::new() + }; Self { - instance: route.instance().to_string(), + instance: std::sync::Mutex::new(instance), reads: Polled::new(route).supervised(driver.clone()), broadcast, driver, @@ -469,13 +476,24 @@ impl MoqRoute { }) } - /// The stream this route serves, minted at creation unless the options named one. + /// The stream this route serves now, or once cancelled, the last one it reported or + /// announced under (empty for a hidden route that did neither). + /// + /// Named options keep one instance. Unnamed, a prefix route moves to a fresh one each + /// time a job holding its current one ends. A job's route takes its prefix route's current + /// one when it first announces or when this first reads it. Read first, the job announces + /// under it, and [`MoqRoute::announce`] fails with `Unroutable` once the prefix route has + /// moved past it, so a signer built from it stays valid. /// /// Named `instance_id` rather than `instance` because the generated C and C++ /// objects keep their handle in a private field named `instance`; the wrappers /// expose it as `instance`. pub fn instance_id(&self) -> String { - self.instance.clone() + let mut last = self.instance.lock().unwrap(); + if let Some(current) = self.reads.peek(|route| route.instance().to_string()) { + *last = current; + } + last.clone() } /// Make the route visible, or keep it so. @@ -485,7 +503,12 @@ impl MoqRoute { /// `Unroutable` when a request's route can never announce because the request was /// released or its prefix route retracted meanwhile. pub fn announce(&self) -> Result<(), MoqError> { - self.with(|route| route.announce()) + let instance = self.with(|route| { + route.announce()?; + Ok(route.instance().to_string()) + })?; + *self.instance.lock().unwrap() = instance; + Ok(()) } /// Hide the route from everyone, keeping its broadcast and instance: `announce` brings the @@ -553,8 +576,11 @@ impl MoqBroadcastRequest { /// Answer with the path's own exact route, owning a new broadcast. /// - /// The route serves a fresh instance unless `options` names one, so a job that restarts - /// is a new stream. Created visible, it announces now and answers the waiting + /// Unless `options` names an instance, the route takes the one its prefix route lends + /// when it first announces or [`MoqRoute::instance_id`] first reads it, so readers that resolved the path through the prefix route + /// continue on it; its end then moves the prefix route to a fresh instance, so a job that + /// restarts is a new stream. A prefix route that names its instance lends none, and the + /// route mints its own. Created visible, it announces now and answers the waiting /// requesters; created hidden, they wait until [`MoqRoute::announce`], so the job can /// fill its catalog first, and cancelling the route before then refuses them. Ending the /// route withdraws only this path. @@ -571,7 +597,8 @@ impl MoqBroadcastRequest { /// Serve a held broadcast under the prefix route's instance without announcing an exact route. /// /// Every answer at this path must continue the same sequences and bytes. Start a new - /// prefix route for different content, or route a job with its own instance. + /// prefix route for different content, or route a job, whose end moves the prefix route + /// to another instance. pub fn serve(&self, broadcast: &Arc) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); self.driver.check()?; diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 269284b3613..d2473f8919b 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -5040,7 +5040,7 @@ async fn dynamic_broadcast_request() { let job = request.route(hidden()).unwrap(); let served = job.broadcast().unwrap(); let track = served.publish_track("status".into(), Some(timed_info())).unwrap(); - // Only announce answers the requester, under a fresh instance and after admission. + // Only announce answers the requester, under the claim's instance and after admission. let instance = job.instance_id(); job.announce().unwrap(); // A re-price keeps the instance the answer was announced under. @@ -5071,8 +5071,12 @@ async fn dynamic_broadcast_request() { assert_eq!(frame.payload, payload); assert_eq!(frame.timestamp.map(micros), Some(20_000)); + // The job carried the claim's instance, and its end moves the claim on. + assert_eq!(dynamic.instance_id(), instance); track.finish().unwrap(); served.close().unwrap(); + job.cancel(); + assert_ne!(dynamic.instance_id(), instance); } /// A prefix serves requests beneath it; cancelling the handle diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index 46bd3c189ee..cd22311333c 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -5000,9 +5000,7 @@ mod session_tests { await_event(&mut follow, |event| { matches!( event, - moq_net::origin::RouteEvent::Restart(_) - | moq_net::origin::RouteEvent::Takeover { .. } - | moq_net::origin::RouteEvent::Start(_) + moq_net::origin::RouteEvent::Restart(_) | moq_net::origin::RouteEvent::Start(_) ) }); diff --git a/rs/moq-net-tests/tests/integration/restart.rs b/rs/moq-net-tests/tests/integration/restart.rs index 3dcd2cfffd3..b6c6a776042 100644 --- a/rs/moq-net-tests/tests/integration/restart.rs +++ b/rs/moq-net-tests/tests/integration/restart.rs @@ -639,7 +639,8 @@ async fn a_more_specific_source_cannot_fill_a_held_covering_track() { } /// A worker claiming `pool` through the public API: each request is answered with its own -/// exact route under a fresh instance, and `outputs[path]` holds the latest. +/// exact route, which carries the claim's instance until it ends, and `outputs[path]` +/// holds the latest. struct Pool { _claim: Rc, outputs: Rc>>, @@ -679,14 +680,17 @@ impl Pool { } } -/// Wake the worker for `path` through the claim and subscribe. Wake the worker first and -/// subscribe through the exact route, so the test reads the same on every wire, whether -/// it carries the answer as a takeover or as an ordinary announcement that resets the -/// first subscription through the claim. -async fn claimed(origin: &origin::Producer, path: &str) -> (broadcast::Consumer, track::Subscriber) { - let cold = origin.consume().request(path, None).await.unwrap(); - drop(cold.track("video").unwrap().subscribe(None).await); - settle().await; +/// Subscribe to `path` through the claim. On moq-lite-07 the answer's exact route carries +/// the claim's instance, so the first subscription continues on it. Older wires and +/// moq-transport mint an instance per announcement, so the answer is another broadcast that +/// resets the first subscription: wake the worker first and subscribe through the exact +/// route. +async fn claimed(version: Version, origin: &origin::Producer, path: &str) -> (broadcast::Consumer, track::Subscriber) { + if version != "moq-lite-07-wip".parse().unwrap() { + let cold = origin.consume().request(path, None).await.unwrap(); + drop(cold.track("video").unwrap().subscribe(None).await); + settle().await; + } subscribe(origin, path).await } @@ -703,11 +707,11 @@ async fn claim_restart(version: &str) { let pool = Pool::new(&worker); settle().await; - let (_sibling, mut sibling) = claimed(&edge, "pool/q").await; + let (_sibling, mut sibling) = claimed(version, &edge, "pool/q").await; pool.write("pool/q", "q:0"); assert_eq!(read(&mut sibling).await, "q:0", "{version}"); - let (old, mut first) = claimed(&edge, "pool/p").await; + let (old, mut first) = claimed(version, &edge, "pool/p").await; for group in 0..3 { pool.write("pool/p", &format!("old:{group}")); assert_eq!(read(&mut first).await, format!("old:{group}"), "{version}"); @@ -716,7 +720,7 @@ async fn claim_restart(version: &str) { pool.close("pool/p"); settle().await; - let (fresh, mut returning) = claimed(&edge, "pool/p").await; + let (fresh, mut returning) = claimed(version, &edge, "pool/p").await; assert!(!fresh.is_clone(&old), "{version}: joined the old output"); assert_eq!(*pool.answered.borrow(), 3, "{version}: the worker was not asked again"); pool.write("pool/p", "new:0"); diff --git a/rs/moq-net/benches/announce.rs b/rs/moq-net/benches/announce.rs index 55372555a20..183fc21f188 100644 --- a/rs/moq-net/benches/announce.rs +++ b/rs/moq-net/benches/announce.rs @@ -36,8 +36,7 @@ fn scanned(events: Vec) -> Vec { let (update, active) = match event { origin::RouteEvent::Start(update) | origin::RouteEvent::Update(update) - | origin::RouteEvent::Restart(update) - | origin::RouteEvent::Takeover { announce: update, .. } => (update, true), + | origin::RouteEvent::Restart(update) => (update, true), origin::RouteEvent::End(update) => (update, false), }; initial.retain(|held| held.prefix != update.prefix); diff --git a/rs/moq-net/benches/auth.rs b/rs/moq-net/benches/auth.rs index 0fd147bc1c2..163be6fb058 100644 --- a/rs/moq-net/benches/auth.rs +++ b/rs/moq-net/benches/auth.rs @@ -308,7 +308,6 @@ fn waiting(c: &mut Criterion) { moq_net::origin::RouteEvent::Start(update) | moq_net::origin::RouteEvent::Update(update) | moq_net::origin::RouteEvent::Restart(update) - | moq_net::origin::RouteEvent::Takeover { announce: update, .. } if update.prefix.as_str().starts_with(&prefix) => { seen.insert(update.prefix); diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 3784a59ee51..67000f6f818 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -29,9 +29,8 @@ //! Readers draining a swapped track are swept over their count, the groups every //! route delivered, and the routes each reader still holds a copy of. //! -//! A claim's answer taking over the claim is swept over the paths the claim already -//! answered and the cursors watching it, and a takeover in place over the fronts -//! resolved through what it takes over. +//! A claim's answer, and its retraction moving the claim to a fresh instance, is swept +//! over the paths the claim already answered and the cursors watching it. //! //! Peer claims land through the hidden `fuzz` module, as a session lands them. //! @@ -669,12 +668,12 @@ fn answer(claim: &origin::Route, path: &str) -> origin::Route { } } -/// A claim's answer delivered to every cursor as a takeover of the claim, then retracted. -/// Swept over the paths the claim already answered and the cursors watching it, so a -/// continuity check that walks the table rather than the claim's prefix shows up as a -/// slope. -fn bench_takeover(c: &mut Criterion) { - let mut group = c.benchmark_group("origin/takeover"); +/// A claim's answer delivered to every cursor as a start, then retracted, which moves the +/// claim to a fresh instance. Swept over the paths the claim already answered and the +/// cursors watching it, so finding the lending claim by walking the table rather than the +/// path's covering routes shows up as a slope. +fn bench_lend(c: &mut Criterion) { + let mut group = c.benchmark_group("origin/lend"); for (publishers, subscribers) in SHAPES { group.bench_function( BenchmarkId::from_parameter(format!("{publishers}p_{subscribers}s")), @@ -701,12 +700,17 @@ fn bench_takeover(c: &mut Criterion) { let (request, output) = ask(&format!("room/incoming{n}")); for cursor in &mut cursors { let event = cursor.next().now_or_never().flatten(); - assert!(matches!(event, Some(origin::RouteEvent::Takeover { .. })), "{event:?}"); + assert!(matches!(event, Some(origin::RouteEvent::Start(_))), "{event:?}"); } drop(request); drop(output); for cursor in &mut cursors { cursor.next().now_or_never().flatten().expect("retract delivered"); + cursor + .next() + .now_or_never() + .flatten() + .expect("the claim's advance delivered"); } }); }, @@ -983,43 +987,6 @@ fn bench_restart_fronts(c: &mut Criterion) { group.finish(); } -/// A pool member taken over in place and retired, under `fronts` fronts resolved -/// through it, against the same member restarting without a takeover. Each change -/// pokes every front beneath the prefix, and the takeover carries each one's verdict -/// forward under the table lock, so the takeover's cost over the restart is that pass. -/// The fronts' tasks never run: this is the install alone. -fn bench_takeover_fronts(c: &mut Criterion) { - let mut group = c.benchmark_group("origin/takeover_fronts"); - for fronts in [100, 1_000, 10_000] { - for takeover in [false, true] { - let kind = if takeover { "takeover" } else { "restart" }; - group.bench_function(format!("{fronts}f_{kind}"), |b| { - let mut pool = pool(1, fronts, 0); - let mut last = pool._members.pop().expect("one member"); - let mut hop = POOL_HOP + 1; - b.iter(|| { - hop += 1; - let next = match takeover { - true => moq_net::fuzz::takeover_route( - &pool.producer, - moq_net::Path::from_static("pool"), - pool_route(hop), - &last, - ), - false => moq_net::fuzz::requests_route( - &pool.producer, - moq_net::Path::from_static("pool"), - pool_route(hop), - ), - }; - drop(std::mem::replace(&mut last, next.unwrap())); - }); - }); - } - } - group.finish(); -} - /// A member joining and leaving a pool that already serves `paths` paths. /// /// Every front under the prefix watches the routes covering it, so each one @@ -1860,14 +1827,13 @@ criterion_group!( bench_announce_duplicate, bench_reprice_duplicate, bench_announce_fronts, - bench_takeover, + bench_lend, bench_serve_idle, bench_subscribe, bench_request, bench_retire, bench_restart_fronts, bench_pool_churn, - bench_takeover_fronts, bench_pool_cursors, bench_handoff, bench_narrow_delivery, diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 1dcd4ff6cae..20548c21a4c 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -476,18 +476,7 @@ pub fn requests_route( prefix: impl Into, route: crate::origin::RouteInfo, ) -> Result { - origin.forward(prefix, route, None) -} - -/// Land a claim taking over the peer's `predecessor` claim, as a session lands an -/// ANNOUNCE_TAKEOVER, for the origin bench. -pub fn takeover_route( - origin: &crate::origin::Producer, - prefix: impl Into, - route: crate::origin::RouteInfo, - predecessor: &crate::origin::Route, -) -> Result { - origin.forward(prefix, route, Some(predecessor)) + origin.forward(prefix, route) } /// Replace a peer claim's route in place, as a session does on a route update. diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index daab6e47e9d..c8a18f6278e 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -4140,8 +4140,7 @@ impl Publisher { match event { crate::origin::RouteEvent::Start(update) | crate::origin::RouteEvent::Update(update) - | crate::origin::RouteEvent::Restart(update) - | crate::origin::RouteEvent::Takeover { announce: update, .. } => { + | crate::origin::RouteEvent::Restart(update) => { initial.insert(update.prefix.clone(), update); } crate::origin::RouteEvent::End(update) => { @@ -5910,11 +5909,6 @@ impl ForwardNamespaces { (update, true, false) } crate::origin::RouteEvent::Restart(update) => (update, true, true), - // moq-transport has no takeover: another instance, in place or not. - crate::origin::RouteEvent::Takeover { announce, predecessor } => { - let restart = predecessor == announce.prefix; - (announce, true, restart) - } crate::origin::RouteEvent::End(update) => (update, false, false), }))); } @@ -11407,10 +11401,7 @@ mod serve_tests { for version in ALL_VERSIONS { let mut h = serve(version); - let claim = h - ._origin - .forward("live", crate::origin::RouteInfo::default(), None) - .unwrap(); + let claim = h._origin.forward("live", crate::origin::RouteInfo::default()).unwrap(); let auth = crate::auth::Link::new(false); auth.handle().authorize(¬hing()); h.publisher = h.publisher.clone().with_auth(auth); @@ -11430,10 +11421,7 @@ mod serve_tests { for version in ALL_VERSIONS { let mut h = serve(version); - let claim = h - ._origin - .forward("live", crate::origin::RouteInfo::default(), None) - .unwrap(); + let claim = h._origin.forward("live", crate::origin::RouteInfo::default()).unwrap(); let auth = crate::auth::Link::new(false); h.publisher = h.publisher.clone().with_auth(auth.clone()); @@ -11750,10 +11738,7 @@ mod serve_tests { let version = Version::Draft22; for path in [".", "./child", ".session", ".session/extension"] { let h = serve(version); - let claim = h - ._origin - .forward("", crate::origin::RouteInfo::default(), None) - .unwrap(); + let claim = h._origin.forward("", crate::origin::RouteInfo::default()).unwrap(); settle().await; let stream = Stream::open(&crate::driver::Transport::new(h.session.clone()), version) .await @@ -13899,11 +13884,7 @@ mod serve_tests { { *namespace = crate::Path::from_static("lobby"); } - Some( - h._origin - .forward("lobby", crate::origin::RouteInfo::default(), None) - .unwrap(), - ) + Some(h._origin.forward("lobby", crate::origin::RouteInfo::default()).unwrap()) } FetchParked::Responding => None, }; @@ -15511,11 +15492,7 @@ mod serve_tests { let origin = crate::origin::Config::new(std::num::NonZeroU64::new(1).unwrap()).produce(); let (claim, published) = match parked { Parked::Resolving => ( - Some( - origin - .forward("room", crate::origin::RouteInfo::default(), None) - .unwrap(), - ), + Some(origin.forward("room", crate::origin::RouteInfo::default()).unwrap()), None, ), Parked::Acking | Parked::Ending => { @@ -15752,9 +15729,7 @@ mod serve_tests { let mut idle = None; match setup { Setup::Resolving => { - let route = origin - .forward("room", crate::origin::RouteInfo::default(), None) - .unwrap(); + let route = origin.forward("room", crate::origin::RouteInfo::default()).unwrap(); let request = { let requested = std::pin::pin!(route.requested()); match futures::future::select(requested, serving.as_mut()).await { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 1da3ff9fa20..c33f213180e 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2865,7 +2865,7 @@ impl Subscriber { if self.going_away.is_set() { route.cost = crate::origin::Cost::DRAIN; } - entry.dynamic = Some(self.origin.forward(path, route, None)?); + entry.dynamic = Some(self.origin.forward(path, route)?); entry.generation += 1; tracing::info!(route = %crate::path::Rooted(self.origin.root(), path), "namespace authorized again"); Ok(Some(entry.generation)) @@ -2958,7 +2958,7 @@ impl Subscriber { return Ok(()); } // Propagates Error::Unauthorized if the namespace is out of scope. - let dynamic = self.origin.forward(&path, route.clone(), None)?; + let dynamic = self.origin.forward(&path, route.clone())?; entry.insert(BroadcastState { route: route.clone(), @@ -10843,8 +10843,7 @@ mod tests { crate::origin::RouteEvent::End(_) => None, crate::origin::RouteEvent::Start(announce) | crate::origin::RouteEvent::Update(announce) - | crate::origin::RouteEvent::Restart(announce) - | crate::origin::RouteEvent::Takeover { announce, .. } => Some(announce.route), + | crate::origin::RouteEvent::Restart(announce) => Some(announce.route), } } diff --git a/rs/moq-net/src/lite/announce.rs b/rs/moq-net/src/lite/announce.rs index be5f324ad66..417a7524c02 100644 --- a/rs/moq-net/src/lite/announce.rs +++ b/rs/moq-net/src/lite/announce.rs @@ -21,8 +21,6 @@ const ANNOUNCE_UPDATE: u64 = 2; // lite-07: the advertisement serves another instance, or a newer epoch of its own. const ANNOUNCE_RESTART: u64 = 3; const SYNCED: u64 = 4; -// lite-07: a new advertisement continues what a live one served beneath its prefix. -const ANNOUNCE_TAKEOVER: u64 = 5; // lite-07: the route layer. ROUTE_END also travels back as the echo. const ROUTE_START: u64 = 6; const ROUTE_UPDATE: u64 = 7; @@ -47,10 +45,9 @@ pub fn update_supported(version: Version) -> bool { /// An announcement on the Announce Stream, advertising or retracting a broadcast. /// /// On lite-06+ these are independently-typed messages (`ANNOUNCE_START`, -/// `ANNOUNCE_END`, `ANNOUNCE_UPDATE`, and on lite-07 `ANNOUNCE_RESTART` and -/// `ANNOUNCE_TAKEOVER`), each +/// `ANNOUNCE_END`, `ANNOUNCE_UPDATE`, and on lite-07 `ANNOUNCE_RESTART`), each /// framed as `Type | Length | Body` like the subscribe stream's responses. Each -/// `Active` (ANNOUNCE_START) or `Takeover` implicitly assigns the next announce id (a +/// `Active` (ANNOUNCE_START) implicitly assigns the next announce id (a /// per-stream ordinal starting at 0); the others reference that id instead of /// repeating the path. Older versions send a single `ANNOUNCE_BROADCAST` /// message that retracts by path (`Ended`). @@ -92,17 +89,6 @@ pub enum AnnounceBroadcast { epoch: u64, cost: Cost, }, - /// ANNOUNCE_TAKEOVER (lite-07+): an `Active` that continues what the live - /// announcement `predecessor` served beneath this one's path. It assigns the next - /// announce id; one at the predecessor's own path retires the predecessor. - Takeover { - predecessor: u64, - suffix: PathRef, - route: u64, - instance: Option, - epoch: u64, - cost: Cost, - }, /// An unknown lite-06+ announce type. The length-prefixed body was skipped so /// the stream stays up; it does not assign an announce id. Skipped, @@ -292,9 +278,6 @@ impl Encode for AnnounceBroadcast { Self::Restart { .. } if version.has_announce_restart() => ANNOUNCE_RESTART, // Older versions send an end and a start instead. Self::Restart { .. } => return Err(EncodeError::Version), - Self::Takeover { .. } if version.has_announce_takeover() => ANNOUNCE_TAKEOVER, - // Older versions send a start instead. - Self::Takeover { .. } => return Err(EncodeError::Version), Self::RouteStart { .. } if version.has_routes() => ROUTE_START, Self::RouteUpdate { .. } if version.has_routes() => ROUTE_UPDATE, Self::RouteHold { .. } if version.has_routes() => ROUTE_HOLD, @@ -344,20 +327,6 @@ impl Encode for AnnounceBroadcast { super::instance::encode_route(w, version, instance.as_ref(), *epoch)?; cost.encode(w, version)?; } - Self::Takeover { - predecessor, - suffix, - route, - instance, - epoch, - cost, - } => { - w.varint(*predecessor)?; - suffix.encode(w, version)?; - encode_route_id(w, version, *route)?; - super::instance::encode_route(w, version, instance.as_ref(), *epoch)?; - cost.encode(w, version)?; - } Self::Synced => {} Self::RouteStart { node, advert } => { if *node == 0 { @@ -409,7 +378,6 @@ impl Encode for AnnounceBroadcast { Self::EndedId { .. } | Self::Update { .. } | Self::Restart { .. } - | Self::Takeover { .. } | Self::Skipped | Self::Synced | Self::RouteStart { .. } @@ -468,20 +436,6 @@ impl<'a> Decode<'a, Version> for AnnounceBroadcast { cost: Cost::decode(&mut body, version)?, } } - ANNOUNCE_TAKEOVER if version.has_announce_takeover() => { - let predecessor = body.varint()?; - let suffix = PathRef::decode(&mut body, version)?; - let route = decode_route_id(&mut body, version)?; - let (instance, epoch) = super::instance::decode_route(&mut body, version)?; - Self::Takeover { - predecessor, - suffix, - route, - instance, - epoch, - cost: Cost::decode(&mut body, version)?, - } - } ROUTE_START if version.has_routes() => { let node = body.varint()?; let advert = decode_advert(&mut body)?; @@ -1096,26 +1050,12 @@ mod tests { cost, }; assert_eq!(broadcast_round_trip(&restart, Version::Lite07), restart); - - let takeover = AnnounceBroadcast::Takeover { - predecessor: 2, - suffix: PathRef { - base: 1, - keep: 1, - rest: Path::from_static("p"), - }, - route: 0, - instance: Some(Instance::random()), - epoch, - cost, - }; - assert_eq!(broadcast_round_trip(&takeover, Version::Lite07), takeover); } } /// The draft's field order, byte for byte: ANNOUNCE_START is the path base, keep, and /// rest, the Route ID, the instance and epoch, and the cost; ANNOUNCE_UPDATE is the - /// Announce ID and the cost; ANNOUNCE_TAKEOVER leads with its predecessor. + /// Announce ID and the cost. #[test] fn lite07_announce_layout() { let encode = |msg: AnnounceBroadcast| { @@ -1157,17 +1097,6 @@ mod tests { }), [3, 5, 7, 1, b'a', 5, 6] ); - assert_eq!( - encode(AnnounceBroadcast::Takeover { - predecessor: 9, - suffix, - route: 3, - instance: Some("a".parse().unwrap()), - epoch: 5, - cost: Cost::new(6), - }), - [5, 10, 9, 1, 2, 1, b'c', 3, 1, b'a', 5, 6] - ); } /// Lite-07 names a claim's route instead of its hop chain, so a chain is refused there. @@ -1216,26 +1145,6 @@ mod tests { } } - // Only lite-07 carries ANNOUNCE_TAKEOVER: older versions send a start instead. - #[test] - fn announce_takeover_needs_lite07() { - let takeover = AnnounceBroadcast::Takeover { - predecessor: 0, - suffix: PathRef::literal(Path::from_static("p")), - route: 0, - instance: Some(Instance::random()), - epoch: 0, - cost: Cost::default(), - }; - assert!(matches!( - takeover.encode( - &mut Encoder::new(&mut Vec::new(), Version::Lite06.into()), - Version::Lite06 - ), - Err(EncodeError::Version) - )); - } - // Only lite-07 carries ANNOUNCE_RESTART: older versions send an end and a start, and an // older receiver skips the unknown type. #[test] diff --git a/rs/moq-net/src/lite/compress.rs b/rs/moq-net/src/lite/compress.rs index 69fb2241565..3aeeeda3982 100644 --- a/rs/moq-net/src/lite/compress.rs +++ b/rs/moq-net/src/lite/compress.rs @@ -1,4 +1,4 @@ -//! Lite-07 announce compression: an ANNOUNCE_START or ANNOUNCE_TAKEOVER may copy the +//! Lite-07 announce compression: an ANNOUNCE_START may copy the //! head of a live announcement's path, naming that base by its distance back from the //! stream's next unassigned Announce ID. //! @@ -87,19 +87,7 @@ impl AnnounceDecoder { Ok(suffix) } - /// An ANNOUNCE_TAKEOVER: resolve it like a start against the live `predecessor`, - /// returning the predecessor's suffix, then its own. One at the predecessor's own - /// suffix retires the predecessor, since a suffix holds one advertisement. - pub fn takeover(&mut self, predecessor: u64, suffix: PathRef) -> Result<(Path, Path), Error> { - let previous = self.live.suffix(predecessor)?.clone(); - let suffix = self.start(suffix)?; - if suffix == previous { - self.live.suffixes.remove(&predecessor); - } - Ok((previous, suffix)) - } - - /// The id the last start or takeover assigned. + /// The id the last start assigned. pub fn last(&self) -> u64 { self.live.next.saturating_sub(1) } @@ -223,18 +211,6 @@ impl AnnounceEncoder { (Some(id), path) } - /// An ANNOUNCE_TAKEOVER of the live `predecessor`: like [`Self::start`], retiring - /// the predecessor when it holds the same suffix. - pub fn takeover(&mut self, predecessor: u64, suffix: Path) -> (u64, PathRef) { - let replaces = self.live.suffixes.get(&predecessor) == Some(&suffix); - let (id, path) = self.start(suffix); - let id = id.expect("a takeover is only sent on a version with announce ids"); - if replaces { - self.end(predecessor); - } - (id, path) - } - /// An ANNOUNCE_END for a live `id`. pub fn end(&mut self, id: u64) { if !self.version.has_announce_compression() { @@ -341,33 +317,6 @@ mod tests { assert_eq!(suffix.as_str(), "b/x"); } - // A takeover assigns the next id like a start. Beneath its predecessor the predecessor - // stays live; at the predecessor's own suffix it retires, after bases resolve against it. - #[test] - fn takeover_assigns_an_id_and_retires_one_in_place() { - let mut decoder = AnnounceDecoder::default(); - decoder.start(literal("pool")).unwrap(); - - let (previous, suffix) = decoder.takeover(0, path(1, 1, "p")).unwrap(); - assert_eq!((previous.as_str(), suffix.as_str()), ("pool", "pool/p")); - assert_eq!(decoder.get(0).unwrap().as_str(), "pool"); - - // Id 2 at id 1's own suffix, copied from id 1 itself, which then retires. - let (previous, suffix) = decoder.takeover(1, path(1, 2, "")).unwrap(); - assert_eq!((previous.as_str(), suffix.as_str()), ("pool/p", "pool/p")); - assert!(matches!(decoder.get(1), Err(Error::ProtocolViolation))); - assert!(matches!( - decoder.takeover(1, literal("pool/p/q")), - Err(Error::ProtocolViolation) - )); - assert!(matches!( - decoder.takeover(9, literal("pool/p/q")), - Err(Error::ProtocolViolation) - )); - assert_eq!(decoder.end(2).unwrap().as_str(), "pool/p"); - assert_eq!(decoder.end(0).unwrap().as_str(), "pool"); - } - #[test] fn rejects_a_keep_longer_than_the_base() { let mut decoder = AnnounceDecoder::default(); @@ -407,29 +356,6 @@ mod tests { (id.unwrap(), size) } - // The encoder's takeover ids and bases agree with the decoder's, in place and beneath. - #[test] - fn encoder_takeover_decodes_to_its_input() { - let mut encoder = AnnounceEncoder::new(VERSION); - let mut decoder = AnnounceDecoder::default(); - let (claim, _) = start(&mut encoder, &mut decoder, "pool"); - let mut predecessor = claim; - for suffix in ["pool/p", "pool/p", "pool/p/q"] { - let (id, wire) = encoder.takeover(predecessor, Path::new(suffix).unwrap()); - let (_, got) = decoder.takeover(predecessor, wire).unwrap(); - assert_eq!(got.as_str(), suffix); - predecessor = id; - } - // Both sides retired the id replaced in place and kept the rest. - let (id, wire) = encoder.start(Path::new("other").unwrap()); - decoder.start(wire).unwrap(); - assert_eq!(id, Some(4)); - assert!(matches!(decoder.end(1), Err(Error::ProtocolViolation))); - decoder.end(claim).unwrap(); - decoder.end(2).unwrap(); - decoder.end(3).unwrap(); - } - #[test] fn encoder_output_decodes_to_its_input() { let mut encoder = AnnounceEncoder::new(VERSION); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 271dc6d4e4d..9d2bcf22010 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1234,8 +1234,7 @@ impl AnnounceRun { match event { origin::RouteEvent::Start(update) | origin::RouteEvent::Update(update) - | origin::RouteEvent::Restart(update) - | origin::RouteEvent::Takeover { announce: update, .. } => { + | origin::RouteEvent::Restart(update) => { current.insert(update.prefix, update.route); } origin::RouteEvent::End(update) => { @@ -1599,15 +1598,10 @@ impl AnnounceRun { return Poll::Pending; }; - let (update, active, restart, predecessor) = match next { - Some(origin::RouteEvent::Start(update) | origin::RouteEvent::Update(update)) => { - (update, true, false, None) - } - Some(origin::RouteEvent::Restart(update)) => (update, true, true, None), - Some(origin::RouteEvent::Takeover { announce, predecessor }) => { - (announce, true, false, Some(predecessor)) - } - Some(origin::RouteEvent::End(update)) => (update, false, false, None), + let (update, active, restart) = match next { + Some(origin::RouteEvent::Start(update) | origin::RouteEvent::Update(update)) => (update, true, false), + Some(origin::RouteEvent::Restart(update)) => (update, true, true), + Some(origin::RouteEvent::End(update)) => (update, false, false), None => { if self.initial.take().is_some() { stream.writer.buffer(&lite::AnnounceBroadcast::Synced)?; @@ -1629,9 +1623,6 @@ impl AnnounceRun { continue; } - // Only lite-07 carries a takeover, and it answers from the route layer. Here one - // is another instance, in place or not. - let restart = restart || predecessor.as_ref() == Some(&suffix); match self.outgoing(&update.route, &absolute) { Some((hops, cost)) if restart && self.live.contains_key(&suffix) => { self.restart(stream, suffix, hops, cost, served(&update.route), &absolute)?; @@ -9666,14 +9657,13 @@ mod tests { crate::origin::RouteInfo::default() .with_hops(echoed_hops) .with_via(assigned), - None, ) .unwrap(); let mut local_hops = Hops::new(); local_hops.push(upstream).unwrap(); let _local = origin - .forward("local", crate::origin::RouteInfo::default().with_hops(local_hops), None) + .forward("local", crate::origin::RouteInfo::default().with_hops(local_hops)) .unwrap(); // A SETUP that declares no origin of its own, so only the assigned one applies. @@ -10247,13 +10237,7 @@ mod tests { let mut _route = None; let mut held = None; match wait { - Wait::Broadcast => { - _route = Some( - origin - .forward("room", crate::origin::RouteInfo::default(), None) - .unwrap(), - ) - } + Wait::Broadcast => _route = Some(origin.forward("room", crate::origin::RouteInfo::default()).unwrap()), Wait::Track => { let broadcast = origin.route("room", crate::origin::RouteOptions::hidden()).unwrap(); let dynamic = broadcast.broadcast().requests(); diff --git a/rs/moq-net/src/lite/publisher/routed.rs b/rs/moq-net/src/lite/publisher/routed.rs index fb4310ec671..cb4faf2cfae 100644 --- a/rs/moq-net/src/lite/publisher/routed.rs +++ b/rs/moq-net/src/lite/publisher/routed.rs @@ -11,7 +11,7 @@ //! target, and what they present is named under the mount point on the same routes. use std::{ - collections::{BTreeMap, BTreeSet, HashMap}, + collections::{BTreeMap, HashMap}, task::{Poll, ready}, }; @@ -338,9 +338,8 @@ impl Answer { for path in &paths { sent.claims.remove(path); } - let mut started = BTreeSet::new(); for path in paths { - self.sync(stream, id, path, &mut started)?; + self.sync(stream, id, path)?; } } } @@ -387,21 +386,14 @@ impl Answer { None => path, }; match claim { - Some(mut claim) => { - if let Some(mount) = mount { - claim.continues = (claim.continues.take()) - .and_then(|(taken, instance)| Some((mount.name(&taken)?, instance))); - } - sent.claims.insert(path.clone(), claim) - } + Some(claim) => sent.claims.insert(path.clone(), claim), None => sent.claims.remove(&path), }; paths.push(path); } sent.floor = floor; - let mut started = BTreeSet::new(); for path in paths { - self.sync(stream, id, path, &mut started)?; + self.sync(stream, id, path)?; } self.floor(stream, id) } @@ -451,83 +443,49 @@ impl Answer { } /// Bring the peer's claim at `path` for the route to `id` in line with the node's. - /// - /// `started` collects the claims this pass started: a claim takes over only one the - /// peer held before, so a snapshot replays no takeover. - fn sync( - &mut self, - stream: &mut Stream, - id: route::Id, - path: Path, - started: &mut BTreeSet, - ) -> Result<(), Error> { + fn sync(&mut self, stream: &mut Stream, id: route::Id, path: Path) -> Result<(), Error> { let sent = self.routes.get(&id).expect("only a held route syncs"); let wanted = (sent.claims.get(&path)) .filter(|claim| self.shows(&path) && sends(&self.link, claim)) .cloned(); let wire = sent.wire; - let taken = (wanted.as_ref()) - .and_then(|claim| self.continued(id, &path, claim)) - .filter(|(taken, _)| !started.contains(taken)); let key = (id, path); match (self.live.get_mut(&key), wanted) { (None, Some(claim)) => { let suffix = self.suffix(&key.1).expect("shown claims are under the prefix"); - let announce = match taken { - Some((taken, predecessor)) => { - tracing::debug!(route = %key.1, node = id, predecessor = %taken, "takeover"); - self.takeover(stream, wire, predecessor, suffix, &claim)? - } - None => { - tracing::debug!(route = %key.1, node = id, "announce"); - let (announce, suffix) = self.encoder.start(suffix); - stream.writer.buffer(&Msg::Active { - suffix, - route: wire, - instance: Some(claim.instance.clone()), - epoch: claim.epoch, - hops: crate::Hops::new(), - cost: origin::Cost::new(claim.cost), - })?; - announce.expect("lite-07 assigns announce ids") - } - }; - started.insert(key.1.clone()); + tracing::debug!(route = %key.1, node = id, "announce"); + let (announce, suffix) = self.encoder.start(suffix); + stream.writer.buffer(&Msg::Active { + suffix, + route: wire, + instance: Some(claim.instance.clone()), + epoch: claim.epoch, + hops: crate::Hops::new(), + cost: origin::Cost::new(claim.cost), + })?; + let announce = announce.expect("lite-07 assigns announce ids"); self.live.insert(key, (announce, claim)); } (Some((announce, held)), Some(claim)) => { let announce = *announce; let restart = held.instance != claim.instance || held.epoch != claim.epoch; let reprice = held.cost != claim.cost; - let replacement = match taken { - // In place: a new Announce ID replaces the one it continues. - Some((taken, predecessor)) if taken == key.1 => { - tracing::debug!(route = %key.1, node = id, "takeover"); - let suffix = self.suffix(&key.1).expect("held claims are under the prefix"); - self.takeover(stream, wire, predecessor, suffix, &claim)? - } - _ if restart => { - tracing::debug!(route = %key.1, "restart"); - stream.writer.buffer(&Msg::Restart { - id: announce, - instance: Some(claim.instance.clone()), - epoch: claim.epoch, - cost: origin::Cost::new(claim.cost), - })?; - announce - } - _ => { - if reprice { - stream.writer.buffer(&Msg::Update { - id: announce, - hops: crate::Hops::new(), - cost: origin::Cost::new(claim.cost), - })?; - } - announce - } - }; - self.live.insert(key, (replacement, claim)); + if restart { + tracing::debug!(route = %key.1, "restart"); + stream.writer.buffer(&Msg::Restart { + id: announce, + instance: Some(claim.instance.clone()), + epoch: claim.epoch, + cost: origin::Cost::new(claim.cost), + })?; + } else if reprice { + stream.writer.buffer(&Msg::Update { + id: announce, + hops: crate::Hops::new(), + cost: origin::Cost::new(claim.cost), + })?; + } + self.live.insert(key, (announce, claim)); } (Some(_), None) => { let (announce, _) = self.live.remove(&key).expect("matched above"); @@ -539,45 +497,6 @@ impl Answer { Ok(()) } - /// Send `claim` at `suffix` under the route `wire` as an ANNOUNCE_TAKEOVER of the - /// peer's live `predecessor`, returning its Announce ID. - fn takeover( - &mut self, - stream: &mut Stream, - wire: u64, - predecessor: u64, - suffix: Path, - claim: &node::Claim, - ) -> Result { - let (announce, suffix) = self.encoder.takeover(predecessor, suffix); - stream.writer.buffer(&Msg::Takeover { - predecessor, - suffix, - route: wire, - instance: Some(claim.instance.clone()), - epoch: claim.epoch, - cost: origin::Cost::new(claim.cost), - })?; - Ok(announce) - } - - /// The claim the peer holds that `claim` at `path` takes over, and its Announce ID. - /// - /// Only the claim it continues, under the same route at the instance it continued, - /// and only while every claim the peer holds at that path serves the same instance: - /// otherwise the peer cannot tell which one it resolved through. - fn continued(&self, id: route::Id, path: &Path, claim: &node::Claim) -> Option<(Path, u64)> { - let (taken, instance) = claim.continues.as_ref()?; - if !path.has_prefix(taken) { - return None; - } - let (predecessor, held) = self.live.get(&(id, taken.clone()))?; - let alike = self.routes.keys().all(|other| { - (self.live.get(&(*other, taken.clone()))).is_none_or(|(_, other)| other.instance == *instance) - }); - (held.instance == *instance && alike).then(|| (taken.clone(), *predecessor)) - } - /// The grant changed: end what it no longer covers, start what it now does, and /// floor every route again, which certifies only while the grant covers the prefix. fn refilter(&mut self, stream: &mut Stream) -> Result<(), Error> { @@ -585,9 +504,8 @@ impl Answer { ids.sort_unstable(); for id in ids { let paths: Vec = self.routes[&id].claims.keys().cloned().collect(); - let mut started = BTreeSet::new(); for path in paths { - self.sync(stream, id, path, &mut started)?; + self.sync(stream, id, path)?; } if self.routes[&id].floored { self.floor(stream, id)?; diff --git a/rs/moq-net/src/lite/replay.rs b/rs/moq-net/src/lite/replay.rs index 1d5549d6689..29f826037f4 100644 --- a/rs/moq-net/src/lite/replay.rs +++ b/rs/moq-net/src/lite/replay.rs @@ -12,8 +12,7 @@ pub(super) fn initial(events: impl IntoIterator) -> V let (update, active) = match event { origin::RouteEvent::Start(update) | origin::RouteEvent::Update(update) - | origin::RouteEvent::Restart(update) - | origin::RouteEvent::Takeover { announce: update, .. } => (update, true), + | origin::RouteEvent::Restart(update) => (update, true), origin::RouteEvent::End(update) => (update, false), }; if let Some(index) = positions.remove(&update.prefix) { diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 846ed0dcb7d..9d8f83f46a7 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -649,9 +649,8 @@ impl Subscriber { )?; } lite::AnnounceBroadcast::Skipped | lite::AnnounceBroadcast::Synced => {} - // Only lite-07 carries the route layer and takeovers, which `routed` handles above. - lite::AnnounceBroadcast::Takeover { .. } - | lite::AnnounceBroadcast::RouteStart { .. } + // Only lite-07 carries the route layer, which `routed` handles above. + lite::AnnounceBroadcast::RouteStart { .. } | lite::AnnounceBroadcast::RouteUpdate { .. } | lite::AnnounceBroadcast::RouteHold { .. } | lite::AnnounceBroadcast::RouteEnd { .. } @@ -9689,7 +9688,7 @@ impl Announced { return false; } // An error means the prefix is outside our origin's scope, so don't serve it. - match subscriber.origin.forward(&path, route.clone(), None) { + match subscriber.origin.forward(&path, route.clone()) { Ok(dynamic) => { self.attach(path, route, dynamic); true diff --git a/rs/moq-net/src/lite/subscriber/routed.rs b/rs/moq-net/src/lite/subscriber/routed.rs index 5461aa8bca8..1372fdbc5d6 100644 --- a/rs/moq-net/src/lite/subscriber/routed.rs +++ b/rs/moq-net/src/lite/subscriber/routed.rs @@ -226,30 +226,6 @@ impl Routed { let claim = claim(instance, epoch, cost)?; self.start(decoder.last(), route, path, claim)?; } - Msg::Takeover { - predecessor, - suffix, - route, - instance, - epoch, - cost, - } => { - let (_, suffix) = decoder.takeover(predecessor, suffix)?; - let path = self.path(prefix, &suffix)?; - // The predecessor is a live claim of the same route covering the new one. - let taken = self.claims.get(&predecessor).ok_or(Error::ProtocolViolation)?; - if taken.route != route || !path.has_prefix(&taken.path) { - return Err(Error::ProtocolViolation); - } - let continues = (taken.path.clone(), taken.claim.instance.clone()); - // At its own path it replaces the predecessor. - if continues.0 == path { - self.end(predecessor)?; - } - let mut claim = claim(instance, epoch, cost)?; - claim.continues = Some(continues); - self.start(decoder.last(), route, path, claim)?; - } Msg::Update { id, cost, .. } => { let claim = self.claims.get_mut(&id).ok_or(Error::ProtocolViolation)?; claim.claim.cost = cost.value(); @@ -469,29 +445,12 @@ impl Routed { return; } } - // The route this one takes over, as this stream serves it at the instance it - // continues, so what resolved through that one beneath the claim carries on. - let taken = (served.claim.continues.as_ref()) - .and_then(|(taken, instance)| { - let entry = self.served.get_mut(&(id, taken.clone()))?; - (entry.route.route.instance.as_ref() == Some(instance)).then_some((taken, entry)) - }) - .map(|(taken, entry)| match *taken == key.1 { - // In place, its sources serve what the new one continues: they live on with - // it rather than closing with the old route. - true => (&entry.route.dynamic, Some(std::mem::take(&mut entry.route.live))), - false => (&entry.route.dynamic, None), - }); // A new instance or epoch replaces the old route, attached before it goes. tracing::debug!(route = %subscriber.log_path(&path), node = id, "announce"); - let (predecessor, live) = taken.unzip(); - match subscriber.origin.forward(&path, route.clone(), predecessor) { + match subscriber.origin.forward(&path, route.clone()) { Ok(dynamic) => { let wake = crate::util::QueueWake::new(key.clone(), self.ready.clone()); - let mut entry = AnnouncedRoute::new(route, dynamic, wake); - if let Some(live) = live.flatten() { - entry.live = live; - } + let entry = AnnouncedRoute::new(route, dynamic, wake); entry.waker.wake_by_ref(); self.served.insert(key.clone(), ServedClaim { named, route: entry }); self.stream.serving(id, &key.1); @@ -558,7 +517,6 @@ fn claim(instance: Option, epoch: u64, cost: crate::origin::Cos instance: instance.ok_or(Error::ProtocolViolation)?, epoch, cost: cost.value(), - continues: None, upstream: false, learned: false, }) @@ -918,76 +876,4 @@ mod tests { } assert_eq!(pending(&routed), 0, "claims started and ended between floors"); } - - /// A takeover in place keeps the sources the predecessor minted open: they serve the - /// instance it continues. A restart in place closes them. - #[kio::test] - async fn a_takeover_in_place_keeps_the_predecessors_sources() { - for takeover in [true, false] { - let (subscriber, _consumer) = super::super::tests::restart_subscriber(SinkSession::new(Default::default())); - let prefix = Path::empty(); - let mut routed = super::Routed::new(&subscriber, &prefix, 1, &Default::default()).unwrap(); - let mut decoder = AnnounceDecoder::default(); - let path = Path::from_static("room/host"); - let advert = Advert { - seqno: 0, - metric: 0, - down_only: false, - }; - let start = Msg::Active { - suffix: PathRef::literal(path.clone()), - route: 0, - instance: Some(Instance::random()), - epoch: 0, - hops: Default::default(), - cost: Cost::new(0), - }; - for msg in [Msg::RouteStart { node: 7, advert }, start] { - routed.receive(&prefix, msg, &mut decoder).unwrap(); - } - routed - .receive( - &prefix, - Msg::Floor { - id: 0, - revision: Some(0), - }, - &mut decoder, - ) - .unwrap(); - routed.outputs(&subscriber); - let minted = routed.served[&(7, path.clone())].route.live.consume(); - - let instance = Some(Instance::random()); - let replace = match takeover { - true => Msg::Takeover { - predecessor: 0, - suffix: PathRef::literal(path.clone()), - route: 0, - instance, - epoch: 0, - cost: Cost::new(0), - }, - false => Msg::Restart { - id: 0, - instance, - epoch: 0, - cost: Cost::new(0), - }, - }; - routed.receive(&prefix, replace, &mut decoder).unwrap(); - routed - .receive( - &prefix, - Msg::Floor { - id: 0, - revision: Some(1), - }, - &mut decoder, - ) - .unwrap(); - routed.outputs(&subscriber); - assert_eq!(minted.is_closed(), !takeover, "takeover: {takeover}"); - } - } } diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index 7934c222b0c..30b4c961be5 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -20,7 +20,7 @@ pub enum Version { /// Lite-07. Adds the hidden opt-in to ANNOUNCE_REQUEST: without it, a route with /// a `.`-prefixed segment below the requested prefix is left out. SUBSCRIBE_DROP /// accounts for every group the subscription will not deliver. - /// ANNOUNCE_START and ANNOUNCE_TAKEOVER may copy a path head from a live + /// ANNOUNCE_START may copy a path head from a live /// announcement on the same stream, and name a Route ID in place of the hop chain. /// Adds the Auth Stream. SUBSCRIBE carries a list of ranges. The wire format is /// still work-in-progress, so it is advertised over ALPN as `moq-lite-07-wip` and @@ -268,7 +268,7 @@ impl Version { ) } - /// Whether ANNOUNCE_START and ANNOUNCE_TAKEOVER may copy a path head from a live + /// Whether ANNOUNCE_START may copy a path head from a live /// announcement on the same stream. Added in lite-07. pub fn has_announce_compression(self) -> bool { !matches!( @@ -368,16 +368,6 @@ impl Version { ) } - /// Whether the announce stream has ANNOUNCE_TAKEOVER: a new advertisement that - /// continues what a live one served beneath its prefix. Added in lite-07. Older - /// versions send an ANNOUNCE_START, which the receiver treats as another instance. - pub(crate) fn has_announce_takeover(self) -> bool { - !matches!( - self, - Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 - ) - } - /// Whether TRACK_INFO carries the refresh ratio and SUBSCRIBE_UPDATE a wanted group, /// so a join at any hop reaches the publisher. Added in lite-07; older peers see a /// track that never refreshes. diff --git a/rs/moq-net/src/model/announced.rs b/rs/moq-net/src/model/announced.rs index c33c6d116cc..def40197835 100644 --- a/rs/moq-net/src/model/announced.rs +++ b/rs/moq-net/src/model/announced.rs @@ -34,11 +34,7 @@ impl AnnounceConsumer { match event { RouteEvent::Start(announce) => Some(AnnounceEvent::Present(announce)), RouteEvent::End(announce) => Some(AnnounceEvent::Absent(announce)), - // A takeover elsewhere starts its own prefix; at its predecessor's it replaces it. - RouteEvent::Takeover { announce, predecessor } if predecessor != announce.prefix => { - Some(AnnounceEvent::Present(announce)) - } - RouteEvent::Update(_) | RouteEvent::Restart(_) | RouteEvent::Takeover { .. } => None, + RouteEvent::Update(_) | RouteEvent::Restart(_) => None, } } diff --git a/rs/moq-net/src/model/follow.rs b/rs/moq-net/src/model/follow.rs index 02fd90889a8..d4468c5bbc0 100644 --- a/rs/moq-net/src/model/follow.rs +++ b/rs/moq-net/src/model/follow.rs @@ -11,9 +11,8 @@ use crate::{Error, Instance, Path, broadcast}; /// and a request resolves through the most specific one, so only that route's events come /// through. Another route taking over is a [`Restart`](RouteEvent::Restart), unless both /// carry the same instance: those serve one continuous stream, so it is an -/// [`Update`](RouteEvent::Update). One announced as a -/// [`Takeover`](RouteEvent::Takeover) of the serving route continues it, and comes through -/// as one. Routes beneath the path serve other broadcasts and are ignored. +/// [`Update`](RouteEvent::Update), as for a job's route continuing the prefix route it +/// answered. Routes beneath the path serve other broadcasts and are ignored. pub(crate) struct Follow { announced: Routes, path: Path, @@ -130,7 +129,6 @@ impl Follow { let (RouteEvent::Start(announce) | RouteEvent::Update(announce) | RouteEvent::Restart(announce) - | RouteEvent::Takeover { announce, .. } | RouteEvent::End(announce)) = &event; if !self.path.has_prefix(&announce.prefix) { return None; @@ -145,12 +143,11 @@ impl Follow { .iter() .position(|standing| standing.announce.prefix == prefix) .map(|index| self.covering.swap_remove(index).since); - let (announce, since, restart, predecessor) = match event { - RouteEvent::End(_) => (None, None, false, None), - RouteEvent::Start(announce) => (Some(announce), None, false, None), - RouteEvent::Update(announce) => (Some(announce), standing, false, None), - RouteEvent::Restart(announce) => (Some(announce), standing, true, None), - RouteEvent::Takeover { announce, predecessor } => (Some(announce), None, true, Some(predecessor)), + let (announce, since, restart) = match event { + RouteEvent::End(_) => (None, None, false), + RouteEvent::Start(announce) => (Some(announce), None, false), + RouteEvent::Update(announce) => (Some(announce), standing, false), + RouteEvent::Restart(announce) => (Some(announce), standing, true), }; if let Some(announce) = announce { self.covering.push(Standing { @@ -173,15 +170,6 @@ impl Follow { { Some(RouteEvent::End(before)) } - // The serving route continues as this one. - (Some((before, _)), Some((after, _))) - if after.prefix == prefix && predecessor.as_ref() == Some(&before.prefix) => - { - Some(RouteEvent::Takeover { - announce: after, - predecessor: before.prefix, - }) - } (Some((before, _)), Some((after, _))) if before.prefix != after.prefix => { let same = before.route.instance == after.route.instance; Some(match same { @@ -323,9 +311,9 @@ impl Following { } }; - // Resolving can run ahead of the follower: a takeover resolves at once while the - // origin holds its restart, and that restart then names the instance handed out. - // The caller freeing it before reading the restart is owed it again. + // Resolving can run ahead of the follower: another instance resolves at once while + // the origin holds its restart, and that restart then names the instance handed + // out. The caller freeing it before reading the restart is owed it again. let weak = broadcast.weak(); if self.last.as_ref().is_some_and(|last| last.same_channel(&weak)) { self.absorbed = true; @@ -380,10 +368,9 @@ impl Following { self.end(); } // After a refusal, another route may answer what the last one refused. - RouteEvent::Update(_) | RouteEvent::Takeover { .. } if self.refused => self.unseen = true, - // The same instance over another route, which its subscriptions ride out, or one - // continuing what was handed out. - RouteEvent::Update(_) | RouteEvent::Takeover { .. } => return false, + RouteEvent::Update(_) if self.refused => self.unseen = true, + // The same instance over another route, which its subscriptions ride out. + RouteEvent::Update(_) => return false, } true } @@ -410,7 +397,6 @@ mod tests { RouteEvent::Start(announce) => ("start", announce.prefix.to_string()), RouteEvent::Update(announce) => ("update", announce.prefix.to_string()), RouteEvent::Restart(announce) => ("restart", announce.prefix.to_string()), - RouteEvent::Takeover { announce, .. } => ("takeover", announce.prefix.to_string()), RouteEvent::End(announce) => ("end", announce.prefix.to_string()), } } @@ -437,7 +423,7 @@ mod tests { let instance = Instance::random(); // A prefix above the path covers it. - let pool = origin.forward("pool", route(&instance), None).unwrap(); + let pool = origin.forward("pool", route(&instance)).unwrap(); assert_eq!(followed(&mut follow).await, ("start", "pool".into())); // A route beneath the path serves another broadcast. @@ -472,7 +458,7 @@ mod tests { #[kio::test] async fn follow_starts_on_the_serving_route_when_joining_late() { let origin = Hop::new(1).unwrap().produce(); - let _pool = origin.forward("pool", RouteInfo::default(), None).unwrap(); + let _pool = origin.forward("pool", RouteInfo::default()).unwrap(); let _exact = origin.route("pool/job", RouteOptions::default()).unwrap(); let mut follow = origin.consume().path_routes("pool/job").unwrap(); @@ -493,7 +479,7 @@ mod tests { // Both land before the follower reads again, and the cursor delivers the prefix's start // ahead of the path's end, but the path went unserved in between. drop(exact); - let _pool = origin.forward("pool", route(&instance), None).unwrap(); + let _pool = origin.forward("pool", route(&instance)).unwrap(); assert_eq!(followed(&mut follow).await, ("end", "pool/job".into())); assert_eq!(followed(&mut follow).await, ("start", "pool".into())); @@ -514,7 +500,7 @@ mod tests { assert_eq!(followed(&mut follow).await, ("start", "pool/job".into())); let instance = Instance::random(); - let _pool = origin.forward("pool", route(&instance), None).unwrap(); + let _pool = origin.forward("pool", route(&instance)).unwrap(); exact.announce_info(route(&instance)).unwrap(); assert_eq!(followed(&mut follow).await, ("restart", "pool/job".into())); @@ -528,7 +514,7 @@ mod tests { let origin = Hop::new(1).unwrap().produce(); let mut follow = origin.consume().path_routes("pool/job").unwrap(); - let _pool = origin.forward("pool", RouteInfo::default(), None).unwrap(); + let _pool = origin.forward("pool", RouteInfo::default()).unwrap(); assert_eq!(followed(&mut follow).await, ("start", "pool".into())); let _exact = origin.route("pool/job", RouteOptions::default()).unwrap(); @@ -544,7 +530,7 @@ mod tests { let _chat = origin .scope("", &chat) .unwrap() - .forward("room/alice", RouteInfo::default(), None) + .forward("room/alice", RouteInfo::default()) .unwrap(); let mut follow = origin.consume().path_routes("room/alice").unwrap(); @@ -690,7 +676,7 @@ mod tests { let exact = origin.route("live", options(&instance)).unwrap(); let first = available(&mut announced).await; - let dynamic = origin.forward("", route(&instance), None).unwrap(); + let dynamic = origin.forward("", route(&instance)).unwrap(); exact.withdraw(); ended(&mut announced).await; let served = broadcast::Info::new().produce(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 935cd396e03..1fa50366b06 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -686,10 +686,9 @@ impl RouteOptions { } /// The route metadata these options advertise, under `instance` at `epoch`. - fn info(&self, instance: &Instance, epoch: u64) -> RouteInfo { + /// The price and credential to advertise, naming no instance. + fn info(&self) -> RouteInfo { RouteInfo { - instance: Some(instance.clone()), - epoch, cost: self.cost, auth: self.auth.clone(), ..RouteInfo::default() @@ -873,19 +872,8 @@ type AnnounceMeta = (RouteMeta, Option>); enum PendingUpdate { Announce(AnnounceMeta), Restart(AnnounceMeta), - /// An announce continuing the delivered `predecessor`, delivered as one only while - /// the consumer still holds the advertisement [`OriginConsumerState::seen`] stamped - /// `stamp`; otherwise as a plain start or restart. - Takeover { - meta: AnnounceMeta, - predecessor: Path, - stamp: u64, - }, Unannounce(AnnounceMeta), - UnannounceAnnounce { - old: AnnounceMeta, - new: AnnounceMeta, - }, + UnannounceAnnounce { old: AnnounceMeta, new: AnnounceMeta }, } // An update is taken only while collapsing it under the cursor state lock. @@ -921,11 +909,6 @@ struct OriginConsumerState { /// retraction cancels it entirely) or a metadata update on a route the /// consumer already observed (a retraction must still be delivered). delivered: BTreeSet, - /// Each delivered prefix's advertisement, stamped when another instance was last - /// delivered there, so a pending takeover can tell its predecessor still stands. - /// A prefix without one continues nothing for this consumer. - seen: HashMap, - next_stamp: u64, /// Set by the origin's teardown: the cursor drains `pending`, then reports /// the end instead of parking forever on a table that can never fire again. ended: bool, @@ -1023,12 +1006,6 @@ impl OriginConsumerState { None | Some(PendingUpdate::Announce(_)) => PendingUpdate::Announce(meta), // The consumer still owes the restart; this only moves its metadata. Some(PendingUpdate::Restart(_)) => PendingUpdate::Restart(meta), - // Likewise the takeover. - Some(PendingUpdate::Takeover { predecessor, stamp, .. }) => PendingUpdate::Takeover { - meta, - predecessor, - stamp, - }, // Consumer needs to observe the retraction before this announce. Some(PendingUpdate::Unannounce(old) | PendingUpdate::UnannounceAnnounce { old, .. }) => { PendingUpdate::UnannounceAnnounce { old, new: meta } @@ -1053,39 +1030,6 @@ impl OriginConsumerState { self.put(prefix, new); } - /// A route at `prefix` continues the delivered `predecessor` (which may be `prefix` - /// itself). The consumer learns it as a takeover only while it holds that - /// predecessor when this is delivered; see [`PendingUpdate::Takeover`]. - fn apply_takeover(&mut self, prefix: Path, meta: RouteMeta, captures: Option>, predecessor: Path) { - // A predecessor about to change on the consumer is not the one it holds. - let settled = prefix == predecessor - || self - .pending - .get(&predecessor) - .is_none_or(|pending| matches!(pending.update, Some(PendingUpdate::Announce(_)))); - let stamp = self.seen.get(&predecessor).copied().filter(|_| settled); - let Some(stamp) = stamp else { - return match prefix == predecessor { - true => self.apply_restart(prefix, meta, captures), - false => self.apply_announce(prefix, meta, captures), - }; - }; - let meta = (meta, captures); - let new = match self.pending.get_mut(&prefix).and_then(|pending| pending.update.take()) { - None | Some(PendingUpdate::Takeover { .. }) => PendingUpdate::Takeover { - meta, - predecessor, - stamp, - }, - Some(PendingUpdate::Unannounce(old) | PendingUpdate::UnannounceAnnounce { old, .. }) => { - PendingUpdate::UnannounceAnnounce { old, new: meta } - } - Some(PendingUpdate::Restart(_)) => PendingUpdate::Restart(meta), - Some(PendingUpdate::Announce(_)) => PendingUpdate::Announce(meta), - }; - self.put(prefix, new); - } - fn apply_unannounce(&mut self, prefix: Path, last: RouteMeta, captures: Option>) { let last = (last, captures); match self.pending.get_mut(&prefix).and_then(|pending| pending.update.take()) { @@ -1095,25 +1039,9 @@ impl OriginConsumerState { let pending = self.remove(&prefix).unwrap(); self.owed -= usize::from(pending.owed); } - // Likewise a takeover, but what the consumer resolved through its predecessor - // may be what it continued, so the predecessor continues nothing more for it - // until another instance is delivered there. - Some(PendingUpdate::Takeover { predecessor, stamp, .. }) if !self.delivered.contains(&prefix) => { - let pending = self.remove(&prefix).unwrap(); - self.owed -= usize::from(pending.owed); - if self.seen.get(&predecessor) == Some(&stamp) { - self.seen.remove(&predecessor); - } - } // Either nothing is pending or the pending announce was a metadata // update on a delivered route; the consumer still owes a retraction. - None - | Some( - PendingUpdate::Announce(_) - | PendingUpdate::Restart(..) - | PendingUpdate::Takeover { .. } - | PendingUpdate::Unannounce(_), - ) => { + None | Some(PendingUpdate::Announce(_) | PendingUpdate::Restart(..) | PendingUpdate::Unannounce(_)) => { self.put(prefix, PendingUpdate::Unannounce(last)); } // The embedded announce cancels with this retraction; the consumer still @@ -1214,7 +1142,6 @@ impl OriginConsumerState { let (RouteEvent::Start(announce) | RouteEvent::Update(announce) | RouteEvent::Restart(announce) - | RouteEvent::Takeover { announce, .. } | RouteEvent::End(announce)) = &event; // A trailing announce left pending is still owed. if owed && !self.pending.contains_key(&announce.prefix) { @@ -1229,7 +1156,6 @@ impl OriginConsumerState { Start, Update, Restart, - Takeover(Path), End, } @@ -1248,20 +1174,6 @@ impl OriginConsumerState { self.delivered.insert(prefix.clone()); (meta, Kind::Restart) } - PendingUpdate::Takeover { - meta, - predecessor, - stamp, - } => { - let kind = match self.seen.get(&predecessor) == Some(&stamp) { - true => Kind::Takeover(predecessor), - // The predecessor the consumer held is gone: this is another instance. - false if self.delivered.contains(&prefix) => Kind::Restart, - false => Kind::Start, - }; - self.delivered.insert(prefix.clone()); - (meta, kind) - } PendingUpdate::Unannounce(meta) => { self.delivered.remove(&prefix); (meta, Kind::End) @@ -1274,16 +1186,6 @@ impl OriginConsumerState { (old, Kind::End) } }; - match &kind { - Kind::Update => {} - Kind::End => { - self.seen.remove(&prefix); - } - Kind::Start | Kind::Restart | Kind::Takeover(_) => { - self.seen.insert(prefix.clone(), self.next_stamp); - self.next_stamp += 1; - } - } let announce = Announce { prefix, captures, @@ -1302,7 +1204,6 @@ impl OriginConsumerState { Kind::Start => RouteEvent::Start(announce), Kind::Update => RouteEvent::Update(announce), Kind::Restart => RouteEvent::Restart(announce), - Kind::Takeover(predecessor) => RouteEvent::Takeover { announce, predecessor }, Kind::End => RouteEvent::End(announce), }; (event, owed) @@ -1366,25 +1267,12 @@ struct RouteEntry { /// announce time for the same reason: the paths beneath the prefix the route /// may serve. A member whose intersection is too large to spell reaches nothing. reach: Vec, - /// The route this one continues, when it was advertised as a takeover. - takeover: Option, -} - -/// A route's predecessor as the route took it over: what was resolved through that -/// route beneath its prefix, before then, is the route's instance now. -/// -/// A one-time continuity reference, never a lifetime dependency: either route ends -/// on its own, and only what had resolved by [`Self::at`] is continued. -#[derive(Clone, Debug)] -struct Takeover { - /// The predecessor's entry id. - route: u64, - /// The instance the predecessor served then. - instance: Instance, - /// The predecessor's absolute prefix. - prefix: Path, - /// [`OriginState::next_route`] once the route took over. - at: u64, + /// Whether the job routes answering this prefix route take its instance, which it + /// minted rather than was given: their readers continue on the job's route. It moves + /// to a fresh one as each job carrying it stops serving; see [`OriginState::advance`]. + lends: bool, + /// The prefix route, by prefix and id, whose instance this job's route took. + lent: Option<(Path, u64)>, } impl RouteEntry { @@ -1762,28 +1650,8 @@ impl TableCursor { && self.named.as_ref().is_none_or(|(mount, _)| mount.names(&entry.prefix)) } - /// Where this cursor presents the predecessor `takeover` continues, when it presents - /// exactly that instance and every route it sees at the predecessor's prefix, - /// other than the successor `id` itself, serves it: a path beneath could resolve - /// through any of them, so another one there would make the continuity claim false - /// for what it served. - fn continued(&self, id: u64, takeover: &Takeover, routes: &RouteTable) -> Option { - let claim = prefix_claim(&takeover.prefix).ok()?; - let presented = self.presented(&takeover.prefix, &claim)?; - let current = self.current.get(&presented)?; - if current.instance != takeover.instance || current.prefix != takeover.prefix { - return None; - } - routes - .at(&takeover.prefix) - .filter(|entry| entry.id != id && self.visible(entry)) - .all(|entry| entry.instance == takeover.instance) - .then_some(presented) - } - - /// Deliver a changed winner at this presented prefix, as of `now`, the - /// [`OriginState::next_route`]. - fn update(&mut self, presented: &Path, best: Option<&RouteEntry>, now: u64, routes: &RouteTable) { + /// Deliver a changed winner at this presented prefix. + fn update(&mut self, presented: &Path, best: Option<&RouteEntry>) { let Some(entry) = best else { if let Some(last) = self.current.remove(presented) && let Ok(mut state) = self.state.write() @@ -1807,13 +1675,6 @@ impl TableCursor { let meta = entry.meta(); let captures = self.captures(&entry.prefix); - // Only as the route takes over: one this cursor sees later continues nothing it - // can vouch for. - let continued = entry - .takeover - .as_ref() - .filter(|takeover| takeover.at == now) - .and_then(|takeover| self.continued(entry.id, takeover, routes)); let previous = self.current.insert( presented.clone(), CursorRoute { @@ -1825,22 +1686,6 @@ impl TableCursor { }, ); match previous { - // A new presentation continuing another one, or this one continued in place. - None if continued.as_ref().is_some_and(|predecessor| predecessor != presented) => { - let predecessor = self.under.rejoin(&continued.unwrap()); - if let Ok(mut state) = self.state.write() { - state.apply_takeover(self.under.rejoin(presented), meta, captures, predecessor); - } - } - Some(previous) - if previous.captures == captures - && continued.as_ref().is_some_and(|predecessor| predecessor == presented) => - { - if let Ok(mut state) = self.state.write() { - let presented = self.under.rejoin(presented); - state.apply_takeover(presented.clone(), meta, captures, presented); - } - } // Captures are consumer identity, not route metadata. Replace the old // identity explicitly so consumers keyed on it remove it. Some(previous) if previous.captures != captures => { @@ -2191,19 +2036,6 @@ pub enum RouteEvent { /// request afresh to follow the new one. Subscriptions already open stay on the old /// one until dropped or its route goes. Restart(Announce), - /// A route now covers the prefix and continues what the route at `predecessor`, - /// a prefix at or above it, served beneath it. - /// - /// Whatever was resolved through the predecessor beneath the prefix is the new - /// route's instance now: keep it rather than request afresh. At the - /// predecessor's own prefix this replaces it, as a restart would; elsewhere the - /// predecessor stays, and either route ends on its own. - Takeover { - /// The route that continues. - announce: Announce, - /// The prefix of the route it continues, relative to the cursor's root. - predecessor: Path, - }, } /// A route over a prefix, as an announcement listing or a [`Routes`] stream reports it. @@ -2557,28 +2389,38 @@ impl Producer { frames: self.frames.clone(), }; let source = info.produce().with_stats(ingress); - let (instance, epoch) = options.instance.clone().unwrap_or_else(|| (Instance::random(), 0)); + // A job's route naming no instance takes its prefix route's, held in the table + // until it first announces; see [`Request::route`]. + let lender = request + .as_ref() + .filter(|_| options.instance.is_none()) + .map(|request| request.home.clone()); + let instance = match (&options.instance, &lender) { + (None, None) => Some((Instance::random(), 0)), + (named, _) => named.clone(), + }; let entry = announcing.announce( - options.info(&instance, epoch), + RouteInfo { + instance: instance.as_ref().map(|(instance, _)| instance.clone()), + epoch: instance.as_ref().map_or(0, |(_, epoch)| *epoch), + ..options.info() + }, Serving { server: None, source: Some(source.consume()), advertised: false, + lends: false, }, - None, + lender.as_ref(), )?; let hidden = options.hidden; + let control = RouteControl::new(options, &entry, request); let route = Route::new(RouteState { announcement: entry, broadcast: Some(source.clone()), server: None, publisher: None, - control: kio::Lock::new(RouteControl { - options, - instance, - epoch, - request, - }), + control: kio::Lock::new(control), _keepalive: self.tasks.keepalive(), }); // Closing the broadcast retracts the route, and refuses the request a job's route @@ -2642,6 +2484,7 @@ impl Producer { server: None, source: None, advertised: true, + lends: false, }, None, ) @@ -2722,12 +2565,19 @@ impl Producer { /// A consumer resolving a path under it through this route is handed to /// [`Route::poll_requested`] as a [`Request`]: answer it by serving a broadcast already /// held ([`Request::serve`]), under this route's instance, or by creating the path's - /// own exact route ([`Request::route`]), under a fresh instance, so restarting one - /// job never splices into a copy of the last. This is how a service answers a whole + /// own exact route ([`Request::route`]). This is how a service answers a whole /// subtree without publishing each path ahead of time; a publisher that /// knows its broadcasts creates each one's exact route with [`Self::route`] /// instead, so subscribers can enumerate them. /// + /// Unless `options` names an instance, the route lends the one it mints to the exact + /// routes answering its requests, so a reader that resolved a path through it + /// continues on that path's answer rather than restarting. Once an exact route holding + /// the lent instance stops serving it (withdrawn, closed, or renamed), this route moves + /// to a fresh instance, as a restarted job's sequences are another stream: see + /// [`Route::instance`]. A route naming a stable instance lends nothing, and its + /// answers each mint their own. + /// /// The route is priced, credentialed and shown like an exact one. It is visible /// for as long as the returned [`Route`] (and every clone) lives, unless withdrawn. /// @@ -2738,20 +2588,24 @@ impl Producer { prefix: impl TryInto>, options: RouteOptions, ) -> Result { - let (instance, epoch) = options.instance.clone().unwrap_or_else(|| (Instance::random(), 0)); - let info = options.info(&instance, epoch); + // A minted instance lives in the table, which moves it on as its jobs end; see + // [`OriginState::advance`]. + let info = RouteInfo { + instance: options.instance.as_ref().map(|(instance, _)| instance.clone()), + epoch: options.instance.as_ref().map_or(0, |(_, epoch)| *epoch), + ..options.info() + }; let advertised = !options.hidden; - self.serving_route(prefix, info, Some(self.clone()), advertised, None, options) + let lends = options.instance.is_none(); + self.serving_route(prefix, info, Some(self.clone()), advertised, lends, options) } - /// Land a peer's route with its wire metadata, continuing what the peer's - /// `predecessor` route served beneath it when the peer took it over. + /// Land a peer's route with its wire metadata. #[cfg(any(feature = "async", test))] pub(crate) fn forward( &self, prefix: impl TryInto>, mut info: RouteInfo, - predecessor: Option<&Route>, ) -> Result { // A handle that names no instance serves one of its own. let instance = info.instance.get_or_insert_with(Instance::random).clone(); @@ -2761,8 +2615,7 @@ impl Producer { auth: info.auth.clone(), instance: Some((instance, info.epoch)), }; - let predecessor = predecessor.and_then(|route| route.state.server.as_ref()); - self.serving_route(prefix, info, None, true, predecessor, options) + self.serving_route(prefix, info, None, true, false, options) } fn serving_route( @@ -2771,34 +2624,29 @@ impl Producer { info: RouteInfo, publisher: Option, advertised: bool, - predecessor: Option<&kio::Shared>, + lends: bool, options: RouteOptions, ) -> Result { let announcing = Announcing::new(self, prefix)?; let serve = kio::Shared::::default(); serve.lock().requests.add_handler(); - let instance = info.instance.clone().expect("the caller names the instance"); - let epoch = info.epoch; let announcement = announcing.announce( info, Serving { server: Some(serve.clone()), source: None, advertised, + lends, }, - predecessor, + None, )?; + let control = RouteControl::new(options, &announcement, None); Ok(Route::new(RouteState { announcement, broadcast: None, server: Some(serve), publisher, - control: kio::Lock::new(RouteControl { - options, - instance, - epoch, - request: None, - }), + control: kio::Lock::new(control), _keepalive: self.tasks.keepalive(), })) } @@ -2983,13 +2831,13 @@ impl Announcing { }) } - /// Insert the route, continuing what `predecessor`, a covering served route, served - /// beneath it, when it is one. + /// Insert the route. A job's route naming no instance reads as its `lender`'s, the + /// prefix route its request came through, until it binds one as it announces. fn announce( &self, route: RouteInfo, serving: Serving, - predecessor: Option<&kio::Shared>, + lender: Option<&kio::Shared>, ) -> Result { debug_assert!( !route.hops.contains(&self.hop), @@ -3004,18 +2852,21 @@ impl Announcing { } // A handle that names no instance serves one of its own. - let instance = route.instance.clone().unwrap_or_else(Instance::random); + let minted = route.instance.clone().unwrap_or_else(Instance::random); let mut entries = Vec::with_capacity(self.prefixes.len()); for (prefix, claim) in &self.prefixes { shared.reannounced(prefix, &route.hops); let id = shared.next_route; shared.next_route += 1; let stale = shared.withdrawn_through(prefix, &route.hops); - let takeover = predecessor.and_then(|server| shared.takeover(prefix, server)); + let (instance, epoch) = match lender.and_then(|server| shared.lender(prefix, server)) { + Some(lent) => (lent.instance, lent.epoch), + None => (minted.clone(), route.epoch), + }; shared.routes.insert(RouteEntry { id, - instance: instance.clone(), - epoch: route.epoch, + instance, + epoch, prefix: prefix.clone(), scope: self.scope.clone(), excluded: self.excluded.clone(), @@ -3033,7 +2884,8 @@ impl Announcing { stale, claim: claim.clone(), reach: reach(&self.scope, claim), - takeover, + lends: serving.lends, + lent: None, }); shared.sync_route(prefix, claim); entries.push((prefix.clone(), id)); @@ -3058,6 +2910,8 @@ struct Serving { server: Option>, source: Option, advertised: bool, + /// Whether the job routes answering it take its instance; see [`RouteEntry::lends`]. + lends: bool, } /// A route on an origin: an exact path owning one broadcast, or a prefix serving the @@ -3115,15 +2969,43 @@ struct RouteState { /// announces, withdrawals and re-prices land in order. struct RouteControl { options: RouteOptions, - instance: Instance, - epoch: u64, + /// The instance and epoch the route advertises, as last seen when [`Self::held`]. + instance: (Instance, u64), + /// Whether the table entry holds the instance: a prefix route that lends its own, which + /// moves on as its jobs end (see [`OriginState::advance`]), or a job's route taking one + /// from it. An update then names none, leaving the entry's as it is. + held: bool, + /// Whether a job's route read its instance before it first announced: it then announces + /// under that one or not at all, so whatever was built from it, such as a signer, stays + /// valid. + bound: bool, /// The request a job's route answers when it first announces. request: Option, } impl RouteControl { + fn new(options: RouteOptions, announcement: &AnnounceProducer, request: Option) -> Self { + let held = options.instance.is_none(); + let instance = announcement + .instance(None) + .expect("a route just inserted has its entries"); + Self { + options, + instance, + held, + bound: false, + request, + } + } + + /// What to advertise. fn info(&self) -> RouteInfo { - self.options.info(&self.instance, self.epoch) + let (instance, epoch) = &self.instance; + RouteInfo { + instance: (!self.held).then(|| instance.clone()), + epoch: *epoch, + ..self.options.info() + } } } @@ -3162,8 +3044,23 @@ impl Route { /// The stream this route serves, minted when it was created unless /// [`RouteOptions::with_instance`] named one. + /// + /// Unnamed, a prefix route's ([`Producer::route_prefix`]) can change: it moves to a fresh + /// instance each time an answer holding its current one ends. A job's route + /// ([`Request::route`]) takes its prefix route's current one when it first announces, + /// or when this reads it first. Read before announcing, it is the one the job announces + /// under, and [`Self::announce`] fails with [`Error::Unroutable`] once the prefix route + /// has moved past it. pub fn instance(&self) -> Instance { - self.state.control.lock().instance.clone() + let mut control = self.state.control.lock(); + if control.held && !control.bound { + let lender = control.request.as_ref().map(|request| &request.home); + if let Some(current) = self.state.announcement.instance(lender) { + control.instance = current; + } + control.bound = control.request.is_some(); + } + control.instance.0.clone() } /// Make the route visible, or keep it so. Idempotent. @@ -3173,22 +3070,27 @@ impl Route { /// /// Fails with [`Error::Closed`] once the route's broadcast closed or the origin's /// [`Driver`] was dropped, and with [`Error::Unroutable`], announcing nothing, when a - /// job's request was released or its prefix route retracted meanwhile: that route can - /// never announce. + /// job's request was released or its prefix route retracted meanwhile, or when the job + /// read its [`instance`](Self::instance) and its prefix route has moved past it since: + /// that route can never announce. In that last case the request goes back to the prefix + /// route's handler, its requesters still waiting. pub fn announce(&self) -> Result<(), Error> { let mut control = self.state.control.lock(); let info = control.info(); - let Some(request) = control.request.take() else { + let Some(mut request) = control.request.take() else { return self.state.announcement.update_if(info, None, |_| true).map(|_| ()); }; + let bound = std::mem::take(&mut control.bound).then(|| control.instance.0.clone()); + let mut moved = false; let answer = self.broadcast().consume(); let mut resolved = None; // Answered under the table lock that every release takes first, so the route // lands only for a request still pending and no release can slip in between. - // The answer continues what the prefix route served for the path: requesters - // waiting on it, and whatever resolved through the prefix route's current instance - // beneath it. Answered and taken over under one lock, so nothing resolves in between. - let admitted = self.state.announcement.update_if(info, Some(&request.home), |table| { + // A job's route naming no instance takes its prefix route's current one as it + // lands, so whatever resolved through the prefix route for the path continues on + // it rather than restarting. + let lender = control.held.then_some(&request.home); + let admitted = self.state.announcement.update_if(info, lender, |table| { // The prefix route's entries leave the table before its retraction releases the // request, so one gone from it no longer answers, even before that release. let home = table.routes.covering(&request.path).any(|entry| { @@ -3200,6 +3102,16 @@ impl Route { if !home { return false; } + // A job that read its instance announces under it or not at all. A prefix route + // that lends nothing never moves, and one no longer serving released the request. + if let Some(bound) = &bound + && table + .lender(&request.path, &request.home) + .is_some_and(|lent| lent.instance != *bound) + { + moved = true; + return false; + } // The exact route owns this answer's availability. Caching it under the // prefix would keep serving it after this route is withdrawn. resolved = request @@ -3215,6 +3127,10 @@ impl Route { // advertising through the ordinary branch now that the request is taken. self.state.announcement.retract(false); drop(control); + // The prefix route moved past the instance the job read: its requesters wait on + // for the handler to prepare the job again under the new one. + request.requeue = moved; + drop(request); return Err(admitted.err().unwrap_or(Error::Unroutable)); }; drop(control); @@ -3307,6 +3223,7 @@ impl Route { producer, home: server.clone(), publisher: self.state.publisher.clone(), + requeue: false, })) } @@ -3323,12 +3240,12 @@ impl Route { let mut control = self.state.control.lock(); match &info.instance { Some(instance) => { - control.instance = instance.clone(); - control.epoch = info.epoch; + control.instance = (instance.clone(), info.epoch); + control.held = false; } None => { - info.instance = Some(control.instance.clone()); - info.epoch = control.epoch; + info.instance = Some(control.instance.0.clone()); + info.epoch = control.instance.1; } } control.options.cost = info.cost; @@ -3359,8 +3276,8 @@ impl Route { pub(crate) fn update(&self, info: RouteInfo) -> Result<(), Error> { let mut control = self.state.control.lock(); if let Some(instance) = &info.instance { - control.instance = instance.clone(); - control.epoch = info.epoch; + control.instance = (instance.clone(), info.epoch); + control.held = false; } control.options.cost = info.cost; control.options.auth = info.auth.clone(); @@ -3416,12 +3333,12 @@ impl AnnounceProducer { /// under the table lock, so nothing ordered after that lock (a release, a /// retraction, the teardown) can change its answer before the route lands. /// - /// An entry advertised by this update continues what `predecessor`, a covering - /// served route, served beneath it; see [`Takeover`]. + /// A job's route answering its request through `lender`, a prefix route that lends + /// its instance, takes that route's instance and epoch as of now. fn update_if( &self, route: RouteInfo, - predecessor: Option<&kio::Shared>, + lender: Option<&kio::Shared>, admit: impl FnOnce(&OriginState) -> bool, ) -> Result { let mut shared = self.shared.lock(); @@ -3444,27 +3361,35 @@ impl AnnounceProducer { for (prefix, id) in &self.entries { shared.reannounced(prefix, &route.hops); let stale = shared.withdrawn_through(prefix, &route.hops); + let lent = lender.and_then(|server| shared.lender(prefix, server)); // Each entry keeps its advertised prefix; only the metadata moves. let Some(entry) = shared.routes.entry_mut(prefix, *id) else { return Err(Error::Closed); }; - // Only advertising takes over: a re-price continues nothing new. - let advertising = !entry.advertised; - if let Some(instance) = &route.instance { - // Another stream continues nothing the old one took over. - if *instance != entry.instance { - entry.takeover = None; - } - // Another stream: what its server was asked, or answered, for the old one is - // not its own. Under the table lock, so no front sees the new instance while - // an old answer can still land. A higher epoch of the same one continues it. - if *instance != entry.instance - && let Some(server) = &entry.server - { - released.extend(server.lock().renew()); + let next = match &lent { + Some(lent) => Some((lent.instance.clone(), lent.epoch)), + None => route.instance.clone().map(|instance| (instance, route.epoch)), + }; + let mut spent = None; + if let Some((instance, epoch)) = next { + if instance != entry.instance { + // Another stream: what its server was asked, or answered, for the old one + // is not its own. Under the table lock, so no front sees the new instance + // while an old answer can still land. A higher epoch of the same one + // continues it. + if let Some(server) = &entry.server { + released.extend(server.lock().renew()); + } + // The instance it took stops serving here. + if entry.advertised { + spent = entry.lent.take().map(|lender| (lender, entry.instance.clone())); + } } - entry.instance = instance.clone(); - entry.epoch = route.epoch; + entry.instance = instance; + entry.epoch = epoch; + } + if let Some(lent) = lent { + entry.lent = Some((lent.prefix, lent.id)); } entry.hops = route.hops.clone(); entry.stale = stale; @@ -3474,15 +3399,10 @@ impl AnnounceProducer { entry.node = route.node; entry.advertised = true; let claim = entry.claim.clone(); - if advertising { - // Past everything asked so far, so only that is continued. - shared.next_route += 1; - let takeover = predecessor.and_then(|server| shared.takeover(prefix, server)); - if let Some(entry) = shared.routes.entry_mut(prefix, *id) { - entry.takeover = takeover; - } - } shared.sync_route(prefix, &claim); + if let Some(((lender, lender_id), instance)) = spent { + shared.advance(&lender, lender_id, &instance); + } shared.prune_withdrawn(prefix); } // Under the table lock, as withdrawing and retracting clear it, so a racing one @@ -3497,6 +3417,18 @@ impl AnnounceProducer { Ok(true) } + /// The instance and epoch the entries carry, or `None` once they are gone. A job's + /// route not yet announced reads its `lender`'s current one, which it would bind now. + fn instance(&self, lender: Option<&kio::Shared>) -> Option<(Instance, u64)> { + let shared = self.shared.lock(); + let (prefix, id) = self.entries.first()?; + let entry = shared.routes.entry(prefix, *id)?; + if let Some(lent) = lender.and_then(|server| shared.lender(prefix, server)) { + return Some((lent.instance, lent.epoch)); + } + Some((entry.instance.clone(), entry.epoch)) + } + /// Whether the entries are advertised, or [`Error::Closed`] once they are gone. fn is_live(&self) -> Result { let shared = self.shared.lock(); @@ -3550,7 +3482,11 @@ impl AnnounceProducer { released.extend(server.lock().renew()); } let claim = entry.claim.clone(); + let spent = entry.lent.take().map(|lender| (lender, entry.instance.clone())); shared.sync_route(prefix, &claim); + if let Some(((lender, lender_id), instance)) = spent { + shared.advance(&lender, lender_id, &instance); + } } drop(shared); drop(guard); @@ -3590,6 +3526,11 @@ impl AnnounceProducer { shared.restale(prefix); } shared.sync_route(&entry.prefix, &entry.claim); + if entry.advertised + && let Some((lender, lender_id)) = &entry.lent + { + shared.advance(lender, *lender_id, &entry.instance); + } shared.prune_withdrawn(&entry.prefix); } drop(shared); @@ -3676,14 +3617,11 @@ impl OwnClaims { } fn apply(&mut self, event: RouteEvent) { - let (announce, predecessor) = match event { - RouteEvent::Start(announce) | RouteEvent::Update(announce) | RouteEvent::Restart(announce) => { - (announce, None) - } - RouteEvent::Takeover { announce, predecessor } => (announce, Some(predecessor)), + let announce = match event { + RouteEvent::Start(announce) | RouteEvent::Update(announce) | RouteEvent::Restart(announce) => announce, RouteEvent::End(announce) => { self.best.remove(&announce.prefix); - return self.node.own(announce.prefix, None, None); + return self.node.own(announce.prefix, None); } }; let route = announce.route; @@ -3694,13 +3632,12 @@ impl OwnClaims { instance, epoch: route.epoch, cost: route.cost.value(), - continues: None, upstream: false, // Only a route published here has crossed no node. learned: !route.hops.is_empty(), }; self.best.insert(announce.prefix.clone(), claim); - self.feed(announce.prefix, predecessor.as_ref()); + self.feed(announce.prefix); } /// The route not learned upstream changed at a prefix: whether the claim there may @@ -3709,23 +3646,20 @@ impl OwnClaims { match event { RouteEvent::End(announce) => { self.local.remove(&announce.prefix); - self.feed(announce.prefix, None); + self.feed(announce.prefix); } - RouteEvent::Start(announce) - | RouteEvent::Update(announce) - | RouteEvent::Restart(announce) - | RouteEvent::Takeover { announce, .. } => { + RouteEvent::Start(announce) | RouteEvent::Update(announce) | RouteEvent::Restart(announce) => { if let Some(instance) = announce.route.instance { self.local.insert(announce.prefix.clone(), instance); } - self.feed(announce.prefix, None); + self.feed(announce.prefix); } } } /// Feed the node the claim at `prefix`, marked as learned upstream unless a route /// not learned upstream serves the same instance there. - fn feed(&self, prefix: Path, predecessor: Option<&Path>) { + fn feed(&self, prefix: Path) { let Some(claim) = self.best.get(&prefix) else { return; }; @@ -3733,7 +3667,7 @@ impl OwnClaims { upstream: self.local.get(&prefix) != Some(&claim.instance), ..claim.clone() }; - self.node.own(prefix, Some(claim), predecessor); + self.node.own(prefix, Some(claim)); } } @@ -4337,26 +4271,6 @@ impl FrontServe { // Read alongside the decision, under the lock a poke takes first. self.seen = self.watch.seen(); self.front.retain_routes(|route| table.routes.covers(&self.path, route)); - // A route that took over the instance the front resolved continues it. The table - // carries the front's verdict forward as each takeover lands; this also catches - // one that landed before the front wrote its first, or won the path only later. - if let Some(instance) = self.resolved.as_mut() - && let Some(next) = self.request.write().ok().and_then(|mut pending| { - if let Some(best) = table.best_route(&self.path, self.horizon, &HashSet::new(), |_| true) { - PendingFront::continue_with(&mut pending, best); - } - match &pending.resolved { - Some(Ok(next)) if next != instance => Some(next.clone()), - _ => None, - } - }) { - for (asked, _) in self.instances.values_mut() { - if asked == instance { - *asked = next.clone(); - } - } - *instance = next; - } let best = pick( &table, &self.path, @@ -4431,14 +4345,8 @@ impl FrontServe { } match &self.resolved { Some(resolved) => resolved != asked, - // A winner that took over the instance asked continues it. - None => pick(&table, &self.path, self.horizon, &self.front, self.pin.as_ref()).is_some_and(|best| { - best.instance != *asked - && best - .takeover - .as_ref() - .is_none_or(|takeover| takeover.instance != *asked) - }), + None => pick(&table, &self.path, self.horizon, &self.front, self.pin.as_ref()) + .is_some_and(|best| best.instance != *asked), } } @@ -4732,10 +4640,10 @@ impl kio::Task for FrontServe { Action::Resolve => { // The broadcast is whatever its first source serves, from the instance // its route had when asked. - let (Some(source), Some(route)) = (self.front.serving(), self.front.serving_route()) else { + let Some(source) = self.front.serving() else { continue; }; - let Some((instance, asked)) = self.instances.get(&source).cloned() else { + let Some((instance, _)) = self.instances.get(&source).cloned() else { continue; }; // Route datagram lookups before a requester can see the broadcast, @@ -4745,24 +4653,9 @@ impl kio::Task for FrontServe { self.broadcast.serve_datagrams_from(self.sources.get(&source).cloned()); } self.resolved = Some(instance.clone()); - // Under the table's lock, as each takeover carries the front: a route - // that took over the one asked before this answer landed (a job route - // answering its prefix route's request) continues it, so requesters - // get its instance rather than one already superseded. - let table = self.shared.read(); - if let Ok(mut pending) = self.request.write() - && pending.resolved.is_none() - { - pending.first = Some(instance.clone()); - pending.resolved = Some(Ok(instance)); - pending.via = Some((route, asked)); - if let Some(best) = - table.best_route(&self.path, self.horizon, &HashSet::new(), |_| true) - { - PendingFront::continue_with(&mut pending, best); - } + if let Ok(mut pending) = self.request.write() { + pending.resolved.get_or_insert(Ok(instance)); } - drop(table); } Action::Query { track: name, source } => { let Some(io) = self.tracks.get_mut(&name) else { continue }; @@ -5266,8 +5159,7 @@ struct Watched { } enum WatchProducer { - /// A front's watch carries the front too; see [`FrontWatch`]. - Changes(kio::Producer, Option), + Changes(kio::Producer), #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] Selection(kio::Producer), } @@ -5276,11 +5168,6 @@ struct Selected { path: Path, via: Hop, instance: Instance, - /// The prefix of the route the instance came through. - prefix: Path, - /// [`OriginState::next_route`] when selected: only a [`Takeover`] after that - /// continues the instance. - since: u64, /// The selection period, closed when the peer stops selecting the instance. current: Option>, /// Open until another instance of the peer takes the path, which may answer a @@ -5300,21 +5187,6 @@ enum Change { } impl Selected { - /// Follow `best` when it took over the selected instance: what the peer served - /// through it is the new instance now, so the current period carries on. A period - /// already over stays over. - fn continue_with(&mut self, best: &RouteEntry) { - if self.current.is_some() - && let Some(takeover) = &best.takeover - && takeover.instance == self.instance - && takeover.prefix == self.prefix - && self.since < takeover.at - { - self.instance = best.instance.clone(); - self.prefix = best.prefix.clone(); - } - } - /// How the period changes now that the peer's best route is `best`, if it does. fn change(&self, best: Option<&RouteEntry>) -> Option { let Some(best) = best else { @@ -5665,15 +5537,10 @@ impl RouteNode { } /// Wake route watchers, and revoke only leases whose selected instance changed. - /// `taken` lists the routes taken over by the change: only a front resolved through - /// one of them can follow it. - fn poke(&self, routes: Option<(&RouteTable, &[u64])>) { + fn poke(&self, routes: Option<&RouteTable>) { for (_, watch) in &self.watches { match watch { - WatchProducer::Changes(watch, front) => { - if let (Some(front), Some((routes, taken))) = (front, routes) { - front.follow(routes, taken); - } + WatchProducer::Changes(watch) => { if let Ok(mut watched) = watch.write() { watched.generation += 1; } @@ -5681,16 +5548,12 @@ impl RouteNode { WatchProducer::Selection(watch) => { if let Ok(mut selected) = watch.write() { let change = match routes { - Some((routes, _)) => { - let best = - routes.best_route(&selected.path, Horizon::default(), &HashSet::new(), |entry| { - entry.via == selected.via - }); - if let Some(best) = best { - selected.continue_with(best); - } - selected.change(best) - } + Some(routes) => selected.change(routes.best_route( + &selected.path, + Horizon::default(), + &HashSet::new(), + |entry| entry.via == selected.via, + )), None => selected.current.is_some().then_some(Change::End), }; // Only a change wakes the requests waiting on the selection. @@ -5704,13 +5567,13 @@ impl RouteNode { } /// Wake only the watched subtrees covered by this route. - fn poke_below(&self, routes: &RouteTable, taken: &[u64]) { + fn poke_below(&self, routes: &RouteTable) { if self.watches_below == 0 { return; } - self.poke(Some((routes, taken))); + self.poke(Some(routes)); for child in self.children.values() { - child.poke_below(routes, taken); + child.poke_below(routes); } } @@ -5901,13 +5764,13 @@ impl RouteTable { } /// Register a watch on the routes covering `path`; see [`Watch`]. - fn add_watch(&mut self, path: &Path, id: u64, front: Option) -> kio::Consumer { + fn add_watch(&mut self, path: &Path, id: u64) -> kio::Consumer { let producer = kio::Producer::::default(); let consumer = producer.consume(); self.root .reach(path.parts(), Below::WATCH) .watches - .push((id, WatchProducer::Changes(producer, front))); + .push((id, WatchProducer::Changes(producer))); consumer } @@ -5949,18 +5812,9 @@ impl RouteTable { } /// Wake the watches of every path a route at `prefix` covers. - /// A route at `prefix` that took over as of `now` carries the fronts resolved through - /// its predecessor along; see [`FrontWatch`]. - fn poke_below(&self, prefix: &Path, now: u64) { + fn poke_below(&self, prefix: &Path) { if let (_, Some(node)) = self.split(prefix) { - let taken: Vec = node - .entries - .iter() - .filter_map(|entry| entry.takeover.as_ref()) - .filter(|takeover| takeover.at == now) - .map(|takeover| takeover.route) - .collect(); - node.poke_below(self, &taken); + node.poke_below(self); } } @@ -6131,7 +5985,6 @@ impl OriginState { fn sync_route(&mut self, prefix: &Path, claim: &Pattern) { // Split borrows: the recompute reads `routes` while mutating a cursor. let routes = &self.routes; - let now = self.next_route; let mut candidates = None; for id in routes.cursors_touching(prefix) { let Some(cursor) = self.cursors.get_mut(&id) else { @@ -6140,7 +5993,7 @@ impl OriginState { if let Some(presented) = cursor.presented(prefix, claim) { if presented.is_empty() { // A cursor root can collapse several covering prefixes into one. - Self::sync_cursor(routes, cursor, &presented, now); + Self::sync_cursor(routes, cursor, &presented); } else { // Every non-root presentation refers to this exact prefix. Rank // its routes once, then take each cursor's first eligible visible entry. @@ -6158,12 +6011,12 @@ impl OriginState { .filter(|entry| cursor.visible(entry)); let epochs = Epochs::of(visible.clone()); let best = visible.clone().find(|entry| !epochs.superseded(entry)); - cursor.update(&presented, best, now, routes); + cursor.update(&presented, best); } } } // The fronts and requesters under the prefix re-select from the table. - routes.poke_below(prefix, now); + routes.poke_below(prefix); } /// Start a [`Snapshot`] at the absolute `prefix` for the routes arriving on `link`. @@ -6263,35 +6116,9 @@ impl OriginState { /// Register a [`Watch`] on the routes covering `path`. fn watch(&mut self, shared: &SendCell>, path: &Path) -> Watch { - self.watch_inner(shared, path, None) - } - - /// Register a front's [`Watch`]: the table also carries the front through each - /// takeover; see [`FrontWatch`]. - fn watch_front( - &mut self, - shared: &SendCell>, - path: &Path, - horizon: Horizon, - front: kio::Producer, - ) -> Watch { - let front = FrontWatch { - path: path.clone(), - horizon, - front, - }; - self.watch_inner(shared, path, Some(front)) - } - - fn watch_inner( - &mut self, - shared: &SendCell>, - path: &Path, - front: Option, - ) -> Watch { let id = self.next_watch; self.next_watch += 1; - let signal = self.routes.add_watch(path, id, front); + let signal = self.routes.add_watch(path, id); Watch { shared: shared.clone(), path: path.clone(), @@ -6302,7 +6129,7 @@ impl OriginState { /// Recompute the best visible route presenting at `presented` (relative) for /// one cursor and deliver the change, if any. - fn sync_cursor(routes: &RouteTable, cursor: &mut TableCursor, presented: &Path, now: u64) { + fn sync_cursor(routes: &RouteTable, cursor: &mut TableCursor, presented: &Path) { // The entries presenting here are the ones announced at the absolute // prefix, or, for the cursor's own root, at the root and every prefix // above it (all of which present as the empty path). Among them, the @@ -6316,7 +6143,7 @@ impl OriginState { } }; - cursor.update(presented, best, now, routes); + cursor.update(presented, best); } /// Register a cursor and replay the current best route per presented prefix. @@ -6350,7 +6177,7 @@ impl OriginState { } } for p in &presented { - Self::sync_cursor(&self.routes, &mut cursor, p, self.next_route); + Self::sync_cursor(&self.routes, &mut cursor, p); } for head in &cursor.heads { self.routes.add_cursor(head, id); @@ -6454,19 +6281,53 @@ impl OriginState { self.routes.best_route(path, horizon, excluded, only) } + /// The prefix route `server` answers through for `path`, when it lends its instance to + /// the job routes answering its requests and its scope authorizes `path`. + fn lender(&self, path: &Path, server: &kio::Shared) -> Option { + self.routes + .covering(path) + .find(|entry| { + entry.lends && entry.live() && entry.server.as_ref().is_some_and(|other| other.same_channel(server)) + }) + .filter(|entry| entry.authorizes(path)) + .map(|entry| Lent { + prefix: entry.prefix.clone(), + id: entry.id, + instance: entry.instance.clone(), + epoch: entry.epoch, + }) + } + + /// A job's route carrying `spent`, the instance it took from the prefix route `id` at + /// `prefix`, stopped serving it: that prefix route moves to a fresh instance. + /// + /// One instance is one stream at each path, and the job's sequences end with it. Were the + /// prefix route to keep the instance, its next answer for the path, a restarted job, + /// would splice a new sequence space into everything that holds the old one, and nothing + /// short of remembering every path it ever answered could tell. Under the table's lock, + /// with the job's retraction, so no request resolves the path through the old instance + /// in between. A prefix route already past `spent` has nothing to do. + fn advance(&mut self, prefix: &Path, id: u64, spent: &Instance) { + let Some(entry) = self.routes.entry_mut(prefix, id) else { + return; + }; + if entry.instance != *spent { + return; + } + entry.instance = Instance::random(); + entry.epoch = 0; + let claim = entry.claim.clone(); + self.sync_route(prefix, &claim); + } + /// Whether `instance` still serves `path` as a request would resolve it now: the winning - /// route carries it or took it over, or, for a request `pinned` to it, a route of its own - /// remains. The rule [`Consumer::lookup`] resolves by. + /// route carries it, or, for a request `pinned` to it, a route of its own remains. The + /// rule [`Consumer::lookup`] resolves by. fn still_serves(&self, path: &Path, horizon: Horizon, instance: &Instance, pinned: bool) -> bool { let Some(best) = self.best_route(path, horizon, &HashSet::new(), |_| true) else { return false; }; - if best.instance == *instance - || best - .takeover - .as_ref() - .is_some_and(|takeover| takeover.instance == *instance) - { + if best.instance == *instance { return true; } pinned @@ -6474,24 +6335,14 @@ impl OriginState { .best_route(path, horizon, &HashSet::new(), |entry| entry.instance == *instance) .is_some() } +} - /// The covering served route `server` answers through, taken over by a route at - /// `prefix` now: `None` unless it is live and its scope authorizes `prefix`. - fn takeover(&self, prefix: &Path, server: &kio::Shared) -> Option { - let entry = self - .routes - .covering(prefix) - .find(|entry| entry.live() && entry.server.as_ref().is_some_and(|other| other.same_channel(server)))?; - if !entry.authorizes(prefix) { - return None; - } - Some(Takeover { - route: entry.id, - instance: entry.instance.clone(), - prefix: entry.prefix.clone(), - at: self.next_route, - }) - } +/// A prefix route lending its instance, as [`OriginState::lender`] found it. +struct Lent { + prefix: Path, + id: u64, + instance: Instance, + epoch: u64, } /// One-shot result of a dynamic broadcast request. @@ -6512,13 +6363,6 @@ struct PendingBroadcast { /// the front held. struct PendingFront { resolved: Option>, - /// The route the front resolved through, and [`OriginState::next_route`] when it - /// asked: only a [`Takeover`] after that continues it. Each route that takes the - /// front over carries this forward; see [`Self::continue_with`]. - via: Option<(u64, u64)>, - /// The instance the front resolved first, which each [`Takeover`] since carried on - /// as `resolved`: a request naming it reads what continues it. - first: Option, /// The instance the front resolves on unless the table moves first: the winner it /// was minted for, then each one it selects. Written under the table's lock, so a /// request never joins a front that picked an instance the request saw beaten. @@ -6527,58 +6371,6 @@ struct PendingFront { refused: bool, } -impl PendingFront { - /// Whether a route that took over by `takeover` continues the instance the front - /// resolved: through the route it resolved that through, after the front asked. - fn continued_by(&self, takeover: Option<&Takeover>) -> bool { - let (Some(Ok(resolved)), Some((route, asked)), Some(takeover)) = (&self.resolved, self.via, takeover) else { - return false; - }; - takeover.route == route && takeover.instance == *resolved && asked < takeover.at - } - - /// Follow `best` when it took over what the front resolved: the front, its sources, - /// and the requests joining it carry the new instance. Under the table's lock as each - /// route takes over (see [`FrontWatch`]), so a chain of takeovers carries the front to - /// its end, however many land before the front's task looks. - fn continue_with(this: &mut kio::Mut<'_, Self>, best: &RouteEntry) { - // Read first: a write through the guard wakes every requester. - if this.continued_by(best.takeover.as_ref()) { - let asked = this.via.map(|(_, asked)| asked); - this.resolved = Some(Ok(best.instance.clone())); - this.via = asked.map(|asked| (best.id, asked)); - } - } -} - -/// A front's [`Watch`] as the route table holds it: what the front is for, so the table -/// can carry its [`PendingFront`] through a takeover as the takeover lands. -struct FrontWatch { - path: Path, - horizon: Horizon, - front: kio::Producer, -} - -impl FrontWatch { - /// Follow the path's winner, when it took over what the front resolved through one - /// of the routes `taken` over just now. - fn follow(&self, routes: &RouteTable, taken: &[u64]) { - if taken.is_empty() { - return; - } - let Ok(mut pending) = self.front.write() else { - return; - }; - // Skip the walk for a front no takeover here can continue. - if !pending.via.is_some_and(|(route, _)| taken.contains(&route)) { - return; - } - if let Some(best) = routes.best_route(&self.path, self.horizon, &HashSet::new(), |_| true) { - PendingFront::continue_with(&mut pending, best); - } - } -} - impl Route { #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(super) fn request_validity(&self, path: &Path) -> Option { @@ -6593,7 +6385,6 @@ impl Route { })?; let via = entry.via; let instance = entry.instance.clone(); - let prefix = entry.prefix.clone(); let active = table .best_route(path, Horizon::default(), &HashSet::new(), |entry| entry.via == via) .is_some_and(|entry| entry.instance == instance); @@ -6601,8 +6392,6 @@ impl Route { path: path.clone(), via, instance, - prefix, - since: table.next_route, current: active.then(kio::Producer::default), answering: active.then(kio::Producer::default), }); @@ -6712,6 +6501,18 @@ impl ServeState { fn forget(shared: &kio::Shared, path: &Path, producer: &kio::Producer) { shared.lock().requests.remove_if(path, |p| p.same_channel(producer)); } + + /// Queue the still-pending entry again for the next handler, its requesters still waiting, + /// or drop it as [`Self::forget`] does when no handler is left to take it. + fn requeue(shared: &kio::Shared, path: &Path, producer: &kio::Producer) { + let mut state = shared.lock(); + if !state.requests.get(path).is_some_and(|p| p.same_channel(producer)) { + return; + } + if !state.requests.requeue(path.clone()) { + state.requests.remove_if(path, |p| p.same_channel(producer)); + } + } } /// A pending request for a broadcast to be served on demand. @@ -6719,7 +6520,8 @@ impl ServeState { /// Yielded by [`Route::poll_requested`]. The requester is awaiting inside /// [`Consumer::request`]. Answer it one of three ways: [`serve`](Self::serve) a /// broadcast already held, under the prefix route's instance; create the path's own -/// exact route with [`route`](Self::route), under a fresh instance; or +/// exact route with [`route`](Self::route), which continues the instance the prefix route +/// lends until it ends; or /// [`reject`](Self::reject) it with an error. Dropping it rejects it. /// /// Serve a held broadcast without announcing an exact route: @@ -6744,6 +6546,10 @@ pub struct Request { // Applications can create routes in this origin; a session-forwarded request // can only serve its peer's already-routed broadcast. publisher: Option, + + // Dropped back onto its queue rather than refused: its job read an instance the prefix + // route has since moved past, so the handler answers it again. + requeue: bool, } impl Request { @@ -6754,9 +6560,12 @@ impl Request { /// Answer with the path's own exact route, owning a new broadcast. /// - /// The route serves a fresh instance unless `options` names one, so a job that ends - /// and restarts beneath the same prefix is a new stream for its restarted sequences. - /// Ending the route withdraws only this path: the prefix route keeps answering later + /// Unless `options` names an instance, the route takes the one its prefix route lends + /// when it first announces or [`Route::instance`] first reads it, so whatever resolved the path through the prefix route + /// continues on this answer; a prefix route that lends nothing has the route mint its + /// own. Ending the route withdraws only this path, and moves a prefix route that lent + /// its instance to a fresh one, so a job that ends and restarts beneath the same prefix + /// is a new stream for its restarted sequences. The prefix route keeps answering later /// requests. Created visible, the route announces now and answers the requesters /// waiting on its path. Created [`RouteOptions::hidden`], they wait until /// [`Route::announce`], so the job can fill its catalog first; dropping the route @@ -6777,7 +6586,7 @@ impl Request { /// The answer belongs to the prefix route's instance at this path: every answer for /// that path must continue the same sequence space and bytes. Start a new prefix /// route to serve different content there. For a job that may restart independently, - /// use [`Self::route`] to announce its own instance. + /// use [`Self::route`]: its end moves the prefix route to another instance. pub fn serve(self, broadcast: impl Consume) { let broadcast = broadcast.consume(); let _ = ServeState::resolve(&self.home, &self.path, &self.producer, Ok(broadcast)); @@ -6792,6 +6601,12 @@ impl Request { impl Drop for Request { fn drop(&mut self) { + // Requeued in the same lock as the removal it replaces, so no handler pops it while + // this one still holds it. + if self.requeue { + ServeState::requeue(&self.home, &self.path, &self.producer); + return; + } // Handed off but neither accepted nor rejected: drop the still-pending entry so its // producer clone (plus this one) closes the channel, resolving coalesced requesters to // `Unroutable` rather than hanging. @@ -6881,20 +6696,14 @@ impl Lookup { RequestState::Failed(error) => Poll::Ready(Err(error.clone())), RequestState::Pending { front, broadcast } => Poll::Ready( match ready!(front.poll(waiter, |state| match &state.resolved { - Some(result) => Poll::Ready(result.clone().map(|instance| (instance, state.first.clone())),), + Some(result) => Poll::Ready(result.clone()), None => Poll::Pending, })) { - // Another instance than the one named, and not one continuing it: never hand - // it out. - Ok(Ok((instance, first))) - if self - .instance - .as_ref() - .is_some_and(|named| *named != instance && Some(named) != first.as_ref()) => - { + // Another instance than the one named: never hand it out. + Ok(Ok(instance)) if self.instance.as_ref().is_some_and(|named| *named != instance) => { Err(Error::Unroutable) } - Ok(Ok((instance, _))) => Ok(self.hand_out(broadcast.clone(), instance)), + Ok(Ok(instance)) => Ok(self.hand_out(broadcast.clone(), instance)), Ok(Err(err)) => Err(err), // The front's driver went away without a verdict: nobody could route it. Err(_closed) => Err(Error::Unroutable), @@ -6938,10 +6747,7 @@ impl Requesting { match discovery.poll_next(waiter) { Poll::Ready(Some(RouteEvent::End(_))) => self.routed = false, Poll::Ready(Some( - RouteEvent::Start(announce) - | RouteEvent::Restart(announce) - | RouteEvent::Update(announce) - | RouteEvent::Takeover { announce, .. }, + RouteEvent::Start(announce) | RouteEvent::Restart(announce) | RouteEvent::Update(announce), )) => { self.routed = self.instance.is_none() || announce.route.instance == self.instance; } @@ -7495,7 +7301,7 @@ impl Consumer { } /// Every change to the routes announced in this consumer's scope, for relays and - /// stats: route metadata, instance replacements and takeovers included. + /// stats: route metadata and instance replacements included. /// /// Allocates a per-cursor coalescing buffer and replays the currently /// announced routes as initial updates. Routes stay prefixes and are named @@ -7853,22 +7659,15 @@ impl Consumer { // Nothing serves the path: no announced broadcast and no served route. // Checked before joining a front, so a front still draining after its // route retracted takes no newcomers. - let Some((winner, takeover)) = state + let Some(winner) = state .best_route(&absolute, horizon, &HashSet::new(), |_| true) - .map(|best| (best.instance.clone(), best.takeover.clone())) + .map(|best| best.instance.clone()) else { return kio::Pending::new(Lookup::failed(Error::Unroutable)); }; // A request naming another instance than the winner follows that instance's own - // routes: every hop resolves the instance the first one chose. A winner that took - // over the named instance continues it, so the request is the winner's. - let pin = instance - .filter(|instance| { - **instance != winner && takeover.as_ref().is_none_or(|takeover| takeover.instance != **instance) - }) - .cloned(); - // Only the winner's takeover can continue a front this request would join. - let takeover = takeover.filter(|_| pin.is_none()); + // routes: every hop resolves the instance the first one chose. + let pin = instance.filter(|instance| **instance != winner).cloned(); let best = match &pin { None => winner, Some(pin) @@ -7894,9 +7693,7 @@ impl Consumer { let pending = front.request.read(); match &pending.resolved { None => pending.selected == best, - // Or the winner took over what the front resolved, which the front's task - // adopts on its next look at the table. - Some(Ok(resolved)) => *resolved == best || pending.continued_by(takeover.as_ref()), + Some(Ok(resolved)) => *resolved == best, Some(Err(_)) => false, } }; @@ -7930,15 +7727,13 @@ impl Consumer { }); let request = kio::Producer::new(PendingFront { resolved: None, - via: None, - first: None, selected: best, refused: false, }); let consumer = request.consume(); // The front starts held by this request; see [`Front::new`]. let held = broadcast.consume(); - let watch = state.watch_front(&self.shared, &absolute, horizon, request.clone()); + let watch = state.watch(&self.shared, &absolute); state.fronts.insert( key, RemoteFront { @@ -8209,10 +8004,7 @@ impl Routes { /// Drive the egress announce guards for one update. fn hand_out(&mut self, event: RouteEvent) -> RouteEvent { match &event { - RouteEvent::Start(announce) - | RouteEvent::Update(announce) - | RouteEvent::Restart(announce) - | RouteEvent::Takeover { announce, .. } => { + RouteEvent::Start(announce) | RouteEvent::Update(announce) | RouteEvent::Restart(announce) => { let scope = self.stats.egress(self.root.rejoin(&announce.prefix)); self.guards .entry(announce.prefix.clone()) @@ -8563,11 +8355,7 @@ impl TestSelection { pub(crate) fn new() -> Self { let origin = Hop::new(1).unwrap().produce(); let route = origin - .forward( - "room", - RouteInfo::default().with_via(Hop::new(Self::PEER).unwrap()), - None, - ) + .forward("room", RouteInfo::default().with_via(Hop::new(Self::PEER).unwrap())) .unwrap(); Self { origin, @@ -8589,7 +8377,7 @@ impl TestSelection { /// Another instance of the peer takes the path. pub(crate) fn replace(&mut self) { let route = RouteInfo::default().with_via(Hop::new(Self::PEER).unwrap()); - self.replacement = Some(self.origin.forward("room/a", route, None).unwrap()); + self.replacement = Some(self.origin.forward("room/a", route).unwrap()); } } @@ -8652,7 +8440,7 @@ mod tests { let waiter = kio::Waiter::noop(); let (producer, mut driver) = Producer::new(Config::from(origin(1))); let server = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let broadcast = broadcast::Info::new().produce(); let track = broadcast.reserve_track("video").unwrap(); @@ -9157,7 +8945,7 @@ mod tests { #[cfg(feature = "async")] async fn mount_resolves_on_the_target_front() { let producer = origin(1).produce(); - let server = producer.forward(".svc", RouteInfo::default(), None).unwrap(); + let server = producer.forward(".svc", RouteInfo::default()).unwrap(); let project = mounted(&producer, "p1", &["**"]); let through = project.request(".svc/foo", None); @@ -9344,11 +9132,11 @@ mod tests { Err(Error::Unauthorized) )); assert!(matches!( - project.forward(".svc", RouteInfo::default(), None), + project.forward(".svc", RouteInfo::default()), Err(Error::Unauthorized) )); assert!(matches!( - project.forward(".svc/foo", RouteInfo::default(), None), + project.forward(".svc/foo", RouteInfo::default()), Err(Error::Unauthorized) )); let _cam = project.route("cam", RouteOptions::default()).unwrap(); @@ -9689,7 +9477,6 @@ mod tests { .with_instance(format!("worker-{id}").parse().unwrap(), 0) .with_hops(hops(&[id])) .with_cost(3), - None, ) .unwrap() }) @@ -9760,9 +9547,7 @@ mod tests { async fn a_reprice_is_an_update() { let producer = origin(1).produce(); let mut announced = producer.consume().routes(); - let route = producer - .forward("room", RouteInfo::default().with_cost(3), None) - .unwrap(); + let route = producer.forward("room", RouteInfo::default().with_cost(3)).unwrap(); announced.expect_next_active("room").await; route.update(RouteInfo::default().with_cost(5)).unwrap(); @@ -9783,7 +9568,7 @@ mod tests { let producer = origin(1).produce(); let mut announced = producer.consume().routes(); let route = producer - .forward("room", RouteInfo::default().with_auth(first.clone()), None) + .forward("room", RouteInfo::default().with_auth(first.clone())) .unwrap(); assert_eq!(announced.expect_next_active("room").await.auth, Some(first)); @@ -9817,7 +9602,6 @@ mod tests { RouteInfo::default() .with_instance("a".parse().unwrap(), 0) .with_hops(hops(&[11])), - None, ) .unwrap(); announced.expect_next_active("pool/job-0").await; @@ -9828,7 +9612,6 @@ mod tests { RouteInfo::default() .with_instance("b".parse().unwrap(), 0) .with_hops(hops(&[10])), - None, ) .unwrap(); announced.assert_next_wait(); @@ -9866,12 +9649,12 @@ mod tests { let consumer = producer.consume(); let mut announced = consumer.routes(); let _old = producer - .forward("pool/job-0", RouteInfo::default().with_hops(hops(&[old_hop])), None) + .forward("pool/job-0", RouteInfo::default().with_hops(hops(&[old_hop]))) .unwrap(); announced.expect_next_active("pool/job-0").await; let new = producer - .forward("pool/job-0", RouteInfo::default().with_hops(hops(&[new_hop])), None) + .forward("pool/job-0", RouteInfo::default().with_hops(hops(&[new_hop]))) .unwrap(); assert_eq!( announced.expect_next_restarted("pool/job-0").await.hops, @@ -9898,11 +9681,7 @@ mod tests { claims .scope("", &scopes(&[scope])) .unwrap() - .forward( - prefix, - RouteInfo::default().with_hops(hops(&[hop])).with_cost(cost), - None, - ) + .forward(prefix, RouteInfo::default().with_hops(hops(&[hop])).with_cost(cost)) .unwrap() }; (producer, claim) @@ -10049,10 +9828,10 @@ mod tests { let consumer = producer.consume(); let anonymous = producer - .forward("room", RouteInfo::default().with_hops(hops(&[0])).with_cost(1), None) + .forward("room", RouteInfo::default().with_hops(hops(&[0])).with_cost(1)) .unwrap(); let identified = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])).with_cost(5), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10])).with_cost(5)) .unwrap(); let _pending = consumer.request("room/alice", None); @@ -10073,10 +9852,10 @@ mod tests { let demoted = producer .clone() .with_preference(Preference::new(-1)) - .forward("room", RouteInfo::default().with_hops(hops(&[10])).with_cost(1), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10])).with_cost(1)) .unwrap(); let unranked = producer - .forward("room", RouteInfo::default().with_hops(hops(&[11])).with_cost(5), None) + .forward("room", RouteInfo::default().with_hops(hops(&[11])).with_cost(5)) .unwrap(); let _pending = consumer.request("room/alice", None); @@ -10108,11 +9887,7 @@ mod tests { let mut announced = producer.consume().routes(); let dynamic = producer - .forward( - "room", - RouteInfo::default().with_instance(instance(), 0).with_cost(5), - None, - ) + .forward("room", RouteInfo::default().with_instance(instance(), 0).with_cost(5)) .unwrap(); announced.expect_next_active("room").await; dynamic.update(dynamic.route().unwrap().with_cost(1)).unwrap(); @@ -10259,7 +10034,6 @@ mod tests { .with_instance(instance(), 0) .with_hops(hops(&[10])) .with_cost(1), - None, ) .unwrap(); let route = announced.expect_next_active("room/alice").await; @@ -10318,7 +10092,6 @@ mod tests { .with_instance(instance(), 0) .with_hops(hops(&[10])) .with_cost(1), - None, ) .unwrap(); let request = queued(&server).await; @@ -10442,7 +10215,6 @@ mod tests { .with_instance(instance(), 0) .with_hops(hops(&[10])) .with_cost(1), - None, ) .unwrap(); let upstream = broadcast::Info::new().produce(); @@ -10454,133 +10226,6 @@ mod tests { assert_eq!(listed(&mut tracks).await, "-a"); } - /// A takeover under an unnamed route, in place, continues the front too: it serves from - /// the successor once the predecessor retires rather than ending with no route. - #[kio::test] - #[cfg(feature = "async")] - async fn a_front_follows_an_unnamed_takeover_in_place() { - let producer = origin(1).produce(); - let consumer = producer.consume(); - let first = producer - .forward("room/alice", RouteInfo::default().with_hops(hops(&[10])), None) - .unwrap(); - let pending = consumer.request("room/alice", None); - let upstream = broadcast::Info::new().produce(); - let _a = upstream.create_track("a", None).unwrap(); - queued(&first).await.serve(&upstream); - let front = pending.await.unwrap(); - let mut tracks = front.tracks().unwrap(); - assert_eq!(listed(&mut tracks).await, "+a"); - assert_eq!(listed(&mut tracks).await, "synced"); - - let second = producer - .forward("room/alice", RouteInfo::default().with_hops(hops(&[10])), Some(&first)) - .unwrap(); - kio::clock::sim::yield_now().await; - drop(first); - - let successor = broadcast::Info::new().produce(); - let _a = successor.create_track("a", None).unwrap(); - let _b = successor.create_track("b", None).unwrap(); - kio::clock::timeout(Duration::from_secs(1), queued(&second)) - .await - .expect("the front asked the successor") - .serve(&successor); - assert_eq!(listed(&mut tracks).await, "+b"); - assert!(!front.is_closed()); - } - - /// A chain of takeovers in place that all land before the front's task looks again - /// carries the front to the last one, though each names only the one before it. - #[kio::test] - #[cfg(feature = "async")] - async fn a_front_follows_a_chain_of_takeovers() { - for named in [false, true] { - for links in [2, 8] { - follows_a_chain_of_takeovers(links, named).await; - } - } - } - - #[cfg(feature = "async")] - async fn follows_a_chain_of_takeovers(links: u64, named: bool) { - let route = |link: u64| { - let route = RouteInfo::default().with_hops(hops(&[10])); - match named { - true => route.with_instance(format!("link-{link}").parse().unwrap(), 0), - false => route, - } - }; - let producer = origin(1).produce(); - let consumer = producer.consume(); - let first = producer.forward("room/alice", route(0), None).unwrap(); - let pending = consumer.request("room/alice", None); - let upstream = broadcast::Info::new().produce(); - let _a = upstream.create_track("a", None).unwrap(); - queued(&first).await.serve(&upstream); - let front = pending.await.unwrap(); - let mut tracks = front.tracks().unwrap(); - assert_eq!(listed(&mut tracks).await, "+a"); - assert_eq!(listed(&mut tracks).await, "synced"); - - // Each link takes over the one before and retires it, with no yield between. - let mut last = first; - for link in 1..=links { - let next = producer.forward("room/alice", route(link), Some(&last)).unwrap(); - drop(std::mem::replace(&mut last, next)); - } - - let successor = broadcast::Info::new().produce(); - let _a = successor.create_track("a", None).unwrap(); - let _b = successor.create_track("b", None).unwrap(); - kio::clock::timeout(Duration::from_secs(1), queued(&last)) - .await - .expect("the front asked the chain's last route") - .serve(&successor); - assert_eq!(listed(&mut tracks).await, "+b", "{links} links, named: {named}"); - assert!(!front.is_closed()); - // The front carries the last instance, so a request now joins it. - let again = consumer.request("room/alice", None).await.unwrap(); - assert!(again.is_clone(&front), "{links} links, named: {named}"); - } - - /// A successor renewed to another instance continues nothing its predecessor served, even - /// before the front resolved through the predecessor looks at the table again. - #[kio::test] - #[cfg(feature = "async")] - async fn a_renewed_successor_continues_nothing() { - let route = |link: u64| { - RouteInfo::default() - .with_hops(hops(&[10])) - .with_instance(format!("link-{link}").parse().unwrap(), 0) - }; - let producer = origin(1).produce(); - let consumer = producer.consume(); - let first = producer.forward("room/alice", route(0), None).unwrap(); - let pending = consumer.request("room/alice", None); - let upstream = broadcast::Info::new().produce(); - queued(&first).await.serve(&upstream); - let resolved = pending.await.unwrap(); - - // Taken over, then renewed, with no yield between. - let second = producer.forward("room/alice", route(1), Some(&first)).unwrap(); - second.update(route(2)).unwrap(); - kio::clock::sim::yield_now().await; - - let pending = consumer.request("room/alice", None); - let renewed = broadcast::Info::new().produce(); - kio::clock::timeout(Duration::from_secs(1), queued(&second)) - .await - .expect("a fresh front asked the renewed route") - .serve(&renewed); - let again = pending.await.unwrap(); - assert_eq!(again.info().instance, route(2).instance); - assert!( - !again.is_clone(&resolved), - "the renewed instance joined the first one's front" - ); - } - /// A path served on request through a covering prefix is not a broadcast anyone /// announced, so it has nothing to list. #[kio::test] @@ -10588,7 +10233,7 @@ mod tests { async fn a_front_through_a_covering_prefix_lists_nothing() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.forward("room", RouteInfo::default(), None).unwrap(); + let server = producer.forward("room", RouteInfo::default()).unwrap(); let pending = consumer.request("room/alice", None); let upstream = broadcast::Info::new().produce(); let _video = upstream.create_track("video", None).unwrap(); @@ -10609,7 +10254,7 @@ mod tests { let consumer = producer.consume(); let _local = producer.route("room/alice", RouteOptions::default()).unwrap(); - let server = producer.forward("room/alice", RouteInfo::default(), None).unwrap(); + let server = producer.forward("room/alice", RouteInfo::default()).unwrap(); // Were the claim to win, the request would park on its handler forever. let resolved = kio::clock::timeout(Duration::from_secs(1), consumer.request("room/alice", None)) @@ -10667,7 +10312,6 @@ mod tests { .with_instance(instance(), 0) .with_hops(hops(&[10])) .with_cost(2), - None, ) .unwrap(); announced.expect_next_active("room/alice").await; @@ -10745,7 +10389,6 @@ mod tests { RouteInfo::default() .with_instance(crate::Instance::random(), 0) .with_hops(hops(&[10])), - None, ) .unwrap() }); @@ -10788,7 +10431,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let server = producer - .forward("room/alice", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room/alice", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let pending = consumer.request("room/alice", None); let upstream = broadcast::Info::new().produce(); @@ -10869,7 +10512,7 @@ mod tests { let producer = origin(1).produce(); // A prefix route, which resolves any path beneath it optimistically. let server = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let upstream = broadcast::Info::new().produce(); let source = upstream.create_track("video", crate::track::Info::timed()).unwrap(); @@ -10921,7 +10564,7 @@ mod tests { async fn a_hop_in_a_chain_gets_its_own_front() { let producer = origin(1).produce(); let _incumbent = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let fronts = || producer.shared.lock().fronts.len(); @@ -10932,7 +10575,7 @@ mod tests { // The same publisher, now also reached through the peer. let _echo = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10, 7])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10, 7]))) .unwrap(); let _filtered = peer.request("room/alice", None); assert_eq!(fronts(), 2, "the peer still shares the plain front"); @@ -10948,7 +10591,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let server = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let upstream = broadcast::Info::new().produce(); let source = upstream.create_track("video", crate::track::Info::timed()).unwrap(); @@ -11009,7 +10652,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let server = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let upstream = broadcast::Info::new().produce(); let source = upstream.create_track("video", None).unwrap(); @@ -11054,8 +10697,6 @@ mod tests { // Nothing joins this front, and its first selection overwrites the instance. request: kio::Producer::new(PendingFront { resolved: None, - via: None, - first: None, selected: instance(), refused: false, }), @@ -11079,15 +10720,11 @@ mod tests { async fn a_peers_filtered_front_ends_once_unread() { let producer = origin(1).produce(); let server = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); // The same publisher, also reached through the peer, at a price the plain front skips. let _echo = producer - .forward( - "room", - RouteInfo::default().with_hops(hops(&[10, 7])).with_cost(100), - None, - ) + .forward("room", RouteInfo::default().with_hops(hops(&[10, 7])).with_cost(100)) .unwrap(); let upstream = broadcast::Info::new().produce(); let source = upstream.create_track("video", crate::track::Info::timed()).unwrap(); @@ -11126,7 +10763,6 @@ mod tests { .forward( "room", RouteInfo::default().with_instance(instance(), 0).with_hops(hops(&[10])), - None, ) .unwrap(); view.expect_next_active("room").await; @@ -11143,7 +10779,6 @@ mod tests { RouteInfo::default() .with_instance(instance(), 0) .with_hops(hops(&[10, 7])), - None, ) .unwrap(); drop(incumbent); @@ -11179,7 +10814,6 @@ mod tests { .with_instance(instance(), 0) .with_hops(hops(&[10])) .with_cost(5), - None, ) .unwrap(); let pending = producer.consume().request("room/alice", None); @@ -11238,7 +10872,6 @@ mod tests { .with_instance(instance(), 0) .with_hops(hops(&[10])) .with_cost(cost), - None, ) .unwrap(); let replacement = broadcast::Info::new().produce(); @@ -11415,8 +11048,6 @@ mod tests { .clone(); let request = kio::Producer::new(PendingFront { resolved: None, - via: None, - first: None, selected, refused: false, }); @@ -11427,7 +11058,7 @@ mod tests { broadcast: held.weak(), }, ); - let watch = state.watch_front(&consumer.shared, &path, Horizon::default(), request.clone()); + let watch = state.watch(&consumer.shared, &path); (request, watch) }; let mut front = FrontServe::new(FrontTask { @@ -11550,12 +11181,8 @@ mod tests { #[cfg(feature = "async")] async fn duplicate_routes_aggregate_until_the_last_leaves() { let producer = origin(1).produce(); - let first = producer - .forward("live", RouteInfo::default().with_cost(3), None) - .unwrap(); - let second = producer - .forward("live", RouteInfo::default().with_cost(1), None) - .unwrap(); + let first = producer.forward("live", RouteInfo::default().with_cost(3)).unwrap(); + let second = producer.forward("live", RouteInfo::default().with_cost(1)).unwrap(); let mut announced = producer.consume().routes(); let Some(Some(RouteEvent::Start(update))) = announced.next().now_or_never() else { @@ -11579,14 +11206,14 @@ mod tests { let producer = origin(1).produce(); let scoped = producer.scope("", &scopes(&["room"])).unwrap(); let _broad = scoped - .forward("", RouteInfo::default(), None) + .forward("", RouteInfo::default()) .expect("an overlapping prefix is accepted"); let _ok = scoped - .forward("room/alice", RouteInfo::default(), None) + .forward("room/alice", RouteInfo::default()) .expect("a contained prefix is accepted"); assert!(matches!( - scoped.forward("other", RouteInfo::default(), None), + scoped.forward("other", RouteInfo::default()), Err(Error::Unauthorized) )); } @@ -11597,7 +11224,7 @@ mod tests { let producer = origin(1).produce(); let scope = Patterns::from("*/chat".parse::().unwrap()); let scoped = producer.scope("", &scope).unwrap(); - let dynamic = scoped.forward("", RouteInfo::default(), None).unwrap(); + let dynamic = scoped.forward("", RouteInfo::default()).unwrap(); let mut matching = producer .consume() @@ -11635,11 +11262,9 @@ mod tests { .scope("", &Patterns::from(pattern.parse::().unwrap())) .unwrap() }; - let _chat = scoped("*/chat") - .forward("", RouteInfo::default().with_cost(1), None) - .unwrap(); + let _chat = scoped("*/chat").forward("", RouteInfo::default().with_cost(1)).unwrap(); let _video = scoped("*/video") - .forward("", RouteInfo::default().with_cost(5), None) + .forward("", RouteInfo::default().with_cost(5)) .unwrap(); let mut video = producer @@ -11672,11 +11297,7 @@ mod tests { let chat = producer .scope("", &scopes(&["room/live/chat"])) .unwrap() - .forward( - "room/live", - RouteInfo::default().with_hops(hops(&[7])).with_cost(1), - None, - ) + .forward("room/live", RouteInfo::default().with_hops(hops(&[7])).with_cost(1)) .unwrap(); assert_eq!(all.expect_next_active("room/live").await.cost, Cost::new(1)); assert_eq!(rooted.expect_next_active("").await.cost, Cost::new(1)); @@ -11686,11 +11307,7 @@ mod tests { let video_route = producer .scope("", &scopes(&["room/live/video"])) .unwrap() - .forward( - "room/live", - RouteInfo::default().with_hops(hops(&[8])).with_cost(5), - None, - ) + .forward("room/live", RouteInfo::default().with_hops(hops(&[8])).with_cost(5)) .unwrap(); all.assert_next_wait(); rooted.assert_next_wait(); @@ -11715,11 +11332,9 @@ mod tests { #[cfg(feature = "async")] async fn root_cursor_keeps_the_more_specific_covering_route() { let producer = origin(1).produce(); - let broad = producer - .forward("room", RouteInfo::default().with_cost(1), None) - .unwrap(); + let broad = producer.forward("room", RouteInfo::default().with_cost(1)).unwrap(); let narrow = producer - .forward("room/live", RouteInfo::default().with_cost(9), None) + .forward("room/live", RouteInfo::default().with_cost(9)) .unwrap(); let mut announced = producer .consume() @@ -11748,7 +11363,7 @@ mod tests { let mut announced = producer.consume().routes(); let dynamic = producer - .forward(&path, RouteInfo::default(), None) + .forward(&path, RouteInfo::default()) .expect("max depth is allowed"); announced.expect_next_active(&path).await; @@ -11761,7 +11376,7 @@ mod tests { async fn dynamic_exclusion_skips_routes_through_the_subscriber() { let producer = origin(1).produce(); let _server = producer - .forward("live", RouteInfo::default().with_hops(hops(&[7])), None) + .forward("live", RouteInfo::default().with_hops(hops(&[7]))) .unwrap(); let mut excluded = producer.consume().excluding(origin(7)).routes(); @@ -11777,7 +11392,7 @@ mod tests { async fn announce_consumer_is_a_stream() { use futures::StreamExt; let producer = origin(1).produce(); - let server = producer.forward("live", RouteInfo::default(), None).unwrap(); + let server = producer.forward("live", RouteInfo::default()).unwrap(); let mut announced = producer.consume().routes(); let mut next = || StreamExt::next(&mut announced).now_or_never(); let Some(Some(RouteEvent::Start(update))) = next() else { @@ -11792,7 +11407,7 @@ mod tests { #[kio::test] async fn dynamic_retracts() { let producer = origin(1).produce(); - let server = producer.forward("live", RouteInfo::default(), None).unwrap(); + let server = producer.forward("live", RouteInfo::default()).unwrap(); let mut announced = producer.consume().routes(); announced.expect_next_active("live").await; @@ -11871,7 +11486,7 @@ mod tests { async fn served_requests_coalesce() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.forward("room", RouteInfo::default(), None).unwrap(); + let server = producer.forward("room", RouteInfo::default()).unwrap(); let first = consumer.request("room/alice", None); let second = consumer.request("room/alice", None); @@ -11893,7 +11508,7 @@ mod tests { async fn retract_rejects_pending_requests() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.forward("room", RouteInfo::default(), None).unwrap(); + let server = producer.forward("room", RouteInfo::default()).unwrap(); let pending = consumer.request("room/alice", None); drop(server); @@ -11919,9 +11534,9 @@ mod tests { // Three identical routes, oldest first: the newest identical route wins // requests, and swapping between them emits no announce update. - let standby_server = producer.forward("room", RouteInfo::default(), None).unwrap(); - let second_server = producer.forward("room", RouteInfo::default(), None).unwrap(); - let incumbent_server = producer.forward("room", RouteInfo::default(), None).unwrap(); + let standby_server = producer.forward("room", RouteInfo::default()).unwrap(); + let second_server = producer.forward("room", RouteInfo::default()).unwrap(); + let incumbent_server = producer.forward("room", RouteInfo::default()).unwrap(); let mut following = consumer.follow("room/alice", None).unwrap(); let mut resolving = Box::pin(following.available()); @@ -11949,7 +11564,7 @@ mod tests { async fn split_horizon_skips_routes_through_the_requester() { let producer = origin(1).produce(); let _server = producer - .forward("room", RouteInfo::default().with_hops(hops(&[7])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[7]))) .unwrap(); // The requester's own bytes must not be served back to it. @@ -11975,18 +11590,13 @@ mod tests { let mut announced = producer.consume().routes(); let _ingest = producer - .forward( - "client", - RouteInfo::default().with_hops(hops(&[5])).with_via(origin(5)), - None, - ) + .forward("client", RouteInfo::default().with_hops(hops(&[5])).with_via(origin(5))) .unwrap(); let _gateway = producer.route("gateway", RouteOptions::default()).unwrap(); let _forwarded = peer .forward( "forwarded", RouteInfo::default().with_hops(hops(&[5, 7])).with_via(origin(7)), - None, ) .unwrap(); @@ -11999,9 +11609,7 @@ mod tests { // The mark survives narrowing the handle. let scoped = peer.scope("room", &Patterns::from(Pattern::all())).unwrap(); - let _nested = scoped - .forward("x", RouteInfo::default().with_via(origin(8)), None) - .unwrap(); + let _nested = scoped.forward("x", RouteInfo::default().with_via(origin(8))).unwrap(); assert_eq!(announced.expect_next_active("room/x").await.source(), Source::Peer(8)); } @@ -12032,10 +11640,10 @@ mod tests { .with_hops(hops(chain)) .with_via(origin(via)) }; - let near = peer.forward("room", route(&[9, 2], 2), None).unwrap(); + let near = peer.forward("room", route(&[9, 2], 2)).unwrap(); announced.assert_try_next_active("room"); - let _far = peer.forward("room", route(&[9, 3, 4], 4), None).unwrap(); + let _far = peer.forward("room", route(&[9, 3, 4], 4)).unwrap(); drop(near); assert!(announced.try_next().is_none(), "the update is held"); step(Duration::from_millis(1)).await; @@ -12070,8 +11678,8 @@ mod tests { let mut near = Vec::new(); let mut far = Vec::new(); for i in 0..k { - near.push(peer.forward(format!("room/{i}"), route(&[9, 2], 2), None).unwrap()); - far.push(peer.forward(format!("room/{i}"), route(&[9, 3, 4], 4), None).unwrap()); + near.push(peer.forward(format!("room/{i}"), route(&[9, 2], 2)).unwrap()); + far.push(peer.forward(format!("room/{i}"), route(&[9, 3, 4], 4)).unwrap()); } let mut announced = producer.consume().routes(); for _ in 0..k { @@ -12116,12 +11724,12 @@ mod tests { .with_hops(hops(chain)) .with_via(origin(via)) }; - let near = peer.forward("room", route(&[9, 2], 2), None).unwrap(); + let near = peer.forward("room", route(&[9, 2], 2)).unwrap(); announced.expect_next_active("room").await; // The best route changes twice; the consumer sees only the last, once. - let far = peer.forward("room", route(&[9, 3, 4], 4), None).unwrap(); - let farther = peer.forward("room", route(&[9, 5, 6, 7], 7), None).unwrap(); + let far = peer.forward("room", route(&[9, 3, 4], 4)).unwrap(); + let farther = peer.forward("room", route(&[9, 5, 6, 7], 7)).unwrap(); let start = kio::clock::now(); drop(near); drop(far); @@ -12155,30 +11763,30 @@ mod tests { .with_via(origin(via)) }; - let near = peer.forward("named", route(&[9, 2], 2), None).unwrap(); + let near = peer.forward("named", route(&[9, 2], 2)).unwrap(); announced.expect_next_active("named").await; drop(near); announced.expect_next_ended("named").await; let start = kio::clock::now(); - let _stale = peer.forward("named", route(&[9, 3, 4], 4), None).unwrap(); + let _stale = peer.forward("named", route(&[9, 3, 4], 4)).unwrap(); match announced.next().await.unwrap() { RouteEvent::Start(announce) => assert_eq!(announce.route.hops, hops(&[9, 3, 4])), other => panic!("should be a start: got {other:?}"), } assert!(kio::clock::now() - start >= RETRACT_QUARANTINE); - let near = peer.forward("anonymous", route(&[0, 2], 2), None).unwrap(); + let near = peer.forward("anonymous", route(&[0, 2], 2)).unwrap(); announced.expect_next_active("anonymous").await; drop(near); announced.expect_next_ended("anonymous").await; - let _other = peer.forward("anonymous", route(&[0, 3, 4], 4), None).unwrap(); + let _other = peer.forward("anonymous", route(&[0, 3, 4], 4)).unwrap(); announced.expect_next_active("anonymous").await; // Retract and announce cycles at one clock reading queue the prefix once, // whether or not the route names its publisher. let queued = announced.anchoring.len(); for chain in [[9, 2], [0, 2], [9, 2], [0, 2]] { - let churn = peer.forward("churn", route(&chain, 2), None).unwrap(); + let churn = peer.forward("churn", route(&chain, 2)).unwrap(); announced.expect_next_active("churn").await; drop(churn); announced.expect_next_ended("churn").await; @@ -12187,7 +11795,7 @@ mod tests { // A quarantine is forgotten once it ends: the cursor wakes to anchor it, // then again to drop it. - let gone = peer.forward("gone", route(&[9, 2], 2), None).unwrap(); + let gone = peer.forward("gone", route(&[9, 2], 2)).unwrap(); announced.expect_next_active("gone").await; drop(gone); announced.expect_next_ended("gone").await; @@ -12211,12 +11819,11 @@ mod tests { // Publisher 9 ingests at relay 2; relay 3 relays 2's route. let direct = || RouteInfo::default().with_hops(hops(&[9, 2])).with_via(origin(2)); - let first = peer.forward("room", direct(), None).unwrap(); + let first = peer.forward("room", direct()).unwrap(); let relayed = peer .forward( "room", RouteInfo::default().with_hops(hops(&[9, 2, 3])).with_via(origin(3)), - None, ) .unwrap(); announced.expect_next_active("room").await; @@ -12228,7 +11835,7 @@ mod tests { announced.assert_next_wait(); // Relay 2 announces again, and the relayed copy is live with it. - let second = peer.forward("room", direct(), None).unwrap(); + let second = peer.forward("room", direct()).unwrap(); announced.expect_next_active("room").await; assert!(producer.shared.lock().withdrawn.is_empty()); @@ -12250,8 +11857,8 @@ mod tests { let route = RouteInfo::default() .with_instance(instance(), 0) .with_hops(hops(&[9, 2])); - let first = peer.forward("room", route.clone(), None).unwrap(); - let second = peer.forward("room", route.clone(), None).unwrap(); + let first = peer.forward("room", route.clone()).unwrap(); + let second = peer.forward("room", route.clone()).unwrap(); announced.expect_next_active("room").await; announced.assert_next_wait(); let remaining = if old_first { @@ -12274,10 +11881,10 @@ mod tests { let producer = origin(1).produce(); let peer = producer.clone().with_link(Link::Peer); let direct = peer - .forward("room", RouteInfo::default().with_hops(hops(&[9, 2])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[9, 2]))) .unwrap(); let _relayed = peer - .forward("room", RouteInfo::default().with_hops(hops(&[9, 2, 3])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[9, 2, 3]))) .unwrap(); let path = Path::from_static("room/video"); let id = producer @@ -12307,11 +11914,11 @@ mod tests { .with_instance(instance(), 0) .with_hops(hops(&[7])) .with_via(origin(7)); - let _forwarded = peer.forward("room", route.clone(), None).unwrap(); + let _forwarded = peer.forward("room", route.clone()).unwrap(); assert_eq!(announced.expect_next_active("room").await.source(), Source::Peer(7)); // The newest identical route wins, so the local twin takes over. - let local = producer.forward("room", route, None).unwrap(); + let local = producer.forward("room", route).unwrap(); match announced.next().await.expect("no next") { RouteEvent::Update(announce) => assert_eq!(announce.route.source(), Source::Local), other => panic!("should be an update: got {other:?}"), @@ -12328,11 +11935,7 @@ mod tests { let mut local = producer.consume().local().routes(); let _forwarded = peer - .forward( - "remote", - RouteInfo::default().with_hops(hops(&[7])).with_via(origin(7)), - None, - ) + .forward("remote", RouteInfo::default().with_hops(hops(&[7])).with_via(origin(7))) .unwrap(); local.assert_next_wait(); @@ -12340,11 +11943,7 @@ mod tests { // and retracts from the local view when the local route goes, even though // the peer's still covers it. let _shadow = peer - .forward( - "both", - RouteInfo::default().with_hops(hops(&[7])).with_via(origin(7)), - None, - ) + .forward("both", RouteInfo::default().with_hops(hops(&[7])).with_via(origin(7))) .unwrap(); let ingest = producer .forward( @@ -12353,7 +11952,6 @@ mod tests { .with_hops(hops(&[5])) .with_via(origin(5)) .with_cost(9), - None, ) .unwrap(); assert_eq!(local.expect_next_active("both").await.source(), Source::Local); @@ -12392,32 +11990,20 @@ mod tests { let mut everyone = producer.consume().routes(); let _core = core - .forward( - "core", - RouteInfo::default().with_hops(hops(&[7])).with_via(origin(7)), - None, - ) + .forward("core", RouteInfo::default().with_hops(hops(&[7])).with_via(origin(7))) .unwrap(); assert_eq!(everyone.expect_next_active("core").await.source(), Source::Peer(7)); toward_core.assert_next_wait(); let _mesh = peer - .forward( - "mesh", - RouteInfo::default().with_hops(hops(&[8])).with_via(origin(8)), - None, - ) + .forward("mesh", RouteInfo::default().with_hops(hops(&[8])).with_via(origin(8))) .unwrap(); assert_eq!(toward_core.expect_next_active("mesh").await.source(), Source::Peer(8)); // A path both an upstream and a mesh peer reach: the upstream route is // cheaper, yet the upstream view selects the mesh peer's. let _cheap = core - .forward( - "both", - RouteInfo::default().with_hops(hops(&[7])).with_via(origin(7)), - None, - ) + .forward("both", RouteInfo::default().with_hops(hops(&[7])).with_via(origin(7))) .unwrap(); let mesh = peer .forward( @@ -12426,7 +12012,6 @@ mod tests { .with_hops(hops(&[8])) .with_via(origin(8)) .with_cost(9), - None, ) .unwrap(); assert_eq!(toward_core.expect_next_active("both").await.source(), Source::Peer(8)); @@ -12454,7 +12039,7 @@ mod tests { async fn handler_rejection_is_final() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.forward("room", RouteInfo::default(), None).unwrap(); + let server = producer.forward("room", RouteInfo::default()).unwrap(); let pending = consumer.request("room/alice", None); let request = queued(&server).await; @@ -12477,7 +12062,7 @@ mod tests { #[kio::test] #[cfg(feature = "async")] - async fn claim_answer_is_hidden_until_published_with_its_instance() { + async fn claim_answer_is_hidden_until_published_under_the_claims_instance() { let producer = origin(1).produce(); let consumer = producer.consume(); let claim = producer.route_prefix("room", RouteOptions::default()).unwrap(); @@ -12494,10 +12079,10 @@ mod tests { prepared.announce().unwrap(); let resolved = pending.await.unwrap(); assert_eq!(resolved.info().instance, prepared.route().unwrap().instance); - assert_ne!( + assert_eq!( resolved.info().instance, claim.route().unwrap().instance, - "a job is a stream of its own" + "the job continues the claim's stream at its path" ); assert!(resolved.track("video").is_ok()); } @@ -12517,13 +12102,13 @@ mod tests { prepared.announce().unwrap(); let instance = prepared.route().and_then(|route| route.instance); - assert!(instance.is_some()); + assert_eq!(instance, claim.route().unwrap().instance, "the claim's instance"); match announced.expect_next().await { - RouteEvent::Takeover { announce, .. } => { + RouteEvent::Start(announce) => { assert_eq!(announce.prefix.as_str(), "room/alice"); assert_eq!(announce.route.instance, instance); } - other => panic!("should take over from the claim: got {other:?}"), + other => panic!("should start beneath the claim: got {other:?}"), } assert_eq!(pending.await.unwrap().info().instance, instance); } @@ -12702,17 +12287,18 @@ mod tests { drop(held); } - /// A takeover withdrawn before the cursor delivers it may have continued what its - /// reader resolved through the claim, so the claim continues nothing more for that - /// reader: the next answer is a start. + /// A job withdrawn while it carries the claim's instance ends that stream at its path, so + /// the claim moves to a fresh instance with it: the next job, at that path or another, + /// starts a new stream rather than splicing into the old one. #[kio::test] #[cfg(feature = "async")] - async fn a_withdrawn_takeover_ends_the_claims_continuity() { + async fn a_withdrawn_job_moves_the_claim_to_a_fresh_instance() { let producer = origin(1).produce(); let consumer = producer.consume(); let mut announced = consumer.routes(); let claim = producer.route_prefix("room", RouteOptions::default()).unwrap(); announced.assert_next_active("room"); + let first = claim.instance(); let answer = async |path: &str| { let pending = consumer.request(path, None); @@ -12721,22 +12307,206 @@ mod tests { published }; - let _alice = answer("room/alice").await; + let alice = answer("room/alice").await; + assert_eq!(alice.instance(), first, "the job takes the claim's instance"); + announced.assert_next_active("room/alice"); + + let bob = answer("room/bob").await; + assert_eq!(bob.instance(), first, "jobs at sibling paths share it"); + announced.assert_next_active("room/bob"); + + alice.withdraw(); + let second = claim.instance(); + assert_ne!(second, first, "the claim moved on"); match announced.expect_next().await { - RouteEvent::Takeover { announce, predecessor } => { - assert_eq!(announce.prefix.as_str(), "room/alice"); - assert_eq!(predecessor.as_str(), "room"); - } - other => panic!("should be a takeover: got {other:?}"), + RouteEvent::End(announce) => assert_eq!(announce.prefix.as_str(), "room/alice"), + other => panic!("should end the job: got {other:?}"), } - - answer("room/bob").await.withdraw(); - let _carol = answer("room/carol").await; match announced.expect_next().await { - RouteEvent::Start(announce) => assert_eq!(announce.prefix.as_str(), "room/carol"), - other => panic!("should be a start: got {other:?}"), + RouteEvent::Restart(announce) => { + assert_eq!(announce.prefix.as_str(), "room"); + assert_eq!(announce.route.instance, Some(second.clone())); + } + other => panic!("should restart the claim: got {other:?}"), } - announced.assert_next_wait(); + // The sibling keeps the stream it holds. + assert_eq!(bob.instance(), first); + + let carol = answer("room/carol").await; + assert_eq!(carol.instance(), second, "a later job takes the claim's new instance"); + // Bob ending now spends nothing the claim still lends. + drop(bob); + assert_eq!(claim.instance(), second); + drop(carol); + assert_ne!(claim.instance(), second); + } + + /// A job's route takes the instance its claim lends when it announces, not when it was + /// created: a hidden job that never read its instance outlives a sibling's end and joins + /// the claim's next stream. + #[kio::test] + #[cfg(feature = "async")] + async fn a_hidden_job_takes_the_claims_instance_when_it_announces() { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let claim = producer.route_prefix("room", RouteOptions::default()).unwrap(); + + let alice = consumer.request("room/alice", None); + let prepared = queued(&claim).await.route(RouteOptions::hidden()).unwrap(); + let bob = consumer.request("room/bob", None); + let sibling = queued(&claim).await.route(RouteOptions::default()).unwrap(); + bob.await.unwrap(); + let first = claim.instance(); + assert_eq!(sibling.instance(), first); + + drop(sibling); + let second = claim.instance(); + assert_ne!(second, first); + + prepared.announce().unwrap(); + assert_eq!(prepared.instance(), second); + assert_eq!(alice.await.unwrap().info().instance, Some(second)); + } + + /// A hidden job that read its instance, as a signer does, announces under it or not at + /// all: once a sibling's end moves the claim past it, the announce is refused and its + /// request goes back to the worker, which prepares the job under the new instance. Every + /// requester waits on, including one that joined after the claim moved. + #[kio::test] + #[cfg(feature = "async")] + async fn a_job_that_read_its_instance_is_refused_once_the_claim_moves() { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let claim = producer.route_prefix("room", RouteOptions::default()).unwrap(); + + let alice = consumer.request("room/alice", None); + let prepared = queued(&claim).await.route(RouteOptions::hidden()).unwrap(); + let bob = consumer.request("room/bob", None); + let sibling = queued(&claim).await.route(RouteOptions::default()).unwrap(); + bob.await.unwrap(); + let first = prepared.instance(); + assert_eq!(first, claim.instance()); + + drop(sibling); + let second = claim.instance(); + assert_ne!(second, first); + assert_eq!(prepared.instance(), first, "a read instance moved"); + let late = consumer.request("room/alice", None); + assert!(matches!(prepared.announce(), Err(Error::Unroutable))); + + let again = queued(&claim).await.route(RouteOptions::hidden()).unwrap(); + assert_eq!(again.instance(), second); + again.announce().unwrap(); + assert_eq!(alice.await.unwrap().info().instance, Some(second.clone())); + assert_eq!(late.await.unwrap().info().instance, Some(second)); + } + + /// A request pinned to the claim's instance resolves through its job's route even when a + /// sibling job ending moved the claim on before the request looked again. + #[kio::test] + #[cfg(feature = "async")] + async fn a_pinned_request_reaches_its_job_after_a_sibling_moves_the_claim() { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let claim = producer.route_prefix("room", RouteOptions::default()).unwrap(); + let lent = claim.instance(); + + let reader = consumer.request("room/alice", None); + let alice = queued(&claim).await.route(RouteOptions::default()).unwrap(); + let held = reader.await.unwrap(); + + let pinned = consumer.request("room/bob", lent.clone()); + let bob = queued(&claim).await.route(RouteOptions::default()).unwrap(); + drop(alice); + assert_ne!(claim.instance(), lent); + assert_eq!(bob.instance(), lent); + let resolved = pinned.await.expect("the job still serves the pinned instance"); + assert_eq!(resolved.info().instance, Some(lent)); + drop(held); + } + + /// Once a job ends, a request pinned to its instance is refused without asking the + /// claim, which serves another stream now, while an unpinned one starts the next job. + #[kio::test] + #[cfg(feature = "async")] + async fn a_request_pinned_to_an_ended_job_is_refused() { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let claim = producer.route_prefix("room", RouteOptions::default()).unwrap(); + + let pending = consumer.request("room/alice", None); + let job = queued(&claim).await.route(RouteOptions::default()).unwrap(); + let held = pending.await.unwrap(); + let ended = job.instance(); + job.broadcast().close(); + drop(job); + + let pinned = consumer.request("room/alice", ended.clone()).await; + assert!(matches!(pinned, Err(Error::Unroutable))); + assert!( + claim.poll_requested(&kio::Waiter::noop()).is_pending(), + "the claim was asked" + ); + + let pending = consumer.request("room/alice", None); + let restarted = queued(&claim).await.route(RouteOptions::default()).unwrap(); + assert_ne!(restarted.instance(), ended); + let fresh = pending.await.unwrap(); + assert!(!fresh.is_clone(&held), "joined the ended job"); + } + + /// A claim and its job end independently: a job outliving its claim keeps its stream, + /// and a claim outliving its job moves on. + #[kio::test] + #[cfg(feature = "async")] + async fn a_claim_and_its_job_end_independently() { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let claim = producer.route_prefix("room", RouteOptions::default()).unwrap(); + let pending = consumer.request("room/alice", None); + let job = queued(&claim).await.route(RouteOptions::default()).unwrap(); + let held = pending.await.unwrap(); + let lent = claim.instance(); + + drop(claim); + assert_eq!(job.instance(), lent); + let again = consumer.request("room/alice", lent.clone()).await.unwrap(); + assert!(again.is_clone(&held), "the job's stream ended with its claim"); + drop(job); + assert!(consumer.request("room/alice", lent).await.is_err()); + } + + /// A claim naming a stable instance lends nothing: its answers mint their own, and their + /// end leaves the claim's instance as it is. A job naming its instance keeps it too. + #[kio::test] + #[cfg(feature = "async")] + async fn named_instances_are_neither_lent_nor_moved() { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let stable = instance(); + let claim = producer + .route_prefix("room", RouteOptions::default().with_instance(stable.clone(), 0)) + .unwrap(); + + let pending = consumer.request("room/alice", None); + let minted = queued(&claim).await.route(RouteOptions::default()).unwrap(); + pending.await.unwrap(); + assert_ne!(minted.instance(), stable, "a stable claim lent its instance"); + drop(minted); + assert_eq!(claim.instance(), stable); + + let lending = producer.route_prefix("lent", RouteOptions::default()).unwrap(); + let lent = lending.instance(); + let named = instance(); + let pending = consumer.request("lent/bob", None); + let job = queued(&lending) + .await + .route(RouteOptions::default().with_instance(named.clone(), 0)) + .unwrap(); + pending.await.unwrap(); + assert_eq!(job.instance(), named); + drop(job); + assert_eq!(lending.instance(), lent, "a named job's end moved the claim"); } /// A request pinned to an instance resolves only through the answer published under @@ -12768,11 +12538,9 @@ mod tests { async fn refusal_never_falls_through() { let producer = origin(1).produce(); let consumer = producer.consume(); - let catch_all = producer.forward("", RouteInfo::default(), None).unwrap(); - let winner = producer.forward("room", RouteInfo::default(), None).unwrap(); - let sibling = producer - .forward("room", RouteInfo::default().with_cost(2), None) - .unwrap(); + let catch_all = producer.forward("", RouteInfo::default()).unwrap(); + let winner = producer.forward("room", RouteInfo::default()).unwrap(); + let sibling = producer.forward("room", RouteInfo::default().with_cost(2)).unwrap(); let mut pending = Box::pin(consumer.request("room/alice", None)); assert!((&mut pending).now_or_never().is_none()); @@ -12798,7 +12566,7 @@ mod tests { async fn superseded_refusal_does_not_end_the_request() { let producer = origin(1).produce(); let consumer = producer.consume(); - let stale = producer.forward("room", RouteInfo::default(), None).unwrap(); + let stale = producer.forward("room", RouteInfo::default()).unwrap(); let mut pending = Box::pin(consumer.request("room/alice", None)); assert!((&mut pending).now_or_never().is_none()); @@ -12825,13 +12593,13 @@ mod tests { async fn refusal_from_a_beaten_route_asks_the_new_winner() { let producer = origin(1).produce(); let consumer = producer.consume(); - let stale = producer.forward("room", RouteInfo::default(), None).unwrap(); + let stale = producer.forward("room", RouteInfo::default()).unwrap(); let mut pending = Box::pin(consumer.request("room/alice", None)); assert!((&mut pending).now_or_never().is_none()); let request = queued(&stale).await; - let winner = producer.forward("room/alice", RouteInfo::default(), None).unwrap(); + let winner = producer.forward("room/alice", RouteInfo::default()).unwrap(); request.reject(Error::NotFound); let source = broadcast::Info::new().produce(); queued(&winner).await.serve(&source); @@ -12851,7 +12619,7 @@ mod tests { async fn follow_reports_teardown_as_closed() { let (producer, driver) = Producer::new(Config::from(origin(1))); let consumer = producer.consume(); - let _server = producer.forward("room", RouteInfo::default(), None).unwrap(); + let _server = producer.forward("room", RouteInfo::default()).unwrap(); // Park on the covering route, past the loop's closed check. let mut following = consumer.follow("room/alice", None).unwrap(); @@ -12895,7 +12663,7 @@ mod tests { async fn late_track_on_a_served_front_replays() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.forward("room", RouteInfo::default(), None).unwrap(); + let server = producer.forward("room", RouteInfo::default()).unwrap(); let source = broadcast::Info::new().produce(); for name in ["a", "b"] { @@ -12936,7 +12704,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); - let broad_server = producer.forward("", RouteInfo::default(), None).unwrap(); + let broad_server = producer.forward("", RouteInfo::default()).unwrap(); // A narrow advertise-only claim: requests under it must NOT route to the // broad server; they fall through to the (absent) fallback handler. let _narrow = producer.announce(".dash", RouteInfo::default()).unwrap(); @@ -12961,7 +12729,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let mut announced = consumer.routes(); - let dynamic = producer.forward("", RouteInfo::default(), None).unwrap(); + let dynamic = producer.forward("", RouteInfo::default()).unwrap(); // The root claim is advertised like any other prefix. announced.expect_next_active("").await; @@ -12995,7 +12763,7 @@ mod tests { #[cfg(feature = "async")] async fn out_of_scope_request_never_reaches_the_dynamic_handler() { let producer = origin(1).produce(); - let dynamic = producer.forward("", RouteInfo::default(), None).unwrap(); + let dynamic = producer.forward("", RouteInfo::default()).unwrap(); let scoped = producer.consume().scope("", &scopes(&["tenant-a"])).unwrap(); // `tenant-a-other` shares a character prefix but not a segment, so this @@ -13089,7 +12857,7 @@ mod tests { let mut announced = consumer.routes(); announced.expect_next_active("room").await; - let _server = producer.forward("served", RouteInfo::default(), None).unwrap(); + let _server = producer.forward("served", RouteInfo::default()).unwrap(); let pending = consumer.request("served/path", None); drop(driver); @@ -13140,7 +12908,7 @@ mod tests { #[test] fn internal_route_requests_are_closed_on_teardown() { let (producer, mut driver) = Producer::new(Config::from(origin(1))); - let server = producer.forward("room", RouteInfo::default(), None).unwrap(); + let server = producer.forward("room", RouteInfo::default()).unwrap(); let _pending = producer.consume().request("room/alice", None); driver.poll(Instant::now(), &kio::Waiter::noop()).unwrap(); let Poll::Ready(Some(request)) = server.poll_requested(&kio::Waiter::noop()) else { @@ -13168,7 +12936,7 @@ mod tests { for teardown in [false, true] { let (producer, mut driver) = Producer::new(Config::from(origin(1))); - let server = producer.forward("room", RouteInfo::default(), None).unwrap(); + let server = producer.forward("room", RouteInfo::default()).unwrap(); let _pending = producer.consume().request("room/alice", None); driver.poll(Instant::ZERO, &kio::Waiter::noop()).unwrap(); let channel = server @@ -13226,7 +12994,6 @@ mod tests { .forward( "room", RouteInfo::default().with_instance(instance(), 0).with_hops(hops(first)), - None, ) .unwrap(); @@ -13273,7 +13040,6 @@ mod tests { .forward( "room", RouteInfo::default().with_instance(instance(), 0).with_hops(hops(first)), - None, ) .unwrap() } @@ -13325,7 +13091,6 @@ mod tests { .with_instance(instance(), 1) .with_hops(hops(&[11])) .with_cost(9), - None, ) .unwrap(); assert_resumes(&mut rig, &newer).await; @@ -13351,7 +13116,6 @@ mod tests { RouteInfo::default() .with_instance(name.parse().unwrap(), 0) .with_hops(hops(&[hop])), - None, ) .unwrap() }; @@ -13390,7 +13154,6 @@ mod tests { RouteInfo::default() .with_instance(name.parse().unwrap(), 0) .with_hops(hops(&[hop])), - None, ) .unwrap() }; @@ -13445,7 +13208,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let first = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])).with_cost(5), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10])).with_cost(5)) .unwrap(); let pending = consumer.request("room/alice", None); let source = broadcast::Info::new().produce(); @@ -13456,7 +13219,7 @@ mod tests { deliver(&track, &mut subscription, b"first").await; let second = producer - .forward("room", RouteInfo::default().with_hops(hops(&[11])).with_cost(1), None) + .forward("room", RouteInfo::default().with_hops(hops(&[11])).with_cost(1)) .unwrap(); deliver(&track, &mut subscription, b"pinned").await; @@ -13586,11 +13349,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let old = producer - .forward( - "room", - RouteInfo::default().with_instance(crate::Instance::random(), 0), - None, - ) + .forward("room", RouteInfo::default().with_instance(crate::Instance::random(), 0)) .unwrap(); let pending = consumer.request("room/alice", None); let request = queued(&old).await; @@ -13626,7 +13385,7 @@ mod tests { let consumer = producer.consume(); let old = crate::Instance::random(); let dynamic = producer - .forward("room", RouteInfo::default().with_instance(old.clone(), 0), None) + .forward("room", RouteInfo::default().with_instance(old.clone(), 0)) .unwrap(); let pending = consumer.request("room/alice", None); let pinned = consumer.request("room/alice", old); @@ -13686,11 +13445,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let dynamic = producer - .forward( - "room", - RouteInfo::default().with_instance(crate::Instance::random(), 0), - None, - ) + .forward("room", RouteInfo::default().with_instance(crate::Instance::random(), 0)) .unwrap(); let pending = consumer.request("room/alice", None); queued(&dynamic).await.reject(Error::NotFound); @@ -13717,7 +13472,7 @@ mod tests { let consumer = producer.consume(); let old = crate::Instance::random(); let dynamic = producer - .forward("room", RouteInfo::default().with_instance(old.clone(), 0), None) + .forward("room", RouteInfo::default().with_instance(old.clone(), 0)) .unwrap(); let pending = consumer.request("room/alice", None); let request = queued(&dynamic).await; @@ -13947,7 +13702,6 @@ mod tests { .forward( "room", RouteInfo::default().with_instance(instance(), 0).with_hops(hops(&[10])), - None, ) .unwrap(); // The same publisher, reached through the requesting peer. @@ -13957,7 +13711,6 @@ mod tests { RouteInfo::default() .with_instance(instance(), 0) .with_hops(hops(&[10, 7])), - None, ) .unwrap(); @@ -14021,7 +13774,7 @@ mod tests { // Session A: an assigned anonymous hop serving the path. let server_a = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); // Session C: a third anonymous session, excluding the hop the server @@ -14077,7 +13830,7 @@ mod tests { // Session B attaches at the same path. Its front is served immediately. let server_b = producer - .forward("room", RouteInfo::default().with_hops(hops(&[20])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[20]))) .unwrap(); let pending = consumer.request("room/alice", None); let request = queued(&server_b).await; @@ -14203,7 +13956,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let server = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); // The front selected the covering route and waits on its answer. @@ -14225,14 +13978,14 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let covering = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let pending = consumer.request("room/alice", None); let _beaten = queued(&covering).await; // The exact path wins, and the front asks it in turn. let exact = producer - .forward("room/alice", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room/alice", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let request = queued(&exact).await; let again = consumer.request("room/alice", None); @@ -14306,7 +14059,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let server = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let pending = consumer.request("room/alice", None); @@ -14354,7 +14107,7 @@ mod tests { async fn a_closed_source_is_not_requested_again_from_its_standing_route() { let producer = origin(1).produce(); let server = producer - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let pending = producer.consume().request("room/alice", None); let source = broadcast::Info::new().produce(); @@ -14446,7 +14199,7 @@ mod tests { async fn a_held_group_continues_after_its_track_goes_unread() { let producer = origin(1).produce(); let server = producer - .forward("live", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("live", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let pending = producer.consume().request("live", None); let source = broadcast::Info::new().produce(); @@ -14538,7 +14291,7 @@ mod tests { let mid = origin(2).produce(); let mid_server = mid - .forward("room", RouteInfo::default().with_hops(hops(&[10])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[10]))) .unwrap(); let mid_pending = mid.consume().request("room/alice", None); queued(&mid_server).await.serve(&leaf_front); @@ -14546,7 +14299,7 @@ mod tests { let edge = origin(3).produce(); let edge_server = edge - .forward("room", RouteInfo::default().with_hops(hops(&[20])), None) + .forward("room", RouteInfo::default().with_hops(hops(&[20]))) .unwrap(); let edge_pending = edge.consume().request("room/alice", None); queued(&edge_server).await.serve(&mid_resolved); @@ -14679,7 +14432,6 @@ mod tests { .forward( "room", RouteInfo::default().with_instance(instance(), 0).with_hops(hops(&[10])), - None, ) .unwrap(); let pending = producer.consume().request("room/alice", None); @@ -14929,7 +14681,6 @@ mod tests { .forward( "room", RouteInfo::default().with_instance(instance(), 0).with_hops(hops(&[10])), - None, ) .unwrap(); let pending = producer.consume().request("room/alice", None); @@ -15390,7 +15141,7 @@ mod tests { let (producer, driver) = Producer::new(Config::from(origin(1))); let run = kio::clock::sim::spawn(crate::time::run(driver)); let consumer = producer.consume(); - let server = producer.forward("room", RouteInfo::default(), None).unwrap(); + let server = producer.forward("room", RouteInfo::default()).unwrap(); drop(producer); let pending = consumer.request("room/alice", None); @@ -15504,7 +15255,7 @@ mod tests { async fn source_selection_leases_survive_reprices_but_never_revive_pending_work() { let producer = origin(1).produce(); let broad = producer - .forward("room", RouteInfo::default().with_via(origin(2)), None) + .forward("room", RouteInfo::default().with_via(origin(2))) .unwrap(); let selected = broad.request_validity(&Path::from_static("room/a")).unwrap(); let pending = selected.current(); @@ -15513,11 +15264,11 @@ mod tests { assert!(!pending.is_closed()); // Another peer's winning route cannot change what a request on peer 2 names. let other = producer - .forward("room/a", RouteInfo::default().with_via(origin(3)), None) + .forward("room/a", RouteInfo::default().with_via(origin(3))) .unwrap(); assert!(!pending.is_closed()); let replacement = producer - .forward("room/a", RouteInfo::default().with_via(origin(2)), None) + .forward("room/a", RouteInfo::default().with_via(origin(2))) .unwrap(); assert!(pending.is_closed()); assert!(selected.current().is_closed()); @@ -15539,7 +15290,7 @@ mod tests { let producer = origin(1).produce(); let path = Path::from_static("room/a"); let first = producer - .forward("room", RouteInfo::default().with_via(origin(2)), None) + .forward("room", RouteInfo::default().with_via(origin(2))) .unwrap(); let selected = first.request_validity(&path).unwrap(); let answering = selected.answering(); @@ -15548,14 +15299,13 @@ mod tests { .forward( "room", RouteInfo::default().with_via(origin(2)).with_cost(Cost::from(7)), - None, ) .unwrap(); assert!(!answering.is_closed()); drop(worse); let replacement = producer - .forward("room/a", RouteInfo::default().with_via(origin(2)), None) + .forward("room/a", RouteInfo::default().with_via(origin(2))) .unwrap(); assert!(answering.is_closed()); // Winning again opens a new lease, which a withdrawal leaves open. @@ -15568,7 +15318,7 @@ mod tests { // Announced again, the path has a new instance: a replacement. let _again = producer - .forward("room", RouteInfo::default().with_via(origin(2)), None) + .forward("room", RouteInfo::default().with_via(origin(2))) .unwrap(); assert!(answering.is_closed()); } @@ -15577,7 +15327,7 @@ mod tests { async fn source_selection_unregisters_without_retaining_the_source() { let producer = origin(1).produce(); let route = producer - .forward("room", RouteInfo::default().with_via(origin(2)), None) + .forward("room", RouteInfo::default().with_via(origin(2))) .unwrap(); let path = Path::from_static("room/a"); let selected = route.request_validity(&path).unwrap(); @@ -15633,7 +15383,7 @@ mod tests { async fn a_discarded_front_task_unregisters_its_watch() { let (producer, _driver) = Producer::new(Config::from(origin(1))); let consumer = producer.consume(); - let _served = producer.forward("room", RouteInfo::default(), None).unwrap(); + let _served = producer.forward("room", RouteInfo::default()).unwrap(); // A consumer outlives its producer by design, so the task set can refuse // submissions while the origin is still open. The front's task is then // dropped on the spot, taking its `Watch` with it: the request must not diff --git a/rs/moq-net/src/model/request_queue.rs b/rs/moq-net/src/model/request_queue.rs index ac27eefc763..8db9bf7453b 100644 --- a/rs/moq-net/src/model/request_queue.rs +++ b/rs/moq-net/src/model/request_queue.rs @@ -81,6 +81,22 @@ impl RequestQueue { Some(key) } + /// Queue a popped request again, still joinable, for the next handler to pop. Returns + /// `false`, leaving it as it is, when no handler is alive or nothing popped is pending. + pub fn requeue(&mut self, key: K) -> bool { + if self.handlers == 0 { + return false; + } + match self.pending.get_mut(&key) { + Some(entry) if !entry.queued => { + entry.queued = true; + self.order.push_back(key); + true + } + _ => false, + } + } + /// The pending request for `key`, if any. pub fn get(&self, key: &Q) -> Option<&V> where diff --git a/rs/moq-net/src/model/resume_model.rs b/rs/moq-net/src/model/resume_model.rs index e2277091071..e9feba9c4c0 100644 --- a/rs/moq-net/src/model/resume_model.rs +++ b/rs/moq-net/src/model/resume_model.rs @@ -453,7 +453,7 @@ impl Model { .with_hops(hops) // The second instance's routes all undercut the first's. .with_cost(((INSTANCES - instance) * SLOTS - slot) as u64); - let requests = self.origin.forward(PATH, route, None).unwrap(); + let requests = self.origin.forward(PATH, route).unwrap(); let broadcast = broadcast::Info::new().produce(); let track = broadcast.create_track("video", track::Info::timed()).unwrap(); // The route's live feed, received from its publisher: its groups land in any order. diff --git a/rs/moq-net/src/route/node.rs b/rs/moq-net/src/route/node.rs index 5cb25080859..ff66f4e7f02 100644 --- a/rs/moq-net/src/route/node.rs +++ b/rs/moq-net/src/route/node.rs @@ -36,11 +36,6 @@ pub(crate) struct Claim { pub epoch: u64, /// The origin's Announce Cost. pub cost: u64, - /// The claim of the same identity this one took over, at or above its path, and the - /// instance that claim served: what was resolved through it beneath this path is - /// this claim's broadcast. Kept while the instance stays, and sent as - /// ANNOUNCE_TAKEOVER to a peer still holding that claim. - pub continues: Option<(Path, Instance)>, /// This node's own claim, served through a route learned from an upstream peer: it /// is never sent to another upstream, so this node never carries traffic between /// two. Always unset on a claim off the wire. @@ -118,10 +113,12 @@ struct Outbox { pending: HashMap, /// Outputs to read in order: the other kinds, and a pending entry once taken. ready: VecDeque, - /// Each claim's last Serve or Unserve, read after the rest, in queue order. A - /// takeover queues its successor's Serve before its predecessor's Unserve, and the - /// session needs the predecessor still served to carry it on: so a Serve replacing - /// a queued Serve keeps its place, while an Unserve, or a Serve after one, goes last. + /// Each claim's last Serve or Unserve, read after the rest, in queue order. A job + /// queues its Serve before the Unserve or restart of the claim it continues, and the + /// session needs the job's route in place before the claim's goes, so a reader + /// resolved through the claim carries on: so a Serve replacing a queued Serve of the + /// same instance and epoch keeps its place, while an Unserve, a restart, or a Serve + /// after an Unserve goes last. serves: BTreeMap, /// Where each claim's last change sits in `serves`. serving: HashMap<(Id, Path), u64>, @@ -222,8 +219,10 @@ impl Outbox { Output::Serve { id, ref claim, .. } | Output::Unserve { id, ref claim } => { let key = (id, claim.clone()); if let Some(&seq) = self.serving.get(&key) - && matches!(output, Output::Serve { .. }) - && matches!(self.serves.get(&seq), Some(Output::Serve { .. })) + && let Output::Serve { served, .. } = &output + && let Some(Output::Serve { served: queued, .. }) = self.serves.get(&seq) + && queued.claim.instance == served.claim.instance + && queued.claim.epoch == served.claim.epoch { self.serves.insert(seq, output); return None; @@ -379,22 +378,13 @@ impl Node { .expect("a node always declares an identity") } - /// This node's own claim at `path` changed: `None` withdraws it. A claim taking over - /// the own claim at `predecessor` continues the instance that one serves now. - pub fn own(&self, path: Path, claim: Option, predecessor: Option<&Path>) { + /// This node's own claim at `path` changed: `None` withdraws it. + pub fn own(&self, path: Path, claim: Option) { let mut state = self.lock(); let changed = match claim { - Some(mut claim) => { + Some(claim) => { let view = state.router.declared().and_then(|id| state.router.view(id)); let current = view.and_then(|view| view.items.get(&path)); - claim.continues = match predecessor { - Some(predecessor) => (view.and_then(|view| view.items.get(predecessor))) - .map(|taken| (predecessor.clone(), taken.instance.clone())), - // A re-price or a newer epoch of the same instance continues what it did. - None => current - .filter(|current| current.instance == claim.instance) - .and_then(|current| current.continues.clone()), - }; let changed = current != Some(&claim); changed && state.router.announce(path, claim).is_ok() } @@ -627,7 +617,9 @@ impl State { .take_while(|((home, _), _)| *home == id) .map(|((_, claim), _)| claim.clone()) .collect(); - for (claim, item, available) in claims { + // Most specific first: a job continuing its claim's instance is served before + // the claim moves on, so a reader resolved through the claim finds the job's route. + for (claim, item, available) in claims.into_iter().rev() { gone.remove(&claim); let routes = self.router.routes(id, &claim); let home = self.homes.entry((id, claim.clone())).or_default(); @@ -731,7 +723,6 @@ mod tests { instance: instance.clone(), epoch: 0, cost, - continues: None, upstream: false, learned: false, } @@ -768,7 +759,7 @@ mod tests { let (origin, relay) = (Node::new(1, 1), Node::new(2, 2)); let answer = origin.answer(Link::default(), Path::empty()).unwrap(); let receive = relay.receive(Link::default(), Path::empty()).unwrap(); - origin.own(p("live/x"), Some(claim(&instance, 5)), None); + origin.own(p("live/x"), Some(claim(&instance, 5))); pump(&answer, &receive); assert_eq!( served(outputs(&receive)), @@ -784,7 +775,7 @@ mod tests { }] ); - origin.own(p("live/x"), None, None); + origin.own(p("live/x"), None); pump(&answer, &receive); assert_eq!( served(outputs(&receive)), @@ -812,7 +803,7 @@ mod tests { let far_answer = origin.answer(Link::default(), Path::empty()).unwrap(); let near = relay.receive(Link::default(), Path::empty()).unwrap(); let near_answer = origin.answer(Link::default(), Path::empty()).unwrap(); - origin.own(p("live/x"), Some(claim(&instance, 0)), None); + origin.own(p("live/x"), Some(claim(&instance, 0))); pump(&far_answer, &far); pump(&near_answer, &near); let metric = |outputs: Vec| match &served(outputs)[..] { @@ -851,8 +842,8 @@ mod tests { }; let b = relay.receive(costly, p("live/b")).unwrap(); let b_answer = origin.answer(Link::default(), p("live/b")).unwrap(); - origin.own(p("live/a/x"), Some(claim(&instance, 0)), None); - origin.own(p("live/b/y"), Some(claim(&instance, 0)), None); + origin.own(p("live/a/x"), Some(claim(&instance, 0))); + origin.own(p("live/b/y"), Some(claim(&instance, 0))); pump(&a_answer, &a); pump(&b_answer, &b); let claims = |outputs: Vec| -> Vec { @@ -867,28 +858,28 @@ mod tests { assert_eq!(claims(outputs(&b)), [p("live/b/y")]); } - /// A stalled stream reads a takeover's successor before its predecessor goes, even - /// once the successor changed again meanwhile, so the session still finds the - /// predecessor to carry on. + /// A stalled stream reads a job's Serve before the Unserve of the claim it + /// continues, even once the job changed again meanwhile, so a reader resolved + /// through the claim finds the job's route before the claim's goes. #[test] - fn a_stalled_takeover_reads_its_successor_first() { - let (old, new) = (Instance::random(), Instance::random()); + fn a_stalled_stream_reads_a_job_before_its_claim_goes() { + let instance = Instance::random(); let origin = Node::new(1, 1); let relay = Node::new(2, 2); let fast = relay.receive(Link::default(), Path::empty()).unwrap(); let stalled = relay.receive(Link::default(), Path::empty()).unwrap(); let fast_answer = origin.answer(Link::default(), Path::empty()).unwrap(); let stalled_answer = origin.answer(Link::default(), Path::empty()).unwrap(); - origin.own(p("live"), Some(claim(&old, 0)), None); + origin.own(p("live"), Some(claim(&instance, 0))); pump(&fast_answer, &fast); pump(&stalled_answer, &stalled); outputs(&fast); outputs(&stalled); - origin.own(p("live/x"), Some(claim(&new, 0)), Some(&p("live"))); - origin.own(p("live"), None, None); + origin.own(p("live/x"), Some(claim(&instance, 0))); + origin.own(p("live"), None); pump(&fast_answer, &fast); - origin.own(p("live/x"), Some(claim(&new, 5)), None); + origin.own(p("live/x"), Some(claim(&instance, 5))); pump(&fast_answer, &fast); let read: Vec<(Path, Option)> = (served(outputs(&stalled)).into_iter()) .map(|output| match output { @@ -900,6 +891,45 @@ mod tests { assert_eq!(read, [(p("live/x"), Some(5)), (p("live"), None)]); } + /// A job and its claim's restart arriving together are read job first, even past a + /// change to the claim still queued, so a reader resolved through the claim's old + /// instance finds the job's route before the claim's moves on. + #[test] + fn a_job_is_read_before_its_claims_restart() { + let (old, new) = (Instance::random(), Instance::random()); + let origin = Node::new(1, 1); + let relay = Node::new(2, 2); + let fast = relay.receive(Link::default(), Path::empty()).unwrap(); + let stalled = relay.receive(Link::default(), Path::empty()).unwrap(); + let fast_answer = origin.answer(Link::default(), Path::empty()).unwrap(); + let stalled_answer = origin.answer(Link::default(), Path::empty()).unwrap(); + origin.own(p("live"), Some(claim(&old, 0))); + pump(&fast_answer, &fast); + pump(&stalled_answer, &stalled); + outputs(&fast); + outputs(&stalled); + + // A change to the claim the stalled stream leaves queued. + origin.own(p("live"), Some(claim(&old, 1))); + pump(&fast_answer, &fast); + pump(&stalled_answer, &stalled); + outputs(&fast); + + origin.own(p("live/x"), Some(claim(&old, 0))); + origin.own(p("live"), Some(claim(&new, 0))); + pump(&fast_answer, &fast); + pump(&stalled_answer, &stalled); + for stream in [&fast, &stalled] { + let read: Vec<(Path, Instance)> = (served(outputs(stream)).into_iter()) + .map(|output| match output { + Output::Serve { claim, served, .. } => (claim, served.claim.instance), + other => panic!("not a serve: {other:?}"), + }) + .collect(); + assert_eq!(read, [(p("live/x"), old.clone()), (p("live"), new.clone())]); + } + } + /// A claim of a scoped peer left with no usable route stays where it is served, /// under the name that peer gave its identity, so the requests it serves meanwhile /// still name it as the peer does. @@ -919,7 +949,7 @@ mod tests { let second = relay.receive(scoped(), Path::empty()).unwrap(); let first_answer = origin.answer(Link::default(), Path::empty()).unwrap(); let second_answer = origin.answer(Link::default(), Path::empty()).unwrap(); - origin.own(p("live/x"), Some(claim(&instance, 0)), None); + origin.own(p("live/x"), Some(claim(&instance, 0))); pump(&first_answer, &first); pump(&second_answer, &second); second.route(1, Some(Reach::Held)); @@ -949,11 +979,11 @@ mod tests { let stalled = relay.receive(Link::default(), Path::empty()).unwrap(); let fast_answer = origin.answer(Link::default(), Path::empty()).unwrap(); let stalled_answer = origin.answer(Link::default(), Path::empty()).unwrap(); - origin.own(p("live/x"), Some(claim(&instance, 0)), None); + origin.own(p("live/x"), Some(claim(&instance, 0))); pump(&fast_answer, &fast); pump(&stalled_answer, &stalled); for cost in 1..1000 { - origin.own(p("live/x"), Some(claim(&instance, cost)), None); + origin.own(p("live/x"), Some(claim(&instance, cost))); pump(&fast_answer, &fast); outputs(&fast); } @@ -975,9 +1005,9 @@ mod tests { let answer = origin.answer(Link::default(), Path::empty()).unwrap(); outputs(&answer); for cost in 0..1000 { - origin.own(p("live/x"), Some(claim(&instance, cost)), None); - origin.own(p("live/churn"), Some(claim(&instance, cost)), None); - origin.own(p("live/churn"), None, None); + origin.own(p("live/x"), Some(claim(&instance, cost))); + origin.own(p("live/churn"), Some(claim(&instance, cost))); + origin.own(p("live/churn"), None); } let read = outputs(&answer); let batch = |read: &[Output]| match read { @@ -989,9 +1019,9 @@ mod tests { // Once sent, a claim's end is kept, behind what started. for cost in 0..1000 { - origin.own(p("live/y"), Some(claim(&instance, cost)), None); + origin.own(p("live/y"), Some(claim(&instance, cost))); } - origin.own(p("live/x"), None, None); + origin.own(p("live/x"), None); let read = outputs(&answer); assert_eq!(read.len(), 1, "{read:?}"); assert_eq!( @@ -1068,7 +1098,7 @@ mod tests { let first_answer = origin.answer(Link::default(), Path::empty()).unwrap(); let second = relay.receive(Link::default(), Path::empty()).unwrap(); let second_answer = origin.answer(Link::default(), Path::empty()).unwrap(); - origin.own(p("live/x"), Some(claim(&instance, 0)), None); + origin.own(p("live/x"), Some(claim(&instance, 0))); pump(&first_answer, &first); pump(&second_answer, &second); outputs(&first); diff --git a/rs/moq-net/tests/integration/announce_covering_route.rs b/rs/moq-net/tests/integration/announce_covering_route.rs index 40c9a34c183..acaad683d1a 100644 --- a/rs/moq-net/tests/integration/announce_covering_route.rs +++ b/rs/moq-net/tests/integration/announce_covering_route.rs @@ -35,7 +35,6 @@ fn drain(announced: &mut moq_net::origin::Routes) -> Vec { Event::Start(announce) => ("Start", announce), Event::Update(announce) => ("Update", announce), Event::Restart(announce) => ("Restart", announce), - Event::Takeover { announce, .. } => ("Takeover", announce), Event::End(announce) => ("End", announce), }; seen.push(format!("{kind} {}", announce.prefix)); diff --git a/rs/moq-net/tests/integration/announce_takeover.rs b/rs/moq-net/tests/integration/announce_takeover.rs deleted file mode 100644 index b9fc5e10802..00000000000 --- a/rs/moq-net/tests/integration/announce_takeover.rs +++ /dev/null @@ -1,267 +0,0 @@ -#![cfg(feature = "async")] - -//! A worker claiming a prefix answers each request by publishing the exact path under a -//! fresh instance. On moq-lite-07 that announcement is a takeover of the claim, so the first -//! subscription through the claim keeps reading, across relays. Older wires carry it as -//! a start, and a takeover that another worker could have served is a start too. - -use crate::support; - -use std::{cell::RefCell, rc::Rc, time::Duration}; - -use moq_net::{Timestamp, Version, origin, track}; -use support::harness::{MockConnectOptions, MockPair, connect_mock}; - -const TEST_TIMEOUT: Duration = Duration::from_secs(600); - -fn produce_origin(hop: u64) -> origin::Producer { - let (producer, driver) = origin::Producer::new(origin::Config::new(std::num::NonZeroU64::new(hop).unwrap())); - support::harness::spawn(driver); - producer -} - -/// Have `to` pull everything `from` publishes, over a link priced at `cost`. -async fn link(version: Version, from: &origin::Producer, to: &origin::Producer, cost: u64) -> MockPair { - let mut options = MockConnectOptions::new(version); - options.server_publish = Some(from.consume()); - options.client_subscribe = Some(to.clone()); - options.cost = Some(cost); - connect_mock(options).await -} - -/// Let every task settle. Time is paused, so this returns once the runtime is idle. -async fn settle() { - kio::clock::sleep(Duration::from_millis(500)).await; -} - -/// A worker claiming `claim`, answering each request with an output whose `video` track -/// carries the worker's name. -struct Worker { - _claim: Rc, - outputs: Rc>>, - origin: origin::Producer, -} - -impl Worker { - fn new(hop: u64, claim: &str, name: &'static str) -> Self { - let origin = produce_origin(hop); - let claim = Rc::new( - origin - .route_prefix(claim, moq_net::origin::RouteOptions::default()) - .unwrap(), - ); - let outputs = Rc::new(RefCell::new(Vec::new())); - let (handler, served) = (claim.clone(), outputs.clone()); - drop(kio::clock::sim::spawn(async move { - while let Some(request) = handler.requested().await { - let prepared = request.route(moq_net::origin::RouteOptions::hidden()).unwrap(); - let track = prepared - .broadcast() - .create_track( - "video", - track::Info::default().with_timescale(moq_net::Timescale::MILLI), - ) - .unwrap(); - let mut group = track.append_group().unwrap(); - group.write_frame(Timestamp::ZERO, name.as_bytes().to_vec()).unwrap(); - group.finish().unwrap(); - prepared.announce().unwrap(); - served.borrow_mut().push((prepared, track)); - } - })); - Self { - _claim: claim, - outputs, - origin, - } - } -} - -/// Every announce event already delivered, as `(kind, prefix, predecessor)`. -async fn drain(announced: &mut origin::Routes) -> Vec<(&'static str, String, Option)> { - let mut events = Vec::new(); - while let Ok(event) = kio::clock::timeout(Duration::from_secs(1), announced.next()).await { - events.push(match event.expect("announce ended") { - origin::RouteEvent::Start(a) => ("start", a.prefix.to_string(), None), - origin::RouteEvent::Update(a) => ("update", a.prefix.to_string(), None), - origin::RouteEvent::Restart(a) => ("restart", a.prefix.to_string(), None), - origin::RouteEvent::Takeover { announce, predecessor } => { - ("takeover", announce.prefix.to_string(), Some(predecessor.to_string())) - } - origin::RouteEvent::End(a) => ("end", a.prefix.to_string(), None), - }); - } - events -} - -/// The first frame of `video` at `path` through `origin`, on the first subscription. -async fn watch(origin: &origin::Producer, path: &str) -> String { - let broadcast = origin.consume().request(path, None).await.unwrap(); - let mut subscription = broadcast.track("video").unwrap().subscribe(None).await.unwrap(); - let mut group = kio::clock::timeout(Duration::from_secs(10), subscription.recv_group()) - .await - .expect("subscription hung") - .expect("the first subscription was reset") - .expect("a group"); - let frame = group.read_frame().await.unwrap().expect("a frame"); - String::from_utf8(frame.payload.to_vec()).unwrap() -} - -fn lite07(version: Version) -> bool { - version == "moq-lite-07-wip".parse().unwrap() -} - -/// `W -> R -> E`: the edge reads through `W`'s claim on its first subscription, and each -/// hop announces the answer as a takeover of the claim on lite-07, a start before it. -async fn chain(version: &str, claim: &str) { - let version: Version = version.parse().unwrap(); - let worker = Worker::new(1, claim, "w"); - let relay = produce_origin(2); - let edge = produce_origin(3); - let _upstream = link(version, &worker.origin, &relay, 1).await; - let _downstream = link(version, &relay, &edge, 1).await; - settle().await; - - let mut at_relay = relay.consume().routes(); - let mut at_edge = edge.consume().routes(); - let claimed = [("start", claim.to_string(), None)]; - assert_eq!(drain(&mut at_relay).await, claimed, "{version}"); - assert_eq!(drain(&mut at_edge).await, claimed, "{version}"); - - if !lite07(version) { - // An older wire resets the first subscription through the claim. - let broadcast = edge.consume().request("pool/p", None).await.unwrap(); - drop(broadcast.track("video").unwrap().subscribe(None).await); - settle().await; - } - assert_eq!(watch(&edge, "pool/p").await, "w", "{version}"); - assert_eq!(worker.outputs.borrow().len(), 1, "{version}"); - - let answered = if !lite07(version) { - // An ordinary announcement, or one replacing the claim at its own path. - if claim == "pool/p" { - vec![ - ("end", "pool/p".to_string(), None), - ("start", "pool/p".to_string(), None), - ] - } else { - vec![("start", "pool/p".to_string(), None)] - } - } else { - vec![("takeover", "pool/p".to_string(), Some(claim.to_string()))] - }; - assert_eq!(drain(&mut at_relay).await, answered, "{version}: at the relay"); - assert_eq!(drain(&mut at_edge).await, answered, "{version}: at the edge"); - - // A late snapshot starts what stands, replaying no takeover. - let mut late = edge.consume().routes(); - let mut snapshot = vec![("start", "pool/p".to_string(), None)]; - if claim != "pool/p" { - snapshot.insert(0, ("start", claim.to_string(), None)); - } - assert_eq!(drain(&mut late).await, snapshot, "{version}: a late snapshot"); -} - -macro_rules! chain_tests { - ($($name:ident: $version:literal, $claim:literal,)*) => { - $( - #[kio::test] - async fn $name() { - kio::clock::timeout(TEST_TIMEOUT, chain($version, $claim)) - .await - .expect("timed out"); - } - )* - }; -} - -chain_tests! { - beneath_the_claim_lite06: "moq-lite-06", "pool", - beneath_the_claim_lite07: "moq-lite-07-wip", "pool", - at_the_claim_lite06: "moq-lite-06", "pool/p", - at_the_claim_lite07: "moq-lite-07-wip", "pool/p", -} - -/// Two workers claim `pool` on `R`. Whichever answers, `R` cannot tell a reader of the -/// claim which worker it saw, so the answer is a start, while `R`'s own reader still -/// keeps reading from the worker it was routed to. -#[kio::test] -async fn two_workers_announce_a_start() { - kio::clock::timeout(TEST_TIMEOUT, async { - let version: Version = "moq-lite-07-wip".parse().unwrap(); - let relay = produce_origin(3); - let a = Worker::new(1, "pool", "a"); - let b = Worker::new(2, "pool", "b"); - let _a = link(version, &a.origin, &relay, 1).await; - let _b = link(version, &b.origin, &relay, 10).await; - settle().await; - - let mut announced = relay.consume().routes(); - assert_eq!(drain(&mut announced).await, [("start", "pool".to_string(), None)]); - - assert_eq!(watch(&relay, "pool/p").await, "a"); - assert_eq!( - drain(&mut announced).await, - [("start", "pool/p".to_string(), None)], - "a takeover with another worker's claim visible" - ); - assert!(b.outputs.borrow().is_empty()); - }) - .await - .expect("timed out"); -} - -/// A broadcast published at the path without answering a request is no takeover, even -/// from the worker holding the claim, and a reader through the claim does not inherit it. -#[kio::test] -async fn an_unrequested_publish_is_a_start() { - kio::clock::timeout(TEST_TIMEOUT, async { - let version: Version = "moq-lite-07-wip".parse().unwrap(); - let worker = Worker::new(1, "pool", "w"); - let relay = produce_origin(2); - let _link = link(version, &worker.origin, &relay, 1).await; - settle().await; - - let mut announced = relay.consume().routes(); - assert_eq!(drain(&mut announced).await, [("start", "pool".to_string(), None)]); - - let _direct = worker - .origin - .route("pool/q", moq_net::origin::RouteOptions::default()) - .unwrap(); - settle().await; - assert_eq!(drain(&mut announced).await, [("start", "pool/q".to_string(), None)]); - }) - .await - .expect("timed out"); -} - -/// `W -> R` on lite-07 and `R -> E` on lite-06: the relay keeps its reader through the -/// takeover and forwards it to the older edge as a start. -#[kio::test] -async fn an_older_downstream_gets_a_start() { - kio::clock::timeout(TEST_TIMEOUT, async { - let worker = Worker::new(1, "pool", "w"); - let relay = produce_origin(2); - let edge = produce_origin(3); - let _upstream = link("moq-lite-07-wip".parse().unwrap(), &worker.origin, &relay, 1).await; - let _downstream = link("moq-lite-06".parse().unwrap(), &relay, &edge, 1).await; - settle().await; - - let mut at_relay = relay.consume().routes(); - let mut at_edge = edge.consume().routes(); - drain(&mut at_relay).await; - drain(&mut at_edge).await; - - assert_eq!(watch(&relay, "pool/p").await, "w"); - assert_eq!( - drain(&mut at_relay).await, - [("takeover", "pool/p".to_string(), Some("pool".to_string()))] - ); - assert_eq!(drain(&mut at_edge).await, [("start", "pool/p".to_string(), None)]); - assert_eq!(watch(&edge, "pool/p").await, "w"); - assert_eq!(worker.outputs.borrow().len(), 1, "the edge asked the worker again"); - }) - .await - .expect("timed out"); -} diff --git a/rs/moq-net/tests/integration/announce_to_serve.rs b/rs/moq-net/tests/integration/announce_to_serve.rs index 3d4c9e7a6a5..2dd4d5c9ae6 100644 --- a/rs/moq-net/tests/integration/announce_to_serve.rs +++ b/rs/moq-net/tests/integration/announce_to_serve.rs @@ -52,7 +52,6 @@ fn drain(announced: &mut moq_net::origin::Routes) -> Vec { Event::Start(announce) => ("Start", announce), Event::Update(announce) => ("Update", announce), Event::Restart(announce) => ("Restart", announce), - Event::Takeover { announce, .. } => ("Takeover", announce), Event::End(announce) => ("End", announce), }; seen.push(format!("{kind} {}", announce.prefix)); diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index f20b098213d..b0c9f1c87f3 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -406,8 +406,7 @@ async fn wait_announced(origin: &origin::Consumer, path: &str, active: bool) { let apply = |live: &mut std::collections::HashSet, event: moq_net::origin::RouteEvent| match event { moq_net::origin::RouteEvent::Start(update) | moq_net::origin::RouteEvent::Update(update) - | moq_net::origin::RouteEvent::Restart(update) - | moq_net::origin::RouteEvent::Takeover { announce: update, .. } => { + | moq_net::origin::RouteEvent::Restart(update) => { live.insert(update.prefix.to_string()); } moq_net::origin::RouteEvent::End(update) => { diff --git a/rs/moq-net/tests/integration/claim_continues.rs b/rs/moq-net/tests/integration/claim_continues.rs new file mode 100644 index 00000000000..d479dc5740d --- /dev/null +++ b/rs/moq-net/tests/integration/claim_continues.rs @@ -0,0 +1,389 @@ +#![cfg(feature = "async")] + +//! A worker claiming a prefix answers each request by publishing the exact path. The exact +//! route carries the claim's instance while it lives, so on moq-lite-07, which carries +//! instances end to end, the first subscription through the claim continues on it, across +//! relays. Once the job ends the claim moves to a fresh instance, so a restarted job is +//! another stream. Older wires mint an instance per announcement, so their first +//! subscription through the claim sees another broadcast rather than a splice. + +use crate::support; + +use std::{cell::RefCell, rc::Rc, time::Duration}; + +use moq_net::{Error, Instance, Timestamp, Version, origin, track}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; + +const TEST_TIMEOUT: Duration = Duration::from_secs(600); + +fn produce_origin(hop: u64) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(std::num::NonZeroU64::new(hop).unwrap())); + support::harness::spawn(driver); + producer +} + +fn lite07() -> Version { + "moq-lite-07-wip".parse().unwrap() +} + +fn lite06() -> Version { + "moq-lite-06".parse().unwrap() +} + +/// Have `to` pull everything `from` publishes, over a link priced at `cost`. +async fn link(version: Version, from: &origin::Producer, to: &origin::Producer, cost: u64) -> MockPair { + let mut options = MockConnectOptions::new(version); + options.server_publish = Some(from.consume()); + options.client_subscribe = Some(to.clone()); + options.cost = Some(cost); + connect_mock(options).await +} + +/// Let every task settle. Time is paused, so this returns once the runtime is idle. +async fn settle() { + kio::clock::sleep(Duration::from_millis(500)).await; +} + +/// A worker claiming `claim`, answering each request with an output whose `video` track +/// carries the worker's name and how many outputs it made before. +struct Worker { + claim: Rc, + outputs: Rc>>, + origin: origin::Producer, +} + +impl Worker { + fn new(hop: u64, claim: &str, name: &'static str) -> Self { + let origin = produce_origin(hop); + let claim = Rc::new(origin.route_prefix(claim, origin::RouteOptions::default()).unwrap()); + let outputs = Rc::new(RefCell::new(Vec::new())); + let (handler, served) = (claim.clone(), outputs.clone()); + drop(kio::clock::sim::spawn(async move { + while let Some(request) = handler.requested().await { + let prepared = request.route(origin::RouteOptions::hidden()).unwrap(); + let track = prepared + .broadcast() + .create_track( + "video", + track::Info::default().with_timescale(moq_net::Timescale::MILLI), + ) + .unwrap(); + let tag = format!("{name}{}", served.borrow().len()); + let mut group = track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, tag.into_bytes()).unwrap(); + group.finish().unwrap(); + prepared.announce().unwrap(); + served.borrow_mut().push((prepared, track)); + } + })); + Self { claim, outputs, origin } + } + + /// How many outputs the worker was asked for. + fn served(&self) -> usize { + self.outputs.borrow().len() + } + + /// End the latest output, as a worker does when the job stops. + fn stop(&self) { + let (output, _) = self.outputs.borrow_mut().pop().expect("an output"); + output.broadcast().close(); + } +} + +/// Every announce event already delivered, as `(kind, prefix, instance)`. +async fn drain(announced: &mut origin::Routes) -> Vec<(&'static str, String, Instance)> { + let mut events = Vec::new(); + while let Ok(event) = kio::clock::timeout(Duration::from_secs(1), announced.next()).await { + let (kind, announce) = match event.expect("announce ended") { + origin::RouteEvent::Start(a) => ("start", a), + origin::RouteEvent::Update(a) => ("update", a), + origin::RouteEvent::Restart(a) => ("restart", a), + origin::RouteEvent::End(a) => ("end", a), + }; + events.push((kind, announce.prefix.to_string(), announce.route.instance().clone())); + } + events +} + +/// The events without their instances. +fn kinds(events: &[(&'static str, String, Instance)]) -> Vec<(&'static str, String)> { + events.iter().map(|(kind, prefix, _)| (*kind, prefix.clone())).collect() +} + +/// The first frame of `video` at `path` through `origin` on the first subscription, or +/// `None` when that subscription was reset before a group arrived. +async fn first(origin: &origin::Producer, path: &str) -> Option { + let broadcast = origin.consume().request(path, None).await.unwrap(); + let mut subscription = broadcast.track("video").unwrap().subscribe(None).await.ok()?; + let mut group = kio::clock::timeout(Duration::from_secs(10), subscription.recv_group()) + .await + .expect("subscription hung") + .ok()??; + let frame = group.read_frame().await.unwrap().expect("a frame"); + Some(String::from_utf8(frame.payload.to_vec()).unwrap()) +} + +/// The first frame at `path` through `origin`. Unless the first subscription through the +/// claim `continues`, it resets, and the next one reads. +async fn watch(continues: bool, origin: &origin::Producer, path: &str) -> String { + if !continues { + assert_eq!(first(origin, path).await, None, "the first subscription continued"); + settle().await; + } + first(origin, path).await.expect("the subscription was reset") +} + +/// `W -> R -> E`: the edge reads through `W`'s claim on its first subscription on lite-07, +/// where every hop announces the answer under the claim's instance. An older wire mints an +/// instance per announcement, so its first subscription resets, unless the claim is at the +/// answer's own path: there the worker's answer is the same stream as the claim, so it +/// announces nothing new. +async fn chain(version: Version, claim: &str) { + let worker = Worker::new(1, claim, "w"); + let relay = produce_origin(2); + let edge = produce_origin(3); + let _upstream = link(version, &worker.origin, &relay, 1).await; + let _downstream = link(version, &relay, &edge, 1).await; + settle().await; + + let mut at_relay = relay.consume().routes(); + let mut at_edge = edge.consume().routes(); + let claimed = [("start", claim.to_string())]; + assert_eq!(kinds(&drain(&mut at_relay).await), claimed, "{version}"); + assert_eq!(kinds(&drain(&mut at_edge).await), claimed, "{version}"); + + let continues = version == lite07() || claim == "pool/p"; + assert_eq!(watch(continues, &edge, "pool/p").await, "w0", "{version}"); + assert_eq!(worker.served(), 1, "{version}"); + + let lent = worker.claim.instance(); + for (at, events) in [ + ("relay", drain(&mut at_relay).await), + ("edge", drain(&mut at_edge).await), + ] { + match claim == "pool/p" { + // Beneath the claim, the answer is another path. + false => assert_eq!( + kinds(&events), + [("start", "pool/p".to_string())], + "{version}: at the {at}" + ), + // At the claim's own path, the answer is the same stream by another route. + true => assert!( + events.iter().all(|(kind, _, _)| *kind == "update"), + "{version}: at the {at}: {events:?}" + ), + } + if version == lite07() + && let Some((_, _, instance)) = events.last() + { + assert_eq!(instance, &lent, "{version}: at the {at}"); + } + } + + // A late snapshot starts what stands. + let mut late = edge.consume().routes(); + let mut snapshot = vec![("start", "pool/p".to_string())]; + if claim != "pool/p" { + snapshot.insert(0, ("start", claim.to_string())); + } + assert_eq!(kinds(&drain(&mut late).await), snapshot, "{version}: a late snapshot"); +} + +macro_rules! chain_tests { + ($($name:ident: $version:expr, $claim:literal,)*) => { + $( + #[kio::test] + async fn $name() { + kio::clock::timeout(TEST_TIMEOUT, chain($version, $claim)) + .await + .expect("timed out"); + } + )* + }; +} + +chain_tests! { + beneath_the_claim_lite06: lite06(), "pool", + beneath_the_claim_lite07: lite07(), "pool", + at_the_claim_lite06: lite06(), "pool/p", + at_the_claim_lite07: lite07(), "pool/p", +} + +/// A job that stops moves the claim to a fresh instance, end to end: the next request for +/// the path wakes the worker for another stream, a request pinned to the ended one is +/// refused, and the first subscription through the claim continues on the new job. +#[kio::test] +async fn a_restarted_job_is_another_stream() { + kio::clock::timeout(TEST_TIMEOUT, async { + let worker = Worker::new(1, "pool", "w"); + let relay = produce_origin(2); + let edge = produce_origin(3); + let _upstream = link(lite07(), &worker.origin, &relay, 1).await; + let _downstream = link(lite07(), &relay, &edge, 1).await; + settle().await; + + let mut at_edge = edge.consume().routes(); + drain(&mut at_edge).await; + assert_eq!(first(&edge, "pool/p").await.as_deref(), Some("w0")); + let spent = worker.claim.instance(); + drain(&mut at_edge).await; + + worker.stop(); + settle().await; + let advanced = worker.claim.instance(); + assert_ne!(advanced, spent, "the claim kept the ended job's instance"); + let events = drain(&mut at_edge).await; + assert!( + events.contains(&("end", "pool/p".to_string(), spent.clone())), + "{events:?}" + ); + assert!( + events.contains(&("restart", "pool".to_string(), advanced.clone())), + "{events:?}" + ); + + let pinned = edge.consume().request("pool/p", spent).await.err(); + assert!(matches!(pinned, Some(Error::Unroutable)), "{pinned:?}"); + assert_eq!(worker.served(), 0, "a pinned request woke the worker"); + + assert_eq!(first(&edge, "pool/p").await.as_deref(), Some("w0")); + assert_eq!(worker.served(), 1); + assert_eq!(worker.outputs.borrow()[0].0.instance(), advanced); + }) + .await + .expect("timed out"); +} + +/// Two workers claim `pool` on `R`, each with its own instance. `R`'s reader resolved through +/// the cheaper one continues on that worker's answer, and the other is never asked. +#[kio::test] +async fn two_workers_keep_their_own_readers() { + kio::clock::timeout(TEST_TIMEOUT, async { + let relay = produce_origin(3); + let a = Worker::new(1, "pool", "a"); + let b = Worker::new(2, "pool", "b"); + let _a = link(lite07(), &a.origin, &relay, 1).await; + let _b = link(lite07(), &b.origin, &relay, 10).await; + settle().await; + assert_ne!(a.claim.instance(), b.claim.instance()); + + let mut announced = relay.consume().routes(); + assert_eq!(kinds(&drain(&mut announced).await), [("start", "pool".to_string())]); + + assert_eq!(first(&relay, "pool/p").await.as_deref(), Some("a0")); + assert_eq!( + drain(&mut announced).await, + [("start", "pool/p".to_string(), a.claim.instance())] + ); + assert_eq!(b.served(), 0); + }) + .await + .expect("timed out"); +} + +/// A broadcast published at the path without answering a request is a stream of its own, +/// even from the worker holding the claim. +#[kio::test] +async fn an_unrequested_publish_is_its_own_stream() { + kio::clock::timeout(TEST_TIMEOUT, async { + let worker = Worker::new(1, "pool", "w"); + let relay = produce_origin(2); + let _link = link(lite07(), &worker.origin, &relay, 1).await; + settle().await; + + let mut announced = relay.consume().routes(); + assert_eq!(kinds(&drain(&mut announced).await), [("start", "pool".to_string())]); + + let direct = worker.origin.route("pool/q", origin::RouteOptions::default()).unwrap(); + assert_ne!(direct.instance(), worker.claim.instance()); + settle().await; + assert_eq!( + drain(&mut announced).await, + [("start", "pool/q".to_string(), direct.instance())] + ); + }) + .await + .expect("timed out"); +} + +/// `W -> R` on lite-07 and `R -> E` on lite-06: the relay's reader continues on the answer, +/// which the older edge sees as another broadcast, served by the same output. +#[kio::test] +async fn an_older_downstream_sees_another_broadcast() { + kio::clock::timeout(TEST_TIMEOUT, async { + let worker = Worker::new(1, "pool", "w"); + let relay = produce_origin(2); + let edge = produce_origin(3); + let _upstream = link(lite07(), &worker.origin, &relay, 1).await; + let _downstream = link(lite06(), &relay, &edge, 1).await; + settle().await; + + let mut at_relay = relay.consume().routes(); + let mut at_edge = edge.consume().routes(); + drain(&mut at_relay).await; + drain(&mut at_edge).await; + + assert_eq!(first(&relay, "pool/p").await.as_deref(), Some("w0")); + assert_eq!(kinds(&drain(&mut at_relay).await), [("start", "pool/p".to_string())]); + assert_eq!(kinds(&drain(&mut at_edge).await), [("start", "pool/p".to_string())]); + assert_eq!(first(&edge, "pool/p").await.as_deref(), Some("w0")); + assert_eq!(worker.served(), 1, "the edge asked the worker again"); + }) + .await + .expect("timed out"); +} + +/// `W -> R` on lite-06 and `R -> E` on lite-07: the older hop mints an instance for each +/// announcement, so the edge's first subscription through the claim sees another broadcast +/// rather than a splice, and the next one reads. +#[kio::test] +async fn an_older_upstream_hop_restarts_the_reader() { + kio::clock::timeout(TEST_TIMEOUT, async { + let worker = Worker::new(1, "pool", "w"); + let relay = produce_origin(2); + let edge = produce_origin(3); + let _upstream = link(lite06(), &worker.origin, &relay, 1).await; + let _downstream = link(lite07(), &relay, &edge, 1).await; + settle().await; + + assert_eq!(watch(false, &edge, "pool/p").await, "w0"); + assert_eq!(worker.served(), 1); + }) + .await + .expect("timed out"); +} + +/// `W -> {R1, R2} -> E` on lite-07: both relays carry the claim and its answer under the +/// worker's instance, so the edge's first subscription continues whichever way it routes. +#[kio::test] +async fn a_diamond_continues_the_first_reader() { + kio::clock::timeout(TEST_TIMEOUT, async { + let worker = Worker::new(1, "pool", "w"); + let left = produce_origin(2); + let right = produce_origin(3); + let edge = produce_origin(4); + let _links = [ + link(lite07(), &worker.origin, &left, 1).await, + link(lite07(), &worker.origin, &right, 1).await, + link(lite07(), &left, &edge, 1).await, + link(lite07(), &right, &edge, 2).await, + ]; + settle().await; + + assert_eq!(first(&edge, "pool/p").await.as_deref(), Some("w0")); + assert_eq!(worker.served(), 1); + let mut late = edge.consume().routes(); + let lent = worker.claim.instance(); + assert_eq!( + drain(&mut late).await, + [ + ("start", "pool".to_string(), lent.clone()), + ("start", "pool/p".to_string(), lent), + ] + ); + }) + .await + .expect("timed out"); +} diff --git a/rs/moq-net/tests/integration/dial_split_horizon.rs b/rs/moq-net/tests/integration/dial_split_horizon.rs index ef02f794539..f7282f59fba 100644 --- a/rs/moq-net/tests/integration/dial_split_horizon.rs +++ b/rs/moq-net/tests/integration/dial_split_horizon.rs @@ -102,8 +102,7 @@ async fn learned_hop(dialer: &origin::Producer, path: &str) -> u64 { let announce = match event { moq_net::origin::RouteEvent::Start(announce) | moq_net::origin::RouteEvent::Update(announce) - | moq_net::origin::RouteEvent::Restart(announce) - | moq_net::origin::RouteEvent::Takeover { announce, .. } => announce, + | moq_net::origin::RouteEvent::Restart(announce) => announce, moq_net::origin::RouteEvent::End(_) => continue, }; if announce.prefix.as_str() == path { diff --git a/rs/moq-net/tests/integration/drained_claim.rs b/rs/moq-net/tests/integration/drained_claim.rs index 226aefd0a41..a7ddf9d7efa 100644 --- a/rs/moq-net/tests/integration/drained_claim.rs +++ b/rs/moq-net/tests/integration/drained_claim.rs @@ -86,10 +86,10 @@ impl Worker { } } -/// Read the first frame of `video` at `pool/p` through `relay`. moq-lite-07 announces the -/// answer as a takeover of the claim, so the first subscription reads. Older wires carry it -/// as an ordinary announcement, which resets the first subscription through the claim, so -/// they wake the worker first. +/// Read the first frame of `video` at `pool/p` through `relay`. On moq-lite-07 the answer's +/// exact route carries the claim's instance, so the first subscription reads. Older wires +/// mint an instance per announcement, so the answer resets the first subscription through +/// the claim: they wake the worker first. async fn watch(version: Version, relay: &origin::Producer) -> String { if version != "moq-lite-07-wip".parse().unwrap() { let cold = relay.consume().request("pool/p", None).await.unwrap(); diff --git a/rs/moq-net/tests/integration/extensions.rs b/rs/moq-net/tests/integration/extensions.rs index b798752d102..d11da27c0e5 100644 --- a/rs/moq-net/tests/integration/extensions.rs +++ b/rs/moq-net/tests/integration/extensions.rs @@ -46,8 +46,7 @@ fn announced(origin: &origin::Producer, path: &str) -> bool { match event { moq_net::origin::RouteEvent::Start(update) | moq_net::origin::RouteEvent::Update(update) - | moq_net::origin::RouteEvent::Restart(update) - | moq_net::origin::RouteEvent::Takeover { announce: update, .. } => { + | moq_net::origin::RouteEvent::Restart(update) => { live |= update.prefix.as_str() == path; } moq_net::origin::RouteEvent::End(update) => { diff --git a/rs/moq-net/tests/integration/lazy_discovery.rs b/rs/moq-net/tests/integration/lazy_discovery.rs index 81022bdbd7e..82624a7eb35 100644 --- a/rs/moq-net/tests/integration/lazy_discovery.rs +++ b/rs/moq-net/tests/integration/lazy_discovery.rs @@ -109,9 +109,9 @@ async fn synced(announced: &mut origin::Routes) -> Vec { .expect("synced in time") .expect("the stream stays open"); match event { - origin::RouteEvent::Start(announce) - | origin::RouteEvent::Restart(announce) - | origin::RouteEvent::Takeover { announce, .. } => seen.push(announce.prefix.to_string()), + origin::RouteEvent::Start(announce) | origin::RouteEvent::Restart(announce) => { + seen.push(announce.prefix.to_string()) + } origin::RouteEvent::End(announce) => seen.retain(|prefix| *prefix != announce.prefix.as_str()), origin::RouteEvent::Update(_) => {} } @@ -423,9 +423,9 @@ async fn settled(announced: &mut origin::Routes, held: &mut Vec) -> bool let mut ended = false; while let Some(event) = announced.try_next() { match event { - origin::RouteEvent::Start(announce) - | origin::RouteEvent::Restart(announce) - | origin::RouteEvent::Takeover { announce, .. } => held.push(announce.prefix.to_string()), + origin::RouteEvent::Start(announce) | origin::RouteEvent::Restart(announce) => { + held.push(announce.prefix.to_string()) + } origin::RouteEvent::End(announce) => { ended = true; held.retain(|prefix| *prefix != announce.prefix.as_str()) diff --git a/rs/moq-net/tests/integration/main.rs b/rs/moq-net/tests/integration/main.rs index 29cd339f18d..766beef70bd 100644 --- a/rs/moq-net/tests/integration/main.rs +++ b/rs/moq-net/tests/integration/main.rs @@ -8,7 +8,6 @@ //! apply to every test in the binary. mod announce_covering_route; -mod announce_takeover; mod announce_to_serve; mod answer_behind_cache; mod auth; @@ -16,6 +15,7 @@ mod broadcast_close; mod catalog_acquisition; mod catalog_churn; mod catalog_handover; +mod claim_continues; mod compress; mod control_stats; mod datagram; diff --git a/rs/moq-net/tests/integration/multi_cdn.rs b/rs/moq-net/tests/integration/multi_cdn.rs index 62338a6b746..40423a2c694 100644 --- a/rs/moq-net/tests/integration/multi_cdn.rs +++ b/rs/moq-net/tests/integration/multi_cdn.rs @@ -133,10 +133,7 @@ async fn next(rx: &mut mpsc::UnboundedReceiver) -> (u64, Option bool { let route = match announce { - origin::RouteEvent::Start(a) - | origin::RouteEvent::Update(a) - | origin::RouteEvent::Restart(a) - | origin::RouteEvent::Takeover { announce: a, .. } => &a.route, + origin::RouteEvent::Start(a) | origin::RouteEvent::Update(a) | origin::RouteEvent::Restart(a) => &a.route, origin::RouteEvent::End(_) => return false, }; route.cost().value() >= A_COST diff --git a/rs/moq-net/tests/integration/rejoin.rs b/rs/moq-net/tests/integration/rejoin.rs index 4f9954de887..400bd99aff8 100644 --- a/rs/moq-net/tests/integration/rejoin.rs +++ b/rs/moq-net/tests/integration/rejoin.rs @@ -837,11 +837,7 @@ async fn quiet_catalog_survives_cache_expiry(version: Version, takeover: bool) { assert!( matches!( event, - Some( - origin::RouteEvent::Update(_) - | origin::RouteEvent::Restart(_) - | origin::RouteEvent::Takeover { .. } - ) + Some(origin::RouteEvent::Update(_) | origin::RouteEvent::Restart(_)) ), "{version}: {event:?}" ); diff --git a/rs/moq-net/tests/integration/session_panic.rs b/rs/moq-net/tests/integration/session_panic.rs index cf9e40cb086..8ffe4bdf489 100644 --- a/rs/moq-net/tests/integration/session_panic.rs +++ b/rs/moq-net/tests/integration/session_panic.rs @@ -83,8 +83,7 @@ async fn until(announced: &mut origin::Routes, path: &str, active: bool) { let (announce, now_active) = match announced.next().await.expect("origin closed") { origin::RouteEvent::Start(announce) | origin::RouteEvent::Update(announce) - | origin::RouteEvent::Restart(announce) - | origin::RouteEvent::Takeover { announce, .. } => (announce, true), + | origin::RouteEvent::Restart(announce) => (announce, true), origin::RouteEvent::End(announce) => (announce, false), }; if announce.prefix.as_str() == path && now_active == active { diff --git a/rs/moq-net/tests/integration/support/mod.rs b/rs/moq-net/tests/integration/support/mod.rs index f9d2a692771..00a79e49950 100644 --- a/rs/moq-net/tests/integration/support/mod.rs +++ b/rs/moq-net/tests/integration/support/mod.rs @@ -58,7 +58,6 @@ pub enum Kind { Start, Update, Restart, - Takeover, End, } @@ -85,7 +84,6 @@ pub fn watch(mut announced: origin::Routes) -> mpsc::UnboundedReceiver<(String, origin::RouteEvent::Start(announce) => (Kind::Start, announce), origin::RouteEvent::Update(announce) => (Kind::Update, announce), origin::RouteEvent::Restart(announce) => (Kind::Restart, announce), - origin::RouteEvent::Takeover { announce, .. } => (Kind::Takeover, announce), origin::RouteEvent::End(announce) => (Kind::End, announce), }; if tx.unbounded_send((announce.prefix.to_string(), kind)).is_err() { diff --git a/rs/moq-net/tests/integration/synced.rs b/rs/moq-net/tests/integration/synced.rs index 0d043420e80..1447f60f477 100644 --- a/rs/moq-net/tests/integration/synced.rs +++ b/rs/moq-net/tests/integration/synced.rs @@ -30,9 +30,7 @@ async fn read_until_synced(announced: &mut origin::Routes) -> BTreeSet { .expect("synced in time") .expect("the cursor stays open"); match event { - origin::RouteEvent::Start(announce) - | origin::RouteEvent::Restart(announce) - | origin::RouteEvent::Takeover { announce, .. } => { + origin::RouteEvent::Start(announce) | origin::RouteEvent::Restart(announce) => { seen.insert(announce.prefix.to_string()); } origin::RouteEvent::End(announce) => { diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index 73a6ed899e8..8238c1c8718 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -2620,8 +2620,7 @@ mod tests { match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-relay/src/internal.rs b/rs/moq-relay/src/internal.rs index fd4955a28eb..30d31152787 100644 --- a/rs/moq-relay/src/internal.rs +++ b/rs/moq-relay/src/internal.rs @@ -1367,8 +1367,7 @@ mod tests { match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 88f3f576eca..b86f6a33334 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -985,8 +985,7 @@ async fn serve_announced( while let Some(event) = announced.try_next() { if let moq_net::origin::RouteEvent::Start(update) | moq_net::origin::RouteEvent::Update(update) - | moq_net::origin::RouteEvent::Restart(update) - | moq_net::origin::RouteEvent::Takeover { announce: update, .. } = event + | moq_net::origin::RouteEvent::Restart(update) = event { broadcasts.push(update.prefix); } diff --git a/rs/moq-relay/tests/drills.rs b/rs/moq-relay/tests/drills.rs index b22e68f8ecd..7c69e03d1e2 100644 --- a/rs/moq-relay/tests/drills.rs +++ b/rs/moq-relay/tests/drills.rs @@ -1403,8 +1403,7 @@ async fn next_update(announced: &mut moq_net::origin::Routes) -> Option<(moq_net match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-relay/tests/integration/auth_lifetime.rs b/rs/moq-relay/tests/integration/auth_lifetime.rs index ec565144d90..67d24faf858 100644 --- a/rs/moq-relay/tests/integration/auth_lifetime.rs +++ b/rs/moq-relay/tests/integration/auth_lifetime.rs @@ -1862,8 +1862,7 @@ async fn next_update(announced: &mut moq_net::origin::Routes) -> Option<(moq_net match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-relay/tests/integration/cluster_unknown.rs b/rs/moq-relay/tests/integration/cluster_unknown.rs index 27739308172..268fc0c0713 100644 --- a/rs/moq-relay/tests/integration/cluster_unknown.rs +++ b/rs/moq-relay/tests/integration/cluster_unknown.rs @@ -315,8 +315,7 @@ async fn next_update(announced: &mut moq_net::origin::Routes) -> Option<(moq_net match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-relay/tests/integration/connection.rs b/rs/moq-relay/tests/integration/connection.rs index ceee8a968cc..12659b40aaf 100644 --- a/rs/moq-relay/tests/integration/connection.rs +++ b/rs/moq-relay/tests/integration/connection.rs @@ -104,8 +104,7 @@ async fn next_active(announced: &mut moq_net::origin::Routes) -> String { { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => return route.prefix.to_string(), + | moq_net::origin::RouteEvent::Restart(route) => return route.prefix.to_string(), moq_net::origin::RouteEvent::End(_) => {} } } diff --git a/rs/moq-relay/tests/integration/embed.rs b/rs/moq-relay/tests/integration/embed.rs index a0bc09bd6ad..3a688783325 100644 --- a/rs/moq-relay/tests/integration/embed.rs +++ b/rs/moq-relay/tests/integration/embed.rs @@ -601,8 +601,7 @@ async fn next_update(announced: &mut moq_net::origin::Routes) -> Option<(moq_net match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-relay/tests/integration/goaway_cluster.rs b/rs/moq-relay/tests/integration/goaway_cluster.rs index cd252b9ca78..75c2cbf8faf 100644 --- a/rs/moq-relay/tests/integration/goaway_cluster.rs +++ b/rs/moq-relay/tests/integration/goaway_cluster.rs @@ -193,8 +193,7 @@ async fn cluster_continues_a_group_split_by_goaway_inner() { // Under another instance, B's route is another source: a restart. if let moq_net::origin::RouteEvent::Start(update) | moq_net::origin::RouteEvent::Update(update) - | moq_net::origin::RouteEvent::Restart(update) - | moq_net::origin::RouteEvent::Takeover { announce: update, .. } = event + | moq_net::origin::RouteEvent::Restart(update) = event && update.route.cost() != moq_net::origin::Cost::DRAIN { break; diff --git a/rs/moq-relay/tests/integration/memory.rs b/rs/moq-relay/tests/integration/memory.rs index 67024a7e0d6..026de5ba3ac 100644 --- a/rs/moq-relay/tests/integration/memory.rs +++ b/rs/moq-relay/tests/integration/memory.rs @@ -325,8 +325,7 @@ pub fn record(mut announced: moq_net::origin::Routes) -> std::sync::Arc (route, false), moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => (route, true), + | moq_net::origin::RouteEvent::Restart(route) => (route, true), }; recorded .lock() diff --git a/rs/moq-relay/tests/integration/runtime_uring.rs b/rs/moq-relay/tests/integration/runtime_uring.rs index 6861cd286ff..6cefa0453cd 100644 --- a/rs/moq-relay/tests/integration/runtime_uring.rs +++ b/rs/moq-relay/tests/integration/runtime_uring.rs @@ -930,8 +930,7 @@ async fn next_update(announced: &mut moq_net::origin::Routes) -> Option<(moq_net match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-relay/tests/integration/runtime_workers.rs b/rs/moq-relay/tests/integration/runtime_workers.rs index 2a96182fc7f..60388b3139c 100644 --- a/rs/moq-relay/tests/integration/runtime_workers.rs +++ b/rs/moq-relay/tests/integration/runtime_workers.rs @@ -169,8 +169,7 @@ async fn next_update(announced: &mut moq_net::origin::Routes) -> Option<(moq_net match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-relay/tests/integration/smoke.rs b/rs/moq-relay/tests/integration/smoke.rs index 5f028189eca..a7f4a0deb14 100644 --- a/rs/moq-relay/tests/integration/smoke.rs +++ b/rs/moq-relay/tests/integration/smoke.rs @@ -997,8 +997,7 @@ async fn next_update(announced: &mut moq_net::origin::Routes) -> Option<(moq_net match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-relay/tests/integration/tenant_matrix.rs b/rs/moq-relay/tests/integration/tenant_matrix.rs index a8fa9d0e8a4..bb707fd4117 100644 --- a/rs/moq-relay/tests/integration/tenant_matrix.rs +++ b/rs/moq-relay/tests/integration/tenant_matrix.rs @@ -353,12 +353,9 @@ async fn synced_listing(url: url::Url) -> BTreeSet { use moq_net::origin::RouteEvent; match next { Some(RouteEvent::End(route)) => listed.remove(route.prefix.as_str()), - Some( - RouteEvent::Start(route) - | RouteEvent::Update(route) - | RouteEvent::Restart(route) - | RouteEvent::Takeover { announce: route, .. }, - ) => listed.insert(route.prefix.to_string()), + Some(RouteEvent::Start(route) | RouteEvent::Update(route) | RouteEvent::Restart(route)) => { + listed.insert(route.prefix.to_string()) + } None => return, }; } diff --git a/rs/moq-stats/src/aggregate.rs b/rs/moq-stats/src/aggregate.rs index 4d55994184f..6fe28026925 100644 --- a/rs/moq-stats/src/aggregate.rs +++ b/rs/moq-stats/src/aggregate.rs @@ -810,11 +810,8 @@ impl Merged { // unannounce. A closed stream ends the merged view. loop { match self.announce.poll_next(waiter) { - // A takeover continues what was read through its predecessor, like an update. Poll::Ready(Some( - moq_net::origin::RouteEvent::Start(update) - | moq_net::origin::RouteEvent::Update(update) - | moq_net::origin::RouteEvent::Takeover { announce: update, .. }, + moq_net::origin::RouteEvent::Start(update) | moq_net::origin::RouteEvent::Update(update), )) => { let instance = update.route.instance().clone(); changed |= self.apply_announce(&update, instance, true) @@ -914,9 +911,9 @@ impl Merged { } self.last_announced.insert(key.0.clone(), key.1.clone()); - // A route update on a node already tracked (a reprice, or a takeover - // with different metadata) keeps the live reader: existing - // subscriptions survive a takeover, and the reader re-resolves through + // A route update on a node already tracked (a reprice, or another route + // of the same instance) keeps the live reader: existing + // subscriptions survive it, and the reader re-resolves through // the current best route the moment it actually ends. Only an ended // node re-arms here, since a fresh route is new evidence that a // re-resolve could succeed. A sticky contribution carries across the @@ -1147,8 +1144,7 @@ mod tests { match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-stats/src/consume.rs b/rs/moq-stats/src/consume.rs index 3d1b052c505..c599914e491 100644 --- a/rs/moq-stats/src/consume.rs +++ b/rs/moq-stats/src/consume.rs @@ -177,8 +177,7 @@ mod tests { match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index 5aaf91b2f62..85bd4b21c87 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -1670,8 +1670,7 @@ mod tests { match announced.next().await? { moq_net::origin::RouteEvent::Start(route) | moq_net::origin::RouteEvent::Update(route) - | moq_net::origin::RouteEvent::Restart(route) - | moq_net::origin::RouteEvent::Takeover { announce: route, .. } => Some((route, true)), + | moq_net::origin::RouteEvent::Restart(route) => Some((route, true)), moq_net::origin::RouteEvent::End(route) => Some((route, false)), } } diff --git a/rs/moq-tokio/tests/integration/broadcast.rs b/rs/moq-tokio/tests/integration/broadcast.rs index 5508bbfe111..4a831b0521d 100644 --- a/rs/moq-tokio/tests/integration/broadcast.rs +++ b/rs/moq-tokio/tests/integration/broadcast.rs @@ -822,8 +822,7 @@ async fn next_route(routes: &mut moq_net::origin::Routes) -> (moq_net::announce: moq_net::origin::RouteEvent::End(announce) => (announce, false), moq_net::origin::RouteEvent::Start(announce) | moq_net::origin::RouteEvent::Update(announce) - | moq_net::origin::RouteEvent::Restart(announce) - | moq_net::origin::RouteEvent::Takeover { announce, .. } => (announce, true), + | moq_net::origin::RouteEvent::Restart(announce) => (announce, true), } } diff --git a/swift/Sources/Moq/Origin.swift b/swift/Sources/Moq/Origin.swift index 86f5f77e46f..44a196c274d 100644 --- a/swift/Sources/Moq/Origin.swift +++ b/swift/Sources/Moq/Origin.swift @@ -68,8 +68,11 @@ public final class BroadcastRequest: Sendable { /// Answer with the path's own exact route, owning a new broadcast. /// - /// It serves a fresh instance unless `instance` names one. Created visible, it announces - /// now and answers the waiting requesters; created `hidden`, they wait until + /// Unless `instance` names one, it takes the instance its prefix route lends when it first + /// announces or `Route.instance` first reads it, so readers that resolved the path through the prefix route continue on it; + /// its end then moves the prefix route to a fresh instance, so a restarted job is a new + /// stream. A prefix route that names its instance lends none, and the route mints its own. + /// Created visible, it announces now and answers the waiting requesters; created `hidden`, they wait until /// `Route.announce()`, so the job can fill its catalog first. public func route( hidden: Bool = false, cost: UInt64 = 0, instance: String? = nil, epoch: UInt64 = 0 @@ -117,7 +120,11 @@ public final class Route: AsyncSequence, Sendable { } } - /// The stream this route serves, minted at creation unless `instance` named one. + /// The stream this route serves now. A named instance never changes. Unnamed, a prefix + /// route moves to a fresh one each time a job holding its current one ends. A job's route + /// takes its prefix route's current one when it first announces or when this first reads + /// it. Read first, the job announces under it, and `announce()` throws `Unroutable` once + /// the prefix route has moved past it, so a signer built from it stays valid. public var instance: String { ffi.instanceId() } /// Make the route visible, or keep it so; throws `Closed` once its broadcast closed. diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index 82a5b7f49e4..d079d95456b 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -407,7 +407,7 @@ final class SmokeTests: XCTestCase { let request = try XCTUnwrap(requested) XCTAssertEqual(try request.path, "live/cam") let served = try request.route(hidden: true) - XCTAssertNotEqual(served.instance, dynamic.instance) + XCTAssertEqual(served.instance, dynamic.instance) let broadcast = try served.broadcast XCTAssertTrue(broadcast === (try served.broadcast)) // Only announcing answers the requester. diff --git a/test/drill/mutations/relay-withdraws-lost-publisher.patch b/test/drill/mutations/relay-withdraws-lost-publisher.patch index 109e8096040..66e1b0ba07d 100644 --- a/test/drill/mutations/relay-withdraws-lost-publisher.patch +++ b/test/drill/mutations/relay-withdraws-lost-publisher.patch @@ -49,7 +49,7 @@ diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber. diff --git a/rs/moq-net/src/lite/subscriber/routed.rs b/rs/moq-net/src/lite/subscriber/routed.rs --- a/rs/moq-net/src/lite/subscriber/routed.rs +++ b/rs/moq-net/src/lite/subscriber/routed.rs -@@ -378,7 +378,7 @@ impl Routed { +@@ -354,7 +354,7 @@ impl Routed { self.previous = previous; self.outputs(subscriber); for (_, served) in std::mem::take(&mut self.previous) { @@ -58,7 +58,7 @@ diff --git a/rs/moq-net/src/lite/subscriber/routed.rs b/rs/moq-net/src/lite/subs } Ok(()) } -@@ -422,7 +422,7 @@ impl Routed { +@@ -398,7 +398,7 @@ impl Routed { } => self.serve(subscriber, id, named, claim, served), node::Output::Unserve { id, claim } => { if let Some(served) = self.served.remove(&(id, claim)) { @@ -67,14 +67,3 @@ diff --git a/rs/moq-net/src/lite/subscriber/routed.rs b/rs/moq-net/src/lite/subs } } // The ROUTE_END echo goes out as the peer's ROUTE_END is read, and a stream -@@ -477,8 +477,8 @@ impl Routed { - .map(|(taken, entry)| match *taken == key.1 { - // In place, its sources serve what the new one continues: they live on with - // it rather than closing with the old route. -- true => (&entry.route.dynamic, Some(std::mem::take(&mut entry.route.live))), -- false => (&entry.route.dynamic, None), -+ true => (&*entry.route.dynamic, Some(std::mem::take(&mut entry.route.live))), -+ false => (&*entry.route.dynamic, None), - }); - // A new instance or epoch replaces the old route, attached before it goes. - tracing::debug!(route = %subscriber.log_path(&path), node = id, "announce");