diff --git a/.github/upgrades/scenarios/new-dotnet-version_02bc64/scenario.json b/.github/upgrades/scenarios/new-dotnet-version_02bc64/scenario.json index 984faf6b..1d47742c 100644 --- a/.github/upgrades/scenarios/new-dotnet-version_02bc64/scenario.json +++ b/.github/upgrades/scenarios/new-dotnet-version_02bc64/scenario.json @@ -3,7 +3,7 @@ "operationId": "02bc6448-a69d-4ff4-a0ec-0db07ec3a09a", "description": "Upgrade solution or project to new version of .NET", "startTime": "2026-03-05T22:18:32.3921759Z", - "lastUpdateTime": "2026-03-08T14:46:31.3820237Z", + "lastUpdateTime": "2026-03-08T15:04:52.4729284Z", "stage": "Execution", "properties": {}, "folderPath": "" diff --git a/sql/Jellyfin index cleanup plan.sql b/sql/Jellyfin index cleanup plan.sql new file mode 100644 index 00000000..6f0ba25f --- /dev/null +++ b/sql/Jellyfin index cleanup plan.sql @@ -0,0 +1,81 @@ +yes-- Jellyfin index cleanup plan (conservative, phased) +-- Generated: 2026-03-08 +-- Notes: +-- 1) This script does NOT run automatically; execute sections manually. +-- 2) Use DROP INDEX CONCURRENTLY to avoid long blocking. +-- 3) Do not run inside an explicit transaction block. +-- 4) Re-check stats after each phase before continuing. + +/* ------------------------------------------------------------------------- + Baseline: capture current index usage before any change +--------------------------------------------------------------------------- */ +SELECT + now() AS captured_at, + schemaname, + relname AS table_name, + indexrelname AS index_name, + idx_scan, + idx_tup_read, + idx_tup_fetch, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size +FROM pg_stat_user_indexes +WHERE schemaname IN ('library', 'authentication', 'users', 'activitylog', 'displaypreferences') +ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC; + +/* ------------------------------------------------------------------------- + Phase 1 (lowest risk): redundant MediaStreamInfos non-unique indexes + Rationale: all 0 scans since last stats reset, strong overlap among keys. +--------------------------------------------------------------------------- */ +-- Run one statement at a time and observe workload/latency between statements. +DROP INDEX CONCURRENTLY IF EXISTS library."IX_MediaStreamInfos_StreamIndex_StreamType_Language"; +DROP INDEX CONCURRENTLY IF EXISTS library."IX_MediaStreamInfos_StreamIndex_StreamType"; +DROP INDEX CONCURRENTLY IF EXISTS library."IX_MediaStreamInfos_StreamIndex"; + +/* ------------------------------------------------------------------------- + Verification checkpoint after Phase 1 +--------------------------------------------------------------------------- */ +SELECT + now() AS check_time, + schemaname, + relname AS table_name, + indexrelname AS index_name, + idx_scan, + idx_tup_read, + idx_tup_fetch, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size +FROM pg_stat_user_indexes +WHERE schemaname = 'library' + AND relname = 'MediaStreamInfos' +ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC; + +/* ------------------------------------------------------------------------- + Phase 2 (medium risk): overlapping BaseItemProviders helper indexes + Rationale: all 0 scans since last stats reset; candidates may be redundant + with PK and alternative access paths, but monitor query plans after each. +--------------------------------------------------------------------------- */ +DROP INDEX CONCURRENTLY IF EXISTS library.baseitemproviders_providerid_idx; +DROP INDEX CONCURRENTLY IF EXISTS library.baseitemproviders_providervalue_idx; + +-- Optional in Phase 2b (only if workload remains healthy after Phase 2): +-- DROP INDEX CONCURRENTLY IF EXISTS library."IX_BaseItemProviders_ProviderId_ProviderValue_ItemId"; + +/* ------------------------------------------------------------------------- + Final review query: confirm remaining low-scan indexes +--------------------------------------------------------------------------- */ +SELECT + schemaname, + relname AS table_name, + indexrelname AS index_name, + idx_scan, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size, + pg_get_indexdef(indexrelid) AS index_definition +FROM pg_stat_user_indexes +WHERE schemaname IN ('library', 'authentication', 'users', 'activitylog', 'displaypreferences') +ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC; + +/* ------------------------------------------------------------------------- + Explicitly excluded from removal in this plan: + - Unique and PK-backed indexes (constraint critical) + - IX_Users_Username + - IX_DeviceOptions_DeviceId +--------------------------------------------------------------------------- */ \ No newline at end of file diff --git a/sql/Jellyfin strict index-removal runbook.sql b/sql/Jellyfin strict index-removal runbook.sql new file mode 100644 index 00000000..cfb0b20a --- /dev/null +++ b/sql/Jellyfin strict index-removal runbook.sql @@ -0,0 +1,183 @@ +-- Jellyfin strict index-removal runbook +-- Strategy: exactly ONE DROP per maintenance window. +-- Generated: 2026-03-08 +-- +-- Important: +-- 1) Execute only one window per maintenance period. +-- 2) Keep each statement standalone (no BEGIN/COMMIT around CONCURRENTLY). +-- 3) If regression appears, run that window's rollback CREATE INDEX CONCURRENTLY. + +/* ===================================================================== + Shared baseline (run before each window) + ===================================================================== */ +SELECT + now() AS captured_at, + schemaname, + relname AS table_name, + indexrelname AS index_name, + idx_scan, + idx_tup_read, + idx_tup_fetch, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size +FROM pg_stat_user_indexes +WHERE schemaname IN ('library', 'authentication', 'users', 'activitylog', 'displaypreferences') +ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC; + +/* ===================================================================== + WINDOW 1 (lowest risk first) + Target: library.IX_MediaStreamInfos_StreamIndex_StreamType_Language + ===================================================================== */ +-- Drop: +DROP INDEX CONCURRENTLY IF EXISTS library."IX_MediaStreamInfos_StreamIndex_StreamType_Language"; + +-- Verify impact: +SELECT + now() AS check_time, + relname AS table_name, + indexrelname AS index_name, + idx_scan, + idx_tup_read, + idx_tup_fetch, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size +FROM pg_stat_user_indexes +WHERE schemaname = 'library' + AND relname = 'MediaStreamInfos' +ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC; + +-- Rollback (recreate): +CREATE INDEX CONCURRENTLY "IX_MediaStreamInfos_StreamIndex_StreamType_Language" + ON library."MediaStreamInfos" USING btree ("StreamIndex", "StreamType", "Language"); + +/* ===================================================================== + WINDOW 2 + Target: library.IX_MediaStreamInfos_StreamIndex_StreamType + ===================================================================== */ +-- Drop: +DROP INDEX CONCURRENTLY IF EXISTS library."IX_MediaStreamInfos_StreamIndex_StreamType"; + +-- Verify impact: +SELECT + now() AS check_time, + relname AS table_name, + indexrelname AS index_name, + idx_scan, + idx_tup_read, + idx_tup_fetch, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size +FROM pg_stat_user_indexes +WHERE schemaname = 'library' + AND relname = 'MediaStreamInfos' +ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC; + +-- Rollback (recreate): +CREATE INDEX CONCURRENTLY "IX_MediaStreamInfos_StreamIndex_StreamType" + ON library."MediaStreamInfos" USING btree ("StreamIndex", "StreamType"); + +/* ===================================================================== + WINDOW 3 + Target: library.IX_MediaStreamInfos_StreamIndex + ===================================================================== */ +-- Drop: +DROP INDEX CONCURRENTLY IF EXISTS library."IX_MediaStreamInfos_StreamIndex"; + +-- Verify impact: +SELECT + now() AS check_time, + relname AS table_name, + indexrelname AS index_name, + idx_scan, + idx_tup_read, + idx_tup_fetch, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size +FROM pg_stat_user_indexes +WHERE schemaname = 'library' + AND relname = 'MediaStreamInfos' +ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC; + +-- Rollback (recreate): +CREATE INDEX CONCURRENTLY "IX_MediaStreamInfos_StreamIndex" + ON library."MediaStreamInfos" USING btree ("StreamIndex"); + +/* ===================================================================== + WINDOW 4 (medium risk) + Target: library.baseitemproviders_providerid_idx + ===================================================================== */ +-- Drop: +DROP INDEX CONCURRENTLY IF EXISTS library.baseitemproviders_providerid_idx; + +-- Verify impact: +SELECT + now() AS check_time, + relname AS table_name, + indexrelname AS index_name, + idx_scan, + idx_tup_read, + idx_tup_fetch, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size +FROM pg_stat_user_indexes +WHERE schemaname = 'library' + AND relname = 'BaseItemProviders' +ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC; + +-- Rollback (recreate): +CREATE INDEX CONCURRENTLY baseitemproviders_providerid_idx + ON library."BaseItemProviders" USING btree ("ProviderId", "ItemId"); + +/* ===================================================================== + WINDOW 5 (medium risk) + Target: library.baseitemproviders_providervalue_idx + ===================================================================== */ +-- Drop: +DROP INDEX CONCURRENTLY IF EXISTS library.baseitemproviders_providervalue_idx; + +-- Verify impact: +SELECT + now() AS check_time, + relname AS table_name, + indexrelname AS index_name, + idx_scan, + idx_tup_read, + idx_tup_fetch, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size +FROM pg_stat_user_indexes +WHERE schemaname = 'library' + AND relname = 'BaseItemProviders' +ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC; + +-- Rollback (recreate): +CREATE INDEX CONCURRENTLY baseitemproviders_providervalue_idx + ON library."BaseItemProviders" USING btree ("ProviderValue", "ProviderId"); + +/* ===================================================================== + WINDOW 6 (highest risk among current candidates; keep for last) + Target: library.IX_BaseItemProviders_ProviderId_ProviderValue_ItemId + ===================================================================== */ +-- Drop: +DROP INDEX CONCURRENTLY IF EXISTS library."IX_BaseItemProviders_ProviderId_ProviderValue_ItemId"; + +-- Verify impact: +SELECT + now() AS check_time, + relname AS table_name, + indexrelname AS index_name, + idx_scan, + idx_tup_read, + idx_tup_fetch, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size +FROM pg_stat_user_indexes +WHERE schemaname = 'library' + AND relname = 'BaseItemProviders' +ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC; + +-- Rollback (recreate): +CREATE INDEX CONCURRENTLY "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId" + ON library."BaseItemProviders" USING btree ("ProviderId", "ProviderValue", "ItemId"); + +/* ===================================================================== + Explicit exclusions (do not remove in this runbook) + ===================================================================== + - Any PK_* index + - Any UNIQUE index enforcing constraints + - users.IX_Users_Username + - authentication.IX_DeviceOptions_DeviceId + ===================================================================== */ \ No newline at end of file diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs index be92c1fa..27d492c6 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs @@ -391,17 +391,18 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider wasConnectionOpened = true; } - // Check if the library schema exists (indicates database is initialized) - var schemaCheckQuery = "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = 'library'"; - await using (var schemaCheckCommand = connection.CreateCommand()) + // Check if database has tables (not just schemas) + // Query for a critical table that must exist - if it doesn't, database needs initialization + var tableCheckQuery = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'users' AND table_name = 'Users' AND table_type = 'BASE TABLE'"; + await using (var tableCheckCommand = connection.CreateCommand()) { - schemaCheckCommand.CommandText = schemaCheckQuery; - var schemaCount = (long?)await schemaCheckCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + tableCheckCommand.CommandText = tableCheckQuery; + var usersTableExists = (long?)await tableCheckCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - if (schemaCount == 0) + if (usersTableExists == 0) { - // Database is empty - initialize from SQL script - logger.LogInformation("Database is empty (library schema not found). Initializing from SQL script..."); + // Database tables don't exist - initialize from SQL script + logger.LogInformation("Database tables not found (users.Users table missing). Initializing from SQL script..."); var schemaScriptPath = Path.Combine(AppContext.BaseDirectory, "sql", "schema_init", "create_database_schema.sql"); @@ -440,7 +441,7 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider } else { - logger.LogInformation("Database schema exists (library schema found)"); + logger.LogInformation("Database tables exist (users.Users table found)"); } }