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>
195 lines
12 KiB
Plaintext
195 lines
12 KiB
Plaintext
Command: printf '%s\n' '--- canonical boundary claim ---'; sed -n '18,118p' src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java; printf '%s\n' '--- transaction guide ---'; cat docs/jpa/transaction-guide.md; printf '%s\n' '--- FullTransactionRetryCoordinator production refs ---'; git grep -n -w 'FullTransactionRetryCoordinator' -- 'src/**/src/main/java/**/*.java'; printf '%s\n' '--- coordinator execute call candidates ---'; git grep -n -E '\.(execute)\(' -- 'src/**/src/main/java/**/*.java' | grep -E 'retry|transaction|coordinator' || true
|
|
Working directory: /shared/codebase/clean-architecture-backend-template
|
|
Executed at: 2026-08-29T08:01:16Z
|
|
Source revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
|
|
Observation boundary: Compares current source/document claims for the application PolicyTransactionPort stack versus the public JPA executor/coordinator stack, including exact production execute references. It identifies coexistence/drift but does not by itself prove external consumers are absent.
|
|
--- stdout/stderr ---
|
|
--- canonical boundary claim ---
|
|
import org.springframework.transaction.PlatformTransactionManager;
|
|
|
|
/**
|
|
* Composes the transaction half of the platform (design §9.3, §17, §19).
|
|
*
|
|
* <p>Separate from {@link JpaPlatformAutoConfiguration} because it backs off separately. An
|
|
* application that already installs its own {@link PlatformTransactionManager} — a JTA setup, a
|
|
* chained manager across two data sources — must keep it, and the rest of the platform is still
|
|
* useful to it. Folding the two together would make "I have my own transaction manager" mean "I get
|
|
* none of the platform".
|
|
*
|
|
* <p>Plain construction rather than Spring auto-configuration: this repository's composition root
|
|
* owns wiring, and an adapter leaf that auto-configured a transaction manager would silently
|
|
* replace one the application had deliberately chosen.
|
|
*
|
|
* <p>There is no declarative retry annotation any more. {@code @RetryableJpaTransaction} lived in
|
|
* the persistence leaf and documented itself as something an application service would put on its
|
|
* own methods — which application-core cannot do without importing an outbound adapter and
|
|
* inverting the dependency this architecture is built on. The canonical boundary is {@code
|
|
* PolicyTransactionPort.inTransaction(TransactionRequest, Supplier)}; the retry coordinator below
|
|
* is what implements it, not a second way to ask for the same thing.
|
|
*/
|
|
public final class JpaTransactionAutoConfiguration {
|
|
|
|
/** How long a logical operation may keep retrying before the budget is spent regardless. */
|
|
public static final Duration DEFAULT_MAX_RETRY_ELAPSED = Duration.ofSeconds(30);
|
|
|
|
private final Clock clock;
|
|
|
|
public JpaTransactionAutoConfiguration(Clock clock) {
|
|
this.clock = Objects.requireNonNull(clock, "clock");
|
|
}
|
|
|
|
/**
|
|
* The commit-phase classifier the evidence-aware transaction manager is built with.
|
|
*
|
|
* <p>The manager itself is constructed inside the persistence leaf, through {@code
|
|
* EvidenceAwareJpaTransactionManager.standard(entityManagerFactory, clock)}. The composition root
|
|
* deliberately does not do it here: that would require {@code jakarta.persistence} and {@code
|
|
* org.hibernate} on the composition root's compile classpath, and this repository keeps ORM types
|
|
* inside the persistence leaf. What the root owns is the decision — whether to install the
|
|
* platform's manager at all — and the classifier it uses.
|
|
*/
|
|
public CommitFailureClassifier commitFailureClassifier() {
|
|
return CommitFailureClassifier.standard(clock);
|
|
}
|
|
|
|
/** The programmatic transaction boundary, with no vendor classification. */
|
|
public SpringJpaTransactionExecutor transactionExecutor(
|
|
PlatformTransactionManager transactionManager) {
|
|
return new SpringJpaTransactionExecutor(transactionManager, clock);
|
|
}
|
|
|
|
/**
|
|
* The programmatic transaction boundary that classifies its attempts with a vendor's SQLSTATEs.
|
|
*
|
|
* <p>The overload above builds the catalog-free chain, whose vendor stage returns the failure
|
|
* unchanged. That is the honest answer when no vendor translator exists, and it is the wrong one
|
|
* whenever a vendor is composed: a 40001 or 40P01 stays a raw {@code DataAccessException}, never
|
|
* becomes a {@code JpaPersistenceException}, and so never reaches the retry coordinator's catch.
|
|
* Contention then goes unretried in exactly the deployments that have a database.
|
|
*
|
|
* @param vendorFailures the composed vendor's SQLSTATE translator
|
|
*/
|
|
public SpringJpaTransactionExecutor transactionExecutor(
|
|
PlatformTransactionManager transactionManager, VendorFailureTranslator vendorFailures) {
|
|
return new SpringJpaTransactionExecutor(
|
|
transactionManager,
|
|
clock,
|
|
new PersistenceFailureTranslatorChain(
|
|
OptimisticConflictTranslator.withoutCatalog(), vendorFailures));
|
|
}
|
|
|
|
/**
|
|
* The retry coordinator for one retry profile.
|
|
*
|
|
* <p>The policy and the coordinator's budget are built from the <em>same</em> profile on purpose.
|
|
* Configuring them independently is how a deployment ends up with a policy that says "retry" and
|
|
* a budget that permits one attempt — which looks like retry being broken rather than like a
|
|
* misconfiguration.
|
|
*/
|
|
public FullTransactionRetryCoordinator retryCoordinator(
|
|
SpringJpaTransactionExecutor executor,
|
|
RetryProfile retryProfile,
|
|
RetryEventListener listener) {
|
|
return retryCoordinator(executor, DefaultJpaRetryPolicy.forProfile(retryProfile), listener);
|
|
}
|
|
|
|
/** The retry coordinator for an application-supplied policy. */
|
|
public FullTransactionRetryCoordinator retryCoordinator(
|
|
SpringJpaTransactionExecutor executor, JpaRetryPolicy policy, RetryEventListener listener) {
|
|
return new FullTransactionRetryCoordinator(
|
|
executor, policy, retrySleeper(), clock, DEFAULT_MAX_RETRY_ELAPSED, listener);
|
|
}
|
|
|
|
/** The production sleeper; tests substitute a recording one. */
|
|
public RetrySleeper retrySleeper() {
|
|
return new ThreadRetrySleeper();
|
|
}
|
|
}
|
|
--- transaction guide ---
|
|
# Transaction Guide
|
|
|
|
Design §15-§20. What owns a transaction, what may be retried, and what must never be.
|
|
|
|
## The application service owns the boundary
|
|
|
|
Repository adapters do not open transactions. The use case does, through `TransactionPort` or
|
|
`JpaTransactionExecutor`, because the unit of work is a business decision and only the use case
|
|
knows where it starts and ends.
|
|
|
|
Open Session In View is off in every runtime profile. It is on by default in Spring Boot, which is
|
|
why `JpaDangerousConfigurationGuard` fails startup rather than trusting configuration review.
|
|
|
|
## Profiles
|
|
|
|
A `TransactionProfile` fixes propagation, isolation, timeout, read-only, and the retry budget. A
|
|
write profile must carry a positive finite timeout — the type refuses to represent one without —
|
|
because an unbounded write transaction holds a connection, its locks, and its row versions for as
|
|
long as one stuck statement takes.
|
|
|
|
`REQUIRES_NEW` is opt-in. It acquires a second physical connection while pinning the first, so a
|
|
profile using it must be paired with the pool-pressure evidence in design §38:
|
|
|
|
```text
|
|
maximumPoolSize >= (concurrent_threads x (1 + max_requires_new_depth)) + 1
|
|
```
|
|
|
|
## Retry is per use case, never per statement
|
|
|
|
`FullTransactionRetryCoordinator` re-enters the executor, which produces a new transaction and a new
|
|
Persistence Context for every attempt. That granularity is the whole point: an optimistic conflict
|
|
means the state the attempt computed against is no longer the committed state, so re-issuing the
|
|
same statement would compute the same wrong answer. The domain rules have to run again against
|
|
reloaded data.
|
|
|
|
Retryable: serialization failure (`40001`), deadlock (`40P01`), optimistic conflict.
|
|
Not retryable: constraint violations, schema mismatch, query timeout, and anything unclassified.
|
|
|
|
Two additional refusals, independent of budget:
|
|
|
|
- An attempt that declared an irreversible external effect through `IrreversibleSideEffectContext`.
|
|
Rollback reverses database work only; an email or a card charge has already changed the world.
|
|
- Anything completion-unknown.
|
|
|
|
## Completion unknown
|
|
|
|
`TransactionCompletionUnknownException` is never retried, and the type system enforces it twice:
|
|
`JpaFailureContext` refuses to represent a retryable completion-unknown failure, and the exception
|
|
rebuilds its context through the safe factory whatever it is handed.
|
|
|
|
`EvidenceAwareJpaTransactionManager` marks the phase `COMMITTING` immediately before delegating to
|
|
the provider commit and never after. If the network, the JVM, or the server dies inside that call,
|
|
the last thing written is "we asked, we do not know" — which is exactly the state that must not be
|
|
mistaken for a rollback.
|
|
|
|
Recovery is reconciliation, not retry:
|
|
|
|
```text
|
|
record the transaction key -> check the idempotency record
|
|
-> check the business row
|
|
-> check the outbox
|
|
-> still undetermined? reconciliation queue
|
|
```
|
|
|
|
`CompletionUnknownRecorder` writes that record through a channel outside the unknown transaction.
|
|
Writing it through the same connection would make the audit trail share the failure it documents.
|
|
--- FullTransactionRetryCoordinator production refs ---
|
|
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java:30:public final class FullTransactionRetryCoordinator {
|
|
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java:50: public FullTransactionRetryCoordinator(
|
|
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java:21: * <p>This class does not retry. Retry lives in {@link FullTransactionRetryCoordinator}, which calls
|
|
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java:7:import dev.caskeleton.adapter.outbound.persistence.transaction.FullTransactionRetryCoordinator;
|
|
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java:170: public FullTransactionRetryCoordinator jpaRetryCoordinator(
|
|
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:9:import dev.caskeleton.adapter.outbound.persistence.transaction.FullTransactionRetryCoordinator;
|
|
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:99: public FullTransactionRetryCoordinator retryCoordinator(
|
|
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:107: public FullTransactionRetryCoordinator retryCoordinator(
|
|
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:109: return new FullTransactionRetryCoordinator(
|
|
--- coordinator execute call candidates ---
|
|
src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/DefaultGenericHttpGateway.java:118: HttpCallResult<T> result = coordinator.execute(call);
|
|
src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/DefaultReactiveHttpGateway.java:149: coordinator.execute(
|
|
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java:101: transactionExecutor.execute(operation, profile, work, attemptNumber, transactionKey);
|
|
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java:83: return template.execute(status -> work.get());
|
|
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java:166: return policyExecutor.execute(request, action);
|
|
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java:197: return template.execute(status -> action.get());
|
|
src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringMongoTransactionExecutor.java:57: return coordinator.execute(profile, work);
|
|
|
|
Exit code: 0
|