wrote clerk

This commit is contained in:
Jay
2026-05-09 20:15:00 -04:00
parent c0c23715e6
commit 19c62682b9
5 changed files with 327 additions and 5 deletions
+153 -4
View File
@@ -2,12 +2,25 @@ package prism
import ( import (
"context" "context"
"errors"
"fmt"
"git.wisehodl.dev/jay/go-honeybee" "git.wisehodl.dev/jay/go-honeybee"
"git.wisehodl.dev/jay/go-mana-component"
"git.wisehodl.dev/jay/go-roots-ws"
"log/slog" "log/slog"
"sync" "sync"
"time" "time"
) )
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
var (
ErrAlreadyStarted = errors.New("clerk already started")
ErrUnknownLabel = errors.New("unknown label")
)
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Types // Types
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -51,12 +64,148 @@ type clerkRoutes = map[string][]chan InboundLetter
// Clerk // 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 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
View File
@@ -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()")
}
-1
View File
@@ -2,7 +2,6 @@ package prism
import ( import (
"context" "context"
// "fmt"
"git.wisehodl.dev/jay/go-honeybee" "git.wisehodl.dev/jay/go-honeybee"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"testing" "testing"
+1
View File
@@ -9,6 +9,7 @@ require (
require ( require (
git.wisehodl.dev/jay/go-mana-component v0.1.0 // indirect 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/davecgh/go-spew v1.1.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
+2
View File
@@ -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-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 h1:wWYN5MzC9Hq3tEt4z7FjrwNuQz3rZY3RWAmgmNE8EZE=
git.wisehodl.dev/jay/go-mana-component v0.1.0/go.mod h1:r2ZaTjKzwV5JJfC5boikxtjAKusPrzlJU/7qul0EUqA= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=