From ce3f169d786da490822094c3971bb203652bc9da Mon Sep 17 00:00:00 2001 From: Tyler Hawkes Date: Mon, 8 Jun 2026 17:02:34 -0600 Subject: [PATCH] feat: XIP-83 bidirectional Subscribe handler (mutable subscriptions + ping/pong liveness) Single-writer bidi Subscribe on the v3 MLS API: mutate-in-place subscriptions over kind-prefixed topics (XIP-49) with per-topic catch-up (batched, bounded-pool), live-gate + high-water-mark dedup, TopicsLive markers, mutate_id-correlated per-wave CatchupComplete, history_only bounded catch-up with half-close drain, ping/pong reaping, and O(1) Add/Remove on the dispatcher subscription. Co-Authored-By: Claude Fable 5 --- pkg/mls/api/v1/service.go | 12 + pkg/mls/api/v1/subscribe.go | 1006 ++++++++++++++++ pkg/mls/api/v1/subscribe_test.go | 1645 +++++++++++++++++++++++++++ pkg/mls/store/readStore.go | 201 ++++ pkg/mls/store/store.go | 26 + pkg/proto/mls/api/v1/mls.pb.go | 1335 +++++++++++++++++++--- pkg/proto/mls/api/v1/mls_grpc.pb.go | 77 ++ pkg/subscriptions/dispatcher.go | 20 + pkg/subscriptions/subscription.go | 27 + 9 files changed, 4166 insertions(+), 183 deletions(-) create mode 100644 pkg/mls/api/v1/subscribe.go create mode 100644 pkg/mls/api/v1/subscribe_test.go diff --git a/pkg/mls/api/v1/service.go b/pkg/mls/api/v1/service.go index 732205d7..1ed6402c 100644 --- a/pkg/mls/api/v1/service.go +++ b/pkg/mls/api/v1/service.go @@ -51,7 +51,15 @@ type Service struct { ctxCancel func() disablePublish bool + cutoverChecker *migration.CutoverChecker + + // Subscribe (XIP-83) tunables; overridable in tests. Default to the package consts + // (subscribePingInterval / subscribePongDeadline / maxPendingBytes / maxFrameBytes). + pingInterval time.Duration + pongDeadline time.Duration + maxPendingBytes int + maxFrameBytes int } func NewService( @@ -71,6 +79,10 @@ func NewService( subDispatcher: subDispatcher, disablePublish: disablePublish, cutoverChecker: cutoverChecker, + pingInterval: subscribePingInterval, + pongDeadline: subscribePongDeadline, + maxPendingBytes: maxPendingBytes, + maxFrameBytes: maxFrameBytes, } s.ctx, s.ctxCancel = context.WithCancel(context.Background()) if s.dbWorker, err = newDBWorker(s.ctx, log, s.readOnlyStore.Queries(), subDispatcher, DEFAULT_POLL_INTERVAL); err != nil { diff --git a/pkg/mls/api/v1/subscribe.go b/pkg/mls/api/v1/subscribe.go new file mode 100644 index 00000000..2f540609 --- /dev/null +++ b/pkg/mls/api/v1/subscribe.go @@ -0,0 +1,1006 @@ +package api + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "time" + + "github.com/xmtp/xmtp-node-go/pkg/metrics" + mlsstore "github.com/xmtp/xmtp-node-go/pkg/mls/store" + v1proto "github.com/xmtp/xmtp-node-go/pkg/proto/message_api/v1" + mlsv1 "github.com/xmtp/xmtp-node-go/pkg/proto/mls/api/v1" + "github.com/xmtp/xmtp-node-go/pkg/topic" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const ( + // subscribePingInterval is how long a Subscribe stream may be idle before the + // node sends a liveness Ping (XIP-83 server requirement 4; RECOMMENDED <= 30s). + subscribePingInterval = 30 * time.Second + // subscribePongDeadline is how long the node waits for the client's Pong before + // reaping the stream (XIP-83 RECOMMENDED <= the ping interval). + subscribePongDeadline = subscribePingInterval + // subscribeBacklog is the message-channel buffer for a Subscribe connection. + subscribeBacklog = 4096 + // sendQueueDepth buffers frame batches between the writer and the sender goroutine, so the + // writer is not parked by a slow stream.Send. Small: it only smooths Send latency. + sendQueueDepth = 8 + + // Batched catch-up tuning (XIP-83). chunk = groups per DB round-trip; + // perGroupLimit = rows per group per round; concurrency = chunks in flight. + // The composite (group_id, id) index makes each per-group seek cheap, so the + // binding constraint is payload bytes, not the planner. All tunable. + catchUpChunkSize = 256 + catchUpPerGroupLimit = 50 + catchUpConcurrency = 4 + // catchUpChannelBuffer smooths handoff from the catch-up fetchers to the writer; + // when full, fetchers block (backpressure) rather than racing ahead of the client. + catchUpChannelBuffer = 64 + + // maxPendingBytes caps live messages buffered while topics catch up. Exceeding it + // drops the stream (the client reconnects from its cursor) rather than risk OOM. + maxPendingBytes = 64 << 20 // 64 MiB + // maxFrameBytes targets server message frames well under gRPC's default 4 MiB limit. It is a + // best-effort cap: messages are packed up to this size but never split, so a single message + // larger than maxFrameBytes is still emitted in its own frame. Individual message size is + // bounded below gRPC's hard limit by the publish path, so a single-message frame still fits. + maxFrameBytes = 2 << 20 // 2 MiB +) + +// Topic kinds shared with the decentralized backend (XIP-49 §3.3.2): the first +// byte of a wire topic is the kind, the remainder is the identifier. +const ( + topicKindGroupMessagesV1 = 0x00 // identifier = group_id + topicKindWelcomeMessagesV1 = 0x01 // identifier = installation_key +) + +// splitTopic validates a kind-prefixed wire topic and returns (kind, identifier). +func splitTopic(t []byte) (byte, []byte, error) { + if len(t) < 2 { + return 0, nil, fmt.Errorf("topic must be a kind byte plus an identifier") + } + kind := t[0] + if kind != topicKindGroupMessagesV1 && kind != topicKindWelcomeMessagesV1 { + return 0, nil, fmt.Errorf("unsupported topic kind %d", kind) + } + return kind, t[1:], nil +} + +// buildMLSTopic maps a (kind, identifier) pair to the dispatcher/content topic +// string used as the canonical per-topic key throughout Subscribe. The kind is +// baked into the string (g- vs w- prefix) so a group and a welcome with the same +// identifier never collide. +func buildMLSTopic(kind byte, id []byte) string { + if kind == topicKindWelcomeMessagesV1 { + return topic.BuildMLSV1WelcomeTopic(id) + } + return topic.BuildMLSV1GroupTopic(id) +} + +// catchUpBatch is one unit of fetched catch-up history handed from a fetcher goroutine to +// the single writer. opened lists internal topics that finished catch-up in this batch — +// the writer flushes their buffered live messages, opens their gate, and announces them in +// a TopicsLive frame; openedWire carries the matching kind-prefixed wire topics the +// announcement echoes back to the client. wave identifies the Mutate whose adds this batch +// serves; the writer emits that wave's CatchupComplete once all its topics have opened. A +// non-nil err means the fetch failed and the writer should tear the stream down (the +// client reconnects from its cursor) rather than emit a misleading CatchupComplete or a +// history gap. +type catchUpBatch struct { + groupMsgs []*mlsv1.GroupMessage + welcomeMsgs []*mlsv1.WelcomeMessage + opened []string + openedWire [][]byte + wave int + err error +} + +// waveState tracks one Mutate's in-flight catch-up: how many of its topics have +// yet to open, and the client's correlation id to echo on its CatchupComplete. +type waveState struct { + remaining int + mutateID uint64 +} + +// Subscribe is the XIP-83 bidirectional subscription. One long-lived stream that the +// client mutates in place via add/remove group & welcome deltas (no reconnect on +// membership change), with a WebSocket-style ping/pong so the client detects silent +// stream death and the node reaps a peer that has gone away. Group and welcome messages +// share the stream. See XIP-83. +// +// Concurrency model: SINGLE WRITER. The select loop below is the sole owner of every piece +// of mutable state (the high-water marks, the catch-up gate, the pending buffer, the ping +// bookkeeping). It is the only goroutine that decides WHAT to send and in what order; the +// actual stream.Send runs on one dedicated sender goroutine fed by an ordered channel, so a +// slow client can never park the writer (it stays free to run the ping/pong reap). Every +// other goroutine — the frame reader, the catch-up fetchers, the sender — is a pure producer +// or consumer that touches no writer-owned state. There are no mutexes: serialization is by +// single-threadedness, like a Rust task that owns its socket and an mpsc receiver. Ordering +// (history before live, no dupes) falls out of the order the writer enqueues, backstopped by +// the per-topic high-water mark. +func (s *Service) Subscribe(stream mlsv1.MlsApi_SubscribeServer) error { + log := s.log.Named("subscribe") + log.Info("subscription started") + defer log.Info("subscription stopped") + + // Tonic-based clients need an initial header (see SubscribeGroupMessages). + _ = stream.SendHeader(metadata.Pairs("subscribed", "true")) + + ctx := stream.Context() + + wrap := func(v1 *mlsv1.SubscribeResponse_V1) *mlsv1.SubscribeResponse { + return &mlsv1.SubscribeResponse{Version: &mlsv1.SubscribeResponse_V1_{V1: v1}} + } + + // ----- Writer-owned state. Touched ONLY by the select loop below. ----- + highWaterMarks := make(map[string]uint64) // topic (kind-prefixed) -> last id sent + catchingUp := make(map[string]bool) // topic -> catch-up in progress + pending := make(map[string][]*v1proto.Envelope) // live held while a topic catches up + subscribed := make(map[string]struct{}) // topics live-registered on the dispatcher + pendingBytes := 0 + lastActivity := time.Now() + var awaitingPong bool + var pingNonce uint64 + var pingSentAt time.Time + subscribedTopics := 0 + // Catch-up wave bookkeeping: each Mutate that adds subscriptions is a wave, and the + // writer emits one CatchupComplete (echoing the Mutate's mutate_id) per wave once all + // of its topics have opened. topicWave maps a not-yet-opened topic to its wave so a + // remove mid-catch-up can free the wave slot it would otherwise wait on forever. + mutateSeen := false + nextWave := 0 + waves := make(map[int]waveState) + topicWave := make(map[string]int) + // halfClosed: the client closed its send direction; finish in-flight waves, then close. + halfClosed := false + + defer func() { + if subscribedTopics > 0 { + metrics.EmitUnsubscribeTopics(ctx, log, subscribedTopics) + } + }() + + // One mutable subscription for the whole stream, grown/shrunk in place. + sub := s.subDispatcher.NewSubscription(subscribeBacklog) + defer sub.Unsubscribe() + + // stream.Send runs on a dedicated sender goroutine, NOT the writer, so a client that stops + // reading (a stalled Send) can never park the writer — it stays free to run the ping/pong + // reap and tear the stream down. The writer hands frame batches to the sender over a + // bounded channel; the sender is the SOLE caller of stream.Send (order preserved by the + // single channel). An async Send error surfaces on sendErrCh (observed in send() and in the + // main select); a handoff that blocks past the pong deadline (sender wedged on a non-reading + // client and the buffer filled) fails the stream too. + outbound := make(chan []*mlsv1.SubscribeResponse, sendQueueDepth) + sendErrCh := make(chan error, 1) + senderDone := make(chan struct{}) + go func() { + defer close(senderDone) + for batch := range outbound { + for _, resp := range batch { + if err := stream.Send(resp); err != nil { + select { + case sendErrCh <- err: + default: + } + return + } + } + } + }() + var closeOutboundOnce sync.Once + closeOutbound := func() { closeOutboundOnce.Do(func() { close(outbound) }) } + defer closeOutbound() // sender exits when this drains (or when a Send error returns it) + + // flush closes the outbound queue and waits for the sender to drain it, so a GRACEFUL + // completion (the bounded-catch-up half-close) delivers every queued frame before the + // handler returns and gRPC closes the stream. Without it the tail of a drain would be + // lost. Bounded by the pong deadline / ctx so a client that stopped reading mid-drain + // cannot wedge the close; if that bound trips the drain did NOT finish, so flush returns + // DeadlineExceeded (never a false OK). Only the clean-completion paths call this; error + // teardowns just return (the sender exits when the stream closes). + flush := func() error { + closeOutbound() + select { + case <-senderDone: + return nil // the sender drained every queued frame + case <-ctx.Done(): + return nil // client disconnected; gRPC surfaces the cancellation + case <-time.After(s.pongDeadline): + // The sender is still blocked in stream.Send (a slow or non-reading client), + // so the queued frames — the bounded catch-up's history tail and its + // CatchupComplete — were NOT delivered. This is NOT a successful completion: + // fail rather than return OK and mislead the client into believing the drain + // finished with a truncated catch-up. + return status.Errorf( + codes.DeadlineExceeded, + "flush timed out waiting for sender to drain", + ) + } + } + + // send is the ONLY path frames take to the client (this includes Started). The single + // writer calls it, in order. lastActivity advances when a batch is accepted by the sender + // and is what gates the liveness Ping, so the Ping probes the client's receive path on a + // send-idle schedule (it is deliberately NOT reset by inbound frames — see requestChannel). + send := func(batches ...[]*mlsv1.SubscribeResponse) error { + var flat []*mlsv1.SubscribeResponse + for _, batch := range batches { + flat = append(flat, batch...) + } + if len(flat) == 0 { + return nil + } + select { + case outbound <- flat: + lastActivity = time.Now() + return nil + case err := <-sendErrCh: + return err + case <-ctx.Done(): + return nil + case <-s.ctx.Done(): + return status.Errorf(codes.Unavailable, "service is shutting down") + case <-time.After(s.pongDeadline): + return status.Errorf(codes.Unavailable, "send stalled; client not reading") + } + } + + // buildGroupFrames dedups by group id (advancing the high-water mark, so no duplicates + // across catch-up/live) and packs the survivors into <=maxFrameBytes frames. + buildGroupFrames := func(msgs []*mlsv1.GroupMessage) []*mlsv1.SubscribeResponse { + var frames []*mlsv1.SubscribeResponse + var frame []*mlsv1.GroupMessage + frameBytes := 0 + flush := func() { + if len(frame) == 0 { + return + } + frames = append(frames, wrap(&mlsv1.SubscribeResponse_V1{ + Response: &mlsv1.SubscribeResponse_V1_Messages_{ + Messages: &mlsv1.SubscribeResponse_V1_Messages{GroupMessages: frame}, + }, + })) + frame = nil + frameBytes = 0 + } + for _, m := range msgs { + key := topic.BuildMLSV1GroupTopic(m.GetV1().GetGroupId()) + if highWaterMarks[key] >= m.GetV1().GetId() { + continue + } + highWaterMarks[key] = m.GetV1().GetId() + sz := len(m.GetV1().GetData()) + if frameBytes+sz > s.maxFrameBytes && len(frame) > 0 { + flush() + } + frame = append(frame, m) + frameBytes += sz + } + flush() + return frames + } + + // buildWelcomeFrames is the welcome-topic analogue of buildGroupFrames. + buildWelcomeFrames := func(msgs []*mlsv1.WelcomeMessage) []*mlsv1.SubscribeResponse { + var frames []*mlsv1.SubscribeResponse + var frame []*mlsv1.WelcomeMessage + frameBytes := 0 + flush := func() { + if len(frame) == 0 { + return + } + frames = append(frames, wrap(&mlsv1.SubscribeResponse_V1{ + Response: &mlsv1.SubscribeResponse_V1_Messages_{ + Messages: &mlsv1.SubscribeResponse_V1_Messages{WelcomeMessages: frame}, + }, + })) + frame = nil + frameBytes = 0 + } + for _, m := range msgs { + key := topic.BuildMLSV1WelcomeTopic(welcomeInstallationKey(m)) + id := welcomeID(m) + if highWaterMarks[key] >= id { + continue + } + highWaterMarks[key] = id + sz := len(welcomeData(m)) + if frameBytes+sz > s.maxFrameBytes && len(frame) > 0 { + flush() + } + frame = append(frame, m) + frameBytes += sz + } + flush() + return frames + } + + // buildOpenGateFrames drains a topic's live messages buffered during catch-up (deduped) + // into frames and clears its gate, so subsequent live messages send directly. + buildOpenGateFrames := func(topicStr string) []*mlsv1.SubscribeResponse { + buffered := pending[topicStr] + delete(pending, topicStr) + delete(catchingUp, topicStr) + if topic.IsMLSV1Welcome(topicStr) { + welcomes := make([]*mlsv1.WelcomeMessage, 0, len(buffered)) + for _, env := range buffered { + pendingBytes -= len(env.Message) + if m, err := getWelcomeMessageFromEnvelope(env); err == nil { + welcomes = append(welcomes, m) + } + } + return buildWelcomeFrames(welcomes) + } + groups := make([]*mlsv1.GroupMessage, 0, len(buffered)) + for _, env := range buffered { + pendingBytes -= len(env.Message) + if m, err := getGroupMessageFromEnvelope(env); err == nil { + groups = append(groups, m) + } + } + return buildGroupFrames(groups) + } + + startedFrame := func() []*mlsv1.SubscribeResponse { + return []*mlsv1.SubscribeResponse{wrap(&mlsv1.SubscribeResponse_V1{ + Response: &mlsv1.SubscribeResponse_V1_Started_{ + Started: &mlsv1.SubscribeResponse_V1_Started{ + KeepaliveIntervalMs: uint32(s.pingInterval / time.Millisecond), + }, + }, + })} + } + catchupCompleteFrame := func(mutateID uint64) []*mlsv1.SubscribeResponse { + return []*mlsv1.SubscribeResponse{wrap(&mlsv1.SubscribeResponse_V1{ + Response: &mlsv1.SubscribeResponse_V1_CatchupComplete_{ + CatchupComplete: &mlsv1.SubscribeResponse_V1_CatchupComplete{ + MutateId: mutateID, + }, + }, + })} + } + + // dropTopic removes one topic from the stream: it stops live delivery, clears the + // per-stream cursor floor (so a later re-add can replay from a lower cursor — XIP-83), + // discards any live messages buffered during its catch-up, and — if the topic was still + // mid catch-up — frees its slot in the owning wave, emitting that wave's CatchupComplete + // if it was the last outstanding topic. Writer-goroutine only; never runs after + // half-close (mutations stop then). + dropTopic := func(t string) error { + sub.Remove(t) + delete(highWaterMarks, t) + // The gauge tracks live topics, so it (and the metric) move only when a genuinely + // live topic leaves — keeping subscribedTopics == len(subscribed) through removes, + // resets, and history_only-over-live, and balanced against the teardown defer. + if _, live := subscribed[t]; live { + delete(subscribed, t) + subscribedTopics-- + metrics.EmitUnsubscribeTopics(ctx, log, 1) + } + if catchingUp[t] { + for _, env := range pending[t] { + pendingBytes -= len(env.Message) + } + delete(pending, t) + delete(catchingUp, t) + } + if wave, ok := topicWave[t]; ok { + delete(topicWave, t) + if w, ok := waves[wave]; ok { + w.remaining-- + if w.remaining > 0 { + waves[wave] = w + } else { + delete(waves, wave) + if err := send(catchupCompleteFrame(w.mutateID)); err != nil { + return err + } + } + } + } + return nil + } + + // Started must be the first frame, before any catch-up, so proxied/buffered transports + // keep the connection open (XIP-83 server requirement 1). Sent here on the sole + // goroutine, before any producer is spawned. + if err := send(startedFrame()); err != nil { + return err + } + + // ----- Producers (no writer-owned state, no stream.Send) ----- + + catchUpCh := make(chan catchUpBatch, catchUpChannelBuffer) + forward := func(b catchUpBatch) { + select { + case catchUpCh <- b: + case <-ctx.Done(): + case <-s.ctx.Done(): + } + } + + // catchUpGroups fetches catch-up history for the given groups — chunked across the DB + // with bounded concurrency — and forwards each page to the writer. It owns no shared + // state and never sends to the stream; it just queries and hands results over. + catchUpGroups := func(adds []mlsstore.GroupCatchup, topics []string, wire [][]byte, wave int) { + processChunk := func(chunk []mlsstore.GroupCatchup, chunkTopics []string, chunkWire [][]byte) { + cursors := make([]uint64, len(chunk)) + for i := range chunk { + cursors[i] = chunk[i].IdCursor + } + active := make([]int, len(chunk)) + for i := range chunk { + active[i] = i + } + for len(active) > 0 { + select { + case <-ctx.Done(): + return + case <-s.ctx.Done(): + return + default: + } + filters := make([]mlsstore.GroupCatchup, len(active)) + for j, idx := range active { + filters[j] = mlsstore.GroupCatchup{ + GroupID: chunk[idx].GroupID, + IdCursor: cursors[idx], + } + } + msgs, err := s.readOnlyStore.QueryGroupMessagesBatch( + ctx, + filters, + catchUpPerGroupLimit, + ) + if err != nil { + if !errors.Is(err, context.Canceled) { + log.Error("batch catch-up (group)", zap.Error(err)) + forward(catchUpBatch{err: err}) + } + return + } + counts := make(map[string]int) + lastID := make(map[string]uint64) + for _, m := range msgs { + gid := string(m.GetV1().GetGroupId()) + counts[gid]++ + lastID[gid] = m.GetV1().GetId() + } + var opened []string + var openedWire [][]byte + var next []int + for _, idx := range active { + gid := string(chunk[idx].GroupID) + if counts[gid] == catchUpPerGroupLimit { + cursors[idx] = lastID[gid] + next = append(next, idx) + } else { + opened = append(opened, chunkTopics[idx]) + openedWire = append(openedWire, chunkWire[idx]) + } + } + forward(catchUpBatch{ + groupMsgs: msgs, + opened: opened, + openedWire: openedWire, + wave: wave, + }) + active = next + } + } + + sem := make(chan struct{}, catchUpConcurrency) + var chunkWg sync.WaitGroup + for start := 0; start < len(adds); start += catchUpChunkSize { + end := start + catchUpChunkSize + if end > len(adds) { + end = len(adds) + } + chunk, chunkTopics, chunkWire := adds[start:end], topics[start:end], wire[start:end] + chunkWg.Add(1) + sem <- struct{}{} + go func(chunk []mlsstore.GroupCatchup, chunkTopics []string, chunkWire [][]byte) { + defer chunkWg.Done() + defer func() { <-sem }() + processChunk(chunk, chunkTopics, chunkWire) + }(chunk, chunkTopics, chunkWire) + } + chunkWg.Wait() + } + + // catchUpWelcomes is the welcome-topic analogue of catchUpGroups. + catchUpWelcomes := func(adds []mlsstore.WelcomeCatchup, topics []string, wire [][]byte, wave int) { + processChunk := func(chunk []mlsstore.WelcomeCatchup, chunkTopics []string, chunkWire [][]byte) { + cursors := make([]uint64, len(chunk)) + for i := range chunk { + cursors[i] = chunk[i].IdCursor + } + active := make([]int, len(chunk)) + for i := range chunk { + active[i] = i + } + for len(active) > 0 { + select { + case <-ctx.Done(): + return + case <-s.ctx.Done(): + return + default: + } + filters := make([]mlsstore.WelcomeCatchup, len(active)) + for j, idx := range active { + filters[j] = mlsstore.WelcomeCatchup{ + InstallationKey: chunk[idx].InstallationKey, + IdCursor: cursors[idx], + } + } + msgs, err := s.readOnlyStore.QueryWelcomeMessagesBatch( + ctx, + filters, + catchUpPerGroupLimit, + ) + if err != nil { + if !errors.Is(err, context.Canceled) { + log.Error("batch catch-up (welcome)", zap.Error(err)) + forward(catchUpBatch{err: err}) + } + return + } + counts := make(map[string]int) + lastID := make(map[string]uint64) + for _, m := range msgs { + key := string(welcomeInstallationKey(m)) + counts[key]++ + lastID[key] = welcomeID(m) + } + var opened []string + var openedWire [][]byte + var next []int + for _, idx := range active { + key := string(chunk[idx].InstallationKey) + if counts[key] == catchUpPerGroupLimit { + cursors[idx] = lastID[key] + next = append(next, idx) + } else { + opened = append(opened, chunkTopics[idx]) + openedWire = append(openedWire, chunkWire[idx]) + } + } + forward(catchUpBatch{ + welcomeMsgs: msgs, + opened: opened, + openedWire: openedWire, + wave: wave, + }) + active = next + } + } + + sem := make(chan struct{}, catchUpConcurrency) + var chunkWg sync.WaitGroup + for start := 0; start < len(adds); start += catchUpChunkSize { + end := start + catchUpChunkSize + if end > len(adds) { + end = len(adds) + } + chunk, chunkTopics, chunkWire := adds[start:end], topics[start:end], wire[start:end] + chunkWg.Add(1) + sem <- struct{}{} + go func(chunk []mlsstore.WelcomeCatchup, chunkTopics []string, chunkWire [][]byte) { + defer chunkWg.Done() + defer func() { <-sem }() + processChunk(chunk, chunkTopics, chunkWire) + }(chunk, chunkTopics, chunkWire) + } + chunkWg.Wait() + } + + // Read client frames in a dedicated goroutine (gRPC Recv blocks) and forward them to + // the writer. This producer touches no writer-owned state. The channel is buffered so a + // forwarded Pong is never left blocking here while the writer is busy in a Send — which + // would otherwise let the ping deadline reap a stream whose client did answer. + // + // recvErr distinguishes a clean half-close (io.EOF — the client called CloseSend, the + // bounded catch-up flow) from a transport failure; on the latter the writer fails the + // RPC instead of reporting a false clean completion. The goroutine writes recvErr + // strictly before close(requestChannel), and the writer reads it only after observing + // the channel closed, so the close establishes the happens-before (no data race). + requestChannel := make(chan *mlsv1.SubscribeRequest, 16) + var recvErr error + go func() { + for { + req, err := stream.Recv() + if err != nil { + switch e, ok := status.FromError(err); { + case ok && e.Code() == codes.Canceled: + // client cancelled; ctx.Done covers teardown — not an error to surface + case err == io.EOF || err == context.Canceled: + // clean half-close + default: + log.Debug("reading subscription", zap.Error(err)) + recvErr = err + } + close(requestChannel) + return + } + select { + case requestChannel <- req: + case <-ctx.Done(): + return + case <-s.ctx.Done(): + return + } + } + }() + + pingTicker := time.NewTicker(s.pingInterval) + defer pingTicker.Stop() + + // ----- The writer. Single goroutine; owns all state and the socket. ----- + for { + select { + case <-ctx.Done(): + return nil + case <-s.ctx.Done(): + return status.Errorf(codes.Unavailable, "service is shutting down") + + case err := <-sendErrCh: + // A stream.Send failed on the sender goroutine after the writer had moved on; + // surface it here so the RPC ends in error rather than hanging. + return err + + case req, ok := <-requestChannel: + if !ok { + // The frame reader closed the channel. A transport error means the stream + // broke mid-flight: fail the RPC so the client reconnects from its cursor, + // rather than report it as a successful completion. + if recvErr != nil { + return status.Errorf(codes.Unavailable, "stream recv failed: %v", recvErr) + } + // Otherwise it is a clean half-close (io.EOF): no more mutations or pongs can + // arrive. Finish any in-flight catch-up waves first (the bounded catch-up + // flow: Mutate then CloseSend, the server hangs up after the last wave), and + // stop pinging — a half-closed peer cannot answer, and the bounded drain plus + // gRPC's own transport timeouts cover liveness. + if len(waves) == 0 { + return flush() + } + halfClosed = true + awaitingPong = false + requestChannel = nil // a nil channel never fires; this case goes dormant + continue + } + // NB: lastActivity is updated ONLY by a successful Send. The idle timer drives the + // liveness Ping, which probes the client's RECEIVE path; inbound frames prove only + // the send path, so they must NOT defer the Ping — else a client that streams frames + // but never reads could suppress the probe (and the reap) forever. + v1 := req.GetV1() + if v1 == nil { + // Unrecognized request version arm: fail rather than silently ignore, so a + // forward-version client is not left waiting on a response (XIP-83 req 8). + return status.Errorf(codes.InvalidArgument, "unrecognized SubscribeRequest version") + } + switch { + case v1.GetMutate() != nil: + m := v1.GetMutate() + // Removes are applied before adds, so a topic appearing in both is reset: + // removed (clearing its cursor floor), then re-added with a fresh catch-up. + // dropTopic owns the gauge/metric for any topic that was actually live, so a + // duplicate or unknown remove is harmless rather than drifting the count. + for _, wire := range m.GetRemoves() { + kind, id, err := splitTopic(wire) + if err != nil { + return status.Errorf(codes.InvalidArgument, "remove: %v", err) + } + if err := dropTopic(buildMLSTopic(kind, id)); err != nil { + return err + } + } + + // history_only adds never touch the dispatcher: no live registration, + // no gate, no pending buffer — a pure batched read with markers. + historyOnly := m.GetHistoryOnly() + // Collapse duplicate topics within this Mutate's adds, lowest id_cursor + // winning, so a repeated topic resolves deterministically and a lower cursor + // still drives the replay path below. + type addReq struct { + wire []byte + kind byte + id []byte + cursor uint64 + } + addOrder := make([]string, 0, len(m.GetAdds())) + addByTopic := make(map[string]*addReq, len(m.GetAdds())) + for _, add := range m.GetAdds() { + wire := add.GetTopic() + kind, id, err := splitTopic(wire) + if err != nil { + return status.Errorf(codes.InvalidArgument, "add: %v", err) + } + t := buildMLSTopic(kind, id) + cursor := add.GetIdCursor() + if ex, ok := addByTopic[t]; ok { + if cursor < ex.cursor { + ex.cursor, ex.wire = cursor, wire + } + continue + } + addByTopic[t] = &addReq{wire: wire, kind: kind, id: id, cursor: cursor} + addOrder = append(addOrder, t) + } + + var groupAdds []mlsstore.GroupCatchup + var groupTopics []string + var groupWire [][]byte + var welcomeAdds []mlsstore.WelcomeCatchup + var welcomeTopics []string + var welcomeWire [][]byte + newlyLive := 0 + for _, t := range addOrder { + a := addByTopic[t] + // Re-adding a topic already active on this stream is a no-op unless its + // cursor is below the current floor, which restarts catch-up to replay + // (XIP-83). The floor seeds to the starting cursor on a fresh add (below), + // so this compares against the topic's own start / last-sent id. + if _, live := subscribed[t]; live || catchingUp[t] { + // A history_only add is a one-shot bounded read that never registers + // for live delivery. Targeting a topic already live (or catching up) on + // this stream is contradictory: there is a single cursor floor per topic, + // so honoring it would have to disturb the live subscription's floor — and + // on the replay path (cursor below the floor) dropTopic would unsubscribe + // the topic without re-registering it, silently severing a live tail. + // Reject rather than guess (XIP-83 req 8 stance: fail contradictory input). + if historyOnly { + return status.Errorf( + codes.InvalidArgument, + "history_only add targets a topic already subscribed on this stream", + ) + } + if a.cursor >= highWaterMarks[t] { + continue + } + if err := dropTopic(t); err != nil { + return err + } + } + highWaterMarks[t] = a.cursor // explicit starting floor + if !historyOnly { + catchingUp[t] = true // gate BEFORE Add: no live escapes before buffering + sub.Add(t) + subscribed[t] = struct{}{} + newlyLive++ + } + switch a.kind { + case topicKindGroupMessagesV1: + groupAdds = append( + groupAdds, + mlsstore.GroupCatchup{GroupID: a.id, IdCursor: a.cursor}, + ) + groupTopics = append(groupTopics, t) + groupWire = append(groupWire, a.wire) + case topicKindWelcomeMessagesV1: + welcomeAdds = append( + welcomeAdds, + mlsstore.WelcomeCatchup{InstallationKey: a.id, IdCursor: a.cursor}, + ) + welcomeTopics = append(welcomeTopics, t) + welcomeWire = append(welcomeWire, a.wire) + } + } + if newlyLive > 0 { + subscribedTopics += newlyLive + metrics.EmitSubscribeTopics(ctx, log, newlyLive) + } + + // Each mutate that catches up subscriptions starts a wave; its + // CatchupComplete (echoing mutate_id) is emitted once all of the wave's + // topics are live. A mutate whose adds were all already-live (no-ops) or that + // added nothing still gets an immediate CatchupComplete — both so the client's + // mutate_id is answered and so a client that subscribed nothing learns it is + // live. A pure remove-only mutate after the first yields neither. + adds := len(groupAdds) + len(welcomeAdds) + switch { + case adds > 0: + wave := nextWave + nextWave++ + waves[wave] = waveState{remaining: adds, mutateID: m.GetMutateId()} + for _, t := range groupTopics { + topicWave[t] = wave + } + for _, t := range welcomeTopics { + topicWave[t] = wave + } + if len(groupAdds) > 0 { + go catchUpGroups(groupAdds, groupTopics, groupWire, wave) + } + if len(welcomeAdds) > 0 { + go catchUpWelcomes(welcomeAdds, welcomeTopics, welcomeWire, wave) + } + case len(m.GetAdds()) > 0 || !mutateSeen: + if err := send(catchupCompleteFrame(m.GetMutateId())); err != nil { + return err + } + } + mutateSeen = true + + case v1.GetPing() != nil: + nonce := v1.GetPing().GetNonce() + if err := send([]*mlsv1.SubscribeResponse{wrap(&mlsv1.SubscribeResponse_V1{ + Response: &mlsv1.SubscribeResponse_V1_Pong{Pong: &mlsv1.Pong{Nonce: nonce}}, + })}); err != nil { + return err + } + + case v1.GetPong() != nil: + // Only a Pong echoing the outstanding nonce clears the liveness deadline; a + // stale or unsolicited Pong must not keep a half-dead stream alive. + if v1.GetPong().GetNonce() == pingNonce { + awaitingPong = false + } + } + + case b := <-catchUpCh: + if b.err != nil { + // A fetch error means catch-up is incomplete; fail fast so the client + // reconnects from its cursor rather than receive a gap or a false + // CATCHUP_COMPLETE. + return status.Errorf(codes.Unavailable, "catch-up failed: %v", b.err) + } + // A topic can be removed (or reset) while its catch-up page is in flight. wanted + // reports whether this batch's wave still owns the topic; history, the gate + // flush, TopicsLive and the wave count all skip topics no longer wanted, so a + // removed topic never gets stale history, a phantom marker, or a miscounted wave. + wanted := func(t string) bool { + wv, ok := topicWave[t] + return ok && wv == b.wave + } + var history []*mlsv1.SubscribeResponse + if len(b.groupMsgs) > 0 { + kept := b.groupMsgs[:0] + for _, m := range b.groupMsgs { + if wanted(topic.BuildMLSV1GroupTopic(m.GetV1().GetGroupId())) { + kept = append(kept, m) + } + } + history = buildGroupFrames(kept) + } else if len(b.welcomeMsgs) > 0 { + kept := b.welcomeMsgs[:0] + for _, m := range b.welcomeMsgs { + if wanted(topic.BuildMLSV1WelcomeTopic(welcomeInstallationKey(m))) { + kept = append(kept, m) + } + } + history = buildWelcomeFrames(kept) + } + var openFrames []*mlsv1.SubscribeResponse + var liveMarker []*mlsv1.SubscribeResponse + openedCount := 0 + if len(b.opened) > 0 { + marker := &mlsv1.SubscribeResponse_V1_TopicsLive{} + for i, t := range b.opened { + if wv, ok := topicWave[t]; !ok || wv != b.wave { + continue // removed or reset mid-catch-up; its remove already settled it + } + delete(topicWave, t) + openFrames = append(openFrames, buildOpenGateFrames(t)...) + marker.Topics = append(marker.Topics, b.openedWire[i]) + openedCount++ + } + if openedCount > 0 { + liveMarker = []*mlsv1.SubscribeResponse{wrap(&mlsv1.SubscribeResponse_V1{ + Response: &mlsv1.SubscribeResponse_V1_TopicsLive_{TopicsLive: marker}, + })} + } + } + // Order is just program order here: history, then the flushed pending buffer + // (live messages that queued behind the catch-up — equally historical from the + // client's perspective), and only then the TopicsLive marker, so every frame a + // client sees after the marker really is live tail. + if err := send(history, openFrames, liveMarker); err != nil { + return err + } + if w, ok := waves[b.wave]; ok { + w.remaining -= openedCount + if w.remaining > 0 { + waves[b.wave] = w + } else { + // The wave's last topic just went live; its CatchupComplete (echoing + // the Mutate's id) follows the wave's final TopicsLive in program order. + delete(waves, b.wave) + if err := send(catchupCompleteFrame(w.mutateID)); err != nil { + return err + } + if halfClosed && len(waves) == 0 { + // Everything the client asked for is queued; return OK only if the + // sender actually drains it (else flush returns DeadlineExceeded). + return flush() + } + } + } + + case env, open := <-sub.MessagesCh: + if !open { + return status.Errorf(codes.Aborted, "subscription closed: consumer too slow") + } + t := env.ContentTopic + if catchingUp[t] { + pending[t] = append(pending[t], env) + pendingBytes += len(env.Message) + if pendingBytes > s.maxPendingBytes { + return status.Errorf( + codes.ResourceExhausted, + "catch-up buffer exceeded; reconnect from cursor", + ) + } + continue + } + var frames []*mlsv1.SubscribeResponse + if topic.IsMLSV1Welcome(t) { + if m, err := getWelcomeMessageFromEnvelope(env); err == nil { + frames = buildWelcomeFrames([]*mlsv1.WelcomeMessage{m}) + } else { + log.Error("error parsing welcome message", zap.Error(err)) + } + } else { + if m, err := getGroupMessageFromEnvelope(env); err == nil { + frames = buildGroupFrames([]*mlsv1.GroupMessage{m}) + } else { + log.Error("error parsing group message", zap.Error(err)) + } + } + if err := send(frames); err != nil { + return err + } + + case <-pingTicker.C: + if halfClosed { + continue // a half-closed peer cannot Pong; the wave drain bounds the stream + } + switch { + case awaitingPong: + if time.Since(pingSentAt) >= s.pongDeadline { + return status.Errorf(codes.DeadlineExceeded, "no Pong within deadline") + } + case time.Since(lastActivity) >= s.pingInterval: + pingNonce++ + if err := send([]*mlsv1.SubscribeResponse{wrap(&mlsv1.SubscribeResponse_V1{ + Response: &mlsv1.SubscribeResponse_V1_Ping{Ping: &mlsv1.Ping{Nonce: pingNonce}}, + })}); err != nil { + return err + } + awaitingPong = true + pingSentAt = time.Now() + } + } + } +} + +// welcomeInstallationKey / welcomeID / welcomeData read a WelcomeMessage regardless of +// which version (V1 or WelcomePointer) it carries. +func welcomeInstallationKey(m *mlsv1.WelcomeMessage) []byte { + if v1 := m.GetV1(); v1 != nil { + return v1.GetInstallationKey() + } + return m.GetWelcomePointer().GetInstallationKey() +} + +func welcomeID(m *mlsv1.WelcomeMessage) uint64 { + if v1 := m.GetV1(); v1 != nil { + return v1.GetId() + } + return m.GetWelcomePointer().GetId() +} + +func welcomeData(m *mlsv1.WelcomeMessage) []byte { + if v1 := m.GetV1(); v1 != nil { + return v1.GetData() + } + return m.GetWelcomePointer().GetWelcomePointer() +} diff --git a/pkg/mls/api/v1/subscribe_test.go b/pkg/mls/api/v1/subscribe_test.go new file mode 100644 index 00000000..55160760 --- /dev/null +++ b/pkg/mls/api/v1/subscribe_test.go @@ -0,0 +1,1645 @@ +package api + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + mlsstore "github.com/xmtp/xmtp-node-go/pkg/mls/store" + "github.com/xmtp/xmtp-node-go/pkg/mocks" + mlsv1 "github.com/xmtp/xmtp-node-go/pkg/proto/mls/api/v1" + test "github.com/xmtp/xmtp-node-go/pkg/testing" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// fakeSubscribeStream is an in-test implementation of mlsv1.MlsApi_SubscribeServer that +// drives the bidirectional XIP-83 Subscribe handler: the test pushes client frames with +// send() (the handler reads them via Recv) and reads the frames the handler emits via Send. +type fakeSubscribeStream struct { + ctx context.Context + incoming chan *mlsv1.SubscribeRequest + failCh chan error // inject a transport Recv error (not a clean half-close) + blockSend chan struct{} // when set, Send blocks on it (a non-reading client) + + mu sync.Mutex + sent []*mlsv1.SubscribeResponse + + closeOnce sync.Once +} + +func newFakeSubscribeStream(ctx context.Context) *fakeSubscribeStream { + return &fakeSubscribeStream{ + ctx: ctx, + incoming: make(chan *mlsv1.SubscribeRequest, 64), + failCh: make(chan error, 1), + } +} + +// send queues a client -> server frame. +func (f *fakeSubscribeStream) send(req *mlsv1.SubscribeRequest) { f.incoming <- req } + +// closeSend simulates the client half-closing its send direction (Recv -> io.EOF). +func (f *fakeSubscribeStream) closeSend() { f.closeOnce.Do(func() { close(f.incoming) }) } + +// failRecv makes the next Recv return err, simulating a mid-stream transport failure. +func (f *fakeSubscribeStream) failRecv(err error) { f.failCh <- err } + +// responses returns a snapshot copy of everything the handler has sent so far. +func (f *fakeSubscribeStream) responses() []*mlsv1.SubscribeResponse { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]*mlsv1.SubscribeResponse, len(f.sent)) + copy(out, f.sent) + return out +} + +// --- mlsv1.MlsApi_SubscribeServer --- + +func (f *fakeSubscribeStream) Send(resp *mlsv1.SubscribeResponse) error { + if f.blockSend != nil { + <-f.blockSend + } + f.mu.Lock() + f.sent = append(f.sent, resp) + f.mu.Unlock() + return nil +} + +func (f *fakeSubscribeStream) Recv() (*mlsv1.SubscribeRequest, error) { + select { + case err := <-f.failCh: + return nil, err + case req, ok := <-f.incoming: + if !ok { + return nil, io.EOF + } + return req, nil + case <-f.ctx.Done(): + return nil, f.ctx.Err() + } +} + +func (f *fakeSubscribeStream) Context() context.Context { return f.ctx } +func (f *fakeSubscribeStream) SendHeader(metadata.MD) error { return nil } +func (f *fakeSubscribeStream) SetHeader(metadata.MD) error { return nil } +func (f *fakeSubscribeStream) SetTrailer(metadata.MD) {} +func (f *fakeSubscribeStream) SendMsg(m interface{}) error { return nil } +func (f *fakeSubscribeStream) RecvMsg(m interface{}) error { return nil } + +// --- request builders --- + +// groupTopic / welcomeTopic build kind-prefixed wire topics (XIP-49 §3.3.2). +func groupTopic(groupId []byte) []byte { + return append([]byte{topicKindGroupMessagesV1}, groupId...) +} + +func welcomeTopic(installationKey []byte) []byte { + return append([]byte{topicKindWelcomeMessagesV1}, installationKey...) +} + +func addSub(topic []byte, cursor uint64) *mlsv1.SubscribeRequest_V1_Mutate_Subscription { + return &mlsv1.SubscribeRequest_V1_Mutate_Subscription{Topic: topic, IdCursor: cursor} +} + +func subReqMutate(m *mlsv1.SubscribeRequest_V1_Mutate) *mlsv1.SubscribeRequest { + return &mlsv1.SubscribeRequest{ + Version: &mlsv1.SubscribeRequest_V1_{V1: &mlsv1.SubscribeRequest_V1{ + Request: &mlsv1.SubscribeRequest_V1_Mutate_{Mutate: m}, + }}, + } +} + +func subReqPong(nonce uint64) *mlsv1.SubscribeRequest { + return &mlsv1.SubscribeRequest{ + Version: &mlsv1.SubscribeRequest_V1_{V1: &mlsv1.SubscribeRequest_V1{ + Request: &mlsv1.SubscribeRequest_V1_Pong{Pong: &mlsv1.Pong{Nonce: nonce}}, + }}, + } +} + +// --- response extractors (operate on a snapshot) --- + +func groupMsgsFrom(resps []*mlsv1.SubscribeResponse) []*mlsv1.GroupMessage { + var out []*mlsv1.GroupMessage + for _, r := range resps { + if msgs := r.GetV1().GetMessages(); msgs != nil { + out = append(out, msgs.GetGroupMessages()...) + } + } + return out +} + +func welcomeMsgsFrom(resps []*mlsv1.SubscribeResponse) []*mlsv1.WelcomeMessage { + var out []*mlsv1.WelcomeMessage + for _, r := range resps { + if msgs := r.GetV1().GetMessages(); msgs != nil { + out = append(out, msgs.GetWelcomeMessages()...) + } + } + return out +} + +func hasStarted(resps []*mlsv1.SubscribeResponse) bool { + for _, r := range resps { + if r.GetV1().GetStarted() != nil { + return true + } + } + return false +} + +// catchupCompletesFrom returns the echoed mutate_ids of every CatchupComplete, in order. +func catchupCompletesFrom(resps []*mlsv1.SubscribeResponse) []uint64 { + var out []uint64 + for _, r := range resps { + if cc := r.GetV1().GetCatchupComplete(); cc != nil { + out = append(out, cc.GetMutateId()) + } + } + return out +} + +func pingsFrom(resps []*mlsv1.SubscribeResponse) []uint64 { + var out []uint64 + for _, r := range resps { + if p := r.GetV1().GetPing(); p != nil { + out = append(out, p.GetNonce()) + } + } + return out +} + +func containsPong(resps []*mlsv1.SubscribeResponse, nonce uint64) bool { + for _, r := range resps { + if p := r.GetV1().GetPong(); p != nil && p.GetNonce() == nonce { + return true + } + } + return false +} + +func groupMsgsWithData(msgs []*mlsv1.GroupMessage, data string) []*mlsv1.GroupMessage { + var out []*mlsv1.GroupMessage + for _, m := range msgs { + if string(m.GetV1().GetData()) == data { + out = append(out, m) + } + } + return out +} + +func welcomesForKey(msgs []*mlsv1.WelcomeMessage, key []byte) []*mlsv1.WelcomeMessage { + var out []*mlsv1.WelcomeMessage + for _, m := range msgs { + if string(m.GetV1().GetInstallationKey()) == string(key) { + out = append(out, m) + } + } + return out +} + +// frameIndex returns the index of the first response satisfying pred, or -1. +func frameIndex(resps []*mlsv1.SubscribeResponse, pred func(*mlsv1.SubscribeResponse) bool) int { + for i, r := range resps { + if pred(r) { + return i + } + } + return -1 +} + +// topicsLiveHas reports whether the frame is a TopicsLive containing the wire topic. +func topicsLiveHas(r *mlsv1.SubscribeResponse, wireTopic []byte) bool { + for _, t := range r.GetV1().GetTopicsLive().GetTopics() { + if string(t) == string(wireTopic) { + return true + } + } + return false +} + +// lastFrameWithGroupMsgs returns the index of the last Messages frame carrying a message +// for the given group, or -1. +func lastFrameWithGroupMsgs(resps []*mlsv1.SubscribeResponse, groupId []byte) int { + last := -1 + for i, r := range resps { + for _, m := range r.GetV1().GetMessages().GetGroupMessages() { + if string(m.GetV1().GetGroupId()) == string(groupId) { + last = i + } + } + } + return last +} + +// lastFrameWithWelcomes is the welcome-topic analogue of lastFrameWithGroupMsgs. +func lastFrameWithWelcomes(resps []*mlsv1.SubscribeResponse, key []byte) int { + last := -1 + for i, r := range resps { + for _, m := range r.GetV1().GetMessages().GetWelcomeMessages() { + if string(m.GetV1().GetInstallationKey()) == string(key) { + last = i + } + } + } + return last +} + +// --- test helpers --- + +// waitForResponses polls the stream until pred is satisfied, failing the test on timeout. +func waitForResponses( + t *testing.T, + stream *fakeSubscribeStream, + timeout time.Duration, + desc string, + pred func([]*mlsv1.SubscribeResponse) bool, +) []*mlsv1.SubscribeResponse { + t.Helper() + deadline := time.After(timeout) + tick := time.NewTicker(10 * time.Millisecond) + defer tick.Stop() + for { + resps := stream.responses() + if pred(resps) { + return resps + } + select { + case <-deadline: + t.Fatalf("timeout waiting for %s", desc) + return resps + case <-tick.C: + } + } +} + +// publishGroup sends one group message with the exact data, (re)mocking validation for +// the target group first (a single mock returns a fixed group id, so it must be reset +// whenever the target group changes). +func publishGroup( + t *testing.T, + ctx context.Context, + svc *Service, + validationSvc *mocks.MockMLSValidationService, + groupId []byte, + data string, +) { + t.Helper() + validationSvc.ExpectedCalls = nil + mockValidateGroupMessages(validationSvc, groupId) + _, err := svc.SendGroupMessages(ctx, &mlsv1.SendGroupMessagesRequest{ + Messages: []*mlsv1.GroupMessageInput{ + { + Version: &mlsv1.GroupMessageInput_V1_{ + V1: &mlsv1.GroupMessageInput_V1{ + Data: []byte(data), + SenderHmac: []byte("hmac"), + ShouldPush: true, + }, + }, + }, + }, + }) + require.NoError(t, err) +} + +// TestSubscribe_CatchUpThenLiveNoDuplicates exercises the live-gate: history is sent +// before live, live messages published while catch-up is in flight are buffered and then +// flushed, and nothing is duplicated or reordered. +func TestSubscribe_CatchUpThenLiveNoDuplicates(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupId := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupId) + + const history = 25 + populateGroupMessages(t, ctx, svc, groupId, history, "hist") + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + // Subscribe from the beginning, then immediately publish live messages so they race + // the catch-up. + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupId), 0)}, + })) + const live = 10 + populateGroupMessages(t, ctx, svc, groupId, live, "live") + + total := history + live + resps := waitForResponses( + t, + stream, + 10*time.Second, + fmt.Sprintf("%d group messages", total), + func(rs []*mlsv1.SubscribeResponse) bool { return len(groupMsgsFrom(rs)) >= total }, + ) + + require.True(t, hasStarted(resps), "Started must be sent") + waitForResponses( + t, + stream, + 5*time.Second, + "CatchupComplete", + func(rs []*mlsv1.SubscribeResponse) bool { + return len(catchupCompletesFrom(rs)) >= 1 + }, + ) + + msgs := groupMsgsFrom(resps) + require.GreaterOrEqual(t, len(msgs), total) + validateMessageOrdering(t, msgs) + validateNoDuplicates(t, msgs) + + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestSubscribe_MutateRemoveStopsDelivery verifies that removing a group in place stops +// delivery for that group while a co-subscribed group keeps flowing on the same stream. +func TestSubscribe_MutateRemoveStopsDelivery(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupId := []byte(test.RandomString(32)) + sentinelId := []byte(test.RandomString(32)) + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + // Subscribe both groups at the live edge (they have no history yet). + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{ + addSub(groupTopic(groupId), 0), + addSub(groupTopic(sentinelId), 0), + }, + })) + waitForResponses( + t, + stream, + 5*time.Second, + "CatchupComplete", + func(rs []*mlsv1.SubscribeResponse) bool { + return len(catchupCompletesFrom(rs)) >= 1 + }, + ) + + // The group delivers before removal. + publishGroup(t, ctx, svc, validationSvc, groupId, "g-live-1") + waitForResponses( + t, + stream, + 5*time.Second, + "first group message", + func(rs []*mlsv1.SubscribeResponse) bool { + return len(groupMsgsWithData(groupMsgsFrom(rs), "g-live-1")) >= 1 + }, + ) + + // Remove the group, then Ping. The single main loop processes the remove before the + // ping, so observing the Pong proves sub.Remove(group) has executed. + stream.send( + subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{Removes: [][]byte{groupTopic(groupId)}}), + ) + stream.send( + &mlsv1.SubscribeRequest{Version: &mlsv1.SubscribeRequest_V1_{V1: &mlsv1.SubscribeRequest_V1{ + Request: &mlsv1.SubscribeRequest_V1_Ping{Ping: &mlsv1.Ping{Nonce: 42}}, + }}}, + ) + waitForResponses( + t, + stream, + 5*time.Second, + "pong(42)", + func(rs []*mlsv1.SubscribeResponse) bool { + return containsPong(rs, 42) + }, + ) + + // Publish to the removed group (must be dropped) then to the still-subscribed + // sentinel. The dbWorker dispatches strictly in id order, so once the sentinel + // message arrives the removed group's message has already been processed and dropped. + publishGroup(t, ctx, svc, validationSvc, groupId, "g-after-remove") + publishGroup(t, ctx, svc, validationSvc, sentinelId, "sentinel-1") + resps := waitForResponses( + t, + stream, + 5*time.Second, + "sentinel message", + func(rs []*mlsv1.SubscribeResponse) bool { + return len(groupMsgsWithData(groupMsgsFrom(rs), "sentinel-1")) >= 1 + }, + ) + + all := groupMsgsFrom(resps) + require.Empty(t, groupMsgsWithData(all, "g-after-remove"), "removed group must not deliver") + require.Len( + t, + groupMsgsWithData(all, "g-live-1"), + 1, + "pre-removal message should be delivered exactly once", + ) + + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestSubscribe_PingPongKeepsStreamAlive verifies the WebSocket-style heartbeat: the node +// Pings when idle and the stream stays open as long as the client Pongs. +func TestSubscribe_PingPongKeepsStreamAlive(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, _, cleanup := newTestService(t, ctx) + defer cleanup() + svc.pingInterval = 200 * time.Millisecond + svc.pongDeadline = 2 * time.Second + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + for round := 0; round < 3; round++ { + resps := waitForResponses( + t, + stream, + 3*time.Second, + fmt.Sprintf("server ping #%d", round+1), + func(rs []*mlsv1.SubscribeResponse) bool { + return len(pingsFrom(rs)) > round + }, + ) + nonces := pingsFrom(resps) + stream.send(subReqPong(nonces[round])) + } + + // The stream must still be open (the handler has not returned). + select { + case err := <-errCh: + t.Fatalf("stream closed unexpectedly: %v", err) + default: + } + + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestSubscribe_ReapsOnMissedPong verifies that a peer that never answers a Ping is reaped. +func TestSubscribe_ReapsOnMissedPong(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, _, cleanup := newTestService(t, ctx) + defer cleanup() + svc.pingInterval = 150 * time.Millisecond + svc.pongDeadline = 150 * time.Millisecond + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + // The node Pings when idle; we never Pong. + waitForResponses( + t, + stream, + 2*time.Second, + "server ping", + func(rs []*mlsv1.SubscribeResponse) bool { + return len(pingsFrom(rs)) >= 1 + }, + ) + + select { + case err := <-errCh: + require.Equal( + t, + codes.DeadlineExceeded, + status.Code(err), + "missed pong should reap with DeadlineExceeded", + ) + case <-time.After(3 * time.Second): + t.Fatal("stream was not reaped after a missed pong") + } +} + +// TestSubscribe_MultiplexesMultipleIdentities is the herald-multiplexer case: two +// installations' welcomes and a group are all subscribed on one stream, caught up and +// streamed live, each routed to the right topic with no cross-contamination or dupes. +func TestSubscribe_MultiplexesMultipleIdentities(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + instA := []byte(test.RandomString(32)) + instB := []byte(test.RandomString(32)) + hpke := []byte(test.RandomString(32)) + groupId := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupId) + + // History for both installations and the group. + populateWelcomeMessages(t, ctx, svc, instA, hpke, 4, "welA") + populateWelcomeMessages(t, ctx, svc, instB, hpke, 6, "welB") + populateGroupMessages(t, ctx, svc, groupId, 5, "grp") + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{ + addSub(groupTopic(groupId), 0), + addSub(welcomeTopic(instA), 0), + addSub(welcomeTopic(instB), 0), + }, + })) + + // Catch-up delivers each identity's history, correctly attributed. + waitForResponses( + t, + stream, + 10*time.Second, + "all history", + func(rs []*mlsv1.SubscribeResponse) bool { + w := welcomeMsgsFrom(rs) + return len(welcomesForKey(w, instA)) >= 4 && + len(welcomesForKey(w, instB)) >= 6 && + len(groupMsgsFrom(rs)) >= 5 + }, + ) + + // Live on both welcome topics and the group, all over the one stream. + populateWelcomeMessages(t, ctx, svc, instA, hpke, 2, "welA-live") + populateWelcomeMessages(t, ctx, svc, instB, hpke, 3, "welB-live") + publishGroup(t, ctx, svc, validationSvc, groupId, "grp-live") + + resps := waitForResponses( + t, + stream, + 10*time.Second, + "all live", + func(rs []*mlsv1.SubscribeResponse) bool { + w := welcomeMsgsFrom(rs) + return len(welcomesForKey(w, instA)) >= 6 && + len(welcomesForKey(w, instB)) >= 9 && + len(groupMsgsWithData(groupMsgsFrom(rs), "grp-live")) >= 1 + }, + ) + + w := welcomeMsgsFrom(resps) + welA := welcomesForKey(w, instA) + welB := welcomesForKey(w, instB) + require.Len(t, welA, 6, "installation A should receive its history + live, nothing else") + require.Len(t, welB, 9, "installation B should receive its history + live, nothing else") + validateWelcomeMessageOrdering(t, welA) + validateWelcomeMessageOrdering(t, welB) + validateWelcomeMessageNoDuplicates(t, welA) + validateWelcomeMessageNoDuplicates(t, welB) + + g := groupMsgsFrom(resps) + validateMessageOrdering(t, g) + validateNoDuplicates(t, g) + + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestSubscribe_TopicsLiveMarksLiveBoundary verifies the live-boundary signals: TopicsLive +// is emitted per opened topic AFTER that topic's history (so every later frame for the +// topic is live tail), and each Mutate that adds subscriptions is a catch-up wave that +// ends with its own CatchupComplete — echoing the Mutate's mutate_id — after the wave's +// last marker, including waves started mid-stream. +func TestSubscribe_TopicsLiveMarksLiveBoundary(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + instA := []byte(test.RandomString(32)) + hpke := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + + populateGroupMessages(t, ctx, svc, groupA, 5, "histA") + populateWelcomeMessages(t, ctx, svc, instA, hpke, 3, "welA") + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{ + addSub(groupTopic(groupA), 0), + addSub(welcomeTopic(instA), 0), + }, + MutateId: 7, + })) + + groupMarkerPred := func(r *mlsv1.SubscribeResponse) bool { + return topicsLiveHas(r, groupTopic(groupA)) + } + welcomeMarkerPred := func(r *mlsv1.SubscribeResponse) bool { + return topicsLiveHas(r, welcomeTopic(instA)) + } + resps := waitForResponses( + t, + stream, + 10*time.Second, + "TopicsLive for both topics + CatchupComplete", + func(rs []*mlsv1.SubscribeResponse) bool { + return frameIndex(rs, groupMarkerPred) >= 0 && + frameIndex(rs, welcomeMarkerPred) >= 0 && + len(catchupCompletesFrom(rs)) >= 1 + }, + ) + + groupMarker := frameIndex(resps, groupMarkerPred) + welcomeMarker := frameIndex(resps, welcomeMarkerPred) + catchUpComplete := frameIndex(resps, func(r *mlsv1.SubscribeResponse) bool { + return r.GetV1().GetCatchupComplete() != nil + }) + require.Equal( + t, + []uint64{7}, + catchupCompletesFrom(resps), + "the wave's CatchupComplete must echo its Mutate's id", + ) + + // Each topic's full history lands strictly before its marker, and both markers + // precede the wave's CatchupComplete. + lastGroupHist := lastFrameWithGroupMsgs(resps, groupA) + lastWelcomeHist := lastFrameWithWelcomes(resps, instA) + require.GreaterOrEqual(t, lastGroupHist, 0, "group history must be delivered") + require.GreaterOrEqual(t, lastWelcomeHist, 0, "welcome history must be delivered") + require.Greater(t, groupMarker, lastGroupHist, "group marker must follow group history") + require.Greater( + t, + welcomeMarker, + lastWelcomeHist, + "welcome marker must follow welcome history", + ) + require.Greater(t, catchUpComplete, groupMarker) + require.Greater(t, catchUpComplete, welcomeMarker) + + // Everything after the marker is live tail: a message published now lands after it. + publishGroup(t, ctx, svc, validationSvc, groupA, "liveA") + resps = waitForResponses( + t, + stream, + 5*time.Second, + "live message after marker", + func(rs []*mlsv1.SubscribeResponse) bool { + return len(groupMsgsWithData(groupMsgsFrom(rs), "liveA")) >= 1 + }, + ) + liveIdx := frameIndex(resps, func(r *mlsv1.SubscribeResponse) bool { + return len(groupMsgsWithData(r.GetV1().GetMessages().GetGroupMessages(), "liveA")) > 0 + }) + require.Greater(t, liveIdx, groupMarker, "live frames must follow the marker") + + // A topic added mid-stream is its own catch-up wave: it gets a marker after its + // history — the signal the client-side fan-out routes to whoever cares — and the + // wave ends with its own CatchupComplete (echoing its mutate_id) after that marker. + groupB := []byte(test.RandomString(32)) + for i := 0; i < 3; i++ { + publishGroup(t, ctx, svc, validationSvc, groupB, fmt.Sprintf("histB-%d", i)) + } + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupB), 0)}, + MutateId: 8, + })) + markerBPred := func(r *mlsv1.SubscribeResponse) bool { + return topicsLiveHas(r, groupTopic(groupB)) + } + resps = waitForResponses( + t, + stream, + 10*time.Second, + "TopicsLive + CatchupComplete for the mid-stream wave", + func(rs []*mlsv1.SubscribeResponse) bool { + return frameIndex(rs, markerBPred) >= 0 && len(catchupCompletesFrom(rs)) >= 2 + }, + ) + require.Len( + t, + groupMsgsWithData(groupMsgsFrom(resps), "histB-0"), + 1, + "mid-stream add must deliver its history", + ) + markerB := frameIndex(resps, markerBPred) + require.Greater( + t, + markerB, + lastFrameWithGroupMsgs(resps, groupB), + "mid-stream marker must follow that topic's history", + ) + require.Equal( + t, + []uint64{7, 8}, + catchupCompletesFrom(resps), + "each adding mutate is a wave whose CatchupComplete echoes its mutate_id", + ) + lastCatchUpComplete := -1 + for i, r := range resps { + if r.GetV1().GetCatchupComplete() != nil { + lastCatchUpComplete = i + } + } + require.Greater( + t, + lastCatchUpComplete, + markerB, + "the wave's CatchupComplete must follow its marker", + ) + + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestSubscribe_HistoryOnlyDeliversNoLive verifies that a history_only Mutate catches its +// topics up — history, marker, wave CatchupComplete — without ever registering them for +// live delivery. +func TestSubscribe_HistoryOnlyDeliversNoLive(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + sentinelId := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + populateGroupMessages(t, ctx, svc, groupA, 5, "histA") + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + // Wave 1: a live sentinel subscription. Wave 2: groupA, history only. + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(sentinelId), 0)}, + })) + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{ + addSub(groupTopic(groupA), 0), + }, + HistoryOnly: true, + })) + + markerAPred := func(r *mlsv1.SubscribeResponse) bool { + return topicsLiveHas(r, groupTopic(groupA)) + } + waitForResponses( + t, + stream, + 10*time.Second, + "history, marker, and both waves' CatchupComplete", + func(rs []*mlsv1.SubscribeResponse) bool { + return len(groupMsgsFrom(rs)) >= 5 && + frameIndex(rs, markerAPred) >= 0 && + len(catchupCompletesFrom(rs)) >= 2 + }, + ) + + // Publish live to the history-only topic (must NOT deliver), then to the live + // sentinel. The dbWorker dispatches strictly in id order, so once the sentinel + // arrives the history-only group's message has already been (not) routed. + publishGroup(t, ctx, svc, validationSvc, groupA, "a-live") + publishGroup(t, ctx, svc, validationSvc, sentinelId, "sentinel-live") + resps := waitForResponses( + t, + stream, + 5*time.Second, + "sentinel live message", + func(rs []*mlsv1.SubscribeResponse) bool { + return len(groupMsgsWithData(groupMsgsFrom(rs), "sentinel-live")) >= 1 + }, + ) + require.Empty( + t, + groupMsgsWithData(groupMsgsFrom(resps), "a-live"), + "history_only topics must not receive live delivery", + ) + + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestSubscribe_HistoryOnlyOnLiveTopicRejected verifies that a history_only add targeting a +// topic already live on the same stream is rejected with InvalidArgument. There is one cursor +// floor per topic, so a one-shot bounded read on a tailed topic is contradictory — and on the +// replay path (cursor below the floor) the old code would dropTopic without re-registering, +// silently severing a live subscription the client was still tailing. +func TestSubscribe_HistoryOnlyOnLiveTopicRejected(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + populateGroupMessages(t, ctx, svc, groupA, 5, "histA") + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + // Subscribe groupA live and wait until its catch-up completes — the cursor floor is now + // above 0, so the history_only re-add below lands on the replay path. + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupA), 0)}, + })) + waitForResponses( + t, + stream, + 10*time.Second, + "groupA live catch-up complete", + func(rs []*mlsv1.SubscribeResponse) bool { + return frameIndex(rs, func(r *mlsv1.SubscribeResponse) bool { + return topicsLiveHas(r, groupTopic(groupA)) + }) >= 0 && len(catchupCompletesFrom(rs)) >= 1 + }, + ) + + // A history_only add for the same, already-live topic (cursor 0, below the floor) is + // contradictory and must be rejected rather than silently severing the live tail. + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{ + addSub(groupTopic(groupA), 0), + }, + HistoryOnly: true, + })) + + select { + case err := <-errCh: + require.Equal( + t, + codes.InvalidArgument, + status.Code(err), + "history_only targeting an already-live topic must be rejected", + ) + case <-time.After(5 * time.Second): + t.Fatal("history_only add on a live topic was not rejected") + } +} + +// TestSubscribe_HalfCloseDrainsCatchUpThenCloses is the bounded catch-up ("catchUpOnce") +// shape end to end: Mutate{history_only} then immediately half-close; the server finishes +// the wave — history, marker, CatchupComplete — and then closes the stream itself. +func TestSubscribe_HalfCloseDrainsCatchUpThenCloses(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + populateGroupMessages(t, ctx, svc, groupA, 25, "histA") + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{ + addSub(groupTopic(groupA), 0), + }, + HistoryOnly: true, + MutateId: 99, + })) + stream.closeSend() + + select { + case err := <-errCh: + require.NoError(t, err, "the drained stream must close cleanly") + case <-time.After(10 * time.Second): + t.Fatal("server did not close the stream after draining the wave") + } + + resps := stream.responses() + require.Len(t, groupMsgsFrom(resps), 25, "the full history must be delivered before close") + markerA := frameIndex(resps, func(r *mlsv1.SubscribeResponse) bool { + return topicsLiveHas(r, groupTopic(groupA)) + }) + require.Greater( + t, + markerA, + lastFrameWithGroupMsgs(resps, groupA), + "marker must follow the history", + ) + require.Equal( + t, + []uint64{99}, + catchupCompletesFrom(resps), + "exactly the one wave's CatchupComplete, echoing its mutate_id", + ) +} + +// TestSubscribe_StalePongDoesNotKeepStreamAlive verifies a Pong must echo the outstanding +// ping nonce: a stale or unsolicited Pong cannot suppress the missed-pong reap. +func TestSubscribe_StalePongDoesNotKeepStreamAlive(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, _, cleanup := newTestService(t, ctx) + defer cleanup() + svc.pingInterval = 150 * time.Millisecond + svc.pongDeadline = 300 * time.Millisecond + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + // Continuously answer EVERY server ping with the WRONG nonce. With the nonce check the + // outstanding ping is never satisfied, so the stream is reaped on schedule; without it, + // each wrong Pong would clear awaitingPong and the stream would live forever. + done := make(chan struct{}) + defer close(done) + go func() { + sent := 0 + for { + select { + case <-done: + return + default: + } + pings := pingsFrom(stream.responses()) + for ; sent < len(pings); sent++ { + stream.send(subReqPong(pings[sent] + 100000)) // never the real nonce + } + time.Sleep(10 * time.Millisecond) + } + }() + + select { + case err := <-errCh: + require.Equal( + t, + codes.DeadlineExceeded, + status.Code(err), + "a client that only ever answers with the wrong nonce must still be reaped", + ) + case <-time.After(3 * time.Second): + t.Fatal("stream not reaped despite only wrong-nonce Pongs") + } +} + +// TestSubscribe_RecvErrorFailsStream verifies a transport Recv error fails the RPC rather +// than being mistaken for a clean half-close (which would report success). +func TestSubscribe_RecvErrorFailsStream(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, _, cleanup := newTestService(t, ctx) + defer cleanup() + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + waitForResponses( + t, + stream, + 5*time.Second, + "Started", + func(rs []*mlsv1.SubscribeResponse) bool { return hasStarted(rs) }, + ) + stream.failRecv(status.Error(codes.Internal, "connection reset")) + + select { + case err := <-errCh: + require.Equal( + t, + codes.Unavailable, + status.Code(err), + "a transport Recv error must fail the stream, not return nil", + ) + case <-time.After(5 * time.Second): + t.Fatal("stream did not fail after a transport Recv error") + } +} + +// TestSubscribe_DuplicateAddsDeduped verifies a topic repeated within one Mutate's adds is +// collapsed: it is announced live (and counted) exactly once, not once per occurrence. +func TestSubscribe_DuplicateAddsDeduped(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + populateGroupMessages(t, ctx, svc, groupA, 5, "histA") + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{ + addSub(groupTopic(groupA), 0), + addSub(groupTopic(groupA), 0), // same topic, twice + }, + MutateId: 5, + })) + resps := waitForResponses( + t, + stream, + 10*time.Second, + "history + CatchupComplete", + func(rs []*mlsv1.SubscribeResponse) bool { + return len(groupMsgsFrom(rs)) >= 5 && len(catchupCompletesFrom(rs)) >= 1 + }, + ) + + liveTopicCount := 0 + for _, r := range resps { + for _, tp := range r.GetV1().GetTopicsLive().GetTopics() { + if string(tp) == string(groupTopic(groupA)) { + liveTopicCount++ + } + } + } + require.Equal( + t, + 1, + liveTopicCount, + "a duplicated add must announce the topic live exactly once", + ) + require.Len(t, groupMsgsFrom(resps), 5, "a duplicated add must not duplicate history") + require.Equal(t, []uint64{5}, catchupCompletesFrom(resps), "one wave, one CatchupComplete") + + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestSubscribe_ReplayAfterRemove verifies removing a topic clears its cursor floor, so a +// later re-add replays the history again (XIP-83 replay-after-remove). +func TestSubscribe_ReplayAfterRemove(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + populateGroupMessages(t, ctx, svc, groupA, 5, "histA") + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupA), 0)}, + MutateId: 1, + })) + waitForResponses( + t, + stream, + 10*time.Second, + "first catch-up", + func(rs []*mlsv1.SubscribeResponse) bool { + return len(groupMsgsFrom(rs)) >= 5 && len(catchupCompletesFrom(rs)) >= 1 + }, + ) + + // Remove, then re-add from cursor 0: the cleared floor lets the history replay. + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Removes: [][]byte{groupTopic(groupA)}, + })) + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupA), 0)}, + MutateId: 2, + })) + resps := waitForResponses( + t, + stream, + 10*time.Second, + "replayed catch-up (wave 2)", + func(rs []*mlsv1.SubscribeResponse) bool { return len(catchupCompletesFrom(rs)) >= 2 }, + ) + require.Len(t, groupMsgsFrom(resps), 10, "remove + re-add must replay the full history") + require.Equal(t, []uint64{1, 2}, catchupCompletesFrom(resps)) + + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestSubscribe_CursorAboveInt64ReturnsNoHistory verifies a cursor above MaxInt64 clamps to +// "no rows" rather than wrapping negative and replaying the whole history. +func TestSubscribe_CursorAboveInt64ReturnsNoHistory(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + populateGroupMessages(t, ctx, svc, groupA, 5, "histA") + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{ + addSub(groupTopic(groupA), math.MaxUint64), + }, + MutateId: 1, + })) + resps := waitForResponses( + t, + stream, + 10*time.Second, + "CatchupComplete", + func(rs []*mlsv1.SubscribeResponse) bool { return len(catchupCompletesFrom(rs)) >= 1 }, + ) + require.Empty( + t, + groupMsgsFrom(resps), + "a cursor above MaxInt64 must return no rows, not replay history", + ) + + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestSubscribe_InboundFramesResetIdleTimer verifies that inbound client frames which produce +// no response (here, no-op removes) still reset the idle timer, so the node does not ping or +// reap a stream the client is actively using. +func TestSubscribe_InboundFramesDoNotSuppressLivenessPing(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, _, cleanup := newTestService(t, ctx) + defer cleanup() + svc.pingInterval = 200 * time.Millisecond + svc.pongDeadline = 2 * time.Second + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + waitForResponses(t, stream, 5*time.Second, "Started", + func(rs []*mlsv1.SubscribeResponse) bool { return hasStarted(rs) }) + + // The liveness Ping probes the client's RECEIVE path and is driven by SEND-side idleness + // only. A client streaming response-free frames (no-op removes) must NOT suppress it — + // otherwise a peer that sends but never reads could never be reaped. + done := make(chan struct{}) + defer close(done) + go func() { + dummy := groupTopic([]byte(test.RandomString(32))) + for { + select { + case <-done: + return + default: + } + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{Removes: [][]byte{dummy}})) + time.Sleep(40 * time.Millisecond) + } + }() + + waitForResponses(t, stream, 3*time.Second, "server ping despite steady inbound traffic", + func(rs []*mlsv1.SubscribeResponse) bool { return len(pingsFrom(rs)) >= 1 }) +} + +// TestSubscribe_GroupAndWelcomeSameIdentifierNoCollision verifies a group and a welcome that +// share an identifier are tracked independently: neither suppresses the other when one +// advances its high-water mark (the per-topic key is kind-distinct). +func TestSubscribe_GroupAndWelcomeSameIdentifierNoCollision(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + id := []byte(test.RandomString(32)) // used as BOTH a group id and an installation key + hpke := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, id) + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{ + addSub(groupTopic(id), 0), + addSub(welcomeTopic(id), 0), + }, + })) + waitForResponses( + t, + stream, + 5*time.Second, + "CatchupComplete", + func(rs []*mlsv1.SubscribeResponse) bool { return len(catchupCompletesFrom(rs)) >= 1 }, + ) + + // Advance the welcome topic's high-water mark first; then publish a group message whose + // (independent) id may be lower. With a shared key the group message would be deduped + // away; with kind-distinct keys it is delivered. + populateWelcomeMessages(t, ctx, svc, id, hpke, 3, "wel") + publishGroup(t, ctx, svc, validationSvc, id, "grp-1") + + resps := waitForResponses( + t, + stream, + 5*time.Second, + "the welcomes and the group message", + func(rs []*mlsv1.SubscribeResponse) bool { + return len(welcomesForKey(welcomeMsgsFrom(rs), id)) >= 3 && + len(groupMsgsWithData(groupMsgsFrom(rs), "grp-1")) >= 1 + }, + ) + require.Len( + t, + groupMsgsWithData(groupMsgsFrom(resps), "grp-1"), + 1, + "the group message must not be suppressed by the same-identifier welcome topic", + ) + require.GreaterOrEqual(t, len(welcomesForKey(welcomeMsgsFrom(resps), id)), 3) + + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestQueryGroupMessagesBatch_CursorAboveInt64ReturnsNothing exercises clampCursor directly: +// the Subscribe-level test is masked by the writer's high-water mark, so guard the store here. +func TestQueryGroupMessagesBatch_CursorAboveInt64ReturnsNothing(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + populateGroupMessages(t, ctx, svc, groupA, 5, "hist") + + msgs, err := svc.readOnlyStore.QueryGroupMessagesBatch( + ctx, + []mlsstore.GroupCatchup{{GroupID: groupA, IdCursor: math.MaxUint64}}, + 50, + ) + require.NoError(t, err) + require.Empty(t, msgs, "a cursor above MaxInt64 must clamp to no rows, not wrap negative") +} + +// TestSubscribe_MultiPageCatchUp exercises catch-up pagination across more than +// catchUpPerGroupLimit messages: the active-loop continuation plus open-on-final-page wave +// accounting, with exactly one TopicsLive and one CatchupComplete. +func TestSubscribe_MultiPageCatchUp(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + const history = catchUpPerGroupLimit*2 + 17 + populateGroupMessages(t, ctx, svc, groupA, history, "hist") + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupA), 0)}, + MutateId: 1, + })) + resps := waitForResponses(t, stream, 15*time.Second, + fmt.Sprintf("%d history messages + CatchupComplete", history), + func(rs []*mlsv1.SubscribeResponse) bool { + return len(groupMsgsFrom(rs)) >= history && len(catchupCompletesFrom(rs)) >= 1 + }) + + msgs := groupMsgsFrom(resps) + require.Len(t, msgs, history, "every page of history delivered exactly once") + validateMessageOrdering(t, msgs) + validateNoDuplicates(t, msgs) + require.Equal(t, []uint64{1}, catchupCompletesFrom(resps)) + markerCount := 0 + for _, r := range resps { + if topicsLiveHas(r, groupTopic(groupA)) { + markerCount++ + } + } + require.Equal(t, 1, markerCount, "a multi-page topic opens (and is announced) exactly once") + + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestSubscribe_NonZeroStartingCursor verifies the id>cursor / seed-floor boundary: a +// subscription from a non-zero in-range cursor delivers only strictly-greater ids. +func TestSubscribe_NonZeroStartingCursor(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + populateGroupMessages(t, ctx, svc, groupA, 10, "hist") + + // Learn the real ids by catching up from 0. + s1 := newFakeSubscribeStream(ctx) + e1 := make(chan error, 1) + go func() { e1 <- svc.Subscribe(s1) }() + s1.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupA), 0)}, + })) + r1 := waitForResponses(t, s1, 10*time.Second, "all 10", + func(rs []*mlsv1.SubscribeResponse) bool { return len(groupMsgsFrom(rs)) >= 10 }) + var ids []uint64 + for _, m := range groupMsgsFrom(r1) { + ids = append(ids, m.GetV1().GetId()) + } + require.Len(t, ids, 10) + s1.closeSend() + require.NoError(t, <-e1) + + // Subscribe from the 5th id: only the 5 strictly-greater ids must arrive (no off-by-one). + cursor := ids[4] + s2 := newFakeSubscribeStream(ctx) + e2 := make(chan error, 1) + go func() { e2 <- svc.Subscribe(s2) }() + s2.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupA), cursor)}, + })) + r2 := waitForResponses(t, s2, 10*time.Second, "messages after the cursor", + func(rs []*mlsv1.SubscribeResponse) bool { return len(catchupCompletesFrom(rs)) >= 1 }) + got := groupMsgsFrom(r2) + require.Len(t, got, 5, "exactly the 5 messages with id > cursor") + for _, m := range got { + require.Greater(t, m.GetV1().GetId(), cursor, "no message at or below the cursor") + } + s2.closeSend() + require.NoError(t, <-e2) +} + +// fakeReadStore wraps a ReadMlsStore to inject catch-up faults: groupErr makes +// QueryGroupMessagesBatch fail, and groupGate (if non-nil) blocks it until released, letting +// a test hold a topic mid-catch-up. All other methods delegate to the embedded store. +type fakeReadStore struct { + mlsstore.ReadMlsStore + groupErr error + groupGate chan struct{} +} + +func (f *fakeReadStore) QueryGroupMessagesBatch( + ctx context.Context, + filters []mlsstore.GroupCatchup, + perGroupLimit int32, +) ([]*mlsv1.GroupMessage, error) { + if f.groupGate != nil { + select { + case <-f.groupGate: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if f.groupErr != nil { + return nil, f.groupErr + } + return f.ReadMlsStore.QueryGroupMessagesBatch(ctx, filters, perGroupLimit) +} + +// TestSubscribe_NonReadingClientIsReaped verifies a connected-but-non-reading client (a +// stalled stream.Send) is still reaped: the sender goroutine absorbs the block so the writer +// stays free to run the ping/pong reap. +func TestSubscribe_NonReadingClientIsReaped(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, _, cleanup := newTestService(t, ctx) + defer cleanup() + svc.pingInterval = 100 * time.Millisecond + svc.pongDeadline = 200 * time.Millisecond + + stream := newFakeSubscribeStream(ctx) + stream.blockSend = make(chan struct{}) // client never reads: every Send blocks + defer close(stream.blockSend) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + select { + case err := <-errCh: + require.Equal( + t, + codes.DeadlineExceeded, + status.Code(err), + "a non-reading client must be reaped; the writer must not be parked on Send", + ) + case <-time.After(5 * time.Second): + t.Fatal("non-reading client was not reaped (writer parked on stream.Send?)") + } +} + +// TestSubscribe_HalfCloseFlushTimeoutFailsNotOK verifies a bounded catch-up (history_only + +// half-close) whose queued frames cannot be delivered — the client stopped reading, so the +// sender goroutine is wedged in stream.Send — fails with DeadlineExceeded rather than returning +// OK with a silently truncated catch-up. flush() must report whether the drain actually +// finished, not just that it waited. +func TestSubscribe_HalfCloseFlushTimeoutFailsNotOK(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + svc.pongDeadline = 300 * time.Millisecond // the bound flush waits for the sender to drain + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + // Small history: every queued frame fits the send buffer, so the writer reaches the + // half-close flush() rather than stalling earlier in send(). + populateGroupMessages(t, ctx, svc, groupA, 3, "hist") + + stream := newFakeSubscribeStream(ctx) + stream.blockSend = make( + chan struct{}, + ) // client never reads: the sender wedges on the first Send + defer close(stream.blockSend) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + // Bounded catch-up: catch groupA up, then half-close so the server finishes the wave and + // closes the stream itself. The frames queue but are never delivered (sender is blocked). + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{ + addSub(groupTopic(groupA), 0), + }, + HistoryOnly: true, + })) + stream.closeSend() + + select { + case err := <-errCh: + require.Equal( + t, + codes.DeadlineExceeded, + status.Code(err), + "a half-close drain that cannot deliver its queued frames must fail, not return OK", + ) + case <-time.After(5 * time.Second): + t.Fatal("half-close flush did not return after the drain timed out") + } +} + +// TestSubscribe_CatchUpFetchErrorTearsDownUnavailable verifies a catch-up fetch error tears +// the stream down with Unavailable (so the client reconnects) rather than hanging. +func TestSubscribe_CatchUpFetchErrorTearsDownUnavailable(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + populateGroupMessages(t, ctx, svc, groupA, 5, "hist") + svc.readOnlyStore = &fakeReadStore{ + ReadMlsStore: svc.readOnlyStore, + groupErr: errors.New("db boom"), + } + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupA), 0)}, + })) + + select { + case err := <-errCh: + require.Equal( + t, + codes.Unavailable, + status.Code(err), + "a catch-up fetch error must tear down Unavailable", + ) + case <-time.After(10 * time.Second): + t.Fatal("stream did not tear down on a catch-up fetch error") + } +} + +// TestSubscribe_RemoveMidCatchUpFreesWaveSlot verifies removing a topic whose catch-up is +// still in flight completes its wave immediately (from the remove) without waiting on the +// blocked fetch, and the orphaned page is dropped. +func TestSubscribe_RemoveMidCatchUpFreesWaveSlot(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + populateGroupMessages(t, ctx, svc, groupA, 5, "hist") + gate := make(chan struct{}) + svc.readOnlyStore = &fakeReadStore{ReadMlsStore: svc.readOnlyStore, groupGate: gate} + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + + // Subscribe (catch-up will block on the gate), then remove before it returns. + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupA), 0)}, + MutateId: 7, + })) + stream.send( + subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{Removes: [][]byte{groupTopic(groupA)}}), + ) + + resps := waitForResponses(t, stream, 5*time.Second, "CatchupComplete from the remove", + func(rs []*mlsv1.SubscribeResponse) bool { return len(catchupCompletesFrom(rs)) >= 1 }) + require.Equal(t, []uint64{7}, catchupCompletesFrom(resps), + "the removed topic's wave completes from the remove, echoing its mutate_id") + require.Empty( + t, + groupMsgsFrom(resps), + "catch-up was still blocked, so no history was delivered", + ) + + close(gate) // release the orphaned fetch; its stale page must be dropped + stream.closeSend() + require.NoError(t, <-errCh) +} + +// TestSubscribe_PendingBufferCapAborts verifies the maxPendingBytes guard: live messages +// buffered while a topic is stuck catching up cannot grow without bound. +func TestSubscribe_PendingBufferCapAborts(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + svc.maxPendingBytes = 50 // tiny: a couple of buffered live messages exceed it + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + gate := make(chan struct{}) + defer close(gate) + svc.readOnlyStore = &fakeReadStore{ReadMlsStore: svc.readOnlyStore, groupGate: gate} + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupA), 0)}, + })) + + // Catch-up is gated, so the topic stays in catch-up; live messages pile into pending. + for i := 0; i < 20; i++ { + publishGroup(t, ctx, svc, validationSvc, groupA, fmt.Sprintf("live-message-%d", i)) + } + + select { + case err := <-errCh: + require.Equal(t, codes.ResourceExhausted, status.Code(err), + "exceeding maxPendingBytes must abort the stream") + case <-time.After(10 * time.Second): + t.Fatal("pending-buffer cap was not enforced") + } +} + +// TestSubscribe_FramesSplitByMaxFrameBytes verifies the per-frame byte cap splits a batch of +// messages across multiple frames. +func TestSubscribe_FramesSplitByMaxFrameBytes(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc, _, validationSvc, cleanup := newTestService(t, ctx) + defer cleanup() + svc.maxFrameBytes = 1 // every non-empty message lands in its own frame + + groupA := []byte(test.RandomString(32)) + mockValidateGroupMessages(validationSvc, groupA) + const n = 5 + populateGroupMessages(t, ctx, svc, groupA, n, "hist") + + stream := newFakeSubscribeStream(ctx) + errCh := make(chan error, 1) + go func() { errCh <- svc.Subscribe(stream) }() + stream.send(subReqMutate(&mlsv1.SubscribeRequest_V1_Mutate{ + Adds: []*mlsv1.SubscribeRequest_V1_Mutate_Subscription{addSub(groupTopic(groupA), 0)}, + })) + resps := waitForResponses(t, stream, 10*time.Second, "all history", + func(rs []*mlsv1.SubscribeResponse) bool { return len(groupMsgsFrom(rs)) >= n }) + + frames := 0 + for _, r := range resps { + if msgs := r.GetV1().GetMessages(); msgs != nil && len(msgs.GetGroupMessages()) > 0 { + frames++ + } + } + require.Equal(t, n, frames, "at maxFrameBytes=1 each message must be sent in its own frame") + require.Len(t, groupMsgsFrom(resps), n) + + stream.closeSend() + require.NoError(t, <-errCh) +} diff --git a/pkg/mls/store/readStore.go b/pkg/mls/store/readStore.go index fdb5bb2c..d6a6ec07 100644 --- a/pkg/mls/store/readStore.go +++ b/pkg/mls/store/readStore.go @@ -2,8 +2,11 @@ package store import ( "context" + "database/sql" "errors" + "time" + "github.com/lib/pq" "github.com/uptrace/bun" queries "github.com/xmtp/xmtp-node-go/pkg/mls/store/queries" identity "github.com/xmtp/xmtp-node-go/pkg/proto/identity/api/v1" @@ -17,12 +20,14 @@ import ( type ReadStore struct { log *zap.Logger + db *sql.DB queries *queries.Queries } func NewReadStore(log *zap.Logger, db *bun.DB) *ReadStore { return &ReadStore{ log: log.Named("read-mlsstore"), + db: db.DB, queries: queries.New(db.DB), } } @@ -331,6 +336,202 @@ func (s *ReadStore) QueryWelcomeMessagesV1( }, nil } +// GroupCatchup names a group and the cursor to resume it from for a batched catch-up. +type GroupCatchup struct { + GroupID []byte + IdCursor uint64 +} + +// WelcomeCatchup names a welcome topic (by installation) and its resume cursor. +type WelcomeCatchup struct { + InstallationKey []byte + IdCursor uint64 +} + +// clampCursor narrows a uint64 client cursor to the int64 the SQL columns use, +// saturating at MaxInt64. A cursor above 2^63-1 can't match any real row id, so +// it must return no rows; without the clamp the int64 conversion would wrap to a +// negative value and the `id > cursor` predicate would replay the entire history. +func clampCursor(c uint64) int64 { + const maxInt64 = 1<<63 - 1 + if c > maxInt64 { + return maxInt64 + } + return int64(c) +} + +const queryGroupMessagesBatch = ` +SELECT m.id, m.created_at, m.group_id, m.data, m.is_commit, m.sender_hmac, m.should_push +FROM unnest($1::BYTEA[], $2::BIGINT[]) AS f (group_id, id_cursor) +CROSS JOIN LATERAL ( + SELECT g.id, g.created_at, g.group_id, g.data, g.is_commit, g.sender_hmac, g.should_push + FROM group_messages g + WHERE g.group_id = f.group_id AND g.id > f.id_cursor + ORDER BY g.id ASC + LIMIT $3 +) m +ORDER BY m.group_id ASC, m.id ASC` + +// QueryGroupMessagesBatch fetches catch-up messages for many groups in one round-trip +// (XIP-83). Each group resumes from its own cursor and is capped at perGroupLimit rows; +// a group returning fewer than perGroupLimit rows is fully caught up. Ordered by +// (group_id, id). The composite index (group_id, id) makes each per-group seek cheap. +// +// Filter keys MUST be unique: unnest preserves duplicates and CROSS JOIN LATERAL runs the +// subquery once per occurrence, so a repeated key returns its rows more than once and +// breaks the caller's per-key pagination count. The Subscribe handler deduplicates adds +// before calling this. +func (s *ReadStore) QueryGroupMessagesBatch( + ctx context.Context, + filters []GroupCatchup, + perGroupLimit int32, +) ([]*mlsv1.GroupMessage, error) { + if len(filters) == 0 { + return nil, nil + } + groupIds := make([][]byte, len(filters)) + cursors := make([]int64, len(filters)) + for i, f := range filters { + groupIds[i] = f.GroupID + cursors[i] = clampCursor(f.IdCursor) + } + rows, err := s.db.QueryContext( + ctx, + queryGroupMessagesBatch, + pq.Array(groupIds), + pq.Array(cursors), + perGroupLimit, + ) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var out []*mlsv1.GroupMessage + for rows.Next() { + var ( + id int64 + createdAt time.Time + groupID []byte + data []byte + isCommit sql.NullBool + senderHmac []byte + shouldPush sql.NullBool + ) + if err := rows.Scan(&id, &createdAt, &groupID, &data, &isCommit, &senderHmac, &shouldPush); err != nil { + return nil, err + } + out = append(out, &mlsv1.GroupMessage{ + Version: &mlsv1.GroupMessage_V1_{ + V1: &mlsv1.GroupMessage_V1{ + Id: uint64(id), + CreatedNs: uint64(createdAt.UnixNano()), + GroupId: groupID, + Data: data, + ShouldPush: shouldPush.Bool, + SenderHmac: senderHmac, + IsCommit: isCommit.Bool, + }, + }, + }) + } + return out, rows.Err() +} + +const queryWelcomeMessagesBatch = ` +SELECT m.id, m.created_at, m.installation_key, m.data, m.hpke_public_key, m.wrapper_algorithm, m.welcome_metadata, m.message_type +FROM unnest($1::BYTEA[], $2::BIGINT[]) AS f (installation_key, id_cursor) +CROSS JOIN LATERAL ( + SELECT w.id, w.created_at, w.installation_key, w.data, w.hpke_public_key, w.wrapper_algorithm, w.welcome_metadata, w.message_type + FROM welcome_messages w + WHERE w.installation_key = f.installation_key AND w.id > f.id_cursor + ORDER BY w.id ASC + LIMIT $3 +) m +ORDER BY m.installation_key ASC, m.id ASC` + +// QueryWelcomeMessagesBatch is the welcome-topic analogue of QueryGroupMessagesBatch. +func (s *ReadStore) QueryWelcomeMessagesBatch( + ctx context.Context, + filters []WelcomeCatchup, + perGroupLimit int32, +) ([]*mlsv1.WelcomeMessage, error) { + if len(filters) == 0 { + return nil, nil + } + keys := make([][]byte, len(filters)) + cursors := make([]int64, len(filters)) + for i, f := range filters { + keys[i] = f.InstallationKey + cursors[i] = clampCursor(f.IdCursor) + } + rows, err := s.db.QueryContext( + ctx, + queryWelcomeMessagesBatch, + pq.Array(keys), + pq.Array(cursors), + perGroupLimit, + ) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var out []*mlsv1.WelcomeMessage + for rows.Next() { + var ( + id int64 + createdAt time.Time + installationKey []byte + data []byte + hpkePublicKey []byte + wrapperAlgorithm int16 + welcomeMetadata []byte + messageType int16 + ) + if err := rows.Scan(&id, &createdAt, &installationKey, &data, &hpkePublicKey, &wrapperAlgorithm, &welcomeMetadata, &messageType); err != nil { + return nil, err + } + switch messageType { + case 0: + out = append(out, &mlsv1.WelcomeMessage{ + Version: &mlsv1.WelcomeMessage_V1_{ + V1: &mlsv1.WelcomeMessage_V1{ + Id: uint64(id), + CreatedNs: uint64(createdAt.UnixNano()), + Data: data, + InstallationKey: installationKey, + HpkePublicKey: hpkePublicKey, + WrapperAlgorithm: types.WrapperAlgorithmToProto( + types.WrapperAlgorithm(wrapperAlgorithm), + ), + WelcomeMetadata: welcomeMetadata, + }, + }, + }) + case 1: + out = append(out, &mlsv1.WelcomeMessage{ + Version: &mlsv1.WelcomeMessage_WelcomePointer_{ + WelcomePointer: &mlsv1.WelcomeMessage_WelcomePointer{ + Id: uint64(id), + CreatedNs: uint64(createdAt.UnixNano()), + InstallationKey: installationKey, + WelcomePointer: data, + HpkePublicKey: hpkePublicKey, + WrapperAlgorithm: types.WrapperAlgorithmToWelcomePointerWrapperAlgorithm( + types.WrapperAlgorithm(wrapperAlgorithm), + ), + }, + }, + }) + default: + // Parity with the live dispatch path (worker.go): surface, don't swallow. + s.log.Error("unknown welcome message type", zap.Int16("message_type", messageType)) + } + } + return out, rows.Err() +} + func (s *ReadStore) QueryCommitLog( ctx context.Context, req *mlsv1.QueryCommitLogRequest, diff --git a/pkg/mls/store/store.go b/pkg/mls/store/store.go index 3ab08a64..d4fc61c2 100644 --- a/pkg/mls/store/store.go +++ b/pkg/mls/store/store.go @@ -56,6 +56,16 @@ type ReadMlsStore interface { ctx context.Context, query *mlsv1.QueryWelcomeMessagesRequest, ) (*mlsv1.QueryWelcomeMessagesResponse, error) + QueryGroupMessagesBatch( + ctx context.Context, + filters []GroupCatchup, + perGroupLimit int32, + ) ([]*mlsv1.GroupMessage, error) + QueryWelcomeMessagesBatch( + ctx context.Context, + filters []WelcomeCatchup, + perGroupLimit int32, + ) ([]*mlsv1.WelcomeMessage, error) QueryCommitLog( ctx context.Context, query *mlsv1.QueryCommitLogRequest, @@ -410,6 +420,22 @@ func (s *Store) QueryWelcomeMessagesV1( return s.readstore.QueryWelcomeMessagesV1(ctx, req) } +func (s *Store) QueryGroupMessagesBatch( + ctx context.Context, + filters []GroupCatchup, + perGroupLimit int32, +) ([]*mlsv1.GroupMessage, error) { + return s.readstore.QueryGroupMessagesBatch(ctx, filters, perGroupLimit) +} + +func (s *Store) QueryWelcomeMessagesBatch( + ctx context.Context, + filters []WelcomeCatchup, + perGroupLimit int32, +) ([]*mlsv1.WelcomeMessage, error) { + return s.readstore.QueryWelcomeMessagesBatch(ctx, filters, perGroupLimit) +} + func (s *Store) GetNewestGroupMessageMetadata( ctx context.Context, groupIds [][]byte, diff --git a/pkg/proto/mls/api/v1/mls.pb.go b/pkg/proto/mls/api/v1/mls.pb.go index 9525aba2..f538c576 100644 --- a/pkg/proto/mls/api/v1/mls.pb.go +++ b/pkg/proto/mls/api/v1/mls.pb.go @@ -79,6 +79,52 @@ func (SortDirection) EnumDescriptor() ([]byte, []int) { return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{0} } +// Optional per-stream protocol features (none defined yet; future +// revisions add values, e.g. fetch-over-stream lookups answered with the +// same read view that feeds the stream, or new streamable topic kinds). +type SubscribeResponse_V1_Capability int32 + +const ( + SubscribeResponse_V1_CAPABILITY_UNSPECIFIED SubscribeResponse_V1_Capability = 0 +) + +// Enum value maps for SubscribeResponse_V1_Capability. +var ( + SubscribeResponse_V1_Capability_name = map[int32]string{ + 0: "CAPABILITY_UNSPECIFIED", + } + SubscribeResponse_V1_Capability_value = map[string]int32{ + "CAPABILITY_UNSPECIFIED": 0, + } +) + +func (x SubscribeResponse_V1_Capability) Enum() *SubscribeResponse_V1_Capability { + p := new(SubscribeResponse_V1_Capability) + *p = x + return p +} + +func (x SubscribeResponse_V1_Capability) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SubscribeResponse_V1_Capability) Descriptor() protoreflect.EnumDescriptor { + return file_mls_api_v1_mls_proto_enumTypes[1].Descriptor() +} + +func (SubscribeResponse_V1_Capability) Type() protoreflect.EnumType { + return &file_mls_api_v1_mls_proto_enumTypes[1] +} + +func (x SubscribeResponse_V1_Capability) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SubscribeResponse_V1_Capability.Descriptor instead. +func (SubscribeResponse_V1_Capability) EnumDescriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{24, 0, 0} +} + // Full representation of a welcome message type WelcomeMessage struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1328,6 +1374,233 @@ func (x *SubscribeWelcomeMessagesRequest) GetFilters() []*SubscribeWelcomeMessag return nil } +// Client -> server. Sent one or more times over the life of the stream. +type SubscribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Version: + // + // *SubscribeRequest_V1_ + Version isSubscribeRequest_Version `protobuf_oneof:"version"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeRequest) Reset() { + *x = SubscribeRequest{} + mi := &file_mls_api_v1_mls_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeRequest) ProtoMessage() {} + +func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead. +func (*SubscribeRequest) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{23} +} + +func (x *SubscribeRequest) GetVersion() isSubscribeRequest_Version { + if x != nil { + return x.Version + } + return nil +} + +func (x *SubscribeRequest) GetV1() *SubscribeRequest_V1 { + if x != nil { + if x, ok := x.Version.(*SubscribeRequest_V1_); ok { + return x.V1 + } + } + return nil +} + +type isSubscribeRequest_Version interface { + isSubscribeRequest_Version() +} + +type SubscribeRequest_V1_ struct { + V1 *SubscribeRequest_V1 `protobuf:"bytes,1,opt,name=v1,proto3,oneof"` +} + +func (*SubscribeRequest_V1_) isSubscribeRequest_Version() {} + +// Server -> client. +type SubscribeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Version: + // + // *SubscribeResponse_V1_ + Version isSubscribeResponse_Version `protobuf_oneof:"version"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeResponse) Reset() { + *x = SubscribeResponse{} + mi := &file_mls_api_v1_mls_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeResponse) ProtoMessage() {} + +func (x *SubscribeResponse) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeResponse.ProtoReflect.Descriptor instead. +func (*SubscribeResponse) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{24} +} + +func (x *SubscribeResponse) GetVersion() isSubscribeResponse_Version { + if x != nil { + return x.Version + } + return nil +} + +func (x *SubscribeResponse) GetV1() *SubscribeResponse_V1 { + if x != nil { + if x, ok := x.Version.(*SubscribeResponse_V1_); ok { + return x.V1 + } + } + return nil +} + +type isSubscribeResponse_Version interface { + isSubscribeResponse_Version() +} + +type SubscribeResponse_V1_ struct { + V1 *SubscribeResponse_V1 `protobuf:"bytes,1,opt,name=v1,proto3,oneof"` +} + +func (*SubscribeResponse_V1_) isSubscribeResponse_Version() {} + +// Liveness challenge/response, shared across versions. Either peer MAY send a +// Ping; the receiver MUST reply with a Pong echoing the nonce. The sender closes +// the stream if no Pong arrives within its deadline — how a node reaps a vanished +// peer (e.g. a mobile client the OS suspended behind a proxy that still ACKs the +// transport). +type Ping struct { + state protoimpl.MessageState `protogen:"open.v1"` + Nonce uint64 `protobuf:"varint,1,opt,name=nonce,proto3" json:"nonce,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Ping) Reset() { + *x = Ping{} + mi := &file_mls_api_v1_mls_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Ping) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Ping) ProtoMessage() {} + +func (x *Ping) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Ping.ProtoReflect.Descriptor instead. +func (*Ping) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{25} +} + +func (x *Ping) GetNonce() uint64 { + if x != nil { + return x.Nonce + } + return 0 +} + +type Pong struct { + state protoimpl.MessageState `protogen:"open.v1"` + Nonce uint64 `protobuf:"varint,1,opt,name=nonce,proto3" json:"nonce,omitempty"` // echoes the nonce of the Ping it answers + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Pong) Reset() { + *x = Pong{} + mi := &file_mls_api_v1_mls_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Pong) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Pong) ProtoMessage() {} + +func (x *Pong) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Pong.ProtoReflect.Descriptor instead. +func (*Pong) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{26} +} + +func (x *Pong) GetNonce() uint64 { + if x != nil { + return x.Nonce + } + return 0 +} + type BatchPublishCommitLogRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Requests []*PublishCommitLogRequest `protobuf:"bytes,1,rep,name=requests,proto3" json:"requests,omitempty"` @@ -1337,7 +1610,7 @@ type BatchPublishCommitLogRequest struct { func (x *BatchPublishCommitLogRequest) Reset() { *x = BatchPublishCommitLogRequest{} - mi := &file_mls_api_v1_mls_proto_msgTypes[23] + mi := &file_mls_api_v1_mls_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1349,7 +1622,7 @@ func (x *BatchPublishCommitLogRequest) String() string { func (*BatchPublishCommitLogRequest) ProtoMessage() {} func (x *BatchPublishCommitLogRequest) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[23] + mi := &file_mls_api_v1_mls_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1362,7 +1635,7 @@ func (x *BatchPublishCommitLogRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchPublishCommitLogRequest.ProtoReflect.Descriptor instead. func (*BatchPublishCommitLogRequest) Descriptor() ([]byte, []int) { - return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{23} + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{27} } func (x *BatchPublishCommitLogRequest) GetRequests() []*PublishCommitLogRequest { @@ -1383,7 +1656,7 @@ type PublishCommitLogRequest struct { func (x *PublishCommitLogRequest) Reset() { *x = PublishCommitLogRequest{} - mi := &file_mls_api_v1_mls_proto_msgTypes[24] + mi := &file_mls_api_v1_mls_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1395,7 +1668,7 @@ func (x *PublishCommitLogRequest) String() string { func (*PublishCommitLogRequest) ProtoMessage() {} func (x *PublishCommitLogRequest) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[24] + mi := &file_mls_api_v1_mls_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1408,7 +1681,7 @@ func (x *PublishCommitLogRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PublishCommitLogRequest.ProtoReflect.Descriptor instead. func (*PublishCommitLogRequest) Descriptor() ([]byte, []int) { - return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{24} + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{28} } func (x *PublishCommitLogRequest) GetGroupId() []byte { @@ -1442,7 +1715,7 @@ type QueryCommitLogRequest struct { func (x *QueryCommitLogRequest) Reset() { *x = QueryCommitLogRequest{} - mi := &file_mls_api_v1_mls_proto_msgTypes[25] + mi := &file_mls_api_v1_mls_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1454,7 +1727,7 @@ func (x *QueryCommitLogRequest) String() string { func (*QueryCommitLogRequest) ProtoMessage() {} func (x *QueryCommitLogRequest) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[25] + mi := &file_mls_api_v1_mls_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1467,7 +1740,7 @@ func (x *QueryCommitLogRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryCommitLogRequest.ProtoReflect.Descriptor instead. func (*QueryCommitLogRequest) Descriptor() ([]byte, []int) { - return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{25} + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{29} } func (x *QueryCommitLogRequest) GetGroupId() []byte { @@ -1495,7 +1768,7 @@ type QueryCommitLogResponse struct { func (x *QueryCommitLogResponse) Reset() { *x = QueryCommitLogResponse{} - mi := &file_mls_api_v1_mls_proto_msgTypes[26] + mi := &file_mls_api_v1_mls_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1507,7 +1780,7 @@ func (x *QueryCommitLogResponse) String() string { func (*QueryCommitLogResponse) ProtoMessage() {} func (x *QueryCommitLogResponse) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[26] + mi := &file_mls_api_v1_mls_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1520,7 +1793,7 @@ func (x *QueryCommitLogResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryCommitLogResponse.ProtoReflect.Descriptor instead. func (*QueryCommitLogResponse) Descriptor() ([]byte, []int) { - return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{26} + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{30} } func (x *QueryCommitLogResponse) GetGroupId() []byte { @@ -1553,7 +1826,7 @@ type BatchQueryCommitLogRequest struct { func (x *BatchQueryCommitLogRequest) Reset() { *x = BatchQueryCommitLogRequest{} - mi := &file_mls_api_v1_mls_proto_msgTypes[27] + mi := &file_mls_api_v1_mls_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1565,7 +1838,7 @@ func (x *BatchQueryCommitLogRequest) String() string { func (*BatchQueryCommitLogRequest) ProtoMessage() {} func (x *BatchQueryCommitLogRequest) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[27] + mi := &file_mls_api_v1_mls_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1578,7 +1851,7 @@ func (x *BatchQueryCommitLogRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchQueryCommitLogRequest.ProtoReflect.Descriptor instead. func (*BatchQueryCommitLogRequest) Descriptor() ([]byte, []int) { - return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{27} + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{31} } func (x *BatchQueryCommitLogRequest) GetRequests() []*QueryCommitLogRequest { @@ -1597,7 +1870,7 @@ type BatchQueryCommitLogResponse struct { func (x *BatchQueryCommitLogResponse) Reset() { *x = BatchQueryCommitLogResponse{} - mi := &file_mls_api_v1_mls_proto_msgTypes[28] + mi := &file_mls_api_v1_mls_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1609,7 +1882,7 @@ func (x *BatchQueryCommitLogResponse) String() string { func (*BatchQueryCommitLogResponse) ProtoMessage() {} func (x *BatchQueryCommitLogResponse) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[28] + mi := &file_mls_api_v1_mls_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1622,7 +1895,7 @@ func (x *BatchQueryCommitLogResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchQueryCommitLogResponse.ProtoReflect.Descriptor instead. func (*BatchQueryCommitLogResponse) Descriptor() ([]byte, []int) { - return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{28} + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{32} } func (x *BatchQueryCommitLogResponse) GetResponses() []*QueryCommitLogResponse { @@ -1644,7 +1917,7 @@ type GetNewestGroupMessageRequest struct { func (x *GetNewestGroupMessageRequest) Reset() { *x = GetNewestGroupMessageRequest{} - mi := &file_mls_api_v1_mls_proto_msgTypes[29] + mi := &file_mls_api_v1_mls_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1656,7 +1929,7 @@ func (x *GetNewestGroupMessageRequest) String() string { func (*GetNewestGroupMessageRequest) ProtoMessage() {} func (x *GetNewestGroupMessageRequest) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[29] + mi := &file_mls_api_v1_mls_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1669,7 +1942,7 @@ func (x *GetNewestGroupMessageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetNewestGroupMessageRequest.ProtoReflect.Descriptor instead. func (*GetNewestGroupMessageRequest) Descriptor() ([]byte, []int) { - return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{29} + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{33} } func (x *GetNewestGroupMessageRequest) GetGroupIds() [][]byte { @@ -1697,7 +1970,7 @@ type GetNewestGroupMessageResponse struct { func (x *GetNewestGroupMessageResponse) Reset() { *x = GetNewestGroupMessageResponse{} - mi := &file_mls_api_v1_mls_proto_msgTypes[30] + mi := &file_mls_api_v1_mls_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1709,7 +1982,7 @@ func (x *GetNewestGroupMessageResponse) String() string { func (*GetNewestGroupMessageResponse) ProtoMessage() {} func (x *GetNewestGroupMessageResponse) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[30] + mi := &file_mls_api_v1_mls_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1722,7 +1995,7 @@ func (x *GetNewestGroupMessageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetNewestGroupMessageResponse.ProtoReflect.Descriptor instead. func (*GetNewestGroupMessageResponse) Descriptor() ([]byte, []int) { - return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{30} + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{34} } func (x *GetNewestGroupMessageResponse) GetResponses() []*GetNewestGroupMessageResponse_Response { @@ -1748,7 +2021,7 @@ type WelcomeMessage_V1 struct { func (x *WelcomeMessage_V1) Reset() { *x = WelcomeMessage_V1{} - mi := &file_mls_api_v1_mls_proto_msgTypes[31] + mi := &file_mls_api_v1_mls_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1760,7 +2033,7 @@ func (x *WelcomeMessage_V1) String() string { func (*WelcomeMessage_V1) ProtoMessage() {} func (x *WelcomeMessage_V1) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[31] + mi := &file_mls_api_v1_mls_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1844,7 +2117,7 @@ type WelcomeMessage_WelcomePointer struct { func (x *WelcomeMessage_WelcomePointer) Reset() { *x = WelcomeMessage_WelcomePointer{} - mi := &file_mls_api_v1_mls_proto_msgTypes[32] + mi := &file_mls_api_v1_mls_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1856,7 +2129,7 @@ func (x *WelcomeMessage_WelcomePointer) String() string { func (*WelcomeMessage_WelcomePointer) ProtoMessage() {} func (x *WelcomeMessage_WelcomePointer) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[32] + mi := &file_mls_api_v1_mls_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1935,7 +2208,7 @@ type WelcomeMessageInput_V1 struct { func (x *WelcomeMessageInput_V1) Reset() { *x = WelcomeMessageInput_V1{} - mi := &file_mls_api_v1_mls_proto_msgTypes[33] + mi := &file_mls_api_v1_mls_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1947,7 +2220,7 @@ func (x *WelcomeMessageInput_V1) String() string { func (*WelcomeMessageInput_V1) ProtoMessage() {} func (x *WelcomeMessageInput_V1) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[33] + mi := &file_mls_api_v1_mls_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2016,7 +2289,7 @@ type WelcomeMessageInput_WelcomePointer struct { func (x *WelcomeMessageInput_WelcomePointer) Reset() { *x = WelcomeMessageInput_WelcomePointer{} - mi := &file_mls_api_v1_mls_proto_msgTypes[34] + mi := &file_mls_api_v1_mls_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2028,7 +2301,7 @@ func (x *WelcomeMessageInput_WelcomePointer) String() string { func (*WelcomeMessageInput_WelcomePointer) ProtoMessage() {} func (x *WelcomeMessageInput_WelcomePointer) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[34] + mi := &file_mls_api_v1_mls_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2088,7 +2361,7 @@ type GroupMessage_V1 struct { func (x *GroupMessage_V1) Reset() { *x = GroupMessage_V1{} - mi := &file_mls_api_v1_mls_proto_msgTypes[35] + mi := &file_mls_api_v1_mls_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2100,7 +2373,7 @@ func (x *GroupMessage_V1) String() string { func (*GroupMessage_V1) ProtoMessage() {} func (x *GroupMessage_V1) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[35] + mi := &file_mls_api_v1_mls_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2177,7 +2450,7 @@ type GroupMessageInput_V1 struct { func (x *GroupMessageInput_V1) Reset() { *x = GroupMessageInput_V1{} - mi := &file_mls_api_v1_mls_proto_msgTypes[36] + mi := &file_mls_api_v1_mls_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2189,7 +2462,7 @@ func (x *GroupMessageInput_V1) String() string { func (*GroupMessageInput_V1) ProtoMessage() {} func (x *GroupMessageInput_V1) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[36] + mi := &file_mls_api_v1_mls_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2236,7 +2509,7 @@ type FetchKeyPackagesResponse_KeyPackage struct { func (x *FetchKeyPackagesResponse_KeyPackage) Reset() { *x = FetchKeyPackagesResponse_KeyPackage{} - mi := &file_mls_api_v1_mls_proto_msgTypes[37] + mi := &file_mls_api_v1_mls_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2248,7 +2521,7 @@ func (x *FetchKeyPackagesResponse_KeyPackage) String() string { func (*FetchKeyPackagesResponse_KeyPackage) ProtoMessage() {} func (x *FetchKeyPackagesResponse_KeyPackage) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[37] + mi := &file_mls_api_v1_mls_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2282,7 +2555,7 @@ type GetIdentityUpdatesResponse_NewInstallationUpdate struct { func (x *GetIdentityUpdatesResponse_NewInstallationUpdate) Reset() { *x = GetIdentityUpdatesResponse_NewInstallationUpdate{} - mi := &file_mls_api_v1_mls_proto_msgTypes[38] + mi := &file_mls_api_v1_mls_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2294,7 +2567,7 @@ func (x *GetIdentityUpdatesResponse_NewInstallationUpdate) String() string { func (*GetIdentityUpdatesResponse_NewInstallationUpdate) ProtoMessage() {} func (x *GetIdentityUpdatesResponse_NewInstallationUpdate) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[38] + mi := &file_mls_api_v1_mls_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2334,7 +2607,7 @@ type GetIdentityUpdatesResponse_RevokedInstallationUpdate struct { func (x *GetIdentityUpdatesResponse_RevokedInstallationUpdate) Reset() { *x = GetIdentityUpdatesResponse_RevokedInstallationUpdate{} - mi := &file_mls_api_v1_mls_proto_msgTypes[39] + mi := &file_mls_api_v1_mls_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2346,7 +2619,7 @@ func (x *GetIdentityUpdatesResponse_RevokedInstallationUpdate) String() string { func (*GetIdentityUpdatesResponse_RevokedInstallationUpdate) ProtoMessage() {} func (x *GetIdentityUpdatesResponse_RevokedInstallationUpdate) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[39] + mi := &file_mls_api_v1_mls_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2384,7 +2657,7 @@ type GetIdentityUpdatesResponse_Update struct { func (x *GetIdentityUpdatesResponse_Update) Reset() { *x = GetIdentityUpdatesResponse_Update{} - mi := &file_mls_api_v1_mls_proto_msgTypes[40] + mi := &file_mls_api_v1_mls_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2396,7 +2669,7 @@ func (x *GetIdentityUpdatesResponse_Update) String() string { func (*GetIdentityUpdatesResponse_Update) ProtoMessage() {} func (x *GetIdentityUpdatesResponse_Update) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[40] + mi := &file_mls_api_v1_mls_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2472,7 +2745,7 @@ type GetIdentityUpdatesResponse_WalletUpdates struct { func (x *GetIdentityUpdatesResponse_WalletUpdates) Reset() { *x = GetIdentityUpdatesResponse_WalletUpdates{} - mi := &file_mls_api_v1_mls_proto_msgTypes[41] + mi := &file_mls_api_v1_mls_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2484,7 +2757,7 @@ func (x *GetIdentityUpdatesResponse_WalletUpdates) String() string { func (*GetIdentityUpdatesResponse_WalletUpdates) ProtoMessage() {} func (x *GetIdentityUpdatesResponse_WalletUpdates) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[41] + mi := &file_mls_api_v1_mls_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2518,7 +2791,7 @@ type SubscribeGroupMessagesRequest_Filter struct { func (x *SubscribeGroupMessagesRequest_Filter) Reset() { *x = SubscribeGroupMessagesRequest_Filter{} - mi := &file_mls_api_v1_mls_proto_msgTypes[42] + mi := &file_mls_api_v1_mls_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2530,7 +2803,7 @@ func (x *SubscribeGroupMessagesRequest_Filter) String() string { func (*SubscribeGroupMessagesRequest_Filter) ProtoMessage() {} func (x *SubscribeGroupMessagesRequest_Filter) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[42] + mi := &file_mls_api_v1_mls_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2571,7 +2844,7 @@ type SubscribeWelcomeMessagesRequest_Filter struct { func (x *SubscribeWelcomeMessagesRequest_Filter) Reset() { *x = SubscribeWelcomeMessagesRequest_Filter{} - mi := &file_mls_api_v1_mls_proto_msgTypes[43] + mi := &file_mls_api_v1_mls_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2583,7 +2856,7 @@ func (x *SubscribeWelcomeMessagesRequest_Filter) String() string { func (*SubscribeWelcomeMessagesRequest_Filter) ProtoMessage() {} func (x *SubscribeWelcomeMessagesRequest_Filter) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[43] + mi := &file_mls_api_v1_mls_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2613,6 +2886,605 @@ func (x *SubscribeWelcomeMessagesRequest_Filter) GetIdCursor() uint64 { return 0 } +type SubscribeRequest_V1 struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Each frame is exactly one of: a mutation, a Ping, or a Pong. + // + // Types that are valid to be assigned to Request: + // + // *SubscribeRequest_V1_Mutate_ + // *SubscribeRequest_V1_Ping + // *SubscribeRequest_V1_Pong + Request isSubscribeRequest_V1_Request `protobuf_oneof:"request"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeRequest_V1) Reset() { + *x = SubscribeRequest_V1{} + mi := &file_mls_api_v1_mls_proto_msgTypes[48] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeRequest_V1) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeRequest_V1) ProtoMessage() {} + +func (x *SubscribeRequest_V1) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[48] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeRequest_V1.ProtoReflect.Descriptor instead. +func (*SubscribeRequest_V1) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{23, 0} +} + +func (x *SubscribeRequest_V1) GetRequest() isSubscribeRequest_V1_Request { + if x != nil { + return x.Request + } + return nil +} + +func (x *SubscribeRequest_V1) GetMutate() *SubscribeRequest_V1_Mutate { + if x != nil { + if x, ok := x.Request.(*SubscribeRequest_V1_Mutate_); ok { + return x.Mutate + } + } + return nil +} + +func (x *SubscribeRequest_V1) GetPing() *Ping { + if x != nil { + if x, ok := x.Request.(*SubscribeRequest_V1_Ping); ok { + return x.Ping + } + } + return nil +} + +func (x *SubscribeRequest_V1) GetPong() *Pong { + if x != nil { + if x, ok := x.Request.(*SubscribeRequest_V1_Pong); ok { + return x.Pong + } + } + return nil +} + +type isSubscribeRequest_V1_Request interface { + isSubscribeRequest_V1_Request() +} + +type SubscribeRequest_V1_Mutate_ struct { + Mutate *SubscribeRequest_V1_Mutate `protobuf:"bytes,1,opt,name=mutate,proto3,oneof"` +} + +type SubscribeRequest_V1_Ping struct { + Ping *Ping `protobuf:"bytes,2,opt,name=ping,proto3,oneof"` // liveness challenge (e.g. probe the link after resuming) +} + +type SubscribeRequest_V1_Pong struct { + Pong *Pong `protobuf:"bytes,3,opt,name=pong,proto3,oneof"` // answer to a server Ping +} + +func (*SubscribeRequest_V1_Mutate_) isSubscribeRequest_V1_Request() {} + +func (*SubscribeRequest_V1_Ping) isSubscribeRequest_V1_Request() {} + +func (*SubscribeRequest_V1_Pong) isSubscribeRequest_V1_Request() {} + +// Add and/or remove subscriptions in place (applied atomically per frame). +// Topics use the kind-prefixed binary representation shared with the +// decentralized backend (XIP-49 §3.3.2): the first byte is the topic kind, +// the remainder is the identifier. This RPC initially serves +// TOPIC_KIND_GROUP_MESSAGES_V1 (0x00, identifier = group_id) and +// TOPIC_KIND_WELCOME_MESSAGES_V1 (0x01, identifier = installation_key); +// a topic whose kind the node does not serve fails the stream with +// INVALID_ARGUMENT. Future kinds (key packages, identity updates) are +// adopted via the capabilities advertised on Started. +type SubscribeRequest_V1_Mutate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Adds []*SubscribeRequest_V1_Mutate_Subscription `protobuf:"bytes,1,rep,name=adds,proto3" json:"adds,omitempty"` // begin delivering these topics + Removes [][]byte `protobuf:"bytes,2,rep,name=removes,proto3" json:"removes,omitempty"` // topics to stop delivering + // Catch this Mutate's adds up to the live edge — history, TopicsLive + // markers, and the wave's CatchupComplete — but do NOT register them + // for live delivery. The markers then mean "you have everything as of + // now". Combined with half-closing the request stream, this is the + // bounded catch-up ("sync") mode: the server finishes the wave and then + // closes the stream itself. Removals in the Mutate are unaffected. + HistoryOnly bool `protobuf:"varint,3,opt,name=history_only,json=historyOnly,proto3" json:"history_only,omitempty"` + // Client-chosen correlation id, echoed on this wave's CatchupComplete + // so completions are attributable when waves overlap. SHOULD be unique + // per stream; 0 = no correlation requested (still echoed as 0). + MutateId uint64 `protobuf:"varint,4,opt,name=mutate_id,json=mutateId,proto3" json:"mutate_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeRequest_V1_Mutate) Reset() { + *x = SubscribeRequest_V1_Mutate{} + mi := &file_mls_api_v1_mls_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeRequest_V1_Mutate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeRequest_V1_Mutate) ProtoMessage() {} + +func (x *SubscribeRequest_V1_Mutate) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[49] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeRequest_V1_Mutate.ProtoReflect.Descriptor instead. +func (*SubscribeRequest_V1_Mutate) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{23, 0, 0} +} + +func (x *SubscribeRequest_V1_Mutate) GetAdds() []*SubscribeRequest_V1_Mutate_Subscription { + if x != nil { + return x.Adds + } + return nil +} + +func (x *SubscribeRequest_V1_Mutate) GetRemoves() [][]byte { + if x != nil { + return x.Removes + } + return nil +} + +func (x *SubscribeRequest_V1_Mutate) GetHistoryOnly() bool { + if x != nil { + return x.HistoryOnly + } + return false +} + +func (x *SubscribeRequest_V1_Mutate) GetMutateId() uint64 { + if x != nil { + return x.MutateId + } + return 0 +} + +// A topic to subscribe, with the cursor to resume from. +type SubscribeRequest_V1_Mutate_Subscription struct { + state protoimpl.MessageState `protogen:"open.v1"` + Topic []byte `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"` + // Deliver ids greater than this; 0 = from the beginning. For a newly + // joined group, a client SHOULD seed this from the welcome's encrypted + // WelcomeMetadata.message_cursor so a new membership does not refetch + // pre-join history it cannot decrypt; for a new installation's welcome + // topic, 0 is how pending welcomes are collected. + IdCursor uint64 `protobuf:"varint,2,opt,name=id_cursor,json=idCursor,proto3" json:"id_cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeRequest_V1_Mutate_Subscription) Reset() { + *x = SubscribeRequest_V1_Mutate_Subscription{} + mi := &file_mls_api_v1_mls_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeRequest_V1_Mutate_Subscription) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeRequest_V1_Mutate_Subscription) ProtoMessage() {} + +func (x *SubscribeRequest_V1_Mutate_Subscription) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[50] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeRequest_V1_Mutate_Subscription.ProtoReflect.Descriptor instead. +func (*SubscribeRequest_V1_Mutate_Subscription) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{23, 0, 0, 0} +} + +func (x *SubscribeRequest_V1_Mutate_Subscription) GetTopic() []byte { + if x != nil { + return x.Topic + } + return nil +} + +func (x *SubscribeRequest_V1_Mutate_Subscription) GetIdCursor() uint64 { + if x != nil { + return x.IdCursor + } + return 0 +} + +type SubscribeResponse_V1 struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Response: + // + // *SubscribeResponse_V1_Messages_ + // *SubscribeResponse_V1_Started_ + // *SubscribeResponse_V1_Ping + // *SubscribeResponse_V1_Pong + // *SubscribeResponse_V1_TopicsLive_ + // *SubscribeResponse_V1_CatchupComplete_ + Response isSubscribeResponse_V1_Response `protobuf_oneof:"response"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeResponse_V1) Reset() { + *x = SubscribeResponse_V1{} + mi := &file_mls_api_v1_mls_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeResponse_V1) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeResponse_V1) ProtoMessage() {} + +func (x *SubscribeResponse_V1) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[51] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeResponse_V1.ProtoReflect.Descriptor instead. +func (*SubscribeResponse_V1) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{24, 0} +} + +func (x *SubscribeResponse_V1) GetResponse() isSubscribeResponse_V1_Response { + if x != nil { + return x.Response + } + return nil +} + +func (x *SubscribeResponse_V1) GetMessages() *SubscribeResponse_V1_Messages { + if x != nil { + if x, ok := x.Response.(*SubscribeResponse_V1_Messages_); ok { + return x.Messages + } + } + return nil +} + +func (x *SubscribeResponse_V1) GetStarted() *SubscribeResponse_V1_Started { + if x != nil { + if x, ok := x.Response.(*SubscribeResponse_V1_Started_); ok { + return x.Started + } + } + return nil +} + +func (x *SubscribeResponse_V1) GetPing() *Ping { + if x != nil { + if x, ok := x.Response.(*SubscribeResponse_V1_Ping); ok { + return x.Ping + } + } + return nil +} + +func (x *SubscribeResponse_V1) GetPong() *Pong { + if x != nil { + if x, ok := x.Response.(*SubscribeResponse_V1_Pong); ok { + return x.Pong + } + } + return nil +} + +func (x *SubscribeResponse_V1) GetTopicsLive() *SubscribeResponse_V1_TopicsLive { + if x != nil { + if x, ok := x.Response.(*SubscribeResponse_V1_TopicsLive_); ok { + return x.TopicsLive + } + } + return nil +} + +func (x *SubscribeResponse_V1) GetCatchupComplete() *SubscribeResponse_V1_CatchupComplete { + if x != nil { + if x, ok := x.Response.(*SubscribeResponse_V1_CatchupComplete_); ok { + return x.CatchupComplete + } + } + return nil +} + +type isSubscribeResponse_V1_Response interface { + isSubscribeResponse_V1_Response() +} + +type SubscribeResponse_V1_Messages_ struct { + Messages *SubscribeResponse_V1_Messages `protobuf:"bytes,1,opt,name=messages,proto3,oneof"` +} + +type SubscribeResponse_V1_Started_ struct { + Started *SubscribeResponse_V1_Started `protobuf:"bytes,2,opt,name=started,proto3,oneof"` // sent once, immediately on open, before any catch-up +} + +type SubscribeResponse_V1_Ping struct { + Ping *Ping `protobuf:"bytes,3,opt,name=ping,proto3,oneof"` // idle liveness challenge; receiver MUST answer with Pong +} + +type SubscribeResponse_V1_Pong struct { + Pong *Pong `protobuf:"bytes,4,opt,name=pong,proto3,oneof"` // answer to a client Ping +} + +type SubscribeResponse_V1_TopicsLive_ struct { + TopicsLive *SubscribeResponse_V1_TopicsLive `protobuf:"bytes,5,opt,name=topics_live,json=topicsLive,proto3,oneof"` // these topics just crossed from catch-up to live +} + +type SubscribeResponse_V1_CatchupComplete_ struct { + CatchupComplete *SubscribeResponse_V1_CatchupComplete `protobuf:"bytes,6,opt,name=catchup_complete,json=catchupComplete,proto3,oneof"` // a Mutate's adds are fully delivered +} + +func (*SubscribeResponse_V1_Messages_) isSubscribeResponse_V1_Response() {} + +func (*SubscribeResponse_V1_Started_) isSubscribeResponse_V1_Response() {} + +func (*SubscribeResponse_V1_Ping) isSubscribeResponse_V1_Response() {} + +func (*SubscribeResponse_V1_Pong) isSubscribeResponse_V1_Response() {} + +func (*SubscribeResponse_V1_TopicsLive_) isSubscribeResponse_V1_Response() {} + +func (*SubscribeResponse_V1_CatchupComplete_) isSubscribeResponse_V1_Response() {} + +// A batch of new messages; group and welcome messages share the stream, +// depending on which subscriptions are active. +type SubscribeResponse_V1_Messages struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupMessages []*GroupMessage `protobuf:"bytes,1,rep,name=group_messages,json=groupMessages,proto3" json:"group_messages,omitempty"` + WelcomeMessages []*WelcomeMessage `protobuf:"bytes,2,rep,name=welcome_messages,json=welcomeMessages,proto3" json:"welcome_messages,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeResponse_V1_Messages) Reset() { + *x = SubscribeResponse_V1_Messages{} + mi := &file_mls_api_v1_mls_proto_msgTypes[52] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeResponse_V1_Messages) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeResponse_V1_Messages) ProtoMessage() {} + +func (x *SubscribeResponse_V1_Messages) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[52] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeResponse_V1_Messages.ProtoReflect.Descriptor instead. +func (*SubscribeResponse_V1_Messages) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{24, 0, 0} +} + +func (x *SubscribeResponse_V1_Messages) GetGroupMessages() []*GroupMessage { + if x != nil { + return x.GroupMessages + } + return nil +} + +func (x *SubscribeResponse_V1_Messages) GetWelcomeMessages() []*WelcomeMessage { + if x != nil { + return x.WelcomeMessages + } + return nil +} + +// The first frame on every stream. +type SubscribeResponse_V1_Started struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The server's ping cadence (ms): the basis for the client's staleness + // threshold and the server's reap deadline. + KeepaliveIntervalMs uint32 `protobuf:"varint,1,opt,name=keepalive_interval_ms,json=keepaliveIntervalMs,proto3" json:"keepalive_interval_ms,omitempty"` + // Optional protocol features the node supports on this stream. The node + // silently ignores request types it does not understand, so a client + // MUST NOT send an optional request type whose capability the node did + // not advertise (it would hang waiting on a response that never comes). + Capabilities []SubscribeResponse_V1_Capability `protobuf:"varint,2,rep,packed,name=capabilities,proto3,enum=xmtp.mls.api.v1.SubscribeResponse_V1_Capability" json:"capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeResponse_V1_Started) Reset() { + *x = SubscribeResponse_V1_Started{} + mi := &file_mls_api_v1_mls_proto_msgTypes[53] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeResponse_V1_Started) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeResponse_V1_Started) ProtoMessage() {} + +func (x *SubscribeResponse_V1_Started) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[53] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeResponse_V1_Started.ProtoReflect.Descriptor instead. +func (*SubscribeResponse_V1_Started) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{24, 0, 1} +} + +func (x *SubscribeResponse_V1_Started) GetKeepaliveIntervalMs() uint32 { + if x != nil { + return x.KeepaliveIntervalMs + } + return 0 +} + +func (x *SubscribeResponse_V1_Started) GetCapabilities() []SubscribeResponse_V1_Capability { + if x != nil { + return x.Capabilities + } + return nil +} + +// Sent once per Mutate that adds subscriptions (a catch-up "wave"), after +// the wave's last TopicsLive: everything the Mutate asked for is delivered. +type SubscribeResponse_V1_CatchupComplete struct { + state protoimpl.MessageState `protogen:"open.v1"` + MutateId uint64 `protobuf:"varint,1,opt,name=mutate_id,json=mutateId,proto3" json:"mutate_id,omitempty"` // echoes the Mutate that started this wave (0 if none given) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeResponse_V1_CatchupComplete) Reset() { + *x = SubscribeResponse_V1_CatchupComplete{} + mi := &file_mls_api_v1_mls_proto_msgTypes[54] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeResponse_V1_CatchupComplete) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeResponse_V1_CatchupComplete) ProtoMessage() {} + +func (x *SubscribeResponse_V1_CatchupComplete) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[54] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeResponse_V1_CatchupComplete.ProtoReflect.Descriptor instead. +func (*SubscribeResponse_V1_CatchupComplete) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{24, 0, 2} +} + +func (x *SubscribeResponse_V1_CatchupComplete) GetMutateId() uint64 { + if x != nil { + return x.MutateId + } + return 0 +} + +// Emitted when topics finish catch-up, AFTER the last history frame for +// them — including any live messages that queued up behind the catch-up, +// which were equally historical from the client's perspective — so every +// later frame for a listed topic is live tail. Informational only: delivery +// correctness (no duplicates, no gaps) never depends on it. Re-adding a +// topic re-runs catch-up and re-emits it; receivers treat it idempotently. +type SubscribeResponse_V1_TopicsLive struct { + state protoimpl.MessageState `protogen:"open.v1"` + Topics [][]byte `protobuf:"bytes,1,rep,name=topics,proto3" json:"topics,omitempty"` // kind-prefixed topics now tailing live + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeResponse_V1_TopicsLive) Reset() { + *x = SubscribeResponse_V1_TopicsLive{} + mi := &file_mls_api_v1_mls_proto_msgTypes[55] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeResponse_V1_TopicsLive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeResponse_V1_TopicsLive) ProtoMessage() {} + +func (x *SubscribeResponse_V1_TopicsLive) ProtoReflect() protoreflect.Message { + mi := &file_mls_api_v1_mls_proto_msgTypes[55] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeResponse_V1_TopicsLive.ProtoReflect.Descriptor instead. +func (*SubscribeResponse_V1_TopicsLive) Descriptor() ([]byte, []int) { + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{24, 0, 3} +} + +func (x *SubscribeResponse_V1_TopicsLive) GetTopics() [][]byte { + if x != nil { + return x.Topics + } + return nil +} + type GetNewestGroupMessageResponse_Response struct { state protoimpl.MessageState `protogen:"open.v1"` // If no message is found on the topic, will be nil @@ -2623,7 +3495,7 @@ type GetNewestGroupMessageResponse_Response struct { func (x *GetNewestGroupMessageResponse_Response) Reset() { *x = GetNewestGroupMessageResponse_Response{} - mi := &file_mls_api_v1_mls_proto_msgTypes[44] + mi := &file_mls_api_v1_mls_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2635,7 +3507,7 @@ func (x *GetNewestGroupMessageResponse_Response) String() string { func (*GetNewestGroupMessageResponse_Response) ProtoMessage() {} func (x *GetNewestGroupMessageResponse_Response) ProtoReflect() protoreflect.Message { - mi := &file_mls_api_v1_mls_proto_msgTypes[44] + mi := &file_mls_api_v1_mls_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2648,7 +3520,7 @@ func (x *GetNewestGroupMessageResponse_Response) ProtoReflect() protoreflect.Mes // Deprecated: Use GetNewestGroupMessageResponse_Response.ProtoReflect.Descriptor instead. func (*GetNewestGroupMessageResponse_Response) Descriptor() ([]byte, []int) { - return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{30, 0} + return file_mls_api_v1_mls_proto_rawDescGZIP(), []int{34, 0} } func (x *GetNewestGroupMessageResponse_Response) GetGroupMessage() *GroupMessage { @@ -2797,7 +3669,54 @@ const file_mls_api_v1_mls_proto_rawDesc = "" + "\afilters\x18\x01 \x03(\v27.xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest.FilterR\afilters\x1aP\n" + "\x06Filter\x12)\n" + "\x10installation_key\x18\x01 \x01(\fR\x0finstallationKey\x12\x1b\n" + - "\tid_cursor\x18\x02 \x01(\x04R\bidCursor\"d\n" + + "\tid_cursor\x18\x02 \x01(\x04R\bidCursor\"\xfe\x03\n" + + "\x10SubscribeRequest\x126\n" + + "\x02v1\x18\x01 \x01(\v2$.xmtp.mls.api.v1.SubscribeRequest.V1H\x00R\x02v1\x1a\xa6\x03\n" + + "\x02V1\x12E\n" + + "\x06mutate\x18\x01 \x01(\v2+.xmtp.mls.api.v1.SubscribeRequest.V1.MutateH\x00R\x06mutate\x12+\n" + + "\x04ping\x18\x02 \x01(\v2\x15.xmtp.mls.api.v1.PingH\x00R\x04ping\x12+\n" + + "\x04pong\x18\x03 \x01(\v2\x15.xmtp.mls.api.v1.PongH\x00R\x04pong\x1a\xf3\x01\n" + + "\x06Mutate\x12L\n" + + "\x04adds\x18\x01 \x03(\v28.xmtp.mls.api.v1.SubscribeRequest.V1.Mutate.SubscriptionR\x04adds\x12\x18\n" + + "\aremoves\x18\x02 \x03(\fR\aremoves\x12!\n" + + "\fhistory_only\x18\x03 \x01(\bR\vhistoryOnly\x12\x1b\n" + + "\tmutate_id\x18\x04 \x01(\x04R\bmutateId\x1aA\n" + + "\fSubscription\x12\x14\n" + + "\x05topic\x18\x01 \x01(\fR\x05topic\x12\x1b\n" + + "\tid_cursor\x18\x02 \x01(\x04R\bidCursorB\t\n" + + "\arequestB\t\n" + + "\aversion\"\xcb\a\n" + + "\x11SubscribeResponse\x127\n" + + "\x02v1\x18\x01 \x01(\v2%.xmtp.mls.api.v1.SubscribeResponse.V1H\x00R\x02v1\x1a\xf1\x06\n" + + "\x02V1\x12L\n" + + "\bmessages\x18\x01 \x01(\v2..xmtp.mls.api.v1.SubscribeResponse.V1.MessagesH\x00R\bmessages\x12I\n" + + "\astarted\x18\x02 \x01(\v2-.xmtp.mls.api.v1.SubscribeResponse.V1.StartedH\x00R\astarted\x12+\n" + + "\x04ping\x18\x03 \x01(\v2\x15.xmtp.mls.api.v1.PingH\x00R\x04ping\x12+\n" + + "\x04pong\x18\x04 \x01(\v2\x15.xmtp.mls.api.v1.PongH\x00R\x04pong\x12S\n" + + "\vtopics_live\x18\x05 \x01(\v20.xmtp.mls.api.v1.SubscribeResponse.V1.TopicsLiveH\x00R\n" + + "topicsLive\x12b\n" + + "\x10catchup_complete\x18\x06 \x01(\v25.xmtp.mls.api.v1.SubscribeResponse.V1.CatchupCompleteH\x00R\x0fcatchupComplete\x1a\x9c\x01\n" + + "\bMessages\x12D\n" + + "\x0egroup_messages\x18\x01 \x03(\v2\x1d.xmtp.mls.api.v1.GroupMessageR\rgroupMessages\x12J\n" + + "\x10welcome_messages\x18\x02 \x03(\v2\x1f.xmtp.mls.api.v1.WelcomeMessageR\x0fwelcomeMessages\x1a\x93\x01\n" + + "\aStarted\x122\n" + + "\x15keepalive_interval_ms\x18\x01 \x01(\rR\x13keepaliveIntervalMs\x12T\n" + + "\fcapabilities\x18\x02 \x03(\x0e20.xmtp.mls.api.v1.SubscribeResponse.V1.CapabilityR\fcapabilities\x1a.\n" + + "\x0fCatchupComplete\x12\x1b\n" + + "\tmutate_id\x18\x01 \x01(\x04R\bmutateId\x1a$\n" + + "\n" + + "TopicsLive\x12\x16\n" + + "\x06topics\x18\x01 \x03(\fR\x06topics\"(\n" + + "\n" + + "Capability\x12\x1a\n" + + "\x16CAPABILITY_UNSPECIFIED\x10\x00B\n" + + "\n" + + "\bresponseB\t\n" + + "\aversion\"\x1c\n" + + "\x04Ping\x12\x14\n" + + "\x05nonce\x18\x01 \x01(\x04R\x05nonce\"\x1c\n" + + "\x04Pong\x12\x14\n" + + "\x05nonce\x18\x01 \x01(\x04R\x05nonce\"d\n" + "\x1cBatchPublishCommitLogRequest\x12D\n" + "\brequests\x18\x01 \x03(\v2(.xmtp.mls.api.v1.PublishCommitLogRequestR\brequests\"\xca\x01\n" + "\x17PublishCommitLogRequest\x12\x19\n" + @@ -2828,7 +3747,7 @@ const file_mls_api_v1_mls_proto_rawDesc = "" + "\rSortDirection\x12\x1e\n" + "\x1aSORT_DIRECTION_UNSPECIFIED\x10\x00\x12\x1c\n" + "\x18SORT_DIRECTION_ASCENDING\x10\x01\x12\x1d\n" + - "\x19SORT_DIRECTION_DESCENDING\x10\x022\x9c\x10\n" + + "\x19SORT_DIRECTION_DESCENDING\x10\x022\xf6\x10\n" + "\x06MlsApi\x12~\n" + "\x11SendGroupMessages\x12).xmtp.mls.api.v1.SendGroupMessagesRequest\x1a\x16.google.protobuf.Empty\"&\x82\xd3\xe4\x93\x02 :\x01*\"\x1b/mls/v1/send-group-messages\x12\x84\x01\n" + "\x13SendWelcomeMessages\x12+.xmtp.mls.api.v1.SendWelcomeMessagesRequest\x1a\x16.google.protobuf.Empty\"(\x82\xd3\xe4\x93\x02\":\x01*\"\x1d/mls/v1/send-welcome-messages\x12\x9d\x01\n" + @@ -2840,7 +3759,8 @@ const file_mls_api_v1_mls_proto_rawDesc = "" + "\x12QueryGroupMessages\x12*.xmtp.mls.api.v1.QueryGroupMessagesRequest\x1a+.xmtp.mls.api.v1.QueryGroupMessagesResponse\"'\x82\xd3\xe4\x93\x02!:\x01*\"\x1c/mls/v1/query-group-messages\x12\x9e\x01\n" + "\x14QueryWelcomeMessages\x12,.xmtp.mls.api.v1.QueryWelcomeMessagesRequest\x1a-.xmtp.mls.api.v1.QueryWelcomeMessagesResponse\")\x82\xd3\xe4\x93\x02#:\x01*\"\x1e/mls/v1/query-welcome-messages\x12\x96\x01\n" + "\x16SubscribeGroupMessages\x12..xmtp.mls.api.v1.SubscribeGroupMessagesRequest\x1a\x1d.xmtp.mls.api.v1.GroupMessage\"+\x82\xd3\xe4\x93\x02%:\x01*\" /mls/v1/subscribe-group-messages0\x01\x12\x9e\x01\n" + - "\x18SubscribeWelcomeMessages\x120.xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest\x1a\x1f.xmtp.mls.api.v1.WelcomeMessage\"-\x82\xd3\xe4\x93\x02':\x01*\"\"/mls/v1/subscribe-welcome-messages0\x01\x12\x8b\x01\n" + + "\x18SubscribeWelcomeMessages\x120.xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest\x1a\x1f.xmtp.mls.api.v1.WelcomeMessage\"-\x82\xd3\xe4\x93\x02':\x01*\"\"/mls/v1/subscribe-welcome-messages0\x01\x12X\n" + + "\tSubscribe\x12!.xmtp.mls.api.v1.SubscribeRequest\x1a\".xmtp.mls.api.v1.SubscribeResponse\"\x00(\x010\x01\x12\x8b\x01\n" + "\x15BatchPublishCommitLog\x12-.xmtp.mls.api.v1.BatchPublishCommitLogRequest\x1a\x16.google.protobuf.Empty\"+\x82\xd3\xe4\x93\x02%:\x01*\" /mls/v1/batch-publish-commit-log\x12\x9b\x01\n" + "\x13BatchQueryCommitLog\x12+.xmtp.mls.api.v1.BatchQueryCommitLogRequest\x1a,.xmtp.mls.api.v1.BatchQueryCommitLogResponse\")\x82\xd3\xe4\x93\x02#:\x01*\"\x1e/mls/v1/batch-query-commit-log\x12\xa0\x01\n" + "\x15GetNewestGroupMessage\x12-.xmtp.mls.api.v1.GetNewestGroupMessageRequest\x1a..xmtp.mls.api.v1.GetNewestGroupMessageResponse\"(\x82\xd3\xe4\x93\x02\"\x12 /mls/v1/get-newest-group-messageB\xc9\x01\x92A\x0f\x12\r\n" + @@ -2859,134 +3779,164 @@ func file_mls_api_v1_mls_proto_rawDescGZIP() []byte { return file_mls_api_v1_mls_proto_rawDescData } -var file_mls_api_v1_mls_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_mls_api_v1_mls_proto_msgTypes = make([]protoimpl.MessageInfo, 45) +var file_mls_api_v1_mls_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_mls_api_v1_mls_proto_msgTypes = make([]protoimpl.MessageInfo, 57) var file_mls_api_v1_mls_proto_goTypes = []any{ (SortDirection)(0), // 0: xmtp.mls.api.v1.SortDirection - (*WelcomeMessage)(nil), // 1: xmtp.mls.api.v1.WelcomeMessage - (*WelcomeMessageInput)(nil), // 2: xmtp.mls.api.v1.WelcomeMessageInput - (*WelcomeMetadata)(nil), // 3: xmtp.mls.api.v1.WelcomeMetadata - (*GroupMessage)(nil), // 4: xmtp.mls.api.v1.GroupMessage - (*GroupMessageInput)(nil), // 5: xmtp.mls.api.v1.GroupMessageInput - (*SendGroupMessagesRequest)(nil), // 6: xmtp.mls.api.v1.SendGroupMessagesRequest - (*SendWelcomeMessagesRequest)(nil), // 7: xmtp.mls.api.v1.SendWelcomeMessagesRequest - (*KeyPackageUpload)(nil), // 8: xmtp.mls.api.v1.KeyPackageUpload - (*RegisterInstallationRequest)(nil), // 9: xmtp.mls.api.v1.RegisterInstallationRequest - (*RegisterInstallationResponse)(nil), // 10: xmtp.mls.api.v1.RegisterInstallationResponse - (*UploadKeyPackageRequest)(nil), // 11: xmtp.mls.api.v1.UploadKeyPackageRequest - (*FetchKeyPackagesRequest)(nil), // 12: xmtp.mls.api.v1.FetchKeyPackagesRequest - (*FetchKeyPackagesResponse)(nil), // 13: xmtp.mls.api.v1.FetchKeyPackagesResponse - (*RevokeInstallationRequest)(nil), // 14: xmtp.mls.api.v1.RevokeInstallationRequest - (*GetIdentityUpdatesRequest)(nil), // 15: xmtp.mls.api.v1.GetIdentityUpdatesRequest - (*GetIdentityUpdatesResponse)(nil), // 16: xmtp.mls.api.v1.GetIdentityUpdatesResponse - (*PagingInfo)(nil), // 17: xmtp.mls.api.v1.PagingInfo - (*QueryGroupMessagesRequest)(nil), // 18: xmtp.mls.api.v1.QueryGroupMessagesRequest - (*QueryGroupMessagesResponse)(nil), // 19: xmtp.mls.api.v1.QueryGroupMessagesResponse - (*QueryWelcomeMessagesRequest)(nil), // 20: xmtp.mls.api.v1.QueryWelcomeMessagesRequest - (*QueryWelcomeMessagesResponse)(nil), // 21: xmtp.mls.api.v1.QueryWelcomeMessagesResponse - (*SubscribeGroupMessagesRequest)(nil), // 22: xmtp.mls.api.v1.SubscribeGroupMessagesRequest - (*SubscribeWelcomeMessagesRequest)(nil), // 23: xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest - (*BatchPublishCommitLogRequest)(nil), // 24: xmtp.mls.api.v1.BatchPublishCommitLogRequest - (*PublishCommitLogRequest)(nil), // 25: xmtp.mls.api.v1.PublishCommitLogRequest - (*QueryCommitLogRequest)(nil), // 26: xmtp.mls.api.v1.QueryCommitLogRequest - (*QueryCommitLogResponse)(nil), // 27: xmtp.mls.api.v1.QueryCommitLogResponse - (*BatchQueryCommitLogRequest)(nil), // 28: xmtp.mls.api.v1.BatchQueryCommitLogRequest - (*BatchQueryCommitLogResponse)(nil), // 29: xmtp.mls.api.v1.BatchQueryCommitLogResponse - (*GetNewestGroupMessageRequest)(nil), // 30: xmtp.mls.api.v1.GetNewestGroupMessageRequest - (*GetNewestGroupMessageResponse)(nil), // 31: xmtp.mls.api.v1.GetNewestGroupMessageResponse - (*WelcomeMessage_V1)(nil), // 32: xmtp.mls.api.v1.WelcomeMessage.V1 - (*WelcomeMessage_WelcomePointer)(nil), // 33: xmtp.mls.api.v1.WelcomeMessage.WelcomePointer - (*WelcomeMessageInput_V1)(nil), // 34: xmtp.mls.api.v1.WelcomeMessageInput.V1 - (*WelcomeMessageInput_WelcomePointer)(nil), // 35: xmtp.mls.api.v1.WelcomeMessageInput.WelcomePointer - (*GroupMessage_V1)(nil), // 36: xmtp.mls.api.v1.GroupMessage.V1 - (*GroupMessageInput_V1)(nil), // 37: xmtp.mls.api.v1.GroupMessageInput.V1 - (*FetchKeyPackagesResponse_KeyPackage)(nil), // 38: xmtp.mls.api.v1.FetchKeyPackagesResponse.KeyPackage - (*GetIdentityUpdatesResponse_NewInstallationUpdate)(nil), // 39: xmtp.mls.api.v1.GetIdentityUpdatesResponse.NewInstallationUpdate - (*GetIdentityUpdatesResponse_RevokedInstallationUpdate)(nil), // 40: xmtp.mls.api.v1.GetIdentityUpdatesResponse.RevokedInstallationUpdate - (*GetIdentityUpdatesResponse_Update)(nil), // 41: xmtp.mls.api.v1.GetIdentityUpdatesResponse.Update - (*GetIdentityUpdatesResponse_WalletUpdates)(nil), // 42: xmtp.mls.api.v1.GetIdentityUpdatesResponse.WalletUpdates - (*SubscribeGroupMessagesRequest_Filter)(nil), // 43: xmtp.mls.api.v1.SubscribeGroupMessagesRequest.Filter - (*SubscribeWelcomeMessagesRequest_Filter)(nil), // 44: xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest.Filter - (*GetNewestGroupMessageResponse_Response)(nil), // 45: xmtp.mls.api.v1.GetNewestGroupMessageResponse.Response - (*message_contents.Signature)(nil), // 46: xmtp.message_contents.Signature - (*associations.RecoverableEd25519Signature)(nil), // 47: xmtp.identity.associations.RecoverableEd25519Signature - (*message_contents1.CommitLogEntry)(nil), // 48: xmtp.mls.message_contents.CommitLogEntry - (message_contents1.WelcomeWrapperAlgorithm)(0), // 49: xmtp.mls.message_contents.WelcomeWrapperAlgorithm - (message_contents1.WelcomePointerWrapperAlgorithm)(0), // 50: xmtp.mls.message_contents.WelcomePointerWrapperAlgorithm - (*emptypb.Empty)(nil), // 51: google.protobuf.Empty + (SubscribeResponse_V1_Capability)(0), // 1: xmtp.mls.api.v1.SubscribeResponse.V1.Capability + (*WelcomeMessage)(nil), // 2: xmtp.mls.api.v1.WelcomeMessage + (*WelcomeMessageInput)(nil), // 3: xmtp.mls.api.v1.WelcomeMessageInput + (*WelcomeMetadata)(nil), // 4: xmtp.mls.api.v1.WelcomeMetadata + (*GroupMessage)(nil), // 5: xmtp.mls.api.v1.GroupMessage + (*GroupMessageInput)(nil), // 6: xmtp.mls.api.v1.GroupMessageInput + (*SendGroupMessagesRequest)(nil), // 7: xmtp.mls.api.v1.SendGroupMessagesRequest + (*SendWelcomeMessagesRequest)(nil), // 8: xmtp.mls.api.v1.SendWelcomeMessagesRequest + (*KeyPackageUpload)(nil), // 9: xmtp.mls.api.v1.KeyPackageUpload + (*RegisterInstallationRequest)(nil), // 10: xmtp.mls.api.v1.RegisterInstallationRequest + (*RegisterInstallationResponse)(nil), // 11: xmtp.mls.api.v1.RegisterInstallationResponse + (*UploadKeyPackageRequest)(nil), // 12: xmtp.mls.api.v1.UploadKeyPackageRequest + (*FetchKeyPackagesRequest)(nil), // 13: xmtp.mls.api.v1.FetchKeyPackagesRequest + (*FetchKeyPackagesResponse)(nil), // 14: xmtp.mls.api.v1.FetchKeyPackagesResponse + (*RevokeInstallationRequest)(nil), // 15: xmtp.mls.api.v1.RevokeInstallationRequest + (*GetIdentityUpdatesRequest)(nil), // 16: xmtp.mls.api.v1.GetIdentityUpdatesRequest + (*GetIdentityUpdatesResponse)(nil), // 17: xmtp.mls.api.v1.GetIdentityUpdatesResponse + (*PagingInfo)(nil), // 18: xmtp.mls.api.v1.PagingInfo + (*QueryGroupMessagesRequest)(nil), // 19: xmtp.mls.api.v1.QueryGroupMessagesRequest + (*QueryGroupMessagesResponse)(nil), // 20: xmtp.mls.api.v1.QueryGroupMessagesResponse + (*QueryWelcomeMessagesRequest)(nil), // 21: xmtp.mls.api.v1.QueryWelcomeMessagesRequest + (*QueryWelcomeMessagesResponse)(nil), // 22: xmtp.mls.api.v1.QueryWelcomeMessagesResponse + (*SubscribeGroupMessagesRequest)(nil), // 23: xmtp.mls.api.v1.SubscribeGroupMessagesRequest + (*SubscribeWelcomeMessagesRequest)(nil), // 24: xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest + (*SubscribeRequest)(nil), // 25: xmtp.mls.api.v1.SubscribeRequest + (*SubscribeResponse)(nil), // 26: xmtp.mls.api.v1.SubscribeResponse + (*Ping)(nil), // 27: xmtp.mls.api.v1.Ping + (*Pong)(nil), // 28: xmtp.mls.api.v1.Pong + (*BatchPublishCommitLogRequest)(nil), // 29: xmtp.mls.api.v1.BatchPublishCommitLogRequest + (*PublishCommitLogRequest)(nil), // 30: xmtp.mls.api.v1.PublishCommitLogRequest + (*QueryCommitLogRequest)(nil), // 31: xmtp.mls.api.v1.QueryCommitLogRequest + (*QueryCommitLogResponse)(nil), // 32: xmtp.mls.api.v1.QueryCommitLogResponse + (*BatchQueryCommitLogRequest)(nil), // 33: xmtp.mls.api.v1.BatchQueryCommitLogRequest + (*BatchQueryCommitLogResponse)(nil), // 34: xmtp.mls.api.v1.BatchQueryCommitLogResponse + (*GetNewestGroupMessageRequest)(nil), // 35: xmtp.mls.api.v1.GetNewestGroupMessageRequest + (*GetNewestGroupMessageResponse)(nil), // 36: xmtp.mls.api.v1.GetNewestGroupMessageResponse + (*WelcomeMessage_V1)(nil), // 37: xmtp.mls.api.v1.WelcomeMessage.V1 + (*WelcomeMessage_WelcomePointer)(nil), // 38: xmtp.mls.api.v1.WelcomeMessage.WelcomePointer + (*WelcomeMessageInput_V1)(nil), // 39: xmtp.mls.api.v1.WelcomeMessageInput.V1 + (*WelcomeMessageInput_WelcomePointer)(nil), // 40: xmtp.mls.api.v1.WelcomeMessageInput.WelcomePointer + (*GroupMessage_V1)(nil), // 41: xmtp.mls.api.v1.GroupMessage.V1 + (*GroupMessageInput_V1)(nil), // 42: xmtp.mls.api.v1.GroupMessageInput.V1 + (*FetchKeyPackagesResponse_KeyPackage)(nil), // 43: xmtp.mls.api.v1.FetchKeyPackagesResponse.KeyPackage + (*GetIdentityUpdatesResponse_NewInstallationUpdate)(nil), // 44: xmtp.mls.api.v1.GetIdentityUpdatesResponse.NewInstallationUpdate + (*GetIdentityUpdatesResponse_RevokedInstallationUpdate)(nil), // 45: xmtp.mls.api.v1.GetIdentityUpdatesResponse.RevokedInstallationUpdate + (*GetIdentityUpdatesResponse_Update)(nil), // 46: xmtp.mls.api.v1.GetIdentityUpdatesResponse.Update + (*GetIdentityUpdatesResponse_WalletUpdates)(nil), // 47: xmtp.mls.api.v1.GetIdentityUpdatesResponse.WalletUpdates + (*SubscribeGroupMessagesRequest_Filter)(nil), // 48: xmtp.mls.api.v1.SubscribeGroupMessagesRequest.Filter + (*SubscribeWelcomeMessagesRequest_Filter)(nil), // 49: xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest.Filter + (*SubscribeRequest_V1)(nil), // 50: xmtp.mls.api.v1.SubscribeRequest.V1 + (*SubscribeRequest_V1_Mutate)(nil), // 51: xmtp.mls.api.v1.SubscribeRequest.V1.Mutate + (*SubscribeRequest_V1_Mutate_Subscription)(nil), // 52: xmtp.mls.api.v1.SubscribeRequest.V1.Mutate.Subscription + (*SubscribeResponse_V1)(nil), // 53: xmtp.mls.api.v1.SubscribeResponse.V1 + (*SubscribeResponse_V1_Messages)(nil), // 54: xmtp.mls.api.v1.SubscribeResponse.V1.Messages + (*SubscribeResponse_V1_Started)(nil), // 55: xmtp.mls.api.v1.SubscribeResponse.V1.Started + (*SubscribeResponse_V1_CatchupComplete)(nil), // 56: xmtp.mls.api.v1.SubscribeResponse.V1.CatchupComplete + (*SubscribeResponse_V1_TopicsLive)(nil), // 57: xmtp.mls.api.v1.SubscribeResponse.V1.TopicsLive + (*GetNewestGroupMessageResponse_Response)(nil), // 58: xmtp.mls.api.v1.GetNewestGroupMessageResponse.Response + (*message_contents.Signature)(nil), // 59: xmtp.message_contents.Signature + (*associations.RecoverableEd25519Signature)(nil), // 60: xmtp.identity.associations.RecoverableEd25519Signature + (*message_contents1.CommitLogEntry)(nil), // 61: xmtp.mls.message_contents.CommitLogEntry + (message_contents1.WelcomeWrapperAlgorithm)(0), // 62: xmtp.mls.message_contents.WelcomeWrapperAlgorithm + (message_contents1.WelcomePointerWrapperAlgorithm)(0), // 63: xmtp.mls.message_contents.WelcomePointerWrapperAlgorithm + (*emptypb.Empty)(nil), // 64: google.protobuf.Empty } var file_mls_api_v1_mls_proto_depIdxs = []int32{ - 32, // 0: xmtp.mls.api.v1.WelcomeMessage.v1:type_name -> xmtp.mls.api.v1.WelcomeMessage.V1 - 33, // 1: xmtp.mls.api.v1.WelcomeMessage.welcome_pointer:type_name -> xmtp.mls.api.v1.WelcomeMessage.WelcomePointer - 34, // 2: xmtp.mls.api.v1.WelcomeMessageInput.v1:type_name -> xmtp.mls.api.v1.WelcomeMessageInput.V1 - 35, // 3: xmtp.mls.api.v1.WelcomeMessageInput.welcome_pointer:type_name -> xmtp.mls.api.v1.WelcomeMessageInput.WelcomePointer - 36, // 4: xmtp.mls.api.v1.GroupMessage.v1:type_name -> xmtp.mls.api.v1.GroupMessage.V1 - 37, // 5: xmtp.mls.api.v1.GroupMessageInput.v1:type_name -> xmtp.mls.api.v1.GroupMessageInput.V1 - 5, // 6: xmtp.mls.api.v1.SendGroupMessagesRequest.messages:type_name -> xmtp.mls.api.v1.GroupMessageInput - 2, // 7: xmtp.mls.api.v1.SendWelcomeMessagesRequest.messages:type_name -> xmtp.mls.api.v1.WelcomeMessageInput - 8, // 8: xmtp.mls.api.v1.RegisterInstallationRequest.key_package:type_name -> xmtp.mls.api.v1.KeyPackageUpload - 8, // 9: xmtp.mls.api.v1.UploadKeyPackageRequest.key_package:type_name -> xmtp.mls.api.v1.KeyPackageUpload - 38, // 10: xmtp.mls.api.v1.FetchKeyPackagesResponse.key_packages:type_name -> xmtp.mls.api.v1.FetchKeyPackagesResponse.KeyPackage - 46, // 11: xmtp.mls.api.v1.RevokeInstallationRequest.wallet_signature:type_name -> xmtp.message_contents.Signature - 42, // 12: xmtp.mls.api.v1.GetIdentityUpdatesResponse.updates:type_name -> xmtp.mls.api.v1.GetIdentityUpdatesResponse.WalletUpdates + 37, // 0: xmtp.mls.api.v1.WelcomeMessage.v1:type_name -> xmtp.mls.api.v1.WelcomeMessage.V1 + 38, // 1: xmtp.mls.api.v1.WelcomeMessage.welcome_pointer:type_name -> xmtp.mls.api.v1.WelcomeMessage.WelcomePointer + 39, // 2: xmtp.mls.api.v1.WelcomeMessageInput.v1:type_name -> xmtp.mls.api.v1.WelcomeMessageInput.V1 + 40, // 3: xmtp.mls.api.v1.WelcomeMessageInput.welcome_pointer:type_name -> xmtp.mls.api.v1.WelcomeMessageInput.WelcomePointer + 41, // 4: xmtp.mls.api.v1.GroupMessage.v1:type_name -> xmtp.mls.api.v1.GroupMessage.V1 + 42, // 5: xmtp.mls.api.v1.GroupMessageInput.v1:type_name -> xmtp.mls.api.v1.GroupMessageInput.V1 + 6, // 6: xmtp.mls.api.v1.SendGroupMessagesRequest.messages:type_name -> xmtp.mls.api.v1.GroupMessageInput + 3, // 7: xmtp.mls.api.v1.SendWelcomeMessagesRequest.messages:type_name -> xmtp.mls.api.v1.WelcomeMessageInput + 9, // 8: xmtp.mls.api.v1.RegisterInstallationRequest.key_package:type_name -> xmtp.mls.api.v1.KeyPackageUpload + 9, // 9: xmtp.mls.api.v1.UploadKeyPackageRequest.key_package:type_name -> xmtp.mls.api.v1.KeyPackageUpload + 43, // 10: xmtp.mls.api.v1.FetchKeyPackagesResponse.key_packages:type_name -> xmtp.mls.api.v1.FetchKeyPackagesResponse.KeyPackage + 59, // 11: xmtp.mls.api.v1.RevokeInstallationRequest.wallet_signature:type_name -> xmtp.message_contents.Signature + 47, // 12: xmtp.mls.api.v1.GetIdentityUpdatesResponse.updates:type_name -> xmtp.mls.api.v1.GetIdentityUpdatesResponse.WalletUpdates 0, // 13: xmtp.mls.api.v1.PagingInfo.direction:type_name -> xmtp.mls.api.v1.SortDirection - 17, // 14: xmtp.mls.api.v1.QueryGroupMessagesRequest.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo - 4, // 15: xmtp.mls.api.v1.QueryGroupMessagesResponse.messages:type_name -> xmtp.mls.api.v1.GroupMessage - 17, // 16: xmtp.mls.api.v1.QueryGroupMessagesResponse.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo - 17, // 17: xmtp.mls.api.v1.QueryWelcomeMessagesRequest.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo - 1, // 18: xmtp.mls.api.v1.QueryWelcomeMessagesResponse.messages:type_name -> xmtp.mls.api.v1.WelcomeMessage - 17, // 19: xmtp.mls.api.v1.QueryWelcomeMessagesResponse.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo - 43, // 20: xmtp.mls.api.v1.SubscribeGroupMessagesRequest.filters:type_name -> xmtp.mls.api.v1.SubscribeGroupMessagesRequest.Filter - 44, // 21: xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest.filters:type_name -> xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest.Filter - 25, // 22: xmtp.mls.api.v1.BatchPublishCommitLogRequest.requests:type_name -> xmtp.mls.api.v1.PublishCommitLogRequest - 47, // 23: xmtp.mls.api.v1.PublishCommitLogRequest.signature:type_name -> xmtp.identity.associations.RecoverableEd25519Signature - 17, // 24: xmtp.mls.api.v1.QueryCommitLogRequest.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo - 48, // 25: xmtp.mls.api.v1.QueryCommitLogResponse.commit_log_entries:type_name -> xmtp.mls.message_contents.CommitLogEntry - 17, // 26: xmtp.mls.api.v1.QueryCommitLogResponse.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo - 26, // 27: xmtp.mls.api.v1.BatchQueryCommitLogRequest.requests:type_name -> xmtp.mls.api.v1.QueryCommitLogRequest - 27, // 28: xmtp.mls.api.v1.BatchQueryCommitLogResponse.responses:type_name -> xmtp.mls.api.v1.QueryCommitLogResponse - 45, // 29: xmtp.mls.api.v1.GetNewestGroupMessageResponse.responses:type_name -> xmtp.mls.api.v1.GetNewestGroupMessageResponse.Response - 49, // 30: xmtp.mls.api.v1.WelcomeMessage.V1.wrapper_algorithm:type_name -> xmtp.mls.message_contents.WelcomeWrapperAlgorithm - 50, // 31: xmtp.mls.api.v1.WelcomeMessage.WelcomePointer.wrapper_algorithm:type_name -> xmtp.mls.message_contents.WelcomePointerWrapperAlgorithm - 49, // 32: xmtp.mls.api.v1.WelcomeMessageInput.V1.wrapper_algorithm:type_name -> xmtp.mls.message_contents.WelcomeWrapperAlgorithm - 50, // 33: xmtp.mls.api.v1.WelcomeMessageInput.WelcomePointer.wrapper_algorithm:type_name -> xmtp.mls.message_contents.WelcomePointerWrapperAlgorithm - 39, // 34: xmtp.mls.api.v1.GetIdentityUpdatesResponse.Update.new_installation:type_name -> xmtp.mls.api.v1.GetIdentityUpdatesResponse.NewInstallationUpdate - 40, // 35: xmtp.mls.api.v1.GetIdentityUpdatesResponse.Update.revoked_installation:type_name -> xmtp.mls.api.v1.GetIdentityUpdatesResponse.RevokedInstallationUpdate - 41, // 36: xmtp.mls.api.v1.GetIdentityUpdatesResponse.WalletUpdates.updates:type_name -> xmtp.mls.api.v1.GetIdentityUpdatesResponse.Update - 4, // 37: xmtp.mls.api.v1.GetNewestGroupMessageResponse.Response.group_message:type_name -> xmtp.mls.api.v1.GroupMessage - 6, // 38: xmtp.mls.api.v1.MlsApi.SendGroupMessages:input_type -> xmtp.mls.api.v1.SendGroupMessagesRequest - 7, // 39: xmtp.mls.api.v1.MlsApi.SendWelcomeMessages:input_type -> xmtp.mls.api.v1.SendWelcomeMessagesRequest - 9, // 40: xmtp.mls.api.v1.MlsApi.RegisterInstallation:input_type -> xmtp.mls.api.v1.RegisterInstallationRequest - 11, // 41: xmtp.mls.api.v1.MlsApi.UploadKeyPackage:input_type -> xmtp.mls.api.v1.UploadKeyPackageRequest - 12, // 42: xmtp.mls.api.v1.MlsApi.FetchKeyPackages:input_type -> xmtp.mls.api.v1.FetchKeyPackagesRequest - 14, // 43: xmtp.mls.api.v1.MlsApi.RevokeInstallation:input_type -> xmtp.mls.api.v1.RevokeInstallationRequest - 15, // 44: xmtp.mls.api.v1.MlsApi.GetIdentityUpdates:input_type -> xmtp.mls.api.v1.GetIdentityUpdatesRequest - 18, // 45: xmtp.mls.api.v1.MlsApi.QueryGroupMessages:input_type -> xmtp.mls.api.v1.QueryGroupMessagesRequest - 20, // 46: xmtp.mls.api.v1.MlsApi.QueryWelcomeMessages:input_type -> xmtp.mls.api.v1.QueryWelcomeMessagesRequest - 22, // 47: xmtp.mls.api.v1.MlsApi.SubscribeGroupMessages:input_type -> xmtp.mls.api.v1.SubscribeGroupMessagesRequest - 23, // 48: xmtp.mls.api.v1.MlsApi.SubscribeWelcomeMessages:input_type -> xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest - 24, // 49: xmtp.mls.api.v1.MlsApi.BatchPublishCommitLog:input_type -> xmtp.mls.api.v1.BatchPublishCommitLogRequest - 28, // 50: xmtp.mls.api.v1.MlsApi.BatchQueryCommitLog:input_type -> xmtp.mls.api.v1.BatchQueryCommitLogRequest - 30, // 51: xmtp.mls.api.v1.MlsApi.GetNewestGroupMessage:input_type -> xmtp.mls.api.v1.GetNewestGroupMessageRequest - 51, // 52: xmtp.mls.api.v1.MlsApi.SendGroupMessages:output_type -> google.protobuf.Empty - 51, // 53: xmtp.mls.api.v1.MlsApi.SendWelcomeMessages:output_type -> google.protobuf.Empty - 10, // 54: xmtp.mls.api.v1.MlsApi.RegisterInstallation:output_type -> xmtp.mls.api.v1.RegisterInstallationResponse - 51, // 55: xmtp.mls.api.v1.MlsApi.UploadKeyPackage:output_type -> google.protobuf.Empty - 13, // 56: xmtp.mls.api.v1.MlsApi.FetchKeyPackages:output_type -> xmtp.mls.api.v1.FetchKeyPackagesResponse - 51, // 57: xmtp.mls.api.v1.MlsApi.RevokeInstallation:output_type -> google.protobuf.Empty - 16, // 58: xmtp.mls.api.v1.MlsApi.GetIdentityUpdates:output_type -> xmtp.mls.api.v1.GetIdentityUpdatesResponse - 19, // 59: xmtp.mls.api.v1.MlsApi.QueryGroupMessages:output_type -> xmtp.mls.api.v1.QueryGroupMessagesResponse - 21, // 60: xmtp.mls.api.v1.MlsApi.QueryWelcomeMessages:output_type -> xmtp.mls.api.v1.QueryWelcomeMessagesResponse - 4, // 61: xmtp.mls.api.v1.MlsApi.SubscribeGroupMessages:output_type -> xmtp.mls.api.v1.GroupMessage - 1, // 62: xmtp.mls.api.v1.MlsApi.SubscribeWelcomeMessages:output_type -> xmtp.mls.api.v1.WelcomeMessage - 51, // 63: xmtp.mls.api.v1.MlsApi.BatchPublishCommitLog:output_type -> google.protobuf.Empty - 29, // 64: xmtp.mls.api.v1.MlsApi.BatchQueryCommitLog:output_type -> xmtp.mls.api.v1.BatchQueryCommitLogResponse - 31, // 65: xmtp.mls.api.v1.MlsApi.GetNewestGroupMessage:output_type -> xmtp.mls.api.v1.GetNewestGroupMessageResponse - 52, // [52:66] is the sub-list for method output_type - 38, // [38:52] is the sub-list for method input_type - 38, // [38:38] is the sub-list for extension type_name - 38, // [38:38] is the sub-list for extension extendee - 0, // [0:38] is the sub-list for field type_name + 18, // 14: xmtp.mls.api.v1.QueryGroupMessagesRequest.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo + 5, // 15: xmtp.mls.api.v1.QueryGroupMessagesResponse.messages:type_name -> xmtp.mls.api.v1.GroupMessage + 18, // 16: xmtp.mls.api.v1.QueryGroupMessagesResponse.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo + 18, // 17: xmtp.mls.api.v1.QueryWelcomeMessagesRequest.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo + 2, // 18: xmtp.mls.api.v1.QueryWelcomeMessagesResponse.messages:type_name -> xmtp.mls.api.v1.WelcomeMessage + 18, // 19: xmtp.mls.api.v1.QueryWelcomeMessagesResponse.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo + 48, // 20: xmtp.mls.api.v1.SubscribeGroupMessagesRequest.filters:type_name -> xmtp.mls.api.v1.SubscribeGroupMessagesRequest.Filter + 49, // 21: xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest.filters:type_name -> xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest.Filter + 50, // 22: xmtp.mls.api.v1.SubscribeRequest.v1:type_name -> xmtp.mls.api.v1.SubscribeRequest.V1 + 53, // 23: xmtp.mls.api.v1.SubscribeResponse.v1:type_name -> xmtp.mls.api.v1.SubscribeResponse.V1 + 30, // 24: xmtp.mls.api.v1.BatchPublishCommitLogRequest.requests:type_name -> xmtp.mls.api.v1.PublishCommitLogRequest + 60, // 25: xmtp.mls.api.v1.PublishCommitLogRequest.signature:type_name -> xmtp.identity.associations.RecoverableEd25519Signature + 18, // 26: xmtp.mls.api.v1.QueryCommitLogRequest.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo + 61, // 27: xmtp.mls.api.v1.QueryCommitLogResponse.commit_log_entries:type_name -> xmtp.mls.message_contents.CommitLogEntry + 18, // 28: xmtp.mls.api.v1.QueryCommitLogResponse.paging_info:type_name -> xmtp.mls.api.v1.PagingInfo + 31, // 29: xmtp.mls.api.v1.BatchQueryCommitLogRequest.requests:type_name -> xmtp.mls.api.v1.QueryCommitLogRequest + 32, // 30: xmtp.mls.api.v1.BatchQueryCommitLogResponse.responses:type_name -> xmtp.mls.api.v1.QueryCommitLogResponse + 58, // 31: xmtp.mls.api.v1.GetNewestGroupMessageResponse.responses:type_name -> xmtp.mls.api.v1.GetNewestGroupMessageResponse.Response + 62, // 32: xmtp.mls.api.v1.WelcomeMessage.V1.wrapper_algorithm:type_name -> xmtp.mls.message_contents.WelcomeWrapperAlgorithm + 63, // 33: xmtp.mls.api.v1.WelcomeMessage.WelcomePointer.wrapper_algorithm:type_name -> xmtp.mls.message_contents.WelcomePointerWrapperAlgorithm + 62, // 34: xmtp.mls.api.v1.WelcomeMessageInput.V1.wrapper_algorithm:type_name -> xmtp.mls.message_contents.WelcomeWrapperAlgorithm + 63, // 35: xmtp.mls.api.v1.WelcomeMessageInput.WelcomePointer.wrapper_algorithm:type_name -> xmtp.mls.message_contents.WelcomePointerWrapperAlgorithm + 44, // 36: xmtp.mls.api.v1.GetIdentityUpdatesResponse.Update.new_installation:type_name -> xmtp.mls.api.v1.GetIdentityUpdatesResponse.NewInstallationUpdate + 45, // 37: xmtp.mls.api.v1.GetIdentityUpdatesResponse.Update.revoked_installation:type_name -> xmtp.mls.api.v1.GetIdentityUpdatesResponse.RevokedInstallationUpdate + 46, // 38: xmtp.mls.api.v1.GetIdentityUpdatesResponse.WalletUpdates.updates:type_name -> xmtp.mls.api.v1.GetIdentityUpdatesResponse.Update + 51, // 39: xmtp.mls.api.v1.SubscribeRequest.V1.mutate:type_name -> xmtp.mls.api.v1.SubscribeRequest.V1.Mutate + 27, // 40: xmtp.mls.api.v1.SubscribeRequest.V1.ping:type_name -> xmtp.mls.api.v1.Ping + 28, // 41: xmtp.mls.api.v1.SubscribeRequest.V1.pong:type_name -> xmtp.mls.api.v1.Pong + 52, // 42: xmtp.mls.api.v1.SubscribeRequest.V1.Mutate.adds:type_name -> xmtp.mls.api.v1.SubscribeRequest.V1.Mutate.Subscription + 54, // 43: xmtp.mls.api.v1.SubscribeResponse.V1.messages:type_name -> xmtp.mls.api.v1.SubscribeResponse.V1.Messages + 55, // 44: xmtp.mls.api.v1.SubscribeResponse.V1.started:type_name -> xmtp.mls.api.v1.SubscribeResponse.V1.Started + 27, // 45: xmtp.mls.api.v1.SubscribeResponse.V1.ping:type_name -> xmtp.mls.api.v1.Ping + 28, // 46: xmtp.mls.api.v1.SubscribeResponse.V1.pong:type_name -> xmtp.mls.api.v1.Pong + 57, // 47: xmtp.mls.api.v1.SubscribeResponse.V1.topics_live:type_name -> xmtp.mls.api.v1.SubscribeResponse.V1.TopicsLive + 56, // 48: xmtp.mls.api.v1.SubscribeResponse.V1.catchup_complete:type_name -> xmtp.mls.api.v1.SubscribeResponse.V1.CatchupComplete + 5, // 49: xmtp.mls.api.v1.SubscribeResponse.V1.Messages.group_messages:type_name -> xmtp.mls.api.v1.GroupMessage + 2, // 50: xmtp.mls.api.v1.SubscribeResponse.V1.Messages.welcome_messages:type_name -> xmtp.mls.api.v1.WelcomeMessage + 1, // 51: xmtp.mls.api.v1.SubscribeResponse.V1.Started.capabilities:type_name -> xmtp.mls.api.v1.SubscribeResponse.V1.Capability + 5, // 52: xmtp.mls.api.v1.GetNewestGroupMessageResponse.Response.group_message:type_name -> xmtp.mls.api.v1.GroupMessage + 7, // 53: xmtp.mls.api.v1.MlsApi.SendGroupMessages:input_type -> xmtp.mls.api.v1.SendGroupMessagesRequest + 8, // 54: xmtp.mls.api.v1.MlsApi.SendWelcomeMessages:input_type -> xmtp.mls.api.v1.SendWelcomeMessagesRequest + 10, // 55: xmtp.mls.api.v1.MlsApi.RegisterInstallation:input_type -> xmtp.mls.api.v1.RegisterInstallationRequest + 12, // 56: xmtp.mls.api.v1.MlsApi.UploadKeyPackage:input_type -> xmtp.mls.api.v1.UploadKeyPackageRequest + 13, // 57: xmtp.mls.api.v1.MlsApi.FetchKeyPackages:input_type -> xmtp.mls.api.v1.FetchKeyPackagesRequest + 15, // 58: xmtp.mls.api.v1.MlsApi.RevokeInstallation:input_type -> xmtp.mls.api.v1.RevokeInstallationRequest + 16, // 59: xmtp.mls.api.v1.MlsApi.GetIdentityUpdates:input_type -> xmtp.mls.api.v1.GetIdentityUpdatesRequest + 19, // 60: xmtp.mls.api.v1.MlsApi.QueryGroupMessages:input_type -> xmtp.mls.api.v1.QueryGroupMessagesRequest + 21, // 61: xmtp.mls.api.v1.MlsApi.QueryWelcomeMessages:input_type -> xmtp.mls.api.v1.QueryWelcomeMessagesRequest + 23, // 62: xmtp.mls.api.v1.MlsApi.SubscribeGroupMessages:input_type -> xmtp.mls.api.v1.SubscribeGroupMessagesRequest + 24, // 63: xmtp.mls.api.v1.MlsApi.SubscribeWelcomeMessages:input_type -> xmtp.mls.api.v1.SubscribeWelcomeMessagesRequest + 25, // 64: xmtp.mls.api.v1.MlsApi.Subscribe:input_type -> xmtp.mls.api.v1.SubscribeRequest + 29, // 65: xmtp.mls.api.v1.MlsApi.BatchPublishCommitLog:input_type -> xmtp.mls.api.v1.BatchPublishCommitLogRequest + 33, // 66: xmtp.mls.api.v1.MlsApi.BatchQueryCommitLog:input_type -> xmtp.mls.api.v1.BatchQueryCommitLogRequest + 35, // 67: xmtp.mls.api.v1.MlsApi.GetNewestGroupMessage:input_type -> xmtp.mls.api.v1.GetNewestGroupMessageRequest + 64, // 68: xmtp.mls.api.v1.MlsApi.SendGroupMessages:output_type -> google.protobuf.Empty + 64, // 69: xmtp.mls.api.v1.MlsApi.SendWelcomeMessages:output_type -> google.protobuf.Empty + 11, // 70: xmtp.mls.api.v1.MlsApi.RegisterInstallation:output_type -> xmtp.mls.api.v1.RegisterInstallationResponse + 64, // 71: xmtp.mls.api.v1.MlsApi.UploadKeyPackage:output_type -> google.protobuf.Empty + 14, // 72: xmtp.mls.api.v1.MlsApi.FetchKeyPackages:output_type -> xmtp.mls.api.v1.FetchKeyPackagesResponse + 64, // 73: xmtp.mls.api.v1.MlsApi.RevokeInstallation:output_type -> google.protobuf.Empty + 17, // 74: xmtp.mls.api.v1.MlsApi.GetIdentityUpdates:output_type -> xmtp.mls.api.v1.GetIdentityUpdatesResponse + 20, // 75: xmtp.mls.api.v1.MlsApi.QueryGroupMessages:output_type -> xmtp.mls.api.v1.QueryGroupMessagesResponse + 22, // 76: xmtp.mls.api.v1.MlsApi.QueryWelcomeMessages:output_type -> xmtp.mls.api.v1.QueryWelcomeMessagesResponse + 5, // 77: xmtp.mls.api.v1.MlsApi.SubscribeGroupMessages:output_type -> xmtp.mls.api.v1.GroupMessage + 2, // 78: xmtp.mls.api.v1.MlsApi.SubscribeWelcomeMessages:output_type -> xmtp.mls.api.v1.WelcomeMessage + 26, // 79: xmtp.mls.api.v1.MlsApi.Subscribe:output_type -> xmtp.mls.api.v1.SubscribeResponse + 64, // 80: xmtp.mls.api.v1.MlsApi.BatchPublishCommitLog:output_type -> google.protobuf.Empty + 34, // 81: xmtp.mls.api.v1.MlsApi.BatchQueryCommitLog:output_type -> xmtp.mls.api.v1.BatchQueryCommitLogResponse + 36, // 82: xmtp.mls.api.v1.MlsApi.GetNewestGroupMessage:output_type -> xmtp.mls.api.v1.GetNewestGroupMessageResponse + 68, // [68:83] is the sub-list for method output_type + 53, // [53:68] is the sub-list for method input_type + 53, // [53:53] is the sub-list for extension type_name + 53, // [53:53] is the sub-list for extension extendee + 0, // [0:53] is the sub-list for field type_name } func init() { file_mls_api_v1_mls_proto_init() } @@ -3008,18 +3958,37 @@ func file_mls_api_v1_mls_proto_init() { file_mls_api_v1_mls_proto_msgTypes[4].OneofWrappers = []any{ (*GroupMessageInput_V1_)(nil), } - file_mls_api_v1_mls_proto_msgTypes[40].OneofWrappers = []any{ + file_mls_api_v1_mls_proto_msgTypes[23].OneofWrappers = []any{ + (*SubscribeRequest_V1_)(nil), + } + file_mls_api_v1_mls_proto_msgTypes[24].OneofWrappers = []any{ + (*SubscribeResponse_V1_)(nil), + } + file_mls_api_v1_mls_proto_msgTypes[44].OneofWrappers = []any{ (*GetIdentityUpdatesResponse_Update_NewInstallation)(nil), (*GetIdentityUpdatesResponse_Update_RevokedInstallation)(nil), } - file_mls_api_v1_mls_proto_msgTypes[44].OneofWrappers = []any{} + file_mls_api_v1_mls_proto_msgTypes[48].OneofWrappers = []any{ + (*SubscribeRequest_V1_Mutate_)(nil), + (*SubscribeRequest_V1_Ping)(nil), + (*SubscribeRequest_V1_Pong)(nil), + } + file_mls_api_v1_mls_proto_msgTypes[51].OneofWrappers = []any{ + (*SubscribeResponse_V1_Messages_)(nil), + (*SubscribeResponse_V1_Started_)(nil), + (*SubscribeResponse_V1_Ping)(nil), + (*SubscribeResponse_V1_Pong)(nil), + (*SubscribeResponse_V1_TopicsLive_)(nil), + (*SubscribeResponse_V1_CatchupComplete_)(nil), + } + file_mls_api_v1_mls_proto_msgTypes[56].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_mls_api_v1_mls_proto_rawDesc), len(file_mls_api_v1_mls_proto_rawDesc)), - NumEnums: 1, - NumMessages: 45, + NumEnums: 2, + NumMessages: 57, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/proto/mls/api/v1/mls_grpc.pb.go b/pkg/proto/mls/api/v1/mls_grpc.pb.go index dc2e571f..d6e5d775 100644 --- a/pkg/proto/mls/api/v1/mls_grpc.pb.go +++ b/pkg/proto/mls/api/v1/mls_grpc.pb.go @@ -33,6 +33,7 @@ const ( MlsApi_QueryWelcomeMessages_FullMethodName = "/xmtp.mls.api.v1.MlsApi/QueryWelcomeMessages" MlsApi_SubscribeGroupMessages_FullMethodName = "/xmtp.mls.api.v1.MlsApi/SubscribeGroupMessages" MlsApi_SubscribeWelcomeMessages_FullMethodName = "/xmtp.mls.api.v1.MlsApi/SubscribeWelcomeMessages" + MlsApi_Subscribe_FullMethodName = "/xmtp.mls.api.v1.MlsApi/Subscribe" MlsApi_BatchPublishCommitLog_FullMethodName = "/xmtp.mls.api.v1.MlsApi/BatchPublishCommitLog" MlsApi_BatchQueryCommitLog_FullMethodName = "/xmtp.mls.api.v1.MlsApi/BatchQueryCommitLog" MlsApi_GetNewestGroupMessage_FullMethodName = "/xmtp.mls.api.v1.MlsApi/GetNewestGroupMessage" @@ -68,6 +69,11 @@ type MlsApiClient interface { SubscribeGroupMessages(ctx context.Context, in *SubscribeGroupMessagesRequest, opts ...grpc.CallOption) (MlsApi_SubscribeGroupMessagesClient, error) // Subscribe stream of new welcome messages SubscribeWelcomeMessages(ctx context.Context, in *SubscribeWelcomeMessagesRequest, opts ...grpc.CallOption) (MlsApi_SubscribeWelcomeMessagesClient, error) + // Bidirectional subscription (XIP-83). One long-lived stream the client mutates + // in place via add/remove topic deltas, with WebSocket-style liveness ping/pong. + // A single stream MAY carry both group-message and welcome topics. + // gRPC-only: bidirectional streaming has no HTTP/grpc-gateway mapping. + Subscribe(ctx context.Context, opts ...grpc.CallOption) (MlsApi_SubscribeClient, error) BatchPublishCommitLog(ctx context.Context, in *BatchPublishCommitLogRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) BatchQueryCommitLog(ctx context.Context, in *BatchQueryCommitLogRequest, opts ...grpc.CallOption) (*BatchQueryCommitLogResponse, error) GetNewestGroupMessage(ctx context.Context, in *GetNewestGroupMessageRequest, opts ...grpc.CallOption) (*GetNewestGroupMessageResponse, error) @@ -226,6 +232,37 @@ func (x *mlsApiSubscribeWelcomeMessagesClient) Recv() (*WelcomeMessage, error) { return m, nil } +func (c *mlsApiClient) Subscribe(ctx context.Context, opts ...grpc.CallOption) (MlsApi_SubscribeClient, error) { + stream, err := c.cc.NewStream(ctx, &MlsApi_ServiceDesc.Streams[2], MlsApi_Subscribe_FullMethodName, opts...) + if err != nil { + return nil, err + } + x := &mlsApiSubscribeClient{stream} + return x, nil +} + +type MlsApi_SubscribeClient interface { + Send(*SubscribeRequest) error + Recv() (*SubscribeResponse, error) + grpc.ClientStream +} + +type mlsApiSubscribeClient struct { + grpc.ClientStream +} + +func (x *mlsApiSubscribeClient) Send(m *SubscribeRequest) error { + return x.ClientStream.SendMsg(m) +} + +func (x *mlsApiSubscribeClient) Recv() (*SubscribeResponse, error) { + m := new(SubscribeResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + func (c *mlsApiClient) BatchPublishCommitLog(ctx context.Context, in *BatchPublishCommitLogRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { out := new(emptypb.Empty) err := c.cc.Invoke(ctx, MlsApi_BatchPublishCommitLog_FullMethodName, in, out, opts...) @@ -283,6 +320,11 @@ type MlsApiServer interface { SubscribeGroupMessages(*SubscribeGroupMessagesRequest, MlsApi_SubscribeGroupMessagesServer) error // Subscribe stream of new welcome messages SubscribeWelcomeMessages(*SubscribeWelcomeMessagesRequest, MlsApi_SubscribeWelcomeMessagesServer) error + // Bidirectional subscription (XIP-83). One long-lived stream the client mutates + // in place via add/remove topic deltas, with WebSocket-style liveness ping/pong. + // A single stream MAY carry both group-message and welcome topics. + // gRPC-only: bidirectional streaming has no HTTP/grpc-gateway mapping. + Subscribe(MlsApi_SubscribeServer) error BatchPublishCommitLog(context.Context, *BatchPublishCommitLogRequest) (*emptypb.Empty, error) BatchQueryCommitLog(context.Context, *BatchQueryCommitLogRequest) (*BatchQueryCommitLogResponse, error) GetNewestGroupMessage(context.Context, *GetNewestGroupMessageRequest) (*GetNewestGroupMessageResponse, error) @@ -326,6 +368,9 @@ func (UnimplementedMlsApiServer) SubscribeGroupMessages(*SubscribeGroupMessagesR func (UnimplementedMlsApiServer) SubscribeWelcomeMessages(*SubscribeWelcomeMessagesRequest, MlsApi_SubscribeWelcomeMessagesServer) error { return status.Errorf(codes.Unimplemented, "method SubscribeWelcomeMessages not implemented") } +func (UnimplementedMlsApiServer) Subscribe(MlsApi_SubscribeServer) error { + return status.Errorf(codes.Unimplemented, "method Subscribe not implemented") +} func (UnimplementedMlsApiServer) BatchPublishCommitLog(context.Context, *BatchPublishCommitLogRequest) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method BatchPublishCommitLog not implemented") } @@ -552,6 +597,32 @@ func (x *mlsApiSubscribeWelcomeMessagesServer) Send(m *WelcomeMessage) error { return x.ServerStream.SendMsg(m) } +func _MlsApi_Subscribe_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(MlsApiServer).Subscribe(&mlsApiSubscribeServer{stream}) +} + +type MlsApi_SubscribeServer interface { + Send(*SubscribeResponse) error + Recv() (*SubscribeRequest, error) + grpc.ServerStream +} + +type mlsApiSubscribeServer struct { + grpc.ServerStream +} + +func (x *mlsApiSubscribeServer) Send(m *SubscribeResponse) error { + return x.ServerStream.SendMsg(m) +} + +func (x *mlsApiSubscribeServer) Recv() (*SubscribeRequest, error) { + m := new(SubscribeRequest) + if err := x.ServerStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + func _MlsApi_BatchPublishCommitLog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(BatchPublishCommitLogRequest) if err := dec(in); err != nil { @@ -673,6 +744,12 @@ var MlsApi_ServiceDesc = grpc.ServiceDesc{ Handler: _MlsApi_SubscribeWelcomeMessages_Handler, ServerStreams: true, }, + { + StreamName: "Subscribe", + Handler: _MlsApi_Subscribe_Handler, + ServerStreams: true, + ClientStreams: true, + }, }, Metadata: "mls/api/v1/mls.proto", } diff --git a/pkg/subscriptions/dispatcher.go b/pkg/subscriptions/dispatcher.go index e21fe34c..c0f4a9a4 100644 --- a/pkg/subscriptions/dispatcher.go +++ b/pkg/subscriptions/dispatcher.go @@ -127,6 +127,26 @@ func (d *SubscriptionDispatcher) Subscribe(topics map[string]bool) *Subscription return sub } +// NewSubscription creates an empty subscription whose topic set is meant to be grown +// and shrunk in place via (*Subscription).Add and Remove (XIP-83 mutate-in-place). +// bufferSize is the message-channel backlog, clamped to a minimum. Unlike Subscribe it +// does not size the buffer from the topic count (the set changes over the stream's +// life), so size it for the connection's throughput. +func (d *SubscriptionDispatcher) NewSubscription(bufferSize int) *Subscription { + if bufferSize < minBacklogBufferLength { + bufferSize = minBacklogBufferLength + } + sub := &Subscription{ + dispatcher: d, + topics: make(map[string]bool), + MessagesCh: make(chan *proto.Envelope, bufferSize), + } + d.mu.Lock() + defer d.mu.Unlock() + d.subscriptions[sub] = true + return sub +} + func isValidSubscribeAllTopic(contentTopic string) bool { return strings.HasPrefix(contentTopic, v2TopicPrefix) || topic.IsMLSV1(contentTopic) } diff --git a/pkg/subscriptions/subscription.go b/pkg/subscriptions/subscription.go index 788b4239..d462469a 100644 --- a/pkg/subscriptions/subscription.go +++ b/pkg/subscriptions/subscription.go @@ -16,3 +16,30 @@ func (sub *Subscription) Unsubscribe() { defer sub.dispatcher.mu.Unlock() delete(sub.dispatcher.subscriptions, sub) } + +// Add grows the live topic set in place (XIP-83 mutate-in-place). It is O(len(topics)) +// and safe to call concurrently with dispatch: HandleEnvelope reads sub.topics while +// holding the dispatcher mutex, so mutating under the same lock serializes the two. +// A no-op on a wildcard ("all") subscription. +func (sub *Subscription) Add(topics ...string) { + sub.dispatcher.mu.Lock() + defer sub.dispatcher.mu.Unlock() + if sub.all { + return + } + if sub.topics == nil { + sub.topics = make(map[string]bool, len(topics)) + } + for _, t := range topics { + sub.topics[t] = true + } +} + +// Remove shrinks the live topic set in place. See Add for concurrency notes. +func (sub *Subscription) Remove(topics ...string) { + sub.dispatcher.mu.Lock() + defer sub.dispatcher.mu.Unlock() + for _, t := range topics { + delete(sub.topics, t) + } +}