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>
228 lines
17 KiB
Plaintext
228 lines
17 KiB
Plaintext
# 포트가 선언한 세 연산 (33 줄 전문)
|
|
AdminOperationStorePort.java:1 package dev.caskeleton.application.notification.platform.admin;
|
|
AdminOperationStorePort.java:2
|
|
AdminOperationStorePort.java:3 import java.util.Optional;
|
|
AdminOperationStorePort.java:4
|
|
AdminOperationStorePort.java:5 /** Idempotent record of operator actions, so a retried request cannot redrive twice. */
|
|
AdminOperationStorePort.java:6 public interface AdminOperationStorePort {
|
|
AdminOperationStorePort.java:7
|
|
AdminOperationStorePort.java:8 /** Claim an operation id, or return the previous result for it. */
|
|
AdminOperationStorePort.java:9 Optional<AdminOperationResult> findByOperationId(String operationId);
|
|
AdminOperationStorePort.java:10
|
|
AdminOperationStorePort.java:11 /** Store the result of an operation. */
|
|
AdminOperationStorePort.java:12 AdminOperationResult save(AdminOperationResult result, AdminActor actor, String action);
|
|
AdminOperationStorePort.java:13
|
|
AdminOperationStorePort.java:14 /**
|
|
AdminOperationStorePort.java:15 * Claims an operation id before anything happens, atomically.
|
|
AdminOperationStorePort.java:16 *
|
|
AdminOperationStorePort.java:17 * <p>The admin path was find, then side effect, then save. Two callers presenting the same
|
|
AdminOperationStorePort.java:18 * operation id both read "not found", both ran the redrive, and both saved — the idempotency key
|
|
AdminOperationStorePort.java:19 * was checked but never held, so it prevented a repeat and not a race. A destructive operation
|
|
AdminOperationStorePort.java:20 * run twice concurrently is the exact failure the id exists to stop.
|
|
AdminOperationStorePort.java:21 *
|
|
AdminOperationStorePort.java:22 * <p>The fingerprint is part of the claim: the same id with a different command is not a replay,
|
|
AdminOperationStorePort.java:23 * it is a conflict, and answering it with the earlier result would silently execute neither.
|
|
AdminOperationStorePort.java:24 *
|
|
AdminOperationStorePort.java:25 * @param operationId the caller's idempotency key
|
|
AdminOperationStorePort.java:26 * @param commandFingerprint a digest of the command this id was presented with
|
|
AdminOperationStorePort.java:27 * @param actor who is asking
|
|
AdminOperationStorePort.java:28 * @param action what they are asking for
|
|
AdminOperationStorePort.java:29 * @return whether this caller may proceed, and what the earlier one did if not
|
|
AdminOperationStorePort.java:30 */
|
|
AdminOperationStorePort.java:31 AdminOperationClaim claim(
|
|
AdminOperationStorePort.java:32 String operationId, String commandFingerprint, AdminActor actor, String action);
|
|
AdminOperationStorePort.java:33 }
|
|
|
|
# claim 이 돌려주는 네 답
|
|
AdminOperationClaim.java:5
|
|
AdminOperationClaim.java:6 /**
|
|
AdminOperationClaim.java:7 * What an admin operation id claim decided.
|
|
AdminOperationClaim.java:8 *
|
|
AdminOperationClaim.java:9 * <p>Four outcomes, because collapsing them loses the distinction that matters. A replay must
|
|
AdminOperationClaim.java:10 * return the earlier result without acting; an in-progress claim must not act and must not pretend
|
|
AdminOperationClaim.java:11 * the work is done; and the same id with a different command is a mistake worth reporting rather
|
|
AdminOperationClaim.java:12 * than an idempotent repeat.
|
|
AdminOperationClaim.java:13 *
|
|
AdminOperationClaim.java:14 * @param outcome what the caller may do
|
|
AdminOperationClaim.java:15 * @param existing the earlier result, when there is one
|
|
AdminOperationClaim.java:16 */
|
|
AdminOperationClaim.java:17 public record AdminOperationClaim(Outcome outcome, Optional<AdminOperationResult> existing) {
|
|
AdminOperationClaim.java:18
|
|
AdminOperationClaim.java:19 /** The four answers a claim can give. */
|
|
AdminOperationClaim.java:20 public enum Outcome {
|
|
AdminOperationClaim.java:21
|
|
AdminOperationClaim.java:22 /** This caller holds the operation and may proceed. */
|
|
AdminOperationClaim.java:23 CLAIMED,
|
|
AdminOperationClaim.java:24
|
|
AdminOperationClaim.java:25 /** The operation already completed; {@code existing} is what it produced. */
|
|
AdminOperationClaim.java:26 REPLAY,
|
|
AdminOperationClaim.java:27
|
|
AdminOperationClaim.java:28 /** Another caller holds it right now. Nothing has been decided yet. */
|
|
AdminOperationClaim.java:29 IN_PROGRESS,
|
|
AdminOperationClaim.java:30
|
|
AdminOperationClaim.java:31 /** The same id was presented with a different command. */
|
|
AdminOperationClaim.java:32 CONFLICT
|
|
AdminOperationClaim.java:33 }
|
|
AdminOperationClaim.java:34
|
|
AdminOperationClaim.java:35 /** Validates the claim. */
|
|
|
|
# 그 연산이 생긴 이유 — 마이그레이션 헤더
|
|
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
|
|
|
|
# JPA 구현이 무엇을 보내고 무엇으로 판단하는가
|
|
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);
|
|
|
|
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,
|
|
|
|
# 서비스가 operations 포트에 거는 호출 전부
|
|
NotificationAdminApplicationService.java:94 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
|
NotificationAdminApplicationService.java:145 return operations.save(result, actor, "ADMIN_REDRIVE");
|
|
NotificationAdminApplicationService.java:154 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
|
NotificationAdminApplicationService.java:194 return operations.save(result, actor, "ADMIN_RECONCILE");
|
|
NotificationAdminApplicationService.java:203 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
|
NotificationAdminApplicationService.java:272 return operations.save(
|
|
NotificationAdminApplicationService.java:282 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
|
NotificationAdminApplicationService.java:328 return operations.save(result, actor, "ADMIN_PROVIDER_STATE");
|
|
operations.claim : 0 줄
|
|
[자기시험] operations.claimZZZ : 0 줄 (0 이어야 정상)
|
|
[대조] operations.save : 4 줄 (0 이면 검색이 깨진 것)
|
|
commandFingerprint 를 계산하는 자리 :
|
|
adapter/outbound/persistence-jpa · main · AdminAuditJpaRepository.java:36 + " command_fingerprint, phase, claimed_at)"
|
|
adapter/outbound/persistence-jpa · main · AdminAuditJpaRepository.java:52 "SELECT phase, command_fingerprint FROM notification_admin_audit"
|
|
adapter/outbound/persistence-jpa · main · JpaAdminOperationStore.java:33 String operationId, String commandFingerprint, AdminActor actor, String action) {
|
|
adapter/outbound/persistence-jpa · main · JpaAdminOperationStore.java:35 Objects.requireNonNull(commandFingerprint, "commandFingerprint");
|
|
adapter/outbound/persistence-jpa · main · JpaAdminOperationStore.java:45 commandFingerprint,
|
|
adapter/outbound/persistence-jpa · main · JpaAdminOperationStore.java:60 if (fingerprint != null && !fingerprint.equals(commandFingerprint)) {
|
|
adapter/outbound/persistence-jpa · postgresqlIntegrationTest · AdminOperationClaimContractTest.java:123 + " command_fingerprint, phase, claimed_at)"
|
|
adapter/outbound/persistence-jpa · postgresqlIntegrationTest · AdminOperationClaimContractTest.java:141 return column(operationId, "command_fingerprint");
|
|
application-core · main · AdminOperationStorePort.java:26 * @param commandFingerprint a digest of the command this id was presented with
|
|
application-core · main · AdminOperationStorePort.java:32 String operationId, String commandFingerprint, AdminActor actor, String action);
|
|
|
|
# 네 경로가 조회와 저장 사이에 부르는 협력자
|
|
(서비스가 선언한 협력자 필드에서 유도한 패턴 : authorization|duplicateRiskGuard|attempts|recipients|reconciliation|suppressions|runtimes|operations|audit|transactions|clock)
|
|
redrive (:90~:147)
|
|
:92 authorization.require(actor, NotificationAdminAuthority.REDRIVE);
|
|
:94 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
|
:103 authorization.requireTenant(actor, original.tenantId());
|
|
:104 duplicateRiskGuard.verify(original, command.approveDuplicateRisk());
|
|
:117 return transactions.inWrite(
|
|
:121 recipients.transition(
|
|
:124 Optional.of(clock.instant()));
|
|
:135 audit.record(
|
|
:141 clock.instant(),
|
|
:145 return operations.save(result, actor, "ADMIN_REDRIVE");
|
|
reconcile (:150~:195)
|
|
:152 authorization.require(actor, NotificationAdminAuthority.RECONCILE);
|
|
:154 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
|
:172 reconciliation.reconcile(attemptId);
|
|
:186 audit.record(
|
|
:192 clock.instant(),
|
|
:194 return operations.save(result, actor, "ADMIN_RECONCILE");
|
|
suppress (:198~:275)
|
|
:200 authorization.require(actor, NotificationAdminAuthority.SUPPRESS);
|
|
:201 authorization.requireTenant(actor, command.tenantId());
|
|
:203 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
|
:218 return transactions.inWrite(
|
|
:227 command.tenantId(), command.targetFingerprint(), clock.instant())
|
|
:229 .map(entry -> suppressions.remove(command.tenantId(), entry.id()))
|
|
:236 suppressions.upsert(
|
|
:249 clock.instant(),
|
|
:264 audit.record(
|
|
:270 clock.instant(),
|
|
:272 return operations.save(
|
|
setProviderState (:278~:329)
|
|
:280 authorization.require(actor, NotificationAdminAuthority.PROVIDER_CONTROL);
|
|
:282 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
|
:298 runtimes.setState(command.profileId(), command.desiredState(), command.reason());
|
|
:318 audit.record(
|
|
:324 clock.instant(),
|
|
:328 return operations.save(result, actor, "ADMIN_PROVIDER_STATE");
|
|
|
|
# 그 경합을 고정한 시험이 어느 레인에서 도는가
|
|
파일 : src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/AdminOperationClaimContractTest.java
|
|
AdminOperationClaimContractTest.java:34 @Tag("jpa-contract")
|
|
persistence-jpa/build.gradle:251 'jpaPlatformContractTest',
|
|
persistence-jpa/build.gradle:252 'jpa-contract',
|
|
그 태스크를 부르는 워크플로 :
|
|
jpa-nightly.yml:49 :adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
|
jpa-pr.yml:86 :adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
|
notification-platform.yml:85 run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest --console=plain
|
|
notification-platform.yml:139 run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest :adapter:outbound:persistence-jpa:jpaPlatformFailureTest --console=plain
|
|
그 워크플로가 도는 조건 :
|
|
notification-platform.yml:14 on:
|
|
notification-platform.yml:15 pull_request:
|
|
notification-platform.yml:16 paths:
|
|
notification-platform.yml:21 - 'src/application-core/src/**/notification/**'
|
|
notification-platform.yml:22 - 'src/adapter/outbound/notification/**'
|
|
notification-platform.yml:23 - 'src/adapter/outbound/persistence-jpa/src/**/notification/**'
|
|
notification-platform.yml:24 - 'src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/notification-platform/**'
|
|
NotificationAdminApplicationService 를 이름에 가진 시험 파일 : 0 개
|