큐를 읽지 못한 작업을 실패로 바꾸면 정상 작업도 회수한다

DB에는 작업이 PROCESSING으로 남아 있는데 워커가 종료됐거나, QUEUED로 남아 있는데 큐 기록이 사라질 수 있다. 반대로 오래 걸리는 정상 작업도 DB 갱신이 뜸하면 같은 후보에 들어온다. DB의 상태와 시간만으로 고아 작업을 확정할 수 없는 이유다.

먼저 큐 객체의 존재와 실행 예정 상태를 구분했다. 보존된 failed·completed 작업은 정상 대기로 보지 않고, 상태 조회 실패는 고아로 단정하지 않았다. 이후 도메인 전반의 상태 대조, 분산 락, 조건부 쓰기와 5분 실행 주기를 더했다. 후보별 직접 조회와 복합 인덱스는 후속 변경에서 보완했다.

큐를 확인하는 순간 Redis 조회에 실패했다고 빈 집합으로 처리하면, 실행 중인 작업도 “큐에 없음”이 된다. 이어서 재큐잉하거나 실패를 쓰면 조회 장애가 실제 상태 변경으로 번진다. AI Commons의 복구 변경은 고아 판정에서 실행 예정, 조회 결과에 없음, 판정 불가를 나눴다.

큐 대조 결과확인한 사실고아 복구의 처리
실행 예정 상태를 찾음대기·지연·실행 중인 대응 작업이 보임재큐잉·실패 변경을 건너뜀
대조한 집합에 없음대응 큐들의 조회 결과에서 찾지 못함복구 가능한 입력과 DB 상태를 확인해 처리
조회 오류 · null큐 상태를 판단할 근거가 부족함그 테이블의 이번 고아 판정을 보류

빈 Set과 null은 같은 값이 아니다. Set은 조회 결과이고 null은 조회를 믿을 수 없다는 신호다. 큐에 작업 객체가 있다는 사실만으로도 충분하지 않다. 보존 기간 때문에 completed·failed 객체가 남아 있을 수 있어 waiting, waiting-children, delayed, active, paused, prioritized만 실행 예정으로 인정한다.

DB 후보를 먼저 고르고 필요할 때만 큐를 넓게 읽는다

복구는 워커 시작 시와 5분 주기에 같은 진입점으로 실행된다. DB에서 PROCESSING 또는 QUEUED이며 updatedAt이 한 시간보다 오래된 후보를 먼저 읽는다. 일부 도메인은 작업 종류도 제한한다. 그 뒤 큐를 대조해 조회 중 새로 생성된 DB 행이 고아 후보에 섞이는 범위를 줄인다.

후보마다 현재 작업 ID 규칙으로 getJob과 상태 조회를 한다. 어느 대응 큐에서든 실행 예정을 찾으면 그 후보의 추가 조회를 끝낸다. 전부 확인됐다면 전체 스캔은 필요 없다. 그러나 못 찾았거나 종료 상태로만 보이는 후보가 하나라도 있으면 대응 큐를 스캔해 payload의 data.jobId로 다시 맞춘다. 옛 명명 규칙의 큐 ID가 남아 있을 수 있어서 현재 ID 조회의 부재를 곧바로 작업 부재로 쓰지 않는다.

슬라이드는 생성 큐와 내보내기 큐, 영상도 생성 큐와 내보내기 큐를 함께 대조한다. 직접 조회의 예외나 필요한 스캔의 실패는 null을 반환한다. 스캔은 상태별로 200건씩 읽고 active를 마지막에 둔다. waiting을 읽은 뒤 실행을 시작한 작업을 마지막 active 조회에서 다시 발견할 수 있게 한 순서다. 그래도 여러 조회 전체가 하나의 스냅샷은 아니므로 작업의 부재를 모든 순간에 증명하지는 못한다.

판정 뒤에 진행된 작업은 덮어쓰지 않는다

큐에 없는 후보 중 입력을 복원할 수 있는 작업은 먼저 재큐잉한다. 현재 영상 합성은 DB의 영상·트랙 정보로 입력을 복원하지만, 다른 종류에 필요한 입력을 추측해 만들지는 않는다. 복원이 불가능한 경우는 작업을 FAILED로 바꾸고 해당 부모의 진행 상태를 정리한다. 재큐잉 호출이 실패하면 DB 상태를 유지해 다음 복구 기회에 다시 확인한다.

