Skip to content

Commit 9bc3b55

Browse files
afrindclaude
andcommitted
relay: give subscribe-initiated forwarders the LF publisher chain + tl slot
In LF mode a subscribe-triggered upstream subscription created its publisher forwarder on relayExec_ with a MoqxRelay-direct callback and never placed it in tlForwarders_. That forwarder lives on the upstream session's iothread, so its onEmpty/forwardChanged/newGroupRequested ran there and touched registry_ off relayExec_, and same-thread subscribers missed the zero-hop fast path. Factor the publisher-forwarder callback chain (Weak -> CrossExec -> Local) and tlForwarders_ slot claim out of createPublisherForwarder into a shared installPublisherForwarderCallbackChain helper. The first subscriber now installs that same Weak->CrossExec(relayExec_)->Local chain and claims the tl slot on the publisher's exec (removeOnEmpty=true, since subscribe-initiated tracks drop on empty). getOrCreateFromSubscribe accepts a null callback so the LF caller installs the real chain on the forwarder's exec; non-LF still passes the relay directly. Document the resulting symmetry in the callback-chain overview and dev doc: subscribe-initiated now uses the same chain as publish-initiated, with the removeOnEmpty contract differing (true vs false). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent efa2da7 commit 9bc3b55

8 files changed

Lines changed: 243 additions & 52 deletions

docs/dev/local-forwarder-flow.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ upstream work, and nests a single sortie to `[Pub]` to wire the channel sub.
135135
[Relay] ├─▶ joinOrPrepareUpstreamSubscription() # registry: first vs subsequent
136136
[Relay] ├─▶ buildLocalToPublisherCallbacks()
137137
[Relay] └─▶ ⇢⇢▶ [Pub] single sortie:
138+
[Pub] ├─ if FIRST subscriber:
139+
[Pub] │ └─▶ installPublisherForwarderCallbackChain() # chain + tlForwarders_ slot
138140
[Pub] ├─▶ installChannelSubscriber(localFwd ↔ publisherFwd)
139141
[Pub] └─ if FIRST subscriber:
140142
[Pub] ├─▶ addChannelSubscriber(relayChain, passive)
@@ -151,9 +153,20 @@ upstream work, and nests a single sortie to `[Pub]` to wire the channel sub.
151153
```
152154

153155
The first subscriber does the heavy lifting (upstream subscribe + installing the relay chain,
154-
the same passive cache/Top-N chain described in [Data flow](#data-flow)). Subsequent
155-
subscribers on the same thread hit the `attachSubscriber` fast path; on other threads they take
156-
only the `installChannelSubscriber` half of the sortie.
156+
the same passive cache/Top-N chain described in [Data flow](#data-flow)). It also installs the
157+
publisher-forwarder control chain and claims the `tlForwarders_` slot via
158+
`installPublisherForwarderCallbackChain` — the **same** wiring the publish path uses, so a
159+
subscribe-initiated publisher forwarder is symmetric with a publish-initiated one. Subsequent
160+
subscribers on the same thread hit the `attachSubscriber` fast path (now also for
161+
subscribe-initiated tracks); on other threads they take only the `installChannelSubscriber` half
162+
of the sortie.
163+
164+
The forwarder is created on `relayExec_` (in `joinOrPrepareUpstreamSubscription`) with a **null**
165+
callback, then gets its real callback + tl slot installed on `[Pub]` in the first-subscriber
166+
sortie — `tlForwarders_.get()` must run on the forwarder's own exec. It uses `removeOnEmpty=true`
167+
(subscribe-initiated tracks drop on empty, unlike publish): `LocalForwarderCallback` vacates the
168+
tl slot on `[Pub]` when the last subscriber leaves, while `onEmptyImpl` unsubscribes upstream and
169+
removes the registry entry on `[Relay]`.
157170

158171
### Why each guard exists
159172

@@ -250,8 +263,10 @@ The reusable adapter layers:
250263

251264
### Publisher forwarder → relay state
252265

253-
Built by `createPublisherForwarder`. Lifecycle events on the publisher's own forwarder must
254-
reach relay-global state on `relayExec_`:
266+
Built by `installPublisherForwarderCallbackChain` for both publish-initiated (via
267+
`createPublisherForwarder`) and subscribe-initiated (via `attachNewLocalForwarderOnRelayExec`'s
268+
first-subscriber sortie) tracks. Lifecycle events on the publisher's own forwarder must reach
269+
relay-global state on `relayExec_`:
255270

256271
```
257272
[Pub] publisherFwd fires onEmpty / forwardChanged / newGroupRequested

