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>
183 lines
13 KiB
Plaintext
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)
|
|
|