조회와 갱신 사이에는 정상 워커가 진척을 저장할 수 있다. 그래서 성공한 재큐잉 뒤의 갱신과 실패 확정 모두 ID에 더해 조회 당시 status·updatedAt을 조건으로 쓴다. 다음은 재큐잉 성공 뒤의 실제 조건이다.

const updated = await prismaAny[handler.model].updateMany({
  where: { id: job.id, status: job.status, updatedAt: job.updatedAt },
  data: { status: "QUEUED", progress: 0, error: null },
});
// updated.count가 0이면 부모 상태도 변경하지 않는다.

영향 행 수 0은 읽은 상태가 더 이상 현재 상태가 아니라는 뜻이다. 복구가 작업 행을 실제로 변경했을 때만 부모 후처리를 진행한다. 내보내기 실패는 내보내기 상태를 바꾸며, 이미 READY인 원본 영상이나 슬라이드를 생성 실패로 내리지 않는다.

재큐잉과 조건부 DB 쓰기는 하나의 트랜잭션이 아니다. 큐에 넣은 뒤 DB 조건이 맞지 않을 수 있고, 부모 정리도 작업 실패 쓰기와 별도여서 부모 갱신 오류가 남을 수 있다. 이 순서와 조건은 손실·덮어쓰기를 줄이지만 정확히 한 번의 실행을 보장하지는 않는다.

해제할 권한과 계속 실행할 권한은 다르다

복구 진입점은 Redis의 lock:job-recovery를 값과 함께 SET NX EX로 획득한다. TTL은 600초다. 다른 워커가 보유 중이거나 Redis에 접근하지 못하면 이번 복구를 건너뛰고 워커 기동은 계속한다. 종료 시에는 저장한 값과 현재 값이 같을 때만 Lua로 삭제한다.

if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end

A가 락을 얻고 10분을 넘겨 실행하는 사이 B가 만료된 키를 새로 얻는 상황을 생각할 수 있다. A가 끝나도 값이 다르므로 B의 락을 삭제하지 않는다. 이 비교는 해제 소유권을 지킨다. 하지만 A의 실행 자체를 중단하지는 않아서 TTL 이후에는 A와 B가 함께 복구할 수 있다.

Redis 서비스에는 락 연장 함수가 있지만 이 복구 진입점은 호출하지 않는다. 락을 주기적으로 갱신하거나 실행 세대 번호로 늦은 쓰기를 거부하는 방식은 더 긴 작업의 대안이 될 수 있다. 현재 구현의 보장은 TTL 안의 진입 제한과 소유자만 해제하는 조건이며, 만료 뒤 중복 효과는 큐 대조·같은 ID·DB 조건부 쓰기로 일부 제한한다.

주기 검사는 빨라야 하지만 읽기 비용만 볼 수는 없다

5분마다 실행하므로 DB 후보 조회와 큐 스캔 비용도 중요하다. 다섯 작업 테이블에 (status, updatedAt) 복합 인덱스를 두어 상태 동등 조건 다음에 시각 범위를 찾는 조회에 맞췄다. 큐는 후보별 직접 조회가 확인되지 않을 때만 넓게 읽는다. 인덱스에는 저장 공간과 갱신 비용이 들며, 진척을 기록할 때마다 updatedAt도 바뀐다. 일부 도메인의 type 조건은 이 인덱스에 포함되지 않는다.

테스트는 직접 조회 성공 시 스캔 생략, 옛 ID 폴백, 두 대응 큐의 대조, 조회 예외의 보류, 후보를 먼저 읽는 순서, active 마지막·페이지 처리, 재큐잉 오류와 조건부 쓰기 0건, 같은 락 값의 해제를 확인한다. 큐와 Redis는 모킹한다. 이 테스트는 실제 Redis 다중 워커의 TTL 경합이나 PostgreSQL 실행 계획·처리 시간을 검증하지 않는다. 인덱스 생성 SQL도 일반 CREATE INDEX여서 잠금 없는 적용으로 설명할 수 없다.

이 복구는 관찰한 DB·큐 상태를 바탕으로 다음 처리를 고른다. active인 작업이 정말 진척하고 있는지는 별도의 문제다. 그 판단과 이미 회수한 작업의 늦은 완료는 작업 회수 뒤의 완료 경계에서 다룬다.