# 회수 게이트웨이 전문 (64 줄) JpaQuotaReclaimGateway.java:1 package dev.caskeleton.adapter.outbound.persistence.fileserver; JpaQuotaReclaimGateway.java:2 JpaQuotaReclaimGateway.java:3 import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.QuotaReservationEntity; JpaQuotaReclaimGateway.java:4 import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverQuotaRepository; JpaQuotaReclaimGateway.java:5 import dev.caskeleton.application.fileserver.api.metadata.QuotaScope; JpaQuotaReclaimGateway.java:6 import dev.caskeleton.application.fileserver.cleanup.QuotaReclaimGateway; JpaQuotaReclaimGateway.java:7 import java.time.Clock; JpaQuotaReclaimGateway.java:8 import java.time.Instant; JpaQuotaReclaimGateway.java:9 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; JpaQuotaReclaimGateway.java:10 import org.springframework.data.domain.Limit; JpaQuotaReclaimGateway.java:11 import org.springframework.stereotype.Repository; JpaQuotaReclaimGateway.java:12 JpaQuotaReclaimGateway.java:13 /** JpaQuotaReclaimGateway.java:14 * Returns reclaimed bytes to a scope's committed total. JpaQuotaReclaimGateway.java:15 * JpaQuotaReclaimGateway.java:16 *

Committed usage is spread over many rows, so a reclaim is drawn down newest-first across them JpaQuotaReclaimGateway.java:17 * until the amount is satisfied. Newest-first matters: the most recently committed rows are the JpaQuotaReclaimGateway.java:18 * ones a delete is most likely to correspond to, and drawing from them keeps historical rows from JpaQuotaReclaimGateway.java:19 * being hollowed out by unrelated deletions. JpaQuotaReclaimGateway.java:20 * JpaQuotaReclaimGateway.java:21 *

Each draw-down is conditional on the row still holding at least that many bytes, so two JpaQuotaReclaimGateway.java:22 * cleanup workers reclaiming at once cannot push the ledger negative — the loser simply moves to JpaQuotaReclaimGateway.java:23 * the next row. JpaQuotaReclaimGateway.java:24 * JpaQuotaReclaimGateway.java:25 *

A remainder that no row can absorb is dropped rather than carried. The ledger's floor is zero: JpaQuotaReclaimGateway.java:26 * a scope cannot owe negative bytes, and a reclaim that outruns the recorded total means the total JpaQuotaReclaimGateway.java:27 * was already understated, which a negative balance would not fix. JpaQuotaReclaimGateway.java:28 */ JpaQuotaReclaimGateway.java:29 @Repository JpaQuotaReclaimGateway.java:30 @ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true") JpaQuotaReclaimGateway.java:31 public class JpaQuotaReclaimGateway implements QuotaReclaimGateway { JpaQuotaReclaimGateway.java:32 JpaQuotaReclaimGateway.java:33 private static final Limit RECLAIM_PAGE = Limit.of(64); JpaQuotaReclaimGateway.java:34 JpaQuotaReclaimGateway.java:35 private final FileserverQuotaRepository reservations; JpaQuotaReclaimGateway.java:36 private final Clock clock; JpaQuotaReclaimGateway.java:37 JpaQuotaReclaimGateway.java:38 public JpaQuotaReclaimGateway(FileserverQuotaRepository reservations, Clock clock) { JpaQuotaReclaimGateway.java:39 this.reservations = reservations; JpaQuotaReclaimGateway.java:40 this.clock = clock; JpaQuotaReclaimGateway.java:41 } JpaQuotaReclaimGateway.java:42 JpaQuotaReclaimGateway.java:43 @Override JpaQuotaReclaimGateway.java:44 public void reclaim(QuotaScope scope, long bytes) { JpaQuotaReclaimGateway.java:45 if (bytes < 0) { JpaQuotaReclaimGateway.java:46 throw new IllegalArgumentException("bytes must not be negative"); JpaQuotaReclaimGateway.java:47 } JpaQuotaReclaimGateway.java:48 if (bytes == 0) { JpaQuotaReclaimGateway.java:49 return; JpaQuotaReclaimGateway.java:50 } JpaQuotaReclaimGateway.java:51 Instant now = clock.instant(); JpaQuotaReclaimGateway.java:52 long outstanding = bytes; JpaQuotaReclaimGateway.java:53 for (QuotaReservationEntity committed : JpaQuotaReclaimGateway.java:54 reservations.findCommittedWithBytes(scope.type(), scope.value(), RECLAIM_PAGE)) { JpaQuotaReclaimGateway.java:55 if (outstanding == 0) { JpaQuotaReclaimGateway.java:56 return; JpaQuotaReclaimGateway.java:57 } JpaQuotaReclaimGateway.java:58 long draw = Math.min(outstanding, committed.getCommittedBytes()); JpaQuotaReclaimGateway.java:59 if (reservations.reduceCommitted(committed.getReservationId(), draw, now) == 1) { JpaQuotaReclaimGateway.java:60 outstanding -= draw; JpaQuotaReclaimGateway.java:61 } JpaQuotaReclaimGateway.java:62 } JpaQuotaReclaimGateway.java:63 } JpaQuotaReclaimGateway.java:64 } # 그 한 번의 질의가 무엇을 돌려주는가 FileserverQuotaRepository.java:106 /** Committed rows for a scope that still carry bytes, newest first. */ FileserverQuotaRepository.java:107 @Query( FileserverQuotaRepository.java:108 """ FileserverQuotaRepository.java:109 select q from QuotaReservationEntity q FileserverQuotaRepository.java:110 where q.scopeType = :scopeType FileserverQuotaRepository.java:111 and q.scopeValue = :scopeValue FileserverQuotaRepository.java:112 and q.status = 'COMMITTED' FileserverQuotaRepository.java:113 and q.committedBytes > 0 FileserverQuotaRepository.java:114 order by q.updatedAt desc FileserverQuotaRepository.java:115 """) FileserverQuotaRepository.java:116 List findCommittedWithBytes( FileserverQuotaRepository.java:117 @Param("scopeType") String scopeType, @Param("scopeValue") String scopeValue, Limit limit); # 그 차감이 0 행을 갱신했을 때 무엇이 되는가 FileserverQuotaRepository.java:119 /** FileserverQuotaRepository.java:120 * Gives back part of a committed row. FileserverQuotaRepository.java:121 * FileserverQuotaRepository.java:122 *

