작업을 FAILED로 바꿔도 실행 중인 호출은 돌아올 수 있다

복구 서비스가 오랫동안 진척하지 않은 작업을 FAILED로 확정해도, 대기하던 외부 호출이나 영상 인코딩은 계속 실행될 수 있다. 결과가 늦게 도착했을 때 워커가 완료를 무조건 저장하면 방금 회수한 작업이 COMPLETED로 돌아가고, 부모가 READY가 되거나 사용량이 가산될 수 있다.

다음은 이 경계를 설명하는 순서다. 실제 운영 시각이나 장애 로그를 재구성한 예시는 아니다.

  1. 워커가 작업을 실행외부 응답이나 인코딩 완료를 기다리는 동안 DB는 PROCESSING이다.
  2. 복구가 정체를 확인해 실패 확정읽었던 상태와 갱신 시각이 그대로인 행에만 FAILED를 쓴다.
  3. 이전 실행의 결과가 도착완료를 쓰는 순간의 작업 상태가 유효해야 후속 저장과 사용량 가산으로 진행한다.

변경은 회수 자체에 더해 내보내기 상태, 늦은 결과의 DB 저장, 영상 사용량 가산까지 다뤘다. 상태 복구의 끝을 “실패를 썼다”가 아니라 “이후 완료가 무엇을 할 수 있는가”에서 확인해야 했기 때문이다.

오래 실행된 것만으로 정체를 확정하지 않는다

active 큐 작업의 실행 시간은 BullMQ의 processedOn으로 계산하고, 상한은 DB에 있는 작업 종류의 설정을 적용한다. 회수 조건은 실행 시간이 종류별 상한을 넘었다는 것과, DB 갱신이 진척 없음 기준 이상 멈췄다는 것의 결합이다. 아직 상한 안에 있거나 최근 updatedAt이 갱신된 작업은 이 판정으로 실패 처리하지 않는다.

진척의 증거는 진행률 저장, 실제 인코딩 출력, 외부 작업 폴링 응답 등에서 updatedAt으로 전달한다. 단순히 시간이 지났다는 이유로 계속 하트비트를 보내면 멈춘 호출도 정상처럼 보일 수 있다. 하트비트는 작업별로 한 프로세스 안에서 5분에 한 번으로 제한하고, 실패하거나 갱신 행이 0인 경우에도 제한 시각을 남긴다. 여러 프로세스 전체에 단 한 번의 쓰기를 보장하는 장치는 아니다.

processedOn이 없으면 시작 시점을 추측하지 않고 건너뛴다. 큐 조회 오류는 경고를 남기며, 읽지 못한 작업의 시작 시점을 추측하지 않는다. 회수 시에는 조회 당시 status·updatedAt이 그대로인 행만 변경한다. 그 사이 진척이 생겨 영향 행 수가 0이면 부모도 변경하지 않는다. 내보내기는 원본의 READY 상태를 유지하고 대응하는 내보내기 상태를 실패 쪽으로 바꾼다.

차시 행을 만든 뒤 완료 거절을 발견하면 늦다

과목에 주차·차시를 추가하는 작업은 모델 결과를 받은 다음 여러 행을 만든다. 이 행들을 먼저 저장하고 작업 완료만 거절하면 회수된 결과가 제품 데이터에 남는다. 현재 주차 추가 경로는 트랜잭션의 첫 쓰기로 조건부 작업 완료를 수행하고, 통과한 뒤 주차·차시·강의 행과 개수를 저장한다. 아래는 실제 호출 순서에서 생성 데이터의 세부 필드와 반복을 생략한 것이다.

await this.prisma.$transaction(async (tx) => {
  await completeCourseJobRow(tx, jobId, { progress: 100 /* ... */ });
  await tx.chapter.create({ data: { /* 생성한 주차 */ } });
  // 차시 생성 → 강의 저장 → 과목의 주차 수 갱신
});

현재 completeCourseJobRow는 ID와 QUEUED·PROCESSING 조건으로 COMPLETED를 쓴다. 영향 행 수가 0이면 job-terminal 오류를 던져 트랜잭션을 중단한다. 그래서 이미 FAILED로 회수된 작업은 새 주차 행을 만들기 전에 멈춘다. 공용 completeJobIfProcessing 헬퍼는 ID와 PROCESSING만 조건으로 쓰는 별도 함수다. 두 함수 모두 실행 시도 번호를 조건으로 삼지는 않는다.

