Files
silo-server/internal/streammonitor/monitor.go
T
CoffeeKnyte fc65dcaf16 fix(playback): make session liveness server-observed
Client progress reports could keep a zero-byte phantom session alive and,
worse, make it outrank a genuinely-serving stream when the enforcer picked
over-cap victims.

`streammonitor.LiveLocalSessions` substituted `Session.LastActivityAt` for a
zero `LastServedAt`, and `LastActivityAt` is advanced by `UpdateProgress` and
by the realtime WebSocket hello/ack/result handlers. Since
`streamenforcer.selectVictims` keeps the `limit` most-recently-served streams,
a progress-only phantom sorted ahead of a real stream and the real one was
trimmed instead. Reaping had the same root cause: `sessionIsInactiveLocked`
keyed idleness on `LastActivityAt`, so a client that kept pinging held a
session open forever.

Per decision A5 (Option C), client progress is now UI metadata only and never
feeds enforcement or reaping:

- `LiveLocalSessions` projects `LastServedAt` verbatim, emitting an empty
  timestamp when the session has never served, so it sorts as the stalest
  over-cap victim.
- `sessionIsInactiveLocked` measures idleness from `LastServedAt`, falling back
  only to `StartedAt`.
- A configurable never-served window (`DefaultUnservedSessionGrace`, 2m, via
  `SetUnservedSessionGrace`) keeps a legitimately slow start from being reaped
  before its first byte, without granting a phantom unbounded life. It is a
  separate knob rather than a hardcoded floor so it cannot silently override
  `SetLivenessGracePeriods`.

In-flight transports remain exempt, so direct-play and remux long pours and
per-segment HLS serves are unaffected.

Paused sessions with an open realtime/WebSocket connection are exempt from
reaping. That preserves the issue #243 fix (reaping a paused transcode froze
clients) while staying within Option C: an open, ping-checked connection is
server-observed, unlike a client's reported progress, and the session still
consumes one of the user's cap slots.

Part 1 of 3 for the Batch 4 liveness/replica work.

Part of #305
2026-07-31 00:03:44 +00:00

439 lines
14 KiB
Go

