Files
minstrel/internal/eventbus/eventbus.go
T
bvandeusenandClaude Opus 5.5 466912e202
release / govulncheck (push) Successful in 25s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m47s
release / integration (push) Successful in 4m35s
release / android (push) Successful in 7m5s
release / Build signed APK (releases and dev) (push) Successful in 7m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m1s
release / Verify release artifacts (tag releases only) (push) Skipped
docs: plan-era "v1", "Task N" and "Phase N" wording restated as facts (#6277)
The marker list behind the earlier server and web passes did not catch
"for v1", "Task 5", "Phase 2" or "today: Flutter". Those comments now say
what the code does.

Corrected: coverart said "both v1 providers" ship on. There are four
providers (MBCAA, TheAudioDB, Deezer, Last.fm), and Last.fm, which needs a
key, ships off.

Non-comment tokens unchanged against HEAD: 10 Go files, 7 TS/Svelte files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:05:03 -04:00

121 lines
3.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package eventbus is an in-process publish/subscribe bus for real-time
// events the server emits to subscribed clients (the web and Android apps,
// over SSE).
//
// Writers (likes handler, requests reconciler, scanner, etc.) call Publish
// to announce a state change. Each connected SSE client holds a
// subscription via Subscribe and forwards events to its HTTP response.
//
// The bus is intentionally simple:
//
// - Per-subscriber bounded channel buffer. If a subscriber's buffer is
// full when an event arrives, the event is dropped FOR THAT SUBSCRIBER
// ONLY — writers never block, and other subscribers are unaffected.
// - No persistence. A client that connects after an event was emitted
// will not see that event. SSE clients are expected to fetch a fresh
// snapshot via the normal /api/* endpoints on (re)connect.
// - No cross-process broadcast. Minstrel runs as a single Go binary; a
// multi-instance deployment would need a real broker (NATS / Redis
// pub-sub / etc.).
package eventbus
import (
"sync"
)
// Event is a single broadcast message. Kind names follow a "domain.action"
// convention (e.g. "track.liked", "request.status_changed"). UserID, when
// non-empty, scopes the event to a specific user — subscribers receive
// only events with UserID == their auth user OR with empty UserID (which
// means "broadcast to everyone subscribed").
type Event struct {
Kind string `json:"kind"`
UserID string `json:"user_id,omitempty"`
Data map[string]any `json:"data"`
}
// Bus is a fan-out broadcaster. Safe for concurrent use; the zero value is
// not usable — call New().
type Bus struct {
mu sync.RWMutex
subscribers map[chan Event]struct{}
closed bool
}
// New returns a Bus with no subscribers.
func New() *Bus {
return &Bus{subscribers: map[chan Event]struct{}{}}
}
// Publish fans an event out to every subscriber. Non-blocking per
// subscriber: if a subscriber's buffer is full, the event is dropped for
// that subscriber rather than stalling the writer.
func (b *Bus) Publish(e Event) {
b.mu.RLock()
defer b.mu.RUnlock()
for ch := range b.subscribers {
select {
case ch <- e:
default:
// Subscriber buffer full — drop. The subscriber will resync
// via a normal API fetch when the client decides to.
}
}
}
// Subscribe registers a new subscriber and returns its receive channel
// plus an unsubscribe func. The buffer argument controls how many events
// can be queued before drops occur; 16–64 is a reasonable range for an
// SSE client that processes events promptly.
//
// Callers MUST invoke the returned unsubscribe func when done (typically
// in a defer), or the subscription leaks.
func (b *Bus) Subscribe(buffer int) (<-chan Event, func()) {
if buffer <= 0 {
buffer = 16
}
ch := make(chan Event, buffer)
b.mu.Lock()
if b.closed {
// A subscriber arriving during shutdown ends at once, like the
// ones Close already ended.
b.mu.Unlock()
close(ch)
return ch, func() {}
}
b.subscribers[ch] = struct{}{}
b.mu.Unlock()
unsub := func() {
b.mu.Lock()
if _, ok := b.subscribers[ch]; ok {
delete(b.subscribers, ch)
close(ch)
}
b.mu.Unlock()
}
return ch, unsub
}
// Close ends every subscription by closing its channel, and any later
// Subscribe gets a channel that is already closed. The server calls it as
// shutdown begins: an SSE stream otherwise runs until its client leaves,
// and holds the graceful shutdown open for its whole deadline. Publish
// after Close is a no-op. Safe to call more than once.
func (b *Bus) Close() {
b.mu.Lock()
defer b.mu.Unlock()
b.closed = true
for ch := range b.subscribers {
delete(b.subscribers, ch)
close(ch)
}
}
// SubscriberCount returns the current number of active subscribers. For
// tests + observability; not load-bearing in hot paths.
func (b *Bus) SubscriberCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subscribers)
}