src/MoqxRelay.cpp

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
#include "relay/PublisherCrossExecFilter.h"
1515
#include "relay/SubscriberCrossExecFilter.h"
1616
#include "relay/WeakRelayForwarderCallback.h"
17+
#include <folly/ScopeGuard.h>
1718
#include <folly/container/F14Set.h>
1819
#include <moxygen/MoQFilters.h>
1920
#include <moxygen/MoQTrackProperties.h>
@@ -451,28 +452,44 @@ MoqxRelay::validatePublishNamespace(const FullTrackName& ftn, RequestID requestI
451452
return std::nullopt;
452453
}
453454

454-
// Constructs the publisher's local forwarder and installs its callback chain (Weak ->
455-
// CrossExec -> LocalForwarder) on publisherExec, before the reply hops to relayExec_.
456-
// tlForwarders_ must already be initialized.
457-
std::shared_ptr<MoQForwarder> MoqxRelay::createPublisherForwarder(const PublishRequest& pub) {
458-
const auto& ftn = pub.fullTrackName;
459-
auto localPubFwd = std::make_shared<MoQForwarder>(ftn, pub.largest);
460-
localPubFwd->setExtensions(pub.extensions);
461-
462-
// removeOnEmpty=false: the publisher's forwarder must survive subscriber churn, so
463-
// LocalForwarderCallback removes it from tlForwarders_ only when the source ends.
455+
// Installs the publisher-forwarder callback chain (Weak -> CrossExec(relayExec_) ->
456+
// LocalForwarder) and claims the tlForwarders_ slot. Must run on the forwarder's exec.
457+
void MoqxRelay::installPublisherForwarderCallbackChain(
458+
const FullTrackName& ftn,
459+
const std::shared_ptr<MoQForwarder>& publisherFwd,
460+
bool removeOnEmpty
461+
) {
462+
if (!tlForwarders_.get()) {
463+
tlForwarders_.reset(new LocalForwarderRegistry());
464+
}
464465
auto relayAdapter = std::make_shared<WeakRelayForwarderCallback>(weak_from_this());
465466
auto crossExec = std::make_shared<CrossExecForwarderCallback>(
466467
relayExec_,
467-
localPubFwd,
468+
publisherFwd,
468469
std::move(relayAdapter)
469470
);
470-
localPubFwd->setCallback(std::make_shared<LocalForwarderCallback>(
471+
publisherFwd->setCallback(std::make_shared<LocalForwarderCallback>(
471472
tlForwarders_.get(),
472473
ftn,
473474
std::move(crossExec),
474-
/*removeOnEmpty=*/false
475+
removeOnEmpty
475476
));
477+
// Authoritative slot claim; displaces any stale subscribe-path local forwarder so
478+
// same-thread subscribers reuse THIS forwarder via the fast path. Publish forwarders
479+
// (removeOnEmpty=false) are seeded at creation; subscribe claims await upstream OK.
480+
tlForwarders_->set(ftn, publisherFwd, /*seeded=*/!removeOnEmpty);
481+
}
482+
483+
// Constructs the publisher's local forwarder and installs its callback chain (Weak ->
484+
// CrossExec -> LocalForwarder) on publisherExec, before the reply hops to relayExec_.
485+
std::shared_ptr<MoQForwarder> MoqxRelay::createPublisherForwarder(const PublishRequest& pub) {
486+
const auto& ftn = pub.fullTrackName;
487+
auto localPubFwd = std::make_shared<MoQForwarder>(ftn, pub.largest);
488+
localPubFwd->setExtensions(pub.extensions);
489+
490+
// removeOnEmpty=false: the publisher's forwarder must survive subscriber churn, so
491+
// LocalForwarderCallback removes it from tlForwarders_ only when the source ends.
492+
installPublisherForwarderCallbackChain(ftn, localPubFwd, /*removeOnEmpty=*/false);
476493

477494
return localPubFwd;
478495
}
@@ -488,17 +505,9 @@ Subscriber::PublishResult MoqxRelay::publishFromPublisherExec(
488505
return folly::makeUnexpected(std::move(*err));
489506
}
490507

491-
if (!tlForwarders_.get()) {
492-
tlForwarders_.reset(new LocalForwarderRegistry());
493-
}
494-
508+
// createPublisherForwarder claims the tlForwarders_ slot on this exec.
495509
auto localPubFwd = createPublisherForwarder(pub);
496510

497-
// The publisher's forwarder is authoritative — claim the slot, displacing any
498-
// stale subscribe-path local forwarder so same-thread subscribers reuse THIS
499-
// forwarder via the fast path.
500-
tlForwarders_->set(pub.fullTrackName, localPubFwd);
501-
502511
// crossExecFilter is a channel subscriber for the relay exec
503512
// regulsterPublishOnRelay exec completes wiring the chain (topNFilter → terminationFilter →
504513
// cache).
@@ -886,14 +895,17 @@ void teardownLocalForwarderOnFailure(
886895
// Single-threaded mode:
887896
// forwarder.callback = MoqxRelay (direct, no hop)
888897
//
889-
// Multi-threaded — publisher forwarder (lives on publisherExec):
898+
// Multi-threaded — publisher forwarder (lives on publisherExec). Built by
899+
// installPublisherForwarderCallbackChain for BOTH publish-initiated (removeOnEmpty=false,
900+
// survives churn) and subscribe-initiated (removeOnEmpty=true, drops on empty) tracks:
890901
// publisherFwd.callback =
891-
// CrossExecForwarderCallback(relayExec_, publisherFwd,
892-
// WeakRelayForwarderCallback(relay))
902+
// LocalForwarderCallback(tlForwarders_, ftn,
903+
// CrossExecForwarderCallback(relayExec_, publisherFwd,
904+
// WeakRelayForwarderCallback(relay)))
893905
//
894-
// [publisherExec] CrossExecForwarderCallback: captures ftn by value,
895-
// dispatches to relayExec_ fire-and-forget
896-
//
906+
// [publisherExec] LocalForwarderCallback: removes from tlForwarders_ (onPublishDone
907+
// always; onEmpty if removeOnEmpty), passes the rest through
908+
// (CrossExecForwarderCallback dispatches to relayExec_ fire-and-forget)
897909
// [relayExec_] WeakRelayForwarderCallback: recovers relay via weak_ptr,
898910
// calls onEmptyImpl / forwardChangedImpl / newGroupRequestedImpl
899911
//
@@ -1762,6 +1774,7 @@ folly::coro::Task<MoqxRelay::PublisherAttachment> MoqxRelay::attachNewLocalForwa
17621774
bool forward
17631775
) {
17641776
// Runs on relayExec_.
1777+
XCHECK(mode() == Mode::LocalForwarder) << "subscribe-init chain install is LF-only";
17651778
const auto& ftn = subReq.fullTrackName;
17661779
PublisherAttachment attach;
17671780

@@ -1796,6 +1809,10 @@ folly::coro::Task<MoqxRelay::PublisherAttachment> MoqxRelay::attachNewLocalForwa
17961809
co_await folly::coro::co_withExecutor(
17971810
folly::getKeepAliveToken(attach.publisherExec),
17981811
[&]() -> folly::coro::Task<void> {
1812+
// First subscriber installs the publisher chain + tl slot before any sub is added.
1813+
if (sr.firstSetup) {
1814+
installPublisherForwarderCallbackChain(ftn, attach.publisherFwd, /*removeOnEmpty=*/true);
1815+
}
17991816
installChannelSubscriber(
18001817
*cbs.channelCb,
18011818
*attach.publisherFwd,
@@ -1888,7 +1905,7 @@ MoqxRelay::joinOrPrepareUpstreamSubscription(SubscribeRequest subReq) {
18881905

18891906
auto firstOrSubsequent = registry_.getOrCreateFromSubscribe(
18901907
ftn,
1891-
shared_from_this(),
1908+
/*callback=*/nullptr,
18921909
[this, &ftn](std::shared_ptr<MoQForwarder> f) { return buildFilterChain(ftn, std::move(f)); }
18931910
);
18941911

@@ -1952,14 +1969,27 @@ folly::coro::Task<Publisher::SubscribeResult> MoqxRelay::subscribeFromSubscriber
19521969
acquireLocalForwarder(ftn, [&] { return std::make_shared<MoQForwarder>(ftn); });
19531970

19541971
if (!isNew) {
1972+
// Wait for an in-flight isNew=true setup to seed largest, else SUBSCRIBE_OK carries
1973+
// the pre-seeding value a client reads as a track restart.
1974+
if (auto ready = localReg->readiness(ftn); ready && !ready->isFulfilled()) {
1975+
co_await ready->getSemiFuture();
1976+
}
19551977
if (auto err = checkRangeNotInPast(*localFwd, subReq)) {
19561978
co_return folly::makeUnexpected(std::move(*err));
19571979
}
19581980
co_return attachSubscriber(*localFwd, std::move(session), subReq, std::move(consumer));
19591981
}
19601982

1961-
// isNew=true: this thread owns setup. Install PendingForwarderCallback first so
1962-
// forwardChanged/newGroupRequested/onEmpty events during setup are captured for replay.
1983+
// Fulfill on every exit (incl. error/cancel below) so isNew=false waiters never hang.
1984+
auto readiness = localReg->beginReadiness(ftn);
1985+
auto fulfillReadiness = folly::makeGuard([&readiness]() noexcept {
1986+
if (!readiness->isFulfilled()) {
1987+
readiness->setValue();
1988+
}
1989+
});
1990+
1991+
// Install PendingForwarderCallback first so forwardChanged/newGroupRequested/onEmpty
1992+
// events during setup are captured for replay.
19631993
auto pendingCb = std::make_shared<PendingForwarderCallback>(localReg, ftn);
19641994
localFwd->setCallback(pendingCb);
19651995

src/MoqxRelay.h

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,10 +349,17 @@ class MoqxRelay : public moxygen::Publisher,
349349
);
350350

351351
// Constructs the publisher's local forwarder and installs its callback chain on
352-
// publisherExec. tlForwarders_ must already be initialized.
352+
// publisherExec.
353353
std::shared_ptr<moxygen::MoQForwarder> createPublisherForwarder(const moxygen::PublishRequest& pub
354354
);
355355

356+
// Must run on the forwarder's exec; removeOnEmpty=false survives churn, true drops on empty.
357+
void installPublisherForwarderCallbackChain(
358+
const moxygen::FullTrackName& ftn,
359+
const std::shared_ptr<moxygen::MoQForwarder>& publisherFwd,
360+
bool removeOnEmpty
361+
);
362+
356363
std::optional<moxygen::PublishError>
357364
validatePublishNamespace(const moxygen::FullTrackName& ftn, moxygen::RequestID requestID) const;
358365

src/SubscriptionRegistry.cpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ SubscriptionRegistry::getOrCreateFromSubscribe(
4747
auto it = subscriptions_.find(ftn);
4848
if (it == subscriptions_.end()) {
4949
auto forwarder = std::make_shared<moxygen::MoQForwarder>(ftn, largest);
50-
forwarder->setCallback(std::move(callback));
50+
// Null callback: caller installs the real chain later on the forwarder's exec (LF path).
51+
if (callback) {
52+
forwarder->setCallback(std::move(callback));
53+
}
5154
auto [consumer, topNFilter] = chainBuilder(forwarder);
5255
auto [emplaceIt, inserted] = subscriptions_.emplace(
5356
std::piecewise_construct,

src/relay/LocalForwarderCallback.h

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ namespace openmoq::moqx {
2222
// never clobber a newer one that has claimed the same track name.
2323
//
2424
// removeOnEmpty distinguishes the two roles:
25-
// - subscribe-path local forwarder (removeOnEmpty=true): when its last
25+
// - subscribe-path local & subscribe-initiated publisher forwarders (removeOnEmpty=true): when
26+
// last
2627
// subscriber leaves, its channel sub is pulled from the publisher and it is
2728
// dead — remove on onEmpty as well as onPublishDone.
28-
// - publisher's publisher forwarder (removeOnEmpty=false): it must survive
29+
// - publish-initiated publisher forwarder (removeOnEmpty=false): it must survive
2930
// subscriber churn (new subscribers may arrive while the publisher is
3031
// live), so it is removed ONLY when the source terminates (onPublishDone).
3132
class LocalForwarderCallback : public moxygen::MoQForwarder::Callback {

src/relay/LocalForwarderRegistry.h

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include <moxygen/relay/MoQForwarder.h>
1111

1212
#include <folly/container/F14Map.h>
13+
#include <folly/futures/SharedPromise.h>
1314

1415
namespace openmoq::moqx {
1516

@@ -25,6 +26,13 @@ class LocalForwarderRegistry {
2526
bool isNew;
2627
};
2728

29+
// readiness is non-null only while a subscribe-initiated forwarder's largest is
30+
// seeded asynchronously; publish-path forwarders seed at creation and leave it null.
31+
struct Entry {
32+
std::shared_ptr<moxygen::MoQForwarder> forwarder;
33+
std::shared_ptr<folly::SharedPromise<folly::Unit>> readiness;
34+
};
35+
2836
// Returns the existing local forwarder for ftn, or calls factory() to create
2937
// one. factory() is called at most once per ftn per thread lifetime.
3038
GetOrCreateResult getOrCreate(
@@ -33,10 +41,10 @@ class LocalForwarderRegistry {
3341
) {
3442
auto it = forwarders_.find(ftn);
3543
if (it != forwarders_.end()) {
36-
return {it->second, /*isNew=*/false};
44+
return {it->second.forwarder, /*isNew=*/false};
3745
}
3846
auto forwarder = factory();
39-
forwarders_.emplace(ftn, forwarder);
47+
forwarders_[ftn].forwarder = forwarder;
4048
return {std::move(forwarder), /*isNew=*/true};
4149
}
4250

@@ -45,16 +53,24 @@ class LocalForwarderRegistry {
4553
// a stale subscribe-path local forwarder under the same name is displaced
4654
// here, and drains itself via the source-termination cascade (its identity-
4755
// checked removal then no-ops, since this forwarder now owns the slot).
56+
// seeded=true releases readiness waiters now (the claiming forwarder's largest is
57+
// authoritative); seeded=false leaves them pending until the upstream OK.
4858
std::shared_ptr<moxygen::MoQForwarder>
49-
set(const moxygen::FullTrackName& ftn, std::shared_ptr<moxygen::MoQForwarder> forwarder) {
50-
auto prev = std::move(forwarders_[ftn]);
51-
forwarders_[ftn] = std::move(forwarder);
59+
set(const moxygen::FullTrackName& ftn,
60+
std::shared_ptr<moxygen::MoQForwarder> forwarder,
61+
bool seeded) {
62+
auto& entry = forwarders_[ftn];
63+
auto prev = std::move(entry.forwarder);
64+
entry.forwarder = std::move(forwarder);
65+
if (seeded && entry.readiness && !entry.readiness->isFulfilled()) {
66+
entry.readiness->setValue();
67+
}
5268
return prev;
5369
}
5470

5571
std::shared_ptr<moxygen::MoQForwarder> get(const moxygen::FullTrackName& ftn) const {
5672
auto it = forwarders_.find(ftn);
57-
return it != forwarders_.end() ? it->second : nullptr;
73+
return it != forwarders_.end() ? it->second.forwarder : nullptr;
5874
}
5975

6076
// Identity-checked removal: erase the entry for ftn only if it still points
@@ -64,18 +80,29 @@ class LocalForwarderRegistry {
6480
// forwarder that has since claimed the same track name.
6581
void remove(const moxygen::FullTrackName& ftn, const moxygen::MoQForwarder* expected) {
6682
auto it = forwarders_.find(ftn);
67-
if (it == forwarders_.end() || it->second.get() != expected) {
83+
if (it == forwarders_.end() || it->second.forwarder.get() != expected) {
6884
return;
6985
}
7086
forwarders_.erase(it);
7187
}
7288

89+
// isNew=false attachers await this before reading largest, so they never observe the
90+
// pre-seeding value a client reads as a track restart.
91+
std::shared_ptr<folly::SharedPromise<folly::Unit>>
92+
beginReadiness(const moxygen::FullTrackName& ftn) {
93+
auto promise = std::make_shared<folly::SharedPromise<folly::Unit>>();
94+
forwarders_[ftn].readiness = promise;
95+
return promise;
96+
}
97+
98+
std::shared_ptr<folly::SharedPromise<folly::Unit>> readiness(const moxygen::FullTrackName& ftn
99+
) const {
100+
auto it = forwarders_.find(ftn);
101+
return it != forwarders_.end() ? it->second.readiness : nullptr;
102+
}
103+
73104
private:
74-
folly::F14FastMap<
75-
moxygen::FullTrackName,
76-
std::shared_ptr<moxygen::MoQForwarder>,
77-
moxygen::FullTrackName::hash>
78-
forwarders_;
105+
folly::F14FastMap<moxygen::FullTrackName, Entry, moxygen::FullTrackName::hash> forwarders_;
79106
};
80107

81108
} // namespace openmoq::moqx

test/MoqxRelayPublishTests.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,9 @@ TEST_P(MoQRelayTest, PublishReconnectDuringSubscribeScopeGuardCrash) {
451451
// Relay state mutations must run on the relay executor; doPublishNamespaceDone
452452
// touches the namespace tree, which publishDone also cleans up via relayEvb_.
453453
verifyOnRelayExec([&] { relay_->doPublishNamespaceDone(kTestNamespace, publisherSession2); });
454+
// Drain the subscriber exec so the detached failure-teardown coro runs and
455+
// releases the subscriber session it holds (else: leaked mock at exit).
456+
driveIfMultiThread();
454457
}
455458

456459
// Same reconnect scenario but the upstream subscribe returns OK instead of an

0 commit comments

Comments
 (0)