The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
9.3 KiB
Plaintext
130 lines
9.3 KiB
Plaintext
# 회수 게이트웨이 전문 (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
|