From ed2d5a09455e7a71ba47894facbc792050f7c99b Mon Sep 17 00:00:00 2001 From: Tyler Hawkes Date: Wed, 17 Jun 2026 16:27:00 -0600 Subject: [PATCH 1/3] feat(api): add mutableSubscription handle to subscribeWorker for in-place topic add/remove Co-Authored-By: Claude Fable 5 --- pkg/api/message/listener.go | 10 +++- pkg/api/message/mutable_subscription.go | 80 +++++++++++++++++++++++++ pkg/api/message/subscribe_worker.go | 22 ++++++- 3 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 pkg/api/message/mutable_subscription.go diff --git a/pkg/api/message/listener.go b/pkg/api/message/listener.go index d92d2ae23..bece10cd7 100644 --- a/pkg/api/message/listener.go +++ b/pkg/api/message/listener.go @@ -11,8 +11,12 @@ import ( ) type listener struct { - ctx context.Context - ch chan<- []*envelopes.OriginatorEnvelope + ctx context.Context + ch chan<- []*envelopes.OriginatorEnvelope + // topicsMu guards topics, which is fixed for a server-stream listener but mutated in + // place by a mutableSubscription (the XIP-83 bidi Subscribe). The worker reads it when + // reaping the listener (closeListener), so concurrent mutation needs the lock. + topicsMu sync.Mutex closed bool topics map[string]struct{} originators map[uint32]struct{} @@ -101,7 +105,7 @@ func (lm *listenersMap[K]) removeListener(keys map[K]struct{}, l *listener) { for key := range keys { value, ok := lm.data.Load(key) if !ok || value == nil { - return // Key doesn't exist, nothing to do + continue // This key is already gone (e.g. reaped by the worker); keep unregistering the rest } set := value.(*listenerSet) set.removeListener(l) diff --git a/pkg/api/message/mutable_subscription.go b/pkg/api/message/mutable_subscription.go new file mode 100644 index 000000000..45f753b3a --- /dev/null +++ b/pkg/api/message/mutable_subscription.go @@ -0,0 +1,80 @@ +package message + +import ( + "context" + + "github.com/xmtp/xmtpd/pkg/envelopes" +) + +// mutableSubscription is an in-place-mutable topic subscription on the subscribeWorker, used by +// the XIP-83 bidirectional Subscribe RPC. Unlike subscribeWorker.listen (a fixed query), its +// topic set is grown and shrunk over the life of the stream via addTopics / removeTopics. It is +// never global: an empty topic set delivers nothing (a fresh stream that has subscribed to +// nothing yet), in contrast to newListener, which treats an empty query as "all envelopes". +// +// All mutation methods are intended to be called from the single owning goroutine (the Subscribe +// handler's writer loop). The worker's reap path (closeListener) may also touch the underlying +// listener's topic set concurrently; both sides take listener.topicsMu, so that is safe. +type mutableSubscription struct { + worker *subscribeWorker + l *listener + // ch is the receive end of the listener channel. The worker pushes envelope batches here; + // it CLOSES the channel when it reaps the listener (ctx done or the consumer fell behind), + // which the writer loop observes as "torn down, reconnect from cursors". + ch <-chan []*envelopes.OriginatorEnvelope +} + +// newMutableSubscription registers an empty, non-global topic subscription on the worker and +// returns a handle the caller mutates over the stream's lifetime. Call close when done. +func (s *subscribeWorker) newMutableSubscription(ctx context.Context) *mutableSubscription { + ch := make(chan []*envelopes.OriginatorEnvelope, subscriptionBufferSize) + l := &listener{ + ctx: ctx, + ch: ch, + topics: make(map[string]struct{}), + originators: make(map[uint32]struct{}), + isGlobal: false, // empty topic set => delivers nothing, NOT all-envelopes + } + return &mutableSubscription{worker: s, l: l, ch: ch} +} + +// addTopics begins delivering the given topic keys to this subscription. Idempotent: a key +// already subscribed is a no-op. A no-op once the worker has reaped the listener. +func (m *mutableSubscription) addTopics(keys map[string]struct{}) { + if len(keys) == 0 { + return + } + m.l.topicsMu.Lock() + defer m.l.topicsMu.Unlock() + if m.l.closed { + return + } + m.worker.topicListeners.addListener(keys, m.l) + for k := range keys { + m.l.topics[k] = struct{}{} + } +} + +// removeTopics stops delivering the given topic keys. Idempotent. +func (m *mutableSubscription) removeTopics(keys map[string]struct{}) { + if len(keys) == 0 { + return + } + m.l.topicsMu.Lock() + defer m.l.topicsMu.Unlock() + m.worker.topicListeners.removeListener(keys, m.l) + for k := range keys { + delete(m.l.topics, k) + } +} + +// close unregisters the subscription from every topic it still holds. Safe to call even after +// the worker has already reaped the listener (removeListener is idempotent). +func (m *mutableSubscription) close() { + m.l.topicsMu.Lock() + defer m.l.topicsMu.Unlock() + if len(m.l.topics) > 0 { + m.worker.topicListeners.removeListener(m.l.topics, m.l) + m.l.topics = make(map[string]struct{}) + } +} diff --git a/pkg/api/message/subscribe_worker.go b/pkg/api/message/subscribe_worker.go index 2382e06e1..f2e0e7f11 100644 --- a/pkg/api/message/subscribe_worker.go +++ b/pkg/api/message/subscribe_worker.go @@ -292,16 +292,32 @@ func (s *subscribeWorker) dispatchToListeners( } func (s *subscribeWorker) closeListener(l *listener) { - // Assumption: this method may not be called across multiple goroutines + // Assumption: this method may not be called across multiple goroutines (worker only). + // closed is set under topicsMu so a concurrent mutableSubscription.addTopics observes it + // atomically with its own addListener call. Otherwise a stale closed==false read could + // re-register the listener in topicListeners AFTER its channel is closed here — leaking it + // (dispatch skips closed listeners but never removes them) or, on a torn read, sending on the + // closed channel. + l.topicsMu.Lock() l.closed = true + l.topicsMu.Unlock() close(l.ch) go func() { if l.isGlobal { s.globalListeners.Delete(l) - } else if len(l.topics) > 0 { + return + } + // topics may have been mutated by a mutableSubscription right up until we set closed + // above; read it under the lock so we remove exactly what is still registered. Lock order + // is always topicsMu -> listenersMap.mu (mutableSubscription does the same), so this + // cannot deadlock. + l.topicsMu.Lock() + if len(l.topics) > 0 { s.topicListeners.removeListener(l.topics, l) - } else if len(l.originators) > 0 { + } + l.topicsMu.Unlock() + if len(l.originators) > 0 { s.originatorListeners.removeListener(l.originators, l) } }() From 29658204cd5f7d948482ec16c41181f692a0e268 Mon Sep 17 00:00:00 2001 From: Tyler Hawkes Date: Tue, 16 Jun 2026 22:34:11 -0600 Subject: [PATCH 2/3] feat(api): XIP-83 bidirectional Subscribe handler on QueryApi + xmtpv4 protos Co-Authored-By: Claude Fable 5 --- pkg/api/message/subscribe.go | 1100 +++++++++++++++ pkg/api/message/subscribe_topics.go | 19 + pkg/proto/openapi/mls/api/v1/mls.swagger.json | 242 ++-- .../xmtpv4/message_api/query_api.swagger.json | 182 +++ .../xmtpv4/message_api/message_api.pb.go | 1217 +++++++++++++++-- .../message_apiconnect/query_api.connect.go | 38 + pkg/proto/xmtpv4/message_api/query_api.pb.go | 37 +- .../xmtpv4/message_api/query_api_grpc.pb.go | 42 + 8 files changed, 2607 insertions(+), 270 deletions(-) create mode 100644 pkg/api/message/subscribe.go diff --git a/pkg/api/message/subscribe.go b/pkg/api/message/subscribe.go new file mode 100644 index 000000000..1780f8d24 --- /dev/null +++ b/pkg/api/message/subscribe.go @@ -0,0 +1,1100 @@ +package message + +import ( + "context" + "errors" + "fmt" + "io" + "maps" + "math" + "time" + + "connectrpc.com/connect" + "go.uber.org/zap" + "google.golang.org/protobuf/proto" + + "github.com/xmtp/xmtpd/pkg/db" + "github.com/xmtp/xmtpd/pkg/envelopes" + envelopesProto "github.com/xmtp/xmtpd/pkg/proto/xmtpv4/envelopes" + "github.com/xmtp/xmtpd/pkg/proto/xmtpv4/message_api" + "github.com/xmtp/xmtpd/pkg/topic" + "github.com/xmtp/xmtpd/pkg/utils" +) + +const ( + // subscribeSendQueueDepth buffers response batches between the writer and the sender + // goroutine, so a slow stream.Send never parks the writer (which must stay free for the + // liveness ping/pong reap). Small: it only smooths Send latency. + subscribeSendQueueDepth = 8 + // subscribeCatchUpQueueDepth buffers catch-up pages from the fetcher to the writer. + subscribeCatchUpQueueDepth = 16 + // maxActiveSubscribeTopics caps the live topic set a single stream may hold. Intentionally + // high: a herald multiplexing many identities wants one fat connection, not a fan-out across + // connections (which would just hit rate limits). At ~hundreds of bytes/topic this is a + // large-but-bounded budget; exceeding it fails the Mutate with ResourceExhausted. + maxActiveSubscribeTopics = 1_000_000 + // maxInflightSubscribeWaves caps the concurrent catch-up waves one stream may have running. Each + // wave is a detached fetcher goroutine plus paginated DB queries, and maxActiveSubscribeTopics + // does NOT bound it: a remove+re-add (reset) leaves the old wave running while the active-topic + // count stays flat, so unbounded reset churn could otherwise pile up fetchers against the shared + // DB pool. A Mutate that would start a wave past this limit is rejected with ResourceExhausted + // (retryable once in-flight catch-ups drain); a well-behaved client batches adds into few Mutates. + maxInflightSubscribeWaves = 256 + // maxSubscribePendingBytes caps live envelopes buffered while a topic catches up. Exceeding + // it tears the stream down (the client reconnects from its cursors) rather than risk OOM. + maxSubscribePendingBytes = 64 << 20 // 64 MiB + // maxSubscribeFrameBytes bounds a single Envelopes response frame. A catch-up page or a flushed + // pending buffer can be large; splitting keeps every frame well under the gRPC message limit so + // the client can receive it (an unsplit multi-MB frame would abort the stream). + maxSubscribeFrameBytes = 2 << 20 // 2 MiB +) + +// subscribeTopic is the immutable identity of one topic in a stream's subscription. cursorKey +// (string of the raw topic bytes) keys the per-topic state and the catch-up query; listenKey (the +// parsed topic string) keys the worker's dispatch registration; wire is the client's topic bytes, +// echoed in TopicsLive. +type subscribeTopic struct { + wire []byte + cursorKey string + listenKey string +} + +// topicPhase is a live topic's position in its gated -> live lifecycle. +type topicPhase uint8 + +const ( + // topicGated: a catch-up wave is replaying this topic's history; its live envelopes are buffered + // in pending and withheld until the owning wave opens the gate. + topicGated topicPhase = iota + // topicLive: caught up; live envelopes are deduped against cursor and delivered immediately. + topicLive +) + +// topicState is the single source of truth for one LIVE topic on a stream: its phase, the wave that +// owns it while gated, its sparse dedup cursor, and the live envelopes buffered while it catches up. +// Collapsing what were four parallel maps (catchingUp, cursors, liveTopics, pending) into one struct +// per topic means a transition cannot advance one aspect and forget another — every mutation goes +// through a session method (gateTopic / bufferLive / flushAndGoLive / removeTopicState) that moves +// the whole struct together. history_only topics never become live, so they are NOT represented +// here; their throwaway dedup cursors live on the wave instead. Owned solely by the writer +// goroutine: no locking. +type topicState struct { + subscribeTopic + phase topicPhase + wave int // the wave catching this topic up while topicGated + cursor db.VectorClock // sparse live cursor: provided floor, grown as originators are seen + pending []*envelopes.OriginatorEnvelope // live envelopes held while topicGated +} + +// subscribeWave tracks one Mutate's catch-up. Its CatchupComplete (echoing mutate_id) is emitted +// once the fetcher signals done, after the wave's TopicsLive. cursors is non-nil only for a +// history_only wave (its throwaway per-topic dedup cursors); a live wave dedups against each topic's +// own live cursor instead. +type subscribeWave struct { + mutateID uint64 + historyOnly bool + topics []subscribeTopic + cursors db.TopicCursors // history_only only: throwaway dedup cursors +} + +// catchUpBatch is one unit handed from a fetcher goroutine to the writer: a page of raw history +// (the writer dedups + sends), a done marker (the wave finished), or an error (tear down). +type catchUpBatch struct { + wave int + envs []*envelopes.OriginatorEnvelope + done bool + err error +} + +// Subscribe is the XIP-83 bidirectional mutable subscription (the decentralized binding; see the +// v3 MlsApi.Subscribe for the other). One long-lived stream the client mutates in place via +// add/remove topic deltas (no reconnect on membership change), with a WebSocket-style ping/pong so +// silent stream death is detected on both ends. In contrast to SubscribeTopics (a fixed, +// server-streaming filter set with a one-way WAITING heartbeat), this RPC is bidirectional. +// +// Concurrency model: SINGLE WRITER. The select loop is the sole owner of all mutable state (the +// per-topic vector cursors, 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 client that stops reading can +// never park the writer. The frame reader, the live envelope worker, and the catch-up fetchers +// are pure producers. Catch-up runs OFF the writer (in a fetcher goroutine) so a slow new topic +// never holds up live delivery for already-live topics; its live envelopes are gated in a pending +// buffer and flushed (deduped) when its catch-up completes. +func (s *Service) Subscribe( + ctx context.Context, + stream *connect.BidiStream[message_api.SubscribeRequest, message_api.SubscribeResponse], +) error { + logger := s.logger.With(utils.MethodField(stream.Spec().Procedure)) + keepAlive := s.options.SendKeepAliveInterval + + // connect-go does NOT cancel the stream context when the handler returns, so derive a cancelable + // child and cancel it on every return path. That tears down the catch-up fetchers and the + // worker's listener promptly instead of leaking them (a fetcher parked on a full catchUpCh, or + // the listener registration) until the outer ServeHTTP unwinds. + streamCtx, cancel := context.WithCancel(ctx) + defer cancel() + + sess := &subscribeSession{ + svc: s, + logger: logger, + ctx: streamCtx, + keepAlive: keepAlive, + sub: s.subscribeWorker.newMutableSubscription(streamCtx), + outbound: make(chan *message_api.SubscribeResponse, subscribeSendQueueDepth), + senderDone: make(chan struct{}), + catchUpCh: make(chan catchUpBatch, subscribeCatchUpQueueDepth), + topics: make(map[string]*topicState), + waves: make(map[int]*subscribeWave), + } + defer sess.sub.close() + + // Sender goroutine: the SOLE caller of stream.Send, fed an ordered channel. + go func() { + var err error + for resp := range sess.outbound { + if sendErr := stream.Send(resp); sendErr != nil { + err = sendErr + break + } + } + // Record the terminal status, THEN signal done. The write happens-before the close, and + // every reader reads sendErr only after observing senderDone closed, so this is race-free. + sess.sendErr = err + close(sess.senderDone) + }() + // On every return path, try to stop the sender before connect finalizes the stream: a stream.Send + // racing connect's stream Close both flush the shared HTTP/2 response writer. Graceful paths + // already wait via flush(); this backstops error/reap returns. The wait is BOUNDED, and that bound + // is a deliberate tradeoff: connect's stream.Send is not cancelable, so a sender wedged inside + // Send on a non-reading client (its HTTP/2 window full) cannot be unblocked from here. We accept a + // narrow residual window where Close may run concurrently with that wedged Send (a -race report on + // a connection that is being torn down regardless) rather than hang teardown forever waiting on a + // sender that may never return. Closing outbound only stops the NEXT send, not one already parked. + defer func() { + sess.closeOutbound() + select { + case <-sess.senderDone: + case <-time.After(sess.keepAlive): + } + }() + + // Frame reader goroutine: pure producer of client frames. + requestCh := make(chan *message_api.SubscribeRequest, 16) + go func() { + defer close(requestCh) + for { + req, err := stream.Receive() + if err != nil { + if !errors.Is(err, io.EOF) { + sess.recvErr = err + } + return + } + select { + case requestCh <- req: + case <-streamCtx.Done(): + return + } + } + }() + + if err := sess.send(newSubscribeStarted(uint32(keepAlive.Milliseconds()))); err != nil { + return err + } + + // Liveness uses two INDEPENDENT timers. pingTicker is the send-idle ping cadence — outbound + // delivery resets it, so a stream actively receiving data is not pinged. pongDeadline is the reap + // deadline, armed ONLY when a Ping is sent and disarmed ONLY by a matching Pong, so steady + // outbound traffic can never postpone it. (A single shared ticker let ordinary delivery keep + // resetting the reap, defeating silent-death detection.) A busy-but-dead client is still caught: + // its unread window fills and the sender's send() trips its own keepAlive stall timeout. + pingTicker := time.NewTicker(keepAlive) + defer pingTicker.Stop() + sess.pingTicker = pingTicker + pongDeadline := time.NewTimer(keepAlive) + stopTimer(pongDeadline) // not armed until a Ping is outstanding + defer pongDeadline.Stop() + requestChannel := requestCh // nilled on half-close so the case goes dormant + + for { + select { + case batch, open := <-sess.sub.ch: + if !open { + // The worker reaped us (ctx done, or we fell behind: channel full). The stream + // cannot continue in order; fail so the client reconnects from its cursors. + return connect.NewError( + connect.CodeAborted, + errors.New("subscription closed: consumer too slow"), + ) + } + if err := sess.routeLive(batch); err != nil { + return err + } + + case b := <-sess.catchUpCh: + done, err := sess.handleCatchUp(b) + if err != nil { + return err + } + if done { + return sess.flush() + } + + case req, open := <-requestChannel: + if !open { + if sess.recvErr != nil { + return connect.NewError( + connect.CodeUnavailable, + fmt.Errorf("stream recv failed: %w", sess.recvErr), + ) + } + // Clean half-close (io.EOF): finish in-flight catch-up waves, then close. If + // nothing is in flight, drain and return now. + if len(sess.waves) == 0 { + return sess.flush() + } + sess.halfClosed = true + requestChannel = nil // dormant: a closed channel would busy-loop this case + continue + } + wasAwaitingPong := sess.awaitingPong + if err := sess.handleRequest(req); err != nil { + return err + } + if wasAwaitingPong && !sess.awaitingPong { + stopTimer(pongDeadline) // a matching Pong arrived; disarm the reap deadline + } + + case <-sess.senderDone: + // The sender exited while we are still producing — only possible on a Send error, since + // outbound is closed solely at teardown. Surface that error (sendErr is set before the + // close we just observed). + return sess.sendErr + + case <-pingTicker.C: + // Ping only when idle, not already awaiting a Pong, and not half-closed (the client's + // read side is gone — it cannot answer; we are only draining in-flight waves). + if !sess.awaitingPong && !sess.halfClosed { + sess.pingNonce++ + sess.awaitingPong = true + pongDeadline.Reset(keepAlive) + if err := sess.send(newSubscribePing(sess.pingNonce)); err != nil { + return err + } + } + + case <-pongDeadline.C: + // The reap deadline fired. A Pong may be sitting in requestChannel right as it fired; + // process queued frames first so select fairness can't reap a stream that did answer. + if err := sess.drainPendingRequests(requestChannel); err != nil { + return err + } + if sess.awaitingPong && !sess.halfClosed { + return connect.NewError( + connect.CodeDeadlineExceeded, + errors.New("no Pong within deadline"), + ) + } + + case <-streamCtx.Done(): + return nil + + case <-s.ctx.Done(): + return connect.NewError(connect.CodeUnavailable, errors.New("service is shutting down")) + } + } +} + +// subscribeSession holds one stream's writer-owned state. All methods run on the writer goroutine, +// so there are no locks; the sender, reader, and fetcher goroutines communicate only via channels. +type subscribeSession struct { + svc *Service + logger *zap.Logger + ctx context.Context + keepAlive time.Duration + sub *mutableSubscription + // pingTicker is the send-idle ping cadence; send() resets it on every frame actually delivered, + // so the cadence tracks real outbound bytes (not internal events that produce no output, e.g. a + // fully-gated catch-up page). nil in white-box unit tests that drive session methods directly. + pingTicker *time.Ticker + // sendTimer bounds a single send() (reused instead of allocating time.After per call); lazily + // created on first send so it works in white-box tests too. + sendTimer *time.Timer + // maxFrameBytes overrides maxSubscribeFrameBytes when > 0 (tests only). + maxFrameBytes int + + outbound chan *message_api.SubscribeResponse + // senderDone is closed exactly once, when the sender goroutine exits; sendErr is its terminal + // status (nil = every queued frame was sent, non-nil = the Send error that stopped it). Together + // they are the sender's single result contract: every reader (send, flush, the writer loop) + // learns the outcome by observing senderDone closed and then reading sendErr — a broadcast, so + // no reader can consume the signal out from under another (the old buffered errCh could). + senderDone chan struct{} + sendErr error + outClosed bool + catchUpCh chan catchUpBatch + recvErr error + + // topics is every LIVE topic on this stream, keyed by cursorKey, each a small gated->live state + // machine (see topicState). It replaces the former catchingUp / cursors / liveTopics / pending + // maps so per-topic state can only move as a unit. Capped at maxActiveSubscribeTopics. + topics map[string]*topicState + pendingBytes int // sum of proto sizes buffered across every topic's pending; bounded + waves map[int]*subscribeWave + nextWave int + + awaitingPong bool + pingNonce uint64 + halfClosed bool +} + +func (sess *subscribeSession) closeOutbound() { + if !sess.outClosed { + sess.outClosed = true + close(sess.outbound) + } +} + +// flush drains queued frames before a GRACEFUL completion so no tail is lost. Bounded by the +// keepalive deadline / ctx; if that bound trips the drain did NOT finish, so it returns +// DeadlineExceeded rather than a false OK (the bug the v3 binding's flush originally had). +func (sess *subscribeSession) flush() error { + sess.closeOutbound() + select { + case <-sess.senderDone: + // Drained. sendErr is the sender's terminal status: nil if every queued frame went out, or + // the Send error that stopped it early (leaving the wave's terminal TopicsLive/CatchupComplete + // unsent) — surfaced rather than returned as a false OK. + return sess.sendErr + case <-sess.ctx.Done(): + // ctx fired — but the sender may have drained in the same instant (select is pseudorandom + // when several cases are ready). Prefer a completed drain so a clean graceful close is never + // reported as a spurious cancellation. Otherwise the stream was torn down before the drain + // finished, so the wave's terminal TopicsLive/CatchupComplete may be unsent — surface that. + if sess.senderDrained() { + return sess.sendErr + } + return connect.NewError( + connect.CodeCanceled, + fmt.Errorf("flush interrupted before drain completed: %w", sess.ctx.Err()), + ) + case <-time.After(sess.keepAlive): + // Same priority: a drain that finished exactly as the deadline tripped is a success, not a + // timeout. + if sess.senderDrained() { + return sess.sendErr + } + return connect.NewError( + connect.CodeDeadlineExceeded, + errors.New("flush timed out waiting for sender to drain"), + ) + } +} + +// senderDrained reports (without blocking) whether the sender goroutine has exited. After it returns +// true, sendErr is safe to read — the sender writes sendErr before closing senderDone. +func (sess *subscribeSession) senderDrained() bool { + select { + case <-sess.senderDone: + return true + default: + return false + } +} + +// send hands one frame to the sender. It never blocks the writer indefinitely: if the sender is +// wedged on a non-reading client and the buffer fills, it fails the stream after the keepalive. It +// runs only on the writer goroutine, so the reused sendTimer needs no synchronization. +func (sess *subscribeSession) send(resp *message_api.SubscribeResponse) error { + // A reused per-session timer rather than time.After per call (which would leak a timer for the + // whole keepalive interval on every send — costly under high throughput / frame-splitting). + if sess.sendTimer == nil { + sess.sendTimer = time.NewTimer(sess.keepAlive) + } else { + sess.sendTimer.Reset(sess.keepAlive) + } + defer stopTimer(sess.sendTimer) + + select { + case sess.outbound <- resp: + // A frame was actually enqueued for delivery; defer the next idle Ping a full interval so + // the ping/pong cadence tracks real outbound traffic. + if sess.pingTicker != nil { + sess.pingTicker.Reset(sess.keepAlive) + } + return nil + case <-sess.senderDone: + // The sender died (Send error) — stop feeding it; surface the error it recorded. + return sess.sendErr + case <-sess.ctx.Done(): + return nil + case <-sess.svc.ctx.Done(): + return connect.NewError(connect.CodeUnavailable, errors.New("service is shutting down")) + case <-sess.sendTimer.C: + return connect.NewError( + connect.CodeUnavailable, + errors.New("send stalled; client not reading"), + ) + } +} + +// sendEnvelopes delivers envelopes split into frames each under maxSubscribeFrameBytes, so a large +// catch-up page or flushed pending buffer never goes out as one oversized (stream-aborting) frame. +func (sess *subscribeSession) sendEnvelopes(envs []*envelopesProto.OriginatorEnvelope) error { + if len(envs) == 0 { + return nil + } + limit := maxSubscribeFrameBytes + if sess.maxFrameBytes > 0 { + limit = sess.maxFrameBytes + } + var frame []*envelopesProto.OriginatorEnvelope + frameBytes := 0 + flush := func() error { + if len(frame) == 0 { + return nil + } + if err := sess.send(newSubscribeEnvelopes(frame)); err != nil { + return err + } + frame = nil // do NOT reuse the backing array; the sent frame still references it + frameBytes = 0 + return nil + } + for _, env := range envs { + size := proto.Size(env) + // An envelope larger than `limit` on its own is NOT dropped: limit is a soft batching + // target (2 MiB), an order of magnitude under the transport's hard cap (GRPCPayloadLimit, + // 25 MiB). Such an envelope simply flushes the current frame and then goes out alone — and + // it always fits, because it was publishable under that same 25 MiB cap. Skipping it (as the + // old batchAndSendEnvelopes did) would silently lose a valid, deliverable message. + if len(frame) > 0 && frameBytes+size > limit { + if err := flush(); err != nil { + return err + } + } + frame = append(frame, env) + frameBytes += size + } + return flush() +} + +// routeLive splits a live batch: envelopes for a topic still catching up are buffered (gated) so +// they cannot overtake that topic's history; the rest are deduped against the live cursor and sent. +func (sess *subscribeSession) routeLive(batch []*envelopes.OriginatorEnvelope) error { + var toSend []*envelopes.OriginatorEnvelope + for _, env := range batch { + ts := sess.topics[string(env.TargetTopic().Bytes())] + if ts != nil && ts.phase == topicGated { + if err := sess.bufferLive(ts, env); err != nil { + return err + } + continue + } + // Live, or not (or no longer) ours: advanceLive dedups the live topics and drops the rest. + toSend = append(toSend, env) + } + return sess.sendEnvelopes(sess.advanceLive(toSend)) +} + +// bufferLive holds a live envelope for a gated topic until its wave opens the gate, enforcing the +// global pending-bytes budget. Only valid while ts.phase == topicGated. +func (sess *subscribeSession) bufferLive(ts *topicState, env *envelopes.OriginatorEnvelope) error { + ts.pending = append(ts.pending, env) + sess.pendingBytes += proto.Size(env.Proto()) + if sess.pendingBytes > maxSubscribePendingBytes { + return connect.NewError( + connect.CodeResourceExhausted, + errors.New("pending buffer exceeded while catching up"), + ) + } + return nil +} + +// advanceLive dedups envs against their topics' live cursors, advancing each cursor in place, and +// returns the proto envelopes ready to send. An envelope for a topic that is not (or no longer) live +// is dropped — the per-topic analogue of advanceTopicCursors, reading the cursor from topicState. +func (sess *subscribeSession) advanceLive( + envs []*envelopes.OriginatorEnvelope, +) []*envelopesProto.OriginatorEnvelope { + result := make([]*envelopesProto.OriginatorEnvelope, 0, len(envs)) + for _, env := range envs { + ts := sess.topics[string(env.TargetTopic().Bytes())] + if ts == nil { + sess.logger.Warn( + "received envelope for unsubscribed topic", + zap.Binary("topic", env.TargetTopic().Bytes()), + ) + continue + } + origID := uint32(env.OriginatorNodeID()) + seqID := env.OriginatorSequenceID() + if last, seen := ts.cursor[origID]; seen && last >= seqID { + continue + } + ts.cursor[origID] = seqID + result = append(result, env.Proto()) + } + return result +} + +// handleCatchUp processes one fetcher batch. Returns done=true when the writer should close (a +// half-close drain finished its last wave). +func (sess *subscribeSession) handleCatchUp(b catchUpBatch) (bool, error) { + if b.err != nil { + // A fetch error: fail so the client reconnects from its cursors, rather than emit a + // misleading CatchupComplete over a history gap. + return false, connect.NewError( + connect.CodeUnavailable, + fmt.Errorf("catch-up failed: %w", b.err), + ) + } + w := sess.waves[b.wave] + if w == nil { + return false, nil // wave already torn down (e.g. all its topics were removed) + } + + // Deliver this page's history. A history_only wave dedups against its own throwaway cursors; a + // live wave first drops pages for topics it no longer owns (removed, or reset under a newer + // wave) — so it cannot advance a reset topic's live cursor and skip history the newer wave owes + // — then dedups the rest against each topic's live cursor. + var toSend []*envelopesProto.OriginatorEnvelope + if w.historyOnly { + toSend = advanceTopicCursors(w.cursors, b.envs, sess.logger) + } else { + toSend = sess.advanceLive(sess.envsOwnedByWave(b.envs, b.wave)) + } + if len(toSend) > 0 { + if err := sess.sendEnvelopes(toSend); err != nil { + return false, err + } + } + + if !b.done { + return false, nil + } + + // Wave complete: open the gate for each live topic this wave still owns (flushing its buffered + // live, deduped against the now-advanced cursor) and collect the topics to announce; then + // CatchupComplete. flushAndGoLive is a no-op for a topic removed or reset under a newer wave, so + // a stale wave never opens the newer wave's gate or flushes its buffer out of order. + wire := make([][]byte, 0, len(w.topics)) + for _, t := range w.topics { + if w.historyOnly { + wire = append(wire, t.wire) + continue + } + announced, err := sess.flushAndGoLive(t.cursorKey, b.wave) + if err != nil { + return false, err + } + if announced { + wire = append(wire, t.wire) + } + } + if len(wire) > 0 { + if err := sess.send(newSubscribeTopicsLive(wire)); err != nil { + return false, err + } + } + if err := sess.send(newSubscribeCatchupComplete(w.mutateID)); err != nil { + return false, err + } + delete(sess.waves, b.wave) + return sess.halfClosed && len(sess.waves) == 0, nil +} + +// flushAndGoLive completes a gated topic owned by `wave`: it flushes the live envelopes buffered +// during catch-up (deduped against the now-advanced live cursor) and transitions it to live, +// returning true once announced. It is a no-op returning false if the topic is gone or now owned by +// a newer wave (a reset), so a stale wave never opens the newer wave's gate or replays its buffer. +func (sess *subscribeSession) flushAndGoLive(cursorKey string, wave int) (bool, error) { + ts := sess.topics[cursorKey] + if ts == nil || ts.phase != topicGated || ts.wave != wave { + return false, nil + } + if len(ts.pending) > 0 { + for _, e := range ts.pending { + sess.pendingBytes -= proto.Size(e.Proto()) + } + buf := ts.pending + ts.pending = nil + if err := sess.sendEnvelopes(sess.advanceLive(buf)); err != nil { + return false, err + } + } + ts.phase = topicLive + return true, nil +} + +// handleRequest dispatches one client frame. +func (sess *subscribeSession) handleRequest(req *message_api.SubscribeRequest) error { + v1 := req.GetV1() + if v1 == nil { + // Unrecognized version arm: fail rather than silently ignore, so a forward-version + // client is not left waiting on a response (XIP-83 req 8). + return connect.NewError( + connect.CodeInvalidArgument, + errors.New("unrecognized SubscribeRequest version"), + ) + } + switch { + case v1.GetPing() != nil: + return sess.send(newSubscribePong(v1.GetPing().GetNonce())) + case v1.GetPong() != nil: + if v1.GetPong().GetNonce() == sess.pingNonce { + sess.awaitingPong = false + } + return nil + case v1.GetMutate() != nil: + return sess.handleMutate(v1.GetMutate()) + default: + return nil + } +} + +// handleMutate applies a Mutate atomically: it FULLY validates the frame (parses every topic, +// dedups adds, enforces the history_only and active-topic-cap rules) before touching any session +// state, so a malformed or over-cap Mutate cannot leave a half-applied subscription. It then +// removes first (so a topic in both removes and adds is reset), then adds with a catch-up wave +// fetched off-writer. Adds register live BEFORE catch-up starts (gate-before-fetch), so no message +// published during catch-up is missed. +func (sess *subscribeSession) handleMutate(m *message_api.SubscribeRequest_V1_Mutate) error { + historyOnly := m.GetHistoryOnly() + + // ---- Validate (no state mutation): any failure here returns before a single change. ---- + + // Parse removes up front so a malformed remove fails the whole Mutate, and so the add cap and + // history_only checks below can account for topics this Mutate will drop. + removes := make([]*topic.Topic, 0, len(m.GetRemoves())) + removedKeys := make(map[string]struct{}, len(m.GetRemoves())) + for _, w := range m.GetRemoves() { + parsed, err := topic.ParseTopic(w) + if err != nil { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("remove: %w", err)) + } + removes = append(removes, parsed) + removedKeys[string(parsed.Bytes())] = struct{}{} + } + + // Dedup adds (first cursor wins); reject malformed topics; enforce one cursor floor / one + // in-flight catch-up per topic. A re-add of a topic that is NOT being reset by a remove in THIS + // Mutate is special: re-gating a live topic would reset its cursor and re-deliver, so the only + // way to replay/reset a topic is remove+re-add (which clears its floor first). + type addReq struct { + t subscribeTopic + provided db.VectorClock + } + order := make([]string, 0, len(m.GetAdds())) + byKey := make(map[string]*addReq, len(m.GetAdds())) + for _, a := range m.GetAdds() { + parsed, err := topic.ParseTopic(a.GetTopic()) + if err != nil { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("add: %w", err)) + } + cursorKey := string(parsed.Bytes()) + if _, dup := byKey[cursorKey]; dup { + continue + } + _, beingRemoved := removedKeys[cursorKey] + if !beingRemoved { + if _, exists := sess.topics[cursorKey]; exists { + if historyOnly { + // history_only needs its own cursor floor; it can't coexist with a live sub. + return connect.NewError( + connect.CodeInvalidArgument, + errors.New( + "history_only add targets a topic already subscribed on this stream", + ), + ) + } + // Plain re-add of an already-live topic is a no-op (idempotent): do not re-gate, + // reset the cursor, or start a redundant wave. Replay requires remove+re-add. + continue + } + } + // A topic with an in-flight history_only catch-up can't take a second overlapping catch-up + // (live or history_only) — even via remove+re-add: removeTopic does NOT cancel a history_only + // wave (it only clears live state), so both waves would paginate and deliver T's history. + if sess.hasInflightHistoryOnly(cursorKey) { + return connect.NewError( + connect.CodeInvalidArgument, + errors.New("add targets a topic with an in-flight history_only catch-up"), + ) + } + // Validate each cursor entry against the signed DB column types. An out-of-range value would + // be silently dropped by the catch-up query (SetPerTopicCursors) AND stored verbatim in the + // sparse live cursor, where it would mark every real envelope from that originator as already + // seen — permanently killing the topic on this stream. Reject instead. + provided := make(db.VectorClock) + for nodeID, seqID := range a.GetLastSeen().GetNodeIdToSequenceId() { + if nodeID > math.MaxInt32 || seqID > uint64(math.MaxInt64) { + return connect.NewError( + connect.CodeInvalidArgument, + fmt.Errorf( + "cursor entry out of range (originator %d, sequence %d)", + nodeID, + seqID, + ), + ) + } + provided[nodeID] = seqID + } + byKey[cursorKey] = &addReq{ + t: subscribeTopic{ + wire: a.GetTopic(), + cursorKey: cursorKey, + listenKey: parsed.String(), + }, + provided: provided, + } + order = append(order, cursorKey) + } + + // Cap the live set against the PROJECTED post-Mutate size: |(live \ removes) ∪ adds|. A herald + // may hold a lot on one stream, but not unbounded. + if !historyOnly { + delta := 0 + for k := range removedKeys { + if _, exists := sess.topics[k]; exists { + delta-- // a currently-subscribed topic this Mutate drops + } + } + for _, k := range order { + _, existsNow := sess.topics[k] + _, dropped := removedKeys[k] + // Net-new entry unless it is already present after the removes (subscribed and kept). + if !existsNow || dropped { + delta++ + } + } + if len(sess.topics)+delta > maxActiveSubscribeTopics { + return connect.NewError( + connect.CodeResourceExhausted, + fmt.Errorf("active topic limit %d exceeded", maxActiveSubscribeTopics), + ) + } + } + + // Bound concurrent in-flight catch-up waves (each a fetcher goroutine + DB queries). Only a + // Mutate that would start a new wave is subject to it; removes (which never cancel an in-flight + // wave) and no-op re-adds are exempt because they produce no order entries. + if len(order) > 0 && len(sess.waves) >= maxInflightSubscribeWaves { + return connect.NewError( + connect.CodeResourceExhausted, + fmt.Errorf("in-flight catch-up limit %d exceeded", maxInflightSubscribeWaves), + ) + } + + // ---- Apply (validation passed; safe to mutate state). Removes first, then adds. ---- + for _, parsed := range removes { + sess.removeTopic(parsed) + } + + if len(order) == 0 { + // Removes-only (or empty) Mutate: no catch-up, but confirm it applied so a client that + // subscribed to nothing still learns the mutate took effect. + if err := sess.send(newSubscribeCatchupComplete(m.GetMutateId())); err != nil { + return err + } + if sess.halfClosed && len(sess.waves) == 0 { + return sess.flush() + } + return nil + } + + wave := &subscribeWave{mutateID: m.GetMutateId(), historyOnly: historyOnly} + if historyOnly { + wave.cursors = make(db.TopicCursors, len(order)) + } + // providedCursors is the SPARSE per-topic client cursor; the fetcher fills it (with the full + // originator set) off the writer goroutine. The persisted live cursor stays sparse (provided + // only, grown as originators are actually seen) to bound memory at the 1M ceiling. + providedCursors := make(db.TopicCursors, len(order)) + cursorKeys := make([]string, 0, len(order)) + for _, k := range order { + a := byKey[k] + wave.topics = append(wave.topics, a.t) + cursorKeys = append(cursorKeys, k) + providedCursors[k] = cloneVectorClock(a.provided) + + if historyOnly { + wave.cursors[k] = cloneVectorClock(a.provided) + continue + } + // Live add: gate it under this wave (seeds the sparse live cursor and registers with the + // worker), all before the fetch starts. + sess.gateTopic(a.t, sess.nextWave, a.provided) + } + + sess.waves[sess.nextWave] = wave + go sess.svc.runSubscribeCatchUp( + sess.ctx, + sess.nextWave, + providedCursors, + cursorKeys, + sess.catchUpCh, + sess.logger, + ) + sess.nextWave++ + return nil +} + +// hasInflightHistoryOnly reports whether the topic currently has an in-flight history_only catch-up +// wave (its cursor lives on the wave's own subscribeWave.cursors, present only while that wave is +// still in sess.waves). Used to reject a second overlapping catch-up for the same topic, which would +// double-deliver its history. +func (sess *subscribeSession) hasInflightHistoryOnly(cursorKey string) bool { + for _, w := range sess.waves { + if w.historyOnly { + if _, ok := w.cursors[cursorKey]; ok { + return true + } + } + } + return false +} + +// gateTopic registers a live add: it creates the topic in the gated phase owned by `wave`, seeds its +// sparse live cursor from the client-provided floor, and registers it with the worker — all before +// the catch-up fetch starts, so no envelope published during catch-up is missed (gate-before-fetch). +func (sess *subscribeSession) gateTopic(t subscribeTopic, wave int, provided db.VectorClock) { + sess.topics[t.cursorKey] = &topicState{ + subscribeTopic: t, + phase: topicGated, + wave: wave, + cursor: cloneVectorClock(provided), + } + sess.sub.addTopics(map[string]struct{}{t.listenKey: {}}) +} + +func (sess *subscribeSession) removeTopic(parsed *topic.Topic) { + sess.sub.removeTopics(map[string]struct{}{parsed.String(): {}}) + sess.removeTopicState(string(parsed.Bytes())) +} + +// removeTopicState drops a live topic's state and refunds its buffered pending bytes. It does NOT +// touch any in-flight wave: a wave only ever acts on topics it still owns (flushAndGoLive / +// envsOwnedByWave both re-check ownership), so a removed topic's pages and completion are ignored. +func (sess *subscribeSession) removeTopicState(cursorKey string) { + ts := sess.topics[cursorKey] + if ts == nil { + return + } + for _, e := range ts.pending { + sess.pendingBytes -= proto.Size(e.Proto()) + } + delete(sess.topics, cursorKey) +} + +// envsOwnedByWave keeps only envelopes whose topic is still being caught up by this wave. A topic +// removed (or removed and re-added under a newer wave) since the fetch began is no longer this +// wave's to deliver; dropping its pages keeps a stale wave from advancing the live cursor of a +// reset topic (which would skip history the newer wave still owes the client). +func (sess *subscribeSession) envsOwnedByWave( + envs []*envelopes.OriginatorEnvelope, + wave int, +) []*envelopes.OriginatorEnvelope { + owned := func(env *envelopes.OriginatorEnvelope) bool { + ts := sess.topics[string(env.TargetTopic().Bytes())] + return ts != nil && ts.phase == topicGated && ts.wave == wave + } + // Fast path: every envelope belongs to a topic this wave still owns (the common case — no + // concurrent remove/reset), so the page passes through without reallocation. + allOwned := true + for _, env := range envs { + if !owned(env) { + allOwned = false + break + } + } + if allOwned { + return envs + } + out := make([]*envelopes.OriginatorEnvelope, 0, len(envs)) + for _, env := range envs { + if owned(env) { + out = append(out, env) + } + } + return out +} + +// runSubscribeCatchUp paginates history for a wave's topics (off the writer goroutine) and hands +// raw pages back over catchUpCh, ending with a done marker. It resolves the originator set and fills +// providedCursors into its own query cursors here — that originator lookup is a DB round-trip on a +// cache miss, so it MUST stay off the writer goroutine (else a slow DB would stall liveness and +// live delivery and could false-reap a healthy stream). The writer owns the sparse live cursors. +// Every channel send is guarded by ctx so the fetcher cannot leak if the writer has torn down. +func (s *Service) runSubscribeCatchUp( + ctx context.Context, + wave int, + providedCursors db.TopicCursors, + cursorKeys []string, + catchUpCh chan<- catchUpBatch, + logger *zap.Logger, +) { + emit := func(b catchUpBatch) bool { + select { + case catchUpCh <- b: + return true + case <-ctx.Done(): + return false + } + } + + // The originator set is snapshotted once. For a LIVE wave that is complete: the listener is + // registered before this snapshot, so an originator that first publishes after the snapshot is + // caught by the live gate (the worker dispatches by topic, independent of this list). A + // history_only wave has no live gate, so a brand-new originator that first publishes during the + // catch-up window is delivered on the client's next subscribe rather than this bounded sync — + // an accepted eventual-consistency property of history_only (it is a periodic re-sync flow). + knownOriginators, err := s.originatorList.GetOriginatorNodeIDs(ctx) + if err != nil { + emit(catchUpBatch{wave: wave, err: fmt.Errorf("could not get originator list: %w", err)}) + return + } + // queryCursors are FILLED (every originator from the provided/zero start) so catch-up covers all + // originators; the fetcher owns and advances them for pagination. + queryCursors := make(db.TopicCursors, len(providedCursors)) + for k, provided := range providedCursors { + filled := cloneVectorClock(provided) + db.FillMissingOriginators(filled, knownOriginators) + queryCursors[k] = filled + } + + for _, chunkKeys := range utils.ChunkSlice(cursorKeys, maxTopicsPerChunk) { + rowsPerEntry := db.CalculateRowsPerEntry(len(chunkKeys), topicPageLimit) + for { + if ctx.Err() != nil { + return + } + subCursors := make(db.TopicCursors, len(chunkKeys)) + for _, k := range chunkKeys { + subCursors[k] = queryCursors[k] + } + rows, err := s.fetchTopicEnvelopesWithRetry( + ctx, + subCursors, + topicPageLimit, + rowsPerEntry, + ) + if err != nil { + emit(catchUpBatch{wave: wave, err: err}) + return + } + envs := unmarshalEnvelopes(rows, logger) + // Advance the fetcher's own (filled) cursors from the RAW rows so pagination always + // progresses even if some rows fail to unmarshal (otherwise a single bad row in a full + // page re-fetches forever); the writer re-dedups the emitted envs against the live cursor. + advanceCursorsFromRows(queryCursors, rows) + if !emit(catchUpBatch{wave: wave, envs: envs}) { + return + } + if int32(len(rows)) < rowsPerEntry { + break + } + } + } + emit(catchUpBatch{wave: wave, done: true}) +} + +func cloneVectorClock(vc db.VectorClock) db.VectorClock { + out := make(db.VectorClock, len(vc)) + maps.Copy(out, vc) + return out +} + +// stopTimer stops t and drains a pending fire if there is one, so a later Reset cannot observe a +// stale value. Safe on an already-stopped or already-fired-and-drained timer. +func stopTimer(t *time.Timer) { + if !t.Stop() { + select { + case <-t.C: + default: + } + } +} + +// drainPendingRequests processes client frames already queued on ch without blocking. It is called +// right before the pong-deadline reap so a Pong that landed in the buffer just as the deadline fired +// (Go select picks randomly among ready cases) is still counted, instead of false-reaping a stream +// that did answer. ch may be nil (half-closed), in which case this is a no-op. +func (sess *subscribeSession) drainPendingRequests(ch <-chan *message_api.SubscribeRequest) error { + for { + select { + case req, open := <-ch: + if !open { + // The reader closed the channel. Mirror the main loop's EOF handling: surface a + // transport failure, else mark halfClosed for a clean EOF — so the pong-deadline + // caller does not then reap a cleanly-finishing (or already-failed) stream with a + // spurious DeadlineExceeded. + if sess.recvErr != nil { + return connect.NewError( + connect.CodeUnavailable, + fmt.Errorf("stream recv failed: %w", sess.recvErr), + ) + } + sess.halfClosed = true + return nil + } + if err := sess.handleRequest(req); err != nil { + return err + } + default: + return nil + } + } +} + +func wrapSubscribeV1(v1 *message_api.SubscribeResponse_V1) *message_api.SubscribeResponse { + return &message_api.SubscribeResponse{Version: &message_api.SubscribeResponse_V1_{V1: v1}} +} + +func newSubscribeStarted(keepaliveIntervalMs uint32) *message_api.SubscribeResponse { + return wrapSubscribeV1(&message_api.SubscribeResponse_V1{ + Response: &message_api.SubscribeResponse_V1_Started_{ + Started: &message_api.SubscribeResponse_V1_Started{ + KeepaliveIntervalMs: keepaliveIntervalMs, + }, + }, + }) +} + +func newSubscribeEnvelopes( + envs []*envelopesProto.OriginatorEnvelope, +) *message_api.SubscribeResponse { + return wrapSubscribeV1(&message_api.SubscribeResponse_V1{ + Response: &message_api.SubscribeResponse_V1_Envelopes_{ + Envelopes: &message_api.SubscribeResponse_V1_Envelopes{Envelopes: envs}, + }, + }) +} + +func newSubscribeTopicsLive(topics [][]byte) *message_api.SubscribeResponse { + return wrapSubscribeV1(&message_api.SubscribeResponse_V1{ + Response: &message_api.SubscribeResponse_V1_TopicsLive_{ + TopicsLive: &message_api.SubscribeResponse_V1_TopicsLive{Topics: topics}, + }, + }) +} + +func newSubscribeCatchupComplete(mutateID uint64) *message_api.SubscribeResponse { + return wrapSubscribeV1(&message_api.SubscribeResponse_V1{ + Response: &message_api.SubscribeResponse_V1_CatchupComplete_{ + CatchupComplete: &message_api.SubscribeResponse_V1_CatchupComplete{MutateId: mutateID}, + }, + }) +} + +func newSubscribePing(nonce uint64) *message_api.SubscribeResponse { + return wrapSubscribeV1(&message_api.SubscribeResponse_V1{ + Response: &message_api.SubscribeResponse_V1_Ping{Ping: &message_api.Ping{Nonce: nonce}}, + }) +} + +func newSubscribePong(nonce uint64) *message_api.SubscribeResponse { + return wrapSubscribeV1(&message_api.SubscribeResponse_V1{ + Response: &message_api.SubscribeResponse_V1_Pong{Pong: &message_api.Pong{Nonce: nonce}}, + }) +} diff --git a/pkg/api/message/subscribe_topics.go b/pkg/api/message/subscribe_topics.go index 85bec21c2..e78aec233 100644 --- a/pkg/api/message/subscribe_topics.go +++ b/pkg/api/message/subscribe_topics.go @@ -357,6 +357,25 @@ func advanceTopicCursors( return result } +// advanceCursorsFromRows advances per-topic pagination cursors from the RAW query rows (not just the +// rows that successfully unmarshal). A catch-up paginator that advanced only from unmarshaled +// envelopes would never move past a row whose envelope bytes fail to parse: since pagination +// terminates on raw row count, a single bad row in an otherwise-full page would re-fetch that page +// forever and the wave would never complete. Advancing from raw rows guarantees forward progress. +func advanceCursorsFromRows(cursors db.TopicCursors, rows []queries.GatewayEnvelopesView) { + for i := range rows { + vc, ok := cursors[string(rows[i].Topic)] + if !ok { + continue + } + origID := uint32(rows[i].OriginatorNodeID) + seqID := uint64(rows[i].OriginatorSequenceID) + if cur, seen := vc[origID]; !seen || cur < seqID { + vc[origID] = seqID + } + } +} + func newSubscriptionStatusMessage( status message_api.SubscribeTopicsResponse_SubscriptionStatus, ) *message_api.SubscribeTopicsResponse { diff --git a/pkg/proto/openapi/mls/api/v1/mls.swagger.json b/pkg/proto/openapi/mls/api/v1/mls.swagger.json index 507db0960..35472d2d3 100644 --- a/pkg/proto/openapi/mls/api/v1/mls.swagger.json +++ b/pkg/proto/openapi/mls/api/v1/mls.swagger.json @@ -563,21 +563,6 @@ }, "title": "A wrapper for the updates for a single wallet" }, - "MutateSubscription": { - "type": "object", - "properties": { - "topic": { - "type": "string", - "format": "byte" - }, - "idCursor": { - "type": "string", - "format": "uint64", - "description": "Deliver ids greater than this; 0 = from the beginning. For a newly\njoined group, a client SHOULD seed this from the welcome's encrypted\nWelcomeMetadata.message_cursor so a new membership does not refetch\npre-join history it cannot decrypt; for a new installation's welcome\ntopic, 0 is how pending welcomes are collected." - } - }, - "description": "A topic to subscribe, with the cursor to resume from." - }, "SignatureECDSACompact": { "type": "object", "properties": { @@ -610,25 +595,6 @@ }, "description": "ECDSA signature bytes and the recovery bit\nproduced by xmtp-js::PublicKey.signWithWallet function, i.e.\nEIP-191 signature of a \"Create Identity\" message with the key embedded.\nUsed to sign identity keys." }, - "V1Capability": { - "type": "string", - "enum": [ - "CAPABILITY_UNSPECIFIED" - ], - "default": "CAPABILITY_UNSPECIFIED", - "description": "Optional per-stream protocol features (none defined yet; future\nrevisions add values, e.g. fetch-over-stream lookups answered with the\nsame read view that feeds the stream, or new streamable topic kinds)." - }, - "V1CatchupComplete": { - "type": "object", - "properties": { - "mutateId": { - "type": "string", - "format": "uint64", - "title": "echoes the Mutate that started this wave (0 if none given)" - } - }, - "description": "Sent once per Mutate that adds subscriptions (a catch-up \"wave\"), after\nthe wave's last TopicsLive: everything the Mutate asked for is delivered." - }, "V1Messages": { "type": "object", "properties": { @@ -649,14 +615,59 @@ }, "description": "A batch of new messages; group and welcome messages share the stream,\ndepending on which subscriptions are active." }, - "V1Mutate": { + "apiv1GroupMessage": { + "type": "object", + "properties": { + "v1": { + "$ref": "#/definitions/v1GroupMessageV1" + } + }, + "title": "Full representation of a group message" + }, + "apiv1Ping": { + "type": "object", + "properties": { + "nonce": { + "type": "string", + "format": "uint64" + } + }, + "description": "Liveness challenge/response, shared across versions. Either peer MAY send a\nPing; the receiver MUST reply with a Pong echoing the nonce. The sender closes\nthe stream if no Pong arrives within its deadline — how a node reaps a vanished\npeer (e.g. a mobile client the OS suspended behind a proxy that still ACKs the\ntransport)." + }, + "apiv1Pong": { + "type": "object", + "properties": { + "nonce": { + "type": "string", + "format": "uint64", + "title": "echoes the nonce of the Ping it answers" + } + } + }, + "apiv1SubscribeRequestV1": { + "type": "object", + "properties": { + "mutate": { + "$ref": "#/definitions/apiv1SubscribeRequestV1Mutate" + }, + "ping": { + "$ref": "#/definitions/apiv1Ping", + "title": "liveness challenge (e.g. probe the link after resuming)" + }, + "pong": { + "$ref": "#/definitions/apiv1Pong", + "title": "answer to a server Ping" + } + } + }, + "apiv1SubscribeRequestV1Mutate": { "type": "object", "properties": { "adds": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/MutateSubscription" + "$ref": "#/definitions/apiv1SubscribeRequestV1MutateSubscription" }, "title": "begin delivering these topics" }, @@ -680,7 +691,78 @@ }, "description": "Add and/or remove subscriptions in place (applied atomically per frame).\nTopics use the kind-prefixed binary representation shared with the\ndecentralized backend (XIP-49 §3.3.2): the first byte is the topic kind,\nthe remainder is the identifier. This RPC initially serves\nTOPIC_KIND_GROUP_MESSAGES_V1 (0x00, identifier = group_id) and\nTOPIC_KIND_WELCOME_MESSAGES_V1 (0x01, identifier = installation_key);\na topic whose kind the node does not serve fails the stream with\nINVALID_ARGUMENT. Future kinds (key packages, identity updates) are\nadopted via the capabilities advertised on Started." }, - "V1Started": { + "apiv1SubscribeRequestV1MutateSubscription": { + "type": "object", + "properties": { + "topic": { + "type": "string", + "format": "byte" + }, + "idCursor": { + "type": "string", + "format": "uint64", + "description": "Deliver ids greater than this; 0 = from the beginning. For a newly\njoined group, a client SHOULD seed this from the welcome's encrypted\nWelcomeMetadata.message_cursor so a new membership does not refetch\npre-join history it cannot decrypt; for a new installation's welcome\ntopic, 0 is how pending welcomes are collected." + } + }, + "description": "A topic to subscribe, with the cursor to resume from." + }, + "apiv1SubscribeResponse": { + "type": "object", + "properties": { + "v1": { + "$ref": "#/definitions/apiv1SubscribeResponseV1" + } + }, + "description": "Server -\u003e client." + }, + "apiv1SubscribeResponseV1": { + "type": "object", + "properties": { + "messages": { + "$ref": "#/definitions/V1Messages" + }, + "started": { + "$ref": "#/definitions/apiv1SubscribeResponseV1Started", + "title": "sent once, immediately on open, before any catch-up" + }, + "ping": { + "$ref": "#/definitions/apiv1Ping", + "title": "idle liveness challenge; receiver MUST answer with Pong" + }, + "pong": { + "$ref": "#/definitions/apiv1Pong", + "title": "answer to a client Ping" + }, + "topicsLive": { + "$ref": "#/definitions/apiv1SubscribeResponseV1TopicsLive", + "title": "these topics just crossed from catch-up to live" + }, + "catchupComplete": { + "$ref": "#/definitions/apiv1SubscribeResponseV1CatchupComplete", + "title": "a Mutate's adds are fully delivered" + } + } + }, + "apiv1SubscribeResponseV1Capability": { + "type": "string", + "enum": [ + "CAPABILITY_UNSPECIFIED" + ], + "default": "CAPABILITY_UNSPECIFIED", + "description": "Optional per-stream protocol features (none defined yet; future\nrevisions add values, e.g. fetch-over-stream lookups answered with the\nsame read view that feeds the stream, or new streamable topic kinds)." + }, + "apiv1SubscribeResponseV1CatchupComplete": { + "type": "object", + "properties": { + "mutateId": { + "type": "string", + "format": "uint64", + "title": "echoes the Mutate that started this wave (0 if none given)" + } + }, + "description": "Sent once per Mutate that adds subscriptions (a catch-up \"wave\"), after\nthe wave's last TopicsLive: everything the Mutate asked for is delivered." + }, + "apiv1SubscribeResponseV1Started": { "type": "object", "properties": { "keepaliveIntervalMs": { @@ -691,14 +773,14 @@ "capabilities": { "type": "array", "items": { - "$ref": "#/definitions/V1Capability" + "$ref": "#/definitions/apiv1SubscribeResponseV1Capability" }, "description": "Optional protocol features the node supports on this stream. The node\nsilently ignores request types it does not understand, so a client\nMUST NOT send an optional request type whose capability the node did\nnot advertise (it would hang waiting on a response that never comes)." } }, "description": "The first frame on every stream." }, - "V1TopicsLive": { + "apiv1SubscribeResponseV1TopicsLive": { "type": "object", "properties": { "topics": { @@ -712,15 +794,6 @@ }, "description": "Emitted when topics finish catch-up, AFTER the last history frame for\nthem — including any live messages that queued up behind the catch-up,\nwhich were equally historical from the client's perspective — so every\nlater frame for a listed topic is live tail. Informational only: delivery\ncorrectness (no duplicates, no gaps) never depends on it. Re-adding a\ntopic re-runs catch-up and re-emits it; receivers treat it idempotently." }, - "apiv1GroupMessage": { - "type": "object", - "properties": { - "v1": { - "$ref": "#/definitions/v1GroupMessageV1" - } - }, - "title": "Full representation of a group message" - }, "associationsRecoverableEd25519Signature": { "type": "object", "properties": { @@ -996,26 +1069,6 @@ "description": "This would be a serialized MLS key package that the node would\n parse, validate, and then store.", "title": "A wrapper around the Key Package bytes" }, - "v1Ping": { - "type": "object", - "properties": { - "nonce": { - "type": "string", - "format": "uint64" - } - }, - "description": "Liveness challenge/response, shared across versions. Either peer MAY send a\nPing; the receiver MUST reply with a Pong echoing the nonce. The sender closes\nthe stream if no Pong arrives within its deadline — how a node reaps a vanished\npeer (e.g. a mobile client the OS suspended behind a proxy that still ACKs the\ntransport)." - }, - "v1Pong": { - "type": "object", - "properties": { - "nonce": { - "type": "string", - "format": "uint64", - "title": "echoes the nonce of the Ping it answers" - } - } - }, "v1PublishCommitLogRequest": { "type": "object", "properties": { @@ -1211,59 +1264,6 @@ }, "title": "Subscription filter" }, - "v1SubscribeRequestV1": { - "type": "object", - "properties": { - "mutate": { - "$ref": "#/definitions/V1Mutate" - }, - "ping": { - "$ref": "#/definitions/v1Ping", - "title": "liveness challenge (e.g. probe the link after resuming)" - }, - "pong": { - "$ref": "#/definitions/v1Pong", - "title": "answer to a server Ping" - } - } - }, - "v1SubscribeResponse": { - "type": "object", - "properties": { - "v1": { - "$ref": "#/definitions/v1SubscribeResponseV1" - } - }, - "description": "Server -\u003e client." - }, - "v1SubscribeResponseV1": { - "type": "object", - "properties": { - "messages": { - "$ref": "#/definitions/V1Messages" - }, - "started": { - "$ref": "#/definitions/V1Started", - "title": "sent once, immediately on open, before any catch-up" - }, - "ping": { - "$ref": "#/definitions/v1Ping", - "title": "idle liveness challenge; receiver MUST answer with Pong" - }, - "pong": { - "$ref": "#/definitions/v1Pong", - "title": "answer to a client Ping" - }, - "topicsLive": { - "$ref": "#/definitions/V1TopicsLive", - "title": "these topics just crossed from catch-up to live" - }, - "catchupComplete": { - "$ref": "#/definitions/V1CatchupComplete", - "title": "a Mutate's adds are fully delivered" - } - } - }, "v1SubscribeWelcomeMessagesRequest": { "type": "object", "properties": { diff --git a/pkg/proto/openapi/xmtpv4/message_api/query_api.swagger.json b/pkg/proto/openapi/xmtpv4/message_api/query_api.swagger.json index eb3a177f5..27f165d7b 100644 --- a/pkg/proto/openapi/xmtpv4/message_api/query_api.swagger.json +++ b/pkg/proto/openapi/xmtpv4/message_api/query_api.swagger.json @@ -17,6 +17,19 @@ ], "paths": {}, "definitions": { + "SubscribeResponseV1Envelopes": { + "type": "object", + "properties": { + "envelopes": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/envelopesOriginatorEnvelope" + } + } + }, + "description": "A batch of envelopes across the active subscriptions; the client demuxes\nby each envelope's target topic." + }, "SubscribeTopicsRequestTopicFilter": { "type": "object", "properties": { @@ -260,6 +273,175 @@ } }, "title": "A single response for a given address" + }, + "xmtpv4message_apiPing": { + "type": "object", + "properties": { + "nonce": { + "type": "string", + "format": "uint64" + } + }, + "description": "Liveness challenge/response for Subscribe, shared across versions. Either peer\nMAY send a Ping; the receiver MUST reply with a Pong echoing the nonce. The\nsender closes the stream if no Pong arrives within its deadline — how a node\nreaps a vanished peer (e.g. a mobile client the OS suspended behind a proxy\nthat still ACKs the transport)." + }, + "xmtpv4message_apiPong": { + "type": "object", + "properties": { + "nonce": { + "type": "string", + "format": "uint64", + "title": "echoes the nonce of the Ping it answers" + } + } + }, + "xmtpv4message_apiSubscribeRequestV1": { + "type": "object", + "properties": { + "mutate": { + "$ref": "#/definitions/xmtpv4message_apiSubscribeRequestV1Mutate" + }, + "ping": { + "$ref": "#/definitions/xmtpv4message_apiPing", + "title": "liveness challenge (e.g. probe the link after resuming)" + }, + "pong": { + "$ref": "#/definitions/xmtpv4message_apiPong", + "title": "answer to a node Ping" + } + } + }, + "xmtpv4message_apiSubscribeRequestV1Mutate": { + "type": "object", + "properties": { + "adds": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/xmtpv4message_apiSubscribeRequestV1MutateSubscription" + }, + "title": "begin delivering these topics" + }, + "removes": { + "type": "array", + "items": { + "type": "string", + "format": "byte" + }, + "title": "topics to stop delivering" + }, + "historyOnly": { + "type": "boolean", + "description": "Catch this Mutate's adds up to the live edge — history, TopicsLive\nmarkers, and the wave's CatchupComplete — but do NOT register them for\nlive delivery. The markers then mean \"you have everything as of now\".\nCombined with half-closing the request stream, this is the bounded\ncatch-up (\"sync\") mode: the node finishes the wave then closes the\nstream itself. Removals in the Mutate are unaffected." + }, + "mutateId": { + "type": "string", + "format": "uint64", + "description": "Client-chosen correlation id, echoed on this wave's CatchupComplete so\ncompletions are attributable when waves overlap. SHOULD be unique per\nstream; 0 = no correlation requested (still echoed as 0)." + } + }, + "description": "Add and/or remove subscriptions in place (applied atomically per frame).\nTopics use the kind-prefixed binary representation (XIP-49 §3.3.2): the\nfirst byte is the topic kind, the remainder is the identifier. A topic\nwhose kind the node does not serve fails the stream with INVALID_ARGUMENT." + }, + "xmtpv4message_apiSubscribeRequestV1MutateSubscription": { + "type": "object", + "properties": { + "topic": { + "type": "string", + "format": "byte" + }, + "lastSeen": { + "$ref": "#/definitions/xmtpv4envelopesCursor", + "description": "Resume point: deliver envelopes beyond this per-originator vector\ncursor. Omitted/empty = from the beginning. Originators absent from\nthe cursor map are treated as sequence 0 (the node fills them in),\nmirroring SubscribeTopics.TopicFilter.last_seen." + } + }, + "description": "A topic to subscribe, with the vector cursor to resume from." + }, + "xmtpv4message_apiSubscribeResponse": { + "type": "object", + "properties": { + "v1": { + "$ref": "#/definitions/xmtpv4message_apiSubscribeResponseV1" + } + }, + "description": "Node -\u003e client." + }, + "xmtpv4message_apiSubscribeResponseV1": { + "type": "object", + "properties": { + "envelopes": { + "$ref": "#/definitions/SubscribeResponseV1Envelopes" + }, + "started": { + "$ref": "#/definitions/xmtpv4message_apiSubscribeResponseV1Started", + "title": "sent once, immediately on open, before any catch-up" + }, + "ping": { + "$ref": "#/definitions/xmtpv4message_apiPing", + "title": "idle liveness challenge; receiver MUST answer with Pong" + }, + "pong": { + "$ref": "#/definitions/xmtpv4message_apiPong", + "title": "answer to a client Ping" + }, + "topicsLive": { + "$ref": "#/definitions/xmtpv4message_apiSubscribeResponseV1TopicsLive", + "title": "these topics just crossed from catch-up to live" + }, + "catchupComplete": { + "$ref": "#/definitions/xmtpv4message_apiSubscribeResponseV1CatchupComplete", + "title": "a Mutate's adds are fully delivered" + } + } + }, + "xmtpv4message_apiSubscribeResponseV1Capability": { + "type": "string", + "enum": [ + "CAPABILITY_UNSPECIFIED" + ], + "default": "CAPABILITY_UNSPECIFIED", + "description": "Optional per-stream protocol features (none defined yet; future revisions\nadd values, e.g. fetch-over-stream lookups answered with the same read\nview that feeds the stream, or new streamable topic kinds)." + }, + "xmtpv4message_apiSubscribeResponseV1CatchupComplete": { + "type": "object", + "properties": { + "mutateId": { + "type": "string", + "format": "uint64", + "title": "echoes the Mutate that started this wave (0 if none given)" + } + }, + "description": "Sent once per Mutate that adds subscriptions (a catch-up \"wave\"), after\nthe wave's last TopicsLive: everything the Mutate asked for is delivered." + }, + "xmtpv4message_apiSubscribeResponseV1Started": { + "type": "object", + "properties": { + "keepaliveIntervalMs": { + "type": "integer", + "format": "int64", + "description": "The node's ping cadence (ms): the basis for the client's staleness\nthreshold and the node's reap deadline." + }, + "capabilities": { + "type": "array", + "items": { + "$ref": "#/definitions/xmtpv4message_apiSubscribeResponseV1Capability" + }, + "description": "Optional protocol features the node supports on this stream. The node\nsilently ignores request types it does not understand, so a client MUST\nNOT send an optional request type whose capability the node did not\nadvertise (it would hang waiting on a response that never comes)." + } + }, + "description": "The first frame on every stream." + }, + "xmtpv4message_apiSubscribeResponseV1TopicsLive": { + "type": "object", + "properties": { + "topics": { + "type": "array", + "items": { + "type": "string", + "format": "byte" + }, + "title": "kind-prefixed topics now tailing live" + } + }, + "description": "Emitted when topics finish catch-up, AFTER the last history frame for\nthem — including any live envelopes that queued behind the catch-up,\nwhich were equally historical from the client's perspective — so every\nlater frame for a listed topic is live tail. Informational only: delivery\ncorrectness (no duplicates, no gaps) never depends on it. Re-adding a\ntopic re-runs catch-up and re-emits it; receivers treat it idempotently." } } } diff --git a/pkg/proto/xmtpv4/message_api/message_api.pb.go b/pkg/proto/xmtpv4/message_api/message_api.pb.go index 59cb0d659..15fcf8e5d 100644 --- a/pkg/proto/xmtpv4/message_api/message_api.pb.go +++ b/pkg/proto/xmtpv4/message_api/message_api.pb.go @@ -77,6 +77,52 @@ func (SubscribeTopicsResponse_SubscriptionStatus) EnumDescriptor() ([]byte, []in return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{3, 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_xmtpv4_message_api_message_api_proto_enumTypes[1].Descriptor() +} + +func (SubscribeResponse_V1_Capability) Type() protoreflect.EnumType { + return &file_xmtpv4_message_api_message_api_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_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{6, 0, 0} +} + // Query for envelopes, shared by query and subscribe endpoints // Either topics or originator_node_ids may be set, but not both type EnvelopesQuery struct { @@ -359,6 +405,233 @@ func (x *SubscribeEnvelopesResponse) GetEnvelopes() []*envelopes.OriginatorEnvel return nil } +// Client -> node. 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_xmtpv4_message_api_message_api_proto_msgTypes[5] + 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_xmtpv4_message_api_message_api_proto_msgTypes[5] + 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_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{5} +} + +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() {} + +// Node -> 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_xmtpv4_message_api_message_api_proto_msgTypes[6] + 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_xmtpv4_message_api_message_api_proto_msgTypes[6] + 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_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{6} +} + +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 for Subscribe, 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_xmtpv4_message_api_message_api_proto_msgTypes[7] + 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_xmtpv4_message_api_message_api_proto_msgTypes[7] + 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_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{7} +} + +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_xmtpv4_message_api_message_api_proto_msgTypes[8] + 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_xmtpv4_message_api_message_api_proto_msgTypes[8] + 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_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{8} +} + +func (x *Pong) GetNonce() uint64 { + if x != nil { + return x.Nonce + } + return 0 +} + // Batch subscribe to all envelopes type SubscribeAllEnvelopesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -368,7 +641,7 @@ type SubscribeAllEnvelopesRequest struct { func (x *SubscribeAllEnvelopesRequest) Reset() { *x = SubscribeAllEnvelopesRequest{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[5] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -380,7 +653,7 @@ func (x *SubscribeAllEnvelopesRequest) String() string { func (*SubscribeAllEnvelopesRequest) ProtoMessage() {} func (x *SubscribeAllEnvelopesRequest) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[5] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -393,7 +666,7 @@ func (x *SubscribeAllEnvelopesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeAllEnvelopesRequest.ProtoReflect.Descriptor instead. func (*SubscribeAllEnvelopesRequest) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{5} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{9} } // Query envelopes request @@ -407,7 +680,7 @@ type QueryEnvelopesRequest struct { func (x *QueryEnvelopesRequest) Reset() { *x = QueryEnvelopesRequest{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[6] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -419,7 +692,7 @@ func (x *QueryEnvelopesRequest) String() string { func (*QueryEnvelopesRequest) ProtoMessage() {} func (x *QueryEnvelopesRequest) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[6] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -432,7 +705,7 @@ func (x *QueryEnvelopesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryEnvelopesRequest.ProtoReflect.Descriptor instead. func (*QueryEnvelopesRequest) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{6} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{10} } func (x *QueryEnvelopesRequest) GetQuery() *EnvelopesQuery { @@ -459,7 +732,7 @@ type QueryEnvelopesResponse struct { func (x *QueryEnvelopesResponse) Reset() { *x = QueryEnvelopesResponse{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[7] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -471,7 +744,7 @@ func (x *QueryEnvelopesResponse) String() string { func (*QueryEnvelopesResponse) ProtoMessage() {} func (x *QueryEnvelopesResponse) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[7] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -484,7 +757,7 @@ func (x *QueryEnvelopesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryEnvelopesResponse.ProtoReflect.Descriptor instead. func (*QueryEnvelopesResponse) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{7} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{11} } func (x *QueryEnvelopesResponse) GetEnvelopes() []*envelopes.OriginatorEnvelope { @@ -503,7 +776,7 @@ type PublishPayerEnvelopesRequest struct { func (x *PublishPayerEnvelopesRequest) Reset() { *x = PublishPayerEnvelopesRequest{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[8] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -515,7 +788,7 @@ func (x *PublishPayerEnvelopesRequest) String() string { func (*PublishPayerEnvelopesRequest) ProtoMessage() {} func (x *PublishPayerEnvelopesRequest) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[8] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -528,7 +801,7 @@ func (x *PublishPayerEnvelopesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PublishPayerEnvelopesRequest.ProtoReflect.Descriptor instead. func (*PublishPayerEnvelopesRequest) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{8} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{12} } func (x *PublishPayerEnvelopesRequest) GetPayerEnvelopes() []*envelopes.PayerEnvelope { @@ -547,7 +820,7 @@ type PublishPayerEnvelopesResponse struct { func (x *PublishPayerEnvelopesResponse) Reset() { *x = PublishPayerEnvelopesResponse{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[9] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -559,7 +832,7 @@ func (x *PublishPayerEnvelopesResponse) String() string { func (*PublishPayerEnvelopesResponse) ProtoMessage() {} func (x *PublishPayerEnvelopesResponse) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[9] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -572,7 +845,7 @@ func (x *PublishPayerEnvelopesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PublishPayerEnvelopesResponse.ProtoReflect.Descriptor instead. func (*PublishPayerEnvelopesResponse) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{9} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{13} } func (x *PublishPayerEnvelopesResponse) GetOriginatorEnvelopes() []*envelopes.OriginatorEnvelope { @@ -592,7 +865,7 @@ type GetInboxIdsRequest struct { func (x *GetInboxIdsRequest) Reset() { *x = GetInboxIdsRequest{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[10] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -604,7 +877,7 @@ func (x *GetInboxIdsRequest) String() string { func (*GetInboxIdsRequest) ProtoMessage() {} func (x *GetInboxIdsRequest) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[10] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -617,7 +890,7 @@ func (x *GetInboxIdsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInboxIdsRequest.ProtoReflect.Descriptor instead. func (*GetInboxIdsRequest) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{10} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{14} } func (x *GetInboxIdsRequest) GetRequests() []*GetInboxIdsRequest_Request { @@ -637,7 +910,7 @@ type GetInboxIdsResponse struct { func (x *GetInboxIdsResponse) Reset() { *x = GetInboxIdsResponse{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[11] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -649,7 +922,7 @@ func (x *GetInboxIdsResponse) String() string { func (*GetInboxIdsResponse) ProtoMessage() {} func (x *GetInboxIdsResponse) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[11] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -662,7 +935,7 @@ func (x *GetInboxIdsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInboxIdsResponse.ProtoReflect.Descriptor instead. func (*GetInboxIdsResponse) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{11} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{15} } func (x *GetInboxIdsResponse) GetResponses() []*GetInboxIdsResponse_Response { @@ -682,7 +955,7 @@ type GetNewestEnvelopeRequest struct { func (x *GetNewestEnvelopeRequest) Reset() { *x = GetNewestEnvelopeRequest{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[12] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -694,7 +967,7 @@ func (x *GetNewestEnvelopeRequest) String() string { func (*GetNewestEnvelopeRequest) ProtoMessage() {} func (x *GetNewestEnvelopeRequest) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[12] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -707,7 +980,7 @@ func (x *GetNewestEnvelopeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetNewestEnvelopeRequest.ProtoReflect.Descriptor instead. func (*GetNewestEnvelopeRequest) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{12} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{16} } func (x *GetNewestEnvelopeRequest) GetTopics() [][]byte { @@ -728,7 +1001,7 @@ type GetNewestEnvelopeResponse struct { func (x *GetNewestEnvelopeResponse) Reset() { *x = GetNewestEnvelopeResponse{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[13] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -740,7 +1013,7 @@ func (x *GetNewestEnvelopeResponse) String() string { func (*GetNewestEnvelopeResponse) ProtoMessage() {} func (x *GetNewestEnvelopeResponse) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[13] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -753,7 +1026,7 @@ func (x *GetNewestEnvelopeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetNewestEnvelopeResponse.ProtoReflect.Descriptor instead. func (*GetNewestEnvelopeResponse) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{13} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{17} } func (x *GetNewestEnvelopeResponse) GetResults() []*GetNewestEnvelopeResponse_Response { @@ -773,7 +1046,7 @@ type SubscribeOriginatorsRequest struct { func (x *SubscribeOriginatorsRequest) Reset() { *x = SubscribeOriginatorsRequest{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[14] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -785,7 +1058,7 @@ func (x *SubscribeOriginatorsRequest) String() string { func (*SubscribeOriginatorsRequest) ProtoMessage() {} func (x *SubscribeOriginatorsRequest) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[14] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -798,7 +1071,7 @@ func (x *SubscribeOriginatorsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeOriginatorsRequest.ProtoReflect.Descriptor instead. func (*SubscribeOriginatorsRequest) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{14} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{18} } func (x *SubscribeOriginatorsRequest) GetFilter() *SubscribeOriginatorsRequest_OriginatorFilter { @@ -821,7 +1094,7 @@ type SubscribeOriginatorsResponse struct { func (x *SubscribeOriginatorsResponse) Reset() { *x = SubscribeOriginatorsResponse{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[15] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -833,7 +1106,7 @@ func (x *SubscribeOriginatorsResponse) String() string { func (*SubscribeOriginatorsResponse) ProtoMessage() {} func (x *SubscribeOriginatorsResponse) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[15] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -846,7 +1119,7 @@ func (x *SubscribeOriginatorsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeOriginatorsResponse.ProtoReflect.Descriptor instead. func (*SubscribeOriginatorsResponse) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{15} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{19} } func (x *SubscribeOriginatorsResponse) GetResponse() isSubscribeOriginatorsResponse_Response { @@ -885,7 +1158,7 @@ type SubscribeTopicsRequest_TopicFilter struct { func (x *SubscribeTopicsRequest_TopicFilter) Reset() { *x = SubscribeTopicsRequest_TopicFilter{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[16] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -897,7 +1170,7 @@ func (x *SubscribeTopicsRequest_TopicFilter) String() string { func (*SubscribeTopicsRequest_TopicFilter) ProtoMessage() {} func (x *SubscribeTopicsRequest_TopicFilter) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[16] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -936,7 +1209,7 @@ type SubscribeTopicsResponse_StatusUpdate struct { func (x *SubscribeTopicsResponse_StatusUpdate) Reset() { *x = SubscribeTopicsResponse_StatusUpdate{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[17] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -948,7 +1221,7 @@ func (x *SubscribeTopicsResponse_StatusUpdate) String() string { func (*SubscribeTopicsResponse_StatusUpdate) ProtoMessage() {} func (x *SubscribeTopicsResponse_StatusUpdate) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[17] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -980,7 +1253,7 @@ type SubscribeTopicsResponse_Envelopes struct { func (x *SubscribeTopicsResponse_Envelopes) Reset() { *x = SubscribeTopicsResponse_Envelopes{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[18] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -992,7 +1265,7 @@ func (x *SubscribeTopicsResponse_Envelopes) String() string { func (*SubscribeTopicsResponse_Envelopes) ProtoMessage() {} func (x *SubscribeTopicsResponse_Envelopes) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[18] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1015,7 +1288,592 @@ func (x *SubscribeTopicsResponse_Envelopes) GetEnvelopes() []*envelopes.Originat return nil } -// A single request for a given address +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_xmtpv4_message_api_message_api_proto_msgTypes[23] + 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_xmtpv4_message_api_message_api_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_V1.ProtoReflect.Descriptor instead. +func (*SubscribeRequest_V1) Descriptor() ([]byte, []int) { + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{5, 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 node 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 (XIP-49 §3.3.2): the +// first byte is the topic kind, the remainder is the identifier. A topic +// whose kind the node does not serve fails the stream with INVALID_ARGUMENT. +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 node finishes the wave 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_xmtpv4_message_api_message_api_proto_msgTypes[24] + 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_xmtpv4_message_api_message_api_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 SubscribeRequest_V1_Mutate.ProtoReflect.Descriptor instead. +func (*SubscribeRequest_V1_Mutate) Descriptor() ([]byte, []int) { + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{5, 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 vector 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"` + // Resume point: deliver envelopes beyond this per-originator vector + // cursor. Omitted/empty = from the beginning. Originators absent from + // the cursor map are treated as sequence 0 (the node fills them in), + // mirroring SubscribeTopics.TopicFilter.last_seen. + LastSeen *envelopes.Cursor `protobuf:"bytes,2,opt,name=last_seen,json=lastSeen,proto3" json:"last_seen,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeRequest_V1_Mutate_Subscription) Reset() { + *x = SubscribeRequest_V1_Mutate_Subscription{} + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[25] + 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_xmtpv4_message_api_message_api_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 SubscribeRequest_V1_Mutate_Subscription.ProtoReflect.Descriptor instead. +func (*SubscribeRequest_V1_Mutate_Subscription) Descriptor() ([]byte, []int) { + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{5, 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) GetLastSeen() *envelopes.Cursor { + if x != nil { + return x.LastSeen + } + return nil +} + +type SubscribeResponse_V1 struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Response: + // + // *SubscribeResponse_V1_Envelopes_ + // *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_xmtpv4_message_api_message_api_proto_msgTypes[26] + 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_xmtpv4_message_api_message_api_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 SubscribeResponse_V1.ProtoReflect.Descriptor instead. +func (*SubscribeResponse_V1) Descriptor() ([]byte, []int) { + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{6, 0} +} + +func (x *SubscribeResponse_V1) GetResponse() isSubscribeResponse_V1_Response { + if x != nil { + return x.Response + } + return nil +} + +func (x *SubscribeResponse_V1) GetEnvelopes() *SubscribeResponse_V1_Envelopes { + if x != nil { + if x, ok := x.Response.(*SubscribeResponse_V1_Envelopes_); ok { + return x.Envelopes + } + } + 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_Envelopes_ struct { + Envelopes *SubscribeResponse_V1_Envelopes `protobuf:"bytes,1,opt,name=envelopes,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_Envelopes_) 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 envelopes across the active subscriptions; the client demuxes +// by each envelope's target topic. +type SubscribeResponse_V1_Envelopes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Envelopes []*envelopes.OriginatorEnvelope `protobuf:"bytes,1,rep,name=envelopes,proto3" json:"envelopes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeResponse_V1_Envelopes) Reset() { + *x = SubscribeResponse_V1_Envelopes{} + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeResponse_V1_Envelopes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeResponse_V1_Envelopes) ProtoMessage() {} + +func (x *SubscribeResponse_V1_Envelopes) ProtoReflect() protoreflect.Message { + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[27] + 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_Envelopes.ProtoReflect.Descriptor instead. +func (*SubscribeResponse_V1_Envelopes) Descriptor() ([]byte, []int) { + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{6, 0, 0} +} + +func (x *SubscribeResponse_V1_Envelopes) GetEnvelopes() []*envelopes.OriginatorEnvelope { + if x != nil { + return x.Envelopes + } + return nil +} + +// The first frame on every stream. +type SubscribeResponse_V1_Started struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The node's ping cadence (ms): the basis for the client's staleness + // threshold and the node'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.xmtpv4.message_api.SubscribeResponse_V1_Capability" json:"capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeResponse_V1_Started) Reset() { + *x = SubscribeResponse_V1_Started{} + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[28] + 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_xmtpv4_message_api_message_api_proto_msgTypes[28] + 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_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{6, 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_xmtpv4_message_api_message_api_proto_msgTypes[29] + 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_xmtpv4_message_api_message_api_proto_msgTypes[29] + 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_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{6, 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 envelopes that queued 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_xmtpv4_message_api_message_api_proto_msgTypes[30] + 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_xmtpv4_message_api_message_api_proto_msgTypes[30] + 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_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{6, 0, 3} +} + +func (x *SubscribeResponse_V1_TopicsLive) GetTopics() [][]byte { + if x != nil { + return x.Topics + } + return nil +} + +// A single request for a given address type GetInboxIdsRequest_Request struct { state protoimpl.MessageState `protogen:"open.v1"` Identifier string `protobuf:"bytes,1,opt,name=identifier,proto3" json:"identifier,omitempty"` @@ -1026,7 +1884,7 @@ type GetInboxIdsRequest_Request struct { func (x *GetInboxIdsRequest_Request) Reset() { *x = GetInboxIdsRequest_Request{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[19] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1038,7 +1896,7 @@ func (x *GetInboxIdsRequest_Request) String() string { func (*GetInboxIdsRequest_Request) ProtoMessage() {} func (x *GetInboxIdsRequest_Request) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[19] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1051,7 +1909,7 @@ func (x *GetInboxIdsRequest_Request) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInboxIdsRequest_Request.ProtoReflect.Descriptor instead. func (*GetInboxIdsRequest_Request) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{10, 0} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{14, 0} } func (x *GetInboxIdsRequest_Request) GetIdentifier() string { @@ -1080,7 +1938,7 @@ type GetInboxIdsResponse_Response struct { func (x *GetInboxIdsResponse_Response) Reset() { *x = GetInboxIdsResponse_Response{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[20] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1092,7 +1950,7 @@ func (x *GetInboxIdsResponse_Response) String() string { func (*GetInboxIdsResponse_Response) ProtoMessage() {} func (x *GetInboxIdsResponse_Response) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[20] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1105,7 +1963,7 @@ func (x *GetInboxIdsResponse_Response) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInboxIdsResponse_Response.ProtoReflect.Descriptor instead. func (*GetInboxIdsResponse_Response) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{11, 0} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{15, 0} } func (x *GetInboxIdsResponse_Response) GetIdentifier() string { @@ -1138,7 +1996,7 @@ type GetNewestEnvelopeResponse_Response struct { func (x *GetNewestEnvelopeResponse_Response) Reset() { *x = GetNewestEnvelopeResponse_Response{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[21] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1150,7 +2008,7 @@ func (x *GetNewestEnvelopeResponse_Response) String() string { func (*GetNewestEnvelopeResponse_Response) ProtoMessage() {} func (x *GetNewestEnvelopeResponse_Response) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[21] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1163,7 +2021,7 @@ func (x *GetNewestEnvelopeResponse_Response) ProtoReflect() protoreflect.Message // Deprecated: Use GetNewestEnvelopeResponse_Response.ProtoReflect.Descriptor instead. func (*GetNewestEnvelopeResponse_Response) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{13, 0} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{17, 0} } func (x *GetNewestEnvelopeResponse_Response) GetOriginatorEnvelope() *envelopes.OriginatorEnvelope { @@ -1183,7 +2041,7 @@ type SubscribeOriginatorsRequest_OriginatorFilter struct { func (x *SubscribeOriginatorsRequest_OriginatorFilter) Reset() { *x = SubscribeOriginatorsRequest_OriginatorFilter{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[22] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1195,7 +2053,7 @@ func (x *SubscribeOriginatorsRequest_OriginatorFilter) String() string { func (*SubscribeOriginatorsRequest_OriginatorFilter) ProtoMessage() {} func (x *SubscribeOriginatorsRequest_OriginatorFilter) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[22] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1208,7 +2066,7 @@ func (x *SubscribeOriginatorsRequest_OriginatorFilter) ProtoReflect() protorefle // Deprecated: Use SubscribeOriginatorsRequest_OriginatorFilter.ProtoReflect.Descriptor instead. func (*SubscribeOriginatorsRequest_OriginatorFilter) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{14, 0} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{18, 0} } func (x *SubscribeOriginatorsRequest_OriginatorFilter) GetOriginatorNodeIds() []uint32 { @@ -1234,7 +2092,7 @@ type SubscribeOriginatorsResponse_Envelopes struct { func (x *SubscribeOriginatorsResponse_Envelopes) Reset() { *x = SubscribeOriginatorsResponse_Envelopes{} - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[23] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1246,7 +2104,7 @@ func (x *SubscribeOriginatorsResponse_Envelopes) String() string { func (*SubscribeOriginatorsResponse_Envelopes) ProtoMessage() {} func (x *SubscribeOriginatorsResponse_Envelopes) ProtoReflect() protoreflect.Message { - mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[23] + mi := &file_xmtpv4_message_api_message_api_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1259,7 +2117,7 @@ func (x *SubscribeOriginatorsResponse_Envelopes) ProtoReflect() protoreflect.Mes // Deprecated: Use SubscribeOriginatorsResponse_Envelopes.ProtoReflect.Descriptor instead. func (*SubscribeOriginatorsResponse_Envelopes) Descriptor() ([]byte, []int) { - return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{15, 0} + return file_xmtpv4_message_api_message_api_proto_rawDescGZIP(), []int{19, 0} } func (x *SubscribeOriginatorsResponse_Envelopes) GetEnvelopes() []*envelopes.OriginatorEnvelope { @@ -1300,7 +2158,53 @@ const file_xmtpv4_message_api_message_api_proto_rawDesc = "" + "\n" + "\bresponse\"e\n" + "\x1aSubscribeEnvelopesResponse\x12G\n" + - "\tenvelopes\x18\x01 \x03(\v2).xmtp.xmtpv4.envelopes.OriginatorEnvelopeR\tenvelopes\"\x1e\n" + + "\tenvelopes\x18\x01 \x03(\v2).xmtp.xmtpv4.envelopes.OriginatorEnvelopeR\tenvelopes\"\xc5\x04\n" + + "\x10SubscribeRequest\x12>\n" + + "\x02v1\x18\x01 \x01(\v2,.xmtp.xmtpv4.message_api.SubscribeRequest.V1H\x00R\x02v1\x1a\xe5\x03\n" + + "\x02V1\x12M\n" + + "\x06mutate\x18\x01 \x01(\v23.xmtp.xmtpv4.message_api.SubscribeRequest.V1.MutateH\x00R\x06mutate\x123\n" + + "\x04ping\x18\x02 \x01(\v2\x1d.xmtp.xmtpv4.message_api.PingH\x00R\x04ping\x123\n" + + "\x04pong\x18\x03 \x01(\v2\x1d.xmtp.xmtpv4.message_api.PongH\x00R\x04pong\x1a\x9a\x02\n" + + "\x06Mutate\x12T\n" + + "\x04adds\x18\x01 \x03(\v2@.xmtp.xmtpv4.message_api.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\x1a`\n" + + "\fSubscription\x12\x14\n" + + "\x05topic\x18\x01 \x01(\fR\x05topic\x12:\n" + + "\tlast_seen\x18\x02 \x01(\v2\x1d.xmtp.xmtpv4.envelopes.CursorR\blastSeenB\t\n" + + "\arequestB\t\n" + + "\aversion\"\xc5\a\n" + + "\x11SubscribeResponse\x12?\n" + + "\x02v1\x18\x01 \x01(\v2-.xmtp.xmtpv4.message_api.SubscribeResponse.V1H\x00R\x02v1\x1a\xe3\x06\n" + + "\x02V1\x12W\n" + + "\tenvelopes\x18\x01 \x01(\v27.xmtp.xmtpv4.message_api.SubscribeResponse.V1.EnvelopesH\x00R\tenvelopes\x12Q\n" + + "\astarted\x18\x02 \x01(\v25.xmtp.xmtpv4.message_api.SubscribeResponse.V1.StartedH\x00R\astarted\x123\n" + + "\x04ping\x18\x03 \x01(\v2\x1d.xmtp.xmtpv4.message_api.PingH\x00R\x04ping\x123\n" + + "\x04pong\x18\x04 \x01(\v2\x1d.xmtp.xmtpv4.message_api.PongH\x00R\x04pong\x12[\n" + + "\vtopics_live\x18\x05 \x01(\v28.xmtp.xmtpv4.message_api.SubscribeResponse.V1.TopicsLiveH\x00R\n" + + "topicsLive\x12j\n" + + "\x10catchup_complete\x18\x06 \x01(\v2=.xmtp.xmtpv4.message_api.SubscribeResponse.V1.CatchupCompleteH\x00R\x0fcatchupComplete\x1aT\n" + + "\tEnvelopes\x12G\n" + + "\tenvelopes\x18\x01 \x03(\v2).xmtp.xmtpv4.envelopes.OriginatorEnvelopeR\tenvelopes\x1a\x9b\x01\n" + + "\aStarted\x122\n" + + "\x15keepalive_interval_ms\x18\x01 \x01(\rR\x13keepaliveIntervalMs\x12\\\n" + + "\fcapabilities\x18\x02 \x03(\x0e28.xmtp.xmtpv4.message_api.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\"\x1e\n" + "\x1cSubscribeAllEnvelopesRequest\"l\n" + "\x15QueryEnvelopesRequest\x12=\n" + "\x05query\x18\x01 \x01(\v2'.xmtp.xmtpv4.message_api.EnvelopesQueryR\x05query\x12\x14\n" + @@ -1367,82 +2271,110 @@ func file_xmtpv4_message_api_message_api_proto_rawDescGZIP() []byte { return file_xmtpv4_message_api_message_api_proto_rawDescData } -var file_xmtpv4_message_api_message_api_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_xmtpv4_message_api_message_api_proto_msgTypes = make([]protoimpl.MessageInfo, 24) +var file_xmtpv4_message_api_message_api_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_xmtpv4_message_api_message_api_proto_msgTypes = make([]protoimpl.MessageInfo, 36) var file_xmtpv4_message_api_message_api_proto_goTypes = []any{ (SubscribeTopicsResponse_SubscriptionStatus)(0), // 0: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.SubscriptionStatus - (*EnvelopesQuery)(nil), // 1: xmtp.xmtpv4.message_api.EnvelopesQuery - (*SubscribeEnvelopesRequest)(nil), // 2: xmtp.xmtpv4.message_api.SubscribeEnvelopesRequest - (*SubscribeTopicsRequest)(nil), // 3: xmtp.xmtpv4.message_api.SubscribeTopicsRequest - (*SubscribeTopicsResponse)(nil), // 4: xmtp.xmtpv4.message_api.SubscribeTopicsResponse - (*SubscribeEnvelopesResponse)(nil), // 5: xmtp.xmtpv4.message_api.SubscribeEnvelopesResponse - (*SubscribeAllEnvelopesRequest)(nil), // 6: xmtp.xmtpv4.message_api.SubscribeAllEnvelopesRequest - (*QueryEnvelopesRequest)(nil), // 7: xmtp.xmtpv4.message_api.QueryEnvelopesRequest - (*QueryEnvelopesResponse)(nil), // 8: xmtp.xmtpv4.message_api.QueryEnvelopesResponse - (*PublishPayerEnvelopesRequest)(nil), // 9: xmtp.xmtpv4.message_api.PublishPayerEnvelopesRequest - (*PublishPayerEnvelopesResponse)(nil), // 10: xmtp.xmtpv4.message_api.PublishPayerEnvelopesResponse - (*GetInboxIdsRequest)(nil), // 11: xmtp.xmtpv4.message_api.GetInboxIdsRequest - (*GetInboxIdsResponse)(nil), // 12: xmtp.xmtpv4.message_api.GetInboxIdsResponse - (*GetNewestEnvelopeRequest)(nil), // 13: xmtp.xmtpv4.message_api.GetNewestEnvelopeRequest - (*GetNewestEnvelopeResponse)(nil), // 14: xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse - (*SubscribeOriginatorsRequest)(nil), // 15: xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest - (*SubscribeOriginatorsResponse)(nil), // 16: xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse - (*SubscribeTopicsRequest_TopicFilter)(nil), // 17: xmtp.xmtpv4.message_api.SubscribeTopicsRequest.TopicFilter - (*SubscribeTopicsResponse_StatusUpdate)(nil), // 18: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.StatusUpdate - (*SubscribeTopicsResponse_Envelopes)(nil), // 19: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.Envelopes - (*GetInboxIdsRequest_Request)(nil), // 20: xmtp.xmtpv4.message_api.GetInboxIdsRequest.Request - (*GetInboxIdsResponse_Response)(nil), // 21: xmtp.xmtpv4.message_api.GetInboxIdsResponse.Response - (*GetNewestEnvelopeResponse_Response)(nil), // 22: xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse.Response - (*SubscribeOriginatorsRequest_OriginatorFilter)(nil), // 23: xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest.OriginatorFilter - (*SubscribeOriginatorsResponse_Envelopes)(nil), // 24: xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse.Envelopes - (*envelopes.Cursor)(nil), // 25: xmtp.xmtpv4.envelopes.Cursor - (*envelopes.OriginatorEnvelope)(nil), // 26: xmtp.xmtpv4.envelopes.OriginatorEnvelope - (*envelopes.PayerEnvelope)(nil), // 27: xmtp.xmtpv4.envelopes.PayerEnvelope - (associations.IdentifierKind)(0), // 28: xmtp.identity.associations.IdentifierKind + (SubscribeResponse_V1_Capability)(0), // 1: xmtp.xmtpv4.message_api.SubscribeResponse.V1.Capability + (*EnvelopesQuery)(nil), // 2: xmtp.xmtpv4.message_api.EnvelopesQuery + (*SubscribeEnvelopesRequest)(nil), // 3: xmtp.xmtpv4.message_api.SubscribeEnvelopesRequest + (*SubscribeTopicsRequest)(nil), // 4: xmtp.xmtpv4.message_api.SubscribeTopicsRequest + (*SubscribeTopicsResponse)(nil), // 5: xmtp.xmtpv4.message_api.SubscribeTopicsResponse + (*SubscribeEnvelopesResponse)(nil), // 6: xmtp.xmtpv4.message_api.SubscribeEnvelopesResponse + (*SubscribeRequest)(nil), // 7: xmtp.xmtpv4.message_api.SubscribeRequest + (*SubscribeResponse)(nil), // 8: xmtp.xmtpv4.message_api.SubscribeResponse + (*Ping)(nil), // 9: xmtp.xmtpv4.message_api.Ping + (*Pong)(nil), // 10: xmtp.xmtpv4.message_api.Pong + (*SubscribeAllEnvelopesRequest)(nil), // 11: xmtp.xmtpv4.message_api.SubscribeAllEnvelopesRequest + (*QueryEnvelopesRequest)(nil), // 12: xmtp.xmtpv4.message_api.QueryEnvelopesRequest + (*QueryEnvelopesResponse)(nil), // 13: xmtp.xmtpv4.message_api.QueryEnvelopesResponse + (*PublishPayerEnvelopesRequest)(nil), // 14: xmtp.xmtpv4.message_api.PublishPayerEnvelopesRequest + (*PublishPayerEnvelopesResponse)(nil), // 15: xmtp.xmtpv4.message_api.PublishPayerEnvelopesResponse + (*GetInboxIdsRequest)(nil), // 16: xmtp.xmtpv4.message_api.GetInboxIdsRequest + (*GetInboxIdsResponse)(nil), // 17: xmtp.xmtpv4.message_api.GetInboxIdsResponse + (*GetNewestEnvelopeRequest)(nil), // 18: xmtp.xmtpv4.message_api.GetNewestEnvelopeRequest + (*GetNewestEnvelopeResponse)(nil), // 19: xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse + (*SubscribeOriginatorsRequest)(nil), // 20: xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest + (*SubscribeOriginatorsResponse)(nil), // 21: xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse + (*SubscribeTopicsRequest_TopicFilter)(nil), // 22: xmtp.xmtpv4.message_api.SubscribeTopicsRequest.TopicFilter + (*SubscribeTopicsResponse_StatusUpdate)(nil), // 23: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.StatusUpdate + (*SubscribeTopicsResponse_Envelopes)(nil), // 24: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.Envelopes + (*SubscribeRequest_V1)(nil), // 25: xmtp.xmtpv4.message_api.SubscribeRequest.V1 + (*SubscribeRequest_V1_Mutate)(nil), // 26: xmtp.xmtpv4.message_api.SubscribeRequest.V1.Mutate + (*SubscribeRequest_V1_Mutate_Subscription)(nil), // 27: xmtp.xmtpv4.message_api.SubscribeRequest.V1.Mutate.Subscription + (*SubscribeResponse_V1)(nil), // 28: xmtp.xmtpv4.message_api.SubscribeResponse.V1 + (*SubscribeResponse_V1_Envelopes)(nil), // 29: xmtp.xmtpv4.message_api.SubscribeResponse.V1.Envelopes + (*SubscribeResponse_V1_Started)(nil), // 30: xmtp.xmtpv4.message_api.SubscribeResponse.V1.Started + (*SubscribeResponse_V1_CatchupComplete)(nil), // 31: xmtp.xmtpv4.message_api.SubscribeResponse.V1.CatchupComplete + (*SubscribeResponse_V1_TopicsLive)(nil), // 32: xmtp.xmtpv4.message_api.SubscribeResponse.V1.TopicsLive + (*GetInboxIdsRequest_Request)(nil), // 33: xmtp.xmtpv4.message_api.GetInboxIdsRequest.Request + (*GetInboxIdsResponse_Response)(nil), // 34: xmtp.xmtpv4.message_api.GetInboxIdsResponse.Response + (*GetNewestEnvelopeResponse_Response)(nil), // 35: xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse.Response + (*SubscribeOriginatorsRequest_OriginatorFilter)(nil), // 36: xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest.OriginatorFilter + (*SubscribeOriginatorsResponse_Envelopes)(nil), // 37: xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse.Envelopes + (*envelopes.Cursor)(nil), // 38: xmtp.xmtpv4.envelopes.Cursor + (*envelopes.OriginatorEnvelope)(nil), // 39: xmtp.xmtpv4.envelopes.OriginatorEnvelope + (*envelopes.PayerEnvelope)(nil), // 40: xmtp.xmtpv4.envelopes.PayerEnvelope + (associations.IdentifierKind)(0), // 41: xmtp.identity.associations.IdentifierKind } var file_xmtpv4_message_api_message_api_proto_depIdxs = []int32{ - 25, // 0: xmtp.xmtpv4.message_api.EnvelopesQuery.last_seen:type_name -> xmtp.xmtpv4.envelopes.Cursor - 1, // 1: xmtp.xmtpv4.message_api.SubscribeEnvelopesRequest.query:type_name -> xmtp.xmtpv4.message_api.EnvelopesQuery - 17, // 2: xmtp.xmtpv4.message_api.SubscribeTopicsRequest.filters:type_name -> xmtp.xmtpv4.message_api.SubscribeTopicsRequest.TopicFilter - 19, // 3: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.envelopes:type_name -> xmtp.xmtpv4.message_api.SubscribeTopicsResponse.Envelopes - 18, // 4: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.status_update:type_name -> xmtp.xmtpv4.message_api.SubscribeTopicsResponse.StatusUpdate - 26, // 5: xmtp.xmtpv4.message_api.SubscribeEnvelopesResponse.envelopes:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope - 1, // 6: xmtp.xmtpv4.message_api.QueryEnvelopesRequest.query:type_name -> xmtp.xmtpv4.message_api.EnvelopesQuery - 26, // 7: xmtp.xmtpv4.message_api.QueryEnvelopesResponse.envelopes:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope - 27, // 8: xmtp.xmtpv4.message_api.PublishPayerEnvelopesRequest.payer_envelopes:type_name -> xmtp.xmtpv4.envelopes.PayerEnvelope - 26, // 9: xmtp.xmtpv4.message_api.PublishPayerEnvelopesResponse.originator_envelopes:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope - 20, // 10: xmtp.xmtpv4.message_api.GetInboxIdsRequest.requests:type_name -> xmtp.xmtpv4.message_api.GetInboxIdsRequest.Request - 21, // 11: xmtp.xmtpv4.message_api.GetInboxIdsResponse.responses:type_name -> xmtp.xmtpv4.message_api.GetInboxIdsResponse.Response - 22, // 12: xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse.results:type_name -> xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse.Response - 23, // 13: xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest.filter:type_name -> xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest.OriginatorFilter - 24, // 14: xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse.envelopes:type_name -> xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse.Envelopes - 25, // 15: xmtp.xmtpv4.message_api.SubscribeTopicsRequest.TopicFilter.last_seen:type_name -> xmtp.xmtpv4.envelopes.Cursor - 0, // 16: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.StatusUpdate.status:type_name -> xmtp.xmtpv4.message_api.SubscribeTopicsResponse.SubscriptionStatus - 26, // 17: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.Envelopes.envelopes:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope - 28, // 18: xmtp.xmtpv4.message_api.GetInboxIdsRequest.Request.identifier_kind:type_name -> xmtp.identity.associations.IdentifierKind - 28, // 19: xmtp.xmtpv4.message_api.GetInboxIdsResponse.Response.identifier_kind:type_name -> xmtp.identity.associations.IdentifierKind - 26, // 20: xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse.Response.originator_envelope:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope - 25, // 21: xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest.OriginatorFilter.last_seen:type_name -> xmtp.xmtpv4.envelopes.Cursor - 26, // 22: xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse.Envelopes.envelopes:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope - 15, // 23: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeOriginators:input_type -> xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest - 2, // 24: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeEnvelopes:input_type -> xmtp.xmtpv4.message_api.SubscribeEnvelopesRequest - 3, // 25: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeTopics:input_type -> xmtp.xmtpv4.message_api.SubscribeTopicsRequest - 7, // 26: xmtp.xmtpv4.message_api.ReplicationApi.QueryEnvelopes:input_type -> xmtp.xmtpv4.message_api.QueryEnvelopesRequest - 9, // 27: xmtp.xmtpv4.message_api.ReplicationApi.PublishPayerEnvelopes:input_type -> xmtp.xmtpv4.message_api.PublishPayerEnvelopesRequest - 11, // 28: xmtp.xmtpv4.message_api.ReplicationApi.GetInboxIds:input_type -> xmtp.xmtpv4.message_api.GetInboxIdsRequest - 13, // 29: xmtp.xmtpv4.message_api.ReplicationApi.GetNewestEnvelope:input_type -> xmtp.xmtpv4.message_api.GetNewestEnvelopeRequest - 16, // 30: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeOriginators:output_type -> xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse - 5, // 31: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeEnvelopes:output_type -> xmtp.xmtpv4.message_api.SubscribeEnvelopesResponse - 4, // 32: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeTopics:output_type -> xmtp.xmtpv4.message_api.SubscribeTopicsResponse - 8, // 33: xmtp.xmtpv4.message_api.ReplicationApi.QueryEnvelopes:output_type -> xmtp.xmtpv4.message_api.QueryEnvelopesResponse - 10, // 34: xmtp.xmtpv4.message_api.ReplicationApi.PublishPayerEnvelopes:output_type -> xmtp.xmtpv4.message_api.PublishPayerEnvelopesResponse - 12, // 35: xmtp.xmtpv4.message_api.ReplicationApi.GetInboxIds:output_type -> xmtp.xmtpv4.message_api.GetInboxIdsResponse - 14, // 36: xmtp.xmtpv4.message_api.ReplicationApi.GetNewestEnvelope:output_type -> xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse - 30, // [30:37] is the sub-list for method output_type - 23, // [23:30] is the sub-list for method input_type - 23, // [23:23] is the sub-list for extension type_name - 23, // [23:23] is the sub-list for extension extendee - 0, // [0:23] is the sub-list for field type_name + 38, // 0: xmtp.xmtpv4.message_api.EnvelopesQuery.last_seen:type_name -> xmtp.xmtpv4.envelopes.Cursor + 2, // 1: xmtp.xmtpv4.message_api.SubscribeEnvelopesRequest.query:type_name -> xmtp.xmtpv4.message_api.EnvelopesQuery + 22, // 2: xmtp.xmtpv4.message_api.SubscribeTopicsRequest.filters:type_name -> xmtp.xmtpv4.message_api.SubscribeTopicsRequest.TopicFilter + 24, // 3: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.envelopes:type_name -> xmtp.xmtpv4.message_api.SubscribeTopicsResponse.Envelopes + 23, // 4: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.status_update:type_name -> xmtp.xmtpv4.message_api.SubscribeTopicsResponse.StatusUpdate + 39, // 5: xmtp.xmtpv4.message_api.SubscribeEnvelopesResponse.envelopes:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope + 25, // 6: xmtp.xmtpv4.message_api.SubscribeRequest.v1:type_name -> xmtp.xmtpv4.message_api.SubscribeRequest.V1 + 28, // 7: xmtp.xmtpv4.message_api.SubscribeResponse.v1:type_name -> xmtp.xmtpv4.message_api.SubscribeResponse.V1 + 2, // 8: xmtp.xmtpv4.message_api.QueryEnvelopesRequest.query:type_name -> xmtp.xmtpv4.message_api.EnvelopesQuery + 39, // 9: xmtp.xmtpv4.message_api.QueryEnvelopesResponse.envelopes:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope + 40, // 10: xmtp.xmtpv4.message_api.PublishPayerEnvelopesRequest.payer_envelopes:type_name -> xmtp.xmtpv4.envelopes.PayerEnvelope + 39, // 11: xmtp.xmtpv4.message_api.PublishPayerEnvelopesResponse.originator_envelopes:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope + 33, // 12: xmtp.xmtpv4.message_api.GetInboxIdsRequest.requests:type_name -> xmtp.xmtpv4.message_api.GetInboxIdsRequest.Request + 34, // 13: xmtp.xmtpv4.message_api.GetInboxIdsResponse.responses:type_name -> xmtp.xmtpv4.message_api.GetInboxIdsResponse.Response + 35, // 14: xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse.results:type_name -> xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse.Response + 36, // 15: xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest.filter:type_name -> xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest.OriginatorFilter + 37, // 16: xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse.envelopes:type_name -> xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse.Envelopes + 38, // 17: xmtp.xmtpv4.message_api.SubscribeTopicsRequest.TopicFilter.last_seen:type_name -> xmtp.xmtpv4.envelopes.Cursor + 0, // 18: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.StatusUpdate.status:type_name -> xmtp.xmtpv4.message_api.SubscribeTopicsResponse.SubscriptionStatus + 39, // 19: xmtp.xmtpv4.message_api.SubscribeTopicsResponse.Envelopes.envelopes:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope + 26, // 20: xmtp.xmtpv4.message_api.SubscribeRequest.V1.mutate:type_name -> xmtp.xmtpv4.message_api.SubscribeRequest.V1.Mutate + 9, // 21: xmtp.xmtpv4.message_api.SubscribeRequest.V1.ping:type_name -> xmtp.xmtpv4.message_api.Ping + 10, // 22: xmtp.xmtpv4.message_api.SubscribeRequest.V1.pong:type_name -> xmtp.xmtpv4.message_api.Pong + 27, // 23: xmtp.xmtpv4.message_api.SubscribeRequest.V1.Mutate.adds:type_name -> xmtp.xmtpv4.message_api.SubscribeRequest.V1.Mutate.Subscription + 38, // 24: xmtp.xmtpv4.message_api.SubscribeRequest.V1.Mutate.Subscription.last_seen:type_name -> xmtp.xmtpv4.envelopes.Cursor + 29, // 25: xmtp.xmtpv4.message_api.SubscribeResponse.V1.envelopes:type_name -> xmtp.xmtpv4.message_api.SubscribeResponse.V1.Envelopes + 30, // 26: xmtp.xmtpv4.message_api.SubscribeResponse.V1.started:type_name -> xmtp.xmtpv4.message_api.SubscribeResponse.V1.Started + 9, // 27: xmtp.xmtpv4.message_api.SubscribeResponse.V1.ping:type_name -> xmtp.xmtpv4.message_api.Ping + 10, // 28: xmtp.xmtpv4.message_api.SubscribeResponse.V1.pong:type_name -> xmtp.xmtpv4.message_api.Pong + 32, // 29: xmtp.xmtpv4.message_api.SubscribeResponse.V1.topics_live:type_name -> xmtp.xmtpv4.message_api.SubscribeResponse.V1.TopicsLive + 31, // 30: xmtp.xmtpv4.message_api.SubscribeResponse.V1.catchup_complete:type_name -> xmtp.xmtpv4.message_api.SubscribeResponse.V1.CatchupComplete + 39, // 31: xmtp.xmtpv4.message_api.SubscribeResponse.V1.Envelopes.envelopes:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope + 1, // 32: xmtp.xmtpv4.message_api.SubscribeResponse.V1.Started.capabilities:type_name -> xmtp.xmtpv4.message_api.SubscribeResponse.V1.Capability + 41, // 33: xmtp.xmtpv4.message_api.GetInboxIdsRequest.Request.identifier_kind:type_name -> xmtp.identity.associations.IdentifierKind + 41, // 34: xmtp.xmtpv4.message_api.GetInboxIdsResponse.Response.identifier_kind:type_name -> xmtp.identity.associations.IdentifierKind + 39, // 35: xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse.Response.originator_envelope:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope + 38, // 36: xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest.OriginatorFilter.last_seen:type_name -> xmtp.xmtpv4.envelopes.Cursor + 39, // 37: xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse.Envelopes.envelopes:type_name -> xmtp.xmtpv4.envelopes.OriginatorEnvelope + 20, // 38: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeOriginators:input_type -> xmtp.xmtpv4.message_api.SubscribeOriginatorsRequest + 3, // 39: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeEnvelopes:input_type -> xmtp.xmtpv4.message_api.SubscribeEnvelopesRequest + 4, // 40: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeTopics:input_type -> xmtp.xmtpv4.message_api.SubscribeTopicsRequest + 12, // 41: xmtp.xmtpv4.message_api.ReplicationApi.QueryEnvelopes:input_type -> xmtp.xmtpv4.message_api.QueryEnvelopesRequest + 14, // 42: xmtp.xmtpv4.message_api.ReplicationApi.PublishPayerEnvelopes:input_type -> xmtp.xmtpv4.message_api.PublishPayerEnvelopesRequest + 16, // 43: xmtp.xmtpv4.message_api.ReplicationApi.GetInboxIds:input_type -> xmtp.xmtpv4.message_api.GetInboxIdsRequest + 18, // 44: xmtp.xmtpv4.message_api.ReplicationApi.GetNewestEnvelope:input_type -> xmtp.xmtpv4.message_api.GetNewestEnvelopeRequest + 21, // 45: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeOriginators:output_type -> xmtp.xmtpv4.message_api.SubscribeOriginatorsResponse + 6, // 46: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeEnvelopes:output_type -> xmtp.xmtpv4.message_api.SubscribeEnvelopesResponse + 5, // 47: xmtp.xmtpv4.message_api.ReplicationApi.SubscribeTopics:output_type -> xmtp.xmtpv4.message_api.SubscribeTopicsResponse + 13, // 48: xmtp.xmtpv4.message_api.ReplicationApi.QueryEnvelopes:output_type -> xmtp.xmtpv4.message_api.QueryEnvelopesResponse + 15, // 49: xmtp.xmtpv4.message_api.ReplicationApi.PublishPayerEnvelopes:output_type -> xmtp.xmtpv4.message_api.PublishPayerEnvelopesResponse + 17, // 50: xmtp.xmtpv4.message_api.ReplicationApi.GetInboxIds:output_type -> xmtp.xmtpv4.message_api.GetInboxIdsResponse + 19, // 51: xmtp.xmtpv4.message_api.ReplicationApi.GetNewestEnvelope:output_type -> xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse + 45, // [45:52] is the sub-list for method output_type + 38, // [38:45] 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 } func init() { file_xmtpv4_message_api_message_api_proto_init() } @@ -1454,18 +2386,37 @@ func file_xmtpv4_message_api_message_api_proto_init() { (*SubscribeTopicsResponse_Envelopes_)(nil), (*SubscribeTopicsResponse_StatusUpdate_)(nil), } - file_xmtpv4_message_api_message_api_proto_msgTypes[15].OneofWrappers = []any{ + file_xmtpv4_message_api_message_api_proto_msgTypes[5].OneofWrappers = []any{ + (*SubscribeRequest_V1_)(nil), + } + file_xmtpv4_message_api_message_api_proto_msgTypes[6].OneofWrappers = []any{ + (*SubscribeResponse_V1_)(nil), + } + file_xmtpv4_message_api_message_api_proto_msgTypes[19].OneofWrappers = []any{ (*SubscribeOriginatorsResponse_Envelopes_)(nil), } - file_xmtpv4_message_api_message_api_proto_msgTypes[20].OneofWrappers = []any{} - file_xmtpv4_message_api_message_api_proto_msgTypes[21].OneofWrappers = []any{} + file_xmtpv4_message_api_message_api_proto_msgTypes[23].OneofWrappers = []any{ + (*SubscribeRequest_V1_Mutate_)(nil), + (*SubscribeRequest_V1_Ping)(nil), + (*SubscribeRequest_V1_Pong)(nil), + } + file_xmtpv4_message_api_message_api_proto_msgTypes[26].OneofWrappers = []any{ + (*SubscribeResponse_V1_Envelopes_)(nil), + (*SubscribeResponse_V1_Started_)(nil), + (*SubscribeResponse_V1_Ping)(nil), + (*SubscribeResponse_V1_Pong)(nil), + (*SubscribeResponse_V1_TopicsLive_)(nil), + (*SubscribeResponse_V1_CatchupComplete_)(nil), + } + file_xmtpv4_message_api_message_api_proto_msgTypes[32].OneofWrappers = []any{} + file_xmtpv4_message_api_message_api_proto_msgTypes[33].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_xmtpv4_message_api_message_api_proto_rawDesc), len(file_xmtpv4_message_api_message_api_proto_rawDesc)), - NumEnums: 1, - NumMessages: 24, + NumEnums: 2, + NumMessages: 36, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/proto/xmtpv4/message_api/message_apiconnect/query_api.connect.go b/pkg/proto/xmtpv4/message_api/message_apiconnect/query_api.connect.go index e292fc8cd..af67451af 100644 --- a/pkg/proto/xmtpv4/message_api/message_apiconnect/query_api.connect.go +++ b/pkg/proto/xmtpv4/message_api/message_apiconnect/query_api.connect.go @@ -40,6 +40,8 @@ const ( // QueryApiSubscribeTopicsProcedure is the fully-qualified name of the QueryApi's SubscribeTopics // RPC. QueryApiSubscribeTopicsProcedure = "/xmtp.xmtpv4.message_api.QueryApi/SubscribeTopics" + // QueryApiSubscribeProcedure is the fully-qualified name of the QueryApi's Subscribe RPC. + QueryApiSubscribeProcedure = "/xmtp.xmtpv4.message_api.QueryApi/Subscribe" // QueryApiGetInboxIdsProcedure is the fully-qualified name of the QueryApi's GetInboxIds RPC. QueryApiGetInboxIdsProcedure = "/xmtp.xmtpv4.message_api.QueryApi/GetInboxIds" // QueryApiGetNewestEnvelopeProcedure is the fully-qualified name of the QueryApi's @@ -51,6 +53,12 @@ const ( type QueryApiClient interface { QueryEnvelopes(context.Context, *connect.Request[message_api.QueryEnvelopesRequest]) (*connect.Response[message_api.QueryEnvelopesResponse], error) SubscribeTopics(context.Context, *connect.Request[message_api.SubscribeTopicsRequest]) (*connect.ServerStreamForClient[message_api.SubscribeTopicsResponse], error) + // XIP-83 bidirectional mutable subscription: a single long-lived stream the + // client mutates in place (add/remove topics) with ping/pong liveness, in + // contrast to SubscribeTopics' fixed, immutable, server-streaming filter set. + // Bidi streaming requires HTTP/2 (not grpc-web / connect-web); browser + // clients stay on SubscribeTopics. + Subscribe(context.Context) *connect.BidiStreamForClient[message_api.SubscribeRequest, message_api.SubscribeResponse] GetInboxIds(context.Context, *connect.Request[message_api.GetInboxIdsRequest]) (*connect.Response[message_api.GetInboxIdsResponse], error) GetNewestEnvelope(context.Context, *connect.Request[message_api.GetNewestEnvelopeRequest]) (*connect.Response[message_api.GetNewestEnvelopeResponse], error) } @@ -78,6 +86,12 @@ func NewQueryApiClient(httpClient connect.HTTPClient, baseURL string, opts ...co connect.WithSchema(queryApiMethods.ByName("SubscribeTopics")), connect.WithClientOptions(opts...), ), + subscribe: connect.NewClient[message_api.SubscribeRequest, message_api.SubscribeResponse]( + httpClient, + baseURL+QueryApiSubscribeProcedure, + connect.WithSchema(queryApiMethods.ByName("Subscribe")), + connect.WithClientOptions(opts...), + ), getInboxIds: connect.NewClient[message_api.GetInboxIdsRequest, message_api.GetInboxIdsResponse]( httpClient, baseURL+QueryApiGetInboxIdsProcedure, @@ -97,6 +111,7 @@ func NewQueryApiClient(httpClient connect.HTTPClient, baseURL string, opts ...co type queryApiClient struct { queryEnvelopes *connect.Client[message_api.QueryEnvelopesRequest, message_api.QueryEnvelopesResponse] subscribeTopics *connect.Client[message_api.SubscribeTopicsRequest, message_api.SubscribeTopicsResponse] + subscribe *connect.Client[message_api.SubscribeRequest, message_api.SubscribeResponse] getInboxIds *connect.Client[message_api.GetInboxIdsRequest, message_api.GetInboxIdsResponse] getNewestEnvelope *connect.Client[message_api.GetNewestEnvelopeRequest, message_api.GetNewestEnvelopeResponse] } @@ -111,6 +126,11 @@ func (c *queryApiClient) SubscribeTopics(ctx context.Context, req *connect.Reque return c.subscribeTopics.CallServerStream(ctx, req) } +// Subscribe calls xmtp.xmtpv4.message_api.QueryApi.Subscribe. +func (c *queryApiClient) Subscribe(ctx context.Context) *connect.BidiStreamForClient[message_api.SubscribeRequest, message_api.SubscribeResponse] { + return c.subscribe.CallBidiStream(ctx) +} + // GetInboxIds calls xmtp.xmtpv4.message_api.QueryApi.GetInboxIds. func (c *queryApiClient) GetInboxIds(ctx context.Context, req *connect.Request[message_api.GetInboxIdsRequest]) (*connect.Response[message_api.GetInboxIdsResponse], error) { return c.getInboxIds.CallUnary(ctx, req) @@ -125,6 +145,12 @@ func (c *queryApiClient) GetNewestEnvelope(ctx context.Context, req *connect.Req type QueryApiHandler interface { QueryEnvelopes(context.Context, *connect.Request[message_api.QueryEnvelopesRequest]) (*connect.Response[message_api.QueryEnvelopesResponse], error) SubscribeTopics(context.Context, *connect.Request[message_api.SubscribeTopicsRequest], *connect.ServerStream[message_api.SubscribeTopicsResponse]) error + // XIP-83 bidirectional mutable subscription: a single long-lived stream the + // client mutates in place (add/remove topics) with ping/pong liveness, in + // contrast to SubscribeTopics' fixed, immutable, server-streaming filter set. + // Bidi streaming requires HTTP/2 (not grpc-web / connect-web); browser + // clients stay on SubscribeTopics. + Subscribe(context.Context, *connect.BidiStream[message_api.SubscribeRequest, message_api.SubscribeResponse]) error GetInboxIds(context.Context, *connect.Request[message_api.GetInboxIdsRequest]) (*connect.Response[message_api.GetInboxIdsResponse], error) GetNewestEnvelope(context.Context, *connect.Request[message_api.GetNewestEnvelopeRequest]) (*connect.Response[message_api.GetNewestEnvelopeResponse], error) } @@ -148,6 +174,12 @@ func NewQueryApiHandler(svc QueryApiHandler, opts ...connect.HandlerOption) (str connect.WithSchema(queryApiMethods.ByName("SubscribeTopics")), connect.WithHandlerOptions(opts...), ) + queryApiSubscribeHandler := connect.NewBidiStreamHandler( + QueryApiSubscribeProcedure, + svc.Subscribe, + connect.WithSchema(queryApiMethods.ByName("Subscribe")), + connect.WithHandlerOptions(opts...), + ) queryApiGetInboxIdsHandler := connect.NewUnaryHandler( QueryApiGetInboxIdsProcedure, svc.GetInboxIds, @@ -166,6 +198,8 @@ func NewQueryApiHandler(svc QueryApiHandler, opts ...connect.HandlerOption) (str queryApiQueryEnvelopesHandler.ServeHTTP(w, r) case QueryApiSubscribeTopicsProcedure: queryApiSubscribeTopicsHandler.ServeHTTP(w, r) + case QueryApiSubscribeProcedure: + queryApiSubscribeHandler.ServeHTTP(w, r) case QueryApiGetInboxIdsProcedure: queryApiGetInboxIdsHandler.ServeHTTP(w, r) case QueryApiGetNewestEnvelopeProcedure: @@ -187,6 +221,10 @@ func (UnimplementedQueryApiHandler) SubscribeTopics(context.Context, *connect.Re return connect.NewError(connect.CodeUnimplemented, errors.New("xmtp.xmtpv4.message_api.QueryApi.SubscribeTopics is not implemented")) } +func (UnimplementedQueryApiHandler) Subscribe(context.Context, *connect.BidiStream[message_api.SubscribeRequest, message_api.SubscribeResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("xmtp.xmtpv4.message_api.QueryApi.Subscribe is not implemented")) +} + func (UnimplementedQueryApiHandler) GetInboxIds(context.Context, *connect.Request[message_api.GetInboxIdsRequest]) (*connect.Response[message_api.GetInboxIdsResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("xmtp.xmtpv4.message_api.QueryApi.GetInboxIds is not implemented")) } diff --git a/pkg/proto/xmtpv4/message_api/query_api.pb.go b/pkg/proto/xmtpv4/message_api/query_api.pb.go index 3cadcd84e..0210f99d9 100644 --- a/pkg/proto/xmtpv4/message_api/query_api.pb.go +++ b/pkg/proto/xmtpv4/message_api/query_api.pb.go @@ -26,10 +26,11 @@ var File_xmtpv4_message_api_query_api_proto protoreflect.FileDescriptor const file_xmtpv4_message_api_query_api_proto_rawDesc = "" + "\n" + - "\"xmtpv4/message_api/query_api.proto\x12\x17xmtp.xmtpv4.message_api\x1a$xmtpv4/message_api/message_api.proto2\xe3\x03\n" + + "\"xmtpv4/message_api/query_api.proto\x12\x17xmtp.xmtpv4.message_api\x1a$xmtpv4/message_api/message_api.proto2\xcd\x04\n" + "\bQueryApi\x12s\n" + "\x0eQueryEnvelopes\x12..xmtp.xmtpv4.message_api.QueryEnvelopesRequest\x1a/.xmtp.xmtpv4.message_api.QueryEnvelopesResponse\"\x00\x12x\n" + - "\x0fSubscribeTopics\x12/.xmtp.xmtpv4.message_api.SubscribeTopicsRequest\x1a0.xmtp.xmtpv4.message_api.SubscribeTopicsResponse\"\x000\x01\x12j\n" + + "\x0fSubscribeTopics\x12/.xmtp.xmtpv4.message_api.SubscribeTopicsRequest\x1a0.xmtp.xmtpv4.message_api.SubscribeTopicsResponse\"\x000\x01\x12h\n" + + "\tSubscribe\x12).xmtp.xmtpv4.message_api.SubscribeRequest\x1a*.xmtp.xmtpv4.message_api.SubscribeResponse\"\x00(\x010\x01\x12j\n" + "\vGetInboxIds\x12+.xmtp.xmtpv4.message_api.GetInboxIdsRequest\x1a,.xmtp.xmtpv4.message_api.GetInboxIdsResponse\"\x00\x12|\n" + "\x11GetNewestEnvelope\x121.xmtp.xmtpv4.message_api.GetNewestEnvelopeRequest\x1a2.xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse\"\x00B\xda\x01\n" + "\x1bcom.xmtp.xmtpv4.message_apiB\rQueryApiProtoP\x01Z2github.com/xmtp/xmtpd/pkg/proto/xmtpv4/message_api\xa2\x02\x03XXM\xaa\x02\x16Xmtp.Xmtpv4.MessageApi\xca\x02\x16Xmtp\\Xmtpv4\\MessageApi\xe2\x02\"Xmtp\\Xmtpv4\\MessageApi\\GPBMetadata\xea\x02\x18Xmtp::Xmtpv4::MessageApib\x06proto3" @@ -37,24 +38,28 @@ const file_xmtpv4_message_api_query_api_proto_rawDesc = "" + var file_xmtpv4_message_api_query_api_proto_goTypes = []any{ (*QueryEnvelopesRequest)(nil), // 0: xmtp.xmtpv4.message_api.QueryEnvelopesRequest (*SubscribeTopicsRequest)(nil), // 1: xmtp.xmtpv4.message_api.SubscribeTopicsRequest - (*GetInboxIdsRequest)(nil), // 2: xmtp.xmtpv4.message_api.GetInboxIdsRequest - (*GetNewestEnvelopeRequest)(nil), // 3: xmtp.xmtpv4.message_api.GetNewestEnvelopeRequest - (*QueryEnvelopesResponse)(nil), // 4: xmtp.xmtpv4.message_api.QueryEnvelopesResponse - (*SubscribeTopicsResponse)(nil), // 5: xmtp.xmtpv4.message_api.SubscribeTopicsResponse - (*GetInboxIdsResponse)(nil), // 6: xmtp.xmtpv4.message_api.GetInboxIdsResponse - (*GetNewestEnvelopeResponse)(nil), // 7: xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse + (*SubscribeRequest)(nil), // 2: xmtp.xmtpv4.message_api.SubscribeRequest + (*GetInboxIdsRequest)(nil), // 3: xmtp.xmtpv4.message_api.GetInboxIdsRequest + (*GetNewestEnvelopeRequest)(nil), // 4: xmtp.xmtpv4.message_api.GetNewestEnvelopeRequest + (*QueryEnvelopesResponse)(nil), // 5: xmtp.xmtpv4.message_api.QueryEnvelopesResponse + (*SubscribeTopicsResponse)(nil), // 6: xmtp.xmtpv4.message_api.SubscribeTopicsResponse + (*SubscribeResponse)(nil), // 7: xmtp.xmtpv4.message_api.SubscribeResponse + (*GetInboxIdsResponse)(nil), // 8: xmtp.xmtpv4.message_api.GetInboxIdsResponse + (*GetNewestEnvelopeResponse)(nil), // 9: xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse } var file_xmtpv4_message_api_query_api_proto_depIdxs = []int32{ 0, // 0: xmtp.xmtpv4.message_api.QueryApi.QueryEnvelopes:input_type -> xmtp.xmtpv4.message_api.QueryEnvelopesRequest 1, // 1: xmtp.xmtpv4.message_api.QueryApi.SubscribeTopics:input_type -> xmtp.xmtpv4.message_api.SubscribeTopicsRequest - 2, // 2: xmtp.xmtpv4.message_api.QueryApi.GetInboxIds:input_type -> xmtp.xmtpv4.message_api.GetInboxIdsRequest - 3, // 3: xmtp.xmtpv4.message_api.QueryApi.GetNewestEnvelope:input_type -> xmtp.xmtpv4.message_api.GetNewestEnvelopeRequest - 4, // 4: xmtp.xmtpv4.message_api.QueryApi.QueryEnvelopes:output_type -> xmtp.xmtpv4.message_api.QueryEnvelopesResponse - 5, // 5: xmtp.xmtpv4.message_api.QueryApi.SubscribeTopics:output_type -> xmtp.xmtpv4.message_api.SubscribeTopicsResponse - 6, // 6: xmtp.xmtpv4.message_api.QueryApi.GetInboxIds:output_type -> xmtp.xmtpv4.message_api.GetInboxIdsResponse - 7, // 7: xmtp.xmtpv4.message_api.QueryApi.GetNewestEnvelope:output_type -> xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse - 4, // [4:8] is the sub-list for method output_type - 0, // [0:4] is the sub-list for method input_type + 2, // 2: xmtp.xmtpv4.message_api.QueryApi.Subscribe:input_type -> xmtp.xmtpv4.message_api.SubscribeRequest + 3, // 3: xmtp.xmtpv4.message_api.QueryApi.GetInboxIds:input_type -> xmtp.xmtpv4.message_api.GetInboxIdsRequest + 4, // 4: xmtp.xmtpv4.message_api.QueryApi.GetNewestEnvelope:input_type -> xmtp.xmtpv4.message_api.GetNewestEnvelopeRequest + 5, // 5: xmtp.xmtpv4.message_api.QueryApi.QueryEnvelopes:output_type -> xmtp.xmtpv4.message_api.QueryEnvelopesResponse + 6, // 6: xmtp.xmtpv4.message_api.QueryApi.SubscribeTopics:output_type -> xmtp.xmtpv4.message_api.SubscribeTopicsResponse + 7, // 7: xmtp.xmtpv4.message_api.QueryApi.Subscribe:output_type -> xmtp.xmtpv4.message_api.SubscribeResponse + 8, // 8: xmtp.xmtpv4.message_api.QueryApi.GetInboxIds:output_type -> xmtp.xmtpv4.message_api.GetInboxIdsResponse + 9, // 9: xmtp.xmtpv4.message_api.QueryApi.GetNewestEnvelope:output_type -> xmtp.xmtpv4.message_api.GetNewestEnvelopeResponse + 5, // [5:10] is the sub-list for method output_type + 0, // [0:5] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name diff --git a/pkg/proto/xmtpv4/message_api/query_api_grpc.pb.go b/pkg/proto/xmtpv4/message_api/query_api_grpc.pb.go index 695494a6b..2492a49ef 100644 --- a/pkg/proto/xmtpv4/message_api/query_api_grpc.pb.go +++ b/pkg/proto/xmtpv4/message_api/query_api_grpc.pb.go @@ -23,6 +23,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( QueryApi_QueryEnvelopes_FullMethodName = "/xmtp.xmtpv4.message_api.QueryApi/QueryEnvelopes" QueryApi_SubscribeTopics_FullMethodName = "/xmtp.xmtpv4.message_api.QueryApi/SubscribeTopics" + QueryApi_Subscribe_FullMethodName = "/xmtp.xmtpv4.message_api.QueryApi/Subscribe" QueryApi_GetInboxIds_FullMethodName = "/xmtp.xmtpv4.message_api.QueryApi/GetInboxIds" QueryApi_GetNewestEnvelope_FullMethodName = "/xmtp.xmtpv4.message_api.QueryApi/GetNewestEnvelope" ) @@ -35,6 +36,12 @@ const ( type QueryApiClient interface { QueryEnvelopes(ctx context.Context, in *QueryEnvelopesRequest, opts ...grpc.CallOption) (*QueryEnvelopesResponse, error) SubscribeTopics(ctx context.Context, in *SubscribeTopicsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SubscribeTopicsResponse], error) + // XIP-83 bidirectional mutable subscription: a single long-lived stream the + // client mutates in place (add/remove topics) with ping/pong liveness, in + // contrast to SubscribeTopics' fixed, immutable, server-streaming filter set. + // Bidi streaming requires HTTP/2 (not grpc-web / connect-web); browser + // clients stay on SubscribeTopics. + Subscribe(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SubscribeRequest, SubscribeResponse], error) GetInboxIds(ctx context.Context, in *GetInboxIdsRequest, opts ...grpc.CallOption) (*GetInboxIdsResponse, error) GetNewestEnvelope(ctx context.Context, in *GetNewestEnvelopeRequest, opts ...grpc.CallOption) (*GetNewestEnvelopeResponse, error) } @@ -76,6 +83,19 @@ func (c *queryApiClient) SubscribeTopics(ctx context.Context, in *SubscribeTopic // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type QueryApi_SubscribeTopicsClient = grpc.ServerStreamingClient[SubscribeTopicsResponse] +func (c *queryApiClient) Subscribe(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SubscribeRequest, SubscribeResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &QueryApi_ServiceDesc.Streams[1], QueryApi_Subscribe_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SubscribeRequest, SubscribeResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type QueryApi_SubscribeClient = grpc.BidiStreamingClient[SubscribeRequest, SubscribeResponse] + func (c *queryApiClient) GetInboxIds(ctx context.Context, in *GetInboxIdsRequest, opts ...grpc.CallOption) (*GetInboxIdsResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetInboxIdsResponse) @@ -104,6 +124,12 @@ func (c *queryApiClient) GetNewestEnvelope(ctx context.Context, in *GetNewestEnv type QueryApiServer interface { QueryEnvelopes(context.Context, *QueryEnvelopesRequest) (*QueryEnvelopesResponse, error) SubscribeTopics(*SubscribeTopicsRequest, grpc.ServerStreamingServer[SubscribeTopicsResponse]) error + // XIP-83 bidirectional mutable subscription: a single long-lived stream the + // client mutates in place (add/remove topics) with ping/pong liveness, in + // contrast to SubscribeTopics' fixed, immutable, server-streaming filter set. + // Bidi streaming requires HTTP/2 (not grpc-web / connect-web); browser + // clients stay on SubscribeTopics. + Subscribe(grpc.BidiStreamingServer[SubscribeRequest, SubscribeResponse]) error GetInboxIds(context.Context, *GetInboxIdsRequest) (*GetInboxIdsResponse, error) GetNewestEnvelope(context.Context, *GetNewestEnvelopeRequest) (*GetNewestEnvelopeResponse, error) } @@ -121,6 +147,9 @@ func (UnimplementedQueryApiServer) QueryEnvelopes(context.Context, *QueryEnvelop func (UnimplementedQueryApiServer) SubscribeTopics(*SubscribeTopicsRequest, grpc.ServerStreamingServer[SubscribeTopicsResponse]) error { return status.Errorf(codes.Unimplemented, "method SubscribeTopics not implemented") } +func (UnimplementedQueryApiServer) Subscribe(grpc.BidiStreamingServer[SubscribeRequest, SubscribeResponse]) error { + return status.Errorf(codes.Unimplemented, "method Subscribe not implemented") +} func (UnimplementedQueryApiServer) GetInboxIds(context.Context, *GetInboxIdsRequest) (*GetInboxIdsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetInboxIds not implemented") } @@ -176,6 +205,13 @@ func _QueryApi_SubscribeTopics_Handler(srv interface{}, stream grpc.ServerStream // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type QueryApi_SubscribeTopicsServer = grpc.ServerStreamingServer[SubscribeTopicsResponse] +func _QueryApi_Subscribe_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(QueryApiServer).Subscribe(&grpc.GenericServerStream[SubscribeRequest, SubscribeResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type QueryApi_SubscribeServer = grpc.BidiStreamingServer[SubscribeRequest, SubscribeResponse] + func _QueryApi_GetInboxIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetInboxIdsRequest) if err := dec(in); err != nil { @@ -238,6 +274,12 @@ var QueryApi_ServiceDesc = grpc.ServiceDesc{ Handler: _QueryApi_SubscribeTopics_Handler, ServerStreams: true, }, + { + StreamName: "Subscribe", + Handler: _QueryApi_Subscribe_Handler, + ServerStreams: true, + ClientStreams: true, + }, }, Metadata: "xmtpv4/message_api/query_api.proto", } From c44972f148beb774dda3cae21ffc956093a5f92d Mon Sep 17 00:00:00 2001 From: Tyler Hawkes Date: Wed, 17 Jun 2026 16:27:37 -0600 Subject: [PATCH 3/3] test(api): Subscribe handler tests + configurable keepalive interval for tests Co-Authored-By: Claude Fable 5 --- pkg/api/message/subscribe_internal_test.go | 393 +++++++++++++++++++++ pkg/api/message/subscribe_test.go | 377 ++++++++++++++++++++ pkg/testutils/api/api.go | 14 +- 3 files changed, 783 insertions(+), 1 deletion(-) create mode 100644 pkg/api/message/subscribe_internal_test.go diff --git a/pkg/api/message/subscribe_internal_test.go b/pkg/api/message/subscribe_internal_test.go new file mode 100644 index 000000000..422893fc6 --- /dev/null +++ b/pkg/api/message/subscribe_internal_test.go @@ -0,0 +1,393 @@ +package message + +import ( + "context" + "errors" + "math" + "sync" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/xmtp/xmtpd/pkg/db" + "github.com/xmtp/xmtpd/pkg/db/queries" + "github.com/xmtp/xmtpd/pkg/envelopes" + envelopesProto "github.com/xmtp/xmtpd/pkg/proto/xmtpv4/envelopes" + "github.com/xmtp/xmtpd/pkg/proto/xmtpv4/message_api" + "github.com/xmtp/xmtpd/pkg/topic" +) + +// TestMutableSubscriptionAddRaceWithClose exercises a mutate (addTopics, on the writer goroutine) +// racing the worker's reap (closeListener) on the same listener. Both touch l.closed; the fix sets +// it under topicsMu, so -race must stay clean. Before the fix, closeListener wrote l.closed without +// the lock addTopics reads it under — a data race that could re-register a listener in +// topicListeners after its channel was already closed (leak / send-on-closed panic). +func TestMutableSubscriptionAddRaceWithClose(t *testing.T) { + worker := &subscribeWorker{} + for range 200 { + ch := make(chan []*envelopes.OriginatorEnvelope, 1) + l := &listener{ + ctx: context.Background(), + ch: ch, + topics: make(map[string]struct{}), + originators: make(map[uint32]struct{}), + } + m := &mutableSubscription{worker: worker, l: l, ch: ch} + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + m.addTopics(map[string]struct{}{"race-topic": {}}) + }() + go func() { + defer wg.Done() + worker.closeListener(l) + }() + wg.Wait() + } +} + +// TestSubscribeSessionStaleWaveSkipsResetTopic covers the reset bug: a topic removed and re-added +// while its first catch-up wave is still in flight is owned by the NEWER wave. The stale wave's +// completion must not open the new wave's gate or announce the topic; only the owning wave does. +func TestSubscribeSessionStaleWaveSkipsResetTopic(t *testing.T) { + ctx := context.Background() + sess := &subscribeSession{ + svc: &Service{ctx: ctx}, + logger: zap.NewNop(), + ctx: ctx, + keepAlive: time.Second, + outbound: make(chan *message_api.SubscribeResponse, 16), + topics: make(map[string]*topicState), + waves: make(map[int]*subscribeWave), + } + + tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, []byte("reset-topic")) + st := subscribeTopic{wire: tp.Bytes(), cursorKey: string(tp.Bytes()), listenKey: tp.String()} + key := st.cursorKey + + // Wave 0 was in flight when the client removed + re-added the topic, so wave 1 now owns its gate. + sess.waves[0] = &subscribeWave{mutateID: 10, topics: []subscribeTopic{st}} + sess.waves[1] = &subscribeWave{mutateID: 20, topics: []subscribeTopic{st}} + sess.topics[key] = &topicState{ + subscribeTopic: st, + phase: topicGated, + wave: 1, // owned by wave 1 + cursor: make(db.VectorClock), + } + + // Stale wave 0 completes: it must NOT open the gate or announce the reset topic. + done, err := sess.handleCatchUp(catchUpBatch{wave: 0, done: true}) + require.NoError(t, err) + require.False(t, done) + require.Equal(t, topicGated, sess.topics[key].phase, "stale wave must leave the topic gated") + require.Equal(t, 1, sess.topics[key].wave, "stale wave must leave the gate owned by wave 1") + + f0 := drainResponses(sess.outbound) + require.Empty(t, topicsLiveFrames(f0), "stale wave must not announce the reset topic") + require.Equal(t, []uint64{10}, catchupCompleteIDs(f0)) + require.NotContains(t, sess.waves, 0) + + // Owning wave 1 completes: now the gate opens and the topic is announced. + done, err = sess.handleCatchUp(catchUpBatch{wave: 1, done: true}) + require.NoError(t, err) + require.False(t, done) + require.Equal( + t, + topicLive, + sess.topics[key].phase, + "owning wave should open the gate (go live)", + ) + + f1 := drainResponses(sess.outbound) + require.Len(t, topicsLiveFrames(f1), 1, "owning wave announces the topic") + require.Equal(t, []uint64{20}, catchupCompleteIDs(f1)) +} + +// TestSubscribeSessionFlushPropagatesSenderError covers the false-OK teardown bug: on a graceful +// half-close path, if the sender goroutine already died with a Send error, flush() observes +// senderDone closed and must surface that error — not return a clean nil while the wave's terminal +// frames were never written to the wire. +func TestSubscribeSessionFlushPropagatesSenderError(t *testing.T) { + sess := &subscribeSession{ + ctx: context.Background(), + keepAlive: time.Second, + outbound: make(chan *message_api.SubscribeResponse, 1), + senderDone: make(chan struct{}), + } + // Simulate the sender having stopped early on a Send error: record its terminal status, signal done. + wantErr := errors.New("stream send failed") + sess.sendErr = wantErr + close(sess.senderDone) + + require.ErrorIs(t, sess.flush(), wantErr, "flush must surface the sender error, not a false OK") +} + +// TestSubscribeSessionFlushPrefersDrainedSenderOverCancel covers the flush select-race finding: when +// the sender has drained (senderDone closed, sendErr nil) AND ctx is cancelled at the same instant, +// Go's select is pseudorandom — flush must deterministically prefer the completed drain and never +// report a clean graceful close as a spurious cancellation. +func TestSubscribeSessionFlushPrefersDrainedSenderOverCancel(t *testing.T) { + for i := range 64 { // without the fix the ctx arm wins ~half the time; 64 iters reliably catches it + ctx, cancel := context.WithCancel(context.Background()) + sess := &subscribeSession{ + ctx: ctx, + keepAlive: time.Second, + outbound: make(chan *message_api.SubscribeResponse, 1), + senderDone: make(chan struct{}), + } + close(sess.senderDone) // drained cleanly; sendErr stays nil + cancel() + require.NoError(t, sess.flush(), + "a completed drain must not be reported as a cancellation (iter %d)", i) + } +} + +// TestSubscribeSessionDrainPendingRequestsHandlesClosedChannel covers the drainPendingRequests +// finding: a closed request channel must surface a transport error and, on a clean EOF, mark the +// session half-closed — so the pong-deadline caller does not then reap with a spurious DeadlineExceeded. +func TestSubscribeSessionDrainPendingRequestsHandlesClosedChannel(t *testing.T) { + // Clean half-close: closed channel, no recvErr -> marks halfClosed, returns nil. + clean := &subscribeSession{logger: zap.NewNop()} + cleanCh := make(chan *message_api.SubscribeRequest) + close(cleanCh) + require.NoError(t, clean.drainPendingRequests(cleanCh)) + require.True( + t, + clean.halfClosed, + "a clean EOF on the request channel must mark the session half-closed", + ) + + // Transport failure: closed channel WITH recvErr -> surfaces Unavailable, not a clean nil. + failed := &subscribeSession{logger: zap.NewNop(), recvErr: errors.New("transport boom")} + failedCh := make(chan *message_api.SubscribeRequest) + close(failedCh) + err := failed.drainPendingRequests(failedCh) + require.Equal( + t, + connect.CodeUnavailable, + connect.CodeOf(err), + "a recv error must surface as Unavailable", + ) + require.False(t, failed.halfClosed, "a transport failure is not a clean half-close") +} + +// TestSubscribeSessionRemoveReaddDuringHistoryOnlyRejected covers the re-review's confirmed gap: the +// in-flight history_only overlap guard must run even on the remove+re-add (reset) path, because +// removeTopic does NOT cancel a history_only wave. Otherwise the surviving history_only wave and the +// new live wave both deliver the topic's history. +func TestSubscribeSessionRemoveReaddDuringHistoryOnlyRejected(t *testing.T) { + ctx := context.Background() + tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, []byte("ho-topic")) + st := subscribeTopic{wire: tp.Bytes(), cursorKey: string(tp.Bytes()), listenKey: tp.String()} + + sess := &subscribeSession{ + svc: &Service{ctx: ctx}, + logger: zap.NewNop(), + ctx: ctx, + keepAlive: time.Second, + topics: make(map[string]*topicState), + // An in-flight history_only wave for the topic (its throwaway cursors live on the wave). + waves: map[int]*subscribeWave{ + 0: { + mutateID: 1, + historyOnly: true, + topics: []subscribeTopic{st}, + cursors: db.TopicCursors{st.cursorKey: make(db.VectorClock)}, + }, + }, + } + + // remove(T) + add(T) — the documented replay path — while T's history_only wave is still running. + err := sess.handleMutate(&message_api.SubscribeRequest_V1_Mutate{ + MutateId: 2, + Removes: [][]byte{tp.Bytes()}, + Adds: []*message_api.SubscribeRequest_V1_Mutate_Subscription{{Topic: tp.Bytes()}}, + }) + require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err), + "remove+re-add over an in-flight history_only wave must be rejected, not double-delivered") +} + +// TestSubscribeSessionRejectsOutOfRangeCursor covers the cursor-overflow finding: a LastSeen entry +// whose value exceeds the signed DB column types would be silently dropped from the catch-up query +// and poison the live cursor (killing the topic). It must be rejected. +func TestSubscribeSessionRejectsOutOfRangeCursor(t *testing.T) { + ctx := context.Background() + tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, []byte("ovf-topic")) + newSess := func() *subscribeSession { + return &subscribeSession{ + svc: &Service{ctx: ctx}, logger: zap.NewNop(), ctx: ctx, keepAlive: time.Second, + topics: make(map[string]*topicState), + waves: make(map[int]*subscribeWave), + } + } + mutate := func(c map[uint32]uint64) *message_api.SubscribeRequest_V1_Mutate { + return &message_api.SubscribeRequest_V1_Mutate{ + MutateId: 1, + Adds: []*message_api.SubscribeRequest_V1_Mutate_Subscription{ + {Topic: tp.Bytes(), LastSeen: &envelopesProto.Cursor{NodeIdToSequenceId: c}}, + }, + } + } + + require.Equal( + t, + connect.CodeInvalidArgument, + connect.CodeOf( + newSess().handleMutate(mutate(map[uint32]uint64{100: uint64(math.MaxInt64) + 1})), + ), + "sequence id beyond int64 must be rejected", + ) + require.Equal( + t, + connect.CodeInvalidArgument, + connect.CodeOf( + newSess().handleMutate(mutate(map[uint32]uint64{uint32(math.MaxInt32) + 1: 5})), + ), + "originator id beyond int32 must be rejected", + ) +} + +// TestSubscribeSessionRejectsTooManyInflightWaves covers the in-flight catch-up cap (round-2 review +// finding): a Mutate that would start a new wave while maxInflightSubscribeWaves are already running +// is rejected, so reset churn (remove+re-add, which never cancels the old wave) cannot pile up +// fetcher goroutines. A removes-only Mutate starts no wave and is exempt even at the cap. +func TestSubscribeSessionRejectsTooManyInflightWaves(t *testing.T) { + ctx := context.Background() + sess := &subscribeSession{ + svc: &Service{ctx: ctx}, logger: zap.NewNop(), ctx: ctx, keepAlive: time.Second, + outbound: make(chan *message_api.SubscribeResponse, 1), + topics: make(map[string]*topicState), + waves: make(map[int]*subscribeWave), + // Minimal sub so the removes-only path's worker unregister is a no-op, not a nil deref. + sub: &mutableSubscription{ + worker: &subscribeWorker{}, + l: &listener{ + topics: make(map[string]struct{}), + originators: make(map[uint32]struct{}), + }, + }, + } + // Saturate the in-flight wave budget. + for i := range maxInflightSubscribeWaves { + sess.waves[i] = &subscribeWave{mutateID: uint64(i)} + } + sess.nextWave = maxInflightSubscribeWaves + + tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, []byte("over-cap")) + err := sess.handleMutate(&message_api.SubscribeRequest_V1_Mutate{ + MutateId: 1, + Adds: []*message_api.SubscribeRequest_V1_Mutate_Subscription{{Topic: tp.Bytes()}}, + }) + require.Equal(t, connect.CodeResourceExhausted, connect.CodeOf(err), + "a Mutate that would exceed the in-flight catch-up cap must be rejected") + + // A removes-only Mutate creates no wave, so it is not blocked by the cap. + err = sess.handleMutate(&message_api.SubscribeRequest_V1_Mutate{ + MutateId: 2, + Removes: [][]byte{tp.Bytes()}, + }) + require.NoError(t, err, "a removes-only Mutate must not be blocked by the in-flight cap") +} + +// TestAdvanceCursorsFromRowsAdvancesPastUnmarshalFailures covers the catch-up spin fix: pagination +// cursors must advance from the RAW rows even when an envelope's bytes don't unmarshal, otherwise a +// bad row in a full page re-fetches forever and the wave never completes. +func TestAdvanceCursorsFromRowsAdvancesPastUnmarshalFailures(t *testing.T) { + tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, []byte("rows-topic")) + key := string(tp.Bytes()) + cursors := db.TopicCursors{key: make(db.VectorClock)} + // Envelope bytes are intentionally garbage (would be dropped by unmarshalEnvelopes). + rows := []queries.GatewayEnvelopesView{ + { + Topic: tp.Bytes(), + OriginatorNodeID: 100, + OriginatorSequenceID: 5, + OriginatorEnvelope: []byte("garbage"), + }, + { + Topic: tp.Bytes(), + OriginatorNodeID: 100, + OriginatorSequenceID: 6, + OriginatorEnvelope: []byte("garbage"), + }, + { + Topic: tp.Bytes(), + OriginatorNodeID: 200, + OriginatorSequenceID: 3, + OriginatorEnvelope: []byte("garbage"), + }, + } + advanceCursorsFromRows(cursors, rows) + require.Equal( + t, + uint64(6), + cursors[key][100], + "cursor must advance to the max raw seq for an originator", + ) + require.Equal(t, uint64(3), cursors[key][200]) +} + +// TestSubscribeSessionSendEnvelopesSplitsFrames covers the frame-splitting fix: a batch larger than +// the frame limit must go out as multiple frames, never one oversized (stream-aborting) frame. +func TestSubscribeSessionSendEnvelopesSplitsFrames(t *testing.T) { + ctx := context.Background() + sess := &subscribeSession{ + svc: &Service{ctx: ctx}, logger: zap.NewNop(), ctx: ctx, keepAlive: time.Second, + outbound: make(chan *message_api.SubscribeResponse, 16), + maxFrameBytes: 120, // each env below is ~52 bytes → 2 fit per frame + } + mkEnv := func(payload int) *envelopesProto.OriginatorEnvelope { + return &envelopesProto.OriginatorEnvelope{UnsignedOriginatorEnvelope: make([]byte, payload)} + } + require.NoError(t, sess.sendEnvelopes([]*envelopesProto.OriginatorEnvelope{ + mkEnv(50), mkEnv(50), mkEnv(50), + })) + + var counts []int + total := 0 + for _, f := range drainResponses(sess.outbound) { + if env := f.GetV1().GetEnvelopes(); env != nil { + counts = append(counts, len(env.GetEnvelopes())) + total += len(env.GetEnvelopes()) + } + } + require.Len(t, counts, 2, "batch must be split into 2 frames") + require.Equal(t, 3, total, "every envelope must be delivered exactly once") +} + +func drainResponses(ch chan *message_api.SubscribeResponse) []*message_api.SubscribeResponse { + var out []*message_api.SubscribeResponse + for { + select { + case f := <-ch: + out = append(out, f) + default: + return out + } + } +} + +func topicsLiveFrames(frames []*message_api.SubscribeResponse) [][]byte { + var out [][]byte + for _, f := range frames { + if tl := f.GetV1().GetTopicsLive(); tl != nil { + out = append(out, tl.GetTopics()...) + } + } + return out +} + +func catchupCompleteIDs(frames []*message_api.SubscribeResponse) []uint64 { + var out []uint64 + for _, f := range frames { + if cc := f.GetV1().GetCatchupComplete(); cc != nil { + out = append(out, cc.GetMutateId()) + } + } + return out +} diff --git a/pkg/api/message/subscribe_test.go b/pkg/api/message/subscribe_test.go index 7a9c9d9ef..08f0ce191 100644 --- a/pkg/api/message/subscribe_test.go +++ b/pkg/api/message/subscribe_test.go @@ -1,11 +1,14 @@ package message_test import ( + "bytes" "context" "database/sql" "errors" "fmt" + "io" "math/rand" + "slices" "sync" "sync/atomic" "testing" @@ -1407,3 +1410,377 @@ func TestSubscribeAll_StreamsOnlyNewMessages(t *testing.T) { require.Equal(t, streamSize, received) } + +// ---- XIP-83 bidirectional Subscribe (QueryApi) ---- + +func subMutate( + mutateID uint64, + historyOnly bool, + adds []*message_api.SubscribeRequest_V1_Mutate_Subscription, + removes [][]byte, +) *message_api.SubscribeRequest { + return &message_api.SubscribeRequest{ + Version: &message_api.SubscribeRequest_V1_{ + V1: &message_api.SubscribeRequest_V1{ + Request: &message_api.SubscribeRequest_V1_Mutate_{ + Mutate: &message_api.SubscribeRequest_V1_Mutate{ + MutateId: mutateID, + HistoryOnly: historyOnly, + Adds: adds, + Removes: removes, + }, + }, + }, + }, + } +} + +func addSub( + topicBytes []byte, + cursor map[uint32]uint64, +) *message_api.SubscribeRequest_V1_Mutate_Subscription { + sub := &message_api.SubscribeRequest_V1_Mutate_Subscription{Topic: topicBytes} + if cursor != nil { + sub.LastSeen = &envelopes.Cursor{NodeIdToSequenceId: cursor} + } + return sub +} + +// bidiReader drains a Subscribe stream on a background goroutine into a thread-safe buffer, so a +// test can poll for expected frames with require.Eventually. It never answers pings (a test that +// needs liveness reaping relies on that). +type bidiReader struct { + mu sync.Mutex + frames []*message_api.SubscribeResponse + err error +} + +func newBidiReader( + stream *connect.BidiStreamForClient[message_api.SubscribeRequest, message_api.SubscribeResponse], +) *bidiReader { + r := &bidiReader{} + go func() { + for { + resp, err := stream.Receive() + r.mu.Lock() + if err != nil { + r.err = err + r.mu.Unlock() + return + } + r.frames = append(r.frames, resp) + r.mu.Unlock() + } + }() + return r +} + +func (r *bidiReader) snapshot() ([]*message_api.SubscribeResponse, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*message_api.SubscribeResponse, len(r.frames)) + copy(out, r.frames) + return out, r.err +} + +// subEnvelopeKeys returns the (originatorNodeID, sequenceID) of every delivered envelope, in order. +func subEnvelopeKeys(t *testing.T, frames []*message_api.SubscribeResponse) [][2]uint64 { + t.Helper() + var keys [][2]uint64 + for _, f := range frames { + env := f.GetV1().GetEnvelopes() + if env == nil { + continue + } + for _, e := range env.GetEnvelopes() { + u := envelopeTestUtils.UnmarshalUnsignedOriginatorEnvelope( + t, + e.GetUnsignedOriginatorEnvelope(), + ) + keys = append( + keys, + [2]uint64{uint64(u.GetOriginatorNodeId()), u.GetOriginatorSequenceId()}, + ) + } + } + return keys +} + +func subTopicsLive(frames []*message_api.SubscribeResponse) [][]byte { + var out [][]byte + for _, f := range frames { + if tl := f.GetV1().GetTopicsLive(); tl != nil { + out = append(out, tl.GetTopics()...) + } + } + return out +} + +func subCatchupCompletes(frames []*message_api.SubscribeResponse) []uint64 { + var out []uint64 + for _, f := range frames { + if cc := f.GetV1().GetCatchupComplete(); cc != nil { + out = append(out, cc.GetMutateId()) + } + } + return out +} + +func hasEnvKey(keys [][2]uint64, nodeID, seqID uint64) bool { + for _, k := range keys { + if k[0] == nodeID && k[1] == seqID { + return true + } + } + return false +} + +func hasTopicBytes(topics [][]byte, want []byte) bool { + for _, tp := range topics { + if bytes.Equal(tp, want) { + return true + } + } + return false +} + +func hasMutateID(ids []uint64, want uint64) bool { + return slices.Contains(ids, want) +} + +// TestSubscribe_CatchUpThenLive verifies a subscription delivers a topic's history (catch-up), +// announces the live boundary, then delivers live messages for that topic only — no duplicates +// across the switch, and nothing from an unsubscribed topic. +func TestSubscribe_CatchUpThenLive(t *testing.T) { + suite := setupTest(t) + insertInitialRows(t, suite) // topicA: (100,1),(200,1) in the DB; worker polled past them + + stream := suite.ClientQuery.Subscribe(t.Context()) + reader := newBidiReader(stream) + + require.NoError(t, stream.Send(subMutate( + 1, false, + []*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, nil)}, + nil, + ))) + + require.Eventually(t, func() bool { + frames, _ := reader.snapshot() + keys := subEnvelopeKeys(t, frames) + return hasEnvKey(keys, 100, 1) && hasEnvKey(keys, 200, 1) && + hasTopicBytes(subTopicsLive(frames), topicA) && + hasMutateID(subCatchupCompletes(frames), 1) + }, 10*time.Second, 20*time.Millisecond, "expected topicA history + TopicsLive + CatchupComplete(1)") + + // (100,3) is topicA (live); (100,2),(200,2) are topicB (not subscribed). + insertAdditionalRows(t, suite.DB) + + require.Eventually(t, func() bool { + frames, _ := reader.snapshot() + return hasEnvKey(subEnvelopeKeys(t, frames), 100, 3) + }, 10*time.Second, 20*time.Millisecond, "expected live topicA delivery of (100,3)") + + frames, err := reader.snapshot() + require.NoError(t, err) + keys := subEnvelopeKeys(t, frames) + require.False(t, hasEnvKey(keys, 100, 2), "topicB (100,2) must not be delivered") + require.False(t, hasEnvKey(keys, 200, 2), "topicB (200,2) must not be delivered") + + seen := make(map[[2]uint64]struct{}, len(keys)) + for _, k := range keys { + _, dup := seen[k] + require.False(t, dup, "duplicate envelope across catch-up/live: %v", k) + seen[k] = struct{}{} + } +} + +// TestSubscribe_MutateRemoveStopsDelivery verifies an in-place remove stops live delivery for a +// topic while an add in the same stream begins it for another — no reconnect. +func TestSubscribe_MutateRemoveStopsDelivery(t *testing.T) { + suite := setupTest(t) + + stream := suite.ClientQuery.Subscribe(t.Context()) + reader := newBidiReader(stream) + + require.NoError(t, stream.Send(subMutate( + 1, false, + []*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, nil)}, + nil, + ))) + require.Eventually(t, func() bool { + frames, _ := reader.snapshot() + return hasMutateID(subCatchupCompletes(frames), 1) + }, 10*time.Second, 20*time.Millisecond) + + // Remove topicA, add topicB, in one mutation. + require.NoError(t, stream.Send(subMutate( + 2, false, + []*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicB, nil)}, + [][]byte{topicA}, + ))) + require.Eventually(t, func() bool { + frames, _ := reader.snapshot() + return hasMutateID(subCatchupCompletes(frames), 2) + }, 10*time.Second, 20*time.Millisecond) + + insertAdditionalRows(t, suite.DB) // topicB (100,2),(200,2); topicA (100,3) + + require.Eventually(t, func() bool { + keys := subEnvelopeKeys(t, mustFrames(reader)) + return hasEnvKey(keys, 100, 2) && hasEnvKey(keys, 200, 2) + }, 10*time.Second, 20*time.Millisecond, "topicB must be delivered live after the add") + + frames, err := reader.snapshot() + require.NoError(t, err) + require.False( + t, + hasEnvKey(subEnvelopeKeys(t, frames), 100, 3), + "removed topicA must not deliver (100,3)", + ) +} + +// TestSubscribe_HalfCloseHistoryOnlyDrains is the bounded catch-up ("sync") flow: history_only + +// half-close, the server finishes the wave (history, TopicsLive, CatchupComplete) then closes the +// stream itself — the client sees a clean io.EOF, not a truncated result. +func TestSubscribe_HalfCloseHistoryOnlyDrains(t *testing.T) { + suite := setupTest(t) + insertInitialRows(t, suite) + + stream := suite.ClientQuery.Subscribe(t.Context()) + reader := newBidiReader(stream) + + require.NoError(t, stream.Send(subMutate( + 7, true, // history_only + []*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, nil)}, + nil, + ))) + require.NoError(t, stream.CloseRequest()) + + require.Eventually(t, func() bool { + _, err := reader.snapshot() + return errors.Is(err, io.EOF) + }, 10*time.Second, 20*time.Millisecond, "stream should close cleanly after the bounded catch-up") + + frames, err := reader.snapshot() + require.ErrorIs(t, err, io.EOF) + keys := subEnvelopeKeys(t, frames) + require.True(t, hasEnvKey(keys, 100, 1) && hasEnvKey(keys, 200, 1), "history must be delivered") + require.True(t, hasTopicBytes(subTopicsLive(frames), topicA)) + require.True(t, hasMutateID(subCatchupCompletes(frames), 7)) +} + +// TestSubscribe_HistoryOnlyOnLiveRejected verifies a history_only add targeting a topic already +// live on the same stream is rejected (one cursor floor per topic). +func TestSubscribe_HistoryOnlyOnLiveRejected(t *testing.T) { + suite := setupTest(t) + + stream := suite.ClientQuery.Subscribe(t.Context()) + reader := newBidiReader(stream) + + require.NoError(t, stream.Send(subMutate( + 1, false, + []*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, nil)}, + nil, + ))) + require.Eventually(t, func() bool { + frames, _ := reader.snapshot() + return hasMutateID(subCatchupCompletes(frames), 1) + }, 10*time.Second, 20*time.Millisecond) + + require.NoError(t, stream.Send(subMutate( + 2, true, // history_only on the already-live topicA + []*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, nil)}, + nil, + ))) + + require.Eventually(t, func() bool { + _, err := reader.snapshot() + return err != nil + }, 10*time.Second, 20*time.Millisecond, "stream should be failed") + + _, err := reader.snapshot() + require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) +} + +// TestSubscribe_NoPongIsReaped verifies an idle client that never answers the server's liveness +// Ping is reaped with DeadlineExceeded. +func TestSubscribe_NoPongIsReaped(t *testing.T) { + nodes := []registry.Node{ + {NodeID: 100, IsCanonical: true}, + {NodeID: 200, IsCanonical: true}, + } + suite := testUtilsApi.NewTestAPIServer( + t, + testUtilsApi.WithRegistryNodes(nodes), + testUtilsApi.WithSendKeepAliveInterval(200*time.Millisecond), + ) + + stream := suite.ClientQuery.Subscribe(t.Context()) + reader := newBidiReader(stream) // only reads; never Pongs + + // Subscribe to nothing and stay idle; the server will Ping, get no Pong, and reap. + require.NoError(t, stream.Send(subMutate(1, false, nil, nil))) + + require.Eventually(t, func() bool { + _, err := reader.snapshot() + return err != nil + }, 5*time.Second, 50*time.Millisecond, "an idle client that never Pongs must be reaped") + + _, err := reader.snapshot() + require.Equal(t, connect.CodeDeadlineExceeded, connect.CodeOf(err)) +} + +// TestSubscribe_ReAddLiveTopicDoesNotReplay verifies that re-adding an already-live topic WITHOUT a +// remove (e.g. a herald re-issuing an add with a default/stale cursor) is a no-op: it must not reset +// the live cursor and re-deliver already-sent envelopes (no duplicates, no backwards seqID). Replay +// is only available via remove+re-add. +func TestSubscribe_ReAddLiveTopicDoesNotReplay(t *testing.T) { + suite := setupTest(t) + insertInitialRows(t, suite) // topicA: (100,1),(200,1) + + stream := suite.ClientQuery.Subscribe(t.Context()) + reader := newBidiReader(stream) + + // Subscribe to topicA; receive its history and go live. + require.NoError(t, stream.Send(subMutate( + 1, false, + []*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, nil)}, + nil, + ))) + require.Eventually(t, func() bool { + keys := subEnvelopeKeys(t, mustFrames(reader)) + return hasEnvKey(keys, 100, 1) && hasEnvKey(keys, 200, 1) && + hasMutateID(subCatchupCompletes(mustFrames(reader)), 1) + }, 10*time.Second, 20*time.Millisecond) + + // Deliver a live message so the live cursor advances past the history. + insertAdditionalRows(t, suite.DB) // topicA (100,3); topicB (100,2),(200,2) not subscribed + require.Eventually(t, func() bool { + return hasEnvKey(subEnvelopeKeys(t, mustFrames(reader)), 100, 3) + }, 10*time.Second, 20*time.Millisecond, "expected live topicA (100,3)") + + // Re-add topicA WITHOUT removing it, with a zero cursor. This must be a no-op — NOT a replay. + require.NoError(t, stream.Send(subMutate( + 2, false, + []*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, map[uint32]uint64{})}, + nil, + ))) + // The no-op mutate is still confirmed (its adds collapsed to none -> removes-only path). + require.Eventually(t, func() bool { + return hasMutateID(subCatchupCompletes(mustFrames(reader)), 2) + }, 10*time.Second, 20*time.Millisecond, "expected CatchupComplete(2) for the no-op re-add") + + // No envelope may be delivered twice: a replay would re-send (100,1),(200,1),(100,3). + counts := make(map[[2]uint64]int) + for _, k := range subEnvelopeKeys(t, mustFrames(reader)) { + counts[k]++ + } + for k, n := range counts { + require.Equalf(t, 1, n, "envelope %v delivered %d times: re-add must not replay", k, n) + } +} + +func mustFrames(r *bidiReader) []*message_api.SubscribeResponse { + frames, _ := r.snapshot() + return frames +} diff --git a/pkg/testutils/api/api.go b/pkg/testutils/api/api.go index 7b6a4ebdf..b9691891b 100644 --- a/pkg/testutils/api/api.go +++ b/pkg/testutils/api/api.go @@ -212,6 +212,7 @@ type APIServerTestSuite struct { type APIServerTestConfig struct { registryNodes []registry.Node requirePayerPositiveBalance bool + sendKeepAliveInterval time.Duration } type TestAPIOption func(*APIServerTestConfig) @@ -228,6 +229,14 @@ func WithRequirePayerPositiveBalance(enabled bool) TestAPIOption { } } +// WithSendKeepAliveInterval overrides the server keepalive/ping cadence (default 30s), so a test +// can exercise the Subscribe liveness ping/pong reaping without waiting tens of seconds. +func WithSendKeepAliveInterval(d time.Duration) TestAPIOption { + return func(cfg *APIServerTestConfig) { + cfg.sendKeepAliveInterval = d + } +} + func createMockRegistry(t *testing.T, nodes []registry.Node) *registryMocks.MockNodeRegistry { reg := registryMocks.NewMockNodeRegistry(t) @@ -252,6 +261,9 @@ func NewTestAPIServer( for _, opt := range opts { opt(&cfg) } + if cfg.sendKeepAliveInterval == 0 { + cfg.sendKeepAliveInterval = 30 * time.Second + } var ( ctx, cancel = context.WithCancel(context.Background()) @@ -307,7 +319,7 @@ func NewTestAPIServer( metadata.NewCursorUpdater(ctx, log, db), fees.NewTestFeeCalculator(), config.APIOptions{ - SendKeepAliveInterval: 30 * time.Second, + SendKeepAliveInterval: cfg.sendKeepAliveInterval, RequirePayerPositiveBalance: cfg.requirePayerPositiveBalance, }, false,