Five defects that all produced a WRONG over-cap count, which is why they land before the revocation batch: decision A1 raises the over-cap revocation TTL from 5m to ~24h, removing the self-healing that currently limits the damage of a miscount. A false positive after A1 blocks a legitimate stream for a day, so the count has to be trustworthy first. #1 -- overlapping edge requests deleted a live stream. Tracker.sessions was a set and Remove tore down all state plus the Redis key, while both proxy pour handlers deferred removal unconditionally. Two overlapping Range GETs on one session id -- ordinary seek behaviour -- meant the first to finish deleted the record while the second was still pouring, and later AddBytes calls were then dropped because AddBytes ignores bytes for a session with no live record. The stream went invisible to authoritative monitoring while still serving. Track now returns a Lease that the request-scoped caller releases exactly once; teardown happens when the last live lease is released. A plain refcount would have been wrong: Track(A) -> Remove -> Track(B) -> Release(A) decrements B, and clamping at zero does not help because the count legitimately belongs to B. That is not hypothetical -- the transcode node deliberately replaces sessions under the same id so a quality switch does not orphan ffmpeg, and it calls unconditional Remove from its reaper and stop paths. So each generation carries an epoch, Remove and Cleanup bump it, and a release from a superseded generation is a logged no-op. Lease identity is a set rather than a counter, which makes a duplicate release detectable instead of silently destructive. The transcode node keeps using Remove: its Track calls are not request-scoped and are correctly owned by session lifecycle. "Every Track needs a paired Release" is true only of the request-scoped callers. #8 -- async transcode tracking could leave a permanent ghost. The tracking write ran as a bare goroutine with a WithoutCancel context, so if stop won the race the delayed Track recreated the record after cleanup -- and because it landed in sessions, Snapshot treated it as live until Remove and it NEVER idle-expired. A permanent phantom inflating its owner's count, able to trigger false over-cap kills of that user's real streams. The write now takes the per-session lifecycle lock that stop and reap already hold, and re-checks session pointer identity before writing, so a stopped or replaced generation cannot resurrect a record. Pointer identity rather than id equality is what makes same-id replacement safe. The write stays off the request path -- the API server and the playback client are blocked on the 202. #9 + M3 -- protocol-v3 counted one stream twice. The stream token carries a transport id distinct from the logical session id, and the node tracked under the transport id while the API/proxy record used the logical one, so mergeStreams saw two streams. M3 was the reason this had not yet bitten: the v3 fresh-start caller sent no owner attribution at all, so the transport record landed under user 0, which the enforcer skips -- silently exempting the stream from the cap entirely. Fresh v3 starts now carry the logical session id and full owner attribution (both were already in scope at the call site), and merging is keyed on logical identity where present via one shared helper used by both merge functions, which had already drifted apart once. The enforcer view resolves SessionID to the logical id so a kill targets the real session rather than a replaceable transport generation. The raw admin view keeps the transport id and exposes logical_session_id as an additive omitempty field, advertised on the node-sessions capability endpoint, so the v1 response shape is unchanged. GAP-15 -- edge transcode liveness was request-observed. touchTranscodeSession fired before proxying, so hammering dead segment URLs advanced LastServedAt with zero bytes served. Visibility and liveness are now separate operations: EnsureEphemeral makes a session visible without claiming bytes were served, and served-byte liveness advances only from a 2xx/206 upstream response. Previously the proxy metered every upstream body regardless of status, so a node 404's error body counted as served bytes -- moving the touch later would not have fixed it. S4 -- LiveLocalSessions moved from the HTTP handlers package to streammonitor, which owns monitoring. A background enforcer importing api/handlers was backwards. Pure move; its existing mapping assertions moved with it. The LastActivityAt fallback inside it is left as-is -- decision A5 removes it in the liveness batch. Verified with go test -race across nodesessions, proxy and transcodenode; the overlap regression test was confirmed to fail under the old unconditional teardown. Part of #305.
440 lines
14 KiB
Go
440 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 a secondary signal. On the edge, LastServedAt is purely byte-observed.
|
|
// In integrated mode LastServedAt is mapped from SessionManager.LastActivityAt,
|
|
// which client progress reports also advance; that is acceptable because it is
|
|
// used only to order over-cap victims (selectVictims), never to decide whether a
|
|
// stream exists or is counted. 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 := s.LastServedAt
|
|
if lastServedAt.IsZero() {
|
|
lastServedAt = s.LastActivityAt
|
|
}
|
|
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.UTC().Format(time.RFC3339),
|
|
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
|
|
}
|