0.1.38 gateway
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
// Package adminevents is the gateway's administrative event bus: one place a backend
|
||||
// service says "this happened" and every reader — the console's notification bell, an
|
||||
// outgoing Discord webhook, anything added later — hears about it without the publisher
|
||||
// knowing they exist.
|
||||
//
|
||||
// The point of it being generic is that authentication code must not know about Discord.
|
||||
// handleLogin publishes a sign-in; it does not format a webhook payload, decide whether
|
||||
// the operator wants one, or handle the failure when the webhook is down. That separation
|
||||
// is what lets a second integration be a file that subscribes rather than an edit to
|
||||
// every publisher.
|
||||
package adminevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// Event is one administrative occurrence. It is store.AdminEvent so that what is
|
||||
// broadcast is exactly what was stored, ids and timestamps included — a subscriber shown
|
||||
// a different shape from the one the console later reads back is a subscriber whose view
|
||||
// can disagree with the feed.
|
||||
type Event = store.AdminEvent
|
||||
|
||||
// Event types. These are routing keys: an integration selects the ones it wants and the
|
||||
// console groups by them, so they are stable strings rather than an enum somebody would
|
||||
// be tempted to renumber.
|
||||
//
|
||||
// Adding one is a constant here and a call to Publish. Nothing has to be taught about it:
|
||||
// the console's type filter is built from what has actually been published, and an
|
||||
// integration configured to send "everything" sends it.
|
||||
const (
|
||||
TypeLogin = "auth.login"
|
||||
TypeLoginFailed = "auth.login_failed"
|
||||
TypeLogout = "auth.logout"
|
||||
TypeDeviceRegistered = "device.registered"
|
||||
TypeDeviceRemoved = "device.removed"
|
||||
TypeDeviceRenamed = "device.renamed"
|
||||
TypeAdminSignIn = "admin.sign_in"
|
||||
TypeServerStarted = "server.started"
|
||||
TypeMaintenanceChanged = "server.maintenance"
|
||||
TypeTaskCompleted = "task.completed"
|
||||
TypeTaskFailed = "task.failed"
|
||||
TypeIntegrationFailed = "integration.failed"
|
||||
TypeLibrarySync = "library.sync"
|
||||
TypeEmbyUnreachable = "emby.unreachable"
|
||||
TypeEmbyRecovered = "emby.recovered"
|
||||
)
|
||||
|
||||
// Severity re-exports the store's vocabulary so a publisher needs only this package.
|
||||
const (
|
||||
SeverityInfo = store.SeverityInfo
|
||||
SeverityWarning = store.SeverityWarning
|
||||
SeverityError = store.SeverityError
|
||||
)
|
||||
|
||||
// Sink is a synchronous reader — an integration dispatcher, typically. Sinks are called
|
||||
// on the bus's own goroutine after the event is persisted, and are expected to return
|
||||
// promptly: anything that talks to a network does its own queueing behind this.
|
||||
type Sink interface {
|
||||
DeliverAdminEvent(ctx context.Context, event Event)
|
||||
}
|
||||
|
||||
// subscriberBuffer is how far behind a live reader may fall before it starts losing
|
||||
// events. A browser tab on a slow connection must not be able to hold up a sign-in, so
|
||||
// the drop is deliberate — and it is recoverable, because the console's stream reconnects
|
||||
// by asking for everything after the last id it saw.
|
||||
const subscriberBuffer = 64
|
||||
|
||||
type subscriber struct {
|
||||
events chan Event
|
||||
}
|
||||
|
||||
// Bus is the publisher. It is safe for concurrent use and every one of its operations is
|
||||
// best-effort: an event that cannot be stored is logged and dropped, never returned as an
|
||||
// error to the thing that was merely reporting it.
|
||||
type Bus struct {
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
subscribers map[*subscriber]struct{}
|
||||
sinks []Sink
|
||||
}
|
||||
|
||||
func New(st *store.Store, log *slog.Logger) *Bus {
|
||||
return &Bus{store: st, log: log, subscribers: map[*subscriber]struct{}{}}
|
||||
}
|
||||
|
||||
// AddSink registers a reader that wants every event. Called during start-up wiring,
|
||||
// before anything can publish.
|
||||
func (b *Bus) AddSink(sink Sink) {
|
||||
if b == nil || sink == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.sinks = append(b.sinks, sink)
|
||||
}
|
||||
|
||||
// Publish stores the event and hands it to every reader.
|
||||
//
|
||||
// The context is detached from the caller's before the write. An event is a *record of
|
||||
// something that already happened*, and the commonest publisher is a request handler —
|
||||
// so hanging the write off the request context would lose exactly the events belonging to
|
||||
// a client that disconnected, which are frequently the interesting ones. The deadline
|
||||
// below is what stops that detachment turning into an unbounded write.
|
||||
func (b *Bus) Publish(ctx context.Context, event Event) Event {
|
||||
if b == nil {
|
||||
return event
|
||||
}
|
||||
if event.Severity == "" {
|
||||
event.Severity = SeverityInfo
|
||||
}
|
||||
if b.store != nil {
|
||||
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
stored, err := b.store.RecordAdminEvent(writeCtx, event)
|
||||
cancel()
|
||||
if err != nil {
|
||||
b.log.Warn("admin event not recorded", "type", event.Type, "error", err)
|
||||
// Still broadcast it. A bell that goes quiet because Postgres hiccuped is
|
||||
// worse than one showing an event that will not survive a refresh.
|
||||
if event.OccurredAt.IsZero() {
|
||||
event.OccurredAt = time.Now()
|
||||
}
|
||||
} else {
|
||||
event = stored
|
||||
}
|
||||
} else if event.OccurredAt.IsZero() {
|
||||
event.OccurredAt = time.Now()
|
||||
}
|
||||
|
||||
b.mu.RLock()
|
||||
sinks := b.sinks
|
||||
for sub := range b.subscribers {
|
||||
select {
|
||||
case sub.events <- event:
|
||||
default:
|
||||
// Behind. See subscriberBuffer: the reader recovers by id on reconnect.
|
||||
}
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
|
||||
for _, sink := range sinks {
|
||||
sink.DeliverAdminEvent(context.WithoutCancel(ctx), event)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
// Subscribe opens a live feed. The returned function must be called when the reader goes
|
||||
// away, or the bus keeps writing into a channel nobody reads and the subscriber leaks for
|
||||
// the life of the process.
|
||||
func (b *Bus) Subscribe() (<-chan Event, func()) {
|
||||
if b == nil {
|
||||
closed := make(chan Event)
|
||||
close(closed)
|
||||
return closed, func() {}
|
||||
}
|
||||
sub := &subscriber{events: make(chan Event, subscriberBuffer)}
|
||||
b.mu.Lock()
|
||||
b.subscribers[sub] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
return sub.events, func() {
|
||||
b.mu.Lock()
|
||||
delete(b.subscribers, sub)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribers reports how many live readers are attached. The console shows it, which is
|
||||
// the cheapest way to tell "the stream is not delivering" from "nothing has happened".
|
||||
func (b *Bus) Subscribers() int {
|
||||
if b == nil {
|
||||
return 0
|
||||
}
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.subscribers)
|
||||
}
|
||||
|
||||
// Meta builds a metadata document, dropping anything that will not encode rather than
|
||||
// failing the publish. Metadata is by definition the part no reader depends on.
|
||||
func Meta(pairs map[string]any) json.RawMessage {
|
||||
if len(pairs) == 0 {
|
||||
return nil
|
||||
}
|
||||
encoded, err := json.Marshal(pairs)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
Reference in New Issue
Block a user