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:
co-authored by
Claude Opus 5
parent
1b4784f1db
commit
c4af182d31
@@ -0,0 +1,247 @@
|
||||
using Flaro.Core.Delivery;
|
||||
|
||||
namespace Flaro.Core.Tests;
|
||||
|
||||
/// <summary>Welche umgepackten Dateien weggeräumt werden.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Anlass: 39 Gigabyte Kratzplatz.</b> Für jede Mitnahme baut der
|
||||
/// Server eine MP4 in Quellgrösse und löschte sie nie. Hier steht die Regel,
|
||||
/// die das beendet — ohne Platte, ohne Server, damit die Entscheidung selbst
|
||||
/// nachlesbar ist und nicht nur ihr Ergebnis.
|
||||
/// </remarks>
|
||||
public class KratzplatzregelTests
|
||||
{
|
||||
private static readonly DateTimeOffset Jetzt = new(2026, 10, 8, 12, 0, 0, TimeSpan.Zero);
|
||||
private const int Bauart = 2;
|
||||
|
||||
private static readonly Guid EinsId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid ZweiId = Guid.Parse("00000000-0000-0000-0000-000000000002");
|
||||
|
||||
private const long Gigabyte = 1024L * 1024 * 1024;
|
||||
|
||||
/// <summary>Eine Datei, wie sie im Kratzplatz läge.</summary>
|
||||
private static Paketakte Akte(
|
||||
Guid id, long gigabyte, TimeSpan vorWieLange, long? geaendertMs = null, int bauart = Bauart)
|
||||
{
|
||||
var ms = geaendertMs ?? 1_000;
|
||||
var name = Paketname.Bauen(id, DateTimeOffset.FromUnixTimeMilliseconds(ms), bauart);
|
||||
return new Paketakte($"/kratz/packages/{name}", gigabyte * Gigabyte,
|
||||
Jetzt - vorWieLange, Paketname.Lesen(name));
|
||||
}
|
||||
|
||||
/// <summary>Die Mediathek kennt beide Dateien in genau diesem Stand.</summary>
|
||||
private static readonly Dictionary<Guid, long> Bekannt =
|
||||
new() { [EinsId] = 1_000, [ZweiId] = 1_000 };
|
||||
|
||||
private static IReadOnlyList<string> Weg(
|
||||
IReadOnlyList<Paketakte> akten,
|
||||
Kratzplatzregeln? regeln = null,
|
||||
IReadOnlyDictionary<Guid, long>? bekannt = null,
|
||||
Func<string, bool>? inBenutzung = null) =>
|
||||
Kratzplatzauswahl.Wegzuwerfen(
|
||||
akten, bekannt ?? Bekannt, Bauart,
|
||||
regeln ?? Kratzplatzregeln.Vorgabe, Jetzt,
|
||||
inBenutzung ?? (_ => false))
|
||||
.Select(a => a.Pfad).ToList();
|
||||
|
||||
/// <summary>Was nie wieder ausgeliefert wird, geht — auch wenn Platz wäre.</summary>
|
||||
[Test]
|
||||
public void Eine_Datei_ohne_Eintrag_in_der_Mediathek_geht_weg()
|
||||
{
|
||||
var akten = new[] { Akte(EinsId, 1, TimeSpan.FromHours(2)) };
|
||||
|
||||
Assert.That(Weg(akten, bekannt: new Dictionary<Guid, long>()), Has.Count.EqualTo(1),
|
||||
"der Titel ist aus der Mediathek verschwunden, das Paket ist wertlos");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ein_veralteter_Zeitstempel_im_Namen_gilt_als_verwaist()
|
||||
{
|
||||
var akten = new[] { Akte(EinsId, 1, TimeSpan.FromHours(2), geaendertMs: 999) };
|
||||
|
||||
Assert.That(Weg(akten), Has.Count.EqualTo(1),
|
||||
"die Quelle wurde ersetzt; dieses Paket gehört zum alten Stand");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Eine_andere_Bauart_gilt_als_verwaist()
|
||||
{
|
||||
var akten = new[] { Akte(EinsId, 1, TimeSpan.FromHours(2), bauart: 1) };
|
||||
|
||||
Assert.That(Weg(akten), Has.Count.EqualTo(1),
|
||||
"so gebaut würde der Server es heute nicht mehr ausliefern");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ein_unleserlicher_Name_gilt_als_verwaist()
|
||||
{
|
||||
var akten = new[]
|
||||
{
|
||||
new Paketakte("/kratz/packages/irgendwas.mp4", Gigabyte, Jetzt, Paketname.Lesen("irgendwas.mp4")),
|
||||
};
|
||||
|
||||
Assert.That(Weg(akten), Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
/// <summary>Die Schonfrist schützt auch Verwaiste nicht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Sie schützt das, was gleich gebraucht wird. Eine Datei, die niemand
|
||||
/// mehr ausliefern kann, wird nicht gleich gebraucht — sie frisch
|
||||
/// liegenzulassen hiesse, auf etwas zu warten, das nicht kommt.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Eine_frische_Verwaiste_geht_trotzdem()
|
||||
{
|
||||
var akten = new[] { Akte(EinsId, 1, TimeSpan.Zero, bauart: 1) };
|
||||
|
||||
Assert.That(Weg(akten), Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Unter_der_Obergrenze_bleibt_alles_liegen()
|
||||
{
|
||||
var akten = new[]
|
||||
{
|
||||
Akte(EinsId, 5, TimeSpan.FromHours(2)),
|
||||
Akte(ZweiId, 5, TimeSpan.FromHours(3)),
|
||||
};
|
||||
|
||||
Assert.That(Weg(akten), Is.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Über der Obergrenze geht das am längsten Ungenutzte zuerst.</summary>
|
||||
[Test]
|
||||
public void Ueber_der_Obergrenze_geht_das_aelteste_zuerst()
|
||||
{
|
||||
var jung = Akte(EinsId, 15, TimeSpan.FromHours(2));
|
||||
var alt = Akte(ZweiId, 15, TimeSpan.FromHours(20));
|
||||
|
||||
var weg = Weg([jung, alt]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(weg, Has.Count.EqualTo(1), "eines von beiden reicht, um unter 20 GB zu kommen");
|
||||
Assert.That(weg[0], Is.EqualTo(alt.Pfad));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Was_in_der_Schonfrist_liegt_bleibt_auch_ueber_der_Obergrenze()
|
||||
{
|
||||
var eben = Akte(EinsId, 15, TimeSpan.FromMinutes(1));
|
||||
var auchEben = Akte(ZweiId, 15, TimeSpan.FromMinutes(2));
|
||||
|
||||
Assert.That(Weg([eben, auchEben]), Is.Empty,
|
||||
"beide wurden gerade gebraucht — wegräumen hiesse, jemandem den Download abzuschneiden");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Was_in_Benutzung_ist_bleibt_auch_ueber_der_Obergrenze()
|
||||
{
|
||||
var laeuft = Akte(EinsId, 15, TimeSpan.FromDays(2));
|
||||
var daneben = Akte(ZweiId, 15, TimeSpan.FromHours(2));
|
||||
|
||||
var weg = Weg([laeuft, daneben], inBenutzung: p => p == laeuft.Pfad);
|
||||
|
||||
Assert.That(weg, Does.Not.Contain(laeuft.Pfad),
|
||||
"ein Download über Mobilfunk kann Stunden dauern, ohne dass eine Anfrage kommt");
|
||||
}
|
||||
|
||||
/// <summary>Unbenutztes geht auch, wenn noch Platz wäre.</summary>
|
||||
[Test]
|
||||
public void Lange_Unbenutzte_gehen_auch_unter_der_Obergrenze()
|
||||
{
|
||||
var akten = new[] { Akte(EinsId, 1, TimeSpan.FromDays(30)) };
|
||||
|
||||
Assert.That(Weg(akten), Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Eine_Obergrenze_von_null_heisst_unbegrenzt()
|
||||
{
|
||||
var regeln = Kratzplatzregeln.Vorgabe with { ObergrenzeBytes = 0 };
|
||||
var akten = new[]
|
||||
{
|
||||
Akte(EinsId, 500, TimeSpan.FromHours(2)),
|
||||
Akte(ZweiId, 500, TimeSpan.FromHours(3)),
|
||||
};
|
||||
|
||||
Assert.That(Weg(akten, regeln), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ohne_Altersabbau_bleibt_auch_sehr_Altes_liegen()
|
||||
{
|
||||
var regeln = Kratzplatzregeln.Vorgabe with { Unbenutzt = TimeSpan.Zero };
|
||||
|
||||
Assert.That(Weg([Akte(EinsId, 1, TimeSpan.FromDays(400))], regeln), Is.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Auch mit abgeschalteten Regeln fliegt Verwaistes.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// „Unbegrenzt" heisst „so viel du willst", nicht „sammle auch Müll".
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Verwaistes_geht_auch_bei_abgeschalteten_Grenzen()
|
||||
{
|
||||
var regeln = new Kratzplatzregeln(0, TimeSpan.Zero, TimeSpan.FromMinutes(30));
|
||||
|
||||
Assert.That(Weg([Akte(EinsId, 1, TimeSpan.FromHours(2), bauart: 1)], regeln), Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Eine_zu_kurze_Schonfrist_wird_hochgesetzt()
|
||||
{
|
||||
var regeln = Kratzplatzregeln.Geprueft(20, 7, 1);
|
||||
|
||||
Assert.That(regeln.Schonfrist, Is.EqualTo(TimeSpan.FromMinutes(5)),
|
||||
"eine Minute löschte die Datei mitten im Herunterladen weg");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Null_und_negative_Werte_heissen_abgeschaltet()
|
||||
{
|
||||
var regeln = Kratzplatzregeln.Geprueft(0, -3, 30);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(regeln.ObergrenzeBytes, Is.Zero);
|
||||
Assert.That(regeln.Unbenutzt, Is.EqualTo(TimeSpan.Zero));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der Name einer umgepackten Datei, in beide Richtungen.</summary>
|
||||
public class PaketnameTests
|
||||
{
|
||||
[Test]
|
||||
public void Ein_gebauter_Name_laesst_sich_wieder_lesen()
|
||||
{
|
||||
var id = Guid.CreateVersion7();
|
||||
var geaendert = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_123);
|
||||
|
||||
var gelesen = Paketname.Lesen(Paketname.Bauen(id, geaendert, 2));
|
||||
|
||||
Assert.That(gelesen, Is.EqualTo(new Paketname.Teile(id, 1_700_000_000_123, 2)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Der_Pfad_davor_stoert_nicht()
|
||||
{
|
||||
var id = Guid.CreateVersion7();
|
||||
var name = Paketname.Bauen(id, DateTimeOffset.FromUnixTimeMilliseconds(5), 2);
|
||||
|
||||
Assert.That(Paketname.Lesen($"/kratz/packages/{name}")?.DateiId, Is.EqualTo(id));
|
||||
}
|
||||
|
||||
[TestCase("irgendwas.mp4")]
|
||||
[TestCase("abc-123-2.mp4")]
|
||||
[TestCase("00000000000000000000000000000001-123.mp4")]
|
||||
[TestCase("00000000000000000000000000000001-123-2.teil")]
|
||||
[TestCase("00000000000000000000000000000001-nichtzahl-2.mp4")]
|
||||
public void Ein_fremder_Name_ergibt_nichts(string name) =>
|
||||
Assert.That(Paketname.Lesen(name), Is.Null);
|
||||
}
|
||||
Reference in New Issue
Block a user