215 lines
6.4 KiB
Python
215 lines
6.4 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Per-connection session lifecycle for the streaming server.
|
|
|
|
Each WebSocket opens exactly one :class:`Session`. :class:`SessionManager`
|
|
enforces the ``generation_segment_cap`` and ``session_timeout_seconds``
|
|
budgets from :class:`fastvideo.api.StreamingConfig`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import enum
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from fastvideo.api.schema import ContinuationState
|
|
|
|
|
|
class SessionState(enum.Enum):
|
|
"""State-machine positions for a streaming session.
|
|
|
|
Transitions are server-owned. See
|
|
``docs/design/server_contracts/streaming.md`` for the full diagram.
|
|
"""
|
|
|
|
INITIALIZING = "initializing"
|
|
QUEUED = "queued"
|
|
GPU_BINDING = "gpu_binding"
|
|
ACTIVE = "active"
|
|
COMPLETE = "complete"
|
|
ERROR = "error"
|
|
TIMEOUT = "timeout"
|
|
REJECTED = "rejected"
|
|
|
|
|
|
_VALID_TRANSITIONS: dict[SessionState, frozenset[SessionState]] = {
|
|
SessionState.INITIALIZING:
|
|
frozenset({
|
|
SessionState.QUEUED,
|
|
SessionState.GPU_BINDING,
|
|
SessionState.REJECTED,
|
|
SessionState.ERROR,
|
|
}),
|
|
SessionState.QUEUED:
|
|
frozenset({
|
|
SessionState.GPU_BINDING,
|
|
SessionState.ERROR,
|
|
SessionState.TIMEOUT,
|
|
SessionState.REJECTED,
|
|
}),
|
|
SessionState.GPU_BINDING:
|
|
frozenset({
|
|
SessionState.ACTIVE,
|
|
SessionState.ERROR,
|
|
SessionState.TIMEOUT,
|
|
}),
|
|
SessionState.ACTIVE:
|
|
frozenset({
|
|
SessionState.ACTIVE,
|
|
SessionState.COMPLETE,
|
|
SessionState.ERROR,
|
|
SessionState.TIMEOUT,
|
|
}),
|
|
SessionState.COMPLETE:
|
|
frozenset(),
|
|
SessionState.ERROR:
|
|
frozenset(),
|
|
SessionState.TIMEOUT:
|
|
frozenset(),
|
|
SessionState.REJECTED:
|
|
frozenset(),
|
|
}
|
|
|
|
|
|
class InvalidSessionTransition(RuntimeError):
|
|
"""Raised when a session is asked to transition along an illegal edge."""
|
|
|
|
|
|
@dataclass
|
|
class Session:
|
|
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
state: SessionState = SessionState.INITIALIZING
|
|
created_at: float = field(default_factory=time.monotonic)
|
|
last_activity: float = field(default_factory=time.monotonic)
|
|
|
|
client_id: str | None = None
|
|
preset: str | None = None
|
|
preset_label: str | None = None
|
|
|
|
curated_prompts: list[str] = field(default_factory=list)
|
|
|
|
segment_idx: int = 0
|
|
|
|
enhancement_enabled: bool = False
|
|
auto_extension_enabled: bool = False
|
|
loop_generation_enabled: bool = False
|
|
single_clip_mode: bool = False
|
|
generation_paused: bool = False
|
|
|
|
stream_mode: str = "av_fmp4"
|
|
gpu_id: int | None = None
|
|
|
|
continuation_state: ContinuationState | None = None
|
|
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def transition(self, target: SessionState) -> None:
|
|
"""Move to ``target`` if the edge is allowed.
|
|
|
|
Raises :class:`InvalidSessionTransition` on illegal moves. The
|
|
self-loop on ``ACTIVE`` is legal so the server can re-assert
|
|
ACTIVE on segment completion without special casing.
|
|
"""
|
|
allowed = _VALID_TRANSITIONS.get(self.state, frozenset())
|
|
if target not in allowed and target is not self.state:
|
|
raise InvalidSessionTransition(f"{self.state.value} -> {target.value} is not a valid "
|
|
f"session transition")
|
|
self.state = target
|
|
self.last_activity = time.monotonic()
|
|
|
|
def touch(self) -> None:
|
|
self.last_activity = time.monotonic()
|
|
|
|
def is_active(self) -> bool:
|
|
return self.state is SessionState.ACTIVE
|
|
|
|
def segment_cap_reached(self, cap: int) -> bool:
|
|
return self.segment_idx >= cap
|
|
|
|
|
|
class SessionManager:
|
|
"""Registers sessions and enforces per-server session limits."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
segment_cap: int,
|
|
session_timeout_seconds: int,
|
|
max_sessions: int = 1,
|
|
) -> None:
|
|
self._segment_cap = segment_cap
|
|
self._session_timeout_seconds = session_timeout_seconds
|
|
self._max_sessions = max_sessions
|
|
self._sessions: dict[str, Session] = {}
|
|
|
|
@property
|
|
def segment_cap(self) -> int:
|
|
return self._segment_cap
|
|
|
|
@property
|
|
def session_timeout_seconds(self) -> int:
|
|
return self._session_timeout_seconds
|
|
|
|
def create(self) -> Session:
|
|
if len(self._sessions) >= self._max_sessions:
|
|
raise SessionRejected(f"max sessions reached ({self._max_sessions})")
|
|
session = Session()
|
|
self._sessions[session.id] = session
|
|
return session
|
|
|
|
def get(self, session_id: str) -> Session | None:
|
|
return self._sessions.get(session_id)
|
|
|
|
def close(self, session_id: str) -> None:
|
|
self._sessions.pop(session_id, None)
|
|
|
|
def __contains__(self, session_id: str) -> bool:
|
|
return session_id in self._sessions
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._sessions)
|
|
|
|
def active_sessions(self) -> list[Session]:
|
|
return [s for s in self._sessions.values() if s.is_active()]
|
|
|
|
def reap_timed_out(self, now: float | None = None) -> list[str]:
|
|
"""Return the ids of sessions that have exceeded the idle timeout.
|
|
|
|
The caller is responsible for actually closing them — this
|
|
method only *identifies* dead sessions so the server can emit
|
|
``session_timeout`` frames before dropping the WebSocket.
|
|
|
|
TODO: unused until a background driver calls it. Per-connection
|
|
idle enforcement currently happens via asyncio.wait_for on
|
|
receive_json; this helper catches sessions stuck before any
|
|
receive (e.g. future QUEUED state) and is expected to be wired
|
|
into the GPU-pool reaper.
|
|
"""
|
|
now = now if now is not None else time.monotonic()
|
|
dead: list[str] = []
|
|
for sid, session in self._sessions.items():
|
|
if session.state in {
|
|
SessionState.COMPLETE,
|
|
SessionState.ERROR,
|
|
SessionState.TIMEOUT,
|
|
SessionState.REJECTED,
|
|
}:
|
|
continue
|
|
if now - session.last_activity > self._session_timeout_seconds:
|
|
dead.append(sid)
|
|
return dead
|
|
|
|
|
|
class SessionRejected(RuntimeError):
|
|
"""Raised when session creation fails (queue full, auth, etc.)."""
|
|
|
|
|
|
__all__ = [
|
|
"InvalidSessionTransition",
|
|
"Session",
|
|
"SessionManager",
|
|
"SessionRejected",
|
|
"SessionState",
|
|
]
|