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>
15 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | body | assets | evidence | source | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a06-f013-recordapplied | recordApplied가 펜스를 비교하지 않아 밀려난 실행자가 원장을 차지한다 | state-ownership-and-concurrency | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a06-f013-recordapplied | 2026-09-02 | case-a06-f013-recordapplied.body.md |
|
|
|
recordApplied가 펜스를 비교하지 않아 밀려난 실행자가 원장을 차지한다
원장 기록 메서드의 계약은 펜스가 여전히 현재 것일 때만 기록한다고 적는다. 구현이 하는 검사는 펜스가 미지정 값인지 하나뿐이다. 복제 세트에서 밀려난 실행자의 항목이 원장에 남고, 실제로 작업한 실행자는 드라이버의 중복 키 오류를 받았다.
관계
- fenced lease — 만료 시각만으로는 부족한 이유 펜스와 소유자를 함께 요구하는 이유를 적은 문서다.
- CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다 같은 원장의 다른 메서드가 지키는 규칙이다.
- 리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다 다른 리프에서도 소유자를 기록하지 않아 최종 상태가 되돌려졌다.
문제
원장은 포크가 구현하도록 공개된 인터페이스다. 그 인터페이스의 javadoc 이 기록 메서드의 계약을 세 문장으로 적는다. 펜스가 현재 것일 때만 기록한다는 것, 밀려난 실행자의 항목은 대체 실행자가 덮어쓴 작업을 완료됐다고 말한다는 것, 더 새로운 획득이 있으면 전용 예외를 던진다는 것이다.
결론
구현은 널 검사 넷과 미지정 값 검사 하나를 하고 삽입한다. 저장된 펜스와의 비교도, 서버측 조건도 없다. 검사 메서드 이름은 requireCurrentFence 인데 몸통의 조건은 fence == UNFENCED 하나다. 그 메서드의 javadoc 은 자기가 거절하는 것이 미지정 리스뿐이라고 정확히 적는다. 어긋난 것은 이름과 그것이 만족시켜야 할 계약이다.
같은 파일의 체크포인트 저장은 그 계약을 지킨다. 저장된 펜스를 필터에 걸고, 중복 키 오류를 잡아 플랫폼 예외로 번역한다. 그 자리의 주석은 왜 그렇게 됐는지도 적는다. 밀려난 실행자가 자기 몫으로 쓰인 문장 대신 드라이버 오류를 받았기 때문이다. 같은 형태를 한쪽에서 고치고 다른 쪽에는 적용하지 않았다.
복제 세트에서 확인했다. 펜스 5 의 체크포인트가 있는 상태에서 펜스 1 을 든 실행자가 두 메서드를 부르면 체크포인트는 거절되고 원장 기록은 수용된다. 그 뒤 펜스 5 로 기록하면 고유 색인이 중복 키로 거절한다. 주석이 서술한 과거와 받는 쪽이 바뀌어 있다. 그때는 밀려난 실행자가 드라이버 오류를 받았고 지금은 실제로 작업한 실행자가 받는다.
중복 키가 나오는 것은 고유 색인이 있을 때뿐이다. 그 색인을 만드는 메서드를 부르는 곳은 시험뿐이고, 만들지 않고 운영하면 같은 경합이 오류 없이 원장에 두 줄을 남긴다. 색인이 없으면 오류가 나지 않으므로 중복이 더 늦게 발견된다.
실행기와 원장과 잠금을 조립하는 프로덕션 코드가 이 리프에 없다. 포크가 이 저장소의 실행기와 잠금을 함께 쓰면 인접한 장치가 막는다. 실행기가 원장을 쓰기 전에 리스를 갱신하고, 그 갱신은 소유자와 펜스를 조건으로 건다. 다만 셋이 남는다. 갱신과 삽입 사이에 사후조건 검사가 들어가는데 그것은 마이그레이션이 넘긴 코드이고, 그 갱신이 던지는 것은 플랫폼의 거절 문장이 아니라 맨 IllegalStateException 이며, 인터페이스가 약속한 보호는 어느 원장 구현에도 없다. 다른 구현은 펜스 인자를 받기만 한다.
시험 중에 밀려난 펜스로 원장 기록을 부르는 것은 없다. 원장 쓰기가 펜스를 실어 나른다는 이름을 단 시험은 미완료를 돌려주는 마이그레이션을 써서 원장 기록 분기까지 가지 않는다. 실서버 레인의 시험 하나가 같은 펜스로 두 번 부르는데, 그 단언에 붙은 문장이 막는 것은 읽고-쓰기 검사가 아니라 고유 색인이라고 적는다.
수정은 체크포인트 저장이 쓰는 방식을 그대로 쓰면 된다. 원장 기록도 저장된 펜스를 조건으로 삼고, 중복 키를 잡아 플랫폼 예외로 번역하는 것이다.
검증 환경
OpenJDK : 21.0.12 MongoDB : 8.0.16 단일 노드 복제 세트 확인 방식 : 계약과 구현 대조, 실제 복제 세트에 두 실행자의 쓰기 실행 소스 수정 : x
재현 조건
- 인터페이스 javadoc 의 계약과 구현의 몸통, 그리고 검사 메서드와 그 javadoc 을 나란히 읽는다.
- 같은 파일의 체크포인트 저장이 거는 필터와 중복 키 처리, 그리고 그 자리 주석을 읽는다.
- 단일 노드 복제 세트를 띄우고 고유 색인을 만든다.
- 펜스 5 로 체크포인트를 쓴다.
- 펜스 1 로 체크포인트 저장과 원장 기록을 각각 시도하고 원장 항목을 읽는다.
- 펜스 5 로 원장 기록을 시도한다.
- 두 메서드에 미지정 값을 넣어 결과를 비교한다.
- 고유 색인 없이 같은 경합을 반복하고 원장 항목 수를 센다.
- 실행기에서 갱신과 원장 기록 사이에 무엇이 실행되는지, 완료를 만드는 팩토리가 체크포인트를 남기는지 읽는다.
- 원장 기록을 부르는 시험과 고유 색인을 만드는 곳을 전수로 센다.
본문
원장 인터페이스는 포크가 구현하도록 공개돼 있고, 기록 메서드의 계약을 javadoc 이 적는다.
계약과 몸통
:::evidence key="a06-f013-recordapplied" alt="원장 인터페이스의 javadoc 계약, 그 계약을 구현한 메서드의 삽입 부분, 그것이 부르는 펜스 검사와 그 검사의 javadoc, 같은 파일의 체크포인트 저장이 거는 저장된 펜스 필터, 그리고 그 자리가 중복 키를 플랫폼 예외로 번역하게 된 이유를 적은 주석과 번역 코드를 출력한 터미널 기록." caption="계약은 펜스가 현재 것일 때만 기록 · 구현은 검사 하나 뒤 삽입 · 검사의 조건은 fence == UNFENCED 하나이고 javadoc 도 미지정 리스만 거절한다고 적음 · 체크포인트 저장은 저장된 펜스를 필터에 걸고 중복 키를 번역 — 64줄 · exit 0" zoom="true" :::
/**
* Records a completed migration, only if the fence is still the current one.
*
* <p>Conditioned on the fence because the runner that wrote the batches may no longer be the
* runner that owns the lease. A ledger entry from a superseded runner says a migration completed
* when the work it describes was overwritten by the runner that replaced it.
*
* @param fence the acquisition token from {@link MongoMigrationLock#fence()}
* @throws ... MongoOperationRejectedException when a newer acquisition exists
*/
구현은 검사 하나를 부르고 삽입한다.
requireCurrentFence(fence, "ledger entry for " + migrationId.value());
ledger.insertOne(
new Document(MIGRATION_ID, migrationId.value())
.append("checksum", checksum.value())
...
.append("fence", fence));
그 검사의 javadoc 과 몸통은 서로 맞는다.
/**
* Refuses a write from an unfenced lease.
...
private static void requireCurrentFence(long fence, String what) {
if (fence == MongoMigrationLock.UNFENCED) {
비교 대상은 저장된 값이 아니라 상수 하나다. 서버로 나가는 조건에 펜스는 없고, 펜스는 삽입되는 문서의 필드로만 남는다. 문서가 틀린 것이 아니라, 이 검사로는 인터페이스가 적은 계약을 만족시킬 수 없다.
같은 파일이 다른 메서드에서는 지킨다
체크포인트 저장은 저장된 펜스를 필터에 건다.
Filters.and(
Filters.eq(MIGRATION_ID, checkpoint.migrationId().value()),
Filters.or(Filters.exists("fence", false), Filters.lte("fence", fence))),
그리고 중복 키를 잡아 번역한다. 그 자리 주석이 이유를 적는다.
// Nor was the refusal itself reachable. With `upsert(true)`, a superseded write matches nothing
// and MongoDB attempts an insert, which the unique index on migrationId rejects — so a
// superseded runner got a driver-level duplicate-key error instead of the sentence written for
// it, and the branch meant to produce that sentence was dead.
복제 세트에서
:::evidence key="a06-f013-recordapplied-probe" alt="단일 노드 복제 세트에서 살아 있는 실행자가 펜스 5 로 쓴 체크포인트, 펜스 1 을 든 밀려난 실행자가 체크포인트 저장과 원장 기록을 각각 시도한 결과와 그 뒤의 원장 항목, 살아 있는 실행자가 다시 원장 기록을 시도한 결과, 두 메서드에 미지정 펜스를 넣었을 때의 결과, 그리고 고유 색인 없이 같은 경합을 반복했을 때 남은 원장 항목 수와 그 두 항목을 출력한 터미널 기록." caption="밀려난 펜스로 체크포인트는 거절되고 원장 기록은 수용 · 살아 있는 펜스의 기록은 migrationId_1 중복 키로 거절 · 미지정 펜스는 두 메서드 다 거절 · 색인이 없으면 둘 다 수용되어 원장에 두 줄 — 22줄 · exit 0" zoom="true" :::
펜스 5 의 체크포인트가 있는 상태에서 펜스 1 을 든 실행자가 두 메서드를 부른다.
[밀려난 실행자, 펜스 1] 같은 계약을 두 메서드에 건다
saveCheckpoint -> 거절, MongoOperationRejectedException: a newer migration runner owns the lease; this runner's checkpoint write was refused
recordApplied -> 수용
원장 항목 : {"migrationId": "20260829-001", "checksum": "superseded", "operator": "stale-runner", ..., "fence": 1}
그 뒤 살아 있는 실행자가 기록하면 이렇게 된다.
recordApplied -> 거절, MongoWriteException code=11000 index=migrationId_1
같은 드라이버 오류가 지금 원장 기록에서 나온다. 다만 받는 쪽이 바뀌었다. 주석이 적은 과거에는 밀려난 실행자가 그 오류를 받았고, 지금은 실제로 작업한 실행자가 받는다.
미지정 펜스는 두 메서드 다 거절한다. 밀려난 펜스는 원장 기록만 통과한다. 두 결과 사이에 이 구현이 거절할 수 있는 값의 집합이 있다.
그 색인이 없으면 어떻게 되는지도 같이 돌렸다.
[대조 2] ensureIndexes 를 부르지 않은 원장에서 같은 경합
recordApplied -> 수용
recordApplied -> 수용
원장 항목 수 : 2
포크가 조립할 때 인접한 장치가 막는다
:::evidence key="a06-f013-recordapplied-around" alt="실행기에서 리스 갱신과 원장 기록 사이에 실행되는 것, 완료와 미완료를 만드는 두 팩토리, 그 갱신이 던지는 예외와 같은 인터페이스의 다른 잠금 구현이 같은 상황에 던지는 예외, 펜스 인자를 쓰지 않는 다른 원장 구현, 원장 기록을 부르는 시험 전수와 그중 실서버 시험의 단언, 그리고 고유 색인을 만드는 메서드를 부르는 곳 전수를 출력한 터미널 기록." caption="갱신과 삽입 사이에 사후조건 검사 · 완료 팩토리는 체크포인트를 널로 넣음 · 갱신은 IllegalStateException, 다른 잠금 구현은 플랫폼 예외 · 다른 원장 구현은 펜스 미사용 · 실서버 시험의 단언 문장이 막는 것은 고유 색인이라고 적음 · 색인 생성 호출자는 시험 하나 — 69줄 · exit 0" zoom="true" :::
이 리프에는 실행기와 원장과 잠금을 조립하는 프로덕션 코드가 없다. 포크가 이 저장소의 실행기와 잠금을 함께 쓰면, 실행기가 원장을 쓰기 전에 리스를 갱신한다.
lock.refresh(template.maxTime());
if (!migration.postcondition().isSatisfied(context, result)) {
...
result.resumePoint().ifPresent(checkpoint -> ledger.saveCheckpoint(checkpoint, lock.fence()));
if (result.status() == MongoMigrationResult.Status.COMPLETED && !context.dryRun()) {
ledger.recordApplied(
갱신과 삽입 사이에 있는 것은 사후조건 검사 하나다. 바로 위 줄의 체크포인트 저장은 이 경로에 들어오지 않는다. 완료를 만드는 팩토리가 체크포인트를 널로 넣으므로 두 줄은 배타적이다.
public static MongoMigrationResult completed(long processedCount) {
return new MongoMigrationResult(Status.COMPLETED, processedCount, null, "");
}
그리고 그 갱신이 실패했을 때 나오는 것은 플랫폼의 거절 문장이 아니다.
throw new IllegalStateException(
"the migration lease was lost before it could be refreshed; another runner may have "
같은 인터페이스의 다른 잠금 구현은 같은 상황에 플랫폼 예외를 던진다. 이 기록이 다루는 형태가 보호 장치 쪽에서 한 번 더 나온다.
시험은 이 자리를 밟지 않는다
원장 기록을 부르는 시험은 다섯 줄이고, 밀려난 펜스를 넣는 것은 없다. 이름이 원장 쓰기가 펜스를 실어 나른다고 말하는 시험은 미완료를 돌려주는 마이그레이션을 쓰므로 원장 기록 분기에 들어가지 않는다. 실서버 레인의 시험 하나가 같은 펜스로 두 번 부르는데, 그 단언에 붙은 문장이 이 기록의 결론을 그대로 적는다.
.as("the unique index, not the read-then-write check, is what makes this impossible")
.isInstanceOf(RuntimeException.class);
그 고유 색인을 만드는 메서드를 부르는 파일은 시험 하나와 선언 파일 자신뿐이다.
확인하지 못한 것
원장이 밀려난 값으로 남은 뒤 후속 마이그레이션 판정이 어떻게 되는지 추적하지 않았다. 갱신과 삽입 사이의 창을 실제로 벌려 보지도 않았다. 확인한 것은 그 창을 지나 원장 기록에 도달했을 때 무엇이 일어나는지다.