Skip to content

Commit ac17e82

Browse files
authored
feat(api): XIP-83 d14n binding — bidirectional Subscribe on QueryApi (#2020)
## XIP-83 d14n binding — bidirectional `QueryApi.Subscribe` Implements the decentralized (d14n) binding of [XIP-83 mutable subscription streams](xmtp/XIPs#139) on xmtpd. A single long-lived bidirectional stream lets a client **mutate its topic set in place** (add/remove subscriptions) and keep the connection alive with ping/pong — instead of tearing down and reopening a stream every time group membership changes. This is the xmtpd counterpart to the node-go v3 `MlsApi.Subscribe` binding. Same control protocol (`Mutate` / `Started` / `CatchupComplete` / `TopicsLive` / `Ping` / `Pong`); the d14n binding delivers `OriginatorEnvelope`s and uses per-originator vector cursors. ### Design: gated async catch-up - **Single-writer + single-sender actor** per subscription. All topic-set mutation happens on the writer goroutine; all stream sends happen on the sender goroutine (drained via a bounded queue). - **Per-topic gate + pending buffer.** A newly-added topic is caught up by an async fetcher goroutine while already-live topics keep flowing — a slow catch-up never head-of-line-blocks live delivery. - **No missing messages across the switch.** A topic is registered *live* **before** its catch-up snapshot is taken, and delivery is deduped by a monotonic per-originator cursor (`advanceTopicCursors`). Anything that lands during catch-up is either in the snapshot or arrives live; the dedup drops the overlap. - **Sparse vs. filled cursors.** The writer keeps only the originator cursors the client provided (growing them as new originators are seen); the fetcher holds a filled copy purely for the query. Keeps memory bounded at the active-topic ceiling. - **1M active-topic ceiling** (`maxActiveSubscribeTopics`) — ~16–32MB for a consumer that large, deliberately favored over forcing clients into multiple connections (which would invite rate-limiting and wreck some topologies). - **Liveness.** Either peer may `Ping`; the receiver must `Pong` the nonce. No pong within the deadline → the node reaps the vanished peer (e.g. a mobile client the OS suspended behind a proxy that still ACKs the transport). ### Commits 1. `feat(api): add mutableSubscription handle to subscribeWorker` — in-place topic add/remove on the existing subscribe worker, guarded by `topicsMu`. 2. `feat(api): XIP-83 bidirectional Subscribe handler on QueryApi + xmtpv4 protos` — the handler (`subscribe.go`) + regenerated xmtpv4 protos. 3. `test(api): Subscribe handler tests + configurable keepalive interval for tests` — 5 race-tested scenarios + a test-only knob to shorten the keepalive cadence. ### Tests `go test -race ./pkg/api/message` — all green: - `TestSubscribe_CatchUpThenLive` - `TestSubscribe_MutateRemoveStopsDelivery` - `TestSubscribe_HalfCloseHistoryOnlyDrains` - `TestSubscribe_HistoryOnlyOnLiveRejected` - `TestSubscribe_NoPongIsReaped` ### Dependency & merge ordering Built on [xmtp/proto#338](xmtp/proto#338) (`QueryApi.Subscribe`). The committed `.pb.go` here is generated from that branch via `dev/gen/protos`; once #338 merges, regenerating from proto `main` produces byte-identical output. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 4b14f2a commit ac17e82

14 files changed

Lines changed: 3496 additions & 277 deletions

File tree

pkg/api/message/listener.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ import (
1111
)
1212

1313
type listener struct {
14-
ctx context.Context
15-
ch chan<- []*envelopes.OriginatorEnvelope
14+
ctx context.Context
15+
ch chan<- []*envelopes.OriginatorEnvelope
16+
// topicsMu guards topics, which is fixed for a server-stream listener but mutated in
17+
// place by a mutableSubscription (the XIP-83 bidi Subscribe). The worker reads it when
18+
// reaping the listener (closeListener), so concurrent mutation needs the lock.
19+
topicsMu sync.Mutex
1620
closed bool
1721
topics map[string]struct{}
1822
originators map[uint32]struct{}
@@ -101,7 +105,7 @@ func (lm *listenersMap[K]) removeListener(keys map[K]struct{}, l *listener) {
101105
for key := range keys {
102106
value, ok := lm.data.Load(key)
103107
if !ok || value == nil {
104-
return // Key doesn't exist, nothing to do
108+
continue // This key is already gone (e.g. reaped by the worker); keep unregistering the rest
105109
}
106110
set := value.(*listenerSet)
107111
set.removeListener(l)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package message
2+
3+
import (
4+
"context"
5+
6+
"github.com/xmtp/xmtpd/pkg/envelopes"
7+
)
8+
9+
// mutableSubscription is an in-place-mutable topic subscription on the subscribeWorker, used by
10+
// the XIP-83 bidirectional Subscribe RPC. Unlike subscribeWorker.listen (a fixed query), its
11+
// topic set is grown and shrunk over the life of the stream via addTopics / removeTopics. It is
12+
// never global: an empty topic set delivers nothing (a fresh stream that has subscribed to
13+
// nothing yet), in contrast to newListener, which treats an empty query as "all envelopes".
14+
//
15+
// All mutation methods are intended to be called from the single owning goroutine (the Subscribe
16+
// handler's writer loop). The worker's reap path (closeListener) may also touch the underlying
17+
// listener's topic set concurrently; both sides take listener.topicsMu, so that is safe.
18+
type mutableSubscription struct {
19+
worker *subscribeWorker
20+
l *listener
21+
// ch is the receive end of the listener channel. The worker pushes envelope batches here;
22+
// it CLOSES the channel when it reaps the listener (ctx done or the consumer fell behind),
23+
// which the writer loop observes as "torn down, reconnect from cursors".
24+
ch <-chan []*envelopes.OriginatorEnvelope
25+
}
26+
27+
// newMutableSubscription registers an empty, non-global topic subscription on the worker and
28+
// returns a handle the caller mutates over the stream's lifetime. Call close when done.
29+
func (s *subscribeWorker) newMutableSubscription(ctx context.Context) *mutableSubscription {
30+
ch := make(chan []*envelopes.OriginatorEnvelope, subscriptionBufferSize)
31+
l := &listener{
32+
ctx: ctx,
33+
ch: ch,
34+
topics: make(map[string]struct{}),
35+
originators: make(map[uint32]struct{}),
36+
isGlobal: false, // empty topic set => delivers nothing, NOT all-envelopes
37+
}
38+
return &mutableSubscription{worker: s, l: l, ch: ch}
39+
}
40+
41+
// addTopics begins delivering the given topic keys to this subscription. Idempotent: a key
42+
// already subscribed is a no-op. A no-op once the worker has reaped the listener.
43+
func (m *mutableSubscription) addTopics(keys map[string]struct{}) {
44+
if len(keys) == 0 {
45+
return
46+
}
47+
m.l.topicsMu.Lock()
48+
defer m.l.topicsMu.Unlock()
49+
if m.l.closed {
50+
return
51+
}
52+
m.worker.topicListeners.addListener(keys, m.l)
53+
for k := range keys {
54+
m.l.topics[k] = struct{}{}
55+
}
56+
}
57+
58+
// removeTopics stops delivering the given topic keys. Idempotent.
59+
func (m *mutableSubscription) removeTopics(keys map[string]struct{}) {
60+
if len(keys) == 0 {
61+
return
62+
}
63+
m.l.topicsMu.Lock()
64+
defer m.l.topicsMu.Unlock()
65+
m.worker.topicListeners.removeListener(keys, m.l)
66+
for k := range keys {
67+
delete(m.l.topics, k)
68+
}
69+
}
70+
71+
// close unregisters the subscription from every topic it still holds. Safe to call even after
72+
// the worker has already reaped the listener (removeListener is idempotent).
73+
func (m *mutableSubscription) close() {
74+
m.l.topicsMu.Lock()
75+
defer m.l.topicsMu.Unlock()
76+
if len(m.l.topics) > 0 {
77+
m.worker.topicListeners.removeListener(m.l.topics, m.l)
78+
m.l.topics = make(map[string]struct{})
79+
}
80+
}

0 commit comments

Comments
 (0)