@
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:
+2
-3
@@ -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
@@ -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>();
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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)
|
||||
foreach (var worker in workers)
|
||||
{
|
||||
_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)
|
||||
if (WorkerInfos is { } list)
|
||||
/// <summary>Löst einen sofortigen manuellen Run des benannten Workers aus.</summary>
|
||||
public async Task TriggerWorkerAsync(string name)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user