Mehrere Instanzen sollen unabhaengig voneinander laufen. Heute tun sie das nicht: Sie teilen sich Protokollverzeichnis, Sicherungsordner und Sicherungszeitplan. In einer gemeinsamen Startup.log standen abwechselnd Zeilen zweier Instanzen, ohne Kennzeichnung, welche gerade spricht -- im Mehrinstanzbetrieb ist damit jede Fehlersuche Ratearbeit. Genau in dieser Lage ist der Aufhaenger aus #529 entstanden. Fuenf Schluessel wandern aus AppSettings nach InstanceSettings.json: logDirectory, backupDirectory, backupKeepCount, autoBackupEnabled, autoBackupTime. Leer heisst jetzt <Instanz>/Logs beziehungsweise <Instanz>/Backups; ein relativer Wert zaehlt weiterhin gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses (#375 bleibt gewahrt). autoBackupEnabled und autoBackupTime standen in der Planung nicht, sind aber mitgegangen: Ein Schalter, der die taegliche Sicherung fuer eine Instanz einschaltet und sie damit fuer alle einschaltet, ist derselbe Fehler wie ein Ordner fuer alle -- und zwei von vier Sicherungsfeldern zu verschieben hinterliesse eine Einstellungsseite, auf der die Haelfte anwendungsweit gilt. Keine Uebernahme der Altwerte, aber eine Ansage: SettingsManager. AbandonedKeys meldet die aufgegebenen Schluessel, solange sie noch in Settings.json stehen, und der Start protokolliert einmal, was nicht mehr gilt und wohin diese Instanz stattdessen schreibt. Ein anwendungsweiter Pfad, in alle Instanzen kopiert, waere wieder ein Ordner fuer alle. webServerPort bleibt vorerst stehen. Es entfaellt mit Stufe 2, sobald Kestrel auf dem Socket hoert; vorher wegzunehmen hiesse, die Weboberflaeche ohne Ersatz abzuschalten. Korrektur an der eigenen Befundlage: "Instanz A wirft die Sicherungen von Instanz B weg" war zu breit. Beide Rotationen filtern bereits nach dem Praefix backup_<Instanzname>_. Der reale Defekt ist enger, und ein Praefix ist keine Grenze: backup_Test_2_*.zip faengt mit backup_Test_ an, also zaehlt Test im gemeinsamen Ordner die Archive von Test_2 mit. Belegt in BackupSchedulerTests, das den alten Zustand eigens wiederherstellt. Nachgeprueft an der laufenden Installation, nicht nur im Test: Nach dem Umbau schreibt Instance-Test nach Instances/Instance-Test/Logs, das alte gemeinsame Verzeichnis bekommt keine Zeile mehr, und die Warnung zu den aufgegebenen Schluesseln erscheint genau einmal. Enthaelt ausserdem, was seit der Weboberflaeche liegengeblieben war: - #526: Instanzgeheimnisse wurden beim Speichern verschluesselt und beim Laden nie entschluesselt. LoadInstanceConfig hatte kein ConfigSecrets.Unprotect; ConfigLoader.LoadAsync macht es richtig, wird zur Laufzeit aber gar nicht aufgerufen. Der OpenRouter-Schluessel ging so verschluesselt an die API. - Ein offener Ereignisstrom hielt das Beenden fuenf Sekunden auf. Die Stroeme hingen nur an RequestAborted, das beim geordneten Herunterfahren nicht feuert; jetzt zusaetzlich an ApplicationStopping. Der Fang in WebUiHost gilt dazu ohne Bedingung auf RequestAborted -- mit ihr waere jeder offene Strom beim Beenden als Fehler im Bugtracker gelandet. - Nach der Anmeldung blieb die Weboberflaeche leer: #anmeldung { display:flex } schlaegt als ID-Selektor das hidden-Attribut. - docs/Instanzmanager-Konzept.md: Befund, die Entscheidungen E-M1 bis E-M6, der Schnitt zwischen Manager und Instanz und die sechs Stufen. Build ohne Warnungen, 709 Tests gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
6.7 KiB
C#
188 lines
6.7 KiB
C#
using ClawdDotNet.Core.Backup;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Storage;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ClawdDotNet.App.Services;
|
|
|
|
/// <summary>
|
|
/// Erstellt einmal täglich zur eingestellten Uhrzeit eine Sicherung.
|
|
///
|
|
/// Bewusst ohne Zugangsdaten: Die Passphrase müsste dafür gespeichert werden, und
|
|
/// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten
|
|
/// mitsichern will, macht das von Hand.
|
|
///
|
|
/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Instanzkonfiguration geprüft,
|
|
/// damit eine Änderung ohne Neustart greift.
|
|
///
|
|
/// <para><b>Schalter, Uhrzeit, Zielordner und Aufbewahrung gehören der Instanz</b>
|
|
/// (#528, Stufe 1). Alle vier standen in den Anwendungseinstellungen und galten damit
|
|
/// für jede Instanz: Wer die tägliche Sicherung für eine einschaltete, schaltete sie für
|
|
/// alle ein, und alle Archive landeten in einem Ordner. Dort räumt
|
|
/// <see cref="ApplyRotation"/> nach dem Präfix <c>backup_<Instanzname>_</c> auf —
|
|
/// bei zwei Namen, die ineinander liegen (<c>Test</c> und <c>Test_2</c>), zählt die eine
|
|
/// Instanz die Archive der anderen mit und wirft die überzähligen weg.</para>
|
|
///
|
|
/// <para><b>Takt.</b> Früher ein <c>System.Windows.Forms.Timer</c> — der braucht eine
|
|
/// Nachrichtenschleife und damit ein Fenster. Jetzt <see cref="PeriodicTimer"/> über
|
|
/// einen <see cref="TimeProvider"/>: läuft ohne Oberfläche, driftet nicht, und Tests
|
|
/// können die Zeit steuern statt zu warten.</para>
|
|
/// </summary>
|
|
public sealed class BackupScheduler : IAsyncDisposable
|
|
{
|
|
private readonly string _instanceDir;
|
|
private readonly InstanceConfig _instance;
|
|
private readonly ILogger _logger;
|
|
private readonly TimeProvider _clock;
|
|
private readonly TimeSpan _tick;
|
|
private readonly BackupService _service = new();
|
|
private readonly CancellationTokenSource _cts = new();
|
|
|
|
private Task? _loop;
|
|
|
|
/// <summary>Verhindert mehrere Sicherungen am selben Tag.</summary>
|
|
private DateTime? _lastRun;
|
|
|
|
public event Action<string>? OnBackupCreated;
|
|
|
|
/// <param name="instanceDir">
|
|
/// Das Instanzverzeichnis. Zugleich der Bezugspunkt, gegen den ein relativ
|
|
/// eingetragener Zielordner zählt.
|
|
/// </param>
|
|
/// <param name="instance">
|
|
/// Die laufende Instanzkonfiguration — als Verweis, nicht als Abzug: Wer Zielordner
|
|
/// oder Uhrzeit in der Oberfläche ändert, ändert dieses Objekt, und der nächste
|
|
/// Durchlauf sieht es.
|
|
/// </param>
|
|
public BackupScheduler(
|
|
string instanceDir,
|
|
InstanceConfig instance,
|
|
ILogger logger,
|
|
TimeProvider? clock = null,
|
|
TimeSpan? tick = null)
|
|
{
|
|
_instanceDir = instanceDir;
|
|
_instance = instance;
|
|
_logger = logger;
|
|
_clock = clock ?? TimeProvider.System;
|
|
|
|
// Minütlich prüfen reicht — die Uhrzeit ist auf Minuten genau eingestellt.
|
|
_tick = tick ?? TimeSpan.FromMinutes(1);
|
|
}
|
|
|
|
public void Start() => _loop ??= RunLoopAsync(_cts.Token);
|
|
|
|
private async Task RunLoopAsync(CancellationToken ct)
|
|
{
|
|
using var timer = new PeriodicTimer(_tick, _clock);
|
|
|
|
while (await timer.WaitForNextTickAsync(ct))
|
|
{
|
|
try
|
|
{
|
|
await TickAsync();
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
// Ein Fehlschlag darf die Anwendung nicht stören.
|
|
_logger.LogError(ex, "Automatische Sicherung fehlgeschlagen");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können.</summary>
|
|
public async Task TickAsync()
|
|
{
|
|
if (!_instance.AutoBackupEnabled)
|
|
return;
|
|
|
|
if (!TimeSpan.TryParse(_instance.AutoBackupTime, out var scheduled))
|
|
return;
|
|
|
|
var now = _clock.GetLocalNow().DateTime;
|
|
|
|
// Fällig, sobald die Uhrzeit erreicht ist und heute noch nichts lief.
|
|
if (now.TimeOfDay < scheduled) return;
|
|
if (_lastRun?.Date == now.Date) return;
|
|
|
|
// Vor dem Lauf setzen: Scheitert er, wird nicht jede Minute erneut versucht,
|
|
// sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem.
|
|
_lastRun = now;
|
|
|
|
await RunAsync(
|
|
AppHost.ResolveBackupDirectory(_instance, _instanceDir),
|
|
_instance.BackupKeepCount,
|
|
now);
|
|
}
|
|
|
|
private async Task RunAsync(string folder, int keepCount, DateTime now)
|
|
{
|
|
// PortableFileName statt Path.GetInvalidFileNameChars: Unter Linux liefert das
|
|
// nur '\0' und '/', ein Instanzname mit ':' ergäbe ein Archiv, das sich unter
|
|
// Windows nicht mehr anlegen lässt.
|
|
var safeName = PortableFileName.Sanitize(_instance.InstanceName, "Instanz");
|
|
|
|
// Der Ordner liegt jetzt in der Regel unterhalb der Instanz und muss beim ersten
|
|
// Lauf erst entstehen.
|
|
Directory.CreateDirectory(folder);
|
|
|
|
var file = Path.Combine(folder, $"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip");
|
|
|
|
var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions
|
|
{
|
|
Secrets = SecretMode.Exclude,
|
|
IncludeChatHistory = true,
|
|
IncludeLogs = false
|
|
});
|
|
|
|
_logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)",
|
|
result.ZipPath, result.SizeBytes);
|
|
|
|
ApplyRotation(folder, safeName, keepCount);
|
|
|
|
OnBackupCreated?.Invoke(result.ZipPath);
|
|
}
|
|
|
|
/// <summary>Behält die neuesten Sicherungen dieser Instanz und entfernt den Rest.</summary>
|
|
private void ApplyRotation(string folder, string safeName, int keepCount)
|
|
{
|
|
if (keepCount <= 0 || !Directory.Exists(folder))
|
|
return;
|
|
|
|
try
|
|
{
|
|
var prefix = $"backup_{safeName}_";
|
|
|
|
var obsolete = new DirectoryInfo(folder)
|
|
.GetFiles("*.zip")
|
|
.Where(f => f.Name.StartsWith(prefix, PathBoundary.Comparison))
|
|
.OrderByDescending(f => f.LastWriteTime)
|
|
.Skip(keepCount)
|
|
.ToList();
|
|
|
|
foreach (var file in obsolete)
|
|
{
|
|
file.Delete();
|
|
_logger.LogInformation("Alte Sicherung entfernt: {Name}", file.Name);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen");
|
|
}
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _cts.CancelAsync();
|
|
|
|
if (_loop is not null)
|
|
{
|
|
try { await _loop; }
|
|
catch (OperationCanceledException) { /* erwartet */ }
|
|
}
|
|
|
|
_cts.Dispose();
|
|
}
|
|
}
|