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
439 lines
14 KiB
Go
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
|
|
}
|