stream

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 19, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

Documentation

Overview

Package stream holds the SSE fan-out primitives shared by the streaming API: the Subscriber connection handle (subscriber.go), the Bucket fan-out primitive (bucket.go), the Heartbeater keepalive wheel (heartbeat.go) that nudges idle GET /v1/stream connections, and the event Hub (hub.go).

The Hub is the #294 delivery hot path: subscribers register under (topic, role), and each event is projected — column-filtered per the role's policy — and serialized ONCE per role, then pushed through the same Subscriber queue the keepalive wheel uses, so the handler drains both from a single byte-pump. That collapses the prior per-subscriber unmarshal/project/re-serialize into one pass per distinct (role, table) output shape. The one per-subscriber decision left is row-level security (#319): a role whose policy carries a row-filter shares the column projection but delivers each event only to the subscribers whose JWT claims admit the row.

Index

Constants

View Source
const (
	KindKeepalive = "keepalive" // wheel keepalive comment
	KindEvent     = "event"     // live event off the hub
	KindReplay    = "replay"    // historical event from gap-fill on (re)connect
)

Frame kinds for the "kind" attribute on the frame and byte counters.

Variables

This section is empty.

Functions

func NumericSpecOf

func NumericSpecOf(st discovery.NumericStorage) policy.NumericSpec

NumericSpecOf renders discovery's storage classification as the policy evaluator's storage model. Exported so the tests/integration differential oracle builds specs through the very mapping production uses — one source, so the oracle can't keep validating a mapping the Hub no longer applies.

Types

type Bucket

type Bucket interface {
	Add(sub *Subscriber)
	Remove(sub *Subscriber)
	Len() int
	Push(f Frame)
	Snapshot() []*Subscriber
}

