- 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>
14 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-f008-grpcserializedstreamwriter-drop-oldest | GrpcSerializedStreamWriter 가 버린 봉투 대신 들어오는 크기를 뺀다 | grpc-and-streaming | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a20-f008-grpcserializedstreamwriter-drop-oldest | 2026-09-04 | case-a20-f008-grpcserializedstreamwriter-drop-oldest.body.md |
|
|
|
GrpcSerializedStreamWriter 가 버린 봉투 대신 들어오는 크기를 뺀다
넘침 분기가 앞에서 봉투를 꺼내 버리면서 누적 바이트에서는 새로 들어오는 메시지의 크기를 뺀다. 봉투가 크기를 담지 않아 버린 봉투의 크기를 알 방법이 없다. 메시지 상한 3 으로 돌리면 누적이 1,000 에 머무는 동안 실제 큐는 3,000 바이트다.
관계
- queued 를 줄이는 유일한 연산이 상한을 읽지 않는 승격 안에 있다 그 기록에서는 큐 계수기를 내리는 유일한 줄이 승격 안에 묶여 있어 대기 값을 되돌릴 수단이 없다. 여기서는 넘침 분기가 버린 봉투 대신 들어오는 메시지 크기를 빼서 누적이 실제 큐 합계와 어긋난다.
- 배치 상한이 개수와 바이트 두 축인 이유 그 개념은 messaging 의 배치 발행에서 개수와 바이트를 함께 두는 이유를 다룬다. 여기서는 같은 이유를 자기 자바독에 적은 gRPC 흐름 제어 정책이 실제와 다른 누적값으로 바이트 축을 판정한다.
- 테스트에서는 드물고 부하에서는 일상인 것 대응 시험이 크기 공급자를 상수로 고정한다. 크기가 하나로 통일되면 두 값이 우연히 일치해 오차가 0 이 된다. 다만 이 결함은 동시 호출이 아니라 크기가 섞이는 것만으로 단일 스레드에서 재현된다.
문제
직렬 스트림 기록기가 넘침 정책 중 하나로 가장 오래된 것을 버린다.
그 분기가 누적 바이트를 어떻게 갱신하는지, 그리고 두 방향에서 결과가 무엇인지 확인했다.
결론
GrpcSerializedStreamWriter:82 가 queue.pollFirst() 로 봉투를 꺼내고 :84 가 queuedBytes 에서 nextBytes 를 뺀다. nextBytes 는 :73 에서 payloadSizer.getAsLong() 로 얻은 들어오는 메시지의 크기다.
올바른 값을 쓸 방법도 없다. GrpcStreamEnvelope 의 성분 일곱에 크기가 없고, 크기 공급자는 인자를 받지 않는다.
이 누적값은 흐름 제어 정책의 판정 입력이다. GrpcFlowControlPolicy:57 이 누적과 다음 메시지 크기의 합을 바이트 상한과 비교한다.
두 방향을 돌렸다. 작은 것을 버리며 큰 것을 넣으면 누적이 1,000 에 고정되는데 실제 큐는 3,000 이다. 순서를 뒤집으면 반대로 부풀어, 실제 큐가 상한의 4분의 1도 차지 않았는데 5,000 바이트짜리가 버려진다.
원문은 뒤쪽에서 조기 TERMINATE 가 된다고 적었는데 그렇게 되지 않는다. 이 정책값으로 decide 를 156,282 조합 불러도 그 결정은 0 회다. 두 분기는 같은 필드의 서로 다른 값에 매달려 있어 한 writer 안에서 함께 도달할 수 없다.
시험이 이 오차를 드러낼 수 없다. DROP_OLDEST 를 쓰는 유일한 자리가 크기 공급자에 상수를 주고, 단언 대상도 결과와 버린 수와 남은 수에 그친다.
배선은 없다. 프로덕션 쪽에 이 타입을 세우는 파일이 하나도 없다.
판정은 P2 이고 원문과 같다. 오차 자체는 양쪽 방향에서 실행으로 잡았다. 반대편에는 이 코드를 도는 인스턴스가 오늘 없다는 것과, 기본 프로파일이 아예 다른 분기를 탄다는 것이 있다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 넘침 분기와 enqueue 를 한 창에서 확인, 크기 획득 지점 추적, 봉투 타입의 성분 전수, 흐름 제어 정책의 판정식과 네 갈래 결정과 기본 팩토리 확인, 경로 제한 없는 참조 검색, 대응 시험의 크기 상수와 단언 확인, 두 방향으로 크기를 바꿔 넣어 회차별로 내부 누적값과 리플렉션으로 읽은 실제 큐 합계 비교, 이 정책값으로 decide 가 낼 수 있는 결정 전수 소스 수정 : x
재현 조건
- 넘침 정책 분기의 본문을 읽고 빼는 값이 어디서 왔는지 거슬러 올라간다.
- 같은 창에서 enqueue 가 그 값을 다시 더하는지 확인한다.
- 봉투 타입의 성분을 전부 나열해 크기가 있는지 본다.
- 크기 공급자의 시그니처를 읽어 버린 봉투로 되물을 수 있는지 본다.
- 흐름 제어 정책의 판정식과 자바독의 두 축 근거를 읽는다.
- 메시지 상한을 작게 두고 작은 것에서 큰 것으로 크기를 올려 가며 넣는다.
- 반대로 큰 것에서 작은 것으로 내려 가며 넣는다.
- 회차마다 내부 누적값과 큐를 직접 읽어 얻은 실제 합계를 비교한다.
- 같은 정책값으로 판정 함수를 조합 전수로 불러 나오는 결정을 센다.
- 대응 시험이 크기 공급자에 주는 값과 단언 대상을 읽는다.
- 이 타입의 참조를 경로 제한 없이 검색한다.
본문
GrpcSerializedStreamWriter 는 봉투를 큐에 쌓고 하나의 기록기가 빼내며, 큐가 넘칠 때 무엇을 할지는 흐름 제어 정책의 느린 소비자 설정이 정한다. 그중 DROP_OLDEST 는 가장 오래된 봉투를 버리고 새 메시지를 받는 손실 허용 프로파일이다.
넘침 분기가 빼는 값
:::evidence key="a20-f008-grpcserializedstreamwriter-drop-oldest" alt="저장소 루트에서 돌린 정적 검색과 /tmp/probe5 에서 컴파일해 돌린 프로브를 합친 출력 174줄. GrpcSerializedStreamWriter 의 넘침 분기가 70번부터 107번 줄까지 실려 DROP_OLDEST 가 pollFirst 로 봉투를 꺼내고 누적에서 nextBytes 를 뺀 뒤 enqueue 가 같은 값을 다시 더하는 것이 한 화면에 보이고, 크기를 얻는 payloadSizer 줄들이 이어진다. GrpcStreamEnvelope 의 성분 일곱에 크기가 없다. GrpcFlowControlPolicy 는 40번부터 87번 줄까지 실려 stable 팩토리와 decide 의 네 갈래가 모두 나오는데 TERMINATE 분기와 DROP_OLDEST 분기와 PAUSE 와 PROCEED 가 각각 보이고, 개수와 바이트를 함께 두는 이유를 적은 자바독이 따라온다. 경로 제한 없는 검색이 이 타입을 언급하는 파일을 전부 내는데 자기 파일 밖 main 참조는 0 개다. 대응 시험의 헬퍼와 각 호출이 크기 공급자에 주는 상수들이 나오고, DROP_OLDEST 를 쓰는 시험이 무엇을 단언하는지 본문째 실린다. 마지막으로 프로브가 두 방향을 각각 표로 낸다. 작은 것을 버리며 큰 것을 넣으면 누적이 1000 에 머무는데 실제 큐는 3000 이고, 큰 것을 버리며 작은 것을 넣으면 누적이 20010 에 고정되는데 실제 큐는 30 까지 줄어 오차가 플러스 19980 이 된다. 그 아래에서 이 정책값으로 decide 를 156282 조합 불렀을 때 TERMINATE 가 0 회이고 나올 수 있는 결정이 DROP_OLDEST 와 PAUSE 와 PROCEED 셋뿐임이 나온다." caption="넘침 분기와 enqueue 가 같은 화면에 · 크기를 담지 않는 봉투 · decide 의 네 갈래와 stable 팩토리 · 자기 파일 밖 main 참조 0 · 시험이 크기 공급자에 주는 상수 · 두 방향의 회차별 표 · 이 정책값으로 TERMINATE 0/156,282 — 174줄 · exit 0" zoom="true" :::
:81~:89 의 DROP_OLDEST 분기는 queue.pollFirst() 로 봉투를 꺼내고(:82), 널이 아니면 queuedBytes = Math.max(0L, queuedBytes - nextBytes) 를 한다(:84). nextBytes 는 :73 에서 payloadSizer.getAsLong() 로 얻은 값, 곧 지금 들어오는 메시지의 크기다. 이어서 :87 의 enqueue 가 :106 에서 같은 nextBytes 를 다시 더한다.
꺼낸 봉투의 크기를 쓸 수도 없다. GrpcStreamEnvelope 는 성분 일곱을 담는데 — streamId, sequence, kind, snapshotVersion, resumeToken, terminationReason, payload — 크기가 없다. payloadSizer 는 인자를 받지 않는 LongSupplier 라 버린 봉투를 넘겨 되물을 수도 없다.
GrpcFlowControlPolicy 가 누적 바이트를 상한과 비교한다
:57 이 queuedBytes + nextMessageBytes > maxQueuedBytes 로 바이트 넘침을 판정하고, :56 의 개수 넘침과 함께 :58 이 둘 중 하나라도 참이면 느린 소비자 정책으로 넘어간다.
자바독 :6~:8 에는 개수와 바이트 중 하나만 두면 다른 축에 상한이 없어진다고 적혀 있다. 바이트 상한 없이 메시지 천 개만 제한하면 큐가 쓸 수 있는 메모리는 누군가 보낸 가장 큰 메시지가 정하고, 반대로 개수 상한 없이 바이트만 제한하면 큐 자체의 부대 비용에 상한이 없다.
두 방향을 돌린 결과
메시지 상한 3, 바이트 상한 1,000,000 으로 100 바이트 셋을 넣고 1000 바이트를 여섯 번 넣었다. 네 번째에서 개수 상한에 걸려 100 바이트짜리가 버려지는데 누적에서는 1000 이 빠진다. 첫 절단에서 Math.max 가 음수를 0 으로 잘라 그때까지의 누적이 사라지고, 그 뒤로는 빼는 값과 더하는 값이 같아 누적이 1000 에 고정된다. 실제 큐는 3,000 바이트다.
이 구성에서는 개수 상한 3 이 먼저 걸리므로 바이트 상한 1,000,000 은 도달하지 않는다. 바이트 경계가 늦게 발화하는 것을 직접 본 것은 아니고, 본 것은 큐가 3,000 바이트를 들고 있는 동안 판정에 들어가는 값이 1,000 이라는 것이다. 바이트 상한을 그 사이 어딘가로 잡은 배포에서는 큐가 개수 경계까지 임의 크기 메시지로 채워지고, 자바독이 막으려던 것이 그 상태다.
크기를 반대로 넣으면 누적이 실제보다 높아진다. 메시지 상한 3, 바이트 상한 25,000 으로 10,000 바이트 둘과 10 바이트 하나를 넣은 뒤 10 바이트를 계속 넣으면 누적이 20,010 에 고정되는데 실제 큐는 30 까지 줄어 오차가 19,980 이 된다. 그 상태에서 5,000 바이트를 넣으면 실제 큐가 5,020 뿐인데도 버려진다.
과대 계상 쪽은 원문과 결과가 다르다
원문은 이 방향에서 조기 TERMINATE 가 된다고 적었다. 그렇게 되지 않는다.
GrpcFlowControlPolicy.decide 를 이 정책값으로 156,282 조합 불러도 TERMINATE 는 0 회이고, 나오는 결정은 DROP_OLDEST·PAUSE·PROCEED 셋뿐이다. 넘침 판정이 TERMINATE 로 가는 분기(:60)는 느린 소비자 정책이 TERMINATE 일 때만 들어가는데, 잘못된 뺄셈이 있는 분기를 도는 writer 의 정책은 DROP_OLDEST 다. 두 값은 같은 record 의 같은 필드라 한 writer 안에서 바뀌지 않는다.
과대 계상은 종료가 아니라 아직 여유가 있는 큐에서 가장 오래된 봉투를 버리게 만든다.
원문과 갈리는 자리
원문은 과소 계상 쪽에서 누적이 Math.max(0, ...) 로 0 에서 멈춘다고 적었다. 관측된 것은 0 이 아니라 마지막에 들어온 메시지 하나의 크기다. 절단은 첫 회에만 일어나고 그 뒤로는 뺀 값과 더한 값이 같다.
원문은 과대 계상 쪽에서 조기 TERMINATE 가 된다고 적었다. 그 정책값으로는 decide 가 그 결정을 낼 수 없다.
분기가 빼는 값, 봉투의 성분 일곱, 판정식, 고정 크기 시험은 원문대로다.
대응 시험이 이것을 볼 수 없다
GrpcSerializedStreamWriterTest:104 가 DROP_OLDEST 정책을 쓰는 유일한 자리인데 크기 공급자를 8L 로 고정한다. 그 시험(:101~:111)이 단언하는 것은 결과가 DROPPED 인 것과 droppedMessages() 가 1 인 것과 queuedMessages() 가 1 인 것이다. 누적 바이트를 보는 단언은 없다.
이 시험 파일의 다른 자리도 1L, 8L, 16L 로 고정한다. 모든 메시지가 같은 크기이면 잘못된 뺄셈이 옳은 값과 같아진다.
GrpcSerializedStreamWriter 를 만드는 main 코드가 없다
경로 제한 없이 검색하면 이 이름은 자기 파일과 GrpcSerializedStreamWriterTest 와 문서 둘에 나온다. 자기 파일 밖에서 이 타입을 쓰는 main 파일은 0 개다.
등급에 대해
원본 분석의 등급은 P2 다. 이 기록은 새로 매기지 않는다.
상류가 P2 로 둔 근거는 판정 입력이 틀어진다는 것이고 그것은 두 방향 모두 실행으로 확인됐다.
반대 근거는 둘이다. 이 타입을 만드는 main 코드가 0 이라 오늘 이 계산을 도는 인스턴스가 없다. 그리고 원문이 적은 대로 GrpcFlowControlPolicy.stable() 의 느린 소비자 정책은 TERMINATE 이므로, 이 분기는 배포가 손실 허용 프로파일을 직접 고를 때만 들어간다.
확인하지 못한 것
gRPC 스트림을 실제로 열지 않았다. 클래스를 직접 인스턴스화해 호출했다.
필요한 값 둘 다 공개 접근자가 없어 관측에 리플렉션을 썼다. 실제 큐 합계도 같은 방식으로 봉투를 읽어 더했다.
배포에서 크기를 어떻게 재는지는 조사하지 않았다.
스트림을 열어 큰 메시지를 흘린 것이 아니다. 타입의 메서드를 직접 호출하고 내부 상태를 읽었다.
크기 공급자의 실제 구현은 범위 밖으로 두었다. 이 타입을 만드는 main 코드가 없으므로 그 자리도 없다.