Skip to content

Commit c44b65c

Browse files
tylerhawkesclaude
andcommitted
test(api): Subscribe handler tests + configurable keepalive interval for tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4346611 commit c44b65c

2 files changed

Lines changed: 340 additions & 1 deletion

File tree

pkg/api/message/subscribe_test.go

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package message_test
22

33
import (
4+
"bytes"
45
"context"
56
"database/sql"
67
"errors"
78
"fmt"
9+
"io"
810
"math/rand"
11+
"slices"
912
"sync"
1013
"sync/atomic"
1114
"testing"
@@ -1407,3 +1410,327 @@ func TestSubscribeAll_StreamsOnlyNewMessages(t *testing.T) {
14071410

14081411
require.Equal(t, streamSize, received)
14091412
}
1413+
1414+
// ---- XIP-83 bidirectional Subscribe (QueryApi) ----
1415+
1416+
func subMutate(
1417+
mutateID uint64,
1418+
historyOnly bool,
1419+
adds []*message_api.SubscribeRequest_V1_Mutate_Subscription,
1420+
removes [][]byte,
1421+
) *message_api.SubscribeRequest {
1422+
return &message_api.SubscribeRequest{
1423+
Version: &message_api.SubscribeRequest_V1_{
1424+
V1: &message_api.SubscribeRequest_V1{
1425+
Request: &message_api.SubscribeRequest_V1_Mutate_{
1426+
Mutate: &message_api.SubscribeRequest_V1_Mutate{
1427+
MutateId: mutateID,
1428+
HistoryOnly: historyOnly,
1429+
Adds: adds,
1430+
Removes: removes,
1431+
},
1432+
},
1433+
},
1434+
},
1435+
}
1436+
}
1437+
1438+
func addSub(
1439+
topicBytes []byte,
1440+
cursor map[uint32]uint64,
1441+
) *message_api.SubscribeRequest_V1_Mutate_Subscription {
1442+
sub := &message_api.SubscribeRequest_V1_Mutate_Subscription{Topic: topicBytes}
1443+
if cursor != nil {
1444+
sub.LastSeen = &envelopes.Cursor{NodeIdToSequenceId: cursor}
1445+
}
1446+
return sub
1447+
}
1448+
1449+
// bidiReader drains a Subscribe stream on a background goroutine into a thread-safe buffer, so a
1450+
// test can poll for expected frames with require.Eventually. It never answers pings (a test that
1451+
// needs liveness reaping relies on that).
1452+
type bidiReader struct {
1453+
mu sync.Mutex
1454+
frames []*message_api.SubscribeResponse
1455+
err error
1456+
}
1457+
1458+
func newBidiReader(
1459+
stream *connect.BidiStreamForClient[message_api.SubscribeRequest, message_api.SubscribeResponse],
1460+
) *bidiReader {
1461+
r := &bidiReader{}
1462+
go func() {
1463+
for {
1464+
resp, err := stream.Receive()
1465+
r.mu.Lock()
1466+
if err != nil {
1467+
r.err = err
1468+
r.mu.Unlock()
1469+
return
1470+
}
1471+
r.frames = append(r.frames, resp)
1472+
r.mu.Unlock()
1473+
}
1474+
}()
1475+
return r
1476+
}
1477+
1478+
func (r *bidiReader) snapshot() ([]*message_api.SubscribeResponse, error) {
1479+
r.mu.Lock()
1480+
defer r.mu.Unlock()
1481+
out := make([]*message_api.SubscribeResponse, len(r.frames))
1482+
copy(out, r.frames)
1483+
return out, r.err
1484+
}
1485+
1486+
// subEnvelopeKeys returns the (originatorNodeID, sequenceID) of every delivered envelope, in order.
1487+
func subEnvelopeKeys(t *testing.T, frames []*message_api.SubscribeResponse) [][2]uint64 {
1488+
t.Helper()
1489+
var keys [][2]uint64
1490+
for _, f := range frames {
1491+
env := f.GetV1().GetEnvelopes()
1492+
if env == nil {
1493+
continue
1494+
}
1495+
for _, e := range env.GetEnvelopes() {
1496+
u := envelopeTestUtils.UnmarshalUnsignedOriginatorEnvelope(
1497+
t,
1498+
e.GetUnsignedOriginatorEnvelope(),
1499+
)
1500+
keys = append(
1501+
keys,
1502+
[2]uint64{uint64(u.GetOriginatorNodeId()), u.GetOriginatorSequenceId()},
1503+
)
1504+
}
1505+
}
1506+
return keys
1507+
}
1508+
1509+
func subTopicsLive(frames []*message_api.SubscribeResponse) [][]byte {
1510+
var out [][]byte
1511+
for _, f := range frames {
1512+
if tl := f.GetV1().GetTopicsLive(); tl != nil {
1513+
out = append(out, tl.GetTopics()...)
1514+
}
1515+
}
1516+
return out
1517+
}
1518+
1519+
func subCatchupCompletes(frames []*message_api.SubscribeResponse) []uint64 {
1520+
var out []uint64
1521+
for _, f := range frames {
1522+
if cc := f.GetV1().GetCatchupComplete(); cc != nil {
1523+
out = append(out, cc.GetMutateId())
1524+
}
1525+
}
1526+
return out
1527+
}
1528+
1529+
func hasEnvKey(keys [][2]uint64, nodeID, seqID uint64) bool {
1530+
for _, k := range keys {
1531+
if k[0] == nodeID && k[1] == seqID {
1532+
return true
1533+
}
1534+
}
1535+
return false
1536+
}
1537+
1538+
func hasTopicBytes(topics [][]byte, want []byte) bool {
1539+
for _, tp := range topics {
1540+
if bytes.Equal(tp, want) {
1541+
return true
1542+
}
1543+
}
1544+
return false
1545+
}
1546+
1547+
func hasMutateID(ids []uint64, want uint64) bool {
1548+
return slices.Contains(ids, want)
1549+
}
1550+
1551+
// TestSubscribe_CatchUpThenLive verifies a subscription delivers a topic's history (catch-up),
1552+
// announces the live boundary, then delivers live messages for that topic only — no duplicates
1553+
// across the switch, and nothing from an unsubscribed topic.
1554+
func TestSubscribe_CatchUpThenLive(t *testing.T) {
1555+
suite := setupTest(t)
1556+
insertInitialRows(t, suite) // topicA: (100,1),(200,1) in the DB; worker polled past them
1557+
1558+
stream := suite.ClientQuery.Subscribe(t.Context())
1559+
reader := newBidiReader(stream)
1560+
1561+
require.NoError(t, stream.Send(subMutate(
1562+
1, false,
1563+
[]*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, nil)},
1564+
nil,
1565+
)))
1566+
1567+
require.Eventually(t, func() bool {
1568+
frames, _ := reader.snapshot()
1569+
keys := subEnvelopeKeys(t, frames)
1570+
return hasEnvKey(keys, 100, 1) && hasEnvKey(keys, 200, 1) &&
1571+
hasTopicBytes(subTopicsLive(frames), topicA) &&
1572+
hasMutateID(subCatchupCompletes(frames), 1)
1573+
}, 10*time.Second, 20*time.Millisecond, "expected topicA history + TopicsLive + CatchupComplete(1)")
1574+
1575+
// (100,3) is topicA (live); (100,2),(200,2) are topicB (not subscribed).
1576+
insertAdditionalRows(t, suite.DB)
1577+
1578+
require.Eventually(t, func() bool {
1579+
frames, _ := reader.snapshot()
1580+
return hasEnvKey(subEnvelopeKeys(t, frames), 100, 3)
1581+
}, 10*time.Second, 20*time.Millisecond, "expected live topicA delivery of (100,3)")
1582+
1583+
frames, err := reader.snapshot()
1584+
require.NoError(t, err)
1585+
keys := subEnvelopeKeys(t, frames)
1586+
require.False(t, hasEnvKey(keys, 100, 2), "topicB (100,2) must not be delivered")
1587+
require.False(t, hasEnvKey(keys, 200, 2), "topicB (200,2) must not be delivered")
1588+
1589+
seen := make(map[[2]uint64]struct{}, len(keys))
1590+
for _, k := range keys {
1591+
_, dup := seen[k]
1592+
require.False(t, dup, "duplicate envelope across catch-up/live: %v", k)
1593+
seen[k] = struct{}{}
1594+
}
1595+
}
1596+
1597+
// TestSubscribe_MutateRemoveStopsDelivery verifies an in-place remove stops live delivery for a
1598+
// topic while an add in the same stream begins it for another — no reconnect.
1599+
func TestSubscribe_MutateRemoveStopsDelivery(t *testing.T) {
1600+
suite := setupTest(t)
1601+
1602+
stream := suite.ClientQuery.Subscribe(t.Context())
1603+
reader := newBidiReader(stream)
1604+
1605+
require.NoError(t, stream.Send(subMutate(
1606+
1, false,
1607+
[]*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, nil)},
1608+
nil,
1609+
)))
1610+
require.Eventually(t, func() bool {
1611+
frames, _ := reader.snapshot()
1612+
return hasMutateID(subCatchupCompletes(frames), 1)
1613+
}, 10*time.Second, 20*time.Millisecond)
1614+
1615+
// Remove topicA, add topicB, in one mutation.
1616+
require.NoError(t, stream.Send(subMutate(
1617+
2, false,
1618+
[]*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicB, nil)},
1619+
[][]byte{topicA},
1620+
)))
1621+
require.Eventually(t, func() bool {
1622+
frames, _ := reader.snapshot()
1623+
return hasMutateID(subCatchupCompletes(frames), 2)
1624+
}, 10*time.Second, 20*time.Millisecond)
1625+
1626+
insertAdditionalRows(t, suite.DB) // topicB (100,2),(200,2); topicA (100,3)
1627+
1628+
require.Eventually(t, func() bool {
1629+
keys := subEnvelopeKeys(t, mustFrames(reader))
1630+
return hasEnvKey(keys, 100, 2) && hasEnvKey(keys, 200, 2)
1631+
}, 10*time.Second, 20*time.Millisecond, "topicB must be delivered live after the add")
1632+
1633+
frames, err := reader.snapshot()
1634+
require.NoError(t, err)
1635+
require.False(
1636+
t,
1637+
hasEnvKey(subEnvelopeKeys(t, frames), 100, 3),
1638+
"removed topicA must not deliver (100,3)",
1639+
)
1640+
}
1641+
1642+
// TestSubscribe_HalfCloseHistoryOnlyDrains is the bounded catch-up ("sync") flow: history_only +
1643+
// half-close, the server finishes the wave (history, TopicsLive, CatchupComplete) then closes the
1644+
// stream itself — the client sees a clean io.EOF, not a truncated result.
1645+
func TestSubscribe_HalfCloseHistoryOnlyDrains(t *testing.T) {
1646+
suite := setupTest(t)
1647+
insertInitialRows(t, suite)
1648+
1649+
stream := suite.ClientQuery.Subscribe(t.Context())
1650+
reader := newBidiReader(stream)
1651+
1652+
require.NoError(t, stream.Send(subMutate(
1653+
7, true, // history_only
1654+
[]*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, nil)},
1655+
nil,
1656+
)))
1657+
require.NoError(t, stream.CloseRequest())
1658+
1659+
require.Eventually(t, func() bool {
1660+
_, err := reader.snapshot()
1661+
return errors.Is(err, io.EOF)
1662+
}, 10*time.Second, 20*time.Millisecond, "stream should close cleanly after the bounded catch-up")
1663+
1664+
frames, err := reader.snapshot()
1665+
require.ErrorIs(t, err, io.EOF)
1666+
keys := subEnvelopeKeys(t, frames)
1667+
require.True(t, hasEnvKey(keys, 100, 1) && hasEnvKey(keys, 200, 1), "history must be delivered")
1668+
require.True(t, hasTopicBytes(subTopicsLive(frames), topicA))
1669+
require.True(t, hasMutateID(subCatchupCompletes(frames), 7))
1670+
}
1671+
1672+
// TestSubscribe_HistoryOnlyOnLiveRejected verifies a history_only add targeting a topic already
1673+
// live on the same stream is rejected (one cursor floor per topic).
1674+
func TestSubscribe_HistoryOnlyOnLiveRejected(t *testing.T) {
1675+
suite := setupTest(t)
1676+
1677+
stream := suite.ClientQuery.Subscribe(t.Context())
1678+
reader := newBidiReader(stream)
1679+
1680+
require.NoError(t, stream.Send(subMutate(
1681+
1, false,
1682+
[]*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, nil)},
1683+
nil,
1684+
)))
1685+
require.Eventually(t, func() bool {
1686+
frames, _ := reader.snapshot()
1687+
return hasMutateID(subCatchupCompletes(frames), 1)
1688+
}, 10*time.Second, 20*time.Millisecond)
1689+
1690+
require.NoError(t, stream.Send(subMutate(
1691+
2, true, // history_only on the already-live topicA
1692+
[]*message_api.SubscribeRequest_V1_Mutate_Subscription{addSub(topicA, nil)},
1693+
nil,
1694+
)))
1695+
1696+
require.Eventually(t, func() bool {
1697+
_, err := reader.snapshot()
1698+
return err != nil
1699+
}, 10*time.Second, 20*time.Millisecond, "stream should be failed")
1700+
1701+
_, err := reader.snapshot()
1702+
require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
1703+
}
1704+
1705+
// TestSubscribe_NoPongIsReaped verifies an idle client that never answers the server's liveness
1706+
// Ping is reaped with DeadlineExceeded.
1707+
func TestSubscribe_NoPongIsReaped(t *testing.T) {
1708+
nodes := []registry.Node{
1709+
{NodeID: 100, IsCanonical: true},
1710+
{NodeID: 200, IsCanonical: true},
1711+
}
1712+
suite := testUtilsApi.NewTestAPIServer(
1713+
t,
1714+
testUtilsApi.WithRegistryNodes(nodes),
1715+
testUtilsApi.WithSendKeepAliveInterval(200*time.Millisecond),
1716+
)
1717+
1718+
stream := suite.ClientQuery.Subscribe(t.Context())
1719+
reader := newBidiReader(stream) // only reads; never Pongs
1720+
1721+
// Subscribe to nothing and stay idle; the server will Ping, get no Pong, and reap.
1722+
require.NoError(t, stream.Send(subMutate(1, false, nil, nil)))
1723+
1724+
require.Eventually(t, func() bool {
1725+
_, err := reader.snapshot()
1726+
return err != nil
1727+
}, 5*time.Second, 50*time.Millisecond, "an idle client that never Pongs must be reaped")
1728+
1729+
_, err := reader.snapshot()
1730+
require.Equal(t, connect.CodeDeadlineExceeded, connect.CodeOf(err))
1731+
}
1732+
1733+
func mustFrames(r *bidiReader) []*message_api.SubscribeResponse {
1734+
frames, _ := r.snapshot()
1735+
return frames
1736+
}

