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>
162 lines
12 KiB
XML
162 lines
12 KiB
XML
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1404" viewBox="0 0 1200 1404" role="img">
|
|
<title>terminal evidence</title>
|
|
<desc>Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.</desc>
|
|
<rect x="1" y="1" width="1198" height="1402" rx="14" fill="#0d1117" stroke="#30363d"/>
|
|
<rect x="1" y="1" width="1198" height="44" rx="14" fill="#161b22"/>
|
|
<rect x="1" y="30" width="1198" height="14" fill="#161b22"/>
|
|
<circle cx="24" cy="22" r="6" fill="#ff5f57"/>
|
|
<circle cx="44" cy="22" r="6" fill="#febc2e"/>
|
|
<circle cx="64" cy="22" r="6" fill="#28c840"/>
|
|
<text x="92" y="27" fill="#8b949e" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">terminal evidence</text>
|
|
<text x="24" y="68" fill="#c9d1d9" font-size="15" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">$ set -e
|
|
mkdir -p /tmp/probe
|
|
CT=recovery-race-probe
|
|
docker rm -f "$CT" >/dev/null 2>&1 || true
|
|
docker run -d --name "$CT</tspan><tspan x="24" dy="22">" --network "container:workmachine" \
|
|
-e POSTGRES_PASSWORD=probe -e POSTGRES_USER=probe -e POSTGRES_DB=probe \
|
|
postg</tspan><tspan x="24" dy="22">res:16-alpine -p 55432 >/dev/null
|
|
for i in $(seq 1 40); do
|
|
docker inspect -f '{{.State.Running}}' "$CT" | grep -q true</tspan><tspan x="24" dy="22"> || { echo '컨테이너가 뜨지 않았다'; exit 1; }
|
|
docker exec "$CT" pg_isready -U probe -p 55432 >/dev/null 2>&1 && break</tspan><tspan x="24" dy="22">
|
|
sleep 1
|
|
done
|
|
docker exec "$CT" psql -U probe -p 55432 -d probe -tAc 'select version()' | cut -c1-24
|
|
M=adapter/outboun</tspan><tspan x="24" dy="22">d/persistence-jpa/src/main/resources/db/migration
|
|
for d in postgresql jpa/core jpa/fileserver; do
|
|
for f in $(ls "$M/$d</tspan><tspan x="24" dy="22">"/V*.sql | sort -V); do
|
|
docker exec -i "$CT" psql -U probe -p 55432 -d probe -q -v ON_ERROR_STOP=1 < "$f" >/dev/null</tspan><tspan x="24" dy="22"> 2>&1 || true
|
|
done
|
|
done
|
|
|
|
echo
|
|
echo '# 열린 항목이 없는 상태에서 두 연결을 장벽으로 맞춰 동시에 푼다. 여섯 번 반복.'
|
|
ec</tspan><tspan x="24" dy="22">ho '# 시계 출처만 DB 로 바꿨다. 조건과 열은 저장소 질의 그대로다.'
|
|
cat > /tmp/probe/cp.sh <<'CPSH'
|
|
# 락파일의 한 구성</tspan><tspan x="24" dy="22">에 실린 좌표 전부를 캐시 경로로 바꾼다. Gradle 실행 없이 재현된다.
|
|
LOCK=${1:-adapter/outbound/persistence-jpa/gradle.loc</tspan><tspan x="24" dy="22">kfile}
|
|
CONF=${2:-postgresqlIntegrationTestRuntimeClasspath}
|
|
CACHE=/root/.gradle/caches/modules-2/files-2.1
|
|
cp=""
|
|
while I</tspan><tspan x="24" dy="22">FS= read -r line; do
|
|
coord=${line%%=*}; confs=${line#*=}
|
|
case ",$confs," in *",$CONF,"*) ;; *) continue ;; esac
|
|
gr</tspan><tspan x="24" dy="22">oup=${coord%%:*}; rest=${coord#*:}; name=${rest%%:*}; ver=${rest##*:}
|
|
jar=$(find "$CACHE/$group/$name/$ver" -name '*.j</tspan><tspan x="24" dy="22">ar' ! -name '*sources*' ! -name '*javadoc*' 2>/dev/null | head -1)
|
|
[ -n "$jar" ] && cp="$cp:$jar"
|
|
done < <(grep -E '^[</tspan><tspan x="24" dy="22">a-zA-Z0-9._-]+:[^=]+=' "$LOCK")
|
|
echo "${cp#:}"
|
|
CPSH
|
|
cat > /tmp/probe/RecoveryRaceProbe.java <<'JAVA'
|
|
import java.sql.Con</tspan><tspan x="24" dy="22">nection;
|
|
import java.sql.DriverManager;
|
|
import java.sql.ResultSet;
|
|
import java.sql.Statement;
|
|
import java.util.UUID;
|
|
imp</tspan><tspan x="24" dy="22">ort java.util.concurrent.CountDownLatch;
|
|
|
|
/** 두 호출자가 동시에 enqueue 를 부르는 상황을 그 구조 그대로 돌린다. */
|
|
publ</tspan><tspan x="24" dy="22">ic class RecoveryRaceProbe {
|
|
|
|
private static final String REFRESH =
|
|
"update fs_recovery_item set reason_code = ?</tspan><tspan x="24" dy="22">, attempt = attempt + 1, updated_at = now()"
|
|
+ " where file_id = ? and status = 'PENDING'";
|
|
|
|
private static </tspan><tspan x="24" dy="22">final String INSERT =
|
|
"insert into fs_recovery_item (recovery_id, file_id, reason_code, status, attempt,"
|
|
</tspan><tspan x="24" dy="22"> + " created_at, updated_at) values (?, ?, ?, 'PENDING', 0, now(), now())";
|
|
|
|
public static void main(String[] args) t</tspan><tspan x="24" dy="22">hrows Exception {
|
|
int wins = 0;
|
|
int violations = 0;
|
|
int extraRows = 0;
|
|
for (int round = 0; round < 6; ro</tspan><tspan x="24" dy="22">und++) {
|
|
runOnce(round);
|
|
}
|
|
}
|
|
|
|
private static void runOnce(int round) throws Exception {
|
|
String url = "</tspan><tspan x="24" dy="22">jdbc:postgresql://127.0.0.1:55432/probe";
|
|
UUID file = UUID.randomUUID();
|
|
try (Connection setup = DriverManager.g</tspan><tspan x="24" dy="22">etConnection(url, "probe", "probe");
|
|
Statement s = setup.createStatement()) {
|
|
s.execute("delete from fs_re</tspan><tspan x="24" dy="22">covery_item");
|
|
s.execute("delete from fs_file");
|
|
s.execute("insert into fs_file (file_id, namespace, state, </tspan><tspan x="24" dy="22">original_name, created_at,"
|
|
+ " updated_at) values ('" + file + "', 'probe', 'CREATED', 'probe.txt', now(), no</tspan><tspan x="24" dy="22">w())");
|
|
}
|
|
|
|
var started = new CountDownLatch(2);
|
|
var go = new CountDownLatch(1);
|
|
String[] result = new St</tspan><tspan x="24" dy="22">ring[2];
|
|
|
|
Runnable enqueue = () -> {
|
|
int slot = Integer.parseInt(Thread.currentThread().getName());
|
|
try </tspan><tspan x="24" dy="22">(Connection db = DriverManager.getConnection(url, "probe", "probe")) {
|
|
db.setAutoCommit(false);
|
|
started.</tspan><tspan x="24" dy="22">countDown();
|
|
go.await();
|
|
int updated;
|
|
try (var refresh = db.prepareStatement(REFRESH)) {
|
|
</tspan><tspan x="24" dy="22"> refresh.setString(1, "reason-" + slot);
|
|
refresh.setObject(2, file);
|
|
updated = refresh.executeUpda</tspan><tspan x="24" dy="22">te();
|
|
}
|
|
if (updated == 0) {
|
|
try (var insert = db.prepareStatement(INSERT)) {
|
|
inser</tspan><tspan x="24" dy="22">t.setObject(1, UUID.randomUUID());
|
|
insert.setObject(2, file);
|
|
insert.setString(3, "reason-" + sl</tspan><tspan x="24" dy="22">ot);
|
|
insert.executeUpdate();
|
|
}
|
|
}
|
|
db.commit();
|
|
result[slot] = updated > 0 ?</tspan><tspan x="24" dy="22"> "갱신으로 흡수" : "삽입 성공";
|
|
} catch (Exception failure) {
|
|
result[slot] = failure.getClass().getSimpleN</tspan><tspan x="24" dy="22">ame() + ": "
|
|
+ String.valueOf(failure.getMessage()).split("\n")[0];
|
|
}
|
|
};
|
|
|
|
Thread a = new Threa</tspan><tspan x="24" dy="22">d(enqueue, "0");
|
|
Thread b = new Thread(enqueue, "1");
|
|
a.start();
|
|
b.start();
|
|
started.await();
|
|
go.coun</tspan><tspan x="24" dy="22">tDown();
|
|
a.join();
|
|
b.join();
|
|
|
|
int rows;
|
|
try (Connection db = DriverManager.getConnection(url, "probe", "</tspan><tspan x="24" dy="22">probe");
|
|
Statement s = db.createStatement();
|
|
ResultSet r = s.executeQuery("select count(*) from fs_recov</tspan><tspan x="24" dy="22">ery_item")) {
|
|
r.next();
|
|
rows = r.getInt(1);
|
|
}
|
|
String winner = result[0].startsWith("삽입") ? "A" : "</tspan><tspan x="24" dy="22">B";
|
|
String loser = winner.equals("A") ? result[1] : result[0];
|
|
System.out.printf("%d 회차: 이긴 쪽 %s · 진 쪽 %</tspan><tspan x="24" dy="22">s · 남은 행 %d%n",
|
|
round + 1, winner, loser.replaceFirst("^PSQLException: ERROR: ", ""), rows);
|
|
}
|
|
}
|
|
JAVA
|
|
CP="</tspan><tspan x="24" dy="22">$(bash /tmp/probe/cp.sh adapter/outbound/persistence-jpa/gradle.lockfile postgresqlIntegrationTestRuntimeClasspath)"
|
|
jav</tspan><tspan x="24" dy="22">ac -encoding UTF-8 -nowarn -cp "$CP" -d /tmp/probe /tmp/probe/RecoveryRaceProbe.java
|
|
java -Dstdout.encoding=UTF-8 -cp "$</tspan><tspan x="24" dy="22">CP:/tmp/probe" RecoveryRaceProbe
|
|
|
|
docker rm -f "$CT" >/dev/null 2>&1 || true</tspan></text>
|
|
<text x="24" y="1080" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">cwd: /shared/codebase/clean-architecture-backend-template/src</tspan></text>
|
|
<text x="24" y="1102" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">time: 2026-09-02T05:03:15+00:00 · exit 0</tspan></text>
|
|
<line x1="24" y1="1116" x2="1176" y2="1116" stroke="#30363d"/>
|
|
<text x="24" y="1156" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">PostgreSQL 16.15 on x86_</tspan></text>
|
|
<text x="24" y="1178" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
|
|
<text x="24" y="1200" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"># 열린 항목이 없는 상태에서 두 연결을 장벽으로 맞춰 동시에 푼다. 여섯 번 반복.</tspan></text>
|
|
<text x="24" y="1222" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"># 시계 출처만 DB 로 바꿨다. 조건과 열은 저장소 질의 그대로다.</tspan></text>
|
|
<text x="24" y="1244" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">1 회차: 이긴 쪽 B · 진 쪽 duplicate key value violates unique constraint "uq_fs_recovery_open" · 남은 행 1</tspan></text>
|
|
<text x="24" y="1266" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">2 회차: 이긴 쪽 B · 진 쪽 duplicate key value violates unique constraint "uq_fs_recovery_open" · 남은 행 1</tspan></text>
|
|
<text x="24" y="1288" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">3 회차: 이긴 쪽 A · 진 쪽 duplicate key value violates unique constraint "uq_fs_recovery_open" · 남은 행 1</tspan></text>
|
|
<text x="24" y="1310" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">4 회차: 이긴 쪽 A · 진 쪽 duplicate key value violates unique constraint "uq_fs_recovery_open" · 남은 행 1</tspan></text>
|
|
<text x="24" y="1332" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">5 회차: 이긴 쪽 B · 진 쪽 duplicate key value violates unique constraint "uq_fs_recovery_open" · 남은 행 1</tspan></text>
|
|
<text x="24" y="1354" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">6 회차: 이긴 쪽 A · 진 쪽 duplicate key value violates unique constraint "uq_fs_recovery_open" · 남은 행 1</tspan></text>
|
|
</svg>
|