25 KiB
Redis Idempotency V2 상태 머신: Claim에서 Replay까지
Redis 코드 상세 시리즈 16/20 · 전체 지도 · 이전: Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기 · 다음: Redis Session 요청은 어디에서 멈추는가: Web 설정과 미완성 Repository
이 글이 답하는 코드 질문
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 |
digested scope, fingerprint, attempt, owner, TTL | claim/mutation/inspection outcome | provider adapter |
IdempotencyExecutorV2.execute |
scope digest, fingerprint, retained attempt, action, codec | action result 또는 replay | claim→start→action→complete |
RedisIdempotencyStoreAdapter |
V2 store calls | provider-neutral typed outcome | SCRIPT lane과 Lua |
IdempotencyScripts |
hash key와 transition args | 6-field reply | claim/transition/release/inspect |
IdempotencyScopeDigest |
lowercase SHA-256, digest version, operation code | opaque scope | physical Redis key |
IdempotencyKeySupport |
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, idempotencyExecutorV2
기본값은 command timeout 200ms, processing lease 30초, replay TTL 24시간, failure retention 24시간, codec json-v2, policy revision 2입니다. RedisCapabilitySettings.Idempotency
IdempotencyProviderSelectionConfig는 provider가 REDIS일 때 V1 store/executor 0개, owner-safe V2 store/executor 각각 1개인지 startup에 검사합니다. 과거에는 같은 이름의 다른 V2 contract를 세어 Redis selection이 불완전하다고 실패하던 문제가 있었고, 현행은 실제 provider가 구현한 application.idempotency.v2 contract를 셉니다. idempotencyProviderExclusivity
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
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하지 않습니다.
전체 실행 순서
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는 먼저 HGET state를 읽습니다.
- record가 없으면
CLAIMED, owner, attempt 1, rev 1, operation, fingerprint, codec, policy, leaseUntil을HSET하고 replay TTL로PEXPIRE합니다.ACQUIRED입니다. - fingerprint가 다르면 어떤 mutation보다 먼저
FINGERPRINT_MISMATCH를 반환합니다. COMPLETED이면 response와 PTTL을 담은COMPLETED_REPLAY입니다.ABANDONED면RECOVERY_REQUIRED입니다.- owner와 operation이 모두 같으면 현재 state가
CLAIMED인지EXECUTING인지 구분하지 않고 lost claim reply의 재호출로 보고REPLAYED_ACQUIRE입니다. - owner만 같고 operation이 다르면
OWNER_OPERATION_CONFLICT입니다. FAILED_RETRYABLE이거나 leaseUntil이 지났으면 owner를 교체하고 attempt/rev를 1씩 올려TAKEN_OVER를 반환합니다.- 그 밖에는
IN_PROGRESS와 남은 시간을 반환합니다.
adapter는 newClaimAttempt에서 owner token을 send 전에 만듭니다. claim exception은 전부 Indeterminate(operationId)입니다. clean unavailable이라고 하면 caller가 새 attempt로 action을 중복 실행할 수 있기 때문입니다. claim
owner와 state revision이 함께 필요한 이유
generic TRANSITION은 다음 순서로 비교합니다.
- record 존재
- owner token 일치
- operation ID 일치
- 이미 target state이면
ALREADY - state revision 일치
- expected source state 일치
HSET state,rev+1, optional response/leaseUntil과 optionalPEXPIRE
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
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
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
여기에는 동일 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 분기, TRANSITION의 target 선검사
둘째, claim이나 start reply가 불확실한 뒤 inspect가 EXECUTING_SAME_OPERATION을 반환하면 executor는 곧바로 runStarted를 호출합니다. 이 관찰만으로는 앞선 action이 아직 실행 중인지, 실행 직전이었는지, 이미 effect를 냈는지 구분할 수 없습니다. 현행 코드는 이 상태를 재실행 권한으로 해석합니다. 따라서 owner·operation이 같다는 사실은 다른 owner를 막는 근거이지만, 같은 attempt의 두 Java invocation 사이에서 action을 한 번만 실행했다는 근거는 아닙니다. reconcile
success, retryable, unknown effect
action의 정상 반환은 세 종류입니다.
Success: response를 serialize하고EXECUTING -> COMPLETEDtransition을 보냅니다.RetryableNoEffect:FAILED_RETRYABLE로 기록해 다음 claim의 takeover를 허용합니다.EffectUnknown:ABANDONED로 남겨 자동 retry를 막습니다.
action이 분류 없이 RuntimeException을 던져도 executor는 unknown effect로 취급해 ABANDONED를 시도한 뒤 원래 exception을 다시 던집니다. runStarted
completion reply가 indeterminate면 inspect합니다. stored response가 방금 serialize한 payload와 같으면 성공으로 확정합니다. record가 여전히 같은 operation의 EXECUTING이면 현재 store가 준 owner로 complete를 한 번 더 시도합니다. stored response가 다르면 어느 결과도 반환하지 않고 recovery required입니다. reconcileCompletion
releaseBeforeExecution은 CLAIMED 상태에서 owner/revision/operation이 맞을 때만 DEL합니다. EXECUTING 이후 release는 거절합니다. 이미 effect가 시작된 record를 지우면 duplicate 방지 증거도 사라지기 때문입니다. RELEASE
NOSCRIPT와 failure certainty
claim/transition/release/inspect는 script별 digest를 cache하고 EVALSHA를 사용합니다. NOSCRIPT일 때만 reload 후 한 번 재시도합니다. IdempotencyScripts.run
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
또한 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는 lowercase digest와 positive version, 분리된 processing/replay TTL, owner의 attempt/revision tuple을 검사합니다.IdempotencyExecutorV2Test는 confirmed start 이후 action 실행, advanced owner 전달, lost claim/start/completion reconciliation, conflicting replay, retryable와 unknown effect 분리를 fake store로 고정합니다.- 같은 테스트의
anIndeterminateClaimIsReconciled는 inspection이EXECUTING_SAME_OPERATION이면 start를 다시 호출하지 않지만 action은 실제로 실행한다고 assert합니다. 이 상태가 in-flight인지 resume 가능한 상태인지 구분하지 않습니다. - 같은 retained attempt로
execute를 두 번 호출해 action 중복 여부를 검사하는 concurrency/retry test는 없습니다. RedisIdempotencyStoreAdapterTest는 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을 던져 executor가 renew를 호출하지 않는다는 사실만 고정합니다. LiveRedisSemanticPortsTest.theIdempotencyStoreClaimsOnceUnderTheAdvancedAccount는 standalone/cluster lane에서 첫 claim과 두 번째 in-progress를 검사하도록 태그되어 있습니다. 전체 executor lifecycle real-server 검증은 아닙니다.RedisCapabilityCompositionTest.idempotencyProviderComposesTheStore는 V2 store bean을 검사합니다. selector guard는 executor까지 요구하지만 이 test method 자체는 executor를 assert하지 않습니다.
현재 한계와 다음 source 순서
- 같은 retained attempt가 이미 EXECUTING인 record를 다시 만나면 executor가 action을 다시 호출할 수 있습니다.
EXECUTING_SAME_OPERATION은 in-flight evidence와 resume 권한을 구분하지 못합니다. - Redis V2
renew는EXECUTING -> EXECUTINGtarget 선검사에서ALREADY로 끝나leaseUntil, TTL, revision을 바꾸지 않는 no-op입니다. adapter renew test도 없습니다. - HTTP V1 helper에서 V2 scope digest/attempt/executor로 가는 production bridge가 확인되지 않습니다.
- Redis state와 외부 side effect 사이의 exactly-once transaction은 없습니다.
- executor는 processing lease renew를 호출하지 않습니다.
markFailed결과가 indeterminate여도preserveUnknown은 결과를 확인하지 않고 원래 exception을 던집니다. recovery evidence가 실제로 기록됐는지는 별도 reconciliation이 필요할 수 있습니다.- response는 opaque string payload이며 codec migration compatibility를 store가 검증하지 않습니다. hash에는 codec/policy가 기록되지만 claim/replay script가 현재 배포 값과 비교하지 않습니다.
- 이번 작성에서는 real-server lane을 재실행하지 않았습니다.
executor execute → claim Lua → generic transition Lua → store mapping → executor test → adapter test 순으로 읽으면 상태와 certainty를 함께 추적할 수 있습니다.