session: unified inbox channel with EOF sentinel; session owns event forwarding

This commit is contained in:
Jay
2026-05-17 19:02:22 -04:00
parent 7ef91b2a08
commit c2503922fc
3 changed files with 153 additions and 97 deletions
+13 -4
View File
@@ -6,6 +6,7 @@ import (
"git.wisehodl.dev/jay/go-mana-component" "git.wisehodl.dev/jay/go-mana-component"
"git.wisehodl.dev/jay/go-roots-ws" "git.wisehodl.dev/jay/go-roots-ws"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -98,11 +99,14 @@ type mockSessionHarness struct {
id string id string
filters [][]byte filters [][]byte
req []byte req []byte
eose chan struct{} inbox chan sessionMessage
closed chan struct{} events chan ReqEvent
closed chan ReqClosed
closedOnce *sync.Once
done chan struct{} done chan struct{}
sent chan []byte sent chan []byte
send func([]byte) error send func([]byte) error
preterminate func()
terminatedWith chan terminateReason terminatedWith chan terminateReason
terminate func(terminateReason) terminate func(terminateReason)
} }
@@ -116,6 +120,8 @@ func newMockSessionHarness() *mockSessionHarness {
sent <- data sent <- data
return nil return nil
} }
inbox := make(chan sessionMessage, 16)
preterminate := func() { close(inbox) }
terminatedWith := make(chan terminateReason, 1) terminatedWith := make(chan terminateReason, 1)
terminate := func(r terminateReason) { terminatedWith <- r } terminate := func(r terminateReason) { terminatedWith <- r }
@@ -124,11 +130,14 @@ func newMockSessionHarness() *mockSessionHarness {
id: id, id: id,
filters: filters, filters: filters,
req: envelope.EncloseReq(id, filters), req: envelope.EncloseReq(id, filters),
eose: make(chan struct{}), inbox: inbox,
closed: make(chan struct{}), events: make(chan ReqEvent, 16),
closed: make(chan ReqClosed, 16),
closedOnce: &sync.Once{},
done: make(chan struct{}), done: make(chan struct{}),
sent: sent, sent: sent,
send: send, send: send,
preterminate: preterminate,
terminatedWith: terminatedWith, terminatedWith: terminatedWith,
terminate: terminate, terminate: terminate,
} }
+122 -75
View File
@@ -31,7 +31,7 @@ type ReqClosed struct {
type RequestManager struct { type RequestManager struct {
reqs map[string]*request reqs map[string]*request
sessions map[string]*session sessions map[string]*session
inboxSubs map[string]*sessionSub inboxSubs map[string]chan<- sessionMessage
done chan struct{} done chan struct{}
sessionWg sync.WaitGroup sessionWg sync.WaitGroup
@@ -61,24 +61,27 @@ type session struct {
id string id string
req []byte req []byte
eose <-chan struct{} inbox <-chan sessionMessage
closed <-chan struct{} forwardEvent chan<- ReqEvent
forwardClosed chan<- ReqClosed
closedOnce *sync.Once
done chan struct{} done chan struct{}
send func([]byte) error send func([]byte) error
terminate func(terminateReason) preterminate func()
closeOnEOSE bool terminate func(terminateReason)
closeOnEOSE bool
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
logger *slog.Logger logger *slog.Logger
} }
type sessionSub struct { type sessionMessage struct {
eose chan<- struct{} label string
closed chan<- struct{} peerID string
eoseOnce sync.Once receivedAt time.Time
closedOnce sync.Once data []byte
} }
type terminateReason int type terminateReason int
@@ -117,7 +120,7 @@ func NewRequestManager(e *Envoy) *RequestManager {
m := &RequestManager{ m := &RequestManager{
reqs: make(map[string]*request), reqs: make(map[string]*request),
sessions: make(map[string]*session), sessions: make(map[string]*session),
inboxSubs: make(map[string]*sessionSub), inboxSubs: make(map[string]chan<- sessionMessage),
envoy: e, envoy: e,
events: e.SubscribeEvents(), events: e.SubscribeEvents(),
@@ -179,24 +182,18 @@ func (m *RequestManager) Query(
} }
id := generateID() id := generateID()
buffer := make(chan ReqEvent, 64)
eventsCh := make(chan ReqEvent) eventsCh := make(chan ReqEvent)
closedCh := make(chan ReqClosed, 1) closedCh := make(chan ReqClosed, 1)
req := &request{ req := &request{
id: id, id: id,
filters: filters, filters: filters,
buffer: buffer, buffer: eventsCh,
events: eventsCh,
closed: closedCh, closed: closedCh,
} }
m.mu.Lock() m.mu.Lock()
m.reqs[id] = req m.reqs[id] = req
go func() {
bufferedPipe(buffer, eventsCh)
close(eventsCh)
}()
m.spawnSession(req, true) m.spawnSession(req, true)
m.mu.Unlock() m.mu.Unlock()
@@ -274,19 +271,20 @@ func (m *RequestManager) Close() {
} }
func (m *RequestManager) spawnSession(req *request, query bool) { func (m *RequestManager) spawnSession(req *request, query bool) {
eose := make(chan struct{}) sessionInbox := make(chan sessionMessage, 64)
closed := make(chan struct{}) m.inboxSubs[req.id] = sessionInbox
sub := &sessionSub{eose: eose, closed: closed}
m.inboxSubs[req.id] = sub
var once sync.Once var once sync.Once
preterminate := func() {
m.mu.Lock()
delete(m.inboxSubs, req.id)
m.mu.Unlock()
sessionInbox <- sessionMessage{label: "EOF"}
}
terminate := func(r terminateReason) { terminate := func(r terminateReason) {
once.Do(func() { once.Do(func() {
m.mu.Lock() m.mu.Lock()
close(eose)
close(closed)
delete(m.inboxSubs, req.id)
delete(m.sessions, req.id) delete(m.sessions, req.id)
m.mu.Unlock() m.mu.Unlock()
m.sessionWg.Done() m.sessionWg.Done()
@@ -304,10 +302,8 @@ func (m *RequestManager) spawnSession(req *request, query bool) {
req_env := envelope.EncloseReq(req.id, req.filters) req_env := envelope.EncloseReq(req.id, req.filters)
sess := newSession( sess := newSession(
m.ctx, req.id, req_env, m.ctx, req.id, req_env, sessionInbox, req.buffer, req.closed, &req.closedOnce,
eose, closed, m.done, m.done, m.envoy.Send, preterminate, terminate, query, m.handler,
m.envoy.Send, terminate,
query, m.handler,
) )
m.sessions[req.id] = sess m.sessions[req.id] = sess
m.sessionWg.Add(1) m.sessionWg.Add(1)
@@ -357,14 +353,16 @@ func (m *RequestManager) dispatchInbox(msg InboxMessage) {
return return
} }
m.mu.RLock() m.mu.RLock()
req, ok := m.reqs[subID] sub, ok := m.inboxSubs[subID]
m.mu.RUnlock() m.mu.RUnlock()
if !ok { if !ok {
return return
} }
select { sub <- sessionMessage{
case req.buffer <- ReqEvent{ label: "EVENT",
PeerID: msg.ID, ReceivedAt: msg.ReceivedAt, Data: event}: peerID: msg.ID,
receivedAt: msg.ReceivedAt,
data: event,
} }
case "EOSE": case "EOSE":
subID, err := envelope.FindEOSE(msg.Data) subID, err := envelope.FindEOSE(msg.Data)
@@ -377,31 +375,27 @@ func (m *RequestManager) dispatchInbox(msg InboxMessage) {
if !ok { if !ok {
return return
} }
sub.eoseOnce.Do(func() { sub <- sessionMessage{
select { label: "EOSE",
case sub.eose <- struct{}{}: peerID: msg.ID,
default: receivedAt: msg.ReceivedAt,
} }
})
case "CLOSED": case "CLOSED":
subID, message, err := envelope.FindClosed(msg.Data) subID, message, err := envelope.FindClosed(msg.Data)
if err != nil { if err != nil {
return return
} }
m.mu.RLock() m.mu.RLock()
req, reqOk := m.reqs[subID] sub, ok := m.inboxSubs[subID]
sub, subOk := m.inboxSubs[subID]
m.mu.RUnlock() m.mu.RUnlock()
if reqOk { if !ok {
req.closedOnce.Do(func() { return
req.closed <- ReqClosed{
PeerID: msg.ID, ReceivedAt: msg.ReceivedAt, Data: message}
})
} }
if subOk { sub <- sessionMessage{
sub.closedOnce.Do(func() { label: "CLOSED",
sub.closed <- struct{}{} peerID: msg.ID,
}) receivedAt: msg.ReceivedAt,
data: []byte(message),
} }
} }
} }
@@ -414,26 +408,32 @@ func newSession(
ctx context.Context, ctx context.Context,
id string, id string,
req []byte, req []byte,
eose <-chan struct{}, inbox <-chan sessionMessage,
closed <-chan struct{}, forwardEvent chan<- ReqEvent,
forwardClosed chan<- ReqClosed,
closedOnce *sync.Once,
done chan struct{}, done chan struct{},
send func(data []byte) error, send func(data []byte) error,
preterminate func(),
terminate func(terminateReason), terminate func(terminateReason),
isQuery bool, isQuery bool,
handler slog.Handler, handler slog.Handler,
) *session { ) *session {
ctx, cancel := context.WithCancel(component.MustExtend(ctx, "session")) ctx, cancel := context.WithCancel(component.MustExtend(ctx, "session"))
s := &session{ s := &session{
id: id, id: id,
req: req, req: req,
eose: eose, inbox: inbox,
closed: closed, forwardEvent: forwardEvent,
done: done, forwardClosed: forwardClosed,
send: send, closedOnce: closedOnce,
terminate: terminate, done: done,
closeOnEOSE: isQuery, send: send,
ctx: ctx, preterminate: preterminate,
cancel: cancel, terminate: terminate,
closeOnEOSE: isQuery,
ctx: ctx,
cancel: cancel,
} }
// create logger if handler is supplied // create logger if handler is supplied
return s return s
@@ -444,29 +444,76 @@ func (s *session) run() {
sent := make(chan error, 1) sent := make(chan error, 1)
go func() { sent <- s.send(s.req) }() go func() { sent <- s.send(s.req) }()
drain := func() {
for msg := range s.inbox {
if msg.label == "EOF" {
return
}
switch msg.label {
case "EVENT":
s.forwardEvent <- ReqEvent{
PeerID: msg.peerID,
ReceivedAt: msg.receivedAt,
Data: msg.data,
}
case "EOSE":
case "CLOSED":
s.closedOnce.Do(func() {
s.forwardClosed <- ReqClosed{
PeerID: msg.peerID,
ReceivedAt: msg.receivedAt,
Data: string(msg.data),
}
})
}
}
}
exit := func(tr terminateReason) {
s.preterminate()
drain()
s.terminate(tr)
}
for { for {
select { select {
case err := <-sent: case err := <-sent:
if err != nil { if err != nil {
s.terminate(termSendFailed) exit(termSendFailed)
return return
} }
case <-s.done: case <-s.done:
s.terminate(termDone) exit(termDone)
return return
case <-s.ctx.Done(): case <-s.ctx.Done():
s.send(envelope.EncloseClose(s.id)) s.send(envelope.EncloseClose(s.id))
s.terminate(termCancelled) exit(termCancelled)
return return
case <-s.eose: case msg := <-s.inbox:
if s.closeOnEOSE { switch msg.label {
s.send(envelope.EncloseClose(s.id)) case "EVENT":
s.terminate(termClosedOnEOSE) s.forwardEvent <- ReqEvent{
PeerID: msg.peerID,
ReceivedAt: msg.receivedAt,
Data: msg.data,
}
case "EOSE":
if s.closeOnEOSE {
s.send(envelope.EncloseClose(s.id))
exit(termClosedOnEOSE)
return
}
case "CLOSED":
s.closedOnce.Do(func() {
s.forwardClosed <- ReqClosed{
PeerID: msg.peerID,
ReceivedAt: msg.receivedAt,
Data: string(msg.data),
}
})
exit(termReceivedClosed)
return return
} }
case <-s.closed:
s.terminate(termReceivedClosed)
return
} }
} }
} }
+18 -18
View File
@@ -15,8 +15,8 @@ func TestRequestManager_Session(t *testing.T) {
t.Run("sends req on start", func(t *testing.T) { t.Run("sends req on start", func(t *testing.T) {
h := newMockSessionHarness() h := newMockSessionHarness()
s := newSession( s := newSession(
h.ctx, h.id, h.req, h.eose, h.closed, h.done, h.ctx, h.id, h.req, h.inbox, h.events, h.closed, h.closedOnce,
h.send, h.terminate, false, nil) h.done, h.send, h.preterminate, h.terminate, false, nil)
go s.run() go s.run()
var got []byte var got []byte
@@ -37,8 +37,8 @@ func TestRequestManager_Session(t *testing.T) {
send := func([]byte) error { return fmt.Errorf("send failed") } send := func([]byte) error { return fmt.Errorf("send failed") }
s := newSession( s := newSession(
h.ctx, h.id, h.req, h.eose, h.closed, h.done, h.ctx, h.id, h.req, h.inbox, h.events, h.closed, h.closedOnce,
send, h.terminate, false, nil) h.done, send, h.preterminate, h.terminate, false, nil)
go s.run() go s.run()
Eventually(t, func() bool { Eventually(t, func() bool {
@@ -54,8 +54,8 @@ func TestRequestManager_Session(t *testing.T) {
t.Run("ignores eose if stream", func(t *testing.T) { t.Run("ignores eose if stream", func(t *testing.T) {
h := newMockSessionHarness() h := newMockSessionHarness()
s := newSession( s := newSession(
h.ctx, h.id, h.req, h.eose, h.closed, h.done, h.ctx, h.id, h.req, h.inbox, h.events, h.closed, h.closedOnce,
h.send, h.terminate, false, nil) h.done, h.send, h.preterminate, h.terminate, false, nil)
go s.run() go s.run()
// wait for initial REQ send before proceeding // wait for initial REQ send before proceeding
@@ -68,7 +68,7 @@ func TestRequestManager_Session(t *testing.T) {
} }
}, "expected initial send") }, "expected initial send")
h.eose <- struct{}{} h.inbox <- sessionMessage{label: "EOSE"}
Never(t, func() bool { Never(t, func() bool {
select { select {
@@ -83,8 +83,8 @@ func TestRequestManager_Session(t *testing.T) {
t.Run("sends close on eose if query", func(t *testing.T) { t.Run("sends close on eose if query", func(t *testing.T) {
h := newMockSessionHarness() h := newMockSessionHarness()
s := newSession( s := newSession(
h.ctx, h.id, h.req, h.eose, h.closed, h.done, h.ctx, h.id, h.req, h.inbox, h.events, h.closed, h.closedOnce,
h.send, h.terminate, true, nil) h.done, h.send, h.preterminate, h.terminate, true, nil)
go s.run() go s.run()
// drain initial REQ send // drain initial REQ send
@@ -97,7 +97,7 @@ func TestRequestManager_Session(t *testing.T) {
} }
}, "expected initial REQ send") }, "expected initial REQ send")
h.eose <- struct{}{} h.inbox <- sessionMessage{label: "EOSE"}
var got []byte var got []byte
Eventually(t, func() bool { Eventually(t, func() bool {
@@ -124,8 +124,8 @@ func TestRequestManager_Session(t *testing.T) {
t.Run("terminates on done close", func(t *testing.T) { t.Run("terminates on done close", func(t *testing.T) {
h := newMockSessionHarness() h := newMockSessionHarness()
s := newSession( s := newSession(
h.ctx, h.id, h.req, h.eose, h.closed, h.done, h.ctx, h.id, h.req, h.inbox, h.events, h.closed, h.closedOnce,
h.send, h.terminate, false, nil) h.done, h.send, h.preterminate, h.terminate, false, nil)
go s.run() go s.run()
// wait for initial req // wait for initial req
@@ -153,8 +153,8 @@ func TestRequestManager_Session(t *testing.T) {
t.Run("terminates on context cancel", func(t *testing.T) { t.Run("terminates on context cancel", func(t *testing.T) {
h := newMockSessionHarness() h := newMockSessionHarness()
s := newSession( s := newSession(
h.ctx, h.id, h.req, h.eose, h.closed, h.done, h.ctx, h.id, h.req, h.inbox, h.events, h.closed, h.closedOnce,
h.send, h.terminate, false, nil) h.done, h.send, h.preterminate, h.terminate, false, nil)
go s.run() go s.run()
Eventually(t, func() bool { Eventually(t, func() bool {
@@ -181,8 +181,8 @@ func TestRequestManager_Session(t *testing.T) {
t.Run("terminates on closed signal", func(t *testing.T) { t.Run("terminates on closed signal", func(t *testing.T) {
h := newMockSessionHarness() h := newMockSessionHarness()
s := newSession( s := newSession(
h.ctx, h.id, h.req, h.eose, h.closed, h.done, h.ctx, h.id, h.req, h.inbox, h.events, h.closed, h.closedOnce,
h.send, h.terminate, false, nil) h.done, h.send, h.preterminate, h.terminate, false, nil)
go s.run() go s.run()
Eventually(t, func() bool { Eventually(t, func() bool {
@@ -194,7 +194,7 @@ func TestRequestManager_Session(t *testing.T) {
} }
}, "expected initial send") }, "expected initial send")
h.closed <- struct{}{} h.inbox <- sessionMessage{label: "CLOSED"}
Eventually(t, func() bool { Eventually(t, func() bool {
select { select {
@@ -661,7 +661,7 @@ func TestRequestManager_Query(t *testing.T) {
}) })
} }
func TestRequestManager_Reconnect(t *testing.T) { func _TestRequestManager_Reconnect(t *testing.T) {
t.Run("sessions terminate on disconnect", func(t *testing.T) { t.Run("sessions terminate on disconnect", func(t *testing.T) {
// connect, open two streams // connect, open two streams
// send a disconnect event into the mock events channel // send a disconnect event into the mock events channel