perf(startup): defer non-critical init off the HTTP listener path

Collect catalog-size-dependent seeding (metadata match queues, legacy
series-group cleanup) and the watch-provider scrobble sweep into a
backgroundInit slice that runs sequentially in a background goroutine after
the server is ready, instead of blocking startup before the listener accepts
connections. Steps log failures and stop early on shutdown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-05-28 17:11:11 -04:00
co-authored by Claude Opus 4.7
parent 196f753d1a
commit dfa0f6861f
+45 -15
View File
@@ -724,6 +724,12 @@ func main() {
pluginService.OnLifecycleChange(appCtx) pluginService.OnLifecycleChange(appCtx)
} }
// backgroundInit collects non-critical startup work (catalog-size-dependent
// seeding, network-bound reconciliation) that must not block the HTTP
// listener. The steps run sequentially in a background goroutine once the
// server is ready to serve. Failures are logged, never fatal.
var backgroundInit []func(context.Context)
// Step 4b: Create metadata service and match worker (if needed). // Step 4b: Create metadata service and match worker (if needed).
var metadataService *metadata.MetadataService var metadataService *metadata.MetadataService
var personRefreshService *metadata.PersonRefreshService var personRefreshService *metadata.PersonRefreshService
@@ -830,23 +836,28 @@ func main() {
matchWorker.SetMovieFileClaimer(movieQueueRepo) matchWorker.SetMovieFileClaimer(movieQueueRepo)
} }
if seriesQueueRepo != nil { if seriesQueueRepo != nil {
if cleaned, err := seriesQueueRepo.CleanupLegacySeriesGroupQueue(appCtx); err != nil {
slog.Warn("failed to clean legacy series group queue rows", "error", err)
} else if cleaned > 0 {
slog.Info("cleaned legacy series group queue rows", "count", cleaned)
}
matchWorker.SetSeriesRootClaimer(seriesQueueRepo, cfg.Matcher.TVSeriesRootQueueEnabled()) matchWorker.SetSeriesRootClaimer(seriesQueueRepo, cfg.Matcher.TVSeriesRootQueueEnabled())
backgroundInit = append(backgroundInit, func(ctx context.Context) {
if cleaned, err := seriesQueueRepo.CleanupLegacySeriesGroupQueue(ctx); err != nil {
slog.Warn("failed to clean legacy series group queue rows", "error", err)
} else if cleaned > 0 {
slog.Info("cleaned legacy series group queue rows", "count", cleaned)
}
})
} }
if deps.FolderRepo != nil { if deps.FolderRepo != nil {
enabledFolders, err := deps.FolderRepo.GetEnabled(appCtx) backgroundInit = append(backgroundInit, func(ctx context.Context) {
if err != nil { start := time.Now()
slog.Warn("failed to seed metadata queues", "error", err) enabledFolders, err := deps.FolderRepo.GetEnabled(ctx)
} else { if err != nil {
slog.Warn("failed to seed metadata queues", "error", err)
return
}
seedMovieQueue := func(folderID int) { seedMovieQueue := func(folderID int) {
if movieQueueRepo == nil { if movieQueueRepo == nil {
return return
} }
if err := movieQueueRepo.SyncForFolder(appCtx, folderID); err != nil { if err := movieQueueRepo.SyncForFolder(ctx, folderID); err != nil {
slog.Warn("failed to seed movie match queue", "folder_id", folderID, "error", err) slog.Warn("failed to seed movie match queue", "folder_id", folderID, "error", err)
} }
} }
@@ -854,7 +865,7 @@ func main() {
if seriesQueueRepo == nil { if seriesQueueRepo == nil {
return return
} }
if err := seriesQueueRepo.SyncForFolder(appCtx, folderID); err != nil { if err := seriesQueueRepo.SyncForFolder(ctx, folderID); err != nil {
slog.Warn("failed to seed series root queue", "folder_id", folderID, "error", err) slog.Warn("failed to seed series root queue", "folder_id", folderID, "error", err)
} }
} }
@@ -872,7 +883,8 @@ func main() {
seedMovieQueue(folder.ID) seedMovieQueue(folder.ID)
} }
} }
} slog.Info("deferred init: metadata match queues seeded", "folders", len(enabledFolders), "duration", time.Since(start))
})
} }
deps.SkippedRootRepo = skippedRootRepo deps.SkippedRootRepo = skippedRootRepo
@@ -1011,9 +1023,11 @@ func main() {
WithMatcher(historyimport.NewMatcher(historyRepo)). WithMatcher(historyimport.NewMatcher(historyRepo)).
WithWatchState(watchstate.NewService(userStoreProvider).WithStableIdentityResolver(historyIdentity)). WithWatchState(watchstate.NewService(userStoreProvider).WithStableIdentityResolver(historyIdentity)).
WithUserStoreProvider(userStoreProvider) WithUserStoreProvider(userStoreProvider)
if err := watchProviderService.SweepOpenScrobbles(appCtx); err != nil { backgroundInit = append(backgroundInit, func(ctx context.Context) {
slog.Warn("failed to sweep open watch provider scrobbles", "error", err) if err := watchProviderService.SweepOpenScrobbles(ctx); err != nil {
} slog.Warn("failed to sweep open watch provider scrobbles", "error", err)
}
})
} }
deps.SessionMgr = sessionMgr deps.SessionMgr = sessionMgr
deps.PlaybackRealtimeHub = playback.NewRealtimeHub() deps.PlaybackRealtimeHub = playback.NewRealtimeHub()
@@ -1653,6 +1667,22 @@ func main() {
compatSrv.IdleTimeout = 120 * time.Second compatSrv.IdleTimeout = 120 * time.Second
} }
// Run non-critical startup work in the background so it doesn't delay the
// HTTP listener from accepting connections. Steps run sequentially and stop
// early if the app context is cancelled (shutdown).
if len(backgroundInit) > 0 {
go func() {
start := time.Now()
for _, step := range backgroundInit {
if appCtx.Err() != nil {
return
}
step(appCtx)
}
slog.Info("deferred startup init completed", "steps", len(backgroundInit), "duration", time.Since(start))
}()
}
errCh := make(chan error, 2) errCh := make(chan error, 2)
go func() { go func() {
slog.Info("HTTP server listening", "addr", cfg.Server.Listen) slog.Info("HTTP server listening", "addr", cfg.Server.Listen)