R4: Worker von WorkerEngine auf IHostedService (Generic Host)

- WorkerBase implementiert IHostedService (StartAsync/StopAsync mit CancellationToken,
  eigener CTS, Guard auf Info.Active); IWorker auf Metadaten + TriggerAsync reduziert
- Worker via AddHostedService registriert (Core + Modul); AppHost.Start() startet sie,
  AppHost.StopAsync() stoppt sie
- WorkerEngine auf leichte Registry reduziert (WorkerInfos fuer UI + TriggerWorkerAsync)
- LauncherForm startet/stoppt keine Worker mehr (Host-getrieben)
- Hosting.Abstractions im Core; Tests angepasst -> 43/43 gruen; smoke-ui + App-Start ok

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-28 18:39:14 +02:00
parent 475c0b41cb
commit 19dac18050
9 changed files with 67 additions and 106 deletions
+2 -3
View File
@@ -115,7 +115,7 @@ public sealed class LauncherForm : Form
module.StopAsync(default).GetAwaiter().GetResult(); module.StopAsync(default).GetAwaiter().GetResult();
} }
catch { /* Shutdown darf nicht am Modul scheitern */ } catch { /* Shutdown darf nicht am Modul scheitern */ }
_workerEngine.StopAllAsync().GetAwaiter().GetResult(); // Worker/Services stoppt der Host in Program.Main via AppHost.StopAsync() nach Application.Run.
base.OnFormClosing(e); base.OnFormClosing(e);
} }
@@ -133,14 +133,13 @@ public sealed class LauncherForm : Form
// Das gesamte Schema (core_ + ct_) läuft über EF-Migrationen, extern via // Das gesamte Schema (core_ + ct_) läuft über EF-Migrationen, extern via
// `dotnet ef database update` angewendet keine Laufzeit-Migration mehr. // `dotnet ef database update` angewendet keine Laufzeit-Migration mehr.
// Worker/Services laufen bereits (Generic Host, AppHost.Start()). Hier nur noch Modul-Start.
foreach (var module in _modules) foreach (var module in _modules)
{ {
try { await module.StartAsync(default); } try { await module.StartAsync(default); }
catch (Exception ex) { _logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); } catch (Exception ex) { _logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
} }
await _workerEngine.StartAllAsync();
_logger.Info("Core", "IBKRTrader bereit."); _logger.Info("Core", "IBKRTrader bereit.");
SetStatus("Bereit"); SetStatus("Bereit");
UpdateButtonStates(); UpdateButtonStates();
+12 -2
View File
@@ -61,10 +61,13 @@ internal static class Program
}) })
.Build(); .Build();
// Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine-Referenz. // Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine-Referenz (vor dem Start).
AppHost.Services.GetRequiredService<WebApiService>() AppHost.Services.GetRequiredService<WebApiService>()
.SetEngine(AppHost.Services.GetRequiredService<WorkerEngine>()); .SetEngine(AppHost.Services.GetRequiredService<WorkerEngine>());
// Host starten → alle Worker/Services (IHostedService) laufen an.
AppHost.Start();
// Views registrieren (Core + Module), dann Launcher starten. // Views registrieren (Core + Module), dann Launcher starten.
var uiHost = AppHost.Services.GetRequiredService<ShellUiHost>(); var uiHost = AppHost.Services.GetRequiredService<ShellUiHost>();
RegisterCoreViews(uiHost, AppHost.Services); RegisterCoreViews(uiHost, AppHost.Services);
@@ -115,13 +118,20 @@ internal static class Program
services.AddSingleton<IBKRInstrumentSyncWorker>(); services.AddSingleton<IBKRInstrumentSyncWorker>();
services.AddSingleton<IBKRPriceHistoryWorker>(); services.AddSingleton<IBKRPriceHistoryWorker>();
// Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable<IWorker>. // Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable<IWorker> (nur UI/Registry).
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebserverService>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebserverService>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
// Lebenszyklus über den Generic Host (jeder Worker ist ein IHostedService).
services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>());
services.AddHostedService(sp => sp.GetRequiredService<WebserverService>());
services.AddHostedService(sp => sp.GetRequiredService<WebApiService>());
services.AddHostedService(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
services.AddHostedService(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
services.AddSingleton<WorkerEngine>(); services.AddSingleton<WorkerEngine>();
} }
+6 -3
View File
@@ -100,9 +100,12 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin
**R3 abgeschlossen** gesamte Persistenz auf EF Core (Migrationen: `InitialCore`, `AddIbkr`, `InitialCongressTrading`), extern via `dotnet ef database update` anzuwenden. **R3 abgeschlossen** gesamte Persistenz auf EF Core (Migrationen: `InitialCore`, `AddIbkr`, `InitialCongressTrading`), extern via `dotnet ef database update` anzuwenden.
- Hinweis: nur build-verifizierbar (Unit-Tests ohne DB); Schema-Anwendung extern via `dotnet ef database update` (env `IBKRTRADER_MYSQL`) - Hinweis: nur build-verifizierbar (Unit-Tests ohne DB); Schema-Anwendung extern via `dotnet ef database update` (env `IBKRTRADER_MYSQL`)
### R4 Trading-Kern einфügen ### R4 Worker auf `IHostedService` ✅
- [ ] Risk/Execution/Portfolio/Broker-Seam nach Core/Trading (aus Phase 3 portiert) - [x] `WorkerBase` implementiert `IHostedService`; `IWorker` auf Metadaten+Trigger reduziert
- [ ] Hintergrund-Jobs als `IHostedService` - [x] Worker via `AddHostedService` registriert; Lebenszyklus über `AppHost.Start()`/`StopAsync()`
- [x] `WorkerEngine` auf leichte Registry reduziert (WorkerInfos für UI + `TriggerWorkerAsync`)
- [x] `LauncherForm` startet/stoppt keine Worker mehr; Build + 43/43 Tests + smoke-ui + App-Start grün
- Hinweis: Trading-Kern (Risk/Execution/Portfolio/Broker) wurde bereits in Phase 3 gebaut und in R3 auf EF gehoben
### R5 CongressTrading als vollständige Strategie ### R5 CongressTrading als vollständige Strategie
- [ ] Scraper/Jobs → `IHostedService`; Signal → `ExecutionService` - [ ] Scraper/Jobs → `IHostedService`; Signal → `ExecutionService`
@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.4" />
<!-- EF Core / Pomelo (MariaDB 11.8.6). EF 8 laeuft auf net10. --> <!-- EF Core / Pomelo (MariaDB 11.8.6). EF 8 laeuft auf net10. -->
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
+4 -9
View File
@@ -1,12 +1,13 @@
namespace IBKRTrader.Core.Workers; namespace IBKRTrader.Core.Workers;
/// <summary> /// <summary>
/// Vertrag für jeden Worker oder Service im System. /// Metadaten-/Steuer-Vertrag für jeden Worker oder Service. Der Lebenszyklus (Start/Stop) läuft
/// Core und Module müssen dieses Interface implementieren. /// über <see cref="Microsoft.Extensions.Hosting.IHostedService"/> (vom Generic Host getrieben);
/// dieses Interface liefert nur Anzeige-Infos und den manuellen Trigger.
/// </summary> /// </summary>
public interface IWorker public interface IWorker
{ {
/// <summary>Anzeigename in dgv_workerlist.</summary> /// <summary>Anzeigename in der Worker-Liste.</summary>
string Name { get; } string Name { get; }
/// <summary>Modul-Kürzel (z. B. "Core", "CT").</summary> /// <summary>Modul-Kürzel (z. B. "Core", "CT").</summary>
@@ -18,12 +19,6 @@ public interface IWorker
/// <summary>Live-Daten für die DataGridView-Zeile.</summary> /// <summary>Live-Daten für die DataGridView-Zeile.</summary>
WorkerInfo Info { get; } WorkerInfo Info { get; }
/// <summary>Startet den Worker/Service asynchron.</summary>
Task StartAsync(CancellationToken ct);
/// <summary>Stoppt den Worker/Service sauber.</summary>
Task StopAsync();
/// <summary>Löst einen sofortigen, manuellen Run aus (unabhängig vom Zeitplan).</summary> /// <summary>Löst einen sofortigen, manuellen Run aus (unabhängig vom Zeitplan).</summary>
Task TriggerAsync(); Task TriggerAsync();
} }
+22 -11
View File
@@ -2,17 +2,16 @@ using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities; using IBKRTrader.Core.Persistence.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
namespace IBKRTrader.Core.Workers; namespace IBKRTrader.Core.Workers;
/// <summary> /// <summary>
/// Basisklasse für alle periodischen Worker. /// Basisklasse für alle Worker/Services. Implementiert <see cref="IHostedService"/> Start/Stop
/// Verwaltet den internen CancellationToken-Lifecycle /// werden vom Generic Host getrieben. Führt ExecuteAsync() im konfigurierten Interval aus,
/// Führt ExecuteAsync() im konfigurierten Interval aus /// aktualisiert WorkerInfo und schreibt Runs in core_worker_log.
/// Aktualisiert WorkerInfo (LastRuntime, NextRuntime, Status, Info)
/// Schreibt Runs in core_worker_log
/// </summary> /// </summary>
public abstract class WorkerBase : IWorker public abstract class WorkerBase : IWorker, IHostedService
{ {
// ─── Abstrakte Member ───────────────────────────────────────────────────── // ─── Abstrakte Member ─────────────────────────────────────────────────────
@@ -56,25 +55,37 @@ public abstract class WorkerBase : IWorker
// ─── Lifecycle ──────────────────────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────────────────────
public Task StartAsync(CancellationToken externalCt) public Task StartAsync(CancellationToken cancellationToken)
{ {
// Deaktivierte Worker starten nicht (Info.Active wird von abgeleiteten Klassen/Settings gesetzt).
if (!Info.Active)
{
Info.Status = WorkerStatus.Stopped;
Info.Info = "Deaktiviert";
return Task.CompletedTask;
}
if (_runLoop is { IsCompleted: false }) return Task.CompletedTask; if (_runLoop is { IsCompleted: false }) return Task.CompletedTask;
// Lazy: RunEvery erst hier setzen, wenn alle Derived-Felder sicher initialisiert sind // Lazy: RunEvery erst hier setzen, wenn alle Derived-Felder sicher initialisiert sind.
if (string.IsNullOrEmpty(Info.RunEvery)) if (string.IsNullOrEmpty(Info.RunEvery))
Info.RunEvery = Interval.HasValue ? FormatInterval(Interval.Value) : "Service"; Info.RunEvery = Interval.HasValue ? FormatInterval(Interval.Value) : "Service";
_cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt); // Eigener CTS (nicht an den Startup-Token des Hosts gebunden der ist nur für den Startvorgang).
_cts = new CancellationTokenSource();
_runLoop = Task.Run(() => RunLoopAsync(_cts.Token), _cts.Token); _runLoop = Task.Run(() => RunLoopAsync(_cts.Token), _cts.Token);
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task StopAsync() public async Task StopAsync(CancellationToken cancellationToken)
{ {
if (_cts == null) return; if (_cts == null) return;
await _cts.CancelAsync(); await _cts.CancelAsync();
if (_runLoop != null) if (_runLoop != null)
await _runLoop.ConfigureAwait(false); {
try { await _runLoop.ConfigureAwait(false); }
catch (OperationCanceledException) { /* erwartet */ }
}
Info.Status = WorkerStatus.Stopped; Info.Status = WorkerStatus.Stopped;
Info.Info = "Gestoppt"; Info.Info = "Gestoppt";
+13 -74
View File
@@ -5,92 +5,31 @@ using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Workers; namespace IBKRTrader.Core.Workers;
/// <summary> /// <summary>
/// Orchestriert alle registrierten IWorker. /// Leichtgewichtige Registry über alle Worker/Services. Start/Stop übernimmt der Generic Host
/// Startet/stoppt Worker /// (jeder Worker ist ein <see cref="Microsoft.Extensions.Hosting.IHostedService"/>). Diese Klasse
/// Stellt BindingList für DataGridView bereit /// stellt die live-bindbare Liste für die Workers-Ansicht bereit und erlaubt den manuellen Trigger.
/// Thread-sicher via ConcurrentDictionary
/// </summary> /// </summary>
public class WorkerEngine public class WorkerEngine
{ {
private readonly IEnumerable<IWorker> _workers;
private readonly LoggingService _logger;
private readonly ConcurrentDictionary<string, IWorker> _registry = new(); private readonly ConcurrentDictionary<string, IWorker> _registry = new();
private CancellationTokenSource _cts = new();
/// <summary>Live-bindbare Liste für dgv_workerlist.</summary> /// <summary>Live-bindbare Liste für die Workers-Ansicht (DataGridView).</summary>
public BindingList<WorkerInfo> WorkerInfos { get; } = []; public BindingList<WorkerInfo> WorkerInfos { get; } = [];
public WorkerEngine(IEnumerable<IWorker> workers, LoggingService logger) public WorkerEngine(IEnumerable<IWorker> workers, LoggingService logger)
{ {
_workers = workers; foreach (var worker in workers)
_logger = logger;
}
// ─── Engine-Lifecycle ─────────────────────────────────────────────────────
public async Task StartAllAsync()
{
_cts = new CancellationTokenSource();
foreach (var worker in _workers)
{
RegisterWorker(worker);
if (worker.Info.Active)
await worker.StartAsync(_cts.Token);
}
_logger.Info("Core", $"WorkerEngine gestartet {_registry.Count} Worker/Services registriert.");
}
public async Task StopAllAsync()
{
await _cts.CancelAsync();
var tasks = _registry.Values.Select(w => w.StopAsync());
await Task.WhenAll(tasks);
_logger.Info("Core", "WorkerEngine gestoppt.");
}
// ─── Einzelsteuerung ──────────────────────────────────────────────────────
public async Task StartWorkerAsync(string name)
{
if (!_registry.TryGetValue(name, out var worker)) return;
worker.Info.Active = true;
await worker.StartAsync(_cts.Token);
_logger.Info("Core", $"Worker manuell gestartet: {name}");
}
public async Task StopWorkerAsync(string name)
{
if (!_registry.TryGetValue(name, out var worker)) return;
worker.Info.Active = false;
await worker.StopAsync();
_logger.Info("Core", $"Worker manuell gestoppt: {name}");
}
public async Task TriggerWorkerAsync(string name)
{
if (!_registry.TryGetValue(name, out var worker)) return;
await worker.TriggerAsync();
_logger.Info("Core", $"Worker manuell ausgelöst: {name}");
}
// ─── Intern ───────────────────────────────────────────────────────────────
private void RegisterWorker(IWorker worker)
{ {
_registry[worker.Name] = worker; _registry[worker.Name] = worker;
WorkerInfos.Add(worker.Info);
}
logger.Info("Core", $"WorkerEngine: {_registry.Count} Worker/Services registriert.");
}
// WorkerInfo in BindingList eintragen (UI-Thread, falls nötig) /// <summary>Löst einen sofortigen manuellen Run des benannten Workers aus.</summary>
if (WorkerInfos is { } list) public async Task TriggerWorkerAsync(string name)
{ {
if (SynchronizationContext.Current != null) if (_registry.TryGetValue(name, out var worker))
list.Add(worker.Info); await worker.TriggerAsync();
else
list.Add(worker.Info);
}
} }
} }
@@ -38,9 +38,12 @@ public sealed class CongressTradingModule : IModule
services.AddSingleton<CongressHistoryImportWorker>(); services.AddSingleton<CongressHistoryImportWorker>();
services.AddSingleton<CongressScrapeWorker>(); services.AddSingleton<CongressScrapeWorker>();
// Worker als IWorker registrieren → die WorkerEngine erhält sie über IEnumerable<IWorker>. // Worker als IWorker registrieren (WorkerEngine/UI-Registry) …
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<CongressHistoryImportWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<CongressHistoryImportWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<CongressScrapeWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<CongressScrapeWorker>());
// … und als Hosted Services (Lebenszyklus über den Generic Host).
services.AddHostedService(sp => sp.GetRequiredService<CongressHistoryImportWorker>());
services.AddHostedService(sp => sp.GetRequiredService<CongressScrapeWorker>());
} }
public void RegisterUi(IModuleUiHost host, IServiceProvider services) public void RegisterUi(IModuleUiHost host, IServiceProvider services)
@@ -82,7 +82,7 @@ public class WorkerBaseTests
worker.Runs.Should().Be(1); worker.Runs.Should().Be(1);
await worker.StopAsync(); await worker.StopAsync(CancellationToken.None);
} }
[Fact] [Fact]
@@ -97,7 +97,7 @@ public class WorkerBaseTests
await worker.TriggerAsync(); await worker.TriggerAsync();
await WaitUntilAsync(() => worker.Runs == 2, "Trigger löst zweiten Lauf aus"); await WaitUntilAsync(() => worker.Runs == 2, "Trigger löst zweiten Lauf aus");
await worker.StopAsync(); await worker.StopAsync(CancellationToken.None);
} }
[Fact] [Fact]
@@ -112,7 +112,7 @@ public class WorkerBaseTests
worker.Runs.Should().Be(1); worker.Runs.Should().Be(1);
worker.Info.Info.Should().Contain("boom"); worker.Info.Info.Should().Contain("boom");
await worker.StopAsync(); await worker.StopAsync(CancellationToken.None);
} }
[Fact] [Fact]
@@ -122,7 +122,7 @@ public class WorkerBaseTests
await worker.StartAsync(CancellationToken.None); await worker.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt"); await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt");
await worker.StopAsync(); await worker.StopAsync(CancellationToken.None);
worker.Info.Status.Should().Be(WorkerStatus.Stopped); worker.Info.Status.Should().Be(WorkerStatus.Stopped);
} }