using System.Collections.Concurrent; using System.Globalization; using Flaro.Core; using Flaro.Data; using Microsoft.EntityFrameworkCore; namespace Flaro.Api; /// Was in Abständen geschieht: sichern, aufräumen, Bilder wegwerfen. /// /// /// /// Der erste Hintergrunddienst in diesem Server, und das mit Bedacht. /// An drei Stellen steht sinngemäss, ein eigener Dienst wäre Aufwand ohne /// Gegenwert — und für das Wegräumen abgelaufener Gerätecodes stimmt das: /// solche Arbeit hängt an einem Aufruf, der ohnehin kommt. Eine Sicherung /// hängt an nichts. Sie soll alle drei Tage geschehen, auch wenn niemand /// etwas anklickt, und genau dafür gibt es eine Uhr. /// /// /// Die Uhr tickt, der Dienst rechnet nicht. Alle fünfzehn Minuten /// eine Frage an die Einstellungen, ob etwas fällig ist. Ist nichts fällig /// — der Normalfall —, kostet das eine Abfrage auf eine Tabelle mit einer /// Handvoll Zeilen. /// /// /// Der letzte Lauf steht in der Datenbank, nicht im Arbeitsspeicher. /// Ein Server, der jede Nacht neu startet, sicherte sonst jeden Morgen — /// oder, bei einem Zähler, der bei null anfängt, nie. /// /// public sealed class Wartungsplan( IServiceScopeFactory scopes, FlaroPaths pfade, TimeProvider uhr, Tagebuch tagebuch, ILogger log) : BackgroundService { /// Wie oft nachgesehen wird, ob etwas fällig ist. /// /// /// Eine Viertelstunde ist fein genug für Abstände in Tagen und grob /// genug, dass das Nachsehen selbst nicht auffällt. /// private static readonly TimeSpan Takt = TimeSpan.FromMinutes(15); /// Was zuletzt herauskam — fürs Dashboard, nicht für die Entscheidung. private readonly ConcurrentDictionary _zuletzt = new(); private readonly SemaphoreSlim _einerZurZeit = new(1, 1); protected override async Task ExecuteAsync(CancellationToken ct) { // **Nicht sofort.** Beim Hochfahren hat der Server Wichtigeres zu // tun — Migration, erster Seitenaufbau, Untertitelvorrat. Eine // Sicherung, die zwei Minuten später anfängt, ist keine schlechtere. try { await Task.Delay(TimeSpan.FromMinutes(2), ct); } catch (OperationCanceledException) { return; } using var takt = new PeriodicTimer(Takt); do { try { await FaelligesLaufenAsync(ct); } catch (OperationCanceledException) { return; } catch (Exception fehler) { // Ein Fehler in der Wartung darf den Dienst nicht beenden — // sonst fällt mit der ersten vollen Platte auch die // Sicherung für immer aus. log.LogError(fehler, "Der Wartungslauf ist gescheitert."); } } while (await takt.WaitForNextTickAsync(ct)); } /// Führt aus, was fällig ist. public async Task FaelligesLaufenAsync(CancellationToken ct) { using var raum = scopes.CreateScope(); var db = raum.ServiceProvider.GetRequiredService(); var regeln = await raum.ServiceProvider .GetRequiredService().RegelnAsync(ct); var jetzt = uhr.GetUtcNow(); var stand = await Wartungsplaneinstellungen.Werte(db, ct); foreach (var (aufgabe, tage) in new[] { (Wartungsaufgabe.Sicherung, regeln.SicherungTage), (Wartungsaufgabe.Optimieren, regeln.OptimierenTage), (Wartungsaufgabe.Bilder, regeln.BilderTage), }) { if (tage <= 0) continue; var zuletzt = Zeit(stand, Wartungsplaneinstellungen.ZuletztSchluessel(aufgabe)); if (zuletzt is { } wann && jetzt - wann < TimeSpan.FromDays(tage)) continue; await LaufenAsync(aufgabe, ct); } } /// Führt eine Aufgabe aus, egal ob sie fällig ist. public async Task LaufenAsync(Wartungsaufgabe aufgabe, CancellationToken ct) { await _einerZurZeit.WaitAsync(ct); try { using var raum = scopes.CreateScope(); var db = raum.ServiceProvider.GetRequiredService(); var regeln = await raum.ServiceProvider .GetRequiredService().RegelnAsync(ct); string ergebnis; try { ergebnis = aufgabe switch { Wartungsaufgabe.Sicherung => await SichernAsync(db, regeln, ct), Wartungsaufgabe.Optimieren => await OptimierenAsync(db, ct), Wartungsaufgabe.Bilder => await BilderAufraeumenAsync(db, ct), _ => "Unbekannte Aufgabe.", }; _zuletzt[aufgabe] = (ergebnis, null); tagebuch.Schreiben($"Wartung — {aufgabe}: {ergebnis}"); } catch (Exception fehler) { _zuletzt[aufgabe] = (null, Fehlertext.FuerMenschen(fehler)); log.LogError(fehler, "Die Wartungsaufgabe {Aufgabe} ist gescheitert.", aufgabe); tagebuch.Schreiben($"Wartung — {aufgabe} gescheitert: {Fehlertext.FuerMenschen(fehler)}", Tagebuch.Art.Fehler); throw; } finally { // **Auch nach einem Fehlschlag merken.** Sonst versucht der // Plan es alle fünfzehn Minuten wieder, und eine volle // Platte erzeugt ein volles Protokoll. await Merken(db, Wartungsplaneinstellungen.ZuletztSchluessel(aufgabe), uhr.GetUtcNow().ToString("O", CultureInfo.InvariantCulture), ct); } return ergebnis; } finally { _einerZurZeit.Release(); } } /// Der Stand aller Aufgaben — für die Oberfläche. public async Task> StandAsync(CancellationToken ct) { using var raum = scopes.CreateScope(); var db = raum.ServiceProvider.GetRequiredService(); var regeln = await raum.ServiceProvider .GetRequiredService().RegelnAsync(ct); var werte = await Wartungsplaneinstellungen.Werte(db, ct); return new[] { (Wartungsaufgabe.Sicherung, regeln.SicherungTage), (Wartungsaufgabe.Optimieren, regeln.OptimierenTage), (Wartungsaufgabe.Bilder, regeln.BilderTage), }.Select(x => { var zuletzt = Zeit(werte, Wartungsplaneinstellungen.ZuletztSchluessel(x.Item1)); var (ergebnis, fehler) = _zuletzt.GetValueOrDefault(x.Item1); return new Wartungsstand( x.Item1.ToString(), x.Item2, zuletzt, x.Item2 > 0 ? (zuletzt ?? uhr.GetUtcNow()).AddDays(x.Item2) : null, ergebnis, fehler); }).ToList(); } /* ---------- Die Aufgaben ---------- */ /// Eine Kopie der Datenbank, in sich stimmig. /// /// /// VACUUM INTO statt Datei kopieren. Die Datenbank läuft im /// WAL-Modus: neben flaro.db stehen -wal und -shm, /// und wer nur die erste Datei kopiert, bekommt einen Stand von vor /// Stunden — oder einen halben. VACUUM INTO schreibt eine /// vollständige, aufgeräumte Kopie, während der Betrieb weiterläuft. /// private async Task SichernAsync(FlaroContext db, Wartungsregeln regeln, CancellationToken ct) { var ordner = regeln.SicherungOrdner.Length > 0 ? regeln.SicherungOrdner : Path.Combine(pfade.Data, "sicherungen"); Directory.CreateDirectory(ordner); var name = $"flaro-{uhr.GetUtcNow():yyyy-MM-dd-HHmm}.db"; var ziel = Path.Combine(ordner, name); // VACUUM INTO legt die Datei selbst an und bricht ab, wenn sie steht. if (File.Exists(ziel)) File.Delete(ziel); // **Als Parameter, nicht in die Anweisung geschrieben.** Der Ordner // ist eine Einstellung; ihn in den Text zu kleben hiesse, eine // Einstellung zu einer SQL-Anweisung zu machen. SQLite nimmt an // dieser Stelle einen gebundenen Wert. await db.Database.ExecuteSqlRawAsync("VACUUM INTO {0}", [ziel], ct); var weg = Aufbewahren(ordner, regeln.SicherungBehalten); var gross = new FileInfo(ziel).Length; return $"{name}, {gross / (1024 * 1024)} MB" + (weg > 0 ? $", {weg} ältere entfernt" : ""); } /// Behält die neuesten Sicherungen und wirft den Rest weg. private int Aufbewahren(string ordner, int behalten) { var weg = 0; foreach (var alt in Directory.EnumerateFiles(ordner, "flaro-*.db") .OrderByDescending(d => d) .Skip(behalten)) { try { File.Delete(alt); weg++; } catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) { log.LogDebug(fehler, "Die alte Sicherung {Datei} liess sich nicht löschen.", alt); } } return weg; } /// Räumt die Datenbank auf. /// /// /// PRAGMA optimize frischt die Statistiken auf, nach denen SQLite /// seine Abfragepläne wählt; VACUUM packt die Datei zusammen. /// Beides sperrt kurz — deshalb nicht bei jedem Start, sondern nach Plan. /// private async Task OptimierenAsync(FlaroContext db, CancellationToken ct) { var pfad = pfade.DatabaseFile; var vorher = File.Exists(pfad) ? new FileInfo(pfad).Length : 0; await db.Database.ExecuteSqlRawAsync("PRAGMA optimize;", ct); await db.Database.ExecuteSqlRawAsync("VACUUM;", ct); var nachher = File.Exists(pfad) ? new FileInfo(pfad).Length : 0; var frei = vorher - nachher; return frei > 0 ? $"{frei / 1024} KB kleiner" : "nichts zusammenzupacken"; } /// Wirft Bilder weg, auf die kein Titel mehr zeigt. /// /// /// Warum nicht einfach alles. Den Bildspeicher zu leeren gibt es /// als Knopf, und es ist gefahrlos — nur holt der Server danach jedes /// Plakat neu, und das sind bei achthundert Titeln ein paar tausend /// Abrufe bei TMDB. Hier fliegt nur, worauf nichts mehr zeigt. /// /// Eine Stunde Schonfrist. Ein Bild wird abgelegt, bevor der /// Verweis darauf gespeichert ist. Ohne diese Frist räumte ein Lauf /// mitten im Scan genau das weg, was gerade geholt wurde. /// private async Task BilderAufraeumenAsync(FlaroContext db, CancellationToken ct) { var ordner = Path.Combine(pfade.Cache, "images"); if (!Directory.Exists(ordner)) return "kein Bildspeicher da"; var benutzt = new HashSet(StringComparer.Ordinal); void Sammeln(IEnumerable schluessel) { foreach (var s in schluessel) if (!string.IsNullOrEmpty(s)) benutzt.Add(s); } // **Drei Spalten je Zeile, nicht drei Abfragen.** `SelectMany` über // ein frisch gebautes Feld lässt sich nicht nach SQL übersetzen — // EF wirft dafür. Also je Zeile ein Tripel holen und hier auffalten. foreach (var m in await db.Movies.AsNoTracking() .Select(m => new { m.PosterImage, m.BackdropImage, m.LogoImage }).ToListAsync(ct)) { Sammeln([m.PosterImage, m.BackdropImage, m.LogoImage]); } foreach (var r in await db.Series.AsNoTracking() .Select(r => new { r.PosterImage, r.BackdropImage, r.LogoImage }).ToListAsync(ct)) { Sammeln([r.PosterImage, r.BackdropImage, r.LogoImage]); } Sammeln(await db.Episodes.AsNoTracking().Select(e => e.StillImage).ToListAsync(ct)); Sammeln(await db.People.AsNoTracking().Select(p => p.Image).ToListAsync(ct)); Sammeln(await db.Users.AsNoTracking().Select(u => u.AvatarImage).ToListAsync(ct)); var jetzt = uhr.GetUtcNow(); long frei = 0; var zahl = 0; foreach (var datei in Directory.EnumerateFiles(ordner, "*.img", SearchOption.AllDirectories)) { var schluessel = Path.GetFileNameWithoutExtension(datei); if (benutzt.Contains(schluessel)) continue; try { var info = new FileInfo(datei); if (jetzt - new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero) < TimeSpan.FromHours(1)) continue; var gross = info.Length; File.Delete(datei); frei += gross; zahl++; } catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) { log.LogDebug(fehler, "{Datei} liess sich nicht löschen.", datei); } } return zahl == 0 ? "nichts verwaist" : $"{zahl} Bild(er), {frei / 1024} KB frei"; } /* ---------- Kleinkram ---------- */ private static DateTimeOffset? Zeit(IReadOnlyDictionary werte, string schluessel) => werte.TryGetValue(schluessel, out var roh) && DateTimeOffset.TryParse(roh, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var wann) ? wann : null; private static async Task Merken(FlaroContext db, string schluessel, string wert, CancellationToken ct) { var zeile = await db.Settings.FindAsync([schluessel], ct); if (zeile is null) db.Settings.Add(new ServerSetting { Key = schluessel, Value = wert }); else zeile.Value = wert; await db.SaveChangesAsync(ct); } }