wrote clerk
This commit is contained in:
@@ -2,12 +2,25 @@ package prism
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.wisehodl.dev/jay/go-honeybee"
|
||||
"git.wisehodl.dev/jay/go-mana-component"
|
||||
"git.wisehodl.dev/jay/go-roots-ws"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
ErrAlreadyStarted = errors.New("clerk already started")
|
||||
ErrUnknownLabel = errors.New("unknown label")
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Types
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -51,12 +64,148 @@ type clerkRoutes = map[string][]chan InboundLetter
|
||||
// Clerk
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func NewClerk() *Clerk {
|
||||
func NewClerk(
|
||||
ctx context.Context,
|
||||
inbox <-chan honeybee.InboxMessage,
|
||||
knownLabels map[string]struct{},
|
||||
handler slog.Handler,
|
||||
) *Clerk {
|
||||
ctx, cancel := context.WithCancel(
|
||||
component.MustNew(ctx, "prism", "clerk"))
|
||||
|
||||
known := make(map[string]struct{}, len(knownLabels))
|
||||
for label := range knownLabels {
|
||||
known[label] = struct{}{}
|
||||
}
|
||||
|
||||
c := &Clerk{
|
||||
inbox: inbox,
|
||||
known: known,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
if handler != nil {
|
||||
comp, ok := component.Get(ctx)
|
||||
if ok {
|
||||
c.logger = slog.New(handler).With(slog.Any("component", comp))
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Clerk) Subscribe(
|
||||
labels map[string]struct{},
|
||||
buffer int,
|
||||
) (<-chan InboundLetter, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.started {
|
||||
return nil, ErrAlreadyStarted
|
||||
}
|
||||
|
||||
for label := range labels {
|
||||
if _, ok := c.known[label]; !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownLabel, label)
|
||||
}
|
||||
}
|
||||
|
||||
subLabels := make(map[string]struct{}, len(labels))
|
||||
for label := range labels {
|
||||
subLabels[label] = struct{}{}
|
||||
}
|
||||
|
||||
ch := make(chan InboundLetter, buffer)
|
||||
c.pending = append(c.pending, clerkSub{ch: ch, labels: subLabels})
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (c *Clerk) Start() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.started {
|
||||
return ErrAlreadyStarted
|
||||
}
|
||||
|
||||
routes := make(clerkRoutes, len(c.known))
|
||||
for _, sub := range c.pending {
|
||||
for label := range sub.labels {
|
||||
routes[label] = append(routes[label], sub.ch)
|
||||
}
|
||||
}
|
||||
|
||||
c.routes = routes
|
||||
c.started = true
|
||||
|
||||
c.wg.Add(1)
|
||||
go c.run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Clerk) Subscribe() {}
|
||||
func (c *Clerk) Close() {
|
||||
c.cancel()
|
||||
c.wg.Wait()
|
||||
|
||||
func (c *Clerk) Start() {}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
func (c *Clerk) Close() {}
|
||||
for _, sub := range c.pending {
|
||||
close(sub.ch)
|
||||
}
|
||||
|
||||
// prevent double channel closes if Close() is called twice
|
||||
c.pending = nil
|
||||
}
|
||||
|
||||
func (c *Clerk) run() {
|
||||
defer c.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
|
||||
case msg, ok := <-c.inbox:
|
||||
if !ok {
|
||||
// inbox closed externally, close clerk
|
||||
c.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
labelBytes, err := envelope.GetLabel(msg.Data)
|
||||
if err != nil {
|
||||
if c.logger != nil {
|
||||
c.logger.Warn("invalid envelope",
|
||||
"peer_id", msg.ID,
|
||||
"received_at", msg.ReceivedAt,
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
subs, ok := c.routes[string(labelBytes)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
letter := InboundLetter{
|
||||
ID: msg.ID,
|
||||
Data: msg.Data,
|
||||
At: msg.ReceivedAt,
|
||||
}
|
||||
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- letter:
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package prism
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.wisehodl.dev/jay/go-honeybee"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func mockInbox() (chan honeybee.InboxMessage, func(label string)) {
|
||||
ch := make(chan honeybee.InboxMessage, 8)
|
||||
inject := func(label string) {
|
||||
ch <- honeybee.InboxMessage{
|
||||
ID: "wss://test",
|
||||
Data: []byte(`["` + label + `","payload"]`),
|
||||
ReceivedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
return ch, inject
|
||||
}
|
||||
|
||||
func makeClerk(inbox chan honeybee.InboxMessage) *Clerk {
|
||||
known := map[string]struct{}{
|
||||
"EVENT": {},
|
||||
"EOSE": {},
|
||||
"CLOSE": {},
|
||||
}
|
||||
return NewClerk(context.Background(), inbox, known, nil)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func TestClerkRouting(t *testing.T) {
|
||||
inbox, inject := mockInbox()
|
||||
c := makeClerk(inbox)
|
||||
|
||||
subA, err := c.Subscribe(map[string]struct{}{"EVENT": {}}, 4)
|
||||
assert.NoError(t, err)
|
||||
|
||||
subB, err := c.Subscribe(map[string]struct{}{"EVENT": {}, "EOSE": {}}, 4)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, c.Start())
|
||||
|
||||
inject("EVENT")
|
||||
inject("EOSE")
|
||||
|
||||
// A receives exactly one letter (EVENT only)
|
||||
Eventually(t, func() bool {
|
||||
select {
|
||||
case l := <-subA:
|
||||
return string(l.Data) == `["EVENT","payload"]`
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "subA should receive the EVENT letter")
|
||||
|
||||
Never(t, func() bool {
|
||||
select {
|
||||
case <-subA:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "subA should receive no further letters")
|
||||
|
||||
// B receives two letters (EVENT and EOSE)
|
||||
count := 0
|
||||
Eventually(t, func() bool {
|
||||
select {
|
||||
case <-subB:
|
||||
count++
|
||||
default:
|
||||
}
|
||||
return count == 2
|
||||
}, "subB should receive both letters")
|
||||
}
|
||||
|
||||
func TestClerkStartup(t *testing.T) {
|
||||
inbox, _ := mockInbox()
|
||||
c := makeClerk(inbox)
|
||||
|
||||
assert.NoError(t, c.Start())
|
||||
|
||||
_, err := c.Subscribe(map[string]struct{}{"EVENT": {}}, 4)
|
||||
assert.ErrorIs(t, err, ErrAlreadyStarted)
|
||||
|
||||
c.Close()
|
||||
}
|
||||
|
||||
func TestClerkUnknownSubscriptionLabel(t *testing.T) {
|
||||
inbox, _ := mockInbox()
|
||||
c := makeClerk(inbox)
|
||||
|
||||
_, err := c.Subscribe(map[string]struct{}{"UNKNOWN": {}}, 4)
|
||||
assert.ErrorIs(t, err, ErrUnknownLabel)
|
||||
}
|
||||
|
||||
func TestClerkUnknownInboxLabel(t *testing.T) {
|
||||
inbox, inject := mockInbox()
|
||||
c := makeClerk(inbox)
|
||||
|
||||
// subscribe to every known label
|
||||
sub, err := c.Subscribe(
|
||||
map[string]struct{}{"EVENT": {}, "EOSE": {}, "CLOSE": {}}, 4)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, c.Start())
|
||||
|
||||
// inject a valid nostr label, but is not in the test label set
|
||||
inject("NOTICE")
|
||||
|
||||
Never(t, func() bool {
|
||||
select {
|
||||
case <-sub:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "no subscriber should receive an unknown label")
|
||||
}
|
||||
|
||||
func TestClerkInboxClose(t *testing.T) {
|
||||
inbox, _ := mockInbox()
|
||||
c := makeClerk(inbox)
|
||||
|
||||
sub, err := c.Subscribe(map[string]struct{}{"EVENT": {}}, 4)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, c.Start())
|
||||
|
||||
// close the inbox as the pool would on shutdown
|
||||
close(inbox)
|
||||
|
||||
// internal waitgroup should clear
|
||||
Eventually(t, func() bool {
|
||||
c.wg.Wait()
|
||||
return true
|
||||
}, "wg should clear")
|
||||
|
||||
// subscriptions remain open. Close() must be called to completely shut down
|
||||
Never(t, func() bool {
|
||||
_, ok := <-sub
|
||||
return !ok
|
||||
}, "sub should remain open")
|
||||
}
|
||||
|
||||
func TestClerkClose(t *testing.T) {
|
||||
inbox, _ := mockInbox()
|
||||
c := makeClerk(inbox)
|
||||
|
||||
subA, err := c.Subscribe(map[string]struct{}{"EVENT": {}}, 4)
|
||||
assert.NoError(t, err)
|
||||
subB, err := c.Subscribe(map[string]struct{}{"EOSE": {}}, 4)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, c.Start())
|
||||
|
||||
c.Close()
|
||||
|
||||
Eventually(t, func() bool {
|
||||
_, okA := <-subA
|
||||
_, okB := <-subB
|
||||
return !okA && !okB
|
||||
}, "all subscriber channels should be closed after Close()")
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package prism
|
||||
|
||||
import (
|
||||
"context"
|
||||
// "fmt"
|
||||
"git.wisehodl.dev/jay/go-honeybee"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
|
||||
@@ -9,6 +9,7 @@ require (
|
||||
|
||||
require (
|
||||
git.wisehodl.dev/jay/go-mana-component v0.1.0 // indirect
|
||||
git.wisehodl.dev/jay/go-roots-ws v0.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
|
||||
@@ -2,6 +2,8 @@ git.wisehodl.dev/jay/go-honeybee v0.2.0 h1:bF+/7WQzJnGBv5VuPBkWjshWWMbK4PZy8gia7
|
||||
git.wisehodl.dev/jay/go-honeybee v0.2.0/go.mod h1:Xf3atUWJ2JgWVYpTBBxSgzL3ELdAo0znpqwpBZk9DlA=
|
||||
git.wisehodl.dev/jay/go-mana-component v0.1.0 h1:wWYN5MzC9Hq3tEt4z7FjrwNuQz3rZY3RWAmgmNE8EZE=
|
||||
git.wisehodl.dev/jay/go-mana-component v0.1.0/go.mod h1:r2ZaTjKzwV5JJfC5boikxtjAKusPrzlJU/7qul0EUqA=
|
||||
git.wisehodl.dev/jay/go-roots-ws v0.1.0 h1:p1veCkpOmL26N//Qz7ekJOYj1Ck30ai4OKq9dxLjodk=
|
||||
git.wisehodl.dev/jay/go-roots-ws v0.1.0/go.mod h1:ANQOOP13lHs2uQwYhrSQGAlL7+zR6QvbLzNPmNBJssQ=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
|
||||
Reference in New Issue
Block a user