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();
}
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);
}
@@ -133,14 +133,13 @@ public sealed class LauncherForm : Form
// Das gesamte Schema (core_ + ct_) läuft über EF-Migrationen, extern via
// `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)
{
try { await module.StartAsync(default); }
catch (Exception ex) { _logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
}
await _workerEngine.StartAllAsync();
_logger.Info("Core", "IBKRTrader bereit.");
SetStatus("Bereit");
UpdateButtonStates();
+12 -2
View File
@@ -61,10 +61,13 @@ internal static class Program
})
.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>()
.SetEngine(AppHost.Services.GetRequiredService<WorkerEngine>());
// Host starten → alle Worker/Services (IHostedService) laufen an.
AppHost.Start();
// Views registrieren (Core + Module), dann Launcher starten.
var uiHost = AppHost.Services.GetRequiredService<ShellUiHost>();
RegisterCoreViews(uiHost, AppHost.Services);
@@ -115,13 +118,20 @@ internal static class Program
services.AddSingleton<IBKRInstrumentSyncWorker>();
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<WebserverService>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
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>();
}
+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.
- Hinweis: nur build-verifizierbar (Unit-Tests ohne DB); Schema-Anwendung extern via `dotnet ef database update` (env `IBKRTRADER_MYSQL`)
### R4 Trading-Kern einфügen
- [ ] Risk/Execution/Portfolio/Broker-Seam nach Core/Trading (aus Phase 3 portiert)
- [ ] Hintergrund-Jobs als `IHostedService`
### R4 Worker auf `IHostedService` ✅
- [x] `WorkerBase` implementiert `IHostedService`; `IWorker` auf Metadaten+Trigger reduziert
- [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
- [ ] Scraper/Jobs → `IHostedService`; Signal → `ExecutionService`
@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.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. -->
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
+4 -9
View File
@@ -1,12 +1,13 @@
namespace IBKRTrader.Core.Workers;
/// <summary>
/// Vertrag für jeden Worker oder Service im System.
/// Core und Module müssen dieses Interface implementieren.
/// Metadaten-/Steuer-Vertrag für jeden Worker oder Service. Der Lebenszyklus (Start/Stop) läuft
/// über <see cref="Microsoft.Extensions.Hosting.IHostedService"/> (vom Generic Host getrieben);
/// dieses Interface liefert nur Anzeige-Infos und den manuellen Trigger.
/// </summary>
public interface IWorker
{
/// <summary>Anzeigename in dgv_workerlist.</summary>
/// <summary>Anzeigename in der Worker-Liste.</summary>
string Name { get; }
/// <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>
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>
Task TriggerAsync();
}
+22 -11
View File
@@ -2,17 +2,16 @@ using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
namespace IBKRTrader.Core.Workers;
/// <summary>
/// Basisklasse für alle periodischen Worker.
/// Verwaltet den internen CancellationToken-Lifecycle
/// Führt ExecuteAsync() im konfigurierten Interval aus
/// Aktualisiert WorkerInfo (LastRuntime, NextRuntime, Status, Info)
/// Schreibt Runs in core_worker_log
/// Basisklasse für alle Worker/Services. Implementiert <see cref="IHostedService"/> Start/Stop
/// werden vom Generic Host getrieben. Führt ExecuteAsync() im konfigurierten Interval aus,
/// aktualisiert WorkerInfo und schreibt Runs in core_worker_log.
/// </summary>
public abstract class WorkerBase : IWorker
public abstract class WorkerBase : IWorker, IHostedService
{
// ─── Abstrakte Member ─────────────────────────────────────────────────────
@@ -56,25 +55,37 @@ public abstract class WorkerBase : IWorker
// ─── 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;
// 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))
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);
return Task.CompletedTask;
}
public async Task StopAsync()
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_cts == null) return;
await _cts.CancelAsync();
if (_runLoop != null)
await _runLoop.ConfigureAwait(false);
{
try { await _runLoop.ConfigureAwait(false); }
catch (OperationCanceledException) { /* erwartet */ }
}
Info.Status = WorkerStatus.Stopped;
Info.Info = "Gestoppt";
+12 -73
View File
@@ -5,92 +5,31 @@ using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Workers;
/// <summary>
/// Orchestriert alle registrierten IWorker.
/// Startet/stoppt Worker
/// Stellt BindingList für DataGridView bereit
/// Thread-sicher via ConcurrentDictionary
/// Leichtgewichtige Registry über alle Worker/Services. Start/Stop übernimmt der Generic Host
/// (jeder Worker ist ein <see cref="Microsoft.Extensions.Hosting.IHostedService"/>). Diese Klasse
/// stellt die live-bindbare Liste für die Workers-Ansicht bereit und erlaubt den manuellen Trigger.
/// </summary>
public class WorkerEngine
{
private readonly IEnumerable<IWorker> _workers;
private readonly LoggingService _logger;
private readonly ConcurrentDictionary<string, IWorker> _registry = new();
private CancellationTokenSource _cts = new();
private readonly ConcurrentDictionary<string, IWorker> _registry = 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 WorkerEngine(IEnumerable<IWorker> workers, LoggingService logger)
{
_workers = workers;
_logger = logger;
}
// ─── Engine-Lifecycle ─────────────────────────────────────────────────────
public async Task StartAllAsync()
{
_cts = new CancellationTokenSource();
foreach (var worker in _workers)
foreach (var worker in workers)
{
RegisterWorker(worker);
if (worker.Info.Active)
await worker.StartAsync(_cts.Token);
_registry[worker.Name] = worker;
WorkerInfos.Add(worker.Info);
}
_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}");
logger.Info("Core", $"WorkerEngine: {_registry.Count} Worker/Services registriert.");
}
/// <summary>Löst einen sofortigen manuellen Run des benannten Workers aus.</summary>
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;
// WorkerInfo in BindingList eintragen (UI-Thread, falls nötig)
if (WorkerInfos is { } list)
{
if (SynchronizationContext.Current != null)
list.Add(worker.Info);
else
list.Add(worker.Info);
}
if (_registry.TryGetValue(name, out var worker))
await worker.TriggerAsync();
}
}
@@ -38,9 +38,12 @@ public sealed class CongressTradingModule : IModule
services.AddSingleton<CongressHistoryImportWorker>();
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<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)
@@ -82,7 +82,7 @@ public class WorkerBaseTests
worker.Runs.Should().Be(1);
await worker.StopAsync();
await worker.StopAsync(CancellationToken.None);
}
[Fact]
@@ -97,7 +97,7 @@ public class WorkerBaseTests
await worker.TriggerAsync();
await WaitUntilAsync(() => worker.Runs == 2, "Trigger löst zweiten Lauf aus");
await worker.StopAsync();
await worker.StopAsync(CancellationToken.None);
}
[Fact]
@@ -112,7 +112,7 @@ public class WorkerBaseTests
worker.Runs.Should().Be(1);
worker.Info.Info.Should().Contain("boom");
await worker.StopAsync();
await worker.StopAsync(CancellationToken.None);
}
[Fact]
@@ -122,7 +122,7 @@ public class WorkerBaseTests
await worker.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt");
await worker.StopAsync();
await worker.StopAsync(CancellationToken.None);
worker.Info.Status.Should().Be(WorkerStatus.Stopped);
}