fix: realtime supervisor — OPC 장기단절 후 자동복구 (OCE 오인중지 + 연결 타임아웃)

- SuperviseAsync: catch(OperationCanceledException){break} 가 OPC SDK 연결 타임아웃
  (TaskCanceledException=OCE 하위)을 '우리 취소'로 오인해 '중지됨'으로 영구정지되던
  버그 수정 → when(ct.IsCancellationRequested) 가드, 그 외 OCE는 일반 오류처럼 재시도
- CreateSessionAsync: CancellationToken.None → 실제 토큰 전달
  (죽은 서버 상대 세션생성이 SDK 기본 OperationTimeout ~2분 블로킹하던 원인 제거)
- 1회 연결 시도 10초 타임아웃(연결토큰 + WaitAsync 하드 백스톱)
- 재시도 주기 30s → 10s (리던던트 서버 전환 시 수분 단절도 빠르게 재포착)
- 라이브 검증: Experion kill→2.5분→revive 시 재연결 순환 유지 후 수동개입 0 자동 구독복구

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-25 17:31:23 +09:00
parent 50705ab0e8
commit 81e2ea145a

View File

@@ -256,8 +256,9 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
// 실패/이상 → RetryDelay 후 무한 재시도 (3회-후-포기 없음, 주기적 자동 재개) // 실패/이상 → RetryDelay 후 무한 재시도 (3회-후-포기 없음, 주기적 자동 재개)
private async Task SuperviseAsync(CancellationToken ct) private async Task SuperviseAsync(CancellationToken ct)
{ {
const int RetryDelayMs = 30_000; // 연결 실패/이상 후 재시도 주기 const int RetryDelayMs = 10_000; // 연결 실패/이상 후 재시도 주기 (리던던트 서버 전환 등 빠른 재포착)
const int HealthPollMs = 5_000; // 건강 점검 주기 const int ConnectTimeoutMs = 10_000; // 1회 연결 시도 최대 시간 (죽은 서버에 무한 블로킹 방지)
const int HealthPollMs = 5_000; // 건강 점검 주기
// flush 루프는 supervisor 수명 동안 단 1회만 기동 (재연결마다 누적되던 버그 차단) // flush 루프는 supervisor 수명 동안 단 1회만 기동 (재연결마다 누적되던 버그 차단)
_flushTask = Task.Run(() => FlushLoopAsync(ct), ct); _flushTask = Task.Run(() => FlushLoopAsync(ct), ct);
@@ -266,7 +267,14 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
{ {
try try
{ {
await ConnectAndSubscribeAsync(ct); // 1회 연결 시도에 타임아웃을 건다(죽은/전환중 서버에 길게 묶이지 않게).
// 타임아웃 시 OCE/TimeoutException이 나지만 우리(ct) 취소가 아니므로 아래 일반 catch에서 재시도된다.
using (var connCts = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
connCts.CancelAfter(ConnectTimeoutMs);
await ConnectAndSubscribeAsync(connCts.Token)
.WaitAsync(TimeSpan.FromMilliseconds(ConnectTimeoutMs + 2_000), ct); // SDK가 토큰 무시해도 하드 백스톱
}
// 연결 유지 동안 건강 감시 // 연결 유지 동안 건강 감시
string? fault = null; string? fault = null;
@@ -282,14 +290,18 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
_logger.LogWarning("[Realtime] 링크 이상 감지 ({Fault}) — {Sec}초 후 재연결", _linkFault, RetryDelayMs / 1000); _logger.LogWarning("[Realtime] 링크 이상 감지 ({Fault}) — {Sec}초 후 재연결", _linkFault, RetryDelayMs / 1000);
await CleanupSessionAsync(); await CleanupSessionAsync();
} }
catch (OperationCanceledException) { break; } // 우리(앱 종료/운전원 정지)가 취소한 경우에만 루프 종료.
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
// OPC SDK 연결/세션 타임아웃은 TaskCanceledException(=OCE 하위)으로 던져진다.
// 우리 취소가 아니면 일반 오류와 동일하게 재시도해야 함.
// (이전: catch(OCE){break} 가 SDK 타임아웃을 우리 취소로 오인 → '중지됨' 으로 죽어 복구 불가 버그)
catch (Exception ex) catch (Exception ex)
{ {
_running = false; _running = false;
_stalled = true; _stalled = true;
_linkFault = ex.Message; _linkFault = ex.Message;
_statusMsg = $"재연결 대기 중: {ex.Message}"; _statusMsg = $"재연결 대기 중: {ex.Message}";
_logger.LogWarning(ex, "[Realtime] 연결 오류 — {Sec}초 후 재시도", RetryDelayMs / 1000); _logger.LogWarning(ex, "[Realtime] 연결 오류({Type}) — {Sec}초 후 재시도", ex.GetType().Name, RetryDelayMs / 1000);
await CleanupSessionAsync(); await CleanupSessionAsync();
} }
@@ -310,7 +322,7 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
var appConfig = await BuildConfigAsync(_currentCfg); var appConfig = await BuildConfigAsync(_currentCfg);
var endpoint = await SelectEndpointAsync(appConfig, _currentCfg.EndpointUrl, ct); var endpoint = await SelectEndpointAsync(appConfig, _currentCfg.EndpointUrl, ct);
_session = await CreateSessionAsync(appConfig, endpoint, _currentCfg); _session = await CreateSessionAsync(appConfig, endpoint, _currentCfg, ct);
_logger.LogInformation("[Realtime] 세션 생성 완료"); _logger.LogInformation("[Realtime] 세션 생성 완료");
@@ -500,7 +512,8 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
private static async Task<ISession> CreateSessionAsync( private static async Task<ISession> CreateSessionAsync(
ApplicationConfiguration appConfig, ApplicationConfiguration appConfig,
ConfiguredEndpoint endpoint, ConfiguredEndpoint endpoint,
ExperionServerConfig cfg) ExperionServerConfig cfg,
CancellationToken ct = default)
{ {
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password)); var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type #pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type
@@ -512,7 +525,7 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
60_000, 60_000,
identity, identity,
null, null,
CancellationToken.None); ct); // 연결 타임아웃 토큰 전달 (이전 CancellationToken.None → 죽은 서버에 ~2분 블로킹 원인)
#pragma warning restore CS8625 #pragma warning restore CS8625
} }