Files
document-haness/.run/redis/redis-execution-failure-certainty.md
T

27 KiB

Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델

Redis 코드 상세 시리즈 12/20 · 전체 지도 · 이전: Batch·Transaction·Script·Function·Pub/Sub·Admin·Raw를 분리한 이유 · 다음: Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL

이 글이 답하는 코드 질문

Redis write가 timeout 또는 connection loss로 실패했을 때 “실행되지 않았다”고 말할 수 있습니까? sync, reactive, transaction queue는 같은 admission과 failure metadata를 어떻게 사용합니까?

현행 translator의 핵심 규칙은 다음과 같습니다.

  • server가 거절했다는 reply가 있으면 confirmed failure로 다룹니다.
  • read timeout/connection failure는 policy가 retry-safe인 경우 retryable metadata를 가질 수 있습니다.
  • 실행됐을 수 있는 write timeout/connection loss는 RedisAmbiguousExecutionException입니다.
  • ambiguous failure는 retryable=false입니다.

executor 자체에는 자동 retry loop가 없습니다. metadata와 ExecutionCertainty는 caller가 retry·reconciliation·compensation을 결정할 근거이지, 현재 production pipeline이 자동 재전송한다는 증거가 아닙니다.

먼저 보는 클래스 지도

클래스 입력 출력 다음 호출
CommandRequest command/key/size/permit/budget/deferred invocation 실행 전 요청 guard
CommandAdmission descriptor/lane/slot/timeout 실행 결정 executor
SyncRedisCommandExecutor request blocking result 또는 typed failure translator·observation
ReactiveRedisCommandExecutor request Mono<R> translator·observation
QueueingRedisCommandExecutor transaction command/stage unresolved stage, explicit await EXEC
LettuceExceptionTranslator Throwable와 execution context stable SDK exception caller
RedisFailureMetadata payload-free failure facts retry/ambiguity 판단 값 caller·telemetry
ExecutionCertainty descriptor와 certainty state 자동 retry 허용 여부 failover model
SentinelFailoverObserver promotion/reconnect/in-flight 분류 counters와 certainty operator/caller

Admission과 wire send의 경계

CommandRequest는 invocation을 Supplier<CompletionStage<R>>로 보관합니다. guard가 실패하면 supplier를 평가하지 않으므로 명령은 전송되지 않습니다.

flowchart TD
    A[CommandRequest] --> B[guard.validate]
    B -->|거절| C[not-sent typed exception]
    B -->|admit| D[invocation.get]
    D --> E{reply/driver outcome}
    E -->|success| F[result + success observation]
    E -->|server error| G[confirmed typed failure]
    E -->|timeout/connection loss| H{read인가, ambiguous write인가}
    H -->|retry-safe read| I[retryable non-ambiguous failure]
    H -->|write may have applied| J[ambiguous non-retryable failure]

admission failure와 invocation 이후 failure는 evidence가 다릅니다. namespace·permit·budget·capability 거절은 not sent입니다. invocation을 시작한 뒤 reply를 못 받은 write는 server에 도달하지 않았다고 증명할 수 없습니다.

Effective timeout은 어디서 옵니까

기본 timeout은 TimeoutProfile에 있습니다.

profile default
FAST 500ms
COLLECTION 2s
SCRIPT 1s
BATCH 2s
ADMIN 3s
BLOCKING 2s default, 실제 block에는 margin 적용

R2 request가 OperationBudget을 가지면 non-blocking path에서는 budget의 timeout이 effective timeout입니다. server block을 선언하지 않은 optional-blocking path도 같은 분기입니다. OperationBudget은 element, request bytes, reply bytes, timeout을 모두 양수로 요구합니다.

blocking command가 bounded server block을 선언하면 budget timeout은 사용하지 않습니다. 0·음수·configured maximum 초과를 거절한 뒤 serverBlock + BLOCKING_MARGIN(2s)를 client-side timeout으로 씁니다. optional-block command에 block이 없을 때만 budget 또는 profile default를 사용합니다. 이 분기는 CommandPolicyGuard.effectiveTimeout에 그대로 드러납니다.

