Files
ExperionCrawler/fastTable/step7.md

16 KiB

STEP 7 — ExperionFastService 신규 파일 생성

사전 확인 (작업 전 반드시 수행)

  1. src/Infrastructure/OpcUa/ 디렉토리 목록을 확인한다.
  2. 아래 항목을 확인하고 기록한다:
    • STEP 5, 6이 완료되어 인터페이스와 DB 메서드가 존재하는가?
    • ExperionFastService.cs 파일이 이미 존재하는가? → 존재하면 내용 비교 후 필요한 부분만 수정 (신규 생성)
    • IExperionOpcClientIsConnectedAsync, CreateSessionAsync가 구현되어 있는가?
    • IOpcUaConfigProvider 인터페이스가 존재하는가? (주입 경로 확인)
    • Opc.Ua.Client NuGet 패키지가 Infrastructure 프로젝트에 있는가?

작업 내용

파일: src/Infrastructure/OpcUa/ExperionFastService.cs (신규 생성)

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;
using Opc.Ua;
using Opc.Ua.Client;

namespace ExperionCrawler.Infrastructure.OpcUa;

/// <summary>
/// fastRecord 데이터 수집 서비스.
/// 세션별 별도 OPC UA Subscription을 관리하고, 2초마다 배치 INSERT.
/// IHostedService로 등록하여 앱 시작/종료 시 자동 관리.
/// </summary>
public class ExperionFastService : IExperionFastService, IHostedService, IDisposable
{
    private readonly IServiceScopeFactory         _scopeFactory;
    private readonly ILogger<ExperionFastService> _logger;
    private readonly IOpcUaConfigProvider         _configProvider;
    private readonly IExperionOpcClient           _opcClient;

    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 FlushIntervalMs       = 2_000;

    public ExperionFastService(
        IServiceScopeFactory scopeFactory,
        ILogger<ExperionFastService> logger,
        IOpcUaConfigProvider configProvider,
        IExperionOpcClient opcClient)
    {
        _scopeFactory   = scopeFactory;
        _logger         = logger;
        _configProvider = configProvider;
        _opcClient      = opcClient;
    }

