|
1 | 1 | package message_test |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
4 | 5 | "context" |
5 | 6 | "database/sql" |
6 | 7 | "errors" |
7 | 8 | "fmt" |
| 9 | + "io" |
8 | 10 | "math/rand" |
| 11 | + "slices" |
9 | 12 | "sync" |
10 | 13 | "sync/atomic" |
11 | 14 | "testing" |
@@ -1407,3 +1410,327 @@ func TestSubscribeAll_StreamsOnlyNewMessages(t *testing.T) { |
1407 | 1410 |
|
1408 | 1411 | require.Equal(t, streamSize, received) |
1409 | 1412 | } |
| 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 | +} |
0 commit comments