Files
ExperionCrawler/src/Infrastructure/OpcUa/ExperionFastService.cs
windpacer f71ec310e4 feat: P&ID 그래프 파이프라인 및 MCP 서버 개선
- P&ID 그래프 파이프라인 구현 (py)
  - pid_geometric_extractor.py: 기하학적 특징 추출
  - pid_intelligent_mapper.py: 태그 매핑
  - pid_topology_builder.py: 위상 구축
  - test_pipeline_phase2.py, test_pipeline_phase3.py: 테스트

- MCP 서버 개선
  - server.py: 멀티프로세싱 지원
  - pipeline/: 분석, 추출, 매핑, 위상 모듈 추가

- C# P&ID 그래프 서비스
  - PidGraphDtos.cs: DTO 정의
  - PidGraphService.cs: 비즈니스 로직
  - PidGraphController.cs: API 컨트롤러

- OPC UA 서비스 개선
  - ExperionOpcServerService.cs
  - ExperionRealtimeService.cs
  - ExperionFastService.cs

- MCP 클라이언트 및 호스팅 서비스 개선
  - McpClient.cs
  - McpServerHostedService.cs

- 웹 UI 개선
  - pid_graph_view.html: P&ID 그래프 뷰어
  - pid-viewer.js: 뷰어 로직
  - app.js: 메인 앱
  - pid_graph.css: 스타일

- 프로젝트 설정 업데이트
  - ExperionCrawler.csproj
  - Program.cs
2026-05-03 03:50:20 +09:00

349 lines
14 KiB
C#

