wrote postmaster

This commit is contained in:
Jay
2026-05-10 18:47:18 -04:00
parent b87d8f8fb1
commit 0c08a7ce09
5 changed files with 414 additions and 50 deletions
+7 -7
View File
@@ -64,7 +64,7 @@ var convertPoolEvent = map[honeybee.OutboundPoolEventKind]PoolEventKind{
type Adapter interface {
Peers() []string
HasPeer(id string) bool
HasPeer(id string) (string, bool)
IsConnected(id string) bool
Subscribe() <-chan PoolEvent
Send(id string, data Envelope) error
@@ -253,17 +253,17 @@ func (e *Embassy) Peers() []string {
return peers
}
func (e *Embassy) HasPeer(url string) bool {
func (e *Embassy) HasPeer(url string) (string, bool) {
url, err := honeybee.NormalizeURL(url)
if err != nil {
return false
return "", false
}
e.mu.RLock()
defer e.mu.RUnlock()
_, ok := e.peers[url]
return ok
return url, ok
}
func (e *Embassy) IsConnected(url string) bool {
@@ -312,7 +312,7 @@ func (e *Embassy) runEventRouter() {
continue
}
if !e.HasPeer(url) {
if _, ok := e.HasPeer(url); !ok {
continue
}
@@ -384,8 +384,8 @@ func (h *Hotel) Peers() []string {
return nil
}
func (h *Hotel) HasPeer(id string) bool {
return false
func (h *Hotel) HasPeer(id string) (string, bool) {
return "", false
}
func (h *Hotel) IsConnected(id string) bool {
+1 -1
View File
@@ -78,7 +78,7 @@ func TestCourierMultipleSends(t *testing.T) {
c.HandleConnect()
outcomes := make([]LetterOutcome, 0, 2)
called := make(chan LetterOutcome, 4)
called := make(chan LetterOutcome, 2)
c.Enqueue(newTestLetter(ctx, 1), func(o LetterOutcome) { called <- o })
c.Enqueue(newTestLetter(ctx, 2), func(o LetterOutcome) { called <- o })
+9 -5
View File
@@ -103,7 +103,9 @@ func TestEmbassyPeerRegistry(t *testing.T) {
// add
e.Dispatch("wss://test")
assert.True(t, e.HasPeer("wss://test"))
url, ok := e.HasPeer("wss://test/")
assert.Equal(t, "wss://test", url)
assert.True(t, ok)
assert.False(t, e.IsConnected("wss://test"))
// connect
@@ -114,7 +116,7 @@ func TestEmbassyPeerRegistry(t *testing.T) {
}
Eventually(t, func() bool {
exists := e.HasPeer("wss://test")
_, exists := e.HasPeer("wss://test")
connected := e.IsConnected("wss://test")
return exists && connected
}, "expected: exists, connected")
@@ -127,7 +129,7 @@ func TestEmbassyPeerRegistry(t *testing.T) {
}
Eventually(t, func() bool {
exists := e.HasPeer("wss://test")
_, exists := e.HasPeer("wss://test")
connected := e.IsConnected("wss://test")
return exists && !connected
}, "expected: exists, disconnected")
@@ -135,7 +137,8 @@ func TestEmbassyPeerRegistry(t *testing.T) {
// remove
e.Dismiss("wss://test")
assert.False(t, e.HasPeer("wss://test"))
_, ok = e.HasPeer("wss://test")
assert.False(t, ok)
assert.False(t, e.IsConnected("wss://test"))
}
@@ -236,7 +239,8 @@ func TestEmbassyClose(t *testing.T) {
}, "expected peer removed")
// peer list is empty
assert.False(t, e.HasPeer("wss://test"))
_, ok := e.HasPeer("wss://test")
assert.False(t, ok)
assert.Len(t, e.Peers(), 0)
// subs close
+182 -30
View File
@@ -64,15 +64,17 @@ type LetterOutcome struct {
type Postmaster struct {
couriers map[string]*Courier
events <-chan PoolEvent // Adapter.Subscribe
send PoolSendFunc // Adapter.Send
poolHasPeer func(id string) (string, bool)
poolEvents <-chan PoolEvent // Adapter.Subscribe
poolSend PoolSendFunc // Adapter.Send
counter atomic.Uint64
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
mu sync.RWMutex
wg sync.WaitGroup
cfg postmasterConfig
handler slog.Handler
logger *slog.Logger
}
@@ -102,33 +104,190 @@ type courierCommand interface {
// Options
const (
DefaultPostmasterDeadline = 30 * time.Second
)
type PostmasterOption func(*postmasterConfig)
type postmasterConfig struct{}
type postmasterConfig struct {
defaultDeadline time.Duration
}
func WithDefaultDeadline(d time.Duration) PostmasterOption {
return func(c *postmasterConfig) { c.defaultDeadline = d }
}
type SendOption func(*sendConfig)
type sendConfig struct {
deadline time.Duration
}
func WithDeadline(d time.Duration) SendOption {
return func(c *sendConfig) { c.deadline = d }
}
// ----------------------------------------------------------------------------
// Postmaster
// ----------------------------------------------------------------------------
func NewPostmaster(
pool *Adapter,
send PoolSendFunc,
ctx context.Context,
poolHasPeer func(id string) (string, bool),
poolEvents <-chan PoolEvent,
poolSendFunc PoolSendFunc,
handler slog.Handler,
opts ...PostmasterOption,
) *Postmaster {
return nil
ctx, cancel := context.WithCancel(
component.MustNew(ctx, "prism", "postmaster"))
cfg := postmasterConfig{
defaultDeadline: DefaultPostmasterDeadline,
}
for _, opt := range opts {
opt(&cfg)
}
func (m *Postmaster) Send(
pm := &Postmaster{
couriers: make(map[string]*Courier),
poolHasPeer: poolHasPeer,
poolEvents: poolEvents,
poolSend: poolSendFunc,
ctx: ctx,
cancel: cancel,
cfg: cfg,
}
if handler != nil {
comp, ok := component.Get(ctx)
if ok {
pm.handler = handler
pm.logger = slog.New(handler).With(slog.Any("component", comp))
}
}
pm.wg.Add(1)
go pm.handlePoolEvents()
return pm
}
func (pm *Postmaster) Send(
ctx context.Context,
peerID string,
data Envelope,
deadline time.Duration,
onOutcome func(LetterOutcome), // should be non-blocking
) (LetterID, error) {
return 0, nil
onOutcome func(LetterOutcome),
opts ...SendOption,
) (context.CancelFunc, error) {
cfg := sendConfig{deadline: pm.cfg.defaultDeadline}
for _, opt := range opts {
opt(&cfg)
}
func (m *Postmaster) Close() {}
pm.mu.RLock()
defer pm.mu.RUnlock()
// check if peer courier exists
peerID, ok := pm.poolHasPeer(peerID)
if !ok {
return nil, fmt.Errorf("peer not found")
}
courier, ok := pm.couriers[peerID]
if !ok {
return nil, fmt.Errorf("peer not found")
}
ctx, cancel := context.WithTimeout(ctx, cfg.deadline)
letter := OutboundLetter{
id: pm.counter.Add(1),
peerID: peerID,
data: data,
ctx: ctx,
cancel: cancel,
}
courier.Enqueue(letter, onOutcome)
return cancel, nil
}
func (pm *Postmaster) Peers() []string {
pm.mu.RLock()
defer pm.mu.RUnlock()
peers := make([]string, 0, len(pm.couriers))
for id, _ := range pm.couriers {
peers = append(peers, id)
}
return peers
}
func (pm *Postmaster) Close() {
pm.cancel()
pm.wg.Wait()
// close each courier
pm.mu.Lock()
couriers := pm.couriers
pm.couriers = make(map[string]*Courier)
pm.mu.Unlock()
for _, courier := range couriers {
courier.Close()
}
}
func (pm *Postmaster) handlePoolEvents() {
defer pm.wg.Done()
for {
select {
case <-pm.ctx.Done():
return
case ev := <-pm.poolEvents:
switch ev.Kind {
case EventAdded:
pm.mu.Lock()
_, exists := pm.couriers[ev.ID]
if exists {
pm.mu.Unlock()
continue
}
send := func(data Envelope) error { return pm.poolSend(ev.ID, data) }
courier := NewCourier(pm.ctx, send, pm.handler)
pm.couriers[ev.ID] = courier
pm.mu.Unlock()
case EventRemoved:
pm.mu.Lock()
courier, exists := pm.couriers[ev.ID]
if exists {
delete(pm.couriers, ev.ID)
}
pm.mu.Unlock()
courier.Close()
case EventConnected:
pm.mu.RLock()
courier, exists := pm.couriers[ev.ID]
if exists {
courier.HandleConnect()
}
pm.mu.RUnlock()
case EventDisconnected:
pm.mu.RLock()
courier, exists := pm.couriers[ev.ID]
if exists {
courier.HandleDisconnect()
}
pm.mu.RUnlock()
}
}
}
}
// ----------------------------------------------------------------------------
// Courier
@@ -201,9 +360,18 @@ func (c *Courier) HandleDisconnect() {
}
func (c *Courier) Close() {
c.command(&cmdCloseCourier{})
c.cancel()
c.wg.Wait()
// cancel remaining letters
for {
t, ok := c.pop()
if !ok {
break
}
t.letter.cancel()
t.setMissedAt(time.Now())
c.doneOnce(t)
}
}
// Internal
@@ -211,7 +379,6 @@ func (c *Courier) Close() {
func (c *Courier) command(cmd courierCommand) {
select {
case <-c.ctx.Done():
fmt.Println("here")
case c.cmd <- cmd:
}
}
@@ -368,18 +535,3 @@ func (cmd cmdHandleSendResult) apply(c *Courier) {
c.doneOnce(cmd.traveller)
}
}
type cmdCloseCourier struct{}
func (cmd cmdCloseCourier) apply(c *Courier) {
// cancel remaining letters
for {
t, ok := c.pop()
if !ok {
break
}
t.letter.cancel()
t.setMissedAt(time.Now())
c.doneOnce(t)
}
}
+208
View File
@@ -0,0 +1,208 @@
package prism
import (
"context"
// "git.wisehodl.dev/jay/go-mana-component"
"github.com/stretchr/testify/assert"
// "sync/atomic"
"testing"
"time"
)
func TestPostmasterUnknownPeerSend(t *testing.T) {
ctx := context.Background()
poolHasPeer := func(id string) (string, bool) { return id, true }
poolEvents := make(chan PoolEvent, 4)
poolSendFunc := func(id string, data Envelope) error { return nil }
pm := NewPostmaster(ctx, poolHasPeer, poolEvents, poolSendFunc, nil)
_, err := pm.Send(ctx, "wss://test", []byte("[]"), func(LetterOutcome) {})
assert.Error(t, err)
}
func TestPostmasterSend(t *testing.T) {
ctx := context.Background()
poolHasPeer := func(id string) (string, bool) { return id, true }
poolEvents := make(chan PoolEvent, 4)
poolSendFunc := func(id string, data Envelope) error { return nil }
pm := NewPostmaster(ctx, poolHasPeer, poolEvents, poolSendFunc, nil)
poolEvents <- PoolEvent{
ID: "wss://test", Kind: EventAdded, At: time.Now()}
poolEvents <- PoolEvent{
ID: "wss://test", Kind: EventConnected, At: time.Now()}
Eventually(t, func() bool { return len(pm.Peers()) > 0 },
"should add peer")
called := make(chan LetterOutcome, 1)
_, err := pm.Send(
ctx, "wss://test", []byte("[]"), func(o LetterOutcome) { called <- o })
assert.NoError(t, err)
var outcome LetterOutcome
Eventually(t, func() bool {
select {
default:
return false
case outcome = <-called:
return true
}
}, "should have received outcome")
assert.Equal(t, OutcomeSent, outcome.Kind)
}
func TestPostmasterPeerRemoved(t *testing.T) {
ctx := context.Background()
poolHasPeer := func(id string) (string, bool) { return id, true }
poolEvents := make(chan PoolEvent, 4)
poolSendFunc := func(id string, data Envelope) error { return nil }
pm := NewPostmaster(ctx, poolHasPeer, poolEvents, poolSendFunc, nil)
// add peer, but do not connect
poolEvents <- PoolEvent{
ID: "wss://test", Kind: EventAdded, At: time.Now()}
Eventually(t, func() bool { return len(pm.Peers()) > 0 },
"should add peer")
// send two letters
outcomes := make([]LetterOutcome, 0, 2)
called := make(chan LetterOutcome, 2)
_, err := pm.Send(
ctx, "wss://test", []byte("[]"), func(o LetterOutcome) { called <- o })
assert.NoError(t, err)
_, err = pm.Send(
ctx, "wss://test", []byte("[]"), func(o LetterOutcome) { called <- o })
assert.NoError(t, err)
// wait for them to hit the courier queue
time.Sleep(100 * time.Millisecond)
// remove the peer
poolEvents <- PoolEvent{
ID: "wss://test", Kind: EventRemoved, At: time.Now()}
// expect each letter to return cancelled
Eventually(t, func() bool {
select {
default:
return false
case o := <-called:
outcomes = append(outcomes, o)
return len(outcomes) == 2
}
}, "should have returned 2 outcomes")
if len(outcomes) >= 2 {
assert.Equal(t, OutcomeCancelled, outcomes[0].Kind)
assert.Equal(t, OutcomeCancelled, outcomes[1].Kind)
}
// subsequent sends should fail
_, err = pm.Send(
ctx, "wss://test", []byte("[]"), func(o LetterOutcome) { called <- o })
assert.Error(t, err)
}
func TestPostmasterCourierCloseRace(t *testing.T) {
ctx := context.Background()
poolHasPeer := func(id string) (string, bool) { return id, true }
poolEvents := make(chan PoolEvent, 4)
poolSendFunc := func(id string, data Envelope) error { return nil }
pm := NewPostmaster(ctx, poolHasPeer, poolEvents, poolSendFunc, nil)
// add peer, but do not connect
poolEvents <- PoolEvent{
ID: "wss://test", Kind: EventAdded, At: time.Now()}
Eventually(t, func() bool { return len(pm.Peers()) > 0 },
"should add peer")
// remove the peer
poolEvents <- PoolEvent{
ID: "wss://test", Kind: EventRemoved, At: time.Now()}
// send a letter
time.Sleep(5 * time.Microsecond) // small wait lines up the race condition
var outcome LetterOutcome
called := make(chan LetterOutcome, 1)
_, err := pm.Send(
ctx, "wss://test", []byte("[]"), func(o LetterOutcome) { called <- o })
if err != nil {
// the close won the race, the letter was not sent
return
}
// the letter might beat the courier close and return cancelled
Eventually(t, func() bool {
select {
default:
return false
case outcome = <-called:
return true
}
}, "should have returned 1 outcomes")
if outcome.LetterID == 0 {
t.Fatal("did not receive an outcome")
}
assert.Equal(t, OutcomeCancelled, outcome.Kind)
}
func TestPostmasterClose(t *testing.T) {
ctx := context.Background()
poolHasPeer := func(id string) (string, bool) { return id, true }
poolEvents := make(chan PoolEvent, 4)
poolSendFunc := func(id string, data Envelope) error { return nil }
pm := NewPostmaster(ctx, poolHasPeer, poolEvents, poolSendFunc, nil)
// add peer, but do not connect
poolEvents <- PoolEvent{
ID: "wss://test", Kind: EventAdded, At: time.Now()}
Eventually(t, func() bool { return len(pm.Peers()) > 0 },
"should add peer")
// send two letters
outcomes := make([]LetterOutcome, 0, 2)
called := make(chan LetterOutcome, 2)
_, err := pm.Send(
ctx, "wss://test", []byte("[]"), func(o LetterOutcome) { called <- o })
assert.NoError(t, err)
_, err = pm.Send(
ctx, "wss://test", []byte("[]"), func(o LetterOutcome) { called <- o })
assert.NoError(t, err)
// wait for them to hit the courier queue
time.Sleep(100 * time.Millisecond)
// close postmaster
pm.Close()
// expect each letter to return cancelled
Eventually(t, func() bool {
select {
default:
return false
case o := <-called:
outcomes = append(outcomes, o)
return len(outcomes) == 2
}
}, "should have returned 2 outcomes")
if len(outcomes) >= 2 {
assert.Equal(t, OutcomeCancelled, outcomes[0].Kind)
assert.Equal(t, OutcomeCancelled, outcomes[1].Kind)
}
// subsequent sends should fail
_, err = pm.Send(
ctx, "wss://test", []byte("[]"), func(o LetterOutcome) { called <- o })
assert.Error(t, err)
}