# 포트가 선언한 세 연산 (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 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 *

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 *

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 *

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 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 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 replayed = operations.findByOperationId(command.operationId()); NotificationAdminApplicationService.java:145 return operations.save(result, actor, "ADMIN_REDRIVE"); NotificationAdminApplicationService.java:154 Optional replayed = operations.findByOperationId(command.operationId()); NotificationAdminApplicationService.java:194 return operations.save(result, actor, "ADMIN_RECONCILE"); NotificationAdminApplicationService.java:203 Optional replayed = operations.findByOperationId(command.operationId()); NotificationAdminApplicationService.java:272 return operations.save( NotificationAdminApplicationService.java:282 Optional 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 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 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 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 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 개