feat(S15P11A705-159): 유실·정지된 AI 처리를 복구하는 재스캔 Scheduler와 FAILED Finalizer 추가 - #104
Conversation
AI 연동의 실패 경로 네 곳이 모두 "재스캔이 복구한다"를 안전망으로 전제하는데 그 재스캔이 없었습니다. 큐 포화로 버려진 요청, 삼켜진 호출 실패, 커밋 이후 리스너의 조립 실패, 그리고 FastAPI가 202 이후 내부에서 실패한 경우 — 네 경우 모두 한 번 실패한 Context가 영구히 PENDING 으로 남았습니다. 상태만 보면 "처리 대기 중"이라 정상과 구별되지 않습니다. 정본은 AI 파트가 소유한 docs/ai/spec/ai-rescan-scheduler.md 3~7장입니다. 한 회차의 순서는 Finalize → 후보 잠금·retry 증가 → 커밋 → Context 재조회 → 삭제 확인 → FastAPI 호출입니다. Bean을 셋으로 가른 것은 트랜잭션 프록시 때문입니다 — 한 클래스에 두면 자기 메서드 호출이 프록시를 지나지 않아 트랜잭션 없이 돌고, 그러면 FOR UPDATE SKIP LOCKED의 잠금이 조회 직후 풀려 중복 방어가 조용히 사라집니다. 스케줄러 자신은 트랜잭션을 열지 않습니다 — 열면 뒤의 HTTP 호출 시간만큼 행 잠금과 DB 커넥션이 붙잡힙니다. 만료 술어는 재스캔과 Finalizer가 한 상수를 공유합니다. 명세 6.1이 "만료 기준은 재스캔과 동일하다"고 정하므로 따로 적으면 한쪽만 고쳐 기준이 갈라집니다. 시각 비교는 DB now()로 합니다 — 자바에서 컷오프를 계산하면 인스턴스마다 시계가 달라 만료 시점이 갈라집니다. 스케줄링이 이 저장소에 처음 들어옵니다. @EnableScheduling은 @EnableAsync와 같은 사정으로 AiIntegrationConfig에 두었고, 전용 ThreadPoolTaskScheduler로 Boot의 기본 단일 스레드 스케줄러를 대체합니다. Bean 이름을 taskScheduler로 점유한 이유와 감수하는 것은 BD-40에 적었습니다. pinlog.ai.rescan.interval만 ISO-8601인 것은 실수가 아닙니다. 이 값은 Boot의 완화된 바인딩이 아니라 @scheduled(fixedDelayString)이 직접 파싱하고, 그쪽은 숫자나 ISO-8601만 받습니다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PostgreSQL Testcontainers 통합 13개와 대역·리플렉션 단위 2개, 설정 계약 1개입니다. 만료는 updated_at을 과거로 밀어 만듭니다(5분을 기다릴 수 없습니다). 임계값을 기본값이 아닌 2분·4분으로 덮고 세 행의 나이를 그 사이에 배치해 한 회차에서 갈라지는 것을 봅니다 — 임계값이 코드 상수라면 이 배치가 성립하지 않으므로, 이 테스트가 곧 "설정으로 주입된다"의 확인입니다. 기본값 자체는 어느 통합 테스트도 지키지 못하므로 ConfigurationContractTests가 파일로 고정합니다. SKIP LOCKED는 주장으로 두지 않고 다른 커넥션이 행을 FOR UPDATE로 붙잡은 채 회차를 돌려 실제로 건너뛰는지 봅니다. 회차를 별 스레드에서 돌리는 이유는 SKIP LOCKED가 빠지면 후보 조회가 잠금을 기다리며 멈추는데 같은 스레드에서는 테스트가 실패하는 대신 영원히 매달리기 때문입니다. 15초 타임아웃을 걸어 실패로 드러나게 했습니다. "Finalize를 먼저"에 대해 명세가 든 근거는 실측에서 관측되지 않았습니다. runOnce의 두 줄을 맞바꿔도 통합 테스트가 통과합니다 — retry 증가가 updated_at을 함께 갱신해 그 행이 만료 상태에서 벗어나 Finalizer 후보 조건에 걸리지 않기 때문입니다. 창을 실제로 확보하는 것은 만료 조건과 updated_at 갱신이고, 순서는 심층 방어로 남겨 InOrder 단위 테스트로 고정했습니다. 근거와 나눈 검증 셋은 BI-26에 적었습니다. @EnableScheduling은 전역이라 모든 @SpringBootTest가 스케줄러를 함께 띄우므로, IntegrationContainerSupport에서 주기를 늘려 배경 실행을 없앴습니다. 끄지 않은 이유는 등록 자체가 검증 대상이기 때문입니다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BD-40은 이 저장소에 스케줄링을 들이는 방식을 남깁니다. 리더 선출을 두지 않는 것은 AI 파트 명세가 정한 제약이고, 제약 안에서 우리가 한 선택은 그 전제를 코드로 지키는 방식(후보 조회를 트랜잭션 안에서만 부르고 외부 호출은 커밋 뒤에 하는 것)입니다. 자체 taskScheduler Bean은 능동적 선택이며, Boot 자동설정이 물러나는 것과 스레드 이름이 언젠가 거짓말이 되는 것을 감수 목록에 적었습니다. BI-26은 구현과 검증, 그리고 명세의 근거 하나가 실측에서 관측되지 않은 경위를 적습니다. package-structure.md의 ai 행에 scheduler 계층을 더했습니다. event와 가른 기준은 무엇이 호출을 촉발하는가이고, 그에 따라 트랜잭션 경계도 다릅니다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
AI 파트 중앙입니다. 리뷰 포인트 1의 지적이 맞습니다. 명세 쪽 결함이고 구현이 옳습니다. 실측 확인
UPDATE ai.context_ai_state
SET retry_count = retry_count + 1,
updated_at = now()
WHERE context_id IN (:contextIds)
명세가 두 곳에서 다르게 말하고 있었습니다
6.1 이 이미 "함께"라고 적었는데 3.1 이 만료 조건을 언급하지 않아, 3.1 만 읽으면 순서가 창을 만드는 것처럼 보입니다. 그리고 즉 구현이 명세보다 한 겹 더 안전합니다. 판정순서를 심층 방어로 남기고 명세를 고치지 않은 것도 맞습니다. 이 PR 의 범위는 구현이었고 계약 수정은 금지 범위였습니다. AI 파트 후속
별건으로 티켓을 발급합니다. 이 PR 병합을 막지 않습니다. 리뷰 요청 확인
|
back#100(S15P11A705-154 인프라)이 BI-26을 함께 선점했습니다. 착수 시점에는 26이 비어 있었는데, 그쪽이 원래 쓰던 BI-25를 back#98(개인 검색)이 먼저 병합해 가져가면서 26으로 옮긴 것입니다. 나중에 병합되는 쪽이 재배정한다는 규칙(check-number.sh)에 따라 우리가 옮깁니다. 27은 back#103(소셜 로그인 이메일 필수화)이 선점 중이라 건너뛰고 28을 씁니다. 확인은 브랜치별 contents API로 직접 했습니다 — dev는 24·25·26, back#103은 27, 우리가 26입니다. BD-40은 어느 브랜치와도 겹치지 않아 그대로 둡니다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-rescan-scheduler # Conflicts: # docs/backend/implements/README.md
오늘 16:00 KST 에 자체 병합하려 합니다AI 파트 중앙입니다. 접점 리뷰를 요청해 둔 PR 인데 오늘이 MVP 개발 마감이라 시한을 밝혀 둡니다. 그 전에 보시면 반영하고, 못 보시면 병합한 뒤 지적을 후속 PR 로 처리하겠습니다. 현재 상태 (14:25 KST 실측)승인 요청이 아닙니다. 접점은 하나입니다 —
|
BD-41 — 탈퇴 회원의 남은 Access 토큰을 인증 필터의 PK 조회로 막은 결정. 창 안에서 새는 것이 읽기가 아니라 쓰기라는 것이 결정적이었다. 탈퇴 직후 30분간 Record·Collection 생성이 되고 그 행들은 연쇄 삭제가 지나간 뒤에 만들어져 어떤 정리 경로에도 걸리지 않는다. 요청당 PK 조회를 감수하는 편이 고아 데이터를 감수하는 것보다 싸다고 봤다. Redis 탈퇴 마커를 기각한 이유는 성능이 아니라 장애 전파다. 필터가 Redis를 읽으면 순단이 곧 전체 인증 실패(fail-closed)이거나 순단 중 탈퇴자 통과(fail-open)가 된다. readiness에 redis를 넣지 않기로 한 BD-28과 같은 방향이다. Refresh 폐기를 트랜잭션 마지막에 둔 이유도 함께 적었다 — 실패가 "아무 일도 없었음"으로 수렴하는 순서이고, 뒤집으면 "로그아웃됐는데 탈퇴는 안 된" 상태가 남는다. BI-29 — 구현 보고. 설계 판단 넷(잠금 없음·record_count 미갱신·폐기 순서·연쇄를 한 서비스에), 드러난 것 셋(CSRF가 인가보다 먼저 돎, loginAs로는 필터를 검증할 수 없음, 기존 테스트 무영향), 뮤테이션 3종 결과. 이슈 본문에서 따르지 않은 것도 표로 남겼다. #34이 2026-07-27 작성이라 email = null과 "유일값이 필요하다"가 지금 기준으로 틀렸고, "범위 밖"이라던 연쇄 삭제·AI 무효화는 이번 범위에 들어왔다. 번호는 pull 후 확정했다. BD-40이 #104(재스캔 Scheduler)에 선점돼 BD-41이다. Refs #34 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
요약
유실·정지된 AI 처리를 복구하는 재스캔 Scheduler와 FAILED Finalizer를 추가합니다. AI 연동의 실패 경로 네 곳이 모두 "재스캔이 복구한다"를 안전망으로 전제하는데 그 재스캔이 없어, 한 번 실패한 Context가 영구히
PENDING으로 남았습니다. 이 저장소에 스케줄링이 처음 들어옵니다(@Scheduled가 0건이었습니다). 도메인 스키마·마이그레이션 변경은 없습니다.Jira (필수)
S15P11A705-159관련 GitHub Issue (선택)
배경
증상은 정상과 구별되지 않는 실패입니다. 아래 네 곳이 각자 주석으로 재스캔을 근거로 들고 있었습니다.
AiIntegrationConfig큐 포화AiProcessClient(401 포함 전부 삼킴)ContextAiRequestedListener조립 실패202이후 내부에서 실패정책의 정본은 AI 파트가 소유한
docs/ai/spec/ai-rescan-scheduler.md이며 이 PR은 그 명세를 고치지 않았습니다. 파라미터(5분·5분·10분·3회·100건, Backoff 없음)와 "리더 선출을 두지 않는다"는 명세가 정한 값입니다.변경 사항
회차
domain/ai/scheduler/AiRescanScheduler—@Scheduled진입점과 트랜잭션 밖 외부 호출. 이 클래스는 트랜잭션을 열지 않습니다 — 열면 뒤의 HTTP 호출 시간만큼 행 잠금과 DB 커넥션이 붙잡힙니다.domain/ai/service/AiFailedFinalizer— 명세 6장. 미완료 단계만FAILED로 종결하고 FastAPI를 부르지 않습니다.domain/ai/service/AiRescanCandidateService— 명세 4·5장. 후보 잠금 +retry_count증가가 이 트랜잭션 경계 안에서 끝납니다. 삭제된 Context의 상태를 다시 읽는 단건 조회도 여기 있습니다(명세 5.1의 정합성 경고는 후보 조회 결과로 판정할 수 없습니다 — 후보 조회가CANCELLED를 애초에 잡지 않기 때문입니다).domain/ai/service/AiRescanProperties—@ConfigurationProperties("pinlog.ai.rescan").AiProperties에 합치지 않은 이유는 저쪽이 FastAPI 연결 계약이고 이쪽은 복구 정책이라 바뀌는 이유가 다르기 때문입니다.domain/ai/repository/AiRescanStateRepository·ContextAiStateRow— SQL 전부. 후보 조회는 호출자의 트랜잭션 안에서만 부르는 계약입니다.domain/ai/service/ContextProcessRequestAssembler—assemble(long contextId)진입점을 더했습니다. 기존 이벤트 경로의 동작은 바꾸지 않았고 공통 본문만 private 메서드로 뺐습니다.domain/ai/AiIntegrationConfig—@EnableScheduling과 전용ThreadPoolTaskScheduler.@EnableAsync와 같은 사정으로 여기 둡니다(전역 스위치인데 켜야 하는 이유가 이 연동에만 있습니다).application.yml—pinlog.ai.rescan다섯 값.docs/backend/decisions/BD-40— 스케줄링을 들이는 방식과 분산 락을 두지 않는 결정.docs/backend/implements/BI-28— 구현·검증과, 명세의 근거 하나가 실측에서 관측되지 않은 경위.docs/development/package-structure.md—ai행에scheduler계층 추가(파일을 만들기 전에 먼저 갱신했습니다).Bean이 셋으로 갈린 이유
트랜잭션 프록시입니다. 한 클래스에 두면 자기 메서드 호출이 프록시를 지나지 않아 트랜잭션 없이 돌고, 그러면
FOR UPDATE SKIP LOCKED의 잠금이 조회 직후 풀려 중복 방어가 조용히 사라집니다. 리더 선출을 두지 않는 근거가 그 잠금이므로, 이 경계가 곧 조정 장치입니다.만료 술어를 하나로 공유합니다
명세 6.1이 "만료 기준은 재스캔과 동일하다"고 정하므로 두 SQL에 따로 적으면 한쪽만 고쳐 기준이 갈라집니다.
String.formatted로 한 상수를 두 쿼리에 끼웠습니다. 시각 비교는 DBnow()로 합니다 — 자바에서 컷오프를 계산하면 인스턴스마다 시계가 달라 만료 시점이 갈라집니다.테스트 / 검증
./gradlew clean check --no-daemon→ BUILD SUCCESSFUL,390 tests, 0 failures, 0 skipped(checkstyle·jacoco 포함, jacocoLINE 96.45% / BRANCH 82.80%, 게이트 80%).dev병합(back#100) 후 재실행한 결과입니다AiRescanSchedulerTests13개 (pgvector/pgvector:0.8.5-pg16)V100에 이미 있습니다docs/backend/decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md만료를 만드는 방법
updated_at을 과거로 밀어 넣습니다(5분을 기다릴 수 없습니다). 임계값을 기본값이 아닌 2분·4분으로 덮고 세 행의 나이를 그 사이에 배치해 한 회차에서 갈라지는 것을 봅니다 — 임계값이 코드 상수라면 이 배치가 성립하지 않으므로 이 테스트가 곧 "설정으로 주입된다"의 확인입니다. 기본값 자체는 어느 통합 테스트도 지키지 못하므로(각자 덮습니다)ConfigurationContractTests가application.yml의 다섯 값을 파일로 고정합니다.SKIP LOCKED를 실제로 관측합니다다른 커넥션(
DriverManager)이 한 행을FOR UPDATE로 붙잡은 채 회차를 돌리고, 그 행의retry_count가 그대로이며 다른 만료 행은 처리된 것을 확인합니다. 회차를 별 스레드에서 돌립니다 —SKIP LOCKED가 빠지면 후보 조회가 잠금을 기다리며 멈추는데, 같은 스레드에서 부르면 테스트가 실패하는 대신 영원히 매달립니다. 15초 타임아웃으로 실패로 드러나게 했습니다.RED 확인 (6건)
구현을 하나씩 되돌려 테스트가 실제로 잡는지 확인했습니다.
runOnce의 Finalize를 재스캔 뒤로finalizeRunsBeforeTheCandidateClaimInEveryRound(통합 테스트는 통과 — 리뷰 포인트 1)anExhaustedRowThatIsNotExpiredYetIsLeftAloneFOR UPDATE SKIP LOCKED→FOR UPDATEskipLockedLeavesALockedRowToWhoeverHoldsIt(15초 타임아웃)CASE를 화이트리스트 → 블랙리스트theFinalizerKeepsCompletedStagesAndNeverOverwritesCancelled(CANCELLED가FAILED로 뒤집힙니다)PROCESSING만료값을PENDING과 같게expiryThresholdsComeFromConfigurationAndDifferByStagefixedDelayString→fixedRateStringtheRoundIsScheduledWithFixedDelayAndReadsTheConfiguredInterval회귀
기존
ContextAiEnqueueTests8개와AiDerivedDataInvalidationTests9개가 그대로 통과합니다 — 조립기에 진입점을 더하면서 이벤트 경로를 건드리지 않았음을 이 둘이 지킵니다.리뷰 포인트
retry_count를 3으로 올린 행을 곧바로 FAILED로 종결한다"*로 듭니다. 그것을 결과로 고정하려고 테스트를 썼는데runOnce의 두 줄을 맞바꿔도 통과했습니다. 재시도 증가가updated_at을 함께 갱신해 그 행이 만료 상태에서 벗어나 Finalizer 후보 조건(6.1의 만료 조건)에 걸리지 않기 때문입니다. 명세 6.1도 그 둘이 "함께 그 창을 확보한다"고 쓰고 있어 명세와 어긋나는 관측은 아니지만, 순서만으로 그 창이 생긴다는 읽기는 사실이 아닙니다. 검증을 셋으로 나누고(결과 / 만료 조건 / 호출 순서) 순서는 심층 방어로 남겼습니다 — 누군가 증가 UPDATE에서updated_at갱신을 빼면 순서가 유일한 보호가 됩니다. 경위는 BI-28에 적었습니다. 명세를 고치는 판단이 필요하면 AI 파트 소관이라 손대지 않았습니다.taskScheduler라는 Bean 이름을 점유합니다. Spring은 스케줄러를 작업별로 고르지 않고ScheduledTaskRegistrar단위로 하나 고르므로@Scheduled메서드가 자기 스케줄러를 지목할 방법이 없습니다. 이름을 다르게 두면 타입이 유일할 때만 해석되고 두 번째TaskSchedulerBean이 생기는 순간 조용히 로컬 단일 스레드 실행자로 떨어집니다. 그래서 이름을 점유해 해석을 확정적으로 만들고,TaskSchedulerBean이 하나뿐임을 테스트로 단언했습니다. 대가로 Boot의spring.task.scheduling.*프로퍼티가 효력을 잃고, 스레드 이름ai-rescan-이 두 번째 배치가 붙는 순간 거짓말이 됩니다(BD-40 감수 목록).@EnableScheduling을AiIntegrationConfig에 두었습니다. 전역 스위치를 도메인 설정 클래스에 두는 것이라 판단이 필요합니다.@EnableAsync가 같은 자리에 있는 선례를 따랐고 근거도 같습니다 — 켜야 하는 이유가 이 연동에만 있어global/config로 올리면 스위치와 유일한 소비자가 떨어져 앉습니다.interval만 ISO-8601(PT5M)입니다. 같은 블록의 다른 Duration은5m인데, 이 값만은 Boot의 완화된 바인딩이 아니라@Scheduled(fixedDelayString)이 직접 파싱하고 그쪽은 숫자(밀리초)나 ISO-8601만 받습니다.5m으로 두면NumberFormatException으로 기동이 실패합니다. 형식 불일치가 눈에 걸릴 자리라 yml 주석·AiRescanPropertiesjavadoc·ConfigurationContractTests셋에 이유를 남겼습니다.UPDATE ai.context_ai_state SET updated_at = now()로 합니다.@BeforeEach·@AfterEach양쪽에서 돕니다. 컨테이너를 JVM이 공유하고 이 클래스가 실제로 커밋하므로, 앞선 테스트가 남긴 만료 행이 다음 회차에 후보로 섞이면 "호출이 오지 않아야 한다" 류 단언이 남의 행 때문에 깨집니다. 뒤도 정리하는 이유는 다른 테스트 클래스의 배경 회차에 만료 행을 물려주지 않기 위해서입니다. Core에 없는context_id는 음수로 만듭니다(ai.context_ai_state에core.contextFK가 없고, 음수면 IDENTITY 값과 절대 겹치지 않습니다).@EnableScheduling이 전역이라 모든@SpringBootTest가 스케줄러를 함께 띄웁니다.IntegrationContainerSupport에서 주기를PT1H로 늘려 배경 실행을 없앴습니다. 조건부로 끄지 않은 이유는 등록 자체(fixedDelay인지, 전용 스케줄러를 쓰는지)가 검증 대상이기 때문입니다.미결 / 후속
INFO로그 한 줄로만 남습니다. 지표(retry_count분포, 상태별 잔량,retry_count >= 3이면서PENDING/PROCESSING인 잔량)는 없습니다. 마지막 항목이 Finalizer의 건강 상태이며 정상이면 한 주기 안에 0으로 수렴해야 합니다. 이 티켓 범위는 Finalizer가FAILED로 드러내는 것까지입니다.FAILED전환 로그에 "마지막 실패 사유"가 없습니다. 명세 6.3이 요구하지만 그 값이 Spring 쪽에 존재하지 않습니다 — 202 이후의 실패는 FastAPI 내부에서 일어나고 통보 경로가 없습니다. 가진 단서(종결 직전 단계별 status·소진한 재시도 횟수)를 남기고 사유를 어디서 찾아야 하는지 문장으로 가리켰습니다. 실을 수 있으려면 FastAPI가 실패를 기록해야 하고 그것은S15P11A705-121(ai#44) 소관입니다.fixedDelay라 겹치지는 않지만 만료 행이 밀립니다. 실제로 보이면 BD-40의 재검토 트리거를 따릅니다.