Sync executor의 호출 순서

SyncRedisCommandExecutor.execute는 다음 순서로 동작합니다.

  1. guard가 CommandAdmission을 만듭니다.
  2. descriptor, lane, topology, slot으로 observation을 시작합니다.
  3. invocation.get()으로 driver call을 시작합니다.
  4. returned stage를 effective timeout까지 기다립니다.
  5. success면 observation을 기록하고 결과를 반환합니다.
  6. runtime failure면 elapsed를 넣은 context로 translate합니다.
  7. translated metadata의 ambiguity를 failure observation에 기록한 뒤 throw합니다.

CompletableFuture.get timeout은 Lettuce RedisCommandTimeoutException으로 감싸 translator에 보냅니다. Java InterruptedExceptioninterrupt flag를 복원한 뒤 CompletionException으로 감쌉니다. 두 failure는 translator에서 같은 branch를 타지 않습니다.

observation sink는 NoThrowObservationSink으로 감쌉니다. success 기록의 경계는 meter failure를 Redis write failure로 오인하지 않게 driver try/catch 밖에서 success observation을 기록합니다.

Reactive executor의 호출 순서

ReactiveRedisCommandExecutor.executeMono.defer 안에서 admission을 실행합니다.

이 위치 때문에 다음이 성립합니다.

  • publisher assembly 때는 Redis 호출과 guard validation이 시작되지 않습니다.
  • subscribe 때 namespace/permit/budget failure가 error signal로 발생합니다.
  • caller는 onErrorResume 같은 reactive recovery를 사용할 수 있습니다.
  • 같은 publisher를 여러 번 subscribe하면 deferred request가 다시 실행될 수 있습니다.

admission 후 Mono.fromCompletionStage.timeout(admission.timeout())을 적용합니다. error는 translator를 거쳐 stable SDK exception이 되고 observation에 ambiguity가 기록됩니다.

sync와 reactive는 같은 guard와 descriptor semantics를 사용하지만 timeout 구현 자체는 Future.get과 Reactor operator로 다릅니다.

Queueing executor는 왜 기다리지 않습니까

transaction의 queued command는 MULTI 안에서 +QUEUED만 받습니다. 실제 reply는 EXEC가 실행될 때까지 존재하지 않습니다. 여기서 일반 sync executor처럼 wait하면 transaction이 자기 reply를 만들 EXEC에 도달하지 못해 deadlock합니다.

QueueingRedisCommandExecutor.queue는 admission과 invocation 시작까지만 하고 stage를 반환합니다.

success observation도 queue 시점이 아니라 stage completion에 붙입니다. watch conflict로 EXEC가 실행하지 않은 command를 성공으로 세지 않기 위해서입니다.

transaction 자체가 소유한 WATCH, MULTI, EXEC, cleanup stage는 await로 기다립니다. 특히 EXEC reply timeout은 transaction 전체가 실행됐을 수도 있으므로 write context로 번역되어 ambiguous입니다. Java interrupt도 flag를 복원한 뒤 EXEC write context로 translator에 보내므로 unclassified ambiguous failure가 됩니다.

Translator의 분류 순서

translateCompletionExceptionExecutionException을 먼저 벗깁니다. 이미 RedisOperationException이면 그대로 반환합니다.

그다음 구체적인 driver type을 분류합니다.

입력 SDK failure retry/ambiguity
RedisCommandTimeoutException 또는 Java TimeoutException read: RedisTimeoutException; ambiguous write: RedisAmbiguousExecutionException read policy에 따라 retryable; write ambiguous
Lettuce RedisCommandInterruptedException timeout과 같은 hierarchy read policy에 따라 retryable; write ambiguous
executor의 Java InterruptedException unclassified read: generic RedisOperationException; ambiguous write: RedisAmbiguousExecutionException retry-safe read만 retryable; write ambiguous
connection failure read: RedisConnectionException; ambiguous write: RedisAmbiguousExecutionException 같은 규칙
loading/busy RedisBusyException read 여부 또는 false
Lettuce NOSCRIPT RedisNoScriptException false/false
read-only replica/partition RedisRedirectionException false/false
server execution error leading error code로 세분화 server reply가 있으므로 non-ambiguous
unclassified failure retry-safe read: generic retryable failure; ambiguous write: ambiguous failure descriptor에서 결정

