From b2a0affbb3181e2c91ea81d93fd78e0ea7777a45 Mon Sep 17 00:00:00 2001 From: Leejaewang03 <192717122+Leejaewang03@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:32:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?budget=20=EC=98=88=EC=95=BD=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BudgetReservationConcurrencyTest.java | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationConcurrencyTest.java diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationConcurrencyTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationConcurrencyTest.java new file mode 100644 index 0000000..3b7aaef --- /dev/null +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationConcurrencyTest.java @@ -0,0 +1,359 @@ +package io.tokenpilot.budget.internal; + +import io.tokenpilot.budget.BudgetKey; +import io.tokenpilot.budget.BudgetReservationRequest; +import io.tokenpilot.budget.BudgetReservationResult; +import io.tokenpilot.budget.BudgetSnapshot; +import io.tokenpilot.budget.BudgetWindow; +import io.tokenpilot.budget.IdempotencyKey; +import io.tokenpilot.budget.ReservationId; +import io.tokenpilot.budget.ReservationStatus; +import io.tokenpilot.core.domain.Cost; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Timeout; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Currency; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * #36 예약 linearization point의 결정적 동시성 회귀 테스트입니다. + * + *

같은 시작선에서 경쟁 명령을 실행하고 공개 snapshot만 검증합니다. 이 테스트는 + * 동시성 정확성의 수학적 증명이나 선형성 검증기를 대체하지 않습니다. #37의 + * commit/release/reconciliation 및 #46의 listener 정책은 해당 계약이 구현된 뒤 확장합니다.

+ */ +class BudgetReservationConcurrencyTest { + + private static final Currency USD = Currency.getInstance("USD"); + private static final Currency KRW = Currency.getInstance("KRW"); + private static final Cost LIMIT = usd("1000.00"); + private static final BudgetKey KEY = key("tenant-a", "2026-08"); + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-18T00:00:00Z"), + ZoneOffset.UTC + ); + + @RepeatedTest(3) + @Timeout(value = 15, unit = TimeUnit.SECONDS) + void 서로_다른_idempotency_key의_동시_예약은_한도를_초과하지_않는다() throws Exception { + StoreFixture fixture = fixture(); + Cost reservationAmount = usd("3.00"); + List> commands = IntStream.range(0, 400) + .mapToObj(index -> (Callable) () -> + fixture.store().checkAndReserve( + KEY, + LIMIT, + reservationAmount, + "request-" + index + )) + .toList(); + + List results = runConcurrently(commands); + BudgetSnapshot snapshot = fixture.store().snapshot(KEY, LIMIT); + Map statuses = statusCounts(results); + + assertThat(statuses) + .as("snapshot=%s", snapshot) + .containsEntry(ReservationStatus.CREATED, 333L) + .containsEntry(ReservationStatus.BLOCKED, 67L); + assertReservationOnlySnapshot(snapshot, usd("999.00"), 333); + assertThat(snapshot.effectiveUsage().compareTo(snapshot.limit())).isNegative(); + assertThat(createdCost(results)).isEqualTo(snapshot.activeReservedCost()); + assertThat(fixture.generatedIds()).hasValue(333); + } + + @RepeatedTest(3) + @Timeout(value = 15, unit = TimeUnit.SECONDS) + void 같은_idempotency_key의_동시_예약은_한_번만_생성된다() throws Exception { + StoreFixture fixture = fixture(); + BudgetReservationRequest request = request( + KEY, + "shared-request", + "gpt-4o-mini", + "10.00" + ); + List> commands = IntStream.range(0, 300) + .mapToObj(ignored -> (Callable) () -> + fixture.store().checkAndReserve(request)) + .toList(); + + List results = runConcurrently(commands); + BudgetSnapshot snapshot = fixture.store().snapshot(KEY, LIMIT); + + assertThat(statusCounts(results)) + .as("snapshot=%s", snapshot) + .containsEntry(ReservationStatus.CREATED, 1L) + .containsEntry(ReservationStatus.REUSED, 299L); + assertThat(reservationIds(results)).hasSize(1); + assertReservationOnlySnapshot(snapshot, usd("10.00"), 1); + assertThat(fixture.generatedIds()).hasValue(1); + } + + @RepeatedTest(3) + @Timeout(value = 15, unit = TimeUnit.SECONDS) + void 같은_idempotency_key의_서로_다른_payload는_하나만_생성되고_나머지는_CONFLICT다() + throws Exception { + StoreFixture fixture = fixture(); + BudgetReservationRequest firstPayload = request( + KEY, + "shared-request", + "model-a", + "10.00" + ); + BudgetReservationRequest secondPayload = request( + KEY, + "shared-request", + "model-b", + "20.00" + ); + List> commands = IntStream.range(0, 300) + .mapToObj(index -> (Callable) () -> + fixture.store().checkAndReserve( + index % 2 == 0 ? firstPayload : secondPayload + )) + .toList(); + + List results = runConcurrently(commands); + BudgetSnapshot snapshot = fixture.store().snapshot(KEY, LIMIT); + Cost acceptedCost = createdCost(results); + + assertThat(statusCounts(results)) + .as("snapshot=%s", snapshot) + .containsEntry(ReservationStatus.CREATED, 1L) + .containsEntry(ReservationStatus.REUSED, 149L) + .containsEntry(ReservationStatus.CONFLICT, 150L); + assertThat(reservationIds(results)).hasSize(1); + assertThat(acceptedCost) + .isIn(firstPayload.safeUpperBoundCost(), secondPayload.safeUpperBoundCost()); + assertReservationOnlySnapshot(snapshot, acceptedCost, 1); + assertThat(fixture.generatedIds()).hasValue(1); + } + + @RepeatedTest(3) + @Timeout(value = 15, unit = TimeUnit.SECONDS) + void 다른_통화의_동시_예약은_bucket_합계를_변경하지_않는다() throws Exception { + StoreFixture fixture = fixture(); + List> commands = IntStream.range(0, 400) + .mapToObj(index -> (Callable) () -> { + Cost amount = index % 2 == 0 + ? usd("2.00") + : Cost.of(new BigDecimal("2.00"), KRW); + return fixture.store().checkAndReserve( + KEY, + LIMIT, + amount, + "currency-request-" + index + ); + }) + .toList(); + + List results = runConcurrently(commands); + BudgetSnapshot snapshot = fixture.store().snapshot(KEY, LIMIT); + + assertThat(statusCounts(results)) + .as("snapshot=%s", snapshot) + .containsEntry(ReservationStatus.CREATED, 200L) + .containsEntry(ReservationStatus.CURRENCY_MISMATCH, 200L); + assertReservationOnlySnapshot(snapshot, usd("400.00"), 200); + assertThat(createdCost(results)).isEqualTo(snapshot.activeReservedCost()); + assertThat(fixture.generatedIds()).hasValue(200); + } + + @RepeatedTest(3) + @Timeout(value = 15, unit = TimeUnit.SECONDS) + void tenant와_window가_다른_동시_예약은_bucket별로_격리된다() throws Exception { + StoreFixture fixture = fixture(); + List keys = List.of( + KEY, + key("tenant-b", "2026-08"), + key("tenant-a", "2026-09") + ); + List> commands = IntStream.range(0, 300) + .mapToObj(index -> (Callable) () -> { + BudgetKey key = keys.get(index % keys.size()); + return fixture.store().checkAndReserve( + key, + LIMIT, + usd("2.00"), + "isolated-request-" + index + ); + }) + .toList(); + + List results = runConcurrently(commands); + + assertThat(statusCounts(results)).containsEntry(ReservationStatus.CREATED, 300L); + for (BudgetKey key : keys) { + BudgetSnapshot snapshot = fixture.store().snapshot(key, LIMIT); + assertReservationOnlySnapshot(snapshot, usd("200.00"), 100); + } + assertThat(fixture.generatedIds()).hasValue(300); + } + + private static List runConcurrently(List> commands) + throws Exception { + CountDownLatch ready = new CountDownLatch(commands.size()); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(commands.size()); + List results = new ArrayList<>(commands.size()); + List failures = new ArrayList<>(); + var executor = Executors.newVirtualThreadPerTaskExecutor(); + + try { + for (Callable command : commands) { + futures.add(executor.submit(() -> { + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("start barrier timed out"); + } + return command.call(); + })); + } + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + executor.shutdown(); + + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + for (Future future : futures) { + if (!future.isDone()) { + future.cancel(true); + } + } + } + + for (int index = 0; index < futures.size(); index++) { + Future future = futures.get(index); + if (future.isCancelled()) { + failures.add(new TimeoutException( + "concurrent command timed out: index=" + index + )); + continue; + } + try { + results.add(future.get()); + } catch (ExecutionException exception) { + failures.add(exception.getCause()); + } + } + + assertThat(failures).as("task failures=%s", failures).isEmpty(); + return results; + } finally { + start.countDown(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(2, TimeUnit.SECONDS)).isTrue(); + } + } + + private static void assertReservationOnlySnapshot( + BudgetSnapshot snapshot, + Cost expectedActiveReservedCost, + int expectedActiveReservationCount + ) { + Cost zero = Cost.zero(snapshot.limit().currency()); + + assertThat(snapshot.committedCost()) + .as("snapshot=%s", snapshot) + .isEqualTo(zero); + assertThat(snapshot.activeReservedCost()) + .as("snapshot=%s", snapshot) + .isEqualTo(expectedActiveReservedCost); + assertThat(snapshot.pendingReconciliationLiability()) + .as("snapshot=%s", snapshot) + .isEqualTo(zero); + assertThat(snapshot.effectiveUsage()) + .as("snapshot=%s", snapshot) + .isEqualTo(expectedActiveReservedCost); + assertThat(snapshot.activeReservationIds()) + .as("snapshot=%s", snapshot) + .hasSize(expectedActiveReservationCount); + } + + private static Map statusCounts( + List results + ) { + Map counts = new EnumMap<>(ReservationStatus.class); + for (BudgetReservationResult result : results) { + counts.merge(result.status(), 1L, Long::sum); + } + return counts; + } + + private static List reservationIds(List results) { + return results.stream() + .map(BudgetReservationResult::reservationId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + } + + private static Cost createdCost(List results) { + return results.stream() + .filter(result -> result.status() == ReservationStatus.CREATED) + .map(result -> result.reservation().amount()) + .reduce(Cost.zero(USD), Cost::add); + } + + private static StoreFixture fixture() { + AtomicInteger generatedIds = new AtomicInteger(); + InMemoryBudgetStateStore store = new InMemoryBudgetStateStore( + CLOCK, + () -> new ReservationId("reservation-" + generatedIds.incrementAndGet()) + ); + return new StoreFixture(store, generatedIds); + } + + private static BudgetReservationRequest request( + BudgetKey key, + String idempotencyKey, + String modelId, + String amount + ) { + return new BudgetReservationRequest( + key, + LIMIT, + usd(amount), + new IdempotencyKey(idempotencyKey), + modelId, + "pricing-v1", + "catalog-v1" + ); + } + + private static BudgetKey key(String tenantId, String window) { + return new BudgetKey( + "budget-policy", + "tenant", + tenantId, + BudgetWindow.parse(window) + ); + } + + private static Cost usd(String amount) { + return Cost.of(new BigDecimal(amount), USD); + } + + private record StoreFixture( + InMemoryBudgetStateStore store, + AtomicInteger generatedIds + ) { + } +} From 813f85fb0fbd08055180a1c428053716fedea4f1 Mon Sep 17 00:00:00 2001 From: HuitaePark Date: Wed, 19 Aug 2026 16:02:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=EC=98=88=EC=82=B0=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BudgetReservationConcurrencyTest.java | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationConcurrencyTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationConcurrencyTest.java index 3b7aaef..870fce1 100644 --- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationConcurrencyTest.java +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationConcurrencyTest.java @@ -10,6 +10,7 @@ import io.tokenpilot.budget.ReservationStatus; import io.tokenpilot.core.domain.Cost; import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import java.math.BigDecimal; @@ -80,6 +81,38 @@ class BudgetReservationConcurrencyTest { assertThat(fixture.generatedIds()).hasValue(333); } + @RepeatedTest(3) + @Timeout(value = 15, unit = TimeUnit.SECONDS) + void 이미_확정된_비용을_포함해_동시_예약은_한도를_초과하지_않는다() throws Exception { + StoreFixture fixture = fixture(); + fixture.store().addCost(KEY, LIMIT, usd("400.00")); + Cost reservationAmount = usd("2.00"); + List> commands = IntStream.range(0, 300) + .mapToObj(index -> (Callable) () -> + fixture.store().checkAndReserve( + KEY, + LIMIT, + reservationAmount, + "committed-request-" + index + )) + .toList(); + + List results = runConcurrently(commands); + BudgetSnapshot snapshot = fixture.store().snapshot(KEY, LIMIT); + + assertThat(statusCounts(results)) + .as("snapshot=%s", snapshot) + .containsEntry(ReservationStatus.CREATED, 299L) + .containsEntry(ReservationStatus.BLOCKED, 1L); + assertThat(snapshot.committedCost()).isEqualTo(usd("400.00")); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("598.00")); + assertThat(snapshot.effectiveUsage()).isEqualTo(usd("998.00")); + assertThat(snapshot.remaining()).isEqualTo(usd("2.00")); + assertThat(snapshot.activeReservationIds()).hasSize(299); + assertThat(createdCost(results)).isEqualTo(snapshot.activeReservedCost()); + assertThat(fixture.generatedIds()).hasValue(299); + } + @RepeatedTest(3) @Timeout(value = 15, unit = TimeUnit.SECONDS) void 같은_idempotency_key의_동시_예약은_한_번만_생성된다() throws Exception { @@ -208,6 +241,55 @@ class BudgetReservationConcurrencyTest { assertThat(fixture.generatedIds()).hasValue(300); } + @Test + void 실패한_예약_시도는_idempotency_key를_점유하지_않는다() { + StoreFixture fixture = fixture(); + + BudgetReservationResult initial = fixture.store().checkAndReserve( + KEY, + LIMIT, + usd("900.00"), + "initial-request" + ); + BudgetReservationResult blocked = fixture.store().checkAndReserve( + KEY, + LIMIT, + usd("100.00"), + "blocked-request" + ); + BudgetReservationResult blockedRetry = fixture.store().checkAndReserve( + KEY, + LIMIT, + usd("100.00"), + "blocked-request" + ); + BudgetReservationResult mismatch = fixture.store().checkAndReserve( + KEY, + LIMIT, + Cost.of(new BigDecimal("1.00"), KRW), + "currency-request" + ); + BudgetReservationResult validCurrencyRetry = fixture.store().checkAndReserve( + KEY, + LIMIT, + usd("1.00"), + "currency-request" + ); + + assertThat(initial.status()).isEqualTo(ReservationStatus.CREATED); + assertThat(blocked.status()).isEqualTo(ReservationStatus.BLOCKED); + assertThat(blocked.reservation()).isNull(); + assertThat(blockedRetry.status()).isEqualTo(ReservationStatus.BLOCKED); + assertThat(blockedRetry.reservation()).isNull(); + assertThat(mismatch.status()).isEqualTo(ReservationStatus.CURRENCY_MISMATCH); + assertThat(mismatch.reservation()).isNull(); + assertThat(validCurrencyRetry.status()).isEqualTo(ReservationStatus.CREATED); + assertThat(validCurrencyRetry.reservation().amount()).isEqualTo(usd("1.00")); + assertThat(fixture.store().snapshot(KEY, LIMIT).activeReservedCost()) + .isEqualTo(usd("901.00")); + assertThat(fixture.generatedIds()).hasValue(2); + } + private static List runConcurrently(List> commands) throws Exception { CountDownLatch ready = new CountDownLatch(commands.size());