using System.Collections.Concurrent;
using System.Text.Json;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// fastRecord 데이터 수집 서비스.
/// realtime_table에서 지정한 샘플링 간격마다 태그 값을 복사하여 fast_records 테이블에 저장.
/// OPC UA 직접 연결 없이 기존 실시간 구독 결과(realtime_table)를 재활용.
/// </summary>
public class ExperionFastService : IExperionFastService, IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExperionFastService> _logger;
private readonly ConcurrentDictionary<int, FastSessionContext> _sessions = new();
private CancellationTokenSource? _cts;
private Task? _monitorTask;
private const int MaxConcurrentSessions = 3;
private const int MaxRowsPerSession = 5_000_000;
private const int MonitorIntervalMs = 1_000;
private static readonly int[] AllowedSamplingMs = [1000, 5000, 10000, 30000, 60000];
public ExperionFastService(
IServiceScopeFactory scopeFactory,
ILogger<ExperionFastService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
// ── IHostedService ────────────────────────────────────────────────────────
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var sessions = await db.GetFastSessionsAsync();
foreach (var s in sessions.Where(s => s.Status == "Running"))
{
_logger.LogWarning("[Fast] 앱 시작 시 Running 세션 {Id} → Failed 마킹", s.Id);
await db.UpdateFastSessionStatusAsync(s.Id, "Failed");
}
_cts = new CancellationTokenSource();
_monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token), _cts.Token);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_cts?.Cancel();
if (_monitorTask != null)
{
try
{
// 종료 시 대기 시간을 2초로 단축하여 빠른 셧다운 유도
await _monitorTask.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) { _logger.LogDebug(ex, "[Fast] StopAsync 대기 중 타임아웃 또는 취소 발생"); }
}
}
public void Dispose() => _cts?.Dispose();
// ── IExperionFastService ──────────────────────────────────────────────────
public async Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request)
{
if (request.TagList.Length == 0 || request.TagList.Length > 8)
throw new ArgumentException("태그는 1~8개까지 가능합니다.");
if (!AllowedSamplingMs.Contains(request.SamplingMs))
throw new ArgumentException(
$"샘플링 간격은 {string.Join('/', AllowedSamplingMs.Select(ms => ms / 1000 + "s"))} 중 하나여야 합니다.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var runningCount = (await db.GetFastSessionsAsync()).Count(s => s.Status == "Running");
if (runningCount >= MaxConcurrentSessions)
throw new InvalidOperationException($"동시 실행 가능한 세션은 {MaxConcurrentSessions}개까지입니다.");
// 태그가 realtime_table에 존재하는지 검증
var realtimeRecords = (await db.GetRealtimeRecordsByTagNamesAsync(request.TagList)).ToList();
var found = realtimeRecords.Select(r => r.TagName).ToHashSet();
foreach (var tag in request.TagList)
{
if (!found.Contains(tag))
throw new ArgumentException($"태그 '{tag}'이 realtime_table에 없습니다. 포인트빌더에서 추가 후 구독을 시작하세요.");
}
var session = await db.CreateFastSessionAsync(new FastSessionCreateRequest(
Name: request.Name,
SamplingMs: request.SamplingMs,
DurationSec: request.DurationSec,
TagList: request.TagList,
RetentionDays: request.RetentionDays));
var ctx = new FastSessionContext
{
SessionId = session.Id,
TagList = request.TagList,
SamplingMs = request.SamplingMs,
DurationSec = request.DurationSec,
StartedAt = DateTime.UtcNow,
LastSampledAt = DateTime.MinValue
};
_sessions[session.Id] = ctx;
_logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개, {Ms}ms, {Sec}s",
session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec);
return MapToInfo(session);
}
public async Task StopSessionAsync(int sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var ctx))
throw new InvalidOperationException($"세션 {sessionId}를 찾을 수 없습니다.");
ctx.Cancel = true;
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.UpdateFastSessionStatusAsync(sessionId, "Completed");
await db.UpdateFastSessionRowCountAsync(sessionId, ctx.TotalRows);
_sessions.TryRemove(sessionId, out _);
_logger.LogInformation("[Fast] 세션 {Id} 중지 — 총 {Count}행", sessionId, ctx.TotalRows);
}
public async Task DeleteSessionAsync(int sessionId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.DeleteFastSessionAsync(sessionId);
_sessions.TryRemove(sessionId, out _);
}
public async Task PinSessionAsync(int sessionId, bool pinned)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.UpdateFastSessionPinnedAsync(sessionId, pinned);
}
public async Task<FastSessionInfo?> GetSessionAsync(int sessionId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var session = await db.GetFastSessionAsync(sessionId);
return session == null ? null : MapToInfo(session);
}
public async Task<IEnumerable<FastSessionInfo>> GetSessionsAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
return (await db.GetFastSessionsAsync()).Select(MapToInfo);
}
public async Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long")
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
return await db.GetFastRecordsAsync(sessionId, from, to);
}
public async Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.ExportFastRecordsToCsvAsync(sessionId, stream, from, to);
}
// ── Private ────────────────────────────────────────────────────────────────
private async Task MonitorLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(MonitorIntervalMs, ct);
foreach (var kvp in _sessions.ToList())
{
var ctx = kvp.Value;
if (ctx.Cancel) continue;
if ((DateTime.UtcNow - ctx.StartedAt).TotalSeconds >= ctx.DurationSec)
{
ctx.Cancel = true;
await CompleteSessionAsync(ctx.SessionId, ctx.TotalRows, "Completed");
continue;
}
if ((DateTime.UtcNow - ctx.LastSampledAt).TotalMilliseconds >= ctx.SamplingMs)
{
ctx.LastSampledAt = DateTime.UtcNow;
await SampleAsync(ctx);
}
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogError(ex, "[Fast] 모니터링 루프 오류");
}
}
}
private async Task SampleAsync(FastSessionContext ctx)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var realtimeRecords = await db.GetRealtimeRecordsByTagNamesAsync(ctx.TagList);
var now = DateTime.UtcNow;
var records = realtimeRecords
.Select(r => new FastRecord
{
SessionId = ctx.SessionId,
RecordedAt = now,
TagName = r.TagName,
Value = r.LiveValue
})
.ToList();
if (records.Count == 0) return;
await db.BatchInsertFastRecordsAsync(records);
ctx.TotalRows += records.Count;
await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows);
if (ctx.TotalRows >= MaxRowsPerSession)
{
ctx.Cancel = true;
await db.UpdateFastSessionStatusAsync(ctx.SessionId, "RowLimitReached");
_sessions.TryRemove(ctx.SessionId, out _);
_logger.LogWarning("[Fast] 세션 {Id} RowLimitReached ({Max}행)", ctx.SessionId, MaxRowsPerSession);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[Fast] 세션 {Id} 샘플링 오류", ctx.SessionId);
}
}
private async Task CompleteSessionAsync(int sessionId, int totalRows, string status)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.UpdateFastSessionStatusAsync(sessionId, status);
await db.UpdateFastSessionRowCountAsync(sessionId, totalRows);
_sessions.TryRemove(sessionId, out _);
_logger.LogInformation("[Fast] 세션 {Id} {Status} — 총 {Count}행", sessionId, status, totalRows);
}
private static FastSessionInfo MapToInfo(FastSession s) => new(
Id: s.Id,
Name: s.Name,
StartedAt: s.StartedAt,
EndedAt: s.EndedAt,
Status: s.Status,
SamplingMs: s.SamplingMs,
DurationSec: s.DurationSec,
TagList: JsonSerializer.Deserialize<string[]>(s.TagList) ?? [],
RowCount: s.RowCount,
RetentionDays: s.RetentionDays,
Pinned: s.Pinned);
private sealed class FastSessionContext
{
public int SessionId { get; set; }
public string[] TagList { get; set; } = [];
public int SamplingMs { get; set; }
public int DurationSec { get; set; }
public DateTime StartedAt { get; set; }
public DateTime LastSampledAt { get; set; }
public int TotalRows { get; set; }
public bool Cancel { get; set; }
}
}
/// <summary>
/// 만료된 FastSession을 정리하는 BackgroundService.
/// 매일 03:00 UTC에 실행. pinned = true 세션과 retention_days = null 세션은 제외.
/// </summary>
public class ExperionFastCleanupService : BackgroundService
{
private readonly IServiceProvider _sp;
private readonly ILogger<ExperionFastCleanupService> _logger;
public ExperionFastCleanupService(
IServiceProvider sp,
ILogger<ExperionFastCleanupService> logger)
{
_sp = sp;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var now = DateTime.UtcNow;
var next = now.Date.AddDays(1).AddHours(3);
var delay = next - now;
if (delay < TimeSpan.Zero) delay = TimeSpan.Zero;
try { await Task.Delay(delay, stoppingToken); }
catch (OperationCanceledException) { break; }
try
{
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var sessions = await db.GetFastSessionsAsync();
var cutoff = DateTime.UtcNow;
foreach (var s in sessions.Where(s =>
!s.Pinned &&
s.RetentionDays.HasValue &&
s.StartedAt.AddDays(s.RetentionDays.Value) < cutoff))
{
_logger.LogInformation("[FastCleanup] 세션 {Id} 삭제 (retention {Days}일 초과)", s.Id, s.RetentionDays);
await db.DeleteFastSessionAsync(s.Id);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[FastCleanup] 정리 작업 오류");
}
}
}
}