Files
llm-wiki/raw/errors/idempotency-expired-row-reclaim-409-loop-2026-06-09.md
T

46 lines
1.9 KiB
Markdown

---
title: error / idempotency-expired-row-reclaim-409-loop-2026-06-09
source_type: error-note
status: raw
related_branches: [feature-rate-limit-idempotency-contract]
related_projects: [ca-skeleton]
tags: [error, ca-skeleton, idempotency, concurrency, ttl]
created: 2026-06-09
status_label: resolved
---
# error: idempotency-expired-row-reclaim-409-loop-2026-06-09
> Layer: `raw/errors/` — TDD 중 발견한 만료 row 재선점 누락 버그.
## Parent / 부모
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
## 증상 / Symptom
`IdempotencyExecutor``expired_record_is_treated_as_absent_and_reclaimed` 테스트가
`IdempotencyInFlightException`(409)으로 실패. 만료된 idempotency record가 있을 때 새 요청이
재처리되지 못하고 in-flight 409로 오판됨.
## 원인 / Root cause
`IdempotencyStore.find(scope, now)`는 만료 row를 `Optional.empty()`로 반환하지만, 저장소의
`tryBegin`(insert)이 **여전히 존재하는** 만료 row와 unique 제약에서 충돌 → `false` 반환.
executor는 "타 호출자가 선점했다"고 판단해 200ms wait 후 409. 즉 `find`의 만료 필터와
`tryBegin`의 물리 row 존재가 불일치.
## 해결 / Resolution
만료 row는 **재선점 가능**해야 한다는 계약을 명문화:
- 포트 `IdempotencyStore.tryBegin` javadoc에 "expired record는 reclaim 대상" 명시.
- 실제 어댑터(`IdempotencyStoreAdapter.tryBegin`)는 lookup 후 `expiresAt <= now``delete`
후 insert (caller 트랜잭션 내 atomic, unique 제약 + `DataIntegrityViolationException`로 race 중재).
- 테스트 fake(`FakeStore.find`)는 read 시 만료 row를 lazy purge.
## 교훈 / Lesson
만료(soft delete/TTL) 시맨틱은 **읽기 필터와 쓰기 선점이 같은 기준**을 공유해야 한다.
read에서만 만료를 숨기고 write 경로가 물리 row를 그대로 보면 "유령 충돌"이 발생한다.
관련: [[raw/branch-notes/feature-rate-limit-idempotency-contract]]