package httpapi import ( "container/list" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "strconv" "sync" "time" "github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // SSE broker is a keep-last-event-id in-memory buffer used for subscriber // fan-out. The database NOTIFY is the primary delivery mechanism; this buffer // just supports the Last-Event-ID replay on connect. type sseBroker struct { mu sync.Mutex buf *list.List // list of sqlcgen.Event cache map[int64]*list.Element // id → list element for O(1) lookup cap int lastID int64 } func newSSEBroker(capacity int) *sseBroker { return &sseBroker{ buf: list.New(), cache: make(map[int64]*list.Element), cap: capacity, } } func (b *sseBroker) push(ev sqlcgen.Event) { b.mu.Lock() defer b.mu.Unlock() // Evict oldest if at capacity for b.buf.Len() >= b.cap && b.buf.Len() > 0 { front := b.buf.Front() b.cache[front.Value.(sqlcgen.Event).ID] = nil // don't delete, just nil b.buf.Remove(front) } elem := b.buf.PushBack(ev) b.cache[ev.ID] = elem if ev.ID > b.lastID { b.lastID = ev.ID } } func (b *sseBroker) after(id int64) []sqlcgen.Event { b.mu.Lock() defer b.mu.Unlock() if id >= b.lastID { return nil } // Walk from the front to find the first element after id var events []sqlcgen.Event for e := b.buf.Front(); e != nil; e = e.Next() { ev := e.Value.(sqlcgen.Event) if ev.ID > id { events = append(events, ev) } } return events } func (b *sseBroker) latestID() int64 { b.mu.Lock() defer b.mu.Unlock() return b.lastID } // notifyPayload is the JSON payload from the pg_notify trigger (migration 008). type notifyPayload struct { ID int64 `json:"id"` Ts string `json:"ts"` Type string `json:"type"` EntityID *string `json:"entity_id"` Severity string `json:"severity"` Source string `json:"source"` CorrelationID *string `json:"correlation_id"` } // sseSubscriber holds the channels and cancel func for one SSE client. type sseSubscriber struct { ch chan sqlcgen.Event done chan struct{} cancel context.CancelFunc } // sseListener runs in a background goroutine: it opens a dedicated pgx // connection, LISTENs on oikos_events, and fans out each notification to // all live subscribers. Runs until ctx is cancelled. func (s *Server) sseListener(ctx context.Context) { poolConn, err := s.pool.Acquire(ctx) if err != nil { slog.Error("sse listener acquire failed", "error", err) return } defer poolConn.Release() conn := poolConn.Conn() if _, err := conn.Exec(ctx, "LISTEN oikos_events"); err != nil { slog.Error("sse listener listen failed", "error", err) return } slog.Info("sse listener started on oikos_events") defer slog.Info("sse listener stopped") for { nt, err := conn.WaitForNotification(ctx) if err != nil { if ctx.Err() != nil { return // normal shutdown } slog.Error("sse listener notification error", "error", err) // Reconnect on error after a brief delay select { case <-time.After(5 * time.Second): case <-ctx.Done(): return } // Re-acquire connection poolConn.Release() var reconnErr error poolConn, reconnErr = s.pool.Acquire(ctx) if reconnErr != nil { slog.Error("sse listener reconnect failed", "error", reconnErr) return } conn = poolConn.Conn() if _, err := conn.Exec(ctx, "LISTEN oikos_events"); err != nil { slog.Error("sse listener re-listen failed", "error", err) return } continue } var p notifyPayload if err := json.Unmarshal([]byte(nt.Payload), &p); err != nil { slog.Error("sse listener unmarshal failed", "error", err) continue } // Fetch full event from DB q := sqlcgen.New(s.pool) events, err := q.ListEventsAfter(ctx, sqlcgen.ListEventsAfterParams{ ID: p.ID - 1, Limit: 1, }) if err != nil || len(events) == 0 { slog.Warn("sse listener event fetch failed", "id", p.ID, "error", err) continue } ev := events[0] // Push to broker s.sseBroker.push(ev) // Fan out to subscribers (non-blocking send) s.sseMu.Lock() for sub := range s.sseSubs { select { case sub.ch <- ev: default: // Subscriber too slow — drop event for them // (they'll reconnect via Last-Event-ID) } } s.sseMu.Unlock() } } // sqlcEventToGen converts a DB event row to the canonical wire shape so the // SSE `data:` payload matches GET /events (snake_case keys, decoded data // object) rather than leaking Go field names and base64-encoded JSONB. func sqlcEventToGen(ev sqlcgen.Event) gen.Event { out := gen.Event{ Id: int(ev.ID), Ts: ev.Ts, Type: ev.Type, Severity: gen.EventSeverity(ev.Severity), Source: ev.Source, CorrelationId: ev.CorrelationID, } if ev.EntityID != nil { s := ev.EntityID.String() out.EntityId = &s } if len(ev.Data) > 0 { var data map[string]any if json.Unmarshal(ev.Data, &data) == nil && len(data) > 0 { out.Data = &data } } return out } // writeSSE writes a single Event as an SSE message. Returns false if the // write failed (client disconnected). flusher may be nil (io.Pipe path, // which has no separate flush step). func writeSSE(w ioWriter, flusher http.Flusher, ev sqlcgen.Event) bool { data, err := json.Marshal(sqlcEventToGen(ev)) if err != nil { return true // skip un-serializable events } _, err = fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, data) if err != nil { return false } if flusher != nil { flusher.Flush() } return true } // ioWriter is an interface satisfied by both http.ResponseWriter and // io.StringWriter, letting writeSSE work with the writer directly. type ioWriter interface { Write([]byte) (int, error) } // StreamEvents implements the OpenAPI interface (SSE event stream). // // The strict-server model hands us only an io.Reader Body (copied to the // client via io.Copy in the generated Visit method), not the raw // ResponseWriter/Flusher — so we bridge with an io.Pipe. ctx is the request // context (the generated wrapper passes r.Context()); when the client // disconnects it cancels, the SSE loop returns, and pw.Close() ends the // io.Copy. Without threading ctx through, each disconnected client would // leak a goroutine holding a pipe. // // Note: io.Copy does not flush per-write, so delivery is chunk-buffered // rather than strictly real-time. Adequate for the event feed; a raw // flushing handler is a possible future refinement. func (s *Server) StreamEvents(ctx context.Context, req gen.StreamEventsRequestObject) (gen.StreamEventsResponseObject, error) { pr, pw := io.Pipe() go func() { s.serveSSEWriter(ctx, pw, req.Params) pw.Close() }() return gen.StreamEvents200TexteventStreamResponse{ Body: pr, ContentLength: 0, // unknown/streaming — omit the Content-Length header }, nil } // serveSSEWriter runs the SSE loop writing to an io.Writer until the request // context is cancelled (client disconnect / server shutdown). func (s *Server) serveSSEWriter(parent context.Context, w io.Writer, params gen.StreamEventsParams) { ctx, cancel := context.WithCancel(parent) defer cancel() sub := &sseSubscriber{ ch: make(chan sqlcgen.Event, 64), done: make(chan struct{}), cancel: cancel, } s.sseMu.Lock() s.sseSubs[sub] = struct{}{} s.sseMu.Unlock() defer func() { s.sseMu.Lock() delete(s.sseSubs, sub) s.sseMu.Unlock() close(sub.done) }() // Replay on Last-Event-ID if params.LastEventID != nil && *params.LastEventID != "" { id, err := strconv.ParseInt(*params.LastEventID, 10, 64) if err == nil { events := s.sseBroker.after(id) if len(events) > 0 { for _, ev := range events { if !writeSSE(w, nil, ev) { return } } } // If broker didn't have them all, fetch from DB if len(events) == 0 || events[len(events)-1].ID != s.sseBroker.latestID() { q := sqlcgen.New(s.pool) dbEvents, err := q.ListEventsAfter(ctx, sqlcgen.ListEventsAfterParams{ID: id, Limit: 5000}) if err != nil { slog.Error("sse db replay failed", "error", err) } else { for _, ev := range dbEvents { if !writeSSE(w, nil, ev) { return } } } } } } // Subscribe and forward heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() for { select { case <-ctx.Done(): return case ev, ok := <-sub.ch: if !ok { return } if !writeSSE(w, nil, ev) { return } case <-heartbeat.C: _, err := fmt.Fprintf(w, ": heartbeat\n\n") if err != nil { return } } } } // Ensure pgxpool is imported — used via Acquire. var _ = &pgxpool.Pool{} var _ = pgx.ErrNoRows