늦게 돌아온 추출 워커의 완료 처리와 소유권 펜싱
문서 추출 워커가 응답하지 않으면 다른 워커가 작업을 이어받을 수 있어야 한다. 그런데 응답이 늦다는 이유만으로 이전 워커가 종료됐다고 볼 수는 없다. 새 워커가 결과를 저장한 뒤 이전 워커도 완료를 보고할 수 있다.
Deep Connector의 추출 코드를 검토하면서 작업을 가져오는 조건과 결과를 확정하는 조건을 나눠 읽었다. 작업을 처음 점유할 때의 행 잠금만으로는 나중에 돌아온 워커의 쓰기까지 막을 수 없기 때문이다. 결과를 확정할 때도 DB에 기록된 현재 소유자를 확인해야 한다.
아래 순서는 지연 상황을 설명하려고 만든 가정이며 실제 장애 회고가 아니다. 예제는 소유권 조건을 축약한 메모리 모델로, 실제 제품 코드나 다중 인스턴스 DB 실험을 실행한 결과와 구분한다.
임대 만료와 워커 종료의 차이
워커 A가 문서 하나를 점유하고 추출을 시작했다고 하자. A가 임대(lease)를 갱신하지 못해 만료 시각을 넘기면 워커 B가 같은 작업을 다시 점유할 수 있다. 그동안 A의 추출 요청은 아직 실행 중일 수 있다.
이때 lease는 다른 워커가 작업을 인계받을 조건이지, A의 프로세스를 종료하는 명령이 아니다. B가 결과를 확정한 뒤 A가 돌아오더라도 A의 결과를 현재 결과로 받아들이지 않아야 한다. 여기서 소유권 펜싱은 현재 소유자가 아닌 워커의 DB 상태 변경을 거절하는 규칙을 뜻한다.
작업 점유와 결과 확정의 분리
점유 함수인 claimOne()은 트랜잭션 안에서 후보 행에 pessimistic_write 잠금을 요청하고 skip_locked를 지정한다. 후보는 새로 발견한 문서, 재시도 시각이 된 문서, 추출 중이지만 lease가 만료된 문서다. PostgreSQL의 SKIP LOCKED는 즉시 잠글 수 없는 행을 건너뛰므로 여러 소비자가 큐 형태의 테이블에서 작업을 가져올 때 쓸 수 있다.
행을 선택한 뒤에는 조건부 UPDATE로 점유 가능 상태를 다시 확인한다. 정확히 한 행을 변경했을 때 새 소유자 토큰과 만료 시각을 기록하고 시도 횟수를 늘린 것으로 인정한다. 이 트랜잭션은 문서 다운로드나 추출이 끝날 때까지 유지되지 않는다.
완료 함수 complete()는 문서 ID만으로 갱신하지 않는다. 문서 ID, extraction_owner_token, 현재 상태 extracting이 모두 일치해야 결과 경로와 체크섬을 기록하고 상태를 indexed로 바꾼다. 갱신 함수 renew()와 실패 함수 fail()에도 같은 소유권 조건이 있다.
검토한 구현에서 소유자 토큰은 기본적으로 UUID이며, 크기를 비교하는 단조 증가 번호가 아니다. 저장된 토큰과 요청 토큰의 일치 여부로 소유자를 구분한다. 따라서 모든 외부 저장소가 이 토큰으로 오래된 쓰기를 거절한다고 해석해서는 안 된다.
만료 시각만으로 거절하지 않는 완료 조건
complete()와 renew()의 조건에는 lease 만료 시각 비교가 없다. 만료됐더라도 아직 다른 워커가 재점유하지 않았고 상태와 토큰이 그대로라면 기존 워커의 완료나 갱신이 허용될 수 있다. 반대로 B가 재점유해 토큰이 바뀌었다면 A는 예전 토큰으로 완료할 수 없다.
이 차이를 생략하면 “만료된 워커의 모든 요청은 거절된다”라는 잘못된 설명이 된다. 이 구현에서 만료 시각은 재점유 후보를 정하고, 결과 갱신 시점의 토큰과 상태는 완료 권한을 정한다.
순서를 고정한 설명용 모델
다음 Python 코드는 DB 한 행을 사전으로 표현한다. 최초 점유와 만료 후 재점유, 완료 시 토큰과 상태 비교만 남겼으며 재시도, 트랜잭션, 타이머, 파일 쓰기는 구현하지 않았다. 함수 호출을 순서대로 실행하므로 실제 동시성이나 DB 잠금 동작을 검증하는 코드가 아니다.
def new_row():
return {"status": "discovered", "owner": None,
"expires": None, "result": None}
def claim(row, owner, now, lease=10):
eligible = row["status"] == "discovered" or (
row["status"] == "extracting" and row["expires"] <= now
)
if not eligible:
return False
row.update(status="extracting", owner=owner, expires=now + lease)
return True
def complete(row, owner, result):
if row["status"] != "extracting" or row["owner"] != owner:
return False
row.update(status="indexed", owner=None, expires=None, result=result)
return True
row = new_row()
assert claim(row, "A", now=0)
assert not claim(row, "B", now=9)
assert claim(row, "B", now=10)
stale_during_b = complete(row, "A", "A-result")
assert not stale_during_b
assert row["owner"] == "B" and row["result"] is None
winner = complete(row, "B", "B-result")
late_after_b = complete(row, "A", "A-result")
assert winner and not late_after_b
assert row["result"] == "B-result"
print("A completion while B owns:", stale_during_b)
print("B completion:", winner)
print("A completion after B finishes:", late_after_b)
print("Stored result:", row["result"])
row = new_row()
assert claim(row, "A", now=0)
now = 11
assert now > row["expires"]
expired_without_reclaim = complete(row, "A", "A-result")
assert expired_without_reclaim
print("Expired A without reclaim:", expired_without_reclaim)
코드를 lease_model.py로 저장하고 python3 lease_model.py로 실행하면 된다. Python 3.9.6에서 위 코드 전체를 실행해 모든 assertion이 통과했으며 표준 출력은 다음과 같았다.
A completion while B owns: False
B completion: True
A completion after B finishes: False
Stored result: B-result
Expired A without reclaim: True
B가 추출 중일 때 A를 거절한 결과는 토큰 비교가 필요하다는 점을 보여준다. B가 완료한 뒤에는 토큰뿐 아니라 상태도 달라져 A의 완료 조건이 성립하지 않는다. 마지막 줄은 만료만으로 완료를 거절하지 않는 구현상의 차이를 확인한 결과다.
타이머로 취소되지 않는 외부 작업
실제 워커는 다운로드, 추출, 미러 파일 쓰기를 각각 withLease()로 감싼다. 각 단계 시작 전에 한 번 갱신하고 이후에는 Math.max(100, Math.floor(leaseMs / 3)) 밀리초를 지연값으로 타이머를 예약한다. 이 값은 정확한 실행 간격을 보장하지 않으며 Node.js 공식 문서도 타이머 콜백의 정확한 실행 시각을 보장하지 않는다고 명시한다.
주기 갱신이 실패하거나 예외를 던지면 lost 플래그를 설정한다. 다만 이미 시작한 work()를 즉시 취소하지는 않는다. 외부 작업이 반환되면 진행 중인 갱신을 기다린 뒤 소유권 상실을 오류로 처리한다.
주기 갱신은 작업 중 lease를 연장하지만, 늦은 완료를 거절하는 최종 조건은 DB UPDATE에 남아 있어야 한다. 워커가 갱신에 성공한 직후라도 다음 쓰기까지 소유권이 유지된다고 가정할 수 없다.
DB 결과와 파일 쓰기의 서로 다른 보호 범위
DB가 A의 완료를 거절해도 A가 이미 수행한 파일 쓰기까지 되돌아가지는 않는다. 검토한 워커는 미러 파일을 쓸 때 소유자 토큰을 generation으로 넘기며, 미러 구현은 세대별 파일 경로를 사용한다. A와 B가 같은 결과 파일명을 공유하지 않도록 나누고 DB에는 완료 조건을 통과한 결과의 경로를 기록하는 구조다.
고아 파일 정리도 늦은 워커 자신의 기억만으로 결정하지 않는다. cleanupOrphansFenced()는 행 잠금을 유지한 채 현재 DB 행을 정리 콜백에 전달한다. 워커는 DB가 가리키는 결과 경로와 현재 추출 중인 소유자의 경로를 보존 대상으로 지정한다.
여기에는 주의할 구현 세부도 있다. processOne()은 complete()가 반환한 불리언을 분기 조건으로 사용하지 않고 정리 단계로 이어간다. 따라서 processOne()의 true를 해당 워커의 결과가 DB에 채택됐다는 뜻으로 읽으면 안 된다.
세대별 경로와 정리 시 행 잠금이 있다고 해서 파일 시스템 작업 전체가 DB 트랜잭션에 포함되는 것은 아니다. 오래된 워커가 뒤늦게 파일을 쓰거나 파일 정리와 쓰기가 엇갈리는 상황은 별도 검증이 필요하다. lease만으로 외부 작업의 정확히 한 번 실행을 보장한다고 말할 수 없는 이유다.
이번 검토의 근거와 남은 검증
제품의 lease 저장소, 워커, 미러 구현과 관련 단위 테스트 소스를 읽고 앞의 설명용 모델을 실행했다. 저장소 테스트는 가짜 쿼리 빌더로 잠금 설정 호출과 소유권 조건을 확인한다. 워커 테스트에는 A를 멈춘 사이 B가 완료한 뒤 A를 재개하고, B의 결과와 실행 중인 C의 파일을 보존하는 시나리오도 있다.
이 테스트의 저장소와 파일은 대역이며 실제 DB나 파일 시스템이 아니다. 이번 작성 환경에는 제품의 Jest 실행 파일이 없어 해당 단위 테스트는 실행하지 않았다. 따라서 직접 실행해 확인한 결과는 앞의 Python 모델에 한정된다.
실제 검증에서는 별도 DB 연결로 점유 경쟁을 만들고, 오래 멈춘 워커를 재개한 뒤 완료 UPDATE의 변경 행 수와 저장된 결과 경로를 함께 확인해야 한다. 공유 파일 시스템에서 쓰기와 정리가 겹칠 때 남는 파일도 확인 대상이다. 이 글은 그 실험의 성공을 주장하지 않는다.
작업을 다시 맡기는 조건과 결과를 받아들이는 조건은 분리해 확인해야 한다. lease 만료는 재점유를 허용하고, 현재 소유자와 상태를 확인하는 UPDATE는 늦게 돌아온 결과의 채택 여부를 결정한다.