The guard is what makes concurrent reclaims safe: a row that another reclaim already drew FileserverQuotaRepository.java:123 * down below {@code amount} updates zero rows, and the caller moves to the next row instead of FileserverQuotaRepository.java:124 * driving the ledger negative. FileserverQuotaRepository.java:125 */ FileserverQuotaRepository.java:126 @Modifying(clearAutomatically = true, flushAutomatically = true) FileserverQuotaRepository.java:127 @Query( FileserverQuotaRepository.java:128 """ FileserverQuotaRepository.java:129 update QuotaReservationEntity q FileserverQuotaRepository.java:130 set q.committedBytes = q.committedBytes - :amount, FileserverQuotaRepository.java:131 q.version = q.version + 1, FileserverQuotaRepository.java:132 q.updatedAt = :now FileserverQuotaRepository.java:133 where q.reservationId = :reservationId FileserverQuotaRepository.java:134 and q.committedBytes >= :amount FileserverQuotaRepository.java:135 """) FileserverQuotaRepository.java:136 int reduceCommitted( FileserverQuotaRepository.java:137 @Param("reservationId") UUID reservationId, FileserverQuotaRepository.java:138 @Param("amount") long amount, FileserverQuotaRepository.java:139 @Param("now") Instant now); # 회수를 부르는 자리 adapter/outbound/persistence-jpa · postgresqlIntegrationTest · PostgreSqlFileserverReclamationIntegrationTest.java:341 quotaReclaim.reclaim(scope, 800); adapter/outbound/persistence-jpa · postgresqlIntegrationTest · PostgreSqlFileserverReclamationIntegrationTest.java:361 quotaReclaim.reclaim(scope, 5_000); application-core · main · DefaultCleanupService.java:185 quotaGateway.reclaim(QuotaScope.ofNamespace(record.namespace().value()), size); # 같은 게이트웨이를 그대로 부른 결과 (리포지터리만 프록시) probe:22 static QuotaReservationEntity committedRow(long bytes) { probe:42 ledger.add(committedRow(1)); probe:51 case "findCommittedWithBytes" -> { probe:54 return ledger.subList(0, Math.min(limit, ledger.size())); probe:56 case "reduceCommitted" -> { probe:76 new JpaQuotaReclaimGateway(repo, Clock.fixed(Instant.EPOCH, ZoneOffset.UTC)); probe:77 gateway.reclaim(new QuotaScope("NAMESPACE", "probe"), askedBytes); JpaQuotaReclaimGateway.reclaim 을 그대로 부른 결과 원장에 1바이트 확정 행 64 개 · 64 바이트 회수 요청 → 페이지 질의 1 회 · 차감 64 회 · 회수 못한 바이트 0 원장에 1바이트 확정 행 65 개 · 65 바이트 회수 요청 → 페이지 질의 1 회 · 차감 64 회 · 회수 못한 바이트 1 원장에 1바이트 확정 행 200 개 · 200 바이트 회수 요청 → 페이지 질의 1 회 · 차감 64 회 · 회수 못한 바이트 136 # 그 경계를 짚는 시험이 있는가 시험에서 64 를 쓰는 줄 : 0 개 시험에서 65 를 쓰는 줄 : 0 개 시험에서 RECLAIM_PAGE 를 쓰는 줄 : 0 개 회수를 다루는 시험 파일 : src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaQuotaReclaimGateway.java src/application-core/src/main/java/dev/caskeleton/application/fileserver/cleanup/QuotaReclaimGateway.java src/application-core/src/test/java/dev/caskeleton/application/fileserver/testkit/RecordingQuotaReclaimGateway.java