refactor: 문서 개선 중
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
# Redis Idempotency V2 상태 머신: Claim에서 Replay까지
|
||||
|
||||
> **Redis 코드 상세 시리즈 16/20** · [전체 지도](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md) · 이전: [Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-lease-code-walkthrough.md) · 다음: [Redis Session 요청은 어디에서 멈추는가: Web 설정과 미완성 Repository](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-session-composition-gap.md)
|
||||
|
||||
## 이 글이 답하는 코드 질문
|
||||
|
||||
Redis Idempotency V2는 같은 scope에서 action을 언제 실행하고, 어떤 owner/revision으로 stale writer를 막으며, lost reply를 어떻게 reconcile합니까? 이 lifecycle이 exactly-once를 보장합니까? HTTP의 `Idempotency-Key`가 현재 V2 executor까지 연결됩니까?
|
||||
|
||||
production composition은 Redis `IdempotencyStorePortV2`와 `IdempotencyExecutorV2`를 함께 만듭니다. 그러나 inbound web helper는 V1 `IdempotencyScope`와 V1 executor용 입력을 만들며 V2 `IdempotencyScopeDigest` bridge는 확인되지 않습니다. V2 backend가 조립됐다는 사실과 HTTP 요청이 그 backend를 호출한다는 사실은 다릅니다. backend 내부에도 같은 retained attempt가 이미 `EXECUTING`인 record를 다시 만나면 action을 다시 호출할 수 있는 경로가 있고, Redis V2 `renew`는 성공처럼 보이는 no-op입니다.
|
||||
|
||||
## 먼저 보는 클래스 지도
|
||||
|
||||
| 코드 | 입력 | 출력 | 다음 호출 |
|
||||
| --- | --- | --- | --- |
|
||||
| [`IdempotencyStorePortV2`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyStorePortV2.java:13) | digested scope, fingerprint, attempt, owner, TTL | claim/mutation/inspection outcome | provider adapter |
|
||||
| [`IdempotencyExecutorV2.execute`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java:94) | scope digest, fingerprint, retained attempt, action, codec | action result 또는 replay | claim→start→action→complete |
|
||||
| [`RedisIdempotencyStoreAdapter`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/RedisIdempotencyStoreAdapter.java:52) | V2 store calls | provider-neutral typed outcome | SCRIPT lane과 Lua |
|
||||
| [`IdempotencyScripts`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java:31) | hash key와 transition args | 6-field reply | claim/transition/release/inspect |
|
||||
| [`IdempotencyScopeDigest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyScopeDigest.java:12) | lowercase SHA-256, digest version, operation code | opaque scope | physical Redis key |
|
||||
| [`IdempotencyKeySupport`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKeySupport.java:24) | HTTP header/principal/body | V1 scope와 fingerprint | 현재 V1 경계 |
|
||||
|
||||
## production 조립과 selection guard
|
||||
|
||||
`ca-skeleton.capabilities.idempotency.provider=redis`일 때 `RedisCapabilityConfig`는 store와 executor bean을 각각 만듭니다. store에는 namespace, key version, scripts, clock, command timeout을 넣고 executor에는 processing lease, replay TTL, failure retention, response codec ID, policy revision을 넣습니다. [`redisIdempotencyStore`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:255), [`idempotencyExecutorV2`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:286)
|
||||
|
||||
기본값은 command timeout 200ms, processing lease 30초, replay TTL 24시간, failure retention 24시간, codec `json-v2`, policy revision 2입니다. [`RedisCapabilitySettings.Idempotency`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java:388)
|
||||
|
||||
`IdempotencyProviderSelectionConfig`는 provider가 REDIS일 때 V1 store/executor 0개, owner-safe V2 store/executor 각각 1개인지 startup에 검사합니다. 과거에는 같은 이름의 다른 V2 contract를 세어 Redis selection이 불완전하다고 실패하던 문제가 있었고, 현행은 실제 provider가 구현한 `application.idempotency.v2` contract를 셉니다. [`idempotencyProviderExclusivity`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderSelectionConfig.java:34)
|
||||
|
||||
## Redis record와 scope key
|
||||
|
||||
physical key에는 raw client key나 principal이 들어가지 않습니다. `IdempotencyKeys.recordKey`는 공통 namespace 아래 `idem`, key layout version, `d<scope.keyDigestVersion>`, operation code, 64자 digest를 렌더링합니다. [`recordKey`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/RedisIdempotencyStoreAdapter.java:523)
|
||||
|
||||
record는 Redis hash입니다. claim script가 만드는 주요 field는 다음과 같습니다.
|
||||
|
||||
| field | 의미 |
|
||||
| --- | --- |
|
||||
| `state` | `CLAIMED`, `EXECUTING`, `COMPLETED`, `FAILED_RETRYABLE`, `ABANDONED` |
|
||||
| `owner` | 32 random bytes를 lowercase hex로 바꾼 64자 token |
|
||||
| `attempt` | takeover마다 증가하는 claim attempt number |
|
||||
| `rev` | confirmed transition마다 증가하는 state revision |
|
||||
| `op` | caller의 `OperationId` |
|
||||
| `fp` | request fingerprint |
|
||||
| `codec` / `policy` | response codec ID와 policy revision |
|
||||
| `leaseUntil` | processing lease absolute epoch millis |
|
||||
| `resp` | completed response의 opaque payload |
|
||||
|
||||
hash를 쓰는 이유는 transition이 필요한 field만 owner/revision check와 함께 바꾸기 위해서입니다. serialized blob을 client에서 read-modify-write하지 않습니다.
|
||||
|
||||
## 전체 실행 순서
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Caller
|
||||
participant E as IdempotencyExecutorV2
|
||||
participant S as RedisIdempotencyStoreAdapter
|
||||
participant R as Redis Lua/hash
|
||||
participant A as Action
|
||||
C->>E: execute(scope, fingerprint, attempt, action, codec)
|
||||
E->>S: claim(request)
|
||||
S->>R: CLAIM EVALSHA
|
||||
alt completed
|
||||
R-->>S: COMPLETED_REPLAY + resp
|
||||
S-->>E: CompletedReplay
|
||||
E-->>C: codec.deserialize(resp)
|
||||
else acquired/taken over from CLAIMED
|
||||
S-->>E: owner(attempt, rev)
|
||||
E->>S: markExecutionStarted(owner)
|
||||
S->>R: CLAIMED -> EXECUTING CAS
|
||||
R-->>S: advanced owner revision
|
||||
E->>A: run()
|
||||
A-->>E: Success / RetryableNoEffect / EffectUnknown
|
||||
E->>S: complete 또는 markFailed
|
||||
S->>R: EXECUTING -> terminal CAS
|
||||
E-->>C: result 또는 typed exception
|
||||
else same attempt, record already EXECUTING
|
||||
S-->>E: ReplayedAcquire (state 구분 없음)
|
||||
E->>S: markExecutionStarted(owner)
|
||||
S->>R: current EXECUTING, target EXECUTING
|
||||
R-->>S: ALREADY (mutation 없음)
|
||||
E->>A: run() 다시 호출
|
||||
else claim response uncertain
|
||||
E->>S: inspect(same attempt)
|
||||
S->>R: INSPECT EVALSHA
|
||||
alt EXECUTING_SAME_OPERATION
|
||||
E->>A: run() 호출
|
||||
else other observation
|
||||
E->>E: resume/replay/recovery
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## claim Lua의 분기
|
||||
|
||||
[`CLAIM`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java:40)는 먼저 `HGET state`를 읽습니다.
|
||||
|
||||
1. record가 없으면 `CLAIMED`, owner, attempt 1, rev 1, operation, fingerprint, codec, policy, leaseUntil을 `HSET`하고 replay TTL로 `PEXPIRE`합니다. `ACQUIRED`입니다.
|
||||
2. fingerprint가 다르면 어떤 mutation보다 먼저 `FINGERPRINT_MISMATCH`를 반환합니다.
|
||||
3. `COMPLETED`이면 response와 PTTL을 담은 `COMPLETED_REPLAY`입니다.
|
||||
4. `ABANDONED`면 `RECOVERY_REQUIRED`입니다.
|
||||
5. owner와 operation이 모두 같으면 현재 state가 `CLAIMED`인지 `EXECUTING`인지 구분하지 않고 lost claim reply의 재호출로 보고 `REPLAYED_ACQUIRE`입니다.
|
||||
6. owner만 같고 operation이 다르면 `OWNER_OPERATION_CONFLICT`입니다.
|
||||
7. `FAILED_RETRYABLE`이거나 leaseUntil이 지났으면 owner를 교체하고 attempt/rev를 1씩 올려 `TAKEN_OVER`를 반환합니다.
|
||||
8. 그 밖에는 `IN_PROGRESS`와 남은 시간을 반환합니다.
|
||||
|
||||
adapter는 `newClaimAttempt`에서 owner token을 send 전에 만듭니다. claim exception은 전부 `Indeterminate(operationId)`입니다. clean unavailable이라고 하면 caller가 새 attempt로 action을 중복 실행할 수 있기 때문입니다. [`claim`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/RedisIdempotencyStoreAdapter.java:104)
|
||||
|
||||
## owner와 state revision이 함께 필요한 이유
|
||||
|
||||
generic [`TRANSITION`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java:90)은 다음 순서로 비교합니다.
|
||||
|
||||
- record 존재
|
||||
- owner token 일치
|
||||
- operation ID 일치
|
||||
- 이미 target state이면 `ALREADY`
|
||||
- state revision 일치
|
||||
- expected source state 일치
|
||||
- `HSET state`, `rev+1`, optional response/leaseUntil과 optional `PEXPIRE`
|
||||
|
||||
target state 확인이 revision보다 먼저인 점이 중요합니다. 첫 transition은 적용됐지만 reply가 사라진 caller는 이전 revision을 들고 같은 transition을 다시 보냅니다. owner·operation·target이 같다면 `ALREADY`로 복구합니다. 반대로 target이 다르고 revision이 오래됐으면 `NOT_OWNER`입니다. 이 순서는 start 같은 서로 다른 상태 전이의 lost reply를 복구하지만, source와 target이 같은 renew에는 다른 결과를 만듭니다.
|
||||
|
||||
confirmed start transition은 새 `IdempotencyOwner`를 돌려줍니다. executor는 claim에서 받은 owner를 계속 쓰지 않고 `started.owner()`의 advanced revision을 complete에 전달합니다. [`startAndRun`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java:159)
|
||||
|
||||
Redis store의 `renew`는 source와 target을 모두 `EXECUTING`으로 넘깁니다. record가 정상적인 EXECUTING 상태라면 Lua의 `state == target` 검사가 먼저 참이 되어 곧바로 `ALREADY`를 반환합니다. 뒤의 `leaseUntil`·TTL·revision mutation에는 도달하지 않습니다. adapter는 이를 `ALREADY_RENEWED_SAME_OPERATION`으로 매핑하고 현재 owner를 돌려주므로 호출자는 성공처럼 읽을 수 있지만 processing lease는 갱신되지 않습니다. [`RedisIdempotencyStoreAdapter.renew`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/RedisIdempotencyStoreAdapter.java:181)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[renew: EXECUTING → EXECUTING] --> B{state == target?}
|
||||
B -->|yes| C[ALREADY]
|
||||
C --> D[ALREADY_RENEWED_SAME_OPERATION]
|
||||
C -.->|도달하지 않음| E[leaseUntil/TTL/revision mutation]
|
||||
```
|
||||
|
||||
## executor가 action을 실행하는 조건
|
||||
|
||||
`execute`는 claim outcome을 다음처럼 처리합니다.
|
||||
|
||||
- `CompletedReplay`: action 없이 deserialize합니다.
|
||||
- `FingerprintMismatch`: `IdempotencyRequestMismatchException`입니다.
|
||||
- `InProgress`: `IdempotencyInFlightException`입니다.
|
||||
- `RecoveryRequired`/`OwnerOperationConflict`: recovery required입니다.
|
||||
- `Unavailable`: `IdempotencyUnavailableException`입니다.
|
||||
- `Indeterminate`: 같은 attempt로 inspect합니다.
|
||||
- `Acquired`/`ReplayedAcquire`/`TakenOverClaimed`: execution start를 먼저 confirm합니다.
|
||||
|
||||
action은 `markExecutionStarted`가 `STARTED` 또는 `ALREADY_STARTED_SAME_OPERATION`일 때만 실행됩니다. start가 indeterminate면 inspect로 `CLAIMED_SAME_OPERATION`, `EXECUTING_SAME_OPERATION`, `COMPLETED_REPLAY` 중 하나를 확인해 resume합니다. 두 번째에도 불확실하면 recovery required로 멈춥니다. [`resumeAfterIndeterminateStart`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java:183)
|
||||
|
||||
여기에는 동일 action을 다시 실행할 수 있는 두 경로가 있습니다. 첫째, record가 이미 EXECUTING인데 같은 retained attempt로 `execute`를 다시 호출하면 claim Lua가 state를 구분하지 않고 `REPLAYED_ACQUIRE`를 반환합니다. executor는 `startAndRun`으로 들어가고, `EXECUTING -> EXECUTING` start transition은 `ALREADY`가 됩니다. executor는 이를 confirmed start로 받아 `runStarted`에서 action을 다시 호출합니다. [`execute`의 replay 분기](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java:128), [`TRANSITION`의 target 선검사](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java:90)
|
||||
|
||||
둘째, claim이나 start reply가 불확실한 뒤 inspect가 `EXECUTING_SAME_OPERATION`을 반환하면 executor는 곧바로 `runStarted`를 호출합니다. 이 관찰만으로는 앞선 action이 아직 실행 중인지, 실행 직전이었는지, 이미 effect를 냈는지 구분할 수 없습니다. 현행 코드는 이 상태를 재실행 권한으로 해석합니다. 따라서 owner·operation이 같다는 사실은 다른 owner를 막는 근거이지만, 같은 attempt의 두 Java invocation 사이에서 action을 한 번만 실행했다는 근거는 아닙니다. [`reconcile`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java:137)
|
||||
|
||||
## success, retryable, unknown effect
|
||||
|
||||
action의 정상 반환은 세 종류입니다.
|
||||
|
||||
- `Success`: response를 serialize하고 `EXECUTING -> COMPLETED` transition을 보냅니다.
|
||||
- `RetryableNoEffect`: `FAILED_RETRYABLE`로 기록해 다음 claim의 takeover를 허용합니다.
|
||||
- `EffectUnknown`: `ABANDONED`로 남겨 자동 retry를 막습니다.
|
||||
|
||||
action이 분류 없이 `RuntimeException`을 던져도 executor는 unknown effect로 취급해 `ABANDONED`를 시도한 뒤 원래 exception을 다시 던집니다. [`runStarted`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java:198)
|
||||
|
||||
completion reply가 indeterminate면 inspect합니다. stored response가 방금 serialize한 payload와 같으면 성공으로 확정합니다. record가 여전히 같은 operation의 EXECUTING이면 현재 store가 준 owner로 complete를 한 번 더 시도합니다. stored response가 다르면 어느 결과도 반환하지 않고 recovery required입니다. [`reconcileCompletion`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java:254)
|
||||
|
||||
`releaseBeforeExecution`은 `CLAIMED` 상태에서 owner/revision/operation이 맞을 때만 `DEL`합니다. EXECUTING 이후 release는 거절합니다. 이미 effect가 시작된 record를 지우면 duplicate 방지 증거도 사라지기 때문입니다. [`RELEASE`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java:136)
|
||||
|
||||
## NOSCRIPT와 failure certainty
|
||||
|
||||
claim/transition/release/inspect는 script별 digest를 cache하고 `EVALSHA`를 사용합니다. `NOSCRIPT`일 때만 reload 후 한 번 재시도합니다. [`IdempotencyScripts.run`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java:207)
|
||||
|
||||
claim과 mutation exception은 `INDETERMINATE`로 보존합니다. inspect exception은 `UNAVAILABLE`입니다. read-only inspect가 unavailable이면 executor도 새 action 실행을 추측하지 않고 unavailable/recovery로 멈춥니다.
|
||||
|
||||
## exactly-once가 아닌 이유
|
||||
|
||||
exactly-once가 아닌 첫 이유는 같은 retained attempt의 재진입입니다. 앞서 본 `REPLAYED_ACQUIRE` 또는 `EXECUTING_SAME_OPERATION` 경로는 record가 이미 EXECUTING이어도 action을 다시 호출할 수 있습니다. 첫 action이 진행 중인 동안 같은 attempt로 두 번째 `execute`가 들어오는 경우를 state만으로 구분하지 못합니다.
|
||||
|
||||
두 번째 이유는 action의 외부 side effect와 Redis `COMPLETED` write가 하나의 transaction이 아니라는 점입니다. effect는 성공했지만 process가 죽어 completion을 기록하지 못하면 record는 EXECUTING lease expiry 뒤 takeover될 수 있습니다. action이 자신의 effect를 idempotent하게 만들거나 effect-point conditional write/outbox 등 별도 경계를 갖지 않으면 cross-store exactly-once도 성립하지 않습니다.
|
||||
|
||||
코드도 이 점을 명시합니다. action은 confirmed start 뒤 실행되지만 “cross-store exactly-once boundary”를 만들지 않습니다. [`IdempotencyExecutorV2` class contract](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java:30)
|
||||
|
||||
또한 executor는 `store.renew`를 호출하지 않습니다. long-running action의 processing lease를 자동 연장하지 않습니다. 이 미사용 공백과 별개로, 누군가 port의 Redis `renew`를 직접 호출해도 앞서 설명한 target-state short-circuit 때문에 현재 mutation은 no-op입니다.
|
||||
|
||||
## inbound V1 bridge 공백
|
||||
|
||||
web의 `IdempotencyKeySupport`는 header를 trim하고 principal + raw key + use-case name으로 V1 `IdempotencyScope`를 만들며 body fingerprint와 JSON codec을 제공합니다. `IdempotencyScopeDigest`를 만들지 않고 `IdempotencyExecutorV2`도 참조하지 않습니다. production controller에서 이 helper 사용처도 검색되지 않습니다.
|
||||
|
||||
그러므로 Redis V2 store/executor bean 조립과 HTTP idempotency 적용을 같은 것으로 설명할 수 없습니다. 필요한 bridge는 raw scope를 versioned HMAC digest로 바꾸고 `OperationId`와 retained V2 attempt를 생성해 executor에 전달해야 하지만, 현행 production source에서는 확인되지 않습니다.
|
||||
|
||||
## 테스트가 고정하는 계약
|
||||
|
||||
- [`IdempotencyV2ContractTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/test/java/dev/caskeleton/application/idempotency/v2/IdempotencyV2ContractTest.java:21)는 lowercase digest와 positive version, 분리된 processing/replay TTL, owner의 attempt/revision tuple을 검사합니다.
|
||||
- [`IdempotencyExecutorV2Test`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/test/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2Test.java:45)는 confirmed start 이후 action 실행, advanced owner 전달, lost claim/start/completion reconciliation, conflicting replay, retryable와 unknown effect 분리를 fake store로 고정합니다.
|
||||
- 같은 테스트의 [`anIndeterminateClaimIsReconciled`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/test/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2Test.java:95)는 inspection이 `EXECUTING_SAME_OPERATION`이면 start를 다시 호출하지 않지만 action은 실제로 실행한다고 assert합니다. 이 상태가 in-flight인지 resume 가능한 상태인지 구분하지 않습니다.
|
||||
- 같은 retained attempt로 `execute`를 두 번 호출해 action 중복 여부를 검사하는 concurrency/retry test는 없습니다.
|
||||
- [`RedisIdempotencyStoreAdapterTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/RedisIdempotencyStoreAdapterTest.java:87)는 exclusion, replay, fingerprint mismatch, full happy path, stale owner 거절, response conflict, retryable takeover, abandoned recovery, pre-execution release와 lost reply inspection을 in-memory gateway로 검사합니다.
|
||||
- Redis adapter test에는 renew case가 없습니다. executor test의 fake store renew는 [`UnsupportedOperationException`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/test/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2Test.java:300)을 던져 executor가 renew를 호출하지 않는다는 사실만 고정합니다.
|
||||
- [`LiveRedisSemanticPortsTest.theIdempotencyStoreClaimsOnceUnderTheAdvancedAccount`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java:254)는 standalone/cluster lane에서 첫 claim과 두 번째 in-progress를 검사하도록 태그되어 있습니다. 전체 executor lifecycle real-server 검증은 아닙니다.
|
||||
- [`RedisCapabilityCompositionTest.idempotencyProviderComposesTheStore`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisCapabilityCompositionTest.java:126)는 V2 store bean을 검사합니다. selector guard는 executor까지 요구하지만 이 test method 자체는 executor를 assert하지 않습니다.
|
||||
|
||||
## 현재 한계와 다음 source 순서
|
||||
|
||||
1. 같은 retained attempt가 이미 EXECUTING인 record를 다시 만나면 executor가 action을 다시 호출할 수 있습니다. `EXECUTING_SAME_OPERATION`은 in-flight evidence와 resume 권한을 구분하지 못합니다.
|
||||
2. Redis V2 `renew`는 `EXECUTING -> EXECUTING` target 선검사에서 `ALREADY`로 끝나 `leaseUntil`, TTL, revision을 바꾸지 않는 no-op입니다. adapter renew test도 없습니다.
|
||||
3. HTTP V1 helper에서 V2 scope digest/attempt/executor로 가는 production bridge가 확인되지 않습니다.
|
||||
4. Redis state와 외부 side effect 사이의 exactly-once transaction은 없습니다.
|
||||
5. executor는 processing lease renew를 호출하지 않습니다.
|
||||
6. `markFailed` 결과가 indeterminate여도 `preserveUnknown`은 결과를 확인하지 않고 원래 exception을 던집니다. recovery evidence가 실제로 기록됐는지는 별도 reconciliation이 필요할 수 있습니다.
|
||||
7. response는 opaque string payload이며 codec migration compatibility를 store가 검증하지 않습니다. hash에는 codec/policy가 기록되지만 claim/replay script가 현재 배포 값과 비교하지 않습니다.
|
||||
8. 이번 작성에서는 real-server lane을 재실행하지 않았습니다.
|
||||
|
||||
executor `execute` → claim Lua → generic transition Lua → store mapping → executor test → adapter test 순으로 읽으면 상태와 certainty를 함께 추적할 수 있습니다.
|
||||
|
||||
## 시리즈에서 이어 읽기
|
||||
|
||||
- 이전 글: [Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-lease-code-walkthrough.md)
|
||||
- 다음 글: [Redis Session 요청은 어디에서 멈추는가: Web 설정과 미완성 Repository](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-session-composition-gap.md)
|
||||
- 전체 흐름: [Redis를 범용 클라이언트가 아니라 정책 경계로 다루기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md)
|
||||
- 운영 흐름: [Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-platform-sre-operations.md)
|
||||
|
||||
Reference in New Issue
Block a user