unclassified write가 plain non-applied failure로 떨어지지 않는 것이 중요합니다. unclassified fallback은 server reply가 없고 write가 ambiguous할 수 있으면 안전한 기본값으로 ambiguity를 선택합니다.

이 구분은 class 이름이 비슷해서 놓치기 쉽습니다. translator가 timeout으로 직접 분류하는 interrupted type은 Lettuce의 RedisCommandInterruptedException뿐입니다. Future.get이 던지는 java.lang.InterruptedException은 그 type이 아니므로 unwrap 뒤 unclassified fallback으로 갑니다.

Server error code와 정보 노출 제한

RedisCommandExecutionException은 message의 첫 uppercase error code만 읽습니다.

  • WRONGTYPERedisDataTypeMismatchException
  • CROSSSLOTRedisCrossSlotException
  • NOPERM, NOAUTH, WRONGPASS, NOUSER, UNAUTHORIZEDRedisAccessDeniedException
  • MOVED, ASK, TRYAGAIN, CLUSTERDOWN, MASTERDOWN, REDIRECTRedisRedirectionException
  • BUSY, LOADING, BUSYGROUP, BUSYKEYRedisBusyException
  • NOSCRIPTRedisNoScriptException
  • OOM, MISCONF, NOREPLICAS, EXECABORT, READONLYRedisCommandRejectedException

serverError는 raw server message를 SDK message에 복사하지 않습니다. Redis error에 들어갈 수 있는 key와 argument fragment가 exception/telemetry로 노출되지 않게 합니다.

RedisFailureMetadata가 보존하는 것

RedisFailureMetadata는 다음 field만 가집니다.

  • low-cardinality commandCategory
  • CommandAccess
  • read 여부
  • retryable 여부
  • ambiguous execution 여부
  • optional server version
  • deployment mode
  • optional Cluster slot
  • elapsed duration

key, value, credential, raw server message는 없습니다. constructor는 retryable과 ambiguous가 동시에 true인 상태를 금지하며 slot을 0..16383으로 제한합니다.

notSent factory는 read rejection만 retryable로 표시하고 ambiguity는 false로 둡니다. stored data corruption은 read여도 retryable이 아니므로 별도 storedDataCorruption factory를 사용합니다.

실행 확실성 네 상태

ExecutionCertainty는 상태를 네 개로 이름 붙입니다.

stateDiagram-v2
    [*] --> CONFIRMED_SUCCESS: server success reply
    [*] --> CONFIRMED_FAILURE: server refusal reply
    [*] --> SAFE_TO_RETRY_FAILURE: server 미도달 증명
    [*] --> AMBIGUOUS_FAILURE: 도달/적용 여부 불명

allowsAutomaticRetry는 confirmed outcome에는 false, safe-to-retry failure에는 true를 반환합니다. ambiguous failure는 descriptor가 retry-safe일 때만 true입니다.

그러나 exception metadata의 invariant는 ambiguous와 retryable을 동시에 허용하지 않습니다. 따라서 ExecutionCertainty.AMBIGUOUS_FAILURE가 retry-safe read에 대해 자동 retry를 허용하는 모델과 translator가 생성하는 metadata는 서로 다른 표현 계층입니다. 현재 executor가 ExecutionCertainty를 사용해 retry하는 코드는 없습니다.

Sentinel reconnect queue와 in-flight 분류

SentinelFailoverObserver는 promotion 시 다음을 기록하도록 설계됐습니다.

  • promotion count
  • ambiguous non-idempotent write count
  • reconnect queue가 차서 거절한 count
  • longest reconnect duration