pkg/testutils/api/api.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ type APIServerTestSuite struct {
212212
type APIServerTestConfig struct {
213213
registryNodes []registry.Node
214214
requirePayerPositiveBalance bool
215+
sendKeepAliveInterval time.Duration
215216
}
216217

217218
type TestAPIOption func(*APIServerTestConfig)
@@ -228,6 +229,14 @@ func WithRequirePayerPositiveBalance(enabled bool) TestAPIOption {
228229
}
229230
}
230231

232+
// WithSendKeepAliveInterval overrides the server keepalive/ping cadence (default 30s), so a test
233+
// can exercise the Subscribe liveness ping/pong reaping without waiting tens of seconds.
234+
func WithSendKeepAliveInterval(d time.Duration) TestAPIOption {
235+
return func(cfg *APIServerTestConfig) {
236+
cfg.sendKeepAliveInterval = d
237+
}
238+
}
239+
231240
func createMockRegistry(t *testing.T, nodes []registry.Node) *registryMocks.MockNodeRegistry {
232241
reg := registryMocks.NewMockNodeRegistry(t)
233242

@@ -252,6 +261,9 @@ func NewTestAPIServer(
252261
for _, opt := range opts {
253262
opt(&cfg)
254263
}
264+
if cfg.sendKeepAliveInterval == 0 {
265+
cfg.sendKeepAliveInterval = 30 * time.Second
266+
}
255267

256268
var (
257269
ctx, cancel = context.WithCancel(context.Background())
@@ -307,7 +319,7 @@ func NewTestAPIServer(
307319
metadata.NewCursorUpdater(ctx, log, db),
308320
fees.NewTestFeeCalculator(),
309321
config.APIOptions{
310-
SendKeepAliveInterval: 30 * time.Second,
322+
SendKeepAliveInterval: cfg.sendKeepAliveInterval,
311323
RequirePayerPositiveBalance: cfg.requirePayerPositiveBalance,
312324
},
313325
false,

0 commit comments

Comments
 (0)