namespace Application.Helpers;
///
/// SEIBro per-키 rolling 수집의 공용 오케스트레이션 (순수 로직, DB 비의존 — 테스트 가능). KrxBackfill 의 자매 헬퍼 —
/// KrxBackfill 이 "날짜 스윕"(기간 파라미터, 전시장 반환)이라면 이쪽은 "키 스윕"(ISIN/발행회사번호별 개별 호출)이다:
/// • 대상 = (키, 마지막 갱신시각) 목록. 미처리(LastUpdatedAt == null) 를 먼저, 그다음 오래된(stale) 순으로 처리한다
/// → 여러 날에 걸친 실행으로 전체 유니버스가 자동 보강·순환 갱신된다 (resumable).
/// • fetch 횟수가 maxPerRun 에 도달하면 즉시 멈춘다 → 하루 배치가 카테고리 quota(SeibroQuota) 상한 안에서 조금씩 채운다.
/// • quota 보호를 위해 호출 사이에 delayMs 만큼 쉰다 (첫 호출 전에는 쉬지 않는다 — KrxBackfill 동일).
/// • 동률(같은 LastUpdatedAt·둘 다 null)은 입력 순서를 보존한다 (stable sort).
/// 반환값은 이번 실행에서 실제 fetch 한 키 수.
///
public static class SeibroRollingSweep
{
/// 대상 키 + 마지막 갱신시각 (null = 미처리 → 최우선)
/// 해당 키를 수집·upsert (부작용 담당)
/// 이번 실행 최대 fetch 수 (quota 상한). 0 이하면 fetch 안 함
/// fetch 호출 사이 지연(ms). 0 이하면 지연 없음
/// 개별 키 수집 실패 시 호출(로깅용). null 이면 조용히 skip. 실패 키는 마커 미기록 → 다음 실행 재시도(무손실)
public static async Task RunAsync(
IReadOnlyList<(TKey Key, DateTime? LastUpdatedAt)> targets,
Func fetchAndUpsert,
int maxPerRun,
int delayMs,
CancellationToken ct,
Func? onError = null)
{
if (maxPerRun <= 0)
{
return 0;
}
// 미처리(null) 우선, 그다음 오래된 순 — OrderBy 는 stable 이라 동률은 입력 순서 유지
var ordered = targets.OrderBy(c => c.LastUpdatedAt.HasValue ? 1 : 0).ThenBy(c => c.LastUpdatedAt ?? DateTime.MinValue);
var fetched = 0;
foreach (var (key, _) in ordered)
{
ct.ThrowIfCancellationRequested();
if (fetched > 0 && delayMs > 0)
{
await Task.Delay(delayMs, ct);
}
try
{
await fetchAndUpsert(key, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// 개별 키 수집/파싱 실패(예: SEIBro soft-error 응답)는 run 전체를 중단시키지 않고 skip.
// 마커는 fetchAndUpsert 내부(성공 시)에만 기록되므로 실패 키는 다음 실행에 재시도된다(무손실).
if (onError is not null)
{
await onError(key, ex);
}
}
fetched++;
if (fetched >= maxPerRun)
{
break;
}
}
return fetched;
}
}