# 마이그레이션이 만든 것 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 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 *

{@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)