431 lines
13 KiB
Go
431 lines
13 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/cache"
|
|
evt "github.com/Silo-Server/silo-server/internal/events"
|
|
)
|
|
|
|
// SessionSync represents the data needed to sync a playback session to the
|
|
// playback_sessions_sync table in PostgreSQL.
|
|
type SessionSync struct {
|
|
SessionID string
|
|
UserID int
|
|
ProfileID string
|
|
MediaFileID int
|
|
RequestedMediaFileID int
|
|
PlayMethod string // current live transport method for admin session views
|
|
ReportingNode string
|
|
ClientIP string
|
|
AudioTrackIndex int
|
|
TranscodeAudio bool
|
|
StreamBitrateKbps int
|
|
TranscodeNodeURL string
|
|
TargetResolution string
|
|
TargetVideoCodec string
|
|
TargetAudioCodec string
|
|
TargetBitrateKbps int
|
|
StartedAt time.Time
|
|
UpdatedAt time.Time
|
|
IsPaused bool
|
|
HasWebSocket bool
|
|
}
|
|
|
|
// AggregateData represents the aggregate counts for a single user that are
|
|
// synced to the user_aggregates table in PostgreSQL.
|
|
type AggregateData struct {
|
|
TotalWatched int
|
|
FavoritesCount int
|
|
WatchlistCount int
|
|
ActiveNode string
|
|
}
|
|
|
|
// SessionSyncProvider returns the current set of active sessions to reconcile.
|
|
// This is typically a closure that reads from the SessionManager.
|
|
type SessionSyncProvider func() []SessionSync
|
|
|
|
// PreSyncHook is called before each reconciliation cycle. Implementations
|
|
// can use it to expire idle sessions from the in-memory manager so they are
|
|
// no longer included in the snapshot sent to the database.
|
|
type PreSyncHook func()
|
|
|
|
// Reconciler performs background reconciliation of user data from per-user
|
|
// SQLite databases into the central PostgreSQL instance.
|
|
type Reconciler struct {
|
|
pool *pgxpool.Pool
|
|
nodeName string
|
|
sessionProvider SessionSyncProvider
|
|
interval time.Duration
|
|
stop chan struct{}
|
|
EventBus cache.EventBus
|
|
EventsHub *evt.Hub
|
|
PreSync PreSyncHook
|
|
}
|
|
|
|
// NewReconciler creates a new Reconciler with sensible defaults. The default
|
|
// reconciliation interval is 30 seconds. The sessionProvider may be nil if
|
|
// session sync is not needed (e.g. in tests).
|
|
func NewReconciler(pool *pgxpool.Pool, nodeName string, sp SessionSyncProvider) *Reconciler {
|
|
return &Reconciler{
|
|
pool: pool,
|
|
nodeName: strings.TrimSpace(nodeName),
|
|
sessionProvider: sp,
|
|
interval: 15 * time.Second,
|
|
stop: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// ReconcileSessions upserts the given sessions into the playback_sessions_sync
|
|
// table. Each session is inserted or updated based on its session_id primary
|
|
// key, making the operation idempotent.
|
|
func (r *Reconciler) ReconcileSessions(ctx context.Context, sessions []SessionSync) error {
|
|
if len(sessions) == 0 {
|
|
return nil
|
|
}
|
|
|
|
grouped := make(map[string][]SessionSync)
|
|
for _, session := range sessions {
|
|
grouped[session.ReportingNode] = append(grouped[session.ReportingNode], session)
|
|
}
|
|
|
|
for reportingNode, nodeSessions := range grouped {
|
|
if err := r.ReconcileNodeSessions(ctx, reportingNode, nodeSessions); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ReconcileNodeSessions upserts the sessions currently active on one reporting
|
|
// node, then removes any stale rows for that same node that are no longer
|
|
// present in the provided snapshot.
|
|
func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode string, sessions []SessionSync) error {
|
|
reportingNode = strings.TrimSpace(reportingNode)
|
|
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("beginning transaction: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
currentSessions, err := loadNodeSessionsSnapshot(ctx, tx, reportingNode)
|
|
if err != nil {
|
|
return fmt.Errorf("loading existing sessions for node %s: %w", reportingNode, err)
|
|
}
|
|
normalizedIncoming := normalizeSessionSyncs(reportingNode, sessions)
|
|
changed := !sessionSnapshotsEqual(currentSessions, normalizedIncoming)
|
|
|
|
sessionIDs := make([]string, 0, len(sessions))
|
|
for _, s := range sessions {
|
|
sessionIDs = append(sessionIDs, s.SessionID)
|
|
sessionNode := strings.TrimSpace(s.ReportingNode)
|
|
if sessionNode == "" {
|
|
sessionNode = reportingNode
|
|
}
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO playback_sessions_sync
|
|
(session_id, user_id, profile_id, media_file_id, requested_media_file_id, play_method,
|
|
reporting_node, started_at, updated_at, last_sync_at, client_ip,
|
|
audio_track_index, transcode_audio, stream_bitrate_kbps, transcode_node_url,
|
|
target_resolution, target_video_codec, target_audio_codec, target_bitrate_kbps,
|
|
is_paused, has_websocket)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10::inet, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
|
ON CONFLICT (session_id) DO UPDATE SET
|
|
user_id = EXCLUDED.user_id,
|
|
profile_id = EXCLUDED.profile_id,
|
|
media_file_id = EXCLUDED.media_file_id,
|
|
requested_media_file_id = EXCLUDED.requested_media_file_id,
|
|
play_method = EXCLUDED.play_method,
|
|
reporting_node = EXCLUDED.reporting_node,
|
|
started_at = EXCLUDED.started_at,
|
|
updated_at = EXCLUDED.updated_at,
|
|
client_ip = EXCLUDED.client_ip,
|
|
audio_track_index = EXCLUDED.audio_track_index,
|
|
transcode_audio = EXCLUDED.transcode_audio,
|
|
stream_bitrate_kbps = EXCLUDED.stream_bitrate_kbps,
|
|
transcode_node_url = EXCLUDED.transcode_node_url,
|
|
target_resolution = EXCLUDED.target_resolution,
|
|
target_video_codec = EXCLUDED.target_video_codec,
|
|
target_audio_codec = EXCLUDED.target_audio_codec,
|
|
target_bitrate_kbps = EXCLUDED.target_bitrate_kbps,
|
|
is_paused = EXCLUDED.is_paused,
|
|
has_websocket = EXCLUDED.has_websocket,
|
|
last_sync_at = NOW()
|
|
`, s.SessionID, s.UserID, s.ProfileID, s.MediaFileID, nullableInt(s.RequestedMediaFileID), s.PlayMethod,
|
|
sessionNode, s.StartedAt, s.UpdatedAt, nullableIP(s.ClientIP), s.AudioTrackIndex,
|
|
s.TranscodeAudio, nullableInt(s.StreamBitrateKbps), nullableString(s.TranscodeNodeURL),
|
|
nullableString(s.TargetResolution), nullableString(s.TargetVideoCodec),
|
|
nullableString(s.TargetAudioCodec), nullableInt(s.TargetBitrateKbps),
|
|
s.IsPaused, s.HasWebSocket)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting session %s: %w", s.SessionID, err)
|
|
}
|
|
}
|
|
|
|
if len(sessionIDs) == 0 {
|
|
if _, err := tx.Exec(ctx, `
|
|
DELETE FROM playback_sessions_sync
|
|
WHERE COALESCE(reporting_node, '') = $1
|
|
`, reportingNode); err != nil {
|
|
return fmt.Errorf("deleting empty snapshot for node %s: %w", reportingNode, err)
|
|
}
|
|
} else {
|
|
if _, err := tx.Exec(ctx, `
|
|
DELETE FROM playback_sessions_sync
|
|
WHERE COALESCE(reporting_node, '') = $1
|
|
AND NOT (session_id = ANY($2))
|
|
`, reportingNode, sessionIDs); err != nil {
|
|
return fmt.Errorf("deleting missing sessions for node %s: %w", reportingNode, err)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("committing transaction: %w", err)
|
|
}
|
|
if changed && r.EventsHub != nil {
|
|
if err := r.EventsHub.PublishJSON(
|
|
ctx,
|
|
evt.ChannelSessions,
|
|
"sessions.replaced",
|
|
nil,
|
|
evt.PublishOptions{AdminOnly: true},
|
|
); err != nil {
|
|
log.Printf("reconciler: failed to publish session event for node %s: %v", reportingNode, err)
|
|
}
|
|
} else if changed && r.EventBus != nil {
|
|
if err := r.EventBus.Publish(ctx, cache.ChannelPlayback, cache.Event{
|
|
Type: cache.EventPlaybackSessionsChanged,
|
|
Payload: reportingNode,
|
|
}); err != nil {
|
|
log.Printf("reconciler: failed to publish playback invalidation event for node %s: %v", reportingNode, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func loadNodeSessionsSnapshot(ctx context.Context, tx pgx.Tx, reportingNode string) ([]SessionSync, error) {
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT
|
|
session_id,
|
|
user_id,
|
|
COALESCE(profile_id, ''),
|
|
media_file_id,
|
|
COALESCE(requested_media_file_id, media_file_id, 0),
|
|
COALESCE(play_method, ''),
|
|
COALESCE(reporting_node, ''),
|
|
COALESCE(HOST(client_ip), ''),
|
|
COALESCE(audio_track_index, 0),
|
|
COALESCE(transcode_audio, FALSE),
|
|
COALESCE(stream_bitrate_kbps, 0),
|
|
COALESCE(transcode_node_url, ''),
|
|
COALESCE(target_resolution, ''),
|
|
COALESCE(target_video_codec, ''),
|
|
COALESCE(target_audio_codec, ''),
|
|
COALESCE(target_bitrate_kbps, 0),
|
|
started_at,
|
|
updated_at,
|
|
COALESCE(is_paused, FALSE),
|
|
COALESCE(has_websocket, FALSE)
|
|
FROM playback_sessions_sync
|
|
WHERE COALESCE(reporting_node, '') = $1
|
|
ORDER BY session_id
|
|
`, reportingNode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var sessions []SessionSync
|
|
for rows.Next() {
|
|
var s SessionSync
|
|
if err := rows.Scan(
|
|
&s.SessionID,
|
|
&s.UserID,
|
|
&s.ProfileID,
|
|
&s.MediaFileID,
|
|
&s.RequestedMediaFileID,
|
|
&s.PlayMethod,
|
|
&s.ReportingNode,
|
|
&s.ClientIP,
|
|
&s.AudioTrackIndex,
|
|
&s.TranscodeAudio,
|
|
&s.StreamBitrateKbps,
|
|
&s.TranscodeNodeURL,
|
|
&s.TargetResolution,
|
|
&s.TargetVideoCodec,
|
|
&s.TargetAudioCodec,
|
|
&s.TargetBitrateKbps,
|
|
&s.StartedAt,
|
|
&s.UpdatedAt,
|
|
&s.IsPaused,
|
|
&s.HasWebSocket,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
sessions = append(sessions, s)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return sessions, nil
|
|
}
|
|
|
|
func normalizeSessionSyncs(reportingNode string, sessions []SessionSync) []SessionSync {
|
|
normalized := make([]SessionSync, len(sessions))
|
|
for i, s := range sessions {
|
|
cp := s
|
|
if strings.TrimSpace(cp.ReportingNode) == "" {
|
|
cp.ReportingNode = reportingNode
|
|
}
|
|
normalized[i] = cp
|
|
}
|
|
sort.Slice(normalized, func(i, j int) bool {
|
|
return normalized[i].SessionID < normalized[j].SessionID
|
|
})
|
|
return normalized
|
|
}
|
|
|
|
func sessionSnapshotsEqual(left, right []SessionSync) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for i := range left {
|
|
if left[i].SessionID != right[i].SessionID ||
|
|
left[i].UserID != right[i].UserID ||
|
|
left[i].ProfileID != right[i].ProfileID ||
|
|
left[i].MediaFileID != right[i].MediaFileID ||
|
|
left[i].RequestedMediaFileID != right[i].RequestedMediaFileID ||
|
|
left[i].PlayMethod != right[i].PlayMethod ||
|
|
left[i].ReportingNode != right[i].ReportingNode ||
|
|
left[i].ClientIP != right[i].ClientIP ||
|
|
left[i].AudioTrackIndex != right[i].AudioTrackIndex ||
|
|
left[i].TranscodeAudio != right[i].TranscodeAudio ||
|
|
left[i].StreamBitrateKbps != right[i].StreamBitrateKbps ||
|
|
left[i].TranscodeNodeURL != right[i].TranscodeNodeURL ||
|
|
left[i].TargetResolution != right[i].TargetResolution ||
|
|
left[i].TargetVideoCodec != right[i].TargetVideoCodec ||
|
|
left[i].TargetAudioCodec != right[i].TargetAudioCodec ||
|
|
left[i].TargetBitrateKbps != right[i].TargetBitrateKbps ||
|
|
!left[i].StartedAt.Equal(right[i].StartedAt) ||
|
|
!left[i].UpdatedAt.Equal(right[i].UpdatedAt) ||
|
|
left[i].IsPaused != right[i].IsPaused ||
|
|
left[i].HasWebSocket != right[i].HasWebSocket {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func nullableIP(ip string) any {
|
|
if strings.TrimSpace(ip) == "" {
|
|
return nil
|
|
}
|
|
return ip
|
|
}
|
|
|
|
func nullableInt(value int) any {
|
|
if value <= 0 {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func nullableString(value string) any {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
// ReconcileAggregates upserts the aggregate counts for a single user into the
|
|
// user_aggregates table. The operation is idempotent.
|
|
func (r *Reconciler) ReconcileAggregates(ctx context.Context, userID int, totals AggregateData) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO user_aggregates
|
|
(user_id, total_watched, favorites_count, watchlist_count, active_node, last_sync_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
total_watched = EXCLUDED.total_watched,
|
|
favorites_count = EXCLUDED.favorites_count,
|
|
watchlist_count = EXCLUDED.watchlist_count,
|
|
active_node = EXCLUDED.active_node,
|
|
last_sync_at = NOW()
|
|
`, userID, totals.TotalWatched, totals.FavoritesCount, totals.WatchlistCount, totals.ActiveNode)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting aggregates for user %d: %w", userID, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Start begins the background reconciliation loop. It runs until Stop is
|
|
// called. On each tick it syncs active playback sessions to PostgreSQL.
|
|
func (r *Reconciler) Start() {
|
|
go func() {
|
|
ticker := time.NewTicker(r.interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-r.stop:
|
|
return
|
|
case <-ticker.C:
|
|
r.tick()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// tick runs one reconciliation cycle.
|
|
func (r *Reconciler) tick() {
|
|
if r.PreSync != nil {
|
|
r.PreSync()
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
if err := r.SyncNow(ctx); err != nil {
|
|
log.Printf("reconciler: session sync error: %v", err)
|
|
}
|
|
}
|
|
|
|
// SyncNow runs one immediate session reconciliation using the current local
|
|
// session snapshot. When nodeName is configured, an empty snapshot still
|
|
// clears any rows previously reported by that node.
|
|
func (r *Reconciler) SyncNow(ctx context.Context) error {
|
|
if r.sessionProvider == nil {
|
|
return nil
|
|
}
|
|
|
|
sessions := r.sessionProvider()
|
|
if len(sessions) == 0 {
|
|
if r.nodeName == "" {
|
|
return nil
|
|
}
|
|
return r.ReconcileNodeSessions(ctx, r.nodeName, nil)
|
|
}
|
|
if r.nodeName != "" {
|
|
for i := range sessions {
|
|
if strings.TrimSpace(sessions[i].ReportingNode) == "" {
|
|
sessions[i].ReportingNode = r.nodeName
|
|
}
|
|
}
|
|
}
|
|
return r.ReconcileSessions(ctx, sessions)
|
|
}
|
|
|
|
// Stop signals the reconciliation loop to stop.
|
|
func (r *Reconciler) Stop() {
|
|
close(r.stop)
|
|
}
|