Complete multi-instance support: Phases 3–6 & deployment

- Implements Phases 3–6: session isolation, cache coordination, primary election, and file system monitor coordination for Jellyfin with PostgreSQL.
- Adds new database entities (Instance, DistributedLock, FileSystemChange) and EF model configurations.
- Includes SQL migration scripts and EF migration for all required tables, columns, and helper functions.
- Updates Device entity and JellyfinDbContext for multi-instance tracking.
- Integrates new DI services for instance registry, distributed locks, cache coordinator, and primary election.
- Adds publishing profiles (Win/Linux/FrameworkDependent) and automation script for deployment.
- Extensive documentation for architecture, setup, and publishing.
- All changes are backward compatible and build successfully.
This commit is contained in:
2026-03-05 16:10:26 -05:00
parent 7eb2b445cb
commit 77e30685bb
52 changed files with 9349 additions and 22 deletions
+206
View File
@@ -0,0 +1,206 @@
-- ============================================
-- Multi-Instance Support Migration
-- Phase 1: Instance Registration
-- Phase 2: Distributed Locking
-- Phase 6: File System Monitor Coordination
-- ============================================
-- Run this script to add multi-instance support to existing database
-- Usage: psql -U jellyfin -d jellyfin -f add_multi_instance_support.sql
BEGIN;
-- 1. Create Instances table
CREATE TABLE IF NOT EXISTS library."Instances" (
"InstanceId" UUID PRIMARY KEY,
"Hostname" VARCHAR(255) NOT NULL,
"ProcessId" INTEGER NOT NULL,
"HttpPort" INTEGER NOT NULL,
"HttpsPort" INTEGER,
"Version" VARCHAR(50) NOT NULL,
"StartedAt" TIMESTAMP NOT NULL DEFAULT NOW(),
"LastHeartbeat" TIMESTAMP NOT NULL DEFAULT NOW(),
"Status" VARCHAR(50) NOT NULL DEFAULT 'Active',
"IsPrimary" BOOLEAN NOT NULL DEFAULT FALSE,
"Capabilities" TEXT NOT NULL DEFAULT '{}',
"Configuration" TEXT NOT NULL DEFAULT '{}',
"RowVersion" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT chk_instance_status CHECK ("Status" IN ('Active', 'Shutdown', 'Failed', 'Maintenance'))
);
CREATE INDEX IF NOT EXISTS idx_instances_lastheartbeat ON library."Instances"("LastHeartbeat");
CREATE INDEX IF NOT EXISTS idx_instances_status ON library."Instances"("Status");
CREATE INDEX IF NOT EXISTS idx_instances_isprimary ON library."Instances"("IsPrimary") WHERE "IsPrimary" = TRUE;
CREATE INDEX IF NOT EXISTS idx_instances_host_process ON library."Instances"("Hostname", "ProcessId");
-- 2. Create Distributed Locks table
CREATE TABLE IF NOT EXISTS library."DistributedLocks" (
"LockName" VARCHAR(255) PRIMARY KEY,
"InstanceId" UUID NOT NULL,
"AcquiredAt" TIMESTAMP NOT NULL DEFAULT NOW(),
"ExpiresAt" TIMESTAMP NOT NULL,
"RenewedAt" TIMESTAMP NOT NULL DEFAULT NOW(),
"RowVersion" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT fk_lock_instance FOREIGN KEY ("InstanceId")
REFERENCES library."Instances"("InstanceId") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_locks_expiration ON library."DistributedLocks"("ExpiresAt");
CREATE INDEX IF NOT EXISTS idx_locks_instance ON library."DistributedLocks"("InstanceId");
CREATE INDEX IF NOT EXISTS idx_locks_expiration_instance ON library."DistributedLocks"("ExpiresAt", "InstanceId");
-- 3. Add InstanceId to ActivityLog
ALTER TABLE activitylog."ActivityLog" ADD COLUMN IF NOT EXISTS "InstanceId" UUID;
CREATE INDEX IF NOT EXISTS idx_activitylog_instance ON activitylog."ActivityLog"("InstanceId");
-- 4. Add InstanceId to Devices for session tracking
ALTER TABLE library."Devices" ADD COLUMN IF NOT EXISTS "InstanceId" UUID;
CREATE INDEX IF NOT EXISTS idx_devices_instance ON library."Devices"("InstanceId");
-- 5. Create File System Changes table (Phase 6)
CREATE TABLE IF NOT EXISTS library."FileSystemChanges" (
"Id" BIGSERIAL PRIMARY KEY,
"Path" TEXT NOT NULL,
"ChangeType" VARCHAR(50) NOT NULL,
"DetectedAt" TIMESTAMP NOT NULL DEFAULT NOW(),
"DetectedBy" UUID NOT NULL,
"ProcessedAt" TIMESTAMP,
"ProcessedBy" UUID,
"LibraryId" UUID,
"Error" TEXT,
"OldPath" TEXT,
CONSTRAINT fk_fschange_detectedby FOREIGN KEY ("DetectedBy")
REFERENCES library."Instances"("InstanceId") ON DELETE CASCADE,
CONSTRAINT fk_fschange_processedby FOREIGN KEY ("ProcessedBy")
REFERENCES library."Instances"("InstanceId") ON DELETE SET NULL,
CONSTRAINT chk_filesystemchange_type CHECK ("ChangeType" IN ('Created', 'Modified', 'Deleted', 'Renamed'))
);
CREATE INDEX IF NOT EXISTS idx_filesystemchanges_unprocessed ON library."FileSystemChanges"("ProcessedAt")
WHERE "ProcessedAt" IS NULL;
CREATE INDEX IF NOT EXISTS idx_filesystemchanges_detectedat ON library."FileSystemChanges"("DetectedAt");
CREATE INDEX IF NOT EXISTS idx_filesystemchanges_path ON library."FileSystemChanges"("Path");
CREATE INDEX IF NOT EXISTS idx_filesystemchanges_library ON library."FileSystemChanges"("LibraryId")
WHERE "LibraryId" IS NOT NULL;
-- 6. Function to cleanup stale instances
CREATE OR REPLACE FUNCTION library.cleanup_stale_instances()
RETURNS INTEGER AS $$
DECLARE
updated_count INTEGER;
BEGIN
UPDATE library."Instances"
SET "Status" = 'Failed'
WHERE "Status" = 'Active'
AND "LastHeartbeat" < NOW() - INTERVAL '2 minutes';
GET DIAGNOSTICS updated_count = ROW_COUNT;
RETURN updated_count;
END;
$$ LANGUAGE plpgsql;
-- 7. Function to get primary instance
CREATE OR REPLACE FUNCTION library.get_primary_instance()
RETURNS UUID AS $$
DECLARE
primary_id UUID;
BEGIN
SELECT "InstanceId" INTO primary_id
FROM library."Instances"
WHERE "Status" = 'Active'
AND "IsPrimary" = TRUE
AND "LastHeartbeat" > NOW() - INTERVAL '1 minute'
LIMIT 1;
RETURN primary_id;
END;
$$ LANGUAGE plpgsql;
-- 8. Function to elect primary instance
CREATE OR REPLACE FUNCTION library.elect_primary_instance()
RETURNS UUID AS $$
DECLARE
elected_id UUID;
BEGIN
-- Clear any existing primary that's not active
UPDATE library."Instances"
SET "IsPrimary" = FALSE
WHERE "IsPrimary" = TRUE
AND ("Status" != 'Active' OR "LastHeartbeat" < NOW() - INTERVAL '1 minute');
-- Check if we have an active primary
SELECT "InstanceId" INTO elected_id
FROM library."Instances"
WHERE "Status" = 'Active'
AND "IsPrimary" = TRUE
AND "LastHeartbeat" > NOW() - INTERVAL '1 minute'
LIMIT 1;
-- If no primary, elect the oldest active instance
IF elected_id IS NULL THEN
SELECT "InstanceId" INTO elected_id
FROM library."Instances"
WHERE "Status" = 'Active'
AND "LastHeartbeat" > NOW() - INTERVAL '1 minute'
ORDER BY "StartedAt" ASC
LIMIT 1;
IF elected_id IS NOT NULL THEN
UPDATE library."Instances"
SET "IsPrimary" = TRUE
WHERE "InstanceId" = elected_id;
END IF;
END IF;
RETURN elected_id;
END;
$$ LANGUAGE plpgsql;
-- 9. Function to cleanup old processed file system changes
CREATE OR REPLACE FUNCTION library.cleanup_old_filesystem_changes()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
-- Delete processed changes older than 7 days
DELETE FROM library."FileSystemChanges"
WHERE "ProcessedAt" IS NOT NULL
AND "ProcessedAt" < NOW() - INTERVAL '7 days';
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- 10. Grant permissions
GRANT ALL ON TABLE library."Instances" TO jellyfin;
GRANT ALL ON TABLE library."DistributedLocks" TO jellyfin;
GRANT ALL ON TABLE library."FileSystemChanges" TO jellyfin;
GRANT ALL ON SEQUENCE library."FileSystemChanges_Id_seq" TO jellyfin;
GRANT EXECUTE ON FUNCTION library.cleanup_stale_instances() TO jellyfin;
GRANT EXECUTE ON FUNCTION library.get_primary_instance() TO jellyfin;
GRANT EXECUTE ON FUNCTION library.elect_primary_instance() TO jellyfin;
GRANT EXECUTE ON FUNCTION library.cleanup_old_filesystem_changes() TO jellyfin;
COMMIT;
-- Verification queries
\echo '--- Verification ---'
\echo 'Checking Instances table...'
SELECT COUNT(*) AS instance_count FROM library."Instances";
\echo 'Checking DistributedLocks table...'
SELECT COUNT(*) AS lock_count FROM library."DistributedLocks";
\echo 'Checking FileSystemChanges table...'
SELECT COUNT(*) AS fschange_count FROM library."FileSystemChanges";
\echo 'Checking Devices table for InstanceId column...'
SELECT COUNT(*) AS devices_with_instance FROM library."Devices" WHERE "InstanceId" IS NOT NULL;
\echo 'Testing cleanup function...'
SELECT library.cleanup_stale_instances() AS stale_instances_cleaned;
\echo ''
\echo '✅ Multi-instance support tables and functions created successfully!'
\echo 'Next step: Start Jellyfin instances with EnableMultiInstance=true in startup.json'
+3 -5
View File
@@ -265,13 +265,11 @@ BEGIN
ORDER BY mean_exec_time DESC
LIMIT 10
LOOP
RAISE NOTICE 'Calls: %, Avg: %ms, Max: %ms, Total: %ms, %%: %, Query: %',
RAISE NOTICE 'Calls: %, Avg: %ms, Max: %ms, Total: %ms, Percent: %%',
query_rec.calls,
query_rec.avg_time_ms,
query_rec.max_time_ms,
query_rec.total_time_ms,
query_rec.percent_total,
query_rec.query_preview;
query_rec.total_time_ms;
END LOOP;
END IF;
END $$;
@@ -341,7 +339,7 @@ BEGIN
FROM pg_stat_database WHERE datname = 'jellyfin';
IF hit_ratio < 95 THEN
RAISE WARNING 'Low cache hit ratio: %%. Target: >95%%', hit_ratio;
RAISE WARNING 'Low cache hit ratio: %. Target: >95', hit_ratio;
RAISE NOTICE 'RECOMMENDATION: Increase shared_buffers and effective_cache_size';
END IF;
+2 -2
View File
@@ -191,9 +191,9 @@ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'activitylog'
AND table_name = 'ActivityLog'
AND table_name = 'ActivityLogs'
) THEN
ANALYZE activitylog."ActivityLog";
ANALYZE activitylog."ActivityLogs";
RAISE NOTICE 'ActivityLog analyzed';
END IF;
END $$;