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