Files
document-haness/docs/clean-architecture-backend-template/final/evidence/raw/294-bounded-purge-never-called.txt
T
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

119 lines
6.4 KiB
Plaintext

# revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
## A. the port declares the bounded overload and says why it exists
/**
* Deletes at most {@code limit} published rows older than the cutoff.
*
* <p>The unbounded version deletes everything before the cutoff in one statement. On a table that
* has been accumulating published rows since the last sweep that is a single long transaction
* holding locks and generating WAL in proportion to the backlog, which shows up as the relay and
* the business writes stalling behind retention. The cleanup jobs describe themselves as bounded
* by batch size; this is the parameter that makes that true.
*
* @return how many rows were deleted; fewer than {@code limit} means the sweep is finished
*/
int purgePublishedBefore(Instant publishedBefore, int limit);
## B. both JDBC repositories implement it
141: public int purgeProcessedBefore(Instant processedBefore, int limit) {
486: public int purgePublishedBefore(Instant publishedBefore, int limit) {
--- and the inbox implementation really is bounded
connection.prepareStatement(
"""
WITH expired AS (
SELECT message_id, consumer_id
FROM messaging_inbox
WHERE processed_at < ?
ORDER BY processed_at
LIMIT ?
FOR UPDATE SKIP LOCKED
)
DELETE FROM messaging_inbox i
USING expired e
WHERE i.message_id = e.message_id AND i.consumer_id = e.consumer_id
""")) {
## C. every appearance of the two-argument form in the repository
src/messaging/messaging-inbox-jdbc-postgresql/src/main/java/dev/caskeleton/messaging/inbox/JdbcInboxRepository.java:141: public int purgeProcessedBefore(Instant processedBefore, int limit) {
src/messaging/messaging-inbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/inbox/IdempotentConsumerTest.java:106: public int purgeProcessedBefore(Instant processedBefore, int limit) {
src/messaging/messaging-inbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/inbox/InboxOperationsTest.java:46: public int purgeProcessedBefore(Instant processedBefore, int limit) {
src/messaging/messaging-outbox-jdbc-postgresql/src/main/java/dev/caskeleton/messaging/outbox/JdbcOutboxRepository.java:486: public int purgePublishedBefore(Instant publishedBefore, int limit) {
src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/OutboxOperationsTest.java:125: public int purgePublishedBefore(Instant publishedBefore, int limit) {
src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/OutboxRelayTest.java:404: public int purgePublishedBefore(Instant publishedBefore, int limit) {
src/messaging/messaging-reliability-api/src/main/java/dev/caskeleton/messaging/reliability/InboxRepository.java:52: int purgeProcessedBefore(Instant processedBefore, int limit);
src/messaging/messaging-reliability-api/src/main/java/dev/caskeleton/messaging/reliability/OutboxRepository.java:151: int purgePublishedBefore(Instant publishedBefore, int limit);
src/messaging/messaging-spring-boot-starter/src/test/java/dev/caskeleton/messaging/autoconfigure/MessagingOutboxRelayLifecycleTest.java:199: public int purgePublishedBefore(Instant publishedBefore, int limit) {
(every line above is a declaration, a production implementation, or a test fake override.
2 port declarations + 2 production implementations + 5 test fake overrides = 9.
None of them is a call site.)
## D. what the two cleanup jobs actually call
54- int removed = 0;
55- for (int batch = 0; batch < maxBatches; batch++) {
56: int deleted = inbox.purgeProcessedBefore(cutoff);
57- removed += deleted;
58- // A short batch means the backlog is drained; continuing would just re-scan an empty range.
48- int removed = 0;
49- for (int batch = 0; batch < maxBatches; batch++) {
50: int deleted = outbox.purgePublishedBefore(cutoff);
51- removed += deleted;
52- if (deleted == 0) {
## E. InboxCleanupJob declares a batch size and never uses it
src/messaging/messaging-inbox-jdbc-postgresql/src/main/java/dev/caskeleton/messaging/inbox/InboxCleanupJob.java:21: public static final int DEFAULT_BATCH_SIZE = 1_000;
(one line = the declaration only)
## F. what the unbounded implementation issues
@Override
public int purgeProcessedBefore(Instant processedBefore) {
Objects.requireNonNull(processedBefore, "processedBefore must not be null");
try (Connection connection = dataSource.getConnection();
PreparedStatement statement =
connection.prepareStatement("DELETE FROM messaging_inbox WHERE processed_at < ?")) {
statement.setTimestamp(1, Timestamp.from(processedBefore));
return statement.executeUpdate();
} catch (SQLException exception) {
throw new MessagingConfigurationException(
"INBOX_PURGE_FAILED", "could not purge the inbox", exception);
}
}
## G. the test named for the property, and the fake it runs against
@Override
public int purgeProcessedBefore(Instant processedBefore, int limit) {
return Math.min(purgeProcessedBefore(processedBefore), limit);
}
@Override
public int purgeProcessedBefore(Instant processedBefore) {
cutoffs.add(processedBefore);
return pass < deletions.size() ? deletions.get(pass++) : 0;
}
}
@Test
void cleanupDeletesInBoundedBatches() {
InMemoryInbox inbox = new InMemoryInbox(List.of(1000, 500));
int removed =
new InboxCleanupJob(inbox, policy(Duration.ofDays(7), Duration.ofDays(1)), 10).runOnce(NOW);
assertThat(removed).isEqualTo(1500);
assertThat(inbox.cutoffs).hasSize(3);
}
## H. the container lane calls the unbounded form too
@Test
void retentionRemovesOldRows() {
MessageId purged = MessageId.newId();
inTransaction(() -> repository.reserve(purged, "order-projection", NOW));
assertThat(repository.purgeProcessedBefore(NOW.plusSeconds(1))).isEqualTo(1);
}
## verdict
Both ports declare a bounded purge, both JDBC classes implement it with LIMIT,
and no code calls it. Both cleanup jobs call the unbounded overload, which the
port's own javadoc describes as the single long transaction the bounded one exists
to avoid. The test that asserts bounded batching runs against a scripted fake whose
unbounded method returns pre-set counts, so the bound is never exercised.