- 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>
13 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | body | assets | evidence | source | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a20-f007-grpcstreamadmission | GrpcStreamAdmission 의 호출자 맵이 줄지 않고 main 소비자는 0 이다 | grpc-and-streaming | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a20-f007-grpcstreamadmission | 2026-09-04 | case-a20-f007-grpcstreamadmission.body.md |
|
|
|
GrpcStreamAdmission 의 호출자 맵이 줄지 않고 main 소비자는 0 이다
GrpcStreamAdmission.tryAdmit 이 호출자별 계수기와 전체 계수기를 각각 읽어 상한과 비교한 뒤 따로 늘린다. release 는 값만 줄이고 perCaller 항목은 지우지 않아 맵이 지금까지 본 지문 수만큼 남는다. 거기에 이 타입을 세우는 프로덕션 코드가 저장소에 하나도 없다.
관계
- 스트림 승인의 경계가 동시성 아래에서 새고, caller별 맵이 줄지 않는다 같은 클래스의 같은 두 결함을 적은 기록이다. 그 기록은 원자성 분석으로 판정했고, 여기서는 줄 번호를 세고 단일 스레드 프로브로 맵이 두 규모에서 줄지 않는 것과 main 참조가 0 인 것을 확인했다.
- queued 를 줄이는 유일한 연산이 상한을 읽지 않는 승격 안에 있다
두 타입 모두 계수기를 읽어 상한과 비교한 뒤 별개 연산으로 늘린다. 다만 이 타입의
release는 호출자별 계수와 전체 계수를 모두 내리는데, 그 기록의 승인 제어기는inFlight만 내린다. - 카디널리티 경계를 타입으로 표현하기
그 개념은 메트릭 태그의 값 공간이 트래픽과 함께 자라지 않도록 금지 목록과 태그별 상한과 폐쇄 집합으로 닫는 방법을 적는다.
perCaller는 호출자 지문을 키로 쓰는데 그 셋에 해당하는 장치가 하나도 없다.
문제
GrpcStreamAdmission 이 두 축에 상한을 건다.
그 두 상한이 어느 줄에서 검사되는지, perCaller 항목이 언제 지워지는지, 그리고 이 타입을 main 에서 만드는 코드가 있는지 확인했다.
결론
읽는 줄 둘(:44, :47)과 늘리는 줄 둘(:50, :51)이 각각 별개의 연산이다. 그 사이에 다른 스레드가 끼어들 수 있다. release:58~:62 도 검사와 감소가 나뉘어 있다.
계수기를 지우는 코드는 없다. 이 맵이 나오는 네 줄은 선언(:17), computeIfAbsent(:43), get(:57, :73) 이고 제거 계열 호출은 0 건이다.
두 규모로 확인했다. 서로 다른 지문 셋으로 열고 전부 닫으면 맵이 3 으로 남고, 오만으로 하면 50,000 으로 남는다.
닫는 것과 무관한 경로도 있다. 항목을 만드는 줄이 상한 검사 두 줄보다 위에 있어, 거절로 끝나는 시도도 자기 항목을 남긴다. 상한을 (1, 1) 로 두고 하나만 승인한 뒤 오만 번 더 시도하면 전부 거절되는데 맵은 50,001 이 된다. 상한 두 개가 맵 크기는 전혀 제한하지 못한다.
같은 가족의 카디널리티 정책은 :24~:33 에 허용 태그 여덟을 열거하고 그 밖을 전부 거부한다. perCaller 쪽에는 그런 장치가 하나도 없다.
이 타입은 라이브러리 안에서도 쓰이지 않는다. 검색에 잡히는 파일이 셋뿐이고 프로덕션 쪽 사용처가 하나도 없다. 승인 제어기 쪽은 프로덕션 파일 둘이 쓴다. 다만 그 차이는 라이브러리 안에 머문다 — modules.json 의 grpc leaf 열여덟이 전부 빈 runtime_memberships 를 갖는다.
판정은 P2 이고 원문과 같다. 다만 상류가 든 근거에 반대 근거가 하나 있다. 세우는 코드가 없으니 이 두 상한은 오늘 어떤 프로세스에서도 계산되지 않는다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 승인·해제 메서드 본문과 필드 넷 확인, 자바독의 실패 시나리오 확인, perCaller 가 나오는 줄 전부와 소속 메서드 확인, 제거 계열 호출 일곱 이름 검색, 경로 제한 없는 참조 검색과 대조군 비교, 자원 파일·자동설정·리플렉션 경로 검색, 카디널리티 정책의 자바독과 허용 목록 전수, 서로 다른 지문 셋과 오만 두 규모로 열고 닫은 뒤 맵 크기를 단일 스레드로 관측 소스 수정 : x
재현 조건
- 승인 메서드에서 값을 읽는 줄과 늘리는 줄을 각각 적는다.
- 해제 메서드에서 검사와 감소가 어느 줄인지 적고, 널 검사가 무엇을 막는지 읽는다.
- 호출자별 맵이 나오는 줄을 전부 찾고 각각 어느 메서드에 속하는지 적는다.
- 제거 계열 호출을 여러 이름으로 검색한다.
- 서로 다른 지문 셋으로 열고 전부 닫은 뒤 열린 수와 맵 크기를 읽는다.
- 같은 절차를 오만으로 반복해 규모가 따라 커지는지 본다.
- 같은 지문들로 한 번 더, 그리고 새 지문 하나로 맵 크기를 읽는다.
- 이 타입의 참조를 경로 제한 없이 검색하고 대조군과 비교한다.
- 자원 파일과 자동설정 목록과 리플렉션 경로도 검색한다.
- 같은 가족에서 값 공간 증가를 막는 정책을 찾아 그 근거와 목록을 읽는다.
본문
GrpcStreamAdmission 은 호출자별 스트림 수와 전체 동시 스트림 수를 상한으로 지킨다. 자바독(:8~:10)은 스트림이 요청과 달라 수명 내내 연결과 큐와 생산자를 차지하므로 그 개수가 용량 숫자라고 적고, 상한이 없으면 오류마다 재접속하는 클라이언트가 예전 스트림이 닫히는 것보다 빠르게 새 스트림을 연다고 적는다.
tryAdmit 과 release 에서 읽기와 쓰기가 나뉜 줄
:::evidence key="a20-f007-grpcstreamadmission" alt="저장소 루트에서 돌린 정적 검색과 /tmp/probe5 에서 컴파일해 돌린 프로브를 합친 출력 112줄. GrpcStreamAdmission 의 tryAdmit 과 release 본문이 38번부터 64번 줄까지 원문 그대로 실리고 필드 넷이 14번부터 18번 줄로 이어진다. 자바독의 실패 시나리오가 나오고, perCaller 가 나오는 네 줄이 각각 어느 메서드에 속하는지 주석과 함께 실리며 제거 계열 호출 검색이 0 건으로 나온다. 이어서 경로 제한 없는 git grep 이 이 타입을 언급하는 파일 셋과 대조군인 GrpcAdmissionController 의 일곱 파일을 나란히 내고, 자원 파일과 자동설정과 리플렉션 경로 검색이 각각 0 건이다. 같은 가족의 GrpcMetricCardinalityPolicy 자바독 네 줄과 ALLOWED_TAGS 선언 전체가 원소 여덟과 함께 나온다. 마지막으로 프로브 소스의 호출 줄이 실리고, 호출자 셋으로 돌린 결과와 오만으로 돌린 결과가 나란히 나오는데 둘 다 스트림을 모두 닫아도 맵 크기가 그대로이고 새 지문 하나에 하나씩 는다." caption="tryAdmit·release 의 본문과 필드 넷 · 자바독의 재접속 시나리오 · perCaller 네 줄과 제거 호출 0 건 · 경로 제한 없는 참조 목록과 대조군 · 카디널리티 정책의 자바독과 허용 태그 여덟 · 호출자 3 과 50,000 두 규모의 맵 크기 — 112줄 · exit 0" zoom="true" :::
tryAdmit:43 이 computeIfAbsent 로 호출자별 계수기를 얻는다. 값을 읽는 줄은 :44 와 :47 둘이고, 값을 늘리는 줄은 :50 과 :51 둘이다. 읽은 뒤 늘리기 전에 다른 스레드가 같은 값을 읽을 수 있다.
release:57 이 계수기를 꺼내고 :58 이 널이 아닌지와 0 보다 큰지를 함께 확인한 뒤 :59 가 줄인다. :61 이 전체 값을 확인하고 :62 가 줄인다. :58 의 널 검사가 release 를 맵에 항목을 새로 만들지 않는 메서드로 만든다.
스트림 오만 개를 모두 닫아도 perCaller 는 오만이다
perCaller 가 나오는 줄은 넷이다. 선언(:17), tryAdmit 안의 computeIfAbsent(:43), release 안의 get(:57), openStreamsFor 안의 get(:73)이다. remove·clear·removeIf·compute·merge·keySet·entrySet 을 통틀어 제거 계열 호출은 0 건이다.
맵 크기는 단일 스레드 프로브로 읽었다. 서로 다른 호출자 지문 셋으로 스트림을 열고 전부 닫으면 openStreams() 는 0 인데 맵 크기는 3 이다. 같은 지문들로 한 번 더 열고 닫아도 3 이고, 새 지문 하나를 더하면 4 가 된다. 오만으로 돌리면 같은 모양이 규모로 나타나 50,000 · 50,000 · 50,001 이 된다.
닫는 것과 무관하게 자라는 경로가 하나 더 있다. computeIfAbsent(:43)가 두 상한 검사(:44, :47)보다 앞에 있어, 상한에 걸려 거절되는 승인도 항목을 먼저 만든다.
상한을 (1, 1) 로 두고 확인했다. 스트림 하나를 승인한 뒤 서로 다른 지문으로 오만 번 더 시도하면 오만 번 모두 거절된다. openStreams() 는 1 로 상한을 지키는데 맵은 50,001 이다.
그러므로 두 상한은 맵 크기에 아무 상한도 주지 않는다. 승인된 스트림이 하나뿐인 동안에도 맵은 시도한 지문 수만큼 자란다.
자바독이 실패 시나리오로 적은 재접속 클라이언트가 재접속마다 다른 지문을 쓰는지는 확인하지 않았다. 다르다면 재접속 한 번마다 항목이 하나씩 늘어난다.
같은 가족이 값 공간의 증가를 막는 자리
GrpcMetricCardinalityPolicy:12~:15 의 자바독은 문제가 나쁜 태그가 아니라 상한 없는 태그이고, 값 공간이 트래픽과 함께 자라는 태그 하나가 시계열 수를 그 공간만큼 곱한다고 적는다. 테넌트 식별자 하나가 시계열 백 개를 십만 개로 만든다는 것이다.
그래서 그 정책은 허용 목록으로 동작한다. ALLOWED_TAGS 는 :24~:33 에 여덟 개가 열거돼 있고 그 밖은 전부 거부된다.
perCaller 에는 허용 목록도 크기 상한도 없다.
GrpcStreamAdmission 을 쓰는 main 코드가 없다
경로 제한 없이 저장소 전체를 검색했다. 이 이름이 나오는 파일은 셋이다 — 자기 파일, GrpcFlowControlPolicyTest, 그리고 구현 계획 문서 하나다. main 소스 세트에서 자기 파일 밖 참조는 0 이다.
자원 파일과 자동설정 목록과 Class.forName 경로도 각각 0 건이다.
대조군은 다르다. GrpcAdmissionController 는 같은 검색에서 일곱 파일에 나오고 그중 GrpcDrainCoordinator 와 GrpcPlatformAutoConfiguration 이 main 이다.
다만 그 차이는 라이브러리 안에서의 차이다. modules.json 의 grpc leaf 열여덟 개는 runtime_memberships 가 모두 비어 있다. 승인 제어기도 배포 아티팩트에 실려 있지 않다.
원문과 갈리는 자리
원문은 GrpcStreamAdmission 을 승인 제어기와 "같은 형태"로 적었다. 읽기와 쓰기가 나뉜 것과 해제가 0 아래로 갈 수 있는 것까지는 그렇다.
원문이 적지 않은 것이 소비자다. 승인 제어기는 자동 설정과 드레인 조정자가 main 에서 쓰는데 이 타입은 그런 코드가 없다. 두 타입 다 배포 아티팩트에는 실려 있지 않으므로, 갈리는 것은 라이브러리 안의 사용처다.
원문은 맵이 "호출자 수만큼 자라고 줄지 않는다"고 적었다. 그 서술은 맞고, 자라는 경로가 하나 더 있다. 거절된 승인도 computeIfAbsent 를 먼저 지나므로 승인이 하나도 나지 않는 동안에도 맵이 자란다.
등급에 대해
원본 분석의 등급은 P2 다. 이 기록은 새로 매기지 않는다.
상류가 P2 로 둔 근거는 상한이 부하 아래에서 새는 것과 맵이 줄지 않는 것이다. 뒤쪽은 단일 스레드로 확인했고, 앞쪽은 재현 가능한 값을 얻지 못했다.
반대 근거는 이 타입을 만드는 main 코드가 0 이라는 것이다. 두 상한은 지금 어느 런타임에서도 검사되지 않는다. 채택자가 스트림 어댑터를 붙여 이 타입을 쓰기 시작하면 두 결함이 나타날 수 있는데, 그 배포를 띄워 확인하지는 않았다.
확인하지 못한 것
동시 부하에서 위반 시행이 나오는 것만 확인했다. 최댓값은 실행마다 달라 수치를 적지 않는다.
실제 클라이언트의 재접속을 일으킨 것이 아니다. 지문 문자열을 손으로 만들어 넣었다.
지문의 생성 규칙과, 문자열로 조립하는 참조는 좁히지 않았다.
호출자 지문이 무엇으로 만들어지는지 확인하지 않았다.