| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124 |
- using Microsoft.Extensions.Hosting;
- using Microsoft.Extensions.Logging;
- namespace Infrastructure.StockData;
- /// <summary>
- /// KST 기준 매일 지정 시각에 1회 실행되는 배치 베이스.
- /// RunOnceAsync 가 false 를 반환하면 (예: T+1 데이터 미반영) RetryDelay 간격으로 MaxRetryCount 회 재시도 후 다음 날로 넘어간다.
- /// </summary>
- internal abstract class DailyScheduledService(ILogger logger) : BackgroundService
- {
- protected static readonly TimeZoneInfo Kst = ResolveKst();
- protected ILogger Logger { get; } = logger;
- /// <summary>로그 프리픽스용 배치 이름</summary>
- protected abstract string JobName { get; }
- /// <summary>KST 실행 시각</summary>
- protected abstract TimeOnly TargetTime { get; }
- /// <summary>실패(false) 시 재시도 횟수 (기본 0 = 재시도 없음)</summary>
- protected virtual int MaxRetryCount => 0;
- /// <summary>재시도 간격</summary>
- protected virtual TimeSpan RetryDelay => TimeSpan.FromHours(2);
- /// <summary>1회 실행. true = 완료, false = 데이터 미반영 등으로 재시도 필요.</summary>
- protected abstract Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct);
- protected static DateTime NowKst() => TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Kst);
- protected static TimeOnly ParseTime(string value, TimeOnly fallback)
- {
- return TimeOnly.TryParse(value, out var parsed) ? parsed : fallback;
- }
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- Logger.LogInformation("[{Job}] 서비스 시작 — 실행 시각(KST): {Time}", JobName, TargetTime);
- while (!stoppingToken.IsCancellationRequested)
- {
- var delay = NextDelay();
- Logger.LogInformation("[{Job}] 다음 실행까지 대기: {Delay}", JobName, delay);
- try
- {
- await Task.Delay(delay, stoppingToken);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- for (var attempt = 0; attempt <= MaxRetryCount; attempt++)
- {
- if (stoppingToken.IsCancellationRequested)
- {
- return;
- }
- var done = true;
- try
- {
- done = await RunOnceAsync(DateOnly.FromDateTime(NowKst()), stoppingToken);
- }
- catch (OperationCanceledException)
- {
- return;
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "[{Job}] 실행 예외 (attempt {Attempt})", JobName, attempt + 1);
- }
- if (done || attempt == MaxRetryCount)
- {
- break;
- }
- Logger.LogInformation("[{Job}] 데이터 미반영 — {Delay} 후 재시도 ({Attempt}/{Max})", JobName, RetryDelay, attempt + 1, MaxRetryCount);
- try
- {
- await Task.Delay(RetryDelay, stoppingToken);
- }
- catch (OperationCanceledException)
- {
- return;
- }
- }
- }
- }
- private TimeSpan NextDelay()
- {
- var nowKst = NowKst();
- var target = nowKst.Date.Add(TargetTime.ToTimeSpan());
- if (target <= nowKst)
- {
- target = target.AddDays(1);
- }
- return target - nowKst;
- }
- private static TimeZoneInfo ResolveKst()
- {
- if (TimeZoneInfo.TryFindSystemTimeZoneById("Asia/Seoul", out var tz))
- {
- return tz;
- }
- if (TimeZoneInfo.TryFindSystemTimeZoneById("Korea Standard Time", out tz))
- {
- return tz;
- }
- // KST 는 DST 없음 — 고정 +9 fallback
- return TimeZoneInfo.CreateCustomTimeZone("KST", TimeSpan.FromHours(9), "KST", "KST");
- }
- }
|