    // ── IHostedService ────────────────────────────────────────────────────────

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        // 앱 시작 시 Running 상태 세션 → Failed 마킹
        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)
            await _monitorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);

        foreach (var kvp in _sessions)
            kvp.Value.Cancel = true;

        await Task.Delay(2000).ConfigureAwait(false); // 마지막 flush 대기
    }

    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 (request.SamplingMs is not (100 or 250 or 500 or 1000))
            throw new ArgumentException("샘플링 간격은 100/250/500/1000ms 중 하나여야 합니다.");

        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}개까지입니다.");

        var cfg = await _configProvider.GetConfigAsync(new ExperionServerConfig());
        if (string.IsNullOrEmpty(cfg?.EndpointUrl))
            throw new InvalidOperationException("서버 엔드포인트 URL이 설정되어 있지 않습니다.");

        if (!await _opcClient.IsConnectedAsync(cfg))
            throw new InvalidOperationException("OPC UA 서버에 연결되어 있지 않습니다.");

        // 노드 유효성 사전 검증
        foreach (var tagName in request.TagList)
        {
            var nodeId = await db.GetNodeIdByTagNameAsync(tagName);
            if (string.IsNullOrEmpty(nodeId))
                throw new ArgumentException($"태그 '{tagName}'의 nodeId를 찾을 수 없습니다.");

            var readResult = await _opcClient.ReadTagAsync(cfg, nodeId);
            if (!readResult.Success)
                throw new ArgumentException($"태그 '{tagName}' 읽기 실패: {readResult.ErrorMessage}");
        }

        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,
            Buffer     = new ConcurrentQueue<FastRecord>()
        };

        _sessions[session.Id] = ctx;
        await StartSubscriptionAsync(ctx, cfg);
        await db.UpdateFastSessionStatusAsync(session.Id, "Running");

        _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;
        await FlushBufferAsync(ctx).ConfigureAwait(false);
        await StopSubscriptionAsync(ctx).ConfigureAwait(false);

        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 StartSubscriptionAsync(FastSessionContext ctx, ApplicationConfiguration cfg)
    {
        var session = await _opcClient.CreateSessionAsync(cfg);
        var subscription = new Subscription(session.DefaultSubscription)
        {
            PublishingInterval = ctx.SamplingMs,
            KeepAliveCount     = 10
        };

        foreach (var tagName in ctx.TagList)
        {
            var nodeId = await GetNodeIdAsync(tagName);
            var item   = new MonitoredItem(subscription)
            {
                StartNodeId      = nodeId,
                SamplingInterval = ctx.SamplingMs,
                DisplayName      = tagName
            };
            item.Notification += (_, e) => OnNotification(ctx, e, tagName);
            subscription.AddItem(item);
        }

        await session.AddSubscriptionAsync(subscription);
        subscription.Create();

        ctx.Subscription = subscription;
        ctx.Session      = session;
    }

    private async Task StopSubscriptionAsync(FastSessionContext ctx)
    {
        if (ctx.Subscription != null)
        {
            ctx.Subscription.Delete(false);
            ctx.Subscription = null;
        }
        if (ctx.Session != null)
        {
            await ctx.Session.CloseAsync();
            await ctx.Session.DisposeAsync();
            ctx.Session = null;
        }
    }

    private void OnNotification(FastSessionContext ctx, MonitoredItemNotificationEventArgs e, string tagName)
    {
        if (ctx.Cancel) return;

        if (e.NotificationValue is MonitoredItemNotification notification)
        {
            ctx.Buffer.Enqueue(new FastRecord
            {
                SessionId  = ctx.SessionId,
                RecordedAt = DateTime.UtcNow,
                TagName    = tagName,
                Value      = notification.Value.Value?.ToString()
            });
            ctx.TotalRows++;
        }
    }

    private async Task FlushBufferAsync(FastSessionContext ctx)
    {
        var buffer = new List<FastRecord>();
        while (ctx.Buffer.TryDequeue(out var record))
            buffer.Add(record);

        if (buffer.Count == 0) return;

        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        await db.BatchInsertFastRecordsAsync(buffer);
        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);
        }
    }

    private async Task MonitorLoopAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(FlushIntervalMs, 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 StopSessionAsync(ctx.SessionId);
                        continue;
                    }

                    await FlushBufferAsync(ctx);
                }
            }
            catch (OperationCanceledException) { }
            catch (Exception ex)
            {
                _logger.LogError(ex, "[Fast] 모니터링 루프 오류");
            }
        }
    }

    private async Task<string> GetNodeIdAsync(string tagName)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        return await db.GetNodeIdByTagNameAsync(tagName) ?? string.Empty;
    }

    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);

    // ── Inner Class ────────────────────────────────────────────────────────────

    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 ConcurrentQueue<FastRecord>  Buffer      { get; set; } = new();
        public int                          TotalRows   { get; set; }  // 누적 행 수
        public bool                         Cancel      { get; set; }
        public ISession?                    Session     { get; set; }
        public Subscription?                Subscription { get; set; }
    }
}

사후 확인 (작업 후 반드시 수행)

  1. ExperionFastService.cs 파일을 읽어 전체 구조를 확인한다.
  2. 아래 항목을 하나씩 확인한다:
    • 클래스가 IExperionFastService, IHostedService, IDisposable 모두 구현하는가?
    • StartAsync — Running 세션 Failed 마킹 로직이 있는가?
    • OnNotificationMonitoredItemNotification 타입 체크를 하는가? (e.NotificationValue is MonitoredItemNotification)
    • FlushBufferAsyncctx.TotalRows >= MaxRowsPerSession 체크가 있는가?
    • MapToInfoJsonSerializer.Deserialize<string[]> 사용하는가?
    • FastSessionContext.TotalRows 필드가 있는가?
    • 파일 상단에 using System.Text.Json; 이 있는가?
    • using Opc.Ua;, using Opc.Ua.Client; 가 있는가?
  3. dotnet build src/Web 실행 → 에러 0, 경고 14개 (기존 경고 포함) 확인
  4. 문제가 있으면 수정 후 다시 빌드 확인

완료 조건

  • dotnet build src/Web 결과: 에러 0, 경고 14개 (기존 경고 포함)
  • ExperionFastService.cs 파일 존재 및 빌드 통과