Skip to content

[Budget] commit/release lifecycle와 actual reconciliation 구현 #37

Description

@HuitaePark

문제와 현재 코드 근거

현재 DefaultLedgerAdvisor.after()는 다음 작업을 별도로 수행한다.

  1. ledgerManager.record()로 비용 계산과 listener event 발행
  2. 같은 usage로 비용을 다시 계산
  3. BudgetStateStore.addCost()로 누적

estimate와 actual을 연결하는 reservation이 없고 callback이 중복되면 ledger와 budget이 모두 중복 기록될 수 있다. listener가 실패하면 event는 일부 전달된 상태로 예외가 전파되고 budget 누적은 실행되지 않을 수도 있다. UsageSource.UNAVAILABLE도 실제 0 token과 구별해 정산할 경로가 없다.

목표 상태 머신

#46 Accounting lifecycle ADR의 전이표를 구현 기준으로 사용한다.

RESERVED
├─ IN_FLIGHT
└─ RELEASED                // provider dispatch 전 또는 미과금 확인

IN_FLIGHT
├─ COMMITTED               // actual known
├─ RELEASED                // provider 미과금이 확인된 경우만
└─ RECONCILIATION_REQUIRED // dispatch 후 actual unknown

RECONCILIATION_REQUIRED
├─ COMMITTED               // late actual known
└─ WRITTEN_OFF             // 명시적인 운영자/정책 결정
  • RECONCILIATION_REQUIRED는 terminal이 아니라 해결 대기 상태다.
  • actual unknown은 0원이나 RELEASED가 아니다.
  • unresolved estimate는 pendingReconciliationLiability로 옮겨 admission 계산에 계속 포함한다.
  • EXPIRED가 필요하면 provider dispatch 전 RESERVED에만 허용한다. IN_FLIGHT를 단순 만료해 비용을 지우지 않는다.
  • automatic TTL scheduler는 MVP 범위가 아니다.

회계 전이 불변식

  • commit은 activeReserved -= estimate, committed += actual을 한 bucket 임계 구역 안에서 처리한다.
  • actual unavailable 전이는 activeReserved -= estimate, pendingReconciliationLiability += estimate를 원자적으로 처리한다.
  • late actual commit은 pendingReconciliationLiability -= estimate, committed += actual을 원자적으로 처리한다.
  • actual이 estimate보다 커도 이미 발생한 비용이므로 commit을 거부하지 않는다. 초과분과 over-limit 상태를 결과에 남기고 이후 예약을 차단한다.
  • actual이 작으면 남는 liability를 해제한다.
  • terminal 명령 재호출은 상태와 event를 바꾸지 않는 idempotent REUSED/no-op 결과다.
  • 다른 actual로 두 번째 commit, commit 후 release 같은 상충 전이는 기존 상태를 보존하고 CONFLICT를 반환한다.
  • bucket 통화와 actual 통화가 다르면 상태를 바꾸지 않고 CURRENCY_MISMATCH 또는 RECONCILIATION_REQUIRED로 남긴다. 환율 변환은 하지 않는다.

AccountingService와 단일 쓰기 주체

Advisor가 LedgerManager.record()와 reservation store를 따로 호출하지 않게 한다. 이름은 예시이며 다음 의미를 가진 framework-independent orchestration API를 둔다.

AccountingService
├─ markInFlight(reservationId)
├─ commit(reservationId, actualUsage/model/pricing input)
├─ markReconciliationRequired(reservationId, reason)
├─ reconcileLateActual(reservationId, actualUsage/model/pricing input)
├─ release(reservationId, reason)
└─ writeOff(reservationId, reason)

처리 순서는 다음과 같다.

actual usage normalization
→ actual cost 계산 1회
→ reservation/bucket atomic transition
→ newly applied transition인 경우 accounting event 1회 생성
→ listener에 best-effort at-most-once dispatch
  • 비용 계산과 상태 전이가 성공하기 전에 CostRecordedEvent를 발행하지 않는다.
  • duplicate/CONFLICT 결과에서는 ledger, metric과 notification event를 다시 생성하지 않는다.
  • 상태 전이의 exactly-once/idempotency와 event 전달 보장은 구분한다.
  • durable outbox가 없는 MVP의 event dispatch는 best-effort at-most-once이며 exactly-once delivery를 주장하지 않는다.
  • listener 실패는 성공한 상태 전이를 rollback하지 않는다. 구체 격리/error handler 정책은 #46과 #40을 따른다.

correlation과 결과

reconciliation result/event는 최소한 다음을 포함한다.

  • requestId, attemptId, reservationId
  • budget key/window
  • request model과 response model
  • pricing policy/version
  • estimate, actual과 delta
  • currency
  • 이전 상태, 최종 상태와 bounded reason

raw prompt, response 본문과 무제한 오류 메시지는 event 기본 payload에 포함하지 않는다.

테스트

  • estimate보다 작은·같은·큰 actual commit과 delta를 검증한다.
  • actual 초과로 limit을 넘더라도 실제 비용이 누락되지 않고 이후 예약이 BLOCK된다.
  • actual unavailable은 RECONCILIATION_REQUIRED이며 pending liability에 estimate가 한 번 반영된다.
  • late actual이 도착하면 pending liability가 제거되고 COMMITTED로 전이된다.
  • 같은 late actual/commit/release 반복은 회계 상태와 event를 중복 변경하지 않는다.
  • release는 RESERVED 또는 미과금이 확인된 IN_FLIGHT에서만 liability를 제거한다.
  • IN_FLIGHT 만료로 liability가 조용히 사라지지 않는다.
  • 상충하는 terminal 전이는 CONFLICT다.
  • 통화 불일치, 음수 actual과 없는 reservation은 bucket 합계를 변경하지 않는다.
  • request/attempt/reservation과 request/response model correlation이 유지된다.
  • newly COMMITTED인 경우만 cost/accounting event가 한 번 생성된다.
  • listener가 실패해도 상태 전이는 보존되고 duplicate 호출에서 event를 다시 만들지 않는다.

동시 commit/release 및 수백 요청 경쟁은 #38에서 확장한다.

Acceptance criteria

  • 명시적인 상태 전이표와 허용/거부 transition result가 public contract로 문서화된다.
  • RECONCILIATION_REQUIRED가 late actual로 해결될 수 있다.
  • active reserved, committed와 pending reconciliation liability의 합계 불변식이 모든 전이에서 유지된다.
  • estimate, actual token/cost와 delta가 같은 요청으로 연결된다.
  • 비용은 하나의 AccountingService 경로에서 한 번만 계산·정산된다.
  • 중복 callback에서 비용, 상태와 event가 한 번만 변경된다.
  • 성공, 확인된 미과금 실패와 actual 미확정 실패가 구분된다.
  • 다른 통화가 조용히 합산되지 않는다.
  • accounting event를 #40이 Spring AI 없이 소비할 수 있다.
  • 상태 transition exactly-once와 best-effort event delivery의 한계가 문서화된다.
  • 구현이 [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