Der Kratzplatz räumt sich auf

39,1 GB in FLARO_TRANSCODE, und das war kein Normalzustand, sondern ein
Leck: für jede Mitnahme baut der Server eine MP4 in Quellgrösse und löschte
sie nie. Die einzige Löschung, die es gab, räumte ältere Fassungen
derselben Datei weg. Stromordner verschwanden nur beim geordneten
Herunterfahren, Untertitel gar nicht.

**Zuerst: fertig heisst fertig.** ffmpeg schrieb direkt auf den Endnamen.
Nach einem harten Abbruch lag dort eine abgeschnittene Datei, `IsReady`
meldete „fertig", und sie ging so hinaus — und ein Aufräumer konnte
vollständig und abgeschnitten nicht unterscheiden. Gebaut wird jetzt unter
`.teil`, umbenannt erst nach Erfolg. Dabei muss das Format ausdrücklich
gesetzt werden: ffmpeg wählt den Muxer an der Endung und antwortet auf
`.teil` mit „Unable to choose an output format". Das hätte jedes Umpacken
zerlegt; die Prüfung hat es gefunden, bevor es jemand zu spüren bekam.

**Zwei Stufen für die Pakete.** Verwaiste gehen bedingungslos: unleserlicher
Name, fremde Bauart, kein Eintrag in der Mediathek, oder der Zeitstempel im
Namen passt nicht mehr. Der Rest fällt unter Obergrenze (20 GB) und Alter
(7 Tage), Ältestes zuerst.

Beides, und nicht eines: ein Alter allein löst den Fall nicht, denn die 39
GB waren gültige, aktuelle Dateien, jede für sich berechtigt. Eine
Obergrenze allein ist zu grob — ein Topf knapp darunter, in dem seit Wochen
nichts gebraucht wurde, besetzt ein Volume, das anderes braucht.

„Zuletzt gebraucht" ist die Änderungszeit der Paketdatei selbst, bei jeder
Auslieferung neu gestempelt. Die Zugriffszeit wäre das Naheliegende, nur
führt sie nicht jedes Dateisystem — mit `noatime` steht sie still, und der
Aufräumer hielte alles für uralt.

Ein laufender Download wird nie gelöscht: ein Benutzungszähler hält die
Datei fest, solange die Antwort läuft, dazu eine Schonfrist von 30 Minuten.
Der Zähler ist auch unter Linux nötig — der laufende Strom überlebt ein
Entfernen, die Fortsetzung einer Bereichsanfrage nicht, und der Client finge
von vorn an.

**Stromordner** werden beim Start bedingungslos geleert — nach einem Start
gibt es keine einzige Sitzung, also ist jeder Ordner verwaist. Im Betrieb
nur die ohne passende Sitzung, deren Änderungszeit älter ist als die
Geduld; das fängt auch den Fall, dass `Abraeumen` an einem IOException
scheitert und der Ordner für immer liegenbleibt.

**Untertitel** nur, wenn sie verwaist sind. Keine Obergrenze: eine .vtt ist
Kilobytes, und eine gültige wird wieder gebraucht — sie nach Platz zu
löschen hiesse, Minuten Lesearbeit gegen Megabytes zu tauschen. Eine leere
bleibt ausdrücklich liegen: sie ist das Ergebnis „diese Spur hat keinen
Text", und wer sie wegräumt, lässt bei jedem Start eine vierzig Gigabyte
grosse Datei neu durchlesen.

**Vier Anlässe, kein Dienst.** Start, vor jedem Umpacken, nach einem Scan,
auf Knopfdruck — wie alles Wiederkehrende in diesem Server. Einer zur Zeit;
wer nicht hineinkommt, geht weiter.

Gemessen am laufenden Server mit Attrappen:

    vorher    packages 17408 KB (4), untertitel 5 KB (3), 1 Stromordner
    nachher   14 MB freigegeben, 4 Dateien
              gültiges Paket blieb, verwaistes/veraltetes/Teilstück weg,
              verwaiste .vtt weg, gültige blieben, Stromordner weg

    Obergrenze 1 MB, Datei in der Schonfrist   -> bleibt
    Obergrenze 1 MB, Schonfrist vorbei         -> 3072 KB freigegeben

In der Übersicht steht die Belegung je Unterordner mit Dateizahl und
daneben „Jetzt aufräumen"; die drei Zahlen stehen unter Wiedergabe, wo
Transcoder-Stufe und Schwellen schon stehen — der Kratzplatz ist, was die
Wiedergabe hinterlässt.

