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; /// /// fastRecord 데이터 수집 서비스. /// realtime_table에서 지정한 샘플링 간격마다 태그 값을 복사하여 fast_records 테이블에 저장. /// OPC UA 직접 연결 없이 기존 실시간 구독 결과(realtime_table)를 재활용. /// public class ExperionFastService : IExperionFastService, IHostedService, IDisposable { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly ConcurrentDictionary _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 logger) { _scopeFactory = scopeFactory; _logger = logger; } // ── IHostedService ──────────────────────────────────────────────────────── public async Task StartAsync(CancellationToken cancellationToken) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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); } public void Dispose() => _cts?.Dispose(); // ── IExperionFastService ────────────────────────────────────────────────── public async Task 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(); 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(); 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(); 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(); await db.UpdateFastSessionPinnedAsync(sessionId, pinned); } public async Task GetSessionAsync(int sessionId) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var session = await db.GetFastSessionAsync(sessionId); return session == null ? null : MapToInfo(session); } public async Task> GetSessionsAsync() { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return (await db.GetFastSessionsAsync()).Select(MapToInfo); } public async Task GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long") { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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(); 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(); 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(); 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(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; } } } /// /// 만료된 FastSession을 정리하는 BackgroundService. /// 매일 03:00 UTC에 실행. pinned = true 세션과 retention_days = null 세션은 제외. /// public class ExperionFastCleanupService : BackgroundService { private readonly IServiceProvider _sp; private readonly ILogger _logger; public ExperionFastCleanupService( IServiceProvider sp, ILogger 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(); 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] 정리 작업 오류"); } } } }