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; } }