agent-orchestrator/backend/internal/cdc/broadcast.go

67 lines
1.8 KiB
Go

package cdc
import (
"log/slog"
"sync"
)
// Broadcaster is the in-process fan-out the poller feeds. Subscribers such as
// terminal session-state fan-out register a callback; every polled Event is
// delivered to all current subscribers. It is the single seam between the CDC
// poller and live delivery, so transports can be built and swapped without
// touching the poller.
type Broadcaster struct {
mu sync.RWMutex
nextID int
subs map[int]func(Event)
logger *slog.Logger
}
// NewBroadcaster returns an empty Broadcaster ready for subscriptions.
func NewBroadcaster() *Broadcaster {
return &Broadcaster{subs: map[int]func(Event){}, logger: slog.Default()}
}
// Subscribe registers fn and returns an unsubscribe function. fn is called
// synchronously from the poller loop, so it must not block; a transport that
// needs buffering should push onto its own channel inside fn.
func (b *Broadcaster) Subscribe(fn func(Event)) (unsubscribe func()) {
b.mu.Lock()
id := b.nextID
b.nextID++
b.subs[id] = fn
b.mu.Unlock()
return func() {
b.mu.Lock()
delete(b.subs, id)
b.mu.Unlock()
}
}
// SubscriberCount reports the number of current subscribers.
func (b *Broadcaster) SubscriberCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subs)
}
// Publish delivers e to every current subscriber. A panicking subscriber is
// recovered and logged so one bad callback can't kill the poller goroutine or
// starve the other subscribers.
func (b *Broadcaster) Publish(e Event) {
b.mu.RLock()
defer b.mu.RUnlock()
for _, fn := range b.subs {
b.deliver(fn, e)
}
}
func (b *Broadcaster) deliver(fn func(Event), e Event) {
defer func() {
if r := recover(); r != nil {
b.logger.Error("cdc broadcaster: subscriber panicked", "seq", e.Seq, "panic", r)
}
}()
fn(e)
}