- 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>
216 lines
14 KiB
Markdown
216 lines
14 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a10-f005-hyperloglog-merge
|
|
title: 채워 넣은 상한은 자기가 잴 요청에서 값을 가져온다
|
|
topic: caching-and-redis
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:a10-f005-hyperloglog-merge
|
|
evidenceCapturedOn: 2026-09-02
|
|
body: case-a10-f005-hyperloglog-merge.body.md
|
|
assets:
|
|
- key: a10-f005-hyperloglog-merge
|
|
file: ../../../final/evidence/rendered/a10-f005-hyperloglog-merge.svg
|
|
- key: a10-f005-hyperloglog-merge-scope
|
|
file: ../../../final/evidence/rendered/a10-f005-hyperloglog-merge-scope.svg
|
|
- key: a10-f005-hyperloglog-merge-budget
|
|
file: ../../../final/evidence/rendered/a10-f005-hyperloglog-merge-budget.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a10-f005-hyperloglog-merge.txt
|
|
- ../../../final/evidence/raw/a10-f005-hyperloglog-merge-scope.txt
|
|
- ../../../final/evidence/raw/a10-f005-hyperloglog-merge-budget.txt
|
|
source:
|
|
- 원본 분석 절은 final/document.md#a10#L277 이다. 등급은 P3 이다. 다중 키 fan-in 표 다섯 줄 중 확률적 집계 둘만 서명에 예산 인자가 없다는 표, 두 명령의 비용이 입력 레지스터 수에 비례한다는 지적, 레지스터가 12KB 고정이라 폭발 범위가 좁다는 판단, 그리고 규칙의 예외가 이유 없이 존재한다는 문장이 그 절에 있다. 그 표의 첫 줄이 집합 대수 셋을 묶은 것이라 연산 수로는 일곱이다.
|
|
- 이 기록에서 확인한 것은 셋이다. 두 명령도 예산 없이는 관문을 통과하지 못하고, 그 예산은 SDK가 채우며, 채운다는 설계는 상한 타입의 첫 문단에 적혀 있다.
|
|
- 남는 문제는 원본이 든 것과 다르다. 예산을 만드는 세 메서드가 요청 바이트 상한을 잴 대상에서 그대로 가져오므로, 이 두 명령뿐 아니라 SDK가 예산을 채우는 R2 명령 전부에서 관문의 요청 바이트 검사가 발화하지 못한다.
|
|
---
|
|
|
|
# 채워 넣은 상한은 자기가 잴 요청에서 값을 가져온다
|
|
|
|
여러 키를 읽어 하나에 쓰는 연산 일곱 중 다섯은 호출자에게 비용 상한을 받고 둘은 받지 않는다. 그 둘도 예산 없이는 관문을 통과하지 못한다. SDK가 대신 만들어 넣는데, 그 상한의 요청 바이트 항목이 자기가 잴 요청의 크기에서 값을 가져온다.
|
|
|
|
## 관계
|
|
|
|
- **상한을 주입받는 자리는 있고 주입하는 곳은 없다**
|
|
같은 리프에서 상한을 받을 자리는 있는데 넣어 주는 코드가 없다.
|
|
- **미배선 인터셉터는 누락이 아니라 중복이다**
|
|
두 사례 모두 빠진 것으로 읽은 자리에 실제로는 다른 형태의 구현이 있었다.
|
|
- **타입이 문서화한 불변식은 타입이 강제한다**
|
|
예산 타입은 기본값으로 채워지지 않는다고 적어 두고 강제하지 않는다.
|
|
|
|
## 문제
|
|
|
|
여러 키를 읽어 하나에 쓰고 비용이 입력 크기에 비례하는 연산은 이 계층에 일곱이다. 원본 표는 집합 대수 셋을 한 줄로 묶어 다섯 줄로 적었다. 그중 다섯에는 호출자가 비용 상한을 건네도록 서명에 인자를 두고, 확률적 집계 계열 둘에는 두지 않는다.
|
|
|
|
인자가 없다는 것과 상한이 없다는 것이 같은 말인지 확인했다.
|
|
|
|
## 결론
|
|
|
|
같은 말이 아니다.
|
|
|
|
정책 카탈로그에서 두 명령은 R2 다. 관문은 R2 요청의 예산이 비어 있으면 거부한다. 예산 없이는 실행 자체가 안 된다.
|
|
|
|
빌더가 예산을 직접 조립해 요청에 얹는다. 두 곳 모두 요소 수와 요청 바이트를 인자로 넘긴다.
|
|
|
|
채워 넣는다는 것 자체는 적혀 있다. 상한을 모아 둔 타입의 첫 문단이 서명에 예산이 없을 때 적용하는 천장이라고 말하고, 호출자가 건넨 쪽이 언제나 이긴다고 덧붙인다.
|
|
|
|
그 문단은 R2 전체를 설명하지 않는다. 비교 대상 셋과 해시 전체 읽기는 허가와 예산을 둘 다 호출자에게 받는다. 반대로 WATCH 와 BLPOP 은 둘 다 SDK가 자기에게 발급한다. 확률적 집계는 SMOVE 나 RENAME 이나 MGET 과 같은 자리에 있다. 허가만 호출자에게 받는 쪽이다.
|
|
|
|
이것이 두 곳만의 방식도 아니다. src/main 에 이름이 나오는 R2 명령 쉰여섯 중 서른넷이 SDK 쪽이고 열넷이 호출자 서명 쪽이다. 나머지 여덟은 예산을 다른 파일에서 조립하거나 직접 만들어 이 스캔으로는 가리지 못했다.
|
|
|
|
실행해 보면 요청 바이트 항목은 어떤 크기에서도 통과한다. 상한을 요청 크기에서 그대로 가져오기 때문이다. 렌더된 키는 타입 상한인 512 바이트를 넘지 못하고 기본 설정의 요소 천장이 1000 이라 이 경로의 요청은 512000 바이트 아래인데, 그 상한선에서도 예산의 상한은 같은 512000 이다.
|
|
|
|
관문은 요소 수를 보지 않는다. 요소 수를 막는 것은 예산을 만드는 쪽이고, 그것도 예산이 만들어지기 전에 던진다. 관문에 도착한 예산이 실제로 기여하는 것은 타임아웃 하나다.
|
|
|
|
같은 패키지에 반대 문장이 있다. 예산 타입의 첫 문단은 모든 R2 API가 예산을 요구하며 기본값으로 채워지지 않는다고 적는다. 두 문장 중 코드가 지키는 쪽은 상한 타입이다.
|
|
|
|
판정은 P3 이되 이유가 다르다. 원본은 인자가 없는 것을 이유로 들었는데, 실제로 남는 문제는 채워 넣은 상한이 관문의 요청 바이트 검사를 발화시킬 수 없다는 것이고, 그것은 이 두 명령만의 일이 아니다. 예산을 만드는 세 메서드가 전부 같은 식을 쓴다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
확인 방식 : 서명과 정책 카탈로그 대조, 요청 빌더 추적, 실행 탐침
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. 여러 키를 읽어 하나에 쓰는 연산 일곱의 서명을 나란히 읽는다.
|
|
2. 정책 카탈로그에서 그 명령들의 위험 등급을 확인한다.
|
|
3. 관문이 R2 요청의 빈 예산을 어떻게 처리하는지 읽는다.
|
|
4. 확률적 집계 요청 빌더가 예산을 어디서 얻는지 따라간다.
|
|
5. 예산을 만드는 세 메서드가 요청 바이트 항목에 무엇을 넣는지 읽는다.
|
|
6. 그 메서드를 여러 요청 크기로 불러 상한과 판정을 출력한다.
|
|
7. R2 명령마다 예산이 호출자 서명에서 오는지 SDK가 만드는지 가려 센다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
확률적 집계 계열의 다중 키 연산 둘은 호출자에게 비용 상한을 받지 않는다. 같은 성격의 다른 다섯은 받는다.
|
|
|
|
## 인자가 없는 것과 상한이 없는 것
|
|
|
|
:::evidence key="a10-f005-hyperloglog-merge" alt="여러 키를 읽어 하나에 쓰는 연산 일곱의 서명 — 다섯에는 비용 상한 인자가 있고 둘에는 없다. 정책 카탈로그가 그 명령들에 매긴 위험 등급, 관문이 R2 요청의 빈 예산을 거부하는 구문, 확률적 집계 요청 빌더가 예산을 만들어 넣는 두 줄과 그 메서드가 상한을 정하는 세 줄, 같은 식을 쓰는 다른 두 메서드의 줄, 그리고 이 설계를 적어 둔 문단과 같은 패키지에서 반대로 적어 둔 문단을 출력한 터미널 기록." caption="일곱 중 다섯의 서명에만 OperationBudget 인자가 있고 확률적 집계 둘은 없다 · 카탈로그 등급은 전부 R2 · 관문은 R2 요청의 빈 예산을 거부하므로 요청 빌더가 예산을 만들어 넣는다 · 요청 바이트 상한을 요청 크기에서 가져오는 식이 세 메서드에 모두 있다 — 112줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
```java
|
|
long count(Collection<? extends HyperLogLogKey<?>> keys, MultiKeyPermit permit);
|
|
void merge(
|
|
HyperLogLogKey<?> destination,
|
|
Collection<? extends HyperLogLogKey<?>> sources,
|
|
MultiKeyPermit permit);
|
|
```
|
|
|
|
앞의 다섯에는 `OperationBudget budget` 이 마지막 인자로 붙어 있다. 이 둘에는 없다. 그런데 카탈로그에서 두 명령은 `R2` 이고, 관문은 이렇게 한다.
|
|
|
|
```java
|
|
if (request.budget().isEmpty()) {
|
|
throw new RedisCommandRejectedException(
|
|
"R2 command requires permit and budget", metadata(policy, OptionalInt.empty()));
|
|
}
|
|
```
|
|
|
|
예산이 비면 실행되지 않는다. 빌더가 직접 조립해 얹는다.
|
|
|
|
```text
|
|
69: Optional.of(context.collectionBudget(rendered.qualified().size(), rendered.requestBytes())),
|
|
93: Optional.of(context.collectionBudget(qualified.size(), size)),
|
|
```
|
|
|
|
## 채워 넣는다고 적힌 곳
|
|
|
|
```text
|
|
/**
|
|
* The ceilings the typed operations apply when the public signature does not carry a budget.
|
|
*
|
|
* <p>Design section 10 gives some R2 methods a caller-supplied {@code OperationBudget} and others a
|
|
* caller-supplied permit, but {@code CommandPolicyGuard} requires both for every R2 command. These
|
|
* limits are what the SDK fills in for the half the signature omits, so an R2 command is never
|
|
* admitted with an unbounded cost. The caller-supplied half always wins; this only supplies what
|
|
* the caller had no way to pass.
|
|
*
|
|
```
|
|
|
|
확률적 집계는 허가만 호출자에게 받고 예산은 SDK가 채운다.
|
|
|
|
이 문단을 R2 전체의 규칙으로 읽으면 틀린다. 집합 대수와 비트 연산과 지리 검색 저장과 해시 전체 읽기는 허가와 예산을 둘 다 호출자에게 받는다. 반대로 `WATCH` 와 `BLPOP` 은 둘 다 SDK가 자기에게 발급한다 — `BLPOP` 의 서명에는 허가 인자가 아예 없다. `BLMOVE` 는 둘 다인 것처럼 보이지만 아니다. 호출자의 다중 키 허가를 문맥이 먼저 검증하고, 관문에는 정책 이름이 맞는 SDK 허가를 대신 건넨다.
|
|
|
|
같은 방식이 R2 전반에 있다.
|
|
|
|
:::evidence key="a10-f005-hyperloglog-merge-scope" alt="src/main 에 이름이 문자열로 나오는 R2 명령마다, 요청을 만드는 메서드의 서명이 비용 상한을 인자로 받는지 아니면 그 메서드 또는 같은 파일의 헬퍼에서 SDK가 만들어 넣는지 가려 센 터미널 기록. 두 단계까지 따라가고도 출처를 못 가린 명령은 따로 적는다." caption="R2 명령 56개 중 34개는 SDK가 예산을 만들고 14개는 호출자 서명이 받는다 · 나머지 8개는 예산이 다른 파일에 있어 미판정 — 25줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
## 채워 넣은 예산이 막는 것
|
|
|
|
:::evidence key="a10-f005-hyperloglog-merge-budget" alt="예산을 만드는 메서드를 세 가지 요청 크기로 불러 얻은 요청 바이트 상한과 그 요청에 대한 판정, 이 경로의 요청이 가질 수 있는 최대치를 키 상한과 요소 천장에서 계산한 값, 같은 크기를 거부하는 호출자 예산 하나, 요소 수를 천장과 천장 초과로 부른 결과, 그리고 두 명령이 선언하는 예상 회신 크기에 대한 판정을 출력한 터미널 기록. 실행에 쓴 자바 판을 첫 줄에 함께 적는다." caption="요청 바이트 상한이 요청 크기와 같아 이 경로의 상한선 512000 바이트에서도 통과 · 호출자가 건넨 상한 4096 은 8192 요청을 거부 · 실제로 거부되는 것은 요소 천장 초과뿐이고 그 거부는 KEY 계열로 기록된다 — 16줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
예산을 만드는 메서드는 요청 바이트 상한을 이렇게 정한다.
|
|
|
|
```java
|
|
long replyCeiling = (long) elements * limits.maxReplyBytesPerElement();
|
|
return new OperationBudget(
|
|
elements, Math.max(1L, requestBytes), replyCeiling, limits.collectionTimeout());
|
|
```
|
|
|
|
상한이 요청 크기다. 관문이 그 둘을 비교한다.
|
|
|
|
```text
|
|
요청 512 -> maxRequestBytes 512 allowsRequestBytes(요청) true
|
|
요청 4096 -> maxRequestBytes 4096 allowsRequestBytes(요청) true
|
|
요청 512000 -> maxRequestBytes 512000 allowsRequestBytes(요청) true
|
|
이 경로의 요청 최대 : 렌더된 키 512 바이트 x 요소 천장 1000 = 512000 바이트
|
|
```
|
|
|
|
기본 설정에서 이 경로가 만들 수 있는 가장 큰 요청에서도 통과한다. 호출자가 건넨 상한은 다르다.
|
|
|
|
```text
|
|
maxRequestBytes 4096, 요청 8192 -> allowsRequestBytes false
|
|
```
|
|
|
|
회신 검사도 지나가는데, 이쪽은 맞는 값이다. `PFCOUNT` 는 정수 하나, `PFMERGE` 는 `OK` 하나라 두 요청이 선언하는 `0L` 이 실제 크기다.
|
|
|
|
거부되는 것은 요소 수 하나다.
|
|
|
|
```text
|
|
요소 1001개 -> RedisCommandRejectedException: operation over 1001 elements exceeds the configured ceiling of 1000 [command=KEY, mode=STANDALONE, ambiguous=false]
|
|
```
|
|
|
|
그 거부는 관문이 아니라 예산을 만드는 쪽에서, 예산이 만들어지기 전에 나온다. 그리고 `command=KEY` 다. 계열 이름이 `"KEY"` 로 고정돼 있어서, 병합 하나가 천장을 넘긴 일이 운영자에게는 키 연산으로 기록된다. 이 자리를 계열 이름으로 먼저 거르는 검사가 확률적 집계에는 없기 때문이다.
|
|
|
|
관문까지 간 예산에서 실제로 쓰이는 것은 타임아웃뿐이다.
|
|
|
|
## 두 명령만의 일이 아니다
|
|
|
|
```text
|
|
313: elements, Math.max(1L, requestBytes), replyCeiling, limits.collectionTimeout());
|
|
336: Math.max(1L, requestBytes),
|
|
349: 1, Math.max(1L, requestBytes), limits.maxReplyBytesPerElement(), limits.scriptTimeout());
|
|
```
|
|
|
|
예산을 만드는 메서드가 셋이고 셋 다 같은 식을 쓴다. 그러니 이 성질은 SDK가 예산을 채우는 R2 명령 서른넷 전부에 있다.
|
|
|
|
## 반대로 적어 둔 곳
|
|
|
|
```text
|
|
/**
|
|
* Explicit bound a caller accepts for one advanced operation.
|
|
*
|
|
* <p>Every R2 API requires a budget. The budget is never optional and never defaulted, because the
|
|
* whole point is that the caller states the cost it is prepared to pay before Redis is asked.
|
|
*/
|
|
```
|
|
|
|
구현이 따르는 것은 상한 타입 쪽이다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
실제 서버에 큰 병합을 보내 지연이 얼마나 커지는지 재지 않았다.
|
|
|
|
512000 은 기본 설정에서의 상한선이다. 요소 천장은 설정에서 올릴 수 있고, 이 리비전에서 요청 문맥을 만드는 곳은 테스트뿐이라 배포에서 실제로 쓰이는 천장은 아직 없다.
|
|
|
|
예산의 출처를 못 가린 여덟 명령은 확인하지 않았다. 그 여덟은 예산을 다른 파일의 공용 실행기에서 받거나 등록된 함수의 자체 한도로 직접 만든다.
|
|
|
|
<!-- body:end -->
|