1.9 KiB
1.9 KiB
title, source_type, status, related_branches, related_projects, tags, created, status_label
| title | source_type | status | related_branches | related_projects | tags | created | status_label | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| error / idempotency-expired-row-reclaim-409-loop-2026-06-09 | error-note | raw |
|
|
|
2026-06-09 | resolved |
error: idempotency-expired-row-reclaim-409-loop-2026-06-09
Layer:
raw/errors/— TDD 중 발견한 만료 row 재선점 누락 버그.
Parent / 부모
증상 / 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.tryBeginjavadoc에 "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