-
Notifications
You must be signed in to change notification settings - Fork 1
feat(budget): 이슈 36 원자적 예산 예약과 멱등성 구현 #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
79 changes: 79 additions & 0 deletions
79
token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| package io.tokenpilot.budget; | ||
|
|
||
| import io.tokenpilot.core.domain.Cost; | ||
|
|
||
| import java.time.Instant; | ||
| import java.util.Objects; | ||
|
|
||
| /** | ||
| * 예산 bucket에 생성된 immutable 예약 snapshot입니다. | ||
| */ | ||
| public record BudgetReservation( | ||
| ReservationId id, | ||
| BudgetKey key, | ||
| Cost limit, | ||
| Cost amount, | ||
| IdempotencyKey idempotencyKey, | ||
| String modelId, | ||
| String pricingPolicyId, | ||
| String catalogVersion, | ||
| ReservationState state, | ||
| Instant createdAt | ||
| ) { | ||
|
|
||
| public BudgetReservation { | ||
| Objects.requireNonNull(id, "id must not be null"); | ||
| Objects.requireNonNull(key, "key must not be null"); | ||
| Objects.requireNonNull(limit, "limit must not be null"); | ||
| Objects.requireNonNull(amount, "amount must not be null"); | ||
| Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null"); | ||
| Objects.requireNonNull(state, "state must not be null"); | ||
| Objects.requireNonNull(createdAt, "createdAt must not be null"); | ||
| if (limit.value().signum() <= 0) { | ||
| throw new IllegalArgumentException("limit must be greater than zero"); | ||
| } | ||
| if (!limit.currency().equals(amount.currency())) { | ||
| throw new IllegalArgumentException("reservation costs must use the budget currency"); | ||
| } | ||
| modelId = optionalText(modelId, "modelId"); | ||
| pricingPolicyId = optionalText(pricingPolicyId, "pricingPolicyId"); | ||
| catalogVersion = optionalText(catalogVersion, "catalogVersion"); | ||
| } | ||
|
|
||
| public static BudgetReservation reserved( | ||
| ReservationId id, | ||
| BudgetReservationRequest request, | ||
| Instant createdAt | ||
| ) { | ||
| Objects.requireNonNull(request, "request must not be null"); | ||
| return new BudgetReservation( | ||
| id, | ||
| request.key(), | ||
| request.limit(), | ||
| request.safeUpperBoundCost(), | ||
| request.idempotencyKey(), | ||
| request.modelId(), | ||
| request.pricingPolicyId(), | ||
| request.catalogVersion(), | ||
| ReservationState.RESERVED, | ||
| createdAt | ||
| ); | ||
| } | ||
|
|
||
| public boolean matches(BudgetReservationRequest request) { | ||
| return key.equals(request.key()) | ||
| && limit.equals(request.limit()) | ||
| && amount.equals(request.safeUpperBoundCost()) | ||
| && idempotencyKey.equals(request.idempotencyKey()) | ||
| && Objects.equals(modelId, request.modelId()) | ||
| && Objects.equals(pricingPolicyId, request.pricingPolicyId()) | ||
| && Objects.equals(catalogVersion, request.catalogVersion()); | ||
| } | ||
|
|
||
| private static String optionalText(String value, String name) { | ||
| if (value != null && value.isBlank()) { | ||
| throw new IllegalArgumentException(name + " must not be blank"); | ||
| } | ||
| return value; | ||
| } | ||
| } |
80 changes: 80 additions & 0 deletions
80
token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| package io.tokenpilot.budget; | ||
|
|
||
| import io.tokenpilot.core.domain.Cost; | ||
|
|
||
| import java.util.Objects; | ||
|
|
||
| /** | ||
| * 호출 전 안전 상한 비용을 예산 bucket에 예약하기 위한 immutable 요청입니다. | ||
| * | ||
| * <p>{@code limit}은 bucket 생성 시 고정되는 정책 snapshot이고, | ||
| * {@code safeUpperBoundCost}는 예약할 실제 금액입니다. 모델과 가격 식별자는 | ||
| * 같은 idempotency key의 요청 payload가 바뀌었는지 검증하는 fingerprint로 사용됩니다.</p> | ||
| */ | ||
| public record BudgetReservationRequest( | ||
| BudgetKey key, | ||
| Cost limit, | ||
| Cost safeUpperBoundCost, | ||
| IdempotencyKey idempotencyKey, | ||
| String modelId, | ||
| String pricingPolicyId, | ||
| String catalogVersion | ||
| ) { | ||
|
|
||
| public BudgetReservationRequest { | ||
| Objects.requireNonNull(key, "key must not be null"); | ||
| Objects.requireNonNull(limit, "limit must not be null"); | ||
| Objects.requireNonNull(safeUpperBoundCost, "safeUpperBoundCost must not be null"); | ||
| Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null"); | ||
| if (limit.value().signum() <= 0) { | ||
| throw new IllegalArgumentException("limit must be greater than zero"); | ||
| } | ||
| modelId = optionalText(modelId, "modelId"); | ||
| pricingPolicyId = optionalText(pricingPolicyId, "pricingPolicyId"); | ||
| catalogVersion = optionalText(catalogVersion, "catalogVersion"); | ||
| } | ||
|
|
||
| public BudgetReservationRequest( | ||
| BudgetKey key, | ||
| Cost limit, | ||
| Cost safeUpperBoundCost, | ||
| String idempotencyKey | ||
| ) { | ||
| this( | ||
| key, | ||
| limit, | ||
| safeUpperBoundCost, | ||
| new IdempotencyKey(idempotencyKey), | ||
| null, | ||
| null, | ||
| null | ||
| ); | ||
| } | ||
|
|
||
| public BudgetReservationRequest( | ||
| BudgetKey key, | ||
| Cost limit, | ||
| Cost safeUpperBoundCost, | ||
| String idempotencyKey, | ||
| String modelId, | ||
| String pricingPolicyId, | ||
| String catalogVersion | ||
| ) { | ||
| this( | ||
| key, | ||
| limit, | ||
| safeUpperBoundCost, | ||
| new IdempotencyKey(idempotencyKey), | ||
| modelId, | ||
| pricingPolicyId, | ||
| catalogVersion | ||
| ); | ||
| } | ||
|
|
||
| private static String optionalText(String value, String name) { | ||
| if (value != null && value.isBlank()) { | ||
| throw new IllegalArgumentException(name + " must not be blank"); | ||
| } | ||
| return value; | ||
| } | ||
| } | ||
84 changes: 84 additions & 0 deletions
84
token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationResult.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| package io.tokenpilot.budget; | ||
|
|
||
| import java.util.Objects; | ||
|
|
||
| /** | ||
| * 원자적 예산 예약 시도의 결과입니다. | ||
| */ | ||
| public record BudgetReservationResult( | ||
| ReservationStatus status, | ||
| BudgetReservation reservation, | ||
| BudgetSnapshot snapshot, | ||
| String reason | ||
| ) { | ||
|
|
||
| public BudgetReservationResult { | ||
| Objects.requireNonNull(status, "status must not be null"); | ||
| Objects.requireNonNull(snapshot, "snapshot must not be null"); | ||
| if (reason == null || reason.isBlank()) { | ||
| throw new IllegalArgumentException("reason must not be blank"); | ||
| } | ||
| if ((status == ReservationStatus.CREATED || status == ReservationStatus.REUSED) | ||
| && reservation == null) { | ||
| throw new IllegalArgumentException(status + " result must include a reservation"); | ||
| } | ||
| } | ||
|
|
||
| public ReservationId reservationId() { | ||
| return reservation == null ? null : reservation.id(); | ||
| } | ||
|
|
||
| public boolean isAccepted() { | ||
| return status == ReservationStatus.CREATED || status == ReservationStatus.REUSED; | ||
| } | ||
|
|
||
| public static BudgetReservationResult created( | ||
| BudgetReservation reservation, | ||
| BudgetSnapshot snapshot | ||
| ) { | ||
| return new BudgetReservationResult( | ||
| ReservationStatus.CREATED, | ||
| reservation, | ||
| snapshot, | ||
| "예산 예약이 생성되었습니다" | ||
| ); | ||
| } | ||
|
|
||
| public static BudgetReservationResult reused( | ||
| BudgetReservation reservation, | ||
| BudgetSnapshot snapshot | ||
| ) { | ||
| return new BudgetReservationResult( | ||
| ReservationStatus.REUSED, | ||
| reservation, | ||
| snapshot, | ||
| "동일 idempotency key의 기존 예약을 재사용했습니다" | ||
| ); | ||
| } | ||
|
|
||
| public static BudgetReservationResult blocked( | ||
| BudgetSnapshot snapshot, | ||
| String reason | ||
| ) { | ||
| return new BudgetReservationResult(ReservationStatus.BLOCKED, null, snapshot, reason); | ||
| } | ||
|
|
||
| public static BudgetReservationResult conflict( | ||
| BudgetReservation reservation, | ||
| BudgetSnapshot snapshot, | ||
| String reason | ||
| ) { | ||
| return new BudgetReservationResult(ReservationStatus.CONFLICT, reservation, snapshot, reason); | ||
| } | ||
|
|
||
| public static BudgetReservationResult currencyMismatch( | ||
| BudgetSnapshot snapshot | ||
| ) { | ||
| return new BudgetReservationResult( | ||
| ReservationStatus.CURRENCY_MISMATCH, | ||
| null, | ||
| snapshot, | ||
| "예산 통화와 예약 비용 통화가 일치하지 않습니다" | ||
| ); | ||
| } | ||
| } |
66 changes: 66 additions & 0 deletions
66
token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetSnapshot.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package io.tokenpilot.budget; | ||
|
|
||
| import io.tokenpilot.core.domain.Cost; | ||
|
|
||
| import java.util.Collections; | ||
| import java.util.Objects; | ||
| import java.util.Set; | ||
|
|
||
| /** | ||
| * 예산 bucket의 framework-independent 읽기 snapshot입니다. | ||
| */ | ||
| public record BudgetSnapshot( | ||
| BudgetKey key, | ||
| Cost limit, | ||
| Cost committedCost, | ||
| Cost activeReservedCost, | ||
| Cost pendingReconciliationLiability, | ||
| Set<ReservationId> activeReservationIds | ||
| ) { | ||
|
|
||
| public BudgetSnapshot { | ||
| Objects.requireNonNull(key, "key must not be null"); | ||
| Objects.requireNonNull(limit, "limit must not be null"); | ||
| Objects.requireNonNull(committedCost, "committedCost must not be null"); | ||
| Objects.requireNonNull(activeReservedCost, "activeReservedCost must not be null"); | ||
| Objects.requireNonNull( | ||
| pendingReconciliationLiability, | ||
| "pendingReconciliationLiability must not be null" | ||
| ); | ||
| Objects.requireNonNull(activeReservationIds, "activeReservationIds must not be null"); | ||
| if (limit.value().signum() <= 0) { | ||
| throw new IllegalArgumentException("limit must be greater than zero"); | ||
| } | ||
| if (!limit.currency().equals(committedCost.currency()) | ||
| || !limit.currency().equals(activeReservedCost.currency()) | ||
| || !limit.currency().equals(pendingReconciliationLiability.currency())) { | ||
| throw new IllegalArgumentException("budget snapshot costs must use the same currency"); | ||
| } | ||
| activeReservationIds = Collections.unmodifiableSet(Set.copyOf(activeReservationIds)); | ||
| } | ||
|
|
||
| public static BudgetSnapshot empty(BudgetKey key, Cost limit) { | ||
| Cost zero = Cost.zero(limit.currency()); | ||
| return new BudgetSnapshot(key, limit, zero, zero, zero, Set.of()); | ||
| } | ||
|
|
||
| /** | ||
| * 예약과 미해결 정산 부채를 포함한 admission 기준 사용량입니다. | ||
| */ | ||
| public Cost effectiveUsage() { | ||
| return committedCost | ||
| .add(activeReservedCost) | ||
| .add(pendingReconciliationLiability); | ||
| } | ||
|
|
||
| /** | ||
| * 사용량을 반영한 남은 예산입니다. 초과 상태에서는 0입니다. | ||
| */ | ||
| public Cost remaining() { | ||
| Cost effectiveUsage = effectiveUsage(); | ||
| if (effectiveUsage.compareTo(limit) >= 0) { | ||
| return Cost.zero(limit.currency()); | ||
| } | ||
| return Cost.of(limit.value().subtract(effectiveUsage.value()), limit.currency()); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
safeUpperBoundCost의 음수 값을 거부하세요.음수
safeUpperBoundCost는 저장소의 유효 사용량과 활성 예약 금액을 감소시킵니다. 이후 요청은 실제 예산 한도를 초과해도CREATED결과를 받을 수 있습니다. 요청 생성 시 음수 값을 거부하세요.수정 예시
if (limit.value().signum() <= 0) { throw new IllegalArgumentException("limit must be greater than zero"); } + if (safeUpperBoundCost.value().signum() < 0) { + throw new IllegalArgumentException("safeUpperBoundCost must not be negative"); + } modelId = optionalText(modelId, "modelId");📝 Committable suggestion
🤖 Prompt for AI Agents