Files
document-haness/docs/clean-architecture-backend-template/final/evidence/raw/analysis-finding-a05-f030.txt
T
DongHyeonkaandClaude Opus 5 b2963105a8 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>
2026-09-04 22:51:59 +09:00

183 lines
13 KiB
Plaintext

# 마이그레이션이 만든 것
V8__notification_admin_claim.sql:1 -- NTF-020 — the admin path was find, then side effect, then save.
V8__notification_admin_claim.sql:2 --
V8__notification_admin_claim.sql:3 -- Two callers presenting the same operation id both read "not found", both ran the redrive, and
V8__notification_admin_claim.sql:4 -- both saved. The unique constraint on operation_id was already there and made the *second save*
V8__notification_admin_claim.sql:5 -- fail — after the second side effect had already happened. An idempotency key that is checked but
V8__notification_admin_claim.sql:6 -- never held prevents a repeat and does nothing about a race, which is the case that matters for a
V8__notification_admin_claim.sql:7 -- destructive operation.
V8__notification_admin_claim.sql:8 --
V8__notification_admin_claim.sql:9 -- The claim is now the insert itself: ON CONFLICT DO NOTHING means exactly one caller creates the
V8__notification_admin_claim.sql:10 -- row, and everyone else reads what that caller is doing.
V8__notification_admin_claim.sql:11
V8__notification_admin_claim.sql:12 ALTER TABLE notification_admin_audit
V8__notification_admin_claim.sql:13 ADD COLUMN IF NOT EXISTS command_fingerprint char(64),
V8__notification_admin_claim.sql:14 ADD COLUMN IF NOT EXISTS phase varchar(20) NOT NULL DEFAULT 'COMPLETED',
V8__notification_admin_claim.sql:15 ADD COLUMN IF NOT EXISTS claimed_at timestamptz;
V8__notification_admin_claim.sql:16
V8__notification_admin_claim.sql:17 COMMENT ON COLUMN notification_admin_audit.command_fingerprint IS
V8__notification_admin_claim.sql:18 'A digest of the command this operation id was presented with. The same id with a different '
V8__notification_admin_claim.sql:19 'command is a mistake worth reporting, not an idempotent repeat — returning the earlier '
V8__notification_admin_claim.sql:20 'result would silently execute neither command.';
V8__notification_admin_claim.sql:21
V8__notification_admin_claim.sql:22 COMMENT ON COLUMN notification_admin_audit.phase IS
V8__notification_admin_claim.sql:23 'CLAIMED while a caller holds the operation, COMPLETED once it finished. A row that exists in '
V8__notification_admin_claim.sql:24 'CLAIMED tells a second caller that the work is under way rather than done, which is a '
V8__notification_admin_claim.sql:25 'different answer and needs to be.';
V8__notification_admin_claim.sql:26
V8__notification_admin_claim.sql:27 ALTER TABLE notification_admin_audit
V8__notification_admin_claim.sql:28 ADD CONSTRAINT ck_notification_admin_phase
V8__notification_admin_claim.sql:29 CHECK (phase IN ('CLAIMED', 'COMPLETED', 'FAILED'));
operation_id 의 유일성 제약 :
src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/notification-platform/V3__notification_platform_inbox_admin.sql:61: CONSTRAINT uk_notification_admin_operation UNIQUE (operation_id)
src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/notification-platform/V3__notification_platform_inbox_admin.sql:64:COMMENT ON CONSTRAINT uk_notification_admin_operation ON notification_admin_audit IS
# 청구가 넣는 행과 저장이 넣는 행
JpaAdminOperationStore.java:30
JpaAdminOperationStore.java:31 @Override
JpaAdminOperationStore.java:32 public dev.caskeleton.application.notification.platform.admin.AdminOperationClaim claim(
JpaAdminOperationStore.java:33 String operationId, String commandFingerprint, AdminActor actor, String action) {
JpaAdminOperationStore.java:34 Objects.requireNonNull(operationId, "operationId");
JpaAdminOperationStore.java:35 Objects.requireNonNull(commandFingerprint, "commandFingerprint");
JpaAdminOperationStore.java:36 Objects.requireNonNull(actor, "actor");
JpaAdminOperationStore.java:37 Objects.requireNonNull(action, "action");
JpaAdminOperationStore.java:38
JpaAdminOperationStore.java:39 int claimed =
JpaAdminOperationStore.java:40 audits.claimOperation(
JpaAdminOperationStore.java:41 ids.nextId(),
JpaAdminOperationStore.java:42 operationId,
JpaAdminOperationStore.java:43 action,
JpaAdminOperationStore.java:44 actor.actorRef(),
JpaAdminOperationStore.java:45 commandFingerprint,
JpaAdminOperationStore.java:46 clock.instant());
JpaAdminOperationStore.java:47 if (claimed == 1) {
JpaAdminOperationStore.java:48 return dev.caskeleton.application.notification.platform.admin.AdminOperationClaim.claimed();
JpaAdminOperationStore.java:49 }
JpaAdminOperationStore.java:50 // Somebody else has the row. What they are doing decides this caller's answer.
JpaAdminOperationStore.java:51 List<Object[]> existing = audits.phaseOf(operationId);
JpaAdminOperationStore.java:52 if (existing.isEmpty()) {
JpaAdminOperationStore.java:53 // The row vanished between the insert and the read, which only a concurrent delete explains.
JpaAdminOperationStore.java:54 // Refusing is safer than assuming the operation is free.
JpaAdminOperationStore.java:55 return dev.caskeleton.application.notification.platform.admin.AdminOperationClaim
JpaAdminOperationStore.java:56 .inProgress();
JpaAdminOperationStore.java:57 }
JpaAdminOperationStore.java:58 String phase = String.valueOf(existing.get(0)[0]);
JpaAdminOperationStore.java:59 String fingerprint = existing.get(0)[1] == null ? null : String.valueOf(existing.get(0)[1]);
JpaAdminOperationStore.java:60 if (fingerprint != null && !fingerprint.equals(commandFingerprint)) {
JpaAdminOperationStore.java:61 // The same id, a different command. Returning the earlier result would execute neither.
JpaAdminOperationStore.java:62 return dev.caskeleton.application.notification.platform.admin.AdminOperationClaim.conflict();
JpaAdminOperationStore.java:63 }
JpaAdminOperationStore.java:64 if ("CLAIMED".equals(phase)) {
JpaAdminOperationStore.java:65 return dev.caskeleton.application.notification.platform.admin.AdminOperationClaim
JpaAdminOperationStore.java:66 .inProgress();
JpaAdminOperationStore.java:67 }
JpaAdminOperationStore.java:68 return findByOperationId(operationId)
JpaAdminOperationStore.java:69 .map(dev.caskeleton.application.notification.platform.admin.AdminOperationClaim::replay)
JpaAdminOperationStore.java:70 .orElseGet(
JpaAdminOperationStore.java:71 dev.caskeleton.application.notification.platform.admin.AdminOperationClaim::inProgress);
JpaAdminOperationStore.java:72 }
AdminAuditJpaRepository.java:22 /**
AdminAuditJpaRepository.java:23 * Claims an operation id, or reports who already holds it.
AdminAuditJpaRepository.java:24 *
AdminAuditJpaRepository.java:25 * <p>{@code ON CONFLICT DO NOTHING} is the claim: exactly one caller inserts the row, and the
AdminAuditJpaRepository.java:26 * update count tells that caller it won. Everything the admin path used to do — read, act, write
AdminAuditJpaRepository.java:27 * — happened between two callers' reads, so both acted.
AdminAuditJpaRepository.java:28 *
AdminAuditJpaRepository.java:29 * @return 1 when this caller claimed it, 0 when somebody else already had
AdminAuditJpaRepository.java:30 */
AdminAuditJpaRepository.java:31 @org.springframework.data.jpa.repository.Modifying
AdminAuditJpaRepository.java:32 @org.springframework.data.jpa.repository.Query(
AdminAuditJpaRepository.java:33 value =
AdminAuditJpaRepository.java:34 "INSERT INTO notification_admin_audit ("
AdminAuditJpaRepository.java:35 + " id, operation_id, action, actor_ref, attributes, dry_run, occurred_at,"
AdminAuditJpaRepository.java:36 + " command_fingerprint, phase, claimed_at)"
AdminAuditJpaRepository.java:37 + " VALUES (:id, :operationId, :action, :actorRef, '{}'::jsonb, false, :now,"
AdminAuditJpaRepository.java:38 + " :fingerprint, 'CLAIMED', :now)"
AdminAuditJpaRepository.java:39 + " ON CONFLICT (operation_id) DO NOTHING",
AdminAuditJpaRepository.java:40 nativeQuery = true)
AdminAuditJpaRepository.java:41 int claimOperation(
AdminAuditJpaRepository.java:42 @org.springframework.data.repository.query.Param("id") java.util.UUID id,
AdminAuditJpaRepository.java:43 @org.springframework.data.repository.query.Param("operationId") String operationId,
AdminAuditJpaRepository.java:44 @org.springframework.data.repository.query.Param("action") String action,
AdminAuditJpaRepository.java:45 @org.springframework.data.repository.query.Param("actorRef") String actorRef,
AdminAuditJpaRepository.java:46 @org.springframework.data.repository.query.Param("fingerprint") String fingerprint,
AdminAuditJpaRepository.java:47 @org.springframework.data.repository.query.Param("now") java.time.Instant now);
JpaAdminOperationStore.java:91 @Override
JpaAdminOperationStore.java:92 public AdminOperationResult save(AdminOperationResult result, AdminActor actor, String action) {
JpaAdminOperationStore.java:93 Objects.requireNonNull(result, "result");
JpaAdminOperationStore.java:94 Objects.requireNonNull(actor, "actor");
JpaAdminOperationStore.java:95 Objects.requireNonNull(action, "action");
JpaAdminOperationStore.java:96 audits.saveAndFlush(
JpaAdminOperationStore.java:97 new AdminAuditEntity(
JpaAdminOperationStore.java:98 ids.nextId(),
JpaAdminOperationStore.java:99 result.operationId(),
JpaAdminOperationStore.java:100 action,
JpaAdminOperationStore.java:101 actor.actorRef(),
JpaAdminOperationStore.java:102 result.reasonCodes().isEmpty() ? null : result.reasonCodes().get(0),
JpaAdminOperationStore.java:103 actor.tenantId().map(tenant -> tenant.value()).orElse(null),
JpaAdminOperationStore.java:104 "{}",
JpaAdminOperationStore.java:105 result.dryRun(),
JpaAdminOperationStore.java:106 clock.instant()));
JpaAdminOperationStore.java:107 return result;
JpaAdminOperationStore.java:108 }
# 그 청구를 부르는 자리
operations.claim 을 부르는 main 줄 : 0
[대조] operations.save 를 부르는 main 줄 : 4
AdminOperationClaim 을 참조하는 파일 :
JpaAdminOperationStore.java
AdminOperationClaimContractTest.java
AdminOperationClaim.java
AdminOperationStorePort.java
# 청구가 넣은 행을 완료로 바꾸는 코드가 있는가
프로덕션에서 그 컬럼을 갱신하는 자리 : 0 개
그 문장이 나오는 자리 전부 (시험 포함) :
AdminOperationClaimContractTest.java:110 "UPDATE notification_admin_audit SET phase = 'ALMOST' WHERE operation_id = ?")) {
AdminAuditEntity 가 가진 필드 :
AdminAuditEntity.java:20 private UUID id;
AdminAuditEntity.java:23 private String operationId;
AdminAuditEntity.java:26 private String action;
AdminAuditEntity.java:29 private String actorRef;
AdminAuditEntity.java:32 private String reasonCode;
AdminAuditEntity.java:35 private String tenantId;
AdminAuditEntity.java:47 private String attributes;
AdminAuditEntity.java:50 private boolean dryRun;
AdminAuditEntity.java:53 private Instant occurredAt;
# 청구 계약 시험이 무엇을 단언하는가
AdminOperationClaimContractTest.java:54 @BeforeEach
AdminOperationClaimContractTest.java:55 void migrate() throws SQLException, IOException {
--
AdminOperationClaimContractTest.java:67 @DisplayName("exactly one of two concurrent claims on the same id wins")
AdminOperationClaimContractTest.java:68 void exactlyOneClaimWins() throws SQLException {
--
AdminOperationClaimContractTest.java:76 @DisplayName("a different operation id claims independently")
AdminOperationClaimContractTest.java:77 void aDifferentIdClaimsIndependently() throws SQLException {
--
AdminOperationClaimContractTest.java:83 @DisplayName("the claim records the phase, so a second caller can tell doing from done")
AdminOperationClaimContractTest.java:84 void theClaimRecordsThePhase() throws SQLException {
--
AdminOperationClaimContractTest.java:93 @DisplayName("the claim records the fingerprint it was made with")
AdminOperationClaimContractTest.java:94 void theClaimRecordsItsFingerprint() throws SQLException {
--
AdminOperationClaimContractTest.java:103 @DisplayName("the phase check constraint refuses an unknown phase")
AdminOperationClaimContractTest.java:104 void thePhaseConstraintRefusesAnUnknownPhase() throws SQLException {
그 시험 파일에서 COMPLETED 가 나오는 줄 : 0 개
phase 를 단언하는 줄 : 1 개
# 두 연산을 이어 부르면 무엇이 일어나는가
postgres:16-alpine 에 V3 51~62 와 V8 12~15·27~29 를 그대로 적용하고 두 삽입을 이어 실행한다.
1. claimOperation 의 삽입 (AdminAuditJpaRepository:34-39)
2. save 의 삽입 (JpaAdminOperationStore:96-106) — ids.nextId() 로 새 id, 같은 operation_id
ERROR: duplicate key value violates unique constraint "uk_notification_admin_operation"
DETAIL: Key (operation_id)=(op-1) already exists.
3. 남은 행
operation_id | phase | id
--------------+---------+--------------------------------------
op-1 | CLAIMED | 11111111-1111-1111-1111-111111111111
(1 row)