DailyScheduledService.cs 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. using Microsoft.Extensions.Hosting;
  2. using Microsoft.Extensions.Logging;
  3. namespace Infrastructure.StockData;
  4. /// <summary>
  5. /// KST 기준 매일 지정 시각에 1회 실행되는 배치 베이스.
  6. /// RunOnceAsync 가 false 를 반환하면 (예: T+1 데이터 미반영) RetryDelay 간격으로 MaxRetryCount 회 재시도 후 다음 날로 넘어간다.
  7. /// </summary>
  8. internal abstract class DailyScheduledService(ILogger logger) : BackgroundService
  9. {
  10. protected static readonly TimeZoneInfo Kst = ResolveKst();
  11. protected ILogger Logger { get; } = logger;
  12. /// <summary>로그 프리픽스용 배치 이름</summary>
  13. protected abstract string JobName { get; }
  14. /// <summary>KST 실행 시각</summary>
  15. protected abstract TimeOnly TargetTime { get; }
  16. /// <summary>실패(false) 시 재시도 횟수 (기본 0 = 재시도 없음)</summary>
  17. protected virtual int MaxRetryCount => 0;
  18. /// <summary>재시도 간격</summary>
  19. protected virtual TimeSpan RetryDelay => TimeSpan.FromHours(2);
  20. /// <summary>1회 실행. true = 완료, false = 데이터 미반영 등으로 재시도 필요.</summary>
  21. protected abstract Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct);
  22. protected static DateTime NowKst() => TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Kst);
  23. protected static TimeOnly ParseTime(string value, TimeOnly fallback)
  24. {
  25. return TimeOnly.TryParse(value, out var parsed) ? parsed : fallback;
  26. }
  27. protected override async Task ExecuteAsync(CancellationToken stoppingToken)
  28. {
  29. Logger.LogInformation("[{Job}] 서비스 시작 — 실행 시각(KST): {Time}", JobName, TargetTime);
  30. while (!stoppingToken.IsCancellationRequested)
  31. {
  32. var delay = NextDelay();
  33. Logger.LogInformation("[{Job}] 다음 실행까지 대기: {Delay}", JobName, delay);
  34. try
  35. {
  36. await Task.Delay(delay, stoppingToken);
  37. }
  38. catch (OperationCanceledException)
  39. {
  40. break;
  41. }
  42. for (var attempt = 0; attempt <= MaxRetryCount; attempt++)
  43. {
  44. if (stoppingToken.IsCancellationRequested)
  45. {
  46. return;
  47. }
  48. var done = true;
  49. try
  50. {
  51. done = await RunOnceAsync(DateOnly.FromDateTime(NowKst()), stoppingToken);
  52. }
  53. catch (OperationCanceledException)
  54. {
  55. return;
  56. }
  57. catch (Exception ex)
  58. {
  59. Logger.LogError(ex, "[{Job}] 실행 예외 (attempt {Attempt})", JobName, attempt + 1);
  60. }
  61. if (done || attempt == MaxRetryCount)
  62. {
  63. break;
  64. }
  65. Logger.LogInformation("[{Job}] 데이터 미반영 — {Delay} 후 재시도 ({Attempt}/{Max})", JobName, RetryDelay, attempt + 1, MaxRetryCount);
  66. try
  67. {
  68. await Task.Delay(RetryDelay, stoppingToken);
  69. }
  70. catch (OperationCanceledException)
  71. {
  72. return;
  73. }
  74. }
  75. }
  76. }
  77. private TimeSpan NextDelay()
  78. {
  79. var nowKst = NowKst();
  80. var target = nowKst.Date.Add(TargetTime.ToTimeSpan());
  81. if (target <= nowKst)
  82. {
  83. target = target.AddDays(1);
  84. }
  85. return target - nowKst;
  86. }
  87. private static TimeZoneInfo ResolveKst()
  88. {
  89. if (TimeZoneInfo.TryFindSystemTimeZoneById("Asia/Seoul", out var tz))
  90. {
  91. return tz;
  92. }
  93. if (TimeZoneInfo.TryFindSystemTimeZoneById("Korea Standard Time", out tz))
  94. {
  95. return tz;
  96. }
  97. // KST 는 DST 없음 — 고정 +9 fallback
  98. return TimeZoneInfo.CreateCustomTimeZone("KST", TimeSpan.FromHours(9), "KST", "KST");
  99. }
  100. }