Skip to content

[Budget] atomic reservation store와 idempotency 구현 #36

Description

@HuitaePark

문제와 현재 코드 근거

현재 BudgetStateStoregetAccumulatedCost()addCost()가 분리되어 있고, DefaultBudgetEvaluator는 조회한 값에 예상 비용을 더해 판단만 한다. ConcurrentHashMap.merge()는 덧셈 한 번만 원자적일 뿐 조회 → 검사 → 예약 전체를 보호하지 않는다.

따라서 여러 요청이 같은 잔여 예산을 동시에 보고 모두 허용되는 oversubscription이 발생할 수 있다. reservation ID, active reserved amount, idempotency key 모델도 아직 없다. 또한 현재 budget limit과 누적값은 통화 없는 BigDecimal이어서 서로 다른 통화를 같은 금액처럼 비교할 수 있다.

목표 계약

아래 이름은 예시이며 의미를 우선 고정한다.

checkAndReserve(BudgetKey, safeUpperBoundCost, idempotencyKey)
→ CREATED | REUSED | BLOCKED | CONFLICT | CURRENCY_MISMATCH
  • BudgetKey는 #27의 target identity와 budget window를 포함한다.
  • 예약 입력은 평균 예상 비용이 아니라 #45가 정의하는 safeUpperBoundCost다.
  • 판단 기준은 committedCost + activeReservedCost + pendingReconciliationLiability + safeUpperBoundCost다.
  • BLOCK이면 reservation, idempotency index와 bucket 합계가 모두 변경되지 않는다.
  • 같은 budget key/window/idempotency key 재호출은 terminal 상태 이후에도 기존 reservation ID와 현재 결과를 재사용하며 금액을 다시 더하지 않는다.
  • 같은 idempotency key에 다른 amount, currency, model, pricing policy/version 또는 window가 들어오면 CONFLICT다.
  • 한도 경계의 >= BLOCK 의미는 [Core] BudgetDecision.BLOCK이 provider 호출을 차단하도록 수정 #25 계약을 재사용한다.
  • missing pricing은 [Pricing] missing pricing·explicit zero와 pricing snapshot 정책 구현 #28 정책을 따르며 budget enforcement의 FAIL_CLOSED 경로에서는 예약 전에 차단한다.

통화 불변식

구현 가이드

  1. ReservationId, IdempotencyKey, ReservationState.RESERVED, immutable reservation snapshot과 결과 타입을 정의한다.
  2. 한 budget bucket 안에서 compute, lock 또는 동등한 임계 구역으로 예산 검사·통화 검사·idempotency 검사·예약 생성을 함께 수행한다.
  3. committed, active reserved, pending reconciliation liability와 idempotency index를 서로 다른 비원자 연산으로 갱신하지 않는다.
  4. reservation handle이 BudgetKey를 포함하게 하거나 동등한 방식으로 reservation lookup과 bucket 갱신 사이의 부분 상태를 피한다.
  5. ID 생성기와 Clock은 주입 가능하게 해 테스트를 결정적으로 만든다.
  6. 내부 구현이 internal에 있으면 LedgerBudgetComponents 같은 public factory를 통해 생성한다.
  7. 기존 BudgetEvaluatorBudgetStateStore의 유지·대체·migration 방식을 문서화한다.
  8. framework-independent snapshot/query API로 아래 합계를 검증할 수 있게 한다.
effectiveUsage = committed + activeReserved + pendingReconciliationLiability
remaining = max(0, limit - effectiveUsage)

테스트

  • 잔여 예산이 충분하면 safe upper bound 금액으로 reservation 하나가 생성된다.
  • 평균 estimate는 통과하지만 safe upper bound는 한도를 넘는 fixture에서 BLOCK된다.
  • 부족하면 BLOCK이며 전후 snapshot이 같다.
  • 동일 idempotency key 반복은 CREATED 1회, 이후 REUSED이며 terminal 이후에도 새 reservation을 만들지 않는다.
  • 동일 key의 amount/currency/model/policy/window가 다른 payload는 CONFLICT이며 기존 reservation은 보존된다.
  • bucket 기준 통화와 다른 reserve는 CURRENCY_MISMATCH이고 snapshot이 바뀌지 않는다.
  • missing pricing FAIL_CLOSED에서는 reservation이 생성되지 않는다.
  • 서로 다른 tenant/window는 격리된다.
  • 2개 요청을 동시에 시작하는 최소 회귀 테스트에서 상한 초과 예약이 생성되지 않는다.
  • committed/reserved/pending/remaining snapshot 합계가 실제 상태와 일치한다.

수백 요청과 terminal 전이 경쟁 검증은 #38에서 담당한다.

Acceptance criteria

  • check와 reserve가 하나의 원자 연산이다.
  • 평균 estimate가 아닌 #45의 safeUpperBoundCost가 예약된다.
  • 결과에 reservation ID와 CREATED/REUSED/BLOCKED/CONFLICT/CURRENCY_MISMATCH가 명시된다.
  • 동일 idempotency key가 예약 금액을 두 번 증가시키지 않는다.
  • BLOCK/CONFLICT/CURRENCY_MISMATCH는 어떤 상태도 변경하지 않는다.
  • bucket 통화와 모든 회계 합계의 불변식이 강제된다.
  • 상태를 검증할 framework-independent snapshot/query API가 있다.
  • Core/Budget에 Spring AI, Micrometer 또는 persistence 의존성을 추가하지 않는다.
  • public API와 migration 경로가 [ADR] 단일 accounting writer와 unresolved liability·event 실패 정책 정의 #46 Accounting lifecycle ADR과 일치한다.

제외 범위

의존관계와 순서

Source

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestmvpTokenPilot 0.1.0 MVP scope

    Type

    No type

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions