- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다 - 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5 (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를 techviz 로 만들었다 - 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs 돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다. Concept 이 인용한 코드가 SSOT 에 없어 뺐다 - candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
11 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | assets | evidence | source | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a05-f029-for-update-skip-locked | SKIP LOCKED 로 잡은 조정 작업을 두 번째 연결이 그대로 받는다 | state-ownership-and-concurrency | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a05-f029-for-update-skip-locked | 2026-09-02 |
|
|
|
SKIP LOCKED 로 잡은 조정 작업을 두 번째 연결이 그대로 받는다
조정 작업 청구는 FOR UPDATE SKIP LOCKED 로 끝나는 SELECT 하나다. 트랜잭션도 열지 않고 표에 소유자나 리스를 쓰지도 않으므로, 그 조회가 끝나면서 잠금도 사라진다. 같은 패키지의 전달 큐는 같은 잠금 구문 뒤에 UPDATE 를 붙여 청구를 행에 남긴다.
관계
- 조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다 이 사례가 위반하는 규칙이다.
- fenced lease — 만료 시각만으로는 부족한 이유 수정 방향이 기대는 구조다.
- CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다 조건을 where 절에 전부 넣고 갱신 결과로 판단하라는 규칙이다.
문제
청구 저장소의 javadoc 은 잠금 구문을 쓰는 이유를 전달 큐와 같다고 적는다. 같은 표를 폴링하는 두 작업자가 같은 작업을 둘 다 가져가면 안 된다는 것이다.
결론
청구 메서드는 질의 하나를 실행하고 끝난다. 그 클래스에서 트랜잭션을 여는 애너테이션도 포트 호출도 찾을 수 없다. 자동 커밋이면 SELECT 종료와 함께 암묵 커밋이 걸리고 행 잠금도 그 자리에서 풀린다.
표 쪽에도 남길 자리가 없다. 조정 작업 표의 열은 다음 확인 시각과 시도 횟수와 마지막 결과뿐이고, 이 표를 건드리는 마이그레이션은 만든 것과 색인 하나와 완결성 검사용 이름 리터럴이 전부다.
작업자도 전체 구간을 감싸지 않는다. 만기 조회로 목록을 받은 뒤, 제공자를 다녀오고 나서야 상태를 쓴다.
실제 PostgreSQL 16 에서 자동 커밋 연결 둘이 그 문장을 차례로 실행했다. 사이에 정산은 없다. 둘 다 같은 작업을 받았고 행 상태는 그대로다. 겹쳐 실행하지 않아도 재현된다는 것이 잠금이 이미 사라졌다는 뜻이다.
같은 패키지의 전달 큐는 다르게 한다. 이쪽은 잠금 구문을 CTE 로 감싸고 한 문장 안에서 소유자·리스 만료·펜스를 갱신하며 상태를 배송 중으로 바꾼다. 청구가 행에 남는 시점이 잠금이 풀리기 전이다. 조정 쪽에서만 그 쓰기가 생략됐다.
여기서 일어나는 일이 전송이 아니므로 판정을 한 단계 낮췄다. 깨지는 것은 javadoc 이 적어 둔 계약 쪽이다.
검증 환경
OpenJDK : 21.0.12 데이터베이스 : PostgreSQL 16.15, 실제 실행 확인 방식 : 청구 메서드와 작업자의 경계 선언 확인, 표를 건드리는 마이그레이션 전수, 실제 PostgreSQL 에서 두 연결로 같은 문장 실행 소스 수정 : x
ca-skeleton.notification.platform.enabled 가 참이고 모드가 SERVING 이며 인스턴스가 둘 이상인 배포다. 출하 기본값이 거짓이고, 프로세스가 하나면 스케줄러가 자기 겹침을 막는다.
재현 조건
- 청구 저장소의 javadoc 과 청구 문장, 그리고 그것을 실행하는 메서드를 읽는다.
- 그 클래스와 작업자 클래스의 트랜잭션 경계 선언을 센다.
- 조정 작업 표의 열과, 그 표를 건드리는 마이그레이션을 전부 나열한다.
- 같은 패키지의 전달 큐 청구문을 읽는다.
- PostgreSQL 에 알림 플랫폼 마이그레이션을 적용하고 만기 작업을 하나 넣는다.
- 자동 커밋 연결 둘로 그 문장을 차례로 실행하고 받은 작업과 행 상태를 비교한다.
본문
조정 작업 저장소는 청구 질의를 FOR UPDATE SKIP LOCKED 로 끝낸다. javadoc 이 이유를 적어 두었다.
* <p>Claiming is {@code FOR UPDATE SKIP LOCKED} inside the select, for the same reason the delivery
* queue is: two workers polling the same table must not both take the same job.
청구 메서드는 트랜잭션을 열지 않는다
:::evidence key="a05-f029-for-update-skip-locked" alt="청구 저장소의 javadoc 과 청구 문장과 그것을 실행하는 메서드, 그 클래스의 트랜잭션 경계 선언 수, 같은 패키지 전달 큐의 청구문 전문, 작업자의 처리 순서와 그 클래스의 경계 선언 수, 조정 작업 표의 열 정의와 그 표를 건드리는 마이그레이션 전부, 그리고 알림 플랫폼 마스터 스위치의 출하 기본값과 배경 작업자 스케줄러의 스레드 수를 출력한 터미널 기록." caption="javadoc 은 전달 큐와 같은 이유라고 적음 · 청구는 질의 하나, 경계 선언 0 · 전달 큐는 같은 잠금 구문에 UPDATE 를 붙여 소유자·리스·펜스를 씀 · 표에는 그 열이 없음 · 마스터 스위치 기본값 false, 스케줄러 스레드 1 — 97줄 · exit 0" zoom="true" :::
public List<ReconciliationJob> claimDue(int limit, Instant now) {
Objects.requireNonNull(now, "now");
if (limit < 1) {
throw new IllegalArgumentException("limit");
}
return jdbc.query(CLAIM_DUE, MAPPER, Timestamp.from(now), limit);
}
그 클래스에는 @Transactional 도 TransactionPort 호출도 없다. 자동 커밋에서는 이 SELECT 가 끝나면서 암묵 커밋이 일어나고, 행 잠금은 거기서 사라진다.
표에도 남길 곳이 없다. 조정 작업 표에는 소유자도 리스도 상태도 없다. 다음 확인 시각과 시도 횟수와 마지막 결과가 전부다. 이 표를 건드리는 마이그레이션은 만든 것과 색인 하나, 그리고 완결성 검사용 이름 리터럴뿐이다.
작업자는 조회와 정산 사이에 제공자를 부른다
public int reconcileOnce() {
List<ReconciliationJob> due = jobs.claimDue(batchSize, clock.instant());
int settled = 0;
for (ReconciliationJob job : due) {
// One job's failure is not the pass's: a provider that is refusing connections would
// otherwise stop every other provider's jobs behind it.
try {
if (handle(job)) {
settled++;
}
} catch (RuntimeException failure) {
jobs.reschedule(
job, failure.getClass().getSimpleName(), clock.instant().plus(retryBackoff));
}
}
return settled;
}
목록을 먼저 받고, 반복문 안에서 제공자 조정을 수행한 뒤에야 완료나 재예약을 부른다. 작업자 클래스도 마찬가지로 경계 선언이 없다.
두 연결이 같은 작업을 받는다
:::evidence key="a05-f029-for-update-skip-locked-probe" alt="실제 PostgreSQL 컨테이너에 알림 플랫폼 마이그레이션을 적용해 상위 표들이 세워진 것을 확인하고, 만기 작업 하나를 넣은 뒤 자동 커밋 연결 두 개가 저장소의 청구 문장을 차례로 실행해 각각 받은 작업 식별자와 그 둘이 같은지, 그리고 행 상태를 출력한 터미널 기록. 상위 행 사슬을 만들지 않으려고 외래 키 하나만 뗐다는 단서가 함께 적혀 있다." caption="PostgreSQL 16.15 · 상위 표는 마이그레이션이 세움 · 두 연결 모두 autoCommit true · A 와 B 가 같은 작업을 받음 · 행은 attempts 0, last_result null, 다음 확인 시각 그대로 — 11줄 · exit 0" zoom="true" :::
두 연결의 autoCommit : A=true B=true
작업자 A 가 받은 작업 : 441a6b30-6574-4af2-a637-1caeef230678
작업자 B 가 받은 작업 : 441a6b30-6574-4af2-a637-1caeef230678
같은 작업인가 : true
행 상태 : attempts=0 last_result=null next_check_at 그대로
겹쳐 실행하지 않아도 재현된다. 두 번째 호출 시점에는 첫 호출의 잠금이 이미 사라져 있다.
전달 청구는 같은 문장 안에서 소유자를 쓴다
javadoc 이 이유로 든 전달 큐의 청구문이 같은 패키지에 있다.
WITH claimable AS (
SELECT id FROM notification_recipient_delivery
WHERE next_dispatch_at <= :now
...
FOR UPDATE SKIP LOCKED
LIMIT :batchSize
)
UPDATE notification_recipient_delivery AS d
SET lease_owner = :owner,
lease_until = :leaseUntil,
lease_fence = d.lease_fence + 1,
delivery_state = 'DISPATCHING',
...
같은 잠금 구문을 CTE 안에 넣고, 그 문장의 UPDATE 로 소유자와 리스 만료와 펜스를 쓰고 상태를 배송 중으로 바꾼다. 잠금이 풀리기 전에 청구가 행에 남는다.
조정 작업 청구에는 그 UPDATE 가 없다. 차이는 절 하나다.
이 결함이 나타나는 조건
알림 플랫폼 마스터 스위치가 켜져 있어야 한다. application.yml 이 그것을 거짓으로 내보내므로 기본 배포는 작업자 빈 자체를 만들지 않는다.
인스턴스도 둘 이상이어야 한다. 한 프로세스 안에서 이 패스는 스레드 하나짜리 스케줄러에 고정 지연으로 걸려 자기 자신과 겹치지 않는다. 두 작업자란 두 프로세스를 뜻한다.
영향과 수정
조정은 전송이 아니라 제공자 상태 조회와 상태 투영이므로 영향은 전달 경로보다 낮다. 다만 javadoc 이 적은 계약은 깨진다.
수정은 전달 청구처럼 잠금과 같은 문장 안에서 소유자와 펜스와 리스를 쓰는 청구문을 두는 것이다. 외부 제공자 호출을 긴 데이터베이스 트랜잭션 안에 넣는 것보다 이 방식이 낫다.
확인하지 못한 것
두 작업자가 실제로 제공자 조정을 중복 수행했을 때의 결과를 관측하지 않았다. 확인한 것은 두 호출이 같은 작업을 받는다는 사실이다.
탐침은 상위 행 사슬을 만들지 않으려고 외래 키 하나를 뗐다. 표는 마이그레이션이 모두 세운다.