The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4951 lines
288 KiB
Markdown
4951 lines
288 KiB
Markdown
# adapter-outbound-persistence-jpa 상세 분석
|
||
|
||
|
||
## SSOT identity — 2026-08-31 재검증
|
||
|
||
- registered leaf id: `adapter-outbound-persistence-jpa`
|
||
- canonical state `analysisFile`: `analysis/05-adapter-outbound-persistence-jpa.md` (이 문서) — 이 leaf의 단일 SSOT
|
||
- source path: `src/adapter/outbound/persistence-jpa` · Gradle `:adapter:outbound:persistence-jpa`
|
||
- registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract"]`
|
||
- registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]`
|
||
- coverage ledger: `FULL_READ` **605** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
|
||
|
||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||
|
||
---
|
||
> 상태: COMPLETE
|
||
> 기준 revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||
> 분석 범위: `src/adapter/outbound/persistence-jpa`
|
||
> Gradle path: `:adapter:outbound:persistence-jpa`
|
||
|
||
## 0. 왜 내부 sub-scope로 나누는가
|
||
|
||
이 leaf는 하나의 Gradle module이지만 실제로는 JPA platform의 여러 capability를 package boundary로 합쳐 놓은 큰 구현체다. tracked file denominator는 605개이며 production Java만 350개다. 따라서 module 전체를 한 번에 훑지 않고, 파일이 정확히 하나의 내부 bounded sub-scope에 귀속되도록 ledger를 먼저 고정하고 각 sub-scope를 exhaustive-reading unit으로 처리한다.
|
||
|
||
### 전체 denominator
|
||
|
||
- tracked module files: **605**
|
||
- `src/main`: 381 files / 350 Java / 31 resources / 약 27,744 Java LOC
|
||
- `src/test`: 101 files / 100 Java / 1 `.gitkeep` / 약 9,511 Java LOC
|
||
- `src/testkit`: 41 Java / 약 3,070 LOC
|
||
- `src/postgresqlIntegrationTest`: 75 files / 71 Java / 4 SQL / 약 13,958 Java LOC
|
||
- `src/jpaPlatformPerformanceTest`: 3 Java / 약 268 LOC
|
||
- leaf top-level: `CLAUDE.md`, `README.md`, `build.gradle`, `gradle.lockfile`
|
||
|
||
### 내부 bounded sub-scope ledger
|
||
|
||
| # | sub-scope | denominator | status |
|
||
|---:|---|---:|---|
|
||
| 1 | governance / build / docs / root boundary | 11 | COMPLETE |
|
||
| 2 | API contracts (`api/**`) | 55 | COMPLETE |
|
||
| 3 | transaction + persistence failure | 51 | COMPLETE |
|
||
| 4 | Spring Data + Hibernate + Querydsl | 53 | COMPLETE |
|
||
| 5 | PostgreSQL vendor implementation + vendor migrations | 73 | COMPLETE |
|
||
| 6 | baseline capability stores/config/audit/cache/H2/etc. | 87 | COMPLETE |
|
||
| 7 | Fileserver persistence + migrations | 29 | COMPLETE |
|
||
| 8 | Notification persistence + migrations | 68 | COMPLETE |
|
||
| 9 | Experimental platform | 38 | COMPLETE |
|
||
| 10 | testkit + testkit fixture tests | 62 | COMPLETE |
|
||
| 11 | PostgreSQL integration/readiness lane | 75 | COMPLETE |
|
||
| 12 | pool/performance contract lane | 3 | COMPLETE |
|
||
| | **TOTAL** | **605** | |
|
||
|
||
이 ledger는 module completion 전까지 모든 tracked file의 최종 disposition(`FULL_READ` / `STRUCTURAL_ONLY` / `EXCLUDED`)을 추적하기 위한 내부 작업 단위다. module-level `state.json`은 이 12개가 모두 닫힐 때만 COMPLETE로 전환한다.
|
||
|
||
12개 sub-scope는 모두 닫혔다. 최종 disposition은 **605 FULL_READ / 0 STRUCTURAL_ONLY / 0 EXCLUDED / 0 UNCLASSIFIED**이며, ledger 재조정과 module 완료 조건은 §142에 있다.
|
||
|
||
## 1. 모듈 구조의 1차 관찰
|
||
|
||
`CLAUDE.md`와 `docs/jpa/repository-adaptation.md`에 따르면 원래 별도의 Stable library module들로 설계된 JPA platform을 이 저장소의 fail-closed leaf registry에 맞춰 **하나의 Gradle leaf 내부 package boundary**로 적응시켰다. 그래서 이 module의 package graph 자체가 사실상 내부 모듈 그래프 역할을 한다.
|
||
|
||
실제 build는 production project dependency로 `application-core`, `shared-contract`를 선언하고 Spring Data JPA, Spring Integration JDBC, Flyway, Micrometer 등을 사용한다. PostgreSQL/H2 driver는 runtimeOnly, Querydsl/Envers는 compileOnly다. 별도 `testkit`, `postgresqlIntegrationTest`, `jpaPlatformPerformanceTest` source set과 fail-closed lane을 갖는다.
|
||
|
||
이 문서는 각 sub-scope를 닫아가며 위 설계 문서의 주장과 실제 source/build/test/runtime evidence를 계속 대조한다.
|
||
|
||
---
|
||
|
||
## 2. Sub-scope 02 — API contracts (`api/**`)
|
||
|
||
> 내부 상태: COMPLETE — 49 production + 6 test, **55 / 55 FULL_READ**
|
||
> 범위: `src/main/java/dev/caskeleton/adapter/outbound/persistence/api/**` + matching dedicated tests
|
||
> 역할: provider/framework implementation보다 안쪽에서 persistence platform의 외부 계약, 실패 의미, query safety, transaction/retry algebra를 고정한다.
|
||
|
||
### 2.1 숫자 지도와 package map
|
||
|
||
| package | production | dedicated test | 역할 |
|
||
|---|---:|---:|---|
|
||
| `api` root | 1 | 1 | bounded persistence operation identity |
|
||
| `api.capability` | 3 | 0 | capability/support-level report vocabulary |
|
||
| `api.error` | 23 | 2 | provider-neutral persistence failure algebra |
|
||
| `api.query` | 10 | 2 | keyset/cursor/query-observation contract |
|
||
| `api.transaction` | 12 | 1 | transaction profile/retry/completion-evidence algebra |
|
||
| **합계** | **49** | **6** | **55** |
|
||
|
||
public top-level production type도 정확히 49개다. `docs/architecture/jpa-api-surface.txt`의 committed API baseline 역시 `api` namespace에서 49개를 기록하고 있어 현재 이름 목록 drift는 없다. Gradle `verifyJpaApiSurface`가 이 surface의 추가/삭제를 fail-closed로 검증한다.
|
||
|
||
이 package에는 Spring/JPA/Repository/Entity/Configuration annotation이 하나도 없다. 즉 JPA adapter 안에 위치하지만 **API vocabulary 자체는 Spring bean discovery나 JPA mapping으로 활성화되지 않는다.** 실제 composition은 `app-bootstrap` 및 implementation package가 소유한다.
|
||
|
||
### 2.2 이 API가 “adapter 내부 DTO”와 다른 이유
|
||
|
||
`api/**`는 implementation package와 달리 의도적으로 외부 adopter surface다. committed API baseline 상단도 `api`를 intended external package로 명시한다. 따라서 다음 두 사실을 구분해야 한다.
|
||
|
||
1. repository 내부 production consumer가 있는가
|
||
2. public library contract로 존재할 이유가 있는가
|
||
|
||
예를 들어 `JpaEntityNotFoundException`은 현재 repository production에서 자신을 제외한 참조 파일이 0개다. 하지만 이 한 사실만으로 dead type이라고 판정하지 않았다. external API surface는 repository 내부에서 직접 생성되지 않더라도 adopter가 catch/translate하는 계약일 수 있기 때문이다.
|
||
|
||
반대로 public API라는 이유로 내부 invariant 결함까지 “미사용이라 안전”으로 넘기지는 않는다. `SignedJsonCursorCodec`처럼 codec 자체가 public contract이고 자기 encode/decode algebra가 불일치하면 repository 내부 consumer 유무와 무관하게 API defect다.
|
||
|
||
### 2.3 `PersistenceOperationName`: 자유 문자열 대신 등록 가능한 identity를 타입으로 만든다
|
||
|
||
`PersistenceOperationName`은 `[a-z][a-z0-9.-]{2,95}` 형식만 허용한다.
|
||
|
||
목적은 단순 validation이 아니다. 이 값은 다음 구현 계층에서 실제로 사용된다.
|
||
|
||
- retry observation
|
||
- transaction observation
|
||
- migration gate
|
||
- PostgreSQL failure translation
|
||
- JSON/lock observation
|
||
- transaction executor/coordinator
|
||
|
||
즉 persistence operation의 이름이 metric/trace/policy lookup으로 퍼지기 전에 **cardinality와 데이터 유출 가능성을 가장 안쪽 public type에서 제한**한다.
|
||
|
||
전용 테스트는 다음을 확인한다.
|
||
|
||
- 동적 identifier를 붙인 이름 거부
|
||
- raw/invalid 형태 거부
|
||
- uppercase/길이 초과 거부
|
||
- registered dotted name 허용
|
||
|
||
이 구조는 이후 query 쪽 `QueryName`과 동일한 방향을 가진다. “관측 이름을 호출자가 자유 문자열로 만드는 것”을 허용하지 않는 것이 공통 원칙이다.
|
||
|
||
## 3. Capability API — 실행 기능과 지원 등급을 reportable contract로 분리
|
||
|
||
### 3.1 `JpaCapability`
|
||
|
||
현재 enum은 16개 capability id를 갖는다. app-bootstrap의 `JpaPlatformAutoConfiguration.capabilities()` 역시 16개를 선언하므로 **enum catalog와 current composition count는 일치**한다.
|
||
|
||
Stable composition은 대표적으로 다음을 기본 지원으로 보고한다.
|
||
|
||
- transaction retry
|
||
- completion evidence
|
||
- keyset pagination
|
||
- batch
|
||
- schema gate
|
||
- runtime-role verification
|
||
- observability
|
||
|
||
Advanced capability는 PostgreSQL native write/work claim/JSONB/array-range, bulk DML, stateless session, COPY, L2 cache, Envers 등을 constraints와 함께 보고한다.
|
||
|
||
이 분리는 “classpath에 코드가 있다”와 “현재 composition이 기본 지원한다고 약속한다”를 동일시하지 않는다. capability enum은 vocabulary이고, `CapabilitySupport`가 support level을 결합하며, app-bootstrap composition이 실제 현재 report를 구성한다.
|
||
|
||
### 3.2 `CapabilitySupport`
|
||
|
||
record는:
|
||
|
||
```text
|
||
capability
|
||
level
|
||
constraints[]
|
||
```
|
||
|
||
을 가진다.
|
||
|
||
constructor가 보장하는 것은:
|
||
|
||
- capability non-null
|
||
- level non-null
|
||
- constraints list defensive copy
|
||
- 각 constraint non-null / non-blank
|
||
|
||
이다.
|
||
|
||
`usableByDefault()`는 STABLE만 true다. Advanced/Experimental이 “존재하므로 기본 사용 가능”으로 오해되지 않게 support level을 코드에 남긴다.
|
||
|
||
### 3.3 actuator까지 이어지는 실제 consumer
|
||
|
||
`CapabilitySupport`는 단순 문서용 record가 아니다.
|
||
|
||
실제 production 흐름은:
|
||
|
||
```text
|
||
JpaPlatformAutoConfiguration.capabilities()
|
||
-> List<CapabilitySupport>
|
||
-> JpaPlatformReport.capabilities
|
||
-> JpaPlatformEndpoint @ReadOperation
|
||
-> management endpoint "jpaplatform"
|
||
```
|
||
|
||
이다.
|
||
|
||
`JpaPlatformReport`는 JDBC URL/user/password/SQL/entity catalog를 필드로 갖지 않도록 설계되어 있고, privilege detail도 boolean으로 축약한다. 즉 management endpoint의 reconnaissance surface를 줄이려는 의도가 source에 명시돼 있다.
|
||
|
||
### 3.4 API invariant gap — “bounded constraint”는 타입이 강제하지 않는다
|
||
|
||
`CapabilitySupport` javadoc은 constraints를 actuator report에 게시할 수 있는 **plain, bounded string**으로 설명한다. 그러나 constructor는 길이/형식 상한을 두지 않는다.
|
||
|
||
focused constructor probe에서 100,000-character constraint가 그대로 accepted/copy되는 것을 확인했다.
|
||
|
||
```text
|
||
constraintLength=100000
|
||
```
|
||
|
||
다만 current app-bootstrap composition이 만드는 constraints는 모두 source에 고정된 짧은 literal이다. 따라서 현재 shipped composition에서 즉시 100KB user-controlled value가 endpoint에 노출된다고 주장하지 않는다.
|
||
|
||
판정:
|
||
|
||
- **Observed:** public API type의 “bounded” invariant는 constructor에서 강제되지 않는다.
|
||
- **Observed:** type은 actuator report의 실제 element type이다.
|
||
- **Observed:** current composition은 bounded static literal만 생성한다.
|
||
- **Conclusion:** current exploit/incident가 아니라 **P2 API-contract hardening gap**이다.
|
||
|
||
향후 이 type을 외부 composition/fork가 직접 사용하거나 dynamic constraint source가 생기면 report bound가 호출자의 규율에 의존한다. public API가 “safe to publish”를 자기 계약으로 주장하려면 max length/accepted vocabulary를 타입에서 고정하거나, report projection 단계에서 별도 sanitization/bounding이 필요하다.
|
||
|
||
## 4. Error API — provider exception을 stable failure algebra로 변환
|
||
|
||
### 4.1 `FailureCategory`가 retry보다 먼저 존재한다
|
||
|
||
error hierarchy의 핵심은 “예외 class를 많이 만든 것”이 아니라 provider-specific signal을 bounded semantic category로 변환하는 것이다.
|
||
|
||
대표 category는:
|
||
|
||
- serialization failure
|
||
- deadlock
|
||
- optimistic conflict
|
||
- lock not available
|
||
- connection unavailable
|
||
- timeout 계열
|
||
- unique/FK/not-null/check constraint
|
||
- entity not found
|
||
- schema mismatch
|
||
- data corruption
|
||
- completion unknown
|
||
|
||
등이다.
|
||
|
||
이 category는 뒤의 retry policy/metric이 SQLSTATE/provider message를 직접 해석하지 않게 하는 중간 vocabulary다.
|
||
|
||
### 4.2 `JpaFailureContext`: telemetry-safe failure metadata
|
||
|
||
`JpaFailureContext`가 가지는 정보는 operation, SQLSTATE/constraint, attempt, retryability, completion-unknown, elapsed, trace 등으로 제한된다.
|
||
|
||
중요한 invariant는 다음이다.
|
||
|
||
- arbitrary identifier는 그대로 담지 않고 bounded/redacted form으로 축약
|
||
- malformed SQLSTATE는 `redacted`
|
||
- absent SQLSTATE는 sentinel로 표현
|
||
- completion unknown과 retryable=true를 동시에 표현할 수 없음
|
||
- completion-unknown factory는 항상 automatic retry를 차단하는 형태를 만든다
|
||
|
||
즉 “exception이 발생한 뒤 로그에서 실수하지 말자”보다 앞선 위치에서 **failure context가 위험한 shape 자체를 표현하기 어렵게** 만든다.
|
||
|
||
### 4.3 `JpaPersistenceException`: bounded message와 raw cause의 역할을 분리
|
||
|
||
base exception message는 provider cause message를 그대로 복사하지 않고 category + bounded context로 만든다. dedicated test도 provider cause에 email marker를 넣었을 때 top-level exception message에 노출되지 않는 것을 검증한다.
|
||
|
||
동시에 raw `Throwable cause`는 보존한다. 이는 중요한 구분이다.
|
||
|
||
```text
|
||
exception.getMessage() -> bounded platform message
|
||
exception.getCause() -> original provider failure
|
||
```
|
||
|
||
따라서 API 자체의 message contract는 안전하게 설계되어 있지만 **downstream logger가 stacktrace/cause message까지 출력해도 안전하다는 뜻은 아니다.** 이 여부는 `observation`/transaction failure logging consumer를 읽을 때 별도 검증해야 한다.
|
||
|
||
현재 API sub-scope에서는 이를 defect로 확대하지 않고 후속 observation/transaction trace 항목으로 넘긴다.
|
||
|
||
### 4.4 constraint exception은 raw constraint name을 외부 meaning으로 쓰지 않는다
|
||
|
||
`ConstraintCode`/`ConstraintViolationDetails`는 DB constraint의 raw name이 application-visible meaning이 되지 않도록 stable code/details로 변환하기 위한 계약이다. 실제 PostgreSQL catalog/translator가 consumer다.
|
||
|
||
Unique/FK/NotNull/Check exception은 이 details를 결합한다. 즉 application이 `uk_user_email_2026_v2` 같은 physical identifier를 분기 조건으로 쓰는 대신 stable platform vocabulary에 의존하게 한다.
|
||
|
||
구체적인 catalog mapping 및 SQLSTATE correctness는 PostgreSQL vendor sub-scope에서 exhaustive하게 검증한다.
|
||
|
||
### 4.5 completion unknown을 exception type으로 분리
|
||
|
||
`TransactionCompletionUnknownException`은 단순 `ConnectionUnavailableException`의 한 종류로 흡수되지 않는다.
|
||
|
||
이 타입은:
|
||
|
||
- failure category = completion unknown
|
||
- retryable=false
|
||
- transaction completion evidence
|
||
- bounded optional transaction key
|
||
|
||
를 결합한다.
|
||
|
||
constructor도 들어온 context가 retryable 형태라 해도 completion-unknown-safe context로 변환한다. 결과적으로 뒤의 retry policy가 실수하더라도 “commit됐을 수 있는 work를 다시 실행”하는 경로를 만들기 어렵다.
|
||
|
||
이 contract는 transaction implementation의 evidence frame/commit classifier가 실제로 언제 UNKNOWN을 선택하는지 확인해야 완성된다. 그 실행 의미는 다음 sub-scope의 핵심 대상이다.
|
||
|
||
### 4.6 `JpaEntityNotFoundException`: current repository consumer 0
|
||
|
||
exact-ish repository production reachability probe에서 이 public type만 현재 source 외 production reference가 0이었다.
|
||
|
||
이 type은 initial JPA platform commit부터 존재하고 committed API baseline에도 명시적으로 포함된다. 따라서 현 단계 판정은:
|
||
|
||
```text
|
||
confirmed dead code ✗
|
||
current repository production consumer 없음 ✓
|
||
committed intended external API ✓
|
||
```
|
||
|
||
이다.
|
||
|
||
향후 public API budget을 줄이는 refactor를 할 때는 “실제 external adopter가 존재하는지”를 확인할 후보지만, source tree만으로 제거 가능하다고 결론 내리지 않는다.
|
||
|
||
## 5. Query API — pagination 비용과 trust boundary를 type shape로 제한
|
||
|
||
### 5.1 `KeysetPageRequest`: offset 자체가 없다
|
||
|
||
record는:
|
||
|
||
```text
|
||
after: Optional<C>
|
||
size: 1..500
|
||
direction
|
||
```
|
||
|
||
만 가진다.
|
||
|
||
offset/page number를 아예 표현하지 않으므로 keyset API를 사용하는 consumer가 실수로 large offset pagination으로 회귀하기 어렵다.
|
||
|
||
`fetchSize()`는 요청 size + 1을 반환한다. 즉 별도 count query 없이 한 row를 더 읽어 `hasNext`를 판단하는 계약이다.
|
||
|
||
### 5.2 `KeysetSlice`: total count를 contract에서 제거
|
||
|
||
slice는:
|
||
|
||
```text
|
||
items
|
||
nextCursor
|
||
hasNext
|
||
```
|
||
|
||
만 가진다.
|
||
|
||
invariant:
|
||
|
||
- hasNext=true -> nextCursor 필수
|
||
- terminal slice -> nextCursor 금지
|
||
- items defensive copy
|
||
|
||
이다.
|
||
|
||
page number/total count가 없다는 것은 API omission이 아니라 의도된 성능 정책이다. “keyset을 쓰면서 매번 count(*)도 수행”하는 모순을 contract shape에서 제거한다.
|
||
|
||
### 5.3 `QueryName`과 `QueryObservation`
|
||
|
||
`QueryName`도 bounded registry key다. raw SQL을 metric/trace identity로 사용할 수 없다.
|
||
|
||
`QueryObservation.start(QueryName)` → `QueryScope` 구조에서 scope는:
|
||
|
||
- rows(count)
|
||
- failure(Throwable)
|
||
- close()
|
||
|
||
를 제공한다.
|
||
|
||
특히 `QueryScope.failure` 문서가 “throwable message를 log하지 말 것”을 직접 계약한다. Micrometer implementation이 이를 실제로 지키는지는 observation sub-scope에서 확인한다.
|
||
|
||
`NoopQueryObservation`은 backend가 없을 때도 caller control flow가 갈라지지 않게 singleton no-op scope를 제공한다. app-bootstrap `JpaObservabilityAutoConfiguration`에서 actual fallback consumer가 존재한다.
|
||
|
||
## 6. `SignedJsonCursorCodec`: 좋은 trust-boundary 설계와 경계값 결함이 동시에 존재
|
||
|
||
### 6.1 의도된 security properties
|
||
|
||
codec은 다음 token을 만든다.
|
||
|
||
```text
|
||
v1.<base64url(payload)>.<base64url(HMAC-SHA256(version + '.' + payload))>
|
||
```
|
||
|
||
확인한 방어는 다음과 같다.
|
||
|
||
- signing key 최소 32 bytes
|
||
- URL-safe Base64 / no padding
|
||
- version까지 MAC input에 포함
|
||
- token 전체 길이 4096-character cap
|
||
- payload 2048-byte cap
|
||
- presented MAC 32-byte exact length 확인
|
||
- `MessageDigest.isEqual` constant-time comparison
|
||
- MAC 검증 전에 application payload decoder를 호출하지 않음
|
||
- oversized public input을 substring/decode/MAC allocation 전에 거부하려는 선행 check
|
||
|
||
기존 dedicated tests도 tampering, foreign key, unknown version, short key, oversized token, wrong-length MAC, oversized payload 등을 폭넓게 검증한다.
|
||
|
||
### 6.2 Confirmed P2 — encode가 발급한 2046~2048-byte cursor를 decode가 거부한다
|
||
|
||
문제는 decoded payload size를 decode **전에** 추정하는 helper다.
|
||
|
||
```java
|
||
private static int decodedLengthOf(int encodedLength) {
|
||
return encodedLength / 4 * 3 + 3;
|
||
}
|
||
```
|
||
|
||
이 함수는 “최대 decoded size”를 빠르게 계산하려는 의도로 commit `2f5d2fc`에서 hostile-input bounds와 함께 추가됐다. 그러나 codec은 **unpadded Base64URL**을 사용한다.
|
||
|
||
실제 self-round-trip probe:
|
||
|
||
```text
|
||
size=2045 -> encode OK / decode OK
|
||
size=2046 -> encode OK / decode rejects as oversized
|
||
size=2047 -> encode OK / decode rejects as oversized
|
||
size=2048 -> encode OK / decode rejects as oversized
|
||
size=2049 -> encode itself rejects
|
||
```
|
||
|
||
즉 현재 accepted encode domain과 accepted decode domain이 다르다.
|
||
|
||
형식적으로:
|
||
|
||
```text
|
||
encode accepts payload bytes <= 2048
|
||
|
||
decode precheck effectively accepts only a smaller subset
|
||
|
||
there exists x:
|
||
encode(x) succeeds
|
||
decode(encode(x)) fails
|
||
```
|
||
|
||
이건 hostile token을 더 엄격히 거부하는 정도가 아니다. **codec의 자기 round-trip contract를 깨는 boundary defect**다.
|
||
|
||
실행 evidence:
|
||
|
||
- `evidence/raw/035a-jpa-cursor-boundary-probe.java`
|
||
- `evidence/raw/035-jpa-cursor-boundary-probe.txt`
|
||
|
||
### 6.3 왜 기존 테스트가 못 잡았는가
|
||
|
||
현재 `SignedJsonCursorCodecTest`는:
|
||
|
||
- ordinary round-trip
|
||
- 2049-byte encode rejection
|
||
- decode 쪽 arbitrary oversized payload segment rejection
|
||
|
||
을 각각 검증한다.
|
||
|
||
하지만 `MAX_PAYLOAD_BYTES` 바로 아래와 정확히 같은 크기에 대해:
|
||
|
||
```text
|
||
decode(encode(payload)) == payload
|
||
```
|
||
|
||
을 검증하지 않는다.
|
||
|
||
따라서 security-bound 테스트는 많지만 **양쪽 bound가 같은 집합을 표현하는지**에 대한 property가 빠져 있다.
|
||
|
||
수정 후보는 두 방향이다.
|
||
|
||
1. unpadded Base64URL의 decoded length를 remainder까지 반영해 정확히 계산
|
||
2. MAC 검증 전에 encoded segment의 최대 허용 길이를 exact bound로 계산하고, 실제 decode 후 byte length도 재확인
|
||
|
||
어느 구현을 택하든 regression criterion은 최소:
|
||
|
||
```text
|
||
payload sizes: 2045, 2046, 2047, 2048 -> round-trip success
|
||
2049 -> encode reject
|
||
forged oversized segment -> pre-decode reject
|
||
```
|
||
|
||
이어야 한다.
|
||
|
||
Tech-Log 후보: **CASE** — “DoS 방어용 Base64 사전 크기 검사가 codec의 자기 round-trip을 깨뜨린 경계값 문제”.
|
||
|
||
## 7. Transaction API — 실행체보다 먼저 retry 가능 상태를 제한한다
|
||
|
||
### 7.1 `TransactionProfile`
|
||
|
||
profile은:
|
||
|
||
- name
|
||
- propagation
|
||
- isolation
|
||
- timeout
|
||
- readOnly
|
||
- retryProfile
|
||
|
||
을 결합한다.
|
||
|
||
write profile은 positive timeout이 필수다. read-only는 zero timeout을 “connection default” 의미로 허용한다.
|
||
|
||
지원 propagation을 REQUIRED / MANDATORY / REQUIRES_NEW로 좁혀 SUPPORTS/NESTED/NOT_SUPPORTED/NEVER처럼 “실제로 transaction 안에 있는가”를 흐리는 mode를 surface에서 제거했다.
|
||
|
||
isolation 역시 PostgreSQL에서 의미가 겹치는 READ_UNCOMMITTED를 expose하지 않는다.
|
||
|
||
### 7.2 `RetryProfile`: completion unknown을 config로 다시 살릴 수 없다
|
||
|
||
retryable category allowlist는 다음 contender 계열로 제한된다.
|
||
|
||
- serialization failure
|
||
- deadlock
|
||
- optimistic conflict
|
||
- lock not available
|
||
- connection unavailable
|
||
|
||
`COMPLETION_UNKNOWN`을 넣으면 constructor가 즉시 거부한다. Unique constraint 같은 ineligible category도 거부한다.
|
||
|
||
즉 failure translator가 retryability를 판단하고, profile이 category allowlist를 가진다고 해서 “어떤 failure도 설정으로 retry 가능하게” 만들 수 없다.
|
||
|
||
### 7.3 `RetryDecision`: retry / reconcile / fail을 별도 algebra로 둔다
|
||
|
||
decision은:
|
||
|
||
- RETRY_FULL_TRANSACTION
|
||
- RECONCILE
|
||
- FAIL
|
||
|
||
세 가지이며 retry만 non-zero delay를 가질 수 있다.
|
||
|
||
이 분리 덕분에 completion unknown이 `delay=0 retry`처럼 표현되지 않는다. “모르겠음”을 “즉시 한 번 더”와 구분한다.
|
||
|
||
### 7.4 `reason`의 bounded 주석과 현재 사용
|
||
|
||
`RetryDecision.reason`은 javadoc상 bounded diagnostic/low-cardinality-safe string으로 설명된다. 그러나 constructor는 non-null/nonblank만 확인하고 길이/형식 상한은 없다.
|
||
|
||
runtime constructor probe에서는 100,000-character reason도 accepted됐다.
|
||
|
||
```text
|
||
retryDecisionReasonLength=100000
|
||
```
|
||
|
||
다만 actual `JpaRetryObservation`은 decision.reason을 metric tag로 사용하지 않는다. metric tag는:
|
||
|
||
- persistence unit
|
||
- operation
|
||
- failure category
|
||
- retry disposition
|
||
|
||
으로 구성된다. current repository의 retry-decision reason accessor도 retry policy test 외 실질 telemetry consumer가 확인되지 않았다.
|
||
|
||
따라서 현재 판정은:
|
||
|
||
- docs/type invariant mismatch: **observed**
|
||
- current metric cardinality incident: **not observed**
|
||
- 우선순위: **P3 API hardening/document precision candidate**
|
||
|
||
이다.
|
||
|
||
### 7.5 `maxAttempts`에는 타입-level upper bound가 없다
|
||
|
||
`RetryProfile.maxAttempts`는 최소 1만 강제한다. probe에서 `Integer.MAX_VALUE`도 구성 가능했다.
|
||
|
||
이것만으로 retry storm defect라고 판정하지 않는다. 실제 coordinator에는 elapsed-time budget도 있고 backoff와 profile binding이 별도로 존재한다. 따라서 maxAttempts의 effective runtime bound는 `RetryBudget`, settings, composition을 포함해 **다음 transaction sub-scope에서 판단**한다.
|
||
|
||
### 7.6 cross-scope candidate — fallback policy branch의 도달 가능성
|
||
|
||
API consumer trace 과정에서 `FullTransactionRetryCoordinator`는:
|
||
|
||
```java
|
||
profile.retryProfile() == null
|
||
? fallbackRetryPolicy
|
||
: DefaultJpaRetryPolicy.forProfile(profile.retryProfile())
|
||
```
|
||
|
||
로 분기한다.
|
||
|
||
그런데 `TransactionProfile` compact constructor는 `retryProfile`을 `Objects.requireNonNull`로 강제한다. public constructors/factory도 모두 non-null profile을 만든다.
|
||
|
||
따라서 current type algebra만 보면 fallback branch는 도달 불가능해 보인다. 그러나 이 파일은 transaction sub-scope 소유이고 coordinator 생성/wiring/history까지 읽지 않았으므로 이번 API scope에서는 **dead-path candidate**로만 넘긴다.
|
||
|
||
## 8. Negative-space probes — API scope
|
||
|
||
### 8.1 Public surface reachability
|
||
|
||
Raw: `032-persistence-jpa-api-public-reachability.txt`
|
||
|
||
49개 public type을 exact import/FQN + same-package reference 기준으로 production tree와 비교했다.
|
||
|
||
핵심 결과:
|
||
|
||
- 대부분 implementation/app-bootstrap consumer가 존재
|
||
- `JpaEntityNotFoundException`: current repository production reference 0
|
||
- 일부 codec/SPI는 implementation 내부 또는 app-bootstrap fallback으로만 소비
|
||
|
||
단 이 probe는 external adopter, reflection/generated code를 볼 수 없고 same-package Javadoc reference를 과대계수할 수 있다. 따라서 zero reference만 meaningful negative evidence로 사용했다.
|
||
|
||
### 8.2 Conditional-wiring sibling comparison
|
||
|
||
API package 안에는 Spring configuration/conditional/entity/repository stereotype가 없다.
|
||
|
||
반면 actual runtime composition은 app-bootstrap의:
|
||
|
||
- `JpaPlatformRuntimeAutoConfiguration`
|
||
- `JpaObservabilityAutoConfiguration`
|
||
- `JpaTransactionAutoConfiguration`
|
||
|
||
등이 소유한다.
|
||
|
||
즉 API가 자체 component scan/autoconfiguration으로 몰래 활성화되는 경로는 확인되지 않았다. 이는 “adapter leaf는 implementation을 제공하고 app-bootstrap이 composition을 소유한다”는 repository 정책과 맞는다.
|
||
|
||
### 8.3 Duplicate-mechanism sweep
|
||
|
||
repository에는 다른 bounded context에 같은 simple name이 있다.
|
||
|
||
- inbound-web `CursorCodec`
|
||
- httpclient `RetryDecision`
|
||
- httpclient `FailureCategory`
|
||
- cache-redis `SortDirection`
|
||
|
||
그러나 package와 input/output responsibility가 서로 다르다.
|
||
|
||
예를 들어 inbound web cursor codec은 transport pagination cursor 계약이고, persistence cursor codec은 ordering-key payload에 대한 HMAC integrity seam이다. httpclient retry algebra 역시 HTTP request/retry ambiguity를 다룬다.
|
||
|
||
따라서 **same-name duplication은 존재하지만 현재 evidence로 competing implementation defect는 아니다.** 오히려 bounded context별 vocabulary가 우연히 같은 이름을 갖는 경우다.
|
||
|
||
### 8.4 Documentation / count drift
|
||
|
||
- committed API baseline: 49 `api` top-level types
|
||
- current source: 49
|
||
- `JpaCapability` enum: 16
|
||
- current app-bootstrap capability declarations: 16
|
||
|
||
현재 수치 drift 없음.
|
||
|
||
API surface verification도 별도 Gradle task가 소유하므로 수동 문서 count만 믿는 구조가 아니다.
|
||
|
||
## 9. 테스트와 증명 범위
|
||
|
||
### 9.1 Dedicated API tests
|
||
|
||
전용 test class는 6개다.
|
||
|
||
- `PersistenceOperationNameTest`
|
||
- `JpaFailureContextTest`
|
||
- `JpaPersistenceExceptionTest`
|
||
- `QueryNameTest`
|
||
- `SignedJsonCursorCodecTest`
|
||
- `TransactionProfileTest`
|
||
|
||
fresh `--rerun-tasks` 실행에서 모두 통과했다.
|
||
|
||
이들은 다음을 잘 검증한다.
|
||
|
||
- low-cardinality operation/query name shape
|
||
- completionUnknown/retryable contradiction 차단
|
||
- top-level exception message의 provider-message 비노출
|
||
- cursor signature/tamper/version/size basics
|
||
- transaction/retry profile의 주요 unsafe shape
|
||
|
||
하지만 확인된 cursor self-round-trip boundary property는 포함하지 않는다.
|
||
|
||
### 9.2 API surface verification
|
||
|
||
`verifyJpaApiSurface --rerun-tasks`가 통과했다.
|
||
|
||
이 task가 증명하는 것은 **public type names가 committed baseline과 동일하다**는 것이다. method semantics나 constructor invariant까지 ABI/API compatibility를 검증하는 것은 아니다.
|
||
|
||
### 9.3 app-bootstrap capability composition test
|
||
|
||
current repository composition은 별도 app-bootstrap test로 확인한다. 이 test는 현재 16개 capability와 default usable support 등을 검증하지만 public `CapabilitySupport`에 arbitrary external constraints가 들어오는 경우의 bound를 검증하는 test는 아니다.
|
||
|
||
## 10. API sub-scope findings backlog
|
||
|
||
### P2 — `SignedJsonCursorCodec` accepted encode domain과 decode domain 불일치
|
||
|
||
- **Observed:** 2046~2048-byte payload는 encode 성공 후 자기 token decode 실패.
|
||
- **Cause shape:** unpadded Base64URL encoded segment의 decoded byte count를 `encodedLength / 4 * 3 + 3`으로 과대 추정.
|
||
- **Why it matters:** public paging cursor codec이 자기가 발급한 token을 다음 page에서 거부할 수 있음.
|
||
- **Existing tests:** green이지만 exact-bound round-trip 없음.
|
||
- **Verification:** `035` runtime probe + 향후 boundary regression/property test.
|
||
- **Candidate fix:** exact unpadded Base64 decoded-size arithmetic 또는 encoded-length exact cap + post-decode byte cap.
|
||
- **Tech-Log:** CASE 우선 후보.
|
||
|
||
### P2 — `CapabilitySupport.constraints`의 bounded/report-safe 계약이 타입에서 강제되지 않음
|
||
|
||
- **Observed:** 100,000-character constraint accepted.
|
||
- **Observed:** capability list는 `JpaPlatformReport`를 통해 actuator endpoint model에 포함됨.
|
||
- **Observed:** current shipped composition은 static short literals만 생성.
|
||
- **Impact:** 현 composition incident가 아니라 public API invariant gap; fork/dynamic composition에서 bound가 caller discipline에 의존.
|
||
- **Candidate fix:** type-level max length/vocabulary 또는 report projection에서 bounding.
|
||
- **Tech-Log:** OPEN QUESTION/DECISION 후보; 실제 외부 dynamic source가 확인되면 CASE 승격 가능.
|
||
|
||
### P3 — `RetryDecision.reason`의 “bounded” 설명과 constructor contract 불일치
|
||
|
||
- **Observed:** 100,000-character reason accepted.
|
||
- **Observed:** current retry metrics는 reason을 tag로 사용하지 않음.
|
||
- **Impact:** 현재 cardinality defect로 확인되지 않음.
|
||
- **Candidate:** length bound를 추가하거나 javadoc의 low-cardinality claim을 실제 사용 범위에 맞게 좁힘.
|
||
|
||
### Cross-scope candidate — retry fallback branch reachability
|
||
|
||
- `TransactionProfile.retryProfile`은 non-null invariant.
|
||
- `FullTransactionRetryCoordinator`는 null retryProfile을 fallback policy 선택 조건으로 사용.
|
||
- transaction wiring/history까지 확인 후 dead branch인지 판정.
|
||
|
||
### External-surface candidate — `JpaEntityNotFoundException`
|
||
|
||
- current repository production consumer 0.
|
||
- committed intended external API baseline에는 존재.
|
||
- external adoption evidence 없이 dead/remove 판정 금지.
|
||
|
||
## 11. API sub-scope에서 확인한 것과 남긴 경계
|
||
|
||
### FULL_READ
|
||
|
||
- production `api/**`: 49 / 49
|
||
- dedicated test `api/**`: 6 / 6
|
||
- unclassified: 0
|
||
|
||
### Cross-scope evidence로 읽은 consumer
|
||
|
||
- `JpaPlatformAutoConfiguration`
|
||
- `JpaPlatformReport`
|
||
- `JpaPlatformEndpoint`
|
||
- `JpaPlatformRuntimeAutoConfiguration` relevant wiring
|
||
- `JpaRetryObservation`
|
||
- `DefaultJpaRetryPolicy`
|
||
- `FullTransactionRetryCoordinator` relevant control flow
|
||
|
||
이 consumer 파일들은 API 의미를 확인하기 위한 cross-scope trace이며, 해당 소유 sub-scope 전체가 FULL_READ됐다는 뜻은 아니다.
|
||
|
||
### 다음 sub-scope로 넘긴 것
|
||
|
||
- 실제 transaction begin/commit/rollback/evidence semantics
|
||
- `RetryBudget`가 maxAttempts/defaultMaxElapsed를 어떻게 결합하는지
|
||
- completion unknown record/reconciliation path
|
||
- provider failure translator chain과 raw cause logging
|
||
- Micrometer query scope가 `Throwable` message를 실제로 무시하는지
|
||
- PostgreSQL SQLSTATE/constraint mapping correctness
|
||
|
||
|
||
---
|
||
|
||
## 12. Sub-scope 03 — transaction + persistence failure
|
||
|
||
> 내부 상태: COMPLETE — 32 production + 19 test, **51 / 51 FULL_READ**
|
||
> 범위: `persistence/transaction/**`, `persistence/failure/**`와 matching dedicated tests
|
||
> 핵심 질문: transaction을 여는 코드가 아니라 **commit 결과를 언제 확정하는가, 어떤 failure만 replay하는가, completion-unknown을 어떤 evidence로 남기는가**.
|
||
|
||
### 12.1 숫자 지도
|
||
|
||
| package | production | dedicated test | 역할 |
|
||
|---|---:|---:|---|
|
||
| `transaction` | 29 | 18 | application transaction port, JPA executor, retry, deadline, completion evidence |
|
||
| `failure` | 3 | 1 | shared operational-error translation |
|
||
| **합계** | **32** | **19** | **51** |
|
||
|
||
모든 51개 source/test를 FULL_READ했다. 이 scope에서는 implementation class를 샘플링하지 않고 transaction state machine, retry budget, Spring mapping, failure translation, root wiring, consumer reachability까지 연결했다.
|
||
|
||
## 13. 같은 leaf 안에 두 개의 transaction model이 존재한다
|
||
|
||
현재 persistence-jpa에는 transaction을 표현하는 두 계열이 동시에 존재한다.
|
||
|
||
### A. application-core canonical boundary
|
||
|
||
```text
|
||
PolicyTransactionPort / TransactionPort
|
||
-> SpringTransactionPort (@Component)
|
||
-> SpringPolicyTransactionPort
|
||
-> PlatformTransactionManager
|
||
```
|
||
|
||
input/output vocabulary:
|
||
|
||
- `TransactionRequest`
|
||
- `TransactionPolicyId`
|
||
- `CallBudget`
|
||
- `TransactionResult`
|
||
- `TransactionOutcome`
|
||
- `OperationId`
|
||
- `TransactionPhase`
|
||
- `ReconciliationReference`
|
||
|
||
이 모델은 application-core가 소유한다. use case가 outbound adapter type을 import하지 않아도 transaction policy와 uncertain outcome을 표현할 수 있다.
|
||
|
||
### B. persistence-jpa public API boundary
|
||
|
||
```text
|
||
JpaTransactionExecutor
|
||
-> SpringJpaTransactionExecutor
|
||
-> FullTransactionRetryCoordinator
|
||
-> TransactionProfile / RetryProfile / JpaRetryPolicy
|
||
```
|
||
|
||
input/output vocabulary:
|
||
|
||
- `PersistenceOperationName`
|
||
- `TransactionProfile`
|
||
- `RetryProfile`
|
||
- `JpaPersistenceException`
|
||
- `TransactionCompletionEvidence`
|
||
|
||
`JpaPlatformRuntimeAutoConfiguration`은 `PlatformTransactionManager`가 있으면 `SpringJpaTransactionExecutor` bean을 만들고, 그 executor가 있으면 `FullTransactionRetryCoordinator` bean도 만든다.
|
||
|
||
따라서 source tree 수준에서는 B가 단순 historical class가 아니라 **현재 runtime bean graph에도 포함되는 구현**이다.
|
||
|
||
그러나 repository production call search에서는 `FullTransactionRetryCoordinator.execute(...)`를 실제 business/application code가 호출하는 경로가 확인되지 않았다. 반대로 application-core transaction port는 sample/use-case/composition에서 canonical contract로 사용된다.
|
||
|
||
이 공존 자체는 곧바로 defect가 아니다. `api/**`는 intended external surface이므로 fork/application이 B를 programmatically 사용할 수 있다. 문제는 문서가 두 boundary의 관계를 일관되게 설명하지 못하고, 일부 composition helper는 실제 type relationship과 다른 설명을 한다는 점이다.
|
||
|
||
## 14. `SpringTransactionPort`: application-core의 실제 Spring 구현
|
||
|
||
`SpringTransactionPort`는 `PolicyTransactionPort`를 구현하며 `JpaAdapterComponentsConfig`의 narrow component scan으로 등록된다.
|
||
|
||
이 wiring은 중요하다. root `CaSkeletonApplication`은 persistence package를 broad scan에서 의도적으로 제외한다. 그래서 adapter leaf 내부의 `@Component`를 “annotation이 있으니 알아서 등록될 것”이라고 볼 수 없다.
|
||
|
||
`JpaAdapterComponentsConfig` source에는 과거 실제 회귀가 기록돼 있다.
|
||
|
||
- persistence package를 broad scan에서 제외
|
||
- `SpringTransactionPort` 같은 component를 별도 scan하지 않음
|
||
- 처음 transaction port가 필요한 capability가 조립될 때 unsatisfied dependency로 드러남
|
||
- 해결: JPA master switch 아래에서만 persistence adapter package를 narrow scan
|
||
|
||
즉 이 module에서 Spring stereotype의 존재와 runtime reachability는 별개다. current root는 `PersistenceJpaRootAutoConfiguration -> JpaAdapterComponentsConfig -> component scan` 체인을 통해 이를 해결한다.
|
||
|
||
### 14.1 기본 transaction mode
|
||
|
||
`TransactionPort` primitive는 다음으로 매핑된다.
|
||
|
||
| application operation | Spring propagation | isolation | read-only |
|
||
|---|---|---|---|
|
||
| `inWrite` | REQUIRED | READ_COMMITTED | false |
|
||
| `inRootWrite` | REQUIRED | READ_COMMITTED | false |
|
||
| `inRead` | REQUIRED | READ_COMMITTED | true |
|
||
| `inNew` | REQUIRES_NEW | READ_COMMITTED | false |
|
||
|
||
특히 vendor default isolation에 맡기지 않고 READ_COMMITTED를 명시한다.
|
||
|
||
`inRootWrite`는 REQUIRED이지만 일반 `inWrite`와 의미가 다르다. 시작 전에 `TransactionSynchronizationManager.isActualTransactionActive()`를 확인해 ambient physical transaction이 있으면 manager/action 호출 전에 거부한다. “root boundary”를 REQUIRED의 join semantics로 조용히 바꾸지 않는다.
|
||
|
||
focused test는 실제로 manager call count/action call count까지 0인지 확인한다.
|
||
|
||
### 14.2 caller-visible 성공은 physical commit 이후
|
||
|
||
`SpringTransactionPortTest`는 다음을 고정한다.
|
||
|
||
- work가 value를 만들었다고 바로 caller에게 반환하지 않음
|
||
- transaction template/manager commit이 끝난 뒤에만 success가 caller-visible
|
||
- commit failure면 work value를 반환하지 않음
|
||
- action failure는 rollback
|
||
- REQUIRES_NEW는 별도 propagation
|
||
|
||
이것은 application-core의 `TransactionPort` javadoc이 요구한 “return after physical commit”을 adapter가 실제로 구현하는 evidence다.
|
||
|
||
## 15. `SpringPolicyTransactionPort`: transaction result를 boolean 성공/실패보다 세밀하게 표현
|
||
|
||
`PolicyTransactionPort.inTransaction(...)`은 단순 예외 기반 wrapper가 아니다.
|
||
|
||
결과는 최소 다음 상태를 구분한다.
|
||
|
||
- `Committed`
|
||
- `CommittedWithPostCommitFailure`
|
||
- `Participating`
|
||
- `DeterminateRollback`
|
||
- `Indeterminate`
|
||
|
||
핵심은 **commit exception = rollback**으로 가정하지 않는 것이다.
|
||
|
||
### 15.1 commit failure 분기
|
||
|
||
commit 호출 전에 phase를 `COMMIT_REQUESTED`로 올리고, Spring `TransactionSynchronization` sentinel로 실제 callback을 관찰한다.
|
||
|
||
commit에서 exception이 발생해도:
|
||
|
||
1. `afterCommit()`이 이미 확인됐으면 `CommittedWithPostCommitFailure`
|
||
2. rollback callback/`UnexpectedRollbackException`/replay-candidate가 확인되면 `DeterminateRollback`
|
||
3. 그 외에는 `Indeterminate`
|
||
|
||
로 나눈다.
|
||
|
||
즉 연결 끊김 같은 애매한 exception을 “rollback이겠지”라고 간주하지 않는다.
|
||
|
||
### 15.2 canonical application path는 자동 duplicate replay를 막는다
|
||
|
||
`Indeterminate`는 retry 대상이 아니다.
|
||
|
||
replay 조건은 모두 만족해야 한다.
|
||
|
||
- policy = `COMMAND_SERIALIZABLE_REPLAY_SAFE`
|
||
- 현재 attempt가 physical transaction owner
|
||
- attempt < configured max
|
||
- current thread not interrupted
|
||
- result가 `DeterminateRollback`
|
||
- failure가 40001 serialization 또는 40P01 deadlock replay candidate
|
||
|
||
따라서 commit ack를 못 받은 상태는 replay되지 않는다.
|
||
|
||
이 점은 뒤에서 다룰 JPA public API completion-evidence wiring gap의 중요한 mitigation이다. **현재 canonical application path는 completion evidence infrastructure가 없어도 불확정 commit을 자동 재실행하지 않는다.**
|
||
|
||
다만 current `SpringPolicyTransactionPort`가 만드는 `TransactionResult.Indeterminate`의 `reconciliationReference`는 두 생성 경로 모두 `Optional.empty()`다. 즉 application-core type은 durable reconciliation reference를 표현할 수 있지만 이 adapter는 현재 그 reference를 채우지 않는다.
|
||
|
||
## 16. CallBudget를 transaction timeout보다 먼저 적용한다
|
||
|
||
application policy path는 timeout을 단순히 `TransactionDefinition.setTimeout()` 하나로 끝내지 않는다.
|
||
|
||
### 16.1 `JpaTransactionSettings`
|
||
|
||
`ca-skeleton.jpa.transaction` settings는 transaction/resource-budget defaults를 가진다.
|
||
|
||
주요 invariant:
|
||
|
||
- duration positive
|
||
- duration <= 1 day
|
||
- retry max attempts 1..5
|
||
- statement timeout <= transaction timeout
|
||
- lock timeout < statement timeout
|
||
- completion/acquisition/action margin hierarchy
|
||
|
||
즉 runtime에서 무한 retry나 무한 transaction timeout을 property 하나로 열 수 없게 hard cap을 둔다.
|
||
|
||
이 점은 API `RetryProfile.maxAttempts`가 upper bound를 갖지 않는 것과 대비된다. canonical application path는 실제 deployment settings에서 최대 5회를 강제한다.
|
||
|
||
### 16.2 `TransactionDeadlineCalculator`
|
||
|
||
CallBudget admission은 connection pool을 빌리기 **전**부터 시작한다.
|
||
|
||
transaction을 열 가치가 있으려면 남은 budget이 최소 다음을 감당해야 한다.
|
||
|
||
```text
|
||
pool acquisition reserve
|
||
+ transaction begin reserve
|
||
+ minimum action budget
|
||
+ completion margin
|
||
```
|
||
|
||
begin 후에는 실제 남은 budget으로:
|
||
|
||
- Spring whole-transaction timeout
|
||
- statement timeout
|
||
- lock timeout
|
||
- idle-in-transaction timeout
|
||
|
||
을 다시 계산한다.
|
||
|
||
따라서 pool에서 오래 기다린 요청이 “원래 5초 timeout이었으니 DB에서 다시 5초”를 받지 않는다. 이미 소비한 wall-clock budget을 transaction layer가 다시 주지 않는 구조다.
|
||
|
||
### 16.3 `TransactionRetryBackoff`
|
||
|
||
canonical path의 retry backoff도 CallBudget-aware다.
|
||
|
||
다음 attempt를 시작하기 전에:
|
||
|
||
- jitter delay
|
||
- 다음 acquisition reserve
|
||
- 다음 최소 transaction/action margin
|
||
|
||
을 모두 감당할 수 있는지 확인한다.
|
||
|
||
budget이 부족하면 sleep 후 시작했다가 즉시 timeout되는 대신 retry 자체를 포기한다.
|
||
|
||
## 17. retry classification은 structured state로 제한한다
|
||
|
||
`TransactionRetryClassifier`는 cause chain에서 SQLSTATE를 찾지만 automatic replay candidate는:
|
||
|
||
- `40001`
|
||
- `40P01`
|
||
|
||
뿐이다.
|
||
|
||
`08007` 같은 transaction-resolution-unknown은 candidate가 아니다.
|
||
|
||
`SpringPolicyTransactionPort`는 ordinary command에서 40001이 나더라도 `COMMAND_SERIALIZABLE_REPLAY_SAFE`가 아니면 retry하지 않는다. failure 종류뿐 아니라 **업무 side-effect가 replay-safe하다고 application policy가 선언했는가**가 함께 필요하다.
|
||
|
||
이것은 “DB가 retryable이라고 말하니 use case를 다시 실행”하는 구조와 다르다.
|
||
|
||
## 18. public JPA path: `SpringJpaTransactionExecutor`
|
||
|
||
이 executor는 한 번의 physical attempt만 담당한다. 자체 retry는 하지 않는다.
|
||
|
||
실행 순서:
|
||
|
||
```text
|
||
TransactionEvidenceContext.begin(...)
|
||
-> TransactionTemplate.execute(work)
|
||
-> success return
|
||
or
|
||
-> attempt boundary에서 failure translation
|
||
-> translated runtime exception rethrow
|
||
finally
|
||
-> TransactionEvidenceScope close
|
||
```
|
||
|
||
attempt boundary에서 operation, attempt number, elapsed time, reconciliation key를 알고 있으므로 raw provider exception을 `JpaPersistenceException`으로 변환하는 위치로 사용된다.
|
||
|
||
vendor translator가 조립되면 PostgreSQL 40001/40P01 같은 structured SQLSTATE가 stable exception으로 바뀌어 coordinator가 처리할 수 있다.
|
||
|
||
## 19. `FullTransactionRetryCoordinator`: whole-use-case retry 의도
|
||
|
||
coordinator는 `JpaPersistenceException`만 catch하고, retry decision에 따라 **새 transaction / 새 persistence context에서 전체 work를 다시 호출**한다.
|
||
|
||
설계상 중요한 guard:
|
||
|
||
- completion unknown -> no retry
|
||
- irreversible side effect context -> no retry
|
||
- retry budget elapsed -> stop
|
||
- max attempts -> stop
|
||
- backoff interrupt -> stop
|
||
- retry listener는 observation only
|
||
|
||
이 모델 자체의 unit tests는 강하다. serialization/deadlock retry, exhaustion, completion unknown no-retry, interrupted sleep, irreversible side effect 등을 검증한다.
|
||
|
||
하지만 current implementation에는 public composition contract와 맞지 않는 별도 defect가 있다.
|
||
|
||
## 20. Confirmed P2 — application-supplied `JpaRetryPolicy`가 valid execution에서 무시된다
|
||
|
||
`JpaTransactionAutoConfiguration`은 명시적으로 다음 overload를 제공한다.
|
||
|
||
```java
|
||
retryCoordinator(
|
||
SpringJpaTransactionExecutor executor,
|
||
JpaRetryPolicy policy,
|
||
RetryEventListener listener)
|
||
```
|
||
|
||
javadoc도 **“retry coordinator for an application-supplied policy”**라고 설명한다.
|
||
|
||
constructor는 이 policy를 `fallbackRetryPolicy`로 저장한다.
|
||
|
||
그러나 `FullTransactionRetryCoordinator.execute(...)`는 실행마다:
|
||
|
||
```java
|
||
profile.retryProfile() == null
|
||
? fallbackRetryPolicy
|
||
: DefaultJpaRetryPolicy.forProfile(profile.retryProfile())
|
||
```
|
||
|
||
를 선택한다.
|
||
|
||
API `TransactionProfile` compact constructor는 `retryProfile`을 non-null로 강제한다. 따라서 valid `TransactionProfile`을 사용하면 `fallbackRetryPolicy` branch는 도달할 수 없다.
|
||
|
||
더구나 바로 다음 `RetryBudget.forProfile(profile.retryProfile(), ...)`도 non-null profile을 요구하므로 null branch가 hypothetically 열려도 정상 execution model과 맞지 않는다.
|
||
|
||
### 실행 probe
|
||
|
||
custom policy를 다음처럼 넣었다.
|
||
|
||
```text
|
||
custom policy decision = always FAIL
|
||
custom policy invocation counter
|
||
valid TransactionProfile with maxAttempts=2
|
||
work = SerializationFailureException
|
||
```
|
||
|
||
결과:
|
||
|
||
```text
|
||
customPolicyCalls=0
|
||
workCalls=2
|
||
```
|
||
|
||
즉 custom policy가 “retry하지 말라”고 해도 한 번도 호출되지 않고 default profile policy에 따라 work가 두 번 실행됐다.
|
||
|
||
Raw:
|
||
|
||
- `evidence/raw/047a-jpa-custom-retry-policy-probe.java`
|
||
- `evidence/raw/047-jpa-custom-retry-policy-probe.txt`
|
||
|
||
이것은 단순 dead field가 아니라 **public composition factory가 제공하는 custom policy 기능이 실제로 작동하지 않는 functional contract bug**다.
|
||
|
||
우선순위: **P2**
|
||
|
||
수정 방향 후보:
|
||
|
||
1. coordinator가 constructor-supplied policy를 authoritative하게 사용하고 budget만 profile에서 계산
|
||
2. custom policy overload를 제거하고 RetryProfile이 단일 SSOT임을 API에 명시
|
||
3. custom policy가 profile-aware해야 한다면 factory에서 policy/profile을 하나의 object로 합성
|
||
|
||
현재처럼 두 설정원을 받되 하나를 silent ignore하는 형태가 가장 위험하다.
|
||
|
||
## 21. completion evidence state machine 자체는 잘 설계돼 있다
|
||
|
||
`EvidenceAwareJpaTransactionManager`는 `JpaTransactionManager`를 상속하고 transaction phase를 `TransactionEvidenceContext`에 기록한다.
|
||
|
||
대략:
|
||
|
||
```text
|
||
NOT_STARTED
|
||
-> ACTIVE (begin)
|
||
-> COMMITTING (provider commit 직전)
|
||
-> COMMITTED (provider commit return)
|
||
```
|
||
|
||
rollback은 `ROLLED_BACK`으로 표시한다.
|
||
|
||
commit 중 RuntimeException이 발생하면 `CommitFailureClassifier`가 **commit phase라는 사실**과 cause chain을 함께 보고 completion unknown 여부를 판단한다.
|
||
|
||
### 21.1 `CommitFailureClassifier`
|
||
|
||
completion unknown candidate:
|
||
|
||
- SQLSTATE 40003
|
||
- connection class 08*
|
||
- admin shutdown / crash / cannot-connect-now 계열
|
||
- transport break cause
|
||
|
||
이다.
|
||
|
||
중요한 건 이 classifier를 generic SQLSTATE translator 대신 **commit call 내부에서만** 적용한다는 것이다.
|
||
|
||
connection reset이 query 실행 중 발생했다면 connection unavailable일 수 있지만, provider에게 COMMIT을 보낸 후 reset됐다면 “commit됐는지 모름”이다. SQLSTATE만으로 이 둘을 구분할 수 없고 transaction phase가 필요하다.
|
||
|
||
PostgreSQL classifier source도 이 이유를 직접 설명한다.
|
||
|
||
## 22. historical regression — REQUIRES_NEW evidence stack ownership
|
||
|
||
`TransactionEvidenceContext`는 single ThreadLocal slot이 아니라 stack을 사용한다.
|
||
|
||
이유는 `REQUIRES_NEW` 때문이다.
|
||
|
||
과거에는:
|
||
|
||
- transaction manager가 commit/rollback 후 frame pop
|
||
- executor finally도 자신이 push한 frame을 pop
|
||
|
||
두 owner가 존재했다.
|
||
|
||
outer와 inner가 같은 operation/attempt를 가진 경우:
|
||
|
||
1. inner manager가 inner frame pop
|
||
2. inner executor finally가 top을 보고 outer frame까지 자기 것이라고 착각해 pop
|
||
3. outer commit failure 시 operation/reconciliation key evidence가 사라짐
|
||
|
||
현재는:
|
||
|
||
- manager는 phase만 mark
|
||
- `TransactionEvidenceScope`만 pop owner
|
||
- scope는 depth identity를 갖고 자기 frame이 top일 때만 pop
|
||
|
||
으로 고쳐졌다.
|
||
|
||
`TransactionEvidenceScopeTest`와 `EvidenceAwareJpaTransactionManagerTest`가 nested/thread-local cleanup을 고정한다.
|
||
|
||
이것은 **현재 defect가 아니라 잘 복구된 historical CASE 후보**다. Clean Architecture보다 transaction infrastructure의 “소유권을 하나로 만들지 않으면 lifecycle evidence가 깨진다”는 주제로 가치가 있다.
|
||
|
||
## 23. Confirmed P1 — Stable completion-evidence capability가 shipped composition에 설치되지 않는다
|
||
|
||
여기서는 알고리즘 존재와 runtime wiring을 분리해야 한다.
|
||
|
||
### 23.1 custom manager production construction = 0
|
||
|
||
production source 전체에서:
|
||
|
||
```text
|
||
EvidenceAwareJpaTransactionManager.standard(...)
|
||
new EvidenceAwareJpaTransactionManager(...)
|
||
```
|
||
|
||
호출이 없다.
|
||
|
||
`JpaTransactionAutoConfiguration` 문서는:
|
||
|
||
> manager itself is constructed inside the persistence leaf ... composition root owns the decision whether to install it
|
||
|
||
라고 설명하지만, 현재 persistence root/import/config 어디에도 실제 installation code가 없다.
|
||
|
||
`commitFailureClassifier()` factory도 production consumer가 없다.
|
||
|
||
따라서 `SpringJpaTransactionExecutor`가 `TransactionEvidenceContext.begin()`으로 frame을 만들더라도 일반 `PlatformTransactionManager`는 그 frame을 `ACTIVE/COMMITTING/COMMITTED`로 advance하지 않는다. frame은 기본 `NOT_STARTED` 상태로 남는다.
|
||
|
||
### 23.2 실제 commit-ack-loss classification probe
|
||
|
||
현재 compiled executor에 normal fake `PlatformTransactionManager`를 넣고 commit에서:
|
||
|
||
```text
|
||
TransactionSystemException
|
||
cause -> SQLException SQLSTATE 08006
|
||
```
|
||
|
||
를 발생시켰다.
|
||
|
||
현재 vendor translator까지 포함한 결과:
|
||
|
||
```text
|
||
type=ConnectionUnavailableException
|
||
category=CONNECTION_UNAVAILABLE
|
||
completionUnknown=false
|
||
retryable=false
|
||
```
|
||
|
||
Raw:
|
||
|
||
- `evidence/raw/050a-jpa-commit-ambiguity-probe.java`
|
||
- `evidence/raw/050-jpa-commit-ambiguity-probe.txt`
|
||
|
||
이 결과는 중요한 두 면을 가진다.
|
||
|
||
#### 안전하게 남은 부분
|
||
|
||
`CONNECTION_UNAVAILABLE`은 current PostgreSQL translator에서 retryable=false다. 따라서 이 probe의 lost commit ack가 coordinator에서 자동 duplicate retry되는 것은 확인되지 않았다.
|
||
|
||
#### 깨진 부분
|
||
|
||
하지만 commit call 중 connection을 잃었다는 **phase-sensitive 의미가 사라졌다.** caller는 “DB에 연결할 수 없었다”와 “COMMIT을 보냈고 결과를 모른다”를 구분할 수 없다.
|
||
|
||
이 구분을 위해 만들어진 `CommitFailureClassifier`/`TransactionCompletionUnknownException`이 composition에서 동작하지 않는다.
|
||
|
||
### 23.3 reconciliation record production path = 0
|
||
|
||
`CompletionUnknownRecord`와 `CompletionUnknownRecorder`는 current production에서 자신들의 정의 외 consumer/implementation이 없다.
|
||
|
||
그런데 documentation은 훨씬 강한 계약을 선언한다.
|
||
|
||
support matrix:
|
||
|
||
```text
|
||
Commit completion evidence = Stable
|
||
Automatic reconciliation unsupported.
|
||
The platform records; the domain resolves.
|
||
```
|
||
|
||
runbook:
|
||
|
||
```text
|
||
Signal:
|
||
- jpa.transaction.completion.unknown incremented
|
||
- a CompletionUnknownRecord in the reconciliation channel
|
||
```
|
||
|
||
그리고 operator procedure는 그 record의 `transactionKey`를 사용하라고 한다.
|
||
|
||
현재 이 record를 실제로 쓰는 production channel은 확인되지 않았다.
|
||
|
||
### 23.4 completion-unknown metric도 현재 transaction path에서 호출되지 않는다
|
||
|
||
`JpaTransactionObservation.recordCompletionUnknown(...)` 구현은 존재한다.
|
||
|
||
하지만 production에서:
|
||
|
||
- `JpaObservabilityAutoConfiguration` construction = 0
|
||
- `JpaTransactionObservation.recordCompletionUnknown(...)` call = 0
|
||
- `recordCommitted/recordRolledBack/recordTimedOut` call도 0
|
||
|
||
이다.
|
||
|
||
`JpaPlatformRuntimeAutoConfiguration`이 만드는 default `RetryEventListener`도 empty implementation이며, `JpaObservabilityAutoConfiguration`을 통해 metric listener로 합성하지 않는다.
|
||
|
||
따라서 runbook의 `jpa.transaction.completion.unknown` signal은 현재 source wiring으로는 생성 근거를 찾지 못했다.
|
||
|
||
이 observability factory 전체의 reachability 문제는 later observation/baseline capability sub-scope에서 다시 exhaustive하게 확인한다. 여기서는 completion-unknown path의 cross-scope evidence로만 기록한다.
|
||
|
||
### 23.5 canonical application boundary의 mitigation
|
||
|
||
이 defect가 곧 “현재 모든 use case가 unknown commit을 duplicate retry한다”는 뜻은 아니다.
|
||
|
||
canonical `SpringPolicyTransactionPort`는 독립적인 Spring synchronization sentinel을 사용해 commit exception을 `TransactionResult.Indeterminate`로 반환하며 replay하지 않는다.
|
||
|
||
즉 current application path의 **automatic retry safety는 별도 mechanism으로 유지**된다.
|
||
|
||
하지만:
|
||
|
||
- JPA platform public executor/coordinator가 advertised completion evidence를 제공하지 못함
|
||
- durable record 없음
|
||
- runbook metric 없음
|
||
- application `Indeterminate`도 reconciliationReference는 empty
|
||
|
||
이므로 advertised operator reconciliation contract는 충족되지 않는다.
|
||
|
||
우선순위: **P1 — reliability / data-integrity operations contract**
|
||
|
||
범위 한정:
|
||
|
||
- current canonical PolicyTransactionPort는 no-auto-retry safety를 유지한다.
|
||
- P1은 “commit ambiguity를 stable semantic + durable reconciliation evidence로 표면화한다”는 JPA platform 약속이 실제 composition에서 빠진 점이다.
|
||
- real PostgreSQL lost-ack end-to-end behavior는 later PostgreSQL integration lane에서 추가 qualification해야 한다.
|
||
|
||
Tech-Log: CASE + DECISION 강한 후보.
|
||
|
||
## 24. dual transaction stack의 architecture drift
|
||
|
||
commit `2f5d2fc`에서 old `@RetryableJpaTransaction` interceptor가 삭제됐다.
|
||
|
||
그 diff는 이유를 명시한다.
|
||
|
||
- application service에 outbound adapter annotation을 붙이면 dependency direction 역전
|
||
- canonical boundary는 `PolicyTransactionPort.inTransaction(...)`
|
||
|
||
여기까지는 Clean Architecture와 일치한다.
|
||
|
||
문제는 이어지는 문장이:
|
||
|
||
> the retry coordinator below is what implements it, not a second way to ask for the same thing
|
||
|
||
이라고 말한다는 점이다.
|
||
|
||
실제 type graph는 그렇지 않다.
|
||
|
||
```text
|
||
PolicyTransactionPort
|
||
<- SpringTransactionPort
|
||
|
||
FullTransactionRetryCoordinator
|
||
X implements PolicyTransactionPort 아님
|
||
```
|
||
|
||
또 runtime auto-configuration은 coordinator를 별도 bean으로 계속 만든다.
|
||
|
||
따라서 현재 code/doc 관계는:
|
||
|
||
- annotation-based retry path는 제거됨
|
||
- application-core canonical port implementation은 별도로 생김
|
||
- old/public JPA executor+coordinator model도 남음
|
||
- 문서는 coordinator가 canonical port를 구현한다고 잘못 설명
|
||
|
||
이다.
|
||
|
||
이것은 단순 문장 오타보다 architecture transition이 완전히 정리되지 않은 흔적이다.
|
||
|
||
우선순위: **P2 architecture consistency**
|
||
|
||
결정이 필요하다.
|
||
|
||
1. JPA executor/coordinator를 진짜 external platform API로 유지한다면 canonical application port와 역할 차이를 명시하고 runtime bean/export 정책을 분리
|
||
2. application-core port로 완전히 수렴한다면 coordinator/profile registry 등 old path를 deprecate/remove하고 completion evidence를 canonical path로 이식
|
||
|
||
현재처럼 “한 경로라고 문서화했지만 실제 두 경로가 존재”하는 상태는 유지보수자가 어느 retry/evidence system을 고쳐야 하는지 혼란을 만든다.
|
||
|
||
## 25. P3 — `TransactionProfileRegistry`는 declarative retry 제거 후 legacy residue 후보
|
||
|
||
`TransactionProfileRegistry`는 current production reference가 0이다.
|
||
|
||
history를 보면 initial design에서는:
|
||
|
||
```text
|
||
@RetryableJpaTransaction
|
||
-> RetryableJpaTransactionInterceptor
|
||
-> TransactionProfileRegistry
|
||
-> FullTransactionRetryCoordinator
|
||
```
|
||
|
||
형태였다.
|
||
|
||
commit `2f5d2fc`에서 annotation/interceptor와 그 test를 삭제했지만 registry는 남았다.
|
||
|
||
현재:
|
||
|
||
- production consumer 0
|
||
- dedicated unit test만 존재
|
||
- `api/**` intended external surface가 아니라 implementation `transaction` package
|
||
- root bean wiring도 없음
|
||
|
||
이 evidence 범위에서는 **confirmed unused production implementation candidate**로 볼 수 있다.
|
||
|
||
단 repository 밖 reflection/external direct construction은 source search로 알 수 없으므로 즉시 삭제 가능성까지 확정하지 않는다. module의 non-api package는 intended external이 아니라는 architecture policy와 함께 보면 cleanup 우선순위는 높아진다.
|
||
|
||
우선순위: **P3 cleanup**
|
||
|
||
## 26. zero-reference지만 dead가 아닌 `JpaTransactionConfig`
|
||
|
||
반대로 `JpaTransactionConfig`도 direct production reference는 거의 없다.
|
||
|
||
하지만 이 class는:
|
||
|
||
```java
|
||
@Configuration
|
||
@EnableConfigurationProperties(JpaTransactionSettings.class)
|
||
```
|
||
|
||
이고 `JpaAdapterComponentsConfig`가 transaction package를 component scan한다.
|
||
|
||
따라서 direct Java call/import가 0이어도 runtime reachability가 있다.
|
||
|
||
이 class source 자체도 historical reason을 기록한다.
|
||
|
||
- root `@ConfigurationPropertiesScan`에서 optional persistence tree 제외
|
||
- JPA on 상태에서도 settings가 아무도 bind하지 않던 문제 발생
|
||
- transaction port construction 실패
|
||
- package-local configuration으로 JPA master switch 안에서만 settings enable
|
||
|
||
이 사례는 mandatory public-reachability probe가 필요한 이유를 잘 보여준다. static reference count만으로 dead code를 찾으면 Spring discovery path를 오탐한다.
|
||
|
||
## 27. 두 failure translator 계열은 현재 역할이 다르다
|
||
|
||
이 scope에는 이름이 비슷한 두 translation mechanism이 있다.
|
||
|
||
### `PersistenceFailureTranslatorChain`
|
||
|
||
input:
|
||
|
||
```text
|
||
raw persistence/provider failure
|
||
```
|
||
|
||
output:
|
||
|
||
```text
|
||
JpaPersistenceException hierarchy
|
||
```
|
||
|
||
consumer:
|
||
|
||
```text
|
||
SpringJpaTransactionExecutor / retry semantics
|
||
```
|
||
|
||
목적은 SQLSTATE/optimistic conflict를 retry/completion semantics에 필요한 stable persistence failure로 바꾸는 것이다.
|
||
|
||
### `failure.PersistenceExceptionTranslator`
|
||
|
||
input:
|
||
|
||
```text
|
||
RuntimeException / SQLSTATE mapping
|
||
```
|
||
|
||
output:
|
||
|
||
```text
|
||
shared OperationalError / PersistenceFailureException
|
||
```
|
||
|
||
consumer는 adapter/application error boundary 쪽이다.
|
||
|
||
따라서 동일 이름 영역을 다루지만 current evidence로는 competing duplicate implementation이 아니다. **transaction retry algebra와 platform operational error mapping이라는 서로 다른 output contract**를 가진다.
|
||
|
||
PostgreSQL vendor translator와 exact SQLSTATE catalog correctness는 vendor sub-scope에서 계속 검증한다.
|
||
|
||
## 28. conditional-wiring probe
|
||
|
||
transaction 관련 configuration은 세 종류로 나뉜다.
|
||
|
||
### 28.1 component-scan-owned
|
||
|
||
- `SpringTransactionPort`
|
||
- `PersistenceExceptionTranslator`
|
||
- `JpaTransactionConfig`
|
||
|
||
`PersistenceJpaRootAutoConfiguration`이 JPA master switch ON일 때 `JpaAdapterComponentsConfig`를 import하고, 그 narrow scan이 이들을 찾는다.
|
||
|
||
### 28.2 runtime bean-factory-owned
|
||
|
||
`JpaPlatformRuntimeAutoConfiguration`:
|
||
|
||
- `SpringJpaTransactionExecutor` — `PlatformTransactionManager`가 있을 때
|
||
- `FullTransactionRetryCoordinator` — executor가 있을 때
|
||
- default empty `RetryEventListener`
|
||
|
||
### 28.3 현재 설치되지 않는 specialized implementation
|
||
|
||
- `EvidenceAwareJpaTransactionManager`
|
||
- `CompletionUnknownRecorder`
|
||
- `JpaTransactionObservation` call path
|
||
|
||
이 셋은 문서상 completion-evidence/operability의 핵심이지만 current root assembly에서 provider가 없다.
|
||
|
||
이 sibling comparison으로 “class가 있으니 feature가 있다”는 판단을 피했다.
|
||
|
||
## 29. documentation drift
|
||
|
||
transaction docs에는 현재 서로 다른 세 시대의 설계가 겹쳐 있다.
|
||
|
||
### current source truth
|
||
|
||
application-core canonical:
|
||
|
||
```text
|
||
PolicyTransactionPort -> SpringTransactionPort
|
||
```
|
||
|
||
public JPA runtime:
|
||
|
||
```text
|
||
SpringJpaTransactionExecutor + FullTransactionRetryCoordinator bean도 별도 존재
|
||
```
|
||
|
||
### `JpaTransactionAutoConfiguration` javadoc
|
||
|
||
canonical port와 coordinator가 같은 구현인 것처럼 설명 — current type graph와 불일치.
|
||
|
||
### `docs/jpa/transaction-guide.md`
|
||
|
||
application service가 `TransactionPort or JpaTransactionExecutor`로 boundary를 연다고 설명 — 두 public usage model을 함께 유지하는 설명.
|
||
|
||
### `support-matrix.md` / runbook
|
||
|
||
completion evidence Stable, platform records unknown, metric + reconciliation record가 있다고 설명 — current wiring과 불일치.
|
||
|
||
따라서 transaction documentation은 단순 오래된 class 이름 수준이 아니라 **어떤 transaction model이 canonical인지와 Stable capability가 무엇을 실제 제공하는지**를 재정렬해야 한다.
|
||
|
||
## 30. fresh verification과 실제 증명 범위
|
||
|
||
### 30.1 transaction/failure focused tests
|
||
|
||
fresh:
|
||
|
||
```text
|
||
:adapter:outbound:persistence-jpa:test
|
||
--tests transaction.*
|
||
--tests failure.*
|
||
--rerun-tasks
|
||
```
|
||
|
||
결과: BUILD SUCCESSFUL.
|
||
|
||
이 19개 dedicated test가 강하게 증명하는 것:
|
||
|
||
- retry backoff math
|
||
- commit failure classifier 자체의 commit-unknown 분류
|
||
- completion record value construction
|
||
- EvidenceAware manager 자체의 phase marking
|
||
- nested evidence scope cleanup
|
||
- FullTransactionRetryCoordinator 자체의 retry/no-retry decisions
|
||
- CallBudget/timeout calculations
|
||
- application policy port의 determinate/indeterminate/commit result
|
||
- root-only tx rejection
|
||
- SQLSTATE operational error mapping
|
||
|
||
그러나 **custom manager의 production installation**은 이 test들이 증명하지 않는다.
|
||
|
||
### 30.2 root wiring tests
|
||
|
||
fresh app-bootstrap:
|
||
|
||
- `JpaPlatformAddonAssemblyTest`
|
||
- `JpaPlatformRuntimeAutoConfigurationTest`
|
||
- `CapabilityEntityScanRegistrationTest`
|
||
|
||
결과: BUILD SUCCESSFUL.
|
||
|
||
이들은:
|
||
|
||
- old class-level `@ConditionalOnBean(DataSource)` ordering regression 방지
|
||
- DataSource/PTM이 있을 때 executor/coordinator bean assembly
|
||
- persistence root import/entity scan shape
|
||
|
||
를 증명한다.
|
||
|
||
하지만 `PlatformTransactionManager`가 `EvidenceAwareJpaTransactionManager`인지, completion record/metric이 실제 transaction path에서 발생하는지는 assert하지 않는다.
|
||
|
||
### 30.3 real lost-ack qualification은 아직 아님
|
||
|
||
`050` probe는 fake manager commit failure로 classification path를 isolate한 것이다.
|
||
|
||
실제 PostgreSQL server가 commit을 적용한 직후 client ack/network를 끊는 시나리오까지 재현한 것은 아니다. 그 수준의 evidence는 `postgresqlIntegrationTest` sub-scope에서 별도 qualification해야 한다.
|
||
|
||
## 31. transaction/failure findings backlog
|
||
|
||
### P1 — completion-evidence Stable contract가 actual composition에 연결되지 않음
|
||
|
||
- `EvidenceAwareJpaTransactionManager` implementation/test는 존재하지만 production construction 0.
|
||
- normal manager commit 08006 probe -> `CONNECTION_UNAVAILABLE`, `completionUnknown=false`.
|
||
- `CompletionUnknownRecorder` implementation/consumer 0.
|
||
- `JpaTransactionObservation` record call 0; observability composition helper construction도 0.
|
||
- support matrix/runbook은 Stable evidence + metric + reconciliation record를 약속.
|
||
- canonical application path는 `Indeterminate`로 no-auto-retry safety는 유지하지만 durable reconciliation reference를 채우지 않음.
|
||
- **우선순위:** P1 reliability/operability, real DB lost-ack integration qualification 필요.
|
||
|
||
### P2 — custom `JpaRetryPolicy`가 silently ignored
|
||
|
||
- public composition overload가 application-supplied policy를 받음.
|
||
- valid TransactionProfile은 retryProfile non-null 강제.
|
||
- coordinator는 profile이 non-null이면 fallback/custom policy를 사용하지 않음.
|
||
- runtime probe: customPolicyCalls=0, workCalls=2.
|
||
- **우선순위:** P2 functional contract.
|
||
|
||
### P2 — canonical transaction boundary documentation과 실제 dual stack 불일치
|
||
|
||
- docs/source comment는 coordinator가 PolicyTransactionPort를 구현한다고 설명.
|
||
- 실제 구현체는 SpringTransactionPort.
|
||
- coordinator/runtime bean은 별도로 계속 존재.
|
||
- **우선순위:** P2 architecture consistency / Decision 필요.
|
||
|
||
### P3 — TransactionProfileRegistry legacy residue
|
||
|
||
- declarative retry interceptor 제거 후 production consumer 0.
|
||
- non-api implementation package.
|
||
- **우선순위:** P3 cleanup candidate.
|
||
|
||
### Cross-scope candidate — JPA observability composition 전체 reachability
|
||
|
||
- `JpaObservabilityAutoConfiguration` current production construction 0.
|
||
- query/transaction/retry observation 중 일부가 별도 경로에서 살아 있을 수도 있으므로 observation owning sub-scope에서 다시 exhaustive 확인.
|
||
- transaction scope에서는 completion-unknown runbook signal 부재 evidence로만 사용.
|
||
|
||
## 32. Sub-scope 03 완료 조건
|
||
|
||
확인한 것:
|
||
|
||
- production 32 / 32 FULL_READ
|
||
- dedicated tests 19 / 19 FULL_READ
|
||
- unclassified 0
|
||
- application canonical transaction implementation trace
|
||
- public JPA executor/coordinator trace
|
||
- retry/deadline/CallBudget algebra
|
||
- completion evidence state machine
|
||
- root/component-scan/conditional wiring
|
||
- current public reachability
|
||
- duplicate retry/failure mechanism comparison
|
||
- documentation drift
|
||
- declarative-retry migration history
|
||
- custom-policy runtime probe
|
||
- commit-ambiguity runtime classification probe
|
||
- focused tests fresh
|
||
- app-bootstrap wiring tests fresh
|
||
|
||
남긴 경계:
|
||
|
||
- actual PostgreSQL lost-commit-ack network qualification
|
||
- PostgreSQL SQLSTATE/constraint translator 전체 correctness
|
||
- observation package 전체 reachability/metrics completeness
|
||
- pool runtime behavior
|
||
|
||
이 후속 항목은 각각 vendor/integration/observation-performance sub-scope에서 다시 확인한다.
|
||
|
||
---
|
||
|
||
## 33. Sub-scope 04 — Spring Data + Hibernate + Querydsl
|
||
|
||
> 내부 상태: COMPLETE — 42 production + 11 dedicated test, **53 / 53 FULL_READ**
|
||
> 범위: `persistence/hibernate/**`, `persistence/springdata/**`, `persistence/querydsl/**`와 matching dedicated tests
|
||
> 핵심 질문: JPA query/batch 최적화 helper가 실제로 어떤 비용 경계를 강제하는지, Stable/Advanced 기능이 runtime과 release evidence에서 어디까지 살아 있는지.
|
||
|
||
### 33.1 숫자 지도
|
||
|
||
| package | production | dedicated test | 역할 |
|
||
|---|---:|---:|---|
|
||
| `hibernate` | 22 | 2 | provider policy, statistics, SQL naming, JDBC batch, bulk DML, StatelessSession |
|
||
| `springdata` | 17 | 8 | repository fragment, entity graph, sort, keyset, stream, Specification policy |
|
||
| `querydsl` | 3 | 1 | optional dynamic-query integration |
|
||
| **합계** | **42** | **11** | **53** |
|
||
|
||
53개 source/test를 모두 FULL_READ했다. 이 scope의 integration semantics를 확인하기 위해 PostgreSQL-backed batch/ID strategy/collection-fetch contract, release-registry/task mapping, app-bootstrap architecture rule도 cross-scope evidence로 읽고 fresh 실행했다.
|
||
|
||
## 34. 이 sub-scope는 하나의 query framework가 아니라 세 단계의 정책층이다
|
||
|
||
현재 code shape는 대략 다음처럼 읽는 것이 맞다.
|
||
|
||
```text
|
||
application-owned query/repository contract
|
||
|
|
||
v
|
||
springdata/**
|
||
- allowlisted sort
|
||
- keyset assembly/predicate
|
||
- fetch-plan catalog
|
||
- bounded stream lifetime
|
||
- Specification safety
|
||
|
|
||
v
|
||
hibernate/**
|
||
- provider/version facts
|
||
- real Statistics/JDBC batch evidence
|
||
- statement naming
|
||
- batch/bulk/stateless provider optimization
|
||
|
|
||
+----------------------+
|
||
|
|
||
v v
|
||
JPA/Hibernate runtime querydsl/**
|
||
optional Advanced helper
|
||
```
|
||
|
||
중요한 점은 `springdata`와 `querydsl`이 application-core의 repository contract를 대체하는 generic CRUD layer가 아니라는 것이다. `JpaRepositoryFragmentSupport`에는 범용 `save/findAll/delete`가 없고, domain-owned adapter가 필요한 query mechanism만 조합하게 설계돼 있다.
|
||
|
||
이 방향은 support matrix의 “platform-owned generic CRUD repository는 unsupported”와 일치한다.
|
||
|
||
## 35. Hibernate provider policy는 declared baseline과 실제 runtime을 분리한다
|
||
|
||
`HibernateProviderPolicy`는 상수로 선언된 Stable provider baseline과 실제 classpath에서 읽은 runtime version을 구분한다.
|
||
|
||
이 설계가 필요한 이유는 repository가 과거 “7.4를 Stable baseline이라고 문서화하면서 실제 Spring Boot BOM은 7.1.x를 resolve”한 상태를 경험했기 때문이다.
|
||
|
||
현재는:
|
||
|
||
- declared baseline: policy constant
|
||
- runtime provider: `org.hibernate.Version`에서 읽음
|
||
- drift 여부: `driftsFromDeclaredBaseline()`
|
||
- app-bootstrap capability/report가 runtime value를 사용
|
||
|
||
으로 나뉜다.
|
||
|
||
즉 “문서 상수와 같은 상수를 assert해서 green”인 self-fulfilling test는 피한다.
|
||
|
||
이 sub-scope에서 outside-leaf production consumer가 명확히 존재하는 핵심 Hibernate type도 `HibernateProviderPolicy`다. app-bootstrap이 이를 composition/report에 사용한다.
|
||
|
||
## 36. 통계 수집은 configuration이 아니라 실제 실행 evidence를 보려 한다
|
||
|
||
`HibernateStatisticsCollector`와 `HibernateStatisticsSnapshot`은 다음을 분리해서 측정한다.
|
||
|
||
- prepared statements
|
||
- entity loads/fetches
|
||
- collection fetches
|
||
- flushes
|
||
- JDBC batches
|
||
|
||
특히 JDBC batch count를 Hibernate `Statistics` 값으로 추정하지 않고 `JdbcBatchCounter`를 별도 주입한다.
|
||
|
||
이것은 중요한 설계 선택이다.
|
||
|
||
```text
|
||
hibernate.jdbc.batch_size = 50
|
||
```
|
||
|
||
은 batching을 “요청한 설정”이지 실제 driver/JDBC batch가 실행됐다는 증거가 아니다.
|
||
|
||
실제 PostgreSQL integration test도 `delta.jdbcBatches()`를 보고 sequence entity와 IDENTITY entity의 차이를 측정한다.
|
||
|
||
또 statistics가 disabled면 0을 반환하지 않고 실패한다. 0을 “실제 쿼리가 없었다”와 “측정 자체가 꺼져 있었다” 사이에서 공유하지 않는다.
|
||
|
||
## 37. batch executor — 과거 data-loss 회귀는 현재 수정돼 있다
|
||
|
||
`HibernateJpaBatchExecutor`의 기본 전략은:
|
||
|
||
```text
|
||
persist
|
||
-> 주기적 flush
|
||
-> 주기적 clear
|
||
-> final flush
|
||
-> final clear
|
||
```
|
||
|
||
이다.
|
||
|
||
Persistence Context를 clear해야 heap growth를 제한할 수 있지만, flush하지 않은 managed entity를 clear하면 INSERT 자체가 사라질 수 있다.
|
||
|
||
과거 구현은 `flushSize=100`, `clearSize=150`처럼 경계가 어긋날 때 150번째 clear에서 101~150 rows를 detach해 버릴 수 있었다. executor는 processed=300을 반환하는데 DB에는 250 rows만 남는 형태였다.
|
||
|
||
현재 코드는:
|
||
|
||
```java
|
||
if (flushDue || clearDue) {
|
||
entityManager.flush();
|
||
}
|
||
if (clearDue) {
|
||
entityManager.clear();
|
||
}
|
||
```
|
||
|
||
로 바뀌었다.
|
||
|
||
즉 clear는 항상 flush barrier를 동반한다.
|
||
|
||
PostgreSQL-backed `HibernateJpaBatchExecutorIntegrationTest`에도 다음 regression이 존재한다.
|
||
|
||
- mismatched flush/clear boundary에서도 모든 rows 보존
|
||
- clear가 flush보다 멀리 있어도 row loss 없음
|
||
- flush size보다 적은 rows도 final flush로 보존
|
||
- 중간 failure 시 전체 transaction rollback
|
||
- active transaction 밖 batch 거부
|
||
- real JDBC batch count > 1
|
||
- Persistence Context max entity count bounded
|
||
|
||
이번 fresh 선택 실행에서도 이 integration class 7 tests가 skip/failure 없이 통과했다.
|
||
|
||
## 38. Confirmed P2 — property-access `IDENTITY` entity가 batch guard를 우회한다
|
||
|
||
`HibernateBatchConfigurationGuard`는 batching-required profile에서 `GenerationType.IDENTITY`를 거부한다.
|
||
|
||
그 이유 자체는 실제 PostgreSQL evidence가 있다.
|
||
|
||
- sequence fixture: JDBC batches > 1
|
||
- IDENTITY fixture: JDBC batches = 0
|
||
|
||
문제는 guard의 annotation 탐색 방식이다.
|
||
|
||
현재 `usesIdentityGeneration(Class<?>)`은 class hierarchy의 **declared fields**만 읽는다.
|
||
|
||
```text
|
||
field @Id
|
||
-> field @GeneratedValue
|
||
-> strategy == IDENTITY ?
|
||
```
|
||
|
||
하지만 JPA는 field access뿐 아니라 property access도 허용한다. 즉 다음과 같은 mapping도 정상적인 JPA mapping이다.
|
||
|
||
```java
|
||
@Id
|
||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||
public Long getId() { ... }
|
||
```
|
||
|
||
### 실행 probe
|
||
|
||
getter에 `@Id + @GeneratedValue(IDENTITY)`를 선언한 entity class를 현재 compiled guard에 전달했다.
|
||
|
||
결과:
|
||
|
||
```text
|
||
propertyIdentityDetected=false
|
||
propertyIdentityValidation=PASSED
|
||
```
|
||
|
||
즉 `batchingRequired=true` profile이어도 legal property-access IDENTITY entity를 통과시킨다.
|
||
|
||
현재 repository production entity search에서는 property-access ID mapping이 확인되지 않았으므로 **현재 shipped entity가 이 결함을 밟는 evidence는 없다.**
|
||
|
||
그러나 이 class는 generic JPA platform guard이고, IDENTITY를 fail-closed로 거부한다고 문서화한다. 따라서 adopter가 property access를 사용하면 guard의 핵심 안전 계약이 깨진다.
|
||
|
||
우선순위: **P2 — provider guard correctness / adoption safety**
|
||
|
||
수정 방향:
|
||
|
||
- JPA metamodel로 actual id attribute/access strategy를 해석하거나
|
||
- field/getter 모두 검사하되 duplicate/override access 규칙까지 JPA access semantics와 맞추거나
|
||
- 지원 mapping을 field access로 제한한다면 그 제한을 architecture rule로 강제
|
||
|
||
단순히 getter reflection을 추가하는 것만으로 mixed access/`@Access`까지 완전히 해결됐다고 보기는 어렵다.
|
||
|
||
Raw: `evidence/raw/053-jpa-query-hibernate-boundary-probe.txt`
|
||
|
||
## 39. `BatchExecutionResult.batched()`는 작은 실행에 false-negative가 있다
|
||
|
||
`BatchExecutionResult` javadoc은 `jdbcBatches`를 “batching happened at all”을 판단하는 값으로 설명한다.
|
||
|
||
그런데 convenience method는:
|
||
|
||
```java
|
||
return jdbcBatches > 1L;
|
||
```
|
||
|
||
이다.
|
||
|
||
probe에서:
|
||
|
||
```text
|
||
jdbcBatches=1
|
||
batched()=false
|
||
```
|
||
|
||
가 확인됐다.
|
||
|
||
다만 이것을 즉시 높은 우선순위 defect로 올리지는 않는다.
|
||
|
||
한 번의 `executeBatch`가 여러 statement를 묶었다면 “batching은 발생했다”고 말할 수 있지만, counter가 단지 executeBatch call count만 제공한다면 `1`만으로 그 batch에 몇 row가 묶였는지는 알 수 없다. 현재 real integration contract도 1,000-row run에서 `jdbcBatches > 1`을 강한 evidence로 사용한다.
|
||
|
||
따라서 현재 판단은:
|
||
|
||
- **P3 semantic/naming edge**
|
||
- `batched()`가 “at least one JDBC batch call”인지 “multiple measured batch executions”인지 API 의미를 명확히 할 필요
|
||
|
||
이다.
|
||
|
||
## 40. bulk DML과 StatelessSession은 일반 repository path와 다른 비용 모델을 명시한다
|
||
|
||
### 40.1 Hibernate bulk DML
|
||
|
||
`HibernateBulkDmlExecutor`는 arbitrary JPQL string을 아무 데서나 실행하는 helper가 아니다.
|
||
|
||
- operation name 등록
|
||
- affected-row expectation
|
||
- persistence-context cleanup
|
||
- transaction requirement
|
||
|
||
를 contract로 둔다.
|
||
|
||
bulk DML은 managed entity lifecycle을 우회하므로 ordinary entity save와 같은 audit/lifecycle guarantee를 기대하면 안 된다. support matrix도 이를 Advanced capability로 분리한다.
|
||
|
||
현재 production business consumer는 확인되지 않았고 PostgreSQL integration fixture에서 실제 behavior를 qualification한다. 따라서 “runtime에서 사용 중”이라고 주장하지 않는다.
|
||
|
||
### 40.2 StatelessSession
|
||
|
||
`HibernateStatelessSessionRunner`는 오히려 이 platform에서 transaction ownership 예외를 명시적으로 드러낸다.
|
||
|
||
일반 repository adapter:
|
||
|
||
```text
|
||
application transaction boundary에 참여
|
||
```
|
||
|
||
StatelessSession runner:
|
||
|
||
```text
|
||
새 StatelessSession
|
||
-> 자체 physical transaction
|
||
-> registered work만 허용
|
||
-> affected row cap 확인
|
||
-> 초과 시 rollback
|
||
```
|
||
|
||
과거 review에서는 caller가 선언한 maxRows가 실제 affected rows와 연결되지 않는 문제가 있었다. 현재는 `StatelessWorkResult(value, affectedRows)`를 요구하고 cap 초과 시 commit 전에 rollback한다.
|
||
|
||
즉 과거의 “이름만 row cap” 문제는 현재 코드에서 수정돼 있다.
|
||
|
||
## 41. Spring Data repository support는 generic CRUD보다 query execution policy에 가깝다
|
||
|
||
`JpaRepositoryFragmentSupport`는 domain-specific repository adapter가 사용할 공통 실행 support다.
|
||
|
||
제공하는 것은 대략:
|
||
|
||
- `EntityManager` access
|
||
- query name context
|
||
- fetch plan application
|
||
- bounded query observation scope
|
||
|
||
이고 범용 business repository contract는 제공하지 않는다.
|
||
|
||
이 구조는 Clean Architecture 관점에서 의미가 있다.
|
||
|
||
application-core가 `JpaRepository`, `EntityManager`, `Specification`을 알 필요가 없고, 실제 domain repository port를 구현하는 outbound adapter 내부에서만 Spring Data/JPA mechanics를 사용한다.
|
||
|
||
## 42. entity graph catalog는 EntityManager-affinity를 피한다
|
||
|
||
`EntityGraphCatalog`는 이미 만들어진 `EntityGraph` instance를 전역 보관하지 않는다.
|
||
|
||
대신 factory를 등록하고 현재 `EntityManager`에서 graph를 생성한다.
|
||
|
||
그 이유는 JPA graph/object가 provider/session/entity-manager lifetime에 묶일 수 있기 때문이다.
|
||
|
||
`FetchPlanApplier`는 registered fetch plan을 조회해서:
|
||
|
||
- fetch graph
|
||
- load graph
|
||
|
||
hint를 구분해 query에 적용한다.
|
||
|
||
이 역시 raw client path를 받지 않고 registered name을 통해 query behavior를 선택한다.
|
||
|
||
## 43. sort는 allowlist + total order를 강제한다
|
||
|
||
`SafeSortMapper`의 핵심 invariant는 두 개다.
|
||
|
||
1. client field를 entity path로 그대로 넘기지 않는다.
|
||
2. ordering 끝에 unique tie-breaker를 붙인다.
|
||
|
||
### 43.1 allowlist
|
||
|
||
`SafeSortRegistry`가 public sort name -> `SafeSortField` mapping을 가진다.
|
||
|
||
unknown name은 fail-closed다.
|
||
|
||
따라서 `JpaSort.unsafe(clientString)` 같은 raw ORDER BY path가 없다.
|
||
|
||
### 43.2 tie-breaker direction historical fix
|
||
|
||
과거 mapper는 request가 tie-breaker를 생략하면 무조건 DESC를 붙였다.
|
||
|
||
예:
|
||
|
||
```text
|
||
createdAt ASC
|
||
```
|
||
|
||
요청이 실제로는:
|
||
|
||
```text
|
||
createdAt ASC, id DESC
|
||
```
|
||
|
||
가 되었다.
|
||
|
||
이는 caller가 고르지 않은 mixed ordering이고 single-direction keyset logic과 충돌했다.
|
||
|
||
현재는 registry 자체가 `tieBreakerDirection`을 선언하고 mapper가 그 값을 사용한다.
|
||
|
||
이 regression은 현재 수정된 상태다.
|
||
|
||
## 44. keyset predicate는 mixed type / mixed direction을 표현하도록 진화했다
|
||
|
||
`KeysetPredicateBuilder`는 conjunction이 아니라 lexicographic predicate를 만든다.
|
||
|
||
예를 들어 `(createdAt ASC, id DESC)`라면 cursor 뒤는 개념적으로:
|
||
|
||
```text
|
||
createdAt > cursorTime
|
||
OR
|
||
(createdAt = cursorTime AND id < cursorId)
|
||
```
|
||
|
||
이다.
|
||
|
||
현재 `KeysetTerm<T>`는 각 term마다:
|
||
|
||
- expression
|
||
- cursor value
|
||
- direction
|
||
|
||
을 가진다.
|
||
|
||
그래서 `(Instant, UUID)`처럼 term type이 다르고 direction도 다른 ordering을 표현할 수 있다.
|
||
|
||
source history에는 과거 one-type/one-direction API가 mixed order에서 rows를 skip/repeat했던 이유가 주석으로 남아 있고, 현재 code/test는 이를 보완했다.
|
||
|
||
### 44.1 남는 contract boundary
|
||
|
||
builder는 “마지막 term이 unique tie-breaker여야 한다”고 문서화하지만 runtime에서 uniqueness를 증명할 metadata는 받지 않는다.
|
||
|
||
검사할 수 있는 것은:
|
||
|
||
```text
|
||
terms.size() >= 2
|
||
```
|
||
|
||
정도다.
|
||
|
||
따라서 uniqueness는 caller/registry contract다. 현재 evidence만으로 이를 defect라 단정하지 않는다. platform이 이를 fail-closed invariant로 승격하려면 unique-key metadata까지 contract에 포함해야 한다.
|
||
|
||
## 45. keyset execution은 `size + 1`로 hasNext를 판정하고 count query를 제거한다
|
||
|
||
`JpaKeysetQuerySupport`는:
|
||
|
||
```text
|
||
query.setMaxResults(page.fetchSize()) // size + 1
|
||
-> result
|
||
-> KeysetSliceAssembler
|
||
```
|
||
|
||
형태다.
|
||
|
||
반환은 최대 `size`개이고 추가 1개로 `hasNext`를 판단한다.
|
||
|
||
이 path에는 `COUNT(*)`가 없다.
|
||
|
||
즉 keyset을 도입해 OFFSET full-walk 비용을 줄여 놓고 total count로 다시 full-work를 추가하는 구조를 피한다.
|
||
|
||
실제 PostgreSQL readiness query도 `(occurred_at,id) > (?,?) ORDER BY ... LIMIT ?` 형태와 representative index 사용을 별도 integration lane에서 검증한다. 해당 entire integration lane 자체는 later sub-scope 11의 denominator이므로 여기서는 cross-scope evidence로만 사용한다.
|
||
|
||
## 46. stream helper는 resource lifetime을 return type shape로 제한한다
|
||
|
||
`JpaStreamExecutor`의 핵심은 `Stream<T>`를 외부로 반환하지 않는 것이다.
|
||
|
||
```text
|
||
active read-only transaction 확인
|
||
-> supplier가 stream open
|
||
-> consumer에 bounded stream 전달
|
||
-> consumer result 생성
|
||
-> stream close
|
||
-> query scope close
|
||
-> result만 반환
|
||
```
|
||
|
||
`ScrollPolicy.maxRows()`를 `stream.limit()`에 적용하고 실제 소비 row count를 observation에 기록한다.
|
||
|
||
또 supplier type이 현재는:
|
||
|
||
```java
|
||
Function<ScrollPolicy, Stream<T>>
|
||
```
|
||
|
||
이라 fetch-size policy가 query 생성 지점까지 전달될 수 있다.
|
||
|
||
과거에는 plain `Supplier<Stream<T>>`여서 executor가 가진 fetch-size가 stream-opening query에 도달하지 않는 문제가 있었고, 현재는 수정됐다.
|
||
|
||
reactive Publisher를 결과로 반환하는 것도 hierarchy name 기반으로 거부한다. Reactor/Reactive Streams dependency를 blocking JPA module compile classpath에 직접 추가하지 않고도 application-declared Publisher implementation까지 탐지하려는 방식이다.
|
||
|
||
## 47. Confirmed P2 — `SpecificationPolicy`는 `Specification.unrestricted()`를 bounded로 오인한다
|
||
|
||
`SpecificationPolicy`의 문서 계약은 명확하다.
|
||
|
||
> a specification with no predicate is a full table scan wearing a builder's clothing
|
||
|
||
그리고 predicate가 없으면 explicit `allow-unbounded-scan` token이 필요하다고 설명한다.
|
||
|
||
하지만 구현은 다음만 확인한다.
|
||
|
||
```java
|
||
if (specification == null && !allowToken) {
|
||
reject
|
||
}
|
||
```
|
||
|
||
즉 **Specification object의 존재와 predicate의 존재를 동일시**한다.
|
||
|
||
### 47.1 Spring Data 4.0.7 자체가 non-null unrestricted Specification을 제공한다
|
||
|
||
현재 resolve된 `spring-data-jpa:4.0.7` bytecode를 확인했다.
|
||
|
||
```text
|
||
Specification.unrestricted()
|
||
-> non-null Specification lambda
|
||
-> lambda toPredicate(...) returns null
|
||
```
|
||
|
||
따라서 이건 인위적인 edge case가 아니다. 현재 dependency가 공식적으로 제공하는 representation이다.
|
||
|
||
### 47.2 실행 probe
|
||
|
||
```java
|
||
Specification<Object> noPredicate = (root, query, cb) -> null;
|
||
SpecificationPolicy.requireBounded(noPredicate, page, null);
|
||
```
|
||
|
||
결과:
|
||
|
||
```text
|
||
nonNullNullPredicateSpecification=PASSED
|
||
```
|
||
|
||
이다.
|
||
|
||
즉 policy가 막겠다고 문서화한 **predicate-free bounded-page full scan**이 explicit opt-in 없이 통과한다.
|
||
|
||
현재 repository production consumer search에서는 `SpecificationPolicy` 사용자가 0이므로 shipped business path 영향은 관찰되지 않았다.
|
||
|
||
그러나 intended Spring Data safety helper로서 자기 계약을 만족하지 않는다.
|
||
|
||
우선순위: **P2 — safety-contract correctness before adoption**
|
||
|
||
주의할 점은 generic `Specification`을 실행 전에 평가해 predicate null 여부를 확인하려면 Criteria context가 필요하다는 것이다. 단순 reflection으로 해결하기 어렵다.
|
||
|
||
가능한 방향:
|
||
|
||
- raw `Specification`을 safety boundary로 받지 않고 platform-owned bounded predicate descriptor를 사용
|
||
- explicit “unrestricted” 여부를 caller가 별도 contract로 선언
|
||
- repository execution helper 안에서 실제 Criteria predicate 생성과 policy validation을 결합
|
||
|
||
Raw:
|
||
|
||
- `evidence/raw/053-jpa-query-hibernate-boundary-probe.txt`
|
||
- `evidence/raw/061-spring-data-specification-unrestricted-contract.txt`
|
||
|
||
## 48. Querydsl integration은 production runtime classpath를 강제로 오염시키지 않는다
|
||
|
||
`QuerydslJpaSupport`는 Advanced opt-in으로 설계돼 있다.
|
||
|
||
build:
|
||
|
||
```gradle
|
||
compileOnly 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
|
||
testImplementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
|
||
```
|
||
|
||
lockfile에서 Querydsl은:
|
||
|
||
- compileClasspath
|
||
- test/integration/performance classpaths
|
||
|
||
에는 나타나지만 production `runtimeClasspath` configuration에는 포함되지 않는다.
|
||
|
||
따라서 JPA leaf를 사용하는 것만으로 Querydsl runtime dependency가 Stable deployment에 따라오는 구조는 아니다.
|
||
|
||
`QuerydslJpaSupport`도:
|
||
|
||
- bounded page size <= 500
|
||
- null predicate는 explicit unbounded opt-in 없으면 거부
|
||
- registered `QueryName`을 Hibernate comment hint로 적용
|
||
|
||
한다.
|
||
|
||
현재 production consumer는 확인되지 않았다. 이는 Advanced opt-in helper의 미채택 상태로 기록하며 dead-code defect로 단정하지 않는다.
|
||
|
||
## 49. SQL query naming mechanism은 구현은 있으나 shipped composition wiring을 찾지 못했다
|
||
|
||
`QueryNameContext`와 `NamedStatementInspector`의 설계는 다음과 같다.
|
||
|
||
```text
|
||
registered QueryName을 thread-local scope에 bind
|
||
-> Hibernate StatementInspector
|
||
-> SQL prefix/comment에 bounded query identity 추가
|
||
```
|
||
|
||
이렇게 하면 raw SQL text가 아니라 registered query identity로 DB statement와 application observation을 연결할 수 있다.
|
||
|
||
문제는 current production source/config 전체에서:
|
||
|
||
- `NamedStatementInspector` construction = class definition 외 0
|
||
- Hibernate `statement_inspector` property registration = 0
|
||
|
||
이라는 점이다.
|
||
|
||
`JpaRepositoryFragmentSupport`는 `QueryNameContext`를 사용하지만 실제 Hibernate statement inspector가 설치되지 않으면 그 name은 SQL layer까지 내려가지 않는다.
|
||
|
||
따라서 query/observability documentation에서 “registered query name이 generated SQL에 연결된다”는 설명은 current shipped composition evidence가 없다.
|
||
|
||
이것은 transaction scope에서 확인한:
|
||
|
||
- `JpaObservabilityAutoConfiguration` production construction 0
|
||
- transaction observation call path 0
|
||
|
||
와 같은 방향의 증거다.
|
||
|
||
**Cross-scope finding:** JPA observability capability의 구현 클래스들은 존재하지만 composition completeness가 부족하다.
|
||
|
||
최종 severity는 later observation/config owning sub-scope에서 전체 mechanism을 다시 읽고 확정한다. 여기서는 SQL query naming path가 현재 unwired라는 observed evidence만 추가한다.
|
||
|
||
## 50. 대부분의 optimization helper가 production에서 직접 소비되지 않는다는 사실은 이미 repository가 알고 있다
|
||
|
||
negative-space search에서 다음 implementation roots는 repository production consumer가 확인되지 않았다.
|
||
|
||
- `HibernateJpaBatchExecutor`
|
||
- `JpaBatchProfileRegistry`
|
||
- `HibernateBulkDmlExecutor`
|
||
- `HibernateStatelessSessionRunner`
|
||
- `FetchPlanApplier`
|
||
- `JpaKeysetQuerySupport`
|
||
- `JpaRepositoryFragmentSupport`
|
||
- `JpaStreamExecutor`
|
||
- `SpecificationPolicy`
|
||
- `QuerydslJpaSupport`
|
||
|
||
하지만 이것을 곧바로 “dead code가 대량 존재한다”라고 해석하면 안 된다.
|
||
|
||
이 repository의 기존 study/review 문서도 이미 JPA platform helper가 **구현/qualification되어 있지만 sample production path가 대부분 채택하지 않은 상태**라고 기록한다. 또한 batch/bulk/stateless helper는 real PostgreSQL integration tests에서 직접 실행된다.
|
||
|
||
따라서 현재 판단은 capability별로 나눈다.
|
||
|
||
### implemented + qualified + not adopted
|
||
|
||
예:
|
||
|
||
- batch
|
||
- bulk DML
|
||
- stateless
|
||
|
||
이들은 library capability로 유지할 수 있다.
|
||
|
||
### implemented but production composition itself가 필요한데 wiring 없음
|
||
|
||
예:
|
||
|
||
- `NamedStatementInspector`처럼 global Hibernate hook이 필요한 기능
|
||
|
||
이 경우는 “아무 use case가 안 쓴다”와 다르다. feature를 사용하려면 composition이 먼저 존재해야 한다.
|
||
|
||
### old mechanism이 consumer 제거 후 남은 경우
|
||
|
||
transaction scope의 `TransactionProfileRegistry`처럼 history를 통해 실제 residue로 판정해야 한다.
|
||
|
||
즉 `grep refs=0`은 finding의 시작점이지 결론이 아니다.
|
||
|
||
## 51. export boundary는 현재 split SSOT다
|
||
|
||
이 leaf는 하나의 jar 안에 많은 public implementation type이 존재한다. 그래서 “public Java modifier”와 “architecturally exported package”를 별도로 관리하려 한다.
|
||
|
||
### 51.1 leaf-local `EXPORTED_PACKAGES`
|
||
|
||
`JpaModuleBoundaryTest`에는 다음 export set이 있다.
|
||
|
||
- api
|
||
- notification.configuration
|
||
- transaction
|
||
- security
|
||
- observation
|
||
- migration
|
||
- hibernate
|
||
- fileserver
|
||
- failure
|
||
- config
|
||
|
||
`springdata`, `querydsl`은 여기 없다.
|
||
|
||
### 51.2 실제 app-bootstrap consumer rule은 별도 allowlist를 다시 가진다
|
||
|
||
`CleanArchitectureTest.BOOTSTRAP_USES_ONLY_THE_PERSISTENCE_EXPORT_SURFACE`는 또 다른 `EXPORTED` set을 정의한다.
|
||
|
||
여기에는 root composition이 vendor entry point를 import해야 하므로:
|
||
|
||
- postgresql
|
||
- h2
|
||
|
||
까지 추가돼 있다.
|
||
|
||
즉 두 목록은 이미 동일하지 않다.
|
||
|
||
### 51.3 leaf list 자체는 outside consumer를 검사하지 않는다
|
||
|
||
`JpaModuleBoundaryTest`의 local export test는:
|
||
|
||
- export package가 실제 존재하는지
|
||
- 새 top-level package가 governance 대상인지
|
||
|
||
를 보지만 repository의 outside consumer import를 직접 스캔하지 않는다.
|
||
|
||
실제 consumer restriction은 app-bootstrap의 별도 ArchUnit rule이 담당한다.
|
||
|
||
따라서 current architecture fitness function은:
|
||
|
||
```text
|
||
leaf export declaration A
|
||
X shared SSOT 아님
|
||
bootstrap allowed imports B
|
||
```
|
||
|
||
형태다.
|
||
|
||
fresh architecture tests는 모두 통과했다. 이것은 현재 import graph가 각자의 rule을 만족한다는 뜻이지 **A와 B가 서로 drift하지 않는다는 증명은 아니다.**
|
||
|
||
우선순위: **P2/P3 architecture-governance hardening**
|
||
|
||
권장 방향은 exported package registry를 한 곳으로 옮기고 leaf package DAG와 consumer ArchUnit rule이 같은 데이터를 읽게 하는 것이다.
|
||
|
||
## 52. Confirmed P1 — `collection-fetch-pagination` blocking release gate가 실제 위험을 증명하지 않는다
|
||
|
||
이번 sub-scope에서 가장 중요한 finding이다.
|
||
|
||
support matrix는 다음 gate를 blocking release gate로 선언한다.
|
||
|
||
```text
|
||
collection-fetch-pagination
|
||
```
|
||
|
||
목적:
|
||
|
||
> paged collection fetch가 전체 table을 읽고 memory에서 pagination하는 provider regression을 차단
|
||
|
||
이다.
|
||
|
||
이 위험의 특성상 **returned page size는 증거가 아니다.** provider가 모든 rows를 읽고 Java에서 20개만 반환해도 결과는 정확하기 때문이다.
|
||
|
||
실제 `HibernateCollectionFetchPaginationContractTest` javadoc도 정확히 이 점을 알고 있다.
|
||
|
||
> assertion is therefore on the generated SQL, not on the returned page size
|
||
|
||
그러나 실제 test body는 그 설명을 구현하지 않는다.
|
||
|
||
### 52.1 실제 collection-fetch test가 SQL limit을 보지 않는다
|
||
|
||
`oneCollectionPageIsBoundedInSql()`의 핵심 assertion은:
|
||
|
||
```text
|
||
returned page size <= expected max
|
||
expected.requiresDatabaseLimit() == true
|
||
```
|
||
|
||
뿐이다.
|
||
|
||
다음을 검사하지 않는다.
|
||
|
||
- generated SQL의 LIMIT/FETCH FIRST/subquery shape
|
||
- StatementInspector capture
|
||
- query AST
|
||
- provider warning/failure
|
||
- `hibernate.query.fail_on_pagination_over_collection_fetch`
|
||
|
||
repository search에서도 해당 fail-on-pagination setting의 runtime configuration은 확인되지 않았다.
|
||
|
||
다른 같은 class tests도:
|
||
|
||
- prepared statement count <= 2
|
||
- N+1 comparison
|
||
- fixture 전체 row amplification bound
|
||
|
||
을 검증할 뿐 parent selection이 SQL에서 제한됐는지 증명하지 않는다.
|
||
|
||
이번 real PostgreSQL fresh run에서 해당 class 4 tests는 전부 통과했다. 하지만 **green은 현재 assertion이 green이라는 뜻**이지 documented risk가 차단됐다는 뜻이 아니다.
|
||
|
||
### 52.2 release registry가 가리키는 producer task는 그 test를 실행하지도 않는다
|
||
|
||
더 큰 문제는 provenance mapping이다.
|
||
|
||
`config/jpa/release-registry.json`:
|
||
|
||
```text
|
||
collection-fetch-pagination
|
||
-> :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
|
||
```
|
||
|
||
그런데 Gradle task registration은:
|
||
|
||
```text
|
||
jpaPlatformQueryPlanTest
|
||
-> includeTags "jpa-queryplan"
|
||
```
|
||
|
||
이다.
|
||
|
||
실제 collection-fetch class는:
|
||
|
||
```text
|
||
@Tag("jpa-contract")
|
||
```
|
||
|
||
이고 `PostgreSqlQueryPlanContractTest`만 `@Tag("jpa-queryplan")`이다.
|
||
|
||
### 52.3 exact registry task fresh 실행 결과
|
||
|
||
이번에 registry가 지정한 task 자체를 fresh 실행했다.
|
||
|
||
결과:
|
||
|
||
```text
|
||
BUILD SUCCESSFUL
|
||
executed XML suites:
|
||
PostgreSqlQueryPlanContractTest
|
||
```
|
||
|
||
`HibernateCollectionFetchPaginationContractTest`는 이 task의 executed suite에 존재하지 않았다.
|
||
|
||
즉 release registry가 **이 gate의 evidence producer라고 지목한 task가 gate scenario를 실행하지 않는다.**
|
||
|
||
### 52.4 현재 gate-validator도 이 mismatch를 잡지 못한다
|
||
|
||
`verifyJpaReleaseGateTasks`는 registry의 각 gate에 대해:
|
||
|
||
- absolute Gradle path인가
|
||
- project가 존재하는가
|
||
- task가 존재하는가
|
||
- `Test` task인가
|
||
|
||
까지만 검사한다.
|
||
|
||
fresh 실행 결과:
|
||
|
||
```text
|
||
verifyJpaReleaseGateTasks: OK — 6 gate task(s) resolve to real Test tasks.
|
||
BUILD SUCCESSFUL
|
||
```
|
||
|
||
이다.
|
||
|
||
즉 지금처럼 **의미상 완전히 다른 tests를 실행하는 Test task**도 valid producer로 인정한다.
|
||
|
||
### 52.5 aggregate release task가 collection test도 실행한다는 점은 mitigation이지 provenance fix가 아니다
|
||
|
||
`jpaPlatformReleaseGate` aggregate는 `jpaPlatformContractTest`와 `jpaPlatformQueryPlanTest` 둘 다 dependsOn 한다.
|
||
|
||
따라서 full aggregate를 실행하면 `@Tag("jpa-contract")`인 collection test 자체는 다른 lane에서 실행될 수 있다.
|
||
|
||
하지만 이것은 registry mapping을 올바르게 만들지 않는다.
|
||
|
||
- gate별 evidence provenance가 틀림
|
||
- gate task를 단독 재검증하면 target scenario 미실행
|
||
- collection test가 실행돼도 target behavior assertion 부족
|
||
|
||
이므로 false evidence 문제가 두 겹이다.
|
||
|
||
### 52.6 역사
|
||
|
||
- collection-fetch contract test: initial JPA platform commit `0e61f86`에서 이미 현재 SQL-inspection 없는 shape로 추가
|
||
- release registry gate mapping: 이후 integration commit `2f5d2fc`에서 추가
|
||
|
||
즉 최근 refactor regression이라기보다 **초기 evidence design부터 존재한 gap**이다.
|
||
|
||
우선순위: **P1 — blocking release evidence integrity**
|
||
|
||
이유:
|
||
|
||
support matrix 자체가 release gate를 “tests가 pass해도 production에서 틀릴 수 있는 경우를 막기 위한 것”이라고 정의한다. 그런데 이 gate는 바로 그 종류의 false green을 허용한다.
|
||
|
||
수정 조건은 둘 다 필요하다.
|
||
|
||
1. gate producer가 실제 collection-fetch scenario를 실행하도록 registry/task/tag 연결 수정
|
||
2. test가 generated SQL 또는 fail-closed provider signal로 **DB-side pagination**을 직접 검증
|
||
|
||
둘 중 하나만 고치면 gate는 여전히 불완전하다.
|
||
|
||
Raw:
|
||
|
||
- `evidence/raw/056-persistence-jpa-collection-fetch-gate-provenance.txt`
|
||
- `evidence/raw/057-persistence-jpa-query-hibernate-postgresql-contracts.txt`
|
||
- `evidence/raw/058-persistence-jpa-queryplan-gate-task.txt`
|
||
- `evidence/raw/059-verify-jpa-release-gate-tasks.txt`
|
||
- `evidence/raw/060-persistence-jpa-query-hibernate-history.txt`
|
||
|
||
## 53. 기존 review finding 중 현재 해결된 것과 남은 것을 분리한다
|
||
|
||
기존 `docs/reviews/2026-08-14-jpa-module-code-review.md`에는 이 영역의 여러 문제를 이미 지적했다.
|
||
|
||
현재 source와 대조하면 다음은 **해결됨**으로 관찰된다.
|
||
|
||
- batch clear가 unflushed entity를 버리던 문제 -> clear 전 flush
|
||
- stream fetch-size가 query supplier에 전달되지 않던 문제 -> `Function<ScrollPolicy,...>`
|
||
- stateless row cap이 실제 affected rows와 연결되지 않던 문제 -> `StatelessWorkResult.affectedRows`
|
||
- sort tie-breaker direction 고정 문제 -> registry-declared direction
|
||
- keyset mixed type/direction 표현 문제 -> per-term type/direction
|
||
|
||
반면 이번에 확인한:
|
||
|
||
- property-access IDENTITY guard bypass
|
||
- `Specification.unrestricted()` bypass
|
||
- collection-fetch release false evidence
|
||
- StatementInspector composition 부재
|
||
- split export SSOT
|
||
|
||
는 current snapshot에 남아 있다.
|
||
|
||
이 분리를 하지 않으면 과거 review의 defect를 현재 defect처럼 중복 보고하거나, 반대로 “이미 review했으니 해결됐다”고 잘못 가정하게 된다.
|
||
|
||
## 54. fresh verification과 증명 범위
|
||
|
||
### 54.1 dedicated unit tests
|
||
|
||
fresh command:
|
||
|
||
```text
|
||
:adapter:outbound:persistence-jpa:test
|
||
--tests hibernate.*
|
||
--tests springdata.*
|
||
--tests querydsl.*
|
||
--rerun-tasks
|
||
```
|
||
|
||
결과:
|
||
|
||
```text
|
||
BUILD SUCCESSFUL in 23s
|
||
18 actionable tasks: 18 executed
|
||
```
|
||
|
||
이 11 dedicated tests는 현재 helper behavior를 확인하지만 다음 새 경계는 포함하지 않는다.
|
||
|
||
- property-access IDENTITY
|
||
- non-null null-predicate Specification
|
||
- one JDBC batch convenience semantics
|
||
- release registry provenance
|
||
|
||
### 54.2 architecture tests
|
||
|
||
fresh:
|
||
|
||
- `JpaModuleBoundaryTest`
|
||
- app-bootstrap `CleanArchitectureTest`
|
||
|
||
결과:
|
||
|
||
```text
|
||
BUILD SUCCESSFUL in 1m 48s
|
||
100 actionable tasks: 100 executed
|
||
```
|
||
|
||
현재 package DAG와 bootstrap import graph는 rules를 만족한다.
|
||
|
||
하지만 두 export allowlist가 같은 SSOT인지까지 검증하지 않는다.
|
||
|
||
### 54.3 selected real PostgreSQL contracts
|
||
|
||
fresh `jpaPlatformContractTest`에서 다음 classes를 직접 선택했다.
|
||
|
||
- `HibernateCollectionFetchPaginationContractTest`: 4 tests
|
||
- `HibernateJpaBatchExecutorIntegrationTest`: 7 tests
|
||
- `IdStrategyContractTest`: 4 tests
|
||
|
||
총 15 tests:
|
||
|
||
```text
|
||
skipped=0
|
||
failures=0
|
||
errors=0
|
||
BUILD SUCCESSFUL
|
||
```
|
||
|
||
이는 실제 PostgreSQL 위에서 current assertions가 통과함을 증명한다.
|
||
|
||
특히 batch/ID strategy evidence에는 의미가 크다. 반면 collection-fetch의 SQL-limit 부재는 assertion design 문제라 이 green 결과로 해소되지 않는다.
|
||
|
||
### 54.4 exact query-plan gate task
|
||
|
||
fresh `jpaPlatformQueryPlanTest`:
|
||
|
||
```text
|
||
3 tests
|
||
PostgreSqlQueryPlanContractTest only
|
||
BUILD SUCCESSFUL
|
||
```
|
||
|
||
registry mapping mismatch를 runtime result XML까지 확인했다.
|
||
|
||
### 54.5 release-task existence validator
|
||
|
||
fresh `verifyJpaReleaseGateTasks`도 성공했다.
|
||
|
||
이 success는 오히려 validator limitation의 evidence다. task semantic coverage/tag를 검사하지 않기 때문이다.
|
||
|
||
## 55. Sub-scope 04 findings backlog
|
||
|
||
### P1 — blocking `collection-fetch-pagination` release gate false evidence
|
||
|
||
- registry producer = `jpaPlatformQueryPlanTest`
|
||
- producer actual suite = `PostgreSqlQueryPlanContractTest` only
|
||
- target collection-fetch class는 `jpa-contract` tag
|
||
- target test 자체도 generated SQL limit을 검사하지 않음
|
||
- current gate validator는 task existence/Test type만 검증해 mismatch를 허용
|
||
- **P1 release-evidence integrity**
|
||
|
||
### P2 — property-access IDENTITY가 batching-required guard를 우회
|
||
|
||
- guard field annotation만 탐색
|
||
- legal getter/property access entity probe가 `usesIdentityGeneration=false`
|
||
- validation passes
|
||
- current production entity exposure는 field access라 shipped-hit evidence 없음
|
||
- **P2 platform guard correctness**
|
||
|
||
### P2 — `SpecificationPolicy`가 unrestricted non-null Specification을 허용
|
||
|
||
- current Spring Data 4.0.7 `Specification.unrestricted()`는 non-null + null predicate
|
||
- policy는 object null만 검사
|
||
- explicit allow token 없이 predicate-free scan 통과
|
||
- current production consumer 0
|
||
- **P2 safety contract before adoption**
|
||
|
||
### Cross-scope P1/P2 — query SQL naming/observability composition 부재
|
||
|
||
- `NamedStatementInspector` runtime registration 0
|
||
- QueryNameContext는 존재하지만 SQL layer bridge가 확인되지 않음
|
||
- transaction observation wiring gap과 함께 later observation/config scope에서 최종 판정
|
||
|
||
### P2/P3 — export surface split SSOT
|
||
|
||
- leaf export list와 app-bootstrap consumer list가 중복 정의되고 이미 다름
|
||
- current tests pass하지만 두 목록 간 drift를 막는 single-source rule 없음
|
||
- **architecture governance hardening**
|
||
|
||
### P3/open — `BatchExecutionResult.batched()` one-batch semantics
|
||
|
||
- jdbcBatches=1 -> false
|
||
- method naming/javadoc 의미를 더 명확히 해야 함
|
||
- current large-run integration evidence에는 영향 없음
|
||
|
||
### acknowledged, not newly promoted defect — unadopted platform helpers
|
||
|
||
- many springdata/hibernate/querydsl executors have no production business consumer
|
||
- repository docs/review already record platform implementation vs sample adoption gap
|
||
- integration qualification이 존재하는 helper도 있으므로 refs=0만으로 dead code라 하지 않음
|
||
|
||
## 56. Sub-scope 04 완료 조건
|
||
|
||
확인한 것:
|
||
|
||
- production 42 / 42 FULL_READ
|
||
- dedicated tests 11 / 11 FULL_READ
|
||
- unclassified 0
|
||
- package DAG / export policy
|
||
- external production reachability
|
||
- Querydsl production-runtime optionality
|
||
- provider statistics / batch measurement
|
||
- batch flush/clear lifecycle
|
||
- ID generation guard
|
||
- bulk/stateless execution model
|
||
- repository fragment / fetch-plan mechanism
|
||
- safe sort / keyset predicate / keyset slice
|
||
- stream lifecycle / fetch-size path / reactive rejection
|
||
- Specification safety contract
|
||
- NamedStatementInspector/QueryName wiring
|
||
- history against prior review
|
||
- blocking release gate provenance
|
||
- focused unit tests fresh
|
||
- architecture tests fresh
|
||
- selected PostgreSQL contracts fresh
|
||
- exact registry query-plan task fresh
|
||
- release-task validator fresh
|
||
|
||
남긴 경계:
|
||
|
||
- PostgreSQL vendor-specific translator/native-query/type implementation 전체
|
||
- complete PostgreSQL integration/readiness source set
|
||
- full observation/config package composition
|
||
- real provider behavior under an intentionally regressed collection-fetch pagination implementation
|
||
|
||
이 항목들은 각각 sub-scope 05, 06/11에서 다시 owning-scope 기준으로 확인한다.
|
||
|
||
|
||
## 57. Sub-scope 05 범위와 denominator
|
||
|
||
이번 sub-scope의 소유 범위는 PostgreSQL vendor 구현과 root vendor migration이다.
|
||
|
||
| 구분 | 범위 | 파일 수 | 판정 |
|
||
|---|---|---:|---|
|
||
| production Java | `src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/**` | 55 | FULL_READ |
|
||
| dedicated unit test | `src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/**` | 9 | FULL_READ |
|
||
| vendor migration | `src/main/resources/db/migration/postgresql/*.sql` | 9 | FULL_READ |
|
||
| 합계 | | **73** | **73 / 73 FULL_READ** |
|
||
|
||
unclassified file은 0이다. `evidence/raw/064-postgresql-vendor-manifest-reachability.txt`에 현재 revision의 tracked blob과 production construction/reachability snapshot을 남겼다.
|
||
|
||
이 sub-scope는 PostgreSQL 전용 SQLSTATE/constraint translation, native write/COPY, work claiming, JSON/array/range support, owner-safe idempotency, same-store inbox, polling outbox와 vendor migration을 소유한다. 반면 전체 `postgresqlIntegrationTest` source set의 완전독해는 sub-scope 11이 소유한다. 여기서는 finding 검증에 필요한 정확한 integration lane만 실행했다.
|
||
|
||
## 58. PostgreSQL failure translation: SQLSTATE 분류는 맞지만 `40003` 의미가 translator에서 소실된다
|
||
|
||
`PostgreSqlFailureClassifier`는 PostgreSQL SQLSTATE를 bounded `FailureCategory`로 분류한다. serialization failure, deadlock, lock-not-available, constraint family, timeout, connection failure, schema/data 문제를 문자열 메시지가 아니라 SQLSTATE/structured server field 기준으로 다루는 방향은 적절하다. constraint 이름도 server error field에서 꺼내 catalog로 번역하므로 localized message parsing에 의존하지 않는다.
|
||
|
||
문제는 `COMPLETION_UNKNOWN`이다.
|
||
|
||
현재 `PostgreSqlExceptionTranslator.translate()`는 classifier 결과가 `COMPLETION_UNKNOWN`이어도 `JpaFailureContext`의 `completionUnknown`을 항상 `false`로 만들고, switch에서 `COMPLETION_UNKNOWN`을 `UNKNOWN`과 함께 일반 `JpaPersistenceException(FailureCategory.UNKNOWN, ...)`으로 강등한다.
|
||
|
||
직접 probe에서 SQLSTATE `40003`은 다음처럼 변환됐다.
|
||
|
||
```text
|
||
type=JpaPersistenceException
|
||
category=UNKNOWN
|
||
sqlState=40003
|
||
completionUnknown=false
|
||
retryable=false
|
||
```
|
||
|
||
여기서 단순 진단 정보만 사라지는 것이 아니다. 현재 `DefaultJpaRetryPolicy`는 `TransactionCompletionUnknownException` 또는 `FailureCategory.COMPLETION_UNKNOWN`을 가장 먼저 검사해 `RECONCILE`로 보낸다. 그런데 실제 translator를 통과시키면 focused policy probe 결과가 다음과 같다.
|
||
|
||
```text
|
||
translated.category=UNKNOWN
|
||
translated.completionUnknown=false
|
||
decision.disposition=FAIL
|
||
decision.reason=failure was classified as non-retryable
|
||
```
|
||
|
||
즉 **재실행은 막지만, commit 결과를 확인해야 하는 reconciliation 경로도 잃는다.** fail-closed라는 이유로 안전하다고 볼 수 없는 이유다. commit이 실제로 적용됐는지 알 수 없는 상태를 terminal failure로 바꾸면 caller는 설계된 recovery protocol을 실행할 근거를 잃는다.
|
||
|
||
현재 revision에서는 이 translator가 실제 composition에 들어온다. `PostgreSqlPersistenceConfig`가 `VendorFailureTranslator`를 제공하고, `JpaPlatformRuntimeAutoConfiguration`이 이를 `SpringJpaTransactionExecutor`의 `PersistenceFailureTranslatorChain`에 넣는다. 따라서 예전 리뷰의 “vendor translator caller 없음” 문제는 현재 wiring에서 해소됐지만, 그 결과 `40003` 의미 손실은 이제 실제 transaction path에 도달 가능한 문제다.
|
||
|
||
2026-08-14 JPA review도 completion-unknown에 대해 **body replay 0회 + reconciliation key 보존**을 기대 계약으로 이미 기록했다. 따라서 이 finding은 새로운 정책 제안이 아니라 기존 recovery contract와 현재 구현 간 불일치다.
|
||
|
||
**판정: P1 — production completion-unknown recovery contract violation.**
|
||
|
||
필요한 수정 방향은 `40003`/`COMPLETION_UNKNOWN`을 `TransactionCompletionUnknownException` 또는 최소한 `FailureCategory.COMPLETION_UNKNOWN + completionUnknown=true`로 보존하고, translator → retry policy까지 한 테스트에서 `RECONCILE`을 고정하는 것이다.
|
||
|
||
## 59. PostgreSQL Idempotency V2: owner/CAS 구조는 강하지만 replay 경계가 두 군데 어긋난다
|
||
|
||
`PostgreSqlOwnerSafeIdempotencyStore`는 row lock, owner token, attempt, state revision, operation id와 transition digest를 결합해 claim/renew/fail/complete를 보호한다. `renew`와 `markFailed`는 동일 operation id replay에서도 semantic argument를 digest에 넣어 `SAME_ARGUMENTS`와 `DIFFERENT_ARGUMENTS`를 분리한다. 이 구조 자체는 강하다.
|
||
|
||
현재 revision에서는 `PostgreSqlIdempotencyProviderConfig`가 이 store를 production provider로 실제 생성하므로 아래 두 finding은 dormant helper 문제가 아니다.
|
||
|
||
### 59.1 P1 — `inspect()`와 `claim()`이 만료된 COMPLETED row를 동시에 다른 상태로 해석한다
|
||
|
||
`inspect()`는 row가 `COMPLETED`이고 response payload가 있으면 `replayUntil`이 이미 지난 값인지 확인하지 않고 무조건 `COMPLETED_REPLAY`를 반환한다. 반면 claim path는 DB time과 expiry를 보고 만료된 row를 takeover 가능 상태로 처리한다.
|
||
|
||
실제 PostgreSQL 16에서 replay TTL 25ms로 완료한 뒤 50ms를 기다린 probe 결과:
|
||
|
||
```text
|
||
expiredInspect.outcome=COMPLETED_REPLAY
|
||
expiredInspect.replayUntil=<already expired>
|
||
expiredInspect.claimAfterExpiry=TakenOverClaimed
|
||
```
|
||
|
||
즉 같은 시점의 같은 row가:
|
||
|
||
```text
|
||
inspect -> "이전 응답을 replay하라"
|
||
claim -> "이전 replay window는 끝났으니 새 실행을 소유할 수 있다"
|
||
```
|
||
|
||
로 갈린다.
|
||
|
||
Application의 `IdempotencyExecutorV2`는 reconciliation에서 `COMPLETED_REPLAY`를 실제 저장 응답 반환 신호로 사용한다. 따라서 이 불일치는 단순 introspection 문제가 아니라 **만료 후 새 실행이 허용된 시점에도 이전 응답을 reconciliation 결과로 반환할 수 있는 lifecycle correctness 문제**다.
|
||
|
||
JPA 설계 문서가 동일 Idempotency V2 contract를 구현한다고 참조하는 Redis state machine도 `COMPLETED -> [*] : replay TTL expires`로 수명을 끝낸다. JPA `inspect()`만 이 만료를 무시한다.
|
||
|
||
**판정: P1 — production idempotency lifecycle/reconciliation inconsistency.**
|
||
|
||
수정 시 `inspect()`도 claim과 같은 DB-time 기준 expiry semantics를 사용해야 하며, `replayUntil <= dbNow` 이후에는 더 이상 `COMPLETED_REPLAY`를 반환하지 않는 real-PostgreSQL boundary test가 필요하다.
|
||
|
||
### 59.2 P2 — `complete()`의 replay 판정이 `replayTtl` 변경을 무시한다
|
||
|
||
첫 completion에서는 transition digest에 다음이 들어간다.
|
||
|
||
- transition kind
|
||
- operation id
|
||
- owner tuple
|
||
- response digest
|
||
- `replayTtl.toMillis()`
|
||
|
||
코드 주석도 “completion이 replay window도 결정하므로 transition digest에 포함해야 한다”고 설명한다.
|
||
|
||
하지만 이미 `COMPLETED`인 동일 operation replay branch는 full transition digest를 비교하지 않고 **operation id + response digest만** 비교한다. 따라서 response는 같고 replay TTL만 바뀌면 `ALREADY_COMPLETED_SAME_RESULT`가 나온다.
|
||
|
||
실제 PostgreSQL probe:
|
||
|
||
```text
|
||
completeReplayTtl.first=COMPLETED
|
||
completeReplayTtl.secondDifferentTtl=ALREADY_COMPLETED_SAME_RESULT
|
||
completeReplayTtl.storedSeconds=3600
|
||
```
|
||
|
||
첫 호출은 1시간, 두 번째 호출은 동일 operation/response에 9시간을 전달했다. 두 번째 호출은 semantic argument가 다른데도 same-result로 판정됐고 DB에는 최초 1시간 window가 그대로 남았다.
|
||
|
||
`IdempotencyDigestPolicyTest`는 이미 “replay window가 다르면 completion digest가 다르다”는 정책을 테스트한다. 또한 같은 integration test suite의 renew/markFailed는 동일 operation id + 다른 TTL/retention을 conflict로 검증한다. complete만 대응하는 replay-argument test가 빠져 있다.
|
||
|
||
**판정: P2 — production idempotency replay semantic mismatch.**
|
||
|
||
동일 operation replay에서도 첫 적용과 같은 complete transition digest를 계산해 `replayTtl`까지 비교해야 한다.
|
||
|
||
## 60. Same-store inbox / polling outbox: 구현 계약은 강하지만 현재 미조립 candidate에 replay holes가 있다
|
||
|
||
`PostgreSqlSameStoreInboxAdapter`와 `PostgreSqlPollingDeliveryAdapter`는 `application-core`의 owner-safe transition contract를 구현하지만, 현재 production composition에서 bean construction이나 stereotype은 확인되지 않았다. 따라서 아래 finding은 **현재 배포 기본 경로의 즉시 장애가 아니라, 이 candidate adapter를 채택할 때 활성화되는 latent defect**로 분리한다.
|
||
|
||
### 60.1 P2 latent — inbox `markProcessing()` duplicate replay가 owner 검증보다 먼저 persisted owner를 반환한다
|
||
|
||
`markProcessing()`은 같은 `START + operationId`를 발견하면 `classifyMismatch()`보다 먼저 `owner(row)`를 반환한다. 이 때문에 scope/operation id만 맞춘 forged owner로 replay하면 DB에 저장된 실제 owner token을 돌려받을 수 있다.
|
||
|
||
실제 PostgreSQL probe:
|
||
|
||
```text
|
||
inboxForgedReplay.outcome=PROCESSING_STARTED
|
||
inboxForgedReplay.returnedActualToken=true
|
||
inboxForgedReplay.returnedForgedToken=false
|
||
inboxForgedReplay.completeWithReturnedOwner=COMPLETED
|
||
```
|
||
|
||
즉 duplicate handling이 owner capability recovery oracle처럼 동작한다. 채택 전에는 duplicate replay에서도 persisted owner tuple/revision과 supplied owner를 먼저 검증하도록 고쳐야 한다.
|
||
|
||
### 60.2 P2 latent — inbox retry/dead replay digest가 retention을 포함하지 않는다
|
||
|
||
`markRetryable`/`markDead`의 `retention`은 실제 SQL update에는 들어가지만 transition digest에는 들어가지 않는다.
|
||
|
||
```text
|
||
inboxRetention.first=RETRYABLE
|
||
inboxRetention.secondDifferentRetention=ALREADY_APPLIED_SAME_OPERATION
|
||
inboxRetention.remainingHours=1.000
|
||
```
|
||
|
||
동일 operation id로 retention만 바꾼 replay가 same-operation으로 흡수된다. retention은 terminal row 보존 기간을 결정하는 semantic argument이므로 digest에 canonical millis를 포함해야 한다.
|
||
|
||
### 60.3 P2 latent — outbox retry replay digest가 `nextAttemptAt`을 포함하지 않는다
|
||
|
||
`markRetryable()`은 `nextAttemptAt`을 DB에 기록하지만 transition digest는 kind + operation + owner + errorCode만 포함한다.
|
||
|
||
```text
|
||
outboxRetry.first=RETRY_SCHEDULED
|
||
outboxRetry.secondDifferentSchedule=ALREADY_APPLIED_SAME_OPERATION
|
||
outboxRetry.storedEqualsFirst=true
|
||
outboxRetry.storedEqualsSecond=false
|
||
```
|
||
|
||
재시도 시각은 delivery scheduling 자체를 바꾸는 semantic argument다. 동일 operation replay consistency를 주장하려면 canonical instant를 digest에 넣어야 한다.
|
||
|
||
## 61. Native write, COPY, work claiming, JSON/array/range support
|
||
|
||
### 61.1 확인된 안전 경계
|
||
|
||
native write와 COPY는 caller가 임의 SQL identifier를 조립하도록 두지 않고 registered statement/name boundary를 사용한다. 값은 JDBC parameter 또는 COPY stream으로 전달된다. COPY에는 format/size bound와 transaction requirement가 있고, work claiming은 등록된 queue definition과 PostgreSQL `FOR UPDATE ... SKIP LOCKED` 경계를 사용한다.
|
||
|
||
JSON path/query support와 range query support도 registry/typed value boundary를 두고 실제 값은 bind한다. constraint translation 역시 structured SQLSTATE/server fields를 사용한다.
|
||
|
||
이번 sub-scope에서 이 영역의 새로운 SQL-injection/runtime-wiring defect는 확인되지 않았다.
|
||
|
||
### 61.2 P2 latent — `PgRangeCodec`이 자신이 escape한 quote를 다시 parse하지 못한다
|
||
|
||
`format()`은 endpoint 내부 `"`와 `\`를 escape한다. 그런데 `separatorIndex()`는 backslash escape를 고려하지 않고 모든 `"` 문자를 quote-state toggle로 취급한다.
|
||
|
||
self round-trip probe 결과:
|
||
|
||
```text
|
||
comma.roundTrip=true
|
||
quote.error=IllegalArgumentException:postgresql range literal has no endpoint separator
|
||
quote-comma.error=IllegalArgumentException:range lower bound exceeds upper bound
|
||
backslash-quote-comma.error=IllegalArgumentException:range lower bound exceeds upper bound
|
||
```
|
||
|
||
즉 comma만 포함한 endpoint는 통과하지만 escaped quote가 포함되면 formatter가 만든 literal조차 parser가 읽지 못한다. 현재 `PgRangeTest`는 timestamp 중심이라 이 grammar boundary를 덮지 않는다.
|
||
|
||
현재 production consumer는 정적 reachability에서 확인되지 않았으므로 **P2 latent helper algebra defect**로 둔다. 채택 전에는 PostgreSQL quoted-range grammar에 맞게 escaped quote/backslash를 인식하는 tokenizer/state machine과 round-trip property test가 필요하다.
|
||
|
||
## 62. Vendor migrations
|
||
|
||
다음 9개 migration을 모두 읽었다.
|
||
|
||
```text
|
||
V1__idempotency_record.sql
|
||
V3__outbox_event.sql
|
||
V4__int_lock.sql
|
||
V5__int_lock_expired_after.sql
|
||
V6__capability_schema_registry_adoption.sql
|
||
V9__widen_capability_schema_stream.sql
|
||
V10__idempotency_request_hash_varchar.sql
|
||
V11__durable_operation.sql
|
||
V12__live_event_log.sql
|
||
```
|
||
|
||
확인한 경계는 다음과 같다.
|
||
|
||
- idempotency owner/state/replay/transition metadata의 persisted shape
|
||
- outbox claim/delivery/index shape
|
||
- integer advisory/row-lock support table와 expiry extension
|
||
- capability schema registry adoption/widening
|
||
- request hash `char`/`varchar` drift 보정
|
||
- durable operation / live-event log schema
|
||
|
||
real PostgreSQL probe에서 Flyway는 vendor 9 migrations를 모두 validate/apply했다. 이번 sub-scope에서 migration 순서, 현재 schema 제약, index 선언 자체로 승격할 신규 defect는 확인하지 못했다. capability-specific migration의 완전한 cross-stream adoption은 각 owning capability scope에서 다시 본다.
|
||
|
||
## 63. Production reachability와 이전 리뷰 대비 변화
|
||
|
||
현재 revision에서 reachability는 동일하게 취급하면 안 된다.
|
||
|
||
| 구현 | 현재 production composition | 판정 의미 |
|
||
|---|---|---|
|
||
| `PostgreSqlExceptionTranslator` | 있음 — vendor failure bean → JPA transaction executor chain | `40003` finding은 production reachable |
|
||
| `PostgreSqlOwnerSafeIdempotencyStore` | 있음 — PostgreSQL idempotency provider config | TTL/replay findings는 production reachable |
|
||
| `PostgreSqlSameStoreInboxAdapter` | 확인 안 됨 | findings는 latent candidate |
|
||
| `PostgreSqlPollingDeliveryAdapter` | 확인 안 됨 | findings는 latent candidate |
|
||
| `PostgreSqlImmutableOutboxAppendAdapter` | 확인 안 됨 | candidate implementation |
|
||
| `PgRangeCodec` | current production consumer 확인 안 됨 | finding은 latent helper |
|
||
|
||
이 구분은 중요하다. 2026-08-14 review에서는 vendor translator와 PostgreSQL idempotency store의 composition 부재가 별도 finding이었다. 이후 commit에서 runtime auto-configuration/provider wiring이 추가되어 그 “미조립” 문제 일부는 해결됐다. 따라서 현재 분석은 과거 finding을 그대로 복사하지 않고 **현재 revision의 wiring 이후 실제 semantics**를 다시 판정했다.
|
||
|
||
history와 prior-review exact-term snapshot은 `evidence/raw/068-postgresql-vendor-history-review-provenance.txt`에 남겼다.
|
||
|
||
## 64. Fresh verification evidence
|
||
|
||
### 64.1 PostgreSQL replay semantic probe
|
||
|
||
`evidence/raw/062-postgresql-replay-semantic-probe.txt`
|
||
|
||
- PostgreSQL 16.15 Testcontainers
|
||
- vendor migration 9개 validate/apply
|
||
- inbox forged-owner replay
|
||
- inbox changed-retention replay
|
||
- outbox changed-nextAttemptAt replay
|
||
|
||
### 64.2 SQLSTATE `40003`
|
||
|
||
`evidence/raw/063-postgresql-40003-probe.txt`
|
||
|
||
- direct translator classification
|
||
- `40003 -> UNKNOWN`
|
||
- `completionUnknown=false`
|
||
|
||
`evidence/raw/069-postgresql-40003-policy-probe.txt`
|
||
|
||
- production translator → `DefaultJpaRetryPolicy`
|
||
- 최종 `decision.disposition=FAIL`
|
||
- expected recovery branch인 `RECONCILE`에 도달하지 못함
|
||
|
||
### 64.3 Range escaped-quote round trip
|
||
|
||
`evidence/raw/065-pg-range-escaped-quote-probe.txt`
|
||
|
||
- current source의 `PgRange`/`PgRangeCodec`만 격리 compile
|
||
- comma는 통과
|
||
- escaped quote / quote+comma / backslash+quote+comma 실패
|
||
|
||
### 64.4 Idempotency real-PostgreSQL TTL boundaries
|
||
|
||
`evidence/raw/066-postgresql-idempotency-replay-boundary-probe.txt`
|
||
|
||
- exact `postgresqlIdempotencyIntegrationTest` lane
|
||
- `complete()` changed replay TTL false-same replay 재현
|
||
- expired COMPLETED row의 `inspect()`/`claim()` lifecycle 불일치 재현
|
||
- temporary test는 실행 후 source에서 복원
|
||
- BUILD SUCCESSFUL
|
||
|
||
### 64.5 Dedicated PostgreSQL unit test full fresh rerun
|
||
|
||
`evidence/raw/067-persistence-jpa-postgresql-unit-tests.txt`
|
||
|
||
9 dedicated test classes를 `--rerun-tasks`로 실행했고 BUILD SUCCESSFUL이다.
|
||
|
||
이 green 결과는 current assertions의 통과를 증명하지만, 위 finding들의 boundary assertions가 기존 suite에 없다는 사실을 해소하지 않는다.
|
||
|
||
## 65. Sub-scope 05 findings backlog
|
||
|
||
| 우선순위 | finding | 현재 reachability |
|
||
|---|---|---|
|
||
| **P1** | SQLSTATE `40003` completion-unknown이 translator에서 `UNKNOWN`으로 강등되어 retry policy가 `RECONCILE` 대신 `FAIL` | **production** |
|
||
| **P1** | 만료된 COMPLETED idempotency row를 `inspect()`는 `COMPLETED_REPLAY`, `claim()`은 takeover 가능으로 동시에 해석 | **production** |
|
||
| **P2** | idempotency `complete()` replay가 changed `replayTtl`을 same-result로 흡수 | **production** |
|
||
| **P2 latent** | inbox `markProcessing()` duplicate replay가 owner mismatch 검증 전에 persisted owner를 반환 | candidate/uncomposed |
|
||
| **P2 latent** | inbox retry/dead replay digest가 retention을 누락 | candidate/uncomposed |
|
||
| **P2 latent** | outbox retry replay digest가 `nextAttemptAt`을 누락 | candidate/uncomposed |
|
||
| **P2 latent** | `PgRangeCodec` escaped quote round-trip 실패 | current production consumer 미확인 |
|
||
|
||
### 이번 scope에서 finding으로 승격하지 않은 항목
|
||
|
||
- registered native write/COPY의 SQL/value boundary
|
||
- work-claim `SKIP LOCKED` 기본 구조
|
||
- structured SQLSTATE/constraint-name 추출
|
||
- array/json helper의 bounded value handling
|
||
- polling outbox cutover sentinel의 transition별 반복 검사 차이: claim 자체가 immutable sentinel을 요구하고 current evidence만으로 stale claim이 cutover를 우회한다고 입증되지 않아 보류
|
||
- vendor migration 9개의 현재 적용 순서/문법
|
||
|
||
## 66. Sub-scope 05 완료 조건
|
||
|
||
확인한 것:
|
||
|
||
- production Java 55 / 55 FULL_READ
|
||
- dedicated unit Java 9 / 9 FULL_READ
|
||
- vendor migration SQL 9 / 9 FULL_READ
|
||
- total 73 / 73 FULL_READ
|
||
- unclassified 0
|
||
- PostgreSQL SQLSTATE/constraint translation
|
||
- current vendor translator production composition
|
||
- owner-safe idempotency implementation + production provider composition
|
||
- same-store inbox / polling-outbox replay semantics + current non-composition 확인
|
||
- registered native write / COPY / work claim
|
||
- JSON / array / range support
|
||
- vendor migration chain
|
||
- prior review 및 key-file history
|
||
- real PostgreSQL replay probes
|
||
- `40003` translator → retry-policy end-to-end policy probe
|
||
- idempotency replay-TTL/expiry integration probes
|
||
- dedicated PostgreSQL unit tests fresh rerun
|
||
- temporary source probes 모두 복원
|
||
|
||
남긴 경계:
|
||
|
||
- baseline capability stores/config/audit/cache/H2 등 나머지 production surface: sub-scope 06
|
||
- fileserver persistence: sub-scope 07
|
||
- notification persistence: sub-scope 08
|
||
- experimental platform: sub-scope 09
|
||
- testkit/fixture: sub-scope 10
|
||
- PostgreSQL integration/readiness source set 75 files의 완전독해: sub-scope 11
|
||
- pool/performance: sub-scope 12
|
||
|
||
따라서 **Sub-scope 05는 COMPLETE**로 닫는다. JPA module 전체는 아직 IN_PROGRESS다.
|
||
|
||
|
||
## 67. Sub-scope 06 범위와 denominator
|
||
|
||
이번 sub-scope는 baseline persistence capability와 그 주변의 configuration/audit/cache/H2/operation/live-event/outbox/security 구현을 소유한다. `evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt`에 고정한 denominator는 다음과 같다.
|
||
|
||
| 구분 | 파일 수 | 판정 |
|
||
|---|---:|---|
|
||
| production Java | 61 | FULL_READ |
|
||
| dedicated unit Java | 19 | FULL_READ |
|
||
| capability migration SQL | 7 | FULL_READ |
|
||
| 합계 | **87** | **87 / 87 FULL_READ** |
|
||
|
||
이번 범위는 `audit`, `auditing`, `cache`, `envers`, `h2`, `idempotency`, `liveevent`, `lock`, `migration`, `observation`, `operation`, `outbox`, `security`와 baseline configuration을 포함한다. PostgreSQL vendor-specific 구현은 sub-scope 05에서 이미 닫았고, complete PostgreSQL integration source set은 sub-scope 11이 소유한다. 여기서는 finding 검증에 필요한 real-PostgreSQL lane만 선택 실행했다.
|
||
|
||
## 68. Baseline composition을 먼저 분리해야 하는 이유
|
||
|
||
`JpaAdapterComponentsConfig`는 adapter 전체를 넓게 scan하지 않고 다음 package만 명시적으로 component scan한다.
|
||
|
||
- `audit`
|
||
- `failure`
|
||
- `idempotency`
|
||
- `lock`
|
||
- `outbox`
|
||
- `transaction`
|
||
|
||
따라서 같은 leaf 안에 있어도 reachability가 다르다.
|
||
|
||
- `OutboxStoreAdapter`는 baseline scan에 들어가고 `app-bootstrap`의 `OutboxConfig`가 `OutboxStorePort`로 사용한다.
|
||
- `DurableOperationStoreAdapter`, `JpaLiveEventReplayAdapter`는 현재 baseline component scan에 들어가지 않고 별도 production constructor/reference도 확인되지 않았다.
|
||
- `HibernateCacheGuard`, `HibernateEnversHistoryReader`와 Spring Data auditing candidate도 default composition에 들어가지 않는다.
|
||
- runtime-role verifier 자체는 app-bootstrap bean으로 구성되지만, policy를 적용하는 `requireSafe()` caller가 없다.
|
||
|
||
이 차이 때문에 아래 finding은 `production`, `conditional-production`, `latent`를 분리해 판정한다. 정적 composition snapshot은 `evidence/raw/072-baseline-capability-reachability.txt`에 남겼다.
|
||
|
||
## 69. P1 — Stable runtime-role verification이 startup에서 실제 policy를 적용하지 않는다
|
||
|
||
문서 계약은 명확하다. `docs/jpa/security.md`는 runtime role이 allowlist 밖이거나 schema/database `CREATE`를 가지면 **startup이 실패한다**고 적고, platform design도 `startup verifier`가 `current_user`, `search_path`, schema privilege를 검사한다고 정의한다. `JpaPlatformAutoConfiguration.capabilities()`도 `RUNTIME_ROLE_VERIFICATION`을 Stable로 광고한다.
|
||
|
||
구현에는 policy가 존재한다.
|
||
|
||
- `PostgreSqlRuntimeRoleVerifier.verify()`는 `current_user`, `search_path`, schema/database CREATE privilege를 읽는다.
|
||
- `DatabaseRolePolicy.requireSafe()`는 approved role, CREATE privilege, `SearchPathPolicy`를 검사한다.
|
||
- `PostgreSqlRuntimeRoleVerifier.requireSafe(dataSource, policy)`는 둘을 연결한다.
|
||
|
||
하지만 production composition에서 이 마지막 경로가 호출되지 않는다. `JpaPlatformRuntimeAutoConfiguration.jpaPlatformStartupCheck()`가 refresh 시 실행하는 것은 `JpaDangerousConfigurationGuard.validate(environment)`뿐이며, runtime database role policy는 받지도 않는다. repository 전체 production source에서 `DatabaseRolePolicy`를 생성하거나 `requireSafe()`를 호출하는 caller 역시 없다. 해당 호출은 unit/integration test에만 존재한다.
|
||
|
||
더 나쁜 점은 actuator semantics다. `JpaPlatformReport.sanitized()`는 `runtimeRoleVerified`를 다음 한 조건으로 계산한다.
|
||
|
||
```text
|
||
privileges != null && !privileges.holdsCreatePrivilege()
|
||
```
|
||
|
||
즉 **wrong role name이나 unsafe `search_path`는 검사하지 않은 채 `runtimeRoleVerified=true`가 될 수 있다.** 문서가 말하는 “role passed verification”과 실제 boolean 의미도 다르다.
|
||
|
||
결과적으로 현재 Stable capability는:
|
||
|
||
```text
|
||
startup fail-fast policy -> 미조립
|
||
actuator verification -> CREATE privilege 일부만 확인
|
||
```
|
||
|
||
상태다.
|
||
|
||
**판정: P1 — production security/runtime-composition contract violation.**
|
||
|
||
필요한 수정 방향은 app-bootstrap이 실제 `DatabaseRolePolicy`/`SearchPathPolicy`를 구성해 startup `InitializingBean`에서 `roleVerifier.requireSafe(dataSource, policy)`를 실행하고, actuator의 `runtimeRoleVerified`도 동일 policy 결과를 기반으로 계산하도록 SSOT를 하나로 만드는 것이다. startup negative composition test는 wrong role, CREATE privilege, unapproved search_path를 각각 포함해야 한다.
|
||
|
||
## 70. P1 conditional-production — baseline outbox는 stale relay worker를 fence하지 못해 terminal state를 되돌릴 수 있다
|
||
|
||
baseline outbox는 현재 composition에 실제 들어간다. `OutboxStoreAdapter`는 `JpaAdapterComponentsConfig`의 `outbox` scan 대상이고, `OutboxConfig`는 `ca-skeleton.outbox.enabled=true`일 때 `OutboxStorePort`를 `PublishPendingOutboxEventsUseCase`에 전달한다. relay까지 켜면 다음 흐름이 된다.
|
||
|
||
```text
|
||
Tx1: claimBatch()
|
||
-> row IN_FLIGHT
|
||
-> next_attempt_at = now + inFlightTimeout
|
||
commit
|
||
|
||
outside transaction: broker publish
|
||
|
||
Tx2: markPublished / markFailed / markDead
|
||
```
|
||
|
||
PostgreSQL claim query의 `FOR UPDATE SKIP LOCKED`는 **동시에 claim하는 순간**만 직렬화한다. timeout이 지나면 `IN_FLIGHT` row도 다시 claim 가능하다. 그런데 baseline entity에는 owner token/claim revision이 없고, `markPublished(eventId)`, `markFailed(eventId, retryAt)`, `markDead(eventId)`는 event id로 row를 다시 읽어 현재 owner/attempt/state를 조건 없이 변경한다.
|
||
|
||
따라서 다음 race가 가능하다.
|
||
|
||
1. worker A가 attempt 1을 claim하고 broker I/O에서 오래 멈춘다.
|
||
2. visibility timeout이 지난 뒤 worker B가 같은 row를 attempt 2로 claim한다.
|
||
3. B가 publish 성공 후 `PUBLISHED`로 mark한다.
|
||
4. 늦게 돌아온 A의 failure path가 `markFailed()`를 호출한다.
|
||
5. 이미 `PUBLISHED`인 row가 `FAILED`로 되돌아가 다시 delivery 대상이 된다.
|
||
|
||
focused probe에서 실제 adapter transition은 다음과 같이 재현됐다.
|
||
|
||
```text
|
||
outboxStaleWorker.before=PUBLISHED
|
||
outboxStaleWorker.after=FAILED
|
||
outboxStaleWorker.retryAt=2026-06-11T10:00:30Z
|
||
```
|
||
|
||
즉 이 문제는 일반적인 at-least-once의 “publish 성공 후 DB mark 실패” window와 별개다. **새 worker가 소유권을 이어받은 뒤에도 stale worker가 새 상태/terminal 상태를 덮어쓸 수 있는 fencing 부재**다.
|
||
|
||
**판정: P1 conditional-production — `app.outbox.enabled` + relay 사용 시 delivery state corruption / duplicate publication risk.**
|
||
|
||
V2 `PostgreSqlPollingDeliveryAdapter`에는 owner-safe transition 개념이 있지만 현재 default composition에 들어오지 않는다. baseline V1을 유지한다면 claim owner/attempt revision을 persisted state에 포함하고 모든 terminal/retry update를 CAS 조건으로 막아야 한다. 최소 regression은 stale attempt가 newer attempt 또는 `PUBLISHED` state를 변경하지 못함을 real PostgreSQL에서 고정해야 한다.
|
||
|
||
Evidence: `evidence/raw/075-outbox-stale-worker-state-regression-output.txt`, `075a-outbox-stale-worker-state-regression-probe.java`.
|
||
|
||
## 71. P1 latent — durable operation은 lease가 만료돼도 takeover 전 stale owner가 완료할 수 있다
|
||
|
||
`DurableOperationJpaRepository`의 주석은 state-changing statement가 owner를 확인하고, lease를 잃은 worker가 결과를 기록하지 못해야 한다는 fencing contract를 설명한다. `heartbeat()`은 실제로 `lease_expires_at > :now`를 조건에 포함한다.
|
||
|
||
반면 `reportProgress()`, `succeed()`, `fail()`은 다음만 확인한다.
|
||
|
||
- operation id
|
||
- state = RUNNING
|
||
- lease owner
|
||
|
||
**lease expiry 자체는 확인하지 않는다.**
|
||
|
||
따라서 takeover가 아직 일어나 owner 문자열이 바뀌지 않은 짧은 window에서는 lease를 이미 잃은 worker가 상태를 확정할 수 있다. real PostgreSQL 16 probe 결과:
|
||
|
||
```text
|
||
durableExpiredLease.completionAt=2026-08-25T09:02:01Z
|
||
durableExpiredLease.leaseExpiredAtCompletion=true
|
||
durableExpiredLease.succeedUpdatedRows=1
|
||
durableExpiredLease.finalState=SUCCEEDED
|
||
```
|
||
|
||
기존 stale-worker test는 새 worker가 takeover해 owner가 이미 달라진 뒤를 검증하므로 이 **expiry-after / takeover-before** 경계를 덮지 않는다.
|
||
|
||
현재 `DurableOperationStoreAdapter`는 baseline component scan 및 다른 production constructor에서 확인되지 않았으므로 즉시 production reachable로 분류하지 않는다.
|
||
|
||
**판정: P1 latent — durable-operation adapter 채택 시 lease fencing contract violation.**
|
||
|
||
수정 시 progress/succeed/fail에도 DB time 기준 `lease_expires_at > now`를 적용하거나 claim revision/fencing token을 도입해야 한다. Evidence: `evidence/raw/073-durable-operation-expired-lease-output.txt`, `073a-durable-operation-expired-lease-probe.java`.
|
||
|
||
## 72. P2 latent — live-event stream이 전부 sweep되면 position high-water mark가 사라져 position 1을 재사용한다
|
||
|
||
`JpaLiveEventReplayAdapter`는 sweep 뒤에도 position을 재사용하지 않아 cursor가 과거 event와 새 event를 혼동하지 않는다고 설명한다. 그러나 append allocator는 `LiveEventJpaRepository.highestEverAssigned(streamId)`를 사용하고, 이 query는 별도 high-water metadata가 아니라 **현재 `live_event_log` row의 `max(position)`**을 계산한다.
|
||
|
||
부분 sweep에서는 마지막 row가 남아 있으므로 문제가 숨는다. stream의 모든 row가 retention sweep으로 삭제되면 `max(position)`은 `null`이 되고 allocator는 다시 1부터 시작한다.
|
||
|
||
real PostgreSQL 16 probe:
|
||
|
||
```text
|
||
liveEventFullSweep.swept=1
|
||
liveEventFullSweep.highestAfterSweep=null
|
||
liveEventFullSweep.nextPosition=1
|
||
liveEventFullSweep.payloadAtReusedPosition=new-event
|
||
```
|
||
|
||
기존 `sweptPositionsAreNotReused()` contract test는 allocator를 호출하지 않고 test fixture가 직접 position을 지정하므로 이 경계를 검증하지 않는다.
|
||
|
||
현재 `JpaLiveEventReplayAdapter` 역시 baseline production composition에서 확인되지 않았다.
|
||
|
||
**판정: P2 latent — live-event adapter 채택 시 monotonic cursor/position invariant violation.**
|
||
|
||
수정은 stream별 durable high-water row/sequence를 sweep 대상과 분리하거나, 삭제되어도 allocation state가 보존되는 구조가 필요하다. Evidence: `evidence/raw/071-liveevent-full-sweep-probe-output.txt`, `071a-liveevent-full-sweep-probe.java`.
|
||
|
||
## 73. 이번 sub-scope에서 finding으로 올리지 않은 항목
|
||
|
||
### 73.1 H2 idempotency와 V2 owner 필드
|
||
|
||
처음에는 `H2IdempotencyClaimRepository`의 MERGE/takeover가 V2 owner/transition field를 초기화하지 않는 점을 의심했다. 그러나 baseline `IdempotencyRecordEntity` 자체가 V1 field만 mapping하고, owner-safe V2는 PostgreSQL capability stream으로 분리돼 현재 별도 activation contract를 가진다. 서로 다른 schema generation의 field를 H2 V1이 reset하지 않는 것은 현 계약 위반이 아니다.
|
||
|
||
### 73.2 `audit`와 `auditing` 두 경로
|
||
|
||
manual `AuditableEntity`/`AuditContextPort` 경로와 Spring Data `AuditMetadata`/`JpaAuditingConfiguration`이 함께 존재하지만 tests/docs가 후자를 candidate/dormant로 명시하고 default composition도 canonical manual audit 경로만 사용한다. 현재 중복 활성화 defect로 판정하지 않는다.
|
||
|
||
### 73.3 cache / Envers
|
||
|
||
`HibernateCacheGuard`, `HibernateEnversHistoryReader`는 Advanced/opt-in surface이며 default bean construction이 없다. 이 sub-scope에서 production mis-wiring으로 올릴 근거는 없었다.
|
||
|
||
## 74. Fresh verification evidence
|
||
|
||
- `evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt` — 87-file exact denominator
|
||
- `evidence/raw/072-baseline-capability-reachability.txt` — baseline package scan, runtime-role policy caller absence, outbox composition, latent adapter construction snapshot
|
||
- `evidence/raw/071-liveevent-full-sweep-probe-output.txt` — real PostgreSQL full-sweep position reuse
|
||
- `evidence/raw/073-durable-operation-expired-lease-output.txt` — real PostgreSQL expired lease completion
|
||
- `evidence/raw/075-outbox-stale-worker-state-regression-output.txt` — baseline outbox terminal-state regression
|
||
- `evidence/raw/076-persistence-jpa-baseline-unit-tests.txt` — fresh full `:adapter:outbound:persistence-jpa:test --rerun-tasks` BUILD SUCCESSFUL
|
||
|
||
세 probe 모두 temporary source replacement를 shell `trap`으로 복원했고 실행 뒤 code repository `git status --short`는 clean이었다.
|
||
|
||
## 75. Sub-scope 06 findings backlog
|
||
|
||
| 우선순위 | finding | 현재 reachability |
|
||
|---|---|---|
|
||
| **P1** | Stable runtime-role verification이 startup에서 `DatabaseRolePolicy`/`SearchPathPolicy`를 적용하지 않고 actuator도 CREATE privilege 일부만으로 verified 판단 | **production** |
|
||
| **P1** | baseline outbox stale worker가 newer/terminal state를 owner fencing 없이 덮어쓸 수 있음 | **conditional-production (`outbox` relay enabled)** |
|
||
| **P1 latent** | durable operation lease 만료 후 takeover 전 stale owner가 progress/succeed/fail 가능 | adapter currently uncomposed |
|
||
| **P2 latent** | live-event full sweep 후 high-water mark 소실로 position 재사용 | adapter currently uncomposed |
|
||
|
||
Sub-scope 06은 **87 / 87 FULL_READ + targeted runtime verification 완료**로 닫는다. 다음 owning unit은 sub-scope 07 Fileserver persistence + migrations 29개다.
|
||
|
||
|
||
## 76. Sub-scope 07 범위와 denominator
|
||
|
||
Fileserver persistence의 owning denominator는 `evidence/raw/077-persistence-jpa-fileserver-manifest.txt`로 고정했다.
|
||
|
||
| 구분 | 파일 수 | 판정 |
|
||
|---|---:|---|
|
||
| production Java | 25 | FULL_READ |
|
||
| Fileserver migration SQL | 4 | FULL_READ |
|
||
| 합계 | **29** | **29 / 29 FULL_READ** |
|
||
|
||
구현 범위는 file/upload/verification/quota/cleanup/recovery entity와 repository, JPA adapters, schema activation, V1~V4 migration을 포함한다. `postgresqlIntegrationTest` source set 자체의 denominator는 sub-scope 11이 소유하지만, 이 sub-scope에서 발견한 Fileserver semantic boundary를 검증하기 위해 해당 real-PostgreSQL lane을 선택적으로 실행했다.
|
||
|
||
## 77. Fileserver composition과 schema lifecycle
|
||
|
||
Fileserver persistence는 latent helper가 아니라 실제 opt-in production capability다.
|
||
|
||
- `PersistenceJpaRootAutoConfiguration`이 `FileserverJpaPersistenceConfig`를 import한다.
|
||
- `app.fileserver-platform.enabled=true`이면 Fileserver entity/repository/component scan이 열린다.
|
||
- `FileserverStorageConfiguration.fileserverSchemaActivation()`은 `JdbcOperations`가 있으면 startup에서 `requireActive()`를 호출한다.
|
||
- 따라서 schema activation, quota, cleanup, recovery adapter는 Fileserver capability가 켜진 배포에서 production-reachable하다.
|
||
|
||
V1은 registry에 `jpa-fileserver-metadata-v1`, `feature_revision=1`, `INSTALLED_INACTIVE`를 기록하고, V2는 recovery schema를 추가한 뒤 revision을 2로 올린다. 이후 V3는 fenced cleanup lease column을, V4는 upload terminal lifecycle column을 추가하지만 registry revision은 더 이상 갱신하지 않는다. 이 차이는 아래 startup fail-open finding의 직접 원인이다.
|
||
|
||
## 78. P1 — persistent byte quota가 실제 admission에서 집행되지 않는다
|
||
|
||
Fileserver 설계와 deviation 문서는 quota를 단순 accounting이 아니라 **scope별 byte enforcement**로 설명한다.
|
||
|
||
- `docs/fileserver/design-deviations.md`는 quota decision이 scope별 `reserved + committed` 합을 사용한다고 명시한다.
|
||
- implementation plan은 DB conditional update로 quota byte를 보호하고, scope limit 초과를 `QuotaExceededException`으로 매핑한다고 정의한다.
|
||
- `DefaultTransferAdmissionController`의 class-level 설명도 “scope over its ceiling”을 `QUOTA_EXCEEDED`라고 표현한다.
|
||
|
||
하지만 production call graph에는 그 ceiling이 없다.
|
||
|
||
1. `DefaultUploadApplicationService.create()`는 namespace를 `QuotaScope`로 만든 뒤 `admissionController.acquireUpload(scope, bytes)`를 호출한다.
|
||
2. admission controller가 검사하는 것은 단일 파일 최대 크기, global storage high-water, JVM-local scope/instance semaphore뿐이다.
|
||
3. 그 다음 `JpaFileQuotaService.reserve()`는 byte aggregate나 limit을 조회하지 않고 `QuotaReservationEntity`를 unconditional `save()`한다.
|
||
4. production source에서 `reservedBytes(scope)` / `committedBytes(scope)` 또는 repository의 aggregate query를 quota decision에 사용하는 caller는 0개다.
|
||
5. `FileserverPlatformSettings.Quota`에도 byte ceiling/tenant capacity가 없고 concurrency permits와 storage high-water만 있다.
|
||
|
||
즉 현재 DB quota ledger는 **사용량 기록**은 하지만 그 사용량을 기반으로 admission을 거절하지 않는다. namespace/tenant가 얼마나 많은 byte를 이미 예약·commit했든, 단일 파일 크기와 global storage high-water/동시성만 통과하면 새 reservation이 생성된다.
|
||
|
||
이는 단순 naming 문제가 아니다. per-scope quota는 multi-tenant resource isolation 경계인데, 현재 구현은 이를 JVM-local concurrent-upload 제한으로 대체하고 있다. 여러 인스턴스 배포에서는 scope semaphore 자체도 instance-local이다.
|
||
|
||
**판정: P1 production cross-scope contract violation — persistent scope/tenant byte quota enforcement missing.**
|
||
|
||
수정 방향은 persistent quota와 transfer concurrency를 분리해야 한다.
|
||
|
||
- explicit scope/tenant byte ceiling policy를 둔다.
|
||
- `reserve/extend`가 `committed + live reserved + delta <= ceiling`을 DB에서 원자적으로 보장해야 한다.
|
||
- 단순 `SUM()` 후 INSERT는 concurrent reservation race가 있으므로 scope별 aggregate row lock/CAS, advisory lock, 또는 동일 수준의 serialized invariant가 필요하다.
|
||
- JVM semaphore는 local concurrency guard로 유지하되 durable byte quota의 대체물이 되어서는 안 된다.
|
||
- regression은 두 인스턴스가 limit 직전에서 동시에 reserve하는 case, expired reservation 제외, committed usage 포함, unknown-length extend, cleanup reclaim을 포함해야 한다.
|
||
|
||
Static evidence: `evidence/raw/079-fileserver-reachability-quota-schema-contract.txt`.
|
||
|
||
## 79. P1 conditional-production — schema activation이 V2를 current schema로 오인한다
|
||
|
||
`FileserverSchemaActivation`의 목적은 주석 그대로 **첫 user request에서 missing relation/column 500이 나기 전에 startup에서 fail closed**하는 것이다. 그러나 현재 gate는 다음만 요구한다.
|
||
|
||
```text
|
||
capability_id = jpa-fileserver-metadata-v1
|
||
core_epoch = 1
|
||
feature_revision >= 2
|
||
lifecycle_state = ACTIVE
|
||
```
|
||
|
||
문제는 current code가 V2보다 뒤의 schema를 필요로 한다는 점이다.
|
||
|
||
- V3: `fs_cleanup_item.claim_owner`, `claim_token`, `lease_until`, `claim_fence`
|
||
- V4: `fs_upload_session.lifecycle_state`
|
||
|
||
그런데 V3/V4는 registry revision을 올리지 않는다. 따라서 V2까지만 적용된 DB를 `ACTIVE`로 promote하면 현재 gate를 통과한다.
|
||
|
||
이를 PostgreSQL 16에서 별도 database로 재현했다. base/core migration 후 Fileserver Flyway를 **target 2**까지만 적용하고 registry를 ACTIVE로 만든 결과:
|
||
|
||
```text
|
||
fileserverSchemaV2.featureRevision=2
|
||
fileserverSchemaV2.activationAccepted=true
|
||
fileserverSchemaV2.cleanupClaimToken=false
|
||
fileserverSchemaV2.uploadLifecycleState=false
|
||
```
|
||
|
||
즉 startup activation은 성공했지만 현재 cleanup/upload repository가 요구하는 V3/V4 column은 존재하지 않았다.
|
||
|
||
이 문제는 Hibernate `validate`가 항상 구해주는 것도 아니다. `JpaDangerousConfigurationGuardTest`가 production에서도 `spring.jpa.hibernate.ddl-auto=none`을 허용하도록 고정하고 있기 때문이다. 이 profile에서는 activation이 사실상 deployment fail-fast gate인데 현재 V2를 허용한다.
|
||
|
||
**판정: P1 conditional-production — Fileserver enabled + schema V2 ACTIVE + `ddl-auto=none`에서 startup fail-open / first-use SQL failure risk.**
|
||
|
||
이미 V3/V4가 배포된 migration history가 있을 수 있으므로 기존 migration 파일의 checksum을 바꾸는 방식은 피해야 한다. 안전한 수선은 새 forward migration에서 current schema revision marker를 올리고 activation이 그 revision 이상을 요구하게 하는 것이다. 그 뒤 V2 ACTIVE database가 startup에서 거부되는 regression을 고정해야 한다.
|
||
|
||
Evidence: `evidence/raw/081-fileserver-schema-activation-v2-output.txt`, `081a-fileserver-schema-activation-v2-probe.java`.
|
||
|
||
## 80. P2 — quota reclaim은 최대 64개 committed row만 처리하고 남은 byte를 조용히 버린다
|
||
|
||
`JpaQuotaReclaimGateway.reclaim(scope, bytes)`는 `findCommittedWithBytes(scope, Limit.of(64))`를 **한 번만** 조회한다. 그 64개 row를 모두 소진한 뒤에도 `outstanding > 0`이면 추가 page/query를 하지 않고 method가 끝난다.
|
||
|
||
real PostgreSQL에서 동일 scope에 1-byte committed row 65개를 만든 뒤 65 bytes reclaim을 요청한 결과:
|
||
|
||
```text
|
||
fileserverQuotaReclaim.before=65
|
||
fileserverQuotaReclaim.requested=65
|
||
fileserverQuotaReclaim.after=1
|
||
```
|
||
|
||
physical delete가 성공한 뒤 cleanup service가 이 gateway를 호출하므로, 64개보다 많은 ledger row에 걸친 reclaim은 실제 사용량보다 committed accounting을 높게 남긴다. 현재 byte quota enforcement가 빠져 있어 즉시 admission rejection으로 이어지지는 않지만, ledger 자체가 quota/reclamation SSOT라는 계약을 위반하고 향후 enforcement가 복구되면 capacity leak로 직결된다.
|
||
|
||
**판정: P2 production accounting correctness defect.**
|
||
|
||
수정은 outstanding이 0이 될 때까지 bounded page를 반복하되 forward progress를 보장하거나, scope aggregate usage를 별도 row로 유지해 reclaim을 O(1) CAS로 만드는 편이 낫다. “최대 64개만 처리”를 의도한 batch boundary라면 caller가 remainder를 재-enqueue해야 하지만 현재 그런 contract는 없다.
|
||
|
||
Evidence: `evidence/raw/078-fileserver-quota-boundary-probe-output.txt`, `078a-fileserver-quota-boundary-probe.java`.
|
||
|
||
## 81. P2 — direct `FileQuotaService.commit()`은 만료 reservation을 commit한다
|
||
|
||
`JpaFileQuotaService`의 own Javadoc은 “already expired or released reservation can never be extended or committed”라고 명시한다. `extend()` query는 실제로 `expiresAt > now`를 조건으로 둔다.
|
||
|
||
반면 `FileserverQuotaRepository.commit()`은 `status='RESERVED'`만 확인하고 expiry predicate가 없다. real PostgreSQL에서 reservation의 `expires_at`을 과거로 이동한 뒤 public `FileQuotaService.commit()`을 호출하면:
|
||
|
||
```text
|
||
fileserverExpiredQuota.status=COMMITTED
|
||
fileserverExpiredQuota.committedBytes=600
|
||
```
|
||
|
||
으로 전환됐다.
|
||
|
||
여기서는 수정 경계를 주의해야 한다. 별도 `JpaQuotaCommitGateway`는 upload가 TTL보다 오래 걸렸더라도 실제 durable byte를 under-count하지 않기 위해 expired upload usage를 기록하는 의도적 path를 가진다. 따라서 shared repository `commit()`에 무조건 expiry predicate를 추가하면 그 settlement contract까지 깨질 수 있다.
|
||
|
||
**판정: P2 production API-contract defect.**
|
||
|
||
수정은 “live reservation direct commit”과 “expired upload durable usage settlement”를 별도 SQL/API로 분리해 전자는 expiry를 엄격히 거부하고 후자는 명시적 recovery/settlement 의미로 유지해야 한다.
|
||
|
||
Evidence: `evidence/raw/078-fileserver-quota-boundary-probe-output.txt`.
|
||
|
||
## 82. P2 — recovery queue의 `enqueue()`는 concurrent upsert가 아니다
|
||
|
||
`JpaRecoveryQueue`는 Javadoc에서 enqueue를 upsert라고 정의하고 “same file reported twice updates the open item rather than adding a second one”이라고 설명한다. 구현은:
|
||
|
||
```text
|
||
UPDATE existing PENDING
|
||
if updated == 0:
|
||
INSERT new PENDING
|
||
```
|
||
|
||
이고 DB에는 `WHERE status='PENDING'` partial unique index가 있다. 최초 item이 없는 상태에서 두 transaction이 동시에 들어오면 둘 다 UPDATE 0을 보고 INSERT로 진행할 수 있다. unique index는 duplicate row는 막지만 loser transaction을 정상 upsert로 흡수하지는 않는다.
|
||
|
||
real PostgreSQL concurrent probe 결과:
|
||
|
||
```text
|
||
fileserverRecovery.concurrentFailures=1
|
||
fileserverRecovery.first=SUCCESS
|
||
fileserverRecovery.second=org.springframework.dao.DataIntegrityViolationException
|
||
fileserverRecovery.rowCount=1
|
||
```
|
||
|
||
recovery enqueue는 finalize의 ambiguous commit path와 reconciliation worker 양쪽에서 production 호출되므로 동일 file에 대한 동시 report가 가능한 seam이다. 한 row만 남는 DB invariant는 지켜지지만 “enqueue request가 durable work item으로 합쳐진다”는 adapter contract 대신 caller 하나가 persistence exception을 받는다.
|
||
|
||
**판정: P2 production concurrency/idempotency defect.**
|
||
|
||
PostgreSQL native upsert가 partial unique predicate와 동일 semantics를 갖도록 구성하거나, insert unique conflict를 잡아 bounded update retry로 수렴시켜야 한다. regression은 barrier를 둔 two-transaction 최초 enqueue에서 둘 다 성공하고 open row는 1개임을 검증해야 한다.
|
||
|
||
Evidence: `evidence/raw/080-fileserver-recovery-concurrent-enqueue-output.txt`, `080a-fileserver-recovery-concurrent-enqueue-probe.java`.
|
||
|
||
## 82.1. P2 — cleanup crash-reclaim은 `MAXIMUM_ATTEMPTS`를 우회해 poison item을 무한 재시도할 수 있다
|
||
|
||
`JpaCleanupQueue`는 `MAXIMUM_ATTEMPTS = 8`을 두고, 정상적인 `markFailed()` 경로에서는 `item.attempt() + 1 >= 8`이면 `ABANDONED`로 전환한다. class Javadoc도 반복 실패한 poison item을 계속 재시도하지 않는 것이 이 queue의 명시적 계약이라고 설명한다.
|
||
|
||
그러나 worker crash는 다른 경로를 탄다. `DefaultCleanupService.runBatch()`는 매 batch 시작 시 `reclaimExpiredClaims()`를 먼저 호출하고, `FileserverCleanupRepository.reclaimExpiredClaim()`은 expired `IN_PROGRESS` row를 항상 다음 상태로 되돌린다.
|
||
|
||
```text
|
||
status = FAILED
|
||
attempt = attempt + 1
|
||
last_error_code = CLAIM_LEASE_EXPIRED
|
||
claim fields = null
|
||
```
|
||
|
||
여기에는 `MAXIMUM_ATTEMPTS` 또는 현재 `attempt`에 대한 terminal 조건이 없다. 따라서 worker가 physical cleanup 중 계속 crash하면 정상 실패 budget을 거치지 않고 lease expiry → reclaim → claim → crash를 반복할 수 있다.
|
||
|
||
real PostgreSQL에서 claim 후 settlement 없이 lease expiry만 9회 반복한 결과:
|
||
|
||
```text
|
||
fileserverCleanupCrash.maxAttempts=8
|
||
fileserverCleanupCrash.actualAttempt=9
|
||
fileserverCleanupCrash.status=FAILED
|
||
fileserverCleanupCrash.lastError=CLAIM_LEASE_EXPIRED
|
||
```
|
||
|
||
즉 명시된 최대 8회를 넘겼는데도 row는 `ABANDONED`가 아니라 다시 claim 가능한 `FAILED`로 남았다. 이는 cleanup queue의 poison-item bounded retry 계약을 깨고, 반복적으로 crash를 유발하는 cleanup item이 scheduler capacity를 계속 소비하게 만든다.
|
||
|
||
**판정: P2 production liveness / bounded-retry defect.**
|
||
|
||
수정은 crash-reclaim과 normal failure가 동일한 attempt budget을 공유하게 해야 한다. `reclaimExpiredClaim()`에서 증가 후 attempt가 limit에 도달하면 `ABANDONED`로 전환하거나, repository가 next-state를 caller로부터 받되 DB CAS가 token과 attempt를 함께 검증하도록 구성할 수 있다. regression은 normal failure와 crash-reclaim을 섞어도 총 attempt budget을 넘으면 반드시 terminal `ABANDONED`가 되는지 고정해야 한다.
|
||
|
||
Evidence: `evidence/raw/079-fileserver-cleanup-crash-budget-output.txt`, `079a-fileserver-cleanup-crash-budget-probe.java`.
|
||
|
||
## 83. 이번 sub-scope에서 finding으로 올리지 않은 항목
|
||
|
||
### 83.1 quota FIFO settlement 자체
|
||
|
||
reservation row가 upload id와 연결되지 않아 `JpaQuotaCommitGateway`가 scope의 가장 오래된 live reservation부터 정산하는 것은 `docs/fileserver/design-deviations.md`에 명시적으로 기록된 adaptation이다. row identity와 실제 upload identity가 1:1이 아닌 것 자체는 현재 설계 계약이다. 다만 그 문서가 전제로 둔 aggregate byte enforcement가 실제로 없다는 점은 §78의 별도 P1 finding으로 올렸다.
|
||
|
||
### 83.2 cleanup fenced lease의 expiry-after / takeover-before window
|
||
|
||
cleanup settlement query는 claim token을 fence하고 reaper takeover가 token을 교체한다. lease expiry 직후 아직 takeover 전인 worker가 settle할 수 있는 window는 보이지만, 새 owner가 생긴 뒤 stale worker가 상태를 덮어쓰는 race는 token CAS가 막는다. durable-operation과 달리 현재 계약만으로 “expiry 순간부터 절대 settle 금지”라고 확정할 충분한 근거가 없어 finding으로 올리지 않았다.
|
||
|
||
### 83.3 과거 JPA-028 cleanup fencing finding
|
||
|
||
이전 review의 Fileserver cleanup owner/token/terminal-state 부재는 V3/V4와 현재 repository code에서 실제로 보완돼 있다. 이번 분석은 그 과거 finding을 중복 집계하지 않는다.
|
||
|
||
## 84. Fresh Fileserver verification evidence
|
||
|
||
- `evidence/raw/077-persistence-jpa-fileserver-manifest.txt` — 25 production Java + 4 migration SQL, **29 / 29 FULL_READ**
|
||
- `evidence/raw/079-fileserver-reachability-quota-schema-contract.txt` — production quota readers/ceiling absence, admission logic, schema revision/activation snapshot
|
||
- `evidence/raw/078-fileserver-quota-boundary-probe-output.txt` — real PostgreSQL expired commit + 65-row reclaim truncation
|
||
- `evidence/raw/080-fileserver-recovery-concurrent-enqueue-output.txt` — real PostgreSQL concurrent recovery enqueue race
|
||
- `evidence/raw/081-fileserver-schema-activation-v2-output.txt` — real PostgreSQL V2-only ACTIVE activation acceptance
|
||
- `evidence/raw/082-fileserver-official-readiness-lanes.txt` — original-source Fileserver migration/metadata/reclamation no-skip lanes, fresh `--rerun-tasks`, BUILD SUCCESSFUL, 21/21 tasks executed, git clean before/after
|
||
- `evidence/raw/079-fileserver-cleanup-crash-budget-output.txt`, `079a-fileserver-cleanup-crash-budget-probe.java` — real PostgreSQL에서 crash-reclaim만으로 attempt 9 / `FAILED`를 재현해 8회 poison budget 우회를 확인
|
||
|
||
기존 temporary probe source는 실행 후 원본으로 복구했다. cleanup crash-budget probe는 Gradle test 자체는 `BUILD SUCCESSFUL` / exit 0이었지만 wrapper의 restore trap이 `cd src` 뒤 상대경로를 사용해 복원 단계만 실패했다. 직전 clean snapshot의 exact `HEAD` blob을 해당 analysis-owned test file 하나에 다시 기록한 뒤 worktree hash와 `HEAD` hash가 동일함을 확인했고, 최종 `git status --short`는 clean이었다.
|
||
|
||
## 85. Sub-scope 07 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| **P1** | persistent `reserved + committed` byte quota를 실제 admission에서 읽거나 ceiling과 비교하는 경로가 없음 | **production when Fileserver enabled** |
|
||
| **P1** | schema activation이 revision 2 ACTIVE를 허용하지만 current code는 V3/V4 columns를 요구 | **conditional-production; especially `ddl-auto=none`** |
|
||
| **P2** | reclaim이 64 committed rows 이후 remainder를 처리하지 않아 usage accounting이 남음 | **production** |
|
||
| **P2** | public direct quota commit이 expired RESERVED row를 COMMITTED로 전환 | **production** |
|
||
| **P2** | concurrent first recovery enqueue 중 한 transaction이 unique violation으로 실패 | **production** |
|
||
| **P2** | cleanup crash-reclaim이 `MAXIMUM_ATTEMPTS=8`을 적용하지 않아 attempt 9+도 `FAILED`로 재활성화됨 | **production** |
|
||
|
||
Sub-scope 07은 **29 / 29 FULL_READ + targeted real-PostgreSQL boundary verification 완료**로 닫는다. 다음 owning unit은 sub-scope 08 Notification persistence + migrations 68개다.
|
||
|
||
|
||
## 86. Sub-scope 08 범위와 denominator
|
||
|
||
Notification persistence의 owning denominator는 `evidence/raw/083-persistence-jpa-notification-manifest.txt`로 고정했다.
|
||
|
||
| 구분 | 파일 수 | 판정 |
|
||
|---|---:|---|
|
||
| production Java | 53 | FULL_READ |
|
||
| Notification migration SQL | 10 | FULL_READ |
|
||
| dedicated unit Java | 5 | FULL_READ |
|
||
| 합계 | **68** | **68 / 68 FULL_READ** |
|
||
|
||
범위는 notification schema activation/facade, payload/contact-point crypto, request/recipient/attempt/policy/admin/reconciliation/provider-event/inbox entities·repositories·stores, V1~V10 opt-in migration을 포함한다. `postgresqlIntegrationTest` notification classes는 sub-scope 11 denominator에 남겨 두되, 이번 finding의 DB semantics를 검증하기 위해 기존 contract/readiness lane과 별도 PostgreSQL probe를 선택적으로 실행했다.
|
||
|
||
## 87. Notification composition과 schema lifecycle
|
||
|
||
Notification JPA capability는 production opt-in path로 실제 composition된다.
|
||
|
||
- `PersistenceJpaRootAutoConfiguration`이 `NotificationJpaPersistenceFacade`를 import한다.
|
||
- facade가 `NotificationJpaPersistenceConfig`를 import하고 entity/repository/store bean을 조립한다.
|
||
- application-side worker/config가 recipient lease, reconciliation, provider-event ledger, admin operation store를 실제 소비한다.
|
||
- `NotificationSchemaActivation`은 capability registry를 읽어 startup activation을 검사한다.
|
||
|
||
schema stream은 V1~V10까지 진화했지만 registry는 V4에서 `jpa-notification-platform-v4`, `feature_revision=4`, `INSTALLED_INACTIVE`를 기록한 뒤 더 이상 revision을 올리지 않는다. 반면 current Java mapping과 SQL은 V5~V10에서 추가된 column/constraint에 실제 의존한다. 이 drift가 §88의 startup false-positive를 만든다.
|
||
|
||
## 88. P1 conditional-production — V4 ACTIVE schema가 current V10-compatible schema로 오인된다
|
||
|
||
`NotificationSchemaActivation`은 다음 조건이면 capability를 active로 인정한다.
|
||
|
||
```text
|
||
capability_id = jpa-notification-platform-v4
|
||
core_epoch = 1
|
||
feature_revision >= 4
|
||
lifecycle_state = ACTIVE
|
||
```
|
||
|
||
하지만 current code는 revision 4 이후 migration을 요구한다. 대표적으로:
|
||
|
||
- V5: recipient `expires_at`
|
||
- V6: delivery-attempt projection facts/version + suppression side-effect claim state
|
||
- V7: request collapse fields
|
||
- V8: admin `command_fingerprint`, `phase`, `claimed_at`
|
||
- V9: provider execution-evidence certainty fields
|
||
- V10: protected payload envelope constraint
|
||
|
||
V5~V10 어느 migration도 capability registry revision을 5 이상으로 올리지 않는다.
|
||
|
||
PostgreSQL 16에서 core V1/V2 + Notification V1~V4만 적용하고 registry row를 ACTIVE로 promote한 뒤 activation SQL을 그대로 실행했다.
|
||
|
||
```text
|
||
activation_count = 1
|
||
feature_revision = 4
|
||
lifecycle_state = ACTIVE
|
||
```
|
||
|
||
동시에 current code가 요구하는 column 존재 여부는 다음과 같았다.
|
||
|
||
```text
|
||
notification_recipient_delivery.expires_at = false
|
||
notification_delivery_attempt.projection_version = false
|
||
notification_request.collapse_key = false
|
||
notification_admin_audit.phase = false
|
||
notification_delivery_attempt.request_started_certainty = false
|
||
```
|
||
|
||
즉 startup gate는 성공하지만 first-use 시 current repository/entity SQL과 DB schema가 맞지 않을 수 있다. 기존 `postgresqlNotificationSchemaActivationIntegrationTest`는 fresh rerun으로 green이지만, 그 green은 현재 gate가 정의한 V4 lifecycle을 검증할 뿐 V10 mapping compatibility를 증명하지 않는다.
|
||
|
||
**판정: P1 conditional-production schema fail-open.** Notification capability가 켜진 상태에서 V4까지만 적용된 DB가 ACTIVE라면 startup이 current schema 부재를 잡지 못한다.
|
||
|
||
기존 V1~V10 migration checksum을 수정하기보다 새 forward migration에서 current schema revision을 명시적으로 올리고 activation이 그 revision 이상을 요구하게 하는 편이 안전하다. regression은 V4 ACTIVE를 반드시 거부하고 current revision만 허용해야 한다.
|
||
|
||
Evidence: `evidence/raw/084-notification-schema-v4-activation-probe.txt`, `089-notification-schema-official-readiness.txt`.
|
||
|
||
## 89. P1 — provider 호출 뒤 recipient projection write가 lease fencing을 우회한다
|
||
|
||
recipient lease 설계 자체는 owner + monotonic fence를 갖는다. claim은 `lease_fence`를 증가시키고 `stillHeld()` / `renewLease()`는 `lease_until > now`까지 검사한다. 문제는 **provider side effect 이후 결과를 저장하는 실제 production path**다.
|
||
|
||
`NotificationDispatchService.dispatch()` 흐름은 다음이다.
|
||
|
||
1. provider call 직전에 `leases.stillHeld(lease)`를 확인한다.
|
||
2. provider call은 transaction 밖에서 실행한다.
|
||
3. call이 돌아오면 `DispatchOutcomeRecorder.record(...)`를 write transaction에서 실행한다.
|
||
4. recorder는 attempt result를 저장한 뒤 recipient projection에 **`recipients.save(updated)`**를 호출한다.
|
||
5. 이 `save()`는 row를 현재 시점에 ID로 다시 읽고 projection field를 변경할 뿐 owner/fence/expiry를 검증하지 않는다.
|
||
|
||
따라서 lease가 provider call 도중 만료되거나 다른 worker가 takeover해도 stale caller의 결과 write가 자동으로 거부되지 않는다.
|
||
|
||
실제 fenced helper도 완전하지 않다. `saveProjectionHeldBy()` / `transitionHeldBy()`는 `id + lease_owner + lease_fence`만 조건으로 두고 `lease_until > now`는 확인하지 않는다. PostgreSQL에서 이미 만료되어 `stillHeld` 조건이 0건인 row에 동일 owner/fence write를 실행하면 `UPDATE 1`이었다.
|
||
|
||
더 강한 takeover case도 재현했다.
|
||
|
||
```text
|
||
# worker B가 takeover한 직후
|
||
state=DISPATCHING, owner=worker-b, fence=8, version=1
|
||
|
||
# stale worker A의 recorder/save와 동등한 ID-only projection write 뒤
|
||
state=RECONCILIATION_REQUIRED, owner=worker-b, fence=8, version=2
|
||
```
|
||
|
||
즉 새 owner의 lease identity는 그대로인데 **이전 provider call의 stale outcome이 recipient state를 덮을 수 있다.** 이는 fencing token을 둔 목적과 직접 충돌한다.
|
||
|
||
**판정: P1 production concurrency/correctness defect.** provider side effect와 authoritative outcome write 사이의 lease handoff에서 stale writer가 살아남는다. 결과에 따라 중복 전송 위험 판단, retry/reconciliation state, attempt count가 새 holder의 흐름과 충돌할 수 있다.
|
||
|
||
수정은 provider completion 이후의 authoritative recipient mutation을 반드시 `RecipientLease`에 결박해야 한다. 최소한 `owner + fence + lease_until > completedAt/now`를 하나의 conditional write에서 검증하고, 0-row update는 superseded result로 처리해야 한다. recorder가 일반 `save()`를 호출하는 구조도 제거하거나 lease-aware recorder API로 바꿔야 한다.
|
||
|
||
Evidence: `evidence/raw/085-notification-expired-lease-write-probe.txt`, `091-notification-stale-provider-overwrite-probe.txt`, `092-notification-reachability-test-gap.txt`.
|
||
|
||
## 90. P2 — reconciliation `FOR UPDATE SKIP LOCKED`는 worker 처리 구간을 claim하지 않는다
|
||
|
||
`JdbcReconciliationJobStore.claimDue()`는 due row를 다음 SQL로 읽는다.
|
||
|
||
```text
|
||
SELECT ...
|
||
FROM notification_reconciliation_job
|
||
WHERE next_check_at <= ?
|
||
ORDER BY next_check_at, id
|
||
LIMIT ?
|
||
FOR UPDATE SKIP LOCKED
|
||
```
|
||
|
||
그러나 이 method는 별도 transaction boundary를 열지 않고 durable `owner/status/lease`도 기록하지 않는다. `ReconciliationJobWorker.reconcileOnce()`도 `claimDue()` 뒤 provider reconciliation을 수행한 다음에야 `complete()` 또는 `reschedule()`을 호출하며 전체 구간을 감싸는 `TransactionPort`/`@Transactional`이 없다.
|
||
|
||
따라서 normal JdbcTemplate autocommit에서는 SELECT가 반환되는 순간 row lock이 풀린다. PostgreSQL에서 worker A의 claim SELECT가 끝난 뒤 A가 아직 complete/reschedule하지 않은 상태를 유지하고 worker B가 같은 SQL을 실행하자 두 호출 모두 같은 job을 반환했다.
|
||
|
||
```text
|
||
worker A -> job 5555... attempts=0
|
||
worker B -> job 5555... attempts=0
|
||
row state -> next_check_at unchanged, attempts=0, last_result=null
|
||
```
|
||
|
||
`SKIP LOCKED` 자체가 잘못된 것이 아니라 **lock lifetime과 work lifetime이 다르다.** 현재 형태는 동시에 SELECT statement를 실행하는 아주 짧은 순간만 중복 read를 피하고 provider 조회/정산 중복을 막지 못한다.
|
||
|
||
**판정: P2 production multi-instance coordination defect.** reconciliation은 send 자체가 아니라 provider 상태 조회/상태 projection이어서 recipient dispatch P1보다 영향도를 낮게 잡지만, 두 worker가 같은 job을 처리할 수 있다는 class contract는 깨진다.
|
||
|
||
수정은 delivery claim처럼 durable owner/fence/lease를 기록하는 short claim transaction을 두거나, 전체 processing을 DB lock transaction 안에 두어야 한다. 외부 provider call을 긴 DB transaction에 넣는 것은 피하는 편이 좋으므로 전자가 더 적합하다.
|
||
|
||
Evidence: `evidence/raw/086-notification-reconciliation-claim-probe.txt`, `092-notification-reachability-test-gap.txt`.
|
||
|
||
## 91. P2 — V8 atomic admin claim은 production service에 연결되지 않았고 completion 모델도 미완성이다
|
||
|
||
V8 migration과 `AdminOperationStorePort.claim()`의 설명은 문제를 정확히 알고 있다. 기존 `find -> act -> save` 구조에서는 두 caller가 모두 빈 상태를 읽고 같은 operation id의 action을 실행할 수 있으므로 `INSERT ... ON CONFLICT DO NOTHING`으로 먼저 claim해야 한다는 설계다.
|
||
|
||
하지만 current production call graph에서 `operations.claim(...)` 호출은 **0개**다. `NotificationAdminApplicationService`의 redrive/reconcile/suppress/provider-state 경로는 여전히 모두:
|
||
|
||
```text
|
||
findByOperationId(operationId)
|
||
... action ...
|
||
operations.save(...)
|
||
```
|
||
|
||
를 사용한다.
|
||
|
||
게다가 현재 persistence completion model은 claim API를 단순히 연결하는 것만으로 끝나지 않는다. `claim()`은 `notification_admin_audit`에 `phase='CLAIMED'` row를 먼저 INSERT하지만, `JpaAdminOperationStore.save()`는 그 row를 update-to-COMPLETED하지 않고 동일 `operation_id`의 새 entity를 INSERT한다. PostgreSQL probe에서 claim 성공 후 현재 save 방식과 동등한 두 번째 INSERT는 unique violation이 났고 기존 row는 계속 `CLAIMED`였다.
|
||
|
||
```text
|
||
claim -> INSERT 1, phase=CLAIMED
|
||
save-style completion -> unique_violation
|
||
final -> phase=CLAIMED
|
||
```
|
||
|
||
기존 `AdminOperationClaimContractTest`는 atomic claim primitive 자체는 검증하고 fresh rerun도 green이지만, production service가 이를 쓰는지와 claim→completion lifecycle은 검증하지 않는다.
|
||
|
||
**판정: P2 production idempotency/wiring defect.** V8에서 만든 fix가 dead path이며 completion state machine도 이어지지 않는다. DB transaction 안에서 수행되는 redrive/suppress 일부 경로는 마지막 unique conflict가 loser transaction을 rollback시켜 결과를 완화하지만, reconcile/provider runtime control처럼 action과 final audit insert가 하나의 동일 DB transaction으로 묶이지 않는 경로까지 전체적으로 exactly-once operation claim을 보장하지 못한다.
|
||
|
||
수정은 service entry에서 command fingerprint와 함께 atomic claim을 먼저 수행하고, owner가 아니면 CLAIMED/COMPLETED 상태를 명시적으로 해석해야 한다. winner는 동일 row를 `COMPLETED`로 update하면서 result snapshot을 저장해야 하며, 별도 duplicate INSERT로 완료해서는 안 된다.
|
||
|
||
Evidence: `evidence/raw/087-notification-admin-claim-completion-probe.txt`, `088-notification-admin-claim-reachability.txt`, `092-notification-reachability-test-gap.txt`.
|
||
|
||
## 92. 이번 sub-scope에서 finding으로 올리지 않은 항목
|
||
|
||
### 92.1 provider-event replay의 중복 scan 자체
|
||
|
||
`ProviderEventReplayWorker`도 unmatched/pending event를 durable lease 없이 scan할 수 있지만, projection write는 transaction 안에서 수행되고 ledger의 applied transition과 suppression side-effect에는 별도 conditional claim이 존재한다. 동일 event가 두 worker에 보일 가능성만으로 중복 external side effect까지 현재 evidence에서 확정할 수 없어 이번 backlog에는 올리지 않았다.
|
||
|
||
### 92.2 crypto envelope와 contact-point secret protection
|
||
|
||
request variable payload는 `NotificationPayloadProtection`을 필수 collaborator로 받아 보호된 envelope를 저장하고, contact point는 ciphertext/nonce/lookup HMAC/key id로 분리된다. V10은 plaintext-looking request envelope를 DB constraint로도 거부한다. 이번 완독에서 이 경계 자체를 우회하는 production write path는 확인하지 못했다.
|
||
|
||
### 92.3 tenant-bound repository guard
|
||
|
||
tenant-sensitive lookup이 전부 완전하다고 corpus 전체 결론을 내리지는 않았지만, `TenantBoundRepositoryGuard`와 tenant-qualified repository method가 존재하고 이번 68-file owning scope에서 즉시 재현 가능한 cross-tenant bypass는 확정하지 못했다. 별도 inbound/application authorization 조합은 cross-scope 단계가 소유한다.
|
||
|
||
## 93. Fresh Notification verification evidence
|
||
|
||
- `evidence/raw/083-persistence-jpa-notification-manifest.txt` — 53 production Java + 10 migration SQL + 5 dedicated unit Java, **68 / 68 FULL_READ**
|
||
- `evidence/raw/084-notification-schema-v4-activation-probe.txt` — PostgreSQL 16에서 V4 ACTIVE activation은 통과하지만 current-required V5~V9 columns 5개가 모두 absent
|
||
- `evidence/raw/085-notification-expired-lease-write-probe.txt` — `stillHeld=0`인 expired lease의 owner+fence write가 `UPDATE 1`
|
||
- `evidence/raw/091-notification-stale-provider-overwrite-probe.txt` — replacement holder B/fence 8 이후 stale result의 ID-only projection write가 새 lease를 보존한 채 lifecycle을 overwrite
|
||
- `evidence/raw/086-notification-reconciliation-claim-probe.txt` — autocommit SKIP LOCKED를 worker A/B가 순차 실행해 동일 reconciliation job을 둘 다 획득
|
||
- `evidence/raw/087-notification-admin-claim-completion-probe.txt` — atomic claim 뒤 current save-style second insert가 unique violation, row는 CLAIMED 유지
|
||
- `evidence/raw/088-notification-admin-claim-reachability.txt`, `092-notification-reachability-test-gap.txt` — production admin claim caller 0, dispatch/reconciliation actual call graph와 predicate snapshot
|
||
- `evidence/raw/089-notification-schema-official-readiness.txt` — original-source `postgresqlNotificationSchemaActivationIntegrationTest --rerun-tasks`, **BUILD SUCCESSFUL in 40s, 19/19 tasks executed**
|
||
- `evidence/raw/090-notification-existing-contracts-fresh.txt` — original `RecipientClaimContractTest` + `AdminOperationClaimContractTest`, fresh `jpaPlatformContractTest --rerun-tasks`, **BUILD SUCCESSFUL in 31s, 19/19 tasks executed**
|
||
|
||
모든 신규 semantic probe는 임시 PostgreSQL container와 repository의 existing migration/source를 읽어 실행했으며 source file을 수정하지 않았다. 최종 code repository `git status --short`는 clean이다.
|
||
|
||
## 94. Sub-scope 08 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| **P1** | schema activation이 revision 4 ACTIVE를 current-compatible로 인정하지만 code는 V5~V10 schema를 요구 | **conditional-production when Notification JPA enabled** |
|
||
| **P1** | provider call 결과 recorder가 lease-unaware `recipients.save()`를 사용해 expired/replaced holder의 stale projection이 새 holder state를 덮을 수 있음 | **production dispatch** |
|
||
| **P2** | reconciliation `FOR UPDATE SKIP LOCKED` lock이 SELECT 종료와 함께 풀려 처리 중 동일 job을 다른 worker가 재claim 가능 | **production multi-instance worker** |
|
||
| **P2** | V8 atomic admin claim API가 production caller 0이고 claim row를 current `save()`로 완료할 수도 없음 | **production admin operations** |
|
||
|
||
Sub-scope 08은 **68 / 68 FULL_READ + targeted real-PostgreSQL verification + original contract/readiness fresh rerun 완료**로 닫는다. 다음 owning unit은 sub-scope 09 Experimental platform 38개다.
|
||
|
||
|
||
## 95. Sub-scope 09 범위와 denominator
|
||
|
||
Sub-scope 09는 `experimental/**`가 소유하는 multi-tenancy, RLS, schema/database-per-tenant, read-replica routing, next-version compatibility/promotion surface를 분석한다.
|
||
|
||
Owning denominator는 **38개**다.
|
||
|
||
- production Java: **29**
|
||
- dedicated unit Java: **8**
|
||
- experimental RLS migration SQL: **1**
|
||
|
||
여기에 실제 PostgreSQL 의미를 확인하기 위해 `postgresqlIntegrationTest/.../platform/experimental`의 contract 5개를 verification dependency로 추가 완독했다. 따라서 이번 실행에서 읽은 파일은 **38 / 38 owning FULL_READ + 5 / 5 verification dependency FULL_READ = 43개**다. 이전 checkpoint의 “38개”와 현재 tree inventory가 처음에는 어긋나 보였지만, 차이는 이 5개 PostgreSQL contract를 owning denominator가 아니라 검증 의존성으로 분리한 데서 나온다.
|
||
|
||
Evidence: `evidence/raw/093-persistence-jpa-experimental-manifest.txt`.
|
||
|
||
## 96. 현재 production composition은 Experimental을 실행하지 않지만 opt-in 경계는 완전히 구조적이지 않다
|
||
|
||
현재 repository 내부 production call graph에서는 `TenantDataSourceRegistry`, `TenantEntityManagerFactoryRegistry`, `SchemaMultiTenantConnectionProvider`, `ConsistencyAwareDataSourceRouter`, `RlsTenantSessionBinder`, `SchemaTenantMigrationOrchestrator` 등을 app-bootstrap이나 다른 production leaf가 조립하는 경로를 찾지 못했다. `backend.jpa.experimental.*` property도 production configuration에서 읽어 bean을 만드는 경로가 없고, 실제 문자열은 `ExperimentalFeature` enum의 property vocabulary에만 존재한다.
|
||
|
||
따라서 아래 semantic finding은 **현재 app-bootstrap runtime에서 즉시 활성화된 production defect가 아니라 latent experimental defect**로 분류한다. 이 구분은 중요하다. public API surface에 올라 있고 같은 artifact에 포함된 library code가 잘못된 것과, 현재 기본 애플리케이션이 그 code를 실제 실행하는 것은 다른 주장이다.
|
||
|
||
반면 structural opt-in은 완전히 닫혀 있지 않다. `PersistenceJpaConfig`의 Stable `@EntityScan`과 `@EnableJpaRepositories` 문자열 목록에는 이미 `dev.caskeleton.adapter.outbound.persistence.experimental`이 들어 있다. 현재 experimental package에는 `@Entity`, `@Repository`, `JpaRepository`, `@MappedSuperclass`가 없어서 당장 persistence unit에 들어오는 concrete JPA type은 없지만, 이후 experimental entity/repository 하나가 추가되면 별도 feature condition 없이 Stable persistence unit이 스캔한다.
|
||
|
||
Evidence: `evidence/raw/096-experimental-gate-reachability.txt`, `099-experimental-structural-optin-gap.txt`.
|
||
|
||
## 97. P1 latent — RLS verifier가 “반드시 보호돼야 하는 table”의 부재를 성공으로 인정한다
|
||
|
||
`RlsPolicyVerifier.requireEnforced(runtimeDataSource, tenantScopedTables)`의 이름과 Javadoc은 caller가 지정한 tenant-scoped table들이 실제로 RLS에 의해 보호되는지 증명하는 contract다. 구현은 runtime role의 `BYPASSRLS`를 확인하고, `current_schema()`의 실제 table들을 순회하면서 이름이 `tenantScopedTables`에 포함된 row만 검사한다.
|
||
|
||
문제는 반대 방향 검증이 없다는 것이다. 즉 caller가 요구한 table 이름이 실제 catalog 결과에 **한 번도 등장하지 않아도** 성공한다.
|
||
|
||
```text
|
||
requested = [missing_tenant_scoped_table]
|
||
actual catalog row = rls_item
|
||
|
||
loop:
|
||
rls_item ∉ requested -> continue
|
||
loop end -> success
|
||
```
|
||
|
||
PostgreSQL 16에서 존재하지 않는 required table 하나를 넘긴 probe도 exception 없이 종료됐다.
|
||
|
||
```text
|
||
experimentalRls.requiredTable=missing_tenant_scoped_table
|
||
experimentalRls.verifierAcceptedMissingTable=true
|
||
BUILD SUCCESSFUL
|
||
```
|
||
|
||
이 경계가 위험한 이유는 단순히 “없는 table을 못 찾는다”가 아니다. tenant table rename/config drift/오타로 expected list가 stale해지면 verifier는 실제 tenant table을 검사하지 않은 채 startup evidence를 성공으로 만들 수 있다. security verifier가 coverage 대상 자체를 증명하지 못하는 fail-open이다.
|
||
|
||
**판정: P1 latent security verification defect.** 현재 기본 composition에는 RLS capability가 연결되지 않아 latent지만, 기능을 활성화해 이 verifier를 startup guard로 사용하는 순간 잘못된 table inventory가 green으로 통과한다.
|
||
|
||
수정은 catalog에서 발견한 tenant-scoped 대상의 상태만 검사할 것이 아니라 `requested - discovered`가 비어 있음을 먼저 강제해야 한다. 가능하면 expected table inventory도 임의 문자열 list가 아니라 migration/schema registry의 SSOT에서 파생하고, missing/renamed table을 real-PostgreSQL regression으로 고정해야 한다.
|
||
|
||
Evidence: `evidence/raw/098-experimental-rls-missing-table-probe.txt`.
|
||
|
||
## 98. P1 latent — database-per-tenant global connection budget이 새 pool 크기를 계산하지 않아 ceiling을 넘긴다
|
||
|
||
`TenantPoolBudget` 문서는 pool 개수와 전체 connection 합계를 모두 제한해야 한다고 명시한다. 특히 pool마다 크기가 다르기 때문에 connection total ceiling이 별도로 필요하다고 설명한다.
|
||
|
||
하지만 `TenantDataSourceRegistry.require()`의 순서는 다음이다.
|
||
|
||
```text
|
||
1. 현재 openPools / allocatedConnections 계산
|
||
2. budget.requireCapacity(currentOpenPools, currentAllocatedConnections)
|
||
3. 새 DataSource 생성
|
||
4. map에 추가
|
||
```
|
||
|
||
`requireCapacity()` 역시 현재 값이 이미 ceiling 이상인지 확인할 뿐, **이번에 추가할 pool의 크기**를 인자로 받지 않는다.
|
||
|
||
따라서 `maxConnectionsAcrossPools=10`이고 현재 8 connections을 가진 pool 하나가 열려 있으면 `8 < 10`이므로 admission이 통과한다. 그 다음 5-connection pool을 열면 결과는 13이다.
|
||
|
||
실측 probe:
|
||
|
||
```text
|
||
experimentalPool.maxConnections=10
|
||
experimentalPool.openPools=2
|
||
experimentalPool.allocatedConnections=13
|
||
BUILD SUCCESSFUL
|
||
```
|
||
|
||
기존 `TenantPoolCapacityContractTest`는 모든 tenant pool 크기를 2로 고정하고 `4/8`, `2/4`처럼 정확히 boundary에 도달한 뒤 다음 tenant를 거부하는 case만 검증한다. 그래서 **remaining capacity보다 다음 pool이 더 큰 case**를 보지 못한다.
|
||
|
||
**판정: P1 latent fleet-capacity defect.** 이 기능의 자체 문서가 connection ceiling 초과 시 한 tenant만이 아니라 전체 DB fleet이 connection refusal을 맞을 수 있다고 정의한다. 현재 app runtime에는 database-per-tenant registry가 조립되지 않아 latent지만, library contract 자체는 global ceiling을 보장하지 못한다.
|
||
|
||
수정은 admission이 `current + candidate`를 검사하게 해야 한다. 후보 pool size를 creation 전에 알 수 있는 profile metadata를 budget input으로 넣거나, 불가피하게 pool을 먼저 만들면 map에 publish하기 전에 size를 검증하고 초과 시 즉시 close해야 한다. regression은 heterogeneous pool sizes로 `8 + 5 > 10` 같은 부분 여유 case를 포함해야 한다.
|
||
|
||
Evidence: `evidence/raw/095-experimental-pool-overshoot-probe.txt`.
|
||
|
||
## 99. P2 latent — replica evidence가 완전히 unavailable이어도 EVENTUAL read는 replica로 간다
|
||
|
||
`ReplicaLagMonitor`의 contract는 명확하다.
|
||
|
||
- monitor down
|
||
- replica unreachable
|
||
- lag metric stale
|
||
|
||
같이 freshness evidence를 얻을 수 없으면 “I do not know”이고 router는 primary를 사용해야 한다. `satisfies()` Javadoc도 evidence가 없으면 false가 default라고 적는다.
|
||
|
||
그러나 구현은 consistency level별로 다음처럼 분기한다.
|
||
|
||
```text
|
||
EVENTUAL -> true
|
||
BOUNDED_STALENESS -> lag().map(...).orElse(false)
|
||
PRIMARY_REQUIRED -> replayedThrough().map(...).orElse(false)
|
||
```
|
||
|
||
즉 EVENTUAL만 evidence availability를 전혀 보지 않는다. `lag()`와 `replayedThrough()`가 모두 `Optional.empty()`인 monitor를 넣은 probe는 replica를 선택했다.
|
||
|
||
```text
|
||
experimentalReplica.unavailableEvidence=true
|
||
experimentalReplica.consistency=EVENTUAL
|
||
experimentalReplica.target=REPLICA
|
||
```
|
||
|
||
EVENTUAL이 stale data를 허용하는 것과 **replica가 usable하다는 evidence 자체가 없는 것**은 다른 조건이다. 현재 contract는 후자를 primary fallback 조건으로 선언해 놓고 EVENTUAL path에서만 우회한다.
|
||
|
||
**판정: P2 latent routing fail-open.** 현재 default runtime에는 router가 조립되지 않는다. 활성화될 경우 monitor outage/unknown state에서 eventual read가 replica target을 선택할 수 있다.
|
||
|
||
수정은 consistency satisfaction과 replica health/evidence availability를 분리하는 편이 명확하다. EVENTUAL은 staleness bound를 요구하지 않을 수 있지만, 최소한 replica가 현재 route 가능한 대상이라는 health/evidence gate는 공통으로 통과해야 한다.
|
||
|
||
Evidence: `evidence/raw/097-experimental-replica-provider-probe.txt`.
|
||
|
||
## 100. P2 latent — Hibernate compatibility policy가 8만 blacklist하고 unknown major 9를 Stable 교체 가능으로 인정한다
|
||
|
||
`HibernateCompatibilityPolicy.mayReplaceStableProvider(version)`은 이름 그대로 특정 provider version이 promotion 없이 Stable provider를 대체해도 되는지를 답한다.
|
||
|
||
현재 구현은 `!providerPolicy.isExperimental(version)`이다. 그런데 `HibernateProviderPolicy`의 experimental provider list는 `List.of("8")` 하나뿐이다. 결과적으로 known Stable 7.x는 true, known Experimental 8.x는 false지만 **아직 어떤 compatibility evidence도 없는 9.x 같은 unknown major는 true**가 된다.
|
||
|
||
probe:
|
||
|
||
```text
|
||
experimentalHibernate.candidate=9.0.0.Final
|
||
experimentalHibernate.mayReplaceStable=true
|
||
```
|
||
|
||
이는 compatibility policy를 denylist로 모델링한 결과다. provider generation이 추가될수록 미측정 버전이 자동 허용되는 방향이라 promotion gate의 목적과 반대다.
|
||
|
||
기존 test도 7.x true와 8.x false만 검증해 unknown-major 경계를 놓친다.
|
||
|
||
**판정: P2 latent compatibility fail-open.** 실제 classpath는 현재 Hibernate 7이고 Hibernate 8 workflow도 `NOT_EXECUTABLE`을 명시하므로 지금 Stable runtime이 9.x라는 주장은 아니다. 문제는 policy가 미래 unknown major를 자동 승인한다는 점이다.
|
||
|
||
수정은 “experimental이 아니면 Stable”이 아니라 **명시적으로 허용된 Stable generation만 true**가 되게 해야 한다. 현재 policy 의도대로라면 최소 `7.x` allowlist 외 major는 false로 닫고, 새 major는 compatibility lane + promotion evidence를 거쳐 allowlist를 바꾸는 방향이 맞다.
|
||
|
||
Evidence: `evidence/raw/097-experimental-replica-provider-probe.txt`.
|
||
|
||
## 101. P2 latent — experimental opt-in이 세 entry point에만 강제되고 Stable scan은 experimental package를 이미 포함한다
|
||
|
||
experimental plan의 global constraint는 “모든 기능은 `backend.jpa.experimental.*` feature flag를 요구한다”이다. `ExperimentalEntryConsentTest`도 더 강하게 “behaviour-bearing entry point는 외부 package에서 public constructor로 만들 수 없어야 하고 gate-taking `enabledBy` factory만 제공해야 한다”고 선언한다.
|
||
|
||
그 test가 실제로 열거하는 class는 세 개뿐이다.
|
||
|
||
```text
|
||
ConsistencyAwareDataSourceRouter
|
||
RlsTenantSessionBinder
|
||
SchemaTenantMigrationOrchestrator
|
||
```
|
||
|
||
하지만 같은 experimental public API에는 flag 없이 바로 생성해서 behavior를 실행할 수 있는 type이 더 있다.
|
||
|
||
- `TenantDataSourceRegistry` — public constructor + tenant pool open
|
||
- `TenantEntityManagerFactoryRegistry` — public constructor + tenant EMF build
|
||
- `SchemaMultiTenantConnectionProvider` — public constructor + connection `search_path` 변경
|
||
- `TenantEntityListenerGuard` — public constructor + `@PrePersist/@PreUpdate` tenant write guard
|
||
|
||
`javap -public`로 이 constructor surface를 확인했고, 현재 repository production caller는 0이었다. 즉 지금 app-bootstrap이 우회하고 있다는 finding이 아니라 **consent test가 “all entry points”라고 부르는 집합 자체가 수동 3-class allowlist라 새/기존 activator를 놓친다**는 finding이다.
|
||
|
||
여기에 Stable `PersistenceJpaConfig`가 experimental package를 `@EntityScan`/`@EnableJpaRepositories`에 unconditional string으로 포함하는 구조가 겹친다. 현재 JPA stereotype이 0개라 즉시 bean activation은 없지만, 향후 experimental entity/repository가 추가되면 이 경로는 feature gate를 거치지 않는다. ArchUnit의 Stable→Experimental dependency rule은 bytecode type edge를 검사하므로 문자열 package scan을 잡지 못한다.
|
||
|
||
**판정: P2 latent architecture/consent gap.** 현재 production wiring이 없어 latent지만 “presence on classpath is not consent”라는 핵심 방어가 type마다 일관되게 강제되지 않는다.
|
||
|
||
수정은 experimental capability를 Stable persistence scan에서 제외하고, 각 feature가 자기 gated configuration에서 필요한 entity/repository/bean을 조립하게 해야 한다. entry-point 검증도 수동 3-class list가 아니라 annotation/package convention 또는 explicit registry SSOT에서 exhaustive하게 파생해야 새 behavior-bearing type이 추가될 때 test가 fail-closed 해야 한다.
|
||
|
||
Evidence: `evidence/raw/096-experimental-gate-reachability.txt`, `099-experimental-structural-optin-gap.txt`.
|
||
|
||
## 102. 이번 sub-scope에서 finding으로 올리지 않은 항목
|
||
|
||
### 102.1 JPA 4 / Hibernate 8 / PostgreSQL 19 workflow의 `NOT_EXECUTABLE`
|
||
|
||
세 workflow는 현재 target dependency/server를 실제로 resolve/run하지 않는다. 그러나 artifact에 `status=NOT_EXECUTABLE`과 이유를 명시하고 promotion checklist도 missing evidence를 통과로 취급하지 않는다. 따라서 “실행하지 않았는데 compatibility green으로 속인다”는 false-evidence finding으로 올리지 않는다. 실제 lane이 실행 가능해지기 전까지는 미검증 상태다.
|
||
|
||
### 102.2 RLS tenant binding 자체
|
||
|
||
`RlsTenantSessionBinder`는 `set_config(..., true)`로 transaction-local tenant setting을 사용하고, existing real-PostgreSQL contract는 session-scoped setting이 pool reuse에서 leak하는 case와 transaction-scoped setting이 leak하지 않는 case를 구분한다. 이번 분석에서 binder 자체의 cross-tenant leak을 재현하지 못했다.
|
||
|
||
### 102.3 schema identifier selection/reset
|
||
|
||
`SchemaTenantRegistry`는 unquoted PostgreSQL identifier shape를 제한하고, connection provider는 schema 값을 statement text에 직접 붙이지 않고 bound `set_config`로 적용하며 release 시 neutral `pg_catalog`로 reset한다. 별도 failure-in-reset / pool-implementation semantics까지 corpus 전체 보장은 하지 않지만, 현재 happy-path isolation contract를 뒤집을 evidence는 없었다.
|
||
|
||
### 102.4 tenant repository/listener guard가 곧 production isolation이라는 주장
|
||
|
||
`TenantAwareRepositoryGuard`와 `TenantEntityListenerGuard`의 local behavior는 fail-closed지만 현재 production repository/entity에 연결된 caller/listener registration은 없다. 따라서 이 type들이 존재한다는 이유만으로 현재 application의 tenant isolation이 보장된다고 쓰지 않는다.
|
||
|
||
## 103. Fresh Experimental verification evidence
|
||
|
||
- `evidence/raw/093-persistence-jpa-experimental-manifest.txt` — **38 / 38 owning FULL_READ**(29 production + 8 unit + 1 SQL), 추가 PostgreSQL verification dependency **5 / 5 FULL_READ**
|
||
- `evidence/raw/095-experimental-pool-overshoot-probe.txt` — connection ceiling 10에서 heterogeneous pools 8 + 5가 admission되어 total **13**으로 overshoot, BUILD SUCCESSFUL
|
||
- `evidence/raw/097-experimental-replica-provider-probe.txt` — evidence가 모두 empty인 EVENTUAL read가 `REPLICA`, unknown Hibernate `9.0.0.Final`이 `mayReplaceStable=true`, BUILD SUCCESSFUL
|
||
- `evidence/raw/098-experimental-rls-missing-table-probe.txt` — PostgreSQL 16에서 required missing table을 `RlsPolicyVerifier`가 성공으로 인정, `jpaPlatformSecurityTest` BUILD SUCCESSFUL
|
||
- `evidence/raw/096-experimental-gate-reachability.txt`, `099-experimental-structural-optin-gap.txt` — omitted public activation constructors, production caller 0, Stable scan의 experimental package unconditional inclusion
|
||
- `evidence/raw/100-experimental-original-unit-tests.txt` — original-source experimental 8 unit classes + `JpaModuleBoundaryTest` + `PersistenceEntityScanCoverageTest`, fresh `--rerun-tasks`, **BUILD SUCCESSFUL in 28s / 18 actionable executed**, git clean before/after
|
||
- `evidence/raw/101-experimental-original-postgresql-contracts.txt` — original-source contract 3 classes **BUILD SUCCESSFUL in 30s / 19 executed**, schema migration **BUILD SUCCESSFUL in 5s**, RLS security **BUILD SUCCESSFUL in 4s**, git clean before/after
|
||
- `evidence/raw/094-preflight-fixture-compile-failure.txt` — 최초 analysis-only DataSource fixture의 `@Override` 누락이 repository `-Werror`에 걸린 preflight 실패. 제품 code failure가 아니며 fixture 수정 후 095/097 clean probe로 대체했다.
|
||
|
||
모든 semantic probe는 temporary test source를 trap으로 복원했다. 최종 code repository `git status --short`는 clean이다.
|
||
|
||
## 104. Sub-scope 09 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| **P1 latent** | RLS verifier가 requested tenant table의 존재/coverage를 확인하지 않아 missing/stale table inventory를 green으로 인정 | **experimental public API, current app runtime unwired** |
|
||
| **P1 latent** | database-per-tenant admission이 candidate pool size를 반영하지 않아 global connection ceiling을 초과 가능 | **experimental public API, current app runtime unwired** |
|
||
| **P2 latent** | replica evidence가 unavailable이어도 EVENTUAL read는 replica target을 선택 | **experimental public API, current app runtime unwired** |
|
||
| **P2 latent** | Hibernate compatibility policy가 8.x만 deny해 unknown 9.x를 promotion 없이 Stable 교체 가능으로 판정 | **experimental compatibility policy, current Stable classpath 7.x** |
|
||
| **P2 latent** | feature consent가 3개 entry point에만 구조적으로 강제되고 Stable JPA scan은 experimental package를 unconditional 포함 | **latent structural activation path; current experimental JPA stereotypes 0** |
|
||
|
||
Sub-scope 09는 **38 / 38 owning FULL_READ + 5 / 5 PostgreSQL verification dependency FULL_READ + targeted semantic probes + original unit/contract/migration/security fresh rerun**으로 닫는다.
|
||
|
||
## 105. Sub-scope 10 범위와 denominator
|
||
|
||
Sub-scope 10은 JPA platform의 testkit 자체와 그 testkit을 검증하는 fixture/unit test를 소유한다. 이 범위는 production persistence 동작이 아니라 **그 동작을 증명한다고 주장하는 architecture rule, query/plan assertion, failure injector, migration runner, release registry adapter, PostgreSQL matrix helper가 false-green evidence를 만들 수 있는지**를 본다.
|
||
|
||
Owning denominator는 정확히 **62개**다.
|
||
|
||
- `src/testkit/java/**`: **41 Java**
|
||
- `src/test/java/**/testkit/**`: **21 Java**
|
||
|
||
이번 sub-scope에서 **62 / 62 FULL_READ**했다.
|
||
|
||
Evidence: `evidence/raw/102-persistence-jpa-testkit-manifest.txt`.
|
||
|
||
## 106. Testkit reachability를 production guard와 self-test helper로 나눈다
|
||
|
||
같은 testkit package에 있어도 영향도는 동일하지 않다.
|
||
|
||
production/release evidence에 실제 연결된 핵심 helper는 다음이다.
|
||
|
||
- `JpaArchitectureRules` / `EntityExposureCondition` — `app-bootstrap`의 `JpaProductionArchitectureTest`가 실제 production graph에 적용한다.
|
||
- `JpaAuditMechanismRule` — 동일 production architecture suite가 audit mechanism과 audited bulk update를 검사한다.
|
||
- `PostgreSqlExplainRunner` / `QueryPlanAssertions` / `QueryPlanExpectation` — real PostgreSQL `PostgreSqlQueryPlanContractTest`가 사용하고 `jpaPlatformQueryPlanTest`라는 blocking release Test task 안에서 실행된다.
|
||
- `MigrationContractRunner`, `CountingDataSource`, lifecycle/mapping fixture들은 실제 PostgreSQL integration lane에서 사용된다.
|
||
|
||
반대로 public surface지만 현재 repository source에서 defining file 외 reference가 0인 helper도 있다.
|
||
|
||
- `CommitAmbiguityProxy`
|
||
- `PostgreSqlContractExtension`
|
||
|
||
`testkit.id.UuidV7Generator`도 simple name은 notification 모듈의 별도 production `UuidV7Generator`와 충돌하지만 **정확한 testkit FQN consumer는 0**이다. 현재는 자신의 unit test만 존재한다.
|
||
|
||
이 구분 때문에 아래 backlog는 “testkit code에 버그가 있다”만으로 승격하지 않고, **현재 production architecture/release evidence producer와 연결된 false-negative**를 우선한다.
|
||
|
||
Evidence: `evidence/raw/105-testkit-public-reachability-sweep.txt`.
|
||
|
||
## 107. P1 latent — SELECT-only query-plan runner가 data-modifying CTE를 허용해 `EXPLAIN ANALYZE`가 실제 DML을 실행한다
|
||
|
||
`PostgreSqlExplainRunner`는 이 위험을 정확히 문서화한다.
|
||
|
||
```text
|
||
EXPLAIN ANALYZE executes the statement;
|
||
therefore this runner refuses anything but SELECT.
|
||
```
|
||
|
||
하지만 실제 `requireReadOnly()`는 다음 두 prefix를 허용한다.
|
||
|
||
```text
|
||
select...
|
||
with...
|
||
```
|
||
|
||
PostgreSQL의 `WITH`는 read-only CTE만 의미하지 않는다. data-modifying CTE가 가능하다.
|
||
|
||
```sql
|
||
WITH changed AS (
|
||
UPDATE plan_row
|
||
SET bucket = 99
|
||
WHERE id = ?
|
||
RETURNING id
|
||
)
|
||
SELECT id FROM changed
|
||
```
|
||
|
||
이 statement는 `WITH`로 시작하므로 guard를 통과하고, runner가 붙이는 `EXPLAIN (ANALYZE, ...)`는 실제 UPDATE를 실행한다.
|
||
|
||
PostgreSQL 16 real-container probe에서 id 42의 `bucket`을 조회한 뒤 위 statement를 runner에 넣었다.
|
||
|
||
```text
|
||
testkitExplain.guardAcceptedWithUpdateCte=true
|
||
testkitExplain.bucketBefore=9
|
||
testkitExplain.bucketAfter=99
|
||
BUILD SUCCESSFUL
|
||
```
|
||
|
||
즉 guard가 보호한다고 명시한 side-effect가 실제로 발생했다.
|
||
|
||
기존 `refusesNonSelect()` contract는 direct `UPDATE ...`만 넣기 때문에 green이다. 원본 `PostgreSqlQueryPlanContractTest` 전체도 fresh rerun에서 green이지만, 그것은 현재 input이 SELECT라는 증거이지 `WITH`가 read-only라는 증거가 아니다.
|
||
|
||
**판정: P1 latent release-evidence safety defect.** 현재 committed query-plan tests는 SELECT만 사용하므로 지금 release run이 DB를 변경했다는 주장은 아니다. 그러나 이 helper는 blocking `jpaPlatformQueryPlanTest`가 사용하는 evidence producer이고, documented safety invariant를 우회하는 legal PostgreSQL syntax가 실제 mutation까지 재현됐다.
|
||
|
||
수정은 string prefix whitelist로 SQL read-only 여부를 판정하지 않는 방향이 필요하다. 최소한 data-modifying CTE를 fail-closed로 거부하는 parser/statement classification을 사용하고, query-plan lane 자체의 connection/transaction도 read-only defense-in-depth로 묶어야 한다. regression은 direct UPDATE뿐 아니라 `WITH ... UPDATE/DELETE/INSERT ... SELECT`를 포함해야 한다.
|
||
|
||
Evidence: `evidence/raw/104-testkit-explain-dml-cte-probe.txt`, `106-testkit-original-verification.txt`.
|
||
|
||
## 108. P1 latent — production entity-exposure rule이 async/reactive wrapper 안의 JPA entity를 보지 못한다
|
||
|
||
`EntityExposureCondition`의 목적은 controller/web method가 persistence entity를 return graph 어디에서도 노출하지 못하게 하는 것이다. 직접 entity뿐 아니라 `List<Entity>`, `Optional<Entity>`, `Map<..., Entity>`까지 generic argument를 검사한다고 명시한다.
|
||
|
||
하지만 generic traversal은 raw return type이 다음 container일 때만 실행된다.
|
||
|
||
```text
|
||
Collection
|
||
Map
|
||
Optional
|
||
array
|
||
```
|
||
|
||
따라서 다음처럼 실제 transport 계층에서 흔한 wrapper는 raw type 단계에서 즉시 `Optional.empty()`가 된다.
|
||
|
||
```text
|
||
CompletableFuture<OrderEntity>
|
||
CompletionStage<OrderEntity>
|
||
ResponseEntity<OrderEntity>
|
||
Mono<OrderEntity>
|
||
Flux<OrderEntity>
|
||
Page<OrderEntity>
|
||
```
|
||
|
||
analysis fixture로 `CompletableFuture<OrderEntity>`를 반환하는 `..web..` class를 넣고 **실제 production에서 사용하는 동일 `JpaArchitectureRules.noEntityFromWeb()`**를 실행했다. rule은 exception 없이 통과했다.
|
||
|
||
```text
|
||
testkitArchitecture.wrapper=CompletableFuture<OrderEntity>
|
||
testkitArchitecture.entityLeakAccepted=true
|
||
```
|
||
|
||
이 문제는 가상의 wrapper family만의 이야기가 아니다. 현재 inbound web 코드도 `Mono<ResponseEntity<...>>` 같은 nested transport wrapper를 실제로 사용한다. 지금 그 내부 payload는 persistence entity가 아니라 DTO/String이므로 current production violation은 확인되지 않았고, 원본 `JpaProductionArchitectureTest`도 fresh green이다.
|
||
|
||
**판정: P1 latent architecture-enforcement false-negative.** controller가 persistence entity를 직접 노출하는 것은 repository의 HARD-STOP 계열 경계이고, 이 rule은 release-wide `app-bootstrap:test`에서 그 경계를 증명하는 production guard다. 현재 code가 위반 중이라는 finding이 아니라, **위반을 추가해도 대표적인 async wrapper 형태면 guard가 green일 수 있다는 것**이 finding이다.
|
||
|
||
수정은 container allowlist로 들어갈지 말지를 결정하지 말고 return `JavaType`의 generic graph를 재귀적으로 traverse하되 cycle을 방지하는 방식이 더 안전하다. 최소 regression에는 `CompletableFuture<Entity>`와 실제 runtime stack의 `Mono<ResponseEntity<Entity>>` 또는 동등한 nested wrapper를 포함해야 한다.
|
||
|
||
Evidence: `evidence/raw/103-testkit-unit-boundary-probes.txt`, `106-testkit-original-verification.txt`.
|
||
|
||
## 109. P2 latent — plan normalizer가 root node 하나의 estimate ratio만 읽어 child node의 큰 cardinality miss를 숨긴다
|
||
|
||
`PostgreSqlExplainRunner.normalize()`는 `Node Type`은 전체 JSON에서 반복 탐색한다. 반면 `Actual Rows`, `Plan Rows`, `Shared Read Blocks`는 `indexOf(key)`로 **첫 occurrence 하나만** 읽는다.
|
||
|
||
PostgreSQL JSON plan은 root node 뒤에 child `Plans[]`가 중첩되는 구조이므로 현재 `estimateRatio`는 사실상 root node ratio다.
|
||
|
||
analysis probe에 다음 plan을 넣었다.
|
||
|
||
```text
|
||
root Nested Loop: Actual 10 / Plan 10 -> ratio 1
|
||
child Seq Scan: Actual 1000 / Plan 1 -> ratio 1000
|
||
```
|
||
|
||
normalizer는 child node type은 발견하면서 estimate ratio는 1.0으로 보고했다.
|
||
|
||
```text
|
||
testkitPlan.nodes=[Nested Loop, Seq Scan]
|
||
testkitPlan.reportedEstimateRatio=1.0
|
||
testkitPlan.childActualToPlanned=1000.0
|
||
```
|
||
|
||
`QueryPlanExpectation.estimateOnly(10)` 같은 assertion은 이런 plan을 estimate-quality 관점에서 green으로 통과시킬 수 있다.
|
||
|
||
**판정: P2 latent query-plan false-evidence.** 현재 committed estimate assertion의 대표 query는 단순 index lookup이라 이 probe만으로 현재 release 결과가 거짓이라고 확대하지 않는다. 그러나 blocking query-plan lane의 normalized model이 “planner estimate error”를 plan 전체가 아니라 root 한 node로 축소하는 것은 명시적인 측정 공백이다.
|
||
|
||
수정은 node별 actual/planned pair를 구조적으로 parse하고, maximum symmetric error ratio 또는 명시한 aggregation policy를 `NormalizedPlan`에 보존해야 한다. raw JSON을 substring scanning하기보다 JSON tree parser로 node recursion을 수행하는 것이 node pairing과 buffer aggregation 모두 안전하다.
|
||
|
||
Evidence: `evidence/raw/103-testkit-unit-boundary-probes.txt`.
|
||
|
||
## 110. P2 latent — audited bulk-update guard가 audit column 이름을 “대입 대상”이 아니라 substring으로 찾아 false-green을 만든다
|
||
|
||
`JpaAuditMechanismRule.bulkUpdateViolation()`은 audited entity의 bulk update가 `updated_at`/`updatedAt`/`modified_at`/`modifiedAt`를 직접 stamp하는지 검사한다.
|
||
|
||
하지만 현재 판정은 `SET ... WHERE` 문자열 전체에 audit-column token이 **어디든 포함되는지**만 본다.
|
||
|
||
```text
|
||
AUDIT_COLUMNS.stream().anyMatch(assignments::contains)
|
||
```
|
||
|
||
따라서 실제 audit field를 변경하지 않고 parameter 이름에 token만 들어 있어도 통과한다.
|
||
|
||
```sql
|
||
update WorkLogEntity w
|
||
set w.status = :updatedAtValue
|
||
where w.id = :id
|
||
```
|
||
|
||
probe 결과:
|
||
|
||
```text
|
||
testkitAudit.decoyParameter=:updatedAtValue
|
||
testkitAudit.violationPresent=false
|
||
```
|
||
|
||
즉 `w.updatedAt = ...` assignment가 하나도 없는데 “stamp 있음”으로 해석했다.
|
||
|
||
현재 production graph에는 이 rule의 audited-entity bulk-update branch를 실제로 밟는 committed query가 없어 original architecture suite는 green이다. class 자체도 이 점을 Javadoc에서 인정하고 direct branch unit test를 둔다. 문제는 그 branch test가 real assignment와 완전 unstamped case만 보고 decoy token을 보지 않는다는 것이다.
|
||
|
||
**판정: P2 latent audit architecture false-negative.** 현재 unstamped audited bulk update가 production에 있다는 주장은 아니다. 다만 future query가 parameter/함수/문자열 literal에 audit token을 포함하면 release architecture guard가 잘못 통과할 수 있다.
|
||
|
||
수정은 SQL/JPQL의 `SET` assignment left-hand side를 최소한 token boundary 기준으로 추출하여 audit property/column과 정확히 비교해야 한다. regression에는 parameter name, string literal, unrelated property suffix에 `updatedAt` token이 포함된 case를 넣어야 한다.
|
||
|
||
Evidence: `evidence/raw/103-testkit-unit-boundary-probes.txt`, `106-testkit-original-verification.txt`.
|
||
|
||
## 111. 이번 sub-scope에서 finding으로 올리지 않은 항목
|
||
|
||
### 111.1 `UuidV7Generator` same-millisecond wrap
|
||
|
||
이 testkit generator는 12-bit counter를 같은 millisecond마다 `& 0x0FFF`로 증가시킨다. lower-half random seed는 wrap을 제거하지 않고 늦출 뿐이다. deterministic probe에서는 같은 millisecond **3,784번째**에 이전 UUID보다 작아지는 정렬 역전이 재현됐다.
|
||
|
||
```text
|
||
testkitUuid.sameMillisecondGeneratedBeforeInversion=3784
|
||
testkitUuid.monotonicityBroken=true
|
||
```
|
||
|
||
하지만 exact `dev.caskeleton.adapter.outbound.persistence.testkit.id.UuidV7Generator` FQN의 repository consumer는 현재 0이고, notification runtime이 사용하는 production `UuidV7Generator`는 다른 모듈의 별도 class다. 따라서 production UUID defect로 승격하지 않고 **self-tested unadopted test fixture defect**로 남긴다. 만약 이 fixture를 ID-strategy PostgreSQL contract에 실제 채택한다면 그 시점에는 same-ms exhaustion policy가 필요하다.
|
||
|
||
### 111.2 `EntityState.REMOVED`
|
||
|
||
`EntityState` enum은 `REMOVED`를 제공하지만 `EntityStateProbe.stateOf()` 구현은 `MANAGED`, `TRANSIENT`, `DETACHED` 세 값만 반환한다. `REMOVED` consumer/assertion도 현재 없다. API vocabulary와 probe capability가 어긋나지만 current evidence lane을 잘못 통과시키는 소비 경로가 없어 backlog 우선순위에는 올리지 않는다.
|
||
|
||
### 111.3 `CommitAmbiguityProxy` / `PostgreSqlContractExtension`
|
||
|
||
두 public helper는 defining file 밖 exact FQN reference가 0이다. 특히 `PostgreSqlContractExtension.serverVersion()`은 이름과 달리 database의 `SHOW server_version`이 아니라 Docker image + container id를 반환하지만 현재 integration support는 별도 `JpaPlatformContractSupport.serverVersion()`로 실제 server version을 읽는다. 따라서 잘못된 current evidence로 분류하지 않고 dead/unadopted helper로 기록한다.
|
||
|
||
### 111.4 `JpaReleaseManifest`의 regex parser
|
||
|
||
Java testkit parser 자체는 정규식 기반이라 일반-purpose JSON parser가 아니다. 그러나 실제 registry 파일은 root Gradle `verifyJpaReleaseGateTasks`에서 `JsonSlurper`로 다시 parse되고 real task graph까지 resolve한다. 현재 malformed JSON을 Java regex parser 하나가 받아들일 가능성만으로 release fail-open을 별도 finding으로 중복 승격하지 않는다.
|
||
|
||
## 112. Fresh Testkit verification evidence
|
||
|
||
- `evidence/raw/102-persistence-jpa-testkit-manifest.txt` — **62 / 62 FULL_READ** (41 testkit Java + 21 fixture/unit Java)
|
||
- `evidence/raw/103-testkit-unit-boundary-probes.txt` — async wrapper entity leak accepted, audit decoy token accepted, child estimate miss hidden by root ratio, testkit UUID same-ms inversion 재현; analysis fixture는 trap으로 복원
|
||
- `evidence/raw/104-testkit-explain-dml-cte-probe.txt` — PostgreSQL 16에서 `WITH UPDATE ... SELECT`가 read-only guard를 통과하고 `EXPLAIN ANALYZE`로 row를 실제 `9 -> 99` 변경; BUILD SUCCESSFUL
|
||
- `evidence/raw/105-testkit-public-reachability-sweep.txt` — public testkit symbol reachability와 zero-reference helper sweep
|
||
- `evidence/raw/106-testkit-original-verification.txt` — 원본 source 상태에서 `:adapter:outbound:persistence-jpa:test --rerun-tasks` **BUILD SUCCESSFUL in 29s / 18 executed**, `JpaProductionArchitectureTest` **BUILD SUCCESSFUL in 1m47s / 98 executed**, original `PostgreSqlQueryPlanContractTest` **BUILD SUCCESSFUL in 25s / 19 executed**, git clean before/after
|
||
|
||
모든 analysis-only source/fixture 변경은 trap으로 복원했고 최종 code repository `git status --short`는 clean이다.
|
||
|
||
## 113. Sub-scope 10 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| **P1 latent** | `PostgreSqlExplainRunner`가 모든 `WITH`를 read-only로 허용해 data-modifying CTE를 `EXPLAIN ANALYZE`로 실제 실행 가능 | **blocking query-plan evidence producer; current committed inputs are SELECT** |
|
||
| **P1 latent** | production `noEntityFromWeb` architecture rule이 async/reactive/custom wrapper 안 entity를 탐색하지 않아 `CompletableFuture<Entity>`가 green | **release-wide app-bootstrap architecture guard; current payloads are DTOs** |
|
||
| **P2 latent** | query-plan estimate ratio가 root node first pair만 사용해 child cardinality miss를 숨김 | **blocking query-plan evidence producer** |
|
||
| **P2 latent** | audited bulk-update guard가 audit column substring만 찾아 parameter/string decoy로 false-green | **production architecture guard; current audited bulk-update branch absent** |
|
||
|
||
Sub-scope 10은 **62 / 62 FULL_READ + four focused unit false-negative probes + real-PostgreSQL DML-CTE probe + full original unit/production-architecture/query-plan fresh rerun**으로 닫는다.
|
||
|
||
---
|
||
|
||
## 114. Sub-scope 01 범위와 denominator
|
||
|
||
> 내부 상태: COMPLETE — **11 / 11 FULL_READ**
|
||
> 범위: leaf 최상위 4개 파일 + `package-info.java` + `config/**` production 3개 + root-level test 3개
|
||
> 역할: 이 leaf가 "무엇이고, 무엇을 always-install하며, 어떤 경계를 스스로 강제하는가"를 선언하는 층
|
||
|
||
| 구분 | 파일 | 라인 |
|
||
|---|---|---:|
|
||
| governance | `CLAUDE.md` | 295 |
|
||
| rationale | `README.md` | 379 |
|
||
| build | `build.gradle` | 356 |
|
||
| build | `gradle.lockfile` | 224 |
|
||
| production | `package-info.java` | 2 |
|
||
| production | `config/JpaAdapterComponentsConfig.java` | 68 |
|
||
| production | `config/PersistenceJpaConfig.java` | 95 |
|
||
| production | `config/PersistenceVendorSettings.java` | 35 |
|
||
| test | `CandidateAdapterCompositionTest.java` | 63 |
|
||
| test | `JpaModuleBoundaryTest.java` | 411 |
|
||
| test | `integration/.gitkeep` | 1 |
|
||
|
||
denominator 근거는 `evidence/raw/107-persistence-jpa-governance-manifest.txt`다. 이 11개는 "다른 어떤 sub-scope manifest도 claim하지 않은 leaf tracked file"로 정의했다. 특히 `config/PersistenceEntityScanCoverageTest`, `config/PersistenceVendorSelectionTest`, `platform/PoolLaneClaimTest` 3개 test는 sub-scope 06 manifest(`070-...`)가 이미 자기 denominator에 넣었기 때문에 여기서 다시 세지 않는다. 대신 이 sub-scope는 그 test들이 **검사하는 대상**인 production type을 소유한다. 즉 소유 경계는 "production type은 01, 그 test의 계수는 06"으로 갈라져 있고, 이 문서는 그 사실을 명시한 뒤 내용 분석은 여기서 한다.
|
||
|
||
`.gitkeep`은 빈 디렉터리 marker이므로 `STRUCTURAL_ONLY`가 아니라 `FULL_READ`(1바이트, 내용 없음)로 처리했다. `gradle.lockfile`은 224줄 전부를 읽었으나 해석은 configuration별 classpath 소속 확인에 한정했다.
|
||
|
||
## 115. governance는 세 겹이고, 세 겹의 강제력이 서로 다르다
|
||
|
||
이 leaf의 규칙은 세 곳에 나뉘어 있고 각각 강제 수단이 다르다.
|
||
|
||
| 층 | 문서/코드 | 강제 수단 | 위반 시 실패 지점 |
|
||
|---|---|---|---|
|
||
| 정책 서술 | `CLAUDE.md` | 없음(산문) | 없음 — 읽는 사람만 안다 |
|
||
| 근거 서술 | `README.md` | 없음(산문) | 없음 |
|
||
| build 계약 | `build.gradle` | Gradle task | task 실행 시 |
|
||
| package 경계 | `JpaModuleBoundaryTest` | ArchUnit + 파일시스템 대조 | `:test` 실행 시 |
|
||
|
||
`build.gradle`은 산문이 아니라 실제 계약이다. `strictTestLanes`가 `postgresqlIntegrationTest` / `testkit` / `jpaPlatformPerformanceTest` 세 source set을 만들고, `testkitPublisher`가 testkit을 `test`와 `postgresqlIntegrationTest`에만 소비시킨다. 즉 "production module은 testkit에 의존하지 않는다"는 설계 주장은 여기서 **구조적으로** 참이 되고, `JpaModuleBoundaryTest.noProductionClassDependsOnTheTestkit()`가 bytecode 수준에서 다시 확인한다. 같은 주장을 두 층이 서로 다른 방식으로 잡는다.
|
||
|
||
`JpaModuleBoundaryTest`는 이 leaf에서 가장 강한 governance 장치다. 24개 top-level package 각각의 허용 edge를 `PACKAGE_CATALOG`에 닫힌 집합으로 적고, 디스크의 실제 package 목록과 **정확히 같은 집합인지** 양방향으로 대조하며(`theCatalogNamesExactlyThePackagesThatExist`), 관측된 모든 package 간 edge가 선언된 edge인지 확인하고(`everyObservedEdgeIsDeclared`), 선언된 edge가 DAG인지 검사한다(`theDeclaredEdgesFormADag`). `importActuallyLoadedTheProductionClasses`는 `noClasses()` rule이 "아무것도 매칭되지 않아 vacuously 통과"하는 실패 모드를 명시적으로 막는다 — ArchUnit rule 모음에서 가장 자주 조용히 무너지는 지점을 이 파일은 알고 있다.
|
||
|
||
다만 catalog의 **값** 쪽은 key 쪽만큼 검증되지 않는다. `postgresql`의 허용 대상에 `"inbox"`가 들어 있는데 `..persistence.inbox`라는 top-level package는 존재하지 않는다(실제 inbox는 `postgresql.inbox` 하위 package라 `topLevelPackageOf()`가 항상 `postgresql`을 돌려준다). 즉 이 항목은 어떤 edge도 허용하지 않는 사문(死文)이다. `theCatalogNamesExactlyThePackagesThatExist`는 key만 대조하므로 이런 값은 잡히지 않는다. 방향은 안전한 쪽이다 — 존재하지 않는 이름은 rule을 **더 엄격하게** 만들 뿐 느슨하게 만들지 않는다 — 그래서 결함이 아니라 잔여 설정으로 기록한다. 근거: `evidence/raw/110-governance-doc-count-drift.txt` §I.
|
||
|
||
## 116. Confirmed P2 — vendor selector의 fail-fast 계약이 shipped composition에 설치돼 있지 않다
|
||
|
||
`PersistenceVendorSettings`는 자기 존재 이유를 javadoc에 명확히 적는다.
|
||
|
||
> Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the two `@ConditionalOnProperty` vendor configurations would both stay off, and the first missing SPI bean would surface as a `NoSuchBeanDefinitionException` naming `OutboxClaimRepository` — a symptom several layers away from the misspelled value that caused it.
|
||
|
||
`CLAUDE.md` §Vendor selection도 같은 주장을 한다. `application.yml:463` 주석은 한 발 더 나가 "PersistenceVendorSettings (adapter-persistence), which rejects any other value at startup"라고 쓴다.
|
||
|
||
문제는 이 타입이 **production에서 한 번도 `@ConfigurationProperties` bean으로 등록되지 않는다**는 것이다.
|
||
|
||
- leaf 안의 `@ConfigurationProperties` 타입은 정확히 3개다: `LockSettings`, `JpaTransactionSettings`, `PersistenceVendorSettings`.
|
||
- 그중 앞의 둘만 같은 package 안의 configuration이 `@EnableConfigurationProperties`로 켠다 — `DistributedLockPersistenceConfig`, `JpaTransactionConfig`.
|
||
- `PersistenceVendorSettings`를 켜는 곳은 repository 전체에서 `PersistenceVendorSelectionTest`의 중첩 `@Configuration` 하나뿐이다.
|
||
- composition root `CaSkeletonApplication`의 `@ConfigurationPropertiesScan` basePackages 21개 중 `dev.caskeleton.adapter.outbound.persistence`를 덮는 항목은 0개다.
|
||
- `PersistenceJpaRootAutoConfiguration`은 이 사실을 알고 있고 javadoc에 적어 두었다 — vendor를 `Environment.getProperty(...)`로 직접 읽는다.
|
||
|
||
역설적인 것은 `JpaAdapterComponentsConfig`의 javadoc이 규칙 자체를 정확히 서술한다는 점이다. "Each package's `@ConfigurationProperties` type is enabled by a configuration inside that same package — `JpaTransactionConfig` for `JpaTransactionSettings`, `DistributedLockPersistenceConfig` for `LockSettings`". 셋 중 둘을 열거하고 셋째를 빠뜨렸는데, 그 셋째를 소유한 package가 바로 이 javadoc이 들어 있는 `config`다.
|
||
|
||
### 실행 probe
|
||
|
||
`evidence/raw/109-vendor-selector-shipped-shape-probe.txt` / `109a-...java`. shipped composition과 같은 모양(두 vendor configuration만 import, properties 타입은 켜지 않음)에서 `ca-skeleton.persistence.vendor=mysql`을 준다.
|
||
|
||
```text
|
||
shipped.unknownVendor.contextFailed=false
|
||
shipped.unknownVendor.vendorSettingsBeans=0
|
||
shipped.unknownVendor.sqlStateErrorMappingBeans=0
|
||
shipped.unknownVendor.postgreSqlConfigBeans=0
|
||
shipped.unknownVendor.h2ConfigBeans=0
|
||
```
|
||
|
||
같은 모양에 `@EnableConfigurationProperties(PersistenceVendorSettings.class)`만 추가하면 문서가 약속한 실패가 실제로 난다.
|
||
|
||
```text
|
||
enabled.unknownVendor.contextFailed=true
|
||
enabled.unknownVendor.mentionsProperty=true
|
||
```
|
||
|
||
(context가 남긴 예외: `ConfigurationPropertiesBindException: ... Could not bind properties to 'PersistenceVendorSettings' : prefix=ca-skeleton.persistence`)
|
||
|
||
selector를 아예 주지 않은 경우에는 PostgreSQL 쪽이 활성화된다는 것도 같은 probe에서 확인된다 — Spring Data가 4개 repository interface를 스캔하고 `entityManagerFactory` 부재로 실패하므로, `@Import(PersistenceJpaConfig.class)` 사슬이 실제로 돌았다는 뜻이다. `matchIfMissing = true`는 살아 있다.
|
||
|
||
`JpaAdapterComponentsConfig`까지 넣은 네 번째 case에서는 context가 실패하지만 **실패 메시지 어디에도 vendor property가 등장하지 않는다**.
|
||
|
||
```text
|
||
components.unknownVendor.contextFailed=true
|
||
components.unknownVendor.mentionsVendorProperty=false
|
||
```
|
||
|
||
**판정: P2 confirmed.** 오타 난 vendor 값은 startup을 실패시키기는 하지만, 그 실패는 property를 지목하지 않는다 — `PersistenceVendorSettings`가 막겠다고 선언한 바로 그 증상이다. app-bootstrap의 `PersistenceVendorProdSafetyValidator`도 도움이 되지 않는다. 그 validator는 prod profile에서 값이 `h2`인지만 보고 알 수 없는 값은 통과시킨다.
|
||
|
||
수정은 작다 — `config` package 안에 `@EnableConfigurationProperties(PersistenceVendorSettings.class)`를 가진 configuration을 두고 `PersistenceJpaRootAutoConfiguration`이 그것을 import하면, 이미 존재하는 두 sibling과 같은 모양이 된다. regression은 unknown vendor로 context를 띄워 실패 메시지가 property 이름을 포함하는지 보면 된다(위 probe가 그대로 red/green 쌍이다).
|
||
|
||
한계: probe context는 full application context가 아니다. 실제 배포에서 **첫 번째로** 실패하는 bean은 다를 수 있다. 증명된 것은 (a) unknown 값이 아무것도 bind하지 않고 두 vendor configuration을 모두 비활성으로 남긴다, (b) 그 경로의 실패가 property를 지목하지 않는다, (c) properties 타입을 켜면 지목하는 실패가 난다 — 세 가지다.
|
||
|
||
## 117. always-install scan과 opt-in scan의 경계는 실제로 지켜지고 있다
|
||
|
||
`PersistenceJpaConfig`는 persistence root를 통째로 스캔하지 않고 20개 package를 열거한다. 빠진 것은 `config`, `h2`(JPA stereotype 없음)와 opt-in 두 개(`notification`, `fileserver`)다. 두 opt-in은 각자의 `@ConditionalOnProperty` configuration이 자기 package만 스캔한다. 이 배치의 이유는 javadoc과 `PersistenceEntityScanCoverageTest`에 기록돼 있다 — 과거에 root를 스캔해서 capability를 끈 배포가 `ddl-auto=validate`에서 `notification_request` / `fs_cleanup_item`을 요구하며 부팅에 실패했다.
|
||
|
||
측정 결과 always-install scan의 건전성은 유지되고 있다. `@Entity` 25개 중 opt-in package(`notification` 13, `fileserver` 6) 밖의 4개는 `idempotency_record`, `outbox_event`, `live_event_log`, `durable_operation`이고, 이 네 테이블은 모두 default location `db/migration/postgresql`(V1/V3/V11/V12)이 만든다. `postgresql` package는 scan 대상이지만 그 안의 candidate adapter들(`inbox`, `outbox` v2, `idempotency` v2)은 `@Entity`가 아니라 native SQL 기반이라 persistence unit에 들어오지 않는다. 즉 sub-scope 08이 발견한 "opt-in stream을 always-install scan이 끌고 들어온다" 유형의 결함은 현재 남아 있지 않다.
|
||
|
||
다만 `PersistenceEntityScanCoverageTest`가 지키는 범위에는 비대칭이 하나 있다. opt-in configuration 두 개에 대해서는 `@EntityScan` 목록과 `@EnableJpaRepositories` 목록이 **정확히 같은지** `containsExactly`로 검사한다("entities without repositories is half a scan, and fails at the first query"). 그런데 always-install `PersistenceJpaConfig`에 대해서는 `@EntityScan` 목록만 읽어 디스크와 대조하고, 두 목록의 일치는 검사하지 않는다. 현재 두 목록은 20개로 동일하다(`110-...` §H, diff 결과 identical). 그래서 지금은 무해하지만, 새 package를 `@EntityScan`에만 추가하는 실수는 이 test가 잡지 못한다 — 그 test가 opt-in 쪽에 대해서만 명시적으로 막고 있는 바로 그 실수다. P3.
|
||
|
||
`CandidateAdapterCompositionTest`는 반대 방향을 지킨다. PostgreSQL 전용 candidate adapter 3종에 `@Repository`/`@Component`/`@Service`가 없는지, 그리고 이미 stereotype을 제거한 `PostgreSqlOwnerSafeIdempotencyStore`가 계속 그 상태인지 검사한다. sub-scope 05가 이 adapter들을 "미조립 candidate"로 분류한 근거가 이 test로 고정돼 있다.
|
||
|
||
## 118. Negative-space probes — governance scope
|
||
|
||
근거: `evidence/raw/108-governance-config-reachability.txt`.
|
||
|
||
### 118.1 Public surface reachability
|
||
|
||
`config` package의 public type 3개는 모두 leaf 밖 소비자가 있다.
|
||
|
||
| type | leaf 밖 소비자 |
|
||
|---|---|
|
||
| `PersistenceJpaConfig` | `sample-portfolio`의 `SamplePostgreSqlPersistenceConfig`가 `@Import`. app-bootstrap은 직접 import하지 않고 두 vendor configuration을 통해 간접 도달 |
|
||
| `JpaAdapterComponentsConfig` | `PersistenceJpaRootAutoConfiguration`의 `@Import` 목록 |
|
||
| `PersistenceVendorSettings` | `PersistenceJpaRootAutoConfiguration`이 `VENDOR_PROPERTY` 상수만 사용(타입 자체는 bean 아님), `application.yml` 주석 |
|
||
|
||
`config`는 `JpaModuleBoundaryTest.EXPORTED_PACKAGES`에 들어 있으므로 이 도달은 선언된 export를 통한 것이다. zero-reference public type은 없다.
|
||
|
||
### 118.2 Conditional sibling comparison
|
||
|
||
두 축에서 비교했다.
|
||
|
||
- **`@ConfigurationProperties` 3형제**: §116. 셋 중 하나만 enablement가 없다 — 비대칭이 확인된 결함이다.
|
||
- **configuration 활성화 조건**: `PersistenceJpaConfig`와 `JpaAdapterComponentsConfig`는 조건이 없고, `PostgreSqlPersistenceConfig`/`H2PersistenceConfig`는 vendor 조건, `NotificationJpaPersistenceConfig`/`FileserverJpaPersistenceConfig`는 capability 조건을 갖는다. 무조건인 둘은 composition root의 JPA master switch(`ca-skeleton.persistence-jpa.enabled`) 뒤 `@Import`로만 도달하므로 "off는 구조적 사실"이라는 설계가 유지된다.
|
||
|
||
### 118.3 Duplicate-mechanism sweep
|
||
|
||
entity/repository/component scan 선언을 repository 전체에서 훑었다. persistence 관련 선언은 5곳이다 — leaf의 `PersistenceJpaConfig`(always), `NotificationJpaPersistenceConfig`, `FileserverJpaPersistenceConfig`, `JpaAdapterComponentsConfig`(component scan만), 그리고 `sample-portfolio`의 자기 package 전용 `JpaConfig`. app-bootstrap production에는 persistence entity/repository scan이 없다. test 쪽 2곳(`OutboxContainerTestSupport`, `FileserverRoundTripContractTest` 주석)은 harness 소유다. 경쟁 구현은 없다.
|
||
|
||
### 118.4 Documentation / measured-count drift
|
||
|
||
§119에서 따로 다룬다.
|
||
|
||
## 119. Confirmed documentation / measured-count drift
|
||
|
||
근거: `evidence/raw/110-governance-doc-count-drift.txt`.
|
||
|
||
이 repository는 leaf count drift를 잡는 전용 gate를 갖고 있다. root `build.gradle`의 `verifyDocumentedLeafCount`는 `(\d+)\s*(?:개\s*)?-?\s*(?:leaf|leaves)` 패턴을 찾아 registry의 실제 leaf 수와 비교하고, `check`에 연결돼 있다. 그 gate의 주석 자체가 "named list missed five module CLAUDE.md files and four leaf build.gradle headers, each restating 19-leaf from before the messaging platform's leaves were registered"라고 과거 사고를 기록한다.
|
||
|
||
그런데 gate의 탐색 domain은 `CLAUDE.md`와 (root를 뺀) `build.gradle` **두 파일명뿐**이다. `README.md`, `docs/**`, `*.java`는 들어가지 않는다. 그 사각지대에 stale claim이 그대로 남아 있다.
|
||
|
||
| 항목 | 문서가 말하는 값 | 측정값 | 위치 |
|
||
|---|---|---|---|
|
||
| registered leaf | 19 | **44** | `docs/jpa/repository-adaptation.md:21`, `:101`, `JpaModuleBoundaryTest.java:20` |
|
||
| 같은 claim(형제 leaf) | 19 | 44 | httpclient / mongo / cache-redis boundary test 3개 |
|
||
| public top-level type | "318 of 324 production files" | **338 / 350** | `JpaModuleBoundaryTest.java:124`, `CleanArchitectureTest.java:1110` |
|
||
| package root | `dev.caskeleton.adapter.persistence` | `dev.caskeleton.adapter.outbound.persistence` | `README.md:3`, `:77` |
|
||
| module 이름 | `adapter-persistence-rdbms` | registry id `adapter-outbound-persistence-jpa` | `README.md:1` |
|
||
|
||
`verifyDocumentedLeafCount`는 실제로 실행했고 **통과한다**(`114-governance-pool-original-verification.txt`, `BUILD SUCCESSFUL`, 9 actionable executed). 즉 gate가 green인 상태에서 leaf 자신의 module-boundary test와 leaf 자신의 adaptation 문서가 44개 registry를 19개라고 말하고 있다. `docs/jpa/repository-adaptation.md`는 leaf `build.gradle`이 `tasks.named('test') { inputs.file(...) }`로 up-to-date 입력에 명시한 **살아 있는** 문서라 더 눈에 띈다.
|
||
|
||
public type 수는 확인 가능한 측정값이다. `docs/architecture/jpa-api-surface.txt`의 committed baseline이 스스로 `# types: 338`을 적고 있고 비주석 항목도 338개다. production Java 파일은 350개다. 문서의 318/324는 두 값 모두 과거치다.
|
||
|
||
반대로 **drift가 아닌 것**도 기록해 둔다. `CLAUDE.md` §Platform lanes가 나열한 7개 lane task 이름은 전부 실재하고, root `jpaReleaseGate`도 `build.gradle:1097`에 등록돼 있다. lane 이름 쪽 문서는 현재 정확하다.
|
||
|
||
**판정: P3 documentation/count drift(확정).** 코드 동작에는 영향이 없다. 영향은 신뢰도다 — module boundary test의 도입 주석이 module registry 크기를 절반 이하로 말하고 있고, 그 숫자를 잡으려고 만든 gate는 java 파일을 보지 않는다. 수정은 두 갈래다. 숫자를 고치거나(값이 또 늙는다), gate가 권하는 대로 "registry가 목록의 소유자"라고 명사를 바꾸거나. gate의 탐색 domain을 `*.java`/`docs/**`로 넓히는 쪽이 근본적이지만, 그러면 위 6개 파일이 즉시 red가 되므로 함께 고쳐야 한다.
|
||
|
||
## 120. Sub-scope 01 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| **P2** | `PersistenceVendorSettings`가 production에서 `@ConfigurationProperties` bean으로 등록되지 않아, 문서가 약속한 "unknown vendor는 startup에서 property를 지목하며 실패" 계약이 shipped composition에 없다 | **shipped composition — 모든 JPA-on 배포** |
|
||
| **P3** | `verifyDocumentedLeafCount`의 탐색 domain(`CLAUDE.md`/`build.gradle`) 밖에서 19-leaf claim 6곳이 생존, registry는 44 | 문서/주석; build는 green |
|
||
| **P3** | `JpaModuleBoundaryTest`/`CleanArchitectureTest`의 "318 of 324 production files" 측정 주석이 현재 338/350과 불일치 | 주석 |
|
||
| **P3** | `README.md`가 package root를 `dev.caskeleton.adapter.persistence`로, module을 `adapter-persistence-rdbms`로 적음 | 문서 |
|
||
| **P3** | `PersistenceEntityScanCoverageTest`가 always-install configuration의 `@EntityScan` / `@EnableJpaRepositories` 목록 일치를 검사하지 않음(opt-in 쪽은 검사) | latent — 현재 두 목록 동일 |
|
||
| **P3/기록** | `JpaModuleBoundaryTest.PACKAGE_CATALOG`의 `postgresql -> "inbox"` 항목이 존재하지 않는 top-level package를 가리켜 사문 | 무해(엄격 방향) |
|
||
|
||
## 121. Sub-scope 01 완료 조건
|
||
|
||
- denominator 11 / 11 FULL_READ (`107-...`)
|
||
- public surface reachability / conditional sibling / duplicate mechanism 3종 probe 수행(`108-...`)
|
||
- documentation/count drift 재측정 및 확정(`110-...`), gate 실행 결과 포함(`114-...`)
|
||
- 실행 probe 1건(`109-...`, `109a-...`)으로 P2 확정, 원본 source 복구 후 `git status --short` clean
|
||
- original source에서 leaf unit lane 재실행 green(`114-...`)
|
||
|
||
---
|
||
|
||
## 122. Sub-scope 12 범위와 denominator
|
||
|
||
> 내부 상태: COMPLETE — **3 / 3 FULL_READ**
|
||
> 범위: `src/jpaPlatformPerformanceTest` (268 Java LOC)
|
||
> Gradle lane: `jpaPlatformPoolContractTest`
|
||
|
||
| 파일 | 라인 | 컨테이너 |
|
||
|---|---:|---|
|
||
| `platform/pool/HikariPoolSaturationContractTest.java` | 119 | 자체 PG 16 |
|
||
| `platform/pool/RequiresNewPoolPressureContractTest.java` | 100 | 자체 PG 16 |
|
||
| `platform/pool/PoolPressureContractTest.java` | 49 | 없음 |
|
||
|
||
manifest: `evidence/raw/111-persistence-jpa-pool-lane-manifest.txt`. `PoolLaneClaimTest`(lane 이름/문구 drift guard)와 `PoolMeasurement`, `PostgreSqlContainerFactory`는 각각 sub-scope 06·10 소유라 여기서는 cross-scope 참조로만 쓴다.
|
||
|
||
## 123. 이 lane의 역사는 이미 한 번 교정됐다
|
||
|
||
lane 이름은 `jpaPlatformPerformanceTest`였고, "pool pressure certification"을 한다고 서술됐으며, `performance.assertions.enabled` flag 뒤에 있었다 — 그런데 그 flag는 build에서도, 그것을 명시적으로 끄던 nightly job에서도 기본값이 false였다. `build.gradle`의 주석이 그 결과를 직설적으로 적는다. "the release gate depended on a lane whose only threshold assertion was that thresholds were not being asserted".
|
||
|
||
교정은 세 갈래로 이루어졌다. task 이름을 `jpaPlatformPoolContractTest`로 바꾸고, flag를 제거하고, `PoolLaneClaimTest`가 lane을 설명하는 6개 파일에 그 flag 이름과 "certification"/"machine bounds" 문구가 남아 있지 않은지 텍스트로 검사한다. flag 이름은 test 자신이 검색 대상이 되지 않도록 세 조각으로 나눠 상수를 만든다 — 자기 자신을 매칭하는 guard는 통과할 수 없다는 것까지 고려돼 있다.
|
||
|
||
## 124. 남아 있는 문제 — lane이 "행동 계약"이라고 부르는 것 중 둘은 산술 항등식이다
|
||
|
||
교정 뒤에도 lane의 8개 test 중 2개는 데이터베이스도, pool도 보지 않는다.
|
||
|
||
`PoolPressureContractTest.requiresNewNeedsTwoConnectionsPerThread()`:
|
||
|
||
```java
|
||
int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1; // 8 * 2 + 1
|
||
assertThat(required).isEqualTo(17);
|
||
```
|
||
|
||
`RequiresNewPoolPressureContractTest.sizingRuleMatchesTheObservedRequirement()`:
|
||
|
||
```java
|
||
int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1; // 1 * 2 + 1
|
||
assertThat(required).isEqualTo(3);
|
||
```
|
||
|
||
두 test 모두 공식을 test 안에서 다시 쓴 다음 그 결과를 상수와 비교한다. `CLAUDE.md`의 pool-sizing 공식이 바뀌어도 이 assertion은 실패하지 않는다. 특히 두 번째는 `@DisplayName`이 "the sizing rule matches the observed requirement"인데, 같은 class가 **관측한** 요구치는 2다(pool 1은 실패, pool 2는 성공). 공식의 답 3과 관측치 2를 비교하는 assertion은 없고, 주석이 "the rule adds headroom"이라고 차이를 설명할 뿐이다.
|
||
|
||
`PoolPressureContractTest.reportsPendingAndAcquireLatencyTogether()`도 손으로 만든 `PoolMeasurement(4, 2, 3, 80ms)`의 accessor를 확인한다. 이는 record 계약 검증이지 pool 관측이 아니다.
|
||
|
||
**판정: P3.** lane은 `jpaPlatformReleaseGate`의 구성원이므로 여기서 green이 나는 것이 release 판단에 들어간다. 다만 lane의 실질 가치는 나머지 6개(컨테이너 기반) test가 만들고, 이 2개는 그 위에 얹힌 항등식이다. flag를 없앤 교정이 "측정하지 않는 것을 측정한다고 말하지 않기"였다면, 이 두 개는 그 교정이 닿지 않은 잔여물이다.
|
||
|
||
## 125. Confirmed P2 — nightly workflow가 광고하는 세 가지 중 하나를 lane이 실제로 관측하지 않는다
|
||
|
||
`.github/workflows/jpa-nightly.yml:122-129`는 이 lane이 검사하는 것을 세 가지로 적는다.
|
||
|
||
> that REQUIRES_NEW needs two connections per concurrent thread, that **a saturated pool reports its pending count**, that a caller waits rather than proceeding without a connection
|
||
|
||
세 번째와 첫 번째는 실제 assertion이 있다. 두 번째는 없다. lane 전체에서 `pending()`/`saturated()`를 assert하는 곳은 `PoolPressureContractTest`의 손으로 만든 record 하나뿐이고, 실제 pool에서 `getThreadsAwaitingConnection()`을 읽는 유일한 지점(`HikariPoolSaturationContractTest.measurementReportsPoolState`)은 **포화되지 않은** pool(size 2, 1개 점유)에서 읽은 뒤 `active()==1`과 `total()>=1`만 확인하고 pending에 대해서는 아무 assertion도 하지 않는다.
|
||
|
||
### 실행 probe
|
||
|
||
`evidence/raw/113-pool-lane-saturation-probe.txt` / `113a-...java`. 실제 PostgreSQL 16 + Hikari(size 2)에서 두 연결을 점유하고 세 번째 요청 스레드를 대기시킨 뒤 측정했다.
|
||
|
||
```text
|
||
realPool.active=2
|
||
realPool.idle=0
|
||
realPool.pending=1
|
||
realPool.saturated=true
|
||
realPool.waiterOutcome=acquired
|
||
```
|
||
|
||
즉 이 주장은 **검증 가능하며 현재 검증되지 않고 있다**. 참고로 손으로 만든 fixture 상태(active=4, idle=2, pending=3)는 실제 포화 pool이 보여준 조합(idle=0)과 다르다.
|
||
|
||
같은 probe에서 acquisition 경계도 측정했다.
|
||
|
||
```text
|
||
acquire.configuredTimeoutMillis=500
|
||
acquire.observedWaitMillis=504
|
||
acquire.outcome=SQLTransientConnectionException
|
||
acquire.assertedUpperBoundMillis=2500
|
||
```
|
||
|
||
`HikariPoolSaturationContractTest`의 javadoc은 "a further acquisition must fail **within the configured timeout**"이라고 쓰지만 assertion 상한은 `ACQUIRE_TIMEOUT.plusSeconds(2)` = 2,500ms — 설정값의 5배다. 실제 동작은 504ms로 설정값에 4ms 붙어 있다. CI 여유를 감안해도 상한이 400% 넓어서, 500ms 설정에도 2초를 기다리게 되는 회귀는 이 assertion을 통과한다. P3.
|
||
|
||
## 126. release gate 소속은 양방향으로 검증되지 않는다
|
||
|
||
근거: `evidence/raw/112-pool-lane-claim-registry-reachability.txt`.
|
||
|
||
`config/jpa/release-registry.json`의 gate는 6개이고 모두 blocking이다.
|
||
|
||
```text
|
||
postgresql-contract :adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
||
completion-unknown-no-retry :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
|
||
osiv-disabled :app-bootstrap:test
|
||
flyway-validate :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
|
||
runtime-role-no-ddl :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
|
||
collection-fetch-pagination :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
|
||
```
|
||
|
||
pool lane은 registry에도, `docs/jpa/support-matrix.md` §Release gates 6행에도, testkit `JpaReleaseGate.required()`에도 없다(세 곳 모두 grep exit=1). 그런데 `jpaPlatformReleaseGate`는 `dependsOn jpaPlatformPoolContractTest`를 갖고, root `jpaReleaseGate`가 그것을 다시 의존한다.
|
||
|
||
`verifyJpaReleaseGateTasks`는 registry → task graph 한 방향만 검사한다(registry의 각 gate가 실제 `Test` task로 resolve되는가). 반대 방향 — release gate에 들어 있는 lane이 registry에 있는가 — 은 어디서도 검사되지 않는다. 따라서 `jpaPlatformReleaseGate`에서 pool lane 의존을 지워도 어떤 verifier도 반응하지 않고, 남는 실행 경로는 nightly workflow 한 줄뿐이다.
|
||
|
||
이것을 결함으로 올리지는 않는다. `jpaPlatformReleaseGate`의 주석이 밝힌 집계 기준은 "documented gate가 검증되지 않은 채 통과하게 만드는 lane"이고, pool lane은 문서화된 gate를 뒷받침하지 않으므로 기준상 registry에 없는 것이 일관적이다. 다만 그 결과로 이 lane의 release gate 소속만은 아무 계약도 보호하지 않는다는 사실을 기록한다. P3.
|
||
|
||
## 127. Fresh verification evidence — sub-scope 12
|
||
|
||
- `evidence/raw/114-governance-pool-original-verification.txt` — original source에서 `:adapter:outbound:persistence-jpa:test --rerun-tasks` **BUILD SUCCESSFUL in 28s / 18 executed**, `jpaPlatformPoolContractTest --rerun-tasks` **BUILD SUCCESSFUL in 24s / 18 executed**, root `verifyDocumentedLeafCount --rerun-tasks` **BUILD SUCCESSFUL in 3s / 9 executed**, git clean before/after
|
||
- `evidence/raw/113-...` / `113a-...` — 실제 포화 pool의 pending/saturated 관측, acquisition 대기 504ms 대 설정 500ms 대 assertion 상한 2,500ms
|
||
|
||
## 128. Sub-scope 12 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| **P2** | nightly workflow가 lane의 검증 항목으로 명시한 "a saturated pool reports its pending count"를 어떤 assertion도 실제 pool에서 확인하지 않음(손으로 만든 record만 확인) | release gate 구성 lane; 관측 가능함을 probe로 확인 |
|
||
| **P3** | `PoolPressureContractTest`/`RequiresNewPoolPressureContractTest`의 sizing-rule assertion 2개가 공식을 test 안에서 재작성한 뒤 자기 자신과 비교하는 항등식 | 실패할 수 없는 assertion |
|
||
| **P3** | saturation timeout assertion 상한이 설정값의 5배(2,500ms vs 500ms)여서 javadoc이 말하는 "within the configured timeout"을 강제하지 않음 | 회귀 탐지 폭 |
|
||
| **P3/기록** | pool lane이 release gate 구성원이면서 release registry·support matrix·`JpaReleaseGate` 어디에도 없어, 소속이 양방향으로 검증되지 않음 | governance |
|
||
|
||
## 129. Sub-scope 12 완료 조건
|
||
|
||
- denominator 3 / 3 FULL_READ (`111-...`)
|
||
- lane claim / release-gate 소속 / assertion 실체 3종 대조(`112-...`)
|
||
- 실제 PostgreSQL probe 1건(`113-...`, `113a-...`), 원본 복구 후 git clean
|
||
- original source lane 재실행 green(`114-...`)
|
||
|
||
---
|
||
|
||
## 130. Sub-scope 11 범위와 denominator
|
||
|
||
> 내부 상태: COMPLETE — **75 / 75 FULL_READ**
|
||
> 범위: `src/postgresqlIntegrationTest` (71 Java + 4 SQL, 13,977 lines)
|
||
> 역할: 이 leaf에서 "실제 PostgreSQL이 답해야만 하는 주장"의 증거 생산자 전부
|
||
|
||
manifest: `evidence/raw/120-persistence-jpa-integration-lane-manifest.txt`.
|
||
|
||
| package | 파일 | 성격 |
|
||
|---|---:|---|
|
||
| `platform/**` (experimental 5 포함) | 33 | design §11~§40 계약, tag 기반 lane |
|
||
| `readiness/**` | 29 | readiness card producer + tag lane 혼재 |
|
||
| `notification/**` | 8 | notification 저장소 계약 + fixture |
|
||
| `operation/`, `liveevent/` | 2 | durable operation / live event 저장소 계약 |
|
||
| `resources/db/readiness/**` | 4 SQL | Flyway 시나리오 fixture(중단·롤링) |
|
||
|
||
이 sub-scope는 sub-scope 10(testkit)과 같은 성격이다 — **production 동작이 아니라 evidence 생산자의 정확성**을 분석한다. 다만 규모가 다르다. testkit이 62파일 3,070 LOC였다면 여기는 75파일 13,977 LOC이고, 이 leaf가 "H2로는 만족시킬 수 없다"고 선언한 모든 계약이 여기에 있다.
|
||
|
||
## 131. 이 source set 안에 서로 다른 두 개의 evidence 세계가 있다
|
||
|
||
파일은 한 source set에 있지만 실행 경로는 둘로 갈라진다.
|
||
|
||
**(1) tag lane.** `@Tag("jpa-contract" | "jpa-migration" | "jpa-failure" | "jpa-queryplan" | "jpa-security")`를 단 클래스는 `registerJpaPlatformLane`이 만든 5개 Test task가 `includeTags`로 고른다. 이 5개가 `jpaPlatformReleaseGate`와 release registry의 blocking gate에 연결된다.
|
||
|
||
**(2) readiness card.** `@Tag`가 없는 클래스는 `registerPostgreSqlReadinessTest`가 클래스 이름으로 하나씩 등록한 14개 task가 `filter.includeTestsMatching`으로 고른다. 이 task들은 `config/jpa/readiness-cards.yaml`의 card가 `readiness-task` / `support-tasks`로 지목하고, `generateJpaEvidenceManifests`가 active card의 producer를 모두 `dependsOn`한다.
|
||
|
||
두 세계의 대응은 정확히 맞아떨어진다. `@Tag`가 없는 test 클래스는 13개, readiness task가 이름으로 지목하는 클래스는 14개이고, 그 차이 1개는 `PostgreSqlNotificationSchemaActivationIntegrationTest` — 유일하게 tag와 readiness task를 **둘 다** 가진 클래스다. 즉 orphan test class는 0개다. `--dry-run`으로 실제 task graph를 resolve해 확인했다.
|
||
|
||
```text
|
||
$ ./gradlew :adapter:outbound:persistence-jpa:generateJpaEvidenceManifests --dry-run
|
||
… postgresqlAggregateIntegrationTest … postgresqlFileserverMetadataIntegrationTest
|
||
… postgresqlFileserverMigrationIntegrationTest … postgresqlFileserverReclamationIntegrationTest
|
||
… (14개 전부 SKIPPED 로 등장)
|
||
```
|
||
|
||
Fileserver migration/reclamation 두 task는 card의 `readiness-task`가 아니라 `jpa-fileserver-metadata-v1`의 `support-tasks`로 들어와 있어서 실행은 되지만 `evidence.task-claims`가 비어 있어 어떤 required-evidence tag도 덮지 않는다. 실행은 fail-closed, 증거 연결은 없음 — 결함은 아니고 배선의 성격이다.
|
||
|
||
반대로 `jpaPlatformReleaseGate --dry-run`에는 readiness task가 **하나도** 없다. 그리고 `.github/workflows` 전체에서 `postgresql*IntegrationTest` task를 직접 부르는 곳도 없다(grep exit=1). readiness 세계의 유일한 자동 실행 경로는 `ci-quality-gates.yml`의 `jpa-candidate-evidence` job이 부르는 `verifyJpaCandidateEvidence`와 `jpa-r2-evidence.yml`의 `verifyJpaPrimaryFoundationEvidence` 둘뿐이다. 이 구조가 §132의 결함이 오래 보이지 않은 이유다.
|
||
|
||
card 자체의 수치도 재측정했다. 총 17개 card 중 `not-implemented` 4개를 뺀 **13개가 active**(selected 7 + implemented-candidate 6)이고, `generateJpaEvidenceManifests`는 그중 `jpa-primary-foundation`을 제외한 **12개의 readiness-task**를 의존한다. `README.md`가 "active card 11개의 producer를 실행"이라고 적은 것과는 어긋난다(작은 count drift, P3).
|
||
|
||
## 132. Confirmed P1 — selected base card `jpa-flyway-migration`의 producer가 현재 revision에서 실패한다
|
||
|
||
`postgresqlMigrationIntegrationTest`는 `jpa-flyway-migration` card의 `readiness-task`다. 이 card는 `state: selected`이고 `jpa-primary-foundation`(R2 집계 gate)의 prerequisite 6개 중 하나다. 원본 소스, `--rerun-tasks`, git clean 상태에서 실행하면 **BUILD FAILED**다.
|
||
|
||
근거: `evidence/raw/117-flyway-migration-readiness-lane-failure.txt`, `118-readiness-task-result-matrix.txt`.
|
||
|
||
```text
|
||
PostgreSqlMigrationIntegrationTest > adoptsImmutableLegacyHistoryThenRunsTheIndependentCoreStream() FAILED
|
||
PostgreSqlMigrationIntegrationTest > freshCoreStreamInitializesWithoutLegacyHistory() FAILED
|
||
taskExit=1
|
||
```
|
||
|
||
두 실패의 정체는 같다 — stream에 migration이 추가됐는데 그 stream의 applied set을 고정한 assertion이 갱신되지 않았다.
|
||
|
||
```text
|
||
Expecting actual:
|
||
["1", "3", "4", "5", "6", "9", "10", "11", "12"]
|
||
to contain exactly (and in same order):
|
||
["1", "3", "4", "5", "6"]
|
||
but some elements were not expected:
|
||
["9", "10", "11", "12"]
|
||
```
|
||
|
||
```text
|
||
Expecting actual:
|
||
["1", "2"]
|
||
to contain exactly (and in same order):
|
||
["1"]
|
||
but some elements were not expected:
|
||
["2"]
|
||
```
|
||
|
||
앞은 `db/migration/postgresql`(legacy adoption stream, 현재 9개 파일), 뒤는 `db/migration/jpa/core`(현재 2개 파일)다. history로 시점을 맞춰 보면 원인이 분명하다.
|
||
|
||
```text
|
||
2026-07-31 PostgreSqlMigrationIntegrationTest.java ← assertion 최종 수정
|
||
2026-08-15 db/migration/jpa/core/V2__widen_capability_schema_stream.sql
|
||
2026-08-15 db/migration/postgresql/V9__widen_capability_schema_stream.sql
|
||
2026-08-18 db/migration/postgresql/V10__idempotency_request_hash_varchar.sql
|
||
2026-08-28 db/migration/postgresql/V11__durable_operation.sql
|
||
2026-08-28 db/migration/postgresql/V12__live_event_log.sql
|
||
```
|
||
|
||
4주에 걸쳐 5개 migration이 두 stream에 들어오는 동안 이 lane의 assertion은 한 번도 갱신되지 않았다. 같은 source set의 `PostgreSqlOptionalStreamLifecycle`은 이 실패 유형을 정확히 알고 있다 — notification stream의 버전 목록 주석에 "the version that landed without being added is why the lane failed the first time anybody ran it"라고 적혀 있다. 그 교훈이 base stream 쪽 lane에는 적용되지 않았다.
|
||
|
||
**드러나지 않은 이유**는 §131의 구조다. 이 task는 5개 tag lane 어디에도 속하지 않고, `jpaPlatformReleaseGate`에도 없고, 어떤 workflow도 이름으로 부르지 않는다. 같은 실행에서 5개 tag lane은 **244 tests / 0 failures**로 전부 green이었다. 이 lane을 실제로 도는 자동 경로는 `verifyJpaCandidateEvidence` 하나뿐이고, 그것이 실패하면 `jpa-candidate-evidence` CI job이 red가 된다.
|
||
|
||
같이 실행한 나머지 13개 readiness task 결과는 다음과 같다(`118-...`).
|
||
|
||
```text
|
||
task tests skip failures errors verdict
|
||
postgresqlAggregateIntegrationTest 1 0 0 0 PASS
|
||
postgresqlFileserverMetadataIntegrationTest 12 0 0 0 PASS
|
||
postgresqlFileserverMigrationIntegrationTest 6 0 0 0 PASS
|
||
postgresqlFileserverReclamationIntegrationTest 15 0 0 0 PASS
|
||
postgresqlIdempotencyIntegrationTest 8 0 0 0 PASS
|
||
postgresqlInboxIntegrationTest 5 0 0 0 PASS
|
||
postgresqlLifecycleIntegrationTest 2 0 0 0 PASS
|
||
postgresqlMigrationIntegrationTest 4 0 2 0 FAIL
|
||
postgresqlNotificationSchemaActivationIntegrationTest 8 0 0 0 PASS
|
||
postgresqlOutboxPollingIntegrationTest 5 0 0 0 PASS
|
||
postgresqlOutboxStorageIntegrationTest 5 0 0 0 PASS
|
||
postgresqlQueryIntegrationTest 1 0 0 0 PASS
|
||
postgresqlSecurityBaselineIntegrationTest 3 0 1 0 FAIL
|
||
postgresqlTransactionIntegrationTest 7 0 0 0 PASS
|
||
```
|
||
|
||
`postgresqlSecurityBaselineIntegrationTest`의 실패는 **분석 환경 제약**이지 결함이 아니다. `verifyFullAcceptsTrustedHostAndRejectsHostnameMismatchAndUntrustedCertificate`는 `PostgreSqlTlsMaterial`이 `CN=localhost` / `SAN=DNS:localhost`로 발급한 인증서를 `verify-full`로 검증하므로 컨테이너의 매핑 포트가 **테스트 JVM의 loopback**에서 열려 있어야 한다. 이번 분석은 Docker 소켓을 공유하는 형제 컨테이너 안에서 실행돼 매핑 포트가 Docker 브리지(172.17.0.1)에만 열렸고, 실패는 `java.net.ConnectException`이다. 이 lane은 skip이 아니라 실패하도록 설계돼 있으므로(no-skip) 동작 자체는 의도대로다. 다만 "no-skip"의 대가로 **Docker 호스트와 테스트 JVM이 loopback을 공유하는 환경**이 이 lane의 암묵적 전제가 된다는 사실은 기록해 둔다.
|
||
|
||
**판정: P1 confirmed.** 수정은 assertion을 stream의 현재 applied set으로 갱신하는 것이고, 재발 방지는 `PostgreSqlOptionalStreamLifecycle`이 이미 쓰는 방식(stream별 버전 목록을 한 곳에 고정)을 base stream에도 적용하는 것이다. 더 근본적으로는 이 lane이 tag lane과 완전히 분리돼 있다는 구조 자체가 재검토 대상이다 — 5개 tag lane이 green이라는 사실이 readiness lane의 상태에 대해 아무것도 말해주지 않는다.
|
||
|
||
## 133. Confirmed P2 — selected base card 3개의 evidence tag가 production code 없는 fixture로 충족된다
|
||
|
||
card의 `required-evidence` tag는 `evidence.scenarios[].covers`로 실제 JUnit selector에 연결된다. 그 연결을 실제 test 본문과 대조하면 세 card에서 tag와 증거의 격차가 나온다.
|
||
|
||
**`jpa-observability-lifecycle` — `observability`**
|
||
`PostgreSqlLifecycleIntegrationTest.poolCapacityExhaustionAndShutdownAreBoundedAndObservable`이 이 tag를 덮는다. 관측 assertion은 다음 한 줄이다.
|
||
|
||
```java
|
||
assertThat(saturated.boundedTags())
|
||
.containsExactlyInAnyOrderEntriesOf(
|
||
Map.of("component", "postgresql-primary", "state", "saturated"));
|
||
```
|
||
|
||
`boundedTags()`는 같은 파일 안의 private record `PoolSnapshot`의 메서드이고, 비교 대상 literal도 같은 파일에 있다. `postgresql-primary`라는 문자열은 repository 전체에서 이 파일 두 줄에만 존재한다(`108-...` 계열 검색과 별개로 `git grep` 확인). production의 persistence metric tag는 `JpaMetricTags`가 만드는 `persistence.unit` / `persistence.operation` / `persistence.query` / `outcome` / `failure.category` 5종이고 `component` tag도, pool 상태 metric도 없다. 즉 이 card의 `observability` 증거는 production 계측이 하나도 없어도 그대로 green이다. pool 자체의 포화/종료 동작(active 2, 종료 후 0, closed)은 실제로 관측하므로 lifecycle 쪽 증거는 유효하다 — 문제는 `observability` tag가 그 위에 얹혀 있다는 점이다.
|
||
|
||
**`jpa-query-model` — `query-contract`, `query-plan`**
|
||
유일한 scenario `PostgreSqlQueryIntegrationTest.boundedKeysetQueryUsesTheRepresentativeIndex`는 `readiness_query` 테이블·인덱스·쿼리를 test가 직접 만든다. production의 `KeysetPageRequest`/`KeysetSlice`/`springdata` keyset 실행 경로는 한 줄도 지나지 않는다. 게다가 plan 확인 직전에 `set enable_seqscan=off`를 실행한다 — 대안을 제거한 상태에서 "인덱스를 쓴다"를 확인하는 것이라, 인덱스나 쿼리 모양이 나빠도 index scan이 가능하기만 하면 통과한다. 같은 source set에는 production `PostgreSqlExplainRunner`/`QueryPlanAssertions`로 실제 plan 구조와 추정 오차를 보는 `PostgreSqlQueryPlanContractTest`가 있는데, card는 그쪽을 가리키지 않는다.
|
||
|
||
**`jpa-aggregate-store` — `mapping`, `optimistic-conflict`**
|
||
유일한 scenario `PostgreSqlAggregateIntegrationTest.roundTripsUuidAndInstantAndDetectsExpectedVersionConflict`는 `readiness_aggregate` 테이블에 대해 raw JDBC로 UUID/timestamptz 왕복과 `update ... where version = ?`가 0행을 반환하는 것을 확인한다. JPA entity도, `@Version`도, Hibernate optimistic locking도, production repository도 없다. **JPA** aggregate store card의 `mapping` 증거가 JPA를 거치지 않는다. 이쪽 역시 같은 source set에 production 경로를 쓰는 `JpaValueMappingContractTest`(Hibernate + `MappingEntity`)와 `OptimisticRetryIntegrationTest`(production `OptimisticConflictTranslator` + 실제 버전 충돌)가 있다.
|
||
|
||
이 셋을 하나로 묶는 사실은 다음과 같다. **card scenario가 가리키는 클래스 12개는 전부 `readiness/**` 안에 있고, `platform/**`의 33개 계약 test 중 card가 가리키는 것은 0개다.** 강한 증거 생산자와 card evidence가 서로 다른 세계에 있고, tag는 약한 쪽에 붙어 있다.
|
||
|
||
**판정: P2 confirmed.** 대비되는 반례가 같은 card 집합 안에 있다는 점이 판단을 쉽게 해 준다 — `jpa-transaction-runtime`의 7개 scenario는 production `SpringTransactionPort` + `PostgreSqlLocalTimeoutConfigurer` + `PersistenceExceptionTranslator`를 실제 서버에서 돌리고, deadlock 40P01, serializable 재시도, lock/statement timeout 경계, pool admission 거부, `pg_terminate_backend`로 만든 commit 유실의 `INDETERMINATE` 판정까지 확인한다. 즉 이 결함은 체계적인 것이 아니라 세 card에 국한된다. 수정은 tag를 옮기는 문제다 — 이미 존재하는 강한 test를 scenario로 등재하거나, 약한 scenario의 `covers`에서 과대 tag를 떼는 것.
|
||
|
||
## 134. notification contract fixture는 하나의 stream을 세 갈래로 다시 만든다
|
||
|
||
같은 source set 안에서 notification schema를 만드는 방법이 두 가지다.
|
||
|
||
- `readiness/PostgreSqlNotification*`은 Flyway를 `classpath:db/migration/jpa/notification-platform`에 겨눈다 — stream 전체가 자동으로 따라온다. `PostgreSqlNotificationSchemaActivationIntegrationTest`는 한 걸음 더 나아가 production `NotificationSchemaStream`을 호출하며 그 이유를 적는다("a test that restates them proves that two authors agreed rather than that the stream is right").
|
||
- `notification/*ContractTest`는 migration 파일 이름을 **손으로 나열**해 `Statement.execute`로 돌린다. 그리고 그 목록이 세 벌 있고 셋 다 길이가 다르다.
|
||
|
||
| 목록 소유자 | notification 버전 | 주석 |
|
||
|---|---|---|
|
||
| `NotificationFixtures.migrations()` | V1–V9 | "The whole notification stream, in order… Applying a subset certifies a schema nobody deploys" |
|
||
| `RecipientClaimContractTest.migrations()` | V1–V8 | "The whole stream, in order. Applying a subset certifies a schema nobody deploys — … which is how the first version of this test discovered that it was testing a database that could not exist." |
|
||
| `ProjectionFactDurabilityContractTest.migrations()` | V1–V6 | (주석 없음) |
|
||
| (배포 stream) | **V1–V10** | `PostgreSqlOptionalStreamLifecycle.notificationPlatform()`이 applied set `0..10`으로 고정 |
|
||
|
||
세 목록 모두 "the whole stream"이라고 말하고, 셋 다 아니다.
|
||
|
||
### 실행 probe
|
||
|
||
`evidence/raw/119-notification-migration-ladder-probe.txt` / `119a-...java`. 각 사다리를 실제 PostgreSQL 16에 적용하고 `information_schema.columns`로 `notification%` 테이블의 컬럼 집합을 비교했다.
|
||
|
||
```text
|
||
ladder.notificationVersions=6 columns=208
|
||
ladder.notificationVersions=8 columns=213
|
||
ladder.notificationVersions=9 columns=218
|
||
ladder.notificationVersions=10 columns=218
|
||
|
||
ladder.9.missing=[]
|
||
ladder.8.missing=[notification_delivery_attempt.provider_acceptance,
|
||
notification_delivery_attempt.provider_acceptance_certainty,
|
||
notification_delivery_attempt.provider_response_received_certainty,
|
||
notification_delivery_attempt.request_body_committed_certainty,
|
||
notification_delivery_attempt.request_started_certainty]
|
||
ladder.6.missing=[notification_admin_audit.claimed_at,
|
||
notification_admin_audit.command_fingerprint,
|
||
notification_admin_audit.phase,
|
||
notification_delivery_attempt.provider_acceptance,
|
||
notification_delivery_attempt.provider_acceptance_certainty,
|
||
notification_delivery_attempt.provider_response_received_certainty,
|
||
notification_delivery_attempt.request_body_committed_certainty,
|
||
notification_delivery_attempt.request_started_certainty,
|
||
notification_request.collapse_key,
|
||
notification_request.collapse_scope]
|
||
```
|
||
|
||
읽는 방법은 이렇다.
|
||
|
||
- **V1–V9는 현재 배포 형상과 컬럼이 동일하다(218 = 218).** V10이 DDL 없는 guard(`variables_payload`가 base64 envelope가 아닌 행이 있으면 `RAISE EXCEPTION`)이기 때문이다. 그래서 `NotificationFixtures`를 쓰는 5개 test는 지금은 배포 형상 위에서 돈다. 다만 stream보다 한 칸 뒤에 있으므로 **다음에 DDL을 가진 migration이 들어오는 순간** 조용히 어긋난다.
|
||
- **V1–V8은 evidence-certainty 5개 컬럼이 없다.** `RecipientClaimContractTest`는 `notification_recipient_delivery`만 다루므로 현재 false-green은 없다.
|
||
- **V1–V6은 admin-claim 3 + evidence-certainty 5 + collapse 2, 총 10개 컬럼이 없다.** `ProjectionFactDurabilityContractTest`는 V6가 도입한 projection fact 컬럼만 다루므로 역시 현재 false-green은 없다.
|
||
|
||
**판정: P3.** 현재 잘못된 통과를 만드는 경로는 확인되지 않는다. 문제는 유지보수 계약이다 — stream에 migration을 하나 추가하려면 네 곳(Flyway location은 자동, 나머지 세 목록은 수동)을 맞춰야 하고, 세 목록은 이미 각각 1·2·4 버전씩 뒤처져 있다. 그리고 세 목록의 주석이 모두 "subset은 아무도 배포하지 않는 schema를 인증한다"고 경고하고 있다. 수정 방향은 같은 source set이 이미 보여 준다 — Flyway location을 겨누거나 production `NotificationSchemaStream`을 호출하면 목록 자체가 사라진다.
|
||
|
||
부수적으로, `notification/*ContractTest`의 fixture는 `variables_payload`에 평문 `'{}'`를 넣는다. 이는 V10 guard가 거부하는 모양이다(guard는 migration 시점의 기존 행만 보므로 지금은 충돌하지 않는다). at-rest 계약이 "불가능하다"고 선언한 형상 위에서 contract test가 도는 셈이라, V10을 목록에 넣는 순간 fixture도 함께 바뀌어야 한다.
|
||
|
||
## 135. `JpaPlatformContractSupport`의 컨테이너 수명 서술은 실제와 다르다
|
||
|
||
클래스 javadoc은 이렇게 말한다.
|
||
|
||
> The containers are shared for the JVM: the contracts verify server behaviour, which does not change between test classes, and **starting a server per class turns a three-version matrix into minutes of container startup.**
|
||
|
||
실제 사용은 정확히 그 "per class"다. `JpaPlatformContractSupport.start()` 호출 지점은 31곳이고 대부분 `@BeforeAll`에서 시작해 `@AfterAll`에서 `close()`한다. JVM 수준 공유 인스턴스나 static holder는 없다. `StablePostgreSqlMatrixContractTest`는 test마다, `JpaPlatformContractSupportOwnershipTest`는 test마다(5개) 컨테이너를 새로 띄운다.
|
||
|
||
실측치는 다음과 같다(`115-integration-lane-original-verification.txt`, XML의 Testcontainers 로그 집계).
|
||
|
||
| lane | 클래스 | tests | skipped | failures | PostgreSQL 컨테이너 기동 |
|
||
|---|---:|---:|---:|---:|---:|
|
||
| jpaPlatformContractTest | 36 | 182 | 0 | 0 | 49 |
|
||
| jpaPlatformMigrationTest | 10 | 43 | 0 | 0 | 33 |
|
||
| jpaPlatformFailureTest | 2 | 6 | 0 | 0 | 2 |
|
||
| jpaPlatformQueryPlanTest | 1 | 3 | 0 | 0 | 1 |
|
||
| jpaPlatformSecurityTest | 2 | 10 | 0 | 0 | 2 |
|
||
| **합계** | **51** | **244** | **0** | **0** | **87** |
|
||
|
||
한 번의 전체 tag lane 통과에 PostgreSQL 컨테이너가 87번 기동한다. 그럼에도 5개 lane 전체가 3분 10초에 끝났으므로 비용 주장이 무너지는 수준은 아니다. 기록하는 이유는 서술과 구현의 불일치다 — 클래스가 자기 설계 근거로 내세운 "JVM 공유"가 소비자 31곳 어디에서도 성립하지 않는다. P3.
|
||
|
||
같은 클래스의 다른 서술은 사실이다. multi-version 선택을 fail-closed로 거부하는 것(`start()`가 `selected.size() != 1`이면 예외), 그리고 "the CI matrix fans out"은 `jpa-release.yml`(16/17/18), `jpa-pr.yml`(16/18), `jpa-nightly.yml`이 `-Pjpa.matrix.versions`로 실제 fan-out하는 것으로 확인된다. `JpaPlatformContractSupportOwnershipTest`가 지키는 pool 소유권(호출당 새 pool을 만들어 참조를 잃던 과거 결함)도 실제 assertion으로 고정돼 있다.
|
||
|
||
## 136. 이 lane이 실제로 강한 지점
|
||
|
||
결함만 나열하면 이 corpus를 오해하게 된다. 다음은 "실서버가 아니면 성립하지 않는" 주장을 실제로 실서버에서 확인하는 사례이고, 대부분 production 클래스를 그대로 쓴다.
|
||
|
||
- **commit ambiguity** — `CommitAmbiguityContractTest`가 `pg_terminate_backend`로 백엔드를 죽인 뒤 commit해서 실제 SQLSTATE가 `57P01`(class 08이 **아님**)임을 확인하고, production `CommitFailureClassifier`가 그것을 `TransactionCompletionUnknownException` / `retryable=false`로 번역하는지 본다. "연결이 끊기면 08일 것"이라는 합리적 추측이 왜 틀리는지가 주석에 적혀 있다.
|
||
- **transaction runtime** — `PostgreSqlTransactionIntegrationTest`가 production `SpringTransactionPort`로 transaction-local timeout 적용/복원, 확정 롤백, serializable 충돌의 replay-safe 정책 한정 재시도(action 호출 3회), 결정적 deadlock 40P01 단일 희생자, lock/statement timeout 경계(55P03/57014), pool 고갈 시 **애플리케이션 작업 시작 전** 거부, commit 중 연결 유실의 `INDETERMINATE`+재시도 금지를 모두 실측한다.
|
||
- **owner-safe idempotency / same-store inbox / immutable outbox / polling delivery** — 네 readiness test 모두 production adapter를 실제 transaction 안에서 돌리고, 가상 스레드로 두 caller를 경쟁시켜 "첫 business commit 전에는 경쟁자가 owner row를 통과하지 못한다"를 실제로 블로킹시켜 확인한다.
|
||
- **RLS 실패 모드** — `RlsIsolationFailureTest`가 enable-but-not-forced일 때 소유자가 정책을 우회하는 고전적 false-green, session-scope 바인딩이 pool 반납 후 다음 차용자에게 새는 것, transaction-scope 바인딩은 새지 않는 것을 실제 pool로 구분한다.
|
||
- **schema/mapping 정합** — `PostgreSqlDefaultPersistenceUnitIntegrationTest`가 `PersistenceJpaConfig`의 `@EntityScan` 목록을 **읽어서** `ddl-auto=validate`를 돌린다. 스캔 목록이 늘면 검사도 함께 늘어난다. notification 쪽도 같은 방식이 있고, 추가로 `jsonb_typeof`로 "JSON처럼 보이는 text"가 아닌지까지 본다.
|
||
- **evidence certainty** — `EvidenceCertaintyContractTest`가 5×5×5×5 = 625조합을 실제 행에 왕복시키고, DB CHECK가 `UNKNOWN`인데 값이 true인 행과 모델 밖 certainty를 거부하는지 확인한다.
|
||
|
||
`RecipientClaimContractTest`는 방법론 면에서 이 corpus의 모범이다 — production `RecipientClaimSql.CLAIM_BATCH` 텍스트를 그대로 가져와 placeholder만 바꿔 쓰고, 그 이유를 "Retyping the SQL here would prove that two authors agreed about a query rather than that the query is right"라고 적는다.
|
||
|
||
## 137. 이전 sub-scope 발견과의 교차 정합
|
||
|
||
이 sub-scope의 파일들은 앞선 sub-scope가 올린 결함이 **왜 lane에서 잡히지 않았는지**를 직접 설명한다.
|
||
|
||
| 앞선 발견 | 이 lane 쪽 대응 사실 |
|
||
|---|---|
|
||
| §52 (sub-scope 04, P1) collection-fetch gate가 SQL limit을 보지 않음 | `HibernateCollectionFetchPaginationContractTest.oneCollectionPageIsBoundedInSql`은 반환 페이지 크기와 `expected.requiresDatabaseLimit()`(기대 객체 자신의 상수)만 확인한다. javadoc은 "The assertion is therefore on the generated SQL"이라고 쓴다. 파일 소유는 sub-scope 11, 결함 판정은 §52 — 중복 계상하지 않는다 |
|
||
| sub-scope 10 (P1 latent) `PostgreSqlExplainRunner`가 data-modifying CTE를 허용 | `PostgreSqlQueryPlanContractTest.refusesNonSelect`가 확인하는 것은 평범한 `update` 한 건뿐이다. CTE 형태는 이 assertion의 사각지대 |
|
||
| sub-scope 06 (P1) Stable runtime-role 검증이 startup에서 실제 policy를 적용하지 않음 | production `requireSafe` 호출자는 0. 유일한 호출자는 `PostgreSqlSecurityContractTest.policyAcceptsVerifiedRole`이고, 그 role과 policy는 test가 만든 것이다. blocking gate `runtime-role-no-ddl`이 green이라는 사실은 verifier가 동작한다는 뜻이지 배포가 그것을 부른다는 뜻이 아니다 |
|
||
| sub-scope 09 (P1 latent) database-per-tenant budget이 이질적 pool 크기에서 ceiling 초과 | `TenantPoolCapacityContractTest`는 `POOL_SIZE_PER_TENANT = 2`로 **균일한** pool만 연다. 이질적 조합이 lane에 없다는 것이 그 결함이 green으로 남은 이유다 |
|
||
| sub-scope 08 (P2) V8 atomic admin claim에 production caller 0 | `AdminOperationClaimContractTest`는 `INSERT ... ON CONFLICT DO NOTHING`을 **test가 직접 작성해** 검증한다(`RecipientClaimSql` 방식과 대조적). claim SQL이 옳다는 것과 production이 그것을 부른다는 것은 별개다 |
|
||
|
||
## 138. finding으로 올리지 않은 관찰
|
||
|
||
- **order-dependent test.** `ConstraintRaceContractTest.exactlyOneRowSurvives`는 앞선 test가 넣은 행에 의존한다. XML의 실행 순서상 현재는 race test가 먼저 돈다. JUnit 기본 순서는 결정적이지만 명세된 계약이 아니고, 같은 source set의 `PostgreSqlOutboxStorageIntegrationTest`는 `@TestMethodOrder(OrderAnnotation)`로 명시한다. 한 corpus 안에 명시적 순서와 암묵적 순서 의존이 공존한다.
|
||
- **삼킨 예외.** `PostgreSqlUpsertContractTest.upsertConcurrently`는 `SQLException`/`InterruptedException`을 기록 없이 삼킨다. 두 스레드 중 하나가 죽어도 `count(*) == 1`은 성립하므로 "동시 upsert가 수렴했다"와 "하나만 돌았다"를 구분하지 못한다.
|
||
- **display name과 assertion 불일치.** `PostgreSqlWorkClaimContractTest.claimsAreDeterministicallyOrdered`의 이름은 결정적 순서를 약속하지만 assertion은 `isNotEmpty()` 하나다.
|
||
- **Docker 없이 도는 tag lane 클래스 2개.** 51개 중 `JpaAuditingContractTest`와 `JpaPlatformContractSupportTest`만 컨테이너를 하나도 띄우지 않는다. 후자는 그 사실을 javadoc에 적어 두었고, 전자는 순수 단위 assertion 4개다. blocking gate `postgresql-contract`("The real database ran the contract suite, not H2")의 test 수 182에는 데이터베이스를 만난 적 없는 assertion이 섞여 있다.
|
||
- **credential fixture 3종.** `JpaPlatformContractSupport.generatedPassword()`는 "리터럴은 committed credential"이라는 이유로 존재하는데, 소비자는 `RlsIsolationFailureTest` 하나다. `PostgreSqlSecurityContractTest`(blocking gate 생산자)는 `password 'contract_runtime'` 리터럴을 쓰고, `PostgreSqlSecurityBaselineIntegrationTest`는 `UUID.randomUUID()`를 `pg_temp` 함수에 파라미터로 넘긴다. 세 형제가 세 방식을 쓴다.
|
||
- **중복 assertion.** `commitAmbiguityHasThreeDistinctInjectionPoints`가 `CommitAmbiguityContractTest`와 `PostgreSqlConcurrencyFailureContractTest`에 거의 같은 형태로 두 번 있다(같은 `jpa-failure` lane).
|
||
- **`PostgreSqlInboxCutoffIntegrationTest`의 SQL 재작성.** production `InboxItemJpaRepository.markAllRead`의 native `@Query`를 test가 다시 타이핑했다. 현재 두 문장은 술어 구조가 일치하지만, `RecipientClaimSql` 방식이 아니라 "두 저자가 합의했음"을 증명하는 형태다.
|
||
- **죽은 helper.** `PostgreSqlNotificationInvariantIntegrationTest.unused(...)`는 `@SuppressWarnings("unused")`와 함께 의도적으로 남아 있다.
|
||
|
||
## 139. Fresh verification evidence — sub-scope 11
|
||
|
||
- `evidence/raw/115-integration-lane-original-verification.txt` — 원본 소스에서 5개 tag lane `--rerun-tasks` **BUILD SUCCESSFUL in 3m 10s**, 51 클래스 / 244 tests / 0 skipped / 0 failures / 87 컨테이너 기동, git clean before/after. 이어서 실행한 `verifyJpaCandidateEvidence`는 `:app-bootstrap:test`의 `ComposeMergeCharacterizationTest`에서 멈춘다 — 그 test는 `docker compose` 유무만 assume으로 확인하고 스크립트가 요구하는 `jq`는 확인하지 않으며, 분석 컨테이너에 `jq`가 없다(스크립트 직접 실행 시 `jq is required`). app-bootstrap 소유 사안이자 환경 제약이므로 이 sub-scope의 결함으로 계상하지 않는다
|
||
- `evidence/raw/116-readiness-lane-original-verification.txt` — 14개 readiness task `--rerun-tasks --continue`, git clean before/after
|
||
- `evidence/raw/117-flyway-migration-readiness-lane-failure.txt` — `postgresqlMigrationIntegrationTest` 단독 `--rerun-tasks` **BUILD FAILED / taskExit=1**, 두 assertion 실패 원문, stream 파일 목록, migration/assertion 최종 수정일 대조
|
||
- `evidence/raw/118-readiness-task-result-matrix.txt` — 14 task × tests/skipped/failures/errors 표, 12 PASS / 2 FAIL
|
||
- `evidence/raw/119-...` / `119a-...` — notification 사다리 4종의 컬럼 집합 실측과 차집합
|
||
- `evidence/raw/120-persistence-jpa-integration-lane-manifest.txt` — 75/75 파일 해시·라인수, tag 분포, 무-tag 클래스 목록, readiness task 등록 목록
|
||
|
||
모든 임시 분석 test는 실행 후 삭제했고 최종 `git status --short`는 clean이다.
|
||
|
||
## 140. Sub-scope 11 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| **P1** | selected base card `jpa-flyway-migration`의 producer `postgresqlMigrationIntegrationTest`가 HEAD에서 실패(base/legacy stream 5개 migration 추가 후 applied-set assertion 미갱신). tag lane·release gate 어디에도 속하지 않아 5개 lane green과 무관 | **CI `jpa-candidate-evidence` job의 유일 실행 경로; `jpa-primary-foundation`(R2)의 prerequisite** |
|
||
| **P2** | base card 3종(`observability` / `query-contract`+`query-plan` / `mapping`+`optimistic-conflict`)의 evidence tag가 production code를 지나지 않는 fixture로 충족. `query-plan`은 `enable_seqscan=off` 상태에서 판정 | selected card 3개; 같은 source set에 더 강한 생산자가 존재 |
|
||
| **P3** | notification contract fixture의 migration 사다리가 3벌(V1–V9 / V1–V8 / V1–V6)로 갈라져 있고 배포 stream은 V1–V10. 실측 컬럼 차이 0 / 5 / 10 | 현재 false-green 경로 없음; 다음 DDL migration에서 어긋남 |
|
||
| **P3** | `JpaPlatformContractSupport` javadoc의 "containers are shared for the JVM"이 소비자 31곳 어디에서도 성립하지 않음(실측 87 컨테이너 기동/전체 tag lane 1회) | 서술/구현 불일치 |
|
||
| **P3** | `README.md`의 "active card 11개" 대 실측 active 13 / producer 12 | 문서 count drift |
|
||
| **P3** | `postgresql-contract` blocking gate의 182 tests에 컨테이너를 띄우지 않는 클래스 2개가 포함 | gate 문구 대 구성 |
|
||
| **P3** | order-dependent test 1건, 삼킨 예외 1건, display-name과 assertion 불일치 1건, credential fixture 3방식, 중복 assertion 1쌍 | §138 |
|
||
| **환경 제약(결함 아님)** | `postgresqlSecurityBaselineIntegrationTest`의 TLS scenario는 컨테이너 매핑 포트가 테스트 JVM의 loopback에 열려 있어야 성립(인증서 SAN이 `localhost` 단일). 형제 컨테이너 실행 환경에서는 `ConnectException`으로 실패 | no-skip 설계의 암묵적 환경 전제 |
|
||
|
||
## 141. Sub-scope 11 완료 조건
|
||
|
||
- denominator 75 / 75 FULL_READ (`120-...`), 71 Java + 4 SQL
|
||
- lane 소속을 tag/Gradle task/card 세 축으로 대조하고 orphan test 0을 `--dry-run` task graph로 확인
|
||
- negative-space probe: test 도달성(무-tag 클래스 ↔ readiness task 1:1), card evidence tag ↔ 실제 assertion 대조, 중복 mechanism(notification 사다리 4벌, SQL 재작성 대 production 상수), 문서/수치 drift(active card 수, 컨테이너 공유 서술, gate 문구)
|
||
- 실행 evidence: 5개 tag lane 전량 재실행, 14개 readiness task 재실행, 실패 lane 단독 재현, notification 사다리 컬럼 실측
|
||
- 앞선 sub-scope 04·06·08·09·10 발견과의 교차 정합을 lane 쪽 사실로 설명
|
||
- 모든 임시 source 복구, `git status --short` clean
|
||
|
||
---
|
||
|
||
## 142. Module ledger 재조정과 module 완료 조건
|
||
|
||
### 142.1 최종 ledger
|
||
|
||
| # | sub-scope | denominator | status | 주요 evidence |
|
||
|---:|---|---:|---|---|
|
||
| 1 | governance / build / docs / root boundary | 11 | **COMPLETE** | 107–110, 114 |
|
||
| 2 | API contracts (`api/**`) | 55 | COMPLETE | 031–040 |
|
||
| 3 | transaction + persistence failure | 51 | COMPLETE | 041–051 |
|
||
| 4 | Spring Data + Hibernate + Querydsl | 53 | COMPLETE | 052–061 |
|
||
| 5 | PostgreSQL vendor + vendor migrations | 73 | COMPLETE | 062–069 |
|
||
| 6 | baseline capability stores/config/audit/cache/H2 등 | 87 | COMPLETE | 070–076 |
|
||
| 7 | Fileserver persistence + migrations | 29 | COMPLETE | 077–082 |
|
||
| 8 | Notification persistence + migrations | 68 | COMPLETE | 083–092 |
|
||
| 9 | Experimental platform | 38 | COMPLETE | 093–101 |
|
||
| 10 | testkit + fixture tests | 62 | COMPLETE | 102–106 |
|
||
| 11 | PostgreSQL integration/readiness lane | 75 | **COMPLETE** | 115–120 |
|
||
| 12 | pool/performance contract lane | 3 | **COMPLETE** | 111–114 |
|
||
| | **TOTAL** | **605** | **12 / 12** | |
|
||
|
||
605 = leaf top-level 4 + `src/main` 381 + `src/test` 101 + `src/testkit` 41 + `src/postgresqlIntegrationTest` 75 + `src/jpaPlatformPerformanceTest` 3. 12개 sub-scope의 denominator 합도 605이며, 모든 tracked file이 정확히 하나의 sub-scope에 귀속된다. 중복 계상은 한 곳에서만 발생할 수 있었고(§114) sub-scope 06이 이미 계상한 3개 test를 sub-scope 01이 다시 세지 않는 것으로 정리했다.
|
||
|
||
module 전체 disposition은 **605 FULL_READ / 0 STRUCTURAL_ONLY / 0 EXCLUDED / 0 UNCLASSIFIED**다.
|
||
|
||
### 142.2 module-level 완료 조건 대조
|
||
|
||
- **정량 denominator**: 605 tracked file, `src/main` 350 Java ≈ 27,744 LOC, public top-level type 338, package 24, 독립 Flyway stream 7, 실서버 lane 5(tag) + 14(readiness) + 1(pool), release gate 6
|
||
- **coverage ledger**: 미분류 0
|
||
- **top-level package map**: 24개 전부 `JpaModuleBoundaryTest.PACKAGE_CATALOG`와 대조 완료
|
||
- **build/runtime wiring**: composition root → `PersistenceJpaRootAutoConfiguration` → vendor config → `PersistenceJpaConfig` / `JpaAdapterComponentsConfig` 사슬을 §116–§117에서 실행 probe로 확인
|
||
- **invariant / failure path**: sub-scope 02~10에서 문서화, sub-scope 11에서 실서버 증거와 대조
|
||
- **test ↔ claim 매핑**: sub-scope 11이 lane·card·gate 세 축으로 완료
|
||
- **rationale 분리**: 코드 주석·문서가 밝힌 역사(observed)와 분석 추론(inferred)을 각 절에서 구분
|
||
- **§8 4종 probe**: 12개 sub-scope 전부에서 수행 또는 비적용 사유 기록
|
||
- **dead/unwired/duplicate**: 정적 검색과 task graph resolve로 확인
|
||
- **documentation/count drift**: §119(leaf), §132·§134·§135(evidence 계층)에서 재측정
|
||
- **한계와 제외 영역**: 각 sub-scope 완료 조건에 기재. module 수준 한계는 §142.3
|
||
- **improvement backlog**: 12개 sub-scope backlog 유지
|
||
|
||
### 142.3 module 수준 한계
|
||
|
||
- 정적 도달성 분석은 reflection, service loader, 생성 코드 등록, **이 repository 밖의 adopter**를 증명하지 않는다. `api` package는 의도된 외부 surface이므로 내부 참조 0이 곧 dead를 뜻하지 않는다.
|
||
- 실서버 증거는 PostgreSQL 16 단일 major에서 수집했다. registry가 Stable로 선언한 17·18은 CI matrix가 fan-out하며, 이 분석에서는 실행하지 않았다.
|
||
- `postgresqlSecurityBaselineIntegrationTest`의 TLS scenario와 `:app-bootstrap:test`의 compose scenario는 분석 환경 제약으로 실행하지 못했다(§132, §139). 두 건 모두 원인을 특정했고 repository 결함으로 계상하지 않았다.
|
||
- 성능·부하 특성은 이 분석의 대상이 아니다. pool lane은 행동 계약이지 측정 lane이 아니며(§123–§125), repository에도 threshold를 가진 성능 gate는 없다.
|
||
|
||
### 142.4 module findings 요약
|
||
|
||
| 우선순위 | 건수 | 출처 sub-scope |
|
||
|---|---:|---|
|
||
| P1 (confirmed) | 8 | 03(completion-evidence 미설치), 04(collection-fetch gate false evidence), 07(quota 미집행, schema activation 오인), 08(V4 schema 오인, lease fencing 우회), 11(flyway-migration lane 실패) 외 |
|
||
| P1 latent / conditional | 6 | 06, 07, 09, 10 |
|
||
| P2 | 12+ | 02, 03, 04, 05, 08, 09, 11, 12, 01 |
|
||
| P3 / 기록 | 다수 | 전 sub-scope |
|
||
|
||
module 전체에서 반복되는 단일 주제가 하나 있다. **구현은 계약을 정확히 서술하는데, 그 계약을 실제 배포나 실제 gate에 연결하는 마지막 한 칸이 비어 있는 경우**다 — completion-evidence capability가 composition에 없고(§23), runtime-role verifier를 production이 부르지 않고(sub-scope 06), admin claim을 production이 쓰지 않고(sub-scope 08), vendor selector의 properties 타입이 켜지지 않고(§116), 그리고 그 미연결을 잡아야 할 evidence lane 자체가 4주간 실패한 채 아무도 실행하지 않았다(§132). 이 leaf의 다음 작업 우선순위는 새 기능이 아니라 이 마지막 한 칸들을 잇는 것이다.
|
||
|
||
## Source anchors
|
||
|
||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **230개** (main 143 · test 36 · 기타 51).
|
||
|
||
```
|
||
src/adapter/outbound/persistence-jpa/build.gradle
|
||
src/config/architecture/modules.json (adapter-outbound-persistence-jpa 항목)
|
||
|
||
main:
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationName.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/CapabilitySupport.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/JpaCapability.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConnectionUnavailableException.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintCode.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintViolationDetails.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/FailureCategory.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaEntityNotFoundException.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContext.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceException.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionCompletionUnknownException.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/VendorFailureTranslator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorCodec.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetPageRequest.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetSlice.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/NoopQueryObservation.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryName.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryObservation.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryScope.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodec.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SortDirection.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaRetryPolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDecision.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryEventListener.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryProfile.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionCompletionEvidence.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfile.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditContextPort.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntity.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/AuditMetadata.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditingConfiguration.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuard.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/config/JpaAdapterComponentsConfig.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceJpaConfig.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/HibernateEnversHistoryReader.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeature.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceRegistry.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantEntityManagerFactoryRegistry.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantPoolBudget.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyAwareDataSourceRouter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaLagMonitor.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsPolicyVerifier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsTenantSessionBinder.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaMultiTenantConnectionProvider.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantMigrationOrchestrator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantRegistry.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantAwareRepositoryGuard.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantEntityListenerGuard.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/FileserverJpaPersistenceConfig.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/FileserverSchemaActivation.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaCleanupQueue.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaFileQuotaService.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaQuotaCommitGateway.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaQuotaReclaimGateway.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaRecoveryQueue.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/entity/QuotaReservationEntity.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/repository/FileserverCleanupRepository.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/repository/FileserverQuotaRepository.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateProviderPolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollector.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsSnapshot.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/JdbcBatchCounter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/NamedStatementInspector.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/QueryNameContext.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/BatchExecutionResult.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuard.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateJpaBatchExecutor.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfileRegistry.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/HibernateBulkDmlExecutor.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/HibernateStatelessSessionRunner.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessWorkResult.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/entity/IdempotencyRecordEntity.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/JpaLiveEventReplayAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/LiveEventJpaRepository.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/DistributedLockPersistenceConfig.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/LockSettings.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationJpaPersistenceConfig.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationSchemaActivation.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationSchemaStream.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/configuration/NotificationJpaPersistenceFacade.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/JdbcReconciliationJobStore.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/JpaAdminOperationStore.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/RecipientClaimSql.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/TenantBoundRepositoryGuard.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/inbox/InboxItemJpaRepository.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTags.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaRetryObservation.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaTransactionObservation.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationJpaRepository.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationStoreAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxClaimRepository.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/package-info.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurer.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlExceptionTranslator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/inbox/PostgreSqlSameStoreInboxAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlImmutableOutboxAppendAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlPollingDeliveryAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRange.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeCodec.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupport.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/security/PostgreSqlRuntimeRoleVerifier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/security/SearchPathPolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityGraphCatalog.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupport.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupport.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutor.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortField.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapper.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortRegistry.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SpecificationPolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecord.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorder.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManager.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionConfig.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettings.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/OptimisticConflictTranslator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/PersistenceFailureTranslatorChain.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudget.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceContext.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceScope.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistry.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifier.java
|
||
|
||
test:
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/CandidateAdapterCompositionTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationNameTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContextTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceExceptionTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryNameTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodecTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfileTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceEntityScanCoverageTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSelectionTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalEntryConsentTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/platform/PoolLaneClaimTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/IdempotencyDigestPolicyTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManagerTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceScopeTest.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityExposureCondition.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRules.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaAuditMechanismRule.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/CommitAmbiguityProxy.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7Generator.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingDataSource.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityState.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbe.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/MappingEntity.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationContractRunner.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurement.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContractExtension.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/NormalizedPlan.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/PostgreSqlExplainRunner.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertions.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanExpectation.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseGate.java
|
||
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java
|
||
|
||
기타:
|
||
CLAUDE.md
|
||
README.md
|
||
docs/architecture/jpa-api-surface.txt
|
||
docs/fileserver/design-deviations.md
|
||
docs/jpa/repository-adaptation.md
|
||
docs/jpa/security.md
|
||
docs/jpa/support-matrix.md
|
||
docs/jpa/transaction-guide.md
|
||
docs/reviews/2026-08-14-jpa-module-code-review.md
|
||
src/build.gradle
|
||
src/config/jpa/readiness-cards.yaml
|
||
src/config/jpa/release-registry.json
|
||
src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/HikariPoolSaturationContractTest.java
|
||
src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/PoolPressureContractTest.java
|
||
src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/RequiresNewPoolPressureContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/AdminOperationClaimContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/EvidenceCertaintyContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationFixtures.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/ProjectionFactDurabilityContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/RecipientClaimContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/CommitAmbiguityContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/ConstraintRaceContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateCollectionFetchPaginationContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateJpaBatchExecutorIntegrationTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/IdStrategyContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaAuditingContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupport.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupportOwnershipTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupportTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaValueMappingContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/OptimisticRetryIntegrationTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlConcurrencyFailureContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlQueryPlanContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSecurityContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlUpsertContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlWorkClaimContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/StablePostgreSqlMatrixContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/RlsIsolationFailureTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantPoolCapacityContractTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlAggregateIntegrationTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlDefaultPersistenceUnitIntegrationTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlInboxCutoffIntegrationTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlLifecycleIntegrationTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlNotificationInvariantIntegrationTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlNotificationSchemaActivationIntegrationTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOptionalStreamLifecycle.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxStorageIntegrationTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlQueryIntegrationTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java
|
||
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTransactionIntegrationTest.java
|
||
|
||
해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등:
|
||
092-notification-reachability-test-gap.txt
|
||
evidence/raw/103-testkit-unit-boundary-probes.txt
|
||
evidence/raw/078-fileserver-quota-boundary-probe-output.txt
|
||
evidence/raw/096-experimental-gate-reachability.txt
|
||
099-experimental-structural-optin-gap.txt
|
||
evidence/raw/097-experimental-replica-provider-probe.txt
|
||
106-testkit-original-verification.txt
|
||
evidence/raw/053-jpa-query-hibernate-boundary-probe.txt
|
||
evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt
|
||
evidence/raw/072-baseline-capability-reachability.txt
|
||
evidence/raw/075-outbox-stale-worker-state-regression-output.txt
|
||
evidence/raw/073-durable-operation-expired-lease-output.txt
|
||
|
||
```
|