342 lines
14 KiB
C#
342 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)
|
|
await _monitorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
|
}
|
|
|
|
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] 정리 작업 오류");
|
|
}
|
|
}
|
|
}
|
|
}
|