714 Tests grün, davon 36 neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 19:07:52 +02:00
co-authored by Claude Opus 5
parent 1b4784f1db
commit c4af182d31
19 changed files with 1490 additions and 17 deletions
@@ -0,0 +1,130 @@
namespace Flaro.Core.Delivery;
/// <summary>Wann eine umgepackte Datei weggeräumt wird.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Anlass: 39 Gigabyte.</b> Für jede Mitnahme baut der Server eine
/// MP4 in Quellgrösse und löschte sie nie — die einzige Löschung, die es
/// gab, räumte ältere Fassungen derselben Datei weg. Es gibt im ganzen
/// Server keinen Hintergrunddienst und keinen Takt; also muss die Regel
/// entscheidbar sein, ohne dass jemand mitschreibt.
/// </para>
/// <para>
/// <b>Warum Obergrenze und Alter.</b> Ein Alter allein löst den Fall nicht:
/// die 39 Gigabyte waren gültige, aktuelle Dateien, jede für sich
/// berechtigt. Nur eine Obergrenze deckelt das. Eine Obergrenze allein ist
/// zu grob: ein Topf knapp unter der Grenze, in dem seit Wochen nichts
/// gebraucht wurde, besetzt ein Volume, das anderes braucht.
/// </para>
/// </remarks>
/// <param name="ObergrenzeBytes">Wie viel insgesamt liegen bleiben darf; <c>0</c> heisst unbegrenzt.</param>
/// <param name="Unbenutzt">Ab wann eine unbenutzte Datei auch unterhalb der Grenze geht; <c>Zero</c> heisst nie.</param>
/// <param name="Schonfrist">Wie lange eine frisch gebrauchte Datei in jedem Fall bleibt.</param>
public sealed record Kratzplatzregeln(
long ObergrenzeBytes,
TimeSpan Unbenutzt,
TimeSpan Schonfrist)
{
/// <remarks>
/// Zwanzig Gigabyte: eine Quelldatei sind zwei bis zwanzig, damit liegen
/// zwei bis fünf Filme vor — genug, dass ein abgebrochener und ein
/// zweiter Anlauf dieselbe Datei treffen. Sieben Tage statt einem, damit
/// eine Urlaubswoche keine angefangene Mitnahme kostet. Dreissig Minuten
/// Schonfrist decken die Lücke zwischen zwei Teilanfragen und einen
/// Neustart, der den Benutzungszähler verliert.
/// </remarks>
public static Kratzplatzregeln Vorgabe { get; } =
new(20L * 1024 * 1024 * 1024, TimeSpan.FromDays(7), TimeSpan.FromMinutes(30));
/// <summary>Nimmt entgegen, was eingestellt wurde, und biegt Unsinn zurecht.</summary>
///
/// <remarks>
/// <b>Eine zu kurze Schonfrist ist gefährlicher als gar keine.</b> Wer
/// sie auf eine Minute stellt, löscht sich mitten im Herunterladen die
/// Datei weg — deshalb eine Untergrenze von fünf Minuten. Negative Werte
/// gelten als „aus".
/// </remarks>
public static Kratzplatzregeln Geprueft(double obergrenzeGb, int unbenutztTage, int schonfristMinuten) =>
new(obergrenzeGb <= 0 ? 0 : (long)(obergrenzeGb * 1024 * 1024 * 1024),
unbenutztTage <= 0 ? TimeSpan.Zero : TimeSpan.FromDays(Math.Min(unbenutztTage, 3650)),
TimeSpan.FromMinutes(Math.Clamp(schonfristMinuten, 5, 1440)));
}
/// <summary>Eine Datei im Kratzplatz, so wie sie dort liegt.</summary>
///
/// <param name="Benutzt">
/// Wann sie zuletzt gebraucht wurde — die Änderungszeit der Datei selbst.
/// <b>Und nicht die Zugriffszeit:</b> die führt nicht jedes Dateisystem
/// (<c>noatime</c>), die Änderungszeit schon. Jede Auslieferung stempelt sie
/// neu; nach dem Bauen steht sie ohnehin richtig.
/// </param>
public readonly record struct Paketakte(
string Pfad,
long Groesse,
DateTimeOffset Benutzt,
Paketname.Teile? Name);
/// <summary>Welche Dateien weggeräumt werden — reine Rechnung, ohne Platte.</summary>
public static class Kratzplatzauswahl
{
/// <param name="bekannt">Kennung → Änderungszeitpunkt der Quelle, aus der Mediathek.</param>
/// <param name="bauart">Wie der Server heute baut.</param>
/// <param name="inBenutzung">Ob gerade jemand diese Datei herunterlädt.</param>
public static IReadOnlyList<Paketakte> Wegzuwerfen(
IReadOnlyList<Paketakte> akten,
IReadOnlyDictionary<Guid, long> bekannt,
int bauart,
Kratzplatzregeln regeln,
DateTimeOffset jetzt,
Func<string, bool> inBenutzung)
{
var weg = new List<Paketakte>();
var behalten = new List<Paketakte>();
// **Stufe eins: Verwaiste, unabhängig vom Platz.** Diese Dateien
// werden nie wieder ausgeliefert — sie zu behalten ist keine
// Abwägung, sondern ein Versehen.
foreach (var a in akten)
{
if (Verwaist(a, bekannt, bauart)) weg.Add(a);
else behalten.Add(a);
}
// Was gerade geholt wird oder eben erst gebraucht wurde, ist tabu.
bool Geschuetzt(Paketakte a) =>
inBenutzung(a.Pfad) || jetzt - a.Benutzt < regeln.Schonfrist;
// **Stufe zwei: zu lange nicht gebraucht.** Gilt auch, wenn noch
// Platz wäre.
if (regeln.Unbenutzt > TimeSpan.Zero)
{
var alt = behalten
.Where(a => !Geschuetzt(a) && jetzt - a.Benutzt > regeln.Unbenutzt)
.ToList();
weg.AddRange(alt);
behalten.RemoveAll(alt.Contains);
}
// **Stufe drei: die Obergrenze, Ältestes zuerst.**
if (regeln.ObergrenzeBytes > 0)
{
var summe = behalten.Sum(a => a.Groesse);
foreach (var a in behalten.Where(a => !Geschuetzt(a)).OrderBy(a => a.Benutzt).ToList())
{
if (summe <= regeln.ObergrenzeBytes) break;
weg.Add(a);
behalten.Remove(a);
summe -= a.Groesse;
}
}
return weg;
}
private static bool Verwaist(Paketakte a, IReadOnlyDictionary<Guid, long> bekannt, int bauart) =>
a.Name is not { } n
|| n.Bauart != bauart
|| !bekannt.TryGetValue(n.DateiId, out var ms)
|| ms != n.GeaendertMs;
}
@@ -0,0 +1,56 @@
using System.Globalization;
namespace Flaro.Core.Delivery;
/// <summary>Der Name einer umgepackten Datei — gebaut und wieder gelesen.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum der Name alles trägt.</b> Im Kratzplatz liegen Dateien, zu denen
/// es keine Tabelle gibt. Ein Aufräumer muss allein am Namen erkennen können,
/// wozu eine Datei gehört und ob sie noch gilt — sonst bliebe ihm nur,
/// alles zu behalten oder alles wegzuwerfen.
/// </para>
/// <para>
/// <b>Drei Angaben.</b> Die Kennung der Mediendatei, der Änderungszeitpunkt
/// der Quelle und die Bauart. Die ersten beiden sagen „gehört zu dieser Datei
/// in genau diesem Stand", die dritte „gebaut mit diesen Regeln". Ändert sich
/// eine davon, heisst das Ergebnis anders und die alte Fassung wird nicht
/// mehr ausgeliefert.
/// </para>
/// <para>
/// <b>Gebaut wird unter <c>.teil</c>.</b> Vorher schrieb ffmpeg direkt auf
/// den Endnamen. Nach einem harten Abbruch lag dort eine abgeschnittene
/// Datei, die als fertig galt und ausgeliefert wurde — und ein Aufräumer
/// konnte vollständig und abgeschnitten nicht unterscheiden. Umbenennen ist
/// auf demselben Dateisystem ein Zug und kann nicht halb geschehen.
/// </para>
/// </remarks>
public static class Paketname
{
public const string Endung = ".mp4";
public const string Teilendung = ".mp4.teil";
public static string Bauen(Guid dateiId, DateTimeOffset geaendert, int bauart) =>
string.Create(CultureInfo.InvariantCulture,
$"{dateiId:N}-{geaendert.ToUnixTimeMilliseconds()}-{bauart}{Endung}");
/// <summary>Was in einem Namen steht — oder <c>null</c>, wenn er keiner ist.</summary>
public readonly record struct Teile(Guid DateiId, long GeaendertMs, int Bauart);
/// <summary>Liest einen Dateinamen; der Pfad davor stört nicht.</summary>
public static Teile? Lesen(string pfadOderName)
{
var name = Path.GetFileName(pfadOderName);
if (!name.EndsWith(Endung, StringComparison.OrdinalIgnoreCase)) return null;
var kern = name[..^Endung.Length].Split('-');
if (kern.Length != 3) return null;
if (!Guid.TryParseExact(kern[0], "N", out var id)) return null;
if (!long.TryParse(kern[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms)) return null;
if (!int.TryParse(kern[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var bauart)) return null;
return new Teile(id, ms, bauart);
}
}