# 회수 게이트웨이 전문 (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  * <p>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  * <p>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  * <p>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<QuotaReservationEntity> 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    * <p>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
