Reconnect laesst keinen Lesethread mehr zurueck (#504, #505)

Nach jedem Verbindungsaufbau lief ein weiterer IBKR-Lesethread mit: Die
Schleifenbedingung "while (_socket.IsConnected())" ist nach einem Reconnect
wieder wahr, weil dasselbe Socket-Objekt neu verbunden wird. Auf der Test-VM
waren es nach zwei TWS-Neustarts drei Threads gleichzeitig.

Alle warten auf demselben EReaderMonitorSignal, und ein Signal weckt genau
einen. Weckt es den falschen, bleibt die Nachricht in der Warteschlange eines
anderen Readers liegen. Bei laufend wiederholten Push-Daten faellt das nicht
auf, die einmalige Antwort auf reqPositions laeuft in die Frist - der Dienst
sieht gesund aus und ist blind. Zeitueberschreitungen je Tag: 1, 3, 215, 96.

Jeder Lesethread bekommt jetzt eine Generationsnummer. Veraltete beenden sich
beim naechsten Aufwachen und reichen das verbrauchte Signal weiter, damit die
Nachricht des Nachfolgers nicht liegen bleibt.

Dazu ein Zwischenspeicher fuer die Handelszeiten (#505): dieselbe Auskunft
wurde viermal je Stunde und Instrument neu angefragt, obwohl sie sich
hoechstens taeglich aendert. Gueltig bis zum Ende des gemeldeten Zeitraums;
Fehlschlaege werden bewusst nicht gemerkt, sonst bliebe ein Modul nach einer
einzigen Stoerung bis zum Neustart stumm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-08 09:43:09 +02:00
co-authored by Claude Opus 5
parent 362207c6f2
commit 2ca5bc6553
2 changed files with 68 additions and 5 deletions
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
@@ -26,6 +27,10 @@ public sealed class IbkrBrokerClient
private readonly TimeSpan _requestTimeout; private readonly TimeSpan _requestTimeout;
private readonly TimeSpan _orderTimeout; private readonly TimeSpan _orderTimeout;
// Handelszeiten je Instrument, bis zum Ende des gemeldeten Zeitraums gueltig (#505).
private readonly ConcurrentDictionary<string, MarketSchedule> _schedules =
new(StringComparer.OrdinalIgnoreCase);
public IbkrBrokerClient(SettingsService settings, LoggingService logger) public IbkrBrokerClient(SettingsService settings, LoggingService logger)
{ {
_logger = logger; _logger = logger;
@@ -113,11 +118,38 @@ public sealed class IbkrBrokerClient
return await _connection!.RequestExecutionsAsync(since, _requestTimeout, ct).ConfigureAwait(false); return await _connection!.RequestExecutionsAsync(since, _requestTimeout, ct).ConfigureAwait(false);
} }
/// <summary>
/// Handelszeiten des Instruments, zwischengespeichert bis zum Ende des gemeldeten Zeitraums.
///
/// <para><b>Warum ueberhaupt ein Zwischenspeicher.</b> Ein Modul im 15-Minuten-Takt fragte sonst
/// dieselbe Auskunft viermal je Stunde und Instrument neu ab, obwohl sie sich hoechstens
/// taeglich aendert. Jede dieser Anfragen belegt einen Antwortplatz beim Broker und kann in die
/// Zeitueberschreitung laufen – Aufwand ohne Ertrag.</para>
///
/// <para><b>Fehlschlaege werden nicht gemerkt.</b> Ein <c>null</c> heisst „keine Auskunft" und
/// gilt beim Aufrufer als geschlossen. Wuerde das haengenbleiben, bliebe ein Modul nach einer
/// einzigen Stoerung bis zum Neustart stumm.</para>
/// </summary>
public async Task<MarketSchedule?> GetScheduleAsync(InstrumentRef instrument, CancellationToken ct = default) public async Task<MarketSchedule?> GetScheduleAsync(InstrumentRef instrument, CancellationToken ct = default)
{ {
var key = instrument.Key;
// Gueltig, solange der gemeldete Zeitraum noch in der Zukunft endet. Danach wuerde die
// Auskunft still veralten - und "gestern war offen" ist die gefaehrliche Richtung.
if (_schedules.TryGetValue(key, out var cached) &&
cached.Sessions.Count > 0 &&
cached.Sessions[^1].CloseUtc > DateTime.UtcNow)
{
return cached;
}
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null; if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null;
return await _connection!.RequestMarketScheduleAsync(instrument, _requestTimeout, ct).ConfigureAwait(false); var schedule = await _connection!.RequestMarketScheduleAsync(instrument, _requestTimeout, ct)
.ConfigureAwait(false);
if (schedule is not null) _schedules[key] = schedule;
return schedule;
} }
public async Task<IReadOnlyList<BrokerOrder>> GetOpenOrdersAsync(CancellationToken ct = default) public async Task<IReadOnlyList<BrokerOrder>> GetOpenOrdersAsync(CancellationToken ct = default)
@@ -55,6 +55,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
private bool _zoneWarningIssued; private bool _zoneWarningIssued;
private volatile bool _ready; private volatile bool _ready;
private volatile bool _disposed; private volatile bool _disposed;
// Laufende Nummer des Lesethreads. Jeder Verbindungsaufbau startet einen neuen; die aelteren
// erkennen an dieser Nummer, dass sie nicht mehr zustaendig sind, und beenden sich.
private int _readerGeneration;
private int _nextRequestId = 1000; private int _nextRequestId = 1000;
private int _nextOrderId = -1; private int _nextOrderId = -1;
private string? _account; private string? _account;
@@ -235,29 +238,57 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
} }
} }
/// <summary>
/// Startet den Lesethread fuer die gerade aufgebaute Verbindung.
///
/// <para><b>Warum hier eine Generationsnummer noetig ist.</b> <see cref="_socket"/> und
/// <see cref="_signal"/> ueberleben den Reconnect – wir verbinden dasselbe Socket-Objekt neu.
/// Ein Lesethread mit der Bedingung <c>while (_socket.IsConnected())</c> wuerde deshalb nach
/// einem Reconnect <b>weiterlaufen</b>, denn die Bedingung ist wieder wahr. Nach zwei Nachtneu-
/// starts der TWS liefen am 2026-09-08 drei Lesethreads gleichzeitig (#504).</para>
///
/// <para>Das ist kein harmloses Leck: Alle warten auf dasselbe
/// <see cref="EReaderMonitorSignal"/>, und ein Signal weckt genau einen. Weckt es den falschen,
/// bleibt die Nachricht in der Warteschlange eines anderen Readers liegen, bis zufaellig das
/// naechste Signal kommt. Was der Broker laufend wiederholt (Konto, Kurse), faellt dadurch nicht
/// auf – die einmalige Antwort auf <c>reqPositions</c> oder <c>reqContractDetails</c> laeuft in
/// die Zeitueberschreitung. Der Dienst sieht dann gesund aus und ist blind.</para>
/// </summary>
private void StartReader() private void StartReader()
{ {
var reader = new EReader(_socket, _signal); var generation = Interlocked.Increment(ref _readerGeneration);
var reader = new EReader(_socket, _signal);
reader.Start(); reader.Start();
new Thread(() => new Thread(() =>
{ {
while (_socket.IsConnected()) while (_socket.IsConnected() && Volatile.Read(ref _readerGeneration) == generation)
{ {
_signal.waitForSignal(); _signal.waitForSignal();
// Nach dem Aufwachen erneut pruefen: Waehrend wir warteten, kann ein Reconnect
// einen neuen Reader gestartet haben. Dann gehoert diese Nachricht ihm.
if (Volatile.Read(ref _readerGeneration) != generation)
{
// Das verbrauchte Signal weitergeben, sonst bliebe die Nachricht des Nachfolgers
// bis zum naechsten Eintreffen liegen.
_signal.issueSignal();
break;
}
try try
{ {
reader.processMsgs(); reader.processMsgs();
} }
catch (Exception ex) catch (Exception ex)
{ {
if (!_disposed) if (!_disposed && Volatile.Read(ref _readerGeneration) == generation)
_logger.Warn(LogModule, $"Nachrichten-Reader beendet: {ex.Message}"); _logger.Warn(LogModule, $"Nachrichten-Reader beendet: {ex.Message}");
break; break;
} }
} }
}) })
{ IsBackground = true, Name = "IBKR-Reader" }.Start(); { IsBackground = true, Name = $"IBKR-Reader-{generation}" }.Start();
} }
private void SafeDisconnect() private void SafeDisconnect()