Options-Fundament und Kapitalmodell im Core; Modul OptionsWheel
Das OptionsWheel setzt auf Dingen auf, die es im Core nicht gab. Diese Sitzung baut sie. #298 Optionen als Instrument. InstrumentKind/OptionSpec und ein druckbarer Instrumentenschluessel ("AAPL|20260918|C|150"); TradeSignal und OrderRequest tragen ein optionales OptionSpec, ohne das sich der Aktienpfad unveraendert verhaelt. Position.Notional rechnet ab jetzt mit der Kontraktgroesse - vorher war jede Option um Faktor 100 zu klein bewertet und damit jedes Risikolimit wirkungslos, lautlos und in die gefaehrliche Richtung. Daneben neu BoundCapital: ein Short Put bindet Strike x Kontraktgroesse, nicht die vereinnahmte Praemie. #300 sell-to-open. Jeder Auftrag zerfaellt in einen schliessenden und einen eroeffnenden Teil; der schliessende wird von keinem Limit verhindert - auch nicht vom Kaufstopp, denn ein Rueckkauf baut eine Verpflichtung ab. Deckungspruefung im Core, nicht im Modul: Short Call nur mit freien Aktien, Short Put nur mit reservierbarem Bargeld. #302-#305 Kapital- und Buchmodell. Buecher mit reservierten Namen und Eigentumsregeln, Pool mit Caps und Headroom, Margin-Sperre in vier Schichten, core_reservation mit Lebenszyklus, Frist, Idempotenz ueber (SignalId, ContractKey) und Quarantaene beim Start. #309/#310 Modul OptionsWheel: ow_-Schema mit fuenf Tabellen, Zustandsautomat, StrikeSelector, RollDecider, PremiumMath - vollstaendig ohne Broker testbar. Der Handelsbetrieb bleibt gesperrt, solange #299 und #294 fehlen; das Modul sagt das ueber GetActivationBlocker, statt still nichts zu tun. #293 --verify-order am Daemon: eine Order durch die vollstaendige Kette (Buch, Risiko, Bindung, Order, Fill, Buchung, Abschluss). Ohne --confirm ein Trockenlauf; Abbruch ausserhalb von Paper-Modus UND Paper-Port. Drei Widersprueche in den Konzepten sind dabei entschieden worden: Schicht B heisst ab jetzt "kein UNGEDECKTER Short" (der Grund der Regel war der Kredit, nicht der Short), die Hausreserve wird einmal abgezogen statt zweimal, und der Positionsschluessel ist eine Spalte statt eines Sechser-Verbunds (MySQL verlangt NOT NULL fuer Schluesselspalten). Begruendungen stehen in docs/ROADMAP.md. Enthaelt ausserdem die noch nicht committete Arbeit aus der vorigen Sitzung: die Fehlerbehebungen aus Epic X (WorkerBase-Intervall, BackupWorker, --check gegen die Datenbank, CapitolTrades-429, SIGTERM) samt DatabaseEndpoint, MysqlDumpTool und der Roadmap-Umstellung. Sie lag im Arbeitsbaum und liess sich nicht abtrennen, weil beide Staende dieselben Dateien beruehren. 213 -> 369 Tests, Build ohne Warnung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,11 +12,15 @@ namespace IBKRTrader.Core.Workers.BuiltIn;
|
||||
/// BackupWorker (Core) – läuft alle 30 Minuten (konfigurierbar).
|
||||
/// 1. Ruft mariadb-dump bzw. mysqldump auf → SQL-Dump nach Backups/DB/
|
||||
/// 2. Kopiert Logs/ → Backups/Logs/
|
||||
/// Wird das Werkzeug nicht gefunden: Warnung und Überspringen, nie ein harter Fehler.
|
||||
///
|
||||
/// <para>Scheitert der Dump – oder fehlt das Werkzeug –, endet der Lauf als Fehler. Ein
|
||||
/// stillschweigend übersprungener Dump sah in Workers-Ansicht und core_worker_log wie ein
|
||||
/// erfolgreiches Backup aus; auffallen würde er erst, wenn man das Backup braucht.</para>
|
||||
/// </summary>
|
||||
public class BackupWorker : WorkerBase
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly DatabaseEndpoint _database;
|
||||
|
||||
public override string Name => "BackupWorker";
|
||||
public override string Module => "Core";
|
||||
@@ -25,16 +29,18 @@ public class BackupWorker : WorkerBase
|
||||
protected override TimeSpan? Interval =>
|
||||
TimeSpan.FromMinutes(_settings.Settings.WorkerSettings.BackupWorker.IntervalMinutes);
|
||||
|
||||
public BackupWorker(LoggingService logger, IDbContextFactory<CoreDbContext> db, SettingsService settings)
|
||||
public BackupWorker(LoggingService logger, IDbContextFactory<CoreDbContext> db,
|
||||
SettingsService settings, DatabaseEndpoint database)
|
||||
: base(logger, db)
|
||||
{
|
||||
_settings = settings;
|
||||
_settings = settings;
|
||||
_database = database;
|
||||
Info.Active = settings.Settings.WorkerSettings.BackupWorker.Enabled;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
var timestamp = AppTimeZone.Now.ToString("yyyy-MM-dd_HH-mm");
|
||||
var timestamp = AppTimeZone.Now.ToString("yyyy-MM-dd_HH-mm", CultureInfo.InvariantCulture);
|
||||
var backupRoot = AppPaths.DataPath("Backups");
|
||||
var dbDir = Path.Combine(backupRoot, "DB");
|
||||
var logsDir = Path.Combine(backupRoot, "Logs");
|
||||
@@ -55,14 +61,13 @@ public class BackupWorker : WorkerBase
|
||||
|
||||
private async Task RunMysqlDumpAsync(string dbDir, string timestamp, CancellationToken ct)
|
||||
{
|
||||
var dump = FindDumpTool();
|
||||
if (dump == null)
|
||||
{
|
||||
Logger.Warn(Module, $"{string.Join(" / ", ToolNames())} nicht gefunden – DB-Backup übersprungen.");
|
||||
return;
|
||||
}
|
||||
var dump = MysqlDumpTool.Find()
|
||||
?? throw new InvalidOperationException(
|
||||
$"{string.Join(" / ", MysqlDumpTool.Names())} nicht gefunden – ohne Dump-Werkzeug gibt es kein DB-Backup.");
|
||||
|
||||
var db = _settings.Settings.Database;
|
||||
// Die maßgebliche Verbindung, nicht die aus settings.json: sonst sichert der Worker eine
|
||||
// andere Datenbank als die, mit der die Anwendung arbeitet.
|
||||
var db = _database;
|
||||
var dumpFile = Path.Combine(dbDir, $"{db.Database}_{timestamp}.sql");
|
||||
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
@@ -97,68 +102,23 @@ public class BackupWorker : WorkerBase
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
{
|
||||
var err = await proc.StandardError.ReadToEndAsync(ct);
|
||||
Logger.Warn(Module, $"{Path.GetFileName(dump)} Exitcode {proc.ExitCode}: {err}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Info(Module, $"DB-Dump erstellt: {Path.GetFileName(dumpFile)}");
|
||||
var err = (await proc.StandardError.ReadToEndAsync(ct)).Trim();
|
||||
|
||||
// Eine unbrauchbare Datei zurücklassen wäre schlimmer als keine: im Backup-Verzeichnis
|
||||
// sähe sie aus wie ein Dump.
|
||||
TryDelete(dumpFile);
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"{Path.GetFileName(dump)} Exitcode {proc.ExitCode} für {db}: {err}");
|
||||
}
|
||||
|
||||
Logger.Info(Module, $"DB-Dump erstellt: {Path.GetFileName(dumpFile)} ({db})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Namen des Dump-Werkzeugs. MariaDB hat <c>mysqldump</c> ab 10.5 in <c>mariadb-dump</c>
|
||||
/// umbenannt und pflegt den alten Namen nur noch als Alias – auf neueren Distributionen ist
|
||||
/// oft nur der neue vorhanden. Die Dateiendung gibt es nur unter Windows.
|
||||
/// </summary>
|
||||
private static string[] ToolNames() =>
|
||||
OperatingSystem.IsWindows()
|
||||
? ["mariadb-dump.exe", "mysqldump.exe"]
|
||||
: ["mariadb-dump", "mysqldump"];
|
||||
|
||||
/// <summary>
|
||||
/// Sucht das Dump-Werkzeug: erst die üblichen Installationspfade der jeweiligen Plattform,
|
||||
/// dann PATH. Findet es nichts, wird das Backup übersprungen (nie ein harter Fehler).
|
||||
/// </summary>
|
||||
private static string? FindDumpTool()
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
var names = ToolNames();
|
||||
|
||||
// Übliche Installationspfade – plattformabhängig.
|
||||
string[] dirs = OperatingSystem.IsWindows()
|
||||
?
|
||||
[
|
||||
@"C:\Program Files\MariaDB\bin",
|
||||
@"C:\Program Files\MySQL\MySQL Server 8.0\bin",
|
||||
@"C:\Program Files\MySQL\MySQL Server 8.4\bin",
|
||||
@"C:\xampp\mysql\bin",
|
||||
]
|
||||
:
|
||||
[
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/opt/mariadb/bin",
|
||||
"/opt/homebrew/bin",
|
||||
];
|
||||
|
||||
foreach (var name in names)
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
var full = Path.Combine(dir, name);
|
||||
if (File.Exists(full)) return full;
|
||||
}
|
||||
|
||||
// PATH-Suche. Das Trennzeichen ist plattformabhängig: ';' unter Windows, ':' unter Linux –
|
||||
// ein fest verdrahtetes ';' hätte auf Linux den gesamten PATH als einen Eintrag gelesen.
|
||||
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
foreach (var name in names)
|
||||
{
|
||||
var full = Path.Combine(dir.Trim(), name);
|
||||
if (File.Exists(full)) return full;
|
||||
}
|
||||
|
||||
return null;
|
||||
try { if (File.Exists(path)) File.Delete(path); }
|
||||
catch { /* best-effort – die Meldung über den Fehlschlag zählt, nicht die Aufräumarbeit */ }
|
||||
}
|
||||
|
||||
// ─── Logs kopieren ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace IBKRTrader.Core.Workers.BuiltIn;
|
||||
|
||||
/// <summary>
|
||||
/// Findet das Dump-Werkzeug der Datenbank. Eigene Klasse, weil zwei Stellen sie brauchen: der
|
||||
/// <see cref="BackupWorker"/> bei jedem Lauf und die Startprüfung, die ein fehlendes Werkzeug
|
||||
/// einmal beim Hochfahren meldet statt es alle 30 Minuten stillschweigend zu übergehen.
|
||||
/// </summary>
|
||||
public static class MysqlDumpTool
|
||||
{
|
||||
/// <summary>
|
||||
/// Namen des Dump-Werkzeugs. MariaDB hat <c>mysqldump</c> ab 10.5 in <c>mariadb-dump</c>
|
||||
/// umbenannt und pflegt den alten Namen nur noch als Alias – auf neueren Distributionen ist
|
||||
/// oft nur der neue vorhanden. Die Dateiendung gibt es nur unter Windows.
|
||||
/// </summary>
|
||||
public static string[] Names() =>
|
||||
OperatingSystem.IsWindows()
|
||||
? ["mariadb-dump.exe", "mysqldump.exe"]
|
||||
: ["mariadb-dump", "mysqldump"];
|
||||
|
||||
/// <summary>
|
||||
/// Sucht das Dump-Werkzeug: erst die üblichen Installationspfade der jeweiligen Plattform,
|
||||
/// dann PATH. <c>null</c> = nicht gefunden.
|
||||
/// </summary>
|
||||
public static string? Find()
|
||||
{
|
||||
var names = Names();
|
||||
|
||||
// Übliche Installationspfade – plattformabhängig.
|
||||
string[] dirs = OperatingSystem.IsWindows()
|
||||
?
|
||||
[
|
||||
@"C:\Program Files\MariaDB\bin",
|
||||
@"C:\Program Files\MySQL\MySQL Server 8.0\bin",
|
||||
@"C:\Program Files\MySQL\MySQL Server 8.4\bin",
|
||||
@"C:\xampp\mysql\bin",
|
||||
]
|
||||
:
|
||||
[
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/opt/mariadb/bin",
|
||||
"/opt/homebrew/bin",
|
||||
];
|
||||
|
||||
foreach (var name in names)
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
var full = Path.Combine(dir, name);
|
||||
if (File.Exists(full)) return full;
|
||||
}
|
||||
|
||||
// PATH-Suche. Das Trennzeichen ist plattformabhängig: ';' unter Windows, ':' unter Linux –
|
||||
// ein fest verdrahtetes ';' hätte auf Linux den gesamten PATH als einen Eintrag gelesen.
|
||||
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
foreach (var name in names)
|
||||
{
|
||||
var full = Path.Combine(dir.Trim(), name);
|
||||
if (File.Exists(full)) return full;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using IBKRTrader.Core.Capital;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Persistence.Ef;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IBKRTrader.Core.Workers.BuiltIn;
|
||||
|
||||
/// <summary>
|
||||
/// Wacht über die Kapitalbindungen (Kapitalmodell §4 Schicht D und §5).
|
||||
///
|
||||
/// <para>Zwei Aufgaben, beide im selben Takt:</para>
|
||||
/// <list type="number">
|
||||
/// <item>Überfällige Reservierungen melden. Die Frist ist zwingend – eine Bindung, die ohne
|
||||
/// Endzustand hängen bleibt, bedeutet, dass unser Bild vom Konto und das echte Konto
|
||||
/// auseinanderlaufen. Sie wird gemeldet, nicht stillschweigend gelöst.</item>
|
||||
/// <item>Den Bargeldstand gegen die Watchdog-Schwelle prüfen. Bei Unterschreitung gilt ein
|
||||
/// globaler Kaufstopp – <b>und niemals ein automatischer Verkauf zur Heilung</b>. Eine Software,
|
||||
/// die in einen fallenden Markt hinein Positionen schließt, weil eine Kennzahl unterschritten
|
||||
/// ist, richtet mehr Schaden an als der Zustand, den sie beheben soll.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class ReservationWatchdogWorker : WorkerBase
|
||||
{
|
||||
private readonly ReservationService _reservations;
|
||||
private readonly IBrokerClient _broker;
|
||||
private readonly Settings.SettingsService _settings;
|
||||
|
||||
public override string Name => "ReservationWatchdogWorker";
|
||||
public override string Module => "Core";
|
||||
public override WorkerType Type => WorkerType.Worker;
|
||||
|
||||
protected override TimeSpan? Interval => TimeSpan.FromMinutes(5);
|
||||
|
||||
public ReservationWatchdogWorker(
|
||||
LoggingService logger,
|
||||
IDbContextFactory<CoreDbContext> db,
|
||||
ReservationService reservations,
|
||||
IBrokerClient broker,
|
||||
Settings.SettingsService settings)
|
||||
: base(logger, db)
|
||||
{
|
||||
_reservations = reservations;
|
||||
_broker = broker;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
var overdue = await _reservations.ExpireOverdueAsync(ct);
|
||||
|
||||
var account = await _broker.GetAccountStateAsync(ct);
|
||||
var minCash = (decimal)_settings.Settings.Capital.WatchdogMinCashPercent;
|
||||
|
||||
// Bei fehlendem Kontowert meldet der Broker gerade nichts Brauchbares. Das ist kein Alarm
|
||||
// ueber den Kontostand, sondern einer ueber die Verbindung - und die Kaufsperre greift
|
||||
// ohnehin ueber dieselbe Pruefung im Risikopfad.
|
||||
if (account.NetLiquidation <= 0m)
|
||||
{
|
||||
Logger.Warn(Module, "Kontostand nicht abrufbar – Bargeldschwelle diesmal nicht geprüft.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (MarginGuard.BuyingShouldHalt(account, minCash))
|
||||
{
|
||||
var threshold = account.NetLiquidation * (minCash / 100m);
|
||||
Logger.Error(Module,
|
||||
$"Bargeld {account.TotalCashValue:F0} liegt unter der Schwelle {threshold:F0} " +
|
||||
$"({minCash:F1} % von {account.NetLiquidation:F0}). Käufe sind gesperrt. " +
|
||||
$"Es wird NICHT automatisch verkauft – das ist eine Entscheidung für einen Menschen.");
|
||||
}
|
||||
|
||||
if (overdue.Count > 0)
|
||||
Logger.Warn(Module, $"{overdue.Count} überfällige Reservierung(en) gemeldet.");
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,13 @@ public abstract class WorkerBase : IWorker, IHostedService
|
||||
{
|
||||
try { await _runLoop.ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { /* erwartet */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Der Run-Loop fängt seine Fehler selbst ab; kommt hier doch einer an, darf er
|
||||
// den Stopp nicht abbrechen. Sonst reißt ein einzelner Worker das geordnete
|
||||
// Herunterfahren des gesamten Prozesses mit – Host.StopAsync wirft weiter.
|
||||
Logger.Error(Module, $"Worker {Name}: Lauf endete mit Fehler: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
Info.Status = WorkerStatus.Stopped;
|
||||
@@ -102,37 +109,70 @@ public abstract class WorkerBase : IWorker, IHostedService
|
||||
|
||||
// ─── Interner Run-Loop ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Obergrenze für eine einzelne Wartezeit. <c>SemaphoreSlim.WaitAsync(TimeSpan, …)</c> nimmt
|
||||
/// höchstens int.MaxValue Millisekunden entgegen (rund 24,85 Tage) und wirft darüber hinaus.
|
||||
/// Längere Intervalle – etwa der Jahresrhythmus des History-Imports – werden deshalb in
|
||||
/// Scheiben abgewartet, bis der Zeitpunkt erreicht ist.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan MaxWaitSlice = TimeSpan.FromHours(1);
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken ct)
|
||||
{
|
||||
Logger.Info(Module, $"Worker gestartet: {Name}");
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
try
|
||||
{
|
||||
await RunOnceAsync(ct);
|
||||
|
||||
if (Interval == null) break; // Service: nur einmal
|
||||
|
||||
// Anzeige-/Zeitplanwerte in der Betriebszeitzone, damit die UI dasselbe zeigt wie die Logs.
|
||||
var next = AppTimeZone.Now.Add(Interval.Value);
|
||||
Info.NextRuntime = next;
|
||||
|
||||
// Warte auf Interval ODER manuellen Trigger
|
||||
var remaining = next - AppTimeZone.Now;
|
||||
if (remaining > TimeSpan.Zero)
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _triggerSemaphore
|
||||
.WaitAsync(remaining, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
await RunOnceAsync(ct);
|
||||
|
||||
if (Interval == null) break; // Service: nur einmal
|
||||
|
||||
// Anzeige-/Zeitplanwerte in der Betriebszeitzone, damit die UI dasselbe zeigt wie die Logs.
|
||||
var next = AppTimeZone.Now.Add(Interval.Value);
|
||||
Info.NextRuntime = next;
|
||||
|
||||
if (!await WaitForNextRunAsync(next, ct).ConfigureAwait(false)) break;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* erwartet */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Alles, was außerhalb von RunOnceAsync schiefgeht, beendete den Worker bisher stumm:
|
||||
// der Task faultete, geloggt wurde nichts, und beim Herunterfahren kam die Ausnahme
|
||||
// erneut hoch und riss den Prozess mit. Hier bleibt sie stehen und wird sichtbar.
|
||||
Logger.Error(Module, $"Worker {Name} abgebrochen: {ex.Message}", ex);
|
||||
Info.Status = WorkerStatus.Error;
|
||||
Info.Info = $"Abgebrochen: {ex.Message}";
|
||||
}
|
||||
|
||||
Logger.Info(Module, $"Worker beendet: {Name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wartet, bis <paramref name="next"/> erreicht ist oder ein manueller Trigger kommt.
|
||||
/// Rückgabe false = Abbruch angefordert, der Lauf-Loop endet.
|
||||
/// </summary>
|
||||
private async Task<bool> WaitForNextRunAsync(DateTime next, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var remaining = next - AppTimeZone.Now;
|
||||
if (remaining <= TimeSpan.Zero) return true;
|
||||
|
||||
var slice = remaining > MaxWaitSlice ? MaxWaitSlice : remaining;
|
||||
try
|
||||
{
|
||||
if (await _triggerSemaphore.WaitAsync(slice, ct).ConfigureAwait(false))
|
||||
return true; // manuell ausgelöst
|
||||
}
|
||||
catch (OperationCanceledException) { return false; }
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
Info.Status = WorkerStatus.Running;
|
||||
@@ -153,16 +193,22 @@ public abstract class WorkerBase : IWorker, IHostedService
|
||||
{
|
||||
if (logId > 0)
|
||||
await EndRunLogAsync(logId, false, "Abgebrochen");
|
||||
Info.Status = WorkerStatus.Stopped;
|
||||
Info.Info = "Abgebrochen";
|
||||
Info.LastRuntime = AppTimeZone.Now;
|
||||
Info.Status = WorkerStatus.Stopped;
|
||||
Info.Info = "Abgebrochen";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (logId > 0)
|
||||
await EndRunLogAsync(logId, false, ex.Message);
|
||||
Logger.Error(Module, $"Fehler in Worker {Name}: {ex.Message}", ex);
|
||||
Info.Status = WorkerStatus.Error;
|
||||
Info.Info = $"Fehler: {ex.Message}";
|
||||
|
||||
// Auch ein gescheiterter Lauf ist ein Lauf: bliebe "Letzter Lauf" leer, wäre ein
|
||||
// Worker, der seit Tagen bei jedem Versuch scheitert, in der Tabelle nicht von
|
||||
// einem zu unterscheiden, der noch nie gelaufen ist.
|
||||
Info.LastRuntime = AppTimeZone.Now;
|
||||
Info.Status = WorkerStatus.Error;
|
||||
Info.Info = $"Fehler: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user