// Package streammonitor produces a normalized "live streams" snapshot grouped by
// user from the authoritative server-side monitoring records written by
// internal/nodesessions. It backs an async enforcement loop and admin views.
//
// EXISTENCE is server-observed on every path and never gated by a client report,
// so a "hidden stream" (a disguised client that pulls bytes but withholds
// progress) is always counted:
// - Edge (multi-node): a record exists in Redis for the whole connection
// (direct/remux Track..Remove) or while segments are pulled (transcode Touch),
// and BytesServed/LastServedAt advance only on real bytes.
// - Integrated: the FuncSource reflects SessionManager.AllSessions(), and a
// session is unreapable while it holds an in-flight transport marker
// (BeginTransport/EndTransport around every byte pour) — no client progress
// required to stay visible.
//
// TIMING is server-observed on every path. LastServedAt advances only when the
// server begins, ends, or writes a media transport. A session that has not
// served media projects an empty LastServedAt and sorts as the stalest over-cap
// victim. See internal/nodesessions for record production.
package streammonitor
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
"github.com/Silo-Server/silo-server/internal/nodesessions"
"github.com/Silo-Server/silo-server/internal/playback"
)
// sessionKeyPrefix is the Redis key prefix under which nodesessions.Tracker
// stores its SessionInfo records — the tracker's own exported constant, so the
// write and read sides can never drift.
const sessionKeyPrefix = nodesessions.KeyPrefix
// scanCount is the COUNT hint passed to SCAN. It bounds the amount of work per
// round trip while keeping the number of round trips reasonable.
const scanCount = 256
// LiveStream is a normalized view of a single active streaming session.
type LiveStream struct {
SessionID string
LogicalSessionID string
UserID int // from SessionInfo.AuthUserID
ProfileID string
NodeName string
NodeURL string
Type string // play method: direct_play | remux | transcode
Route string // origin protocol: native | jellycompat
MediaFileID int
ClientIP string
ClientName string
Position float64 // last known playback position (seconds); secondary timing
HWAccel string
LastServedAt time.Time // parsed from SessionInfo.LastServedAt (zero if absent)
BytesServed int64
StartedAt time.Time // parsed from SessionInfo.StartedAt (zero if unparseable)
}
// Snapshot is a point-in-time picture of the live streams.
type Snapshot struct {
Streams []LiveStream
}
// CountByUser returns the number of live streams owned by userID.
func (s Snapshot) CountByUser(userID int) int {
n := 0
for _, st := range s.Streams {
if st.UserID == userID {
n++
}
}
return n
}
// StreamsForUser returns the live streams owned by userID.
func (s Snapshot) StreamsForUser(userID int) []LiveStream {
out := make([]LiveStream, 0)
for _, st := range s.Streams {
if st.UserID == userID {
out = append(out, st)
}
}
return out
}
// ByUser groups the live streams by owning user id.
func (s Snapshot) ByUser() map[int][]LiveStream {
out := make(map[int][]LiveStream)
for _, st := range s.Streams {
out[st.UserID] = append(out[st.UserID], st)
}
return out
}
// Source yields the current live picture.
type Source interface {
Snapshot(ctx context.Context) (Snapshot, error)
}
// MultiSource unions several sources into one snapshot, de-duplicating sessions
// that appear in more than one backend (e.g. a session held both in the central
// session manager and mirrored by an edge's Redis record) by keeping the most-
// recently-served copy. A source that errors is skipped (logged), so one
// unavailable backend never blinds the enforcer to the others. This is what lets
// the enforcer see BOTH locally-served (integrated) and edge-served (multi-node)
// streams regardless of whether Redis is configured.
type MultiSource struct {
sources []Source
}
// NewMultiSource builds a union source over the given sources (nil entries are
// ignored).
func NewMultiSource(sources ...Source) *MultiSource {
return &MultiSource{sources: sources}
}
// Snapshot merges every sub-source's snapshot, de-duplicated by canonical
// logical session identity when available.
func (m *MultiSource) Snapshot(ctx context.Context) (Snapshot, error) {
var all []LiveStream
for _, src := range m.sources {
if src == nil {
continue
}
snap, err := src.Snapshot(ctx)
if err != nil {
slog.Warn("streammonitor: source snapshot failed; skipping", "error", err)
continue
}
all = append(all, snap.Streams...)
}
return Snapshot{Streams: mergeStreams(all)}, nil
}
// toLiveStream converts a nodesessions.SessionInfo into a LiveStream, parsing
// the RFC3339 timestamps and tolerating empty/unparseable values (which map to
// the zero time).
func toLiveStream(info nodesessions.SessionInfo) LiveStream {
return LiveStream{
SessionID: info.SessionID,
LogicalSessionID: info.LogicalSessionID,
UserID: info.AuthUserID,
ProfileID: info.ProfileID,
NodeName: info.NodeName,
NodeURL: info.NodeURL,
Type: info.Type,
Route: info.Route,
MediaFileID: info.MediaFileID,
ClientIP: info.ClientIP,
ClientName: info.ClientName,
Position: info.Position,
HWAccel: info.HWAccel,
LastServedAt: parseTime(info.LastServedAt),
BytesServed: info.BytesServed,
StartedAt: parseTime(info.StartedAt),
}
}
// sessionIdentity returns the stable identity used to merge and enforce a
// playback stream. SessionID remains the transport-addressing key on raw node
// records; a logical id, when present, takes precedence only in monitoring.
func sessionIdentity(sessionID, logicalSessionID string) string {
if logicalSessionID != "" {
return logicalSessionID
}
return sessionID
}
// parseTime parses an RFC3339 timestamp, returning the zero time for empty or
// unparseable input.
func parseTime(s string) time.Time {
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
slog.Debug("streammonitor: unparseable timestamp", "value", s, "error", err)
return time.Time{}
}
return t
}
// mergeStreams collapses records that share a canonical identity (the logical
// session id when present, otherwise the transport SessionID). The same stream
// can be tracked by more than one node or transport generation. The freshest
// record wins; genuinely distinct logical sessions are retained. The relative
// order of kept records is not guaranteed.
//
// Ownership and attribution are carried forward independently of the freshness
// pick: the transcode node's own start record has no resolved owner (UserID 0)
// and thinner attribution, while the proxy record fronting it does. If the
// ownerless node record happened to be the freshest copy, taking it wholesale
// would bucket the session under user 0 (which the enforcer skips — silently
// exempting it from the concurrency cap) and drop route/client detail from the
// monitor view. So a merged record adopts a resolved owner from either candidate
// and backfills any empty attribution/display field from the other copy.
func mergeStreams(streams []LiveStream) []LiveStream {
bySession := make(map[string]LiveStream, len(streams))
for _, st := range streams {
key := sessionIdentity(st.SessionID, st.LogicalSessionID)
existing, ok := bySession[key]
if !ok {
st.SessionID = key
bySession[key] = st
continue
}
winner := existing
other := st
if st.LastServedAt.After(existing.LastServedAt) {
winner, other = st, existing
}
// If the freshest copy is ownerless, adopt a resolved owner from either
// candidate so the session is still attributed (and enforced) correctly.
if winner.UserID <= 0 {
for _, cand := range []LiveStream{existing, st} {
if cand.UserID > 0 {
winner.UserID = cand.UserID
winner.ProfileID = cand.ProfileID
winner.MediaFileID = cand.MediaFileID
break
}
}
}
// Backfill display/attribution fields the freshest copy lacks so the
// merged record is as complete as possible for the monitor view.
if winner.Route == "" {
winner.Route = other.Route
}
if winner.ClientIP == "" {
winner.ClientIP = other.ClientIP
}
if winner.ClientName == "" {
winner.ClientName = other.ClientName
}
if winner.HWAccel == "" {
winner.HWAccel = other.HWAccel
}
if winner.Position == 0 {
winner.Position = other.Position
}
// These are observers of one pour, not independent byte sources.
winner.BytesServed = max(winner.BytesServed, other.BytesServed)
winner.SessionID = key
bySession[key] = winner
}
out := make([]LiveStream, 0, len(bySession))
for _, st := range bySession {
out = append(out, st)
}
return out
}
// DedupeSessionInfos collapses raw monitoring records that share a canonical
// identity, applying the same rules as mergeStreams — keep the most-recently-
// served copy, carry a resolved owner forward, backfill missing attribution —
// but preserving the SessionInfo shape and transport SessionID for surfaces
// whose wire format IS the raw record. Kept as a sibling of mergeStreams rather
// than a shared generic because mergeStreams operates on parsed LiveStream.
func DedupeSessionInfos(infos []nodesessions.SessionInfo) []nodesessions.SessionInfo {
bySession := make(map[string]nodesessions.SessionInfo, len(infos))
order := make([]string, 0, len(infos))
for _, in := range infos {
key := sessionIdentity(in.SessionID, in.LogicalSessionID)
existing, ok := bySession[key]
if !ok {
bySession[key] = in
order = append(order, key)
continue
}
winner, other := existing, in
if parseTime(in.LastServedAt).After(parseTime(existing.LastServedAt)) {
winner, other = in, existing
}
if winner.AuthUserID <= 0 && other.AuthUserID > 0 {
winner.AuthUserID = other.AuthUserID
winner.ProfileID = other.ProfileID
winner.MediaFileID = other.MediaFileID
}
if winner.Route == "" {
winner.Route = other.Route
}
if winner.ClientIP == "" {
winner.ClientIP = other.ClientIP
}
if winner.ClientName == "" {
winner.ClientName = other.ClientName
}
if winner.HWAccel == "" {
winner.HWAccel = other.HWAccel
}
if winner.Position == 0 {
winner.Position = other.Position
}
winner.BytesServed = max(winner.BytesServed, other.BytesServed)
bySession[key] = winner
}
out := make([]nodesessions.SessionInfo, 0, len(bySession))
for _, id := range order {
out = append(out, bySession[id])
}
return out
}
// LiveLocalSessions maps in-process playback sessions into monitoring records
// so integrated streams are visible to both the enforcer and admin view.
func LiveLocalSessions(sm *playback.SessionManager, nodeName string) []nodesessions.SessionInfo {
if sm == nil {
return nil
}
live := sm.AllSessions()
out := make([]nodesessions.SessionInfo, 0, len(live))
for _, s := range live {
lastServedAt := ""
if !s.LastServedAt.IsZero() {
lastServedAt = s.LastServedAt.UTC().Format(time.RFC3339)
}
out = append(out, nodesessions.SessionInfo{
SessionID: s.ID,
NodeName: nodeName,
AuthUserID: s.UserID,
ProfileID: s.ProfileID,
Type: string(s.PlayMethod),
Route: s.Origin(),
MediaFileID: s.MediaFileID,
ClientIP: s.ClientIP,
ClientName: s.ClientName,
Position: s.Position,
Resolution: s.TargetResolution,
HWAccel: s.TranscodeHWAccel,
StartedAt: s.StartedAt.UTC().Format(time.RFC3339),
LastServedAt: lastServedAt,
BytesServed: s.BytesServed,
})
}
return out
}
// RedisSource reads silo:sessions:* records, producing the multi-node
// authoritative picture.
type RedisSource struct {
rdb *redis.Client
}
// NewRedisSource creates a RedisSource backed by rdb.
func NewRedisSource(rdb *redis.Client) *RedisSource {
return &RedisSource{rdb: rdb}
}
// Snapshot SCANs every silo:sessions:* record, decodes it, and returns the
// deduped live picture. The same logical session appearing on multiple nodes or
// transport generations is collapsed to the record with the newest liveness.
func (r *RedisSource) Snapshot(ctx context.Context) (Snapshot, error) {
if r.rdb == nil {
return Snapshot{Streams: []LiveStream{}}, nil
}
// Fully iterate the cursor to collect matching keys. SCAN (not KEYS) keeps
// this non-blocking against a large keyspace.
var keys []string
var cursor uint64
for {
batch, next, err := r.rdb.Scan(ctx, cursor, sessionKeyPrefix+"*", scanCount).Result()
if err != nil {
return Snapshot{}, err
}
keys = append(keys, batch...)
cursor = next
if cursor == 0 {
break
}
}
if len(keys) == 0 {
return Snapshot{Streams: []LiveStream{}}, nil
}
vals, err := r.rdb.MGet(ctx, keys...).Result()
if err != nil {
return Snapshot{}, err
}
streams := make([]LiveStream, 0, len(vals))
for i, v := range vals {
if v == nil {
// Key expired between SCAN and MGET; skip.
continue
}
var raw string
switch val := v.(type) {
case string:
raw = val
case []byte:
raw = string(val)
default:
slog.Debug("streammonitor: unexpected redis value type", "key", keys[i])
continue
}
var info nodesessions.SessionInfo
if err := json.Unmarshal([]byte(raw), &info); err != nil {
slog.Debug("streammonitor: unmarshal session record failed", "key", keys[i], "error", err)
continue
}
streams = append(streams, toLiveStream(info))
}
return Snapshot{Streams: mergeStreams(streams)}, nil
}
// FuncSource adapts an in-process provider (integrated single-node) that returns
// the local tracker records.
type FuncSource struct {
fn func(ctx context.Context) ([]nodesessions.SessionInfo, error)
}
// NewFuncSource creates a FuncSource backed by fn.
func NewFuncSource(fn func(ctx context.Context) ([]nodesessions.SessionInfo, error)) *FuncSource {
return &FuncSource{fn: fn}
}
// Snapshot invokes the wrapped provider and returns the deduped live picture.
func (f *FuncSource) Snapshot(ctx context.Context) (Snapshot, error) {
if f.fn == nil {
return Snapshot{Streams: []LiveStream{}}, nil
}
infos, err := f.fn(ctx)
if err != nil {
return Snapshot{}, err
}
streams := make([]LiveStream, 0, len(infos))
for _, info := range infos {
streams = append(streams, toLiveStream(info))
}
return Snapshot{Streams: mergeStreams(streams)}, nil
}