614 lines
16 KiB
Go
614 lines
16 KiB
Go
package prism
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"git.wisehodl.dev/jay/go-mana-component"
|
|
"git.wisehodl.dev/jay/go-roots-ws"
|
|
"github.com/stretchr/testify/assert"
|
|
"reflect"
|
|
"slices"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TODO: remove
|
|
var (
|
|
_ context.Context
|
|
_ assert.Assertions
|
|
_ testing.T
|
|
_ time.Time
|
|
_ fmt.Formatter
|
|
)
|
|
|
|
// Helpers
|
|
|
|
type reqTestHarness struct {
|
|
ctx context.Context
|
|
pm *Postmaster
|
|
events chan PoolEvent
|
|
sent map[string][]string
|
|
sentMu *sync.RWMutex
|
|
isConnected func(string) bool
|
|
collector *JournalCollector
|
|
journals <-chan JournalEntry
|
|
closed atomic.Bool
|
|
}
|
|
|
|
func setupReqHarness(t *testing.T, peers []string) reqTestHarness {
|
|
ctx := component.MustNew(context.Background(), "prism", "test")
|
|
pm, poolEvents, sent, sentMu, isConnected := mockReqPostmaster(t, ctx, peers)
|
|
collector := NewJournalCollector()
|
|
journals := collector.Out()
|
|
return reqTestHarness{
|
|
ctx: ctx,
|
|
pm: pm,
|
|
events: poolEvents,
|
|
sent: sent,
|
|
sentMu: sentMu,
|
|
isConnected: isConnected,
|
|
collector: collector,
|
|
journals: journals,
|
|
}
|
|
}
|
|
|
|
func mockReqPostmaster(
|
|
t *testing.T,
|
|
ctx context.Context,
|
|
peers []string,
|
|
) (
|
|
pm *Postmaster,
|
|
poolEvents chan PoolEvent,
|
|
sent map[string][]string,
|
|
sentMu *sync.RWMutex,
|
|
isConnected func(id string) bool,
|
|
) {
|
|
t.Helper()
|
|
|
|
poolHasPeer := func(id string) (string, bool) {
|
|
if ok := slices.Contains(peers, id); ok {
|
|
return id, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
poolEvents = make(chan PoolEvent, 4)
|
|
pmEvents := make(chan PoolEvent, 4)
|
|
|
|
connected := make(map[string]bool)
|
|
connMu := sync.RWMutex{}
|
|
isConnected = func(id string) bool {
|
|
connMu.RLock()
|
|
defer connMu.RUnlock()
|
|
return connected[id]
|
|
}
|
|
|
|
go func() {
|
|
for ev := range poolEvents {
|
|
connMu.Lock()
|
|
switch ev.Kind {
|
|
case EventConnected:
|
|
connected[ev.ID] = true
|
|
case EventDisconnected:
|
|
connected[ev.ID] = false
|
|
}
|
|
connMu.Unlock()
|
|
pmEvents <- ev
|
|
}
|
|
}()
|
|
|
|
sent = make(map[string][]string)
|
|
sentMu = &sync.RWMutex{}
|
|
poolSendFunc := func(id string, data Envelope) error {
|
|
sentMu.Lock()
|
|
defer sentMu.Unlock()
|
|
sent[id] = append(sent[id], string(data))
|
|
return nil
|
|
}
|
|
|
|
pm = NewPostmaster(ctx, poolHasPeer, pmEvents, poolSendFunc, nil)
|
|
|
|
for _, id := range peers {
|
|
poolEvents <- PoolEvent{ID: id, Kind: EventAdded, At: time.Now()}
|
|
connected[id] = false
|
|
}
|
|
|
|
Eventually(t, func() bool { return len(pm.Peers()) == len(peers) },
|
|
"should add peers")
|
|
|
|
return
|
|
}
|
|
|
|
func expectSentMessage(t *testing.T,
|
|
sent map[string][]string,
|
|
mu *sync.RWMutex,
|
|
peerID string,
|
|
msg []byte,
|
|
index int,
|
|
) {
|
|
t.Helper()
|
|
|
|
Eventually(t, func() bool {
|
|
mu.RLock()
|
|
defer mu.RUnlock()
|
|
if len(sent[peerID]) <= index {
|
|
return false
|
|
}
|
|
return sent[peerID][index] == string(msg)
|
|
}, fmt.Sprintf("expected message to be sent to %q: %s", peerID, string(msg)))
|
|
}
|
|
|
|
func neverSentMessage(t *testing.T,
|
|
sent map[string][]string,
|
|
mu *sync.RWMutex,
|
|
peerID string,
|
|
msg []byte,
|
|
index int,
|
|
) {
|
|
t.Helper()
|
|
|
|
Never(t, func() bool {
|
|
mu.RLock()
|
|
defer mu.RUnlock()
|
|
if len(sent[peerID]) <= index {
|
|
return false
|
|
}
|
|
return sent[peerID][index] == string(msg)
|
|
}, fmt.Sprintf("unexpected message sent to %q: %s", peerID, string(msg)))
|
|
}
|
|
|
|
func expectJournalEntry(t *testing.T,
|
|
journals <-chan JournalEntry,
|
|
expected reflect.Type,
|
|
) {
|
|
t.Helper()
|
|
|
|
Eventually(t, func() bool {
|
|
select {
|
|
default:
|
|
return false
|
|
case entry := <-journals:
|
|
got := reflect.TypeOf(entry)
|
|
return expected == got
|
|
}
|
|
}, fmt.Sprintf("expected journal entry: %s", expected))
|
|
}
|
|
|
|
// Tests
|
|
|
|
func TestStreamReq_InitialReq(t *testing.T) {
|
|
t.Run("sends req to one peer", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
// connect to peer
|
|
h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()}
|
|
Eventually(t, func() bool { return h.isConnected("peer") },
|
|
"expected peer to connect")
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
expectedReq := envelope.EncloseReq("REQ", filters)
|
|
expectedClose := envelope.EncloseClose("REQ")
|
|
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(ReqQueuedJournal{}))
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{}))
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0)
|
|
|
|
// close req
|
|
req.Close()
|
|
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(CloseQueuedJournal{}))
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(CloseSendOutcomeJournal{}))
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 1)
|
|
|
|
Eventually(t, func() bool { return h.closed.Load() },
|
|
"expected close callback to be called")
|
|
})
|
|
|
|
t.Run("doesn't send to disconnected peer", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
expectedReq := envelope.EncloseReq("REQ", filters)
|
|
expectedClose := envelope.EncloseClose("REQ")
|
|
|
|
neverSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0)
|
|
|
|
// close req
|
|
req.Close()
|
|
|
|
neverSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 0)
|
|
|
|
Eventually(t, func() bool { return h.closed.Load() },
|
|
"expected close callback to be called")
|
|
})
|
|
|
|
t.Run("sends req to multiple peers", func(t *testing.T) {
|
|
peers := []string{"peer1", "peer2"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
// connect to peers
|
|
h.events <- PoolEvent{ID: "peer1", Kind: EventConnected, At: time.Now()}
|
|
h.events <- PoolEvent{ID: "peer2", Kind: EventConnected, At: time.Now()}
|
|
Eventually(t, func() bool { return h.isConnected("peer1") },
|
|
"expected peer 1 to connect")
|
|
Eventually(t, func() bool { return h.isConnected("peer2") },
|
|
"expected peer 2 to connect")
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
expectedReq := envelope.EncloseReq("REQ", filters)
|
|
expectedClose := envelope.EncloseClose("REQ")
|
|
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer1", expectedReq, 0)
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer2", expectedReq, 0)
|
|
|
|
// expect two req outcomes
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{}))
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{}))
|
|
|
|
// close req
|
|
req.Close()
|
|
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer1", expectedClose, 1)
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer2", expectedClose, 1)
|
|
|
|
Eventually(t, func() bool { return h.closed.Load() },
|
|
"expected close callback to be called")
|
|
})
|
|
}
|
|
|
|
func TestStreamReq_EventForwarding(t *testing.T) {
|
|
t.Run("events are forwarded", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
// simulate receive event
|
|
req.order(newEventTask("peer", time.Now(), []byte("event")))
|
|
|
|
// receive event
|
|
var event ReqEvent
|
|
Eventually(t, func() bool {
|
|
select {
|
|
default:
|
|
return false
|
|
case event = <-req.events:
|
|
return true
|
|
}
|
|
}, "expected event")
|
|
|
|
assert.Equal(t, "peer", event.PeerID)
|
|
assert.False(t, event.ReceivedAt.IsZero())
|
|
assert.Equal(t, []byte("event"), event.Data)
|
|
})
|
|
|
|
t.Run("events channel closes on close", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
// close req
|
|
req.Close()
|
|
|
|
Eventually(t, func() bool {
|
|
select {
|
|
default:
|
|
return false
|
|
case _, ok := <-req.events:
|
|
// expect channel close
|
|
return !ok
|
|
}
|
|
}, "expected event channel to close")
|
|
})
|
|
}
|
|
|
|
func TestStreamReq_EOSEHandling(t *testing.T) {
|
|
t.Run("eose emits journal", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
// simulate EOSE
|
|
req.order(newEOSETask("peer", time.Now()))
|
|
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(ReceivedEOSEJournal{}))
|
|
})
|
|
}
|
|
|
|
func TestStreamReq_ClosedHandling(t *testing.T) {
|
|
t.Run("closed forwards message once", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
// simulate closed
|
|
req.order(newClosedTask("peer", time.Now(), "closed"))
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(ReceivedClosedJournal{}))
|
|
|
|
// receive message
|
|
var message ReqMessage
|
|
Eventually(t, func() bool {
|
|
select {
|
|
default:
|
|
return false
|
|
case message = <-req.messages:
|
|
return true
|
|
}
|
|
}, "expected closed message")
|
|
|
|
assert.Equal(t, "peer", message.PeerID)
|
|
assert.False(t, message.ReceivedAt.IsZero())
|
|
assert.Equal(t, "closed", message.Data)
|
|
|
|
// multiple closed emit journals
|
|
req.order(newClosedTask("peer", time.Now(), "closed"))
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(ReceivedClosedJournal{}))
|
|
|
|
// but do not emit more than one message to the caller
|
|
Never(t, func() bool {
|
|
select {
|
|
default:
|
|
return false
|
|
case <-req.messages:
|
|
return true
|
|
}
|
|
}, "second closed message should not arrive")
|
|
|
|
// close req
|
|
req.Close()
|
|
|
|
// expect messages channel to close
|
|
Eventually(t, func() bool {
|
|
select {
|
|
default:
|
|
return false
|
|
case _, ok := <-req.messages:
|
|
// expect channel close
|
|
return !ok
|
|
}
|
|
}, "expected messages channel to close")
|
|
})
|
|
}
|
|
|
|
func TestStreamReq_Reconnect(t *testing.T) {
|
|
t.Run("req replays after reconnect", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()}
|
|
Eventually(t, func() bool { return h.isConnected("peer") },
|
|
"expected peer to connect")
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
expectedReq := envelope.EncloseReq("REQ", filters)
|
|
expectedClose := envelope.EncloseClose("REQ")
|
|
|
|
// initial req is sent
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0)
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{}))
|
|
|
|
// cycle disconnect-reconnect
|
|
h.events <- PoolEvent{ID: "peer", Kind: EventDisconnected, At: time.Now()}
|
|
Eventually(t, func() bool { return !h.isConnected("peer") },
|
|
"expected peer to disconnect")
|
|
h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()}
|
|
Eventually(t, func() bool { return h.isConnected("peer") },
|
|
"expected peer to connect")
|
|
|
|
// simulate req manager handling connect event
|
|
req.order(newHandleReconnect("peer"))
|
|
|
|
// expect replayed req
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 1)
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{}))
|
|
|
|
// close req
|
|
req.Close()
|
|
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 2)
|
|
|
|
Eventually(t, func() bool { return h.closed.Load() },
|
|
"expected close callback to be called")
|
|
})
|
|
|
|
t.Run("delayed connection sends req", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
expectedReq := envelope.EncloseReq("REQ", filters)
|
|
expectedClose := envelope.EncloseClose("REQ")
|
|
|
|
neverSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0)
|
|
|
|
// postmaster-side connect
|
|
h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()}
|
|
Eventually(t, func() bool { return h.isConnected("peer") },
|
|
"expected peer to connect")
|
|
|
|
// simulate req manager handling connect event
|
|
req.order(newHandleReconnect("peer"))
|
|
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0)
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{}))
|
|
|
|
// close req
|
|
req.Close()
|
|
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 1)
|
|
|
|
Eventually(t, func() bool { return h.closed.Load() },
|
|
"expected close callback to be called")
|
|
})
|
|
|
|
t.Run("no replay when closing", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
expectedReq := envelope.EncloseReq("REQ", filters)
|
|
|
|
neverSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0)
|
|
|
|
// postmaster-side connect
|
|
h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()}
|
|
Eventually(t, func() bool { return h.isConnected("peer") },
|
|
"expected peer to connect")
|
|
|
|
// close req
|
|
req.Close()
|
|
|
|
// reconnect during or after close
|
|
req.order(newHandleReconnect("peer"))
|
|
neverSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0)
|
|
|
|
Eventually(t, func() bool { return h.closed.Load() },
|
|
"expected close callback to be called")
|
|
})
|
|
}
|
|
|
|
func TestStreamReq_Terminal(t *testing.T) {
|
|
}
|
|
|
|
func TestStreamReq_Close(t *testing.T) {
|
|
t.Run("close is idempotent", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
// connect to peer
|
|
h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()}
|
|
Eventually(t, func() bool { return h.isConnected("peer") },
|
|
"expected peer to connect")
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
expectedReq := envelope.EncloseReq("REQ", filters)
|
|
expectedClose := envelope.EncloseClose("REQ")
|
|
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0)
|
|
expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{}))
|
|
|
|
// close req twice
|
|
req.Close()
|
|
req.Close()
|
|
|
|
// only expect one close
|
|
expectSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 1)
|
|
|
|
// second close never arrives
|
|
neverSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 2)
|
|
|
|
Eventually(t, func() bool { return h.closed.Load() },
|
|
"expected close callback to be called")
|
|
})
|
|
|
|
t.Run("close not sent if req was never sent", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
expectedReq := envelope.EncloseReq("REQ", filters)
|
|
expectedClose := envelope.EncloseClose("REQ")
|
|
|
|
neverSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0)
|
|
|
|
// close req
|
|
req.Close()
|
|
|
|
// req was never sent, so a close should not be sent
|
|
neverSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 0)
|
|
|
|
Eventually(t, func() bool { return h.closed.Load() },
|
|
"expected close callback to be called")
|
|
})
|
|
|
|
t.Run("close not sent if req was cancelled", func(t *testing.T) {
|
|
peers := []string{"peer"}
|
|
h := setupReqHarness(t, peers)
|
|
|
|
// open req
|
|
filters := [][]byte{[]byte("{}")}
|
|
req := NewStreamReq(
|
|
h.ctx, "REQ", filters, peers, h.pm, h.isConnected,
|
|
h.collector, func() { h.closed.Store(true) }, nil)
|
|
|
|
expectedClose := envelope.EncloseClose("REQ")
|
|
|
|
// simulate cancelled req outcome
|
|
req.order(newReqOutcomeTask("peer", LetterOutcome{Kind: OutcomeCancelled}))
|
|
|
|
// close req
|
|
req.Close()
|
|
|
|
// req was never sent, so a close should not be sent
|
|
neverSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 0)
|
|
|
|
Eventually(t, func() bool { return h.closed.Load() },
|
|
"expected close callback to be called")
|
|
})
|
|
}
|
|
|
|
func TestStreamReq_Journals(t *testing.T) {
|
|
}
|