offerWhileReconnecting은 atomic counter가 configured maximum을 넘으면 즉시 false를 반환하고 refusal을 셉니다. unbounded backlog를 만들지 않습니다.

classify(descriptor, reachedServer)는 server에 도달하지 않았으면 SAFE_TO_RETRY_FAILURE, 도달했으면 AMBIGUOUS_FAILURE를 반환합니다. 후자의 descriptor가 retry-safe가 아니면 ambiguous write counter를 올립니다.

이 observer의 class comment에 있는 2,086과 1 수치는 historical Sentinel 실험 설명입니다. client가 성공 reply를 받은 뒤 old primary의 write가 유실되는 경우는 observer가 볼 수 없으며, server-side min-replicas-to-write와 bounded min-replicas-max-lag가 필요하다고 설명합니다. 이 수치를 현행 runtime test 결과로 표현하면 안 됩니다.

WAIT로 이 공백을 해결한다고 읽어도 안 됩니다. 현행 command policy에 WAIT가 없어 default-deny이며 typed/semantic surface도 없습니다.

테스트가 고정하는 계약

translator 테스트는 failure별 시작 행을 따로 가집니다.

Sentinel observer는 server 미도달, non-idempotent in-flight write, idempotent read, confirmed outcome, bounded queue, longest reconnect를 각각 단위 테스트합니다.

Transaction 쪽은 queued command의 commit 전 미적용, QueuedReply 조기 접근 금지, watch conflict에서 미실행을 별도 테스트가 고정합니다.

이 테스트는 이번 문서 작업에서 실행하지 않았습니다. real-server standalone/Sentinel/Cluster/TLS lane도 실행하지 않았습니다.

현재 구현 공백과 잘못 읽기 쉬운 지점

  1. sync/reactive/queueing executor, translator, guard의 production bean 조립은 확인되지 않습니다.
  2. aggregate facade와 application bridge가 미조립이므로 이 failure model이 모든 production Redis call에 적용된다고 단정할 수 없습니다.
  3. executor에는 automatic retry loop가 없습니다. retryable은 재전송이 일어났다는 뜻이 아닙니다.
  4. ExecutionCertaintySentinelFailoverObserver는 production source에서 서로 외의 사용처나 runtime wiring을 찾지 못했습니다.
  5. CommandExecutionContext.of는 server version을 Optional.empty()로 만들며 executors는 withServerVersion을 호출하지 않습니다. translator가 만든 failure metadata의 server version은 현재 비어 있습니다. guard rejection metadata에는 probed version이 들어가는 것과 다릅니다.
  6. QueueingRedisCommandExecutor.queue의 asynchronously failed stage는 translator로 observation을 만들지만 returned stage 자체를 translated failure로 교체하지 않습니다. transaction caller가 받는 exception shape는 별도 검증이 필요합니다.
  7. Java InterruptedException은 Lettuce RedisCommandInterruptedException과 달리 timeout hierarchy로 번역되지 않습니다. sync read는 generic unclassified failure가 될 수 있고, write와 transaction EXEC는 ambiguous가 됩니다. 이 차이를 직접 고정하는 executor contract test는 확인되지 않았습니다.
  8. Sentinel observer의 reconnect queue counter는 실제 driver queue를 소유하는 자료구조가 아니라 admission 판단과 metric 모델입니다. production 연결도 확인되지 않았습니다.
  9. success reply 뒤 promotion으로 유실된 write는 client ambiguity model이 탐지할 수 없습니다.
  10. WAIT는 현재 default-deny입니다.

다음에 source를 열 때는 guard와 admission, 세 executor, execution context, translator, metadata, certainty enum, Sentinel observer, tests 순으로 보면 됩니다.

시리즈의 관련 문서

관련 범위는 command admission, connection lifecycle, typed operations, advanced surfaces입니다.

시리즈에서 이어 읽기