Files
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
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>
2026-09-04 22:51:59 +09:00

550 lines
19 KiB
Java

package dev.caskeleton.adapter.outbound.persistence.readiness;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaCleanupQueue;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaContentReferenceLedger;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileMetadataStore;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileQuotaService;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaCommitGateway;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaReclaimGateway;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaRecoveryQueue;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaStagingUploadLocator;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaUploadSessionStore;
import dev.caskeleton.application.fileserver.api.ContentKey;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.FileState;
import dev.caskeleton.application.fileserver.api.StorageNamespace;
import dev.caskeleton.application.fileserver.api.UploadId;
import dev.caskeleton.application.fileserver.api.metadata.FileRecord;
import dev.caskeleton.application.fileserver.api.metadata.FileRecordDraft;
import dev.caskeleton.application.fileserver.api.metadata.FileRecordMutation;
import dev.caskeleton.application.fileserver.api.metadata.QuotaScope;
import dev.caskeleton.application.fileserver.api.metadata.UploadSession;
import dev.caskeleton.application.fileserver.api.metadata.UploadSessionDraft;
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
import dev.caskeleton.application.fileserver.cleanup.CleanupItem;
import dev.caskeleton.application.fileserver.cleanup.CleanupRequest;
import dev.caskeleton.application.fileserver.cleanup.CleanupType;
import dev.caskeleton.application.fileserver.recovery.ReconciliationStatus;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.UUID;
import java.util.function.Supplier;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Real-PostgreSQL proof of the reclamation side of the Fileserver.
*
* <p>The cleanup queue, the recovery queue, and the quota ledger are the components that decide
* when physical bytes may be destroyed and how much space a tenant is charged for. All three are
* conditional-update designs whose correctness lives in SQL, so an in-memory fake would prove
* nothing about them: the partial unique index, the {@code IN ('PENDING','FAILED')} claim guard,
* and the {@code committedBytes >= :amount} floor only exist in the database.
*/
class PostgreSqlFileserverReclamationIntegrationTest {
private static PostgreSqlReadinessSupport postgres;
private static FileserverJpaTestContext context;
private static JdbcTemplate jdbc;
private static TransactionTemplate transactions;
private static JpaFileMetadataStore files;
private static JpaUploadSessionStore uploads;
private static JpaFileQuotaService quota;
private static JpaCleanupQueue cleanupQueue;
private static JpaRecoveryQueue recoveryQueue;
private static JpaQuotaCommitGateway quotaCommit;
private static JpaQuotaReclaimGateway quotaReclaim;
private static JpaContentReferenceLedger ledger;
private static JpaStagingUploadLocator stagingLocator;
@BeforeAll
static void startAndMigratePostgreSql() {
PostgreSqlReadinessSupport.assertDockerAvailable();
postgres = PostgreSqlReadinessSupport.start(8, 2_000);
migrate("classpath:db/migration/postgresql", "flyway_schema_history");
migrateIndependent(
"classpath:db/migration/jpa/core", "flyway_jpa_core_history", "explicit-jpa-core-adoption");
migrateIndependent(
"classpath:db/migration/jpa/fileserver",
"flyway_jpa_fileserver_history",
"explicit-jpa-fileserver-adoption");
jdbc = new JdbcTemplate(postgres.dataSource());
context = new FileserverJpaTestContext(postgres.dataSource(), Clock.systemUTC());
transactions = context.transactions();
files = context.bean(JpaFileMetadataStore.class);
uploads = context.bean(JpaUploadSessionStore.class);
quota = context.bean(JpaFileQuotaService.class);
cleanupQueue = context.bean(JpaCleanupQueue.class);
recoveryQueue = context.bean(JpaRecoveryQueue.class);
quotaCommit = context.bean(JpaQuotaCommitGateway.class);
quotaReclaim = context.bean(JpaQuotaReclaimGateway.class);
ledger = context.bean(JpaContentReferenceLedger.class);
stagingLocator = context.bean(JpaStagingUploadLocator.class);
}
@AfterAll
static void stopPostgreSql() {
if (context != null) {
context.close();
}
if (postgres != null) {
postgres.close();
}
}
@BeforeEach
void truncate() {
jdbc.execute(
"TRUNCATE fs_recovery_item, fs_cleanup_item, fs_quota_reservation,"
+ " fs_verification_result, fs_upload_session, fs_file CASCADE");
}
@Test
void aStagingCleanupSurvivesTheRoundTripWithItsUploadIdentity() {
FileRecord record = insertRecord();
UploadId uploadId = UploadId.of(UUID.randomUUID());
inTransaction(
() -> {
cleanupQueue.enqueue(
CleanupRequest.forStaging(CleanupType.CANCELLED_STAGING, record.fileId(), uploadId));
return null;
});
List<CleanupItem> claimed = inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10));
assertThat(claimed)
.singleElement()
.satisfies(
item -> {
assertThat(item.request().uploadId()).contains(uploadId);
assertThat(item.request().contentKey()).isEmpty();
assertThat(item.type()).isEqualTo(CleanupType.CANCELLED_STAGING);
});
}
@Test
void aClaimedItemIsNotHandedToASecondWorker() {
FileRecord record = insertRecord();
inTransaction(
() -> {
cleanupQueue.enqueue(
CleanupRequest.forContent(
CleanupType.DELETED_READY_CONTENT, record.fileId(), contentKey()));
return null;
});
List<CleanupItem> first = inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10));
List<CleanupItem> second = inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10));
assertThat(first).hasSize(1);
assertThat(second).isEmpty();
}
@Test
void aFailedItemBecomesDueAgainOnlyAfterItsBackoff() {
FileRecord record = insertRecord();
inTransaction(
() -> {
cleanupQueue.enqueue(
CleanupRequest.forContent(
CleanupType.DELETED_READY_CONTENT, record.fileId(), contentKey()));
return null;
});
CleanupItem item = inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10)).get(0);
Instant retryAt = Instant.now().plus(Duration.ofMinutes(5));
inTransaction(
() -> {
cleanupQueue.markFailed(item, "STORAGE_UNAVAILABLE", retryAt);
return null;
});
assertThat(inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10))).isEmpty();
assertThat(inTransaction(() -> cleanupQueue.claimDue(retryAt.plusSeconds(1), 10))).hasSize(1);
}
@Test
void anItemThatKeepsFailingIsAbandonedRatherThanRetriedForever() {
FileRecord record = insertRecord();
inTransaction(
() -> {
cleanupQueue.enqueue(
CleanupRequest.forContent(
CleanupType.DELETED_READY_CONTENT, record.fileId(), contentKey()));
return null;
});
Instant due = Instant.now();
for (int attempt = 0; attempt < JpaCleanupQueue.MAXIMUM_ATTEMPTS; attempt++) {
List<CleanupItem> claimed = inTransaction(() -> cleanupQueue.claimDue(due, 10));
if (claimed.isEmpty()) {
break;
}
inTransaction(
() -> {
cleanupQueue.markFailed(claimed.get(0), "STILL_FAILING", due);
return null;
});
}
assertThat(inTransaction(() -> cleanupQueue.claimDue(due.plusSeconds(1), 10))).isEmpty();
assertThat(statusCounts("fs_cleanup_item", "ABANDONED")).isEqualTo(1);
}
@Test
void aDoneItemIsNeverClaimedAgain() {
FileRecord record = insertRecord();
inTransaction(
() -> {
cleanupQueue.enqueue(
CleanupRequest.forContent(
CleanupType.DELETED_READY_CONTENT, record.fileId(), contentKey()));
return null;
});
CleanupItem item = inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10)).get(0);
inTransaction(
() -> {
cleanupQueue.markDone(item);
return null;
});
assertThat(inTransaction(() -> cleanupQueue.claimDue(Instant.now().plusSeconds(600), 10)))
.isEmpty();
}
@Test
void thesameFileReportedTwiceHoldsOneOpenRecoveryItem() {
FileRecord record = insertRecord();
inTransaction(
() -> {
recoveryQueue.enqueue(record.fileId(), "READY_DIGEST_MISMATCH");
recoveryQueue.enqueue(record.fileId(), "READY_SIZE_MISMATCH");
return null;
});
assertThat(inTransaction(() -> recoveryQueue.pending(10))).containsExactly(record.fileId());
assertThat(rowCount("fs_recovery_item")).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT reason_code FROM fs_recovery_item", String.class))
.isEqualTo("READY_SIZE_MISMATCH");
}
@Test
void aResolvedRecoveryItemLeavesThePendingListButKeepsItsOutcome() {
FileRecord record = insertRecord();
inTransaction(
() -> {
recoveryQueue.enqueue(record.fileId(), "PUBLISH_EVIDENCE_INCOMPLETE");
return null;
});
inTransaction(
() -> {
recoveryQueue.resolve(record.fileId(), ReconciliationStatus.QUARANTINE_REQUIRED);
return null;
});
assertThat(inTransaction(() -> recoveryQueue.pending(10))).isEmpty();
assertThat(statusCounts("fs_recovery_item", "QUARANTINE_REQUIRED")).isEqualTo(1);
}
@Test
void aResolvedFileCanBeRaisedAgainLater() {
FileRecord record = insertRecord();
inTransaction(
() -> {
recoveryQueue.enqueue(record.fileId(), "FIRST");
recoveryQueue.resolve(record.fileId(), ReconciliationStatus.UNRESOLVED);
recoveryQueue.enqueue(record.fileId(), "SECOND");
return null;
});
assertThat(inTransaction(() -> recoveryQueue.pending(10))).containsExactly(record.fileId());
assertThat(rowCount("fs_recovery_item")).isEqualTo(2);
}
@Test
void committingAnUploadMovesReservedBytesToCommitted() {
FileRecord record = insertRecord();
UploadSession session = insertSession(record.fileId());
QuotaScope scope = QuotaScope.ofNamespace(record.namespace().value());
inTransaction(() -> quota.reserve(scope, 1_000, Duration.ofHours(1)));
inTransaction(
() -> {
quotaCommit.commit(session, 600);
return null;
});
assertThat(inTransaction(() -> quota.reservedBytes(scope))).isZero();
assertThat(inTransaction(() -> quota.committedBytes(scope))).isEqualTo(600);
}
@Test
void committingWithoutALiveReservationStillRecordsTheDurableUsage() {
FileRecord record = insertRecord();
UploadSession session = insertSession(record.fileId());
QuotaScope scope = QuotaScope.ofNamespace(record.namespace().value());
inTransaction(
() -> {
quotaCommit.commit(session, 450);
return null;
});
assertThat(inTransaction(() -> quota.committedBytes(scope))).isEqualTo(450);
}
@Test
void releasingAnUploadGivesTheReservedCapacityBack() {
FileRecord record = insertRecord();
UploadSession session = insertSession(record.fileId());
QuotaScope scope = QuotaScope.ofNamespace(record.namespace().value());
inTransaction(() -> quota.reserve(scope, 2_000, Duration.ofHours(1)));
inTransaction(
() -> {
quotaCommit.release(session);
return null;
});
assertThat(inTransaction(() -> quota.reservedBytes(scope))).isZero();
assertThat(inTransaction(() -> quota.committedBytes(scope))).isZero();
}
@Test
void reclaimingDrawsCommittedBytesDownAcrossRows() {
FileRecord record = insertRecord();
UploadSession session = insertSession(record.fileId());
QuotaScope scope = QuotaScope.ofNamespace(record.namespace().value());
inTransaction(
() -> {
quotaCommit.commit(session, 300);
quotaCommit.commit(session, 700);
return null;
});
inTransaction(
() -> {
quotaReclaim.reclaim(scope, 800);
return null;
});
assertThat(inTransaction(() -> quota.committedBytes(scope))).isEqualTo(200);
}
@Test
void reclaimingMoreThanIsRecordedStopsAtZeroRatherThanGoingNegative() {
FileRecord record = insertRecord();
UploadSession session = insertSession(record.fileId());
QuotaScope scope = QuotaScope.ofNamespace(record.namespace().value());
inTransaction(
() -> {
quotaCommit.commit(session, 100);
return null;
});
inTransaction(
() -> {
quotaReclaim.reclaim(scope, 5_000);
return null;
});
assertThat(inTransaction(() -> quota.committedBytes(scope))).isZero();
}
@Test
void theLedgerReportsAKeyAsReferencedOnlyWhileARecordNamesIt() {
ContentKey key = contentKey();
assertThat(inTransaction(() -> ledger.isReferenced(key))).isFalse();
FileRecord record = insertRecord();
inTransaction(() -> publish(record, key));
assertThat(inTransaction(() -> ledger.isReferenced(key))).isTrue();
}
@Test
void theStagingLocatorReturnsTheNewestSessionForAFile() {
FileRecord record = insertRecord();
UploadSession older = insertSession(record.fileId());
UploadSession newer = insertSession(record.fileId());
Optional<UploadId> located = inTransaction(() -> stagingLocator.locate(record.fileId()));
assertThat(located).isPresent();
assertThat(located.get()).isIn(older.uploadId(), newer.uploadId());
assertThat(inTransaction(() -> stagingLocator.locate(FileId.of(UUID.randomUUID())))).isEmpty();
}
private FileRecord insertRecord() {
return inTransaction(
() ->
files.insert(
new FileRecordDraft(
FileId.of(UUID.randomUUID()),
StorageNamespace.of("tenant-a"),
"report.bin",
Optional.of("application/octet-stream"),
OptionalLong.of(1_000))));
}
private FileRecord publish(FileRecord record, ContentKey key) {
FileRecord uploading =
files.transition(
record.fileId(),
record.version(),
FileState.CREATED,
FileState.UPLOADING,
FileRecordMutation.none());
FileRecord uploaded =
files.transition(
uploading.fileId(),
uploading.version(),
FileState.UPLOADING,
FileState.UPLOADED,
FileRecordMutation.uploaded(10, "b".repeat(64)));
FileRecord verifying =
files.transition(
uploaded.fileId(),
uploaded.version(),
FileState.UPLOADED,
FileState.VERIFYING,
FileRecordMutation.none());
return files.transition(
verifying.fileId(),
verifying.version(),
FileState.VERIFYING,
FileState.READY,
FileRecordMutation.publishAt(
key, 10, "b".repeat(64), "\"" + "b".repeat(64) + "\"", Instant.now()));
}
private UploadSession insertSession(FileId fileId) {
return inTransaction(
() ->
uploads.create(
new UploadSessionDraft(
UploadId.of(UUID.randomUUID()),
fileId,
UploadProtocol.RAW,
OptionalLong.of(1_000),
Instant.now().plus(Duration.ofHours(1)))));
}
private static ContentKey contentKey() {
String flat = UUID.randomUUID().toString().replace("-", "");
return ContentKey.of(flat.substring(0, 2) + '/' + flat.substring(2, 4) + '/' + flat);
}
private static <T> T inTransaction(Supplier<T> action) {
return transactions.execute(status -> action.get());
}
private static int rowCount(String table) {
Integer count = jdbc.queryForObject("SELECT count(*) FROM " + table, Integer.class);
return count == null ? 0 : count;
}
private static int statusCounts(String table, String status) {
Integer count =
jdbc.queryForObject(
"SELECT count(*) FROM " + table + " WHERE status = ?", Integer.class, status);
return count == null ? 0 : count;
}
private static void migrate(String location, String historyTable) {
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
}
private static void migrateIndependent(
String location, String historyTable, String baselineDescription) {
Flyway flyway =
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineVersion("0")
.baselineDescription(baselineDescription)
.baselineOnMigrate(false)
.outOfOrder(false)
.load();
flyway.baseline();
flyway.migrate();
}
@Test
void analysisProbeReclaimStopsAfterSixtyFourCommittedRows() {
FileRecord record = insertRecord();
UploadSession session = insertSession(record.fileId());
QuotaScope scope = QuotaScope.ofNamespace(record.namespace().value());
for (int i = 0; i < 65; i++) {
inTransaction(
() -> {
quotaCommit.commit(session, 1);
return null;
});
}
long before = inTransaction(() -> quota.committedBytes(scope));
inTransaction(
() -> {
quotaReclaim.reclaim(scope, 65);
return null;
});
long after = inTransaction(() -> quota.committedBytes(scope));
System.out.println("fileserverQuotaReclaim.before=" + before);
System.out.println("fileserverQuotaReclaim.requested=65");
System.out.println("fileserverQuotaReclaim.after=" + after);
assertThat(before).isEqualTo(65);
assertThat(after).isEqualTo(1);
}
@Test
void analysisProbeExpiredDirectReservationStillCommits() {
QuotaScope scope = QuotaScope.ofTenant("analysis-expired");
var reservation = inTransaction(() -> quota.reserve(scope, 1000, Duration.ofHours(1)));
jdbc.update(
"update fs_quota_reservation set expires_at = now() - interval '1 minute' where reservation_id = ?",
reservation.reservationId());
inTransaction(
() -> {
quota.commit(reservation, 600);
return null;
});
String status = jdbc.queryForObject(
"select status from fs_quota_reservation where reservation_id = ?",
String.class,
reservation.reservationId());
long committed = inTransaction(() -> quota.committedBytes(scope));
System.out.println("fileserverExpiredQuota.status=" + status);
System.out.println("fileserverExpiredQuota.committedBytes=" + committed);
assertThat(status).isEqualTo("COMMITTED");
assertThat(committed).isEqualTo(600);
}
}