docs(keycloak-session-store): import the session-storage lab as a new project
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
@@ -0,0 +1,226 @@
|
||||
# grpc-operation-ledger-jpa 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 3파일과 마이그레이션 1개 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-operation-ledger-jpa`
|
||||
> SSOT owner: `grpc-operation-ledger-jpa`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api"]`
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
| 파일 | LOC | 성격 |
|
||||
|---|---:|---|
|
||||
| `GrpcOperationLedgerEntity` | 155 | JPA 엔티티 + 상태 전이 |
|
||||
| `JpaGrpcOperationLedger` | 93 | 포트 구현 (insert-first 주장) |
|
||||
| `GrpcOperationLedgerRepository` | 28 | Spring Data 인터페이스 (메서드 4개) |
|
||||
| `V001__create_grpc_operation_ledger.sql` | 39 | 테이블 + 제약 4 + 인덱스 1 |
|
||||
| `GrpcOperationLedgerRepositoryTest` | 208 | 테스트 (인메모리 이중) |
|
||||
| `build.gradle` | 17 | project 1 + vendor 2 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 3 | `FULL_READ` | 155+93+28 전 본문 |
|
||||
| `main/resources/db/migration/grpc/*.sql` | 1 | `FULL_READ` | 39줄 전문 |
|
||||
| `test/java/**` | 1 | `FULL_READ` | 208줄, 인메모리 이중 구현 포함 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 17줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-8
|
||||
// Durable mutation idempotency: the operation ledger entity, its state machine, the vendor-neutral
|
||||
// repository port and the Spring Data JPA binding, plus the migration that owns the unique
|
||||
// constraint the whole contract rests on.
|
||||
// … Tests here run against a hand-rolled in-memory port implementation — a real datastore is only
|
||||
// justified when vendor semantics are the thing under test, and the constraint is asserted by the migration.
|
||||
```
|
||||
|
||||
마지막 문장이 이 리프의 검증 전략을 규정한다. §17.1 이 그 전략의 경계를 다룬다.
|
||||
|
||||
## 2. 스키마가 계약이다
|
||||
|
||||
```sql
|
||||
CONSTRAINT pk_grpc_operation_ledger PRIMARY KEY (storage_key),
|
||||
CONSTRAINT uq_grpc_operation_ledger_identity
|
||||
UNIQUE (caller_fingerprint, full_method_name, idempotency_key_hash),
|
||||
CONSTRAINT ck_grpc_operation_ledger_state
|
||||
CHECK (state IN ('IN_PROGRESS', 'COMMITTED', 'FAILED_TERMINAL')),
|
||||
CONSTRAINT ck_grpc_operation_ledger_committed_has_outcome
|
||||
CHECK (state <> 'COMMITTED' OR outcome_reference IS NOT NULL),
|
||||
CONSTRAINT ck_grpc_operation_ledger_terminal_has_completion
|
||||
CHECK (state = 'IN_PROGRESS' OR completed_at IS NOT NULL)
|
||||
```
|
||||
|
||||
마이그레이션 헤더가 왜 애플리케이션 검사가 아니라 제약인지 적는다.
|
||||
|
||||
> "A uniqueness check in application code instead would be a read followed by a write, with a window
|
||||
> between them precisely as wide as the race it is meant to close."
|
||||
|
||||
커밋 행이 결과를 반드시 갖는다는 검사를 자바 record 와 DB 양쪽에 둔 이유도 적혀 있다 — 마이그레이션·백필·지원 스크립트가 쓴 행은 record 를 지나지 않는다.
|
||||
|
||||
전용 Flyway 위치(`db/migration/grpc`)를 쓰는 이유도 적혀 있다. gRPC 플랫폼을 채택하지 않은 배포가 이 테이블을 만들도록 강요받지 않기 위해서다.
|
||||
|
||||
## 3. 저장 키와 유니크 제약이 같은 행을 가리킨다
|
||||
|
||||
`GrpcOperationIdentity`(grpc-core-api):
|
||||
|
||||
```java
|
||||
public String storageKey() {
|
||||
return callerFingerprint + "|" + method.canonical() + "|" + idempotencyKeyHash;
|
||||
}
|
||||
```
|
||||
|
||||
즉 기본 키는 유니크 제약의 세 컬럼을 이어 붙인 파생값이다. 엔티티 javadoc 이 그 이중 저장을 설명한다 — 복합 쪽이 원자성을 주고, 파생 키가 조회에 단일 컬럼 기본 키를 준다.
|
||||
|
||||
같은 신원의 두 번째 청구는 **같은 기본 키 행**을 겨냥한다. §17.1 이 그 사실에서 나온다.
|
||||
|
||||
## 4. 좁은 저장소 인터페이스
|
||||
|
||||
`Repository` 를 확장하고 네 메서드만 이름 짓는다.
|
||||
|
||||
> "`JpaRepository` publishes `deleteAll`, `findAll` and `saveAll` on the table that decides whether a
|
||||
> payment runs twice."
|
||||
|
||||
## 5. 어댑터의 주장
|
||||
|
||||
`JpaGrpcOperationLedger` javadoc:
|
||||
|
||||
> "`claim` is insert-first, read-on-conflict — not read-then-insert. That ordering is the whole
|
||||
> adapter… A read-first implementation has a window between the read and the insert that is exactly
|
||||
> as wide as the race it is supposed to close, and it passes every test that does not run the two
|
||||
> attempts concurrently."
|
||||
|
||||
트랜잭션 애너테이션이 없는 이유도 적혀 있다 — 커밋은 호출자의 업무 트랜잭션 안에서 일어나야 하고 `REQUIRES_NEW` 는 변경이 내구적인데 청구는 아닌 창을 다시 만든다.
|
||||
|
||||
## 6. 상태 전이
|
||||
|
||||
`IN_PROGRESS` 에서만 전이할 수 있다(`requireInProgress`). 커밋은 결과 참조가 비면 거부한다. `EnumType.STRING` 을 쓰는 이유가 javadoc 에 있다 — 서수 컬럼은 열거형에 값이 끼어들면 저장된 모든 행을 조용히 다른 값으로 만든다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
11개 테스트. 인메모리 저장소 이중이 `putIfAbsent` 로 기존 행이 있으면 `DataIntegrityViolationException` 을 던진다 — INSERT + 유니크 제약의 동작을 모사한다.
|
||||
|
||||
마지막 테스트가 마이그레이션 파일을 직접 읽어 유니크 제약 문장이 있는지 단언한다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** build-only. `JpaGrpcOperationLedger` 를 만드는 production 코드가 없다.
|
||||
|
||||
**12.2 마이그레이션 적용 경로.** `db/migration/grpc` 를 가리키는 설정이 저장소에 없다. main 설정 어디에도 `spring.flyway.locations` 가 없고(app-bootstrap 의 네 프로파일 yml 전수 확인), 그 경로를 이름으로 부르는 것은 이 리프의 테스트 한 곳뿐이다. messaging 가족이 §7.2 에서 기록한 것과 같은 형태다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 주석이 서술한 네 요소(엔티티·상태 기계·포트·Spring Data 바인딩)와 마이그레이션이 전부 존재한다. 드리프트 없음.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 데이터베이스로 `claim` 을 두 번 돌려 §17.1 을 재현하지 않았다. Spring Data JPA 의 `save` 계약과 이 엔티티의 식별자 형태로 판정했다.
|
||||
- 동시 청구를 실제 커넥션 둘로 재현하지 않았다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — insert-first 주장이 Spring Data 의 `save` 계약과 어긋난다. 그리고 테스트 이중이 그 차이를 가린다
|
||||
|
||||
어댑터는 이렇게 쓴다.
|
||||
|
||||
```java
|
||||
try {
|
||||
repository.save(GrpcOperationLedgerEntity.claim(identity, requestFingerprint, now));
|
||||
return Optional.empty(); // 내가 이겼다
|
||||
} catch (DataIntegrityViolationException alreadyClaimed) {
|
||||
return repository.findById(identity.storageKey()).map(entity -> entity.toRecord(identity));
|
||||
}
|
||||
```
|
||||
|
||||
전제는 `save` 가 INSERT 이고, 같은 신원의 두 번째 청구가 유니크 제약을 건드린다는 것이다.
|
||||
|
||||
그러나 이 엔티티의 식별자는 **호출자가 배정한다**. `claim(...)` 팩토리가 `storageKey` 를 `identity.storageKey()` 로 채우므로 `@Id` 가 널이 아니다. Spring Data JPA 의 `SimpleJpaRepository.save` 는 식별자가 널이 아닌 엔티티를 새 것으로 보지 않고 `EntityManager.merge` 로 보낸다.
|
||||
|
||||
그리고 §3 에서 확인했듯 기본 키는 유니크 제약의 세 컬럼에서 파생된다. 같은 신원의 두 번째 청구는 **같은 행**을 겨냥한다.
|
||||
|
||||
따라서 실제 JPA 에서 일어나는 일은 이렇다.
|
||||
|
||||
1. 두 번째 청구가 `merge` 로 들어간다. 그 행은 이미 존재한다.
|
||||
2. 유니크 제약이 발화하지 않는다. 새 행을 넣는 것이 아니라 같은 행을 갱신하기 때문이다.
|
||||
3. 분리 상태의 새 엔티티가 기존 행 위에 복사된다 — `state` 는 `IN_PROGRESS`, `outcome_reference` 는 널, `completed_at` 은 널, `claimed_at` 은 지금.
|
||||
4. 예외가 없으므로 `claim` 은 `Optional.empty()` 를 돌려준다. 호출자는 자기가 청구를 소유했다고 읽는다.
|
||||
|
||||
즉 이미 커밋된 연산의 결과 참조가 지워지고, 재시도가 그 변경을 다시 실행한다. 이 모듈이 존재하는 이유로 든 바로 그 결과다.
|
||||
|
||||
세 CHECK 제약도 이것을 막지 못한다. 갱신 후 상태는 `IN_PROGRESS` + `completed_at` 널이라 전부 합법이다.
|
||||
|
||||
덧붙여 `merge` 는 즉시 flush 하지 않으므로, 서로 다른 트랜잭션의 진짜 경합에서 제약 위반이 나더라도 그것은 flush 나 커밋 시점에 도착한다 — `try` 블록 밖이다.
|
||||
|
||||
**테스트가 이것을 볼 수 없는 이유.** 인메모리 이중의 `save` 는 키가 이미 있으면 예외를 던진다.
|
||||
|
||||
```java
|
||||
GrpcOperationLedgerEntity existing = rows.putIfAbsent(entity.getStorageKey(), entity);
|
||||
if (existing != null && existing != entity) { throw new DataIntegrityViolationException(...); }
|
||||
```
|
||||
|
||||
즉 이중은 INSERT 를, 실제 저장소는 UPSERT 를 한다. build.gradle 주석이 실제 데이터스토어를 쓰지 않는 근거로 "vendor semantics 가 시험 대상일 때만 정당하다" 고 적었는데, 여기서 어긋난 것이 정확히 vendor semantics 다.
|
||||
|
||||
**등급.** 이 리프는 build-only 이고 어떤 배포도 이 어댑터를 조립하지 않는다. 그래서 오늘의 사고는 아니다. 배선하는 순간 성립한다.
|
||||
|
||||
**수정.** 셋 중 하나다.
|
||||
|
||||
- 엔티티가 `Persistable<String>` 을 구현해 `isNew()` 를 명시한다. 신규 여부를 어댑터가 안다.
|
||||
- 저장소에 `@Modifying @Query` 로 명시적 INSERT 를 두고 `save` 를 청구 경로에서 쓰지 않는다.
|
||||
- 청구를 `INSERT … ON CONFLICT DO NOTHING` 의 영향 행 수로 판정한다.
|
||||
|
||||
어느 쪽이든 테스트 이중이 아니라 실제 데이터베이스에서 두 번 청구하는 계약 테스트가 함께 필요하다.
|
||||
|
||||
### 17.2 P3 — 낙관적 잠금 컬럼이 없어 전이 가드가 메모리 안에만 있다
|
||||
|
||||
`requireInProgress()` 가 두 번째 종결 전이를 막는다. 그 가드는 한 영속성 컨텍스트 안의 인스턴스 상태에만 적용된다. 엔티티에 `@Version` 이 없으므로 두 트랜잭션이 같은 행을 읽어 각각 전이하면 나중 쓰기가 앞의 것을 덮는다.
|
||||
|
||||
DB 의 세 CHECK 제약은 행의 모양을 지키지 지 전이 순서를 지키지 않는다. `COMMITTED` 행이 다른 결과 참조로 갱신되는 것을 막는 제약이 없다.
|
||||
|
||||
청구가 배타적이라는 설계 전제 아래서는 도달성이 낮다. 다만 §17.1 을 고치면 이 전제가 실제로 성립하는지가 함께 확인되어야 한다.
|
||||
|
||||
### 17.3 P3 — `markCommitted` 는 던지고 `markFailed` 는 조용히 넘어간다
|
||||
|
||||
```java
|
||||
markCommitted → findById(...).orElseThrow(IllegalStateException…) // 청구 없으면 실패
|
||||
markFailed → findById(...).ifPresent(entity -> …) // 청구 없으면 무동작
|
||||
```
|
||||
|
||||
커밋 쪽의 근거는 자바독에 있다 — 청구 없이 커밋하면 변경은 내구적이고 보호받지 못한다.
|
||||
|
||||
실패 쪽에는 근거가 없다. 청구가 사라진 뒤 도착한 종결 실패가 아무 흔적도 남기지 않는다. 회수가 청구를 지운 뒤 원래 소유자가 실패를 기록하려는 경우가 그 형태다. 의도라면 그 이유를 자바독에 적어야 하고, 아니라면 커밋 쪽과 같게 다뤄야 한다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **유니크 제약을 애플리케이션 검사 대신 쓰기로 한 판단과 그 근거.**
|
||||
- **커밋 행이 결과를 갖는다는 규칙을 record 와 DB 양쪽에 둔 것** — 마이그레이션·백필·지원 스크립트는 record 를 지나지 않는다.
|
||||
- **`Repository` 를 확장해 네 메서드만 노출한 것.**
|
||||
- **`EnumType.STRING`** — 서수 컬럼의 조용한 재지정을 피한다.
|
||||
- **전용 Flyway 위치** — 채택하지 않은 배포에 테이블을 강요하지 않는다.
|
||||
- **트랜잭션 애너테이션을 두지 않은 것과 그 근거.**
|
||||
- **상태·완료 시각 CHECK 제약** — 종결 상태는 완료 시각을 갖는다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-operation-ledger-jpa/build.gradle:1-17
|
||||
main/java/…/ledger/JpaGrpcOperationLedger.java:1-93
|
||||
main/java/…/ledger/GrpcOperationLedgerEntity.java:1-155
|
||||
main/java/…/ledger/GrpcOperationLedgerRepository.java:1-28
|
||||
main/resources/db/migration/grpc/V001__create_grpc_operation_ledger.sql:1-39
|
||||
test/java/…/ledger/GrpcOperationLedgerRepositoryTest.java:1-208
|
||||
src/grpc/grpc-core-api/…/ledger/GrpcOperationIdentity.java:36-38
|
||||
src/app-bootstrap/src/main/resources/application*.yml (flyway locations 부재 확인)
|
||||
```
|
||||
Reference in New Issue
Block a user