모델 호출과 완료 쓰기를 한 트랜잭션 안에서 기다리는 방법은 DB 연결과 트랜잭션을 외부 응답 시간 동안 붙잡는다. 이 경로는 모델 결과를 먼저 준비하고 짧은 저장 트랜잭션 안에서 완료 자격을 다시 확인한다. 그 대신 이미 실행된 모델 호출을 되돌리거나 호출 비용을 취소하는 범위까지 얻지는 않는다.

사용량은 산출물 확정이 확인된 경로에서만 올린다

영상 합성도 완료 쓰기를 READY 메타데이터 갱신 앞에 둔다. 현재 영상 경로는 삭제·종결 여부를 직전에 다시 확인하고, QUEUED·PROCESSING인 작업에만 COMPLETED를 쓴다. 영향 행 수가 0이면 후속 READY 갱신과 영상 사용량 가산으로 진행하지 않는다.

조건부 완료 뒤에는 영상·산출물·콘텐츠 메타데이터를 병렬로 갱신한다. 이들 갱신은 완료 쓰기와 하나의 트랜잭션이 아니다. 일부 메타데이터 갱신이 실패하면 작업 완료는 남고 확정 결과가 false가 되어 사용량 가산을 건너뛴다. 실패한 여러 메타데이터를 전부 롤백했다는 의미도 아니다.

결과 저장 지점후속 저장·가산을 멈추는 범위이미 남을 수 있는 것
과목 주차 추가트랜잭션 안의 새 주차·차시·강의 행앞서 끝난 모델 호출
영상 합성완료 뒤 READY 갱신·사용량 가산생성 파일과 완료 전 SlideAsset 갱신
영상 메타데이터 일부 실패확정 실패 시 사용량 가산 생략작업 완료와 성공한 일부 메타데이터 쓰기

영상은 조건부 완료 전에 파일을 만들고 SlideAsset을 upsert하며 파일 메타데이터도 조사한다. 따라서 늦은 완료를 버렸다고 모든 흔적이 사라지는 것은 아니다. 완료 이후의 공개 상태와 가산 경계를 보호하는 것, 외부 생성물과 파일을 정리하는 것은 별도로 확인해야 한다.

실패 상태를 다시 진행 중으로 바꾸는 시작 경로도 확인한다

완료 조건만 두면 큐 재실행이 FAILED를 PROCESSING으로 되돌리는 경로가 남을 수 있다. 복구는 DB의 실패 사유와 7일 TTL의 Redis 표식을 남긴다. 프로세서 시작 가드는 둘 중 하나로 실행 시간 상한 회수를 확인하면 재시도 없는 오류로 종료한다. Redis 표식은 늦은 워커가 DB 실패 사유를 덮은 경우에도 회수 사실을 남기기 위한 보완이다.

표식은 영구 실행 세대 번호가 아니다. Redis 표식은 만료되고, Redis·DB 조회 실패는 차단 근거가 없다고 처리해 실행을 통과시킬 수 있다. 다른 경로가 같은 작업을 다시 PROCESSING으로 만들면 상태만 검사하는 이전 실행의 완료가 통과할 여지도 있다. 작업 ID마다 실행 세대를 발급하고 완료 쓰기에 그 세대를 포함하는 방식은 이 한계를 다루는 별도의 설계가 된다.

회귀 테스트에는 상한과 진척 조건의 결합, 시작 시각 누락·조회 오류, 회수 조건부 쓰기 0건, 표식 TTL과 실패 시 경고가 있다. 과목 테스트는 완료 0건이면 생성 행을 쓰지 않는지와 쓰기 순서를 모킹해 확인한다. 영상 테스트는 완료 거절·메타데이터 실패 시 사용량 가산이 없는지 확인한다. 하트비트의 동시 호출과 0건 제한도 단위 테스트에 포함된다. 이 단위 테스트는 실제 프로세스 중단이나 여러 워커의 경합을 재현하지 않는다.

DB와 큐를 대조하는 복구가 어느 작업을 회수할지 고른다면, 이 경계는 그 뒤 도착한 결과가 어떤 데이터를 바꿀 수 있을지 정한다. 이미 시작된 외부 작업의 종료와 비용까지 해결하려면 외부 생성 결과의 보상 처리도 함께 다뤄야 한다.