# 회수 메서드가 자기 javadoc 에 적어 둔 것
108:   * because {@code recordAttempt} matches on it too.
109:   *
110:   * <p>The item comes back as FAILED rather than PENDING, and its attempt counter advances. An item
111:   * whose worker dies every time is then bounded by the same retry budget as one that fails
112:   * outright, instead of being reclaimed forever.
113:   *
114:   * @return 1 when this caller reclaimed it, 0 when someone else already had
115:   */
116:  @Modifying(clearAutomatically = true, flushAutomatically = true)
117:  @Query(
118:      """
119:      update CleanupItemEntity c
120:         set c.status = 'FAILED',
121:             c.attempt = c.attempt + 1,
122:             c.nextAttemptAt = :now,
123:             c.lastErrorCode = 'CLAIM_LEASE_EXPIRED',
124:             c.claimOwner = null,
125:             c.claimToken = null,
126:             c.leaseUntil = null,
127:             c.updatedAt = :now
128:       where c.cleanupId = :cleanupId
129:         and c.claimToken = :token
130:         and c.status = 'IN_PROGRESS'
131:      """)
#   저장소 전체에서 attempt 를 비교하는 줄: 2
#   저장소가 attempt 에 하는 일 전부
57:   * Records the outcome of an attempt, for the worker that still holds the claim.
68:             c.attempt = c.attempt + 1,
110:   * <p>The item comes back as FAILED rather than PENDING, and its attempt counter advances. An item
121:             c.attempt = c.attempt + 1,

# 예산을 끊는 곳은 코드베이스에 한 군데다
JpaCleanupQueue.java:45:  private static final String STATUS_ABANDONED = "ABANDONED";
JpaCleanupQueue.java:150:        exhausted ? STATUS_ABANDONED : STATUS_FAILED,
CleanupType.java:16:  ABANDONED_LEASE,
145:  public void markFailed(CleanupItem item, String reasonCode, Instant nextAttemptAt) {
146-    Instant now = clock.instant();
147-    boolean exhausted = item.attempt() + 1 >= MAXIMUM_ATTEMPTS;
148-    settle(
149-        item.cleanupId(),
150-        exhausted ? STATUS_ABANDONED : STATUS_FAILED,
151-        nextAttemptAt,
152-        reasonCode,
153-        now);
154-  }

# 회수 대상을 고르는 조회 — 만료된 것만, 시도 한계 없음
93:  @Query(
94:      """
95:      select c from CleanupItemEntity c
96:       where c.status = 'IN_PROGRESS'
97:         and c.leaseUntil is not null
98:         and c.leaseUntil < :now
99:       order by c.leaseUntil
100:      """)
101:  List<CleanupItemEntity> findExpiredClaims(@Param("now") Instant now, Limit limit);
# 다시 청구되는 조건 — 상태만이 아니라 다음 시도 시각도 본다
16:  @Query(
17:      """
18:      select c from CleanupItemEntity c
19:       where c.status in ('PENDING', 'FAILED')
20:         and c.nextAttemptAt <= :now
21:       order by c.nextAttemptAt asc
22:      """)
23:  List<CleanupItemEntity> findDue(@Param("now") Instant now, Limit limit);

# 회수 래퍼가 next_attempt_at 에 넣는 시각
122:  public int reclaimExpiredClaims(Instant now, int limit) {
123-    if (limit < 1) {
124-      throw new IllegalArgumentException("limit must be positive");
125-    }
126-    int reclaimed = 0;
127-    for (CleanupItemEntity abandoned : items.findExpiredClaims(now, Limit.of(limit))) {
128-      // Matched on the token that was read, so the reaper that loses the race changes nothing —
129-      // and the worker that eventually wakes up finds its own token gone and settles nothing.
130-      reclaimed +=
131-          items.reclaimExpiredClaim(
132-              abandoned.getCleanupId(), abandoned.getClaimToken(), clock.instant());
133-      heldClaims.remove(abandoned.getCleanupId());
134-    }
135-    return reclaimed;
136-  }
#   배치는 시각을 한 번 잡아 회수와 청구에 같이 쓴다
69:    Instant now = clock.instant();
70-    // Abandoned claims come back first. A claim takes an item out of the due set, so an item whose
71-    // worker performed the physical delete and then died is invisible to claimDue — nothing ever
72-    // reclaimed it, and the file's quota and lifecycle stayed unsettled for good. Reclaiming before
73-    // claiming means the recovered items are eligible in this same pass.
74-    transactions.inWrite(() -> queue.reclaimExpiredClaims(now, maxItems));
75-    List<CleanupItem> due = transactions.inWrite(() -> queue.claimDue(now, maxItems));
76-
77-    int deleted = 0;
78-    int skippedActiveLease = 0;
79-    int skippedStateChanged = 0;
71:  private static final java.time.Duration LEASE = java.time.Duration.ofMinutes(10);

# 정상 실패 경로는 실제로 불린다
87:        markFailed(item, "BATCH_BYTE_BUDGET_EXHAUSTED", now);
123:      markFailed(item, "CLEANUP_ATTEMPT_FAILED", now);
140:      markFailed(item, "ACTIVE_WRITER_LEASE", now);
209:          queue.markFailed(item, reasonCode, now.plus(retryBackoff));
121-      return Outcome.of(OutcomeKind.SKIPPED_STATE_CHANGED, 0);
122-    } catch (RuntimeException failure) {
123:      markFailed(item, "CLEANUP_ATTEMPT_FAILED", now);
124-      return Outcome.of(OutcomeKind.FAILED, 0);
125-    }
126-  }

# 도달성
51:    return registrar -> registrar.addFixedDelayTask(worker::runBatch, interval);
854:    enabled: ${APP_FILESERVER_PLATFORM_ENABLED:false}
914:      enabled: ${APP_FILESERVER_PLATFORM_CLEANUP_ENABLED:false}