Bucket is a concurrency-safe set of subscribers. Push fans one Frame out to every member fire-and-forget (the keepalive wheel and the Hub's no-row-filter fast path; Send itself counts any queue-full drop); Snapshot exposes the members so the event Hub can evaluate row visibility per subscriber before sending (and, later, evict).

type Frame

type Frame struct {
	Kind string // a Kind* constant: keepalive, event, or replay
	Data []byte // the exact bytes written to the client
}

Frame is one ready-to-write SSE byte frame tagged with its kind, so the handler can label the write (frames_sent_total{kind=…}) at the moment it happens — a frame dropped by a full queue is never counted as sent. Producers (the keepalive wheel, the event Hub) fan frames in with Send; the handler writes Data verbatim.

type Heartbeater

type Heartbeater struct {
	// contains filtered or unexported fields
}

Heartbeater is a timing wheel that keepalives every live subscriber from one timer: subscribers spread across a ring of buckets, one bucket fired per tick, so each is nudged once per period while only ~1/buckets are written per tick — spreading the load instead of writing every connection at the same instant.

func NewHeartbeater

func NewHeartbeater(period time.Duration, buckets int) *Heartbeater

NewHeartbeater builds the keepalive wheel. period is the effective per-connection interval (stream.keepalive_interval); buckets spreads that work across it, so the per-tick interval is period/buckets and one rotation spans the period. Non-positive inputs fall back to the package defaults.

func (*Heartbeater) Add

func (hb *Heartbeater) Add(sub *Subscriber)

Add registers sub in the bucket that fires last (just behind the hand), giving it a full period before its first keepalive.

func (*Heartbeater) Len

func (hb *Heartbeater) Len() int

Len reports the live subscriber count across the ring (for tests and metrics).

func (*Heartbeater) Remove

func (hb *Heartbeater) Remove(sub *Subscriber)

Remove deregisters sub from its bucket. A no-op if it was never added, so the handler's deferred Remove is always safe.

func (*Heartbeater) Run

func (hb *Heartbeater) Run(ctx context.Context)

Run drives the wheel until ctx is cancelled. Run it in its own goroutine for the lifetime of the server.

type Hub

type Hub struct {
	// contains filtered or unexported fields
}

Hub fans live events out to SSE subscribers. Column projection is serialized ONCE per (topic, role) instead of once per subscriber — the #294/#353 lever — because column visibility depends solely on the role+table policy entry, never on JWT claims, so the projected frame is identical for every subscriber of a role.

Row-level security is the exception: a role's row-filter (RLS predicate) is resolved against each subscriber's claims, so two subscribers of the same role can be entitled to different rows. For a role that carries a row-filter, Broadcast therefore keeps the shared column projection but evaluates row visibility PER subscriber (ResolvedPermissions.RowVisible) before delivering — closing the query/stream RLS drift in #319. Roles without a row-filter keep the pure once-per-role fast path unchanged. See projectColumns.

func NewHub

func NewHub(policyStore *policy.Store, registry *discovery.SchemaRegistry, metric *Metrics) *Hub

NewHub builds an event hub. A nil policy store passes every event through unfiltered (the unwired-tests case); a non-nil store whose Get returns nil is a total lockout (a deleted/absent policy denies everyone). A nil registry leaves every column's type unknown, so row-filter comparison degrades FAIL-CLOSED: equality/set predicates admit only a byte-identical value and ordering/!= admit nothing (see policy.ColumnKind); metric may be nil.

func (*Hub) Add

func (h *Hub) Add(topic, role string, sub *Subscriber)

Add registers sub to receive events for (topic, role), creating the role bucket (and topic) on first use.

func (*Hub) Broadcast

func (h *Hub) Broadcast(topic string, raw []byte)

Broadcast projects raw — a published EventMessage JSON delivered on topic — and fans the finished SSE frame to each subscribed role's bucket. The column projection (decode, evaluate, marshal) happens once per distinct role. For a role that carries a row-level-security filter, that shared frame is still delivered only to the subscribers whose claims admit this row (evaluated per subscriber); a role without a filter takes the pure once-per-role fast path.

func (*Hub) Len

func (h *Hub) Len(topic string) int

Len reports the live subscriber count across one topic (for tests and metrics).

func (*Hub) Remove

func (h *Hub) Remove(topic, role string, sub *Subscriber)

Remove deregisters sub from (topic, role), garbage-collecting the bucket and the topic once empty. A no-op if the registration is already gone, so the handler's deferred Remove is always safe.

func (*Hub) ReplayProjector

func (h *Hub) ReplayProjector(role string, claims map[string]any) func(raw []byte) (Frame, bool)

ReplayProjector returns the projection function for one connection's gap-fill: each call projects a single replayed event for the connection's role+claims into a ready-to-write replay frame, or ok=false to skip it (denied table, invalid payload, or a row the claims aren't entitled to see). It is a Hub method so replay shares the Hub's policy store and schema registry with the live fan-out — the handler can't accidentally project replay against a different (or nil) policy. Replay is already per-connection, so row-level security evaluates against this connection's claims directly; the returned closure holds one policy snapshot for the whole gap-fill (matching Broadcast's one-snapshot-per-event — a reload landing mid-replay applies from the first live event) and caches the per-table column-kind lookup across the replay loop, so a large Last-Event-ID gap-fill doesn't pay a store read-lock plus a registry lookup and map build per event. The closure is for a single goroutine — each connection makes its own. The live path uses Broadcast.

type Metrics

type Metrics struct {
	// contains filtered or unexported fields
}

Metrics records SSE stream activity: how many streams are open, how long they last, and how much is written out. The zero value (a nil *Metrics) is a no-op, so the handler can hold one unconditionally and tests can skip wiring it.

func NewMetrics

func NewMetrics() *Metrics

NewMetrics builds the SSE instruments on the global meter provider. Call it after observability.InitProvider, otherwise the instruments bind to the no-op default provider for the process lifetime.

func (*Metrics) ConnClosed

func (m *Metrics) ConnClosed(d time.Duration)

ConnClosed records a stream closing and its total lifetime.

func (*Metrics) ConnOpened

func (m *Metrics) ConnOpened()

ConnOpened records a newly established stream.

func (*Metrics) FrameDropped

func (m *Metrics) FrameDropped(kind string)

FrameDropped records one frame dropped because a subscriber's queue was full — the slow-consumer signal that was silent before #294. kind is a Kind* constant.

func (*Metrics) FrameSent

func (m *Metrics) FrameSent(kind string, n int)

FrameSent records one frame of the given kind (a Kind* constant) and its size in bytes.

func (*Metrics) RowWithheld

func (m *Metrics) RowWithheld(table, role string)

RowWithheld records one event row withheld from one subscriber (live or replay) by the role's row-level-security filter, including fail-closed evaluations. Labeled by table and role (policy-bounded, not data-bounded) so an operator can tell "no matching rows" from "a misconfigured filter withholding everything".

type Subscriber

type Subscriber struct {
	// contains filtered or unexported fields
}

Subscriber is one SSE connection's outbound side: a queue of ready-to-write frames the HTTP handler drains to the client. Producers fan frames in via Send.

func NewSubscriber

func NewSubscriber(claims map[string]any, m *Metrics) *Subscriber

NewSubscriber returns a Subscriber ready to register with a Heartbeater and the event Hub, carrying the connection's JWT claims (nil for a tokenless caller), deep-copied — see the claims field for the snapshot rationale — and the shared stream metrics (nil-safe) that Send counts drops on.

func (*Subscriber) Evicted

func (s *Subscriber) Evicted() <-chan struct{}

Evicted is closed when the subscriber has been marked for disconnection. The handler selects on it to tear the connection down. Inert until the slow-consumer follow-up wires the threshold that closes it.

func (*Subscriber) Frames

func (s *Subscriber) Frames() <-chan Frame

Frames is the queue of ready-to-write frames; the handler writes whatever arrives here to the client verbatim.

func (*Subscriber) Send

func (s *Subscriber) Send(f Frame) bool

Send enqueues one frame without blocking, returning false if the queue is full (a slow consumer). The drop is counted here, labeled by the frame's kind, so no producer can forget to; callers may ignore the result (a keepalive that drops coalesces harmlessly — the full queue keeps the stream alive anyway).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL